Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@
"vitest": "^2.1.9",
"vitest-mock-extended": "^2.0.2"
},
"peerDependencies": {
"@vitejs/plugin-legacy": "^7.0.0 || ^8.0.0"
},
"lint-staged": {
"*.ts": [
"prettier --write",
Expand Down
69 changes: 69 additions & 0 deletions src/core/CoreTextNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,75 @@ describe('CoreTextNode (canvas) clearing text', () => {
});
});

describe('CoreTextNode eager dimensions', () => {
const makeCanvasRendererWithFont = (
isFontLoaded: boolean,
measured: TextRenderInfo,
): TextRenderer => {
const font = mock<FontHandler>({
type: 'canvas',
isFontLoaded: vi.fn(() => isFontLoaded),
waitingForFont: vi.fn(),
});
return {
type: 'canvas',
font,
renderText: vi.fn(),
measureText: vi.fn(() => measured),
addQuads: vi.fn(),
renderQuads: vi.fn(),
init: vi.fn(),
} as unknown as TextRenderer;
};

it('measures dimensions synchronously at construction when the font is loaded, without rendering', () => {
const renderer = makeCanvasRendererWithFont(true, {
width: 120,
height: 40,
});
const stage = makeStage(makeLoadedTexture());

const onLoaded = vi.fn();
// Spy must be wired through the renderer-agnostic path; emit happens only
// on render, not on measure.
const node = new CoreTextNode(
stage,
defaultProps({ text: 'Hello' }),
renderer,
);
node.on('loaded', onLoaded);

// Dimensions are available immediately — no frame, no microtask.
expect(renderer.measureText).toHaveBeenCalledTimes(1);
expect(node.props.w).toBe(120);
expect(node.props.h).toBe(40);

// Measuring must NOT render: no rasterization, no texture, no loaded event.
expect(renderer.renderText).not.toHaveBeenCalled();
expect(node.texture).toBe(null);
expect(onLoaded).not.toHaveBeenCalled();
});

it('does not measure or render when the font is not yet loaded', () => {
const renderer = makeCanvasRendererWithFont(false, {
width: 120,
height: 40,
});
const stage = makeStage(makeLoadedTexture());

const node = new CoreTextNode(
stage,
defaultProps({ text: 'Hello' }),
renderer,
);

expect(renderer.measureText).not.toHaveBeenCalled();
expect(renderer.renderText).not.toHaveBeenCalled();
expect(node.props.w).toBe(0);
expect(node.props.h).toBe(0);
});
});

describe('CoreTextNode (sdf) renderOnlyInViewport', () => {
const makeSdfRenderer = (): TextRenderer => {
const font = mock<FontHandler>({ type: 'sdf' });
Expand Down
71 changes: 57 additions & 14 deletions src/core/CoreTextNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
} from './CoreNode.js';
import type { Stage } from './Stage.js';
import type {
NodeTextFailedPayload,
NodeTextLoadedPayload,
NodeTextureLoadedPayload,
} from '../common/CommonTypes.js';
Expand Down Expand Up @@ -85,6 +84,39 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
this._containType = TextConstraint[props.contain];

this.setUpdateType(UpdateType.All);

// Eagerly measure the text dimensions at construction (when the font is
// already loaded) so layout consumers can read the node's real w/h before
// it is rendered. This computes layout only — the actual rasterization /
// glyph upload stays on update()'s visibility-gated path, exactly as
// before, so off-screen text still does no render work. Measuring is cheap
// and emits no `loaded` event, so it runs synchronously here.
if (this.fontHandler.isFontLoaded(props.fontFamily) === true) {
this.generateDimensions();
}
}

/**
* Measure the text and apply its dimensions to the node without rendering.
*
* Sets w/h from the renderer's measure-only path and flags the
* dimension-dependent updates, but leaves `_layoutGenerated` false so
* update() still runs the full (deferred, visibility-gated) render later.
*/
private generateDimensions(): void {
const info = this.textRenderer.measureText(this.textProps);
if (info.width === this.props.w && info.height === this.props.h) {
return;
}
this.props.w = info.width;
this.props.h = info.height;
this._renderInfo = info;
// Direct props.w/h writes bypass the w/h setters, so raise RecalcUniforms
// (dimensions feed shader uniforms) alongside Local/RenderBounds — mirrors
// handleRenderResult.
this.setUpdateType(
UpdateType.Local | UpdateType.RenderBounds | UpdateType.RecalcUniforms,
);
}

protected override onTextureLoaded: TextureLoadedEventHandler = (
Expand Down Expand Up @@ -192,25 +224,36 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
}
}

/**
* Generate the text layout for the current props.
*
* If the font is already loaded the layout is produced and `_layoutGenerated`
* is latched so `update()` won't redo it. If the font is not yet loaded the
* node registers with the font handler and the layout is produced on a later
* frame, once the load promise resolves.
*/
private generateLayout(): void {
if (this.fontHandler.isFontLoaded(this.textProps.fontFamily)) {
this._waitingForFont = false;
this._cachedLayout = null; // Invalidate cached layout
const resp = this.textRenderer.renderText(this.textProps);
this.handleRenderResult(resp);
this._layoutGenerated = true;
} else if (!this._waitingForFont) {
this.fontHandler.waitingForFont(this.textProps.fontFamily, this);
this._waitingForFont = true;
}
}

