Skip to content

Feature/performance improvements - #841

Open
bobsingor wants to merge 116 commits into
mainfrom
feature/performance-improvements
Open

bobsingor wants to merge 116 commits into
mainfrom
feature/performance-improvements

Conversation

@bobsingor

Copy link
Copy Markdown
Contributor

No description provided.

The kernel has one plugin shape and one context. A plugin is
`definePlugin({ id, token, scope, requires, optional, state, create })`, and
`create(ctx)` returns `{ api, connect }`. The action/reducer path is deleted:
`initialState`, `reduce`, `capability`, `init`, `effects`, `dispatch`,
`getState`, `onAction`, `EffectContext` and the `CORE_*` actions. The
controller context is merged into `PluginContext`.

- State: `ctx.state` (`get`, `update(transition, ...args)`, `onChange`),
  `ctx.notify()` for resources, `ctx.watch()` for state a plugin does not own.
- Engine data: `ctx.mirror()` and `ctx.pageMirror()` load after connect and
  fold confirmed document events from every origin, with cursor replay, page
  reloads and resync on desync or a new version. `memo`/`memoByKey` give
  identity-stable reads; `createHostToken` builds a plugin's host lens.
- Engine invariant: an own mutation's event is published before its promise
  settles. The conformance suite checks it; local and cloud engines pass.
- Plugins: every plugin runs on the new context. Annotation and form stay in
  sync through mirrors instead of re-reading after writes; a create is matched
  to its confirmation by the client-assigned /NM.
- Fixes: parked signings the engine no longer knows; disarms made by the
  annotation plugin; raster invalidation for flattens, signatures and desyncs;
  link and selection re-reads after redactions and flattens; redaction mark
  verbs reject instead of throwing; command definitions can resolve any
  capability in development builds (`resolvesAnyCapability`).
- Names: `pageObjectNumber` for `pon`; `Point`, `ContentGeometry`,
  `ModelAnnotation`, `Message`; `kind` for things that are, `type` for things
  that happen; no single letters or retired abbreviations. Public renames:
  `usePages().previous`, `ChromeGeometry`, `Paint.lineCap`, draft `current`.
  `useMeasurement()` drops `canCalibratePage`; read `canCalibrate()` with a
  selector. `/internal` entries are removed.
- Comments state present behavior, with no plan, phase, history or missing-doc
  references and no capitals for emphasis.
- Conventions live in docs/conventions/ (architecture, state and sync, events,
  plugins, naming, comments, testing, packages and the moved references); the
  agent skill routes into them.
- Enforcement: every package typechecks its tests; `check:comments` and
  `check:architecture` run in CI; ESLint naming rules are errors; the plugin
  boundary check knows `controller`/`connect`; a pull-request template.
- API snapshots regenerated; one changeset per package with a user-visible
  change.
The annotation core no longer stores records. `update(model, message)`
returns `{ session, change, effects }`: the next session, the records the
message changed, and the engine work. The plugin keeps three kinds of data,
each with one owner: the records mirror holds what the engine confirmed,
`pending` holds the user's changes until their writes settle, and `session`
holds selection, gestures and settings. The view composes them into the
core's `Model`.

- Core: `Model = Session & AnnotationView`. The record messages (`loaded`,
  `hydrated`, `upsert`, `remove`, `bumpAp`, `created`, `createFailed`) are
  gone; `rekey` follows a new record to its engine key and `forget` drops
  references to records that left the view. New records are `new:<n>`.
  Typing emits a `text` effect; `patch` no longer guesses `apChanged`;
  `delete` names the record by id, and `delete`/`flags` are emitted for
  unconfirmed records too. `src/update.ts` is split into `src/update/*`,
  one module per gesture family.
- Confirmed: the records mirror holds `{ dto, apVersion }` and the order,
  and owns appearance versions (the engine's `appearance.changed`, form and
  signature repaints, reloads). A weak (position-addressed) record keeps one
  record when the engine names it; a page is re-read when positions moved.
- Pending: one change per engine write, holding only the fields that write
  carries. A refused change is dropped at once, so the view shows the
  engine's record again (rollback is deletion). An accepted change is
  dropped once the mirror holds it and every older change of the record has
  settled, and stays while the mirror is stale. Changes follow a record
  whose key changes; writes to an unconfirmed record wait for its create.
- View: memoized per record, with per-page slices so a change on one page
  does not rebuild another page's items.
- Fixes: a refused delete no longer hides the annotation; refused flags
  revert; a refused restyle shows a collaborator's change made meanwhile;
  typed text survives another write of the same record settling; edits and
  deletes of a new annotation before confirmation are kept and written
  after the create; weak annotations leave no duplicate after an edit and
  no ghost after a delete.
- Public: `onWriteFailed`; `Annotation.pending`; `Annotation.raw` is always
  the confirmed record; programmatic updates keep the engine's raster and
  re-fetch it after a re-bake; every annotation the plugin creates has /NM.
- Kernel: a failed page re-read sets the mirror status to `error` until a
  full load succeeds. Engine core: `positionKey(page, index)`.
- React: the free-text editor writes through `draftRichText`, one write
  after a pause or when the edit ends instead of one per keystroke.
- Readability: crud split into verbs, effect runners, outcomes and
  page-space patching; READMEs for core-annotation and plugin-annotation;
  conventions updated (overlay rules, mirror failures, overlay tests).
- Tests: capability scenarios for refusals, out-of-order answers, edits of
  unconfirmed records, weak records and a stale mirror; 40 seeded random
  interleavings; the update contract; a kernel mirror failure test;
  real-engine tests for weak records and changes faster than the engine
  answers; a React DOM test for typing.
- API snapshots regenerated; changesets extended.
Replace the old new-records logic with a single record-identity service that centralizes how records change keys (confirmed new records and engine-named weak records). Update services API (newRecords -> identity) and wire identity into intents, store, write runners, links and text-editing so writes wait for a record's engine ref and pending data (including textSelection and vector preference) follow on key changes. Adjust followRecord to accept ref and move text ranges, improve write error reporting so one engine write reports refusal once, and settle held creates when the mirror holds them. Update docs and tests to reflect behavior changes (key renames, rendering source, batching of keystrokes, link sync) and remove the deleted new-records module.
Every annotation kind is declared once, and its read, create and update
types and schemas are derived from that declaration. Both engines read and
write the same shapes, so an annotation read from the engine can be sent
back as an update, or as a create. A session has one identity object on
both engines, and the engine stamps attribution from it.

- Declarations: `defineKind` with `field.data`, `field.attribution`,
  `field.engine` and `field.preserved`, plus `.nullable()`,
  `.nullableOnRead()`, `.optional()`, `.createOnly()` and `.writes()`.
  There are twenty kinds in `kinds/<kind>/declaration.ts`, including a new
  `popup` kind. The hand-written dto, draft, patch and schema files are
  gone. Read schemas drop unknown keys; create and update schemas are
  strict.
