@litfamily/litopencode 1.0.31 → 1.0.33

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 (282) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README-Ko-KR.md +6 -6
  3. package/README.md +6 -6
  4. package/TRADEMARK.md +12 -0
  5. package/dist/activation-primary-prompts.js +5 -1
  6. package/dist/activation-probe.js +1 -0
  7. package/dist/activation-prompt-utils.d.ts +1 -1
  8. package/dist/activation-reentry.d.ts +20 -0
  9. package/dist/activation-reentry.js +110 -1
  10. package/dist/activation-routing.d.ts +2 -1
  11. package/dist/activation-routing.js +100 -11
  12. package/dist/activation-workflow-prompts.d.ts +4 -0
  13. package/dist/activation-workflow-prompts.js +30 -0
  14. package/dist/activation.d.ts +1 -1
  15. package/dist/activation.js +44 -5
  16. package/dist/adhd-next.d.ts +51 -0
  17. package/dist/adhd-next.js +150 -0
  18. package/dist/agents/defaults.js +1 -1
  19. package/dist/cli/managed-skill-assets.d.ts +1 -1
  20. package/dist/cli/managed-skill-assets.js +7 -1
  21. package/dist/cli/native-skill-integrity.js +1 -1
  22. package/dist/cli.js +7 -1
  23. package/dist/commands.d.ts +28 -1
  24. package/dist/commands.js +58 -5
  25. package/dist/durable-plan.d.ts +5 -1
  26. package/dist/durable-plan.js +14 -2
  27. package/dist/features.d.ts +80 -1
  28. package/dist/features.js +105 -1
  29. package/dist/hooks.js +3 -0
  30. package/dist/ledger.js +12 -1
  31. package/dist/plan-scope-roots.d.ts +23 -0
  32. package/dist/plan-scope-roots.js +116 -0
  33. package/dist/restart-notice.d.ts +7 -0
  34. package/dist/restart-notice.js +40 -0
  35. package/dist/secret-shapes.js +5 -5
  36. package/dist/server.js +4 -0
  37. package/dist/session-liveness.d.ts +20 -0
  38. package/dist/session-liveness.js +99 -0
  39. package/dist/skills.d.ts +22 -1
  40. package/dist/skills.js +36 -0
  41. package/dist/start-work-typed.d.ts +34 -0
  42. package/dist/start-work-typed.js +167 -0
  43. package/dist/state.d.ts +3 -0
  44. package/dist/state.js +3 -0
  45. package/dist/tools.js +22 -2
  46. package/dist/tui/mission-band.d.ts +22 -1
  47. package/dist/tui/mission-band.js +75 -16
  48. package/dist/tui/mission-looks.d.ts +38 -0
  49. package/dist/tui/mission-looks.js +73 -0
  50. package/dist/tui/mission-model.d.ts +2 -0
  51. package/dist/tui/mission-model.js +3 -1
  52. package/dist/tui/mission-rainbow.d.ts +33 -0
  53. package/dist/tui/mission-rainbow.js +90 -0
  54. package/dist/tui/mission-source.d.ts +49 -0
  55. package/dist/tui/mission-source.js +217 -2
  56. package/dist/tui/mission-text.d.ts +2 -0
  57. package/dist/tui/mission-text.js +9 -1
  58. package/dist/tui/mission-threads.d.ts +58 -0
  59. package/dist/tui/mission-threads.js +128 -0
  60. package/dist/tui.js +112 -7
  61. package/dist/uiux-visual-catalog.js +8 -1
  62. package/dist/v2/events.js +3 -0
  63. package/dist/v2/session-hooks.js +38 -5
  64. package/dist/workflow-stack.d.ts +53 -0
  65. package/dist/workflow-stack.js +196 -0
  66. package/docs/assets/readme/badge-version.svg +1 -1
  67. package/docs/assets/skills/lit-schematic.webp +0 -0
  68. package/docs/reference-Ko-KR.md +48 -3
  69. package/docs/reference.md +54 -3
  70. package/package.json +1 -1
  71. package/skills/frontend-ui-ux/SKILL.md +3 -2
  72. package/skills/frontend-ui-ux/kit/components.css +414 -0
  73. package/skills/frontend-ui-ux/kit/fonts.json +22 -0
  74. package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  75. package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  76. package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  77. package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  78. package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  79. package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  80. package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  81. package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  82. package/skills/frontend-ui-ux/kit/ui.js +226 -0
  83. package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
  84. package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  85. package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
  86. package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
  87. package/skills/frontend-ui-ux/references/house-styles.md +236 -0
  88. package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
  89. package/skills/frontend-ui-ux/references/production.md +3 -1
  90. package/skills/frontend-ui-ux/references/slop-register.md +4 -4
  91. package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  92. package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  93. package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
  94. package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
  95. package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
  96. package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
  97. package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
  98. package/skills/lit-adhd/SKILL.md +209 -0
  99. package/skills/lit-adhd/references/brief-format.md +46 -0
  100. package/skills/lit-adhd/references/card-examples.md +103 -0
  101. package/skills/lit-adhd/references/status-taxonomy.md +34 -0
  102. package/skills/lit-adhd/scripts/rewind.mjs +933 -0
  103. package/skills/lit-diagram-drawer/SKILL.md +4 -3
  104. package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
  105. package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
  106. package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
  107. package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
  108. package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
  109. package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
  110. package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
  111. package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
  112. package/skills/lit-docx/SKILL.md +2 -1
  113. package/skills/lit-handoff/SKILL.md +2 -0
  114. package/skills/lit-hijack/SKILL.md +422 -0
  115. package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
  116. package/skills/lit-hijack/references/clean-room-build.md +111 -0
  117. package/skills/lit-hijack/references/distil-and-licence.md +100 -0
  118. package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
  119. package/skills/lit-hijack/references/parity-and-report.md +110 -0
  120. package/skills/lit-hijack/scripts/hijack.mjs +993 -0
  121. package/skills/lit-plan/SKILL.md +8 -0
  122. package/skills/lit-pptx/SKILL.md +27 -11
  123. package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  124. package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  125. package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  126. package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
  127. package/skills/lit-pptx/references/density-and-fill.md +9 -3
  128. package/skills/lit-pptx/references/direction-step.md +21 -6
  129. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
  130. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
  131. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
  132. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
  133. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
  134. package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  135. package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  136. package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  137. package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  138. package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  139. package/skills/lit-pptx/references/layout-families.md +65 -47
  140. package/skills/lit-pptx/references/title-treatments.md +83 -134
  141. package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
  142. package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
  143. package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
  144. package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
  145. package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
  146. package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
  147. package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
  148. package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
  149. package/skills/lit-pptx/references/tonalities/night.md +1 -1
  150. package/skills/lit-pptx/references/tonalities/paper.md +2 -2
  151. package/skills/lit-pptx/references/tonalities/riso.md +137 -0
  152. package/skills/lit-pptx/references/tonalities/signal.md +3 -3
  153. package/skills/lit-pptx/references/tonalities/studio.md +7 -6
  154. package/skills/lit-pptx/run.mjs +2 -1
  155. package/skills/lit-pptx/scripts/compile-deck.js +1 -1
  156. package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
  157. package/skills/lit-pptx/scripts/craft_extras.py +56 -3
  158. package/skills/lit-pptx/scripts/deck_output.py +75 -90
  159. package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  160. package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
  161. package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
  162. package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  163. package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
  164. package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  165. package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  166. package/skills/lit-pptx/scripts/qa_deck.py +10 -11
  167. package/skills/lit-pptx/scripts/render_pages.py +118 -0
  168. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  169. package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  170. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  171. package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  172. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  173. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  174. package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  175. package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  176. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  177. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  178. package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  179. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  180. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  181. package/skills/lit-recap/SKILL.md +4 -0
  182. package/skills/lit-schematic/SKILL.md +118 -0
  183. package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
  184. package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
  185. package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
  186. package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
  187. package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
  188. package/skills/lit-schematic/references/isometric-craft.md +101 -0
  189. package/skills/lit-schematic/references/journal-figures.md +72 -0
  190. package/skills/lit-schematic/references/kit-api.md +134 -0
  191. package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
  192. package/skills/lit-schematic/references/pid-drafting.md +103 -0
  193. package/skills/lit-schematic/references/pid-symbols.md +163 -0
  194. package/skills/lit-schematic/references/verification.md +100 -0
  195. package/skills/lit-schematic/scripts/doctor.mjs +57 -0
  196. package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
  197. package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
  198. package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
  199. package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
  200. package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
  201. package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
  202. package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
  203. package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
  204. package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
  205. package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
  206. package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
  207. package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
  208. package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
  209. package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
  210. package/skills/lit-schematic/scripts/motion.mjs +129 -0
  211. package/skills/lit-schematic/scripts/qa.mjs +92 -0
  212. package/skills/lit-schematic/scripts/render.mjs +233 -0
  213. package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
  214. package/skills/lit-scientific-visualization/SKILL.md +27 -4
  215. package/skills/lit-typographic-motion/SKILL.md +19 -17
  216. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
  217. package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
  218. package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
  219. package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
  220. package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
  221. package/skills/lit-typographic-motion/engine/board.mjs +138 -0
  222. package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
  223. package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
  224. package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
  225. package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
  226. package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
  227. package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
  228. package/skills/lit-typographic-motion/engine/house.mjs +42 -0
  229. package/skills/lit-typographic-motion/engine/look.mjs +65 -8
  230. package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
  231. package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
  232. package/skills/lit-typographic-motion/engine/score.mjs +594 -0
  233. package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
  234. package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
  235. package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
  236. package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
  237. package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
  238. package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
  239. package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
  240. package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
  241. package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
  242. package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
  243. package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
  244. package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
  245. package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
  246. package/skills/lit-typographic-motion/engine/text.mjs +1 -1
  247. package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
  248. package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  249. package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  250. package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  251. package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  252. package/skills/lit-typographic-motion/gate.mjs +18 -2
  253. package/skills/lit-typographic-motion/probe.mjs +13 -1
  254. package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
  255. package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
  256. package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
  257. package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
  258. package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
  259. package/skills/lit-typographic-motion/references/cover.md +56 -13
  260. package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
  261. package/skills/lit-typographic-motion/references/depth.md +112 -0
  262. package/skills/lit-typographic-motion/references/runtime.md +21 -6
  263. package/skills/lit-typographic-motion/references/sound.md +76 -0
  264. package/skills/lit-typographic-motion/references/stage.md +50 -7
  265. package/skills/lit-typographic-motion/references/studio.md +162 -0
  266. package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
  267. package/skills/lit-typographic-motion/references/treatment.md +108 -9
  268. package/skills/lit-typographic-motion/render.mjs +60 -3
  269. package/skills/lit-typographic-motion/styles/paper.json +185 -0
  270. package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
  271. package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
  272. package/skills/lit-typographic-motion/styles/tidal.json +187 -0
  273. package/skills/litresearch/SKILL.md +2 -0
  274. package/skills/managed-skill-manifest.json +517 -88
  275. package/skills/readme-studio/SKILL.md +1 -1
  276. package/skills/review-work/SKILL.md +2 -0
  277. package/skills/start-work/SKILL.md +8 -0
  278. package/skills/workflow-loop/SKILL.md +4 -0
  279. package/tools/check-pack-payload.mjs +5 -2
  280. package/tools/payload-substance-parity.json +4 -3
  281. package/tools/qa-cover-recipes.mjs +4 -3
  282. package/tools/version-manifests.json +2 -2
