dsh-office-kit 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CAPABILITY.md +147 -68
- package/README.md +3 -1
- package/assets/skills/excel-live-control/SKILL.md +234 -0
- package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
- package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
- package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
- package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
- package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
- package/assets/skills/excel-live-control/references/office-automation.md +207 -0
- package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
- package/assets/skills/excel-live-control/style_guidelines.md +87 -0
- package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
- package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
- package/assets/skills/office-documents/SKILL.md +90 -77
- package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
- package/assets/skills/office-documents/ooxml/comments.md +40 -0
- package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
- package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
- package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
- package/assets/skills/office-documents/references/docx-structure.md +83 -0
- package/assets/skills/office-documents/references/template-elicitation.md +49 -0
- package/assets/skills/office-documents/references/writing-quality.md +49 -0
- package/assets/skills/office-documents/render_docx.py +99 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +201 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +158 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +155 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +115 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +111 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +109 -0
- package/assets/skills/office-documents/scripts/comments_add.py +143 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +121 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +92 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +81 -0
- package/assets/skills/office-documents/scripts/content_controls.py +199 -0
- package/assets/skills/office-documents/scripts/core_properties.py +131 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +594 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +232 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +106 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +73 -0
- package/assets/skills/office-documents/scripts/fields_report.py +66 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +69 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +84 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +103 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +88 -0
- package/assets/skills/office-documents/scripts/images_audit.py +87 -0
- package/assets/skills/office-documents/scripts/insert_note.py +133 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +97 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +91 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +153 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +176 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +246 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +138 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +120 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +108 -0
- package/assets/skills/office-documents/scripts/replace_text.py +161 -0
- package/assets/skills/office-documents/scripts/section_audit.py +98 -0
- package/assets/skills/office-documents/scripts/set_protection.py +99 -0
- package/assets/skills/office-documents/scripts/style_lint.py +118 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +110 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +174 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +55 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +74 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +124 -0
- package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
- package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
- package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
- package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
- package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
- package/assets/skills/office-documents/tasks/create_edit.md +107 -0
- package/assets/skills/office-documents/tasks/equations.md +59 -0
- package/assets/skills/office-documents/tasks/fields_update.md +44 -0
- package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
- package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
- package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
- package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
- package/assets/skills/office-documents/tasks/images_figures.md +63 -0
- package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
- package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
- package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
- package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
- package/assets/skills/office-documents/tasks/read_review.md +50 -0
- package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
- package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
- package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
- package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
- package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
- package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
- package/assets/skills/office-documents/tasks/verify_render.md +50 -0
- package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
- package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
- package/assets/skills/office-pdf/SKILL.md +73 -56
- package/assets/skills/office-pdf/references/form-fields.md +56 -0
- package/assets/skills/office-pdf/references/geometry.md +44 -0
- package/assets/skills/office-pdf/references/layout-quality.md +41 -0
- package/assets/skills/office-pdf/references/limits.md +58 -0
- package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
- package/assets/skills/office-pdf/scripts/forms.py +556 -0
- package/assets/skills/office-pdf/scripts/metadata.py +161 -0
- package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
- package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
- package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
- package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
- package/assets/skills/office-pdf/tasks/annotations.md +98 -0
- package/assets/skills/office-pdf/tasks/create.md +75 -0
- package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
- package/assets/skills/office-pdf/tasks/forms.md +67 -0
- package/assets/skills/office-pdf/tasks/read.md +62 -0
- package/assets/skills/office-pdf/tasks/redact.md +72 -0
- package/assets/skills/office-pdf/tasks/stamp.md +60 -0
- package/assets/skills/office-presentations/SKILL.md +141 -87
- package/assets/skills/office-presentations/container_tools/add_slides.py +143 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +127 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +133 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +292 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +164 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +151 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +206 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +219 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +352 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +382 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +146 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +175 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +236 -0
- package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
- package/assets/skills/office-presentations/recipes/README.md +71 -0
- package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
- package/assets/skills/office-presentations/recipes/connectors.md +83 -0
- package/assets/skills/office-presentations/recipes/images.md +80 -0
- package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
- package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
- package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
- package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
- package/assets/skills/office-presentations/recipes/tables.md +88 -0
- package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
- package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
- package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
- package/assets/skills/office-presentations/references/finalization.md +63 -0
- package/assets/skills/office-presentations/references/native-bullets.md +78 -0
- package/assets/skills/office-presentations/references/native-evidence.md +67 -0
- package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
- package/assets/skills/office-presentations/references/templates.md +76 -0
- package/assets/skills/office-presentations/routing/google_slides.md +45 -0
- package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
- package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
- package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
- package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
- package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
- package/assets/skills/office-presentations/tasks/template-following.md +132 -0
- package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +183 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +161 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +118 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +246 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +192 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +243 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +114 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +129 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +313 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +405 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +287 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +142 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +141 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +140 -0
- package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
- package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
- package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
- package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
- package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
- package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
- package/assets/skills/office-spreadsheets/references/charts.md +63 -0
- package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
- package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
- package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
- package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
- package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
- package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
- package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
- package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
- package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
- package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
- package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
- package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
- package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
- package/assets/skills/shared/annotation-recipes.md +252 -0
- package/assets/skills/shared/check_runtime.py +260 -0
- package/assets/skills/shared/common-workflow.md +139 -0
- package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/index.js +246 -89
- package/package.json +1 -1
- package/postinstall.mjs +92 -82
- package/python/office_cli.py +213 -254
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Append rows to the bottom of an existing worksheet.
|
|
3
|
+
|
|
4
|
+
Rows are JSON arrays. The new rows start one row below the sheet's current last
|
|
5
|
+
row, so a table that ends at row 13 grows at row 14. Pass `null` for a blank
|
|
6
|
+
cell; do not pad with empty strings, which turn into text.
|
|
7
|
+
|
|
8
|
+
Because appending a row does not extend Excel Tables, conditional formats, or
|
|
9
|
+
chart ranges, follow up with the checks in the workbook's own conventions:
|
|
10
|
+
extend `tables[].ref`, copy the format of the row above, and widen any chart
|
|
11
|
+
that is supposed to include the new data.
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
append_rows.py --input book.xlsx --sheet Pipeline --rows '[["Delta Co","R. Osei","Proposal",98000,0.4]]'
|
|
15
|
+
append_rows.py --input book.xlsx --sheet Pipeline --rows-file rows.json
|
|
16
|
+
|
|
17
|
+
rows.json: [["Delta Co", "R. Osei", "Proposal", 98000, 0.4]]
|
|
18
|
+
|
|
19
|
+
Exit codes:
|
|
20
|
+
0 rows appended (the JSON on stdout reports the new dimensions)
|
|
21
|
+
1 refused: unknown sheet or malformed rows
|
|
22
|
+
2 usage error, missing file, or unreadable workbook
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
33
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
34
|
+
try:
|
|
35
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
36
|
+
except (AttributeError, ValueError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
40
|
+
sys.stdout.reconfigure(errors="replace")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_args() -> argparse.Namespace:
|
|
44
|
+
parser = argparse.ArgumentParser(description="Append rows to the bottom of an .xlsx worksheet.")
|
|
45
|
+
parser.add_argument("--input", required=True, help="source workbook")
|
|
46
|
+
parser.add_argument("--out", help="destination workbook (default: <input>-edited.xlsx)")
|
|
47
|
+
parser.add_argument("--sheet", required=True, help="target worksheet")
|
|
48
|
+
parser.add_argument("--rows", metavar="JSON", help="rows as a JSON array of arrays")
|
|
49
|
+
parser.add_argument("--rows-file", help="JSON file holding the rows array")
|
|
50
|
+
return parser.parse_args()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _error(message: str, code: int) -> int:
|
|
54
|
+
print(json.dumps({"ok": False, "error": message}), file=sys.stderr)
|
|
55
|
+
return code
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_rows(args: argparse.Namespace) -> list[list[object]]:
|
|
59
|
+
if args.rows_file:
|
|
60
|
+
payload = json.loads(Path(args.rows_file).expanduser().read_text(encoding="utf-8-sig"))
|
|
61
|
+
elif args.rows:
|
|
62
|
+
payload = json.loads(args.rows)
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError("nothing to do: pass --rows or --rows-file")
|
|
65
|
+
if isinstance(payload, dict):
|
|
66
|
+
payload = payload.get("rows")
|
|
67
|
+
if not isinstance(payload, list) or not all(isinstance(row, list) for row in payload):
|
|
68
|
+
raise ValueError("rows must be a JSON array of arrays")
|
|
69
|
+
if not payload:
|
|
70
|
+
raise ValueError("rows array is empty")
|
|
71
|
+
return payload
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main() -> int:
|
|
75
|
+
args = parse_args()
|
|
76
|
+
source = Path(args.input).expanduser()
|
|
77
|
+
if not source.is_file():
|
|
78
|
+
return _error(f"input file not found: {source}", 2)
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
from openpyxl import load_workbook
|
|
82
|
+
except ImportError as error: # pragma: no cover - environment guard
|
|
83
|
+
return _error(f"openpyxl missing: {error}", 2)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
rows = read_rows(args)
|
|
87
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
88
|
+
return _error(str(error), 1)
|
|
89
|
+
|
|
90
|
+
workbook = load_workbook(str(source))
|
|
91
|
+
if args.sheet not in workbook.sheetnames:
|
|
92
|
+
return _error(f"sheet not found: {args.sheet}", 1)
|
|
93
|
+
|
|
94
|
+
worksheet = workbook[args.sheet]
|
|
95
|
+
before = worksheet.dimensions
|
|
96
|
+
start = worksheet.max_row + 1
|
|
97
|
+
for offset, row in enumerate(rows):
|
|
98
|
+
for column, value in enumerate(row, start=1):
|
|
99
|
+
worksheet.cell(row=start + offset, column=column, value=value)
|
|
100
|
+
|
|
101
|
+
out = Path(args.out).expanduser() if args.out else source.with_name(
|
|
102
|
+
f"{source.stem}-edited{source.suffix}"
|
|
103
|
+
)
|
|
104
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
workbook.save(str(out))
|
|
106
|
+
after = worksheet.dimensions
|
|
107
|
+
workbook.close()
|
|
108
|
+
|
|
109
|
+
print(
|
|
110
|
+
json.dumps(
|
|
111
|
+
{
|
|
112
|
+
"ok": True,
|
|
113
|
+
"input": str(source),
|
|
114
|
+
"output": str(out),
|
|
115
|
+
"sheet": args.sheet,
|
|
116
|
+
"rows_appended": len(rows),
|
|
117
|
+
"first_row": start,
|
|
118
|
+
"dimensions_before": before,
|
|
119
|
+
"dimensions_after": after,
|
|
120
|
+
},
|
|
121
|
+
ensure_ascii=False,
|
|
122
|
+
indent=2,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
sys.exit(main())
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compare two .xlsx workbooks and report every difference.
|
|
3
|
+
|
|
4
|
+
Compares what the files store, not what they display: cell values, formula
|
|
5
|
+
text, number formats and font/fill/alignment, merged ranges, column widths,
|
|
6
|
+
freeze panes, tables, conditional formats, charts and defined names. Use it to
|
|
7
|
+
prove that an edit touched only the cells that were asked for.
|
|
8
|
+
|
|
9
|
+
Computed results are not compared here; run recalc_gate.py for that.
|
|
10
|
+
|
|
11
|
+
Exit codes:
|
|
12
|
+
0 the compared scope is identical
|
|
13
|
+
1 differences found (the JSON on stdout lists them)
|
|
14
|
+
2 usage error, missing file, or unreadable workbook
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# Windows consoles default to a legacy code page; every payload is UTF-8 JSON.
|
|
25
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
26
|
+
try:
|
|
27
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
28
|
+
except (AttributeError, ValueError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
32
|
+
# Legacy console codepages (cp936, cp1252) must not abort a report.
|
|
33
|
+
sys.stdout.reconfigure(errors="replace")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_args() -> argparse.Namespace:
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
description="Diff two .xlsx workbooks: sheets, cells, formulas, "
|
|
39
|
+
"formatting, merges, widths, tables, charts, defined names.",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("--before", required=True, help="baseline .xlsx file")
|
|
42
|
+
parser.add_argument("--after", required=True, help="edited .xlsx file")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--limit",
|
|
45
|
+
type=int,
|
|
46
|
+
default=200,
|
|
47
|
+
help="maximum cell changes reported per sheet (default 200)",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--no-formats",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="compare values and formulas only, ignore styling and number formats",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("--sheet", action="append", help="restrict to this sheet (repeatable)")
|
|
55
|
+
return parser.parse_args()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _color_signature(color) -> str | None:
|
|
59
|
+
if color is None:
|
|
60
|
+
return None
|
|
61
|
+
rgb = getattr(color, "rgb", None)
|
|
62
|
+
if isinstance(rgb, str) and rgb.upper() not in ("00000000", "000000"):
|
|
63
|
+
return rgb.upper()
|
|
64
|
+
theme = getattr(color, "theme", None)
|
|
65
|
+
if theme is not None:
|
|
66
|
+
return f"theme{theme}"
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _style(cell) -> dict[str, object]:
|
|
71
|
+
font = cell.font
|
|
72
|
+
fill = cell.fill
|
|
73
|
+
return {
|
|
74
|
+
"format": cell.number_format,
|
|
75
|
+
"bold": bool(font.bold),
|
|
76
|
+
"italic": bool(font.italic),
|
|
77
|
+
"size": font.sz,
|
|
78
|
+
"color": _color_signature(font.color),
|
|
79
|
+
"fill": _color_signature(fill.fgColor) if getattr(fill, "patternType", None) else None,
|
|
80
|
+
"align": cell.alignment.horizontal,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cells(worksheet) -> dict[str, tuple[object, dict[str, object]]]:
|
|
85
|
+
found: dict[str, tuple[object, dict[str, object]]] = {}
|
|
86
|
+
for row in worksheet.iter_rows():
|
|
87
|
+
for cell in row:
|
|
88
|
+
if cell.value is None and not cell.has_style:
|
|
89
|
+
continue
|
|
90
|
+
found[cell.coordinate] = (cell.value, _style(cell))
|
|
91
|
+
return found
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _chart_entry(chart) -> dict[str, object]:
|
|
95
|
+
from openpyxl.utils import get_column_letter
|
|
96
|
+
|
|
97
|
+
refs = []
|
|
98
|
+
for series in getattr(chart, "series", None) or []:
|
|
99
|
+
value = getattr(series, "val", None)
|
|
100
|
+
reference = getattr(getattr(value, "numRef", None), "f", None) or getattr(
|
|
101
|
+
getattr(value, "strRef", None), "f", None
|
|
102
|
+
)
|
|
103
|
+
if reference:
|
|
104
|
+
refs.append(reference)
|
|
105
|
+
anchor = getattr(chart, "anchor", None)
|
|
106
|
+
start = getattr(anchor, "_from", None)
|
|
107
|
+
position = (
|
|
108
|
+
f"{get_column_letter(start.col + 1)}{start.row + 1}" if start is not None else type(anchor).__name__
|
|
109
|
+
)
|
|
110
|
+
return {"anchor": position, "series": refs}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _structure(worksheet) -> dict[str, object]:
|
|
114
|
+
conditions = [
|
|
115
|
+
{"range": str(item.sqref), "rules": [rule.type for rule in item.rules]}
|
|
116
|
+
for item in worksheet.conditional_formatting
|
|
117
|
+
]
|
|
118
|
+
return {
|
|
119
|
+
"dimensions": worksheet.dimensions,
|
|
120
|
+
"freeze_panes": worksheet.freeze_panes,
|
|
121
|
+
"merged": sorted(str(item) for item in worksheet.merged_cells.ranges),
|
|
122
|
+
"auto_filter": getattr(worksheet.auto_filter, "ref", None),
|
|
123
|
+
"gridlines": bool(worksheet.sheet_view.showGridLines),
|
|
124
|
+
"conditional_formats": conditions,
|
|
125
|
+
"data_validations": len(worksheet.data_validations.dataValidation),
|
|
126
|
+
"tables": {table.name: str(table.ref) for table in worksheet.tables.values()},
|
|
127
|
+
"charts": [_chart_entry(chart) for chart in getattr(worksheet, "_charts", [])],
|
|
128
|
+
"images": len(getattr(worksheet, "_images", [])),
|
|
129
|
+
"column_widths": {
|
|
130
|
+
name: round(dimension.width, 2)
|
|
131
|
+
for name, dimension in worksheet.column_dimensions.items()
|
|
132
|
+
if dimension.width
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_formula(value: object) -> bool:
|
|
138
|
+
return isinstance(value, str) and value.startswith("=")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _cell_changes(before, after, *, formats: bool, limit: int) -> list[dict[str, object]]:
|
|
142
|
+
left = _cells(before)
|
|
143
|
+
right = _cells(after)
|
|
144
|
+
changes: list[dict[str, object]] = []
|
|
145
|
+
for coordinate in sorted(set(left) | set(right)):
|
|
146
|
+
a_value, a_style = left.get(coordinate, (None, None))
|
|
147
|
+
b_value, b_style = right.get(coordinate, (None, None))
|
|
148
|
+
if coordinate not in left:
|
|
149
|
+
changes.append({"cell": coordinate, "kind": "added", "after": b_value})
|
|
150
|
+
elif coordinate not in right:
|
|
151
|
+
changes.append({"cell": coordinate, "kind": "removed", "before": a_value})
|
|
152
|
+
elif a_value != b_value:
|
|
153
|
+
if _is_formula(a_value) and not _is_formula(b_value):
|
|
154
|
+
kind = "formula_removed"
|
|
155
|
+
elif _is_formula(b_value) and not _is_formula(a_value):
|
|
156
|
+
kind = "formula_added"
|
|
157
|
+
elif _is_formula(a_value) and _is_formula(b_value):
|
|
158
|
+
kind = "formula"
|
|
159
|
+
else:
|
|
160
|
+
kind = "value"
|
|
161
|
+
changes.append(
|
|
162
|
+
{"cell": coordinate, "kind": kind, "before": a_value, "after": b_value}
|
|
163
|
+
)
|
|
164
|
+
elif formats and a_style != b_style:
|
|
165
|
+
fields = [
|
|
166
|
+
key for key in a_style if a_style.get(key) != b_style.get(key) # type: ignore[union-attr]
|
|
167
|
+
]
|
|
168
|
+
changes.append(
|
|
169
|
+
{
|
|
170
|
+
"cell": coordinate,
|
|
171
|
+
"kind": "format",
|
|
172
|
+
"fields": fields,
|
|
173
|
+
"before": {key: a_style[key] for key in fields}, # type: ignore[index]
|
|
174
|
+
"after": {key: b_style[key] for key in fields}, # type: ignore[index]
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
if len(changes) > limit:
|
|
178
|
+
break
|
|
179
|
+
return changes
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _structure_changes(before, after) -> dict[str, object]:
|
|
183
|
+
left = _structure(before)
|
|
184
|
+
right = _structure(after)
|
|
185
|
+
return {
|
|
186
|
+
key: {"before": left[key], "after": right[key]}
|
|
187
|
+
for key in left
|
|
188
|
+
if left[key] != right[key]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _defined_names(workbook) -> dict[str, str | None]:
|
|
193
|
+
return {
|
|
194
|
+
entry.name: (entry.attr_text if entry.localSheetId is None else f"[{entry.localSheetId}] {entry.attr_text}")
|
|
195
|
+
for entry in workbook.defined_names.values()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _names_changes(before, after) -> dict[str, object]:
|
|
200
|
+
left = _defined_names(before)
|
|
201
|
+
right = _defined_names(after)
|
|
202
|
+
return {
|
|
203
|
+
"added": sorted(set(right) - set(left)),
|
|
204
|
+
"removed": sorted(set(left) - set(right)),
|
|
205
|
+
"changed": {
|
|
206
|
+
name: {"before": left[name], "after": right[name]}
|
|
207
|
+
for name in sorted(set(left) & set(right))
|
|
208
|
+
if left[name] != right[name]
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main() -> int:
|
|
214
|
+
args = parse_args()
|
|
215
|
+
before_path = Path(args.before).expanduser()
|
|
216
|
+
after_path = Path(args.after).expanduser()
|
|
217
|
+
for label, path in (("before", before_path), ("after", after_path)):
|
|
218
|
+
if not path.is_file():
|
|
219
|
+
print(json.dumps({"ok": False, "error": f"{label} file not found: {path}"}), file=sys.stderr)
|
|
220
|
+
return 2
|
|
221
|
+
|
|
222
|
+
try:
|
|
223
|
+
from openpyxl import load_workbook
|
|
224
|
+
except ImportError as error: # pragma: no cover - environment guard
|
|
225
|
+
print(json.dumps({"ok": False, "error": f"openpyxl missing: {error}"}), file=sys.stderr)
|
|
226
|
+
return 2
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
before = load_workbook(str(before_path), data_only=False)
|
|
230
|
+
after = load_workbook(str(after_path), data_only=False)
|
|
231
|
+
except Exception as error: # noqa: BLE001 - any unreadable file is reported the same way
|
|
232
|
+
print(json.dumps({"ok": False, "error": f"cannot read workbook: {error}"}), file=sys.stderr)
|
|
233
|
+
return 2
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
before_names = list(before.sheetnames)
|
|
237
|
+
after_names = list(after.sheetnames)
|
|
238
|
+
added = [name for name in after_names if name not in before_names]
|
|
239
|
+
removed = [name for name in before_names if name not in after_names]
|
|
240
|
+
common = [name for name in before_names if name in after_names]
|
|
241
|
+
if args.sheet:
|
|
242
|
+
common = [name for name in common if name in set(args.sheet)]
|
|
243
|
+
|
|
244
|
+
sheet_reports = []
|
|
245
|
+
for name in common:
|
|
246
|
+
before_sheet = before[name]
|
|
247
|
+
after_sheet = after[name]
|
|
248
|
+
changes = _cell_changes(
|
|
249
|
+
before_sheet, after_sheet, formats=not args.no_formats, limit=args.limit
|
|
250
|
+
)
|
|
251
|
+
structure = _structure_changes(before_sheet, after_sheet)
|
|
252
|
+
if changes or structure:
|
|
253
|
+
kinds: dict[str, int] = {}
|
|
254
|
+
for change in changes:
|
|
255
|
+
kinds[change["kind"]] = kinds.get(change["kind"], 0) + 1
|
|
256
|
+
sheet_reports.append(
|
|
257
|
+
{
|
|
258
|
+
"name": name,
|
|
259
|
+
"change_count": len(changes),
|
|
260
|
+
"kinds": kinds,
|
|
261
|
+
"truncated": len(changes) > args.limit,
|
|
262
|
+
"structure": structure,
|
|
263
|
+
"changes": changes,
|
|
264
|
+
}
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
workbook_names = _names_changes(before, after)
|
|
268
|
+
identical = not (
|
|
269
|
+
added
|
|
270
|
+
or removed
|
|
271
|
+
or sheet_reports
|
|
272
|
+
or any(workbook_names.values()) # type: ignore[arg-type]
|
|
273
|
+
)
|
|
274
|
+
by_kind: dict[str, int] = {}
|
|
275
|
+
for report in sheet_reports:
|
|
276
|
+
for kind, count in report["kinds"].items(): # type: ignore[union-attr]
|
|
277
|
+
by_kind[kind] = by_kind.get(kind, 0) + count
|
|
278
|
+
|
|
279
|
+
payload = {
|
|
280
|
+
"ok": True,
|
|
281
|
+
"before": str(before_path),
|
|
282
|
+
"after": str(after_path),
|
|
283
|
+
"identical": identical,
|
|
284
|
+
"workbook": {
|
|
285
|
+
"sheets_added": added,
|
|
286
|
+
"sheets_removed": removed,
|
|
287
|
+
"sheet_order_changed": [
|
|
288
|
+
name
|
|
289
|
+
for name in after_names
|
|
290
|
+
if name in before_names and after_names.index(name) != before_names.index(name)
|
|
291
|
+
],
|
|
292
|
+
"defined_names": workbook_names,
|
|
293
|
+
},
|
|
294
|
+
"sheets": sheet_reports,
|
|
295
|
+
"summary": {
|
|
296
|
+
"sheets_compared": len(common),
|
|
297
|
+
"sheets_added": len(added),
|
|
298
|
+
"sheets_removed": len(removed),
|
|
299
|
+
"sheets_changed": len(sheet_reports),
|
|
300
|
+
"cells_changed": sum(report["change_count"] for report in sheet_reports), # type: ignore[arg-type]
|
|
301
|
+
"by_kind": by_kind,
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
finally:
|
|
305
|
+
before.close()
|
|
306
|
+
after.close()
|
|
307
|
+
|
|
308
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
|
|
309
|
+
return 0 if payload["identical"] else 1
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
sys.exit(main())
|