From 93d039cc9851a029644c02b658f53f6c565fad22 Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Sun, 28 Jan 2024 21:06:30 -0600 Subject: [PATCH] feat: Implement Font lineHeight (#2905) This PR implements a feature suggestion for line height in Excalibur Text --- CHANGELOG.md | 1 + src/engine/Graphics/Font.ts | 5 ++ src/engine/Graphics/FontCommon.ts | 4 ++ src/engine/Graphics/FontTextInstance.ts | 21 +++--- src/engine/Graphics/SpriteFont.ts | 10 ++- src/spec/TextSpec.ts | 68 ++++++++++++++++++ .../GraphicsTextSpec/line-height-linux.png | Bin 0 -> 3357 bytes .../images/GraphicsTextSpec/line-height.png | Bin 0 -> 3743 bytes .../sprite-font-line-height.png | Bin 0 -> 1020 bytes 9 files changed, 99 insertions(+), 10 deletions(-) create mode 100644 src/spec/images/GraphicsTextSpec/line-height-linux.png create mode 100644 src/spec/images/GraphicsTextSpec/line-height.png create mode 100644 src/spec/images/GraphicsTextSpec/sprite-font-line-height.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fa9f654b..b40c21502 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added +- Added new `lineHeight` property on `SpriteFont` and `Font` to manually adjust the line height when rendering text. - Added missing dual of `ex.GraphicsComponent.add()`, you can now `ex.GraphicsComponent.remove(name)`; - Added additional options to `ex.Animation.fromSpriteSheetCoordinates()` you can now pass any valid `ex.GraphicOptions` to influence the sprite per frame ```typescript diff --git a/src/engine/Graphics/Font.ts b/src/engine/Graphics/Font.ts index 61c15cdb3..28b56b0d7 100644 --- a/src/engine/Graphics/Font.ts +++ b/src/engine/Graphics/Font.ts @@ -43,6 +43,7 @@ export class Font extends Graphic implements FontRenderer { this.textAlign = options?.textAlign ?? this.textAlign; this.baseAlign = options?.baseAlign ?? this.baseAlign; this.direction = options?.direction ?? this.direction; + this.lineHeight = options?.lineHeight ?? this.lineHeight; this.quality = options?.quality ?? this.quality; if (options?.shadow) { this.shadow = {}; @@ -98,6 +99,10 @@ export class Font extends Graphic implements FontRenderer { public textAlign: TextAlign = TextAlign.Left; public baseAlign: BaseAlign = BaseAlign.Alphabetic; public direction: Direction = Direction.LeftToRight; + /** + * Font line height in pixels, default line height if unset + */ + public lineHeight: number | undefined = undefined; public size: number = 10; public shadow: { blur?: number; offset?: Vector; color?: Color } = null; diff --git a/src/engine/Graphics/FontCommon.ts b/src/engine/Graphics/FontCommon.ts index 3d177dbed..b4d698c3d 100644 --- a/src/engine/Graphics/FontCommon.ts +++ b/src/engine/Graphics/FontCommon.ts @@ -147,6 +147,10 @@ export interface FontOptions { * Optionally specify the text direction, by default LeftToRight */ direction?: Direction; + /** + * Optionally override the text line height in pixels, useful for multiline text. If unset will use default. + */ + lineHeight?: number | undefined; /** * Optionally specify the quality of the text bitmap, it is a multiplier on the size size, by default 2. * Higher quality text has a higher memory impact diff --git a/src/engine/Graphics/FontTextInstance.ts b/src/engine/Graphics/FontTextInstance.ts index 1b11cb009..5593f732e 100644 --- a/src/engine/Graphics/FontTextInstance.ts +++ b/src/engine/Graphics/FontTextInstance.ts @@ -57,11 +57,15 @@ export class FontTextInstance { } private _setDimension(textBounds: BoundingBox, bitmap: CanvasRenderingContext2D) { + let lineHeightRatio = 1; + if (this.font.lineHeight) { + lineHeightRatio = (this.font.lineHeight/this.font.size); + } // Changing the width and height clears the context properties // We double the bitmap width to account for all possible alignment // We scale by "quality" so we render text without jaggies bitmap.canvas.width = (textBounds.width + this.font.padding * 2) * 2 * this.font.quality; - bitmap.canvas.height = (textBounds.height + this.font.padding * 2) * 2 * this.font.quality; + bitmap.canvas.height = (textBounds.height + this.font.padding * 2) * 2 * this.font.quality * lineHeightRatio; } public static getHashCode(font: Font, text: string, color?: Color) { @@ -73,6 +77,7 @@ export class FontTextInstance { font.textAlign + font.baseAlign + font.direction + + font.lineHeight + JSON.stringify(font.shadow) + (font.padding.toString() + font.smoothing.toString() + @@ -187,7 +192,7 @@ export class FontTextInstance { this.dimensions = this.measureText(this.text, maxWidth); this._setDimension(this.dimensions, this.ctx); const lines = this._getLinesFromText(this.text, maxWidth); - const lineHeight = this.dimensions.height / lines.length; + const lineHeight = this.font.lineHeight ?? this.dimensions.height / lines.length; // draws the text to the main bitmap this._drawText(this.ctx, lines, lineHeight); @@ -245,12 +250,12 @@ export class FontTextInstance { * @param text * @param maxWidth */ - private _chachedText: string; - private _chachedLines: string[]; + private _cachedText: string; + private _cachedLines: string[]; private _cachedRenderWidth: number; private _getLinesFromText(text: string, maxWidth?: number) { - if (this._chachedText === text && this._cachedRenderWidth === maxWidth) { - return this._chachedLines; + if (this._cachedText === text && this._cachedRenderWidth === maxWidth) { + return this._cachedLines; } const lines = text.split('\n'); @@ -275,8 +280,8 @@ export class FontTextInstance { } } - this._chachedText = text; - this._chachedLines = lines; + this._cachedText = text; + this._cachedLines = lines; this._cachedRenderWidth = maxWidth; return lines; diff --git a/src/engine/Graphics/SpriteFont.ts b/src/engine/Graphics/SpriteFont.ts index b62e5eb06..1189a7cc6 100644 --- a/src/engine/Graphics/SpriteFont.ts +++ b/src/engine/Graphics/SpriteFont.ts @@ -22,6 +22,10 @@ export interface SpriteFontOptions { * Optionally ignore case in the supplied text; */ caseInsensitive?: boolean; + /** + * Optionally override the text line height, useful for multiline text. If unset will use default. + */ + lineHeight?: number | undefined; /** * Optionally adjust the spacing between character sprites */ @@ -40,17 +44,19 @@ export class SpriteFont extends Graphic implements FontRenderer { public shadow: { offset: Vector } = null; public caseInsensitive = false; public spacing: number = 0; + public lineHeight: number | undefined = undefined; private _logger = Logger.getInstance(); constructor(options: SpriteFontOptions & GraphicOptions) { super(options); - const { alphabet, spriteSheet, caseInsensitive, spacing, shadow } = options; + const { alphabet, spriteSheet, caseInsensitive, spacing, shadow, lineHeight } = options; this.alphabet = alphabet; this.spriteSheet = spriteSheet; this.caseInsensitive = caseInsensitive ?? this.caseInsensitive; this.spacing = spacing ?? this.spacing; this.shadow = shadow ?? this.shadow; + this.lineHeight = lineHeight ?? this.lineHeight; } private _getCharacterSprites(text: string): Sprite[] { @@ -109,7 +115,7 @@ export class SpriteFont extends Graphic implements FontRenderer { height = Math.max(height, sprite.height); } xCursor = 0; - yCursor += height; + yCursor += this.lineHeight ?? height; } } diff --git a/src/spec/TextSpec.ts b/src/spec/TextSpec.ts index ffb344da6..a327be1ba 100644 --- a/src/spec/TextSpec.ts +++ b/src/spec/TextSpec.ts @@ -444,6 +444,35 @@ describe('A Text Graphic', () => { }); }); + it('can have line height', async () => { + const sut = new ex.Text({ + text: 'green text\nthat has multiple\nlines to it.', + color: ex.Color.Green, + font: new ex.Font({ + family: 'Open Sans', + size: 18, + quality: 1, + lineHeight: 36 + }) + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 100; + canvasElement.height = 100; + const ctx = new ex.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.draw(ctx, 10, 10); + + await runOnWindows(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/line-height.png'); + }); + + await runOnLinux(async () => { + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/line-height-linux.png'); + }); + }); + it('can have a shadow', async () => { const sut = new ex.Text({ text: 'green text', @@ -890,6 +919,45 @@ describe('A Text Graphic', () => { await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/spritefont-text.png'); }); + + it('can have a custom lineHeight', async () => { + const spriteFontImage = new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png'); + + await spriteFontImage.load(); + + const spriteFontSheet = ex.SpriteSheet.fromImageSource({ + image: spriteFontImage, + grid: { + rows: 3, + columns: 16, + spriteWidth: 16, + spriteHeight: 16 + } + }); + + const spriteFont = new ex.SpriteFont({ + alphabet: '0123456789abcdefghijklmnopqrstuvwxyz,!\'&."?- ', + caseInsensitive: true, + spacing: -5, + spriteSheet: spriteFontSheet, + lineHeight: 32 + }); + + const sut = new ex.Text({ + text: '111\n222\n333\n444', + font: spriteFont + }); + + const canvasElement = document.createElement('canvas'); + canvasElement.width = 200; + canvasElement.height = 100; + const ctx = new ex.ExcaliburGraphicsContext2DCanvas({ canvasElement }); + + ctx.clear(); + sut.draw(ctx, 0, 0); + + await expectAsync(canvasElement).toEqualImage('src/spec/images/GraphicsTextSpec/sprite-font-line-height.png'); + }); }); it('will log warnings when there are issues', async () => { diff --git a/src/spec/images/GraphicsTextSpec/line-height-linux.png b/src/spec/images/GraphicsTextSpec/line-height-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..43db22a13e30f695b7e0a08c544570e9554e272e GIT binary patch literal 3357 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 Q*L{afPs>QNR^18lKmI;eP5=M^ literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/line-height.png b/src/spec/images/GraphicsTextSpec/line-height.png new file mode 100644 index 0000000000000000000000000000000000000000..2cb2a261c11e2d3396902e82dadace60ee28ee98 GIT binary patch literal 3743 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 literal 0 HcmV?d00001 diff --git a/src/spec/images/GraphicsTextSpec/sprite-font-line-height.png b/src/spec/images/GraphicsTextSpec/sprite-font-line-height.png new file mode 100644 index 0000000000000000000000000000000000000000..97790e4c1f739828e4b54f629b542b859af9cee8 GIT binary patch literal 1020 zcmeAS@N?(olHy`uVBq!ia0vp^CxAGGgAGU?ZmZ`8QjEnx?oJHr&dI!FU|`Eakt z5%>1)#oW6FJZ=xux^k|m8u?E@kv4acD>n-V)9)z0+|DO|f9cG8^0W5UYnurD_4)7X z>wdH|FwJI(<9Kw=Az?m);~iNJ8wCe}?d(h}h5QO1Y?7f zvv04w($4I+iHY0-J30?IJKE*_5OjPORekx6UiSe3M*j(lK4P}oj7>J#pPtDnIiPYk zXrFjK{oVQSTfefe#b&pwZQy8>=9IB>%vhzYJ+Q^SxTVKA+++f@2>$r{Y&`oQZ=j*n zh;rG!;%B>5GqN_xHnum_{C)Z5g~Ybq*S0C=EeK+1yYF}+P%HdwoXdlU)+hE$xwn_k z`#G~D?XBzQojgp6Oh65Le}yN0JI8i&((5y~E`*pV7YH^koc^Ud3n>4TLtsfviqt&M z1_k5H)uDY1j;CCgJ)FtHVKjML)F71Psh|BldDB||iSKF+4R>vm@#WpFZ19EUrnPiR z_p#XQ+Y`+LIDuZiQ+jy*&OFXZvvNES{8Z3QHoX$c!Z%+*zV+On{`%{;R=1VOvbb^D z*!+9&*?XHYm;1!1>zxNy?oWy>EP6XrxX`-M%-fggu>HoYzUA30Nh};fkJHPGnOHLA fHhdghynjq`Dk~gTpU-UrW@iRZS3j3^P6