@qvac/skills 0.1.10 → 0.1.12
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/bundled.js +11 -29
- package/hash.js +1 -1
- package/package.json +1 -1
- package/skills/excel/SKILL.md +146 -111
- package/skills/gmail/SKILL.md +7 -4
- package/skills/google-calendar/SKILL.md +7 -4
- package/skills/google-docs/SKILL.md +7 -5
- package/skills/google-drive/SKILL.md +7 -4
- package/skills/google-sheets/SKILL.md +7 -5
- package/skills/music-generation/SKILL.md +15 -0
- package/skills/pdf/SKILL.md +146 -96
- package/skills/presentations/SKILL.md +133 -109
- package/skills/sheet-music/SKILL.md +93 -0
- package/skills/word/SKILL.md +144 -124
- package/skills/excel/references/create.md +0 -374
- package/skills/excel/references/edit.md +0 -357
- package/skills/excel/references/read.md +0 -99
- package/skills/pdf/references/create.md +0 -169
- package/skills/pdf/references/transform.md +0 -270
- package/skills/pdf/scripts/decrypt.py +0 -26
- package/skills/pdf/scripts/encrypt.py +0 -25
- package/skills/pdf/scripts/extract_text.py +0 -25
- package/skills/pdf/scripts/merge.py +0 -21
- package/skills/pdf/scripts/rotate.py +0 -27
- package/skills/presentations/references/create.md +0 -399
- package/skills/presentations/references/edit.md +0 -314
- package/skills/presentations/references/read.md +0 -127
- package/skills/word/references/create.md +0 -400
- package/skills/word/references/paragraphs.md +0 -125
- package/skills/word/references/read.md +0 -141
- package/skills/word/references/rework.md +0 -767
- package/skills/word/scripts/list_paragraphs.py +0 -25
- package/skills/word/scripts/replace_paragraphs.py +0 -58
|
@@ -1,357 +0,0 @@
|
|
|
1
|
-
# Editing an Attached Workbook (openpyxl)
|
|
2
|
-
|
|
3
|
-
Edit a workbook that is already in this chat by running Python through the
|
|
4
|
-
`exec` tool: change cells, add or insert rows, add columns or sheets, delete
|
|
5
|
-
rows, columns, or sheets. Stage the workbook as an input, modify it, and save
|
|
6
|
-
under a **new** output name such as `revised.xlsx` — never overwrite the staged
|
|
7
|
-
input.
|
|
8
|
-
|
|
9
|
-
**No `.xlsx` in this chat means this is not an edit.** "Put this image in a
|
|
10
|
-
spreadsheet" with no workbook to open is a CREATE: load `references/create.md` and
|
|
11
|
-
build a new one. Never ask the user to attach a workbook they did not mention.
|
|
12
|
-
|
|
13
|
-
Macro-enabled files (`.xlsm`) can be staged as inputs and read, but this
|
|
14
|
-
runtime cannot deliver `.xlsm` back — macros never survive. Save the edit as
|
|
15
|
-
`.xlsx` and tell the user the macros were not preserved.
|
|
16
|
-
|
|
17
|
-
## Staging the Workbook
|
|
18
|
-
|
|
19
|
-
**A workbook you built earlier in this chat is edited exactly like any other
|
|
20
|
-
attachment — through its id.** The `exec` result that produced it carried
|
|
21
|
-
`attachments: [{ attachmentId, fileName, byteLength }]`; scroll back, copy that
|
|
22
|
-
`attachmentId` character for character, and stage it. The working directory is
|
|
23
|
-
wiped between calls, so a file you saved last turn is not on disk — without a
|
|
24
|
-
staged input `load_workbook("report.xlsx")` raises
|
|
25
|
-
`FileNotFoundError: [Errno 44] No such file or directory`.
|
|
26
|
-
|
|
27
|
-
```json
|
|
28
|
-
{
|
|
29
|
-
"language": "python",
|
|
30
|
-
"packages": ["openpyxl==3.1.5"],
|
|
31
|
-
"inputs": [{ "attachmentId": "<the id from the earlier exec result>", "path": "existing.xlsx" }],
|
|
32
|
-
"outputs": ["revised.xlsx"],
|
|
33
|
-
"command": "..."
|
|
34
|
-
}
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
**A workbook the user uploaded is staged the same way — by its id.** The
|
|
38
|
-
`[Attached file …]` line on their message names it:
|
|
39
|
-
|
|
40
|
-
```
|
|
41
|
-
[Attached file "budget.xlsx" (application/vnd.openxmlformats-officedocument.spreadsheetml.sheet) — attachmentId: 4f9c2ab1]
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Copy that id verbatim into `attachmentId`, exactly as for a workbook a tool
|
|
45
|
-
produced. This holds for **`.csv` and `.xlsm` uploads too**, not just `.xlsx`: an
|
|
46
|
-
id-less entry resolves to an uploaded *image*, so any `inputs` entry whose `path`
|
|
47
|
-
names a data or document file is rejected outright. Only an uploaded **image** is
|
|
48
|
-
staged with `path` alone and no `attachmentId` key.
|
|
49
|
-
|
|
50
|
-
Staged files land in the working directory under the bare `path` names —
|
|
51
|
-
reference `load_workbook("existing.xlsx")` by that name only.
|
|
52
|
-
`attachment … not found in this chat` means you invented an id or the file is not
|
|
53
|
-
attached. Re-copy the exact id from the `exec` result or the `[Attached file …]`
|
|
54
|
-
line that names the workbook; if no id appears anywhere in the chat, ask the
|
|
55
|
-
user to attach the file again.
|
|
56
|
-
|
|
57
|
-
`wb.save("revised.xlsx")` must match the declared output name. Keep the
|
|
58
|
-
`command` source multi-line with real newlines — never collapse it with `;`.
|
|
59
|
-
|
|
60
|
-
## Adding an Image to an Existing Workbook
|
|
61
|
-
|
|
62
|
-
Stage two files: the workbook by its `attachmentId`, and the picture. Add `"pillow"`
|
|
63
|
-
to `packages` — **deliberately unpinned**, since the runtime owns its version and a
|
|
64
|
-
pin sends openpyxl to PyPI with it.
|
|
65
|
-
|
|
66
|
-
```json
|
|
67
|
-
{
|
|
68
|
-
"language": "python",
|
|
69
|
-
"packages": ["openpyxl==3.1.5", "pillow"],
|
|
70
|
-
"inputs": [
|
|
71
|
-
{ "attachmentId": "<id of the workbook>", "path": "existing.xlsx" },
|
|
72
|
-
{ "attachmentId": "<id from the generate_image result>", "path": "photo.png" }
|
|
73
|
-
],
|
|
74
|
-
"outputs": ["revised.xlsx"],
|
|
75
|
-
"command": "..."
|
|
76
|
-
}
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
`path` is a name you choose; it has nothing to do with the attachment id, and a
|
|
80
|
-
`fileName` seen in a tool result is not a file on disk. A `generate_image` result
|
|
81
|
-
anywhere in the conversation is the image the request points at — stage that id
|
|
82
|
-
rather than asking the user to attach it again.
|
|
83
|
-
|
|
84
|
-
```python
|
|
85
|
-
from openpyxl.drawing.image import Image as XLImage
|
|
86
|
-
|
|
87
|
-
img = XLImage("photo.png") # the path from inputs, nothing else
|
|
88
|
-
img.width, img.height = 320, 320 # pixels
|
|
89
|
-
ws.add_image(img, "A1") # a worksheet method; A1 is the top-left corner
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
One `add_image` per workbook — inside a loop over sheets it embeds a copy per sheet.
|
|
93
|
-
Never wrap the import or the call in a `try`/`except` that saves anyway.
|
|
94
|
-
|
|
95
|
-
## The Golden Rule of Edits
|
|
96
|
-
|
|
97
|
-
**The staged sheet already has its header and all its data.** Editing never
|
|
98
|
-
re-creates them: no `HEADERS`, no `ROWS`, no copy of the create template. Append
|
|
99
|
-
only what is genuinely new, and change only the cells you were asked to change.
|
|
100
|
-
Re-appending the header and the rows writes the whole table a second time and the
|
|
101
|
-
user opens a file where every row appears twice — the create template belongs to
|
|
102
|
-
the create path (`references/create.md`) and nowhere else.
|
|
103
|
-
|
|
104
|
-
Adding one row is the whole program:
|
|
105
|
-
|
|
106
|
-
```python
|
|
107
|
-
from openpyxl import load_workbook
|
|
108
|
-
|
|
109
|
-
wb = load_workbook("existing.xlsx")
|
|
110
|
-
ws = wb.active # wb["Sheet Name"] to pick another
|
|
111
|
-
|
|
112
|
-
before = ws.max_row # the sheet is already this long
|
|
113
|
-
ws.append([2026, 82500, 135000, 107500])
|
|
114
|
-
|
|
115
|
-
wb.save("revised.xlsx") # NEW name, matching the declared output
|
|
116
|
-
print(f"{before} rows in, {ws.max_row} rows out")
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
That print is the check: one added row means the count goes up by exactly one. If
|
|
120
|
-
it roughly doubles, the run re-appended the existing data — fix it and rerun
|
|
121
|
-
rather than delivering a workbook with the table in it twice.
|
|
122
|
-
|
|
123
|
-
## Putting the Row Where It Belongs
|
|
124
|
-
|
|
125
|
-
Appending is right when the table has no order, and wrong when it has one: a sheet
|
|
126
|
-
running 2016…2026 with 2015 stuck on the end reads as broken. When the new row
|
|
127
|
-
belongs inside an existing order, insert it at that position instead.
|
|
128
|
-
|
|
129
|
-
You already know the position — 2015 sorts above 2016, and the data starts at row 2
|
|
130
|
-
— so write the index as a number. Do not scan, sort, or compare anything to work it
|
|
131
|
-
out:
|
|
132
|
-
|
|
133
|
-
```python
|
|
134
|
-
from openpyxl import load_workbook
|
|
135
|
-
|
|
136
|
-
wb = load_workbook("existing.xlsx")
|
|
137
|
-
ws = wb.active
|
|
138
|
-
|
|
139
|
-
ROW = [2015, 278, 930, 45.5]
|
|
140
|
-
AT = 2 # 2015 goes above 2016 — row 1 is the header
|
|
141
|
-
|
|
142
|
-
assert not any(m.max_row >= AT for m in ws.merged_cells.ranges) and not any(
|
|
143
|
-
isinstance(c.value, str) and c.value.startswith("=") for r in ws.iter_rows() for c in r
|
|
144
|
-
), "a merged range at or below AT, or a formula — append instead, their ranges do not move"
|
|
145
|
-
|
|
146
|
-
before = ws.max_row
|
|
147
|
-
ws.insert_rows(AT)
|
|
148
|
-
for column, value in enumerate(ROW, start=1):
|
|
149
|
-
cell = ws.cell(row=AT, column=column, value=value)
|
|
150
|
-
cell.number_format = ws.cell(row=AT + 1, column=column).number_format
|
|
151
|
-
|
|
152
|
-
wb.save("revised.xlsx")
|
|
153
|
-
print(f"{before} rows in, {ws.max_row} rows out")
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
`AT` is never `1` — that would push the header down into the data.
|
|
157
|
-
|
|
158
|
-
The two lines that look optional are the ones that matter. An inserted cell starts
|
|
159
|
-
with no number format, so without the copy the new row shows a bare `278` in a
|
|
160
|
-
column of `278.00`s; taking the format from `AT + 1` uses the row that used to sit
|
|
161
|
-
there. And `insert_rows` moves cells but neither formula ranges nor merged ranges,
|
|
162
|
-
so a `=SUM(B2:B11)` total would go on summing the old span and quietly leave the
|
|
163
|
-
new row out, while a `Total` merged across `A8:B8` would stay pinned to row 8 as
|
|
164
|
-
its row slid to 9 — the assert stops both before anything is saved.
|
|
165
|
-
|
|
166
|
-
The two halves are scoped differently on purpose. A merged range is disturbed only
|
|
167
|
-
if it sits at or below `AT`, which is why the check is `m.max_row >= AT` rather than
|
|
168
|
-
"any merged cell": a title merged across `A1:C1` is untouched by an insert further
|
|
169
|
-
down, and failing on it would push you to append out of order for no reason. A
|
|
170
|
-
formula gives no such signal — one in `D1` can reference `B2:B11` — so any formula
|
|
171
|
-
at all is enough to stop the insert.
|
|
172
|
-
|
|
173
|
-
When the assert fires, do not delete it. Append the row at the end with the
|
|
174
|
-
previous template and tell the user the table kept its file order so their totals
|
|
175
|
-
stay correct.
|
|
176
|
-
|
|
177
|
-
## Other Edits — Touch Only What Changes
|
|
178
|
-
|
|
179
|
-
```python
|
|
180
|
-
from openpyxl import load_workbook
|
|
181
|
-
from openpyxl.styles import Font
|
|
182
|
-
|
|
183
|
-
wb = load_workbook("existing.xlsx")
|
|
184
|
-
ws = wb["Q1 Sales"] # or wb.active; wb.sheetnames lists them
|
|
185
|
-
|
|
186
|
-
ws.cell(row=1, column=5, value="Margin %").font = Font(bold=True)
|
|
187
|
-
for row, margin in [(2, 0.31), (3, 0.42), (4, 0.18)]:
|
|
188
|
-
cell = ws.cell(row=row, column=5, value=margin)
|
|
189
|
-
cell.number_format = '0.0%'
|
|
190
|
-
|
|
191
|
-
ws["B2"] = 150 # update a cell in place
|
|
192
|
-
|
|
193
|
-
notes = wb.create_sheet("Notes")
|
|
194
|
-
notes["A1"] = "Updated unit counts for North"
|
|
195
|
-
|
|
196
|
-
wb.save("revised.xlsx") # NEW name, matching the declared output
|
|
197
|
-
print(f"{len(wb.sheetnames)} sheets: {wb.sheetnames}")
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
`wb["Sheet Name"]` raises `KeyError` when the name does not exist — when unsure,
|
|
201
|
-
print `wb.sheetnames` in the same run that edits, pick from it, and never guess.
|
|
202
|
-
|
|
203
|
-
## Deleting Rows, Columns and Sheets
|
|
204
|
-
|
|
205
|
-
openpyxl deletes for real, so none of this needs XML work. `ws.delete_rows(index)`
|
|
206
|
-
and `ws.delete_cols(index)` take a **1-based** index and an optional count, so
|
|
207
|
-
`ws.delete_rows(5, 3)` drops rows 5, 6 and 7 together; a sheet goes with
|
|
208
|
-
`del wb["Notes"]`. Row 1 is the header — `delete_rows(1)` throws it away, and data
|
|
209
|
-
rows start at 2, exactly as for an insert.
|
|
210
|
-
|
|
211
|
-
**Delete from the bottom up.** Each delete shifts everything below it, so a loop
|
|
212
|
-
over ascending indices removes the wrong rows after the first: dropping rows 3 and
|
|
213
|
-
5 top-down deletes row 3, then deletes what used to be row 6. Collect the row
|
|
214
|
-
numbers first and walk them in reverse — the same rule applies right-to-left for
|
|
215
|
-
`delete_cols`:
|
|
216
|
-
|
|
217
|
-
```python
|
|
218
|
-
from openpyxl import load_workbook
|
|
219
|
-
|
|
220
|
-
wb = load_workbook("existing.xlsx")
|
|
221
|
-
ws = wb.active
|
|
222
|
-
|
|
223
|
-
DROP = (2021, 2023) # the column-A values whose rows go
|
|
224
|
-
|
|
225
|
-
before = ws.max_row
|
|
226
|
-
targets = [r for r in range(2, ws.max_row + 1) if ws.cell(row=r, column=1).value in DROP]
|
|
227
|
-
assert targets, f"no row matched {DROP} — check the values are numbers, not strings"
|
|
228
|
-
|
|
229
|
-
assert not any(m.max_row >= min(targets) for m in ws.merged_cells.ranges) and not any(
|
|
230
|
-
isinstance(c.value, str) and c.value.startswith("=") for r in ws.iter_rows() for c in r
|
|
231
|
-
), "a merged range at or below the first deleted row, or a formula — their ranges do not move"
|
|
232
|
-
|
|
233
|
-
for row in reversed(targets): # bottom-up; ascending order deletes the wrong rows
|
|
234
|
-
ws.delete_rows(row)
|
|
235
|
-
|
|
236
|
-
wb.save("revised.xlsx") # NEW name, matching the declared output
|
|
237
|
-
print(f"{before} rows in, {ws.max_row} rows out")
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
The `assert targets` line is what stops a no-op being delivered, and it belongs
|
|
241
|
-
before the loop rather than after it. Without it, values that match nothing leave
|
|
242
|
-
the sheet untouched and the workbook still saves at `exitCode 0` with an
|
|
243
|
-
attachment indistinguishable from a real delete. With it the run raises, nothing
|
|
244
|
-
is written, and the result carries `missingOutputs` instead. The usual cause is a
|
|
245
|
-
type mismatch — the string `"2021"` is not the number `2021` — or a wrong column
|
|
246
|
-
index. **An assert that fires is a failed turn to diagnose, not a workbook to
|
|
247
|
-
deliver**: fix the match and rerun, and never delete the assert to get a file out.
|
|
248
|
-
The printed `rows in / rows out` line is then the reply line, not the check, and it
|
|
249
|
-
still costs no extra call — both live in the run that does the deleting.
|
|
250
|
-
|
|
251
|
-
The second assert is the `insert_rows` hazard in reverse, and it covers the same two
|
|
252
|
-
things with the same scoping. `delete_rows` moves cells but leaves formula text
|
|
253
|
-
alone, so a `=SUM(B2:B11)` total goes on summing eleven rows of a table that now
|
|
254
|
-
holds nine, pulling in blanks or the wrong cells. It leaves merged ranges alone too:
|
|
255
|
-
a `Total` merged at `A8:B8` keeps covering row 8 after a row above it is deleted, and
|
|
256
|
-
a title merged across `A1:C1` still claims three columns after a `delete_cols`. Both
|
|
257
|
-
are silent — no error, and the damage only shows when the user opens the file.
|
|
258
|
-
|
|
259
|
-
Merges are again checked from the first deleted row down (`m.max_row >= min(targets)`),
|
|
260
|
-
so a banner above every deletion does not block the edit, while any formula anywhere
|
|
261
|
-
does. When it fires, do not delete it: say which rows you would have removed and ask
|
|
262
|
-
whether to drop the merges and formulas too, or tell the user the deletion has to
|
|
263
|
-
happen in Excel, where the ranges follow. Note the column case is not covered by that
|
|
264
|
-
row check — if you are calling `delete_cols` on a sheet with horizontal merges, treat
|
|
265
|
-
any merged range as a stop.
|
|
266
|
-
|
|
267
|
-
## Reading Cell Values During an Edit
|
|
268
|
-
|
|
269
|
-
`load_workbook` has two modes, and neither gives both formulas and values:
|
|
270
|
-
|
|
271
|
-
- `load_workbook("f.xlsx")` — formula cells hold the formula **string**
|
|
272
|
-
(`"=SUM(D2:D4)"`).
|
|
273
|
-
- `load_workbook("f.xlsx", data_only=True)` — formula cells hold the value the
|
|
274
|
-
last spreadsheet app **cached** when it saved. A file that openpyxl itself wrote
|
|
275
|
-
has no cache, so these cells read `None`.
|
|
276
|
-
|
|
277
|
-
Plain data cells read the same either way. When a formula cell reads `None` under
|
|
278
|
-
`data_only=True`, the file was never recalculated by a spreadsheet app — compute
|
|
279
|
-
the number in Python from the data cells instead of hunting for it.
|
|
280
|
-
|
|
281
|
-
**Never `save()` a workbook opened with `data_only=True`.** That mode loads values
|
|
282
|
-
in place of formulas, so saving writes the values back and every formula the user
|
|
283
|
-
had is gone — silently, at `exitCode 0`, with an attachment that looks fine. A
|
|
284
|
-
workbook you intend to save is always opened plainly:
|
|
285
|
-
|
|
286
|
-
```python
|
|
287
|
-
from openpyxl import load_workbook
|
|
288
|
-
|
|
289
|
-
values = load_workbook("existing.xlsx", data_only=True) # read numbers here
|
|
290
|
-
wb = load_workbook("existing.xlsx") # edit and save this one
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
Read from `values`, write to `wb`, and save `wb`. One open, one job.
|
|
294
|
-
|
|
295
|
-
That is reading in service of an edit. Reading for the *user* — a summary or an
|
|
296
|
-
answer delivered as chat text — is its own flow with its own call shape: load
|
|
297
|
-
`references/read.md`.
|
|
298
|
-
|
|
299
|
-
## Errors
|
|
300
|
-
|
|
301
|
-
- Never print the workbook's bytes or base64 — stdout is capped and the file
|
|
302
|
-
travels through `outputs`. A build call prints only a short summary line.
|
|
303
|
-
Never pass an absolute path to `save()`.
|
|
304
|
-
- `attachment … not found in this chat` — `inputs` listed an id that is not in
|
|
305
|
-
this chat (often a copied placeholder). Only stage real ids from prior tool
|
|
306
|
-
results or `[Attached file …]` lines.
|
|
307
|
-
- `an id-less input stages an uploaded image, and this path names a document` —
|
|
308
|
-
an `.xlsx` was staged with no `attachmentId`. Spreadsheets are always staged
|
|
309
|
-
by id.
|
|
310
|
-
- `no uploaded image in this chat — attach an image or pass an attachmentId` —
|
|
311
|
-
an id-less input was sent when the user uploaded no image at all.
|
|
312
|
-
- `FileNotFoundError: [Errno 44] No such file or directory` on a workbook you
|
|
313
|
-
saved in an earlier call means it was never staged: the working directory is
|
|
314
|
-
fresh every call. Add the file to `inputs` with its `attachmentId`.
|
|
315
|
-
- A delivered workbook whose formulas have turned into blanks means it was opened
|
|
316
|
-
with `data_only=True` and then saved. Open a second, plain workbook to edit.
|
|
317
|
-
- A delivered workbook whose table appears twice means the edit re-appended the
|
|
318
|
-
header and rows onto the staged sheet. An edit adds only what is new.
|
|
319
|
-
- `AssertionError: a merged range at or below AT, or a formula — append instead …`
|
|
320
|
-
means a merged range sits at or below the insertion row, or the sheet has a formula
|
|
321
|
-
whose range `insert_rows` would not move. Append the row at the end instead and say
|
|
322
|
-
why in the reply.
|
|
323
|
-
- `AssertionError: a merged range at or below the first deleted row, or a formula …`
|
|
324
|
-
is the same hazard on a delete, and has no safe fallback: say which rows you would
|
|
325
|
-
remove and ask the user how to handle the merges and formulas.
|
|
326
|
-
- `AssertionError: no row matched …` means the delete found nothing, usually because
|
|
327
|
-
the compared values are strings on one side and numbers on the other. Diagnose the
|
|
328
|
-
match and rerun; do not remove the assert, because the workbook it would deliver
|
|
329
|
-
is the staged one unchanged.
|
|
330
|
-
- Rows that disappeared from the wrong places mean the delete loop ran over
|
|
331
|
-
ascending indices. Collect the targets first and delete in reverse.
|
|
332
|
-
- A row that renders unlike the rest of its column — `278` among `278.00`s — was
|
|
333
|
-
inserted without copying `number_format` from the row below it.
|
|
334
|
-
- `'MergedCell' object attribute 'value' is read-only` means the write hit a merged
|
|
335
|
-
non-anchor cell — write the range's top-left cell instead.
|
|
336
|
-
- `".xlsm" is not an allowed output type` means the run tried to deliver a
|
|
337
|
-
macro-enabled file — save as `.xlsx` and tell the user macros were not preserved.
|
|
338
|
-
- `KeyError` on `wb["Sheet Name"]` — the sheet name does not exist; print
|
|
339
|
-
`wb.sheetnames` in the run that edits and pick from it.
|
|
340
|
-
- `ModuleNotFoundError: No module named 'openpyxl'` — add
|
|
341
|
-
`["openpyxl==3.1.5"]` to `packages` and rerun. Never try to install it.
|
|
342
|
-
- `ImportError: You must install Pillow to fetch image objects` means an image was
|
|
343
|
-
embedded without `"pillow"` in `packages` — openpyxl does not install it.
|
|
344
|
-
- `FileNotFoundError` on a 32-character hex name means an attachment id was opened as
|
|
345
|
-
a path. The id belongs in `attachmentId`; open the `path` you chose.
|
|
346
|
-
- On an `AttributeError` from openpyxl the API name is wrong, and on a `TypeError`
|
|
347
|
-
about missing positional arguments a required argument was left out — fix either
|
|
348
|
-
against this file's examples. Do not retry the same call, and do not switch to a
|
|
349
|
-
shell.
|
|
350
|
-
|
|
351
|
-
## Finish
|
|
352
|
-
|
|
353
|
-
When `exitCode` is `0` and `attachments` lists the `.xlsx`, stop tool use and
|
|
354
|
-
answer with one line: file name + the `rows in / rows out` (or sheets) summary
|
|
355
|
-
from stdout. Exactly one successful `exec` per request. If the result has
|
|
356
|
-
`missingOutputs`, read stderr first — an `AssertionError` there means a guard
|
|
357
|
-
stopped the save on purpose and its message names what to fix.
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
# Reading a Workbook to Answer in Chat (openpyxl)
|
|
2
|
-
|
|
3
|
-
When the user asks what an attached workbook *holds* — a summary, a question
|
|
4
|
-
answered, specific values pulled out — the deliverable is your reply in the
|
|
5
|
-
chat, not a file. This is a **read request**: exactly one `exec` call, staging
|
|
6
|
-
the workbook in `inputs` and declaring **no `outputs`**, whose whole job is to
|
|
7
|
-
print the sheets so you can read them in the result.
|
|
8
|
-
|
|
9
|
-
## The exec call
|
|
10
|
-
|
|
11
|
-
```json
|
|
12
|
-
{
|
|
13
|
-
"language": "python",
|
|
14
|
-
"packages": ["openpyxl==3.1.5"],
|
|
15
|
-
"inputs": [{ "attachmentId": "<id from the [Attached file …] line>", "path": "existing.xlsx" }],
|
|
16
|
-
"maxOutputChars": 24000,
|
|
17
|
-
"command": "..."
|
|
18
|
-
}
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
The id rules: copy it verbatim from the `[Attached file …]` line on the user's
|
|
22
|
-
message or from the earlier `exec` result that produced the file, never invent
|
|
23
|
-
one, never stage a workbook id-less (an id-less entry resolves to an uploaded
|
|
24
|
-
*image*). If no id appears anywhere in the chat, ask the user to attach the
|
|
25
|
-
file again. `maxOutputChars` raises the stdout cap so a full workbook comes
|
|
26
|
-
back in one result; keep the sample's 24000. Declare no `outputs` — a read
|
|
27
|
-
builds nothing.
|
|
28
|
-
|
|
29
|
-
## The Read Program
|
|
30
|
-
|
|
31
|
-
Prints every sheet under a `[Sheet]` marker, then its rows — one printed line
|
|
32
|
-
per row:
|
|
33
|
-
|
|
34
|
-
```python
|
|
35
|
-
from openpyxl import load_workbook
|
|
36
|
-
|
|
37
|
-
wb = load_workbook("existing.xlsx", data_only=True)
|
|
38
|
-
for name in wb.sheetnames:
|
|
39
|
-
ws = wb[name]
|
|
40
|
-
print(f"[Sheet] {name} ({ws.max_row} rows)")
|
|
41
|
-
for row in ws.iter_rows(values_only=True):
|
|
42
|
-
print(" | ".join("" if v is None else str(v).replace("\n", " ") for v in row))
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
The `replace` is load-bearing: a multi-line cell (Alt+Enter in Excel) embeds
|
|
46
|
-
`"\n"` in its value, and an embedded newline would split one row across two
|
|
47
|
-
printed lines. Flattened, every printed line is exactly one sheet row.
|
|
48
|
-
|
|
49
|
-
`data_only=True` is the right mode here: a formula cell prints the value the
|
|
50
|
-
last spreadsheet app cached, or an empty field when the file was never
|
|
51
|
-
recalculated (`None` prints as nothing). An empty field under a `Total` header
|
|
52
|
-
is that, not missing data — say so, and when the answer needs the number, work
|
|
53
|
-
it out from the data rows that did print.
|
|
54
|
-
|
|
55
|
-
**You cannot summarize in the call that reads.** The words in `command` are
|
|
56
|
-
fixed before the program runs, so one call cannot inform itself: any summary
|
|
57
|
-
written into it was written blind — recalled or invented, not read. Python only
|
|
58
|
-
*transports* the values; the summarizing happens in your reply, after the
|
|
59
|
-
result comes back.
|
|
60
|
-
|
|
61
|
-
## A Successful Read Ends Tool Use
|
|
62
|
-
|
|
63
|
-
When the result prints the sheets, reply with the summary or the answer as chat
|
|
64
|
-
text — when the user asked for the table in the chat, that reply is a markdown
|
|
65
|
-
table built from the rows you read, never from memory. **Scale the reply to the
|
|
66
|
-
workbook**: a summary is much shorter than what it summarizes — a small sheet
|
|
67
|
-
earns a few sentences, and only a many-sheet workbook earns sections. Restating
|
|
68
|
-
every row is not a summary. Do **not**:
|
|
69
|
-
|
|
70
|
-
- call `exec` again to "re-check", "read more", or read the same workbook a
|
|
71
|
-
second time;
|
|
72
|
-
- build a summary `.xlsx` the user never asked for — an unrequested file is a
|
|
73
|
-
failed turn, not a bonus.
|
|
74
|
-
|
|
75
|
-
If stdout ends with `… [truncated]`, the workbook is longer than the cap:
|
|
76
|
-
answer from what came back and say the answer covers the sheets up to that
|
|
77
|
-
point. Do not rerun the read — it prints the same beginning again.
|
|
78
|
-
|
|
79
|
-
If the user asks for the summary **as a file**, that is a read followed by a
|
|
80
|
-
build: the read call above first, then one build call that writes the new
|
|
81
|
-
workbook from the values you actually read (load `references/create.md` for the
|
|
82
|
-
build). The read still declares no `outputs`.
|
|
83
|
-
|
|
84
|
-
## Errors
|
|
85
|
-
|
|
86
|
-
- `attachment … not found in this chat` — the id was invented or the file is
|
|
87
|
-
not attached. Re-copy the exact id; if none exists, ask the user to attach
|
|
88
|
-
the file again instead of retrying.
|
|
89
|
-
- `an id-less input stages an uploaded image, and this path names a document` —
|
|
90
|
-
the workbook was staged with no `attachmentId`. Spreadsheets (`.xlsx`,
|
|
91
|
-
`.csv`, `.xlsm`) are always staged by id.
|
|
92
|
-
- `FileNotFoundError: [Errno 44] No such file or directory` — the file was
|
|
93
|
-
never staged; the working directory is fresh every call. Add it to `inputs`
|
|
94
|
-
with its `attachmentId`.
|
|
95
|
-
- `ModuleNotFoundError: No module named 'openpyxl'` — add
|
|
96
|
-
`["openpyxl==3.1.5"]` to `packages` and rerun. Never try to install it.
|
|
97
|
-
- A formula cell that prints nothing is not a bug — the file was never
|
|
98
|
-
recalculated by a spreadsheet app. Compute the number from the data rows
|
|
99
|
-
instead of rerunning.
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
# Creating a PDF (fpdf2)
|
|
2
|
-
|
|
3
|
-
Create a new `.pdf` from scratch by running Python through the `exec` tool.
|
|
4
|
-
A new PDF needs **no** `inputs` — do not invent attachment ids. **Exactly one**
|
|
5
|
-
`exec` call per user request when that call succeeds.
|
|
6
|
-
|
|
7
|
-
This file is also the recipe for **changing the content of an existing PDF**
|
|
8
|
-
(add a paragraph, reword, restyle): pypdf cannot edit page content, so the job
|
|
9
|
-
is writing the whole document again with fpdf2 — every original section,
|
|
10
|
-
reproduced unchanged, plus the requested change. A rebuild that condenses,
|
|
11
|
-
summarizes, or drops original sections is a failed turn; the user must get
|
|
12
|
-
their document back with only the asked-for difference. Save the rebuild under
|
|
13
|
-
a **new** output name (`updated.pdf`, never the original file's name), and
|
|
14
|
-
stop after the one successful build.
|
|
15
|
-
|
|
16
|
-
## The exec call
|
|
17
|
-
|
|
18
|
-
```json
|
|
19
|
-
{
|
|
20
|
-
"language": "python",
|
|
21
|
-
"packages": ["fpdf2==2.8.8"],
|
|
22
|
-
"outputs": ["report.pdf"],
|
|
23
|
-
"command": "..."
|
|
24
|
-
}
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
- `packages` — pin exactly `fpdf2==2.8.8` (imported as `fpdf`). This version
|
|
28
|
-
ships with the app and installs with no network; any other version has to be
|
|
29
|
-
downloaded, which fails on a device that is offline. Never install `fpdf`
|
|
30
|
-
(no `2`) — that is an abandoned, incompatible library.
|
|
31
|
-
- `outputs` — the file to deliver. `pdf.output("report.pdf")` must match the
|
|
32
|
-
declared output name exactly.
|
|
33
|
-
- `command` — the multi-line Python source, with real newline characters.
|
|
34
|
-
Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a
|
|
35
|
-
semicolon is a `SyntaxError`.
|
|
36
|
-
|
|
37
|
-
## The Recipe
|
|
38
|
-
|
|
39
|
-
Start from this. It is a complete, working document — title, body paragraphs,
|
|
40
|
-
a bulleted section, a table, automatic page breaks, saved under the declared
|
|
41
|
-
output name. Copy it and change the content; do not assemble a PDF from memory.
|
|
42
|
-
|
|
43
|
-
```python
|
|
44
|
-
from fpdf import FPDF
|
|
45
|
-
|
|
46
|
-
pdf = FPDF(format="A4")
|
|
47
|
-
pdf.set_auto_page_break(auto=True, margin=15)
|
|
48
|
-
pdf.add_page()
|
|
49
|
-
|
|
50
|
-
pdf.set_font("helvetica", style="B", size=24)
|
|
51
|
-
pdf.multi_cell(0, 12, "Quarterly Report", new_x="LMARGIN", new_y="NEXT")
|
|
52
|
-
|
|
53
|
-
pdf.set_font("helvetica", size=12)
|
|
54
|
-
pdf.ln(4)
|
|
55
|
-
pdf.multi_cell(0, 6, "Revenue grew 20% quarter over quarter, driven by APAC. "
|
|
56
|
-
"EMEA held flat while new logos offset churn.",
|
|
57
|
-
new_x="LMARGIN", new_y="NEXT")
|
|
58
|
-
pdf.ln(2)
|
|
59
|
-
|
|
60
|
-
pdf.set_font("helvetica", style="B", size=14)
|
|
61
|
-
pdf.multi_cell(0, 10, "Highlights", new_x="LMARGIN", new_y="NEXT")
|
|
62
|
-
pdf.set_font("helvetica", size=12)
|
|
63
|
-
for point in [
|
|
64
|
-
"APAC bookings grew 34% and drove most of the quarter",
|
|
65
|
-
"EMEA held flat; new logos offset churn",
|
|
66
|
-
"Gross margin improved 2 points on infra savings",
|
|
67
|
-
]:
|
|
68
|
-
pdf.multi_cell(0, 6, "- " + point, new_x="LMARGIN", new_y="NEXT")
|
|
69
|
-
pdf.ln(2)
|
|
70
|
-
|
|
71
|
-
with pdf.table() as table:
|
|
72
|
-
for row_data in [("Region", "Revenue"), ("APAC", "$1.2M"), ("EMEA", "$0.9M")]:
|
|
73
|
-
row = table.row()
|
|
74
|
-
for cell_text in row_data:
|
|
75
|
-
row.cell(cell_text)
|
|
76
|
-
|
|
77
|
-
pdf.output("report.pdf") # must match the declared output exactly
|
|
78
|
-
print(f"{pdf.pages_count} pages")
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
## The Rules That Keep It Working
|
|
82
|
-
|
|
83
|
-
- **Sizes are positional — there is no `width=` or `height=` keyword.** The
|
|
84
|
-
first two arguments of `cell` and `multi_cell` are `w` and `h`; passing
|
|
85
|
-
`width=` raises `TypeError`. The fix is renaming the arguments as in the
|
|
86
|
-
sample — never deleting the `new_x`/`new_y` keywords, which "fixes" the
|
|
87
|
-
error and prints every later line on top of the previous one.
|
|
88
|
-
- **Every line of text goes through
|
|
89
|
-
`pdf.multi_cell(0, h, text, new_x="LMARGIN", new_y="NEXT")` — headings,
|
|
90
|
-
paragraphs, and bullets alike. Do not use `pdf.cell` at all.** `cell` does
|
|
91
|
-
not wrap, so a long heading is silently clipped at the right margin, and
|
|
92
|
-
without `new_x`/`new_y` it leaves the cursor at the END of the line so the
|
|
93
|
-
next write starts at the right margin — raising
|
|
94
|
-
`FPDFException: Not enough horizontal space` or printing on top of earlier
|
|
95
|
-
text. `multi_cell` wraps everything. Keep `new_x="LMARGIN", new_y="NEXT"`
|
|
96
|
-
on every call, exactly as in the sample. A bullet is one
|
|
97
|
-
`multi_cell(0, 6, "- " + point, …)` per point — never several bullets
|
|
98
|
-
packed into one string. `pdf.text(x, y, s)` is not a third option: it
|
|
99
|
-
paints at a fixed point with no wrapping and exists only for the watermark
|
|
100
|
-
stamp in `references/transform.md`.
|
|
101
|
-
- **Write the whole document, not a cover page.** "A small PDF about X"
|
|
102
|
-
still means real content: a title, then several short sections, each a
|
|
103
|
-
heading plus a paragraph or bullets, as in the sample. A PDF containing
|
|
104
|
-
only a title and a subtitle is a failed turn — the explanation the user
|
|
105
|
-
asked for belongs inside the PDF, not in your chat reply.
|
|
106
|
-
- **`set_font` before every block, not once.** A heading's bold 14-24 pt
|
|
107
|
-
style stays active until changed — reset to `pdf.set_font("helvetica",
|
|
108
|
-
size=12)` after each heading or the whole body renders huge and bold.
|
|
109
|
-
Core fonts: `helvetica`, `times`, `courier`; body text 10-12 pt.
|
|
110
|
-
- **ASCII punctuation only.** The core fonts cover latin-1 and nothing else,
|
|
111
|
-
and one character outside it fails the whole cell with
|
|
112
|
-
`FPDFUnicodeEncodingException`. Curly quotes, em dashes, arrows, the •
|
|
113
|
-
bullet, CJK, and emoji are all outside. Write straight quotes `"` `'` and
|
|
114
|
-
hyphens `-` in every string — a bullet is `"- "`, never the `•` character.
|
|
115
|
-
Accented latin (`café`, `naïve`) is fine. When user content may carry smart
|
|
116
|
-
punctuation, normalize it first:
|
|
117
|
-
|
|
118
|
-
```python
|
|
119
|
-
def latin1(text):
|
|
120
|
-
for bad, good in [("‘", "'"), ("’", "'"), ("“", '"'),
|
|
121
|
-
("”", '"'), ("–", "-"), ("—", "-"),
|
|
122
|
-
("…", "..."), ("→", "->"), ("•", "-")]:
|
|
123
|
-
text = text.replace(bad, good)
|
|
124
|
-
return text
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
Text that genuinely needs CJK or emoji cannot be rendered — there are no
|
|
128
|
-
font files in this runtime and `add_font` has nothing to load, so never call
|
|
129
|
-
it. Say so and offer a latin transliteration instead of shipping `?`.
|
|
130
|
-
- **Units are millimetres**, page format defaults to A4. `FPDF(format="letter")`
|
|
131
|
-
for US letter. An A4 page is 210 x 297 mm with 10 mm margins; a width of
|
|
132
|
-
`0` extends to the right margin.
|
|
133
|
-
- **No markdown syntax in strings.** fpdf2 prints text literally — `##`,
|
|
134
|
-
`**bold**`, and `*italics*` come out as those exact characters. Headings
|
|
135
|
-
and emphasis are made with `set_font(..., style="B", size=…)`, as in the
|
|
136
|
-
sample.
|
|
137
|
-
- **`pdf.output("name.pdf")` writes the deliverable.** Calling `output()`
|
|
138
|
-
with no argument returns the bytes instead — useful only for in-memory
|
|
139
|
-
intermediates; the file the user receives must be written under its
|
|
140
|
-
declared `outputs` name.
|
|
141
|
-
- Never print the PDF's bytes or base64 — stdout is capped and the file
|
|
142
|
-
travels through `outputs`. Print only the page-count line.
|
|
143
|
-
|
|
144
|
-
## Errors
|
|
145
|
-
|
|
146
|
-
- `ModuleNotFoundError: No module named 'fpdf'` — `packages` was missing or
|
|
147
|
-
wrong; the pin is `fpdf2==2.8.8` (imported as `fpdf`). Never try to install
|
|
148
|
-
inside the script.
|
|
149
|
-
- `TypeError: FPDF.cell() (or multi_cell) got an unexpected keyword argument
|
|
150
|
-
'width'` (or `'height'`) — the size parameters are the positional `w` and
|
|
151
|
-
`h`. Rewrite the call as in the sample —
|
|
152
|
-
`multi_cell(0, 6, text, new_x="LMARGIN", new_y="NEXT")`. Renaming is the
|
|
153
|
-
whole fix; deleting the keywords instead produces overlapping text.
|
|
154
|
-
- `FPDFException: Not enough horizontal space to render a single character` —
|
|
155
|
-
an earlier `cell`/`multi_cell` left the cursor at the right margin. Add
|
|
156
|
-
`new_x="LMARGIN", new_y="NEXT"` to every `cell` and `multi_cell` call.
|
|
157
|
-
- `FPDFUnicodeEncodingException: Character "…" is outside the range …` — a
|
|
158
|
-
non-latin-1 character reached a core font. Normalize the string (see the
|
|
159
|
-
`latin1` helper) and rerun; for CJK or emoji, tell the user it cannot be
|
|
160
|
-
rendered.
|
|
161
|
-
- On an `AttributeError` or `TypeError` from fpdf2 the API name or arguments
|
|
162
|
-
are wrong — fix against this file's recipe. Do not retry the same call and
|
|
163
|
-
do not switch to a shell.
|
|
164
|
-
|
|
165
|
-
## Finish
|
|
166
|
-
|
|
167
|
-
When `exitCode` is `0` and `attachments` lists the `.pdf`, stop tool use and
|
|
168
|
-
answer with one line: file name + the page count from stdout. Exactly one
|
|
169
|
-
successful `exec` per request.
|