q5 2.4.10 → 2.5.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.
@@ -1,32 +1,520 @@
1
1
  Q5.renderers.webgpu.text = ($, q) => {
2
- let t = $.createGraphics(1, 1);
3
- t.pixelDensity($._pixelDensity);
4
- t._imageMode = 'corner';
2
+ let textShader = Q5.device.createShaderModule({
3
+ label: 'MSDF text shader',
4
+ code: `
5
+ // Positions for simple quad geometry
6
+ const pos = array(vec2f(0, -1), vec2f(1, -1), vec2f(0, 0), vec2f(1, 0));
5
7
 
6
- $.loadFont = (f) => {
8
+ struct VertexInput {
9
+ @builtin(vertex_index) vertex : u32,
10
+ @builtin(instance_index) instance : u32,
11
+ };
12
+ struct VertexOutput {
13
+ @builtin(position) position : vec4f,
14
+ @location(0) texcoord : vec2f,
15
+ @location(1) colorIndex : f32
16
+ };
17
+ struct Char {
18
+ texOffset: vec2f,
19
+ texExtent: vec2f,
20
+ size: vec2f,
21
+ offset: vec2f,
22
+ };
23
+ struct Text {
24
+ pos: vec2f,
25
+ scale: f32,
26
+ transformIndex: f32,
27
+ fillIndex: f32,
28
+ strokeIndex: f32
29
+ };
30
+ struct Uniforms {
31
+ halfWidth: f32,
32
+ halfHeight: f32
33
+ };
34
+
35
+ @group(0) @binding(0) var<uniform> uniforms: Uniforms;
36
+ @group(0) @binding(1) var<storage, read> transforms: array<mat4x4<f32>>;
37
+
38
+ @group(1) @binding(0) var<storage, read> colors : array<vec4f>;
39
+
40
+ @group(2) @binding(0) var fontTexture: texture_2d<f32>;
41
+ @group(2) @binding(1) var fontSampler: sampler;
42
+ @group(2) @binding(2) var<storage> fontChars: array<Char>;
43
+
44
+ @group(3) @binding(0) var<storage> textChars: array<vec4f>;
45
+ @group(3) @binding(1) var<storage> textMetadata: array<Text>;
46
+
47
+ @vertex
48
+ fn vertexMain(input : VertexInput) -> VertexOutput {
49
+ let char = textChars[input.instance];
50
+
51
+ let text = textMetadata[i32(char.w)];
52
+
53
+ let fontChar = fontChars[i32(char.z)];
54
+
55
+ let charPos = ((pos[input.vertex] * fontChar.size + char.xy + fontChar.offset) * text.scale) + text.pos;
56
+
57
+ var vert = vec4f(charPos, 0.0, 1.0);
58
+ vert = transforms[i32(text.transformIndex)] * vert;
59
+ vert.x /= uniforms.halfWidth;
60
+ vert.y /= uniforms.halfHeight;
61
+
62
+ var output : VertexOutput;
63
+ output.position = vert;
64
+ output.texcoord = (pos[input.vertex] * vec2f(1, -1)) * fontChar.texExtent + fontChar.texOffset;
65
+ output.colorIndex = text.fillIndex;
66
+ return output;
67
+ }
68
+
69
+ fn sampleMsdf(texcoord: vec2f) -> f32 {
70
+ let c = textureSample(fontTexture, fontSampler, texcoord);
71
+ return max(min(c.r, c.g), min(max(c.r, c.g), c.b));
72
+ }
73
+
74
+ @fragment
75
+ fn fragmentMain(input : VertexOutput) -> @location(0) vec4f {
76
+ // pxRange (AKA distanceRange) comes from the msdfgen tool,
77
+ // uses the default which is 4.
78
+ let pxRange = 4.0;
79
+ let sz = vec2f(textureDimensions(fontTexture, 0));
80
+ let dx = sz.x*length(vec2f(dpdxFine(input.texcoord.x), dpdyFine(input.texcoord.x)));
81
+ let dy = sz.y*length(vec2f(dpdxFine(input.texcoord.y), dpdyFine(input.texcoord.y)));
82
+ let toPixels = pxRange * inverseSqrt(dx * dx + dy * dy);
83
+ let sigDist = sampleMsdf(input.texcoord) - 0.5;
84
+ let pxDist = sigDist * toPixels;
85
+ let edgeWidth = 0.5;
86
+ let alpha = smoothstep(-edgeWidth, edgeWidth, pxDist);
87
+ if (alpha < 0.001) {
88
+ discard;
89
+ }
90
+ let fillColor = colors[i32(input.colorIndex)];
91
+ return vec4f(fillColor.rgb, fillColor.a * alpha);
92
+ }
93
+ `
94
+ });
95
+
96
+ class MsdfFont {
97
+ constructor(pipeline, bindGroup, lineHeight, chars, kernings) {
98
+ this.pipeline = pipeline;
99
+ this.bindGroup = bindGroup;
100
+ this.lineHeight = lineHeight;
101
+ this.chars = chars;
102
+ this.kernings = kernings;
103
+ let charArray = Object.values(chars);
104
+ this.charCount = charArray.length;
105
+ this.defaultChar = charArray[0];
106
+ }
107
+ getChar(charCode) {
108
+ return this.chars[charCode] ?? this.defaultChar;
109
+ }
110
+ // Gets the distance in pixels a line should advance for a given character code. If the upcoming
111
+ // character code is given any kerning between the two characters will be taken into account.
112
+ getXAdvance(charCode, nextCharCode = -1) {
113
+ let char = this.getChar(charCode);
114
+ if (nextCharCode >= 0) {
115
+ let kerning = this.kernings.get(charCode);
116
+ if (kerning) {
117
+ return char.xadvance + (kerning.get(nextCharCode) ?? 0);
118
+ }
119
+ }
120
+ return char.xadvance;
121
+ }
122
+ }
123
+
124
+ let textBindGroupLayout = Q5.device.createBindGroupLayout({
125
+ label: 'MSDF text group layout',
126
+ entries: [
127
+ {
128
+ binding: 0,
129
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
130
+ buffer: { type: 'read-only-storage' }
131
+ },
132
+ {
133
+ binding: 1,
134
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
135
+ buffer: { type: 'read-only-storage' }
136
+ }
137
+ ]
138
+ });
139
+
140
+ let fonts = {};
141
+
142
+ let createFont = async (fontJsonUrl, fontName, cb) => {
7
143
  q._preloadCount++;
8
- return t.loadFont(f, () => {
144
+
145
+ let res = await fetch(fontJsonUrl);
146
+ if (res.status == 404) {
9
147
  q._preloadCount--;
148
+ return '';
149
+ }
150
+ let atlas = await res.json();
151
+
152
+ let slashIdx = fontJsonUrl.lastIndexOf('/');
153
+ let baseUrl = slashIdx != -1 ? fontJsonUrl.substring(0, slashIdx + 1) : '';
154
+ // load font image
155
+ res = await fetch(baseUrl + atlas.pages[0]);
156
+ let img = await createImageBitmap(await res.blob());
157
+
158
+ // convert image to texture
159
+ let imgSize = [img.width, img.height, 1];
160
+ let texture = Q5.device.createTexture({
161
+ label: `MSDF ${fontName}`,
162
+ size: imgSize,
163
+ format: 'rgba8unorm',
164
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
165
+ });
166
+ Q5.device.queue.copyExternalImageToTexture({ source: img }, { texture }, imgSize);
167
+
168
+ // to make q5's default font file smaller,
169
+ // the chars and kernings are stored as csv strings
170
+ if (typeof atlas.chars == 'string') {
171
+ atlas.chars = $.CSV.parse(atlas.chars, ' ');
172
+ atlas.kernings = $.CSV.parse(atlas.kernings, ' ');
173
+ }
174
+
175
+ let charCount = atlas.chars.length;
176
+ let charsBuffer = Q5.device.createBuffer({
177
+ size: charCount * 32,
178
+ usage: GPUBufferUsage.STORAGE,
179
+ mappedAtCreation: true
180
+ });
181
+
182
+ let fontChars = new Float32Array(charsBuffer.getMappedRange());
183
+ let u = 1 / atlas.common.scaleW;
184
+ let v = 1 / atlas.common.scaleH;
185
+ let chars = {};
186
+ let o = 0; // offset
187
+ for (let [i, char] of atlas.chars.entries()) {
188
+ chars[char.id] = char;
189
+ chars[char.id].charIndex = i;
190
+ fontChars[o] = char.x * u; // texOffset.x
191
+ fontChars[o + 1] = char.y * v; // texOffset.y
192
+ fontChars[o + 2] = char.width * u; // texExtent.x
193
+ fontChars[o + 3] = char.height * v; // texExtent.y
194
+ fontChars[o + 4] = char.width; // size.x
195
+ fontChars[o + 5] = char.height; // size.y
196
+ fontChars[o + 6] = char.xoffset; // offset.x
197
+ fontChars[o + 7] = -char.yoffset; // offset.y
198
+ o += 8;
199
+ }
200
+ charsBuffer.unmap();
201
+
202
+ let fontSampler = Q5.device.createSampler({
203
+ minFilter: 'linear',
204
+ magFilter: 'linear',
205
+ mipmapFilter: 'linear',
206
+ maxAnisotropy: 16
207
+ });
208
+ let fontBindGroupLayout = Q5.device.createBindGroupLayout({
209
+ label: 'MSDF font group layout',
210
+ entries: [
211
+ {
212
+ binding: 0,
213
+ visibility: GPUShaderStage.FRAGMENT,
214
+ texture: {}
215
+ },
216
+ {
217
+ binding: 1,
218
+ visibility: GPUShaderStage.FRAGMENT,
219
+ sampler: {}
220
+ },
221
+ {
222
+ binding: 2,
223
+ visibility: GPUShaderStage.VERTEX,
224
+ buffer: { type: 'read-only-storage' }
225
+ }
226
+ ]
227
+ });
228
+ let fontPipeline = Q5.device.createRenderPipeline({
229
+ label: 'msdf font pipeline',
230
+ layout: Q5.device.createPipelineLayout({
231
+ bindGroupLayouts: [...$.bindGroupLayouts, fontBindGroupLayout, textBindGroupLayout]
232
+ }),
233
+ vertex: {
234
+ module: textShader,
235
+ entryPoint: 'vertexMain'
236
+ },
237
+ fragment: {
238
+ module: textShader,
239
+ entryPoint: 'fragmentMain',
240
+ targets: [
241
+ {
242
+ format: 'bgra8unorm',
243
+ blend: {
244
+ color: {
245
+ srcFactor: 'src-alpha',
246
+ dstFactor: 'one-minus-src-alpha'
247
+ },
248
+ alpha: {
249
+ srcFactor: 'one',
250
+ dstFactor: 'one'
251
+ }
252
+ }
253
+ }
254
+ ]
255
+ },
256
+ primitive: {
257
+ topology: 'triangle-strip',
258
+ stripIndexFormat: 'uint32'
259
+ }
260
+ });
261
+
262
+ let fontBindGroup = Q5.device.createBindGroup({
263
+ label: 'msdf font bind group',
264
+ layout: fontBindGroupLayout,
265
+ entries: [
266
+ {
267
+ binding: 0,
268
+ resource: texture.createView()
269
+ },
270
+ { binding: 1, resource: fontSampler },
271
+ { binding: 2, resource: { buffer: charsBuffer } }
272
+ ]
273
+ });
274
+
275
+ let kernings = new Map();
276
+ if (atlas.kernings) {
277
+ for (let kerning of atlas.kernings) {
278
+ let charKerning = kernings.get(kerning.first);
279
+ if (!charKerning) {
280
+ charKerning = new Map();
281
+ kernings.set(kerning.first, charKerning);
282
+ }
283
+ charKerning.set(kerning.second, kerning.amount);
284
+ }
285
+ }
286
+
287
+ $._font = new MsdfFont(fontPipeline, fontBindGroup, atlas.common.lineHeight, chars, kernings);
288
+
289
+ fonts[fontName] = $._font;
290
+ $.pipelines[2] = $._font.pipeline;
291
+
292
+ q._preloadCount--;
293
+
294
+ if (cb) cb(fontName);
295
+ };
296
+
297
+ // q2d graphics context to use for text image creation
298
+ let g = $.createGraphics(1, 1);
299
+ g.colorMode($.RGB, 1);
300
+
301
+ $.loadFont = (url, cb) => {
302
+ let ext = url.slice(url.lastIndexOf('.') + 1);
303
+ if (ext != 'json') return g.loadFont(url, cb);
304
+ let fontName = url.slice(url.lastIndexOf('/') + 1, url.lastIndexOf('-'));
305
+ createFont(url, fontName, cb);
306
+ return fontName;
307
+ };
308
+
309
+ $._textSize = 18;
310
+ $._textAlign = 'left';
311
+ $._textBaseline = 'alphabetic';
312
+ let leadingSet = false,
313
+ leading = 22.5,
314
+ leadDiff = 4.5,
315
+ leadPercent = 1.25;
316
+
317
+ $.textFont = (fontName) => {
318
+ $._font = fonts[fontName];
319
+
320
+ // replay the change of font in the draw stack
321
+ $.drawStack.push(-1, () => {
322
+ $._font = fonts[fontName];
323
+ $.pipelines[2] = $._font.pipeline;
10
324
  });
11
325
  };
326
+ $.textSize = (size) => {
327
+ $._textSize = size;
328
+ if (!leadingSet) {
329
+ leading = size * leadPercent;
330
+ leadDiff = leading - size;
331
+ }
332
+ };
333
+ $.textLeading = (lineHeight) => {
334
+ $._font.lineHeight = leading = lineHeight;
335
+ leadDiff = leading - $._textSize;
336
+ leadPercent = leading / $._textSize;
337
+ leadingSet = true;
338
+ };
339
+ $.textAlign = (horiz, vert) => {
340
+ $._textAlign = horiz;
341
+ if (vert) $._textBaseline = vert;
342
+ };
12
343
 
13
- // directly add these text setting functions to the webgpu renderer
14
- $.textFont = t.textFont;
15
- $.textSize = t.textSize;
16
- $.textLeading = t.textLeading;
17
- $.textStyle = t.textStyle;
18
- $.textAlign = t.textAlign;
19
- $.textWidth = t.textWidth;
20
- $.textAscent = t.textAscent;
21
- $.textDescent = t.textDescent;
344
+ $._charStack = [];
345
+ $._textStack = [];
22
346
 
23
- $.textFill = (r, g, b, a) => t.fill($.color(r, g, b, a));
24
- $.textStroke = (r, g, b, a) => t.stroke($.color(r, g, b, a));
347
+ let measureText = (font, text, charCallback) => {
348
+ let maxWidth = 0,
349
+ offsetX = 0,
350
+ offsetY = 0,
351
+ line = 0,
352
+ printedCharCount = 0,
353
+ lineWidths = [],
354
+ nextCharCode = text.charCodeAt(0);
355
+
356
+ for (let i = 0; i < text.length; ++i) {
357
+ let charCode = nextCharCode;
358
+ nextCharCode = i < text.length - 1 ? text.charCodeAt(i + 1) : -1;
359
+ switch (charCode) {
360
+ case 10: // Newline
361
+ lineWidths.push(offsetX);
362
+ line++;
363
+ maxWidth = Math.max(maxWidth, offsetX);
364
+ offsetX = 0;
365
+ offsetY -= font.lineHeight * leadPercent;
366
+ break;
367
+ case 13: // CR
368
+ break;
369
+ case 32: // Space
370
+ // advance the offset without actually adding a character
371
+ offsetX += font.getXAdvance(charCode);
372
+ break;
373
+ case 9: // Tab
374
+ offsetX += font.getXAdvance(charCode) * 2;
375
+ break;
376
+ default:
377
+ if (charCallback) {
378
+ charCallback(offsetX, offsetY, line, font.getChar(charCode));
379
+ }
380
+ offsetX += font.getXAdvance(charCode, nextCharCode);
381
+ printedCharCount++;
382
+ }
383
+ }
384
+ lineWidths.push(offsetX);
385
+ maxWidth = Math.max(maxWidth, offsetX);
386
+ return {
387
+ width: maxWidth,
388
+ height: lineWidths.length * font.lineHeight * leadPercent,
389
+ lineWidths,
390
+ printedCharCount
391
+ };
392
+ };
393
+
394
+ let initLoadDefaultFont;
25
395
 
26
396
  $.text = (str, x, y, w, h) => {
27
- let img = t.createTextImage(str, w, h);
397
+ if (!$._font) {
398
+ // check if online and loading the default font hasn't been attempted yet
399
+ if (navigator.onLine && !initLoadDefaultFont) {
400
+ initLoadDefaultFont = true;
401
+ $.loadFont('https://q5js.org/fonts/YaHei-msdf.json');
402
+ }
403
+ return;
404
+ }
405
+
406
+ if (str.length > w) {
407
+ let wrapped = [];
408
+ let i = 0;
409
+ while (i < str.length) {
410
+ let max = i + w;
411
+ if (max >= str.length) {
412
+ wrapped.push(str.slice(i));
413
+ break;
414
+ }
415
+ let end = str.lastIndexOf(' ', max);
416
+ if (end == -1 || end < i) end = max;
417
+ wrapped.push(str.slice(i, end));
418
+ i = end + 1;
419
+ }
420
+ str = wrapped.join('\n');
421
+ }
422
+
423
+ let spaces = 0, // whitespace char count, not literal spaces
424
+ hasNewline;
425
+ for (let i = 0; i < str.length; i++) {
426
+ let c = str[i];
427
+ switch (c) {
428
+ case '\n':
429
+ hasNewline = true;
430
+ case '\r':
431
+ case '\t':
432
+ case ' ':
433
+ spaces++;
434
+ }
435
+ }
436
+
437
+ let charsData = new Float32Array((str.length - spaces) * 4);
438
+
439
+ let ta = $._textAlign,
440
+ tb = $._textBaseline,
441
+ textIndex = $._textStack.length,
442
+ o = 0, // offset
443
+ measurements;
444
+
445
+ if (ta == 'left' && !hasNewline) {
446
+ measurements = measureText($._font, str, (textX, textY, line, char) => {
447
+ charsData[o] = textX;
448
+ charsData[o + 1] = textY;
449
+ charsData[o + 2] = char.charIndex;
450
+ charsData[o + 3] = textIndex;
451
+ o += 4;
452
+ });
453
+
454
+ if (tb == 'alphabetic') y -= $._textSize;
455
+ else if (tb == 'center') y -= $._textSize * 0.5;
456
+ else if (tb == 'bottom') y -= leading;
457
+ } else {
458
+ // measure the text to get the line widths before setting
459
+ // the x position to properly align the text
460
+ measurements = measureText($._font, str);
461
+
462
+ let offsetY = 0;
463
+ if (tb == 'alphabetic') y -= $._textSize;
464
+ else if (tb == 'center') offsetY = measurements.height * 0.5;
465
+ else if (tb == 'bottom') offsetY = measurements.height;
466
+
467
+ measureText($._font, str, (textX, textY, line, char) => {
468
+ let offsetX = 0;
469
+ if (ta == 'center') {
470
+ offsetX = measurements.width * -0.5 - (measurements.width - measurements.lineWidths[line]) * -0.5;
471
+ } else if (ta == 'right') {
472
+ offsetX = measurements.width - measurements.lineWidths[line];
473
+ }
474
+ charsData[o] = textX + offsetX;
475
+ charsData[o + 1] = textY + offsetY;
476
+ charsData[o + 2] = char.charIndex;
477
+ charsData[o + 3] = textIndex;
478
+ o += 4;
479
+ });
480
+ }
481
+ $._charStack.push(charsData);
482
+
483
+ let text = new Float32Array(6);
484
+
485
+ if ($._matrixDirty) $._saveMatrix();
486
+
487
+ text[0] = x;
488
+ text[1] = -y;
489
+ text[2] = $._textSize / 44;
490
+ text[3] = $._transformIndex;
491
+ text[4] = $._fillIndex;
492
+ text[5] = $._strokeIndex;
493
+
494
+ $._textStack.push(text);
495
+ $.drawStack.push(2, measurements.printedCharCount);
496
+ };
497
+
498
+ $.textWidth = (str) => {
499
+ if (!$._font) return 0;
500
+ return measureText($._font, str).width;
501
+ };
502
+
503
+ $.createTextImage = (str, w, h) => {
504
+ g.textSize($._textSize);
505
+
506
+ if ($._doFill) {
507
+ let fi = $._fillIndex * 4;
508
+ g.fill(colorsStack.slice(fi, fi + 4));
509
+ }
510
+ if ($._doStroke) {
511
+ let si = $._strokeIndex * 4;
512
+ g.stroke(colorsStack.slice(si, si + 4));
513
+ }
28
514
 
29
- if (img.canvas.textureIndex === undefined) {
515
+ let img = g.createTextImage(str, w, h);
516
+
517
+ if (img.canvas.textureIndex == undefined) {
30
518
  $._createTexture(img);
31
519
  } else if (img.modified) {
32
520
  let cnv = img.canvas;
@@ -40,27 +528,94 @@ Q5.renderers.webgpu.text = ($, q) => {
40
528
  );
41
529
  img.modified = false;
42
530
  }
43
-
44
- $.textImage(img, x, y);
531
+ return img;
45
532
  };
46
533
 
47
- $.createTextImage = t.createTextImage;
48
-
49
534
  $.textImage = (img, x, y) => {
535
+ if (typeof img == 'string') img = $.createTextImage(img);
536
+
50
537
  let og = $._imageMode;
51
538
  $._imageMode = 'corner';
52
539
 
53
- let ta = t._textAlign;
540
+ let ta = $._textAlign;
54
541
  if (ta == 'center') x -= img.canvas.hw;
55
542
  else if (ta == 'right') x -= img.width;
56
543
 
57
- let bl = t._textBaseline;
58
- if (bl == 'alphabetic') y -= t._textLeading;
59
- else if (bl == 'middle') y -= img._middle;
544
+ let bl = $._textBaseline;
545
+ if (bl == 'alphabetic') y -= img._leading;
546
+ else if (bl == 'center') y -= img._middle;
60
547
  else if (bl == 'bottom') y -= img._bottom;
61
548
  else if (bl == 'top') y -= img._top;
62
549
 
63
550
  $.image(img, x, y);
64
551
  $._imageMode = og;
65
552
  };
553
+
554
+ $._hooks.preRender.push(() => {
555
+ if (!$._charStack.length) return;
556
+
557
+ // Calculate total buffer size for text data
558
+ let totalTextSize = 0;
559
+ for (let charsData of $._charStack) {
560
+ totalTextSize += charsData.length * 4;
561
+ }
562
+
563
+ // Create a single buffer for all text data
564
+ let charBuffer = Q5.device.createBuffer({
565
+ label: 'charBuffer',
566
+ size: totalTextSize,
567
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
568
+ mappedAtCreation: true
569
+ });
570
+
571
+ // Copy all text data into the buffer
572
+ let textArray = new Float32Array(charBuffer.getMappedRange());
573
+ let o = 0;
574
+ for (let array of $._charStack) {
575
+ textArray.set(array, o);
576
+ o += array.length;
577
+ }
578
+ charBuffer.unmap();
579
+
580
+ // Calculate total buffer size for metadata
581
+ let totalMetadataSize = $._textStack.length * 6 * 4;
582
+
583
+ // Create a single buffer for all metadata
584
+ let textBuffer = Q5.device.createBuffer({
585
+ label: 'textBuffer',
586
+ size: totalMetadataSize,
587
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
588
+ mappedAtCreation: true
589
+ });
590
+
591
+ // Copy all metadata into the buffer
592
+ let metadataArray = new Float32Array(textBuffer.getMappedRange());
593
+ o = 0;
594
+ for (let array of $._textStack) {
595
+ metadataArray.set(array, o);
596
+ o += array.length;
597
+ }
598
+ textBuffer.unmap();
599
+
600
+ // Create a single bind group for the text buffer and metadata buffer
601
+ $._textBindGroup = Q5.device.createBindGroup({
602
+ label: 'msdf text bind group',
603
+ layout: textBindGroupLayout,
604
+ entries: [
605
+ {
606
+ binding: 0,
607
+ resource: { buffer: charBuffer }
608
+ },
609
+ {
610
+ binding: 1,
611
+ resource: { buffer: textBuffer }
612
+ }
613
+ ]
614
+ });
615
+ });
616
+
617
+ $._hooks.postRender.push(() => {
618
+ $._charStack.length = 0;
619
+ $._textStack.length = 0;
620
+ });
66
621
  };