@litfamily/litclaude 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +14 -8
  3. package/README_ko-KR.md +13 -8
  4. package/docs/assets/readme/badge-version.svg +1 -1
  5. package/docs/hooks.md +3 -2
  6. package/package.json +1 -1
  7. package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
  8. package/plugins/litclaude/bin/litclaude-hook.js +4 -4
  9. package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
  10. package/plugins/litclaude/lib/canonical-skill-resources.mjs +91 -31
  11. package/plugins/litclaude/skills/frontend-ui-ux/ATTRIBUTION.md +3 -6
  12. package/plugins/litclaude/skills/lit-docx/SKILL.md +50 -19
  13. package/plugins/litclaude/skills/lit-docx/references/components.md +207 -0
  14. package/plugins/litclaude/skills/lit-docx/references/direction-step.md +277 -0
  15. package/plugins/litclaude/skills/lit-docx/references/examples/01-report-ko.md +166 -0
  16. package/plugins/litclaude/skills/lit-docx/references/examples/02-brief-ko.md +105 -0
  17. package/plugins/litclaude/skills/lit-docx/references/examples/03-manual-ko.md +164 -0
  18. package/plugins/litclaude/skills/lit-docx/references/examples/04-proposal-ko.md +138 -0
  19. package/plugins/litclaude/skills/lit-docx/references/examples/05-memo-en.md +55 -0
  20. package/plugins/litclaude/skills/lit-docx/references/examples/06-journal-en.md +130 -0
  21. package/plugins/litclaude/skills/lit-docx/references/examples/07-report-en.md +123 -0
  22. package/plugins/litclaude/skills/lit-docx/references/examples/08-proposal-en.md +123 -0
  23. package/plugins/litclaude/skills/lit-docx/references/examples/09-memo-ko.md +45 -0
  24. package/plugins/litclaude/skills/lit-docx/references/page-composition.md +221 -0
  25. package/plugins/litclaude/skills/lit-docx/references/tonalities/brief.md +143 -0
  26. package/plugins/litclaude/skills/lit-docx/references/tonalities/journal.md +142 -0
  27. package/plugins/litclaude/skills/lit-docx/references/tonalities/manual.md +148 -0
  28. package/plugins/litclaude/skills/lit-docx/references/tonalities/memo.md +137 -0
  29. package/plugins/litclaude/skills/lit-docx/references/tonalities/proposal.md +140 -0
  30. package/plugins/litclaude/skills/lit-docx/references/tonalities/report.md +156 -0
  31. package/plugins/litclaude/skills/lit-docx/scripts/convert_md_to_docx.py +274 -73
  32. package/plugins/litclaude/skills/lit-docx/scripts/docx_design.py +2449 -0
  33. package/plugins/litclaude/skills/lit-docx/scripts/docx_layout.py +959 -0
  34. package/plugins/litclaude/skills/lit-docx/scripts/qa_docx.py +66 -9
  35. package/plugins/litclaude/skills/lit-docx/scripts/slop_lint.py +18 -6
  36. package/plugins/litclaude/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
  37. package/plugins/litclaude/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
  38. package/plugins/litclaude/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
  39. package/plugins/litclaude/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
  40. package/plugins/litclaude/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
  41. package/plugins/litclaude/skills/lit-docx/templates/tonalities/report.yaml +26 -0
  42. package/plugins/litclaude/skills/lit-pptx/SKILL.md +105 -48
  43. package/plugins/litclaude/skills/lit-pptx/references/agents/analyst.md +1 -1
  44. package/plugins/litclaude/skills/lit-pptx/references/agents/thesis.md +1 -1
  45. package/plugins/litclaude/skills/lit-pptx/references/anti-slop-checklist.md +59 -30
  46. package/plugins/litclaude/skills/lit-pptx/references/authoring-guide.md +119 -42
  47. package/plugins/litclaude/skills/lit-pptx/references/consensus-protocol.md +1 -1
  48. package/plugins/litclaude/skills/lit-pptx/references/data-display-cookbook.md +13 -6
  49. package/plugins/litclaude/skills/lit-pptx/references/density-and-fill.md +164 -0
  50. package/plugins/litclaude/skills/lit-pptx/references/design-system-azure.md +7 -4
  51. package/plugins/litclaude/skills/lit-pptx/references/direction-step.md +186 -0
  52. package/plugins/litclaude/skills/lit-pptx/references/examples/01-pitch-signal-ko.md +210 -0
  53. package/plugins/litclaude/skills/lit-pptx/references/examples/02-pitch-studio-ko.md +147 -0
  54. package/plugins/litclaude/skills/lit-pptx/references/examples/03-business-review-ledger-ko.md +199 -0
  55. package/plugins/litclaude/skills/lit-pptx/references/examples/04-business-review-night-en.md +203 -0
  56. package/plugins/litclaude/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +179 -0
  57. package/plugins/litclaude/skills/lit-pptx/references/examples/06-lecture-chalk-ko.md +199 -0
  58. package/plugins/litclaude/skills/lit-pptx/references/examples/07-data-review-night-en.md +194 -0
  59. package/plugins/litclaude/skills/lit-pptx/references/examples/08-image-led-atlas-ko.md +175 -0
  60. package/plugins/litclaude/skills/lit-pptx/references/examples/09-briefing-gazette-ko.md +204 -0
  61. package/plugins/litclaude/skills/lit-pptx/references/examples/10-status-update-ledger-ko.md +179 -0
  62. package/plugins/litclaude/skills/lit-pptx/references/examples/11-portfolio-studio-en.md +164 -0
  63. package/plugins/litclaude/skills/lit-pptx/references/examples/12-research-tutorial-chalk-en.md +174 -0
  64. package/plugins/litclaude/skills/lit-pptx/references/interview-protocol.md +1 -1
  65. package/plugins/litclaude/skills/lit-pptx/references/layout-families.md +198 -0
  66. package/plugins/litclaude/skills/lit-pptx/references/stages/autopilot.md +1 -1
  67. package/plugins/litclaude/skills/lit-pptx/references/stages/interview.md +2 -2
  68. package/plugins/litclaude/skills/lit-pptx/references/stages/plan.md +1 -1
  69. package/plugins/litclaude/skills/lit-pptx/references/title-treatments.md +109 -0
  70. package/plugins/litclaude/skills/lit-pptx/references/tonalities/atlas.md +139 -0
  71. package/plugins/litclaude/skills/lit-pptx/references/tonalities/chalk.md +148 -0
  72. package/plugins/litclaude/skills/lit-pptx/references/tonalities/gazette.md +149 -0
  73. package/plugins/litclaude/skills/lit-pptx/references/tonalities/ledger.md +149 -0
  74. package/plugins/litclaude/skills/lit-pptx/references/tonalities/night.md +147 -0
  75. package/plugins/litclaude/skills/lit-pptx/references/tonalities/paper.md +151 -0
  76. package/plugins/litclaude/skills/lit-pptx/references/tonalities/signal.md +150 -0
  77. package/plugins/litclaude/skills/lit-pptx/references/tonalities/studio.md +132 -0
  78. package/plugins/litclaude/skills/lit-pptx/scripts/compile-deck.js +64 -16
  79. package/plugins/litclaude/skills/lit-pptx/scripts/craft_extras.py +664 -3
  80. package/plugins/litclaude/skills/lit-pptx/scripts/deck_craft.py +48 -6
  81. package/plugins/litclaude/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  82. package/plugins/litclaude/skills/lit-pptx/scripts/lib/layout-resolver.js +362 -11
  83. package/plugins/litclaude/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  84. package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +211 -65
  85. package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-pack.js +3559 -0
  86. package/plugins/litclaude/skills/lit-pptx/scripts/lib/slide-ast.js +10 -6
  87. package/plugins/litclaude/skills/lit-pptx/scripts/lib/template-registry.js +286 -1
  88. package/plugins/litclaude/skills/lit-pptx/scripts/qa_deck.py +39 -10
  89. package/plugins/litclaude/skills/lit-pptx/scripts/validate_pptx.py +22 -7
  90. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  91. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
  92. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
  93. package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
  94. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +92 -0
  95. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +97 -0
  96. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +93 -0
  97. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +94 -0
  98. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/night/pack.yaml +93 -0
  99. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/paper/pack.yaml +98 -0
  100. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/signal/pack.yaml +94 -0
  101. package/plugins/litclaude/skills/lit-pptx/templates/tonalities/studio/pack.yaml +92 -0
