@litfamily/litmuse 0.1.0 → 0.1.2

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 (176) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +115 -26
  3. package/README_ko-KR.md +112 -23
  4. package/bin/litmuse.mjs +4 -0
  5. package/docs/assets/card/after.webp +0 -0
  6. package/docs/assets/card/idle.webp +0 -0
  7. package/docs/assets/card/working.webp +0 -0
  8. package/docs/assets/handoff/latest.webp +0 -0
  9. package/docs/assets/handoff/saved.webp +0 -0
  10. package/docs/assets/readme/demo.webp +0 -0
  11. package/lib/stage-card.mjs +180 -5
  12. package/package.json +7 -1
  13. package/plugin/.muse-plugin/plugin.json +26 -11
  14. package/plugin/commands/lit-status.md +2 -1
  15. package/plugin/commands/lit.md +1 -1
  16. package/plugin/hooks/lib/digest.mjs +1 -1
  17. package/plugin/hooks/lib/next-step.mjs +68 -0
  18. package/plugin/hooks/lib/routes.mjs +59 -0
  19. package/plugin/hooks/lib/workflow-stack.mjs +318 -0
  20. package/plugin/hooks/session-start.mjs +29 -2
  21. package/plugin/hooks/user-prompt-submit.mjs +138 -7
  22. package/plugin/lib/capped-fetch.mjs +25 -0
  23. package/plugin/lib/pinned-fetch.mjs +79 -0
  24. package/plugin/lib/plan-task-rows.mjs +32 -0
  25. package/plugin/lib/render_pages.py +29 -1
  26. package/plugin/lib/rewind/collect.mjs +679 -0
  27. package/plugin/lib/rewind/gauge.mjs +72 -0
  28. package/plugin/lib/rewind/next-step.mjs +91 -0
  29. package/plugin/lib/rewind/redact.mjs +83 -0
  30. package/plugin/lib/rewind/transcripts.mjs +227 -0
  31. package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
  32. package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
  33. package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
  34. package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
  35. package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
  36. package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
  37. package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
  38. package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
  39. package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
  40. package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
  41. package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
  42. package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
  43. package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
  44. package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
  45. package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
  46. package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
  47. package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
  48. package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
  49. package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
  50. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
  51. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
  52. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
  53. package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
  54. package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
  55. package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
  56. package/plugin/skills/lit-adhd/SKILL.md +188 -0
  57. package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
  58. package/plugin/skills/lit-adhd/references/status-taxonomy.md +43 -0
  59. package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
  60. package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
  61. package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
  62. package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
  63. package/plugin/skills/lit-docx/SKILL.md +1 -1
  64. package/plugin/skills/lit-hijack/SKILL.md +589 -0
  65. package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
  66. package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
  67. package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
  68. package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
  69. package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
  70. package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
  71. package/plugin/skills/lit-pptx/SKILL.md +20 -9
  72. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
  73. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
  74. package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
  75. package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
  76. package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
  77. package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
  78. package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
  79. package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
  80. package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
  81. package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
  82. package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
  83. package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
  84. package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
  85. package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
  86. package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
  87. package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
  88. package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
  89. package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
  90. package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
  91. package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
  92. package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
  93. package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
  94. package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
  95. package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
  96. package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
  97. package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
  98. package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
  99. package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
  100. package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
  101. package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
  102. package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
  103. package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
  104. package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
  105. package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
  106. package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
  107. package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
  108. package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
  109. package/plugin/skills/lit-recap/SKILL.md +3 -0
  110. package/plugin/skills/lit-schematic/SKILL.md +241 -0
  111. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
  112. package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
  113. package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
  114. package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
  115. package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
  116. package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
  117. package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
  118. package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
  119. package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
  120. package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
  121. package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
  122. package/plugin/skills/lit-schematic/references/verification.md +24 -0
  123. package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
  124. package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
  125. package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
  126. package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
  127. package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
  128. package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
  129. package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
  130. package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
  131. package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
  132. package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
  133. package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
  134. package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
  135. package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
  136. package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
  137. package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
  138. package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
  139. package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
  140. package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
  141. package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
  142. package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
  143. package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
  144. package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
  145. package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
  146. package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
  147. package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
  148. package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
  149. package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
  150. package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
  151. package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
  152. package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
  153. package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
  154. package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
  155. package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
  156. package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
  157. package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
  158. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
  159. package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
  160. package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
  161. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
  162. package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
  163. package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
  164. package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
  165. package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
  166. package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
  167. package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
  168. package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
  169. package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
  170. package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
  171. package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
  172. package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
  173. package/plugin/skills/readme-studio/SKILL.md +1 -1
  174. package/plugin/skills/readme-studio/templates/hyperframes-cover/package-lock.json +3 -3
  175. package/plugin/skills/readme-studio/templates/remotion-cover/package-lock.json +3 -3
  176. package/plugin/skills/start-work/SKILL.md +6 -0
