@litfamily/litmuse 0.1.1 → 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.
- package/CHANGELOG.md +11 -0
- package/README.md +20 -0
- package/README_ko-KR.md +20 -0
- package/bin/litmuse.mjs +4 -0
- package/lib/stage-card.mjs +180 -5
- package/package.json +1 -1
- package/plugin/.muse-plugin/plugin.json +26 -11
- package/plugin/commands/lit-status.md +2 -1
- package/plugin/commands/lit.md +1 -1
- package/plugin/hooks/lib/digest.mjs +1 -1
- package/plugin/hooks/lib/next-step.mjs +68 -0
- package/plugin/hooks/lib/routes.mjs +59 -0
- package/plugin/hooks/lib/workflow-stack.mjs +318 -0
- package/plugin/hooks/session-start.mjs +29 -2
- package/plugin/hooks/user-prompt-submit.mjs +138 -7
- package/plugin/lib/capped-fetch.mjs +25 -0
- package/plugin/lib/pinned-fetch.mjs +79 -0
- package/plugin/lib/plan-task-rows.mjs +32 -0
- package/plugin/lib/render_pages.py +29 -1
- package/plugin/lib/rewind/collect.mjs +679 -0
- package/plugin/lib/rewind/gauge.mjs +72 -0
- package/plugin/lib/rewind/next-step.mjs +91 -0
- package/plugin/lib/rewind/redact.mjs +83 -0
- package/plugin/lib/rewind/transcripts.mjs +227 -0
- package/plugin/skills/frontend-ui-ux/SKILL.md +24 -9
- package/plugin/skills/frontend-ui-ux/kit/components.css +414 -0
- package/plugin/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/plugin/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/plugin/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/plugin/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/plugin/skills/frontend-ui-ux/references/craft-floor.md +9 -4
- package/plugin/skills/frontend-ui-ux/references/default-editorial-pixel.json +59 -7
- package/plugin/skills/frontend-ui-ux/references/house-styles.md +234 -0
- package/plugin/skills/frontend-ui-ux/references/motion-guide.md +3 -3
- package/plugin/skills/frontend-ui-ux/references/slop-register.md +7 -7
- package/plugin/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-lock.mjs +196 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-page.mjs +31 -10
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-rules.mjs +19 -7
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe-static.mjs +4 -0
- package/plugin/skills/frontend-ui-ux/scripts/interface-probe.mjs +59 -7
- package/plugin/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/plugin/skills/lit-adhd/SKILL.md +188 -0
- package/plugin/skills/lit-adhd/references/card-examples.md +121 -0
- package/plugin/skills/lit-adhd/references/status-taxonomy.md +43 -0
- package/plugin/skills/lit-adhd/scripts/rewind.mjs +11 -0
- package/plugin/skills/lit-diagram-drawer/SKILL.md +3 -3
- package/plugin/skills/lit-diagram-drawer/scripts/diagram-craft-rules.mjs +1 -2
- package/plugin/skills/lit-diagram-drawer/scripts/visual-quality.mjs +0 -1
- package/plugin/skills/lit-docx/SKILL.md +1 -1
- package/plugin/skills/lit-hijack/SKILL.md +589 -0
- package/plugin/skills/lit-hijack/references/capture-and-provenance.md +123 -0
- package/plugin/skills/lit-hijack/references/clean-room-build.md +133 -0
- package/plugin/skills/lit-hijack/references/distil-and-licence.md +128 -0
- package/plugin/skills/lit-hijack/references/gap-and-port-design.md +152 -0
- package/plugin/skills/lit-hijack/references/parity-and-report.md +133 -0
- package/plugin/skills/lit-hijack/scripts/hijack.mjs +879 -0
- package/plugin/skills/lit-pptx/SKILL.md +20 -9
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/plugin/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/plugin/skills/lit-pptx/fonts/provenance.json +32 -1
- package/plugin/skills/lit-pptx/references/direction-step.md +19 -2
- package/plugin/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/plugin/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/plugin/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/plugin/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/plugin/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/plugin/skills/lit-pptx/references/tonalities/bauhaus.md +112 -0
- package/plugin/skills/lit-pptx/references/tonalities/computational-brutalism.md +118 -0
- package/plugin/skills/lit-pptx/references/tonalities/neo-brutalism.md +125 -0
- package/plugin/skills/lit-pptx/references/tonalities/newsroom.md +115 -0
- package/plugin/skills/lit-pptx/references/tonalities/riso.md +109 -0
- package/plugin/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/plugin/skills/lit-pptx/scripts/craft_extras.py +136 -92
- package/plugin/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/plugin/skills/lit-pptx/scripts/lib/grid-resolver.js +105 -116
- package/plugin/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/plugin/skills/lit-pptx/scripts/lib/render-pack.js +368 -561
- package/plugin/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/plugin/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/plugin/skills/lit-pptx/scripts/qa_deck.py +4 -4
- package/plugin/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/plugin/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/plugin/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/plugin/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/plugin/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/plugin/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/plugin/skills/lit-recap/SKILL.md +3 -0
- package/plugin/skills/lit-schematic/SKILL.md +241 -0
- package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +14 -0
- package/plugin/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +8 -0
- package/plugin/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +22 -0
- package/plugin/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +73 -0
- package/plugin/skills/lit-schematic/references/cutaway-outputs.md +25 -0
- package/plugin/skills/lit-schematic/references/isometric-craft.md +24 -0
- package/plugin/skills/lit-schematic/references/journal-figures.md +19 -0
- package/plugin/skills/lit-schematic/references/kit-api.md +27 -0
- package/plugin/skills/lit-schematic/references/pfd-and-layout.md +20 -0
- package/plugin/skills/lit-schematic/references/pid-drafting.md +26 -0
- package/plugin/skills/lit-schematic/references/pid-symbols.md +27 -0
- package/plugin/skills/lit-schematic/references/verification.md +24 -0
- package/plugin/skills/lit-schematic/scripts/doctor.mjs +13 -0
- package/plugin/skills/lit-schematic/scripts/lib/figure.mjs +49 -0
- package/plugin/skills/lit-schematic/scripts/lib/isa.mjs +18 -0
- package/plugin/skills/lit-schematic/scripts/lib/iso.mjs +58 -0
- package/plugin/skills/lit-schematic/scripts/lib/legend-sheet.mjs +10 -0
- package/plugin/skills/lit-schematic/scripts/lib/qa-core.mjs +241 -0
- package/plugin/skills/lit-schematic/scripts/lib/sheet.mjs +446 -0
- package/plugin/skills/lit-schematic/scripts/lib/svg.mjs +39 -0
- package/plugin/skills/lit-schematic/scripts/lib/theme.mjs +25 -0
- package/plugin/skills/lit-schematic/scripts/motion.mjs +8 -0
- package/plugin/skills/lit-schematic/scripts/qa.mjs +21 -0
- package/plugin/skills/lit-schematic/scripts/render.mjs +38 -0
- package/plugin/skills/lit-schematic/scripts/scaffold.mjs +73 -0
- package/plugin/skills/lit-scientific-visualization/SKILL.md +2 -0
- package/plugin/skills/lit-typographic-motion/SKILL.md +12 -8
- package/plugin/skills/lit-typographic-motion/engine/THIRD_PARTY_NOTICES +11 -0
- package/plugin/skills/lit-typographic-motion/engine/blender/runner.py +218 -0
- package/plugin/skills/lit-typographic-motion/engine/core/presets.mjs +1 -1
- package/plugin/skills/lit-typographic-motion/engine/node/assets.mjs +218 -0
- package/plugin/skills/lit-typographic-motion/engine/node/blender.mjs +144 -0
- package/plugin/skills/lit-typographic-motion/engine/node/board.mjs +136 -0
- package/plugin/skills/lit-typographic-motion/engine/node/done.mjs +42 -3
- package/plugin/skills/lit-typographic-motion/engine/node/gallery.mjs +120 -0
- package/plugin/skills/lit-typographic-motion/engine/node/house.mjs +44 -0
- package/plugin/skills/lit-typographic-motion/engine/node/pipeline.mjs +18 -7
- package/plugin/skills/lit-typographic-motion/engine/node/runtime.mjs +6 -3
- package/plugin/skills/lit-typographic-motion/engine/node/scaffold.mjs +210 -0
- package/plugin/skills/lit-typographic-motion/engine/node/score.mjs +639 -0
- package/plugin/skills/lit-typographic-motion/engine/node/sound-cli.mjs +21 -4
- package/plugin/skills/lit-typographic-motion/engine/node/sound.mjs +126 -27
- package/plugin/skills/lit-typographic-motion/engine/node/stage-serve.mjs +29 -5
- package/plugin/skills/lit-typographic-motion/engine/node/stage.mjs +82 -14
- package/plugin/skills/lit-typographic-motion/engine/node/styles.mjs +214 -0
- package/plugin/skills/lit-typographic-motion/engine/node/treatment.mjs +311 -10
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/plugin/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/plugin/skills/lit-typographic-motion/recipes/hero-3d/expected.json +69 -0
- package/plugin/skills/lit-typographic-motion/recipes/hero-3d/index.html +139 -0
- package/plugin/skills/lit-typographic-motion/references/depth.md +122 -0
- package/plugin/skills/lit-typographic-motion/references/sound.md +74 -16
- package/plugin/skills/lit-typographic-motion/references/stage.md +29 -5
- package/plugin/skills/lit-typographic-motion/references/studio.md +245 -0
- package/plugin/skills/lit-typographic-motion/references/treatment.md +31 -8
- package/plugin/skills/lit-typographic-motion/scripts/motion.mjs +48 -7
- package/plugin/skills/lit-typographic-motion/styles/paper.json +41 -0
- package/plugin/skills/lit-typographic-motion/styles/swiss-signal.json +41 -0
- package/plugin/skills/lit-typographic-motion/styles/terminalcore.json +42 -0
- package/plugin/skills/lit-typographic-motion/styles/tidal.json +41 -0
- package/plugin/skills/readme-studio/SKILL.md +1 -1
- 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
|
|
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
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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":
|
|
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
|
-
|
|
110
|
-
|
|
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", "
|
|
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", "
|
|
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
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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"]) >=
|
|
472
|
-
and e["x"]
|
|
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
|
|
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
|
|
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:
|
|
687
|
-
#
|
|
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["
|
|
691
|
-
groups.setdefault(s["
|
|
692
|
-
|
|
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
|
|
697
|
-
values = [round(
|
|
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} {
|
|
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
|
|
703
|
-
"Every
|
|
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
|
|
713
|
-
#
|
|
714
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
813
|
-
short = _short_column(s, content, title,
|
|
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
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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"{
|
|
878
|
-
"
|
|
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
|
|
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
|
|