@@ -1,4 +1,13 @@
1
- """Measured OOXML craft checks for the LitOpenCode slide QA gate."""
1
+ """Measured OOXML craft checks for the LitOpenCode slide QA gate.
2
+
3
+ OF-101 at most four accent hue families per slide (among solid shape fills); five fail
4
+ OF-120 a hard offset shadow (no blur) only in a pack that allows one, and there every shadow is hard
5
+
6
+ A tonality pack may widen two limits through its own `qa:` block: `accent-families` and
7
+ `display-accent-families` (how many accent hue families a content or display slide may carry before
8
+ OF-101 fails; five or six, the default is four) and `offset-shadow` (OF-120). The gate finds the pack
9
+ through the deck's tonality stamp; a deck without a stamp keeps the defaults.
10
+ """
2
11
 
3
12
  from __future__ import annotations
4
13
 
@@ -18,6 +27,8 @@ EMU = 914400
18
27
  NUMERIC = re.compile(r"^[\d\s,.%+\-±()$€£¥₩▲▼]+$")
19
28
  HANGUL = re.compile(r"[가-힣]")
20
29
  EMOJI = re.compile(r"[\U0001F000-\U0001FAFF\u2600-\u27BF]")
30
+ STAMP = re.compile(r"\blit-pptx tonality=(\S+) density=(\d+) grid=(\w+) variance=(\d+)")
31
+ QA_KEY = re.compile(r"^ ([a-z-]+):\s*(\S+)\s*$")
21
32
  # Families the tonality compiler draws as display pages; every other `family@` name is a content slide.
