Annotations and XFDF
Comments live in the PDF the viewer has open. Most apps don't save a new PDF for every comment: they keep the original file and store the comments separately as XFDF, an XML format for PDF annotations. Free PDF SDK exports and imports XFDF, so you can save comments to your database and restore them on top of the original PDF later.
Everything happens in the browser: the SDK sends nothing to a server. Where the XFDF goes is up to your code.
Export comments
const xfdf = await viewer.exportXfdf();
// <?xml version="1.0" encoding="UTF-8"?> <xfdf ...><annots>...</annots></xfdf>exportXfdf() resolves to a string with every comment of the open document, replies
included. Each comment's author is in its title attribute, its page in a 0-based page
attribute and its id in name.
The export always contains an <annots> element, even when there are no comments. Don't test
xfdf.includes("<annots") to find out whether there are comments; count them instead (see
Count and list comments).
Import comments
const imported = await viewer.importXfdf(xfdf);
console.log(`${imported} comments imported`);importXfdf() accepts a string or a Blob (for example a File from an <input>), adds the
comments to the open document and resolves to the number of top-level comments imported.
Replies are imported with their comment but not counted. Comments on pages the document doesn't
have are skipped. An XFDF with no comments imports 0 without an error.
Replace the existing comments
To show exactly the stored set, for example when the user picks an earlier revision, pass
replace: true:
await viewer.importXfdf(storedXfdf, { replace: true });The existing comments are deleted first, in the same call. The XFDF is checked before anything
is deleted: if it is invalid, the call rejects with XFDF_INVALID and the document keeps its
comments.
What import needs
importXfdf changes the document, so it needs:
- An annotation feature. At least one of
comments,markup,shapes,ink,stamps,measurementorfillSignmust be on, otherwise it rejects withREAD_ONLY.viewandorganizehave none; every other mode has at least one (fillandsignhavefillSign). - Permission from the document. If the PDF's security forbids comments, it rejects with
NOT_PERMITTEDuntil you unlock it with the owner password (see Protected documents). - Comments only. XFDF that carries form field values is refused with
XFDF_INVALID.
Turning off a single annotation feature does not filter what you import: with
features: { stamps: false } users can't add stamps, but stamps in the XFDF are still
imported and shown.
Show stored comments read-only
view mode refuses imports, because nothing may change the document there. To show a stored
comment set that users can't change, import in an annotating mode and then switch to view:
const viewer = await FreePdf.init(element, {
baseUrl: "/free-pdf-sdk/",
document: { url: pdfUrl },
mode: "annotate",
});
await viewer.importXfdf(storedXfdf, { replace: true });
await viewer.setMode("view");The comments stay on screen; every editing tool disappears. The PDF on your server is
unchanged: imported comments live in the viewer until you call save().
Count and list comments
const annotations = await viewer.getAnnotations();
const hasComments = annotations.length > 0;getAnnotations() lists the document's top-level comments, each with its replies. Its length
is the exact number of comments, counted the same way as exportXfdf() writes them, so you can
rely on it to decide whether there is anything to store.
Each entry is an Annotation:
{
id: "8f1c2b9e-...", // XFDF name
type: "highlight", // text, freetext, square, circle, line, ink, polygon, polyline,
// highlight, underline, squiggly, strikeout, stamp, caret,
// fileattachment, sound, redact
author: "Priya Raman", // XFDF title
subject: "Highlight",
contents: "Check this figure",
date: "2026-10-21T09:14:03Z",// ISO 8601: last modified, else created
pageIndex: 2, // 0-based
color: "#FFD400",
replies: [
{ id: "...", author: "Sam Lee", contents: "Fixed in v3", date: "2026-10-21T10:02:41Z" },
],
}contents is plain text. Replies are listed oldest first.
To follow changes as they happen, listen to annotationsChanged. It fires when comments are
added, changed or removed, by the user or by your code, with the current count:
viewer.on("annotationsChanged", ({ count }) => {
badge.textContent = String(count);
});Delete all comments
const deleted = await viewer.deleteAllAnnotations();Deletes every comment, locked ones included, and resolves to how many there were. Like
importXfdf, it rejects with READ_ONLY when no annotation feature is on and with
NOT_PERMITTED when the document's security forbids comments.
Set the author of new comments
New comments carry the author's name in their XFDF title attribute. Set it when the viewer
starts:
const viewer = await FreePdf.init(element, {
baseUrl: "/free-pdf-sdk/",
mode: "annotate",
author: currentUser.displayName,
});or change it later, for example when a different user signs in:
await viewer.setAuthor(currentUser.displayName);The name you set wins over any name saved in the viewer's preferences and stays in effect when you load another document. It is trimmed, must not be empty, and can be up to 200 characters.
Changing the author affects new comments only: existing comments, including imported ones, keep the author they were written with.
Store comments in a database
A typical flow: load the original PDF, import the stored XFDF, and save the XFDF back when the user is done.
import { FreePdf, FreePdfError } from "free-pdf-sdk";
const viewer = await FreePdf.init(element, {
baseUrl: "/free-pdf-sdk/",
document: { url: `/files/${fileId}.pdf` },
mode: "annotate",
author: currentUser.displayName,
features: { save: false }, // your page has its own Save button
});
// Restore.
const res = await fetch(`/api/files/${fileId}/comments`);
if (res.ok) {
await viewer.importXfdf(await res.text(), { replace: true });
}
// Save.
saveButton.onclick = async () => {
const annotations = await viewer.getAnnotations();
const xfdf = await viewer.exportXfdf();
await fetch(`/api/files/${fileId}/comments`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
xfdf,
count: annotations.length,
authors: [...new Set(annotations.map((a) => a.author).filter(Boolean))],
}),
});
};Tips:
Store the XFDF as text next to a reference to the PDF. It is usually much smaller than the PDF. If your database limits document size, keep large XFDF in file storage instead.
Store a summary for queries.
getAnnotations()gives you the count, authors, pages and dates without parsing XML on the server.Save on change. To save automatically, listen to
annotationsChangedand debounce:let timer: ReturnType<typeof setTimeout> | undefined; viewer.on("annotationsChanged", () => { clearTimeout(timer); timer = setTimeout(async () => { const xfdf = await viewer.exportXfdf(); await fetch(`/api/files/${fileId}/comments`, { method: "PUT", body: xfdf }); }, 1000); });Keep revisions. Store each saved XFDF as a new row; showing an older revision is one
importXfdf(xfdf, { replace: true }).Rename authors on the server if you need to. The author is a plain
title="..."attribute, so rewriting it (for example from an old placeholder name) is a string replacement.
Save a PDF with the comments in it
When you do want a file with the comments embedded, for download or archiving, call save():
const pdf = await viewer.save(); // Uint8ArrayOr let users press Save in the viewer and receive the file in saveRequested:
viewer.on("saveRequested", async ({ name, data }) => {
await fetch(`/api/files/${fileId}/pdf`, { method: "PUT", body: data });
});Protected documents
Some PDFs are encrypted with an owner password that forbids commenting. Check before offering annotation tools:
const permissions = await viewer.getPermissions();
if (!permissions.canAnnotate && permissions.canUnlock) {
await viewer.unlock(ownerPassword); // rejects with WRONG_PASSWORD
}Or pass the password when loading: FreePdf.init(element, { ..., password }) or
viewer.load(source, { password }). Unlocking only lifts the restrictions in the viewer: the
file itself stays protected, and a PDF saved with save() keeps its protection.
XFDF compatibility
The SDK reads standard XFDF (ISO 19444-1) as other PDF tools write it: authors in title,
0-based page, name ids, inreplyto replies, colours, ink paths, vertices, callouts and
pop-ups. It writes the author in title too, so XFDF you have already stored keeps working,
and server code that reads or rewrites title attributes doesn't need to change. Annotation
type names in getAnnotations() are the PDF subtypes in lowercase (text, freetext,
square, circle, line, ink, and so on).
Known losses
Importing XFDF keeps every annotation with its author, page, colour, contents, replies, rectangle, ink, vertices, callouts and pop-ups. These details are not kept:
| Lost | Effect |
|---|---|
Measurement dictionaries (<measure>) |
Measurements import as plain lines and polygons with their text (for example "105.83 mm"); the scale is not kept. |
Intents other than callouts (LineDimension, PolygonCloud, FreeTextTypeWriter) |
Shown as plain lines, polygons and text boxes. |
Rich text (<contents-richtext>) |
The plain <contents> is used; fonts, colours and sizes inside the text are lost. |
Appearance streams (<appearance>) |
Stamps are redrawn by the viewer: standard stamps keep their name, custom stamp artwork is lost. |
| File attachment and sound data | The annotation stays, its file does not. |
| Line leader extras, dash patterns, cloud borders | Drawn without them. |
Check your stored XFDF
Before switching production data, round-trip a sample of your stored XFDF covering every annotation type your users make (including measurements and signatures). A small page can do it:
import { FreePdf } from "free-pdf-sdk";
const viewer = await FreePdf.init(element, { baseUrl: "/free-pdf-sdk/", mode: "annotate" });
for (const sample of samples) { // [{ pdfUrl, xfdf }] from your database
await viewer.load({ url: sample.pdfUrl });
const imported = await viewer.importXfdf(sample.xfdf, { replace: true });
const list = await viewer.getAnnotations();
const reimported = await viewer.importXfdf(await viewer.exportXfdf(), { replace: true });
console.table({
pdf: sample.pdfUrl,
imported,
listed: list.length,
roundTrip: reimported,
authors: [...new Set(list.map((a) => a.author))].join(", "),
pages: [...new Set(list.map((a) => a.pageIndex))].join(", "),
});
}Compare the counts, authors and pages with what your previous viewer showed for the same records.