- Complete reads: every declared field is present, `null` when the PDF
  has no entry. `flags` is replaced by ten flag fields, and
  `inReplyTo`/`replyType` by `reply: { to, type } | null`. Captions are
  split: `captionEnabled`, `captionPosition` and `captionOffset` on lines;
  `captionEnabled` and `captionCenter` on polygons and polylines. Reads
  also carry the `popup` back-link, `userId`, `createdBy`, `updatedBy` and
  `importedBy`. `actions` is read and kept but not writable. A missing
  attachment file reads as `file: null`.
- Writes: a field left out keeps its value, `null` removes it, and a value
  replaces it. Patches merge at the top level and replace below it. A
  patch takes its subtype from its target. A different subtype, a changed
  `nm`, or a field the kind doesn't declare is refused: the worker checks
  names against the zod-free `ANNOTATION_FIELD_NAMES`, and the server
  checks values against the target kind's schema. Engine-owned and
  attribution fields sent back are ignored. `reply.type` defaults to
  `'reply'`. A group can be reassigned but not removed.
- Popups are listed as their own items, linked both ways to their parent
  (`/Parent`, `/Popup`), and can be created. `/Open` is not modeled yet.
- Foreign (GEO) measures read as a marker. An update that sends the marker
  back keeps the measure, and an explicit scale replaces it; the writer
  removes it first, because the native setter can't replace one in place.
  A create carrying the marker is refused for now.
- Identity: one `Identity` (`userId`, `displayName`, `email`, `title`,
  `organization`, `organizationalUnit`, `groupId`, `groups`). The local
  engine takes it as an open option; the document JWT carries it in one
  `identity` claim; `/access` returns it; `doc.security.identity` exposes
  it. Breaking: this replaces the flat `user_id`, `group_id`,
  `display_name` and `groups` claims, and the flat identity fields of the
  doc-token mint request. `DocumentIdentity` and `IdentityClaims` are
  removed.
- Attribution: create stamps `/T`, `/CreationDate` and `/M` (the same
  moment), plus `UserID`, `CreatedBy`, `UpdatedBy` and `GroupID`, and
  ignores attribution sent in the data. Update stamps `/M` and
  `UpdatedBy`. A new annotation goes in the draft's group, else the
  session's; any other group needs set-group authority on both engines.
  The server now runs its collab check after parsing the draft.
- New capability `doc.annotate.import`. `pdf.permissions` never grants
  it, `doc.annotate.modify` doesn't imply it, and signature protection
  removes it. Nothing checks it yet.
- Fixes:
  - The native runtime read a missing `/EMBD_Metadata` key as `''`, so
    cloud reads showed `groupId: ''`.
  - The local engine refused to open with
    `annotations:set-group:group=X` unless the session was in group X.
  - The cloud dashboard example minted share tokens with no identity.
  - acrojs `identity.corporation` came from the group id. It now comes
    from `organization`, and `identity.email` is filled in.
- Consumers moved: the annotation plugin (its model keeps `flags` and one
  caption object, and the repository translates at the DTO boundary),
  link, actions, the viewer's measurement panel, the form and stamp
  tests, the cloud engine, the server, the contract, the cloud dashboard
  example and the engine docs.
- Tests: two conformance suites run on local and cloud.
  `runAnnotationDeclarationConformance` checks that every read of real
  and created annotations matches its declaration with zero drift, that
  shared fields follow the write rules on every creatable kind, that a
  read sent back as a patch works, that unknown fields and names are
  refused, and that popups link both ways.
  `runAnnotationAttributionConformance` covers create and update
  stamping, forged attribution, a second session's update, an
  Acrobat-authored annotation, groups with and without set-group
  authority, and the import capability. Also new: a field-name table
  test, `doc.annotate.import` scope tests, and JWT `identity` claim tests.
- `openapi.json` and the API snapshots are regenerated.
Bytes are no longer part of an annotation's data. `create(data, resources?)`
and `update(ref, patch, resources?)` take them in a second argument, keyed by
what they are for, so the data a read returns is exactly what a create or an
update takes, on the wire as in the API.

- Resources: one list of roles, `ANNOTATION_RESOURCE_ROLE_NAMES`
  (`appearance`, `file`), gives `AnnotationResourceRole`,
  `AnnotationResources` (`Partial<Record<role, Uint8Array | Blob>>`),
  `WireAnnotationResources` (`Partial<Record<role, ArrayBuffer>>`) and the
  declarations' `KindResources`. A create without a role its kind requires,
  a role the kind doesn't take, an unknown role, or an appearance that isn't
  PNG, JPEG or PDF is refused with `InvalidArg`. The zod-free
  `ANNOTATION_RESOURCE_ROLES` table is kept equal to the declarations by the
  field-name test.
- One data shape: `WireAnnotationDraft`, `WireAnnotationPatch`, the stamp
  and file-attachment `Wire*` types, `ResourceRef` and
  `normalizeAnnotationDraft`/`normalizeAnnotationPatch` are gone.
  - The worker job carries the resources by role.
  - An HTTP write is the data as JSON, or multipart with the data as `body`
    and the bytes as `resource:appearance` and `resource:file`.
  - The update route now accepts any bytes as a `file`; it only accepted
    images before.
- Stamp: `source` becomes the `appearance` resource. `fit` becomes a
  declared field, recorded in `/EMBD_Metadata/AppearanceFit` and read back
  (`null` for a stamp that records none).
  - A create records the fit it used, unless the data says `fit: null`.
  - A resize re-fits with the recorded fit.
  - A patch's `fit` re-fits the drawing and is recorded.
  - A new appearance replaces the drawing.
- File attachment: `file` holds the name, MIME type and description; the
  bytes are the `file` resource. In an update, `file` replaces all three and
  writes only what differs, so sending a read back changes nothing. The
  resource replaces the bytes and keeps the MIME type when the patch gives
  none. File metadata no longer counts as an appearance change.
- New fork setter `EPDFAttachment_SetName`: sets `/UF` and `/F` and keeps
  every other filespec entry. The runtime's function table gains it, and its
  generator writes the header the committed file has.
- Document attachments: `WireAttachmentFile` and
  `normalizeAttachmentFileSource` move to `dto/`, since only
  `attachments.create` uses them now.
- `toCreateDraft` is no longer exported; the plugin keeps it internally to
  turn its own records into engine data.
- Plugins:
  - Stamp placement and the stamp library put `fit` in the data and the
    bytes in `appearance`.
  - Icon placement splits a picked file into `file` data and the `file`
    resource.
- Fix: the plugin's public `update(ref, { flags })` still sent a `flags`
  object, which the engine has refused since the flags became separate
  fields. It now writes each flag as its own field.
