@jbrowse/svgcanvas 6.5.0 → 8.0.0

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.
package/src/index.ts CHANGED
@@ -9,61 +9,12 @@
9
9
  * Copyright (c) 2014 Gliffy Inc.
10
10
  * Copyright (c) 2021 Zeno Zeng
11
11
  *
12
- * Vendored and converted to ESM/TypeScript for pure ESM compatibility.
12
+ * Vendored, converted to ESM/TypeScript, and cut down to the calls the
13
+ * renderers make: rectangles, paths, arcs and glyphs.
13
14
  */
14
15
 
15
16
  /* eslint-disable @typescript-eslint/no-explicit-any */
16
17
 
17
- function toString(obj: any): string {
18
- if (!obj) {
19
- return obj
20
- }
21
- if (typeof obj === 'string') {
22
- return obj
23
- }
24
- return obj + ''
25
- }
26
-
27
- function format(str: string, args: Record<string, any>): string {
28
- const keys = Object.keys(args)
29
- for (const key of keys) {
30
- str = str.replace(new RegExp('\\{' + key + '\\}', 'gi'), args[key])
31
- }
32
- return str
33
- }
34
-
35
- function randomString(holder: Record<string, string>): string {
36
- if (!holder) {
37
- throw new Error(
38
- 'cannot create a random attribute name for an undefined object',
39
- )
40
- }
41
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz'
42
- let randomstring = ''
43
- do {
44
- randomstring = ''
45
- for (let i = 0; i < 12; i++) {
46
- randomstring += chars[Math.floor(Math.random() * chars.length)]
47
- }
48
- } while (holder[randomstring])
49
- return randomstring
50
- }
51
-
52
- function createNamedToNumberedLookup(
53
- items: string,
54
- radix: number = 10,
55
- ): Record<string, string> {
56
- const lookup: Record<string, string> = {}
57
- const parts = items.split(',')
58
- for (let i = 0; i < parts.length; i += 2) {
59
- const entity = '&' + parts[i + 1] + ';'
60
- const base10 = parseInt(parts[i]!, radix)
61
- lookup[entity] = '&#' + base10 + ';'
62
- }
63
- lookup['\\xa0'] = '&#160;'
64
- return lookup
65
- }
66
-
67
18
  function getTextAnchor(textAlign: string): string {
68
19
  const mapping: Record<string, string> = {
69
20
  left: 'start',
@@ -87,14 +38,12 @@ function getDominantBaseline(textBaseline: string): string {
87
38
  }
88
39
 
89
40
  /**
90
- * The value SVG already assumes for a text presentation attribute, so writing it
91
- * out says nothing. A text-heavy export repeats each of these on every glyph:
92
- * dropping the four below takes ~22% off a sequence-logo figure.
41
+ * SVG's initial values for text presentation attributes. Omitting them from
42
+ * every glyph takes ~22% off a sequence-logo figure.
93
43
  */
94
44
  const TEXT_ATTR_DEFAULTS: Record<string, string> = {
95
45
  'font-style': 'normal',
96
46
  'font-weight': 'normal',
97
- 'font-variant': 'normal',
98
47
  // the initial value is `auto`, which resolves to the alphabetic baseline
99
48
  'dominant-baseline': 'alphabetic',
100
49
  }
@@ -103,35 +52,6 @@ function omitDefault(attr: string, value: string | undefined) {
103
52
  return value === TEXT_ATTR_DEFAULTS[attr] ? undefined : value
104
53
  }
105
54
 
106
- const namedEntities = createNamedToNumberedLookup(
107
- '50,nbsp,51,iexcl,52,cent,53,pound,54,curren,55,yen,56,brvbar,57,sect,58,uml,59,copy,' +
108
- '5a,ordf,5b,laquo,5c,not,5d,shy,5e,reg,5f,macr,5g,deg,5h,plusmn,5i,sup2,5j,sup3,5k,acute,' +
109
- '5l,micro,5m,para,5n,middot,5o,cedil,5p,sup1,5q,ordm,5r,raquo,5s,frac14,5t,frac12,5u,frac34,' +
110
- '5v,iquest,60,Agrave,61,Aacute,62,Acirc,63,Atilde,64,Auml,65,Aring,66,AElig,67,Ccedil,' +
111
- '68,Egrave,69,Eacute,6a,Ecirc,6b,Euml,6c,Igrave,6d,Iacute,6e,Icirc,6f,Iuml,6g,ETH,6h,Ntilde,' +
112
- '6i,Ograve,6j,Oacute,6k,Ocirc,6l,Otilde,6m,Ouml,6n,times,6o,Oslash,6p,Ugrave,6q,Uacute,' +
113
- '6r,Ucirc,6s,Uuml,6t,Yacute,6u,THORN,6v,szlig,70,agrave,71,aacute,72,acirc,73,atilde,74,auml,' +
114
- '75,aring,76,aelig,77,ccedil,78,egrave,79,eacute,7a,ecirc,7b,euml,7c,igrave,7d,iacute,7e,icirc,' +
115
- '7f,iuml,7g,eth,7h,ntilde,7i,ograve,7j,oacute,7k,ocirc,7l,otilde,7m,ouml,7n,divide,7o,oslash,' +
116
- '7p,ugrave,7q,uacute,7r,ucirc,7s,uuml,7t,yacute,7u,thorn,7v,yuml,ci,fnof,sh,Alpha,si,Beta,' +
117
- 'sj,Gamma,sk,Delta,sl,Epsilon,sm,Zeta,sn,Eta,so,Theta,sp,Iota,sq,Kappa,sr,Lambda,ss,Mu,' +
118
- 'st,Nu,su,Xi,sv,Omicron,t0,Pi,t1,Rho,t3,Sigma,t4,Tau,t5,Upsilon,t6,Phi,t7,Chi,t8,Psi,' +
119
- 't9,Omega,th,alpha,ti,beta,tj,gamma,tk,delta,tl,epsilon,tm,zeta,tn,eta,to,theta,tp,iota,' +
120
- 'tq,kappa,tr,lambda,ts,mu,tt,nu,tu,xi,tv,omicron,u0,pi,u1,rho,u2,sigmaf,u3,sigma,u4,tau,' +
121
- 'u5,upsilon,u6,phi,u7,chi,u8,psi,u9,omega,uh,thetasym,ui,upsih,um,piv,812,bull,816,hellip,' +
122
- '81i,prime,81j,Prime,81u,oline,824,frasl,88o,weierp,88h,image,88s,real,892,trade,89l,alefsym,' +
123
- '8cg,larr,8ch,uarr,8ci,rarr,8cj,darr,8ck,harr,8dl,crarr,8eg,lArr,8eh,uArr,8ei,rArr,8ej,dArr,' +
124
- '8ek,hArr,8g0,forall,8g2,part,8g3,exist,8g5,empty,8g7,nabla,8g8,isin,8g9,notin,8gb,ni,8gf,prod,' +
125
- '8gh,sum,8gi,minus,8gn,lowast,8gq,radic,8gt,prop,8gu,infin,8h0,ang,8h7,and,8h8,or,8h9,cap,8ha,cup,' +
126
- '8hb,int,8hk,there4,8hs,sim,8i5,cong,8i8,asymp,8j0,ne,8j1,equiv,8j4,le,8j5,ge,8k2,sub,8k3,sup,8k4,' +
127
- 'nsub,8k6,sube,8k7,supe,8kl,oplus,8kn,otimes,8l5,perp,8m5,sdot,8o8,lceil,8o9,rceil,8oa,lfloor,8ob,' +
128
- 'rfloor,8p9,lang,8pa,rang,9ea,loz,9j0,spades,9j3,clubs,9j5,hearts,9j6,diams,ai,OElig,aj,oelig,b0,' +
129
- 'Scaron,b1,scaron,bo,Yuml,m6,circ,ms,tilde,802,ensp,803,emsp,809,thinsp,80c,zwnj,80d,zwj,80e,lrm,' +
130
- '80f,rlm,80j,ndash,80k,mdash,80o,lsquo,80p,rsquo,80q,sbquo,80s,ldquo,80t,rdquo,80u,bdquo,810,dagger,' +
131
- '811,Dagger,81g,permil,81p,lsaquo,81q,rsaquo,85c,euro',
132
- 32,
133
- )
134
-
135
55
  const STYLES: Record<string, any> = {
136
56
  strokeStyle: {
137
57
  svgAttr: 'stroke',
@@ -145,130 +65,48 @@ const STYLES: Record<string, any> = {
145
65
  svg: null,
146
66
  apply: 'fill',
147
67
  },
148
- lineCap: {
149
- svgAttr: 'stroke-linecap',
150
- canvas: 'butt',
151
- svg: 'butt',
152
- apply: 'stroke',
153
- },
154
- lineJoin: {
155
- svgAttr: 'stroke-linejoin',
156
- canvas: 'miter',
157
- svg: 'miter',
158
- apply: 'stroke',
159
- },
160
- miterLimit: {
161
- svgAttr: 'stroke-miterlimit',
162
- canvas: 10,
163
- svg: 4,
164
- apply: 'stroke',
165
- },
166
68
  lineWidth: {
167
69
  svgAttr: 'stroke-width',
168
70
  canvas: 1,
169
71
  svg: 1,
170
72
  apply: 'stroke',
171
73
  },
172
- globalAlpha: {
173
- svgAttr: 'opacity',
174
- canvas: 1,
175
- svg: 1,
176
- apply: 'fill stroke',
177
- },
178
74
  font: {
179
75
  canvas: '10px sans-serif',
180
76
  },
181
- shadowColor: {
182
- canvas: '#000000',
183
- },
184
- shadowOffsetX: {
185
- canvas: 0,
186
- },
187
- shadowOffsetY: {
188
- canvas: 0,
189
- },
190
- shadowBlur: {
191
- canvas: 0,
192
- },
193
77
  textAlign: {
194
78
  canvas: 'start',
195
79
  },
196
80
  textBaseline: {
197
81
  canvas: 'alphabetic',
198
82
  },
83
+ // null on both sides so the canvas and svg defaults compare equal and strokes
84
+ // carry no empty stroke-dasharray
199
85
  lineDash: {
200
86
  svgAttr: 'stroke-dasharray',
201
- canvas: [],
87
+ canvas: null,
202
88
  svg: null,
203
89
  apply: 'stroke',
204
90
  },
205
91
  }
206
92
 
207
- class CanvasGradient {
208
- __root: SVGElement
209
- __ctx: Context
210
-
211
- constructor(gradientNode: SVGElement, ctx: Context) {
212
- this.__root = gradientNode
213
- this.__ctx = ctx
214
- }
215
-
216
- addColorStop(offset: number, color: string) {
217
- const stop = this.__ctx.__createElement('stop')
218
- stop.setAttribute('offset', String(offset))
219
- if (toString(color).includes('rgba')) {
220
- const regex =
221
- /rgba\(\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d?\.?\d*)\s*\)/gi
222
- const matches = regex.exec(color)
223
- if (matches) {
224
- stop.setAttribute(
225
- 'stop-color',
226
- format('rgb({r},{g},{b})', {
227
- r: matches[1],
228
- g: matches[2],
229
- b: matches[3],
230
- }),
231
- )
232
- stop.setAttribute('stop-opacity', matches[4]!)
233
- }
234
- } else {
235
- stop.setAttribute('stop-color', toString(color))
236
- }
237
- this.__root.appendChild(stop)
238
- }
239
- }
240
-
241
- class CanvasPattern {
242
- __root: SVGElement
243
- __ctx: Context
244
-
245
- constructor(pattern: SVGElement, ctx: Context) {
246
- this.__root = pattern
247
- this.__ctx = ctx
248
- }
249
- }
93
+ const rgbaRegex =
94
+ /rgba\(\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d?\.?\d*)\s*\)/i
250
95
 
251
96
  interface ContextOptions {
252
- width?: number
253
- height?: number
254
- enableMirroring?: boolean
255
97
  document?: Document
256
98
  ctx?: CanvasRenderingContext2D
257
- debug?: boolean
258
99
  }
259
100
 
260
101
  export class Context {
261
102
  width: number
262
103
  height: number
263
- enableMirroring: boolean
264
104
  canvas: Context
265
105
 
266
106
  __document: Document
267
107
  __ctx: CanvasRenderingContext2D
268
108
  __canvas?: HTMLCanvasElement
269
109
  __root: SVGSVGElement
270
- __ids: Record<string, string>
271
- __defs: SVGDefsElement
272
110
  __currentElement: SVGElement
273
111
  __styleStack: any[]
274
112
  __groupStack: SVGElement[]
@@ -276,50 +114,21 @@ export class Context {
276
114
  __currentPosition: { x?: number; y?: number }
277
115
  __transformMatrix: DOMMatrix
278
116
  __transformMatrixStack?: DOMMatrix[]
279
- __currentElementsToStyle?: { element: SVGElement; children: SVGElement[] }
280
- __options: ContextOptions
281
- __id: string
282
- __fontUnderline?: string
283
- __fontHref?: string
117
+ // parsed fonts keyed by `font` string; each parse costs a DOM element and a
118
+ // CSS parse, and exports ask per glyph
119
+ __fontCache = new Map<string, CSSStyleDeclaration>()
284
120
 
285
- // Style properties
286
121
  strokeStyle: any
287
122
  fillStyle: any
288
- lineCap: any
289
- lineJoin: any
290
- miterLimit: any
291
123
  lineWidth: any
292
- globalAlpha: any
293
124
  font: any
294
- shadowColor: any
295
- shadowOffsetX: any
296
- shadowOffsetY: any
297
- shadowBlur: any
298
125
  textAlign: any
299
126
  textBaseline: any
300
127
  lineDash: any
301
128
 
302
- constructor(o?: ContextOptions | number, height?: number) {
303
- const defaultOptions = { width: 500, height: 500, enableMirroring: false }
304
- let options: ContextOptions
305
-
306
- if (typeof o === 'number' && height !== undefined) {
307
- options = { ...defaultOptions, width: o, height }
308
- } else if (!o) {
309
- options = defaultOptions
310
- } else if (typeof o === 'object') {
311
- options = o
312
- } else {
313
- options = defaultOptions
314
- }
315
-
316
- this.width = options.width || defaultOptions.width
317
- this.height = options.height || defaultOptions.height
318
- this.enableMirroring =
319
- options.enableMirroring !== undefined
320
- ? options.enableMirroring
321
- : defaultOptions.enableMirroring
322
-
129
+ constructor(width: number, height: number, options: ContextOptions = {}) {
130
+ this.width = width
131
+ this.height = height
323
132
  this.canvas = this
324
133
  this.__document = options.document || document
325
134
 
@@ -340,21 +149,8 @@ export class Context {
340
149
  )
341
150
  this.__root.setAttribute('version', '1.1')
342
151
  this.__root.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
343
- this.__root.setAttributeNS(
344
- 'http://www.w3.org/2000/xmlns/',
345
- 'xmlns:xlink',
346
- 'http://www.w3.org/1999/xlink',
347
- )
348
- this.__root.setAttribute('width', String(this.width))
349
- this.__root.setAttribute('height', String(this.height))
350
-
351
- this.__ids = {}
352
-
353
- this.__defs = this.__document.createElementNS(
354
- 'http://www.w3.org/2000/svg',
355
- 'defs',
356
- )
357
- this.__root.appendChild(this.__defs)
152
+ this.__root.setAttribute('width', String(width))
153
+ this.__root.setAttribute('height', String(height))
358
154
 
359
155
  this.__currentElement = this.__document.createElementNS(
360
156
  'http://www.w3.org/2000/svg',
@@ -367,9 +163,6 @@ export class Context {
367
163
  this.__transformMatrix = new DOMMatrix()
368
164
 
369
165
  this.resetTransform()
370
-
371
- this.__options = options
372
- this.__id = Math.random().toString(16).substring(2, 8)
373
166
  }
374
167
 
375
168
  __createElement(
@@ -382,14 +175,15 @@ export class Context {
382
175
  elementName,
383
176
  )
384
177
  if (resetFill) {
178
+ // placeholders: fill()/stroke() overwrite their own attribute, and
179
+ // __applyStyleToCurrentElement drops the other when it is the svg default
385
180
  element.setAttribute('fill', 'none')
386
181
  element.setAttribute('stroke', 'none')
387
182
  }
388
183
  for (const key of Object.keys(properties)) {
389
- // an unset property means "no such attribute", not the string
390
- // "undefined". __applyText passes text-decoration through unconditionally,
391
- // so without this every glyph in an export carries
392
- // text-decoration="undefined" -- invalid, and ~28 wasted bytes per letter
184
+ // __applyText passes optional attributes through unconditionally; skipping
185
+ // unset ones keeps an invalid text-decoration="undefined" (~28 bytes) off
186
+ // every glyph
393
187
  const value = properties[key]
394
188
  if (value !== undefined && value !== null) {
395
189
  element.setAttribute(key, value)
@@ -418,91 +212,70 @@ export class Context {
418
212
  return styleState
419
213
  }
420
214
 
215
+ /**
216
+ * A pure translation folds into the element's x/y, saving ~35 bytes per
217
+ * glyph. Any other transform, such as a sequence-logo letter's scale, is
218
+ * written as a matrix.
219
+ */
421
220
  __applyTransformation(element: SVGElement, matrix?: DOMMatrix) {
422
221
  const { a, b, c, d, e, f } = matrix || this.getTransform()
222
+ if (
223
+ a === 1 &&
224
+ b === 0 &&
225
+ c === 0 &&
226
+ d === 1 &&
227
+ element.hasAttribute('x') &&
228
+ element.hasAttribute('y')
229
+ ) {
230
+ if (e !== 0) {
231
+ element.setAttribute('x', String(Number(element.getAttribute('x')) + e))
232
+ }
233
+ if (f !== 0) {
234
+ element.setAttribute('y', String(Number(element.getAttribute('y')) + f))
235
+ }
236
+ return
237
+ }
423
238
  element.setAttribute('transform', `matrix(${a} ${b} ${c} ${d} ${e} ${f})`)
424
239
  }
425
240
 
426
241
  __applyStyleToCurrentElement(type: string) {
427
- let currentElement = this.__currentElement
428
- const currentStyleGroup = this.__currentElementsToStyle
429
- if (currentStyleGroup) {
430
- currentElement.setAttribute(type, '')
431
- currentElement = currentStyleGroup.element
432
- for (const node of currentStyleGroup.children) {
433
- node.setAttribute(type, '')
434
- }
435
- }
242
+ const currentElement = this.__currentElement
436
243
 
437
- const keys = Object.keys(STYLES)
438
- for (const key of keys) {
244
+ for (const key of Object.keys(STYLES)) {
439
245
  const style = STYLES[key]
440
246
  const value = (this as any)[key]
441
- if (style.apply) {
442
- if (value instanceof CanvasPattern) {
443
- if (value.__ctx) {
444
- for (const node of Array.from(value.__ctx.__defs.childNodes)) {
445
- const id = (node as Element).getAttribute('id')
446
- if (id) {
447
- this.__ids[id] = id
448
- this.__defs.appendChild(node)
449
- }
450
- }
451
- }
452
- currentElement.setAttribute(
453
- style.apply,
454
- format('url(#{id})', { id: value.__root.getAttribute('id') }),
455
- )
456
- } else if (value instanceof CanvasGradient) {
457
- currentElement.setAttribute(
458
- style.apply,
459
- format('url(#{id})', { id: value.__root.getAttribute('id') }),
460
- )
461
- } else if (style.apply.includes(type) && style.svg !== value) {
462
- if (
463
- (style.svgAttr === 'stroke' || style.svgAttr === 'fill') &&
464
- value &&
465
- value.includes('rgba')
466
- ) {
467
- const regex =
468
- /rgba\(\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d?\.?\d*)\s*\)/gi
469
- const matches = regex.exec(value)
470
- if (matches) {
471
- currentElement.setAttribute(
472
- style.svgAttr,
473
- format('rgb({r},{g},{b})', {
474
- r: matches[1],
475
- g: matches[2],
476
- b: matches[3],
477
- }),
478
- )
479
- let opacity = Number(matches[4])
480
- const globalAlpha = this.globalAlpha
481
- if (globalAlpha != null) {
482
- opacity *= globalAlpha
483
- }
484
- currentElement.setAttribute(
485
- style.svgAttr + '-opacity',
486
- String(opacity),
487
- )
488
- }
489
- } else {
490
- let attr = style.svgAttr
491
- let val = value
492
- if (key === 'globalAlpha') {
493
- attr = type + '-' + style.svgAttr
494
- if (currentElement.getAttribute(attr)) {
495
- continue
496
- }
497
- } else if (key === 'lineWidth') {
498
- const scale = this.__getTransformScale()
499
- val = value * Math.max(scale.x, scale.y)
500
- }
501
- currentElement.setAttribute(attr, val)
502
- }
503
- }
247
+ if (!style.apply?.includes(type) || style.svg === value) {
248
+ continue
249
+ }
250
+ const matches =
251
+ (style.svgAttr === 'stroke' || style.svgAttr === 'fill') &&
252
+ typeof value === 'string' &&
253
+ value.includes('rgba')
254
+ ? rgbaRegex.exec(value)
255
+ : null
256
+ if (matches) {
257
+ // SVG has no rgba(); the alpha goes in the -opacity attribute
258
+ currentElement.setAttribute(
259
+ style.svgAttr,
260
+ `rgb(${matches[1]},${matches[2]},${matches[3]})`,
261
+ )
262
+ currentElement.setAttribute(`${style.svgAttr}-opacity`, matches[4]!)
263
+ } else if (key === 'lineWidth') {
264
+ const scale = this.__getTransformScale()
265
+ currentElement.setAttribute(
266
+ style.svgAttr,
267
+ String(value * Math.max(scale.x, scale.y)),
268
+ )
269
+ } else {
270
+ currentElement.setAttribute(style.svgAttr, value)
504
271
  }
505
272
  }
273
+
274
+ // svg defaults stroke to none, so a fill drops stroke="none". Fill defaults
275
+ // to black, so a stroked element keeps fill="none".
276
+ if (type === 'fill' && currentElement.getAttribute('stroke') === 'none') {
277
+ currentElement.removeAttribute('stroke')
278
+ }
506
279
  }
507
280
 
508
281
  __closestGroupOrSvg(node?: SVGElement): SVGElement {
@@ -513,29 +286,6 @@ export class Context {
513
286
  return this.__closestGroupOrSvg(node.parentNode as SVGElement)
514
287
  }
515
288
 
516
- getSerializedSvg(fixNamedEntities?: boolean): string {
517
- let serialized = new XMLSerializer().serializeToString(this.__root)
518
- const xmlns =
519
- /xmlns="http:\/\/www\.w3\.org\/2000\/svg".+xmlns="http:\/\/www\.w3\.org\/2000\/svg/gi
520
- if (xmlns.test(serialized)) {
521
- serialized = serialized.replace(
522
- 'xmlns="http://www.w3.org/2000/svg',
523
- 'xmlns:xlink="http://www.w3.org/1999/xlink',
524
- )
525
- }
526
-
527
- if (fixNamedEntities) {
528
- for (const key of Object.keys(namedEntities)) {
529
- const regexp = new RegExp(key, 'gi')
530
- if (regexp.test(serialized)) {
531
- serialized = serialized.replace(regexp, namedEntities[key]!)
532
- }
533
- }
534
- }
535
-
536
- return serialized
537
- }
538
-
539
289
  getSvg(): SVGSVGElement {
540
290
  return this.__root
541
291
  }
@@ -546,8 +296,7 @@ export class Context {
546
296
  this.__groupStack.push(parent)
547
297
  parent.appendChild(group)
548
298
  this.__currentElement = group
549
- const style = this.__getStyleState()
550
- this.__styleStack.push(style)
299
+ this.__styleStack.push(this.__getStyleState())
551
300
  if (!this.__transformMatrixStack) {
552
301
  this.__transformMatrixStack = []
553
302
  }
@@ -556,12 +305,10 @@ export class Context {
556
305
 
557
306
  restore() {
558
307
  this.__currentElement = this.__groupStack.pop()!
559
- this.__currentElementsToStyle = undefined
560
308
  if (!this.__currentElement) {
561
- this.__currentElement = this.__root.childNodes[1] as SVGElement
309
+ this.__currentElement = this.__root.childNodes[0] as SVGElement
562
310
  }
563
- const state = this.__styleStack.pop()
564
- this.__applyStyleState(state)
311
+ this.__applyStyleState(this.__styleStack.pop())
565
312
  if (this.__transformMatrixStack && this.__transformMatrixStack.length > 0) {
566
313
  this.setTransform(this.__transformMatrixStack.pop()!)
567
314
  }
@@ -571,15 +318,13 @@ export class Context {
571
318
  this.__currentDefaultPath = ''
572
319
  this.__currentPosition = {}
573
320
  const path = this.__createElement('path', {}, true)
574
- const parent = this.__closestGroupOrSvg()
575
- parent.appendChild(path)
321
+ this.__closestGroupOrSvg().appendChild(path)
576
322
  this.__currentElement = path
577
323
  }
578
324
 
579
325
  __applyCurrentDefaultPath() {
580
- const currentElement = this.__currentElement
581
- if (currentElement.nodeName === 'path') {
582
- currentElement.setAttribute('d', this.__currentDefaultPath)
326
+ if (this.__currentElement.nodeName === 'path') {
327
+ this.__currentElement.setAttribute('d', this.__currentDefaultPath)
583
328
  }
584
329
  }
585
330
 
@@ -593,12 +338,8 @@ export class Context {
593
338
  this.beginPath()
594
339
  }
595
340
  this.__currentPosition = { x, y }
596
- this.__addPathCommand(
597
- format('M {x} {y}', {
598
- x: this.__matrixTransform(x, y).x,
599
- y: this.__matrixTransform(x, y).y,
600
- }),
601
- )
341
+ const p = this.__matrixTransform(x, y)
342
+ this.__addPathCommand(`M ${p.x} ${p.y}`)
602
343
  }
603
344
 
604
345
  closePath() {
@@ -609,112 +350,51 @@ export class Context {
609
350
 
610
351
  lineTo(x: number, y: number) {
611
352
  this.__currentPosition = { x, y }
612
- if (this.__currentDefaultPath.includes('M')) {
613
- this.__addPathCommand(
614
- format('L {x} {y}', {
615
- x: this.__matrixTransform(x, y).x,
616
- y: this.__matrixTransform(x, y).y,
617
- }),
618
- )
619
- } else {
620
- this.__addPathCommand(
621
- format('M {x} {y}', {
622
- x: this.__matrixTransform(x, y).x,
623
- y: this.__matrixTransform(x, y).y,
624
- }),
625
- )
626
- }
627
- }
628
-
629
- bezierCurveTo(
630
- cp1x: number,
631
- cp1y: number,
632
- cp2x: number,
633
- cp2y: number,
634
- x: number,
635
- y: number,
636
- ) {
637
- this.__currentPosition = { x, y }
353
+ const p = this.__matrixTransform(x, y)
638
354
  this.__addPathCommand(
639
- format('C {cp1x} {cp1y} {cp2x} {cp2y} {x} {y}', {
640
- cp1x: this.__matrixTransform(cp1x, cp1y).x,
641
- cp1y: this.__matrixTransform(cp1x, cp1y).y,
642
- cp2x: this.__matrixTransform(cp2x, cp2y).x,
643
- cp2y: this.__matrixTransform(cp2x, cp2y).y,
644
- x: this.__matrixTransform(x, y).x,
645
- y: this.__matrixTransform(x, y).y,
646
- }),
355
+ this.__currentDefaultPath.includes('M')
356
+ ? `L ${p.x} ${p.y}`
357
+ : `M ${p.x} ${p.y}`,
647
358
  )
648
359
  }
649
360
 
650
361
  quadraticCurveTo(cpx: number, cpy: number, x: number, y: number) {
651
362
  this.__currentPosition = { x, y }
652
- this.__addPathCommand(
653
- format('Q {cpx} {cpy} {x} {y}', {
654
- cpx: this.__matrixTransform(cpx, cpy).x,
655
- cpy: this.__matrixTransform(cpx, cpy).y,
656
- x: this.__matrixTransform(x, y).x,
657
- y: this.__matrixTransform(x, y).y,
658
- }),
659
- )
363
+ const cp = this.__matrixTransform(cpx, cpy)
364
+ const p = this.__matrixTransform(x, y)
365
+ this.__addPathCommand(`Q ${cp.x} ${cp.y} ${p.x} ${p.y}`)
660
366
  }
661
367
 
662
- stroke() {
663
- if (this.__currentElement.nodeName === 'path') {
664
- this.__currentElement.setAttribute('paint-order', 'fill stroke markers')
368
+ // paint-order matters only on an element with both fill and stroke: the
369
+ // collapsed-clade triangle
370
+ __applyPaintOrder(order: string, other: string) {
371
+ const current = this.__currentElement
372
+ if (
373
+ current.nodeName === 'path' &&
374
+ current.hasAttribute(other) &&
375
+ current.getAttribute(other) !== 'none'
376
+ ) {
377
+ current.setAttribute('paint-order', order)
665
378
  }
379
+ }
380
+
381
+ stroke() {
382
+ this.__applyPaintOrder('fill stroke markers', 'fill')
666
383
  this.__applyCurrentDefaultPath()
667
384
  this.__applyStyleToCurrentElement('stroke')
668
385
  }
669
386
 
670
387
  fill() {
671
- if (this.__currentElement.nodeName === 'path') {
672
- this.__currentElement.setAttribute('paint-order', 'stroke fill markers')
673
- }
388
+ this.__applyPaintOrder('stroke fill markers', 'stroke')
674
389
  this.__applyCurrentDefaultPath()
675
390
  this.__applyStyleToCurrentElement('fill')
676
391
  }
677
392
 
678
- rect(x: number, y: number, width: number, height: number) {
679
- if (this.__currentElement.nodeName !== 'path') {
680
- this.beginPath()
681
- }
682
- this.moveTo(x, y)
683
- this.lineTo(x + width, y)
684
- this.lineTo(x + width, y + height)
685
- this.lineTo(x, y + height)
686
- this.lineTo(x, y)
687
- this.closePath()
688
- }
689
-
690
- __clearCanvas() {
691
- const rootGroup = this.__root.childNodes[1]!
692
- this.__root.removeChild(rootGroup)
693
- this.__currentElement = this.__document.createElementNS(
694
- 'http://www.w3.org/2000/svg',
695
- 'g',
696
- )
697
- this.__root.appendChild(this.__currentElement)
698
- this.__groupStack = []
699
- }
700
-
393
+ // A fill covering the whole context paints over what is under it, as on a
394
+ // canvas; it does not clear the drawing.
701
395
  fillRect(x: number, y: number, width: number, height: number) {
702
- const { a, b, c, d, e, f } = this.getTransform()
703
- if (
704
- JSON.stringify([a, b, c, d, e, f]) === JSON.stringify([1, 0, 0, 1, 0, 0])
705
- ) {
706
- if (
707
- x === 0 &&
708
- y === 0 &&
709
- width === this.width &&
710
- height === this.height
711
- ) {
712
- this.__clearCanvas()
713
- }
714
- }
715
396
  const rect = this.__createElement('rect', { x, y, width, height }, true)
716
- const parent = this.__closestGroupOrSvg()
717
- parent.appendChild(rect)
397
+ this.__closestGroupOrSvg().appendChild(rect)
718
398
  this.__currentElement = rect
719
399
  this.__applyTransformation(rect)
720
400
  this.__applyStyleToCurrentElement('fill')
@@ -722,8 +402,7 @@ export class Context {
722
402
 
723
403
  strokeRect(x: number, y: number, width: number, height: number) {
724
404
  const rect = this.__createElement('rect', { x, y, width, height }, true)
725
- const parent = this.__closestGroupOrSvg()
726
- parent.appendChild(rect)
405
+ this.__closestGroupOrSvg().appendChild(rect)
727
406
  this.__currentElement = rect
728
407
  this.__applyTransformation(rect)
729
408
  this.__applyStyleToCurrentElement('stroke')
@@ -732,17 +411,19 @@ export class Context {
732
411
  clearRect(x: number, y: number, width: number, height: number) {
733
412
  const { a, b, c, d, e, f } = this.getTransform()
734
413
  if (
735
- JSON.stringify([a, b, c, d, e, f]) === JSON.stringify([1, 0, 0, 1, 0, 0])
414
+ a === 1 &&
415
+ b === 0 &&
416
+ c === 0 &&
417
+ d === 1 &&
418
+ e === 0 &&
419
+ f === 0 &&
420
+ x === 0 &&
421
+ y === 0 &&
422
+ width === this.width &&
423
+ height === this.height
736
424
  ) {
737
- if (
738
- x === 0 &&
739
- y === 0 &&
740
- width === this.width &&
741
- height === this.height
742
- ) {
743
- this.__clearCanvas()
744
- return
745
- }
425
+ this.__clearCanvas()
426
+ return
746
427
  }
747
428
  const rect = this.__createElement(
748
429
  'rect',
@@ -750,54 +431,32 @@ export class Context {
750
431
  true,
751
432
  )
752
433
  this.__applyTransformation(rect)
753
- const parent = this.__closestGroupOrSvg()
754
- parent.appendChild(rect)
434
+ this.__closestGroupOrSvg().appendChild(rect)
755
435
  }
756
436
 
757
- createLinearGradient(
758
- x1: number,
759
- y1: number,
760
- x2: number,
761
- y2: number,
762
- ): CanvasGradient {
763
- const grad = this.__createElement('linearGradient', {
764
- id: randomString(this.__ids),
765
- x1: x1 + 'px',
766
- x2: x2 + 'px',
767
- y1: y1 + 'px',
768
- y2: y2 + 'px',
769
- gradientUnits: 'userSpaceOnUse',
770
- })
771
- this.__defs.appendChild(grad)
772
- return new CanvasGradient(grad, this)
773
- }
774
-
775
- createRadialGradient(
776
- x0: number,
777
- y0: number,
778
- r0: number,
779
- x1: number,
780
- y1: number,
781
- r1: number,
782
- ): CanvasGradient {
783
- const grad = this.__createElement('radialGradient', {
784
- id: randomString(this.__ids),
785
- cx: x1 + 'px',
786
- cy: y1 + 'px',
787
- r: r1 + 'px',
788
- fx: x0 + 'px',
789
- fy: y0 + 'px',
790
- gradientUnits: 'userSpaceOnUse',
791
- })
792
- this.__defs.appendChild(grad)
793
- return new CanvasGradient(grad, this)
437
+ __clearCanvas() {
438
+ this.__root.removeChild(this.__root.childNodes[0]!)
439
+ this.__currentElement = this.__document.createElementNS(
440
+ 'http://www.w3.org/2000/svg',
441
+ 'g',
442
+ )
443
+ this.__root.appendChild(this.__currentElement)
444
+ this.__groupStack = []
794
445
  }
795
446
 
796
- __applyText(text: string, x: number, y: number, action: string) {
447
+ __parsedFont() {
448
+ const hit = this.__fontCache.get(this.font)
449
+ if (hit) {
450
+ return hit
451
+ }
797
452
  const el = document.createElement('span')
798
- el.setAttribute('style', 'font:' + this.font)
453
+ el.setAttribute('style', `font:${this.font}`)
454
+ this.__fontCache.set(this.font, el.style)
455
+ return el.style
456
+ }
799
457
 
800
- const style = el.style
458
+ fillText(text: string, x: number, y: number) {
459
+ const style = this.__parsedFont()
801
460
  const parent = this.__closestGroupOrSvg()
802
461
  const textElement = this.__createElement(
803
462
  'text',
@@ -806,9 +465,8 @@ export class Context {
806
465
  'font-size': style.fontSize,
807
466
  'font-style': omitDefault('font-style', style.fontStyle),
808
467
  'font-weight': omitDefault('font-weight', style.fontWeight),
809
- 'text-decoration': this.__fontUnderline,
810
- x: x,
811
- y: y,
468
+ x,
469
+ y,
812
470
  'text-anchor': getTextAnchor(this.textAlign),
813
471
  'dominant-baseline': omitDefault(
814
472
  'dominant-baseline',
@@ -821,29 +479,8 @@ export class Context {
821
479
  textElement.appendChild(this.__document.createTextNode(text))
822
480
  this.__currentElement = textElement
823
481
  this.__applyTransformation(textElement)
824
- this.__applyStyleToCurrentElement(action)
825
-
826
- let finalElement: SVGElement = textElement
827
- if (this.__fontHref) {
828
- const a = this.__createElement('a')
829
- a.setAttributeNS(
830
- 'http://www.w3.org/1999/xlink',
831
- 'xlink:href',
832
- this.__fontHref,
833
- )
834
- a.appendChild(textElement)
835
- finalElement = a
836
- }
837
-
838
- parent.appendChild(finalElement)
839
- }
840
-
841
- fillText(text: string, x: number, y: number) {
842
- this.__applyText(text, x, y, 'fill')
843
- }
844
-
845
- strokeText(text: string, x: number, y: number) {
846
- this.__applyText(text, x, y, 'stroke')
482
+ this.__applyStyleToCurrentElement('fill')
483
+ parent.appendChild(textElement)
847
484
  }
848
485
 
849
486
  measureText(text: string): TextMetrics {
@@ -874,71 +511,30 @@ export class Context {
874
511
  const startX = x + radius * Math.cos(startAngle)
875
512
  const startY = y + radius * Math.sin(startAngle)
876
513
  const sweepFlag = counterClockwise ? 0 : 1
877
- let largeArcFlag = 0
878
514
  let diff = endAngle - startAngle
879
-
880
515
  if (diff < 0) {
881
516
  diff += 2 * Math.PI
882
517
  }
518
+ const largeArcFlag = counterClockwise
519
+ ? diff > Math.PI
520
+ ? 0
521
+ : 1
522
+ : diff > Math.PI
523
+ ? 1
524
+ : 0
883
525
 
884
- if (counterClockwise) {
885
- largeArcFlag = diff > Math.PI ? 0 : 1
886
- } else {
887
- largeArcFlag = diff > Math.PI ? 1 : 0
888
- }
889
-
890
- const scaleX = Math.hypot(
891
- this.__transformMatrix.a,
892
- this.__transformMatrix.b,
893
- )
894
- const scaleY = Math.hypot(
895
- this.__transformMatrix.c,
896
- this.__transformMatrix.d,
897
- )
526
+ const { x: scaleX, y: scaleY } = this.__getTransformScale()
527
+ const end = this.__matrixTransform(endX, endY)
898
528
 
899
529
  this.lineTo(startX, startY)
900
530
  this.__addPathCommand(
901
- format(
902
- 'A {rx} {ry} {xAxisRotation} {largeArcFlag} {sweepFlag} {endX} {endY}',
903
- {
904
- rx: radius * scaleX,
905
- ry: radius * scaleY,
906
- xAxisRotation: 0,
907
- largeArcFlag,
908
- sweepFlag,
909
- endX: this.__matrixTransform(endX, endY).x,
910
- endY: this.__matrixTransform(endX, endY).y,
911
- },
912
- ),
531
+ `A ${radius * scaleX} ${radius * scaleY} 0 ${largeArcFlag} ${sweepFlag} ${end.x} ${end.y}`,
913
532
  )
914
-
915
533
  this.__currentPosition = { x: endX, y: endY }
916
534
  }
917
535
 
918
- clip() {
919
- const group = this.__closestGroupOrSvg()
920
- const clipPath = this.__createElement('clipPath')
921
- const id = randomString(this.__ids)
922
- const newGroup = this.__createElement('g')
923
-
924
- this.__applyCurrentDefaultPath()
925
- group.removeChild(this.__currentElement)
926
- clipPath.setAttribute('id', id)
927
- clipPath.appendChild(this.__currentElement)
928
-
929
- this.__defs.appendChild(clipPath)
930
- group.setAttribute('clip-path', format('url(#{id})', { id }))
931
- group.appendChild(newGroup)
932
-
933
- this.__currentElement = newGroup
934
- }
935
-
936
536
  setLineDash(dashArray: number[]) {
937
- if (dashArray && dashArray.length > 0) {
938
- this.lineDash = dashArray.join(',')
939
- } else {
940
- this.lineDash = null
941
- }
537
+ this.lineDash = dashArray.length > 0 ? dashArray.join(',') : null
942
538
  }
943
539
 
944
540
  setTransform(
@@ -949,11 +545,10 @@ export class Context {
949
545
  e?: number,
950
546
  f?: number,
951
547
  ) {
952
- if (a instanceof DOMMatrix) {
953
- this.__transformMatrix = new DOMMatrix([a.a, a.b, a.c, a.d, a.e, a.f])
954
- } else {
955
- this.__transformMatrix = new DOMMatrix([a, b!, c!, d!, e!, f!])
956
- }
548
+ this.__transformMatrix =
549
+ a instanceof DOMMatrix
550
+ ? new DOMMatrix([a.a, a.b, a.c, a.d, a.e, a.f])
551
+ : new DOMMatrix([a, b!, c!, d!, e!, f!])
957
552
  }
958
553
 
959
554
  getTransform(): DOMMatrix {
@@ -972,34 +567,11 @@ export class Context {
972
567
  if (isNaN(x) || isNaN(y) || !isFinite(x) || !isFinite(y)) {
973
568
  return
974
569
  }
975
- const matrix = this.getTransform().scale(x, y)
976
- this.setTransform(matrix)
977
- }
978
-
979
- rotate(angle: number) {
980
- const matrix = this.getTransform().multiply(
981
- new DOMMatrix([
982
- Math.cos(angle),
983
- Math.sin(angle),
984
- -Math.sin(angle),
985
- Math.cos(angle),
986
- 0,
987
- 0,
988
- ]),
989
- )
990
- this.setTransform(matrix)
570
+ this.setTransform(this.getTransform().scale(x, y))
991
571
  }
992
572
 
993
573
  translate(x: number, y: number) {
994
- const matrix = this.getTransform().translate(x, y)
995
- this.setTransform(matrix)
996
- }
997
-
998
- transform(a: number, b: number, c: number, d: number, e: number, f: number) {
999
- const matrix = this.getTransform().multiply(
1000
- new DOMMatrix([a, b, c, d, e, f]),
1001
- )
1002
- this.setTransform(matrix)
574
+ this.setTransform(this.getTransform().translate(x, y))
1003
575
  }
1004
576
 
1005
577
  __matrixTransform(x: number, y: number): DOMPoint {
@@ -1012,72 +584,4 @@ export class Context {
1012
584
  y: Math.hypot(this.__transformMatrix.c, this.__transformMatrix.d),
1013
585
  }
1014
586
  }
1015
-
1016
- __getTransformRotation(): number {
1017
- return Math.atan2(this.__transformMatrix.b, this.__transformMatrix.a)
1018
- }
1019
-
1020
- /**
1021
- * An <image> of the source, cropped and scaled the way the canvas call asks:
1022
- * a nested <svg> whose viewBox is the source rectangle maps it onto the
1023
- * destination rectangle exactly. Throws rather than dropping the image, so a
1024
- * renderer that draws one into an export finds out.
1025
- */
1026
- drawImage(image: unknown, ...args: number[]) {
1027
- const source = image as {
1028
- width: number
1029
- height: number
1030
- toDataURL?: (type?: string) => string
1031
- }
1032
- if (typeof source.toDataURL !== 'function') {
1033
- throw new TypeError(
1034
- 'svgcanvas drawImage needs a source with toDataURL (a canvas); an OffscreenCanvas has to be copied onto one first',
1035
- )
1036
- }
1037
- const [sx, sy, sw, sh, dx, dy, dw, dh] =
1038
- args.length >= 8
1039
- ? args
1040
- : [
1041
- 0,
1042
- 0,
1043
- source.width,
1044
- source.height,
1045
- args[0],
1046
- args[1],
1047
- args[2] ?? source.width,
1048
- args[3] ?? source.height,
1049
- ]
1050
- const group = this.__createElement('g')
1051
- const viewport = this.__createElement('svg', {
1052
- x: dx,
1053
- y: dy,
1054
- width: dw,
1055
- height: dh,
1056
- viewBox: `${sx} ${sy} ${sw} ${sh}`,
1057
- preserveAspectRatio: 'none',
1058
- })
1059
- const img = this.__createElement('image', {
1060
- href: source.toDataURL('image/png'),
1061
- width: source.width,
1062
- height: source.height,
1063
- preserveAspectRatio: 'none',
1064
- 'image-rendering': this.imageSmoothingEnabled ? undefined : 'pixelated',
1065
- })
1066
- viewport.appendChild(img)
1067
- group.appendChild(viewport)
1068
- this.__closestGroupOrSvg().appendChild(group)
1069
- this.__currentElement = group
1070
- this.__applyTransformation(group)
1071
- }
1072
-
1073
- imageSmoothingEnabled = true
1074
-
1075
- // Stubs for unimplemented methods
1076
- drawFocusRing() {}
1077
- createImageData() {}
1078
- putImageData() {}
1079
- globalCompositeOperation() {}
1080
- createPattern() {
1081
- return null
1082
- }
1083
587
  }