@litfamily/litopencode 1.0.15 → 1.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README-Ko-KR.md +11 -11
- package/README.md +11 -11
- package/dist/activation-routing.js +1 -1
- package/dist/auto-handoff.js +34 -1
- package/dist/features.js +2 -2
- package/dist/skills.js +6 -4
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +1 -1
- package/package.json +1 -1
- package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
- package/skills/lit-docx/SKILL.md +80 -19
- package/skills/lit-docx/references/components.md +253 -0
- package/skills/lit-docx/references/direction-step.md +234 -0
- package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
- package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
- package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
- package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
- package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
- package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
- package/skills/lit-docx/references/examples/07-report-en.md +137 -0
- package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
- package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
- package/skills/lit-docx/references/page-composition.md +306 -0
- package/skills/lit-docx/references/tonalities/brief.md +104 -0
- package/skills/lit-docx/references/tonalities/journal.md +101 -0
- package/skills/lit-docx/references/tonalities/manual.md +111 -0
- package/skills/lit-docx/references/tonalities/memo.md +105 -0
- package/skills/lit-docx/references/tonalities/proposal.md +101 -0
- package/skills/lit-docx/references/tonalities/report.md +126 -0
- package/skills/lit-docx/run.py +2 -1
- package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
- package/skills/lit-docx/scripts/craft_docx.py +204 -1
- package/skills/lit-docx/scripts/docx_design.py +2445 -0
- package/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/skills/lit-docx/scripts/slop_lint.py +23 -6
- package/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
- package/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
- package/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
- package/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
- package/skills/lit-docx/templates/tonalities/report.yaml +27 -0
- package/skills/lit-pptx/NOTICE.md +1 -1
- package/skills/lit-pptx/SKILL.md +108 -19
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
- package/skills/lit-pptx/references/density-and-fill.md +283 -0
- package/skills/lit-pptx/references/direction-step.md +249 -0
- package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
- package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
- package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
- package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
- package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
- package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
- package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
- package/skills/lit-pptx/references/layout-families.md +470 -0
- package/skills/lit-pptx/references/title-treatments.md +271 -0
- package/skills/lit-pptx/references/tonalities/atlas.md +132 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
- package/skills/lit-pptx/references/tonalities/ledger.md +151 -0
- package/skills/lit-pptx/references/tonalities/night.md +148 -0
- package/skills/lit-pptx/references/tonalities/paper.md +136 -0
- package/skills/lit-pptx/references/tonalities/signal.md +148 -0
- package/skills/lit-pptx/references/tonalities/studio.md +149 -0
- package/skills/lit-pptx/scripts/compile-deck.js +68 -21
- package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
- package/skills/lit-pptx/scripts/craft_extras.py +37 -8
- package/skills/lit-pptx/scripts/deck_output.py +654 -0
- package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
- package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
- package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
- package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
- package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
- package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
- package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
- package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
- package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
- package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
- package/skills/lit-pptx/scripts/qa_deck.py +48 -13
- package/skills/lit-pptx/scripts/validate_pptx.py +17 -5
- package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
- package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +105 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +110 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +106 -0
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +106 -0
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +111 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +105 -0
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
- package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
- package/skills/lit-typographic-motion/fonts/pins.json +2 -2
- package/skills/lit-typographic-motion/gate.mjs +1 -1
- package/skills/lit-typographic-motion/references/stage.md +1 -1
- package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
- package/skills/lit-typographic-motion/references/treatment.md +1 -1
- package/skills/lit-typographic-motion/references/type-craft.md +3 -3
- package/skills/managed-skill-manifest.json +288 -44
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Regular.otf +0 -0
|
@@ -33,7 +33,7 @@ A2Z_WEIGHTS = {
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def _pretendard(weight):
|
|
36
|
-
p = os.path.join(ROOT, "pretendard-font", "public", "static", f"
|
|
36
|
+
p = os.path.join(ROOT, "pretendard-font", "public", "static", f"Pretendard-{weight}.otf")
|
|
37
37
|
return p if os.path.exists(p) else None
|
|
38
38
|
|
|
39
39
|
|
|
@@ -48,7 +48,7 @@ def resolve_family(typeface):
|
|
|
48
48
|
slot is one of regular/bold/italic/boldItalic (we only use regular/bold).
|
|
49
49
|
"""
|
|
50
50
|
tf = typeface.strip()
|
|
51
|
-
if tf == "
|
|
51
|
+
if tf == "Pretendard":
|
|
52
52
|
slots = {}
|
|
53
53
|
if _pretendard("Regular"):
|
|
54
54
|
slots["regular"] = _pretendard("Regular")
|
|
@@ -7,11 +7,38 @@ layering are not counted as defects; semantic overlap is checked by that gate.
|
|
|
7
7
|
|
|
8
8
|
import argparse
|
|
9
9
|
import json
|
|
10
|
+
from functools import lru_cache
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
13
|
+
from PIL import ImageFont
|
|
12
14
|
from pptx import Presentation
|
|
13
15
|
from pptx.util import Inches
|
|
14
16
|
|
|
17
|
+
FACES = Path(__file__).resolve().parents[1] / "pretendard-font" / "public" / "static"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@lru_cache(maxsize=8)
|
|
21
|
+
def _face(bold):
|
|
22
|
+
return ImageFont.truetype(str(FACES / f"Pretendard-{'Bold' if bold else 'Regular'}.otf"), 100)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _measured_lines(line, size, bold, usable_points):
|
|
26
|
+
"""Lines a run of the bundled face takes at this width, wrapped between words like the renderer."""
|
|
27
|
+
face = _face(bold)
|
|
28
|
+
width = lambda text: face.getlength(text) * size / 100
|
|
29
|
+
lines, current = 1, ""
|
|
30
|
+
for word in line.split(" "):
|
|
31
|
+
candidate = f"{current} {word}" if current else word
|
|
32
|
+
if current and width(candidate) > usable_points:
|
|
33
|
+
lines += 1
|
|
34
|
+
current = word
|
|
35
|
+
else:
|
|
36
|
+
current = candidate
|
|
37
|
+
while width(current) > usable_points and len(current) > 1:
|
|
38
|
+
lines += 1
|
|
39
|
+
current = current[max(1, int(len(current) * usable_points / width(current))):]
|
|
40
|
+
return lines
|
|
41
|
+
|
|
15
42
|
|
|
16
43
|
def inspect(path):
|
|
17
44
|
deck = Presentation(path)
|
|
@@ -45,8 +72,14 @@ def inspect(path):
|
|
|
45
72
|
continue
|
|
46
73
|
sizes = [run.font.size.pt for run in paragraph.runs if run.font.size]
|
|
47
74
|
size = max(sizes, default=16)
|
|
48
|
-
|
|
49
|
-
|
|
75
|
+
runs = [run for run in paragraph.runs if run.text.strip()]
|
|
76
|
+
if runs and all("Pretendard" in (run.font.name or "") for run in runs):
|
|
77
|
+
# The bundled face is measured, not estimated; its bold is wider than its regular.
|
|
78
|
+
bold = sum(len(run.text) for run in runs if run.font.bold) * 2 > sum(len(run.text) for run in runs)
|
|
79
|
+
line_count = max(1, sum(_measured_lines(line, size, bold, usable_width / 12700) for line in paragraph.text.splitlines() if line.strip()))
|
|
80
|
+
else:
|
|
81
|
+
approximate_chars = max(1, usable_width / Inches(size / 72 * 0.52))
|
|
82
|
+
line_count = max(1, sum(max(1, -(-len(line) // approximate_chars)) for line in paragraph.text.splitlines()))
|
|
50
83
|
estimated += line_count * Inches(size / 72 * 1.15)
|
|
51
84
|
if estimated > usable_height + Inches(0.08):
|
|
52
85
|
overflow["frame"] = "estimated text height exceeds frame"
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
* directive-parser.js — Extract ::: fenced blocks and detect forbidden constructs.
|
|
5
5
|
*
|
|
6
6
|
* Responsibilities:
|
|
7
|
-
* - Find ::: fenced blocks (main-box, kpi-table, notes, image, key-message)
|
|
7
|
+
* - Find ::: fenced blocks (main-box, kpi-table, chart, notes, image, key-message)
|
|
8
8
|
* - Reject nested ::: blocks
|
|
9
9
|
* - Reject arbitrary HTML/CSS (div, span, style, inline style attributes)
|
|
10
10
|
* - Extract tables from | pipe | syntax
|
|
11
11
|
* - Extract images from  syntax
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const APPROVED_DIRECTIVES = new Set(["main-box", "kpi-table", "notes", "image", "key-message"]);
|
|
14
|
+
const APPROVED_DIRECTIVES = new Set(["main-box", "kpi-table", "chart", "notes", "image", "key-message"]);
|
|
15
15
|
|
|
16
16
|
// A void directive is a single line: it draws something and has no body, so it
|
|
17
17
|
// takes no closing fence. Shapes are the only such directive.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* which enrolled template was chosen. Lets a user pick AZURE design + any font.
|
|
6
6
|
*
|
|
7
7
|
* Three systems:
|
|
8
|
-
* pretendard — ONE weighted family "
|
|
8
|
+
* pretendard — ONE weighted family "Pretendard" (bold via weight; bold:true OK)
|
|
9
9
|
* a2z — 에이투지체, one family PER weight (bold:false; weight by name)
|
|
10
10
|
*
|
|
11
11
|
* Each font_role is reassigned by its INTENDED weight class (derived from the
|
|
@@ -51,12 +51,12 @@ function applyFont(templateObj, key) {
|
|
|
51
51
|
const caps = templateObj.capabilities || {};
|
|
52
52
|
|
|
53
53
|
if (key === "pretendard") {
|
|
54
|
-
t.fonts = { title: "
|
|
55
|
-
t.global_typography = { ...(t.global_typography || {}), bullet_font: "
|
|
54
|
+
t.fonts = { title: "Pretendard", body: "Pretendard", light: "Pretendard" };
|
|
55
|
+
t.global_typography = { ...(t.global_typography || {}), bullet_font: "Pretendard" };
|
|
56
56
|
const roles = caps.font_roles || {};
|
|
57
57
|
for (const [name, r] of Object.entries(roles)) {
|
|
58
58
|
const cls = weightClass(name);
|
|
59
|
-
r.font = "
|
|
59
|
+
r.font = "Pretendard";
|
|
60
60
|
r.bold = cls === "bold" || cls === "black";
|
|
61
61
|
}
|
|
62
62
|
if (caps.constraints) caps.constraints.no_bold_on_bold_fonts = false;
|
|
@@ -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
|
+
* 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
|
+
};
|