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
|
@@ -1,110 +1,114 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Create, inspect, edit, recalculate, and
|
|
2
|
+
description: Create, inspect, edit, recalculate, and verify Excel workbooks (.xlsx/.csv) using openpyxl recipes and the bundled Office helper CLI.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# Spreadsheets (.xlsx)
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
A workbook is cells: each one holds a value, a format, and possibly a formula *string*. openpyxl writes all three and evaluates none of them, so a formula you just wrote has no cached result and `xlsx read` shows `=SUM(B2:B9)` where the user expects `1,950,400`. The calculation happens in an engine — Microsoft Excel or WPS Spreadsheets via `xlsx recalc`, or whatever app the user opens the file in. Everything below follows from that: write values and formulas, then verify structure by reading back, then verify numbers by recalculating, then verify appearance by rendering.
|
|
8
|
+
|
|
9
|
+
| User job | What is really happening | Fast path |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| Build a new workbook | Cells written top-left at A1; `=`-prefixed strings become formulas; styles are per-cell objects; sheets get names, widths, freeze panes, merges | `xlsx create` spec, `tasks/create-workbook.md` |
|
|
12
|
+
| Change cells in an existing file | Only the cells you name change; a re-save can drop objects openpyxl does not model (pivots, foreign charts) | `container_tools/set_cells.py` and friends, `tasks/edit-workbook.md` |
|
|
13
|
+
| The workbook is open in Excel/WPS and the user wants it changed live | The target is the window, not the file on disk: unsaved user edits are in play and a COM write is not transactional | `../excel-live-control/SKILL.md` |
|
|
14
|
+
| Inventory a file you did not write | Iterate sheets, used range, formulas vs values, merged ranges, conditional formats, defined names, charts | `container_tools/inspect_workbook.py` |
|
|
15
|
+
| Compare two versions | Cell-by-cell walk with formula-aware, format-aware, structure-aware levels | `container_tools/diff_workbooks.py`, `tasks/diff-workbooks.md` |
|
|
16
|
+
| Prove the numbers are real | Recalculate in Excel, then check every formula cell has a value, no error, no dangling sheet | `xlsx recalc` + `container_tools/recalc_gate.py`, `tasks/recalc-and-check.md` |
|
|
17
|
+
| Format numbers, conditions, names, charts, tables | Number formats, conditional-formatting rules, defined names, native chart objects, Excel Tables | `recipes/*.md` (openpyxl patterns, runnable) |
|
|
18
|
+
| Answer a question about a file | Read values, not formulas; quote the cells you read | `tasks/analyze-workbook.md` |
|
|
19
|
+
| Spreadsheet lives in Google Sheets | Not this runtime — see `routing/google-sheets.md` | — |
|
|
20
|
+
|
|
21
|
+
Sheet and range semantics matter before any edit: `B4:D9` is a rectangle, `'Build'!E14` is a cross-sheet reference whose sheet name needs quotes when it contains spaces, and a merged range is one value plus a lot of emptiness. Number formats are display only — the stored value keeps full precision, so never round data to make it look right.
|
|
8
22
|
|
|
9
23
|
## Before any file work
|
|
10
24
|
|
|
11
25
|
1. Call the `office_status` tool.
|
|
12
|
-
2.
|
|
13
|
-
3. If
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
##
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
-
|
|
101
|
-
- Keep one header row, avoid merged cells in data ranges, and leave a frozen header.
|
|
102
|
-
- Verify every formula you authored with `read` (formula) and, where possible, `recalc` + `--data-only` (value).
|
|
103
|
-
|
|
104
|
-
## Not supported
|
|
105
|
-
|
|
106
|
-
- Google Sheets / Google Drive editing.
|
|
107
|
-
- Pivot tables, Power Query, macros/VBA, data validation, conditional formatting, slicers, external workbook links.
|
|
108
|
-
- Formula evaluation without Microsoft Excel; `recalc` is Windows + Excel only.
|
|
109
|
-
- Exact Excel rendering fidelity on LibreOffice; charts from foreign producers may not survive editing.
|
|
110
|
-
- `@oai/artifact-tool` helpers or artifact-tool workbook gates.
|
|
26
|
+
2. Read the `## Office runtime` JSON block at the end of this file and use its `python`, `cli`, `skill`, and `shared` values verbatim, quoted: `"<python>"`, `"<cli>"`, `"<skill>/container_tools/<name>.py"`.
|
|
27
|
+
3. If that block reports no working runtime, stop. Give the user the `node "..."` remedy command it names, then call `office_status` again. Never claim a workbook you could not create, read, or recalculate.
|
|
28
|
+
4. Run `<python> <script> --help` before using a container tool you have not called in this session.
|
|
29
|
+
|
|
30
|
+
## Delivery
|
|
31
|
+
|
|
32
|
+
Always hand the finished file to the user with the `present` tool, then summarise in one sentence what changed. A bare path is not delivery: the chat transcript renders `.docx`/`.pptx`/`.xlsx`/`.pdf` links as "二进制文件,无法显示改动" and the user cannot open them.
|
|
33
|
+
|
|
34
|
+
- Creating a file: write it into the user's workspace, verify it, then `present`.
|
|
35
|
+
- Editing an existing file: never overwrite the original. Write `<original>-edited.<ext>` (or a name the user asked for), verify it, `present` it, and state both paths.
|
|
36
|
+
|
|
37
|
+
## Annotations
|
|
38
|
+
|
|
39
|
+
The user may reply with an `office-annotations` block: `[{ page, text, requirement, region: { left, top, width, height } }]`. `region` is normalised 0–1 against the rendered preview page the user drew on; `requirement` is free-form natural language. Treat it as a change request, not as editor state: locate what the region covers, apply the smallest edit that satisfies `requirement`, re-render, and confirm the region now looks right.
|
|
40
|
+
|
|
41
|
+
When the payload cannot be mapped to a concrete object (the region is empty or the file has no geometry), do not guess silently — say what you could not locate.
|
|
42
|
+
|
|
43
|
+
For xlsx the payload is `{ target, fileName, text: "", requirement, region, imageName, unmapped: true }`: no `page`, no `sheet`, no `range`. The preview client cannot resolve a cell from the drag and refuses to fake one. So:
|
|
44
|
+
|
|
45
|
+
1. `read_image` the attachment named `imageName` — it is a screenshot of the area the user marked. Note visible headers, row labels, and values.
|
|
46
|
+
2. Run `"<python>" "<cli>" xlsx read --input "<file>" --json-out "<dump>.json"` and match those values to sheets and A1 addresses; `container_tools/inspect_workbook.py` adds formulas, merged ranges, and names when the match is ambiguous.
|
|
47
|
+
3. State the resolved sheet and range back to the user before editing ("`Pipeline!D2:D13`, the Amount column"), and ask if two sheets or columns match.
|
|
48
|
+
4. Never convert region pixels into a cell address. Full recipe: `tasks/annotations.md` and `"<shared>/annotation-recipes.md"`.
|
|
49
|
+
|
|
50
|
+
## Verify
|
|
51
|
+
|
|
52
|
+
Three checks, in this order, after every mutation:
|
|
53
|
+
|
|
54
|
+
1. **Structure and values.** `"<python>" "<cli>" xlsx read --input "<file>"` proves the cells you wrote are where you wrote them; `container_tools/inspect_workbook.py` proves charts, tables, conditional formats, names, and merges survived.
|
|
55
|
+
2. **Numbers.** If any formula changed or was added, run `"<python>" "<cli>" xlsx recalc --input "<file>" --out "<file>"` and then `container_tools/recalc_gate.py --input "<file>"`. On a host without Microsoft Excel or WPS Spreadsheets the recalc fails and `recalc_gate.py` reports `unverified` — say that plainly instead of presenting an empty cache as a result.
|
|
56
|
+
3. **Appearance.** `"<python>" "<cli>" xlsx render --input "<file>" --out-dir "<render-dir>"`, then `read_image` each PNG. Check titles and labels, `###` columns, clipped numbers, charts cut off by the print area, plots with no categories, legends naming `Series 1`, conditional formatting that never fired, and blank pages. The render goes through Excel, WPS Spreadsheets, or LibreOffice; with none installed it fails explicitly: report the file as structurally verified but visually unchecked, and offer to rebuild from a spec. WPS in particular needs its print area and page setup right before exporting, so a layout complaint may be the export settings rather than the sheet.
|
|
57
|
+
|
|
58
|
+
Two or three render/fix passes are normal. If a defect survives three, say what still looks wrong.
|
|
59
|
+
|
|
60
|
+
## Mutation tools
|
|
61
|
+
|
|
62
|
+
The CLI is read-only for `.xlsx`: `create`, `read`, `render`, `status`, `recalc` and nothing else. Every change to an existing workbook goes through these openpyxl tools. Each one reads `--input`, writes `<input>-edited.xlsx` unless you pass `--out`, prints one JSON object on stdout, and exits non-zero with a JSON `error` on stderr when it refuses.
|
|
63
|
+
|
|
64
|
+
| Tool | Does | Does not |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `sheet_ops.py` | `--add NAME`, `--rename OLD=NEW`, `--delete NAME`, `--order "A,B,C"` | rename the formulas that point at a renamed sheet; deleting the last sheet is refused |
|
|
67
|
+
| `set_cells.py` | `--sheet S --set B2=42 --set C3="=SUM(C2:C9)"`, or `--spec cells.json` | parse dates for you — write `2026-01-31` and format the cell, or pass a serial |
|
|
68
|
+
| `append_rows.py` | `--sheet S --rows '[[...],[...]]'`, or `--rows-file rows.json` | extend an Excel Table, copy the row above's formula, or grow charts and conditional formats |
|
|
69
|
+
| `style_cells.py` | `--sheet S --range A1:D1 --style '{"bold":true}'`, or `--spec styles.json` | autofit columns or restyle cells outside the range |
|
|
70
|
+
| `add_chart.py` | `--sheet S --type line --data-min-col ... --cat-min-row ...`, or `--spec chart.json` | set axis number formats, series colours, or a legend position; a one-row data range is treated as one series of many points |
|
|
71
|
+
| `add_image.py` | `--sheet S --path logo.png --anchor B2 [--width 480]` | update an embedded image when the file on disk changes |
|
|
72
|
+
|
|
73
|
+
Chain them by naming the output: `set_cells.py --input book.xlsx --out step1.xlsx`, then `style_cells.py --input step1.xlsx --out step2.xlsx`. Without `--out` each tool writes `book-edited.xlsx` next to the input and the next step would read the unedited original.
|
|
74
|
+
|
|
75
|
+
Values: numbers, booleans, `null`, and JSON arrays parse; anything else is text, and a leading `=` is stored as a formula. `--set B3=` stores an empty string, which is not the same as a blank cell — leave a cell out of the assignment to keep it blank.
|
|
76
|
+
|
|
77
|
+
## Formulas you will write
|
|
78
|
+
|
|
79
|
+
Write the formula text a person would type; openpyxl stores it verbatim and never evaluates it.
|
|
80
|
+
|
|
81
|
+
- Anchoring: `C8` moves when filled, `C$4` pins the row (a period header), `$A8` pins the column (an item label), `$B$3` pins one cell (a rate or case selector). `=SUMIFS(Amount,Month,C$4,Item,$A8)` is the shape a grid needs.
|
|
82
|
+
- Aggregation: `SUM`, `SUMIFS`, `COUNTIFS`, `AVERAGEIFS`, `SUMPRODUCT` for quantity × rate pairs, `PRODUCT` for chained factors (it silently skips blanks, so a missing input looks valid).
|
|
83
|
+
- Lookups: `INDEX`/`MATCH`, `VLOOKUP` with `FALSE`, or `XLOOKUP` if the file already uses it. A matched key is not a populated value — check the looked-up cell too.
|
|
84
|
+
- Guards: `IFERROR` only around an understood failure, never `IFERROR(x,0)` across a block. A blank input and a real zero are different answers: `=IF(C8="","",B8/C8)` and `=IF(C8=0,"n.a.",B8/C8)`.
|
|
85
|
+
- No `LET`, no dynamic arrays, no `LAMBDA`: the engines that read these files disagree on them.
|
|
86
|
+
- Cross-sheet references quote the name: `='Build'!E14`, `='Case A'!$B$4`.
|
|
87
|
+
|
|
88
|
+
After writing formulas, recalculate before anyone reads a number — see `## Verify`.
|
|
89
|
+
|
|
90
|
+
## Tasks
|
|
91
|
+
|
|
92
|
+
| Task | Read |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| Build a workbook from scratch | `tasks/create-workbook.md` |
|
|
95
|
+
| Edit an existing workbook without collateral damage | `tasks/edit-workbook.md` |
|
|
96
|
+
| Inspect sheets, ranges, formulas, names, conditions | `tasks/inspect-workbook.md` |
|
|
97
|
+
| Diff two workbook versions | `tasks/diff-workbooks.md` |
|
|
98
|
+
| Recalculate and gate consistency | `tasks/recalc-and-check.md` |
|
|
99
|
+
| Answer questions about an existing file | `tasks/analyze-workbook.md` |
|
|
100
|
+
| Resolve an `office-annotations` region to cells | `tasks/annotations.md` |
|
|
101
|
+
|
|
102
|
+
## Recipe index
|
|
103
|
+
|
|
104
|
+
`recipes/number-formats.md`, `recipes/conditional-formats.md`, `recipes/data-tables.md`, `recipes/charts.md`, `recipes/sparklines.md`, `recipes/named-ranges.md` — each is a short runnable openpyxl snippet plus the pitfall it avoids. Use them when the CLI spec has no key for what you need; the CLI is a shortcut, not a ceiling.
|
|
105
|
+
|
|
106
|
+
Background: `references/workbook-structure.md` (tab roles and layout), `references/formula-patterns.md` (anchoring, aggregation, dates), `references/style-guidelines.md` (visual defaults), `references/charts.md` (chart choice and audit), `references/domain/*.md` (finance, healthcare, marketing, research conventions).
|
|
107
|
+
|
|
108
|
+
## Edit discipline
|
|
109
|
+
|
|
110
|
+
An existing workbook is the reference: its structure, formula style, formats, and terminology are what the user expects to keep seeing. Change the cells the request names and the formulas those cells feed. Everything else — other sheets, pivot sources, shared formulas, named ranges, conditional formats, data validation, protection, hidden rows, freeze panes, chart contents — stays untouched unless the request or a real dependency forces it. Never flatten formulas to values because it is easier, never autofit whole sheets for a visual tweak, and never quietly repair errors the user did not ask about: list them and ask. Details and failure modes: `tasks/edit-workbook.md`.
|
|
111
|
+
|
|
112
|
+
## Shared workflow
|
|
113
|
+
|
|
114
|
+
Follow `"<shared>/common-workflow.md"` for the create-or-edit decision, the failure ladder, and the edit naming rule, and `"<shared>/annotation-recipes.md"` for the payload shapes the client sends.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Add a native, editable chart to an existing worksheet.
|
|
3
|
+
|
|
4
|
+
The chart is bound to cells, not to copied values: read the numbers you want to
|
|
5
|
+
plot straight from the sheet so the chart tracks later edits. Chart types are
|
|
6
|
+
the helper CLI's: `bar`, `column`, `line`, `pie`.
|
|
7
|
+
|
|
8
|
+
Reference rows/columns are 1-based sheet coordinates. `--data`-style flags
|
|
9
|
+
describe the plotted values; category flags describe the labels.
|
|
10
|
+
|
|
11
|
+
Examples:
|
|
12
|
+
add_chart.py --input book.xlsx --sheet Trend --type line --title "Monthly revenue (USD)" \
|
|
13
|
+
--anchor A6 --data-min-col 2 --data-min-row 3 --data-max-col 13 --data-max-row 3 \
|
|
14
|
+
--cat-min-col 2 --cat-min-row 2 --cat-max-row 2
|
|
15
|
+
add_chart.py --input book.xlsx --sheet Model --spec chart.json
|
|
16
|
+
|
|
17
|
+
chart.json: {"title": "Q4 plan (USD)", "type": "column", "anchor": "A16",
|
|
18
|
+
"data": {"min_col": 2, "min_row": 11, "max_col": 2, "max_row": 14,
|
|
19
|
+
"titles_from_data": true,
|
|
20
|
+
"categories": {"min_col": 1, "min_row": 11, "max_row": 14}}}
|
|
21
|
+
|
|
22
|
+
A chart added this way has no explicit axis number format and keeps the default
|
|
23
|
+
series name, so check the rendered page and fix the title, units, and legend.
|
|
24
|
+
|
|
25
|
+
Exit codes:
|
|
26
|
+
0 chart added (the JSON on stdout reports the sheet's chart count)
|
|
27
|
+
1 refused: unknown sheet, incomplete reference, or unsupported chart type
|
|
28
|
+
2 usage error, missing file, or unreadable workbook
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import sys
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
39
|
+
sys.stdout.reconfigure(errors="replace")
|
|
40
|
+
|
|
41
|
+
ROOT = Path(__file__).resolve().parents[4]
|
|
42
|
+
CHART_TYPES = {"bar", "column", "line", "pie"}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_office_cli():
|
|
46
|
+
"""Reuse the helper CLI's chart builder so create-time and edit-time charts match."""
|
|
47
|
+
scripts = ROOT / "python"
|
|
48
|
+
if not (scripts / "office_cli.py").is_file():
|
|
49
|
+
raise RuntimeError(f"helper CLI not found next to the skill: {scripts / 'office_cli.py'}")
|
|
50
|
+
if str(scripts) not in sys.path:
|
|
51
|
+
sys.path.insert(0, str(scripts))
|
|
52
|
+
import office_cli
|
|
53
|
+
|
|
54
|
+
return office_cli
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_args() -> argparse.Namespace:
|
|
58
|
+
parser = argparse.ArgumentParser(description="Add a native chart to an .xlsx worksheet.")
|
|
59
|
+
parser.add_argument("--input", required=True, help="source workbook")
|
|
60
|
+
parser.add_argument("--out", help="destination workbook (default: <input>-edited.xlsx)")
|
|
61
|
+
parser.add_argument("--sheet", help="target worksheet (required unless --spec names one)")
|
|
62
|
+
parser.add_argument("--spec", help="JSON file with a chart object or a list of chart objects")
|
|
63
|
+
parser.add_argument("--type", default="column", choices=sorted(CHART_TYPES), help="chart type")
|
|
64
|
+
parser.add_argument("--title", default="", help="chart title")
|
|
65
|
+
parser.add_argument("--anchor", default="E2", help="top-left cell the chart floats over")
|
|
66
|
+
parser.add_argument("--height", type=float, default=8.0, help="chart height in cm")
|
|
67
|
+
parser.add_argument("--width", type=float, default=14.0, help="chart width in cm")
|
|
68
|
+
parser.add_argument("--data-min-col", type=int, help="first data column (1-based)")
|
|
69
|
+
parser.add_argument("--data-min-row", type=int, help="first data row (1-based)")
|
|
70
|
+
parser.add_argument("--data-max-col", type=int, help="last data column (1-based)")
|
|
71
|
+
parser.add_argument("--data-max-row", type=int, help="last data row (1-based)")
|
|
72
|
+
parser.add_argument("--titles-from-data", action="store_true", help="first data row holds series names")
|
|
73
|
+
parser.add_argument("--cat-min-col", type=int, help="first category column (1-based)")
|
|
74
|
+
parser.add_argument("--cat-min-row", type=int, help="first category row (1-based)")
|
|
75
|
+
parser.add_argument("--cat-max-row", type=int, help="last category row (1-based)")
|
|
76
|
+
return parser.parse_args()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _error(message: str, code: int) -> int:
|
|
80
|
+
print(json.dumps({"ok": False, "error": message}), file=sys.stderr)
|
|
81
|
+
return code
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def charts_from_flags(args: argparse.Namespace) -> list[dict[str, object]]:
|
|
85
|
+
if args.data_min_col is None or args.data_min_row is None:
|
|
86
|
+
raise ValueError("--data-min-col and --data-min-row are required without --spec")
|
|
87
|
+
data: dict[str, object] = {
|
|
88
|
+
"min_col": args.data_min_col,
|
|
89
|
+
"min_row": args.data_min_row,
|
|
90
|
+
"max_col": args.data_max_col if args.data_max_col is not None else args.data_min_col,
|
|
91
|
+
"max_row": args.data_max_row if args.data_max_row is not None else args.data_min_row,
|
|
92
|
+
"titles_from_data": bool(args.titles_from_data),
|
|
93
|
+
}
|
|
94
|
+
if args.cat_min_col is not None:
|
|
95
|
+
categories: dict[str, object] = {"min_col": args.cat_min_col}
|
|
96
|
+
if args.cat_min_row is not None:
|
|
97
|
+
categories["min_row"] = args.cat_min_row
|
|
98
|
+
if args.cat_max_row is not None:
|
|
99
|
+
categories["max_row"] = args.cat_max_row
|
|
100
|
+
data["categories"] = categories
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
"type": args.type,
|
|
104
|
+
"title": args.title,
|
|
105
|
+
"anchor": args.anchor,
|
|
106
|
+
"height": args.height,
|
|
107
|
+
"width": args.width,
|
|
108
|
+
"data": data,
|
|
109
|
+
}
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def charts_from_spec(path: Path) -> list[dict[str, object]]:
|
|
114
|
+
payload = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
115
|
+
items = payload if isinstance(payload, list) else payload.get("charts", [payload])
|
|
116
|
+
charts = [item for item in items if isinstance(item, dict)]
|
|
117
|
+
if not charts:
|
|
118
|
+
raise ValueError("--spec holds no chart objects")
|
|
119
|
+
return charts
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_row_oriented(spec: dict[str, object]) -> bool:
|
|
123
|
+
"""A one-row data range holds one series of many points, not many series of one."""
|
|
124
|
+
data = spec.get("data")
|
|
125
|
+
if not isinstance(data, dict):
|
|
126
|
+
return False
|
|
127
|
+
if data.get("from_rows"):
|
|
128
|
+
return True
|
|
129
|
+
min_row = int(data.get("min_row", 0))
|
|
130
|
+
max_row = int(data.get("max_row", min_row))
|
|
131
|
+
min_col = int(data.get("min_col", 0))
|
|
132
|
+
max_col = int(data.get("max_col", min_col))
|
|
133
|
+
return min_row == max_row and max_col > min_col
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def add_row_oriented_chart(worksheet, spec: dict[str, object]) -> None:
|
|
137
|
+
"""Build the chart directly: the helper CLI's reference builder reads columns only."""
|
|
138
|
+
from openpyxl.chart import BarChart, LineChart, PieChart, Reference
|
|
139
|
+
|
|
140
|
+
chart_type = str(spec.get("type", "column")).lower()
|
|
141
|
+
chart = {"bar": BarChart, "column": BarChart, "line": LineChart, "pie": PieChart}[chart_type]()
|
|
142
|
+
chart.title = str(spec.get("title", ""))
|
|
143
|
+
chart.height = float(spec.get("height", 8))
|
|
144
|
+
chart.width = float(spec.get("width", 14))
|
|
145
|
+
|
|
146
|
+
data = spec["data"]
|
|
147
|
+
values = Reference(
|
|
148
|
+
worksheet,
|
|
149
|
+
min_col=int(data["min_col"]),
|
|
150
|
+
min_row=int(data["min_row"]),
|
|
151
|
+
max_col=int(data.get("max_col", data["min_col"])),
|
|
152
|
+
max_row=int(data.get("max_row", data["min_row"])),
|
|
153
|
+
)
|
|
154
|
+
chart.add_data(values, titles_from_data=bool(data.get("titles_from_data", False)), from_rows=True)
|
|
155
|
+
categories = data.get("categories")
|
|
156
|
+
if isinstance(categories, dict):
|
|
157
|
+
chart.set_categories(Reference(
|
|
158
|
+
worksheet,
|
|
159
|
+
min_col=int(categories.get("min_col", 1)),
|
|
160
|
+
min_row=int(categories.get("min_row", 1)),
|
|
161
|
+
max_row=int(categories.get("max_row", data.get("max_row", 1))),
|
|
162
|
+
))
|
|
163
|
+
worksheet.add_chart(chart, str(spec.get("anchor", "E2")))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main() -> int:
|
|
167
|
+
args = parse_args()
|
|
168
|
+
source = Path(args.input).expanduser()
|
|
169
|
+
if not source.is_file():
|
|
170
|
+
return _error(f"input file not found: {source}", 2)
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
office_cli = load_office_cli()
|
|
174
|
+
from openpyxl import load_workbook
|
|
175
|
+
except (ImportError, RuntimeError) as error: # pragma: no cover - environment guard
|
|
176
|
+
return _error(str(error), 2)
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
charts = charts_from_spec(Path(args.spec).expanduser()) if args.spec else charts_from_flags(args)
|
|
180
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
181
|
+
return _error(str(error), 1)
|
|
182
|
+
|
|
183
|
+
if not args.sheet:
|
|
184
|
+
return _error("--sheet is required", 2)
|
|
185
|
+
|
|
186
|
+
workbook = load_workbook(str(source))
|
|
187
|
+
if args.sheet not in workbook.sheetnames:
|
|
188
|
+
return _error(f"sheet not found: {args.sheet}", 1)
|
|
189
|
+
|
|
190
|
+
worksheet = workbook[args.sheet]
|
|
191
|
+
added = []
|
|
192
|
+
for chart in charts:
|
|
193
|
+
chart_type = str(chart.get("type", "column")).lower()
|
|
194
|
+
if chart_type not in CHART_TYPES:
|
|
195
|
+
return _error(f"unsupported chart type: {chart_type}", 1)
|
|
196
|
+
if is_row_oriented(chart):
|
|
197
|
+
add_row_oriented_chart(worksheet, chart)
|
|
198
|
+
else:
|
|
199
|
+
office_cli._xlsx_add_chart(worksheet, chart)
|
|
200
|
+
added.append(
|
|
201
|
+
{
|
|
202
|
+
"type": chart_type,
|
|
203
|
+
"title": chart.get("title", ""),
|
|
204
|
+
"anchor": chart.get("anchor", "E2"),
|
|
205
|
+
"data": chart.get("data"),
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
out = Path(args.out).expanduser() if args.out else source.with_name(
|
|
210
|
+
f"{source.stem}-edited{source.suffix}"
|
|
211
|
+
)
|
|
212
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
workbook.save(str(out))
|
|
214
|
+
charts_on_sheet = len(worksheet._charts)
|
|
215
|
+
workbook.close()
|
|
216
|
+
|
|
217
|
+
print(
|
|
218
|
+
json.dumps(
|
|
219
|
+
{
|
|
220
|
+
"ok": True,
|
|
221
|
+
"input": str(source),
|
|
222
|
+
"output": str(out),
|
|
223
|
+
"sheet": args.sheet,
|
|
224
|
+
"charts_added": len(added),
|
|
225
|
+
"charts_on_sheet": charts_on_sheet,
|
|
226
|
+
"charts": added,
|
|
227
|
+
},
|
|
228
|
+
ensure_ascii=False,
|
|
229
|
+
indent=2,
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
sys.exit(main())
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Place an image file on a worksheet at a cell anchor.
|
|
3
|
+
|
|
4
|
+
Images are embedded copies: the workbook keeps the pixels it was given, so an
|
|
5
|
+
image that later changes on disk does not update. Prefer a native chart or a
|
|
6
|
+
table when the content is data; use an image only for logos, screenshots, and
|
|
7
|
+
diagrams that have no cell representation.
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
add_image.py --input book.xlsx --sheet Cover --path logo.png --anchor B2
|
|
11
|
+
add_image.py --input book.xlsx --sheet Cover --path shot.png --anchor B10 --width 640
|
|
12
|
+
|
|
13
|
+
Exit codes:
|
|
14
|
+
0 image embedded (the JSON on stdout reports the anchor and size)
|
|
15
|
+
1 refused: unknown sheet or unsupported image file
|
|
16
|
+
2 usage error or missing file
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
27
|
+
sys.stdout.reconfigure(errors="replace")
|
|
28
|
+
|
|
29
|
+
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff", ".emf", ".wmf"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_args() -> argparse.Namespace:
|
|
33
|
+
parser = argparse.ArgumentParser(description="Embed an image on an .xlsx worksheet.")
|
|
34
|
+
parser.add_argument("--input", required=True, help="source workbook")
|
|
35
|
+
parser.add_argument("--out", help="destination workbook (default: <input>-edited.xlsx)")
|
|
36
|
+
parser.add_argument("--sheet", required=True, help="target worksheet")
|
|
37
|
+
parser.add_argument("--path", required=True, help="image file to embed")
|
|
38
|
+
parser.add_argument("--anchor", default="A1", help="top-left cell the image floats over")
|
|
39
|
+
parser.add_argument("--width", type=int, help="display width in pixels")
|
|
40
|
+
parser.add_argument("--height", type=int, help="display height in pixels")
|
|
41
|
+
return parser.parse_args()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _error(message: str, code: int) -> int:
|
|
45
|
+
print(json.dumps({"ok": False, "error": message}), file=sys.stderr)
|
|
46
|
+
return code
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> int:
|
|
50
|
+
args = parse_args()
|
|
51
|
+
source = Path(args.input).expanduser()
|
|
52
|
+
image_path = Path(args.path).expanduser()
|
|
53
|
+
if not source.is_file():
|
|
54
|
+
return _error(f"input file not found: {source}", 2)
|
|
55
|
+
if not image_path.is_file():
|
|
56
|
+
return _error(f"image file not found: {image_path}", 2)
|
|
57
|
+
if image_path.suffix.lower() not in IMAGE_SUFFIXES:
|
|
58
|
+
return _error(f"unsupported image type: {image_path.suffix}", 1)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
from openpyxl import load_workbook
|
|
62
|
+
from openpyxl.drawing.image import Image as XLImage
|
|
63
|
+
except ImportError as error: # pragma: no cover - environment guard
|
|
64
|
+
return _error(f"openpyxl or Pillow missing: {error}", 2)
|
|
65
|
+
|
|
66
|
+
workbook = load_workbook(str(source))
|
|
67
|
+
if args.sheet not in workbook.sheetnames:
|
|
68
|
+
return _error(f"sheet not found: {args.sheet}", 1)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
image = XLImage(str(image_path))
|
|
72
|
+
except Exception as error: # noqa: BLE001 - Pillow rejects unreadable files
|
|
73
|
+
return _error(f"cannot read image: {error}", 1)
|
|
74
|
+
|
|
75
|
+
if args.width:
|
|
76
|
+
image.width = args.width
|
|
77
|
+
if args.height:
|
|
78
|
+
image.height = args.height
|
|
79
|
+
workbook[args.sheet].add_image(image, args.anchor)
|
|
80
|
+
|
|
81
|
+
out = Path(args.out).expanduser() if args.out else source.with_name(
|
|
82
|
+
f"{source.stem}-edited{source.suffix}"
|
|
83
|
+
)
|
|
84
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
workbook.save(str(out))
|
|
86
|
+
workbook.close()
|
|
87
|
+
|
|
88
|
+
print(
|
|
89
|
+
json.dumps(
|
|
90
|
+
{
|
|
91
|
+
"ok": True,
|
|
92
|
+
"input": str(source),
|
|
93
|
+
"output": str(out),
|
|
94
|
+
"sheet": args.sheet,
|
|
95
|
+
"anchor": args.anchor,
|
|
96
|
+
"image": str(image_path),
|
|
97
|
+
"width": image.width,
|
|
98
|
+
"height": image.height,
|
|
99
|
+
},
|
|
100
|
+
indent=2,
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
sys.exit(main())
|