@litfamily/litopencode 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README-Ko-KR.md +11 -11
  3. package/README.md +11 -11
  4. package/dist/activation-routing.js +1 -1
  5. package/dist/auto-handoff.js +34 -1
  6. package/dist/features.js +2 -2
  7. package/dist/skills.js +6 -4
  8. package/docs/assets/readme/badge-version.svg +1 -1
  9. package/docs/reference-Ko-KR.md +1 -1
  10. package/package.json +1 -1
  11. package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
  12. package/skills/lit-docx/SKILL.md +80 -19
  13. package/skills/lit-docx/references/components.md +253 -0
  14. package/skills/lit-docx/references/direction-step.md +234 -0
  15. package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
  16. package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
  17. package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
  18. package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
  19. package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
  20. package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
  21. package/skills/lit-docx/references/examples/07-report-en.md +137 -0
  22. package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
  23. package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
  24. package/skills/lit-docx/references/page-composition.md +306 -0
  25. package/skills/lit-docx/references/tonalities/brief.md +104 -0
  26. package/skills/lit-docx/references/tonalities/journal.md +101 -0
  27. package/skills/lit-docx/references/tonalities/manual.md +111 -0
  28. package/skills/lit-docx/references/tonalities/memo.md +105 -0
  29. package/skills/lit-docx/references/tonalities/proposal.md +101 -0
  30. package/skills/lit-docx/references/tonalities/report.md +126 -0
  31. package/skills/lit-docx/run.py +2 -1
  32. package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
  33. package/skills/lit-docx/scripts/craft_docx.py +204 -1
  34. package/skills/lit-docx/scripts/docx_design.py +2445 -0
  35. package/skills/lit-docx/scripts/docx_layout.py +959 -0
  36. package/skills/lit-docx/scripts/slop_lint.py +23 -6
  37. package/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
  38. package/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
  39. package/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
  40. package/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
  41. package/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
  42. package/skills/lit-docx/templates/tonalities/report.yaml +27 -0
  43. package/skills/lit-pptx/NOTICE.md +1 -1
  44. package/skills/lit-pptx/SKILL.md +108 -19
  45. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
  46. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
  47. package/skills/lit-pptx/references/density-and-fill.md +283 -0
  48. package/skills/lit-pptx/references/direction-step.md +249 -0
  49. package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
  50. package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
  51. package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
  52. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
  53. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
  54. package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
  55. package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
  56. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
  57. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
  58. package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
  59. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
  60. package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
  61. package/skills/lit-pptx/references/layout-families.md +470 -0
  62. package/skills/lit-pptx/references/title-treatments.md +271 -0
  63. package/skills/lit-pptx/references/tonalities/atlas.md +132 -0
  64. package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
  65. package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
  66. package/skills/lit-pptx/references/tonalities/ledger.md +151 -0
  67. package/skills/lit-pptx/references/tonalities/night.md +148 -0
  68. package/skills/lit-pptx/references/tonalities/paper.md +136 -0
  69. package/skills/lit-pptx/references/tonalities/signal.md +148 -0
  70. package/skills/lit-pptx/references/tonalities/studio.md +149 -0
  71. package/skills/lit-pptx/scripts/compile-deck.js +68 -21
  72. package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
  73. package/skills/lit-pptx/scripts/craft_extras.py +37 -8
  74. package/skills/lit-pptx/scripts/deck_output.py +654 -0
  75. package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
  76. package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
  77. package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
  78. package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
  79. package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  80. package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
  81. package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  82. package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
  83. package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
  84. package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
  85. package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
  86. package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
  87. package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
  88. package/skills/lit-pptx/scripts/qa_deck.py +48 -13
  89. package/skills/lit-pptx/scripts/validate_pptx.py +17 -5
  90. package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
  91. package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
  92. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  93. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
  94. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
  95. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
  96. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
  97. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
  98. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
  99. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +105 -0
  100. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +110 -0
  101. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +106 -0
  102. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +107 -0
  103. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +106 -0
  104. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +111 -0
  105. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +107 -0
  106. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +105 -0
  107. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
  108. package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
  109. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
  110. package/skills/lit-typographic-motion/fonts/pins.json +2 -2
  111. package/skills/lit-typographic-motion/gate.mjs +1 -1
  112. package/skills/lit-typographic-motion/references/stage.md +1 -1
  113. package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
  114. package/skills/lit-typographic-motion/references/treatment.md +1 -1
  115. package/skills/lit-typographic-motion/references/type-craft.md +3 -3
  116. package/skills/managed-skill-manifest.json +288 -44
  117. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
  118. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Regular.otf +0 -0
@@ -14,20 +14,23 @@
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, "../..");
25
+ const ASSETS_MEDIA = path.join(TOOLKIT_ROOT, "assets", "media");
24
26
  const ASSETS_AZURE = path.join(TOOLKIT_ROOT, "assets", "azure");
25
27
 
26
28
  /** Resolve a decoration asset path: explicit absolute override (custom-accent
27
- * regenerated gradients), then the enrolled Azure assets. */
29
+ * regenerated gradients), then d.dir "azure" → assets/azure, else assets/media. */
28
30
  function decorAssetPath(d) {
29
31
  if (d.assetPath) return d.assetPath;
30
- return path.join(ASSETS_AZURE, d.asset);
32
+ if (d.dir === "azure") return path.join(ASSETS_AZURE, d.asset);
33
+ return path.join(ASSETS_MEDIA, d.asset);
31
34
  }
32
35
  const hex = (c) => (c || "#000000").replace("#", "");
33
36
 
@@ -54,13 +57,15 @@ function linkedTableCell(text, options) {
54
57
  };
55
58
  }
56
59
 
57
- // ── Text style constants with neutral fallbacks.
60
+ // ── Text style constants (defaults; every template reassigns them in render()).
58
61
  // These are reassigned from the template at the start of render() so the same
59
62
  // adapter serves any enrolled template (e.g. BOILERPLATE-PRETENDARD).
60
63
  let FONT_BOLD = "Pretendard";
61
64
  let FONT_MEDIUM = "Pretendard";
62
65
  let FONT_LIGHT = "Pretendard";
63
66
  let CHAR_SPACING = -0.7;
67
+ // Chart colours and faces for templates without the rich palette; set per render().
68
+ let PLAIN_CHART_STYLE = { series: ["3C3836", "689D6A", "D79921", "B16286"], ink: "3C3836", ink_muted: "6E6256", line: "CBC2B5", titleFont: "Pretendard", bodyFont: "Pretendard" };
64
69
 
