srm-report 0.0.0-stage → 1.0.0
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/CONTRIBUTING.md +29 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/SKILL.md +97 -0
- package/assets/examples/project_21cse734p.py +150 -0
- package/bin/srm-report.js +171 -0
- package/package.json +34 -4
- package/references/content.md +40 -0
- package/references/families.md +38 -0
- package/references/rules.md +105 -0
- package/scripts/check_report.py +205 -0
- package/scripts/inspect_template.py +124 -0
- package/scripts/render_pages.py +34 -0
- package/scripts/rptlib.py +873 -0
- package/scripts/word_pass.ps1 +42 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
"""Build an SRM report by filling a COPY of the template, so fixed pages stay exactly as issued.
|
|
2
|
+
|
|
3
|
+
Typical use (see assets/examples/project_21cse734p.py):
|
|
4
|
+
r = Report(template, style={...})
|
|
5
|
+
r.strip_notes(); r.replace(old, new); r.set_para(prefix, text); r.signature_table(...)
|
|
6
|
+
r.cut_from(r.find("ABSTRACT")) # drops the specimen body, keeps the pages before it
|
|
7
|
+
r.prepare(all_nodes, refs, img_dir)
|
|
8
|
+
r.begin_front(); r.add_nodes(...); r.add_toc(); r.begin_body(); r.add_nodes(chapters); r.add_references()
|
|
9
|
+
r.save(out)
|
|
10
|
+
Then run word_pass.ps1 (fills TOC and lists, exports PDF) and check_report.py.
|
|
11
|
+
|
|
12
|
+
Content nodes are plain dicts with key "t": chapter, appendix, front, heading, h2, h3, p, quote, num, bullets,
|
|
13
|
+
fig, table, code, abbrev, pagebreak. Inline text: [@key] citation, {fig:id} / {tab:id} cross reference, **bold**.
|
|
14
|
+
"""
|
|
15
|
+
import copy, io, os, re
|
|
16
|
+
import docx
|
|
17
|
+
from docx.enum.section import WD_SECTION
|
|
18
|
+
from docx.oxml import OxmlElement, parse_xml
|
|
19
|
+
from docx.oxml.ns import qn
|
|
20
|
+
from docx.shared import Emu, Inches, Pt
|
|
21
|
+
from PIL import Image
|
|
22
|
+
|
|
23
|
+
W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
|
24
|
+
XS = "{http://www.w3.org/XML/1998/namespace}space"
|
|
25
|
+
A_BLIP = "{http://schemas.openxmlformats.org/drawingml/2006/main}blip"
|
|
26
|
+
WP_DOCPR = "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}docPr"
|
|
27
|
+
NOTE_RE = re.compile(r"(?<!<)<(?!<)[^<>\n]{2,80}>(?!>)")
|
|
28
|
+
|
|
29
|
+
DEFAULT_STYLE = dict(font="Times New Roman", body=12, line=1.5, after=12, h1=16, h2=16, h3=12, h3_caps=True,
|
|
30
|
+
front_h=16, caption=12, table=12, table_small=10.5, code=8, left=1.0, right=0.5, top=1.0,
|
|
31
|
+
bottom=0.9, footer=0.42, page_w=8.27, page_h=11.69, fig_label="Fig", tab_label="Table")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ptext(el):
|
|
35
|
+
return "".join(x.text or "" for x in el.iter(qn("w:t")))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _mk_t(text):
|
|
39
|
+
t = OxmlElement("w:t"); t.text = text; t.set(XS, "preserve"); return t
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def set_text(p_el, text):
|
|
43
|
+
"""Replace a paragraph's text and keep its first run's formatting. Removes stale runs, which is what
|
|
44
|
+
caused the 'DepartmentAssistant Professor,' bug when only some runs were edited."""
|
|
45
|
+
runs = [r for r in p_el.iter(qn("w:r")) if r.find(qn("w:t")) is not None or r.find(qn("w:tab")) is not None]
|
|
46
|
+
if not runs:
|
|
47
|
+
r = OxmlElement("w:r"); r.append(_mk_t(text)); p_el.append(r); return
|
|
48
|
+
first = runs[0]
|
|
49
|
+
for r in runs[1:]:
|
|
50
|
+
r.getparent().remove(r)
|
|
51
|
+
for x in list(first):
|
|
52
|
+
if x.tag != qn("w:rPr"):
|
|
53
|
+
first.remove(x)
|
|
54
|
+
for li, line in enumerate(text.split("\n")):
|
|
55
|
+
if li:
|
|
56
|
+
first.append(OxmlElement("w:br"))
|
|
57
|
+
for i, part in enumerate(line.split("\t")):
|
|
58
|
+
if i:
|
|
59
|
+
first.append(OxmlElement("w:tab"))
|
|
60
|
+
if part:
|
|
61
|
+
first.append(_mk_t(part))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_run_like(p_el, text, like, bold=None):
|
|
65
|
+
r = OxmlElement("w:r")
|
|
66
|
+
rpr = like.find(qn("w:rPr")) if like is not None else None
|
|
67
|
+
rpr = copy.deepcopy(rpr) if rpr is not None else OxmlElement("w:rPr")
|
|
68
|
+
if bold is not None:
|
|
69
|
+
for tag in ("w:b", "w:bCs"):
|
|
70
|
+
for x in rpr.findall(qn(tag)):
|
|
71
|
+
rpr.remove(x)
|
|
72
|
+
b = OxmlElement("w:b"); b.set(qn("w:val"), "1" if bold else "0"); rpr.append(b)
|
|
73
|
+
r.append(rpr)
|
|
74
|
+
for i, part in enumerate(text.split("\t")):
|
|
75
|
+
if i:
|
|
76
|
+
r.append(OxmlElement("w:tab"))
|
|
77
|
+
if part:
|
|
78
|
+
r.append(_mk_t(part))
|
|
79
|
+
p_el.append(r)
|
|
80
|
+
return r
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _replace_across_runs(p_el, a, b):
|
|
84
|
+
"""Replace a with b even when Word split a across runs. The new text takes the first touched run's format."""
|
|
85
|
+
nodes = list(p_el.iter(qn("w:t")))
|
|
86
|
+
full = "".join(t.text or "" for t in nodes)
|
|
87
|
+
n = 0
|
|
88
|
+
start = full.find(a)
|
|
89
|
+
while start != -1:
|
|
90
|
+
end = start + len(a); pos = 0; placed = False
|
|
91
|
+
for t in nodes:
|
|
92
|
+
s = t.text or ""; ts, te = pos, pos + len(s); pos = te
|
|
93
|
+
if te <= start or ts >= end:
|
|
94
|
+
continue
|
|
95
|
+
left = s[:max(0, start - ts)]
|
|
96
|
+
right = s[end - ts:] if te > end else ""
|
|
97
|
+
t.text = left + (b if not placed else "") + right
|
|
98
|
+
t.set(XS, "preserve"); placed = True
|
|
99
|
+
n += 1
|
|
100
|
+
full = "".join(t.text or "" for t in nodes)
|
|
101
|
+
start = full.find(a, start + len(b))
|
|
102
|
+
return n
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Report:
|
|
106
|
+
def __init__(self, template, style=None):
|
|
107
|
+
self.path = template
|
|
108
|
+
self.d = docx.Document(template)
|
|
109
|
+
self.body = self.d.element.body
|
|
110
|
+
self.s = {**DEFAULT_STYLE, **(style or {})}
|
|
111
|
+
self.fig_no, self.tab_no, self.cite_no = {}, {}, {}
|
|
112
|
+
self.refs = {}
|
|
113
|
+
self._styles_ready = False
|
|
114
|
+
self._img_base = "."
|
|
115
|
+
|
|
116
|
+
# ---------------- reading the template ----------------
|
|
117
|
+
def els(self):
|
|
118
|
+
return [e for e in self.body.iterchildren() if e.tag != qn("w:sectPr")]
|
|
119
|
+
|
|
120
|
+
def all_paras(self):
|
|
121
|
+
return list(self.body.iter(qn("w:p")))
|
|
122
|
+
|
|
123
|
+
def find(self, prefix, nth=0):
|
|
124
|
+
"""First top-level paragraph starting with prefix. Raises if missing, so a different template fails loudly."""
|
|
125
|
+
seen = 0
|
|
126
|
+
for e in self.els():
|
|
127
|
+
if e.tag == qn("w:p") and ptext(e).strip().startswith(prefix):
|
|
128
|
+
if seen == nth:
|
|
129
|
+
return e
|
|
130
|
+
seen += 1
|
|
131
|
+
raise KeyError(f"not found in template: {prefix!r} (nth={nth})")
|
|
132
|
+
|
|
133
|
+
def tables_with(self, text):
|
|
134
|
+
return [t for t in self.body.iter(qn("w:tbl")) if text in ptext(t)]
|
|
135
|
+
|
|
136
|
+
# ---------------- editing kept pages ----------------
|
|
137
|
+
def apply_cover_notes(self):
|
|
138
|
+
"""Carry out the cover's notes, then delete them. In SRM templates a line such as <Font Size 16> <Italic> is an
|
|
139
|
+
instruction for the text ABOVE it, back to the previous note, and <Font Style ... Bold> covers the whole cover.
|
|
140
|
+
Call this before filling names, so cloned lines inherit the size. Returns the number of notes applied."""
|
|
141
|
+
kids = self.els(); group = []; all_bold = False; applied = 0
|
|
142
|
+
|
|
143
|
+
def fmt_of(notes):
|
|
144
|
+
joined = " ".join(notes)
|
|
145
|
+
m = re.search(r"Font Size\s*(\d+)", joined)
|
|
146
|
+
return dict(size=int(m.group(1)) if m else None, italic="italic" in joined.lower(),
|
|
147
|
+
wide=bool(re.search(r"[1lI]\.5 line", joined)))
|
|
148
|
+
|
|
149
|
+
def apply(paras, f):
|
|
150
|
+
for k, p in enumerate(paras):
|
|
151
|
+
ppr = p.find(qn("w:pPr"))
|
|
152
|
+
if ppr is None:
|
|
153
|
+
ppr = OxmlElement("w:pPr"); p.insert(0, ppr)
|
|
154
|
+
for tag in ("w:ind", "w:spacing", "w:jc", "w:tabs"):
|
|
155
|
+
for x in ppr.findall(qn(tag)):
|
|
156
|
+
ppr.remove(x)
|
|
157
|
+
after = 120 if k == len(paras) - 1 else 0
|
|
158
|
+
line = 360 if f["wide"] else 276
|
|
159
|
+
ppr.append(parse_xml(f'<w:spacing xmlns:w="{W}" w:before="0" w:after="{after}" w:line="{line}" w:lineRule="auto"/>'))
|
|
160
|
+
ppr.append(parse_xml(f'<w:ind xmlns:w="{W}" w:left="0" w:right="0" w:firstLine="0"/>'))
|
|
161
|
+
ppr.append(parse_xml(f'<w:jc xmlns:w="{W}" w:val="center"/>'))
|
|
162
|
+
for r in p.iter(qn("w:r")):
|
|
163
|
+
rpr = r.find(qn("w:rPr"))
|
|
164
|
+
if rpr is None:
|
|
165
|
+
rpr = OxmlElement("w:rPr"); r.insert(0, rpr)
|
|
166
|
+
# Negative character spacing in the template squeezes words together ("requirementsfor").
|
|
167
|
+
for tag in ("w:rFonts", "w:b", "w:bCs", "w:i", "w:iCs", "w:spacing", "w:position"):
|
|
168
|
+
for x in rpr.findall(qn(tag)):
|
|
169
|
+
rpr.remove(x)
|
|
170
|
+
font = self.s["font"]
|
|
171
|
+
rpr.append(parse_xml(f'<w:rFonts xmlns:w="{W}" w:ascii="{font}" w:hAnsi="{font}" w:eastAsia="{font}" w:cs="{font}"/>'))
|
|
172
|
+
val = "1" if all_bold else "0"
|
|
173
|
+
rpr.append(parse_xml(f'<w:b xmlns:w="{W}" w:val="{val}"/>'))
|
|
174
|
+
rpr.append(parse_xml(f'<w:bCs xmlns:w="{W}" w:val="{val}"/>'))
|
|
175
|
+
if f["italic"]:
|
|
176
|
+
rpr.append(OxmlElement("w:i"))
|
|
177
|
+
if f["size"]:
|
|
178
|
+
for tag in ("w:sz", "w:szCs"):
|
|
179
|
+
for x in rpr.findall(qn(tag)):
|
|
180
|
+
rpr.remove(x)
|
|
181
|
+
rpr.append(parse_xml(f'<w:sz xmlns:w="{W}" w:val="{f["size"] * 2}"/>'))
|
|
182
|
+
rpr.append(parse_xml(f'<w:szCs xmlns:w="{W}" w:val="{f["size"] * 2}"/>'))
|
|
183
|
+
|
|
184
|
+
for e in kids:
|
|
185
|
+
if e.tag != qn("w:p"):
|
|
186
|
+
break
|
|
187
|
+
has_sect = e.find(".//" + qn("w:sectPr")) is not None
|
|
188
|
+
has_pic = e.find(".//" + A_BLIP) is not None
|
|
189
|
+
t = ptext(e); notes = NOTE_RE.findall(t); clean = NOTE_RE.sub("", t).strip()
|
|
190
|
+
if clean.startswith("(A typical Specimen"):
|
|
191
|
+
self.body.remove(e)
|
|
192
|
+
elif notes and not clean and not has_pic: # the logo can sit inside a note's paragraph
|
|
193
|
+
if "bold" in " ".join(notes).lower() and not group:
|
|
194
|
+
all_bold = True
|
|
195
|
+
elif group:
|
|
196
|
+
apply(group, fmt_of(notes)); applied += 1
|
|
197
|
+
group = []
|
|
198
|
+
self.body.remove(e)
|
|
199
|
+
elif notes:
|
|
200
|
+
for m in set(notes):
|
|
201
|
+
_replace_across_runs(e, m, "")
|
|
202
|
+
apply(group + [e], fmt_of(notes)); applied += 1
|
|
203
|
+
group = []
|
|
204
|
+
elif not clean and not has_pic and not has_sect:
|
|
205
|
+
self.body.remove(e)
|
|
206
|
+
elif clean or has_pic:
|
|
207
|
+
group.append(e)
|
|
208
|
+
if has_sect:
|
|
209
|
+
break
|
|
210
|
+
return applied
|
|
211
|
+
|
|
212
|
+
def strip_notes(self, keep_lines=False):
|
|
213
|
+
"""Delete remaining hints like <Font Size 18>, along with paragraphs that hold nothing else. Keeping those as
|
|
214
|
+
empty lines (keep_lines=True) pushes a full page such as the bonafide onto a second page."""
|
|
215
|
+
removed = 0
|
|
216
|
+
for p in self.all_paras():
|
|
217
|
+
t = ptext(p)
|
|
218
|
+
if not NOTE_RE.search(t):
|
|
219
|
+
continue
|
|
220
|
+
only_note = not NOTE_RE.sub("", t).strip()
|
|
221
|
+
if only_note and not keep_lines and p.find(".//" + A_BLIP) is None and p.find(".//" + qn("w:sectPr")) is None:
|
|
222
|
+
p.getparent().remove(p); removed += 1; continue
|
|
223
|
+
for m in set(NOTE_RE.findall(t)):
|
|
224
|
+
_replace_across_runs(p, m, "")
|
|
225
|
+
removed += 1
|
|
226
|
+
for p in self.all_paras():
|
|
227
|
+
if ptext(p).strip().startswith("(A typical Specimen"):
|
|
228
|
+
p.getparent().remove(p); removed += 1
|
|
229
|
+
return removed
|
|
230
|
+
|
|
231
|
+
def replace(self, a, b, expect=None):
|
|
232
|
+
n = sum(_replace_across_runs(p, a, b) for p in self.all_paras())
|
|
233
|
+
if expect is not None and n != expect:
|
|
234
|
+
raise ValueError(f"replace {a!r}: expected {expect}, got {n}")
|
|
235
|
+
if n == 0:
|
|
236
|
+
raise KeyError(f"replace: {a!r} not found")
|
|
237
|
+
return n
|
|
238
|
+
|
|
239
|
+
def set_para(self, prefix, text, nth=0):
|
|
240
|
+
seen = 0
|
|
241
|
+
for p in self.all_paras():
|
|
242
|
+
if ptext(p).strip().startswith(prefix):
|
|
243
|
+
if seen == nth:
|
|
244
|
+
set_text(p, text); return p
|
|
245
|
+
seen += 1
|
|
246
|
+
raise KeyError(f"set_para: {prefix!r} not found")
|
|
247
|
+
|
|
248
|
+
def fill_field(self, label, value, tab_pos=3780, left=3960, hanging=3060):
|
|
249
|
+
"""Fill a 'Label<tab>:' row, wrapping long values under the value column."""
|
|
250
|
+
for p in self.all_paras():
|
|
251
|
+
if ptext(p).strip().startswith(label):
|
|
252
|
+
like = next(r for r in p.iter(qn("w:r")) if r.find(qn("w:t")) is not None)
|
|
253
|
+
like = copy.deepcopy(like)
|
|
254
|
+
set_text(p, label)
|
|
255
|
+
ppr = p.find(qn("w:pPr"))
|
|
256
|
+
if ppr is None:
|
|
257
|
+
ppr = OxmlElement("w:pPr"); p.insert(0, ppr)
|
|
258
|
+
for x in ppr.findall(qn("w:tabs")) + ppr.findall(qn("w:ind")):
|
|
259
|
+
ppr.remove(x)
|
|
260
|
+
ppr.append(parse_xml(f'<w:tabs xmlns:w="{W}"><w:tab w:val="left" w:pos="{tab_pos}"/></w:tabs>'))
|
|
261
|
+
ppr.append(parse_xml(f'<w:ind xmlns:w="{W}" w:left="{left}" w:hanging="{hanging}"/>'))
|
|
262
|
+
add_run_like(p, "\t:", like, bold=True)
|
|
263
|
+
add_run_like(p, " " + value, like, bold=False)
|
|
264
|
+
return p
|
|
265
|
+
raise KeyError(f"fill_field: {label!r} not found")
|
|
266
|
+
|
|
267
|
+
def trim_blank_before(self, el, keep=0):
|
|
268
|
+
"""Remove empty lines directly above el, leaving `keep` of them. Stops at an image, section break or page break."""
|
|
269
|
+
blanks = []
|
|
270
|
+
prev = el.getprevious()
|
|
271
|
+
while prev is not None and prev.tag == qn("w:p") and not ptext(prev).strip() \
|
|
272
|
+
and prev.find(".//" + qn("w:drawing")) is None and prev.find(".//" + qn("w:sectPr")) is None \
|
|
273
|
+
and not any(b.get(qn("w:type")) == "page" for b in prev.iter(qn("w:br"))):
|
|
274
|
+
blanks.append(prev); prev = prev.getprevious()
|
|
275
|
+
for b in blanks[keep:]:
|
|
276
|
+
b.getparent().remove(b)
|
|
277
|
+
|
|
278
|
+
def start_new_page(self, el):
|
|
279
|
+
"""Give a heading a real page break. Templates often reach the next page with blank lines alone, so anything
|
|
280
|
+
that removes those lines or lengthens the page above needs this."""
|
|
281
|
+
self.trim_blank_before(el)
|
|
282
|
+
ppr = el.find(qn("w:pPr"))
|
|
283
|
+
if ppr is None:
|
|
284
|
+
ppr = OxmlElement("w:pPr"); el.insert(0, ppr)
|
|
285
|
+
if ppr.find(qn("w:pageBreakBefore")) is None:
|
|
286
|
+
ppr.append(OxmlElement("w:pageBreakBefore"))
|
|
287
|
+
|
|
288
|
+
def tidy_paragraphs(self, start_prefix, end_prefix):
|
|
289
|
+
"""Clean template padding between two markers: repeated and leading spaces, and first-line offsets.
|
|
290
|
+
Left and right indents stay, because they set the width the page's text lines up on."""
|
|
291
|
+
a = self.find(start_prefix); b = self.find(end_prefix); on = False
|
|
292
|
+
for e in self.els():
|
|
293
|
+
if e is a:
|
|
294
|
+
on = True; continue
|
|
295
|
+
if e is b:
|
|
296
|
+
break
|
|
297
|
+
if not on or e.tag != qn("w:p"):
|
|
298
|
+
continue
|
|
299
|
+
ind = e.find(qn("w:pPr") + "/" + qn("w:ind")) if e.find(qn("w:pPr")) is not None else None
|
|
300
|
+
if ind is not None:
|
|
301
|
+
for att in ("w:firstLine", "w:hanging"):
|
|
302
|
+
ind.attrib.pop(qn(att), None)
|
|
303
|
+
space = True # true at the start, so leading spaces go too
|
|
304
|
+
for t in e.iter(qn("w:t")):
|
|
305
|
+
out = []
|
|
306
|
+
for ch in t.text or "":
|
|
307
|
+
is_sp = ch.isspace()
|
|
308
|
+
if not (is_sp and space):
|
|
309
|
+
out.append(" " if is_sp else ch)
|
|
310
|
+
space = is_sp
|
|
311
|
+
t.text = "".join(out); t.set(XS, "preserve")
|
|
312
|
+
|
|
313
|
+
def clone_after(self, model, text, after=None):
|
|
314
|
+
"""Copy paragraph `model`, set its text, place it after `after` (default: after model)."""
|
|
315
|
+
c = copy.deepcopy(model)
|
|
316
|
+
for s in list(c.iter(qn("w:sectPr"))):
|
|
317
|
+
s.getparent().remove(s) # a cloned sectPr silently starts a new page
|
|
318
|
+
set_text(c, text)
|
|
319
|
+
(after if after is not None else model).addnext(c)
|
|
320
|
+
return c
|
|
321
|
+
|
|
322
|
+
def bold_names(self, start_prefix, end_prefix, names):
|
|
323
|
+
"""Bold each name in the paragraphs between two markers (acknowledgements). Raises if a name is absent."""
|
|
324
|
+
a = self.find(start_prefix); b = self.find(end_prefix)
|
|
325
|
+
rx = re.compile("(" + "|".join(re.escape(n) for n in sorted(names, key=len, reverse=True)) + ")")
|
|
326
|
+
found = set(); on = False
|
|
327
|
+
for e in self.els():
|
|
328
|
+
if e is a:
|
|
329
|
+
on = True; continue
|
|
330
|
+
if e is b:
|
|
331
|
+
break
|
|
332
|
+
if not on or e.tag != qn("w:p"):
|
|
333
|
+
continue
|
|
334
|
+
t = ptext(e)
|
|
335
|
+
if not rx.search(t):
|
|
336
|
+
continue
|
|
337
|
+
t = re.sub(r"\s{2,}", " ", t).strip() # the template pads some lines with spaces
|
|
338
|
+
runs = e.findall(qn("w:r")); like = runs[0] if runs else None
|
|
339
|
+
for r in runs:
|
|
340
|
+
e.remove(r)
|
|
341
|
+
for part in rx.split(t):
|
|
342
|
+
if part:
|
|
343
|
+
is_name = bool(rx.fullmatch(part))
|
|
344
|
+
if is_name:
|
|
345
|
+
found.add(part)
|
|
346
|
+
add_run_like(e, part, like, bold=is_name)
|
|
347
|
+
missing = set(names) - found
|
|
348
|
+
if missing:
|
|
349
|
+
raise KeyError(f"names not found between markers: {sorted(missing)}")
|
|
350
|
+
|
|
351
|
+
def signature_table(self, table_el, blocks):
|
|
352
|
+
"""Rebuild a bonafide signature table with one equal column per signer.
|
|
353
|
+
blocks = [[('SIGNATURE', True), ('Dr. X', False), ('GUIDE', True), ...], ...]
|
|
354
|
+
Pad blocks to the same line count so the columns line up."""
|
|
355
|
+
tr = table_el.find(qn("w:tr")); tcs = tr.findall(qn("w:tc"))
|
|
356
|
+
model = next(c for c in tcs if ptext(c).strip())
|
|
357
|
+
para_model = next(p for p in model.iter(qn("w:p")) if ptext(p).strip())
|
|
358
|
+
run_model = copy.deepcopy(next(r for r in para_model.iter(qn("w:r")) if r.find(qn("w:t")) is not None))
|
|
359
|
+
grid = table_el.find(qn("w:tblGrid")); gcs = grid.findall(qn("w:gridCol"))
|
|
360
|
+
total = sum(int(g.get(qn("w:w"))) for g in gcs)
|
|
361
|
+
col = total // len(blocks)
|
|
362
|
+
for g in gcs:
|
|
363
|
+
grid.remove(g)
|
|
364
|
+
for _ in blocks:
|
|
365
|
+
grid.append(parse_xml(f'<w:gridCol xmlns:w="{W}" w:w="{col}"/>'))
|
|
366
|
+
new = []
|
|
367
|
+
for lines in blocks:
|
|
368
|
+
tc = OxmlElement("w:tc")
|
|
369
|
+
tc.append(parse_xml(f'<w:tcPr xmlns:w="{W}"><w:tcW w:w="{col}" w:type="dxa"/><w:tcMar><w:left w:w="0" w:type="dxa"/><w:right w:w="0" w:type="dxa"/></w:tcMar></w:tcPr>'))
|
|
370
|
+
for text, bold in lines:
|
|
371
|
+
p = OxmlElement("w:p")
|
|
372
|
+
p.append(parse_xml(f'<w:pPr xmlns:w="{W}"><w:spacing w:before="0" w:after="0"/><w:jc w:val="center"/></w:pPr>'))
|
|
373
|
+
add_run_like(p, text, run_model, bold=bold)
|
|
374
|
+
tc.append(p)
|
|
375
|
+
new.append(tc)
|
|
376
|
+
for c in tcs:
|
|
377
|
+
tr.remove(c)
|
|
378
|
+
for c in new:
|
|
379
|
+
tr.append(c)
|
|
380
|
+
|
|
381
|
+
def copy_block(self, src_path, start_prefix, end_prefix, anchor, style_prefix="Tpl"):
|
|
382
|
+
"""Copy template elements [start..end] in front of `anchor`, for a report NOT built from the template (for example
|
|
383
|
+
one the user hand-edited). Brings images and list numbering along, and renames styles so they keep the template's look."""
|
|
384
|
+
t = docx.Document(src_path)
|
|
385
|
+
tb = [e for e in t.element.body.iterchildren() if e.tag != qn("w:sectPr")]
|
|
386
|
+
i = next(k for k, e in enumerate(tb) if ptext(e).strip().startswith(start_prefix))
|
|
387
|
+
j = next(k for k in range(i, len(tb)) if ptext(tb[k]).strip().startswith(end_prefix))
|
|
388
|
+
have = {s.style_id for s in self.d.styles}
|
|
389
|
+
tstyles = {s.style_id: s for s in t.styles}
|
|
390
|
+
smap = {}
|
|
391
|
+
|
|
392
|
+
def bring(sid):
|
|
393
|
+
if sid in smap:
|
|
394
|
+
return smap[sid]
|
|
395
|
+
new_id = style_prefix + sid; smap[sid] = new_id
|
|
396
|
+
if new_id in have or sid not in tstyles:
|
|
397
|
+
return new_id
|
|
398
|
+
el = copy.deepcopy(tstyles[sid].element); el.set(qn("w:styleId"), new_id)
|
|
399
|
+
el.find(qn("w:name")).set(qn("w:val"), style_prefix + " " + tstyles[sid].name)
|
|
400
|
+
el.attrib.pop(qn("w:default"), None)
|
|
401
|
+
for tag in ("w:link", "w:next"):
|
|
402
|
+
for x in el.findall(qn(tag)):
|
|
403
|
+
el.remove(x)
|
|
404
|
+
based = el.find(qn("w:basedOn"))
|
|
405
|
+
if based is not None:
|
|
406
|
+
based.set(qn("w:val"), bring(based.get(qn("w:val"))))
|
|
407
|
+
self.d.styles.element.append(el); have.add(new_id)
|
|
408
|
+
return new_id
|
|
409
|
+
|
|
410
|
+
normal = next((s.style_id for s in t.styles if s.name == "Normal"), "Normal")
|
|
411
|
+
out = []; dp = 9000; num_map = {}
|
|
412
|
+
tnum = t.part.numbering_part.element
|
|
413
|
+
for e in tb[i:j + 1]:
|
|
414
|
+
c = copy.deepcopy(e)
|
|
415
|
+
for s in list(c.iter(qn("w:sectPr"))):
|
|
416
|
+
s.getparent().remove(s)
|
|
417
|
+
for ps in c.iter(qn("w:pStyle")):
|
|
418
|
+
ps.set(qn("w:val"), bring(ps.get(qn("w:val"))))
|
|
419
|
+
for p in c.iter(qn("w:p")):
|
|
420
|
+
ppr = p.find(qn("w:pPr"))
|
|
421
|
+
if ppr is None:
|
|
422
|
+
ppr = OxmlElement("w:pPr"); p.insert(0, ppr)
|
|
423
|
+
if ppr.find(qn("w:pStyle")) is None:
|
|
424
|
+
ps = OxmlElement("w:pStyle"); ps.set(qn("w:val"), bring(normal)); ppr.insert(0, ps)
|
|
425
|
+
for n in c.iter(qn("w:numId")):
|
|
426
|
+
old = n.get(qn("w:val"))
|
|
427
|
+
if old not in num_map:
|
|
428
|
+
num_map[old] = self._bring_numbering(tnum, old)
|
|
429
|
+
n.set(qn("w:val"), num_map[old])
|
|
430
|
+
for b in c.iter(A_BLIP):
|
|
431
|
+
blob = t.part.rels[b.get(qn("r:embed"))].target_part.blob
|
|
432
|
+
rid, _ = self.d.part.get_or_add_image(io.BytesIO(blob)); b.set(qn("r:embed"), rid)
|
|
433
|
+
for x in c.iter(WP_DOCPR):
|
|
434
|
+
dp += 1; x.set("id", str(dp))
|
|
435
|
+
anchor.addprevious(c); out.append(c)
|
|
436
|
+
return out
|
|
437
|
+
|
|
438
|
+
def _bring_numbering(self, tnum, old):
|
|
439
|
+
dnum = self.d.part.numbering_part.element
|
|
440
|
+
tn = next(n for n in tnum.findall(qn("w:num")) if n.get(qn("w:numId")) == old)
|
|
441
|
+
aid = tn.find(qn("w:abstractNumId")).get(qn("w:val"))
|
|
442
|
+
ta = next(a for a in tnum.findall(qn("w:abstractNum")) if a.get(qn("w:abstractNumId")) == aid)
|
|
443
|
+
new_a = str(max([int(a.get(qn("w:abstractNumId"))) for a in dnum.findall(qn("w:abstractNum"))] + [0]) + 1)
|
|
444
|
+
new_n = str(max([int(n.get(qn("w:numId"))) for n in dnum.findall(qn("w:num"))] + [0]) + 1)
|
|
445
|
+
na = copy.deepcopy(ta); na.set(qn("w:abstractNumId"), new_a)
|
|
446
|
+
for x in na.findall(qn("w:nsid")) + na.findall(qn("w:tmpl")):
|
|
447
|
+
na.remove(x)
|
|
448
|
+
first = dnum.find(qn("w:num"))
|
|
449
|
+
if first is not None:
|
|
450
|
+
first.addprevious(na)
|
|
451
|
+
else:
|
|
452
|
+
dnum.append(na)
|
|
453
|
+
dnum.append(parse_xml(f'<w:num xmlns:w="{W}" w:numId="{new_n}"><w:abstractNumId w:val="{new_a}"/></w:num>'))
|
|
454
|
+
return new_n
|
|
455
|
+
|
|
456
|
+
# ---------------- sections ----------------
|
|
457
|
+
def cut_from(self, el):
|
|
458
|
+
"""Delete el and everything after it (the specimen). The kept pages keep their own section settings."""
|
|
459
|
+
kids = self.els()
|
|
460
|
+
i = kids.index(el)
|
|
461
|
+
# Empty lines and page breaks in front of the cut would spill onto a blank page.
|
|
462
|
+
while i > 0 and kids[i - 1].tag == qn("w:p") and not ptext(kids[i - 1]).strip() \
|
|
463
|
+
and kids[i - 1].find(".//" + A_BLIP) is None and kids[i - 1].find(".//" + qn("w:sectPr")) is None:
|
|
464
|
+
i -= 1
|
|
465
|
+
prev_has = i > 0 and kids[i - 1].find(".//" + qn("w:sectPr")) is not None
|
|
466
|
+
next_sect = None
|
|
467
|
+
for e in kids[i:]:
|
|
468
|
+
next_sect = e.find(".//" + qn("w:sectPr"))
|
|
469
|
+
if next_sect is not None:
|
|
470
|
+
next_sect = copy.deepcopy(next_sect)
|
|
471
|
+
break
|
|
472
|
+
for e in kids[i:]:
|
|
473
|
+
self.body.remove(e)
|
|
474
|
+
if not prev_has:
|
|
475
|
+
# The kept pages before the cut need a section of their own, or the final sectPr would restyle them.
|
|
476
|
+
carrier = parse_xml(f'<w:p xmlns:w="{W}"><w:pPr><w:spacing w:before="0" w:after="0" w:line="20" w:lineRule="exact"/></w:pPr></w:p>')
|
|
477
|
+
sect = next_sect if next_sect is not None else copy.deepcopy(self.body.find(qn("w:sectPr")))
|
|
478
|
+
carrier.find(qn("w:pPr")).append(sect)
|
|
479
|
+
self.body.find(qn("w:sectPr")).addprevious(carrier)
|
|
480
|
+
return self
|
|
481
|
+
|
|
482
|
+
def _footer(self, sec, fmt, start=None, number=True):
|
|
483
|
+
s = self.s
|
|
484
|
+
sec.footer.is_linked_to_previous = False
|
|
485
|
+
ft = sec.footer
|
|
486
|
+
for p in list(ft.paragraphs)[1:]:
|
|
487
|
+
p._p.getparent().remove(p._p)
|
|
488
|
+
p = ft.paragraphs[0] if ft.paragraphs else ft.add_paragraph()
|
|
489
|
+
for c in list(p._p):
|
|
490
|
+
if c.tag != qn("w:pPr"):
|
|
491
|
+
p._p.remove(c)
|
|
492
|
+
ppr = p._p.get_or_add_pPr()
|
|
493
|
+
for x in ppr.findall(qn("w:pBdr")) + ppr.findall(qn("w:jc")):
|
|
494
|
+
ppr.remove(x) # number only, never a rule line
|
|
495
|
+
ppr.append(parse_xml(f'<w:jc xmlns:w="{W}" w:val="center"/>'))
|
|
496
|
+
if number:
|
|
497
|
+
for part in ('<w:fldChar w:fldCharType="begin"/>', '<w:instrText xml:space="preserve"> PAGE </w:instrText>', '<w:fldChar w:fldCharType="end"/>'):
|
|
498
|
+
p._p.append(parse_xml(f'<w:r xmlns:w="{W}"><w:rPr><w:rFonts w:ascii="{s["font"]}" w:hAnsi="{s["font"]}" w:cs="{s["font"]}"/><w:sz w:val="{int(s["body"] * 2)}"/></w:rPr>{part}</w:r>'))
|
|
499
|
+
sp = sec._sectPr
|
|
500
|
+
for x in sp.findall(qn("w:pgNumType")):
|
|
501
|
+
sp.remove(x)
|
|
502
|
+
attrs = f'w:fmt="{fmt}"' + (f' w:start="{start}"' if start is not None else "")
|
|
503
|
+
sp.find(qn("w:pgMar")).addnext(parse_xml(f'<w:pgNumType xmlns:w="{W}" {attrs}/>'))
|
|
504
|
+
|
|
505
|
+
def number_kept_sections(self, cover_sections=1, fmt="lowerRoman"):
|
|
506
|
+
"""Footer numbers on the kept front pages. The cover counts as page i but shows no number.
|
|
507
|
+
Call after begin_front, which claims the last section."""
|
|
508
|
+
secs = list(self.d.sections)[:-1]
|
|
509
|
+
for i, sec in enumerate(secs):
|
|
510
|
+
show = i >= cover_sections
|
|
511
|
+
self._footer(sec, fmt, start=1 if i == 0 else None, number=show)
|
|
512
|
+
if show:
|
|
513
|
+
sec.footer_distance = Inches(self.s["footer"])
|
|
514
|
+
sec.bottom_margin = max(sec.bottom_margin, Inches(0.62))
|
|
515
|
+
|
|
516
|
+
def _apply_page(self, sec):
|
|
517
|
+
s = self.s
|
|
518
|
+
sec.page_width, sec.page_height = Inches(s["page_w"]), Inches(s["page_h"])
|
|
519
|
+
sec.left_margin, sec.right_margin = Inches(s["left"]), Inches(s["right"])
|
|
520
|
+
sec.top_margin, sec.bottom_margin = Inches(s["top"]), Inches(s["bottom"])
|
|
521
|
+
sec.footer_distance = Inches(s["footer"]); sec.header_distance = Inches(0.3)
|
|
522
|
+
for x in sec._sectPr.findall(qn("w:cols")):
|
|
523
|
+
x.attrib.pop(qn("w:num"), None)
|
|
524
|
+
|
|
525
|
+
def begin_front(self, fmt="lowerRoman"):
|
|
526
|
+
"""Start the generated front section (abstract, contents, lists). Uses the document's last section."""
|
|
527
|
+
self._ensure_styles()
|
|
528
|
+
sec = self.d.sections[-1]
|
|
529
|
+
self._apply_page(sec); self._footer(sec, fmt)
|
|
530
|
+
|
|
531
|
+
def begin_body(self, fmt="decimal", start=1):
|
|
532
|
+
"""Start the arabic-numbered section. Chapter 1 is page 1."""
|
|
533
|
+
self._ensure_styles()
|
|
534
|
+
sec = self.d.add_section(WD_SECTION.NEW_PAGE)
|
|
535
|
+
self._apply_page(sec); self._footer(sec, fmt, start=start)
|
|
536
|
+
|
|
537
|
+
# ---------------- styles ----------------
|
|
538
|
+
def _para_style(self, sid, size, bold=False, italic=False, jc="both", line=None, before=0, after=None, outline=None, keep_next=False, page_break=False):
|
|
539
|
+
s = self.s
|
|
540
|
+
line = int((line or s["line"]) * 240)
|
|
541
|
+
after = int((s["after"] if after is None else after) * 20)
|
|
542
|
+
for st in list(self.d.styles):
|
|
543
|
+
if st.style_id == sid:
|
|
544
|
+
self.d.styles.element.remove(st.element)
|
|
545
|
+
flags = ("<w:keepNext/>" if keep_next else "") + ("<w:pageBreakBefore/>" if page_break else "")
|
|
546
|
+
ol = f'<w:outlineLvl w:val="{outline}"/>' if outline is not None else ""
|
|
547
|
+
bi = lambda on: "1" if on else "0"
|
|
548
|
+
self.d.styles.element.append(parse_xml(
|
|
549
|
+
f'<w:style xmlns:w="{W}" w:type="paragraph" w:customStyle="1" w:styleId="{sid}"><w:name w:val="{sid}"/><w:qFormat/>'
|
|
550
|
+
f'<w:pPr>{flags}<w:widowControl/><w:spacing w:before="{int(before * 20)}" w:after="{after}" w:line="{line}" w:lineRule="auto"/>'
|
|
551
|
+
f'<w:ind w:left="0" w:right="0" w:firstLine="0"/><w:jc w:val="{jc}"/>{ol}</w:pPr>'
|
|
552
|
+
f'<w:rPr><w:rFonts w:ascii="{s["font"]}" w:eastAsia="{s["font"]}" w:hAnsi="{s["font"]}" w:cs="{s["font"]}"/>'
|
|
553
|
+
f'<w:b w:val="{bi(bold)}"/><w:bCs w:val="{bi(bold)}"/><w:i w:val="{bi(italic)}"/><w:color w:val="000000"/>'
|
|
554
|
+
f'<w:sz w:val="{int(size * 2)}"/><w:szCs w:val="{int(size * 2)}"/></w:rPr></w:style>'))
|
|
555
|
+
|
|
556
|
+
def _ensure_styles(self):
|
|
557
|
+
if self._styles_ready:
|
|
558
|
+
return
|
|
559
|
+
s = self.s
|
|
560
|
+
# Own style names: template pages use Heading 1-5 on the cover, so a plain \o TOC would list cover lines.
|
|
561
|
+
self._para_style("RptChapter", s["h1"], bold=True, jc="center", after=24, outline=0, keep_next=True, page_break=True)
|
|
562
|
+
self._para_style("RptFront", s["front_h"], bold=True, jc="center", after=18, outline=0, keep_next=True, page_break=True)
|
|
563
|
+
self._para_style("RptH2", s["h2"], bold=True, jc="left", before=12, outline=1, keep_next=True, after=12)
|
|
564
|
+
self._para_style("RptH3", s["h3"], bold=True, jc="left", before=6, outline=2, keep_next=True, after=10)
|
|
565
|
+
self._para_style("RptBody", s["body"])
|
|
566
|
+
self._para_style("RptCell", s["table"], jc="left", line=1.05, after=0)
|
|
567
|
+
self._para_style("RptRef", s["body"], jc="left", line=1.25, after=8)
|
|
568
|
+
self._para_style("RptCode", s["code"], jc="left", line=1.0, after=0)
|
|
569
|
+
self._para_style("RptFigure", s["body"], jc="center", line=1.0, before=6, after=6, keep_next=True)
|
|
570
|
+
self._para_style("FigCaption", s["caption"], jc="center", line=1.15, before=3, after=12)
|
|
571
|
+
self._para_style("TabCaption", s["caption"], jc="center", line=1.15, before=3, after=12, keep_next=True)
|
|
572
|
+
self._para_style("CodeCaption", s["caption"] - 1, italic=True, jc="center", line=1.15, before=3, after=12)
|
|
573
|
+
self._toc_styles()
|
|
574
|
+
self._styles_ready = True
|
|
575
|
+
|
|
576
|
+
def _toc_styles(self):
|
|
577
|
+
"""Chapter lines bold, sub-topics regular. Set in the styles so it survives 'Update field' in Word."""
|
|
578
|
+
s = self.s
|
|
579
|
+
tw = int((s["page_w"] - s["left"] - s["right"]) * 1440)
|
|
580
|
+
for level, name, bold, left in ((1, "toc 1", True, 0), (2, "toc 2", False, 240), (3, "toc 3", False, 480), (4, "toc 4", False, 0)):
|
|
581
|
+
sid = f"TOC{level}"
|
|
582
|
+
for st in list(self.d.styles):
|
|
583
|
+
if st.style_id == sid or st.name.lower() == name:
|
|
584
|
+
self.d.styles.element.remove(st.element)
|
|
585
|
+
b = "1" if bold else "0"
|
|
586
|
+
self.d.styles.element.append(parse_xml(
|
|
587
|
+
f'<w:style xmlns:w="{W}" w:type="paragraph" w:styleId="{sid}"><w:name w:val="{name}"/><w:uiPriority w:val="39"/><w:unhideWhenUsed/>'
|
|
588
|
+
f'<w:pPr><w:tabs><w:tab w:val="right" w:leader="dot" w:pos="{tw}"/></w:tabs><w:spacing w:before="0" w:after="80" w:line="276" w:lineRule="auto"/><w:ind w:left="{left}" w:right="360"/></w:pPr>'
|
|
589
|
+
f'<w:rPr><w:rFonts w:ascii="{s["font"]}" w:eastAsia="{s["font"]}" w:hAnsi="{s["font"]}" w:cs="{s["font"]}"/><w:b w:val="{b}"/><w:bCs w:val="{b}"/><w:sz w:val="{int(s["body"] * 2)}"/></w:rPr></w:style>'))
|
|
590
|
+
|
|
591
|
+
# ---------------- numbering ----------------
|
|
592
|
+
def prepare(self, nodes, refs, img_dir="."):
|
|
593
|
+
"""First pass over all nodes: number figures and tables per chapter, citations in order of first use."""
|
|
594
|
+
self.refs = refs; self._img_base = img_dir
|
|
595
|
+
ch, f, t = "", 0, 0
|
|
596
|
+
for n in nodes:
|
|
597
|
+
k = n.get("t")
|
|
598
|
+
if k == "chapter":
|
|
599
|
+
ch, f, t = str(n["num"]), 0, 0
|
|
600
|
+
if k == "appendix":
|
|
601
|
+
ch, f, t = str(n["letter"]), 0, 0
|
|
602
|
+
if k == "fig":
|
|
603
|
+
f += 1; self.fig_no[n["id"]] = f"{ch}.{f}"
|
|
604
|
+
if k == "table":
|
|
605
|
+
t += 1; self.tab_no[n["id"]] = f"{ch}.{t}"
|
|
606
|
+
texts = [n.get("text")] + list(n.get("items") or []) + list(n.get("head") or []) + [c for r in (n.get("rows") or []) for c in r]
|
|
607
|
+
for s in texts:
|
|
608
|
+
if s:
|
|
609
|
+
self._scan(str(s))
|
|
610
|
+
unused = [k for k in refs if k not in self.cite_no]
|
|
611
|
+
if unused:
|
|
612
|
+
raise ValueError(f"references never cited: {unused}")
|
|
613
|
+
|
|
614
|
+
def _scan(self, s):
|
|
615
|
+
for m in re.finditer(r"\[@([^\]]+)\]", s):
|
|
616
|
+
for k in m.group(1).split(","):
|
|
617
|
+
k = k.strip().lstrip("@")
|
|
618
|
+
if k not in self.refs:
|
|
619
|
+
raise KeyError(f"citation with no reference: {k}")
|
|
620
|
+
self.cite_no.setdefault(k, len(self.cite_no) + 1)
|
|
621
|
+
|
|
622
|
+
def _fmt_cite(self, keys):
|
|
623
|
+
nums = sorted({self.cite_no[k.strip().lstrip("@")] for k in keys}); out = []; i = 0
|
|
624
|
+
while i < len(nums):
|
|
625
|
+
j = i
|
|
626
|
+
while j + 1 < len(nums) and nums[j + 1] == nums[j] + 1:
|
|
627
|
+
j += 1
|
|
628
|
+
if j - i >= 2:
|
|
629
|
+
out.append(f"{nums[i]}-{nums[j]}")
|
|
630
|
+
else:
|
|
631
|
+
out.extend(str(x) for x in nums[i:j + 1])
|
|
632
|
+
i = j + 1
|
|
633
|
+
return "[" + ",".join(out) + "]"
|
|
634
|
+
|
|
635
|
+
def _resolve(self, s):
|
|
636
|
+
s = re.sub(r"\[@([^\]]+)\]", lambda m: self._fmt_cite(m.group(1).split(",")), str(s))
|
|
637
|
+
|
|
638
|
+
def fig(m):
|
|
639
|
+
return f'{self.s["fig_label"]} {self.fig_no[m.group(1)]}'
|
|
640
|
+
|
|
641
|
+
def tab(m):
|
|
642
|
+
return f'{self.s["tab_label"]} {self.tab_no[m.group(1)]}'
|
|
643
|
+
return re.sub(r"\{tab:([\w-]+)\}", tab, re.sub(r"\{fig:([\w-]+)\}", fig, s))
|
|
644
|
+
|
|
645
|
+
# ---------------- generated content ----------------
|
|
646
|
+
def _runs(self, p, text, bold=False, italic=False, size=None):
|
|
647
|
+
for seg in re.split(r"(\*\*[^*]+\*\*)", self._resolve(text)):
|
|
648
|
+
if not seg:
|
|
649
|
+
continue
|
|
650
|
+
b = seg.startswith("**") and seg.endswith("**")
|
|
651
|
+
r = p.add_run(seg[2:-2] if b else seg)
|
|
652
|
+
if b or bold:
|
|
653
|
+
r.bold = True
|
|
654
|
+
if italic:
|
|
655
|
+
r.italic = True
|
|
656
|
+
if size:
|
|
657
|
+
r.font.size = Pt(size)
|
|
658
|
+
|
|
659
|
+
def _p(self, style, text=None, bold=False, italic=False, size=None):
|
|
660
|
+
p = self.d.add_paragraph(style=self.d.styles[style])
|
|
661
|
+
if text is not None:
|
|
662
|
+
self._runs(p, text, bold, italic, size)
|
|
663
|
+
return p
|
|
664
|
+
|
|
665
|
+
def _field(self, instr, style="RptBody"):
|
|
666
|
+
p = self.d.add_paragraph(style=self.d.styles[style])
|
|
667
|
+
for part in ('<w:fldChar w:fldCharType="begin" w:dirty="true"/>', f'<w:instrText xml:space="preserve"> {instr} </w:instrText>',
|
|
668
|
+
'<w:fldChar w:fldCharType="separate"/>', '<w:t>Update this field in Word</w:t>', '<w:fldChar w:fldCharType="end"/>'):
|
|
669
|
+
p._p.append(parse_xml(f'<w:r xmlns:w="{W}">{part}</w:r>'))
|
|
670
|
+
return p
|
|
671
|
+
|
|
672
|
+
def add_toc(self, title="TABLE OF CONTENTS"):
|
|
673
|
+
self._p("RptFront", title)
|
|
674
|
+
self._field('TOC \\h \\z \\t "RptFront,1,RptChapter,1,RptH2,2,RptH3,3"')
|
|
675
|
+
|
|
676
|
+
def add_list_of_figures(self, title="LIST OF FIGURES"):
|
|
677
|
+
self._p("RptFront", title); self._field('TOC \\h \\z \\t "FigCaption,4"')
|
|
678
|
+
|
|
679
|
+
def add_list_of_tables(self, title="LIST OF TABLES"):
|
|
680
|
+
self._p("RptFront", title); self._field('TOC \\h \\z \\t "TabCaption,4"')
|
|
681
|
+
|
|
682
|
+
def _borders(self, tbl_el, on=True):
|
|
683
|
+
pr = tbl_el.find(qn("w:tblPr"))
|
|
684
|
+
for x in pr.findall(qn("w:tblBorders")):
|
|
685
|
+
pr.remove(x)
|
|
686
|
+
val = "single" if on else "nil"
|
|
687
|
+
pr.append(parse_xml(f'<w:tblBorders xmlns:w="{W}">' + "".join(f'<w:{e} w:val="{val}" w:sz="4" w:space="0" w:color="808080"/>' for e in ("top", "left", "bottom", "right", "insideH", "insideV")) + "</w:tblBorders>"))
|
|
688
|
+
|
|
689
|
+
def _table(self, widths_in, rows, head=True, size=None, borders=True, shade="D9E2F3", cell_style="RptCell", bold_first=False):
|
|
690
|
+
text_w = self.s["page_w"] - self.s["left"] - self.s["right"] - 0.03
|
|
691
|
+
scale = min(1, text_w / sum(widths_in))
|
|
692
|
+
tw = [int(w * 1440 * scale) for w in widths_in]
|
|
693
|
+
t = self.d.add_table(rows=len(rows), cols=len(widths_in))
|
|
694
|
+
el = t._tbl; pr = el.find(qn("w:tblPr"))
|
|
695
|
+
for tag in ("w:tblStyle", "w:tblW", "w:jc", "w:tblLayout"):
|
|
696
|
+
for x in pr.findall(qn(tag)):
|
|
697
|
+
pr.remove(x)
|
|
698
|
+
pr.append(parse_xml(f'<w:tblW xmlns:w="{W}" w:w="{sum(tw)}" w:type="dxa"/>'))
|
|
699
|
+
pr.append(parse_xml(f'<w:jc xmlns:w="{W}" w:val="center"/>'))
|
|
700
|
+
self._borders(el, borders)
|
|
701
|
+
pr.append(parse_xml(f'<w:tblLayout xmlns:w="{W}" w:type="fixed"/>'))
|
|
702
|
+
pr.append(parse_xml(f'<w:tblCellMar xmlns:w="{W}"><w:top w:w="50" w:type="dxa"/><w:left w:w="90" w:type="dxa"/><w:bottom w:w="50" w:type="dxa"/><w:right w:w="90" w:type="dxa"/></w:tblCellMar>'))
|
|
703
|
+
for g, w in zip(el.find(qn("w:tblGrid")).findall(qn("w:gridCol")), tw):
|
|
704
|
+
g.set(qn("w:w"), str(w))
|
|
705
|
+
for ri, row in enumerate(rows):
|
|
706
|
+
for ci, val in enumerate(row):
|
|
707
|
+
c = t.cell(ri, ci); c.width = Emu(tw[ci] * 635)
|
|
708
|
+
p = c.paragraphs[0]; p.style = self.d.styles[cell_style]
|
|
709
|
+
is_head = head and ri == 0
|
|
710
|
+
self._runs(p, val, bold=is_head or (bold_first and ci == 0), size=size)
|
|
711
|
+
if is_head and shade:
|
|
712
|
+
c._tc.get_or_add_tcPr().append(parse_xml(f'<w:shd xmlns:w="{W}" w:val="clear" w:color="auto" w:fill="{shade}"/>'))
|
|
713
|
+
return t
|
|
714
|
+
|
|
715
|
+
def add_nodes(self, nodes):
|
|
716
|
+
s = self.s
|
|
717
|
+
for n in nodes:
|
|
718
|
+
k = n["t"]
|
|
719
|
+
if k == "chapter":
|
|
720
|
+
p = self._p("RptChapter", f'CHAPTER {n["num"]}'); p.add_run().add_break(); p.add_run(n["title"])
|
|
721
|
+
elif k == "appendix":
|
|
722
|
+
p = self._p("RptChapter", f'APPENDIX {n["letter"]}'); p.add_run().add_break(); p.add_run(n["title"])
|
|
723
|
+
elif k == "front":
|
|
724
|
+
self._p("RptFront", n["text"])
|
|
725
|
+
elif k == "heading":
|
|
726
|
+
self._p("RptChapter", n["text"])
|
|
727
|
+
elif k == "h2":
|
|
728
|
+
self._p("RptH2", n["text"])
|
|
729
|
+
elif k == "h3":
|
|
730
|
+
self._p("RptH3", n["text"].upper() if s["h3_caps"] else n["text"])
|
|
731
|
+
elif k == "p":
|
|
732
|
+
self._p("RptBody", n["text"], size=n.get("size"))
|
|
733
|
+
elif k == "quote":
|
|
734
|
+
p = self._p("RptBody", n["text"], italic=True)
|
|
735
|
+
p.paragraph_format.left_indent = Inches(0.4); p.paragraph_format.right_indent = Inches(0.4)
|
|
736
|
+
elif k in ("num", "bullets"):
|
|
737
|
+
for i, it in enumerate(n["items"]):
|
|
738
|
+
p = self._p("RptBody", f"{i + 1}.\t{it}" if k == "num" else "•\t" + it)
|
|
739
|
+
p.paragraph_format.left_indent = Inches(0.3); p.paragraph_format.first_line_indent = Inches(-0.3)
|
|
740
|
+
elif k == "fig":
|
|
741
|
+
self._add_fig(n)
|
|
742
|
+
elif k == "table":
|
|
743
|
+
self._add_table(n)
|
|
744
|
+
elif k == "code":
|
|
745
|
+
self._add_code(n)
|
|
746
|
+
elif k == "abbrev":
|
|
747
|
+
self._table([1.4, 5.3], [[a, b] for a, b in n["items"]], head=False, borders=False, bold_first=True)
|
|
748
|
+
elif k == "pagebreak":
|
|
749
|
+
self.d.add_page_break()
|
|
750
|
+
else:
|
|
751
|
+
raise ValueError("unknown node " + k)
|
|
752
|
+
|
|
753
|
+
def _add_fig(self, n):
|
|
754
|
+
path = n["file"] if os.path.isabs(n["file"]) else os.path.join(self._img_base, n["file"])
|
|
755
|
+
with Image.open(path) as im:
|
|
756
|
+
iw, ih = im.size
|
|
757
|
+
max_w = self.s["page_w"] - self.s["left"] - self.s["right"]
|
|
758
|
+
w = min(float(n.get("w", 6.0)), max_w); h = w * ih / iw
|
|
759
|
+
if h > 8.0:
|
|
760
|
+
w *= 8.0 / h
|
|
761
|
+
p = self._p("RptFigure"); p.add_run().add_picture(path, width=Inches(w))
|
|
762
|
+
d = p._p.find(".//" + WP_DOCPR)
|
|
763
|
+
if d is not None:
|
|
764
|
+
d.set("descr", n["caption"])
|
|
765
|
+
c = self._p("FigCaption")
|
|
766
|
+
c.add_run(f'{self.s["fig_label"]} {self.fig_no[n["id"]]}: ').bold = True
|
|
767
|
+
c.add_run(self._resolve(n["caption"]))
|
|
768
|
+
|
|
769
|
+
def _add_table(self, n):
|
|
770
|
+
c = self._p("TabCaption")
|
|
771
|
+
c.add_run(f'{self.s["tab_label"]} {self.tab_no[n["id"]]}: ').bold = True
|
|
772
|
+
c.add_run(self._resolve(n["caption"]))
|
|
773
|
+
size = self.s["table_small"] if n.get("small") else None
|
|
774
|
+
t = self._table(n["widths"], [n["head"]] + n["rows"], size=size)
|
|
775
|
+
t.rows[0]._tr.get_or_add_trPr().append(OxmlElement("w:tblHeader"))
|
|
776
|
+
self._p("RptBody")
|
|
777
|
+
|
|
778
|
+
def _add_code(self, n):
|
|
779
|
+
"""Code box. Long listings are cut into (a), (b), (c) at blank lines, because a box longer than a page would split."""
|
|
780
|
+
lines = n["lines"] if "lines" in n else open(n["file"], encoding="utf-8").read().replace("\r", "").replace("\t", " ").split("\n")
|
|
781
|
+
lines = list(lines)
|
|
782
|
+
while lines and not lines[-1].strip():
|
|
783
|
+
lines.pop()
|
|
784
|
+
limit = int(n.get("max_lines", 55)); parts = []; cur = []
|
|
785
|
+
for ln in lines:
|
|
786
|
+
cur.append(ln)
|
|
787
|
+
if (len(cur) >= limit - 12 and not ln.strip()) or len(cur) >= limit:
|
|
788
|
+
parts.append(cur); cur = []
|
|
789
|
+
if cur:
|
|
790
|
+
parts.append(cur)
|
|
791
|
+
width = self.s["page_w"] - self.s["left"] - self.s["right"] - 0.05
|
|
792
|
+
for i, chunk in enumerate(parts):
|
|
793
|
+
t = self.d.add_table(rows=1, cols=1); el = t._tbl; pr = el.find(qn("w:tblPr"))
|
|
794
|
+
for x in pr.findall(qn("w:tblStyle")):
|
|
795
|
+
pr.remove(x)
|
|
796
|
+
self._borders(el)
|
|
797
|
+
cell = t.cell(0, 0); cell.width = Inches(width)
|
|
798
|
+
cell._tc.get_or_add_tcPr().append(parse_xml(f'<w:shd xmlns:w="{W}" w:val="clear" w:color="auto" w:fill="F4F4F4"/>'))
|
|
799
|
+
for j, ln in enumerate(chunk):
|
|
800
|
+
p = cell.paragraphs[0] if j == 0 else cell.add_paragraph()
|
|
801
|
+
p.style = self.d.styles["RptCode"]
|
|
802
|
+
r = p.add_run(ln or " "); r.font.name = "Courier New"
|
|
803
|
+
r._r.get_or_add_rPr().rFonts.set(qn("w:cs"), "Courier New")
|
|
804
|
+
cap = n["caption"]
|
|
805
|
+
if len(parts) > 1:
|
|
806
|
+
suffix = f'({"abcdefghijkl"[i]})'
|
|
807
|
+
if ":" in cap:
|
|
808
|
+
a, b = cap.split(":", 1); cap = f"{a}{suffix}:{b}"
|
|
809
|
+
else:
|
|
810
|
+
cap = f"{cap} {suffix}"
|
|
811
|
+
self._p("CodeCaption", cap)
|
|
812
|
+
|
|
813
|
+
def add_references(self, title="REFERENCES"):
|
|
814
|
+
self._p("RptChapter", title)
|
|
815
|
+
for k in sorted(self.cite_no, key=self.cite_no.get):
|
|
816
|
+
p = self._p("RptRef"); p.add_run(f"[{self.cite_no[k]}]\t{self.refs[k]}")
|
|
817
|
+
p.paragraph_format.left_indent = Inches(0.42); p.paragraph_format.first_line_indent = Inches(-0.42)
|
|
818
|
+
p.paragraph_format.tab_stops.add_tab_stop(Inches(0.42))
|
|
819
|
+
|
|
820
|
+
# ---------------- final passes ----------------
|
|
821
|
+
def no_split_tables(self):
|
|
822
|
+
"""Keep each table on one page: rows cannot split and every row stays with the next. A table longer than a page
|
|
823
|
+
still breaks, and word_pass reports it as SPLIT so it can be cut into two numbered tables."""
|
|
824
|
+
def flag(p, tag):
|
|
825
|
+
ppr = p.find(qn("w:pPr"))
|
|
826
|
+
if ppr is None:
|
|
827
|
+
ppr = OxmlElement("w:pPr"); p.insert(0, ppr)
|
|
828
|
+
if ppr.find(qn(tag)) is None:
|
|
829
|
+
ppr.append(OxmlElement(tag))
|
|
830
|
+
for tbl in self.body.iter(qn("w:tbl")):
|
|
831
|
+
rows = tbl.findall(qn("w:tr"))
|
|
832
|
+
nxt = tbl.getnext()
|
|
833
|
+
caption_below = nxt is not None and nxt.tag == qn("w:p") and "CodeCaption" in (nxt.xpath("./w:pPr/w:pStyle/@w:val") or [""])
|
|
834
|
+
for i, tr in enumerate(rows):
|
|
835
|
+
trpr = tr.find(qn("w:trPr"))
|
|
836
|
+
if trpr is None:
|
|
837
|
+
trpr = OxmlElement("w:trPr"); pex = tr.find(qn("w:tblPrEx"))
|
|
838
|
+
if pex is not None:
|
|
839
|
+
pex.addnext(trpr)
|
|
840
|
+
else:
|
|
841
|
+
tr.insert(0, trpr)
|
|
842
|
+
if trpr.find(qn("w:cantSplit")) is None:
|
|
843
|
+
trpr.insert(0, OxmlElement("w:cantSplit"))
|
|
844
|
+
for p in tr.iter(qn("w:p")):
|
|
845
|
+
flag(p, "w:keepLines")
|
|
846
|
+
if i < len(rows) - 1 or caption_below:
|
|
847
|
+
flag(p, "w:keepNext")
|
|
848
|
+
|
|
849
|
+
def schema_order(self):
|
|
850
|
+
"""Word calls a file corrupt when pPr/rPr/trPr children are out of schema order. Sort them."""
|
|
851
|
+
order = {
|
|
852
|
+
"pPr": "pStyle keepNext keepLines pageBreakBefore framePr widowControl numPr suppressLineNumbers pBdr shd tabs suppressAutoHyphens kinsoku wordWrap overflowPunct topLinePunct autoSpaceDE autoSpaceDN bidi adjustRightInd snapToGrid spacing ind contextualSpacing mirrorIndents suppressOverlap jc textDirection textAlignment textboxTightWrap outlineLvl divId cnfStyle rPr sectPr pPrChange",
|
|
853
|
+
"rPr": "rStyle rFonts b bCs i iCs caps smallCaps strike dstrike outline shadow emboss imprint noProof snapToGrid vanish webHidden color spacing w kern position sz szCs highlight u effect bdr shd fitText vertAlign rtl cs em lang eastAsianLayout specVanish oMath",
|
|
854
|
+
"trPr": "cnfStyle divId gridBefore gridAfter wBefore wAfter cantSplit trHeight tblHeader tblCellSpacing jc hidden ins del trPrChange",
|
|
855
|
+
"tblPr": "tblStyle tblpPr tblOverlap bidiVisual tblStyleRowBandSize tblStyleColBandSize tblW jc tblCellSpacing tblInd tblBorders shd tblLayout tblCellMar tblLook tblCaption tblDescription",
|
|
856
|
+
"tcPr": "cnfStyle tcW gridSpan hMerge vMerge tcBorders shd noWrap tcMar textDirection tcFitText vAlign hideMark",
|
|
857
|
+
"sectPr": "headerReference footerReference footnotePr endnotePr type pgSz pgMar paperSrc pgBorders lnNumType pgNumType cols formProt vAlign noEndnote titlePg textDirection bidi rtlGutter docGrid printerSettings",
|
|
858
|
+
}
|
|
859
|
+
order = {k: {n: i for i, n in enumerate(v.split())} for k, v in order.items()}
|
|
860
|
+
for tag, rank in order.items():
|
|
861
|
+
for el in self.body.iter(qn("w:" + tag)):
|
|
862
|
+
kids = list(el)
|
|
863
|
+
srt = sorted(kids, key=lambda x: rank.get(x.tag.split("}")[1], 999) if isinstance(x.tag, str) else 999)
|
|
864
|
+
if srt != kids:
|
|
865
|
+
for x in kids:
|
|
866
|
+
el.remove(x)
|
|
867
|
+
for x in srt:
|
|
868
|
+
el.append(x)
|
|
869
|
+
|
|
870
|
+
def save(self, out):
|
|
871
|
+
self.no_split_tables(); self.schema_order()
|
|
872
|
+
self.d.save(out)
|
|
873
|
+
return dict(figures=len(self.fig_no), tables=len(self.tab_no), references=len(self.cite_no))
|