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,225 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Escape hatch for WordprocessingML features python-docx does not expose.
|
|
3
|
+
|
|
4
|
+
One invocation may combine several patches, which is what makes it useful for
|
|
5
|
+
finishing a document in one pass:
|
|
6
|
+
|
|
7
|
+
--enable-track switch on revisions
|
|
8
|
+
--tracked-replace-ins-id ID turn an insertion into delete + insert
|
|
9
|
+
--add-comment --comment-text attach a comment to the first matching paragraph
|
|
10
|
+
--header-date TEXT set a right-aligned first-header line
|
|
11
|
+
--add-page-numbers centered PAGE field in the footer
|
|
12
|
+
--hyperlink-first URL wrap the first text paragraph in a hyperlink
|
|
13
|
+
|
|
14
|
+
Exit codes: 0 success, 1 requested target not found, 2 bad input.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import datetime
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from lxml import etree
|
|
25
|
+
|
|
26
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
27
|
+
import docx_kit as kit
|
|
28
|
+
|
|
29
|
+
PKG_REL = "{http://schemas.openxmlformats.org/package/2006/relationships}"
|
|
30
|
+
REL_HYPERLINK = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stamp(args) -> str:
|
|
34
|
+
return args.date or datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def enable_track(doc: kit.Docx) -> str:
|
|
38
|
+
settings = doc.xml("word/settings.xml")
|
|
39
|
+
if settings.find(kit.q("trackRevisions")) is None:
|
|
40
|
+
settings.append(settings.makeelement(kit.q("trackRevisions"), {}))
|
|
41
|
+
return "trackRevisions enabled"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tracked_replace(doc: kit.Docx, ins_id: str, new_text: str, author: str, date: str) -> str:
|
|
45
|
+
for part in doc.text_parts():
|
|
46
|
+
for node in doc.xml(part).iter(kit.q("ins")):
|
|
47
|
+
if (node.get(kit.q("id")) or "") != str(ins_id):
|
|
48
|
+
continue
|
|
49
|
+
parent = node.getparent()
|
|
50
|
+
ident = doc.next_id()
|
|
51
|
+
deletion = etree.Element(kit.q("del"))
|
|
52
|
+
deletion.set(kit.q("id"), str(ident))
|
|
53
|
+
deletion.set(kit.q("author"), author)
|
|
54
|
+
deletion.set(kit.q("date"), date)
|
|
55
|
+
for child in list(node):
|
|
56
|
+
clone = etree.fromstring(etree.tostring(child))
|
|
57
|
+
for text in clone.iter(kit.q("t")):
|
|
58
|
+
text.tag = kit.q("delText")
|
|
59
|
+
deletion.append(clone)
|
|
60
|
+
insertion = etree.Element(kit.q("ins"))
|
|
61
|
+
insertion.set(kit.q("id"), str(ident + 1))
|
|
62
|
+
insertion.set(kit.q("author"), author)
|
|
63
|
+
insertion.set(kit.q("date"), date)
|
|
64
|
+
insertion.append(kit.make_run(new_text))
|
|
65
|
+
position = parent.index(node)
|
|
66
|
+
parent.remove(node)
|
|
67
|
+
parent.insert(position, deletion)
|
|
68
|
+
parent.insert(position + 1, insertion)
|
|
69
|
+
return "converted ins %s to del+ins in %s" % (ins_id, part)
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_paragraph(doc: kit.Docx, contains: str | None, indent: int | None):
|
|
74
|
+
for paragraph in doc.paragraphs():
|
|
75
|
+
props = paragraph.find(kit.q("pPr"))
|
|
76
|
+
if contains is not None and contains not in kit.paragraph_text(paragraph):
|
|
77
|
+
continue
|
|
78
|
+
if indent is not None:
|
|
79
|
+
ind = props.find(kit.q("ind")) if props is not None else None
|
|
80
|
+
if ind is None or (ind.get(kit.q("left")) or "") != str(indent):
|
|
81
|
+
continue
|
|
82
|
+
return paragraph
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def add_comment(doc: kit.Docx, paragraph, text: str, author: str, date: str, explicit_id: str | None) -> str:
|
|
87
|
+
if not doc.exists("word/comments.xml"):
|
|
88
|
+
root = etree.Element(kit.q("comments"), nsmap={"w": kit.W, "r": kit.R})
|
|
89
|
+
doc.set_xml("word/comments.xml", root)
|
|
90
|
+
doc.ensure_override("word/comments.xml", kit.CT_BY_REL[kit.REL_COMMENTS])
|
|
91
|
+
doc.ensure_rel("word/document.xml", kit.REL_COMMENTS, "comments.xml")
|
|
92
|
+
root = doc.xml("word/comments.xml")
|
|
93
|
+
ident = int(explicit_id) if explicit_id and explicit_id.isdigit() else doc.next_id()
|
|
94
|
+
comment = etree.SubElement(root, kit.q("comment"))
|
|
95
|
+
comment.set(kit.q("id"), str(ident))
|
|
96
|
+
comment.set(kit.q("author"), author)
|
|
97
|
+
comment.set(kit.q("initials"), author[:2].upper())
|
|
98
|
+
comment.set(kit.q("date"), date)
|
|
99
|
+
body = etree.SubElement(comment, kit.q("p"))
|
|
100
|
+
run = etree.SubElement(body, kit.q("r"))
|
|
101
|
+
node = etree.SubElement(run, kit.q("t"))
|
|
102
|
+
node.text = text
|
|
103
|
+
node.set(kit.q("space"), kit.XML)
|
|
104
|
+
|
|
105
|
+
start = etree.Element(kit.q("commentRangeStart"))
|
|
106
|
+
start.set(kit.q("id"), str(ident))
|
|
107
|
+
position = 1 if paragraph.find(kit.q("pPr")) is not None else 0
|
|
108
|
+
paragraph.insert(position, start)
|
|
109
|
+
end = etree.SubElement(paragraph, kit.q("commentRangeEnd"))
|
|
110
|
+
end.set(kit.q("id"), str(ident))
|
|
111
|
+
ref_run = etree.SubElement(paragraph, kit.q("r"))
|
|
112
|
+
etree.SubElement(ref_run, kit.q("commentReference")).set(kit.q("id"), str(ident))
|
|
113
|
+
return "comment %d anchored" % ident
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def header_date(doc: kit.Docx, text: str) -> str:
|
|
117
|
+
part = doc.ensure_header_footer("header")
|
|
118
|
+
paragraph = doc.xml(part).find(".//" + kit.q("p"))
|
|
119
|
+
if paragraph is None:
|
|
120
|
+
paragraph = etree.SubElement(doc.xml(part), kit.q("p"))
|
|
121
|
+
props = paragraph.find(kit.q("pPr"))
|
|
122
|
+
if props is None:
|
|
123
|
+
props = etree.Element(kit.q("pPr"))
|
|
124
|
+
paragraph.insert(0, props)
|
|
125
|
+
for existing in props.findall(kit.q("jc")):
|
|
126
|
+
props.remove(existing)
|
|
127
|
+
etree.SubElement(props, kit.q("jc")).set(kit.q("val"), "right")
|
|
128
|
+
kit.set_paragraph_text(paragraph, text)
|
|
129
|
+
return "header set in %s" % part
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def page_numbers(doc: kit.Docx) -> str:
|
|
133
|
+
part = doc.ensure_header_footer("footer")
|
|
134
|
+
paragraph = doc.xml(part).find(".//" + kit.q("p"))
|
|
135
|
+
if paragraph is None:
|
|
136
|
+
paragraph = etree.SubElement(doc.xml(part), kit.q("p"))
|
|
137
|
+
props = paragraph.find(kit.q("pPr"))
|
|
138
|
+
if props is None:
|
|
139
|
+
props = etree.Element(kit.q("pPr"))
|
|
140
|
+
paragraph.insert(0, props)
|
|
141
|
+
etree.SubElement(props, kit.q("jc")).set(kit.q("val"), "center")
|
|
142
|
+
for run in kit.field_runs("PAGE", "1"):
|
|
143
|
+
paragraph.append(run)
|
|
144
|
+
return "PAGE field added to %s" % part
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def hyperlink_first(doc: kit.Docx, url: str) -> str:
|
|
148
|
+
for paragraph in doc.paragraphs():
|
|
149
|
+
runs = [child for child in paragraph if child.tag == kit.q("r") and kit.text_of(child).strip()]
|
|
150
|
+
if not runs:
|
|
151
|
+
continue
|
|
152
|
+
rid = doc.ensure_rel("word/document.xml", REL_HYPERLINK, url, external=True)
|
|
153
|
+
link = etree.Element(kit.q("hyperlink"))
|
|
154
|
+
link.set(kit.q("id", kit.R), rid)
|
|
155
|
+
position = paragraph.index(runs[0])
|
|
156
|
+
for run in runs:
|
|
157
|
+
paragraph.remove(run)
|
|
158
|
+
link.append(run)
|
|
159
|
+
paragraph.insert(position, link)
|
|
160
|
+
return "wrapped %d run(s) in hyperlink %s" % (len(runs), url)
|
|
161
|
+
return ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def main(argv: list[str] | None = None) -> int:
|
|
165
|
+
parser = argparse.ArgumentParser(description="Apply targeted OOXML patches that python-docx cannot express.")
|
|
166
|
+
parser.add_argument("docx", help="Input .docx")
|
|
167
|
+
parser.add_argument("--out", default=None, help="Output .docx (default: overwrite the input)")
|
|
168
|
+
parser.add_argument("--enable-track", action="store_true")
|
|
169
|
+
parser.add_argument("--author", default="dsh-office")
|
|
170
|
+
parser.add_argument("--date", default=None, help="ISO timestamp for revisions and comments")
|
|
171
|
+
parser.add_argument("--tracked-replace-ins-id", default=None)
|
|
172
|
+
parser.add_argument("--new-text", default="")
|
|
173
|
+
parser.add_argument("--add-comment", action="store_true")
|
|
174
|
+
parser.add_argument("--comment-text", default="")
|
|
175
|
+
parser.add_argument("--contains", default=None, help="Target paragraph containing this text")
|
|
176
|
+
parser.add_argument("--indent-left-twips", type=int, default=None, help="Target paragraph with this left indent")
|
|
177
|
+
parser.add_argument("--comment-id", default=None)
|
|
178
|
+
parser.add_argument("--header-date", nargs="?", const="auto", default=None, help="Right-aligned header line; omit the value to use today's date")
|
|
179
|
+
parser.add_argument("--add-page-numbers", action="store_true")
|
|
180
|
+
parser.add_argument("--hyperlink-first", default=None)
|
|
181
|
+
args = parser.parse_args(argv)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
doc = kit.Docx(args.docx)
|
|
185
|
+
except (OSError, KeyError) as exc:
|
|
186
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
187
|
+
return 2
|
|
188
|
+
|
|
189
|
+
actions = []
|
|
190
|
+
missing = []
|
|
191
|
+
when = stamp(args)
|
|
192
|
+
|
|
193
|
+
if args.enable_track:
|
|
194
|
+
actions.append(enable_track(doc))
|
|
195
|
+
if args.tracked_replace_ins_id is not None:
|
|
196
|
+
result = tracked_replace(doc, args.tracked_replace_ins_id, args.new_text, args.author, when)
|
|
197
|
+
actions.append(result) if result else missing.append("ins id %s" % args.tracked_replace_ins_id)
|
|
198
|
+
if args.add_comment:
|
|
199
|
+
paragraph = find_paragraph(doc, args.contains, args.indent_left_twips)
|
|
200
|
+
if paragraph is None:
|
|
201
|
+
missing.append("paragraph for --add-comment")
|
|
202
|
+
else:
|
|
203
|
+
actions.append(add_comment(doc, paragraph, args.comment_text, args.author, when, args.comment_id))
|
|
204
|
+
if args.header_date is not None:
|
|
205
|
+
from datetime import date
|
|
206
|
+
|
|
207
|
+
stamp_text = date.today().isoformat() if args.header_date == "auto" else args.header_date
|
|
208
|
+
actions.append(header_date(doc, stamp_text))
|
|
209
|
+
if args.add_page_numbers:
|
|
210
|
+
actions.append(page_numbers(doc))
|
|
211
|
+
if args.hyperlink_first:
|
|
212
|
+
result = hyperlink_first(doc, args.hyperlink_first)
|
|
213
|
+
actions.append(result) if result else missing.append("paragraph for --hyperlink-first")
|
|
214
|
+
|
|
215
|
+
if not actions and not missing:
|
|
216
|
+
parser.error("no patch requested")
|
|
217
|
+
|
|
218
|
+
out = args.out or args.docx
|
|
219
|
+
doc.save(out)
|
|
220
|
+
kit.emit({"ok": not missing, "output": str(Path(out)), "actions": actions, "missing": missing})
|
|
221
|
+
return 1 if missing else 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Export one table from a .docx to CSV.
|
|
3
|
+
|
|
4
|
+
Merged cells are expanded so the CSV has a rectangular shape: a horizontally
|
|
5
|
+
merged cell repeats its text across the spanned columns and a vertically merged
|
|
6
|
+
cell inherits the value from the row above. That keeps downstream spreadsheets
|
|
7
|
+
aligned instead of silently shifting columns.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 success, 1 table index out of range, 2 bad input.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import csv
|
|
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
|
+
|
|
23
|
+
def cell_span(cell) -> int:
|
|
24
|
+
props = cell.find(kit.q("tcPr"))
|
|
25
|
+
if props is None:
|
|
26
|
+
return 1
|
|
27
|
+
span = props.find(kit.q("gridSpan"))
|
|
28
|
+
if span is None:
|
|
29
|
+
return 1
|
|
30
|
+
try:
|
|
31
|
+
return max(1, int(span.get(kit.q("val")) or 1))
|
|
32
|
+
except ValueError:
|
|
33
|
+
return 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def vertical_merge(cell) -> str:
|
|
37
|
+
props = cell.find(kit.q("tcPr"))
|
|
38
|
+
if props is None:
|
|
39
|
+
return ""
|
|
40
|
+
merge = props.find(kit.q("vMerge"))
|
|
41
|
+
if merge is None:
|
|
42
|
+
return ""
|
|
43
|
+
return merge.get(kit.q("val")) or "continue"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def table_rows(table) -> list[list[str]]:
|
|
47
|
+
rows: list[list[str]] = []
|
|
48
|
+
grid_width = 0
|
|
49
|
+
for row in table.findall(kit.q("tr")):
|
|
50
|
+
values: list[str] = []
|
|
51
|
+
for cell in row.findall(kit.q("tc")):
|
|
52
|
+
text = " ".join(" ".join(kit.text_of(paragraph).split()) for paragraph in cell.findall(kit.q("p"))).strip()
|
|
53
|
+
span = cell_span(cell)
|
|
54
|
+
merge = vertical_merge(cell)
|
|
55
|
+
if merge == "continue":
|
|
56
|
+
column = len(values)
|
|
57
|
+
inherited = ""
|
|
58
|
+
if rows and column < len(rows[-1]):
|
|
59
|
+
inherited = rows[-1][column]
|
|
60
|
+
text = inherited
|
|
61
|
+
values.extend([text] * span)
|
|
62
|
+
grid_width = max(grid_width, len(values))
|
|
63
|
+
rows.append(values)
|
|
64
|
+
for row in rows:
|
|
65
|
+
while len(row) < grid_width:
|
|
66
|
+
row.append("")
|
|
67
|
+
return rows
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv: list[str] | None = None) -> int:
|
|
71
|
+
parser = argparse.ArgumentParser(description="Export a table from a .docx to CSV, expanding merged cells.")
|
|
72
|
+
parser.add_argument("docx", help="Input .docx")
|
|
73
|
+
parser.add_argument("--table_index", type=int, default=0, help="Zero-based table index")
|
|
74
|
+
parser.add_argument("--out", required=True, help="Output .csv path")
|
|
75
|
+
args = parser.parse_args(argv)
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
doc = kit.Docx(args.docx)
|
|
79
|
+
except (OSError, KeyError) as exc:
|
|
80
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
|
|
83
|
+
tables = list(doc.xml("word/document.xml").iter(kit.q("tbl")))
|
|
84
|
+
if args.table_index < 0 or args.table_index >= len(tables):
|
|
85
|
+
print("error: table_index %d out of range (document has %d)" % (args.table_index, len(tables)), file=sys.stderr)
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
rows = table_rows(tables[args.table_index])
|
|
89
|
+
out = Path(args.out)
|
|
90
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
with out.open("w", encoding="utf-8", newline="") as handle:
|
|
92
|
+
csv.writer(handle).writerows(rows)
|
|
93
|
+
|
|
94
|
+
kit.emit({"ok": True, "output": str(out), "tables": len(tables), "table_index": args.table_index, "rows": len(rows), "columns": len(rows[0]) if rows else 0})
|
|
95
|
+
return 0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Freeze SEQ/REF field results into plain text so renders match Word.
|
|
3
|
+
|
|
4
|
+
Headless renderers do not always evaluate fields, so a caption may show "Table"
|
|
5
|
+
with no number. The cached result is already inside the document; this script
|
|
6
|
+
throws away the field machinery and keeps that result as ordinary runs.
|
|
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
|
+
|
|
23
|
+
def freeze(doc: kit.Docx, allowed: set[str]) -> int:
|
|
24
|
+
flattened = 0
|
|
25
|
+
for part in doc.text_parts():
|
|
26
|
+
for paragraph in doc.paragraphs(part):
|
|
27
|
+
for field in reversed(kit.paragraph_fields(paragraph)):
|
|
28
|
+
if field["type"] not in allowed:
|
|
29
|
+
continue
|
|
30
|
+
span = field["span"]
|
|
31
|
+
if not span:
|
|
32
|
+
continue
|
|
33
|
+
first = span[0]
|
|
34
|
+
rpr = first.find(kit.q("rPr")) if first.tag == kit.q("r") else None
|
|
35
|
+
position = paragraph.index(first)
|
|
36
|
+
for node in span:
|
|
37
|
+
if node.getparent() is paragraph:
|
|
38
|
+
paragraph.remove(node)
|
|
39
|
+
if field["result"]:
|
|
40
|
+
run = kit.make_run(field["result"], etree.fromstring(etree.tostring(rpr)) if rpr is not None else None)
|
|
41
|
+
paragraph.insert(position, run)
|
|
42
|
+
flattened += 1
|
|
43
|
+
return flattened
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(argv: list[str] | None = None) -> int:
|
|
47
|
+
parser = argparse.ArgumentParser(description="Replace SEQ/REF fields with their cached result text.")
|
|
48
|
+
parser.add_argument("input_docx", help="Input .docx")
|
|
49
|
+
parser.add_argument("--out", required=True, help="Output .docx")
|
|
50
|
+
parser.add_argument("--only", nargs="+", default=["SEQ", "REF"], choices=["SEQ", "REF"], help="Field types to freeze")
|
|
51
|
+
args = parser.parse_args(argv)
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
doc = kit.Docx(args.input_docx)
|
|
55
|
+
except (OSError, KeyError) as exc:
|
|
56
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
|
|
59
|
+
flattened = freeze(doc, set(args.only))
|
|
60
|
+
doc.save(args.out)
|
|
61
|
+
kit.emit({"ok": True, "output": str(Path(args.out)), "only": args.only, "fields_frozen": flattened})
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""List the Word fields in a .docx and show what each one asks for.
|
|
3
|
+
|
|
4
|
+
Fields are the dynamic bits Word fills at layout time: ``TOC``, ``SEQ``, ``REF``,
|
|
5
|
+
``PAGEREF``, ``HYPERLINK``, ``DATE`` and friends. Each is reported once with its
|
|
6
|
+
instruction, cached result, and the part it lives in, so a headless workflow can
|
|
7
|
+
tell whether a document depends on a refresh before rendering.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 success (also when there are no fields), 2 bad input or missing part.
|
|
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
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
parser = argparse.ArgumentParser(description="Report Word fields (TOC, SEQ, REF, PAGEREF, HYPERLINK, ...) in a .docx.")
|
|
24
|
+
parser.add_argument("docx", help="Input .docx")
|
|
25
|
+
parser.add_argument("--max_examples", type=int, default=8, help="How many sample instructions to keep per field type")
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
doc = kit.Docx(args.docx)
|
|
30
|
+
except (OSError, KeyError) as exc:
|
|
31
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
32
|
+
return 2
|
|
33
|
+
|
|
34
|
+
counts: dict[str, int] = {}
|
|
35
|
+
examples: dict[str, list[dict]] = {}
|
|
36
|
+
kinds: dict[str, int] = {}
|
|
37
|
+
for part, paragraph, field in kit.iter_fields(doc):
|
|
38
|
+
name = field["type"] or "UNKNOWN"
|
|
39
|
+
counts[name] = counts.get(name, 0) + 1
|
|
40
|
+
kinds[field["kind"]] = kinds.get(field["kind"], 0) + 1
|
|
41
|
+
bucket = examples.setdefault(name, [])
|
|
42
|
+
if len(bucket) < args.max_examples:
|
|
43
|
+
bucket.append({"part": part, "instr": field["instr"], "result": field["result"][:80]})
|
|
44
|
+
|
|
45
|
+
kit.emit(
|
|
46
|
+
{
|
|
47
|
+
"ok": True,
|
|
48
|
+
"input": str(Path(args.docx)),
|
|
49
|
+
"total": sum(counts.values()),
|
|
50
|
+
"counts": counts,
|
|
51
|
+
"kinds": kinds,
|
|
52
|
+
"examples": examples,
|
|
53
|
+
}
|
|
54
|
+
)
|
|
55
|
+
return 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Replace REF and PAGEREF fields with the text they resolve to.
|
|
3
|
+
|
|
4
|
+
A focused subset of field freezing: cross-reference fields are the ones that
|
|
5
|
+
most often render blank outside Word, so this script rewrites only those and
|
|
6
|
+
leaves TOC, SEQ and date fields untouched.
|
|
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
|
+
TARGETS = {"REF", "PAGEREF"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv: list[str] | None = None) -> int:
|
|
26
|
+
parser = argparse.ArgumentParser(description="Flatten REF/PAGEREF cross-reference fields to literal text.")
|
|
27
|
+
parser.add_argument("in_docx", help="Input .docx")
|
|
28
|
+
parser.add_argument("--out", required=True, help="Output .docx")
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
doc = kit.Docx(args.in_docx)
|
|
33
|
+
except (OSError, KeyError) as exc:
|
|
34
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
35
|
+
return 2
|
|
36
|
+
|
|
37
|
+
flattened = 0
|
|
38
|
+
for part in doc.text_parts():
|
|
39
|
+
for paragraph in doc.paragraphs(part):
|
|
40
|
+
for field in reversed(kit.paragraph_fields(paragraph)):
|
|
41
|
+
if field["type"] not in TARGETS or not field["span"]:
|
|
42
|
+
continue
|
|
43
|
+
first = field["span"][0]
|
|
44
|
+
rpr = first.find(kit.q("rPr")) if first.tag == kit.q("r") else None
|
|
45
|
+
position = paragraph.index(first)
|
|
46
|
+
for node in field["span"]:
|
|
47
|
+
if node.getparent() is paragraph:
|
|
48
|
+
paragraph.remove(node)
|
|
49
|
+
if field["result"]:
|
|
50
|
+
paragraph.insert(
|
|
51
|
+
position,
|
|
52
|
+
kit.make_run(field["result"], etree.fromstring(etree.tostring(rpr)) if rpr is not None else None),
|
|
53
|
+
)
|
|
54
|
+
flattened += 1
|
|
55
|
+
|
|
56
|
+
doc.save(args.out)
|
|
57
|
+
kit.emit({"ok": True, "output": str(Path(args.out)), "flattened": flattened})
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Report footnotes and endnotes stored in a .docx.
|
|
3
|
+
|
|
4
|
+
Real notes are not footer text: they live in word/footnotes.xml or
|
|
5
|
+
word/endnotes.xml, and the body only carries a reference run. This script
|
|
6
|
+
prints every note with its id, kind and text, plus how many references the body
|
|
7
|
+
makes, which is how you catch notes that were orphaned by an edit.
|
|
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
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
19
|
+
import docx_kit as kit
|
|
20
|
+
|
|
21
|
+
PARTS = {"footnote": "word/footnotes.xml", "endnote": "word/endnotes.xml"}
|
|
22
|
+
SEPARATOR_TYPES = {"separator", "continuationSeparator", "continuationNotice"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def collect(doc: kit.Docx, kind: str) -> list[dict]:
|
|
26
|
+
part = PARTS[kind]
|
|
27
|
+
if not doc.exists(part):
|
|
28
|
+
return []
|
|
29
|
+
notes = []
|
|
30
|
+
for node in doc.xml(part).iter(kit.q(kind)):
|
|
31
|
+
note_type = node.get(kit.q("type")) or "normal"
|
|
32
|
+
if note_type in SEPARATOR_TYPES:
|
|
33
|
+
continue
|
|
34
|
+
paragraphs = [kit.text_of(paragraph).strip() for paragraph in node.iter(kit.q("p"))]
|
|
35
|
+
notes.append(
|
|
36
|
+
{
|
|
37
|
+
"kind": kind,
|
|
38
|
+
"id": node.get(kit.q("id")),
|
|
39
|
+
"text": " ".join(text for text in paragraphs if text),
|
|
40
|
+
"empty": not any(text for text in paragraphs),
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
return notes
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def references(doc: kit.Docx, kind: str) -> list[str]:
|
|
47
|
+
return [node.get(kit.q("id")) for node in doc.xml("word/document.xml").iter(kit.q(kind + "Reference"))]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main(argv: list[str] | None = None) -> int:
|
|
51
|
+
parser = argparse.ArgumentParser(description="Report footnote/endnote content and reference counts in a .docx.")
|
|
52
|
+
parser.add_argument("docx", help="Input .docx")
|
|
53
|
+
args = parser.parse_args(argv)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
doc = kit.Docx(args.docx)
|
|
57
|
+
except (OSError, KeyError) as exc:
|
|
58
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
59
|
+
return 2
|
|
60
|
+
|
|
61
|
+
payload = {"ok": True, "input": str(Path(args.docx)), "notes": {}, "references": {}, "orphans": {}}
|
|
62
|
+
for kind in ("footnote", "endnote"):
|
|
63
|
+
notes = collect(doc, kind)
|
|
64
|
+
refs = references(doc, kind)
|
|
65
|
+
ids = {note["id"] for note in notes}
|
|
66
|
+
payload["notes"][kind] = notes
|
|
67
|
+
payload["references"][kind] = {"count": len(refs), "ids": refs}
|
|
68
|
+
payload["orphans"][kind] = {
|
|
69
|
+
"unreferenced_notes": sorted(ids - set(refs), key=lambda value: int(value) if (value or "").lstrip("-").isdigit() else 0),
|
|
70
|
+
"dangling_references": [value for value in refs if value not in ids],
|
|
71
|
+
}
|
|
72
|
+
kit.emit(payload)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Undo the heading damage left by importing a document from a web editor.
|
|
3
|
+
|
|
4
|
+
Imports commonly arrive with a run of empty paragraphs before the body starts and
|
|
5
|
+
with the document title duplicated as a level-1 heading, which makes the outline
|
|
6
|
+
read "My Doc" twice and pushes the real first section down. The cleanup only
|
|
7
|
+
touches the leading region, so a deliberate title page further in is untouched.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 changed or nothing to change, 1 --check found problems, 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
|
+
|
|
22
|
+
def leading_region(body, limit: int) -> list:
|
|
23
|
+
seen = 0
|
|
24
|
+
region = []
|
|
25
|
+
for child in body:
|
|
26
|
+
if child.tag == kit.q("p") and kit.paragraph_text(child).strip():
|
|
27
|
+
seen += 1
|
|
28
|
+
if seen >= limit:
|
|
29
|
+
region.append(child)
|
|
30
|
+
break
|
|
31
|
+
region.append(child)
|
|
32
|
+
return region
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sanitize(body, limit: int) -> dict:
|
|
36
|
+
removed_empty = 0
|
|
37
|
+
removed_duplicate = 0
|
|
38
|
+
region = leading_region(body, limit)
|
|
39
|
+
for paragraph in list(region):
|
|
40
|
+
if paragraph.tag != kit.q("p") or kit.paragraph_text(paragraph).strip():
|
|
41
|
+
continue
|
|
42
|
+
parent = paragraph.getparent()
|
|
43
|
+
if parent is not None:
|
|
44
|
+
parent.remove(paragraph)
|
|
45
|
+
removed_empty += 1
|
|
46
|
+
|
|
47
|
+
region = leading_region(body, limit)
|
|
48
|
+
for index, paragraph in enumerate(region):
|
|
49
|
+
if paragraph.tag != kit.q("p") or kit.paragraph_style(paragraph) != "Title":
|
|
50
|
+
continue
|
|
51
|
+
title = kit.paragraph_text(paragraph).strip()
|
|
52
|
+
for follower in region[index + 1 :]:
|
|
53
|
+
if follower.tag != kit.q("p"):
|
|
54
|
+
continue
|
|
55
|
+
text = kit.paragraph_text(follower).strip()
|
|
56
|
+
if not text:
|
|
57
|
+
continue
|
|
58
|
+
if text == title and kit.heading_level(follower) == 1:
|
|
59
|
+
body.remove(follower)
|
|
60
|
+
removed_duplicate += 1
|
|
61
|
+
break
|
|
62
|
+
return {"removed_empty": removed_empty, "removed_duplicate_heading": removed_duplicate}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(argv: list[str] | None = None) -> int:
|
|
66
|
+
parser = argparse.ArgumentParser(description="Remove leading empty paragraphs and a duplicated title heading after import.")
|
|
67
|
+
parser.add_argument("input_docx", help="Input .docx")
|
|
68
|
+
parser.add_argument("--out", default=None, help="Output .docx")
|
|
69
|
+
parser.add_argument("--in-place", action="store_true", help="Rewrite the input file")
|
|
70
|
+
parser.add_argument("--check", action="store_true", help="Report only; exit 1 when cleanup is needed")
|
|
71
|
+
parser.add_argument("--leading-nonempty-paragraphs", type=int, default=3, help="How far into the document to look")
|
|
72
|
+
args = parser.parse_args(argv)
|
|
73
|
+
|
|
74
|
+
if not (args.out or args.in_place or args.check):
|
|
75
|
+
parser.error("choose --out, --in-place or --check")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
doc = kit.Docx(args.input_docx)
|
|
79
|
+
except (OSError, KeyError) as exc:
|
|
80
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
|
|
83
|
+
result = sanitize(doc.body(), max(1, args.leading_nonempty_paragraphs))
|
|
84
|
+
changed = bool(result["removed_empty"] or result["removed_duplicate_heading"])
|
|
85
|
+
|
|
86
|
+
output = None
|
|
87
|
+
if args.in_place or args.out:
|
|
88
|
+
output = args.out or args.input_docx
|
|
89
|
+
doc.save(output)
|
|
90
|
+
|
|
91
|
+
kit.emit({"ok": not (args.check and changed), "changed": changed, "check": args.check, "output": str(Path(output)) if output else None, **result})
|
|
92
|
+
return 1 if (args.check and changed) else 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
if __name__ == "__main__":
|
|
96
|
+
raise SystemExit(main())
|