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