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

  1. Open the app. Navigate to the site root (the page this guide links to).
  2. 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), and pdfTool refuses to work until they have. Run pdfTool.notice(), show the text and both links to the user, and call pdfTool.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.
  3. Load the PDF. Upload the user's file with the "Open PDF…" button (chrome-devtools: upload_file on it), then run await 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 call waitForDocument() again. For a small file you already have as bytes, use pdfTool.open({base64, name}) instead.
  4. Find what to fill. await pdfTool.listBlanks() for dotted lines (……, ...., ____) and await pdfTool.listFields() for real form fields.
  5. Fill. fillBlank, setField, addText, addMark, addDate, addImage.
  6. 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 with updateItem.
  7. 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

API reference

All methods live on window.pdfTool. The async ones return promises, so await them. pdfTool.help() returns this list as JSON.

MethodWhat 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

Good to know