@litfamily/litopencode 1.0.15 → 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 +11 -0
- package/README-Ko-KR.md +11 -11
- package/README.md +11 -11
- package/dist/activation-routing.js +1 -1
- package/dist/auto-handoff.js +34 -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,2445 @@
|
|
|
1
|
+
"""Design directions (tonalities) and page components for convert_md_to_docx.py.
|
|
2
|
+
|
|
3
|
+
A tonality is a data file, templates/tonalities/<name>.yaml, merged over the korean-generic
|
|
4
|
+
`design:` tree of templates/registry.yaml; its two dials (density, variance) then set the page
|
|
5
|
+
margins, leading and body size. The converter reads the pack; nothing here forks per look.
|
|
6
|
+
|
|
7
|
+
The six tonalities differ in structure, not colour: what stands on page 1 (a title block, a cover
|
|
8
|
+
page, a memo block, a document-control block), how the summary is set, how headings are numbered,
|
|
9
|
+
which components a document may use and what the running head carries. Every pack sets body and headings in near-black ink, booktabs tables without fills,
|
|
10
|
+
a quiet 1.2x type scale on an A4 page with 25 mm sides, and at most one accent colour on at most
|
|
11
|
+
two element kinds.
|
|
12
|
+
|
|
13
|
+
The Markdown dialect adds fenced directives (::: cover, callout, sidebar, pullquote, keyfigures,
|
|
14
|
+
columns; <!-- column-break --> inside columns) and a {style=...} attribute on table captions. The
|
|
15
|
+
pre-pass splits them out before Markdown sees the text, so no fence ever reaches the page. A
|
|
16
|
+
directive the tonality does not use, or one past the component budget, keeps its content as
|
|
17
|
+
ordinary text; a pull quote that repeats a sentence of the body is left out.
|
|
18
|
+
|
|
19
|
+
The plain profile (no tonality, no publisher) runs through the same builders with neutral tokens:
|
|
20
|
+
A4, Pretendard, booktabs tables, the notice in every header.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import copy
|
|
25
|
+
import datetime as _dt
|
|
26
|
+
import math
|
|
27
|
+
import re
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
import yaml
|
|
32
|
+
from docx.enum.section import WD_SECTION
|
|
33
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_TAB_ALIGNMENT, WD_TAB_LEADER
|
|
34
|
+
from docx.oxml import OxmlElement
|
|
35
|
+
from docx.oxml.ns import qn
|
|
36
|
+
from docx.shared import Emu, Mm, Pt, RGBColor
|
|
37
|
+
from docx.table import Table
|
|
38
|
+
from docx.text.paragraph import Paragraph
|
|
39
|
+
|
|
40
|
+
SKILL_ROOT = Path(__file__).resolve().parent.parent
|
|
41
|
+
TONALITIES = ("Report", "Brief", "Manual", "Proposal", "Memo", "Journal")
|
|
42
|
+
NUMBERING = ("none", "decimal", "roman-ko")
|
|
43
|
+
TITLE_BLOCKS = ("masthead", "cover", "memo", "manual")
|
|
44
|
+
RUNNING_ITEMS = ("title", "folio", "section")
|
|
45
|
+
TABLE_STYLES = ("booktabs", "banded", "light-grid", "header-fill") # caption attributes; every tonality draws booktabs
|
|
46
|
+
COVERS = ("typographic", "band", "split", "masthead") # ::: cover variant= values the source may name
|
|
47
|
+
KINDS = ("callout", "sidebar", "pullquote", "keyfigures", "columns")
|
|
48
|
+
CALLOUT_KINDS = ("note", "key", "warning")
|
|
49
|
+
CALLOUT_PRIORITY = {"key": 0, "warning": 1, "note": 2}
|
|
50
|
+
NOTICE = ("A21B12", "FDECEA") # the plain and publisher profiles' example-data tag: ink on fill
|
|
51
|
+
LABELS = {
|
|
52
|
+
"ko": {"note": "참고", "key": "핵심", "warning": "주의", "abstract": "초록", "keywords": "주요어", "contents": "목차",
|
|
53
|
+
"memo": "메모", "to": "받는 사람", "from": "보내는 사람", "date": "날짜", "subject": "제목", "unit": "단위",
|
|
54
|
+
"doc": "문서", "owner": "작성 부서", "org": "기관", "issued": "시행일"},
|
|
55
|
+
"en": {"note": "Note", "key": "Key point", "warning": "Warning", "abstract": "Abstract", "keywords": "Keywords", "contents": "Contents",
|
|
56
|
+
"memo": "Memo", "to": "To", "from": "From", "date": "Date", "subject": "Subject", "unit": "Unit",
|
|
57
|
+
"doc": "Document", "owner": "Owner", "org": "Organisation", "issued": "Issued"},
|
|
58
|
+
}
|
|
59
|
+
PACK_KEYS = {"schema_version", "tonality", "summary", "dials", "docx", "design"}
|
|
60
|
+
DESIGN_KEYS = {"ramp", "numbering", "h1_rule", "title_block", "contents", "summary_form", "conclusion_first", "running_head", "justify",
|
|
61
|
+
"palette", "accent_on", "page_geometry", "components", "figure_style", "fill", "spacing"}
|
|
62
|
+
ACCENT_PLACES = ("title_rule", "callout_rule", "sidebar_rule")
|
|
63
|
+
DIRECTIVE_KEYS = {
|
|
64
|
+
"cover": {"variant", "image", "kicker"},
|
|
65
|
+
"callout": {"kind", "title"},
|
|
66
|
+
"sidebar": {"title", "width", "float"},
|
|
67
|
+
"pullquote": {"cite"},
|
|
68
|
+
"keyfigures": {"cols"},
|
|
69
|
+
"columns": {"gap", "rule"},
|
|
70
|
+
}
|
|
71
|
+
SOURCE_LINE = re.compile(r"^(출처|자료|주|Source|Sources|Note|Notes)\s*[::]")
|
|
72
|
+
CAPTION_ATTR = re.compile(r"^((?:표|Table)[ \t]*\d+\..*?)[ \t]*\{style=([a-z-]+)\}[ \t]*$", re.M)
|
|
73
|
+
TABLE_CAPTION = re.compile(r"^(?:표|Table)\s*\d+[.:]|^<표\s*\d+>")
|
|
74
|
+
FIGURE_LABEL = re.compile(r"^((?:Figure|Fig\.|그림|도)\s*\d+[.:])\s*")
|
|
75
|
+
TOTALS = re.compile(r"^(합계|총계|소계|계|total|sum)(?![\w])", re.I)
|
|
76
|
+
ISO_DATE = re.compile(r"(?<![\d-])((?:19|20)\d{2})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])(?![\d-])")
|
|
77
|
+
BACK_MATTER = re.compile(r"^(references|bibliography|acknowledg(e)?ments?|참고\s*문헌|감사의\s*글)$", re.I)
|
|
78
|
+
ROMAN = "ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ"
|
|
79
|
+
# A single line in em, measured on rendered pages (Word 1.55 / LibreOffice 1.51 for Pretendard; the mean is used).
|
|
80
|
+
SINGLE_EM = {"Pretendard": 1.53, "Times New Roman": 1.15}
|
|
81
|
+
GANADA = "가나다라마바사아자차카타파하"
|
|
82
|
+
CIRCLED = "①②③④⑤"
|
|
83
|
+
MONTHS = ("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DesignError(ValueError):
|
|
87
|
+
"""A pack, dial or directive the engine cannot honour; the message names what is allowed."""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Packs and dials ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def _merge(base: dict, over: dict) -> dict:
|
|
93
|
+
out = copy.deepcopy(base)
|
|
94
|
+
for key, value in (over or {}).items():
|
|
95
|
+
out[key] = _merge(out[key], value) if isinstance(value, dict) and isinstance(out.get(key), dict) else copy.deepcopy(value)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _half(x: float) -> float:
|
|
100
|
+
return math.floor(x * 2 + 0.5) / 2
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Margins (top, bottom, left, right) in mm by density: sides never under 25 mm (a text block of at most
|
|
104
|
+
# 160 mm on A4) and the bottom never under the top.
|
|
105
|
+
MARGINS = [(2, (30, 32, 30, 30)), (4, (28, 30, 28, 28)), (6, (27, 29, 27, 27)), (8, (26, 28, 26, 26)), (9, (25, 27, 25, 25)), (10, (24, 26, 25, 25))]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class Tonality:
|
|
110
|
+
name: str
|
|
111
|
+
dials: dict
|
|
112
|
+
design: dict
|
|
113
|
+
docx: dict
|
|
114
|
+
locale: str = "ko"
|
|
115
|
+
neutral: bool = False
|
|
116
|
+
palette: dict = field(default_factory=dict)
|
|
117
|
+
|
|
118
|
+
# Derived tokens; the converter reads these and never a number of its own.
|
|
119
|
+
@property
|
|
120
|
+
def density(self) -> int:
|
|
121
|
+
return self.dials["density"]
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def variance(self) -> int:
|
|
125
|
+
return self.dials["variance"]
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def margins_mm(self) -> tuple:
|
|
129
|
+
return next(m for d, m in MARGINS if self.density <= d)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def body_pt(self) -> float:
|
|
133
|
+
font = self.docx.get("font", {})
|
|
134
|
+
b = float(font.get("body_pt_en" if self.locale == "en" else "body_pt_ko", 10.5))
|
|
135
|
+
b += 0.5 if self.density <= 3 else 0
|
|
136
|
+
return max(10.5, b) # the readable floor: body text never under 10.5 pt
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def tight(self) -> bool:
|
|
140
|
+
"""A pack that keeps a short document on one page (Memo): the low end of the line pitches, tighter heading space."""
|
|
141
|
+
return self.design.get("spacing") == "tight"
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def pitch(self) -> float:
|
|
145
|
+
"""Body line pitch over the body size: Hangul 175-185 %, Latin 130-140 %; a tight pack 165 % / 120 %."""
|
|
146
|
+
en = self.locale == "en"
|
|
147
|
+
if self.tight:
|
|
148
|
+
return 1.2 if en else 1.65
|
|
149
|
+
return (1.4 if en else 1.85) if self.density <= 4 else (1.37 if en else 1.8) if self.density <= 7 else (1.33 if en else 1.75)
|
|
150
|
+
|
|
151
|
+
def line(self, pitch: float, face: str | None = None) -> float:
|
|
152
|
+
"""The Word "multiple" that sets a line pitch of `pitch` times the size in the given face (the body face
|
|
153
|
+
when omitted). A single line is not 1.0 em: measured on rendered pages, Pretendard's is 1.55 em in Word
|
|
154
|
+
and 1.51 em in LibreOffice (task-12b/leading-calibration.txt), Times New Roman's about 1.15 em."""
|
|
155
|
+
face = face or self.fonts["latin" if self.locale == "en" else "hangul"]
|
|
156
|
+
return round(pitch / SINGLE_EM.get(face, SINGLE_EM["Pretendard"]), 3)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def leading(self) -> float:
|
|
160
|
+
"""Body line spacing as a Word multiple (the pitch over the face's single line)."""
|
|
161
|
+
return self.line(self.pitch)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def unit(self) -> float:
|
|
165
|
+
return self.body_pt * self.pitch
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def sizes(self) -> dict:
|
|
169
|
+
b, ramp = self.body_pt, self.design.get("ramp") or {}
|
|
170
|
+
return {"h1": _half(b * float(ramp.get("h1", 1.4))), "h2": _half(b * float(ramp.get("h2", 1.2))), "h3": b, "h4": b,
|
|
171
|
+
"title": _half(b * float(ramp.get("title", 2.5)))}
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def table_pt(self) -> float:
|
|
175
|
+
return max(9.0, _half(self.body_pt * 0.92))
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def note_pt(self) -> float:
|
|
179
|
+
return max(8.5, self.table_pt - 0.5)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def furniture_pt(self) -> float:
|
|
183
|
+
return max(8.5, _half(self.body_pt * 0.82))
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def keyfigure_pt(self) -> float:
|
|
187
|
+
# A key figure is one modest step above the body, never a display number.
|
|
188
|
+
return self.sizes["h2"]
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def numbering(self) -> str:
|
|
192
|
+
n = self.design.get("numbering") or "none"
|
|
193
|
+
if isinstance(n, dict):
|
|
194
|
+
n = n.get(self.locale) or n.get("default") or "none"
|
|
195
|
+
return n
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def title_block(self) -> str:
|
|
199
|
+
return self.design.get("title_block") or "masthead"
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def running_head(self) -> dict:
|
|
203
|
+
return self.design.get("running_head") or {}
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def columns(self) -> int:
|
|
207
|
+
return int((self.design.get("page_geometry") or {}).get("columns", 1) or 1)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def column_gap_mm(self) -> float:
|
|
211
|
+
return float((self.design.get("page_geometry") or {}).get("column_gap_cm", 0.6)) * 10
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def components(self) -> dict:
|
|
215
|
+
return self.design.get("components") or {}
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def allowed_kinds(self) -> list:
|
|
219
|
+
return list(self.components.get("allowed") or [])
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def justify(self) -> bool:
|
|
223
|
+
j = self.design.get("justify", "ko")
|
|
224
|
+
return j == "all" or (j == "ko" and self.locale != "en")
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def fonts(self) -> dict:
|
|
228
|
+
f = self.docx.get("font", {})
|
|
229
|
+
return {"latin": f.get("latin", "Pretendard"), "hangul": f.get("hangul", "Pretendard"), "heading": f.get("heading", "Pretendard")}
|
|
230
|
+
|
|
231
|
+
def colour(self, role: str) -> str:
|
|
232
|
+
return self.palette[role]
|
|
233
|
+
|
|
234
|
+
def accent_for(self, place: str) -> str:
|
|
235
|
+
"""The accent on the places the pack names (at most two element kinds), ink everywhere else."""
|
|
236
|
+
return self.palette["accent"] if place in (self.design.get("accent_on") or []) else self.palette["ink"]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _registry_base(registry: Path | None = None) -> dict:
|
|
240
|
+
path = registry or SKILL_ROOT / "templates" / "registry.yaml"
|
|
241
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
242
|
+
return copy.deepcopy(data["publishers"]["korean-generic"]["design"])
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _check(value, allowed, what):
|
|
246
|
+
if value not in allowed:
|
|
247
|
+
raise DesignError(f"{what} {value!r} is not one of: {', '.join(allowed)}")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load_tonality(name: str, density: int | None = None, variance: int | None = None, locale: str = "ko", registry: Path | None = None) -> Tonality:
|
|
251
|
+
"""Load a pack by name (any case), merge it over the korean-generic design tree and apply the dials."""
|
|
252
|
+
match = next((t for t in TONALITIES if t.lower() == str(name).strip().lower()), None)
|
|
253
|
+
if not match:
|
|
254
|
+
raise DesignError(f"unknown tonality {name!r}; the tonalities are {', '.join(sorted(TONALITIES))}")
|
|
255
|
+
pack = yaml.safe_load((SKILL_ROOT / "templates" / "tonalities" / f"{match.lower()}.yaml").read_text(encoding="utf-8"))
|
|
256
|
+
unknown = set(pack) - PACK_KEYS
|
|
257
|
+
if unknown:
|
|
258
|
+
raise DesignError(f"{match}: unknown pack keys {sorted(unknown)}; allowed {sorted(PACK_KEYS)}")
|
|
259
|
+
unknown = set(pack.get("design") or {}) - DESIGN_KEYS
|
|
260
|
+
if unknown:
|
|
261
|
+
raise DesignError(f"{match}: unknown design keys {sorted(unknown)}; allowed {sorted(DESIGN_KEYS)}")
|
|
262
|
+
dials = dict(pack.get("dials") or {})
|
|
263
|
+
for key, value in (("density", density), ("variance", variance)):
|
|
264
|
+
if value is not None:
|
|
265
|
+
dials[key] = value
|
|
266
|
+
if not isinstance(dials.get(key), int) or not 1 <= dials[key] <= 10:
|
|
267
|
+
raise DesignError(f"{match}: dial {key} must be an integer from 1 to 10, got {dials.get(key)!r}")
|
|
268
|
+
design = _merge(_registry_base(registry), pack.get("design") or {})
|
|
269
|
+
numbering = design.get("numbering") or "none"
|
|
270
|
+
for value in (numbering.values() if isinstance(numbering, dict) else [numbering]):
|
|
271
|
+
_check(value, NUMBERING, f"{match} numbering")
|
|
272
|
+
_check(design.get("title_block", "masthead"), TITLE_BLOCKS, f"{match} title_block")
|
|
273
|
+
_check(design.get("h1_rule", "none"), ("none", "above"), f"{match} h1_rule")
|
|
274
|
+
_check(design.get("spacing", "normal"), ("normal", "tight"), f"{match} spacing")
|
|
275
|
+
for where, items in (design.get("running_head") or {}).items():
|
|
276
|
+
_check(where, ("header", "footer", "folio_align"), f"{match} running_head key")
|
|
277
|
+
if where != "folio_align":
|
|
278
|
+
for item in items:
|
|
279
|
+
_check(item, RUNNING_ITEMS, f"{match} running_head.{where}")
|
|
280
|
+
for place in design.get("accent_on") or []:
|
|
281
|
+
_check(place, ACCENT_PLACES, f"{match} accent_on")
|
|
282
|
+
if len(design.get("accent_on") or []) > 2:
|
|
283
|
+
raise DesignError(f"{match}: the accent marks at most two element kinds, got {design['accent_on']}")
|
|
284
|
+
for kind in (design.get("components") or {}).get("allowed") or []:
|
|
285
|
+
_check(kind, KINDS, f"{match} components.allowed")
|
|
286
|
+
if "pullquote" in ((design.get("components") or {}).get("allowed") or []):
|
|
287
|
+
raise DesignError(f"{match}: pull quotes are off in every tonality")
|
|
288
|
+
pal = design.get("palette") or {}
|
|
289
|
+
palette = {"ink": str(pal.get("ink", "1A1A1A")).lstrip("#").upper(), "ink_muted": str(pal.get("ink_muted", "555555")).lstrip("#").upper(),
|
|
290
|
+
"line": str(pal.get("line", "8C8C8C")).lstrip("#").upper()}
|
|
291
|
+
palette["accent"] = str(pal["accent"]).lstrip("#").upper() if pal.get("accent") else palette["ink"]
|
|
292
|
+
return Tonality(match, dials, design, dict(pack.get("docx") or {}), locale, False, palette)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def neutral_tonality(locale: str = "ko") -> Tonality:
|
|
296
|
+
"""The plain profile's tokens: ink and line only, booktabs tables, plain headings, notice-only header."""
|
|
297
|
+
design = _registry_base()
|
|
298
|
+
design.update({
|
|
299
|
+
"ramp": {"h1": 1.4, "h2": 1.2, "title": 2.1},
|
|
300
|
+
"numbering": "none",
|
|
301
|
+
"title_block": "masthead",
|
|
302
|
+
"figure_style": {"max_height_ratio": 0.45},
|
|
303
|
+
"page_geometry": {"columns": 1, "column_gap_cm": 0.6},
|
|
304
|
+
"components": {"allowed": [k for k in KINDS if k != "pullquote"], "keyfigures": {"per_row": 3}, "sidebar": {"default_width": "third", "float": "right"}},
|
|
305
|
+
"justify": "none",
|
|
306
|
+
})
|
|
307
|
+
palette = {"ink": "1A1A1A", "ink_muted": "555555", "line": "8C8C8C", "accent": "1A1A1A"}
|
|
308
|
+
docx_cfg = {"font": {"latin": "Pretendard", "hangul": "Pretendard", "heading": "Pretendard", "body_pt_ko": 10.5, "body_pt_en": 10.5}}
|
|
309
|
+
return Tonality("plain", {"density": 7, "variance": 10}, design, docx_cfg, locale, True, palette)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def format_date(value, lang: str) -> str:
|
|
313
|
+
"""A frontmatter or body date as a reader writes it: 2026. 10. 1. in Korean, 1 October 2026 in English
|
|
314
|
+
(no ISO dates in Korean text)."""
|
|
315
|
+
if isinstance(value, (_dt.date, _dt.datetime)):
|
|
316
|
+
y, m, d = value.year, value.month, value.day
|
|
317
|
+
else:
|
|
318
|
+
found = ISO_DATE.fullmatch(str(value).strip())
|
|
319
|
+
if not found:
|
|
320
|
+
return str(value)
|
|
321
|
+
y, m, d = (int(x) for x in found.groups())
|
|
322
|
+
return f"{y}. {m}. {d}." if lang != "en" else f"{d} {MONTHS[m - 1]} {y}"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def korean_dates(text: str) -> str:
|
|
326
|
+
"""ISO dates in Korean Markdown, written the Korean way; a date that opens a line escapes its first full stop,
|
|
327
|
+
or Markdown would read "2026. 6. 30." as a numbered list."""
|
|
328
|
+
def korean(m):
|
|
329
|
+
escape = "\\" if m.start() == 0 or text[m.start() - 1] == "\n" else ""
|
|
330
|
+
return f"{int(m.group(1))}{escape}. {int(m.group(2))}. {int(m.group(3))}."
|
|
331
|
+
return ISO_DATE.sub(korean, text)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ── Directive pre-pass ──────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
@dataclass
|
|
337
|
+
class Directive:
|
|
338
|
+
name: str
|
|
339
|
+
args: list
|
|
340
|
+
attrs: dict
|
|
341
|
+
body: str
|
|
342
|
+
line: int
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
_OPEN = re.compile(r"^(:{3,})[ \t]+([A-Za-z]+)(.*)$")
|
|
346
|
+
_ARG = re.compile(r'\s*([a-z_]+)=("(?:[^"\\]|\\")*"|[A-Za-z0-9_.%-]+)|\s*([A-Za-z0-9_.%-]+)')
|
|
347
|
+
COLUMN_BREAK = re.compile(r"^\s*<!--\s*column-break\s*-->\s*$", re.M)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _parse_args(name: str, rest: str, line: int) -> tuple[list, dict]:
|
|
351
|
+
args, attrs, pos = [], {}, 0
|
|
352
|
+
rest = rest.rstrip()
|
|
353
|
+
while pos < len(rest):
|
|
354
|
+
m = _ARG.match(rest, pos)
|
|
355
|
+
if not m or m.end() == pos:
|
|
356
|
+
raise DesignError(f"line {line}: cannot read the attributes of ::: {name}: {rest[pos:]!r}")
|
|
357
|
+
if m.group(1):
|
|
358
|
+
key, value = m.group(1), m.group(2)
|
|
359
|
+
if key in attrs:
|
|
360
|
+
raise DesignError(f"line {line}: ::: {name} repeats {key}=")
|
|
361
|
+
if key not in DIRECTIVE_KEYS[name]:
|
|
362
|
+
raise DesignError(f"line {line}: ::: {name} has no attribute {key}; it takes {', '.join(sorted(DIRECTIVE_KEYS[name]))}")
|
|
363
|
+
attrs[key] = value[1:-1].replace('\\"', '"') if value.startswith('"') else value
|
|
364
|
+
else:
|
|
365
|
+
args.append(m.group(3))
|
|
366
|
+
pos = m.end()
|
|
367
|
+
return args, attrs
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def split_directives(text: str, offset: int = 0) -> list:
|
|
371
|
+
"""The source as a list of Markdown strings and Directive blocks, in order. Fenced code stays text."""
|
|
372
|
+
lines = text.split("\n")
|
|
373
|
+
out, buf, i, code = [], [], 0, False
|
|
374
|
+
while i < len(lines):
|
|
375
|
+
line = lines[i]
|
|
376
|
+
if line.lstrip().startswith(("```", "~~~")):
|
|
377
|
+
code = not code
|
|
378
|
+
m = None if code else _OPEN.match(line)
|
|
379
|
+
if m:
|
|
380
|
+
fence, name = m.group(1), m.group(2)
|
|
381
|
+
if name not in DIRECTIVE_KEYS:
|
|
382
|
+
raise DesignError(f"line {offset + i + 1}: unknown directive ::: {name}; directives are {', '.join(DIRECTIVE_KEYS)}")
|
|
383
|
+
j = i + 1
|
|
384
|
+
while j < len(lines) and lines[j].strip() != fence:
|
|
385
|
+
j += 1
|
|
386
|
+
if j == len(lines):
|
|
387
|
+
raise DesignError(f"line {offset + i + 1}: ::: {name} is never closed (a line of {len(fence)} colons)")
|
|
388
|
+
if buf:
|
|
389
|
+
out.append("\n".join(buf))
|
|
390
|
+
buf = []
|
|
391
|
+
args, attrs = _parse_args(name, m.group(3), offset + i + 1)
|
|
392
|
+
out.append(Directive(name, args, attrs, "\n".join(lines[i + 1:j]), offset + i + 2))
|
|
393
|
+
i = j + 1
|
|
394
|
+
continue
|
|
395
|
+
if not code and re.match(r"^:{3,}\s*$", line):
|
|
396
|
+
raise DesignError(f"line {offset + i + 1}: a closing fence with no directive open")
|
|
397
|
+
buf.append(line)
|
|
398
|
+
i += 1
|
|
399
|
+
if buf:
|
|
400
|
+
out.append("\n".join(buf))
|
|
401
|
+
return out
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def has_directives(text: str) -> bool:
|
|
405
|
+
return any(isinstance(b, Directive) for b in split_directives(text)) or bool(COLUMN_BREAK.search(text))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ── XML helpers ─────────────────────────────────────────────────────────────
|
|
409
|
+
|
|
410
|
+
PPR_ORDER = ["pStyle", "keepNext", "keepLines", "pageBreakBefore", "framePr", "widowControl", "numPr", "suppressLineNumbers", "pBdr", "shd",
|
|
411
|
+
"tabs", "suppressAutoHyphens", "kinsoku", "wordWrap", "overflowPunct", "topLinePunct", "autoSpaceDE", "autoSpaceDN", "bidi",
|
|
412
|
+
"adjustRightInd", "snapToGrid", "spacing", "ind", "contextualSpacing", "mirrorIndents", "suppressOverlap", "jc", "textDirection",
|
|
413
|
+
"textAlignment", "textboxTightWrap", "outlineLvl", "divId", "cnfStyle", "rPr", "sectPr", "pPrChange"]
|
|
414
|
+
TBLPR_ORDER = ["tblStyle", "tblpPr", "tblOverlap", "bidiVisual", "tblStyleRowBandSize", "tblStyleColBandSize", "tblW", "jc", "tblCellSpacing",
|
|
415
|
+
"tblInd", "tblBorders", "shd", "tblLayout", "tblCellMar", "tblLook", "tblCaption", "tblDescription"]
|
|
416
|
+
TCPR_ORDER = ["cnfStyle", "tcW", "gridSpan", "hMerge", "vMerge", "tcBorders", "shd", "noWrap", "tcMar", "textDirection", "tcFitText", "vAlign", "hideMark"]
|
|
417
|
+
RPR_ORDER = ["rStyle", "rFonts", "b", "bCs", "i", "iCs", "caps", "smallCaps", "strike", "dstrike", "outline", "shadow", "emboss", "imprint",
|
|
418
|
+
"noProof", "snapToGrid", "vanish", "webHidden", "color", "spacing", "w", "kern", "position", "sz", "szCs", "highlight", "u",
|
|
419
|
+
"effect", "bdr", "shd", "fitText", "vertAlign", "rtl", "cs", "em", "lang", "eastAsianLayout", "specVanish", "oMath"]
|
|
420
|
+
SECTPR_ORDER = ["headerReference", "footerReference", "footnotePr", "endnotePr", "type", "pgSz", "pgMar", "paperSrc", "pgBorders", "lnNumType",
|
|
421
|
+
"pgNumType", "cols", "formProt", "vAlign", "noEndnote", "titlePg", "textDirection", "bidi", "rtlGutter", "docGrid", "printerSettings"]
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _el(tag: str, **attrs):
|
|
425
|
+
el = OxmlElement(f"w:{tag}")
|
|
426
|
+
for key, value in attrs.items():
|
|
427
|
+
el.set(qn(f"w:{key}"), str(value))
|
|
428
|
+
return el
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _put(parent, child, order):
|
|
432
|
+
"""Insert child into parent in schema order, replacing an element of the same tag."""
|
|
433
|
+
for old in parent.findall(child.tag):
|
|
434
|
+
parent.remove(old)
|
|
435
|
+
names = [qn(f"w:{n}") for n in order]
|
|
436
|
+
later = set(names[names.index(child.tag) + 1:]) if child.tag in names else set()
|
|
437
|
+
for el in parent:
|
|
438
|
+
if el.tag in later:
|
|
439
|
+
el.addprevious(child)
|
|
440
|
+
return child
|
|
441
|
+
parent.append(child)
|
|
442
|
+
return child
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _borders(tag: str, sides: dict, order=None):
|
|
446
|
+
box = _el(tag)
|
|
447
|
+
for side in order or ("top", "left", "bottom", "right", "insideH", "insideV"):
|
|
448
|
+
if side in sides:
|
|
449
|
+
spec = sides[side]
|
|
450
|
+
if spec is None:
|
|
451
|
+
box.append(_el(side, val="nil"))
|
|
452
|
+
else:
|
|
453
|
+
sz, colour = spec[:2]
|
|
454
|
+
space = spec[2] if len(spec) > 2 else 0
|
|
455
|
+
box.append(_el(side, val="single", sz=int(sz), space=space, color=colour))
|
|
456
|
+
return box
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def para_border(p: Paragraph, sides: dict):
|
|
460
|
+
_put(p._p.get_or_add_pPr(), _borders("pBdr", sides, ("top", "left", "bottom", "right", "between")), PPR_ORDER)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def run_shade(run, fill: str):
|
|
464
|
+
_put(run._r.get_or_add_rPr(), _el("shd", val="clear", color="auto", fill=fill), RPR_ORDER)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def set_run_fonts(rpr, latin: str, hangul: str):
|
|
468
|
+
rf = rpr.find(qn("w:rFonts"))
|
|
469
|
+
if rf is None:
|
|
470
|
+
rf = _put(rpr, _el("rFonts"), RPR_ORDER)
|
|
471
|
+
for key in ("asciiTheme", "hAnsiTheme", "eastAsiaTheme", "cstheme", "hint"):
|
|
472
|
+
if rf.get(qn(f"w:{key}")) is not None:
|
|
473
|
+
del rf.attrib[qn(f"w:{key}")]
|
|
474
|
+
rf.set(qn("w:ascii"), latin)
|
|
475
|
+
rf.set(qn("w:hAnsi"), latin)
|
|
476
|
+
rf.set(qn("w:cs"), latin)
|
|
477
|
+
rf.set(qn("w:eastAsia"), hangul)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def styled_run(p: Paragraph, text: str, size: float | None = None, colour: str | None = None, bold: bool | None = None, font: tuple | None = None):
|
|
481
|
+
run = p.add_run(text)
|
|
482
|
+
if size:
|
|
483
|
+
run.font.size = Pt(size)
|
|
484
|
+
if colour:
|
|
485
|
+
run.font.color.rgb = RGBColor.from_string(colour)
|
|
486
|
+
if bold is not None:
|
|
487
|
+
run.bold = bold
|
|
488
|
+
if font:
|
|
489
|
+
set_run_fonts(run._r.get_or_add_rPr(), *font)
|
|
490
|
+
return run
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _advance(text: str, size: float) -> float:
|
|
494
|
+
"""Estimated width in points of bold display type: a Hangul syllable a full em, Latin by letter class."""
|
|
495
|
+
em = 0.0
|
|
496
|
+
for ch in text:
|
|
497
|
+
if "가" <= ch <= "힣" or "ㄱ" <= ch <= "ㆎ" or "一" <= ch <= "鿿":
|
|
498
|
+
em += 1.0
|
|
499
|
+
elif ch == " ":
|
|
500
|
+
em += 0.28
|
|
501
|
+
elif ch.isupper() or ch in "mwMW%&@":
|
|
502
|
+
em += 0.74
|
|
503
|
+
elif ch.isdigit():
|
|
504
|
+
em += 0.62
|
|
505
|
+
elif ch.isalpha():
|
|
506
|
+
em += 0.58
|
|
507
|
+
else:
|
|
508
|
+
em += 0.4
|
|
509
|
+
return em * size
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
TITLE_LINES = 3
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def wrap_words(text: str, size: float, width_pt: float) -> list[str]:
|
|
516
|
+
"""Break a display line between words only, into the fewest lines that fit the measure, with the
|
|
517
|
+
lines as even as that count allows (least squared slack); a line never ends inside a word (제/안서)."""
|
|
518
|
+
words = text.split()
|
|
519
|
+
if not words or _advance(text, size) <= width_pt:
|
|
520
|
+
return [" ".join(words)]
|
|
521
|
+
measure = lambda i, j: _advance(" ".join(words[i:j]), size) # noqa: E731
|
|
522
|
+
n = len(words)
|
|
523
|
+
for lines in range(2, n + 1):
|
|
524
|
+
best = {0: (0.0, [])} # words placed -> (squared slack so far, break positions)
|
|
525
|
+
for _ in range(lines):
|
|
526
|
+
nxt = {}
|
|
527
|
+
for i, (cost, cuts) in best.items():
|
|
528
|
+
for j in range(i + 1, n + 1):
|
|
529
|
+
w = measure(i, j)
|
|
530
|
+
if w > width_pt:
|
|
531
|
+
break
|
|
532
|
+
cand = (cost + (width_pt - w) ** 2, cuts + [j])
|
|
533
|
+
if j not in nxt or cand[0] < nxt[j][0]:
|
|
534
|
+
nxt[j] = cand
|
|
535
|
+
best = nxt
|
|
536
|
+
if n in best:
|
|
537
|
+
cuts = [0] + best[n][1]
|
|
538
|
+
return [" ".join(words[a:b]) for a, b in zip(cuts, cuts[1:])]
|
|
539
|
+
return [" ".join(words)]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def display_run(p: Paragraph, text: str, size: float, width_emu: int, colour: str, bold: bool, font: tuple):
|
|
543
|
+
"""A title at its size, broken between words for its measure in at most three lines; a word wider
|
|
544
|
+
than the measure, or a fourth line, steps the size down."""
|
|
545
|
+
width_pt = width_emu / 12700 * 0.94 # keep a margin for metric differences between renderers
|
|
546
|
+
words = str(text).split() or [""]
|
|
547
|
+
while size > 14 and (max(_advance(w, size) for w in words) > width_pt or len(wrap_words(str(text), size, width_pt)) > TITLE_LINES):
|
|
548
|
+
size -= 1
|
|
549
|
+
lines = wrap_words(str(text), size, width_pt)
|
|
550
|
+
for i, line in enumerate(lines):
|
|
551
|
+
last = i == len(lines) - 1
|
|
552
|
+
run = styled_run(p, line if last else line + " ", size, colour, bold, font) # the space keeps the text whole
|
|
553
|
+
if not last:
|
|
554
|
+
run.add_break()
|
|
555
|
+
return size
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def add_field(p: Paragraph, instr: str, size: float, colour: str, placeholder: str = "1"):
|
|
559
|
+
fld = _el("fldSimple", instr=instr)
|
|
560
|
+
r = OxmlElement("w:r")
|
|
561
|
+
rpr = OxmlElement("w:rPr")
|
|
562
|
+
_put(rpr, _el("color", val=colour), RPR_ORDER)
|
|
563
|
+
_put(rpr, _el("sz", val=int(size * 2)), RPR_ORDER)
|
|
564
|
+
r.append(rpr)
|
|
565
|
+
t = OxmlElement("w:t")
|
|
566
|
+
t.text = placeholder
|
|
567
|
+
r.append(t)
|
|
568
|
+
fld.append(r)
|
|
569
|
+
p._p.append(fld)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def tiny(p: Paragraph):
|
|
573
|
+
"""A structural paragraph (section or column break carrier) that takes no visible height."""
|
|
574
|
+
pf = p.paragraph_format
|
|
575
|
+
pf.space_before = Pt(0)
|
|
576
|
+
pf.space_after = Pt(0)
|
|
577
|
+
pf.line_spacing = Pt(1)
|
|
578
|
+
rpr = p._p.get_or_add_pPr()
|
|
579
|
+
mark = _put(rpr, _el("rPr"), PPR_ORDER)
|
|
580
|
+
mark.append(_el("sz", val=2))
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def tbl_set(table, tag: str, el):
|
|
584
|
+
tblPr = table._tbl.tblPr
|
|
585
|
+
_put(tblPr, el, TBLPR_ORDER)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def cell_set(cell, el):
|
|
589
|
+
_put(cell._tc.get_or_add_tcPr(), el, TCPR_ORDER)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def cell_margins(table, top: float, bottom: float, left: float, right: float):
|
|
593
|
+
mar = _el("tblCellMar")
|
|
594
|
+
for side, v in (("top", top), ("left", left), ("bottom", bottom), ("right", right)):
|
|
595
|
+
mar.append(_el(side, w=int(v * 20), type="dxa"))
|
|
596
|
+
tbl_set(table, "tblCellMar", mar)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def mark_component(table, kind: str):
|
|
600
|
+
tbl_set(table, "tblCaption", _el("tblCaption", val=kind))
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def table_width(table, width_emu: int, widths: list | None = None):
|
|
604
|
+
tbl_set(table, "tblW", _el("tblW", w=int(width_emu / 635), type="dxa"))
|
|
605
|
+
tbl_set(table, "tblLayout", _el("tblLayout", type="fixed"))
|
|
606
|
+
table.autofit = False
|
|
607
|
+
cols = len(table.columns)
|
|
608
|
+
widths = widths or [width_emu // cols] * cols
|
|
609
|
+
for ci, column in enumerate(table.columns):
|
|
610
|
+
column.width = Emu(widths[ci])
|
|
611
|
+
for row in table.rows:
|
|
612
|
+
for ci, cell in enumerate(row.cells):
|
|
613
|
+
cell.width = Emu(widths[ci])
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def no_borders(table):
|
|
617
|
+
tbl_set(table, "tblBorders", _borders("tblBorders", {s: None for s in ("top", "left", "bottom", "right", "insideH", "insideV")}))
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
# ── Containers ──────────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
class _FrameSection:
|
|
623
|
+
"""Stands in for a section where shape_table reads the usable width (a cell or a column)."""
|
|
624
|
+
|
|
625
|
+
def __init__(self, width: int):
|
|
626
|
+
self.page_width, self.left_margin, self.right_margin = Emu(width), Emu(0), Emu(0)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class Box:
|
|
630
|
+
"""A table cell that html_to_docx can write into as it writes into a document."""
|
|
631
|
+
|
|
632
|
+
def __init__(self, cell, width: int, doc):
|
|
633
|
+
self.cell, self.width, self.doc = cell, width, doc
|
|
634
|
+
self.sections = [_FrameSection(width)]
|
|
635
|
+
self.styles = doc.styles
|
|
636
|
+
self._fresh = True
|
|
637
|
+
|
|
638
|
+
def add_paragraph(self, text: str = "", style=None):
|
|
639
|
+
if self._fresh and len(self.cell.paragraphs) == 1 and not self.cell.paragraphs[0].text and not len(self.cell.tables):
|
|
640
|
+
p = self.cell.paragraphs[0]
|
|
641
|
+
if text:
|
|
642
|
+
p.add_run(text)
|
|
643
|
+
if style:
|
|
644
|
+
p.style = style
|
|
645
|
+
else:
|
|
646
|
+
p = self.cell.add_paragraph(text, style)
|
|
647
|
+
self._fresh = False
|
|
648
|
+
return p
|
|
649
|
+
|
|
650
|
+
def add_heading(self, text: str = "", level: int = 1):
|
|
651
|
+
return self.add_paragraph(text, f"Heading {level}")
|
|
652
|
+
|
|
653
|
+
def add_table(self, rows: int, cols: int):
|
|
654
|
+
self._fresh = False
|
|
655
|
+
return self.cell.add_table(rows, cols)
|
|
656
|
+
|
|
657
|
+
@property
|
|
658
|
+
def paragraphs(self):
|
|
659
|
+
return self.cell.paragraphs
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def frame_width(doc) -> int:
|
|
663
|
+
s = doc.sections[-1]
|
|
664
|
+
return int(s.page_width - s.left_margin - s.right_margin)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def frame_height(doc) -> int:
|
|
668
|
+
s = doc.sections[-1]
|
|
669
|
+
return int(s.page_height - s.top_margin - s.bottom_margin)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def span(doc, n: int, of: int = 12, width: int | None = None) -> int:
|
|
673
|
+
"""n of 12 grid columns with a 4 mm gutter."""
|
|
674
|
+
w = width or frame_width(doc)
|
|
675
|
+
gutter = Mm(4)
|
|
676
|
+
col = (w - (of - 1) * gutter) / of
|
|
677
|
+
return int(n * col + (n - 1) * gutter)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# ── Document setup: page, styles, fonts ─────────────────────────────────────
|
|
681
|
+
|
|
682
|
+
def set_page(section, T: Tonality):
|
|
683
|
+
size = ((T.docx.get("page") or {}).get("size", "A4"))
|
|
684
|
+
section.page_width, section.page_height = (Mm(215.9), Mm(279.4)) if size == "Letter" else (Mm(210), Mm(297))
|
|
685
|
+
top, bottom, left, right = T.margins_mm
|
|
686
|
+
section.top_margin, section.bottom_margin, section.left_margin, section.right_margin = Mm(top), Mm(bottom), Mm(left), Mm(right)
|
|
687
|
+
section.header_distance = section.footer_distance = Mm(min(12, top - 10))
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _style(doc, name: str, base: str | None = "Normal"):
|
|
691
|
+
from docx.enum.style import WD_STYLE_TYPE
|
|
692
|
+
try:
|
|
693
|
+
return doc.styles[name]
|
|
694
|
+
except KeyError:
|
|
695
|
+
st = doc.styles.add_style(name, WD_STYLE_TYPE.PARAGRAPH)
|
|
696
|
+
if base:
|
|
697
|
+
st.base_style = doc.styles[base]
|
|
698
|
+
return st
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _fmt(st, size=None, colour=None, bold=None, italic=False, before=None, after=None, line=None, keep=None, font=None):
|
|
702
|
+
if size:
|
|
703
|
+
st.font.size = Pt(size)
|
|
704
|
+
if colour:
|
|
705
|
+
st.font.color.rgb = RGBColor.from_string(colour)
|
|
706
|
+
if bold is not None:
|
|
707
|
+
st.font.bold = bold
|
|
708
|
+
st.font.italic = italic
|
|
709
|
+
pf = st.paragraph_format
|
|
710
|
+
if before is not None:
|
|
711
|
+
pf.space_before = Pt(before)
|
|
712
|
+
if after is not None:
|
|
713
|
+
pf.space_after = Pt(after)
|
|
714
|
+
if line is not None:
|
|
715
|
+
pf.line_spacing = line
|
|
716
|
+
if keep is not None:
|
|
717
|
+
pf.keep_with_next = keep
|
|
718
|
+
if font:
|
|
719
|
+
set_run_fonts(st.element.get_or_add_rPr(), *font)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def korean_line_breaking(doc):
|
|
723
|
+
"""Hangul wraps between words (어절) in Word: wordWrap on, kinsoku on, and the text marked Korean. The probe
|
|
724
|
+
through Word itself (task-12b/word-probe) broke between syllables with wordWrap 0 and between words with
|
|
725
|
+
wordWrap 1 or none; LibreOffice breaks between syllables whatever the paragraph says."""
|
|
726
|
+
ppr = doc.styles["Normal"].element.get_or_add_pPr()
|
|
727
|
+
_put(ppr, _el("kinsoku", val=1), PPR_ORDER)
|
|
728
|
+
_put(ppr, _el("wordWrap", val=1), PPR_ORDER)
|
|
729
|
+
rpr = doc.styles.element.find(qn("w:docDefaults")).find(qn("w:rPrDefault")).find(qn("w:rPr"))
|
|
730
|
+
lang = rpr.find(qn("w:lang"))
|
|
731
|
+
if lang is None:
|
|
732
|
+
lang = _put(rpr, _el("lang"), RPR_ORDER)
|
|
733
|
+
lang.set(qn("w:eastAsia"), "ko-KR")
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def setup_styles(doc, T: Tonality):
|
|
737
|
+
f = T.fonts
|
|
738
|
+
body_font = (f["latin"], f["hangul"])
|
|
739
|
+
head_font = (f["heading"], f["hangul"])
|
|
740
|
+
ink, muted = T.colour("ink"), T.colour("ink_muted")
|
|
741
|
+
after = (5 if T.locale != "en" else 4) if T.tight else (6 if T.locale != "en" else 5) # paragraphs part by space, never by space and an indent
|
|
742
|
+
normal = doc.styles["Normal"]
|
|
743
|
+
_fmt(normal, T.body_pt, ink, None, False, 0, after, T.leading, None, body_font)
|
|
744
|
+
normal.paragraph_format.widow_control = True
|
|
745
|
+
normal.paragraph_format.first_line_indent = Pt(0)
|
|
746
|
+
normal.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY if T.justify else WD_ALIGN_PARAGRAPH.LEFT
|
|
747
|
+
sizes = T.sizes
|
|
748
|
+
# Levels part by weight and space (space above about twice the space below), never by colour.
|
|
749
|
+
steps = ((1, 12, 5), (2, 10, 4), (3, 9, 3), (4, 8, 3)) if T.tight else ((1, 22, 9), (2, 15, 6), (3, 11, 4), (4, 9, 3))
|
|
750
|
+
for level, before, below in steps:
|
|
751
|
+
st = doc.styles[f"Heading {level}"]
|
|
752
|
+
_fmt(st, sizes[f"h{level}"], ink, True, False, before, below, T.line(1.3, f["heading"]), True, head_font)
|
|
753
|
+
st.paragraph_format.left_indent = Pt(0)
|
|
754
|
+
st.paragraph_format.first_line_indent = Pt(0)
|
|
755
|
+
st.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
756
|
+
if T.design.get("h1_rule") == "above":
|
|
757
|
+
# Brief: an unnumbered section opens under a full-width ink hairline instead of a number.
|
|
758
|
+
_put(doc.styles["Heading 1"].element.get_or_add_pPr(), _borders("pBdr", {"top": (4, ink, 6)}, ("top", "left", "bottom", "right", "between")), PPR_ORDER)
|
|
759
|
+
caption = _style(doc, "Caption")
|
|
760
|
+
_fmt(caption, T.table_pt, ink, False, False, 4, 4, T.line(1.4), None, body_font)
|
|
761
|
+
caption.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
762
|
+
note = _style(doc, "Source Note")
|
|
763
|
+
_fmt(note, T.note_pt, muted, False, False, 3, 8, T.line(1.4), None, body_font)
|
|
764
|
+
note.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
765
|
+
quote = _style(doc, "Quote")
|
|
766
|
+
_fmt(quote, T.body_pt, ink, False, False, 4, 6, T.leading, None, body_font)
|
|
767
|
+
quote.paragraph_format.left_indent = Mm(6)
|
|
768
|
+
for name in ("List Bullet", "List Number", "List Bullet 2", "List Number 2"):
|
|
769
|
+
try:
|
|
770
|
+
_fmt(doc.styles[name], T.body_pt, ink, None, False, 0, 3, T.leading, None, body_font)
|
|
771
|
+
doc.styles[name].paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
772
|
+
except KeyError:
|
|
773
|
+
pass
|
|
774
|
+
for name in ("Title", "Subtitle", "Intense Quote", "TOC Heading"):
|
|
775
|
+
try:
|
|
776
|
+
set_run_fonts(doc.styles[name].element.get_or_add_rPr(), *head_font)
|
|
777
|
+
except KeyError:
|
|
778
|
+
pass
|
|
779
|
+
# Every style the document carries is in ink, including the ones this build does not use (Heading 5-9,
|
|
780
|
+
# Title, Intense Quote): a reader who applies them later gets no colour either.
|
|
781
|
+
for el in doc.styles.element.iter(qn("w:color")):
|
|
782
|
+
el.set(qn("w:val"), ink)
|
|
783
|
+
for key in ("themeColor", "themeShade", "themeTint"):
|
|
784
|
+
if el.get(qn(f"w:{key}")) is not None:
|
|
785
|
+
del el.attrib[qn(f"w:{key}")]
|
|
786
|
+
declare_fonts(doc, body_font, head_font, T.locale)
|
|
787
|
+
if T.locale != "en":
|
|
788
|
+
korean_line_breaking(doc)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def setup_component_styles(doc, T: Tonality):
|
|
792
|
+
"""Only the styles the components need, for a document whose other styles belong to a publisher."""
|
|
793
|
+
_fmt(_style(doc, "Source Note"), T.note_pt, T.colour("ink_muted"), False, False, 3, 8, 1.2)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def declare_fonts(doc, body_font: tuple, head_font: tuple, locale: str = "en"):
|
|
797
|
+
"""Name the pack's faces everywhere a renderer looks: the document defaults, every style that
|
|
798
|
+
names a font or a theme font, the theme itself and the bullet glyphs of the numbering part.
|
|
799
|
+
Word's own defaults (Calibri, Cambria, Symbol, the theme's Hangul face) are what LibreOffice
|
|
800
|
+
replaced with Carlito, Caladea and Malgun Gothic before. Korean lists take a plain dash."""
|
|
801
|
+
styles = doc.styles.element
|
|
802
|
+
defaults = styles.find(qn("w:docDefaults"))
|
|
803
|
+
rpr = defaults.find(qn("w:rPrDefault")).find(qn("w:rPr"))
|
|
804
|
+
set_run_fonts(rpr, *body_font)
|
|
805
|
+
for st in styles.findall(qn("w:style")):
|
|
806
|
+
r = st.find(qn("w:rPr"))
|
|
807
|
+
rf = r.find(qn("w:rFonts")) if r is not None else None
|
|
808
|
+
if rf is None:
|
|
809
|
+
continue
|
|
810
|
+
if (rf.get(qn("w:ascii")) or "").startswith("Courier"):
|
|
811
|
+
set_run_fonts(r, "Courier New", body_font[1])
|
|
812
|
+
continue
|
|
813
|
+
heading = (st.get(qn("w:styleId")) or "").startswith(("Heading", "Title", "Subtitle"))
|
|
814
|
+
set_run_fonts(r, *(head_font if heading else body_font))
|
|
815
|
+
for part in doc.part.package.iter_parts():
|
|
816
|
+
if str(part.partname).startswith("/word/theme/"):
|
|
817
|
+
blob = part.blob.decode("utf-8")
|
|
818
|
+
blob = re.sub(r'(<a:(?:latin|ea|cs) typeface=")[^"]*(")', rf"\g<1>{body_font[1]}\g<2>", blob)
|
|
819
|
+
blob = re.sub(r'<a:font script="[^"]*" typeface="[^"]*"/>', "", blob)
|
|
820
|
+
part._blob = blob.encode("utf-8")
|
|
821
|
+
try:
|
|
822
|
+
numbering = doc.part.numbering_part.element
|
|
823
|
+
except Exception:
|
|
824
|
+
return
|
|
825
|
+
glyphs = ["–", "–", "–"] if locale != "en" else ["•", "–", "▪"]
|
|
826
|
+
for lvl in numbering.iter(qn("w:lvl")):
|
|
827
|
+
fmt = lvl.find(qn("w:numFmt"))
|
|
828
|
+
if fmt is not None and fmt.get(qn("w:val")) == "bullet":
|
|
829
|
+
level = int(lvl.get(qn("w:ilvl"), "0"))
|
|
830
|
+
lvl.find(qn("w:lvlText")).set(qn("w:val"), glyphs[level % 3])
|
|
831
|
+
r = lvl.find(qn("w:rPr"))
|
|
832
|
+
if r is None:
|
|
833
|
+
r = OxmlElement("w:rPr")
|
|
834
|
+
lvl.append(r)
|
|
835
|
+
set_run_fonts(r, body_font[1], body_font[1])
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
# ── Components ──────────────────────────────────────────────────────────────
|
|
839
|
+
|
|
840
|
+
def _strip_md(text: str) -> str:
|
|
841
|
+
return re.sub(r"[*_`]", "", text).strip()
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _inline(p: Paragraph, text: str, size=None, colour=None, bold=None):
|
|
845
|
+
"""Markdown bold (**…**) inside a short line, as runs."""
|
|
846
|
+
for i, part in enumerate(re.split(r"\*\*(.+?)\*\*", text)):
|
|
847
|
+
if part:
|
|
848
|
+
styled_run(p, part, size, colour, True if i % 2 else bold)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _with_basis(label: str, basis: list) -> str:
|
|
852
|
+
"""A key figure's label with its basis in brackets; a label that already ends in brackets takes the basis
|
|
853
|
+
inside them ("품목 (예시, 2026년 9월 기준)"), never a second pair."""
|
|
854
|
+
if not basis:
|
|
855
|
+
return label
|
|
856
|
+
if label.endswith(")"):
|
|
857
|
+
return f"{label[:-1]}, {', '.join(basis)})"
|
|
858
|
+
return f"{label} ({', '.join(basis)})"
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def keyfigure_items(body: str, line: int) -> list:
|
|
862
|
+
"""`- **figure** label` items; an indented `- …` under one is its basis (period and source)."""
|
|
863
|
+
items = []
|
|
864
|
+
for raw in body.split("\n"):
|
|
865
|
+
if not raw.strip():
|
|
866
|
+
continue
|
|
867
|
+
m = re.match(r"^(\s*)[-*]\s+(.*)$", raw)
|
|
868
|
+
if not m:
|
|
869
|
+
raise DesignError(f"line {line}: ::: keyfigures holds one list; {raw.strip()!r} is not a list item")
|
|
870
|
+
if m.group(1) and items:
|
|
871
|
+
items[-1]["basis"].append(m.group(2).strip())
|
|
872
|
+
continue
|
|
873
|
+
fm = re.match(r"^\*\*(.+?)\*\*\s*(.*)$", m.group(2))
|
|
874
|
+
if not fm or not re.search(r"\d", fm.group(1)):
|
|
875
|
+
raise DesignError(f"line {line}: a key figure is `**<figure with a number>** <label>`, got {m.group(2)!r}")
|
|
876
|
+
items.append({"figure": fm.group(1), "label": fm.group(2), "basis": []})
|
|
877
|
+
if not 1 <= len(items) <= 8:
|
|
878
|
+
raise DesignError(f"line {line}: ::: keyfigures takes 1 to 8 items, got {len(items)}")
|
|
879
|
+
return items
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def add_keyfigures(ctx, container, d: Directive, width: int):
|
|
883
|
+
"""One row of figures over a hairline in ink: the figure one step above the body, its label, its basis."""
|
|
884
|
+
T = ctx.T
|
|
885
|
+
items = keyfigure_items(d.body, d.line)
|
|
886
|
+
cfg = T.components.get("keyfigures") or {}
|
|
887
|
+
cols = min(len(items), int(d.attrs.get("cols") or cfg.get("per_row", 3)), 4)
|
|
888
|
+
rows = math.ceil(len(items) / cols)
|
|
889
|
+
table = container.add_table(rows, cols)
|
|
890
|
+
mark_component(table, "keyfigures")
|
|
891
|
+
no_borders(table)
|
|
892
|
+
table_width(table, width)
|
|
893
|
+
cell_margins(table, 5, 4, 0, 10)
|
|
894
|
+
for row in table.rows:
|
|
895
|
+
row._tr.get_or_add_trPr().append(_el("cantSplit"))
|
|
896
|
+
for i, item in enumerate(items):
|
|
897
|
+
cell = table.cell(i // cols, i % cols)
|
|
898
|
+
cell_set(cell, _borders("tcBorders", {"top": (4, T.colour("ink"), 0)}))
|
|
899
|
+
p = cell.paragraphs[0]
|
|
900
|
+
p.paragraph_format.space_after = Pt(1)
|
|
901
|
+
p.paragraph_format.line_spacing = T.line(1.2, "Pretendard")
|
|
902
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
903
|
+
styled_run(p, _strip_md(item["figure"]), T.keyfigure_pt, T.colour("ink"), True)
|
|
904
|
+
lp = cell.add_paragraph()
|
|
905
|
+
lp.paragraph_format.space_after = Pt(1)
|
|
906
|
+
lp.paragraph_format.line_spacing = T.line(1.4)
|
|
907
|
+
lp.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
908
|
+
_inline(lp, item["label"], T.table_pt, T.colour("ink"))
|
|
909
|
+
for basis in item["basis"]:
|
|
910
|
+
bp = cell.add_paragraph()
|
|
911
|
+
bp.paragraph_format.space_after = Pt(0)
|
|
912
|
+
bp.paragraph_format.line_spacing = T.line(1.4)
|
|
913
|
+
bp.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
914
|
+
_inline(bp, basis, T.note_pt, T.colour("ink_muted"))
|
|
915
|
+
for cell in table._cells[len(items):]:
|
|
916
|
+
cell.paragraphs[0].text = ""
|
|
917
|
+
# The box stays whole: every paragraph but those of the last row keeps with the next.
|
|
918
|
+
for row in table.rows[:-1]:
|
|
919
|
+
for cell in row.cells:
|
|
920
|
+
for q in cell.paragraphs:
|
|
921
|
+
q.paragraph_format.keep_with_next = True
|
|
922
|
+
ctx.spacer(container)
|
|
923
|
+
return table
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def add_callout(ctx, container, d: Directive, width: int):
|
|
927
|
+
"""One callout style per document: a ruled box in the vocabulary of the tables (0.75 pt above, 0.5 pt below, in
|
|
928
|
+
the pack's accent), no side stripe, no fill, the title in bold ink."""
|
|
929
|
+
T = ctx.T
|
|
930
|
+
kind = d.attrs.get("kind")
|
|
931
|
+
if kind not in CALLOUT_KINDS:
|
|
932
|
+
raise DesignError(f"line {d.line}: ::: callout needs kind= one of {', '.join(CALLOUT_KINDS)}")
|
|
933
|
+
table = container.add_table(1, 1)
|
|
934
|
+
mark_component(table, f"callout {kind}")
|
|
935
|
+
table_width(table, width)
|
|
936
|
+
no_borders(table)
|
|
937
|
+
cell_margins(table, 5, 5, 0, 0)
|
|
938
|
+
table.rows[0]._tr.get_or_add_trPr().append(_el("cantSplit"))
|
|
939
|
+
cell = table.cell(0, 0)
|
|
940
|
+
rule = T.accent_for("callout_rule")
|
|
941
|
+
cell_set(cell, _borders("tcBorders", {"top": (6, rule, 0), "bottom": (4, rule, 0)}))
|
|
942
|
+
box = Box(cell, width, ctx.doc)
|
|
943
|
+
label = d.attrs.get("title") or LABELS[ctx.lang][kind]
|
|
944
|
+
p = box.add_paragraph()
|
|
945
|
+
p.paragraph_format.space_after = Pt(2)
|
|
946
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
947
|
+
styled_run(p, label, T.body_pt, T.colour("ink"), True)
|
|
948
|
+
ctx.render(box, d.body)
|
|
949
|
+
for q in cell.paragraphs:
|
|
950
|
+
q.paragraph_format.keep_with_next = True
|
|
951
|
+
if q.paragraph_format.space_after is None or q is cell.paragraphs[-1]:
|
|
952
|
+
q.paragraph_format.space_after = Pt(0)
|
|
953
|
+
ctx.spacer(container)
|
|
954
|
+
return table
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def add_sidebar(ctx, container, d: Directive, width: int):
|
|
958
|
+
"""A box beside the text (Manual): a hairline down its left side, no fill, table-size text."""
|
|
959
|
+
T = ctx.T
|
|
960
|
+
cfg = T.components.get("sidebar") or {}
|
|
961
|
+
which = d.attrs.get("width") or cfg.get("default_width", "third")
|
|
962
|
+
float_side = d.attrs.get("float") or cfg.get("float", "right")
|
|
963
|
+
if ctx.column_count > 1:
|
|
964
|
+
float_side = "none"
|
|
965
|
+
w = width if float_side == "none" else span(ctx.doc, 6 if which == "half" else 4, width=width)
|
|
966
|
+
table = container.add_table(1, 1)
|
|
967
|
+
mark_component(table, "sidebar")
|
|
968
|
+
table_width(table, w)
|
|
969
|
+
table.rows[0]._tr.get_or_add_trPr().append(_el("cantSplit"))
|
|
970
|
+
no_borders(table)
|
|
971
|
+
cell_margins(table, 2, 2, 8, 0)
|
|
972
|
+
if float_side in ("right", "left"):
|
|
973
|
+
tbl_set(table, "tblpPr", _el("tblpPr", leftFromText=284, rightFromText=284, topFromText=57, bottomFromText=113,
|
|
974
|
+
vertAnchor="text", horzAnchor="margin", tblpXSpec=float_side))
|
|
975
|
+
cell = table.cell(0, 0)
|
|
976
|
+
cell_set(cell, _borders("tcBorders", {"left": (6, T.accent_for("sidebar_rule"), 0)}))
|
|
977
|
+
box = Box(cell, w - Mm(4), ctx.doc)
|
|
978
|
+
if d.attrs.get("title"):
|
|
979
|
+
p = box.add_paragraph()
|
|
980
|
+
p.paragraph_format.space_after = Pt(2)
|
|
981
|
+
styled_run(p, d.attrs["title"], T.table_pt, T.colour("ink"), True)
|
|
982
|
+
ctx.render(box, d.body, size=T.table_pt)
|
|
983
|
+
return table
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def add_columns(ctx, d: Directive):
|
|
987
|
+
T = ctx.T
|
|
988
|
+
n = int(d.args[0]) if d.args else 2
|
|
989
|
+
if n not in (1, 2, 3):
|
|
990
|
+
raise DesignError(f"line {d.line}: ::: columns takes 1, 2 or 3, got {n}")
|
|
991
|
+
gap = float(d.attrs.get("gap") or T.column_gap_mm)
|
|
992
|
+
parts = COLUMN_BREAK.split(d.body)
|
|
993
|
+
if len(parts) > 1 and n > 1:
|
|
994
|
+
# The author placed the break: each part gets its own cell of one borderless row, tops aligned.
|
|
995
|
+
# A column section with a manual column break is not balanced by LibreOffice: it ran to the page
|
|
996
|
+
# foot and the next block started a new page.
|
|
997
|
+
side_by_side(ctx, parts[: n - 1] + ["\n\n".join(parts[n - 1:])], gap, d.attrs.get("rule") in ("true", "1"))
|
|
998
|
+
return
|
|
999
|
+
ctx.section_break(cols=n, gap=gap, rule=d.attrs.get("rule") in ("true", "1"))
|
|
1000
|
+
for i, part in enumerate(parts):
|
|
1001
|
+
if i:
|
|
1002
|
+
p = ctx.doc.add_paragraph()
|
|
1003
|
+
tiny(p)
|
|
1004
|
+
p.add_run().add_break(WD_BREAK.COLUMN)
|
|
1005
|
+
ctx.render(ctx.doc, part)
|
|
1006
|
+
ctx.section_break(cols=T.columns, gap=T.column_gap_mm)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def balanced_parts(d: Directive) -> bool:
|
|
1010
|
+
"""Two columns only when the parts are within 20 % of each other's length; a column section
|
|
1011
|
+
without a break balances itself."""
|
|
1012
|
+
parts = [len(_strip_md(p).replace(" ", "")) for p in COLUMN_BREAK.split(d.body) if p.strip()]
|
|
1013
|
+
return len(parts) < 2 or min(parts) >= 0.8 * max(parts)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def side_by_side(ctx, parts: list, gap_mm: float, rule: bool):
|
|
1017
|
+
T, doc = ctx.T, ctx.doc
|
|
1018
|
+
n = len(parts)
|
|
1019
|
+
full = frame_width(doc) if ctx.column_count == 1 else int((frame_width(doc) - Mm(T.column_gap_mm) * (ctx.column_count - 1)) / ctx.column_count)
|
|
1020
|
+
half = int(Mm(gap_mm) / 2)
|
|
1021
|
+
text_w = int((full - 2 * half * (n - 1)) / n)
|
|
1022
|
+
widths = [text_w + half * ((i > 0) + (i < n - 1)) for i in range(n)]
|
|
1023
|
+
table = doc.add_table(1, n)
|
|
1024
|
+
mark_component(table, "columns")
|
|
1025
|
+
no_borders(table)
|
|
1026
|
+
table_width(table, sum(widths), widths)
|
|
1027
|
+
cell_margins(table, 0, 0, 0, 0)
|
|
1028
|
+
for i, part in enumerate(parts):
|
|
1029
|
+
cell = table.cell(0, i)
|
|
1030
|
+
mar = _el("tcMar")
|
|
1031
|
+
mar.append(_el("left", w=int(half / 635) if i else 0, type="dxa"))
|
|
1032
|
+
mar.append(_el("right", w=int(half / 635) if i < n - 1 else 0, type="dxa"))
|
|
1033
|
+
cell_set(cell, mar)
|
|
1034
|
+
if rule and i < n - 1:
|
|
1035
|
+
cell_set(cell, _borders("tcBorders", {"right": (4, T.colour("line"), 0)}))
|
|
1036
|
+
box = Box(cell, text_w, doc)
|
|
1037
|
+
ctx.render(box, part)
|
|
1038
|
+
ctx.spacer(doc)
|
|
1039
|
+
return table
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
def settle_sidebars(doc, T: Tonality):
|
|
1043
|
+
"""A floating sidebar needs text beside it down to its own foot. When the paragraphs before the next
|
|
1044
|
+
heading are shorter than the box, the next heading would start beside it, so the box stands full width
|
|
1045
|
+
in the flow instead."""
|
|
1046
|
+
full = frame_width(doc)
|
|
1047
|
+
lead = T.body_pt * 1.8
|
|
1048
|
+
|
|
1049
|
+
def listed(q):
|
|
1050
|
+
return q.find(f"{qn('w:pPr')}/{qn('w:numPr')}") is not None or "List" in (Paragraph(q, doc._body).style.name or "")
|
|
1051
|
+
|
|
1052
|
+
for tbl in doc.element.body.iter(qn("w:tbl")):
|
|
1053
|
+
cap = tbl.find(f"{qn('w:tblPr')}/{qn('w:tblCaption')}")
|
|
1054
|
+
pos = tbl.find(f"{qn('w:tblPr')}/{qn('w:tblpPr')}")
|
|
1055
|
+
if cap is None or cap.get(qn("w:val")) != "sidebar" or pos is None:
|
|
1056
|
+
continue
|
|
1057
|
+
w_box = int(tbl.find(f"{qn('w:tblPr')}/{qn('w:tblW')}").get(qn("w:w"))) * 635
|
|
1058
|
+
# Measured on rendered pages: box lines run about 1.9 x the table size, a list item loses its indent.
|
|
1059
|
+
box_h = sum(max(1, math.ceil(_advance("".join(t.text or "" for t in q.iter(qn("w:t"))), T.table_pt) / (w_box / 12700 - 16 - (18 if listed(q) else 0))))
|
|
1060
|
+
* T.table_pt * 1.9 + 4 for q in tbl.iter(qn("w:p"))) + 16
|
|
1061
|
+
beside, el = 0.0, tbl.getnext()
|
|
1062
|
+
while el is not None and el.tag == qn("w:p") and not _level(Paragraph(el, doc._body)):
|
|
1063
|
+
text = "".join(t.text or "" for t in el.iter(qn("w:t")))
|
|
1064
|
+
if text.strip():
|
|
1065
|
+
beside += math.ceil(_advance(text, T.body_pt) * 0.9 / ((full - w_box) / 12700 - 12 - (18 if listed(el) else 0))) * lead + T.body_pt * 0.4
|
|
1066
|
+
el = el.getnext()
|
|
1067
|
+
if beside < box_h:
|
|
1068
|
+
tbl.find(qn("w:tblPr")).remove(pos)
|
|
1069
|
+
table_width(Table(tbl, doc._body), full)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def flow_paragraphs(doc):
|
|
1073
|
+
"""Body paragraphs in reading order, with those inside a side-by-side columns row."""
|
|
1074
|
+
for el in doc.element.body.iterchildren():
|
|
1075
|
+
if el.tag == qn("w:p"):
|
|
1076
|
+
yield Paragraph(el, doc._body)
|
|
1077
|
+
elif el.tag == qn("w:tbl"):
|
|
1078
|
+
cap = el.find(f"{qn('w:tblPr')}/{qn('w:tblCaption')}")
|
|
1079
|
+
if cap is not None and cap.get(qn("w:val")) == "columns":
|
|
1080
|
+
for tc in el.iter(qn("w:tc")):
|
|
1081
|
+
for q in tc.iterchildren(qn("w:p")):
|
|
1082
|
+
yield Paragraph(q, doc._body)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
# ── Title blocks and covers ─────────────────────────────────────────────────
|
|
1086
|
+
|
|
1087
|
+
def _byline(front: dict) -> str:
|
|
1088
|
+
"""Author and organisation, the organisation left out when the author already names it."""
|
|
1089
|
+
authors = front.get("authors") or front.get("author")
|
|
1090
|
+
if isinstance(authors, list):
|
|
1091
|
+
authors = ", ".join(str(a.get("name", a)) if isinstance(a, dict) else str(a) for a in authors)
|
|
1092
|
+
org = front.get("organization")
|
|
1093
|
+
parts = [str(authors)] if authors else []
|
|
1094
|
+
if org and not (authors and str(org) in str(authors)):
|
|
1095
|
+
parts.append(str(org))
|
|
1096
|
+
return ", ".join(parts)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _meta_line(front: dict, lang: str = "ko") -> str:
|
|
1100
|
+
"""The plain profile's meta line: author, organisation and date."""
|
|
1101
|
+
date = format_date(front["date"], lang) if front.get("date") else None
|
|
1102
|
+
return " ".join(v for v in (_byline(front), date) if v)
|
|
1103
|
+
|
|
1104
|
+
|
|
1105
|
+
def notice_tag(p: Paragraph, text: str, size: float = 8.5):
|
|
1106
|
+
run = styled_run(p, f" {text} ", size, NOTICE[0], True)
|
|
1107
|
+
run_shade(run, NOTICE[1])
|
|
1108
|
+
return run
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def notice_line(container, T: Tonality, text: str, before: float = 2, after: float = 0):
|
|
1112
|
+
"""The sample-data notice, once, in ink at the furniture size."""
|
|
1113
|
+
p = container.add_paragraph()
|
|
1114
|
+
p.paragraph_format.space_before = Pt(before)
|
|
1115
|
+
p.paragraph_format.space_after = Pt(after)
|
|
1116
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1117
|
+
styled_run(p, text, T.furniture_pt, T.colour("ink_muted"))
|
|
1118
|
+
return p
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _abstract(ctx, container, front: dict):
|
|
1122
|
+
T, lab = ctx.T, LABELS[ctx.lang]
|
|
1123
|
+
if front.get("abstract"):
|
|
1124
|
+
p = container.add_paragraph()
|
|
1125
|
+
p.paragraph_format.space_before = Pt(8)
|
|
1126
|
+
p.paragraph_format.space_after = Pt(2)
|
|
1127
|
+
styled_run(p, lab["abstract"], T.body_pt, T.colour("ink"), True)
|
|
1128
|
+
a = container.add_paragraph()
|
|
1129
|
+
a.paragraph_format.space_after = Pt(4)
|
|
1130
|
+
styled_run(a, str(front["abstract"]), T.body_pt, T.colour("ink"))
|
|
1131
|
+
keywords = front.get("keywords")
|
|
1132
|
+
if keywords:
|
|
1133
|
+
k = container.add_paragraph()
|
|
1134
|
+
k.paragraph_format.space_after = Pt(4)
|
|
1135
|
+
k.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1136
|
+
styled_run(k, f"{lab['keywords']}: ", T.table_pt, T.colour("ink"), True)
|
|
1137
|
+
styled_run(k, "; ".join(str(x) for x in keywords) if isinstance(keywords, list) else str(keywords), T.table_pt, T.colour("ink"))
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
MEMO_PAIRS = re.compile(r"^\s*(?:To|From|Date|Subject|Cc|받는 사람|보내는 사람|날짜|제목|참조)\s*:")
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def memo_pairs(line: str, lang: str) -> list:
|
|
1144
|
+
"""`To: … · From: … · Subject: …` as (label, value) pairs; an English value starts with a capital."""
|
|
1145
|
+
pairs = [tuple(x.strip() for x in part.split(":", 1)) for part in line.split(" · ") if ":" in part]
|
|
1146
|
+
return [(k, v[:1].upper() + v[1:] if lang == "en" else v) for k, v in pairs]
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _pairs_table(ctx, pairs: list, caption: str):
|
|
1150
|
+
"""Label and value rows between two hairlines (the memo block and the manual's control block)."""
|
|
1151
|
+
doc, T = ctx.doc, ctx.T
|
|
1152
|
+
table = doc.add_table(len(pairs), 2)
|
|
1153
|
+
mark_component(table, caption)
|
|
1154
|
+
no_borders(table)
|
|
1155
|
+
tbl_set(table, "tblBorders", _borders("tblBorders", {"top": (4, T.colour("ink"), 0), "bottom": (4, T.colour("ink"), 0), "left": None, "right": None,
|
|
1156
|
+
"insideH": None, "insideV": None}))
|
|
1157
|
+
label_w = span(doc, 2)
|
|
1158
|
+
table_width(table, frame_width(doc), [label_w, frame_width(doc) - label_w])
|
|
1159
|
+
cell_margins(table, 2, 2, 0, 6)
|
|
1160
|
+
for i, (key, value) in enumerate(pairs):
|
|
1161
|
+
for ci, (text, bold) in enumerate(((key, True), (value, False))):
|
|
1162
|
+
p = table.cell(i, ci).paragraphs[0]
|
|
1163
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1164
|
+
p.paragraph_format.space_after = Pt(0)
|
|
1165
|
+
styled_run(p, text, T.table_pt, T.colour("ink"), bold)
|
|
1166
|
+
return table
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
def _title(ctx, front: dict, size: float, after: float = 6):
|
|
1170
|
+
doc, T = ctx.doc, ctx.T
|
|
1171
|
+
t = doc.add_paragraph()
|
|
1172
|
+
t.paragraph_format.space_after = Pt(after)
|
|
1173
|
+
t.paragraph_format.line_spacing = T.line(1.22, T.fonts["heading"])
|
|
1174
|
+
t.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1175
|
+
display_run(t, str(front["title"]), size, frame_width(doc), T.colour("ink"), True, (T.fonts["heading"], T.fonts["hangul"]))
|
|
1176
|
+
return t
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def title_block(ctx, front: dict, d: Directive | None):
|
|
1180
|
+
"""Page 1 opens with the document's own title block above the body (no cover page):
|
|
1181
|
+
|
|
1182
|
+
masthead a series line (kind left, date right), the title, the subtitle, the byline, one rule, the notice;
|
|
1183
|
+
a journal adds affiliations, abstract and keywords
|
|
1184
|
+
memo a label, the subject as title, To / From / Date / Subject rows between hairlines, the notice
|
|
1185
|
+
manual the title and subtitle over a document-control block (document, owner, organisation, issued)
|
|
1186
|
+
"""
|
|
1187
|
+
doc, T, lab = ctx.doc, ctx.T, LABELS[ctx.lang]
|
|
1188
|
+
kicker = d.attrs.get("kicker") if d is not None else None
|
|
1189
|
+
date = format_date(front["date"], ctx.lang) if front.get("date") else ""
|
|
1190
|
+
inner = [b for b in split_directives(d.body, d.line)] if d is not None else []
|
|
1191
|
+
lead = "\n".join(b for b in inner if isinstance(b, str)).strip()
|
|
1192
|
+
sizes = T.sizes
|
|
1193
|
+
if T.title_block == "memo":
|
|
1194
|
+
k = doc.add_paragraph()
|
|
1195
|
+
k.paragraph_format.space_after = Pt(4)
|
|
1196
|
+
styled_run(k, kicker or lab["memo"], T.table_pt, T.colour("ink_muted"), True)
|
|
1197
|
+
_title(ctx, front, _half(T.body_pt * 1.8), 8)
|
|
1198
|
+
pairs, rest = [], []
|
|
1199
|
+
for line in lead.split("\n"):
|
|
1200
|
+
if MEMO_PAIRS.match(line):
|
|
1201
|
+
pairs += memo_pairs(line, ctx.lang)
|
|
1202
|
+
elif line.strip():
|
|
1203
|
+
rest.append(line)
|
|
1204
|
+
if date and not any(k in (lab["date"], "Date", "날짜") for k, _ in pairs):
|
|
1205
|
+
at = next((i + 1 for i, (k, _) in enumerate(pairs) if k in (lab["from"], "From", "보내는 사람")), len(pairs))
|
|
1206
|
+
pairs.insert(at, (lab["date"], date))
|
|
1207
|
+
if pairs:
|
|
1208
|
+
_pairs_table(ctx, pairs, "cover memo")
|
|
1209
|
+
if front.get("notice"):
|
|
1210
|
+
notice_line(doc, T, str(front["notice"]), 4, 10)
|
|
1211
|
+
else:
|
|
1212
|
+
ctx.spacer(doc)
|
|
1213
|
+
if rest:
|
|
1214
|
+
ctx.render(doc, "\n".join(rest))
|
|
1215
|
+
return
|
|
1216
|
+
if kicker or date:
|
|
1217
|
+
s = doc.add_paragraph()
|
|
1218
|
+
s.paragraph_format.space_after = Pt(10)
|
|
1219
|
+
s.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1220
|
+
s.paragraph_format.tab_stops.add_tab_stop(Emu(frame_width(doc)), WD_TAB_ALIGNMENT.RIGHT)
|
|
1221
|
+
styled_run(s, kicker or "", T.furniture_pt + 0.5, T.colour("ink"), True)
|
|
1222
|
+
if date and T.title_block != "manual":
|
|
1223
|
+
styled_run(s, f"\t{date}", T.furniture_pt + 0.5, T.colour("ink"))
|
|
1224
|
+
_title(ctx, front, sizes["title"])
|
|
1225
|
+
if front.get("subtitle"):
|
|
1226
|
+
sub = doc.add_paragraph()
|
|
1227
|
+
sub.paragraph_format.space_after = Pt(6)
|
|
1228
|
+
sub.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1229
|
+
styled_run(sub, str(front["subtitle"]), sizes["h2"], T.colour("ink_muted"))
|
|
1230
|
+
if T.title_block == "manual":
|
|
1231
|
+
pairs = [(lab["doc"], str(front.get("short_title") or front["title"]))]
|
|
1232
|
+
for key, value in ((lab["owner"], front.get("author")), (lab["org"], front.get("organization")), (lab["issued"], date)):
|
|
1233
|
+
if value:
|
|
1234
|
+
pairs.append((key, str(value)))
|
|
1235
|
+
sp = doc.add_paragraph()
|
|
1236
|
+
tiny(sp)
|
|
1237
|
+
sp.paragraph_format.space_before = Pt(6)
|
|
1238
|
+
_pairs_table(ctx, pairs, "cover manual")
|
|
1239
|
+
if front.get("notice"):
|
|
1240
|
+
notice_line(doc, T, str(front["notice"]), 4, 0)
|
|
1241
|
+
else:
|
|
1242
|
+
byline = _byline(front)
|
|
1243
|
+
if byline:
|
|
1244
|
+
m = doc.add_paragraph()
|
|
1245
|
+
m.paragraph_format.space_after = Pt(2)
|
|
1246
|
+
m.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1247
|
+
styled_run(m, byline, T.table_pt, T.colour("ink"))
|
|
1248
|
+
affs = front.get("affiliations")
|
|
1249
|
+
if isinstance(affs, dict):
|
|
1250
|
+
for key in sorted(affs, key=str):
|
|
1251
|
+
a = doc.add_paragraph()
|
|
1252
|
+
a.paragraph_format.space_after = Pt(0)
|
|
1253
|
+
a.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1254
|
+
styled_run(a, f"{key} {affs[key]}", T.note_pt, T.colour("ink_muted"))
|
|
1255
|
+
if front.get("corresponding_email"):
|
|
1256
|
+
c = doc.add_paragraph()
|
|
1257
|
+
c.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1258
|
+
styled_run(c, f"* {front['corresponding_email']}", T.note_pt, T.colour("ink_muted"))
|
|
1259
|
+
rule = doc.add_paragraph()
|
|
1260
|
+
tiny(rule)
|
|
1261
|
+
rule.paragraph_format.space_before = Pt(4)
|
|
1262
|
+
para_border(rule, {"bottom": (6, T.accent_for("title_rule"), 1)})
|
|
1263
|
+
if front.get("notice"):
|
|
1264
|
+
notice_line(doc, T, str(front["notice"]), 4, 0)
|
|
1265
|
+
if lead:
|
|
1266
|
+
ctx.spacer(doc)
|
|
1267
|
+
lines = lead.split("\n")
|
|
1268
|
+
for line in [x for x in lines if MEMO_PAIRS.match(x)]:
|
|
1269
|
+
# To / From / Subject in a title block that is not a memo: one line, labels bold, no middle dots.
|
|
1270
|
+
m = doc.add_paragraph()
|
|
1271
|
+
m.paragraph_format.space_after = Pt(4)
|
|
1272
|
+
for i, (key, value) in enumerate(memo_pairs(line, ctx.lang)):
|
|
1273
|
+
styled_run(m, ("\u2003" if i else "") + key + " ", T.body_pt, T.colour("ink"), True)
|
|
1274
|
+
styled_run(m, value, T.body_pt, T.colour("ink"))
|
|
1275
|
+
lead = "\n".join(x for x in lines if not MEMO_PAIRS.match(x)).strip()
|
|
1276
|
+
before = len(doc.paragraphs)
|
|
1277
|
+
ctx.render(doc, lead)
|
|
1278
|
+
for p in doc.paragraphs[before:]:
|
|
1279
|
+
for r in p.runs:
|
|
1280
|
+
r.font.size = Pt(T.body_pt + 0.5)
|
|
1281
|
+
_abstract(ctx, doc, front)
|
|
1282
|
+
end = doc.add_paragraph()
|
|
1283
|
+
tiny(end)
|
|
1284
|
+
end.paragraph_format.space_after = Pt(10)
|
|
1285
|
+
ctx.title_end = end
|
|
1286
|
+
|
|
1287
|
+
|
|
1288
|
+
def masthead(ctx, front: dict, d: Directive | None):
|
|
1289
|
+
"""The plain profile's title block: title, subtitle, one meta line, affiliations, abstract and keywords,
|
|
1290
|
+
closed by a hairline."""
|
|
1291
|
+
doc, T = ctx.doc, ctx.T
|
|
1292
|
+
tb = T.design.get("title_block_pt") or {}
|
|
1293
|
+
_title(ctx, front, float(tb.get("title", 22)), 4)
|
|
1294
|
+
if front.get("subtitle"):
|
|
1295
|
+
s = doc.add_paragraph()
|
|
1296
|
+
s.paragraph_format.space_after = Pt(4)
|
|
1297
|
+
styled_run(s, str(front["subtitle"]), 13, T.colour("ink_muted"))
|
|
1298
|
+
lead = "\n".join(b for b in split_directives(d.body, d.line) if isinstance(b, str)).strip() if d is not None else ""
|
|
1299
|
+
meta = _meta_line(front, ctx.lang)
|
|
1300
|
+
if meta:
|
|
1301
|
+
m = doc.add_paragraph()
|
|
1302
|
+
m.paragraph_format.space_after = Pt(2)
|
|
1303
|
+
styled_run(m, meta, T.body_pt, T.colour("ink_muted"))
|
|
1304
|
+
affs = front.get("affiliations")
|
|
1305
|
+
if isinstance(affs, dict):
|
|
1306
|
+
for key in sorted(affs, key=str):
|
|
1307
|
+
a = doc.add_paragraph()
|
|
1308
|
+
a.paragraph_format.space_after = Pt(0)
|
|
1309
|
+
styled_run(a, f"{key} {affs[key]}", T.table_pt, T.colour("ink_muted"))
|
|
1310
|
+
if front.get("corresponding_email"):
|
|
1311
|
+
c = doc.add_paragraph()
|
|
1312
|
+
styled_run(c, f"* {front['corresponding_email']}", T.table_pt, T.colour("ink_muted"))
|
|
1313
|
+
if lead:
|
|
1314
|
+
ctx.render(doc, lead)
|
|
1315
|
+
_abstract(ctx, doc, front)
|
|
1316
|
+
rule = doc.add_paragraph()
|
|
1317
|
+
tiny(rule)
|
|
1318
|
+
para_border(rule, {"bottom": (6, T.colour("line"), 1)})
|
|
1319
|
+
rule.paragraph_format.space_after = Pt(12)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def cover_page(ctx, front: dict, d: Directive | None):
|
|
1323
|
+
"""A typographic cover on page 1, its own section: the kind, the title left-aligned in the upper
|
|
1324
|
+
third, one short rule, the subtitle and the lead; the byline, date and notice at the foot of the page.
|
|
1325
|
+
No colour block, no key figures. The body starts on the next page with page number 1."""
|
|
1326
|
+
doc, T = ctx.doc, ctx.T
|
|
1327
|
+
section = doc.sections[0]
|
|
1328
|
+
H, top, W = section.page_height, section.top_margin, frame_width(doc)
|
|
1329
|
+
kicker = d.attrs.get("kicker") if d is not None else None
|
|
1330
|
+
lead = "\n".join(b for b in split_directives(d.body, d.line) if isinstance(b, str)).strip() if d is not None else ""
|
|
1331
|
+
first = doc.paragraphs[0] if doc.paragraphs and not doc.paragraphs[0].text else doc.add_paragraph()
|
|
1332
|
+
tiny(first)
|
|
1333
|
+
first.paragraph_format.space_before = Pt(max(0, (0.22 * H - top) / 12700))
|
|
1334
|
+
if kicker:
|
|
1335
|
+
k = doc.add_paragraph()
|
|
1336
|
+
k.paragraph_format.space_after = Pt(10)
|
|
1337
|
+
k.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1338
|
+
styled_run(k, kicker, T.table_pt + 0.5, T.colour("ink"), True)
|
|
1339
|
+
t = doc.add_paragraph()
|
|
1340
|
+
t.alignment = WD_ALIGN_PARAGRAPH.LEFT # a justified paragraph would spread the broken title lines
|
|
1341
|
+
t.paragraph_format.space_after = Pt(12)
|
|
1342
|
+
t.paragraph_format.line_spacing = T.line(1.22, T.fonts["heading"])
|
|
1343
|
+
t.paragraph_format.right_indent = Emu(int(W - span(doc, 10)))
|
|
1344
|
+
display_run(t, str(front["title"]), T.sizes["title"], span(doc, 10), T.colour("ink"), True, (T.fonts["heading"], T.fonts["hangul"]))
|
|
1345
|
+
r = doc.add_paragraph()
|
|
1346
|
+
tiny(r)
|
|
1347
|
+
r.paragraph_format.right_indent = Emu(max(0, W - Mm(36)))
|
|
1348
|
+
para_border(r, {"bottom": (8, T.accent_for("title_rule"), 1)})
|
|
1349
|
+
r.paragraph_format.space_after = Pt(14)
|
|
1350
|
+
if front.get("subtitle"):
|
|
1351
|
+
s = doc.add_paragraph()
|
|
1352
|
+
s.paragraph_format.space_after = Pt(10)
|
|
1353
|
+
s.paragraph_format.right_indent = Emu(int(W - span(doc, 9)))
|
|
1354
|
+
s.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1355
|
+
styled_run(s, str(front["subtitle"]), T.sizes["h2"], T.colour("ink_muted"))
|
|
1356
|
+
if lead:
|
|
1357
|
+
before = len(doc.paragraphs)
|
|
1358
|
+
ctx.render(doc, lead)
|
|
1359
|
+
for p in doc.paragraphs[before:]:
|
|
1360
|
+
p.paragraph_format.right_indent = Emu(int(W - span(doc, 9)))
|
|
1361
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1362
|
+
# The byline, the date and the notice stand at the foot of the cover, in the cover section's own footer.
|
|
1363
|
+
footer = section.footer
|
|
1364
|
+
footer.is_linked_to_previous = False
|
|
1365
|
+
lines = [(_byline(front), T.table_pt, T.colour("ink"), True), (format_date(front["date"], ctx.lang) if front.get("date") else "", T.table_pt, T.colour("ink"), False),
|
|
1366
|
+
(str(front.get("notice") or ""), T.furniture_pt, T.colour("ink_muted"), False)]
|
|
1367
|
+
fp = footer.paragraphs[0]
|
|
1368
|
+
for i, (text, size, colour, bold) in enumerate(x for x in lines if x[0]):
|
|
1369
|
+
p = fp if i == 0 else footer.add_paragraph()
|
|
1370
|
+
p.paragraph_format.space_after = Pt(1)
|
|
1371
|
+
styled_run(p, text, size, colour, bold)
|
|
1372
|
+
section.header.is_linked_to_previous = False
|
|
1373
|
+
new = doc.add_section(WD_SECTION.NEW_PAGE)
|
|
1374
|
+
set_page(new, T)
|
|
1375
|
+
_put(new._sectPr, _el("pgNumType", start=1), SECTPR_ORDER)
|
|
1376
|
+
for p in doc.paragraphs[-1:]:
|
|
1377
|
+
tiny(p)
|
|
1378
|
+
ctx.title_end = None
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
# ── Post-passes over the body ───────────────────────────────────────────────
|
|
1382
|
+
|
|
1383
|
+
def _level(p: Paragraph) -> int:
|
|
1384
|
+
name = p.style.name if p.style is not None else ""
|
|
1385
|
+
m = re.match(r"Heading (\d)$", name)
|
|
1386
|
+
return int(m.group(1)) if m else 0
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def _prefix_runs(p: Paragraph, runs: list):
|
|
1390
|
+
"""Insert runs (text, size, colour, bold) and an optional tab at the start of a paragraph."""
|
|
1391
|
+
anchor = p._p.find(qn("w:pPr"))
|
|
1392
|
+
for spec in reversed(runs):
|
|
1393
|
+
r = OxmlElement("w:r")
|
|
1394
|
+
rpr = OxmlElement("w:rPr")
|
|
1395
|
+
if spec == "\t":
|
|
1396
|
+
r.append(rpr)
|
|
1397
|
+
r.append(OxmlElement("w:tab"))
|
|
1398
|
+
else:
|
|
1399
|
+
text, size, colour, bold = spec
|
|
1400
|
+
if bold:
|
|
1401
|
+
_put(rpr, _el("b"), RPR_ORDER)
|
|
1402
|
+
_put(rpr, _el("color", val=colour), RPR_ORDER)
|
|
1403
|
+
if size:
|
|
1404
|
+
_put(rpr, _el("sz", val=int(size * 2)), RPR_ORDER)
|
|
1405
|
+
r.append(rpr)
|
|
1406
|
+
t = OxmlElement("w:t")
|
|
1407
|
+
t.text = text
|
|
1408
|
+
t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
|
|
1409
|
+
r.append(t)
|
|
1410
|
+
if anchor is not None:
|
|
1411
|
+
anchor.addnext(r)
|
|
1412
|
+
else:
|
|
1413
|
+
p._p.insert(0, r)
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
def _hang(p: Paragraph, mm: float):
|
|
1417
|
+
ind = _el("ind", left=int(mm * 56.693), hanging=int(mm * 56.693))
|
|
1418
|
+
_put(p._p.get_or_add_pPr(), ind, PPR_ORDER)
|
|
1419
|
+
tabs = _el("tabs")
|
|
1420
|
+
tabs.append(_el("tab", val="left", pos=int(mm * 56.693)))
|
|
1421
|
+
_put(p._p.get_or_add_pPr(), tabs, PPR_ORDER)
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def heading_number(scheme: str, counters: list, level: int) -> str | None:
|
|
1425
|
+
"""Plain ink numbering at the heading's own size: 1 / 1.1 / 1.1.1, or the Korean institute order Ⅰ. / 1. / 가."""
|
|
1426
|
+
if scheme == "decimal":
|
|
1427
|
+
return ".".join(str(c) for c in counters[:level])
|
|
1428
|
+
if scheme == "roman-ko":
|
|
1429
|
+
n = counters[level - 1]
|
|
1430
|
+
if level == 1:
|
|
1431
|
+
return f"{ROMAN[n - 1]}." if n <= len(ROMAN) else f"{n}."
|
|
1432
|
+
if level == 2:
|
|
1433
|
+
return f"{n}."
|
|
1434
|
+
return f"{GANADA[n - 1]}." if n <= len(GANADA) else f"{n})"
|
|
1435
|
+
return None
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
HANG_MM = {"decimal": (8, 11, 13)}
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def treat_headings(doc, T: Tonality):
|
|
1442
|
+
counters = [0, 0, 0]
|
|
1443
|
+
scheme = T.numbering
|
|
1444
|
+
numbered = []
|
|
1445
|
+
for p in flow_paragraphs(doc):
|
|
1446
|
+
level = _level(p)
|
|
1447
|
+
if not level or level > 3 or not p.text.strip():
|
|
1448
|
+
continue
|
|
1449
|
+
if scheme == "none" or BACK_MATTER.match(p.text.strip()):
|
|
1450
|
+
continue
|
|
1451
|
+
counters[level - 1] += 1
|
|
1452
|
+
for i in range(level, 3):
|
|
1453
|
+
counters[i] = 0
|
|
1454
|
+
if 0 in counters[:level]:
|
|
1455
|
+
continue # a level under a missing parent stays unnumbered
|
|
1456
|
+
number = heading_number(scheme, counters, level)
|
|
1457
|
+
if number and scheme == "roman-ko":
|
|
1458
|
+
# The institute manner: "Ⅴ. 소요 예산" with one space. A tab after a Roman numeral landed at different
|
|
1459
|
+
# places in LibreOffice, so the gaps after Ⅰ., Ⅳ. and Ⅴ. differed.
|
|
1460
|
+
_prefix_runs(p, [(number, None, T.colour("ink"), True), (" ", None, T.colour("ink"), False)])
|
|
1461
|
+
elif number:
|
|
1462
|
+
_prefix_runs(p, [(number, None, T.colour("ink"), True), "\t"])
|
|
1463
|
+
numbered.append((p, level, number))
|
|
1464
|
+
# Decimal numbers: one hang per level, wide enough for the widest number (2.10) plus a gap of about 3 mm, so
|
|
1465
|
+
# the heading texts of a level start on one line.
|
|
1466
|
+
hang = {}
|
|
1467
|
+
for _, level, number in numbered:
|
|
1468
|
+
need = _advance(number, T.sizes[f"h{level}"]) * 1.1 / 2.835 + 3
|
|
1469
|
+
hang[level] = max(hang.get(level, HANG_MM[scheme][level - 1]), need)
|
|
1470
|
+
for p, level, _ in numbered:
|
|
1471
|
+
_hang(p, hang[level])
|
|
1472
|
+
|
|
1473
|
+
|
|
1474
|
+
def bookmark_h1(doc) -> list:
|
|
1475
|
+
"""Each numbered h1 gets a bookmark the contents list points at; returns (text, bookmark) in order."""
|
|
1476
|
+
out = []
|
|
1477
|
+
for i, p in enumerate(q for q in flow_paragraphs(doc) if _level(q) == 1 and q.text.strip()):
|
|
1478
|
+
name = f"_LitSec{i + 1}"
|
|
1479
|
+
start = _el("bookmarkStart", id=900 + i, name=name)
|
|
1480
|
+
end = _el("bookmarkEnd", id=900 + i)
|
|
1481
|
+
anchor = p._p.find(qn("w:pPr"))
|
|
1482
|
+
if anchor is not None:
|
|
1483
|
+
anchor.addnext(start)
|
|
1484
|
+
else:
|
|
1485
|
+
p._p.insert(0, start)
|
|
1486
|
+
p._p.append(end)
|
|
1487
|
+
out.append((p.text.replace("\t", " ").strip(), name))
|
|
1488
|
+
return out
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
def add_contents(ctx, entries: list):
|
|
1492
|
+
"""A short contents list after the title block: each h1, a dotted leader and its page (PAGEREF)."""
|
|
1493
|
+
doc, T = ctx.doc, ctx.T
|
|
1494
|
+
anchor = ctx.title_end
|
|
1495
|
+
if anchor is None or not entries:
|
|
1496
|
+
return
|
|
1497
|
+
W = frame_width(doc)
|
|
1498
|
+
made = []
|
|
1499
|
+
head = doc.add_paragraph()
|
|
1500
|
+
head.paragraph_format.space_after = Pt(4)
|
|
1501
|
+
head.paragraph_format.keep_with_next = True
|
|
1502
|
+
styled_run(head, LABELS[ctx.lang]["contents"], T.table_pt + 0.5, T.colour("ink"), True)
|
|
1503
|
+
made.append(head)
|
|
1504
|
+
for text, name in entries:
|
|
1505
|
+
p = doc.add_paragraph()
|
|
1506
|
+
p.paragraph_format.space_after = Pt(0)
|
|
1507
|
+
p.paragraph_format.line_spacing = T.line(1.6)
|
|
1508
|
+
p.paragraph_format.keep_with_next = True
|
|
1509
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1510
|
+
p.paragraph_format.tab_stops.add_tab_stop(Emu(W), WD_TAB_ALIGNMENT.RIGHT, WD_TAB_LEADER.DOTS)
|
|
1511
|
+
styled_run(p, f"{text}\t", T.table_pt, T.colour("ink"))
|
|
1512
|
+
add_field(p, f"PAGEREF {name} \\h", T.table_pt, T.colour("ink"), "")
|
|
1513
|
+
made.append(p)
|
|
1514
|
+
made[-1].paragraph_format.keep_with_next = False
|
|
1515
|
+
rule = doc.add_paragraph()
|
|
1516
|
+
tiny(rule)
|
|
1517
|
+
para_border(rule, {"bottom": (4, T.colour("line"), 1)})
|
|
1518
|
+
rule.paragraph_format.space_after = Pt(10)
|
|
1519
|
+
made.append(rule)
|
|
1520
|
+
for p in reversed(made):
|
|
1521
|
+
anchor._p.addnext(p._p)
|
|
1522
|
+
|
|
1523
|
+
|
|
1524
|
+
def summary_points(doc, T: Tonality):
|
|
1525
|
+
"""Brief: the paragraphs of the first section become numbered points ①-④, each a bold lead sentence then
|
|
1526
|
+
plain text (the conclusion-first summary of a briefing)."""
|
|
1527
|
+
paras = list(flow_paragraphs(doc))
|
|
1528
|
+
start = next((i for i, p in enumerate(paras) if _level(p) == 1), None)
|
|
1529
|
+
if start is None:
|
|
1530
|
+
return
|
|
1531
|
+
n = 0
|
|
1532
|
+
for p in paras[start + 1:]:
|
|
1533
|
+
if _level(p):
|
|
1534
|
+
break
|
|
1535
|
+
style = p.style.name if p.style is not None else ""
|
|
1536
|
+
text = p.text.strip()
|
|
1537
|
+
if not text or style != "Normal" or n >= len(CIRCLED) - 1:
|
|
1538
|
+
continue
|
|
1539
|
+
m = re.match(r"^(.+?(?:다\.|[.!?]))(\s+|$)(.*)$", text, re.S)
|
|
1540
|
+
lead, rest = (m.group(1), m.group(3)) if m else (text, "")
|
|
1541
|
+
for r in list(p.runs):
|
|
1542
|
+
r._r.getparent().remove(r._r)
|
|
1543
|
+
styled_run(p, f"{CIRCLED[n]} ", None, T.colour("ink"), True)
|
|
1544
|
+
styled_run(p, lead, None, T.colour("ink"), True)
|
|
1545
|
+
if rest:
|
|
1546
|
+
styled_run(p, " " + rest, None, T.colour("ink"))
|
|
1547
|
+
_hang(p, 5.5)
|
|
1548
|
+
p.paragraph_format.tab_stops.clear_all()
|
|
1549
|
+
n += 1
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
def keep_short_followers(doc):
|
|
1553
|
+
"""A heading carries at least two lines of its section: a short paragraph after it keeps with
|
|
1554
|
+
what follows too."""
|
|
1555
|
+
ps = doc.paragraphs
|
|
1556
|
+
for i, p in enumerate(ps[:-1]):
|
|
1557
|
+
if _level(p) and not _level(ps[i + 1]) and len(ps[i + 1].text) < 60:
|
|
1558
|
+
ps[i + 1].paragraph_format.keep_with_next = True
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def last_line_company(doc):
|
|
1562
|
+
"""A closing paragraph of up to about four lines does not stand alone on the last page: the block before it
|
|
1563
|
+
keeps with it: one paragraph on an otherwise empty last page reads as left over."""
|
|
1564
|
+
body = [el for el in doc.element.body.iterchildren() if el.tag in (qn("w:p"), qn("w:tbl"))]
|
|
1565
|
+
body = [el for el in body if el.tag == qn("w:tbl") or "".join(t.text or "" for t in el.iter(qn("w:t"))).strip()]
|
|
1566
|
+
if len(body) < 2 or body[-1].tag != qn("w:p") or body[-2].tag != qn("w:p"):
|
|
1567
|
+
return
|
|
1568
|
+
last, prev = Paragraph(body[-1], doc._body), Paragraph(body[-2], doc._body)
|
|
1569
|
+
if len(last.text) <= 400 and not _level(prev):
|
|
1570
|
+
prev.paragraph_format.keep_with_next = True
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
REFERENCE_ENTRY = re.compile(r"^\[\d+\]\s")
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def _listed(p: Paragraph) -> bool:
|
|
1577
|
+
# A reference list ("[1] Author …") keeps together like any short list.
|
|
1578
|
+
return ((p.style is not None and p.style.name.startswith("List")) or p._p.find(f"{qn('w:pPr')}/{qn('w:numPr')}") is not None
|
|
1579
|
+
or bool(REFERENCE_ENTRY.match(p.text.strip())))
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _column_paragraphs(doc) -> set:
|
|
1583
|
+
"""The body paragraphs set in a section of two or more columns."""
|
|
1584
|
+
found, pending = set(), []
|
|
1585
|
+
for el in doc.element.body.iterchildren():
|
|
1586
|
+
if el.tag == qn("w:p"):
|
|
1587
|
+
pending.append(el)
|
|
1588
|
+
sect = el if el.tag == qn("w:sectPr") else el.find(f"{qn('w:pPr')}/{qn('w:sectPr')}") if el.tag == qn("w:p") else None
|
|
1589
|
+
if sect is not None:
|
|
1590
|
+
cols = sect.find(qn("w:cols"))
|
|
1591
|
+
if cols is not None and int(cols.get(qn("w:num"), "1") or 1) >= 2:
|
|
1592
|
+
found.update(pending)
|
|
1593
|
+
pending = []
|
|
1594
|
+
return found
|
|
1595
|
+
|
|
1596
|
+
|
|
1597
|
+
def keep_short_lists(doc, most: int = 6):
|
|
1598
|
+
"""A list of up to six items reads as one block: every item but the last keeps with the next, and a lead-in of
|
|
1599
|
+
a line or two right before it ("다음 단계는 아래와 같다.") keeps with the list. In a
|
|
1600
|
+
column the block moved whole and left the column before it a third empty: there the list keeps its first two
|
|
1601
|
+
items with the lead-in and its last two together, and may break between them."""
|
|
1602
|
+
body = list(doc.element.body.iterchildren())
|
|
1603
|
+
in_columns = _column_paragraphs(doc)
|
|
1604
|
+
run = []
|
|
1605
|
+
|
|
1606
|
+
def close():
|
|
1607
|
+
if 2 <= len(run) <= most:
|
|
1608
|
+
split = len(run) >= 5 and run[0]._p in in_columns
|
|
1609
|
+
for k, q in enumerate(run[:-1]):
|
|
1610
|
+
q.paragraph_format.keep_with_next = not split or k == 0 or k == len(run) - 2
|
|
1611
|
+
first = run[0]._p.getprevious()
|
|
1612
|
+
if first is not None and first.tag == qn("w:p"):
|
|
1613
|
+
lead = Paragraph(first, doc._body)
|
|
1614
|
+
if lead.text.strip() and not _level(lead) and not _listed(lead) and len(lead.text) <= 160:
|
|
1615
|
+
lead.paragraph_format.keep_with_next = True
|
|
1616
|
+
run.clear()
|
|
1617
|
+
|
|
1618
|
+
for el in body:
|
|
1619
|
+
if el.tag == qn("w:p"):
|
|
1620
|
+
p = Paragraph(el, doc._body)
|
|
1621
|
+
if _listed(p) and p.text.strip():
|
|
1622
|
+
run.append(p)
|
|
1623
|
+
continue
|
|
1624
|
+
close()
|
|
1625
|
+
close()
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def keep_heading_with_table(doc):
|
|
1629
|
+
"""A heading whose first block is a short lead sentence and then a table keeps all three together: the heading
|
|
1630
|
+
and its sentence never wait at a page foot while the table opens the next page."""
|
|
1631
|
+
for el in doc.element.body.iterchildren(qn("w:p")):
|
|
1632
|
+
p = Paragraph(el, doc._body)
|
|
1633
|
+
if not _level(p):
|
|
1634
|
+
continue
|
|
1635
|
+
lead = el.getnext()
|
|
1636
|
+
if lead is None or lead.tag != qn("w:p"):
|
|
1637
|
+
continue
|
|
1638
|
+
after = lead.getnext()
|
|
1639
|
+
# A caption or a units line may stand between the sentence and its table.
|
|
1640
|
+
def between(q):
|
|
1641
|
+
text = "".join(t.text or "" for t in q.iter(qn("w:t"))).strip()
|
|
1642
|
+
return q.tag == qn("w:p") and bool(TABLE_CAPTION.match(text) or text.startswith(("(단위", "(unit", "(Unit")))
|
|
1643
|
+
while after is not None and between(after):
|
|
1644
|
+
Paragraph(after, doc._body).paragraph_format.keep_with_next = True
|
|
1645
|
+
after = after.getnext()
|
|
1646
|
+
# A short lead (about two lines) and a table of up to eight body rows: a longer chain moved whole and left
|
|
1647
|
+
# half a page empty behind it.
|
|
1648
|
+
rows = len(after.findall(qn("w:tr"))) if after is not None and after.tag == qn("w:tbl") else 99
|
|
1649
|
+
if after is not None and after.tag == qn("w:tbl") and rows <= 9 and len(re.sub(r"\s+", "", "".join(t.text or "" for t in lead.iter(qn("w:t"))))) <= 200:
|
|
1650
|
+
p.paragraph_format.keep_with_next = True
|
|
1651
|
+
Paragraph(lead, doc._body).paragraph_format.keep_with_next = True
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
def keep_closing_section(doc, T: Tonality):
|
|
1655
|
+
"""A closing section of a few lines and a short list, with no table or picture, keeps whole: its paragraph
|
|
1656
|
+
travels with the list to the last page instead of leaving the list and a closing line alone there."""
|
|
1657
|
+
body = [el for el in doc.element.body.iterchildren() if el.tag in (qn("w:p"), qn("w:tbl"))]
|
|
1658
|
+
heads = [i for i, el in enumerate(body) if el.tag == qn("w:p") and _level(Paragraph(el, doc._body))]
|
|
1659
|
+
if not heads:
|
|
1660
|
+
return
|
|
1661
|
+
rest = body[heads[-1] + 1:]
|
|
1662
|
+
if any(el.tag == qn("w:tbl") or el.find(f".//{qn('w:drawing')}") is not None for el in rest):
|
|
1663
|
+
return
|
|
1664
|
+
paras = [Paragraph(el, doc._body) for el in rest if "".join(t.text or "" for t in el.iter(qn("w:t"))).strip()]
|
|
1665
|
+
width = frame_width(doc) / 12700
|
|
1666
|
+
lines = sum(_est_lines(p.text, width, T.body_pt) for p in paras)
|
|
1667
|
+
if 2 <= len(paras) <= 10 and lines <= 12:
|
|
1668
|
+
for p in paras[:-1]:
|
|
1669
|
+
p.paragraph_format.keep_with_next = True
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
def single_rule_after_boxes(doc):
|
|
1673
|
+
"""A section that opens under a hairline right after a ruled box draws no second rule: the box's own bottom
|
|
1674
|
+
rule parts the two: a tinted and a black rule stacked under one heading read as a doubled line."""
|
|
1675
|
+
for el in doc.element.body.iterchildren(qn("w:p")):
|
|
1676
|
+
p = Paragraph(el, doc._body)
|
|
1677
|
+
prev = el.getprevious()
|
|
1678
|
+
# An empty spacer paragraph after the box does not part the two rules.
|
|
1679
|
+
while prev is not None and prev.tag == qn("w:p") and not "".join(t.text or "" for t in prev.iter(qn("w:t"))).strip() and prev.find(f".//{qn('w:drawing')}") is None:
|
|
1680
|
+
prev = prev.getprevious()
|
|
1681
|
+
if _level(p) == 1 and prev is not None and prev.tag == qn("w:tbl") and prev.find(f".//{qn('w:tcBorders')}/{qn('w:bottom')}") is not None:
|
|
1682
|
+
para_border(p, {"top": None})
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
def restart_numbered_lists(doc):
|
|
1686
|
+
"""Each numbered list counts from 1. "List Number" numbers through one style-level list, so a second
|
|
1687
|
+
list went on at 4; every list after the first gets its own instance with a start override."""
|
|
1688
|
+
try:
|
|
1689
|
+
style_num = doc.styles["List Number"].element.pPr.numPr.numId.val
|
|
1690
|
+
except (KeyError, AttributeError):
|
|
1691
|
+
return
|
|
1692
|
+
numbering = doc.part.numbering_part.element
|
|
1693
|
+
abstract = next((n.abstractNumId.val for n in numbering.num_lst if n.numId == style_num), None)
|
|
1694
|
+
if abstract is None:
|
|
1695
|
+
return
|
|
1696
|
+
seen, prev = 0, False
|
|
1697
|
+
for p in doc.paragraphs:
|
|
1698
|
+
is_item = p.style is not None and p.style.name == "List Number"
|
|
1699
|
+
if is_item and not prev:
|
|
1700
|
+
seen += 1
|
|
1701
|
+
num_id = None
|
|
1702
|
+
if seen > 1:
|
|
1703
|
+
num = numbering.add_num(abstract)
|
|
1704
|
+
num.add_lvlOverride(ilvl=0).add_startOverride(1)
|
|
1705
|
+
num_id = num.numId
|
|
1706
|
+
if is_item and seen > 1:
|
|
1707
|
+
p_pr = p._p.get_or_add_pPr()
|
|
1708
|
+
p_pr.get_or_add_numPr().get_or_add_ilvl().val = 0
|
|
1709
|
+
p_pr.get_or_add_numPr().get_or_add_numId().val = num_id
|
|
1710
|
+
prev = is_item if p.text.strip() or is_item else prev
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def space_after_lists(doc):
|
|
1714
|
+
"""A paragraph after a list stands a paragraph's space clear of its last item (list items part by a smaller step),
|
|
1715
|
+
so the list reads as one block and the paragraph after it as the next."""
|
|
1716
|
+
def listed(q):
|
|
1717
|
+
return q.style is not None and q.style.name.startswith("List") or q._p.find(f"{qn('w:pPr')}/{qn('w:numPr')}") is not None
|
|
1718
|
+
prev = None
|
|
1719
|
+
for p in doc.paragraphs:
|
|
1720
|
+
if prev is not None and listed(prev) and not listed(p) and p.text.strip() and not _level(p) and p.paragraph_format.space_before in (None, Pt(0)):
|
|
1721
|
+
p.paragraph_format.space_before = Pt(6)
|
|
1722
|
+
prev = p
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def lone_list_items(doc):
|
|
1726
|
+
"""A bulleted list of one item is a plain paragraph: a lone item takes no symbol."""
|
|
1727
|
+
ps = doc.paragraphs
|
|
1728
|
+
for i, p in enumerate(ps):
|
|
1729
|
+
if p.style is None or p.style.name != "List Bullet" or not p.text.strip():
|
|
1730
|
+
continue
|
|
1731
|
+
before = ps[i - 1].style.name if i and ps[i - 1].style is not None else ""
|
|
1732
|
+
after = ps[i + 1].style.name if i + 1 < len(ps) and ps[i + 1].style is not None else ""
|
|
1733
|
+
if before != "List Bullet" and after != "List Bullet":
|
|
1734
|
+
p.style = doc.styles["Normal"]
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def hangul_upright(doc):
|
|
1738
|
+
"""Hangul is emphasised by weight only, never set in italic."""
|
|
1739
|
+
for p in doc.paragraphs:
|
|
1740
|
+
for r in p.runs:
|
|
1741
|
+
if r.italic and re.search(r"[가-힣]", r.text):
|
|
1742
|
+
r.italic = False
|
|
1743
|
+
|
|
1744
|
+
|
|
1745
|
+
def style_source_lines(doc, lang: str = "en"):
|
|
1746
|
+
for p in doc.paragraphs:
|
|
1747
|
+
text = p.text.strip()
|
|
1748
|
+
if SOURCE_LINE.match(text) and not _level(p) and (p.style is None or p.style.name in ("Normal", "Body Text")):
|
|
1749
|
+
p.style = doc.styles["Source Note"]
|
|
1750
|
+
if lang != "en" and text.startswith("출처") and p.runs:
|
|
1751
|
+
# Korean tables name their source as 자료: (주: notes first, then 자료:).
|
|
1752
|
+
p.runs[0].text = re.sub(r"^출처(\s*[::])", r"자료\1", p.runs[0].text)
|
|
1753
|
+
|
|
1754
|
+
|
|
1755
|
+
def korean_captions(doc):
|
|
1756
|
+
"""Korean captions in the institute form: <표 1> above a table, [그림 1] for a figure, the label bold."""
|
|
1757
|
+
for p in doc.paragraphs:
|
|
1758
|
+
if p.style is None or p.style.name != "Caption":
|
|
1759
|
+
continue
|
|
1760
|
+
text = p.text.strip()
|
|
1761
|
+
m = re.match(r"^(표|그림)\s*(\d+)[.:]\s*(.*)$", text, re.S)
|
|
1762
|
+
if not m:
|
|
1763
|
+
continue
|
|
1764
|
+
label = f"<표 {m.group(2)}>" if m.group(1) == "표" else f"[그림 {m.group(2)}]"
|
|
1765
|
+
size = next((r.font.size for r in p.runs if r.font.size), None)
|
|
1766
|
+
for r in list(p.runs):
|
|
1767
|
+
r._r.getparent().remove(r._r)
|
|
1768
|
+
a = p.add_run(label + " ")
|
|
1769
|
+
a.bold = True
|
|
1770
|
+
b = p.add_run(m.group(3))
|
|
1771
|
+
for r in (a, b):
|
|
1772
|
+
if size:
|
|
1773
|
+
r.font.size = size
|
|
1774
|
+
|
|
1775
|
+
|
|
1776
|
+
def english_captions(doc):
|
|
1777
|
+
"""An English table caption keeps its label bold ("Table 1.")."""
|
|
1778
|
+
for p in doc.paragraphs:
|
|
1779
|
+
if p.style is None or p.style.name != "Caption" or not p.runs or p.runs[0].bold:
|
|
1780
|
+
continue
|
|
1781
|
+
m = re.match(r"^(Table\s*\d+[.:])(.*)$", p.text.strip(), re.S)
|
|
1782
|
+
if m:
|
|
1783
|
+
for r in list(p.runs):
|
|
1784
|
+
r._r.getparent().remove(r._r)
|
|
1785
|
+
p.add_run(m.group(1)).bold = True
|
|
1786
|
+
p.add_run(m.group(2))
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
def recolour_links(doc, T: Tonality):
|
|
1790
|
+
for hl in doc.element.body.iter(qn("w:hyperlink")):
|
|
1791
|
+
for c in hl.iter(qn("w:color")):
|
|
1792
|
+
c.set(qn("w:val"), T.colour("ink"))
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def _all_tables(doc):
|
|
1796
|
+
for tbl in doc.element.body.iter(qn("w:tbl")):
|
|
1797
|
+
cap = tbl.find(f"{qn('w:tblPr')}/{qn('w:tblCaption')}")
|
|
1798
|
+
if cap is None:
|
|
1799
|
+
yield Table(tbl, doc._body)
|
|
1800
|
+
|
|
1801
|
+
|
|
1802
|
+
def _est_lines(text: str, width_pt: float, size: float) -> int:
|
|
1803
|
+
ems = sum(1.0 if "가" <= ch <= "힣" else 0.55 for ch in text)
|
|
1804
|
+
return max(1, math.ceil(ems * size * 1.05 / max(width_pt, 1)))
|
|
1805
|
+
|
|
1806
|
+
|
|
1807
|
+
NUMERIC = re.compile(r"^[+\-−±▲▼△▽]?\s*[\d.,:]+\s*(?:\([^)]*\))?\s*(?:%p?|배|x|pt|[가-힣]{1,3}|[A-Za-z]{1,3})?$")
|
|
1808
|
+
DASH_CELL = re.compile(r"^[—–\-]?$")
|
|
1809
|
+
MINUS = re.compile(r"(?:^|(?<=[\s(]))-(?=\d)")
|
|
1810
|
+
|
|
1811
|
+
|
|
1812
|
+
def style_table(doc, T: Tonality, table):
|
|
1813
|
+
"""Booktabs: a 1 pt rule above and below, 0.5 pt under the header, no verticals, no fills; the header
|
|
1814
|
+
bold ink; a total row bold under a 0.5 pt rule; numbers right-aligned in tabular figures."""
|
|
1815
|
+
ink = T.colour("ink")
|
|
1816
|
+
table.style = None
|
|
1817
|
+
rows = table.rows
|
|
1818
|
+
total = len(rows) > 2 and bool(TOTALS.match(rows[-1].cells[0].text.strip()))
|
|
1819
|
+
tbl_set(table, "tblBorders", _borders("tblBorders", {"top": (8, ink), "left": None, "bottom": (8, ink), "right": None, "insideH": None, "insideV": None}))
|
|
1820
|
+
cell_margins(table, 2 if T.tight else 3.5, 2 if T.tight else 3.5, 5, 5)
|
|
1821
|
+
cols = len(table.columns)
|
|
1822
|
+
numeric = []
|
|
1823
|
+
for ci in range(cols):
|
|
1824
|
+
data = [r.cells[ci].text.strip() for r in rows[1:] if ci < len(r.cells)]
|
|
1825
|
+
data = [t for t in data if not DASH_CELL.match(t)]
|
|
1826
|
+
numeric.append(bool(data) and all(NUMERIC.match(t) for t in data))
|
|
1827
|
+
for ri, row in enumerate(rows):
|
|
1828
|
+
for ci, cell in enumerate(row.cells):
|
|
1829
|
+
if ri == 0:
|
|
1830
|
+
cell_set(cell, _borders("tcBorders", {"bottom": (4, ink)}))
|
|
1831
|
+
elif total and ri == len(rows) - 1:
|
|
1832
|
+
cell_set(cell, _borders("tcBorders", {"top": (4, ink)}))
|
|
1833
|
+
for shd in cell._tc.findall(f"{qn('w:tcPr')}/{qn('w:shd')}"):
|
|
1834
|
+
shd.getparent().remove(shd)
|
|
1835
|
+
for p in cell.paragraphs:
|
|
1836
|
+
p.paragraph_format.line_spacing = T.line(1.25 if T.tight else 1.4)
|
|
1837
|
+
if ci < len(numeric) and numeric[ci]:
|
|
1838
|
+
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
|
1839
|
+
elif p.alignment in (None, WD_ALIGN_PARAGRAPH.JUSTIFY):
|
|
1840
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
1841
|
+
for r in p.runs:
|
|
1842
|
+
# A negative figure takes the minus sign, never a hyphen, Korean too.
|
|
1843
|
+
if ri and r.text:
|
|
1844
|
+
r.text = MINUS.sub("\u2212", r.text)
|
|
1845
|
+
r.font.size = Pt(T.table_pt)
|
|
1846
|
+
r.font.color.rgb = RGBColor.from_string(ink)
|
|
1847
|
+
if ri == 0 or (total and ri == len(rows) - 1):
|
|
1848
|
+
r.bold = True
|
|
1849
|
+
if ci < len(numeric) and numeric[ci] and ri:
|
|
1850
|
+
r._r.get_or_add_rPr().append(OxmlElement("w14:numSpacing", {qn("w14:val"): "tabular"}))
|
|
1851
|
+
|
|
1852
|
+
|
|
1853
|
+
def hoist_units(doc, table, lang: str):
|
|
1854
|
+
"""When every unit-bearing header cell names the same unit, the unit moves to one "(단위: …)" line
|
|
1855
|
+
right-aligned above the table and leaves the header cells (Korean tables)."""
|
|
1856
|
+
if lang == "en":
|
|
1857
|
+
return None
|
|
1858
|
+
head = table.rows[0].cells
|
|
1859
|
+
units = [re.search(r"\s*\(([^()]+)\)\s*$", c.text) for c in head[1:]]
|
|
1860
|
+
found = [u.group(1).strip() for u in units if u]
|
|
1861
|
+
if len(found) < 2 or len(set(found)) != 1 or len(found) != len(head) - 1:
|
|
1862
|
+
return None
|
|
1863
|
+
for cell in head[1:]:
|
|
1864
|
+
for p in cell.paragraphs:
|
|
1865
|
+
for r in p.runs:
|
|
1866
|
+
r.text = re.sub(r"\s*\([^()]+\)\s*$", "", r.text)
|
|
1867
|
+
return found[0]
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def fit_word_widths(table, size: float):
|
|
1871
|
+
"""No column narrower than its longest word plus the cell margins, so "Proposed" never wraps inside the
|
|
1872
|
+
word; the room comes from the widest column, and the table keeps its total width."""
|
|
1873
|
+
cols = len(table.columns)
|
|
1874
|
+
widths = [c.width or 0 for c in table.rows[0].cells][:cols]
|
|
1875
|
+
if cols < 2 or not all(widths):
|
|
1876
|
+
return
|
|
1877
|
+
need = [0] * cols
|
|
1878
|
+
for row in table.rows:
|
|
1879
|
+
for ci, cell in enumerate(row.cells[:cols]):
|
|
1880
|
+
for word in re.findall(r"[^\s()]+", cell.text):
|
|
1881
|
+
need[ci] = max(need[ci], int(Pt(_advance(word, size) + 14)))
|
|
1882
|
+
short = [ci for ci in range(cols) if widths[ci] < need[ci]]
|
|
1883
|
+
if not short:
|
|
1884
|
+
return
|
|
1885
|
+
for ci in short:
|
|
1886
|
+
donor = max(range(cols), key=lambda k: widths[k] - need[k])
|
|
1887
|
+
give = min(need[ci] - widths[ci], max(0, widths[donor] - need[donor]))
|
|
1888
|
+
widths[donor] -= give
|
|
1889
|
+
widths[ci] += give
|
|
1890
|
+
table_width(table, sum(widths), widths)
|
|
1891
|
+
|
|
1892
|
+
|
|
1893
|
+
def balance_columns(table, size: float):
|
|
1894
|
+
"""A text column that wraps while other columns hold their longest cell with room to spare takes that room (all of
|
|
1895
|
+
it when that is less than its longest cell needs, so it wraps onto fewer lines; "Harbour Road" no longer breaks);
|
|
1896
|
+
the table keeps its width and no column drops under its own longest cell."""
|
|
1897
|
+
cols = len(table.columns)
|
|
1898
|
+
widths = [c.width or 0 for c in table.rows[0].cells][:cols]
|
|
1899
|
+
if cols < 2 or not all(widths):
|
|
1900
|
+
return
|
|
1901
|
+
# Data cells set the need; a header may take two lines (its longest word still fits, fit_word_widths).
|
|
1902
|
+
body = list(table.rows)[1:] or list(table.rows)
|
|
1903
|
+
line = [max(int(Pt(_advance(row.cells[ci].text.strip(), size) + 14)) for row in body if ci < len(row.cells)) for ci in range(cols)]
|
|
1904
|
+
# A donor keeps its own longest cell on one line and every word of its header whole.
|
|
1905
|
+
words = [max([int(Pt(_advance(w, size) + 14)) for row in table.rows if ci < len(row.cells) for w in re.findall(r"[^\s()]+", row.cells[ci].text)] or [0]) for ci in range(cols)]
|
|
1906
|
+
# ... and its header within three lines.
|
|
1907
|
+
head = list(table.rows)[0].cells
|
|
1908
|
+
words = [max(words[ci], int(Pt(_advance(head[ci].text.strip(), size) / 3 + 14)) if ci < len(head) else 0) for ci in range(cols)]
|
|
1909
|
+
spare = [max(0, widths[k] - max(line[k], words[k])) for k in range(cols)]
|
|
1910
|
+
changed = False
|
|
1911
|
+
for ci in sorted((c for c in range(cols) if widths[c] < line[c]), key=lambda c: line[c] - widths[c]):
|
|
1912
|
+
deficit = line[ci] - widths[ci]
|
|
1913
|
+
donors = [k for k in range(cols) if k != ci and spare[k] > 0]
|
|
1914
|
+
# The room the others can spare, all of it when it is less than the column needs: fewer lines still help.
|
|
1915
|
+
gain = min(deficit, sum(spare[k] for k in donors))
|
|
1916
|
+
if gain < Pt(12):
|
|
1917
|
+
continue
|
|
1918
|
+
left = gain
|
|
1919
|
+
for k in sorted(donors, key=lambda k: -spare[k]):
|
|
1920
|
+
give = min(spare[k], left)
|
|
1921
|
+
widths[k] -= give
|
|
1922
|
+
spare[k] -= give
|
|
1923
|
+
left -= give
|
|
1924
|
+
if not left:
|
|
1925
|
+
break
|
|
1926
|
+
widths[ci] += gain
|
|
1927
|
+
spare[ci] = max(0, widths[ci] - max(line[ci], words[ci]))
|
|
1928
|
+
changed = True
|
|
1929
|
+
if changed:
|
|
1930
|
+
table_width(table, sum(widths), widths)
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def boxes_clear(doc):
|
|
1934
|
+
"""A component box (callout, sidebar) right under a paragraph or list item stands 6 pt clear of it: its top rule
|
|
1935
|
+
touched the line above."""
|
|
1936
|
+
for el in doc.element.body.iterchildren(qn("w:tbl")):
|
|
1937
|
+
cap = el.find(f"{qn('w:tblPr')}/{qn('w:tblCaption')}")
|
|
1938
|
+
if cap is not None and (cap.get(qn("w:val")) or "").split(" ")[0] in KINDS:
|
|
1939
|
+
lead_in_clear(Table(el, doc._body), boxes=True)
|
|
1940
|
+
|
|
1941
|
+
|
|
1942
|
+
def lead_in_clear(table, boxes=False):
|
|
1943
|
+
"""A table without a caption under a sentence stands clear of it: the sentence keeps 6 pt under itself (a table
|
|
1944
|
+
cannot take space above), so the header rule never touches the line above."""
|
|
1945
|
+
prev = table._tbl.getprevious()
|
|
1946
|
+
if prev is None or prev.tag != qn("w:p"):
|
|
1947
|
+
return
|
|
1948
|
+
p = Paragraph(prev, table._parent)
|
|
1949
|
+
text = p.text.strip()
|
|
1950
|
+
if not text or (not boxes and TABLE_CAPTION.match(text)) or _level(p) or (p.style is not None and p.style.name in ("Caption", "Source Note")):
|
|
1951
|
+
return
|
|
1952
|
+
if p.paragraph_format.space_after is None or p.paragraph_format.space_after < Pt(6):
|
|
1953
|
+
p.paragraph_format.space_after = Pt(6)
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def finish_tables(doc, T: Tonality, lang: str):
|
|
1957
|
+
"""Every data table in booktabs, its caption kept with it (with a units line under the caption when the
|
|
1958
|
+
unit moves out of the header), and kept on one page when it fits there."""
|
|
1959
|
+
frame_h = frame_height(doc) / 12700
|
|
1960
|
+
for table in list(_all_tables(doc)):
|
|
1961
|
+
prev = table._tbl.getprevious()
|
|
1962
|
+
caption = Paragraph(prev, table._parent) if prev is not None and prev.tag == qn("w:p") else None
|
|
1963
|
+
if caption is not None and TABLE_CAPTION.match(caption.text.strip()):
|
|
1964
|
+
caption.style = doc.styles["Caption"]
|
|
1965
|
+
caption.paragraph_format.keep_with_next = True
|
|
1966
|
+
unit = hoist_units(doc, table, lang)
|
|
1967
|
+
style_table(doc, T, table)
|
|
1968
|
+
if unit:
|
|
1969
|
+
p = Paragraph(OxmlElement("w:p"), table._parent)
|
|
1970
|
+
table._tbl.addprevious(p._p)
|
|
1971
|
+
p.style = doc.styles["Source Note"]
|
|
1972
|
+
p.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
|
1973
|
+
p.paragraph_format.space_before = Pt(0)
|
|
1974
|
+
p.paragraph_format.space_after = Pt(2)
|
|
1975
|
+
p.paragraph_format.keep_with_next = True
|
|
1976
|
+
p.add_run(f"({LABELS[lang]['unit']}: {unit})")
|
|
1977
|
+
if caption is not None:
|
|
1978
|
+
caption.paragraph_format.space_after = Pt(0)
|
|
1979
|
+
fit_word_widths(table, T.table_pt)
|
|
1980
|
+
balance_columns(table, T.table_pt)
|
|
1981
|
+
lead_in_clear(table)
|
|
1982
|
+
widths = [c.width / 12700 if c.width else 100 for c in table.rows[0].cells]
|
|
1983
|
+
heights = [7 + T.table_pt * 1.4 * max(_est_lines(c.text, (widths[i] if i < len(widths) else 100) - 12, T.table_pt) for i, c in enumerate(row.cells)) for row in table.rows]
|
|
1984
|
+
est = sum(heights)
|
|
1985
|
+
if est <= 0.9 * frame_h:
|
|
1986
|
+
# A source or note line under the table keeps with it, so it never opens the next page alone.
|
|
1987
|
+
nxt = table._tbl.getnext()
|
|
1988
|
+
noted = nxt is not None and nxt.tag == qn("w:p") and bool(SOURCE_LINE.match("".join(t.text or "" for t in nxt.iter(qn("w:t"))).strip()))
|
|
1989
|
+
rows = list(table.rows)
|
|
1990
|
+
# A long table (header + 6 body rows or more) may break between rows, header repeated,
|
|
1991
|
+
# only where a quarter of the frame or more is left: the header keeps rows worth a quarter of the frame
|
|
1992
|
+
# (and at least three body rows) with it, so the table can only break when moving it whole would leave
|
|
1993
|
+
# the page under 0.75 filled; the last three body rows stay together.
|
|
1994
|
+
head, filled = 0, 0.0
|
|
1995
|
+
while head < len(rows) and (head < 4 or filled < 0.25 * frame_h):
|
|
1996
|
+
filled += heights[head]
|
|
1997
|
+
head += 1
|
|
1998
|
+
if len(rows) >= 7 and head <= len(rows) - 3:
|
|
1999
|
+
keep = rows[:head - 1] + rows[-3:-1] + ([rows[-1]] if noted else [])
|
|
2000
|
+
else:
|
|
2001
|
+
keep = rows if noted else rows[:-1]
|
|
2002
|
+
for row in keep:
|
|
2003
|
+
for cell in row.cells:
|
|
2004
|
+
cell.paragraphs[0].paragraph_format.keep_with_next = True
|
|
2005
|
+
space_after_table(table)
|
|
2006
|
+
|
|
2007
|
+
|
|
2008
|
+
def space_after_table(table):
|
|
2009
|
+
"""The text under a table stands clear of its bottom rule: a note or source line 3 pt below it, the next
|
|
2010
|
+
paragraph 8 pt below the table or its last note (it touched the rule)."""
|
|
2011
|
+
nxt = table._tbl.getnext()
|
|
2012
|
+
gap = 3
|
|
2013
|
+
while nxt is not None and nxt.tag == qn("w:p"):
|
|
2014
|
+
if nxt.find(f"{qn('w:pPr')}/{qn('w:sectPr')}") is not None:
|
|
2015
|
+
# The section break after a page-wide table in a column body: the text below starts after it.
|
|
2016
|
+
nxt = nxt.getnext()
|
|
2017
|
+
continue
|
|
2018
|
+
p = Paragraph(nxt, table._parent)
|
|
2019
|
+
note = bool(SOURCE_LINE.match(p.text.strip())) or (p.style is not None and p.style.name == "Source Note")
|
|
2020
|
+
if not note and _level(p):
|
|
2021
|
+
return
|
|
2022
|
+
current = p.paragraph_format.space_before
|
|
2023
|
+
if current is None or current.pt < (gap if note else 8):
|
|
2024
|
+
p.paragraph_format.space_before = Pt(gap if note else 8)
|
|
2025
|
+
if not note:
|
|
2026
|
+
return
|
|
2027
|
+
gap = 0
|
|
2028
|
+
nxt = nxt.getnext()
|
|
2029
|
+
|
|
2030
|
+
|
|
2031
|
+
def size_figures(doc, T: Tonality, start: int, width: int):
|
|
2032
|
+
shapes = list(doc.inline_shapes)[start:]
|
|
2033
|
+
max_h = frame_height(doc) * float((T.design.get("figure_style") or {}).get("max_height_ratio", 0.45))
|
|
2034
|
+
for shape in shapes:
|
|
2035
|
+
w, h = shape.width, shape.height
|
|
2036
|
+
if not w or not h:
|
|
2037
|
+
continue
|
|
2038
|
+
scale = min(width / w, max_h / h)
|
|
2039
|
+
shape.width, shape.height = Emu(int(w * scale)), Emu(int(h * scale))
|
|
2040
|
+
|
|
2041
|
+
|
|
2042
|
+
def single_spaced_figures(doc):
|
|
2043
|
+
"""A paragraph holding a picture is single spaced: a Latin body multiple under 1 cut the top off the image."""
|
|
2044
|
+
for p in doc.element.body.iter(qn("w:p")):
|
|
2045
|
+
if p.find(f".//{qn('w:drawing')}") is not None:
|
|
2046
|
+
Paragraph(p, doc._body).paragraph_format.line_spacing = 1.0
|
|
2047
|
+
|
|
2048
|
+
|
|
2049
|
+
# ── Header and footer ───────────────────────────────────────────────────────
|
|
2050
|
+
|
|
2051
|
+
def short_title(front: dict) -> str:
|
|
2052
|
+
if front.get("short_title"):
|
|
2053
|
+
return str(front["short_title"])
|
|
2054
|
+
title = str(front.get("title") or "")
|
|
2055
|
+
if len(title) <= 40:
|
|
2056
|
+
return title
|
|
2057
|
+
cut = title[:40].rsplit(" ", 1)[0]
|
|
2058
|
+
return cut or title[:40]
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
def _running_line(p: Paragraph, items: list, front: dict, T: Tonality, width: int, align: str):
|
|
2062
|
+
"""Running-head items on one line at about 82 % of the body size, regular, in the muted ink: the first
|
|
2063
|
+
item at the left edge, the last at the right; a folio alone takes the pack's alignment."""
|
|
2064
|
+
size, muted = T.furniture_pt, T.colour("ink_muted")
|
|
2065
|
+
p.paragraph_format.space_before = Pt(0)
|
|
2066
|
+
p.paragraph_format.space_after = Pt(0)
|
|
2067
|
+
|
|
2068
|
+
def put(item):
|
|
2069
|
+
if item == "title":
|
|
2070
|
+
styled_run(p, short_title(front), size, muted)
|
|
2071
|
+
elif item == "folio":
|
|
2072
|
+
add_field(p, "PAGE", size, muted)
|
|
2073
|
+
elif item == "section":
|
|
2074
|
+
add_field(p, 'STYLEREF "Heading 1"', size, muted, "")
|
|
2075
|
+
|
|
2076
|
+
if len(items) == 1:
|
|
2077
|
+
p.alignment = {"center": WD_ALIGN_PARAGRAPH.CENTER, "left": WD_ALIGN_PARAGRAPH.LEFT}.get(align, WD_ALIGN_PARAGRAPH.RIGHT) if items[0] == "folio" else WD_ALIGN_PARAGRAPH.LEFT
|
|
2078
|
+
put(items[0])
|
|
2079
|
+
return
|
|
2080
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
2081
|
+
# The Header and Footer styles carry a centre tab mid-line; cleared, or the folio stops there.
|
|
2082
|
+
style = p.style
|
|
2083
|
+
while style is not None:
|
|
2084
|
+
for stop in style.paragraph_format.tab_stops:
|
|
2085
|
+
if stop.position < width:
|
|
2086
|
+
p.paragraph_format.tab_stops.add_tab_stop(stop.position, WD_TAB_ALIGNMENT.CLEAR)
|
|
2087
|
+
style = style.base_style
|
|
2088
|
+
p.paragraph_format.tab_stops.add_tab_stop(Emu(width), WD_TAB_ALIGNMENT.RIGHT)
|
|
2089
|
+
put(items[0])
|
|
2090
|
+
p.add_run("\t")
|
|
2091
|
+
put(items[-1])
|
|
2092
|
+
|
|
2093
|
+
|
|
2094
|
+
def headers(ctx, front: dict, first_body: int):
|
|
2095
|
+
"""The running head and folio the pack names; none on a cover page or on page 1 of a document that
|
|
2096
|
+
opens with its title block. The notice is never repeated here."""
|
|
2097
|
+
doc, T = ctx.doc, ctx.T
|
|
2098
|
+
section = doc.sections[first_body]
|
|
2099
|
+
W = frame_width(doc)
|
|
2100
|
+
header, footer = section.header, section.footer
|
|
2101
|
+
header.is_linked_to_previous = False
|
|
2102
|
+
footer.is_linked_to_previous = False
|
|
2103
|
+
rh = T.running_head
|
|
2104
|
+
if not first_body:
|
|
2105
|
+
section.different_first_page_header_footer = True
|
|
2106
|
+
if rh.get("header"):
|
|
2107
|
+
_running_line(header.paragraphs[0], rh["header"], front, T, W, rh.get("folio_align", "right"))
|
|
2108
|
+
if rh.get("footer"):
|
|
2109
|
+
_running_line(footer.paragraphs[0], rh["footer"], front, T, W, rh.get("folio_align", "right"))
|
|
2110
|
+
|
|
2111
|
+
|
|
2112
|
+
def neutral_headers(ctx, front: dict):
|
|
2113
|
+
"""The plain profile: the notice tag in every header."""
|
|
2114
|
+
if front.get("notice"):
|
|
2115
|
+
notice_tag(ctx.doc.sections[0].header.paragraphs[0], str(front["notice"]))
|
|
2116
|
+
|
|
2117
|
+
|
|
2118
|
+
# ── The build ───────────────────────────────────────────────────────────────
|
|
2119
|
+
|
|
2120
|
+
def _plain_text(text: str) -> str:
|
|
2121
|
+
return re.sub(r"\s+", "", re.sub(r"[*_`#>|\-]", "", text))
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def estimate_pages(body: str, T: Tonality) -> float:
|
|
2125
|
+
"""A rough page count for the component budget: characters of running text plus about one line of
|
|
2126
|
+
characters per table row, over the characters a page holds at the pack's size and leading."""
|
|
2127
|
+
rows = sum(1 for line in body.split("\n") if line.strip().startswith("|"))
|
|
2128
|
+
chars = len(_plain_text(body)) + rows * (40 if T.locale != "en" else 90)
|
|
2129
|
+
per_page = 1500 if T.locale != "en" else 3300
|
|
2130
|
+
return chars / per_page
|
|
2131
|
+
|
|
2132
|
+
|
|
2133
|
+
class Build:
|
|
2134
|
+
"""One conversion: the document, its tokens and the Markdown renderer the converter lends it."""
|
|
2135
|
+
|
|
2136
|
+
def __init__(self, doc, T: Tonality, render_md, lang: str, filters=None):
|
|
2137
|
+
self.doc, self.T, self._render_md, self.lang = doc, T, render_md, lang
|
|
2138
|
+
self.filters = filters or (lambda s: s)
|
|
2139
|
+
self.column_count = T.columns
|
|
2140
|
+
self.kinds = []
|
|
2141
|
+
self.callouts_ok = None # the callouts drawn as boxes, by kind, title and text (None: every callout)
|
|
2142
|
+
self.keyfigures_drawn = 0
|
|
2143
|
+
self.body_text = ""
|
|
2144
|
+
self.title_end = None
|
|
2145
|
+
self.notes = []
|
|
2146
|
+
|
|
2147
|
+
def render(self, container, text: str, size: float | None = None):
|
|
2148
|
+
"""Markdown (which may hold directives) into a container; figures sized to its width."""
|
|
2149
|
+
width = container.width if isinstance(container, Box) else frame_width(self.doc) if self.column_count == 1 else \
|
|
2150
|
+
int((frame_width(self.doc) - Mm(self.T.column_gap_mm) * (self.column_count - 1)) / self.column_count)
|
|
2151
|
+
for block in split_directives(text):
|
|
2152
|
+
if isinstance(block, Directive):
|
|
2153
|
+
self.directive(container, block, width)
|
|
2154
|
+
continue
|
|
2155
|
+
if COLUMN_BREAK.search(block):
|
|
2156
|
+
raise DesignError("<!-- column-break --> stands only inside ::: columns")
|
|
2157
|
+
block = re.sub(r"<!--.*?-->", "", block, flags=re.S)
|
|
2158
|
+
block = CAPTION_ATTR.sub(self._caption, block)
|
|
2159
|
+
if not block.strip():
|
|
2160
|
+
continue
|
|
2161
|
+
start = len(self.doc.inline_shapes)
|
|
2162
|
+
before = len(container.paragraphs) if size else 0
|
|
2163
|
+
tables = len(container.tables) if not isinstance(container, Box) else 0
|
|
2164
|
+
self._render_md(container, self.filters(block))
|
|
2165
|
+
size_figures(self.doc, self.T, start, width)
|
|
2166
|
+
if not isinstance(container, Box) and self.column_count > 1:
|
|
2167
|
+
for table in container.tables[tables:]:
|
|
2168
|
+
cols = [c.width or 1 for c in table.rows[0].cells]
|
|
2169
|
+
if len(cols) > 3:
|
|
2170
|
+
# Wider than a column reads: the table and its caption span the page.
|
|
2171
|
+
self.span_page(table)
|
|
2172
|
+
full = frame_width(self.doc)
|
|
2173
|
+
table_width(table, full, [int(full * c / sum(cols)) for c in cols])
|
|
2174
|
+
else:
|
|
2175
|
+
# A table set in a column takes the column's width, its columns in proportion.
|
|
2176
|
+
table_width(table, width, [int(width * c / sum(cols)) for c in cols])
|
|
2177
|
+
if size:
|
|
2178
|
+
for p in container.paragraphs[before:]:
|
|
2179
|
+
for r in p.runs:
|
|
2180
|
+
r.font.size = Pt(size)
|
|
2181
|
+
|
|
2182
|
+
def _caption(self, m):
|
|
2183
|
+
style = m.group(2)
|
|
2184
|
+
if style not in TABLE_STYLES:
|
|
2185
|
+
raise DesignError(f"{{style={style}}}: table styles are {', '.join(TABLE_STYLES)}")
|
|
2186
|
+
if style != "booktabs" and not self.T.neutral:
|
|
2187
|
+
self.notes.append(f"{{style={style}}} on '{m.group(1).strip()[:30]}' is drawn as booktabs (every tonality)")
|
|
2188
|
+
return m.group(1)
|
|
2189
|
+
|
|
2190
|
+
def allowed(self, d: Directive) -> bool:
|
|
2191
|
+
"""The component budget: the pack's kinds, three kinds at most, one key-figure strip, the callouts
|
|
2192
|
+
chosen for the document's length, two columns only when the parts are balanced."""
|
|
2193
|
+
kind = d.name
|
|
2194
|
+
if self.T.neutral:
|
|
2195
|
+
return kind != "pullquote"
|
|
2196
|
+
if kind not in self.T.allowed_kinds:
|
|
2197
|
+
return False
|
|
2198
|
+
if kind == "keyfigures" and self.keyfigures_drawn >= 1:
|
|
2199
|
+
return False
|
|
2200
|
+
if kind == "callout" and self.callouts_ok is not None and _callout_key(d) not in self.callouts_ok:
|
|
2201
|
+
return False
|
|
2202
|
+
if kind == "columns" and not balanced_parts(d):
|
|
2203
|
+
return False
|
|
2204
|
+
if kind not in self.kinds:
|
|
2205
|
+
if len(self.kinds) >= 3:
|
|
2206
|
+
return False
|
|
2207
|
+
self.kinds.append(kind)
|
|
2208
|
+
return True
|
|
2209
|
+
|
|
2210
|
+
def directive(self, container, d: Directive, width: int):
|
|
2211
|
+
if d.name == "cover":
|
|
2212
|
+
raise DesignError(f"line {d.line}: ::: cover stands first in the document, before any other block")
|
|
2213
|
+
if d.name == "columns" and isinstance(container, Box):
|
|
2214
|
+
raise DesignError(f"line {d.line}: ::: columns stands at the top level only")
|
|
2215
|
+
if d.name == "pullquote" and not self.allowed(d):
|
|
2216
|
+
# Pull quotes are off: a sentence the body already says is left out, anything else stays as a paragraph.
|
|
2217
|
+
if _plain_text(d.body) in self.body_text:
|
|
2218
|
+
self.notes.append(f"line {d.line}: the pull quote repeats the body and is left out")
|
|
2219
|
+
return
|
|
2220
|
+
self.render(container, d.body)
|
|
2221
|
+
return
|
|
2222
|
+
if not self.allowed(d):
|
|
2223
|
+
# A component the direction does not use keeps its content as ordinary text.
|
|
2224
|
+
before = len(container.paragraphs)
|
|
2225
|
+
if d.attrs.get("title"):
|
|
2226
|
+
# A run-in head over the block's text takes a heading's space above it, so it starts a block.
|
|
2227
|
+
p = container.add_paragraph()
|
|
2228
|
+
p.paragraph_format.keep_with_next = True
|
|
2229
|
+
p.paragraph_format.space_before = Pt(12)
|
|
2230
|
+
p.paragraph_format.space_after = Pt(2)
|
|
2231
|
+
styled_run(p, d.attrs["title"], None, self.T.colour("ink"), True)
|
|
2232
|
+
body = COLUMN_BREAK.sub("", d.body)
|
|
2233
|
+
if d.name == "keyfigures":
|
|
2234
|
+
body = "\n".join(f"- {i['figure'].join(['**', '**'])} {_with_basis(i['label'], i['basis'])}" for i in keyfigure_items(d.body, d.line))
|
|
2235
|
+
self.render(container, body)
|
|
2236
|
+
# A short block set inline (a sidebar of definitions, a figure list) stays together on one page.
|
|
2237
|
+
added = [q for q in container.paragraphs[before:] if q.text.strip()]
|
|
2238
|
+
if d.name in ("sidebar", "keyfigures", "callout") and len(added) <= 8:
|
|
2239
|
+
for q in added[:-1]:
|
|
2240
|
+
q.paragraph_format.keep_with_next = True
|
|
2241
|
+
return
|
|
2242
|
+
if d.name == "keyfigures":
|
|
2243
|
+
self.keyfigures_drawn += 1
|
|
2244
|
+
add_keyfigures(self, container, d, width)
|
|
2245
|
+
elif d.name == "callout":
|
|
2246
|
+
add_callout(self, container, d, width)
|
|
2247
|
+
elif d.name == "sidebar":
|
|
2248
|
+
add_sidebar(self, container, d, width)
|
|
2249
|
+
elif d.name == "columns":
|
|
2250
|
+
add_columns(self, d)
|
|
2251
|
+
|
|
2252
|
+
def spacer(self, container):
|
|
2253
|
+
p = container.add_paragraph()
|
|
2254
|
+
tiny(p)
|
|
2255
|
+
p.paragraph_format.space_after = Pt(8)
|
|
2256
|
+
|
|
2257
|
+
def span_page(self, table):
|
|
2258
|
+
"""Set one table (and the caption above it) in a one-column section inside a column body."""
|
|
2259
|
+
sentinel = self.doc.element.body.find(qn("w:sectPr"))
|
|
2260
|
+
|
|
2261
|
+
def carrier(cols: int):
|
|
2262
|
+
sect = copy.deepcopy(sentinel)
|
|
2263
|
+
for el in sect.findall(qn("w:headerReference")) + sect.findall(qn("w:footerReference")) + sect.findall(qn("w:pgNumType")) + sect.findall(qn("w:titlePg")):
|
|
2264
|
+
sect.remove(el)
|
|
2265
|
+
_put(sect, _el("type", val="continuous"), SECTPR_ORDER)
|
|
2266
|
+
_put(sect, _el("cols", num=cols, space=int(self.T.column_gap_mm * 56.693)), SECTPR_ORDER)
|
|
2267
|
+
p = Paragraph(OxmlElement("w:p"), self.doc._body)
|
|
2268
|
+
tiny(p)
|
|
2269
|
+
_put(p._p.get_or_add_pPr(), sect, PPR_ORDER)
|
|
2270
|
+
return p._p
|
|
2271
|
+
|
|
2272
|
+
start = table._tbl
|
|
2273
|
+
prev = start.getprevious()
|
|
2274
|
+
if prev is not None and prev.tag == qn("w:p") and TABLE_CAPTION.match("".join(t.text or "" for t in prev.iter(qn("w:t"))).strip()):
|
|
2275
|
+
start = prev
|
|
2276
|
+
start.addprevious(carrier(self.column_count))
|
|
2277
|
+
# A source or note line under the table spans with it: left in the column section it opened a short
|
|
2278
|
+
# column at the page foot, and the heading after it stood there alone.
|
|
2279
|
+
end = table._tbl
|
|
2280
|
+
while end.getnext() is not None and end.getnext().tag == qn("w:p") and SOURCE_LINE.match("".join(t.text or "" for t in end.getnext().iter(qn("w:t"))).strip()):
|
|
2281
|
+
end = end.getnext()
|
|
2282
|
+
end.addnext(carrier(1))
|
|
2283
|
+
|
|
2284
|
+
def section_break(self, cols: int, gap: float, rule: bool = False):
|
|
2285
|
+
doc = self.doc
|
|
2286
|
+
new = doc.add_section(WD_SECTION.CONTINUOUS)
|
|
2287
|
+
tiny(doc.paragraphs[-1])
|
|
2288
|
+
# Page numbers run on through a column section; only the first body section restarts them.
|
|
2289
|
+
for restart in new._sectPr.findall(qn("w:pgNumType")) + new._sectPr.findall(qn("w:titlePg")):
|
|
2290
|
+
new._sectPr.remove(restart)
|
|
2291
|
+
el = _el("cols", num=cols, space=int(gap * 56.693))
|
|
2292
|
+
if rule:
|
|
2293
|
+
el.set(qn("w:sep"), "1")
|
|
2294
|
+
_put(new._sectPr, el, SECTPR_ORDER)
|
|
2295
|
+
self.column_count = cols
|
|
2296
|
+
|
|
2297
|
+
def balance_last_page(self):
|
|
2298
|
+
"""A column body ends with a continuous break, so its last page sets the columns to an even depth instead
|
|
2299
|
+
of one column of text beside an empty one."""
|
|
2300
|
+
cols = self.doc.element.body.find(qn("w:sectPr")).find(qn("w:cols"))
|
|
2301
|
+
if cols is None or int(cols.get(qn("w:num"), "1") or 1) < 2:
|
|
2302
|
+
return
|
|
2303
|
+
# LibreOffice gave up balancing when widow control held a short closing paragraph whole.
|
|
2304
|
+
last = next((p for p in reversed(self.doc.paragraphs) if p.text.strip()), None)
|
|
2305
|
+
if last is not None:
|
|
2306
|
+
last.paragraph_format.widow_control = False
|
|
2307
|
+
self.section_break(1, self.T.column_gap_mm)
|
|
2308
|
+
# The closing section holds one hidden 1 pt paragraph: an empty one took a body line of its own, and a last
|
|
2309
|
+
# page whose columns could not balance spilled it onto a blank page.
|
|
2310
|
+
end = self.doc.add_paragraph()
|
|
2311
|
+
tiny(end)
|
|
2312
|
+
end._p.pPr.find(qn("w:rPr")).insert(0, _el("vanish"))
|
|
2313
|
+
|
|
2314
|
+
|
|
2315
|
+
def choose_callouts(blocks: list, T: Tonality, body: str) -> set | None:
|
|
2316
|
+
"""The callouts drawn as boxes: about one per four pages (the pack may cap it), the decision request first,
|
|
2317
|
+
then warnings, then notes; the others keep their content as text."""
|
|
2318
|
+
if T.neutral:
|
|
2319
|
+
return None
|
|
2320
|
+
found = [b for b in blocks if isinstance(b, Directive) and b.name == "callout"]
|
|
2321
|
+
cap = T.components.get("callout_max")
|
|
2322
|
+
budget = max(1, round(estimate_pages(body, T) / 4))
|
|
2323
|
+
if cap is not None:
|
|
2324
|
+
budget = min(budget, int(cap))
|
|
2325
|
+
ranked = sorted(found, key=lambda b: (CALLOUT_PRIORITY.get(b.attrs.get("kind"), 3), b.line))
|
|
2326
|
+
return {_callout_key(b) for b in ranked[:budget]}
|
|
2327
|
+
|
|
2328
|
+
|
|
2329
|
+
def _callout_key(d: Directive) -> tuple:
|
|
2330
|
+
return (d.attrs.get("kind"), d.attrs.get("title"), d.body.strip())
|
|
2331
|
+
|
|
2332
|
+
|
|
2333
|
+
def _place_after_first_paragraph(blocks: list, directive: Directive) -> list:
|
|
2334
|
+
"""Put a directive after the first paragraph of the first section (the summary)."""
|
|
2335
|
+
for i, b in enumerate(blocks):
|
|
2336
|
+
if isinstance(b, str) and re.search(r"^#{1,2}\s", b, re.M):
|
|
2337
|
+
m = re.search(r"^#{1,2}\s.*$", b, re.M)
|
|
2338
|
+
rest = b[m.end():]
|
|
2339
|
+
para = re.search(r"\S.*?(?:\n\s*\n|\Z)", rest, re.S)
|
|
2340
|
+
if not para:
|
|
2341
|
+
return blocks[:i + 1] + [directive] + blocks[i + 1:]
|
|
2342
|
+
cut = m.end() + para.end()
|
|
2343
|
+
return blocks[:i] + [b[:cut], directive, b[cut:]] + blocks[i + 1:]
|
|
2344
|
+
return [directive] + blocks
|
|
2345
|
+
|
|
2346
|
+
|
|
2347
|
+
def build(doc, front: dict, body: str, T: Tonality, render_md, lang: str, filters=None):
|
|
2348
|
+
"""Convert a body (frontmatter already parsed) into doc with the tonality T."""
|
|
2349
|
+
# A source short enough for about one page is set tight in any tonality, as a memo is, so a page of text never
|
|
2350
|
+
# spills a few lines onto a near-empty second page.
|
|
2351
|
+
if not T.neutral and not T.tight and estimate_pages(body, T) <= 1.0:
|
|
2352
|
+
T.design["spacing"] = "tight"
|
|
2353
|
+
print(f"Note: a source of about one page is set with tight spacing so it stays on one page")
|
|
2354
|
+
ctx = Build(doc, T, render_md, lang, filters)
|
|
2355
|
+
ctx.body_text = _plain_text(body)
|
|
2356
|
+
set_page(doc.sections[0], T)
|
|
2357
|
+
setup_styles(doc, T)
|
|
2358
|
+
blocks = split_directives(body)
|
|
2359
|
+
while blocks and isinstance(blocks[0], str) and not blocks[0].strip():
|
|
2360
|
+
blocks.pop(0)
|
|
2361
|
+
cover = blocks.pop(0) if blocks and isinstance(blocks[0], Directive) and blocks[0].name == "cover" else None
|
|
2362
|
+
if any(isinstance(b, Directive) and b.name == "cover" for b in blocks):
|
|
2363
|
+
raise DesignError("::: cover stands first in the document, before any other block")
|
|
2364
|
+
if cover is not None:
|
|
2365
|
+
_check(cover.attrs.get("variant") or "typographic", COVERS, f"line {cover.line}: cover variant")
|
|
2366
|
+
if not front.get("title"):
|
|
2367
|
+
raise DesignError(f"line {cover.line}: a cover takes its title from frontmatter title:, which is missing")
|
|
2368
|
+
# Key figures never stand on a cover: they move to the summary when the tonality uses them, and
|
|
2369
|
+
# otherwise stay out (the summary states the same numbers).
|
|
2370
|
+
figures = [b for b in split_directives(cover.body, cover.line) if isinstance(b, Directive) and b.name == "keyfigures"]
|
|
2371
|
+
if figures and not T.neutral:
|
|
2372
|
+
if "keyfigures" in T.allowed_kinds:
|
|
2373
|
+
blocks = _place_after_first_paragraph(blocks, figures[0])
|
|
2374
|
+
else:
|
|
2375
|
+
ctx.notes.append(f"{T.name} sets no key figures; the cover's figures stay in the summary text")
|
|
2376
|
+
if T.design.get("conclusion_first"):
|
|
2377
|
+
# Brief: the decision request opens the document, right under the title block.
|
|
2378
|
+
key = next((b for b in blocks if isinstance(b, Directive) and b.name == "callout" and b.attrs.get("kind") == "key"), None)
|
|
2379
|
+
if key is not None:
|
|
2380
|
+
blocks.remove(key)
|
|
2381
|
+
blocks.insert(0, key)
|
|
2382
|
+
ctx.callouts_ok = choose_callouts(blocks, T, body)
|
|
2383
|
+
first_body = 0
|
|
2384
|
+
if T.neutral:
|
|
2385
|
+
if front.get("title"):
|
|
2386
|
+
masthead(ctx, front, cover)
|
|
2387
|
+
elif T.title_block == "cover" and front.get("title"):
|
|
2388
|
+
cover_page(ctx, front, cover)
|
|
2389
|
+
first_body = 1
|
|
2390
|
+
elif front.get("title"):
|
|
2391
|
+
title_block(ctx, front, cover)
|
|
2392
|
+
if T.columns > 1:
|
|
2393
|
+
if first_body:
|
|
2394
|
+
_put(doc.sections[-1]._sectPr, _el("cols", num=T.columns, space=int(T.column_gap_mm * 56.693)), SECTPR_ORDER)
|
|
2395
|
+
else:
|
|
2396
|
+
ctx.column_count = 1
|
|
2397
|
+
ctx.section_break(T.columns, T.column_gap_mm)
|
|
2398
|
+
rest = "\n".join(b if isinstance(b, str) else _unsplit(b) for b in blocks)
|
|
2399
|
+
ctx.render(doc, rest)
|
|
2400
|
+
if not T.neutral:
|
|
2401
|
+
treat_headings(doc, T)
|
|
2402
|
+
if T.design.get("summary_form") == "points":
|
|
2403
|
+
summary_points(doc, T)
|
|
2404
|
+
settle_sidebars(doc, T)
|
|
2405
|
+
keep_short_followers(doc)
|
|
2406
|
+
keep_short_lists(doc)
|
|
2407
|
+
if T.columns == 1:
|
|
2408
|
+
# In a column body the chain jumped to the next column and left the one before it empty.
|
|
2409
|
+
keep_heading_with_table(doc)
|
|
2410
|
+
keep_closing_section(doc, T)
|
|
2411
|
+
single_rule_after_boxes(doc)
|
|
2412
|
+
last_line_company(doc)
|
|
2413
|
+
single_spaced_figures(doc)
|
|
2414
|
+
restart_numbered_lists(doc)
|
|
2415
|
+
space_after_lists(doc)
|
|
2416
|
+
style_source_lines(doc, lang)
|
|
2417
|
+
finish_tables(doc, T, lang)
|
|
2418
|
+
boxes_clear(doc)
|
|
2419
|
+
if not T.neutral:
|
|
2420
|
+
lone_list_items(doc)
|
|
2421
|
+
if lang != "en":
|
|
2422
|
+
korean_captions(doc)
|
|
2423
|
+
hangul_upright(doc)
|
|
2424
|
+
else:
|
|
2425
|
+
english_captions(doc)
|
|
2426
|
+
if T.design.get("contents") and estimate_pages(body, T) >= 5:
|
|
2427
|
+
add_contents(ctx, bookmark_h1(doc))
|
|
2428
|
+
recolour_links(doc, T)
|
|
2429
|
+
if T.neutral:
|
|
2430
|
+
neutral_headers(ctx, front)
|
|
2431
|
+
else:
|
|
2432
|
+
headers(ctx, front, first_body)
|
|
2433
|
+
if T.columns > 1:
|
|
2434
|
+
ctx.balance_last_page()
|
|
2435
|
+
for note in ctx.notes:
|
|
2436
|
+
print(f"Note: {note}")
|
|
2437
|
+
return ctx
|
|
2438
|
+
|
|
2439
|
+
|
|
2440
|
+
def _unsplit(d: Directive) -> str:
|
|
2441
|
+
"""A directive written back as source, so the body renders in one ordered pass."""
|
|
2442
|
+
depth = 3 + max([len(m.group(1)) - 2 for m in re.finditer(r"^(:{3,})", d.body, re.M)] or [0])
|
|
2443
|
+
fence = ":" * depth
|
|
2444
|
+
attrs = " ".join([*d.args, *(f'{k}="{v}"' for k, v in d.attrs.items())])
|
|
2445
|
+
return f"{fence} {d.name} {attrs}\n{d.body}\n{fence}"
|