@litfamily/litopencode 1.0.16 → 1.0.18
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 +13 -0
- package/README-Ko-KR.md +11 -11
- package/README.md +11 -11
- package/dist/activation-routing.js +1 -1
- package/dist/deliverable-hedge-guard.js +3 -1
- package/dist/features.js +2 -2
- package/dist/hooks.js +2 -1
- package/dist/skills.js +6 -4
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +1 -1
- package/package.json +1 -1
- package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
- package/skills/lit-docx/SKILL.md +80 -19
- package/skills/lit-docx/references/components.md +253 -0
- package/skills/lit-docx/references/direction-step.md +234 -0
- package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
- package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
- package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
- package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
- package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
- package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
- package/skills/lit-docx/references/examples/07-report-en.md +137 -0
- package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
- package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
- package/skills/lit-docx/references/page-composition.md +306 -0
- package/skills/lit-docx/references/tonalities/brief.md +104 -0
- package/skills/lit-docx/references/tonalities/journal.md +101 -0
- package/skills/lit-docx/references/tonalities/manual.md +111 -0
- package/skills/lit-docx/references/tonalities/memo.md +105 -0
- package/skills/lit-docx/references/tonalities/proposal.md +101 -0
- package/skills/lit-docx/references/tonalities/report.md +126 -0
- package/skills/lit-docx/run.py +2 -1
- package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
- package/skills/lit-docx/scripts/craft_docx.py +204 -1
- package/skills/lit-docx/scripts/docx_design.py +2445 -0
- package/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/skills/lit-docx/scripts/slop_lint.py +23 -6
- package/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
- package/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
- package/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/report.yaml +26 -0
- package/skills/lit-pptx/NOTICE.md +1 -1
- package/skills/lit-pptx/SKILL.md +108 -19
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
- package/skills/lit-pptx/references/density-and-fill.md +283 -0
- package/skills/lit-pptx/references/direction-step.md +249 -0
- package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
- package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
- package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
- package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
- package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
- package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
- package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
- package/skills/lit-pptx/references/layout-families.md +470 -0
- package/skills/lit-pptx/references/title-treatments.md +271 -0
- package/skills/lit-pptx/references/tonalities/atlas.md +131 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
- package/skills/lit-pptx/references/tonalities/ledger.md +150 -0
- package/skills/lit-pptx/references/tonalities/night.md +148 -0
- package/skills/lit-pptx/references/tonalities/paper.md +135 -0
- package/skills/lit-pptx/references/tonalities/signal.md +147 -0
- package/skills/lit-pptx/references/tonalities/studio.md +149 -0
- package/skills/lit-pptx/scripts/compile-deck.js +68 -21
- package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
- package/skills/lit-pptx/scripts/craft_extras.py +37 -8
- package/skills/lit-pptx/scripts/deck_output.py +654 -0
- package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
- package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
- package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
- package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
- package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
- package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
- package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
- package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
- package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
- package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
- package/skills/lit-pptx/scripts/qa_deck.py +48 -13
- package/skills/lit-pptx/scripts/validate_pptx.py +25 -7
- package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
- package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +100 -0
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +101 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
- package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
- package/skills/lit-typographic-motion/fonts/pins.json +2 -2
- package/skills/lit-typographic-motion/gate.mjs +1 -1
- package/skills/lit-typographic-motion/references/stage.md +1 -1
- package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
- package/skills/lit-typographic-motion/references/treatment.md +1 -1
- package/skills/lit-typographic-motion/references/type-craft.md +3 -3
- package/skills/managed-skill-manifest.json +288 -44
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Regular.otf +0 -0
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""Deck-wide output checks for the LitOpenCode slide QA gate (OF-110..OF-119).
|
|
2
|
+
|
|
3
|
+
craft_extras.py judges one slide at a time. These checks read the compiled deck as a whole:
|
|
4
|
+
|
|
5
|
+
OF-110 a deck of eight or more slides mixes at least three title treatments, and every title
|
|
6
|
+
is drawn the way its `title@<treatment>` name says
|
|
7
|
+
OF-111 content slides vary their composition (title zone x body partition x dominant content)
|
|
8
|
+
OF-112 the body is used: a slide over its density cap is advisory, a deck median over the
|
|
9
|
+
limit fails
|
|
10
|
+
OF-113 every slide that uses one treatment keeps that treatment's title frame (a bottom title
|
|
11
|
+
keeps its bottom edge on the floor; its top follows its line count)
|
|
12
|
+
OF-114 titles and the cover subtitle name a topic; they are not declarative sentences
|
|
13
|
+
OF-115 no region stands empty beside a full one: a bottom title ends on the body floor, a side
|
|
14
|
+
rail carries what belongs beside the title, a takeaway column runs as long as the visual
|
|
15
|
+
next to it, a column of a two-column body holds no tall empty band beside a longer one, a
|
|
16
|
+
title panel is as tall as its text, and no plate leaves the page above it open
|
|
17
|
+
OF-116 (qa_deck.py --sibling) another tonality of the same source draws a different skeleton
|
|
18
|
+
(two content slides once the deck has four)
|
|
19
|
+
OF-117 no light figure card stands on a dark ground
|
|
20
|
+
OF-118 a bold run-in label is followed by its separator ("요약: …", "Summary: …")
|
|
21
|
+
OF-119 an agenda's title carries no count numeral beside it (its rows are numbered)
|
|
22
|
+
|
|
23
|
+
The treatment is read from the title frame's geometry and what is drawn with it. The name the
|
|
24
|
+
compiler writes is compared with that reading, never trusted alone. A deck the tonality compiler
|
|
25
|
+
did not build (no `title@` shape) is out of scope. On a legacy template (`title@legacy`) the
|
|
26
|
+
variety checks are advisory: whoever names a legacy template has asked for its single look.
|
|
27
|
+
Geometry is in points; every threshold lives in craft-thresholds.json (`output*` keys).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import math
|
|
34
|
+
import re
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
38
|
+
from pptx import Presentation
|
|
39
|
+
|
|
40
|
+
from layout_inventory import _measured_lines
|
|
41
|
+
|
|
42
|
+
T = json.loads(Path(__file__).with_name("craft-thresholds.json").read_text())
|
|
43
|
+
PT = 12700
|
|
44
|
+
TREATMENTS = ("top-rule", "top-plain-large", "side-rail", "band", "statement", "overlay", "kicker-numeral", "bottom-anchor")
|
|
45
|
+
ZONE = {"top-rule": "top", "top-plain-large": "top", "kicker-numeral": "top", "side-rail": "side", "band": "band",
|
|
46
|
+
"overlay": "overlay", "bottom-anchor": "bottom", "statement": "none"}
|
|
47
|
+
# The widest four-column title and the earliest column-5 start over the four grids, per canvas width.
|
|
48
|
+
RAIL = {960: (288.0, 336.0), 720: (216.0, 252.0)}
|
|
49
|
+
SIDE_MARGIN = {960: {"airy": 60.0, "standard": 48.0, "dense": 36.0, "compact": 24.0},
|
|
50
|
+
720: {"airy": 54.0, "standard": 42.0, "dense": 30.0, "compact": 24.0}}
|
|
51
|
+
STAMP = re.compile(r"\blit-pptx tonality=(\S+) density=(\d+) grid=(\w+) variance=(\d+)")
|
|
52
|
+
NUMERAL = re.compile(r"\d+(?:[.:/-]\d+)?\.?")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _finding(rule, severity, slide, shape, value, hint):
|
|
56
|
+
return {"rule": rule, "severity": severity, "tier": "measured", "slide": slide, "shape": shape,
|
|
57
|
+
"value": value, "threshold": None, "hint": hint}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _text(shape):
|
|
61
|
+
if not getattr(shape, "has_text_frame", False):
|
|
62
|
+
return ""
|
|
63
|
+
return re.sub(r"\s+", " ", shape.text_frame.text).strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _filled(shape):
|
|
67
|
+
try:
|
|
68
|
+
return shape.fill.type == 1
|
|
69
|
+
except (AttributeError, TypeError, NotImplementedError):
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _touches_two_edges(x, y, w, h, width, height, tol=2.0):
|
|
74
|
+
"""A fill on two or more canvas edges (band, rail, colour field) is page furniture, not a card."""
|
|
75
|
+
return sum([x <= tol, y <= tol, x + w >= width - tol, y + h >= height - tol]) >= 2
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _inside(item, box, tol=1.0):
|
|
79
|
+
cx, cy = item["x"] + item["w"] / 2, item["y"] + item["h"] / 2
|
|
80
|
+
return box["x"] - tol <= cx <= box["x"] + box["w"] + tol and box["y"] - tol <= cy <= box["y"] + box["h"] + tol
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _footer(item):
|
|
84
|
+
return item["y"] >= T["outputFooterTopPt"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _items(slide, width, height):
|
|
88
|
+
"""Every drawn shape in points with its kind and its role: content, card or decoration."""
|
|
89
|
+
items = []
|
|
90
|
+
for shape in slide.shapes:
|
|
91
|
+
if shape.left is None or shape.width is None or shape.name.startswith("lit-notice"):
|
|
92
|
+
continue
|
|
93
|
+
x, y, w, h = shape.left / PT, shape.top / PT, shape.width / PT, shape.height / PT
|
|
94
|
+
if w + h <= 0:
|
|
95
|
+
continue
|
|
96
|
+
text = _text(shape)
|
|
97
|
+
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
98
|
+
kind = "picture"
|
|
99
|
+
elif getattr(shape, "has_table", False):
|
|
100
|
+
kind = "table"
|
|
101
|
+
h = max(h, sum(row.height for row in shape.table.rows) / PT)
|
|
102
|
+
elif getattr(shape, "has_chart", False):
|
|
103
|
+
kind = "chart"
|
|
104
|
+
else:
|
|
105
|
+
kind = "text" if text else "shape"
|
|
106
|
+
sizes = [run.font.size.pt for paragraph in shape.text_frame.paragraphs for run in paragraph.runs
|
|
107
|
+
if run.text.strip() and run.font.size is not None] if text else []
|
|
108
|
+
items.append({"shape": shape, "name": shape.name, "kind": kind, "x": x, "y": y, "w": w, "h": h, "text": text,
|
|
109
|
+
"sizes": sizes, "filled": kind == "shape" and _filled(shape)})
|
|
110
|
+
for item in items:
|
|
111
|
+
if item["kind"] != "shape":
|
|
112
|
+
item["role"] = "content"
|
|
113
|
+
continue
|
|
114
|
+
holds = any(other["kind"] != "shape" and _inside(other, item) for other in items if other is not item)
|
|
115
|
+
card = (item["filled"] and holds and not _touches_two_edges(item["x"], item["y"], item["w"], item["h"], width, height)
|
|
116
|
+
and item["w"] >= 100 and item["h"] >= 40 and item["w"] * item["h"] < 0.5 * width * height)
|
|
117
|
+
item["role"] = "card" if card else "decoration"
|
|
118
|
+
return items
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def treatment_of(title, items, width, height):
|
|
122
|
+
"""The treatment a title shows, tested in a fixed order; the first that fits wins."""
|
|
123
|
+
tol, size_tol = T["outputGeometryTolPt"], T["outputSizeTolPt"]
|
|
124
|
+
size = max(title["sizes"] or [0.0])
|
|
125
|
+
bottom = title["y"] + title["h"]
|
|
126
|
+
others = [item for item in items if item is not title]
|
|
127
|
+
body = [item for item in others if not _footer(item) and item["role"] != "decoration"]
|
|
128
|
+
|
|
129
|
+
def behind(item):
|
|
130
|
+
return (item["x"] - tol <= title["x"] and title["x"] + title["w"] <= item["x"] + item["w"] + tol
|
|
131
|
+
and item["y"] - tol <= title["y"] and bottom <= item["y"] + item["h"] + tol)
|
|
132
|
+
|
|
133
|
+
if (any(item["kind"] == "picture" and item["w"] * item["h"] >= 0.8 * width * height for item in others)
|
|
134
|
+
and title["y"] >= 300 - tol and any(item["kind"] == "shape" and item["filled"] and behind(item) for item in others)):
|
|
135
|
+
return "overlay"
|
|
136
|
+
if any(item["kind"] == "shape" and item["filled"] and item["y"] <= 2 + tol and 96 - tol <= item["h"] <= 120 + tol
|
|
137
|
+
and item["w"] >= 0.9 * width and _inside(title, item, tol) for item in others):
|
|
138
|
+
return "band"
|
|
139
|
+
if (size >= 36 - size_tol and 220 - tol <= title["y"] + title["h"] / 2 <= 380 + tol
|
|
140
|
+
and not any(item["kind"] in ("table", "chart", "picture") or item["role"] == "card" for item in body)
|
|
141
|
+
and sum(1 for item in body if item["kind"] == "text") <= 1):
|
|
142
|
+
return "statement"
|
|
143
|
+
if title["y"] >= 360 - tol and all(item["y"] <= title["y"] + tol for item in body if item["kind"] == "text"):
|
|
144
|
+
return "bottom-anchor"
|
|
145
|
+
rail_width, column_five = RAIL.get(round(width), RAIL[960])
|
|
146
|
+
beside = [item for item in body if item["x"] + item["w"] / 2 > title["x"] + title["w"]]
|
|
147
|
+
# Criteria labels, takeaways and the source under the title stand in the rail and belong to it; only a
|
|
148
|
+
# block that runs out of the rail into the body makes the title something else.
|
|
149
|
+
if (title["w"] <= rail_width + tol and beside and min(item["x"] for item in beside) >= column_five - tol
|
|
150
|
+
and not any(item["x"] < column_five - tol and item["y"] > bottom and item["x"] + item["w"] > column_five + tol for item in body)):
|
|
151
|
+
return "side-rail"
|
|
152
|
+
for item in others:
|
|
153
|
+
if (item["kind"] == "text" and item["sizes"] and NUMERAL.fullmatch(item["text"]) and max(item["sizes"]) >= 44 - size_tol
|
|
154
|
+
and item["x"] + item["w"] <= title["x"] + tol and item["y"] >= 30 - tol and item["y"] + item["h"] <= 114 + tol):
|
|
155
|
+
return "kicker-numeral"
|
|
156
|
+
if title["y"] <= 48 + tol:
|
|
157
|
+
# A rule is a line under the title (wider than 16 pt and four times its height), never a list dot.
|
|
158
|
+
rule = any(item["kind"] == "shape" and item["h"] <= 8 and item["w"] >= max(16, 4 * item["h"])
|
|
159
|
+
and bottom - tol <= item["y"] <= bottom + 24 and item["x"] < title["x"] + title["w"]
|
|
160
|
+
and item["x"] + item["w"] > title["x"] for item in others)
|
|
161
|
+
if size >= 36 - size_tol and not rule:
|
|
162
|
+
return "top-plain-large"
|
|
163
|
+
if size <= 32 + size_tol and rule:
|
|
164
|
+
return "top-rule"
|
|
165
|
+
return "unclassified"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _is_figure(item):
|
|
169
|
+
"""Text of 44 pt or more that is mostly digits reads as a number, not as text."""
|
|
170
|
+
compact = re.sub(r"\s+", "", item["text"])
|
|
171
|
+
digits = len(re.findall(r"\d", compact))
|
|
172
|
+
return item["kind"] == "text" and bool(item["sizes"]) and max(item["sizes"]) >= 44 - T["outputSizeTolPt"] and digits / max(1, len(compact)) >= 0.5
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def signature(title, treatment, items, width, height, margin):
|
|
176
|
+
"""(title zone, body partition, dominant content) of one slide.
|
|
177
|
+
|
|
178
|
+
Body blocks group into columns by their horizontal extent: two blocks that overlap by half the
|
|
179
|
+
narrower one share a column. A block wider than the wide-block share of the body (a takeaway
|
|
180
|
+
under two columns) never merges columns.
|
|
181
|
+
"""
|
|
182
|
+
zone = ZONE.get(treatment, "none")
|
|
183
|
+
if treatment == "statement":
|
|
184
|
+
return (zone, "one", "text")
|
|
185
|
+
column_five = RAIL.get(round(width), RAIL[960])[1]
|
|
186
|
+
body = []
|
|
187
|
+
for item in items:
|
|
188
|
+
if item is title or _footer(item) or item["role"] == "decoration":
|
|
189
|
+
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
|
+
if item["kind"] == "shape" and title is not None and _inside(title, item, 0):
|
|
193
|
+
continue # the panel the title stands on
|
|
194
|
+
if treatment == "kicker-numeral" and item["kind"] == "text" and NUMERAL.fullmatch(item["text"]) and item["y"] + item["h"] <= 116:
|
|
195
|
+
continue
|
|
196
|
+
body.append(item)
|
|
197
|
+
cards = [item for item in body if item["role"] == "card"]
|
|
198
|
+
blocks = [item for item in body if not any(card is not item and _inside(item, card) for card in cards)]
|
|
199
|
+
if any(item["kind"] == "picture" and item["w"] * item["h"] >= 0.8 * width * height for item in items):
|
|
200
|
+
partition = "full-bleed"
|
|
201
|
+
elif not blocks:
|
|
202
|
+
partition = "one"
|
|
203
|
+
else:
|
|
204
|
+
narrow = [item for item in blocks if item["w"] < T["outputWideBlockShare"] * (width - 2 * margin)] or blocks
|
|
205
|
+
columns = []
|
|
206
|
+
for item in sorted(narrow, key=lambda entry: entry["x"]):
|
|
207
|
+
hits = [column for column in columns
|
|
208
|
+
if min(column["r"], item["x"] + item["w"]) - max(column["l"], item["x"]) >= 0.5 * min(column["r"] - column["l"], item["w"])]
|
|
209
|
+
if hits:
|
|
210
|
+
keep = hits[0]
|
|
211
|
+
for other in hits[1:]:
|
|
212
|
+
keep["l"], keep["r"] = min(keep["l"], other["l"]), max(keep["r"], other["r"])
|
|
213
|
+
keep["items"] += other["items"]
|
|
214
|
+
columns.remove(other)
|
|
215
|
+
keep["l"], keep["r"] = min(keep["l"], item["x"]), max(keep["r"], item["x"] + item["w"])
|
|
216
|
+
keep["items"].append(item)
|
|
217
|
+
else:
|
|
218
|
+
columns.append({"l": item["x"], "r": item["x"] + item["w"], "items": [item]})
|
|
219
|
+
column_of = {id(entry): number for number, column in enumerate(columns) for entry in column["items"]}
|
|
220
|
+
rows = []
|
|
221
|
+
for item in sorted(narrow, key=lambda entry: entry["y"]):
|
|
222
|
+
if rows and item["y"] < rows[-1]["bottom"] - 2:
|
|
223
|
+
rows[-1]["bottom"] = max(rows[-1]["bottom"], item["y"] + item["h"])
|
|
224
|
+
rows[-1]["items"].append(item)
|
|
225
|
+
else:
|
|
226
|
+
rows.append({"bottom": item["y"] + item["h"], "items": [item]})
|
|
227
|
+
shared_rows = sum(1 for row in rows if len({column_of[id(entry)] for entry in row["items"]}) >= 2)
|
|
228
|
+
count = len(columns)
|
|
229
|
+
if count >= 4 or (count >= 2 and shared_rows >= 2):
|
|
230
|
+
partition = "grid"
|
|
231
|
+
elif count == 3:
|
|
232
|
+
partition = "three"
|
|
233
|
+
elif count == 2:
|
|
234
|
+
first, second = (column["r"] - column["l"] for column in columns)
|
|
235
|
+
partition = "two-even" if min(first, second) / max(first, second) >= T["outputEvenColumns"] else "two-asym"
|
|
236
|
+
else:
|
|
237
|
+
partition = "one"
|
|
238
|
+
area = {}
|
|
239
|
+
kinds = {"picture": "image", "table": "table", "chart": "chart"}
|
|
240
|
+
for item in blocks:
|
|
241
|
+
if item["kind"] in kinds:
|
|
242
|
+
key = kinds[item["kind"]]
|
|
243
|
+
elif item["kind"] == "text":
|
|
244
|
+
key = "number" if _is_figure(item) else "text"
|
|
245
|
+
else:
|
|
246
|
+
continue
|
|
247
|
+
area[key] = area.get(key, 0.0) + item["w"] * item["h"]
|
|
248
|
+
for card in cards:
|
|
249
|
+
held = [item for item in body if item is not card and _inside(item, card)]
|
|
250
|
+
key = "number" if any(_is_figure(item) for item in held) else "text"
|
|
251
|
+
area[key] = area.get(key, 0.0) + card["w"] * card["h"]
|
|
252
|
+
return (zone, partition, max(area, key=area.get) if area else "text")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def empty_band(title, items, top_margin=36.0):
|
|
256
|
+
"""Share of the body left empty under its lowest block, from the title's foot (or the top margin
|
|
257
|
+
when the title sits low) to the body floor; decoration, footer and notice are not counted."""
|
|
258
|
+
floor = T["outputBandFloorPt"]
|
|
259
|
+
top = title["y"] + title["h"] if title is not None and title["y"] + title["h"] < floor * 0.6 else top_margin
|
|
260
|
+
if floor - top < 10:
|
|
261
|
+
return None
|
|
262
|
+
ink = [item for item in items if item["role"] != "decoration" and not _footer(item) and item["y"] < floor and item["y"] + item["h"] > top]
|
|
263
|
+
lowest = max((min(floor, item["y"] + item["h"]) for item in ink), default=top)
|
|
264
|
+
return max(0.0, (floor - lowest) / (floor - top))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _role(family):
|
|
268
|
+
for kind in ("cover", "section", "closing"):
|
|
269
|
+
if family == kind or family.startswith(kind + "-"):
|
|
270
|
+
return kind
|
|
271
|
+
return "content"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _variance_allows(variance):
|
|
275
|
+
return 2 if variance <= 3 else 3 if variance == 4 else 4 if variance <= 6 else 5
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def read_slides(deck) -> dict:
|
|
279
|
+
"""Every slide's items, family, role, named and drawn treatment and (content slides) signature, with the deck's dials."""
|
|
280
|
+
width, height = deck.slide_width / PT, deck.slide_height / PT
|
|
281
|
+
stamp = STAMP.search(deck.core_properties.subject or "")
|
|
282
|
+
density, variance = (int(stamp.group(2)), int(stamp.group(4))) if stamp else (5, 5)
|
|
283
|
+
margin = SIDE_MARGIN[960 if round(width) == 960 else 720].get(stamp.group(3) if stamp else "standard", 48.0)
|
|
284
|
+
slides = []
|
|
285
|
+
for number, slide in enumerate(deck.slides, start=1):
|
|
286
|
+
items = _items(slide, width, height)
|
|
287
|
+
family = next((shape.name.split("@", 1)[1] for shape in slide.shapes if shape.name.startswith("family@")), "")
|
|
288
|
+
title = next((item for item in items if item["name"].startswith("title@")), None)
|
|
289
|
+
entry = {"n": number, "slide": slide, "items": items, "family": family, "role": _role(family), "title": title,
|
|
290
|
+
"named": title["name"].split("@", 1)[1] if title else None}
|
|
291
|
+
entry["treatment"] = treatment_of(title, items, width, height) if title and title["sizes"] else None
|
|
292
|
+
if entry["role"] == "content":
|
|
293
|
+
entry["signature"] = signature(title, entry["treatment"], items, width, height, margin)
|
|
294
|
+
slides.append(entry)
|
|
295
|
+
return {"width": width, "height": height, "stamp": stamp, "density": density, "variance": variance, "slides": slides}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def check_output(deck) -> list:
|
|
299
|
+
"""OF-110..OF-113 and OF-115 over the whole deck."""
|
|
300
|
+
read = read_slides(deck)
|
|
301
|
+
width, height, stamp, density, variance, slides = (read[key] for key in ("width", "height", "stamp", "density", "variance", "slides"))
|
|
302
|
+
if not any(entry["named"] for entry in slides):
|
|
303
|
+
return []
|
|
304
|
+
legacy = any(entry["named"] == "legacy" for entry in slides)
|
|
305
|
+
variety = "MEDIUM" if legacy else "HIGH"
|
|
306
|
+
found = []
|
|
307
|
+
for entry in slides:
|
|
308
|
+
if entry["named"] in TREATMENTS and entry["treatment"] not in (None, entry["named"]):
|
|
309
|
+
found.append(_finding("OF-110", "HIGH", entry["n"], entry["title"]["name"],
|
|
310
|
+
f"the title is drawn as {entry['treatment']}, named {entry['named']}",
|
|
311
|
+
"Draw the treatment with its own frame and companions so the name matches the page."))
|
|
312
|
+
# Covers and sections are variants of their own, so only the other titled slides are counted.
|
|
313
|
+
titled = [entry for entry in slides if entry["role"] not in ("cover", "section") and entry["treatment"]]
|
|
314
|
+
used = sorted({entry["treatment"] for entry in titled} - {"unclassified"})
|
|
315
|
+
unknown = [entry["n"] for entry in titled if entry["treatment"] == "unclassified"]
|
|
316
|
+
if len(slides) >= T["outputMinSlides"]:
|
|
317
|
+
if variance <= 3 and len(used) < T["outputMinTreatments"]:
|
|
318
|
+
found.append(_finding("OF-110", "MEDIUM", None, None, f"{len(used)} title treatment(s) at variance {variance}",
|
|
319
|
+
"Variance under 4 is the user's choice; name it in the reply."))
|
|
320
|
+
elif len(used) < T["outputMinTreatments"]:
|
|
321
|
+
found.append(_finding("OF-110", variety, None, None, f"{len(used)} title treatment(s) ({', '.join(used) or 'none'}) on {len(slides)} slides",
|
|
322
|
+
"Give slide roles the tonality's other treatments: data beside a side rail or over a bottom title, steps under a numeral."))
|
|
323
|
+
elif stamp and len(used) > _variance_allows(variance):
|
|
324
|
+
found.append(_finding("OF-110", variety, None, None, f"{len(used)} title treatments; variance {variance} allows {_variance_allows(variance)}",
|
|
325
|
+
"Use fewer treatments, or raise variance by at most 2 without asking."))
|
|
326
|
+
if unknown:
|
|
327
|
+
found.append(_finding("OF-110", variety, unknown[0], None, f"no known treatment on slide(s) {', '.join(map(str, unknown))}",
|
|
328
|
+
"Let the compiler place the title on one of the eight treatment frames; never position it by hand."))
|
|
329
|
+
content = [entry for entry in slides if entry["role"] == "content"]
|
|
330
|
+
if len(content) >= 3:
|
|
331
|
+
need = min(T["outputVarietyCap"], math.ceil(round(T["outputVarietyShare"] * len(content), 6)))
|
|
332
|
+
problems = []
|
|
333
|
+
for label, key in (("signature", lambda entry: entry["signature"]), ("layout", lambda entry: entry["signature"][:2])):
|
|
334
|
+
counts = {}
|
|
335
|
+
for entry in content:
|
|
336
|
+
counts[key(entry)] = counts.get(key(entry), 0) + 1
|
|
337
|
+
common, common_n = max(counts.items(), key=lambda pair: pair[1])
|
|
338
|
+
if len(counts) < need:
|
|
339
|
+
problems.append(f"{label}: {len(counts)} distinct, needs {need}")
|
|
340
|
+
if common_n / len(content) > T["outputMaxShare"]:
|
|
341
|
+
hit = [entry["n"] for entry in content if key(entry) == common]
|
|
342
|
+
problems.append(f"{label}: {'/'.join(common)} on {common_n} of {len(content)} content slides ({', '.join(map(str, hit))})")
|
|
343
|
+
if problems:
|
|
344
|
+
found.append(_finding("OF-111", variety, None, None, "; ".join(problems),
|
|
345
|
+
"Change the family or title treatment of the repeated slides; let each slide's job choose its shape."))
|
|
346
|
+
cap = next(limit for upto, limit in T["outputBandCaps"] if density <= upto)
|
|
347
|
+
bands = []
|
|
348
|
+
for entry in slides:
|
|
349
|
+
if entry["role"] in ("cover", "section") or entry["family"] in ("statement", "quote", "closing-statement") or entry["treatment"] == "statement":
|
|
350
|
+
continue
|
|
351
|
+
band = empty_band(entry["title"], entry["items"])
|
|
352
|
+
if band is None:
|
|
353
|
+
continue
|
|
354
|
+
bands.append(band)
|
|
355
|
+
if band > cap:
|
|
356
|
+
found.append(_finding("OF-112", "MEDIUM", entry["n"], None, f"{band:.0%} of the body empty under the last block (cap {cap:.0%} at density {density})",
|
|
357
|
+
"Follow the tonality's fill order: a grown visual, wider gaps, or a family that fits the content."))
|
|
358
|
+
if bands:
|
|
359
|
+
bands.sort()
|
|
360
|
+
middle = len(bands) // 2
|
|
361
|
+
median = bands[middle] if len(bands) % 2 else (bands[middle - 1] + bands[middle]) / 2
|
|
362
|
+
if median > T["outputBandDeckMax"]:
|
|
363
|
+
# A legacy template has no fill policies, so its median is advisory; OF-109 still blocks a hollow slide.
|
|
364
|
+
found.append(_finding("OF-112", variety, None, None, f"median empty band {median:.2f} over {len(bands)} slides (limit {T['outputBandDeckMax']:.2f})",
|
|
365
|
+
"Give content slides material that reaches the floor: a takeaway under a table, the next step under an ask."))
|
|
366
|
+
groups = {}
|
|
367
|
+
for entry in titled:
|
|
368
|
+
if entry["treatment"] in TREATMENTS and entry["named"] in TREATMENTS:
|
|
369
|
+
groups.setdefault(entry["treatment"], []).append(entry)
|
|
370
|
+
# A statement is centred vertically, so only its x and width repeat. A bottom title stands on the floor:
|
|
371
|
+
# its bottom edge repeats and its top moves with the line count.
|
|
372
|
+
edge = lambda title, key: title["y"] + title["h"] if key == "bottom" else title[key]
|
|
373
|
+
for treatment, group in groups.items():
|
|
374
|
+
keys = ("x", "w") if treatment == "statement" else ("x", "w", "bottom") if treatment == "bottom-anchor" else ("x", "y", "w")
|
|
375
|
+
modes = {}
|
|
376
|
+
for key in keys:
|
|
377
|
+
values = [round(edge(entry["title"], key)) for entry in group]
|
|
378
|
+
modes[key] = max(set(values), key=values.count)
|
|
379
|
+
for entry in group:
|
|
380
|
+
off = [f"{key} {edge(entry['title'], key):.0f} vs {modes[key]}" for key in keys
|
|
381
|
+
if abs(edge(entry["title"], key) - modes[key]) > T["outputAnchorTolPt"]]
|
|
382
|
+
if off:
|
|
383
|
+
found.append(_finding("OF-113", "HIGH", entry["n"], entry["title"]["name"], f"{treatment} title off its frame: {', '.join(off)}",
|
|
384
|
+
"Slides with one treatment share its title frame; fix the source instead of nudging a title."))
|
|
385
|
+
return found + empty_regions(read) + agenda_numerals(read)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# OF-115. The empty band (OF-112) reads the body as one page, yet one region can stand empty beside a full
|
|
389
|
+
# one: the floor under a bottom title, the rail under a side title, the takeaway column beside a visual that
|
|
390
|
+
# runs on below it, the panel that carries a title over a picture, the open page above a plate. Each is read
|
|
391
|
+
# as a share of its own region, or under a bottom title as a distance.
|
|
392
|
+
def _title_end(title):
|
|
393
|
+
"""Where a top-aligned title's last line ends; the frame keeps spare lines, so its own bottom does not say."""
|
|
394
|
+
frame = title["shape"].text_frame
|
|
395
|
+
size = max(title["sizes"] or [0.0])
|
|
396
|
+
usable = max(1.0, (title["shape"].width - frame.margin_left - frame.margin_right) / PT)
|
|
397
|
+
bold = any(run.font.bold for paragraph in frame.paragraphs for run in paragraph.runs)
|
|
398
|
+
count = sum(_measured_lines(paragraph.text, size, bold, usable) for paragraph in frame.paragraphs if paragraph.text.strip())
|
|
399
|
+
return title["y"] + max(1, count) * size * 1.15
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _largest_gap(top, bottom, spans):
|
|
403
|
+
"""The tallest stretch of [top, bottom] that no (y0, y1) span covers."""
|
|
404
|
+
gap, cursor = 0.0, top
|
|
405
|
+
for y0, y1 in sorted(spans):
|
|
406
|
+
if y1 <= cursor:
|
|
407
|
+
continue
|
|
408
|
+
gap = max(gap, min(y0, bottom) - cursor)
|
|
409
|
+
cursor = max(cursor, y1)
|
|
410
|
+
return max(gap, bottom - cursor)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _short_column(entry, content, title, treatment, body_floor, column_five, width, height):
|
|
414
|
+
"""A column of a two-column body that stops short of the column beside it: its tallest empty band, from the
|
|
415
|
+
top of the pair down to where the other column ends, over the share limit of the body. A source or note strip
|
|
416
|
+
closing the column counts as a block but does not hide the band above it. Beside the figures of a kpi-row or
|
|
417
|
+
big-number slide the column is measured to the body floor (spec-v2 Amendments 9)."""
|
|
418
|
+
tol = T["outputGeometryTolPt"]
|
|
419
|
+
# Under a top title the body starts below it; beside a side title (its rail left out) or over a bottom title, at the top.
|
|
420
|
+
start = title["y"] + title["h"] if treatment not in ("bottom-anchor", "side-rail") else 0.0
|
|
421
|
+
fields = [item for item in entry["items"] if item["kind"] == "shape" and item["filled"] and item["w"] * item["h"] >= 0.1 * width * height]
|
|
422
|
+
body = [item for item in content if start - tol <= item["y"] < body_floor and item["w"] > 0 and item["h"] > 0
|
|
423
|
+
and not (treatment == "side-rail" and item["x"] + item["w"] <= column_five + tol) and not any(_inside(item, field) for field in fields)]
|
|
424
|
+
if len(body) < 2:
|
|
425
|
+
return None
|
|
426
|
+
left, right = min(item["x"] for item in body), max(item["x"] + item["w"] for item in body)
|
|
427
|
+
for cut in sorted({item["x"] + item["w"] for item in body}):
|
|
428
|
+
a = [item for item in body if item["x"] + item["w"] <= cut + tol]
|
|
429
|
+
b = [item for item in body if item["x"] >= cut - tol]
|
|
430
|
+
# A block across most of the body (a note under both columns) belongs to neither; a narrower block across
|
|
431
|
+
# the cut means no column line runs there.
|
|
432
|
+
across = [item for item in body if item not in a and item not in b]
|
|
433
|
+
if not a or not b or any(item["w"] < T["outputWideBlockShare"] * (right - left) for item in across):
|
|
434
|
+
continue
|
|
435
|
+
top = min(item["y"] for item in a + b)
|
|
436
|
+
for column, other in ((a, b), (b, a)):
|
|
437
|
+
# Takeaways beside a chart that close with the chart's values: that table is part of the column, at its drawn extent.
|
|
438
|
+
values = any(item["kind"] == "chart" for item in other) and any(item["kind"] == "text" for item in column) \
|
|
439
|
+
and not any(item["kind"] in ("chart", "picture") for item in column)
|
|
440
|
+
# A text column, or a picture drawn smaller than its column, beside a column that runs on.
|
|
441
|
+
if not any(item["kind"] in ("text", "picture") for item in column) or (any(item["kind"] in ("chart", "table") for item in column) and not values):
|
|
442
|
+
continue
|
|
443
|
+
# Row heads (a matrix's or a comparison's short labels, each level with its row) are no column to fill.
|
|
444
|
+
if all(item["kind"] == "text" and item["h"] <= 56 and len(re.sub(r"\s+", "", item["text"])) <= 16 for item in column):
|
|
445
|
+
continue
|
|
446
|
+
# Read whatever the neighbour's length: one that stops short still marks how far this column should run.
|
|
447
|
+
reach = min(body_floor, max(item["y"] + item["h"] for item in other))
|
|
448
|
+
# A takeaway column beside the figures of a kpi-row or big-number slide runs to the body floor: the rows stop
|
|
449
|
+
# where their spacing ends. The figures' own labels stand exactly level with figures below the first row.
|
|
450
|
+
size = lambda side: max([max(item["sizes"]) for item in side if item["kind"] == "text" and item["sizes"]] or [0.0]) # noqa: E731
|
|
451
|
+
figures = [item for item in other if item["kind"] == "text" and item["sizes"] and max(item["sizes"]) > size(column) + 4]
|
|
452
|
+
labels = any(abs(item["y"] - f["y"]) <= 0.5 and f["y"] > top + tol for item in column for f in figures)
|
|
453
|
+
if entry["family"] in ("kpi-row", "big-number") and figures and not labels:
|
|
454
|
+
reach = body_floor
|
|
455
|
+
share = _largest_gap(top, reach, [(item["y"], item["y"] + item["h"]) for item in column]) / max(1.0, body_floor - top)
|
|
456
|
+
if share > T["outputRegionEmptyShare"]:
|
|
457
|
+
what = "picture" if any(item["kind"] == "picture" for item in column) else "text"
|
|
458
|
+
return _finding("OF-115", "HIGH", entry["n"], column[0]["name"], f"the {what} column beside a longer one leaves {share:.0%} of the body empty inside it",
|
|
459
|
+
"Fill the column by layout: lead size, the source and values in the column, a narrower column with a wider visual, or the points under the visual.")
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def empty_regions(read) -> list:
|
|
464
|
+
width, height = read["width"], read["height"]
|
|
465
|
+
floor, tol, share_max = T["outputBandFloorPt"], T["outputGeometryTolPt"], T["outputRegionEmptyShare"]
|
|
466
|
+
column_five = RAIL.get(round(width), RAIL[960])[1]
|
|
467
|
+
found = []
|
|
468
|
+
for entry in read["slides"]:
|
|
469
|
+
title, treatment = entry["title"], entry["treatment"]
|
|
470
|
+
if title is None:
|
|
471
|
+
continue
|
|
472
|
+
full_bleed = lambda item: item["kind"] == "picture" and item["w"] * item["h"] >= 0.8 * width * height
|
|
473
|
+
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
|
+
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
|
+
# The takeaway column beside a chart, table or picture that runs on below it. Text on a colour
|
|
488
|
+
# field (a closing's ask) stands on its own ground and is no column.
|
|
489
|
+
body_floor = title["y"] - tol if treatment == "bottom-anchor" else floor
|
|
490
|
+
fields = [item for item in entry["items"] if item["kind"] == "shape" and item["filled"] and item["w"] * item["h"] >= 0.1 * width * height]
|
|
491
|
+
short = _short_column(entry, content, title, treatment, body_floor, column_five, width, height)
|
|
492
|
+
if short:
|
|
493
|
+
found.append(short)
|
|
494
|
+
for visual in ([] if short else [item for item in content if item["kind"] in ("chart", "table", "picture")]):
|
|
495
|
+
beside = [item for item in content if item["kind"] == "text" and item["y"] < visual["y"] + visual["h"] and item["y"] + item["h"] > visual["y"]
|
|
496
|
+
and (item["x"] >= visual["x"] + visual["w"] - tol or item["x"] + item["w"] <= visual["x"] + tol)
|
|
497
|
+
and not any(_inside(item, field) for field in fields)]
|
|
498
|
+
if not beside:
|
|
499
|
+
continue
|
|
500
|
+
left, right = min(item["x"] for item in beside), max(item["x"] + item["w"] for item in beside)
|
|
501
|
+
centre = lambda item: item["x"] + item["w"] / 2
|
|
502
|
+
top = min([visual["y"]] + [item["y"] for item in beside])
|
|
503
|
+
column = max(item["y"] + item["h"] for item in content if left - tol <= centre(item) <= right + tol)
|
|
504
|
+
reach = min(body_floor, max(item["y"] + item["h"] for item in content if visual["x"] - tol <= centre(item) <= visual["x"] + visual["w"] + tol))
|
|
505
|
+
share = (reach - column) / max(1.0, body_floor - top)
|
|
506
|
+
if share > share_max:
|
|
507
|
+
found.append(_finding("OF-115", "HIGH", entry["n"], visual["name"], f"the column beside the {visual['kind']} leaves {share:.0%} of the body empty under its last block",
|
|
508
|
+
"Close the column with the source and note, the chart's values or the takeaways; never stretch the visual alone."))
|
|
509
|
+
break
|
|
510
|
+
if entry["role"] in ("cover", "section") or treatment in ("statement", "overlay"):
|
|
511
|
+
# A title panel over a picture, and a plate under a statement or quote. A typographic cover's plate
|
|
512
|
+
# is the cover's own device, not a region its text must fill.
|
|
513
|
+
pictured = any(full_bleed(item) for item in entry["items"])
|
|
514
|
+
panels = [item for item in entry["items"] if item["kind"] == "shape" and item["filled"] and _inside(title, item) and item["w"] * item["h"] < 0.9 * width * height]
|
|
515
|
+
for panel in panels:
|
|
516
|
+
inside = [item for item in content + [title] if item is not panel and _inside(item, panel)]
|
|
517
|
+
if panel["y"] > tol and pictured:
|
|
518
|
+
bottom = min(panel["y"] + panel["h"], floor)
|
|
519
|
+
share = _largest_gap(panel["y"], bottom, [(item["y"], item["y"] + item["h"]) for item in inside]) / max(1.0, bottom - panel["y"])
|
|
520
|
+
if share > share_max:
|
|
521
|
+
found.append(_finding("OF-115", "HIGH", entry["n"], panel["name"], f"{share:.0%} of the panel that carries the title is empty",
|
|
522
|
+
"Make the panel as tall as the title and its lines, standing on the page foot."))
|
|
523
|
+
if panel["y"] > tol and treatment == "statement" and panel["w"] >= 0.9 * width and not any(item["y"] + item["h"] <= panel["y"] + tol for item in content):
|
|
524
|
+
found.append(_finding("OF-115", "HIGH", entry["n"], panel["name"], "a plate under part of the page leaves the open page above it empty",
|
|
525
|
+
"Set the sentence on the whole-page field or on the open page; a plate needs content above it."))
|
|
526
|
+
return found
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
# OF-116. A deck's skeleton is each content slide's title zone and column partition, read as OF-111 reads
|
|
530
|
+
# them. Two tonalities of one source with the same skeleton differ in colour only.
|
|
531
|
+
def skeleton(deck) -> list:
|
|
532
|
+
return [entry["signature"][:2] for entry in read_slides(deck)["slides"] if entry["role"] == "content"]
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def compare_skeletons(path, sibling) -> list:
|
|
536
|
+
mine, theirs = skeleton(Presentation(str(path))), skeleton(Presentation(str(sibling)))
|
|
537
|
+
count = min(len(mine), len(theirs))
|
|
538
|
+
if not count:
|
|
539
|
+
return []
|
|
540
|
+
differ = sum(1 for a, b in zip(mine, theirs) if a != b)
|
|
541
|
+
# One differing slide left a longer deck reading as one deck in two colours.
|
|
542
|
+
need = T["outputSiblingMinDifferLong"] if count >= T["outputSiblingLongDeck"] else T["outputSiblingMinDiffer"]
|
|
543
|
+
if differ >= need:
|
|
544
|
+
return []
|
|
545
|
+
return [_finding("OF-116", "HIGH", None, None, f"{differ} of {count} content slides differ in title zone or partition from {Path(sibling).name} (needs {need})",
|
|
546
|
+
"Give the tonality its own structure: its role defaults, another title zone or partition on the slides it shares with the other.")]
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
# OF-118. A point that opens with a bold label and runs on in regular weight reads as one sentence when nothing
|
|
550
|
+
# parts the two ("요약 누리집에서 …"): the label carries a colon unless it ends in a separator of its own.
|
|
551
|
+
RUN_IN_SEPARATOR = re.compile(r"[::.)\]?!–—\-]$")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def check_run_ins(deck) -> list:
|
|
555
|
+
found = []
|
|
556
|
+
for number, slide in enumerate(deck.slides, start=1):
|
|
557
|
+
for shape in slide.shapes:
|
|
558
|
+
if not getattr(shape, "has_text_frame", False) or shape.name.startswith(("title@", "lit-notice")):
|
|
559
|
+
continue
|
|
560
|
+
for paragraph in shape.text_frame.paragraphs:
|
|
561
|
+
runs = [run for run in paragraph.runs if run.text]
|
|
562
|
+
if len(runs) < 2 or not runs[0].font.bold or runs[1].font.bold:
|
|
563
|
+
continue
|
|
564
|
+
label, rest = runs[0].text.strip(), runs[1].text
|
|
565
|
+
if not label or not re.search(r"\w", rest) or RUN_IN_SEPARATOR.search(label) or rest.lstrip()[:1] in "::":
|
|
566
|
+
continue
|
|
567
|
+
found.append(_finding("OF-118", "HIGH", number, shape.name, f"a bold run-in label without a separator: {(runs[0].text + rest)[:40]}",
|
|
568
|
+
"Write the label with its colon (**요약:** …) or let the compiler add it; a bold label never runs into its sentence."))
|
|
569
|
+
return found
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
# OF-119. An agenda numbers its rows; a numeral beside its title repeats their count and reads as a stray section number.
|
|
573
|
+
def agenda_numerals(read) -> list:
|
|
574
|
+
return [_finding("OF-119", "HIGH", entry["n"], entry["title"]["name"], "a numeral beside the agenda title repeats the count of its numbered rows",
|
|
575
|
+
"Give the agenda a title without a numeral; its rows carry the numbers.")
|
|
576
|
+
for entry in read["slides"] if entry["family"] == "agenda" and entry["treatment"] == "kicker-numeral"]
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
# OF-117. A chart or diagram exported on white and placed on a dark ground reads as a white card with small
|
|
580
|
+
# grey labels. Such a figure is a picture of mostly near-white pixels; a photograph rarely is.
|
|
581
|
+
def check_figures(deck) -> list:
|
|
582
|
+
width, height = deck.slide_width / PT, deck.slide_height / PT
|
|
583
|
+
found = []
|
|
584
|
+
for number, slide in enumerate(deck.slides, start=1):
|
|
585
|
+
try:
|
|
586
|
+
ground = slide.background.fill.fore_color.rgb if slide.background.fill.type == 1 else None
|
|
587
|
+
except (AttributeError, TypeError, ValueError):
|
|
588
|
+
ground = None
|
|
589
|
+
if ground is None:
|
|
590
|
+
continue
|
|
591
|
+
r, g, b = (int(str(ground)[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
|
592
|
+
if 0.2126 * r + 0.7152 * g + 0.0722 * b > T["figureGroundLuminanceMax"]:
|
|
593
|
+
continue
|
|
594
|
+
import io
|
|
595
|
+
from PIL import Image
|
|
596
|
+
for shape in slide.shapes:
|
|
597
|
+
if shape.shape_type != MSO_SHAPE_TYPE.PICTURE or shape.width is None:
|
|
598
|
+
continue
|
|
599
|
+
area = shape.width / PT * shape.height / PT
|
|
600
|
+
if not T["figureMinArea"] * width * height <= area < 0.8 * width * height:
|
|
601
|
+
continue
|
|
602
|
+
with Image.open(io.BytesIO(shape.image.blob)) as image:
|
|
603
|
+
raw = image.convert("RGB").resize((64, 64)).tobytes()
|
|
604
|
+
share = sum(1 for i in range(0, len(raw), 3) if min(raw[i:i + 3]) >= T["figureNearWhite"]) / (len(raw) / 3)
|
|
605
|
+
if share > T["figureLightShare"]:
|
|
606
|
+
found.append(_finding("OF-117", "HIGH", number, shape.name, f"a light figure ({share:.0%} near-white) on a dark ground",
|
|
607
|
+
"Put the figure's dark variant beside it (name.dark.png) or draw it as a native chart."))
|
|
608
|
+
return found
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# A Korean title is read on its last word: a sentence ending or a nominalised claim makes it a
|
|
612
|
+
# sentence. An English title is read for a final period or a finite verb. A trailing parenthesis is
|
|
613
|
+
# set aside first, and a question is never a claim.
|
|
614
|
+
KO_ENDING = re.compile(r"(?:니다|[어아해세에예네지군래게]요|죠|[었았였했겠됐](?:다|음)|[가-힣]다|[가-힣](?:함|됨)|(?:있|없)음)$")
|
|
615
|
+
KO_NOUNS = {"바다", "판다", "소다", "람다", "캐나다", "어젠다", "아젠다", "포함", "보다", "함", "다"}
|
|
616
|
+
EN_AUXILIARY = re.compile(r"\b(?:is|are|was|were|has|have|had|will|would|can|could|should|must|does|did|won't|isn't|aren't|wasn't|doesn't|didn't)\b", re.I)
|
|
617
|
+
EN_REPORT_VERBS = {
|
|
618
|
+
"grew", "grows", "rose", "rises", "fell", "falls", "doubled", "doubles", "tripled", "triples", "halved", "halves",
|
|
619
|
+
"increased", "increases", "decreased", "decreases", "declined", "declines", "improved", "improves", "dropped", "drops",
|
|
620
|
+
"climbed", "climbs", "beats", "outperforms", "outperformed", "leads", "drives", "drove", "shows", "showed", "needs",
|
|
621
|
+
"makes", "reached", "reaches", "exceeded", "exceeds", "missed", "misses", "stays", "stayed", "remains", "remained",
|
|
622
|
+
"comes", "came", "takes", "took", "works", "worked", "pays", "paid", "saves", "saved", "explains", "explained",
|
|
623
|
+
"matters", "wins", "won", "lost", "loses", "cuts", "lifts", "lifted", "slowed", "slows", "fails", "failed",
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def is_sentence(text: str) -> bool:
|
|
628
|
+
"""True when a title or subtitle states a claim instead of naming a topic."""
|
|
629
|
+
value = re.sub(r"\s+", " ", str(text or "")).strip()
|
|
630
|
+
value = re.sub(r"\s*\([^()]*\)$", "", value).strip().rstrip("\"'”’")
|
|
631
|
+
if not value or value.endswith("?") or value[0] in "“\"「『‘'":
|
|
632
|
+
return False
|
|
633
|
+
if re.search(r"[가-힣]", value):
|
|
634
|
+
last = re.sub(r"[.!…]+$", "", value.split(" ")[-1])
|
|
635
|
+
return last not in KO_NOUNS and bool(KO_ENDING.search(last))
|
|
636
|
+
if value.endswith((".", "!")):
|
|
637
|
+
return True
|
|
638
|
+
words = re.findall(r"[A-Za-z']+", value)
|
|
639
|
+
return bool(EN_AUXILIARY.search(value)) or any(word.lower() in EN_REPORT_VERBS for word in words[1:])
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def check_labels(deck) -> list:
|
|
643
|
+
"""OF-114 over every compiler-drawn title and the cover subtitle."""
|
|
644
|
+
named = [(number, shape) for number, slide in enumerate(deck.slides, start=1) for shape in slide.shapes
|
|
645
|
+
if shape.name.startswith(("title@", "subtitle@")) and getattr(shape, "has_text_frame", False)]
|
|
646
|
+
legacy = any(shape.name == "title@legacy" for _, shape in named)
|
|
647
|
+
found = []
|
|
648
|
+
for number, shape in named:
|
|
649
|
+
text = _text(shape)
|
|
650
|
+
if is_sentence(text):
|
|
651
|
+
what = "subtitle" if shape.name.startswith("subtitle@") else "title"
|
|
652
|
+
found.append(_finding("OF-114", "MEDIUM" if legacy else "HIGH", number, shape.name, f"the {what} is a sentence: {text[:60]}",
|
|
653
|
+
"Name the topic (분기별 매출 추이; Conversion by feed rate) and move the claim into the first takeaway or caption."))
|
|
654
|
+
return found
|