@litfamily/litopencode 1.0.16 → 1.0.17

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 (117) hide show
  1. package/CHANGELOG.md +7 -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/features.js +2 -2
  6. package/dist/skills.js +6 -4
  7. package/docs/assets/readme/badge-version.svg +1 -1
  8. package/docs/reference-Ko-KR.md +1 -1
  9. package/package.json +1 -1
  10. package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
  11. package/skills/lit-docx/SKILL.md +80 -19
  12. package/skills/lit-docx/references/components.md +253 -0
  13. package/skills/lit-docx/references/direction-step.md +234 -0
  14. package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
  15. package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
  16. package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
  17. package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
  18. package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
  19. package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
  20. package/skills/lit-docx/references/examples/07-report-en.md +137 -0
  21. package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
  22. package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
  23. package/skills/lit-docx/references/page-composition.md +306 -0
  24. package/skills/lit-docx/references/tonalities/brief.md +104 -0
  25. package/skills/lit-docx/references/tonalities/journal.md +101 -0
  26. package/skills/lit-docx/references/tonalities/manual.md +111 -0
  27. package/skills/lit-docx/references/tonalities/memo.md +105 -0
  28. package/skills/lit-docx/references/tonalities/proposal.md +101 -0
  29. package/skills/lit-docx/references/tonalities/report.md +126 -0
  30. package/skills/lit-docx/run.py +2 -1
  31. package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
  32. package/skills/lit-docx/scripts/craft_docx.py +204 -1
  33. package/skills/lit-docx/scripts/docx_design.py +2445 -0
  34. package/skills/lit-docx/scripts/docx_layout.py +959 -0
  35. package/skills/lit-docx/scripts/slop_lint.py +23 -6
  36. package/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
  37. package/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
  38. package/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
  39. package/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
  40. package/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
  41. package/skills/lit-docx/templates/tonalities/report.yaml +27 -0
  42. package/skills/lit-pptx/NOTICE.md +1 -1
  43. package/skills/lit-pptx/SKILL.md +108 -19
  44. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
  45. package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
  46. package/skills/lit-pptx/references/density-and-fill.md +283 -0
  47. package/skills/lit-pptx/references/direction-step.md +249 -0
  48. package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
  49. package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
  50. package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
  51. package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
  52. package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
  53. package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
  54. package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
  55. package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
  56. package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
  57. package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
  58. package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
  59. package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
  60. package/skills/lit-pptx/references/layout-families.md +470 -0
  61. package/skills/lit-pptx/references/title-treatments.md +271 -0
  62. package/skills/lit-pptx/references/tonalities/atlas.md +132 -0
  63. package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
  64. package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
  65. package/skills/lit-pptx/references/tonalities/ledger.md +151 -0
  66. package/skills/lit-pptx/references/tonalities/night.md +148 -0
  67. package/skills/lit-pptx/references/tonalities/paper.md +136 -0
  68. package/skills/lit-pptx/references/tonalities/signal.md +148 -0
  69. package/skills/lit-pptx/references/tonalities/studio.md +149 -0
  70. package/skills/lit-pptx/scripts/compile-deck.js +68 -21
  71. package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
  72. package/skills/lit-pptx/scripts/craft_extras.py +37 -8
  73. package/skills/lit-pptx/scripts/deck_output.py +654 -0
  74. package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
  75. package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
  76. package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
  77. package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
  78. package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
  79. package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
  80. package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
  81. package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
  82. package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
  83. package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
  84. package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
  85. package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
  86. package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
  87. package/skills/lit-pptx/scripts/qa_deck.py +48 -13
  88. package/skills/lit-pptx/scripts/validate_pptx.py +17 -5
  89. package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
  90. package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
  91. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
  92. package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
  93. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
  94. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
  95. package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
  96. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
  97. package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
  98. package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +105 -0
  99. package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +110 -0
  100. package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +106 -0
  101. package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +107 -0
  102. package/skills/lit-pptx/templates/tonalities/night/pack.yaml +106 -0
  103. package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +111 -0
  104. package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +107 -0
  105. package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +105 -0
  106. package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
  107. package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
  108. package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
  109. package/skills/lit-typographic-motion/fonts/pins.json +2 -2
  110. package/skills/lit-typographic-motion/gate.mjs +1 -1
  111. package/skills/lit-typographic-motion/references/stage.md +1 -1
  112. package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
  113. package/skills/lit-typographic-motion/references/treatment.md +1 -1
  114. package/skills/lit-typographic-motion/references/type-craft.md +3 -3
  115. package/skills/managed-skill-manifest.json +288 -44
  116. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
  117. package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Regular.otf +0 -0
