Skip to content

Commit 4131db2

Browse files
authored
Merge pull request #569 from embedpdf/feature/stamp-image-import-improvements
Add symmetric annotation import/export API
2 parents ab15a0b + f683ab2 commit 4131db2

54 files changed

Lines changed: 1898 additions & 311 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/export-annotations.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@embedpdf/plugin-annotation': minor
3+
---
4+
5+
Add symmetric annotation import/export API using a unified `AnnotationTransferItem` type. `exportAnnotations()` produces the same format that `importAnnotations()` consumes — zero remapping needed for round-tripping. Stamp appearances are automatically exported as PDF buffers in `ctx.data`. On import, stamps can be created from PNG, JPEG, or PDF buffers via `ctx: { data: ArrayBuffer }` — the engine auto-detects the format from magic bytes. Also adds `deleteAllAnnotations()` convenience method.
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<script lang="ts">
2+
import { onMount, onDestroy } from 'svelte';
3+
import { Viewport } from '@embedpdf/plugin-viewport/svelte';
4+
import { Scroller, type RenderPageProps } from '@embedpdf/plugin-scroll/svelte';
5+
import { RenderLayer } from '@embedpdf/plugin-render/svelte';
6+
import { AnnotationLayer, useAnnotationCapability } from '@embedpdf/plugin-annotation/svelte';
7+
import type { AnnotationTransferItem } from '@embedpdf/plugin-annotation';
8+
import { PagePointerProvider } from '@embedpdf/plugin-interaction-manager/svelte';
9+
import { SelectionLayer } from '@embedpdf/plugin-selection/svelte';
10+
import {
11+
Check,
12+
X,
13+
Pencil,
14+
Square,
15+
Highlighter,
16+
Type,
17+
Download,
18+
Upload,
19+
Trash2,
20+
} from 'lucide-svelte';
21+
22+
interface Props {
23+
documentId: string;
24+
}
25+
26+
let { documentId }: Props = $props();
27+
28+
let activeTool = $state<string | null>(null);
29+
let exported = $state<AnnotationTransferItem[] | null>(null);
30+
let status = $state<string | null>(null);
31+
let annotationCount = $state(0);
32+
33+
const annotationCapability = useAnnotationCapability();
34+
const annotationApi = $derived(annotationCapability.provides?.forDocument(documentId));
35+
36+
const tools = [
37+
{ id: 'stampCheckmark', name: 'Checkmark', icon: Check },
38+
{ id: 'stampCross', name: 'Cross', icon: X },
39+
{ id: 'ink', name: 'Pen', icon: Pencil },
40+
{ id: 'square', name: 'Square', icon: Square },
41+
{ id: 'highlight', name: 'Highlight', icon: Highlighter },
42+
{ id: 'freeText', name: 'Text', icon: Type },
43+
];
44+
45+
let unsubscribeToolChange: (() => void) | undefined;
46+
let unsubscribeStateChange: (() => void) | undefined;
47+
48+
onMount(() => {
49+
if (!annotationApi) return;
50+
51+
unsubscribeToolChange = annotationApi.onActiveToolChange((tool) => {
52+
activeTool = tool?.id ?? null;
53+
});
54+
55+
unsubscribeStateChange = annotationApi.onStateChange((state) => {
56+
annotationCount = Object.values(state.pages).reduce((sum, uids) => sum + uids.length, 0);
57+
});
58+
});
59+
60+
onDestroy(() => {
61+
unsubscribeToolChange?.();
62+
unsubscribeStateChange?.();
63+
});
64+
65+
const handleToolClick = (toolId: string) => {
66+
annotationApi?.setActiveTool(activeTool === toolId ? null : toolId);
67+
};
68+
69+
const handleExport = () => {
70+
if (!annotationApi) return;
71+
annotationApi.exportAnnotations().wait(
72+
(result) => {
73+
exported = result;
74+
status = `Exported ${result.length} annotation${result.length !== 1 ? 's' : ''}`;
75+
},
76+
() => {
77+
status = 'Export failed';
78+
},
79+
);
80+
};
81+
82+
const handleClear = () => {
83+
if (!annotationApi) return;
84+
annotationApi.deleteAllAnnotations();
85+
status = 'Cleared all annotations';
86+
};
87+
88+
const handleImport = () => {
89+
if (!annotationApi || !exported) return;
90+
annotationApi.importAnnotations(exported);
91+
status = `Imported ${exported.length} annotation${exported.length !== 1 ? 's' : ''}`;
92+
};
93+
</script>
94+
95+
<div
96+
class="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-sm dark:border-gray-700 dark:bg-gray-900"
97+
style="user-select: none"
98+
>
99+
<div
100+
class="flex flex-col gap-2 border-b border-gray-300 bg-gray-100 px-3 py-2 dark:border-gray-700 dark:bg-gray-800"
101+
>
102+
<div class="flex flex-wrap items-center gap-3">
103+
<span class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
104+
Tools
105+
</span>
106+
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
107+
<div class="flex items-center gap-1.5">
108+
{#each tools as tool (tool.id)}
109+
<button
110+
type="button"
111+
onclick={() => handleToolClick(tool.id)}
112+
class={[
113+
'inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium shadow-sm transition-all',
114+
activeTool === tool.id
115+
? 'bg-blue-500 text-white ring-1 ring-blue-600'
116+
: 'bg-white text-gray-600 ring-1 ring-gray-300 hover:bg-gray-50 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:ring-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-100',
117+
].join(' ')}
118+
title={tool.name}
119+
>
120+
<tool.icon size={14} />
121+
<span class="hidden sm:inline">{tool.name}</span>
122+
</button>
123+
{/each}
124+
</div>
125+
</div>
126+
127+
<div class="flex flex-wrap items-center gap-3">
128+
<span class="text-xs font-medium uppercase tracking-wide text-gray-600 dark:text-gray-300">
129+
Import / Export
130+
</span>
131+
<div class="h-4 w-px bg-gray-300 dark:bg-gray-600"></div>
132+
<div class="flex items-center gap-1.5">
133+
<button
134+
type="button"
135+
onclick={handleExport}
136+
disabled={annotationCount === 0}
137+
class="inline-flex items-center gap-1.5 rounded-md bg-emerald-500 px-2.5 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-emerald-600 disabled:cursor-not-allowed disabled:opacity-50"
138+
>
139+
<Download size={14} />
140+
Export ({annotationCount})
141+
</button>
142+
<button
143+
type="button"
144+
onclick={handleClear}
145+
disabled={annotationCount === 0}
146+
class="inline-flex items-center gap-1.5 rounded-md bg-red-500 px-2.5 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
147+
>
148+
<Trash2 size={14} />
149+
Clear All
150+
</button>
151+
<button
152+
type="button"
153+
onclick={handleImport}
154+
disabled={!exported}
155+
class="inline-flex items-center gap-1.5 rounded-md bg-blue-500 px-2.5 py-1.5 text-xs font-medium text-white shadow-sm transition-all hover:bg-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
156+
>
157+
<Upload size={14} />
158+
Import{exported ? ` (${exported.length})` : ''}
159+
</button>
160+
</div>
161+
162+
{#if status}
163+
<span class="text-xs text-gray-500 dark:text-gray-400">{status}</span>
164+
{/if}
165+
</div>
166+
</div>
167+
168+
<div class="relative h-[450px] sm:h-[550px]">
169+
{#snippet renderPage(page: RenderPageProps)}
170+
<PagePointerProvider {documentId} pageIndex={page.pageIndex}>
171+
<RenderLayer
172+
{documentId}
173+
pageIndex={page.pageIndex}
174+
scale={1}
175+
class="pointer-events-none"
176+
/>
177+
<SelectionLayer {documentId} pageIndex={page.pageIndex} />
178+
<AnnotationLayer {documentId} pageIndex={page.pageIndex} />
179+
</PagePointerProvider>
180+
{/snippet}
181+
<Viewport {documentId} class="absolute inset-0 bg-gray-200 dark:bg-gray-800">
182+
<Scroller {documentId} {renderPage} />
183+
</Viewport>
184+
</div>
185+
</div>
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<script lang="ts">
2+
import { usePdfiumEngine } from '@embedpdf/engines/svelte';
3+
import { EmbedPDF } from '@embedpdf/core/svelte';
4+
import { createPluginRegistration, type PluginRegistry } from '@embedpdf/core';
5+
import {
6+
DocumentManagerPluginPackage,
7+
DocumentContent,
8+
} from '@embedpdf/plugin-document-manager/svelte';
9+
import { ViewportPluginPackage } from '@embedpdf/plugin-viewport/svelte';
10+
import { ScrollPluginPackage } from '@embedpdf/plugin-scroll/svelte';
11+
import { RenderPluginPackage } from '@embedpdf/plugin-render/svelte';
12+
import {
13+
type AnnotationPlugin,
14+
AnnotationPluginPackage,
15+
type AnnotationTool,
16+
} from '@embedpdf/plugin-annotation/svelte';
17+
import { InteractionManagerPluginPackage } from '@embedpdf/plugin-interaction-manager/svelte';
18+
import { SelectionPluginPackage } from '@embedpdf/plugin-selection/svelte';
19+
import { HistoryPluginPackage } from '@embedpdf/plugin-history/svelte';
20+
import { PdfAnnotationSubtype, type PdfStampAnnoObject } from '@embedpdf/models';
21+
import ImportExportContent from './annotation-import-export-example-content.svelte';
22+
23+
const pdfEngine = usePdfiumEngine();
24+
25+
const plugins = [
26+
createPluginRegistration(DocumentManagerPluginPackage, {
27+
initialDocuments: [{ url: 'https://snippet.embedpdf.com/ebook.pdf' }],
28+
}),
29+
createPluginRegistration(ViewportPluginPackage),
30+
createPluginRegistration(ScrollPluginPackage),
31+
createPluginRegistration(RenderPluginPackage),
32+
createPluginRegistration(InteractionManagerPluginPackage),
33+
createPluginRegistration(SelectionPluginPackage),
34+
createPluginRegistration(HistoryPluginPackage),
35+
createPluginRegistration(AnnotationPluginPackage, {
36+
annotationAuthor: 'EmbedPDF User',
37+
}),
38+
];
39+
40+
const handleInitialized = async (registry: PluginRegistry) => {
41+
const annotation = registry.getPlugin<AnnotationPlugin>('annotation')?.provides();
42+
43+
annotation?.addTool<AnnotationTool<PdfStampAnnoObject>>({
44+
id: 'stampCheckmark',
45+
name: 'Checkmark',
46+
interaction: {
47+
exclusive: true,
48+
cursor: 'crosshair',
49+
},
50+
matchScore: () => 0,
51+
defaults: {
52+
type: PdfAnnotationSubtype.STAMP,
53+
imageSrc: '/circle-checkmark.png',
54+
imageSize: { width: 30, height: 30 },
55+
},
56+
behavior: {
57+
showGhost: true,
58+
deactivateToolAfterCreate: true,
59+
selectAfterCreate: true,
60+
},
61+
});
62+
63+
annotation?.addTool<AnnotationTool<PdfStampAnnoObject>>({
64+
id: 'stampCross',
65+
name: 'Cross',
66+
interaction: {
67+
exclusive: true,
68+
cursor: 'crosshair',
69+
},
70+
matchScore: () => 0,
71+
defaults: {
72+
type: PdfAnnotationSubtype.STAMP,
73+
imageSrc: '/circle-cross.png',
74+
imageSize: { width: 30, height: 30 },
75+
},
76+
behavior: {
77+
showGhost: true,
78+
deactivateToolAfterCreate: true,
79+
selectAfterCreate: true,
80+
},
81+
});
82+
};
83+
</script>
84+
85+
{#if pdfEngine.isLoading || !pdfEngine.engine}
86+
<div>Loading PDF Engine...</div>
87+
{:else}
88+
<EmbedPDF engine={pdfEngine.engine} {plugins} onInitialized={handleInitialized}>
89+
{#snippet children({ activeDocumentId })}
90+
{#if activeDocumentId}
91+
<DocumentContent documentId={activeDocumentId}>
92+
{#snippet children(documentContent)}
93+
{#if documentContent.isLoaded}
94+
<ImportExportContent documentId={activeDocumentId} />
95+
{/if}
96+
{/snippet}
97+
</DocumentContent>
98+
{/if}
99+
{/snippet}
100+
</EmbedPDF>
101+
{/if}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default } from './annotation-import-export-example.svelte';

0 commit comments

Comments
 (0)