@litfamily/litgrok 1.0.13 → 1.0.15
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/.grok/hooks/auto-handoff-gate.mjs +15 -2
- package/.grok/rules/00-litgrok.md +1 -1
- package/.grok/skills/frontend-ui-ux/THIRD-PARTY-NOTICE.txt +0 -5
- package/.grok/skills/frontend-ui-ux/references/slop-register.md +4 -4
- package/.grok/skills/frontend-ui-ux/scripts/import-design-intelligence.mjs +1 -1
- package/.grok/skills/lit-docx/SKILL.md +59 -11
- package/.grok/skills/lit-docx/examples/01-report-ko.md +137 -0
- package/.grok/skills/lit-docx/examples/02-brief-ko.md +82 -0
- package/.grok/skills/lit-docx/examples/03-manual-ko.md +118 -0
- package/.grok/skills/lit-docx/examples/04-proposal-ko.md +92 -0
- package/.grok/skills/lit-docx/examples/05-memo-en.md +42 -0
- package/.grok/skills/lit-docx/examples/06-journal-en.md +90 -0
- package/.grok/skills/lit-docx/examples/07-report-en.md +103 -0
- package/.grok/skills/lit-docx/examples/08-proposal-en.md +90 -0
- package/.grok/skills/lit-docx/examples/09-memo-ko.md +41 -0
- package/.grok/skills/lit-docx/references/components.md +151 -0
- package/.grok/skills/lit-docx/references/direction-step.md +129 -0
- package/.grok/skills/lit-docx/references/page-composition.md +129 -0
- package/.grok/skills/lit-docx/references/tonalities/brief.md +98 -0
- package/.grok/skills/lit-docx/references/tonalities/journal.md +100 -0
- package/.grok/skills/lit-docx/references/tonalities/manual.md +107 -0
- package/.grok/skills/lit-docx/references/tonalities/memo.md +95 -0
- package/.grok/skills/lit-docx/references/tonalities/proposal.md +105 -0
- package/.grok/skills/lit-docx/references/tonalities/report.md +114 -0
- package/.grok/skills/lit-docx/scripts/convert_md_to_docx.py +394 -51
- package/.grok/skills/lit-docx/scripts/docx_design.py +2446 -0
- package/.grok/skills/lit-docx/scripts/docx_gate.py +203 -0
- package/.grok/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/.grok/skills/lit-docx/scripts/run.mjs +3 -2
- package/.grok/skills/lit-docx/scripts/slop_lint.py +63 -17
- package/.grok/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
- package/.grok/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
- package/.grok/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
- package/.grok/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
- package/.grok/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
- package/.grok/skills/lit-docx/templates/tonalities/report.yaml +27 -0
- package/.grok/skills/lit-pptx/NOTICE.md +1 -1
- package/.grok/skills/lit-pptx/SKILL.md +70 -14
- package/.grok/skills/lit-pptx/agents/analyst.md +1 -1
- package/.grok/skills/lit-pptx/agents/thesis.md +2 -2
- package/.grok/skills/lit-pptx/examples/01-pitch-signal-ko.md +175 -0
- package/.grok/skills/lit-pptx/examples/02-business-review-ledger-ko.md +199 -0
- package/.grok/skills/lit-pptx/examples/03-research-talk-paper-en.md +177 -0
- package/.grok/skills/lit-pptx/examples/04-lecture-chalk-ko.md +176 -0
- package/.grok/skills/lit-pptx/examples/05-data-review-night-en.md +221 -0
- package/.grok/skills/lit-pptx/examples/06-image-led-atlas-en.md +143 -0
- package/.grok/skills/lit-pptx/examples/07-briefing-gazette-ko.md +192 -0
- package/.grok/skills/lit-pptx/examples/08-status-update-ledger-en.md +168 -0
- package/.grok/skills/lit-pptx/examples/09-pitch-studio-en.md +157 -0
- package/.grok/skills/lit-pptx/examples/10-image-led-studio-ko.md +175 -0
- package/.grok/skills/lit-pptx/examples/11-business-review-night-en.md +181 -0
- package/.grok/skills/lit-pptx/examples/12-status-update-gazette-ko.md +199 -0
- package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
- package/.grok/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
- package/.grok/skills/lit-pptx/references/density-and-fill.md +155 -0
- package/.grok/skills/lit-pptx/references/direction-step.md +211 -0
- package/.grok/skills/lit-pptx/references/layout-families.md +193 -0
- package/.grok/skills/lit-pptx/references/title-treatments.md +134 -0
- package/.grok/skills/lit-pptx/references/tonalities/atlas.md +124 -0
- package/.grok/skills/lit-pptx/references/tonalities/chalk.md +135 -0
- package/.grok/skills/lit-pptx/references/tonalities/gazette.md +127 -0
- package/.grok/skills/lit-pptx/references/tonalities/ledger.md +134 -0
- package/.grok/skills/lit-pptx/references/tonalities/night.md +132 -0
- package/.grok/skills/lit-pptx/references/tonalities/paper.md +127 -0
- package/.grok/skills/lit-pptx/references/tonalities/signal.md +118 -0
- package/.grok/skills/lit-pptx/references/tonalities/studio.md +123 -0
- package/.grok/skills/lit-pptx/scripts/compile-deck.js +65 -17
- package/.grok/skills/lit-pptx/scripts/craft_extras.py +82 -18
- package/.grok/skills/lit-pptx/scripts/deck_output.py +598 -0
- package/.grok/skills/lit-pptx/scripts/embed_fonts.py +1 -1
- package/.grok/skills/lit-pptx/scripts/inventory.py +6 -2
- package/.grok/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
- package/.grok/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/.grok/skills/lit-pptx/scripts/lib/layout-resolver.js +401 -16
- package/.grok/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/.grok/skills/lit-pptx/scripts/lib/render-adapter-html.js +2 -2
- package/.grok/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -126
- package/.grok/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
- package/.grok/skills/lit-pptx/scripts/lib/slide-ast.js +54 -6
- package/.grok/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
- package/.grok/skills/lit-pptx/scripts/qa_deck.py +43 -10
- package/.grok/skills/lit-pptx/scripts/run.mjs +3 -2
- package/.grok/skills/lit-pptx/scripts/validate_pptx.py +17 -5
- package/.grok/skills/lit-pptx/templates/boilerplate_professional.css +6 -6
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +4 -1
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -1
- package/.grok/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +4 -1
- package/.grok/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
- package/.grok/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
- package/.grok/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +99 -0
- package/.grok/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +100 -0
- package/.grok/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
- package/.grok/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
- package/.grok/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
- package/.grok/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
- package/.grok/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
- package/.grok/skills/lit-typographic-motion/fonts/pins.json +6 -6
- package/.grok/skills/lit-typographic-motion/references/runtime.md +1 -1
- package/.grok/skills/lit-typographic-motion/references/style-bible.md +2 -2
- package/.grok/skills/lit-typographic-motion/references/type-craft.md +2 -2
- package/.grok/skills/lit-typographic-motion/scripts/lib/gate.mjs +1 -1
- package/.grok/skills/litgrok/SKILL.md +1 -1
- package/.grok-plugin/plugin.json +1 -1
- package/CHANGELOG.md +11 -0
- package/README.md +9 -9
- package/README_ko-KR.md +9 -9
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference.md +1 -1
- package/docs/reference_ko-KR.md +1 -1
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Bold.otf +0 -0
- package/.grok/skills/lit-pptx/pretendard-font/public/static/PretendardGOV-Regular.otf +0 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deck-wide output checks, read from the compiled file.
|
|
3
|
+
|
|
4
|
+
OF-110 a deck of 8+ slides shows at least three title treatments, each drawn as it is named
|
|
5
|
+
OF-111 content slides do not repeat one composition (title zone x body partition x dominant content)
|
|
6
|
+
OF-112 the median empty band under the body stays at or under 0.20
|
|
7
|
+
OF-113 every slide under one title treatment keeps that treatment's frame
|
|
8
|
+
OF-114 titles and the cover subtitle are topic labels, never sentences
|
|
9
|
+
OF-115 no region stands empty beside a full one: the floor under a bottom title, the rail under a
|
|
10
|
+
side title, the column beside a visual, a title panel over a picture, the page above a plate
|
|
11
|
+
OF-116 (qa_deck.py --sibling) another tonality of the same source draws another skeleton
|
|
12
|
+
OF-117 no mostly-white figure picture on a dark slide ground
|
|
13
|
+
OF-118 a bold run-in label is followed by its separator ("요약: …", "Summary: …")
|
|
14
|
+
OF-119 an agenda's title carries no count numeral beside it (its rows carry the numbers)
|
|
15
|
+
|
|
16
|
+
The deck engine names its title frame `title@<treatment>` and the first shape of a slide
|
|
17
|
+
`family@<layout family>`. A name is only compared with what the geometry shows; it is never the
|
|
18
|
+
evidence on its own. A deck without those names was not drawn by the engine and is out of scope.
|
|
19
|
+
A legacy template (`title@legacy`) asked for one look on purpose, so its variety and band findings
|
|
20
|
+
are MEDIUM. Geometry is in points on the 960 x 540 or 720 x 540 canvas.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import io
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
import statistics
|
|
29
|
+
import sys
|
|
30
|
+
|
|
31
|
+
from pptx import Presentation
|
|
32
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
33
|
+
|
|
34
|
+
from craft_extras import text_em
|
|
35
|
+
|
|
36
|
+
PT = 12700
|
|
37
|
+
TOL, SIZE_TOL = 2.0, 0.5
|
|
38
|
+
FLOOR, FOOTER = 486.0, 492.0
|
|
39
|
+
LIMIT = {"slides": 8, "treatments": 3, "share": 0.40, "cap": 5, "band": 0.20, "anchor": 1.0}
|
|
40
|
+
# Per-slide band caps by density: (highest density in the step, cap).
|
|
41
|
+
BAND_CAPS = ((2, .28), (4, .24), (6, .20), (8, .16), (10, .12))
|
|
42
|
+
ZONES = {"top-rule": "top", "top-plain-large": "top", "kicker-numeral": "top", "side-rail": "side",
|
|
43
|
+
"band": "band", "overlay": "overlay", "bottom-anchor": "bottom", "statement": "none"}
|
|
44
|
+
# The widest four-column title and the earliest column-5 start over all density grids.
|
|
45
|
+
RAIL = {960: (288.0, 336.0), 720: (216.0, 252.0)}
|
|
46
|
+
MARGINS = {960: {"airy": 60, "standard": 48, "dense": 36, "compact": 24}, 720: {"airy": 54, "standard": 42, "dense": 30, "compact": 24}}
|
|
47
|
+
STAMP = re.compile(r"tonality=(\S+) density=(\d+) grid=(\w+) variance=(\d+)")
|
|
48
|
+
NUMERAL = re.compile(r"\d+(?:[.:/-]\d+)?\.?")
|
|
49
|
+
BAND_EXEMPT = re.compile(r"(?:cover|section|statement|quote|closing-statement)")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _shape(shape):
|
|
53
|
+
if shape.left is None or shape.width is None:
|
|
54
|
+
return None
|
|
55
|
+
text = shape.text_frame.text.strip() if getattr(shape, "has_text_frame", False) and shape.has_text_frame else ""
|
|
56
|
+
if shape.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
57
|
+
kind = "picture"
|
|
58
|
+
elif getattr(shape, "has_table", False) and shape.has_table:
|
|
59
|
+
kind = "table"
|
|
60
|
+
elif getattr(shape, "has_chart", False) and shape.has_chart:
|
|
61
|
+
kind = "chart"
|
|
62
|
+
else:
|
|
63
|
+
kind = "text" if text else "shape"
|
|
64
|
+
x, y, w, h = (v / PT for v in (shape.left, shape.top, shape.width, shape.height))
|
|
65
|
+
if kind == "table":
|
|
66
|
+
h = max(h, sum(row.height for row in shape.table.rows) / PT)
|
|
67
|
+
sizes = [r.font.size.pt for p in shape.text_frame.paragraphs for r in p.runs
|
|
68
|
+
if r.text.strip() and r.font.size is not None] if text else []
|
|
69
|
+
try:
|
|
70
|
+
filled = kind == "shape" and shape.fill.type == 1
|
|
71
|
+
except (AttributeError, TypeError):
|
|
72
|
+
filled = False
|
|
73
|
+
return {"name": shape.name, "kind": kind, "x": x, "y": y, "w": w, "h": h, "text": text, "sizes": sizes, "filled": filled}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _inside(e, box, tol=1.0):
|
|
77
|
+
cx, cy = e["x"] + e["w"] / 2, e["y"] + e["h"] / 2
|
|
78
|
+
return box["x"] - tol <= cx <= box["x"] + box["w"] + tol and box["y"] - tol <= cy <= box["y"] + box["h"] + tol
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def elements(slide, W, H):
|
|
82
|
+
"""Drawn shapes in points, each with a role: content, card (a fill that holds text) or decoration."""
|
|
83
|
+
out = [e for e in map(_shape, slide.shapes) if e and e["w"] + e["h"] > 0 and not e["name"].startswith("lit-notice")]
|
|
84
|
+
for e in out:
|
|
85
|
+
if e["kind"] != "shape":
|
|
86
|
+
e["role"] = "content"
|
|
87
|
+
continue
|
|
88
|
+
bleeds = e["x"] <= 1 or e["y"] <= 1 or e["x"] + e["w"] >= W - 1 or e["y"] + e["h"] >= H - 1
|
|
89
|
+
holds = any(o["kind"] != "shape" and _inside(o, e) for o in out if o is not e)
|
|
90
|
+
card = e["filled"] and holds and not bleeds and e["w"] >= 100 and e["h"] >= 40 and e["w"] * e["h"] < .5 * W * H
|
|
91
|
+
e["role"] = "card" if card else "decoration"
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def treatment(title, els, W, H):
|
|
96
|
+
"""The title treatment the geometry shows; the tests run in a fixed order and the first match wins."""
|
|
97
|
+
size = max(title["sizes"] or [0])
|
|
98
|
+
bottom = title["y"] + title["h"]
|
|
99
|
+
others = [e for e in els if e is not title]
|
|
100
|
+
body = [e for e in others if e["y"] < FOOTER and e["role"] != "decoration"]
|
|
101
|
+
|
|
102
|
+
def under_title(e):
|
|
103
|
+
return (e["x"] - TOL <= title["x"] and title["x"] + title["w"] <= e["x"] + e["w"] + TOL
|
|
104
|
+
and e["y"] - TOL <= title["y"] and bottom <= e["y"] + e["h"] + TOL)
|
|
105
|
+
|
|
106
|
+
if (any(e["kind"] == "picture" and e["w"] * e["h"] >= .8 * W * H for e in others) and title["y"] >= 300 - TOL
|
|
107
|
+
and any(e["kind"] == "shape" and e["filled"] and under_title(e) for e in others)):
|
|
108
|
+
return "overlay"
|
|
109
|
+
if any(e["kind"] == "shape" and e["filled"] and e["y"] <= 2 + TOL and 96 - TOL <= e["h"] <= 120 + TOL
|
|
110
|
+
and e["w"] >= .9 * W and _inside(title, e, TOL) for e in others):
|
|
111
|
+
return "band"
|
|
112
|
+
if (size >= 36 - SIZE_TOL and 220 - TOL <= title["y"] + title["h"] / 2 <= 380 + TOL
|
|
113
|
+
and not any(e["kind"] in ("table", "chart", "picture") or e["role"] == "card" for e in body)
|
|
114
|
+
and sum(e["kind"] == "text" for e in body) <= 1):
|
|
115
|
+
return "statement"
|
|
116
|
+
if title["y"] >= 360 - TOL and all(e["y"] <= title["y"] + TOL for e in body if e["kind"] == "text"):
|
|
117
|
+
return "bottom-anchor"
|
|
118
|
+
rail, column5 = RAIL.get(round(W), RAIL[960])
|
|
119
|
+
beside = [e for e in body if e["x"] + e["w"] / 2 > title["x"] + title["w"]]
|
|
120
|
+
# What stands in the rail under the title (criteria labels, takeaways, the source) is the rail's; only a
|
|
121
|
+
# block that runs from the rail on into the body makes the title something else.
|
|
122
|
+
if (title["w"] <= rail + TOL and beside and min(e["x"] for e in beside) >= column5 - TOL
|
|
123
|
+
and not any(e["x"] < column5 - TOL and e["y"] > bottom and e["x"] + e["w"] > column5 + TOL for e in body)):
|
|
124
|
+
return "side-rail"
|
|
125
|
+
if any(e["kind"] == "text" and e["sizes"] and NUMERAL.fullmatch(e["text"]) and max(e["sizes"]) >= 44 - SIZE_TOL
|
|
126
|
+
and e["x"] + e["w"] <= title["x"] + TOL and e["y"] >= 30 - TOL and e["y"] + e["h"] <= 114 + TOL for e in others):
|
|
127
|
+
return "kicker-numeral"
|
|
128
|
+
if title["y"] <= 48 + TOL:
|
|
129
|
+
# A rule is a line under the title, never a list-marker dot.
|
|
130
|
+
rule = any(e["kind"] == "shape" and e["h"] <= 8 and e["w"] >= max(16, 4 * e["h"]) and bottom - TOL <= e["y"] <= bottom + 24
|
|
131
|
+
and e["x"] < title["x"] + title["w"] and e["x"] + e["w"] > title["x"] for e in others)
|
|
132
|
+
if size >= 36 - SIZE_TOL and not rule:
|
|
133
|
+
return "top-plain-large"
|
|
134
|
+
if size <= 32 + SIZE_TOL and rule:
|
|
135
|
+
return "top-rule"
|
|
136
|
+
return "unclassified"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _figure(e):
|
|
140
|
+
"""A run of 44 pt or more that is mostly digits reads as a number, not as text."""
|
|
141
|
+
compact = re.sub(r"\s+", "", e["text"])
|
|
142
|
+
return (e["kind"] == "text" and bool(e["sizes"]) and max(e["sizes"]) >= 44 - SIZE_TOL
|
|
143
|
+
and len(re.findall(r"\d", compact)) >= .5 * max(1, len(compact)))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def signature(title, kind, els, W, H, margin):
|
|
147
|
+
"""(title zone, body partition, dominant content) of one slide."""
|
|
148
|
+
zone = ZONES.get(kind, "none")
|
|
149
|
+
if kind == "statement":
|
|
150
|
+
return (zone, "one", "text")
|
|
151
|
+
column5 = RAIL.get(round(W), RAIL[960])[1]
|
|
152
|
+
body = []
|
|
153
|
+
for e in els:
|
|
154
|
+
if e is title or e["y"] >= FOOTER or e["role"] == "decoration":
|
|
155
|
+
continue
|
|
156
|
+
if kind == "side-rail" and e["x"] + e["w"] <= column5 + TOL:
|
|
157
|
+
continue # the rail under a side title is part of the title zone, not a body column
|
|
158
|
+
if e["kind"] == "shape" and title is not None and _inside(title, e, 0):
|
|
159
|
+
continue # the band, rail or panel that carries the title
|
|
160
|
+
if kind == "kicker-numeral" and e["kind"] == "text" and NUMERAL.fullmatch(e["text"]) and e["y"] + e["h"] <= 116:
|
|
161
|
+
continue
|
|
162
|
+
body.append(e)
|
|
163
|
+
cards = [e for e in body if e["role"] == "card"]
|
|
164
|
+
blocks = [e for e in body if not any(c is not e and _inside(e, c) for c in cards)]
|
|
165
|
+
if any(e["kind"] == "picture" and e["w"] * e["h"] >= .8 * W * H for e in els):
|
|
166
|
+
partition = "full-bleed"
|
|
167
|
+
elif not blocks:
|
|
168
|
+
partition = "one"
|
|
169
|
+
else:
|
|
170
|
+
# Columns join boxes whose x-extents overlap by half the narrower one; a box wider than 70 % of
|
|
171
|
+
# the body (a takeaway under two columns) never joins columns.
|
|
172
|
+
narrow = [e for e in blocks if e["w"] < .7 * (W - 2 * margin)] or blocks
|
|
173
|
+
columns = []
|
|
174
|
+
for e in sorted(narrow, key=lambda e: e["x"]):
|
|
175
|
+
hits = [c for c in columns if min(c[1], e["x"] + e["w"]) - max(c[0], e["x"]) >= .5 * min(c[1] - c[0], e["w"])]
|
|
176
|
+
merged = [min([e["x"]] + [c[0] for c in hits]), max([e["x"] + e["w"]] + [c[1] for c in hits]),
|
|
177
|
+
[e] + [i for c in hits for i in c[2]]]
|
|
178
|
+
columns = [c for c in columns if c not in hits] + [merged]
|
|
179
|
+
column = {id(i): n for n, c in enumerate(columns) for i in c[2]}
|
|
180
|
+
rows, grid_rows = [], 0
|
|
181
|
+
for e in sorted(narrow, key=lambda e: e["y"]):
|
|
182
|
+
if rows and e["y"] < rows[-1][0] - 2:
|
|
183
|
+
rows[-1][0] = max(rows[-1][0], e["y"] + e["h"])
|
|
184
|
+
rows[-1][1].append(e)
|
|
185
|
+
else:
|
|
186
|
+
rows.append([e["y"] + e["h"], [e]])
|
|
187
|
+
grid_rows = sum(len({column[id(i)] for i in r[1]}) >= 2 for r in rows)
|
|
188
|
+
k = len(columns)
|
|
189
|
+
if k >= 4 or (k >= 2 and grid_rows >= 2):
|
|
190
|
+
partition = "grid"
|
|
191
|
+
elif k == 3:
|
|
192
|
+
partition = "three"
|
|
193
|
+
elif k == 2:
|
|
194
|
+
a, b = (c[1] - c[0] for c in columns)
|
|
195
|
+
partition = "two-even" if min(a, b) / max(a, b) >= .85 else "two-asym"
|
|
196
|
+
else:
|
|
197
|
+
partition = "one"
|
|
198
|
+
area = {}
|
|
199
|
+
for e in blocks:
|
|
200
|
+
key = {"picture": "image", "table": "table", "chart": "chart"}.get(e["kind"])
|
|
201
|
+
if key is None and e["kind"] == "text":
|
|
202
|
+
key = "number" if _figure(e) else "text"
|
|
203
|
+
if key:
|
|
204
|
+
area[key] = area.get(key, 0) + e["w"] * e["h"]
|
|
205
|
+
for c in cards:
|
|
206
|
+
key = "number" if any(_figure(e) for e in body if e is not c and _inside(e, c)) else "text"
|
|
207
|
+
area[key] = area.get(key, 0) + c["w"] * c["h"]
|
|
208
|
+
return (zone, partition, max(area, key=area.get) if area else "text")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def band(title, els):
|
|
212
|
+
"""Share of the body left empty under its lowest block, from the title (or the top margin when
|
|
213
|
+
the title sits low) down to the body floor. A title set at the foot is ink there too."""
|
|
214
|
+
top = title["y"] + title["h"] if title and title["y"] + title["h"] < FLOOR * .6 else 36.0
|
|
215
|
+
if FLOOR - top < 10:
|
|
216
|
+
return None
|
|
217
|
+
lowest = max((min(FLOOR, e["y"] + e["h"]) for e in els if e["role"] != "decoration" and e["y"] < FOOTER
|
|
218
|
+
and e["y"] < FLOOR and e["y"] + e["h"] > top), default=top)
|
|
219
|
+
return max(0.0, (FLOOR - lowest) / (FLOOR - top))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _role(family):
|
|
223
|
+
return next((r for r in ("cover", "section", "closing") if family == r or family.startswith(r + "-")), "content")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _most_treatments(variance):
|
|
227
|
+
return 2 if variance <= 3 else 3 if variance == 4 else 4 if variance <= 6 else 5
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Titles are labels. Korean is read on its last 어절: a sentence ending or a nominalised claim makes
|
|
231
|
+
# the title a sentence. English is read for closing punctuation, an auxiliary or a finite report
|
|
232
|
+
# verb. A closing parenthesis is set aside first; a question or a quotation is not a claim.
|
|
233
|
+
KO_ENDING = re.compile(r"(?:니다|[아어여해게네지세래]요|죠|[았었였했됐겠]다|[가-힣]다|[가-힣][함됨]|[있없]음|[았었였했됐]음)$")
|
|
234
|
+
KO_NOUNS = {"바다", "판다", "소다", "람다", "캐나다", "어젠다", "아젠다", "보다", "다", "포함", "함"}
|
|
235
|
+
EN_AUX = re.compile(r"\b(?:is|are|was|were|has|have|had|will|would|can|could|should|must|does|did|isn't|aren't|wasn't|won't|doesn't|didn't)\b", re.I)
|
|
236
|
+
# Finite verbs that rarely stand as nouns in a label: past forms, and a few third-person forms whose
|
|
237
|
+
# plural-noun reading is rare. Forms such as "cuts", "leads", "works" or "won" read as nouns too often.
|
|
238
|
+
EN_FINITE = {
|
|
239
|
+
"rose", "grew", "fell", "climbed", "dropped", "doubled", "tripled", "halved", "increased", "decreased",
|
|
240
|
+
"declined", "improved", "reached", "exceeded", "missed", "outperformed", "drove", "showed", "proved",
|
|
241
|
+
"stayed", "remained", "needed", "made", "took", "saved", "lifted", "slowed", "failed", "worked", "explained",
|
|
242
|
+
"outperforms", "exceeds", "beats", "proves", "explains", "improves", "grows", "slows", "fails", "loses", "pays",
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def sentence(text):
|
|
247
|
+
t = re.sub(r"\s+", " ", str(text or "")).strip()
|
|
248
|
+
t = re.sub(r"\s*\([^()]*\)$", "", t).strip().rstrip("\"'”’")
|
|
249
|
+
if not t or t.endswith("?") or t[0] in "“\"「『‘'":
|
|
250
|
+
return False
|
|
251
|
+
if re.search(r"[가-힣]", t):
|
|
252
|
+
last = re.sub(r"[.!…]+$", "", t.split(" ")[-1])
|
|
253
|
+
return last not in KO_NOUNS and bool(KO_ENDING.search(last))
|
|
254
|
+
if t[-1] in ".!":
|
|
255
|
+
return True
|
|
256
|
+
words = re.findall(r"[A-Za-z][A-Za-z'-]*", t) # a hyphenated compound ("cut-over") is one word
|
|
257
|
+
return bool(EN_AUX.search(t)) or any(w.lower() in EN_FINITE for w in words[1:])
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# OF-115. The band (OF-112) reads the body as one page, so a region can still stand empty beside a full one.
|
|
261
|
+
# Each reading is a share of its own region, or under a bottom title a distance from the floor.
|
|
262
|
+
REGION = {"floor_gap": 12.0, "share": 0.40}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _finding(rule, slide, value, threshold, note):
|
|
266
|
+
return {"rule": rule, "severity": "HIGH", "slide": slide, "tier": "measured", "value": value, "threshold": threshold, "note": note}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _title_lines(title):
|
|
270
|
+
"""Lines a title is set in: its explicit breaks, each wrapped again in the frame at the bundled face's widths."""
|
|
271
|
+
size = max(title["sizes"] or [0]) or 1
|
|
272
|
+
segments = [seg for seg in re.split(r"[\n\v]", title["text"]) if seg.strip()]
|
|
273
|
+
return sum(max(1, math.ceil(text_em(seg) * size / max(1.0, title["w"]))) for seg in segments) or 1
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _largest_gap(top, bottom, spans):
|
|
277
|
+
"""The tallest stretch of [top, bottom] that no (y0, y1) span covers."""
|
|
278
|
+
gap, cursor = 0.0, top
|
|
279
|
+
for y0, y1 in sorted(spans):
|
|
280
|
+
if y1 <= cursor:
|
|
281
|
+
continue
|
|
282
|
+
gap = max(gap, max(0.0, min(y0, bottom) - cursor))
|
|
283
|
+
cursor = max(cursor, y1)
|
|
284
|
+
return max(gap, bottom - cursor)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _short_column(content, title, kind, body_floor, column5, fields, family=None):
|
|
288
|
+
"""A column of a two-column body that stops short of its neighbour: the largest empty band inside it, from
|
|
289
|
+
the body's top down to where the neighbour ends, is over 40 % of the body. A source or note strip that closes
|
|
290
|
+
the column does not hide the band above it. Beside the figures of a kpi-row or big-number slide the column is
|
|
291
|
+
measured to the body floor (spec-v2 Amendments 9)."""
|
|
292
|
+
# The body starts under a top title; beside a side title (in its rail) or over a bottom title it starts at the top.
|
|
293
|
+
top_of_title = title["y"] + title["h"] if kind not in ("bottom-anchor", "side-rail") else 0.0
|
|
294
|
+
body = [e for e in content if e["y"] >= top_of_title - TOL and e["y"] < body_floor and e["w"] > 0 and e["h"] > 0
|
|
295
|
+
and not (kind == "side-rail" and e["x"] + e["w"] <= column5 + TOL) and not any(_inside(e, f) for f in fields)]
|
|
296
|
+
if len(body) < 2:
|
|
297
|
+
return None
|
|
298
|
+
left_edge, right_edge = min(e["x"] for e in body), max(e["x"] + e["w"] for e in body)
|
|
299
|
+
for cut in sorted({e["x"] + e["w"] for e in body}):
|
|
300
|
+
a = [e for e in body if e["x"] + e["w"] <= cut + TOL]
|
|
301
|
+
b = [e for e in body if e["x"] >= cut - TOL]
|
|
302
|
+
# A block across most of the body (a note under both columns) belongs to neither column; a narrower
|
|
303
|
+
# block across the cut means there is no column line here.
|
|
304
|
+
across = [e for e in body if e not in a and e not in b]
|
|
305
|
+
if not a or not b or any(e["w"] < .7 * (right_edge - left_edge) for e in across):
|
|
306
|
+
continue
|
|
307
|
+
top = min(e["y"] for e in a + b)
|
|
308
|
+
for column, other in ((a, b), (b, a)):
|
|
309
|
+
# Takeaways beside a chart that close with the chart's values: the table counts at its drawn extent.
|
|
310
|
+
values = any(e["kind"] == "chart" for e in other) and any(e["kind"] == "text" for e in column) \
|
|
311
|
+
and not any(e["kind"] in ("chart", "picture") for e in column)
|
|
312
|
+
# A text column, or a picture drawn smaller than its column, beside a column that runs on.
|
|
313
|
+
if not any(e["kind"] in ("text", "picture") for e in column) or (any(e["kind"] in ("chart", "table") for e in column) and not values):
|
|
314
|
+
continue
|
|
315
|
+
# Row heads (a matrix's or a comparison's short labels, each level with its row) are no column to fill.
|
|
316
|
+
if all(e["kind"] == "text" and e["h"] <= 56 and len(re.sub(r"\s+", "", e["text"])) <= 16 for e in column):
|
|
317
|
+
continue
|
|
318
|
+
# Read whatever the neighbour's length: one that stops short still marks how far this column should run.
|
|
319
|
+
reach = min(body_floor, max(e["y"] + e["h"] for e in other))
|
|
320
|
+
# A takeaway column beside the figures of a kpi-row or big-number slide runs to the body floor: the rows stop
|
|
321
|
+
# where their spacing ends. The figures' own labels stand exactly level with figures below the first row.
|
|
322
|
+
size = lambda side: max([max(e["sizes"]) for e in side if e["kind"] == "text" and e["sizes"]] or [0.0]) # noqa: E731
|
|
323
|
+
figures = [e for e in other if e["kind"] == "text" and e["sizes"] and max(e["sizes"]) > size(column) + 4]
|
|
324
|
+
labels = any(abs(e["y"] - f["y"]) <= .5 and f["y"] > top + TOL for e in column for f in figures)
|
|
325
|
+
if family in ("kpi-row", "big-number") and figures and not labels:
|
|
326
|
+
reach = body_floor
|
|
327
|
+
share = _largest_gap(top, reach, [(e["y"], e["y"] + e["h"]) for e in column]) / max(1.0, body_floor - top)
|
|
328
|
+
if share > REGION["share"]:
|
|
329
|
+
what = "picture" if any(e["kind"] == "picture" for e in column) else "text"
|
|
330
|
+
return _finding("OF-115", None, round(share, 3), REGION["share"],
|
|
331
|
+
f"the {what} column beside a longer one leaves its share of the body empty inside it: fill it by layout "
|
|
332
|
+
"(lead size, the source and values in the column, a narrower column with a wider visual, or the points under the visual)")
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def empty_regions(slides, W, H):
|
|
337
|
+
found = []
|
|
338
|
+
column5 = RAIL.get(round(W), RAIL[960])[1]
|
|
339
|
+
full = lambda e: e["kind"] == "picture" and e["w"] * e["h"] >= .8 * W * H # noqa: E731
|
|
340
|
+
for s in slides:
|
|
341
|
+
title, kind = s["title"], s["kind"]
|
|
342
|
+
if title is None or not title["sizes"]:
|
|
343
|
+
continue
|
|
344
|
+
content = [e for e in s["els"] if e is not title and e["role"] != "decoration" and e["y"] < FOOTER and not full(e)]
|
|
345
|
+
if s["role"] in ("content", "closing"):
|
|
346
|
+
if kind == "bottom-anchor":
|
|
347
|
+
size = max(title["sizes"])
|
|
348
|
+
gap = FLOOR - (title["y"] + _title_lines(title) * size * 1.15)
|
|
349
|
+
if gap > REGION["floor_gap"]:
|
|
350
|
+
found.append(_finding("OF-115", s["n"], round(gap, 1), REGION["floor_gap"],
|
|
351
|
+
"the bottom title ends above the body floor: stand its last line on the floor"))
|
|
352
|
+
if kind == "side-rail" and s["family"] not in ("quote", "statement"):
|
|
353
|
+
top = title["y"] + title["h"] # the frame keeps a spare line for a title that wraps once more
|
|
354
|
+
rail = [e for e in content if e["x"] + e["w"] <= column5 + TOL and e["y"] + e["h"] > top]
|
|
355
|
+
share = _largest_gap(top, FLOOR, [(e["y"], e["y"] + e["h"]) for e in rail]) / max(1.0, FLOOR - top)
|
|
356
|
+
if share > REGION["share"]:
|
|
357
|
+
found.append(_finding("OF-115", s["n"], round(share, 3), REGION["share"],
|
|
358
|
+
"the side rail under the title stands empty: give it the criteria, takeaways or source, or take another title"))
|
|
359
|
+
# A takeaway column beside a chart, table or picture that runs on below it. Text on a colour field
|
|
360
|
+
# (a closing's ask) stands on its own ground, not in a column.
|
|
361
|
+
body_floor = title["y"] - TOL if kind == "bottom-anchor" else FLOOR
|
|
362
|
+
fields = [e for e in s["els"] if e["kind"] == "shape" and e["filled"] and e["w"] * e["h"] >= .1 * W * H]
|
|
363
|
+
short = _short_column(content, title, kind, body_floor, column5, fields, s["family"])
|
|
364
|
+
if short:
|
|
365
|
+
found.append({**short, "slide": s["n"]})
|
|
366
|
+
for v in ([] if short else [e for e in content if e["kind"] in ("chart", "table", "picture")]):
|
|
367
|
+
beside = [e for e in content if e["kind"] == "text" and e["y"] < v["y"] + v["h"] and e["y"] + e["h"] > v["y"]
|
|
368
|
+
and (e["x"] >= v["x"] + v["w"] - TOL or e["x"] + e["w"] <= v["x"] + TOL)
|
|
369
|
+
and not any(_inside(e, f) for f in fields)]
|
|
370
|
+
if not beside:
|
|
371
|
+
continue
|
|
372
|
+
left, right = min(e["x"] for e in beside), max(e["x"] + e["w"] for e in beside)
|
|
373
|
+
top = min([v["y"]] + [e["y"] for e in beside])
|
|
374
|
+
column = max(e["y"] + e["h"] for e in content if left - TOL <= e["x"] + e["w"] / 2 <= right + TOL)
|
|
375
|
+
visual = min(body_floor, max(e["y"] + e["h"] for e in content if v["x"] - TOL <= e["x"] + e["w"] / 2 <= v["x"] + v["w"] + TOL))
|
|
376
|
+
share = (visual - column) / max(1.0, body_floor - top)
|
|
377
|
+
if share > REGION["share"]:
|
|
378
|
+
found.append(_finding("OF-115", s["n"], round(share, 3), REGION["share"],
|
|
379
|
+
f"the column beside the {v['kind']} stops short of it: close it with the source and note, the values or the takeaways"))
|
|
380
|
+
break
|
|
381
|
+
if s["role"] in ("cover", "section") or kind in ("statement", "overlay"):
|
|
382
|
+
# A title panel over a picture, and a plate under a statement or quote. A typographic cover's plate
|
|
383
|
+
# is the cover's own device, not a region its text must fill.
|
|
384
|
+
pictured = any(full(e) for e in s["els"])
|
|
385
|
+
for p in [e for e in s["els"] if e["kind"] == "shape" and e["filled"] and _inside(title, e) and e["w"] * e["h"] < .9 * W * H]:
|
|
386
|
+
inside = [e for e in content + [title] if e is not p and _inside(e, p)]
|
|
387
|
+
if p["y"] > TOL and pictured:
|
|
388
|
+
floor = min(p["y"] + p["h"], FLOOR)
|
|
389
|
+
share = _largest_gap(p["y"], floor, [(e["y"], e["y"] + e["h"]) for e in inside]) / max(1.0, floor - p["y"])
|
|
390
|
+
if share > REGION["share"]:
|
|
391
|
+
found.append(_finding("OF-115", s["n"], round(share, 3), REGION["share"],
|
|
392
|
+
"the panel that carries the title stands partly empty: make it as tall as its text, on the page foot"))
|
|
393
|
+
if p["y"] > TOL and kind == "statement" and p["w"] >= .9 * W and not any(e["y"] + e["h"] <= p["y"] + TOL for e in content):
|
|
394
|
+
found.append(_finding("OF-115", s["n"], round(p["y"], 1), 0,
|
|
395
|
+
"a plate under part of the page leaves the open page above it empty: use the whole-page field"))
|
|
396
|
+
return found
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# OF-116. A skeleton is each content slide's title zone and partition, as OF-111 reads them; two tonalities of
|
|
400
|
+
# one source whose skeletons match differ in colour only.
|
|
401
|
+
def skeleton(prs):
|
|
402
|
+
return [s["sig"][:2] for s in _read(prs) if s["role"] == "content"]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def compare_skeletons(prs, sibling, name="the sibling deck"):
|
|
406
|
+
a, b = skeleton(prs), skeleton(sibling)
|
|
407
|
+
n = min(len(a), len(b))
|
|
408
|
+
differ = sum(x != y for x, y in zip(a, b))
|
|
409
|
+
# Two slides once the deck has four content slides: one differing slide left the alternatives reading as
|
|
410
|
+
# one deck in two colours.
|
|
411
|
+
need = 2 if n >= 4 else 1
|
|
412
|
+
if not n or differ >= need:
|
|
413
|
+
return []
|
|
414
|
+
return [_finding("OF-116", None, differ, need, f"{differ} of {n} content slides differ in title zone or partition from {name} "
|
|
415
|
+
f"(needs {need}): give the tonality its own structure on more slides")]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# OF-117. A chart or diagram exported on white stands on a dark ground as a white card with small grey labels.
|
|
419
|
+
# A figure is a picture of mostly near-white pixels; a photograph rarely is.
|
|
420
|
+
FIGURE = {"ground_max": .25, "near_white": 235, "share": .5, "min_area": .03}
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def light_figures(prs, W, H):
|
|
424
|
+
found = []
|
|
425
|
+
try:
|
|
426
|
+
from PIL import Image
|
|
427
|
+
except ImportError:
|
|
428
|
+
return found
|
|
429
|
+
for number, slide in enumerate(prs.slides, 1):
|
|
430
|
+
try:
|
|
431
|
+
rgb = slide.background.fill.fore_color.rgb if slide.background.fill.type == 1 else None
|
|
432
|
+
except (AttributeError, TypeError, ValueError):
|
|
433
|
+
rgb = None
|
|
434
|
+
if rgb is None:
|
|
435
|
+
continue
|
|
436
|
+
r, g, b = (int(str(rgb)[i:i + 2], 16) / 255 for i in (0, 2, 4))
|
|
437
|
+
if .2126 * r + .7152 * g + .0722 * b > FIGURE["ground_max"]:
|
|
438
|
+
continue
|
|
439
|
+
for shape in slide.shapes:
|
|
440
|
+
if shape.shape_type != MSO_SHAPE_TYPE.PICTURE or shape.width is None:
|
|
441
|
+
continue
|
|
442
|
+
area = shape.width / PT * shape.height / PT
|
|
443
|
+
if not FIGURE["min_area"] * W * H <= area < .8 * W * H:
|
|
444
|
+
continue
|
|
445
|
+
with Image.open(io.BytesIO(shape.image.blob)) as im:
|
|
446
|
+
raw = im.convert("RGB").resize((64, 64)).tobytes()
|
|
447
|
+
share = sum(min(raw[i:i + 3]) >= FIGURE["near_white"] for i in range(0, len(raw), 3)) / (len(raw) / 3)
|
|
448
|
+
if share > FIGURE["share"]:
|
|
449
|
+
found.append(_finding("OF-117", number, round(share, 3), FIGURE["share"],
|
|
450
|
+
f"{shape.name}: a light figure on a dark ground; put its dark variant beside it (name.dark.png) or draw a native chart"))
|
|
451
|
+
return found
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# OF-118. A point that opens with a bold label and runs on in regular weight reads as one sentence when nothing
|
|
455
|
+
# separates the two ("요약 앱에서 …"): the label takes a colon unless it ends in its own separator.
|
|
456
|
+
RUN_IN_SEPARATOR = re.compile(r"[::.)\]?!–—\-]$")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def run_ins(prs):
|
|
460
|
+
found = []
|
|
461
|
+
for number, slide in enumerate(prs.slides, 1):
|
|
462
|
+
for shape in slide.shapes:
|
|
463
|
+
if not getattr(shape, "has_text_frame", False) or not shape.has_text_frame or shape.name.startswith(("title@", "lit-notice")):
|
|
464
|
+
continue
|
|
465
|
+
for paragraph in shape.text_frame.paragraphs:
|
|
466
|
+
runs = [r for r in paragraph.runs if r.text]
|
|
467
|
+
if len(runs) < 2 or not runs[0].font.bold or runs[1].font.bold:
|
|
468
|
+
continue
|
|
469
|
+
label, rest = runs[0].text, runs[1].text
|
|
470
|
+
if not label.strip() or not re.search(r"\w", rest) or RUN_IN_SEPARATOR.search(label.strip()) or rest.lstrip()[:1] in "::":
|
|
471
|
+
continue
|
|
472
|
+
found.append(_finding("OF-118", number, (label + rest)[:40], "a separator",
|
|
473
|
+
f"{shape.name}: a bold run-in label without a separator; write the label with its colon (**요약:** …) or let the engine set it"))
|
|
474
|
+
return found
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# OF-119. An agenda numbers its rows; a numeral beside its title repeats their count and reads as a stray section number.
|
|
478
|
+
def agenda_numerals(slides):
|
|
479
|
+
return [_finding("OF-119", s["n"], s["kind"], "no numeral", "a numeral beside the agenda title repeats the count of its rows: take a title without one")
|
|
480
|
+
for s in slides if s["family"] == "agenda" and s["kind"] == "kicker-numeral"]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _read(prs):
|
|
484
|
+
"""Every slide's elements, family, role, written and drawn treatment and (content slides) signature."""
|
|
485
|
+
W, H = prs.slide_width / PT, prs.slide_height / PT
|
|
486
|
+
stamp = STAMP.search(prs.core_properties.subject or "")
|
|
487
|
+
margin = MARGINS[960 if round(W) == 960 else 720].get(stamp.group(3) if stamp else "standard", 48)
|
|
488
|
+
slides = []
|
|
489
|
+
for number, slide in enumerate(prs.slides, 1):
|
|
490
|
+
els = elements(slide, W, H)
|
|
491
|
+
family = next((s.name.split("@", 1)[1] for s in slide.shapes if s.name.startswith("family@")), "")
|
|
492
|
+
title = next((e for e in els if e["name"].startswith("title@")), None)
|
|
493
|
+
s = {"n": number, "els": els, "family": family, "role": _role(family), "title": title,
|
|
494
|
+
"named": title["name"].split("@", 1)[1] if title else None}
|
|
495
|
+
s["kind"] = treatment(title, els, W, H) if title and title["sizes"] else None
|
|
496
|
+
if s["role"] == "content":
|
|
497
|
+
s["sig"] = signature(title, s["kind"], els, W, H, margin)
|
|
498
|
+
slides.append(s)
|
|
499
|
+
return slides
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def assess(prs):
|
|
503
|
+
findings = []
|
|
504
|
+
|
|
505
|
+
def add(rule, severity, slide, value, threshold, note=""):
|
|
506
|
+
findings.append({"rule": rule, "severity": severity, "slide": slide, "tier": "measured",
|
|
507
|
+
"value": value, "threshold": threshold, "note": note})
|
|
508
|
+
|
|
509
|
+
W, H = prs.slide_width / PT, prs.slide_height / PT
|
|
510
|
+
stamp = STAMP.search(prs.core_properties.subject or "")
|
|
511
|
+
density, variance = (int(stamp.group(2)), int(stamp.group(4))) if stamp else (5, 5)
|
|
512
|
+
slides = _read(prs)
|
|
513
|
+
labels = [(n, s) for n, slide in enumerate(prs.slides, 1) for s in slide.shapes
|
|
514
|
+
if s.name.startswith(("title@", "subtitle@")) and getattr(s, "has_text_frame", False) and s.has_text_frame]
|
|
515
|
+
if not any(s["named"] for s in slides):
|
|
516
|
+
return {"pass": True, "findings": [], "scope": "not drawn by the deck engine"}
|
|
517
|
+
legacy = any(s["named"] == "legacy" for s in slides)
|
|
518
|
+
variety = "MEDIUM" if legacy else "HIGH"
|
|
519
|
+
|
|
520
|
+
for s in slides:
|
|
521
|
+
if s["named"] in ZONES and s["kind"] not in (None, s["named"]):
|
|
522
|
+
add("OF-110", "HIGH", s["n"], s["kind"], s["named"], "the title is drawn as one treatment and named as another")
|
|
523
|
+
|
|
524
|
+
# OF-110: cover and section titles are variants of their own and do not count.
|
|
525
|
+
titled = [s for s in slides if s["role"] not in ("cover", "section") and s["kind"]]
|
|
526
|
+
used = sorted({s["kind"] for s in titled} - {"unclassified"})
|
|
527
|
+
if len(slides) >= LIMIT["slides"]:
|
|
528
|
+
if len(used) < LIMIT["treatments"]:
|
|
529
|
+
add("OF-110", "MEDIUM" if variance <= 3 else variety, None, len(used), LIMIT["treatments"],
|
|
530
|
+
"variance below 4 lowers variety on the user's request only" if variance <= 3 else ", ".join(used) or "none")
|
|
531
|
+
elif stamp and len(used) > _most_treatments(variance):
|
|
532
|
+
add("OF-110", variety, None, len(used), _most_treatments(variance), f"variance {variance}")
|
|
533
|
+
lost = [s["n"] for s in titled if s["kind"] == "unclassified"]
|
|
534
|
+
if lost:
|
|
535
|
+
add("OF-110", variety, lost[0], len(lost), 0, "no treatment recognised on slide(s) " + ", ".join(map(str, lost)))
|
|
536
|
+
|
|
537
|
+
# OF-111: the whole signature and its layout part (title zone x partition) both have to vary.
|
|
538
|
+
content = [s for s in slides if s["role"] == "content"]
|
|
539
|
+
if content:
|
|
540
|
+
need = min(LIMIT["cap"], -(-len(content) * 3 // 5))
|
|
541
|
+
for part, key in (("signature", lambda s: s["sig"]), ("layout", lambda s: s["sig"][:2])):
|
|
542
|
+
counts = {}
|
|
543
|
+
for s in content:
|
|
544
|
+
counts[key(s)] = counts.get(key(s), 0) + 1
|
|
545
|
+
top = max(counts.values())
|
|
546
|
+
if len(counts) < need or top > LIMIT["share"] * len(content):
|
|
547
|
+
add("OF-111", variety, None, {"distinct": len(counts), "largest_share": round(top / len(content), 2)},
|
|
548
|
+
{"distinct": need, "largest_share": LIMIT["share"]}, part)
|
|
549
|
+
|
|
550
|
+
# OF-112: the band under the body, median over slides that have a body.
|
|
551
|
+
cap = next(c for upto, c in BAND_CAPS if density <= upto)
|
|
552
|
+
bands = []
|
|
553
|
+
for s in slides:
|
|
554
|
+
if s["role"] in ("cover", "section") or BAND_EXEMPT.fullmatch(s["family"] or "") or s["kind"] == "statement":
|
|
555
|
+
continue
|
|
556
|
+
value = band(s["title"], s["els"])
|
|
557
|
+
if value is None:
|
|
558
|
+
continue
|
|
559
|
+
bands.append(value)
|
|
560
|
+
if value > cap:
|
|
561
|
+
add("OF-112", "MEDIUM", s["n"], round(value, 3), cap, "slide band over the density cap")
|
|
562
|
+
if bands and statistics.median(bands) > LIMIT["band"]:
|
|
563
|
+
add("OF-112", "MEDIUM" if legacy else "HIGH", None, round(statistics.median(bands), 3), LIMIT["band"], "deck median")
|
|
564
|
+
|
|
565
|
+
# OF-113: one frame per treatment. A statement keeps its x and width (its height follows the lines); a
|
|
566
|
+
# bottom title keeps its x, width and bottom edge, standing on the floor whatever its line count.
|
|
567
|
+
frames = {}
|
|
568
|
+
for s in slides:
|
|
569
|
+
if s["kind"] and s["kind"] != "unclassified" and s["named"] not in ("cover", "section"):
|
|
570
|
+
frames.setdefault(s["kind"], []).append(s)
|
|
571
|
+
for kind, group in frames.items():
|
|
572
|
+
first = group[0]["title"]
|
|
573
|
+
keys = ("x", "w") if kind == "statement" else ("x", "w", "bottom") if kind == "bottom-anchor" else ("x", "y", "w")
|
|
574
|
+
edge = lambda t, k: t["y"] + t["h"] if k == "bottom" else t[k] # noqa: E731
|
|
575
|
+
for s in group[1:]:
|
|
576
|
+
drift = max(abs(edge(s["title"], k) - edge(first, k)) for k in keys)
|
|
577
|
+
if drift > LIMIT["anchor"]:
|
|
578
|
+
add("OF-113", "HIGH", s["n"], round(drift, 1), LIMIT["anchor"], f"{kind} frame moved against slide {group[0]['n']}")
|
|
579
|
+
|
|
580
|
+
findings.extend(empty_regions(slides, W, H))
|
|
581
|
+
findings.extend(light_figures(prs, W, H))
|
|
582
|
+
findings.extend(run_ins(prs))
|
|
583
|
+
findings.extend(agenda_numerals(slides))
|
|
584
|
+
|
|
585
|
+
# OF-114: every engine-drawn title and the cover subtitle.
|
|
586
|
+
for number, shape in labels:
|
|
587
|
+
text = re.sub(r"\s+", " ", shape.text_frame.text).strip()
|
|
588
|
+
if sentence(text):
|
|
589
|
+
add("OF-114", "MEDIUM" if legacy else "HIGH", number, text[:60], "a topic label", shape.name)
|
|
590
|
+
return {"pass": not any(f["severity"] == "HIGH" for f in findings), "findings": findings}
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
if __name__ == "__main__":
|
|
594
|
+
if len(sys.argv) != 2:
|
|
595
|
+
raise SystemExit("usage: deck_output.py deck.pptx")
|
|
596
|
+
result = assess(Presentation(sys.argv[1]))
|
|
597
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
598
|
+
raise SystemExit(0 if result["pass"] else 1)
|
|
@@ -33,7 +33,7 @@ A2Z_WEIGHTS = {
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def _pretendard(weight):
|
|
36
|
-
p = os.path.join(ROOT, "pretendard-font", "public", "static", f"
|
|
36
|
+
p = os.path.join(ROOT, "pretendard-font", "public", "static", f"Pretendard-{weight}.otf")
|
|
37
37
|
return p if os.path.exists(p) else None
|
|
38
38
|
|
|
39
39
|
|
|
@@ -8,6 +8,8 @@ from pathlib import Path
|
|
|
8
8
|
from pptx import Presentation
|
|
9
9
|
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
10
10
|
|
|
11
|
+
from craft_extras import text_em
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
def inspect(path):
|
|
13
15
|
deck = Presentation(path)
|
|
@@ -41,8 +43,10 @@ def inspect(path):
|
|
|
41
43
|
for paragraph in shape.text_frame.paragraphs:
|
|
42
44
|
font_sizes = [int(run.font.size) for run in paragraph.runs if run.font.size]
|
|
43
45
|
points = max(font_sizes, default=int(paragraph.font.size or 18 * 12700))
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
# Each explicit break starts a line; each line wraps by the face's advance widths.
|
|
47
|
+
for line in paragraph.text.split('\v') or ['']:
|
|
48
|
+
lines = max(1, math.ceil(text_em(line) * points / available_width))
|
|
49
|
+
estimated_height += lines * points * 1.15
|
|
46
50
|
if estimated_height > available_height * 1.25:
|
|
47
51
|
overflow['frame'] = True
|
|
48
52
|
if overflow:
|
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
* directive-parser.js — Extract ::: fenced blocks and detect forbidden constructs.
|
|
5
5
|
*
|
|
6
6
|
* Responsibilities:
|
|
7
|
-
* - Find ::: fenced blocks (main-box, kpi-table, notes, image, key-message)
|
|
7
|
+
* - Find ::: fenced blocks (main-box, kpi-table, chart, notes, image, key-message)
|
|
8
8
|
* - Reject nested ::: blocks
|
|
9
9
|
* - Reject arbitrary HTML/CSS (div, span, style, inline style attributes)
|
|
10
10
|
* - Extract tables from | pipe | syntax
|
|
11
11
|
* - Extract images from  syntax
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const APPROVED_DIRECTIVES = new Set(["main-box", "kpi-table", "notes", "image", "key-message"]);
|
|
14
|
+
const APPROVED_DIRECTIVES = new Set(["main-box", "kpi-table", "chart", "notes", "image", "key-message"]);
|
|
15
15
|
|
|
16
16
|
// A void directive is a single line: it draws something and has no body, so it
|
|
17
17
|
// takes no closing fence. Shapes are the only such directive.
|