/**
* Override CoreNode's update method to handle text-specific updates
*/
override update(delta: number, parentClippingRect: RectWithValid): void {
if (
(this.textProps.forceLoad === true ||
this.allowTextGeneration() === true) &&
this._layoutGenerated === false
(this.textProps.forceLoad || this.allowTextGeneration()) &&
!this._layoutGenerated
) {
if (this.fontHandler.isFontLoaded(this.textProps.fontFamily) === true) {
this._waitingForFont = false;
this._cachedLayout = null; // Invalidate cached layout
const resp = this.textRenderer.renderText(this.textProps);
this.handleRenderResult(resp);
this._layoutGenerated = true;
} else if (this._waitingForFont === false) {
this.fontHandler.waitingForFont(this.textProps.fontFamily, this);
this._waitingForFont = true;
}
this.generateLayout();
}

// First run the standard CoreNode update
Expand Down
48 changes: 48 additions & 0 deletions src/core/text-rendering/CanvasTextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,53 @@ const init = (stage: Stage): void => {
CanvasFontHandler.init(context, measureContext);
};

/**
* Measure-only path: lay out the text via the layout engine and return its
* dimensions, without rasterizing. Used to populate a text node's w/h eagerly
* at construction.
*
* Kept as a standalone function (rather than a helper shared with
* {@link renderText}) so renderText stays byte-identical to upstream — the
* mapTextLayout invocation below is intentionally a mirror of the one in
* renderText and must be kept in sync with it. See {@link TextRenderer.measureText}.
*/
const measureText = (props: CoreTextNodeProps): TextRenderInfo => {
assertTruthy(measureContext, 'Canvas measureContext is not available');

if (props.text.length === 0) {
return {
width: 0,
height: 0,
};
}

const { fontFamily, fontStyle, fontSize } = props;
measureContext.font = `${fontStyle} ${fontSize}px Unknown, ${fontFamily}`;
measureContext.textBaseline = 'alphabetic';

const metrics = CanvasFontHandler.getFontMetrics(fontFamily, fontSize);

const [, , , , , effectiveWidth, effectiveHeight] = mapTextLayout(
CanvasFontHandler.measureText,
metrics,
props.text,
props.textAlign,
fontFamily,
props.lineHeight,
props.overflowSuffix,
props.wordBreak,
props.letterSpacing,
props.maxLines,
props.maxWidth,
props.maxHeight,
);

return {
width: effectiveWidth,
height: effectiveHeight,
};
};

/**
* Canvas text renderer
*
Expand Down Expand Up @@ -273,6 +320,7 @@ const CanvasTextRenderer = {
type,
font,
renderText,
measureText,
addQuads,
renderQuads,
init,
Expand Down
54 changes: 54 additions & 0 deletions src/core/text-rendering/SdfTextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,59 @@ const renderQuads = (
return null;
};

/**
* Measure-only path: lay out the text via the layout engine and return its
* dimensions, without building the per-glyph vertex buffer. Used to populate a
* text node's w/h eagerly at construction.
*
* Kept as a standalone function (rather than a helper shared with
* {@link generateTextLayout}) so generateTextLayout stays byte-identical to
* upstream — the mapTextLayout invocation and the design-unit -> pixel scaling
* below mirror generateTextLayout and must be kept in sync with it. See
* {@link TextRenderer.measureText}.
*/
const measureText = (props: CoreTextNodeProps): TextRenderInfo => {
if (props.text.length === 0) {
return { width: 0, height: 0 };
}

const fontCache = SdfFontHandler.getFontData(props.fontFamily);
if (fontCache === undefined) {
return { width: 0, height: 0 };
}

const fontSize = props.fontSize;
const fontFamily = props.fontFamily;
const metrics = SdfFontHandler.getFontMetrics(fontFamily, fontSize);

// Pixel scale from atlas design units to pixels.
const fontScale = fontSize / fontCache.data.info.size;
const letterSpacing = props.letterSpacing / fontScale;
const maxWidth = props.maxWidth / fontScale;

const [, , , , , effectiveWidth, effectiveHeight] = mapTextLayout(
SdfFontHandler.measureText,
metrics,
props.text,
props.textAlign,
fontFamily,
props.lineHeight,
props.overflowSuffix,
props.wordBreak,
letterSpacing,
props.maxLines,
maxWidth,
props.maxHeight,
);

// generateTextLayout returns width in pixel space (effectiveWidth * fontScale)
// and height already in pixels — mirror that here.
return {
width: effectiveWidth * fontScale,
height: effectiveHeight,
};
};

/**
* Generate complete text layout with glyph positioning for caching
*/
Expand Down Expand Up @@ -439,6 +492,7 @@ const SdfTextRenderer = {
type,
font,
renderText,
measureText,
addQuads,
renderQuads,
init,
Expand Down
8 changes: 8 additions & 0 deletions src/core/text-rendering/TextRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,14 @@ export interface TextRenderer {
type: 'canvas' | 'sdf';
font: FontHandler;
renderText: (props: CoreTextNodeProps) => TextRenderInfo;
/**
* Compute only the laid-out dimensions of the text, without rasterizing
* (Canvas) or building glyph vertex data (SDF). Used to populate a text
* node's `w`/`h` eagerly at construction so layout consumers can react to
* the real size before the node is actually rendered. The returned width
* and height must match what {@link renderText} produces for the same props.
*/
measureText: (props: CoreTextNodeProps) => TextRenderInfo;
// Updated to accept layout data and return vertex buffer for performance
addQuads: (layout?: TextLayout) => Float32Array | null;
renderQuads: (
Expand Down