dsh-office-kit 0.2.0 → 0.3.1
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 +99 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +201 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +158 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +155 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +115 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +111 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +109 -0
- package/assets/skills/office-documents/scripts/comments_add.py +143 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +121 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +92 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +81 -0
- package/assets/skills/office-documents/scripts/content_controls.py +199 -0
- package/assets/skills/office-documents/scripts/core_properties.py +131 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +594 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +232 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +106 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +73 -0
- package/assets/skills/office-documents/scripts/fields_report.py +66 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +69 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +84 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +103 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +88 -0
- package/assets/skills/office-documents/scripts/images_audit.py +87 -0
- package/assets/skills/office-documents/scripts/insert_note.py +133 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +97 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +91 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +153 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +176 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +246 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +138 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +120 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +108 -0
- package/assets/skills/office-documents/scripts/replace_text.py +161 -0
- package/assets/skills/office-documents/scripts/section_audit.py +98 -0
- package/assets/skills/office-documents/scripts/set_protection.py +99 -0
- package/assets/skills/office-documents/scripts/style_lint.py +118 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +110 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +174 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +55 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +74 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +124 -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 +143 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +127 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +133 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +292 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +164 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +151 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +206 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +219 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +352 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +382 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +146 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +175 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +236 -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 +183 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +161 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +118 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +246 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +192 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +243 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +114 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +129 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +313 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +405 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +287 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +142 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +141 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +140 -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 +260 -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 +246 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +213 -254
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
23
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
24
|
+
try:
|
|
25
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
except (AttributeError, ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
DC = "http://purl.org/dc/elements/1.1/"
|
|
30
|
+
CP = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
|
31
|
+
DCTERMS = "http://purl.org/dc/terms/"
|
|
32
|
+
EP = "http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"
|
|
33
|
+
REL_CUSTOM = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"
|
|
34
|
+
|
|
35
|
+
CORE_CLEAR = ["{%s}creator" % DC, "{%s}lastModifiedBy" % CP, "{%s}keywords" % CP, "{%s}description" % DC, "{%s}subject" % DC, "{%s}title" % DC, "{%s}category" % CP]
|
|
36
|
+
APP_CLEAR = ["{%s}Company" % EP, "{%s}Manager" % EP, "{%s}Application" % EP]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def clear_core(doc: kit.Docx) -> list[str]:
|
|
40
|
+
cleared = []
|
|
41
|
+
if not doc.exists("docProps/core.xml"):
|
|
42
|
+
return cleared
|
|
43
|
+
root = doc.xml("docProps/core.xml")
|
|
44
|
+
for tag in CORE_CLEAR:
|
|
45
|
+
for node in root.findall(tag):
|
|
46
|
+
node.text = ""
|
|
47
|
+
cleared.append(tag.rsplit("}", 1)[-1])
|
|
48
|
+
for node in root.findall("{%s}revision" % CP):
|
|
49
|
+
node.text = "1"
|
|
50
|
+
return cleared
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clear_app(doc: kit.Docx) -> list[str]:
|
|
54
|
+
cleared = []
|
|
55
|
+
if not doc.exists("docProps/app.xml"):
|
|
56
|
+
return cleared
|
|
57
|
+
root = doc.xml("docProps/app.xml")
|
|
58
|
+
for tag in APP_CLEAR:
|
|
59
|
+
for node in root.findall(tag):
|
|
60
|
+
node.text = ""
|
|
61
|
+
cleared.append(tag.rsplit("}", 1)[-1])
|
|
62
|
+
return cleared
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def drop_custom(doc: kit.Docx) -> bool:
|
|
66
|
+
dropped = False
|
|
67
|
+
if doc.exists("docProps/custom.xml"):
|
|
68
|
+
doc.drop("docProps/custom.xml")
|
|
69
|
+
doc.drop_override("docProps/custom.xml")
|
|
70
|
+
dropped = True
|
|
71
|
+
ns = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
|
72
|
+
for name in [part for part in doc.parts if part.endswith(".rels")]:
|
|
73
|
+
root = doc.xml(name)
|
|
74
|
+
changed = False
|
|
75
|
+
for node in list(root.findall(ns + "Relationship")):
|
|
76
|
+
if node.get("Type") == REL_CUSTOM:
|
|
77
|
+
root.remove(node)
|
|
78
|
+
changed = True
|
|
79
|
+
dropped = True
|
|
80
|
+
if changed:
|
|
81
|
+
doc.set_xml(name, root)
|
|
82
|
+
return dropped
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def scrub_document(doc: kit.Docx) -> dict:
|
|
86
|
+
stats = {"rsid_attributes": 0, "proof_error_markers": 0, "render_breaks": 0}
|
|
87
|
+
for name in [part for part in doc.parts if part.endswith(".xml") and (part.startswith("word/") or part == "word/document.xml")]:
|
|
88
|
+
root = doc.xml(name)
|
|
89
|
+
changed = False
|
|
90
|
+
for node in root.iter():
|
|
91
|
+
for key in list(node.attrib):
|
|
92
|
+
if key.startswith(kit.q("rsid")) or key.startswith("{%s}rsid" % kit.W):
|
|
93
|
+
del node.attrib[key]
|
|
94
|
+
stats["rsid_attributes"] += 1
|
|
95
|
+
changed = True
|
|
96
|
+
for tag, key in ((kit.q("proofErr"), "proof_error_markers"), (kit.q("lastRenderedPageBreak"), "render_breaks")):
|
|
97
|
+
for node in list(root.iter(tag)):
|
|
98
|
+
node.getparent().remove(node)
|
|
99
|
+
stats[key] += 1
|
|
100
|
+
changed = True
|
|
101
|
+
if changed:
|
|
102
|
+
doc.set_xml(name, root)
|
|
103
|
+
return stats
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def main(argv: list[str] | None = None) -> int:
|
|
107
|
+
parser = argparse.ArgumentParser(description="Strip author, company, custom properties and revision identifiers.")
|
|
108
|
+
parser.add_argument("input_docx", help="Input .docx")
|
|
109
|
+
parser.add_argument("--out", required=True, help="Output .docx")
|
|
110
|
+
parser.add_argument("--keep_title", action="store_true", help="Keep dc:title and dc:subject")
|
|
111
|
+
args = parser.parse_args(argv)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
doc = kit.Docx(args.input_docx)
|
|
115
|
+
except (OSError, KeyError) as exc:
|
|
116
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
117
|
+
return 2
|
|
118
|
+
|
|
119
|
+
cleared = clear_core(doc) + clear_app(doc)
|
|
120
|
+
if args.keep_title:
|
|
121
|
+
cleared = [name for name in cleared if name not in ("title", "subject")]
|
|
122
|
+
root = doc.xml("docProps/core.xml")
|
|
123
|
+
original = kit.Docx(args.input_docx).xml("docProps/core.xml")
|
|
124
|
+
for tag in ("{%s}title" % DC, "{%s}subject" % DC):
|
|
125
|
+
source = original.find(tag)
|
|
126
|
+
target = root.find(tag)
|
|
127
|
+
if source is not None and target is not None:
|
|
128
|
+
target.text = source.text
|
|
129
|
+
|
|
130
|
+
custom_dropped = drop_custom(doc)
|
|
131
|
+
stats = scrub_document(doc)
|
|
132
|
+
doc.save(args.out)
|
|
133
|
+
kit.emit({"ok": True, "output": str(Path(args.out)), "cleared": cleared, "custom_properties_dropped": custom_dropped, **stats})
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
23
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
24
|
+
try:
|
|
25
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
except (AttributeError, ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
EMAIL = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
|
|
30
|
+
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)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def redact_text(text: str, patterns: list[re.Pattern], mask: str, preserve: bool) -> tuple[str, int]:
|
|
34
|
+
total = 0
|
|
35
|
+
for pattern in patterns:
|
|
36
|
+
def replace(match: re.Match) -> str:
|
|
37
|
+
nonlocal total
|
|
38
|
+
total += 1
|
|
39
|
+
return (mask * len(match.group(0))) if preserve else mask
|
|
40
|
+
|
|
41
|
+
text = pattern.sub(replace, text)
|
|
42
|
+
return text, total
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def collect_parts(doc: kit.Docx, include_comments: bool) -> list[str]:
|
|
46
|
+
parts = ["word/document.xml"] + doc.names("word/header") + doc.names("word/footer")
|
|
47
|
+
for note in ("word/footnotes.xml", "word/endnotes.xml"):
|
|
48
|
+
if doc.exists(note):
|
|
49
|
+
parts.append(note)
|
|
50
|
+
if include_comments and doc.exists("word/comments.xml"):
|
|
51
|
+
parts.append("word/comments.xml")
|
|
52
|
+
return parts
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: list[str] | None = None) -> int:
|
|
56
|
+
parser = argparse.ArgumentParser(description="Mask emails, phone numbers or custom regex matches in text runs.")
|
|
57
|
+
parser.add_argument("input_docx", help="Input .docx")
|
|
58
|
+
parser.add_argument("output_docx", help="Output .docx")
|
|
59
|
+
parser.add_argument("--emails", action="store_true", help="Redact email addresses (default when no pattern is given)")
|
|
60
|
+
parser.add_argument("--phones", action="store_true", help="Redact phone numbers (default when no pattern is given)")
|
|
61
|
+
parser.add_argument("--pattern", action="append", default=[], help="Extra regex to redact (repeatable)")
|
|
62
|
+
parser.add_argument("--mask_char", default="*", help="Masking character")
|
|
63
|
+
parser.add_argument("--replacement", default="[redacted]", help="Replacement text when length is not preserved")
|
|
64
|
+
parser.add_argument("--no_preserve_length", action="store_true", help="Use --replacement instead of a per-character mask")
|
|
65
|
+
parser.add_argument("--include_comments", action="store_true", help="Also redact comment text")
|
|
66
|
+
args = parser.parse_args(argv)
|
|
67
|
+
|
|
68
|
+
patterns: list[re.Pattern] = []
|
|
69
|
+
if args.emails or not (args.emails or args.phones or args.pattern):
|
|
70
|
+
patterns.append(EMAIL)
|
|
71
|
+
if args.phones or not (args.emails or args.phones or args.pattern):
|
|
72
|
+
patterns.append(PHONE)
|
|
73
|
+
for raw in args.pattern:
|
|
74
|
+
try:
|
|
75
|
+
patterns.append(re.compile(raw))
|
|
76
|
+
except re.error as exc:
|
|
77
|
+
print("error: bad --pattern %r: %s" % (raw, exc), file=sys.stderr)
|
|
78
|
+
return 2
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
doc = kit.Docx(args.input_docx)
|
|
82
|
+
except (OSError, KeyError) as exc:
|
|
83
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
84
|
+
return 2
|
|
85
|
+
|
|
86
|
+
mask = args.replacement if args.no_preserve_length else (args.mask_char or "*")[:1]
|
|
87
|
+
preserve = not args.no_preserve_length
|
|
88
|
+
matches = 0
|
|
89
|
+
touched_parts = []
|
|
90
|
+
for part in collect_parts(doc, args.include_comments):
|
|
91
|
+
if not doc.exists(part):
|
|
92
|
+
continue
|
|
93
|
+
root = doc.xml(part)
|
|
94
|
+
part_matches = 0
|
|
95
|
+
for node in root.iter(kit.q("t")):
|
|
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
|
+
for node in root.iter(kit.q("delText")):
|
|
103
|
+
if not node.text:
|
|
104
|
+
continue
|
|
105
|
+
updated, count = redact_text(node.text, patterns, mask, preserve)
|
|
106
|
+
if count:
|
|
107
|
+
node.text = updated
|
|
108
|
+
part_matches += count
|
|
109
|
+
if part_matches:
|
|
110
|
+
doc.set_xml(part, root)
|
|
111
|
+
matches += part_matches
|
|
112
|
+
touched_parts.append({"part": part, "matches": part_matches})
|
|
113
|
+
|
|
114
|
+
doc.save(args.output_docx)
|
|
115
|
+
kit.emit({"ok": True, "output": str(Path(args.output_docx)), "matches": matches, "patterns": len(patterns), "parts": touched_parts})
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Render two documents and report which pages changed, pixel by pixel.
|
|
3
|
+
|
|
4
|
+
This is how you prove an edit did what you meant without opening Word: render
|
|
5
|
+
both versions, diff the page images, then look at the pages that moved. The diff
|
|
6
|
+
image marks changed pixels in colour so a one-line change is obvious.
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 compared, 1 render failed, 3 no renderer installed, 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
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
21
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
22
|
+
try:
|
|
23
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
24
|
+
except (AttributeError, ValueError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
THRESHOLD = 8
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render(path: Path, out_dir: Path, dpi: int) -> tuple[int, list[str], str]:
|
|
31
|
+
code, stdout, stderr = kit.run_office_cli(["docx", "render", "--input", str(path), "--out-dir", str(out_dir), "--dpi", str(dpi)])
|
|
32
|
+
payload = kit.cli_json(stdout) or kit.cli_json(stderr) or {}
|
|
33
|
+
detail = payload.get("error") or payload.get("reason") or ""
|
|
34
|
+
if not detail:
|
|
35
|
+
lines = [line.strip() for line in (stderr or stdout).splitlines() if line.strip() not in ("", "{", "}")]
|
|
36
|
+
detail = lines[-1] if lines else "render failed"
|
|
37
|
+
return code, payload.get("pages") or [], detail
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def diff_pages(first: Path, second: Path, out_path: Path) -> dict:
|
|
41
|
+
from PIL import Image, ImageChops, ImageOps
|
|
42
|
+
|
|
43
|
+
with Image.open(first) as a, Image.open(second) as b:
|
|
44
|
+
left = a.convert("RGB")
|
|
45
|
+
right = b.convert("RGB")
|
|
46
|
+
if left.size != right.size:
|
|
47
|
+
right = right.resize(left.size)
|
|
48
|
+
difference = ImageChops.difference(left, right)
|
|
49
|
+
grey = difference.convert("L").point(lambda value: 255 if value > THRESHOLD else 0)
|
|
50
|
+
changed = sum(1 for value in grey.getdata() if value)
|
|
51
|
+
box = difference.getbbox()
|
|
52
|
+
ImageOps.autocontrast(difference).save(out_path)
|
|
53
|
+
return {
|
|
54
|
+
"changed_pixels": changed,
|
|
55
|
+
"total_pixels": left.width * left.height,
|
|
56
|
+
"ratio": round(changed / float(left.width * left.height), 6),
|
|
57
|
+
"bbox": list(box) if box else None,
|
|
58
|
+
"size": list(left.size),
|
|
59
|
+
"diff_image": str(out_path),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: list[str] | None = None) -> int:
|
|
64
|
+
parser = argparse.ArgumentParser(description="Render two .docx files and report per-page pixel differences.")
|
|
65
|
+
parser.add_argument("first", help="Original .docx")
|
|
66
|
+
parser.add_argument("second", help="Changed .docx")
|
|
67
|
+
parser.add_argument("--outdir", required=True, help="Directory for rendered pages and diff images")
|
|
68
|
+
parser.add_argument("--dpi", type=int, default=120)
|
|
69
|
+
parser.add_argument("--threshold", type=int, default=THRESHOLD, help="Per-pixel channel difference that counts as a change")
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
|
|
72
|
+
root = Path(args.outdir).expanduser().resolve()
|
|
73
|
+
first = Path(args.first).expanduser().resolve()
|
|
74
|
+
second = Path(args.second).expanduser().resolve()
|
|
75
|
+
for path in (first, second):
|
|
76
|
+
if not path.is_file():
|
|
77
|
+
print("error: %s not found" % path, file=sys.stderr)
|
|
78
|
+
return 2
|
|
79
|
+
|
|
80
|
+
code_a, pages_a, detail_a = render(first, root / "a", args.dpi)
|
|
81
|
+
code_b, pages_b, detail_b = render(second, root / "b", args.dpi)
|
|
82
|
+
if code_a != 0 or code_b != 0 or not pages_a or not pages_b:
|
|
83
|
+
detail = detail_a or detail_b or "render failed"
|
|
84
|
+
reason = "no_renderer" if ("renderer" in detail.lower() or "libreoffice" in detail.lower()) else "render_failed"
|
|
85
|
+
kit.emit({"ok": False, "reason": reason, "detail": detail, "remedy": "Install Microsoft Office, WPS Office, or LibreOffice, then re-run."})
|
|
86
|
+
return 3 if reason == "no_renderer" else 1
|
|
87
|
+
|
|
88
|
+
pairs = list(zip(pages_a, pages_b))
|
|
89
|
+
results = []
|
|
90
|
+
for index, (left, right) in enumerate(pairs, start=1):
|
|
91
|
+
results.append(diff_pages(Path(left), Path(right), root / ("diff-%02d.png" % index)))
|
|
92
|
+
|
|
93
|
+
changed_pages = [index for index, item in enumerate(results, start=1) if item["changed_pixels"]]
|
|
94
|
+
kit.emit(
|
|
95
|
+
{
|
|
96
|
+
"ok": True,
|
|
97
|
+
"pages_first": len(pages_a),
|
|
98
|
+
"pages_second": len(pages_b),
|
|
99
|
+
"changed_pages": changed_pages,
|
|
100
|
+
"page_count_delta": len(pages_b) - len(pages_a),
|
|
101
|
+
"diffs": results,
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Replace text in a Word document without touching the original.
|
|
3
|
+
|
|
4
|
+
Pairs run inside single runs first; when a match straddles runs the paragraph
|
|
5
|
+
text is rewritten into its first run, so ordinary prose survives intact and a
|
|
6
|
+
paragraph that mixes formats collapses. Body, tables, and every section header
|
|
7
|
+
and footer are covered.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
replace_text.py --input IN.docx [--out OUT.docx] --find OLD --replace NEW [--find ...]
|
|
11
|
+
replace_text.py --input IN.docx [--out OUT.docx] --spec edits.json
|
|
12
|
+
|
|
13
|
+
The spec file is {"replace": [{"find": "18%", "replace": "21%"}]} or a bare list
|
|
14
|
+
of those objects.
|
|
15
|
+
|
|
16
|
+
Exit codes: 0 replaced, 1 nothing replaced (with --require-all) or invalid
|
|
17
|
+
input, 2 argparse 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
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
31
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
32
|
+
try:
|
|
33
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
34
|
+
except (AttributeError, ValueError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def paragraph_text_nodes(paragraph) -> list:
|
|
39
|
+
"""Every w:t below a paragraph, including runs inside hyperlinks."""
|
|
40
|
+
return paragraph._p.xpath(".//w:t")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def replace_in_paragraph(paragraph, find: str, replacement: str) -> int:
|
|
44
|
+
nodes = paragraph_text_nodes(paragraph)
|
|
45
|
+
if not nodes:
|
|
46
|
+
return 0
|
|
47
|
+
count = 0
|
|
48
|
+
for node in nodes:
|
|
49
|
+
text = node.text or ""
|
|
50
|
+
if find in text:
|
|
51
|
+
hits = text.count(find)
|
|
52
|
+
node.text = text.replace(find, replacement)
|
|
53
|
+
count += hits
|
|
54
|
+
if count:
|
|
55
|
+
return count
|
|
56
|
+
joined = "".join(node.text or "" for node in nodes)
|
|
57
|
+
if find not in joined:
|
|
58
|
+
return 0
|
|
59
|
+
hits = joined.count(find)
|
|
60
|
+
nodes[0].text = joined.replace(find, replacement)
|
|
61
|
+
for node in nodes[1:]:
|
|
62
|
+
node.text = ""
|
|
63
|
+
return hits
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def iter_paragraphs(document):
|
|
67
|
+
def walk_tables(tables):
|
|
68
|
+
for table in tables:
|
|
69
|
+
for row in table.rows:
|
|
70
|
+
for cell in row.cells:
|
|
71
|
+
yield from cell.paragraphs
|
|
72
|
+
yield from walk_tables(cell.tables)
|
|
73
|
+
|
|
74
|
+
yield from document.paragraphs
|
|
75
|
+
yield from walk_tables(document.tables)
|
|
76
|
+
for section in document.sections:
|
|
77
|
+
for part in (section.header, section.footer, section.first_page_header,
|
|
78
|
+
section.first_page_footer, section.even_page_header,
|
|
79
|
+
section.even_page_footer):
|
|
80
|
+
try:
|
|
81
|
+
yield from part.paragraphs
|
|
82
|
+
except Exception: # a header/footer that is not linked or not present
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fail(payload: dict, code: int = 1) -> int:
|
|
87
|
+
kit.emit({"ok": False, **payload})
|
|
88
|
+
return code
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_spec(path: str) -> list[tuple[str, str]]:
|
|
92
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
93
|
+
items = payload.get("replace", []) if isinstance(payload, dict) else payload
|
|
94
|
+
pairs: list[tuple[str, str]] = []
|
|
95
|
+
for item in items:
|
|
96
|
+
find = str(item.get("find", ""))
|
|
97
|
+
if find:
|
|
98
|
+
pairs.append((find, str(item.get("replace", ""))))
|
|
99
|
+
return pairs
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main() -> int:
|
|
103
|
+
parser = argparse.ArgumentParser(description="Replace text in a .docx copy.")
|
|
104
|
+
parser.add_argument("--input", required=True, help="source .docx (never modified)")
|
|
105
|
+
parser.add_argument("--out", help="output path; default <name>-edited.docx")
|
|
106
|
+
parser.add_argument("--find", action="append", default=[], help="text to find; pair with --replace")
|
|
107
|
+
parser.add_argument("--replace", action="append", default=[], help="replacement for the matching --find")
|
|
108
|
+
parser.add_argument("--spec", help="JSON file with replace pairs")
|
|
109
|
+
parser.add_argument("--require-all", action="store_true", help="exit 1 when a pair matches nothing")
|
|
110
|
+
args = parser.parse_args()
|
|
111
|
+
|
|
112
|
+
if args.find and len(args.find) != len(args.replace):
|
|
113
|
+
return fail({"error": "--find and --replace must be given in pairs"}, 2)
|
|
114
|
+
pairs = list(zip(args.find, args.replace))
|
|
115
|
+
if args.spec:
|
|
116
|
+
try:
|
|
117
|
+
pairs.extend(load_spec(args.spec))
|
|
118
|
+
except (OSError, ValueError) as exc:
|
|
119
|
+
return fail({"error": f"cannot read spec: {exc}"}, 2)
|
|
120
|
+
if not pairs:
|
|
121
|
+
return fail({"error": "no replacements requested"}, 2)
|
|
122
|
+
|
|
123
|
+
source = Path(args.input).expanduser()
|
|
124
|
+
if not source.is_file():
|
|
125
|
+
return fail({"error": f"input not found: {args.input}"}, 1)
|
|
126
|
+
out = Path(args.out).expanduser() if args.out else source.with_name(f"{source.stem}-edited.docx")
|
|
127
|
+
if out.resolve() == source.resolve():
|
|
128
|
+
return fail({"error": "refusing to overwrite the input; pass a different --out"}, 1)
|
|
129
|
+
|
|
130
|
+
from docx import Document
|
|
131
|
+
|
|
132
|
+
document = Document(str(source))
|
|
133
|
+
per_pair: list[dict] = []
|
|
134
|
+
total = 0
|
|
135
|
+
for find, replacement in pairs:
|
|
136
|
+
hits = 0
|
|
137
|
+
for paragraph in iter_paragraphs(document):
|
|
138
|
+
hits += replace_in_paragraph(paragraph, find, replacement)
|
|
139
|
+
total += hits
|
|
140
|
+
per_pair.append({"find": find, "replace": replacement, "count": hits})
|
|
141
|
+
|
|
142
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
document.save(str(out))
|
|
144
|
+
misses = [item["find"] for item in per_pair if not item["count"]]
|
|
145
|
+
payload = {
|
|
146
|
+
"ok": not (args.require_all and misses),
|
|
147
|
+
"input": str(source),
|
|
148
|
+
"output": str(out),
|
|
149
|
+
"replacements": total,
|
|
150
|
+
"pairs": per_pair,
|
|
151
|
+
"misses": misses,
|
|
152
|
+
}
|
|
153
|
+
if args.require_all and misses:
|
|
154
|
+
payload["error"] = "patterns matched nothing: " + ", ".join(misses)
|
|
155
|
+
return fail(payload, 1)
|
|
156
|
+
kit.emit(payload)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Report section geometry in inches so layout problems are visible as numbers.
|
|
3
|
+
|
|
4
|
+
Every ``sectPr`` is one page-layout decision: page size, orientation (portrait is
|
|
5
|
+
a page size where width < height, not a flag), margins, columns, header/footer
|
|
6
|
+
links, and the section start type. Mixed page sizes in one file are the most
|
|
7
|
+
common cause of "why did page 4 shrink" reports, so a summary flag calls it out.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 clean, 1 suspicious geometry, 2 bad input.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
import docx_kit as kit
|
|
20
|
+
|
|
21
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
22
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
23
|
+
try:
|
|
24
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
25
|
+
except (AttributeError, ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
TWIPS_PER_INCH = 1440
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def inches(value: str | None) -> float | None:
|
|
32
|
+
return round(int(value) / TWIPS_PER_INCH, 3) if value and value.lstrip("-").isdigit() else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def describe(part: str, sect, index: int, doc: kit.Docx) -> dict:
|
|
36
|
+
page = sect.find(kit.q("pgSz"))
|
|
37
|
+
margins = sect.find(kit.q("pgMar"))
|
|
38
|
+
columns = sect.find(kit.q("cols"))
|
|
39
|
+
kind = sect.find(kit.q("type"))
|
|
40
|
+
info = {
|
|
41
|
+
"index": index,
|
|
42
|
+
"part": part,
|
|
43
|
+
"width_in": inches(page.get(kit.q("w"))) if page is not None else None,
|
|
44
|
+
"height_in": inches(page.get(kit.q("h"))) if page is not None else None,
|
|
45
|
+
"orientation": None,
|
|
46
|
+
"margin_in": {},
|
|
47
|
+
"columns": int(columns.get(kit.q("num"))) if columns is not None and columns.get(kit.q("num")) else 1,
|
|
48
|
+
"start": kind.get(kit.q("val")) if kind is not None else "nextPage",
|
|
49
|
+
"headers": [],
|
|
50
|
+
"footers": [],
|
|
51
|
+
"title_page": sect.find(kit.q("titlePg")) is not None,
|
|
52
|
+
"page_number_start": None,
|
|
53
|
+
}
|
|
54
|
+
if info["width_in"] and info["height_in"]:
|
|
55
|
+
info["orientation"] = "portrait" if info["width_in"] < info["height_in"] else "landscape"
|
|
56
|
+
if margins is not None:
|
|
57
|
+
for side in ("top", "bottom", "left", "right", "header", "footer", "gutter"):
|
|
58
|
+
info["margin_in"][side] = inches(margins.get(kit.q(side)))
|
|
59
|
+
numbering = sect.find(kit.q("pgNumType"))
|
|
60
|
+
if numbering is not None and numbering.get(kit.q("start")):
|
|
61
|
+
info["page_number_start"] = int(numbering.get(kit.q("start")))
|
|
62
|
+
rels = {entry["id"]: entry for entry in doc.relationships(part)}
|
|
63
|
+
for tag, key in (("headerReference", "headers"), ("footerReference", "footers")):
|
|
64
|
+
for ref in sect.findall(kit.q(tag)):
|
|
65
|
+
rid = ref.get(kit.q("id", kit.R))
|
|
66
|
+
info[key].append({"type": ref.get(kit.q("type")), "part": rels[rid]["resolved"] if rid in rels else None})
|
|
67
|
+
return info
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv: list[str] | None = None) -> int:
|
|
71
|
+
parser = argparse.ArgumentParser(description="Report page size, orientation, margins and columns per section.")
|
|
72
|
+
parser.add_argument("docx", help="Input .docx")
|
|
73
|
+
args = parser.parse_args(argv)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
doc = kit.Docx(args.docx)
|
|
77
|
+
except (OSError, KeyError) as exc:
|
|
78
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
79
|
+
return 2
|
|
80
|
+
|
|
81
|
+
sections = [describe("word/document.xml", sect, index, doc) for index, sect in enumerate(doc.sections(), start=1)]
|
|
82
|
+
findings = []
|
|
83
|
+
sizes = {(item["width_in"], item["height_in"]) for item in sections}
|
|
84
|
+
if len(sizes) > 1:
|
|
85
|
+
findings.append({"kind": "mixed_page_sizes", "sizes": sorted(sizes, key=lambda pair: (pair[0] or 0, pair[1] or 0))})
|
|
86
|
+
for item in sections:
|
|
87
|
+
margins = item["margin_in"]
|
|
88
|
+
if margins.get("left") is not None and margins.get("right") is not None and margins["left"] + margins["right"] >= (item["width_in"] or 8.5) - 1:
|
|
89
|
+
findings.append({"kind": "narrow_text_column", "section": item["index"], "left": margins["left"], "right": margins["right"]})
|
|
90
|
+
if item["width_in"] and item["width_in"] > 8.5 and item["orientation"] == "portrait":
|
|
91
|
+
findings.append({"kind": "portrait_wider_than_letter", "section": item["index"]})
|
|
92
|
+
|
|
93
|
+
kit.emit({"ok": not findings, "sections": sections, "findings": findings})
|
|
94
|
+
return 1 if findings else 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
raise SystemExit(main())
|