Skip to content

Change an annotation's kind in place - #973

Merged
bobsingor merged 6 commits into
mainfrom
feature/annotation-kind-change
Nov 4, 2026
Merged

bobsingor merged 6 commits into
mainfrom
feature/annotation-kind-change

Conversation

@bobsingor

@bobsingor bobsingor commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

What changes

Switching a highlight to an underline (or squiggly, or strikethrough), or a square to a circle, took a delete and a create with every field copied by hand. That loses the annotation itself: its ref, its replies' /IRT, its popup and group, its place in the drawing order, and its author, which a create stamps anew. update() refused a changed subtype. (v2 looked as if it allowed it, but it never wrote /Subtype: the file kept a highlight, and a reload showed one.)

Now an update changes the kind to a sibling and the annotation stays itself:

await page.annotations.update(highlight.ref, { subtype: 'squiggly' });
await page.annotations.update(square.ref, { subtype: 'circle', color: '#1d4ed8' });
  • Siblings are the kinds ISO 32000-2 describes with one table of entries: highlight, underline, squiggly and strikeout (12.5.6.10), and square and circle (12.5.6.8). Our declarations give each group the same fields, and a test holds them to it (siblings.ts).

  • Any other subtype is refused with InvalidArg (details.field: 'subtype'), naming the kinds it can become.

  • A value the new kind has no field for is refused, never dropped. The caller clears it first, in the same change, so it stays one undo step. Today that's only a replacement's strikeout and its intent:

    await doc.apply({ ops: [
      { type: 'annotations.update', ref: strikeout.ref, patch: { intent: null } },
      { type: 'annotations.update', ref: strikeout.ref, patch: { subtype: 'highlight' } },
    ] });

