diff --git a/examples/tests/text-line-height.ts b/examples/tests/text-line-height.ts index 34c37da7..b707c820 100644 --- a/examples/tests/text-line-height.ts +++ b/examples/tests/text-line-height.ts @@ -37,7 +37,7 @@ export default async function test(settings: ExampleSettings) { const pageContainer = new PageContainer(settings, { width: renderer.settings.appWidth, height: renderer.settings.appHeight, - title: 'Text LineHeight', + title: 'Text Line Height', }); await paginateTestRows(pageContainer, [ @@ -49,10 +49,11 @@ export default async function test(settings: ExampleSettings) { } const NODE_PROPS = { - x: 100, - y: 100, + x: 90, + y: 90, + mount: 0.5, color: 0x000000ff, - text: 'txyz', + text: 'abcd\ntxyz', fontFamily: 'Ubuntu', textRendererOverride: 'sdf', fontSize: 50, @@ -64,8 +65,8 @@ function generateLineHeightTest( ): TestRow[] { return [ { - title: `Text Node ('lineHeight', ${textRenderer})${ - textRenderer === 'sdf' ? ', "BROKEN!"' : '' + title: `Text Node ('lineHeight', ${textRenderer}, fontSize=50)${ + textRenderer === 'canvas' ? ', "BROKEN!"' : '' }`, content: async (rowNode) => { const nodeProps = { @@ -76,38 +77,47 @@ function generateLineHeightTest( const baselineNode = renderer.createTextNode({ ...nodeProps, }); - const dimensions = await waitForTextDimensions(baselineNode); + // const dimensions = await waitForTextDimensions(baselineNode); - // Get the position for the center of the container based on mount = 0 - const position = { - x: 100 - dimensions.width / 2, - y: 100 - dimensions.height / 2, - }; + // // Get the position for the center of the container based on mount = 0 + // const position = { + // y: 100 - dimensions.height / 2, + // }; - baselineNode.x = position.x; - baselineNode.y = position.y; + // baselineNode.y = position.y; - return await constructTestRow({ renderer, rowNode }, [ - baselineNode, - 'lineHeight 50 ->', - renderer.createTextNode({ - ...nodeProps, - ...position, - lineHeight: 50, - }), - 'lineHeight 60 ->', - renderer.createTextNode({ - ...nodeProps, - ...position, - lineHeight: 60, - }), - 'lineHeight 70 ->', - renderer.createTextNode({ - ...nodeProps, - ...position, - lineHeight: 70, - }), - ]); + return await constructTestRow( + { renderer, rowNode, containerSize: 180 }, + [ + 'lineHeight: fontSize\n(default)\n->', + baselineNode, + '60 ->', + renderer.createTextNode({ + ...nodeProps, + lineHeight: 60, + }), + '70 ->', + renderer.createTextNode({ + ...nodeProps, + lineHeight: 70, + }), + '25 ->', + renderer.createTextNode({ + ...nodeProps, + lineHeight: 25, + }), + '10 ->', + renderer.createTextNode({ + ...nodeProps, + lineHeight: 10, + }), + '0 ->', + renderer.createTextNode({ + ...nodeProps, + lineHeight: 0, + }), + ], + ); }, }, ] satisfies TestRow[]; diff --git a/examples/tests/text-max-lines.ts b/examples/tests/text-max-lines.ts index ee8acd39..4f579134 100644 --- a/examples/tests/text-max-lines.ts +++ b/examples/tests/text-max-lines.ts @@ -42,18 +42,18 @@ export default async function test(settings: ExampleSettings) { await paginateTestRows(pageContainer, [ ...generateMaxLinesTest(renderer, 'sdf'), + null, ...generateMaxLinesTest(renderer, 'canvas'), ]); return pageContainer; } -const NODE_PROPS = { +const BASE_NODE_PROPS = { x: 100, y: 100, width: 200, color: 0x000000ff, - text: getLoremIpsum(100), fontFamily: 'Ubuntu', textRendererOverride: 'sdf', fontSize: 20, @@ -67,12 +67,11 @@ function generateMaxLinesTest( ): TestRow[] { return [ { - title: `Text Node ('maxLines', ${textRenderer})${ - textRenderer === 'sdf' ? ', "BROKEN!"' : '' - }`, + title: `Wrapped + Explicit Lines ('maxLines', ${textRenderer})`, content: async (rowNode) => { const nodeProps = { - ...NODE_PROPS, + ...BASE_NODE_PROPS, + text: 'Line1 Line1_Line1_Line1\nLine2 Line2____Line2\nLine 3\nLine 4', textRendererOverride: textRenderer, } satisfies Partial; @@ -96,18 +95,86 @@ function generateMaxLinesTest( ...position, maxLines: 1, }), - 'maxLines: 2 ->', + '2 ->', renderer.createTextNode({ ...nodeProps, ...position, maxLines: 2, }), - 'maxLines: 3 ->', + '3 ->', renderer.createTextNode({ ...nodeProps, ...position, maxLines: 3, }), + '4 ->', + renderer.createTextNode({ + ...nodeProps, + ...position, + maxLines: 4, + }), + '5 ->', + renderer.createTextNode({ + ...nodeProps, + ...position, + maxLines: 5, + }), + ]); + }, + }, + { + title: `Lorem Ipsum ('maxLines', ${textRenderer})`, + content: async (rowNode) => { + const nodeProps = { + ...BASE_NODE_PROPS, + text: getLoremIpsum(100), + textRendererOverride: textRenderer, + } satisfies Partial; + + const baselineNode = renderer.createTextNode({ + ...nodeProps, + }); + + const position = { + x: 0, + y: 0, + }; + + baselineNode.x = position.x; + baselineNode.y = position.y; + + return await constructTestRow({ renderer, rowNode }, [ + baselineNode, + 'maxLines: 1 ->', + renderer.createTextNode({ + ...nodeProps, + ...position, + maxLines: 1, + }), + '2 ->', + renderer.createTextNode({ + ...nodeProps, + ...position, + maxLines: 2, + }), + '3 ->', + renderer.createTextNode({ + ...nodeProps, + ...position, + maxLines: 3, + }), + '4 ->', + renderer.createTextNode({ + ...nodeProps, + ...position, + maxLines: 4, + }), + '5 ->', + renderer.createTextNode({ + ...nodeProps, + ...position, + maxLines: 5, + }), ]); }, }, diff --git a/examples/tests/text-overflow-suffix.ts b/examples/tests/text-overflow-suffix.ts index 7614c2ca..415179b4 100644 --- a/examples/tests/text-overflow-suffix.ts +++ b/examples/tests/text-overflow-suffix.ts @@ -52,13 +52,14 @@ const NODE_PROPS = { x: 100, y: 100, width: 200, + height: 200, color: 0x000000ff, text: getLoremIpsum(100), fontFamily: 'Ubuntu', textRendererOverride: 'sdf', fontSize: 20, lineHeight: 28, - contain: 'width', + contain: 'both', } satisfies Partial; function generateOverflowSuffixTest( @@ -67,9 +68,7 @@ function generateOverflowSuffixTest( ): TestRow[] { return [ { - title: `Text Node ('overflowSuffix', ${textRenderer})${ - textRenderer === 'sdf' ? ', "BROKEN!"' : '' - }`, + title: `Text Node ('overflowSuffix', ${textRenderer})`, content: async (rowNode) => { const nodeProps = { ...NODE_PROPS, diff --git a/examples/tests/text-vertical-align.ts b/examples/tests/text-vertical-align.ts index 55dd16cb..86c6ec1a 100644 --- a/examples/tests/text-vertical-align.ts +++ b/examples/tests/text-vertical-align.ts @@ -42,6 +42,7 @@ export default async function test(settings: ExampleSettings) { await paginateTestRows(pageContainer, [ ...generateVerticalAlignTest(renderer, 'sdf'), + null, ...generateVerticalAlignTest(renderer, 'canvas'), ]); @@ -49,10 +50,7 @@ export default async function test(settings: ExampleSettings) { } const NODE_PROPS = { - x: 100, - y: 100, color: 0x000000ff, - text: 'txyz', fontFamily: 'Ubuntu', textRendererOverride: 'sdf', fontSize: 50, @@ -65,47 +63,58 @@ function generateVerticalAlignTest( ): TestRow[] { return [ { - title: `Text Node ('verticalAlign', ${textRenderer}), lineHeight = 70${ - textRenderer === 'sdf' ? ', "BROKEN!"' : '' - }`, + title: `One Line ('verticalAlign', ${textRenderer}, fontSize = 50, lineHeight = 70)`, content: async (rowNode) => { const nodeProps = { ...NODE_PROPS, + text: 'txyz', textRendererOverride: textRenderer, } satisfies Partial; const baselineNode = renderer.createTextNode({ ...nodeProps, }); - const dimensions = await waitForTextDimensions(baselineNode); - - // Get the position for the center of the container based on mount = 0 - const position = { - x: 100 - dimensions.width / 2, - y: 100 - dimensions.height / 2, - }; - - baselineNode.x = position.x; - baselineNode.y = position.y; return await constructTestRow({ renderer, rowNode }, [ + 'verticalAlign: top\n(default)\n->', baselineNode, - 'verticalAlign: top ->', + 'middle ->', + renderer.createTextNode({ + ...nodeProps, + verticalAlign: 'middle', + }), + 'bottom ->', renderer.createTextNode({ ...nodeProps, - ...position, - verticalAlign: 'top', + verticalAlign: 'bottom', }), - 'verticalAlign: middle ->', + ]); + }, + }, + { + title: `Two Lines ('verticalAlign', ${textRenderer}, fontSize = 50, lineHeight = 70)`, + content: async (rowNode) => { + const nodeProps = { + ...NODE_PROPS, + text: 'abcd\ntxyz', + textRendererOverride: textRenderer, + } satisfies Partial; + + const baselineNode = renderer.createTextNode({ + ...nodeProps, + }); + + return await constructTestRow({ renderer, rowNode }, [ + 'verticalAlign: top\n(default)\n->', + baselineNode, + 'middle ->', renderer.createTextNode({ ...nodeProps, - ...position, verticalAlign: 'middle', }), - 'verticalAlign: bottom ->', + 'bottom ->', renderer.createTextNode({ ...nodeProps, - ...position, verticalAlign: 'bottom', }), ]); diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts b/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts index 706ccaca..a60c7091 100644 --- a/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts +++ b/src/core/text-rendering/renderers/SdfTextRenderer/SdfTextRenderer.ts @@ -23,6 +23,8 @@ import { type Rect, createBound, type BoundWithValid, + intersectRect, + isBoundPositive, } from '../../../lib/utils.js'; import { TextRenderer, @@ -35,7 +37,10 @@ import { SdfTrFontFace } from '../../font-face-types/SdfTrFontFace/SdfTrFontFace import { FLOATS_PER_GLYPH } from './internal/constants.js'; import { getStartConditions } from './internal/getStartConditions.js'; import { layoutText } from './internal/layoutText.js'; -import { makeRenderWindow } from './internal/makeRenderWindow.js'; +import { + setRenderWindow, + type SdfRenderWindow, +} from './internal/setRenderWindow.js'; import type { TrFontFace } from '../../font-face-types/TrFontFace.js'; import { TrFontManager, type FontFamilyMap } from '../../TrFontManager.js'; import { assertTruthy, mergeColorAlpha } from '../../../../utils.js'; @@ -77,7 +82,7 @@ export interface SdfTextRendererState extends TextRendererState { */ lineCache: LineCacheItem[]; - renderWindow: Bound | undefined; + renderWindow: SdfRenderWindow; visibleWindow: BoundWithValid; @@ -282,7 +287,23 @@ export class SdfTextRenderer extends TextRenderer { emitter: new EventEmitter(), lineCache: [], forceFullLayoutCalc: false, - renderWindow: undefined, + renderWindow: { + screen: { + x1: 0, + y1: 0, + x2: 0, + y2: 0, + }, + sdf: { + x1: 0, + y1: 0, + x2: 0, + y2: 0, + }, + firstLineIdx: 0, + numLines: 0, + valid: false, + }, visibleWindow: { x1: 0, y1: 0, @@ -345,22 +366,39 @@ export class SdfTextRenderer extends TextRenderer { // If the font is loaded then so should the data assertTruthy(trFontFace.data, 'Font face data should be loaded'); - const { text, fontSize, x, y, contain, width, height, scrollable } = - state.props; + const { + text, + fontSize, + x, + y, + contain, + width, + height, + lineHeight, + verticalAlign, + scrollable, + overflowSuffix, + maxLines, + } = state.props; // scrollY only has an effect when contain === 'both' and scrollable === true const scrollY = contain === 'both' && scrollable ? state.props.scrollY : 0; - let { renderWindow } = state; + const { renderWindow } = state; - // Needed in renderWindow calculation - const sdfLineHeight = trFontFace.data.info.size; + /** + * The font size of the SDF font face (the basis for SDF space units) + */ + const sdfFontSize = trFontFace.data.info.size; /** - * Divide screen space points by this to get the SDF space points - * Mulitple SDF space points by this to get screen space points + * Divide screen space units by this to get the SDF space units + * Mulitple SDF space units by this to get screen space units */ - const fontSizeRatio = fontSize / sdfLineHeight; + const fontSizeRatio = fontSize / sdfFontSize; + + // Needed in renderWindow calculation + const sdfLineHeight = lineHeight / fontSizeRatio; state.distanceRange = fontSizeRatio * trFontFace.data.distanceField.distanceRange; @@ -393,44 +431,46 @@ export class SdfTextRenderer extends TextRenderer { // Return early if we're still viewing inside the established render window // No need to re-render what we've already rendered // (Only if there's an established renderWindow and we're not suppressing early exit) - if (!forceFullLayoutCalc && renderWindow) { + if (!forceFullLayoutCalc && renderWindow.valid) { + const rwScreen = renderWindow.screen; if ( - x + renderWindow.x1 <= visibleWindow.x1 && - x + renderWindow.x2 >= visibleWindow.x2 && - y - scrollY + renderWindow.y1 <= visibleWindow.y1 && - y - scrollY + renderWindow.y2 >= visibleWindow.y2 + x + rwScreen.x1 <= visibleWindow.x1 && + x + rwScreen.x2 >= visibleWindow.x2 && + y - scrollY + rwScreen.y1 <= visibleWindow.y1 && + y - scrollY + rwScreen.y2 >= visibleWindow.y2 ) { this.setStatus(state, 'loaded'); return; } - // Otherwise clear the renderWindow so it can be redone - renderWindow = state.renderWindow = undefined; + // Otherwise invalidate the renderWindow so it can be redone + renderWindow.valid = false; this.setStatus(state, 'loading'); } const { offsetY, textAlign } = state.props; // Create a new renderWindow if needed - if (!renderWindow) { - const visibleWindowHeight = visibleWindow.y2 - visibleWindow.y1; - const maxLinesPerCanvasPage = Math.ceil( - visibleWindowHeight / sdfLineHeight, - ); - renderWindow = makeRenderWindow( + if (!renderWindow.valid) { + setRenderWindow( + renderWindow, x, y, scrollY, - sdfLineHeight, - maxLinesPerCanvasPage, + lineHeight, + visibleWindow.y2 - visibleWindow.y1, visibleWindow, + fontSizeRatio, ); + // console.log('newRenderWindow', renderWindow); } const start = getStartConditions( - fontSize, + sdfFontSize, + sdfLineHeight, + lineHeight, + verticalAlign, offsetY, fontSizeRatio, - sdfLineHeight, renderWindow, lineCache, textH, @@ -447,21 +487,24 @@ export class SdfTextRenderer extends TextRenderer { const out2 = layoutText( start.lineIndex, - start.x, - start.y, + start.sdfX, + start.sdfY, text, textAlign, width, height, fontSize, + lineHeight, letterSpacing, vertexBuffer, contain, lineCache, - renderWindow, + renderWindow.sdf, trFontFace, forceFullLayoutCalc, scrollable, + overflowSuffix, + maxLines, ); state.bufferUploaded = false; @@ -496,14 +539,8 @@ export class SdfTextRenderer extends TextRenderer { return; } - const drawStartTime = performance.now(); - - const { sdfShader } = this; - const { renderer } = this.stage; - const { appWidth, appHeight } = this.stage.options; - const { fontSize, color, contain, scrollable, zIndex, debug } = state.props; // scrollY only has an effect when contain === 'both' and scrollable === true @@ -563,6 +600,19 @@ export class SdfTextRenderer extends TextRenderer { assertTruthy(trFontFace); + if (scrollable && contain === 'both') { + const visibleWindowRect: Rect = { + x: state.visibleWindow.x1, + y: state.visibleWindow.y1, + width: state.visibleWindow.x2 - state.visibleWindow.x1, + height: state.visibleWindow.y2 - state.visibleWindow.y1, + }; + + clippingRect = clippingRect + ? intersectRect(clippingRect, visibleWindowRect) + : visibleWindowRect; + } + const renderOp = new WebGlCoreRenderOp( renderer.glw, renderer.options, @@ -596,23 +646,6 @@ export class SdfTextRenderer extends TextRenderer { renderer.addRenderOp(renderOp); - // const elementRect = { - // x: x, - // y: y, - // w: contain !== 'none' ? width : textW, - // h: contain === 'both' ? height : textH, - // }; - - // const visibleRect = intersectRect( - // { - // x: 0, - // y: 0, - // w: renderer.w, - // h: renderer.h, - // }, - // elementRect, - // ); - // if (!debug.disableScissor) { // renderer.enableScissor( // visibleRect.x, @@ -696,7 +729,7 @@ export class SdfTextRenderer extends TextRenderer { */ protected invalidateLayoutCache(state: SdfTextRendererState): void { state.visibleWindow.valid = false; - state.renderWindow = undefined; + state.renderWindow.valid = false; state.textH = undefined; state.textW = undefined; state.lineCache = []; diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/internal/getStartConditions.ts b/src/core/text-rendering/renderers/SdfTextRenderer/internal/getStartConditions.ts index 00d4ff92..b66b4587 100644 --- a/src/core/text-rendering/renderers/SdfTextRenderer/internal/getStartConditions.ts +++ b/src/core/text-rendering/renderers/SdfTextRenderer/internal/getStartConditions.ts @@ -17,8 +17,10 @@ * limitations under the License. */ +import type { Bound } from '../../../../lib/utils.js'; import type { TrProps, TextRendererState } from '../../TextRenderer.js'; import type { SdfTextRendererState } from '../SdfTextRenderer.js'; +import type { SdfRenderWindow } from './setRenderWindow.js'; /** * Gets the start conditions for the layout loop. @@ -35,42 +37,48 @@ import type { SdfTextRendererState } from '../SdfTextRenderer.js'; * @returns */ export function getStartConditions( - fontSize: TrProps['fontSize'], + sdfFontSize: number, + sdfLineHeight: number, + lineHeight: number, + verticalAlign: TrProps['verticalAlign'], offsetY: TrProps['offsetY'], fontSizeRatio: number, - sdfLineHeight: number, - renderWindow: SdfTextRendererState['renderWindow'], + renderWindow: SdfRenderWindow, lineCache: SdfTextRendererState['lineCache'], textH: TextRendererState['textH'], ): | { - x: number; - y: number; + sdfX: number; + sdfY: number; lineIndex: number; } | undefined { // State variables - let startLineIndex = 0; - if (renderWindow) { - startLineIndex = Math.min( - Math.max(Math.floor(renderWindow.y1 / fontSize), 0), - lineCache.length, - ); - } + const startLineIndex = Math.min( + Math.max(renderWindow.firstLineIdx, 0), + lineCache.length, + ); - // TODO: Possibly break out startX / startY into a separate function // TODO: (fontSize / 6.4286 / fontSizeRatio) Adding this to the startY helps the text line up better with Canvas rendered text - const startX = 0; - const startY = offsetY / fontSizeRatio + startLineIndex * sdfLineHeight; // TODO: Figure out what determines the initial y offset of text. + const sdfStartX = 0; + let sdfVerticalAlignYOffset = 0; + if (verticalAlign === 'middle') { + sdfVerticalAlignYOffset = (sdfLineHeight - sdfFontSize) / 2; + } else if (verticalAlign === 'bottom') { + sdfVerticalAlignYOffset = sdfLineHeight - sdfFontSize; + } + const sdfOffsetY = offsetY / fontSizeRatio; + const sdfStartY = + sdfOffsetY + startLineIndex * sdfLineHeight + sdfVerticalAlignYOffset; // TODO: Figure out what determines the initial y offset of text. // Don't attempt to render anything if we know we're starting past the established end of the text - if (textH && startY >= textH / fontSizeRatio) { + if (textH && sdfStartY >= textH / fontSizeRatio) { return; } return { - x: startX, - y: startY, + sdfX: sdfStartX, + sdfY: sdfStartY, lineIndex: startLineIndex, }; } diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/internal/layoutText.ts b/src/core/text-rendering/renderers/SdfTextRenderer/internal/layoutText.ts index b3bbbd1f..dd49e17b 100644 --- a/src/core/text-rendering/renderers/SdfTextRenderer/internal/layoutText.ts +++ b/src/core/text-rendering/renderers/SdfTextRenderer/internal/layoutText.ts @@ -18,6 +18,7 @@ */ import { assertTruthy } from '../../../../../utils.js'; +import type { Bound } from '../../../../lib/utils.js'; import type { FontShaperProps, MappedGlyphInfo, @@ -38,6 +39,7 @@ export function layoutText( width: TrProps['width'], height: TrProps['height'], fontSize: TrProps['fontSize'], + lineHeight: TrProps['lineHeight'], letterSpacing: TrProps['letterSpacing'], /** * Mutated @@ -48,10 +50,12 @@ export function layoutText( * Mutated */ lineCache: SdfTextRendererState['lineCache'], - renderWindow: SdfTextRendererState['renderWindow'], + rwSdf: Bound, trFontFace: SdfTextRendererState['trFontFace'], forceFullLayoutCalc: TextRendererState['forceFullLayoutCalc'], scrollable: TrProps['scrollable'], + overflowSuffix: TrProps['overflowSuffix'], + maxLines: TrProps['maxLines'], ): { bufferNumFloats: number; bufferNumQuads: number; @@ -75,13 +79,13 @@ export function layoutText( // We convert these to the vertex space by dividing them the `fontSizeRatio` factor. /** - * `lineHeight` in vertex coordinates + * See above */ - const vertexLineHeight = trFontFace.data.info.size; + const fontSizeRatio = fontSize / trFontFace.data.info.size; /** - * See above + * `lineHeight` in vertex coordinates */ - const fontSizeRatio = fontSize / vertexLineHeight; + const vertexLineHeight = lineHeight / fontSizeRatio; /** * `w` in vertex coordinates */ @@ -142,24 +146,32 @@ export function layoutText( bufferEnd: number; }[] = []; - const truncateSeq = '...'; const vertexTruncateHeight = height / fontSizeRatio; - const truncateSeqVertexWidth = measureText(truncateSeq, shaperProps, shaper); + const overflowSuffVertexWidth = measureText( + overflowSuffix, + shaperProps, + shaper, + ); // Line-by-line layout let moreLines = true; while (moreLines) { const nextLineWillFit = - contain !== 'both' || - scrollable || - curY + vertexLineHeight + vertexLineHeight <= vertexTruncateHeight; + (maxLines === 0 || curLineIndex + 1 < maxLines) && + (contain !== 'both' || + scrollable || + curY + vertexLineHeight + vertexLineHeight <= vertexTruncateHeight); const lineVertexW = nextLineWillFit ? vertexW - : vertexW - truncateSeqVertexWidth; + : 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 + vertexLineHeight >= 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) { @@ -220,7 +232,7 @@ export function layoutText( } else { glyphs = shaper.shapeText( shaperProps, - new PeekableIterator(getUnicodeCodepoints(truncateSeq, 0), 0), + new PeekableIterator(getUnicodeCodepoints(overflowSuffix, 0), 0), ); curX = lastWord.xStart; bufferOffset = lastWord.bufferOffset; @@ -230,15 +242,8 @@ export function layoutText( const quadX = curX + glyph.xOffset; const quadY = curY + glyph.yOffset; - const lineIsBelowWindowTop = renderWindow - ? curY + vertexLineHeight >= renderWindow.y1 / fontSizeRatio - : true; - const lineIsAboveWindowBottom = renderWindow - ? curY <= renderWindow.y2 / fontSizeRatio - : true; - // Only add to buffer for rendering if the line is within the render window - if (lineIsBelowWindowTop && lineIsAboveWindowBottom) { + if (lineIsWithinWindow) { if (curLineBufferStart === -1) { curLineBufferStart = bufferOffset; } @@ -288,7 +293,19 @@ export function layoutText( // Handle newlines if (glyph.codepoint === 10) { - break; + 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'; + } } } } @@ -308,12 +325,7 @@ export function layoutText( xStartLastWordBoundary = 0; // Figure out if there are any more lines to render... - if ( - !forceFullLayoutCalc && - contain === 'both' && - renderWindow && - curY > renderWindow.y2 / fontSizeRatio - ) { + 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') @@ -323,7 +335,7 @@ export function layoutText( } else if (glyphResult && glyphResult.done) { // If we've reached the end of the text, we know we're done moreLines = false; - } else if (contain === 'both' && !scrollable && !nextLineWillFit) { + } 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; diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/internal/makeRenderWindow.test.ts b/src/core/text-rendering/renderers/SdfTextRenderer/internal/makeRenderWindow.test.ts deleted file mode 100644 index 835ab7d4..00000000 --- a/src/core/text-rendering/renderers/SdfTextRenderer/internal/makeRenderWindow.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * 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 { expect, describe, it } from 'vitest'; -import { makeRenderWindow } from './makeRenderWindow.js'; - -describe('makeRenderWindow', () => { - it('should return a empty window when all inputs are zero / empty', () => { - expect( - makeRenderWindow(0, 0, 0, 0, 0, { x1: 0, y1: 0, x2: 0, y2: 0 }), - ).toEqual({ - x1: 0, - y1: 0, - x2: 0, - y2: 0, - }); - // Visible window is empty - expect( - makeRenderWindow(0, 0, 0, 0, 0, { x1: 100, y1: 100, x2: 100, y2: 100 }), - ).toEqual({ - x1: 0, - y1: 0, - x2: 0, - y2: 0, - }); - }); - - it('should return an empty window when the visible window is empty regardless of other inputs', () => { - expect( - makeRenderWindow(100, 200, 300, 10, 3, { x1: 0, y1: 0, x2: 0, y2: 0 }), - ).toEqual({ - x1: 0, - y1: 0, - x2: 0, - y2: 0, - }); - expect( - makeRenderWindow(400, 500, 100, 20, 2, { - x1: 100, - y1: 100, - x2: 100, - y2: 100, - }), - ).toEqual({ - x1: 0, - y1: 0, - x2: 0, - y2: 0, - }); - }); - - it('should return a window with no margin around the visible area if lineHeight and/or numExtraLines set are zero', () => { - expect( - makeRenderWindow(0, 0, 0, 0, 0, { x1: 0, y1: 0, x2: 100, y2: 100 }), - ).toEqual({ - x1: 0, - y1: 0, - x2: 100, - y2: 100, - }); - - expect( - makeRenderWindow(0, 0, 0, 10, 0, { x1: 0, y1: 0, x2: 100, y2: 100 }), - ).toEqual({ - x1: 0, - y1: 0, - x2: 100, - y2: 100, - }); - - expect( - makeRenderWindow(0, 0, 0, 0, 2, { x1: 0, y1: 0, x2: 100, y2: 100 }), - ).toEqual({ - x1: 0, - y1: 0, - x2: 100, - y2: 100, - }); - }); - - it('should return a window with a margin around the visible area (if lineHeight/numExtraLines set)', () => { - expect( - makeRenderWindow(0, 0, 0, 10, 1, { x1: 0, y1: 0, x2: 100, y2: 100 }), - ).toEqual({ - x1: 0, - y1: -10, - x2: 100, - y2: 110, - }); - - expect( - makeRenderWindow(0, 0, 0, 5, 3, { x1: 0, y1: 0, x2: 100, y2: 100 }), - ).toEqual({ - x1: 0, - y1: -15, - x2: 100, - y2: 115, - }); - }); - - it('should return a window scrolled to scrollY when set', () => { - expect( - makeRenderWindow(0, 0, 100, 10, 1, { x1: 0, y1: 0, x2: 100, y2: 100 }), - ).toEqual({ - x1: 0, - y1: 90, - x2: 100, - y2: 210, - }); - - expect( - makeRenderWindow(0, 0, 200, 10, 1, { x1: 0, y1: 0, x2: 100, y2: 100 }), - ).toEqual({ - x1: 0, - y1: 190, - x2: 100, - y2: 310, - }); - }); -}); diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/internal/setRenderWindow.test.ts b/src/core/text-rendering/renderers/SdfTextRenderer/internal/setRenderWindow.test.ts new file mode 100644 index 00000000..a249e168 --- /dev/null +++ b/src/core/text-rendering/renderers/SdfTextRenderer/internal/setRenderWindow.test.ts @@ -0,0 +1,205 @@ +/* + * 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 { expect, describe, it } from 'vitest'; +import { setRenderWindow, type SdfRenderWindow } from './setRenderWindow.js'; + +function makeRenderWindow(): SdfRenderWindow { + return { + screen: { x1: 0, y1: 0, x2: 0, y2: 0 }, + sdf: { x1: 0, y1: 0, x2: 0, y2: 0 }, + firstLineIdx: 0, + numLines: 0, + valid: false, + }; +} + +describe('setRenderWindow', () => { + it('should return a empty window when visibleWindow is empty', () => { + const rw = makeRenderWindow(); + setRenderWindow(rw, 0, 0, 0, 10, 0, { x1: 0, y1: 0, x2: 0, y2: 0 }, 1); + expect(rw).toEqual({ + screen: { x1: 0, y1: 0, x2: 0, y2: 0 }, + sdf: { x1: 0, y1: 0, x2: 0, y2: 0 }, + firstLineIdx: 0, + numLines: 0, + valid: true, + }); + + setRenderWindow( + rw, + 0, + 0, + 0, + 20, + 0, + { x1: 100, y1: 100, x2: 100, y2: 100 }, + 2, + ); + expect(rw).toEqual({ + screen: { x1: 0, y1: 0, x2: 0, y2: 0 }, + sdf: { x1: 0, y1: 0, x2: 0, y2: 0 }, + firstLineIdx: 0, + numLines: 0, + valid: true, + }); + + setRenderWindow( + rw, + 100, + 200, + 300, + 10, + 30, + { x1: 0, y1: 0, x2: 0, y2: 0 }, + 1, + ); + expect(rw).toEqual({ + screen: { x1: 0, y1: 0, x2: 0, y2: 0 }, + sdf: { x1: 0, y1: 0, x2: 0, y2: 0 }, + firstLineIdx: 0, + numLines: 0, + valid: true, + }); + setRenderWindow( + rw, + 400, + 500, + 100, + 20, + 40, + { + x1: 100, + y1: 100, + x2: 100, + y2: 100, + }, + 1, + ), + expect(rw).toEqual({ + screen: { x1: 0, y1: 0, x2: 0, y2: 0 }, + sdf: { x1: 0, y1: 0, x2: 0, y2: 0 }, + firstLineIdx: 0, + numLines: 0, + valid: true, + }); + }); + + it('should return a window with no margin around the visible area if lineHeight and/or bufferMargin set are zero', () => { + const rw = makeRenderWindow(); + + setRenderWindow(rw, 0, 0, 0, 10, 0, { x1: 0, y1: 0, x2: 100, y2: 100 }, 1), + expect(rw).toEqual({ + screen: { x1: 0, y1: 0, x2: 100, y2: 100 }, + sdf: { x1: 0, y1: 0, x2: 100, y2: 100 }, + firstLineIdx: 0, + numLines: 10, + valid: true, + }); + + setRenderWindow(rw, 0, 0, 0, 10, 0, { x1: 0, y1: 0, x2: 200, y2: 205 }, 2); + expect(rw).toEqual({ + screen: { x1: 0, y1: 0, x2: 200, y2: 210 }, + sdf: { x1: 0, y1: 0, x2: 100, y2: 105 }, + firstLineIdx: 0, + numLines: 21, + valid: true, + }); + }); + + it('should return a window with a margin around the visible area (if boundsMargin set)', () => { + const rw = makeRenderWindow(); + + setRenderWindow( + rw, + 0, + 0, + 0, + 10, + 100, + { x1: 0, y1: 0, x2: 100, y2: 100 }, + 1, + ), + expect(rw).toEqual({ + screen: { x1: 0, y1: -100, x2: 100, y2: 200 }, + sdf: { x1: 0, y1: -100, x2: 100, y2: 200 }, + firstLineIdx: -10, + numLines: 30, + valid: true, + }); + + setRenderWindow( + rw, + 0, + 0, + 0, + 10, + 200, + { x1: 0, y1: 0, x2: 200, y2: 200 }, + 1, + ); + expect(rw).toEqual({ + screen: { x1: 0, y1: -200, x2: 200, y2: 400 }, + sdf: { x1: 0, y1: -200, x2: 200, y2: 400 }, + firstLineIdx: -20, + numLines: 60, + valid: true, + }); + }); + + it('should return a window scrolled to scrollY when set', () => { + const rw = makeRenderWindow(); + + setRenderWindow( + rw, + 0, + 0, + 100, + 10, + 100, + { x1: 0, y1: 0, x2: 100, y2: 100 }, + 1, + ), + expect(rw).toEqual({ + screen: { x1: 0, y1: 0, x2: 100, y2: 300 }, + sdf: { x1: 0, y1: 0, x2: 100, y2: 300 }, + firstLineIdx: 0, + numLines: 30, + valid: true, + }); + + setRenderWindow( + rw, + 0, + 0, + 105, + 10, + 100, + { x1: 0, y1: 0, x2: 100, y2: 100 }, + 1, + ), + expect(rw).toEqual({ + screen: { x1: 0, y1: 0, x2: 100, y2: 310 }, + sdf: { x1: 0, y1: 0, x2: 100, y2: 310 }, + firstLineIdx: 0, + numLines: 31, + valid: true, + }); + }); +}); diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/internal/makeRenderWindow.ts b/src/core/text-rendering/renderers/SdfTextRenderer/internal/setRenderWindow.ts similarity index 53% rename from src/core/text-rendering/renderers/SdfTextRenderer/internal/makeRenderWindow.ts rename to src/core/text-rendering/renderers/SdfTextRenderer/internal/setRenderWindow.ts index ca6689f8..f30c392a 100644 --- a/src/core/text-rendering/renderers/SdfTextRenderer/internal/makeRenderWindow.ts +++ b/src/core/text-rendering/renderers/SdfTextRenderer/internal/setRenderWindow.ts @@ -19,6 +19,15 @@ import { isBoundPositive, type Bound } from '../../../../lib/utils.js'; import type { TrProps } from '../../TextRenderer.js'; +import { roundDownToMultiple, roundUpToMultiple } from './util.js'; + +export interface SdfRenderWindow { + screen: Bound; + sdf: Bound; + firstLineIdx: number; + numLines: number; + valid: boolean; +} /** * Create a render window from the given parameters. @@ -32,33 +41,53 @@ import type { TrProps } from '../../TextRenderer.js'; * @param x The x coordinate of the text element's top left corner relative to the screen. * @param y The y coordinate of the text element's top left corner relative to the screen. * @param scrollY The amount of pixels to scroll the text vertically. - * @param lineHeight The height of a single line of text. - * @param numExtraLines The number of extra lines to render above and below the visible window. + * @param lineHeight The number of extra lines to render above and below the visible window. * @param visibleWindow The visible window of the text element relative to the screen * @returns */ -export function makeRenderWindow( +export function setRenderWindow( + outRenderWindow: SdfRenderWindow, x: TrProps['x'], y: TrProps['y'], scrollY: TrProps['scrollY'], lineHeight: number, - numExtraLines: number, + bufferMargin: number, visibleWindow: Bound, -): Bound { - const bufferMargin = lineHeight * numExtraLines; - const x1 = visibleWindow.x1 - x; - const y1 = visibleWindow.y1 - y; - return isBoundPositive(visibleWindow) - ? { - x1: x1, - y1: y1 + scrollY - bufferMargin, - x2: x1 + (visibleWindow.x2 - visibleWindow.x1), - y2: y1 + scrollY + (visibleWindow.y2 - visibleWindow.y1) + bufferMargin, - } - : { - x1: 0, - y1: 0, - x2: 0, - y2: 0, - }; + fontSizeRatio: number, +): void { + const { screen, sdf } = outRenderWindow; + if (!isBoundPositive(visibleWindow)) { + screen.x1 = 0; + screen.y1 = 0; + screen.x2 = 0; + screen.y2 = 0; + sdf.x1 = 0; + sdf.y1 = 0; + sdf.x2 = 0; + sdf.y2 = 0; + outRenderWindow.numLines = 0; + outRenderWindow.firstLineIdx = 0; + } else { + const x1 = visibleWindow.x1 - x; + const x2 = x1 + (visibleWindow.x2 - visibleWindow.x1); + const y1Base = visibleWindow.y1 - y + scrollY; + const y1 = roundDownToMultiple(y1Base - bufferMargin, lineHeight || 1); + const y2 = roundUpToMultiple( + y1Base + (visibleWindow.y2 - visibleWindow.y1) + bufferMargin, + lineHeight || 1, + ); + + screen.x1 = x1; + screen.y1 = y1; + screen.x2 = x2; + screen.y2 = y2; + sdf.x1 = x1 / fontSizeRatio; + sdf.y1 = y1 / fontSizeRatio; + sdf.x2 = x2 / fontSizeRatio; + sdf.y2 = y2 / fontSizeRatio; + + outRenderWindow.numLines = Math.ceil((y2 - y1) / lineHeight); + outRenderWindow.firstLineIdx = Math.floor(y1 / lineHeight); + } + outRenderWindow.valid = true; } diff --git a/src/core/text-rendering/renderers/SdfTextRenderer/internal/util.ts b/src/core/text-rendering/renderers/SdfTextRenderer/internal/util.ts new file mode 100644 index 00000000..611b18a8 --- /dev/null +++ b/src/core/text-rendering/renderers/SdfTextRenderer/internal/util.ts @@ -0,0 +1,40 @@ +/* + * 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. + */ + +/** + * Round up to the nearest multiple of the given number. + * + * @param value + * @param multiple + * @returns + */ +export function roundUpToMultiple(value: number, multiple: number) { + return Math.ceil(value / multiple) * multiple; +} + +/** + * Round down to the nearest multiple of the given number. + * + * @param value + * @param multiple + * @returns + */ +export function roundDownToMultiple(value: number, multiple: number) { + return Math.floor(value / multiple) * multiple; +} diff --git a/src/core/text-rendering/renderers/TextRenderer.ts b/src/core/text-rendering/renderers/TextRenderer.ts index b310b581..04ceabb5 100644 --- a/src/core/text-rendering/renderers/TextRenderer.ts +++ b/src/core/text-rendering/renderers/TextRenderer.ts @@ -417,6 +417,10 @@ export abstract class TextRenderer< (state: StateT, value: TrProps[keyof TrProps]) => { if (state.props[key as keyof TrProps] !== value) { setter(state, value as never); + // Assume any prop change will require a render + // This is required because otherwise a paused RAF will result + // in renders when text props are changed. + this.stage.requestRender(); } }, ]; diff --git a/src/main-api/RendererMain.ts b/src/main-api/RendererMain.ts index 8bbb3d9a..cf9f87c2 100644 --- a/src/main-api/RendererMain.ts +++ b/src/main-api/RendererMain.ts @@ -419,11 +419,12 @@ export class RendererMain extends EventEmitter { * @returns */ createTextNode(props: Partial): ITextNode { + const fontSize = props.fontSize ?? 16; const data = { ...this.resolveNodeDefaults(props), text: props.text ?? '', textRendererOverride: props.textRendererOverride ?? null, - fontSize: props.fontSize ?? 16, + fontSize, fontFamily: props.fontFamily ?? 'sans-serif', fontStyle: props.fontStyle ?? 'normal', fontWeight: props.fontWeight ?? 'normal', @@ -434,7 +435,7 @@ export class RendererMain extends EventEmitter { scrollY: props.scrollY ?? 0, offsetY: props.offsetY ?? 0, letterSpacing: props.letterSpacing ?? 0, - lineHeight: props.lineHeight ?? 0, + lineHeight: props.lineHeight ?? fontSize, maxLines: props.maxLines ?? 0, textBaseline: props.textBaseline ?? 'alphabetic', verticalAlign: props.verticalAlign ?? 'top', diff --git a/visual-regression/certified-snapshots/chromium-ci/text-line-height-1.png b/visual-regression/certified-snapshots/chromium-ci/text-line-height-1.png index bd2daaf0..9c668a84 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-line-height-1.png and b/visual-regression/certified-snapshots/chromium-ci/text-line-height-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-max-lines-1.png b/visual-regression/certified-snapshots/chromium-ci/text-max-lines-1.png index f5520919..5ab2defe 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-max-lines-1.png and b/visual-regression/certified-snapshots/chromium-ci/text-max-lines-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-max-lines-2.png b/visual-regression/certified-snapshots/chromium-ci/text-max-lines-2.png new file mode 100644 index 00000000..3db4f4fd Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/text-max-lines-2.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-overflow-suffix-1.png b/visual-regression/certified-snapshots/chromium-ci/text-overflow-suffix-1.png index 92492a1b..a22e5bf2 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-overflow-suffix-1.png and b/visual-regression/certified-snapshots/chromium-ci/text-overflow-suffix-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-1.png b/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-1.png index 392ac6f3..7017c229 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-1.png and b/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-2.png b/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-2.png new file mode 100644 index 00000000..531889c8 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-2.png differ