dsh-office-kit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY.md +147 -68
- package/README.md +3 -1
- package/assets/skills/excel-live-control/SKILL.md +234 -0
- package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
- package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
- package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
- package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
- package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
- package/assets/skills/excel-live-control/references/office-automation.md +207 -0
- package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
- package/assets/skills/excel-live-control/style_guidelines.md +87 -0
- package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
- package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
- package/assets/skills/office-documents/SKILL.md +90 -77
- package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
- package/assets/skills/office-documents/ooxml/comments.md +40 -0
- package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
- package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
- package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
- package/assets/skills/office-documents/references/docx-structure.md +83 -0
- package/assets/skills/office-documents/references/template-elicitation.md +49 -0
- package/assets/skills/office-documents/references/writing-quality.md +49 -0
- package/assets/skills/office-documents/render_docx.py +92 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
- package/assets/skills/office-documents/scripts/comments_add.py +136 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
- package/assets/skills/office-documents/scripts/content_controls.py +192 -0
- package/assets/skills/office-documents/scripts/core_properties.py +124 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
- package/assets/skills/office-documents/scripts/fields_report.py +59 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
- package/assets/skills/office-documents/scripts/images_audit.py +80 -0
- package/assets/skills/office-documents/scripts/insert_note.py +126 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
- package/assets/skills/office-documents/scripts/replace_text.py +154 -0
- package/assets/skills/office-documents/scripts/section_audit.py +91 -0
- package/assets/skills/office-documents/scripts/set_protection.py +92 -0
- package/assets/skills/office-documents/scripts/style_lint.py +111 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
- package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
- package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
- package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
- package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
- package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
- package/assets/skills/office-documents/tasks/create_edit.md +107 -0
- package/assets/skills/office-documents/tasks/equations.md +59 -0
- package/assets/skills/office-documents/tasks/fields_update.md +44 -0
- package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
- package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
- package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
- package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
- package/assets/skills/office-documents/tasks/images_figures.md +63 -0
- package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
- package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
- package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
- package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
- package/assets/skills/office-documents/tasks/read_review.md +50 -0
- package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
- package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
- package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
- package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
- package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
- package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
- package/assets/skills/office-documents/tasks/verify_render.md +50 -0
- package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
- package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
- package/assets/skills/office-pdf/SKILL.md +73 -56
- package/assets/skills/office-pdf/references/form-fields.md +56 -0
- package/assets/skills/office-pdf/references/geometry.md +44 -0
- package/assets/skills/office-pdf/references/layout-quality.md +41 -0
- package/assets/skills/office-pdf/references/limits.md +58 -0
- package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
- package/assets/skills/office-pdf/scripts/forms.py +556 -0
- package/assets/skills/office-pdf/scripts/metadata.py +161 -0
- package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
- package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
- package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
- package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
- package/assets/skills/office-pdf/tasks/annotations.md +98 -0
- package/assets/skills/office-pdf/tasks/create.md +75 -0
- package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
- package/assets/skills/office-pdf/tasks/forms.md +67 -0
- package/assets/skills/office-pdf/tasks/read.md +62 -0
- package/assets/skills/office-pdf/tasks/redact.md +72 -0
- package/assets/skills/office-pdf/tasks/stamp.md +60 -0
- package/assets/skills/office-presentations/SKILL.md +141 -87
- package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
- package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
- package/assets/skills/office-presentations/recipes/README.md +71 -0
- package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
- package/assets/skills/office-presentations/recipes/connectors.md +83 -0
- package/assets/skills/office-presentations/recipes/images.md +80 -0
- package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
- package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
- package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
- package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
- package/assets/skills/office-presentations/recipes/tables.md +88 -0
- package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
- package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
- package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
- package/assets/skills/office-presentations/references/finalization.md +63 -0
- package/assets/skills/office-presentations/references/native-bullets.md +78 -0
- package/assets/skills/office-presentations/references/native-evidence.md +67 -0
- package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
- package/assets/skills/office-presentations/references/templates.md +76 -0
- package/assets/skills/office-presentations/routing/google_slides.md +45 -0
- package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
- package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
- package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
- package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
- package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
- package/assets/skills/office-presentations/tasks/template-following.md +132 -0
- package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
- package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
- package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
- package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
- package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
- package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
- package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
- package/assets/skills/office-spreadsheets/references/charts.md +63 -0
- package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
- package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
- package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
- package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
- package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
- package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
- package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
- package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
- package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
- package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
- package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
- package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
- package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
- package/assets/skills/shared/annotation-recipes.md +252 -0
- package/assets/skills/shared/check_runtime.py +253 -0
- package/assets/skills/shared/common-workflow.md +139 -0
- package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/index.js +239 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +171 -246
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Edit existing comments in place from a JSON patch file.
|
|
3
|
+
|
|
4
|
+
The patch is a JSON list (or an object with an ``ops`` list) of operations keyed
|
|
5
|
+
by comment id:
|
|
6
|
+
|
|
7
|
+
[{"id": 1, "append": "Also check 2024."},
|
|
8
|
+
{"id": 2, "replace": "Rewritten note."},
|
|
9
|
+
{"id": 3, "resolved": true}]
|
|
10
|
+
|
|
11
|
+
``append`` adds a paragraph, ``replace`` rewrites the body, and ``resolved``
|
|
12
|
+
marks the comment done (the threading metadata Word uses for "Resolve").
|
|
13
|
+
|
|
14
|
+
Exit codes: 0 all operations applied, 1 some ids were missing, 2 bad input.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from lxml import etree
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
27
|
+
import docx_kit as kit
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_body(comment, text: str) -> None:
|
|
31
|
+
paragraphs = comment.findall(kit.q("p"))
|
|
32
|
+
if not paragraphs:
|
|
33
|
+
paragraph = etree.SubElement(comment, kit.q("p"))
|
|
34
|
+
else:
|
|
35
|
+
paragraph = paragraphs[0]
|
|
36
|
+
for extra in paragraphs[1:]:
|
|
37
|
+
comment.remove(extra)
|
|
38
|
+
ppr = paragraph.find(kit.q("pPr"))
|
|
39
|
+
for child in list(paragraph):
|
|
40
|
+
if child is not ppr:
|
|
41
|
+
paragraph.remove(child)
|
|
42
|
+
run = etree.SubElement(paragraph, kit.q("r"))
|
|
43
|
+
node = etree.SubElement(run, kit.q("t"))
|
|
44
|
+
node.text = text
|
|
45
|
+
node.set(kit.q("space", kit.XML), "preserve")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def append_body(comment, text: str) -> None:
|
|
49
|
+
paragraph = etree.SubElement(comment, kit.q("p"))
|
|
50
|
+
run = etree.SubElement(paragraph, kit.q("r"))
|
|
51
|
+
node = etree.SubElement(run, kit.q("t"))
|
|
52
|
+
node.text = text
|
|
53
|
+
node.set(kit.q("space", kit.XML), "preserve")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
parser = argparse.ArgumentParser(description="Apply append/replace/resolve operations to existing Word comments.")
|
|
58
|
+
parser.add_argument("in_docx", help="Input .docx")
|
|
59
|
+
parser.add_argument("patch_json", help="JSON patch file")
|
|
60
|
+
parser.add_argument("--out", required=True, help="Output .docx")
|
|
61
|
+
args = parser.parse_args(argv)
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
patch = json.loads(Path(args.patch_json).read_text(encoding="utf-8"))
|
|
65
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
66
|
+
print("error: cannot read patch: %s" % exc, file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
ops = patch.get("ops") if isinstance(patch, dict) else patch
|
|
69
|
+
if not isinstance(ops, list):
|
|
70
|
+
print("error: patch must be a list or an object with an 'ops' list", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
doc = kit.Docx(args.in_docx)
|
|
75
|
+
except (OSError, KeyError) as exc:
|
|
76
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
77
|
+
return 2
|
|
78
|
+
if not doc.exists("word/comments.xml"):
|
|
79
|
+
print("error: document has no comments", file=sys.stderr)
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
by_id = {node.get(kit.q("id")): node for node in doc.xml("word/comments.xml").findall(kit.q("comment"))}
|
|
83
|
+
applied, missing = [], []
|
|
84
|
+
for op in ops:
|
|
85
|
+
if not isinstance(op, dict):
|
|
86
|
+
print("error: each operation must be an object", file=sys.stderr)
|
|
87
|
+
return 2
|
|
88
|
+
ident = str(op.get("id"))
|
|
89
|
+
comment = by_id.get(ident)
|
|
90
|
+
if comment is None:
|
|
91
|
+
missing.append(ident)
|
|
92
|
+
continue
|
|
93
|
+
changed = []
|
|
94
|
+
if "replace" in op:
|
|
95
|
+
set_body(comment, str(op["replace"]))
|
|
96
|
+
changed.append("replace")
|
|
97
|
+
if "append" in op:
|
|
98
|
+
append_body(comment, str(op["append"]))
|
|
99
|
+
changed.append("append")
|
|
100
|
+
if "resolved" in op:
|
|
101
|
+
if op["resolved"]:
|
|
102
|
+
comment.set(kit.q("done", kit.W15), "1")
|
|
103
|
+
else:
|
|
104
|
+
comment.attrib.pop(kit.q("done", kit.W15), None)
|
|
105
|
+
changed.append("resolved")
|
|
106
|
+
applied.append({"id": ident, "changes": changed})
|
|
107
|
+
|
|
108
|
+
doc.save(args.out)
|
|
109
|
+
kit.emit({"ok": not missing, "output": str(Path(args.out)), "applied": applied, "missing_ids": missing})
|
|
110
|
+
return 1 if missing else 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Extract Word comments to JSON, including the text each comment is anchored to.
|
|
3
|
+
|
|
4
|
+
The anchor is recovered by walking the marked-up parts in document order and
|
|
5
|
+
collecting the text between ``commentRangeStart`` and ``commentRangeEnd`` for
|
|
6
|
+
the same id. Comments whose range was lost still appear, with an empty anchor.
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 success (an empty list when the file has no comments), 2 bad input.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
18
|
+
import docx_kit as kit
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def anchor_map(doc: kit.Docx) -> dict[str, dict]:
|
|
22
|
+
found: dict[str, dict] = {}
|
|
23
|
+
hf = [name for name in doc.names("word/header") + doc.names("word/footer") if name.endswith(".xml") and "_rels" not in name]
|
|
24
|
+
parts = ["word/document.xml"] + hf + [part for part in doc.text_parts() if part != "word/document.xml"]
|
|
25
|
+
for part in parts:
|
|
26
|
+
active = None
|
|
27
|
+
for node in doc.xml(part).iter():
|
|
28
|
+
if not isinstance(node.tag, str):
|
|
29
|
+
continue
|
|
30
|
+
local = node.tag.split("}", 1)[1] if node.tag.startswith("{%s}" % kit.W) else ""
|
|
31
|
+
if local == "commentRangeStart":
|
|
32
|
+
ident = node.get(kit.q("id")) or ""
|
|
33
|
+
active = ident
|
|
34
|
+
found.setdefault(ident, {"part": part, "anchored_text": ""})
|
|
35
|
+
elif local == "commentRangeEnd":
|
|
36
|
+
if active is not None and (node.get(kit.q("id")) or "") == active:
|
|
37
|
+
active = None
|
|
38
|
+
elif local == "commentReference" and active is None:
|
|
39
|
+
ident = node.get(kit.q("id")) or ""
|
|
40
|
+
found.setdefault(ident, {"part": part, "anchored_text": ""})
|
|
41
|
+
elif active is not None and local in ("t", "delText"):
|
|
42
|
+
found.setdefault(active, {"part": part, "anchored_text": ""})["anchored_text"] += node.text or ""
|
|
43
|
+
return found
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(argv: list[str] | None = None) -> int:
|
|
47
|
+
parser = argparse.ArgumentParser(description="Extract comments (author, date, body, anchored text) from a .docx.")
|
|
48
|
+
parser.add_argument("in_docx", help="Input .docx")
|
|
49
|
+
parser.add_argument("--out", required=True, help="Write the JSON report here")
|
|
50
|
+
args = parser.parse_args(argv)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
doc = kit.Docx(args.in_docx)
|
|
54
|
+
except (OSError, KeyError) as exc:
|
|
55
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
56
|
+
return 2
|
|
57
|
+
if not doc.exists("word/comments.xml"):
|
|
58
|
+
kit.emit({"ok": True, "input": str(Path(args.in_docx)), "count": 0, "comments": []}, args.out)
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
anchors = anchor_map(doc)
|
|
62
|
+
comments = []
|
|
63
|
+
for node in doc.xml("word/comments.xml").findall(kit.q("comment")):
|
|
64
|
+
ident = node.get(kit.q("id")) or ""
|
|
65
|
+
paragraphs = [kit.text_of(paragraph) for paragraph in node.iter(kit.q("p"))]
|
|
66
|
+
info = anchors.get(ident, {})
|
|
67
|
+
comments.append(
|
|
68
|
+
{
|
|
69
|
+
"id": int(ident) if ident.lstrip("-").isdigit() else ident,
|
|
70
|
+
"author": node.get(kit.q("author")) or "",
|
|
71
|
+
"initials": node.get(kit.q("initials")) or "",
|
|
72
|
+
"date": node.get(kit.q("date")) or "",
|
|
73
|
+
"text": "\n".join(text for text in paragraphs if text),
|
|
74
|
+
"anchored_text": info.get("anchored_text", "").strip(),
|
|
75
|
+
"part": info.get("part"),
|
|
76
|
+
"resolved": node.get(kit.q("done", kit.W15)) in ("1", "true"),
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
comments.sort(key=lambda item: item["id"] if isinstance(item["id"], int) else 0)
|
|
80
|
+
kit.emit({"ok": True, "input": str(Path(args.in_docx)), "count": len(comments), "comments": comments}, args.out)
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Remove every comment from a .docx: markers, bodies, relationships, part.
|
|
3
|
+
|
|
4
|
+
Word needs all four removed together. Leaving a stray ``commentReference``
|
|
5
|
+
behind makes Word report a corrupt package, and leaving comments.xml in the zip
|
|
6
|
+
re-adds metadata you thought you deleted, so each is handled here.
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 success (also when there was nothing to strip), 2 bad input.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
18
|
+
import docx_kit as kit
|
|
19
|
+
|
|
20
|
+
MARKERS = ("commentRangeStart", "commentRangeEnd", "commentReference")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: list[str] | None = None) -> int:
|
|
24
|
+
parser = argparse.ArgumentParser(description="Strip all comments and comment markers from a .docx.")
|
|
25
|
+
parser.add_argument("in_docx", help="Input .docx")
|
|
26
|
+
parser.add_argument("--out", required=True, help="Output .docx")
|
|
27
|
+
args = parser.parse_args(argv)
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
doc = kit.Docx(args.in_docx)
|
|
31
|
+
except (OSError, KeyError) as exc:
|
|
32
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
33
|
+
return 2
|
|
34
|
+
|
|
35
|
+
hf = [name for name in doc.names("word/header") + doc.names("word/footer") if name.endswith(".xml") and "_rels" not in name]
|
|
36
|
+
removed_markers = 0
|
|
37
|
+
for part in ["word/document.xml"] + hf + doc.text_parts():
|
|
38
|
+
if not doc.exists(part):
|
|
39
|
+
continue
|
|
40
|
+
root = doc.xml(part)
|
|
41
|
+
for local in MARKERS:
|
|
42
|
+
for node in list(root.iter(kit.q(local))):
|
|
43
|
+
parent = node.getparent()
|
|
44
|
+
if parent is None:
|
|
45
|
+
continue
|
|
46
|
+
if local == "commentReference" and parent.tag == kit.q("r") and len(parent) <= 1:
|
|
47
|
+
grand = parent.getparent()
|
|
48
|
+
if grand is not None:
|
|
49
|
+
grand.remove(parent)
|
|
50
|
+
else:
|
|
51
|
+
parent.remove(node)
|
|
52
|
+
removed_markers += 1
|
|
53
|
+
|
|
54
|
+
had_part = doc.exists("word/comments.xml")
|
|
55
|
+
if had_part:
|
|
56
|
+
doc.drop("word/comments.xml")
|
|
57
|
+
doc.drop_rel_target("word/document.xml", "word/comments.xml")
|
|
58
|
+
doc.drop_override("word/comments.xml")
|
|
59
|
+
|
|
60
|
+
doc.save(args.out)
|
|
61
|
+
kit.emit(
|
|
62
|
+
{
|
|
63
|
+
"ok": True,
|
|
64
|
+
"input": str(Path(args.in_docx)),
|
|
65
|
+
"output": str(Path(args.out)),
|
|
66
|
+
"comments_part_removed": had_part,
|
|
67
|
+
"markers_removed": removed_markers,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Read or edit Word content controls (structured document tags / SDT).
|
|
3
|
+
|
|
4
|
+
python-docx has no API for content controls, so this works directly on the
|
|
5
|
+
``w:sdt`` elements: list them, wrap a paragraph into a typed control, or replace
|
|
6
|
+
the text of controls matched by tag. Tags are the stable key — they survive
|
|
7
|
+
copying between documents better than the numeric ids.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 success, 1 a tag was not found, 2 bad input or bad subcommand.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from lxml import etree
|
|
19
|
+
|
|
20
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
21
|
+
import docx_kit as kit
|
|
22
|
+
|
|
23
|
+
TYPE_TAGS = ("text", "richText", "date", "dropDownList", "comboBox", "picture", "checkbox")
|
|
24
|
+
CONTAINER_TAGS = ("docPartObj", "group", "citation", "bibliography")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def part_list(doc: kit.Docx, include_headers: bool) -> list[str]:
|
|
28
|
+
parts = ["word/document.xml"]
|
|
29
|
+
if include_headers:
|
|
30
|
+
parts += [name for name in doc.names("word/header") + doc.names("word/footer") if name.endswith(".xml") and "_rels" not in name]
|
|
31
|
+
return [part for part in parts if doc.exists(part)]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def controls(doc: kit.Docx, part: str):
|
|
35
|
+
return [node for node in doc.xml(part).iter(kit.q("sdt")) if node.find(kit.q("sdtPr")) is not None]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def describe(doc: kit.Docx, part: str, sdt) -> dict:
|
|
39
|
+
props = sdt.find(kit.q("sdtPr"))
|
|
40
|
+
tag = props.find(kit.q("tag")) if props is not None else None
|
|
41
|
+
alias = props.find(kit.q("alias")) if props is not None else None
|
|
42
|
+
ident = props.find(kit.q("id")) if props is not None else None
|
|
43
|
+
kind = "unknown"
|
|
44
|
+
if props is not None:
|
|
45
|
+
for name in TYPE_TAGS:
|
|
46
|
+
if props.find(kit.q(name)) is not None:
|
|
47
|
+
kind = name
|
|
48
|
+
break
|
|
49
|
+
if kind == "unknown" and any(props.find(kit.q(name)) is not None for name in CONTAINER_TAGS):
|
|
50
|
+
kind = "container"
|
|
51
|
+
content = sdt.find(kit.q("sdtContent"))
|
|
52
|
+
text = kit.text_of(content) if content is not None else ""
|
|
53
|
+
return {
|
|
54
|
+
"part": part,
|
|
55
|
+
"tag": tag.get(kit.q("val")) if tag is not None else "",
|
|
56
|
+
"alias": alias.get(kit.q("val")) if alias is not None else "",
|
|
57
|
+
"id": ident.get(kit.q("val")) if ident is not None else "",
|
|
58
|
+
"type": kind,
|
|
59
|
+
"text": text,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_control(tag: str, alias: str, text: str, ident: int, content) -> etree._Element:
|
|
64
|
+
sdt = etree.Element(kit.q("sdt"))
|
|
65
|
+
props = etree.SubElement(sdt, kit.q("sdtPr"))
|
|
66
|
+
if tag:
|
|
67
|
+
etree.SubElement(props, kit.q("tag")).set(kit.q("val"), tag)
|
|
68
|
+
if alias:
|
|
69
|
+
etree.SubElement(props, kit.q("alias")).set(kit.q("val"), alias)
|
|
70
|
+
etree.SubElement(props, kit.q("id")).set(kit.q("val"), str(ident))
|
|
71
|
+
etree.SubElement(props, kit.q("text"))
|
|
72
|
+
body = etree.SubElement(sdt, kit.q("sdtContent"))
|
|
73
|
+
if content is not None:
|
|
74
|
+
body.append(content)
|
|
75
|
+
else:
|
|
76
|
+
paragraph = etree.SubElement(body, kit.q("p"))
|
|
77
|
+
run = etree.SubElement(paragraph, kit.q("r"))
|
|
78
|
+
node = etree.SubElement(run, kit.q("t"))
|
|
79
|
+
node.text = text
|
|
80
|
+
return sdt
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def replace_text(sdt, value: str) -> bool:
|
|
84
|
+
content = sdt.find(kit.q("sdtContent"))
|
|
85
|
+
if content is None:
|
|
86
|
+
return False
|
|
87
|
+
paragraphs = list(content.iter(kit.q("p")))
|
|
88
|
+
if not paragraphs:
|
|
89
|
+
return False
|
|
90
|
+
paragraph = paragraphs[0]
|
|
91
|
+
for extra in paragraphs[1:]:
|
|
92
|
+
parent = extra.getparent()
|
|
93
|
+
if parent is not None:
|
|
94
|
+
parent.remove(extra)
|
|
95
|
+
ppr = paragraph.find(kit.q("pPr"))
|
|
96
|
+
for child in list(paragraph):
|
|
97
|
+
if child is not ppr:
|
|
98
|
+
paragraph.remove(child)
|
|
99
|
+
run = etree.SubElement(paragraph, kit.q("r"))
|
|
100
|
+
node = etree.SubElement(run, kit.q("t"))
|
|
101
|
+
node.text = value
|
|
102
|
+
node.set(kit.q("space"), kit.XML)
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv: list[str] | None = None) -> int:
|
|
107
|
+
parser = argparse.ArgumentParser(description="List, create, or fill Word content controls (SDTs).")
|
|
108
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
109
|
+
|
|
110
|
+
listing = sub.add_parser("list", help="list content controls")
|
|
111
|
+
listing.add_argument("docx")
|
|
112
|
+
listing.add_argument("--json", action="store_true", help="emit JSON (always on; kept for scripts)")
|
|
113
|
+
listing.add_argument("--no_headers_footers", action="store_true")
|
|
114
|
+
|
|
115
|
+
create = sub.add_parser("create", help="wrap a paragraph or add a new control")
|
|
116
|
+
create.add_argument("docx")
|
|
117
|
+
create.add_argument("--tag", default="", help="Stable tag used by later edits")
|
|
118
|
+
create.add_argument("--alias", default="", help="Display name shown in Word")
|
|
119
|
+
create.add_argument("--text", default="", help="Initial value for a new control")
|
|
120
|
+
create.add_argument("--placeholder", default="", help="Wrap the paragraph containing this text instead of adding a new control")
|
|
121
|
+
create.add_argument("--output", required=True)
|
|
122
|
+
create.add_argument("--no_headers_footers", action="store_true")
|
|
123
|
+
|
|
124
|
+
update = sub.add_parser("set", help="fill controls by tag")
|
|
125
|
+
update.add_argument("docx")
|
|
126
|
+
update.add_argument("--set", action="append", default=[], help="TAG=VALUE (repeatable)")
|
|
127
|
+
update.add_argument("--output", required=True)
|
|
128
|
+
update.add_argument("--no_headers_footers", action="store_true")
|
|
129
|
+
|
|
130
|
+
args = parser.parse_args(argv)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
doc = kit.Docx(args.docx)
|
|
134
|
+
except (OSError, KeyError) as exc:
|
|
135
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
136
|
+
return 2
|
|
137
|
+
|
|
138
|
+
if args.command == "list":
|
|
139
|
+
rows = []
|
|
140
|
+
for part in part_list(doc, not args.no_headers_footers):
|
|
141
|
+
for sdt in controls(doc, part):
|
|
142
|
+
rows.append(describe(doc, part, sdt))
|
|
143
|
+
kit.emit({"ok": True, "input": str(Path(args.docx)), "count": len(rows), "controls": rows})
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
if args.command == "set":
|
|
147
|
+
if not args.set:
|
|
148
|
+
parser.error("at least one --set TAG=VALUE is required")
|
|
149
|
+
wanted = {}
|
|
150
|
+
for raw in args.set:
|
|
151
|
+
if "=" not in raw:
|
|
152
|
+
parser.error("--set expects TAG=VALUE, got %r" % raw)
|
|
153
|
+
tag, value = raw.split("=", 1)
|
|
154
|
+
wanted[tag] = value
|
|
155
|
+
filled, missing = [], []
|
|
156
|
+
for part in part_list(doc, not args.no_headers_footers):
|
|
157
|
+
for sdt in controls(doc, part):
|
|
158
|
+
info = describe(doc, part, sdt)
|
|
159
|
+
if info["tag"] in wanted and replace_text(sdt, wanted[info["tag"]]):
|
|
160
|
+
filled.append({"tag": info["tag"], "value": wanted[info["tag"]], "part": part})
|
|
161
|
+
wanted.pop(info["tag"], None)
|
|
162
|
+
missing = sorted(wanted)
|
|
163
|
+
doc.save(args.output)
|
|
164
|
+
kit.emit({"ok": not missing, "output": str(Path(args.output)), "filled": filled, "missing_tags": missing})
|
|
165
|
+
return 1 if missing else 0
|
|
166
|
+
|
|
167
|
+
ident = doc.next_id()
|
|
168
|
+
if args.placeholder:
|
|
169
|
+
target = None
|
|
170
|
+
for paragraph in doc.paragraphs():
|
|
171
|
+
if args.placeholder in kit.paragraph_text(paragraph):
|
|
172
|
+
target = paragraph
|
|
173
|
+
break
|
|
174
|
+
if target is None:
|
|
175
|
+
print("error: placeholder %r not found" % args.placeholder, file=sys.stderr)
|
|
176
|
+
return 1
|
|
177
|
+
parent = target.getparent()
|
|
178
|
+
index = list(parent).index(target)
|
|
179
|
+
sdt = build_control(args.tag, args.alias, args.text, ident, target)
|
|
180
|
+
parent.insert(index, sdt)
|
|
181
|
+
where = "wrapped"
|
|
182
|
+
else:
|
|
183
|
+
sdt = build_control(args.tag, args.alias, args.text, ident, None)
|
|
184
|
+
doc.body().append(sdt)
|
|
185
|
+
where = "appended"
|
|
186
|
+
doc.save(args.output)
|
|
187
|
+
kit.emit({"ok": True, "output": str(Path(args.output)), "mode": where, "tag": args.tag, "id": ident})
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read or set the core properties of a Word document.
|
|
3
|
+
|
|
4
|
+
Core properties are the metadata a reader sees in File > Info: title, author,
|
|
5
|
+
subject, keywords, comments, category and the last modifier. They live outside
|
|
6
|
+
the body in docProps/core.xml, so changing them never touches the content.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
core_properties.py --input IN.docx [--out OUT.docx] --title "..." --author "..."
|
|
10
|
+
core_properties.py --input IN.docx [--out OUT.docx] --spec core.json
|
|
11
|
+
core_properties.py --input IN.docx --show
|
|
12
|
+
|
|
13
|
+
The spec file is {"core": {"title": "...", "author": "..."}} or a bare object of
|
|
14
|
+
the same names. With no property arguments the command only reports.
|
|
15
|
+
|
|
16
|
+
Exit codes: 0 success, 1 the document or spec could not be read, 2 argparse
|
|
17
|
+
usage error.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
27
|
+
|
|
28
|
+
import docx_kit as kit # noqa: E402
|
|
29
|
+
|
|
30
|
+
TEXT_FIELDS = (
|
|
31
|
+
"title",
|
|
32
|
+
"author",
|
|
33
|
+
"subject",
|
|
34
|
+
"keywords",
|
|
35
|
+
"comments",
|
|
36
|
+
"category",
|
|
37
|
+
"last_modified_by",
|
|
38
|
+
"content_status",
|
|
39
|
+
"identifier",
|
|
40
|
+
"language",
|
|
41
|
+
"version",
|
|
42
|
+
)
|
|
43
|
+
INT_FIELDS = ("revision",)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def fail(payload: dict, code: int = 1) -> int:
|
|
47
|
+
kit.emit({"ok": False, **payload})
|
|
48
|
+
return code
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def snapshot(document) -> dict:
|
|
52
|
+
props = document.core_properties
|
|
53
|
+
data: dict = {}
|
|
54
|
+
for name in TEXT_FIELDS:
|
|
55
|
+
value = getattr(props, name, None)
|
|
56
|
+
data[name] = "" if value is None else str(value)
|
|
57
|
+
for name in INT_FIELDS:
|
|
58
|
+
data[name] = getattr(props, name, None)
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> int:
|
|
63
|
+
parser = argparse.ArgumentParser(description="Read or set .docx core properties.")
|
|
64
|
+
parser.add_argument("--input", required=True, help="source .docx (never modified)")
|
|
65
|
+
parser.add_argument("--out", help="output path; default <name>-edited.docx")
|
|
66
|
+
parser.add_argument("--spec", help="JSON file with a core properties object")
|
|
67
|
+
parser.add_argument("--show", action="store_true", help="report properties and change nothing")
|
|
68
|
+
for name in TEXT_FIELDS:
|
|
69
|
+
parser.add_argument("--" + name.replace("_", "-"), dest=name, help=f"set {name}")
|
|
70
|
+
parser.add_argument("--revision", type=int, help="set the revision counter")
|
|
71
|
+
args = parser.parse_args()
|
|
72
|
+
|
|
73
|
+
source = Path(args.input).expanduser()
|
|
74
|
+
if not source.is_file():
|
|
75
|
+
return fail({"error": f"input not found: {args.input}"})
|
|
76
|
+
|
|
77
|
+
updates: dict = {}
|
|
78
|
+
if args.spec:
|
|
79
|
+
try:
|
|
80
|
+
payload = json.loads(Path(args.spec).expanduser().read_text(encoding="utf-8"))
|
|
81
|
+
except (OSError, ValueError) as exc:
|
|
82
|
+
return fail({"error": f"cannot read spec: {exc}"}, 2)
|
|
83
|
+
updates.update(payload.get("core", payload) if isinstance(payload, dict) else {})
|
|
84
|
+
for name in TEXT_FIELDS:
|
|
85
|
+
value = getattr(args, name)
|
|
86
|
+
if value is not None:
|
|
87
|
+
updates[name] = value
|
|
88
|
+
if args.revision is not None:
|
|
89
|
+
updates["revision"] = args.revision
|
|
90
|
+
|
|
91
|
+
unknown = [key for key in updates if key not in TEXT_FIELDS and key not in INT_FIELDS]
|
|
92
|
+
if unknown:
|
|
93
|
+
return fail({"error": "unknown properties: " + ", ".join(sorted(unknown)), "known": list(TEXT_FIELDS)}, 2)
|
|
94
|
+
|
|
95
|
+
from docx import Document
|
|
96
|
+
|
|
97
|
+
document = Document(str(source))
|
|
98
|
+
before = snapshot(document)
|
|
99
|
+
if args.show or not updates:
|
|
100
|
+
kit.emit({"ok": True, "input": str(source), "properties": before})
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
for key, value in updates.items():
|
|
104
|
+
setattr(document.core_properties, key, value)
|
|
105
|
+
after = snapshot(document)
|
|
106
|
+
|
|
107
|
+
out = Path(args.out).expanduser() if args.out else source.with_name(f"{source.stem}-edited.docx")
|
|
108
|
+
if out.resolve() == source.resolve():
|
|
109
|
+
return fail({"error": "refusing to overwrite the input; pass a different --out"})
|
|
110
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
document.save(str(out))
|
|
112
|
+
kit.emit({
|
|
113
|
+
"ok": True,
|
|
114
|
+
"input": str(source),
|
|
115
|
+
"output": str(out),
|
|
116
|
+
"changed": sorted(updates),
|
|
117
|
+
"before": {key: before[key] for key in updates},
|
|
118
|
+
"after": {key: after[key] for key in updates},
|
|
119
|
+
})
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
raise SystemExit(main())
|