By layer:

  • Runtime: pinned to f2dd40897, which has EPDFAnnot_SetSubtype (Change an annotation's kind in place runtime#103, Breaking changes introduced with new PATCH version #108). The bindings gain it, and Text Box Problems #104's EPDFAnnot_SetNoColor. Pin the runtime to f2dd40897: shapes without a stroke, and ink drawn as Acrobat draws it #971 brought the same pin to main with ink's viewer half, and main is merged in, so the pin itself no longer shows as a change here.
  • Engine core: siblingKindsOf, canChangeKind, fieldTheKindCannotHold. The patch resolver and schema check run as the new kind. The prediction (applyAnnotationPatch) reads the new kind, and annotationPatchBetween states the subtype.
  • Engine services: AnnotationMutator.update calls EPDFAnnot_SetSubtype before writing the fields, and the appearance is drawn as the new kind. The undo fallback (annotation changed since) keeps the subtype.
  • Viewer core and plugin: selection.update({ subtype }) changes the members that can become that kind; the rest keep theirs. A locked annotation, a replacement's strikeout, or a kind editable doesn't allow by hand keep their kind. getProperties() leads with a subtype choice (selectionProperties). A tool's defaults never offer one.
  • Viewer chrome: a Type row in the style panel, with one toggle per kind and each tool's icon. "Type" is in all 15 languages. Acrobat, Preview and Foxit can't change a mark's kind at all; Zotero 7 does it from the mark's context menu.
  • Docs: engine annotations (Change the kind), text markup, shapes, headless with-code and selecting, and the viewer's annotations page, synced to both sites.
  • Changesets: engine-core, engine-runtime, engine-services, engine, core-annotation, plugin-annotation (all minor), and viewer-chrome (minor).

Not in this PR: polygon ↔ polyline (more fields differ, and a measured polygon measures an area while a polyline measures a length). The wire shape and the contract's update operation don't change, so there's nothing for api:sync.

Testing

  • Fork (Change an annotation's kind in place runtime#103, released): SetSubtypeChangesTheKindInPlace and LayerSetSubtypeKeepsTheObjectAndAborts, native darwin-arm64, plus every *EPDFAnnot* test (170) and layer mode.

  • Conformance on the local (wasm) and cloud engines (runAnnotationMutationConformance, 4 new tests):

    • a highlight becomes a squiggly line with its ref, nm, place in the order, reply and author;
    • a square becomes a circle with a new color, and back;
    • another family and a replacement's intent are refused, and clearing the intent first in the same change works;
    • undo, redo, and undo after a restyle.

    runAnnotationPredictionConformance adds 4 kind changes, predicted as the engine writes them.

  • Suites, after merging main (with Pin the runtime to f2dd40897: shapes without a stroke, and ink drawn as Acrobat draws it #971 and Write a dashed border's default pattern out; an empty pattern reads solid #972): @embedpdf/engine 1917 passed, none failing (the 4 annotation-rect ink cases that the pin alone failed now pass with Pin the runtime to f2dd40897: shapes without a stroke, and ink drawn as Acrobat draws it #971's ink half). engine-core 1033, core-annotation 663, plugin-annotation 280, and the cloud mutation and prediction suites 126 passed. Before the merge, the full @cloudpdf/engine suite passed 1110.

  • Checks: typecheck of the 93 packages that depend on core-annotation (websites aside), api:check, and docs:check on both sites all pass.

  • Viewer dev harness:

    • select the demo's square, style panel, Type → Circle: an ellipse in its turned box, the same object, stroke and rotation kept, written by the engine. Cmd+Z brings the square back.
    • a new highlight → Squiggly keeps its comment, color and multiply blend.
    • a highlight and a circle selected together get no Type row.

An update with a new subtype turns a highlight, underline, squiggly
line or strikeout into another of the four, and a rectangle into an
ellipse and back: the kinds ISO 32000 describes with one table of
entries, and that we declare with the same fields. It stays the same
annotation: its ref, nm, place in the drawing order, replies, popup,
group and author. The update takes the new kind's fields; any other
subtype fails with InvalidArg, naming the kinds it can become.

A value the new kind has no field for is refused, never dropped: the
caller clears it first, in the same change, so both writes are one
step to undo. Today that is only a replacement's strikeout, which
keeps its kind while it has its intent.

In the headless docs, update() takes the subtype, the style panel has
a subtype choice (none for a replacement's strikeout),
selection.update gives it to the annotations that can take it, and
changing a kind by hand needs both kinds in editable.

Docs first: the engine still refuses a changed subtype.
The patch resolver takes a subtype the annotation can become: a
highlight, underline, squiggly line or strikeout another of the four,
a square a circle and back. ISO 32000-2 describes each group with one
table of entries, and our declarations give them the same fields
(siblings.ts; a test holds them to it: same owners, read-backs, colors
and spaces, apart from a strikeout's intent). The patch is the new
kind's, checked against its schema. Any other subtype is refused,
naming the kinds it can become. A value the new kind has no field for
is refused, never dropped: the caller clears it first, in the same
change.

The prediction makes the annotation the new kind, a field only it has
reading null, and annotationPatchBetween states the subtype when the
kind changed.

Engine core only. The engines still keep /Subtype as it was until the
fork's EPDFAnnot_SetSubtype (embedpdf/runtime#103) is released and
pinned; the writer, undo and conformance follow with it.
Pin the runtime to f2dd40897, which has EPDFAnnot_SetSubtype
(embedpdf/runtime#103, #108); the bindings gain it and #104's
EPDFAnnot_SetNoColor.

An update whose resolved patch names a sibling kind calls
EPDFAnnot_SetSubtype before writing the fields, so they are written as
the new kind's and the appearance is drawn as one (appearanceImpactOf
already answers regenerate for a changed subtype). The undo fallback
keeps a changed subtype, so an undo after a later edit puts the old
kind back by value; the usual undo restores the dictionaries.

Conformance, on the local and the cloud engine: a highlight becomes a
squiggly line with its ref, nm, place in the order, reply and author;
a square becomes a circle with a new color and back; another family
and a replacement's intent are refused, and clearing the intent first
in the same change works; undo, redo, and undo after a restyle. The
prediction suite predicts four kind changes as the engine writes them.

The contract's update operation and the wire shape don't change, so
there is nothing for api:sync.
withValues takes a sibling subtype when the record may change and the
annotation can become it (canChangeKind, from engine-core, the same
rule the resolver refuses by), so its patch and pending view are the
new kind's. setFields gives it only where people may change the new
kind by hand too, so selection.update({ subtype }) changes the members
that can and restyles the rest. changeBetween no longer pins a patch
to the old subtype. The plugin's restoring patch already comes out
right: annotationPatchBetween states the subtype.

selectionProperties leads a selection's properties with a subtype
choice when its kinds are siblings that can each change kind (not a
replacement's strikeout), offering the kinds people may change by
hand; each named as the comment list names it, under
annotation.style.kind. A tool's defaults panel keeps propertiesOf, so
it never offers a kind. The viewer's style panel renders no control
for subtype yet; that is the next step.

engine-core: fieldTheKindCannotHold and canChangeKind beside
siblingKindsOf; the resolver's refusal uses the first. The API reports
follow, and the conformance report catches up with a formatting-only
change from the previous commit.
The style panel's Type row shows the subtype choice selectionProperties
leads with: one toggle per kind the selection can become, each with
its tool's icon (KIND_ICON), as the alignment row does. Picking one
sends selection.update({ subtype }). annotation.style.kind ("Type") is
in all 15 viewer languages; the kinds reuse the comment list's names.

Acrobat, Preview and Foxit have no way to change a mark's kind (delete
and mark again); Zotero 7 converts highlight and underline from the
mark's context menu. The style panel is where a selected mark's other
properties are edited, so the kind sits there, first.

Checked in the dev viewer: a square becomes a circle in its turned box
(same object, stroke and rotation, written by the engine), Cmd+Z turns
it back; a new highlight becomes a squiggly line with its comment,
color and blend mode; a highlight and a circle selected together get
no Type row.

The viewer's annotations page says the style panel changes a mark's
kind, with a short section under Good to know.
@vercel

vercel Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cloudpdf-com Ready Ready Preview Nov 4, 2026 8:05am UTC
embed-pdf-snippet Error Error Nov 4, 2026 8:05am UTC
embed-pdf-viewer-react-mui Error Error Nov 4, 2026 8:05am UTC
embed-pdf-viewer-svelte-tailwind Error Error Nov 4, 2026 8:05am UTC
embed-pdf-viewer-vue-vuetify Error Error Nov 4, 2026 8:05am UTC
embed-pdf-website Ready Ready Preview Nov 4, 2026 8:05am UTC

Request Review

…nd-change

# Conflicts:
#	api/embedpdf__angular/annotation.api.txt
#	api/embedpdf__angular/index.api.txt
#	api/embedpdf__core-annotation/index.api.txt
#	api/embedpdf__plugin-annotation/contract.api.txt
#	api/embedpdf__plugin-annotation/contract__host.api.txt
#	api/embedpdf__plugin-annotation/index.api.txt
#	api/embedpdf__react/annotation.api.txt
#	api/embedpdf__react/index.api.txt
#	api/embedpdf__svelte/annotation.api.txt
#	api/embedpdf__svelte/index.api.txt
#	api/embedpdf__vue/annotation.api.txt
#	api/embedpdf__vue/index.api.txt
#	packages/engine/core/src/annotation/resolve/resolveAnnotationPatch.ts

This branch had an error being deployed

4 failed and 2 active deployments
Preview – embed-pdf-website — 8743b8d1 Deployed Nov 4, 2026 by vercel[bot]
Preview – cloudpdf-com — 8743b8d1 Deployed Nov 4, 2026 by vercel[bot]
Preview – embed-pdf-viewer-svelte-tailwind — 8743b8d1 Deployed Nov 4, 2026 by vercel[bot]
Preview – embed-pdf-snippet — 8743b8d1 Deployed Nov 4, 2026 by vercel[bot]
Preview – embed-pdf-viewer-react-mui — 8743b8d1 Deployed Nov 4, 2026 by vercel[bot]
Preview – embed-pdf-viewer-vue-vuetify — 8743b8d1 Deployed Nov 4, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant