diff --git a/README.md b/README.md index 4c190214..709d990d 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2066 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2067 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/properties/bounds.ts b/src/core/properties/bounds.ts index 12e699f1..65dfe70d 100644 --- a/src/core/properties/bounds.ts +++ b/src/core/properties/bounds.ts @@ -34,8 +34,25 @@ const JOINT_SYMBOL = 24; const CHAR_WIDTH = 0.55; const LINE_HEIGHT = 1.25; +/** + * How big a text is drawn, given its props and the file of its font – or null where nothing can be + * measured. The viewport sets it, since only a canvas can measure; tests in node estimate instead. + */ +export type TextMeasure = (props: PropertyBag, font: string) => { w: number; h: number } | null; + +let textMeasure: TextMeasure | null = null; + +export function setTextMeasure(measure: TextMeasure | null): void { + textMeasure = measure; +} + +/** The text as drawn where it can be measured, `font` being its font's file; estimated otherwise. */ +export function textSize(props: PropertyBag, font = ''): { w: number; h: number } { + return textMeasure?.(props, font) ?? estimatedTextSize(props); +} + /** Estimated from the content and font size, without measuring the text. */ -export function textSize(props: PropertyBag): { w: number; h: number } { +export function estimatedTextSize(props: PropertyBag): { w: number; h: number } { const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize)); const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n'); const longest = lines.reduce((max, line) => Math.max(max, line.length), 0); @@ -54,19 +71,21 @@ export function textAnchor(props: PropertyBag): number { /** * Centred on the object's position; text is anchored instead (see `textAnchor`). A collider that - * wears a frame (#193) is that frame's size, which only the caller can look up. + * wears a frame (#193) is that frame's size, and a text is measured in its font's file – both of + * which only the caller can look up. */ export function footprintOf( type: AssetType, props: PropertyBag, frame?: FrameOutline | null, + font = '', ): { w: number; h: number } { switch (type) { case 'sprite': case 'clip': return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) }; case 'text': - return textSize(props); + return textSize(props, font); case 'collider': return colliderSize(props, frame); case 'behavior': @@ -113,9 +132,10 @@ export function footprintCorners( m: Mat, pad = 0, frame?: FrameOutline | null, + font = '', ): Vec2[] { const placed = placementOf(type, m); - const { w, h } = footprintOf(type, props, frame); + const { w, h } = footprintOf(type, props, frame, font); const local = pad / (scaleOf(placed) || 1); return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0); } @@ -142,12 +162,19 @@ export function footprintExtent( props: PropertyBag, m: Mat, frame?: FrameOutline | null, + font = '', ): Extent { - return extentOfPoints(footprintCorners(type, props, m, 0, frame)); + return extentOfPoints(footprintCorners(type, props, m, 0, frame, font)); } -export function footprintContains(type: AssetType, props: PropertyBag, m: Mat, point: Vec2): boolean { - const { w, h } = footprintOf(type, props); +export function footprintContains( + type: AssetType, + props: PropertyBag, + m: Mat, + point: Vec2, + font = '', +): boolean { + const { w, h } = footprintOf(type, props, null, font); const local = apply(invert(placementOf(type, m)), point); return Math.abs(local.x - anchorOffset(type, props, w)) <= w / 2 && Math.abs(local.y) <= h / 2; } diff --git a/src/editor/viewport/FreeViewport.ts b/src/editor/viewport/FreeViewport.ts index 3a76740f..4747d73c 100644 --- a/src/editor/viewport/FreeViewport.ts +++ b/src/editor/viewport/FreeViewport.ts @@ -63,7 +63,7 @@ export abstract class FreeViewport extends WorldViewport { protected frameContent(): void { const { items } = this.collectFrame(); const boxes = items.map((item) => { - const e = footprintExtent(item.type, item.props, item.m, item.frame); + const e = footprintExtent(item.type, item.props, item.m, item.frame, item.font); return { id: item.id, x: e.left, y: e.top, w: e.right - e.left, h: e.bottom - e.top }; }); const { minX, minY, maxX, maxY } = boundsOf(boxes, { minX: 0, minY: 0, maxX: 0, maxY: 0 })!; diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index ac8e2ebd..7e853e92 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -20,11 +20,13 @@ import { linearOf, type Mat, } from '../../core/model/matrix'; +import { fontFileOf } from '../../core/model/font'; import { placementRejection } from '../../core/model/placement'; import type { Position, Vec2 } from '../../core/model/Properties'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { cellAtPoint } from '../../core/model/tilemap'; import { footprintCorners, iconCorners } from '../../core/properties/bounds'; +import { fontOf } from '../../core/properties/fonts'; import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform'; import { FramePreview, isDragFrame } from '../framePreview'; import { panelPolicy } from '../playThrottle'; @@ -1138,9 +1140,10 @@ export class SceneViewport extends FreeViewport { if (!preview || !asset) return; const color = preview.rejection ? Theme.colors.danger : Theme.colors.accent; const m = localMatrixOf(preview.at, transformOf(asset.props)); + const font = fontFileOf(this.state.project, fontOf(asset.props)); const centre = worldToScreen(view, preview.at.x, preview.at.y); this.gizmos - .poly(this.screenCorners(footprintCorners(asset.type, asset.props, m), view), true) + .poly(this.screenCorners(footprintCorners(asset.type, asset.props, m, 0, null, font), view), true) .fill({ color, alpha: 0.12 }) .stroke({ width: 2, color }); this.gizmos @@ -1216,7 +1219,7 @@ export class SceneViewport extends FreeViewport { return this.screenCorners( spot ? iconCorners(item.type, item.props, spot, this.symbolScale(), pad / view.scale) - : footprintCorners(item.type, item.props, item.m, pad / view.scale, item.frame), + : footprintCorners(item.type, item.props, item.m, pad / view.scale, item.frame, item.font), view, ); } diff --git a/src/editor/viewport/sizeGizmoEditor.ts b/src/editor/viewport/sizeGizmoEditor.ts index 72d0114a..fe5f523b 100644 --- a/src/editor/viewport/sizeGizmoEditor.ts +++ b/src/editor/viewport/sizeGizmoEditor.ts @@ -143,7 +143,7 @@ export class SizeGizmoEditor { const frame = contentFrame(item.m, this.contentCorners(item.id)); if (frame) return frame; } - return footprintCorners(item.type, item.props, item.m, 0, item.frame); + return footprintCorners(item.type, item.props, item.m, 0, item.frame, item.font); } /** The corners of everything below a container that has a shape of its own, at any depth. */ @@ -157,7 +157,7 @@ export class SizeGizmoEditor { }; return items .filter((i) => !SHAPELESS.has(i.type) && below(i)) - .flatMap((i) => footprintCorners(i.type, i.props, i.m, 0, i.frame)); + .flatMap((i) => footprintCorners(i.type, i.props, i.m, 0, i.frame, i.font)); } private screenBox(item: DrawItem, view: View): { corners: Vec2[]; base: Vec2; knob: Vec2 } { @@ -184,7 +184,7 @@ export class SizeGizmoEditor { e.stopPropagation(); const tree = resolvedHolder(state.project, holder); const parent = parentTransform(state.project, tree, ref.object.parentId); - const size = footprintOf(item.type, item.props, item.frame); + const size = footprintOf(item.type, item.props, item.frame, item.font); const overrides: Record = {}; for (const key of ['width', 'height', 'radius', 'rotation', 'scale']) overrides[key] = ref.object.overrides[key]; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 7cae3a88..81390ed0 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -19,10 +19,12 @@ import { descendantsBelow } from '../core/model/walk'; import { isOverlayCamera } from '../core/model/renderLayers'; import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project'; import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject'; +import { fontFileOf } from '../core/model/font'; import { apply, IDENTITY } from '../core/model/matrix'; import { hasTransformProps } from '../core/properties/PropertySchema'; import { carriedTransform, localMatrixOf, transformOf } from '../core/properties/transform'; import { footprintContains } from '../core/properties/bounds'; +import { fontOf } from '../core/properties/fonts'; import { pointerThrough } from '../core/properties/camera'; import { colliderFrame, colliderShape, shapeContains } from '../core/properties/collider'; import { resolveSceneObjectProps } from '../core/properties/resolve'; @@ -606,7 +608,13 @@ export class World { return parts.some( (e) => (e.type === 'sprite' || e.type === 'text') && - footprintContains(e.type, e.props, worldMatrixOf(e), point), + footprintContains( + e.type, + e.props, + worldMatrixOf(e), + point, + fontFileOf(this.project, fontOf(e.props)), + ), ); } diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts index b0b3e750..3cf36757 100644 --- a/src/viewport/WorldViewport.ts +++ b/src/viewport/WorldViewport.ts @@ -371,7 +371,7 @@ export abstract class WorldViewport extends W const view = this.currentView(); const item = this.lastItems.find((i) => i.id === objectId); if (!item || !view) return null; - const extent = footprintExtent(item.type, item.props, item.m, item.frame); + const extent = footprintExtent(item.type, item.props, item.m, item.frame, item.font); const local = worldToScreen(view, (extent.left + extent.right) / 2, (extent.top + extent.bottom) / 2); const origin = this.getGlobalPosition(); return { @@ -434,8 +434,8 @@ export abstract class WorldViewport extends W const seen = new Set(); for (const item of items) { seen.add(item.id); - const size = footprintOf(item.type, item.props, item.frame); - const extent = footprintExtent(item.type, item.props, item.m, item.frame); + const size = footprintOf(item.type, item.props, item.frame, item.font); + const extent = footprintExtent(item.type, item.props, item.m, item.frame, item.font); // A switched-off object is not drawn at all, in the scene view as in the game; it is // selected and switched back on from the hierarchy panel. const unbound = isUnbound(item, shaders); @@ -512,7 +512,7 @@ export abstract class WorldViewport extends W if (!videoDrivenOf(item.props)) return false; const key = drivenVideoKey(file, this.videoWearer(item)); if (this.videosShown.get(item.id) === key) return false; - const extent = footprintExtent(item.type, item.props, item.m, item.frame); + const extent = footprintExtent(item.type, item.props, item.m, item.frame, item.font); if (!isUnbound(item, shaders) && !rectsIntersect(extent, bounds)) return false; // Worn here already, so the seek to the asked picture starts with the frame held. this.wearVideo(item); diff --git a/src/viewport/drawOrder.ts b/src/viewport/drawOrder.ts index f62c6d40..749c2389 100644 --- a/src/viewport/drawOrder.ts +++ b/src/viewport/drawOrder.ts @@ -158,7 +158,7 @@ function standsOnY(slot: Candidate): boolean { */ function feetOf({ item, row }: Candidate): number { if (row === null) { - const box = footprintExtent(item.type, item.props, item.m, item.frame); + const box = footprintExtent(item.type, item.props, item.m, item.frame, item.font); const line = item.type === 'sprite' ? (item.texture?.sortLine ?? 0) : 0; const frameH = item.texture?.rect?.h ?? item.texture?.sourceHeight ?? 0; return line > 0 && frameH > 0 ? box.bottom - (line / frameH) * (box.bottom - box.top) : box.bottom; diff --git a/src/viewport/textPicture.ts b/src/viewport/textPicture.ts index 38ca608c..36608376 100644 --- a/src/viewport/textPicture.ts +++ b/src/viewport/textPicture.ts @@ -4,9 +4,9 @@ * UV run over the rectangle of the rendered text. Pixi's own `Text` takes no shader, and it shares * one texture between texts of the same content and style, whatever filter each of them wants. */ -import { CanvasSource, CanvasTextGenerator, type Mesh, Texture, TextStyle } from 'pixi.js'; -import { textOf } from '../core/model/Properties'; -import { textAnchor } from '../core/properties/bounds'; +import { CanvasSource, CanvasTextGenerator, CanvasTextMetrics, type Mesh, Texture, TextStyle } from 'pixi.js'; +import { type PropertyBag, textOf } from '../core/model/Properties'; +import { setTextMeasure, textAnchor } from '../core/properties/bounds'; import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes'; import { Theme } from '../ui'; import { fontLibrary } from './FontLibrary'; @@ -71,6 +71,106 @@ export function sharpCoverage( return { data, width: w, height: h }; } +/** Where a text lies on the canvas Pixi draws it on, in world units – see `textBox`. */ +export interface TextBox { + width: number; + height: number; + /** The box's top edge, from the top of Pixi's own (unpadded) box; zero or above it. */ + top: number; + /** The padding Pixi has to draw with, so that the box lies inside its canvas. */ + room: number; +} + +/** The faces' own line boxes, by CSS font string. */ +const faceBoxes = new Map(); +let faceProbe: OffscreenCanvasRenderingContext2D | null = null; + +function faceBox(font: string): { ascent: number; descent: number } | null { + const known = faceBoxes.get(font); + if (known) return known; + faceProbe ??= new OffscreenCanvas(1, 1).getContext('2d'); + if (!faceProbe) return null; + faceProbe.font = font; + const metrics = faceProbe.measureText('M'); + // Missing in a browser that predates the line box; Pixi's own box then stands alone. + if (!Number.isFinite(metrics.fontBoundingBoxAscent)) return null; + const box = { ascent: metrics.fontBoundingBoxAscent, descent: metrics.fontBoundingBoxDescent }; + faceBoxes.set(font, box); + return box; +} + +/** + * Pixi makes a line as tall as "|ÉqÅM" reaches above and below the baseline, and whatever reaches + * further is off its canvas – the tail of Manrope's "g" was. A text takes the line box its face + * declares (`fontBoundingBox…`) instead, or Pixi's where that one is taller, so every glyph that + * keeps to its face's metrics is whole, and the height does not change with what is typed. + */ +export function textBox( + pixi: { width: number; lineHeight: number; lines: number; ascent: number; descent: number }, + face: { ascent: number; descent: number } | null, +): TextBox { + const above = Math.max(pixi.ascent, face?.ascent ?? 0); + const below = Math.max(pixi.descent, face?.descent ?? 0); + return { + width: Math.ceil(Math.max(1, pixi.width)), + height: above + (pixi.lines - 1) * pixi.lineHeight + below, + top: pixi.ascent - above, + room: Math.ceil(Math.max(above - pixi.ascent, below - pixi.descent)), + }; +} + +function measureBox(text: string, style: TextStyle): TextBox { + const measured = CanvasTextMetrics.measureText(text || ' ', style); + const { ascent, descent } = measured.fontProperties; + const pixi = { width: measured.width, lineHeight: measured.lineHeight, lines: measured.lines.length }; + return textBox({ ...pixi, ascent, descent }, faceBox(style._fontString)); +} + +/** + * The box's rectangle in the pixels of a canvas drawn at `resolution` with the box's `room` as + * padding. Its edges fall on whole steps of `1 / snap` world units: a pixel for a smooth face, a + * whole unit for a sharp one, whose blocks of `resolution` pixels each make a texel. + */ +export function textCut( + box: TextBox, + resolution: number, + snap: number, +): { x: number; y: number; w: number; h: number } { + const step = resolution / snap; + const top = Math.floor((box.room + box.top) * snap); + const bottom = Math.ceil((box.room + box.top + box.height) * snap); + return { x: box.room * resolution, y: top * step, w: box.width * resolution, h: (bottom - top) * step }; +} + +/** Sets what a text's props say about its glyphs, `family` being its face as the browser knows it. */ +function styleText(style: TextStyle, props: PropertyBag, family: string): string { + style.fontFamily = family; + style.fontSize = Math.max(1, Number(props.fontSize ?? 32)); + style.letterSpacing = Number(props.letterSpacing ?? 0) || 0; + return textOf(props.text ?? ''); +} + +const measureStyle = new TextStyle({ fontFamily: Theme.font, fontSize: 32 }); +const measuredTexts = new Map(); + +/** + * A text's footprint is the box its picture is cut to, so the frame around it, a click on it and + * the camera's framing all go by what is drawn rather than by an estimate. Without a canvas – a + * unit test in node – there is nothing to measure with, and the estimate stands. + */ +setTextMeasure((props, font) => { + if (typeof OffscreenCanvas === 'undefined') return null; + const text = styleText(measureStyle, props, fontLibrary.family(font) || Theme.font); + const key = JSON.stringify([text, measureStyle._fontString, measureStyle.letterSpacing]); + const known = measuredTexts.get(key); + if (known) return known; + const box = measureBox(text, measureStyle); + const size = { w: box.width, h: box.height }; + if (measuredTexts.size >= 1000) measuredTexts.clear(); + measuredTexts.set(key, size); + return size; +}); + /** * The text each picture shows, by its mesh. The mesh is a sprite quad and carries no text, so the test * hook reads a game text through here – otherwise `locate` finds only the editor's own labels. @@ -95,21 +195,15 @@ export class TextPicture { sync(item: DrawItem, scale: number, shaders: ShaderSource, grid: number | null = null): void { // An uploaded face that has not arrived yet reads as `''`; until it does, the editor's own // typeface stands in, and the library's change event brings the redraw that swaps it. - const family = fontLibrary.family(item.font) || Theme.font; - const text = textOf(item.props.text ?? ''); - const size = Math.max(1, Number(item.props.fontSize ?? 32)); - const spacing = Number(item.props.letterSpacing ?? 0) || 0; + const text = styleText(this.style, item.props, fontLibrary.family(item.font) || Theme.font); const color = Number(item.props.color ?? 0xffffff); const resolution = textResolution(scale, item.fontSmooth); - const key = JSON.stringify([text, family, size, spacing, color, resolution]); + const key = JSON.stringify([text, this.style._fontString, this.style.letterSpacing, color, resolution]); shownTexts.set(this.mesh, text); const stale = key !== this.rasterKey; const previous = stale ? this.texture : null; if (stale) { this.rasterKey = key; - this.style.fontFamily = family; - this.style.fontSize = size; - this.style.letterSpacing = spacing; this.style.fill = color; if (item.fontSmooth) this.raster(text, resolution); else this.rasterSharp(text, color); @@ -129,36 +223,48 @@ export class TextPicture { } /** - * What a check reads: the face, the width before the view's zoom, how the glyphs are sampled, and - * how many texels of the picture are neither empty nor fully covered. + * What a check reads: the face, the size before the view's zoom, how the glyphs are sampled, how + * many texels of the picture are neither empty nor fully covered, and how many in its top and its + * bottom row are inked – a glyph cut off at an edge leaves ink there. */ info(): { family: string; width: number; + height: number; resolution: number; smooth: boolean; shader: string; partial: number; + edgeInk: { top: number; bottom: number }; } { const source = this.texture?.source; return { family: String(this.style.fontFamily), width: Math.round(Math.abs(this.mesh.scale.x)), + height: Math.round(Math.abs(this.mesh.scale.y)), resolution: source?.resolution ?? 0, smooth: source?.style.scaleMode !== 'nearest', shader: wornShaderId(this.mesh), - partial: this.partialTexels(), + ...this.texels(), }; } - private partialTexels(): number { + private texels(): { partial: number; edgeInk: { top: number; bottom: number } } { const canvas = this.texture?.source.resource as OffscreenCanvas | undefined; const context = canvas?.getContext('2d'); - if (!canvas || !context) return 0; + if (!canvas || !context) return { partial: 0, edgeInk: { top: 0, bottom: 0 } }; const { data } = context.getImageData(0, 0, canvas.width, canvas.height); + const row = canvas.width * 4; let partial = 0; - for (let i = 3; i < data.length; i += 4) if (data[i]! > 0 && data[i]! < 255) partial++; - return partial; + let top = 0; + let bottom = 0; + for (let i = 3; i < data.length; i += 4) { + const alpha = data[i]!; + if (alpha > 0 && alpha < 255) partial++; + if (alpha > 0 && i < row) top++; + if (alpha > 0 && i >= data.length - row) bottom++; + } + return { partial, edgeInk: { top, bottom } }; } destroy(): void { @@ -168,18 +274,32 @@ export class TextPicture { } private raster(text: string, resolution: number): void { - const { canvasAndContext, frame } = CanvasTextGenerator.getCanvasAndContext({ + const box = measureBox(text, this.style); + this.style.padding = box.room; + const { canvasAndContext } = CanvasTextGenerator.getCanvasAndContext({ text, style: this.style, resolution, }); // The pooled canvas goes back at once and may be bigger than the text, so the text is copied onto // a canvas of exactly its size: the quad's 0..1 UV are then the text's rectangle and nothing more. - const { width, height } = frame; + const cut = textCut(box, resolution, resolution); + const width = cut.w; + const height = cut.h; const canvas = new OffscreenCanvas(width, height); canvas .getContext('2d') - ?.drawImage(canvasAndContext.canvas as CanvasImageSource, 0, 0, width, height, 0, 0, width, height); + ?.drawImage( + canvasAndContext.canvas as CanvasImageSource, + cut.x, + cut.y, + width, + height, + 0, + 0, + width, + height, + ); CanvasTextGenerator.returnCanvasAndContext(canvasAndContext); this.texture = new Texture({ source: new CanvasSource({ resource: canvas, resolution, alphaMode: 'premultiply-alpha-on-upload' }), @@ -190,14 +310,17 @@ export class TextPicture { /** A sharp face: rasterised `SHARP_SUPERSAMPLE` times finer and cut down to hard texels. */ private rasterSharp(text: string, color: number): void { - const { canvasAndContext, frame } = CanvasTextGenerator.getCanvasAndContext({ + const box = measureBox(text, this.style); + this.style.padding = box.room; + const { canvasAndContext } = CanvasTextGenerator.getCanvasAndContext({ text, style: this.style, resolution: SHARP_SUPERSAMPLE, }); - const fine = canvasAndContext.context.getImageData(0, 0, frame.width, frame.height).data; + const cut = textCut(box, SHARP_SUPERSAMPLE, 1); + const fine = canvasAndContext.context.getImageData(cut.x, cut.y, cut.w, cut.h).data; CanvasTextGenerator.returnCanvasAndContext(canvasAndContext); - const sharp = sharpCoverage(fine, frame.width, frame.height, SHARP_SUPERSAMPLE, color); + const sharp = sharpCoverage(fine, cut.w, cut.h, SHARP_SUPERSAMPLE, color); const canvas = new OffscreenCanvas(sharp.width, sharp.height); canvas.getContext('2d')?.putImageData(new ImageData(sharp.data, sharp.width, sharp.height), 0, 0); this.texture = new Texture({ diff --git a/tests/core/text-and-pointer.test.ts b/tests/core/text-and-pointer.test.ts index 6999ac8c..1188b0d2 100644 --- a/tests/core/text-and-pointer.test.ts +++ b/tests/core/text-and-pointer.test.ts @@ -2,11 +2,12 @@ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; -import { footprintOf, textSize } from '../../src/core/properties/bounds'; +import { footprintContains, footprintOf, setTextMeasure, textSize } from '../../src/core/properties/bounds'; +import { IDENTITY } from '../../src/core/model/matrix'; import { makeSpriteScene, place, startWorld } from '../helpers'; describe('text objects', () => { - it('measures a text footprint from its content and font size', () => { + it('estimates a text footprint from its content and font size where nothing measures', () => { expect(textSize({ text: 'ab', fontSize: 100 }).w).toBeCloseTo(110, 5); expect(textSize({ text: 'ab', fontSize: 100 }).h).toBeCloseTo(125, 5); expect(footprintOf('text', { text: 'abcd', fontSize: 100 }).w).toBeCloseTo(220, 5); @@ -17,6 +18,27 @@ describe('text objects', () => { expect(textSize({ text: 'abcd', fontSize: 100, letterSpacing: -6 }).w).toBeCloseTo(202, 5); expect(textSize({ text: 'a', fontSize: 100, letterSpacing: 10 }).w).toBeCloseTo(55, 5); }); + + it('takes the measured size, in the text’s own font, once something measures', () => { + const asked: string[] = []; + setTextMeasure((props, font) => { + asked.push(font); + return props.text === 'unmeasured' ? null : { w: 300, h: 80 }; + }); + try { + expect(footprintOf('text', { text: 'Ag', fontSize: 100 }, null, 'face.ttf')).toEqual({ w: 300, h: 80 }); + expect( + footprintContains('text', { text: 'Ag', align: 'center' }, IDENTITY, { x: 140, y: 0 }, 'a.ttf'), + ).toBe(true); + expect(footprintContains('text', { text: 'Ag', align: 'center' }, IDENTITY, { x: 0, y: 45 })).toBe( + false, + ); + expect(asked).toEqual(['face.ttf', 'a.ttf', '']); + expect(textSize({ text: 'unmeasured', fontSize: 100 }).h).toBeCloseTo(125, 5); + } finally { + setTextMeasure(null); + } + }); }); describe('the mouse', () => { diff --git a/tests/core/text-shader.test.ts b/tests/core/text-shader.test.ts index 49af524d..5d0a7c97 100644 --- a/tests/core/text-shader.test.ts +++ b/tests/core/text-shader.test.ts @@ -11,7 +11,7 @@ import { fontSmoothOf } from '../../src/core/model/font'; import { findAssetOf } from '../../src/core/model/Project'; import { schemaFor, schemaForAsset, schemaForObject } from '../../src/core/properties/PropertySchema'; import { SHADER_KEY, SHADER_PROPERTY } from '../../src/core/properties/shaders'; -import { sharpCoverage, textResolution } from '../../src/viewport/textPicture'; +import { sharpCoverage, textBox, textCut, textResolution } from '../../src/viewport/textPicture'; import { makeSpriteScene, place, roundTrip } from '../helpers'; function wearing(uniform = 'glow') { @@ -127,3 +127,37 @@ describe('the filter of a font', () => { expect([...sharp.data].filter((_, i) => i % 4 === 3)).toEqual([255, 0]); }); }); + +describe('the box a text is drawn in', () => { + // Manrope at 2000 units per em: Pixi's "|ÉqÅM" reaches 2019 up and 480 down, the face declares + // 2132 and 600, and the tail of its "g" reaches 510 down. + const pixi = { width: 812.4, lineHeight: 2499, lines: 1, ascent: 2019, descent: 480 }; + const face = { ascent: 2132, descent: 600 }; + + it('is as tall as the face’s own line box, so the tail of a "g" is inside', () => { + const box = textBox(pixi, face); + expect(box).toEqual({ width: 813, height: 2732, top: -113, room: 120 }); + // The baseline lies `ascent - top` below the box's top edge, and the "g" ends 510 below that. + expect(pixi.ascent - box.top + 510).toBeLessThanOrEqual(box.height); + }); + + it('keeps Pixi’s box wherever that one is the taller, and all of it without a face box', () => { + expect(textBox(pixi, { ascent: 1800, descent: 700 })).toMatchObject({ + height: 2019 + 700, + top: 0, + room: 220, + }); + expect(textBox(pixi, null)).toEqual({ width: 813, height: 2499, top: 0, room: 0 }); + }); + + it('spaces lines as Pixi does and adds the face’s reach above the first and below the last', () => { + expect(textBox({ ...pixi, lines: 3 }, face).height).toBe(2132 + 2 * 2499 + 600); + }); + + it('is cut out of the padded canvas on whole pixels, or on whole units for a sharp face', () => { + const box = { width: 100, height: 10.4, top: -0.6, room: 1 }; + expect(textCut(box, 2, 2)).toEqual({ x: 2, y: 0, w: 200, h: 22 }); + expect(textCut({ ...box, top: -0.2 }, 2, 2)).toEqual({ x: 2, y: 1, w: 200, h: 22 }); + expect(textCut(box, 8, 1)).toEqual({ x: 8, y: 0, w: 800, h: 88 }); + }); +}); diff --git a/tools/e2e/30-font.mjs b/tools/e2e/30-font.mjs index 8d37cd14..02047f7c 100644 --- a/tools/e2e/30-font.mjs +++ b/tools/e2e/30-font.mjs @@ -218,6 +218,26 @@ export async function font(t) { JSON.stringify({ plain: plain.width, wide: wide?.width, tight: tight?.width }), ); + // 30f-iii. A text is as tall as its face's own line box, not as Pixi's "|ÉqÅM" reaches, so a + // tail like Manrope's "g" stays whole – and the frame around it is the drawn picture. + const upright = await waitFor(info, (value) => value?.width === plain.width); + const lineBox = await evaluate(`(() => { + const context = new OffscreenCanvas(1, 1).getContext('2d'); + context.font = '48px ' + ${JSON.stringify(upright?.family ?? '')}; + const metrics = context.measureText('M'); + return metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; + })()`); + const framed = await json(`__gm.objectRect(${JSON.stringify(TEXT_OBJECT)})`); + const frameScale = framed ? framed.w / upright.width : 0; + check( + 'a text is as tall as its face’s line box, with no glyph cut at its top or bottom, and framed as drawn', + Math.abs(upright?.height - lineBox) <= 1 && + upright?.edgeInk.top === 0 && + upright?.edgeInk.bottom === 0 && + Math.abs(framed?.h / frameScale - upright?.height) <= 1, + JSON.stringify({ upright, lineBox, framed }), + ); + // 30g. Dropping a font file onto the editor uploads it and makes an asset named after the file. const dropped = await evaluate(` (async () => {