@@ -4,7 +4,7 @@
4
4
  deck_craft.py answers "does the deck look finished"; these checks answer "does it carry the
5
5
  reflex defaults a designer would flag", each as a number a gate can hold:
6
6
 
7
- OF-101 one accent colour per slide (hue families among solid shape fills)
7
+ OF-101 at most four accent hue families per slide (among solid shape fills); five fail
8
8
  OF-102 body text-box measure (characters per wrapped line, CJK or Latin)
9
9
  OF-103 numeric table columns aligned right
10
10
  OF-104 concentric radius on a nested rounded frame
@@ -16,15 +16,21 @@ reflex defaults a designer would flag", each as a number a gate can hold:
16
16
  OF-110 a deck of 8+ slides mixes at least three title treatments; each drawn as it is named
17
17
  OF-111 content slides vary their composition (title zone x body partition x dominant content)
18
18
  OF-112 the median empty band under the body's last block stays at or under 0.20
19
- OF-113 every slide that uses one title treatment keeps that treatment's frame
19
+ OF-113 every content title sits on the shared title frame, every display title on the display frame
20
20
  OF-114 titles and the cover subtitle are topic labels, not declarative sentences
21
- OF-115 no region left empty: a bottom title on the floor, a used side rail, a takeaway column as long
22
- as the visual beside it (its values table included), a title panel as tall as its text and no
23
- plate under an empty page
24
- OF-116 (with --sibling) another tonality of the same source draws a different skeleton
21
+ OF-115 no region left empty: a takeaway column as long as the visual beside it (its values table
22
+ included), a title panel as tall as its text and no plate under an empty page
23
+ OF-116 (with --sibling) another tonality of the same source sets its titles and bodies in the same places
25
24
  OF-117 no light figure card on a dark ground
26
25
  OF-118 a bold run-in label is followed by its separator ("요약: …", "Summary: …")
27
26
  OF-119 an agenda's title carries no count numeral beside it (its rows are numbered)
27
+ OF-120 a hard offset shadow (no blur) only in a pack that allows one, and there every shadow is hard
28
+
29
+ A tonality pack may widen two of these limits through its own `qa:` block:
30
+ `accent-families` and `display-accent-families` (how many accent hue families a content
31
+ or display slide may carry before OF-101 fails, five or six; the default is four),
32
+ and `offset-shadow` (OF-120). The gate finds the pack through the deck's tonality
33
+ stamp; a deck without a stamp keeps the defaults.
28
34
 
29
35
  Every threshold lives in THRESHOLDS. A HIGH finding fails the gate; a MEDIUM finding is
30
36
  advisory and is reported, not blocking. `tier` is "measured" when read off the OOXML and
@@ -63,7 +69,7 @@ from layout_inventory import DEFAULT_INSETS, EMU_PER_IN, PT_PER_IN, _attr_int, p
63
69
 
64
70
  THRESHOLDS = {
65
71
  "accent": {"min_lightness": 0.12, "max_lightness": 0.93, "min_saturation": 0.12, "family_deg": 20.0,
66
- "content_high": 3, "content_medium": 2, "display_high": 4, "display_medium": 3},
72
+ "content_high": 5, "content_medium": 5, "display_high": 5, "display_medium": 5},
67
73
  "measure": {"latin_max": 90, "cjk_max": 38, "cjk_share": 0.5, "narrow_min": 10, "narrow_lines": 3},
68
74
  "numeric": {"share": 0.7, "min_rows": 2},
69
75
  "radius": {"inset_spread_in": 0.06, "tolerance_in": 0.02, "min_outer_in": 0.04, "default_adj": 16667},
@@ -106,8 +112,52 @@ def _solid_fill(shape):
106
112
  return None
107
113
 
108
114
 
