@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.
- package/CHANGELOG.md +7 -0
- package/README-Ko-KR.md +11 -11
- package/README.md +11 -11
- package/dist/activation-routing.js +1 -1
- package/dist/features.js +2 -2
- package/dist/skills.js +6 -4
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/reference-Ko-KR.md +1 -1
- package/package.json +1 -1
- package/skills/frontend-ui-ux/data/THIRD-PARTY-NOTICE.txt +1 -3
- package/skills/lit-docx/SKILL.md +80 -19
- package/skills/lit-docx/references/components.md +253 -0
- package/skills/lit-docx/references/direction-step.md +234 -0
- package/skills/lit-docx/references/examples/01-report-ko.md +160 -0
- package/skills/lit-docx/references/examples/02-brief-ko.md +58 -0
- package/skills/lit-docx/references/examples/03-manual-ko.md +107 -0
- package/skills/lit-docx/references/examples/04-proposal-ko.md +93 -0
- package/skills/lit-docx/references/examples/05-memo-ko.md +42 -0
- package/skills/lit-docx/references/examples/06-journal-en.md +84 -0
- package/skills/lit-docx/references/examples/07-report-en.md +137 -0
- package/skills/lit-docx/references/examples/08-journal-ko.md +93 -0
- package/skills/lit-docx/references/examples/09-brief-en.md +64 -0
- package/skills/lit-docx/references/page-composition.md +306 -0
- package/skills/lit-docx/references/tonalities/brief.md +104 -0
- package/skills/lit-docx/references/tonalities/journal.md +101 -0
- package/skills/lit-docx/references/tonalities/manual.md +111 -0
- package/skills/lit-docx/references/tonalities/memo.md +105 -0
- package/skills/lit-docx/references/tonalities/proposal.md +101 -0
- package/skills/lit-docx/references/tonalities/report.md +126 -0
- package/skills/lit-docx/run.py +2 -1
- package/skills/lit-docx/scripts/convert_md_to_docx.py +384 -67
- package/skills/lit-docx/scripts/craft_docx.py +204 -1
- package/skills/lit-docx/scripts/docx_design.py +2445 -0
- package/skills/lit-docx/scripts/docx_layout.py +959 -0
- package/skills/lit-docx/scripts/slop_lint.py +23 -6
- package/skills/lit-docx/templates/tonalities/brief.yaml +29 -0
- package/skills/lit-docx/templates/tonalities/journal.yaml +26 -0
- package/skills/lit-docx/templates/tonalities/manual.yaml +27 -0
- package/skills/lit-docx/templates/tonalities/memo.yaml +27 -0
- package/skills/lit-docx/templates/tonalities/proposal.yaml +27 -0
- package/skills/lit-docx/templates/tonalities/report.yaml +27 -0
- package/skills/lit-pptx/NOTICE.md +1 -1
- package/skills/lit-pptx/SKILL.md +108 -19
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/Pretendard-Regular.otf +0 -0
- package/skills/lit-pptx/references/density-and-fill.md +283 -0
- package/skills/lit-pptx/references/direction-step.md +249 -0
- package/skills/lit-pptx/references/examples/01-ops-review-ledger-ko.md +173 -0
- package/skills/lit-pptx/references/examples/02-launch-pitch-signal-en.md +153 -0
- package/skills/lit-pptx/references/examples/03-field-survey-atlas-ko.md +120 -0
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +132 -0
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +162 -0
- package/skills/lit-pptx/references/examples/06-policy-briefing-gazette-ko.md +176 -0
- package/skills/lit-pptx/references/examples/07-annual-story-studio-en.md +131 -0
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +182 -0
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +147 -0
- package/skills/lit-pptx/references/examples/10-investor-pitch-signal-ko.md +136 -0
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +177 -0
- package/skills/lit-pptx/references/examples/12-exhibition-proposal-studio-ko.md +149 -0
- package/skills/lit-pptx/references/layout-families.md +470 -0
- package/skills/lit-pptx/references/title-treatments.md +271 -0
- package/skills/lit-pptx/references/tonalities/atlas.md +132 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +132 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +137 -0
- package/skills/lit-pptx/references/tonalities/ledger.md +151 -0
- package/skills/lit-pptx/references/tonalities/night.md +148 -0
- package/skills/lit-pptx/references/tonalities/paper.md +136 -0
- package/skills/lit-pptx/references/tonalities/signal.md +148 -0
- package/skills/lit-pptx/references/tonalities/studio.md +149 -0
- package/skills/lit-pptx/scripts/compile-deck.js +68 -21
- package/skills/lit-pptx/scripts/craft-thresholds.json +45 -1
- package/skills/lit-pptx/scripts/craft_extras.py +37 -8
- package/skills/lit-pptx/scripts/deck_output.py +654 -0
- package/skills/lit-pptx/scripts/embed_fonts.py +2 -2
- package/skills/lit-pptx/scripts/layout_inventory.py +35 -2
- package/skills/lit-pptx/scripts/lib/directive-parser.js +2 -2
- package/skills/lit-pptx/scripts/lib/font-map.js +4 -4
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +521 -0
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +403 -21
- package/skills/lit-pptx/scripts/lib/markdown-loader.js +6 -4
- package/skills/lit-pptx/scripts/lib/render-adapter-html.js +3 -9
- package/skills/lit-pptx/scripts/lib/render-adapter-pptx.js +500 -113
- package/skills/lit-pptx/scripts/lib/render-pack.js +3558 -0
- package/skills/lit-pptx/scripts/lib/slide-ast.js +55 -24
- package/skills/lit-pptx/scripts/lib/spec-validator.js +2 -2
- package/skills/lit-pptx/scripts/lib/template-registry.js +335 -11
- package/skills/lit-pptx/scripts/qa_deck.py +48 -13
- package/skills/lit-pptx/scripts/validate_pptx.py +17 -5
- package/skills/lit-pptx/specs/template-enrollment-contract.md +4 -4
- package/skills/lit-pptx/templates/boilerplate_professional.css +13 -13
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/layout-mapping.yaml +1 -1
- package/skills/lit-pptx/templates/enrolled/AZURE-A2Z/template.yaml +5 -2
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/capabilities.yaml +20 -20
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/layout-mapping.yaml +1 -11
- package/skills/lit-pptx/templates/enrolled/AZURE-PRO/template.yaml +10 -7
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/capabilities.yaml +17 -17
- package/skills/lit-pptx/templates/enrolled/BOILERPLATE-PRETENDARD/template.yaml +8 -8
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +105 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +110 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +106 -0
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +106 -0
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +111 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +105 -0
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +1 -1
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +2 -2
- package/skills/lit-typographic-motion/engine/treatment.mjs +1 -1
- package/skills/lit-typographic-motion/fonts/pins.json +2 -2
- package/skills/lit-typographic-motion/gate.mjs +1 -1
- package/skills/lit-typographic-motion/references/stage.md +1 -1
- package/skills/lit-typographic-motion/references/style-bibles.md +2 -2
- package/skills/lit-typographic-motion/references/treatment.md +1 -1
- package/skills/lit-typographic-motion/references/type-craft.md +3 -3
- package/skills/managed-skill-manifest.json +288 -44
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Bold.otf +0 -0
- package/skills/lit-pptx/pretendard-font/public/static/LitOpenCodeSans-Regular.otf +0 -0
|
@@ -0,0 +1,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}
|