PDF Fill & Watermark: guide for AI agents
For agents that control a browser, such as Claude Code or Cursor with a browser MCP like chrome-devtools. The app is at the site root. Humans use the same page.
Don't click on the page image. Call the window.pdfTool
JavaScript API with your browser tool's "evaluate script" function
(chrome-devtools: evaluate_script). It is faster and exact.
Every method returns JSON, or throws an Error whose message
says what to do next.
Quick start
- Open the app. Navigate to the site root (the page this guide links to).
- Terms and Privacy Notice. On a first visit the page asks
the user to agree to the Terms of Use (
/terms) and acknowledge the Privacy Notice (/privacy), andpdfToolrefuses to work until they have. RunpdfTool.notice(), show the text and both links to the user, and callpdfTool.acceptNotice()only if the user agrees. Never accept on the user's behalf without asking: under the Terms (3.5), what you accept counts as the user's own acceptance. - Load the PDF. Upload the user's file with the
"Open PDF…" button (chrome-devtools:
upload_fileon it), then runawait pdfTool.waitForDocument(). The path must be readable by the machine the browser runs on. If it says the file "could not be read", ask the user to open the PDF themselves with that button, then callwaitForDocument()again. For a small file you already have as bytes, usepdfTool.open({base64, name})instead. - Find what to fill.
await pdfTool.listBlanks()for dotted lines (……,....,____) andawait pdfTool.listFields()for real form fields. - Fill.
fillBlank,setField,addText,addMark,addDate,addImage. - Check. For each page you changed, run
await pdfTool.goto(n)and then take a screenshot. What you see is what gets saved, except the dashed outlines around added items, which are only for editing. Fix positions withupdateItem. - Save.
await pdfTool.download(). The file goes to the browser's Downloads folder as<name>_filled.pdf. Tell the user its name. Until then, navigating away or reloading shows a “leave page?” dialog (unsaved work): download first, or accept that dialog.
Example
// 1. after upload_file on the "Open PDF…" button
await pdfTool.waitForDocument()
// → {loaded: true, name: "application.pdf", pages: 1, ...}
// 2. what can be filled?
await pdfTool.listBlanks({page: 1})
// → [{id: "p1-b1", page: 1, x: 105.1, y: 112, width: 148.5, fontSize: 11,
// before: "Full name:", after: "Age: …"},
// {id: "p1-b2", ..., before: "Full name: … Age:", after: ""}, ...]
// 3. fill it (Thai is fine; the default font is Sarabun)
await pdfTool.fillBlank("p1-b1", "นางสาวสมหญิง รักเรียน")
await pdfTool.fillBlank("p1-b2", "29", {align: "center"})
await pdfTool.fillBlank("p1-b5", pdfTool.today()) // 23 กันยายน 2569
await pdfTool.addMark({page: 1, x: 57, y: 168}) // ✓ centered at x, y
await pdfTool.setField("agree", true)
// 4. look, then save
await pdfTool.goto(1) // then take a screenshot
await pdfTool.download()
// → {name: "application_filled.pdf", size: 36629, warnings: [], savedTo: "..."}
Coordinates
- Units are PDF points (72 pt = 1 inch). An A4 page is 595 × 842 pt; US Letter is 612 × 792 pt.
- The origin is the top-left of the page as displayed: x goes right, y goes down. Rotated pages are handled for you.
- Pages start at 1.
addTextplaces the text's baseline atyby default (like writing on a line). Useanchor: "top"or"center"to change that.addImageputs the image's top-left corner at x, y.
API reference
All methods live on window.pdfTool. The async ones return
promises, so await them. pdfTool.help() returns
this list as JSON.
| Method | What it does |
|---|---|
help() | Method list, fonts and defaults as JSON. |
status() | Whether a PDF is loaded, its name and pages, the current page, item count, whether the notice is accepted, and the last status message. |
notice() / acceptNotice() | Read the PDPA notice / accept it after the user agreed. |
open({url})open({base64, name}) | Open a PDF
without the file input. url must be on this same site
(the page's security policy blocks other sites). Prefer
upload_file for the user's local files. Add
password for an encrypted PDF; the download is saved
decrypted. |
newBlank({pages?, size?}) | Open a new PDF of empty
portrait pages (default 1; size "a4", the default, or
"letter"), then add text and images with addText() /
addImage() and download(). |
merge([{url | base64, name, password?, pages?}, ...]) | Merge
PDFs in the order given and open the result, as open() does;
then fill, sign or watermark it and download().
pages picks and orders pages, e.g. "1-3, 5, 8-"
(default: all; "5-1" runs backwards). Form fields keep
working; a name that repeats becomes name_2. A digital
signature in an input doesn't survive, which warnings
reports. |
waitForDocument(timeoutMs = 30000) | Resolve once the uploaded PDF has loaded. |
listBlanks({page?}) | Dotted or underscored blank lines:
id, page, x, y
(baseline), width, fontSize of the text
around it, and the words before / after it.
Only lines that read left to right on screen are listed; a line that
shows up vertical on a rotated page is skipped.
Positions come from the PDF's text layer, so scanned (image-only) PDFs
have no blanks: use addText with coordinates read from a
screenshot instead. Words in some PDFs extract as garbled
characters; use the screenshot to confirm which blank is which. |
fillBlank(id, text, {size?, font?, bold?, color?, align?}) | Write
text on a blank, left-aligned (align: "center" to center).
The size defaults to the surrounding text's size. |
listFields() | Real form fields: name,
type (text, check, radio, choice), value,
options, pages, readOnly, and the
first box's x, y, width,
height. Radio option names are often just
"0", "1", …; buttons gives each
one's position, so match them to the labels in a screenshot. |
setField(name, value) | text → string; check →
true/false; radio / choice → one of its
options. Choice also accepts the option's label. |
addText({page, x, y, text, size?, font?, bold?, color?, anchor?}) | Free
text anywhere. Use \n for new lines. |
addMark({page, x, y, mark?, size?}) | A ✓
("check", default) or ✗ ("cross"), centered on x, y.
Use it for tick boxes. |
addDate({page, x, y, ...}) | Today's date in Thai,
e.g. 23 กันยายน 2569. Same options as addText. |
today() | That date as a string, e.g. for
fillBlank(id, pdfTool.today()). |
addImage({page, x, y, width, url | base64, type?, pages?, rotation?, opacity?}) | A
signature or stamp (PNG with transparency looks best). base64
may be a data: URL. The height follows the image's aspect ratio.
rotation turns it clockwise around its centre, in degrees (x, y stay
the upright box's top-left); opacity is 0.1–1.
pages repeats it at the same place: "all" or a list like
"1-3, 5" (page is always included); listItems
reports it as pages (1-based, or "all"). |
listItems({page?}) | Everything added so far, with
id, position and size. |
updateItem(id, {x?, y?, baseline?, text?, size?, font?, bold?, color?, width?, rotation?, opacity?}) | Move
or edit an item. Here x, y are the item's top-left, as
listItems reports them. For text you can give
baseline instead of y. |
removeItem(id) / clearItems({page?}) | Delete one item, or all of them (on one page). |
goto(page) | Show a page, so a screenshot shows it. |
setWatermark({enabled?, text?, size?, angle?, opacity?, color?, font?, bold?, pages?, cx?, cy?}) | Add
a diagonal watermark. cx, cy = its center as
fractions of the page (0–1). pages: "all" (default),
"current" (the page on show) or a list like "1-3, 5, 8-".
Pass enabled: false to turn it off. |
download({flatten?, editable?, safe?, rasterize?}) | Write the
PDF and download it (no preview dialog). flatten: true draws fields and
comments into the page (hidden ones are dropped, signature fields kept).
editable (default: on, off once a watermark is on) embeds the original PDF
and the placed items, so opening the file here again restores them as editable items
(status().restored); anyone with the file can extract that original.
safe: true is a copy for sharing without metadata, attachments, scripts or
signatures (always flattened, never editable); with it, rasterize: true
turns every page into an image. A PDF already signed by someone
(status().signatures) keeps that signature valid unless safe.
Returns {name, size, editable, warnings, report}; report
says what went in and report.issuer.result what happened to existing
signatures (kept, removed, broken,
certified). |
Styles
- Fonts:
sarabun(default, has a true bold),sans(Noto Sans Thai),serif(Noto Serif Thai),mono. All of them handle Thai. - The default color is dark blue ink,
#0a1f8f. Use#000000for black. - Size is in points. Most forms use 10–14 pt.
fillBlankmatches the document by itself.
Good to know
- Privacy. The app never uploads the PDF, and nothing is
stored on the server. But what you read (screenshots,
listBlanks,statusand other results) goes to your AI provider, so the document's content does leave the device through you. Make sure the user knows that, and don't send the content anywhere else they didn't ask for. - Thai in real form fields. A field value that isn't plain Latin text is drawn onto the page, and that field is removed from the saved PDF. That's expected, not an error.
- Text becomes an image. Filled text is saved as a high-resolution image, so it prints sharply but can't be selected in the output.
- A new upload starts fresh. Loading another PDF discards unsaved items. Download first.
- Humans can help. Items you add can be dragged and
edited by the user on the page, and vice versa.
listItems()shows the current state either way.