dsh-office-kit 0.4.3 → 0.4.5

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.
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Drive a workbook that is already open in Excel or WPS Spreadsheets on Windows over COM: read cells, write values and formulas, style ranges, add charts and tables, recalculate, save, and report the exact ranges touched.
2
+ description: 'Drive a workbook that is already open in Excel or WPS Spreadsheets on Windows over COM: read cells, write values and formulas, style ranges, add charts and tables, recalculate, save, and report the exact ranges touched.'
3
3
  ---
4
4
 
5
5
  # Live spreadsheets (Excel / WPS)
@@ -137,9 +137,10 @@ def main(argv: list[str] | None = None) -> int:
137
137
  print("error: %s" % exc, file=sys.stderr)
138
138
  return 2
139
139
  hits = 0
140
- for paragraph in doc.paragraphs():
141
- if tracked_replace(paragraph, old, new, args.author, stamp, counter):
142
- hits += 1
140
+ for part in doc.text_parts():
141
+ for paragraph in doc.paragraphs(part):
142
+ if tracked_replace(paragraph, old, new, args.author, stamp, counter):
143
+ hits += 1
143
144
  applied.append({"old": old, "new": new, "paragraphs": hits})
144
145
 
145
146
  settings = doc.xml("word/settings.xml")
@@ -250,7 +250,7 @@ class Docx:
250
250
  while self.exists("word/%s%d.xml" % (kind, index)):
251
251
  index += 1
252
252
  part = "word/%s%d.xml" % (kind, index)
253
- root = etree.Element(q(kind), nsmap={"w": W, "r": R, "wp": WP, "v": V})
253
+ root = etree.Element(q("hdr" if kind == "header" else "ftr"), nsmap={"w": W, "r": R, "wp": WP, "v": V})
254
254
  etree.SubElement(etree.SubElement(root, q("p")), q("r"))
255
255
  self.set_xml(part, root)
256
256
  self.ensure_override(part, CT_BY_REL[rel_type])
@@ -459,45 +459,86 @@ def add_bookmark(parent, name: str, ident: int) -> None:
459
459
 
460
460
  # -- watermarks -------------------------------------------------------------
461
461
 
462
- _WATERMARK_SHAPETYPE = """<v:shapetype xmlns:v="urn:schemas-microsoft-com:vml" id="_x0000_t136" coordsize="21600,21600" adj="10800" path="m@7,l@8,m@5,21600l@11,21600e"><v:formulas><v:f eqn="sum #0 0 10800"/><v:f eqn="prod #0 2 1"/><v:f eqn="sum 21600 0 @1"/><v:f eqn="sum 0 0 @2"/><v:f eqn="sum 21600 0 @3"/><v:f eqn="if @0 @3 0"/><v:f eqn="if @0 21600 @1"/><v:f eqn="if @0 0 @2"/><v:f eqn="if @0 @4 21600"/><v:f eqn="mid @5 @6"/><v:f eqn="mid @8 @5"/><v:f eqn="mid @7 @8"/><v:f eqn="mid @6 @7"/><v:f eqn="sum @6 0 @5"/></v:formulas><v:path textpathok="t"/><v:textpath on="t" fitshape="t"/><v:handles><v:h position="#0,bottomRight" xrange="6629,14971"/></v:handles></v:shapetype>"""
463
-
464
- _WATERMARK_STYLE = (
465
- "position:absolute;margin-left:0;margin-top:0;width:468pt;height:117pt;"
466
- "rotation:-45;z-index:-251658752;mso-position-horizontal:center;"
467
- "mso-position-horizontal-relative:margin;mso-position-vertical:center;"
468
- "mso-position-vertical-relative:margin"
469
- )
462
+ WATERMARK_DESCR = "DSH Office watermark"
463
+
464
+ _WATERMARK_ANCHOR = """<wp:anchor xmlns:wp="%(wp)s" xmlns:a="%(a)s" xmlns:w="%(w)s" xmlns:wps="%(wps)s"
465
+ distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="251658240"
466
+ behindDoc="1" locked="0" layoutInCell="1" allowOverlap="1">
467
+ <wp:simplePos x="0" y="0"/>
468
+ <wp:positionH relativeFrom="page"><wp:align>center</wp:align></wp:positionH>
469
+ <wp:positionV relativeFrom="page"><wp:align>center</wp:align></wp:positionV>
470
+ <wp:extent cx="5486400" cy="1371600"/>
471
+ <wp:effectExtent l="0" t="0" r="0" b="0"/>
472
+ <wp:wrapNone/>
473
+ <wp:docPr id="%(id)d" name="PowerPlusWaterMarkObject%(id)d" descr="%(descr)s"/>
474
+ <wp:cNvGraphicFramePr/>
475
+ <a:graphic><a:graphicData uri="%(wps)s"><wps:wsp>
476
+ <wps:cNvSpPr/>
477
+ <wps:spPr>
478
+ <a:xfrm rot="-2700000"><a:off x="0" y="0"/><a:ext cx="5486400" cy="1371600"/></a:xfrm>
479
+ <a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
480
+ <a:noFill/><a:ln><a:noFill/></a:ln>
481
+ </wps:spPr>
482
+ <wps:txbx><w:txbxContent><w:p><w:pPr><w:jc w:val="center"/></w:pPr>
483
+ <w:r><w:rPr><w:color w:val="%(color)s"/><w:sz w:val="120"/></w:rPr><w:t>%(text)s</w:t></w:r>
484
+ </w:p></w:txbxContent></wps:txbx>
485
+ <wps:bodyPr vert="horz" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0"
486
+ anchor="ctr" anchorCtr="0"><a:noAutofit/></wps:bodyPr>
487
+ </wps:wsp></a:graphicData></a:graphic>
488
+ </wp:anchor>"""
489
+
490
+ _WPS_NS = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"
470
491
 
471
492
 
472
493
  def watermark_paragraph(text: str, ident: int = 1, color: str = "#C0C0C0"):
473
- """A header paragraph holding one VML text watermark."""
494
+ """A header paragraph holding one DrawingML text watermark.
495
+
496
+ Word stores watermarks as VML, but VML shapes are routinely dropped by other
497
+ renderers, so the shape here is DrawingML (wps) and marked with a stable
498
+ ``descr`` so it can be found and removed again.
499
+ """
500
+ from xml.sax.saxutils import escape
501
+
502
+ color = color.lstrip("#").upper() or "C0C0C0"
474
503
  paragraph = etree.Element(q("p"))
475
504
  run = etree.SubElement(paragraph, q("r"))
476
505
  run_props = etree.SubElement(run, q("rPr"))
477
506
  etree.SubElement(run_props, q("noProof"))