@@ -16,9 +16,11 @@ const os = require("os");
16
16
  const path = require("path");
17
17
  const { runtimeRequire } = require("./runtime-require");
18
18
  const { extractContent, looksLikeKpi } = require("./layout-resolver");
19
+ const { LEGACY_TOKENS } = require("./template-registry");
19
20
 
20
- // Narrowest a KPI badge can be and still read as one glance.
21
- const KPI_MIN_CARD_WIDTH_IN = 1.9;
21
+ // Narrowest a KPI badge can be and still read as one glance (template token kpi.minCardWidth).
22
+ let KPI_MIN_CARD_WIDTH_IN = LEGACY_TOKENS.kpi.minCardWidth;
23
+ let KPI_GAP = LEGACY_TOKENS.kpi.gap;
22
24
 
23
25
  const TOOLKIT_ROOT = path.resolve(__dirname, "../..");
24
26
  const ASSETS_MEDIA = path.join(TOOLKIT_ROOT, "assets", "media");
@@ -129,43 +131,52 @@ function containBox(x, y, w, h, imageSize) {
129
131
  return { x: x + (w - fittedW) / 2, y, w: fittedW, h };
130
132
  }
131
133
 
132
- // Bullet items
133
- let BULLET_FONT = FONT_BOLD;
134
- const BULLET_SIZE = 16;
134
+ // Bullet items. Geometry comes from the template's tokens (bullet.*, secHeader.*, summary.*,
135
+ // toc.*), reassigned in render(); these defaults are the legacy values.
135
136
  let BULLET_COLOR = "1D4ED8"; // bullet color (reassigned per template)
136
137
  let SECTION_ACCENT = "1D4ED8"; // section-card / accent color (reassigned per template)
137
138
  const BULLET_PREFIX = "\u2022 "; // "• " (bullet + two spaces)
138
- const BULLET_X = 0.59;
139
- const BULLET_W = 8.8;
140
- const BULLET_H = 0.38;
141
- const BULLET_Y_SPACING = 0.42;
142
- const BULLET_LINE_SPACING = 1.25;
139
+ let BULLET_X = LEGACY_TOKENS.bullet.x;
140
+ let BULLET_W = LEGACY_TOKENS.bullet.w;
143
141
 
144
142
  // Section headers
145
143
  let SEC_HEADER_FONT = FONT_BOLD;
146
- const SEC_HEADER_SIZE = 18;
144
+ let SEC_HEADER_SIZE = LEGACY_TOKENS.secHeader.size;
147
145
  const SEC_HEADER_COLOR = "000000";
148
- const SEC_HEADER_X = 0.39;
149
- const SEC_HEADER_W = 9.0;
150
- const SEC_HEADER_H = 0.5;
146
+ let SEC_HEADER_X = LEGACY_TOKENS.secHeader.x;
147
+ let SEC_HEADER_W = LEGACY_TOKENS.secHeader.w;
148
+ let SEC_HEADER_H = LEGACY_TOKENS.secHeader.h;
151
149
 
