@jbrowse/svgcanvas 6.4.2 → 7.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,15 @@
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 and converted to ESM/TypeScript for pure ESM compatibility, and cut
13
+ * down to the drawing calls the renderers make: what a multiple sequence
14
+ * alignment needs is rectangles, paths, arcs and a great many glyphs. Gradients,
15
+ * patterns, clipping, rotation, bezier curves, stroked text, shadows and image
16
+ * drawing are gone with the state they carried.
13
17
  */
14
18
 
15
19
  /* eslint-disable @typescript-eslint/no-explicit-any */
16
20
 
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
21
  function getTextAnchor(textAlign: string): string {
68
22
  const mapping: Record<string, string> = {
69
23
  left: 'start',
@@ -94,7 +48,6 @@ function getDominantBaseline(textBaseline: string): string {
94
48
  const TEXT_ATTR_DEFAULTS: Record<string, string> = {
95
49
  'font-style': 'normal',
96
50
  'font-weight': 'normal',
97
- 'font-variant': 'normal',
98
51
  // the initial value is `auto`, which resolves to the alphabetic baseline
99
52
  'dominant-baseline': 'alphabetic',
100
53
  }
@@ -103,35 +56,6 @@ function omitDefault(attr: string, value: string | undefined) {
103
56
  return value === TEXT_ATTR_DEFAULTS[attr] ? undefined : value
104
57
  }
105
58
 
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
59
  const STYLES: Record<string, any> = {
136
60
  strokeStyle: {
137
61
  svgAttr: 'stroke',
@@ -145,130 +69,49 @@ const STYLES: Record<string, any> = {
145
69
  svg: null,
146
70
  apply: 'fill',
147
71
  },
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
72
  lineWidth: {
167
73
  svgAttr: 'stroke-width',
168
74
  canvas: 1,
169
75
  svg: 1,
170
76
  apply: 'stroke',
171
77
  },
172
- globalAlpha: {
173
- svgAttr: 'opacity',
174
- canvas: 1,
175
- svg: 1,
176
- apply: 'fill stroke',
177
- },
178
78
  font: {
179
79
  canvas: '10px sans-serif',
180
80
  },
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
81
  textAlign: {
194
82
  canvas: 'start',
195
83
  },
196
84
  textBaseline: {
197
85
  canvas: 'alphabetic',
198
86
  },
87
+ // null rather than an empty array: the canvas default is "no dashes", which is
88
+ // also the svg default, and the two have to compare equal or every stroke
89
+ // carries an empty stroke-dasharray
199
90
  lineDash: {
200
91
  svgAttr: 'stroke-dasharray',
201
- canvas: [],
92
+ canvas: null,
202
93
  svg: null,
203
94
  apply: 'stroke',
204
95
  },
205
96
  }
206
97
 
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
- }
98
+ const rgbaRegex =
99
+ /rgba\(\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d*\.?\d+)\s*,\s*(\d?\.?\d*)\s*\)/i
250
100
 
251
101
  interface ContextOptions {
252
- width?: number
253
- height?: number
254
- enableMirroring?: boolean
255
102
  document?: Document
256
103
  ctx?: CanvasRenderingContext2D
257
- debug?: boolean
258
104
  }
259
105
 
260
106
  export class Context {
261
107
  width: number
262
108
  height: number
263
- enableMirroring: boolean
264
109
  canvas: Context
265
110
 
266
111
  __document: Document
267
112
  __ctx: CanvasRenderingContext2D
268
113
  __canvas?: HTMLCanvasElement
269
114
  __root: SVGSVGElement
270
- __ids: Record<string, string>
271
- __defs: SVGDefsElement
272
115
  __currentElement: SVGElement
273
116
  __styleStack: any[]
274
117
  __groupStack: SVGElement[]
@@ -276,50 +119,22 @@ export class Context {
276
119
  __currentPosition: { x?: number; y?: number }
277
120
  __transformMatrix: DOMMatrix
278
121
  __transformMatrixStack?: DOMMatrix[]
279
- __currentElementsToStyle?: { element: SVGElement; children: SVGElement[] }
280
- __options: ContextOptions
281
- __id: string
282
- __fontUnderline?: string
283
- __fontHref?: string
122
+ // the parsed font, keyed by the `font` string that produced it: parsing costs
123
+ // a DOM element and a CSS parse, and a text-heavy export asks per glyph
124
+ __fontCache = new Map<string, CSSStyleDeclaration>()
284
125
 
285
126
  // Style properties
286
127
  strokeStyle: any
287
128
  fillStyle: any
288
- lineCap: any
289
- lineJoin: any
290
- miterLimit: any
291
129
  lineWidth: any
292
- globalAlpha: any
293
130
  font: any
294
- shadowColor: any
295
- shadowOffsetX: any
296
- shadowOffsetY: any
297
- shadowBlur: any
298
131
  textAlign: any
299
132
  textBaseline: any
300
133
  lineDash: any
301
134
 
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
-
135
+ constructor(width: number, height: number, options: ContextOptions = {}) {
136
+ this.width = width
137
+ this.height = height
323
138
  this.canvas = this
324
139
  this.__document = options.document || document
325
140
 
@@ -340,21 +155,8 @@ export class Context {
340
155
  )
341
156
  this.__root.setAttribute('version', '1.1')
342
157
  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)
158
+ this.__root.setAttribute('width', String(width))
159
+ this.__root.setAttribute('height', String(height))
358
160
 
359
161
  this.__currentElement = this.__document.createElementNS(
360
162
  'http://www.w3.org/2000/svg',
@@ -367,9 +169,6 @@ export class Context {
367
169
  this.__transformMatrix = new DOMMatrix()
368
170
 
369
171
  this.resetTransform()
370
-
371
- this.__options = options
372
- this.__id = Math.random().toString(16).substring(2, 8)
373
172
  }
374
173
 
375
174
  __createElement(
@@ -382,13 +181,16 @@ export class Context {
382
181
  elementName,
383
182
  )
384
183
  if (resetFill) {
184
+ // a placeholder: whichever of fill()/stroke() paints this element
185
+ // overwrites its own, and __applyStyleToCurrentElement drops the other
186
+ // when it is the svg default anyway
385
187
  element.setAttribute('fill', 'none')
386
188
  element.setAttribute('stroke', 'none')
387
189
  }
388
190
  for (const key of Object.keys(properties)) {
389
191
  // 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
192
+ // "undefined". __applyText passes optional attributes through
193
+ // unconditionally, so without this every glyph in an export carries
392
194
  // text-decoration="undefined" -- invalid, and ~28 wasted bytes per letter
393
195
  const value = properties[key]
394
196
  if (value !== undefined && value !== null) {
@@ -418,91 +220,71 @@ export class Context {
418
220
  return styleState
419
221
  }
420
222
 
223
+ /**
224
+ * A translation is what an element's own x/y already say, and repeating it as
225
+ * a matrix costs ~35 bytes on every glyph of an alignment. Anything else --
226
+ * the scale a sequence-logo letter is stretched by -- still needs the matrix.
227
+ */
421
228
  __applyTransformation(element: SVGElement, matrix?: DOMMatrix) {
422
229
  const { a, b, c, d, e, f } = matrix || this.getTransform()
230
+ if (
231
+ a === 1 &&
232
+ b === 0 &&
233
+ c === 0 &&
234
+ d === 1 &&
235
+ element.hasAttribute('x') &&
236
+ element.hasAttribute('y')
237
+ ) {
238
+ if (e !== 0) {
239
+ element.setAttribute('x', String(Number(element.getAttribute('x')) + e))
240
+ }
241
+ if (f !== 0) {
242
+ element.setAttribute('y', String(Number(element.getAttribute('y')) + f))
243
+ }
244
+ return
245
+ }
423
246
  element.setAttribute('transform', `matrix(${a} ${b} ${c} ${d} ${e} ${f})`)
424
247
  }
425
248
 
426
249
  __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
- }
250
+ const currentElement = this.__currentElement
436
251
 
437
- const keys = Object.keys(STYLES)
438
- for (const key of keys) {
252
+ for (const key of Object.keys(STYLES)) {
439
253
  const style = STYLES[key]
440
254
  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
- }
255
+ if (!style.apply?.includes(type) || style.svg === value) {
256
+ continue
257
+ }
258
+ const matches =
259
+ (style.svgAttr === 'stroke' || style.svgAttr === 'fill') &&
260
+ typeof value === 'string' &&
261
+ value.includes('rgba')
262
+ ? rgbaRegex.exec(value)
263
+ : null
264
+ if (matches) {
265
+ // SVG has no rgba(): the alpha belongs in its own -opacity attribute
266
+ currentElement.setAttribute(
267
+ style.svgAttr,
268
+ `rgb(${matches[1]},${matches[2]},${matches[3]})`,
269
+ )
270
+ currentElement.setAttribute(`${style.svgAttr}-opacity`, matches[4]!)
271
+ } else if (key === 'lineWidth') {
272
+ const scale = this.__getTransformScale()
273
+ currentElement.setAttribute(
274
+ style.svgAttr,
275
+ String(value * Math.max(scale.x, scale.y)),
276
+ )
277
+ } else {
278
+ currentElement.setAttribute(style.svgAttr, value)
504
279
  }
505
280
  }
281
+
282
+ // a filled element with no stroke does not have to say so: `none` is what
283
+ // svg assumes. The reverse is not true -- an unstroked fill defaults to
284
+ // black -- so a stroked element keeps its fill="none"
285
+ if (type === 'fill' && currentElement.getAttribute('stroke') === 'none') {
286
+ currentElement.removeAttribute('stroke')
287
+ }
506
288
  }
507
289
 
508
290
  __closestGroupOrSvg(node?: SVGElement): SVGElement {
@@ -513,29 +295,6 @@ export class Context {
513
295
  return this.__closestGroupOrSvg(node.parentNode as SVGElement)
514
296
  }
515
297
 
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
298
  getSvg(): SVGSVGElement {
540
299
  return this.__root
541
300
  }
@@ -546,8 +305,7 @@ export class Context {
546
305
  this.__groupStack.push(parent)
547
306
  parent.appendChild(group)
548
307
  this.__currentElement = group
549
- const style = this.__getStyleState()
550
- this.__styleStack.push(style)
308
+ this.__styleStack.push(this.__getStyleState())
551
309
  if (!this.__transformMatrixStack) {
552
310
  this.__transformMatrixStack = []
553
311
  }
@@ -556,12 +314,10 @@ export class Context {
556
314
 
557
315
  restore() {
558
316
  this.__currentElement = this.__groupStack.pop()!
559
- this.__currentElementsToStyle = undefined
560
317
  if (!this.__currentElement) {
561
- this.__currentElement = this.__root.childNodes[1] as SVGElement
318
+ this.__currentElement = this.__root.childNodes[0] as SVGElement
562
319
  }
563
- const state = this.__styleStack.pop()
564
- this.__applyStyleState(state)
320
+ this.__applyStyleState(this.__styleStack.pop())
565
321
  if (this.__transformMatrixStack && this.__transformMatrixStack.length > 0) {
566
322
  this.setTransform(this.__transformMatrixStack.pop()!)
567
323
  }
@@ -571,15 +327,13 @@ export class Context {
571
327
  this.__currentDefaultPath = ''
572
328
  this.__currentPosition = {}
573
329
  const path = this.__createElement('path', {}, true)
574
- const parent = this.__closestGroupOrSvg()
575
- parent.appendChild(path)
330
+ this.__closestGroupOrSvg().appendChild(path)
576
331
  this.__currentElement = path
577
332
  }
578
333
 
579
334
  __applyCurrentDefaultPath() {
580
- const currentElement = this.__currentElement
581
- if (currentElement.nodeName === 'path') {
582
- currentElement.setAttribute('d', this.__currentDefaultPath)
335
+ if (this.__currentElement.nodeName === 'path') {
336
+ this.__currentElement.setAttribute('d', this.__currentDefaultPath)
583
337
  }
584
338
  }
585
339
 
@@ -593,12 +347,8 @@ export class Context {
593
347
  this.beginPath()
594
348
  }
595
349
  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
- )
350
+ const p = this.__matrixTransform(x, y)
351
+ this.__addPathCommand(`M ${p.x} ${p.y}`)
602
352
  }
603
353
 
604
354
  closePath() {
@@ -609,112 +359,53 @@ export class Context {
609
359
 
610
360
  lineTo(x: number, y: number) {
611
361
  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 }
362
+ const p = this.__matrixTransform(x, y)
638
363
  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
- }),
364
+ this.__currentDefaultPath.includes('M')
365
+ ? `L ${p.x} ${p.y}`
366
+ : `M ${p.x} ${p.y}`,
647
367
  )
648
368
  }
649
369
 
650
370
  quadraticCurveTo(cpx: number, cpy: number, x: number, y: number) {
651
371
  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
- )
372
+ const cp = this.__matrixTransform(cpx, cpy)
373
+ const p = this.__matrixTransform(x, y)
374
+ this.__addPathCommand(`Q ${cp.x} ${cp.y} ${p.x} ${p.y}`)
660
375
  }
661
376
 
662
- stroke() {
663
- if (this.__currentElement.nodeName === 'path') {
664
- this.__currentElement.setAttribute('paint-order', 'fill stroke markers')
377
+ // paint-order only says something when the element carries both a fill and a
378
+ // stroke, which is the collapsed-clade triangle and nothing else
379
+ __applyPaintOrder(order: string, other: string) {
380
+ const current = this.__currentElement
381
+ if (
382
+ current.nodeName === 'path' &&
383
+ current.hasAttribute(other) &&
384
+ current.getAttribute(other) !== 'none'
385
+ ) {
386
+ current.setAttribute('paint-order', order)
665
387
  }
388
+ }
389
+
390
+ stroke() {
391
+ this.__applyPaintOrder('fill stroke markers', 'fill')
666
392
  this.__applyCurrentDefaultPath()
667
393
  this.__applyStyleToCurrentElement('stroke')
668
394
  }
669
395
 
670
396
  fill() {
671
- if (this.__currentElement.nodeName === 'path') {
672
- this.__currentElement.setAttribute('paint-order', 'stroke fill markers')
673
- }
397
+ this.__applyPaintOrder('stroke fill markers', 'stroke')
674
398
  this.__applyCurrentDefaultPath()
675
399
  this.__applyStyleToCurrentElement('fill')
676
400
  }
677
401
 
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
-
402
+ // A fill that happens to cover the context is still a fill: it paints over
403
+ // what is under it, exactly as the canvas would. Treating it as a clear threw
404
+ // the drawing away instead -- a full-width highlight band over an alignment
405
+ // took the export from 124 letters to 4.
701
406
  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
407
  const rect = this.__createElement('rect', { x, y, width, height }, true)
716
- const parent = this.__closestGroupOrSvg()
717
- parent.appendChild(rect)
408
+ this.__closestGroupOrSvg().appendChild(rect)
718
409
  this.__currentElement = rect
719
410
  this.__applyTransformation(rect)
720
411
  this.__applyStyleToCurrentElement('fill')
@@ -722,8 +413,7 @@ export class Context {
722
413
 
723
414
  strokeRect(x: number, y: number, width: number, height: number) {
724
415
  const rect = this.__createElement('rect', { x, y, width, height }, true)
725
- const parent = this.__closestGroupOrSvg()
726
- parent.appendChild(rect)
416
+ this.__closestGroupOrSvg().appendChild(rect)
727
417
  this.__currentElement = rect
728
418
  this.__applyTransformation(rect)
729
419
  this.__applyStyleToCurrentElement('stroke')
@@ -732,17 +422,19 @@ export class Context {
732
422
  clearRect(x: number, y: number, width: number, height: number) {
733
423
  const { a, b, c, d, e, f } = this.getTransform()
734
424
  if (
735
- JSON.stringify([a, b, c, d, e, f]) === JSON.stringify([1, 0, 0, 1, 0, 0])
425
+ a === 1 &&
426
+ b === 0 &&
427
+ c === 0 &&
428
+ d === 1 &&
429
+ e === 0 &&
430
+ f === 0 &&
431
+ x === 0 &&
432
+ y === 0 &&
433
+ width === this.width &&
434
+ height === this.height
736
435
  ) {
737
- if (
738
- x === 0 &&
739
- y === 0 &&
740
- width === this.width &&
741
- height === this.height
742
- ) {
743
- this.__clearCanvas()
744
- return
745
- }
436
+ this.__clearCanvas()
437
+ return
746
438
  }
747
439
  const rect = this.__createElement(
748
440
  'rect',
@@ -750,54 +442,32 @@ export class Context {
750
442
  true,
751
443
  )
752
444
  this.__applyTransformation(rect)
753
- const parent = this.__closestGroupOrSvg()
754
- parent.appendChild(rect)
445
+ this.__closestGroupOrSvg().appendChild(rect)
755
446
  }
756
447
 
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)
448
+ __clearCanvas() {
449
+ this.__root.removeChild(this.__root.childNodes[0]!)
450
+ this.__currentElement = this.__document.createElementNS(
451
+ 'http://www.w3.org/2000/svg',
452
+ 'g',
453
+ )
454
+ this.__root.appendChild(this.__currentElement)
455
+ this.__groupStack = []
794
456
  }
795
457
 
796
- __applyText(text: string, x: number, y: number, action: string) {
458
+ __parsedFont() {
459
+ const hit = this.__fontCache.get(this.font)
460
+ if (hit) {
461
+ return hit
462
+ }
797
463
  const el = document.createElement('span')
798
- el.setAttribute('style', 'font:' + this.font)
464
+ el.setAttribute('style', `font:${this.font}`)
465
+ this.__fontCache.set(this.font, el.style)
466
+ return el.style
467
+ }
799
468
 
800
- const style = el.style
469
+ fillText(text: string, x: number, y: number) {
470
+ const style = this.__parsedFont()
801
471
  const parent = this.__closestGroupOrSvg()
802
472
  const textElement = this.__createElement(
803
473
  'text',
@@ -806,9 +476,8 @@ export class Context {
806
476
  'font-size': style.fontSize,
807
477
  'font-style': omitDefault('font-style', style.fontStyle),
808
478
  'font-weight': omitDefault('font-weight', style.fontWeight),
809
- 'text-decoration': this.__fontUnderline,
810
- x: x,
811
- y: y,
479
+ x,
480
+ y,
812
481
  'text-anchor': getTextAnchor(this.textAlign),
813
482
  'dominant-baseline': omitDefault(
814
483
  'dominant-baseline',
@@ -821,29 +490,8 @@ export class Context {
821
490
  textElement.appendChild(this.__document.createTextNode(text))
822
491
  this.__currentElement = textElement
823
492
  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')
493
+ this.__applyStyleToCurrentElement('fill')
494
+ parent.appendChild(textElement)
847
495
  }
848
496
 
849
497
  measureText(text: string): TextMetrics {
@@ -874,71 +522,30 @@ export class Context {
874
522
  const startX = x + radius * Math.cos(startAngle)
875
523
  const startY = y + radius * Math.sin(startAngle)
876
524
  const sweepFlag = counterClockwise ? 0 : 1
877
- let largeArcFlag = 0
878
525
  let diff = endAngle - startAngle
879
-
880
526
  if (diff < 0) {
881
527
  diff += 2 * Math.PI
882
528
  }
529
+ const largeArcFlag = counterClockwise
530
+ ? diff > Math.PI
531
+ ? 0
532
+ : 1
533
+ : diff > Math.PI
534
+ ? 1
535
+ : 0
883
536
 
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
- )
537
+ const { x: scaleX, y: scaleY } = this.__getTransformScale()
538
+ const end = this.__matrixTransform(endX, endY)
898
539
 
899
540
  this.lineTo(startX, startY)
900
541
  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
- ),
542
+ `A ${radius * scaleX} ${radius * scaleY} 0 ${largeArcFlag} ${sweepFlag} ${end.x} ${end.y}`,
913
543
  )
914
-
915
544
  this.__currentPosition = { x: endX, y: endY }
916
545
  }
917
546
 
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
547
  setLineDash(dashArray: number[]) {
937
- if (dashArray && dashArray.length > 0) {
938
- this.lineDash = dashArray.join(',')
939
- } else {
940
- this.lineDash = null
941
- }
548
+ this.lineDash = dashArray.length > 0 ? dashArray.join(',') : null
942
549
  }
943
550
 
944
551
  setTransform(
@@ -949,11 +556,10 @@ export class Context {
949
556
  e?: number,
950
557
  f?: number,
951
558
  ) {
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
- }
559
+ this.__transformMatrix =
560
+ a instanceof DOMMatrix
561
+ ? new DOMMatrix([a.a, a.b, a.c, a.d, a.e, a.f])
562
+ : new DOMMatrix([a, b!, c!, d!, e!, f!])
957
563
  }
958
564
 
959
565
  getTransform(): DOMMatrix {
@@ -972,34 +578,11 @@ export class Context {
972
578
  if (isNaN(x) || isNaN(y) || !isFinite(x) || !isFinite(y)) {
973
579
  return
974
580
  }
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)
581
+ this.setTransform(this.getTransform().scale(x, y))
991
582
  }
992
583
 
993
584
  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)
585
+ this.setTransform(this.getTransform().translate(x, y))
1003
586
  }
1004
587
 
1005
588
  __matrixTransform(x: number, y: number): DOMPoint {
@@ -1012,72 +595,4 @@ export class Context {
1012
595
  y: Math.hypot(this.__transformMatrix.c, this.__transformMatrix.d),
1013
596
  }
1014
597
  }
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
598
  }