109
- def check_accents(slide, number, shapes, page_area, display):
110
- t = THRESHOLDS["accent"]
115
+ QA_KEY = re.compile(r"^ ([a-z-]+):\s*(\S+)\s*$")
116
+
117
+
118
+ def pack_allowances(prs) -> dict:
119
+ """The stamped tonality pack's `qa:` block as {key: int or bool}; empty without a stamp or a block."""
120
+ stamp = STAMP.search(prs.core_properties.subject or "")
121
+ if not stamp or not re.fullmatch(r"[a-z][a-z0-9-]*", stamp.group(1)):
122
+ return {}
123
+ path = Path(_HERE).parent / "templates" / "tonalities" / stamp.group(1) / "pack.yaml"
124
+ if not path.is_file():
125
+ return {}
126
+ allowed, inside = {}, False
127
+ for line in path.read_text(encoding="utf-8").splitlines():
128
+ if not line.strip() or line.lstrip().startswith("#"):
129
+ continue
130
+ if not line.startswith(" "):
131
+ inside = line.rstrip() == "qa:"
132
+ continue
133
+ match = QA_KEY.match(line) if inside else None
134
+ if match:
135
+ value = match.group(2)
136
+ allowed[match.group(1)] = value == "true" if value in ("true", "false") else int(value) if value.isdigit() else value
137
+ return allowed
138
+
139
+
140
+ def check_shadows(number, slide, allowed):
141
+ """OF-120: a hard offset shadow (blur 0) is a pack's look or nothing; a pack that has it keeps every shadow hard."""
142
+ found = []
143
+ for shape in slide.shapes:
144
+ for shadow in shape._element.iter(qn("a:outerShdw")):
145
+ hard = int(shadow.get("blurRad") or 0) == 0
146
+ if hard and not allowed.get("offset-shadow"):
147
+ found.append(_finding("OF-120", "HIGH", number, shape.name, "hard offset shadow in a pack that does not draw one",
148
+ "Remove the shadow; only a pack whose qa block allows offset-shadow draws one."))
149
+ elif not hard and allowed.get("offset-shadow"):
150
+ found.append(_finding("OF-120", "HIGH", number, shape.name, "soft shadow beside the pack's hard offset shadows",
151
+ "Draw the shadow with no blur at the pack's offset, or remove it."))
152
+ return found
153
+
154
+
155
+ def check_accents(slide, number, shapes, page_area, display, allowed=None):
156
+ t = dict(THRESHOLDS["accent"])
157
+ # A pack's own limit only widens the default: N families pass, N + 1 fail, with no advisory step between.
158
+ for key, high, medium in (("accent-families", "content_high", "content_medium"), ("display-accent-families", "display_high", "display_medium")):
159
+ if isinstance((allowed or {}).get(key), int) and not isinstance(allowed[key], bool):
160
+ t[high] = t[medium] = max(t[high], allowed[key] + 1)
111
161
  hues = []
112
162
  for shape in shapes:
113
163
  box = deck_craft._box(shape)
@@ -124,9 +174,9 @@ def check_accents(slide, number, shapes, page_area, display):
124
174
  count = len(_families(hues, t["family_deg"]))
125
175
  high, medium = (t["display_high"], t["display_medium"]) if display else (t["content_high"], t["content_medium"])
126
176
  if count >= high:
127
- return [_finding("OF-101", "HIGH", number, None, f"{count} accent hue families", "Keep one accent colour per slide; move the other fills to neutrals.")]
177
+ return [_finding("OF-101", "HIGH", number, None, f"{count} accent hue families", f"Hold a slide to {high - 1} accent hue families; fold the extra fills into a family the slide already uses.")]
128
178
  if count >= medium:
129
- return [_finding("OF-101", "MEDIUM", number, None, f"{count} accent hue families", "Two accents only when they carry a real pair (a delta or a status).")]
179
+ return [_finding("OF-101", "MEDIUM", number, None, f"{count} accent hue families", "Close to the accent limit: fold an extra fill into a family the slide already uses.")]
130
180
  return []
131
181
 
132
182
 
@@ -365,8 +415,9 @@ def check_bands(number, shapes, area, boxes, page_area, W, H):
365
415
  # as MEDIUM: whoever names a legacy template has asked for its single look. All geometry is in points.
366
416
 
367
417
  TREATMENTS = ("top-rule", "top-plain-large", "side-rail", "band", "statement", "overlay", "kicker-numeral", "bottom-anchor")
