@qvac/skills 0.0.0 → 0.1.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/LICENSE.md +198 -0
- package/README.md +49 -0
- package/index.d.ts +4 -0
- package/index.js +9 -0
- package/package.json +87 -1
- package/skills/apple-notes/SKILL.md +92 -0
- package/skills/apple-notes/append-note.applescript +9 -0
- package/skills/apple-notes/cli.schema.json +32 -0
- package/skills/apple-notes/create-note.applescript +15 -0
- package/skills/apple-notes/delete-note.applescript +10 -0
- package/skills/apple-notes/edit-note.applescript +10 -0
- package/skills/apple-notes/read-note.applescript +28 -0
- package/skills/apple-notes/references/read.md +65 -0
- package/skills/apple-notes/references/write.md +105 -0
- package/skills/apple-notes/search-notes.applescript +21 -0
- package/skills/apple-reminders/SKILL.md +129 -0
- package/skills/apple-reminders/cli.schema.json +201 -0
- package/skills/apple-reminders/references/edit.md +69 -0
- package/skills/apple-reminders/references/view.md +58 -0
- package/skills/asana/SKILL.md +59 -0
- package/skills/diagrams/SKILL.md +107 -0
- package/skills/diagrams/references/class.md +29 -0
- package/skills/diagrams/references/er.md +27 -0
- package/skills/diagrams/references/flowchart.md +33 -0
- package/skills/diagrams/references/gantt.md +38 -0
- package/skills/diagrams/references/mindmap.md +35 -0
- package/skills/diagrams/references/pie.md +27 -0
- package/skills/diagrams/references/sequence.md +32 -0
- package/skills/diagrams/references/state.md +30 -0
- package/skills/diagrams/references/timeline.md +28 -0
- package/skills/excel/SKILL.md +120 -0
- package/skills/excel/references/create.md +374 -0
- package/skills/excel/references/edit.md +353 -0
- package/skills/excel/references/read.md +99 -0
- package/skills/github/SKILL.md +42 -0
- package/skills/gmail/SKILL.md +142 -0
- package/skills/gmail/operations.json +71 -0
- package/skills/google-calendar/SKILL.md +139 -0
- package/skills/google-calendar/operations.json +62 -0
- package/skills/google-docs/SKILL.md +74 -0
- package/skills/google-docs/operations.json +61 -0
- package/skills/google-docs/references/create.md +97 -0
- package/skills/google-docs/references/edit.md +146 -0
- package/skills/google-docs/references/read.md +49 -0
- package/skills/google-drive/SKILL.md +118 -0
- package/skills/google-drive/operations.json +40 -0
- package/skills/google-sheets/SKILL.md +71 -0
- package/skills/google-sheets/operations.json +85 -0
- package/skills/google-sheets/references/create.md +54 -0
- package/skills/google-sheets/references/edit.md +124 -0
- package/skills/google-sheets/references/read.md +74 -0
- package/skills/image-generation/SKILL.md +48 -0
- package/skills/music-generation/SKILL.md +76 -0
- package/skills/notion/SKILL.md +61 -0
- package/skills/notion/operations.json +53 -0
- package/skills/notion/references/comments.md +65 -0
- package/skills/notion/references/databases.md +68 -0
- package/skills/notion/references/pages.md +119 -0
- package/skills/notion/references/tasks.md +28 -0
- package/skills/obsidian/SKILL.md +122 -0
- package/skills/obsidian/cli.schema.json +392 -0
- package/skills/obsidian/references/read.md +79 -0
- package/skills/obsidian/references/write.md +67 -0
- package/skills/pdf/SKILL.md +110 -0
- package/skills/pdf/references/create.md +169 -0
- package/skills/pdf/references/transform.md +270 -0
- package/skills/pdf/scripts/decrypt.py +26 -0
- package/skills/pdf/scripts/encrypt.py +25 -0
- package/skills/pdf/scripts/extract_text.py +25 -0
- package/skills/pdf/scripts/merge.py +21 -0
- package/skills/pdf/scripts/rotate.py +27 -0
- package/skills/presentations/SKILL.md +118 -0
- package/skills/presentations/references/create.md +399 -0
- package/skills/presentations/references/edit.md +314 -0
- package/skills/presentations/references/read.md +127 -0
- package/skills/spotify/SKILL.md +86 -0
- package/skills/weather/SKILL.md +33 -0
- package/skills/word/SKILL.md +141 -0
- package/skills/word/references/create.md +368 -0
- package/skills/word/references/edit.md +704 -0
- package/skills/word/references/read.md +141 -0
- package/skills/word/references/replace.md +86 -0
- package/skills/word/scripts/list_paragraphs.py +19 -0
- package/skills/word/scripts/replace_paragraphs.py +58 -0
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
# Editing an Existing Word Document (python-docx)
|
|
2
|
+
|
|
3
|
+
**Stop here if the request changes some of the facts, points, bullets, items,
|
|
4
|
+
or paragraphs** — "replace the first 10 facts", "change fact 3", "swap the
|
|
5
|
+
bullets", "reword paragraph 7". That job is `references/replace.md`: call the
|
|
6
|
+
`skill` tool with `name: "word"` and `file: "references/replace.md"` now, and
|
|
7
|
+
do not use anything in this file for it. No Python is written for that job.
|
|
8
|
+
|
|
9
|
+
Change, replace, extend, trim, or rework a `.docx` that is already in this
|
|
10
|
+
chat by running Python through the `exec` tool: stage it as an input, modify
|
|
11
|
+
paragraphs and tables, and save a **new** output such as
|
|
12
|
+
`existing_revised.docx`. Never overwrite the staged input.
|
|
13
|
+
|
|
14
|
+
## Staging the Document
|
|
15
|
+
|
|
16
|
+
Stage the document as an input **by its `attachmentId`** and open it with
|
|
17
|
+
`Document("existing.docx")`. The id comes from wherever the document entered
|
|
18
|
+
the chat:
|
|
19
|
+
|
|
20
|
+
- **Produced earlier in this chat** — the `attachmentId` is in that `exec` result.
|
|
21
|
+
- **Uploaded by the user** — the `[Attached file …]` line on their message names
|
|
22
|
+
it, when the message carries one:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
[Attached file "report.docx" (application/vnd.openxmlformats-officedocument.wordprocessingml.document) — attachmentId: 4f9c2ab1]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Copy the id verbatim — never placeholders like `att_doc`, `att_image`, or any
|
|
29
|
+
id you made up. A `.docx` is **never** staged id-less: an id-less input
|
|
30
|
+
resolves to an uploaded _image_, so it can never reach a document. If no
|
|
31
|
+
`attachmentId` for the document appears anywhere in the chat, say you cannot
|
|
32
|
+
open that file for editing and ask the user to attach it again — do not invent
|
|
33
|
+
an id, do not stage it id-less, and do not retry. An attachment from an
|
|
34
|
+
earlier turn can be used when its attachment id is available in the
|
|
35
|
+
conversation.
|
|
36
|
+
|
|
37
|
+
**The file only exists if this same `exec` call stages it.** The working
|
|
38
|
+
directory starts empty on every call, so an edit needs an `inputs` entry
|
|
39
|
+
naming the attachment, and `Document("existing.docx")` must use that entry's
|
|
40
|
+
exact `path`. Opening a name that was never staged raises
|
|
41
|
+
`PackageNotFoundError: Package not found at '…'` — the fix is the missing
|
|
42
|
+
`inputs`, never a different file name.
|
|
43
|
+
|
|
44
|
+
## The exec call
|
|
45
|
+
|
|
46
|
+
Images can be staged alongside the document. Tool-produced images
|
|
47
|
+
(`generate_image` output) take the exact `attachmentId` from the tool result;
|
|
48
|
+
an image the user uploaded is staged with `path` only and **no
|
|
49
|
+
`attachmentId` key** — the first id-less entry is the first image of the
|
|
50
|
+
user's latest message, and so on. Id-less entries resolve _images only_.
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"language": "python",
|
|
55
|
+
"packages": ["python-docx==1.2.0"],
|
|
56
|
+
"inputs": [
|
|
57
|
+
{
|
|
58
|
+
"attachmentId": "<id from the exec result or the [Attached file …] line>",
|
|
59
|
+
"path": "existing.docx"
|
|
60
|
+
},
|
|
61
|
+
{ "path": "photo.png" }
|
|
62
|
+
],
|
|
63
|
+
"outputs": ["existing_revised.docx"],
|
|
64
|
+
"command": "..."
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- `packages` — `["python-docx==1.2.0"]` on every call. The PyPI package is
|
|
69
|
+
`python-docx` but the import is `docx`; never list `docx` as the package —
|
|
70
|
+
that resolves a different, abandoned library. Pin the version; this exact
|
|
71
|
+
version ships with the app and installs with no network; any other version
|
|
72
|
+
has to be downloaded, which fails on a device that is offline.
|
|
73
|
+
- `inputs` — staged files land in the working directory under the bare `path`
|
|
74
|
+
names — reference `Document("existing.docx")` /
|
|
75
|
+
`doc.add_picture("photo.png", …)` by that name only. Paths must be unique
|
|
76
|
+
bare filenames.
|
|
77
|
+
- `outputs` — the new file to deliver; a file you write but do not declare
|
|
78
|
+
here is discarded. Never the staged input's name. **Name it after the
|
|
79
|
+
document you edited**, not after the change: keep the input's stem and add a
|
|
80
|
+
marker — `report.docx` edited is `report_revised.docx`. A fresh name picked
|
|
81
|
+
from the new content (`cats.docx` for an edit of `parrot_facts.docx`) reads
|
|
82
|
+
as a second, unrelated document and hides the fact that an edit happened at
|
|
83
|
+
all.
|
|
84
|
+
- `command` — the multi-line Python source, with real newline characters.
|
|
85
|
+
Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a
|
|
86
|
+
semicolon is a `SyntaxError`. There is no shell and no installer — packages
|
|
87
|
+
are declared in `packages`.
|
|
88
|
+
|
|
89
|
+
If the image was staged in `inputs`, embed it in **that** single build with
|
|
90
|
+
`doc.add_picture("photo.png", width=Inches(5.5))` — never deliver a document
|
|
91
|
+
and then rebuild to add the image. **Do not soft-fail images or imports**:
|
|
92
|
+
never wrap `add_picture` or an import in `try`/`except` that prints a warning
|
|
93
|
+
and continues — a document saved without the requested image is a failed
|
|
94
|
+
turn, not a success. Pass only one of `width`/`height`; passing both distorts
|
|
95
|
+
the picture.
|
|
96
|
+
|
|
97
|
+
**Image URLs do not work — never download.** Your Python code has **no
|
|
98
|
+
network access**: `requests`, `urllib`, and `socket` all fail with a network
|
|
99
|
+
error, and `http_request` returns truncated text, never image bytes. Say the
|
|
100
|
+
link cannot be downloaded and ask the user to attach the image itself, or
|
|
101
|
+
offer `generate_image` for a similar visual.
|
|
102
|
+
|
|
103
|
+
## Editing: Work the Objects, Save a New Name
|
|
104
|
+
|
|
105
|
+
One call does the whole edit: open, change, verify the document actually
|
|
106
|
+
changed, save. Keep the fingerprint lines exactly as written — they are what
|
|
107
|
+
stops an edit that silently matched nothing (or a read that only inspected)
|
|
108
|
+
from delivering an unchanged copy of the user's document at `exitCode 0`. The
|
|
109
|
+
fingerprint covers the body **and** the styles part, so a style-only change —
|
|
110
|
+
the resize recipe below — counts as a change too:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
import hashlib
|
|
114
|
+
from docx import Document
|
|
115
|
+
|
|
116
|
+
doc = Document("existing.docx")
|
|
117
|
+
fingerprint = hashlib.md5((doc.element.xml + doc.styles.element.xml).encode()).hexdigest()
|
|
118
|
+
|
|
119
|
+
for paragraph in doc.paragraphs:
|
|
120
|
+
if paragraph.text == "Prepared by the finance team.":
|
|
121
|
+
paragraph.text = "Prepared by the finance team. Revised after board review."
|
|
122
|
+
|
|
123
|
+
table = doc.tables[0]
|
|
124
|
+
row = table.add_row().cells
|
|
125
|
+
row[0].text = "Margin"
|
|
126
|
+
row[1].text = "25%"
|
|
127
|
+
row[2].text = "36%"
|
|
128
|
+
|
|
129
|
+
doc.add_heading("Appendix", level=1)
|
|
130
|
+
doc.add_paragraph("Margins recovered as one-off costs rolled out of the base.")
|
|
131
|
+
|
|
132
|
+
assert (
|
|
133
|
+
hashlib.md5((doc.element.xml + doc.styles.element.xml).encode()).hexdigest() != fingerprint
|
|
134
|
+
), "nothing changed — the edit matched nothing or never ran; fix it, never deliver an unchanged copy"
|
|
135
|
+
doc.save("existing_revised.docx") # a NEW name — never the staged input
|
|
136
|
+
print(f"{len(doc.paragraphs)} paragraphs, {len(doc.tables)} table(s)")
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**An assert that fires is a failed turn to diagnose, not a document to
|
|
140
|
+
deliver**: the usual cause is a paragraph match on text that is not exactly
|
|
141
|
+
there — print the real `.text` values in the rerun, fix the match, and never
|
|
142
|
+
delete the assert to get a file out.
|
|
143
|
+
|
|
144
|
+
**Keep it an `assert`, never a `print` or an `if`.** Two `print` lines showing
|
|
145
|
+
the old and new hashes let a no-op save and deliver anyway, which is the one
|
|
146
|
+
thing the assert exists to stop. They also invite a second miscoding: taking
|
|
147
|
+
both hashes together, before the change. Then they match whatever the edit did,
|
|
148
|
+
and the run reports "nothing changed" over a document that changed correctly.
|
|
149
|
+
Take the second hash after the last mutation and before `save`, and let the
|
|
150
|
+
assert raise.
|
|
151
|
+
|
|
152
|
+
**A printed line is never a reason to call `exec` again.** The guard is the
|
|
153
|
+
assert: if it did not fire and the result carries an attachment, the document is
|
|
154
|
+
delivered and the turn is over, whatever stdout says about it. Re-running to
|
|
155
|
+
check saves the same edit under a second name, and the user gets two documents
|
|
156
|
+
for one request.
|
|
157
|
+
|
|
158
|
+
`doc.paragraphs` walks only the document body — text inside tables, headers, and
|
|
159
|
+
footers is **not** in it. Table text is reached through `doc.tables`; match
|
|
160
|
+
paragraphs by their exact `.text` before rewriting them, and remember the
|
|
161
|
+
formatting-loss rule below.
|
|
162
|
+
|
|
163
|
+
The `add_heading`/`add_paragraph` pair above appends an **Appendix** because
|
|
164
|
+
that is what the sample edit asks for. Copy that shape only when the user
|
|
165
|
+
genuinely wants new content at the end. Substituting content that is already
|
|
166
|
+
in the document — "change the first five points", "rewrite section 2" — is a
|
|
167
|
+
different job with its own recipe and its own guards: see Replacing Content In
|
|
168
|
+
Place.
|
|
169
|
+
|
|
170
|
+
When an edit adds substantial new content — new sections, formatted runs,
|
|
171
|
+
bulleted lists, whole tables — the writing rules apply unchanged: load
|
|
172
|
+
`references/create.md` too and copy its shapes (no markdown characters in
|
|
173
|
+
strings, bold/italic on runs never paragraphs, one string per `add_paragraph`,
|
|
174
|
+
tables built from a header row with `rows=1`).
|
|
175
|
+
|
|
176
|
+
### Setting `paragraph.text` erases formatting
|
|
177
|
+
|
|
178
|
+
Assigning `paragraph.text = "…"` replaces **all** runs with one plain run: every
|
|
179
|
+
bold, italic, size, and color in that paragraph is gone. Fine for plain
|
|
180
|
+
paragraphs; on a formatted paragraph edit the runs instead, or accept the loss
|
|
181
|
+
deliberately. This is the top footgun when editing an uploaded document.
|
|
182
|
+
|
|
183
|
+
### Styles must exist in the document
|
|
184
|
+
|
|
185
|
+
`style="List Bullet"` names a style **inside the document**. A missing name
|
|
186
|
+
raises `KeyError: "no style with name 'List Bullet'"` at `add_paragraph` time.
|
|
187
|
+
An **uploaded** document carries only its own styles — one written by another
|
|
188
|
+
tool may lack even `List Bullet`. When editing, guard once and fall back:
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
names = [s.name for s in doc.styles]
|
|
192
|
+
bullet = "List Bullet" if "List Bullet" in names else None
|
|
193
|
+
doc.add_paragraph("point one", style=bullet) # style=None → Normal
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Replacing Content In Place
|
|
197
|
+
|
|
198
|
+
This recipe is for whole *sections* (a heading plus its body). Changing some
|
|
199
|
+
of the facts, points, bullets, or paragraphs is `references/replace.md` —
|
|
200
|
+
never hand-write a loop for that.
|
|
201
|
+
|
|
202
|
+
`add_paragraph`, `add_heading`, and `add_picture` **always append at the end of
|
|
203
|
+
the document.** None of them takes a position. "Change the first five points",
|
|
204
|
+
"rewrite section 2", "swap these facts for those" are all *replacements*, and
|
|
205
|
+
reaching for `add_*` silently turns them into an append: the original content
|
|
206
|
+
stays where it is, the new content lands after the closing line, and the
|
|
207
|
+
document comes back longer than it started with both versions in it. That is a
|
|
208
|
+
failed turn, not a partial success — it is the most common way this skill goes
|
|
209
|
+
wrong.
|
|
210
|
+
|
|
211
|
+
**A section is a heading plus everything under it, up to the next heading of
|
|
212
|
+
the same or higher rank** — which may be one paragraph, or six bullets, or a
|
|
213
|
+
whole subsection, or nothing at all. Never assume it is exactly one paragraph:
|
|
214
|
+
rewriting the heading and the single paragraph after it leaves the rest of the
|
|
215
|
+
old section sitting under its new title, which is the same contradiction an
|
|
216
|
+
append produces and is just as invisible in the result. Work out where each
|
|
217
|
+
section ends before changing anything.
|
|
218
|
+
|
|
219
|
+
Rank matters as much as position. `Heading 2` under a `Heading 1` is a
|
|
220
|
+
subsection, not the next section, so "replace the first two sections" on a
|
|
221
|
+
document with subheadings must not consume the parent's own subheading as
|
|
222
|
+
section two — the same rule the removal recipe below follows. `rank()` reads
|
|
223
|
+
the level off the style name, and only the shallowest rank counts as a section
|
|
224
|
+
start.
|
|
225
|
+
|
|
226
|
+
**A heading shallower than every other heading is the document's title, not its
|
|
227
|
+
first section.** A document headed `Heading 1` and sectioned `Heading 2` — the
|
|
228
|
+
shape most attached documents have — would otherwise have exactly one
|
|
229
|
+
"section": the title, spanning everything under it. Replacing that section
|
|
230
|
+
replaces the entire document, and nothing about the result says so. The `if`
|
|
231
|
+
drops such a heading before sections are picked, and the whole-document assert
|
|
232
|
+
refuses the span even if one is somehow selected.
|
|
233
|
+
|
|
234
|
+
**One heading is dropped, never a chain of them.** It is an `if`, not a
|
|
235
|
+
`while`: a document is titled once. Stripping repeatedly walks down the
|
|
236
|
+
outline — on a `Heading 1` title over a `Heading 2` phase holding `Heading 3`
|
|
237
|
+
weeks it drops the title, then the phase, and the weeks become the "sections",
|
|
238
|
+
so replacing the first two rewrites the weeks and leaves the phase untouched.
|
|
239
|
+
That is the silent wrong target this section exists to prevent. Stopping after
|
|
240
|
+
one leaves the phase as the only section, and asking for a second raises an
|
|
241
|
+
error that says so.
|
|
242
|
+
|
|
243
|
+
Take one snapshot of `doc.paragraphs` and index into it. **Every string in
|
|
244
|
+
`NEW` is a placeholder** — the sample fills it with report sections so the
|
|
245
|
+
shape is readable, and you replace all of it with the content this request asks
|
|
246
|
+
for. Shipping a sample string in the user's document is a failed turn:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from docx import Document
|
|
250
|
+
|
|
251
|
+
doc = Document("existing.docx")
|
|
252
|
+
paras = doc.paragraphs # one snapshot — index into THIS list
|
|
253
|
+
blocks = list(doc.element.body) # paragraphs AND tables, in document order
|
|
254
|
+
|
|
255
|
+
NEW = [ # placeholders — you write every string here
|
|
256
|
+
("Regional Performance", "Revenue grew in every region except EMEA, where the quarter closed flat."),
|
|
257
|
+
("Cost Base", "Headcount costs fell as the contractor pool wound down, and the saving held."),
|
|
258
|
+
]
|
|
259
|
+
TARGET = range(len(NEW)) # which sections to replace — here the first len(NEW)
|
|
260
|
+
|
|
261
|
+
def rank(paragraph): # "Heading 2" -> 2; a bare "Heading" is rank 1
|
|
262
|
+
tail = paragraph.style.name.split()[-1]
|
|
263
|
+
return int(tail) if tail.isdigit() else 1
|
|
264
|
+
|
|
265
|
+
heads = [i for i, p in enumerate(paras) if p.style.name.startswith("Heading")]
|
|
266
|
+
if len(heads) > 1 and all(rank(paras[heads[0]]) < rank(paras[i]) for i in heads[1:]):
|
|
267
|
+
heads = heads[1:] # a lone heading above all the rest is the title
|
|
268
|
+
top = min((rank(paras[i]) for i in heads), default=1)
|
|
269
|
+
starts = [i for i in heads if rank(paras[i]) == top] # sections, never their subsections
|
|
270
|
+
ends = [next((j for j in heads if j > i and rank(paras[j]) <= top), len(paras)) for i in starts]
|
|
271
|
+
|
|
272
|
+
assert NEW, "NEW is empty — write the replacement content before running the edit"
|
|
273
|
+
assert len(TARGET) == len(NEW), f"TARGET names {len(TARGET)} sections but NEW has {len(NEW)} items"
|
|
274
|
+
assert len(starts) > max(TARGET), f"TARGET reaches section {max(TARGET) + 1}, but the document has {len(starts)}"
|
|
275
|
+
at = [blocks.index(p._element) for p in paras] # where each paragraph sits among the blocks
|
|
276
|
+
for k in TARGET:
|
|
277
|
+
assert ends[k] > starts[k] + 1, f"section {paras[starts[k]].text!r} has no body paragraph to replace"
|
|
278
|
+
assert (starts[k], ends[k]) != (heads[0], len(paras)), f"section {paras[starts[k]].text!r} spans the whole document — that is a rewrite, not a section replacement"
|
|
279
|
+
span = blocks[at[starts[k]] : at[ends[k]] if ends[k] < len(paras) else len(blocks)]
|
|
280
|
+
assert not any(el.tag.endswith("}tbl") for el in span), f"section {paras[starts[k]].text!r} holds a table — this recipe replaces paragraphs only"
|
|
281
|
+
|
|
282
|
+
# measured from the document, before anything changes — never from what the loop below does
|
|
283
|
+
before = len(paras)
|
|
284
|
+
old_body = [p.text for k in TARGET for p in paras[starts[k] + 1 : ends[k]]]
|
|
285
|
+
doomed = [(p.text, p._element) for k in TARGET for p in paras[starts[k] + 2 : ends[k]]]
|
|
286
|
+
expected = before - len(old_body) + len(NEW) # each replaced section keeps exactly one body paragraph
|
|
287
|
+
|
|
288
|
+
for k, (title, body) in zip(TARGET, NEW):
|
|
289
|
+
paras[starts[k]].text = title # the heading keeps its own style
|
|
290
|
+
paras[starts[k] + 1].text = body
|
|
291
|
+
paras[starts[k] + 1].style = doc.styles["Normal"] # the reused paragraph may have been a bullet
|
|
292
|
+
for p in paras[starts[k] + 2 : ends[k]]: # whatever else the section held
|
|
293
|
+
p._element.getparent().remove(p._element)
|
|
294
|
+
|
|
295
|
+
assert len(doc.paragraphs) == expected, f"expected {expected} paragraphs, got {len(doc.paragraphs)} — an old section was not fully replaced, or content was appended"
|
|
296
|
+
for text, el in doomed:
|
|
297
|
+
assert el.getparent() is None, f"an old paragraph is still in the document: {text[:40]!r}"
|
|
298
|
+
doc.save("existing_revised.docx") # a NEW name — never the staged input
|
|
299
|
+
print(f"{len(NEW)} of {len(starts)} sections replaced, {before} -> {len(doc.paragraphs)} paragraphs")
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`TARGET` names the sections to replace, once, and `zip` pairs each new item with
|
|
303
|
+
the section it overwrites. Replacing a different range is a change to that one
|
|
304
|
+
line — `TARGET = range(2, 5)` for "sections 3 through 5", with three items in
|
|
305
|
+
`NEW` to match. Keep it bound in a single place: a range written twice drifts
|
|
306
|
+
apart the moment one copy is edited, and every guard below reads `TARGET`
|
|
307
|
+
rather than assuming the range starts at zero.
|
|
308
|
+
|
|
309
|
+
Assigning `paras[head].text` keeps that paragraph's style, because the style
|
|
310
|
+
lives on the paragraph and not on its runs: a `Heading 2` stays a `Heading 2`.
|
|
311
|
+
Only the run-level formatting inside it is lost, per the rule above. The body
|
|
312
|
+
paragraph is the opposite case — it is reused, so it arrives carrying whatever
|
|
313
|
+
style the old body had, which is why the sample sets it back to `Normal`. Set it
|
|
314
|
+
to something else when the new body should be a bullet or a quote, and guard the
|
|
315
|
+
name as shown under Styles.
|
|
316
|
+
|
|
317
|
+
**Keep the document's own numbering.** The sample titles carry no `1.`, `2.`
|
|
318
|
+
prefix because the document it edits does not number itself, and a typed prefix
|
|
319
|
+
on a `List Number` paragraph double-numbers. When the headings you are
|
|
320
|
+
overwriting *do* carry manual numbers, take each number from the position being
|
|
321
|
+
overwritten so the sequence continues — replacing sections 3 through 5 writes
|
|
322
|
+
`3.`, `4.`, `5.`, never restarting at `1.`
|
|
323
|
+
|
|
324
|
+
**Every assert, exactly as written — and measured before the loop runs.**
|
|
325
|
+
`old_body` and `expected` come from the document's own structure, never from
|
|
326
|
+
what the loop reports about itself. That is the whole point: a loop that
|
|
327
|
+
rewrites only the paragraph after each heading, the mistake this recipe exists
|
|
328
|
+
to prevent, would tally its own work as complete. Derived up front, the numbers
|
|
329
|
+
contradict it. None of these failures is distinguishable from success by
|
|
330
|
+
`exitCode 0` plus an attachment:
|
|
331
|
+
|
|
332
|
+
- **`assert NEW`** catches an empty content list. Without it `max(TARGET)`
|
|
333
|
+
raises a bare `ValueError`, and were it not for that the run would save an
|
|
334
|
+
untouched copy of the user's document at `exitCode 0`.
|
|
335
|
+
- **`len(TARGET) == len(NEW)`** catches a target range and a content list that
|
|
336
|
+
drifted apart. `zip` would silently pair only the shorter of the two.
|
|
337
|
+
- **`len(starts) > max(TARGET)`** catches a range reaching past the last
|
|
338
|
+
section. It reads `TARGET`, not `len(NEW)`, because the range need not start
|
|
339
|
+
at zero — a `len(NEW)` check passes on `range(2, 5)` over four sections and
|
|
340
|
+
the run then dies on an `IndexError` that names nothing.
|
|
341
|
+
- **the whole-document assert** refuses a section running from the first
|
|
342
|
+
heading to the last paragraph. That is not a replacement, it is a rewrite:
|
|
343
|
+
every other check passes while the document is emptied down to one heading
|
|
344
|
+
and one paragraph. It anchors on `heads[0]`, not paragraph 0 — a document
|
|
345
|
+
whose only heading sits under a draft notice, a date, or a byline still has
|
|
346
|
+
exactly one section, and anchoring on index 0 would wave it through.
|
|
347
|
+
- **`ends[k] > starts[k] + 1`** catches a section with no body paragraph — a
|
|
348
|
+
heading followed straight by a table, or the last heading in the document.
|
|
349
|
+
There is nothing under it to rewrite. It runs before any mutation, so a bad
|
|
350
|
+
target changes nothing.
|
|
351
|
+
- **the `}tbl` assert** catches a table inside a section being replaced.
|
|
352
|
+
`doc.paragraphs` does not see tables, so the loop below cannot remove one:
|
|
353
|
+
without this the old table survives under the new heading with every other
|
|
354
|
+
check passing. Say the table has to be rebuilt, or target a different section.
|
|
355
|
+
- **the `expected` assert** catches an old section left partly in place *and*
|
|
356
|
+
new content appended, because `expected` is what the paragraph count must be
|
|
357
|
+
once each replaced section holds exactly one body paragraph.
|
|
358
|
+
- **the `getparent() is None` assert** catches an old paragraph the loop was
|
|
359
|
+
supposed to drop but left attached. Compare **elements, not text**: text
|
|
360
|
+
comparison cannot tell a paragraph that survived from an identical one
|
|
361
|
+
standing legitimately elsewhere, and a document that repeats a line — three
|
|
362
|
+
status sections each reading `Nothing to report.` — would fail a correct edit
|
|
363
|
+
with no way to satisfy the assert. Identity has no such collision, and it
|
|
364
|
+
needs no special case for blank paragraphs.
|
|
365
|
+
|
|
366
|
+
An assert that fires is a failed turn to diagnose, never a document to deliver.
|
|
367
|
+
|
|
368
|
+
### When the replacement needs more than one paragraph
|
|
369
|
+
|
|
370
|
+
The loop above reuses one paragraph per section and drops the rest. When a
|
|
371
|
+
replacement needs an **extra** paragraph, insert it before the paragraph that
|
|
372
|
+
should follow it. `insert_paragraph_before` is the only insert there is, and it
|
|
373
|
+
is a method on the paragraph you want to push down:
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
anchor = paras[ends[k]] # the next section's heading
|
|
377
|
+
extra = anchor.insert_paragraph_before("A second body paragraph.", style="Normal")
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
It takes the same style names as `add_paragraph` (`"Heading 2"`, `"List
|
|
381
|
+
Bullet"`, `None` for Normal) and returns the new paragraph, so runs can be
|
|
382
|
+
formatted on it. Inserting a whole new section is this call once per paragraph,
|
|
383
|
+
each against the heading it goes above. A section at the very end of the
|
|
384
|
+
document has no next heading to anchor to — `ends[k]` is `len(paras)` — so
|
|
385
|
+
append there with `doc.add_paragraph`, the one case where appending is right.
|
|
386
|
+
|
|
387
|
+
Inserting does not disturb the `paras` snapshot: it is a plain Python list
|
|
388
|
+
holding the paragraphs that already existed, so every index taken before the
|
|
389
|
+
insert still points at the same paragraph afterwards. Only a fresh
|
|
390
|
+
`doc.paragraphs` shifts.
|
|
391
|
+
|
|
392
|
+
Count what you insert and fold it into `expected` rather than dropping the
|
|
393
|
+
guard — `expected = before - len(old_body) + len(NEW) + added` — so an
|
|
394
|
+
accidental append is still caught.
|
|
395
|
+
|
|
396
|
+
## Removing Content
|
|
397
|
+
|
|
398
|
+
python-docx has **no delete API.** There is no `doc.remove_paragraph` and no
|
|
399
|
+
`paragraph.delete`, and `doc.paragraphs` is rebuilt on every access, so
|
|
400
|
+
`doc.paragraphs.remove(p)` edits a throwaway list and changes nothing in the file.
|
|
401
|
+
Removing anything means dropping its XML element from the parent — this one line
|
|
402
|
+
is the whole technique, and there is no alternative to it:
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
p._element.getparent().remove(p._element)
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Code that finds the paragraphs and never runs that line — a `for`/`if` that
|
|
409
|
+
matches the text and falls through, or a comment like
|
|
410
|
+
`# Find and remove paragraphs containing "Conclusion"` standing in for the
|
|
411
|
+
removal — saves a document byte-identical to the input at `exitCode 0`, with an
|
|
412
|
+
attachment that looks like a success. Nothing in the result says the edit was a
|
|
413
|
+
no-op, which is why the sample below asserts the count changed before it saves.
|
|
414
|
+
|
|
415
|
+
Because `doc.paragraphs` is a fresh list each time, `for p in doc.paragraphs:`
|
|
416
|
+
walks a snapshot and removing inside the loop is safe.
|
|
417
|
+
|
|
418
|
+
**A whole section** — a heading plus everything under it, up to the next heading
|
|
419
|
+
of the same or higher rank — is that line plus a flag. Track the heading's level,
|
|
420
|
+
or a sub-heading inside the section ends the removal early and orphans the
|
|
421
|
+
paragraphs below it:
|
|
422
|
+
|
|
423
|
+
```python
|
|
424
|
+
from docx import Document
|
|
425
|
+
|
|
426
|
+
doc = Document("existing.docx")
|
|
427
|
+
|
|
428
|
+
TARGET = "Conclusion" # the heading text that opens the section
|
|
429
|
+
|
|
430
|
+
before = len(doc.paragraphs)
|
|
431
|
+
depth = None # the target heading's level while removing
|
|
432
|
+
for p in doc.paragraphs:
|
|
433
|
+
style = p.style.name # a style object — compare through .name
|
|
434
|
+
if style.startswith("Heading"):
|
|
435
|
+
tail = style.split()[-1]
|
|
436
|
+
level = int(tail) if tail.isdigit() else 1 # "Heading 2" -> 2
|
|
437
|
+
if depth is not None and level <= depth:
|
|
438
|
+
depth = None # a sibling heading closes the section
|
|
439
|
+
if p.text.strip() == TARGET:
|
|
440
|
+
depth = level
|
|
441
|
+
if depth is not None:
|
|
442
|
+
p._element.getparent().remove(p._element)
|
|
443
|
+
|
|
444
|
+
after = len(doc.paragraphs)
|
|
445
|
+
assert after < before, f"removed nothing ({before} -> {after}) — the match never fired"
|
|
446
|
+
doc.save("existing_revised.docx") # a NEW name — never the staged input
|
|
447
|
+
print(f"{before} -> {after} paragraphs")
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Find the heading through `p.style.name`, never the text alone — a body paragraph
|
|
451
|
+
that mentions "Conclusion" is not the section heading. Removing individual
|
|
452
|
+
paragraphs is the same loop without the flag: match them, and call the removal
|
|
453
|
+
line on each one.
|
|
454
|
+
|
|
455
|
+
**Assert the count changed, before you save.** Keep the
|
|
456
|
+
`assert after < before` line exactly where the sample puts it — between the loop
|
|
457
|
+
and `doc.save(...)` — and do not soften it to a `print` or an `if`. It is what
|
|
458
|
+
makes a no-op impossible to deliver: the assert raises, `save` never runs, so no
|
|
459
|
+
file is written and the result comes back with `missingOutputs` instead of an
|
|
460
|
+
attachment. Without it a removal that never fired still saves the unchanged
|
|
461
|
+
document, and the run is indistinguishable from a real edit — `exitCode 0`, an
|
|
462
|
+
attachment, and nothing anywhere saying the document is a copy of the input.
|
|
463
|
+
|
|
464
|
+
An assert that fires is a **failed turn to diagnose**, never a result to report.
|
|
465
|
+
It means the match did not fire: wrong heading text, a heading style the document
|
|
466
|
+
does not use, or text living in a table, header, or footer, which
|
|
467
|
+
`doc.paragraphs` never walks. Fix the match and rerun — do not delete the assert
|
|
468
|
+
to get a file out.
|
|
469
|
+
|
|
470
|
+
The printed `before -> after` line is then just the reply line (`16 -> 12
|
|
471
|
+
paragraphs`), not the check. Both live inside the build, so this takes no extra
|
|
472
|
+
call: the assert and the `print` are in the same `exec` that does the removal.
|
|
473
|
+
|
|
474
|
+
This assert is also why "Success = stop" needs no second call on a destructive
|
|
475
|
+
edit: `exitCode: 0` plus an attachment cannot on its own tell a real edit from
|
|
476
|
+
a copy of the input, because a removal that never fired produces both. The
|
|
477
|
+
build itself closes that gap — it asserts the count changed before `save`, so
|
|
478
|
+
a no-op returns `missingOutputs` rather than a convincing attachment. A
|
|
479
|
+
delivered document is still never reopened to "verify" it; the fix for a
|
|
480
|
+
failed assert is a corrected build, never an `exec` opened to inspect what was
|
|
481
|
+
already delivered.
|
|
482
|
+
|
|
483
|
+
**Table rows** have no delete API either, and take the same idiom on the row's own
|
|
484
|
+
element. `table.rows` iterates a snapshot just as `doc.paragraphs` does, so
|
|
485
|
+
removing inside the loop is safe — and the count gets the same assert, because a
|
|
486
|
+
row matched by its cell text can miss exactly the way a paragraph can:
|
|
487
|
+
|
|
488
|
+
```python
|
|
489
|
+
table = doc.tables[0]
|
|
490
|
+
|
|
491
|
+
before = len(table.rows)
|
|
492
|
+
for row in table.rows:
|
|
493
|
+
if row.cells[0].text == "Discontinued":
|
|
494
|
+
row._element.getparent().remove(row._element)
|
|
495
|
+
assert len(table.rows) < before, f"no row matched ({before} rows unchanged)"
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Removing a row by position needs no assert — `table.rows[9]` on a 4-row table
|
|
499
|
+
raises `IndexError` rather than quietly doing nothing:
|
|
500
|
+
|
|
501
|
+
```python
|
|
502
|
+
row = table.rows[2]
|
|
503
|
+
row._element.getparent().remove(row._element)
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Rows only grow at the bottom: there is no insert-at, and no delete either
|
|
507
|
+
outside this idiom. Address cells as `table.cell(row, col)` or
|
|
508
|
+
`table.rows[r].cells[c]` — they are the same cell; for formatting inside a
|
|
509
|
+
cell go through `cell.paragraphs[0]` and its runs like any other paragraph.
|
|
510
|
+
|
|
511
|
+
**Table columns cannot be removed.** A column is not one element — it is an entry
|
|
512
|
+
in the table grid plus one cell in every row — and a horizontally merged cell is a
|
|
513
|
+
single `<w:tc>` shared across two grid positions, so removing "the second cell" of
|
|
514
|
+
every row deletes that merged cell whole and leaves its row a column short. The
|
|
515
|
+
document opens visibly ragged and nothing raises. Rebuild the table with the
|
|
516
|
+
columns you want instead, or say the column has to be dropped in Word.
|
|
517
|
+
|
|
518
|
+
## Reading What Is Already in the Document
|
|
519
|
+
|
|
520
|
+
A document exposes exactly two collections — `doc.paragraphs` and `doc.tables`.
|
|
521
|
+
Everything else is derived by filtering them; there is no `doc.headings`, no
|
|
522
|
+
`doc.sections_by_title`, no `doc.text`. A `Paragraph` has `.text`, `.style` and
|
|
523
|
+
`.runs`, and no `.paragraphs` of its own.
|
|
524
|
+
|
|
525
|
+
**`paragraph.style` is a style object, not a string** — compare through
|
|
526
|
+
`.name`, or you get
|
|
527
|
+
`AttributeError: 'ParagraphStyle' object has no attribute 'startswith'`:
|
|
528
|
+
|
|
529
|
+
```python
|
|
530
|
+
headings = [p for p in doc.paragraphs if p.style.name.startswith("Heading")]
|
|
531
|
+
body = [p for p in doc.paragraphs if p.style.name == "Normal"]
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
Most edits need no inspection at all — go straight to the change. When a look
|
|
535
|
+
is genuinely needed first (an exact `.text` to match, a style name), that call
|
|
536
|
+
only prints: **an inspection never saves and declares no `outputs`** — a save
|
|
537
|
+
without the change delivers a stale copy of the user's document. The new file
|
|
538
|
+
comes only from the one call that changes it. Reading for the _user_ — a
|
|
539
|
+
summary or an answer delivered as chat text — is its own flow with its own
|
|
540
|
+
call shape: load `references/read.md`.
|
|
541
|
+
|
|
542
|
+
## Resizing Text: Set the Styles, Never Scale `run.font.size`
|
|
543
|
+
|
|
544
|
+
`run.font.size` is `None` whenever the size comes from the paragraph's style,
|
|
545
|
+
which is the normal case for a document you did not hand-size. **`None` does not
|
|
546
|
+
mean zero.** Reading it as a number and scaling it writes a 0pt font, and 0pt
|
|
547
|
+
text is invisible in Word and Pages — the document opens looking blank, with no
|
|
548
|
+
error anywhere to tell you why:
|
|
549
|
+
|
|
550
|
+
```python
|
|
551
|
+
size = run.font.size.pt if run.font.size else 0
|
|
552
|
+
run.font.size = Pt(size * 1.5) # WRONG: 0 * 1.5 = 0pt, invisible text
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
"Make the font bigger" is a change to the **styles**, because every run without
|
|
556
|
+
its own size inherits from them. Set absolute point sizes on the styles the
|
|
557
|
+
document actually uses, and the whole document — body, tables, headers — follows
|
|
558
|
+
in four lines:
|
|
559
|
+
|
|
560
|
+
```python
|
|
561
|
+
from docx import Document
|
|
562
|
+
from docx.shared import Pt
|
|
563
|
+
|
|
564
|
+
doc = Document("existing.docx")
|
|
565
|
+
|
|
566
|
+
doc.styles["Normal"].font.size = Pt(14) # body text; 11pt is the default
|
|
567
|
+
doc.styles["List Bullet"].font.size = Pt(14)
|
|
568
|
+
doc.styles["Heading 1"].font.size = Pt(20)
|
|
569
|
+
doc.styles["Title"].font.size = Pt(32)
|
|
570
|
+
|
|
571
|
+
doc.save("larger.docx")
|
|
572
|
+
print(f"{len(doc.paragraphs)} paragraphs resized")
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
**Keep the hierarchy.** Raise every style you touch, not one size for all of
|
|
576
|
+
them — a title and a heading set to the body size read as unstyled text. Body
|
|
577
|
+
around 14pt pairs with roughly 20pt headings and a 32pt title, and the same
|
|
578
|
+
ratios hold at any size the user asks for.
|
|
579
|
+
|
|
580
|
+
Only touch a style the document has — guard with the `doc.styles` check above
|
|
581
|
+
when unsure. If a specific run really must be sized on its own, assign an
|
|
582
|
+
absolute `Pt(...)` value; never one derived from the size you read back.
|
|
583
|
+
|
|
584
|
+
## Errors
|
|
585
|
+
|
|
586
|
+
- `PackageNotFoundError: Package not found at '…'` — the document was never
|
|
587
|
+
staged, or an id-less entry staged an image under a `.docx` path. Add
|
|
588
|
+
`inputs: [{ "attachmentId": "<id from the exec result or the [Attached file …]
|
|
589
|
+
line>", "path": "existing.docx" }]` and open that exact path. If no id is
|
|
590
|
+
available, ask the user to attach the file again rather than guessing a name.
|
|
591
|
+
- `attachment … not found in this chat` means `inputs` listed an id that is not
|
|
592
|
+
in this chat (often a copied placeholder like `att_doc`). Re-copy the exact id
|
|
593
|
+
from the `exec` result or the `[Attached file …]` line that names the file;
|
|
594
|
+
if no id appears anywhere in the chat, ask the user to re-attach.
|
|
595
|
+
- `ModuleNotFoundError: No module named 'docx'` means `packages` was missing or
|
|
596
|
+
wrong — add `["python-docx==1.2.0"]` and rerun. Never try to install it, and
|
|
597
|
+
never "fix" it by importing `python_docx`; the import stays `docx`.
|
|
598
|
+
- `KeyError: "no style with name '…'"` — the style is not in this document.
|
|
599
|
+
Check `doc.styles` and fall back as shown under Styles.
|
|
600
|
+
- `NameError: name 'Pt' is not defined` (or `Inches`, `RGBColor`) — the import
|
|
601
|
+
line is missing that name; they all import from `docx.shared`.
|
|
602
|
+
- `AssertionError: removed nothing (16 -> 16)` means the removal matched nothing:
|
|
603
|
+
either the loop never fired or it never called
|
|
604
|
+
`p._element.getparent().remove(p._element)`; python-docx has no delete method to
|
|
605
|
+
reach for instead. Diagnose the match and rerun — never delete the assert to get
|
|
606
|
+
a file out, since the file it would produce is a copy of the input.
|
|
607
|
+
- `AssertionError: expected 7 paragraphs, got 9` means the document did not end
|
|
608
|
+
up the shape a replacement makes. Two causes: the old sections were only
|
|
609
|
+
partly replaced — the loop rewrote the paragraph after each heading and left
|
|
610
|
+
the rest of the section standing — or new content was appended with
|
|
611
|
+
`add_paragraph`/`add_heading`, which only ever append. `expected` is derived
|
|
612
|
+
from the section bounds before anything changes, so it is right and the
|
|
613
|
+
document is wrong: replace each section through to the next heading.
|
|
614
|
+
- `AssertionError: an old paragraph is still in the document: '…'` means the
|
|
615
|
+
loop was adapted and no longer drops everything past the paragraph it reuses.
|
|
616
|
+
Every paragraph from `starts[k] + 2` to `ends[k]` has to go; the removal idiom
|
|
617
|
+
below is the only thing that removes one. This compares elements, so it never
|
|
618
|
+
fires because the document happens to repeat a line elsewhere.
|
|
619
|
+
- `AssertionError: section '…' has no body paragraph to replace` means that
|
|
620
|
+
heading is followed straight by a table, or is the last paragraph in the
|
|
621
|
+
document. There is nothing under it to rewrite: target a different section, or
|
|
622
|
+
insert the body with `insert_paragraph_before` before adding to it.
|
|
623
|
+
- `IndexError: list index out of range` while walking sections means an index
|
|
624
|
+
ran past the end of `starts` or of `paras`: a `TARGET` reaching past the last
|
|
625
|
+
section, or `paras[i + 1]` on a document whose final paragraph is a heading.
|
|
626
|
+
Guard the range with `len(starts) > max(TARGET)` — not against `len(NEW)`,
|
|
627
|
+
which says nothing when the range does not start at zero — and take section
|
|
628
|
+
ends from the next heading of the same or higher rank, with `len(paras)`
|
|
629
|
+
closing the last one.
|
|
630
|
+
- `AssertionError: section '…' holds a table` means the section being replaced
|
|
631
|
+
contains a table. `doc.paragraphs` never sees tables, so the loop cannot
|
|
632
|
+
remove one and it would survive under the new heading. Rebuild the table
|
|
633
|
+
explicitly, or tell the user that section has to be replaced by hand.
|
|
634
|
+
- `AssertionError: TARGET names 2 sections but NEW has 3 items` means the range
|
|
635
|
+
and the content list drifted apart. Fix whichever is wrong; do not let `zip`
|
|
636
|
+
quietly use the shorter.
|
|
637
|
+
- `AssertionError: TARGET reaches section 2, but the document has 1` on a
|
|
638
|
+
document that plainly has several usually means its sections are `Heading 2`
|
|
639
|
+
under a `Heading 1` title. The title is dropped before sections are picked,
|
|
640
|
+
so check `rank()` is reading the style names this document actually uses —
|
|
641
|
+
print `[p.style.name for p in doc.paragraphs]` — rather than lowering
|
|
642
|
+
`TARGET` until the assert passes. Section 1 of a title-only document is the
|
|
643
|
+
whole document.
|
|
644
|
+
- `AssertionError: section '…' spans the whole document` means the heading
|
|
645
|
+
selected covers every paragraph, so replacing it would empty the document.
|
|
646
|
+
It is a title being treated as a section, or a request to rewrite rather than
|
|
647
|
+
edit — build a new document with `references/create.md` if that is what the
|
|
648
|
+
user wants.
|
|
649
|
+
- `AttributeError: 'Document' object has no attribute 'insert_paragraph'` means
|
|
650
|
+
the code guessed an insert API on the document. There is none. The only insert
|
|
651
|
+
is `paragraph.insert_paragraph_before(text, style)`, on the paragraph the new
|
|
652
|
+
one goes above.
|
|
653
|
+
- `TypeError: Document.add_paragraph() takes from 1 to 3 positional arguments
|
|
654
|
+
but 4 were given` means a position was passed to `add_paragraph`. It has no
|
|
655
|
+
position parameter and always appends; use `insert_paragraph_before`.
|
|
656
|
+
- Identical old and new fingerprints on a run that saved anyway means the
|
|
657
|
+
assert was softened into `print` lines and both hashes were taken before the
|
|
658
|
+
change. It is not evidence the edit failed, and it is not grounds for another
|
|
659
|
+
`exec`: restore the assert and take the second hash after the mutation.
|
|
660
|
+
- A delivered document identical to the one you opened means an edit ran
|
|
661
|
+
without the fingerprint assert — an edit that matched nothing, or an
|
|
662
|
+
inspection that saved. Add the assert before `save` and rerun the actual
|
|
663
|
+
change.
|
|
664
|
+
- `AssertionError: nothing changed — the edit matched nothing or never ran`
|
|
665
|
+
means exactly that: the paragraph match found no text, or no mutation
|
|
666
|
+
happened before `save`. Print the real `.text` values, fix the match, rerun
|
|
667
|
+
— never remove the assert.
|
|
668
|
+
- `TypeError: 'Table' object is not subscriptable` — a table was indexed
|
|
669
|
+
directly (`table[0]`). Cells are reached through `table.rows[r].cells[c]` or
|
|
670
|
+
`table.cell(r, c)`; a whole row of cells is `table.add_row().cells`.
|
|
671
|
+
- `AttributeError: 'Document' object has no attribute 'remove_paragraph'` (or
|
|
672
|
+
`'Paragraph' object has no attribute 'delete'`) means the code guessed a delete
|
|
673
|
+
API. There is none; drop the XML element instead.
|
|
674
|
+
- A resize that "worked" but left the document blank means a 0pt font: something
|
|
675
|
+
scaled `run.font.size` while it was `None`. Set absolute sizes on the styles
|
|
676
|
+
instead — see Resizing Text.
|
|
677
|
+
- `SyntaxError: invalid syntax` on a one-line `for`/`if` means the source was
|
|
678
|
+
collapsed — restore multi-line newlines from the sample and rerun. Underscores
|
|
679
|
+
in names (`add_paragraph`, not `addparagraph`) must stay. Do not switch to
|
|
680
|
+
`python -c` or change the package pin.
|
|
681
|
+
- On an `AttributeError` from python-docx the API name is wrong; on a `TypeError`
|
|
682
|
+
about positional arguments the call passes the wrong number of them — usually
|
|
683
|
+
several strings where one is allowed. Fix either against this file's examples,
|
|
684
|
+
reading the line number in the traceback. Do not retry the same call, and do
|
|
685
|
+
not switch to a shell.
|
|
686
|
+
- If the result has `missingOutputs`, the file was never written. Read stderr
|
|
687
|
+
first: an `AssertionError` there means a guard stopped the save on purpose
|
|
688
|
+
and its message names what to fix — rerunning the same code fails the same
|
|
689
|
+
way. Only when stderr is clean is this a naming problem: check the `save()`
|
|
690
|
+
name matches the declared output and rerun once.
|
|
691
|
+
- Never print the document's bytes or base64 — stdout is capped and the file
|
|
692
|
+
travels through `outputs`. A build call prints exactly one line (e.g. `9
|
|
693
|
+
paragraphs, 1 table(s)`).
|
|
694
|
+
- Never pass an absolute path to `save()`.
|
|
695
|
+
|
|
696
|
+
## Finish
|
|
697
|
+
|
|
698
|
+
When `exitCode` is `0` and `attachments` lists the `.docx`, the edit is done —
|
|
699
|
+
the `exec` result carries
|
|
700
|
+
`attachments: [{ attachmentId, fileName, byteLength }]` and the file is already
|
|
701
|
+
attached to the chat for the user to open or save. Stop tool use and reply
|
|
702
|
+
with a single line: file name + the count line from stdout. Exactly one
|
|
703
|
+
successful `exec` per request; never reopen a delivered document to "verify"
|
|
704
|
+
it.
|