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.
@@ -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))