22
33
  DISPLAY_FAMILY = re.compile(r"(?:cover|section)(?:-|$)|(?:statement|quote|closing-statement|image-full)$")
23
34
 
@@ -102,8 +113,42 @@ def _vertical_gaps(top, bottom, boxes):
102
113
  return gaps
103
114
 
104
115
 
116
+ def pack_allowances(deck):
117
+ """The stamped tonality pack's `qa:` block as {key: int or bool}; empty without a stamp or a block."""
118
+ stamp = STAMP.search(deck.core_properties.subject or "")
119
+ if not stamp or not re.fullmatch(r"[a-z][a-z0-9-]*", stamp.group(1)):
120
+ return {}
121
+ pack = Path(__file__).resolve().parent.parent / "templates" / "tonalities" / stamp.group(1) / "pack.yaml"
122
+ if not pack.is_file():
123
+ return {}
124
+ allowed, inside = {}, False
125
+ for line in pack.read_text(encoding="utf-8").splitlines():
126
+ if not line.strip() or line.lstrip().startswith("#"):
127
+ continue
128
+ if not line.startswith(" "):
129
+ inside = line.rstrip() == "qa:"
130
+ continue
131
+ match = QA_KEY.match(line) if inside else None
132
+ if match:
133
+ value = match.group(2)
134
+ allowed[match.group(1)] = value == "true" if value in ("true", "false") else int(value) if value.isdigit() else value
135
+ return allowed
136
+
137
+
138
+ def _accent_limits(display, allowed):
139
+ """The hue-family count that is advisory and the count that fails. A pack's own limit only widens the
140
+ default: N families pass, N + 1 fail, with no advisory step between."""
141
+ medium = T["displayAccentMedium"] if display else T["contentAccentMedium"]
142
+ high = T["displayAccentHigh"] if display else T["contentAccentHigh"]
143
+ allowance = allowed.get("display-accent-families" if display else "accent-families")
144
+ if isinstance(allowance, int) and not isinstance(allowance, bool):
145
+ medium = high = max(high, allowance + 1)
146
+ return medium, high
147
+
148
+
105
149
  def analyse(path):
106
150
  deck = Presentation(str(path))
151
+ allowed = pack_allowances(deck)
107
152
  canvas = deck.slide_width * deck.slide_height
108
153
  findings = check_output(deck) + check_labels(deck) + check_figures(deck) + check_run_ins(deck)
109
154
 
@@ -170,11 +215,19 @@ def analyse(path):
170
215
  add("OF-103", "HIGH", slide_no, {"row": row, "col": col + 1, "alignment": str(paragraph.alignment)}, "right", shape=index)
171
216
 
172
217
  count = _family_count(hues)
