Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/stamp-annotation-opacity.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 5 additions & 3 deletions packages/core/annotation/src/kinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,8 +530,10 @@ export const KINDS: Record<string, AnnotationKind> = {
},
// 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',
Expand All @@ -546,7 +548,7 @@ export const KINDS: Record<string, AnnotationKind> = {
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
Expand Down
17 changes: 16 additions & 1 deletion packages/core/annotation/test/annotation-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions packages/engine/core/src/annotation/kinds/stamp/draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/engine/core/src/annotation/kinds/stamp/dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`). */
Expand Down
3 changes: 3 additions & 0 deletions packages/engine/core/src/annotation/kinds/stamp/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions packages/engine/core/src/annotation/kinds/stamp/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const StampFitSchema = z.enum(['contain', 'cover', 'fill']);
export const StampDTOSchema: z.ZodType<StampAnnotationDTO> = 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'),
Expand All @@ -36,6 +37,7 @@ export const StampWireDraftSchema: z.ZodType<StampWireDraft> = 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(),
Expand All @@ -47,6 +49,7 @@ export const StampWirePatchSchema: z.ZodType<StampWirePatch> = 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(),
Expand Down
8 changes: 8 additions & 0 deletions packages/engine/core/test/annotation/normalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
67 changes: 67 additions & 0 deletions packages/engine/main/test/annotations-stamp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand All @@ -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 } : {}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<StampFit, number> = {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/plugin/annotation/src/repository/seam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,17 @@ export function styleFromDTO(dto: AnnotationDTO): Style {
border: { kind: 'solid' },
};
}
if (dto.subtype === 'stamp') {
const d = dto as Extract<AnnotationDTO, { subtype: 'stamp' }>;
return {
color: '#444444',
interiorColor: null,
strokeWidth: 1,
opacity: d.opacity,
blendMode: dto.blendMode,
border: { kind: 'solid' },
};
}
return {
color: '#444444',
interiorColor: null,
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin/annotation/src/tools/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading