@lightningtv/renderer
Version:
Lightning 3 Renderer
308 lines • 14.6 kB
JavaScript
/*
* If not stated otherwise in this file or this component's LICENSE file the
* following copyright and licenses apply:
*
* Copyright 2023 Comcast Cable Communications Management, LLC.
*
* 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.
*/
import { assertTruthy } from '../../../../../utils.js';
import { PeekableIterator } from './PeekableGenerator.js';
import { getUnicodeCodepoints } from './getUnicodeCodepoints.js';
import { measureText } from './measureText.js';
export function layoutText(curLineIndex, startX, startY, text, textAlign, width, height, fontSize, lineHeight, letterSpacing,
/**
* Mutated
*/
vertexBuffer, contain,
/**
* Mutated
*/
lineCache, rwSdf, trFontFace, forceFullLayoutCalc, scrollable, overflowSuffix, maxLines) {
assertTruthy(trFontFace, 'Font face must be loaded');
assertTruthy(trFontFace.loaded, 'Font face must be loaded');
assertTruthy(trFontFace.data, 'Font face must be loaded');
assertTruthy(trFontFace.shaper, 'Font face must be loaded');
// Regardless of fontSize (or other scaling properties), we layout the vertices of each glyph
// using the fixed coordinate space determined by font size used to produce the atlas.
// Scaling for display is handled by shader uniforms inexpensively.
// So we have:
// - vertex space: the space in which the vertices of each glyph are laid out
// - screen space: the screen pixel space
// Input properties such as x, y, w, fontSize, letterSpacing, etc. are all expressed in screen space.
// We convert these to the vertex space by dividing them the `fontSizeRatio` factor.
/**
* See above
*/
const fontSizeRatio = fontSize / trFontFace.data.info.size;
/**
* `lineHeight` in vertex coordinates
*/
const vertexLineHeight = lineHeight / fontSizeRatio;
/**
* `w` in vertex coordinates
*/
const vertexW = width / fontSizeRatio;
/**
* `letterSpacing` in vertex coordinates
*/
const vertexLSpacing = letterSpacing / fontSizeRatio;
const startingLineCacheEntry = lineCache[curLineIndex];
const startingCodepointIndex = startingLineCacheEntry?.codepointIndex || 0;
const startingMaxX = startingLineCacheEntry?.maxX || 0;
const startingMaxY = startingLineCacheEntry?.maxY || 0;
let maxX = startingMaxX;
let maxY = startingMaxY;
let curX = startX;
let curY = startY;
let bufferOffset = 0;
/**
* Buffer offset to last word boundry. This is -1 when we aren't in a word boundry.
*/
const lastWord = {
codepointIndex: -1,
bufferOffset: -1,
xStart: -1,
};
const shaper = trFontFace.shaper;
const shaperProps = {
letterSpacing: vertexLSpacing,
};
// HACK: The space is used as a word boundary. When a text ends with a space, we need to
// add an extra space to ensure the space is included in the line width calculation.
if (text.endsWith(' ')) {
text += ' ';
}
// Get glyphs
let glyphs = shaper.shapeText(shaperProps, new PeekableIterator(getUnicodeCodepoints(text, startingCodepointIndex), startingCodepointIndex));
let glyphResult;
let curLineBufferStart = -1;
const bufferLineInfos = [];
const vertexTruncateHeight = height / fontSizeRatio;
const overflowSuffVertexWidth = measureText(overflowSuffix, shaperProps, shaper);
// Line-by-line layout
let moreLines = true;
while (moreLines) {
const nextLineWillFit = (maxLines === 0 || curLineIndex + 1 < maxLines) &&
(contain !== 'both' ||
scrollable ||
curY + vertexLineHeight + trFontFace.maxCharHeight <=
vertexTruncateHeight);
const lineVertexW = nextLineWillFit
? vertexW
: vertexW - overflowSuffVertexWidth;
/**
* Vertex X position to the beginning of the last word boundary. This becomes -1 when we start traversing a word.
*/
let xStartLastWordBoundary = 0;
const lineIsBelowWindowTop = curY + trFontFace.maxCharHeight >= rwSdf.y1;
const lineIsAboveWindowBottom = curY <= rwSdf.y2;
const lineIsWithinWindow = lineIsBelowWindowTop && lineIsAboveWindowBottom;
// Layout glyphs in this line
// Any break statements in this while loop will trigger a line break
while ((glyphResult = glyphs.next()) && !glyphResult.done) {
const glyph = glyphResult.value;
if (curLineIndex === lineCache.length) {
lineCache.push({
codepointIndex: glyph.cluster,
maxY,
maxX,
});
}
else if (curLineIndex > lineCache.length) {
throw new Error('Unexpected lineCache length');
}
// If we encounter a word boundary (white space or newline) we invalidate
// the lastWord and set the xStartLastWordBoundary if we haven't already.
if (glyph.codepoint === 32 ||
glyph.codepoint === 10 ||
glyph.codepoint === 8203) {
if (lastWord.codepointIndex !== -1) {
lastWord.codepointIndex = -1;
xStartLastWordBoundary = curX;
}
}
else if (lastWord.codepointIndex === -1) {
lastWord.codepointIndex = glyph.cluster;
lastWord.bufferOffset = bufferOffset;
lastWord.xStart = xStartLastWordBoundary;
}
if (glyph.mapped) {
// Mapped glyph
const charEndX = curX + glyph.xOffset + glyph.width;
// Word wrap check
if (
// We are containing the text
contain !== 'none' &&
// The current glyph reaches outside the contained width
charEndX >= lineVertexW &&
// There is a last word that we can break to the next line
lastWord.codepointIndex !== -1 &&
// Prevents infinite loop when a single word is longer than the width
lastWord.xStart > 0) {
// The current word is about to go off the edge of the container width
// Reinitialize the iterator starting at the last word
// and proceeding to the next line
if (nextLineWillFit) {
glyphs = shaper.shapeText(shaperProps, new PeekableIterator(getUnicodeCodepoints(text, lastWord.codepointIndex), lastWord.codepointIndex));
bufferOffset = lastWord.bufferOffset;
break;
}
else {
glyphs = shaper.shapeText(shaperProps, new PeekableIterator(getUnicodeCodepoints(overflowSuffix, 0), 0));
curX = lastWord.xStart;
bufferOffset = lastWord.bufferOffset;
// HACK: For the rest of the line when inserting the overflow suffix,
// set contain = 'none' to prevent an infinite loop.
contain = 'none';
}
}
else {
// This glyph fits, so we can add it to the buffer
const quadX = curX + glyph.xOffset;
const quadY = curY + glyph.yOffset;
// Only add to buffer for rendering if the line is within the render window
if (lineIsWithinWindow) {
if (curLineBufferStart === -1) {
curLineBufferStart = bufferOffset;
}
const atlasEntry = trFontFace.getAtlasEntry(glyph.glyphId);
// Add texture coordinates
const u = atlasEntry.x / trFontFace.data.common.scaleW;
const v = atlasEntry.y / trFontFace.data.common.scaleH;
const uvWidth = atlasEntry.width / trFontFace.data.common.scaleW;
const uvHeight = atlasEntry.height / trFontFace.data.common.scaleH;
// TODO: (Performance) We can optimize this by using ELEMENT_ARRAY_BUFFER
// eliminating the need to duplicate vertices
// Top-left
vertexBuffer[bufferOffset++] = quadX;
vertexBuffer[bufferOffset++] = quadY;
vertexBuffer[bufferOffset++] = u;
vertexBuffer[bufferOffset++] = v;
// Top-right
vertexBuffer[bufferOffset++] = quadX + glyph.width;
vertexBuffer[bufferOffset++] = quadY;
vertexBuffer[bufferOffset++] = u + uvWidth;
vertexBuffer[bufferOffset++] = v;
// Bottom-left
vertexBuffer[bufferOffset++] = quadX;
vertexBuffer[bufferOffset++] = quadY + glyph.height;
vertexBuffer[bufferOffset++] = u;
vertexBuffer[bufferOffset++] = v + uvHeight;
// Bottom-right
vertexBuffer[bufferOffset++] = quadX + glyph.width;
vertexBuffer[bufferOffset++] = quadY + glyph.height;
vertexBuffer[bufferOffset++] = u + uvWidth;
vertexBuffer[bufferOffset++] = v + uvHeight;
}
maxY = Math.max(maxY, quadY + glyph.height);
maxX = Math.max(maxX, quadX + glyph.width);
curX += glyph.xAdvance;
}
}
else {
// Unmapped character
// Handle newlines
if (glyph.codepoint === 10) {
if (nextLineWillFit) {
// The whole line fit, so we can break to the next line
break;
}
else {
// The whole line won't fit, so we need to add the overflow suffix
glyphs = shaper.shapeText(shaperProps, new PeekableIterator(getUnicodeCodepoints(overflowSuffix, 0), 0));
// HACK: For the rest of the line when inserting the overflow suffix,
// set contain = 'none' to prevent an infinite loop.
contain = 'none';
}
}
}
}
// Prepare for the next line...
if (curLineBufferStart !== -1) {
bufferLineInfos.push({
bufferStart: curLineBufferStart,
bufferEnd: bufferOffset,
});
curLineBufferStart = -1;
}
curX = 0;
curY += vertexLineHeight;
curLineIndex++;
lastWord.codepointIndex = -1;
xStartLastWordBoundary = 0;
// Figure out if there are any more lines to render...
if (!forceFullLayoutCalc && contain === 'both' && curY > rwSdf.y2) {
// Stop layout calculation early (for performance purposes) if:
// - We're not forcing a full layout calculation (for width/height calculation)
// - ...and we're containing the text vertically+horizontally (contain === 'both')
// - ...and we have a render window
// - ...and the next line is below the bottom of the render window
moreLines = false;
}
else if (glyphResult && glyphResult.done) {
// If we've reached the end of the text, we know we're done
moreLines = false;
}
else if (!nextLineWillFit) {
// If we're contained vertically+horizontally (contain === 'both')
// but not scrollable and the next line won't fit, we're done.
moreLines = false;
}
}
// Use textAlign to determine if we need to adjust the x position of the text
// in the buffer line by line
if (textAlign === 'center') {
const vertexTextW = contain === 'none' ? maxX : vertexW;
for (let i = 0; i < bufferLineInfos.length; i++) {
const line = bufferLineInfos[i];
// - 4 = the x position of a rightmost vertex
const lineWidth =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
vertexBuffer[line.bufferEnd - 4] - vertexBuffer[line.bufferStart];
const xOffset = (vertexTextW - lineWidth) / 2;
for (let j = line.bufferStart; j < line.bufferEnd; j += 4) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
vertexBuffer[j] += xOffset;
}
}
}
else if (textAlign === 'right') {
const vertexTextW = contain === 'none' ? maxX : vertexW;
for (let i = 0; i < bufferLineInfos.length; i++) {
const line = bufferLineInfos[i];
const lineWidth = line.bufferEnd === line.bufferStart
? 0
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
vertexBuffer[line.bufferEnd - 4] - vertexBuffer[line.bufferStart];
const xOffset = vertexTextW - lineWidth;
for (let j = line.bufferStart; j < line.bufferEnd; j += 4) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
vertexBuffer[j] += xOffset;
}
}
}
assertTruthy(glyphResult);
return {
bufferNumFloats: bufferOffset,
bufferNumQuads: bufferOffset / 16,
layoutNumCharacters: glyphResult.done
? text.length - startingCodepointIndex
: glyphResult.value.cluster - startingCodepointIndex + 1,
fullyProcessed: !!glyphResult.done,
maxX,
maxY,
numLines: lineCache.length,
};
}
//# sourceMappingURL=layoutText.js.map