@litfamily/lithermes 1.0.17 → 1.0.19
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/README.md +21 -67
- package/README_Ko-KR.md +20 -65
- package/assets/lithermes-plugin/auto_update.py +4 -1
- package/assets/lithermes-plugin/core_routing.py +7 -2
- package/assets/lithermes-plugin/payload-version.json +42 -38
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -2
- package/assets/lithermes-plugin/skills/lit-docx/references/page-composition.md +1 -3
- package/assets/lithermes-plugin/skills/lit-docx/scripts/convert_md_to_docx.py +9 -9
- package/assets/lithermes-plugin/skills/lit-docx/scripts/docx_design.py +9 -9
- package/assets/lithermes-plugin/skills/lit-docx/scripts/docx_layout.py +6 -6
- package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/brief.yaml +0 -1
- package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/journal.yaml +0 -1
- package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/manual.yaml +0 -1
- package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/memo.yaml +0 -1
- package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/proposal.yaml +0 -1
- package/assets/lithermes-plugin/skills/lit-docx/templates/tonalities/report.yaml +0 -1
- package/assets/lithermes-plugin/skills/lit-pptx/FORBIDDEN_TERMS.json +55 -0
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/atlas.md +2 -2
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/chalk.md +2 -3
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/gazette.md +2 -2
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/ledger.md +5 -5
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/night.md +3 -3
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/paper.md +2 -2
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/signal.md +3 -3
- package/assets/lithermes-plugin/skills/lit-pptx/references/tonalities/studio.md +2 -3
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/deck_output.py +8 -8
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/render-pack.js +96 -96
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/lib/template-registry.js +1 -1
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/qa_deck.py +7 -7
- package/assets/lithermes-plugin/skills/lit-pptx/scripts/validate_pptx.py +8 -3
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +0 -13
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +0 -13
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +0 -13
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +2 -15
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +0 -13
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +0 -13
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +0 -13
- package/assets/lithermes-plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +0 -13
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +2 -2
- package/package.json +19 -3
- package/readme-assets/badge-version.svg +1 -1
- package/src/cli.js +164 -34
- package/src/lib/check.js +13 -1
- package/src/lib/config.js +94 -36
- package/src/lib/hud.js +33 -2
- package/src/lib/install.js +39 -1
- package/src/lib/skins.js +29 -9
- package/readme-assets/ascii-readme.svg +0 -5
|
@@ -55,7 +55,7 @@ function titleRuns(text, S, base) {
|
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/** Breaks display text at word boundaries into lines of near-equal width, at the frame's measured line count. */
|
|
59
59
|
function balanced(S, text, w, size, role, room = Infinity) {
|
|
60
60
|
const s = String(text || "");
|
|
61
61
|
if (/\*\*/u.test(s)) return s;
|
|
@@ -205,7 +205,7 @@ function bodySize(S, state, role = "body") {
|
|
|
205
205
|
return state.stepped ? S.sizes[G.stepRole(role)] : S.sizes[role];
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
/** A point that opens with a bold run-in label reads "label */
|
|
208
|
+
/** A point that opens with a bold run-in label reads "label: rest"; a bold line alone is a heading. */
|
|
209
209
|
function runIn(text) {
|
|
210
210
|
const m = /^\*\*(.+?)\*\*\s+(\S[\s\S]*)$/u.exec(String(text == null ? "" : text));
|
|
211
211
|
if (!m || /[::.)\]?!–—-]$/u.test(m[1].trim())) return text;
|
|
@@ -245,7 +245,7 @@ function measureTable(item, w, S, state) {
|
|
|
245
245
|
const widths = headers.length ? shared().proportionalWidths(headers, rows, IN(w)).map((v) => v * 72) : [w];
|
|
246
246
|
const cell = 6;
|
|
247
247
|
const minRow = S.tok.rowMin * (state.anchor ? 1.25 : 1);
|
|
248
|
-
// Rows are measured at the face's natural line (about 1.45 of the size with its gap),
|
|
248
|
+
// Rows are measured at the face's natural line (about 1.45 of the size with its gap), as office renderers set cells.
|
|
249
249
|
const rowH = (cells, size) => Math.max(minRow, ...cells.map((t, i) => G.textHeight(plainText(t), (widths[i] || w) - cell * 2, size, 1.45) + cell * 2));
|
|
250
250
|
const heights = [rowH(headers, header), ...rows.map((r) => rowH(r, data))];
|
|
251
251
|
const tableH = heights.reduce((a, b) => a + b, 0);
|
|
@@ -370,7 +370,7 @@ function shiftGroup(p, dy) {
|
|
|
370
370
|
}
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
/** Pitch for n rows that share a region */
|
|
373
|
+
/** Pitch for n rows that share a region; the last row ends on the region floor. */
|
|
374
374
|
function rowPitch(n, rowH, R, gap, max) {
|
|
375
375
|
if (n <= 1) return rowH;
|
|
376
376
|
return Math.max(rowH + gap, Math.min(max, (R.bottom - R.y - rowH) / (n - 1)));
|
|
@@ -382,21 +382,21 @@ function textAt(paras, R, S, state, o = {}) {
|
|
|
382
382
|
return { ...m, x: R.x, y: R.y, limit: R.bottom };
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
-
// ── Layout families ───────────────────────────────────────────────────────── Each plan gets ctx
|
|
385
|
+
// ── Layout families ───────────────────────────────────────────────────────── Each plan gets ctx and returns placements.
|
|
386
386
|
|
|
387
387
|
/** A visual with its takeaway: beside it (share of the columns), or under it on a narrow body. */
|
|
388
388
|
function visualWithText(ctx, visuals, texts, share, o = {}) {
|
|
389
389
|
const { S, state, Z } = ctx;
|
|
390
390
|
const out = [];
|
|
391
|
-
// A visual alone (its takeaways in the rail, or none)
|
|
391
|
+
// A visual alone (its takeaways in the rail, or none) takes the body: a table opens its rows.
|
|
392
392
|
if (!texts.length) return ((S.tok.tight && !ctx.railValues && valuesUnder(visuals, Z, S, state)) || openRows(stack(visuals, Z, S, state).placed, Z, S));
|
|
393
393
|
if (!visuals.length) return stack(texts, Z, S, state).placed;
|
|
394
|
-
// Takeaways too short for their column give the visual columns
|
|
394
|
+
// Takeaways too short for their column give the visual columns.
|
|
395
395
|
if (!o.fixed && Z.b - Z.a + 1 >= 6 && visuals.some((v) => v.type === "image" || (v.content || {}).chart)) {
|
|
396
396
|
const n = Z.b - Z.a + 1;
|
|
397
397
|
const base = visualWithText(ctx, visuals, texts, share, { ...o, fixed: true });
|
|
398
398
|
let pick = base;
|
|
399
|
-
// A picture drawn short of its column
|
|
399
|
+
// A picture drawn short of its column takes columns while either side leaves over a fifth of the body empty.
|
|
400
400
|
const target = visuals.some((v) => v.type === "image") ? 0.2 : RAIL_EMPTY;
|
|
401
401
|
for (let step = 1; shortColumn(pick, Z.bottom, S).share > target; step++) {
|
|
402
402
|
const k = o.textFirst ? share - step / n : share + step / n;
|
|
@@ -407,7 +407,7 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
|
|
|
407
407
|
const size = (plan) => Math.max(0, ...plan.filter((q) => q.kind === "text" && !q.strip && !q.muted).map((q) => q.size || 0));
|
|
408
408
|
if (!overflows(next) && size(next) >= size(base) && shortColumn(next, Z.bottom, S).share < shortColumn(pick, Z.bottom, S).share) pick = next;
|
|
409
409
|
}
|
|
410
|
-
// Takeaways too few for
|
|
410
|
+
// Takeaways too few for a three-column column beside a chart run under it; the chart takes the full width.
|
|
411
411
|
if (shortColumn(pick, Z.bottom, S).share > RAIL_EMPTY && visuals.some((v) => (v.content || {}).chart)) {
|
|
412
412
|
const paras = texts.flatMap(itemParas);
|
|
413
413
|
const half = Math.ceil(paras.length / 2);
|
|
@@ -431,7 +431,7 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
|
|
|
431
431
|
let v = stack(visuals, vR, S, state);
|
|
432
432
|
const col = takeawayColumn(ctx, texts, visuals, full, v.bottom);
|
|
433
433
|
const t = col.t;
|
|
434
|
-
// When the chart's values
|
|
434
|
+
// When the chart's values find no room beside it, they stand under the chart as a compact table.
|
|
435
435
|
if (S.tok.tight && !col.values) {
|
|
436
436
|
const under = valuesUnder(visuals, vR, S, state, true);
|
|
437
437
|
if (under) v = under;
|
|
@@ -449,7 +449,7 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
|
|
|
449
449
|
: stack(texts, below, S, state).placed;
|
|
450
450
|
const end = Math.max(...notes.map((q) => q.y + q.h));
|
|
451
451
|
if (end <= Z.bottom) {
|
|
452
|
-
// Still short of the density band
|
|
452
|
+
// Still short of the density band, the table's rows open up first.
|
|
453
453
|
let room = Z.bottom - end;
|
|
454
454
|
let grow = 0;
|
|
455
455
|
const table = top.placed.find((q) => q.kind === "table");
|
|
@@ -466,7 +466,7 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
|
|
|
466
466
|
return [...top.placed, ...notes.map((q) => ({ ...q, y: q.y + dy }))];
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
|
-
// A picture
|
|
469
|
+
// A picture shorter than its box stands on the box floor when the takeaways beside it run past its top.
|
|
470
470
|
for (const q of v.placed) if (q.kind === "image") q.beside = t.bottom;
|
|
471
471
|
out.push(...v.placed, ...t.placed);
|
|
472
472
|
if (S.deco.has("column-hairlines")) out.push(columnHairline(S, o.textFirst ? right : right, Z));
|
|
@@ -475,7 +475,7 @@ function visualWithText(ctx, visuals, texts, share, o = {}) {
|
|
|
475
475
|
return stack([...visuals, ...texts], Z, S, state).placed;
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
-
/** A chart over its values as a compact table in region R */
|
|
478
|
+
/** A chart over its values as a compact table in region R; returns the placements or null. */
|
|
479
479
|
function valuesUnder(visuals, R, S, state, asStack = false) {
|
|
480
480
|
// Across the body the values run across (two or three rows under a wide chart); in a column, as columns.
|
|
481
481
|
const order = R.w >= S.grid.span(1, 8).w ? [chartTable(visuals, true), chartTable(visuals)] : [chartTable(visuals), chartTable(visuals, true)];
|
|
@@ -510,7 +510,7 @@ function takeawayColumn(ctx, texts, visuals, full, visualBottom) {
|
|
|
510
510
|
return { ...pick, strip, R };
|
|
511
511
|
}
|
|
512
512
|
|
|
513
|
-
/** A table
|
|
513
|
+
/** A table alone in its region opens its rows while the region leaves more than the density band empty. */
|
|
514
514
|
function openRows(placed, Z, S) {
|
|
515
515
|
const tables = placed.filter((q) => q.kind === "table");
|
|
516
516
|
if (tables.length !== 1 || placed.some((q) => q.flex)) return placed;
|
|
@@ -562,9 +562,9 @@ const PLANS = {
|
|
|
562
562
|
const { visuals, texts } = sortItems(items);
|
|
563
563
|
if (visuals.length && texts.length) return visualWithText(ctx, visuals, texts, 7 / 12);
|
|
564
564
|
const placed = stack(items, R, S, state).placed;
|
|
565
|
-
// Two to five
|
|
565
|
+
// Two to five bold-headed points that leave the body more than the density band empty read as rows.
|
|
566
566
|
const ents = !visuals.length && ctx.cls !== "side" ? entries(texts.flatMap(itemParas)) : [];
|
|
567
|
-
// Groups of sub-points under bold heads, held to one reading measure
|
|
567
|
+
// Groups of sub-points under bold heads, held to one reading measure, run in two columns instead.
|
|
568
568
|
const groups = ents.length >= 2 && ents.every((e) => e.head && !e.rest && e.children.length);
|
|
569
569
|
if (groups && R.w < Z.w - S.grid.pitch) {
|
|
570
570
|
const two = PLANS["text-two-column"](ctx);
|
|
@@ -602,7 +602,7 @@ const PLANS = {
|
|
|
602
602
|
acc += heights[i];
|
|
603
603
|
}
|
|
604
604
|
let out = [textAt(paras.slice(0, cut), l, S, state), textAt(paras.slice(cut), r, S, state)];
|
|
605
|
-
// Two sections of unequal length leave one column half empty
|
|
605
|
+
// Two sections of unequal length leave one column half empty, so the break moves to even them.
|
|
606
606
|
if (shortColumn(out, Z.bottom, S).share > 0.35) {
|
|
607
607
|
const evenest = (ok) => {
|
|
608
608
|
let best = null;
|
|
@@ -676,7 +676,7 @@ const PLANS = {
|
|
|
676
676
|
const m = measureText(itemParas(note), nR.w - pad * 2, S, state);
|
|
677
677
|
out.push({ kind: "box", x: nR.x, y: nR.y, w: nR.w, h: m.h + pad * 2, surface: true, limit: Z.bottom },
|
|
678
678
|
{ ...m, x: nR.x + pad, y: nR.y + pad, limit: Z.bottom });
|
|
679
|
-
// A short note beside a long main column
|
|
679
|
+
// A short note beside a long main column stands across the top; the points run in two columns.
|
|
680
680
|
if (main.length && shortColumn(out, Z.bottom, S).share > RAIL_EMPTY && Z.b - Z.a + 1 >= 12) {
|
|
681
681
|
// First a narrower note column at the body size (the note never set larger than the main points), the main
|
|
682
682
|
const [mR3, nR3] = splitRegion(S, Z, 9 / 12);
|
|
@@ -693,7 +693,7 @@ const PLANS = {
|
|
|
693
693
|
const boxH = lead.h + pad * 2;
|
|
694
694
|
const below = { ...Z, y: Z.y + boxH + state.blockGap };
|
|
695
695
|
const fits = (plan) => !plan.some((q) => !q.decor && q.y + q.h > Z.bottom + 0.5);
|
|
696
|
-
//
|
|
696
|
+
// The points take one column at the reading measure when it holds them and reaches the density band.
|
|
697
697
|
for (const z of [S.sizes.lead, bodySize(S, state)]) {
|
|
698
698
|
const m = measureText(itemParas(note).map((p) => ({ ...p, kind: "text" })), Math.min(Z.w - pad * 2, (korean ? 30 : 35) * z), S, state, { size: z });
|
|
699
699
|
const top = Z.y + m.h + pad * 2 + state.blockGap;
|
|
@@ -754,7 +754,7 @@ const PLANS = {
|
|
|
754
754
|
const { S, state, Z } = ctx;
|
|
755
755
|
const paras = sortItems(ctx.items).texts.flatMap(itemParas).map((p) => ({ ...p, kind: "text" }));
|
|
756
756
|
let size = S.tok.ramp === "presented" ? (state.stepped ? S.sizes.body : S.sizes.label) : bodySize(S, state);
|
|
757
|
-
// Entries are scanned for a name or a year
|
|
757
|
+
// Entries are scanned for a name or a year, one per row.
|
|
758
758
|
const fits = (z, w, n) => n * Math.max(...paras.slice(0, n).map((p) => G.textHeight(plainText(p.text), w, z, 1.3))) + 12 * (n - 1) <= Z.bottom - Z.y;
|
|
759
759
|
let cols = [Z];
|
|
760
760
|
if (!fits(size, Z.w, paras.length)) {
|
|
@@ -782,7 +782,7 @@ const PLANS = {
|
|
|
782
782
|
if (!kpi) return PLANS["table-insight"](ctx);
|
|
783
783
|
const others = visuals.filter((v) => v !== kpi);
|
|
784
784
|
if (ctx.cls === "side" || Z.b - Z.a + 1 < 10 || (state.anchor && ctx.cls === "top")) {
|
|
785
|
-
// Under a side title, or when the values take the room, they run down the body as rows
|
|
785
|
+
// Under a side title, or when the values take the room, they run down the body as rows.
|
|
786
786
|
const cards = shared().kpiCards(kpi.content).slice(0, 6);
|
|
787
787
|
const out = [];
|
|
788
788
|
const restTexts = texts;
|
|
@@ -807,7 +807,7 @@ const PLANS = {
|
|
|
807
807
|
const out = [rows, ...notes.placed];
|
|
808
808
|
if (kpi.caption) out.push({ kind: "caption", item: { content: kpi.caption }, x: fR.x, y: fR.bottom - capH + 6, w: fR.w, h: capH - 6, limit: Z.bottom });
|
|
809
809
|
if (shortColumn(out, Z.bottom, S).share <= 0.35) return out;
|
|
810
|
-
// Takeaways too few to fill the column beside the rows
|
|
810
|
+
// Takeaways too few to fill the column beside the rows stand under them, side by side.
|
|
811
811
|
const paras = texts.flatMap(itemParas);
|
|
812
812
|
const half = Math.ceil(paras.length / 2);
|
|
813
813
|
// Up to three points read down one column at the reading measure; four or more stand side by side.
|
|
@@ -868,7 +868,7 @@ const PLANS = {
|
|
|
868
868
|
const R = tR ? region(S, tR.a + 1, tR.b, Z.y, Z.bottom)
|
|
869
869
|
: ctx.cls === "bottom" ? { ...Z, bottom: y - state.blockGap } : { ...Z, y: Z.y + blockH + state.blockGap };
|
|
870
870
|
const evidence = stack(texts, R, S, { ...state, stepped: true });
|
|
871
|
-
// Beside a number on the floor the evidence ends level with the number block,
|
|
871
|
+
// Beside a number on the floor the evidence ends level with the number block, not at the page top.
|
|
872
872
|
const lift = tR && ctx.cls === "bottom" ? Math.max(0, Math.min(y + blockH, Z.bottom) - evidence.bottom) : 0;
|
|
873
873
|
out.push(...evidence.placed.map((q) => ({ ...q, y: q.y + lift })));
|
|
874
874
|
}
|
|
@@ -947,7 +947,7 @@ const PLANS = {
|
|
|
947
947
|
return { head: headIdx >= 0 ? paras[headIdx].text : null,
|
|
948
948
|
items: paras.filter((_, j) => j !== headIdx).map((p) => String(p.text).replace(/^\(\d+\)\s*/u, "")) };
|
|
949
949
|
});
|
|
950
|
-
// Points that open with the same label on both sides
|
|
950
|
+
// Points that open with the same label on both sides ("비용: …") read as criteria rows.
|
|
951
951
|
const LABEL = /^([^::]{1,24})[::]\s*(.+)$/u;
|
|
952
952
|
const isLabel = (t) => { const m = LABEL.exec(plainText(t)); return Boolean(m) && m[1].trim().split(/\s+/u).length <= 3; };
|
|
953
953
|
let criteria = null;
|
|
@@ -962,13 +962,13 @@ const PLANS = {
|
|
|
962
962
|
for (const sd of sides) sd.items = sd.items.map((t) => LABEL.exec(plainText(t))[2]);
|
|
963
963
|
}
|
|
964
964
|
}
|
|
965
|
-
// Under a side title the criteria stand in the rail, level with their rows, and the two sides take the body
|
|
965
|
+
// Under a side title the criteria stand in the rail, level with their rows, and the two sides take the body.
|
|
966
966
|
const inRail = Boolean(criteria && ctx.rail);
|
|
967
967
|
const floor = inRail ? Math.min(Z.bottom, ctx.rail.bottom) : Z.bottom;
|
|
968
968
|
const critR = inRail ? { ...ctx.rail, bottom: floor } : criteria ? region(S, Z.a, Z.a + 1, Z.y, Z.bottom) : null;
|
|
969
969
|
const parts = criteria && !inRail ? splitRegion(S, region(S, Z.a + 2, Z.b, Z.y, Z.bottom), 0.5) : splitRegion(S, Z, 0.5);
|
|
970
970
|
const headSize = bodySize(S, state, "lead");
|
|
971
|
-
// Up to three rows of short points are set a step or two larger
|
|
971
|
+
// Up to three rows of short points are set a step or two larger while a line keeps a dozen glyphs.
|
|
972
972
|
const rowsN = Math.max(...sides.map((sd) => sd.items.length));
|
|
973
973
|
const fitsAt = (z) => {
|
|
974
974
|
if (parts[0].w / z < 12) return false;
|
|
@@ -1025,7 +1025,7 @@ const PLANS = {
|
|
|
1025
1025
|
process(ctx) {
|
|
1026
1026
|
const { S, state } = ctx;
|
|
1027
1027
|
const all = entries(sortItems(ctx.items).texts.flatMap(itemParas));
|
|
1028
|
-
// Points without a bold head after the steps are the slide's takeaways
|
|
1028
|
+
// Points without a bold head after the steps are the slide's takeaways, set under the steps.
|
|
1029
1029
|
const headed = all.filter((e) => e.head);
|
|
1030
1030
|
const loose = headed.length >= 2 && headed.length < all.length ? all.filter((e) => !e.head) : [];
|
|
1031
1031
|
const ents = loose.length ? headed : all;
|
|
@@ -1109,7 +1109,7 @@ const PLANS = {
|
|
|
1109
1109
|
const slots = equalParts(S, Z, events.length);
|
|
1110
1110
|
const dateSize = bodySize(S, state, "lead");
|
|
1111
1111
|
const labelSize = bodySize(S, state);
|
|
1112
|
-
// The axis runs under the tallest date at the top of the body
|
|
1112
|
+
// The axis runs under the tallest date at the top of the body.
|
|
1113
1113
|
const dateH = Math.max(...events.map((e, i) => G.textHeight(e.date, slots[i].w, dateSize, 1.25)));
|
|
1114
1114
|
const axisY = Math.round(Z.y + dateH + 18);
|
|
1115
1115
|
const items = events.map((e, i) => {
|
|
@@ -1127,10 +1127,10 @@ const PLANS = {
|
|
|
1127
1127
|
const top = Math.max(...out.map((q) => q.y + q.h)) + state.blockGap;
|
|
1128
1128
|
out.push(...stack(texts, { ...Z, y: top }, S, state).placed);
|
|
1129
1129
|
}
|
|
1130
|
-
// Room past the density band is shared evenly
|
|
1130
|
+
// Room past the density band is shared evenly around the axis.
|
|
1131
1131
|
const room = Z.bottom - contentBottom(out, Z.y);
|
|
1132
1132
|
if (room / (Z.bottom - Z.y) > S.tok.cap) {
|
|
1133
|
-
// A single takeaway line stays with the axis it reads
|
|
1133
|
+
// A single takeaway line stays with the axis it reads, so the pair moves together.
|
|
1134
1134
|
const single = texts.flatMap(itemParas).length === 1;
|
|
1135
1135
|
const blocks = out.length > (cap ? 2 : 1) && !single ? 3 : 2;
|
|
1136
1136
|
const step = Math.floor(room / blocks);
|
|
@@ -1151,7 +1151,7 @@ const PLANS = {
|
|
|
1151
1151
|
const c = (table && table.content) || {};
|
|
1152
1152
|
if (!table || (c.headers || []).length !== 3 || (c.rows || []).length !== 2) return PLANS["table-insight"](ctx);
|
|
1153
1153
|
const [yName, xName] = String(plainText(c.headers[0])).split(/\s*[\\/]\s*/u);
|
|
1154
|
-
// The takeaways stand beside the quadrants in the last four columns
|
|
1154
|
+
// The takeaways stand beside the quadrants in the last four columns, so the matrix never drops them.
|
|
1155
1155
|
const wide = texts.length && Z.b - Z.a + 1 >= 10;
|
|
1156
1156
|
const M = wide ? region(S, Z.a, Z.b - 4, Z.y, Z.bottom) : Z;
|
|
1157
1157
|
const notesH = texts.length && !wide ? stack(texts, Z, S, state).bottom - Z.y + state.blockGap : 0;
|
|
@@ -1166,7 +1166,7 @@ const PLANS = {
|
|
|
1166
1166
|
const qh = (grid.bottom - capH - top - gap) / 2;
|
|
1167
1167
|
const qw = (grid.w - gap) / 2;
|
|
1168
1168
|
const pad = 18;
|
|
1169
|
-
// A cell is its item over its details
|
|
1169
|
+
// A cell is its item over its details, one detail a line under the item.
|
|
1170
1170
|
const head = bodySize(S, state, "lead");
|
|
1171
1171
|
const detail = bodySize(S, state);
|
|
1172
1172
|
const cells = [0, 1].flatMap((ri) => [0, 1].map((ci) => {
|
|
@@ -1195,7 +1195,7 @@ const PLANS = {
|
|
|
1195
1195
|
const size = S.sizes.title;
|
|
1196
1196
|
const h = G.textHeight(plainText(said), R.w, size, 1.3);
|
|
1197
1197
|
const blockH = h + (by ? 18 + S.sizes.label * 1.4 : 0);
|
|
1198
|
-
// Beside a side title the quote is the slide's display element
|
|
1198
|
+
// Beside a side title the quote is the slide's display element, set in the middle of the body.
|
|
1199
1199
|
const y = ctx.cls === "side" ? Math.max(R.y, Math.round(Z.y + (Z.bottom - Z.y - blockH) / 2)) : R.y;
|
|
1200
1200
|
return [{ kind: "quote", text: said, by: by ? plainText(by.text) : "", size, x: R.x, w: R.w, y, h: blockH, textH: h, markX: Z.x, markW, limit: Z.bottom }];
|
|
1201
1201
|
},
|
|
@@ -1230,7 +1230,7 @@ const PLANS = {
|
|
|
1230
1230
|
if (!defsItem.length) { place(Z); return out; }
|
|
1231
1231
|
const defsAt = (R) => textAt(defs, R, S, { ...state, itemGap: Math.max(state.itemGap, 12) });
|
|
1232
1232
|
if (!visuals.length && Z.b - Z.a + 1 >= 8) {
|
|
1233
|
-
// The formula runs across the top and its terms in columns under it,
|
|
1233
|
+
// The formula runs across the top and its terms in columns under it, never beside an empty column.
|
|
1234
1234
|
place(Z);
|
|
1235
1235
|
const below = { ...Z, y: Math.max(...out.map((q) => q.y + q.h)) + state.blockGap };
|
|
1236
1236
|
// Two columns of terms, else three (a formula beside its terms left its own column four-fifths empty).
|
|
@@ -1240,7 +1240,7 @@ const PLANS = {
|
|
|
1240
1240
|
}
|
|
1241
1241
|
out.length = 0;
|
|
1242
1242
|
}
|
|
1243
|
-
// A wide figure
|
|
1243
|
+
// A wide figure set in half the body would draw at a third of its height, so it runs across the top.
|
|
1244
1244
|
const size = visuals.length === 1 && visuals[0].type === "image" && visuals[0].imageSize;
|
|
1245
1245
|
if (S.tok.tight && size && size.width / size.height >= 2 && Z.b - Z.a + 1 >= 8) {
|
|
1246
1246
|
const cap = captionHeight(visuals[0].caption, Z.w, S);
|
|
@@ -1261,7 +1261,7 @@ const PLANS = {
|
|
|
1261
1261
|
}
|
|
1262
1262
|
const [vR, full] = splitRegion(S, Z, 7 / 12);
|
|
1263
1263
|
place(vR);
|
|
1264
|
-
// Beside a figure the terms' column closes with the source strip
|
|
1264
|
+
// Beside a figure the terms' column closes with the source strip, and sparse terms are set a step up.
|
|
1265
1265
|
const strip = ctx.strip && visuals.length ? stripPlacements(ctx.strip, full, S) : null;
|
|
1266
1266
|
const dR = strip ? { ...full, bottom: full.bottom - strip.h - S.tok.block } : full;
|
|
1267
1267
|
let beside = defsAt(dR);
|
|
@@ -1271,7 +1271,7 @@ const PLANS = {
|
|
|
1271
1271
|
}
|
|
1272
1272
|
if (strip && beside.y + beside.h <= dR.bottom + 0.5) out.push(...strip.placed.map((q) => ({ ...q, strip: true })));
|
|
1273
1273
|
if (S.tok.tight && visuals.length && beside.y + beside.h > Z.bottom) {
|
|
1274
|
-
// Terms too long for five columns beside the figure
|
|
1274
|
+
// Terms too long for five columns beside the figure take one column, then two.
|
|
1275
1275
|
const [hR, tR6] = splitRegion(S, Z, 6 / 12);
|
|
1276
1276
|
const strip6 = ctx.strip ? stripPlacements(ctx.strip, tR6, S) : null;
|
|
1277
1277
|
const one = textAt(defs, strip6 ? { ...tR6, bottom: tR6.bottom - strip6.h - S.tok.block } : tR6, S, { ...state, itemGap: Math.max(state.itemGap, 12) });
|
|
@@ -1280,14 +1280,14 @@ const PLANS = {
|
|
|
1280
1280
|
place(hR);
|
|
1281
1281
|
return [...out, { ...one, limit: Z.bottom - one.h * 0.1 }, ...(strip6 ? strip6.placed.map((q) => ({ ...q, strip: true })) : [])];
|
|
1282
1282
|
}
|
|
1283
|
-
// The text estimate runs short of what a renderer sets for Latin, so the columns keep a tenth
|
|
1283
|
+
// The text estimate runs short of what a renderer sets for Latin, so the columns keep a tenth spare.
|
|
1284
1284
|
for (const share of [5 / 12, 4 / 12]) {
|
|
1285
1285
|
out.length = 0;
|
|
1286
1286
|
const [fR, tR] = splitRegion(S, Z, share);
|
|
1287
1287
|
const cols = defsInColumns(defs, tR);
|
|
1288
1288
|
if (share < 5 / 12 || cols.every((q) => q.y + q.h * 1.1 <= Z.bottom)) {
|
|
1289
1289
|
place(fR);
|
|
1290
|
-
// The spare tenth is part of each column's limit, so a slide that cannot keep it reads as overflowing
|
|
1290
|
+
// The spare tenth is part of each column's limit, so a slide that cannot keep it reads as overflowing.
|
|
1291
1291
|
return [...out, ...cols.map((q) => ({ ...q, limit: Z.bottom - q.h * 0.1 }))];
|
|
1292
1292
|
}
|
|
1293
1293
|
}
|
|
@@ -1295,7 +1295,7 @@ const PLANS = {
|
|
|
1295
1295
|
out.push(beside);
|
|
1296
1296
|
return out;
|
|
1297
1297
|
|
|
1298
|
-
// The terms split between two columns at the entry that brings the first nearest half the height
|
|
1298
|
+
// The terms split between two columns at the entry that brings the first nearest half the height.
|
|
1299
1299
|
function defsInColumns(list, R, n = 2) {
|
|
1300
1300
|
const ents = entries(list);
|
|
1301
1301
|
const flat = (part) => part.flatMap((e) => [{ kind: e.kind === "section" ? "section" : "bullet", text: e.head ? `**${e.head}** ${e.rest}`.trim() : e.rest },
|
|
@@ -1419,7 +1419,7 @@ const PLANS = {
|
|
|
1419
1419
|
const { S, state, Z } = ctx;
|
|
1420
1420
|
const { visuals, texts } = sortItems(ctx.items);
|
|
1421
1421
|
const acts = visuals.length === 1 && visuals[0].type === "kpi-table" && !visuals[0].content.chart ? actionRows(visuals[0].content) : null;
|
|
1422
|
-
// The field starts where the rows' text ends (
|
|
1422
|
+
// The field starts where the rows' text ends (five to seven of twelve columns), so each key stands near its row.
|
|
1423
1423
|
const n = Z.b - Z.a + 1;
|
|
1424
1424
|
if (acts && !ctx.splitCols) {
|
|
1425
1425
|
const at = (k) => PLANS["closing-contact-split"]({ ...ctx, splitCols: k });
|
|
@@ -1441,7 +1441,7 @@ const PLANS = {
|
|
|
1441
1441
|
const F = { ...fR, x: fieldX + inset, w: fR.x + fR.w - fieldX - inset, y: Z.y + inset, bottom: Z.bottom };
|
|
1442
1442
|
const field = { decor: true, kind: "rect", x: fieldX, y: Z.y, w: S.grid.W - fieldX, h: S.grid.H - Z.y, fill: "field", bleed: true };
|
|
1443
1443
|
if (acts) {
|
|
1444
|
-
// The field is the ask
|
|
1444
|
+
// The field is the ask, each row's key in line with its row.
|
|
1445
1445
|
const total = actionTotal(acts) || actionSpan(acts);
|
|
1446
1446
|
// The band stops at the field's edge.
|
|
1447
1447
|
const stepW = total ? fieldX - lR.x : F.x + F.w - lR.x;
|
|
@@ -1474,7 +1474,7 @@ const PLANS = {
|
|
|
1474
1474
|
PLANS["step-diagram"] = PLANS.process;
|
|
1475
1475
|
PLANS.statement = PLANS["text-column"];
|
|
1476
1476
|
|
|
1477
|
-
/** Headed points as rows */
|
|
1477
|
+
/** Headed points run as rows, the head bold in the first three columns. */
|
|
1478
1478
|
function headedRows(ctx, ents) {
|
|
1479
1479
|
const { S, state, Z } = ctx;
|
|
1480
1480
|
const headR = region(S, Z.a, Z.a + 2, Z.y, Z.bottom);
|
|
@@ -1514,7 +1514,7 @@ function figureRows(cards, Z, S, state, spread = null) {
|
|
|
1514
1514
|
return { kind: "figrows", rows, x: Z.x, w: Z.w, valueW, labelX, labelW, value, labelSize, noteSize, pitch, y: Z.y, h: (n - 1) * pitch + heights[n - 1], limit: Z.bottom };
|
|
1515
1515
|
}
|
|
1516
1516
|
|
|
1517
|
-
/** A big-number slide
|
|
1517
|
+
/** A big-number slide is a data panel, one row per figure. */
|
|
1518
1518
|
function numberPanel(ctx, table, texts) {
|
|
1519
1519
|
const { S, state, Z } = ctx;
|
|
1520
1520
|
const c = table.content || {};
|
|
@@ -1572,7 +1572,7 @@ function actionRows(content) {
|
|
|
1572
1572
|
const rows = (content.rows || []).map((r) => r.map((cell) => plainText(cell).trim()))
|
|
1573
1573
|
.filter((r, i, all) => !(all.length > 1 && i === all.length - 1 && TOTALS.test(r[0] || "")));
|
|
1574
1574
|
if (rows.length < 1 || rows.length > 5 || headers.length < 2) return null;
|
|
1575
|
-
// A bare number under a head that names its unit ("Budget (k USD)") carries that unit
|
|
1575
|
+
// A bare number under a head that names its unit ("Budget (k USD)") carries that unit wherever it stands.
|
|
1576
1576
|
const units = headers.map((h) => (/\(([^()]{1,12})\)\s*$/u.exec(h) || [])[1] || null);
|
|
1577
1577
|
const withUnit = (cell, j) => (units[j] && /^\d[\d,.]*$/u.test(cell) ? joinUnit(cell, units[j]) : cell);
|
|
1578
1578
|
let key = -1;
|
|
@@ -1629,7 +1629,7 @@ function actionSpan(acts) {
|
|
|
1629
1629
|
return first && last && first !== last ? `${count} · ${first} – ${last}` : count;
|
|
1630
1630
|
}
|
|
1631
1631
|
|
|
1632
|
-
/** Action rows down a region */
|
|
1632
|
+
/** Action rows run down a region, the key large on the left. */
|
|
1633
1633
|
function placeActions(ctx, acts, R, o = {}) {
|
|
1634
1634
|
const { S, state } = ctx;
|
|
1635
1635
|
const n = acts.rows.length;
|
|
@@ -1639,7 +1639,7 @@ function placeActions(ctx, acts, R, o = {}) {
|
|
|
1639
1639
|
const sizes = [S.sizes.title, S.sizes.lead, S.sizes.body];
|
|
1640
1640
|
const total = acts.kind === "amount" ? acts.rows.reduce((a, r) => a + r.value, 0) : 0;
|
|
1641
1641
|
const gaps = o.last ? n : Math.max(1, n - 1);
|
|
1642
|
-
//
|
|
1642
|
+
// Rows start at lead size and step down until they fit.
|
|
1643
1643
|
const fit = (step) => {
|
|
1644
1644
|
const fitting = sizes.filter((v) => v && longest * v * 1.04 <= keyR.w);
|
|
1645
1645
|
const keySize = fitting[Math.min(step, fitting.length - 1)] || S.sizes.body;
|
|
@@ -1662,7 +1662,7 @@ function placeActions(ctx, acts, R, o = {}) {
|
|
|
1662
1662
|
if (chosen.room < 0) return null;
|
|
1663
1663
|
const { keySize, head, meta, rows } = chosen;
|
|
1664
1664
|
const sum = rows.reduce((a, r) => a + r.h, 0);
|
|
1665
|
-
// Rows sit at most 96 pt apart
|
|
1665
|
+
// Rows sit at most 96 pt apart.
|
|
1666
1666
|
const gap = Math.max(state.itemGap, Math.min(96, (R.bottom - R.y - sum) / gaps));
|
|
1667
1667
|
const spare = Math.max(0, R.bottom - R.y - sum - gap * gaps);
|
|
1668
1668
|
let y = R.y + Math.round(spare / 2);
|
|
@@ -1672,20 +1672,20 @@ function placeActions(ctx, acts, R, o = {}) {
|
|
|
1672
1672
|
head, meta, total, gap, x: R.x, y: R.y, w: (o.keys ? keyR.x + keyR.w : R.x + R.w) - R.x, h, limit: R.bottom }];
|
|
1673
1673
|
}
|
|
1674
1674
|
|
|
1675
|
-
/** The next step of a closing, set at lead size on the body floor in the pack's closing device */
|
|
1675
|
+
/** The next step of a closing, set at lead size on the body floor in the pack's closing device. */
|
|
1676
1676
|
function nextStep(ctx, texts, R, device, o = {}) {
|
|
1677
1677
|
const { S, state } = ctx;
|
|
1678
1678
|
const lead = bodySize(S, { ...state, stepped: false }, "lead");
|
|
1679
1679
|
const ruled = device === "rules" || device === "field";
|
|
1680
1680
|
const pad = ruled ? 0 : Math.max(18, S.tok.pad);
|
|
1681
|
-
// Each point keeps its own line
|
|
1681
|
+
// Each point keeps its own line; a source line is a note at label size.
|
|
1682
1682
|
const paras = texts.flatMap(itemParas).map((p) => plainText(p.text));
|
|
1683
1683
|
const notes = paras.filter((t) => /^(출처|자료|sources?)\b/iu.test(t));
|
|
1684
1684
|
const points = paras.filter((t) => !notes.includes(t)).map((t) => t.replace(/^([^::]{1,16})([::])\s*/u, "**$1$2** "));
|
|
1685
1685
|
const size = points.length > 2 || o.small ? S.sizes.body : lead;
|
|
1686
1686
|
const box = R.w - pad * 2;
|
|
1687
1687
|
const inner = Math.min(box, (G.isKorean(paras.join("")) ? 34 : 70) * size * (G.isKorean(paras.join("")) ? 1 : 0.5));
|
|
1688
|
-
// A line that fits the box on one line takes the box's width
|
|
1688
|
+
// A line that fits the box on one line takes the box's width.
|
|
1689
1689
|
const widthOf = (t, z) => {
|
|
1690
1690
|
if (G.lineCount(plainText(t), box, z) <= 1) return box;
|
|
1691
1691
|
const n = G.lineCount(plainText(t), inner, z);
|
|
@@ -1749,7 +1749,7 @@ function drawPlaced(slide, S, p, ctx) {
|
|
|
1749
1749
|
const soft = p.onField ? S.colour("on-field") : S.colour("ink-muted");
|
|
1750
1750
|
for (const para of p.paras) {
|
|
1751
1751
|
y += para.gap;
|
|
1752
|
-
// The marker is a small dot centred on the first line
|
|
1752
|
+
// The marker is a small dot centred on the first line, drawn as a shape so no font swaps it.
|
|
1753
1753
|
if (para.kind === "bullet") {
|
|
1754
1754
|
const d = Math.max(4, Math.round(p.size * 0.28));
|
|
1755
1755
|
const lineH = p.size * G.leading(para.text);
|
|
@@ -1910,7 +1910,7 @@ function drawPlaced(slide, S, p, ctx) {
|
|
|
1910
1910
|
if (p.kind === "caption") drawCaption(slide, S, p.item.content, p.x, p.y, p.w, p.role === "on-field" ? "on-field" : "ink-muted");
|
|
1911
1911
|
}
|
|
1912
1912
|
|
|
1913
|
-
/** A figure on a dark ground is drawn from its dark variant when the source folder has one beside it */
|
|
1913
|
+
/** A figure on a dark ground is drawn from its dark variant when the source folder has one beside it. */
|
|
1914
1914
|
function forGround(file, S) {
|
|
1915
1915
|
const c = (i) => parseInt(String(S.P.ground).replace("#", "").slice(i, i + 2), 16) / 255;
|
|
1916
1916
|
if (0.2126 * c(0) + 0.7152 * c(2) + 0.0722 * c(4) >= 0.25) return file;
|
|
@@ -1945,7 +1945,7 @@ function periodOf(name) {
|
|
|
1945
1945
|
return y * 100 + (within || 0);
|
|
1946
1946
|
}
|
|
1947
1947
|
|
|
1948
|
-
/** The series a chart highlights */
|
|
1948
|
+
/** The series a chart highlights is the latest period, else the measured one. */
|
|
1949
1949
|
function primarySeries(names) {
|
|
1950
1950
|
const periods = names.map(periodOf);
|
|
1951
1951
|
if (periods.every((v) => v != null) && new Set(periods).size === periods.length) return periods.indexOf(Math.max(...periods));
|
|
@@ -1959,7 +1959,7 @@ function seriesKey(name) {
|
|
|
1959
1959
|
return plainText(name).replace(/\s*[((][^))]*[))]\s*$/u, "").trim().toLowerCase();
|
|
1960
1960
|
}
|
|
1961
1961
|
|
|
1962
|
-
/**
|
|
1962
|
+
/** Draws a native chart in the pack's chart style. */
|
|
1963
1963
|
function drawChart(slide, S, c, p) {
|
|
1964
1964
|
const P = S.P;
|
|
1965
1965
|
const chart = S.pack.chart || {};
|
|
@@ -1967,7 +1967,7 @@ function drawChart(slide, S, c, p) {
|
|
|
1967
1967
|
const muted = mix(P["ink-muted"], P.ground, 0.45);
|
|
1968
1968
|
let colours;
|
|
1969
1969
|
if (series <= 1) {
|
|
1970
|
-
// One series is one hue
|
|
1970
|
+
// One series is one hue; a per-point highlight would make the chart read as categories.
|
|
1971
1971
|
const other = S.seriesRole && S.seriesRole.get(seriesKey((c.headers || [])[1])) === "other";
|
|
1972
1972
|
const receding = chart.highlight === "accent-on-muted" ? muted : P.series.find((h) => String(h).toUpperCase() !== String(P.accent).toUpperCase()) || muted;
|
|
1973
1973
|
colours = [other ? receding : P.accent];
|
|
@@ -2068,7 +2068,7 @@ function overflows(placed) {
|
|
|
2068
2068
|
return placed.some((p) => !p.decor && p.limit != null && p.y + p.h > p.limit + 0.5);
|
|
2069
2069
|
}
|
|
2070
2070
|
|
|
2071
|
-
/**
|
|
2071
|
+
/** Returns the zones of one treatment frame. */
|
|
2072
2072
|
function zonesOf(frame, S) {
|
|
2073
2073
|
const t = frame.title;
|
|
2074
2074
|
const cls = frame.id === "side-rail" ? "side" : frame.id === "bottom-anchor" ? "bottom" : "top";
|
|
@@ -2077,11 +2077,11 @@ function zonesOf(frame, S) {
|
|
|
2077
2077
|
return { cls, Z, bandTop: cls === "bottom" ? Z.y : Math.min(t.y + t.h, Z.y) };
|
|
2078
2078
|
}
|
|
2079
2079
|
|
|
2080
|
-
/** Lay a content slide out under one frame
|
|
2080
|
+
/** Lay a content slide out under one frame, then try the pack's fill policies while the body stays under-filled. */
|
|
2081
2081
|
// A line that names the slide's source or a note on its basis ("출처:", "주:", "Source:", "Note:").
|
|
2082
2082
|
const STRIP = /^(?:출처|자료|주|Sources?|Notes?)\s*[::]/iu;
|
|
2083
2083
|
|
|
2084
|
-
/** The source and note lines of a content slide's text, taken out of its takeaways */
|
|
2084
|
+
/** The source and note lines of a content slide's text, taken out of its takeaways into a foot strip. */
|
|
2085
2085
|
function takeStrip(spec, items) {
|
|
2086
2086
|
// A closing with a next step keeps its source there; a closing summary has no such line, so it takes the strip.
|
|
2087
2087
|
if (G.VARIANTS.closing.includes(spec.family) && spec.family !== "closing-summary-list") return { items, lines: [] };
|
|
@@ -2103,20 +2103,20 @@ function stripPlacements(lines, Z, S) {
|
|
|
2103
2103
|
paras[0].gap = 0;
|
|
2104
2104
|
const h = paras.reduce((a, q) => a + q.gap + q.h, 0);
|
|
2105
2105
|
const y = Z.bottom - h;
|
|
2106
|
-
// A hairline sets the strip off where the pack draws hairlines
|
|
2106
|
+
// A hairline sets the strip off where the pack draws hairlines.
|
|
2107
2107
|
return { h: h + 10, placed: [
|
|
2108
2108
|
...(S.deco.has("hairline-rule") ? [{ decor: true, kind: "rect", x: Z.x, y: y - 6, w: Z.w, h: 0.5, fill: "line" }] : []),
|
|
2109
2109
|
{ kind: "text", paras, size, x: Z.x, y, w: Z.w, h, muted: true, limit: Z.bottom },
|
|
2110
2110
|
] };
|
|
2111
2111
|
}
|
|
2112
2112
|
|
|
2113
|
-
// Families that pair a visual with its takeaways
|
|
2113
|
+
// Families that pair a visual with its takeaways may use the rail.
|
|
2114
2114
|
const RAIL_TAKEAWAYS = new Set(["chart-insight", "full-chart", "table-insight", "figure-academic", "image-split",
|
|
2115
2115
|
"kpi-row", "big-number", "kpi-over-chart", "dashboard-grid", "photo-grid", "figure-pair", "asymmetric-feature"]);
|
|
2116
2116
|
// A rail more empty than this, from the title down, costs the slide the side rail.
|
|
2117
2117
|
const RAIL_EMPTY = 0.4;
|
|
2118
2118
|
|
|
2119
|
-
/** The rail under a side title */
|
|
2119
|
+
/** The rail under a side title holds the slide's supporting content. */
|
|
2120
2120
|
function railOf(frame, S) {
|
|
2121
2121
|
const t = frame.title;
|
|
2122
2122
|
const s = S.grid.span(1, 4);
|
|
@@ -2143,7 +2143,7 @@ function planSlide(spec, frame, S, items) {
|
|
|
2143
2143
|
const rail = z.cls === "side" ? railOf(frame, S) : null;
|
|
2144
2144
|
const railPlaced = [];
|
|
2145
2145
|
const taken = takeStrip(spec, items);
|
|
2146
|
-
// The source strip
|
|
2146
|
+
// The source strip stands at the rail's foot or the body's.
|
|
2147
2147
|
let lines = taken.lines;
|
|
2148
2148
|
items = taken.items;
|
|
2149
2149
|
if (lines.length && rail) {
|
|
@@ -2152,11 +2152,11 @@ function planSlide(spec, frame, S, items) {
|
|
|
2152
2152
|
rail.bottom -= strip.h + S.tok.block;
|
|
2153
2153
|
lines = [];
|
|
2154
2154
|
}
|
|
2155
|
-
// A visual's takeaways stand in the rail when they fit there, a chart's values table under them
|
|
2155
|
+
// A visual's takeaways stand in the rail when they fit there, a chart's values table under them.
|
|
2156
2156
|
if (rail && RAIL_TAKEAWAYS.has(spec.family) && !spec.stacked) {
|
|
2157
2157
|
const { texts, visuals } = sortItems(items);
|
|
2158
2158
|
const paras = texts.filter((t) => t.type !== "main-box").flatMap(itemParas);
|
|
2159
|
-
// A table keeps its own height
|
|
2159
|
+
// A table keeps its own height.
|
|
2160
2160
|
const fills = () => {
|
|
2161
2161
|
// Figure rows and number panels spread to the floor; so do charts and pictures.
|
|
2162
2162
|
if (["kpi-row", "big-number"].includes(spec.family) || visuals.some((v) => v.type !== "kpi-table" || (v.content || {}).chart)) return true;
|
|
@@ -2174,7 +2174,7 @@ function planSlide(spec, frame, S, items) {
|
|
|
2174
2174
|
// Body size when it fills the rail; else whichever size leaves the rail least empty.
|
|
2175
2175
|
const pick = sets.find((x) => x.band <= RAIL_EMPTY) || sets.reduce((a, b) => (b.band < a.band ? b : a), sets[0]);
|
|
2176
2176
|
if (pick) {
|
|
2177
|
-
// Takeaways that still leave a quarter of the rail empty share that room
|
|
2177
|
+
// Takeaways that still leave a quarter of the rail empty share that room as gaps, a sixth of the rail at most.
|
|
2178
2178
|
const [m] = pick.set;
|
|
2179
2179
|
const breaks = m.paras.filter((para, i) => i > 0 && para.kind !== "child");
|
|
2180
2180
|
const room = rail.bottom - S.tok.block - (pick.set[pick.set.length - 1].y + pick.set[pick.set.length - 1].h);
|
|
@@ -2188,7 +2188,7 @@ function planSlide(spec, frame, S, items) {
|
|
|
2188
2188
|
}
|
|
2189
2189
|
}
|
|
2190
2190
|
}
|
|
2191
|
-
// A sidebar note
|
|
2191
|
+
// A sidebar note stands in the rail under a side title.
|
|
2192
2192
|
if (rail && spec.family === "sidebar-note") {
|
|
2193
2193
|
const texts = sortItems(items).texts;
|
|
2194
2194
|
const note = texts.find((t) => t.type === "main-box") || (texts.length > 1 ? texts[texts.length - 1] : null);
|
|
@@ -2257,7 +2257,7 @@ function planSlide(spec, frame, S, items) {
|
|
|
2257
2257
|
}
|
|
2258
2258
|
}
|
|
2259
2259
|
z.Z = zone();
|
|
2260
|
-
// A text column that stops short of the column beside it is filled by layout
|
|
2260
|
+
// A text column that stops short of the column beside it is filled by layout.
|
|
2261
2261
|
const floor = full.bottom;
|
|
2262
2262
|
let short = shortColumn([...out.placed, ...(across && !offer ? across.placed : [])], floor, S);
|
|
2263
2263
|
// The fill starts a little under the line (0.35), so a column the gate reads a point or two emptier than the
|
|
@@ -2301,7 +2301,7 @@ function chooseTreatment(spec, S, deck) {
|
|
|
2301
2301
|
const cost = (x) => x.plan.band + (x.plan.band > S.tok.cap ? 0.15 : 0) + 0.07 * (deck.count[x.t] || 0) +
|
|
2302
2302
|
(x.t === deck.prev ? 0.08 : 0) - (x.t === options[0] ? 0.03 : 0) + ((deck.count[x.t] || 0) >= deck.limit ? 1 : 0);
|
|
2303
2303
|
const best = pool.reduce((b, x) => (cost(x) < cost(b) - 1e-9 ? x : b));
|
|
2304
|
-
// The role's own title is the pack's structure
|
|
2304
|
+
// The role's own title is the pack's structure and usually stays.
|
|
2305
2305
|
const ownT = options[0] === "bottom-anchor" && !drawn(spec) ? options.find((t) => t !== "bottom-anchor") : options[0];
|
|
2306
2306
|
const own = pool.find((x) => x.t === ownT);
|
|
2307
2307
|
const margin = best.plan.band > S.tok.cap ? 0.15 : 0.1;
|
|
@@ -2334,7 +2334,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
|
|
|
2334
2334
|
choices.set(best.spec, { t: best.t, plan: best.plan });
|
|
2335
2335
|
lines.push(`variety: slide ${best.spec.index + 1} drawn under ${best.t} so the deck carries ${used().size} title treatments`);
|
|
2336
2336
|
}
|
|
2337
|
-
// No title zone
|
|
2337
|
+
// No title zone may hold over 40 % of the content slides.
|
|
2338
2338
|
const content = [...choices.keys()].filter((spec) => spec.kind === "content");
|
|
2339
2339
|
const movable = (spec) => spec.role === "data" || spec.role === "data-takeaway";
|
|
2340
2340
|
const cap = 0.4 * content.length;
|
|
@@ -2370,7 +2370,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
|
|
|
2370
2370
|
if (!crowded || n <= cap) break;
|
|
2371
2371
|
let best = moveOut(crowded, zones, new Set());
|
|
2372
2372
|
if (!best) {
|
|
2373
|
-
//
|
|
2373
|
+
// When every zone is full, a data slide moves on to a third zone first.
|
|
2374
2374
|
for (const [full, m] of Object.entries(zones)) {
|
|
2375
2375
|
if (full === crowded || m + 1 <= cap) continue;
|
|
2376
2376
|
const first = moveOut(full, zones, new Set([crowded]));
|
|
@@ -2390,7 +2390,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
|
|
|
2390
2390
|
const counted = content.filter((spec) => !G.VARIANTS.closing.includes(spec.family));
|
|
2391
2391
|
const layoutsNeeded = Math.min(5, Math.ceil(0.6 * counted.length));
|
|
2392
2392
|
const layoutOf = (t, plan) => `${ZONE_OF[t]}/${partitionOf(plan, S)}`;
|
|
2393
|
-
// The check also fails
|
|
2393
|
+
// The check also fails one layout on more than 40 % of those slides, so slides move off that layout.
|
|
2394
2394
|
const shareCap = 0.4 * counted.length;
|
|
2395
2395
|
for (let guard = 0; guard < counted.length; guard++) {
|
|
2396
2396
|
const keys = {};
|
|
@@ -2418,7 +2418,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
|
|
|
2418
2418
|
}
|
|
2419
2419
|
}
|
|
2420
2420
|
if (!best) {
|
|
2421
|
-
// No title gives a new layout
|
|
2421
|
+
// No title gives a new layout, so the takeaways may stack under the visual.
|
|
2422
2422
|
for (const spec of counted) {
|
|
2423
2423
|
const current = choices.get(spec);
|
|
2424
2424
|
const from = layoutOf(current.t, current.plan);
|
|
@@ -2444,7 +2444,7 @@ function spreadTreatments(choices, S, pack, slideCount) {
|
|
|
2444
2444
|
return lines;
|
|
2445
2445
|
}
|
|
2446
2446
|
|
|
2447
|
-
/** The boxes a placement draws as the composition check reads them from the page */
|
|
2447
|
+
/** The boxes a placement draws, as the composition check reads them from the page. */
|
|
2448
2448
|
function drawnBoxes(p, S) {
|
|
2449
2449
|
const B = (x, y, w, h, card = false) => ({ x, y, w, h, card });
|
|
2450
2450
|
const filled = S.pack.edge !== "border";
|
|
@@ -2478,7 +2478,7 @@ function drawnBoxes(p, S) {
|
|
|
2478
2478
|
// A chart's caption is its own frame under the plot, as the check reads it.
|
|
2479
2479
|
case "chart": return p.cap ? [B(p.x, p.y, p.w, p.h - p.cap), B(p.x, p.y + p.h - p.cap + 6, p.w, p.cap - 6)] : [B(p.x, p.y, p.w, p.h)];
|
|
2480
2480
|
case "image": {
|
|
2481
|
-
// A single picture is drawn contained in its box
|
|
2481
|
+
// A single picture is drawn contained in its box, its caption under it, as the check reads them.
|
|
2482
2482
|
const size = p.item && p.item.type === "image" && p.item.imageSize;
|
|
2483
2483
|
if (!size) return [B(p.x, p.y, p.w, p.h)];
|
|
2484
2484
|
const fit = shared().containBox(p.x, p.y, p.w, p.h - p.cap, size);
|
|
@@ -2491,7 +2491,7 @@ function drawnBoxes(p, S) {
|
|
|
2491
2491
|
}
|
|
2492
2492
|
}
|
|
2493
2493
|
|
|
2494
|
-
/** The column partition a plan draws, read the way the composition check reads the page */
|
|
2494
|
+
/** The column partition a plan draws, read the way the composition check reads the page. */
|
|
2495
2495
|
function partitionOf(plan, S) {
|
|
2496
2496
|
const Z = plan.z && plan.z.Z;
|
|
2497
2497
|
// What stands in a side title's rail is part of the title zone, as the check reads it.
|
|
@@ -2532,7 +2532,7 @@ function partitionOf(plan, S) {
|
|
|
2532
2532
|
return "one";
|
|
2533
2533
|
}
|
|
2534
2534
|
|
|
2535
|
-
/** The emptiest text column of a two-column body, read as the gate reads it (OF-115) */
|
|
2535
|
+
/** The emptiest text column of a two-column body, read as the gate reads it (OF-115). */
|
|
2536
2536
|
const TEXT_KINDS = new Set(["text", "caption", "callout", "box", "panel"]);
|
|
2537
2537
|
// A picture drawn smaller than its box leaves its own column empty above it as a short text column does.
|
|
2538
2538
|
const SHORT_KINDS = new Set([...TEXT_KINDS, "image"]);
|
|
@@ -2552,7 +2552,7 @@ function shortColumn(placed, floor, S) {
|
|
|
2552
2552
|
for (const cut of [...new Set(boxes.map((b) => b.x + b.w))].sort((m, n) => m - n)) {
|
|
2553
2553
|
const a = boxes.filter((b) => b.x + b.w <= cut + 2);
|
|
2554
2554
|
const c = boxes.filter((b) => b.x >= cut - 2);
|
|
2555
|
-
// A block across most of the body (a note under both columns) belongs to neither
|
|
2555
|
+
// A block across most of the body (a note under both columns) belongs to neither column.
|
|
2556
2556
|
const across = boxes.filter((b) => !a.includes(b) && !c.includes(b));
|
|
2557
2557
|
if (!a.length || !c.length || across.some((b) => b.w < 0.7 * (right - left))) continue;
|
|
2558
2558
|
const top = Math.min(...[...a, ...c].map((b) => b.y));
|
|
@@ -2576,7 +2576,7 @@ function shortColumn(placed, floor, S) {
|
|
|
2576
2576
|
return worst;
|
|
2577
2577
|
}
|
|
2578
2578
|
|
|
2579
|
-
/**
|
|
2579
|
+
/** Fills a short text column by layout; type never shrinks. */
|
|
2580
2580
|
function fillColumn(short, floor, S, state) {
|
|
2581
2581
|
const texts = short.owners.filter((q) => q.kind === "text" && !q.muted && !q.strip && q.paras);
|
|
2582
2582
|
if (!texts.length) return;
|
|
@@ -2625,7 +2625,7 @@ function drawDecor(slide, S, list) {
|
|
|
2625
2625
|
for (const d of list || []) packRect(slide, S, d, { fill: d.role, alpha: d.alpha });
|
|
2626
2626
|
}
|
|
2627
2627
|
|
|
2628
|
-
/**
|
|
2628
|
+
/** Draws a content slide, the title frame last. */
|
|
2629
2629
|
function renderContent(slide, spec, S, ctx, chosen) {
|
|
2630
2630
|
const f = frameFor(spec, chosen.t);
|
|
2631
2631
|
const t = f.title;
|
|
@@ -2671,7 +2671,7 @@ function renderContent(slide, spec, S, ctx, chosen) {
|
|
|
2671
2671
|
});
|
|
2672
2672
|
}
|
|
2673
2673
|
|
|
2674
|
-
/** What carries a statement, from the pack's display keys */
|
|
2674
|
+
/** What carries a statement, from the pack's display keys; returns the colour the sentence takes. */
|
|
2675
2675
|
function statementDevice(slide, S, f, supportH) {
|
|
2676
2676
|
const g = S.grid;
|
|
2677
2677
|
const t = f.title;
|
|
@@ -2681,7 +2681,7 @@ function statementDevice(slide, S, f, supportH) {
|
|
|
2681
2681
|
return "on-field";
|
|
2682
2682
|
}
|
|
2683
2683
|
if (device === "plate") {
|
|
2684
|
-
// The sentence moves down onto a plate that bleeds off three edges and covers
|
|
2684
|
+
// The sentence moves down onto a plate that bleeds off three edges and covers under half the page.
|
|
2685
2685
|
const top = Math.round((376 - t.h / 2) / 6) * 6;
|
|
2686
2686
|
if (top - 24 < 282 || top + t.h + 24 + supportH > G.ZONE.floor) return t.colour;
|
|
2687
2687
|
f.support.y += top - t.y;
|
|
@@ -2697,7 +2697,7 @@ function statementDevice(slide, S, f, supportH) {
|
|
|
2697
2697
|
return t.colour;
|
|
2698
2698
|
}
|
|
2699
2699
|
if (device === "offset") {
|
|
2700
|
-
// The rail stops a column short of the sentence
|
|
2700
|
+
// The rail stops a column short of the sentence and at the body floor, so both gaps belong to the grid.
|
|
2701
2701
|
packRect(slide, S, { x: 0, y: 0, w: t.x - g.gutter / 2 - g.pitch, h: G.ZONE.floor }, { fill: S.pack.rail === "field" ? "field" : "surface" });
|
|
2702
2702
|
return t.colour;
|
|
2703
2703
|
}
|
|
@@ -2721,14 +2721,14 @@ function coverImage(slide, box, src, sourceDir) {
|
|
|
2721
2721
|
slide.addImage({ path: file, ...croppedTo(file, box), altText: src.caption || path.basename(file) });
|
|
2722
2722
|
}
|
|
2723
2723
|
|
|
2724
|
-
/** A picture that fills a box by cropping, never by stretching */
|
|
2724
|
+
/** A picture that fills a box by cropping, never by stretching. */
|
|
2725
2725
|
function croppedTo(file, box) {
|
|
2726
2726
|
const size = shared().readImageSize(file);
|
|
2727
2727
|
const ratio = size && size.width && size.height ? size.height / size.width : box.h / box.w;
|
|
2728
2728
|
return { x: IN(box.x), y: IN(box.y), w: IN(box.w), h: IN(box.w * ratio), sizing: { type: "cover", w: IN(box.w), h: IN(box.h) } };
|
|
2729
2729
|
}
|
|
2730
2730
|
|
|
2731
|
-
/** A type-led cover drawn with the pack's display device */
|
|
2731
|
+
/** A type-led cover drawn with the pack's display device. */
|
|
2732
2732
|
const TYPE_COVERS = {
|
|
2733
2733
|
drench(slide, spec, S, meta) {
|
|
2734
2734
|
const g = S.grid;
|
|
@@ -2830,7 +2830,7 @@ const COVERS = {
|
|
|
2830
2830
|
const g = S.grid;
|
|
2831
2831
|
coverImage(slide, { x: 0, y: 0, w: g.W, h: g.H }, spec.cover.image, spec.sourceDir);
|
|
2832
2832
|
const span = g.span(1, 8);
|
|
2833
|
-
// The panel is as tall as the title and its meta line and stands on the page foot
|
|
2833
|
+
// The panel is as tall as the title and its meta line and stands on the page foot.
|
|
2834
2834
|
const { size, h } = coverTitle(S, spec.title, span.w, 2, S.sizes.display);
|
|
2835
2835
|
const line = [meta.subtitle, metaLine(meta)].filter(Boolean).join(" · ");
|
|
2836
2836
|
const lineH = line ? G.textHeight(line, span.w, S.sizes.label, 1.3) : 0;
|
|
@@ -2847,7 +2847,7 @@ const COVERS = {
|
|
|
2847
2847
|
packText(slide, S, spec.title, { ...span, y: Math.max(48, 180 - h), h }, { face: "display", size, colour: S.colour("on-field"), lineSpacing: 1.1, title: true, balance: true, name: "title@cover" });
|
|
2848
2848
|
const subH = meta.subtitle ? G.textHeight(meta.subtitle, g.span(1, 8).w, S.sizes.lead) : 0;
|
|
2849
2849
|
if (meta.subtitle) packText(slide, S, meta.subtitle, { ...g.span(1, 8), y: 252, h: subH }, { name: "subtitle@cover", size: S.sizes.lead, colour: S.colour("ink-muted") });
|
|
2850
|
-
// Under the band, the deck's parts (else its headline figures) run across the page as
|
|
2850
|
+
// Under the band, the deck's parts (else its headline figures) run across the page as its contents.
|
|
2851
2851
|
const parts = (spec.cover.index || []).length >= 2 ? spec.cover.index.map((name, i) => [String(i + 1).padStart(2, "0"), name])
|
|
2852
2852
|
: (spec.cover.figures || []).length >= 2 ? spec.cover.figures.map((f) => [f.value, f.label]) : [];
|
|
2853
2853
|
if (parts.length) {
|
|
@@ -2896,7 +2896,7 @@ const COVERS = {
|
|
|
2896
2896
|
const g = S.grid;
|
|
2897
2897
|
const span = g.span(1, 9);
|
|
2898
2898
|
const { size, h } = coverTitle(S, spec.title, span.w, 2, S.sizes.display);
|
|
2899
|
-
// The figures sit between the title and the meta line, with a hairline above them
|
|
2899
|
+
// The figures sit between the title and the meta line, with a hairline above them.
|
|
2900
2900
|
const figs = spec.cover.figures.slice(0, 4);
|
|
2901
2901
|
// The row stands in the middle of the room between the title and the meta line.
|
|
2902
2902
|
const rowH = S.sizes.title * 1.3 + 6 + S.sizes.body * 1.4;
|
|
@@ -2959,7 +2959,7 @@ const SECTIONS = {
|
|
|
2959
2959
|
} });
|
|
2960
2960
|
},
|
|
2961
2961
|
"section-band"(slide, spec, S) {
|
|
2962
|
-
// The header band deepened to y 216
|
|
2962
|
+
// The header band deepened to y 216.
|
|
2963
2963
|
const g = S.grid;
|
|
2964
2964
|
packRect(slide, S, { x: 0, y: 0, w: g.W, h: 216 }, { fill: "field" });
|
|
2965
2965
|
const index = showsIndex(spec, S);
|
|
@@ -2978,7 +2978,7 @@ const SECTIONS = {
|
|
|
2978
2978
|
const numeral = S.sizes.title;
|
|
2979
2979
|
if (spec.parts) partNumeral(slide, S, spec, { ...g.span(1, 3), y: 216, h: numeral * 1.15 }, numeral, "on-field");
|
|
2980
2980
|
else if ((spec.contents || []).length) {
|
|
2981
|
-
//
|
|
2981
|
+
// Without a part count the rail lists what the section opens with.
|
|
2982
2982
|
const w = g.span(1, 3).w;
|
|
2983
2983
|
let y = 216;
|
|
2984
2984
|
for (const entry of spec.contents) {
|
|
@@ -3028,7 +3028,7 @@ function sectionTitle(slide, spec, S, o = {}) {
|
|
|
3028
3028
|
return box;
|
|
3029
3029
|
}
|
|
3030
3030
|
|
|
3031
|
-
/** A section the deck gives no part count (one section, no agenda) */
|
|
3031
|
+
/** A section the deck gives no part count (one section, no agenda), set as a statement with a preview. */
|
|
3032
3032
|
function sectionOpener(slide, spec, S, o = {}) {
|
|
3033
3033
|
const g = S.grid;
|
|
3034
3034
|
const span = g.span(1, 10);
|
|
@@ -3049,7 +3049,7 @@ function sectionOpener(slide, spec, S, o = {}) {
|
|
|
3049
3049
|
return box;
|
|
3050
3050
|
}
|
|
3051
3051
|
|
|
3052
|
-
/** A section with no part count whose slides carry body lines */
|
|
3052
|
+
/** A section with no part count whose slides carry body lines previews them under a hairline. */
|
|
3053
3053
|
function sectionDigestPage(slide, spec, S, o, d) {
|
|
3054
3054
|
const g = S.grid;
|
|
3055
3055
|
const head = S.sizes.lead;
|
|
@@ -3062,7 +3062,7 @@ function sectionDigestPage(slide, spec, S, o, d) {
|
|
|
3062
3062
|
const right = g.span(1, k);
|
|
3063
3063
|
const ink = S.colour(d.onField ? "on-field" : "ink");
|
|
3064
3064
|
const soft = S.colour(d.onField ? "on-field" : "ink-muted");
|
|
3065
|
-
// Each opened slide shows up to two of its lines (a preview, not a copy)
|
|
3065
|
+
// Each opened slide shows up to two of its lines (a preview, not a copy).
|
|
3066
3066
|
const gap = 24;
|
|
3067
3067
|
const room = G.ZONE.floor - Math.max(o.top || 0, G.ZONE.body) - d.h - 54;
|
|
3068
3068
|
const build = (entries, per) => entries.map((e) => {
|
|
@@ -3081,7 +3081,7 @@ function sectionDigestPage(slide, spec, S, o, d) {
|
|
|
3081
3081
|
}
|
|
3082
3082
|
if (!rows) rows = build(d.digest.slice(0, 1), 1);
|
|
3083
3083
|
const sum = rows.reduce((a, r) => a + r.h, 0);
|
|
3084
|
-
// Title, hairline and preview form one block centred in the body
|
|
3084
|
+
// Title, hairline and preview form one block centred in the body, like a statement of what the part holds.
|
|
3085
3085
|
const total = d.h + 54 + sum + gap * (rows.length - 1);
|
|
3086
3086
|
const y = Math.max(o.top || 0, G.ZONE.body, Math.round(((o.centre || 303) - total / 2) / 6) * 6);
|
|
3087
3087
|
const listTop = y + d.h + 54;
|
|
@@ -3097,7 +3097,7 @@ function sectionDigestPage(slide, spec, S, o, d) {
|
|
|
3097
3097
|
return box;
|
|
3098
3098
|
}
|
|
3099
3099
|
|
|
3100
|
-
/** A section in a deck with an agenda, set as its index page */
|
|
3100
|
+
/** A section in a deck with an agenda, set as its index page. */
|
|
3101
3101
|
function sectionIndex(slide, spec, S) {
|
|
3102
3102
|
const g = S.grid;
|
|
3103
3103
|
const left = g.span(1, 6);
|
|
@@ -3210,7 +3210,7 @@ async function renderPack(resolved, templateObj, outputPath) {
|
|
|
3210
3210
|
const deck = { used: new Set(), max: pack.dials.varianceMax, count: {}, prev: null, limit: Math.max(3, Math.floor(contentSlides * 0.34)) };
|
|
3211
3211
|
const chosenBy = {};
|
|
3212
3212
|
const choices = new Map();
|
|
3213
|
-
// One colour per series name across the deck
|
|
3213
|
+
// One colour per series name across the deck.
|
|
3214
3214
|
S.seriesRole = new Map();
|
|
3215
3215
|
for (const spec of resolved.slides || []) {
|
|
3216
3216
|
for (const item of spec.items || []) {
|