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/kinds.ts b/packages/core/annotation/src/kinds.ts index b992279fa..7ba674b18 100644 --- a/packages/core/annotation/src/kinds.ts +++ b/packages/core/annotation/src/kinds.ts @@ -530,8 +530,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', @@ -546,7 +548,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/core/annotation/test/annotation-core.test.ts b/packages/core/annotation/test/annotation-core.test.ts index 0e42385d2..82c5a08c3 100644 --- a/packages/core/annotation/test/annotation-core.test.ts +++ b/packages/core/annotation/test/annotation-core.test.ts @@ -23,7 +23,8 @@ import { import { cursorAt, groupUnionBounds, hitTest, paintOrder } from '../src/hit'; import { isAttachedLink, isConversationOnly, isSubstrateOnly } from '../src/plane'; import { linkChildrenOf, linkOf } from '../src/links'; -import { capsFor } from '../src/kinds'; +import { capsFor, propsFor } from '../src/kinds'; +import { applyProps, readProp } from '../src/props'; import { annotDeletable, annotTransformable, DRAWN_FLAGS } from '../src/flags'; import { chordThrough, @@ -2676,6 +2677,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/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 6ab6d968d..55c33daeb 100644 --- a/packages/engine/main/test/annotations-stamp.test.ts +++ b/packages/engine/main/test/annotations-stamp.test.ts @@ -334,6 +334,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(toPageRef(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/repository/seam.ts b/packages/plugin/annotation/src/repository/seam.ts index 5f94df491..116fa1029 100644 --- a/packages/plugin/annotation/src/repository/seam.ts +++ b/packages/plugin/annotation/src/repository/seam.ts @@ -239,6 +239,17 @@ export function styleFromDTO(dto: AnnotationDTO): Style { border: { kind: 'solid' }, }; } + if (dto.subtype === 'stamp') { + 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/definitions.ts b/packages/plugin/annotation/src/tools/definitions.ts index 20d1c86a9..e68a243ac 100644 --- a/packages/plugin/annotation/src/tools/definitions.ts +++ b/packages/plugin/annotation/src/tools/definitions.ts @@ -134,7 +134,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'], diff --git a/packages/plugin/annotation/src/write/stamps.ts b/packages/plugin/annotation/src/write/stamps.ts index a1008f302..684850485 100644 --- a/packages/plugin/annotation/src/write/stamps.ts +++ b/packages/plugin/annotation/src/write/stamps.ts @@ -1,4 +1,4 @@ -import { fitStampBox, type Rect, type Vec } from '@embedpdf/core-annotation'; +import { defaultsFor, fitStampBox, type Rect, type Vec } from '@embedpdf/core-annotation'; import { annotationKey, resolveBinarySource, @@ -141,7 +141,7 @@ export function createStamps( 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 = geometry.cropOf(pon); @@ -156,6 +156,7 @@ export function createStamps( 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. @@ -174,7 +175,7 @@ export function createStamps( 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; @@ -201,6 +202,7 @@ export function createStamps( { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.subject !== undefined ? { subject: input.subject } : {}), + opacity: defaultsFor(store.model(), ARMED_STAMP_TOOL_ID).opacity, }, ); if (!placed) { @@ -214,6 +216,7 @@ export function createStamps( const placeArmedStamp = (pon: number, point: Vec, displayRotation?: number): boolean => { const payload = armed; if (!payload) return false; + const tool = tools.activeTool(); return stageStampAt( pon, point, @@ -223,6 +226,7 @@ export function createStamps( { ...(payload.name !== undefined ? { name: payload.name } : {}), ...(payload.subject !== undefined ? { subject: payload.subject } : {}), + opacity: defaultsFor(store.model(), tool?.preset ?? ARMED_STAMP_TOOL_ID).opacity, }, ); }; @@ -236,6 +240,7 @@ export function createStamps( source: BinarySource, rotCW: number, targetWidth?: number, + opacity?: number, ): Promise => { const resolved = await resolveBinarySource(source); const meta = sniffBinaryMetadata(resolved.bytes); @@ -243,7 +248,7 @@ export function createStamps( console.error('[annotation] stamp source must be PNG, JPEG, or single-page PDF bytes'); return; } - stageStampAt(pon, point, source, desiredStampSize(meta, targetWidth), rotCW); + stageStampAt(pon, point, source, desiredStampSize(meta, targetWidth), rotCW, { opacity }); }; /** @@ -260,8 +265,9 @@ export function createStamps( // belongs to the moment the author picked the spot, even when a 'prompt' // source resolves the bytes later. const rotCW = tools.uprightRotFor(tool.upright ? displayRotation : undefined); + const opacity = defaultsFor(store.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. @@ -269,7 +275,7 @@ export function createStamps( tool, pon, point, - (picked) => void placeStampSource(pon, point, picked.data, rotCW), + (picked) => void placeStampSource(pon, point, picked.data, rotCW, undefined, opacity), ); };