three-text 0.2.11 → 0.2.13
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/README.md +1 -1
- package/dist/index.cjs +168 -83
- package/dist/index.js +168 -83
- package/dist/index.min.cjs +2 -2
- package/dist/index.min.js +2 -2
- package/dist/index.umd.js +168 -83
- package/dist/index.umd.min.js +2 -2
- package/dist/types/core/cache/GlyphGeometryBuilder.d.ts +3 -1
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* three-text v0.2.
|
|
2
|
+
* three-text v0.2.13
|
|
3
3
|
* Copyright (C) 2025 Countertype LLC
|
|
4
4
|
*
|
|
5
5
|
* This program is free software: you can redistribute it and/or modify
|
|
@@ -2583,7 +2583,6 @@ class Extruder {
|
|
|
2583
2583
|
vertices.push(points[i], points[i + 1], 0);
|
|
2584
2584
|
normals.push(0, 0, -1);
|
|
2585
2585
|
}
|
|
2586
|
-
// Add triangle indices
|
|
2587
2586
|
for (let i = 0; i < triangleIndices.length; i++) {
|
|
2588
2587
|
indices.push(baseIndex + triangleIndices[i]);
|
|
2589
2588
|
}
|
|
@@ -2634,18 +2633,21 @@ class BoundaryClusterer {
|
|
|
2634
2633
|
perfLogger.start('BoundaryClusterer.cluster', {
|
|
2635
2634
|
glyphCount: glyphContoursList.length
|
|
2636
2635
|
});
|
|
2637
|
-
|
|
2636
|
+
const n = glyphContoursList.length;
|
|
2637
|
+
if (n === 0) {
|
|
2638
2638
|
perfLogger.end('BoundaryClusterer.cluster');
|
|
2639
2639
|
return [];
|
|
2640
2640
|
}
|
|
2641
|
+
if (n === 1) {
|
|
2642
|
+
perfLogger.end('BoundaryClusterer.cluster');
|
|
2643
|
+
return [[0]];
|
|
2644
|
+
}
|
|
2641
2645
|
const result = this.clusterSweepLine(glyphContoursList, positions);
|
|
2642
2646
|
perfLogger.end('BoundaryClusterer.cluster');
|
|
2643
2647
|
return result;
|
|
2644
2648
|
}
|
|
2645
2649
|
clusterSweepLine(glyphContoursList, positions) {
|
|
2646
2650
|
const n = glyphContoursList.length;
|
|
2647
|
-
if (n <= 1)
|
|
2648
|
-
return n === 0 ? [] : [[0]];
|
|
2649
2651
|
const bounds = new Array(n);
|
|
2650
2652
|
const events = new Array(2 * n);
|
|
2651
2653
|
let eventIndex = 0;
|
|
@@ -2665,7 +2667,6 @@ class BoundaryClusterer {
|
|
|
2665
2667
|
const py = find(y);
|
|
2666
2668
|
if (px === py)
|
|
2667
2669
|
return;
|
|
2668
|
-
// Union by rank, attach smaller tree under larger tree
|
|
2669
2670
|
if (rank[px] < rank[py]) {
|
|
2670
2671
|
parent[px] = py;
|
|
2671
2672
|
}
|
|
@@ -2681,8 +2682,6 @@ class BoundaryClusterer {
|
|
|
2681
2682
|
for (const [, eventType, glyphIndex] of events) {
|
|
2682
2683
|
if (eventType === 0) {
|
|
2683
2684
|
const bounds1 = bounds[glyphIndex];
|
|
2684
|
-
// Check y-overlap with all currently active glyphs
|
|
2685
|
-
// (x-overlap is guaranteed by the sweep line)
|
|
2686
2685
|
for (const activeIndex of active) {
|
|
2687
2686
|
const bounds2 = bounds[activeIndex];
|
|
2688
2687
|
if (bounds1.minY < bounds2.maxY + OVERLAP_EPSILON &&
|
|
@@ -2699,10 +2698,12 @@ class BoundaryClusterer {
|
|
|
2699
2698
|
const clusters = new Map();
|
|
2700
2699
|
for (let i = 0; i < n; i++) {
|
|
2701
2700
|
const root = find(i);
|
|
2702
|
-
|
|
2703
|
-
|
|
2701
|
+
let list = clusters.get(root);
|
|
2702
|
+
if (!list) {
|
|
2703
|
+
list = [];
|
|
2704
|
+
clusters.set(root, list);
|
|
2704
2705
|
}
|
|
2705
|
-
|
|
2706
|
+
list.push(i);
|
|
2706
2707
|
}
|
|
2707
2708
|
return Array.from(clusters.values());
|
|
2708
2709
|
}
|
|
@@ -3755,6 +3756,10 @@ class GlyphGeometryBuilder {
|
|
|
3755
3756
|
return size;
|
|
3756
3757
|
}
|
|
3757
3758
|
});
|
|
3759
|
+
this.clusteringCache = new LRUCache({
|
|
3760
|
+
maxEntries: 2000,
|
|
3761
|
+
calculateSize: () => 1
|
|
3762
|
+
});
|
|
3758
3763
|
}
|
|
3759
3764
|
getOptimizationStats() {
|
|
3760
3765
|
return this.collector.getOptimizationStats();
|
|
@@ -3769,7 +3774,7 @@ class GlyphGeometryBuilder {
|
|
|
3769
3774
|
this.fontId = fontId;
|
|
3770
3775
|
}
|
|
3771
3776
|
// Build instanced geometry from glyph contours
|
|
3772
|
-
buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false) {
|
|
3777
|
+
buildInstancedGeometry(clustersByLine, depth, removeOverlaps, isCFF, separateGlyphs = false, coloredTextIndices) {
|
|
3773
3778
|
perfLogger.start('GlyphGeometryBuilder.buildInstancedGeometry', {
|
|
3774
3779
|
lineCount: clustersByLine.length,
|
|
3775
3780
|
wordCount: clustersByLine.flat().length,
|
|
@@ -3793,64 +3798,115 @@ class GlyphGeometryBuilder {
|
|
|
3793
3798
|
clusterGlyphContours.push(this.getContoursForGlyph(glyph.g));
|
|
3794
3799
|
}
|
|
3795
3800
|
// Step 2: Check for overlaps within the cluster
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
const
|
|
3804
|
-
let
|
|
3805
|
-
if (
|
|
3806
|
-
|
|
3807
|
-
for (let i = 0; i <
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
clusterPaths.push({
|
|
3812
|
-
...path,
|
|
3813
|
-
points: path.points.map((p) => new Vec2(p.x + (glyph.x ?? 0), p.y + (glyph.y ?? 0)))
|
|
3814
|
-
});
|
|
3801
|
+
let boundaryGroups;
|
|
3802
|
+
if (cluster.glyphs.length <= 1) {
|
|
3803
|
+
boundaryGroups = [[0]];
|
|
3804
|
+
}
|
|
3805
|
+
else {
|
|
3806
|
+
// Check clustering cache (same text + glyph IDs = same overlap groups)
|
|
3807
|
+
const cacheKey = cluster.text;
|
|
3808
|
+
const cached = this.clusteringCache.get(cacheKey);
|
|
3809
|
+
let isValid = false;
|
|
3810
|
+
if (cached && cached.glyphIds.length === cluster.glyphs.length) {
|
|
3811
|
+
isValid = true;
|
|
3812
|
+
for (let i = 0; i < cluster.glyphs.length; i++) {
|
|
3813
|
+
if (cached.glyphIds[i] !== cluster.glyphs[i].g) {
|
|
3814
|
+
isValid = false;
|
|
3815
|
+
break;
|
|
3815
3816
|
}
|
|
3816
3817
|
}
|
|
3817
|
-
cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
|
|
3818
|
-
this.wordCache.set(clusterKey, cachedCluster);
|
|
3819
3818
|
}
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
const
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3819
|
+
if (isValid && cached) {
|
|
3820
|
+
boundaryGroups = cached.groups;
|
|
3821
|
+
}
|
|
3822
|
+
else {
|
|
3823
|
+
const relativePositions = cluster.glyphs.map((g) => new Vec3(g.x, g.y, 0));
|
|
3824
|
+
boundaryGroups = this.clusterer.cluster(clusterGlyphContours, relativePositions);
|
|
3825
|
+
this.clusteringCache.set(cacheKey, {
|
|
3826
|
+
glyphIds: cluster.glyphs.map(g => g.g),
|
|
3827
|
+
groups: boundaryGroups
|
|
3828
|
+
});
|
|
3830
3829
|
}
|
|
3831
3830
|
}
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3831
|
+
const clusterHasColoredGlyphs = coloredTextIndices &&
|
|
3832
|
+
cluster.glyphs.some((g) => coloredTextIndices.has(g.absoluteTextIndex));
|
|
3833
|
+
// Force glyph-level caching if:
|
|
3834
|
+
// - separateGlyphs flag is set (for shader attributes), OR
|
|
3835
|
+
// - cluster contains selectively colored text (needs separate vertex ranges per glyph)
|
|
3836
|
+
const forceSeparate = separateGlyphs || clusterHasColoredGlyphs;
|
|
3837
|
+
// Iterate over the geometric groups identified by BoundaryClusterer
|
|
3838
|
+
// logical groups (words) are now split into geometric sub-groups (e.g. "aa", "XX", "bb")
|
|
3839
|
+
for (const groupIndices of boundaryGroups) {
|
|
3840
|
+
const isOverlappingGroup = groupIndices.length > 1;
|
|
3841
|
+
const shouldCluster = isOverlappingGroup && !forceSeparate;
|
|
3842
|
+
if (shouldCluster) {
|
|
3843
|
+
// Cluster-level caching for this specific group of overlapping glyphs
|
|
3844
|
+
const subClusterGlyphs = groupIndices.map((i) => cluster.glyphs[i]);
|
|
3845
|
+
const clusterKey = this.getClusterKey(subClusterGlyphs, depth, removeOverlaps);
|
|
3846
|
+
let cachedCluster = this.wordCache.get(clusterKey);
|
|
3847
|
+
if (!cachedCluster) {
|
|
3848
|
+
const clusterPaths = [];
|
|
3849
|
+
const refX = subClusterGlyphs[0].x ?? 0;
|
|
3850
|
+
const refY = subClusterGlyphs[0].y ?? 0;
|
|
3851
|
+
for (let i = 0; i < groupIndices.length; i++) {
|
|
3852
|
+
const originalIndex = groupIndices[i];
|
|
3853
|
+
const glyphContours = clusterGlyphContours[originalIndex];
|
|
3854
|
+
const glyph = cluster.glyphs[originalIndex];
|
|
3855
|
+
// Position relative to the sub-cluster start
|
|
3856
|
+
const relX = (glyph.x ?? 0) - refX;
|
|
3857
|
+
const relY = (glyph.y ?? 0) - refY;
|
|
3858
|
+
for (const path of glyphContours.paths) {
|
|
3859
|
+
clusterPaths.push({
|
|
3860
|
+
...path,
|
|
3861
|
+
points: path.points.map((p) => new Vec2(p.x + relX, p.y + relY))
|
|
3862
|
+
});
|
|
3863
|
+
}
|
|
3864
|
+
}
|
|
3865
|
+
cachedCluster = this.tessellateGlyphCluster(clusterPaths, depth, isCFF);
|
|
3866
|
+
this.wordCache.set(clusterKey, cachedCluster);
|
|
3867
|
+
}
|
|
3868
|
+
// Calculate the absolute position of this sub-cluster based on its first glyph
|
|
3869
|
+
// (since the cached geometry is relative to that first glyph)
|
|
3870
|
+
const firstGlyphInGroup = subClusterGlyphs[0];
|
|
3871
|
+
const groupPosition = new Vec3(cluster.position.x + (firstGlyphInGroup.x ?? 0), cluster.position.y + (firstGlyphInGroup.y ?? 0), cluster.position.z);
|
|
3872
|
+
const vertexOffset = vertices.length / 3;
|
|
3873
|
+
this.appendGeometry(vertices, normals, indices, cachedCluster, groupPosition, vertexOffset);
|
|
3874
|
+
const clusterVertexCount = cachedCluster.vertices.length / 3;
|
|
3875
|
+
// Register glyph infos for all glyphs in this sub-cluster
|
|
3876
|
+
// They all point to the same merged geometry
|
|
3877
|
+
for (let i = 0; i < groupIndices.length; i++) {
|
|
3878
|
+
const originalIndex = groupIndices[i];
|
|
3879
|
+
const glyph = cluster.glyphs[originalIndex];
|
|
3880
|
+
const glyphContours = clusterGlyphContours[originalIndex];
|
|
3881
|
+
const absoluteGlyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
|
|
3882
|
+
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, clusterVertexCount, absoluteGlyphPosition, glyphContours, depth);
|
|
3841
3883
|
glyphInfos.push(glyphInfo);
|
|
3842
|
-
|
|
3884
|
+
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3843
3885
|
}
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3886
|
+
}
|
|
3887
|
+
else {
|
|
3888
|
+
// Glyph-level caching (standard path for isolated glyphs or when forced separate)
|
|
3889
|
+
for (const i of groupIndices) {
|
|
3890
|
+
const glyph = cluster.glyphs[i];
|
|
3891
|
+
const glyphContours = clusterGlyphContours[i];
|
|
3892
|
+
const glyphPosition = new Vec3(cluster.position.x + (glyph.x ?? 0), cluster.position.y + (glyph.y ?? 0), cluster.position.z);
|
|
3893
|
+
// Skip glyphs with no paths (spaces, zero-width characters, etc.)
|
|
3894
|
+
if (glyphContours.paths.length === 0) {
|
|
3895
|
+
const glyphInfo = this.createGlyphInfo(glyph, 0, 0, glyphPosition, glyphContours, depth);
|
|
3896
|
+
glyphInfos.push(glyphInfo);
|
|
3897
|
+
continue;
|
|
3898
|
+
}
|
|
3899
|
+
let cachedGlyph = this.cache.get(this.fontId, glyph.g, depth, removeOverlaps);
|
|
3900
|
+
if (!cachedGlyph) {
|
|
3901
|
+
cachedGlyph = this.tessellateGlyph(glyphContours, depth, removeOverlaps, isCFF);
|
|
3902
|
+
this.cache.set(this.fontId, glyph.g, depth, removeOverlaps, cachedGlyph);
|
|
3903
|
+
}
|
|
3904
|
+
const vertexOffset = vertices.length / 3;
|
|
3905
|
+
this.appendGeometry(vertices, normals, indices, cachedGlyph, glyphPosition, vertexOffset);
|
|
3906
|
+
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
|
|
3907
|
+
glyphInfos.push(glyphInfo);
|
|
3908
|
+
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3848
3909
|
}
|
|
3849
|
-
const vertexOffset = vertices.length / 3;
|
|
3850
|
-
this.appendGeometry(vertices, normals, indices, cachedGlyph, glyphPosition, vertexOffset);
|
|
3851
|
-
const glyphInfo = this.createGlyphInfo(glyph, vertexOffset, cachedGlyph.vertices.length / 3, glyphPosition, glyphContours, depth);
|
|
3852
|
-
glyphInfos.push(glyphInfo);
|
|
3853
|
-
this.updatePlaneBounds(glyphInfo.bounds, planeBounds);
|
|
3854
3910
|
}
|
|
3855
3911
|
}
|
|
3856
3912
|
}
|
|
@@ -3867,6 +3923,21 @@ class GlyphGeometryBuilder {
|
|
|
3867
3923
|
planeBounds
|
|
3868
3924
|
};
|
|
3869
3925
|
}
|
|
3926
|
+
getClusterKey(glyphs, depth, removeOverlaps) {
|
|
3927
|
+
if (glyphs.length === 0)
|
|
3928
|
+
return '';
|
|
3929
|
+
// Normalize positions relative to the first glyph in the cluster
|
|
3930
|
+
const refX = glyphs[0].x ?? 0;
|
|
3931
|
+
const refY = glyphs[0].y ?? 0;
|
|
3932
|
+
const parts = glyphs.map((g) => {
|
|
3933
|
+
const relX = (g.x ?? 0) - refX;
|
|
3934
|
+
const relY = (g.y ?? 0) - refY;
|
|
3935
|
+
return `${g.g}:${relX},${relY}`;
|
|
3936
|
+
});
|
|
3937
|
+
const ids = parts.join('|');
|
|
3938
|
+
const roundedDepth = Math.round(depth * 1000) / 1000;
|
|
3939
|
+
return `${this.fontId}_${ids}_${roundedDepth}_${removeOverlaps}`;
|
|
3940
|
+
}
|
|
3870
3941
|
appendGeometry(vertices, normals, indices, data, position, offset) {
|
|
3871
3942
|
for (let j = 0; j < data.vertices.length; j += 3) {
|
|
3872
3943
|
vertices.push(data.vertices[j] + position.x, data.vertices[j + 1] + position.y, data.vertices[j + 2] + position.z);
|
|
@@ -3991,6 +4062,7 @@ class GlyphGeometryBuilder {
|
|
|
3991
4062
|
clearCache() {
|
|
3992
4063
|
this.cache.clear();
|
|
3993
4064
|
this.wordCache.clear();
|
|
4065
|
+
this.clusteringCache.clear();
|
|
3994
4066
|
}
|
|
3995
4067
|
}
|
|
3996
4068
|
|
|
@@ -4005,30 +4077,14 @@ class TextShaper {
|
|
|
4005
4077
|
perfLogger.start('TextShaper.shapeLines', {
|
|
4006
4078
|
lineCount: lineInfos.length
|
|
4007
4079
|
});
|
|
4008
|
-
// Calculate color boundaries once for the entire text before line processing
|
|
4009
|
-
const colorBoundaries = new Set();
|
|
4010
|
-
if (color &&
|
|
4011
|
-
typeof color === 'object' &&
|
|
4012
|
-
'byText' in color &&
|
|
4013
|
-
color.byText &&
|
|
4014
|
-
originalText) {
|
|
4015
|
-
for (const textToColor of Object.keys(color.byText)) {
|
|
4016
|
-
let index = 0;
|
|
4017
|
-
while ((index = originalText.indexOf(textToColor, index)) !== -1) {
|
|
4018
|
-
colorBoundaries.add(index);
|
|
4019
|
-
colorBoundaries.add(index + textToColor.length);
|
|
4020
|
-
index += textToColor.length;
|
|
4021
|
-
}
|
|
4022
|
-
}
|
|
4023
|
-
}
|
|
4024
4080
|
const clustersByLine = [];
|
|
4025
4081
|
lineInfos.forEach((lineInfo, lineIndex) => {
|
|
4026
|
-
const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction
|
|
4082
|
+
const clusters = this.shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction);
|
|
4027
4083
|
clustersByLine.push(clusters);
|
|
4028
4084
|
});
|
|
4029
4085
|
return clustersByLine;
|
|
4030
4086
|
}
|
|
4031
|
-
shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction
|
|
4087
|
+
shapeLineIntoClusters(lineInfo, lineIndex, scaledLineHeight, letterSpacing, align, direction) {
|
|
4032
4088
|
const buffer = this.loadedFont.hb.createBuffer();
|
|
4033
4089
|
if (direction === 'rtl') {
|
|
4034
4090
|
buffer.setDirection('rtl');
|
|
@@ -4061,8 +4117,9 @@ class TextShaper {
|
|
|
4061
4117
|
glyph.absoluteTextIndex = lineInfo.originalStart + glyph.cl;
|
|
4062
4118
|
}
|
|
4063
4119
|
glyph.lineIndex = lineIndex;
|
|
4064
|
-
|
|
4065
|
-
|
|
4120
|
+
// Cluster boundaries are based on whitespace only.
|
|
4121
|
+
// Coloring is applied later via vertex colors and must never affect shaping/kerning.
|
|
4122
|
+
if (isWhitespace) {
|
|
4066
4123
|
if (currentClusterGlyphs.length > 0) {
|
|
4067
4124
|
clusters.push({
|
|
4068
4125
|
text: currentClusterText,
|
|
@@ -5249,7 +5306,35 @@ class Text {
|
|
|
5249
5306
|
// Allow manual override via options.removeOverlaps
|
|
5250
5307
|
const shouldRemoveOverlaps = options.removeOverlaps ?? this.loadedFont.isVariable ?? false;
|
|
5251
5308
|
const clustersByLine = this.textShaper.shapeLines(layoutData.lines, layoutData.scaledLineHeight, layoutData.letterSpacing, layoutData.align, layoutData.direction, options.color, options.text);
|
|
5252
|
-
|
|
5309
|
+
// Pre-compute which character indices will be colored. This allows geometry building
|
|
5310
|
+
// to selectively use glyph-level caching (separate vertices) only for clusters containing
|
|
5311
|
+
// colored text, while non-colored clusters can still use fast cluster-level merging
|
|
5312
|
+
let coloredTextIndices;
|
|
5313
|
+
if (options.color && typeof options.color === 'object' && !Array.isArray(options.color)) {
|
|
5314
|
+
if (options.color.byText || options.color.byCharRange) {
|
|
5315
|
+
// Build the set manually since glyphs don't exist yet
|
|
5316
|
+
coloredTextIndices = new Set();
|
|
5317
|
+
if (options.color.byText) {
|
|
5318
|
+
for (const pattern of Object.keys(options.color.byText)) {
|
|
5319
|
+
let index = 0;
|
|
5320
|
+
while ((index = options.text.indexOf(pattern, index)) !== -1) {
|
|
5321
|
+
for (let i = index; i < index + pattern.length; i++) {
|
|
5322
|
+
coloredTextIndices.add(i);
|
|
5323
|
+
}
|
|
5324
|
+
index += pattern.length;
|
|
5325
|
+
}
|
|
5326
|
+
}
|
|
5327
|
+
}
|
|
5328
|
+
if (options.color.byCharRange) {
|
|
5329
|
+
for (const range of options.color.byCharRange) {
|
|
5330
|
+
for (let i = range.start; i < range.end; i++) {
|
|
5331
|
+
coloredTextIndices.add(i);
|
|
5332
|
+
}
|
|
5333
|
+
}
|
|
5334
|
+
}
|
|
5335
|
+
}
|
|
5336
|
+
}
|
|
5337
|
+
const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, options.separateGlyphsWithAttributes || false, coloredTextIndices);
|
|
5253
5338
|
const cacheStats = this.geometryBuilder.getCacheStats();
|
|
5254
5339
|
const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, cacheStats, options.text);
|
|
5255
5340
|
if (options.separateGlyphsWithAttributes) {
|