dsh-office-kit 0.4.2 → 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.
Files changed (123) hide show
  1. package/assets/skills/excel-live-control/SKILL.md +119 -152
  2. package/assets/skills/excel-live-control/domain_guidance/financial_models.md +42 -58
  3. package/assets/skills/excel-live-control/domain_guidance/healthcare.md +36 -42
  4. package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +33 -34
  5. package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +34 -36
  6. package/assets/skills/excel-live-control/references/office-automation.md +127 -153
  7. package/assets/skills/excel-live-control/references/read_only_qna.md +40 -45
  8. package/assets/skills/excel-live-control/style_guidelines.md +56 -64
  9. package/assets/skills/excel-live-control/workflows/create_workflows.md +40 -47
  10. package/assets/skills/excel-live-control/workflows/edit_workflows.md +37 -45
  11. package/assets/skills/office-documents/SKILL.md +24 -60
  12. package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +5 -5
  13. package/assets/skills/office-documents/ooxml/comments.md +8 -8
  14. package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +10 -10
  15. package/assets/skills/office-documents/ooxml/rels_and_content_types.md +7 -7
  16. package/assets/skills/office-documents/ooxml/tracked_changes.md +12 -12
  17. package/assets/skills/office-documents/references/docx-structure.md +11 -13
  18. package/assets/skills/office-documents/references/template-elicitation.md +11 -13
  19. package/assets/skills/office-documents/references/writing-quality.md +20 -20
  20. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +4 -3
  21. package/assets/skills/office-documents/scripts/docx_kit.py +68 -27
  22. package/assets/skills/office-documents/scripts/heading_audit.py +3 -4
  23. package/assets/skills/office-documents/scripts/internal_nav.py +3 -1
  24. package/assets/skills/office-documents/scripts/merge_docx_append.py +104 -0
  25. package/assets/skills/office-documents/scripts/table_geometry.py +13 -3
  26. package/assets/skills/office-documents/scripts/watermark_add.py +6 -5
  27. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +6 -4
  28. package/assets/skills/office-documents/tasks/accessibility_a11y.md +16 -22
  29. package/assets/skills/office-documents/tasks/captions_crossrefs.md +23 -26
  30. package/assets/skills/office-documents/tasks/clean_tracked_changes.md +17 -22
  31. package/assets/skills/office-documents/tasks/comments_manage.md +9 -11
  32. package/assets/skills/office-documents/tasks/compare_diff.md +10 -16
  33. package/assets/skills/office-documents/tasks/create_edit.md +14 -16
  34. package/assets/skills/office-documents/tasks/equations.md +17 -28
  35. package/assets/skills/office-documents/tasks/fields_update.md +14 -16
  36. package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +19 -33
  37. package/assets/skills/office-documents/tasks/footnotes_endnotes.md +12 -14
  38. package/assets/skills/office-documents/tasks/forms_content_controls.md +16 -20
  39. package/assets/skills/office-documents/tasks/headings_numbering.md +17 -22
  40. package/assets/skills/office-documents/tasks/images_figures.md +15 -25
  41. package/assets/skills/office-documents/tasks/multi_doc_merge.md +13 -23
  42. package/assets/skills/office-documents/tasks/navigation_internal_links.md +18 -21
  43. package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +16 -22
  44. package/assets/skills/office-documents/tasks/protection_restrict_editing.md +19 -21
  45. package/assets/skills/office-documents/tasks/read_review.md +9 -20
  46. package/assets/skills/office-documents/tasks/redaction_anonymization.md +17 -18
  47. package/assets/skills/office-documents/tasks/sections_layout.md +16 -23
  48. package/assets/skills/office-documents/tasks/style_lint_normalize.md +18 -22
  49. package/assets/skills/office-documents/tasks/tables_spreadsheets.md +21 -31
  50. package/assets/skills/office-documents/tasks/templates_style_packs.md +17 -21
  51. package/assets/skills/office-documents/tasks/toc_workflow.md +10 -14
  52. package/assets/skills/office-documents/tasks/verify_render.md +11 -23
  53. package/assets/skills/office-documents/tasks/watermarks_background.md +10 -14
  54. package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +6 -6
  55. package/assets/skills/office-documents/troubleshooting/run_splitting.md +12 -12
  56. package/assets/skills/office-pdf/SKILL.md +36 -51
  57. package/assets/skills/office-pdf/references/form-fields.md +20 -20
  58. package/assets/skills/office-pdf/references/geometry.md +10 -10
  59. package/assets/skills/office-pdf/references/layout-quality.md +13 -13
  60. package/assets/skills/office-pdf/references/limits.md +12 -56
  61. package/assets/skills/office-pdf/tasks/annotations.md +5 -5
  62. package/assets/skills/office-pdf/tasks/create.md +6 -9
  63. package/assets/skills/office-pdf/tasks/edit-pages.md +14 -14
  64. package/assets/skills/office-pdf/tasks/forms.md +15 -15
  65. package/assets/skills/office-pdf/tasks/read.md +9 -9
  66. package/assets/skills/office-pdf/tasks/redact.md +18 -18
  67. package/assets/skills/office-pdf/tasks/stamp.md +10 -10
  68. package/assets/skills/office-presentations/SKILL.md +71 -91
  69. package/assets/skills/office-presentations/container_tools/preview_render.py +4 -1
  70. package/assets/skills/office-presentations/container_tools/render_slides.py +1 -1
  71. package/assets/skills/office-presentations/domain_guidance/finance.md +16 -16
  72. package/assets/skills/office-presentations/recipes/README.md +24 -35
  73. package/assets/skills/office-presentations/recipes/autolayout.md +12 -13
  74. package/assets/skills/office-presentations/recipes/connectors.md +11 -12
  75. package/assets/skills/office-presentations/recipes/images.md +11 -12
  76. package/assets/skills/office-presentations/recipes/master-and-layouts.md +18 -18
  77. package/assets/skills/office-presentations/recipes/native-charts.md +8 -8
  78. package/assets/skills/office-presentations/recipes/rich-text-runs.md +13 -13
  79. package/assets/skills/office-presentations/recipes/speaker-notes.md +7 -7
  80. package/assets/skills/office-presentations/recipes/tables.md +14 -14
  81. package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +9 -9
  82. package/assets/skills/office-presentations/recipes/theme-tokens.md +7 -8
  83. package/assets/skills/office-presentations/references/authoring-workflow.md +38 -43
  84. package/assets/skills/office-presentations/references/finalization.md +10 -12
  85. package/assets/skills/office-presentations/references/native-bullets.md +14 -18
  86. package/assets/skills/office-presentations/references/native-evidence.md +18 -32
  87. package/assets/skills/office-presentations/references/style-guidelines.md +27 -32
  88. package/assets/skills/office-presentations/references/templates.md +31 -60
  89. package/assets/skills/office-presentations/routing/google_slides.md +4 -4
  90. package/assets/skills/office-presentations/tasks/apply-annotations.md +17 -17
  91. package/assets/skills/office-presentations/tasks/charts-tables-notes.md +20 -23
  92. package/assets/skills/office-presentations/tasks/create-deck.md +19 -19
  93. package/assets/skills/office-presentations/tasks/edit-deck.md +14 -16
  94. package/assets/skills/office-presentations/tasks/inspect-and-verify.md +31 -34
  95. package/assets/skills/office-presentations/tasks/template-following.md +23 -28
  96. package/assets/skills/office-spreadsheets/SKILL.md +32 -39
  97. package/assets/skills/office-spreadsheets/recipes/charts.md +8 -26
  98. package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +9 -26
  99. package/assets/skills/office-spreadsheets/recipes/data-tables.md +7 -23
  100. package/assets/skills/office-spreadsheets/recipes/named-ranges.md +8 -21
  101. package/assets/skills/office-spreadsheets/recipes/number-formats.md +7 -23
  102. package/assets/skills/office-spreadsheets/recipes/sparklines.md +7 -20
  103. package/assets/skills/office-spreadsheets/references/charts.md +15 -34
  104. package/assets/skills/office-spreadsheets/references/domain/financial-models.md +10 -25
  105. package/assets/skills/office-spreadsheets/references/domain/healthcare.md +8 -19
  106. package/assets/skills/office-spreadsheets/references/domain/marketing.md +8 -22
  107. package/assets/skills/office-spreadsheets/references/domain/research.md +8 -19
  108. package/assets/skills/office-spreadsheets/references/formula-patterns.md +20 -50
  109. package/assets/skills/office-spreadsheets/references/style-guidelines.md +19 -44
  110. package/assets/skills/office-spreadsheets/references/workbook-structure.md +12 -35
  111. package/assets/skills/office-spreadsheets/routing/google-sheets.md +11 -26
  112. package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +10 -25
  113. package/assets/skills/office-spreadsheets/tasks/annotations.md +12 -30
  114. package/assets/skills/office-spreadsheets/tasks/create-workbook.md +5 -5
  115. package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +2 -2
  116. package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +5 -5
  117. package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +9 -9
  118. package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +10 -30
  119. package/assets/skills/shared/annotation-recipes.md +98 -120
  120. package/assets/skills/shared/common-workflow.md +70 -80
  121. package/dist/index.js +1 -1
  122. package/package.json +1 -1
  123. package/python/office_cli.py +38 -4