@@ -0,0 +1,959 @@
1
+ """docx_layout.py — the document output checks craft_docx.py runs on a converted .docx.
2
+
3
+ Two groups. The structural checks read the Word file (and the frontmatter of its Markdown source):
4
+
5
+ heading.declarative a heading, the title or the subtitle is a sentence, not a noun-phrase label
6
+ heading.order heading levels never skip downward, and the first heading is h1 or h2
7
+ notice.dash the frontmatter notice joins its label and line with a colon, not a spaced dash (FAIL on a
8
+ tonality run, advice on the plain and publisher paths)
9
+ component.count components per kind (cover, callout, sidebar, pullquote, keyfigures, columns)
10
+
11
+ The page checks need the document rendered (craft_docx.py --layout; soffice converts it to PDF and
12
+ PyMuPDF reads the text lines, drawings and pictures of each page, in points):
13
+
14
+ fill.page no body page is filled under 0.35 of its frame (the cover and the last page excepted)
15
+ memo.fit a memo never spills onto a second page that it fills under a quarter of the frame
16
+ page.spill a two-page document fills its second page to 0.4 of the frame or more, and no
17
+ document ends on a page holding a paragraph or two (under an eighth of the frame)
18
+ list.split a list of up to six items stands on one page (or in one column); in a column body a list of
19
+ five or six may break once with two items or more on each side
20
+ heading.apart a heading and its short lead sentence stand on the page where the table (up to eight body
21
+ rows) they open starts
22
+ heading.stranded no page ends on a heading
23
+ heading.column in a column body a heading never ends a column while its text opens the next one
24
+ columns.balance in a column body no column stops a quarter of the frame short beside one that runs to the
25
+ foot, and the last page sets its columns to about the same depth
26
+ heading.wrap the title, the subtitle and every heading wrap only between words
27
+ title.lines the title takes at most three lines
28
+ folio.total a "page / total" folio counts the pages the reader has
29
+ sidebar.overlap a floating sidebar ends before the next heading starts beside it
30
+ table.split a table that fits one frame (0.9 of its height) stays on one page and with its
31
+ caption, except an allowed long-table split (tonality run, header
32
+ + 6 body rows or more, 3 body rows each side, the table started above 0.75 of the
33
+ frame); a component box (key figures, callout, sidebar) always stays whole
34
+ figure.split a picture and its caption stand on the same page
35
+ component.variety a document of four or more pages uses at least two component kinds
36
+ cover.block a filled shape covers more than a quarter of the cover page (tonality runs)
37
+
38
+ The restraint checks read the Word file; they FAIL on a tonality run and are advice on
39
+ the plain and publisher paths, except date.iso, which holds on every path:
40
+
41
+ color.accent-kinds more than one non-ink hue, or the accent on more than two element kinds (figures aside)
42
+ heading.ink a heading set in a colour instead of ink
43
+ heading.ratio h1 more than 1.5 times the body size
44
+ table.fill a shaded data-table cell, or a component tint darker than L 95 %
45
+ component.budget more than three component kinds, more than one key-figure strip, any pull quote, or key
46
+ figures on the cover
47
+ date.iso an ISO date (2026-10-01) in the text of a Korean document
48
+ furniture.chip a coloured or shaded notice in a page header
49
+ tonality.structure (craft_docx.py --compare) two tonalities of one source differ in fewer than three structural
50
+ features: title block, summary form, heading numbering, component set, running head, contents
51
+
52
+ Each finding is {"check", "severity" ("FAIL" or "ADVISORY"), "page" or None, "detail", "fix"}.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import re
58
+ import statistics
59
+ import zipfile
60
+ from pathlib import Path
61
+ from xml.etree import ElementTree as ET
62
+
63
+ W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
64
+ PIC = "{http://schemas.openxmlformats.org/drawingml/2006/main}blip"
65
+ KINDS = ("cover", "callout", "sidebar", "pullquote", "keyfigures", "columns")
66
+ FILL_MIN = 0.35
67
+ MEMO_SPILL = 0.25
68
+ SPILL_SECOND = 0.4 # page 2 of a two-page document
69
+ SPILL_LAST = 0.125 # the last page of any document
70
+ SHORT_LIST = 6
71
+ TITLE_LINES = 3
72
+ TABLE_CAPTION = re.compile(r"^(?:표|Table)\d+[.:]") # on normalised text (no spaces)
73
+ FIT_SHARE = 0.9
74
+
75
+
76
+ def finding(check: str, severity: str, page, detail: str, fix: str) -> dict:
77
+ return {"check": check, "severity": severity, "page": page, "detail": detail, "fix": fix}
78
+
79
+
80
+ def norm(text: str) -> str:
81
+ """Text as both sides print it: no spaces, dashes and quotes folded (the converter sets 1-3 as 1–3)."""
82
+ text = re.sub(r"\s+", "", text or "")
83
+ return text.translate(str.maketrans({"–": "-", "—": "-", "‘": "'", "’": "'", "“": '"', "”": '"'}))
84
+
85
+
86
+ # ── heading.declarative ─────────────────────────────────────────────────────
87
+ #
88
+ # A heading names what its section covers; the finding goes in the first sentence under it. Korean is
89
+ # read on its last word: a sentence ending (다, 요, 죠, 니다) or a nominalised claim (…함, …됨, …음 after
90
+ # a verb stem) makes the line a sentence. English is read for a closing full stop or a finite verb (an
91
+ # auxiliary, or a reporting verb in the past or the third person). A closing parenthesis is set aside
92
+ # ("시범 운영 결과 (예시)"), and a question is not a claim.
93
+
94
+ KO_SENTENCE = re.compile(r"(?:니다|[어아해세에예네지군래게]요|죠|[었았였했겠됐](?:다|음)|[가-힣]다|[가-힣](?:함|됨)|(?:있|없)음)$")
95
+ KO_LABEL_WORDS = {"바다", "판다", "소다", "람다", "캐나다", "어젠다", "아젠다", "포함", "보다", "함", "다", "중심", "가능성", "다음"}
96
+ EN_AUX = 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)
97
+ EN_FINITE = {
98
+ "grew", "grows", "rose", "rises", "fell", "falls", "doubled", "doubles", "tripled", "halved", "increased", "increases",
99
+ "decreased", "decreases", "declined", "declines", "improved", "improves", "dropped", "drops", "reached", "reaches",
100
+ "exceeded", "exceeds", "missed", "misses", "stays", "stayed", "remains", "remained", "shows", "showed", "needs",
101
+ "makes", "made", "drives", "drove", "leads", "led", "beats", "cuts", "saves", "saved", "pays", "paid", "works",
102
+ "worked", "takes", "took", "wins", "won", "lost", "loses", "fails", "failed", "outperforms", "outperformed",
103
+ "explains", "explained", "matters", "lifts", "lifted", "slowed", "slows", "delivers", "delivered", "requires",
104
+ }
105
+
106
+
107
+ def declarative(text: str) -> bool:
108
+ t = re.sub(r"\s+", " ", str(text or "")).strip()
109
+ t = re.sub(r"^(?:\d+(?:\.\d+)*\.?|[A-Z]\.|[IVX]+\.|[□○■▪•-])\s+", "", t) # a number or marker the treatment adds
110
+ t = re.sub(r"\s*\([^()]*\)$", "", t).strip().rstrip("\"'”’")
111
+ if not t or t.endswith("?") or t[0] in "“\"「『‘'":
112
+ return False
113
+ if re.search(r"[가-힣]", t):
114
+ last = re.sub(r"[.!…]+$", "", t.split(" ")[-1])
115
+ return last not in KO_LABEL_WORDS and bool(KO_SENTENCE.search(last))
116
+ if t.endswith((".", "!")):
117
+ return True
118
+ words = re.findall(r"[A-Za-z']+", t)
119
+ return bool(EN_AUX.search(t)) or any(w.lower() in EN_FINITE for w in words[1:])
120
+
121
+
122
+ # ── Reading the Word file ───────────────────────────────────────────────────
123
+
124
+ class Styles:
125
+ """Paragraph style ids to heading levels and names, following basedOn."""
126
+
127
+ def __init__(self, root):
128
+ self.s = {}
129
+ for st in root.findall(W + "style") if root is not None else []:
130
+ name = st.find(W + "name")
131
+ based = st.find(W + "basedOn")
132
+ outline = st.find(f"{W}pPr/{W}outlineLvl")
133
+ self.s[st.get(W + "styleId")] = (
134
+ name.get(W + "val") if name is not None else "",
135
+ based.get(W + "val") if based is not None else None,
136
+ int(outline.get(W + "val")) if outline is not None else None,
137
+ )
138
+
139
+ def chain(self, sid):
140
+ seen = set()
141
+ while sid and sid in self.s and sid not in seen:
142
+ seen.add(sid)
143
+ yield sid, self.s[sid]
144
+ sid = self.s[sid][1]
145
+
146
+ def level(self, sid):
147
+ for key, (name, _, outline) in self.chain(sid):
148
+ m = re.match(r"(?i)^heading\s*(\d)$", name or "") or re.match(r"^Heading(\d)$", key)
149
+ if m:
150
+ return int(m.group(1))
151
+ if (name or "").lower() in ("title", "subtitle"):
152
+ return None
153
+ if outline is not None and outline < 9:
154
+ return outline + 1
155
+ return None
156
+
157
+ def names(self, sid) -> str:
158
+ return " ".join(f"{key} {name}" for key, (name, _, _) in self.chain(sid)).lower()
159
+
160
+
161
+ def _text(el) -> str:
162
+ return "".join(t.text or "" for t in el.iter(W + "t"))
163
+
164
+
165
+ def _body_blocks(parent):
166
+ for child in parent:
167
+ if child.tag == W + "sdt":
168
+ content = child.find(W + "sdtContent")
169
+ if content is not None:
170
+ yield from _body_blocks(content)
171
+ elif child.tag in (W + "p", W + "tbl"):
172
+ yield child
173
+
174
+
175
+ def _columns(blocks: list, sect) -> None:
176
+ """The blocks a section break closes take its column count ("cols")."""
177
+ cols = sect.find(W + "cols") if sect is not None else None
178
+ n = int(cols.get(W + "num", "1") or 1) if cols is not None else 1
179
+ for b in reversed(blocks):
180
+ if "cols" in b:
181
+ break
182
+ b["cols"] = n
183
+
184
+
185
+ def read_docx(path: Path) -> dict:
186
+ """Blocks in reading order ({"t": "h"|"p"|"cap"|"img"|"tbl"}), components, body frame and sections."""
187
+ with zipfile.ZipFile(path) as z:
188
+ body = ET.fromstring(z.read("word/document.xml")).find(W + "body")
189
+ styles = Styles(ET.fromstring(z.read("word/styles.xml")) if "word/styles.xml" in z.namelist() else None)
190
+ blocks, comp = [], {k: 0 for k in KINDS}
191
+ cover = memo = False
192
+ seen_heading = False
193
+ for el in _body_blocks(body):
194
+ if el.tag == W + "tbl":
195
+ tag = el.find(f"{W}tblPr/{W}tblCaption")
196
+ kind = (tag.get(W + "val") if tag is not None else "") or ""
197
+ word = kind.split(" ")[0]
198
+ memo = memo or kind.startswith("cover memo")
199
+ if word in comp:
200
+ comp[word] += 1
201
+ if word == "cover":
202
+ cover = True
203
+ rows = []
204
+ for tr in el.findall(W + "tr"):
205
+ cells = [norm(_text(tc)) for tc in tr.findall(W + "tc")]
206
+ rows.append(next((c for c in cells if c), ""))
207
+ paras = [norm(_text(p)) for p in el.iter(W + "p")]
208
+ blocks.append({"t": "tbl", "rows": rows, "component": word if word in comp else None, "cells": [t for t in paras if t]})
209
+ continue
210
+ ppr = el.find(W + "pPr")
211
+ sid_el = ppr.find(W + "pStyle") if ppr is not None else None
212
+ sid = sid_el.get(W + "val") if sid_el is not None else None
213
+ level = styles.level(sid)
214
+ names = styles.names(sid)
215
+ text = _text(el)
216
+ if "pullquote" in names:
217
+ comp["pullquote"] += 1
218
+ if el.find(f".//{PIC}") is not None:
219
+ blocks.append({"t": "img", "text": norm(text)})
220
+ elif level and text.strip():
221
+ blocks.append({"t": "h", "level": level, "text": norm(text), "raw": text.strip()})
222
+ seen_heading = True
223
+ elif text.strip():
224
+ listed = (ppr is not None and ppr.find(W + "numPr") is not None) or "list" in names
225
+ blocks.append({"t": "cap" if "caption" in names else "p", "text": norm(text), "list": listed})
226
+ if not seen_heading and ppr is not None and ppr.find(W + "sectPr") is not None and blocks:
227
+ cover = True # the first section ends before any heading: a cover page
228
+ if ppr is not None and ppr.find(W + "sectPr") is not None:
229
+ _columns(blocks, ppr.find(W + "sectPr"))
230
+ _columns(blocks, body.find(W + "sectPr"))
231
+ sections = list(body.iter(W + "sectPr"))
232
+ # Column sections, plus the side-by-side rows the converter draws for columns with a column break.
233
+ comp["columns"] += sum(1 for s in sections for c in s.findall(W + "cols") if int(c.get(W + "num", "1")) >= 2)
234
+ comp["cover"] = max(comp["cover"], int(cover))
235
+ main = body.find(W + "sectPr")
236
+ if main is None and sections:
237
+ main = sections[-1]
238
+ frame = None
239
+ if main is not None:
240
+ size, margin = main.find(W + "pgSz"), main.find(W + "pgMar")
241
+ pt = lambda el, key: abs(int(el.get(W + key, "0"))) / 20.0 # noqa: E731 (twips)
242
+ frame = {"page_h": pt(size, "h"), "top": pt(margin, "top"), "bottom": pt(margin, "bottom")}
243
+ return {"blocks": blocks, "components": comp, "cover": cover, "memo": memo, "frame": frame}
244
+
245
+
246
+ # ── Restraint checks ────────────────────────────────────────────────────────
247
+
248
+ def _rgb(hexs: str):
249
+ try:
250
+ return tuple(int(hexs[i:i + 2], 16) for i in (0, 2, 4))
251
+ except (ValueError, TypeError):
252
+ return None
253
+
254
+
255
+ def hue(hexs: str):
256
+ """None for ink (black, greys, white, auto) and for a near-white tint; else a hue bucket of 30 degrees."""
257
+ rgb = _rgb((hexs or "").lstrip("#"))
258
+ if rgb is None:
259
+ return None
260
+ hi, lo = max(rgb), min(rgb)
261
+ if hi - lo < 32 or lo >= 236: # a neutral grey, or a tint too pale to read as a colour
262
+ return None
263
+ r, g, b = (c / 255 for c in rgb)
264
+ mx, mn = max(r, g, b), min(r, g, b)
265
+ d = mx - mn
266
+ h = ((g - b) / d) % 6 if mx == r else (b - r) / d + 2 if mx == g else (r - g) / d + 4
267
+ return int(round(h * 60 / 30)) % 12
268
+
269
+
270
+ def lightness(hexs: str) -> float:
271
+ rgb = _rgb((hexs or "").lstrip("#"))
272
+ return 1.0 if rgb is None else (max(rgb) + min(rgb)) / 510
273
+
274
+
275
+ INK_FILLS = {"", "AUTO", "FFFFFF"}
276
+ ISO_DATE = re.compile(r"(?<!\d)(?:19|20)\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?!\d)")
277
+ HANGUL = re.compile(r"[가-힣]")
278
+
279
+
280
+ def _style_props(root):
281
+ """styleId -> (heading level or 0, own colour, own size in pt, basedOn, is a pull-quote style)."""
282
+ out = {}
283
+ for st in root.findall(W + "style") if root is not None else []:
284
+ sid = st.get(W + "styleId")
285
+ name = (st.find(W + "name").get(W + "val") if st.find(W + "name") is not None else "") or ""
286
+ m = re.match(r"(?i)^heading\s*(\d)$", name) or re.match(r"^Heading(\d)$", sid or "")
287
+ colour = st.find(f"{W}rPr/{W}color")
288
+ size = st.find(f"{W}rPr/{W}sz")
289
+ based = st.find(W + "basedOn")
290
+ out[sid] = (int(m.group(1)) if m else 0, colour.get(W + "val") if colour is not None else None,
291
+ int(size.get(W + "val")) / 2 if size is not None else None, based.get(W + "val") if based is not None else None,
292
+ "pullquote" in name.lower())
293
+ return out
294
+
295
+
296
+ def _resolve(props, sid, index):
297
+ seen = set()
298
+ while sid and sid in props and sid not in seen:
299
+ seen.add(sid)
300
+ if props[sid][index] is not None:
301
+ return props[sid][index]
302
+ sid = props[sid][3]
303
+ return None
304
+
305
+
306
+ def restraint(path: Path, tonality: bool) -> list[dict]:
307
+ """The restraint checks read from the Word XML (everything but cover.block and tonality.structure)."""
308
+ sev = "FAIL" if tonality else "ADVISORY"
309
+ found = []
310
+ with zipfile.ZipFile(path) as z:
311
+ names = z.namelist()
312
+ doc = ET.fromstring(z.read("word/document.xml"))
313
+ sroot = ET.fromstring(z.read("word/styles.xml")) if "word/styles.xml" in names else None
314
+ heads = {n: ET.fromstring(z.read(n)) for n in names if re.match(r"word/header\d*\.xml$", n)}
315
+ foots = {n: ET.fromstring(z.read(n)) for n in names if re.match(r"word/footer\d*\.xml$", n)}
316
+ props = _style_props(sroot)
317
+ default_id = next((s.get(W + "styleId") for s in (sroot.findall(W + "style") if sroot is not None else [])
318
+ if s.get(W + "type") == "paragraph" and s.get(W + "default") in ("1", "true")), "Normal")
319
+ body = doc.find(W + "body")
320
+ uses = {} # hue bucket -> set of element kinds
321
+
322
+ def use(colour, kind):
323
+ h = hue(colour)
324
+ if h is not None:
325
+ uses.setdefault(h, set()).add(kind)
326
+
327
+ used = {el.get(W + "val") for el in doc.iter(W + "pStyle")} | {el.get(W + "val") for el in doc.iter(W + "rStyle")} | {default_id}
328
+ for sid in list(used):
329
+ s, seen = sid, set()
330
+ while s in props and s not in seen: # a used style's colour may come from the style it is based on
331
+ seen.add(s)
332
+ if props[s][1]:
333
+ use(props[s][1], "heading text" if sid in props and props[sid][0] else "text")
334
+ break
335
+ s = props[s][3]
336
+ # heading.ink and heading.ratio
337
+ body_pt = _resolve(props, default_id, 2) or 11.0
338
+ h1_pt = 0.0
339
+ coloured, shaded = [], []
340
+ for p in body.iter(W + "p"):
341
+ sid_el = p.find(f"{W}pPr/{W}pStyle")
342
+ sid = sid_el.get(W + "val") if sid_el is not None else default_id
343
+ level = 0
344
+ s, seen = sid, set()
345
+ while s in props and s not in seen:
346
+ seen.add(s)
347
+ if props[s][0]:
348
+ level = props[s][0]
349
+ break
350
+ s = props[s][3]
351
+ if not level:
352
+ continue
353
+ text = "".join(t.text or "" for t in p.iter(W + "t")).strip()
354
+ if not text:
355
+ continue
356
+ colours = [c.get(W + "val") for c in p.iter(W + "color")] + [_resolve(props, sid, 1)]
357
+ bad = [c for c in colours if hue(c) is not None]
358
+ if bad:
359
+ coloured.append(f"h{level} '{text[:30]}' #{bad[0]}")
360
+ if level == 1:
361
+ sizes = [int(s.get(W + "val")) / 2 for s in p.iter(W + "sz")] + [_resolve(props, sid, 2) or 0]
362
+ h1_pt = max(h1_pt, max(sizes))
363
+ if coloured:
364
+ found.append(finding("heading.ink", sev, None, f"{len(coloured)} heading(s) in colour, e.g. {coloured[0]}",
365
+ "Set every heading in the body ink; separate levels by weight and space, never by colour."))
366
+ if h1_pt and h1_pt / body_pt > 1.5:
367
+ found.append(finding("heading.ratio", sev, None, f"h1 {h1_pt:g} pt over body {body_pt:g} pt is {h1_pt / body_pt:.2f}x (at most 1.5)",
368
+ "Keep the steps quiet: h1 about 1.4x, h2 about 1.2x, h3 the body size in bold."))
369
+ # colours in the body: runs, rules, fills; tables apart from figures
370
+ comp_count = {}
371
+ for tbl in body.iter(W + "tbl"):
372
+ cap = tbl.find(f"{W}tblPr/{W}tblCaption")
373
+ kind = ((cap.get(W + "val") if cap is not None else "") or "").split(" ")[0]
374
+ if kind:
375
+ comp_count[kind] = comp_count.get(kind, 0) + 1
376
+ for b in tbl.iter():
377
+ if b.tag in (W + "top", W + "bottom", W + "left", W + "right", W + "insideH", W + "insideV") and b.get(W + "val") not in (None, "nil", "none"):
378
+ use(b.get(W + "color"), "box rule" if kind else "table rule")
379
+ for tc in tbl.iter(W + "tc"):
380
+ shd = tc.find(f"{W}tcPr/{W}shd")
381
+ fill = (shd.get(W + "fill") if shd is not None else "") or ""
382
+ if fill.upper() in INK_FILLS:
383
+ continue
384
+ use(fill, "box fill" if kind else "table fill")
385
+ if not kind:
386
+ shaded.append(f"a data-table cell #{fill}")
387
+ break
388
+ if kind in ("callout", "sidebar", "keyfigures") and lightness(fill) < 0.95:
389
+ shaded.append(f"a {kind} tint #{fill} darker than L 95 %")
390
+ break
391
+ if shaded:
392
+ found.append(finding("table.fill", sev, None, f"{len(shaded)} shaded table(s), e.g. {shaded[0]}",
393
+ "Booktabs only: three rules, no fills, no zebra, a bold ink header; at most one very light tint (L 95 % or lighter) for one component kind."))
394
+ for p in body.iter(W + "p"):
395
+ for b in p.iter(W + "pBdr"):
396
+ for side in b:
397
+ if side.get(W + "val") not in (None, "nil", "none"):
398
+ use(side.get(W + "color"), "rule")
399
+ shd = p.find(f"{W}pPr/{W}shd")
400
+ if shd is not None:
401
+ use(shd.get(W + "fill"), "paragraph fill")
402
+ for r in p.iter(W + "r"):
403
+ c = r.find(f"{W}rPr/{W}color")
404
+ if c is not None:
405
+ use(c.get(W + "val"), "text")
406
+ rs = r.find(f"{W}rPr/{W}shd")
407
+ if rs is not None:
408
+ use(rs.get(W + "fill"), "text fill")
409
+ for part in list(heads.values()) + list(foots.values()):
410
+ for el in part.iter():
411
+ if el.tag == W + "color":
412
+ use(el.get(W + "val"), "page furniture")
413
+ elif el.tag == W + "shd":
414
+ use(el.get(W + "fill"), "page furniture")
415
+ if len(uses) > 1:
416
+ found.append(finding("color.accent-kinds", sev, None, f"{len(uses)} hues outside the ink: {sorted({k for v in uses.values() for k in v})}",
417
+ "One accent colour per document at most; everything else is ink and grey."))
418
+ else:
419
+ for h, kinds in uses.items():
420
+ if len(kinds) > 2:
421
+ found.append(finding("color.accent-kinds", sev, None, f"the accent marks {len(kinds)} element kinds: {sorted(kinds)}",
422
+ "Use the accent on two element kinds at most (for example the cover rule and the callout hairline)."))
423
+ # component.budget
424
+ kinds = {k for k in ("callout", "sidebar", "keyfigures", "columns") if comp_count.get(k)}
425
+ pull = sum(1 for p in body.iter(W + "p")
426
+ if (lambda s: s is not None and _resolve(props, s.get(W + "val"), 4))(p.find(f"{W}pPr/{W}pStyle")))
427
+ if any(int(c.get(W + "num", "1")) >= 2 for s in body.iter(W + "sectPr") for c in s.findall(W + "cols")):
428
+ kinds.add("columns")
429
+ if pull:
430
+ kinds.add("pullquote")
431
+ cover_kf = False
432
+ for el in body:
433
+ if el.tag == W + "tbl":
434
+ cap = el.find(f"{W}tblPr/{W}tblCaption")
435
+ if cap is not None and cap.get(W + "val") == "keyfigures":
436
+ cover_kf = True
437
+ if el.tag == W + "p" and el.find(f"{W}pPr/{W}sectPr") is not None:
438
+ break
439
+ if el.tag == W + "p" and el.find(f"{W}pPr/{W}pStyle") is not None and props.get(el.find(f"{W}pPr/{W}pStyle").get(W + "val"), (0,))[0]:
440
+ cover_kf = False # a heading before the first section break: page 1 is not a cover
441
+ break
442
+ else:
443
+ cover_kf = False
444
+ problems = []
445
+ if len(kinds) > 3:
446
+ problems.append(f"{len(kinds)} component kinds ({', '.join(sorted(kinds))})")
447
+ if comp_count.get("keyfigures", 0) > 1:
448
+ problems.append(f"{comp_count['keyfigures']} key-figure strips")
449
+ if pull:
450
+ problems.append(f"{pull} pull quote(s)")
451
+ if cover_kf:
452
+ problems.append("key figures on the cover")
453
+ if problems:
454
+ found.append(finding("component.budget", sev, None, "; ".join(problems),
455
+ "Three component kinds at most; one key-figure strip, in the summary; no pull quotes; a cover holds no key figures."))
456
+ # date.iso (every path) and furniture.chip
457
+ text = " ".join(t.text or "" for part in [doc, *heads.values(), *foots.values()] for t in part.iter(W + "t"))
458
+ if len(HANGUL.findall(text)) > 0.05 * max(1, len(re.sub(r"\s", "", text))):
459
+ m = ISO_DATE.search(text)
460
+ if m:
461
+ found.append(finding("date.iso", "FAIL", None, f"an ISO date {m.group(0)} in a Korean document",
462
+ "Write Korean dates as 2026. 10. 1. or 2026년 10월 1일."))
463
+ for name, part in heads.items():
464
+ chip = [el for el in part.iter() if (el.tag == W + "shd" and (el.get(W + "fill") or "").upper() not in INK_FILLS)
465
+ or (el.tag == W + "color" and hue(el.get(W + "val")) is not None)]
466
+ if chip and "".join(t.text or "" for t in part.iter(W + "t")).strip():
467
+ found.append(finding("furniture.chip", sev, None, f"{name.split('/')[-1]} holds a coloured or shaded notice",
468
+ "State the sample-data notice once, on the cover or the first page, in ink; page headers stay quiet."))
469
+ break
470
+ return found
471
+
472
+
473
+ # ── tonality.structure ──────────────────────────────────────────────────────
474
+
475
+ STRUCTURE_KEYS = ("title_block", "summary", "numbering", "components", "running_head", "contents")
476
+
477
+
478
+ def structure(path: Path) -> dict:
479
+ """The structural features of a document: what stands on page 1, how the summary is set, how headings
480
+ are numbered, which components it uses, what the running head carries and whether it lists its contents."""
481
+ info = read_docx(path)
482
+ with zipfile.ZipFile(path) as z:
483
+ names = z.namelist()
484
+ heads = [ET.fromstring(z.read(n)) for n in names if re.match(r"word/header\d*\.xml$", n)]
485
+ foots = [ET.fromstring(z.read(n)) for n in names if re.match(r"word/footer\d*\.xml$", n)]
486
+ body = ET.fromstring(z.read("word/document.xml")).find(W + "body")
487
+ caps = [((t.find(f"{W}tblPr/{W}tblCaption").get(W + "val") if t.find(f"{W}tblPr/{W}tblCaption") is not None else "") or "")
488
+ for t in body.iter(W + "tbl")]
489
+ # A cover page: the first section ends before any heading and the next section starts on a new page
490
+ # (a section's w:type says how that section starts).
491
+ cover_page = False
492
+ sects = [el.find(f"{W}pPr/{W}sectPr") for el in body if el.tag == W + "p" and el.find(f"{W}pPr/{W}sectPr") is not None]
493
+ sects.append(body.find(W + "sectPr"))
494
+ for el in body:
495
+ ppr = el.find(W + "pPr") if el.tag == W + "p" else None
496
+ if ppr is not None and ppr.find(W + "pStyle") is not None and re.match(r"Heading\d", ppr.find(W + "pStyle").get(W + "val") or ""):
497
+ break
498
+ if ppr is not None and ppr.find(W + "sectPr") is not None:
499
+ nxt = sects[1] if len(sects) > 1 else None
500
+ kind = nxt.find(W + "type") if nxt is not None else None
501
+ cover_page = kind is None or kind.get(W + "val") != "continuous"
502
+ break
503
+ if cover_page:
504
+ title = "cover page"
505
+ elif any(c.startswith("cover memo") for c in caps):
506
+ title = "memo block"
507
+ elif any(c.startswith("cover") for c in caps):
508
+ title = "title block + " + next(c for c in caps if c.startswith("cover")).split(" ", 1)[-1]
509
+ else:
510
+ title = "title block"
511
+ blocks = info["blocks"]
512
+ first_h = next((i for i, b in enumerate(blocks) if b["t"] == "h"), None)
513
+ lead = blocks[first_h + 1:first_h + 6] if first_h is not None else []
514
+ if any(b["t"] == "p" and re.match(r"^[①②③④⑤]", b["text"]) for b in lead):
515
+ summary = "numbered points"
516
+ elif any(b["t"] == "tbl" and b["component"] == "keyfigures" for b in blocks[: (first_h or 0) + 8]):
517
+ summary = "key figures"
518
+ else:
519
+ summary = "prose"
520
+ heads_text = [b["text"] for b in blocks if b["t"] == "h"]
521
+ if heads_text and sum(bool(re.match(r"^[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]\.", h)) for h in heads_text) >= 2:
522
+ numbering = "roman"
523
+ elif heads_text and sum(bool(re.match(r"^\d+(\.\d+)*\.?", h)) for h in heads_text) >= 2:
524
+ numbering = "decimal"
525
+ else:
526
+ numbering = "none"
527
+ comps = sorted(k for k, v in info["components"].items() if v and k != "cover")
528
+
529
+ def furniture(parts):
530
+ out = set()
531
+ for part in parts:
532
+ xml = ET.tostring(part, encoding="unicode")
533
+ text = "".join(t.text or "" for t in part.iter(W + "t")).strip()
534
+ if "PAGE" not in xml and "STYLEREF" not in xml:
535
+ continue # a cover's own footer (byline, date) is not a running head
536
+ if "STYLEREF" in xml:
537
+ out.add("section")
538
+ if "PAGE" in xml:
539
+ out.add("folio")
540
+ if re.sub(r"\d", "", text):
541
+ out.add("title")
542
+ return out
543
+ running = f"header:{'+'.join(sorted(furniture(heads))) or '-'} footer:{'+'.join(sorted(furniture(foots))) or '-'}"
544
+ contents = any(b["t"] == "p" and b["text"] in ("목차", "Contents") for b in blocks)
545
+ return {"title_block": title, "summary": summary, "numbering": numbering, "components": ",".join(comps) or "-",
546
+ "running_head": running, "contents": contents}
547
+
548
+
549
+ def structure_diff(a: dict, b: dict) -> list[str]:
550
+ return [k for k in STRUCTURE_KEYS if a.get(k) != b.get(k)]
551
+
552
+
553
+ # ── Structural checks ───────────────────────────────────────────────────────
554
+
555
+ def structural(info: dict, front: dict, manuscript: bool, tonality: bool = True) -> list[dict]:
556
+ found = []
557
+ heads = [b for b in info["blocks"] if b["t"] == "h"]
558
+ if heads and heads[0]["level"] > 2:
559
+ found.append(finding("heading.order", "FAIL", None, f"the first heading is h{heads[0]['level']}: {heads[0]['raw'][:60]}",
560
+ "Open the document with a # or ## heading."))
561
+ for prev, head in zip(heads, heads[1:]):
562
+ if head["level"] > prev["level"] + 1:
563
+ found.append(finding("heading.order", "FAIL", None, f"h{prev['level']} -> h{head['level']}: {head['raw'][:60]}",
564
+ f"Make it an h{prev['level'] + 1}, or add the missing level above it."))
565
+ # The example-data note reads as a label and its line, never two halves around a spaced dash.
566
+ notice = str(front.get("notice") or "")
567
+ if re.search(r"\s[—–]\s", notice):
568
+ found.append(finding("notice.dash", "FAIL" if tonality else "ADVISORY", None, f"the notice joins its parts with a spaced dash: {notice[:60]}",
569
+ "Write the label with a colon (예시 데이터: …; Sample memo: …)."))
570
+ labels = [("title", front.get("title")), ("subtitle", front.get("subtitle"))] + [(f"h{h['level']}", h["raw"]) for h in heads]
571
+ for what, text in labels:
572
+ if text and declarative(str(text)):
573
+ found.append(finding("heading.declarative", "ADVISORY" if manuscript else "FAIL", None, f"the {what} is a sentence: {str(text)[:60]}",
574
+ "Name the topic as a noun phrase (1년 운영 결과; Conversion by feed rate) and put the claim in the first sentence under it."))
575
+ return found
576
+
577
+
578
+ # ── Page checks ─────────────────────────────────────────────────────────────
579
+
580
+ def read_pdf(pdf: Path) -> list[dict]:
581
+ """Per page: size, text lines (top, bottom, normalised text) and ink boxes (text, drawings, pictures)."""
582
+ import pymupdf
583
+
584
+ pages = []
585
+ with pymupdf.open(pdf) as document:
586
+ for page in document:
587
+ lines, ink, boxes = [], [], []
588
+ for block in page.get_text("dict")["blocks"]:
589
+ for line in block.get("lines", []):
590
+ text = "".join(span["text"] for span in line["spans"])
591
+ if text.strip():
592
+ x0, y0, x1, y1 = line["bbox"]
593
+ lines.append((y0, y1, norm(text)))
594
+ boxes.append((x0, y0, x1, y1, norm(text)))
595
+ ink.append((y0, y1))
596
+ fills = 0.0
597
+ for drawing in page.get_drawings():
598
+ fill = drawing.get("fill")
599
+ if drawing.get("color") is not None or (fill is not None and min(fill) < 0.96):
600
+ ink.append((drawing["rect"].y0, drawing["rect"].y1))
601
+ if fill is not None and min(fill) < 0.96:
602
+ fills += drawing["rect"].width * drawing["rect"].height
603
+ images = [(info["bbox"][1], info["bbox"][3]) for info in page.get_image_info()]
604
+ ink.extend(images)
605
+ lines.sort()
606
+ pages.append({"h": page.rect.height, "w": page.rect.width, "lines": lines, "ink": ink, "images": len(images), "boxes": boxes,
607
+ "filled": fills / (page.rect.width * page.rect.height)})
608
+ return pages
609
+
610
+
611
+ def _column(hit, pages) -> int:
612
+ """0 for a line in the left half of its page, 1 for the right half (a two-column body)."""
613
+ page = pages[hit[0] - 1]
614
+ box = next((b for b in page.get("boxes", []) if abs(b[1] - hit[1]) < 0.5 and b[4] == hit[3]), None)
615
+ return 0 if box is None or box[0] < 297 else 1
616
+
617
+
618
+ def _starts(line: str, target: str) -> bool:
619
+ """The PDF line opens the target: six shared characters, or all of a short target, or a short wrapped first line."""
620
+ if not line or not target:
621
+ return False
622
+ k = 0
623
+ for a, b in zip(line, target):
624
+ if a != b:
625
+ break
626
+ k += 1
627
+ return k >= min(len(target), 6) or k == len(line) >= min(2, len(target))
628
+
629
+
630
+ def _wraps(raw: str, lines: list) -> list[str]:
631
+ """Where a label set over several PDF lines breaks inside a word: the text either side of each such break.
632
+ A break may fall at a space or after a dash or slash; anywhere else it splits a word (제/안서)."""
633
+ target = norm(raw)
634
+ spaced = re.sub(r"\s+", " ", raw.translate(str.maketrans({"–": "-", "—": "-"}))).strip()
635
+ ok_after, k = set(), 0 # positions in the normalised text where a break is allowed
636
+ marker = re.match(r"^(?:\d+(?:\.\d+)*\.?|[A-Z]\.|[IVX]+\.|[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]\.|[가나다라마바사아자차카타파하]\.|[□○■▪•-])", target)
637
+ if marker:
638
+ ok_after.add(marker.end()) # a numeral or marker the treatment sets apart with a tab
639
+ for i, ch in enumerate(spaced):
640
+ if ch == " ":
641
+ ok_after.add(k)
642
+ continue
643
+ k += 1
644
+ if ch in "-/":
645
+ ok_after.add(k)
646
+ cut = _set_over(target, lines)
647
+ return [f"{target[max(0, c - 4):c]}/{target[c:c + 4]}" for c in cut or [] if c not in ok_after]
648
+
649
+
650
+ def _set_over(target: str, lines: list) -> list[int] | None:
651
+ """The break positions of a normalised label set over consecutive PDF lines ([] on one line), or None."""
652
+ for start in range(len(lines)):
653
+ if lines[start][2] == target:
654
+ return []
655
+ if not lines[start][2] or not target.startswith(lines[start][2]):
656
+ continue
657
+ got, cut = lines[start][2], []
658
+ for nxt in lines[start + 1:start + 8]:
659
+ if not target.startswith(got + nxt[2]):
660
+ break
661
+ cut.append(len(got))
662
+ got += nxt[2]
663
+ if got == target:
664
+ return cut
665
+ return None
666
+
667
+
668
+ def _same(line: str, target: str) -> bool:
669
+ """A stricter _starts for captions and box cells: 12 shared characters (or the whole text), so a sentence
670
+ that only opens alike ("Table 2 summarises", "Half-conversion time.") is not taken for them."""
671
+ if not line or not target:
672
+ return False
673
+ k = next((i for i, (a, b) in enumerate(zip(line, target)) if a != b), min(len(line), len(target)))
674
+ return k >= min(len(target), 12) or (k == len(line) >= 8)
675
+
676
+
677
+ LONG_TABLE_ROWS = 7 # header + 6 body rows
678
+ SPLIT_SIDE_MIN = 3 # body rows on each side of an allowed split
679
+ SPLIT_START_MAX = 0.75 # moving the table whole would leave its first page under this fill
680
+
681
+
682
+ def long_split_ok(block, got, cap_hit, pages, frame, tonality) -> bool:
683
+ """In a tonality run, a table of a header and six or more body rows may break between rows
684
+ (header repeated) when it started high enough that moving it whole would leave the page under 0.75 filled,
685
+ with at least three body rows on each side. Never on the publisher path."""
686
+ spread = sorted({h[0] for h in got})
687
+ if not tonality or len(block["rows"]) < LONG_TABLE_ROWS or len(spread) != 2:
688
+ return False
689
+ first = sum(1 for h in got if h[0] == spread[0]) - 1 # the header row stands on the first page
690
+ second = sum(1 for h in got if h[0] == spread[1])
691
+ page = pages[spread[0] - 1]
692
+ top, floor = frame["top"], page["h"] - frame["bottom"]
693
+ start = cap_hit[1] if cap_hit and cap_hit[0] == spread[0] else min(h[1] for h in got if h[0] == spread[0])
694
+ return first >= SPLIT_SIDE_MIN and second >= SPLIT_SIDE_MIN and (start - top) / (floor - top) <= SPLIT_START_MAX
695
+
696
+
697
+ BALANCE_SHORT = 0.75 # a column that stops above this share of the frame beside one that runs to the foot
698
+ BALANCE_FULL = 0.9
699
+
700
+
701
+ def column_balance(info: dict, pages: list[dict], frame: dict, tonality: bool) -> list[dict]:
702
+ """Columns of a two-column body: on a full page neither column stops a quarter of the
703
+ frame short while the other runs to the foot, and the last page sets both columns to about the same depth
704
+ instead of one column beside an empty one."""
705
+ if not any(b.get("cols", 1) >= 2 for b in info["blocks"]):
706
+ return []
707
+ found = []
708
+ top = frame["top"]
709
+ for i, page in enumerate(pages, 1):
710
+ floor, mid = page["h"] - frame["bottom"], page.get("w", 595.3) / 2
711
+ body = [b for b in page.get("boxes", []) if b[1] >= top - 2 and b[3] <= floor + 2]
712
+ left = [b for b in body if b[2] <= mid + 5]
713
+ right = [b for b in body if b[0] >= mid - 5]
714
+ depth = lambda side: (max(b[3] for b in side) - top) / (floor - top) if side else 0.0 # noqa: E731
715
+ if i < len(pages):
716
+ if left and right and max(depth(left), depth(right)) >= BALANCE_FULL and min(depth(left), depth(right)) < BALANCE_SHORT:
717
+ short = "left" if depth(left) < depth(right) else "right"
718
+ found.append(finding("columns.balance", "FAIL" if tonality else "ADVISORY", i,
719
+ f"the {short} column on page {i} stops at {min(depth(left), depth(right)):.2f} of the frame while the other runs to the foot",
720
+ "Let a list of five or six items in a column break after its first two items; keep only short blocks whole."))
721
+ elif info["blocks"] and info["blocks"][-1].get("cols", 1) >= 2:
722
+ start = min((b[1] for b in left + right), default=top)
723
+ tall = lambda side: (max(b[3] for b in side) - start) / (floor - top) if side else 0.0 # noqa: E731
724
+ longer, shorter = max(tall(left), tall(right)), min(tall(left), tall(right))
725
+ if longer > 0.125 and shorter < 0.5 * longer:
726
+ found.append(finding("columns.balance", "FAIL" if tonality else "ADVISORY", i,
727
+ f"the last page sets one column {longer:.2f} of the frame deep beside one {shorter:.2f} deep",
728
+ "End the column body with a continuous section break so the last page balances its columns."))
729
+ return found
730
+
731
+
732
+ def paged(info: dict, pages: list[dict], tonality: bool, front: dict | None = None) -> tuple[list[dict], dict]:
733
+ frame = info["frame"] or {"page_h": 841.9, "top": 56.7, "bottom": 56.7}
734
+ top = frame["top"]
735
+ found, fills = [], []
736
+ n = len(pages)
737
+ for i, page in enumerate(pages, 1):
738
+ floor = page["h"] - frame["bottom"]
739
+ body = [(y0, y1) for y0, y1 in page["ink"] if y1 > top - 2 and y0 < floor + 2]
740
+ fill = round(max(0.0, min(1.0, (max((y1 for _, y1 in body), default=top) - top) / (floor - top))), 3)
741
+ exempt = "last" if i == n else "cover" if info["cover"] and i == 1 else None
742
+ fills.append({"page": i, "fill": fill, "exempt": exempt})
743
+ if not exempt and fill < FILL_MIN:
744
+ found.append(finding("fill.page", "FAIL", i, f"page {i} is filled to {fill:.2f} of its frame (floor {FILL_MIN})",
745
+ "Let the next block move up: drop a forced break, keep a short section with its neighbour, or move a table under its text."))
746
+ # A memo that spills under a quarter of a page onto page 2 is a one-page memo set loosely; more than that is a
747
+ # two-page memo, which the pack allows.
748
+ if info.get("memo") and n == 2 and fills[-1]["fill"] < MEMO_SPILL:
749
+ found.append(finding("memo.fit", "FAIL", 2, f"the memo runs onto a second page filled to {fills[-1]['fill']:.2f} of its frame",
750
+ "Set the memo on one page: the Memo pack's tight spacing, or shorten the text by the lines that spill."))
751
+ # A second page filled under a third of its frame is a one-page document set loosely or a block left over;
752
+ # a last page under an eighth holds a paragraph or two that the page before could carry.
753
+ if not info.get("memo") and n >= 2:
754
+ last = fills[-1]["fill"]
755
+ if n == 2 and last < SPILL_SECOND:
756
+ found.append(finding("page.spill", "FAIL" if tonality else "ADVISORY", 2, f"page 2 of 2 is filled to {last:.2f} of its frame (at least {SPILL_SECOND:.2f})",
757
+ "Set the document on one page (tight spacing for a source of about a page), or give page 2 a whole section."))
758
+ elif last < SPILL_LAST:
759
+ found.append(finding("page.spill", "FAIL" if tonality else "ADVISORY", n, f"the last page holds only {last:.2f} of its frame",
760
+ "Keep the closing paragraph with the block before it, so the two share a page."))
761
+ heads = [b for b in info["blocks"] if b["t"] == "h"]
762
+ labels = [(what, str((front or {}).get(what))) for what in ("title", "subtitle") if (front or {}).get(what)]
763
+ labels += [(f"h{h['level']}", h["raw"]) for h in heads]
764
+ for what, raw in labels:
765
+ for i, page in enumerate(pages, 1):
766
+ splits = _wraps(raw, page["lines"])
767
+ if splits:
768
+ found.append(finding("heading.wrap", "FAIL", i, f"the {what} '{raw[:50]}' wraps inside a word on page {i} ({', '.join(splits)})",
769
+ "Break the line between words: the converter sets cover and title lines at word boundaries; a heading too long for its column needs a shorter label."))
770
+ break
771
+ title = str((front or {}).get("title") or "")
772
+ for i, page in enumerate(pages[:2], 1):
773
+ cut = _set_over(norm(title), page["lines"]) if title else None
774
+ if cut is not None and len(cut) + 1 > TITLE_LINES:
775
+ found.append(finding("title.lines", "FAIL", i, f"the title takes {len(cut) + 1} lines (at most {TITLE_LINES})",
776
+ "The converter sets a title in at most three lines; a longer title needs a shorter wording or a subtitle."))
777
+ break
778
+ body_pages = n - (1 if info["cover"] else 0)
779
+ for i, page in enumerate(pages, 1):
780
+ floor = page["h"] - frame["bottom"]
781
+ for _, y0, _, t in [(None, ln[0], ln[1], ln[2]) for ln in page["lines"] if ln[0] > floor - 2]:
782
+ m = re.fullmatch(r"(\d+)/(\d+)", t)
783
+ if m and int(m.group(2)) not in (n, body_pages):
784
+ found.append(finding("folio.total", "FAIL", i, f"the folio reads {m.group(1)} / {m.group(2)} in a document of {n} pages",
785
+ "Print the page number alone, or a total that counts the pages the reader has."))
786
+ break
787
+ else:
788
+ continue
789
+ break
790
+ for i, page in enumerate(pages[:-1], 1):
791
+ floor = page["h"] - frame["bottom"]
792
+ inside = [ln for ln in page["lines"] if ln[0] >= top - 2 and ln[1] <= floor + 2]
793
+ if not inside:
794
+ continue
795
+ last = max(inside, key=lambda ln: ln[1])[2]
796
+ for head in heads:
797
+ h = head["text"]
798
+ if last == h or (len(last) >= 4 and h.endswith(last)) or (last.endswith(h) and len(last) - len(h) <= 6 and len(h) >= 2):
799
+ found.append(finding("heading.stranded", "FAIL", i, f"page {i} ends on the heading '{head['raw'][:50]}'",
800
+ "The converter keeps a heading with its next paragraph; a heading over a long table or a picture needs a short lead sentence under it."))
801
+ break
802
+ lines = [(p, y0, y1, t) for p, page in enumerate(pages, 1) for y0, y1, t in page["lines"]]
803
+ cursor = 0
804
+
805
+ def find(target, max_page=None):
806
+ nonlocal cursor
807
+ for j in range(cursor, len(lines)):
808
+ if max_page and lines[j][0] > max_page:
809
+ return None
810
+ if _starts(lines[j][3], target):
811
+ cursor = j + 1
812
+ return lines[j]
813
+ return None
814
+
815
+ body_h = frame["page_h"] - frame["top"] - frame["bottom"]
816
+ image_pages = [p for p, page in enumerate(pages, 1) for _ in range(page["images"])]
817
+ pictures = 0
818
+ pending = None
819
+ for block in info["blocks"]:
820
+ if block["t"] == "h":
821
+ find(block["text"])
822
+ elif block["t"] == "tbl" and block["component"] in ("keyfigures", "callout", "sidebar"):
823
+ # A component box is short by design; reading order inside it runs across cells, so each
824
+ # paragraph is looked up from here without moving the cursor past the next block.
825
+ hits = []
826
+ for text in block.get("cells", []):
827
+ if len(text) < 2:
828
+ continue
829
+ hit = next((ln for ln in lines[cursor:] if _same(ln[3], text) and (not hits or ln[0] <= hits[0][0] + 1)), None)
830
+ if hit:
831
+ hits.append(hit)
832
+ spread = sorted({h[0] for h in hits})
833
+ if len(spread) > 1:
834
+ found.append(finding("table.split", "FAIL", spread[0], f"a {block['component']} box runs over pages {spread}",
835
+ "A component box stays whole: its rows cannot split and it keeps with the heading above it."))
836
+ elif block["t"] == "tbl" and block["component"] is None:
837
+ prev = info["blocks"][info["blocks"].index(block) - 1] if info["blocks"].index(block) else None
838
+ cap_hit = None
839
+ if prev is not None and prev["t"] in ("cap", "p") and TABLE_CAPTION.match(prev["text"]):
840
+ cap_hit = next((ln for ln in lines[max(0, cursor - 3):] if _same(ln[3], prev["text"])), None)
841
+ got = []
842
+ for row in block["rows"]:
843
+ hit = find(row, got[-1][0] + 1 if got else None) if row else None
844
+ if hit:
845
+ got.append(hit)
846
+ spread = sorted({h[0] for h in got})
847
+ if cap_hit:
848
+ # The caption ends its page when no body line stands below it there.
849
+ cp, cy1 = cap_hit[0], cap_hit[2]
850
+ floor_y = pages[cp - 1]["h"] - frame["bottom"]
851
+ if not any(ln[0] > cy1 + 1 and ln[1] <= floor_y and ln[2] not in prev["text"] for ln in pages[cp - 1]["lines"]): # a wrapped caption line is not body
852
+ found.append(finding("table.split", "FAIL", cp, f"the caption '{prev['text'][:30]}' ends page {cp}; its table starts on the next page",
853
+ "Keep the caption with its table (keep with next on the caption paragraph)."))
854
+ height = sum(max(h[2] for h in got if h[0] == p) - min(h[1] for h in got if h[0] == p) for p in spread)
855
+ if len(spread) > 1 and height <= FIT_SHARE * body_h and not long_split_ok(block, got, cap_hit, pages, frame, tonality):
856
+ found.append(finding("table.split", "FAIL", spread[0], f"a table of {len(block['rows'])} rows (about {height:.0f} pt of a {body_h:.0f} pt frame) runs over pages {spread}",
857
+ "Put a short lead sentence before it or move it under the next paragraph; never shrink its type to fit."))
858
+ elif block["t"] == "img":
859
+ pending = image_pages[pictures] if pictures < len(image_pages) else None
860
+ pictures += 1
861
+ elif block["t"] == "cap":
862
+ hit = find(block["text"])
863
+ if pending is not None and hit and hit[0] != pending:
864
+ found.append(finding("figure.split", "FAIL", pending, f"a picture on page {pending} has its caption on page {hit[0]}",
865
+ "Make the picture smaller in its source or give the section a lead sentence so both move together."))
866
+ pending = None
867
+ # Short lists and a heading with the table it opens, located by a cursor of their own.
868
+ spots = []
869
+ at, used = 0, set()
870
+ for block in info["blocks"]:
871
+ target = block["rows"][0] if block["t"] == "tbl" and block["rows"] and block["rows"][0] else block.get("text")
872
+ hit = None
873
+ if target and len(target) >= 2:
874
+ # Lines on one baseline sort by height, so the head of the next column can stand a few lines back.
875
+ for j in range(max(0, at - 4), len(lines)):
876
+ if j not in used and _starts(lines[j][3], target):
877
+ hit, at = lines[j], max(at, j + 1)
878
+ used.add(j)
879
+ break
880
+ spots.append(hit)
881
+ k = 0
882
+ blocks = info["blocks"]
883
+ while k < len(blocks):
884
+ if blocks[k]["t"] == "p" and blocks[k].get("list"):
885
+ j = k
886
+ while j < len(blocks) and blocks[j]["t"] == "p" and blocks[j].get("list"):
887
+ j += 1
888
+ items = [spots[i] for i in range(k, j) if spots[i] is not None]
889
+ where = sorted({(h[0], _column(h, pages)) for h in items})
890
+ # In a column a list of five or six may break once, two items or more on each side.
891
+ column_break = blocks[k].get("cols", 1) >= 2 and len(where) == 2 and j - k >= 5 and \
892
+ min(sum(1 for h in items if (h[0], _column(h, pages)) == w) for w in where) >= 2
893
+ if 2 <= j - k <= SHORT_LIST and len(where) > 1 and not column_break:
894
+ found.append(finding("list.split", "FAIL" if tonality else "ADVISORY", where[0][0], f"a list of {j - k} items runs over {len(where)} pages or columns",
895
+ "A list of up to six items keeps together: every item but the last keeps with the next, and its lead-in keeps with the list."))
896
+ k = j
897
+ continue
898
+ if blocks[k]["t"] == "h" and k + 2 < len(blocks) and blocks[k + 1]["t"] == "p" and not blocks[k + 1].get("list"):
899
+ nxt = next((i for i in range(k + 2, min(k + 5, len(blocks))) if blocks[i]["t"] == "tbl" and blocks[i]["component"] is None), None)
900
+ # A short lead (about two lines) over a table of up to eight body rows, the chain the converter keeps.
901
+ short = len(blocks[k + 1]["text"]) <= 200 and nxt is not None and len(blocks[nxt]["rows"]) <= 9
902
+ # Another heading in between opens its own section; the table is that heading's.
903
+ if short and all(blocks[i]["t"] == "cap" or (blocks[i]["t"] != "h" and len(blocks[i].get("text", "")) <= 40) for i in range(k + 2, nxt)):
904
+ if spots[k] and spots[nxt] and spots[k][0] != spots[nxt][0]:
905
+ found.append(finding("heading.apart", "FAIL" if tonality else "ADVISORY", spots[k][0],
906
+ f"the heading '{blocks[k].get('raw', '')[:40]}' and its lead sentence end page {spots[k][0]}; the table they open starts page {spots[nxt][0]}",
907
+ "Keep the heading, its lead sentence and the table together (keep with next), or move the sentence under the table."))
908
+ k += 1
909
+ # A heading in a column body stands with its first lines: never alone at the foot of a column while its text
910
+ # opens the next column or page.
911
+ for k, block in enumerate(blocks[:-1]):
912
+ nxt = blocks[k + 1]
913
+ if block["t"] != "h" or block.get("cols", 1) < 2 or nxt["t"] == "h" or not spots[k] or not spots[k + 1]:
914
+ continue
915
+ here, there = (spots[k][0], _column(spots[k], pages)), (spots[k + 1][0], _column(spots[k + 1], pages))
916
+ if here != there:
917
+ found.append(finding("heading.column", "FAIL" if tonality else "ADVISORY", here[0],
918
+ f"the heading '{block.get('raw', '')[:40]}' ends a column on page {here[0]}; its text starts in the next column",
919
+ "Keep the heading with its first lines; a source or note line under a page-wide table spans with the table."))
920
+ found.extend(column_balance(info, pages, frame, tonality))
921
+ # A floating sidebar that reaches past the next heading: the heading and its rule run beside the box.
922
+ for k, block in enumerate(info["blocks"]):
923
+ if block["t"] != "tbl" or block["component"] != "sidebar":
924
+ continue
925
+ nxt = next((b for b in info["blocks"][k + 1:] if b["t"] == "h"), None)
926
+ if nxt is None:
927
+ continue
928
+ for i, page in enumerate(pages, 1):
929
+ boxes = [b for b in page.get("boxes", []) if b[1] >= top - 2] # the running header is not body
930
+ cells = [c for c in block.get("cells", []) if len(c) >= 2]
931
+ anchor = next((b for b in boxes if cells and _same(b[4], cells[0])), None)
932
+ # the box's own lines share its left edge; body lines that open alike stand elsewhere
933
+ mine = []
934
+ if anchor:
935
+ # the box: its title line, then every line in its column that follows without a gap (wrapped
936
+ # lines included, which do not open a cell paragraph)
937
+ mine = [anchor]
938
+ for bx in sorted((b for b in boxes if b[1] > anchor[1] and b[0] >= anchor[0] - 24), key=lambda b: b[1]):
939
+ if bx[1] - mine[-1][3] > 20:
940
+ break
941
+ mine.append(bx)
942
+ names = {nxt["text"], re.sub(r"^(?:\d+(?:\.\d+)*\.?|[□○■▪•-])", "", nxt["text"])} # the numeral may stand apart
943
+ head = next((b for b in boxes if any(_same(b[4], t) for t in names if len(t) >= 2)), None)
944
+ if mine and head:
945
+ x0, y0, y1 = min(b[0] for b in mine), min(b[1] for b in mine), max(b[3] for b in mine)
946
+ if head[1] < y1 and head[3] > y0 and head[0] < x0:
947
+ found.append(finding("sidebar.overlap", "FAIL", i, f"the sidebar on page {i} runs beside the next heading '{nxt['raw'][:40]}'",
948
+ "Give the sidebar more text beside it, or let it stand full width; the converter stops the float when the text before the next heading is shorter than the box."))
949
+ break
950
+ if tonality and info["cover"] and pages and pages[0].get("filled", 0) > 0.25:
951
+ found.append(finding("cover.block", "FAIL", 1, f"filled shapes cover {pages[0]['filled']:.0%} of the cover page (at most 25 %)",
952
+ "A typographic cover: left-aligned title, white space, one rule at most; no colour block, no tiles."))
953
+ kinds = [k for k, v in info["components"].items() if v]
954
+ if n >= 4 and len(kinds) < 2:
955
+ found.append(finding("component.variety", "FAIL" if tonality else "ADVISORY", None,
956
+ f"{n} pages use {len(kinds)} component kind(s): {', '.join(kinds) or 'none'}",
957
+ "Give the document a cover and the components its tonality allows: a callout for the decision, key figures with their basis, a sidebar for definitions."))
958
+ body_fills = [f["fill"] for f in fills if not f["exempt"]]
959
+ return found, {"pages": n, "fills": fills, "fill_median": round(statistics.median(body_fills), 3) if body_fills else None}