@litfamily/litopencode 1.0.31 → 1.0.32
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 +24 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/dist/activation-probe.js +1 -0
- package/dist/activation-prompt-utils.d.ts +1 -1
- package/dist/activation-reentry.d.ts +20 -0
- package/dist/activation-reentry.js +110 -1
- package/dist/activation-routing.d.ts +2 -1
- package/dist/activation-routing.js +100 -11
- package/dist/activation-workflow-prompts.d.ts +4 -0
- package/dist/activation-workflow-prompts.js +30 -0
- package/dist/activation.d.ts +1 -1
- package/dist/activation.js +44 -5
- package/dist/adhd-next.d.ts +51 -0
- package/dist/adhd-next.js +150 -0
- package/dist/cli/managed-skill-assets.d.ts +1 -1
- package/dist/cli/managed-skill-assets.js +7 -1
- package/dist/cli/native-skill-integrity.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/commands.d.ts +28 -1
- package/dist/commands.js +48 -1
- package/dist/features.d.ts +80 -1
- package/dist/features.js +105 -1
- package/dist/ledger.js +12 -1
- package/dist/secret-shapes.js +5 -5
- package/dist/skills.d.ts +22 -1
- package/dist/skills.js +36 -0
- package/dist/start-work-typed.d.ts +34 -0
- package/dist/start-work-typed.js +156 -0
- package/dist/state.d.ts +2 -0
- package/dist/state.js +2 -0
- package/dist/tools.js +22 -2
- package/dist/tui/mission-band.d.ts +20 -1
- package/dist/tui/mission-band.js +73 -16
- package/dist/tui/mission-looks.d.ts +38 -0
- package/dist/tui/mission-looks.js +73 -0
- package/dist/tui/mission-model.d.ts +2 -0
- package/dist/tui/mission-model.js +3 -1
- package/dist/tui/mission-rainbow.d.ts +33 -0
- package/dist/tui/mission-rainbow.js +90 -0
- package/dist/tui/mission-source.d.ts +48 -0
- package/dist/tui/mission-source.js +184 -2
- package/dist/tui/mission-text.d.ts +2 -0
- package/dist/tui/mission-text.js +9 -1
- package/dist/tui/mission-threads.d.ts +50 -0
- package/dist/tui/mission-threads.js +119 -0
- package/dist/tui.js +84 -6
- package/dist/uiux-visual-catalog.js +8 -1
- package/dist/v2/session-hooks.js +38 -5
- package/dist/workflow-stack.d.ts +53 -0
- package/dist/workflow-stack.js +196 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/assets/skills/lit-schematic.webp +0 -0
- package/docs/reference-Ko-KR.md +30 -2
- package/docs/reference.md +34 -2
- package/package.json +1 -1
- package/skills/frontend-ui-ux/SKILL.md +3 -2
- package/skills/frontend-ui-ux/kit/components.css +414 -0
- package/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
- package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
- package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
- package/skills/frontend-ui-ux/references/house-styles.md +236 -0
- package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
- package/skills/frontend-ui-ux/references/production.md +3 -1
- package/skills/frontend-ui-ux/references/slop-register.md +4 -4
- package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
- package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
- package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
- package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
- package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
- package/skills/lit-adhd/SKILL.md +208 -0
- package/skills/lit-adhd/references/brief-format.md +43 -0
- package/skills/lit-adhd/references/card-examples.md +82 -0
- package/skills/lit-adhd/references/status-taxonomy.md +29 -0
- package/skills/lit-adhd/scripts/rewind.mjs +835 -0
- package/skills/lit-diagram-drawer/SKILL.md +4 -3
- package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
- package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
- package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
- package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
- package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
- package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
- package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
- package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
- package/skills/lit-docx/SKILL.md +2 -1
- package/skills/lit-handoff/SKILL.md +2 -0
- package/skills/lit-hijack/SKILL.md +422 -0
- package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
- package/skills/lit-hijack/references/clean-room-build.md +111 -0
- package/skills/lit-hijack/references/distil-and-licence.md +100 -0
- package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
- package/skills/lit-hijack/references/parity-and-report.md +110 -0
- package/skills/lit-hijack/scripts/hijack.mjs +993 -0
- package/skills/lit-plan/SKILL.md +2 -0
- package/skills/lit-pptx/SKILL.md +27 -11
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
- package/skills/lit-pptx/references/density-and-fill.md +9 -3
- package/skills/lit-pptx/references/direction-step.md +21 -6
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
- package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/skills/lit-pptx/references/layout-families.md +65 -47
- package/skills/lit-pptx/references/title-treatments.md +83 -134
- package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
- package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
- package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
- package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
- package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
- package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
- package/skills/lit-pptx/references/tonalities/night.md +1 -1
- package/skills/lit-pptx/references/tonalities/paper.md +2 -2
- package/skills/lit-pptx/references/tonalities/riso.md +137 -0
- package/skills/lit-pptx/references/tonalities/signal.md +3 -3
- package/skills/lit-pptx/references/tonalities/studio.md +7 -6
- package/skills/lit-pptx/run.mjs +2 -1
- package/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
- package/skills/lit-pptx/scripts/craft_extras.py +56 -3
- package/skills/lit-pptx/scripts/deck_output.py +75 -90
- package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
- package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/skills/lit-pptx/scripts/qa_deck.py +10 -11
- package/skills/lit-pptx/scripts/render_pages.py +118 -0
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/skills/lit-recap/SKILL.md +4 -0
- package/skills/lit-schematic/SKILL.md +118 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
- package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
- package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
- package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
- package/skills/lit-schematic/references/isometric-craft.md +101 -0
- package/skills/lit-schematic/references/journal-figures.md +72 -0
- package/skills/lit-schematic/references/kit-api.md +134 -0
- package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
- package/skills/lit-schematic/references/pid-drafting.md +103 -0
- package/skills/lit-schematic/references/pid-symbols.md +163 -0
- package/skills/lit-schematic/references/verification.md +100 -0
- package/skills/lit-schematic/scripts/doctor.mjs +57 -0
- package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
- package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
- package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
- package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
- package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
- package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
- package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
- package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
- package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
- package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
- package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
- package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
- package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
- package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
- package/skills/lit-schematic/scripts/motion.mjs +129 -0
- package/skills/lit-schematic/scripts/qa.mjs +92 -0
- package/skills/lit-schematic/scripts/render.mjs +233 -0
- package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
- package/skills/lit-scientific-visualization/SKILL.md +27 -4
- package/skills/lit-typographic-motion/SKILL.md +19 -17
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
- package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
- package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
- package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
- package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
- package/skills/lit-typographic-motion/engine/board.mjs +138 -0
- package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
- package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
- package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
- package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
- package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
- package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
- package/skills/lit-typographic-motion/engine/house.mjs +42 -0
- package/skills/lit-typographic-motion/engine/look.mjs +65 -8
- package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
- package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
- package/skills/lit-typographic-motion/engine/score.mjs +594 -0
- package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
- package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
- package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
- package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
- package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
- package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
- package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
- package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
- package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
- package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
- package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
- package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
- package/skills/lit-typographic-motion/engine/text.mjs +1 -1
- package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
- package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/skills/lit-typographic-motion/gate.mjs +18 -2
- package/skills/lit-typographic-motion/probe.mjs +13 -1
- package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
- package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
- package/skills/lit-typographic-motion/references/cover.md +56 -13
- package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
- package/skills/lit-typographic-motion/references/depth.md +112 -0
- package/skills/lit-typographic-motion/references/runtime.md +21 -6
- package/skills/lit-typographic-motion/references/sound.md +76 -0
- package/skills/lit-typographic-motion/references/stage.md +50 -7
- package/skills/lit-typographic-motion/references/studio.md +162 -0
- package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
- package/skills/lit-typographic-motion/references/treatment.md +108 -9
- package/skills/lit-typographic-motion/render.mjs +60 -3
- package/skills/lit-typographic-motion/styles/paper.json +185 -0
- package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
- package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
- package/skills/lit-typographic-motion/styles/tidal.json +187 -0
- package/skills/litresearch/SKILL.md +2 -0
- package/skills/managed-skill-manifest.json +517 -88
- package/skills/readme-studio/SKILL.md +1 -1
- package/skills/review-work/SKILL.md +2 -0
- package/skills/start-work/SKILL.md +8 -0
- package/skills/workflow-loop/SKILL.md +4 -0
- package/tools/check-pack-payload.mjs +4 -2
- package/tools/payload-substance-parity.json +4 -3
- package/tools/qa-cover-recipes.mjs +4 -3
|
@@ -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 =
|
|
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
|
|
11
|
-
|
|
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
|
|
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
|
|
18
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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"]) >=
|
|
154
|
-
and item["x"]
|
|
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
|
|
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
|
|
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
|
|
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["
|
|
369
|
-
groups.setdefault(entry["
|
|
370
|
-
|
|
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
|
|
377
|
-
values = [round(
|
|
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} {
|
|
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
|
|
384
|
-
"
|
|
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
|
|
390
|
-
#
|
|
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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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,
|
|
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.
|
|
530
|
-
#
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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"{
|
|
546
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|