Annotations
You read and edit annotations through two services: the document service
(doc.annotations) for cheap whole-document reads, and the page service
(doc.page(pon).annotations) for typed reads and edits. The examples below assume
you already opened a document (see Quick start).
Reading annotations#
For a quick “what’s where” overview, the document service has two fast read paths:
// Every page, cheapest read.
const all = await doc.annotations.listRawAll();
// A single page, by its page object number.
const onePage = await doc.annotations.listRaw(pageObjectNumber);To get the full, typed annotations on one page, use the page service:
const page = doc.page(pageObjectNumber);
const { annotations } = await page.annotations.list();Adding an annotation#
Call create() with the annotation you want. Each kind has its own fields — see
Annotation types for the full list.
Here’s a highlight:
const page = doc.page(pageObjectNumber);
const { created } = await page.annotations.create({
subtype: 'highlight',
color: { r: 255, g: 215, b: 0 },
opacity: 0.4,
quadPoints: [
{
p1: { x: 72, y: 712 },
p2: { x: 272, y: 712 },
p3: { x: 72, y: 696 },
p4: { x: 272, y: 696 },
},
],
});
// `created` is the new annotation. Keep `created.ref` to edit it later.Measurements#
Measurements use the existing annotation types and editing methods. Set the
dimension intent and attach a rectilinear measure dictionary:
| Measurement | subtype | intent | Geometry |
|---|---|---|---|
| Distance | line | LineDimension | linePoints |
| Perimeter | polyline | PolyLineDimension | vertices |
| Area | polygon | PolygonDimension | vertices |
The pure helpers exported by @embedpdf/engine-core include
measureFromKnownLength, measureFromRatio, measurementReadout, and
formatMeasurement. A known-length calibration takes a length in PDF user-space
units and its real-world value and unit. A ratio calibration also accepts the
page’s userUnit. The readout reports the formatted label and numeric value, or
an unavailable reason when the imported scale or geometry cannot be interpreted.
Polygon readouts also include a formatted perimeter when distance formats exist.
A perimeter sums the open polyline’s segments; it does not add a closing segment.
Areas require a simple, nonzero boundary. Crossing, overlapping, or degenerate
boundaries return invalid-geometry instead of a potentially misleading area.
The engine derives contents on creation and when geometry, measure, intent, or
contents is updated. Supplying a different contents string cannot override an
available measurement result. A style-only edit preserves the stored label;
missing or unsupported scales leave it unchanged. Preview calculations normalize
coordinates and conversion factors to the same float precision used by the PDF
runtime.
Captions and placement#
Set caption.enabled to paint the saved label into the annotation’s appearance.
Imported polygons and polylines keep their existing appearance behavior until
explicitly enabled.
- A line caption uses
position: 'inline' | 'top'and an optionaloffset: { along, perpendicular }. These are displacements in PDF units along the directed line and its positive 90-degree normal. - A polygon or polyline caption uses an optional
center: { x, y }. This is an absolute point in PDF user space: y increases upward and the original page origin is retained. The point(0, 0)is a valid manual placement. - Caption patches merge with saved placement. Hiding a caption retains its
position;
offset: nullorcenter: nullrestores automatic placement, andcaption: nullclears the caption settings. - A manual shape center follows a verified translation or rotation of all vertices. Editing individual vertices leaves it fixed. An explicit center in the patch takes precedence. The editor also carries the center during group resizing; it writes the resulting absolute PDF point explicitly.
- Automatic area captions use the polygon centroid, with an interior fallback for concave polygons. Perimeter captions sit just above the halfway point of the path. Captions follow the annotation’s authored rotation, including when the label has been placed manually.
Viewer authoring#
The Measure toolbar provides Distance, Perimeter, Area, and Calibrate. Each drawing icon previews that tool’s current stroke color; changing the default updates its toolbar, overflow-menu, and cursor icon together.
Click successive vertices for perimeter or area, then double-click or use the normal drawing completion action to finish. Press Escape to cancel. The scale is captured at the first vertex and stays fixed throughout that drawing. An invalid area previews an unavailable value and cannot be finished.
Select a measurement and drag its text to reposition the label. Labels have no separate visible handle. The selection frame encloses the shape and label, and rotation uses that frame’s center. Edited measurements stay vector-rendered, following the same lifecycle as other annotations.
The measurement sidebar shows both area and perimeter for a selected area. Its
Area unit control changes the area display independently of distance formatting;
Reset label position returns selected shape labels to automatic placement.
The measurement capability exposes areaUnits() and setAreaUnit() alongside
its existing unit, precision, and calibration methods.
Page calibration#
Both local and cloud engines expose page.measure. Its viewports() method
returns the page’s measurement regions in drawing order; viewportForPoint
selects the last containing region, including regions with unsupported measures.
The owned flag identifies EmbedPDF’s calibration viewport.
setScale(measure) saves a full-page calibration, while setScale(null) removes
it. Other producers’ viewports survive both operations. Calibration emits
page.viewportsChanged, persists with the document or layer, and leaves existing
annotations’ scale snapshots unchanged. To recalibrate an existing annotation,
update its measure explicitly.
Reading viewports requires doc.open; changing calibration requires
doc.annotate.modify. Foreign measures such as /GEO remain readable and are
preserved in the PDF, but cannot be authored through the measurement API.
Updating and deleting#
You never build an identity by hand. Every annotation you read or create comes with a
ref — pass that same ref back to update, delete, or move:
const { annotations } = await page.annotations.list();
const note = annotations[0];
// Change something. Repeat the `subtype` so the engine knows which fields are valid.
await page.annotations.update(note.ref, { subtype: note.subtype, contents: 'edited note' });
// Remove it.
await page.annotations.delete(note.ref);Advanced: a ref points at an annotation in one of three ways — by PDF object number (preferred,
durable), by its /NM name, or by its position (array index). You normally don’t care which: just
reuse the ref you were handed. Index refs are a legacy escape hatch and need an edit session for
structural edits (see below).
Reordering#
move() changes annotation order on a page. Pass the annotations to move (a contiguous
block; a single ref is the common case) and the position to insert them at:
await page.annotations.move([note.ref], 0); // move to the frontStyling#
Most annotations share the same styling fields.
Color is RGB only — { r, g, b }, each 0–255. Transparency is a separate
opacity field (0–1), not part of the color:
await page.annotations.update(note.ref, {
subtype: note.subtype,
color: { r: 0, g: 120, b: 255 },
opacity: 0.6,
});| Field | What it is | Values |
|---|---|---|
color | Stroke color (or highlight color) | { r, g, b }, 0–255 |
interiorColor | Fill color for closed shapes | { r, g, b } or null (no fill) |
opacity | Whole-annotation transparency | 0–1 |
strokeWidth | Line/border thickness in points | number, default 1 |
borderStyle | Border style | 'solid', 'dashed', 'beveled', 'inset' |
dashArray | Dash pattern (with 'dashed') | array of numbers |
When you omit a styling field, the engine uses a sensible default: a 1pt solid red stroke at full opacity.
Free text is the one exception to “color = stroke”: there color is the border and the text
color, interiorColor is the box background, and an optional fontColor overrides just the text.
See Free text and callout.
Flags#
Every annotation has the standard PDF flags. Set only the ones you care about — the rest keep their current value:
// On create: print, but don't show on screen.
await page.annotations.create({
subtype: 'square',
rect: { left: 100, bottom: 600, right: 200, top: 680 },
color: { r: 0, g: 0, b: 0 },
flags: { print: true, noView: true },
});
// Later: lock from editing without touching any other flag.
await page.annotations.update(note.ref, { subtype: note.subtype, flags: { readOnly: true } });Available flags: invisible, hidden, print, noZoom, noRotate, noView,
readOnly, locked, toggleNoView, lockedContents.
Rendering annotations#
Every annotation carries its visual as an appearance stream inside the PDF. To display them, batch-render a page’s appearances into images:
const { appearances } = await page.annotations.renderAppearanceImages({ scale: 2 });
for (const ap of appearances) {
// ap.ref — which annotation this is
// ap.rect — WHERE to place it (PDF points, y-up)
// ap.image — a lazy image handle; ap.image.objectUrl() gives you a blob: URL
}One convention to know: when an annotation’s DTO carries both rotation and
unrotatedRect (the box-family kinds — square, circle, free text, stamp), its
appearance renders rotation-stripped: ap.rect is the logical unrotatedRect and
the image is the flat content mapped into it. You re-apply that rotation as a
transform about the box centre. Everything else — line, polyline, polygon, ink (their
rotation is pre-baked into the geometry) and annotations from other tools — comes back
as-is, placed by ap.rect, no transform needed.
const { url } = await ap.image.objectUrl();
// `annotation` is the matching entry from list() — ref, rotation, etc.
const stripped = 'unrotatedRect' in annotation && annotation.unrotatedRect && annotation.rotation;
<img
src={url}
style={{
position: 'absolute',
/* place by ap.rect, converted to your view coordinates */
transform: stripped ? `rotate(${annotation.rotation}deg)` : undefined,
transformOrigin: 'center',
}}
/>;This split is what makes interactions cheap:
- Rotating an annotation never needs a re-render — the image is rotation-invariant; only your transform changes.
- Moving never needs a re-render — only the placement changes.
- Resizing can stretch the existing image live during the gesture, then fetch once
after committing the new
rect(the engine re-fits the appearance natively).
Re-render appearances when the content changed — a committed geometry edit, a style
patch, a replaced stamp source — or when your zoom level changes and you want a
sharper raster (scale).
If you use the viewer packages (plugin-annotation + a framework adapter), all of this is wired
for you. This section is for rendering annotations yourself against the raw engine.
Flattening chosen annotations#
pages.flatten bakes every annotation of a page into its content. The
per-page annotation service does the same for a chosen set, and reports
what happened to each:
const result = await page.annotations.flatten!([refA, refB], 'display');
result.results; // [{ ref: refA, status: 'applied' }, { ref: refB, status: 'skipped' }]applied annotations are painted into the page and removed; skipped ones
stay where they were — hidden for the usage ('display' or 'print'), a
Popup, or without a usable normal appearance — so a caller can say “2 of 3
flattened”. A ref on another page rejects the whole call before anything is
mutated. Gated like pages.flatten: doc.pages.modify and
doc.annotate.modify.
Exporting appearances as a PDF#
The same plan aimed at a fresh page: the normal appearances of the given annotations, flattened into a new single-page PDF sized to their union rect — vector, positions preserved, exactly as this page shows them. The source document is untouched.
const bytes = await page.annotations.exportAppearance!([refA, refB]);
// …a one-page PDF: hand it to a stamp library, or download it.All-or-nothing: any ref that is not on the page, hidden, or without an
appearance rejects the call with InvalidArg. A derived read that egresses
content, so it is gated by doc.download like pages.extract.
Edit sessions (cloud)#
On the cloud, structural edits addressed by index (a delete or move that shifts the array) need an active edit session. It proves you’re the only one editing those pages — a guard against two clients shifting the same page at once.
const session = await doc.annotations.beginWeakEdit([pageObjectNumber]);
try {
// …index-addressed structural edits on covered pages…
await session.heartbeat(); // keep the session alive
} finally {
await session.release();
}Edits that reuse a ref from list()/create() (object number or /NM) and non-shifting
updates don’t need a session. You only need beginWeakEdit for index-addressed structural edits.
A conflicting edit fails with a WeakAnnotationSessionConflict error.
Annotation writes are gated by the caller’s scope on the cloud (for example
doc.annotate.write and collab scopes like
annotations:update:self). Insufficient scope fails with
Forbidden.
Next#
Your feedback goes directly to the documentation team.