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