368
- ZONE_OF = {"top-rule": "top", "top-plain-large": "top", "kicker-numeral": "top", "side-rail": "side", "band": "band",
369
- "overlay": "overlay", "bottom-anchor": "bottom", "statement": "none"}
418
+ # Every content treatment styles the one shared title frame, so a slide's title "zone" in the
419
+ # composition signature is its treatment: the look varies, the place does not.
420
+ ZONE_OF = {t: t for t in TREATMENTS}
370
421
  OUTPUT = {
371
422
  "tol": 2.0, "size_tol": 0.5,
372
423
  "min_slides": 8, "min_treatments": 3,
@@ -376,8 +427,6 @@ OUTPUT = {
376
427
  "band_caps": ((2, 0.28), (4, 0.24), (6, 0.20), (8, 0.16), (10, 0.12)),
377
428
  "anchor_tol": 1.0,
378
429
  }
379
- # Widest four-column title and earliest column-5 start over the airy, standard, dense and compact grids.
380
- RAIL = {960: (288.0, 336.0), 720: (216.0, 252.0)}
381
430
  DIGITS = re.compile(r"\d+(?:[.:/-]\d+)?\.?")
382
431
  STAMP = re.compile(r"\blit-pptx tonality=(\S+) density=(\d+) grid=(\w+) variance=(\d+)")
383
432
  MARGIN = {960: {"airy": 60.0, "standard": 48.0, "dense": 36.0, "compact": 24.0}, 720: {"airy": 54.0, "standard": 42.0, "dense": 30.0, "compact": 24.0}}
@@ -406,8 +455,9 @@ def _elements(slide, W, H):
406
455
  for shape in slide.shapes:
407
456
  box = _pt_box(shape)
408
457
  # The engine names the first shape it draws `family@<id>`: a band or a rule keeps its role;
409
- # only an empty marker (no width and no height) is skipped.
410
- if box is None or box[2] + box[3] <= 0 or shape.name.startswith("lit-notice"):
458
+ # only an empty marker (no width and no height) is skipped. The notice tag and a pack's grid marks
459
+ # in the top margin are page chrome, not composition.
460
+ if box is None or box[2] + box[3] <= 0 or shape.name.startswith(("lit-notice", "grid-mark")):
411
461
  continue
412
462
  text = deck_craft._text(shape) if getattr(shape, "has_text_frame", False) and shape.has_text_frame else ""
413
463
  kind = ("picture" if shape.shape_type == MSO_SHAPE_TYPE.PICTURE else "table" if deck_craft._is_table(shape)
@@ -458,22 +508,23 @@ def classify_title(title, els, W, H):
458
508
  and not any(e["kind"] in ("table", "chart", "picture") or e["role"] == "card" for e in body)
459
509
  and sum(1 for e in body if e["kind"] == "text") <= 1):
460
510
  return "statement"
461
- if title["y"] >= 360 - tol and all(e["y"] <= title["y"] + tol for e in body if e["kind"] == "text"):
511
+ bottom_frame = title["y"] + title["h"]
512
+ content_w = W - 2 * title["x"]
513
+ shapes = [e for e in others if e["kind"] == "shape" and e["filled"]]
514
+ # A heavy rule across the columns closing the title frame.
515
+ if any(2 - st <= e["h"] <= 4 + st and e["w"] >= 0.9 * content_w and bottom_frame - tol <= e["y"] <= bottom_frame + 8 for e in shapes):
462
516
  return "bottom-anchor"
463
- rail_w, col5 = RAIL.get(round(W), RAIL[960])
464
- right = [e for e in body if e["x"] + e["w"] / 2 > title["x"] + title["w"]]
465
- # What stands under the title inside the rail (criteria labels, takeaways, the source) is the rail's;
466
- # only a block that runs from the rail into the body makes the title something else.
467
- if (title["w"] <= rail_w + tol and right and min(e["x"] for e in right) >= col5 - tol
468
- and not any(e["x"] < col5 - tol and e["y"] > bottom and e["x"] + e["w"] > col5 + tol for e in body)):
517
+ # A bar in the left margin, at least as tall as the title frame.
518
+ if any(e["x"] <= 1 and e["w"] <= 14 and e["h"] >= title["h"] - tol and e["y"] <= title["y"] + tol and e["y"] + e["h"] >= bottom_frame - tol
519
+ for e in shapes):
469
520
  return "side-rail"
470
521
  for e in others:
471
- if (e["kind"] == "text" and e["sizes"] and DIGITS.fullmatch(e["text"]) and max(e["sizes"]) >= 44 - st
472
- and e["x"] + e["w"] <= title["x"] + tol and e["y"] >= 30 - tol and e["y"] + e["h"] <= 114 + tol):
522
+ if (e["kind"] == "text" and e["sizes"] and DIGITS.fullmatch(e["text"]) and max(e["sizes"]) >= 40 - st
523
+ and e["x"] >= title["x"] + title["w"] - tol and e["y"] >= title["y"] - tol and e["y"] + e["h"] <= bottom_frame + tol):
473
524
  return "kicker-numeral"
474
525
  if title["y"] <= 48 + tol:
475
526
  # A rule is a line (wider than 16 pt and four times its height), never a list-marker dot.
476
- rule = any(e["kind"] == "shape" and e["h"] <= 8 and e["w"] >= max(16, 4 * e["h"]) and bottom - tol <= e["y"] <= bottom + 24
527
+ rule = any(e["kind"] == "shape" and e["h"] <= 8 and e["w"] >= max(16, 4 * e["h"]) and title["y"] < e["y"] <= bottom + 24
477
528
  and e["x"] < title["x"] + title["w"] and e["x"] + e["w"] > title["x"] for e in others)
478
529
  if size >= 36 - st and not rule:
479
530
  return "top-plain-large"
@@ -498,17 +549,16 @@ def composition(title, treatment, els, W, H, margin):
498
549
  zone = ZONE_OF.get(treatment, "none")
499
550
  if treatment == "statement":
500
551
  return (zone, "one", "text")
501
- col5 = RAIL.get(round(W), RAIL[960])[1]
502
552
  body = []
503
553
  for e in els:
504
554
  if e is title or _footer(e) or e["role"] == "decoration":
505
555
  continue
506
- if treatment == "side-rail" and e["x"] + e["w"] <= col5 + OUTPUT["tol"]:
507
- continue # what stands in the rail belongs to the title zone, not to the body's partition
508
556
  if e["kind"] == "shape" and title is not None and _centre_in(title, e, 0):
509
557
  continue # the panel that carries the title
510
- if treatment == "kicker-numeral" and e["kind"] == "text" and DIGITS.fullmatch(e["text"]) and e["y"] + e["h"] <= 116:
558
+ if e["name"].startswith(("numeral@", "header@")):
511
559
  continue
560
+ if e["kind"] == "text" and e["h"] <= 16 and e["y"] + e["h"] >= OUTPUT["band_floor"] - 2:
561
+ continue # a one-line caption or source on the body floor is the slide's foot, not a column
512
562
  body.append(e)
513
563
  cards = [e for e in body if e["role"] == "card"]
514
564
  blocks = [e for e in body if not any(c is not e and _centre_in(e, c) for c in cards)]
@@ -683,24 +733,24 @@ def analyse_output(prs) -> list:
683
733
  # padding, so the median is advisory there and OF-109 still blocks a hollow slide.
684
734
  found.append(_finding("OF-112", variety, None, None, f"median empty band {median:.2f} over {len(bands)} slides (limit {OUTPUT['band_deck_max']:.2f})",
685
735
  "Give the content slides content that reaches the floor: a takeaway under a table, the next step under an ask, a supporting fact under a claim."))
686
- # OF-113: one geometry per treatment (a statement is centred, so only its x and width repeat; a bottom
687
- # title stands on the floor, so its bottom edge repeats and its top follows the line count).
736
+ # OF-113: every content title on one frame and every display title (a statement, a quote, a closing
737
+ # statement) on another, whatever its treatment: a treatment changes the look, never the place. A title
738
+ # over a full-bleed picture (overlay) keeps its own panel. Slides are grouped by the treatment they name,
739
+ # so a title moved so far that it no longer reads as any treatment is still measured.
688
740
  groups = {}
689
741
  for s in titled:
690
- if s["treatment"] in TREATMENTS and s["written"] in TREATMENTS:
691
- groups.setdefault(s["treatment"], []).append(s)
692
- edge = lambda t, k: t["y"] + t["h"] if k == "bottom" else t[k]
693
- for treatment, group in groups.items():
694
- keys = ("x", "w") if treatment == "statement" else ("x", "w", "bottom") if treatment == "bottom-anchor" else ("x", "y", "w")
742
+ if s["written"] in TREATMENTS and s["written"] != "overlay":
743
+ groups.setdefault("display" if s["written"] == "statement" else "content", []).append(s)
744
+ for frame, group in groups.items():
695
745
  modes = {}
696
- for k in keys:
697
- values = [round(edge(s["title"], k)) for s in group]
746
+ for k in ("x", "y", "w", "h"):
747
+ values = [round(s["title"][k]) for s in group]
698
748
  modes[k] = max(set(values), key=values.count)
699
749
  for s in group:
700
- off = [f"{k} {edge(s['title'], k):.0f} vs {modes[k]}" for k in keys if abs(edge(s["title"], k) - modes[k]) > OUTPUT["anchor_tol"]]
750
+ off = [f"{k} {s['title'][k]:.0f} vs {modes[k]}" for k in modes if abs(s["title"][k] - modes[k]) > OUTPUT["anchor_tol"]]
701
751
  if off:
702
- found.append(_finding("OF-113", "HIGH", s["n"], s["title"]["name"], f"{treatment} title off its frame: {', '.join(off)}",
703
- "Every slide with one treatment keeps its title frame; fix the source, never nudge a title."))
752
+ found.append(_finding("OF-113", "HIGH", s["n"], s["title"]["name"], f"{s['treatment']} title off the shared {frame} title frame: {', '.join(off)}",
753
+ "Every title sits on the slide grid's title zone; fix the source, never nudge a title."))
704
754
  found += empty_regions(deck)
705
755
  found += agenda_numerals(deck)
706
756
  return found
@@ -709,16 +759,9 @@ def analyse_output(prs) -> list:
709
759
  # ── OF-115: no region left empty ────────────────────────────────────────────
710
760
  #
711
761
  # The bottom band (OF-112) reads the body as one page. A region can still stand empty beside a full one:
712
- # the floor under a bottom title, the rail under a side title, the takeaway column beside a chart that runs
713
- # to the floor, the panel that carries a title over a picture, and the open page above a plate. Each reading
714
- # is a share of its own region (40 %) or, under a bottom title, a distance (12 pt).
715
- REGION = {"floor_gap": 12.0, "share": 0.40}
716
-
717
-
718
- def _text_bottom(title):
719
- """Where a top-aligned title's last line ends: frames keep spare lines, so the frame's own bottom does not say."""
720
- lines = _wrapped_lines(title["shape"]) or 1
721
- return title["y"] + lines * max(title["sizes"] or [0.0]) * 1.15
762
+ # the takeaway column beside a chart that runs to the floor, the panel that carries a title over a
763
+ # picture, and the open page above a plate. Each reading is a share of its own region (40 %).
764
+ REGION = {"share": 0.40}
722
765
 
723
766
 
724
767
  def _largest_gap(top, bottom, spans):
@@ -732,18 +775,17 @@ def _largest_gap(top, bottom, spans):
732
775
  return max(gap, bottom - cursor)
733
776
 
734
777
 
735
- def _short_column(s, content, title, treatment, body_floor, col5):
778
+ def _short_column(s, content, title, body_floor):
736
779
  """A column of a two-column body that stops short of its neighbour: the largest empty band inside it, from
737
780
  the body's top down to where the neighbour ends, is over 40 % of the body. A source or note strip that
738
781
  closes the column does not hide the band above it (spec-v2 Amendments 8). Beside the figures of a kpi-row or
739
782
  big-number slide the column is measured to the body floor (Amendments 9)."""
740
783
  tol = OUTPUT["tol"]
741
- # The body starts under a top title; beside a side title (in its rail) or over a bottom title it starts at the top.
742
- top_of_title = title["y"] + title["h"] if treatment not in ("bottom-anchor", "side-rail") else 0.0
784
+ top_of_title = title["y"] + title["h"]
743
785
  W, H = s["W"], s["H"]
744
786
  fields = [e for e in s["els"] if e["kind"] == "shape" and e["filled"] and e["w"] * e["h"] >= 0.1 * W * H]
745
787
  body = [e for e in content if e["y"] >= top_of_title - tol and e["y"] < body_floor and e["w"] > 0 and e["h"] > 0
746
- and not (treatment == "side-rail" and e["x"] + e["w"] <= col5 + tol) and not any(_centre_in(e, f) for f in fields)]
788
+ and not any(_centre_in(e, f) for f in fields)]
747
789
  if len(body) < 2:
748
790
  return None
749
791
  left_edge, right_edge = min(e["x"] for e in body), max(e["x"] + e["w"] for e in body)
@@ -788,7 +830,6 @@ def _short_column(s, content, title, treatment, body_floor, col5):
788
830
  def empty_regions(deck) -> list:
789
831
  W, H = deck["W"], deck["H"]
790
832
  floor = OUTPUT["band_floor"]
791
- col5 = RAIL.get(round(W), RAIL[960])[1]
792
833
  tol = OUTPUT["tol"]
793
834
  found = []
794
835
  for s in deck["slides"]:
@@ -796,21 +837,9 @@ def empty_regions(deck) -> list:
796
837
  content = [e for e in s["els"] if e is not title and e["role"] != "decoration" and not _footer(e)
797
838
  and not (e["kind"] == "picture" and e["w"] * e["h"] >= 0.8 * W * H)]
798
839
  if s["kind"] in ("content", "closing") and title is not None and title["sizes"]:
799
- if treatment == "bottom-anchor":
800
- gap = floor - _text_bottom(title)
801
- if gap > REGION["floor_gap"]:
802
- found.append(_finding("OF-115", "HIGH", s["n"], title["name"], f"the bottom title ends {gap:.0f} pt above the body floor",
803
- "Stand the title's last line on the floor and give the visual above the room."))
804
- if treatment == "side-rail" and s["family"] not in ("quote", "statement"):
805
- top = title["y"] + title["h"] # the title frame keeps a spare line for a title that wraps once more
806
- rail = [e for e in content if e["x"] + e["w"] <= col5 + tol and e["y"] + e["h"] > top]
807
- share = _largest_gap(top, floor, [(e["y"], e["y"] + e["h"]) for e in rail]) / max(1.0, floor - top)
808
- if share > REGION["share"]:
809
- found.append(_finding("OF-115", "HIGH", s["n"], title["name"], f"{share:.0%} of the side rail under the title is empty",
810
- "Put the slide's criteria labels, takeaways or source in the rail, or take a top or bottom title."))
811
840
  # A takeaway column beside a chart, table or picture that runs on below it.
812
- body_floor = title["y"] - tol if treatment == "bottom-anchor" else floor
813
- short = _short_column(s, content, title, treatment, body_floor, col5)
841
+ body_floor = floor
842
+ short = _short_column(s, content, title, body_floor)
814
843
  if short:
815
844
  found.append(short)
816
845
  for v in ([] if short else [e for e in content if e["kind"] in ("chart", "table", "picture")]):
@@ -852,30 +881,43 @@ def empty_regions(deck) -> list:
852
881
  return found
853
882
 
854
883
 
855
- # ── OF-116: another tonality of the same source draws another skeleton ─────
856
- #
857
- # A deck's skeleton is each content slide's title zone and column partition, read as OF-111 reads them (a
858
- # kicker numeral title is a top title). Two tonalities of one source whose skeletons are identical differ in
859
- # colour only: at least one content slide must take another zone or partition.
884
+ # ── OF-116: another tonality of the same source sets everything in the same places ─────
860
885
 
886
+ # Titles, headers and the body sit on the shared slide grid, so two tonalities of one source
887
+ # may differ in every look but never in where a slide's title frame and its body's top-left corner stand.
888
+ # Slides are compared in order; a slide the other deck lacks is not compared.
861
889
 
862
- def skeleton(prs) -> list:
863
- return [s["signature"][:2] for s in read_deck(prs)["slides"] if s["kind"] == "content"]
864
890
 
891
+ def placement(prs) -> list:
892
+ """Per slide: the title frame and the top-left corner of what stands under it, in points."""
893
+ out = []
894
+ for s in read_deck(prs)["slides"]:
895
+ t = s["title"]
896
+ if t is None:
897
+ out.append(None)
898
+ continue
899
+ under = [e for e in s["els"] if e is not t and e["role"] != "decoration" and not _footer(e) and e["y"] >= t["y"] + t["h"] - OUTPUT["tol"]
900
+ and not e["name"].startswith(("header@", "numeral@", "subtitle@"))]
901
+ corner = (min(e["x"] for e in under), min(e["y"] for e in under)) if under and s["kind"] == "content" else None
902
+ out.append({"n": s["n"], "title": (t["x"], t["y"], t["w"], t["h"]), "body": corner})
903
+ return out
865
904
 
866
- def compare_skeletons(path: Path, sibling: Path) -> list:
867
- a, b = skeleton(Presentation(str(path))), skeleton(Presentation(str(sibling)))
868
- n = min(len(a), len(b))
869
- if not n:
870
- return []
871
- differ = sum(1 for x, y in zip(a, b) if x != y)
872
- # Two slides at least once the deck has four content slides (spec-v2 Amendments 8): one differing slide
873
- # left the alternatives reading as one deck in two colours.
874
- need = 2 if n >= 4 else 1
875
- if differ >= need:
905
+
906
+ def compare_placement(path: Path, sibling: Path) -> list:
907
+ a, b = placement(Presentation(str(path))), placement(Presentation(str(sibling)))
908
+ tol = OUTPUT["anchor_tol"]
909
+ moved = []
910
+ for x, y in zip(a, b):
911
+ if x is None or y is None:
912
+ continue
913
+ if any(abs(p - q) > tol for p, q in zip(x["title"], y["title"])):
914
+ moved.append(f"slide {x['n']} title at {[round(v) for v in x['title']]} vs {[round(v) for v in y['title']]}")
915
+ elif x["body"] and y["body"] and any(abs(p - q) > tol for p, q in zip(x["body"], y["body"])):
916
+ moved.append(f"slide {x['n']} body from {tuple(round(v) for v in x['body'])} vs {tuple(round(v) for v in y['body'])}")
917
+ if not moved:
876
918
  return []
877
- return [_finding("OF-116", "HIGH", None, None, f"{differ} of {n} content slides differ in title zone or partition from {Path(sibling).name} (needs {need})",
878
- "Give the tonality its own structure: its role defaults, a different title zone or partition for the slides it shares with the other.")]
919
+ return [_finding("OF-116", "HIGH", None, None, f"{len(moved)} slide(s) placed differently from {Path(sibling).name}: {'; '.join(moved[:4])}",
920
+ "A tonality changes the look of a zone, never its place: draw the title and the body on the slide grid's zones.")]
879
921
 
880
922
 
881
923
  # ── OF-117: no light figure card on a dark ground ───────────────────────────
@@ -1004,6 +1046,7 @@ def analyse(path: Path) -> dict:
1004
1046
  prs = Presentation(str(path))
1005
1047
  W, H = prs.slide_width / EMU_PER_IN, prs.slide_height / EMU_PER_IN
1006
1048
  page_area = W * H
1049
+ allowed = pack_allowances(prs)
1007
1050
  findings = analyse_output(prs) + check_labels(prs)
1008
1051
  for number, slide in enumerate(prs.slides, start=1):
1009
1052
  shapes = [s for s in slide.shapes if not s.name.startswith("lit-notice")]
@@ -1011,7 +1054,8 @@ def analyse(path: Path) -> dict:
1011
1054
  findings += check_run_effects(number, slide)
1012
1055
  findings += check_run_ins(number, slide)
1013
1056
  findings += light_figures(number, slide, W * PT_PER_IN, H * PT_PER_IN)
1014
- findings += check_accents(slide, number, shapes, page_area, layout["display"])
1057
+ findings += check_accents(slide, number, shapes, page_area, layout["display"], allowed)
1058
+ findings += check_shadows(number, slide, allowed)
1015
1059
  findings += check_numeric_columns(number, shapes)
1016
1060
  findings += check_concentric(number, shapes)
1017
1061
  if layout["display"]:
@@ -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 (fonts/pretendard, fonts/a2z) are embedded; unknown families (fonts a learned
9
+ Only families with a bundled font file (fonts/pretendard, fonts/a2z, fonts/jetbrains-mono) are embedded; unknown families (fonts a learned
10
10
  template names but this skill does not bundle) are warned
11
11
  about and skipped — never corrupting the file.
12
12
 
@@ -49,6 +49,11 @@ def _a2z(weight):
49
49
  return p if os.path.exists(p) else None
50
50
 
51
51
 
52
+ def _mono(weight):
53
+ p = os.path.join(ROOT, "fonts", "jetbrains-mono", f"JetBrainsMono-{weight}.ttf")
54
+ return p if os.path.exists(p) else None
55
+
56
+
52
57
  def resolve_family(typeface):
53
58
  """Return {slot: otf_path} for a typeface, or {} if no bundled file.
54
59
 
@@ -67,6 +72,13 @@ def resolve_family(typeface):
67
72
  if ("에이투지체" in tf or tf.startswith("A2Z")) and last in A2Z_WEIGHTS:
68
73
  f = _a2z(last)
69
74
  return {"regular": f} if f else {}
75
+ if tf == "JetBrains Mono":
76
+ slots = {}
77
+ if _mono("Regular"):
78
+ slots["regular"] = _mono("Regular")
79
+ if _mono("Bold"):
80
+ slots["bold"] = _mono("Bold")
81
+ return slots
70
82
  return {}
71
83
 
72
84