478
- pict = etree.SubElement(run, q("pict"))
479
- pict.append(etree.fromstring(_WATERMARK_SHAPETYPE))
480
- shape = etree.SubElement(pict, q("shape", V))
481
- shape.set("id", "WaterMarkObject%d" % ident)
482
- shape.set("type", "#_x0000_t136")
483
- shape.set("style", _WATERMARK_STYLE)
484
- shape.set("fillcolor", color)
485
- shape.set("stroked", "f")
486
- textpath = etree.SubElement(shape, q("textpath", V))
487
- textpath.set("style", 'font-family:"Calibri";font-size:1pt')
488
- textpath.set("string", text)
507
+ drawing = etree.SubElement(run, q("drawing"))
508
+ anchor = etree.fromstring(_WATERMARK_ANCHOR % {
509
+ "wp": WP,
510
+ "a": A,
511
+ "w": W,
512
+ "wps": _WPS_NS,
513
+ "id": ident,
514
+ "descr": WATERMARK_DESCR,
515
+ "color": color,
516
+ "text": escape(str(text)),
517
+ })
518
+ drawing.append(anchor)
489
519
  return paragraph
490
520
 
491
521
 
522
+ def _paragraph_above(node):
523
+ while node is not None and node.tag != q("p"):
524
+ node = node.getparent()
525
+ return node
526
+
527
+
492
528
  def watermark_texts(doc: Docx) -> list[dict]:
493
- """Every VML text watermark in the headers, with the paragraph that holds it."""
529
+ """Every VML or DrawingML text watermark in the headers, with its paragraph."""
494
530
  found = []
495
531
  for part in doc.names("word/header"):
496
- for textpath in doc.xml(part).iter(q("textpath", V)):
497
- paragraph = textpath
498
- while paragraph is not None and paragraph.tag != q("p"):
499
- paragraph = paragraph.getparent()
500
- found.append({"part": part, "text": textpath.get("string"), "paragraph": paragraph})
532
+ root = doc.xml(part)
533
+ for textpath in root.iter(q("textpath", V)):
534
+ text = textpath.get("string")
535
+ if text:
536
+ found.append({"part": part, "kind": "vml", "text": text, "paragraph": _paragraph_above(textpath)})
537
+ for pr in root.iter(q("docPr", WP)):
538
+ if pr.get("descr") != WATERMARK_DESCR:
539
+ continue
540
+ text = "".join(node.text or "" for node in pr.getparent().iter(q("t")))
541
+ found.append({"part": part, "kind": "drawingml", "text": text, "paragraph": _paragraph_above(pr)})
501
542
  return found
502
543
 
503
544
 
@@ -58,7 +58,8 @@ def main(argv: list[str] | None = None) -> int:
58
58
  for index, paragraph in enumerate(doc.paragraphs(), start=1):
59
59
  text = kit.paragraph_text(paragraph).strip()
60
60
  level = kit.heading_level(paragraph)
61
- if level == 0:
61
+ style = (kit.paragraph_style(paragraph) or "").strip().lower()
62
+ if style == "title":
62
63
  outline.append({"index": index, "style": "Title", "text": text})
63
64
  continue
64
65
  if level:
@@ -71,9 +72,7 @@ def main(argv: list[str] | None = None) -> int:
71
72
  if manual_heading(paragraph):
72
73
  findings.append({"kind": "manual_formatting", "index": index, "text": text})
73
74
  previous = level
74
- elif not text:
75
- continue
76
- elif manual_heading(paragraph) and len(text) <= 120:
75
+ elif text and manual_heading(paragraph) and len(text) <= 120:
77
76
  findings.append({"kind": "heading_not_styled", "index": index, "text": text})
78
77
 
79
78
  counts = {}
@@ -131,8 +131,10 @@ def main(argv: list[str] | None = None) -> int:
131
131
  body.insert(position + offset, node)
132
132
 
133
133
  if not args.no_back_to_toc:
134
+ anchors = {entry["anchor"] for entry in headings}
134
135
  for paragraph_node in list(body.iter(kit.q("p"))):
135
- if any(entry["anchor"] == name for entry in headings for name in (paragraph_node.find(kit.q("bookmarkStart")).get(kit.q("name")),) if paragraph_node.find(kit.q("bookmarkStart")) is not None):
136
+ start = paragraph_node.find(kit.q("bookmarkStart"))
137
+ if start is not None and start.get(kit.q("name")) in anchors:
136
138
  back = paragraph("Caption")
137
139
  back.append(link_run("Back to contents", TOC_BOOKMARK))
138
140
  paragraph_node.addnext(back)
@@ -173,6 +173,101 @@ def remap_relationships(base: kit.Docx, append: kit.Docx, nodes: list) -> int:
173
173
  return copied
174
174
 
175
175
 
176
+ NOTE_PARTS = (
177
+ ("word/footnotes.xml", kit.REL_FOOTNOTES, "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", kit.q("footnote"), (kit.q("footnoteReference"),)),
178
+ ("word/endnotes.xml", kit.REL_ENDNOTES, "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml", kit.q("endnote"), (kit.q("endnoteReference"),)),
179
+ ("word/comments.xml", kit.REL_COMMENTS, "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", kit.q("comment"), (kit.q("commentReference"), kit.q("commentRangeStart"), kit.q("commentRangeEnd"))),
180
+ )
181
+
182
+
183
+ def remap_part_relationships(base: kit.Docx, append: kit.Docx, append_part: str, base_part: str, nodes: list) -> int:
184
+ """Remap r:embed/r:id/r:link inside nodes, copying targets into the base package."""
185
+ source_rels = {entry["id"]: entry for entry in append.relationships(append_part)}
186
+ attributes = (kit.q("embed", kit.R), kit.q("id", kit.R), kit.q("link", kit.R))
187
+ mapping = {}
188
+ copied = 0
189
+ for node in nodes:
190
+ for element in node.iter():
191
+ for attribute in attributes:
192
+ rid = element.get(attribute)
193
+ if not rid:
194
+ continue
195
+ if rid not in mapping:
196
+ rel = source_rels.get(rid)
197
+ if rel is None:
198
+ mapping[rid] = rid
199
+ continue
200
+ if rel["mode"] == "External":
201
+ mapping[rid] = base.ensure_rel(base_part, rel["type"], rel["target"], external=True)
202
+ elif append.exists(rel["resolved"]):
203
+ target = copy_part(base, append, rel["resolved"])
204
+ relative = posixpath.relpath(target, posixpath.dirname(base_part))
205
+ mapping[rid] = base.ensure_rel(base_part, rel["type"], relative)
206
+ copied += 1
207
+ else:
208
+ mapping[rid] = rid
209
+ element.set(attribute, mapping[rid])
210
+ return copied
211
+
212
+
213
+ def merge_note_part(base: kit.Docx, append: kit.Docx, part: str, rel_type: str, content_type: str, note_tag: str) -> dict[str, str]:
214
+ """Merge a footnotes/endnotes/comments part into the base, returning old→new ids."""
215
+ if not append.exists(part):
216
+ return {}
217
+ source_root = append.xml(part)
218
+ if base.exists(part):
219
+ target_root = base.xml(part)
220
+ else:
221
+ target_root = etree.Element(source_root.tag, nsmap=source_root.nsmap)
222
+ for note in source_root.findall(note_tag):
223
+ raw = note.get(kit.q("id"))
224
+ if raw is not None and raw.lstrip("-").isdigit() and int(raw) <= 0:
225
+ target_root.append(etree.fromstring(etree.tostring(note)))
226
+ base.set_xml(part, target_root)
227
+ base.ensure_override(part, content_type)
228
+ base.ensure_rel("word/document.xml", rel_type, posixpath.relpath(part, "word"))
229
+
230
+ used = {
231
+ int(node.get(kit.q("id")))
232
+ for node in target_root.findall(note_tag)
233
+ if (node.get(kit.q("id")) or "").lstrip("-").isdigit()
234
+ }
235
+ next_id = max(used, default=0) + 1
236
+ mapping: dict[str, str] = {}
237
+ for note in source_root.findall(note_tag):
238
+ raw = note.get(kit.q("id"))
239
+ if raw is None or not raw.lstrip("-").isdigit() or int(raw) <= 0:
240
+ continue
241
+ clone = etree.fromstring(etree.tostring(note))
242
+ clone.set(kit.q("id"), str(next_id))
243
+ remap_part_relationships(base, append, part, part, [clone])
244
+ target_root.append(clone)
245
+ mapping[raw] = str(next_id)
246
+ next_id += 1
247
+ return mapping
248
+
249
+
250
+ def bind_note_references(clones: list, ref_tags: tuple, mapping: dict[str, str], target_exists: bool) -> int:
251
+ """Point body references at the merged notes; drop references that have no note."""
252
+ dropped = 0
253
+ for clone in clones:
254
+ for ref_tag in ref_tags:
255
+ for element in list(clone.iter(ref_tag)):
256
+ raw = element.get(kit.q("id"))
257
+ if raw is None:
258
+ continue
259
+ if raw in mapping:
260
+ element.set(kit.q("id"), mapping[raw])
261
+ elif target_exists and raw.isdigit() and int(raw) <= 0:
262
+ continue
263
+ else:
264
+ parent = element.getparent()
265
+ if parent is not None:
266
+ parent.remove(element)
267
+ dropped += 1
268
+ return dropped
269
+
270
+
176
271
  def main(argv: list[str] | None = None) -> int:
177
272
  parser = argparse.ArgumentParser(description="Append one .docx body to another, copying media, numbering and styles.")
178
273
  parser.add_argument("base", help="Document to append to")
@@ -220,6 +315,13 @@ def main(argv: list[str] | None = None) -> int:
220
315
  if num_id is not None and num_id.get(kit.q("val")) in number_map:
221
316
  num_id.set(kit.q("val"), number_map[num_id.get(kit.q("val"))])
222
317
  media_copied = remap_relationships(base, append, clones)
318
+ notes_merged = 0
319
+ references_dropped = 0
320
+ for part, rel_type, content_type, note_tag, ref_tags in NOTE_PARTS:
321
+ exists = append.exists(part)
322
+ mapping = merge_note_part(base, append, part, rel_type, content_type, note_tag) if exists else {}
323
+ notes_merged += len(mapping)
324
+ references_dropped += bind_note_references(clones, ref_tags, mapping, exists)
223
325
  copy_defaults(base, append)
224
326
 
225
327
  body = base.body()
@@ -237,6 +339,8 @@ def main(argv: list[str] | None = None) -> int:
237
339
  "styles_copied": styles_copied,
238
340
  "media_copied": media_copied,
239
341
  "numbering_remapped": len(number_map),
342
+ "notes_merged": notes_merged,
343
+ "references_dropped": references_dropped,
240
344
  }
241
345
  )
242
346
  return 0
@@ -98,7 +98,11 @@ def fix_table(table, target: int) -> None:
98
98
  if not columns:
99
99
  columns = [1]
100
100
  total = sum(columns) or len(columns)
