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.umd.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
|
|
@@ -1636,9 +1636,7 @@
|
|
|
1636
1636
|
const TABLE_TAG_GSUB = 0x47535542; // 'GSUB'
|
|
1637
1637
|
const TABLE_TAG_GPOS = 0x47504f53; // 'GPOS'
|
|
1638
1638
|
|
|
1639
|
-
//
|
|
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
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4388
|
-
|
|
4586
|
+
charIndex === lineTextLength - 1 &&
|
|
4587
|
+
char === '-') {
|
|
4389
4588
|
glyph.absoluteTextIndex = lineInfo.originalEnd;
|
|
4390
4589
|
}
|
|
4391
4590
|
else {
|
|
4392
|
-
glyph.absoluteTextIndex = lineInfo.originalStart +
|
|
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:
|
|
4599
|
+
text: clusterTextChars.join(''),
|
|
4401
4600
|
glyphs: currentClusterGlyphs,
|
|
4402
4601
|
position: new Vec3(clusterStartX, clusterStartY, cursorZ)
|
|
4403
4602
|
});
|
|
4404
4603
|
currentClusterGlyphs = [];
|
|
4405
|
-
|
|
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
|
-
|
|
4617
|
+
clusterTextChars.push(char);
|
|
4419
4618
|
}
|
|
4420
4619
|
cursorX += glyph.ax;
|
|
4421
4620
|
cursorY += glyph.ay;
|
|
4422
|
-
if (letterSpacingFU !== 0 && i <
|
|
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 <
|
|
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 =
|
|
4433
|
-
const
|
|
4434
|
-
|
|
4435
|
-
if (
|
|
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(
|
|
4638
|
+
if (LineBreak.isCJOpeningPunctuation(char)) {
|
|
4441
4639
|
shouldApply = false;
|
|
4442
4640
|
}
|
|
4443
|
-
if (LineBreak.isCJPunctuation(
|
|
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:
|
|
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
|
-
|
|
5250
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
5670
|
-
|
|
5671
|
-
const
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
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: (
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
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
|
};
|