- Tests:
  - The declaration suite covers, on both engines: resource checks per
    kind, replacing a stamp's drawing, recording and re-applying its fit,
    and renaming an attached file and replacing its bytes.
  - `resources.test.ts` replaces `normalize.test.ts`.
  - Stamp, multipart and plugin tests move to the new shape.
- Docs: the stamp section is rewritten, a file-attachment section is added,
  and the annotations page explains the second argument. `openapi.json` and
  the API snapshots are regenerated.
- readResource(ref, role) replaces downloadFile on both engines. The
  `appearance` role returns a stamp's drawing as a one-page PDF, before
  the fit, rotation and opacity its data describes; `file` returns the
  attached bytes. Worker job annotations.readAppearance, route
  GET …/items/:annotKey/resources/appearance, contract operation
  doc.annotations.readAppearance; needs doc.download.
- Stamp `opacity` (/CA) is data. It is painted once, as the layer
  Acrobat writes and recognises by name (`/R0 gs /MWFOForm Do` over our
  wrapper), so an opacity set here or in Acrobat replaces the other
  rather than multiplying it. Opacity changes go through
  EPDFAnnot_SetStampOpacity, which reads the appearance with the old
  value. Our wrapper is found again under Acrobat's forms that only draw
  or move it; a layer that doesn't paint /CA stays part of the drawing.
- A stamp that records no fit is re-fit as `fill`; a create with
  `fit: null` fits that way too.
- Viewer: stamps get an Opacity prop, and the plugin reads /CA into the
  style.
- Runtime: fork bumped to 0657b971e (wrapped-appearance module, nested
  form opacity applied once, EPDFAttachment_SetName); generated function
  list.
- Tests: runAnnotationResourceConformance on both engines (12 tests),
  with fixtures stamp-opacity-acrobat.pdf, stamp-rewrapped-acrobat.pdf
  (and its generator) and stamp-roundtrip-acrobat-{60,100}.pdf, the
  Acrobat round trip of a stamp made here; a plugin repository test for
  stamp opacity.
- Docs: annotation types (opacity both ways with Acrobat, `fit: null`,
  copying a stamp, readResource), synced to both sites; openapi.json and
  API snapshots regenerated.
- Runtime: fork bumped to <sha>: canonical drawing export, adoption on
  import, boolean entry accessors, and the exporter no longer writes the
  target's /Info. Generated function list updated.
- A PNG or JPEG stamp is drawn from a one-page drawing of the image at
  its own size, a pixel to a point, scaled down to fit 14,400 pt. Before,
  it was the image placed in its first box with its first fit baked in.
  A `cover` stamp later set to `contain` now shows the whole image, and
  the same image is the same drawing in any box.
- Popups declare, read and write `open` (/Open, false when absent).
- Tests: runAnnotationResourceConformance on both engines checks that
  export after import is the same bytes (vector, PNG and both Acrobat
  stamps), that an image is the same drawing in any box, that cover then
  contain shows the whole image, and that an export has no /Info or
  private keys. The declaration suite covers popup `open`. A local test
  checks that wasm and native export the same bytes.
- Docs: what a stamp's drawing is (images at their own size, the same
  bytes from either engine, storable by hash), synced to both sites.
  openapi.json and the API snapshots regenerated.
setStampContent no longer empties the old appearance through the object
API before replacing it; both paths set a whole new appearance. A full
rewrite after a new drawing has none of the old one. Bumps runtime-src
for copy-on-write of shared forms.
- Runtime: fork bumped to <sha> (shared stamp drawings,
  EPDF_DigestBuffer). Generated function list updated.
- DocumentSession keeps a DrawingIndex for the open document. It maps
  each drawing's content id (the SHA-256 of its canonical bytes) to the
  drawing, built from the file's stamps on first use, and notes which
  drawing each uploaded set of bytes made. It lives in memory only and
  is dropped with the document.
- The stamp writer finds or adds the drawing (stampDrawing.ts: check the
  note, canonicalize, look up the index, import only new content), then
  places it with EPDFAnnot_SetStampDrawing.
  - 100 placements of one image store it once.
  - A PNG and the drawing exported from it are one drawing.
  - Drawings already in a reopened file are placed again, not copied.
- A stamp another tool made exports its drawing at its own size. Its
  `fit: null` (a fill) places it again.
- Tests:
  - Resource conformance, on both engines: 10 PNG and 10 PDF placements
    give one drawing each; a PNG and its export are one drawing; new
    artwork for one stamp leaves a stamp sharing the old artwork alone.
  - A local test places artwork that a reopened document already has.
- Docs: stamps share drawings, and another tool's stamp is exported at
  its own size. Synced to both sites. API snapshots updated.
A bundle holds annotations and the bytes beside them, to move them
between documents in one call. Each item is a row: its data, exactly
what a read returns, and the ids of the resources it names. Each
resource is stored once, under the SHA-256 of its bytes.

- engine-core src/transfer/, zod-free, exported from the main entry:
  - AnnotationBundle, AnnotationBundlePage, AnnotationBundleItem and
    ResourceId. resourceIdOf hashes with WebCrypto.
    assertAnnotationBundle checks a bundle whole: format, limits, shape,
    references, then every resource against its id.
  - AnnotationBundleLimits and DEFAULT_ANNOTATION_BUNDLE_LIMITS: one set
    for making and reading a bundle, measured on the bundle itself (the
    manifest's JSON plus each resource's bytes).
  - AnnotationTransfer.stringify and parse: the bundle as one JSON file,
    resources in base64. parse checks the limits before it decodes
    anything: first the text's length, before JSON.parse, then the
    counts and each resource's size, read from its base64, and only
    then decodes and hashes. It refuses:
    - an unknown format, version or field, naming the field;
    - an item on a page the bundle doesn't list;
    - a missing or unused resource;
    - bad base64;
    - a resource that doesn't match its id.
    stringify runs the same checks on shape and size.
- EngineErrorCode.PayloadTooLarge, with details { limit, max, value }.
  The server maps it to 413 and the kernel to invalid-input.
  openapi.json regenerated.
- Base64 moved from wire/schemas.ts to resource/base64.ts. It is now
  zod-free and one pass over a preallocated buffer, and
  decodedLengthOf sizes base64 without decoding it. The /wire entry
  still exports toBase64 and fromBase64.
- Fixes a type error in the resource conformance helper (a
  Uint8Array<ArrayBufferLike> passed to new Blob).

Tests: the codec (a round trip; resources stored once; each refusal;
each limit refused before anything is decoded or hashed),
assertAnnotationBundle, and base64 encode, decode and sized-length.
Engine core 612, kernel 136, cloud engine 290.
doc.annotations.export(selection?) takes annotations out of a document
with the bytes beside them, as one AnnotationBundle: every annotation,
chosen refs, or whole pages.

