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