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/README.md +5 -34
- package/dist/index.cjs +336 -112
- package/dist/index.d.ts +17 -1
- package/dist/index.js +336 -112
- package/dist/index.min.cjs +638 -592
- package/dist/index.min.js +623 -577
- package/dist/index.umd.js +336 -112
- package/dist/index.umd.min.js +619 -573
- package/dist/three/react.d.ts +0 -1
- package/dist/types/core/font/FontMetadata.d.ts +17 -0
- package/dist/types/core/types.d.ts +0 -1
- package/package.json +1 -1
- package/dist/types/utils/LRUCache.d.ts +0 -38
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* three-text v0.3.
|
|
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
|
-
//
|
|
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
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4381
|
-
|
|
4579
|
+
charIndex === lineTextLength - 1 &&
|
|
4580
|
+
char === '-') {
|
|
4382
4581
|
glyph.absoluteTextIndex = lineInfo.originalEnd;
|
|
4383
4582
|
}
|
|
4384
4583
|
else {
|
|
4385
|
-
glyph.absoluteTextIndex = lineInfo.originalStart +
|
|
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:
|
|
4592
|
+
text: clusterTextChars.join(''),
|
|
4394
4593
|
glyphs: currentClusterGlyphs,
|
|
4395
4594
|
position: new Vec3(clusterStartX, clusterStartY, cursorZ)
|
|
4396
4595
|
});
|
|
4397
4596
|
currentClusterGlyphs = [];
|
|
4398
|
-
|
|
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
|
-
|
|
4610
|
+
clusterTextChars.push(char);
|
|
4412
4611
|
}
|
|
4413
4612
|
cursorX += glyph.ax;
|
|
4414
4613
|
cursorY += glyph.ay;
|
|
4415
|
-
if (letterSpacingFU !== 0 && i <
|
|
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 <
|
|
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 =
|
|
4426
|
-
const
|
|
4427
|
-
|
|
4428
|
-
if (
|
|
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(
|
|
4631
|
+
if (LineBreak.isCJOpeningPunctuation(char)) {
|
|
4434
4632
|
shouldApply = false;
|
|
4435
4633
|
}
|
|
4436
|
-
if (LineBreak.isCJPunctuation(
|
|
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:
|
|
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
|
-
|
|
5243
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
5663
|
-
|
|
5664
|
-
const
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
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: (
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
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
|
};
|