diff --git a/packages/troika-three-text/src/FontParser.js b/packages/troika-three-text/src/FontParser.js index 55f7cbea..42c0b199 100644 --- a/packages/troika-three-text/src/FontParser.js +++ b/packages/troika-three-text/src/FontParser.js @@ -163,6 +163,9 @@ function parserFactory(Typr, woff2otf) { 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 glyphX = 0 const fontScale = 1 / fontObj.unitsPerEm * fontSize diff --git a/packages/troika-three-text/src/TextBuilder.js b/packages/troika-three-text/src/TextBuilder.js index e0962974..85d507af 100644 --- a/packages/troika-three-text/src/TextBuilder.js +++ b/packages/troika-three-text/src/TextBuilder.js @@ -4,14 +4,16 @@ import { createTypesetter } from './Typesetter.js' import { generateSDF, warmUpSDFCanvas, resizeWebGLCanvasWithoutClearing } from './SDFGenerator.js' import bidiFactory from 'bidi-js' import fontParser from './FontParser.js' +import { fallbackFonts } from './fonts.js' const CONFIG = { - defaultFontURL: 'https://fonts.gstatic.com/s/roboto/v18/KFOmCnqEu92Fr1Mu4mxM.woff', //Roboto Regular + defaultFontURL: null, // just uses fallbackFonts unless user configures a new default - this will change sdfGlyphSize: 64, sdfMargin: 1 / 16, sdfExponent: 9, - textureWidth: 2048 + textureWidth: 2048, + fallbackFonts } const tempColor = /*#__PURE__*/new Color() let hasRequested = false @@ -82,7 +84,7 @@ const atlases = Object.create(null) * @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 - * three elements: the starting X, the ending X, and the bottom Y for the caret. + * 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. @@ -118,9 +120,17 @@ function getTextRenderInfo(args, callback) { args = assign({}, args) const totalStart = now() - // Apply default font here to avoid a 'null' atlas, and convert relative - // URLs to absolute so they can be resolved in the worker - args.font = toAbsoluteURL(args.font || CONFIG.defaultFontURL) + // 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, fallbackFonts } = CONFIG + const fonts = fallbackFonts.slice() + 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 @@ -173,26 +183,32 @@ function getTextRenderInfo(args, callback) { } const {sdfTexture, sdfCanvas} = atlas - let fontGlyphs = atlas.glyphsByFont.get(args.font) - if (!fontGlyphs) { - atlas.glyphsByFont.set(args.font, fontGlyphs = new Map()) - } // Issue request to the typesetting engine in the worker typesetInWorker(args).then(result => { - const {glyphIds, glyphPositions, fontSize, unitsPerEm, timings} = result + const {glyphIds, glyphFontIndices, fontData, glyphPositions, fontSize, timings} = result const neededSDFs = [] const glyphBounds = new Float32Array(glyphIds.length * 4) - const fontSizeMult = fontSize / unitsPerEm 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) => { - let glyphInfo = fontGlyphs.get(glyphId) + 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[glyphId] + 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 @@ -207,7 +223,7 @@ function getTextRenderInfo(args, callback) { pathBounds[2] + fontUnitsMargin, pathBounds[3] + fontUnitsMargin, ] - fontGlyphs.set(glyphId, (glyphInfo = { path, atlasIndex, sdfViewBox })) + fontGlyphMaps[fontIndex].set(glyphId, (glyphInfo = { path, atlasIndex, sdfViewBox })) // Collect those that need SDF generation neededSDFs.push(glyphInfo) @@ -218,6 +234,7 @@ function getTextRenderInfo(args, callback) { 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 @@ -266,7 +283,6 @@ function getTextRenderInfo(args, callback) { glyphAtlasIndices: glyphIds, glyphColors: result.glyphColors, caretPositions: result.caretPositions, - caretHeight: result.caretHeight, chunkedBounds: result.chunkedBounds, ascender: result.ascender, descender: result.descender, @@ -449,15 +465,11 @@ const typesetInWorker = /*#__PURE__*/defineWorkerModule({ }, getTransferables(result) { // Mark array buffers as transferable to avoid cloning during postMessage - const transferables = [ - result.glyphPositions.buffer, - result.glyphIds.buffer - ] - if (result.caretPositions) { - transferables.push(result.caretPositions.buffer) - } - if (result.glyphColors) { - transferables.push(result.glyphColors.buffer) + const transferables = [] + for (let p in result) { + if (result[p] && result[p].buffer) { + transferables.push(result[p].buffer) + } } return transferables } diff --git a/packages/troika-three-text/src/Typesetter.js b/packages/troika-three-text/src/Typesetter.js index 91e1c22c..e3170a6b 100644 --- a/packages/troika-three-text/src/Typesetter.js +++ b/packages/troika-three-text/src/Typesetter.js @@ -41,7 +41,7 @@ export function createTypesetter(fontParser, bidi, config) { /** * Holds parsed font objects by url */ - const fonts = Object.create(null) + const parsedFonts = Object.create(null) const INF = Infinity @@ -101,7 +101,7 @@ export function createTypesetter(fontParser, bidi, config) { */ function loadFont(fontUrl, callback) { if (!fontUrl) fontUrl = defaultFontURL - let font = fonts[fontUrl] + let font = parsedFonts[fontUrl] if (font) { // if currently loading font, add to callbacks, otherwise execute immediately if (font.pending) { @@ -110,15 +110,105 @@ export function createTypesetter(fontParser, bidi, config) { callback(font) } } else { - fonts[fontUrl] = {pending: [callback]} + parsedFonts[fontUrl] = {pending: [callback]} doLoadFont(fontUrl, fontObj => { - let callbacks = fonts[fontUrl].pending - fonts[fontUrl] = fontObj + let callbacks = parsedFonts[fontUrl].pending + parsedFonts[fontUrl] = fontObj callbacks.forEach(cb => cb(fontObj)) }) } } + /** + * Inspect each character in a text string to determine which defined font will be used to render it, + * loading those fonts when necessary, then group them into consecutive runs of characters sharing a font. + * TODO: force whitespace characters to use the font of their preceding/surrounding characters? + */ + function calculateFontRuns(text, fontDefs, onDone) { + fontDefs = fontDefs.slice().reverse() // switch order for easier iteration + const fontsToLoad = new Set() + + // Array to store per-char font resolutions: + // - The first bit is a boolean for whether the font has been resolved (1) or not (0); when fully resolved + // every char will have a 1 in this position. + // - Bits 2-8 store the index of the resolved font, or the one currently/most recently being evaluated. This + // limits us to 127 possible fonts; if we ever have that many we probably want a better algorithm anyway. + const fontMap = new Uint8Array(text.length); + const knownMask = 0b10000000 + + function isCodeInRanges(code, ranges) { + // todo optimize search + for (let k = 0; k < ranges.length; k++) { + const [start, end = start] = ranges[k] + if (start <= code && code <= end) { + return true + } + } + return false + } + + function tryResolveChars() { + for (let i = 0, len = text.length; i < len; i++) { + const code = text.codePointAt(i) + if ((fontMap[i] & knownMask) === 0) { + for (let j = fontMap[i]; j < fontDefs.length; j++) { + fontMap[i] = j + const {src, unicodeRange} = fontDefs[j] + // if the font explicitly declares ranges that don't match, skip it + if (unicodeRange && !isCodeInRanges(code, unicodeRange)) { + continue + } + // font is loaded - if the font actually covers this char, or is the final fallback, + // lock it in, otherwise move on to the next candidate font + const fontObj = parsedFonts[src] + if (fontObj) { + if (j === fontDefs.length - 1 || fontObj.supportsCodePoint(code)) { + fontMap[i] |= knownMask; + break + } + // else continue to next font + } + // not yet loaded - check unicode ranges to see if we should try loading it + else { + fontsToLoad.add(fontDefs[j].src) + break + } + } + } + if (code > 0xffff) { + fontMap[i + 1] = fontMap[i] + i++ + } + } + // if we need to load more fonts to get a complete answer, wait for them and then retry + if (fontsToLoad.size) { + Promise.all( + [...fontsToLoad].map(src => new Promise(resolve => { + loadFont(src, resolve) + })) + ).then(tryResolveChars) + fontsToLoad.clear() + } + // all font mappings are known! collapse the full char mapping into runs of consecutive chars sharing a font + else { + let curRun, prevVal = null + const runs = [] + for (let i = 0; i < fontMap.length; i++) { + if (fontMap[i] !== prevVal && (i === 0 || !/\s/.test(text.charAt(i)))) { // start of a new run + const fontSrc = fontDefs[fontMap[i] ^ knownMask].src + prevVal = fontMap[i] + runs.push(curRun = { start: i, end: i, fontObj: parsedFonts[fontSrc], fontSrc }) + } else { + curRun.end = i + } + } + onDone(runs) + } + } + tryResolveChars() + } + + /** * Main entry point. @@ -164,9 +254,13 @@ export function createTypesetter(fontParser, bidi, config) { lineHeight = lineHeight || 'normal' textIndent = +textIndent - loadFont(font, fontObj => { + const fontDefs = typeof font === 'string' ? [{src: font}] : font + + calculateFontRuns(text, fontDefs, runs => { + timings.fontLoad = now() - mainStart const hasMaxWidth = isFinite(maxWidth) let glyphIds = null + let glyphFontIndices = null let glyphPositions = null let glyphData = null let glyphColors = null @@ -176,109 +270,160 @@ export function createTypesetter(fontParser, bidi, config) { let maxLineWidth = 0 let renderableGlyphCount = 0 let canWrap = whiteSpace !== 'nowrap' - const {ascender, descender, unitsPerEm, lineGap, capHeight, xHeight} = fontObj - timings.fontLoad = now() - mainStart + const metricsByFont = new Map() // fontObj -> metrics const typesetStart = now() - // Find conversion between native font units and fontSize units; this will already be done - // for the gx/gy values below but everything else we'll need to convert - const fontSizeMult = fontSize / unitsPerEm - - // Determine appropriate value for 'normal' line height based on the font's actual metrics - // TODO this does not guarantee individual glyphs won't exceed the line height, e.g. Roboto; should we use yMin/Max instead? - if (lineHeight === 'normal') { - lineHeight = (ascender - descender + lineGap) / unitsPerEm - } - - // Determine line height and leading adjustments - lineHeight = lineHeight * fontSize - const halfLeading = (lineHeight - (ascender - descender) * fontSizeMult) / 2 - const topBaseline = -(ascender * fontSizeMult + halfLeading) - const caretHeight = Math.min(lineHeight, (ascender - descender) * fontSizeMult) - const caretBottomOffset = (ascender + descender) / 2 * fontSizeMult - caretHeight / 2 - // Distribute glyphs into lines based on wrapping let lineXOffset = textIndent + let prevRunEndX = 0 let currentLine = new TextLine() const lines = [currentLine] - - fontObj.forEachGlyph(text, fontSize, letterSpacing, (glyphObj, glyphX, charIndex) => { - 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++ + runs.forEach(run => { + const { fontObj, fontSrc } = 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: fontSrc, + 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: (ascender + descender) / 2 * fontSizeMult + caretHeight / 2, + 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, 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 + // 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 } - break } } + if (nextLine) { + currentLine.isSoftWrapped = true + currentLine = nextLine + lines.push(currentLine) + maxLineWidth = maxWidth //after soft wrapping use maxWidth as calculated width + } } - if (nextLine) { - currentLine.isSoftWrapped = true - currentLine = nextLine + + let fly = currentLine.glyphAt(currentLine.count) + fly.glyphObj = glyphObj + fly.x = glyphX + lineXOffset + fly.width = glyphWidth + fly.charIndex = charIndex + fly.fontData = fontData + + // Handle hard line breaks + if (char === '\n') { + currentLine = new TextLine() lines.push(currentLine) - maxLineWidth = maxWidth //after soft wrapping use maxWidth as calculated width + lineXOffset = -(glyphX + glyphWidth + (letterSpacing * fontSize)) + textIndent } - } - - let fly = currentLine.glyphAt(currentLine.count) - fly.glyphObj = glyphObj - fly.x = glyphX + lineXOffset - fly.width = glyphWidth - fly.charIndex = charIndex - - // 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 of each line (excluding trailing whitespace) and maximum block width + // 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--;) { - let {glyphObj, x, width} = line.glyphAt(i) - if (!glyphObj.isWhitespace) { - line.width = x + width + 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 } - return + 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 @@ -302,15 +447,14 @@ export function createTypesetter(fontParser, bidi, config) { anchorYOffset = -anchorY } else if (typeof anchorY === 'string') { - let height = lines.length * lineHeight anchorYOffset = anchorY === 'top' ? 0 : - anchorY === 'top-baseline' ? -topBaseline : - anchorY === 'top-cap' ? -topBaseline - capHeight * fontSizeMult : - anchorY === 'top-ex' ? -topBaseline - xHeight * fontSizeMult : - anchorY === 'middle' ? height / 2 : - anchorY === 'bottom' ? height : - anchorY === 'bottom-baseline' ? height - halfLeading + descender * fontSizeMult : - parsePercent(anchorY) * height + 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 } } @@ -321,13 +465,13 @@ export function createTypesetter(fontParser, bidi, config) { // 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 = [] - let lineYOffset = topBaseline if (includeCaretPositions) { - caretPositions = new Float32Array(text.length * 3) + caretPositions = new Float32Array(text.length * 4) } if (colorRanges) { glyphColors = new Uint8Array(renderableGlyphCount * 3) @@ -413,7 +557,7 @@ export function createTypesetter(fontParser, bidi, config) { let glyphObj const setGlyphObj = g => glyphObj = g for (let i = 0; i < lineGlyphCount; i++) { - let glyphInfo = line.glyphAt(i) + const glyphInfo = line.glyphAt(i) glyphObj = glyphInfo.glyphObj const glyphId = glyphObj.index @@ -422,18 +566,19 @@ export function createTypesetter(fontParser, bidi, config) { if (rtl) { const mirrored = bidi.getMirroredCharacter(text[glyphInfo.charIndex]) if (mirrored) { - fontObj.forEachGlyph(mirrored, 0, 0, setGlyphObj) + glyphInfo.fontData.fontObj.forEachGlyph(mirrored, 0, 0, setGlyphObj) } } // Add caret positions if (includeCaretPositions) { - const {charIndex} = glyphInfo + const {charIndex, fontData} = glyphInfo const caretLeft = glyphInfo.x + anchorXOffset const caretRight = glyphInfo.x + glyphInfo.width + anchorXOffset - caretPositions[charIndex * 3] = rtl ? caretRight : caretLeft //start edge x - caretPositions[charIndex * 3 + 1] = rtl ? caretLeft : caretRight //end edge x - caretPositions[charIndex * 3 + 2] = lineYOffset + caretBottomOffset + anchorYOffset //common bottom y + 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 @@ -460,10 +605,12 @@ export function createTypesetter(fontParser, bidi, config) { // 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 - if (!glyphData[glyphId]) { - glyphData[glyphId] = { + const fontGlyphData = glyphData[fontSrc] || (glyphData[fontSrc] = {}) + if (!fontGlyphData[glyphId]) { + fontGlyphData[glyphId] = { path: glyphObj.path, pathBounds: [glyphObj.xMin, glyphObj.yMin, glyphObj.xMax, glyphObj.yMax] } @@ -471,7 +618,7 @@ export function createTypesetter(fontParser, bidi, config) { // Determine final glyph position and add to glyphPositions array const glyphX = glyphInfo.x + anchorXOffset - const glyphY = lineYOffset + anchorYOffset + const glyphY = line.baseline + anchorYOffset glyphPositions[idx * 2] = glyphX glyphPositions[idx * 2 + 1] = glyphY @@ -497,8 +644,9 @@ export function createTypesetter(fontParser, bidi, config) { if (visX1 > chunkRect[2]) chunkRect[2] = visX1 if (visY1 > chunkRect[3]) chunkRect[3] = visY1 - // Add to glyph ids array + // Add to glyph ids and font indices arrays glyphIds[idx] = glyphId + glyphFontIndices[idx] = fontIndex // Add colors if (colorRanges) { @@ -510,9 +658,6 @@ export function createTypesetter(fontParser, bidi, config) { } } } - - // Increment y offset for next line - lineYOffset -= lineHeight }) // Fill in remaining caret positions in case the final character was a ligature @@ -524,28 +669,30 @@ export function createTypesetter(fontParser, bidi, config) { } } + // 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, //font indices for each glyph + 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 + // 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 - unitsPerEm, //font units per em - ascender: ascender * fontSizeMult, //font ascender - descender: descender * fontSizeMult, //font descender - capHeight: capHeight * fontSizeMult, //font cap-height - xHeight: xHeight * fontSizeMult, //font x-height - lineHeight, //computed line height - topBaseline, //y coordinate of the top line's baseline + 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 - lines.length * lineHeight, + anchorYOffset - totalHeight, anchorXOffset + maxLineWidth, anchorYOffset ], @@ -579,15 +726,17 @@ export function createTypesetter(fontParser, bidi, config) { } function fillLigatureCaretPositions(caretPositions, ligStartIndex, ligCount) { - const ligStartX = caretPositions[ligStartIndex * 3] - const ligEndX = caretPositions[ligStartIndex * 3 + 1] - const ligY = caretPositions[ligStartIndex * 3 + 2] + 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) * 3 + const startIndex = (ligStartIndex + i) * 4 caretPositions[startIndex] = ligStartX + guessedAdvanceX * i caretPositions[startIndex + 1] = ligStartX + guessedAdvanceX * (i + 1) - caretPositions[startIndex + 2] = ligY + caretPositions[startIndex + 2] = ligBottom + caretPositions[startIndex + 3] = ligTop } } @@ -599,9 +748,13 @@ export function createTypesetter(fontParser, bidi, config) { function TextLine() { this.data = [] } - const textLineProps = ['glyphObj', 'x', 'width', 'charIndex'] + const textLineProps = ['glyphObj', 'x', '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) diff --git a/packages/troika-three-text/src/fonts.js b/packages/troika-three-text/src/fonts.js new file mode 100644 index 00000000..fee29095 --- /dev/null +++ b/packages/troika-three-text/src/fonts.js @@ -0,0 +1,61 @@ +const prefix = 'https://fonts.gstatic.com/s/' + +/** + * A set of Google-hosted fonts by unicode range that will serve as defaults for when a user-defined + * font is not supplied or does not support certain characters in the user's text. + */ +export const fallbackFonts = [ + { + label: 'catchall', + src: `${prefix}roboto/v29/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff`, + }, + { + label: 'arabic', + // src: `${prefix}scheherazadenew/v8/4UaZrFhTvxVnHDvUkUiHg8jprP4DOwFmPXwq9IqeuA.woff`, + src: `${prefix}notosansarabic/v14/nwpxtLGrOAZMl5nJ_wfgRg3DrWFZWsnVBJ_sS6tlqHHFlhQ5l3sQWIHPqzCfyGyfuXqGNwfKi3ZU.woff`, + unicodeRange: 'U+0600-06FF,U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE80-FEFC', + }, + { + label: 'cyrillic-ext', + src: `${prefix}roboto/v29/KFOmCnqEu92Fr1Mu72xMKTU1Kvnz.woff`, + unicodeRange: 'U+0460-052F,U+1C80-1C88,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F' + }, + { + label: 'cyrillic', + src: `${prefix}roboto/v29/KFOmCnqEu92Fr1Mu5mxMKTU1Kvnz.woff`, + unicodeRange: 'U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116' + }, + { + label: 'greek-ext', + src: `${prefix}roboto/v29/KFOmCnqEu92Fr1Mu7mxMKTU1Kvnz.woff`, + unicodeRange: 'U+1F00-1FFF' + }, + { + label: 'greek', + src: `${prefix}roboto/v29/KFOmCnqEu92Fr1Mu4WxMKTU1Kvnz.woff`, + unicodeRange: 'U+0370-03FF' + }, + { + label: 'vietnamese', + src: `${prefix}roboto/v29/KFOmCnqEu92Fr1Mu7WxMKTU1Kvnz.woff`, + unicodeRange: 'U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+1EA0-1EF9,U+20AB' + }, + { + label: 'latin-ext', + src: `${prefix}roboto/v29/KFOmCnqEu92Fr1Mu7GxMKTU1Kvnz.woff`, + unicodeRange: 'U+0100-024F,U+0259,U+1E00-1EFF,U+2020,U+20A0-20AB,U+20AD-20CF,U+2113,U+2C60-2C7F,U+A720-A7FF' + }, + { + label: 'latin', + src: `${prefix}roboto/v29/KFOmCnqEu92Fr1Mu4mxMKTU1Kg.woff`, + unicodeRange: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD' + }, +] + +fallbackFonts.forEach(d => { + d.unicodeRange = unicodeRangeStringToArray(d.unicodeRange) +}) + +function unicodeRangeStringToArray(str) { + return str && str.replace(/U\+/g, '').split(/\s*,\s*/g).map(part => part.split('-').map(n => parseInt(n, 16))) +} diff --git a/packages/troika-three-text/src/selectionUtils.js b/packages/troika-three-text/src/selectionUtils.js index 8e00bf3c..df1de008 100644 --- a/packages/troika-three-text/src/selectionUtils.js +++ b/packages/troika-three-text/src/selectionUtils.js @@ -18,19 +18,18 @@ */ export function getCaretAtPoint(textRenderInfo, x, y) { let closestCaret = null - const {caretHeight} = textRenderInfo - const caretsByRow = groupCaretsByRow(textRenderInfo) + const rows = groupCaretsByRow(textRenderInfo) // Find nearest row by y first - let closestRowY = Infinity - caretsByRow.forEach((carets, rowY) => { - if (Math.abs(y - (rowY + caretHeight / 2)) < Math.abs(y - (closestRowY + caretHeight / 2))) { - closestRowY = rowY + 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 - caretsByRow.get(closestRowY).forEach(caret => { + closestRow.carets.forEach(caret => { if (!closestCaret || Math.abs(x - caret.x) < Math.abs(x - closestCaret.x)) { closestCaret = caret } @@ -58,7 +57,7 @@ export function getSelectionRects(textRenderInfo, start, end) { return prevResult.rects } - const {caretPositions, caretHeight} = textRenderInfo + const {caretPositions} = textRenderInfo // Normalize if (end < start) { @@ -74,17 +73,18 @@ export function getSelectionRects(textRenderInfo, start, end) { rects = [] let currentRect = null for (let i = start; i < end; i++) { - const x1 = caretPositions[i * 3] - const x2 = caretPositions[i * 3 + 1] + 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 * 3 + 2] - if (!currentRect || bottom !== currentRect.bottom || left > currentRect.right || right < currentRect.left) { + 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: bottom, - top: bottom + caretHeight + bottom, + top, } rects.push(currentRect) } @@ -97,7 +97,7 @@ export function getSelectionRects(textRenderInfo, start, end) { for (let i = rects.length - 1; i-- > 0;) { const rectA = rects[i] const rectB = rects[i + 1] - if (rectA.bottom === rectB.bottom && rectA.left <= rectB.right && rectA.right >= rectB.left) { + 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) @@ -111,35 +111,43 @@ export function getSelectionRects(textRenderInfo, start, end) { 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 caretsByRow = _caretsByRowCache.get(textRenderInfo) - if (!caretsByRow) { - const {caretPositions, caretHeight} = textRenderInfo - caretsByRow = new Map() - for (let i = 0; i < caretPositions.length; i += 3) { - const rowY = caretPositions[i + 2] - let rowCarets = caretsByRow.get(rowY) - if (!rowCarets) { - caretsByRow.set(rowY, rowCarets = []) + 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: []}) } - rowCarets.push({ - x: caretPositions[i], - y: rowY, - height: caretHeight, - charIndex: i / 3 + // 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, }) - // Add one more caret after the final char - if (i + 3 >= caretPositions.length) { - rowCarets.push({ - x: caretPositions[i + 1], - y: rowY, - height: caretHeight, - charIndex: i / 3 + 1 - }) - } } + + 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, caretsByRow) - return caretsByRow + _caretsByRowCache.set(textRenderInfo, rows) + return rows }