three-text 0.3.1 → 0.3.2

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/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * three-text v0.3.1
2
+ * three-text v0.3.2
3
3
  * Copyright (C) 2025 Countertype LLC
4
4
  *
5
5
  * This program is free software: you can redistribute it and/or modify
@@ -1634,9 +1634,7 @@ const TABLE_TAG_CFF2 = 0x43464632; // 'CFF2'
1634
1634
  const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
1635
1635
  const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
1636
1636
 
1637
- // Parses the SFNT table directory for TTF/OTF fonts
1638
- // Assumes the DataView is positioned at the start of an sfnt font (offset 0)
1639
- // Table records are 16 bytes each starting at byte offset 12
1637
+ // SFNT table directory
1640
1638
  function parseTableDirectory(view) {
1641
1639
  const numTables = view.getUint16(4);
1642
1640
  const tableRecordsStart = 12;
@@ -1656,6 +1654,10 @@ function parseTableDirectory(view) {
1656
1654
  return tables;
1657
1655
  }
1658
1656
 
1657
+ // OpenType tag prefixes as uint16 for bitwise comparison
1658
+ const TAG_SS_PREFIX = 0x7373; // 'ss'
1659
+ const TAG_CV_PREFIX = 0x6376; // 'cv'
1660
+ const utf16beDecoder = new TextDecoder('utf-16be');
1659
1661
  class FontMetadataExtractor {
1660
1662
  static extractMetadata(fontBuffer) {
1661
1663
  if (!fontBuffer || fontBuffer.byteLength < 12) {
@@ -1757,7 +1759,6 @@ class FontMetadataExtractor {
1757
1759
  const names = {};
1758
1760
  for (let i = 0; i < featureCount; i++) {
1759
1761
  const recordOffset = featureListStart + 2 + i * 6;
1760
- // Decode feature tag
1761
1762
  const tag = String.fromCharCode(view.getUint8(recordOffset), view.getUint8(recordOffset + 1), view.getUint8(recordOffset + 2), view.getUint8(recordOffset + 3));
1762
1763
  features.push(tag);
1763
1764
  // Extract feature name for stylistic sets and character variants
@@ -1789,9 +1790,7 @@ class FontMetadataExtractor {
1789
1790
  }
1790
1791
  static extractAxisNames(view, statOffset, nameOffset) {
1791
1792
  try {
1792
- // STAT table structure
1793
1793
  const majorVersion = view.getUint16(statOffset);
1794
- // We need at least version 1.0
1795
1794
  if (majorVersion < 1)
1796
1795
  return null;
1797
1796
  const designAxisSize = view.getUint16(statOffset + 4);
@@ -1816,10 +1815,8 @@ class FontMetadataExtractor {
1816
1815
  }
1817
1816
  static getNameFromNameTable(view, nameOffset, nameID) {
1818
1817
  try {
1819
- // const format = view.getUint16(nameOffset);
1820
1818
  const count = view.getUint16(nameOffset + 2);
1821
1819
  const stringOffset = view.getUint16(nameOffset + 4);
1822
- // Look for the name record with our nameID (preferring English)
1823
1820
  for (let i = 0; i < count; i++) {
1824
1821
  const recordOffset = nameOffset + 6 + i * 12;
1825
1822
  const platformID = view.getUint16(recordOffset);
@@ -1828,26 +1825,22 @@ class FontMetadataExtractor {
1828
1825
  const recordNameID = view.getUint16(recordOffset + 6);
1829
1826
  const length = view.getUint16(recordOffset + 8);
1830
1827
  const offset = view.getUint16(recordOffset + 10);
1831
- if (recordNameID === nameID) {
1832
- // Prefer Unicode or Windows platform English names
1833
- if (platformID === 0 || (platformID === 3 && languageID === 0x0409)) {
1834
- const stringStart = nameOffset + stringOffset + offset;
1835
- const bytes = new Uint8Array(view.buffer, stringStart, length);
1836
- // Decode based on platform
1837
- if (platformID === 0 || (platformID === 3 && encodingID === 1)) {
1838
- // UTF-16BE
1839
- let str = '';
1840
- for (let j = 0; j < bytes.length; j += 2) {
1841
- str += String.fromCharCode((bytes[j] << 8) | bytes[j + 1]);
1842
- }
1843
- return str;
1844
- }
1845
- else {
1846
- // ASCII
1847
- return new TextDecoder('ascii').decode(bytes);
1848
- }
1828
+ if (recordNameID !== nameID)
1829
+ continue;
1830
+ // Accept Unicode platform or Windows US English
1831
+ if (platformID !== 0 && !(platformID === 3 && languageID === 0x0409)) {
1832
+ continue;
1833
+ }
1834
+ const stringStart = nameOffset + stringOffset + offset;
1835
+ const bytes = new Uint8Array(view.buffer, stringStart, length);
1836
+ if (platformID === 0 || (platformID === 3 && encodingID === 1)) {
1837
+ let str = '';
1838
+ for (let j = 0; j < bytes.length; j += 2) {
1839
+ str += String.fromCharCode((bytes[j] << 8) | bytes[j + 1]);
1849
1840
  }
1841
+ return str;
1850
1842
  }
1843
+ return new TextDecoder('ascii').decode(bytes);
1851
1844
  }
1852
1845
  return null;
1853
1846
  }
@@ -1855,7 +1848,210 @@ class FontMetadataExtractor {
1855
1848
  return null;
1856
1849
  }
1857
1850
  }
1858
- // Priority: typo metrics > hhea metrics > win metrics > fallback, ignore line gap (Google strategy)
1851
+ static extractAll(fontBuffer) {
1852
+ if (!fontBuffer || fontBuffer.byteLength < 12) {
1853
+ throw new Error('Invalid font buffer: too small to be a valid font file');
1854
+ }
1855
+ const view = new DataView(fontBuffer);
1856
+ const sfntVersion = view.getUint32(0);
1857
+ const validSignatures = [
1858
+ FONT_SIGNATURE_TRUE_TYPE,
1859
+ FONT_SIGNATURE_OPEN_TYPE_CFF
1860
+ ];
1861
+ if (!validSignatures.includes(sfntVersion)) {
1862
+ throw new Error(`Invalid font format. Expected TTF/OTF (or WOFF), got signature: 0x${sfntVersion.toString(16)}`);
1863
+ }
1864
+ const tableDirectory = parseTableDirectory(view);
1865
+ const nameTableOffset = tableDirectory.get(TABLE_TAG_NAME)?.offset ?? 0;
1866
+ const nameIndex = nameTableOffset
1867
+ ? this.buildNameIndex(view, nameTableOffset)
1868
+ : null;
1869
+ const metrics = this.extractMetricsWithIndex(view, tableDirectory, nameIndex);
1870
+ const features = this.extractFeaturesWithIndex(view, tableDirectory, nameIndex);
1871
+ return { metrics, features };
1872
+ }
1873
+ // Index name records by nameID; prefers Unicode (platform 0) or Windows US English
1874
+ static buildNameIndex(view, nameOffset) {
1875
+ const index = new Map();
1876
+ const count = view.getUint16(nameOffset + 2);
1877
+ const stringOffset = view.getUint16(nameOffset + 4);
1878
+ for (let i = 0; i < count; i++) {
1879
+ const recordOffset = nameOffset + 6 + i * 12;
1880
+ const platformID = view.getUint16(recordOffset);
1881
+ const encodingID = view.getUint16(recordOffset + 2);
1882
+ const languageID = view.getUint16(recordOffset + 4);
1883
+ const nameID = view.getUint16(recordOffset + 6);
1884
+ const length = view.getUint16(recordOffset + 8);
1885
+ const offset = view.getUint16(recordOffset + 10);
1886
+ if (platformID === 0 || (platformID === 3 && languageID === 0x0409)) {
1887
+ if (!index.has(nameID)) {
1888
+ index.set(nameID, {
1889
+ offset: nameOffset + stringOffset + offset,
1890
+ length,
1891
+ platformID,
1892
+ encodingID
1893
+ });
1894
+ }
1895
+ }
1896
+ }
1897
+ return index;
1898
+ }
1899
+ static getNameFromIndex(view, nameIndex, nameID) {
1900
+ if (!nameIndex)
1901
+ return null;
1902
+ const record = nameIndex.get(nameID);
1903
+ if (!record)
1904
+ return null;
1905
+ try {
1906
+ const bytes = new Uint8Array(view.buffer, record.offset, record.length);
1907
+ // UTF-16BE for Unicode/Windows platform with encoding 1
1908
+ if (record.platformID === 0 ||
1909
+ (record.platformID === 3 && record.encodingID === 1)) {
1910
+ return utf16beDecoder.decode(bytes);
1911
+ }
1912
+ // ASCII fallback
1913
+ return new TextDecoder('ascii').decode(bytes);
1914
+ }
1915
+ catch {
1916
+ return null;
1917
+ }
1918
+ }
1919
+ static extractMetricsWithIndex(view, tableDirectory, nameIndex) {
1920
+ const isCFF = tableDirectory.has(TABLE_TAG_CFF) || tableDirectory.has(TABLE_TAG_CFF2);
1921
+ const headTableOffset = tableDirectory.get(TABLE_TAG_HEAD)?.offset ?? 0;
1922
+ const hheaTableOffset = tableDirectory.get(TABLE_TAG_HHEA)?.offset ?? 0;
1923
+ const os2TableOffset = tableDirectory.get(TABLE_TAG_OS2)?.offset ?? 0;
1924
+ const fvarTableOffset = tableDirectory.get(TABLE_TAG_FVAR)?.offset ?? 0;
1925
+ const statTableOffset = tableDirectory.get(TABLE_TAG_STAT)?.offset ?? 0;
1926
+ const unitsPerEm = headTableOffset
1927
+ ? view.getUint16(headTableOffset + 18)
1928
+ : 1000;
1929
+ let hheaMetrics = null;
1930
+ if (hheaTableOffset) {
1931
+ hheaMetrics = {
1932
+ ascender: view.getInt16(hheaTableOffset + 4),
1933
+ descender: view.getInt16(hheaTableOffset + 6),
1934
+ lineGap: view.getInt16(hheaTableOffset + 8)
1935
+ };
1936
+ }
1937
+ let os2Metrics = null;
1938
+ if (os2TableOffset) {
1939
+ os2Metrics = {
1940
+ typoAscender: view.getInt16(os2TableOffset + 68),
1941
+ typoDescender: view.getInt16(os2TableOffset + 70),
1942
+ typoLineGap: view.getInt16(os2TableOffset + 72),
1943
+ winAscent: view.getUint16(os2TableOffset + 74),
1944
+ winDescent: view.getUint16(os2TableOffset + 76)
1945
+ };
1946
+ }
1947
+ let axisNames = null;
1948
+ if (fvarTableOffset && statTableOffset && nameIndex) {
1949
+ axisNames = this.extractAxisNamesWithIndex(view, statTableOffset, nameIndex);
1950
+ }
1951
+ return {
1952
+ isCFF,
1953
+ unitsPerEm,
1954
+ hheaAscender: hheaMetrics?.ascender || null,
1955
+ hheaDescender: hheaMetrics?.descender || null,
1956
+ hheaLineGap: hheaMetrics?.lineGap || null,
1957
+ typoAscender: os2Metrics?.typoAscender || null,
1958
+ typoDescender: os2Metrics?.typoDescender || null,
1959
+ typoLineGap: os2Metrics?.typoLineGap || null,
1960
+ winAscent: os2Metrics?.winAscent || null,
1961
+ winDescent: os2Metrics?.winDescent || null,
1962
+ axisNames
1963
+ };
1964
+ }
1965
+ static extractAxisNamesWithIndex(view, statOffset, nameIndex) {
1966
+ try {
1967
+ const majorVersion = view.getUint16(statOffset);
1968
+ if (majorVersion < 1)
1969
+ return null;
1970
+ const designAxisSize = view.getUint16(statOffset + 4);
1971
+ const designAxisCount = view.getUint16(statOffset + 6);
1972
+ const designAxisOffset = view.getUint32(statOffset + 8);
1973
+ const axisNames = {};
1974
+ for (let i = 0; i < designAxisCount; i++) {
1975
+ const axisRecordOffset = statOffset + designAxisOffset + i * designAxisSize;
1976
+ const axisTag = String.fromCharCode(view.getUint8(axisRecordOffset), view.getUint8(axisRecordOffset + 1), view.getUint8(axisRecordOffset + 2), view.getUint8(axisRecordOffset + 3));
1977
+ const axisNameID = view.getUint16(axisRecordOffset + 4);
1978
+ const name = this.getNameFromIndex(view, nameIndex, axisNameID);
1979
+ if (name) {
1980
+ axisNames[axisTag] = name;
1981
+ }
1982
+ }
1983
+ return Object.keys(axisNames).length > 0 ? axisNames : null;
1984
+ }
1985
+ catch {
1986
+ return null;
1987
+ }
1988
+ }
1989
+ static extractFeaturesWithIndex(view, tableDirectory, nameIndex) {
1990
+ const gsubTableOffset = tableDirectory.get(TABLE_TAG_GSUB)?.offset ?? 0;
1991
+ const gposTableOffset = tableDirectory.get(TABLE_TAG_GPOS)?.offset ?? 0;
1992
+ // Tags stored as uint32 during iteration, converted to strings at end
1993
+ const featureTags = new Set();
1994
+ const featureNames = {};
1995
+ try {
1996
+ if (gsubTableOffset) {
1997
+ this.extractFeatureData(view, gsubTableOffset, nameIndex, featureTags, featureNames);
1998
+ }
1999
+ if (gposTableOffset) {
2000
+ this.extractFeatureData(view, gposTableOffset, nameIndex, featureTags, featureNames);
2001
+ }
2002
+ }
2003
+ catch {
2004
+ return undefined;
2005
+ }
2006
+ if (featureTags.size === 0)
2007
+ return undefined;
2008
+ // Numeric sort preserves alphabetical order for big-endian tags
2009
+ const sortedTags = Array.from(featureTags).sort((a, b) => a - b);
2010
+ const featureArray = sortedTags.map(this.tagToString);
2011
+ return {
2012
+ tags: featureArray,
2013
+ names: Object.keys(featureNames).length > 0 ? featureNames : {}
2014
+ };
2015
+ }
2016
+ static extractFeatureData(view, tableOffset, nameIndex, featureTags, featureNames) {
2017
+ const featureListOffset = view.getUint16(tableOffset + 6);
2018
+ const featureListStart = tableOffset + featureListOffset;
2019
+ const featureCount = view.getUint16(featureListStart);
2020
+ for (let i = 0; i < featureCount; i++) {
2021
+ const recordOffset = featureListStart + 2 + i * 6;
2022
+ const tagBytes = view.getUint32(recordOffset);
2023
+ featureTags.add(tagBytes);
2024
+ // Stylistic sets (ss01-ss20) and character variants (cv01-cv99) may have UI names
2025
+ const prefix = (tagBytes >> 16) & 0xffff;
2026
+ if (!nameIndex)
2027
+ continue;
2028
+ if (prefix !== TAG_SS_PREFIX && prefix !== TAG_CV_PREFIX)
2029
+ continue;
2030
+ const d1 = (tagBytes >> 8) & 0xff;
2031
+ const d2 = tagBytes & 0xff;
2032
+ if (d1 < 0x30 || d1 > 0x39 || d2 < 0x30 || d2 > 0x39)
2033
+ continue;
2034
+ const featureOffset = view.getUint16(recordOffset + 4);
2035
+ const featureTableStart = featureListStart + featureOffset;
2036
+ const featureParamsOffset = view.getUint16(featureTableStart);
2037
+ if (featureParamsOffset === 0)
2038
+ continue;
2039
+ const paramsStart = featureTableStart + featureParamsOffset;
2040
+ const version = view.getUint16(paramsStart);
2041
+ if (version !== 0)
2042
+ continue;
2043
+ const nameID = view.getUint16(paramsStart + 2);
2044
+ const name = this.getNameFromIndex(view, nameIndex, nameID);
2045
+ if (name) {
2046
+ const tag = String.fromCharCode((tagBytes >> 24) & 0xff, (tagBytes >> 16) & 0xff, (tagBytes >> 8) & 0xff, tagBytes & 0xff);
2047
+ featureNames[tag] = name;
2048
+ }
2049
+ }
2050
+ }
2051
+ static tagToString(tag) {
2052
+ return String.fromCharCode((tag >> 24) & 0xff, (tag >> 16) & 0xff, (tag >> 8) & 0xff, tag & 0xff);
2053
+ }
2054
+ // Metric priority: typo > hhea > win > fallback (ignoring line gap per Google's approach)
1859
2055
  static getVerticalMetrics(metrics) {
1860
2056
  if (metrics.typoAscender !== null && metrics.typoDescender !== null) {
1861
2057
  return {
@@ -1954,7 +2150,6 @@ class WoffConverter {
1954
2150
  sfntView.setUint16(6, searchRange);
1955
2151
  sfntView.setUint16(8, Math.floor(Math.log2(numTables)));
1956
2152
  sfntView.setUint16(10, numTables * 16 - searchRange);
1957
- // Read and decompress table directory
1958
2153
  let sfntOffset = 12 + numTables * 16; // Start of table data
1959
2154
  const tableDirectory = [];
1960
2155
  // Read WOFF table directory
@@ -1970,29 +2165,27 @@ class WoffConverter {
1970
2165
  }
1971
2166
  // Sort tables by tag (required for SFNT)
1972
2167
  tableDirectory.sort((a, b) => a.tag - b.tag);
1973
- // Write SFNT table directory and decompress tables
2168
+ // Tables are independent, decompress in parallel
2169
+ const decompressedTables = await Promise.all(tableDirectory.map(async (table) => {
2170
+ if (table.length === table.origLength) {
2171
+ return data.subarray(table.offset, table.offset + table.length);
2172
+ }
2173
+ const compressedData = data.subarray(table.offset, table.offset + table.length);
2174
+ const decompressed = await WoffConverter.decompressZlib(compressedData);
2175
+ if (decompressed.byteLength !== table.origLength) {
2176
+ throw new Error(`Decompression failed: expected ${table.origLength} bytes, got ${decompressed.byteLength}`);
2177
+ }
2178
+ return new Uint8Array(decompressed);
2179
+ }));
2180
+ // Write SFNT table directory and table data
1974
2181
  for (let i = 0; i < numTables; i++) {
1975
2182
  const table = tableDirectory[i];
1976
2183
  const dirOffset = 12 + i * 16;
1977
- // Write SFNT table directory entry
1978
2184
  sfntView.setUint32(dirOffset, table.tag);
1979
2185
  sfntView.setUint32(dirOffset + 4, table.checksum);
1980
2186
  sfntView.setUint32(dirOffset + 8, sfntOffset);
1981
2187
  sfntView.setUint32(dirOffset + 12, table.origLength);
1982
- // Decompress or copy table data
1983
- if (table.length === table.origLength) {
1984
- // Uncompressed table - just copy
1985
- sfntData.set(data.subarray(table.offset, table.offset + table.length), sfntOffset);
1986
- }
1987
- else {
1988
- // Compressed table - decompress using DecompressionStream
1989
- const compressedData = data.subarray(table.offset, table.offset + table.length);
1990
- const decompressed = await WoffConverter.decompressZlib(compressedData);
1991
- if (decompressed.byteLength !== table.origLength) {
1992
- throw new Error(`Decompression failed: expected ${table.origLength} bytes, got ${decompressed.byteLength}`);
1993
- }
1994
- sfntData.set(new Uint8Array(decompressed), sfntOffset);
1995
- }
2188
+ sfntData.set(decompressedTables[i], sfntOffset);
1996
2189
  // Add padding to 4-byte boundary
1997
2190
  sfntOffset += table.origLength;
1998
2191
  const padding = (4 - (table.origLength % 4)) % 4;
@@ -2052,7 +2245,7 @@ class FontLoader {
2052
2245
  }
2053
2246
  const axisInfos = face.getAxisInfos();
2054
2247
  const isVariable = Object.keys(axisInfos).length > 0;
2055
- const metrics = FontMetadataExtractor.extractMetadata(fontBuffer);
2248
+ const { metrics, features: featureData } = FontMetadataExtractor.extractAll(fontBuffer);
2056
2249
  // Merge axis names from STAT table with HarfBuzz axis info
2057
2250
  let variationAxes = undefined;
2058
2251
  if (isVariable && axisInfos) {
@@ -2064,7 +2257,6 @@ class FontLoader {
2064
2257
  };
2065
2258
  }
2066
2259
  }
2067
- const featureData = FontMetadataExtractor.extractFeatureTags(fontBuffer);
2068
2260
  return {
2069
2261
  hb,
2070
2262
  fontBlob,
@@ -4365,7 +4557,7 @@ class TextShaper {
4365
4557
  buffer.destroy();
4366
4558
  const clusters = [];
4367
4559
  let currentClusterGlyphs = [];
4368
- let currentClusterText = '';
4560
+ let clusterTextChars = [];
4369
4561
  let clusterStartX = 0;
4370
4562
  let clusterStartY = 0;
4371
4563
  let cursorX = lineInfo.xOffset;
@@ -4375,17 +4567,24 @@ class TextShaper {
4375
4567
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
4376
4568
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4377
4569
  const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
4378
- for (let i = 0; i < glyphInfos.length; i++) {
4570
+ const lineText = lineInfo.text;
4571
+ const lineTextLength = lineText.length;
4572
+ const glyphCount = glyphInfos.length;
4573
+ let nextCharIsCJK;
4574
+ for (let i = 0; i < glyphCount; i++) {
4379
4575
  const glyph = glyphInfos[i];
4380
- const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
4576
+ const charIndex = glyph.cl;
4577
+ const char = lineText[charIndex];
4578
+ const charCode = char.charCodeAt(0);
4579
+ const isWhitespace = charCode === 32 || charCode === 9 || charCode === 10 || charCode === 13;
4381
4580
  // Inserted hyphens inherit the color of the last character in the word
4382
4581
  if (lineInfo.endedWithHyphen &&
4383
- glyph.cl === lineInfo.text.length - 1 &&
4384
- lineInfo.text[glyph.cl] === '-') {
4582
+ charIndex === lineTextLength - 1 &&
4583
+ char === '-') {
4385
4584
  glyph.absoluteTextIndex = lineInfo.originalEnd;
4386
4585
  }
4387
4586
  else {
4388
- glyph.absoluteTextIndex = lineInfo.originalStart + glyph.cl;
4587
+ glyph.absoluteTextIndex = lineInfo.originalStart + charIndex;
4389
4588
  }
4390
4589
  glyph.lineIndex = lineIndex;
4391
4590
  // Cluster boundaries are based on whitespace only.
@@ -4393,12 +4592,12 @@ class TextShaper {
4393
4592
  if (isWhitespace) {
4394
4593
  if (currentClusterGlyphs.length > 0) {
4395
4594
  clusters.push({
4396
- text: currentClusterText,
4595
+ text: clusterTextChars.join(''),
4397
4596
  glyphs: currentClusterGlyphs,
4398
4597
  position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4399
4598
  });
4400
4599
  currentClusterGlyphs = [];
4401
- currentClusterText = '';
4600
+ clusterTextChars = [];
4402
4601
  }
4403
4602
  }
4404
4603
  const absoluteGlyphX = cursorX + glyph.dx;
@@ -4411,32 +4610,31 @@ class TextShaper {
4411
4610
  glyph.x = absoluteGlyphX - clusterStartX;
4412
4611
  glyph.y = absoluteGlyphY - clusterStartY;
4413
4612
  currentClusterGlyphs.push(glyph);
4414
- currentClusterText += lineInfo.text[glyph.cl];
4613
+ clusterTextChars.push(char);
4415
4614
  }
4416
4615
  cursorX += glyph.ax;
4417
4616
  cursorY += glyph.ay;
4418
- if (letterSpacingFU !== 0 && i < glyphInfos.length - 1) {
4617
+ if (letterSpacingFU !== 0 && i < glyphCount - 1) {
4419
4618
  cursorX += letterSpacingFU;
4420
4619
  }
4421
4620
  if (isWhitespace) {
4422
4621
  cursorX += spaceAdjustment;
4423
4622
  }
4424
4623
  // CJK glue adjustment (must match exactly where LineBreak adds glue)
4425
- if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
4426
- const currentChar = lineInfo.text[glyph.cl];
4624
+ if (cjkAdjustment !== 0 && i < glyphCount - 1 && !isWhitespace) {
4427
4625
  const nextGlyph = glyphInfos[i + 1];
4428
- const nextChar = lineInfo.text[nextGlyph.cl];
4429
- const isCJKChar = LineBreak.isCJK(currentChar);
4430
- const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
4431
- if (isCJKChar && nextIsCJKChar) {
4626
+ const nextChar = lineText[nextGlyph.cl];
4627
+ const isCJK = nextCharIsCJK !== undefined ? nextCharIsCJK : LineBreak.isCJK(char);
4628
+ nextCharIsCJK = nextChar ? LineBreak.isCJK(nextChar) : false;
4629
+ if (isCJK && nextCharIsCJK) {
4432
4630
  let shouldApply = true;
4433
4631
  if (LineBreak.isCJClosingPunctuation(nextChar)) {
4434
4632
  shouldApply = false;
4435
4633
  }
4436
- if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4634
+ if (LineBreak.isCJOpeningPunctuation(char)) {
4437
4635
  shouldApply = false;
4438
4636
  }
4439
- if (LineBreak.isCJPunctuation(currentChar) &&
4637
+ if (LineBreak.isCJPunctuation(char) &&
4440
4638
  LineBreak.isCJPunctuation(nextChar)) {
4441
4639
  shouldApply = false;
4442
4640
  }
@@ -4445,10 +4643,13 @@ class TextShaper {
4445
4643
  }
4446
4644
  }
4447
4645
  }
4646
+ else {
4647
+ nextCharIsCJK = undefined;
4648
+ }
4448
4649
  }
4449
4650
  if (currentClusterGlyphs.length > 0) {
4450
4651
  clusters.push({
4451
- text: currentClusterText,
4652
+ text: clusterTextChars.join(''),
4452
4653
  glyphs: currentClusterGlyphs,
4453
4654
  position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4454
4655
  });
@@ -5242,8 +5443,13 @@ class Text {
5242
5443
  // Stringify with sorted keys for cache stability
5243
5444
  static stableStringify(obj) {
5244
5445
  const keys = Object.keys(obj).sort();
5245
- const pairs = keys.map((k) => `${k}:${obj[k]}`);
5246
- return pairs.join(',');
5446
+ let result = '';
5447
+ for (let i = 0; i < keys.length; i++) {
5448
+ if (i > 0)
5449
+ result += ',';
5450
+ result += keys[i] + ':' + obj[keys[i]];
5451
+ }
5452
+ return result;
5247
5453
  }
5248
5454
  constructor() {
5249
5455
  this.currentFontId = '';
@@ -5476,16 +5682,19 @@ class Text {
5476
5682
  // to selectively use glyph-level caching (separate vertices) only for clusters containing
5477
5683
  // colored text, while non-colored clusters can still use fast cluster-level merging
5478
5684
  let coloredTextIndices;
5685
+ let byTextMatches;
5479
5686
  if (options.color &&
5480
5687
  typeof options.color === 'object' &&
5481
5688
  !Array.isArray(options.color)) {
5482
5689
  if (options.color.byText || options.color.byCharRange) {
5483
- // Build the set manually since glyphs don't exist yet
5690
+ // Glyphs don't exist yet, so we scan text directly
5484
5691
  coloredTextIndices = new Set();
5485
5692
  if (options.color.byText) {
5693
+ byTextMatches = [];
5486
5694
  for (const pattern of Object.keys(options.color.byText)) {
5487
5695
  let index = 0;
5488
5696
  while ((index = options.text.indexOf(pattern, index)) !== -1) {
5697
+ byTextMatches.push({ pattern, start: index, end: index + pattern.length });
5489
5698
  for (let i = index; i < index + pattern.length; i++) {
5490
5699
  coloredTextIndices.add(i);
5491
5700
  }
@@ -5503,7 +5712,7 @@ class Text {
5503
5712
  }
5504
5713
  }
5505
5714
  const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, layoutData.pixelsPerFontUnit, options.perGlyphAttributes ?? false, coloredTextIndices);
5506
- const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, options.text);
5715
+ const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, options.text, byTextMatches);
5507
5716
  if (options.perGlyphAttributes) {
5508
5717
  const glyphAttrs = this.createGlyphAttributes(result.vertices.length / 3, result.glyphs);
5509
5718
  result.glyphAttributes = glyphAttrs;
@@ -5627,7 +5836,7 @@ class Text {
5627
5836
  pixelsPerFontUnit: 1 / fontUnitsPerPixel
5628
5837
  };
5629
5838
  }
5630
- applyColorSystem(vertices, glyphInfoArray, color, originalText) {
5839
+ applyColorSystem(vertices, glyphInfoArray, color, originalText, byTextMatches) {
5631
5840
  const vertexCount = vertices.length / 3;
5632
5841
  const colors = new Float32Array(vertexCount * 3);
5633
5842
  const coloredRanges = [];
@@ -5658,36 +5867,50 @@ class Text {
5658
5867
  colors[i + 1] = defaultColor[1];
5659
5868
  colors[i + 2] = defaultColor[2];
5660
5869
  }
5661
- // Apply text-based coloring using query system
5662
- if (color.byText) {
5663
- const rangeQuery = new TextRangeQuery(originalText, glyphInfoArray);
5664
- const textRanges = rangeQuery.execute({
5665
- byText: Object.keys(color.byText)
5666
- });
5667
- textRanges.forEach((range) => {
5668
- const targetColor = color.byText[range.originalText];
5669
- if (targetColor) {
5670
- range.glyphs.forEach((glyph) => {
5671
- for (let i = 0; i < glyph.vertexCount; i++) {
5672
- const vertexIndex = (glyph.vertexStart + i) * 3;
5673
- if (vertexIndex >= 0 && vertexIndex < colors.length) {
5674
- colors[vertexIndex] = targetColor[0];
5675
- colors[vertexIndex + 1] = targetColor[1];
5676
- colors[vertexIndex + 2] = targetColor[2];
5870
+ if (color.byText && byTextMatches) {
5871
+ const glyphsByTextIndex = new Map();
5872
+ for (const glyph of glyphInfoArray) {
5873
+ const existing = glyphsByTextIndex.get(glyph.textIndex);
5874
+ if (existing) {
5875
+ existing.push(glyph);
5876
+ }
5877
+ else {
5878
+ glyphsByTextIndex.set(glyph.textIndex, [glyph]);
5879
+ }
5880
+ }
5881
+ for (const match of byTextMatches) {
5882
+ const targetColor = color.byText[match.pattern];
5883
+ if (!targetColor)
5884
+ continue;
5885
+ const matchGlyphs = [];
5886
+ const lineIndicesSet = new Set();
5887
+ for (let i = match.start; i < match.end; i++) {
5888
+ const glyphs = glyphsByTextIndex.get(i);
5889
+ if (glyphs) {
5890
+ for (const glyph of glyphs) {
5891
+ matchGlyphs.push(glyph);
5892
+ lineIndicesSet.add(glyph.lineIndex);
5893
+ for (let v = 0; v < glyph.vertexCount; v++) {
5894
+ const vertexIndex = (glyph.vertexStart + v) * 3;
5895
+ if (vertexIndex >= 0 && vertexIndex < colors.length) {
5896
+ colors[vertexIndex] = targetColor[0];
5897
+ colors[vertexIndex + 1] = targetColor[1];
5898
+ colors[vertexIndex + 2] = targetColor[2];
5899
+ }
5677
5900
  }
5678
5901
  }
5679
- });
5680
- coloredRanges.push({
5681
- start: range.start,
5682
- end: range.end,
5683
- originalText: range.originalText,
5684
- color: targetColor,
5685
- bounds: range.bounds,
5686
- glyphs: range.glyphs,
5687
- lineIndices: range.lineIndices
5688
- });
5902
+ }
5689
5903
  }
5690
- });
5904
+ coloredRanges.push({
5905
+ start: match.start,
5906
+ end: match.end,
5907
+ originalText: match.pattern,
5908
+ color: targetColor,
5909
+ bounds: [],
5910
+ glyphs: matchGlyphs,
5911
+ lineIndices: Array.from(lineIndicesSet).sort((a, b) => a - b)
5912
+ });
5913
+ }
5691
5914
  }
5692
5915
  // Apply range coloring
5693
5916
  if (color.byCharRange) {
@@ -5720,7 +5943,7 @@ class Text {
5720
5943
  }
5721
5944
  return { colors, coloredRanges };
5722
5945
  }
5723
- finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, originalText) {
5946
+ finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, originalText, byTextMatches) {
5724
5947
  const { layout = {} } = options;
5725
5948
  const { width, align = layout.direction === 'rtl' ? 'right' : 'left' } = layout;
5726
5949
  if (!this.textLayout) {
@@ -5746,7 +5969,7 @@ class Text {
5746
5969
  let colors;
5747
5970
  let coloredRanges;
5748
5971
  if (options.color) {
5749
- const colorResult = this.applyColorSystem(vertices, glyphInfoArray, options.color, options.text);
5972
+ const colorResult = this.applyColorSystem(vertices, glyphInfoArray, options.color, options.text, byTextMatches);
5750
5973
  colors = colorResult.colors;
5751
5974
  coloredRanges = colorResult.coloredRanges;
5752
5975
  }
@@ -5768,13 +5991,18 @@ class Text {
5768
5991
  pointsRemovedByColinear: optimizationStats.pointsRemovedByColinear,
5769
5992
  originalPointCount: optimizationStats.originalPointCount
5770
5993
  },
5771
- query: (options) => {
5772
- if (!originalText) {
5773
- throw new Error('Original text not available for querying');
5774
- }
5775
- const queryInstance = new TextRangeQuery(originalText, glyphInfoArray);
5776
- return queryInstance.execute(options);
5777
- },
5994
+ query: (() => {
5995
+ let cachedQuery = null;
5996
+ return (options) => {
5997
+ if (!originalText) {
5998
+ throw new Error('Original text not available for querying');
5999
+ }
6000
+ if (!cachedQuery) {
6001
+ cachedQuery = new TextRangeQuery(originalText, glyphInfoArray);
6002
+ }
6003
+ return cachedQuery.execute(options);
6004
+ };
6005
+ })(),
5778
6006
  coloredRanges,
5779
6007
  glyphAttributes: undefined
5780
6008
  };