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