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.umd.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
@@ -1636,9 +1636,7 @@
1636
1636
  const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
1637
1637
  const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
1638
1638
 
1639
- // Parses the SFNT table directory for TTF/OTF fonts
1640
- // Assumes the DataView is positioned at the start of an sfnt font (offset 0)
1641
- // Table records are 16 bytes each starting at byte offset 12
1639
+ // SFNT table directory
1642
1640
  function parseTableDirectory(view) {
1643
1641
  const numTables = view.getUint16(4);
1644
1642
  const tableRecordsStart = 12;
@@ -1658,6 +1656,10 @@
1658
1656
  return tables;
1659
1657
  }
1660
1658
 
1659
+ // OpenType tag prefixes as uint16 for bitwise comparison
1660
+ const TAG_SS_PREFIX = 0x7373; // 'ss'
1661
+ const TAG_CV_PREFIX = 0x6376; // 'cv'
1662
+ const utf16beDecoder = new TextDecoder('utf-16be');
1661
1663
  class FontMetadataExtractor {
1662
1664
  static extractMetadata(fontBuffer) {
1663
1665
  if (!fontBuffer || fontBuffer.byteLength < 12) {
@@ -1759,7 +1761,6 @@
1759
1761
  const names = {};
1760
1762
  for (let i = 0; i < featureCount; i++) {
1761
1763
  const recordOffset = featureListStart + 2 + i * 6;
1762
- // Decode feature tag
1763
1764
  const tag = String.fromCharCode(view.getUint8(recordOffset), view.getUint8(recordOffset + 1), view.getUint8(recordOffset + 2), view.getUint8(recordOffset + 3));
1764
1765
  features.push(tag);
1765
1766
  // Extract feature name for stylistic sets and character variants
@@ -1791,9 +1792,7 @@
1791
1792
  }
1792
1793
  static extractAxisNames(view, statOffset, nameOffset) {
1793
1794
  try {
1794
- // STAT table structure
1795
1795
  const majorVersion = view.getUint16(statOffset);
1796
- // We need at least version 1.0
1797
1796
  if (majorVersion < 1)
1798
1797
  return null;
1799
1798
  const designAxisSize = view.getUint16(statOffset + 4);
@@ -1818,10 +1817,8 @@
1818
1817
  }
1819
1818
  static getNameFromNameTable(view, nameOffset, nameID) {
1820
1819
  try {
1821
- // const format = view.getUint16(nameOffset);
1822
1820
  const count = view.getUint16(nameOffset + 2);
1823
1821
  const stringOffset = view.getUint16(nameOffset + 4);
1824
- // Look for the name record with our nameID (preferring English)
1825
1822
  for (let i = 0; i < count; i++) {
1826
1823
  const recordOffset = nameOffset + 6 + i * 12;
1827
1824
  const platformID = view.getUint16(recordOffset);
@@ -1830,26 +1827,22 @@
1830
1827
  const recordNameID = view.getUint16(recordOffset + 6);
1831
1828
  const length = view.getUint16(recordOffset + 8);
1832
1829
  const offset = view.getUint16(recordOffset + 10);
1833
- if (recordNameID === nameID) {
1834
- // Prefer Unicode or Windows platform English names
1835
- if (platformID === 0 || (platformID === 3 && languageID === 0x0409)) {
1836
- const stringStart = nameOffset + stringOffset + offset;
1837
- const bytes = new Uint8Array(view.buffer, stringStart, length);
1838
- // Decode based on platform
1839
- if (platformID === 0 || (platformID === 3 && encodingID === 1)) {
1840
- // UTF-16BE
1841
- let str = '';
1842
- for (let j = 0; j < bytes.length; j += 2) {
1843
- str += String.fromCharCode((bytes[j] << 8) | bytes[j + 1]);
1844
- }
1845
- return str;
1846
- }
1847
- else {
1848
- // ASCII
1849
- return new TextDecoder('ascii').decode(bytes);
1850
- }
1830
+ if (recordNameID !== nameID)
1831
+ continue;
1832
+ // Accept Unicode platform or Windows US English
1833
+ if (platformID !== 0 && !(platformID === 3 && languageID === 0x0409)) {
1834
+ continue;
1835
+ }
1836
+ const stringStart = nameOffset + stringOffset + offset;
1837
+ const bytes = new Uint8Array(view.buffer, stringStart, length);
1838
+ if (platformID === 0 || (platformID === 3 && encodingID === 1)) {
1839
+ let str = '';
1840
+ for (let j = 0; j < bytes.length; j += 2) {
1841
+ str += String.fromCharCode((bytes[j] << 8) | bytes[j + 1]);
1851
1842
  }
1843
+ return str;
1852
1844
  }
1845
+ return new TextDecoder('ascii').decode(bytes);
1853
1846
  }
1854
1847
  return null;
1855
1848
  }
@@ -1857,7 +1850,210 @@
1857
1850
  return null;
1858
1851
  }
1859
1852
  }
1860
- // Priority: typo metrics > hhea metrics > win metrics > fallback, ignore line gap (Google strategy)
1853
+ static extractAll(fontBuffer) {
1854
+ if (!fontBuffer || fontBuffer.byteLength < 12) {
1855
+ throw new Error('Invalid font buffer: too small to be a valid font file');
1856
+ }
1857
+ const view = new DataView(fontBuffer);
1858
+ const sfntVersion = view.getUint32(0);
1859
+ const validSignatures = [
1860
+ FONT_SIGNATURE_TRUE_TYPE,
1861
+ FONT_SIGNATURE_OPEN_TYPE_CFF
1862
+ ];
1863
+ if (!validSignatures.includes(sfntVersion)) {
1864
+ throw new Error(`Invalid font format. Expected TTF/OTF (or WOFF), got signature: 0x${sfntVersion.toString(16)}`);
1865
+ }
1866
+ const tableDirectory = parseTableDirectory(view);
1867
+ const nameTableOffset = tableDirectory.get(TABLE_TAG_NAME)?.offset ?? 0;
1868
+ const nameIndex = nameTableOffset
1869
+ ? this.buildNameIndex(view, nameTableOffset)
1870
+ : null;
1871
+ const metrics = this.extractMetricsWithIndex(view, tableDirectory, nameIndex);
1872
+ const features = this.extractFeaturesWithIndex(view, tableDirectory, nameIndex);
1873
+ return { metrics, features };
1874
+ }
1875
+ // Index name records by nameID; prefers Unicode (platform 0) or Windows US English
1876
+ static buildNameIndex(view, nameOffset) {
1877
+ const index = new Map();
1878
+ const count = view.getUint16(nameOffset + 2);
1879
+ const stringOffset = view.getUint16(nameOffset + 4);
1880
+ for (let i = 0; i < count; i++) {
1881
+ const recordOffset = nameOffset + 6 + i * 12;
1882
+ const platformID = view.getUint16(recordOffset);
1883
+ const encodingID = view.getUint16(recordOffset + 2);
1884
+ const languageID = view.getUint16(recordOffset + 4);
1885
+ const nameID = view.getUint16(recordOffset + 6);
1886
+ const length = view.getUint16(recordOffset + 8);
1887
+ const offset = view.getUint16(recordOffset + 10);
1888
+ if (platformID === 0 || (platformID === 3 && languageID === 0x0409)) {
1889
+ if (!index.has(nameID)) {
1890
+ index.set(nameID, {
1891
+ offset: nameOffset + stringOffset + offset,
1892
+ length,
1893
+ platformID,
1894
+ encodingID
1895
+ });
1896
+ }
1897
+ }
1898
+ }
1899
+ return index;
1900
+ }
1901
+ static getNameFromIndex(view, nameIndex, nameID) {
1902
+ if (!nameIndex)
1903
+ return null;
1904
+ const record = nameIndex.get(nameID);
1905
+ if (!record)
1906
+ return null;
1907
+ try {
1908
+ const bytes = new Uint8Array(view.buffer, record.offset, record.length);
1909
+ // UTF-16BE for Unicode/Windows platform with encoding 1
1910
+ if (record.platformID === 0 ||
1911
+ (record.platformID === 3 && record.encodingID === 1)) {
1912
+ return utf16beDecoder.decode(bytes);
1913
+ }
1914
+ // ASCII fallback
1915
+ return new TextDecoder('ascii').decode(bytes);
1916
+ }
1917
+ catch {
1918
+ return null;
1919
+ }
1920
+ }
1921
+ static extractMetricsWithIndex(view, tableDirectory, nameIndex) {
1922
+ const isCFF = tableDirectory.has(TABLE_TAG_CFF) || tableDirectory.has(TABLE_TAG_CFF2);
1923
+ const headTableOffset = tableDirectory.get(TABLE_TAG_HEAD)?.offset ?? 0;
1924
+ const hheaTableOffset = tableDirectory.get(TABLE_TAG_HHEA)?.offset ?? 0;
1925
+ const os2TableOffset = tableDirectory.get(TABLE_TAG_OS2)?.offset ?? 0;
1926
+ const fvarTableOffset = tableDirectory.get(TABLE_TAG_FVAR)?.offset ?? 0;
1927
+ const statTableOffset = tableDirectory.get(TABLE_TAG_STAT)?.offset ?? 0;
1928
+ const unitsPerEm = headTableOffset
1929
+ ? view.getUint16(headTableOffset + 18)
1930
+ : 1000;
1931
+ let hheaMetrics = null;
1932
+ if (hheaTableOffset) {
1933
+ hheaMetrics = {
1934
+ ascender: view.getInt16(hheaTableOffset + 4),
1935
+ descender: view.getInt16(hheaTableOffset + 6),
1936
+ lineGap: view.getInt16(hheaTableOffset + 8)
1937
+ };
1938
+ }
1939
+ let os2Metrics = null;
1940
+ if (os2TableOffset) {
1941
+ os2Metrics = {
1942
+ typoAscender: view.getInt16(os2TableOffset + 68),
1943
+ typoDescender: view.getInt16(os2TableOffset + 70),
1944
+ typoLineGap: view.getInt16(os2TableOffset + 72),
1945
+ winAscent: view.getUint16(os2TableOffset + 74),
1946
+ winDescent: view.getUint16(os2TableOffset + 76)
1947
+ };
1948
+ }
1949
+ let axisNames = null;
1950
+ if (fvarTableOffset && statTableOffset && nameIndex) {
1951
+ axisNames = this.extractAxisNamesWithIndex(view, statTableOffset, nameIndex);
1952
+ }
1953
+ return {
1954
+ isCFF,
1955
+ unitsPerEm,
1956
+ hheaAscender: hheaMetrics?.ascender || null,
1957
+ hheaDescender: hheaMetrics?.descender || null,
1958
+ hheaLineGap: hheaMetrics?.lineGap || null,
1959
+ typoAscender: os2Metrics?.typoAscender || null,
1960
+ typoDescender: os2Metrics?.typoDescender || null,
1961
+ typoLineGap: os2Metrics?.typoLineGap || null,
1962
+ winAscent: os2Metrics?.winAscent || null,
1963
+ winDescent: os2Metrics?.winDescent || null,
1964
+ axisNames
1965
+ };
1966
+ }
1967
+ static extractAxisNamesWithIndex(view, statOffset, nameIndex) {
1968
+ try {
1969
+ const majorVersion = view.getUint16(statOffset);
1970
+ if (majorVersion < 1)
1971
+ return null;
1972
+ const designAxisSize = view.getUint16(statOffset + 4);
1973
+ const designAxisCount = view.getUint16(statOffset + 6);
1974
+ const designAxisOffset = view.getUint32(statOffset + 8);
1975
+ const axisNames = {};
1976
+ for (let i = 0; i < designAxisCount; i++) {
1977
+ const axisRecordOffset = statOffset + designAxisOffset + i * designAxisSize;
1978
+ const axisTag = String.fromCharCode(view.getUint8(axisRecordOffset), view.getUint8(axisRecordOffset + 1), view.getUint8(axisRecordOffset + 2), view.getUint8(axisRecordOffset + 3));
1979
+ const axisNameID = view.getUint16(axisRecordOffset + 4);
1980
+ const name = this.getNameFromIndex(view, nameIndex, axisNameID);
1981
+ if (name) {
1982
+ axisNames[axisTag] = name;
1983
+ }
1984
+ }
1985
+ return Object.keys(axisNames).length > 0 ? axisNames : null;
1986
+ }
1987
+ catch {
1988
+ return null;
1989
+ }
1990
+ }
1991
+ static extractFeaturesWithIndex(view, tableDirectory, nameIndex) {
1992
+ const gsubTableOffset = tableDirectory.get(TABLE_TAG_GSUB)?.offset ?? 0;
1993
+ const gposTableOffset = tableDirectory.get(TABLE_TAG_GPOS)?.offset ?? 0;
1994
+ // Tags stored as uint32 during iteration, converted to strings at end
1995
+ const featureTags = new Set();
1996
+ const featureNames = {};
1997
+ try {
1998
+ if (gsubTableOffset) {
1999
+ this.extractFeatureData(view, gsubTableOffset, nameIndex, featureTags, featureNames);
2000
+ }
2001
+ if (gposTableOffset) {
2002
+ this.extractFeatureData(view, gposTableOffset, nameIndex, featureTags, featureNames);
2003
+ }
2004
+ }
2005
+ catch {
2006
+ return undefined;
2007
+ }
2008
+ if (featureTags.size === 0)
2009
+ return undefined;
2010
+ // Numeric sort preserves alphabetical order for big-endian tags
2011
+ const sortedTags = Array.from(featureTags).sort((a, b) => a - b);
2012
+ const featureArray = sortedTags.map(this.tagToString);
2013
+ return {
2014
+ tags: featureArray,
2015
+ names: Object.keys(featureNames).length > 0 ? featureNames : {}
2016
+ };
2017
+ }
2018
+ static extractFeatureData(view, tableOffset, nameIndex, featureTags, featureNames) {
2019
+ const featureListOffset = view.getUint16(tableOffset + 6);
2020
+ const featureListStart = tableOffset + featureListOffset;
2021
+ const featureCount = view.getUint16(featureListStart);
2022
+ for (let i = 0; i < featureCount; i++) {
2023
+ const recordOffset = featureListStart + 2 + i * 6;
2024
+ const tagBytes = view.getUint32(recordOffset);
2025
+ featureTags.add(tagBytes);
2026
+ // Stylistic sets (ss01-ss20) and character variants (cv01-cv99) may have UI names
2027
+ const prefix = (tagBytes >> 16) & 0xffff;
2028
+ if (!nameIndex)
2029
+ continue;
2030
+ if (prefix !== TAG_SS_PREFIX && prefix !== TAG_CV_PREFIX)
2031
+ continue;
2032
+ const d1 = (tagBytes >> 8) & 0xff;
2033
+ const d2 = tagBytes & 0xff;
2034
+ if (d1 < 0x30 || d1 > 0x39 || d2 < 0x30 || d2 > 0x39)
2035
+ continue;
2036
+ const featureOffset = view.getUint16(recordOffset + 4);
2037
+ const featureTableStart = featureListStart + featureOffset;
2038
+ const featureParamsOffset = view.getUint16(featureTableStart);
2039
+ if (featureParamsOffset === 0)
2040
+ continue;
2041
+ const paramsStart = featureTableStart + featureParamsOffset;
2042
+ const version = view.getUint16(paramsStart);
2043
+ if (version !== 0)
2044
+ continue;
2045
+ const nameID = view.getUint16(paramsStart + 2);
2046
+ const name = this.getNameFromIndex(view, nameIndex, nameID);
2047
+ if (name) {
2048
+ const tag = String.fromCharCode((tagBytes >> 24) & 0xff, (tagBytes >> 16) & 0xff, (tagBytes >> 8) & 0xff, tagBytes & 0xff);
2049
+ featureNames[tag] = name;
2050
+ }
2051
+ }
2052
+ }
2053
+ static tagToString(tag) {
2054
+ return String.fromCharCode((tag >> 24) & 0xff, (tag >> 16) & 0xff, (tag >> 8) & 0xff, tag & 0xff);
2055
+ }
2056
+ // Metric priority: typo > hhea > win > fallback (ignoring line gap per Google's approach)
1861
2057
  static getVerticalMetrics(metrics) {
1862
2058
  if (metrics.typoAscender !== null && metrics.typoDescender !== null) {
1863
2059
  return {
@@ -1956,7 +2152,6 @@
1956
2152
  sfntView.setUint16(6, searchRange);
1957
2153
  sfntView.setUint16(8, Math.floor(Math.log2(numTables)));
1958
2154
  sfntView.setUint16(10, numTables * 16 - searchRange);
1959
- // Read and decompress table directory
1960
2155
  let sfntOffset = 12 + numTables * 16; // Start of table data
1961
2156
  const tableDirectory = [];
1962
2157
  // Read WOFF table directory
@@ -1972,29 +2167,27 @@
1972
2167
  }
1973
2168
  // Sort tables by tag (required for SFNT)
1974
2169
  tableDirectory.sort((a, b) => a.tag - b.tag);
1975
- // Write SFNT table directory and decompress tables
2170
+ // Tables are independent, decompress in parallel
2171
+ const decompressedTables = await Promise.all(tableDirectory.map(async (table) => {
2172
+ if (table.length === table.origLength) {
2173
+ return data.subarray(table.offset, table.offset + table.length);
2174
+ }
2175
+ const compressedData = data.subarray(table.offset, table.offset + table.length);
2176
+ const decompressed = await WoffConverter.decompressZlib(compressedData);
2177
+ if (decompressed.byteLength !== table.origLength) {
2178
+ throw new Error(`Decompression failed: expected ${table.origLength} bytes, got ${decompressed.byteLength}`);
2179
+ }
2180
+ return new Uint8Array(decompressed);
2181
+ }));
2182
+ // Write SFNT table directory and table data
1976
2183
  for (let i = 0; i < numTables; i++) {
1977
2184
  const table = tableDirectory[i];
1978
2185
  const dirOffset = 12 + i * 16;
1979
- // Write SFNT table directory entry
1980
2186
  sfntView.setUint32(dirOffset, table.tag);
1981
2187
  sfntView.setUint32(dirOffset + 4, table.checksum);
1982
2188
  sfntView.setUint32(dirOffset + 8, sfntOffset);
1983
2189
  sfntView.setUint32(dirOffset + 12, table.origLength);
1984
- // Decompress or copy table data
1985
- if (table.length === table.origLength) {
1986
- // Uncompressed table - just copy
1987
- sfntData.set(data.subarray(table.offset, table.offset + table.length), sfntOffset);
1988
- }
1989
- else {
1990
- // Compressed table - decompress using DecompressionStream
1991
- const compressedData = data.subarray(table.offset, table.offset + table.length);
1992
- const decompressed = await WoffConverter.decompressZlib(compressedData);
1993
- if (decompressed.byteLength !== table.origLength) {
1994
- throw new Error(`Decompression failed: expected ${table.origLength} bytes, got ${decompressed.byteLength}`);
1995
- }
1996
- sfntData.set(new Uint8Array(decompressed), sfntOffset);
1997
- }
2190
+ sfntData.set(decompressedTables[i], sfntOffset);
1998
2191
  // Add padding to 4-byte boundary
1999
2192
  sfntOffset += table.origLength;
2000
2193
  const padding = (4 - (table.origLength % 4)) % 4;
@@ -2054,7 +2247,7 @@
2054
2247
  }
2055
2248
  const axisInfos = face.getAxisInfos();
2056
2249
  const isVariable = Object.keys(axisInfos).length > 0;
2057
- const metrics = FontMetadataExtractor.extractMetadata(fontBuffer);
2250
+ const { metrics, features: featureData } = FontMetadataExtractor.extractAll(fontBuffer);
2058
2251
  // Merge axis names from STAT table with HarfBuzz axis info
2059
2252
  let variationAxes = undefined;
2060
2253
  if (isVariable && axisInfos) {
@@ -2066,7 +2259,6 @@
2066
2259
  };
2067
2260
  }
2068
2261
  }
2069
- const featureData = FontMetadataExtractor.extractFeatureTags(fontBuffer);
2070
2262
  return {
2071
2263
  hb,
2072
2264
  fontBlob,
@@ -4369,7 +4561,7 @@
4369
4561
  buffer.destroy();
4370
4562
  const clusters = [];
4371
4563
  let currentClusterGlyphs = [];
4372
- let currentClusterText = '';
4564
+ let clusterTextChars = [];
4373
4565
  let clusterStartX = 0;
4374
4566
  let clusterStartY = 0;
4375
4567
  let cursorX = lineInfo.xOffset;
@@ -4379,17 +4571,24 @@
4379
4571
  const letterSpacingFU = letterSpacing * this.loadedFont.upem;
4380
4572
  const spaceAdjustment = this.calculateSpaceAdjustment(lineInfo, align, letterSpacing);
4381
4573
  const cjkAdjustment = this.calculateCJKAdjustment(lineInfo, align);
4382
- for (let i = 0; i < glyphInfos.length; i++) {
4574
+ const lineText = lineInfo.text;
4575
+ const lineTextLength = lineText.length;
4576
+ const glyphCount = glyphInfos.length;
4577
+ let nextCharIsCJK;
4578
+ for (let i = 0; i < glyphCount; i++) {
4383
4579
  const glyph = glyphInfos[i];
4384
- const isWhitespace = /\s/.test(lineInfo.text[glyph.cl]);
4580
+ const charIndex = glyph.cl;
4581
+ const char = lineText[charIndex];
4582
+ const charCode = char.charCodeAt(0);
4583
+ const isWhitespace = charCode === 32 || charCode === 9 || charCode === 10 || charCode === 13;
4385
4584
  // Inserted hyphens inherit the color of the last character in the word
4386
4585
  if (lineInfo.endedWithHyphen &&
4387
- glyph.cl === lineInfo.text.length - 1 &&
4388
- lineInfo.text[glyph.cl] === '-') {
4586
+ charIndex === lineTextLength - 1 &&
4587
+ char === '-') {
4389
4588
  glyph.absoluteTextIndex = lineInfo.originalEnd;
4390
4589
  }
4391
4590
  else {
4392
- glyph.absoluteTextIndex = lineInfo.originalStart + glyph.cl;
4591
+ glyph.absoluteTextIndex = lineInfo.originalStart + charIndex;
4393
4592
  }
4394
4593
  glyph.lineIndex = lineIndex;
4395
4594
  // Cluster boundaries are based on whitespace only.
@@ -4397,12 +4596,12 @@
4397
4596
  if (isWhitespace) {
4398
4597
  if (currentClusterGlyphs.length > 0) {
4399
4598
  clusters.push({
4400
- text: currentClusterText,
4599
+ text: clusterTextChars.join(''),
4401
4600
  glyphs: currentClusterGlyphs,
4402
4601
  position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4403
4602
  });
4404
4603
  currentClusterGlyphs = [];
4405
- currentClusterText = '';
4604
+ clusterTextChars = [];
4406
4605
  }
4407
4606
  }
4408
4607
  const absoluteGlyphX = cursorX + glyph.dx;
@@ -4415,32 +4614,31 @@
4415
4614
  glyph.x = absoluteGlyphX - clusterStartX;
4416
4615
  glyph.y = absoluteGlyphY - clusterStartY;
4417
4616
  currentClusterGlyphs.push(glyph);
4418
- currentClusterText += lineInfo.text[glyph.cl];
4617
+ clusterTextChars.push(char);
4419
4618
  }
4420
4619
  cursorX += glyph.ax;
4421
4620
  cursorY += glyph.ay;
4422
- if (letterSpacingFU !== 0 && i < glyphInfos.length - 1) {
4621
+ if (letterSpacingFU !== 0 && i < glyphCount - 1) {
4423
4622
  cursorX += letterSpacingFU;
4424
4623
  }
4425
4624
  if (isWhitespace) {
4426
4625
  cursorX += spaceAdjustment;
4427
4626
  }
4428
4627
  // CJK glue adjustment (must match exactly where LineBreak adds glue)
4429
- if (cjkAdjustment !== 0 && i < glyphInfos.length - 1 && !isWhitespace) {
4430
- const currentChar = lineInfo.text[glyph.cl];
4628
+ if (cjkAdjustment !== 0 && i < glyphCount - 1 && !isWhitespace) {
4431
4629
  const nextGlyph = glyphInfos[i + 1];
4432
- const nextChar = lineInfo.text[nextGlyph.cl];
4433
- const isCJKChar = LineBreak.isCJK(currentChar);
4434
- const nextIsCJKChar = nextChar && LineBreak.isCJK(nextChar);
4435
- if (isCJKChar && nextIsCJKChar) {
4630
+ const nextChar = lineText[nextGlyph.cl];
4631
+ const isCJK = nextCharIsCJK !== undefined ? nextCharIsCJK : LineBreak.isCJK(char);
4632
+ nextCharIsCJK = nextChar ? LineBreak.isCJK(nextChar) : false;
4633
+ if (isCJK && nextCharIsCJK) {
4436
4634
  let shouldApply = true;
4437
4635
  if (LineBreak.isCJClosingPunctuation(nextChar)) {
4438
4636
  shouldApply = false;
4439
4637
  }
4440
- if (LineBreak.isCJOpeningPunctuation(currentChar)) {
4638
+ if (LineBreak.isCJOpeningPunctuation(char)) {
4441
4639
  shouldApply = false;
4442
4640
  }
4443
- if (LineBreak.isCJPunctuation(currentChar) &&
4641
+ if (LineBreak.isCJPunctuation(char) &&
4444
4642
  LineBreak.isCJPunctuation(nextChar)) {
4445
4643
  shouldApply = false;
4446
4644
  }
@@ -4449,10 +4647,13 @@
4449
4647
  }
4450
4648
  }
4451
4649
  }
4650
+ else {
4651
+ nextCharIsCJK = undefined;
4652
+ }
4452
4653
  }
4453
4654
  if (currentClusterGlyphs.length > 0) {
4454
4655
  clusters.push({
4455
- text: currentClusterText,
4656
+ text: clusterTextChars.join(''),
4456
4657
  glyphs: currentClusterGlyphs,
4457
4658
  position: new Vec3(clusterStartX, clusterStartY, cursorZ)
4458
4659
  });
@@ -5246,8 +5447,13 @@
5246
5447
  // Stringify with sorted keys for cache stability
5247
5448
  static stableStringify(obj) {
5248
5449
  const keys = Object.keys(obj).sort();
5249
- const pairs = keys.map((k) => `${k}:${obj[k]}`);
5250
- return pairs.join(',');
5450
+ let result = '';
5451
+ for (let i = 0; i < keys.length; i++) {
5452
+ if (i > 0)
5453
+ result += ',';
5454
+ result += keys[i] + ':' + obj[keys[i]];
5455
+ }
5456
+ return result;
5251
5457
  }
5252
5458
  constructor() {
5253
5459
  this.currentFontId = '';
@@ -5282,7 +5488,6 @@
5282
5488
  const loadedFont = await Text.resolveFont(options);
5283
5489
  const text = new Text();
5284
5490
  text.setLoadedFont(loadedFont);
5285
- // Pass full options so createGeometry honors maxCacheSizeMB etc
5286
5491
  const result = await text.createGeometry(options);
5287
5492
  // Recursive update function
5288
5493
  const update = async (newOptions) => {
@@ -5463,10 +5668,7 @@
5463
5668
  options = updatedOptions;
5464
5669
  this.updateFontVariations(options);
5465
5670
  if (!this.geometryBuilder) {
5466
- const cache = options.maxCacheSizeMB
5467
- ? createGlyphCache()
5468
- : globalGlyphCache;
5469
- this.geometryBuilder = new GlyphGeometryBuilder(cache, this.loadedFont);
5671
+ this.geometryBuilder = new GlyphGeometryBuilder(globalGlyphCache, this.loadedFont);
5470
5672
  this.geometryBuilder.setFontId(this.currentFontId);
5471
5673
  }
5472
5674
  this.geometryBuilder.setCurveFidelityConfig(options.curveFidelity);
@@ -5484,16 +5686,19 @@
5484
5686
  // to selectively use glyph-level caching (separate vertices) only for clusters containing
5485
5687
  // colored text, while non-colored clusters can still use fast cluster-level merging
5486
5688
  let coloredTextIndices;
5689
+ let byTextMatches;
5487
5690
  if (options.color &&
5488
5691
  typeof options.color === 'object' &&
5489
5692
  !Array.isArray(options.color)) {
5490
5693
  if (options.color.byText || options.color.byCharRange) {
5491
- // Build the set manually since glyphs don't exist yet
5694
+ // Glyphs don't exist yet, so we scan text directly
5492
5695
  coloredTextIndices = new Set();
5493
5696
  if (options.color.byText) {
5697
+ byTextMatches = [];
5494
5698
  for (const pattern of Object.keys(options.color.byText)) {
5495
5699
  let index = 0;
5496
5700
  while ((index = options.text.indexOf(pattern, index)) !== -1) {
5701
+ byTextMatches.push({ pattern, start: index, end: index + pattern.length });
5497
5702
  for (let i = index; i < index + pattern.length; i++) {
5498
5703
  coloredTextIndices.add(i);
5499
5704
  }
@@ -5511,7 +5716,7 @@
5511
5716
  }
5512
5717
  }
5513
5718
  const shapedResult = this.geometryBuilder.buildInstancedGeometry(clustersByLine, layoutData.depth, shouldRemoveOverlaps, this.loadedFont.metrics.isCFF, layoutData.pixelsPerFontUnit, options.perGlyphAttributes ?? false, coloredTextIndices);
5514
- const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, options.text);
5719
+ const result = this.finalizeGeometry(shapedResult.vertices, shapedResult.normals, shapedResult.indices, shapedResult.glyphInfos, shapedResult.planeBounds, options, options.text, byTextMatches);
5515
5720
  if (options.perGlyphAttributes) {
5516
5721
  const glyphAttrs = this.createGlyphAttributes(result.vertices.length / 3, result.glyphs);
5517
5722
  result.glyphAttributes = glyphAttrs;
@@ -5635,7 +5840,7 @@
5635
5840
  pixelsPerFontUnit: 1 / fontUnitsPerPixel
5636
5841
  };
5637
5842
  }
5638
- applyColorSystem(vertices, glyphInfoArray, color, originalText) {
5843
+ applyColorSystem(vertices, glyphInfoArray, color, originalText, byTextMatches) {
5639
5844
  const vertexCount = vertices.length / 3;
5640
5845
  const colors = new Float32Array(vertexCount * 3);
5641
5846
  const coloredRanges = [];
@@ -5666,36 +5871,50 @@
5666
5871
  colors[i + 1] = defaultColor[1];
5667
5872
  colors[i + 2] = defaultColor[2];
5668
5873
  }
5669
- // Apply text-based coloring using query system
5670
- if (color.byText) {
5671
- const rangeQuery = new TextRangeQuery(originalText, glyphInfoArray);
5672
- const textRanges = rangeQuery.execute({
5673
- byText: Object.keys(color.byText)
5674
- });
5675
- textRanges.forEach((range) => {
5676
- const targetColor = color.byText[range.originalText];
5677
- if (targetColor) {
5678
- range.glyphs.forEach((glyph) => {
5679
- for (let i = 0; i < glyph.vertexCount; i++) {
5680
- const vertexIndex = (glyph.vertexStart + i) * 3;
5681
- if (vertexIndex >= 0 && vertexIndex < colors.length) {
5682
- colors[vertexIndex] = targetColor[0];
5683
- colors[vertexIndex + 1] = targetColor[1];
5684
- colors[vertexIndex + 2] = targetColor[2];
5874
+ if (color.byText && byTextMatches) {
5875
+ const glyphsByTextIndex = new Map();
5876
+ for (const glyph of glyphInfoArray) {
5877
+ const existing = glyphsByTextIndex.get(glyph.textIndex);
5878
+ if (existing) {
5879
+ existing.push(glyph);
5880
+ }
5881
+ else {
5882
+ glyphsByTextIndex.set(glyph.textIndex, [glyph]);
5883
+ }
5884
+ }
5885
+ for (const match of byTextMatches) {
5886
+ const targetColor = color.byText[match.pattern];
5887
+ if (!targetColor)
5888
+ continue;
5889
+ const matchGlyphs = [];
5890
+ const lineIndicesSet = new Set();
5891
+ for (let i = match.start; i < match.end; i++) {
5892
+ const glyphs = glyphsByTextIndex.get(i);
5893
+ if (glyphs) {
5894
+ for (const glyph of glyphs) {
5895
+ matchGlyphs.push(glyph);
5896
+ lineIndicesSet.add(glyph.lineIndex);
5897
+ for (let v = 0; v < glyph.vertexCount; v++) {
5898
+ const vertexIndex = (glyph.vertexStart + v) * 3;
5899
+ if (vertexIndex >= 0 && vertexIndex < colors.length) {
5900
+ colors[vertexIndex] = targetColor[0];
5901
+ colors[vertexIndex + 1] = targetColor[1];
5902
+ colors[vertexIndex + 2] = targetColor[2];
5903
+ }
5685
5904
  }
5686
5905
  }
5687
- });
5688
- coloredRanges.push({
5689
- start: range.start,
5690
- end: range.end,
5691
- originalText: range.originalText,
5692
- color: targetColor,
5693
- bounds: range.bounds,
5694
- glyphs: range.glyphs,
5695
- lineIndices: range.lineIndices
5696
- });
5906
+ }
5697
5907
  }
5698
- });
5908
+ coloredRanges.push({
5909
+ start: match.start,
5910
+ end: match.end,
5911
+ originalText: match.pattern,
5912
+ color: targetColor,
5913
+ bounds: [],
5914
+ glyphs: matchGlyphs,
5915
+ lineIndices: Array.from(lineIndicesSet).sort((a, b) => a - b)
5916
+ });
5917
+ }
5699
5918
  }
5700
5919
  // Apply range coloring
5701
5920
  if (color.byCharRange) {
@@ -5728,7 +5947,7 @@
5728
5947
  }
5729
5948
  return { colors, coloredRanges };
5730
5949
  }
5731
- finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, originalText) {
5950
+ finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, originalText, byTextMatches) {
5732
5951
  const { layout = {} } = options;
5733
5952
  const { width, align = layout.direction === 'rtl' ? 'right' : 'left' } = layout;
5734
5953
  if (!this.textLayout) {
@@ -5754,7 +5973,7 @@
5754
5973
  let colors;
5755
5974
  let coloredRanges;
5756
5975
  if (options.color) {
5757
- const colorResult = this.applyColorSystem(vertices, glyphInfoArray, options.color, options.text);
5976
+ const colorResult = this.applyColorSystem(vertices, glyphInfoArray, options.color, options.text, byTextMatches);
5758
5977
  colors = colorResult.colors;
5759
5978
  coloredRanges = colorResult.coloredRanges;
5760
5979
  }
@@ -5776,13 +5995,18 @@
5776
5995
  pointsRemovedByColinear: optimizationStats.pointsRemovedByColinear,
5777
5996
  originalPointCount: optimizationStats.originalPointCount
5778
5997
  },
5779
- query: (options) => {
5780
- if (!originalText) {
5781
- throw new Error('Original text not available for querying');
5782
- }
5783
- const queryInstance = new TextRangeQuery(originalText, glyphInfoArray);
5784
- return queryInstance.execute(options);
5785
- },
5998
+ query: (() => {
5999
+ let cachedQuery = null;
6000
+ return (options) => {
6001
+ if (!originalText) {
6002
+ throw new Error('Original text not available for querying');
6003
+ }
6004
+ if (!cachedQuery) {
6005
+ cachedQuery = new TextRangeQuery(originalText, glyphInfoArray);
6006
+ }
6007
+ return cachedQuery.execute(options);
6008
+ };
6009
+ })(),
5786
6010
  coloredRanges,
5787
6011
  glyphAttributes: undefined
5788
6012
  };