Skip to content

Commit df8be3a

Browse files
committed
feat(annotations): add annotation subsystem (beta)
Add comprehensive PDF annotation support including: - Text markup: highlight, underline, strikeout, squiggly - Shapes: line, square, circle, polygon - Interactive: link, text (sticky notes), popup - Other: stamp, ink, free-text, caret, file-attachment This feature is still in beta - the API may change in future releases.
1 parent 1a6b300 commit df8be3a

26 files changed

+5744
-15
lines changed

src/annotations/annotations.integration.test.ts

Lines changed: 667 additions & 0 deletions
Large diffs are not rendered by default.

src/annotations/annotations.test.ts

Lines changed: 585 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Highlight annotation appearance generation.
3+
*
4+
* Highlights use a transparency group with Multiply blend mode
5+
* to allow text to show through.
6+
*/
7+
8+
import { type Color, colorToArray } from "#src/helpers/colors";
9+
import { PdfArray } from "#src/objects/pdf-array";
10+
import { PdfBool } from "#src/objects/pdf-bool";
11+
import { PdfDict } from "#src/objects/pdf-dict";
12+
import { PdfName } from "#src/objects/pdf-name";
13+
import { PdfNumber } from "#src/objects/pdf-number";
14+
import { PdfStream } from "#src/objects/pdf-stream";
15+
import type { Rect } from "../types";
16+
17+
/**
18+
* Generate the normal appearance stream for a highlight annotation.
19+
*
20+
* @param quadPoints - Array of quads, each quad is 8 numbers defining corners
21+
* @param color - Highlight color
22+
* @param rect - Bounding rectangle
23+
* @param opacity - Opacity (0-1)
24+
* @returns A PdfStream appearance XObject
25+
*/
26+
export function generateHighlightAppearance(
27+
quadPoints: number[][],
28+
color: Color,
29+
rect: Rect,
30+
opacity = 1,
31+
): PdfStream {
32+
const colorComponents = colorToArray(color);
33+
34+
// Build the content stream
35+
// For highlight, we draw filled quads in the highlight color
36+
let content = "";
37+
38+
// Set fill color based on color type
39+
if (colorComponents.length === 1) {
40+
content += `${formatNumber(colorComponents[0])} g\n`;
41+
} else if (colorComponents.length === 3) {
42+
content += `${formatNumber(colorComponents[0])} ${formatNumber(colorComponents[1])} ${formatNumber(colorComponents[2])} rg\n`;
43+
} else if (colorComponents.length === 4) {
44+
content += `${formatNumber(colorComponents[0])} ${formatNumber(colorComponents[1])} ${formatNumber(colorComponents[2])} ${formatNumber(colorComponents[3])} k\n`;
45+
}
46+
47+
// Draw each quad as a filled path
48+
for (const quad of quadPoints) {
49+
if (quad.length < 8) {
50+
continue;
51+
}
52+
53+
// Translate coordinates to appearance stream coordinate system
54+
// (origin at rect.x, rect.y)
55+
const x1 = quad[0] - rect.x;
56+
const y1 = quad[1] - rect.y;
57+
const x2 = quad[2] - rect.x;
58+
const y2 = quad[3] - rect.y;
59+
const x3 = quad[4] - rect.x;
60+
const y3 = quad[5] - rect.y;
61+
const x4 = quad[6] - rect.x;
62+
const y4 = quad[7] - rect.y;
63+
64+
// PDF QuadPoints order: top-left, top-right, bottom-left, bottom-right
65+
// We need to draw: top-left -> top-right -> bottom-right -> bottom-left -> close
66+
content += `${formatNumber(x1)} ${formatNumber(y1)} m\n`;
67+
content += `${formatNumber(x2)} ${formatNumber(y2)} l\n`;
68+
content += `${formatNumber(x4)} ${formatNumber(y4)} l\n`;
69+
content += `${formatNumber(x3)} ${formatNumber(y3)} l\n`;
70+
content += "h f\n";
71+
}
72+
73+
const bytes = new TextEncoder().encode(content);
74+
const stream = new PdfStream([], bytes);
75+
76+
// Set up as Form XObject
77+
stream.set("Type", PdfName.of("XObject"));
78+
stream.set("Subtype", PdfName.of("Form"));
79+
stream.set("FormType", PdfNumber.of(1));
80+
stream.set(
81+
"BBox",
82+
new PdfArray([
83+
PdfNumber.of(0),
84+
PdfNumber.of(0),
85+
PdfNumber.of(rect.width),
86+
PdfNumber.of(rect.height),
87+
]),
88+
);
89+
90+
// Set up transparency group for proper blending
91+
const group = PdfDict.of({
92+
S: PdfName.of("Transparency"),
93+
CS: PdfName.of("DeviceRGB"),
94+
I: PdfBool.of(true), // Isolated
95+
K: PdfBool.of(false), // Non-knockout
96+
});
97+
stream.set("Group", group);
98+
99+
// Resources with blend mode
100+
const extGState = PdfDict.of({
101+
GS0: PdfDict.of({
102+
Type: PdfName.of("ExtGState"),
103+
BM: PdfName.of("Multiply"),
104+
ca: PdfNumber.of(opacity),
105+
CA: PdfNumber.of(opacity),
106+
}),
107+
});
108+
const resources = PdfDict.of({
109+
ExtGState: extGState,
110+
});
111+
stream.set("Resources", resources);
112+
113+
// Update content to use graphics state
114+
const contentWithGS = `/GS0 gs\n${content}`;
115+
stream.setData(new TextEncoder().encode(contentWithGS));
116+
117+
return stream;
118+
}
119+
120+
/**
121+
* Format a number for PDF content stream.
122+
*/
123+
function formatNumber(n: number): string {
124+
const rounded = Math.round(n * 10000) / 10000;
125+
126+
if (Number.isInteger(rounded)) {
127+
return String(rounded);
128+
}
129+
130+
return rounded.toString();
131+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Appearance generation for PDF annotations.
3+
*
4+
* Generates appearance streams for annotations that lack them.
5+
*/
6+
7+
export { generateHighlightAppearance } from "./highlight";
8+
export { generateSquigglyAppearance } from "./squiggly";
9+
export { generateStrikeOutAppearance } from "./strikeout";
10+
export { generateUnderlineAppearance } from "./underline";
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* Squiggly annotation appearance generation.
3+
*
4+
* Draws a wavy underline using a sine wave approximation.
5+
*/
6+
7+
import { type Color, colorToArray } from "#src/helpers/colors";
8+
import { PdfArray } from "#src/objects/pdf-array";
9+
import { PdfDict } from "#src/objects/pdf-dict";
10+
import { PdfName } from "#src/objects/pdf-name";
11+
import { PdfNumber } from "#src/objects/pdf-number";
12+
import { PdfStream } from "#src/objects/pdf-stream";
13+
import type { Rect } from "../types";
14+
15+
/**
16+
* Generate the normal appearance stream for a squiggly annotation.
17+
*
18+
* @param quadPoints - Array of quads, each quad is 8 numbers defining corners
19+
* @param color - Line color
20+
* @param rect - Bounding rectangle
21+
* @param opacity - Opacity (0-1)
22+
* @param lineWidth - Line width
23+
* @returns A PdfStream appearance XObject
24+
*/
25+
export function generateSquigglyAppearance(
26+
quadPoints: number[][],
27+
color: Color,
28+
rect: Rect,
29+
opacity = 1,
30+
lineWidth = 0.5,
31+
): PdfStream {
32+
const colorComponents = colorToArray(color);
33+
34+
// Parameters for the wave
35+
const waveHeight = 2; // Height of wave peaks
36+
const waveLength = 4; // Length of one wave cycle
37+
38+
// Build the content stream
39+
let content = "";
40+
41+
// Set stroke color
42+
if (colorComponents.length === 1) {
43+
content += `${formatNumber(colorComponents[0])} G\n`;
44+
} else if (colorComponents.length === 3) {
45+
content += `${formatNumber(colorComponents[0])} ${formatNumber(colorComponents[1])} ${formatNumber(colorComponents[2])} RG\n`;
46+
} else if (colorComponents.length === 4) {
47+
content += `${formatNumber(colorComponents[0])} ${formatNumber(colorComponents[1])} ${formatNumber(colorComponents[2])} ${formatNumber(colorComponents[3])} K\n`;
48+
}
49+
50+
// Set line width
51+
content += `${formatNumber(lineWidth)} w\n`;
52+
53+
// Draw squiggly for each quad
54+
for (const quad of quadPoints) {
55+
if (quad.length < 8) {
56+
continue;
57+
}
58+
59+
// Bottom-left and bottom-right points
60+
const x1 = quad[4] - rect.x;
61+
const y1 = quad[5] - rect.y;
62+
const x2 = quad[6] - rect.x;
63+
const y2 = quad[7] - rect.y;
64+
65+
const lineLength = Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
66+
const numWaves = Math.max(1, Math.floor(lineLength / waveLength));
67+
const actualWaveLength = lineLength / numWaves;
68+
69+
// Direction vector
70+
const dx = (x2 - x1) / lineLength;
71+
const dy = (y2 - y1) / lineLength;
72+
73+
// Perpendicular vector (for wave height)
74+
const px = -dy;
75+
const py = dx;
76+
77+
// Start at first point
78+
content += `${formatNumber(x1)} ${formatNumber(y1)} m\n`;
79+
80+
// Draw wave using Bezier curves
81+
for (let i = 0; i < numWaves; i++) {
82+
const startX = x1 + dx * i * actualWaveLength;
83+
const startY = y1 + dy * i * actualWaveLength;
84+
85+
// Control points for a sine-wave-like curve using cubic Bezier
86+
// Each half-wave is one Bezier curve
87+
const halfLen = actualWaveLength / 2;
88+
89+
// First half-wave (going up)
90+
const cp1x = startX + dx * halfLen * 0.5 + px * waveHeight;
91+
const cp1y = startY + dy * halfLen * 0.5 + py * waveHeight;
92+
const midX = startX + dx * halfLen;
93+
const midY = startY + dy * halfLen;
94+
95+
content += `${formatNumber(cp1x)} ${formatNumber(cp1y)} ${formatNumber(midX)} ${formatNumber(midY + waveHeight / 2)} ${formatNumber(midX)} ${formatNumber(midY)} c\n`;
96+
97+
// Second half-wave (going down)
98+
const cp2x = midX + dx * halfLen * 0.5 - px * waveHeight;
99+
const cp2y = midY + dy * halfLen * 0.5 - py * waveHeight;
100+
const endX = startX + dx * actualWaveLength;
101+
const endY = startY + dy * actualWaveLength;
102+
103+
content += `${formatNumber(cp2x)} ${formatNumber(cp2y)} ${formatNumber(endX)} ${formatNumber(endY - waveHeight / 2)} ${formatNumber(endX)} ${formatNumber(endY)} c\n`;
104+
}
105+
106+
content += "S\n";
107+
}
108+
109+
const bytes = new TextEncoder().encode(content);
110+
const stream = new PdfStream([], bytes);
111+
112+
// Set up as Form XObject
113+
stream.set("Type", PdfName.of("XObject"));
114+
stream.set("Subtype", PdfName.of("Form"));
115+
stream.set("FormType", PdfNumber.of(1));
116+
stream.set(
117+
"BBox",
118+
new PdfArray([
119+
PdfNumber.of(0),
120+
PdfNumber.of(0),
121+
PdfNumber.of(rect.width),
122+
PdfNumber.of(rect.height),
123+
]),
124+
);
125+
126+
// Add resources for opacity if needed
127+
if (opacity < 1) {
128+
const extGState = PdfDict.of({
129+
GS0: PdfDict.of({
130+
Type: PdfName.of("ExtGState"),
131+
CA: PdfNumber.of(opacity),
132+
ca: PdfNumber.of(opacity),
133+
}),
134+
});
135+
const resources = PdfDict.of({
136+
ExtGState: extGState,
137+
});
138+
stream.set("Resources", resources);
139+
140+
// Prepend graphics state
141+
const contentWithGS = `/GS0 gs\n${content}`;
142+
stream.setData(new TextEncoder().encode(contentWithGS));
143+
}
144+
145+
return stream;
146+
}
147+
148+
/**
149+
* Format a number for PDF content stream.
150+
*/
151+
function formatNumber(n: number): string {
152+
const rounded = Math.round(n * 10000) / 10000;
153+
154+
if (Number.isInteger(rounded)) {
155+
return String(rounded);
156+
}
157+
158+
return rounded.toString();
159+
}

0 commit comments

Comments
 (0)