- What an export takes (closeExportSelection, engine-core):
  - the selection, plus what it points at: a reply brings its parents
    up to the root, an annotation its popup, a popup its annotation;
  - by default (include: 'threads'), every reply below a selected
    annotation; include: 'references' leaves them out;
  - replies and popups are on their parent's page (ISO 32000-2
    §12.5.6.2), so only the selection's pages are read.
  pageRefsIn lists the pages each item names; each bundle page carries
  its position and crop box, for mapping pages on import.
- The worker job (AnnotationExporter) loads no page: annotations come
  from the raw read and each resource from a raw annotation handle. A
  drawing is exported once per drawing object however many stamps
  place it; another tool's stamp through EPDFAnnot_ExportAppearance;
  attached files are capped at the resource limit. Ids are SHA-256
  from the fork's digest, and every limit is checked while the bundle
  is built (PayloadTooLarge names it).
- Local engine: needs doc.annotate.read and doc.download; resource
  buffers go on the transfer list.
- Cloud: a versioned, CDN-cacheable GET, not a POST:
  - GET …/layers/:layer/annotations/export@<token>, and a doc-level
    twin for layers that inherit the annotations and layout planes;
  - the token pins annotationsVersion and layoutVersion (a page
    reorder moves positions without bumping annotationsVersion) and
    carries the selection canonically, one selection one URL, durable
    refs only;
  - resources annotations-export and layer-annotations-export require
    both capabilities (kind 'all');
  - the response is multipart: the manifest, then one resource:<id>
    part per resource. The server checks the pins before and after
    the job; the client refreshes on a stale pin and checks every
    part against its id.
- Shared helpers, one owner each: saveDocumentToBuffer (out of
  AnnotationFlattener), digest (out of stampDrawing), buildMultipart
  (routes/_multipart.ts), readBoxes (exported from PagesReader), and
  the conformance stamp fixtures (stampFixtures.ts).
- An attachment past its decoded-size limit is PayloadTooLarge.

Tests: runAnnotationExportConformance on both engines:
- data equals the raw read; one resource per drawing;
- resources equal what readResource returns;
- the thread and reference closures;
- the export after an edit and after a page move;
- a whole-file SHA-256 of two Acrobat fixtures that both engines
  reproduce.
Local: the doc.download gate and each limit through WorkerHost.
Cloud: Forbidden without doc.download. Also the closure and the
export token.
Engine core 630, local engine 546, cloud engine 297, server 869,
kernel 136. Docs: "Exporting annotations", synced to both sites; API
snapshots updated. openapi.json unchanged: these are viewer routes.
Implement annotation bundle import end-to-end across engine and server.

- Engine core: add transfer types/schemas for import, pageRefs helpers, planAnnotationImport, import options/results and event tx grouping.
- Local & Cloud: DocumentAnnotationsService.import(), SessionEventPublisher tx support, SSE handling for multi-fact audit rows.
- Worker: AnnotationsImport worker request, AnnotationImporter + AnnotationBatchApplier to apply creates inside a DocumentCheckpoint for safe rollback.
- Server: multipart import endpoint with Idempotency-Key handling, limits enforced while streaming, repo lookup for idempotent commits, audit row kind 'annot.import'.
- Added tests and conformance suites, helper utilities (uniqueViolation, drawing hashes), and documentation for import behavior.
Measurement formatting now preserves the precision Acrobat expects, including trailing zeros for /RD and /FD, and keeps labels consistent across multi-unit cascades. This also adds annotation transfer conformance coverage, keeps form-field widgets with their field, drops unsupported action payloads and attachment metadata, and fixes file attachment date and MIME handling.
Add support for linking creates to existing annotations and strengthen weak refs during batch annotation imports/creates. Key changes: introduce BatchLinkTarget and resolveAnnotIndexRaw; extend DocumentCheckpoint with object() to checkpoint arbitrary objects; refactor AnnotationBatchApplier to claim names, resolve/link reply/popup targets (including existing annotations), track strengthened stable IDs, update meta generation and weak-annotation bookkeeping, and add helpers (withOpenExisting). AnnotationMutator.create now delegates to AnnotationBatchApplier. Add E8 transfer benchmark tests and shared helpers, a worker test helper and counting fault harness updates. Update runtime bindings and local runtime artifact refs.
One shared engine/ corpus for embedpdf.com and cloudpdf.com: quick start,
setup (per site), authentication (cloud), documents, text (extraction,
search, selection & geometry), annotations (one page per family, import &
export, appearances), forms & signatures, and good-to-know pages
(permissions, events, errors, identity). The pages describe the target API
in docs/plans/2026-09-25-engine-api-v3.md; don't deploy them before its
rounds land.

- sync.mjs mounts all of engine/ and swaps @embedpdf/engine imports per
  flavor; the site-owned getting-started pages moved into content
- docs/content/redirects.mjs: every old core-concepts and getting-started
  URL (and its .md) redirects, imported by both next.configs
- website: <CloudPdfCallout> (mdx registry + markdown projection), used
  inside <Engine local> where the cloud engine does more
- inbound links updated in headless, server and api-reference pages, the
  cloud product switcher, guide section and landing, the website overview
- cloudpdf.com's stale headless pages and samples resynced
…fields

- PermissionDenied, InvalidScope, MissingIdentity and AbortError are
  EngineErrors (Forbidden with details.required, InvalidArg, Aborted);
  the server's guards throw PermissionDenied, so cloud 403s name the
  missing capability
- a ref that finds nothing is NotFound; InvalidReference is a stale index
- @embedpdf/engine-core/public: the export list both engine packages share
- objectUrl() and render.policy() are AbortablePromises; abortWith(signal)
  ties a call to an AbortController
- remove createCloudEngine, CloudEngineOptions.sessionId,
  PageHandle.pageIndex, capabilities.pageEditSessions; no optional members
  both engines implement (forms.submit stays feature-detected)
- caps builders cover every capability; stale comments and dead code
- fix @embedpdf/engine's test typecheck; API snapshots regenerated
- localEngine() works in Node: with no Worker global it runs PDFium in
  this thread (native, or wasm via the new `runtime` option) and renders
  PNG/BMP with the new PortableImageEncoder (CompressionStream deflate).
  WebP there is NotImplemented, naming `imageEncoder`. It also forwards
  signedDocumentPolicy and sessionKind.
- The PDF password lives only in OpenOptions.password; `id` is optional
  on bytes, layerBytes and layerFile.
- Every input kind opens a locked file locked: the worker parks the
  kind's own load and unlock() runs it with the password.
- A wrong open() password opens locked with passwordPrompt.incorrect
  true (both engines); the kernel's locked event carries it.
- An unlock refreshes the local guard's PDF permission bits, so the
  owner password lifts the file's restrictions under pdf.permissions.