@@ -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,8 +1,6 @@
1
1
  # Accessibility
2
2
 
3
- **Goal.** A document a screen reader can navigate: real headings, described images, tables with a header row, no fake emphasis carrying meaning.
4
-
5
- The accessibility tree in Word comes from structure, not appearance. Bold text announces as bold; a `Heading 1` announces as a heading and appears in the navigation pane. Alt text lives in `wp:docPr @descr`.
3
+ Accessibility comes from structure, not appearance: bold announces as bold, `Heading 1` announces as a heading and appears in the navigation pane. Alt text lives in `wp:docPr @descr`.
6
4
 
7
5
  ## Audit
8
6
 
@@ -10,36 +8,32 @@ The accessibility tree in Word comes from structure, not appearance. Bold text a
10
8
  "<python>" "<skill>/scripts/a11y_audit.py" "<file.docx>" --out_json "<report.json>"
11
9
  ```
12
10
 
13
- Checks: images without a description or title, tables without a repeating header row, heading levels that jump, empty headings, links whose text is only "click here" / "here" / "link", and a missing core title.
14
-
15
- Exit code 1 means at least one finding; `--out_json` writes the same report for a larger pass.
11
+ Checks images without description or title, tables without a repeating header row, heading levels that jump, empty headings, link text that is only "click here", "here" or "link", and a missing core title. Exit 1 means at least one finding.
16
12
 
17
- ## Fix the mechanical parts
13
+ ## Mechanical fixes
18
14
 
19
15
  ```bash