173
- medium = T["displayAccentMedium"] if display else T["contentAccentMedium"]
174
- high = T["displayAccentHigh"] if display else T["contentAccentHigh"]
218
+ medium, high = _accent_limits(display, allowed)
175
219
  if count >= medium:
176
220
  add("OF-101", "HIGH" if count >= high else "MEDIUM", slide_no, count, {"medium": medium, "high": high})
177
221
 
222
+ # OF-120: a hard offset shadow (blur 0) is a pack's look or nothing; a pack that has it keeps every shadow hard.
223
+ for index, shape in enumerate(slide.shapes, 1):
224
+ for shadow in shape._element.xpath('.//a:outerShdw'):
225
+ hard = int(shadow.get("blurRad") or 0) == 0
226
+ if hard and not allowed.get("offset-shadow"):
227
+ add("OF-120", "HIGH", slide_no, "hard offset shadow in a pack that does not draw one", "no shadow", shape=index)
228
+ elif not hard and allowed.get("offset-shadow"):
229
+ add("OF-120", "HIGH", slide_no, "soft shadow beside the pack's hard offset shadows", "blur 0", shape=index)
230
+
178
231
  rounds = [(index, shape, _box(shape), _radius(shape)) for index, shape in enumerate(shapes, 1) if shape.shape_type == MSO_SHAPE_TYPE.AUTO_SHAPE and "ROUNDED_RECTANGLE" in str(shape.auto_shape_type)]
179
232
  for inner_index, inner, ibox, iradius in rounds:
180
233
  for outer_index, outer, obox, oradius in rounds:
@@ -7,15 +7,14 @@ craft_extras.py judges one slide at a time. These checks read the compiled deck
7
7
  OF-111 content slides vary their composition (title zone x body partition x dominant content)
8
8
  OF-112 the body is used: a slide over its density cap is advisory, a deck median over the
9
9
  limit fails
10
- OF-113 every slide that uses one treatment keeps that treatment's title frame (a bottom title
11
- keeps its bottom edge on the floor; its top follows its line count)
10
+ OF-113 every content title sits on the shared title frame and every display title on the display
11
+ frame, whatever its treatment (a treatment changes the look, never the place)
12
12
  OF-114 titles and the cover subtitle name a topic; they are not declarative sentences
13
- OF-115 no region stands empty beside a full one: a bottom title ends on the body floor, a side
14
- rail carries what belongs beside the title, a takeaway column runs as long as the visual
13
+ OF-115 no region stands empty beside a full one: a takeaway column runs as long as the visual
15
14
  next to it, a column of a two-column body holds no tall empty band beside a longer one, a
16
15
  title panel is as tall as its text, and no plate leaves the page above it open
17
- OF-116 (qa_deck.py --sibling) another tonality of the same source draws a different skeleton
18
- (two content slides once the deck has four)
16
+ OF-116 (qa_deck.py --sibling) another tonality of the same source sets its titles and bodies in
17
+ the same places (the slide grid in scripts/lib/slide-grid.js)
19
18
  OF-117 no light figure card stands on a dark ground
20
19
  OF-118 a bold run-in label is followed by its separator ("요약: …", "Summary: …")
21
20
  OF-119 an agenda's title carries no count numeral beside it (its rows are numbered)
@@ -37,15 +36,12 @@ from pathlib import Path
37
36
  from pptx.enum.shapes import MSO_SHAPE_TYPE
38
37
  from pptx import Presentation
39
38
 
40
- from layout_inventory import _measured_lines
41
-
42
39
  T = json.loads(Path(__file__).with_name("craft-thresholds.json").read_text())
43
40
  PT = 12700
44
41
  TREATMENTS = ("top-rule", "top-plain-large", "side-rail", "band", "statement", "overlay", "kicker-numeral", "bottom-anchor")
45
- ZONE = {"top-rule": "top", "top-plain-large": "top", "kicker-numeral": "top", "side-rail": "side", "band": "band",
46
- "overlay": "overlay", "bottom-anchor": "bottom", "statement": "none"}
47
- # The widest four-column title and the earliest column-5 start over the four grids, per canvas width.
48
- RAIL = {960: (288.0, 336.0), 720: (216.0, 252.0)}
42
+ # Every content treatment styles the one shared title frame (slide-grid.js), so a slide's title "zone" in the
43
+ # composition signature is its treatment: the look varies, the place does not.
44
+ ZONE = {treatment: treatment for treatment in TREATMENTS}
49
45
  SIDE_MARGIN = {960: {"airy": 60.0, "standard": 48.0, "dense": 36.0, "compact": 24.0},
50
46
  720: {"airy": 54.0, "standard": 42.0, "dense": 30.0, "compact": 24.0}}
51
47
  STAMP = re.compile(r"\blit-pptx tonality=(\S+) density=(\d+) grid=(\w+) variance=(\d+)")
@@ -88,7 +84,7 @@ def _items(slide, width, height):
88
84
  """Every drawn shape in points with its kind and its role: content, card or decoration."""
89
85
  items = []
90
86
  for shape in slide.shapes:
91
- if shape.left is None or shape.width is None or shape.name.startswith("lit-notice"):
87
+ if shape.left is None or shape.width is None or shape.name.startswith(("lit-notice", "grid-mark")):
92
88
  continue
93
89
  x, y, w, h = shape.left / PT, shape.top / PT, shape.width / PT, shape.height / PT
94
90
  if w + h <= 0:
