@litfamily/litclaude 1.0.19 → 1.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +14 -8
- package/README_ko-KR.md +13 -8
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +3 -2
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +1 -1
- package/plugins/litclaude/bin/litclaude-hook.js +4 -4
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/lib/canonical-skill-resources.mjs +91 -31
- package/plugins/litclaude/skills/frontend-ui-ux/ATTRIBUTION.md +3 -6
- package/plugins/litclaude/skills/lit-docx/SKILL.md +50 -19
- package/plugins/litclaude/skills/lit-docx/references/components.md +207 -0
- package/plugins/litclaude/skills/lit-docx/references/direction-step.md +277 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/01-report-ko.md +166 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/02-brief-ko.md +105 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/03-manual-ko.md +164 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/04-proposal-ko.md +138 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/05-memo-en.md +55 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/06-journal-en.md +130 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/07-report-en.md +123 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/08-proposal-en.md +123 -0
- package/plugins/litclaude/skills/lit-docx/references/examples/09-memo-ko.md +45 -0
- package/plugins/litclaude/skills/lit-docx/references/page-composition.md +221 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/brief.md +143 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/journal.md +142 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/manual.md +148 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/memo.md +137 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/proposal.md +140 -0
- package/plugins/litclaude/skills/lit-docx/references/tonalities/report.md +156 -0
- package/plugins/litclaude/skills/lit-docx/scripts/convert_md_to_docx.py +274 -73
- package/plugins/litclaude/skills/lit-docx/scripts/docx_design.py +2449 -0
- package/plugins/litclaude/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/plugins/litclaude/skills/lit-docx/scripts/qa_docx.py +66 -9
- package/plugins/litclaude/skills/lit-docx/scripts/slop_lint.py +18 -6
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
- package/plugins/litclaude/skills/lit-docx/templates/tonalities/report.yaml +26 -0
- package/plugins/litclaude/skills/lit-pptx/SKILL.md +105 -48
- package/plugins/litclaude/skills/lit-pptx/references/agents/analyst.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/agents/thesis.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/anti-slop-checklist.md +59 -30
- package/plugins/litclaude/skills/lit-pptx/references/authoring-guide.md +119 -42
- package/plugins/litclaude/skills/lit-pptx/references/consensus-protocol.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/data-display-cookbook.md +13 -6
- package/plugins/litclaude/skills/lit-pptx/references/density-and-fill.md +164 -0
- package/plugins/litclaude/skills/lit-pptx/references/design-system-azure.md +7 -4
- package/plugins/litclaude/skills/lit-pptx/references/direction-step.md +186 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/01-pitch-signal-ko.md +210 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/02-pitch-studio-ko.md +147 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/03-business-review-ledger-ko.md +199 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/04-business-review-night-en.md +203 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +179 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/06-lecture-chalk-ko.md +199 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/07-data-review-night-en.md +194 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/08-image-led-atlas-ko.md +175 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/09-briefing-gazette-ko.md +204 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/10-status-update-ledger-ko.md +179 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/11-portfolio-studio-en.md +164 -0
- package/plugins/litclaude/skills/lit-pptx/references/examples/12-research-tutorial-chalk-en.md +174 -0
- package/plugins/litclaude/skills/lit-pptx/references/interview-protocol.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/layout-families.md +198 -0
- package/plugins/litclaude/skills/lit-pptx/references/stages/autopilot.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/stages/interview.md +2 -2
- package/plugins/litclaude/skills/lit-pptx/references/stages/plan.md +1 -1
- package/plugins/litclaude/skills/lit-pptx/references/title-treatments.md +109 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/atlas.md +139 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/chalk.md +148 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/gazette.md +149 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/ledger.md +149 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/night.md +147 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/paper.md +151 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/signal.md +150 -0
- package/plugins/litclaude/skills/lit-pptx/references/tonalities/studio.md +132 -0
- package/plugins/litclaude/skills/lit-pptx/scripts/compile-deck.js +64 -16
- package/plugins/litclaude/skills/lit-pptx/scripts/craft_extras.py +664 -3
- package/plugins/litclaude/skills/lit-pptx/scripts/deck_craft.py +48 -6
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/layout-resolver.js +362 -11
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +211 -65
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/render-pack.js +3559 -0
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/slide-ast.js +10 -6
- package/plugins/litclaude/skills/lit-pptx/scripts/lib/template-registry.js +286 -1
- package/plugins/litclaude/skills/lit-pptx/scripts/qa_deck.py +39 -10
- package/plugins/litclaude/skills/lit-pptx/scripts/validate_pptx.py +22 -7
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
- package/plugins/litclaude/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +92 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +97 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +93 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +94 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/night/pack.yaml +93 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/paper/pack.yaml +98 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/signal/pack.yaml +94 -0
- package/plugins/litclaude/skills/lit-pptx/templates/tonalities/studio/pack.yaml +92 -0
|
@@ -46,6 +46,7 @@ EMU = 914400.0
|
|
|
46
46
|
FILL_MIN = 0.45
|
|
47
47
|
DISPLAY_PT = 30.0
|
|
48
48
|
CAPTION_PT = 10.5
|
|
49
|
+
QUOTE_MARKS = {"“", "”", "‘", "’", "\"", "«", "»", "「", "」", "『", "』"}
|
|
49
50
|
EDGE_TOL = 0.03
|
|
50
51
|
FULL_BLEED = 0.92
|
|
51
52
|
# A bracket with a digit in it is a citation marker ([1], [S3]); a blank has none.
|
|
@@ -144,6 +145,15 @@ def _outlined(shape):
|
|
|
144
145
|
return len(line) > 0 or line.get("w") is not None
|
|
145
146
|
|
|
146
147
|
|
|
148
|
+
def _bleeds(box, W, H, tol=EDGE_TOL):
|
|
149
|
+
"""A fill that touches two or more canvas edges (a band, a rail, a colour field) is page
|
|
150
|
+
decoration that may hold a title; it is not a card around content."""
|
|
151
|
+
if box is None:
|
|
152
|
+
return False
|
|
153
|
+
x, y, w, h = box
|
|
154
|
+
return sum([x <= tol, y <= tol, x + w >= W - tol, y + h >= H - tol]) >= 2
|
|
155
|
+
|
|
156
|
+
|
|
147
157
|
def _contains(outer, inner, tol=0.05):
|
|
148
158
|
return (inner[0] >= outer[0] - tol and inner[1] >= outer[1] - tol
|
|
149
159
|
and inner[0] + inner[2] <= outer[0] + outer[2] + tol and inner[1] + inner[3] <= outer[1] + outer[3] + tol)
|
|
@@ -152,6 +162,8 @@ def _contains(outer, inner, tol=0.05):
|
|
|
152
162
|
def slide_layout(shapes, W, H, page_area):
|
|
153
163
|
"""The slide's display flag, title, content area and content boxes, shared by the craft
|
|
154
164
|
checks here and in craft_extras.py so both judge the same region."""
|
|
165
|
+
# A frame holding only a quotation mark is the quote's ornament, not a text block.
|
|
166
|
+
shapes = [s for s in shapes if _text(s) not in QUOTE_MARKS]
|
|
155
167
|
texts = [(s, _text(s)) for s in shapes]
|
|
156
168
|
# Cover, section and closing slides: display-size type, no data visual, and at
|
|
157
169
|
# most two headline-sized lines. A KPI slide has several large numbers and is content.
|
|
@@ -159,12 +171,41 @@ def slide_layout(shapes, W, H, page_area):
|
|
|
159
171
|
for s in shapes)
|
|
160
172
|
large = [s for s, t in texts if t and _max_pt(s) >= 18]
|
|
161
173
|
display = (any(_max_pt(s) >= DISPLAY_PT for s, t in texts if t) and not has_visual and len(large) <= 2)
|
|
162
|
-
|
|
163
|
-
#
|
|
174
|
+
# A slide the tonality engine names with a content family (`family@<id>`) is a content slide,
|
|
175
|
+
# however little it holds; covers, sections, statements, quotes and full-bleed pictures are the
|
|
176
|
+
# display slides.
|
|
177
|
+
family = next((s.name.split("@", 1)[1] for s in shapes if s.name.startswith("family@")), "")
|
|
178
|
+
if family:
|
|
179
|
+
display = bool(re.match(r"(cover|section)(-|$)|(statement|quote|closing-statement|image-full)$", family))
|
|
180
|
+
|
|
181
|
+
# Title: the frame the engine names `title@…`, else the largest text near the top.
|
|
164
182
|
tops = [(s, _max_pt(s)) for s, t in texts if t and _box(s) and _box(s)[1] < H * 0.25]
|
|
165
|
-
|
|
183
|
+
named = next((s for s, t in texts if t and s.name.startswith("title@") and _box(s)), None)
|
|
184
|
+
title = named if named is not None else max(tops, key=lambda item: item[1])[0] if tops else None
|
|
166
185
|
title_bottom = (_box(title)[1] + _box(title)[3]) if title is not None else H * 0.2
|
|
167
186
|
area = (0.5, title_bottom + 0.1, W - 1.0, max(0.1, H - 0.55 - title_bottom - 0.1))
|
|
187
|
+
# A title on the body floor (bottom-anchor) closes the page: the content area is above it.
|
|
188
|
+
if title is not None and _box(title)[1] > H * 0.5:
|
|
189
|
+
area = (0.5, 0.5, W - 1.0, max(0.1, _box(title)[1] - 0.1 - 0.5))
|
|
190
|
+
# A title in a side rail: a narrow headline at the left edge, the body to its right. What stands in
|
|
191
|
+
# the rail under the title (its takeaways, criteria labels, the source) is the rail's, so the content
|
|
192
|
+
# area is the body beside the rail, from the top, and the rail's own blocks are not counted in it.
|
|
193
|
+
in_rail = []
|
|
194
|
+
rail_tops = [(named, _max_pt(named))] if named is not None and _box(named)[1] < H * 0.25 else tops
|
|
195
|
+
for shape, _ in rail_tops:
|
|
196
|
+
rail = _box(shape)
|
|
197
|
+
if _max_pt(shape) < 24 or rail[0] > W * 0.1 or rail[2] > W * 0.35:
|
|
198
|
+
continue
|
|
199
|
+
right = rail[0] + rail[2]
|
|
200
|
+
others = [_box(s) for s, t in texts if s is not shape and _box(s) and (t or _is_table(s) or _is_chart(s)) and _box(s)[1] < H - 0.55]
|
|
201
|
+
others += [_box(s) for s in shapes if s.shape_type == MSO_SHAPE_TYPE.PICTURE and _box(s) and not _decorative_picture(s, page_area)]
|
|
202
|
+
under = [b for b in others if b[0] + b[2] <= right + 0.05 and b[1] >= rail[1] + rail[3] - 0.05]
|
|
203
|
+
body = [b for b in others if b[0] >= right - 0.05]
|
|
204
|
+
if body and len(body) + len(under) == len(others):
|
|
205
|
+
title = shape
|
|
206
|
+
in_rail = under
|
|
207
|
+
area = (right + 0.1, rail[1], max(0.1, W - 0.5 - right - 0.1), max(0.1, H - 0.55 - rail[1]))
|
|
208
|
+
break
|
|
168
209
|
|
|
169
210
|
visuals = []
|
|
170
211
|
boxes = []
|
|
@@ -172,7 +213,7 @@ def slide_layout(shapes, W, H, page_area):
|
|
|
172
213
|
if shape is title:
|
|
173
214
|
continue
|
|
174
215
|
box = _box(shape)
|
|
175
|
-
if box is None:
|
|
216
|
+
if box is None or box in in_rail:
|
|
176
217
|
continue
|
|
177
218
|
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
178
219
|
if _decorative_picture(shape, page_area):
|
|
@@ -186,7 +227,7 @@ def slide_layout(shapes, W, H, page_area):
|
|
|
186
227
|
if _max_pt(shape) <= CAPTION_PT:
|
|
187
228
|
continue
|
|
188
229
|
elif shape.shape_type in (MSO_SHAPE_TYPE.AUTO_SHAPE, MSO_SHAPE_TYPE.GROUP) and box[2] * box[3] >= 0.5 and (
|
|
189
|
-
shape.shape_type == MSO_SHAPE_TYPE.GROUP or _filled(shape)):
|
|
230
|
+
shape.shape_type == MSO_SHAPE_TYPE.GROUP or _filled(shape)) and not _bleeds(box, W, H):
|
|
190
231
|
if box[2] * box[3] < FULL_BLEED * page_area:
|
|
191
232
|
visuals.append("card")
|
|
192
233
|
else:
|
|
@@ -245,7 +286,8 @@ def analyse(path: Path) -> dict:
|
|
|
245
286
|
rectangular = False
|
|
246
287
|
if not rectangular or _filled(shape) or not _outlined(shape):
|
|
247
288
|
continue
|
|
248
|
-
|
|
289
|
+
# A hairline frame drawn exactly on a picture's edge is that picture's frame.
|
|
290
|
+
if any(_contains(box, inner) for inner in content_boxes):
|
|
249
291
|
continue
|
|
250
292
|
findings.append({"check": "stray_box", "slide": number, "shape": shape.name,
|
|
251
293
|
"detail": [round(v, 2) for v in box],
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* grid-resolver.js — where things go on a slide, read from a tonality pack and two dials.
|
|
5
|
+
*
|
|
6
|
+
* A pack never reaches this file as a name: it arrives as values (palette roles, a ramp, the
|
|
7
|
+
* treatments it allows, its density and variance). The density dial picks one of three grids and
|
|
8
|
+
* the spacing tokens; a title treatment fixes where the title sits, what is drawn with it and where
|
|
9
|
+
* the body starts; a layout family decides which treatments a slide may take. Geometry is worked
|
|
10
|
+
* out in points on the 960 x 540 (16:9) or 720 x 540 (4:3) canvas; the renderer converts to inches.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const CANVAS = { "16:9": { W: 960, H: 540 }, "4:3": { W: 720, H: 540 } };
|
|
14
|
+
|
|
15
|
+
// Side margin, column width and gutter of the airy, standard, dense and compact grids. Each sums
|
|
16
|
+
// exactly to the canvas width over 12 columns and 11 gutters.
|
|
17
|
+
const GRIDS = {
|
|
18
|
+
"16:9": { airy: { margin: 60, col: 48, gutter: 24 }, standard: { margin: 48, col: 50, gutter: 24 }, dense: { margin: 36, col: 52, gutter: 24 }, compact: { margin: 24, col: 54, gutter: 24 } },
|
|
19
|
+
"4:3": { airy: { margin: 54, col: 40, gutter: 12 }, standard: { margin: 42, col: 42, gutter: 12 }, dense: { margin: 30, col: 44, gutter: 12 }, compact: { margin: 24, col: 45, gutter: 12 } },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const RAMPS = {
|
|
23
|
+
presented: { source: 12, label: 14, body: 18, lead: 24, title: 32, display: 44, cover: 56, hero: 72 },
|
|
24
|
+
reading: { source: 10, label: 12, body: 14, lead: 18, title: 26, display: 36, cover: 44, hero: 60 },
|
|
25
|
+
// The reading ramp with its three small steps one point down: the readable floor of a page read
|
|
26
|
+
// closely on screen (body 13, table cells and captions 11, sources 9).
|
|
27
|
+
compact: { source: 9, label: 11, body: 13, lead: 18, title: 26, display: 36, cover: 44, hero: 60 },
|
|
28
|
+
};
|
|
29
|
+
const RAMP_ORDER = ["source", "label", "body", "lead", "title", "display", "cover", "hero"];
|
|
30
|
+
const SCALE = [6, 12, 18, 24, 36, 48, 72];
|
|
31
|
+
|
|
32
|
+
// Vertical zones shared by every canvas (all are 540 pt tall).
|
|
33
|
+
const ZONE = { top: 36, body: 120, floor: 486, footer: 498, footerH: 18 };
|
|
34
|
+
|
|
35
|
+
// What the density dial sets: grid, ramp, gaps, padding, table rows and the empty band a content
|
|
36
|
+
// slide may leave under its last line. From density 5 a deck reads on the reading ramp (body 14 pt);
|
|
37
|
+
// density 10 is the compact step: 24 pt margins, body 13 pt, titles that sit close over the body.
|
|
38
|
+
const DENSITY_BANDS = [
|
|
39
|
+
{ upTo: 2, grid: "airy", ramp: "presented", item: 18, group: 36, block: 48, pad: 24, rowMin: 36, maxLines: 6, cap: 0.28, stepUp: true },
|
|
40
|
+
{ upTo: 4, grid: "standard", ramp: "presented", item: 12, group: 24, block: 36, pad: 24, rowMin: 32, maxLines: 8, cap: 0.24, stepUp: true },
|
|
41
|
+
{ upTo: 6, grid: "standard", ramp: "reading", item: 6, group: 18, block: 24, pad: 18, rowMin: 24, maxLines: 12, cap: 0.18, stepUp: true },
|
|
42
|
+
{ upTo: 8, grid: "dense", ramp: "reading", item: 6, group: 12, block: 24, pad: 12, rowMin: 22, maxLines: 16, cap: 0.14, stepUp: true },
|
|
43
|
+
{ upTo: 9, grid: "dense", ramp: "reading", item: 6, group: 12, block: 24, pad: 12, rowMin: 20, maxLines: 18, cap: 0.12, stepUp: false },
|
|
44
|
+
{ upTo: 10, grid: "compact", ramp: "compact", item: 6, group: 12, block: 18, pad: 12, rowMin: 18, maxLines: 22, cap: 0.12, stepUp: false, tight: true },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const TREATMENTS = ["top-rule", "top-plain-large", "side-rail", "band", "statement", "overlay", "kicker-numeral", "bottom-anchor"];
|
|
48
|
+
|
|
49
|
+
// Content layout families: the slide role that picks a treatment from the pack, and the
|
|
50
|
+
// treatments the family's geometry allows.
|
|
51
|
+
const FAMILIES = {
|
|
52
|
+
"text-column": { role: "content", allow: ["top-rule", "band", "kicker-numeral", "side-rail", "top-plain-large"] },
|
|
53
|
+
"text-two-column": { role: "content", allow: ["top-rule", "band", "kicker-numeral", "top-plain-large"] },
|
|
54
|
+
"summary-box-list": { role: "content", allow: ["band", "top-rule", "kicker-numeral"] },
|
|
55
|
+
"sidebar-note": { role: "content", allow: ["top-rule", "kicker-numeral", "band", "side-rail"] },
|
|
56
|
+
agenda: { role: "content", allow: ["top-rule", "kicker-numeral", "band", "top-plain-large", "side-rail"] },
|
|
57
|
+
statement: { role: "statement", allow: ["statement"] },
|
|
58
|
+
quote: { role: "statement", allow: ["statement", "top-rule"] },
|
|
59
|
+
"kpi-row": { role: "data", allow: ["top-rule", "band", "top-plain-large", "side-rail", "bottom-anchor"] },
|
|
60
|
+
"table-insight": { role: "data", allow: ["top-rule", "band", "side-rail", "bottom-anchor"] },
|
|
61
|
+
"ledger-table": { role: "data", allow: ["top-rule", "band", "kicker-numeral"] },
|
|
62
|
+
"dashboard-grid": { role: "data", allow: ["top-rule", "band", "side-rail"] },
|
|
63
|
+
"kpi-over-chart": { role: "data", allow: ["top-rule", "band", "top-plain-large"] },
|
|
64
|
+
"matrix-2x2": { role: "data", allow: ["top-rule", "band", "side-rail"] },
|
|
65
|
+
comparison: { role: "data", allow: ["top-rule", "band", "top-plain-large", "side-rail", "bottom-anchor"] },
|
|
66
|
+
"chart-insight": { role: "data-takeaway", allow: ["top-rule", "band", "side-rail", "bottom-anchor", "top-plain-large"] },
|
|
67
|
+
"full-chart": { role: "data-takeaway", allow: ["bottom-anchor", "top-plain-large", "top-rule"] },
|
|
68
|
+
"big-number": { role: "data-takeaway", allow: ["top-rule", "side-rail", "bottom-anchor", "top-plain-large"] },
|
|
69
|
+
process: { role: "sequence", allow: ["top-rule", "band", "top-plain-large", "side-rail"] },
|
|
70
|
+
timeline: { role: "sequence", allow: ["top-rule", "band", "top-plain-large", "bottom-anchor"] },
|
|
71
|
+
"step-diagram": { role: "sequence", allow: ["top-rule", "top-plain-large", "band"] },
|
|
72
|
+
"image-full": { role: "image", allow: ["overlay"] },
|
|
73
|
+
"image-split": { role: "image", allow: ["top-plain-large", "side-rail", "bottom-anchor", "top-rule"] },
|
|
74
|
+
"photo-grid": { role: "image", allow: ["top-plain-large", "bottom-anchor", "top-rule"] },
|
|
75
|
+
"figure-pair": { role: "image", allow: ["top-rule", "bottom-anchor", "side-rail"] },
|
|
76
|
+
"figure-academic": { role: "image", allow: ["top-rule", "bottom-anchor", "side-rail"] },
|
|
77
|
+
"asymmetric-feature": { role: "image", allow: ["top-plain-large", "bottom-anchor", "top-rule"] },
|
|
78
|
+
method: { role: "definition", allow: ["top-rule", "side-rail", "band"] },
|
|
79
|
+
"references-appendix": { role: "reference", allow: ["top-rule", "band"] },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Cover, section and closing variants. A closing other than the statement is a content slide whose
|
|
83
|
+
// title states the ask, so it takes the content role.
|
|
84
|
+
const VARIANTS = {
|
|
85
|
+
cover: ["cover-typographic", "cover-split-field", "cover-split-image", "cover-full-image", "cover-band", "cover-numeral", "cover-index", "cover-figures", "cover-rail"],
|
|
86
|
+
section: ["section-field", "section-numeral", "section-rail", "section-image", "section-rule", "section-band"],
|
|
87
|
+
closing: ["closing-ask", "closing-decision-box", "closing-statement", "closing-summary-list", "closing-contact-split"],
|
|
88
|
+
};
|
|
89
|
+
// The six layouts every existing deck uses, read as families; v1 archetype names that were renamed.
|
|
90
|
+
const LEGACY_LAYOUTS = { content: "text-column", main: "text-column", summary: "comparison", cover: "cover", section: "section", closing: "closing-statement", free: "free" };
|
|
91
|
+
const RENAMED = { "cover-split": "cover-split-field" };
|
|
92
|
+
|
|
93
|
+
function clampDial(value, fallback) {
|
|
94
|
+
const n = Math.round(Number(value));
|
|
95
|
+
return Number.isFinite(n) ? Math.max(1, Math.min(10, n)) : fallback;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function densityTokens(density, packRamp) {
|
|
99
|
+
const d = clampDial(density, 5);
|
|
100
|
+
const band = DENSITY_BANDS.find((b) => d <= b.upTo);
|
|
101
|
+
const ramp = band.ramp || packRamp || "presented";
|
|
102
|
+
return { density: d, ...band, ramp, sizes: RAMPS[ramp] };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Most distinct title treatments a deck may mix at this variance, never more than the pack allows. */
|
|
106
|
+
function varianceMax(variance, treatmentCount) {
|
|
107
|
+
const v = clampDial(variance, 5);
|
|
108
|
+
const max = v <= 3 ? 2 : v === 4 ? 3 : v <= 6 ? 4 : 5;
|
|
109
|
+
return Math.min(max, treatmentCount);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function makeGrid(canvas, name) {
|
|
113
|
+
const { W, H } = CANVAS[canvas];
|
|
114
|
+
const g = GRIDS[canvas][name];
|
|
115
|
+
const pitch = g.col + g.gutter;
|
|
116
|
+
return {
|
|
117
|
+
canvas, name, W, H, ...g, pitch,
|
|
118
|
+
/** Columns a…b (1-based, inclusive) as x and width in points. */
|
|
119
|
+
span(a, b) { return { x: g.margin + pitch * (a - 1), w: pitch * (b - a + 1) - g.gutter }; },
|
|
120
|
+
colX(a) { return g.margin + pitch * (a - 1); },
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Next step of the spacing scale, never past `max`. */
|
|
125
|
+
function stepGap(value, max) {
|
|
126
|
+
const next = SCALE.find((s) => s > value);
|
|
127
|
+
return next != null && next <= max ? next : value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Next ramp role above `role` (body → lead), or the same role at the top of the ramp. */
|
|
131
|
+
function stepRole(role) {
|
|
132
|
+
const i = RAMP_ORDER.indexOf(role);
|
|
133
|
+
return i >= 0 && i < RAMP_ORDER.length - 1 ? RAMP_ORDER[i + 1] : role;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Text measure ─────────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
/** Advance of a string in ems: Hangul about one em, Latin about half, spaces less. */
|
|
139
|
+
function textEms(text) {
|
|
140
|
+
let ems = 0;
|
|
141
|
+
for (const ch of String(text || "")) {
|
|
142
|
+
if (/\s/u.test(ch)) ems += 0.28;
|
|
143
|
+
else if (/[ᄀ-ᇿ-가-힣一-鿿]/u.test(ch)) ems += 0.94;
|
|
144
|
+
else if (/[A-Z0-9%]/u.test(ch)) ems += 0.62;
|
|
145
|
+
else ems += 0.52;
|
|
146
|
+
}
|
|
147
|
+
return ems;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Lines a paragraph wraps to in a frame `widthPt` wide; a little slack covers word breaks. */
|
|
151
|
+
function lineCount(text, widthPt, sizePt) {
|
|
152
|
+
const perLine = Math.max(1, widthPt / sizePt);
|
|
153
|
+
return String(text || "").split("\n").reduce((sum, part) => sum + Math.max(1, Math.ceil((textEms(part) * 1.06) / perLine)), 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Display text broken into `lines` lines of near-equal width at word boundaries, so a two-line
|
|
158
|
+
* statement never ends on one orphaned word. Returns the text unchanged when it has no spaces,
|
|
159
|
+
* already holds a break, or no split keeps every line inside `perLineEms`.
|
|
160
|
+
*/
|
|
161
|
+
function balanceLines(text, lines, perLineEms) {
|
|
162
|
+
const s = String(text || "");
|
|
163
|
+
if (lines < 2 || s.includes("\n")) return s;
|
|
164
|
+
const words = breakUnits(s);
|
|
165
|
+
if (words.length < lines) return s;
|
|
166
|
+
const width = (i, j) => textEms(words.slice(i, j).join(" ")) * 1.06;
|
|
167
|
+
// best[k][j]: the smallest widest line setting words 0..j-1 on k lines; the last line holds at
|
|
168
|
+
// least `tail` words.
|
|
169
|
+
const solve = (tail) => {
|
|
170
|
+
const best = Array.from({ length: lines + 1 }, () => new Array(words.length + 1).fill(Infinity));
|
|
171
|
+
const cut = Array.from({ length: lines + 1 }, () => new Array(words.length + 1).fill(-1));
|
|
172
|
+
best[0][0] = 0;
|
|
173
|
+
for (let k = 1; k <= lines; k++) {
|
|
174
|
+
for (let j = k; j <= words.length; j++) {
|
|
175
|
+
const last = k === lines ? Math.min(j - tail, j - 1) : j - 1;
|
|
176
|
+
for (let i = k - 1; i <= last; i++) {
|
|
177
|
+
const v = Math.max(best[k - 1][i], width(i, j));
|
|
178
|
+
if (v < best[k][j]) { best[k][j] = v; cut[k][j] = i; }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (best[lines][words.length] > perLineEms) return null;
|
|
183
|
+
const out = [];
|
|
184
|
+
for (let k = lines, j = words.length; k > 0; k--) {
|
|
185
|
+
const i = cut[k][j];
|
|
186
|
+
out.unshift(words.slice(i, j).join(" "));
|
|
187
|
+
j = i;
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
};
|
|
191
|
+
// Widow control: a last line of one word only when no split keeps two there.
|
|
192
|
+
const set = (words.length >= lines + 1 && solve(2)) || solve(1);
|
|
193
|
+
return set ? set.join("\n") : s;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Native Korean number words that take a counter after them ("다섯 분기", "한 곳"), and prefixes
|
|
197
|
+
// that belong to the figure after them ("연 6억 원", "약 40%").
|
|
198
|
+
const NUMBER_WORD = /(?:^|\s)(?:한|두|세|네|다섯|여섯|일곱|여덟|아홉|열|열한|열두|스무|몇|첫|연|월|주|약|총|최대|최소)$/u;
|
|
199
|
+
// A separator stays at the end of the line before it, never at the start of the next.
|
|
200
|
+
const SEPARATOR = /^[·・|/–—]$/u;
|
|
201
|
+
|
|
202
|
+
/** A short parenthetical ("(▲ +3.9%)", up to 16 characters) reads as one unit and never breaks inside. */
|
|
203
|
+
const SHORT_PAREN = 16;
|
|
204
|
+
|
|
205
|
+
function closesShort(open, w) {
|
|
206
|
+
const at = open.lastIndexOf("(");
|
|
207
|
+
return at >= 0 && !open.slice(at).includes(")") && `${open.slice(at)} ${w}`.length <= SHORT_PAREN;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Words a line may break between; a number stays with the word after it ("30억 원을", "4 분기", "다섯 분기"),
|
|
212
|
+
* and a short parenthetical stays whole.
|
|
213
|
+
*/
|
|
214
|
+
function breakUnits(s) {
|
|
215
|
+
const words = String(s).split(/ +/u).filter(Boolean);
|
|
216
|
+
const joined = [];
|
|
217
|
+
for (let i = 0; i < words.length; i++) {
|
|
218
|
+
let unit = words[i];
|
|
219
|
+
// Join up to the word that closes the parenthesis, only when it closes within the short length.
|
|
220
|
+
let j = i;
|
|
221
|
+
let probe = unit;
|
|
222
|
+
while (j + 1 < words.length && closesShort(probe, words[j + 1])) {
|
|
223
|
+
probe += ` ${words[++j]}`;
|
|
224
|
+
if (probe.slice(probe.lastIndexOf("(")).includes(")")) { unit = probe; i = j; break; }
|
|
225
|
+
}
|
|
226
|
+
joined.push(unit);
|
|
227
|
+
}
|
|
228
|
+
return joined.reduce((out, w) => {
|
|
229
|
+
if (out.length && SEPARATOR.test(w)) out[out.length - 1] += ` ${w}`;
|
|
230
|
+
else if (out.length && (/[\d억만조천]$/u.test(out[out.length - 1]) || NUMBER_WORD.test(out[out.length - 1]))) out[out.length - 1] += ` ${w}`;
|
|
231
|
+
else out.push(w);
|
|
232
|
+
return out;
|
|
233
|
+
}, []);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Body text broken at spaces only, filling each line in turn, for a frame that holds `maxLines`.
|
|
238
|
+
* A renderer's own line breaking may split a number from the Hangul after it ("51 / 점이라는",
|
|
239
|
+
* "(10 / 월"), so text that wraps gets its breaks here, where a number keeps its unit and a word
|
|
240
|
+
* stays whole; a one-word last line takes a word from the line above when it fits. The text is
|
|
241
|
+
* returned unchanged when it fits one line, already holds a break, has a word wider than a line,
|
|
242
|
+
* or needs more lines than the frame holds.
|
|
243
|
+
*/
|
|
244
|
+
function keepLines(text, perLineEms, maxLines, slack = 1.06) {
|
|
245
|
+
const s = String(text || "");
|
|
246
|
+
const width = (t) => textEms(t.replace(/\*\*/gu, "")) * slack;
|
|
247
|
+
if (s.includes("\n") || width(s) <= perLineEms) return s;
|
|
248
|
+
const words = breakUnits(s);
|
|
249
|
+
if (words.some((w) => width(w) > perLineEms)) return s;
|
|
250
|
+
// Lines are lists of break units, so the widow pull below never splits a number from its word.
|
|
251
|
+
const lines = [];
|
|
252
|
+
for (const w of words) {
|
|
253
|
+
const last = lines[lines.length - 1];
|
|
254
|
+
if (last != null && width([...last, w].join(" ")) <= perLineEms) last.push(w);
|
|
255
|
+
else lines.push([w]);
|
|
256
|
+
}
|
|
257
|
+
const n = lines.length;
|
|
258
|
+
if (n >= 2 && lines[n - 1].length === 1 && !/ /u.test(lines[n - 1][0])) {
|
|
259
|
+
const prev = lines[n - 2];
|
|
260
|
+
const moved = [prev[prev.length - 1], ...lines[n - 1]];
|
|
261
|
+
if (prev.length >= 2 && width(moved.join(" ")) <= perLineEms) { lines[n - 2] = prev.slice(0, -1); lines[n - 1] = moved; }
|
|
262
|
+
}
|
|
263
|
+
// The estimate runs wide of the faces, so a set that needs a line more than the frame holds is
|
|
264
|
+
// tried once more at the bare estimate before the renderer is left to break the text.
|
|
265
|
+
if (n > maxLines) return slack > 1 ? keepLines(s, perLineEms, maxLines, 1) : s;
|
|
266
|
+
return lines.map((l) => l.join(" ")).join("\n");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isKorean(text) {
|
|
270
|
+
return /[가-힣]/u.test(String(text || ""));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Leading for body text: Hangul needs more line space than Latin at the same size. */
|
|
274
|
+
function leading(text) {
|
|
275
|
+
return isKorean(text) ? 1.4 : 1.25;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* How much wider than the estimate a face sets: heavier weights of the bundled faces run wider,
|
|
280
|
+
* so a frame measured at the regular width would lose its last line.
|
|
281
|
+
*/
|
|
282
|
+
function faceWidth(face) {
|
|
283
|
+
const font = (face && face.font) || "";
|
|
284
|
+
return /9 Black/u.test(font) ? 1.14 : /7 Bold/u.test(font) ? 1.08 : /5 Medium/u.test(font) ? 1.05 : face && face.bold ? 1.04 : 1.0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function textHeight(text, widthPt, sizePt, lineSpacing) {
|
|
288
|
+
return lineCount(text, widthPt, sizePt) * sizePt * (lineSpacing || leading(text));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Families and treatments ─────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
function familyIds() {
|
|
294
|
+
return [...Object.keys(FAMILIES), ...VARIANTS.cover, ...VARIANTS.section, ...VARIANTS.closing];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The family a slide's `layout:` names, its kind and the role that picks its title treatment.
|
|
299
|
+
* Legacy layout names keep working; an unknown name is an error that lists what exists.
|
|
300
|
+
*/
|
|
301
|
+
function resolveFamily(layout, slideNumber) {
|
|
302
|
+
const name = RENAMED[layout] || layout;
|
|
303
|
+
const legacy = LEGACY_LAYOUTS[name];
|
|
304
|
+
if (legacy === "cover") return { family: null, kind: "cover", legacyLayout: name };
|
|
305
|
+
if (legacy === "section") return { family: null, kind: "section", legacyLayout: name };
|
|
306
|
+
if (legacy === "free") return { family: "free", kind: "free", legacyLayout: name };
|
|
307
|
+
const family = legacy || name;
|
|
308
|
+
if (FAMILIES[family]) return { family, kind: "content", role: FAMILIES[family].role, ...(legacy ? { legacyLayout: name } : {}) };
|
|
309
|
+
if (VARIANTS.cover.includes(family)) return { family, kind: "cover" };
|
|
310
|
+
if (VARIANTS.section.includes(family)) return { family, kind: "section" };
|
|
311
|
+
if (family === "closing-statement") return { family, kind: "content", role: "statement", ...(legacy ? { legacyLayout: name } : {}) };
|
|
312
|
+
if (VARIANTS.closing.includes(family)) return { family, kind: "content", role: "content" };
|
|
313
|
+
throw new Error(
|
|
314
|
+
`Slide ${slideNumber}: layout "${layout}" is neither a layout family nor a legacy layout. ` +
|
|
315
|
+
`Families: ${familyIds().join(", ")}. Legacy layouts: ${Object.keys(LEGACY_LAYOUTS).join(", ")}`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Treatments a family may take. A closing that is a content slide states the ask as a top title
|
|
321
|
+
* (S8.1 #20), so it takes only the top treatments.
|
|
322
|
+
*/
|
|
323
|
+
function familyAllows(family) {
|
|
324
|
+
if (FAMILIES[family]) return FAMILIES[family].allow;
|
|
325
|
+
if (family === "closing-statement") return ["statement"];
|
|
326
|
+
if (VARIANTS.closing.includes(family)) return ["top-rule", "band", "top-plain-large"];
|
|
327
|
+
return FAMILIES["text-column"].allow;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Choose the slide's title treatment: the per-slide `title:` key when given (it must be a known
|
|
332
|
+
* treatment, one of the pack's, allowed by the family and drawable on this slide), else the pack's
|
|
333
|
+
* default for the slide role, else the next pack treatment the family allows. The variance dial
|
|
334
|
+
* stops a deck from adding treatments past its maximum.
|
|
335
|
+
*/
|
|
336
|
+
function pickTreatment({ pack, family, role, override, slideNumber, used, max, applicable, onNote }) {
|
|
337
|
+
const allow = familyAllows(family);
|
|
338
|
+
if (override) {
|
|
339
|
+
if (!TREATMENTS.includes(override)) {
|
|
340
|
+
throw new Error(`Slide ${slideNumber}: unknown title treatment "${override}". Treatments: ${TREATMENTS.join(", ")}`);
|
|
341
|
+
}
|
|
342
|
+
if (!pack.treatments.includes(override)) {
|
|
343
|
+
throw new Error(`Slide ${slideNumber}: title "${override}" is not one of the ${pack.id} treatments (${pack.treatments.join(", ")})`);
|
|
344
|
+
}
|
|
345
|
+
if (!allow.includes(override)) {
|
|
346
|
+
throw new Error(`Slide ${slideNumber}: the ${family} family does not take "${override}"; it allows ${allow.join(", ")}`);
|
|
347
|
+
}
|
|
348
|
+
const why = applicable(override);
|
|
349
|
+
if (why !== true) throw new Error(`Slide ${slideNumber}: title "${override}" cannot be drawn here: ${why}`);
|
|
350
|
+
return override;
|
|
351
|
+
}
|
|
352
|
+
const preferred = (pack.structure || {})[family] || pack["role-defaults"][role];
|
|
353
|
+
const order = [preferred, ...pack.treatments.filter((t) => t !== preferred)].filter(Boolean);
|
|
354
|
+
let candidates = order.filter((t) => pack.treatments.includes(t) && allow.includes(t) && applicable(t) === true);
|
|
355
|
+
if (!candidates.length) {
|
|
356
|
+
// A title longer than every budget still gets the first treatment it would otherwise take.
|
|
357
|
+
candidates = order.filter((t) => pack.treatments.includes(t) && allow.includes(t) && applicable(t) === BUDGET);
|
|
358
|
+
if (candidates.length && onNote) onNote(`slide ${slideNumber}: the title is longer than the ${candidates[0]} budget; shorten it`);
|
|
359
|
+
}
|
|
360
|
+
if (used.size >= max) {
|
|
361
|
+
const reuse = candidates.filter((t) => used.has(t));
|
|
362
|
+
if (reuse.length) candidates = reuse;
|
|
363
|
+
}
|
|
364
|
+
if (!candidates.length) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`Slide ${slideNumber}: no ${pack.id} treatment (${pack.treatments.join(", ")}) fits the ${family} family ` +
|
|
367
|
+
`(${allow.join(", ")}) on this slide; choose another layout family or tonality`
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
return candidates[0];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Treatment geometry ──────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Title frame, companion decoration and body zone of one treatment, in points.
|
|
377
|
+
*
|
|
378
|
+
* ctx: { grid, sizes, title, hero, decoration (Set), numeral, rail ("surface" or "field") }
|
|
379
|
+
* Returns { title, decor[], numeral?, body: {a, b, y, bottom}, side?, lines } where body columns
|
|
380
|
+
* a…b are on the deck grid. `fits` is false when the title does not fit the treatment's budget.
|
|
381
|
+
*/
|
|
382
|
+
function treatmentGeometry(id, ctx) {
|
|
383
|
+
const { grid, sizes } = ctx;
|
|
384
|
+
// Lines of the title at a size, in the face that sets it (ctx.wide: face role → width factor).
|
|
385
|
+
const fit = (t, w, size, face = "title") => lineCount(t, w / ((ctx.wide && ctx.wide[face]) || 1), size);
|
|
386
|
+
const text = ctx.title || "";
|
|
387
|
+
const decor = [];
|
|
388
|
+
const full = grid.span(1, 12);
|
|
389
|
+
const out = { id, decor, fits: true };
|
|
390
|
+
const box = (cols, y, size, lines, face, colour) => {
|
|
391
|
+
const s = grid.span(cols[0], cols[1]);
|
|
392
|
+
return { x: s.x, y, w: s.w, h: Math.ceil(lines * size * 1.15), size, face, colour, lines };
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// On the compact step (ctx.tight) a top title frame is as tall as its lines, the rule sits 6 pt
|
|
396
|
+
// under it and the body 18 pt under the rule; a band keeps its height and the body follows it at 12.
|
|
397
|
+
if (id === "top-rule" || id === "band") {
|
|
398
|
+
const cols = id === "band" ? [1, 11] : [1, 12];
|
|
399
|
+
const w = grid.span(cols[0], cols[1]).w;
|
|
400
|
+
const lines = fit(text, w, sizes.title);
|
|
401
|
+
out.fits = lines <= 1 || (lines <= 2 && sizes.title * 1.15 * 2 <= 60.5);
|
|
402
|
+
const h = ctx.tight && id === "top-rule" ? Math.ceil(Math.max(1, lines) * sizes.title * 1.15) + 2 : 60;
|
|
403
|
+
out.title = { ...box(cols, 36, sizes.title, lines, "title", id === "band" ? "on-field" : "ink"), h };
|
|
404
|
+
if (id === "band") {
|
|
405
|
+
decor.push({ x: 0, y: 0, w: grid.W, h: 108, role: "field", kind: "band" });
|
|
406
|
+
out.body = { a: 1, b: 12, y: ctx.tight ? 120 : 132, bottom: ZONE.floor };
|
|
407
|
+
} else {
|
|
408
|
+
const ruleY = 36 + h + 6;
|
|
409
|
+
if (ctx.decoration.has("accent-rule")) decor.push({ x: out.title.x, y: ruleY, w: grid.col, h: 4, role: "accent", kind: "rule" });
|
|
410
|
+
else decor.push({ x: full.x, y: ruleY, w: full.w, h: 0.75, role: "line", kind: "rule" });
|
|
411
|
+
out.body = { a: 1, b: 12, y: ctx.tight ? ruleY + 18 : ZONE.body, bottom: ZONE.floor };
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (id === "top-plain-large") {
|
|
417
|
+
const w = grid.span(1, 10).w;
|
|
418
|
+
const lines = fit(text, w, sizes.display, "display");
|
|
419
|
+
out.fits = lines <= 2;
|
|
420
|
+
// The frame reaches 8 pt into the gap below it: heavy display faces hang their descenders lower.
|
|
421
|
+
const h = lines <= 1 ? 54 : 102;
|
|
422
|
+
out.title = { ...box([1, 10], 36, sizes.display, lines, "display", "ink"), h: h + 8 };
|
|
423
|
+
out.body = { a: 1, b: 12, y: 36 + h + (ctx.tight ? 18 : 24), bottom: ZONE.floor };
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (id === "side-rail") {
|
|
428
|
+
const s = grid.span(1, 4);
|
|
429
|
+
const lines = fit(text, s.w, sizes.title);
|
|
430
|
+
out.fits = lines <= 4;
|
|
431
|
+
const filled = ctx.decoration.has("rail-fill");
|
|
432
|
+
const rail = filled && ctx.rail === "field" ? "field" : "surface";
|
|
433
|
+
out.title = box([1, 4], 36, sizes.title, lines, "title", rail === "field" ? "on-field" : "ink");
|
|
434
|
+
// In a rail a few words fill a line, so one word moving down can add a line the estimate does
|
|
435
|
+
// not see; the frame keeps a spare line (the text is top-aligned, so nothing moves).
|
|
436
|
+
out.title.h = Math.ceil((lines + 1) * sizes.title * 1.15);
|
|
437
|
+
const railRight = s.x + s.w + grid.gutter / 2;
|
|
438
|
+
if (filled) decor.push({ x: 0, y: 0, w: railRight, h: grid.H, role: rail, kind: "rail" });
|
|
439
|
+
else if (ctx.decoration.has("hairline-rule")) decor.push({ x: railRight, y: 36, w: 0.75, h: 450, role: "line", kind: "rule" });
|
|
440
|
+
out.body = { a: 5, b: 12, y: 36, bottom: ZONE.floor };
|
|
441
|
+
return out;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (id === "statement") {
|
|
445
|
+
// A pack may set the sentence beside a rail (`statementCols`); a hero pack sets it at the hero
|
|
446
|
+
// step while it breaks into at most three balanced lines.
|
|
447
|
+
const cols = ctx.statementCols || [1, 10];
|
|
448
|
+
const w = grid.span(cols[0], cols[1]).w;
|
|
449
|
+
const heroLines = fit(text, w, sizes.hero, "display");
|
|
450
|
+
const useHero = ctx.hero && heroLines <= 3;
|
|
451
|
+
const size = useHero ? sizes.hero : sizes.display;
|
|
452
|
+
const lines = useHero ? heroLines : fit(text, w, sizes.display, "display");
|
|
453
|
+
out.fits = lines <= 3;
|
|
454
|
+
const h = Math.ceil(lines * size * 1.15);
|
|
455
|
+
const y = Math.round((303 - h / 2) / 6) * 6;
|
|
456
|
+
out.title = { ...box(cols, y, size, lines, "display", "ink"), h };
|
|
457
|
+
out.support = { ...grid.span(cols[0], Math.min(12, cols[0] + 7)), y: y + h + 24 };
|
|
458
|
+
out.body = null;
|
|
459
|
+
return out;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (id === "overlay") {
|
|
463
|
+
const s = grid.span(1, 7);
|
|
464
|
+
const lines = fit(text, s.w, sizes.title);
|
|
465
|
+
out.fits = lines <= 2;
|
|
466
|
+
// The panel is as tall as the title and its caption line (ending 24 pt under the caption at 432), so its
|
|
467
|
+
// lower part never stands empty; the sample tag in the footer zone sits on the picture, clear of its edge.
|
|
468
|
+
decor.push({ x: 0, y: 324, w: s.x + s.w + 24, h: 146, role: "field", kind: "panel", alpha: 12 });
|
|
469
|
+
out.title = { ...box([1, 7], 348, sizes.title, lines, "title", "on-field"), h: 72 };
|
|
470
|
+
out.image = { x: 0, y: 0, w: grid.W, h: grid.H };
|
|
471
|
+
out.body = null;
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (id === "kicker-numeral") {
|
|
476
|
+
const w = grid.span(3, 12).w;
|
|
477
|
+
const lines = fit(text, w, sizes.title);
|
|
478
|
+
out.fits = lines <= 1 && ctx.numeral != null;
|
|
479
|
+
const n = grid.span(1, 2);
|
|
480
|
+
out.numeral = { x: n.x, y: 36, w: n.w, h: 72, size: sizes.cover, text: String(ctx.numeral || "") };
|
|
481
|
+
out.title = { ...box([3, 12], 48, sizes.title, lines, "title", "ink"), h: 60 };
|
|
482
|
+
out.body = { a: 1, b: 12, y: ctx.tight ? 120 : 132, bottom: ZONE.floor };
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (id === "bottom-anchor") {
|
|
487
|
+
// The title's last line ends on the body floor, so a one-line title leaves no band under itself;
|
|
488
|
+
// the hairline sits 12 pt above the title and the visual's zone ends 24 pt above it.
|
|
489
|
+
const s = grid.span(1, 9);
|
|
490
|
+
const lines = fit(text, s.w, sizes.title);
|
|
491
|
+
out.fits = lines <= 2;
|
|
492
|
+
const h = Math.ceil(Math.max(1, lines) * sizes.title * 1.15) + 2;
|
|
493
|
+
const y = ZONE.floor - h;
|
|
494
|
+
out.title = { ...box([1, 9], y, sizes.title, lines, "title", "ink"), h };
|
|
495
|
+
if (ctx.decoration.has("hairline-rule")) decor.push({ x: full.x, y: y - 12, w: full.w, h: 0.75, role: "line", kind: "rule" });
|
|
496
|
+
out.side = { ...grid.span(10, 12), y, bottom: ZONE.floor };
|
|
497
|
+
out.body = { a: 1, b: 12, y: 36, bottom: y - 24 };
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
throw new Error(`unknown title treatment "${id}"`);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const BUDGET = "the title does not fit its budget";
|
|
504
|
+
|
|
505
|
+
/** Can this treatment be drawn on a slide with these facts? true, or the reason it cannot. */
|
|
506
|
+
function treatmentApplies(id, facts, ctx) {
|
|
507
|
+
if (id === "kicker-numeral" && ctx.numeral == null) return "no real number to set beside the title";
|
|
508
|
+
const g = treatmentGeometry(id, ctx);
|
|
509
|
+
if (!g.fits) return BUDGET;
|
|
510
|
+
if (id === "overlay" && !facts.image) return "it needs a full-bleed image";
|
|
511
|
+
if (id === "statement" && (facts.visual || facts.textBlocks > 1)) return "a statement carries one sentence and at most one support line";
|
|
512
|
+
if (id === "bottom-anchor" && !facts.visual && facts.textBlocks > 1) return "the title closes a page whose upper part is a visual";
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
module.exports = {
|
|
517
|
+
CANVAS, GRIDS, RAMPS, SCALE, ZONE, TREATMENTS, FAMILIES, VARIANTS, LEGACY_LAYOUTS,
|
|
518
|
+
densityTokens, varianceMax, makeGrid, stepGap, stepRole, clampDial,
|
|
519
|
+
textEms, lineCount, leading, textHeight, isKorean, faceWidth, balanceLines, keepLines,
|
|
520
|
+
familyIds, resolveFamily, familyAllows, pickTreatment, treatmentGeometry, treatmentApplies,
|
|
521
|
+
};
|