@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README-Ko-KR.md +11 -11
  3. package/README.md +11 -11
  4. package/dist/activation-routing.js +1 -1
  5. package/dist/auto-handoff.js +34 -1
  6. package/dist/features.js +2 -2
  7. package/dist/skills.js +6 -4
  8. package/docs/assets/readme/badge-version.svg +1 -1
  9. package/docs/reference-Ko-KR.md +1 -1
  10. package/package.json +1 -1
  11. package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
  12. package/skills/lit-docx/SKILL.md +80 -19
  13. package/skills/lit-docx/references/components.md +253 -0
  14. package/skills/lit-docx/references/direction-step.md +234 -0
  15. package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
  16. package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
  17. package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
  18. package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
  19. package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
  20. package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
  21. package/skills/lit-docx/references/examples/07-report-en.md +137 -0
  22. package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
  23. package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
  24. package/skills/lit-docx/references/page-composition.md +306 -0
  25. package/skills/lit-docx/references/tonalities/brief.md +104 -0
  26. package/skills/lit-docx/references/tonalities/journal.md +101 -0
  27. package/skills/lit-docx/references/tonalities/manual.md +111 -0
  28. package/skills/lit-docx/references/tonalities/memo.md +105 -0
  29. package/skills/lit-docx/references/tonalities/proposal.md +101 -0
  30. package/skills/lit-docx/references/tonalities/report.md +126 -0
  31. package/skills/lit-docx/run.py +2 -1
  32. package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
  33. package/skills/lit-docx/scripts/craft_docx.py +204 -1
  34. package/skills/lit-docx/scripts/docx_design.py +2445 -0
  35. package/skills/lit-docx/scripts/docx_layout.py +959 -0
  36. package/skills/lit-docx/scripts/slop_lint.py +23 -6
  37. package/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
  38. package/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
  39. package/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
  40. package/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
  41. package/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
  42. package/skills/lit-docx/templates/tonalities/report.yaml +27 -0
  43. package/skills/lit-pptx/NOTICE.md +1 -1
  44. package/skills/lit-pptx/SKILL.md +108 -19
  45. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
  46. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
  47. package/skills/lit-pptx/references/density-and-fill.md +283 -0
  48. package/skills/lit-pptx/references/direction-step.md +249 -0
  49. package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
  50. package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
  51. package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
  52. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
  53. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
  54. package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
  55. package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
  56. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
  57. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
  58. package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
  59. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
  60. package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
  61. package/skills/lit-pptx/references/layout-families.md +470 -0
  62. package/skills/lit-pptx/references/title-treatments.md +271 -0
  63. package/skills/lit-pptx/references/tonalities/atlas.md +132 -0
  64. package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
  65. package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
  66. package/skills/lit-pptx/references/tonalities/ledger.md +151 -0
  67. package/skills/lit-pptx/references/tonalities/night.md +148 -0
  68. package/skills/lit-pptx/references/tonalities/paper.md +136 -0
  69. package/skills/lit-pptx/references/tonalities/signal.md +148 -0
  70. package/skills/lit-pptx/references/tonalities/studio.md +149 -0
  71. package/skills/lit-pptx/scripts/compile-deck.js +68 -21
  72. package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
  73. package/skills/lit-pptx/scripts/craft_extras.py +37 -8
  74. package/skills/lit-pptx/scripts/deck_output.py +654 -0
  75. package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
  76. package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
  77. package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
  78. package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
  79. package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  80. package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
  81. package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  82. package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
  83. package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
  84. package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
  85. package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
  86. package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
  87. package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
  88. package/skills/lit-pptx/scripts/qa_deck.py +48 -13
  89. package/skills/lit-pptx/scripts/validate_pptx.py +17 -5
  90. package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
  91. package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
  92. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  93. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
  94. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
  95. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
  96. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
  97. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
  98. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
  99. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +105 -0
  100. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +110 -0
  101. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +106 -0
  102. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +107 -0
  103. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +106 -0
  104. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +111 -0
  105. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +107 -0
  106. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +105 -0
  107. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
  108. package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
  109. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
  110. package/skills/lit-typographic-motion/fonts/pins.json +2 -2
  111. package/skills/lit-typographic-motion/gate.mjs +1 -1
  112. package/skills/lit-typographic-motion/references/stage.md +1 -1
  113. package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
  114. package/skills/lit-typographic-motion/references/treatment.md +1 -1
  115. package/skills/lit-typographic-motion/references/type-craft.md +3 -3
  116. package/skills/managed-skill-manifest.json +288 -44
  117. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
  118. 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"LitOpenCodeSans-{weight}.otf")
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 == "LitOpenCode Sans":
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
- approximate_chars = max(1, usable_width / Inches(size / 72 * 0.52))
49
- line_count = max(1, sum(max(1, -(-len(line) // approximate_chars)) for line in paragraph.text.splitlines()))
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 ![alt](path) 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 "LitOpenCode Sans" (bold via weight; bold:true OK)
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: "LitOpenCode Sans", body: "LitOpenCode Sans", light: "LitOpenCode Sans" };
55
- t.global_typography = { ...(t.global_typography || {}), bullet_font: "LitOpenCode Sans" };
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 = "LitOpenCode Sans";
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
+ };