- A local scope without doc.open is refused before the file is read.
- layerFile on wasm is NotImplemented; inserting an encrypted PDF is
  DocPasswordRequired (shared services/runtime/loadError.ts).
- Cloud engine.destroy() closes the documents it opened.
- Tests: local-engine-node, opening (encrypted.pdf fixture from PDFium),
  cloud engine-destroy; scopes in two suites gain doc.open.
- Docs: opening, setup (Node), rendering, permissions, errors;
  API snapshots regenerated.
- doc.annotations.list({ pages? }) replaces listRaw/listRawAll. One
  AnnotationList { annotations, pages, auditHead? } on every layer: both
  engines, the worker's one annotations.list job, and both REST list
  endpoints. The page list is a raw read. Snapshot types removed.
- get/list names: metadata.get, text.get, actions.get,
  pieceInfo.get/list/delete, render.getPolicy, measure.listViewports,
  signatures.getContents/getDigest/downloadRevision/cancel.
- Write results: { annotation } for create/update, { annotations } for
  move and import, { meta } for delete on every layer; the
  annotation.deleted event names what went (deletedAnnotationOf).
- REST contract declares the real annotation results, and names
  Annotation, AnnotationList, AnnotationMutationMeta and PageState once
  (AnnotationWireComponents); openapi.json, SDKs and API reference
  regenerated.
- downloadResource, beginEdit, session close().
- renderAppearances() is the encoded render on both engines,
  renderAppearancesRaw() the pixels; both take a viewport. Width
  viewports are off an enforced appearance lattice.
- Annotation flatten items are 'applied' | 'unchanged'.
- Kebab-case intents (line-dimension, …) and measure subtypes
  (rectilinear, geospatial); the file keeps PDF names.
- Plugins, tests, conformance, server, runtime demo, docs and API
  snapshots follow.
- One range shape: TextRange { start, count } and PageTextRange.
- page.text: get(), slice(range) and layout(). layout() is a TextLayout
  with charCount, runs, charAt(point), wordAt/lineAt(point | index),
  segments(range) and charQuad(index); the free layout functions are
  internal and page.geometry is gone.
- Raw geometry: runs have start, characters loose/tight and
  space/empty (only when true) instead of looseBox/tightBox/
  looseQuad/tightQuad and flag bits; a turned run has baselineAngle.
- Search: a flat request (query + snippets, from, cursor, limit),
  matches as page ranges with snippet { before, match, after }, results
  with pagesSearched/pageCount; snippets default off; skip is internal
  (SearchScanRequest). charRangeForTextOffsets takes and returns a
  range; sliceText(snapshot, range).
- Cloud search token keys follow the request; result format ranges1.
- Search plugin hits are ranges (selection.select(hit)), snippets
  replaces mode, from replaces startPage; the selection plugin reads
  text.layout(); chrome, React layer, example app, docs, tests and API
  snapshots follow.
- doc.forms verbs: export, import, create, update, delete, addWidget,
  removeWidget; toFieldRef(name) in both engine packages
