@zseven-w/pen-renderer 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ import type { CanvasKit } from 'canvaskit-wasm'
2
+ import type { PenFill, PenStroke } from '@zseven-w/pen-types'
3
+ import { DEFAULT_FILL, DEFAULT_STROKE_WIDTH } from '@zseven-w/pen-core'
4
+
5
+ export { cssFontFamily } from '@zseven-w/pen-core'
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Color parsing — ck.Color4f takes 0-1 floats for all channels (r, g, b, a)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export function parseColor(ck: CanvasKit, color: string): Float32Array {
12
+ if (color.startsWith('#')) {
13
+ const hex = color.slice(1)
14
+ if (hex.length === 8) {
15
+ const r = parseInt(hex.slice(0, 2), 16) / 255
16
+ const g = parseInt(hex.slice(2, 4), 16) / 255
17
+ const b = parseInt(hex.slice(4, 6), 16) / 255
18
+ const a = parseInt(hex.slice(6, 8), 16) / 255
19
+ return ck.Color4f(r, g, b, a)
20
+ }
21
+ if (hex.length === 6) {
22
+ const r = parseInt(hex.slice(0, 2), 16) / 255
23
+ const g = parseInt(hex.slice(2, 4), 16) / 255
24
+ const b = parseInt(hex.slice(4, 6), 16) / 255
25
+ return ck.Color4f(r, g, b, 1)
26
+ }
27
+ if (hex.length === 3) {
28
+ const r = parseInt(hex[0] + hex[0], 16) / 255
29
+ const g = parseInt(hex[1] + hex[1], 16) / 255
30
+ const b = parseInt(hex[2] + hex[2], 16) / 255
31
+ return ck.Color4f(r, g, b, 1)
32
+ }
33
+ }
34
+ if (color === 'transparent') return ck.Color4f(0, 0, 0, 0)
35
+ if (color === 'white') return ck.Color4f(1, 1, 1, 1)
36
+ if (color === 'black') return ck.Color4f(0, 0, 0, 1)
37
+ // rgba() parsing
38
+ const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
39
+ if (rgbaMatch) {
40
+ return ck.Color4f(
41
+ parseInt(rgbaMatch[1]) / 255,
42
+ parseInt(rgbaMatch[2]) / 255,
43
+ parseInt(rgbaMatch[3]) / 255,
44
+ rgbaMatch[4] !== undefined ? parseFloat(rgbaMatch[4]) : 1,
45
+ )
46
+ }
47
+ return ck.Color4f(0.82, 0.835, 0.858, 1) // fallback #d1d5db
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Corner radius helpers
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export function cornerRadiusValue(cr: number | [number, number, number, number] | undefined): number {
55
+ if (cr === undefined) return 0
56
+ if (typeof cr === 'number') return cr
57
+ return cr[0]
58
+ }
59
+
60
+ export function cornerRadii(cr: number | [number, number, number, number] | undefined): [number, number, number, number] {
61
+ if (cr === undefined) return [0, 0, 0, 0]
62
+ if (typeof cr === 'number') return [cr, cr, cr, cr]
63
+ return cr
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Fill / stroke helpers
68
+ // ---------------------------------------------------------------------------
69
+
70
+ export function resolveFillColor(fills?: PenFill[] | string): string {
71
+ if (typeof fills === 'string') return fills
72
+ if (!fills || fills.length === 0) return DEFAULT_FILL
73
+ const first = fills[0]
74
+ if (first.type === 'solid') return first.color
75
+ if (first.type === 'linear_gradient' || first.type === 'radial_gradient') {
76
+ return first.stops[0]?.color ?? DEFAULT_FILL
77
+ }
78
+ return DEFAULT_FILL
79
+ }
80
+
81
+ export function resolveStrokeColor(stroke?: PenStroke): string | undefined {
82
+ if (!stroke) return undefined
83
+ if (typeof stroke.fill === 'string') return stroke.fill
84
+ if (stroke.fill && stroke.fill.length > 0) return resolveFillColor(stroke.fill)
85
+ return undefined
86
+ }
87
+
88
+ export function resolveStrokeWidth(stroke?: PenStroke): number {
89
+ if (!stroke) return 0
90
+ if (typeof stroke.thickness === 'number') return stroke.thickness
91
+ if (typeof stroke.thickness === 'object' && !Array.isArray(stroke.thickness)) return 0
92
+ return stroke.thickness?.[0] ?? DEFAULT_STROKE_WIDTH
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Text wrapping utilities
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /** CJK character range check (for character-level line breaking). */
100
+ function isCJK(ch: string): boolean {
101
+ const c = ch.charCodeAt(0)
102
+ return (c >= 0x4E00 && c <= 0x9FFF) || (c >= 0x3400 && c <= 0x4DBF) ||
103
+ (c >= 0x3000 && c <= 0x303F) || (c >= 0xFF00 && c <= 0xFFEF) ||
104
+ (c >= 0x2E80 && c <= 0x2FDF)
105
+ }
106
+
107
+ /** Word-wrap a single line of text, appending wrapped lines to `out`. */
108
+ export function wrapLine(ctx: CanvasRenderingContext2D, text: string, maxW: number, out: string[]) {
109
+ if (ctx.measureText(text).width <= maxW) { out.push(text); return }
110
+
111
+ let current = ''
112
+ let i = 0
113
+ while (i < text.length) {
114
+ const ch = text[i]
115
+ if (isCJK(ch)) {
116
+ const test = current + ch
117
+ if (ctx.measureText(test).width > maxW && current) {
118
+ out.push(current)
119
+ current = ch
120
+ } else {
121
+ current = test
122
+ }
123
+ i++
124
+ } else if (ch === ' ') {
125
+ const test = current + ch
126
+ if (ctx.measureText(test).width > maxW && current) {
127
+ out.push(current)
128
+ current = ''
129
+ } else {
130
+ current = test
131
+ }
132
+ i++
133
+ } else {
134
+ let word = ''
135
+ while (i < text.length && text[i] !== ' ' && !isCJK(text[i])) {
136
+ word += text[i]; i++
137
+ }
138
+ const test = current + word
139
+ if (ctx.measureText(test).width > maxW && current) {
140
+ out.push(current)
141
+ current = word
142
+ } else {
143
+ current = test
144
+ }
145
+ }
146
+ }
147
+ if (current) out.push(current)
148
+ }
@@ -0,0 +1,225 @@
1
+ import type { CanvasKit, Path } from 'canvaskit-wasm'
2
+
3
+ /**
4
+ * Normalize SVG path data for CanvasKit's parser:
5
+ * - Add spaces between command letters and numbers
6
+ * - Handle negative-sign number separators (e.g. "10-5" -> "10 -5")
7
+ * - Normalize comma separators to spaces
8
+ * - Separate concatenated arc flags (e.g. "a2 2 0 012 2" -> "a2 2 0 0 1 2 2")
9
+ */
10
+ export function sanitizeSvgPath(d: string): string {
11
+ let result = d
12
+ // Add space between command letter and following number/sign
13
+ .replace(/([MLCQZAHVSmlcqzahvsTt])([0-9.+-])/g, '$1 $2')
14
+ // Add space between digit and following negative sign (number separator)
15
+ .replace(/(\d)-/g, '$1 -')
16
+ // Replace commas with spaces
17
+ .replace(/,/g, ' ')
18
+ // Collapse multiple spaces
19
+ .replace(/\s+/g, ' ')
20
+ .trim()
21
+
22
+ // Separate concatenated arc flags: in SVG arc commands, the large-arc and
23
+ // sweep flags are single digits (0 or 1) that may be concatenated with each
24
+ // other and with the following number. e.g. "a2 2 0 012 2" -> "a2 2 0 0 1 2 2"
25
+ result = result.replace(
26
+ /([aA])\s*([\d.e+-]+)\s+([\d.e+-]+)\s+([\d.e+-]+)\s+([01])([01])([\d.+-])/g,
27
+ '$1 $2 $3 $4 $5 $6 $7',
28
+ )
29
+ // Handle the case where all three (rotation + flags) are concatenated without spaces,
30
+ // e.g. "a4 4 0100-8" where 0100 = rotation=0, large-arc=1, sweep=0, then 0 is start of x
31
+ result = result.replace(
32
+ /([aA])\s*([\d.e+-]+)\s+([\d.e+-]+)\s+(\d)([01])([01])([\d.+-])/g,
33
+ '$1 $2 $3 $4 $5 $6 $7',
34
+ )
35
+
36
+ return result
37
+ }
38
+
39
+ /** Returns true if the path string contains NaN or Infinity values. */
40
+ export function hasInvalidNumbers(d: string): boolean {
41
+ return /NaN|Infinity/i.test(d)
42
+ }
43
+
44
+ /**
45
+ * Convert an SVG arc segment to cubic bezier curves and add them to the path.
46
+ * Based on the W3C SVG implementation note for arc-to-cubic conversion.
47
+ */
48
+ function arcToCubics(
49
+ path: Path,
50
+ x1: number, y1: number,
51
+ rxIn: number, ryIn: number,
52
+ largeArc: boolean, sweep: boolean,
53
+ x2: number, y2: number,
54
+ ): void {
55
+ // Degenerate: start == end
56
+ if (x1 === x2 && y1 === y2) return
57
+
58
+ let rx = Math.abs(rxIn)
59
+ let ry = Math.abs(ryIn)
60
+
61
+ const dx = (x1 - x2) / 2
62
+ const dy = (y1 - y2) / 2
63
+ // Simplified: ignore rotation (most icons use rotation=0)
64
+ const x1p = dx
65
+ const y1p = dy
66
+
67
+ // Correct radii
68
+ let lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
69
+ if (lambda > 1) {
70
+ const s = Math.sqrt(lambda)
71
+ rx *= s
72
+ ry *= s
73
+ }
74
+
75
+ const rxSq = rx * rx
76
+ const rySq = ry * ry
77
+ const x1pSq = x1p * x1p
78
+ const y1pSq = y1p * y1p
79
+
80
+ let sq = (rxSq * rySq - rxSq * y1pSq - rySq * x1pSq) / (rxSq * y1pSq + rySq * x1pSq)
81
+ if (sq < 0) sq = 0
82
+ let root = Math.sqrt(sq)
83
+ if (largeArc === sweep) root = -root
84
+
85
+ const cxp = root * rx * y1p / ry
86
+ const cyp = -root * ry * x1p / rx
87
+
88
+ const cx = cxp + (x1 + x2) / 2
89
+ const cy = cyp + (y1 + y2) / 2
90
+
91
+ const angle = (ux: number, uy: number, vx: number, vy: number) => {
92
+ const n = Math.sqrt(ux * ux + uy * uy)
93
+ const d = Math.sqrt(vx * vx + vy * vy)
94
+ const c = (ux * vx + uy * vy) / (n * d)
95
+ const clamped = Math.max(-1, Math.min(1, c))
96
+ let a = Math.acos(clamped)
97
+ if (ux * vy - uy * vx < 0) a = -a
98
+ return a
99
+ }
100
+
101
+ const theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry)
102
+ let dTheta = angle(
103
+ (x1p - cxp) / rx, (y1p - cyp) / ry,
104
+ (-x1p - cxp) / rx, (-y1p - cyp) / ry,
105
+ )
106
+
107
+ if (!sweep && dTheta > 0) dTheta -= 2 * Math.PI
108
+ if (sweep && dTheta < 0) dTheta += 2 * Math.PI
109
+
110
+ // Split into segments of at most PI/2
111
+ const segments = Math.ceil(Math.abs(dTheta) / (Math.PI / 2))
112
+ const segAngle = dTheta / segments
113
+
114
+ for (let i = 0; i < segments; i++) {
115
+ const t1 = theta1 + i * segAngle
116
+ const t2 = t1 + segAngle
117
+ const alpha = Math.sin(segAngle) * (Math.sqrt(4 + 3 * Math.pow(Math.tan(segAngle / 2), 2)) - 1) / 3
118
+
119
+ const cos1 = Math.cos(t1), sin1 = Math.sin(t1)
120
+ const cos2 = Math.cos(t2), sin2 = Math.sin(t2)
121
+
122
+ const p1x = cx + rx * cos1
123
+ const p1y = cy + ry * sin1
124
+ const p2x = cx + rx * cos2
125
+ const p2y = cy + ry * sin2
126
+
127
+ const cp1x = p1x - alpha * rx * sin1
128
+ const cp1y = p1y + alpha * ry * cos1
129
+ const cp2x = p2x + alpha * rx * sin2
130
+ const cp2y = p2y - alpha * ry * cos2
131
+
132
+ path.cubicTo(cp1x, cp1y, cp2x, cp2y, p2x, p2y)
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Try building a CanvasKit path manually by tokenizing the SVG path string.
138
+ * Handles edge cases that MakeFromSVGString may reject (e.g. missing spaces,
139
+ * numbers with leading dots like ".5", relative commands, arcs).
140
+ */
141
+ export function tryManualPathParse(ck: CanvasKit, d: string): Path | null {
142
+ try {
143
+ const path = new ck.Path()
144
+ // Replace NaN/Infinity with 0 so commands keep their parameter count.
145
+ const cleaned = d.replace(/-?NaN/g, '0').replace(/-?Infinity/g, '0')
146
+ // Tokenize: split on commands and extract numbers
147
+ const tokens = cleaned.match(/[MLCQZAHVSmlcqzahvs]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/g)
148
+ if (!tokens || tokens.length === 0) return null
149
+
150
+ let i = 0
151
+ let lastCmd = ''
152
+ let cx = 0, cy = 0 // current point
153
+
154
+ while (i < tokens.length) {
155
+ let cmd = tokens[i]
156
+ if (/^[MLCQZAHVSmlcqzahvs]$/.test(cmd)) {
157
+ lastCmd = cmd
158
+ i++
159
+ } else if (lastCmd) {
160
+ // Implicit repeat of last command (M becomes L after first pair)
161
+ cmd = lastCmd === 'M' ? 'L' : lastCmd === 'm' ? 'l' : lastCmd
162
+ } else {
163
+ i++
164
+ continue
165
+ }
166
+
167
+ const nums = (count: number): number[] => {
168
+ const result: number[] = []
169
+ for (let j = 0; j < count && i < tokens.length; j++) {
170
+ const n = parseFloat(tokens[i])
171
+ if (isNaN(n)) break
172
+ result.push(n)
173
+ i++
174
+ }
175
+ return result
176
+ }
177
+
178
+ switch (cmd) {
179
+ case 'M': { const p = nums(2); if (p.length === 2) { path.moveTo(p[0], p[1]); cx = p[0]; cy = p[1]; lastCmd = 'L' } break }
180
+ case 'm': { const p = nums(2); if (p.length === 2) { path.moveTo(cx + p[0], cy + p[1]); cx += p[0]; cy += p[1]; lastCmd = 'l' } break }
181
+ case 'L': { const p = nums(2); if (p.length === 2) { path.lineTo(p[0], p[1]); cx = p[0]; cy = p[1] } break }
182
+ case 'l': { const p = nums(2); if (p.length === 2) { path.lineTo(cx + p[0], cy + p[1]); cx += p[0]; cy += p[1] } break }
183
+ case 'H': { const p = nums(1); if (p.length === 1) { path.lineTo(p[0], cy); cx = p[0] } break }
184
+ case 'h': { const p = nums(1); if (p.length === 1) { path.lineTo(cx + p[0], cy); cx += p[0] } break }
185
+ case 'V': { const p = nums(1); if (p.length === 1) { path.lineTo(cx, p[0]); cy = p[0] } break }
186
+ case 'v': { const p = nums(1); if (p.length === 1) { path.lineTo(cx, cy + p[0]); cy += p[0] } break }
187
+ case 'C': { const p = nums(6); if (p.length === 6) { path.cubicTo(p[0], p[1], p[2], p[3], p[4], p[5]); cx = p[4]; cy = p[5] } break }
188
+ case 'c': { const p = nums(6); if (p.length === 6) { path.cubicTo(cx+p[0], cy+p[1], cx+p[2], cy+p[3], cx+p[4], cy+p[5]); cx += p[4]; cy += p[5] } break }
189
+ case 'Q': { const p = nums(4); if (p.length === 4) { path.quadTo(p[0], p[1], p[2], p[3]); cx = p[2]; cy = p[3] } break }
190
+ case 'q': { const p = nums(4); if (p.length === 4) { path.quadTo(cx+p[0], cy+p[1], cx+p[2], cy+p[3]); cx += p[2]; cy += p[3] } break }
191
+ case 'S': { const p = nums(4); if (p.length === 4) { path.cubicTo(cx, cy, p[0], p[1], p[2], p[3]); cx = p[2]; cy = p[3] } break }
192
+ case 's': { const p = nums(4); if (p.length === 4) { path.cubicTo(cx, cy, cx+p[0], cy+p[1], cx+p[2], cy+p[3]); cx += p[2]; cy += p[3] } break }
193
+ case 'Z': case 'z': path.close(); break
194
+ case 'A': case 'a': {
195
+ // Arc: rx, ry, rotation, largeArc, sweep, x, y
196
+ const p = nums(7)
197
+ if (p.length === 7) {
198
+ const [rx, ry, , largeArc, sweep, ex, ey] = p
199
+ const endX = cmd === 'a' ? cx + ex : ex
200
+ const endY = cmd === 'a' ? cy + ey : ey
201
+ if (rx > 0 && ry > 0) {
202
+ arcToCubics(path, cx, cy, rx, ry, largeArc !== 0, sweep !== 0, endX, endY)
203
+ } else {
204
+ path.lineTo(endX, endY)
205
+ }
206
+ cx = endX
207
+ cy = endY
208
+ }
209
+ break
210
+ }
211
+ default: i++
212
+ }
213
+ }
214
+
215
+ // Check if path has any geometry
216
+ const bounds = path.getBounds()
217
+ if (bounds[2] - bounds[0] < 0.001 && bounds[3] - bounds[1] < 0.001) {
218
+ path.delete()
219
+ return null
220
+ }
221
+ return path
222
+ } catch {
223
+ return null
224
+ }
225
+ }