65
70
  function expandHome(p) {
66
71
  if (p === "~") return os.homedir();
@@ -76,6 +81,7 @@ function resolveImagePath(src, sourceDir) {
76
81
  else {
77
82
  if (sourceDir) candidates.push(path.resolve(sourceDir, expanded));
78
83
  candidates.push(path.resolve(process.cwd(), expanded));
84
+ candidates.push(path.join(ASSETS_MEDIA, expanded));
79
85
  candidates.push(path.resolve(TOOLKIT_ROOT, expanded));
80
86
  }
81
87
  const found = candidates.find((candidate) => fs.existsSync(candidate));
@@ -119,43 +125,52 @@ function containBox(x, y, w, h, imageSize) {
119
125
  return { x: x + (w - fittedW) / 2, y, w: fittedW, h };
120
126
  }
121
127
 
122
- // Bullet items
123
- let BULLET_FONT = FONT_BOLD;
124
- const BULLET_SIZE = 16;
125
- let BULLET_COLOR = "00AE41"; // brand bullet color (default green; reassigned per-template)
126
- let SECTION_ACCENT = "00AE41"; // brand section-card / accent color (default green)
128
+ // Bullet items. Geometry comes from the template's tokens (bullet.*, secHeader.*, summary.*,
129
+ // toc.*), reassigned in render(); these defaults are the legacy values.
130
+ let BULLET_COLOR = "1D4ED8"; // bullet color (reassigned per template)
131
+ let SECTION_ACCENT = "1D4ED8"; // section-card / accent color (reassigned per template)
127
132
  const BULLET_PREFIX = "\u2022 "; // "• " (bullet + two spaces)
128
- const BULLET_X = 0.59;
129
- const BULLET_W = 8.8;
130
- const BULLET_H = 0.38;
131
- const BULLET_Y_SPACING = 0.42;
132
- const BULLET_LINE_SPACING = 1.25;
133
+ let BULLET_X = LEGACY_TOKENS.bullet.x;
134
+ let BULLET_W = LEGACY_TOKENS.bullet.w;
133
135
 
134
136
  // Section headers
135
137
  let SEC_HEADER_FONT = FONT_BOLD;
136
- const SEC_HEADER_SIZE = 18;
138
+ let SEC_HEADER_SIZE = LEGACY_TOKENS.secHeader.size;
137
139
  const SEC_HEADER_COLOR = "000000";
138
- const SEC_HEADER_X = 0.39;
139
- const SEC_HEADER_W = 9.0;
140
- const SEC_HEADER_H = 0.5;
140
+ let SEC_HEADER_X = LEGACY_TOKENS.secHeader.x;
141
+ let SEC_HEADER_W = LEGACY_TOKENS.secHeader.w;
142
+ let SEC_HEADER_H = LEGACY_TOKENS.secHeader.h;
141
143
 
142
144
  // Summary (narrow width)
143
- const SUMMARY_HEADER_W = 4.3;
144
- const SUMMARY_BULLET_W = 4.8;
145
- const SUMMARY_BULLET_SIZE = 14;
146
- const SUMMARY_BULLET_H = 0.34;
147
- const SUMMARY_BULLET_Y_SPACING = 0.38;
148
- const SUMMARY_BULLET_LINE_SPACING = 1.05;
145
+ let SUMMARY_HEADER_W = LEGACY_TOKENS.summary.headerW;
146
+ let SUMMARY_BULLET_W = LEGACY_TOKENS.summary.bulletW;
147
+ let SUMMARY_BULLET_SIZE = LEGACY_TOKENS.summary.bulletSize;
148
+ let SUMMARY_BULLET_H = LEGACY_TOKENS.summary.bulletH;
149
+ let SUMMARY_BULLET_Y_SPACING = LEGACY_TOKENS.summary.ySpacing;
150
+ let SUMMARY_BULLET_LINE_SPACING = LEGACY_TOKENS.summary.lineSpacing;
149
151
 
150
152
  // TOC items (separate shapes)
151
153
  let TOC_FONT = FONT_BOLD;
152
- const TOC_SIZE = 18;
154
+ let TOC_SIZE = LEGACY_TOKENS.toc.size;
153
155
  const TOC_COLOR = "000000";
154
- const TOC_X = 0.390;
155
- const TOC_W = 7.500;
156
- const TOC_H = 0.550;
157
- const TOC_Y_START = 0.957;
158
- const TOC_Y_SPACING = 0.650;
156
+ let TOC_X = LEGACY_TOKENS.toc.x;
157
+ let TOC_W = LEGACY_TOKENS.toc.w;
158
+ let TOC_H = LEGACY_TOKENS.toc.h;
159
+ let TOC_Y_START = LEGACY_TOKENS.toc.yStart;
160
+ let TOC_Y_SPACING = LEGACY_TOKENS.toc.ySpacing;
161
+
162
+ /** Reassign the token-driven geometry from the template's pack (legacy values when absent). */
163
+ function applyTokens(tokens) {
164
+ const t = tokens || LEGACY_TOKENS;
165
+ ({ x: BULLET_X, w: BULLET_W } = t.bullet);
166
+ ({ size: SEC_HEADER_SIZE, x: SEC_HEADER_X, w: SEC_HEADER_W, h: SEC_HEADER_H } = t.secHeader);
167
+ ({ headerW: SUMMARY_HEADER_W, bulletW: SUMMARY_BULLET_W, bulletSize: SUMMARY_BULLET_SIZE, bulletH: SUMMARY_BULLET_H,
168
+ ySpacing: SUMMARY_BULLET_Y_SPACING, lineSpacing: SUMMARY_BULLET_LINE_SPACING } = t.summary);
169
+ ({ size: TOC_SIZE, x: TOC_X, w: TOC_W, h: TOC_H, yStart: TOC_Y_START, ySpacing: TOC_Y_SPACING } = t.toc);
170
+ ({ pad: CARD_PAD, chip: CARD_CHIP, minH: CARD_MIN_H, gap: CARD_GAP, radius: CARD_RADIUS } = t.card);
171
+ ({ minCardWidth: KPI_MIN_CARD_WIDTH_IN, gap: KPI_GAP } = t.kpi);
172
+ NOTICE = { ...t.notice };
173
+ }
159
174
 
160
175
  function weightedTextLength(text) {
161
176
  let total = 0;
@@ -394,7 +409,7 @@ function estimateDiagnosticCardHeight(card, w) {
394
409
  return Math.max(0.56, Math.min(2.35, padY * 2 + titleH + bodyH));
395
410
  }
396
411
 
397
- function renderCramStressBody(slide, items, pageW, pageH) {
412
+ function renderCramStressBody(slide, items, pageH) {
398
413
  const cards = bodyItemsToDiagnosticCards(items);
399
414
  const columns = 3;
400
415
  const groups = cards.length ? splitCardsByWeight(cards, columns) : splitLinesByWeight(bodyItemsToDiagnosticLines(items), columns).map((group) => [{ title: "", body: group }]);
@@ -540,7 +555,7 @@ function renderCramStressSlide(slide, slideSpec, pageW, pageH, sourceDir) {
540
555
  });
541
556
  }
542
557
  if (regions.body && regions.body.content && regions.body.content.type === "body") {
543
- renderCramStressBody(slide, regions.body.content.items || [], pageW, pageH);
558
+ renderCramStressBody(slide, regions.body.content.items || [], pageH);
544
559
  }
545
560
  if (regions.image && regions.image.content) {
546
561
  renderCramStressImageSheet(slide, regions.image.content, sourceDir, pageW, pageH);
@@ -604,7 +619,7 @@ function buildFontRoles(templateObj) {
604
619
  * Add decoration elements to a pptxgenjs slide.
605
620
  * Note: slide_number is intentionally omitted — the reference template has none.
606
621
  */
607
- function addDecorations(slide, decorations, slideIdx, fontRoles) {
622
+ function addDecorations(slide, decorations, fontRoles) {
608
623
  if (!decorations || !Array.isArray(decorations)) return;
609
624
 
610
625
  for (const d of decorations) {
@@ -982,7 +997,7 @@ function addSummaryShapes(slide, content, region) {
982
997
 
983
998
  /**
984
999
  * Add TOC items as separate text shapes, matching TEMPLATE-PPTX.pptx exactly.
985
- * Each item is: "N. text" in Pretendard 18pt, w=7.5, h=0.55, spaced 0.65" apart.
1000
+ * Each item is: "N. text" in the bold face at 18pt, w=7.5, h=0.55, spaced 0.65" apart.
986
1001
  */
987
1002
  function addTocShapes(slide, items) {
988
1003
  let y = TOC_Y_START;
@@ -1104,24 +1119,11 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1104
1119
  fontSize: opts.fontSize,
1105
1120
  color: opts.color,
1106
1121
  ...(opts.charSpacing != null ? { charSpacing: opts.charSpacing } : {}),
1107
- }), opts);
1122
+ }), name === "title" ? { ...opts, objectName: "title@legacy" } : opts);
1108
1123
  return;
1109
1124
  }
1110
1125
 
1111
1126
  // ── Body content → TOC shapes or individual shapes ──────────────────
1112
- if (content.type === "chart") {
1113
- slide.addChart(content.chartType === "line" ? "line" : "bar", content.series.map((item) => ({
1114
- name: item.name, labels: content.labels, values: item.values,
1115
- })), {
1116
- x: region.x, y: region.y, w: region.w, h: region.h,
1117
- catAxisLabelFontFace: FONT_MEDIUM, valAxisLabelFontFace: FONT_MEDIUM,
1118
- showLegend: content.series.length > 1, showTitle: false,
1119
- showValue: false, showCatName: false,
1120
- showBorder: false, showMarker: content.chartType === "line",
1121
- chartColors: ["1D4ED8", "93C5FD", "0B2E6F", "51607A"],
1122
- });
1123
- return;
1124
- }
1125
1127
  if (content.type === "body") {
1126
1128
  if (isToc) {
1127
1129
  addTocShapes(slide, content.items || []);
@@ -1133,7 +1135,14 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1133
1135
  }
1134
1136
 
1135
1137
  // ── KPI Table ──────────────────────────────────────────────────────
1138
+ if (content.type === "kpi-table" && content.chart) {
1139
+ renderChart(slide, content, region, PLAIN_CHART_STYLE, regionBottom);
1140
+ return;
1141
+ }
1136
1142
  if (content.type === "kpi-table") {
1143
+ const numeric = numericColumns(content.headers || [], content.rows || []);
1144
+ const metrics = tableMetrics((content.rows || []).length + 1, region.y || 0, regionBottom,
1145
+ { rowH: 0.35, maxRowH: 0.55, headerSize: 14, dataSize: 14 });
1137
1146
  const headerRole = fontRoles[region.font_role_header] || fontRoles.table_header;
1138
1147
  const dataRole = fontRoles[region.font_role_data] || fontRoles.table_data;
1139
1148
 
@@ -1148,7 +1157,7 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1148
1157
  fontSize: headerRole ? headerRole.size : 14,
1149
1158
  fontFace: headerRole ? headerRole.font : FONT_BOLD,
1150
1159
  color: headerRole ? headerRole.color : "000000",
1151
- align: headerRole ? headerRole.align : "left",
1160
+ align: numeric[content.headers.indexOf(h)] ? "right" : (headerRole ? headerRole.align : "left"),
1152
1161
  valign: "middle",
1153
1162
  ...(headerRole && headerRole.charSpacing != null
1154
1163
  ? { charSpacing: headerRole.charSpacing }
@@ -1164,11 +1173,11 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1164
1173
  if (content.rows) {
1165
1174
  for (const row of content.rows) {
1166
1175
  tableData.push(
1167
- row.map((cell) => linkedTableCell(cell, {
1176
+ row.map((cell, ci) => linkedTableCell(cell, {
1168
1177
  fontSize: dataRole ? dataRole.size : 14,
1169
1178
  fontFace: dataRole ? dataRole.font : FONT_MEDIUM,
1170
1179
  color: dataRole ? dataRole.color : "000000",
1171
- align: dataRole ? dataRole.align : "left",
1180
+ align: numeric[ci] ? "right" : (dataRole ? dataRole.align : "left"),
1172
1181
  valign: "middle",
1173
1182
  ...(dataRole && dataRole.charSpacing != null
1174
1183
  ? { charSpacing: dataRole.charSpacing }
@@ -1187,9 +1196,9 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1187
1196
  y: tblY,
1188
1197
  w: tblW,
1189
1198
  border: { pt: 1.5, color: "000000" },
1190
- colW: Array(numCols).fill(tblW / numCols),
1199
+ colW: numCols > 0 && content.headers ? proportionalWidths(content.headers, content.rows || [], tblW) : Array(numCols).fill(tblW / numCols),
1191
1200
  margin: [3.6, 7.2, 3.6, 7.2],
1192
- rowH: 0.35,
1201
+ rowH: metrics.rowH,
1193
1202
  });
1194
1203
  return;
1195
1204
  }
@@ -1310,7 +1319,7 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1310
1319
  }
1311
1320
  }
1312
1321
 
1313
- // ── AZURE rich rendering (isolated; only used when template.render_style==="azure") ──
1322
+ // ── Rich card, KPI and table rendering (templates with the rich-blocks capability) ──
1314
1323
 
1315
1324
  /** Split a title string into runs, emphasizing **word** in the primary color. */
1316
1325
  function azureTitleRuns(text, P) {
@@ -1328,6 +1337,7 @@ function cleanMd(s) {
1328
1337
  .replace(/^#{1,6}\s+/, "")
1329
1338
  .replace(/^\s*[*-]\s+/, "")
1330
1339
  .replace(/\*\*(.+?)\*\*/g, "$1")
1340
+ .replace(/(?<![\w*])\*([^*\s](?:[^*]*[^*\s])?)\*(?![\w*])/gu, "$1")
1331
1341
  .replace(/`/g, "")
1332
1342
  .trim();
1333
1343
  }
@@ -1341,12 +1351,15 @@ function mdRuns(text, runOpts = {}) {
1341
1351
  .replace(/^#{1,6}\s+/, "")
1342
1352
  .replace(/^\s*[*-]\s+/, "")
1343
1353
  .replace(/`/g, "");
1344
- const parts = clean.split(/(\*\*[^*]+\*\*)/g).filter((p) => p !== "");
1354
+ // **x** is bold; *x* (a journal or book name) is italic.
1355
+ const parts = clean.split(/(\*\*[^*]+\*\*|(?<![\w*])\*[^*\s](?:[^*]*[^*\s])?\*(?![\w*]))/gu).filter((p) => p !== "");
1345
1356
  if (parts.length === 0) return [{ text: "", options: { ...runOpts } }];
1346
1357
  return parts.map((p) =>
1347
1358
  p.startsWith("**") && p.endsWith("**")
1348
1359
  ? { text: p.slice(2, -2), options: { ...runOpts, bold: true } }
1349
- : { text: p, options: { ...runOpts } }
1360
+ : p.length > 2 && p.startsWith("*") && p.endsWith("*")
1361
+ ? { text: p.slice(1, -1), options: { ...runOpts, italic: true } }
1362
+ : { text: p, options: { ...runOpts } }
1350
1363
  );
1351
1364
  }
1352
1365
 
@@ -1381,15 +1394,63 @@ function bodyToCards(items) {
1381
1394
  return cards.slice(0, 4);
1382
1395
  }
1383
1396
 
1397
+ // Card geometry shared by drawing and sizing, so a card is exactly as tall as its text
1398
+ // (template tokens card.*; reassigned in render()).
1399
+ let CARD_PAD = LEGACY_TOKENS.card.pad;
1400
+ let CARD_CHIP = LEGACY_TOKENS.card.chip;
1401
+ let CARD_MIN_H = LEGACY_TOKENS.card.minH;
1402
+ let CARD_GAP = LEGACY_TOKENS.card.gap;
1403
+ let CARD_RADIUS = LEGACY_TOKENS.card.radius;
1404
+
1405
+ // Primary reading text stays at or above the 12 pt floor of the design laws.
1406
+ function cardBodySize(card) {
1407
+ return card.lines && card.lines.length > 4 ? 12 : 13;
1408
+ }
1409
+
1410
+ // Card text height: the conservative wrap count, but line boxes at their real
1411
+ // leading (estimateTextHeight pads every paragraph, which left short cards hollow).
1412
+ function cardTextHeight(text, innerW, fontSize, lineSpacing) {
1413
+ return (estimateWrappedLines(text, innerW, fontSize) * fontSize * lineSpacing * 1.2) / 72;
1414
+ }
1415
+
1416
+ function cardHeadingHeight(card, innerW) {
1417
+ return card.heading ? cardTextHeight(card.heading, innerW, 15, 1.05) + 0.06 : 0;
1418
+ }
1419
+
1420
+ function cardBodyHeight(card, innerW) {
1421
+ if (card.bodyRuns && card.bodyRuns.length) return card.bodyHeight || 0;
1422
+ const fs = cardBodySize(card);
1423
+ return (card.lines || []).reduce((sum, line) => sum + cardTextHeight(line, innerW, fs, 1.3), 0.1);
1424
+ }
1425
+
1426
+ /** Height a card needs for its chip, heading and body at the width it is drawn. */
1427
+ function azureCardHeight(card, w) {
1428
+ const innerW = w - 2 * CARD_PAD;
1429
+ const head = card.heading ? 0.18 + Math.max(0.4, cardHeadingHeight(card, innerW)) + 0.04 : 0.18;
1430
+ return CARD_PAD + CARD_CHIP + head + cardBodyHeight(card, innerW) + CARD_PAD;
1431
+ }
1432
+
1433
+ /**
1434
+ * The height a row of cards is drawn at: the tallest card's content, never taller
1435
+ * than the region the template reserves and never shorter than CARD_MIN_H. A fixed
1436
+ * region height left short cards mostly empty; the space below a short row is free
1437
+ * for the takeaway, and the craft gate reports a slide that stays thin.
1438
+ */
1439
+ function fittedCardHeight(cards, w, regionH) {
1440
+ const need = Math.max(...cards.map((c) => azureCardHeight(c, w)));
1441
+ return Math.min(regionH, Math.max(CARD_MIN_H, need));
1442
+ }
1443
+
1384
1444
  /** Draw one rounded card with a numbered chip, heading, and body lines. */
1385
1445
  function drawAzureCard(slide, x, y, w, h, card, idx, P, emphasized) {
1386
1446
  const bg = emphasized ? hex(P.primary_deep) : hex(P.tint);
1387
1447
  const headColor = emphasized ? "FFFFFF" : hex(P.ink);
1388
1448
  const bodyColor = emphasized ? hex(P.emphasisTextColor) : hex(P.ink_muted);
1389
- slide.addShape("roundRect", { x, y, w, h, rectRadius: 0.16, fill: { color: bg }, line: { type: "none" } });
1390
- const pad = 0.30;
1449
+ slide.addShape("roundRect", { x, y, w, h, rectRadius: CARD_RADIUS, fill: { color: bg }, line: { type: "none" } });
1450
+ const pad = CARD_PAD;
1451
+ const innerW = w - 2 * pad;
1391
1452
  // numbered chip
1392
- const chip = 0.52;
1453
+ const chip = CARD_CHIP;
1393
1454
  slide.addShape("roundRect", {
1394
1455
  x: x + pad, y: y + pad, w: chip, h: chip, rectRadius: 0.12,
1395
1456
  fill: { color: emphasized ? "FFFFFF" : hex(P.primary) }, line: { type: "none" },
@@ -1402,43 +1463,45 @@ function drawAzureCard(slide, x, y, w, h, card, idx, P, emphasized) {
1402
1463
  color: emphasized ? hex(P.primary_deep) : "FFFFFF",
1403
1464
  margin: 0, lineSpacingMultiple: 1,
1404
1465
  });
1466
+ const headH = cardHeadingHeight(card, innerW);
1405
1467
  if (card.heading) {
1406
1468
  slide.addText(card.heading, {
1407
- x: x + pad, y: y + pad + chip + 0.18, w: w - 2 * pad, h: 0.4,
1469
+ x: x + pad, y: y + pad + chip + 0.18, w: innerW, h: Math.max(0.4, headH),
1408
1470
  fontFace: P.titleFont, fontSize: 15, bold: true, color: headColor, align: "left", valign: "top",
1409
1471
  charSpacing: -0.3,
1410
1472
  });
1411
1473
  }
1412
- const bodyY = y + pad + chip + (card.heading ? 0.62 : 0.18);
1413
- const bodyH = h - (bodyY - y) - 0.18;
1474
+ const bodyY = y + pad + chip + 0.18 + (card.heading ? Math.max(0.4, headH) + 0.04 : 0);
1475
+ const bodyH = Math.max(0.3, h - (bodyY - y) - 0.18);
1414
1476
  if (card.bodyRuns && card.bodyRuns.length) {
1415
1477
  slide.addText(card.bodyRuns, {
1416
- x: x + pad, y: bodyY, w: w - 2 * pad, h: bodyH,
1478
+ x: x + pad, y: bodyY, w: innerW, h: bodyH,
1417
1479
  color: bodyColor, align: "left", valign: "top", lineSpacingMultiple: card.lineSpacing || 1.28, charSpacing: -0.2,
1418
1480
  });
1419
1481
  } else if (card.lines && card.lines.length) {
1420
1482
  // Shrink body font when there are many lines so it never exceeds the frame.
1421
- const fs = card.lines.length > 4 ? 10.5 : 11.5;
1483
+ const fs = cardBodySize(card);
1422
1484
  slide.addText(card.lines.join("\n"), {
1423
- x: x + pad, y: bodyY, w: w - 2 * pad, h: bodyH,
1485
+ x: x + pad, y: bodyY, w: innerW, h: bodyH,
1424
1486
  fontFace: P.bodyFont, fontSize: fs, color: bodyColor, align: "left", valign: "top",
1425
1487
  lineSpacingMultiple: 1.3, charSpacing: -0.2,
1426
1488
  });
1427
1489
  }
1428
1490
  }
1429
1491
 
1430
- /** Render content/main body as a row of cards. */
1492
+ /** Render content/main body as a row of cards, as tall as the longest card needs. */
1431
1493
  function renderAzureCards(slide, items, region, P) {
1432
1494
  const cards = bodyToCards(items);
1433
1495
  if (!cards.length) return;
1434
1496
  const n = cards.length;
1435
1497
  const x0 = region.x || 0.6, y0 = region.y || 1.6, w = region.w || 12.1;
1436
- const h = region.h || 3.0;
1437
- const gap = 0.28;
1498
+ const gap = CARD_GAP;
1438
1499
  const cardW = (w - (n - 1) * gap) / n;
1500
+ const h = fittedCardHeight(cards, cardW, region.h || 3.0);
1439
1501
  cards.forEach((c, i) => {
1440
1502
  drawAzureCard(slide, x0 + i * (cardW + gap), y0, cardW, h, c, i, P, n >= 3 && i === n - 1);
1441
1503
  });
1504
+ return y0 + h;
1442
1505
  }
1443
1506
 
1444
1507
  /** Flatten body/summary items (sections + children + bullets) into styled runs.
@@ -1448,12 +1511,12 @@ function itemsToRuns(items, P, scale = 1) {
1448
1511
  const runs = [];
1449
1512
  for (const it of items || []) {
1450
1513
  if (it.type === "section") {
1451
- runs.push({ text: cleanMd(it.heading || ""), options: { bold: true, fontFace: P.titleFont, fontSize: s(12.5), color: hex(P.ink), breakLine: true } });
1514
+ runs.push({ text: cleanMd(it.heading || ""), options: { bold: true, fontFace: P.titleFont, fontSize: s(14), color: hex(P.ink), breakLine: true } });
1452
1515
  for (const c of it.children || []) {
1453
- runs.push({ text: " " + cleanMd(c.text || ""), options: { fontFace: P.bodyFont, fontSize: s(11), color: hex(P.ink_muted), breakLine: true } });
1516
+ runs.push({ text: " " + cleanMd(c.text || ""), options: { fontFace: P.bodyFont, fontSize: s(12.5), color: hex(P.ink_muted), breakLine: true } });
1454
1517
  }
1455
1518
  } else if (it.text) {
1456
- runs.push({ text: (it.type === "numbered" ? " " : "• ") + cleanMd(it.text), options: { fontFace: P.bodyFont, fontSize: s(11.5), color: hex(P.ink_muted), breakLine: true } });
1519
+ runs.push({ text: (it.type === "numbered" ? " " : "• ") + cleanMd(it.text), options: { fontFace: P.bodyFont, fontSize: s(13), color: hex(P.ink_muted), breakLine: true } });
1457
1520
  }
1458
1521
  }
1459
1522
  return runs;
@@ -1465,51 +1528,229 @@ function estimateGroupHeight(items, innerW, scale, lineSpacing) {
1465
1528
  let hsum = 0;
1466
1529
  for (const it of items || []) {
1467
1530
  if (it.type === "section") {
1468
- hsum += estimateTextHeight(it.heading || "", innerW, 12.5 * scale, lineSpacing);
1469
- for (const c of it.children || []) hsum += estimateTextHeight(" " + (c.text || ""), innerW - 0.1, 11 * scale, lineSpacing);
1531
+ hsum += estimateTextHeight(it.heading || "", innerW, 14 * scale, lineSpacing);
1532
+ for (const c of it.children || []) hsum += estimateTextHeight(" " + (c.text || ""), innerW - 0.1, 12.5 * scale, lineSpacing);
1470
1533
  } else if (it.text) {
1471
- hsum += estimateTextHeight(it.text, innerW, 11.5 * scale, lineSpacing);
1534
+ hsum += estimateTextHeight(it.text, innerW, 13 * scale, lineSpacing);
1472
1535
  }
1473
1536
  }
1474
1537
  return hsum;
1475
1538
  }
1476
1539
 
1477
- /** Render a single summary-group as one card (heading + items, iterative shrink-to-fit). */
1478
- function renderAzureGroupCard(slide, content, region, P, idx) {
1479
- const w = region.w || 5.6, h = region.h || 2.4;
1480
- const innerW = w - 0.60;
1481
- const innerH = h - 0.30 - 0.52 - (content.heading ? 0.62 : 0.18) - 0.18;
1540
+ /** The height a group's runs take at their real leading, for sizing (not fitting) the card. */
1541
+ function groupTextHeight(items, innerW, scale, lineSpacing) {
1542
+ let hsum = 0.1;
1543
+ for (const it of items || []) {
1544
+ if (it.type === "section") {
1545
+ hsum += cardTextHeight(it.heading || "", innerW, 14 * scale, lineSpacing);
1546
+ for (const c of it.children || []) hsum += cardTextHeight(" " + (c.text || ""), innerW - 0.1, 12.5 * scale, lineSpacing);
1547
+ } else if (it.text) {
1548
+ hsum += cardTextHeight("• " + it.text, innerW, 13 * scale, lineSpacing);
1549
+ }
1550
+ }
1551
+ return hsum;
1552
+ }
1553
+
1554
+ /** A summary group as a card: the largest font scale (1 down to 0.55) whose text fits
1555
+ * `maxH`, and the height that text needs at that scale. */
1556
+ function groupCard(content, w, maxH, P) {
1557
+ const innerW = w - 2 * CARD_PAD;
1558
+ const heading = cleanMd(content.heading || "");
1559
+ const headH = heading ? 0.18 + Math.max(0.4, cardHeadingHeight({ heading }, innerW)) + 0.04 : 0.18;
1560
+ const fixed = CARD_PAD + CARD_CHIP + headH + 0.18;
1482
1561
  let scale = 0.55, ls = 1.12;
1483
1562
  for (const s of [1, 0.92, 0.85, 0.78, 0.72, 0.66, 0.6, 0.55]) {
1484
- if (estimateGroupHeight(content.items, innerW, s, s < 1 ? 1.12 : 1.28) <= innerH) { scale = s; ls = s < 1 ? 1.12 : 1.28; break; }
1563
+ if (estimateGroupHeight(content.items, innerW, s, s < 1 ? 1.12 : 1.28) <= maxH - fixed) { scale = s; ls = s < 1 ? 1.12 : 1.28; break; }
1485
1564
  }
1486
- const card = {
1487
- heading: cleanMd(content.heading || ""),
1488
- bodyRuns: itemsToRuns(content.items, P, scale),
1489
- lineSpacing: ls,
1565
+ const bodyHeight = groupTextHeight(content.items, innerW, scale, ls);
1566
+ return {
1567
+ card: { heading, bodyRuns: itemsToRuns(content.items, P, scale), lineSpacing: ls, bodyHeight },
1568
+ need: Math.min(maxH, fixed + bodyHeight + CARD_PAD - 0.18),
1490
1569
  };
1570
+ }
1571
+
1572
+ /** Render a single summary-group as one card, at `opts.height` (shared by the
1573
+ * groups of one slide) or at the height its own text needs. */
1574
+ function renderAzureGroupCard(slide, content, region, P, idx, height) {
1575
+ const w = region.w || 5.6, maxH = region.h || 2.4;
1576
+ const { card, need } = groupCard(content, w, maxH, P);
1577
+ const h = Math.min(maxH, Math.max(CARD_MIN_H, height || need));
1491
1578
  drawAzureCard(slide, region.x || 0.6, region.y || 1.6, w, h, card, idx, P, false);
1492
1579
  }
1493
1580
 
1494
1581
  /** Render a general data table (palette-styled): primary header, zebra tint rows. */
1495
- function renderAzureTable(slide, content, region, P) {
1582
+ // ── Tables and charts that fill their region ────────────────────────────────
1583
+
1584
+ // A cell reads as a number when, after units and signs, only digits remain.
1585
+ const NUMERIC_CELL = /^(?:[▲▼△▽]\s*)?[+\-−±]?\s*[\d.,]+\s*(?:%p?|배|x|pt|bp|[가-힣]{1,3}|[A-Za-z]{1,3})?$/u;
1586
+ const isNumericCell = (text) => NUMERIC_CELL.test(cleanMd(String(text)).trim());
1587
+
1588
+ /** Columns whose data cells are all numbers are right-aligned; a dash for a missing value is blank. */
1589
+ function numericColumns(headers, rows) {
1590
+ return headers.map((_, ci) => {
1591
+ const cells = rows.map((r) => r[ci]).filter((c) => c != null && !/^[\s—–\-]*$/u.test(cleanMd(String(c))));
1592
+ return cells.length > 0 && cells.every(isNumericCell);
1593
+ });
1594
+ }
1595
+
1596
+ /** Column widths proportional to the longest cell, Hangul counted wider, with a floor. */
1597
+ function proportionalWidths(headers, rows, totalW) {
1598
+ const len = (t) => [...cleanMd(String(t || ""))].reduce((n, ch) => n + (/[\u3131-\uD7A3]/u.test(ch) ? 1.7 : 1), 0);
1599
+ const want = headers.map((h, ci) => Math.max(len(h), ...rows.map((r) => len(r[ci])), 4));
1600
+ const floor = Math.min(0.9, totalW / headers.length);
1601
+ const sum = want.reduce((a, b) => a + b, 0);
1602
+ let widths = want.map((w) => Math.max(floor, (w / sum) * totalW));
1603
+ const scale = totalW / widths.reduce((a, b) => a + b, 0);
1604
+ widths = widths.map((w) => w * scale);
1605
+ return widths;
1606
+ }
1607
+
1608
+ /**
1609
+ * Row height and font size for a table that should use the room it has: rows
1610
+ * grow toward `bottom` (up to a comfortable maximum) instead of leaving the lower
1611
+ * half of the slide empty, and a roomy table gets a larger font.
1612
+ */
1613
+ function tableMetrics(rowCount, y, bottom, base) {
1614
+ const room = Math.max(0, (bottom || y + rowCount * base.rowH) - y);
1615
+ const rowH = Math.min(base.maxRowH, Math.max(base.rowH, (room * 0.92) / rowCount));
1616
+ const scale = rowH >= 0.55 ? 1.2 : rowH >= 0.47 ? 1.1 : 1;
1617
+ return { rowH, headerSize: +(base.headerSize * scale).toFixed(1), dataSize: +(base.dataSize * scale).toFixed(1) };
1618
+ }
1619
+
1620
+ function renderAzureTable(slide, content, region, P, bottom) {
1496
1621
  const headers = content.headers || [];
1497
1622
  const rows = content.rows || [];
1498
1623
  if (!headers.length) return;
1499
1624
  const x = region.x || 0.6, y = region.y || 1.6, w = region.w || 6.0;
1625
+ const numeric = numericColumns(headers, rows);
1626
+ const m = tableMetrics(rows.length + 1, y, bottom, { rowH: 0.4, maxRowH: 0.72, headerSize: 12, dataSize: 11.5 });
1500
1627
  const tableRows = [];
1501
- tableRows.push(headers.map((h) => ({
1628
+ tableRows.push(headers.map((h, ci) => ({
1502
1629
  text: cleanMd(h),
1503
- options: { bold: true, color: "FFFFFF", fill: { color: hex(P.primary) }, fontFace: P.titleFont, fontSize: 12, align: "left", valign: "middle" },
1630
+ options: { bold: true, color: "FFFFFF", fill: { color: hex(P.primary) }, fontFace: P.titleFont, fontSize: m.headerSize, align: numeric[ci] ? "right" : "left", valign: "middle" },
1504
1631
  })));
1505
1632
  rows.forEach((r, ri) => {
1506
- tableRows.push(r.map((c) => linkedTableCell(c, {
1507
- color: hex(P.ink), fill: { color: ri % 2 ? "FFFFFF" : hex(P.tint) }, fontFace: P.bodyFont, fontSize: 11.5, align: /^[-+]?\d[\d,.]*(?:%|원|억|만)?$/.test(cleanMd(c)) ? "right" : "left", valign: "middle",
1633
+ tableRows.push(r.map((c, ci) => linkedTableCell(c, {
1634
+ color: hex(P.ink), fill: { color: ri % 2 ? "FFFFFF" : hex(P.tint) }, fontFace: P.bodyFont, fontSize: m.dataSize,
1635
+ align: numeric[ci] ? "right" : "left", valign: "middle", bold: ci === 0 && !numeric[0],
1508
1636
  })));
1509
1637
  });
1510
1638
  slide.addTable(tableRows, {
1511
- x, y, w, colW: Array(headers.length).fill(w / headers.length),
1512
- border: { type: "solid", color: hex(P.line), pt: 0.5 }, rowH: 0.4, margin: 0.06, autoPage: false,
1639
+ x, y, w, colW: proportionalWidths(headers, rows, w),
1640
+ border: { type: "solid", color: hex(P.line), pt: 0.5 }, rowH: m.rowH, margin: [0.04, 0.12, 0.04, 0.12], autoPage: false,
1641
+ });
1642
+ }
1643
+
1644
+ /** Parse "1,234", "▲12.5%", "−3" into a number; anything else is null. */
1645
+ function chartNumber(text) {
1646
+ const t = cleanMd(String(text || "")).trim().replace(/[,\s]/g, "").replace(/^[▲△+]/u, "").replace(/^[▼▽−]/u, "-");
1647
+ const m = t.match(/^-?\d+(?:\.\d+)?/);
1648
+ return m ? parseFloat(m[0]) : null;
1649
+ }
1650
+
1651
+ const CHART_TYPE = { bar: "bar", column: "bar", stacked: "bar", line: "line", area: "area", pie: "pie", doughnut: "doughnut" };
1652
+
1653
+ /**
1654
+ * A native PowerPoint chart (editable in PowerPoint: right-click > Edit Data)
1655
+ * from a `::: chart` block. It takes the whole region down to `bottom`.
1656
+ */
1657
+ function renderChart(slide, content, region, style, bottom) {
1658
+ const headers = content.headers || [];
1659
+ const rows = content.rows || [];
1660
+ const spec = content.chart || {};
1661
+ const kind = CHART_TYPE[spec.type] || "bar";
1662
+ const labels = rows.map((r) => cleanMd(String(r[0] || "")));
1663
+ const series = headers.slice(1).map((name, si) => ({
1664
+ name: cleanMd(String(name)),
1665
+ labels,
1666
+ // No number in a cell means null: the chart writes a blank point and the line breaks there rather than dropping to zero.
1667
+ values: rows.map((r) => chartNumber(r[si + 1])),
1668
+ }));
1669
+ // Data labels keep the decimals the table writes: a table that says 18.0 gets a label that says 18.0.
1670
+ 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))));
1671
+ const x = region.x || 0.6, y = region.y || 1.6, w = region.w || 8;
1672
+ const h = Math.max(1.6, ((bottom || y + (region.h || 3.2)) - y));
1673
+ const colors = style.series.map((c) => hex(c));
1674
+ const round = kind === "pie" || kind === "doughnut";
1675
+ const data = round ? series.slice(0, 1) : series;
1676
+ // Crowding, estimated in points: a category label wider than its slot is slanted; side-by-side
1677
+ // value labels wider than their bars collide, so the values are read off a shown axis instead.
1678
+ 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);
1679
+ const labelPt = style.labelSize || 11;
1680
+ const slot = (w * 72 * 0.85) / Math.max(1, labels.length);
1681
+ // A renderer adds space between Hangul and digits ("25 년 2Q"), so a label needs a quarter of its slot spare.
1682
+ // Horizontal bars stack their category labels down the side, where slanting only drops some of them.
1683
+ const slanted = !round && kind !== "line" && spec.type !== "bar" && Math.max(0, ...labels.map(ems)) * labelPt > slot * 0.75;
1684
+ const clustered = kind === "bar" && spec.type !== "stacked" && data.length > 1;
1685
+ const valueW = Math.max(0, ...data.flatMap((d) => d.values.map((v) => ems(Number(v).toLocaleString("en-US"))))) * labelPt;
1686
+ // Lines of several series run close together, so their value labels print on top of each other.
1687
+ const crowded = !round && ((clustered && valueW > (slot / 1.6 / data.length) * 1.1) || (kind === "line" && data.length > 1));
1688
+ const values = data.flatMap((d) => d.values).filter((v) => v !== null);
1689
+ // Several series of horizontal bars split each row into thin bars; once a bar is thinner than a label line, the value labels collide.
1690
+ const horizontal = !round && spec.type === "bar";
1691
+ const dataPt = style.labelSize || (round ? 12 : 10);
1692
+ const barPt = (h * 72 - 54) / Math.max(1, labels.length) / (data.length + 0.6);
1693
+ const thin = horizontal && data.length > 1 && spec.labels !== false && barPt < dataPt * 1.2;
1694
+ // A narrow horizontal bar chart gets a wide axis step, so its numbers have room to stay flat; LibreOffice slants a crowded value axis.
1695
+ const top = Math.max(0, ...values), low = Math.min(0, ...values);
1696
+ const tickW = ems(Math.round(Math.max(top, -low)).toLocaleString("en-US")) * dataPt;
1697
+ const catW = Math.min(w * 72 * 0.4, Math.max(0, ...labels.map(ems)) * labelPt + 8);
1698
+ const ticks = Math.floor((w * 72 - catW - 16) / (tickW * 2));
1699
+ const nice = (step) => { const p = 10 ** Math.floor(Math.log10(step)); return [1, 2, 2.5, 5, 10].find((m) => m * p >= step) * p; };
1700
+ const majorUnit = horizontal && top > low && ticks < 6 ? nice((top - low) / Math.max(1, ticks - 1)) : null;
1701
+ const zeroBased = !round && values.length > 0 && values.every((v) => v >= 0);
1702
+ // The unit stands once: a legend whose series names carry it already says it, so the axis title goes;
1703
+ // otherwise it is set level (a rotated title stacks Hangul a glyph a line and reads tiny; 360 degrees, since
1704
+ // pptxgenjs writes no angle for 0 and the axis default is vertical).
1705
+ const unitShown = spec.unit && data.length > 1 && data.every((d) => String(d.name || "").includes(spec.unit));
1706
+ const axisTitle = spec.unit && !unitShown && !(Boolean(style.valAxisHidden) && !crowded && !thin);
1707
+ slide.addChart(kind, data, {
1708
+ x, y, w, h,
1709
+ barDir: spec.type === "bar" ? "bar" : "col",
1710
+ barGrouping: spec.type === "stacked" ? "stacked" : "clustered",
1711
+ barGapWidthPct: 60,
1712
+ chartColors: round ? labels.map((_, i) => colors[i % colors.length]) : colors,
1713
+ showLegend: round || data.length > 1,
1714
+ legendPos: round ? "r" : "t",
1715
+ legendFontFace: style.bodyFont, legendFontSize: 11, legendColor: hex(style.ink),
1716
+ showValue: !round && spec.labels !== false && !crowded && !thin,
1717
+ showPercent: round && spec.labels !== false, showLabel: false,
1718
+ dataLabelFontFace: style.bodyFont, dataLabelFontSize: style.labelSize || (round ? 12 : 10),
1719
+ dataLabelColor: round ? "FFFFFF" : hex(style.ink),
1720
+ dataLabelPosition: round ? "ctr" : kind === "line" ? "t" : "outEnd",
1721
+ dataLabelFormatCode: round ? "0%" : decimals ? `#,##0.${"0".repeat(decimals)}` : "#,##0",
1722
+ catAxisLabelFontFace: style.bodyFont, catAxisLabelFontSize: style.labelSize || 11, catAxisLabelColor: hex(style.ink),
1723
+ valAxisLabelFontFace: style.bodyFont, valAxisLabelFontSize: style.labelSize || 10, valAxisLabelColor: hex(style.ink_muted),
1724
+ valAxisLabelFormatCode: "#,##0",
1725
+ // A pack with direct labels and no gridlines drops the value axis: the labels carry the values.
1726
+ valAxisHidden: Boolean(style.valAxisHidden) && !crowded && !thin,
1727
+ ...(majorUnit ? { valAxisMajorUnit: majorUnit } : {}),
1728
+ // With a negative value the zero line crosses the plot, so the category labels move to the axis minimum, clear of the bars.
1729
+ ...(horizontal && low < 0 ? { catAxisLabelPos: "low" } : {}),
1730
+ // Blank points show as gaps.
1731
+ displayBlanksAs: "gap",
1732
+ ...(style.valMax ? { valAxisMaxVal: style.valMax } : {}),
1733
+ ...(zeroBased ? { valAxisMinVal: 0 } : {}),
1734
+ ...(slanted ? { catAxisLabelRotate: -45 } : {}),
1735
+ catAxisLineShow: true, catAxisLineColor: hex(style.line),
1736
+ valGridLine: style.grid === false && !crowded && !thin ? { style: "none" } : { color: hex(style.line), size: 0.5 },
1737
+ valAxisLineShow: false,
1738
+ lineSize: 2.5, lineDataSymbol: "circle", lineDataSymbolSize: 7,
1739
+ holeSize: 58,
1740
+ ...(axisTitle ? { showValAxisTitle: true, valAxisTitle: spec.unit, valAxisTitleRotate: 360, valAxisTitleFontSize: style.labelSize || 10, valAxisTitleColor: hex(style.ink_muted), valAxisTitleFontFace: style.bodyFont } : {}),
1741
+ ...(spec.title ? { showTitle: true, title: spec.title, titleFontFace: style.titleFont, titleFontSize: 13, titleColor: hex(style.ink) } : {}),
1742
+ });
1743
+ }
1744
+
1745
+ /** A main-box as a tinted callout sized to its text, not an outlined frame. */
1746
+ function renderAzureCallout(slide, text, region, P) {
1747
+ const x = region.x || 0.84, y = region.y || 5.8, w = region.w || 11.6;
1748
+ const lines = Math.max(1, Math.ceil(cleanMd(text).length / Math.max(20, w * 9)));
1749
+ const h = Math.min(region.h || 1.0, 0.28 + lines * 0.3);
1750
+ slide.addShape("roundRect", { x, y, w, h, rectRadius: 0.1, fill: { color: hex(P.tint) }, line: { type: "none" } });
1751
+ slide.addText(cleanMd(text), {
1752
+ x: x + 0.24, y, w: w - 0.48, h, fontFace: P.bodyFont, fontSize: 13, bold: true,
1753
+ color: hex(P.primary_deep), align: "left", valign: "middle", margin: 0,
1513
1754
  });
1514
1755
  }
1515
1756
 
@@ -1521,7 +1762,7 @@ function renderAzureKpi(slide, content, region, P) {
1521
1762
  const n = Math.min(values.length, 4);
1522
1763
  if (!n) return;
1523
1764
  const x0 = region.x || 0.6, y0 = region.y || 1.6, w = region.w || 12.1, h = region.h || 1.7;
1524
- const gap = 0.26;
1765
+ const gap = KPI_GAP;
1525
1766
  const cardW = (w - (n - 1) * gap) / n;
1526
1767
  for (let i = 0; i < n; i++) {
1527
1768
  const fill = i === 0;
@@ -1532,7 +1773,7 @@ function renderAzureKpi(slide, content, region, P) {
1532
1773
  line: fill ? { type: "none" } : { color: hex(P.line), width: 1 },
1533
1774
  });
1534
1775
  slide.addText(cleanMd(values[i]), {
1535
- x: x + 0.28, y: y0 + Math.max(0.20, h * 0.24), w: cardW - 0.56, h: 0.8,
1776
+ x: x + 0.28, y: y0 + 0.20, w: cardW - 0.56, h: 0.8,
1536
1777
  fontFace: P.titleFont, fontSize: 30, bold: true,
1537
1778
  color: fill ? "FFFFFF" : hex(P.primary), align: "left", valign: "middle", charSpacing: -1, margin: 0,
1538
1779
  });
@@ -1572,7 +1813,9 @@ function kpiCards(content) {
1572
1813
  return [headers, ...rows].slice(0, 4).map((p) => ({ value: p[0], label: p[1] }));
1573
1814
  }
1574
1815
  const row = rows[0] || [];
1575
- return headers.slice(0, 4).map((lab, i) => ({ value: row[i] != null ? row[i] : "", label: lab }));
1816
+ // A second body row is each figure's basis or comparison ("전년 대비 +12%", "목표 50%").
1817
+ const basis = rows[1] || [];
1818
+ return headers.slice(0, 6).map((lab, i) => ({ value: row[i] != null ? row[i] : "", label: lab, ...(basis[i] ? { note: basis[i] } : {}) }));
1576
1819
  }
1577
1820
 
1578
1821
  /** Azure region dispatch. Returns true if it fully handled the region.
@@ -1580,9 +1823,17 @@ function kpiCards(content) {
1580
1823
  function renderAzureRegion(slide, layout, name, region, fontRoles, P, opts = {}) {
1581
1824
  const content = region.content;
1582
1825
  if (content == null) return false;
1826
+ if (name === "main_box" && typeof content === "string" && content.trim()) {
1827
+ // Below a card row that came out shorter than its region, the takeaway follows
1828
+ // the cards instead of leaving a band of empty slide between them.
1829
+ const follow = opts.flow && opts.flow.cardsBottom != null ? opts.flow.cardsBottom + 0.32 : null;
1830
+ renderAzureCallout(slide, content, follow != null && follow < (region.y || 5.8) ? { ...region, y: follow } : region, P);
1831
+ return true;
1832
+ }
1583
1833
  if (layout === "cover" && name === "title" && typeof content === "string" && content.includes("**")) {
1584
1834
  const role = fontRoles.cover_title || {};
1585
1835
  slide.addText(azureTitleRuns(content, P), {
1836
+ objectName: "title@legacy",
1586
1837
  x: region.x || 0, y: region.y || 0, w: region.w || 8, h: region.h || 1,
1587
1838
  fontFace: role.font || P.titleFont, fontSize: role.size || 48, bold: role.bold !== false,
1588
1839
  align: role.align || "left", valign: "middle",
@@ -1597,21 +1848,28 @@ function renderAzureRegion(slide, layout, name, region, fontRoles, P, opts = {})
1597
1848
  // normal text column (fall through to addRegion) so nothing is cramped.
1598
1849
  const sections = (content.items || []).filter((i) => i.type === "section");
1599
1850
  if (sections.length >= 2 && !opts.hasImage) {
1600
- renderAzureCards(slide, content.items, region, P);
1851
+ const bottom = renderAzureCards(slide, content.items, region, P);
1852
+ if (opts.flow) opts.flow.cardsBottom = bottom;
1601
1853
  return true;
1602
1854
  }
1603
1855
  return false;
1604
1856
  }
1605
1857
  if (content.type === "kpi-table") {
1606
- if (looksLikeKpi(content) && kpiFits(content, region.w)) {
1858
+ if (content.chart) {
1859
+ // Pie and doughnut slices carry white labels, so they use the darker tones only.
1860
+ const round = ["pie", "doughnut"].includes(content.chart.type);
1861
+ const series = round ? [P.primary, P.primary_deep, P.azure, P.ink_muted] : [P.primary, P.azure_soft, P.primary_deep, P.ink_muted];
1862
+ renderChart(slide, content, region, { ...P, series }, opts.bottom);
1863
+ }
1864
+ else if (looksLikeKpi(content) && kpiFits(content, region.w)) {
1607
1865
  renderAzureKpi(slide, content, region, P);
1608
1866
  }
1609
- else renderAzureTable(slide, content, region, P);
1867
+ else renderAzureTable(slide, content, region, P, opts.bottom);
1610
1868
  return true;
1611
1869
  }
1612
1870
  if (content.type === "summary-group") {
1613
1871
  const idx = name === "group_bottom" ? 1 : 0;
1614
- renderAzureGroupCard(slide, content, region, P, idx);
1872
+ renderAzureGroupCard(slide, content, region, P, idx, opts.groupHeight);
1615
1873
  return true;
1616
1874
  }
1617
1875
  }
@@ -1731,7 +1989,7 @@ function renderPlacements(slide, placements, band, ctx) {
1731
1989
  for (const placement of placements) {
1732
1990
  if (placement.z !== band) continue;
1733
1991
  if (placement.kind === "shape") {
1734
- addDecorations(slide, [shapeToDecoration(placement, ctx.sourceDir)], ctx.slideIndex, ctx.fontRoles);
1992
+ addDecorations(slide, [shapeToDecoration(placement, ctx.sourceDir)], ctx.fontRoles);
1735
1993
  } else {
1736
1994
  renderPlacementBox(slide, placement, ctx);
1737
1995
  }
@@ -1748,10 +2006,67 @@ function renderPlacements(slide, placements, band, ctx) {
1748
2006
  * @param {string} outputPath - Where to write the .pptx file
1749
2007
  * @returns {Promise<string>} Resolves with the output path
1750
2008
  */
2009
+ /**
2010
+ * The deck-wide `notice:` (for example "예시 데이터 — 실제 수치로 바꿔 주세요") as a
2011
+ * coloured tag at the bottom left of every slide, cover included, so a sample or
2012
+ * draft deck can never be mistaken for a sourced one. A 9 pt grey footnote was easy
2013
+ * to miss; the tag keeps its own fill and text colour (template palette
2014
+ * `notice_fill` / `notice_ink`, default a light red with dark red text, 6:1).
2015
+ */
2016
+ const NOTICE_FILL = "FDECEA";
2017
+ const NOTICE_INK = "A21B12";
2018
+ let NOTICE = { ...LEGACY_TOKENS.notice };
2019
+
2020
+ function addNotice(slide, text, pageW, pageH, pal) {
2021
+ const label = cleanMd(text);
2022
+ const fontSize = NOTICE.size;
2023
+ const textW = (weightedTextLength(label) * fontSize * 0.98) / 72;
2024
+ const w = Math.min(pageW * 0.55, textW + 0.46);
2025
+ const h = NOTICE.h;
2026
+ const x = NOTICE.x, y = pageH - h - NOTICE.bottom;
2027
+ const fill = ((pal && pal.notice_fill) || NOTICE_FILL).replace("#", "");
2028
+ const ink = ((pal && pal.notice_ink) || NOTICE_INK).replace("#", "");
2029
+ slide.addShape("roundRect", { x, y, w, h, rectRadius: 0.15, fill: { color: fill }, line: { type: "none" }, objectName: "lit-notice tag" });
2030
+ slide.addText(label, {
2031
+ objectName: "lit-notice text",
2032
+ x: x + 0.16, y, w: w - 0.32, h,
2033
+ fontFace: FONT_BOLD, fontSize, bold: true, color: ink,
2034
+ align: "left", valign: "middle", margin: 0, fit: "shrink",
2035
+ });
2036
+ }
2037
+
2038
+ /**
2039
+ * Give the first object drawn on a slide that has no name of its own the name `family@<id>`, so a
2040
+ * checker can read the layout family the source asked for and compare it with what it measures.
2041
+ */
2042
+ function stampFamily(slide, family) {
2043
+ const at = { addText: 1, addShape: 1, addImage: 0, addTable: 1, addChart: 2 };
2044
+ const originals = {};
2045
+ let done = false;
2046
+ const restore = () => { for (const m of Object.keys(originals)) slide[m] = originals[m]; };
2047
+ for (const [method, index] of Object.entries(at)) {
2048
+ originals[method] = slide[method];
2049
+ slide[method] = (...args) => {
2050
+ if (!(args[index] && args[index].objectName)) {
2051
+ restore();
2052
+ done = true;
2053
+ args[index] = { ...(args[index] || {}), objectName: `family@${family}` };
2054
+ }
2055
+ return originals[method].apply(slide, args);
2056
+ };
2057
+ }
2058
+ /** Whether a shape took the name; unwraps the slide either way. */
2059
+ return () => { restore(); return done; };
2060
+ }
2061
+
1751
2062
  async function render(resolved, templateObj, outputPath) {
2063
+ const pack = (templateObj && templateObj.pack) || null;
2064
+ if (pack && !pack.legacy) return require("./render-pack").renderPack(resolved, templateObj, outputPath);
2065
+ applyTokens(pack && pack.tokens);
1752
2066
  const fontRoles = buildFontRoles(templateObj);
1753
2067
 
1754
- // Derive font, spacing, and accent from the template, with neutral defaults.
2068
+ // Brand-neutralize: derive font/spacing/accent from the template, falling back
2069
+ // to Pretendard when a template names no family.
1755
2070
  const tpl = (templateObj && templateObj.template) || {};
1756
2071
  const tplFonts = tpl.fonts || {};
1757
2072
  const tplType = tpl.global_typography || {};
@@ -1759,20 +2074,25 @@ async function render(resolved, templateObj, outputPath) {
1759
2074
  FONT_MEDIUM = tplFonts.body || "Pretendard";
1760
2075
  FONT_LIGHT = tplFonts.light || "Pretendard";
1761
2076
  CHAR_SPACING = tplType.char_spacing != null ? tplType.char_spacing : -0.7;
1762
- BULLET_FONT = FONT_BOLD;
1763
2077
  SEC_HEADER_FONT = FONT_BOLD;
1764
2078
  TOC_FONT = FONT_BOLD;
1765
- BULLET_COLOR = (tplType.bullet_color || "00AE41").replace("#", "");
1766
- SECTION_ACCENT = (tplType.section_accent || "00AE41").replace("#", "");
2079
+ BULLET_COLOR = (tplType.bullet_color || "1D4ED8").replace("#", "");
2080
+ SECTION_ACCENT = (tplType.section_accent || "1D4ED8").replace("#", "");
1767
2081
 
1768
- // Rich card, KPI, and divider colors come from the template palette.
1769
- const rich = tpl.render_style === "azure";
2082
+ // Rich card/KPI/divider rendering — palette-driven, for templates with the rich-blocks
2083
+ // capability. Tokens come from the template palette.
2084
+ const rich = Boolean(pack && (pack.capabilities || []).includes("rich-blocks"));
1770
2085
  const pal = tpl.palette || {};
2086
+ PLAIN_CHART_STYLE = {
2087
+ series: [pal.ink || "#3C3836", pal.accent_green_2 || pal.primary || "#689D6A", pal.accent_amber || "#D79921", pal.accent_purple || "#B16286"],
2088
+ ink: pal.ink || "#3C3836", ink_muted: pal.ink_muted || "#6E6256", line: pal.line || "#CBC2B5",
2089
+ titleFont: FONT_BOLD, bodyFont: FONT_MEDIUM,
2090
+ };
1771
2091
  const P = {
1772
2092
  titleFont: FONT_BOLD, bodyFont: FONT_MEDIUM,
1773
2093
  primary: pal.primary || "#1D4ED8", primary_deep: pal.primary_deep || "#0B2E6F",
1774
2094
  ink: pal.ink || "#0E1B2C", ink_muted: pal.ink_muted || "#51607A",
1775
- tint: pal.tint || "#EEF4FF", line: pal.line || "#D5DEEC", azure_soft: pal.azure_soft || "#93C5FD",
2095
+ tint: pal.tint || "#EEF4FF", line: pal.line || "#D5DEEC", azure_soft: pal.azure_soft || "#93C5FD", azure: pal.azure || "#3B82F6",
1776
2096
  emphasisTextColor: pal.emphasis_text || "#C9D8F5",
1777
2097
  };
1778
2098
 
@@ -1781,6 +2101,8 @@ async function render(resolved, templateObj, outputPath) {
1781
2101
  const pageW = dims.width || 10.833;
1782
2102
  const pageH = dims.height || 7.5;
1783
2103
 
2104
+ // Loaded here, not at module load, so AST-only and --list-* runs never install.
2105
+ const PptxGenJS = require("pptxgenjs");
1784
2106
  const pptx = new PptxGenJS();
1785
2107
  pptx.defineLayout({ name: "A4_LANDSCAPE", width: pageW, height: pageH });
1786
2108
  pptx.layout = "A4_LANDSCAPE";
@@ -1789,20 +2111,22 @@ async function render(resolved, templateObj, outputPath) {
1789
2111
  pptx.title = title;
1790
2112
  const sourceDir = resolved.sourceDir;
1791
2113
  const cramStress = isCramStressDeck(resolved);
2114
+ const deckMeta = (resolved.deck && resolved.deck.metadata) || {};
2115
+ const notice = typeof deckMeta.notice === "string" && deckMeta.notice.trim() ? deckMeta.notice.trim() : null;
1792
2116
 
1793
2117
  for (const slideSpec of resolved.slides || []) {
1794
2118
  const slide = pptx.addSlide();
2119
+ stampFamily(slide, slideSpec.family || slideSpec.layout);
1795
2120
 
1796
2121
  // Draw order is a band at a time: template decorations, then anything the
1797
2122
  // author put under the content, then the template's regions, then the
1798
2123
  // columns that replaced the body, then anything sitting on top.
1799
- addDecorations(slide, slideSpec.decorations, slideSpec.index, fontRoles);
2124
+ addDecorations(slide, slideSpec.decorations, fontRoles);
1800
2125
 
1801
2126
  const placements = slideSpec.placements || [];
1802
2127
  const placementCtx = {
1803
2128
  rich, P, fontRoles, pageH, sourceDir,
1804
2129
  layout: slideSpec.layout,
1805
- slideIndex: slideSpec.index,
1806
2130
  };
1807
2131
  renderPlacements(slide, placements, "under", placementCtx);
1808
2132
 
@@ -1832,17 +2156,80 @@ async function render(resolved, templateObj, outputPath) {
1832
2156
  (n === "image" || n === "figure") && r.content &&
1833
2157
  (r.content.type === "image" || r.content.type === "image-grid"));
1834
2158
 
2159
+ // Summary groups sit side by side, so they share the height the fuller one needs.
2160
+ const groups = Object.values(regions).filter((r) => r.content && r.content.type === "summary-group");
2161
+ const groupHeight = rich && groups.length
2162
+ ? Math.max(...groups.map((r) => groupCard(r.content, r.w || 5.6, r.h || 2.4, P).need))
2163
+ : null;
2164
+
2165
+ const flow = {};
1835
2166
  for (const [name, region] of Object.entries(regions)) {
1836
- if (rich && renderAzureRegion(slide, slideSpec.layout, name, region, fontRoles, P, { hasImage })) continue;
2167
+ if (rich && renderAzureRegion(slide, slideSpec.layout, name, region, fontRoles, P, { hasImage, bottom: bottomBoundary[name], groupHeight, flow })) continue;
1837
2168
  addRegion(slide, name, region, fontRoles, pageH, bottomBoundary[name], tocSlide, sourceDir);
1838
2169
  }
1839
2170
 
1840
2171
  renderPlacements(slide, placements, "content", placementCtx);
1841
2172
  renderPlacements(slide, placements, "over", placementCtx);
2173
+ if (notice) addNotice(slide, notice, pageW, pageH, pal);
1842
2174
  }
1843
2175
 
1844
- await pptx.writeFile({ fileName: outputPath });
2176
+ await writeTagged(pptx, outputPath);
1845
2177
  return outputPath;
1846
2178
  }
1847
2179
 
1848
- module.exports = { render };
2180
+ const HANGUL = /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7A3]/u;
2181
+
2182
+ /**
2183
+ * Korean runs name their language. pptxgenjs writes lang="en-US" on every run, so a renderer applies Latin
2184
+ * rules to Hangul (LibreOffice on a host whose locale is not Korean spaces Hangul from digits: "주 2회" set as
2185
+ * "주 2 회"). A run holding Hangul becomes ko-KR with en-US as its alternate language, and a chart holding
2186
+ * Hangul names ko-KR for its text; Latin-only runs keep en-US.
2187
+ */
2188
+ function tagLanguages(name, xml) {
2189
+ if (/^ppt\/slides\/slide\d+\.xml$/u.test(name)) {
2190
+ return xml.replace(/<a:r><a:rPr\b([^>]*?)(\/?>)([\s\S]*?)<a:t>([^<]*)<\/a:t><\/a:r>/gu, (m, attrs, close, mid, text) => {
2191
+ if (!HANGUL.test(text)) return m;
2192
+ const rest = attrs.replace(/\s(?:lang|altLang)="[^"]*"/gu, "");
2193
+ return `<a:r><a:rPr lang="ko-KR" altLang="en-US"${rest}${close}${mid}<a:t>${text}</a:t></a:r>`;
2194
+ });
2195
+ }
2196
+ if (/^ppt\/charts\/chart\d+\.xml$/u.test(name) && HANGUL.test(xml)) {
2197
+ let out = xml.replace(/<a:defRPr\b(?![^>]*\blang=)/gu, '<a:defRPr lang="ko-KR" altLang="en-US"')
2198
+ .replace(/<a:endParaRPr lang="en-US"/gu, '<a:endParaRPr lang="ko-KR" altLang="en-US"');
2199
+ if (!/<c:lang\b/u.test(out)) out = out.replace(/(<c:date1904 val="[01]"\/>)/u, '$1<c:lang val="ko-KR"/>');
2200
+ return out;
2201
+ }
2202
+ return xml;
2203
+ }
2204
+
2205
+ /** Write a deck with its Korean runs and charts tagged (tagLanguages); `edit` may rewrite a part first. */
2206
+ async function writeTagged(pptx, outputPath, edit = (name, xml) => xml) {
2207
+ const JSZip = require("jszip");
2208
+ const zip = await JSZip.loadAsync(await pptx.write({ outputType: "nodebuffer" }));
2209
+ for (const name of Object.keys(zip.files).filter((n) => /^ppt\/(slides\/slide|charts\/chart|theme\/theme)\d+\.xml$/u.test(n))) {
2210
+ const xml = await zip.file(name).async("string");
2211
+ zip.file(name, tagLanguages(name, edit(name, xml)));
2212
+ }
2213
+ fs.writeFileSync(outputPath, await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }));
2214
+ }
2215
+
2216
+ // ── Tonality packs ──────────────────────────────────────────────────────────
2217
+ //
2218
+ // A pack deck is drawn by render-pack.js. It reuses the helpers below, and the font globals the
2219
+ // free placement boxes read are switched to the pack's faces before it draws.
2220
+
2221
+ function usePackFonts(titleFont, bodyFont, tokens) {
2222
+ FONT_BOLD = titleFont;
2223
+ FONT_MEDIUM = bodyFont;
2224
+ FONT_LIGHT = bodyFont;
2225
+ CHAR_SPACING = 0;
2226
+ applyTokens(tokens);
2227
+ }
2228
+
2229
+ module.exports = {
2230
+ render,
2231
+ shared: {
2232
+ hex, cleanMd, mdRuns, linkedTableCell, resolveImagePath, readImageSize, containBox,
2233
+ kpiCards, numericColumns, proportionalWidths, renderChart, stampFamily, renderPlacements, usePackFonts, writeTagged,
2234
+ },
2235
+ };