20
- "<python>" "<skill>/scripts/a11y_audit.py" "<file.docx>" \
21
- --fix_image_alt from_filename --fix_table_headers first_row \
22
- --out "<file-a11y.docx>"
16
+ "<python>" "<skill>/scripts/a11y_audit.py" "<file.docx>" --fix_image_alt from_filename --fix_table_headers first_row --out "<file-a11y.docx"
23
17
  ```
24
18
 
25
- Both fixes are honest placeholders: the file name as alt text is better than an empty description, and a header row that repeats across pages helps a reader who lands mid-table. Neither substitutes for real text.
19
+ These are honest placeholders, not substitutes for real text.
26
20
 
27
- ## Fix the parts that need judgement
21
+ ## Judgement fixes
28
22
 
29
- - **Alt text.** Describe what the reader would lose without the picture: "Bar chart: revenue by region, EMEA 4.1M ahead of APAC 3.2M." Decorative rules and dividers get an empty description.
30
- - **Link text.** "Download the Q2 ledger" instead of "click here".
31
- - **Reading order.** Decorative text boxes and floating images insert themselves at awkward places in the reading order; convert to inline if the flow matters.
32
- - **Colour.** Meaning carried only by colour (red = bad) fails for a colour-blind reader; add a word.
33
- - **Tables for layout.** A layout table announces as a data table. Restructure into paragraphs if the content is not tabular.
23
+ - **Alt text** describes what the reader loses, e.g. "Bar chart: revenue by region, EMEA 4.1M ahead of APAC 3.2M". Decorative rules and dividers get an empty description.
24
+ - **Link text** names the destination: "Download the Q2 ledger", not "click here".
25
+ - **Reading order:** decorative text boxes and floating images land awkwardly; convert them to inline.
26
+ - **Colour alone** (red = bad) fails colour-blind readers; add a word.
27
+ - **Layout tables** announce as data tables; restructure them as paragraphs.
34
28
 
35
- Long documents also want a TOC (`tasks/toc_workflow.md`), which is the screen-reader equivalent of the navigation pane, and language tags so a screen reader pronounces correctly.
29
+ Longer documents also want a TOC (`tasks/toc_workflow.md`) and language tags.
36
30
 
37
31
  ## Verify
38
32
 
39
- Re-run the audit to zero findings, then render and read the page: heading hierarchy visible, alt text is not visible (it is metadata), table header row distinct. If a screen reader is available, open the document and navigate by heading — that is the true test.
33
+ Re-run the audit to zero findings and render. Heading hierarchy is visible; alt text is metadata and stays invisible; the header row must be distinct. With a screen reader available, navigate by heading.
40
34
 
41
35
  ## Pitfalls
42
36
 
43
- - Alt text on a decorative image is worse than none; an empty description plus `title` marks it decorative.
44
- - A table header row is `w:tblHeader` on the row, not a bold first row.
45
- - Images inside a table cell still need descriptions.
37
+ - Alt text on a decorative image is worse than none: use an empty description plus `title` to mark it decorative.
38
+ - A header row is `w:tblHeader` on the row, not a bold first row.
39
+ - Images inside table cells still need descriptions.
@@ -1,49 +1,46 @@
1
- # Captions and cross-references
1
+ # Captions and numbered cross-references
2
2
 
3
- **Goal.** Number figures and tables automatically and refer to them from the prose without typing a number that will drift.
4
-
5
- Two parts: a caption paragraph with a `SEQ` field (`SEQ Table \* ARABIC`), and a bookmark around it that the prose reaches with a `REF` field. Without the bookmark a `REF` has nothing to point at.
3
+ A caption is a paragraph with a `SEQ` field (`SEQ Table \* ARABIC`) plus a bookmark; the prose reaches that bookmark with a `REF` field. Without the bookmark a `REF` has no target.
6
4
 
7
5
  ## Add captions
8
6
 
9
7
  ```bash
