Skip to content

Commit 52cd89f

Browse files
feat: add PDF annotation APIs for interactive links and navigation (#1140)
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: Brooooooklyn <[email protected]> Co-authored-by: LongYinan <[email protected]>
1 parent c780187 commit 52cd89f

File tree

6 files changed

+449
-1
lines changed

6 files changed

+449
-1
lines changed

__test__/pdf-annotations.spec.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import { writeFile } from 'node:fs/promises'
2+
import { join, dirname } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
5+
import ava, { TestFn } from 'ava'
6+
7+
import { PDFDocument } from '../index'
8+
9+
const __dirname = dirname(fileURLToPath(import.meta.url))
10+
11+
const test = ava as TestFn<{
12+
doc: PDFDocument
13+
}>
14+
15+
test.beforeEach((t) => {
16+
t.context.doc = new PDFDocument()
17+
})
18+
19+
test('should create PDF with URL link annotation', async (t) => {
20+
const { doc } = t.context
21+
const ctx = doc.beginPage(612, 792)
22+
23+
// Draw a clickable link
24+
ctx.fillStyle = 'blue'
25+
ctx.fillRect(50, 50, 200, 40)
26+
ctx.fillStyle = 'white'
27+
ctx.font = '20px sans-serif'
28+
ctx.fillText('Click here to visit GitHub', 60, 75)
29+
30+
// Add URL annotation
31+
ctx.annotateLinkUrl(50, 50, 250, 90, 'https://github.com/Brooooooklyn/canvas')
32+
33+
doc.endPage()
34+
const pdfBuffer = doc.close()
35+
36+
t.true(pdfBuffer instanceof Buffer)
37+
t.true(pdfBuffer.length > 0)
38+
t.is(pdfBuffer.toString('utf8', 0, 5), '%PDF-')
39+
40+
// Check for annotation in PDF content
41+
const pdfContent = pdfBuffer.toString('latin1')
42+
t.true(pdfContent.includes('/Annot'))
43+
44+
await writeFile(join(__dirname, 'pdf', 'link-annotation.pdf'), pdfBuffer)
45+
})
46+
47+
test('should create PDF with named destination and link to it', async (t) => {
48+
const { doc } = t.context
49+
50+
// Page 1 - Create link to destination
51+
const ctx1 = doc.beginPage(612, 792)
52+
ctx1.fillStyle = 'black'
53+
ctx1.font = '24px sans-serif'
54+
ctx1.fillText('Table of Contents', 50, 50)
55+
56+
// Draw a clickable link to page 2
57+
ctx1.fillStyle = 'blue'
58+
ctx1.fillRect(50, 100, 200, 30)
59+
ctx1.fillStyle = 'white'
60+
ctx1.font = '18px sans-serif'
61+
ctx1.fillText('Go to Chapter 1', 60, 120)
62+
63+
// Add link to named destination
64+
ctx1.annotateLinkToDestination(50, 100, 250, 130, 'chapter1')
65+
66+
doc.endPage()
67+
68+
// Page 2 - Create the destination
69+
const ctx2 = doc.beginPage(612, 792)
70+
71+
// Create named destination at the top of page 2
72+
ctx2.annotateNamedDestination(50, 50, 'chapter1')
73+
74+
ctx2.fillStyle = 'black'
75+
ctx2.font = '30px sans-serif'
76+
ctx2.fillText('Chapter 1', 50, 100)
77+
ctx2.font = '16px sans-serif'
78+
ctx2.fillText('This is the content of chapter 1.', 50, 150)
79+
80+
doc.endPage()
81+
82+
const pdfBuffer = doc.close()
83+
84+
t.true(pdfBuffer instanceof Buffer)
85+
t.true(pdfBuffer.length > 0)
86+
t.is(pdfBuffer.toString('utf8', 0, 5), '%PDF-')
87+
88+
await writeFile(join(__dirname, 'pdf', 'named-destination.pdf'), pdfBuffer)
89+
})
90+
91+
test('should create PDF with multiple URL links', async (t) => {
92+
const { doc } = t.context
93+
const ctx = doc.beginPage(612, 792)
94+
95+
ctx.fillStyle = 'black'
96+
ctx.font = '24px sans-serif'
97+
ctx.fillText('Useful Links', 50, 50)
98+
99+
const links = [
100+
{ text: 'GitHub Repository', url: 'https://github.com/Brooooooklyn/canvas', y: 100 },
101+
{ text: 'NPM Package', url: 'https://www.npmjs.com/package/@napi-rs/canvas', y: 160 },
102+
{ text: 'Documentation', url: 'https://github.com/Brooooooklyn/canvas#readme', y: 220 },
103+
]
104+
105+
links.forEach((link) => {
106+
// Draw link background
107+
ctx.fillStyle = 'lightblue'
108+
ctx.fillRect(50, link.y, 300, 40)
109+
110+
// Draw link text
111+
ctx.fillStyle = 'darkblue'
112+
ctx.font = '18px sans-serif'
113+
ctx.fillText(link.text, 60, link.y + 25)
114+
115+
// Add URL annotation
116+
ctx.annotateLinkUrl(50, link.y, 350, link.y + 40, link.url)
117+
})
118+
119+
doc.endPage()
120+
const pdfBuffer = doc.close()
121+
122+
t.true(pdfBuffer instanceof Buffer)
123+
t.true(pdfBuffer.length > 0)
124+
125+
await writeFile(join(__dirname, 'pdf', 'multiple-links.pdf'), pdfBuffer)
126+
})
127+
128+
test('should create table of contents with multiple named destinations', async (t) => {
129+
const { doc } = t.context
130+
131+
// Table of Contents page
132+
const toc = doc.beginPage(612, 792)
133+
toc.fillStyle = 'black'
134+
toc.font = '30px sans-serif'
135+
toc.fillText('Table of Contents', 50, 50)
136+
137+
const chapters = [
138+
{ title: 'Chapter 1: Introduction', dest: 'intro', y: 120 },
139+
{ title: 'Chapter 2: Getting Started', dest: 'getting-started', y: 160 },
140+
{ title: 'Chapter 3: Advanced Topics', dest: 'advanced', y: 200 },
141+
{ title: 'Chapter 4: Conclusion', dest: 'conclusion', y: 240 },
142+
]
143+
144+
toc.font = '18px sans-serif'
145+
chapters.forEach((chapter) => {
146+
toc.fillStyle = 'blue'
147+
toc.fillText(chapter.title, 70, chapter.y)
148+
toc.annotateLinkToDestination(70, chapter.y - 20, 400, chapter.y + 5, chapter.dest)
149+
})
150+
151+
doc.endPage()
152+
153+
// Create pages for each chapter
154+
chapters.forEach((chapter) => {
155+
const ctx = doc.beginPage(612, 792)
156+
ctx.annotateNamedDestination(50, 50, chapter.dest)
157+
ctx.fillStyle = 'black'
158+
ctx.font = '30px sans-serif'
159+
ctx.fillText(chapter.title.split(':')[0], 50, 100)
160+
ctx.font = '18px sans-serif'
161+
ctx.fillText(`Content of ${chapter.title.toLowerCase()}`, 50, 150)
162+
doc.endPage()
163+
})
164+
165+
const pdfBuffer = doc.close()
166+
167+
t.true(pdfBuffer instanceof Buffer)
168+
t.true(pdfBuffer.length > 0)
169+
170+
await writeFile(join(__dirname, 'pdf', 'toc-with-destinations.pdf'), pdfBuffer)
171+
})
172+
173+
test('should handle empty string URL gracefully', (t) => {
174+
const { doc } = t.context
175+
const ctx = doc.beginPage(612, 792)
176+
177+
ctx.fillStyle = 'black'
178+
ctx.fillRect(50, 50, 100, 100)
179+
180+
// Should not crash with empty URL
181+
t.notThrows(() => {
182+
ctx.annotateLinkUrl(50, 50, 150, 150, '')
183+
})
184+
185+
doc.endPage()
186+
const pdfBuffer = doc.close()
187+
188+
t.true(pdfBuffer instanceof Buffer)
189+
})
190+
191+
test('should handle annotations with special characters in URL', async (t) => {
192+
const { doc } = t.context
193+
const ctx = doc.beginPage(612, 792)
194+
195+
ctx.fillStyle = 'blue'
196+
ctx.fillRect(50, 50, 300, 40)
197+
ctx.fillStyle = 'white'
198+
ctx.font = '16px sans-serif'
199+
ctx.fillText('Link with special chars', 60, 75)
200+
201+
// URL with query parameters and special characters
202+
const specialUrl = 'https://example.com/search?q=test&lang=en&special=äöü'
203+
ctx.annotateLinkUrl(50, 50, 350, 90, specialUrl)
204+
205+
doc.endPage()
206+
const pdfBuffer = doc.close()
207+
208+
t.true(pdfBuffer instanceof Buffer)
209+
t.true(pdfBuffer.length > 0)
210+
211+
await writeFile(join(__dirname, 'pdf', 'special-chars-link.pdf'), pdfBuffer)
212+
})
213+
214+
test('should support annotations on rotated/transformed canvas', async (t) => {
215+
const { doc } = t.context
216+
const ctx = doc.beginPage(612, 792)
217+
218+
// Draw without transformation
219+
ctx.fillStyle = 'red'
220+
ctx.fillRect(50, 50, 100, 40)
221+
ctx.annotateLinkUrl(50, 50, 150, 90, 'https://example.com/normal')
222+
223+
// Apply transformation and draw
224+
ctx.save()
225+
ctx.translate(300, 300)
226+
ctx.rotate((45 * Math.PI) / 180)
227+
228+
ctx.fillStyle = 'blue'
229+
ctx.fillRect(-50, -20, 100, 40)
230+
// Note: Annotation coordinates should be in the transformed space
231+
ctx.annotateLinkUrl(-50, -20, 50, 20, 'https://example.com/rotated')
232+
233+
ctx.restore()
234+
235+
doc.endPage()
236+
const pdfBuffer = doc.close()
237+
238+
t.true(pdfBuffer instanceof Buffer)
239+
t.true(pdfBuffer.length > 0)
240+
241+
await writeFile(join(__dirname, 'pdf', 'transformed-annotations.pdf'), pdfBuffer)
242+
})

index.d.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ interface CanvasRenderingContext2D
1818
CanvasState,
1919
CanvasText,
2020
CanvasTextDrawingStyles,
21-
CanvasTransform {}
21+
CanvasTransform,
22+
CanvasPDFAnnotations {}
2223

2324
interface CanvasState {
2425
isContextLost(): boolean
@@ -120,6 +121,40 @@ interface CanvasTransform {
120121
transform(a: number, b: number, c: number, d: number, e: number, f: number): void
121122
translate(x: number, y: number): void
122123
}
124+
125+
interface CanvasPDFAnnotations {
126+
/**
127+
* Create a clickable URL link annotation in a PDF document.
128+
* This is only effective when used with PDF documents.
129+
* @param left - Left coordinate of the link rectangle
130+
* @param top - Top coordinate of the link rectangle
131+
* @param right - Right coordinate of the link rectangle
132+
* @param bottom - Bottom coordinate of the link rectangle
133+
* @param url - The URL to link to
134+
*/
135+
annotateLinkUrl(left: number, top: number, right: number, bottom: number, url: string): void
136+
137+
/**
138+
* Create a named destination at a specific point in a PDF document.
139+
* This destination can be used as a target for internal links.
140+
* @param x - X coordinate of the destination point
141+
* @param y - Y coordinate of the destination point
142+
* @param name - Name identifier for the destination
143+
*/
144+
annotateNamedDestination(x: number, y: number, name: string): void
145+
146+
/**
147+
* Create a link to a named destination within the PDF document.
148+
* This is only effective when used with PDF documents.
149+
* @param left - Left coordinate of the link rectangle
150+
* @param top - Top coordinate of the link rectangle
151+
* @param right - Right coordinate of the link rectangle
152+
* @param bottom - Bottom coordinate of the link rectangle
153+
* @param name - Name of the destination to link to
154+
*/
155+
annotateLinkToDestination(left: number, top: number, right: number, bottom: number, name: string): void
156+
}
157+
123158
type PredefinedColorSpace = 'display-p3' | 'srgb'
124159

125160
interface ImageDataSettings {

skia-c/skia_c.cpp

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1895,4 +1895,44 @@ void skiac_document_close(skiac_pdf_document* c_document,
18951895
output_data->ptr = raw_data ? raw_data->bytes() : nullptr;
18961896
output_data->data = reinterpret_cast<skiac_data*>(data.release());
18971897
}
1898+
1899+
// SkAnnotation
1900+
void skiac_canvas_annotate_link_url(skiac_canvas* c_canvas,
1901+
const skiac_rect* rect,
1902+
const char* url) {
1903+
if (!c_canvas || !rect || !url) {
1904+
return;
1905+
}
1906+
auto canvas = CANVAS_CAST;
1907+
SkRect sk_rect =
1908+
SkRect::MakeLTRB(rect->left, rect->top, rect->right, rect->bottom);
1909+
sk_sp<SkData> url_data = SkData::MakeWithCString(url);
1910+
SkAnnotateRectWithURL(canvas, sk_rect, url_data.get());
1911+
}
1912+
1913+
void skiac_canvas_annotate_named_destination(skiac_canvas* c_canvas,
1914+
float x,
1915+
float y,
1916+
const char* name) {
1917+
if (!c_canvas || !name) {
1918+
return;
1919+
}
1920+
auto canvas = CANVAS_CAST;
1921+
SkPoint point = SkPoint::Make(x, y);
1922+
sk_sp<SkData> name_data = SkData::MakeWithCString(name);
1923+
SkAnnotateNamedDestination(canvas, point, name_data.get());
1924+
}
1925+
1926+
void skiac_canvas_annotate_link_to_destination(skiac_canvas* c_canvas,
1927+
const skiac_rect* rect,
1928+
const char* name) {
1929+
if (!c_canvas || !rect || !name) {
1930+
return;
1931+
}
1932+
auto canvas = CANVAS_CAST;
1933+
SkRect sk_rect =
1934+
SkRect::MakeLTRB(rect->left, rect->top, rect->right, rect->bottom);
1935+
sk_sp<SkData> name_data = SkData::MakeWithCString(name);
1936+
SkAnnotateLinkToDestination(canvas, sk_rect, name_data.get());
1937+
}
18981938
}

skia-c/skia_c.hpp

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
#include <include/codec/SkCodec.h>
55
#include <include/codec/SkEncodedImageFormat.h>
6+
#include <include/core/SkAnnotation.h>
67
#include <include/core/SkBitmap.h>
78
#include <include/core/SkBlurTypes.h>
89
#include <include/core/SkCanvas.h>
@@ -740,6 +741,18 @@ skiac_canvas* skiac_document_begin_page(skiac_pdf_document* c_document,
740741
void skiac_document_end_page(skiac_pdf_document* c_document);
741742
void skiac_document_close(skiac_pdf_document* c_document,
742743
skiac_sk_data* output_data);
744+
745+
// SkAnnotation
746+
void skiac_canvas_annotate_link_url(skiac_canvas* c_canvas,
747+
const skiac_rect* rect,
748+
const char* url);
749+
void skiac_canvas_annotate_named_destination(skiac_canvas* c_canvas,
750+
float x,
751+
float y,
752+
const char* name);
753+
void skiac_canvas_annotate_link_to_destination(skiac_canvas* c_canvas,
754+
const skiac_rect* rect,
755+
const char* name);
743756
}
744757

745758
#endif // SKIA_CAPI_H

0 commit comments

Comments
 (0)