101
- scaled = [max(1, round(target * value / total)) for value in columns]
101
+ scaled = [max(1, target * value // total) for value in columns]
102
+ remainder = target - sum(scaled)
103
+ order = sorted(range(len(columns)), key=lambda index: (target * columns[index]) % total, reverse=True)
104
+ for index in range(max(0, remainder)):
105
+ scaled[order[index % len(order)]] += 1
102
106
  grid = table.find(kit.q("tblGrid"))
103
107
  if grid is None:
104
108
  grid = etree.Element(kit.q("tblGrid"))
@@ -163,8 +167,14 @@ def main(argv: list[str] | None = None) -> int:
163
167
  fix_table(table, target)
164
168
  out = args.out or args.docx
165
169
  doc.save(out)
166
- kit.emit({"ok": True, "fixed": True, "output": str(Path(out)), "tables": len(tables), "text_width": target, "findings": findings})
167
- return 0
170
+ remaining = []
171
+ for index, info in enumerate((table_info(table) for table in tables), start=1):
172
+ if info["grid_total"] and info["grid_total"] > target:
173
+ remaining.append({"kind": "grid_wider_than_text", "table": index, "grid_total": info["grid_total"], "text_width": target})
174
+ if info["layout"] != "fixed":
175
+ remaining.append({"kind": "not_fixed_layout", "table": index})
176
+ kit.emit({"ok": not remaining, "fixed": True, "output": str(Path(out)), "tables": len(tables), "text_width": target, "findings": remaining})
177
+ return 0 if not remaining else 1
168
178
 
169
179
  kit.emit({"ok": not findings, "text_width": target, "tables": rows, "findings": findings})
170
180
  return 1 if findings else 0
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env python
2
- """Add a diagonal text watermark to the page background.
2
+ """Add a text watermark to the page background.
3
3
 
4
- The watermark is a VML text shape in the default header, which is how Word has
5
- stored them since the 2007 file format: it survives conversion, prints on every
6
- page, and can be removed again by deleting the header shape.
4
+ The watermark is a DrawingML text box in the default header: Word rotates it
5
+ -45 degrees, while VML-only renderers that drop VML shapes still show the mark.
6
+ It prints behind the body on every page and can be removed again by deleting
7
+ the header paragraph.
7
8
 
8
9
  Exit codes: 0 success, 2 bad input.
9
10
  """
@@ -26,7 +27,7 @@ for _stream in (sys.stdout, sys.stderr):
26
27
 
27
28
 
28
29
  def main(argv: list[str] | None = None) -> int:
29
- parser = argparse.ArgumentParser(description="Add a VML text watermark to the document header.")
30
+ parser = argparse.ArgumentParser(description="Add a DrawingML text watermark to the document header.")
30
31
  parser.add_argument("input_docx", help="Input .docx")
31
32
  parser.add_argument("--out", required=True, help="Output .docx")
32
33
  parser.add_argument("--text", required=True, help="Watermark text, e.g. CONFIDENTIAL")
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env python
2
- """Find and remove VML text watermarks from headers.
2
+ """Find and remove text watermarks from headers.
3
3
 
4
- A watermark that was meant for a draft can end up in a signed document, and it is
5
- invisible in text extraction because it lives in a shape, not in a paragraph run.
4
+ Both DrawingML watermarks (what ``watermark_add.py`` writes) and legacy VML
5
+ ``v:textpath`` shapes are detected. A watermark that was meant for a draft can
6
+ end up in a signed document, and it is invisible in text extraction because it
7
+ lives in a shape, not in a paragraph run.
6
8
 
7
9
  Exit codes: 0 clean, 1 watermarks found (report mode), 2 bad input.
8
10
  """
@@ -25,7 +27,7 @@ for _stream in (sys.stdout, sys.stderr):
25
27
 
26
28
 
27
29
  def main(argv: list[str] | None = None) -> int:
28
- parser = argparse.ArgumentParser(description="Report or remove watermarks stored as VML shapes in headers.")
30
+ parser = argparse.ArgumentParser(description="Report or remove watermarks stored as DrawingML or VML shapes in headers.")
29
31
  parser.add_argument("input_docx", help="Input .docx")
30
32
  parser.add_argument("--mode", choices=("report", "remove"), default="report")
31
33
  parser.add_argument("--contains", default=None, help="Only match watermarks containing this text")
@@ -1,14 +1,14 @@
1
1
  # Watermarks and background marks
2
2
 
3
- A Word watermark is not its own element: it is a VML shape (`v:shape` with `v:textpath`) inside a header part, behind the text. That is why it disappears when the header is replaced and can differ per section.
3
+ A text watermark is a shape inside a header part, behind the body text: Word writes legacy VML (`v:shape` with `v:textpath`), while `watermark_add.py` writes DrawingML (`wps:wsp`) because VML is routinely dropped by non-Word renderers. That is why a watermark disappears when the header is replaced and can differ per section.
4
4
 
5
5
  ## Add
6
6
 
7
7
  ```bash
8
- "<python>" "<skill>/scripts/watermark_add.py" "<file.docx>" --out "<file-draft.docx" --text "DRAFT"
8
+ "<python>" "<skill>/scripts/watermark_add.py" "<file.docx>" --out "<file-draft.docx>" --text "DRAFT"
9
9
  ```
10
10
 
11
- Writes the shape into the default header: light grey, rotated -45°, behind the body. The script refuses to add a second watermark when it finds one, so remove the old one first. For a mark on every page across sections, each section's header needs the shape — loop over the sections.
11
+ Writes the shape into the default header: light grey, centred on the page, tagged with `descr="DSH Office watermark"`. Word rotates it -45°; renderers that ignore the rotation still show a horizontal mark, so it is always visible. The script refuses to add a second watermark when it finds one, so remove the old one first. For a mark on every page across sections, each section's header needs the shape — loop over the sections.
12
12
 
13
13
  ## Find and remove
14
14
 
@@ -17,15 +17,15 @@ Writes the shape into the default header: light grey, rotated -45°, behind the
17
17
  "<python>" "<skill>/scripts/watermark_audit_remove.py" "<file.docx>" --mode remove --out "<file-clean.docx"
18
18
  ```
19
19
 
20
- The report lists every VML text path and the part it lives in, and exits 1 when any watermark exists. `--contains` narrows it to a substring, for the "template says Company Confidential" case. Remove deletes the enclosing paragraph from the header.
20
+ The report lists every DrawingML watermark and every legacy VML text path, with the part it lives in, and exits 1 when any watermark exists. `--contains` narrows it to a substring, for the "template says Company Confidential" case. Remove deletes the enclosing paragraph from the header.
21
21
 
22
22
  ## Verify
23
23
 
24
- Render and read the pages: the mark sits behind the text, inside the page, on every expected page. A watermark missing on page two means that section has a different header.
24
+ Render and read the pages: the mark sits behind the text, inside the page, on every expected page. A light grey drawing that covers a line of body text is still a defect; a mark missing on page two means that section has a different header.
25
25
 
26
26
  ## Pitfalls
27
27
 
28
28
  - A watermark in a "different first page" header shows only on page one.
29
29
  - Removing the header paragraph can leave an empty header that adds vertical space — check the top margin.
30
- - An image watermark is `v:imagedata`, not a text path, so the report will not name it; inspect the header XML.
30
+ - An image watermark is `v:imagedata`, not a text shape, so the report will not name it; inspect the header XML.
31
31
  - A watermark is not security (`tasks/protection_restrict_editing.md`).
@@ -15,6 +15,7 @@ Exit codes: 0 success, 1 bad arguments, 2 unreadable input or write failure,
15
15
  from __future__ import annotations
16
16
 
17
17
  import argparse
18
+ import io
18
19
  import json
19
20
  import sys
20
21
  from pathlib import Path
@@ -31,6 +32,8 @@ except ImportError: # pragma: no cover - pypdf is a required dependency
31
32
  IndirectObject = None # type: ignore[assignment]
32
33
  NameObject = None # type: ignore[assignment]
33
34
 
35
+ from pdf_fonts import find_cjk_font, has_non_latin, register_cjk_font
36
+
34
37
 
35
38
  def emit(value, out=None):
36
39
  text = json.dumps(value, ensure_ascii=False, indent=2, default=str)
@@ -49,6 +52,114 @@ def fail(message, code=1, detail=None):
49
52
  raise SystemExit(code)
50
53
 
51
54
 
55
+ def rebuild_cjk_appearances(path, values, font_file):
56
+ """Replace the appearance of text widgets whose value is non-Latin.
57
+
58
+ pypdf can only generate appearance streams with the base-14 fonts, so a
59
+ Chinese value would paint as question marks even though /V is correct.
60
+ Each such widget gets a fresh form XObject drawn with a real CJK font,
61
+ with the field border and background kept.
62
+ """
63
+ from pypdf import PdfReader, PdfWriter
64
+ from pypdf.generic import ArrayObject, DictionaryObject, FloatObject, NameObject, NumberObject, StreamObject
65
+ from reportlab.lib.colors import Color
66
+ from reportlab.pdfbase import pdfmetrics
67
+ from reportlab.pdfgen import canvas as rl_canvas
68
+
69
+ reader = PdfReader(str(path))
70
+ report = inspect_document(reader)
71
+ targets = []
72
+ for widget in report["widgets"]:
73
+ name = widget["name"]
74
+ value = values.get(name)
75
+ if not has_non_latin(value) or widget["field_type"] != "/Tx":
76
+ continue
77
+ rect = widget["rect"]
78
+ if len(rect) != 4:
79
+ continue
80
+ width = float(rect[2]) - float(rect[0])
81
+ height = float(rect[3]) - float(rect[1])
82
+ if width <= 1 or height <= 1:
83
+ continue
84
+ targets.append({"page": widget["page"], "name": name, "value": value, "width": width, "height": height})
85
+ if not targets:
86
+ return []
87
+
88
+ try:
89
+ font_path = find_cjk_font(font_file)
90
+ except FileNotFoundError as exc:
91
+ fail(str(exc), code=2)
92
+ if font_path is None:
93
+ fail("values contain non-Latin text but no CJK font was found; pass --font-file", code=2)
94
+ try:
95
+ font_name = register_cjk_font(font_path, "FormCJK")
96
+ except Exception as exc:
97
+ fail(f"cannot use font file {font_path}: {exc}", code=2)
98
+
99
+ overlay = io.BytesIO()
100
+ page = rl_canvas.Canvas(overlay)
101
+ for target in targets:
102
+ width, height = target["width"], target["height"]
103
+ page.setPageSize((width, height))
104
+ page.setFillColor(Color(1, 1, 1))
105
+ page.rect(0, 0, width, height, stroke=0, fill=1)
106
+ page.setStrokeColor(Color(0.55, 0.55, 0.55))
107
+ page.setLineWidth(0.5)
108
+ page.rect(0.25, 0.25, width - 0.5, height - 0.5, stroke=1, fill=0)
109
+ size = min(10.0, max(height - 3, 4.0))
110
+ while size > 4 and pdfmetrics.stringWidth(target["value"], font_name, size) > width - 4:
111
+ size -= 0.5
112
+ page.setFillColor(Color(0, 0, 0))
113
+ page.setFont(font_name, size)
114
+ baseline = (height - size) / 2 + size * 0.22
115
+ page.drawString(2, baseline, target["value"])
116
+ page.showPage()
117
+ page.save()
118
+ overlay.seek(0)
119
+ overlay_reader = PdfReader(overlay)
120
+
121
+ writer = PdfWriter()
122
+ writer.clone_document_from_reader(reader)
123
+ original_pages = len(writer.pages)
124
+ writer.append(overlay_reader)
125
+ appended = writer.pages[original_pages:]
126
+
127
+ for index, target in enumerate(targets):
128
+ overlay_page = appended[index]
129
+ contents = overlay_page.get_contents()
130
+ data = contents.get_data() if contents is not None else b""
131
+ resources = overlay_page.get("/Resources")
132
+ stream = StreamObject()
133
+ stream.set_data(data)
134
+ stream[NameObject("/Type")] = NameObject("/XObject")
135
+ stream[NameObject("/Subtype")] = NameObject("/Form")
136
+ stream[NameObject("/FormType")] = NumberObject(1)
137
+ stream[NameObject("/BBox")] = ArrayObject(
138
+ [FloatObject(0), FloatObject(0), FloatObject(target["width"]), FloatObject(target["height"])]
139
+ )
140
+ if resources is not None:
141
+ stream[NameObject("/Resources")] = resources
142
+ reference = writer._add_object(stream)
143
+ page = writer.pages[target["page"] - 1]
144
+ for annotation in page.get("/Annots") or []:
145
+ obj = annotation.get_object()
146
+ if obj.get("/Subtype") != "/Widget":
147
+ continue
148
+ parent = obj.get("/Parent")
149
+ source = parent.get_object() if parent is not None else obj
150
+ tag = obj.get("/T") or source.get("/T")
151
+ if str(tag) != target["name"]:
152
+ continue
153
+ obj[NameObject("/AP")] = DictionaryObject({NameObject("/N"): reference})
154
+ break
155
+
156
+ while len(writer.pages) > original_pages:
157
+ writer.remove_page(len(writer.pages) - 1)
158
+ with open(path, "wb") as handle:
159
+ writer.write(handle)
160
+ return [target["name"] for target in targets]
161
+
162
+
52
163
  def ref_id(node):
53
164
  if isinstance(node, IndirectObject):
54
165
  return node.idnum
@@ -285,6 +396,85 @@ def values_for_field(field, value):
285
396
  return str(value)
286
397
 
287
398
 
399
+ def flatten_widgets(path):
400
+ """Paint every widget appearance into page content and drop the form."""
401
+ from pypdf import PdfReader, PdfWriter
402
+ from pypdf.generic import ArrayObject, DictionaryObject, FloatObject, NameObject, StreamObject
403
+
404
+ reader = PdfReader(str(path))
405
+ writer = PdfWriter()
406
+ writer.clone_document_from_reader(reader)
407
+
408
+ for page in writer.pages:
409
+ annotations = page.get("/Annots")
410
+ if not annotations:
411
+ continue
412
+ resources = page.get("/Resources")
413
+ if resources is None:
414
+ resources = DictionaryObject()
415
+ page[NameObject("/Resources")] = resources
416
+ resources = resources.get_object()
417
+ xobjects = resources.get("/XObject")
418
+ if xobjects is None:
419
+ xobjects = DictionaryObject()
420
+ resources[NameObject("/XObject")] = xobjects
421
+ xobjects = xobjects.get_object()
422
+
423
+ calls = []
424
+ for index, annotation in enumerate(annotations):
425
+ widget = annotation.get_object()
426
+ if widget.get("/Subtype") != "/Widget":
427
+ continue
428
+ normal = widget.get("/AP")
429
+ if normal is None:
430
+ continue
431
+ normal = normal.get_object().get("/N")
432
+ if normal is None:
433
+ continue
434
+ normal = normal.get_object()
435
+ if not hasattr(normal, "get_data"):
436
+ if not hasattr(normal, "keys"):
437
+ continue
438
+ state = widget.get("/AS")
439
+ if state is None or normal.get(state) is None:
440
+ continue
441
+ normal = normal[state].get_object()
442
+ if not hasattr(normal, "get_data"):
443
+ continue
444
+ name = NameObject(f"/DSCJK{index}")
445
+ xobjects[name] = normal.indirect_reference or normal
446
+ rect = [float(value) for value in widget.get("/Rect") or []]
447
+ bbox = [float(value) for value in normal.get("/BBox") or []]
448
+ if len(rect) != 4 or len(bbox) != 4:
449
+ continue
450
+ x, y, x2, y2 = rect[0], rect[1], rect[2], rect[3]
451
+ width = x2 - x
452
+ height = y2 - y
453
+ box_width = bbox[2] - bbox[0] or width
454
+ box_height = bbox[3] - bbox[1] or height
455
+ calls.append(
456
+ f"q {width / box_width:.6f} 0 0 {height / box_height:.6f} {x:.4f} {y:.4f} cm {name} Do Q"
457
+ )
458
+ if not calls:
459
+ continue
460
+ stream = StreamObject()
461
+ stream.set_data("\n".join(calls).encode("latin-1"))
462
+ reference = writer._add_object(stream)
463
+ existing = page.get("/Contents")
464
+ if existing is None:
465
+ page[NameObject("/Contents")] = reference
466
+ elif hasattr(existing, "get_data"):
467
+ page[NameObject("/Contents")] = ArrayObject([existing.indirect_reference or existing, reference])
468
+ else:
469
+ page[NameObject("/Contents")] = ArrayObject([*existing, reference])
470
+
471
+ for page in writer.pages:
472
+ page[NameObject("/Annots")] = ArrayObject()
473
+ writer.root_object.pop(NameObject("/AcroForm"), None)
474
+ with open(path, "wb") as handle:
475
+ writer.write(handle)
476
+
477
+
288
478
  def write_fill(args, values):
289
479
  from pypdf import PdfReader, PdfWriter
290
480
 
@@ -332,16 +522,28 @@ def write_fill(args, values):
332
522
  payload = current
333
523
  payload = {name: values_for_field(fields[name], value) for name, value in payload.items()}
334
524
 
335
- writer.update_page_form_field_values(None, payload, auto_regenerate=False, flatten=args.flatten)
336
- sync_button_states(writer)
337
- if args.flatten:
338
- writer.remove_annotations(subtypes="/Widget")
339
- writer.root_object.pop(NameObject("/AcroForm"), None)
525
+ cjk_values = {name: value for name, value in expected.items() if has_non_latin(value)}
526
+ flatten = args.flatten
527
+ if flatten and cjk_values:
528
+ # pypdf can only flatten with base-14 fonts; keep the widgets, give them
529
+ # real CJK appearances, then paint every appearance into the page.
530
+ writer.update_page_form_field_values(None, payload, auto_regenerate=False, flatten=False)
531
+ sync_button_states(writer)
532
+ else:
533
+ writer.update_page_form_field_values(None, payload, auto_regenerate=False, flatten=flatten)
534
+ sync_button_states(writer)
535
+ if flatten:
536
+ writer.remove_annotations(subtypes="/Widget")
537
+ writer.root_object.pop(NameObject("/AcroForm"), None)
340
538
 
341
539
  args.out.parent.mkdir(parents=True, exist_ok=True)
342
540
  with open(args.out, "wb") as handle:
343
541
  writer.write(handle)
344
542
 
543
+ cjk_rendered = rebuild_cjk_appearances(args.out, expected, args.font_file)
544
+ if flatten and cjk_values:
545
+ flatten_widgets(args.out)
546
+
345
547
  checks = verify(args.out, expected, flatten=args.flatten)
346
548
  rendered = render(args.out, args.render_dir, args.dpi)
347
549
 
@@ -351,6 +553,7 @@ def write_fill(args, values):
351
553
  "output": str(args.out),
352
554
  "flatten": args.flatten,
353
555
  "reattached_fields": reattached,
556
+ "cjk_appearance_fields": cjk_rendered,
354
557
  "written": expected,
355
558
  "checks": checks,
356
559
  "rendered_pages": rendered,
@@ -517,6 +720,7 @@ def main():
517
720
  help='JSON object of {field: value}; accepts a file path, inline JSON, or "-" for stdin',
518
721
  )
519
722
  fill_parser.add_argument("--flatten", action="store_true", help="Paint values and remove every widget")
723
+ fill_parser.add_argument("--font-file", help="TTF/OTF/TTC file used to paint non-Latin values")
520
724
  fill_parser.add_argument("--no-reattach", action="store_true", help="Do not repair widgets missing from /AcroForm/Fields")
521
725
  fill_parser.add_argument("--allow-ambiguous", action="store_true", help="Fill even when widget and field views disagree")
522
726
  fill_parser.add_argument("--render-dir", help="Rasterise the result here for a visual check")
@@ -0,0 +1,67 @@
1
+ """Font handling shared by the PDF scripts.
2
+
3
+ reportlab's base-14 fonts cover Latin-1 only, so a Chinese, Japanese, or Korean
4
+ stamp or form value needs a real font file. These helpers find one, or report
5
+ that the caller must pass --font-file.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ CJK_FONT_CANDIDATES = {
15
+ "win32": [
16
+ "C:/Windows/Fonts/msyh.ttc",
17
+ "C:/Windows/Fonts/msyhbd.ttc",
18
+ "C:/Windows/Fonts/simhei.ttf",
19
+ "C:/Windows/Fonts/simsun.ttc",
20
+ ],
21
+ "linux": [
22
+ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
23
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
24
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
25
+ "/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc",
26
+ ],
27
+ "darwin": [
28
+ "/System/Library/Fonts/PingFang.ttc",
29
+ "/System/Library/Fonts/STHeiti Medium.ttc",
30
+ "/System/Library/Fonts/Hiragino Sans GB.ttc",
31
+ ],
32
+ }
33
+
34
+
35
+ def has_non_latin(text) -> bool:
36
+ """True when text needs a font beyond the base-14 Latin sets."""
37
+ return isinstance(text, str) and any(ord(character) > 0xFF for character in text)
38
+
39
+
40
+ def find_cjk_font(explicit=None) -> Path | None:
41
+ """Return a usable CJK font path, honouring an explicit path first."""
42
+ if explicit:
43
+ path = Path(explicit).expanduser()
44
+ if not path.is_file():
45
+ raise FileNotFoundError(f"font file not found: {path}")
46
+ return path
47
+ override = os.environ.get("DSH_OFFICE_CJK_FONT")
48
+ if override and Path(override).expanduser().is_file():
49
+ return Path(override).expanduser()
50
+ platform_key = "win32" if sys.platform.startswith("win") else "darwin" if sys.platform == "darwin" else "linux"
51
+ for candidate in CJK_FONT_CANDIDATES[platform_key]:
52
+ path = Path(candidate)
53
+ if path.is_file():
54
+ return path
55
+ return None
56
+
57
+
58
+ def register_cjk_font(path: Path, name: str) -> str:
59
+ """Register a TTF/OTF/TTC file with reportlab and return the resource name."""
60
+ from reportlab.pdfbase import pdfmetrics
61
+ from reportlab.pdfbase.ttfonts import TTFont
62
+
63
+ kwargs = {}
64
+ if path.suffix.lower() in {".ttc", ".otc", ".dfont"}:
65
+ kwargs["subfontIndex"] = 0
66
+ pdfmetrics.registerFont(TTFont(name, str(path), **kwargs))
67
+ return name
@@ -28,6 +28,8 @@ from pypdf import PdfReader, PdfWriter
28
28
  from reportlab.lib.colors import HexColor
29
29
  from reportlab.pdfgen import canvas
30
30
 
31
+ from pdf_fonts import find_cjk_font, has_non_latin, register_cjk_font
32
+
31
33
  try:
32
34
  import pymupdf
33
35
  except ImportError: # pragma: no cover - PyMuPDF is a required dependency
@@ -90,7 +92,8 @@ def main():
90
92
  default="bottom-center",
91
93
  help="Anchor of the stamp (default: bottom-center)",
92
94
  )
93
- parser.add_argument("--font", default="Helvetica", help="reportlab base-14 font name (default: Helvetica)")
95
+ parser.add_argument("--font", default="Helvetica", help="reportlab font name (default: Helvetica)")
96
+ parser.add_argument("--font-file", help="TTF/OTF/TTC file used for the stamp; required for CJK unless a system font is found")
94
97
  parser.add_argument("--font-size", type=float, default=9.0, help="Font size in points (default: 9)")
95
98
  parser.add_argument("--color", default="#666666", help="Hex text colour (default: #666666)")
96
99
  parser.add_argument("--margin", type=float, default=28.0, help="Page-edge margin in points (default: 28)")
@@ -127,6 +130,22 @@ def main():
127
130
  page_count = len(reader.pages)
128
131
  selected = parse_pages(args.pages, page_count)
129
132
 
133
+ sample = args.text.format(page=1, pages=page_count)
134
+ font_name = args.font
135
+ font_file = None
136
+ if args.font_file or has_non_latin(sample):
137
+ try:
138
+ path = find_cjk_font(args.font_file)
139
+ except FileNotFoundError as exc:
140
+ fail(str(exc), code=2)
141
+ if path is None:
142
+ fail("stamp text needs a font beyond the base-14 Latin sets; pass --font-file with a CJK-capable TTF/TTC", code=2)
143
+ try:
144
+ font_name = register_cjk_font(path, "StampCJK")
145
+ except Exception as exc:
146
+ fail(f"cannot use font file {path}: {exc}", code=2)
147
+ font_file = str(path)
148
+
130
149
  writer = PdfWriter()
131
150
  stamped = []
132
151
  for index, page in enumerate(reader.pages, start=1):
@@ -138,9 +157,9 @@ def main():
138
157
  buffer = io.BytesIO()
139
158
  stamp = canvas.Canvas(buffer, pagesize=(width, height))
140
159
  stamp.setFillColor(colour)
141
- stamp.setFont(args.font, args.font_size)
160
+ stamp.setFont(font_name, args.font_size)
142
161
  stamp.setFillAlpha(args.opacity)
143
- text_width = stamp.stringWidth(label, args.font, args.font_size)
162
+ text_width = stamp.stringWidth(label, font_name, args.font_size)
144
163
  anchor = POSITIONS[args.position]
145
164
  if anchor == "left":
146
165
  x = args.margin
@@ -185,7 +204,8 @@ def main():
185
204
  "pages": page_count,
186
205
  "stamped_pages": stamped,
187
206
  "position": args.position,
188
- "font": args.font,
207
+ "font": font_name,
208
+ "font_file": font_file,
189
209
  "font_size": args.font_size,
190
210
  "color": args.color,
191
211
  "rendered_pages": rendered,
@@ -55,6 +55,15 @@ Radio groups fail most: pypdf updates the group `/V` but leaves the old button's
55
55
 
56
56
  Flatten when the file must stay uneditable: fill with `flatten=True` (the value is painted into page content), remove every widget annotation (`subtypes="/Widget"`), and drop `/AcroForm` from the catalog. Then confirm no `/Widget` and no `/AcroForm` remain; otherwise readers still treat the file as editable.
57
57
 
58
+ ## Non-Latin values
59
+
60
+ pypdf's appearance writer encodes WinAnsi, so a Chinese, Japanese, or Korean value lands in `/V` but the widget paints `?` or falls back to Helvetica. The script detects non-Latin values, registers a system CJK font (YaHei, WenQuanYi, PingFang, Noto) or the `--font-file` you pass, draws the value with reportlab, and replaces `/AP /N` with that form; the field names it repainted are listed as `cjk_appearance_fields`. `--flatten` paints those streams into the page content before stripping the widgets. Verify `/V` plus the render: the subset font has no ToUnicode map, so a text dump of the appearance is not reliable evidence.
61
+
62
+ ```bash
63
+ "<python>" "<skill>/scripts/forms.py" fill --input "<form.pdf>" --out "<filled.pdf>" \
64
+ --values '{"full_name": "张伟"}' --font-file "C:/Windows/Fonts/msyh.ttc" --render-dir "<render>"
65
+ ```
66
+
58
67
  ## What cannot be filled, and the fix
59
68
 
60
69
  - **No appearance stream and a broken `/DA`**: the reader may not show a new value. Render after filling; if invisible, rebuild that widget's appearance with reportlab and merge it over the page.
@@ -52,7 +52,8 @@ Use each page's own `mediabox` when sizes may mix; a stamp drawn for A4 lands in
52
52
  - **Existing footers**: check the bottom margin first; the render is the proof, not the coordinate math. If a page number is already there, skip the stamp or use the opposite side.
53
53
  - **Rotated pages**: a `/Rotate 90` page renders sideways, so text drawn unrotated appears rotated. Rotate the stamp (`c.rotate(90)`, adjusting the origin), or rotate the page back first.
54
54
  - **Repeated stamping**: merging the same overlay twice doubles the opacity at the same position; re-run from the original input when iterating.
55
- - **Fonts**: Helvetica handles Latin; a CJK stamp needs a registered TTF, or the text renders as blank boxes. Check the render.
55
+ - **Fonts**: Helvetica handles Latin; for CJK the script registers a system CJK font (YaHei, WenQuanYi, PingFang, Noto) and reports it as `font_file`. Pass `--font-file` with a specific TTF/OTF/TTC when the host has none; without one it exits 2 instead of drawing blank boxes. Check the render.
56
+ - **Font files**: a `.ttc` collection needs its first face; the script handles that. Keep `--font` at its default when passing `--font-file` — the file overrides the base-14 name.
56
57
  - **Opacity**: `setFillAlpha` works only in viewers that honour transparency groups; a stamp that must show on any viewer is safer at full opacity.
57
58
 
58
59
  ## Verify
@@ -19,6 +19,7 @@ Exit codes: 0 ok, 2 unreadable input, 3 nothing rasterised.
19
19
  from __future__ import annotations
20
20
 
21
21
  import json
22
+ import math
22
23
  import sys
23
24
  from pathlib import Path
24
25
  from typing import Any
@@ -254,7 +255,9 @@ def _draw_chart(page, shape, rect: fitz.Rect, slide_no: int, notes: list[dict[st
254
255
  for index, value in enumerate(values):
255
256
  sweep = 360.0 * abs(value) / total
256
257
  shade = shades[index % len(shades)]
257
- page.draw_pie(center, radius, fitz.Point(radius * 1.0, 0), start, start + sweep, color=RULE, fill=(shade, shade, shade * 0.95 + 0.05), width=0.6)
258
+ # PyMuPDF 1.28 dropped Page.draw_pie; draw_sector has been stable since 1.16.
259
+ edge = fitz.Point(center.x + radius * math.cos(math.radians(start)), center.y + radius * math.sin(math.radians(start)))
260
+ page.draw_sector(center, edge, sweep, color=RULE, fill=(shade, shade, shade * 0.95 + 0.05), width=0.6)
258
261
  start += sweep
259
262
  return
260
263
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-office-kit",
3
3
  "type": "module",
4
- "version": "0.4.3",
4
+ "version": "0.4.5",
5
5
  "description": "Office documents, PDF, presentations, and spreadsheets for DeepSeek Harness",
6
6
  "author": "Hairy",
7
7
  "license": "MIT",
@@ -278,10 +278,130 @@ def export_pdf(input_path: Path, out_dir: Path, dpi: int) -> dict[str, Any]:
278
278
  fail(f"the LibreOffice kit reported a PDF export for {input_path.name} but wrote none")
279
279
  return {
280
280
  "pdf": str(produced),
281
+ "backend": report.get("backend"),
281
282
  "missing_fonts": [str(name) for name in report.get("missingFonts") or []],
282
283
  }
283
284
 
284
285
 
286
+ def xlsx_render(input_path: Path, out_dir: Path, dpi: int) -> dict[str, Any]:
287
+ """Render a workbook through PDF, because the kit's direct render prints ranges only.
288
+
289
+ The direct xlsx render path rasterises each used range and drops charts, images
290
+ and print areas; converting first lays the sheet out the way the app prints it.
291
+ """
292
+ converted = export_pdf(input_path, out_dir, dpi)
293
+ pages = rasterize_pdf(converted["pdf"], out_dir, dpi)
294
+ missing = converted["missing_fonts"]
295
+ warnings = glyph_warnings(missing)
296
+ report_warnings(warnings)
297
+ return {
298
+ "ok": True,
299
+ "kind": "xlsx",
300
+ "command": "render",
301
+ "input": str(input_path),
302
+ "renderer": "libreoffice-kit",
303
+ "engine": converted.get("backend"),
304
+ "page_count": len(pages),
305
+ "pages": pages,
306
+ "images": [
307
+ {"page": index, "sheet": None, "range": None, "path": path}
308
+ for index, path in enumerate(pages, start=1)
309
+ ],
310
+ "pdf": converted["pdf"],
311
+ "missing_fonts": missing,
312
+ "glyph_fallback": bool(missing),
313
+ "warnings": warnings,
314
+ }
315
+
316
+
317
+ def docx_used_fonts(path: Path) -> set[str]:
318
+ """Font names the document actually applies.
319
+
320
+ The kit reports every font in the package, including latent styles inherited
321
+ from python-docx's template (e.g. Courier in the unused MacroText style), so
322
+ a document with no Courier text still warns about it. Keep only fonts that
323
+ appear on a run, in a referenced style, in the defaults, or in the theme.
324
+ """
325
+ import zipfile
326
+
327
+ from lxml import etree
328
+
329
+ w = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
330
+ fonts: set[str] = set()
331
+ styles: dict[str, tuple[set[str], str | None]] = {}
332
+ referenced: set[str] = set()
333
+ theme_fonts: set[str] = set()
334
+
335
+ def rfonts(element) -> set[str]:
336
+ found = set()
337
+ for node in element.iter(f"{w}rFonts"):
338
+ for attribute in ("ascii", "hAnsi", "eastAsia", "cs"):
339
+ value = node.get(f"{w}{attribute}")
340
+ if value:
341
+ found.add(value)
342
+ return found
343
+
344
+ with zipfile.ZipFile(path) as archive:
345
+ names = set(archive.namelist())
346
+
347
+ def load(name: str):
348
+ return etree.fromstring(archive.read(name))
349
+
350
+ if "word/styles.xml" in names:
351
+ root = load("word/styles.xml")
352
+ defaults = root.find(f"{w}docDefaults")
353
+ if defaults is not None:
354
+ fonts.update(rfonts(defaults))
355
+ for style in root.iter(f"{w}style"):
356
+ style_id = style.get(f"{w}styleId")
357
+ based_on = style.find(f"{w}basedOn")
358
+ styles[style_id] = (rfonts(style), based_on.get(f"{w}val") if based_on is not None else None)
359
+
360
+ for name in names:
361
+ if not name.startswith("word/") or not name.endswith(".xml"):
362
+ continue
363
+ if name.startswith(("word/styles", "word/theme/")):
364
+ continue
365
+ root = load(name)
366
+ fonts.update(rfonts(root))
367
+ for tag in ("pStyle", "rStyle", "tblStyle"):
368
+ for element in root.iter(f"{w}{tag}"):
369
+ value = element.get(f"{w}val")
370
+ if value:
371
+ referenced.add(value)
372
+
373
+ if "word/theme/theme1.xml" in names:
374
+ theme = load("word/theme/theme1.xml")
375
+ for tag in ("majorFont", "minorFont"):
376
+ for element in theme.iter(tag):
377
+ latin = element.find("latin")
378
+ if latin is not None and latin.get("typeface"):
379
+ theme_fonts.add(latin.get("typeface"))
380
+
381
+ pending = list(referenced)
382
+ seen = set()
383
+ while pending:
384
+ style_id = pending.pop()
385
+ if style_id in seen:
386
+ continue
387
+ seen.add(style_id)
388
+ entry = styles.get(style_id)
389
+ if entry is None:
390
+ continue
391
+ style_fonts, based_on = entry
392
+ fonts.update(style_fonts)
393
+ if based_on:
394
+ pending.append(based_on)
395
+ return fonts | theme_fonts
396
+
397
+
398
+ def filter_missing_fonts(input_path: Path, kind: str, missing: list[str]) -> list[str]:
399
+ if kind != "docx" or not missing:
400
+ return missing
401
+ used = {name.lower() for name in docx_used_fonts(input_path)}
402
+ return [name for name in missing if name.lower() in used]
403
+
404
+
285
405
  def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf: bool = False) -> dict[str, Any]:
286
406
  """Rasterise an Office file page by page through the kit.
287
407
 
@@ -314,7 +434,7 @@ def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf
314
434
  shutil.rmtree(staging, ignore_errors=True)
315
435
  if not images:
316
436
  fail(f"the LibreOffice kit rendered no pages for {input_path.name}")
317
- missing = [str(name) for name in report.get("missingFonts") or []]
437
+ missing = filter_missing_fonts(input_path, kind, [str(name) for name in report.get("missingFonts") or []])
318
438
  warnings = glyph_warnings(missing)
319
439
  report_warnings(warnings)
320
440
  result: dict[str, Any] = {
@@ -333,10 +453,11 @@ def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf
333
453
  if emit_pdf:
334
454
  converted = export_pdf(input_path, out_dir, dpi)
335
455
  result["pdf"] = converted["pdf"]
336
- if not missing and converted["missing_fonts"]:
337
- result["missing_fonts"] = converted["missing_fonts"]
456
+ converted_missing = filter_missing_fonts(input_path, kind, converted["missing_fonts"])
457
+ if not missing and converted_missing:
458
+ result["missing_fonts"] = converted_missing
338
459
  result["glyph_fallback"] = True
339
- result["warnings"] = glyph_warnings(converted["missing_fonts"])
460
+ result["warnings"] = glyph_warnings(converted_missing)
340
461
  report_warnings(result["warnings"])
341
462
  return result
342
463
 
@@ -769,8 +890,8 @@ def _pdf_flowables(blocks: Iterable[dict[str, Any]], font: str, counters: dict[s
769
890
 
770
891
  styles = getSampleStyleSheet()
771
892
  body = ParagraphStyle("OfficeBody", parent=styles["BodyText"], fontName=font, fontSize=10.5, leading=15)
772
- heading1 = ParagraphStyle("OfficeH1", parent=styles["Heading1"], fontName=font, fontSize=18, leading=24)
773
- heading2 = ParagraphStyle("OfficeH2", parent=styles["Heading2"], fontName=font, fontSize=14, leading=20)
893
+ heading1 = ParagraphStyle("OfficeH1", parent=styles["Heading1"], fontName=font, fontSize=18, leading=24, spaceBefore=16, spaceAfter=8)
894
+ heading2 = ParagraphStyle("OfficeH2", parent=styles["Heading2"], fontName=font, fontSize=14, leading=20, spaceBefore=12, spaceAfter=6)
774
895
  title = ParagraphStyle("OfficeTitle", parent=styles["Title"], fontName=font, fontSize=22, leading=28)
775
896
  flowables = []
776
897
  for block in blocks:
@@ -788,8 +909,8 @@ def _pdf_flowables(blocks: Iterable[dict[str, Any]], font: str, counters: dict[s
788
909
  flowables.append(Paragraph(escape(str(block.get("text", ""))), body))
789
910
  counters["paragraphs"] += 1
790
911
  elif block_type in {"bullets", "numbered"}:
791
- for item in block.get("items", []):
792
- prefix = "• " if block_type == "bullets" else "1. "
912
+ for index, item in enumerate(block.get("items", []), start=1):
913
+ prefix = "• " if block_type == "bullets" else f"{index}. "
793
914
  flowables.append(Paragraph(prefix + escape(str(item)), body))
794
915
  counters["list_items"] += 1
795
916
  elif block_type == "table":
@@ -1427,6 +1548,8 @@ def main() -> None:
1427
1548
  out_dir = Path(args.out_dir)
1428
1549
  if args.format == "pdf":
1429
1550
  result = pdf_render(input_path, out_dir, args.dpi)
1551
+ elif args.format == "xlsx":
1552
+ result = xlsx_render(input_path, out_dir, args.dpi)
1430
1553
  else:
1431
1554
  result = render_office(input_path, args.format, out_dir, args.dpi, emit_pdf=bool(getattr(args, "pdf", False)))
1432
1555
  elif args.command == "recalc":