dsh-office-kit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY.md +147 -68
- package/README.md +3 -1
- package/assets/skills/excel-live-control/SKILL.md +234 -0
- package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
- package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
- package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
- package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
- package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
- package/assets/skills/excel-live-control/references/office-automation.md +207 -0
- package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
- package/assets/skills/excel-live-control/style_guidelines.md +87 -0
- package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
- package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
- package/assets/skills/office-documents/SKILL.md +90 -77
- package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
- package/assets/skills/office-documents/ooxml/comments.md +40 -0
- package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
- package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
- package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
- package/assets/skills/office-documents/references/docx-structure.md +83 -0
- package/assets/skills/office-documents/references/template-elicitation.md +49 -0
- package/assets/skills/office-documents/references/writing-quality.md +49 -0
- package/assets/skills/office-documents/render_docx.py +92 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
- package/assets/skills/office-documents/scripts/comments_add.py +136 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
- package/assets/skills/office-documents/scripts/content_controls.py +192 -0
- package/assets/skills/office-documents/scripts/core_properties.py +124 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
- package/assets/skills/office-documents/scripts/fields_report.py +59 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
- package/assets/skills/office-documents/scripts/images_audit.py +80 -0
- package/assets/skills/office-documents/scripts/insert_note.py +126 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
- package/assets/skills/office-documents/scripts/replace_text.py +154 -0
- package/assets/skills/office-documents/scripts/section_audit.py +91 -0
- package/assets/skills/office-documents/scripts/set_protection.py +92 -0
- package/assets/skills/office-documents/scripts/style_lint.py +111 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
- package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
- package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
- package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
- package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
- package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
- package/assets/skills/office-documents/tasks/create_edit.md +107 -0
- package/assets/skills/office-documents/tasks/equations.md +59 -0
- package/assets/skills/office-documents/tasks/fields_update.md +44 -0
- package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
- package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
- package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
- package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
- package/assets/skills/office-documents/tasks/images_figures.md +63 -0
- package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
- package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
- package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
- package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
- package/assets/skills/office-documents/tasks/read_review.md +50 -0
- package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
- package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
- package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
- package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
- package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
- package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
- package/assets/skills/office-documents/tasks/verify_render.md +50 -0
- package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
- package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
- package/assets/skills/office-pdf/SKILL.md +73 -56
- package/assets/skills/office-pdf/references/form-fields.md +56 -0
- package/assets/skills/office-pdf/references/geometry.md +44 -0
- package/assets/skills/office-pdf/references/layout-quality.md +41 -0
- package/assets/skills/office-pdf/references/limits.md +58 -0
- package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
- package/assets/skills/office-pdf/scripts/forms.py +556 -0
- package/assets/skills/office-pdf/scripts/metadata.py +161 -0
- package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
- package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
- package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
- package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
- package/assets/skills/office-pdf/tasks/annotations.md +98 -0
- package/assets/skills/office-pdf/tasks/create.md +75 -0
- package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
- package/assets/skills/office-pdf/tasks/forms.md +67 -0
- package/assets/skills/office-pdf/tasks/read.md +62 -0
- package/assets/skills/office-pdf/tasks/redact.md +72 -0
- package/assets/skills/office-pdf/tasks/stamp.md +60 -0
- package/assets/skills/office-presentations/SKILL.md +141 -87
- package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
- package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
- package/assets/skills/office-presentations/recipes/README.md +71 -0
- package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
- package/assets/skills/office-presentations/recipes/connectors.md +83 -0
- package/assets/skills/office-presentations/recipes/images.md +80 -0
- package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
- package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
- package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
- package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
- package/assets/skills/office-presentations/recipes/tables.md +88 -0
- package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
- package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
- package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
- package/assets/skills/office-presentations/references/finalization.md +63 -0
- package/assets/skills/office-presentations/references/native-bullets.md +78 -0
- package/assets/skills/office-presentations/references/native-evidence.md +67 -0
- package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
- package/assets/skills/office-presentations/references/templates.md +76 -0
- package/assets/skills/office-presentations/routing/google_slides.md +45 -0
- package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
- package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
- package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
- package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
- package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
- package/assets/skills/office-presentations/tasks/template-following.md +132 -0
- package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
- package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
- package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
- package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
- package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
- package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
- package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
- package/assets/skills/office-spreadsheets/references/charts.md +63 -0
- package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
- package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
- package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
- package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
- package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
- package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
- package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
- package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
- package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
- package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
- package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
- package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
- package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
- package/assets/skills/shared/annotation-recipes.md +252 -0
- package/assets/skills/shared/check_runtime.py +253 -0
- package/assets/skills/shared/common-workflow.md +139 -0
- package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/index.js +239 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +171 -246
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Tracked changes
|
|
2
|
+
|
|
3
|
+
Tracked changes are markup, not a diff. The file contains both the old and the new text, wrapped in elements that say which is which, plus enough metadata for Word to show reviewer names and accept or reject each edit.
|
|
4
|
+
|
|
5
|
+
## The elements
|
|
6
|
+
|
|
7
|
+
| Element | Meaning |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `w:ins` | Text inserted (contains runs) |
|
|
10
|
+
| `w:del` | Text deleted (contains runs whose text is `w:delText`) |
|
|
11
|
+
| `w:moveFrom` / `w:moveTo` | Moved text, paired by `w:name` |
|
|
12
|
+
| `w:rPrChange` / `w:pPrChange` | Formatting change, with the previous properties inside |
|
|
13
|
+
| `w:delText` | The deleted characters — not `w:t` |
|
|
14
|
+
| `w:delInstrText` | A deleted field instruction |
|
|
15
|
+
|
|
16
|
+
Each carries `w:id`, `w:author`, `w:date`. Ids must be unique within the document; reusing one makes Word show the wrong reviewer.
|
|
17
|
+
|
|
18
|
+
A deletion is a `w:del` containing runs with `w:delText`, not a run with a strikethrough:
|
|
19
|
+
|
|
20
|
+
```xml
|
|
21
|
+
<w:del w:id="101" w:author="dsh-office" w:date="2026-10-21T00:00:00Z">
|
|
22
|
+
<w:r><w:delText>Revenue grew</w:delText></w:r>
|
|
23
|
+
</w:del>
|
|
24
|
+
<w:ins w:id="102" w:author="dsh-office" w:date="2026-10-21T00:00:00Z">
|
|
25
|
+
<w:r><w:t>Revenue climbed</w:t></w:r>
|
|
26
|
+
</w:ins>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Tracking has to be on
|
|
30
|
+
|
|
31
|
+
`<w:trackRevisions/>` in `word/settings.xml` makes *Word* track subsequent edits. It does not affect markup already in the file. Write the markup directly when you are generating the change; turn tracking on when the user will continue editing.
|
|
32
|
+
|
|
33
|
+
## Accepting and rejecting
|
|
34
|
+
|
|
35
|
+
- **Accept** — keep `w:ins` content and unwrap it; delete `w:del` entirely; for moves keep `w:moveTo`.
|
|
36
|
+
- **Reject** — delete `w:ins` content; keep `w:del` content and convert `w:delText` back to `w:t` (unwrap the `w:del`).
|
|
37
|
+
- Either way, remove the revision containers afterwards; leaving an empty `w:ins` shows a stray marker.
|
|
38
|
+
- Format-change elements have no accept/reject choice in the content sense: accepted means the new properties stay, rejected means the old ones are restored.
|
|
39
|
+
|
|
40
|
+
## Where tracking hides
|
|
41
|
+
|
|
42
|
+
- Inside headers, footers, footnotes, endnotes, table cells, text boxes.
|
|
43
|
+
- Table-level changes: `w:trPr/w:ins` for an inserted row, `w:tcPr/w:cellIns` for a cell.
|
|
44
|
+
- Paragraph-level: a paragraph mark inserted or deleted uses `w:rPr/w:ins` in `w:pPr`, so "delete this paragraph" is not a `w:del` around the paragraph.
|
|
45
|
+
- Comments are separate markup; a comment can survive its being accepted.
|
|
46
|
+
|
|
47
|
+
## Reading markup without a renderer
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
"<python>" "<skill>/scripts/accept_tracked_changes.py" "<file.docx>" --mode report
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The report lists insertions and deletions with authors and the text on each side. That is the honest way to answer "what changed" when there is no rendered view, and it is also how you confirm a generated change landed as markup rather than as plain text.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# DOCX structure reference
|
|
2
|
+
|
|
3
|
+
## The package
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
[Content_Types].xml extensions and per-part overrides
|
|
7
|
+
_rels/.rels package relationships (document, core props)
|
|
8
|
+
docProps/core.xml author, title, subject, keywords, revision
|
|
9
|
+
docProps/app.xml application, company, manager
|
|
10
|
+
word/document.xml the body: paragraphs, tables, sections
|
|
11
|
+
word/styles.xml paragraph/character/table style definitions
|
|
12
|
+
word/numbering.xml abstract lists and their num instances
|
|
13
|
+
word/settings.xml protection, tracking, updateFields
|
|
14
|
+
word/theme/theme1.xml fonts and colour scheme
|
|
15
|
+
word/header1.xml, footer1.xml
|
|
16
|
+
word/media/* images
|
|
17
|
+
word/comments.xml, footnotes.xml, endnotes.xml optional
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## The body
|
|
21
|
+
|
|
22
|
+
`word/document.xml` is a sequence of block-level elements: `w:p` (paragraph), `w:tbl` (table), `w:sdt` (content control), and finally one `w:sectPr` per section (the last one sits in the body, earlier ones inside a paragraph's `pPr`).
|
|
23
|
+
|
|
24
|
+
A paragraph is:
|
|
25
|
+
|
|
26
|
+
```xml
|
|
27
|
+
<w:p>
|
|
28
|
+
<w:pPr> <!-- paragraph properties -->
|
|
29
|
+
<w:pStyle w:val="Heading1"/>
|
|
30
|
+
<w:spacing w:before="240" w:after="80"/>
|
|
31
|
+
<w:keepNext/>
|
|
32
|
+
<w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr>
|
|
33
|
+
<w:jc w:val="center"/>
|
|
34
|
+
</w:pPr>
|
|
35
|
+
<w:r> <!-- run -->
|
|
36
|
+
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr> <!-- sz is half-points: 24 = 12pt -->
|
|
37
|
+
<w:t xml:space="preserve">text</w:t>
|
|
38
|
+
</w:r>
|
|
39
|
+
</w:p>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Everything a reader sees is a run inside a paragraph; everything that makes it look different from its style is direct formatting in `pPr`/`rPr`. Direct formatting is why `style_normalize.py` exists.
|
|
43
|
+
|
|
44
|
+
A table is `w:tbl` > `w:tblPr` (style, width, layout) + `w:tblGrid` (column widths) + `w:tr` > `w:tc` > `w:p`. A cell always contains at least one paragraph.
|
|
45
|
+
|
|
46
|
+
## Units
|
|
47
|
+
|
|
48
|
+
| Unit | Where | Conversion |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| twip (1/1440 in) | margins, widths, indents, cell widths | 1440 per inch |
|
|
51
|
+
| half-point | `w:sz` | 2 per point |
|
|
52
|
+
| eighth-point | border widths (`w:sz` on borders) | 8 per point |
|
|
53
|
+
| EMU (914400/in) | drawing extents | 914400 per inch |
|
|
54
|
+
|
|
55
|
+
A Letter page is 12240×15840 twips. Text width = page width − left − right margins (default 9360 twips with 1in margins).
|
|
56
|
+
|
|
57
|
+
## Styles
|
|
58
|
+
|
|
59
|
+
`w:style` with `w:type` of `paragraph`, `character`, `table` or `numbering`. Attributes: `w:styleId` (the internal name), `w:name` (the UI name), `w:basedOn`, `w:next`, `w:link`, plus `w:pPr`/`w:rPr`.
|
|
60
|
+
|
|
61
|
+
- `Normal` is the root; `Heading1`..`Heading9` drive the outline level and the TOC.
|
|
62
|
+
- Outline level can also be set explicitly with `w:outlineLvl` (0-based) in `pPr`; a paragraph can look like a heading without being one, and the TOC will not see it.
|
|
63
|
+
- Latent styles do not exist in the file until used; a document may reference `Heading3` while `styles.xml` has no definition for it.
|
|
64
|
+
|
|
65
|
+
## Numbering
|
|
66
|
+
|
|
67
|
+
`word/numbering.xml` holds `w:abstractNum` (the list definition, per level: format, text, indentation) and `w:num` (`w:numId` → `w:abstractNumId`, optionally overridden). A paragraph joins a list with `w:numPr` referencing a `numId`. Two paragraphs with the same `numId` share a counter.
|
|
68
|
+
|
|
69
|
+
## Sections
|
|
70
|
+
|
|
71
|
+
`w:sectPr` carries page size (`w:pgSz`), margins (`w:pgMar`), orientation (implied by width vs height, or `w:orient`), columns, and the header/footer references. A document with one section has one `sectPr`; a new section is a paragraph whose `pPr` contains the *previous* section's `sectPr`.
|
|
72
|
+
|
|
73
|
+
## Fields
|
|
74
|
+
|
|
75
|
+
Simple (`w:fldSimple`) and complex (`fldChar` begin/separate/end with `w:instrText`). The visible text is a cache. `w:updateFields` in `settings.xml` asks the editor to refresh on open. See `ooxml/hyperlinks_and_fields.md`.
|
|
76
|
+
|
|
77
|
+
## Revision markup
|
|
78
|
+
|
|
79
|
+
`w:ins`, `w:del` (with `w:delText`), `w:moveFrom`/`w:moveTo`, `w:rPrChange`/`w:pPrChange`, each with author and date. See `ooxml/tracked_changes.md`.
|
|
80
|
+
|
|
81
|
+
## Minimum valid package
|
|
82
|
+
|
|
83
|
+
`[Content_Types].xml`, `_rels/.rels`, `word/document.xml`, `word/_rels/document.xml.rels`. Word accepts a document without styles or settings; most libraries add them. When hand-building, write `[Content_Types].xml` first in the zip.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Eliciting a template
|
|
2
|
+
|
|
3
|
+
A user who says "use our house style" has a file somewhere. Getting it is worth one question; guessing produces a document that has to be redone.
|
|
4
|
+
|
|
5
|
+
## Ask for the artefact, not the description
|
|
6
|
+
|
|
7
|
+
Description ("blue headings, maybe 11pt") produces a different document every time. The artefact (`house-template.dotx`, an old `.docx` of the same kind, a PDF of the last report) carries the actual styles, margins and fonts.
|
|
8
|
+
|
|
9
|
+
One sentence is enough:
|
|
10
|
+
|
|
11
|
+
> Is there an existing document in this style I should match? Send the file (`.dotx`, or any `.docx` in that style) and I will follow it exactly.
|
|
12
|
+
|
|
13
|
+
If they have no file, ask for two or three specifics that cannot be guessed and choose the rest from `SKILL.md` defaults:
|
|
14
|
+
|
|
15
|
+
- page size and margins, if their organisation is not US Letter,
|
|
16
|
+
- the heading/body typeface and sizes,
|
|
17
|
+
- anything required by policy (logo in the header, classification mark in the footer).
|
|
18
|
+
|
|
19
|
+
## Read the template before using it
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
"<python>" "<cli>" docx read --input "<template.docx>"
|
|
23
|
+
"<python>" "<skill>/scripts/docx_kit.py" xml '<template.docx>' word/styles.xml
|
|
24
|
+
"<python>" "<skill>/scripts/section_audit.py" "<template.docx>"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
From `styles.xml`, note the `styleId` of what will be used: `Heading1`..`Heading3`, `Title`, `Caption`, `Quote`, `ListParagraph`. From the section audit, record page size, margins, header/footer text and whether the first page is different.
|
|
28
|
+
|
|
29
|
+
## Apply it
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
"<python>" "<skill>/scripts/apply_template_styles.py" --template "<template.docx>" --target "<draft.docx>" --out "<styled.docx>"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
This merges style definitions (and numbering) by id — the body content is untouched, so anything written with direct formatting keeps it. Follow with:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
"<python>" "<skill>/scripts/style_normalize.py" "<styled.docx>" --out "<styled-final.docx>" --clear_paragraph_format --enforce_heading_spacing
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Now the text picks up the template's look instead of the draft's.
|
|
42
|
+
|
|
43
|
+
## When the template is a PDF or an image
|
|
44
|
+
|
|
45
|
+
Extract structure, not styles: heading hierarchy, section order, table layout, caption convention, margin proportions. Then reproduce them with named styles in a generated document. Say which parts were matched approximately — a PDF does not tell you the font size of a heading, only how it looks.
|
|
46
|
+
|
|
47
|
+
## When there is no template and no details
|
|
48
|
+
|
|
49
|
+
Fall back to the defaults in `SKILL.md` (Letter, 1in margins, 11–12pt body, black headings, `#D9D9D9` table borders) and state the assumption in one line with the delivered file. Do not stall on a question the user may not care about.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Writing quality
|
|
2
|
+
|
|
3
|
+
Content quality is the part of a document the format cannot fix. These are the rules to apply while drafting, before any rendering.
|
|
4
|
+
|
|
5
|
+
## Structure before prose
|
|
6
|
+
|
|
7
|
+
- Every heading states a claim the reader can disagree with ("Services carried the quarter", not "Services"). A heading that only names a topic is a missed opportunity to be clear.
|
|
8
|
+
- A section that cannot be summarised in one sentence is two sections.
|
|
9
|
+
- Three levels of heading is the practical maximum; a fourth usually means the content should be a table or an appendix.
|
|
10
|
+
- Nothing between a heading and its first paragraph. No label line, no restated title.
|
|
11
|
+
|
|
12
|
+
## Paragraphs
|
|
13
|
+
|
|
14
|
+
- One idea per paragraph, first sentence carries it.
|
|
15
|
+
- 3–6 sentences is the comfortable range; a 1600-word paragraph is a formatting defect even if the prose is good.
|
|
16
|
+
- No em-dash pile-ups, no "furthermore/moreover" chains, no sentence that only announces the next sentence.
|
|
17
|
+
- The last paragraph of a section says what it meant, not that it is finished.
|
|
18
|
+
|
|
19
|
+
## Lists and tables
|
|
20
|
+
|
|
21
|
+
- Bullets for parallel items of equal weight; never for a sequence (that is a numbered list).
|
|
22
|
+
- If items have two attributes, a table beats a bulleted list with parentheticals.
|
|
23
|
+
- Table headers are nouns, units in the header ("Revenue ($m)"), numbers right-aligned.
|
|
24
|
+
- Every table earns its caption; a table without a purpose is a screenshot of data.
|
|
25
|
+
|
|
26
|
+
## Voice
|
|
27
|
+
|
|
28
|
+
- Active unless the actor is unknown or unimportant; "We found", not "It was found".
|
|
29
|
+
- Present tense for findings, past for actions taken.
|
|
30
|
+
- No unsupported superlatives. "18% growth" beats "strong growth"; if a number is unknown, say so rather than adding an adverb.
|
|
31
|
+
- Define an acronym once, then use it. Never define it twice in different places.
|
|
32
|
+
|
|
33
|
+
## Numbers
|
|
34
|
+
|
|
35
|
+
- Consistent precision. Do not mix "12.4%" and "8%" in one table unless the source did.
|
|
36
|
+
- Percentages below 1 take a leading zero (0.4%, not .4%).
|
|
37
|
+
- State the period and the currency in the table or its caption, not in the surrounding prose.
|
|
38
|
+
- A number in prose must match the number in the table; rendering does not catch this, re-reading does.
|
|
39
|
+
|
|
40
|
+
## What to cut
|
|
41
|
+
|
|
42
|
+
- Opening paragraphs that explain that the document will explain something.
|
|
43
|
+
- Closing paragraphs that thank the reader.
|
|
44
|
+
- Bold on every second phrase: emphasis used everywhere is emphasis used nowhere.
|
|
45
|
+
- Sentences that begin "It is important to note that".
|
|
46
|
+
|
|
47
|
+
## Where this connects
|
|
48
|
+
|
|
49
|
+
`references/docx-structure.md` covers how the structure is expressed in Word — styles, captions, table layout. `tasks/verify_render.md` covers checking the result on a page. Neither replaces reading the text once with the question "would a busy reader get the point from the headings alone?"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Render a .docx to page images through the helper CLI's render verb.
|
|
3
|
+
|
|
4
|
+
Rendering is the only honest check that a document looks right: XML can be valid
|
|
5
|
+
and still overlap, clip or reflow badly. This script keeps the plumbing in one
|
|
6
|
+
place so tasks can say "render it, look at the images" and mean a single command.
|
|
7
|
+
|
|
8
|
+
The render verb needs a real Word engine (Microsoft Word, WPS Writer, or
|
|
9
|
+
LibreOffice). When none is installed the script reports exactly that and exits 3,
|
|
10
|
+
because a placeholder image would be worse than no image.
|
|
11
|
+
|
|
12
|
+
Exit codes: 0 rendered, 1 render failed, 3 no renderer installed, 2 bad input.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent / "scripts"))
|
|
22
|
+
import docx_kit as kit
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def resize(path: Path, width: int | None, height: int | None) -> None:
|
|
26
|
+
try:
|
|
27
|
+
from PIL import Image
|
|
28
|
+
except ImportError:
|
|
29
|
+
return
|
|
30
|
+
if not (width or height):
|
|
31
|
+
return
|
|
32
|
+
with Image.open(path) as image:
|
|
33
|
+
size = (width or image.width, height or image.height)
|
|
34
|
+
image.resize(size).save(path)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: list[str] | None = None) -> int:
|
|
38
|
+
parser = argparse.ArgumentParser(description="Render a .docx to PNG page images (and optionally keep the PDF).")
|
|
39
|
+
parser.add_argument("input_docx", help="Input .docx")
|
|
40
|
+
parser.add_argument("--output_dir", default=None, help="Directory for page images (default: next to the input)")
|
|
41
|
+
parser.add_argument("--dpi", type=int, default=150)
|
|
42
|
+
parser.add_argument("--width", type=int, default=None, help="Resize page images to this width")
|
|
43
|
+
parser.add_argument("--height", type=int, default=None, help="Resize page images to this height")
|
|
44
|
+
parser.add_argument("--emit_pdf", action="store_true", help="Keep the intermediate PDF and report its path")
|
|
45
|
+
parser.add_argument("--verbose", action="store_true", help="Include the CLI's raw output")
|
|
46
|
+
args = parser.parse_args(argv)
|
|
47
|
+
|
|
48
|
+
source = Path(args.input_docx).expanduser().resolve()
|
|
49
|
+
if not source.is_file():
|
|
50
|
+
print("error: %s not found" % source, file=sys.stderr)
|
|
51
|
+
return 2
|
|
52
|
+
out_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else source.parent / (source.stem + "-render")
|
|
53
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
command = ["docx", "render", "--input", str(source), "--out-dir", str(out_dir)]
|
|
56
|
+
if args.dpi:
|
|
57
|
+
command += ["--dpi", str(args.dpi)]
|
|
58
|
+
code, stdout, stderr = kit.run_office_cli(command)
|
|
59
|
+
payload = kit.cli_json(stdout) or kit.cli_json(stderr) or {}
|
|
60
|
+
pages = payload.get("pages") or []
|
|
61
|
+
|
|
62
|
+
if code != 0 or not pages:
|
|
63
|
+
detail = payload.get("error") or payload.get("reason") or ""
|
|
64
|
+
if not detail:
|
|
65
|
+
lines = [line for line in (stderr or stdout or "").splitlines() if line.strip() not in ("", "{", "}")]
|
|
66
|
+
detail = lines[-1].strip() if lines else "render failed"
|
|
67
|
+
if "renderer" in detail.lower() or "libreoffice" in detail.lower():
|
|
68
|
+
kit.emit({"ok": False, "reason": "no_renderer", "detail": detail, "remedy": "Install Microsoft Office, WPS Office, or LibreOffice, then re-run this command."})
|
|
69
|
+
return 3
|
|
70
|
+
kit.emit({"ok": False, "reason": "render_failed", "detail": detail})
|
|
71
|
+
return 1
|
|
72
|
+
|
|
73
|
+
for page in pages:
|
|
74
|
+
resize(Path(page), args.width, args.height)
|
|
75
|
+
|
|
76
|
+
result = {
|
|
77
|
+
"ok": True,
|
|
78
|
+
"input": str(source),
|
|
79
|
+
"pages": pages,
|
|
80
|
+
"page_count": len(pages),
|
|
81
|
+
"renderer": payload.get("renderer"),
|
|
82
|
+
}
|
|
83
|
+
if args.emit_pdf and payload.get("pdf"):
|
|
84
|
+
result["pdf"] = payload["pdf"]
|
|
85
|
+
if args.verbose:
|
|
86
|
+
result["stdout"] = stdout.strip()
|
|
87
|
+
kit.emit(result)
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Accessibility audit for a .docx, with two safe repair switches.
|
|
3
|
+
|
|
4
|
+
Checks images for missing alternative text, tables for a repeating header row,
|
|
5
|
+
heading runs for skipped levels, links for vague link text, and the package for
|
|
6
|
+
a document title. Repairs are opt-in and never guess at meaning: alt text is
|
|
7
|
+
derived from the image file name and table headers come from the first row.
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 clean (or all findings repaired), 1 findings remain, 2 bad input.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import posixpath
|
|
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
|
+
GENERIC_LINK_TEXT = {"click here", "here", "link", "this link", "read more"}
|
|
23
|
+
DETAIL_PARTS = ("word/document.xml", "word/header1.xml", "word/footer1.xml")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def header_footer_parts(doc: kit.Docx) -> list[str]:
|
|
27
|
+
names = doc.names("word/header") + doc.names("word/footer")
|
|
28
|
+
return [name for name in names if name.endswith(".xml") and "_rels" not in name]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def scan_images(doc: kit.Docx) -> list[dict]:
|
|
32
|
+
findings = []
|
|
33
|
+
for part in ["word/document.xml"] + header_footer_parts(doc):
|
|
34
|
+
if not doc.exists(part):
|
|
35
|
+
continue
|
|
36
|
+
rels = {rel["id"]: rel for rel in doc.relationships(part)}
|
|
37
|
+
for docpr in doc.xml(part).iter(kit.q("docPr", kit.WP)):
|
|
38
|
+
blip = docpr.getparent().find(".//" + kit.q("blip", kit.A))
|
|
39
|
+
rid = blip.get(kit.q("embed", kit.R)) if blip is not None else None
|
|
40
|
+
target = rels.get(rid, {}).get("resolved", "")
|
|
41
|
+
alt = (docpr.get("descr") or docpr.get("title") or "").strip()
|
|
42
|
+
findings.append(
|
|
43
|
+
{
|
|
44
|
+
"code": "image_missing_alt",
|
|
45
|
+
"part": part,
|
|
46
|
+
"name": docpr.get("name"),
|
|
47
|
+
"target": target,
|
|
48
|
+
"ok": bool(alt),
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
return findings
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def scan_tables(doc: kit.Docx) -> list[dict]:
|
|
55
|
+
findings = []
|
|
56
|
+
for index, table in enumerate(doc.xml("word/document.xml").iter(kit.q("tbl"))):
|
|
57
|
+
rows = table.findall(kit.q("tr"))
|
|
58
|
+
if not rows:
|
|
59
|
+
continue
|
|
60
|
+
props = rows[0].find(kit.q("trPr"))
|
|
61
|
+
repeat = props is not None and props.find(kit.q("tblHeader")) is not None
|
|
62
|
+
findings.append(
|
|
63
|
+
{
|
|
64
|
+
"code": "table_missing_header_row",
|
|
65
|
+
"table_index": index,
|
|
66
|
+
"rows": len(rows),
|
|
67
|
+
"ok": repeat,
|
|
68
|
+
"style": (table.find(kit.q("tblPr")).find(kit.q("tblStyle")).get(kit.q("val")) if table.find(kit.q("tblPr")) is not None and table.find(kit.q("tblPr")).find(kit.q("tblStyle")) is not None else ""),
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
return findings
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def scan_headings(doc: kit.Docx) -> list[dict]:
|
|
75
|
+
findings = []
|
|
76
|
+
previous = 0
|
|
77
|
+
for index, paragraph in enumerate(doc.paragraphs()):
|
|
78
|
+
level = kit.heading_level(paragraph)
|
|
79
|
+
if not level:
|
|
80
|
+
continue
|
|
81
|
+
text = kit.paragraph_text(paragraph).strip()
|
|
82
|
+
if previous and level > previous + 1:
|
|
83
|
+
findings.append({"code": "heading_level_jump", "paragraph": index, "from": previous, "to": level, "text": text, "ok": False})
|
|
84
|
+
if not text:
|
|
85
|
+
findings.append({"code": "empty_heading", "paragraph": index, "level": level, "ok": False})
|
|
86
|
+
previous = level
|
|
87
|
+
return findings
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def scan_links(doc: kit.Docx) -> list[dict]:
|
|
91
|
+
findings = []
|
|
92
|
+
for part in ["word/document.xml"] + header_footer_parts(doc):
|
|
93
|
+
if not doc.exists(part):
|
|
94
|
+
continue
|
|
95
|
+
for link in doc.xml(part).iter(kit.q("hyperlink")):
|
|
96
|
+
text = kit.text_of(link).strip().lower()
|
|
97
|
+
if not text:
|
|
98
|
+
findings.append({"code": "empty_link", "part": part, "ok": False})
|
|
99
|
+
elif text in GENERIC_LINK_TEXT:
|
|
100
|
+
findings.append({"code": "vague_link_text", "part": part, "text": text, "ok": False})
|
|
101
|
+
return findings
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def scan_title(doc: kit.Docx) -> list[dict]:
|
|
105
|
+
if not doc.exists("docProps/core.xml"):
|
|
106
|
+
return [{"code": "missing_document_title", "ok": False}]
|
|
107
|
+
core = doc.xml("docProps/core.xml")
|
|
108
|
+
title = core.find("{http://purl.org/dc/elements/1.1/}title")
|
|
109
|
+
text = (title.text or "").strip() if title is not None else ""
|
|
110
|
+
return [{"code": "missing_document_title", "ok": bool(text)}] if not text else []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def fix_alt_text(doc: kit.Docx) -> int:
|
|
114
|
+
changed = 0
|
|
115
|
+
for part in ["word/document.xml"] + header_footer_parts(doc):
|
|
116
|
+
if not doc.exists(part):
|
|
117
|
+
continue
|
|
118
|
+
rels = {rel["id"]: rel for rel in doc.relationships(part)}
|
|
119
|
+
for docpr in doc.xml(part).iter(kit.q("docPr", kit.WP)):
|
|
120
|
+
if (docpr.get("descr") or "").strip():
|
|
121
|
+
continue
|
|
122
|
+
blip = docpr.getparent().find(".//" + kit.q("blip", kit.A))
|
|
123
|
+
rid = blip.get(kit.q("embed", kit.R)) if blip is not None else None
|
|
124
|
+
target = rels.get(rid, {}).get("resolved", "")
|
|
125
|
+
stem = posixpath.splitext(posixpath.basename(target))[0]
|
|
126
|
+
label = (stem or docpr.get("name") or "image").replace("_", " ").replace("-", " ").strip()
|
|
127
|
+
docpr.set("descr", label.title() or "Image")
|
|
128
|
+
changed += 1
|
|
129
|
+
return changed
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def fix_table_headers(doc: kit.Docx) -> int:
|
|
133
|
+
changed = 0
|
|
134
|
+
for table in doc.xml("word/document.xml").iter(kit.q("tbl")):
|
|
135
|
+
rows = table.findall(kit.q("tr"))
|
|
136
|
+
if not rows:
|
|
137
|
+
continue
|
|
138
|
+
props = rows[0].find(kit.q("trPr"))
|
|
139
|
+
if props is None:
|
|
140
|
+
props = rows[0].makeelement(kit.q("trPr"), {})
|
|
141
|
+
rows[0].insert(0, props)
|
|
142
|
+
if props.find(kit.q("tblHeader")) is None:
|
|
143
|
+
props.append(props.makeelement(kit.q("tblHeader"), {}))
|
|
144
|
+
changed += 1
|
|
145
|
+
return changed
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main(argv: list[str] | None = None) -> int:
|
|
149
|
+
parser = argparse.ArgumentParser(description="Audit a .docx for accessibility blockers, optionally repairing the two safe cases.")
|
|
150
|
+
parser.add_argument("in_docx", help="Input .docx")
|
|
151
|
+
parser.add_argument("--fix_image_alt", choices=["from_filename"], help="Derive missing alt text from the image file name")
|
|
152
|
+
parser.add_argument("--fix_table_headers", choices=["first_row"], help="Mark the first row as a repeating header")
|
|
153
|
+
parser.add_argument("--out", help="Write the repaired .docx here (required with any --fix)")
|
|
154
|
+
parser.add_argument("--out_json", help="Write the full report JSON here instead of stdout")
|
|
155
|
+
args = parser.parse_args(argv)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
doc = kit.Docx(args.in_docx)
|
|
159
|
+
except (OSError, KeyError) as exc:
|
|
160
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
161
|
+
return 2
|
|
162
|
+
|
|
163
|
+
findings = scan_images(doc) + scan_tables(doc) + scan_headings(doc) + scan_links(doc) + scan_title(doc)
|
|
164
|
+
issues = [item for item in findings if not item["ok"]]
|
|
165
|
+
|
|
166
|
+
repairs = {}
|
|
167
|
+
if args.fix_image_alt or args.fix_table_headers:
|
|
168
|
+
if not args.out:
|
|
169
|
+
parser.error("--out is required when applying fixes")
|
|
170
|
+
if args.fix_image_alt:
|
|
171
|
+
repairs["image_alt"] = fix_alt_text(doc)
|
|
172
|
+
if args.fix_table_headers:
|
|
173
|
+
repairs["table_headers"] = fix_table_headers(doc)
|
|
174
|
+
if repairs and args.out:
|
|
175
|
+
doc.save(args.out)
|
|
176
|
+
issues = [item for item in (scan_images(doc) + scan_tables(doc) + scan_headings(doc) + scan_links(doc) + scan_title(doc)) if not item["ok"]]
|
|
177
|
+
|
|
178
|
+
counts: dict[str, int] = {}
|
|
179
|
+
for item in issues:
|
|
180
|
+
counts[item["code"]] = counts.get(item["code"], 0) + 1
|
|
181
|
+
report = {
|
|
182
|
+
"ok": not issues,
|
|
183
|
+
"input": str(Path(args.in_docx)),
|
|
184
|
+
"output": str(Path(args.out)) if args.out and repairs else None,
|
|
185
|
+
"repairs": repairs,
|
|
186
|
+
"counts": counts,
|
|
187
|
+
"findings": issues,
|
|
188
|
+
}
|
|
189
|
+
kit.emit(report, args.out_json)
|
|
190
|
+
return 1 if issues else 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
if __name__ == "__main__":
|
|
194
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Report, accept, or reject tracked revisions in a .docx.
|
|
3
|
+
|
|
4
|
+
Revision marks live inline in the WordprocessingML: ``w:ins`` wraps content that
|
|
5
|
+
was added, ``w:del`` wraps content that was removed, and paragraph-mark changes
|
|
6
|
+
sit in ``w:rPrChange``/``w:pPrChange``. Accepting keeps inserted content and
|
|
7
|
+
drops deleted content; rejecting does the reverse. Format-only revisions are
|
|
8
|
+
reported but always dropped because they carry no text.
|
|
9
|
+
|
|
10
|
+
Exit codes: 0 success, 1 nothing to report while --mode report, 2 bad input.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
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
|
+
REVISION_TAGS = {
|
|
23
|
+
"ins": "insertion",
|
|
24
|
+
"del": "deletion",
|
|
25
|
+
"moveFrom": "move_from",
|
|
26
|
+
"moveTo": "move_to",
|
|
27
|
+
"rPrChange": "format_change",
|
|
28
|
+
"pPrChange": "format_change",
|
|
29
|
+
"sectPrChange": "format_change",
|
|
30
|
+
"tblPrChange": "format_change",
|
|
31
|
+
"trPrChange": "format_change",
|
|
32
|
+
"tcPrChange": "format_change",
|
|
33
|
+
}
|
|
34
|
+
REMOVABLE_TEXT_TAGS = ("ins", "del", "moveFrom", "moveTo")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def scan(doc: kit.Docx) -> list[dict]:
|
|
38
|
+
rows = []
|
|
39
|
+
for part in doc.text_parts():
|
|
40
|
+
for node in doc.xml(part).iter():
|
|
41
|
+
if not isinstance(node.tag, str) or not node.tag.startswith("{%s}" % kit.W):
|
|
42
|
+
continue
|
|
43
|
+
local = node.tag.split("}", 1)[1]
|
|
44
|
+
if local not in REVISION_TAGS:
|
|
45
|
+
continue
|
|
46
|
+
rows.append(
|
|
47
|
+
{
|
|
48
|
+
"part": part,
|
|
49
|
+
"type": REVISION_TAGS[local],
|
|
50
|
+
"author": node.get(kit.q("author")) or "",
|
|
51
|
+
"date": node.get(kit.q("date")) or "",
|
|
52
|
+
"id": node.get(kit.q("id")) or "",
|
|
53
|
+
"text": kit.text_of(node)[:120],
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
return rows
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def unwrap(node) -> None:
|
|
60
|
+
parent = node.getparent()
|
|
61
|
+
if parent is None:
|
|
62
|
+
return
|
|
63
|
+
index = parent.index(node)
|
|
64
|
+
for child in list(node):
|
|
65
|
+
parent.insert(index, child)
|
|
66
|
+
index += 1
|
|
67
|
+
parent.remove(node)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def convert_deleted_text(node) -> None:
|
|
71
|
+
for del_text in list(node.iter(kit.q("delText"))):
|
|
72
|
+
del_text.tag = kit.q("t")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def apply(doc: kit.Docx, mode: str) -> int:
|
|
76
|
+
"""Apply accept or reject across every text-bearing part."""
|
|
77
|
+
touched = 0
|
|
78
|
+
for part in doc.text_parts():
|
|
79
|
+
root = doc.xml(part)
|
|
80
|
+
nodes = [node for node in root.iter() if isinstance(node.tag, str) and node.tag.startswith("{%s}" % kit.W) and node.tag.split("}", 1)[1] in REMOVABLE_TEXT_TAGS]
|
|
81
|
+
for node in reversed(nodes):
|
|
82
|
+
local = node.tag.split("}", 1)[1]
|
|
83
|
+
keep = (local in ("ins", "moveTo")) == (mode == "accept")
|
|
84
|
+
if keep:
|
|
85
|
+
if local == "del" or local == "moveFrom":
|
|
86
|
+
convert_deleted_text(node)
|
|
87
|
+
unwrap(node)
|
|
88
|
+
else:
|
|
89
|
+
parent = node.getparent()
|
|
90
|
+
if parent is not None:
|
|
91
|
+
parent.remove(node)
|
|
92
|
+
touched += 1
|
|
93
|
+
# Paragraph-mark revisions have no text: drop them either way so a
|
|
94
|
+
# reopened document no longer claims to be dirty.
|
|
95
|
+
for local in ("rPrChange", "pPrChange", "sectPrChange", "tblPrChange", "trPrChange", "tcPrChange"):
|
|
96
|
+
for node in list(root.iter(kit.q(local))):
|
|
97
|
+
parent = node.getparent()
|
|
98
|
+
if parent is not None:
|
|
99
|
+
parent.remove(node)
|
|
100
|
+
touched += 1
|
|
101
|
+
return touched
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main(argv: list[str] | None = None) -> int:
|
|
105
|
+
parser = argparse.ArgumentParser(description="Report, accept, or reject tracked changes in a .docx.")
|
|
106
|
+
parser.add_argument("in_docx", help="Input .docx")
|
|
107
|
+
parser.add_argument("--mode", required=True, choices=["report", "accept", "reject"])
|
|
108
|
+
parser.add_argument("--out", help="Output .docx (required for accept/reject)")
|
|
109
|
+
parser.add_argument("--keep_tracking_on", action="store_true", help="Leave the track-revisions flag enabled in settings.xml")
|
|
110
|
+
args = parser.parse_args(argv)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
doc = kit.Docx(args.in_docx)
|
|
114
|
+
except (OSError, KeyError) as exc:
|
|
115
|
+
print("error: %s" % exc, file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
|
|
118
|
+
revisions = scan(doc)
|
|
119
|
+
counts: dict[str, int] = {}
|
|
120
|
+
for row in revisions:
|
|
121
|
+
counts[row["type"]] = counts.get(row["type"], 0) + 1
|
|
122
|
+
|
|
123
|
+
if args.mode == "report":
|
|
124
|
+
kit.emit({"ok": True, "input": str(Path(args.in_docx)), "total": len(revisions), "counts": counts, "revisions": revisions})
|
|
125
|
+
return 0 if revisions else 1
|
|
126
|
+
|
|
127
|
+
if not args.out:
|
|
128
|
+
parser.error("--out is required for accept/reject")
|
|
129
|
+
touched = apply(doc, args.mode)
|
|
130
|
+
|
|
131
|
+
settings = doc.xml("word/settings.xml")
|
|
132
|
+
if not args.keep_tracking_on:
|
|
133
|
+
for node in list(settings.iter(kit.q("trackRevisions"))):
|
|
134
|
+
settings.remove(node)
|
|
135
|
+
|
|
136
|
+
doc.save(args.out)
|
|
137
|
+
kit.emit(
|
|
138
|
+
{
|
|
139
|
+
"ok": True,
|
|
140
|
+
"mode": args.mode,
|
|
141
|
+
"output": str(Path(args.out)),
|
|
142
|
+
"revisions_seen": len(revisions),
|
|
143
|
+
"nodes_rewritten": touched,
|
|
144
|
+
"counts": counts,
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
raise SystemExit(main())
|