From 2679be7f57291348652158754ecb3f26e69ce0aa Mon Sep 17 00:00:00 2001 From: Marina Date: Sat, 12 Sep 2026 17:25:15 +0200 Subject: [PATCH 1/2] fix: stamp annotations now support /CA opacity Adds an opacity field to StampAnnotationDTO/StampDraft/StampPatch (and their wire schemas), reads/writes /CA in the engine services layer, declares opacity as an editable stamp prop in core-annotation's kind table, derives it into the plugin model's style via styleFromDTO, allows a stamp tool's configured default opacity (TOOL_DEFAULT_KEYS), and threads that default into every stamp placement path (armed, click-to-place, and the programmatic placeStamp API). Fixes #795 --- .changeset/stamp-annotation-opacity.md | 12 ++++ .../annotation/src/annotation-core.test.ts | 17 ++++- packages/core/annotation/src/kinds.ts | 8 ++- .../core/src/annotation/kinds/stamp/draft.ts | 3 + .../core/src/annotation/kinds/stamp/dto.ts | 2 + .../core/src/annotation/kinds/stamp/patch.ts | 3 + .../core/src/annotation/kinds/stamp/schema.ts | 3 + .../core/test/annotation/normalize.test.ts | 8 +++ .../main/test/annotations-stamp.test.ts | 67 +++++++++++++++++++ .../internal/read/readStampAnnotation.ts | 9 ++- .../internal/write/writeStampAnnotation.ts | 7 +- packages/plugin/annotation/src/capability.ts | 17 +++-- .../plugin/annotation/src/repository/seam.ts | 13 ++++ packages/plugin/annotation/src/tools.ts | 2 +- 14 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 .changeset/stamp-annotation-opacity.md diff --git a/.changeset/stamp-annotation-opacity.md b/.changeset/stamp-annotation-opacity.md new file mode 100644 index 000000000..65234980e --- /dev/null +++ b/.changeset/stamp-annotation-opacity.md @@ -0,0 +1,12 @@ +--- +'@embedpdf/engine-core': minor +'@embedpdf/engine-services': minor +'@embedpdf/core-annotation': minor +'@embedpdf/plugin-annotation': minor +--- + +Stamp annotations now support `/CA` opacity like every other annotation kind: +`StampAnnotationDTO` declares an `opacity` field (read from and written back to +the PDF), stamp drafts/patches accept `opacity`, the stamp kind now declares +`opacity` as an editable prop, and a stamp tool's configured default opacity is +applied when a stamp is placed (armed, click-to-place, or programmatic). diff --git a/packages/core/annotation/src/annotation-core.test.ts b/packages/core/annotation/src/annotation-core.test.ts index e03b3fcac..4b2c7ceb6 100644 --- a/packages/core/annotation/src/annotation-core.test.ts +++ b/packages/core/annotation/src/annotation-core.test.ts @@ -22,7 +22,8 @@ import { import { cursorAt, groupUnionBounds, hitTest, paintOrder } from './hit'; import { isAttachedLink, isConversationOnly, isSubstrateOnly } from './plane'; import { linkChildrenOf, linkOf } from './links'; -import { capsFor } from './kinds'; +import { capsFor, propsFor } from './kinds'; +import { applyProps, readProp } from './props'; import { annotDeletable, annotTransformable, DRAWN_FLAGS } from './flags'; import { chordThrough, @@ -2487,6 +2488,20 @@ describe('annotation-core opaqueBody (stamp) gestures', () => { }); const loadStamp = (): Model => update(initialModel, { t: 'loaded', annots: [stamp()] })[0]; + it('declares opacity as an editable prop, unlike color/stroke/border', () => { + const keys = propsFor('stamp').map((s) => s.key); + expect(keys).toContain('opacity'); + expect(keys).not.toContain('color'); + expect(keys).not.toContain('strokeWidth'); + }); + + it('reads and applies opacity like every other kind', () => { + const a = stamp(); + expect(readProp(a, 'opacity')).toBe(1); + const patched = applyProps(a, { opacity: 0.5 }); + expect(patched?.style.opacity).toBe(0.5); + }); + it('stays baked MID-resize with the raster box following the live geometry', () => { // select (body click — opaqueBody hits anywhere inside), then grab the SE // handle at (200,150) and drag WITHOUT releasing. diff --git a/packages/core/annotation/src/kinds.ts b/packages/core/annotation/src/kinds.ts index 31c638997..193b5d591 100644 --- a/packages/core/annotation/src/kinds.ts +++ b/packages/core/annotation/src/kinds.ts @@ -522,8 +522,10 @@ export const KINDS: Record = { }, // Stamp: a rect-variant kind whose visual is ALWAYS the engine-baked /AP // (image or vector appearance authored at create time) — never a vector - // re-render, so it declares no editable style props. Geometry edits - // (move/resize/rotate) re-fit the appearance natively on the engine side. + // re-render, so it declares no editable style props beyond `/CA` opacity, + // which the engine bakes into the appearance raster on write. Geometry + // edits (move/resize/rotate) re-fit the appearance natively on the engine + // side. stamp: { subtype: 'stamp', variant: 'rect', @@ -538,7 +540,7 @@ export const KINDS: Record = { commentable: true, opaqueBody: true, }), - props: [LINKABLE], + props: [LINKABLE, OPACITY], }, // Link: an invisible hit rectangle that navigates somewhere. Paints nothing // of its own (scene() skips it; any /AP a PDF baked shows via the page diff --git a/packages/engine/core/src/annotation/kinds/stamp/draft.ts b/packages/engine/core/src/annotation/kinds/stamp/draft.ts index 07b855d8a..2b8490826 100644 --- a/packages/engine/core/src/annotation/kinds/stamp/draft.ts +++ b/packages/engine/core/src/annotation/kinds/stamp/draft.ts @@ -23,6 +23,8 @@ export interface StampDraft extends AnnotationDraftBase { rect: PdfRect; /** What the stamp displays: PNG, JPEG, or single-page PDF bytes. */ source: BinarySource; + /** `/CA` constant opacity, 0..1. Default `1`. */ + opacity?: number; /** * `/Name` — the stamp's identifier: a standard name ('Approved', 'Draft', * …) or any custom name such as an Acrobat library identifier @@ -49,6 +51,7 @@ export interface StampWireDraft extends AnnotationDraftBase { rect: PdfRect; source: ResourceRef; name?: string; + opacity?: number; fit?: StampFit; rotation?: number | null; unrotatedRect?: PdfRect | null; diff --git a/packages/engine/core/src/annotation/kinds/stamp/dto.ts b/packages/engine/core/src/annotation/kinds/stamp/dto.ts index 85e171bca..5107559f4 100644 --- a/packages/engine/core/src/annotation/kinds/stamp/dto.ts +++ b/packages/engine/core/src/annotation/kinds/stamp/dto.ts @@ -12,6 +12,8 @@ export type StampAnnotationDTO = AnnotationBase & { subtype: 'stamp'; /** `/Name` — advisory stamp label ('Approved', 'Draft', …). */ name: string | null; + /** `/CA` constant opacity, 0..1. */ + opacity: number; /** Rotation (deg) from `/EMBD_Metadata`. */ rotation?: number; /** Pre-rotation `/Rect` from `/EMBD_Metadata` (present with `rotation`). */ diff --git a/packages/engine/core/src/annotation/kinds/stamp/patch.ts b/packages/engine/core/src/annotation/kinds/stamp/patch.ts index 59585887d..522a45345 100644 --- a/packages/engine/core/src/annotation/kinds/stamp/patch.ts +++ b/packages/engine/core/src/annotation/kinds/stamp/patch.ts @@ -13,6 +13,8 @@ export interface StampPatch extends AnnotationPatchBase { source?: BinarySource; /** `/Name`; `null` removes it (the reader then reports the default). */ name?: string | null; + /** `/CA` constant opacity, 0..1. */ + opacity?: number; fit?: StampFit; rotation?: number | null; unrotatedRect?: PdfRect | null; @@ -24,6 +26,7 @@ export interface StampWirePatch extends AnnotationPatchBase { rect?: PdfRect; source?: ResourceRef; name?: string | null; + opacity?: number; fit?: StampFit; rotation?: number | null; unrotatedRect?: PdfRect | null; diff --git a/packages/engine/core/src/annotation/kinds/stamp/schema.ts b/packages/engine/core/src/annotation/kinds/stamp/schema.ts index 58c3a984d..59b483c22 100644 --- a/packages/engine/core/src/annotation/kinds/stamp/schema.ts +++ b/packages/engine/core/src/annotation/kinds/stamp/schema.ts @@ -26,6 +26,7 @@ const StampFitSchema = z.enum(['contain', 'cover', 'fill']); export const StampDTOSchema: z.ZodType = z.object({ ...AnnotationBaseShape, name: z.string().nullable(), + opacity: z.number().min(0).max(1), rotation: z.number().optional(), unrotatedRect: PdfRectSchema.optional(), subtype: z.literal('stamp'), @@ -36,6 +37,7 @@ export const StampWireDraftSchema: z.ZodType = z.object({ rect: PdfRectSchema, source: ResourceRefSchema, name: z.string().min(1).optional(), + opacity: z.number().min(0).max(1).optional(), fit: StampFitSchema.optional(), rotation: z.number().nullable().optional(), unrotatedRect: PdfRectSchema.nullable().optional(), @@ -47,6 +49,7 @@ export const StampWirePatchSchema: z.ZodType = z.object({ rect: PdfRectSchema.optional(), source: ResourceRefSchema.optional(), name: z.string().min(1).nullable().optional(), + opacity: z.number().min(0).max(1).optional(), fit: StampFitSchema.optional(), rotation: z.number().nullable().optional(), unrotatedRect: PdfRectSchema.nullable().optional(), diff --git a/packages/engine/core/test/annotation/normalize.test.ts b/packages/engine/core/test/annotation/normalize.test.ts index 13fd64633..54a55efd8 100644 --- a/packages/engine/core/test/annotation/normalize.test.ts +++ b/packages/engine/core/test/annotation/normalize.test.ts @@ -117,6 +117,14 @@ describe('normalizeAnnotationDraft', () => { ); }); + test('stamp: opacity passes through untouched and validates against the wire schema', async () => { + const png = makePng(2, 2); + const draft: StampDraft = { subtype: 'stamp', rect: RECT, source: png, opacity: 0.6 }; + const { wire } = await normalizeAnnotationDraft(draft); + expect((wire as StampWireDraft).opacity).toBe(0.6); + expect(() => AnnotationDraftSchema.parse(wire)).not.toThrow(); + }); + test('non-binary kinds pass through untouched with an empty resource map', async () => { const draft: InkDraft = { subtype: 'ink', diff --git a/packages/engine/main/test/annotations-stamp.test.ts b/packages/engine/main/test/annotations-stamp.test.ts index 7b43263a1..156a97e4a 100644 --- a/packages/engine/main/test/annotations-stamp.test.ts +++ b/packages/engine/main/test/annotations-stamp.test.ts @@ -332,6 +332,73 @@ describe('stamp annotations: engine-local (inline transport, wasm runtime)', () expect(re.rect.right).toBeCloseTo(flat.right, 0); }); + test('opacity: defaults to 1, round-trips through create/read, and persists across save', async () => { + const page = handle.page(PAGE_OBJECT_NUMBER); + const png = makePng(4, 4, [255, 0, 0, 255]); + + const noOpacity = await page.annotations.create({ + subtype: 'stamp', + rect: { left: 400, bottom: 400, right: 440, top: 440 }, + source: png, + }); + expect((noOpacity.created as StampAnnotationDTO).opacity).toBe(1); + + const { created } = await page.annotations.create({ + subtype: 'stamp', + rect: { left: 450, bottom: 400, right: 490, top: 440 }, + source: png, + opacity: 0.4, + }); + expect((created as StampAnnotationDTO).opacity).toBeCloseTo(0.4, 2); + + const list = await page.annotations.list(); + const re = list.annotations.find( + (a) => + a.ref.kind === 'objectNumber' && + created.ref.kind === 'objectNumber' && + a.ref.annotObjectNumber === created.ref.annotObjectNumber, + ) as StampAnnotationDTO; + expect(re.opacity).toBeCloseTo(0.4, 2); + + const saved = await handle.download(); + const reopened = await engine.open({ kind: 'bytes', id: 'stamp-opacity-reopen', bytes: saved }); + try { + const reopenedList = await reopened.page(PAGE_OBJECT_NUMBER).annotations.list(); + const stamps = reopenedList.annotations.filter( + (a): a is StampAnnotationDTO => a.subtype === 'stamp', + ); + expect(stamps.some((s) => Math.abs(s.opacity - 0.4) < 0.02)).toBe(true); + } finally { + await reopened.close(); + } + }); + + test('opacity: a patch updates /CA without touching the appearance content', async () => { + const page = handle.page(PAGE_OBJECT_NUMBER); + const png = makePng(4, 4, [0, 128, 0, 255]); + const { created } = await page.annotations.create({ + subtype: 'stamp', + rect: { left: 500, bottom: 400, right: 540, top: 440 }, + source: png, + opacity: 1, + }); + + const updated = await page.annotations.update(created.ref, { + subtype: 'stamp', + opacity: 0.25, + }); + expect((updated.updated as StampAnnotationDTO).opacity).toBeCloseTo(0.25, 2); + + const list = await page.annotations.list(); + const re = list.annotations.find( + (a) => + a.ref.kind === 'objectNumber' && + created.ref.kind === 'objectNumber' && + a.ref.annotObjectNumber === created.ref.annotObjectNumber, + ) as StampAnnotationDTO; + expect(re.opacity).toBeCloseTo(0.25, 2); + }); + test('unsupported source bytes reject with InvalidArg before any transport', async () => { const page = handle.page(PAGE_OBJECT_NUMBER); await expect( diff --git a/packages/engine/services/src/features/annotations/internal/read/readStampAnnotation.ts b/packages/engine/services/src/features/annotations/internal/read/readStampAnnotation.ts index e5b937fc1..d091091ad 100644 --- a/packages/engine/services/src/features/annotations/internal/read/readStampAnnotation.ts +++ b/packages/engine/services/src/features/annotations/internal/read/readStampAnnotation.ts @@ -1,15 +1,15 @@ import type { AnnotationBase, StampAnnotationDTO } from '@embedpdf/engine-core/runtime'; import type { PdfFunctions, PdfRuntimeMemory, Ptr } from '@embedpdf/engine-runtime'; -import { readAnnotName } from './annotationReadPrimitives'; +import { readAnnotName, readAnnotOpacity } from './annotationReadPrimitives'; import { readAnnotationRotation, readAnnotationUnrotatedRect, } from './readAnnotationTransformMetadata'; /** - * Stamp DTO: base + `/Name` (standard or custom identifier, verbatim) + - * transform metadata. The visual content + * Stamp DTO: base + `/Name` (standard or custom identifier, verbatim) + `/CA` + * opacity + transform metadata. The visual content * stays in the `/AP` stream — rendered via `renderAppearanceImages()`, * never surfaced as DTO data. */ @@ -21,10 +21,13 @@ export function readStamp( ): StampAnnotationDTO { const rotation = readAnnotationRotation(fn, mem, annotPtr); const unrotatedRect = readAnnotationUnrotatedRect(fn, mem, annotPtr); + const ca = readAnnotOpacity(fn, mem, annotPtr); + const opacity = ca == null ? 1 : Math.max(0, Math.min(1, ca)); return { ...base, subtype: 'stamp', name: readAnnotName(fn, mem, annotPtr), + opacity, ...(rotation != null ? { rotation } : {}), ...(unrotatedRect != null ? { unrotatedRect } : {}), }; diff --git a/packages/engine/services/src/features/annotations/internal/write/writeStampAnnotation.ts b/packages/engine/services/src/features/annotations/internal/write/writeStampAnnotation.ts index 8aa1c2290..f55c5299a 100644 --- a/packages/engine/services/src/features/annotations/internal/write/writeStampAnnotation.ts +++ b/packages/engine/services/src/features/annotations/internal/write/writeStampAnnotation.ts @@ -15,9 +15,10 @@ import type { PdfFunctions, PdfRuntimeMemory, Ptr } from '@embedpdf/engine-runti import { F32_BYTES } from '../../../../runtime/memory/structs'; import { readAnnotRect } from '../read/annotationReadPrimitives'; import type { AnnotationWriteContext } from './annotationWriteContext'; -import { setAnnotRect } from './annotationWritePrimitives'; +import { setAnnotOpacity, setAnnotRect } from './annotationWritePrimitives'; import { applyAnnotationBaseDraft, applyAnnotationBasePatch } from './writeAnnotationBase'; import { writeBoxTransformMetadata } from './writeAnnotationTransformMetadata'; +import { DEFAULT_OPACITY } from './writeStyle'; /** `EPDF_STAMP_FIT` codes from `public/fpdf_annot.h` (CSS `object-fit` naming on the wire). */ const STAMP_FIT_TO_CODE: Record = { @@ -65,6 +66,7 @@ export function applyStampDraft( if (draft.name !== undefined) { setStampName(fn, annotPtr, draft.name); } + setAnnotOpacity(fn, annotPtr, draft.opacity ?? DEFAULT_OPACITY); authorStampAppearance( fn, mem, @@ -97,6 +99,9 @@ export function applyStampPatch( } else if (patch.name !== undefined) { setStampName(fn, annotPtr, patch.name); } + if (patch.opacity !== undefined) { + setAnnotOpacity(fn, annotPtr, patch.opacity); + } if (patch.source !== undefined) { // Content replacement: rebuild the appearance from the new bytes, authored // in the unrotated frame (see authorStampAppearance) so a rotated stamp diff --git a/packages/plugin/annotation/src/capability.ts b/packages/plugin/annotation/src/capability.ts index ee804b274..6488774e0 100644 --- a/packages/plugin/annotation/src/capability.ts +++ b/packages/plugin/annotation/src/capability.ts @@ -638,7 +638,7 @@ export function createAnnotationCapability( source: BinarySource, desired: { width: number; height: number }, rotCW = 0, - identity: { name?: string; subject?: string } = {}, + identity: { name?: string; subject?: string; opacity?: number } = {}, ): Promise | null => { const doc = ctx.doc; const crop = cropOf(pon); @@ -654,6 +654,7 @@ export function createAnnotationCapability( fit: 'contain', ...(identity.name !== undefined ? { name: identity.name } : {}), ...(identity.subject !== undefined ? { subject: identity.subject } : {}), + ...(identity.opacity !== undefined ? { opacity: identity.opacity } : {}), }) .then((res) => { // A stamp has no vector render — the engine-baked /AP IS the visual. @@ -672,7 +673,7 @@ export function createAnnotationCapability( source: BinarySource, desired: { width: number; height: number }, rotCW = 0, - identity: { name?: string; subject?: string } = {}, + identity: { name?: string; subject?: string; opacity?: number } = {}, ): boolean => { const placed = createStampAt(pon, point, source, desired, rotCW, identity); if (!placed) return false; @@ -699,6 +700,7 @@ export function createAnnotationCapability( { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.subject !== undefined ? { subject: input.subject } : {}), + opacity: defaultsFor(model(), ARMED_STAMP_TOOL_ID).opacity, }, ); if (!placed) { @@ -721,6 +723,8 @@ export function createAnnotationCapability( const placeArmedStamp = (pon: number, point: Vec, displayRotation?: number): boolean => { const armed = armedStamp; if (!armed) return false; + const ix = ctx.tryGet(InteractionToken); + const tool = ix ? registry.get(ix.activeToolId()) : undefined; return createStampAtSync( pon, point, @@ -730,6 +734,7 @@ export function createAnnotationCapability( { ...(armed.name !== undefined ? { name: armed.name } : {}), ...(armed.subject !== undefined ? { subject: armed.subject } : {}), + opacity: defaultsFor(model(), tool?.preset ?? ARMED_STAMP_TOOL_ID).opacity, }, ); }; @@ -743,6 +748,7 @@ export function createAnnotationCapability( source: BinarySource, rotCW: number, targetWidth?: number, + opacity?: number, ): Promise => { const resolved = await resolveBinarySource(source); const meta = sniffBinaryMetadata(resolved.bytes); @@ -750,7 +756,7 @@ export function createAnnotationCapability( console.error('[annotation] stamp source must be PNG, JPEG, or single-page PDF bytes'); return; } - createStampAtSync(pon, point, source, desiredStampSize(meta, targetWidth), rotCW); + createStampAtSync(pon, point, source, desiredStampSize(meta, targetWidth), rotCW, { opacity }); }; /** @@ -768,8 +774,9 @@ export function createAnnotationCapability( // belongs to the moment the author picked the spot, even when a 'prompt' // source resolves the bytes later. const rotCW = tool.upright && displayRotation ? uprightRotation(displayRotation) : 0; + const opacity = defaultsFor(model(), tool.preset).opacity; if (spec.kind === 'bytes') { - void placeStampSource(pon, point, spec.source, rotCW); + void placeStampSource(pon, point, spec.source, rotCW, undefined, opacity); return true; } // kind === 'prompt' — needs the environment: the ONE file-picker port. @@ -777,7 +784,7 @@ export function createAnnotationCapability( tool, pon, point, - (picked) => void placeStampSource(pon, point, picked.data, rotCW), + (picked) => void placeStampSource(pon, point, picked.data, rotCW, undefined, opacity), ); }; diff --git a/packages/plugin/annotation/src/repository/seam.ts b/packages/plugin/annotation/src/repository/seam.ts index 0acbddcb5..6530dcfcc 100644 --- a/packages/plugin/annotation/src/repository/seam.ts +++ b/packages/plugin/annotation/src/repository/seam.ts @@ -244,6 +244,19 @@ export function styleFromDTO(dto: AnnotationDTO): Style { border: { kind: 'solid' }, }; } + if (dto.subtype === 'stamp') { + // The stamp's visual is the engine-baked /AP raster — no color/stroke to + // surface, but /CA opacity is real and travels like every other kind. + const d = dto as Extract; + return { + color: '#444444', + interiorColor: null, + strokeWidth: 1, + opacity: d.opacity, + blendMode: dto.blendMode, + border: { kind: 'solid' }, + }; + } return { color: '#444444', interiorColor: null, diff --git a/packages/plugin/annotation/src/tools.ts b/packages/plugin/annotation/src/tools.ts index a19bae0d6..1a39c9f3a 100644 --- a/packages/plugin/annotation/src/tools.ts +++ b/packages/plugin/annotation/src/tools.ts @@ -122,7 +122,7 @@ export const TOOL_DEFAULT_KEYS = { squiggly: ['color', 'opacity', 'blendMode'], caret: ['color', 'opacity'], redact: ['color', 'interiorColor', 'opacity', 'fontFamily', 'fontSize', 'fontColor', 'textAlign'], - stamp: [], + stamp: ['opacity'], // A link preset may carry a FIXED target ('docs-link' style one-click links). link: ['link'], text: ['icon', 'color', 'opacity'], From ed42642c12002d207406d80a62d49a66da41541f Mon Sep 17 00:00:00 2001 From: Marina Date: Sat, 12 Sep 2026 17:35:35 +0200 Subject: [PATCH 2/2] Remove added code comment in seam.ts --- packages/plugin/annotation/src/repository/seam.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/plugin/annotation/src/repository/seam.ts b/packages/plugin/annotation/src/repository/seam.ts index 6530dcfcc..897bfa09c 100644 --- a/packages/plugin/annotation/src/repository/seam.ts +++ b/packages/plugin/annotation/src/repository/seam.ts @@ -245,8 +245,6 @@ export function styleFromDTO(dto: AnnotationDTO): Style { }; } if (dto.subtype === 'stamp') { - // The stamp's visual is the engine-baked /AP raster — no color/stroke to - // surface, but /CA opacity is real and travels like every other kind. const d = dto as Extract; return { color: '#444444',