dsh-office-kit 0.1.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/LICENSE.md +21 -21
- package/README.md +108 -106
- 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 +146 -149
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +171 -246
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Generate small documents that exercise the awkward parts of the format.
|
|
3
|
+
|
|
4
|
+
Reviewing comments, tracked changes or watermarks is impossible without files
|
|
5
|
+
that contain them, and hand-building those by clicking in Word is not repeatable.
|
|
6
|
+
Each fixture targets one feature so a script's report can be judged against a
|
|
7
|
+
known-clean or known-broken input.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 success, 2 bad input.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from lxml import etree
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
22
|
+
import docx_kit as kit
|
|
23
|
+
|
|
24
|
+
from docx import Document
|
|
25
|
+
from docx.shared import Inches
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def base_document(title: str, paragraphs: list[str]) -> Document:
|
|
29
|
+
document = Document()
|
|
30
|
+
document.add_heading(title, level=1)
|
|
31
|
+
for text in paragraphs:
|
|
32
|
+
document.add_paragraph(text)
|
|
33
|
+
return document
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def mark_paragraph(paragraph, before: str, deleted: str, inserted: str, after: str, author: str, date: str) -> None:
|
|
37
|
+
rpr = kit.first_rpr(paragraph)
|
|
38
|
+
kit.clear_paragraph_content(paragraph)
|
|
39
|
+
paragraph.append(kit.make_run(before, etree.fromstring(etree.tostring(rpr)) if rpr is not None else None))
|
|
40
|
+
deletion = etree.SubElement(paragraph, kit.q("del"))
|
|
41
|
+
deletion.set(kit.q("id"), "101")
|
|
42
|
+
deletion.set(kit.q("author"), author)
|
|
43
|
+
deletion.set(kit.q("date"), date)
|
|
44
|
+
deletion.append(kit.make_run(deleted, tag="delText"))
|
|
45
|
+
insertion = etree.SubElement(paragraph, kit.q("ins"))
|
|
46
|
+
insertion.set(kit.q("id"), "102")
|
|
47
|
+
insertion.set(kit.q("author"), author)
|
|
48
|
+
insertion.set(kit.q("date"), date)
|
|
49
|
+
insertion.append(kit.make_run(inserted))
|
|
50
|
+
paragraph.append(kit.make_run(after))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def make_tracked(target: Path) -> dict:
|
|
54
|
+
document = base_document("Revisions demo", ["Regional revenue grew by 12% in the quarter.", "Hardware sales stayed flat."])
|
|
55
|
+
document.save(target)
|
|
56
|
+
doc = kit.Docx(target)
|
|
57
|
+
paragraphs = doc.paragraphs()
|
|
58
|
+
mark_paragraph(paragraphs[1], "Regional revenue grew by ", "12%", "14%", " in the quarter.", "Reviewer", "2024-05-01T09:00:00Z")
|
|
59
|
+
mark_paragraph(paragraphs[2], "Hardware sales ", "stayed", "remained", " flat.", "Reviewer", "2024-05-01T09:01:00Z")
|
|
60
|
+
settings = doc.xml("word/settings.xml")
|
|
61
|
+
if settings.find(kit.q("trackRevisions")) is None:
|
|
62
|
+
settings.append(settings.makeelement(kit.q("trackRevisions"), {}))
|
|
63
|
+
doc.save(target)
|
|
64
|
+
return {"path": str(target), "tracked_changes": 4}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def make_watermark(target: Path) -> dict:
|
|
68
|
+
document = base_document("Watermark demo", ["This copy is for internal review."])
|
|
69
|
+
document.save(target)
|
|
70
|
+
doc = kit.Docx(target)
|
|
71
|
+
part = doc.ensure_header_footer("header")
|
|
72
|
+
doc.xml(part).append(kit.watermark_paragraph("DRAFT", doc.next_id()))
|
|
73
|
+
doc.save(target)
|
|
74
|
+
return {"path": str(target), "watermark": "DRAFT"}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def make_comments(target: Path) -> dict:
|
|
78
|
+
document = base_document("Comments demo", ["The first paragraph carries a question.", "The second paragraph is clean."])
|
|
79
|
+
document.save(target)
|
|
80
|
+
doc = kit.Docx(target)
|
|
81
|
+
root = etree.Element(kit.q("comments"), nsmap={"w": kit.W, "r": kit.R})
|
|
82
|
+
doc.set_xml("word/comments.xml", root)
|
|
83
|
+
doc.ensure_override("word/comments.xml", kit.CT_BY_REL[kit.REL_COMMENTS])
|
|
84
|
+
doc.ensure_rel("word/document.xml", kit.REL_COMMENTS, "comments.xml")
|
|
85
|
+
comment = etree.SubElement(root, kit.q("comment"))
|
|
86
|
+
comment.set(kit.q("id"), "1")
|
|
87
|
+
comment.set(kit.q("author"), "Reviewer")
|
|
88
|
+
comment.set(kit.q("initials"), "RV")
|
|
89
|
+
comment.set(kit.q("date"), "2024-05-02T10:00:00Z")
|
|
90
|
+
body = etree.SubElement(comment, kit.q("p"))
|
|
91
|
+
etree.SubElement(etree.SubElement(body, kit.q("r")), kit.q("t")).text = "Is this number current?"
|
|
92
|
+
|
|
93
|
+
paragraph = doc.paragraphs()[1]
|
|
94
|
+
start = etree.Element(kit.q("commentRangeStart"))
|
|
95
|
+
start.set(kit.q("id"), "1")
|
|
96
|
+
paragraph.insert(1, start)
|
|
97
|
+
end = etree.SubElement(paragraph, kit.q("commentRangeEnd"))
|
|
98
|
+
end.set(kit.q("id"), "1")
|
|
99
|
+
run = etree.SubElement(paragraph, kit.q("r"))
|
|
100
|
+
etree.SubElement(run, kit.q("commentReference")).set(kit.q("id"), "1")
|
|
101
|
+
doc.save(target)
|
|
102
|
+
return {"path": str(target), "comments": 1}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def make_edge_cases(target: Path, image_dir: Path) -> dict:
|
|
106
|
+
image_path = image_dir / "sample.png"
|
|
107
|
+
try:
|
|
108
|
+
from PIL import Image
|
|
109
|
+
|
|
110
|
+
Image.new("RGB", (320, 200), "#4472c4").save(image_path)
|
|
111
|
+
except ImportError:
|
|
112
|
+
image_path = None
|
|
113
|
+
|
|
114
|
+
document = Document()
|
|
115
|
+
document.add_heading("Edge cases", level=1)
|
|
116
|
+
document.add_paragraph("")
|
|
117
|
+
document.add_heading("A level three heading after a level one", level=3)
|
|
118
|
+
document.add_paragraph("A very long paragraph " * 40)
|
|
119
|
+
document.add_paragraph("Contact: writer@example.org or +1 555 010 9999.")
|
|
120
|
+
table = document.add_table(rows=2, cols=10)
|
|
121
|
+
for row_index in range(2):
|
|
122
|
+
for column_index in range(10):
|
|
123
|
+
table.cell(row_index, column_index).text = "R%dC%d" % (row_index + 1, column_index + 1)
|
|
124
|
+
if image_path is not None:
|
|
125
|
+
document.add_picture(str(image_path), width=Inches(4))
|
|
126
|
+
document.save(target)
|
|
127
|
+
return {"path": str(target), "fixtures": ["empty_paragraph", "skipped_heading_level", "wide_table", "image_without_alt", "contact_details"]}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
BUILDERS = {
|
|
131
|
+
"tracked": make_tracked,
|
|
132
|
+
"watermark": make_watermark,
|
|
133
|
+
"comments": make_comments,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main(argv: list[str] | None = None) -> int:
|
|
138
|
+
parser = argparse.ArgumentParser(description="Create sample .docx files for tracked changes, watermarks, comments and edge cases.")
|
|
139
|
+
parser.add_argument("--outdir", required=True, help="Directory for the generated files")
|
|
140
|
+
parser.add_argument("--only", default="all", help="tracked, watermark, comments, edge, or all")
|
|
141
|
+
args = parser.parse_args(argv)
|
|
142
|
+
|
|
143
|
+
out_dir = Path(args.outdir).expanduser().resolve()
|
|
144
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
wanted = [name.strip() for name in args.only.split(",") if name.strip()]
|
|
146
|
+
if "all" in wanted:
|
|
147
|
+
wanted = ["tracked", "watermark", "comments", "edge"]
|
|
148
|
+
unknown = [name for name in wanted if name not in BUILDERS and name != "edge"]
|
|
149
|
+
if unknown:
|
|
150
|
+
print("error: unknown fixture %s" % ", ".join(unknown), file=sys.stderr)
|
|
151
|
+
return 2
|
|
152
|
+
|
|
153
|
+
results = []
|
|
154
|
+
try:
|
|
155
|
+
for name in wanted:
|
|
156
|
+
if name == "edge":
|
|
157
|
+
results.append(make_edge_cases(out_dir / "edge_cases.docx", out_dir))
|
|
158
|
+
else:
|
|
159
|
+
results.append(BUILDERS[name](out_dir / ("%s.docx" % name)))
|
|
160
|
+
except (OSError, KeyError) as exc:
|
|
161
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
162
|
+
return 2
|
|
163
|
+
|
|
164
|
+
kit.emit({"ok": True, "outdir": str(out_dir), "fixtures": results})
|
|
165
|
+
return 0
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
if __name__ == "__main__":
|
|
169
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Append one document's body to another, carrying its parts across.
|
|
3
|
+
|
|
4
|
+
Merging by pasting XML is the easy half; the hard half is that the second file's
|
|
5
|
+
relationships point at its own media, numbering and styles. This script copies
|
|
6
|
+
those parts into the base under free names, remaps relationship ids and
|
|
7
|
+
numbering ids, and brings over any style the appended content needs.
|
|
8
|
+
|
|
9
|
+
Drawings are refused by default: a nested drawing can carry its own rel chain and
|
|
10
|
+
silently break a merge, so it has to be asked for explicitly.
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 success, 1 refused (drawings present, incompatible input), 2 bad input.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import posixpath
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from lxml import etree
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
25
|
+
import docx_kit as kit
|
|
26
|
+
|
|
27
|
+
CT = "{http://schemas.openxmlformats.org/package/2006/content-types}"
|
|
28
|
+
W_NS = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
|
29
|
+
REL_NUMBERING = kit.REL_NUMBERING
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def free_part_name(base: kit.Docx, source: str) -> str:
|
|
33
|
+
folder = posixpath.dirname(source) or "word/media"
|
|
34
|
+
stem, extension = posixpath.splitext(posixpath.basename(source))
|
|
35
|
+
candidate = posixpath.join(folder, stem + extension)
|
|
36
|
+
counter = 1
|
|
37
|
+
while base.exists(candidate):
|
|
38
|
+
candidate = posixpath.join(folder, "%s_copy%d%s" % (stem, counter, extension))
|
|
39
|
+
counter += 1
|
|
40
|
+
return candidate
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def copy_part(base: kit.Docx, append: kit.Docx, source: str) -> str:
|
|
44
|
+
target = free_part_name(base, source)
|
|
45
|
+
base.parts[target] = append.parts[source]
|
|
46
|
+
base.order.append(target)
|
|
47
|
+
base._cache.pop(target, None)
|
|
48
|
+
for node in append.content_types().findall(CT + "Override"):
|
|
49
|
+
if node.get("PartName") == "/" + source:
|
|
50
|
+
base.ensure_override(target, node.get("ContentType"))
|
|
51
|
+
break
|
|
52
|
+
return target
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def copy_defaults(base: kit.Docx, append: kit.Docx) -> None:
|
|
56
|
+
existing = {(node.get("Extension") or "").lower() for node in base.content_types().findall(CT + "Default")}
|
|
57
|
+
for node in append.content_types().findall(CT + "Default"):
|
|
58
|
+
extension = (node.get("Extension") or "").lower()
|
|
59
|
+
if extension and extension not in existing:
|
|
60
|
+
clone = etree.SubElement(base.content_types(), CT + "Default")
|
|
61
|
+
clone.set("Extension", node.get("Extension"))
|
|
62
|
+
clone.set("ContentType", node.get("ContentType"))
|
|
63
|
+
existing.add(extension)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_style(base: kit.Docx, append: kit.Docx, wanted: str) -> bool:
|
|
67
|
+
if not append.exists("word/styles.xml") or not wanted:
|
|
68
|
+
return False
|
|
69
|
+
base_styles = base.xml("word/styles.xml")
|
|
70
|
+
present = {node.get(kit.q("styleId")) for node in base_styles.findall(kit.q("style"))}
|
|
71
|
+
if wanted in present:
|
|
72
|
+
return False
|
|
73
|
+
source_styles = {node.get(kit.q("styleId")): node for node in append.xml("word/styles.xml").findall(kit.q("style"))}
|
|
74
|
+
pending = [wanted]
|
|
75
|
+
copied = False
|
|
76
|
+
while pending:
|
|
77
|
+
style_id = pending.pop()
|
|
78
|
+
node = source_styles.get(style_id)
|
|
79
|
+
if node is None or style_id in present:
|
|
80
|
+
continue
|
|
81
|
+
clone = etree.fromstring(etree.tostring(node))
|
|
82
|
+
base_styles.append(clone)
|
|
83
|
+
present.add(style_id)
|
|
84
|
+
copied = True
|
|
85
|
+
for link_tag in ("basedOn", "next", "link"):
|
|
86
|
+
ref = node.find(kit.q(link_tag))
|
|
87
|
+
if ref is not None and ref.get(kit.q("val")):
|
|
88
|
+
pending.append(ref.get(kit.q("val")))
|
|
89
|
+
return copied
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def remap_numbering(base: kit.Docx, append: kit.Docx, nodes: list) -> dict[str, str]:
|
|
93
|
+
used = set()
|
|
94
|
+
for node in nodes:
|
|
95
|
+
for num_pr in node.iter(kit.q("numPr")):
|
|
96
|
+
num_id = num_pr.find(kit.q("numId"))
|
|
97
|
+
if num_id is not None and num_id.get(kit.q("val")):
|
|
98
|
+
used.add(num_id.get(kit.q("val")))
|
|
99
|
+
if not used or not append.exists("word/numbering.xml"):
|
|
100
|
+
return {}
|
|
101
|
+
if not base.exists("word/numbering.xml"):
|
|
102
|
+
base.parts["word/numbering.xml"] = append.parts["word/numbering.xml"]
|
|
103
|
+
base.order.append("word/numbering.xml")
|
|
104
|
+
base.ensure_override("word/numbering.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml")
|
|
105
|
+
base.ensure_rel("word/document.xml", kit.REL_NUMBERING, "numbering.xml")
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
base_root = base.xml("word/numbering.xml")
|
|
109
|
+
source_root = append.xml("word/numbering.xml")
|
|
110
|
+
base_nums = {node.get(kit.q("numId")): node for node in base_root.findall(kit.q("num"))}
|
|
111
|
+
base_abstract = {node.get(kit.q("abstractNumId")): node for node in base_root.findall(kit.q("abstractNum"))}
|
|
112
|
+
next_num = max([int(value) for value in base_nums if (value or "").isdigit()], default=0) + 1
|
|
113
|
+
next_abstract = max([int(value) for value in base_abstract if (value or "").isdigit()], default=0) + 1
|
|
114
|
+
source_nums = {node.get(kit.q("numId")): node for node in source_root.findall(kit.q("num"))}
|
|
115
|
+
source_abstract = {node.get(kit.q("abstractNumId")): node for node in source_root.findall(kit.q("abstractNum"))}
|
|
116
|
+
mapping = {}
|
|
117
|
+
for old in used:
|
|
118
|
+
node = source_nums.get(old)
|
|
119
|
+
if node is None:
|
|
120
|
+
continue
|
|
121
|
+
clone = etree.fromstring(etree.tostring(node))
|
|
122
|
+
reference = clone.find(kit.q("abstractNumId"))
|
|
123
|
+
abstract_old = reference.get(kit.q("val")) if reference is not None else None
|
|
124
|
+
if abstract_old in source_abstract and abstract_old not in base_abstract:
|
|
125
|
+
abstract_clone = etree.fromstring(etree.tostring(source_abstract[abstract_old]))
|
|
126
|
+
abstract_clone.set(kit.q("abstractNumId"), str(next_abstract))
|
|
127
|
+
base_root.insert(0, abstract_clone)
|
|
128
|
+
base_abstract[str(next_abstract)] = abstract_clone
|
|
129
|
+
next_abstract += 1
|
|
130
|
+
if reference is not None:
|
|
131
|
+
reference.set(kit.q("val"), abstract_clone.get(kit.q("abstractNumId")))
|
|
132
|
+
clone.set(kit.q("numId"), str(next_num))
|
|
133
|
+
mapping[old] = str(next_num)
|
|
134
|
+
base_root.append(clone)
|
|
135
|
+
base_nums[str(next_num)] = clone
|
|
136
|
+
next_num += 1
|
|
137
|
+
return mapping
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def remap_relationships(base: kit.Docx, append: kit.Docx, nodes: list) -> int:
|
|
141
|
+
mapping = {}
|
|
142
|
+
copied = 0
|
|
143
|
+
source_rels = {entry["id"]: entry for entry in append.relationships("word/document.xml")}
|
|
144
|
+
attributes = (kit.q("embed", kit.R), kit.q("id", kit.R), kit.q("link", kit.R))
|
|
145
|
+
for node in nodes:
|
|
146
|
+
for element in node.iter():
|
|
147
|
+
for attribute in attributes:
|
|
148
|
+
rid = element.get(attribute)
|
|
149
|
+
if not rid:
|
|
150
|
+
continue
|
|
151
|
+
if rid not in mapping:
|
|
152
|
+
rel = source_rels.get(rid)
|
|
153
|
+
if rel is None:
|
|
154
|
+
mapping[rid] = rid
|
|
155
|
+
continue
|
|
156
|
+
if rel["mode"] == "External":
|
|
157
|
+
mapping[rid] = base.ensure_rel("word/document.xml", rel["type"], rel["target"], external=True)
|
|
158
|
+
elif append.exists(rel["resolved"]):
|
|
159
|
+
target = copy_part(base, append, rel["resolved"])
|
|
160
|
+
relative = posixpath.relpath(target, "word")
|
|
161
|
+
mapping[rid] = base.ensure_rel("word/document.xml", rel["type"], relative)
|
|
162
|
+
copied += 1
|
|
163
|
+
else:
|
|
164
|
+
mapping[rid] = rid
|
|
165
|
+
element.set(attribute, mapping[rid])
|
|
166
|
+
return copied
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv: list[str] | None = None) -> int:
|
|
170
|
+
parser = argparse.ArgumentParser(description="Append one .docx body to another, copying media, numbering and styles.")
|
|
171
|
+
parser.add_argument("base", help="Document to append to")
|
|
172
|
+
parser.add_argument("append", help="Document whose body is copied in")
|
|
173
|
+
parser.add_argument("--out", required=True, help="Output .docx")
|
|
174
|
+
parser.add_argument("--allow_drawings", action="store_true", help="Copy drawings and their relationships")
|
|
175
|
+
args = parser.parse_args(argv)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
base = kit.Docx(args.base)
|
|
179
|
+
append = kit.Docx(args.append)
|
|
180
|
+
except (OSError, KeyError) as exc:
|
|
181
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
182
|
+
return 2
|
|
183
|
+
|
|
184
|
+
append_body = append.body()
|
|
185
|
+
incoming = [child for child in append_body if child.tag != kit.q("sectPr")]
|
|
186
|
+
if not incoming:
|
|
187
|
+
print("error: %s has an empty body" % args.append, file=sys.stderr)
|
|
188
|
+
return 2
|
|
189
|
+
|
|
190
|
+
has_drawings = any(child.find(".//" + tag) is not None for child in incoming for tag in (kit.q("drawing"), kit.q("pict")))
|
|
191
|
+
if has_drawings and not args.allow_drawings:
|
|
192
|
+
print("error: appended body contains drawings; re-run with --allow_drawings", file=sys.stderr)
|
|
193
|
+
return 1
|
|
194
|
+
|
|
195
|
+
style_ids = set()
|
|
196
|
+
numbering_nodes = []
|
|
197
|
+
for child in incoming:
|
|
198
|
+
for node in child.iter():
|
|
199
|
+
for tag in ("pStyle", "rStyle", "tblStyle"):
|
|
200
|
+
if node.tag == kit.q(tag) and node.get(kit.q("val")):
|
|
201
|
+
style_ids.add(node.get(kit.q("val")))
|
|
202
|
+
if child.find(".//" + kit.q("numPr")) is not None:
|
|
203
|
+
numbering_nodes.append(child)
|
|
204
|
+
|
|
205
|
+
styles_copied = sum(1 for style_id in sorted(style_ids) if ensure_style(base, append, style_id))
|
|
206
|
+
number_map = remap_numbering(base, append, numbering_nodes)
|
|
207
|
+
|
|
208
|
+
clones = [etree.fromstring(etree.tostring(child)) for child in incoming]
|
|
209
|
+
if number_map:
|
|
210
|
+
for clone in clones:
|
|
211
|
+
for num_pr in clone.iter(kit.q("numPr")):
|
|
212
|
+
num_id = num_pr.find(kit.q("numId"))
|
|
213
|
+
if num_id is not None and num_id.get(kit.q("val")) in number_map:
|
|
214
|
+
num_id.set(kit.q("val"), number_map[num_id.get(kit.q("val"))])
|
|
215
|
+
media_copied = remap_relationships(base, append, clones)
|
|
216
|
+
copy_defaults(base, append)
|
|
217
|
+
|
|
218
|
+
body = base.body()
|
|
219
|
+
sect = body.find(kit.q("sectPr"))
|
|
220
|
+
anchor = body.index(sect) if sect is not None else len(body)
|
|
221
|
+
for offset, clone in enumerate(clones):
|
|
222
|
+
body.insert(anchor + offset, clone)
|
|
223
|
+
|
|
224
|
+
base.save(args.out)
|
|
225
|
+
kit.emit(
|
|
226
|
+
{
|
|
227
|
+
"ok": True,
|
|
228
|
+
"output": str(Path(args.out)),
|
|
229
|
+
"appended_elements": len(clones),
|
|
230
|
+
"styles_copied": styles_copied,
|
|
231
|
+
"media_copied": media_copied,
|
|
232
|
+
"numbering_remapped": len(number_map),
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
return 0
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
if __name__ == "__main__":
|
|
239
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Remove metadata that identifies the author or the machine that edited a file.
|
|
3
|
+
|
|
4
|
+
Word leaves a trail: core properties, the company and manager fields, custom
|
|
5
|
+
properties, revision-save identifiers (``w:rsid*``) and spell-check markers. None
|
|
6
|
+
of it is visible in the document, all of it travels with the file.
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 success, 2 bad input.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from lxml import etree
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
20
|
+
import docx_kit as kit
|
|
21
|
+
|
|
22
|
+
DC = "http://purl.org/dc/elements/1.1/"
|
|
23
|
+
CP = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
|
24
|
+
DCTERMS = "http://purl.org/dc/terms/"
|
|
25
|
+
EP = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
|
|
26
|
+
REL_CUSTOM = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"
|
|
27
|
+
|
|
28
|
+
CORE_CLEAR = ["{%s}creator" % DC, "{%s}lastModifiedBy" % CP, "{%s}keywords" % CP, "{%s}description" % DC, "{%s}subject" % DC, "{%s}title" % DC, "{%s}category" % CP]
|
|
29
|
+
APP_CLEAR = ["{%s}Company" % EP, "{%s}Manager" % EP, "{%s}Application" % EP]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def clear_core(doc: kit.Docx) -> list[str]:
|
|
33
|
+
cleared = []
|
|
34
|
+
if not doc.exists("docProps/core.xml"):
|
|
35
|
+
return cleared
|
|
36
|
+
root = doc.xml("docProps/core.xml")
|
|
37
|
+
for tag in CORE_CLEAR:
|
|
38
|
+
for node in root.findall(tag):
|
|
39
|
+
node.text = ""
|
|
40
|
+
cleared.append(tag.rsplit("}", 1)[-1])
|
|
41
|
+
for node in root.findall("{%s}revision" % CP):
|
|
42
|
+
node.text = "1"
|
|
43
|
+
return cleared
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def clear_app(doc: kit.Docx) -> list[str]:
|
|
47
|
+
cleared = []
|
|
48
|
+
if not doc.exists("docProps/app.xml"):
|
|
49
|
+
return cleared
|
|
50
|
+
root = doc.xml("docProps/app.xml")
|
|
51
|
+
for tag in APP_CLEAR:
|
|
52
|
+
for node in root.findall(tag):
|
|
53
|
+
node.text = ""
|
|
54
|
+
cleared.append(tag.rsplit("}", 1)[-1])
|
|
55
|
+
return cleared
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def drop_custom(doc: kit.Docx) -> bool:
|
|
59
|
+
dropped = False
|
|
60
|
+
if doc.exists("docProps/custom.xml"):
|
|
61
|
+
doc.drop("docProps/custom.xml")
|
|
62
|
+
doc.drop_override("docProps/custom.xml")
|
|
63
|
+
dropped = True
|
|
64
|
+
ns = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
|
65
|
+
for name in [part for part in doc.parts if part.endswith(".rels")]:
|
|
66
|
+
root = doc.xml(name)
|
|
67
|
+
changed = False
|
|
68
|
+
for node in list(root.findall(ns + "Relationship")):
|
|
69
|
+
if node.get("Type") == REL_CUSTOM:
|
|
70
|
+
root.remove(node)
|
|
71
|
+
changed = True
|
|
72
|
+
dropped = True
|
|
73
|
+
if changed:
|
|
74
|
+
doc.set_xml(name, root)
|
|
75
|
+
return dropped
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def scrub_document(doc: kit.Docx) -> dict:
|
|
79
|
+
stats = {"rsid_attributes": 0, "proof_error_markers": 0, "render_breaks": 0}
|
|
80
|
+
for name in [part for part in doc.parts if part.endswith(".xml") and (part.startswith("word/") or part == "word/document.xml")]:
|
|
81
|
+
root = doc.xml(name)
|
|
82
|
+
changed = False
|
|
83
|
+
for node in root.iter():
|
|
84
|
+
for key in list(node.attrib):
|
|
85
|
+
if key.startswith(kit.q("rsid")) or key.startswith("{%s}rsid" % kit.W):
|
|
86
|
+
del node.attrib[key]
|
|
87
|
+
stats["rsid_attributes"] += 1
|
|
88
|
+
changed = True
|
|
89
|
+
for tag, key in ((kit.q("proofErr"), "proof_error_markers"), (kit.q("lastRenderedPageBreak"), "render_breaks")):
|
|
90
|
+
for node in list(root.iter(tag)):
|
|
91
|
+
node.getparent().remove(node)
|
|
92
|
+
stats[key] += 1
|
|
93
|
+
changed = True
|
|
94
|
+
if changed:
|
|
95
|
+
doc.set_xml(name, root)
|
|
96
|
+
return stats
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv: list[str] | None = None) -> int:
|
|
100
|
+
parser = argparse.ArgumentParser(description="Strip author, company, custom properties and revision identifiers.")
|
|
101
|
+
parser.add_argument("input_docx", help="Input .docx")
|
|
102
|
+
parser.add_argument("--out", required=True, help="Output .docx")
|
|
103
|
+
parser.add_argument("--keep_title", action="store_true", help="Keep dc:title and dc:subject")
|
|
104
|
+
args = parser.parse_args(argv)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
doc = kit.Docx(args.input_docx)
|
|
108
|
+
except (OSError, KeyError) as exc:
|
|
109
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
110
|
+
return 2
|
|
111
|
+
|
|
112
|
+
cleared = clear_core(doc) + clear_app(doc)
|
|
113
|
+
if args.keep_title:
|
|
114
|
+
cleared = [name for name in cleared if name not in ("title", "subject")]
|
|
115
|
+
root = doc.xml("docProps/core.xml")
|
|
116
|
+
original = kit.Docx(args.input_docx).xml("docProps/core.xml")
|
|
117
|
+
for tag in ("{%s}title" % DC, "{%s}subject" % DC):
|
|
118
|
+
source = original.find(tag)
|
|
119
|
+
target = root.find(tag)
|
|
120
|
+
if source is not None and target is not None:
|
|
121
|
+
target.text = source.text
|
|
122
|
+
|
|
123
|
+
custom_dropped = drop_custom(doc)
|
|
124
|
+
stats = scrub_document(doc)
|
|
125
|
+
doc.save(args.out)
|
|
126
|
+
kit.emit({"ok": True, "output": str(Path(args.out)), "cleared": cleared, "custom_properties_dropped": custom_dropped, **stats})
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Mask emails, phone numbers and custom patterns in a document's text.
|
|
3
|
+
|
|
4
|
+
The replacement happens inside individual text runs, so a match split across
|
|
5
|
+
formatted runs is not found; that trade keeps the surrounding formatting (bold,
|
|
6
|
+
italics, links) untouched. Length-preserving masking keeps a redacted table
|
|
7
|
+
aligned, which is usually why the number of characters matters.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 success, 2 bad input.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
20
|
+
import docx_kit as kit
|
|
21
|
+
|
|
22
|
+
EMAIL = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
|
|
23
|
+
PHONE = re.compile(r"(?<!\d)(?:\+\d{1,3}[\s.\-]?)?(?:\(\d{2,4}\)[\s.\-]?)?\d{2,4}[\s.\-]\d{3,4}(?:[\s.\-]\d{3,4})?(?!\d)")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def redact_text(text: str, patterns: list[re.Pattern], mask: str, preserve: bool) -> tuple[str, int]:
|
|
27
|
+
total = 0
|
|
28
|
+
for pattern in patterns:
|
|
29
|
+
def replace(match: re.Match) -> str:
|
|
30
|
+
nonlocal total
|
|
31
|
+
total += 1
|
|
32
|
+
return (mask * len(match.group(0))) if preserve else mask
|
|
33
|
+
|
|
34
|
+
text = pattern.sub(replace, text)
|
|
35
|
+
return text, total
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def collect_parts(doc: kit.Docx, include_comments: bool) -> list[str]:
|
|
39
|
+
parts = ["word/document.xml"] + doc.names("word/header") + doc.names("word/footer")
|
|
40
|
+
for note in ("word/footnotes.xml", "word/endnotes.xml"):
|
|
41
|
+
if doc.exists(note):
|
|
42
|
+
parts.append(note)
|
|
43
|
+
if include_comments and doc.exists("word/comments.xml"):
|
|
44
|
+
parts.append("word/comments.xml")
|
|
45
|
+
return parts
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: list[str] | None = None) -> int:
|
|
49
|
+
parser = argparse.ArgumentParser(description="Mask emails, phone numbers or custom regex matches in text runs.")
|
|
50
|
+
parser.add_argument("input_docx", help="Input .docx")
|
|
51
|
+
parser.add_argument("output_docx", help="Output .docx")
|
|
52
|
+
parser.add_argument("--emails", action="store_true", help="Redact email addresses (default when no pattern is given)")
|
|
53
|
+
parser.add_argument("--phones", action="store_true", help="Redact phone numbers (default when no pattern is given)")
|
|
54
|
+
parser.add_argument("--pattern", action="append", default=[], help="Extra regex to redact (repeatable)")
|
|
55
|
+
parser.add_argument("--mask_char", default="*", help="Masking character")
|
|
56
|
+
parser.add_argument("--replacement", default="[redacted]", help="Replacement text when length is not preserved")
|
|
57
|
+
parser.add_argument("--no_preserve_length", action="store_true", help="Use --replacement instead of a per-character mask")
|
|
58
|
+
parser.add_argument("--include_comments", action="store_true", help="Also redact comment text")
|
|
59
|
+
args = parser.parse_args(argv)
|
|
60
|
+
|
|
61
|
+
patterns: list[re.Pattern] = []
|
|
62
|
+
if args.emails or not (args.emails or args.phones or args.pattern):
|
|
63
|
+
patterns.append(EMAIL)
|
|
64
|
+
if args.phones or not (args.emails or args.phones or args.pattern):
|
|
65
|
+
patterns.append(PHONE)
|
|
66
|
+
for raw in args.pattern:
|
|
67
|
+
try:
|
|
68
|
+
patterns.append(re.compile(raw))
|
|
69
|
+
except re.error as exc:
|
|
70
|
+
print("error: bad --pattern %r: %s" % (raw, exc), file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
doc = kit.Docx(args.input_docx)
|
|
75
|
+
except (OSError, KeyError) as exc:
|
|
76
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
77
|
+
return 2
|
|
78
|
+
|
|
79
|
+
mask = args.replacement if args.no_preserve_length else (args.mask_char or "*")[:1]
|
|
80
|
+
preserve = not args.no_preserve_length
|
|
81
|
+
matches = 0
|
|
82
|
+
touched_parts = []
|
|
83
|
+
for part in collect_parts(doc, args.include_comments):
|
|
84
|
+
if not doc.exists(part):
|
|
85
|
+
continue
|
|
86
|
+
root = doc.xml(part)
|
|
87
|
+
part_matches = 0
|
|
88
|
+
for node in root.iter(kit.q("t")):
|
|
89
|
+
if not node.text:
|
|
90
|
+
continue
|
|
91
|
+
updated, count = redact_text(node.text, patterns, mask, preserve)
|
|
92
|
+
if count:
|
|
93
|
+
node.text = updated
|
|
94
|
+
part_matches += count
|
|
95
|
+
for node in root.iter(kit.q("delText")):
|
|
96
|
+
if not node.text:
|
|
97
|
+
continue
|
|
98
|
+
updated, count = redact_text(node.text, patterns, mask, preserve)
|
|
99
|
+
if count:
|
|
100
|
+
node.text = updated
|
|
101
|
+
part_matches += count
|
|
102
|
+
if part_matches:
|
|
103
|
+
doc.set_xml(part, root)
|
|
104
|
+
matches += part_matches
|
|
105
|
+
touched_parts.append({"part": part, "matches": part_matches})
|
|
106
|
+
|
|
107
|
+
doc.save(args.output_docx)
|
|
108
|
+
kit.emit({"ok": True, "output": str(Path(args.output_docx)), "matches": matches, "patterns": len(patterns), "parts": touched_parts})
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
raise SystemExit(main())
|