@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.
Files changed (119) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README-Ko-KR.md +11 -11
  3. package/README.md +11 -11
  4. package/dist/activation-routing.js +1 -1
  5. package/dist/deliverable-hedge-guard.js +3 -1
  6. package/dist/features.js +2 -2
  7. package/dist/hooks.js +2 -1
  8. package/dist/skills.js +6 -4
  9. package/docs/assets/readme/badge-version.svg +1 -1
  10. package/docs/reference-Ko-KR.md +1 -1
  11. package/package.json +1 -1
  12. package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
  13. package/skills/lit-docx/SKILL.md +80 -19
  14. package/skills/lit-docx/references/components.md +253 -0
  15. package/skills/lit-docx/references/direction-step.md +234 -0
  16. package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
  17. package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
  18. package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
  19. package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
  20. package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
  21. package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
  22. package/skills/lit-docx/references/examples/07-report-en.md +137 -0
  23. package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
  24. package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
  25. package/skills/lit-docx/references/page-composition.md +306 -0
  26. package/skills/lit-docx/references/tonalities/brief.md +104 -0
  27. package/skills/lit-docx/references/tonalities/journal.md +101 -0
  28. package/skills/lit-docx/references/tonalities/manual.md +111 -0
  29. package/skills/lit-docx/references/tonalities/memo.md +105 -0
  30. package/skills/lit-docx/references/tonalities/proposal.md +101 -0
  31. package/skills/lit-docx/references/tonalities/report.md +126 -0
  32. package/skills/lit-docx/run.py +2 -1
  33. package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
  34. package/skills/lit-docx/scripts/craft_docx.py +204 -1
  35. package/skills/lit-docx/scripts/docx_design.py +2445 -0
  36. package/skills/lit-docx/scripts/docx_layout.py +959 -0
  37. package/skills/lit-docx/scripts/slop_lint.py +23 -6
  38. package/skills/lit-docx/templates/tonalities/brief.yaml +28 -0
  39. package/skills/lit-docx/templates/tonalities/journal.yaml +25 -0
  40. package/skills/lit-docx/templates/tonalities/manual.yaml +26 -0
  41. package/skills/lit-docx/templates/tonalities/memo.yaml +26 -0
  42. package/skills/lit-docx/templates/tonalities/proposal.yaml +26 -0
  43. package/skills/lit-docx/templates/tonalities/report.yaml +26 -0
  44. package/skills/lit-pptx/NOTICE.md +1 -1
  45. package/skills/lit-pptx/SKILL.md +108 -19
  46. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
  47. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
  48. package/skills/lit-pptx/references/density-and-fill.md +283 -0
  49. package/skills/lit-pptx/references/direction-step.md +249 -0
  50. package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
  51. package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
  52. package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
  53. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
  54. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
  55. package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
  56. package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
  57. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
  58. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
  59. package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
  60. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
  61. package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
  62. package/skills/lit-pptx/references/layout-families.md +470 -0
  63. package/skills/lit-pptx/references/title-treatments.md +271 -0
  64. package/skills/lit-pptx/references/tonalities/atlas.md +131 -0
  65. package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
  66. package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
  67. package/skills/lit-pptx/references/tonalities/ledger.md +150 -0
  68. package/skills/lit-pptx/references/tonalities/night.md +148 -0
  69. package/skills/lit-pptx/references/tonalities/paper.md +135 -0
  70. package/skills/lit-pptx/references/tonalities/signal.md +147 -0
  71. package/skills/lit-pptx/references/tonalities/studio.md +149 -0
  72. package/skills/lit-pptx/scripts/compile-deck.js +68 -21
  73. package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
  74. package/skills/lit-pptx/scripts/craft_extras.py +37 -8
  75. package/skills/lit-pptx/scripts/deck_output.py +654 -0
  76. package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
  77. package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
  78. package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
  79. package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
  80. package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  81. package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
  82. package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  83. package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
  84. package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
  85. package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
  86. package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
  87. package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
  88. package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
  89. package/skills/lit-pptx/scripts/qa_deck.py +48 -13
  90. package/skills/lit-pptx/scripts/validate_pptx.py +25 -7
  91. package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
  92. package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
  93. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  94. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
  95. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
  96. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
  97. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
  98. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
  99. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
  100. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +99 -0
  101. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +104 -0
  102. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +100 -0
  103. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +101 -0
  104. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +100 -0
  105. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +105 -0
  106. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +101 -0
  107. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +99 -0
  108. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
  109. package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
  110. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
  111. package/skills/lit-typographic-motion/fonts/pins.json +2 -2
  112. package/skills/lit-typographic-motion/gate.mjs +1 -1
  113. package/skills/lit-typographic-motion/references/stage.md +1 -1
  114. package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
  115. package/skills/lit-typographic-motion/references/treatment.md +1 -1
  116. package/skills/lit-typographic-motion/references/type-craft.md +3 -3
  117. package/skills/managed-skill-manifest.json +288 -44
  118. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
  119. 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