@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,169 @@
|
|
|
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.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# Transforming an Existing PDF (pypdf)
|
|
2
|
+
|
|
3
|
+
Transform a PDF that is already in this chat by running Python through the
|
|
4
|
+
`exec` tool: merge, split, rotate, watermark, encrypt, decrypt, fill form
|
|
5
|
+
fields, extract text.
|
|
6
|
+
|
|
7
|
+
**pypdf transforms pages; it cannot restyle or edit their content.** Merging,
|
|
8
|
+
splitting, rotating, stamping, and encrypting work on a staged PDF. Adding a
|
|
9
|
+
paragraph, rewording, or changing fonts, sizes, or colors does not — never
|
|
10
|
+
poke at page objects to try (`KeyError`/`IndexError` is that mistake). For a
|
|
11
|
+
content change, load `references/create.md` NOW and rebuild the whole document
|
|
12
|
+
with fpdf2: every original section, unchanged, plus the requested change (no
|
|
13
|
+
`inputs` needed when the original text is already in this chat; otherwise run
|
|
14
|
+
the extract-text script below first). A rebuilt PDF that condenses or drops
|
|
15
|
+
original sections is a failed turn.
|
|
16
|
+
|
|
17
|
+
## Staging the PDF
|
|
18
|
+
|
|
19
|
+
Any PDF already in this chat can be staged, whoever put it there. Stage it as
|
|
20
|
+
an input by its `attachmentId` and open it with `PdfReader("existing.pdf")` or
|
|
21
|
+
`PdfWriter(clone_from="existing.pdf")`; then transform and save a **new**
|
|
22
|
+
output such as `merged.pdf`. Never overwrite the staged input. The working
|
|
23
|
+
directory starts empty on every call, so a file you do not stage does not
|
|
24
|
+
exist — opening last turn's PDF by name alone raises `FileNotFoundError`.
|
|
25
|
+
|
|
26
|
+
The id comes from wherever the PDF entered the chat:
|
|
27
|
+
|
|
28
|
+
- **Produced earlier in this chat** (a prior `exec` output, a `create_pdf`
|
|
29
|
+
result) — the `attachmentId` is in that tool result.
|
|
30
|
+
- **Uploaded by the user** — the `[Attached file …]` line on their message
|
|
31
|
+
names it, and every non-image upload gets one:
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
[Attached file "contract.pdf" (application/pdf) — attachmentId: 4f9c2ab1]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Copy the id verbatim — never placeholders like `att_pdf` or any id you made
|
|
38
|
+
up. A `.pdf` is never staged id-less: the id-less form resolves to an uploaded
|
|
39
|
+
*image*, so it cannot reach a PDF. Id-less entries (`{ "path": "photo.png" }`,
|
|
40
|
+
no `attachmentId` key) are only for images the user uploaded — the first
|
|
41
|
+
id-less entry is the first image of the user's latest message, and so on.
|
|
42
|
+
|
|
43
|
+
If no `attachmentId` for the PDF is available anywhere in the chat, ask the
|
|
44
|
+
user to attach it again. Do not invent ids and do not retry.
|
|
45
|
+
|
|
46
|
+
## The exec call
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"language": "python",
|
|
51
|
+
"packages": ["pypdf==6.15.0"],
|
|
52
|
+
"inputs": [
|
|
53
|
+
{ "attachmentId": "<id from the tool result that built a.pdf>", "path": "a.pdf" },
|
|
54
|
+
{ "attachmentId": "<id from the tool result that built b.pdf>", "path": "b.pdf" }
|
|
55
|
+
],
|
|
56
|
+
"outputs": ["merged.pdf"],
|
|
57
|
+
"command": "..."
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- `packages` — pin exactly `pypdf==6.15.0`; add `fpdf2==2.8.8` when one run
|
|
62
|
+
also builds pages (watermark). Add `"cryptography"` (deliberately unpinned —
|
|
63
|
+
the runtime supplies its own build) only for AES encryption or decrypting
|
|
64
|
+
AES-encrypted files. These exact versions ship with the app and install with
|
|
65
|
+
no network; any other version has to be downloaded, which fails on a device
|
|
66
|
+
that is offline.
|
|
67
|
+
- `inputs` — the staged attachments. Paths must be unique bare filenames;
|
|
68
|
+
staged files land in the working directory under those names — reference
|
|
69
|
+
`PdfReader("a.pdf")` by that name only.
|
|
70
|
+
- `outputs` — the files to deliver. A file you write but do not declare here
|
|
71
|
+
is discarded, and an undeclared `.pdf` earns a warning — build intermediate
|
|
72
|
+
pages in memory (`io.BytesIO`, as in the watermark recipe) instead of
|
|
73
|
+
writing extra files.
|
|
74
|
+
- `command` — the multi-line Python source, with real newline characters.
|
|
75
|
+
Never collapse it to one line joined by `;`.
|
|
76
|
+
|
|
77
|
+
`attachment … not found in this chat` means you invented an id or the file is
|
|
78
|
+
not attached. Re-copy the exact id from the tool result or the
|
|
79
|
+
`[Attached file …]` line that names the PDF; if neither exists, ask the user to
|
|
80
|
+
attach it again instead of retrying.
|
|
81
|
+
|
|
82
|
+
## Scripted Transforms — Run These, Do Not Write Them
|
|
83
|
+
|
|
84
|
+
Merge, rotate, extract text, encrypt, and decrypt are bundled scripts: run them
|
|
85
|
+
by naming the script in the `exec` call — no Python source at all. Stage the
|
|
86
|
+
attachment(s) as `inputs`, declare the produced file in `outputs`, and pass the
|
|
87
|
+
file names again as `scriptArgs` (scripts see the working directory, so the
|
|
88
|
+
same bare names). Always write a new output name — never the staged input's.
|
|
89
|
+
|
|
90
|
+
**Merge** several PDFs into one — args: output first, then the inputs in order:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"language": "python",
|
|
95
|
+
"packages": ["pypdf==6.15.0"],
|
|
96
|
+
"inputs": [
|
|
97
|
+
{ "attachmentId": "<real id>", "path": "a.pdf" },
|
|
98
|
+
{ "attachmentId": "<real id>", "path": "b.pdf" }
|
|
99
|
+
],
|
|
100
|
+
"outputs": ["merged.pdf"],
|
|
101
|
+
"skill": "pdf",
|
|
102
|
+
"script": "scripts/merge.py",
|
|
103
|
+
"scriptArgs": ["merged.pdf", "a.pdf", "b.pdf"]
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
**Rotate** every page — args: input, output, degrees (a multiple of 90):
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"language": "python",
|
|
112
|
+
"packages": ["pypdf==6.15.0"],
|
|
113
|
+
"inputs": [{ "attachmentId": "<real id>", "path": "existing.pdf" }],
|
|
114
|
+
"outputs": ["rotated.pdf"],
|
|
115
|
+
"skill": "pdf",
|
|
116
|
+
"script": "scripts/rotate.py",
|
|
117
|
+
"scriptArgs": ["existing.pdf", "rotated.pdf", "90"]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Extract text** into a delivered `.txt` — args: input, output:
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"language": "python",
|
|
126
|
+
"packages": ["pypdf==6.15.0"],
|
|
127
|
+
"inputs": [{ "attachmentId": "<real id>", "path": "existing.pdf" }],
|
|
128
|
+
"outputs": ["extracted.txt"],
|
|
129
|
+
"skill": "pdf",
|
|
130
|
+
"script": "scripts/extract_text.py",
|
|
131
|
+
"scriptArgs": ["existing.pdf", "extracted.txt"]
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The script refuses a scanned PDF (no text layer, no OCR in this runtime) with a
|
|
136
|
+
message saying so — tell the user the pages are images rather than rerunning.
|
|
137
|
+
|
|
138
|
+
**Encrypt** with AES-256 — args: input, output, password. Needs the extra
|
|
139
|
+
`"cryptography"` package entry (deliberately unpinned):
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"language": "python",
|
|
144
|
+
"packages": ["pypdf==6.15.0", "cryptography"],
|
|
145
|
+
"inputs": [{ "attachmentId": "<real id>", "path": "existing.pdf" }],
|
|
146
|
+
"outputs": ["locked.pdf"],
|
|
147
|
+
"skill": "pdf",
|
|
148
|
+
"script": "scripts/encrypt.py",
|
|
149
|
+
"scriptArgs": ["existing.pdf", "locked.pdf", "s3cret"]
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
**Decrypt** — args: input, output, password (the password must come from the
|
|
154
|
+
user). Needs `"cryptography"` too when the file is AES-encrypted:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"language": "python",
|
|
159
|
+
"packages": ["pypdf==6.15.0", "cryptography"],
|
|
160
|
+
"inputs": [{ "attachmentId": "<real id>", "path": "locked.pdf" }],
|
|
161
|
+
"outputs": ["unlocked.pdf"],
|
|
162
|
+
"skill": "pdf",
|
|
163
|
+
"script": "scripts/decrypt.py",
|
|
164
|
+
"scriptArgs": ["locked.pdf", "unlocked.pdf", "s3cret"]
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### If a script fails
|
|
169
|
+
|
|
170
|
+
Read its stderr first — the scripts print exactly what is wrong (bad argument
|
|
171
|
+
order, wrong password, scanned pages). A failure is almost always fixed by
|
|
172
|
+
correcting `scriptArgs` or `inputs` and running the script again. Only when the
|
|
173
|
+
message does not explain it, load the script's source to inspect it — call the
|
|
174
|
+
`skill` tool with `name: "pdf"` and `file: "scripts/<name>.py"` — then either
|
|
175
|
+
rerun the script with fixed arguments or fall back to an inline `command`
|
|
176
|
+
adapted from that source. Never load script sources up front; run them.
|
|
177
|
+
|
|
178
|
+
## Coded Transforms
|
|
179
|
+
|
|
180
|
+
Split, watermark, and form filling take shapes a fixed script cannot cover, so
|
|
181
|
+
they stay inline Python in `command` (no `script` key). Each recipe below is
|
|
182
|
+
complete; stage the attachment(s) as `inputs` first. `reader.pages` is
|
|
183
|
+
zero-indexed. Always write a new output name — never the staged input's.
|
|
184
|
+
|
|
185
|
+
**Split** — keep a page range (here: pages 1-3, i.e. indexes 0-2):
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from pypdf import PdfReader, PdfWriter
|
|
189
|
+
|
|
190
|
+
reader = PdfReader("existing.pdf")
|
|
191
|
+
part = PdfWriter()
|
|
192
|
+
for page in reader.pages[0:3]:
|
|
193
|
+
part.add_page(page)
|
|
194
|
+
part.write("pages-1-3.pdf")
|
|
195
|
+
print(f"{len(part.pages)} pages")
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**Watermark** — build the stamp with fpdf2 in memory, merge it onto every
|
|
199
|
+
page. Needs both packages (`pypdf==6.15.0` and `fpdf2==2.8.8`). `over=False`
|
|
200
|
+
puts the mark under the content, `over=True` on top:
|
|
201
|
+
|
|
202
|
+
```python
|
|
203
|
+
import io
|
|
204
|
+
from fpdf import FPDF
|
|
205
|
+
from pypdf import PdfReader, PdfWriter
|
|
206
|
+
|
|
207
|
+
mark = FPDF(format="A4")
|
|
208
|
+
mark.add_page()
|
|
209
|
+
mark.set_font("helvetica", style="B", size=60)
|
|
210
|
+
mark.set_text_color(200, 200, 200)
|
|
211
|
+
with mark.rotation(45, x=105, y=148):
|
|
212
|
+
mark.text(30, 160, "DRAFT") # text(x, y, s) is stamp-only: fixed point, no wrapping
|
|
213
|
+
stamp = PdfReader(io.BytesIO(mark.output())).pages[0]
|
|
214
|
+
|
|
215
|
+
writer = PdfWriter(clone_from="existing.pdf")
|
|
216
|
+
for page in writer.pages:
|
|
217
|
+
page.merge_page(stamp, over=False)
|
|
218
|
+
writer.write("watermarked.pdf")
|
|
219
|
+
print(f"{len(writer.pages)} pages")
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Fill form fields** — inspect the field names first (`get_fields`), then set
|
|
223
|
+
them by exact name:
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
from pypdf import PdfReader, PdfWriter
|
|
227
|
+
|
|
228
|
+
reader = PdfReader("form.pdf")
|
|
229
|
+
fields = reader.get_fields()
|
|
230
|
+
print("fields:", sorted(fields) if fields else "none")
|
|
231
|
+
|
|
232
|
+
writer = PdfWriter(clone_from="form.pdf")
|
|
233
|
+
writer.update_page_form_field_values(
|
|
234
|
+
writer.pages[0], {"name": "Ada Lovelace", "email": "ada@example.com"}
|
|
235
|
+
)
|
|
236
|
+
writer.write("filled.pdf")
|
|
237
|
+
print(f"{len(writer.pages)} pages")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
If `get_fields()` returns `None` the PDF has no form layer — flattened forms
|
|
241
|
+
cannot be filled; say so instead of guessing at overlay coordinates.
|
|
242
|
+
|
|
243
|
+
## Errors
|
|
244
|
+
|
|
245
|
+
- `ModuleNotFoundError: No module named 'pypdf'` — `packages` was missing or
|
|
246
|
+
wrong; the pin is `pypdf==6.15.0`. Never try to install inside the script.
|
|
247
|
+
- `DependencyError: cryptography>=3.1 is required for AES algorithm` — add
|
|
248
|
+
`"cryptography"` to `packages` and rerun. The encrypt/decrypt scripts clear
|
|
249
|
+
pypdf's cached crypto backend themselves.
|
|
250
|
+
- `usage: <script>.py …` on stderr — the `scriptArgs` were wrong (order,
|
|
251
|
+
count, or a non-numeric degrees). Fix the arguments and run the script
|
|
252
|
+
again; do not rewrite the script as inline code for an argument mistake.
|
|
253
|
+
- `EOF marker not found` or `PdfReadError` on open — the staged file is not a
|
|
254
|
+
complete PDF; check the right attachment was staged, or ask the user to
|
|
255
|
+
re-attach it.
|
|
256
|
+
- `File has not been decrypted` — the input is password-protected: ask the
|
|
257
|
+
user for the password and run the decrypt recipe first.
|
|
258
|
+
- `FileNotFoundError` — the file was never staged, or the name does not match
|
|
259
|
+
an `inputs` path. The working directory starts empty on every call: stage
|
|
260
|
+
the PDF via `inputs` with the `attachmentId` from the tool result that
|
|
261
|
+
produced it, or rebuild from scratch. Do not invent ids.
|
|
262
|
+
- On an `AttributeError` or `TypeError` from pypdf the API name or arguments
|
|
263
|
+
are wrong — fix against this file's recipes. Do not retry the same call and
|
|
264
|
+
do not switch to a shell.
|
|
265
|
+
|
|
266
|
+
## Finish
|
|
267
|
+
|
|
268
|
+
When `exitCode` is `0` and `attachments` lists the output file, stop tool use
|
|
269
|
+
and answer with one line: file name + the page count from stdout. Exactly one
|
|
270
|
+
successful `exec` per request.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Decrypt a password-protected PDF (requires the "cryptography" package entry for AES files).
|
|
2
|
+
|
|
3
|
+
Usage: decrypt.py <input.pdf> <output.pdf> <password>
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
# pypdf picks its crypto backend at first import and the interpreter is reused
|
|
9
|
+
# across calls — clear it so the backend is chosen with "cryptography" present.
|
|
10
|
+
for name in [m for m in sys.modules if m.startswith("pypdf")]:
|
|
11
|
+
del sys.modules[name]
|
|
12
|
+
from pypdf import PdfReader, PdfWriter
|
|
13
|
+
|
|
14
|
+
if len(sys.argv) != 4:
|
|
15
|
+
sys.exit("usage: decrypt.py <input.pdf> <output.pdf> <password> — the password never prints")
|
|
16
|
+
|
|
17
|
+
source, target, password = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
18
|
+
if source == target:
|
|
19
|
+
sys.exit(f"output {target} must be a new name, not the staged input")
|
|
20
|
+
|
|
21
|
+
reader = PdfReader(source)
|
|
22
|
+
if reader.is_encrypted and not reader.decrypt(password):
|
|
23
|
+
sys.exit("wrong password — ask the user for the correct one; do not retry blind")
|
|
24
|
+
writer = PdfWriter(clone_from=reader)
|
|
25
|
+
writer.write(target)
|
|
26
|
+
print(f"{len(writer.pages)} pages")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Encrypt a PDF with AES-256 (requires the "cryptography" package entry).
|
|
2
|
+
|
|
3
|
+
Usage: encrypt.py <input.pdf> <output.pdf> <password>
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
# pypdf picks its crypto backend at first import and the interpreter is reused
|
|
9
|
+
# across calls, so an earlier pypdf run without "cryptography" would otherwise
|
|
10
|
+
# pin the backend without AES.
|
|
11
|
+
for name in [m for m in sys.modules if m.startswith("pypdf")]:
|
|
12
|
+
del sys.modules[name]
|
|
13
|
+
from pypdf import PdfWriter
|
|
14
|
+
|
|
15
|
+
if len(sys.argv) != 4:
|
|
16
|
+
sys.exit("usage: encrypt.py <input.pdf> <output.pdf> <password> — the password never prints")
|
|
17
|
+
|
|
18
|
+
source, target, password = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
19
|
+
if source == target:
|
|
20
|
+
sys.exit(f"output {target} must be a new name, not the staged input")
|
|
21
|
+
|
|
22
|
+
writer = PdfWriter(clone_from=source)
|
|
23
|
+
writer.encrypt(user_password=password, algorithm="AES-256")
|
|
24
|
+
writer.write(target)
|
|
25
|
+
print(f"{len(writer.pages)} pages, AES-256")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Extract all text from a PDF into a .txt file.
|
|
2
|
+
|
|
3
|
+
Usage: extract_text.py <input.pdf> <output.txt>
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from pypdf import PdfReader
|
|
9
|
+
|
|
10
|
+
if len(sys.argv) != 3:
|
|
11
|
+
sys.exit(f"usage: extract_text.py <input.pdf> <output.txt> — got {sys.argv[1:]}")
|
|
12
|
+
|
|
13
|
+
source, target = sys.argv[1], sys.argv[2]
|
|
14
|
+
reader = PdfReader(source)
|
|
15
|
+
text = "\n\n".join(page.extract_text() for page in reader.pages)
|
|
16
|
+
|
|
17
|
+
if not text.strip():
|
|
18
|
+
sys.exit(
|
|
19
|
+
f"{source} has no text layer ({len(reader.pages)} pages are images; "
|
|
20
|
+
"there is no OCR in this runtime) — tell the user instead of rerunning"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
with open(target, "w") as f:
|
|
24
|
+
f.write(text)
|
|
25
|
+
print(f"{len(reader.pages)} pages, {len(text)} characters")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Merge two or more PDFs into one, in argument order.
|
|
2
|
+
|
|
3
|
+
Usage: merge.py <output.pdf> <input1.pdf> <input2.pdf> [more inputs...]
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from pypdf import PdfWriter
|
|
9
|
+
|
|
10
|
+
if len(sys.argv) < 4:
|
|
11
|
+
sys.exit(f"usage: merge.py <output.pdf> <input1.pdf> <input2.pdf> [...] — got {sys.argv[1:]}")
|
|
12
|
+
|
|
13
|
+
target, sources = sys.argv[1], sys.argv[2:]
|
|
14
|
+
if target in sources:
|
|
15
|
+
sys.exit(f"output {target} must be a new name, not one of the staged inputs")
|
|
16
|
+
|
|
17
|
+
merged = PdfWriter()
|
|
18
|
+
for source in sources:
|
|
19
|
+
merged.append(source)
|
|
20
|
+
merged.write(target)
|
|
21
|
+
print(f"{len(merged.pages)} pages from {len(sources)} files")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Rotate every page of a PDF clockwise by a multiple of 90 degrees.
|
|
2
|
+
|
|
3
|
+
Usage: rotate.py <input.pdf> <output.pdf> <degrees>
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from pypdf import PdfWriter
|
|
9
|
+
|
|
10
|
+
if len(sys.argv) != 4:
|
|
11
|
+
sys.exit(f"usage: rotate.py <input.pdf> <output.pdf> <degrees> — got {sys.argv[1:]}")
|
|
12
|
+
|
|
13
|
+
source, target, degrees_raw = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
14
|
+
if source == target:
|
|
15
|
+
sys.exit(f"output {target} must be a new name, not the staged input")
|
|
16
|
+
try:
|
|
17
|
+
degrees = int(degrees_raw)
|
|
18
|
+
except ValueError:
|
|
19
|
+
sys.exit(f"degrees must be an integer multiple of 90, got: {degrees_raw}")
|
|
20
|
+
if degrees % 90 != 0:
|
|
21
|
+
sys.exit(f"degrees must be a multiple of 90, got: {degrees}")
|
|
22
|
+
|
|
23
|
+
writer = PdfWriter(clone_from=source)
|
|
24
|
+
for page in writer.pages:
|
|
25
|
+
page.rotate(degrees)
|
|
26
|
+
writer.write(target)
|
|
27
|
+
print(f"{len(writer.pages)} pages rotated {degrees} degrees")
|