152
150
  // Summary (narrow width)
153
- const SUMMARY_HEADER_W = 4.3;
154
- const SUMMARY_BULLET_W = 4.8;
155
- const SUMMARY_BULLET_SIZE = 14;
156
- const SUMMARY_BULLET_H = 0.34;
157
- const SUMMARY_BULLET_Y_SPACING = 0.38;
158
- const SUMMARY_BULLET_LINE_SPACING = 1.05;
151
+ let SUMMARY_HEADER_W = LEGACY_TOKENS.summary.headerW;
152
+ let SUMMARY_BULLET_W = LEGACY_TOKENS.summary.bulletW;
153
+ let SUMMARY_BULLET_SIZE = LEGACY_TOKENS.summary.bulletSize;
154
+ let SUMMARY_BULLET_H = LEGACY_TOKENS.summary.bulletH;
155
+ let SUMMARY_BULLET_Y_SPACING = LEGACY_TOKENS.summary.ySpacing;
156
+ let SUMMARY_BULLET_LINE_SPACING = LEGACY_TOKENS.summary.lineSpacing;
159
157
 
160
158
  // TOC items (separate shapes)
161
159
  let TOC_FONT = FONT_BOLD;
162
- const TOC_SIZE = 18;
160
+ let TOC_SIZE = LEGACY_TOKENS.toc.size;
163
161
  const TOC_COLOR = "000000";
164
- const TOC_X = 0.390;
165
- const TOC_W = 7.500;
166
- const TOC_H = 0.550;
167
- const TOC_Y_START = 0.957;
168
- const TOC_Y_SPACING = 0.650;
162
+ let TOC_X = LEGACY_TOKENS.toc.x;
163
+ let TOC_W = LEGACY_TOKENS.toc.w;
164
+ let TOC_H = LEGACY_TOKENS.toc.h;
165
+ let TOC_Y_START = LEGACY_TOKENS.toc.yStart;
166
+ let TOC_Y_SPACING = LEGACY_TOKENS.toc.ySpacing;
167
+
168
+ /** Reassign the token-driven geometry from the template's pack (legacy values when absent). */
169
+ function applyTokens(tokens) {
170
+ const t = tokens || LEGACY_TOKENS;
171
+ ({ x: BULLET_X, w: BULLET_W } = t.bullet);
172
+ ({ size: SEC_HEADER_SIZE, x: SEC_HEADER_X, w: SEC_HEADER_W, h: SEC_HEADER_H } = t.secHeader);
173
+ ({ headerW: SUMMARY_HEADER_W, bulletW: SUMMARY_BULLET_W, bulletSize: SUMMARY_BULLET_SIZE, bulletH: SUMMARY_BULLET_H,
174
+ ySpacing: SUMMARY_BULLET_Y_SPACING, lineSpacing: SUMMARY_BULLET_LINE_SPACING } = t.summary);
175
+ ({ size: TOC_SIZE, x: TOC_X, w: TOC_W, h: TOC_H, yStart: TOC_Y_START, ySpacing: TOC_Y_SPACING } = t.toc);
176
+ ({ pad: CARD_PAD, chip: CARD_CHIP, minH: CARD_MIN_H, gap: CARD_GAP, radius: CARD_RADIUS } = t.card);
177
+ ({ minCardWidth: KPI_MIN_CARD_WIDTH_IN, gap: KPI_GAP } = t.kpi);
178
+ NOTICE = { ...t.notice };
179
+ }
169
180
 
170
181
  function weightedTextLength(text) {
171
182
  let total = 0;
@@ -404,7 +415,7 @@ function estimateDiagnosticCardHeight(card, w) {
404
415
  return Math.max(0.56, Math.min(2.35, padY * 2 + titleH + bodyH));
405
416
  }
406
417
 
407
- function renderCramStressBody(slide, items, pageW, pageH) {
418
+ function renderCramStressBody(slide, items, pageH) {
408
419
  const cards = bodyItemsToDiagnosticCards(items);
409
420
  const columns = 3;
410
421
  const groups = cards.length ? splitCardsByWeight(cards, columns) : splitLinesByWeight(bodyItemsToDiagnosticLines(items), columns).map((group) => [{ title: "", body: group }]);
@@ -550,7 +561,7 @@ function renderCramStressSlide(slide, slideSpec, pageW, pageH, sourceDir) {
550
561
  });
551
562
  }
552
563
  if (regions.body && regions.body.content && regions.body.content.type === "body") {
553
- renderCramStressBody(slide, regions.body.content.items || [], pageW, pageH);
564
+ renderCramStressBody(slide, regions.body.content.items || [], pageH);
554
565
  }
555
566
  if (regions.image && regions.image.content) {
556
567
  renderCramStressImageSheet(slide, regions.image.content, sourceDir, pageW, pageH);
@@ -614,7 +625,7 @@ function buildFontRoles(templateObj) {
614
625
  * Add decoration elements to a pptxgenjs slide.
615
626
  * Note: slide_number is intentionally omitted — the reference template has none.
616
627
  */
617
- function addDecorations(slide, decorations, slideIdx, fontRoles) {
628
+ function addDecorations(slide, decorations, fontRoles) {
618
629
  if (!decorations || !Array.isArray(decorations)) return;
619
630
 
620
631
  for (const d of decorations) {
@@ -1113,7 +1124,7 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1113
1124
  fontSize: opts.fontSize,
1114
1125
  color: opts.color,
1115
1126
  ...(opts.charSpacing != null ? { charSpacing: opts.charSpacing } : {}),
1116
- }), opts);
1127
+ }), name === "title" ? { ...opts, objectName: "title@legacy" } : opts);
1117
1128
  return;