@@ -140,23 +136,23 @@ def treatment_of(title, items, width, height):
140
136
  and not any(item["kind"] in ("table", "chart", "picture") or item["role"] == "card" for item in body)
141
137
  and sum(1 for item in body if item["kind"] == "text") <= 1):
142
138
  return "statement"
143
- if title["y"] >= 360 - tol and all(item["y"] <= title["y"] + tol for item in body if item["kind"] == "text"):
139
+ content_width = width - 2 * title["x"]
140
+ marks = [item for item in others if item["kind"] == "shape" and item["filled"]]
141
+ # A heavy rule across the columns closing the title frame.
142
+ if any(2 - size_tol <= item["h"] <= 4 + size_tol and item["w"] >= 0.9 * content_width and bottom - tol <= item["y"] <= bottom + 8 for item in marks):
144
143
  return "bottom-anchor"
145
- rail_width, column_five = RAIL.get(round(width), RAIL[960])
146
- beside = [item for item in body if item["x"] + item["w"] / 2 > title["x"] + title["w"]]
147
- # Criteria labels, takeaways and the source under the title stand in the rail and belong to it; only a
148
- # block that runs out of the rail into the body makes the title something else.
149
- if (title["w"] <= rail_width + tol and beside and min(item["x"] for item in beside) >= column_five - tol
150
- and not any(item["x"] < column_five - tol and item["y"] > bottom and item["x"] + item["w"] > column_five + tol for item in body)):
144
+ # A bar in the left margin, at least as tall as the title frame.
145
+ if any(item["x"] <= 1 and item["w"] <= 14 and item["h"] >= title["h"] - tol and item["y"] <= title["y"] + tol and item["y"] + item["h"] >= bottom - tol
146
+ for item in marks):
151
147
  return "side-rail"
152
148
  for item in others:
153
- if (item["kind"] == "text" and item["sizes"] and NUMERAL.fullmatch(item["text"]) and max(item["sizes"]) >= 44 - size_tol
154
- and item["x"] + item["w"] <= title["x"] + tol and item["y"] >= 30 - tol and item["y"] + item["h"] <= 114 + tol):
149
+ if (item["kind"] == "text" and item["sizes"] and NUMERAL.fullmatch(item["text"]) and max(item["sizes"]) >= 40 - size_tol
150
+ and item["x"] >= title["x"] + title["w"] - tol and item["y"] >= title["y"] - tol and item["y"] + item["h"] <= bottom + tol):
155
151
  return "kicker-numeral"
156
152
  if title["y"] <= 48 + tol:
157
- # A rule is a line under the title (wider than 16 pt and four times its height), never a list dot.
153
+ # A rule is a line (wider than 16 pt and four times its height), never a list dot.
158
154
  rule = any(item["kind"] == "shape" and item["h"] <= 8 and item["w"] >= max(16, 4 * item["h"])
159
- and bottom - tol <= item["y"] <= bottom + 24 and item["x"] < title["x"] + title["w"]
155
+ and title["y"] < item["y"] <= bottom + 24 and item["x"] < title["x"] + title["w"]
160
156
  and item["x"] + item["w"] > title["x"] for item in others)
161
157
  if size >= 36 - size_tol and not rule:
162
158
  return "top-plain-large"
@@ -182,17 +178,16 @@ def signature(title, treatment, items, width, height, margin):
182
178
  zone = ZONE.get(treatment, "none")
183
179
  if treatment == "statement":
184
180
  return (zone, "one", "text")
185
- column_five = RAIL.get(round(width), RAIL[960])[1]
186
181
  body = []
187
182
  for item in items:
188
183
  if item is title or _footer(item) or item["role"] == "decoration":
189
184
  continue
190
- if treatment == "side-rail" and item["x"] + item["w"] <= column_five + T["outputGeometryTolPt"]:
191
- continue # the rail is part of the title zone, not a column of the body
192
185
  if item["kind"] == "shape" and title is not None and _inside(title, item, 0):
193
186
  continue # the panel the title stands on
194
- if treatment == "kicker-numeral" and item["kind"] == "text" and NUMERAL.fullmatch(item["text"]) and item["y"] + item["h"] <= 116:
187
+ if item["name"].startswith(("numeral@", "header@")):
195
188
  continue
189
+ if item["kind"] == "text" and item["h"] <= 16 and item["y"] + item["h"] >= T["outputBandFloorPt"] - 2:
190
+ continue # a one-line caption or source on the body floor is the slide's foot, not a column
196
191
  body.append(item)
197
192
  cards = [item for item in body if item["role"] == "card"]
198
193
  blocks = [item for item in body if not any(card is not item and _inside(item, card) for card in cards)]
@@ -363,42 +358,30 @@ def check_output(deck) -> list:
363
358
  # A legacy template has no fill policies, so its median is advisory; OF-109 still blocks a hollow slide.
364
359
  found.append(_finding("OF-112", variety, None, None, f"median empty band {median:.2f} over {len(bands)} slides (limit {T['outputBandDeckMax']:.2f})",
365
360
  "Give content slides material that reaches the floor: a takeaway under a table, the next step under an ask."))
361
+ # Every content title on one frame and every display title (a statement, a quote, a closing statement) on
362
+ # another, whatever its treatment. A title over a full-bleed picture (overlay) keeps its own panel. Slides are
363
+ # grouped by the treatment they name, so a title moved so far that it no longer reads as any treatment is
364
+ # still measured.
366
365
  groups = {}
