three-text 0.3.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +335 -107
- package/dist/index.d.ts +17 -0
- package/dist/index.js +335 -107
- package/dist/index.min.cjs +619 -572
- package/dist/index.min.js +623 -576
- package/dist/index.umd.js +335 -107
- package/dist/index.umd.min.js +599 -552
- 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.2
|
|
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,36 +5867,50 @@ class Text {
|
|
|
5658
5867
|
colors[i + 1] = defaultColor[1];
|
|
5659
5868
|
colors[i + 2] = defaultColor[2];
|
|
5660
5869
|
}
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
const
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
|
|
5673
|
-
|
|
5674
|
-
|
|
5675
|
-
|
|
5676
|
-
|
|
5870
|
+
if (color.byText && byTextMatches) {
|
|
5871
|
+
const glyphsByTextIndex = new Map();
|
|
5872
|
+
for (const glyph of glyphInfoArray) {
|
|
5873
|
+
const existing = glyphsByTextIndex.get(glyph.textIndex);
|
|
5874
|
+
if (existing) {
|
|
5875
|
+
existing.push(glyph);
|
|
5876
|
+
}
|
|
5877
|
+
else {
|
|
5878
|
+
glyphsByTextIndex.set(glyph.textIndex, [glyph]);
|
|
5879
|
+
}
|
|
5880
|
+
}
|
|
5881
|
+
for (const match of byTextMatches) {
|
|
5882
|
+
const targetColor = color.byText[match.pattern];
|
|
5883
|
+
if (!targetColor)
|
|
5884
|
+
continue;
|
|
5885
|
+
const matchGlyphs = [];
|
|
5886
|
+
const lineIndicesSet = new Set();
|
|
5887
|
+
for (let i = match.start; i < match.end; i++) {
|
|
5888
|
+
const glyphs = glyphsByTextIndex.get(i);
|
|
5889
|
+
if (glyphs) {
|
|
5890
|
+
for (const glyph of glyphs) {
|
|
5891
|
+
matchGlyphs.push(glyph);
|
|
5892
|
+
lineIndicesSet.add(glyph.lineIndex);
|
|
5893
|
+
for (let v = 0; v < glyph.vertexCount; v++) {
|
|
5894
|
+
const vertexIndex = (glyph.vertexStart + v) * 3;
|
|
5895
|
+
if (vertexIndex >= 0 && vertexIndex < colors.length) {
|
|
5896
|
+
colors[vertexIndex] = targetColor[0];
|
|
5897
|
+
colors[vertexIndex + 1] = targetColor[1];
|
|
5898
|
+
colors[vertexIndex + 2] = targetColor[2];
|
|
5899
|
+
}
|
|
5677
5900
|
}
|
|
5678
5901
|
}
|
|
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
|
-
});
|
|
5902
|
+
}
|
|
5689
5903
|
}
|
|
5690
|
-
|
|
5904
|
+
coloredRanges.push({
|
|
5905
|
+
start: match.start,
|
|
5906
|
+
end: match.end,
|
|
5907
|
+
originalText: match.pattern,
|
|
5908
|
+
color: targetColor,
|
|
5909
|
+
bounds: [],
|
|
5910
|
+
glyphs: matchGlyphs,
|
|
5911
|
+
lineIndices: Array.from(lineIndicesSet).sort((a, b) => a - b)
|
|
5912
|
+
});
|
|
5913
|
+
}
|
|
5691
5914
|
}
|
|
5692
5915
|
// Apply range coloring
|
|
5693
5916
|
if (color.byCharRange) {
|
|
@@ -5720,7 +5943,7 @@ class Text {
|
|
|
5720
5943
|
}
|
|
5721
5944
|
return { colors, coloredRanges };
|
|
5722
5945
|
}
|
|
5723
|
-
finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, originalText) {
|
|
5946
|
+
finalizeGeometry(vertices, normals, indices, glyphInfoArray, planeBounds, options, originalText, byTextMatches) {
|
|
5724
5947
|
const { layout = {} } = options;
|
|
5725
5948
|
const { width, align = layout.direction === 'rtl' ? 'right' : 'left' } = layout;
|
|
5726
5949
|
if (!this.textLayout) {
|
|
@@ -5746,7 +5969,7 @@ class Text {
|
|
|
5746
5969
|
let colors;
|
|
5747
5970
|
let coloredRanges;
|
|
5748
5971
|
if (options.color) {
|
|
5749
|
-
const colorResult = this.applyColorSystem(vertices, glyphInfoArray, options.color, options.text);
|
|
5972
|
+
const colorResult = this.applyColorSystem(vertices, glyphInfoArray, options.color, options.text, byTextMatches);
|
|
5750
5973
|
colors = colorResult.colors;
|
|
5751
5974
|
coloredRanges = colorResult.coloredRanges;
|
|
5752
5975
|
}
|
|
@@ -5768,13 +5991,18 @@ class Text {
|
|
|
5768
5991
|
pointsRemovedByColinear: optimizationStats.pointsRemovedByColinear,
|
|
5769
5992
|
originalPointCount: optimizationStats.originalPointCount
|
|
5770
5993
|
},
|
|
5771
|
-
query: (
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5994
|
+
query: (() => {
|
|
5995
|
+
let cachedQuery = null;
|
|
5996
|
+
return (options) => {
|
|
5997
|
+
if (!originalText) {
|
|
5998
|
+
throw new Error('Original text not available for querying');
|
|
5999
|
+
}
|
|
6000
|
+
if (!cachedQuery) {
|
|
6001
|
+
cachedQuery = new TextRangeQuery(originalText, glyphInfoArray);
|
|
6002
|
+
}
|
|
6003
|
+
return cachedQuery.execute(options);
|
|
6004
|
+
};
|
|
6005
|
+
})(),
|
|
5778
6006
|
coloredRanges,
|
|
5779
6007
|
glyphAttributes: undefined
|
|
5780
6008
|
};
|