@qvac/skills 0.1.11 → 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.
@@ -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.