367
366
  for entry in titled:
368
- if entry["treatment"] in TREATMENTS and entry["named"] in TREATMENTS:
369
- groups.setdefault(entry["treatment"], []).append(entry)
370
- # A statement is centred vertically, so only its x and width repeat. A bottom title stands on the floor:
371
- # its bottom edge repeats and its top moves with the line count.
372
- edge = lambda title, key: title["y"] + title["h"] if key == "bottom" else title[key]
373
- for treatment, group in groups.items():
374
- keys = ("x", "w") if treatment == "statement" else ("x", "w", "bottom") if treatment == "bottom-anchor" else ("x", "y", "w")
367
+ if entry["named"] in TREATMENTS and entry["named"] != "overlay":
368
+ groups.setdefault("display" if entry["named"] == "statement" else "content", []).append(entry)
369
+ for frame, group in groups.items():
375
370
  modes = {}
376
- for key in keys:
377
- values = [round(edge(entry["title"], key)) for entry in group]
371
+ for key in ("x", "y", "w", "h"):
372
+ values = [round(entry["title"][key]) for entry in group]
378
373
  modes[key] = max(set(values), key=values.count)
379
374
  for entry in group:
380
- off = [f"{key} {edge(entry['title'], key):.0f} vs {modes[key]}" for key in keys
381
- if abs(edge(entry["title"], key) - modes[key]) > T["outputAnchorTolPt"]]
375
+ off = [f"{key} {entry['title'][key]:.0f} vs {modes[key]}" for key in modes if abs(entry["title"][key] - modes[key]) > T["outputAnchorTolPt"]]
382
376
  if off:
383
- found.append(_finding("OF-113", "HIGH", entry["n"], entry["title"]["name"], f"{treatment} title off its frame: {', '.join(off)}",
384
- "Slides with one treatment share its title frame; fix the source instead of nudging a title."))
377
+ found.append(_finding("OF-113", "HIGH", entry["n"], entry["title"]["name"], f"{entry['treatment']} title off the shared {frame} title frame: {', '.join(off)}",
378
+ "Every title sits on the slide grid's title zone; fix the source instead of nudging a title."))
385
379
  return found + empty_regions(read) + agenda_numerals(read)
386
380
 
387
381
 
388
382
  # OF-115. The empty band (OF-112) reads the body as one page, yet one region can stand empty beside a full
389
- # one: the floor under a bottom title, the rail under a side title, the takeaway column beside a visual that
390
- # runs on below it, the panel that carries a title over a picture, the open page above a plate. Each is read
391
- # as a share of its own region, or under a bottom title as a distance.
392
- def _title_end(title):
393
- """Where a top-aligned title's last line ends; the frame keeps spare lines, so its own bottom does not say."""
394
- frame = title["shape"].text_frame
395
- size = max(title["sizes"] or [0.0])
396
- usable = max(1.0, (title["shape"].width - frame.margin_left - frame.margin_right) / PT)
397
- bold = any(run.font.bold for paragraph in frame.paragraphs for run in paragraph.runs)
398
- count = sum(_measured_lines(paragraph.text, size, bold, usable) for paragraph in frame.paragraphs if paragraph.text.strip())
399
- return title["y"] + max(1, count) * size * 1.15
400
-
401
-
383
+ # one: the takeaway column beside a visual that runs on below it, the panel that carries a title over a
384
+ # picture, the open page above a plate. Each is read as a share of its own region.
402
385
  def _largest_gap(top, bottom, spans):
403
386
  """The tallest stretch of [top, bottom] that no (y0, y1) span covers."""
404
387
  gap, cursor = 0.0, top
@@ -410,17 +393,16 @@ def _largest_gap(top, bottom, spans):
410
393
  return max(gap, bottom - cursor)
411
394
 
412
395
 
413
- def _short_column(entry, content, title, treatment, body_floor, column_five, width, height):
396
+ def _short_column(entry, content, title, body_floor, width, height):
414
397
  """A column of a two-column body that stops short of the column beside it: its tallest empty band, from the
415
398
  top of the pair down to where the other column ends, over the share limit of the body. A source or note strip
416
399
  closing the column counts as a block but does not hide the band above it. Beside the figures of a kpi-row or
417
400
  big-number slide the column is measured to the body floor (spec-v2 Amendments 9)."""
418
401
  tol = T["outputGeometryTolPt"]
419
- # Under a top title the body starts below it; beside a side title (its rail left out) or over a bottom title, at the top.
420
- start = title["y"] + title["h"] if treatment not in ("bottom-anchor", "side-rail") else 0.0
402
+ start = title["y"] + title["h"]
421
403
  fields = [item for item in entry["items"] if item["kind"] == "shape" and item["filled"] and item["w"] * item["h"] >= 0.1 * width * height]
422
404
  body = [item for item in content if start - tol <= item["y"] < body_floor and item["w"] > 0 and item["h"] > 0
423
- and not (treatment == "side-rail" and item["x"] + item["w"] <= column_five + tol) and not any(_inside(item, field) for field in fields)]
405
+ and not any(_inside(item, field) for field in fields)]
424
406
  if len(body) < 2:
425
407
  return None
426
408
  left, right = min(item["x"] for item in body), max(item["x"] + item["w"] for item in body)
