dsh-office-kit 0.4.3 → 0.4.4

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`).
@@ -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.4",
5
5
  "description": "Office documents, PDF, presentations, and spreadsheets for DeepSeek Harness",
6
6
  "author": "Hairy",
7
7
  "license": "MIT",
@@ -278,10 +278,42 @@ 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
+
285
317
  def render_office(input_path: Path, kind: str, out_dir: Path, dpi: int, emit_pdf: bool = False) -> dict[str, Any]:
286
318
  """Rasterise an Office file page by page through the kit.
287
319
 
@@ -769,8 +801,8 @@ def _pdf_flowables(blocks: Iterable[dict[str, Any]], font: str, counters: dict[s
769
801
 
770
802
  styles = getSampleStyleSheet()
771
803
  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)
804
+ heading1 = ParagraphStyle("OfficeH1", parent=styles["Heading1"], fontName=font, fontSize=18, leading=24, spaceBefore=16, spaceAfter=8)
805
+ heading2 = ParagraphStyle("OfficeH2", parent=styles["Heading2"], fontName=font, fontSize=14, leading=20, spaceBefore=12, spaceAfter=6)
774
806
  title = ParagraphStyle("OfficeTitle", parent=styles["Title"], fontName=font, fontSize=22, leading=28)
775
807
  flowables = []
776
808
  for block in blocks:
@@ -788,8 +820,8 @@ def _pdf_flowables(blocks: Iterable[dict[str, Any]], font: str, counters: dict[s
788
820
  flowables.append(Paragraph(escape(str(block.get("text", ""))), body))
789
821
  counters["paragraphs"] += 1
790
822
  elif block_type in {"bullets", "numbered"}:
791
- for item in block.get("items", []):
792
- prefix = "• " if block_type == "bullets" else "1. "
823
+ for index, item in enumerate(block.get("items", []), start=1):
824
+ prefix = "• " if block_type == "bullets" else f"{index}. "
793
825
  flowables.append(Paragraph(prefix + escape(str(item)), body))
794
826
  counters["list_items"] += 1
795
827
  elif block_type == "table":
@@ -1427,6 +1459,8 @@ def main() -> None:
1427
1459
  out_dir = Path(args.out_dir)
1428
1460
  if args.format == "pdf":
1429
1461
  result = pdf_render(input_path, out_dir, args.dpi)
1462
+ elif args.format == "xlsx":
1463
+ result = xlsx_render(input_path, out_dir, args.dpi)
1430
1464
  else:
1431
1465
  result = render_office(input_path, args.format, out_dir, args.dpi, emit_pdf=bool(getattr(args, "pdf", False)))
1432
1466
  elif args.command == "recalc":