10
- "<python>" "<skill>/scripts/captions_and_crossrefs.py" "<file.docx>" "<file-captioned.docx>" \
11
- --tables --figures --bookmarks --caption_text "Source: finance ledger"
8
+ "<python>" "<skill>/scripts/captions_and_crossrefs.py" "<file.docx>" "<file-captioned.docx>" --tables --figures --bookmarks --caption_text "Source: finance ledger"
12
9
  ```
13
10
 
14
- - `--tables` adds `Table N` under each table, `--figures` adds `Figure N` under each picture. With neither flag, both run.
15
- - Captions use the `Caption` style and sit inside the same visual block as the object, so a page break cannot orphan them.
16
- - `--bookmarks` names the captions `tbl1`, `tbl2`, `fig1`, … so cross-references have targets.
17
- - `--materialize` writes literal numbers instead of `SEQ` fields; use it for a frozen PDF export, not for a document that will change.
18
- - `--caption_text` appends a fixed note after the number.
11
+ - `--tables` adds `Table N` under each table; `--figures` adds `Figure N` under each picture.
12
+ - Neither flag runs both.
13
+ - `--bookmarks` names them `tbl1`, `tbl2`, `fig1`, … — the `tblN`/`figN` pattern `insert_ref_fields.py` expects.
14
+ - `--materialize` writes literal numbers: for a frozen PDF export, not for a document that will change.
15
+ - `--caption_text` appends a fixed note.
19
16
 
20
- The bookmarks the script creates (`tblN`, `figN`) are what `insert_ref_fields.py` expects by default.
17
+ Captions use the `Caption` style and stay in the same visual block as the object, so a page break cannot orphan them.
21
18
 
22
- ## Add cross-references
19
+ ## Cross-reference
23
20
 
24
- Write placeholders in your content, then convert them:
21
+ Write `[[REF:tbl1]]` placeholders in the prose, then:
25
22
 
26
23
  ```bash
27
- "<python>" "<skill>/scripts/insert_ref_fields.py" "<file.docx>" "<file-refs.docx>"
24
+ "<python>" "<skill>/scripts/insert_ref_fields.py" "<file.docx>" "<file-refs.docx"
28
25
  ```
29
26
 
30
- `[[REF:tbl1]]` becomes a `REF tbl1 \h` field, which renumbers when the table moves. `--pattern` accepts your own regular expression as long as it has a named group `bookmark`; `--prefix` prepends text such as `Table ` to each result.
27
+ That produces a `REF tbl1 \h` field, so the number follows the table. `--pattern` accepts your own regex with the named group `bookmark`; `--prefix` prepends text such as `Table `.
31
28
 
32
- If the reference should read "Table 2" rather than just "2", set the `REF` field's `\n` behaviour off and include the label in the prose — a field cannot know whether its target is a table or a figure.
29
+ If a reference should read "Table 2" rather than "2", set the REF field's `\n` behaviour off and put the label in the prose: a field cannot know whether it points at a table or a figure.
33
30
 
34
- ## Order of operations
31
+ ## Order
35
32
 
36
- 1. Add or restructure tables and figures.
37
- 2. Run `captions_and_crossrefs.py` (creates captions and bookmarks).
38
- 3. Run `insert_ref_fields.py` (turns placeholders into fields).
39
- 4. Re-render: caption positions and numbering are a layout decision.
33
+ 1. Add or restructure the tables and figures.
34
+ 2. Run `captions_and_crossrefs.py`.
35
+ 3. Run `insert_ref_fields.py`.
36
+ 4. Re-render.
40
37
 
41
38
  ## Verify
42
39
 
43
- `fields_report.py`: one `SEQ` per caption, one `REF` per reference, no `REF` left with an empty result. Then read the rendered page — a caption above a table instead of below, or a caption separated from its object by a page break, is the usual defect.
40
+ Run `fields_report.py`: one SEQ per caption, one REF per reference, and no REF with an empty result. Then render. The usual defects are a caption above its table, or a caption separated from it by a page break.
44
41
 
45
42
  ## Pitfalls
46
43
 
47
- - Renumbering happens on field update. Two captions claim `Table 2` until Word recalculates.
48
- - A caption added by hand with the number typed in will not renumber; use the script or a `SEQ` field.
49
- - Bookmarks must be unique; copying a caption copies its bookmark name and breaks the reference.
44
+ - Numbering changes on field update, so two captions can claim `Table 2` until Word recalculates.
45
+ - A hand-typed caption number never renumbers.
46
+ - Bookmarks must be unique: copying a caption copies its bookmark name and breaks the reference.
@@ -1,49 +1,44 @@
1
- # Tracked changes
1
+ # Proposed and tracked changes
2
2
 
3
- **Goal.** Propose edits as tracked changes so a reviewer can accept or reject them, or clean up a file that arrives full of markup.
3
+ A tracked edit is markup: an insertion wraps runs in `w:ins`, a deletion wraps `w:delText` in `w:del`, a move is `w:moveFrom`/`w:moveTo` paired by `w:name`, and a formatting change is `w:rPrChange`/`w:pPrChange`. `w:trackRevisions` in `word/settings.xml` makes Word record new edits.
4
4
 
5
- A tracked edit is markup, not text: an insertion is `w:ins` wrapping runs, a deletion is `w:del` wrapping `w:delText`, and formatting changes are `w:rPrChange` / `w:pPrChange`. The setting `w:trackRevisions` in `word/settings.xml` makes Word record new edits the same way.
6
-
7
- ## Propose an edit
5
+ ## Propose
8
6
 
9
7
  ```bash
10
- "<python>" "<skill>/scripts/add_tracked_replacements.py" "<file.docx>" --out "<file-tracked.docx>" \
11
- --author "Review" --replace "18%=21%" --replace "flat=steady"
8
+ "<python>" "<skill>/scripts/add_tracked_replacements.py" "<file.docx>" --out "<file-tracked.docx>" --author "Review" --replace "18%=21%" --replace "flat=steady"
12
9
  ```
13
10
 
14
- Each `--replace` is `OLD=NEW`; the script splits runs at the match so only the changed characters are marked, keeps the surrounding formatting, and turns on `trackRevisions`. Quote the `--author` the reviewer should see.
11
+ Each `--replace` is `OLD=NEW`. The script splits runs at the match so only the changed characters are marked, keeps the surrounding formatting, and turns on `trackRevisions`. Quote the `--author` the reviewer should see.
15
12
 
16
- Limit: one replacement per pattern per paragraph, and a match split across runs is still found because the split is computed on paragraph text. Reword the pattern if the paragraph repeats the phrase.
13
+ Limit: one replacement per pattern per paragraph. A match split across runs is still found because the split is computed on paragraph text; reword if a paragraph repeats the phrase.
17
14
 
18
- ## Inspect what is proposed
15
+ ## Inspect
19
16
 
20
17
  ```bash
21
18
  "<python>" "<skill>/scripts/accept_tracked_changes.py" "<file.docx>" --mode report
22
19
  "<python>" "<skill>/scripts/docx_kit.py" xml "<file.docx>" word/document.xml
23
20
  ```
24
21
 
25
- The report counts insertions, deletions, move pairs and format changes. Use `docx_kit.py xml` when you need to know exactly which words change.
22
+ The report counts insertions, deletions, move pairs and format changes.
26
23
 
27
24
  ## Accept or reject
28
25
 
29
26
  ```bash
30
- "<python>" "<skill>/scripts/accept_tracked_changes.py" "<file.docx>" --mode accept --out "<file-accepted.docx>"
31
- "<python>" "<skill>/scripts/accept_tracked_changes.py" "<file.docx>" --mode reject --out "<file-rejected.docx>"
27
+ "<python>" "<skill>/scripts/accept_tracked_changes.py" "<file.docx>" --mode accept --out "<file-accepted.docx"
28
+ "<python>" "<skill>/scripts/accept_tracked_changes.py" "<file.docx>" --mode reject --out "<file-rejected.docx"
32
29
  ```
33
30
 
34
- Accept keeps inserted text and drops deleted text; reject does the reverse and restores `w:delText` as ordinary text. Format-only changes are discarded either way. `--keep_tracking_on` leaves `trackRevisions` enabled, so the next person's edits are also recorded.
35
-
36
- ## Before delivery
31
+ Accept keeps inserted text and drops deleted text; reject reverses that and restores `w:delText` as ordinary text. Format-only changes are discarded either way. `--keep_tracking_on` leaves `trackRevisions` enabled.
37
32
 
38
- Rendered pages contain no markup, so a document can look finished while still carrying redlines. Always check `--mode report` on a file you are about to send, and accept or reject explicitly.
33
+ Before delivery: rendered pages contain no markup, so a file can look finished while carrying redlines. Always run `--mode report` on a file about to be sent, then accept or reject explicitly.
39
34
 
40
35
  ## Verify
41
36
 
42
- After accepting, `docx_kit.py text` should show the new wording and no `w:ins`/`w:del` should remain in `word/document.xml`. Render and read the affected page.
37
+ After accepting, `docx_kit.py text` shows the new wording and `word/document.xml` contains no `w:ins`/`w:del`. Render the affected page.
43
38
 
44
39
  ## Pitfalls
45
40
 
46
- - `w:del` contains `w:delText`, not `w:t`; a text extraction that only looks at `w:t` silently loses deletions.
47
- - Tables, headers and footnotes can each carry their own tracked changes — the script covers every text part, but a hand edit in one part can miss another.
48
- - Accepting a move pair (`w:moveFrom`/`w:moveTo`) as two unrelated edits duplicates content; the script treats them as a pair.
49
- - Comments are not tracked changes. See `comments_manage.md`.
41
+ - `w:del` contains `w:delText`, not `w:t`; extraction that reads only `w:t` loses deletions.
42
+ - Tables, headers and footnotes can each carry tracked changes. The script covers every text part; a hand edit can miss one.
43
+ - Accepting a move pair as two unrelated edits duplicates content. The script treats them as a pair.
44
+ - Comments are not tracked changes; see `comments_manage.md`.