@@ -463,7 +445,6 @@ def _short_column(entry, content, title, treatment, body_floor, column_five, wid
463
445
  def empty_regions(read) -> list:
464
446
  width, height = read["width"], read["height"]
465
447
  floor, tol, share_max = T["outputBandFloorPt"], T["outputGeometryTolPt"], T["outputRegionEmptyShare"]
466
- column_five = RAIL.get(round(width), RAIL[960])[1]
467
448
  found = []
468
449
  for entry in read["slides"]:
469
450
  title, treatment = entry["title"], entry["treatment"]
@@ -472,23 +453,11 @@ def empty_regions(read) -> list:
472
453
  full_bleed = lambda item: item["kind"] == "picture" and item["w"] * item["h"] >= 0.8 * width * height
473
454
  content = [item for item in entry["items"] if item is not title and item["role"] != "decoration" and not _footer(item) and not full_bleed(item)]
474
455
  if entry["role"] in ("content", "closing") and title["sizes"]:
475
- if treatment == "bottom-anchor":
476
- gap = floor - _title_end(title)
477
- if gap > T["outputRegionFloorGapPt"]:
478
- found.append(_finding("OF-115", "HIGH", entry["n"], title["name"], f"the bottom title ends {gap:.0f} pt above the body floor",
479
- "Stand the title's last line on the floor and give the visual above it the room."))
480
- if treatment == "side-rail" and entry["family"] not in ("quote", "statement"):
481
- top = title["y"] + title["h"]
482
- rail = [item for item in content if item["x"] + item["w"] <= column_five + tol and item["y"] + item["h"] > top]
483
- share = _largest_gap(top, floor, [(item["y"], item["y"] + item["h"]) for item in rail]) / max(1.0, floor - top)
484
- if share > share_max:
485
- found.append(_finding("OF-115", "HIGH", entry["n"], title["name"], f"{share:.0%} of the side rail under the title is empty",
486
- "Put the slide's criteria labels, takeaways or source in the rail, or give it a top or bottom title."))
487
456
  # The takeaway column beside a chart, table or picture that runs on below it. Text on a colour
488
457
  # field (a closing's ask) stands on its own ground and is no column.
489
- body_floor = title["y"] - tol if treatment == "bottom-anchor" else floor
458
+ body_floor = floor
490
459
  fields = [item for item in entry["items"] if item["kind"] == "shape" and item["filled"] and item["w"] * item["h"] >= 0.1 * width * height]
491
- short = _short_column(entry, content, title, treatment, body_floor, column_five, width, height)
460
+ short = _short_column(entry, content, title, body_floor, width, height)
492
461
  if short:
493
462
  found.append(short)
494
463
  for visual in ([] if short else [item for item in content if item["kind"] in ("chart", "table", "picture")]):
@@ -526,24 +495,40 @@ def empty_regions(read) -> list:
526
495
  return found
527
496
 
528
497
 
529
- # OF-116. A deck's skeleton is each content slide's title zone and column partition, read as OF-111 reads
530
- # them. Two tonalities of one source with the same skeleton differ in colour only.
531
- def skeleton(deck) -> list:
532
- return [entry["signature"][:2] for entry in read_slides(deck)["slides"] if entry["role"] == "content"]
533
-
534
-
535
- def compare_skeletons(path, sibling) -> list:
536
- mine, theirs = skeleton(Presentation(str(path))), skeleton(Presentation(str(sibling)))
537
- count = min(len(mine), len(theirs))
538
- if not count:
539
- return []
540
- differ = sum(1 for a, b in zip(mine, theirs) if a != b)
541
- # One differing slide left a longer deck reading as one deck in two colours.
542
- need = T["outputSiblingMinDifferLong"] if count >= T["outputSiblingLongDeck"] else T["outputSiblingMinDiffer"]
543
- if differ >= need:
498
+ # OF-116. Titles, headers and the body sit on the shared slide grid (slide-grid.js), so two tonalities of one
499
+ # source may differ in every look but never in where a slide's title frame and the top-left corner of its
500
+ # body stand. Slides are compared in order; a slide the other deck lacks is not compared.
501
+ def placement(deck) -> list:
502
+ """Per slide: the title frame and (content slides) the top-left corner of what stands under it, in points."""
503
+ out = []
504
+ for entry in read_slides(deck)["slides"]:
505
+ title = entry["title"]
506
+ if title is None:
507
+ out.append(None)
508
+ continue
509
+ under = [item for item in entry["items"] if item is not title and item["role"] != "decoration" and not _footer(item)
510
+ and item["y"] >= title["y"] + title["h"] - T["outputGeometryTolPt"]
511
+ and not item["name"].startswith(("header@", "numeral@", "subtitle@"))]
512
+ corner = (min(item["x"] for item in under), min(item["y"] for item in under)) if under and entry["role"] == "content" else None
513
+ out.append({"n": entry["n"], "title": (title["x"], title["y"], title["w"], title["h"]), "body": corner})
514
+ return out
515
+
516
+
517
+ def compare_placement(path, sibling) -> list:
518
+ mine, theirs = placement(Presentation(str(path))), placement(Presentation(str(sibling)))
519
+ tol = T["outputAnchorTolPt"]
520
+ moved = []
521
+ for a, b in zip(mine, theirs):
522
+ if a is None or b is None:
523
+ continue
524
+ if any(abs(p - q) > tol for p, q in zip(a["title"], b["title"])):
525
+ moved.append(f"slide {a['n']} title at {[round(v) for v in a['title']]} vs {[round(v) for v in b['title']]}")
526
+ elif a["body"] and b["body"] and any(abs(p - q) > tol for p, q in zip(a["body"], b["body"])):
527
+ moved.append(f"slide {a['n']} body from {tuple(round(v) for v in a['body'])} vs {tuple(round(v) for v in b['body'])}")
528
+ if not moved:
544
529
  return []
545
- return [_finding("OF-116", "HIGH", None, None, f"{differ} of {count} content slides differ in title zone or partition from {Path(sibling).name} (needs {need})",
546
- "Give the tonality its own structure: its role defaults, another title zone or partition on the slides it shares with the other.")]
530
+ return [_finding("OF-116", "HIGH", None, None, f"{len(moved)} slide(s) placed differently from {Path(sibling).name}: {'; '.join(moved[:4])}",
531
+ "A tonality changes the look of a zone, never its place: draw the title and the body on the slide grid's zones.")]
547
532
 
548
533
 
549
534
  # OF-118. A point that opens with a bold label and runs on in regular weight reads as one sentence when nothing
@@ -6,7 +6,7 @@ python-pptx has no font-embedding API, so this post-processes the OOXML package
6
6
  directly: it adds /ppt/fonts/*.fntdata parts, a fntdata content-type default,
7
7
  presentation relationships, and a <p:embeddedFontLst> in ppt/presentation.xml.
8
8
 
9
- Only families with a bundled OTF are embedded; unknown families (e.g. a custom
9
+ Only families with a bundled font file (Pretendard, A2Z, JetBrains Mono) are embedded; unknown families (e.g. a custom
10
10
  family, whose binaries are not supplied and are not bundled here) are warned
11
11
  about and skipped — never corrupting the file.
12
12
 
@@ -42,6 +42,11 @@ def _a2z(weight):
42
42
  return p if os.path.exists(p) else None
43
43
 
44
44
 
45
+ def _mono(weight):
46
+ p = os.path.join(ROOT, "fonts", "jetbrains-mono", f"JetBrainsMono-{weight}.ttf")
47
+ return p if os.path.exists(p) else None
48
+
49
+
45
50
  def resolve_family(typeface):
46
51
  """Return {slot: otf_path} for a typeface, or {} if no bundled file.
47
52
 
@@ -55,6 +60,13 @@ def resolve_family(typeface):
55
60
  if _pretendard("Bold"):
56
61
  slots["bold"] = _pretendard("Bold")
57
62
  return slots
63
+ if tf == "JetBrains Mono":
64
+ slots = {}
65
+ if _mono("Regular"):
66
+ slots["regular"] = _mono("Regular")
67
+ if _mono("Bold"):
68
+ slots["bold"] = _mono("Bold")
69
+ return slots
58
70
  # Per-weight families: "에이투지체 7 Bold", "에이투지체 4 Regular", ...
59
71
  last = tf.split()[-1] if tf.split() else ""
60
72
  if ("에이투지체" in tf or tf.startswith("A2Z")) and last in A2Z_WEIGHTS:
@@ -22,10 +22,13 @@ def _face(bold):
22
22
  return ImageFont.truetype(str(FACES / f"Pretendard-{'Bold' if bold else 'Regular'}.otf"), 100)
23
23
 
24
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."""
25
+ def _measured_lines(line, size, bold, usable_points, tracking=0.0):
26
+ """Lines a run of the bundled face takes at this width, wrapped between words like the renderer.
27
+
28
+ `tracking` is the character spacing the runs set, in points per character (display type is set tight).
29
+ """
27
30
  face = _face(bold)
28
- width = lambda text: face.getlength(text) * size / 100
31
+ width = lambda text: face.getlength(text) * size / 100 + len(text) * tracking
29
32
  lines, current = 1, ""
30
33
  for word in line.split(" "):
31
34
  candidate = f"{current} {word}" if current else word
@@ -76,11 +79,15 @@ def inspect(path):
76
79
  if runs and all("Pretendard" in (run.font.name or "") for run in runs):
77
80
  # The bundled face is measured, not estimated; its bold is wider than its regular.
78
81
  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()))
82
+ tracking = int(runs[0]._r.get_or_add_rPr().get("spc", "0")) / 100
83
+ line_count = max(1, sum(_measured_lines(line, size, bold, usable_width / 12700, tracking) for line in paragraph.text.splitlines() if line.strip()))
80
84
  else:
81
85
  approximate_chars = max(1, usable_width / Inches(size / 72 * 0.52))
82
86
  line_count = max(1, sum(max(1, -(-len(line) // approximate_chars)) for line in paragraph.text.splitlines()))
83
- estimated += line_count * Inches(size / 72 * 1.15)
87
+ # The engine tightens a title's spacing to keep it in its frame; measure with the leading the paragraph sets.
88
+ leading = paragraph.line_spacing
89
+ line_height = int(leading) if leading is not None and not isinstance(leading, float) else Inches(size / 72 * (leading or 1.15))
90
+ estimated += line_count * line_height
84
91
  if estimated > usable_height + Inches(0.08):
85
92
  overflow["frame"] = "estimated text height exceeds frame"
86
93
  if overflow or off_slide: