|
| 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