|
| 1 | +import { calcDegree, lengthToNumber, splitEffects } from '../utils.js' |
| 2 | + |
| 3 | +type Point = [number, number] |
| 4 | + |
| 5 | +const horizontalKeywords: Record<string, number> = { |
| 6 | + left: 0, |
| 7 | + 'x-start': 0, |
| 8 | + center: 0.5, |
| 9 | + right: 1, |
| 10 | + 'x-end': 1, |
| 11 | +} |
| 12 | + |
| 13 | +const verticalKeywords: Record<string, number> = { |
| 14 | + top: 0, |
| 15 | + 'y-start': 0, |
| 16 | + center: 0.5, |
| 17 | + bottom: 1, |
| 18 | + 'y-end': 1, |
| 19 | +} |
| 20 | + |
| 21 | +function format(value: number) { |
| 22 | + return Math.round(value * 1000) / 1000 |
| 23 | +} |
| 24 | + |
| 25 | +export function parseShapeFunction( |
| 26 | + value: string, |
| 27 | + width: number, |
| 28 | + height: number, |
| 29 | + inheritedStyle: Record<string, string | number> |
| 30 | +) { |
| 31 | + const match = value.match(/^shape\((.*)\)$/s) |
| 32 | + if (!match) return null |
| 33 | + |
| 34 | + const parts = splitEffects(match[1]) |
| 35 | + const first = parts.shift() |
| 36 | + if (!first) throw new Error('Invalid `shape()` value.') |
| 37 | + |
| 38 | + const startMatch = first.match(/^(?:(nonzero|evenodd)\s+)?from\s+(.+)$/) |
| 39 | + if (!startMatch) throw new Error('Invalid `shape()` starting point.') |
| 40 | + |
| 41 | + const fillRule = startMatch[1] || 'nonzero' |
| 42 | + let current = resolvePosition( |
| 43 | + tokenize(startMatch[2]), |
| 44 | + width, |
| 45 | + height, |
| 46 | + inheritedStyle |
| 47 | + ) |
| 48 | + let subpathStart = current |
| 49 | + let path = `M${format(current[0])},${format(current[1])}` |
| 50 | + |
| 51 | + for (const part of parts) { |
| 52 | + const tokens = tokenize(part) |
| 53 | + const command = tokens.shift() |
| 54 | + |
| 55 | + if (command === 'close') { |
| 56 | + if (tokens.length) |
| 57 | + throw new Error('Invalid `close` command in `shape()`.') |
| 58 | + path += ' Z' |
| 59 | + current = subpathStart |
| 60 | + continue |
| 61 | + } |
| 62 | + |
| 63 | + if (command === 'move' || command === 'line') { |
| 64 | + const endpoint = resolveEndpoint( |
| 65 | + tokens, |
| 66 | + current, |
| 67 | + width, |
| 68 | + height, |
| 69 | + inheritedStyle |
| 70 | + ) |
| 71 | + path += ` ${command === 'move' ? 'M' : 'L'}${format( |
| 72 | + endpoint[0] |
| 73 | + )},${format(endpoint[1])}` |
| 74 | + current = endpoint |
| 75 | + if (command === 'move') subpathStart = endpoint |
| 76 | + continue |
| 77 | + } |
| 78 | + |
| 79 | + if (command === 'hline' || command === 'vline') { |
| 80 | + const axis = command === 'hline' ? 0 : 1 |
| 81 | + const mode = tokens.shift() |
| 82 | + const token = tokens.shift() |
| 83 | + if ((mode !== 'to' && mode !== 'by') || !token || tokens.length) { |
| 84 | + throw new Error(`Invalid \`${command}\` command in \`shape()\`.`) |
| 85 | + } |
| 86 | + const base = axis === 0 ? width : height |
| 87 | + const keywordMap = axis === 0 ? horizontalKeywords : verticalKeywords |
| 88 | + const resolved = |
| 89 | + mode === 'to' && token in keywordMap |
| 90 | + ? keywordMap[token] * base |
| 91 | + : resolveLength(token, base, inheritedStyle) |
| 92 | + current = [...current] as Point |
| 93 | + current[axis] = mode === 'by' ? current[axis] + resolved : resolved |
| 94 | + path += ` ${axis === 0 ? 'H' : 'V'}${format(current[axis])}` |
| 95 | + continue |
| 96 | + } |
| 97 | + |
| 98 | + if (command === 'curve' || command === 'smooth') { |
| 99 | + const withIndex = tokens.indexOf('with') |
| 100 | + const endpointTokens = |
| 101 | + withIndex === -1 ? tokens : tokens.slice(0, withIndex) |
| 102 | + const controlTokens = withIndex === -1 ? [] : tokens.slice(withIndex + 1) |
| 103 | + const mode = endpointTokens[0] |
| 104 | + const endpoint = resolveEndpoint( |
| 105 | + endpointTokens, |
| 106 | + current, |
| 107 | + width, |
| 108 | + height, |
| 109 | + inheritedStyle |
| 110 | + ) |
| 111 | + |
| 112 | + if (command === 'curve' && withIndex === -1) { |
| 113 | + throw new Error('A `curve` command requires a control point.') |
| 114 | + } |
| 115 | + |
| 116 | + const controls = splitAtSlash(controlTokens).map((control) => |
| 117 | + resolveControlPoint( |
| 118 | + control, |
| 119 | + current, |
| 120 | + endpoint, |
| 121 | + width, |
| 122 | + height, |
| 123 | + inheritedStyle |
| 124 | + ) |
| 125 | + ) |
| 126 | + |
| 127 | + if (command === 'curve') { |
| 128 | + if (controls.length === 1) { |
| 129 | + path += ` Q${pointString(controls[0])} ${pointString(endpoint)}` |
| 130 | + } else if (controls.length === 2) { |
| 131 | + path += ` C${pointString(controls[0])} ${pointString( |
| 132 | + controls[1] |
| 133 | + )} ${pointString(endpoint)}` |
| 134 | + } else { |
| 135 | + throw new Error( |
| 136 | + 'A `curve` command accepts one or two control points.' |
| 137 | + ) |
| 138 | + } |
| 139 | + } else if (!controls.length) { |
| 140 | + path += ` T${pointString(endpoint)}` |
| 141 | + } else if (controls.length === 1) { |
| 142 | + path += ` S${pointString(controls[0])} ${pointString(endpoint)}` |
| 143 | + } else { |
| 144 | + throw new Error('A `smooth` command accepts at most one control point.') |
| 145 | + } |
| 146 | + |
| 147 | + current = endpoint |
| 148 | + continue |
| 149 | + } |
| 150 | + |
| 151 | + if (command === 'arc') { |
| 152 | + const optionIndex = tokens.findIndex((token) => |
| 153 | + ['of', 'cw', 'ccw', 'large', 'small', 'rotate'].includes(token) |
| 154 | + ) |
| 155 | + const endpointTokens = |
| 156 | + optionIndex === -1 ? tokens : tokens.slice(0, optionIndex) |
| 157 | + const options = optionIndex === -1 ? [] : tokens.slice(optionIndex) |
| 158 | + const endpoint = resolveEndpoint( |
| 159 | + endpointTokens, |
| 160 | + current, |
| 161 | + width, |
| 162 | + height, |
| 163 | + inheritedStyle |
| 164 | + ) |
| 165 | + let rx = 0 |
| 166 | + let ry = 0 |
| 167 | + let rotation = 0 |
| 168 | + let large = 0 |
| 169 | + let sweep = 0 |
| 170 | + |
| 171 | + for (let index = 0; index < options.length; index++) { |
| 172 | + const option = options[index] |
| 173 | + if (option === 'of') { |
| 174 | + const firstRadius = options[++index] |
| 175 | + if (!firstRadius) |
| 176 | + throw new Error('Invalid `arc` radius in `shape()`.') |
| 177 | + const secondRadius = options[index + 1] |
| 178 | + const hasSecondRadius = |
| 179 | + secondRadius && |
| 180 | + !['cw', 'ccw', 'large', 'small', 'rotate'].includes(secondRadius) |
| 181 | + if (hasSecondRadius) index++ |
| 182 | + if (hasSecondRadius) { |
| 183 | + rx = resolveLength(firstRadius, width, inheritedStyle) |
| 184 | + ry = resolveLength(secondRadius, height, inheritedStyle) |
| 185 | + } else { |
| 186 | + const base = |
| 187 | + Math.sqrt(width * width + height * height) / Math.sqrt(2) |
| 188 | + rx = ry = resolveLength(firstRadius, base, inheritedStyle) |
| 189 | + } |
| 190 | + } else if (option === 'cw' || option === 'ccw') { |
| 191 | + sweep = option === 'cw' ? 1 : 0 |
| 192 | + } else if (option === 'large' || option === 'small') { |
| 193 | + large = option === 'large' ? 1 : 0 |
| 194 | + } else if (option === 'rotate') { |
| 195 | + const angle = options[++index] |
| 196 | + if (!angle) throw new Error('Invalid `arc` rotation in `shape()`.') |
| 197 | + const degree = calcDegree(angle) |
| 198 | + if (typeof degree === 'undefined') { |
| 199 | + throw new Error('Invalid `arc` rotation in `shape()`.') |
| 200 | + } |
| 201 | + rotation = degree |
| 202 | + } else { |
| 203 | + throw new Error('Invalid `arc` option in `shape()`.') |
| 204 | + } |
| 205 | + } |
| 206 | + |
| 207 | + path += ` A${format(Math.abs(rx))},${format(Math.abs(ry))} ${format( |
| 208 | + rotation |
| 209 | + )} ${large} ${sweep} ${pointString(endpoint)}` |
| 210 | + current = endpoint |
| 211 | + continue |
| 212 | + } |
| 213 | + |
| 214 | + throw new Error(`Unsupported \`${command}\` command in \`shape()\`.`) |
| 215 | + } |
| 216 | + |
| 217 | + return { |
| 218 | + type: 'path', |
| 219 | + d: path, |
| 220 | + 'fill-rule': fillRule, |
| 221 | + } |
| 222 | +} |
| 223 | + |
| 224 | +function tokenize(value: string) { |
| 225 | + return value.trim().replace(/\//g, ' / ').split(/\s+/).filter(Boolean) |
| 226 | +} |
| 227 | + |
| 228 | +function splitAtSlash(tokens: string[]) { |
| 229 | + if (!tokens.length) return [] |
| 230 | + const slash = tokens.indexOf('/') |
| 231 | + return slash === -1 |
| 232 | + ? [tokens] |
| 233 | + : [tokens.slice(0, slash), tokens.slice(slash + 1)] |
| 234 | +} |
| 235 | + |
| 236 | +function resolveEndpoint( |
| 237 | + tokens: string[], |
| 238 | + current: Point, |
| 239 | + width: number, |
| 240 | + height: number, |
| 241 | + inheritedStyle: Record<string, string | number> |
| 242 | +) { |
| 243 | + const mode = tokens[0] |
| 244 | + if (mode !== 'to' && mode !== 'by') { |
| 245 | + throw new Error('A `shape()` command must use `to` or `by`.') |
| 246 | + } |
| 247 | + const point = resolvePosition(tokens.slice(1), width, height, inheritedStyle) |
| 248 | + return mode === 'by' |
| 249 | + ? ([current[0] + point[0], current[1] + point[1]] as Point) |
| 250 | + : point |
| 251 | +} |
| 252 | + |
| 253 | +function resolveControlPoint( |
| 254 | + tokens: string[], |
| 255 | + start: Point, |
| 256 | + end: Point, |
| 257 | + width: number, |
| 258 | + height: number, |
| 259 | + inheritedStyle: Record<string, string | number> |
| 260 | +) { |
| 261 | + const fromIndex = tokens.indexOf('from') |
| 262 | + const pointTokens = fromIndex === -1 ? tokens : tokens.slice(0, fromIndex) |
| 263 | + const reference = fromIndex === -1 ? undefined : tokens[fromIndex + 1] |
| 264 | + if (fromIndex !== -1 && (fromIndex + 2 !== tokens.length || !reference)) { |
| 265 | + throw new Error('Invalid control point reference in `shape()`.') |
| 266 | + } |
| 267 | + const point = resolvePosition( |
| 268 | + pointTokens, |
| 269 | + width, |
| 270 | + height, |
| 271 | + inheritedStyle, |
| 272 | + true |
| 273 | + ) |
| 274 | + |
| 275 | + if (reference === 'start' || (!reference && !hasPositionKeyword(tokens))) { |
| 276 | + return [start[0] + point[0], start[1] + point[1]] as Point |
| 277 | + } |
| 278 | + if (reference === 'end') { |
| 279 | + return [end[0] + point[0], end[1] + point[1]] as Point |
| 280 | + } |
| 281 | + if (reference === 'origin') return point |
| 282 | + return point |
| 283 | +} |
| 284 | + |
| 285 | +function resolvePosition( |
| 286 | + tokens: string[], |
| 287 | + width: number, |
| 288 | + height: number, |
| 289 | + inheritedStyle: Record<string, string | number>, |
| 290 | + isControlPoint = false |
| 291 | +): Point { |
| 292 | + if (!tokens.length || tokens.length > 4) { |
| 293 | + throw new Error('Invalid position in `shape()`.') |
| 294 | + } |
| 295 | + |
| 296 | + let x: number | undefined |
| 297 | + let y: number | undefined |
| 298 | + const remaining = [...tokens] |
| 299 | + |
| 300 | + for (let index = 0; index < remaining.length; index++) { |
| 301 | + const token = remaining[index] |
| 302 | + if ( |
| 303 | + token === 'left' || |
| 304 | + token === 'right' || |
| 305 | + token === 'x-start' || |
| 306 | + token === 'x-end' |
| 307 | + ) { |
| 308 | + const next = remaining[index + 1] |
| 309 | + if (next && !isPositionKeyword(next)) { |
| 310 | + const offset = resolveLength(next, width, inheritedStyle) |
| 311 | + x = token === 'left' || token === 'x-start' ? offset : width - offset |
| 312 | + index++ |
| 313 | + } else { |
| 314 | + x = horizontalKeywords[token] * width |
| 315 | + } |
| 316 | + } else if ( |
| 317 | + token === 'top' || |
| 318 | + token === 'bottom' || |
| 319 | + token === 'y-start' || |
| 320 | + token === 'y-end' |
| 321 | + ) { |
| 322 | + const next = remaining[index + 1] |
| 323 | + if (next && !isPositionKeyword(next)) { |
| 324 | + const offset = resolveLength(next, height, inheritedStyle) |
| 325 | + y = token === 'top' || token === 'y-start' ? offset : height - offset |
| 326 | + index++ |
| 327 | + } else { |
| 328 | + y = verticalKeywords[token] * height |
| 329 | + } |
| 330 | + } else if (token === 'center') { |
| 331 | + if (x === undefined) x = width / 2 |
| 332 | + else if (y === undefined) y = height / 2 |
| 333 | + else throw new Error('Invalid position in `shape()`.') |
| 334 | + } else if (x === undefined) { |
| 335 | + x = resolveLength(token, width, inheritedStyle) |
| 336 | + } else if (y === undefined) { |
| 337 | + y = resolveLength(token, height, inheritedStyle) |
| 338 | + } else { |
| 339 | + throw new Error('Invalid position in `shape()`.') |
| 340 | + } |
| 341 | + } |
| 342 | + |
| 343 | + if (!isControlPoint && tokens.length === 1 && y === undefined) y = height / 2 |
| 344 | + return [ |
| 345 | + x ?? (isControlPoint ? 0 : width / 2), |
| 346 | + y ?? (isControlPoint ? 0 : height / 2), |
| 347 | + ] |
| 348 | +} |
| 349 | + |
| 350 | +function isPositionKeyword(value: string) { |
| 351 | + return value in horizontalKeywords || value in verticalKeywords |
| 352 | +} |
| 353 | + |
| 354 | +function hasPositionKeyword(tokens: string[]) { |
| 355 | + return tokens.some(isPositionKeyword) |
| 356 | +} |
| 357 | + |
| 358 | +function resolveLength( |
| 359 | + value: string, |
| 360 | + base: number, |
| 361 | + inheritedStyle: Record<string, string | number> |
| 362 | +) { |
| 363 | + const resolved = lengthToNumber( |
| 364 | + value, |
| 365 | + inheritedStyle.fontSize as number, |
| 366 | + base, |
| 367 | + inheritedStyle, |
| 368 | + true |
| 369 | + ) |
| 370 | + if (typeof resolved === 'undefined') { |
| 371 | + throw new Error(`Invalid length \`${value}\` in \`shape()\`.`) |
| 372 | + } |
| 373 | + return resolved |
| 374 | +} |
| 375 | + |
| 376 | +function pointString(point: Point) { |
| 377 | + return `${format(point[0])},${format(point[1])}` |
| 378 | +} |
0 commit comments