1118
1129
  }
1119
1130
 
@@ -1313,7 +1324,7 @@ function addRegion(slide, name, region, fontRoles, pageH, regionBottom, isToc, s
1313
1324
  }
1314
1325
  }
1315
1326
 
1316
- // ── AZURE rich rendering (isolated; only used when template.render_style==="azure") ──
1327
+ // ── Rich card, KPI and table rendering (templates with the rich-blocks capability) ──
1317
1328
 
1318
1329
  /** Split a title string into runs, emphasizing **word** in the primary color. */
1319
1330
  function azureTitleRuns(text, P) {
@@ -1331,6 +1342,7 @@ function cleanMd(s) {
1331
1342
  .replace(/^#{1,6}\s+/, "")
1332
1343
  .replace(/^\s*[*-]\s+/, "")
1333
1344
  .replace(/\*\*(.+?)\*\*/g, "$1")
1345
+ .replace(/(?<![\w*])\*([^*\s](?:[^*]*[^*\s])?)\*(?![\w*])/gu, "$1")
1334
1346
  .replace(/`/g, "")
1335
1347
  .trim();
1336
1348
  }
@@ -1344,12 +1356,15 @@ function mdRuns(text, runOpts = {}) {
1344
1356
  .replace(/^#{1,6}\s+/, "")
1345
1357
  .replace(/^\s*[*-]\s+/, "")
1346
1358
  .replace(/`/g, "");
1347
- const parts = clean.split(/(\*\*[^*]+\*\*)/g).filter((p) => p !== "");
1359
+ // **x** is bold; *x* (a journal or book name) is italic.
1360
+ const parts = clean.split(/(\*\*[^*]+\*\*|(?<![\w*])\*[^*\s](?:[^*]*[^*\s])?\*(?![\w*]))/gu).filter((p) => p !== "");
1348
1361
  if (parts.length === 0) return [{ text: "", options: { ...runOpts } }];
1349
1362
  return parts.map((p) =>
1350
1363
  p.startsWith("**") && p.endsWith("**")
1351
1364
  ? { text: p.slice(2, -2), options: { ...runOpts, bold: true } }
1352
- : { text: p, options: { ...runOpts } }
1365
+ : p.length > 2 && p.startsWith("*") && p.endsWith("*")
1366
+ ? { text: p.slice(1, -1), options: { ...runOpts, italic: true } }
1367
+ : { text: p, options: { ...runOpts } }
1353
1368
  );
1354
1369
  }
1355
1370
 
@@ -1384,10 +1399,13 @@ function bodyToCards(items) {
1384
1399
  return cards.slice(0, 4);
1385
1400
  }
1386
1401
 
1387
- // Card geometry shared by drawing and sizing, so a card is exactly as tall as its text.
1388
- const CARD_PAD = 0.30;
1389
- const CARD_CHIP = 0.52;
1390
- const CARD_MIN_H = 1.6;
1402
+ // Card geometry shared by drawing and sizing, so a card is exactly as tall as its text
1403
+ // (template tokens card.*; reassigned in render()).
1404
+ let CARD_PAD = LEGACY_TOKENS.card.pad;
1405
+ let CARD_CHIP = LEGACY_TOKENS.card.chip;
1406
+ let CARD_MIN_H = LEGACY_TOKENS.card.minH;
1407
+ let CARD_GAP = LEGACY_TOKENS.card.gap;
1408
+ let CARD_RADIUS = LEGACY_TOKENS.card.radius;
1391
1409
 
1392
1410
  // Primary reading text stays at or above the 12 pt floor of the design laws.
1393
1411
  function cardBodySize(card) {
@@ -1433,7 +1451,7 @@ function drawAzureCard(slide, x, y, w, h, card, idx, P, emphasized) {
1433
1451
  const bg = emphasized ? hex(P.primary_deep) : hex(P.tint);
1434
1452
  const headColor = emphasized ? "FFFFFF" : hex(P.ink);
1435
1453
  const bodyColor = emphasized ? hex(P.emphasisTextColor) : hex(P.ink_muted);
1436
- slide.addShape("roundRect", { x, y, w, h, rectRadius: 0.16, fill: { color: bg }, line: { type: "none" } });
1454
+ slide.addShape("roundRect", { x, y, w, h, rectRadius: CARD_RADIUS, fill: { color: bg }, line: { type: "none" } });
1437
1455
  const pad = CARD_PAD;
1438
1456
  const innerW = w - 2 * pad;
1439
1457
  // numbered chip
@@ -1482,7 +1500,7 @@ function renderAzureCards(slide, items, region, P) {
1482
1500
  if (!cards.length) return;
1483
1501
  const n = cards.length;
1484
1502
  const x0 = region.x || 0.6, y0 = region.y || 1.6, w = region.w || 12.1;
1485
- const gap = 0.28;
1503
+ const gap = CARD_GAP;
1486
1504
  const cardW = (w - (n - 1) * gap) / n;
1487
1505
  const h = fittedCardHeight(cards, cardW, region.h || 3.0);
1488
1506
  cards.forEach((c, i) => {
@@ -1569,13 +1587,13 @@ function renderAzureGroupCard(slide, content, region, P, idx, height) {
1569
1587
  // ── Tables and charts that fill their region ────────────────────────────────
1570
1588
 
1571
1589
  // A cell reads as a number when, after units and signs, only digits remain.
1572
- const NUMERIC_CELL = /^[+\-−±▲▼△▽]?\s*[\d.,]+\s*(?:%p?|배|x|pt|bp|[가-힣]{1,3}|[A-Za-z]{1,3})?$/u;
1590
+ const NUMERIC_CELL = /^(?:[▲▼△▽]\s*)?[+\-−±]?\s*[\d.,]+\s*(?:%p?|배|x|pt|bp|[가-힣]{1,3}|[A-Za-z]{1,3})?$/u;
1573
1591
  const isNumericCell = (text) => NUMERIC_CELL.test(cleanMd(String(text)).trim());
1574
1592
 
1575
- /** Columns whose data cells are all numbers are right-aligned. */
1593
+ /** Columns whose data cells are all numbers are right-aligned; a dash for a missing value is blank. */
1576
1594
  function numericColumns(headers, rows) {
1577
1595
  return headers.map((_, ci) => {
1578
- const cells = rows.map((r) => r[ci]).filter((c) => c != null && String(c).trim() !== "");
1596
+ const cells = rows.map((r) => r[ci]).filter((c) => c != null && !/^[\s—–\-]*$/u.test(cleanMd(String(c))));
1579
1597
  return cells.length > 0 && cells.every(isNumericCell);
1580
1598
  });
1581
1599
  }
@@ -1650,13 +1668,47 @@ function renderChart(slide, content, region, style, bottom) {
1650
1668
  const series = headers.slice(1).map((name, si) => ({
1651
1669
  name: cleanMd(String(name)),
1652
1670
  labels,
1653
- values: rows.map((r) => chartNumber(r[si + 1]) ?? 0),
1671
+ // A missing cell stays null: pptxgenjs writes it as a blank point, and the line breaks there instead of dropping to zero.
1672
+ values: rows.map((r) => chartNumber(r[si + 1])),
1654
1673
  }));
1674
+ // Data labels keep the decimals the table writes: "18.0" stays 18.0 beside a table that says 18.0.
1675
+ 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))));
1655
1676
  const x = region.x || 0.6, y = region.y || 1.6, w = region.w || 8;
1656
1677
  const h = Math.max(1.6, ((bottom || y + (region.h || 3.2)) - y));
1657
1678
  const colors = style.series.map((c) => hex(c));
1658
1679
  const round = kind === "pie" || kind === "doughnut";
1659
1680
  const data = round ? series.slice(0, 1) : series;
1681
+ // Crowding, estimated in points: a category label wider than its slot is slanted; side-by-side
1682
+ // value labels wider than their bars collide, so the values are read off a shown axis instead.
1683
+ 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);
1684
+ const labelPt = style.labelSize || 11;
1685
+ const slot = (w * 72 * 0.85) / Math.max(1, labels.length);
1686
+ // A renderer adds space between Hangul and digits ("25 년 2Q"), so a label needs a quarter of its slot spare.
1687
+ // A horizontal bar chart lists its categories down the left side; slanted there, LibreOffice skips some of them.
1688
+ const slanted = !round && kind !== "line" && spec.type !== "bar" && Math.max(0, ...labels.map(ems)) * labelPt > slot * 0.75;
1689
+ const clustered = kind === "bar" && spec.type !== "stacked" && data.length > 1;
1690
+ const valueW = Math.max(0, ...data.flatMap((d) => d.values.map((v) => ems(Number(v).toLocaleString("en-US"))))) * labelPt;
1691
+ // Several lines share one band of the plot, so a value printed over each point lands on its neighbours'.
1692
+ const crowded = !round && ((clustered && valueW > (slot / 1.6 / data.length) * 1.1) || (kind === "line" && data.length > 1));
1693
+ const values = data.flatMap((d) => d.values).filter((v) => v !== null);
1694
+ // Horizontal bars of several series stack thin bars down each row; once a bar is thinner than its value label, the labels touch.
1695
+ const horizontal = !round && spec.type === "bar";
1696
+ const dataPt = style.labelSize || (round ? 12 : 10);
1697
+ const barPt = (h * 72 - 54) / Math.max(1, labels.length) / (data.length + 0.6);
1698
+ const thin = horizontal && data.length > 1 && spec.labels !== false && barPt < dataPt * 1.2;
1699
+ // A narrow horizontal bar chart gets few axis numbers, each with room to stand level; left to itself, LibreOffice slants a crowded value axis.
1700
+ const top = Math.max(0, ...values), low = Math.min(0, ...values);
1701
+ const tickW = ems(Math.round(Math.max(top, -low)).toLocaleString("en-US")) * dataPt;
1702
+ const catW = Math.min(w * 72 * 0.4, Math.max(0, ...labels.map(ems)) * labelPt + 8);
1703
+ const ticks = Math.floor((w * 72 - catW - 16) / (tickW * 2));
1704
+ const nice = (step) => { const p = 10 ** Math.floor(Math.log10(step)); return [1, 2, 2.5, 5, 10].find((m) => m * p >= step) * p; };
1705
+ const majorUnit = horizontal && top > low && ticks < 6 ? nice((top - low) / Math.max(1, ticks - 1)) : null;
1706
+ const zeroBased = !round && values.length > 0 && values.every((v) => v >= 0);
1707
+ // The unit stands once: a legend whose series names carry it already says it, so the axis title goes;
1708
+ // otherwise it is set level (a rotated title stacks Hangul a glyph a line and reads tiny; 360 degrees, since
1709
+ // pptxgenjs writes no angle for 0 and the axis default is vertical). Amendments 8.
1710
+ const unitShown = spec.unit && data.length > 1 && data.every((d) => String(d.name || "").includes(spec.unit));
1711
+ const axisTitle = spec.unit && !unitShown && !(Boolean(style.valAxisHidden) && !crowded && !thin);
1660
1712
  slide.addChart(kind, data, {
1661
1713
  x, y, w, h,
1662
1714
  barDir: spec.type === "bar" ? "bar" : "col",
@@ -1666,22 +1718,31 @@ function renderChart(slide, content, region, style, bottom) {
1666
1718
  showLegend: round || data.length > 1,
1667
1719
  legendPos: round ? "r" : "t",
1668
1720
  legendFontFace: style.bodyFont, legendFontSize: 11, legendColor: hex(style.ink),
1669
- showValue: !round && spec.labels !== false,
1721
+ showValue: !round && spec.labels !== false && !crowded && !thin,
1670
1722
  showPercent: round && spec.labels !== false, showLabel: false,
1671
- dataLabelFontFace: style.bodyFont, dataLabelFontSize: round ? 12 : 10,
1723
+ dataLabelFontFace: style.bodyFont, dataLabelFontSize: style.labelSize || (round ? 12 : 10),
1672
1724
  dataLabelColor: round ? "FFFFFF" : hex(style.ink),
1673
1725
  dataLabelPosition: round ? "ctr" : kind === "line" ? "t" : "outEnd",
1674
- dataLabelFormatCode: round ? "0%" : "#,##0.##",
1675
- catAxisLabelFontFace: style.bodyFont, catAxisLabelFontSize: 11, catAxisLabelColor: hex(style.ink),
1676
- valAxisLabelFontFace: style.bodyFont, valAxisLabelFontSize: 10, valAxisLabelColor: hex(style.ink_muted),
1726
+ dataLabelFormatCode: round ? "0%" : decimals ? `#,##0.${"0".repeat(decimals)}` : "#,##0",
1727
+ catAxisLabelFontFace: style.bodyFont, catAxisLabelFontSize: style.labelSize || 11, catAxisLabelColor: hex(style.ink),
1728
+ valAxisLabelFontFace: style.bodyFont, valAxisLabelFontSize: style.labelSize || 10, valAxisLabelColor: hex(style.ink_muted),
1677
1729
  valAxisLabelFormatCode: "#,##0",
1678
- valAxisHidden: false,
1730
+ // A pack with direct labels and no gridlines drops the value axis: the labels carry the values.
1731
+ valAxisHidden: Boolean(style.valAxisHidden) && !crowded && !thin,
1732
+ ...(majorUnit ? { valAxisMajorUnit: majorUnit } : {}),
1733
+ // With negative values the zero line runs through the plot, so the category labels move to the axis minimum, off the bars.
1734
+ ...(horizontal && low < 0 ? { catAxisLabelPos: "low" } : {}),
1735
+ // Blank points leave a gap, never a joined or zero point.
1736
+ displayBlanksAs: "gap",
1737
+ ...(style.valMax ? { valAxisMaxVal: style.valMax } : {}),
1738
+ ...(zeroBased ? { valAxisMinVal: 0 } : {}),
1739
+ ...(slanted ? { catAxisLabelRotate: -45 } : {}),
1679
1740
  catAxisLineShow: true, catAxisLineColor: hex(style.line),
1680
- valGridLine: { color: hex(style.line), size: 0.5 },
1741
+ valGridLine: style.grid === false && !crowded && !thin ? { style: "none" } : { color: hex(style.line), size: 0.5 },
1681
1742
  valAxisLineShow: false,
1682
1743
  lineSize: 2.5, lineDataSymbol: "circle", lineDataSymbolSize: 7,
1683
1744
  holeSize: 58,
1684
- ...(spec.unit ? { showValAxisTitle: true, valAxisTitle: spec.unit, valAxisTitleFontSize: 10, valAxisTitleColor: hex(style.ink_muted), valAxisTitleFontFace: style.bodyFont } : {}),
1745
+ ...(axisTitle ? { showValAxisTitle: true, valAxisTitle: spec.unit, valAxisTitleRotate: 360, valAxisTitleFontSize: style.labelSize || 10, valAxisTitleColor: hex(style.ink_muted), valAxisTitleFontFace: style.bodyFont } : {}),
1685
1746
  ...(spec.title ? { showTitle: true, title: spec.title, titleFontFace: style.titleFont, titleFontSize: 13, titleColor: hex(style.ink) } : {}),
1686
1747
  });
1687
1748
  }
@@ -1706,7 +1767,7 @@ function renderAzureKpi(slide, content, region, P) {
1706
1767
  const n = Math.min(values.length, 4);
1707
1768
  if (!n) return;
1708
1769
  const x0 = region.x || 0.6, y0 = region.y || 1.6, w = region.w || 12.1, h = region.h || 1.7;
1709
- const gap = 0.26;
1770
+ const gap = KPI_GAP;
1710
1771
  const cardW = (w - (n - 1) * gap) / n;
1711
1772
  for (let i = 0; i < n; i++) {
1712
1773
  const fill = i === 0;
@@ -1757,7 +1818,9 @@ function kpiCards(content) {
1757
1818
  return [headers, ...rows].slice(0, 4).map((p) => ({ value: p[0], label: p[1] }));
1758
1819
  }
1759
1820
  const row = rows[0] || [];
1760
- return headers.slice(0, 4).map((lab, i) => ({ value: row[i] != null ? row[i] : "", label: lab }));
1821
+ // A second body row is each figure's basis or comparison ("전년 대비 +12%", "목표 50%").
1822
+ const basis = rows[1] || [];
1823
+ return headers.slice(0, 6).map((lab, i) => ({ value: row[i] != null ? row[i] : "", label: lab, ...(basis[i] ? { note: basis[i] } : {}) }));
1761
1824
  }
1762
1825
 
1763
1826
  /** Azure region dispatch. Returns true if it fully handled the region.
@@ -1775,6 +1838,7 @@ function renderAzureRegion(slide, layout, name, region, fontRoles, P, opts = {})
1775
1838
  if (layout === "cover" && name === "title" && typeof content === "string" && content.includes("**")) {
1776
1839
  const role = fontRoles.cover_title || {};
1777
1840
  slide.addText(azureTitleRuns(content, P), {
1841
+ objectName: "title@legacy",
1778
1842
  x: region.x || 0, y: region.y || 0, w: region.w || 8, h: region.h || 1,
1779
1843
  fontFace: role.font || P.titleFont, fontSize: role.size || 48, bold: role.bold !== false,
1780
1844
  align: role.align || "left", valign: "middle",
@@ -1930,7 +1994,7 @@ function renderPlacements(slide, placements, band, ctx) {
1930
1994
  for (const placement of placements) {
1931
1995
  if (placement.z !== band) continue;
1932
1996
  if (placement.kind === "shape") {
1933
- addDecorations(slide, [shapeToDecoration(placement, ctx.sourceDir)], ctx.slideIndex, ctx.fontRoles);
1997
+ addDecorations(slide, [shapeToDecoration(placement, ctx.sourceDir)], ctx.fontRoles);
1934
1998
  } else {
1935
1999
  renderPlacementBox(slide, placement, ctx);
1936
2000
  }
@@ -1956,14 +2020,15 @@ function renderPlacements(slide, placements, band, ctx) {
1956
2020
  */
1957
2021
  const NOTICE_FILL = "FDECEA";
1958
2022
  const NOTICE_INK = "A21B12";
2023
+ let NOTICE = { ...LEGACY_TOKENS.notice };
1959
2024
 
1960
2025
  function addNotice(slide, text, pageW, pageH, pal) {
1961
2026
  const label = cleanMd(text);
1962
- const fontSize = 10.5;
2027
+ const fontSize = NOTICE.size;
1963
2028
  const textW = (weightedTextLength(label) * fontSize * 0.98) / 72;
1964
2029
  const w = Math.min(pageW * 0.55, textW + 0.46);
1965
- const h = 0.3;
1966
- const x = 0.45, y = pageH - h - 0.14;
2030
+ const h = NOTICE.h;
2031
+ const x = NOTICE.x, y = pageH - h - NOTICE.bottom;
1967
2032
  const fill = ((pal && pal.notice_fill) || NOTICE_FILL).replace("#", "");
1968
2033
  const ink = ((pal && pal.notice_ink) || NOTICE_INK).replace("#", "");
1969
2034
  slide.addShape("roundRect", { x, y, w, h, rectRadius: 0.15, fill: { color: fill }, line: { type: "none" }, objectName: "lit-notice tag" });
@@ -1975,7 +2040,34 @@ function addNotice(slide, text, pageW, pageH, pal) {
1975
2040
  });
1976
2041
  }
1977
2042
 
2043
+ /**
2044
+ * Give the first object drawn on a slide that has no name of its own the name `family@<id>`, so a
2045
+ * checker can read the layout family the source asked for and compare it with what it measures.
2046
+ */
2047
+ function stampFamily(slide, family) {
2048
+ const at = { addText: 1, addShape: 1, addImage: 0, addTable: 1, addChart: 2 };
2049
+ const originals = {};
2050
+ let done = false;
2051
+ const restore = () => { for (const m of Object.keys(originals)) slide[m] = originals[m]; };
2052
+ for (const [method, index] of Object.entries(at)) {
2053
+ originals[method] = slide[method];
2054
+ slide[method] = (...args) => {
2055
+ if (!(args[index] && args[index].objectName)) {
2056
+ restore();
2057
+ done = true;
2058
+ args[index] = { ...(args[index] || {}), objectName: `family@${family}` };
2059
+ }
2060
+ return originals[method].apply(slide, args);
2061
+ };
2062
+ }
2063
+ /** Whether a shape took the name; unwraps the slide either way. */
2064
+ return () => { restore(); return done; };
2065
+ }
2066
+
1978
2067
  async function render(resolved, templateObj, outputPath) {
2068
+ const pack = (templateObj && templateObj.pack) || null;
2069
+ if (pack && !pack.legacy) return require("./render-pack").renderPack(resolved, templateObj, outputPath);
2070
+ applyTokens(pack && pack.tokens);
1979
2071
  const fontRoles = buildFontRoles(templateObj);
1980
2072
 
1981
2073
  // Brand-neutralize: derive font/spacing/accent from the template, falling back
@@ -1987,15 +2079,14 @@ async function render(resolved, templateObj, outputPath) {
1987
2079
  FONT_MEDIUM = tplFonts.body || "Pretendard";
1988
2080
  FONT_LIGHT = tplFonts.light || "Pretendard";
1989
2081
  CHAR_SPACING = tplType.char_spacing != null ? tplType.char_spacing : -0.7;
1990
- BULLET_FONT = FONT_BOLD;
1991
2082
  SEC_HEADER_FONT = FONT_BOLD;
1992
2083
  TOC_FONT = FONT_BOLD;
1993
2084
  BULLET_COLOR = (tplType.bullet_color || "1D4ED8").replace("#", "");
1994
2085
  SECTION_ACCENT = (tplType.section_accent || "1D4ED8").replace("#", "");
1995
2086
 
1996
- // Rich card/KPI/divider rendering — palette-driven, shared by render_style
1997
- // "azure". Tokens come from the template palette.
1998
- const rich = tpl.render_style === "azure";
2087
+ // Rich card/KPI/divider rendering — palette-driven, for templates with the rich-blocks
2088
+ // capability. Tokens come from the template palette.
2089
+ const rich = Boolean(pack && (pack.capabilities || []).includes("rich-blocks"));
1999
2090
  const pal = tpl.palette || {};
2000
2091
  PLAIN_CHART_STYLE = {
2001
2092
  series: [pal.ink || "#3C3836", pal.accent_green_2 || pal.primary || "#689D6A", pal.accent_amber || "#D79921", pal.accent_purple || "#B16286"],
@@ -2030,17 +2121,17 @@ async function render(resolved, templateObj, outputPath) {
2030
2121
 
2031
2122
  for (const slideSpec of resolved.slides || []) {
2032
2123
  const slide = pptx.addSlide();
2124
+ stampFamily(slide, slideSpec.family || slideSpec.layout);
2033
2125
 
2034
2126
  // Draw order is a band at a time: template decorations, then anything the
2035
2127
  // author put under the content, then the template's regions, then the
2036
2128
  // columns that replaced the body, then anything sitting on top.
2037
- addDecorations(slide, slideSpec.decorations, slideSpec.index, fontRoles);
2129
+ addDecorations(slide, slideSpec.decorations, fontRoles);
2038
2130
 
2039
2131
  const placements = slideSpec.placements || [];
2040
2132
  const placementCtx = {
2041
2133
  rich, P, fontRoles, pageH, sourceDir,
2042
2134
  layout: slideSpec.layout,
2043
- slideIndex: slideSpec.index,
2044
2135
  };
2045
2136
  renderPlacements(slide, placements, "under", placementCtx);
2046
2137
 
@@ -2087,8 +2178,63 @@ async function render(resolved, templateObj, outputPath) {
2087
2178
  if (notice) addNotice(slide, notice, pageW, pageH, pal);
2088
2179
  }
2089
2180
 
2090
- await pptx.writeFile({ fileName: outputPath });
2181
+ await writeTagged(pptx, outputPath);
2091
2182
  return outputPath;
2092
2183
  }
2093
2184
 
2094
- module.exports = { render };
2185
+ const HANGUL = /[\u1100-\u11FF\u3130-\u318F\uAC00-\uD7A3]/u;
2186
+
2187
+ /**
2188
+ * Korean text runs carry their language: pptxgenjs writes lang="en-US" on every run, and a renderer then
2189
+ * applies Latin rules to Hangul (LibreOffice on a host whose system locale is not Korean adds space
2190
+ * between Hangul and digits: "주 2일" set as "주 2 일"). A run that holds Hangul is ko-KR with en-US as its
2191
+ * alternate language; a chart that holds Hangul names ko-KR for its text. Latin-only runs keep en-US.
2192
+ */
2193
+ function tagLanguages(name, xml) {
2194
+ if (/^ppt\/slides\/slide\d+\.xml$/u.test(name)) {
2195
+ return xml.replace(/<a:r><a:rPr\b([^>]*?)(\/?>)([\s\S]*?)<a:t>([^<]*)<\/a:t><\/a:r>/gu, (m, attrs, close, mid, text) => {
2196
+ if (!HANGUL.test(text)) return m;
2197
+ const rest = attrs.replace(/\s(?:lang|altLang)="[^"]*"/gu, "");
2198
+ return `<a:r><a:rPr lang="ko-KR" altLang="en-US"${rest}${close}${mid}<a:t>${text}</a:t></a:r>`;
2199
+ });
2200
+ }
2201
+ if (/^ppt\/charts\/chart\d+\.xml$/u.test(name) && HANGUL.test(xml)) {
2202
+ let out = xml.replace(/<a:defRPr\b(?![^>]*\blang=)/gu, '<a:defRPr lang="ko-KR" altLang="en-US"')
2203
+ .replace(/<a:endParaRPr lang="en-US"/gu, '<a:endParaRPr lang="ko-KR" altLang="en-US"');
2204
+ if (!/<c:lang\b/u.test(out)) out = out.replace(/(<c:date1904 val="[01]"\/>)/u, '$1<c:lang val="ko-KR"/>');
2205
+ return out;
2206
+ }
2207
+ return xml;
2208
+ }
2209
+
2210
+ /** Write a deck with its Korean runs and charts tagged (see tagLanguages); `edit` may rewrite a part first. */
2211
+ async function writeTagged(pptx, outputPath, edit = (name, xml) => xml) {
2212
+ const JSZip = runtimeRequire("jszip");
2213
+ const zip = await JSZip.loadAsync(await pptx.write({ outputType: "nodebuffer" }));
2214
+ for (const name of Object.keys(zip.files).filter((n) => /^ppt\/(slides\/slide|charts\/chart|theme\/theme)\d+\.xml$/u.test(n))) {
2215
+ const xml = await zip.file(name).async("string");
2216
+ zip.file(name, tagLanguages(name, edit(name, xml)));
2217
+ }
2218
+ fs.writeFileSync(outputPath, await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" }));
2219
+ }
2220
+
2221
+ // ── Tonality packs ──────────────────────────────────────────────────────────
2222
+ //
2223
+ // A pack deck is drawn by render-pack.js. It reuses the helpers below, and the font globals the
2224
+ // free placement boxes read are switched to the pack's faces before it draws.
2225
+
2226
+ function usePackFonts(titleFont, bodyFont, tokens) {
2227
+ FONT_BOLD = titleFont;
2228
+ FONT_MEDIUM = bodyFont;
2229
+ FONT_LIGHT = bodyFont;
2230
+ CHAR_SPACING = 0;
2231
+ applyTokens(tokens);
2232
+ }
2233
+
2234
+ module.exports = {
2235
+ render,
2236
+ shared: {
2237
+ hex, cleanMd, mdRuns, linkedTableCell, resolveImagePath, readImageSize, containBox,
2238
+ kpiCards, numericColumns, proportionalWidths, renderChart, stampFamily, renderPlacements, usePackFonts, writeTagged,
2239
+ },
2240
+ };