Keep descenders of a text whole and frame it at its drawn size
A text was drawn into a texture as tall as Pixi measures the string "|ÉqÅM". Whatever reached further, like the "g" of Manrope (0.015 em below the "q"), was cut off at the bottom. The blue frame around a text was only estimated from the font size and the number of letters, so it was far too wide for most texts. - textPicture.ts takes the height of a line from the font itself (fontBoundingBoxAscent and fontBoundingBoxDescent, as a browser does). Pixi draws with a margin, and the picture is cut to that box afterwards. Where Pixi's own box is larger, Pixi's box wins. - Both raster paths (linear and nearest) use the cut. - The height no longer depends on the letters typed, so a text does not jump while it is edited. - bounds.ts asks a measurer that textPicture.ts registers (setTextMeasure). Selection, picking in a running game, framing the camera, the drop preview and culling all get the measured size. The footprint functions take the font file for this. - Unit tests in node have no canvas and keep the estimate (estimatedTextSize). - Texts get taller: Source Sans 3 at 48 px is 68 instead of 55 px, Manrope is 1.366 em instead of 1.25 em. The glyphs stay in place, the centre moves by less than half a pixel. A shader on a text gets a taller UV rectangle. Tests: - Gate on melektaus-atx green: 307 files, 4762 tests. - 5 new unit tests for textBox, textCut and the measurer hook. - e2e 30: 26 checks, 30f-iii new. The texture is 68 high, the font box is 68, the frame equals the drawn picture (237x68). - Counter test: with Pixi's old box the texture is 55 high and 30f-iii is red. - Not checked in the browser: the Manrope caption of the user and texts with several lines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9445f39c8d
commit
d4ff652293
12 files changed
+284
-47
No files matched your search
@@ -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', () => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user