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,101 @@
|
|
|
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
|
+
THRESHOLD = 8
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render(path: Path, out_dir: Path, dpi: int) -> tuple[int, list[str], str]:
|
|
24
|
+
code, stdout, stderr = kit.run_office_cli(["docx", "render", "--input", str(path), "--out-dir", str(out_dir), "--dpi", str(dpi)])
|
|
25
|
+
payload = kit.cli_json(stdout) or kit.cli_json(stderr) or {}
|
|
26
|
+
detail = payload.get("error") or payload.get("reason") or ""
|
|
27
|
+
if not detail:
|
|
28
|
+
lines = [line.strip() for line in (stderr or stdout).splitlines() if line.strip() not in ("", "{", "}")]
|
|
29
|
+
detail = lines[-1] if lines else "render failed"
|
|
30
|
+
return code, payload.get("pages") or [], detail
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def diff_pages(first: Path, second: Path, out_path: Path) -> dict:
|
|
34
|
+
from PIL import Image, ImageChops, ImageOps
|
|
35
|
+
|
|
36
|
+
with Image.open(first) as a, Image.open(second) as b:
|
|
37
|
+
left = a.convert("RGB")
|
|
38
|
+
right = b.convert("RGB")
|
|
39
|
+
if left.size != right.size:
|
|
40
|
+
right = right.resize(left.size)
|
|
41
|
+
difference = ImageChops.difference(left, right)
|
|
42
|
+
grey = difference.convert("L").point(lambda value: 255 if value > THRESHOLD else 0)
|
|
43
|
+
changed = sum(1 for value in grey.getdata() if value)
|
|
44
|
+
box = difference.getbbox()
|
|
45
|
+
ImageOps.autocontrast(difference).save(out_path)
|
|
46
|
+
return {
|
|
47
|
+
"changed_pixels": changed,
|
|
48
|
+
"total_pixels": left.width * left.height,
|
|
49
|
+
"ratio": round(changed / float(left.width * left.height), 6),
|
|
50
|
+
"bbox": list(box) if box else None,
|
|
51
|
+
"size": list(left.size),
|
|
52
|
+
"diff_image": str(out_path),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
parser = argparse.ArgumentParser(description="Render two .docx files and report per-page pixel differences.")
|
|
58
|
+
parser.add_argument("first", help="Original .docx")
|
|
59
|
+
parser.add_argument("second", help="Changed .docx")
|
|
60
|
+
parser.add_argument("--outdir", required=True, help="Directory for rendered pages and diff images")
|
|
61
|
+
parser.add_argument("--dpi", type=int, default=120)
|
|
62
|
+
parser.add_argument("--threshold", type=int, default=THRESHOLD, help="Per-pixel channel difference that counts as a change")
|
|
63
|
+
args = parser.parse_args(argv)
|
|
64
|
+
|
|
65
|
+
root = Path(args.outdir).expanduser().resolve()
|
|
66
|
+
first = Path(args.first).expanduser().resolve()
|
|
67
|
+
second = Path(args.second).expanduser().resolve()
|
|
68
|
+
for path in (first, second):
|
|
69
|
+
if not path.is_file():
|
|
70
|
+
print("error: %s not found" % path, file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
code_a, pages_a, detail_a = render(first, root / "a", args.dpi)
|
|
74
|
+
code_b, pages_b, detail_b = render(second, root / "b", args.dpi)
|
|
75
|
+
if code_a != 0 or code_b != 0 or not pages_a or not pages_b:
|
|
76
|
+
detail = detail_a or detail_b or "render failed"
|
|
77
|
+
reason = "no_renderer" if ("renderer" in detail.lower() or "libreoffice" in detail.lower()) else "render_failed"
|
|
78
|
+
kit.emit({"ok": False, "reason": reason, "detail": detail, "remedy": "Install Microsoft Office, WPS Office, or LibreOffice, then re-run."})
|
|
79
|
+
return 3 if reason == "no_renderer" else 1
|
|
80
|
+
|
|
81
|
+
pairs = list(zip(pages_a, pages_b))
|
|
82
|
+
results = []
|
|
83
|
+
for index, (left, right) in enumerate(pairs, start=1):
|
|
84
|
+
results.append(diff_pages(Path(left), Path(right), root / ("diff-%02d.png" % index)))
|
|
85
|
+
|
|
86
|
+
changed_pages = [index for index, item in enumerate(results, start=1) if item["changed_pixels"]]
|
|
87
|
+
kit.emit(
|
|
88
|
+
{
|
|
89
|
+
"ok": True,
|
|
90
|
+
"pages_first": len(pages_a),
|
|
91
|
+
"pages_second": len(pages_b),
|
|
92
|
+
"changed_pages": changed_pages,
|
|
93
|
+
"page_count_delta": len(pages_b) - len(pages_a),
|
|
94
|
+
"diffs": results,
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
|
|
31
|
+
def paragraph_text_nodes(paragraph) -> list:
|
|
32
|
+
"""Every w:t below a paragraph, including runs inside hyperlinks."""
|
|
33
|
+
return paragraph._p.xpath(".//w:t")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def replace_in_paragraph(paragraph, find: str, replacement: str) -> int:
|
|
37
|
+
nodes = paragraph_text_nodes(paragraph)
|
|
38
|
+
if not nodes:
|
|
39
|
+
return 0
|
|
40
|
+
count = 0
|
|
41
|
+
for node in nodes:
|
|
42
|
+
text = node.text or ""
|
|
43
|
+
if find in text:
|
|
44
|
+
hits = text.count(find)
|
|
45
|
+
node.text = text.replace(find, replacement)
|
|
46
|
+
count += hits
|
|
47
|
+
if count:
|
|
48
|
+
return count
|
|
49
|
+
joined = "".join(node.text or "" for node in nodes)
|
|
50
|
+
if find not in joined:
|
|
51
|
+
return 0
|
|
52
|
+
hits = joined.count(find)
|
|
53
|
+
nodes[0].text = joined.replace(find, replacement)
|
|
54
|
+
for node in nodes[1:]:
|
|
55
|
+
node.text = ""
|
|
56
|
+
return hits
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def iter_paragraphs(document):
|
|
60
|
+
def walk_tables(tables):
|
|
61
|
+
for table in tables:
|
|
62
|
+
for row in table.rows:
|
|
63
|
+
for cell in row.cells:
|
|
64
|
+
yield from cell.paragraphs
|
|
65
|
+
yield from walk_tables(cell.tables)
|
|
66
|
+
|
|
67
|
+
yield from document.paragraphs
|
|
68
|
+
yield from walk_tables(document.tables)
|
|
69
|
+
for section in document.sections:
|
|
70
|
+
for part in (section.header, section.footer, section.first_page_header,
|
|
71
|
+
section.first_page_footer, section.even_page_header,
|
|
72
|
+
section.even_page_footer):
|
|
73
|
+
try:
|
|
74
|
+
yield from part.paragraphs
|
|
75
|
+
except Exception: # a header/footer that is not linked or not present
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fail(payload: dict, code: int = 1) -> int:
|
|
80
|
+
kit.emit({"ok": False, **payload})
|
|
81
|
+
return code
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_spec(path: str) -> list[tuple[str, str]]:
|
|
85
|
+
payload = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
86
|
+
items = payload.get("replace", []) if isinstance(payload, dict) else payload
|
|
87
|
+
pairs: list[tuple[str, str]] = []
|
|
88
|
+
for item in items:
|
|
89
|
+
find = str(item.get("find", ""))
|
|
90
|
+
if find:
|
|
91
|
+
pairs.append((find, str(item.get("replace", ""))))
|
|
92
|
+
return pairs
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def main() -> int:
|
|
96
|
+
parser = argparse.ArgumentParser(description="Replace text in a .docx copy.")
|
|
97
|
+
parser.add_argument("--input", required=True, help="source .docx (never modified)")
|
|
98
|
+
parser.add_argument("--out", help="output path; default <name>-edited.docx")
|
|
99
|
+
parser.add_argument("--find", action="append", default=[], help="text to find; pair with --replace")
|
|
100
|
+
parser.add_argument("--replace", action="append", default=[], help="replacement for the matching --find")
|
|
101
|
+
parser.add_argument("--spec", help="JSON file with replace pairs")
|
|
102
|
+
parser.add_argument("--require-all", action="store_true", help="exit 1 when a pair matches nothing")
|
|
103
|
+
args = parser.parse_args()
|
|
104
|
+
|
|
105
|
+
if args.find and len(args.find) != len(args.replace):
|
|
106
|
+
return fail({"error": "--find and --replace must be given in pairs"}, 2)
|
|
107
|
+
pairs = list(zip(args.find, args.replace))
|
|
108
|
+
if args.spec:
|
|
109
|
+
try:
|
|
110
|
+
pairs.extend(load_spec(args.spec))
|
|
111
|
+
except (OSError, ValueError) as exc:
|
|
112
|
+
return fail({"error": f"cannot read spec: {exc}"}, 2)
|
|
113
|
+
if not pairs:
|
|
114
|
+
return fail({"error": "no replacements requested"}, 2)
|
|
115
|
+
|
|
116
|
+
source = Path(args.input).expanduser()
|
|
117
|
+
if not source.is_file():
|
|
118
|
+
return fail({"error": f"input not found: {args.input}"}, 1)
|
|
119
|
+
out = Path(args.out).expanduser() if args.out else source.with_name(f"{source.stem}-edited.docx")
|
|
120
|
+
if out.resolve() == source.resolve():
|
|
121
|
+
return fail({"error": "refusing to overwrite the input; pass a different --out"}, 1)
|
|
122
|
+
|
|
123
|
+
from docx import Document
|
|
124
|
+
|
|
125
|
+
document = Document(str(source))
|
|
126
|
+
per_pair: list[dict] = []
|
|
127
|
+
total = 0
|
|
128
|
+
for find, replacement in pairs:
|
|
129
|
+
hits = 0
|
|
130
|
+
for paragraph in iter_paragraphs(document):
|
|
131
|
+
hits += replace_in_paragraph(paragraph, find, replacement)
|
|
132
|
+
total += hits
|
|
133
|
+
per_pair.append({"find": find, "replace": replacement, "count": hits})
|
|
134
|
+
|
|
135
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
document.save(str(out))
|
|
137
|
+
misses = [item["find"] for item in per_pair if not item["count"]]
|
|
138
|
+
payload = {
|
|
139
|
+
"ok": not (args.require_all and misses),
|
|
140
|
+
"input": str(source),
|
|
141
|
+
"output": str(out),
|
|
142
|
+
"replacements": total,
|
|
143
|
+
"pairs": per_pair,
|
|
144
|
+
"misses": misses,
|
|
145
|
+
}
|
|
146
|
+
if args.require_all and misses:
|
|
147
|
+
payload["error"] = "patterns matched nothing: " + ", ".join(misses)
|
|
148
|
+
return fail(payload, 1)
|
|
149
|
+
kit.emit(payload)
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
TWIPS_PER_INCH = 1440
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def inches(value: str | None) -> float | None:
|
|
25
|
+
return round(int(value) / TWIPS_PER_INCH, 3) if value and value.lstrip("-").isdigit() else None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def describe(part: str, sect, index: int, doc: kit.Docx) -> dict:
|
|
29
|
+
page = sect.find(kit.q("pgSz"))
|
|
30
|
+
margins = sect.find(kit.q("pgMar"))
|
|
31
|
+
columns = sect.find(kit.q("cols"))
|
|
32
|
+
kind = sect.find(kit.q("type"))
|
|
33
|
+
info = {
|
|
34
|
+
"index": index,
|
|
35
|
+
"part": part,
|
|
36
|
+
"width_in": inches(page.get(kit.q("w"))) if page is not None else None,
|
|
37
|
+
"height_in": inches(page.get(kit.q("h"))) if page is not None else None,
|
|
38
|
+
"orientation": None,
|
|
39
|
+
"margin_in": {},
|
|
40
|
+
"columns": int(columns.get(kit.q("num"))) if columns is not None and columns.get(kit.q("num")) else 1,
|
|
41
|
+
"start": kind.get(kit.q("val")) if kind is not None else "nextPage",
|
|
42
|
+
"headers": [],
|
|
43
|
+
"footers": [],
|
|
44
|
+
"title_page": sect.find(kit.q("titlePg")) is not None,
|
|
45
|
+
"page_number_start": None,
|
|
46
|
+
}
|
|
47
|
+
if info["width_in"] and info["height_in"]:
|
|
48
|
+
info["orientation"] = "portrait" if info["width_in"] < info["height_in"] else "landscape"
|
|
49
|
+
if margins is not None:
|
|
50
|
+
for side in ("top", "bottom", "left", "right", "header", "footer", "gutter"):
|
|
51
|
+
info["margin_in"][side] = inches(margins.get(kit.q(side)))
|
|
52
|
+
numbering = sect.find(kit.q("pgNumType"))
|
|
53
|
+
if numbering is not None and numbering.get(kit.q("start")):
|
|
54
|
+
info["page_number_start"] = int(numbering.get(kit.q("start")))
|
|
55
|
+
rels = {entry["id"]: entry for entry in doc.relationships(part)}
|
|
56
|
+
for tag, key in (("headerReference", "headers"), ("footerReference", "footers")):
|
|
57
|
+
for ref in sect.findall(kit.q(tag)):
|
|
58
|
+
rid = ref.get(kit.q("id", kit.R))
|
|
59
|
+
info[key].append({"type": ref.get(kit.q("type")), "part": rels[rid]["resolved"] if rid in rels else None})
|
|
60
|
+
return info
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: list[str] | None = None) -> int:
|
|
64
|
+
parser = argparse.ArgumentParser(description="Report page size, orientation, margins and columns per section.")
|
|
65
|
+
parser.add_argument("docx", help="Input .docx")
|
|
66
|
+
args = parser.parse_args(argv)
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
doc = kit.Docx(args.docx)
|
|
70
|
+
except (OSError, KeyError) as exc:
|
|
71
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
72
|
+
return 2
|
|
73
|
+
|
|
74
|
+
sections = [describe("word/document.xml", sect, index, doc) for index, sect in enumerate(doc.sections(), start=1)]
|
|
75
|
+
findings = []
|
|
76
|
+
sizes = {(item["width_in"], item["height_in"]) for item in sections}
|
|
77
|
+
if len(sizes) > 1:
|
|
78
|
+
findings.append({"kind": "mixed_page_sizes", "sizes": sorted(sizes, key=lambda pair: (pair[0] or 0, pair[1] or 0))})
|
|
79
|
+
for item in sections:
|
|
80
|
+
margins = item["margin_in"]
|
|
81
|
+
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:
|
|
82
|
+
findings.append({"kind": "narrow_text_column", "section": item["index"], "left": margins["left"], "right": margins["right"]})
|
|
83
|
+
if item["width_in"] and item["width_in"] > 8.5 and item["orientation"] == "portrait":
|
|
84
|
+
findings.append({"kind": "portrait_wider_than_letter", "section": item["index"]})
|
|
85
|
+
|
|
86
|
+
kit.emit({"ok": not findings, "sections": sections, "findings": findings})
|
|
87
|
+
return 1 if findings else 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Set or clear Word's document-protection editing restriction.
|
|
3
|
+
|
|
4
|
+
Protection is a workplace guardrail, not encryption: without a password hash the
|
|
5
|
+
restriction can be removed by anyone who knows the settings part, which is often
|
|
6
|
+
exactly what is wanted for review copies. Supply a precomputed ``--hash`` if the
|
|
7
|
+
file must resist casual removal.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 success, 2 bad input.
|
|
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
|
+
MODES = {
|
|
24
|
+
"off": None,
|
|
25
|
+
"none": None,
|
|
26
|
+
"readonly": "readOnly",
|
|
27
|
+
"read_only": "readOnly",
|
|
28
|
+
"read-only": "readOnly",
|
|
29
|
+
"comments": "comments",
|
|
30
|
+
"comment": "comments",
|
|
31
|
+
"trackedchanges": "trackedChanges",
|
|
32
|
+
"tracked_changes": "trackedChanges",
|
|
33
|
+
"tracked-changes": "trackedChanges",
|
|
34
|
+
"forms": "forms",
|
|
35
|
+
"form": "forms",
|
|
36
|
+
}
|
|
37
|
+
HASH_ALGORITHM_SID = "4" # SHA-512, the modern Word default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv: list[str] | None = None) -> int:
|
|
41
|
+
parser = argparse.ArgumentParser(description="Apply or remove the editing restriction stored in settings.xml.")
|
|
42
|
+
parser.add_argument("docx", help="Input .docx")
|
|
43
|
+
parser.add_argument("--mode", required=True, help="off, readOnly, comments, trackedChanges, forms")
|
|
44
|
+
parser.add_argument("--out", default=None, help="Output .docx (default: overwrite the input)")
|
|
45
|
+
parser.add_argument("--no_enforce", action="store_true", help="Write the mode but leave enforcement off")
|
|
46
|
+
parser.add_argument("--hash", default=None, help="Precomputed password hash (skip to leave protection password-free)")
|
|
47
|
+
parser.add_argument("--salt", default=None, help="Salt value that belongs with --hash")
|
|
48
|
+
parser.add_argument("--spin_count", default="100000")
|
|
49
|
+
args = parser.parse_args(argv)
|
|
50
|
+
|
|
51
|
+
mode = MODES.get(args.mode.strip().lower())
|
|
52
|
+
if mode is None and args.mode.strip().lower() not in ("off", "none"):
|
|
53
|
+
print("error: unknown --mode %r (use off, readOnly, comments, trackedChanges, forms)" % args.mode, file=sys.stderr)
|
|
54
|
+
return 2
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
doc = kit.Docx(args.docx)
|
|
58
|
+
except (OSError, KeyError) as exc:
|
|
59
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
60
|
+
return 2
|
|
61
|
+
|
|
62
|
+
settings = doc.xml("word/settings.xml")
|
|
63
|
+
for existing in settings.findall(kit.q("documentProtection")):
|
|
64
|
+
settings.remove(existing)
|
|
65
|
+
|
|
66
|
+
if mode is not None:
|
|
67
|
+
node = etree.Element(kit.q("documentProtection"))
|
|
68
|
+
node.set(kit.q("edit"), mode)
|
|
69
|
+
node.set(kit.q("enforcement"), "0" if args.no_enforce else "1")
|
|
70
|
+
if args.hash:
|
|
71
|
+
node.set(kit.q("cryptProviderType"), "rsaFull")
|
|
72
|
+
node.set(kit.q("cryptAlgorithmClass"), "hash")
|
|
73
|
+
node.set(kit.q("cryptAlgorithmType"), "typeAny")
|
|
74
|
+
node.set(kit.q("cryptAlgorithmSid"), HASH_ALGORITHM_SID)
|
|
75
|
+
node.set(kit.q("cryptSpinCount"), args.spin_count)
|
|
76
|
+
node.set(kit.q("hash"), args.hash)
|
|
77
|
+
if args.salt:
|
|
78
|
+
node.set(kit.q("salt"), args.salt)
|
|
79
|
+
anchor = settings.find(kit.q("writeProtection"))
|
|
80
|
+
if anchor is None:
|
|
81
|
+
settings.insert(0, node)
|
|
82
|
+
else:
|
|
83
|
+
anchor.addnext(node)
|
|
84
|
+
|
|
85
|
+
out = args.out or args.docx
|
|
86
|
+
doc.save(out)
|
|
87
|
+
kit.emit({"ok": True, "output": str(Path(out)), "mode": mode or "off", "enforced": bool(mode) and not args.no_enforce, "password_hash": bool(args.hash)})
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Lint a document for formatting that fights its own styles.
|
|
3
|
+
|
|
4
|
+
The report answers "can Word restyle this document?" Direct run formatting,
|
|
5
|
+
undefined style ids, hand-typed bullets and one-off font sizes are the usual
|
|
6
|
+
reasons a restyle pass does nothing. Findings are advisory: a few are legitimate.
|
|
7
|
+
|
|
8
|
+
Exit codes: 0 clean, 1 findings, 2 bad input.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
20
|
+
import docx_kit as kit
|
|
21
|
+
|
|
22
|
+
MAX_FINDINGS = 50
|
|
23
|
+
BULLET_TEXT = ("\u2022", "- ", "* ")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def known_styles(doc: kit.Docx) -> dict[str, str]:
|
|
27
|
+
styles = {}
|
|
28
|
+
root = doc.xml("word/styles.xml")
|
|
29
|
+
for node in root.findall(kit.q("style")):
|
|
30
|
+
ident = node.get(kit.q("styleId"))
|
|
31
|
+
name = node.find(kit.q("name"))
|
|
32
|
+
styles[ident] = name.get(kit.q("val")) if name is not None else ident
|
|
33
|
+
return styles
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: list[str] | None = None) -> int:
|
|
37
|
+
parser = argparse.ArgumentParser(description="Report direct formatting, undefined styles and other style conflicts.")
|
|
38
|
+
parser.add_argument("docx", help="Input .docx")
|
|
39
|
+
parser.add_argument("--json", default=None, help="Also write the report to this path")
|
|
40
|
+
args = parser.parse_args(argv)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
doc = kit.Docx(args.docx)
|
|
44
|
+
except (OSError, KeyError) as exc:
|
|
45
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
46
|
+
return 2
|
|
47
|
+
|
|
48
|
+
styles = known_styles(doc)
|
|
49
|
+
findings = []
|
|
50
|
+
sizes = Counter()
|
|
51
|
+
direct = 0
|
|
52
|
+
|
|
53
|
+
for index, paragraph in enumerate(doc.paragraphs(), start=1):
|
|
54
|
+
text = kit.paragraph_text(paragraph).strip()
|
|
55
|
+
style_id = kit.paragraph_style(paragraph)
|
|
56
|
+
level = kit.heading_level(paragraph)
|
|
57
|
+
if style_id and style_id not in styles:
|
|
58
|
+
findings.append({"kind": "undefined_style", "index": index, "style": style_id})
|
|
59
|
+
if level and paragraph.find(kit.q("pPr") + "/" + kit.q("rPr")) is not None:
|
|
60
|
+
findings.append({"kind": "heading_paragraph_formatting", "index": index, "text": text[:80]})
|
|
61
|
+
for run in paragraph.iter(kit.q("r")):
|
|
62
|
+
props = run.find(kit.q("rPr"))
|
|
63
|
+
if props is None or len(props) == 0:
|
|
64
|
+
continue
|
|
65
|
+
direct += 1
|
|
66
|
+
if level and any(props.find(kit.q(tag)) is not None for tag in ("b", "i", "color", "sz")):
|
|
67
|
+
findings.append({"kind": "heading_direct_formatting", "index": index, "text": text[:80]})
|
|
68
|
+
break
|
|
69
|
+
size = props.find(kit.q("sz"))
|
|
70
|
+
if size is not None and not level and (size.get(kit.q("val")) or "").isdigit():
|
|
71
|
+
sizes[int(size.get(kit.q("val")))] += 1
|
|
72
|
+
if not kit.text_of(run):
|
|
73
|
+
findings.append({"kind": "empty_run", "index": index})
|
|
74
|
+
break
|
|
75
|
+
if text.startswith(BULLET_TEXT) and paragraph.find(kit.q("pPr") + "/" + kit.q("numPr")) is None:
|
|
76
|
+
findings.append({"kind": "manual_bullet", "index": index, "text": text[:80]})
|
|
77
|
+
if " " in text:
|
|
78
|
+
findings.append({"kind": "double_space", "index": index, "text": text[:80]})
|
|
79
|
+
if text != text.rstrip():
|
|
80
|
+
findings.append({"kind": "trailing_space", "index": index})
|
|
81
|
+
|
|
82
|
+
if sizes:
|
|
83
|
+
common, _ = sizes.most_common(1)[0]
|
|
84
|
+
for size, count in sizes.items():
|
|
85
|
+
if size != common and count <= 3:
|
|
86
|
+
findings.append({"kind": "body_size_outlier", "half_points": size, "count": count, "common_half_points": common})
|
|
87
|
+
|
|
88
|
+
for table in doc.body().iter(kit.q("tbl")):
|
|
89
|
+
if table.find(kit.q("tblPr") + "/" + kit.q("tblStyle")) is None:
|
|
90
|
+
findings.append({"kind": "table_without_style"})
|
|
91
|
+
|
|
92
|
+
counts = {}
|
|
93
|
+
for finding in findings:
|
|
94
|
+
counts[finding["kind"]] = counts.get(finding["kind"], 0) + 1
|
|
95
|
+
|
|
96
|
+
report = {
|
|
97
|
+
"ok": not findings,
|
|
98
|
+
"styles_defined": len(styles),
|
|
99
|
+
"directly_formatted_runs": direct,
|
|
100
|
+
"counts": counts,
|
|
101
|
+
"findings": findings[:MAX_FINDINGS],
|
|
102
|
+
"truncated": max(0, len(findings) - MAX_FINDINGS),
|
|
103
|
+
}
|
|
104
|
+
if args.json:
|
|
105
|
+
Path(args.json).write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
106
|
+
kit.emit(report)
|
|
107
|
+
return 1 if findings else 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
if __name__ == "__main__":
|
|
111
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Push formatting back onto styles so the document can be restyled again.
|
|
3
|
+
|
|
4
|
+
Two passes, both opt-in:
|
|
5
|
+
|
|
6
|
+
``--clear_paragraph_format``
|
|
7
|
+
delete paragraph-mark direct formatting and stray run formatting, keeping
|
|
8
|
+
character styles, so the paragraph style governs again.
|
|
9
|
+
``--enforce_heading_spacing``
|
|
10
|
+
give headings consistent spacing and "keep with next" so a page break cannot
|
|
11
|
+
strand a heading at the bottom of a page.
|
|
12
|
+
|
|
13
|
+
Exit codes: 0 success, 2 bad input.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
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
|
+
HEADING_SPACING = {1: (320, 120), 2: (240, 80), 3: (200, 60)}
|
|
28
|
+
KEEP_RUN_PROPS = {kit.q("rStyle"), kit.q("rtl"), kit.q("lang")}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def strip_run_formatting(paragraph) -> int:
|
|
32
|
+
removed = 0
|
|
33
|
+
for run in paragraph.iter(kit.q("r")):
|
|
34
|
+
props = run.find(kit.q("rPr"))
|
|
35
|
+
if props is None:
|
|
36
|
+
continue
|
|
37
|
+
for child in list(props):
|
|
38
|
+
if child.tag not in KEEP_RUN_PROPS:
|
|
39
|
+
props.remove(child)
|
|
40
|
+
removed += 1
|
|
41
|
+
if len(props) == 0:
|
|
42
|
+
run.remove(props)
|
|
43
|
+
return removed
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def enforce_spacing(paragraph, level: int) -> None:
|
|
47
|
+
props = paragraph.find(kit.q("pPr"))
|
|
48
|
+
if props is None:
|
|
49
|
+
props = etree.Element(kit.q("pPr"))
|
|
50
|
+
paragraph.insert(0, props)
|
|
51
|
+
for tag in ("spacing", "keepNext"):
|
|
52
|
+
for existing in props.findall(kit.q(tag)):
|
|
53
|
+
props.remove(existing)
|
|
54
|
+
before, after = HEADING_SPACING.get(level, (240, 120))
|
|
55
|
+
spacing = etree.SubElement(props, kit.q("spacing"))
|
|
56
|
+
spacing.set(kit.q("before"), str(before))
|
|
57
|
+
spacing.set(kit.q("after"), str(after))
|
|
58
|
+
etree.SubElement(props, kit.q("keepNext"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main(argv: list[str] | None = None) -> int:
|
|
62
|
+
parser = argparse.ArgumentParser(description="Remove direct formatting and normalize heading spacing.")
|
|
63
|
+
parser.add_argument("input_docx", help="Input .docx")
|
|
64
|
+
parser.add_argument("output_docx", nargs="?", default=None, help="Output .docx")
|
|
65
|
+
parser.add_argument("--out", default=None, help="Output .docx (alternative to the positional argument)")
|
|
66
|
+
parser.add_argument("--clear_paragraph_format", action="store_true", help="Strip direct run and paragraph-mark formatting")
|
|
67
|
+
parser.add_argument("--enforce_heading_spacing", action="store_true", help="Apply consistent heading spacing and keep-with-next")
|
|
68
|
+
args = parser.parse_args(argv)
|
|
69
|
+
|
|
70
|
+
out = args.out or args.output_docx
|
|
71
|
+
if not out:
|
|
72
|
+
parser.error("provide an output path (positional or --out)")
|
|
73
|
+
if not (args.clear_paragraph_format or args.enforce_heading_spacing):
|
|
74
|
+
parser.error("choose --clear_paragraph_format and/or --enforce_heading_spacing")
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
doc = kit.Docx(args.input_docx)
|
|
78
|
+
except (OSError, KeyError) as exc:
|
|
79
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
80
|
+
return 2
|
|
81
|
+
|
|
82
|
+
runs_cleaned = 0
|
|
83
|
+
headings_spaced = 0
|
|
84
|
+
for paragraph in doc.paragraphs():
|
|
85
|
+
if args.clear_paragraph_format:
|
|
86
|
+
props = paragraph.find(kit.q("pPr"))
|
|
87
|
+
if props is not None:
|
|
88
|
+
for mark in props.findall(kit.q("rPr")):
|
|
89
|
+
props.remove(mark)
|
|
90
|
+
runs_cleaned += strip_run_formatting(paragraph)
|
|
91
|
+
if args.enforce_heading_spacing:
|
|
92
|
+
level = kit.heading_level(paragraph)
|
|
93
|
+
if level:
|
|
94
|
+
enforce_spacing(paragraph, level)
|
|
95
|
+
headings_spaced += 1
|
|
96
|
+
|
|
97
|
+
doc.save(out)
|
|
98
|
+
kit.emit({"ok": True, "output": str(Path(out)), "runs_cleaned": runs_cleaned, "headings_spaced": headings_spaced})
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
raise SystemExit(main())
|