- Every form write's meta is a FormMutationMeta (affectedPages,
  cacheDelta, changedFields, changedWidgets), filled by one worker
  helper; delete returns { meta } and its event names `deleted`;
  import returns { form, applied, skipped, meta }; applyEffects meta is
  never null (the worker's internal `wrote` gates persist and events)
- needsAppearancesCleared
- Signatures: prepare takes `signer` facts; cancel returns 'cancelled'
  and emits signature.cancelled; worker job and wire path are cancel
- core-signature: sign({ key }), refusals are EngineError
  SignatureRefused with details.reason (SigningError removed)
- Signature plugin and chrome: `key` for the key holder, `signer` for
  the facts, cancelPending
- REST: doc.forms.list, doc.signatures.cancel, typed form results;
  importData/exportData keep their names (SDK keyword guard);
  openapi, SDKs and API reference regenerated
- Conformance: value-write meta, effects meta union law, no-op batch
  names nothing; docs synced, API snapshots updated
- doc.events.on(type, listener), typed by the event (DocumentEventOf<T>),
  beside subscribe(); both engines build it on their subscribe through
  the shared subscribeToType helper (a type listener opens the cloud's
  live stream like any subscriber); @embedpdf/core/testing has it too
- Event names follow the docs: annotations.*, attachments.*,
  forms.{valueSet,created,updated,deleted,widgetAdded,widgetRemoved,
  imported,repaired,effectsApplied}, signatures.{prepared,completed,
  cancelled}, pages.scaleSet; engines, cloud audit mapping, conformance,
  plugins and convention docs follow
- pages.moved / pages.inserted carry toIndex
- Conformance: on(type) hears only its type; its unsubscriber stops it
- Server audit kinds unchanged (they are not event names)
- Every write is { <noun>, meta }, meta never null: page, metadata and
  attachment cache pins fold into MutationMeta.cacheDelta
  (layoutVersion/metadataVersion/attachmentsVersion); the per-domain
  cache types go; the cloud absorbs through one apply(meta)
- Flatten and redaction return an empty meta when nothing applied (the
  worker's wrote flag gates artifact, event and server persist)
- setScale returns PageScaleResult; pieceInfo.update/delete return
  results; the signatures.prepared event carries its result
- Attachments: AttachmentRef/Attachment with ref, toAttachmentRef,
  list() -> { attachments }, { attachment, meta } / { meta } with
  meta.changed naming them, ArrayBuffer data
- redaction.apply({ pages } | { annotations }); listViewports() ->
  { viewports }; toIndex everywhere; flatten(refs, { usage }) and
  DownloadOptions
- security.state / security.scope; allowsAnnotation(action, target)
- REST contract declares the real page, redaction and viewport results;
  openapi, SDKs and API reference regenerated; conformance, plugins,
  chrome and tests follow
- Deleting a merged field/widget deletes the field and its widgets in one
  change; removeWidget and the annotation delete refuse it and point to
  doc.forms.delete
- Fields a signature locked: applyEffects rejects them and FDF/XFDF
  import skips them (the runtime import takes a skip list)
- forms.create is one change: the draft is checked first, then applied
  in a document checkpoint, so a rejected draft creates nothing
- forms.update needs no family; members of another family are InvalidArg
- complete() after cancel() is NotFound on the cloud; the cloud analyze()
  judges the saved version unless until: 'working-copy'
- A signed field's appearance change is ProtectedDocument; the worker
  refuses a rewrite save of a signed document on both engines; the
  server answers ProtectedDocument with 409
- The server audits signature prepare, cancel and complete, and other
  cloud sessions receive the signatures.* events
- core-signature: sign({ kind: 'timestamp' }) makes RFC 3161 document
  timestamps through a timestamp authority; unknown subFilters are
  InvalidArg; createTestTimestampAuthority for tests
- Docs, conformance, bindings and API snapshots follow
- One value check on both engines: the kind schemas run in the worker
  (assertAnnotationDraft/checkAnnotationPatch), InvalidArg names the
  field; server schema errors name it too
- Field trait readBack(): a read sent back keeps a read-only value
  (link target, an orphan popup's parent), a changed one is refused
- Opacity reads as written (float /CA, shortest decimal); quadPoints
  replace; a redaction's rect comes from its quads and its font reads a
  registered key; a standard state brings its model; a popup needs a
  parent; a stamp takes a one-page PDF
- Deleting an annotation deletes its thread and popups in one change,
  checked member by member on both engines (all or nothing, details.refs);
  the deleted event names them all; a popup deleted alone clears /Popup
- Reads never leave a field out (attachments, viewports, namedPages,
  a line's caption flag); popups leave comment threads
- REST read shapes, SDK, docs, conformance, bindings and API snapshots
  follow
- Measure helpers throw EngineError(InvalidArg); measureFromRatio takes
  { userUnit } in its options; precision defaults to 100; the measure
  types are public
- Imports: a copied file's dates travel in both modes; a stamping import
  needs only create authority; the cloud checks a bundle against the
  limits /v1/access advertises, and HTTP 413 is PayloadTooLarge
- Cloud export takes any selection: a POST when a URL can't carry it
  (position refs, long ref lists)
- Metadata: '' is a value; a standard or malformed custom key is
  InvalidArg naming it, before anything is written
- Fonts: a standard font's name can't be a key
- Files: the engine never guesses a type (no octet-stream fallback, a
  download's mimeType is null, X-EmbedPDF-File-Type header); a File
  brings its name and type to a file-attachment annotation
- Docs, conformance, bindings and API snapshots follow
The thumbnail rail is now configured to ignore document interactions so it behaves as a secondary navigation lens instead of exposing the document's tools. This keeps the fixed-magnification thumbnail strip focused on click-to-navigate behavior while preserving scroll-based navigation without zoom gestures.
A create tool answers one question: if the gesture ended now, what would
exist? The commit, the drawing in progress and the tool's ghost now make
the same calls. gesturePlacement places the dragged box or segment, or
the tool's click default, and placedShape lays the kind's shape there
through the new ShapeFamily.placed. unmadeItem paints an annotation not
made yet the way the made one will paint. The callout's text box and the
form palette go through the same calls. clickCreateGeom and the free-text
click fallback are gone.

A line click is centred on the pointer. A line's ClickCreate takes
`anchor` ('center', 'start' or 'end', which puts an arrow's tip on the
click) and `rotation`, which replaces angleDeg. Under `upright` a click is
laid out as the person sees the page: a line points as seen, and a
centred box keeps its width × height and slides inside the page by the
area it covers there. A square or circle takes the quarter turn as
swapped sides, so it stores no rotation; only a stamp keeps the turn. The
built-in square, circle and line tools are upright.

A tool's `ghost` is true, false or { opacity } and works for every tool
whose click places something. The plugin keeps only the pointer and
derives the ghost from it and the tool's live defaults. The handler that
takes the click shows the ghost on hover, and only where no hover claim
sits above it (checked with the hub's new hasCursorClaim) and the user
may create. The ghost stays while a press is still a click and clears
when the gesture ends. Ghosts paint see-through with --epdf-ghost-opacity
winning. The ghost handler and its priority are removed; getToolGhost is
now getImageGhost, and setPlacementPreview is now previewPlacement.

Form field tools show their ghost. A radio button is its own kind,
widget-radio, drawn round as the engine draws it, in its ghost and while
it is dragged. A cancelled placement places nothing. The viewer turns
ghosts on for square, circle, line and arrow.
A callout is its own kind, free-text-callout: kindOf returns it for a
free text with the callout intent. It keeps the turn it was made with,
the one that stands it upright on a turned page. So it shows no rotate
knob, rotate 90° and reset rotation leave it as it is, and a selection
that includes a callout can't turn or resize as a group. Reset rotation
now skips every kind that doesn't turn, so a caret keeps the turn of its
text. The callout tool edits its own kind's fields.

A callout's selection frame is everything it paints: its box, its line
and the ending at its tip, the same box as its rect. A selected callout
is grabbed on its line or anywhere in that frame. A multi-selection box
takes in its line, the menu sits below the whole callout, and a move
keeps the whole callout on the page; page-bound's callout special case
is gone. Any selected annotation is grabbed in its frame or anywhere a
click would hit it, so selecting never shrinks where it can be grabbed.

Two things stay on the text box. An attached link covers only the box.
A double-click opens the text only on the box or a resize handle on its
border (isOnTextBox in the core, textBoxAt in the plugin); on the line or
the empty rest of the frame it is a normal press. The caret and the text
box share isInTurnedBox.
Each shape family says what it paints as a few simple pieces: ink along
a path, a filled polygon, or an ellipse (PaintedPiece, in painted.ts).
Two questions are asked of the pieces: paintedNear, for a click, and
paintedTouches, for a rectangle. A rectangle of no size touches exactly
what a click with no margin hits there, so a click and the marquee can't
disagree.

ShapeFamily.painted replaces ShapeFamily.hit. endingPieces and
distancePainted/captionPainted describe endings and measurements, and
paintedOf(record, view) puts a record's pieces together: its shape as a
screen-anchored body shows at the view, and a measurement's caption and
lines. hitTest and geomHit read them, and geomPainted keeps the pieces
per shape object, since a hover asks on every pointer move. The plain
geometry the questions need lives in rect.ts: point, segment and polygon
against a rect.

Clicks hit what they hit before, with two exceptions. A cloudy border is
hit on its own curves, flattened from the same Bézier arcs the path
draws, so its bumps' tips hit and the gaps between them don't. The
margin around a text box, a caret or a measurement's caption reaches the
same distance every way, so its corners are rounded. Before the old
rules were deleted, a check over a grid of points for every family,
measurements and a screen-anchored note found no other difference.

The family hit functions, endingNodesHit, distanceHit and
cloudyBorderExtent are gone.
The marquee asks the painted pieces whether the box touches them
(paintedTouches over paintedOf), instead of testing the selection frame.
It catches an annotation's ink, or the inside of a filled shape, as a
click hits it; a screen-anchored body counts where it shows at the view.
A box inside an unfilled shape, or in the empty corners of a diagonal
line's bounds, no longer selects it; one that touches only a callout's
line, an arrowhead, a measurement's caption or a cloud's bumps does. A
marquee of no size catches exactly what a click with no margin hits.

selectionInBox is what a box selects: every annotation it touches and
the rest of each one's group. The marquee gesture and the capability's
selectInRect both use it, so selectInRect now selects exactly what
dragging the same box does, leaving out annotations an engaged tool
makes inert. It used to select whatever overlapped an annotation's
bounds, without its group. annotsInBox takes a rect, and
quadIntersectsRect, which only the marquee used, is gone.

The selecting docs say what a box has to touch, synced to both sites.
A measurement's offset, leader and caption were built out from a pointer
kept on the page, but nothing held the result there: on a slanted line
the dimension line, its arrows and its caption could leave the page. A
gesture that reshapes an annotation now stops where its frame would reach
further past the page's edge than when the gesture began (fractionOnPage,
and deltaOnPage for a two-way drag that slides along an edge). It holds
for drawing a distance's offset, dragging its leader, dragging any
measurement's caption, and every handle drag: a line's end stops before
its arrowhead leaves the page, and a turned box stops when a corner
reaches the edge. An annotation already past the edge is never pushed
further out.
documents.save() and saveLayer() settle the document before they read
the file (settle.ts): the kernel runs every flush plugins registered
with ctx.onSettle() and waits for it, so the bytes have everything the
user sees. A download used to read the file at once, so an autosave or
a save shortcut right after typing could miss the last words, and it
could overtake writes still on their way. The caller's signal, or the
document closing, cancels the wait with operation-cancelled. A flush
that fails is reported, and the file is written from what the engine
has.

The annotation plugin writes the text typed in the last 250 ms
(flushAllText) and waits for its running writes (store.whenWritten).
The draw handler finishes an ink drawing waiting for its next stroke,
never a stroke still being drawn. The text being typed in a form field
lives in the form plugin (draftText, commitDraftText, discardDraftText
on the host lens) instead of the React components, and is committed
first, as Acrobat commits the field being edited before a save.

ctx.serialQueue() returns a queue with idle(). Forms, page edits,
redaction apply and measurement scales register theirs with
ctx.onSettle(() => queue.idle()). A queue that carries verbs never
does: the actions plugin's queued runDocumentVerb('save') calls
documents.save() from inside its queue, and waiting for that queue
would wait for itself. The rules are in
docs/conventions/state-and-sync.md, and the test context has settle().

i18n: dotted keys ('review.reject') become branches wherever strings
enter the state (nestKeys), so they match; they used to be stored as
they were. addTranslations() takes strings for a language in loaders
that hasn't loaded yet: they wait in waitingTranslations and go in on
top of the pack when it registers.
A plugin's settings are declared in its definition
(definePlugin({ settings: { defaults, registered } })). The kernel
builds one store per registration when it plans the plugin list, so
the settings exist before any document opens and one change reaches
every document (kernel.settingsOf(token), ctx.settings()). Every
store has getSettings, updateSettings, resetSettings and
onSettingsChanged ({ settings, changed }). Nested objects merge,
arrays and values replace, and reset goes back to what the app
registered. definePlugin reads its types from token, state and
settings, so a plugin passes no type arguments; create and inScope
are checked against them, never a source of them. A plugin without
settings has NoSettings, a type of its own rather than never, so one
plugin list holds plugins with and without settings. ctx.settings()
throws there.

A plugin declares its State table once, framework-free:
defineState(token, { read, empty }). React turns any declaration
into its hook (stateHook), which re-renders only when a field
changes and returns `empty` when no document is open. settingsHook
does the same for settings. Outside a document, use<Plugin>()
returns a stand-in: rendering works, the settings calls reach the
store, and every other call throws not-ready.

A workspace plugin can say what it looks like inside a document's
scope (PluginDef.inScope). The documents capability and commands use
it, so documents.save() and commands.execute() inside a
<DocumentScope> act on that document, and useDocuments() follows the
scope.

A refused call names what was missing: PluginError.permission, set
from the engine's refusal or by ctx.assertAllowed(permission,
operation). Every can<Verb> reads ctx.allows(permission). Form,
render, selection, metadata, measurement, redaction and annotations
no longer build refusals by hand. Events carry the engine's own
origin (EventOrigin: kind, sessionId, sub, ts, serverId, tx);
ChangeOrigin and originOf are gone, and check-architecture flags an
origin built by hand. ctx.pageOf(page) takes a PageRef or an index
and throws not-found for a verb; ctx.getPage(page) returns null for
a read. ctx.cancellable(signal, task) aborts an engine call when the
caller cancels, and a queued operation whose signal fired before it
started is skipped.

@embedpdf/web lists every --epdf-* variable (EPDF_VARIABLES) and
builds the lookup the theming page promises (paint): the part's own
variable, then the part it follows, then its plugin's accent and
--epdf-accent, then the setting.

Search is the first plugin on the contract. Its settings are reveal
and highlight { color, activeColor, blendMode }, and <SearchLayer>
paints from them and the CSS variables instead of color props. Its
state is a declaration (activeHitIndex, activeHit), with
useSearchHits(page?) and useSearchSettings(). onProgressChanged
comes from state, so it also fires when a new search starts over,
and the events no longer carry operationId. Every verb takes a
signal, page-taking reads return empty for a page that is gone,
listPagesWithHits() is in document order, and a drag that starts on
a match selects text instead of calling onHitClick.

The docs check themselves. snippets.mjs compiles every page's
snippets per framework, and samples.mjs compiles the live examples
straight from docs/content, failing on an error; both share
compile.mjs and run in a directory of their own. publish-gate.mjs
writes which pages are live: a page is live for a framework when its
snippets compile and its reference tables name nothing pending.
Otherwise the sites show its title and a notice, and keep it out of
llms.txt, search and search engines. With DOCS_PREVIEW=1 (and in
next dev) every page shows, and a held-back one carries a banner
under its title. The reference check also compares State tables
with defineState, Settings tables with the settings defaults, and
the From CSS columns with EPDF_VARIABLES.

Live examples are plain elements with class names and one ordinary
CSS file each, shown as a tab next to the code; on the page, a
build step scopes each example's stylesheet to it. Search has four
examples (a search box, options, a results list, highlight colors).
The Stage examples and every other example that used the demo kit
are converted, and the kit is gone.
… page

The headless docs work: each plugin went through the shared
contract with its React binding, until the code does what its docs page
says. Names now match the pages, and nothing keeps an old name (pre-3.0,
no compat aliases). Details per package are in the changesets.

The contract, plugin by plugin:
- Settings live in definePlugin({ settings }): getSettings, updateSettings,
  resetSettings and onSettingsChanged on every plugin that has them (render,
  selection, actions, commands, i18n, measurement, redaction, signature, ...).
- State is declared once (stageState, shellState, metadataState, ...) and
  each framework turns it into its hooks.
- Permissions are can* reads, events carry their origin, pages are PageRefs.

Notable changes:
- Documents: open() and retry() resolve { document }; a download runs the
  document's save actions itself; a split pane opens beside the focused one.
- Viewing: the current page is a PageRef; links open websites in the user's
  gesture; navigation called before the stage has a size waits for the first
  placement instead of being lost (so a PDF's open action to a page works).
- Text: goToHit() takes a hit; selection colors follow the viewer accent.
- Annotations: the capability has nouns (selection, draft, text, tools,
  stamps); the record type is Annotation (was AnnotationDTO); create() copies
  a read of another annotation; a kind's style schema is its properties.
- Stamps, measurements, redaction, signatures: a free placement uses
  `center` (was `at`); their settings are live.
- Forms: list/get/validate and the other names the forms pages use.
- Your app's UI: commands and i18n are settings; useCommandShortcuts takes a
  target; registerTool() for your own tools; epdfThemeVariables() and
  fitAnchoredRect() in @embedpdf/web.

Docs:
- 150 React live examples: plain elements and one ordinary CSS file each,
  shown as a tab next to the code. Every example shows its feature working
  on load.
- A samples check compiles them; the sync mounts every sample area.
- The reference check has 0 pending names except print. 38 of 39 pages are
  live for React; Saving waits on printing.

Verified: about 3,800 tests, 0 type errors, API snapshots, docs:check on both
sites, the architecture and comments checks, and a browser pass over all 39
pages.
Custom annotation looks and UI about an annotation each get one clear
place: a look is part of the page and draws into a frame the layer
places; UI about an annotation (a badge, a status, a menu) floats over
the pages with <Anchored>. Details per package are in the changesets.

Anchored UI:
- `pinned` keeps it where it's put: no flip, no move to stay in view,
  and it scrolls away with its box. Placements line up with a side's
  start or end ('top-end', ...), and a negative gap overlaps the box.
- Anchored UI on a page that isn't on screen renders nothing and sits
  out camera frames; out of view, it isn't drawn. Stage and PageView
  provide the pages on screen next to the per-frame projection.
- A note's AnnotationAnchor carries boundsIn(view), so
  useAnnotationAnchor() re-renders only when the annotation moves.

One frame for every annotation:
- Every render item carries `frame` (box, turn, scale) and `raster` (the
  engine's picture inside it), resolved in the annotation core and
  handed out through the capability. Adapters convert with @embedpdf/web
  (frameInPixels, rasterInFrame) and decide no geometry
  (docs/conventions/architecture.md).
- The layer draws its own look and yours in the same frame: custom looks
  land on the native drawing on turned pages, notes stay upright like
  their icons, and blending happens on the frame.
- A look is drawn at the annotation's 100% size and scaled with the
  page, text and borders included; `scale: false` opts out.
- Renderers get frame { width, height, rotation, scale } and `selected`;
  `box` and `page` are gone; useRichTextEditor(annotation).
- A plain text box's scene is drawn upright; a callout's is unchanged.

Docs:
- Custom rendering: "Its look, or UI on it"; the frame and its scale;
  bubbles filling a bigger note, with Turn the view; a pinned stamp
  status (annotation/stamp-status replaces annotation/interactive).
- Menus over the page: pinned, start/end placements, a negative gap, and
  a Pinned switch in the placement example.
- Coordinates: the pin snippet draws in the page function with toPixels.
- Both sites are synced, including the cloud site's copies of the
  previous commit's example fixes.
Align redaction label preview and engine apply behavior by measuring text in Helvetica, fitting auto-sized labels to the region, and preserving the font in /DA for size-0 labels. Repeated labels now tile at 12pt, default no-font redactions use Helvetica, and docs/tests were updated to cover the fixed layout and burn-in behavior.
A noZoom annotation's projection reused the resize gesture's scale, which
keeps a box at least 4pt. Past about 6x zoom a note stopped shrinking on the
page and grew with it, while its frame still drew a custom look at 1x: a
large bubble with small text and a thin border. A gesture's commit at that
zoom also stored the annotation larger than it was. scaleAbout and
geomScaleAbout now scale exactly; group resize passes its 4pt limit as
minSize.

The hit margin was in page points, so a click reached about 200px around an
annotation at 25x zoom and about 2px at 25%. hitMargin is now a screen-pixel
setting, converted by the page's view scale on pointer-down, hover and the
cursor, like snap.alignmentThreshold.

Also regenerates the engine-core conformance API snapshot for the redaction
label checks added earlier.
…all four frameworks

- @embedpdf/vue (SFCs + composables), @embedpdf/svelte (Svelte 5 runes) and
  @embedpdf/angular (provideEmbedPdf + withX(), one signal service per plugin,
  ng-packagr entry points, zoneless), each covering every headless page.
- Every React example ported: 150 per framework, 600 live demos; every page's
  snippets compile per framework; both sites' check:samples compile all four.
- Shared code consolidated into @embedpdf/web, core, core-ui, core-geometry and
  the plugins; publish guards for Angular declarations and the Vue/Svelte tarballs.
- Docs: code panels moved out of the compiled MDX into per-framework JSON read at
  render (the dev server ran out of memory with four frameworks per page); the
  leak test now catches React code on non-React pages; Angular demos build in
  production mode.
- Tile plane lifetimes in every adapter: the view handle follows the renderer and
  the view, the page's claim follows the page, the demand follows the camera.
  Vue and Svelte released the page on every zoom or scroll (so tiles stopped
  updating), Svelte also re-acquired the handle and re-bound unchanged tiles, and
  Angular kept the old page when a page view switched pages. Four lifecycle tests
  per adapter.
- Fixes found along the way: plugin-stage ignores a same-size viewport report
  (it cancelled an on-open reveal/goToPage), placement latch for navigation
  before placement, annotation selection.set([]) clears, React/Vue/Svelte
  PageView follows DocumentScope and is the page's pointer surface, React link
  and form presses isolated natively; Angular render layer sets the picture
  URL directly and tiles report "unpainted" to their view.
- API snapshots cover Angular and list Vue/Svelte component props.
The stage now owns its frame loops: pending animation frames are cancelled when the instance is disposed, and no new ones are scheduled afterward. This prevents late callbacks from writing into a closed stage while page teardown happens mid-countdown.

Viewport reports with no area (hidden containers or elements removed from the page before teardown) now keep the last real viewport and view instead of fitting to a 0 × 0 box. When shown again, the stage resumes from that state without firing duplicate viewport, zoom, or camera events.

This branch had an error being deployed

6 failed deployments
Preview – embed-pdf-website — 1d349227 Deployed Oct 2, 2026 by vercel[bot]
Preview – cloudpdf-com — 1d349227 Deployed Oct 2, 2026 by vercel[bot]
Preview – embed-pdf-snippet — 1d349227 Deployed Oct 2, 2026 by vercel[bot]
Preview – embed-pdf-viewer-svelte-tailwind — 1d349227 Deployed Oct 2, 2026 by vercel[bot]
Preview – embed-pdf-viewer-react-mui — 1d349227 Deployed Oct 2, 2026 by vercel[bot]
Preview – embed-pdf-viewer-vue-vuetify — 1d349227 Deployed Oct 2, 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