Initial project import
This commit is contained in:
491
node_modules/troika-three-text/src/BatchedText.js
generated
vendored
Normal file
491
node_modules/troika-three-text/src/BatchedText.js
generated
vendored
Normal file
@@ -0,0 +1,491 @@
|
||||
import { Text } from "./Text.js";
|
||||
import { DataTexture, FloatType, RGBAFormat, Vector2, Box3, Color, DynamicDrawUsage } from "three";
|
||||
import { glyphBoundsAttrName, glyphIndexAttrName } from "./GlyphsGeometry";
|
||||
import { createDerivedMaterial } from "troika-three-utils";
|
||||
import { createTextDerivedMaterial } from "./TextDerivedMaterial";
|
||||
|
||||
const syncStartEvent = { type: "syncstart" };
|
||||
const syncCompleteEvent = { type: "synccomplete" };
|
||||
const memberIndexAttrName = "aTroikaTextBatchMemberIndex";
|
||||
|
||||
|
||||
/*
|
||||
Data texture packing strategy:
|
||||
|
||||
# Common:
|
||||
0-15: matrix
|
||||
16-19: uTroikaTotalBounds
|
||||
20-23: uTroikaClipRect
|
||||
24: diffuse (color/outlineColor)
|
||||
25: uTroikaFillOpacity (fillOpacity/outlineOpacity)
|
||||
26: uTroikaCurveRadius
|
||||
27: <blank>
|
||||
|
||||
# Main:
|
||||
28: uTroikaStrokeWidth
|
||||
29: uTroikaStrokeColor
|
||||
30: uTroikaStrokeOpacity
|
||||
|
||||
# Outline:
|
||||
28-29: uTroikaPositionOffset
|
||||
30: uTroikaEdgeOffset
|
||||
31: uTroikaBlurRadius
|
||||
*/
|
||||
const floatsPerMember = 32;
|
||||
|
||||
const tempBox3 = new Box3();
|
||||
const tempColor = new Color();
|
||||
|
||||
/**
|
||||
* @experimental
|
||||
*
|
||||
* A specialized `Text` implementation that accepts any number of `Text` children
|
||||
* and automatically batches them together to render in a single draw call.
|
||||
*
|
||||
* The `material` of each child `Text` will be ignored, and the `material` of the
|
||||
* `BatchedText` will be used for all of them instead.
|
||||
*
|
||||
* NOTE: This only works in WebGL2 or where the OES_texture_float extension is available.
|
||||
*/
|
||||
export class BatchedText extends Text {
|
||||
constructor () {
|
||||
super();
|
||||
|
||||
/**
|
||||
* @typedef {Object} PackingInfo
|
||||
* @property {number} index - the packing order index when last packed, or -1
|
||||
* @property {boolean} dirty - whether it has synced since last pack
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {Map<Text, PackingInfo>}
|
||||
*/
|
||||
this._members = new Map();
|
||||
this._dataTextures = {}
|
||||
|
||||
this._onMemberSynced = (e) => {
|
||||
this._members.get(e.target).dirty = true;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* Batch any Text objects added as children
|
||||
*/
|
||||
add (...objects) {
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
if (objects[i] instanceof Text) {
|
||||
this.addText(objects[i]);
|
||||
} else {
|
||||
super.add(objects[i]);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
remove (...objects) {
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
if (objects[i] instanceof Text) {
|
||||
this.removeText(objects[i]);
|
||||
} else {
|
||||
super.remove(objects[i]);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Text} text
|
||||
*/
|
||||
addText (text) {
|
||||
if (!this._members.has(text)) {
|
||||
this._members.set(text, {
|
||||
index: -1,
|
||||
glyphCount: -1,
|
||||
dirty: true
|
||||
});
|
||||
text.addEventListener("synccomplete", this._onMemberSynced);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Text} text
|
||||
*/
|
||||
removeText (text) {
|
||||
this._needsRepack = true
|
||||
text.removeEventListener("synccomplete", this._onMemberSynced);
|
||||
this._members.delete(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Use the custom derivation with extra batching logic
|
||||
*/
|
||||
createDerivedMaterial (baseMaterial) {
|
||||
return createBatchedTextMaterial(baseMaterial);
|
||||
}
|
||||
|
||||
updateMatrixWorld (force) {
|
||||
super.updateMatrixWorld(force);
|
||||
this.updateBounds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the batched geometry bounds to hold all members
|
||||
*/
|
||||
updateBounds () {
|
||||
// Update member local matrices and the overall bounds
|
||||
const bbox = this.geometry.boundingBox.makeEmpty();
|
||||
this._members.forEach((_, text) => {
|
||||
if (text.matrixAutoUpdate) text.updateMatrix(); // ignore world matrix
|
||||
tempBox3.copy(text.geometry.boundingBox).applyMatrix4(text.matrix);
|
||||
bbox.union(tempBox3);
|
||||
});
|
||||
bbox.getBoundingSphere(this.geometry.boundingSphere);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
hasOutline() {
|
||||
// Iterator.some() not supported in Safari
|
||||
for (let member of this._members.keys()) {
|
||||
if (member.hasOutline()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* Copy member matrices and uniform values into the data texture
|
||||
*/
|
||||
_prepareForRender (material) {
|
||||
const isOutline = material.isTextOutlineMaterial
|
||||
material.uniforms.uTroikaIsOutline.value = isOutline
|
||||
|
||||
// Resize the texture to fit in powers of 2
|
||||
let texture = this._dataTextures[isOutline ? 'outline' : 'main'];
|
||||
const dataLength = Math.pow(2, Math.ceil(Math.log2(this._members.size * floatsPerMember)));
|
||||
if (!texture || dataLength !== texture.image.data.length) {
|
||||
// console.log(`resizing: ${dataLength}`);
|
||||
if (texture) texture.dispose();
|
||||
const width = Math.min(dataLength / 4, 1024);
|
||||
texture = this._dataTextures[isOutline ? 'outline' : 'main'] = new DataTexture(
|
||||
new Float32Array(dataLength),
|
||||
width,
|
||||
dataLength / 4 / width,
|
||||
RGBAFormat,
|
||||
FloatType
|
||||
);
|
||||
}
|
||||
|
||||
const texData = texture.image.data;
|
||||
const setTexData = (index, value) => {
|
||||
if (value !== texData[index]) {
|
||||
texData[index] = value;
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
this._members.forEach(({ index, dirty }, text) => {
|
||||
if (index > -1) {
|
||||
const startIndex = index * floatsPerMember
|
||||
|
||||
// Matrix
|
||||
const matrix = text.matrix.elements;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
setTexData(startIndex + i, matrix[i])
|
||||
}
|
||||
|
||||
// Let the member populate the uniforms, since that does all the appropriate
|
||||
// logic and handling of defaults, and we'll just grab the results from there
|
||||
text._prepareForRender(material)
|
||||
const {
|
||||
uTroikaTotalBounds,
|
||||
uTroikaClipRect,
|
||||
uTroikaPositionOffset,
|
||||
uTroikaEdgeOffset,
|
||||
uTroikaBlurRadius,
|
||||
uTroikaStrokeWidth,
|
||||
uTroikaStrokeColor,
|
||||
uTroikaStrokeOpacity,
|
||||
uTroikaFillOpacity,
|
||||
uTroikaCurveRadius,
|
||||
} = material.uniforms;
|
||||
|
||||
// Total bounds for uv
|
||||
for (let i = 0; i < 4; i++) {
|
||||
setTexData(startIndex + 16 + i, uTroikaTotalBounds.value.getComponent(i));
|
||||
}
|
||||
|
||||
// Clip rect
|
||||
for (let i = 0; i < 4; i++) {
|
||||
setTexData(startIndex + 20 + i, uTroikaClipRect.value.getComponent(i));
|
||||
}
|
||||
|
||||
// Color
|
||||
let color = isOutline ? (text.outlineColor || 0) : text.color;
|
||||
if (color == null) color = this.color;
|
||||
if (color == null) color = this.material.color;
|
||||
if (color == null) color = 0xffffff;
|
||||
setTexData(startIndex + 24, tempColor.set(color).getHex());
|
||||
|
||||
// Fill opacity / outline opacity
|
||||
setTexData(startIndex + 25, uTroikaFillOpacity.value)
|
||||
|
||||
// Curve radius
|
||||
setTexData(startIndex + 26, uTroikaCurveRadius.value)
|
||||
|
||||
if (isOutline) {
|
||||
// Outline properties
|
||||
setTexData(startIndex + 28, uTroikaPositionOffset.value.x);
|
||||
setTexData(startIndex + 29, uTroikaPositionOffset.value.y);
|
||||
setTexData(startIndex + 30, uTroikaEdgeOffset.value);
|
||||
setTexData(startIndex + 31, uTroikaBlurRadius.value);
|
||||
} else {
|
||||
// Stroke properties
|
||||
setTexData(startIndex + 28, uTroikaStrokeWidth.value);
|
||||
setTexData(startIndex + 29, tempColor.set(uTroikaStrokeColor.value).getHex());
|
||||
setTexData(startIndex + 30, uTroikaStrokeOpacity.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
material.setMatrixTexture(texture);
|
||||
|
||||
// For the non-member-specific uniforms:
|
||||
super._prepareForRender(material);
|
||||
}
|
||||
|
||||
sync (callback) {
|
||||
// TODO: skip members updating their geometries, just use textRenderInfo directly
|
||||
|
||||
// Trigger sync on all members that need it
|
||||
let syncPromises = this._needsRepack ? [] : null;
|
||||
this._needsRepack = false;
|
||||
this._members.forEach((packingInfo, text) => {
|
||||
if (packingInfo.dirty || text._needsSync) {
|
||||
packingInfo.dirty = false;
|
||||
(syncPromises || (syncPromises = [])).push(new Promise(resolve => {
|
||||
if (text._needsSync) {
|
||||
text.sync(resolve);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// If any needed syncing, wait for them and then repack the batched geometry
|
||||
if (syncPromises) {
|
||||
this.dispatchEvent(syncStartEvent);
|
||||
|
||||
Promise.all(syncPromises).then(() => {
|
||||
const { geometry } = this;
|
||||
const batchedAttributes = geometry.attributes;
|
||||
let memberIndexes = batchedAttributes[memberIndexAttrName] && batchedAttributes[memberIndexAttrName].array || new Uint16Array(0);
|
||||
let batchedGlyphIndexes = batchedAttributes[glyphIndexAttrName] && batchedAttributes[glyphIndexAttrName].array || new Float32Array(0);
|
||||
let batchedGlyphBounds = batchedAttributes[glyphBoundsAttrName] && batchedAttributes[glyphBoundsAttrName].array || new Float32Array(0);
|
||||
|
||||
// Initial pass to collect total glyph count and resize the arrays if needed
|
||||
let totalGlyphCount = 0;
|
||||
this._members.forEach((packingInfo, { textRenderInfo }) => {
|
||||
if (textRenderInfo) {
|
||||
totalGlyphCount += textRenderInfo.glyphAtlasIndices.length;
|
||||
this._textRenderInfo = textRenderInfo; // TODO - need this, but be smarter
|
||||
}
|
||||
});
|
||||
if (totalGlyphCount !== memberIndexes.length) {
|
||||
memberIndexes = cloneAndResize(memberIndexes, totalGlyphCount);
|
||||
batchedGlyphIndexes = cloneAndResize(batchedGlyphIndexes, totalGlyphCount);
|
||||
batchedGlyphBounds = cloneAndResize(batchedGlyphBounds, totalGlyphCount * 4);
|
||||
}
|
||||
|
||||
// Populate batch arrays
|
||||
let memberIndex = 0;
|
||||
let glyphIndex = 0;
|
||||
this._members.forEach((packingInfo, { textRenderInfo }) => {
|
||||
if (textRenderInfo) {
|
||||
const glyphCount = textRenderInfo.glyphAtlasIndices.length;
|
||||
memberIndexes.fill(memberIndex, glyphIndex, glyphIndex + glyphCount);
|
||||
|
||||
// TODO can skip these for members that are not dirty or shifting overall position:
|
||||
batchedGlyphIndexes.set(textRenderInfo.glyphAtlasIndices, glyphIndex, glyphIndex + glyphCount);
|
||||
batchedGlyphBounds.set(textRenderInfo.glyphBounds, glyphIndex * 4, (glyphIndex + glyphCount) * 4);
|
||||
|
||||
glyphIndex += glyphCount;
|
||||
packingInfo.index = memberIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
// Update the geometry attributes
|
||||
geometry.updateAttributeData(memberIndexAttrName, memberIndexes, 1);
|
||||
geometry.getAttribute(memberIndexAttrName).setUsage(DynamicDrawUsage);
|
||||
geometry.updateAttributeData(glyphIndexAttrName, batchedGlyphIndexes, 1);
|
||||
geometry.updateAttributeData(glyphBoundsAttrName, batchedGlyphBounds, 4);
|
||||
|
||||
this.updateBounds();
|
||||
|
||||
this.dispatchEvent(syncCompleteEvent);
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
copy (source) {
|
||||
if (source instanceof BatchedText) {
|
||||
super.copy(source);
|
||||
this._members.forEach((_, text) => this.removeText(text));
|
||||
source._members.forEach((_, text) => this.addText(text));
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
dispose () {
|
||||
super.dispose();
|
||||
Object.values(this._dataTextures).forEach(tex => tex.dispose())
|
||||
}
|
||||
}
|
||||
|
||||
function cloneAndResize (source, newLength) {
|
||||
const copy = new source.constructor(newLength);
|
||||
copy.set(source.subarray(0, newLength));
|
||||
return copy;
|
||||
}
|
||||
|
||||
function createBatchedTextMaterial (baseMaterial) {
|
||||
const texUniformName = "uTroikaMatricesTexture";
|
||||
const texSizeUniformName = "uTroikaMatricesTextureSize";
|
||||
|
||||
// Due to how vertexTransform gets injected, the matrix transforms must happen
|
||||
// in the base material of TextDerivedMaterial, but other transforms to its
|
||||
// shader must come after, so we sandwich it between two derivations.
|
||||
|
||||
// Transform the vertex position
|
||||
let batchMaterial = createDerivedMaterial(baseMaterial, {
|
||||
chained: true,
|
||||
uniforms: {
|
||||
[texSizeUniformName]: { value: new Vector2() },
|
||||
[texUniformName]: { value: null }
|
||||
},
|
||||
// language=GLSL
|
||||
vertexDefs: `
|
||||
uniform highp sampler2D ${texUniformName};
|
||||
uniform vec2 ${texSizeUniformName};
|
||||
attribute float ${memberIndexAttrName};
|
||||
|
||||
vec4 troikaBatchTexel(float offset) {
|
||||
offset += ${memberIndexAttrName} * ${floatsPerMember.toFixed(1)} / 4.0;
|
||||
float w = ${texSizeUniformName}.x;
|
||||
vec2 uv = (vec2(mod(offset, w), floor(offset / w)) + 0.5) / ${texSizeUniformName};
|
||||
return texture2D(${texUniformName}, uv);
|
||||
}
|
||||
`,
|
||||
// language=GLSL prefix="void main() {" suffix="}"
|
||||
vertexTransform: `
|
||||
mat4 matrix = mat4(
|
||||
troikaBatchTexel(0.0),
|
||||
troikaBatchTexel(1.0),
|
||||
troikaBatchTexel(2.0),
|
||||
troikaBatchTexel(3.0)
|
||||
);
|
||||
position.xyz = (matrix * vec4(position, 1.0)).xyz;
|
||||
`,
|
||||
});
|
||||
|
||||
// Add the text shaders
|
||||
batchMaterial = createTextDerivedMaterial(batchMaterial);
|
||||
|
||||
// Now make other changes to the derived text shader code
|
||||
batchMaterial = createDerivedMaterial(batchMaterial, {
|
||||
chained: true,
|
||||
uniforms: {
|
||||
uTroikaIsOutline: {value: false},
|
||||
},
|
||||
customRewriter(shaders) {
|
||||
// Convert some text shader uniforms to varyings
|
||||
const varyingUniforms = [
|
||||
'uTroikaTotalBounds',
|
||||
'uTroikaClipRect',
|
||||
'uTroikaPositionOffset',
|
||||
'uTroikaEdgeOffset',
|
||||
'uTroikaBlurRadius',
|
||||
'uTroikaStrokeWidth',
|
||||
'uTroikaStrokeColor',
|
||||
'uTroikaStrokeOpacity',
|
||||
'uTroikaFillOpacity',
|
||||
'uTroikaCurveRadius',
|
||||
'diffuse'
|
||||
]
|
||||
varyingUniforms.forEach(uniformName => {
|
||||
shaders = uniformToVarying(shaders, uniformName)
|
||||
})
|
||||
return shaders
|
||||
},
|
||||
// language=GLSL
|
||||
vertexDefs: `
|
||||
uniform bool uTroikaIsOutline;
|
||||
vec3 troikaFloatToColor(float v) {
|
||||
return mod(floor(vec3(v / 65536.0, v / 256.0, v)), 256.0) / 256.0;
|
||||
}
|
||||
`,
|
||||
// language=GLSL prefix="void main() {" suffix="}"
|
||||
vertexTransform: `
|
||||
uTroikaTotalBounds = troikaBatchTexel(4.0);
|
||||
uTroikaClipRect = troikaBatchTexel(5.0);
|
||||
|
||||
vec4 data = troikaBatchTexel(6.0);
|
||||
diffuse = troikaFloatToColor(data.x);
|
||||
uTroikaFillOpacity = data.y;
|
||||
uTroikaCurveRadius = data.z;
|
||||
|
||||
data = troikaBatchTexel(7.0);
|
||||
if (uTroikaIsOutline) {
|
||||
if (data == vec4(0.0)) { // degenerate if zero outline
|
||||
position = vec3(0.0);
|
||||
} else {
|
||||
uTroikaPositionOffset = data.xy;
|
||||
uTroikaEdgeOffset = data.z;
|
||||
uTroikaBlurRadius = data.w;
|
||||
}
|
||||
} else {
|
||||
uTroikaStrokeWidth = data.x;
|
||||
uTroikaStrokeColor = troikaFloatToColor(data.y);
|
||||
uTroikaStrokeOpacity = data.z;
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
batchMaterial.setMatrixTexture = (texture) => {
|
||||
batchMaterial.uniforms[texUniformName].value = texture;
|
||||
batchMaterial.uniforms[texSizeUniformName].value.set(texture.image.width, texture.image.height);
|
||||
};
|
||||
return batchMaterial;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a uniform into a varying/writeable value.
|
||||
* - If the uniform was used in the fragment shader, it will become a varying in both shaders.
|
||||
* - If the uniform was only used in the vertex shader, it will become a writeable var.
|
||||
*/
|
||||
export function uniformToVarying({vertexShader, fragmentShader}, uniformName, varyingName = uniformName) {
|
||||
const uniformRE = new RegExp(`uniform\\s+(bool|float|vec[234]|mat[34])\\s+${uniformName}\\b`)
|
||||
|
||||
let type
|
||||
let hadFragmentUniform = false
|
||||
fragmentShader = fragmentShader.replace(uniformRE, ($0, $1) => {
|
||||
hadFragmentUniform = true
|
||||
return `varying ${type = $1} ${varyingName}`
|
||||
})
|
||||
|
||||
let hadVertexUniform = false
|
||||
vertexShader = vertexShader.replace(uniformRE, (_, $1) => {
|
||||
hadVertexUniform = true
|
||||
return `${hadFragmentUniform ? 'varying' : ''} ${type = $1} ${varyingName}`
|
||||
})
|
||||
if (!hadVertexUniform) {
|
||||
vertexShader = `${hadFragmentUniform ? 'varying' : ''} ${type} ${varyingName};\n${vertexShader}`
|
||||
}
|
||||
return {vertexShader, fragmentShader}
|
||||
}
|
||||
|
||||
432
node_modules/troika-three-text/src/FontParser.js
generated
vendored
Normal file
432
node_modules/troika-three-text/src/FontParser.js
generated
vendored
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* A factory wrapper parsing a font file using Typr.
|
||||
* Also adds support for WOFF files (not WOFF2).
|
||||
*/
|
||||
|
||||
import typrFactory from '../libs/typr.factory.js'
|
||||
import woff2otfFactory from '../libs/woff2otf.factory.js'
|
||||
import { defineWorkerModule } from 'troika-worker-utils'
|
||||
|
||||
/**
|
||||
* @typedef ParsedFont
|
||||
* @property {number} ascender
|
||||
* @property {number} descender
|
||||
* @property {number} xHeight
|
||||
* @property {(number) => boolean} supportsCodePoint
|
||||
* @property {(text:string, fontSize:number, letterSpacing:number, callback) => number} forEachGlyph
|
||||
* @property {number} lineGap
|
||||
* @property {number} capHeight
|
||||
* @property {number} unitsPerEm
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {(buffer: ArrayBuffer) => ParsedFont} FontParser
|
||||
*/
|
||||
|
||||
/**
|
||||
* @returns {FontParser}
|
||||
*/
|
||||
function parserFactory(Typr, woff2otf) {
|
||||
const cmdArgLengths = {
|
||||
M: 2,
|
||||
L: 2,
|
||||
Q: 4,
|
||||
C: 6,
|
||||
Z: 0
|
||||
}
|
||||
|
||||
// {joinType: "skip+step,..."}
|
||||
const joiningTypeRawData = {"C":"18g,ca,368,1kz","D":"17k,6,2,2+4,5+c,2+6,2+1,10+1,9+f,j+11,2+1,a,2,2+1,15+2,3,j+2,6+3,2+8,2,2,2+1,w+a,4+e,3+3,2,3+2,3+5,23+w,2f+4,3,2+9,2,b,2+3,3,1k+9,6+1,3+1,2+2,2+d,30g,p+y,1,1+1g,f+x,2,sd2+1d,jf3+4,f+3,2+4,2+2,b+3,42,2,4+2,2+1,2,3,t+1,9f+w,2,el+2,2+g,d+2,2l,2+1,5,3+1,2+1,2,3,6,16wm+1v","R":"17m+3,2,2,6+3,m,15+2,2+2,h+h,13,3+8,2,2,3+1,2,p+1,x,5+4,5,a,2,2,3,u,c+2,g+1,5,2+1,4+1,5j,6+1,2,b,2+2,f,2+1,1s+2,2,3+1,7,1ez0,2,2+1,4+4,b,4,3,b,42,2+2,4,3,2+1,2,o+3,ae,ep,x,2o+2,3+1,3,5+1,6","L":"x9u,jff,a,fd,jv","T":"4t,gj+33,7o+4,1+1,7c+18,2,2+1,2+1,2,21+a,2,1b+k,h,2u+6,3+5,3+1,2+3,y,2,v+q,2k+a,1n+8,a,p+3,2+8,2+2,2+4,18+2,3c+e,2+v,1k,2,5+7,5,4+6,b+1,u,1n,5+3,9,l+1,r,3+1,1m,5+1,5+1,3+2,4,v+1,4,c+1,1m,5+4,2+1,5,l+1,n+5,2,1n,3,2+3,9,8+1,c+1,v,1q,d,1f,4,1m+2,6+2,2+3,8+1,c+1,u,1n,3,7,6+1,l+1,t+1,1m+1,5+3,9,l+1,u,21,8+2,2,2j,3+6,d+7,2r,3+8,c+5,23+1,s,2,2,1k+d,2+4,2+1,6+a,2+z,a,2v+3,2+5,2+1,3+1,q+1,5+2,h+3,e,3+1,7,g,jk+2,qb+2,u+2,u+1,v+1,1t+1,2+6,9,3+a,a,1a+2,3c+1,z,3b+2,5+1,a,7+2,64+1,3,1n,2+6,2,2,3+7,7+9,3,1d+d,1,1+1,1s+3,1d,2+4,2,6,15+8,d+1,x+3,3+1,2+2,1l,2+1,4,2+2,1n+7,3+1,49+2,2+c,2+6,5,7,4+1,5j+1l,2+4,ek,3+1,r+4,1e+4,6+5,2p+c,1+3,1,1+2,1+b,2db+2,3y,2p+v,ff+3,30+1,n9x,1+2,2+9,x+1,29+1,7l,4,5,q+1,6,48+1,r+h,e,13+7,q+a,1b+2,1d,3+3,3+1,14,1w+5,3+1,3+1,d,9,1c,1g,2+2,3+1,6+1,2,17+1,9,6n,3,5,fn5,ki+f,h+f,5s,6y+2,ea,6b,46+4,1af+2,2+1,6+3,15+2,5,4m+1,fy+3,as+1,4a+a,4x,1j+e,1l+2,1e+3,3+1,1y+2,11+4,2+7,1r,d+1,1h+8,b+3,3,2o+2,3,2+1,7,4h,4+7,m+1,1m+1,4,12+6,4+4,5g+7,3+2,2,o,2d+5,2,5+1,2+1,6n+3,7+1,2+1,s+1,2e+7,3,2+1,2z,2,3+5,2,2u+2,3+3,2+4,78+8,2+1,75+1,2,5,41+3,3+1,5,x+9,15+5,3+3,9,a+5,3+2,1b+c,2+1,bb+6,2+5,2,2b+l,3+6,2+1,2+1,3f+5,4,2+1,2+6,2,21+1,4,2,9o+1,470+8,at4+4,1o+6,t5,1s+3,2a,f5l+1,2+3,43o+2,a+7,1+7,3+6,v+3,45+2,1j0+1i,5+1d,9,f,n+4,2+e,11t+6,2+g,3+6,2+1,2+4,7a+6,c6+3,15t+6,32+6,1,gzau,v+2n,3l+6n"}
|
||||
|
||||
const JT_LEFT = 1, //indicates that a character joins with the subsequent character, but does not join with the preceding character.
|
||||
JT_RIGHT = 2, //indicates that a character joins with the preceding character, but does not join with the subsequent character.
|
||||
JT_DUAL = 4, //indicates that a character joins with the preceding character and joins with the subsequent character.
|
||||
JT_TRANSPARENT = 8, //indicates that the character does not join with adjacent characters and that the character must be skipped over when the shaping engine is evaluating the joining positions in a sequence of characters. When a JT_TRANSPARENT character is encountered in a sequence, the JOINING_TYPE of the preceding character passes through. Diacritical marks are frequently assigned this value.
|
||||
JT_JOIN_CAUSING = 16, //indicates that the character forces the use of joining forms with the preceding and subsequent characters. Kashidas and the Zero Width Joiner (U+200D) are both JOIN_CAUSING characters.
|
||||
JT_NON_JOINING = 32 //indicates that a character does not join with the preceding or with the subsequent character.,
|
||||
|
||||
let joiningTypeMap
|
||||
function getCharJoiningType(ch) {
|
||||
if (!joiningTypeMap) {
|
||||
const m = {
|
||||
R: JT_RIGHT,
|
||||
L: JT_LEFT,
|
||||
D: JT_DUAL,
|
||||
C: JT_JOIN_CAUSING,
|
||||
U: JT_NON_JOINING,
|
||||
T: JT_TRANSPARENT
|
||||
}
|
||||
joiningTypeMap = new Map()
|
||||
for (let type in joiningTypeRawData) {
|
||||
let lastCode = 0
|
||||
joiningTypeRawData[type].split(',').forEach(range => {
|
||||
let [skip, step] = range.split('+')
|
||||
skip = parseInt(skip,36)
|
||||
step = step ? parseInt(step, 36) : 0
|
||||
joiningTypeMap.set(lastCode += skip, m[type])
|
||||
for (let i = step; i--;) {
|
||||
joiningTypeMap.set(++lastCode, m[type])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
return joiningTypeMap.get(ch) || JT_NON_JOINING
|
||||
}
|
||||
|
||||
const ISOL = 1, INIT = 2, FINA = 3, MEDI = 4
|
||||
const formsToFeatures = [null, 'isol', 'init', 'fina', 'medi']
|
||||
|
||||
function detectJoiningForms(str) {
|
||||
// This implements the algorithm described here:
|
||||
// https://github.com/n8willis/opentype-shaping-documents/blob/master/opentype-shaping-arabic-general.md
|
||||
const joiningForms = new Uint8Array(str.length)
|
||||
let prevJoiningType = JT_NON_JOINING
|
||||
let prevForm = ISOL
|
||||
let prevIndex = -1
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.codePointAt(i)
|
||||
let joiningType = getCharJoiningType(code) | 0
|
||||
let form = ISOL
|
||||
if (joiningType & JT_TRANSPARENT) {
|
||||
continue
|
||||
}
|
||||
if (prevJoiningType & (JT_LEFT | JT_DUAL | JT_JOIN_CAUSING)) {
|
||||
if (joiningType & (JT_RIGHT | JT_DUAL | JT_JOIN_CAUSING)) {
|
||||
form = FINA
|
||||
// isol->init, fina->medi
|
||||
if (prevForm === ISOL || prevForm === FINA) {
|
||||
joiningForms[prevIndex]++
|
||||
}
|
||||
}
|
||||
else if (joiningType & (JT_LEFT | JT_NON_JOINING)) {
|
||||
// medi->fina, init->isol
|
||||
if (prevForm === INIT || prevForm === MEDI) {
|
||||
joiningForms[prevIndex]--
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (prevJoiningType & (JT_RIGHT | JT_NON_JOINING)) {
|
||||
// medi->fina, init->isol
|
||||
if (prevForm === INIT || prevForm === MEDI) {
|
||||
joiningForms[prevIndex]--
|
||||
}
|
||||
}
|
||||
prevForm = joiningForms[i] = form
|
||||
prevJoiningType = joiningType
|
||||
prevIndex = i
|
||||
if (code > 0xffff) i++
|
||||
}
|
||||
// console.log(str.split('').map(ch => ch.codePointAt(0).toString(16)))
|
||||
// console.log(str.split('').map(ch => getCharJoiningType(ch.codePointAt(0))))
|
||||
// console.log(Array.from(joiningForms).map(f => formsToFeatures[f] || 'none'))
|
||||
return joiningForms
|
||||
}
|
||||
|
||||
function stringToGlyphs (font, str) {
|
||||
const glyphIds = []
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const cc = str.codePointAt(i)
|
||||
if (cc > 0xffff) i++
|
||||
glyphIds.push(Typr.U.codeToGlyph(font, cc))
|
||||
}
|
||||
|
||||
const gsub = font['GSUB']
|
||||
if (gsub) {
|
||||
const {lookupList, featureList} = gsub
|
||||
let joiningForms
|
||||
const supportedFeatures = /^(rlig|liga|mset|isol|init|fina|medi|half|pres|blws|ccmp)$/
|
||||
const usedLookups = []
|
||||
featureList.forEach(feature => {
|
||||
if (supportedFeatures.test(feature.tag)) {
|
||||
for (let ti = 0; ti < feature.tab.length; ti++) {
|
||||
if (usedLookups[feature.tab[ti]]) continue
|
||||
usedLookups[feature.tab[ti]] = true
|
||||
const tab = lookupList[feature.tab[ti]]
|
||||
const isJoiningFeature = /^(isol|init|fina|medi)$/.test(feature.tag)
|
||||
if (isJoiningFeature && !joiningForms) { //lazy
|
||||
joiningForms = detectJoiningForms(str)
|
||||
}
|
||||
for (let ci = 0; ci < glyphIds.length; ci++) {
|
||||
if (!joiningForms || !isJoiningFeature || formsToFeatures[joiningForms[ci]] === feature.tag) {
|
||||
Typr.U._applySubs(glyphIds, ci, tab, lookupList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return glyphIds
|
||||
}
|
||||
|
||||
// Calculate advances and x/y offsets for each glyph, e.g. kerning and mark
|
||||
// attachments. This is a more complete version of Typr.U.getPairAdjustment
|
||||
// and should become an upstream replacement eventually.
|
||||
function calcGlyphPositions(font, glyphIds) {
|
||||
const positions = new Int16Array(glyphIds.length * 3); // [offsetX, offsetY, advanceX, ...]
|
||||
let glyphIndex = 0;
|
||||
for (; glyphIndex < glyphIds.length; glyphIndex++) {
|
||||
const glyphId = glyphIds[glyphIndex]
|
||||
if (glyphId === -1) continue;
|
||||
|
||||
positions[glyphIndex * 3 + 2] = font.hmtx.aWidth[glyphId]; // populate advanceX in...advance.
|
||||
|
||||
const gpos = font.GPOS;
|
||||
if (gpos) {
|
||||
const llist = gpos.lookupList;
|
||||
for (let i = 0; i < llist.length; i++) {
|
||||
const lookup = llist[i];
|
||||
for (let j = 0; j < lookup.tabs.length; j++) {
|
||||
const tab = lookup.tabs[j];
|
||||
// Single char placement
|
||||
if (lookup.ltype === 1) {
|
||||
const ind = Typr._lctf.coverageIndex(tab.coverage, glyphId);
|
||||
if (ind !== -1 && tab.pos) {
|
||||
applyValueRecord(tab.pos, glyphIndex)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Pairs (kerning)
|
||||
else if (lookup.ltype === 2) {
|
||||
let adj = null;
|
||||
let prevGlyphIndex = getPrevGlyphIndex()
|
||||
if (prevGlyphIndex !== -1) {
|
||||
const coverageIndex = Typr._lctf.coverageIndex(tab.coverage, glyphIds[prevGlyphIndex]);
|
||||
if (coverageIndex !== -1) {
|
||||
if (tab.fmt === 1) {
|
||||
const right = tab.pairsets[coverageIndex];
|
||||
for (let k = 0; k < right.length; k++) {
|
||||
if (right[k].gid2 === glyphId) adj = right[k];
|
||||
}
|
||||
} else if (tab.fmt === 2) {
|
||||
const c1 = Typr.U._getGlyphClass(glyphIds[prevGlyphIndex], tab.classDef1);
|
||||
const c2 = Typr.U._getGlyphClass(glyphId, tab.classDef2);
|
||||
adj = tab.matrix[c1][c2];
|
||||
}
|
||||
if (adj) {
|
||||
if (adj.val1) applyValueRecord(adj.val1, prevGlyphIndex)
|
||||
if (adj.val2) applyValueRecord(adj.val2, glyphIndex)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mark to base
|
||||
else if (lookup.ltype === 4) {
|
||||
const markArrIndex = Typr._lctf.coverageIndex(tab.markCoverage, glyphId);
|
||||
if (markArrIndex !== -1) {
|
||||
const baseGlyphIndex = getPrevGlyphIndex(isBaseGlyph);
|
||||
const baseArrIndex = baseGlyphIndex === -1 ? -1 : Typr._lctf.coverageIndex(tab.baseCoverage, glyphIds[baseGlyphIndex])
|
||||
if (baseArrIndex !== -1) {
|
||||
const markRecord = tab.markArray[markArrIndex];
|
||||
const baseAnchor = tab.baseArray[baseArrIndex][markRecord.markClass];
|
||||
positions[glyphIndex * 3] = baseAnchor.x - markRecord.x + positions[baseGlyphIndex * 3] - positions[baseGlyphIndex * 3 + 2]
|
||||
positions[glyphIndex * 3 + 1] = baseAnchor.y - markRecord.y + positions[baseGlyphIndex * 3 + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mark to mark
|
||||
else if (lookup.ltype === 6) {
|
||||
const mark1ArrIndex = Typr._lctf.coverageIndex(tab.mark1Coverage, glyphId);
|
||||
if (mark1ArrIndex !== -1) {
|
||||
const prevGlyphIndex = getPrevGlyphIndex();
|
||||
if (prevGlyphIndex !== -1) {
|
||||
const prevGlyphId = glyphIds[prevGlyphIndex]
|
||||
if (getGlyphClass(font, prevGlyphId) === 3) { // only check mark glyphs
|
||||
const mark2ArrIndex = Typr._lctf.coverageIndex(tab.mark2Coverage, prevGlyphId)
|
||||
if (mark2ArrIndex !== -1) {
|
||||
const mark1Record = tab.mark1Array[mark1ArrIndex];
|
||||
const mark2Anchor = tab.mark2Array[mark2ArrIndex][mark1Record.markClass];
|
||||
positions[glyphIndex * 3] = mark2Anchor.x - mark1Record.x + positions[prevGlyphIndex * 3] - positions[prevGlyphIndex * 3 + 2];
|
||||
positions[glyphIndex * 3 + 1] = mark2Anchor.y - mark1Record.y + positions[prevGlyphIndex * 3 + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check kern table if no GPOS
|
||||
else if (font.kern && !font.cff) {
|
||||
const prevGlyphIndex = getPrevGlyphIndex();
|
||||
if (prevGlyphIndex !== -1) {
|
||||
const ind1 = font.kern.glyph1.indexOf(glyphIds[prevGlyphIndex]);
|
||||
if (ind1 !== -1) {
|
||||
const ind2 = font.kern.rval[ind1].glyph2.indexOf(glyphId);
|
||||
if (ind2 !== -1) {
|
||||
positions[prevGlyphIndex * 3 + 2] += font.kern.rval[ind1].vals[ind2];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
|
||||
function getPrevGlyphIndex(filter) {
|
||||
for (let i = glyphIndex - 1; i >=0; i--) {
|
||||
if (glyphIds[i] !== -1 && (!filter || filter(glyphIds[i]))) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isBaseGlyph(glyphId) {
|
||||
return getGlyphClass(font, glyphId) === 1;
|
||||
}
|
||||
|
||||
function applyValueRecord(source, gi) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
positions[gi * 3 + i] += source[i] || 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getGlyphClass(font, glyphId) {
|
||||
const classDef = font.GDEF && font.GDEF.glyphClassDef
|
||||
return classDef ? Typr.U._getGlyphClass(glyphId, classDef) : 0;
|
||||
}
|
||||
|
||||
function firstNum(...args) {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (typeof args[i] === 'number') {
|
||||
return args[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns ParsedFont
|
||||
*/
|
||||
function wrapFontObj(typrFont) {
|
||||
const glyphMap = Object.create(null)
|
||||
|
||||
const os2 = typrFont['OS/2']
|
||||
const hhea = typrFont.hhea
|
||||
const unitsPerEm = typrFont.head.unitsPerEm
|
||||
const ascender = firstNum(os2 && os2.sTypoAscender, hhea && hhea.ascender, unitsPerEm)
|
||||
|
||||
/** @type ParsedFont */
|
||||
const fontObj = {
|
||||
unitsPerEm,
|
||||
ascender,
|
||||
descender: firstNum(os2 && os2.sTypoDescender, hhea && hhea.descender, 0),
|
||||
capHeight: firstNum(os2 && os2.sCapHeight, ascender),
|
||||
xHeight: firstNum(os2 && os2.sxHeight, ascender),
|
||||
lineGap: firstNum(os2 && os2.sTypoLineGap, hhea && hhea.lineGap),
|
||||
supportsCodePoint(code) {
|
||||
return Typr.U.codeToGlyph(typrFont, code) > 0
|
||||
},
|
||||
forEachGlyph(text, fontSize, letterSpacing, callback) {
|
||||
let penX = 0
|
||||
const fontScale = 1 / fontObj.unitsPerEm * fontSize
|
||||
|
||||
const glyphIds = stringToGlyphs(typrFont, text)
|
||||
let charIndex = 0
|
||||
const positions = calcGlyphPositions(typrFont, glyphIds)
|
||||
|
||||
glyphIds.forEach((glyphId, i) => {
|
||||
// Typr returns a glyph index per string codepoint, with -1s in place of those that
|
||||
// were omitted due to ligature substitution. So we can track original index in the
|
||||
// string via simple increment, and skip everything else when seeing a -1.
|
||||
if (glyphId !== -1) {
|
||||
let glyphObj = glyphMap[glyphId]
|
||||
if (!glyphObj) {
|
||||
const {cmds, crds} = Typr.U.glyphToPath(typrFont, glyphId)
|
||||
|
||||
// Build path string
|
||||
let path = ''
|
||||
let crdsIdx = 0
|
||||
for (let i = 0, len = cmds.length; i < len; i++) {
|
||||
const numArgs = cmdArgLengths[cmds[i]]
|
||||
path += cmds[i]
|
||||
for (let j = 1; j <= numArgs; j++) {
|
||||
path += (j > 1 ? ',' : '') + crds[crdsIdx++]
|
||||
}
|
||||
}
|
||||
|
||||
// Find extents - Glyf gives this in metadata but not CFF, and Typr doesn't
|
||||
// normalize the two, so it's simplest just to iterate ourselves.
|
||||
let xMin, yMin, xMax, yMax
|
||||
if (crds.length) {
|
||||
xMin = yMin = Infinity
|
||||
xMax = yMax = -Infinity
|
||||
for (let i = 0, len = crds.length; i < len; i += 2) {
|
||||
let x = crds[i]
|
||||
let y = crds[i + 1]
|
||||
if (x < xMin) xMin = x
|
||||
if (y < yMin) yMin = y
|
||||
if (x > xMax) xMax = x
|
||||
if (y > yMax) yMax = y
|
||||
}
|
||||
} else {
|
||||
xMin = xMax = yMin = yMax = 0
|
||||
}
|
||||
|
||||
glyphObj = glyphMap[glyphId] = {
|
||||
index: glyphId,
|
||||
advanceWidth: typrFont.hmtx.aWidth[glyphId],
|
||||
xMin,
|
||||
yMin,
|
||||
xMax,
|
||||
yMax,
|
||||
path,
|
||||
}
|
||||
}
|
||||
|
||||
callback.call(
|
||||
null,
|
||||
glyphObj,
|
||||
penX + positions[i * 3] * fontScale,
|
||||
positions[i * 3 + 1] * fontScale,
|
||||
charIndex
|
||||
)
|
||||
|
||||
penX += positions[i * 3 + 2] * fontScale
|
||||
if (letterSpacing) {
|
||||
penX += letterSpacing * fontSize
|
||||
}
|
||||
}
|
||||
charIndex += (text.codePointAt(charIndex) > 0xffff ? 2 : 1)
|
||||
})
|
||||
|
||||
return penX
|
||||
}
|
||||
}
|
||||
|
||||
return fontObj
|
||||
}
|
||||
|
||||
/**
|
||||
* @type FontParser
|
||||
*/
|
||||
return function parse(buffer) {
|
||||
// Look to see if we have a WOFF file and convert it if so:
|
||||
const peek = new Uint8Array(buffer, 0, 4)
|
||||
const tag = Typr._bin.readASCII(peek, 0, 4)
|
||||
if (tag === 'wOFF') {
|
||||
buffer = woff2otf(buffer)
|
||||
} else if (tag === 'wOF2') {
|
||||
throw new Error('woff2 fonts not supported')
|
||||
}
|
||||
return wrapFontObj(Typr.parse(buffer)[0])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const workerModule = /*#__PURE__*/defineWorkerModule({
|
||||
name: 'Typr Font Parser',
|
||||
dependencies: [typrFactory, woff2otfFactory, parserFactory],
|
||||
init(typrFactory, woff2otfFactory, parserFactory) {
|
||||
const Typr = typrFactory()
|
||||
const woff2otf = woff2otfFactory()
|
||||
return parserFactory(Typr, woff2otf)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
export default workerModule
|
||||
276
node_modules/troika-three-text/src/FontResolver.js
generated
vendored
Normal file
276
node_modules/troika-three-text/src/FontResolver.js
generated
vendored
Normal file
@@ -0,0 +1,276 @@
|
||||
import fontParser from "./FontParser.js";
|
||||
import unicodeFontResolverClientFactory from "../libs/unicode-font-resolver-client.factory.js";
|
||||
import { defineWorkerModule } from "troika-worker-utils";
|
||||
|
||||
/**
|
||||
* @typedef {string | {src:string, label?:string, unicodeRange?:string, lang?:string}} UserFont
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {ClientOptions} FontResolverOptions
|
||||
* @property {Array<UserFont>|UserFont} [fonts]
|
||||
* @property {'normal'|'italic'} [style]
|
||||
* @property {'normal'|'bold'|number} [style]
|
||||
* @property {string} [unicodeFontsURL]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FontResolverResult
|
||||
* @property {Uint8Array} chars
|
||||
* @property {Array<ParsedFont & {src:string}>} fonts
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {function} FontResolver
|
||||
* @param {string} text
|
||||
* @param {(FontResolverResult) => void} callback
|
||||
* @param {FontResolverOptions} [options]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Factory for the FontResolver function.
|
||||
* @param {FontParser} fontParser
|
||||
* @param {{getFontsForString: function, CodePointSet: function}} unicodeFontResolverClient
|
||||
* @return {FontResolver}
|
||||
*/
|
||||
export function createFontResolver(fontParser, unicodeFontResolverClient) {
|
||||
/**
|
||||
* @type {Record<string, ParsedFont>}
|
||||
*/
|
||||
const parsedFonts = Object.create(null)
|
||||
|
||||
/**
|
||||
* @type {Record<string, Array<(ParsedFont) => void>>}
|
||||
*/
|
||||
const loadingFonts = Object.create(null)
|
||||
|
||||
/**
|
||||
* Load a given font url
|
||||
*/
|
||||
function doLoadFont(url, callback) {
|
||||
const onError = err => {
|
||||
console.error(`Failure loading font ${url}`, err)
|
||||
}
|
||||
try {
|
||||
const request = new XMLHttpRequest()
|
||||
request.open('get', url, true)
|
||||
request.responseType = 'arraybuffer'
|
||||
request.onload = function () {
|
||||
if (request.status >= 400) {
|
||||
onError(new Error(request.statusText))
|
||||
}
|
||||
else if (request.status > 0) {
|
||||
try {
|
||||
const fontObj = fontParser(request.response)
|
||||
fontObj.src = url;
|
||||
callback(fontObj)
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
request.onerror = onError
|
||||
request.send()
|
||||
} catch(err) {
|
||||
onError(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Load a given font url if needed, invoking a callback when it's loaded. If already
|
||||
* loaded, the callback will be called synchronously.
|
||||
* @param {string} fontUrl
|
||||
* @param {(font: ParsedFont) => void} callback
|
||||
*/
|
||||
function loadFont(fontUrl, callback) {
|
||||
let font = parsedFonts[fontUrl]
|
||||
if (font) {
|
||||
callback(font)
|
||||
} else if (loadingFonts[fontUrl]) {
|
||||
loadingFonts[fontUrl].push(callback)
|
||||
} else {
|
||||
loadingFonts[fontUrl] = [callback]
|
||||
doLoadFont(fontUrl, fontObj => {
|
||||
fontObj.src = fontUrl
|
||||
parsedFonts[fontUrl] = fontObj
|
||||
loadingFonts[fontUrl].forEach(cb => cb(fontObj))
|
||||
delete loadingFonts[fontUrl];
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given string of text, determine which fonts are required to fully render it and
|
||||
* ensure those fonts are loaded.
|
||||
*/
|
||||
return function (text, callback, {
|
||||
lang,
|
||||
fonts: userFonts = [],
|
||||
style = 'normal',
|
||||
weight = 'normal',
|
||||
unicodeFontsURL
|
||||
} = {}) {
|
||||
const charResolutions = new Uint8Array(text.length);
|
||||
const fontResolutions = [];
|
||||
if (!text.length) {
|
||||
allDone()
|
||||
}
|
||||
|
||||
const fontIndices = new Map();
|
||||
const fallbackRanges = [] // [[start, end], ...]
|
||||
|
||||
if (style !== 'italic') style = 'normal'
|
||||
if (typeof weight !== 'number') {
|
||||
weight = weight === 'bold' ? 700 : 400
|
||||
}
|
||||
|
||||
if (userFonts && !Array.isArray(userFonts)) {
|
||||
userFonts = [userFonts]
|
||||
}
|
||||
userFonts = userFonts.slice()
|
||||
// filter by language
|
||||
.filter(def => !def.lang || def.lang.test(lang))
|
||||
// switch order for easier iteration
|
||||
.reverse()
|
||||
if (userFonts.length) {
|
||||
const UNKNOWN = 0
|
||||
const RESOLVED = 1
|
||||
const NEEDS_FALLBACK = 2
|
||||
let prevCharResult = UNKNOWN
|
||||
|
||||
;(function resolveUserFonts (startIndex = 0) {
|
||||
for (let i = startIndex, iLen = text.length; i < iLen; i++) {
|
||||
const codePoint = text.codePointAt(i)
|
||||
// Carry previous character's result forward if:
|
||||
// - it resolved to a font that also covers this character
|
||||
// - this character is whitespace
|
||||
if (
|
||||
(prevCharResult === RESOLVED && fontResolutions[charResolutions[i - 1]].supportsCodePoint(codePoint)) ||
|
||||
(i > 0 && /\s/.test(text[i]))
|
||||
) {
|
||||
charResolutions[i] = charResolutions[i - 1]
|
||||
if (prevCharResult === NEEDS_FALLBACK) {
|
||||
fallbackRanges[fallbackRanges.length - 1][1] = i
|
||||
}
|
||||
} else {
|
||||
for (let j = charResolutions[i], jLen = userFonts.length; j <= jLen; j++) {
|
||||
if (j === jLen) {
|
||||
// none of the user fonts matched; needs fallback
|
||||
const range = prevCharResult === NEEDS_FALLBACK ?
|
||||
fallbackRanges[fallbackRanges.length - 1] :
|
||||
(fallbackRanges[fallbackRanges.length] = [i, i])
|
||||
range[1] = i;
|
||||
prevCharResult = NEEDS_FALLBACK;
|
||||
} else {
|
||||
charResolutions[i] = j;
|
||||
const { src, unicodeRange } = userFonts[j];
|
||||
// filter by optional explicit unicode ranges
|
||||
if (!unicodeRange || isCodeInRanges(codePoint, unicodeRange)) {
|
||||
const fontObj = parsedFonts[src];
|
||||
// font not yet loaded, load it and resume
|
||||
if (!fontObj) {
|
||||
loadFont(src, () => {
|
||||
resolveUserFonts(i);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// if the font actually contains a glyph for this char, lock it in
|
||||
if (fontObj.supportsCodePoint(codePoint)) {
|
||||
let fontIndex = fontIndices.get(fontObj);
|
||||
if (typeof fontIndex !== 'number') {
|
||||
fontIndex = fontResolutions.length;
|
||||
fontResolutions.push(fontObj);
|
||||
fontIndices.set(fontObj, fontIndex);
|
||||
}
|
||||
charResolutions[i] = fontIndex;
|
||||
prevCharResult = RESOLVED;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (codePoint > 0xffff && i + 1 < iLen) {
|
||||
charResolutions[i + 1] = charResolutions[i]
|
||||
i++
|
||||
if (prevCharResult === NEEDS_FALLBACK) {
|
||||
fallbackRanges[fallbackRanges.length - 1][1] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
resolveFallbacks();
|
||||
})();
|
||||
} else {
|
||||
fallbackRanges.push([0, text.length - 1])
|
||||
resolveFallbacks();
|
||||
}
|
||||
|
||||
function resolveFallbacks() {
|
||||
if (fallbackRanges.length) {
|
||||
// Combine all fallback substrings into a single string for querying
|
||||
const fallbackString = fallbackRanges.map(range => text.substring(range[0], range[1] + 1)).join('\n')
|
||||
unicodeFontResolverClient.getFontsForString(fallbackString, {
|
||||
lang: lang || undefined,
|
||||
style,
|
||||
weight,
|
||||
dataUrl: unicodeFontsURL
|
||||
}).then(({fontUrls, chars}) => {
|
||||
// Extract results and put them back in the main array
|
||||
const fontIndexOffset = fontResolutions.length
|
||||
let charIdx = 0;
|
||||
fallbackRanges.forEach(range => {
|
||||
for (let i = 0, endIdx = range[1] - range[0]; i <= endIdx; i++) {
|
||||
charResolutions[range[0] + i] = chars[charIdx++] + fontIndexOffset
|
||||
}
|
||||
charIdx++ //skip segment separator
|
||||
})
|
||||
|
||||
// Load and parse the fallback fonts - avoiding Promise here to prevent polyfills in the worker
|
||||
let loadedCount = 0;
|
||||
fontUrls.forEach((url, i) => {
|
||||
loadFont(url, fontObj => {
|
||||
fontResolutions[i + fontIndexOffset] = fontObj
|
||||
if (++loadedCount === fontUrls.length) {
|
||||
allDone();
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
} else {
|
||||
allDone();
|
||||
}
|
||||
}
|
||||
|
||||
function allDone() {
|
||||
callback({
|
||||
chars: charResolutions,
|
||||
fonts: fontResolutions
|
||||
})
|
||||
}
|
||||
|
||||
function isCodeInRanges(code, ranges) {
|
||||
// todo optimize search - CodePointSet from unicode-font-resolver?
|
||||
for (let k = 0; k < ranges.length; k++) {
|
||||
const [start, end = start] = ranges[k]
|
||||
if (start <= code && code <= end) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const fontResolverWorkerModule = /*#__PURE__*/defineWorkerModule({
|
||||
name: 'FontResolver',
|
||||
dependencies: [
|
||||
createFontResolver,
|
||||
fontParser,
|
||||
unicodeFontResolverClientFactory,
|
||||
],
|
||||
init(createFontResolver, fontParser, unicodeFontResolverClientFactory) {
|
||||
return createFontResolver(fontParser, unicodeFontResolverClientFactory());
|
||||
}
|
||||
})
|
||||
218
node_modules/troika-three-text/src/GlyphsGeometry.js
generated
vendored
Normal file
218
node_modules/troika-three-text/src/GlyphsGeometry.js
generated
vendored
Normal file
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
PlaneGeometry,
|
||||
InstancedBufferGeometry,
|
||||
InstancedBufferAttribute,
|
||||
Sphere,
|
||||
Box3,
|
||||
} from 'three'
|
||||
|
||||
const templateGeometries = {}
|
||||
|
||||
function getTemplateGeometry(detail) {
|
||||
let geom = templateGeometries[detail]
|
||||
if (!geom) {
|
||||
geom = templateGeometries[detail] = new PlaneGeometry(1, 1, detail, detail).translate(0.5, 0.5, 0)
|
||||
}
|
||||
return geom
|
||||
}
|
||||
|
||||
const glyphBoundsAttrName = 'aTroikaGlyphBounds'
|
||||
const glyphIndexAttrName = 'aTroikaGlyphIndex'
|
||||
const glyphColorAttrName = 'aTroikaGlyphColor'
|
||||
|
||||
/**
|
||||
@class GlyphsGeometry
|
||||
|
||||
A specialized Geometry for rendering a set of text glyphs. Uses InstancedBufferGeometry to
|
||||
render the glyphs using GPU instancing of a single quad, rather than constructing a whole
|
||||
geometry with vertices, for much smaller attribute arraybuffers according to this math:
|
||||
|
||||
Where N = number of glyphs...
|
||||
|
||||
Instanced:
|
||||
- position: 4 * 3
|
||||
- index: 2 * 3
|
||||
- normal: 4 * 3
|
||||
- uv: 4 * 2
|
||||
- glyph x/y bounds: N * 4
|
||||
- glyph indices: N * 1
|
||||
= 5N + 38
|
||||
|
||||
Non-instanced:
|
||||
- position: N * 4 * 3
|
||||
- index: N * 2 * 3
|
||||
- normal: N * 4 * 3
|
||||
- uv: N * 4 * 2
|
||||
- glyph indices: N * 1
|
||||
= 39N
|
||||
|
||||
A downside of this is the rare-but-possible lack of the instanced arrays extension,
|
||||
which we could potentially work around with a fallback non-instanced implementation.
|
||||
|
||||
*/
|
||||
class GlyphsGeometry extends InstancedBufferGeometry {
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.detail = 1
|
||||
this.curveRadius = 0
|
||||
|
||||
// Define groups for rendering text outline as a separate pass; these will only
|
||||
// be used when the `material` getter returns an array, i.e. outlineWidth > 0.
|
||||
this.groups = [
|
||||
{start: 0, count: Infinity, materialIndex: 0},
|
||||
{start: 0, count: Infinity, materialIndex: 1}
|
||||
]
|
||||
|
||||
// Preallocate empty bounding objects
|
||||
this.boundingSphere = new Sphere()
|
||||
this.boundingBox = new Box3()
|
||||
}
|
||||
|
||||
computeBoundingSphere () {
|
||||
// No-op; we'll sync the boundingSphere proactively when needed.
|
||||
}
|
||||
|
||||
computeBoundingBox() {
|
||||
// No-op; we'll sync the boundingBox proactively when needed.
|
||||
}
|
||||
|
||||
set detail(detail) {
|
||||
if (detail !== this._detail) {
|
||||
this._detail = detail
|
||||
if (typeof detail !== 'number' || detail < 1) {
|
||||
detail = 1
|
||||
}
|
||||
let tpl = getTemplateGeometry(detail)
|
||||
;['position', 'normal', 'uv'].forEach(attr => {
|
||||
this.attributes[attr] = tpl.attributes[attr].clone()
|
||||
})
|
||||
this.setIndex(tpl.getIndex().clone())
|
||||
}
|
||||
}
|
||||
get detail() {
|
||||
return this._detail
|
||||
}
|
||||
|
||||
set curveRadius(r) {
|
||||
if (r !== this._curveRadius) {
|
||||
this._curveRadius = r
|
||||
this._updateBounds()
|
||||
}
|
||||
}
|
||||
get curveRadius() {
|
||||
return this._curveRadius
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the geometry for a new set of glyphs.
|
||||
* @param {Float32Array} glyphBounds - An array holding the planar bounds for all glyphs
|
||||
* to be rendered, 4 entries for each glyph: x1,x2,y1,y1
|
||||
* @param {Float32Array} glyphAtlasIndices - An array holding the index of each glyph within
|
||||
* the SDF atlas texture.
|
||||
* @param {Array} blockBounds - An array holding the [minX, minY, maxX, maxY] across all glyphs
|
||||
* @param {Array} [chunkedBounds] - An array of objects describing bounds for each chunk of N
|
||||
* consecutive glyphs: `{start:N, end:N, rect:[minX, minY, maxX, maxY]}`. This can be
|
||||
* used with `applyClipRect` to choose an optimized `instanceCount`.
|
||||
* @param {Uint8Array} [glyphColors] - An array holding r,g,b values for each glyph.
|
||||
*/
|
||||
updateGlyphs(glyphBounds, glyphAtlasIndices, blockBounds, chunkedBounds, glyphColors) {
|
||||
// Update the instance attributes
|
||||
this.updateAttributeData(glyphBoundsAttrName, glyphBounds, 4)
|
||||
this.updateAttributeData(glyphIndexAttrName, glyphAtlasIndices, 1)
|
||||
this.updateAttributeData(glyphColorAttrName, glyphColors, 3)
|
||||
this._blockBounds = blockBounds
|
||||
this._chunkedBounds = chunkedBounds
|
||||
this.instanceCount = glyphAtlasIndices.length
|
||||
this._updateBounds()
|
||||
}
|
||||
|
||||
_updateBounds() {
|
||||
const bounds = this._blockBounds
|
||||
if (bounds) {
|
||||
const { curveRadius, boundingBox: bbox } = this
|
||||
if (curveRadius) {
|
||||
const { PI, floor, min, max, sin, cos } = Math
|
||||
const halfPi = PI / 2
|
||||
const twoPi = PI * 2
|
||||
const absR = Math.abs(curveRadius)
|
||||
const leftAngle = bounds[0] / absR
|
||||
const rightAngle = bounds[2] / absR
|
||||
const minX = floor((leftAngle + halfPi) / twoPi) !== floor((rightAngle + halfPi) / twoPi)
|
||||
? -absR : min(sin(leftAngle) * absR, sin(rightAngle) * absR)
|
||||
const maxX = floor((leftAngle - halfPi) / twoPi) !== floor((rightAngle - halfPi) / twoPi)
|
||||
? absR : max(sin(leftAngle) * absR, sin(rightAngle) * absR)
|
||||
const maxZ = floor((leftAngle + PI) / twoPi) !== floor((rightAngle + PI) / twoPi)
|
||||
? absR * 2 : max(absR - cos(leftAngle) * absR, absR - cos(rightAngle) * absR)
|
||||
bbox.min.set(minX, bounds[1], curveRadius < 0 ? -maxZ : 0)
|
||||
bbox.max.set(maxX, bounds[3], curveRadius < 0 ? 0 : maxZ)
|
||||
} else {
|
||||
bbox.min.set(bounds[0], bounds[1], 0)
|
||||
bbox.max.set(bounds[2], bounds[3], 0)
|
||||
}
|
||||
bbox.getBoundingSphere(this.boundingSphere)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a clipping rect, and the chunkedBounds from the last updateGlyphs call, choose the lowest
|
||||
* `instanceCount` that will show all glyphs within the clipped view. This is an optimization
|
||||
* for long blocks of text that are clipped, to skip vertex shader evaluation for glyphs that would
|
||||
* be clipped anyway.
|
||||
*
|
||||
* Note that since `drawElementsInstanced[ANGLE]` only accepts an instance count and not a starting
|
||||
* offset, this optimization becomes less effective as the clipRect moves closer to the end of the
|
||||
* text block. We could fix that by switching from instancing to a full geometry with a drawRange,
|
||||
* but at the expense of much larger attribute buffers (see classdoc above.)
|
||||
*
|
||||
* @param {Vector4} clipRect
|
||||
*/
|
||||
applyClipRect(clipRect) {
|
||||
let count = this.getAttribute(glyphIndexAttrName).count
|
||||
let chunks = this._chunkedBounds
|
||||
if (chunks) {
|
||||
for (let i = chunks.length; i--;) {
|
||||
count = chunks[i].end
|
||||
let rect = chunks[i].rect
|
||||
// note: both rects are l-b-r-t
|
||||
if (rect[1] < clipRect.w && rect[3] > clipRect.y && rect[0] < clipRect.z && rect[2] > clipRect.x) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
this.instanceCount = count
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility for updating instance attributes with automatic resizing
|
||||
*/
|
||||
updateAttributeData(attrName, newArray, itemSize) {
|
||||
const attr = this.getAttribute(attrName)
|
||||
if (newArray) {
|
||||
// If length isn't changing, just update the attribute's array data
|
||||
if (attr && attr.array.length === newArray.length) {
|
||||
attr.array.set(newArray)
|
||||
attr.needsUpdate = true
|
||||
} else {
|
||||
this.setAttribute(attrName, new InstancedBufferAttribute(newArray, itemSize))
|
||||
// If the new attribute has a different size, we also have to (as of r117) manually clear the
|
||||
// internal cached max instance count. See https://github.com/mrdoob/three.js/issues/19706
|
||||
// It's unclear if this is a threejs bug or a truly unsupported scenario; discussion in
|
||||
// that ticket is ambiguous as to whether replacing a BufferAttribute with one of a
|
||||
// different size is supported, but https://github.com/mrdoob/three.js/pull/17418 strongly
|
||||
// implies it should be supported. It's possible we need to
|
||||
delete this._maxInstanceCount //for r117+, could be fragile
|
||||
this.dispose() //for r118+, more robust feeling, but more heavy-handed than I'd like
|
||||
}
|
||||
} else if (attr) {
|
||||
this.deleteAttribute(attrName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
GlyphsGeometry,
|
||||
glyphBoundsAttrName,
|
||||
glyphColorAttrName,
|
||||
glyphIndexAttrName,
|
||||
}
|
||||
137
node_modules/troika-three-text/src/SDFGenerator.js
generated
vendored
Normal file
137
node_modules/troika-three-text/src/SDFGenerator.js
generated
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
import { defineWorkerModule, terminateWorker } from 'troika-worker-utils'
|
||||
import createSDFGenerator from 'webgl-sdf-generator'
|
||||
|
||||
const now = () => (self.performance || Date).now()
|
||||
|
||||
const mainThreadGenerator = /*#__PURE__*/ createSDFGenerator()
|
||||
|
||||
let warned
|
||||
|
||||
/**
|
||||
* Generate an SDF texture image for a single glyph path, placing the result into a webgl canvas at a
|
||||
* given location and channel. Utilizes the webgl-sdf-generator external package for GPU-accelerated SDF
|
||||
* generation when supported.
|
||||
*/
|
||||
export function generateSDF(width, height, path, viewBox, distance, exponent, canvas, x, y, channel, useWebGL = true) {
|
||||
// Allow opt-out
|
||||
if (!useWebGL) {
|
||||
return generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel)
|
||||
}
|
||||
|
||||
// Attempt GPU-accelerated generation first
|
||||
return generateSDF_GL(width, height, path, viewBox, distance, exponent, canvas, x, y, channel).then(
|
||||
null,
|
||||
err => {
|
||||
// WebGL failed either due to a hard error or unexpected results; fall back to JS in workers
|
||||
if (!warned) {
|
||||
console.warn(`WebGL SDF generation failed, falling back to JS`, err)
|
||||
warned = true
|
||||
}
|
||||
return generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const queue = []
|
||||
const chunkTimeBudget = 5 // ms
|
||||
let timer = 0
|
||||
|
||||
function nextChunk() {
|
||||
const start = now()
|
||||
while (queue.length && now() - start < chunkTimeBudget) {
|
||||
queue.shift()()
|
||||
}
|
||||
timer = queue.length ? setTimeout(nextChunk, 0) : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* WebGL-based implementation executed on the main thread. Requests are executed in time-bounded
|
||||
* macrotask chunks to allow render frames to execute in between.
|
||||
*/
|
||||
const generateSDF_GL = (...args) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
queue.push(() => {
|
||||
const start = now()
|
||||
try {
|
||||
mainThreadGenerator.webgl.generateIntoCanvas(...args)
|
||||
resolve({ timing: now() - start })
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
if (!timer) {
|
||||
timer = setTimeout(nextChunk, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const threadCount = 4 // how many workers to spawn
|
||||
const idleTimeout = 2000 // workers will be terminated after being idle this many milliseconds
|
||||
const threads = {}
|
||||
let callNum = 0
|
||||
|
||||
/**
|
||||
* Fallback JS-based implementation, fanned out to a number of worker threads for parallelism
|
||||
*/
|
||||
function generateSDF_JS_Worker(width, height, path, viewBox, distance, exponent, canvas, x, y, channel) {
|
||||
const workerId = 'TroikaTextSDFGenerator_JS_' + ((callNum++) % threadCount)
|
||||
let thread = threads[workerId]
|
||||
if (!thread) {
|
||||
thread = threads[workerId] = {
|
||||
workerModule: defineWorkerModule({
|
||||
name: workerId,
|
||||
workerId,
|
||||
dependencies: [
|
||||
createSDFGenerator,
|
||||
now
|
||||
],
|
||||
init(_createSDFGenerator, now) {
|
||||
const generate = _createSDFGenerator().javascript.generate
|
||||
return function (...args) {
|
||||
const start = now()
|
||||
const textureData = generate(...args)
|
||||
return {
|
||||
textureData,
|
||||
timing: now() - start
|
||||
}
|
||||
}
|
||||
},
|
||||
getTransferables(result) {
|
||||
return [result.textureData.buffer]
|
||||
}
|
||||
}),
|
||||
requests: 0,
|
||||
idleTimer: null
|
||||
}
|
||||
}
|
||||
|
||||
thread.requests++
|
||||
clearTimeout(thread.idleTimer)
|
||||
return thread.workerModule(width, height, path, viewBox, distance, exponent)
|
||||
.then(({ textureData, timing }) => {
|
||||
// copy result data into the canvas
|
||||
const start = now()
|
||||
// expand single-channel data into rgba
|
||||
const imageData = new Uint8Array(textureData.length * 4)
|
||||
for (let i = 0; i < textureData.length; i++) {
|
||||
imageData[i * 4 + channel] = textureData[i]
|
||||
}
|
||||
mainThreadGenerator.webglUtils.renderImageData(canvas, imageData, x, y, width, height, 1 << (3 - channel))
|
||||
timing += now() - start
|
||||
|
||||
// clean up workers after a while
|
||||
if (--thread.requests === 0) {
|
||||
thread.idleTimer = setTimeout(() => { terminateWorker(workerId) }, idleTimeout)
|
||||
}
|
||||
return { timing }
|
||||
})
|
||||
}
|
||||
|
||||
export function warmUpSDFCanvas(canvas) {
|
||||
if (!canvas._warm) {
|
||||
mainThreadGenerator.webgl.isSupported(canvas)
|
||||
canvas._warm = true
|
||||
}
|
||||
}
|
||||
|
||||
export const resizeWebGLCanvasWithoutClearing = mainThreadGenerator.webglUtils.resizeWebGLCanvasWithoutClearing
|
||||
800
node_modules/troika-three-text/src/Text.js
generated
vendored
Normal file
800
node_modules/troika-three-text/src/Text.js
generated
vendored
Normal file
@@ -0,0 +1,800 @@
|
||||
import {
|
||||
Color,
|
||||
DoubleSide,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
PlaneGeometry,
|
||||
Vector3,
|
||||
Vector2,
|
||||
} from 'three'
|
||||
import { GlyphsGeometry } from './GlyphsGeometry.js'
|
||||
import { createTextDerivedMaterial } from './TextDerivedMaterial.js'
|
||||
import { getTextRenderInfo } from './TextBuilder.js'
|
||||
|
||||
|
||||
const defaultMaterial = /*#__PURE__*/ new MeshBasicMaterial({
|
||||
color: 0xffffff,
|
||||
side: DoubleSide,
|
||||
transparent: true
|
||||
})
|
||||
const defaultStrokeColor = 0x808080
|
||||
|
||||
const tempMat4 = /*#__PURE__*/ new Matrix4()
|
||||
const tempVec3a = /*#__PURE__*/ new Vector3()
|
||||
const tempVec3b = /*#__PURE__*/ new Vector3()
|
||||
const tempArray = []
|
||||
const origin = /*#__PURE__*/ new Vector3()
|
||||
const defaultOrient = '+x+y'
|
||||
|
||||
function first(o) {
|
||||
return Array.isArray(o) ? o[0] : o
|
||||
}
|
||||
|
||||
let getFlatRaycastMesh = () => {
|
||||
const mesh = new Mesh(
|
||||
new PlaneGeometry(1, 1),
|
||||
defaultMaterial
|
||||
)
|
||||
getFlatRaycastMesh = () => mesh
|
||||
return mesh
|
||||
}
|
||||
let getCurvedRaycastMesh = () => {
|
||||
const mesh = new Mesh(
|
||||
new PlaneGeometry(1, 1, 32, 1),
|
||||
defaultMaterial
|
||||
)
|
||||
getCurvedRaycastMesh = () => mesh
|
||||
return mesh
|
||||
}
|
||||
|
||||
const syncStartEvent = { type: 'syncstart' }
|
||||
const syncCompleteEvent = { type: 'synccomplete' }
|
||||
|
||||
const SYNCABLE_PROPS = [
|
||||
'font',
|
||||
'fontSize',
|
||||
'fontStyle',
|
||||
'fontWeight',
|
||||
'lang',
|
||||
'letterSpacing',
|
||||
'lineHeight',
|
||||
'maxWidth',
|
||||
'overflowWrap',
|
||||
'text',
|
||||
'direction',
|
||||
'textAlign',
|
||||
'textIndent',
|
||||
'whiteSpace',
|
||||
'anchorX',
|
||||
'anchorY',
|
||||
'colorRanges',
|
||||
'sdfGlyphSize'
|
||||
]
|
||||
|
||||
const COPYABLE_PROPS = SYNCABLE_PROPS.concat(
|
||||
'material',
|
||||
'color',
|
||||
'depthOffset',
|
||||
'clipRect',
|
||||
'curveRadius',
|
||||
'orientation',
|
||||
'glyphGeometryDetail'
|
||||
)
|
||||
|
||||
/**
|
||||
* @class Text
|
||||
*
|
||||
* A ThreeJS Mesh that renders a string of text on a plane in 3D space using signed distance
|
||||
* fields (SDF).
|
||||
*/
|
||||
class Text extends Mesh {
|
||||
constructor() {
|
||||
const geometry = new GlyphsGeometry()
|
||||
super(geometry, null)
|
||||
|
||||
// === Text layout properties: === //
|
||||
|
||||
/**
|
||||
* @member {string} text
|
||||
* The string of text to be rendered.
|
||||
*/
|
||||
this.text = ''
|
||||
|
||||
/**
|
||||
* @member {number|string} anchorX
|
||||
* Defines the horizontal position in the text block that should line up with the local origin.
|
||||
* Can be specified as a numeric x position in local units, a string percentage of the total
|
||||
* text block width e.g. `'25%'`, or one of the following keyword strings: 'left', 'center',
|
||||
* or 'right'.
|
||||
*/
|
||||
this.anchorX = 0
|
||||
|
||||
/**
|
||||
* @member {number|string} anchorY
|
||||
* Defines the vertical position in the text block that should line up with the local origin.
|
||||
* Can be specified as a numeric y position in local units (note: down is negative y), a string
|
||||
* percentage of the total text block height e.g. `'25%'`, or one of the following keyword strings:
|
||||
* 'top', 'top-baseline', 'top-cap', 'top-ex', 'middle', 'bottom-baseline', or 'bottom'.
|
||||
*/
|
||||
this.anchorY = 0
|
||||
|
||||
/**
|
||||
* @member {number} curveRadius
|
||||
* Defines a cylindrical radius along which the text's plane will be curved. Positive numbers put
|
||||
* the cylinder's centerline (oriented vertically) that distance in front of the text, for a concave
|
||||
* curvature, while negative numbers put it behind the text for a convex curvature. The centerline
|
||||
* will be aligned with the text's local origin; you can use `anchorX` to offset it.
|
||||
*
|
||||
* Since each glyph is by default rendered with a simple quad, each glyph remains a flat plane
|
||||
* internally. You can use `glyphGeometryDetail` to add more vertices for curvature inside glyphs.
|
||||
*/
|
||||
this.curveRadius = 0
|
||||
|
||||
/**
|
||||
* @member {string} direction
|
||||
* Sets the base direction for the text. The default value of "auto" will choose a direction based
|
||||
* on the text's content according to the bidi spec. A value of "ltr" or "rtl" will force the direction.
|
||||
*/
|
||||
this.direction = 'auto'
|
||||
|
||||
/**
|
||||
* @member {string|null} font
|
||||
* URL of a custom font to be used. Font files can be in .ttf, .otf, or .woff (not .woff2) formats.
|
||||
* Defaults to Noto Sans.
|
||||
*/
|
||||
this.font = null //will use default from TextBuilder
|
||||
|
||||
this.unicodeFontsURL = null //defaults to CDN
|
||||
|
||||
/**
|
||||
* @member {number} fontSize
|
||||
* The size at which to render the font in local units; corresponds to the em-box height
|
||||
* of the chosen `font`.
|
||||
*/
|
||||
this.fontSize = 0.1
|
||||
|
||||
/**
|
||||
* @member {number|'normal'|'bold'}
|
||||
* The weight of the font. Currently only used for fallback Noto fonts.
|
||||
*/
|
||||
this.fontWeight = 'normal'
|
||||
|
||||
/**
|
||||
* @member {'normal'|'italic'}
|
||||
* The style of the font. Currently only used for fallback Noto fonts.
|
||||
*/
|
||||
this.fontStyle = 'normal'
|
||||
|
||||
/**
|
||||
* @member {string|null} lang
|
||||
* The language code of this text; can be used for explicitly selecting certain CJK fonts.
|
||||
*/
|
||||
this.lang = null;
|
||||
|
||||
/**
|
||||
* @member {number} letterSpacing
|
||||
* Sets a uniform adjustment to spacing between letters after kerning is applied. Positive
|
||||
* numbers increase spacing and negative numbers decrease it.
|
||||
*/
|
||||
this.letterSpacing = 0
|
||||
|
||||
/**
|
||||
* @member {number|string} lineHeight
|
||||
* Sets the height of each line of text, as a multiple of the `fontSize`. Defaults to 'normal'
|
||||
* which chooses a reasonable height based on the chosen font's ascender/descender metrics.
|
||||
*/
|
||||
this.lineHeight = 'normal'
|
||||
|
||||
/**
|
||||
* @member {number} maxWidth
|
||||
* The maximum width of the text block, above which text may start wrapping according to the
|
||||
* `whiteSpace` and `overflowWrap` properties.
|
||||
*/
|
||||
this.maxWidth = Infinity
|
||||
|
||||
/**
|
||||
* @member {string} overflowWrap
|
||||
* Defines how text wraps if the `whiteSpace` property is `normal`. Can be either `'normal'`
|
||||
* to break at whitespace characters, or `'break-word'` to allow breaking within words.
|
||||
* Defaults to `'normal'`.
|
||||
*/
|
||||
this.overflowWrap = 'normal'
|
||||
|
||||
/**
|
||||
* @member {string} textAlign
|
||||
* The horizontal alignment of each line of text within the overall text bounding box.
|
||||
*/
|
||||
this.textAlign = 'left'
|
||||
|
||||
/**
|
||||
* @member {number} textIndent
|
||||
* Indentation for the first character of a line; see CSS `text-indent`.
|
||||
*/
|
||||
this.textIndent = 0
|
||||
|
||||
/**
|
||||
* @member {string} whiteSpace
|
||||
* Defines whether text should wrap when a line reaches the `maxWidth`. Can
|
||||
* be either `'normal'` (the default), to allow wrapping according to the `overflowWrap` property,
|
||||
* or `'nowrap'` to prevent wrapping. Note that `'normal'` here honors newline characters to
|
||||
* manually break lines, making it behave more like `'pre-wrap'` does in CSS.
|
||||
*/
|
||||
this.whiteSpace = 'normal'
|
||||
|
||||
|
||||
// === Presentation properties: === //
|
||||
|
||||
/**
|
||||
* @member {THREE.Material} material
|
||||
* Defines a _base_ material to be used when rendering the text. This material will be
|
||||
* automatically replaced with a material derived from it, that adds shader code to
|
||||
* decrease the alpha for each fragment (pixel) outside the text glyphs, with antialiasing.
|
||||
* By default it will derive from a simple white MeshBasicMaterial, but you can use any
|
||||
* of the other mesh materials to gain other features like lighting, texture maps, etc.
|
||||
*
|
||||
* Also see the `color` shortcut property.
|
||||
*/
|
||||
this.material = null
|
||||
|
||||
/**
|
||||
* @member {string|number|THREE.Color} color
|
||||
* This is a shortcut for setting the `color` of the text's material. You can use this
|
||||
* if you don't want to specify a whole custom `material`. Also, if you do use a custom
|
||||
* `material`, this color will only be used for this particuar Text instance, even if
|
||||
* that same material instance is shared across multiple Text objects.
|
||||
*/
|
||||
this.color = null
|
||||
|
||||
/**
|
||||
* @member {object|null} colorRanges
|
||||
* WARNING: This API is experimental and may change.
|
||||
* This allows more fine-grained control of colors for individual or ranges of characters,
|
||||
* taking precedence over the material's `color`. Its format is an Object whose keys each
|
||||
* define a starting character index for a range, and whose values are the color for each
|
||||
* range. The color value can be a numeric hex color value, a `THREE.Color` object, or
|
||||
* any of the strings accepted by `THREE.Color`.
|
||||
*/
|
||||
this.colorRanges = null
|
||||
|
||||
/**
|
||||
* @member {number|string} outlineWidth
|
||||
* WARNING: This API is experimental and may change.
|
||||
* The width of an outline/halo to be drawn around each text glyph using the `outlineColor` and `outlineOpacity`.
|
||||
* Can be specified as either an absolute number in local units, or as a percentage string e.g.
|
||||
* `"12%"` which is treated as a percentage of the `fontSize`. Defaults to `0`, which means
|
||||
* no outline will be drawn unless an `outlineOffsetX/Y` or `outlineBlur` is set.
|
||||
*/
|
||||
this.outlineWidth = 0
|
||||
|
||||
/**
|
||||
* @member {string|number|THREE.Color} outlineColor
|
||||
* WARNING: This API is experimental and may change.
|
||||
* The color of the text outline, if `outlineWidth`/`outlineBlur`/`outlineOffsetX/Y` are set.
|
||||
* Defaults to black.
|
||||
*/
|
||||
this.outlineColor = 0x000000
|
||||
|
||||
/**
|
||||
* @member {number} outlineOpacity
|
||||
* WARNING: This API is experimental and may change.
|
||||
* The opacity of the outline, if `outlineWidth`/`outlineBlur`/`outlineOffsetX/Y` are set.
|
||||
* Defaults to `1`.
|
||||
*/
|
||||
this.outlineOpacity = 1
|
||||
|
||||
/**
|
||||
* @member {number|string} outlineBlur
|
||||
* WARNING: This API is experimental and may change.
|
||||
* A blur radius applied to the outer edge of the text's outline. If the `outlineWidth` is
|
||||
* zero, the blur will be applied at the glyph edge, like CSS's `text-shadow` blur radius.
|
||||
* Can be specified as either an absolute number in local units, or as a percentage string e.g.
|
||||
* `"12%"` which is treated as a percentage of the `fontSize`. Defaults to `0`.
|
||||
*/
|
||||
this.outlineBlur = 0
|
||||
|
||||
/**
|
||||
* @member {number|string} outlineOffsetX
|
||||
* WARNING: This API is experimental and may change.
|
||||
* A horizontal offset for the text outline.
|
||||
* Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"`
|
||||
* which is treated as a percentage of the `fontSize`. Defaults to `0`.
|
||||
*/
|
||||
this.outlineOffsetX = 0
|
||||
|
||||
/**
|
||||
* @member {number|string} outlineOffsetY
|
||||
* WARNING: This API is experimental and may change.
|
||||
* A vertical offset for the text outline.
|
||||
* Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"`
|
||||
* which is treated as a percentage of the `fontSize`. Defaults to `0`.
|
||||
*/
|
||||
this.outlineOffsetY = 0
|
||||
|
||||
/**
|
||||
* @member {number|string} strokeWidth
|
||||
* WARNING: This API is experimental and may change.
|
||||
* The width of an inner stroke drawn inside each text glyph using the `strokeColor` and `strokeOpacity`.
|
||||
* Can be specified as either an absolute number in local units, or as a percentage string e.g. `"12%"`
|
||||
* which is treated as a percentage of the `fontSize`. Defaults to `0`.
|
||||
*/
|
||||
this.strokeWidth = 0
|
||||
|
||||
/**
|
||||
* @member {string|number|THREE.Color} strokeColor
|
||||
* WARNING: This API is experimental and may change.
|
||||
* The color of the text stroke, if `strokeWidth` is greater than zero. Defaults to gray.
|
||||
*/
|
||||
this.strokeColor = defaultStrokeColor
|
||||
|
||||
/**
|
||||
* @member {number} strokeOpacity
|
||||
* WARNING: This API is experimental and may change.
|
||||
* The opacity of the stroke, if `strokeWidth` is greater than zero. Defaults to `1`.
|
||||
*/
|
||||
this.strokeOpacity = 1
|
||||
|
||||
/**
|
||||
* @member {number} fillOpacity
|
||||
* WARNING: This API is experimental and may change.
|
||||
* The opacity of the glyph's fill from 0 to 1. This behaves like the material's `opacity` but allows
|
||||
* giving the fill a different opacity than the `strokeOpacity`. A fillOpacity of `0` makes the
|
||||
* interior of the glyph invisible, leaving just the `strokeWidth`. Defaults to `1`.
|
||||
*/
|
||||
this.fillOpacity = 1
|
||||
|
||||
/**
|
||||
* @member {number} depthOffset
|
||||
* This is a shortcut for setting the material's `polygonOffset` and related properties,
|
||||
* which can be useful in preventing z-fighting when this text is laid on top of another
|
||||
* plane in the scene. Positive numbers are further from the camera, negatives closer.
|
||||
*/
|
||||
this.depthOffset = 0
|
||||
|
||||
/**
|
||||
* @member {Array<number>} clipRect
|
||||
* If specified, defines a `[minX, minY, maxX, maxY]` of a rectangle outside of which all
|
||||
* pixels will be discarded. This can be used for example to clip overflowing text when
|
||||
* `whiteSpace='nowrap'`.
|
||||
*/
|
||||
this.clipRect = null
|
||||
|
||||
/**
|
||||
* @member {string} orientation
|
||||
* Defines the axis plane on which the text should be laid out when the mesh has no extra
|
||||
* rotation transform. It is specified as a string with two axes: the horizontal axis with
|
||||
* positive pointing right, and the vertical axis with positive pointing up. By default this
|
||||
* is '+x+y', meaning the text sits on the xy plane with the text's top toward positive y
|
||||
* and facing positive z. A value of '+x-z' would place it on the xz plane with the text's
|
||||
* top toward negative z and facing positive y.
|
||||
*/
|
||||
this.orientation = defaultOrient
|
||||
|
||||
/**
|
||||
* @member {number} glyphGeometryDetail
|
||||
* Controls number of vertical/horizontal segments that make up each glyph's rectangular
|
||||
* plane. Defaults to 1. This can be increased to provide more geometrical detail for custom
|
||||
* vertex shader effects, for example.
|
||||
*/
|
||||
this.glyphGeometryDetail = 1
|
||||
|
||||
/**
|
||||
* @member {number|null} sdfGlyphSize
|
||||
* The size of each glyph's SDF (signed distance field) used for rendering. This must be a
|
||||
* power-of-two number. Defaults to 64 which is generally a good balance of size and quality
|
||||
* for most fonts. Larger sizes can improve the quality of glyph rendering by increasing
|
||||
* the sharpness of corners and preventing loss of very thin lines, at the expense of
|
||||
* increased memory footprint and longer SDF generation time.
|
||||
*/
|
||||
this.sdfGlyphSize = null
|
||||
|
||||
/**
|
||||
* @member {boolean} gpuAccelerateSDF
|
||||
* When `true`, the SDF generation process will be GPU-accelerated with WebGL when possible,
|
||||
* making it much faster especially for complex glyphs, and falling back to a JavaScript version
|
||||
* executed in web workers when support isn't available. It should automatically detect support,
|
||||
* but it's still somewhat experimental, so you can set it to `false` to force it to use the JS
|
||||
* version if you encounter issues with it.
|
||||
*/
|
||||
this.gpuAccelerateSDF = true
|
||||
|
||||
this.debugSDF = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the text rendering according to the current text-related configuration properties.
|
||||
* This is an async process, so you can pass in a callback function to be executed when it
|
||||
* finishes.
|
||||
* @param {function} [callback]
|
||||
*/
|
||||
sync(callback) {
|
||||
if (this._needsSync) {
|
||||
this._needsSync = false
|
||||
|
||||
// If there's another sync still in progress, queue
|
||||
if (this._isSyncing) {
|
||||
(this._queuedSyncs || (this._queuedSyncs = [])).push(callback)
|
||||
} else {
|
||||
this._isSyncing = true
|
||||
this.dispatchEvent(syncStartEvent)
|
||||
|
||||
getTextRenderInfo({
|
||||
text: this.text,
|
||||
font: this.font,
|
||||
lang: this.lang,
|
||||
fontSize: this.fontSize || 0.1,
|
||||
fontWeight: this.fontWeight || 'normal',
|
||||
fontStyle: this.fontStyle || 'normal',
|
||||
letterSpacing: this.letterSpacing || 0,
|
||||
lineHeight: this.lineHeight || 'normal',
|
||||
maxWidth: this.maxWidth,
|
||||
direction: this.direction || 'auto',
|
||||
textAlign: this.textAlign,
|
||||
textIndent: this.textIndent,
|
||||
whiteSpace: this.whiteSpace,
|
||||
overflowWrap: this.overflowWrap,
|
||||
anchorX: this.anchorX,
|
||||
anchorY: this.anchorY,
|
||||
colorRanges: this.colorRanges,
|
||||
includeCaretPositions: true, //TODO parameterize
|
||||
sdfGlyphSize: this.sdfGlyphSize,
|
||||
gpuAccelerateSDF: this.gpuAccelerateSDF,
|
||||
unicodeFontsURL: this.unicodeFontsURL,
|
||||
}, textRenderInfo => {
|
||||
this._isSyncing = false
|
||||
|
||||
// Save result for later use in onBeforeRender
|
||||
this._textRenderInfo = textRenderInfo
|
||||
|
||||
// Update the geometry attributes
|
||||
this.geometry.updateGlyphs(
|
||||
textRenderInfo.glyphBounds,
|
||||
textRenderInfo.glyphAtlasIndices,
|
||||
textRenderInfo.blockBounds,
|
||||
textRenderInfo.chunkedBounds,
|
||||
textRenderInfo.glyphColors
|
||||
)
|
||||
|
||||
// If we had extra sync requests queued up, kick it off
|
||||
const queued = this._queuedSyncs
|
||||
if (queued) {
|
||||
this._queuedSyncs = null
|
||||
this._needsSync = true
|
||||
this.sync(() => {
|
||||
queued.forEach(fn => fn && fn())
|
||||
})
|
||||
}
|
||||
|
||||
this.dispatchEvent(syncCompleteEvent)
|
||||
if (callback) {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a sync if needed - note it won't complete until next frame at the
|
||||
* earliest so if possible it's a good idea to call sync() manually as soon as
|
||||
* all the properties have been set.
|
||||
* @override
|
||||
*/
|
||||
onBeforeRender(renderer, scene, camera, geometry, material, group) {
|
||||
this.sync()
|
||||
|
||||
// This may not always be a text material, e.g. if there's a scene.overrideMaterial present
|
||||
if (material.isTroikaTextMaterial) {
|
||||
this._prepareForRender(material)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut to dispose the geometry specific to this instance.
|
||||
* Note: we don't also dispose the derived material here because if anything else is
|
||||
* sharing the same base material it will result in a pause next frame as the program
|
||||
* is recompiled. Instead users can dispose the base material manually, like normal,
|
||||
* and we'll also dispose the derived material at that time.
|
||||
*/
|
||||
dispose() {
|
||||
this.geometry.dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* @property {TroikaTextRenderInfo|null} textRenderInfo
|
||||
* @readonly
|
||||
* The current processed rendering data for this TextMesh, returned by the TextBuilder after
|
||||
* a `sync()` call. This will be `null` initially, and may be stale for a short period until
|
||||
* the asynchrous `sync()` process completes.
|
||||
*/
|
||||
get textRenderInfo() {
|
||||
return this._textRenderInfo || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the text derived material from the base material. Can be overridden to use a custom
|
||||
* derived material.
|
||||
*/
|
||||
createDerivedMaterial(baseMaterial) {
|
||||
return createTextDerivedMaterial(baseMaterial)
|
||||
}
|
||||
|
||||
// Handler for automatically wrapping the base material with our upgrades. We do the wrapping
|
||||
// lazily on _read_ rather than write to avoid unnecessary wrapping on transient values.
|
||||
get material() {
|
||||
let derivedMaterial = this._derivedMaterial
|
||||
const baseMaterial = this._baseMaterial || this._defaultMaterial || (this._defaultMaterial = defaultMaterial.clone())
|
||||
if (!derivedMaterial || !derivedMaterial.isDerivedFrom(baseMaterial)) {
|
||||
derivedMaterial = this._derivedMaterial = this.createDerivedMaterial(baseMaterial)
|
||||
// dispose the derived material when its base material is disposed:
|
||||
baseMaterial.addEventListener('dispose', function onDispose() {
|
||||
baseMaterial.removeEventListener('dispose', onDispose)
|
||||
derivedMaterial.dispose()
|
||||
})
|
||||
}
|
||||
// If text outline is configured, render it as a preliminary draw using Three's multi-material
|
||||
// feature (see GlyphsGeometry which sets up `groups` for this purpose) Doing it with multi
|
||||
// materials ensures the layers are always rendered consecutively in a consistent order.
|
||||
// Each layer will trigger onBeforeRender with the appropriate material.
|
||||
if (this.hasOutline()) {
|
||||
let outlineMaterial = derivedMaterial._outlineMtl
|
||||
if (!outlineMaterial) {
|
||||
outlineMaterial = derivedMaterial._outlineMtl = Object.create(derivedMaterial, {
|
||||
id: {value: derivedMaterial.id + 0.1}
|
||||
})
|
||||
outlineMaterial.isTextOutlineMaterial = true
|
||||
outlineMaterial.depthWrite = false
|
||||
outlineMaterial.map = null //???
|
||||
derivedMaterial.addEventListener('dispose', function onDispose() {
|
||||
derivedMaterial.removeEventListener('dispose', onDispose)
|
||||
outlineMaterial.dispose()
|
||||
})
|
||||
}
|
||||
return [
|
||||
outlineMaterial,
|
||||
derivedMaterial
|
||||
]
|
||||
} else {
|
||||
return derivedMaterial
|
||||
}
|
||||
}
|
||||
set material(baseMaterial) {
|
||||
if (baseMaterial && baseMaterial.isTroikaTextMaterial) { //prevent double-derivation
|
||||
this._derivedMaterial = baseMaterial
|
||||
this._baseMaterial = baseMaterial.baseMaterial
|
||||
} else {
|
||||
this._baseMaterial = baseMaterial
|
||||
}
|
||||
}
|
||||
|
||||
hasOutline() {
|
||||
return !!(this.outlineWidth || this.outlineBlur || this.outlineOffsetX || this.outlineOffsetY)
|
||||
}
|
||||
|
||||
get glyphGeometryDetail() {
|
||||
return this.geometry.detail
|
||||
}
|
||||
set glyphGeometryDetail(detail) {
|
||||
this.geometry.detail = detail
|
||||
}
|
||||
|
||||
get curveRadius() {
|
||||
return this.geometry.curveRadius
|
||||
}
|
||||
set curveRadius(r) {
|
||||
this.geometry.curveRadius = r
|
||||
}
|
||||
|
||||
// Create and update material for shadows upon request:
|
||||
get customDepthMaterial() {
|
||||
return first(this.material).getDepthMaterial()
|
||||
}
|
||||
set customDepthMaterial(m) {
|
||||
// future: let the user override with their own?
|
||||
}
|
||||
get customDistanceMaterial() {
|
||||
return first(this.material).getDistanceMaterial()
|
||||
}
|
||||
set customDistanceMaterial(m) {
|
||||
// future: let the user override with their own?
|
||||
}
|
||||
|
||||
_prepareForRender(material) {
|
||||
const isOutline = material.isTextOutlineMaterial
|
||||
const uniforms = material.uniforms
|
||||
const textInfo = this.textRenderInfo
|
||||
if (textInfo) {
|
||||
const {sdfTexture, blockBounds} = textInfo
|
||||
uniforms.uTroikaSDFTexture.value = sdfTexture
|
||||
uniforms.uTroikaSDFTextureSize.value.set(sdfTexture.image.width, sdfTexture.image.height)
|
||||
uniforms.uTroikaSDFGlyphSize.value = textInfo.sdfGlyphSize
|
||||
uniforms.uTroikaSDFExponent.value = textInfo.sdfExponent
|
||||
uniforms.uTroikaTotalBounds.value.fromArray(blockBounds)
|
||||
uniforms.uTroikaUseGlyphColors.value = !isOutline && !!textInfo.glyphColors
|
||||
|
||||
let distanceOffset = 0
|
||||
let blurRadius = 0
|
||||
let strokeWidth = 0
|
||||
let fillOpacity
|
||||
let strokeOpacity
|
||||
let strokeColor
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
|
||||
if (isOutline) {
|
||||
let {outlineWidth, outlineOffsetX, outlineOffsetY, outlineBlur, outlineOpacity} = this
|
||||
distanceOffset = this._parsePercent(outlineWidth) || 0
|
||||
blurRadius = Math.max(0, this._parsePercent(outlineBlur) || 0)
|
||||
fillOpacity = outlineOpacity
|
||||
offsetX = this._parsePercent(outlineOffsetX) || 0
|
||||
offsetY = this._parsePercent(outlineOffsetY) || 0
|
||||
} else {
|
||||
strokeWidth = Math.max(0, this._parsePercent(this.strokeWidth) || 0)
|
||||
if (strokeWidth) {
|
||||
strokeColor = this.strokeColor
|
||||
uniforms.uTroikaStrokeColor.value.set(strokeColor == null ? defaultStrokeColor : strokeColor)
|
||||
strokeOpacity = this.strokeOpacity
|
||||
if (strokeOpacity == null) strokeOpacity = 1
|
||||
}
|
||||
fillOpacity = this.fillOpacity
|
||||
}
|
||||
|
||||
uniforms.uTroikaEdgeOffset.value = distanceOffset
|
||||
uniforms.uTroikaPositionOffset.value.set(offsetX, offsetY)
|
||||
uniforms.uTroikaBlurRadius.value = blurRadius
|
||||
uniforms.uTroikaStrokeWidth.value = strokeWidth
|
||||
uniforms.uTroikaStrokeOpacity.value = strokeOpacity
|
||||
uniforms.uTroikaFillOpacity.value = fillOpacity == null ? 1 : fillOpacity
|
||||
uniforms.uTroikaCurveRadius.value = this.curveRadius || 0
|
||||
|
||||
let clipRect = this.clipRect
|
||||
if (clipRect && Array.isArray(clipRect) && clipRect.length === 4) {
|
||||
uniforms.uTroikaClipRect.value.fromArray(clipRect)
|
||||
} else {
|
||||
// no clipping - choose a finite rect that shouldn't ever be reached by overflowing glyphs or outlines
|
||||
const pad = (this.fontSize || 0.1) * 100
|
||||
uniforms.uTroikaClipRect.value.set(
|
||||
blockBounds[0] - pad,
|
||||
blockBounds[1] - pad,
|
||||
blockBounds[2] + pad,
|
||||
blockBounds[3] + pad
|
||||
)
|
||||
}
|
||||
this.geometry.applyClipRect(uniforms.uTroikaClipRect.value)
|
||||
}
|
||||
uniforms.uTroikaSDFDebug.value = !!this.debugSDF
|
||||
material.polygonOffset = !!this.depthOffset
|
||||
material.polygonOffsetFactor = material.polygonOffsetUnits = this.depthOffset || 0
|
||||
|
||||
// Shortcut for setting material color via `color` prop on the mesh; this is
|
||||
// applied only to the derived material to avoid mutating a shared base material.
|
||||
const color = isOutline ? (this.outlineColor || 0) : this.color
|
||||
|
||||
if (color == null) {
|
||||
delete material.color //inherit from base
|
||||
} else {
|
||||
const colorObj = material.hasOwnProperty('color') ? material.color : (material.color = new Color())
|
||||
if (color !== colorObj._input || typeof color === 'object') {
|
||||
colorObj.set(colorObj._input = color)
|
||||
}
|
||||
}
|
||||
|
||||
// base orientation
|
||||
let orient = this.orientation || defaultOrient
|
||||
if (orient !== material._orientation) {
|
||||
let rotMat = uniforms.uTroikaOrient.value
|
||||
orient = orient.replace(/[^-+xyz]/g, '')
|
||||
let match = orient !== defaultOrient && orient.match(/^([-+])([xyz])([-+])([xyz])$/)
|
||||
if (match) {
|
||||
let [, hSign, hAxis, vSign, vAxis] = match
|
||||
tempVec3a.set(0, 0, 0)[hAxis] = hSign === '-' ? 1 : -1
|
||||
tempVec3b.set(0, 0, 0)[vAxis] = vSign === '-' ? -1 : 1
|
||||
tempMat4.lookAt(origin, tempVec3a.cross(tempVec3b), tempVec3b)
|
||||
rotMat.setFromMatrix4(tempMat4)
|
||||
} else {
|
||||
rotMat.identity()
|
||||
}
|
||||
material._orientation = orient
|
||||
}
|
||||
}
|
||||
|
||||
_parsePercent(value) {
|
||||
if (typeof value === 'string') {
|
||||
let match = value.match(/^(-?[\d.]+)%$/)
|
||||
let pct = match ? parseFloat(match[1]) : NaN
|
||||
value = (isNaN(pct) ? 0 : pct / 100) * this.fontSize
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a point in local space to an x/y in the text plane.
|
||||
*/
|
||||
localPositionToTextCoords(position, target = new Vector2()) {
|
||||
target.copy(position) //simple non-curved case is 1:1
|
||||
const r = this.curveRadius
|
||||
if (r) { //flatten the curve
|
||||
target.x = Math.atan2(position.x, Math.abs(r) - Math.abs(position.z)) * Math.abs(r)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a point in world space to an x/y in the text plane.
|
||||
*/
|
||||
worldPositionToTextCoords(position, target = new Vector2()) {
|
||||
tempVec3a.copy(position)
|
||||
return this.localPositionToTextCoords(this.worldToLocal(tempVec3a), target)
|
||||
}
|
||||
|
||||
/**
|
||||
* @override Custom raycasting to test against the whole text block's max rectangular bounds
|
||||
* TODO is there any reason to make this more granular, like within individual line or glyph rects?
|
||||
*/
|
||||
raycast(raycaster, intersects) {
|
||||
const {textRenderInfo, curveRadius} = this
|
||||
if (textRenderInfo) {
|
||||
const bounds = textRenderInfo.blockBounds
|
||||
const raycastMesh = curveRadius ? getCurvedRaycastMesh() : getFlatRaycastMesh()
|
||||
const geom = raycastMesh.geometry
|
||||
const {position, uv} = geom.attributes
|
||||
for (let i = 0; i < uv.count; i++) {
|
||||
let x = bounds[0] + (uv.getX(i) * (bounds[2] - bounds[0]))
|
||||
const y = bounds[1] + (uv.getY(i) * (bounds[3] - bounds[1]))
|
||||
let z = 0
|
||||
if (curveRadius) {
|
||||
z = curveRadius - Math.cos(x / curveRadius) * curveRadius
|
||||
x = Math.sin(x / curveRadius) * curveRadius
|
||||
}
|
||||
position.setXYZ(i, x, y, z)
|
||||
}
|
||||
geom.boundingSphere = this.geometry.boundingSphere
|
||||
geom.boundingBox = this.geometry.boundingBox
|
||||
raycastMesh.matrixWorld = this.matrixWorld
|
||||
raycastMesh.material.side = this.material.side
|
||||
tempArray.length = 0
|
||||
raycastMesh.raycast(raycaster, tempArray)
|
||||
for (let i = 0; i < tempArray.length; i++) {
|
||||
tempArray[i].object = this
|
||||
intersects.push(tempArray[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
copy(source) {
|
||||
// Prevent copying the geometry reference so we don't end up sharing attributes between instances
|
||||
const geom = this.geometry
|
||||
super.copy(source)
|
||||
this.geometry = geom
|
||||
|
||||
COPYABLE_PROPS.forEach(prop => {
|
||||
this[prop] = source[prop]
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new this.constructor().copy(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create setters for properties that affect text layout:
|
||||
SYNCABLE_PROPS.forEach(prop => {
|
||||
const privateKey = '_private_' + prop
|
||||
Object.defineProperty(Text.prototype, prop, {
|
||||
get() {
|
||||
return this[privateKey]
|
||||
},
|
||||
set(value) {
|
||||
if (value !== this[privateKey]) {
|
||||
this[privateKey] = value
|
||||
this._needsSync = true
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export {
|
||||
Text
|
||||
}
|
||||
505
node_modules/troika-three-text/src/TextBuilder.js
generated
vendored
Normal file
505
node_modules/troika-three-text/src/TextBuilder.js
generated
vendored
Normal file
@@ -0,0 +1,505 @@
|
||||
import { Color, Texture, LinearFilter } from 'three'
|
||||
import { defineWorkerModule } from 'troika-worker-utils'
|
||||
import { fontResolverWorkerModule } from "./FontResolver.js";
|
||||
import { createTypesetter } from './Typesetter.js'
|
||||
import { generateSDF, warmUpSDFCanvas, resizeWebGLCanvasWithoutClearing } from './SDFGenerator.js'
|
||||
import bidiFactory from 'bidi-js'
|
||||
|
||||
|
||||
const CONFIG = {
|
||||
defaultFontURL: null,
|
||||
unicodeFontsURL: null,
|
||||
sdfGlyphSize: 64,
|
||||
sdfMargin: 1 / 16,
|
||||
sdfExponent: 9,
|
||||
textureWidth: 2048,
|
||||
useWorker: true,
|
||||
}
|
||||
const tempColor = /*#__PURE__*/new Color()
|
||||
let hasRequested = false
|
||||
|
||||
function now() {
|
||||
return (self.performance || Date).now()
|
||||
}
|
||||
|
||||
/**
|
||||
* Customizes the text builder configuration. This must be called prior to the first font processing
|
||||
* request, and applies to all fonts.
|
||||
*
|
||||
* @param {String} config.defaultFontURL - The URL of the default font to use for text processing
|
||||
* requests, in case none is specified or the specifiede font fails to load or parse.
|
||||
* Defaults to "Roboto Regular" from Google Fonts.
|
||||
* @param {String} config.unicodeFontsURL - A custom location for the fallback unicode-font-resolver
|
||||
* data and font files, if you don't want to use the default CDN. See
|
||||
* https://github.com/lojjic/unicode-font-resolver for details. It can also be
|
||||
* configured per text instance, but this lets you do it once globally.
|
||||
* @param {Number} config.sdfGlyphSize - The default size of each glyph's SDF (signed distance field)
|
||||
* texture used for rendering. Must be a power-of-two number, and applies to all fonts,
|
||||
* but note that this can also be overridden per call to `getTextRenderInfo()`.
|
||||
* Larger sizes can improve the quality of glyph rendering by increasing the sharpness
|
||||
* of corners and preventing loss of very thin lines, at the expense of memory. Defaults
|
||||
* to 64 which is generally a good balance of size and quality.
|
||||
* @param {Number} config.sdfExponent - The exponent used when encoding the SDF values. A higher exponent
|
||||
* shifts the encoded 8-bit values to achieve higher precision/accuracy at texels nearer
|
||||
* the glyph's path, with lower precision further away. Defaults to 9.
|
||||
* @param {Number} config.sdfMargin - How much space to reserve in the SDF as margin outside the glyph's
|
||||
* path, as a percentage of the SDF width. A larger margin increases the quality of
|
||||
* extruded glyph outlines, but decreases the precision available for the glyph itself.
|
||||
* Defaults to 1/16th of the glyph size.
|
||||
* @param {Number} config.textureWidth - The width of the SDF texture; must be a power of 2. Defaults to
|
||||
* 2048 which is a safe maximum texture dimension according to the stats at
|
||||
* https://webglstats.com/webgl/parameter/MAX_TEXTURE_SIZE and should allow for a
|
||||
* reasonably large number of glyphs (default glyph size of 64^2 and safe texture size of
|
||||
* 2048^2, times 4 channels, allows for 4096 glyphs.) This can be increased if you need to
|
||||
* increase the glyph size and/or have an extraordinary number of glyphs.
|
||||
* @param {Boolean} config.useWorker - Whether to run typesetting in a web worker. Defaults to true.
|
||||
*/
|
||||
function configureTextBuilder(config) {
|
||||
if (hasRequested) {
|
||||
console.warn('configureTextBuilder called after first font request; will be ignored.')
|
||||
} else {
|
||||
assign(CONFIG, config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository for all font SDF atlas textures and their glyph mappings. There is a separate atlas for
|
||||
* each sdfGlyphSize. Each atlas has a single Texture that holds all glyphs for all fonts.
|
||||
*
|
||||
* {
|
||||
* [sdfGlyphSize]: {
|
||||
* glyphCount: number,
|
||||
* sdfGlyphSize: number,
|
||||
* sdfTexture: Texture,
|
||||
* sdfCanvas: HTMLCanvasElement,
|
||||
* contextLost: boolean,
|
||||
* glyphsByFont: Map<fontURL, Map<glyphID, {path, atlasIndex, sdfViewBox}>>
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
const atlases = Object.create(null)
|
||||
|
||||
/**
|
||||
* @typedef {object} TroikaTextRenderInfo - Format of the result from `getTextRenderInfo`.
|
||||
* @property {TypesetParams} parameters - The normalized input arguments to the render call.
|
||||
* @property {Texture} sdfTexture - The SDF atlas texture.
|
||||
* @property {number} sdfGlyphSize - The size of each glyph's SDF; see `configureTextBuilder`.
|
||||
* @property {number} sdfExponent - The exponent used in encoding the SDF's values; see `configureTextBuilder`.
|
||||
* @property {Float32Array} glyphBounds - List of [minX, minY, maxX, maxY] quad bounds for each glyph.
|
||||
* @property {Float32Array} glyphAtlasIndices - List holding each glyph's index in the SDF atlas.
|
||||
* @property {Uint8Array} [glyphColors] - List holding each glyph's [r, g, b] color, if `colorRanges` was supplied.
|
||||
* @property {Float32Array} [caretPositions] - A list of caret positions for all characters in the string; each is
|
||||
* four elements: the starting X, the ending X, the bottom Y, and the top Y for the caret.
|
||||
* @property {number} [caretHeight] - An appropriate height for all selection carets.
|
||||
* @property {number} ascender - The font's ascender metric.
|
||||
* @property {number} descender - The font's descender metric.
|
||||
* @property {number} capHeight - The font's cap height metric, based on the height of Latin capital letters.
|
||||
* @property {number} xHeight - The font's x height metric, based on the height of Latin lowercase letters.
|
||||
* @property {number} lineHeight - The final computed lineHeight measurement.
|
||||
* @property {number} topBaseline - The y position of the top line's baseline.
|
||||
* @property {Array<number>} blockBounds - The total [minX, minY, maxX, maxY] rect of the whole text block;
|
||||
* this can include extra vertical space beyond the visible glyphs due to lineHeight, and is
|
||||
* equivalent to the dimensions of a block-level text element in CSS.
|
||||
* @property {Array<number>} visibleBounds - The total [minX, minY, maxX, maxY] rect of the whole text block;
|
||||
* unlike `blockBounds` this is tightly wrapped to the visible glyph paths.
|
||||
* @property {Array<object>} chunkedBounds - List of bounding rects for each consecutive set of N glyphs,
|
||||
* in the format `{start:N, end:N, rect:[minX, minY, maxX, maxY]}`.
|
||||
* @property {object} timings - Timing info for various parts of the rendering logic including SDF
|
||||
* generation, typesetting, etc.
|
||||
* @frozen
|
||||
*/
|
||||
|
||||
/**
|
||||
* @callback getTextRenderInfo~callback
|
||||
* @param {TroikaTextRenderInfo} textRenderInfo
|
||||
*/
|
||||
|
||||
/**
|
||||
* Main entry point for requesting the data needed to render a text string with given font parameters.
|
||||
* This is an asynchronous call, performing most of the logic in a web worker thread.
|
||||
* @param {TypesetParams} args
|
||||
* @param {getTextRenderInfo~callback} callback
|
||||
*/
|
||||
function getTextRenderInfo(args, callback) {
|
||||
hasRequested = true
|
||||
args = assign({}, args)
|
||||
const totalStart = now()
|
||||
|
||||
// Convert relative URL to absolute so it can be resolved in the worker, and add fallbacks.
|
||||
// In the future we'll allow args.font to be a list with unicode ranges too.
|
||||
const { defaultFontURL } = CONFIG
|
||||
const fonts = [];
|
||||
if (defaultFontURL) {
|
||||
fonts.push({label: 'default', src: toAbsoluteURL(defaultFontURL)})
|
||||
}
|
||||
if (args.font) {
|
||||
fonts.push({label: 'user', src: toAbsoluteURL(args.font)})
|
||||
}
|
||||
args.font = fonts
|
||||
|
||||
// Normalize text to a string
|
||||
args.text = '' + args.text
|
||||
|
||||
args.sdfGlyphSize = args.sdfGlyphSize || CONFIG.sdfGlyphSize
|
||||
args.unicodeFontsURL = args.unicodeFontsURL || CONFIG.unicodeFontsURL
|
||||
|
||||
// Normalize colors
|
||||
if (args.colorRanges != null) {
|
||||
let colors = {}
|
||||
for (let key in args.colorRanges) {
|
||||
if (args.colorRanges.hasOwnProperty(key)) {
|
||||
let val = args.colorRanges[key]
|
||||
if (typeof val !== 'number') {
|
||||
val = tempColor.set(val).getHex()
|
||||
}
|
||||
colors[key] = val
|
||||
}
|
||||
}
|
||||
args.colorRanges = colors
|
||||
}
|
||||
|
||||
Object.freeze(args)
|
||||
|
||||
// Init the atlas if needed
|
||||
const {textureWidth, sdfExponent} = CONFIG
|
||||
const {sdfGlyphSize} = args
|
||||
const glyphsPerRow = (textureWidth / sdfGlyphSize * 4)
|
||||
let atlas = atlases[sdfGlyphSize]
|
||||
if (!atlas) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = textureWidth
|
||||
canvas.height = sdfGlyphSize * 256 / glyphsPerRow // start tall enough to fit 256 glyphs
|
||||
atlas = atlases[sdfGlyphSize] = {
|
||||
glyphCount: 0,
|
||||
sdfGlyphSize,
|
||||
sdfCanvas: canvas,
|
||||
sdfTexture: new Texture(
|
||||
canvas,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
LinearFilter,
|
||||
LinearFilter
|
||||
),
|
||||
contextLost: false,
|
||||
glyphsByFont: new Map()
|
||||
}
|
||||
atlas.sdfTexture.generateMipmaps = false
|
||||
initContextLossHandling(atlas)
|
||||
}
|
||||
|
||||
const {sdfTexture, sdfCanvas} = atlas
|
||||
|
||||
// Issue request to the typesetting engine in the worker
|
||||
const typeset = CONFIG.useWorker ? typesetInWorker : typesetOnMainThread
|
||||
typeset(args).then(result => {
|
||||
const {glyphIds, glyphFontIndices, fontData, glyphPositions, fontSize, timings} = result
|
||||
const neededSDFs = []
|
||||
const glyphBounds = new Float32Array(glyphIds.length * 4)
|
||||
let boundsIdx = 0
|
||||
let positionsIdx = 0
|
||||
const quadsStart = now()
|
||||
|
||||
const fontGlyphMaps = fontData.map(font => {
|
||||
let map = atlas.glyphsByFont.get(font.src)
|
||||
if (!map) {
|
||||
atlas.glyphsByFont.set(font.src, map = new Map())
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
glyphIds.forEach((glyphId, i) => {
|
||||
const fontIndex = glyphFontIndices[i]
|
||||
const {src: fontSrc, unitsPerEm} = fontData[fontIndex]
|
||||
let glyphInfo = fontGlyphMaps[fontIndex].get(glyphId)
|
||||
|
||||
// If this is a glyphId not seen before, add it to the atlas
|
||||
if (!glyphInfo) {
|
||||
const {path, pathBounds} = result.glyphData[fontSrc][glyphId]
|
||||
|
||||
// Margin around path edges in SDF, based on a percentage of the glyph's max dimension.
|
||||
// Note we add an extra 0.5 px over the configured value because the outer 0.5 doesn't contain
|
||||
// useful interpolated values and will be ignored anyway.
|
||||
const fontUnitsMargin = Math.max(pathBounds[2] - pathBounds[0], pathBounds[3] - pathBounds[1])
|
||||
/ sdfGlyphSize * (CONFIG.sdfMargin * sdfGlyphSize + 0.5)
|
||||
|
||||
const atlasIndex = atlas.glyphCount++
|
||||
const sdfViewBox = [
|
||||
pathBounds[0] - fontUnitsMargin,
|
||||
pathBounds[1] - fontUnitsMargin,
|
||||
pathBounds[2] + fontUnitsMargin,
|
||||
pathBounds[3] + fontUnitsMargin,
|
||||
]
|
||||
fontGlyphMaps[fontIndex].set(glyphId, (glyphInfo = { path, atlasIndex, sdfViewBox }))
|
||||
|
||||
// Collect those that need SDF generation
|
||||
neededSDFs.push(glyphInfo)
|
||||
}
|
||||
|
||||
// Calculate bounds for renderable quads
|
||||
// TODO can we get this back off the main thread?
|
||||
const {sdfViewBox} = glyphInfo
|
||||
const posX = glyphPositions[positionsIdx++]
|
||||
const posY = glyphPositions[positionsIdx++]
|
||||
const fontSizeMult = fontSize / unitsPerEm
|
||||
glyphBounds[boundsIdx++] = posX + sdfViewBox[0] * fontSizeMult
|
||||
glyphBounds[boundsIdx++] = posY + sdfViewBox[1] * fontSizeMult
|
||||
glyphBounds[boundsIdx++] = posX + sdfViewBox[2] * fontSizeMult
|
||||
glyphBounds[boundsIdx++] = posY + sdfViewBox[3] * fontSizeMult
|
||||
|
||||
// Convert glyphId to SDF index for the shader
|
||||
glyphIds[i] = glyphInfo.atlasIndex
|
||||
})
|
||||
timings.quads = (timings.quads || 0) + (now() - quadsStart)
|
||||
|
||||
const sdfStart = now()
|
||||
timings.sdf = {}
|
||||
|
||||
// Grow the texture height by power of 2 if needed
|
||||
const currentHeight = sdfCanvas.height
|
||||
const neededRows = Math.ceil(atlas.glyphCount / glyphsPerRow)
|
||||
const neededHeight = Math.pow(2, Math.ceil(Math.log2(neededRows * sdfGlyphSize)))
|
||||
if (neededHeight > currentHeight) {
|
||||
// Since resizing the canvas clears its render buffer, it needs special handling to copy the old contents over
|
||||
console.info(`Increasing SDF texture size ${currentHeight}->${neededHeight}`)
|
||||
resizeWebGLCanvasWithoutClearing(sdfCanvas, textureWidth, neededHeight)
|
||||
// As of Three r136 textures cannot be resized once they're allocated on the GPU, we must dispose to reallocate it
|
||||
sdfTexture.dispose()
|
||||
}
|
||||
|
||||
Promise.all(neededSDFs.map(glyphInfo =>
|
||||
generateGlyphSDF(glyphInfo, atlas, args.gpuAccelerateSDF).then(({timing}) => {
|
||||
timings.sdf[glyphInfo.atlasIndex] = timing
|
||||
})
|
||||
)).then(() => {
|
||||
if (neededSDFs.length && !atlas.contextLost) {
|
||||
safariPre15Workaround(atlas)
|
||||
sdfTexture.needsUpdate = true
|
||||
}
|
||||
timings.sdfTotal = now() - sdfStart
|
||||
timings.total = now() - totalStart
|
||||
// console.log(`SDF - ${timings.sdfTotal}, Total - ${timings.total - timings.fontLoad}`)
|
||||
|
||||
// Invoke callback with the text layout arrays and updated texture
|
||||
callback(Object.freeze({
|
||||
parameters: args,
|
||||
sdfTexture,
|
||||
sdfGlyphSize,
|
||||
sdfExponent,
|
||||
glyphBounds,
|
||||
glyphAtlasIndices: glyphIds,
|
||||
glyphColors: result.glyphColors,
|
||||
caretPositions: result.caretPositions,
|
||||
chunkedBounds: result.chunkedBounds,
|
||||
ascender: result.ascender,
|
||||
descender: result.descender,
|
||||
lineHeight: result.lineHeight,
|
||||
capHeight: result.capHeight,
|
||||
xHeight: result.xHeight,
|
||||
topBaseline: result.topBaseline,
|
||||
blockBounds: result.blockBounds,
|
||||
visibleBounds: result.visibleBounds,
|
||||
timings: result.timings,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
// While the typesetting request is being handled, go ahead and make sure the atlas canvas context is
|
||||
// "warmed up"; the first request will be the longest due to shader program compilation so this gets
|
||||
// a head start on that process before SDFs actually start getting processed.
|
||||
Promise.resolve().then(() => {
|
||||
if (!atlas.contextLost) {
|
||||
warmUpSDFCanvas(sdfCanvas)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function generateGlyphSDF({path, atlasIndex, sdfViewBox}, {sdfGlyphSize, sdfCanvas, contextLost}, useGPU) {
|
||||
if (contextLost) {
|
||||
// If the context is lost there's nothing we can do, just quit silently and let it
|
||||
// get regenerated when the context is restored
|
||||
return Promise.resolve({timing: -1})
|
||||
}
|
||||
const {textureWidth, sdfExponent} = CONFIG
|
||||
const maxDist = Math.max(sdfViewBox[2] - sdfViewBox[0], sdfViewBox[3] - sdfViewBox[1])
|
||||
const squareIndex = Math.floor(atlasIndex / 4)
|
||||
const x = squareIndex % (textureWidth / sdfGlyphSize) * sdfGlyphSize
|
||||
const y = Math.floor(squareIndex / (textureWidth / sdfGlyphSize)) * sdfGlyphSize
|
||||
const channel = atlasIndex % 4
|
||||
return generateSDF(sdfGlyphSize, sdfGlyphSize, path, sdfViewBox, maxDist, sdfExponent, sdfCanvas, x, y, channel, useGPU)
|
||||
}
|
||||
|
||||
function initContextLossHandling(atlas) {
|
||||
const canvas = atlas.sdfCanvas
|
||||
|
||||
/*
|
||||
// Begin context loss simulation
|
||||
if (!window.WebGLDebugUtils) {
|
||||
let script = document.getElementById('WebGLDebugUtilsScript')
|
||||
if (!script) {
|
||||
script = document.createElement('script')
|
||||
script.id = 'WebGLDebugUtils'
|
||||
document.head.appendChild(script)
|
||||
script.src = 'https://cdn.jsdelivr.net/gh/KhronosGroup/WebGLDeveloperTools@b42e702/src/debug/webgl-debug.js'
|
||||
}
|
||||
script.addEventListener('load', () => {
|
||||
initContextLossHandling(atlas)
|
||||
})
|
||||
return
|
||||
}
|
||||
window.WebGLDebugUtils.makeLostContextSimulatingCanvas(canvas)
|
||||
canvas.loseContextInNCalls(500)
|
||||
canvas.addEventListener('webglcontextrestored', (event) => {
|
||||
canvas.loseContextInNCalls(5000)
|
||||
})
|
||||
// End context loss simulation
|
||||
*/
|
||||
|
||||
canvas.addEventListener('webglcontextlost', (event) => {
|
||||
console.log('Context Lost', event)
|
||||
event.preventDefault()
|
||||
atlas.contextLost = true
|
||||
})
|
||||
canvas.addEventListener('webglcontextrestored', (event) => {
|
||||
console.log('Context Restored', event)
|
||||
atlas.contextLost = false
|
||||
// Regenerate all glyphs into the restored canvas:
|
||||
const promises = []
|
||||
atlas.glyphsByFont.forEach(glyphMap => {
|
||||
glyphMap.forEach(glyph => {
|
||||
promises.push(generateGlyphSDF(glyph, atlas, true))
|
||||
})
|
||||
})
|
||||
Promise.all(promises).then(() => {
|
||||
safariPre15Workaround(atlas)
|
||||
atlas.sdfTexture.needsUpdate = true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload a given font and optionally pre-generate glyph SDFs for one or more character sequences.
|
||||
* This can be useful to avoid long pauses when first showing text in a scene, by preloading the
|
||||
* needed fonts and glyphs up front along with other assets.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.font - URL of the font file to preload. If not given, the default font will
|
||||
* be loaded.
|
||||
* @param {string|string[]} options.characters - One or more character sequences for which to pre-
|
||||
* generate glyph SDFs. Note that this will honor ligature substitution, so you may need
|
||||
* to specify ligature sequences in addition to their individual characters to get all
|
||||
* possible glyphs, e.g. `["t", "h", "th"]` to get the "t" and "h" glyphs plus the "th" ligature.
|
||||
* @param {number} options.sdfGlyphSize - The size at which to prerender the SDF textures for the
|
||||
* specified `characters`.
|
||||
* @param {function} callback - A function that will be called when the preloading is complete.
|
||||
*/
|
||||
function preloadFont({font, characters, sdfGlyphSize}, callback) {
|
||||
let text = Array.isArray(characters) ? characters.join('\n') : '' + characters
|
||||
getTextRenderInfo({ font, sdfGlyphSize, text }, callback)
|
||||
}
|
||||
|
||||
|
||||
// Local assign impl so we don't have to import troika-core
|
||||
function assign(toObj, fromObj) {
|
||||
for (let key in fromObj) {
|
||||
if (fromObj.hasOwnProperty(key)) {
|
||||
toObj[key] = fromObj[key]
|
||||
}
|
||||
}
|
||||
return toObj
|
||||
}
|
||||
|
||||
// Utility for making URLs absolute
|
||||
let linkEl
|
||||
function toAbsoluteURL(path) {
|
||||
if (!linkEl) {
|
||||
linkEl = typeof document === 'undefined' ? {} : document.createElement('a')
|
||||
}
|
||||
linkEl.href = path
|
||||
return linkEl.href
|
||||
}
|
||||
|
||||
/**
|
||||
* Safari < v15 seems unable to use the SDF webgl canvas as a texture. This applies a workaround
|
||||
* where it reads the pixels out of that canvas and uploads them as a data texture instead, at
|
||||
* a slight performance cost.
|
||||
*/
|
||||
function safariPre15Workaround(atlas) {
|
||||
// Use createImageBitmap support as a proxy for Safari<15, all other mainstream browsers
|
||||
// have supported it for a long while so any false positives should be minimal.
|
||||
if (typeof createImageBitmap !== 'function') {
|
||||
console.info('Safari<15: applying SDF canvas workaround')
|
||||
const {sdfCanvas, sdfTexture} = atlas
|
||||
const {width, height} = sdfCanvas
|
||||
const gl = atlas.sdfCanvas.getContext('webgl')
|
||||
let pixels = sdfTexture.image.data
|
||||
if (!pixels || pixels.length !== width * height * 4) {
|
||||
pixels = new Uint8Array(width * height * 4)
|
||||
sdfTexture.image = {width, height, data: pixels}
|
||||
sdfTexture.flipY = false
|
||||
sdfTexture.isDataTexture = true
|
||||
}
|
||||
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels)
|
||||
}
|
||||
}
|
||||
|
||||
const typesetterWorkerModule = /*#__PURE__*/defineWorkerModule({
|
||||
name: 'Typesetter',
|
||||
dependencies: [
|
||||
createTypesetter,
|
||||
fontResolverWorkerModule,
|
||||
bidiFactory,
|
||||
],
|
||||
init(createTypesetter, fontResolver, bidiFactory) {
|
||||
return createTypesetter(fontResolver, bidiFactory())
|
||||
}
|
||||
})
|
||||
|
||||
const typesetInWorker = /*#__PURE__*/defineWorkerModule({
|
||||
name: 'Typesetter',
|
||||
dependencies: [
|
||||
typesetterWorkerModule,
|
||||
],
|
||||
init(typesetter) {
|
||||
return function(args) {
|
||||
return new Promise(resolve => {
|
||||
typesetter.typeset(args, resolve)
|
||||
})
|
||||
}
|
||||
},
|
||||
getTransferables(result) {
|
||||
// Mark array buffers as transferable to avoid cloning during postMessage
|
||||
const transferables = []
|
||||
for (let p in result) {
|
||||
if (result[p] && result[p].buffer) {
|
||||
transferables.push(result[p].buffer)
|
||||
}
|
||||
}
|
||||
return transferables
|
||||
}
|
||||
})
|
||||
|
||||
const typesetOnMainThread = typesetInWorker.onMainThread
|
||||
|
||||
function dumpSDFTextures() {
|
||||
Object.keys(atlases).forEach(size => {
|
||||
const canvas = atlases[size].sdfCanvas
|
||||
const {width, height} = canvas
|
||||
console.log("%c.", `
|
||||
background: url(${canvas.toDataURL()});
|
||||
background-size: ${width}px ${height}px;
|
||||
color: transparent;
|
||||
font-size: 0;
|
||||
line-height: ${height}px;
|
||||
padding-left: ${width}px;
|
||||
`)
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
configureTextBuilder,
|
||||
getTextRenderInfo,
|
||||
preloadFont,
|
||||
typesetterWorkerModule,
|
||||
dumpSDFTextures
|
||||
}
|
||||
281
node_modules/troika-three-text/src/TextDerivedMaterial.js
generated
vendored
Normal file
281
node_modules/troika-three-text/src/TextDerivedMaterial.js
generated
vendored
Normal file
@@ -0,0 +1,281 @@
|
||||
import { createDerivedMaterial, voidMainRegExp } from 'troika-three-utils'
|
||||
import { Color, Vector2, Vector4, Matrix3 } from 'three'
|
||||
|
||||
// language=GLSL
|
||||
const VERTEX_DEFS = `
|
||||
uniform vec2 uTroikaSDFTextureSize;
|
||||
uniform float uTroikaSDFGlyphSize;
|
||||
uniform vec4 uTroikaTotalBounds;
|
||||
uniform vec4 uTroikaClipRect;
|
||||
uniform mat3 uTroikaOrient;
|
||||
uniform bool uTroikaUseGlyphColors;
|
||||
uniform float uTroikaEdgeOffset;
|
||||
uniform float uTroikaBlurRadius;
|
||||
uniform vec2 uTroikaPositionOffset;
|
||||
uniform float uTroikaCurveRadius;
|
||||
attribute vec4 aTroikaGlyphBounds;
|
||||
attribute float aTroikaGlyphIndex;
|
||||
attribute vec3 aTroikaGlyphColor;
|
||||
varying vec2 vTroikaGlyphUV;
|
||||
varying vec4 vTroikaTextureUVBounds;
|
||||
varying float vTroikaTextureChannel;
|
||||
varying vec3 vTroikaGlyphColor;
|
||||
varying vec2 vTroikaGlyphDimensions;
|
||||
`
|
||||
|
||||
// language=GLSL prefix="void main() {" suffix="}"
|
||||
const VERTEX_TRANSFORM = `
|
||||
vec4 bounds = aTroikaGlyphBounds;
|
||||
bounds.xz += uTroikaPositionOffset.x;
|
||||
bounds.yw -= uTroikaPositionOffset.y;
|
||||
|
||||
vec4 outlineBounds = vec4(
|
||||
bounds.xy - uTroikaEdgeOffset - uTroikaBlurRadius,
|
||||
bounds.zw + uTroikaEdgeOffset + uTroikaBlurRadius
|
||||
);
|
||||
vec4 clippedBounds = vec4(
|
||||
clamp(outlineBounds.xy, uTroikaClipRect.xy, uTroikaClipRect.zw),
|
||||
clamp(outlineBounds.zw, uTroikaClipRect.xy, uTroikaClipRect.zw)
|
||||
);
|
||||
|
||||
vec2 clippedXY = (mix(clippedBounds.xy, clippedBounds.zw, position.xy) - bounds.xy) / (bounds.zw - bounds.xy);
|
||||
|
||||
position.xy = mix(bounds.xy, bounds.zw, clippedXY);
|
||||
|
||||
uv = (position.xy - uTroikaTotalBounds.xy) / (uTroikaTotalBounds.zw - uTroikaTotalBounds.xy);
|
||||
|
||||
float rad = uTroikaCurveRadius;
|
||||
if (rad != 0.0) {
|
||||
float angle = position.x / rad;
|
||||
position.xz = vec2(sin(angle) * rad, rad - cos(angle) * rad);
|
||||
normal.xz = vec2(sin(angle), cos(angle));
|
||||
}
|
||||
|
||||
position = uTroikaOrient * position;
|
||||
normal = uTroikaOrient * normal;
|
||||
|
||||
vTroikaGlyphUV = clippedXY.xy;
|
||||
vTroikaGlyphDimensions = vec2(bounds[2] - bounds[0], bounds[3] - bounds[1]);
|
||||
|
||||
${''/* NOTE: it seems important to calculate the glyph's bounding texture UVs here in the
|
||||
vertex shader, rather than in the fragment shader, as the latter gives strange artifacts
|
||||
on some glyphs (those in the leftmost texture column) on some systems. The exact reason
|
||||
isn't understood but doing this here, then mix()-ing in the fragment shader, seems to work. */}
|
||||
float txCols = uTroikaSDFTextureSize.x / uTroikaSDFGlyphSize;
|
||||
vec2 txUvPerSquare = uTroikaSDFGlyphSize / uTroikaSDFTextureSize;
|
||||
vec2 txStartUV = txUvPerSquare * vec2(
|
||||
mod(floor(aTroikaGlyphIndex / 4.0), txCols),
|
||||
floor(floor(aTroikaGlyphIndex / 4.0) / txCols)
|
||||
);
|
||||
vTroikaTextureUVBounds = vec4(txStartUV, vec2(txStartUV) + txUvPerSquare);
|
||||
vTroikaTextureChannel = mod(aTroikaGlyphIndex, 4.0);
|
||||
`
|
||||
|
||||
// language=GLSL
|
||||
const FRAGMENT_DEFS = `
|
||||
uniform sampler2D uTroikaSDFTexture;
|
||||
uniform vec2 uTroikaSDFTextureSize;
|
||||
uniform float uTroikaSDFGlyphSize;
|
||||
uniform float uTroikaSDFExponent;
|
||||
uniform float uTroikaEdgeOffset;
|
||||
uniform float uTroikaFillOpacity;
|
||||
uniform float uTroikaBlurRadius;
|
||||
uniform vec3 uTroikaStrokeColor;
|
||||
uniform float uTroikaStrokeWidth;
|
||||
uniform float uTroikaStrokeOpacity;
|
||||
uniform bool uTroikaSDFDebug;
|
||||
varying vec2 vTroikaGlyphUV;
|
||||
varying vec4 vTroikaTextureUVBounds;
|
||||
varying float vTroikaTextureChannel;
|
||||
varying vec2 vTroikaGlyphDimensions;
|
||||
|
||||
float troikaSdfValueToSignedDistance(float alpha) {
|
||||
// Inverse of exponential encoding in webgl-sdf-generator
|
||||
${''/* TODO - there's some slight inaccuracy here when dealing with interpolated alpha values; those
|
||||
are linearly interpolated where the encoding is exponential. Look into improving this by rounding
|
||||
to nearest 2 whole texels, decoding those exponential values, and linearly interpolating the result.
|
||||
*/}
|
||||
float maxDimension = max(vTroikaGlyphDimensions.x, vTroikaGlyphDimensions.y);
|
||||
float absDist = (1.0 - pow(2.0 * (alpha > 0.5 ? 1.0 - alpha : alpha), 1.0 / uTroikaSDFExponent)) * maxDimension;
|
||||
float signedDist = absDist * (alpha > 0.5 ? -1.0 : 1.0);
|
||||
return signedDist;
|
||||
}
|
||||
|
||||
float troikaGlyphUvToSdfValue(vec2 glyphUV) {
|
||||
vec2 textureUV = mix(vTroikaTextureUVBounds.xy, vTroikaTextureUVBounds.zw, glyphUV);
|
||||
vec4 rgba = texture2D(uTroikaSDFTexture, textureUV);
|
||||
float ch = floor(vTroikaTextureChannel + 0.5); //NOTE: can't use round() in WebGL1
|
||||
return ch == 0.0 ? rgba.r : ch == 1.0 ? rgba.g : ch == 2.0 ? rgba.b : rgba.a;
|
||||
}
|
||||
|
||||
float troikaGlyphUvToDistance(vec2 uv) {
|
||||
return troikaSdfValueToSignedDistance(troikaGlyphUvToSdfValue(uv));
|
||||
}
|
||||
|
||||
float troikaGetAADist() {
|
||||
${''/*
|
||||
When the standard derivatives extension is available, we choose an antialiasing alpha threshold based
|
||||
on the potential change in the SDF's alpha from this fragment to its neighbor. This strategy maximizes
|
||||
readability and edge crispness at all sizes and screen resolutions.
|
||||
*/}
|
||||
#if defined(GL_OES_standard_derivatives) || __VERSION__ >= 300
|
||||
return length(fwidth(vTroikaGlyphUV * vTroikaGlyphDimensions)) * 0.5;
|
||||
#else
|
||||
return vTroikaGlyphDimensions.x / 64.0;
|
||||
#endif
|
||||
}
|
||||
|
||||
float troikaGetFragDistValue() {
|
||||
vec2 clampedGlyphUV = clamp(vTroikaGlyphUV, 0.5 / uTroikaSDFGlyphSize, 1.0 - 0.5 / uTroikaSDFGlyphSize);
|
||||
float distance = troikaGlyphUvToDistance(clampedGlyphUV);
|
||||
|
||||
// Extrapolate distance when outside bounds:
|
||||
distance += clampedGlyphUV == vTroikaGlyphUV ? 0.0 :
|
||||
length((vTroikaGlyphUV - clampedGlyphUV) * vTroikaGlyphDimensions);
|
||||
|
||||
${''/*
|
||||
// TODO more refined extrapolated distance by adjusting for angle of gradient at edge...
|
||||
// This has potential but currently gives very jagged extensions, maybe due to precision issues?
|
||||
float uvStep = 1.0 / uTroikaSDFGlyphSize;
|
||||
vec2 neighbor1UV = clampedGlyphUV + (
|
||||
vTroikaGlyphUV.x != clampedGlyphUV.x ? vec2(0.0, uvStep * sign(0.5 - vTroikaGlyphUV.y)) :
|
||||
vTroikaGlyphUV.y != clampedGlyphUV.y ? vec2(uvStep * sign(0.5 - vTroikaGlyphUV.x), 0.0) :
|
||||
vec2(0.0)
|
||||
);
|
||||
vec2 neighbor2UV = clampedGlyphUV + (
|
||||
vTroikaGlyphUV.x != clampedGlyphUV.x ? vec2(0.0, uvStep * -sign(0.5 - vTroikaGlyphUV.y)) :
|
||||
vTroikaGlyphUV.y != clampedGlyphUV.y ? vec2(uvStep * -sign(0.5 - vTroikaGlyphUV.x), 0.0) :
|
||||
vec2(0.0)
|
||||
);
|
||||
float neighbor1Distance = troikaGlyphUvToDistance(neighbor1UV);
|
||||
float neighbor2Distance = troikaGlyphUvToDistance(neighbor2UV);
|
||||
float distToUnclamped = length((vTroikaGlyphUV - clampedGlyphUV) * vTroikaGlyphDimensions);
|
||||
float distToNeighbor = length((clampedGlyphUV - neighbor1UV) * vTroikaGlyphDimensions);
|
||||
float gradientAngle1 = min(asin(abs(neighbor1Distance - distance) / distToNeighbor), PI / 2.0);
|
||||
float gradientAngle2 = min(asin(abs(neighbor2Distance - distance) / distToNeighbor), PI / 2.0);
|
||||
distance += (cos(gradientAngle1) + cos(gradientAngle2)) / 2.0 * distToUnclamped;
|
||||
*/}
|
||||
|
||||
return distance;
|
||||
}
|
||||
|
||||
float troikaGetEdgeAlpha(float distance, float distanceOffset, float aaDist) {
|
||||
#if defined(IS_DEPTH_MATERIAL) || defined(IS_DISTANCE_MATERIAL)
|
||||
float alpha = step(-distanceOffset, -distance);
|
||||
#else
|
||||
|
||||
float alpha = smoothstep(
|
||||
distanceOffset + aaDist,
|
||||
distanceOffset - aaDist,
|
||||
distance
|
||||
);
|
||||
#endif
|
||||
|
||||
return alpha;
|
||||
}
|
||||
`
|
||||
|
||||
// language=GLSL prefix="void main() {" suffix="}"
|
||||
const FRAGMENT_TRANSFORM = `
|
||||
float aaDist = troikaGetAADist();
|
||||
float fragDistance = troikaGetFragDistValue();
|
||||
float edgeAlpha = uTroikaSDFDebug ?
|
||||
troikaGlyphUvToSdfValue(vTroikaGlyphUV) :
|
||||
troikaGetEdgeAlpha(fragDistance, uTroikaEdgeOffset, max(aaDist, uTroikaBlurRadius));
|
||||
|
||||
#if !defined(IS_DEPTH_MATERIAL) && !defined(IS_DISTANCE_MATERIAL)
|
||||
vec4 fillRGBA = gl_FragColor;
|
||||
fillRGBA.a *= uTroikaFillOpacity;
|
||||
vec4 strokeRGBA = uTroikaStrokeWidth == 0.0 ? fillRGBA : vec4(uTroikaStrokeColor, uTroikaStrokeOpacity);
|
||||
if (fillRGBA.a == 0.0) fillRGBA.rgb = strokeRGBA.rgb;
|
||||
gl_FragColor = mix(fillRGBA, strokeRGBA, smoothstep(
|
||||
-uTroikaStrokeWidth - aaDist,
|
||||
-uTroikaStrokeWidth + aaDist,
|
||||
fragDistance
|
||||
));
|
||||
gl_FragColor.a *= edgeAlpha;
|
||||
#endif
|
||||
|
||||
if (edgeAlpha == 0.0) {
|
||||
discard;
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
/**
|
||||
* Create a material for rendering text, derived from a baseMaterial
|
||||
*/
|
||||
export function createTextDerivedMaterial(baseMaterial) {
|
||||
const textMaterial = createDerivedMaterial(baseMaterial, {
|
||||
chained: true,
|
||||
extensions: {
|
||||
derivatives: true
|
||||
},
|
||||
uniforms: {
|
||||
uTroikaSDFTexture: {value: null},
|
||||
uTroikaSDFTextureSize: {value: new Vector2()},
|
||||
uTroikaSDFGlyphSize: {value: 0},
|
||||
uTroikaSDFExponent: {value: 0},
|
||||
uTroikaTotalBounds: {value: new Vector4(0,0,0,0)},
|
||||
uTroikaClipRect: {value: new Vector4(0,0,0,0)},
|
||||
uTroikaEdgeOffset: {value: 0},
|
||||
uTroikaFillOpacity: {value: 1},
|
||||
uTroikaPositionOffset: {value: new Vector2()},
|
||||
uTroikaCurveRadius: {value: 0},
|
||||
uTroikaBlurRadius: {value: 0},
|
||||
uTroikaStrokeWidth: {value: 0},
|
||||
uTroikaStrokeColor: {value: new Color()},
|
||||
uTroikaStrokeOpacity: {value: 1},
|
||||
uTroikaOrient: {value: new Matrix3()},
|
||||
uTroikaUseGlyphColors: {value: true},
|
||||
uTroikaSDFDebug: {value: false}
|
||||
},
|
||||
vertexDefs: VERTEX_DEFS,
|
||||
vertexTransform: VERTEX_TRANSFORM,
|
||||
fragmentDefs: FRAGMENT_DEFS,
|
||||
fragmentColorTransform: FRAGMENT_TRANSFORM,
|
||||
customRewriter({vertexShader, fragmentShader}) {
|
||||
let uDiffuseRE = /\buniform\s+vec3\s+diffuse\b/
|
||||
if (uDiffuseRE.test(fragmentShader)) {
|
||||
// Replace all instances of `diffuse` with our varying
|
||||
fragmentShader = fragmentShader
|
||||
.replace(uDiffuseRE, 'varying vec3 vTroikaGlyphColor')
|
||||
.replace(/\bdiffuse\b/g, 'vTroikaGlyphColor')
|
||||
// Make sure the vertex shader declares the uniform so we can grab it as a fallback
|
||||
if (!uDiffuseRE.test(vertexShader)) {
|
||||
vertexShader = vertexShader.replace(
|
||||
voidMainRegExp,
|
||||
'uniform vec3 diffuse;\n$&\nvTroikaGlyphColor = uTroikaUseGlyphColors ? aTroikaGlyphColor / 255.0 : diffuse;\n'
|
||||
)
|
||||
}
|
||||
}
|
||||
return { vertexShader, fragmentShader }
|
||||
}
|
||||
})
|
||||
|
||||
// Force transparency - TODO is this reasonable?
|
||||
textMaterial.transparent = true
|
||||
|
||||
// Force single draw call when double-sided
|
||||
textMaterial.forceSinglePass = true
|
||||
|
||||
Object.defineProperties(textMaterial, {
|
||||
isTroikaTextMaterial: {value: true},
|
||||
|
||||
// WebGLShadowMap reverses the side of the shadow material by default, which fails
|
||||
// for planes, so here we force the `shadowSide` to always match the main side.
|
||||
shadowSide: {
|
||||
get() {
|
||||
return this.side
|
||||
},
|
||||
set() {
|
||||
//no-op
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return textMaterial
|
||||
}
|
||||
|
||||
|
||||
|
||||
729
node_modules/troika-three-text/src/Typesetter.js
generated
vendored
Normal file
729
node_modules/troika-three-text/src/Typesetter.js
generated
vendored
Normal file
@@ -0,0 +1,729 @@
|
||||
/**
|
||||
* @typedef {number|'left'|'center'|'right'} AnchorXValue
|
||||
*/
|
||||
/**
|
||||
* @typedef {number|'top'|'top-baseline'|'top-cap'|'top-ex'|'middle'|'bottom-baseline'|'bottom'} AnchorYValue
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} TypesetParams
|
||||
* @property {string} text
|
||||
* @property {UserFont|UserFont[]} [font]
|
||||
* @property {string} [lang]
|
||||
* @property {number} [sdfGlyphSize=64]
|
||||
* @property {number} [fontSize=1]
|
||||
* @property {number|'normal'|'bold'} [fontWeight='normal']
|
||||
* @property {'normal'|'italic'} [fontStyle='normal']
|
||||
* @property {number} [letterSpacing=0]
|
||||
* @property {'normal'|number} [lineHeight='normal']
|
||||
* @property {number} [maxWidth]
|
||||
* @property {'ltr'|'rtl'} [direction='ltr']
|
||||
* @property {string} [textAlign='left']
|
||||
* @property {number} [textIndent=0]
|
||||
* @property {'normal'|'nowrap'} [whiteSpace='normal']
|
||||
* @property {'normal'|'break-word'} [overflowWrap='normal']
|
||||
* @property {AnchorXValue} [anchorX=0]
|
||||
* @property {AnchorYValue} [anchorY=0]
|
||||
* @property {boolean} [metricsOnly=false]
|
||||
* @property {string} [unicodeFontsURL]
|
||||
* @property {FontResolverResult} [preResolvedFonts]
|
||||
* @property {boolean} [includeCaretPositions=false]
|
||||
* @property {number} [chunkedBoundsSize=8192]
|
||||
* @property {{[rangeStartIndex]: number}} [colorRanges]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} TypesetResult
|
||||
* @property {Uint16Array} glyphIds id for each glyph, specific to that glyph's font
|
||||
* @property {Uint8Array} glyphFontIndices index into fontData for each glyph
|
||||
* @property {Float32Array} glyphPositions x,y of each glyph's origin in layout
|
||||
* @property {{[font]: {[glyphId]: {path: string, pathBounds: number[]}}}} glyphData data about each glyph appearing in the text
|
||||
* @property {TypesetFontData[]} fontData data about each font used in the text
|
||||
* @property {Float32Array} [caretPositions] startX,endX,bottomY caret positions for each char
|
||||
* @property {Uint8Array} [glyphColors] color for each glyph, if color ranges supplied
|
||||
* chunkedBounds, //total rects per (n=chunkedBoundsSize) consecutive glyphs
|
||||
* fontSize, //calculated em height
|
||||
* topBaseline: anchorYOffset + lines[0].baseline, //y coordinate of the top line's baseline
|
||||
* blockBounds: [ //bounds for the whole block of text, including vertical padding for lineHeight
|
||||
* anchorXOffset,
|
||||
* anchorYOffset - totalHeight,
|
||||
* anchorXOffset + maxLineWidth,
|
||||
* anchorYOffset
|
||||
* ],
|
||||
* visibleBounds, //total bounds of visible text paths, may be larger or smaller than blockBounds
|
||||
* timings
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} TypesetFontData
|
||||
* @property src
|
||||
* @property unitsPerEm
|
||||
* @property ascender
|
||||
* @property descender
|
||||
* @property lineHeight
|
||||
* @property capHeight
|
||||
* @property xHeight
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {function} TypesetterTypesetFunction - compute fonts and layout for some text.
|
||||
* @param {TypesetParams} params
|
||||
* @param {(TypesetResult) => void} callback - function called when typesetting is complete.
|
||||
* If the params included `preResolvedFonts`, this will be called synchronously.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {function} TypesetterMeasureFunction - compute width/height for some text.
|
||||
* @param {TypesetParams} params
|
||||
* @param {(width:number, height:number) => void} callback - function called when measurement is complete.
|
||||
* If the params included `preResolvedFonts`, this will be called synchronously.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Factory function that creates a self-contained environment for processing text typesetting requests.
|
||||
*
|
||||
* It is important that this function has no closure dependencies, so that it can be easily injected
|
||||
* into the source for a Worker without requiring a build step or complex dependency loading. All its
|
||||
* dependencies must be passed in at initialization.
|
||||
*
|
||||
* @param {FontResolver} resolveFonts - function to resolve a string to parsed fonts
|
||||
* @param {object} bidi - the bidi.js implementation object
|
||||
* @return {{typeset: TypesetterTypesetFunction, measure: TypesetterMeasureFunction}}
|
||||
*/
|
||||
export function createTypesetter(resolveFonts, bidi) {
|
||||
const INF = Infinity
|
||||
|
||||
// Set of Unicode Default_Ignorable_Code_Point characters, these will not produce visible glyphs
|
||||
// eslint-disable-next-line no-misleading-character-class
|
||||
const DEFAULT_IGNORABLE_CHARS = /[\u00AD\u034F\u061C\u115F-\u1160\u17B4-\u17B5\u180B-\u180E\u200B-\u200F\u202A-\u202E\u2060-\u206F\u3164\uFE00-\uFE0F\uFEFF\uFFA0\uFFF0-\uFFF8]/
|
||||
|
||||
// This regex (instead of /\s/) allows us to select all whitespace EXCEPT for non-breaking white spaces
|
||||
const lineBreakingWhiteSpace = `[^\\S\\u00A0]`
|
||||
|
||||
// Incomplete set of characters that allow line breaking after them
|
||||
// In the future we may consider a full Unicode line breaking algorithm impl: https://www.unicode.org/reports/tr14
|
||||
const BREAK_AFTER_CHARS = new RegExp(`${lineBreakingWhiteSpace}|[\\-\\u007C\\u00AD\\u2010\\u2012-\\u2014\\u2027\\u2056\\u2E17\\u2E40]`)
|
||||
|
||||
/**
|
||||
* Load and parse all the necessary fonts to render a given string of text, then group
|
||||
* them into consecutive runs of characters sharing a font.
|
||||
*/
|
||||
function calculateFontRuns({text, lang, fonts, style, weight, preResolvedFonts, unicodeFontsURL}, onDone) {
|
||||
const onResolved = ({chars, fonts: parsedFonts}) => {
|
||||
let curRun, prevVal;
|
||||
const runs = []
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
if (chars[i] !== prevVal) {
|
||||
prevVal = chars[i];
|
||||
runs.push(curRun = { start: i, end: i, fontObj: parsedFonts[chars[i]]});
|
||||
} else {
|
||||
curRun.end = i;
|
||||
}
|
||||
}
|
||||
onDone(runs);
|
||||
}
|
||||
if (preResolvedFonts) {
|
||||
onResolved(preResolvedFonts)
|
||||
} else {
|
||||
resolveFonts(
|
||||
text,
|
||||
onResolved,
|
||||
{ lang, fonts, style, weight, unicodeFontsURL }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point.
|
||||
* Process a text string with given font and formatting parameters, and return all info
|
||||
* necessary to render all its glyphs.
|
||||
* @type TypesetterTypesetFunction
|
||||
*/
|
||||
function typeset(
|
||||
{
|
||||
text='',
|
||||
font,
|
||||
lang,
|
||||
sdfGlyphSize=64,
|
||||
fontSize=400,
|
||||
fontWeight=1,
|
||||
fontStyle='normal',
|
||||
letterSpacing=0,
|
||||
lineHeight='normal',
|
||||
maxWidth=INF,
|
||||
direction,
|
||||
textAlign='left',
|
||||
textIndent=0,
|
||||
whiteSpace='normal',
|
||||
overflowWrap='normal',
|
||||
anchorX = 0,
|
||||
anchorY = 0,
|
||||
metricsOnly=false,
|
||||
unicodeFontsURL,
|
||||
preResolvedFonts=null,
|
||||
includeCaretPositions=false,
|
||||
chunkedBoundsSize=8192,
|
||||
colorRanges=null
|
||||
},
|
||||
callback
|
||||
) {
|
||||
const mainStart = now()
|
||||
const timings = {fontLoad: 0, typesetting: 0}
|
||||
|
||||
// Ensure newlines are normalized
|
||||
if (text.indexOf('\r') > -1) {
|
||||
console.info('Typesetter: got text with \\r chars; normalizing to \\n')
|
||||
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
}
|
||||
|
||||
// Ensure we've got numbers not strings
|
||||
fontSize = +fontSize
|
||||
letterSpacing = +letterSpacing
|
||||
maxWidth = +maxWidth
|
||||
lineHeight = lineHeight || 'normal'
|
||||
textIndent = +textIndent
|
||||
|
||||
calculateFontRuns({
|
||||
text,
|
||||
lang,
|
||||
style: fontStyle,
|
||||
weight: fontWeight,
|
||||
fonts: typeof font === 'string' ? [{src: font}] : font,
|
||||
unicodeFontsURL,
|
||||
preResolvedFonts
|
||||
}, runs => {
|
||||
timings.fontLoad = now() - mainStart
|
||||
const hasMaxWidth = isFinite(maxWidth)
|
||||
let glyphIds = null
|
||||
let glyphFontIndices = null
|
||||
let glyphPositions = null
|
||||
let glyphData = null
|
||||
let glyphColors = null
|
||||
let caretPositions = null
|
||||
let visibleBounds = null
|
||||
let chunkedBounds = null
|
||||
let maxLineWidth = 0
|
||||
let renderableGlyphCount = 0
|
||||
let canWrap = whiteSpace !== 'nowrap'
|
||||
const metricsByFont = new Map() // fontObj -> metrics
|
||||
const typesetStart = now()
|
||||
|
||||
// Distribute glyphs into lines based on wrapping
|
||||
let lineXOffset = textIndent
|
||||
let prevRunEndX = 0
|
||||
let currentLine = new TextLine()
|
||||
const lines = [currentLine]
|
||||
runs.forEach(run => {
|
||||
const { fontObj } = run
|
||||
const { ascender, descender, unitsPerEm, lineGap, capHeight, xHeight } = fontObj
|
||||
|
||||
// Calculate metrics for each font used
|
||||
let fontData = metricsByFont.get(fontObj)
|
||||
if (!fontData) {
|
||||
// Find conversion between native font units and fontSize units
|
||||
const fontSizeMult = fontSize / unitsPerEm
|
||||
|
||||
// Determine appropriate value for 'normal' line height based on the font's actual metrics
|
||||
// This does not guarantee individual glyphs won't exceed the line height, e.g. Roboto; should we use yMin/Max instead?
|
||||
const calcLineHeight = lineHeight === 'normal' ?
|
||||
(ascender - descender + lineGap) * fontSizeMult : lineHeight * fontSize
|
||||
|
||||
// Determine line height and leading adjustments
|
||||
const halfLeading = (calcLineHeight - (ascender - descender) * fontSizeMult) / 2
|
||||
const caretHeight = Math.min(calcLineHeight, (ascender - descender) * fontSizeMult)
|
||||
const caretTop = (ascender + descender) / 2 * fontSizeMult + caretHeight / 2
|
||||
fontData = {
|
||||
index: metricsByFont.size,
|
||||
src: fontObj.src,
|
||||
fontObj,
|
||||
fontSizeMult,
|
||||
unitsPerEm,
|
||||
ascender: ascender * fontSizeMult,
|
||||
descender: descender * fontSizeMult,
|
||||
capHeight: capHeight * fontSizeMult,
|
||||
xHeight: xHeight * fontSizeMult,
|
||||
lineHeight: calcLineHeight,
|
||||
baseline: -halfLeading - ascender * fontSizeMult, // baseline offset from top of line height
|
||||
// cap: -halfLeading - capHeight * fontSizeMult, // cap from top of line height
|
||||
// ex: -halfLeading - xHeight * fontSizeMult, // ex from top of line height
|
||||
caretTop,
|
||||
caretBottom: caretTop - caretHeight
|
||||
}
|
||||
metricsByFont.set(fontObj, fontData)
|
||||
}
|
||||
const { fontSizeMult } = fontData
|
||||
|
||||
const runText = text.slice(run.start, run.end + 1)
|
||||
let prevGlyphX, prevGlyphObj
|
||||
fontObj.forEachGlyph(runText, fontSize, letterSpacing, (glyphObj, glyphX, glyphY, charIndex) => {
|
||||
glyphX += prevRunEndX
|
||||
charIndex += run.start
|
||||
prevGlyphX = glyphX
|
||||
prevGlyphObj = glyphObj
|
||||
const char = text.charAt(charIndex)
|
||||
const glyphWidth = glyphObj.advanceWidth * fontSizeMult
|
||||
const curLineCount = currentLine.count
|
||||
let nextLine
|
||||
|
||||
// Calc isWhitespace and isEmpty once per glyphObj
|
||||
if (!('isEmpty' in glyphObj)) {
|
||||
glyphObj.isWhitespace = !!char && new RegExp(lineBreakingWhiteSpace).test(char)
|
||||
glyphObj.canBreakAfter = !!char && BREAK_AFTER_CHARS.test(char)
|
||||
glyphObj.isEmpty = glyphObj.xMin === glyphObj.xMax || glyphObj.yMin === glyphObj.yMax || DEFAULT_IGNORABLE_CHARS.test(char)
|
||||
}
|
||||
if (!glyphObj.isWhitespace && !glyphObj.isEmpty) {
|
||||
renderableGlyphCount++
|
||||
}
|
||||
|
||||
// If a non-whitespace character overflows the max width, we need to soft-wrap
|
||||
if (canWrap && hasMaxWidth && !glyphObj.isWhitespace && glyphX + glyphWidth + lineXOffset > maxWidth && curLineCount) {
|
||||
// If it's the first char after a whitespace, start a new line
|
||||
if (currentLine.glyphAt(curLineCount - 1).glyphObj.canBreakAfter) {
|
||||
nextLine = new TextLine()
|
||||
lineXOffset = -glyphX
|
||||
} else {
|
||||
// Back up looking for a whitespace character to wrap at
|
||||
for (let i = curLineCount; i--;) {
|
||||
// If we got the start of the line there's no soft break point; make hard break if overflowWrap='break-word'
|
||||
if (i === 0 && overflowWrap === 'break-word') {
|
||||
nextLine = new TextLine()
|
||||
lineXOffset = -glyphX
|
||||
break
|
||||
}
|
||||
// Found a soft break point; move all chars since it to a new line
|
||||
else if (currentLine.glyphAt(i).glyphObj.canBreakAfter) {
|
||||
nextLine = currentLine.splitAt(i + 1)
|
||||
const adjustX = nextLine.glyphAt(0).x
|
||||
lineXOffset -= adjustX
|
||||
for (let j = nextLine.count; j--;) {
|
||||
nextLine.glyphAt(j).x -= adjustX
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nextLine) {
|
||||
currentLine.isSoftWrapped = true
|
||||
currentLine = nextLine
|
||||
lines.push(currentLine)
|
||||
maxLineWidth = maxWidth //after soft wrapping use maxWidth as calculated width
|
||||
}
|
||||
}
|
||||
|
||||
let fly = currentLine.glyphAt(currentLine.count)
|
||||
fly.glyphObj = glyphObj
|
||||
fly.x = glyphX + lineXOffset
|
||||
fly.y = glyphY
|
||||
fly.width = glyphWidth
|
||||
fly.charIndex = charIndex
|
||||
fly.fontData = fontData
|
||||
|
||||
// Handle hard line breaks
|
||||
if (char === '\n') {
|
||||
currentLine = new TextLine()
|
||||
lines.push(currentLine)
|
||||
lineXOffset = -(glyphX + glyphWidth + (letterSpacing * fontSize)) + textIndent
|
||||
}
|
||||
})
|
||||
// At the end of a run we must capture the x position as the starting point for the next run
|
||||
prevRunEndX = prevGlyphX + prevGlyphObj.advanceWidth * fontSizeMult + letterSpacing * fontSize
|
||||
})
|
||||
|
||||
// Calculate width/height/baseline of each line (excluding trailing whitespace) and maximum block width
|
||||
let totalHeight = 0
|
||||
lines.forEach(line => {
|
||||
let isTrailingWhitespace = true;
|
||||
for (let i = line.count; i--;) {
|
||||
const glyphInfo = line.glyphAt(i)
|
||||
// omit trailing whitespace from width calculation
|
||||
if (isTrailingWhitespace && !glyphInfo.glyphObj.isWhitespace) {
|
||||
line.width = glyphInfo.x + glyphInfo.width
|
||||
if (line.width > maxLineWidth) {
|
||||
maxLineWidth = line.width
|
||||
}
|
||||
isTrailingWhitespace = false
|
||||
}
|
||||
// use the tallest line height, lowest baseline, and highest cap/ex
|
||||
let {lineHeight, capHeight, xHeight, baseline} = glyphInfo.fontData
|
||||
if (lineHeight > line.lineHeight) line.lineHeight = lineHeight
|
||||
const baselineDiff = baseline - line.baseline
|
||||
if (baselineDiff < 0) { //shift all metrics down
|
||||
line.baseline += baselineDiff
|
||||
line.cap += baselineDiff
|
||||
line.ex += baselineDiff
|
||||
}
|
||||
// compare cap/ex based on new lowest baseline
|
||||
line.cap = Math.max(line.cap, line.baseline + capHeight)
|
||||
line.ex = Math.max(line.ex, line.baseline + xHeight)
|
||||
}
|
||||
line.baseline -= totalHeight
|
||||
line.cap -= totalHeight
|
||||
line.ex -= totalHeight
|
||||
totalHeight += line.lineHeight
|
||||
})
|
||||
|
||||
// Find overall position adjustments for anchoring
|
||||
let anchorXOffset = 0
|
||||
let anchorYOffset = 0
|
||||
if (anchorX) {
|
||||
if (typeof anchorX === 'number') {
|
||||
anchorXOffset = -anchorX
|
||||
}
|
||||
else if (typeof anchorX === 'string') {
|
||||
anchorXOffset = -maxLineWidth * (
|
||||
anchorX === 'left' ? 0 :
|
||||
anchorX === 'center' ? 0.5 :
|
||||
anchorX === 'right' ? 1 :
|
||||
parsePercent(anchorX)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (anchorY) {
|
||||
if (typeof anchorY === 'number') {
|
||||
anchorYOffset = -anchorY
|
||||
}
|
||||
else if (typeof anchorY === 'string') {
|
||||
anchorYOffset = anchorY === 'top' ? 0 :
|
||||
anchorY === 'top-baseline' ? -lines[0].baseline :
|
||||
anchorY === 'top-cap' ? -lines[0].cap :
|
||||
anchorY === 'top-ex' ? -lines[0].ex :
|
||||
anchorY === 'middle' ? totalHeight / 2 :
|
||||
anchorY === 'bottom' ? totalHeight :
|
||||
anchorY === 'bottom-baseline' ? -lines[lines.length - 1].baseline :
|
||||
parsePercent(anchorY) * totalHeight
|
||||
}
|
||||
}
|
||||
|
||||
if (!metricsOnly) {
|
||||
// Resolve bidi levels
|
||||
const bidiLevelsResult = bidi.getEmbeddingLevels(text, direction)
|
||||
|
||||
// Process each line, applying alignment offsets, adding each glyph to the atlas, and
|
||||
// collecting all renderable glyphs into a single collection.
|
||||
glyphIds = new Uint16Array(renderableGlyphCount)
|
||||
glyphFontIndices = new Uint8Array(renderableGlyphCount)
|
||||
glyphPositions = new Float32Array(renderableGlyphCount * 2)
|
||||
glyphData = {}
|
||||
visibleBounds = [INF, INF, -INF, -INF]
|
||||
chunkedBounds = []
|
||||
if (includeCaretPositions) {
|
||||
caretPositions = new Float32Array(text.length * 4)
|
||||
}
|
||||
if (colorRanges) {
|
||||
glyphColors = new Uint8Array(renderableGlyphCount * 3)
|
||||
}
|
||||
let renderableGlyphIndex = 0
|
||||
let prevCharIndex = -1
|
||||
let colorCharIndex = -1
|
||||
let chunk
|
||||
let currentColor
|
||||
lines.forEach((line, lineIndex) => {
|
||||
let {count:lineGlyphCount, width:lineWidth} = line
|
||||
|
||||
// Ignore empty lines
|
||||
if (lineGlyphCount > 0) {
|
||||
// Count trailing whitespaces, we want to ignore these for certain things
|
||||
let trailingWhitespaceCount = 0
|
||||
for (let i = lineGlyphCount; i-- && line.glyphAt(i).glyphObj.isWhitespace;) {
|
||||
trailingWhitespaceCount++
|
||||
}
|
||||
|
||||
// Apply horizontal alignment adjustments
|
||||
let lineXOffset = 0
|
||||
let justifyAdjust = 0
|
||||
if (textAlign === 'center') {
|
||||
lineXOffset = (maxLineWidth - lineWidth) / 2
|
||||
} else if (textAlign === 'right') {
|
||||
lineXOffset = maxLineWidth - lineWidth
|
||||
} else if (textAlign === 'justify' && line.isSoftWrapped) {
|
||||
// count non-trailing whitespace characters, and we'll adjust the offsets per character in the next loop
|
||||
let whitespaceCount = 0
|
||||
for (let i = lineGlyphCount - trailingWhitespaceCount; i--;) {
|
||||
if (line.glyphAt(i).glyphObj.isWhitespace) {
|
||||
whitespaceCount++
|
||||
}
|
||||
}
|
||||
justifyAdjust = (maxLineWidth - lineWidth) / whitespaceCount
|
||||
}
|
||||
if (justifyAdjust || lineXOffset) {
|
||||
let justifyOffset = 0
|
||||
for (let i = 0; i < lineGlyphCount; i++) {
|
||||
let glyphInfo = line.glyphAt(i)
|
||||
const glyphObj = glyphInfo.glyphObj
|
||||
glyphInfo.x += lineXOffset + justifyOffset
|
||||
// Expand non-trailing whitespaces for justify alignment
|
||||
if (justifyAdjust !== 0 && glyphObj.isWhitespace && i < lineGlyphCount - trailingWhitespaceCount) {
|
||||
justifyOffset += justifyAdjust
|
||||
glyphInfo.width += justifyAdjust
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Perform bidi range flipping
|
||||
const flips = bidi.getReorderSegments(
|
||||
text, bidiLevelsResult, line.glyphAt(0).charIndex, line.glyphAt(line.count - 1).charIndex
|
||||
)
|
||||
for (let fi = 0; fi < flips.length; fi++) {
|
||||
const [start, end] = flips[fi]
|
||||
// Map start/end string indices to indices in the line
|
||||
let left = Infinity, right = -Infinity
|
||||
for (let i = 0; i < lineGlyphCount; i++) {
|
||||
if (line.glyphAt(i).charIndex >= start) { // gte to handle removed characters
|
||||
let startInLine = i, endInLine = i
|
||||
for (; endInLine < lineGlyphCount; endInLine++) {
|
||||
let info = line.glyphAt(endInLine)
|
||||
if (info.charIndex > end) {
|
||||
break
|
||||
}
|
||||
if (endInLine < lineGlyphCount - trailingWhitespaceCount) { //don't include trailing ws in flip width
|
||||
left = Math.min(left, info.x)
|
||||
right = Math.max(right, info.x + info.width)
|
||||
}
|
||||
}
|
||||
for (let j = startInLine; j < endInLine; j++) {
|
||||
const glyphInfo = line.glyphAt(j)
|
||||
glyphInfo.x = right - (glyphInfo.x + glyphInfo.width - left)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble final data arrays
|
||||
let glyphObj
|
||||
const setGlyphObj = g => glyphObj = g
|
||||
for (let i = 0; i < lineGlyphCount; i++) {
|
||||
const glyphInfo = line.glyphAt(i)
|
||||
glyphObj = glyphInfo.glyphObj
|
||||
const glyphId = glyphObj.index
|
||||
|
||||
// Replace mirrored characters in rtl
|
||||
const rtl = bidiLevelsResult.levels[glyphInfo.charIndex] & 1 //odd level means rtl
|
||||
if (rtl) {
|
||||
const mirrored = bidi.getMirroredCharacter(text[glyphInfo.charIndex])
|
||||
if (mirrored) {
|
||||
glyphInfo.fontData.fontObj.forEachGlyph(mirrored, 0, 0, setGlyphObj)
|
||||
}
|
||||
}
|
||||
|
||||
// Add caret positions
|
||||
if (includeCaretPositions) {
|
||||
const {charIndex, fontData} = glyphInfo
|
||||
const caretLeft = glyphInfo.x + anchorXOffset
|
||||
const caretRight = glyphInfo.x + glyphInfo.width + anchorXOffset
|
||||
caretPositions[charIndex * 4] = rtl ? caretRight : caretLeft //start edge x
|
||||
caretPositions[charIndex * 4 + 1] = rtl ? caretLeft : caretRight //end edge x
|
||||
caretPositions[charIndex * 4 + 2] = line.baseline + fontData.caretBottom + anchorYOffset //common bottom y
|
||||
caretPositions[charIndex * 4 + 3] = line.baseline + fontData.caretTop + anchorYOffset //common top y
|
||||
|
||||
// If we skipped any chars from the previous glyph (due to ligature subs), fill in caret
|
||||
// positions for those missing char indices; currently this uses a best-guess by dividing
|
||||
// the ligature's width evenly. In the future we may try to use the font's LigatureCaretList
|
||||
// table to get better interior caret positions.
|
||||
const ligCount = charIndex - prevCharIndex
|
||||
if (ligCount > 1) {
|
||||
fillLigatureCaretPositions(caretPositions, prevCharIndex, ligCount)
|
||||
}
|
||||
prevCharIndex = charIndex
|
||||
}
|
||||
|
||||
// Track current color range
|
||||
if (colorRanges) {
|
||||
const {charIndex} = glyphInfo
|
||||
while(charIndex > colorCharIndex) {
|
||||
colorCharIndex++
|
||||
if (colorRanges.hasOwnProperty(colorCharIndex)) {
|
||||
currentColor = colorRanges[colorCharIndex]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get atlas data for renderable glyphs
|
||||
if (!glyphObj.isWhitespace && !glyphObj.isEmpty) {
|
||||
const idx = renderableGlyphIndex++
|
||||
const {fontSizeMult, src: fontSrc, index: fontIndex} = glyphInfo.fontData
|
||||
|
||||
// Add this glyph's path data
|
||||
const fontGlyphData = glyphData[fontSrc] || (glyphData[fontSrc] = {})
|
||||
if (!fontGlyphData[glyphId]) {
|
||||
fontGlyphData[glyphId] = {
|
||||
path: glyphObj.path,
|
||||
pathBounds: [glyphObj.xMin, glyphObj.yMin, glyphObj.xMax, glyphObj.yMax]
|
||||
}
|
||||
}
|
||||
|
||||
// Determine final glyph position and add to glyphPositions array
|
||||
const glyphX = glyphInfo.x + anchorXOffset
|
||||
const glyphY = glyphInfo.y + line.baseline + anchorYOffset
|
||||
glyphPositions[idx * 2] = glyphX
|
||||
glyphPositions[idx * 2 + 1] = glyphY
|
||||
|
||||
// Track total visible bounds
|
||||
const visX0 = glyphX + glyphObj.xMin * fontSizeMult
|
||||
const visY0 = glyphY + glyphObj.yMin * fontSizeMult
|
||||
const visX1 = glyphX + glyphObj.xMax * fontSizeMult
|
||||
const visY1 = glyphY + glyphObj.yMax * fontSizeMult
|
||||
if (visX0 < visibleBounds[0]) visibleBounds[0] = visX0
|
||||
if (visY0 < visibleBounds[1]) visibleBounds[1] = visY0
|
||||
if (visX1 > visibleBounds[2]) visibleBounds[2] = visX1
|
||||
if (visY1 > visibleBounds[3]) visibleBounds[3] = visY1
|
||||
|
||||
// Track bounding rects for each chunk of N glyphs
|
||||
if (idx % chunkedBoundsSize === 0) {
|
||||
chunk = {start: idx, end: idx, rect: [INF, INF, -INF, -INF]}
|
||||
chunkedBounds.push(chunk)
|
||||
}
|
||||
chunk.end++
|
||||
const chunkRect = chunk.rect
|
||||
if (visX0 < chunkRect[0]) chunkRect[0] = visX0
|
||||
if (visY0 < chunkRect[1]) chunkRect[1] = visY0
|
||||
if (visX1 > chunkRect[2]) chunkRect[2] = visX1
|
||||
if (visY1 > chunkRect[3]) chunkRect[3] = visY1
|
||||
|
||||
// Add to glyph ids and font indices arrays
|
||||
glyphIds[idx] = glyphId
|
||||
glyphFontIndices[idx] = fontIndex
|
||||
|
||||
// Add colors
|
||||
if (colorRanges) {
|
||||
const start = idx * 3
|
||||
glyphColors[start] = currentColor >> 16 & 255
|
||||
glyphColors[start + 1] = currentColor >> 8 & 255
|
||||
glyphColors[start + 2] = currentColor & 255
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Fill in remaining caret positions in case the final character was a ligature
|
||||
if (caretPositions) {
|
||||
const ligCount = text.length - prevCharIndex;
|
||||
if (ligCount > 1) {
|
||||
fillLigatureCaretPositions(caretPositions, prevCharIndex, ligCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assemble final data about each font used
|
||||
const fontData = []
|
||||
metricsByFont.forEach(({index, src, unitsPerEm, ascender, descender, lineHeight, capHeight, xHeight}) => {
|
||||
fontData[index] = {src, unitsPerEm, ascender, descender, lineHeight, capHeight, xHeight}
|
||||
})
|
||||
|
||||
// Timing stats
|
||||
timings.typesetting = now() - typesetStart
|
||||
|
||||
callback({
|
||||
glyphIds, //id for each glyph, specific to that glyph's font
|
||||
glyphFontIndices, //index into fontData for each glyph
|
||||
glyphPositions, //x,y of each glyph's origin in layout
|
||||
glyphData, //dict holding data about each glyph appearing in the text
|
||||
fontData, //data about each font used in the text
|
||||
caretPositions, //startX,endX,bottomY caret positions for each char
|
||||
// caretHeight, //height of cursor from bottom to top - todo per glyph?
|
||||
glyphColors, //color for each glyph, if color ranges supplied
|
||||
chunkedBounds, //total rects per (n=chunkedBoundsSize) consecutive glyphs
|
||||
fontSize, //calculated em height
|
||||
topBaseline: anchorYOffset + lines[0].baseline, //y coordinate of the top line's baseline
|
||||
blockBounds: [ //bounds for the whole block of text, including vertical padding for lineHeight
|
||||
anchorXOffset,
|
||||
anchorYOffset - totalHeight,
|
||||
anchorXOffset + maxLineWidth,
|
||||
anchorYOffset
|
||||
],
|
||||
visibleBounds, //total bounds of visible text paths, may be larger or smaller than blockBounds
|
||||
timings
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* For a given text string and font parameters, determine the resulting block dimensions
|
||||
* after wrapping for the given maxWidth.
|
||||
* @param args
|
||||
* @param callback
|
||||
*/
|
||||
function measure(args, callback) {
|
||||
typeset({...args, metricsOnly: true}, (result) => {
|
||||
const [x0, y0, x1, y1] = result.blockBounds
|
||||
callback({
|
||||
width: x1 - x0,
|
||||
height: y1 - y0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function parsePercent(str) {
|
||||
let match = str.match(/^([\d.]+)%$/)
|
||||
let pct = match ? parseFloat(match[1]) : NaN
|
||||
return isNaN(pct) ? 0 : pct / 100
|
||||
}
|
||||
|
||||
function fillLigatureCaretPositions(caretPositions, ligStartIndex, ligCount) {
|
||||
const ligStartX = caretPositions[ligStartIndex * 4]
|
||||
const ligEndX = caretPositions[ligStartIndex * 4 + 1]
|
||||
const ligBottom = caretPositions[ligStartIndex * 4 + 2]
|
||||
const ligTop = caretPositions[ligStartIndex * 4 + 3]
|
||||
const guessedAdvanceX = (ligEndX - ligStartX) / ligCount
|
||||
for (let i = 0; i < ligCount; i++) {
|
||||
const startIndex = (ligStartIndex + i) * 4
|
||||
caretPositions[startIndex] = ligStartX + guessedAdvanceX * i
|
||||
caretPositions[startIndex + 1] = ligStartX + guessedAdvanceX * (i + 1)
|
||||
caretPositions[startIndex + 2] = ligBottom
|
||||
caretPositions[startIndex + 3] = ligTop
|
||||
}
|
||||
}
|
||||
|
||||
function now() {
|
||||
return (self.performance || Date).now()
|
||||
}
|
||||
|
||||
// Array-backed structure for a single line's glyphs data
|
||||
function TextLine() {
|
||||
this.data = []
|
||||
}
|
||||
const textLineProps = ['glyphObj', 'x', 'y', 'width', 'charIndex', 'fontData']
|
||||
TextLine.prototype = {
|
||||
width: 0,
|
||||
lineHeight: 0,
|
||||
baseline: 0,
|
||||
cap: 0,
|
||||
ex: 0,
|
||||
isSoftWrapped: false,
|
||||
get count() {
|
||||
return Math.ceil(this.data.length / textLineProps.length)
|
||||
},
|
||||
glyphAt(i) {
|
||||
let fly = TextLine.flyweight
|
||||
fly.data = this.data
|
||||
fly.index = i
|
||||
return fly
|
||||
},
|
||||
splitAt(i) {
|
||||
let newLine = new TextLine()
|
||||
newLine.data = this.data.splice(i * textLineProps.length)
|
||||
return newLine
|
||||
}
|
||||
}
|
||||
TextLine.flyweight = textLineProps.reduce((obj, prop, i, all) => {
|
||||
Object.defineProperty(obj, prop, {
|
||||
get() {
|
||||
return this.data[this.index * textLineProps.length + i]
|
||||
},
|
||||
set(val) {
|
||||
this.data[this.index * textLineProps.length + i] = val
|
||||
}
|
||||
})
|
||||
return obj
|
||||
}, {data: null, index: 0})
|
||||
|
||||
|
||||
return {
|
||||
typeset,
|
||||
measure,
|
||||
}
|
||||
}
|
||||
|
||||
9
node_modules/troika-three-text/src/index.js
generated
vendored
Normal file
9
node_modules/troika-three-text/src/index.js
generated
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
// Exports for troika-three-text package:
|
||||
|
||||
export { configureTextBuilder, getTextRenderInfo, typesetterWorkerModule, preloadFont, dumpSDFTextures } from './TextBuilder.js'
|
||||
export { fontResolverWorkerModule } from './FontResolver.js'
|
||||
export { Text } from './Text.js'
|
||||
export { BatchedText } from './BatchedText.js'
|
||||
export { GlyphsGeometry } from './GlyphsGeometry.js'
|
||||
export { createTextDerivedMaterial } from './TextDerivedMaterial.js'
|
||||
export { getCaretAtPoint, getSelectionRects } from './selectionUtils.js'
|
||||
153
node_modules/troika-three-text/src/selectionUtils.js
generated
vendored
Normal file
153
node_modules/troika-three-text/src/selectionUtils.js
generated
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
//=== Utility functions for dealing with carets and selection ranges ===//
|
||||
|
||||
/**
|
||||
* @typedef {object} TextCaret
|
||||
* @property {number} x - x position of the caret
|
||||
* @property {number} y - y position of the caret's bottom
|
||||
* @property {number} height - height of the caret
|
||||
* @property {number} charIndex - the index in the original input string of this caret's target
|
||||
* character; the caret will be for the position _before_ that character.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Given a local x/y coordinate in the text block plane, find the nearest caret position.
|
||||
* @param {TroikaTextRenderInfo} textRenderInfo - a result object from TextBuilder#getTextRenderInfo
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @return {TextCaret | null}
|
||||
*/
|
||||
export function getCaretAtPoint(textRenderInfo, x, y) {
|
||||
let closestCaret = null
|
||||
const rows = groupCaretsByRow(textRenderInfo)
|
||||
|
||||
// Find nearest row by y first
|
||||
let closestRow = null
|
||||
rows.forEach(row => {
|
||||
if (!closestRow || Math.abs(y - (row.top + row.bottom) / 2) < Math.abs(y - (closestRow.top + closestRow.bottom) / 2)) {
|
||||
closestRow = row
|
||||
}
|
||||
})
|
||||
|
||||
// Then find closest caret by x within that row
|
||||
closestRow.carets.forEach(caret => {
|
||||
if (!closestCaret || Math.abs(x - caret.x) < Math.abs(x - closestCaret.x)) {
|
||||
closestCaret = caret
|
||||
}
|
||||
})
|
||||
return closestCaret
|
||||
}
|
||||
|
||||
|
||||
const _rectsCache = new WeakMap()
|
||||
|
||||
/**
|
||||
* Given start and end character indexes, return a list of rectangles covering all the
|
||||
* characters within that selection.
|
||||
* @param {TroikaTextRenderInfo} textRenderInfo
|
||||
* @param {number} start - index of the first char in the selection
|
||||
* @param {number} end - index of the first char after the selection
|
||||
* @return {Array<{left, top, right, bottom}> | null}
|
||||
*/
|
||||
export function getSelectionRects(textRenderInfo, start, end) {
|
||||
let rects
|
||||
if (textRenderInfo) {
|
||||
// Check cache - textRenderInfo is frozen so it's safe to cache based on it
|
||||
let prevResult = _rectsCache.get(textRenderInfo)
|
||||
if (prevResult && prevResult.start === start && prevResult.end === end) {
|
||||
return prevResult.rects
|
||||
}
|
||||
|
||||
const {caretPositions} = textRenderInfo
|
||||
|
||||
// Normalize
|
||||
if (end < start) {
|
||||
const s = start
|
||||
start = end
|
||||
end = s
|
||||
}
|
||||
start = Math.max(start, 0)
|
||||
end = Math.min(end, caretPositions.length + 1)
|
||||
|
||||
// Build list of rects, expanding the current rect for all characters in a run and starting
|
||||
// a new rect whenever reaching a new line or a new bidi direction
|
||||
rects = []
|
||||
let currentRect = null
|
||||
for (let i = start; i < end; i++) {
|
||||
const x1 = caretPositions[i * 4]
|
||||
const x2 = caretPositions[i * 4 + 1]
|
||||
const left = Math.min(x1, x2)
|
||||
const right = Math.max(x1, x2)
|
||||
const bottom = caretPositions[i * 4 + 2]
|
||||
const top = caretPositions[i * 4 + 3]
|
||||
if (!currentRect || bottom !== currentRect.bottom || top !== currentRect.top || left > currentRect.right || right < currentRect.left) {
|
||||
currentRect = {
|
||||
left: Infinity,
|
||||
right: -Infinity,
|
||||
bottom,
|
||||
top,
|
||||
}
|
||||
rects.push(currentRect)
|
||||
}
|
||||
currentRect.left = Math.min(left, currentRect.left)
|
||||
currentRect.right = Math.max(right, currentRect.right)
|
||||
}
|
||||
|
||||
// Merge any overlapping rects, e.g. those formed by adjacent bidi runs
|
||||
rects.sort((a, b) => b.bottom - a.bottom || a.left - b.left)
|
||||
for (let i = rects.length - 1; i-- > 0;) {
|
||||
const rectA = rects[i]
|
||||
const rectB = rects[i + 1]
|
||||
if (rectA.bottom === rectB.bottom && rectA.top === rectB.top && rectA.left <= rectB.right && rectA.right >= rectB.left) {
|
||||
rectB.left = Math.min(rectB.left, rectA.left)
|
||||
rectB.right = Math.max(rectB.right, rectA.right)
|
||||
rects.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
_rectsCache.set(textRenderInfo, {start, end, rects})
|
||||
}
|
||||
return rects
|
||||
}
|
||||
|
||||
const _caretsByRowCache = new WeakMap()
|
||||
|
||||
/**
|
||||
* Group a set of carets by row of text, caching the result. A single row of text may contain carets of
|
||||
* differing positions/heights if it has multiple fonts, and they may overlap slightly across rows, so this
|
||||
* uses an assumption of "at least overlapping by half" to put them in the same row.
|
||||
* @return Array<{bottom: number, top: number, carets: TextCaret[]}>
|
||||
*/
|
||||
function groupCaretsByRow(textRenderInfo) {
|
||||
// textRenderInfo is frozen so it's safe to cache based on it
|
||||
let rows = _caretsByRowCache.get(textRenderInfo)
|
||||
if (!rows) {
|
||||
rows = []
|
||||
const {caretPositions} = textRenderInfo
|
||||
let curRow
|
||||
|
||||
const visitCaret = (x, bottom, top, charIndex) => {
|
||||
// new row if not overlapping by at least half
|
||||
if (!curRow || (top < (curRow.top + curRow.bottom) / 2)) {
|
||||
rows.push(curRow = {bottom, top, carets: []})
|
||||
}
|
||||
// expand vertical limits if necessary
|
||||
if (top > curRow.top) curRow.top = top
|
||||
if (bottom < curRow.bottom) curRow.bottom = bottom
|
||||
curRow.carets.push({
|
||||
x,
|
||||
y: bottom,
|
||||
height: top - bottom,
|
||||
charIndex,
|
||||
})
|
||||
}
|
||||
|
||||
let i = 0
|
||||
for (; i < caretPositions.length; i += 4) {
|
||||
visitCaret(caretPositions[i], caretPositions[i + 2], caretPositions[i + 3], i / 4)
|
||||
}
|
||||
// Add one more caret after the final char
|
||||
visitCaret(caretPositions[i - 3], caretPositions[i - 2], caretPositions[i - 1], i / 4)
|
||||
}
|
||||
_caretsByRowCache.set(textRenderInfo, rows)
|
||||
return rows
|
||||
}
|
||||
149
node_modules/troika-three-text/src/woff2otf.js
generated
vendored
Normal file
149
node_modules/troika-three-text/src/woff2otf.js
generated
vendored
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
Copyright 2012, Steffen Hanikel (https://github.com/hanikesn)
|
||||
Modified by Artemy Tregubenko, 2014 (https://github.com/arty-name/woff2otf)
|
||||
Modified by Jason Johnston, 2019 (pako --> fflate)
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
A tool to convert a WOFF back to a TTF/OTF font file, in pure Javascript
|
||||
*/
|
||||
|
||||
import { inflateSync } from 'fflate'
|
||||
|
||||
export function convert_streams(bufferIn) {
|
||||
var dataViewIn = new DataView(bufferIn);
|
||||
var offsetIn = 0;
|
||||
|
||||
function read2() {
|
||||
var uint16 = dataViewIn.getUint16(offsetIn);
|
||||
offsetIn += 2;
|
||||
return uint16;
|
||||
}
|
||||
|
||||
function read4() {
|
||||
var uint32 = dataViewIn.getUint32(offsetIn);
|
||||
offsetIn += 4;
|
||||
return uint32;
|
||||
}
|
||||
|
||||
function write2(uint16) {
|
||||
dataViewOut.setUint16(offsetOut, uint16);
|
||||
offsetOut += 2;
|
||||
}
|
||||
|
||||
function write4(uint32) {
|
||||
dataViewOut.setUint32(offsetOut, uint32);
|
||||
offsetOut += 4;
|
||||
}
|
||||
|
||||
var WOFFHeader = {
|
||||
signature: read4(),
|
||||
flavor: read4(),
|
||||
length: read4(),
|
||||
numTables: read2(),
|
||||
reserved: read2(),
|
||||
totalSfntSize: read4(),
|
||||
majorVersion: read2(),
|
||||
minorVersion: read2(),
|
||||
metaOffset: read4(),
|
||||
metaLength: read4(),
|
||||
metaOrigLength: read4(),
|
||||
privOffset: read4(),
|
||||
privLength: read4()
|
||||
};
|
||||
|
||||
var entrySelector = 0;
|
||||
while (Math.pow(2, entrySelector) <= WOFFHeader.numTables) {
|
||||
entrySelector++;
|
||||
}
|
||||
entrySelector--;
|
||||
|
||||
var searchRange = Math.pow(2, entrySelector) * 16;
|
||||
var rangeShift = WOFFHeader.numTables * 16 - searchRange;
|
||||
|
||||
var offset = 4 + 2 + 2 + 2 + 2;
|
||||
var TableDirectoryEntries = [];
|
||||
for (var i = 0; i < WOFFHeader.numTables; i++) {
|
||||
TableDirectoryEntries.push({
|
||||
tag: read4(),
|
||||
offset: read4(),
|
||||
compLength: read4(),
|
||||
origLength: read4(),
|
||||
origChecksum: read4()
|
||||
});
|
||||
offset += 4 * 4;
|
||||
}
|
||||
|
||||
var arrayOut = new Uint8Array(
|
||||
4 + 2 + 2 + 2 + 2 +
|
||||
TableDirectoryEntries.length * (4 + 4 + 4 + 4) +
|
||||
TableDirectoryEntries.reduce(function(acc, entry) { return acc + entry.origLength + 4; }, 0)
|
||||
);
|
||||
var bufferOut = arrayOut.buffer;
|
||||
var dataViewOut = new DataView(bufferOut);
|
||||
var offsetOut = 0;
|
||||
|
||||
write4(WOFFHeader.flavor);
|
||||
write2(WOFFHeader.numTables);
|
||||
write2(searchRange);
|
||||
write2(entrySelector);
|
||||
write2(rangeShift);
|
||||
|
||||
TableDirectoryEntries.forEach(function(TableDirectoryEntry) {
|
||||
write4(TableDirectoryEntry.tag);
|
||||
write4(TableDirectoryEntry.origChecksum);
|
||||
write4(offset);
|
||||
write4(TableDirectoryEntry.origLength);
|
||||
|
||||
TableDirectoryEntry.outOffset = offset;
|
||||
offset += TableDirectoryEntry.origLength;
|
||||
if ((offset % 4) != 0) {
|
||||
offset += 4 - (offset % 4)
|
||||
}
|
||||
});
|
||||
|
||||
var size;
|
||||
|
||||
TableDirectoryEntries.forEach(function(TableDirectoryEntry) {
|
||||
var compressedData = bufferIn.slice(
|
||||
TableDirectoryEntry.offset,
|
||||
TableDirectoryEntry.offset + TableDirectoryEntry.compLength
|
||||
);
|
||||
|
||||
if (TableDirectoryEntry.compLength != TableDirectoryEntry.origLength) {
|
||||
var uncompressedData = new Uint8Array(TableDirectoryEntry.origLength)
|
||||
inflateSync(
|
||||
new Uint8Array(compressedData, 2), //skip deflate header
|
||||
uncompressedData
|
||||
)
|
||||
} else {
|
||||
uncompressedData = new Uint8Array(compressedData);
|
||||
}
|
||||
|
||||
arrayOut.set(uncompressedData, TableDirectoryEntry.outOffset);
|
||||
offset = TableDirectoryEntry.outOffset + TableDirectoryEntry.origLength;
|
||||
|
||||
var padding = 0;
|
||||
if ((offset % 4) != 0) {
|
||||
padding = 4 - (offset % 4);
|
||||
}
|
||||
arrayOut.set(
|
||||
new Uint8Array(padding).buffer,
|
||||
TableDirectoryEntry.outOffset + TableDirectoryEntry.origLength
|
||||
);
|
||||
|
||||
size = offset + padding;
|
||||
});
|
||||
|
||||
return bufferOut.slice(0, size);
|
||||
}
|
||||
Reference in New Issue
Block a user