diff --git a/.cspell/project-words.txt b/.cspell/project-words.txt
index cb023b9f..5ef17b5c 100644
--- a/.cspell/project-words.txt
+++ b/.cspell/project-words.txt
@@ -222,3 +222,5 @@ winit
xadvance
yoavbls
Zoltan
+rrggbb
+rrggbbaa
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0641ba08..c709652a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **ecs:** Systems and system groups can run conditionally. Pass a `runIf` function of the world to `addSystem` or `addSystemGroup`, and the world checks it each tick just before the system or group would run, skipping it (and its query) when it returns `false`. `EcsWorld` also gains a built-in `firstSystemGroup` that runs before every other group of the tick. A group ordered `after` it joins the start of the tick, before every other group. Ordering a group `before` the first group throws, and so does ordering a group before a start-of-tick group, or a start-of-tick group after one that isn't
- **states:** New `@forge-game-engine/forge/states` module for a game's top-level states (menu, playing, paused, game over). `createGameState(world, initial)` returns a `GameState` whose `set` switches state at the start of the next tick. Its `exitGroup` and `enterGroup` run once per transition, before any other system, holding systems that use `onExit`/`onEnter` as their `runIf`; `inState` runs a system only in some states. The initial state is entered on the first tick, and setting the current state again restarts it. `addStateScopedComponent` removes an entity when its state leaves one of `removeOnExit` or enters one of `removeOnEnter`. See the new Game States guide and demo
- **rendering:** `getCameraView(world, camera, renderContext)` (and `computeCameraView(camera, position, renderContext)` for systems that already hold the camera's components) returns what a camera sees: the world area it shows (`bounds`, `size`, accounting for its position and zoom), its `pixelsPerUnit` in CSS pixels, and `worldToViewport`/`viewportToWorld` conversions to and from CSS pixels on the canvas. To place something drawn by one camera over something drawn by another, convert through the viewport: `hudView.viewportToWorld(gameView.worldToViewport(position))`
+- **text:** Rich text tags. `TextEcsComponent.text` (and `shapeText`) parse `...` and `...` (`#rgb`, `#rgba` and `#rrggbbaa` too) to style part of a string. Tags are stripped before shaping, so they never change kerning or wrapping. `` draws a synthetic bold from the same atlas, thickening each glyph by `FAUX_BOLD_EMBOLDEN` ems per side and widening its advance to match. A `` replaces the text's `color`, alpha included. Markup that isn't a valid tag, such as `HP < 50%` or ``, is drawn as literal text, so a string that happens to contain `` or `` now renders styled. `parseRichText` is public, and `GlyphQuad` gains `color` and `embolden`
- **text:** The engine's default font can be imported through a bundler from `@forge-game-engine/forge/fonts/default/default.json` and `@forge-game-engine/forge/fonts/default/default.png`, so you no longer need to copy it out of `node_modules`
#### Changed
diff --git a/design/rich-text-tags.md b/design/rich-text-tags.md
deleted file mode 100644
index 5ebdd5ab..00000000
--- a/design/rich-text-tags.md
+++ /dev/null
@@ -1,254 +0,0 @@
-# Design: Rich Text Tags
-
-| | |
-| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| **Status** | Draft — for review. Scoped out of `design/ui-system.md` backlog 5.7 rather than implemented directly, per real open questions surfaced while scoping it (see below). |
-| **Target module** | `/src/text` (shaping pipeline, `GlyphQuad`/`TextMeshEcsComponent`) — new; `/src/ui` unaffected, this is a text concern, not a UI one (matching DL-04's precedent) |
-| **Engine version at time of writing** | `0.24.2` |
-| **Model** | Inline markup tags (``, ``) parsed out of a `TextEcsComponent.text` string into per-run style data, consumed by `shapeText`/`createTextShapingEcsSystem` |
-
----
-
-## 1. Summary
-
-`design/ui-system.md`'s Phase 5 backlog (5.7) calls for rich text tags -
-`bold` and `red` - as inline markup inside
-`TextEcsComponent.text`, sized **L**. Scoping it for implementation (rather
-than guessing at an approach) surfaced real architectural questions the
-existing text pipeline doesn't have answers to yet, spanning three areas
-that are each individually non-trivial:
-
-1. **Per-glyph vs. per-entity styling.** Every color/effect field
- (`TextEcsComponent.color`, `outlineColor`, `outlineWidth`, `shadowColor`,
- `shadowOffset`, `shadowSoftness`) is a single value applied uniformly to
- every glyph in the entity. `GlyphQuad` carries no style data of its own
- at all - `pushTextFillRenderCommands` (`src/text/rendering/glyph-quad.ts`)
- reads `textComponent.color` once per entity and applies it to every
- glyph's synthetic sprite. A `` span needs a color that varies
- *within* one entity's text - a genuinely new capability, not a bug fix
- or an extension of an existing per-entity field.
-2. **What `` means without a bold-weight atlas.** `forge-generate-font-atlas`
- bakes exactly one `.ttf`/`.otf` file - one weight, one style - into one
- `FontAtlas`. There is no bold variant, no italic variant, and no concept
- of a font *family* with multiple weights anywhere in `/src/text`. ``
- has no meaning to render with today; it needs one of several genuinely
- different mechanisms (§7), each with real cost and limitations, decided
- before any shaping code is written.
-3. **Interaction with word-wrap and kerning.** `shapeText`
- (`src/text/utilities/shape-text.ts`, 560+ lines) tokenizes into words,
- measures each word's width for wrapping, and walks kerning pairs
- character-by-character within a word. Tags must be invisible to both -
- `AV` must kern "AV" exactly as `AV` would, and must
- not count the tag markup's own characters toward wrap width - which
- means stripping tags before measuring/kerning while retaining a
- character-index-to-style mapping for the glyphs that measurement and
- kerning walk eventually produces. Retrofitting that into an existing,
- well-tested shaping pipeline (rather than designing it in from the
- start) is exactly the kind of change worth a design pass before code.
-
-This document exists to answer these questions before backlog 5.7 is
-implemented, not to implement it - no code changes ship with this
-document, matching how the MSDF text rendering and
-column-aligned form layout designs were written before their own
-implementation phases.
-
----
-
-## 2. Scope
-
-### In scope
-
-- Inline tag syntax for the two tags backlog 5.7 names: `...` and
- `...`.
-- How tags interact with existing `TextEcsComponent` features: word-wrap
- (`maxWidth`), kerning, `horizontalAlign`/`verticalAlign`, and the
- existing outline/shadow effects.
-- The data-model change `GlyphQuad`/`TextMeshEcsComponent` needs to carry
- per-run style through to rendering.
-- Malformed/unclosed tag handling.
-
-### Out of scope
-
-- A general-purpose rich-text/markup language (headings, lists, inline
- images, hyperlinks). Backlog 5.7 names exactly two tags; this document
- scopes only those two, though the tag-parsing approach chosen should not
- make adding a third tag later disproportionately hard.
-- Bidirectional text and complex script shaping - already an explicit
- non-goal of the whole text/UI effort (`design/ui-system.md` §2).
-- Any change to `forge-generate-font-atlas`'s single-file-in, single-atlas-out
- model, unless the bold decision below (§7) requires it.
-
----
-
-## 3. Why this needs a design pass rather than a guess
-
-The three points in §1 are not independent - the answer to "what does
-`` mean" determines whether bold needs new atlas-level data (a second
-`FontAtlas`) or can be expressed as a per-glyph shader parameter on the
-*existing* atlas, which in turn determines how much of `GlyphQuad`'s shape
-needs to change, and whether that change looks anything like ``'s
-(a plain per-glyph field) or needs its own, differently-shaped mechanism
-entirely. Picking a shape for one without deciding the other risks a
-second migration once the real answer to `` is settled. Guessing here -
-the way an implementer would
-have to, absent this document - risks: shipping a `GlyphQuad` shape that
-can express color-per-glyph but not weight-per-glyph (or vice versa),
-tag-stripping logic bolted onto `shapeText` in a way that silently breaks
-an existing wrap/kerning test case, and a syntax choice (see §6) that
-doesn't extend cleanly if a third tag is ever wanted.
-
----
-
-## 4. Phases
-
-Two independently shippable phases fall out of resolving §7 in favor of
-faux-bold (the recommended direction) - if a real bold atlas is chosen
-instead, Phase 2 changes shape but Phase 1 is unaffected either way, since
-`` doesn't depend on the `` decision at all.
-
-### Phase 1 — Tag parsing + ``
-
-| # | Task | Size |
-| - | ---- | ---- |
-| 1.1 | Tag parser: strips ``/`` (and, structurally, any future tag) from a `TextEcsComponent.text` string into a plain string plus a list of `{ startIndex, endIndex, color }` runs over that plain string's character indices | M |
-| 1.2 | `shapeText` accepts the parsed plain string (unchanged measurement/kerning/wrap logic - it already only sees the stripped string) plus the run list, and stamps each emitted `GlyphQuad` with the color of the run its source character index falls in | M |
-| 1.3 | `GlyphQuad.color?: Color` (optional - `undefined` for a glyph with no active `` run, falling back to `TextEcsComponent.color` exactly as today) | S |
-| 1.4 | `pushTextFillRenderCommands` reads `glyph.color ?? textComponent.color` per glyph instead of `textComponent.color` for every glyph | S |
-| 1.5 | Malformed-tag handling: an unclosed `` runs to the end of the string; an unrecognized tag name throws (see §8) | S |
-| 1.6 | Unit tests: nested-vs-sequential runs, a tag spanning a word-wrap break, kerning across a tag boundary, malformed tags | M |
-| 1.7 | Docs + demo update (`documentation-site/src/pages/demos/text`) | S |
-
-**Phase 1 exit criterion:** `red and green text` renders with each span in its own color, word-wraps identically to the same string with tags removed, and kerns identically across tag boundaries to the equivalent untagged string.
-
-### Phase 2 — ``
-
-Blocked on resolving the bold mechanism (§7). Sized **L** on its own if a
-second atlas per weight is chosen (needs `forge-generate-font-atlas`
-changes, a `FontAtlas` weight-selection API, and asset-pipeline docs); **M**
-if faux-bold is chosen (a per-glyph shader parameter, closer in shape to
-the existing outline effect than to a new asset type).
-
----
-
-## 5. Decision log
-
-### DL-1 — Runs are computed as index ranges over the stripped string, not stored inline in a parsed tree
-
-**Options.** (a) Parse into a tree of styled nodes (an AST), walked during
-shaping. (b) Strip tags into a plain string plus a flat list of
-`{ startIndex, endIndex, style }` runs against that string's indices.
-
-**Decision: (b).**
-
-**Rationale.** `shapeText` already operates on a plain string end to end
-- tokenizing into words, measuring, kerning, wrapping. A flat run list
-keyed by character index slots into that pipeline with the smallest
-possible change: every place `shapeText` already tracks "which source
-character is this glyph" (it must, to look up whitespace/kerning) can look
-up the active run(s) at that index the same way. A tree walk would instead
-require re-deriving word/line boundaries against tree node boundaries,
-duplicating logic `shapeText` already has for the plain-string case.
-Nested tags (`...`) are simply two run lists
-that both cover the same index range - no tree structure needed to express
-nesting when styles are independent axes (weight, color) rather than a
-strict containment hierarchy.
-
-**Consequences.** Adding a future tag type is "add another run list", not
-a tree-schema change. The parser (1.1) still has to validate proper
-nesting/closing at parse time even though the *output* is flat.
-
----
-
-## 6. Tag syntax
-
-**Recommendation: an HTML-like syntax, `` / `` / ``,
-matching the backlog's own examples exactly.** This is also what Unity's
-TextMeshPro and most game-engine rich text implementations converge on,
-not because of that precedent but because it reuses a syntax most authors
-already have muscle memory for, and is unambiguous to tokenize (a `<`
-followed immediately by a letter or `/` starts a tag; a lone `<` followed
-by anything else - a space, a digit, end of string - is literal text, so
-existing strings containing a bare `<` for other reasons stay unaffected
-unless they happen to spell out a real tag name).
-
-Literal `<`/`>` in authored text (wanting to display the character `<`
-itself) needs an escape - recommend `<`/`>`, again matching the
-existing web-adjacent convention rather than inventing a new one, at the
-cost of also needing to escape `&` itself (`&`) once entities exist at
-all. **Open question**, see §8 - whether this corner is worth the parser
-complexity for a v1 that only ships two tags is genuinely debatable.
-
----
-
-## 7. What `` means without a bold-weight atlas
-
-**Options.**
-
-(a) **A second `FontAtlas` per weight**, generated from a separate bold
-`.ttf`/`.otf` file, with `TextEcsComponent`/`createLabel` accepting a
-`boldFontAtlas` alongside `fontAtlas`, switched to for glyphs inside a
-`` run. Matches how real bold type actually looks (different glyph
-shapes, not just thicker outlines) - genuine typographic bold, at the cost
-of every consumer needing to source and ship a matching bold font file,
-and `forge-generate-font-atlas`/`FontAtlasCache` both needing multi-atlas
-awareness.
-
-(b) **Faux-bold via the existing outline mechanism**, extended to be
-settable per-glyph: a `` run's glyphs render with fill *plus* a small
-same-color outline (or a slightly negative signed-distance threshold
-shift), thickening strokes without a second atlas. This is what browsers
-do when a `font-weight: bold` is requested but only a regular weight is
-installed ("synthetic bold"), and is exactly the well-known approximation,
-with the well-known limitation: it doesn't reproduce a real bold cut's
-actual glyph shapes (different curve tension, different counter sizes),
-and reads noticeably worse at small sizes or with already-thick strokes.
-Reuses `msdf-effects.frag`'s existing screen-pixel-range outline math
-directly - the smallest implementation, and the one that needs no new
-asset type or pipeline change.
-
-(c) **No `` in v1** - ship `` only, defer bold until a concrete
-consumer needs real typographic bold badly enough to justify sourcing a
-second font file.
-
-**No decision recorded here** - this is the open question this whole
-document exists to raise, not resolve unilaterally, since it trades off
-asset-pipeline complexity (a) against visual fidelity (a vs. b) in a way
-that depends on how this module's consumers actually use bold text (a
-"press A to continue" prompt's occasional bold word vs. a text-heavy
-document viewer that needs correct typography), which isn't something to
-guess at. **Recommendation, not a decision: (b) for v1** - it ships without
-any asset-pipeline change, is a small, contained addition next to the
-outline effect that already exists, and can be superseded by (a) later
-for any consumer that needs real bold cuts without (b) having been wasted
-work, since the `` tag/run-list plumbing from Phase 1 doesn't change
-either way - only how a `` run is *rendered* changes.
-
----
-
-## 8. Open questions
-
-1. **Which of §7's three options for ``?** The one genuinely
- load-bearing decision this document raises. Needs a decision before
- Phase 2 can be sized precisely or started.
-2. **Escape sequences for literal `<`/`>`/`&`** - full entity-style
- escaping (§6), or a simpler rule (e.g. "a `<` not immediately followed
- by a recognized tag name or `/` is literal, no escaping needed at all")?
- The simpler rule covers the common case (stray `<`/`>` in game text,
- e.g. "HP < 50%") with zero authoring overhead, at the cost of making a
- literal ``-shaped substring unrepresentable without an escape hatch
- of some kind - probably an acceptable trade for a v1 shipping exactly
- two tag names.
-3. **Malformed tags: throw, or degrade gracefully?** §4's Phase 1 plan
- assumes an unrecognized tag name throws (loud failure during
- development, matching this codebase's general error-handling
- convention of throwing descriptive errors rather than silently
- degrading) and an unclosed tag runs to the end of the string (matching
- how a browser's own malformed-HTML recovery behaves, and simple to
- implement). Worth confirming both defaults before Phase 1 ships rather
- than after.
-4. **Does a `` run need its own alpha, or only RGB?** `Color` in
- this codebase always carries all four channels; the recommendation is
- a `` run fully replaces the fill color (RGB and alpha both),
- matching how `TextEcsComponent.color` itself works today, but this is
- worth confirming rather than assuming - a consumer might expect
- `` to change hue without changing opacity.
diff --git a/design/ui-system.md b/design/ui-system.md
index 73c26faa..d1eafc9a 100644
--- a/design/ui-system.md
+++ b/design/ui-system.md
@@ -87,9 +87,9 @@ unblocked.
CSS-ish style sheets, no parser. Layout is expressed in TypeScript.
- **No visual editor.** The engine is code-only by design. A future editor may
read and write this system, but is out of scope here.
-- **No rich-text document layout.** Single-font, single-style runs per text
- element for v1. No inline images, no bidirectional text, no complex script
- shaping (Arabic, Devanagari).
+- **No rich-text document layout.** One font per text element, styled only
+ by inline ``/`` tags. No inline images, no bidirectional text, no
+ complex script shaping (Arabic, Devanagari).
- **No DOM-backed widgets.** The one place browser text entry is unavoidable
(IME, mobile keyboards, clipboard) is isolated as an input primitive in
`/src/input`, not as DOM inside a UI component — so this module stays
@@ -1500,7 +1500,7 @@ widest label with no hand-computed offsets. Documented in the UI doc's "Layout g
| 5.4 | **Landed**, outside this module: text effects (outline, drop shadow, glow as MSDF shader parameters) shipped via `/src/text` ([#608](https://github.com/Forge-Game-Engine/Forge/pull/608), [#610](https://github.com/Forge-Game-Engine/Forge/pull/610)), documented in the text module's Text Effects doc. Nothing left to do here. | S |
| 5.5 | **Landed.** Tooltips + a UI-safe-area concept for notched displays | S |
| 5.6 | **Landed.** UI stress-test demo - dirty-tracking optimization evaluated and deferred, not warranted at the scale measured; see DL-12 | M (DL-12) |
-| 5.7 | **Scoped out.** Rich text tags (``, ``) — scoping surfaced real architectural gaps (no per-glyph styling data model, no bold-weight concept in the font-atlas pipeline); see [`design/rich-text-tags.md`](./rich-text-tags.md) for the design addendum instead of a guessed implementation. | L |
+| 5.7 | **Landed.** Rich text tags (``, ``) in `TextEcsComponent.text`: `shapeText` parses them into per-glyph color and a synthetic-bold embolden; see the Rendering Text guide. | L |
---
diff --git a/documentation-site/docs/docs/text/rendering-text.md b/documentation-site/docs/docs/text/rendering-text.md
index 09a22177..6c1ecfcd 100644
--- a/documentation-site/docs/docs/text/rendering-text.md
+++ b/documentation-site/docs/docs/text/rendering-text.md
@@ -95,7 +95,8 @@ wrapping, alignment) when `text`, `fontAtlas`, `size`, `letterSpacing`,
`lineHeight`, `horizontalAlign`, `verticalAlign`, `maxWidth`, or
`horizontalAlignPivot` actually changed since the last tick it ran against
this entity. Changing `color`, `layer`, or `enabled` alone never triggers a
-re-shape, they're read directly by the render system each frame.
+re-shape, they're read directly by the render system each frame. A
+`` tag is part of `text`, so changing one re-shapes the string.
## Multi-line layout
@@ -156,6 +157,57 @@ A single word wider than `maxWidth` on its own is never split mid-word - it
simply overflows its own line, the same as any other greedy word-wrapping
implementation.
+## Rich text tags
+
+Tags inside `text` style part of a string. Two are supported:
+
+- `...` draws its text bold.
+- `...` draws its text in a color. `#rgb`, `#rgba`
+ and `#rrggbbaa` work too. The color replaces `color` for those
+ characters, alpha included, so `` is half transparent.
+
+```ts
+addTextComponent(world, label, {
+ text: 'Press A to continue',
+ fontAtlas,
+ size: 24,
+});
+```
+
+Tags nest. When colors nest, the innermost one wins. `` and ``
+are independent, so their ranges can overlap without nesting cleanly.
+
+Tags are stripped before the string is shaped. Kerning, wrapping and
+alignment see only the visible text, so a tagged string lays out exactly as
+the same string without tags would. Bold glyphs are the one exception: they
+take up a little more room (see below). `shapeText` parses tags too, so
+bounds you measure with it match what's drawn.
+
+### Bold without a bold font
+
+A font atlas holds one weight. `` draws a synthetic bold, the same thing
+a browser does when a page asks for bold and only the regular font is
+installed: each glyph's edge is pushed out by
+[`FAUX_BOLD_EMBOLDEN`](/Forge/docs/api/variables/FAUX_BOLD_EMBOLDEN) ems on
+every side, and its advance grows by the added width so bold letters don't
+run into each other. It reads well at body and heading sizes, but it isn't a
+real bold cut: letter shapes are only thickened, not redrawn. An outline on
+bold text wraps the thickened ink (see [Text Effects](./text-effects.md)).
+
+### Literal `<`
+
+A `<` only starts a tag when it begins a complete ``, ``,
+`` or `` tag. Anything else is drawn as written, never
+an error, since text often comes from players or translations:
+
+- a `<` that doesn't start a complete tag: `HP < 50%`, `a<3`
+- an unknown tag, such as ``
+- a tag with an invalid value, such as ``
+- a closing tag with no matching open tag
+
+A tag that's never closed runs to the end of the string. There's no escape
+syntax, so the exact text `` can't be displayed.
+
## Positioning and scale
`size` is the rendered em height in world units, positioned the same way as
diff --git a/documentation-site/docs/docs/text/text-effects.md b/documentation-site/docs/docs/text/text-effects.md
index bdd9281f..bdaabc60 100644
--- a/documentation-site/docs/docs/text/text-effects.md
+++ b/documentation-site/docs/docs/text/text-effects.md
@@ -103,6 +103,13 @@ merged stroke or glow; see
"outline + soft shadow / glow together, at a larger size" example for what
a properly bold effect looks like at a size that has room for it.
+Bold text spends part of the same budget. A `` glyph (see
+[Rich text tags](./rendering-text.md#rich-text-tags)) is drawn by pushing
+its edge outwards through the distance field, and its outline and shadow
+are measured from that thicker edge, so they wrap the bold ink. What the
+bold edge uses is no longer available to the outline and shadow on that
+glyph.
+
## A note on extreme values and unusual glyphs
Multi-channel signed distance fields are a lossy, low-resolution encoding of
diff --git a/documentation-site/src/data/demos.ts b/documentation-site/src/data/demos.ts
index 170c09a3..dd000f6b 100644
--- a/documentation-site/src/data/demos.ts
+++ b/documentation-site/src/data/demos.ts
@@ -169,7 +169,7 @@ export const demos: Demo[] = [
slug: 'text',
title: 'Text Rendering',
description:
- 'MSDF text rendering: alignment, line height, live reflow and outline/shadow effects.',
+ 'MSDF text rendering: alignment, line height, live reflow, rich text tags and outline/shadow effects.',
categories: ['rendering'],
},
{
diff --git a/documentation-site/src/pages/demos/text/_create-game.ts b/documentation-site/src/pages/demos/text/_create-game.ts
index 1b881111..27661820 100644
--- a/documentation-site/src/pages/demos/text/_create-game.ts
+++ b/documentation-site/src/pages/demos/text/_create-game.ts
@@ -19,6 +19,7 @@ import { createEffectsHeroExample } from './_create-effects-hero-example';
import { createHorizontalAlignmentExamples } from './_create-horizontal-alignment-examples';
import { createLineHeightExamples } from './_create-line-height-examples';
import { createLiveMaxWidthExample } from './_create-live-max-width-example';
+import { createRichTextExample } from './_create-rich-text-example';
import { createVerticalAlignmentExamples } from './_create-vertical-alignment-examples';
import { createLiveMaxWidthEcsSystem } from './_live-max-width.system';
import { createPlayground, Playground } from './_create-playground';
@@ -42,7 +43,8 @@ const sectionGap = 16;
* Builds the text rendering demo: a showcase of every `horizontalAlign` and
* `verticalAlign` value, a comparison of a few `lineHeight` multipliers, a
* paragraph whose `maxWidth` oscillates every frame to show
- * `createTextShapingEcsSystem` reflowing text live, an interactive
+ * `createTextShapingEcsSystem` reflowing text live, rich text tags, an
+ * interactive
* playground, and an outline/soft-shadow showcase, all drawn from one
* shared `FontAtlas`: the engine's default font, imported through the
* package's `fonts/default` exports so webpack serves both files.
@@ -135,6 +137,17 @@ export const createTextGame = async (
);
y -= sectionGap;
+ y = createRichTextExample(
+ world,
+ fontAtlas,
+ whiteSprite,
+ drawOrder.guide,
+ drawOrder.content,
+ { x: left, y },
+ usableWidth,
+ );
+ y -= sectionGap;
+
const playground = createPlayground(
world,
fontAtlas,
diff --git a/documentation-site/src/pages/demos/text/_create-playground.ts b/documentation-site/src/pages/demos/text/_create-playground.ts
index d2481db4..d9561caa 100644
--- a/documentation-site/src/pages/demos/text/_create-playground.ts
+++ b/documentation-site/src/pages/demos/text/_create-playground.ts
@@ -22,7 +22,7 @@ const glowColor = new Color(0.15, 0.65, 1, 1);
/** The values the playground's controls start at (see `_PlaygroundControls.tsx`). */
export const playgroundDefaults = {
- text: "Type your own text here! This is the engine's shipped default font atlas (Liberation Sans, SIL OFL 1.1) - zero font setup required.",
+ text: "Type your own text here! This is the engine's shipped default font atlas (Liberation Sans, SIL OFL 1.1) - zero font setup required. Try bold and color tags.",
size: 24,
minSize: 12,
maxSize: 56,
diff --git a/documentation-site/src/pages/demos/text/_create-rich-text-example.ts b/documentation-site/src/pages/demos/text/_create-rich-text-example.ts
new file mode 100644
index 00000000..31ebd02e
--- /dev/null
+++ b/documentation-site/src/pages/demos/text/_create-rich-text-example.ts
@@ -0,0 +1,107 @@
+import { addPositionComponent } from '@forge-game-engine/forge/common';
+import { EcsWorld } from '@forge-game-engine/forge/ecs';
+import { Vector2 } from '@forge-game-engine/forge/math';
+import { Color, SpriteEcsComponent } from '@forge-game-engine/forge/rendering';
+import {
+ addTextComponent,
+ FontAtlas,
+ shapeText,
+} from '@forge-game-engine/forge/text';
+import { createGuideBox } from './_create-guide-box';
+
+const paragraph =
+ "Rich text tags style part of a string: bold words, colored words, and both at once. Tags are stripped before shaping, so this paragraph wraps and kerns exactly as it would without them. Markup that isn't a tag stays literal: HP < 50%.";
+const outlinedText = 'Bold and regular, outlined';
+const bodySize = 16;
+const outlinedSize = 24;
+const captionSize = 13;
+const captionColor = new Color(0.55, 0.6, 0.72, 1);
+const bodyColor = new Color(0.9, 0.92, 0.96, 1);
+const outlineColor = new Color(1, 0.55, 0.15, 1);
+const captionGap = 14;
+const rowGap = 12;
+
+/**
+ * Builds the rich text section: a wrapped paragraph mixing `` and
+ * `` tags, and a bold word next to a regular one with an outline, to
+ * show the outline following the thickened bold ink.
+ * @param world - The ECS world to add label entities to.
+ * @param fontAtlas - The font atlas every label draws from.
+ * @param whiteSprite - A plain white sprite template for the guide box.
+ * @param guideLayer - The draw-order layer for the guide box (drawn behind text).
+ * @param contentLayer - The draw-order layer for the caption/body text.
+ * @param topLeft - This section's top-left corner, in world units.
+ * @param usableWidth - The total width available to lay the example out in.
+ * @returns The y coordinate immediately below the section's content.
+ */
+export function createRichTextExample(
+ world: EcsWorld,
+ fontAtlas: FontAtlas,
+ whiteSprite: SpriteEcsComponent,
+ guideLayer: number,
+ contentLayer: number,
+ topLeft: Vector2,
+ usableWidth: number,
+): number {
+ const captionEntity = world.createEntity();
+ addPositionComponent(world, captionEntity, {
+ local: { x: topLeft.x, y: topLeft.y },
+ });
+ addTextComponent(world, captionEntity, {
+ text: 'rich text tags: b and color=#rrggbb',
+ fontAtlas,
+ size: captionSize,
+ color: captionColor,
+ layer: contentLayer,
+ });
+
+ // `shapeText` parses tags too, so the guide box is sized from exactly the
+ // text that's drawn.
+ const { bounds: paragraphBounds } = shapeText(paragraph, fontAtlas.data, {
+ size: bodySize,
+ maxWidth: usableWidth,
+ });
+ const { bounds: outlinedBounds } = shapeText(outlinedText, fontAtlas.data, {
+ size: outlinedSize,
+ });
+
+ const paragraphTop = topLeft.y - captionGap;
+ const outlinedTop = paragraphTop - paragraphBounds.height - rowGap;
+
+ createGuideBox(
+ world,
+ whiteSprite,
+ { x: topLeft.x, y: paragraphTop },
+ { x: usableWidth, y: paragraphBounds.height },
+ guideLayer,
+ );
+
+ const paragraphEntity = world.createEntity();
+ addPositionComponent(world, paragraphEntity, {
+ local: { x: topLeft.x, y: paragraphTop },
+ });
+ addTextComponent(world, paragraphEntity, {
+ text: paragraph,
+ fontAtlas,
+ size: bodySize,
+ color: bodyColor,
+ maxWidth: usableWidth,
+ layer: contentLayer,
+ });
+
+ const outlinedEntity = world.createEntity();
+ addPositionComponent(world, outlinedEntity, {
+ local: { x: topLeft.x, y: outlinedTop },
+ });
+ addTextComponent(world, outlinedEntity, {
+ text: outlinedText,
+ fontAtlas,
+ size: outlinedSize,
+ color: bodyColor,
+ outlineColor,
+ outlineWidth: 1.5,
+ layer: contentLayer,
+ });
+
+ return outlinedTop - outlinedBounds.height;
+}
diff --git a/documentation-site/src/pages/demos/text/index.tsx b/documentation-site/src/pages/demos/text/index.tsx
index c037efe0..f59442b4 100644
--- a/documentation-site/src/pages/demos/text/index.tsx
+++ b/documentation-site/src/pages/demos/text/index.tsx
@@ -154,10 +154,10 @@ export default function Text(): JSX.Element {
metaData={{
title: 'Text Rendering Demo',
description:
- 'A demo showcasing MSDF text rendering, multi-line layout, alignment, live reflow, an interactive playground, and outline/soft-shadow effects with addTextComponent and createTextShapingEcsSystem.',
+ 'A demo showcasing MSDF text rendering, multi-line layout, alignment, live reflow, rich text tags, an interactive playground, and outline/soft-shadow effects with addTextComponent and createTextShapingEcsSystem.',
}}
header="Text Rendering"
- blurb="A showcase of MSDF text rendering using the engine's shipped default font atlas (Liberation Sans, SIL OFL 1.1 - zero font setup required): every horizontalAlign value (left/center/right/justify) wrapping the same sentence, every verticalAlign value (top/middle/bottom/baseline/capline) positioned against a shared anchor line, with middle centering the cap-height-to-baseline band, a few lineHeight multipliers compared side by side, a paragraph whose maxWidth oscillates every frame (driving createTextShapingEcsSystem to reflow it live), an interactive playground you can type into using the controls above, and - at the bottom - outline/soft-shadow effects at a conservative, documented-safe size (see the Text Effects guide for why). Every guide box/line is sized from shapeText's own computed bounds, not guessed."
+ blurb="A showcase of MSDF text rendering using the engine's shipped default font atlas (Liberation Sans, SIL OFL 1.1 - zero font setup required): every horizontalAlign value (left/center/right/justify) wrapping the same sentence, every verticalAlign value (top/middle/bottom/baseline/capline) positioned against a shared anchor line, with middle centering the cap-height-to-baseline band, a few lineHeight multipliers compared side by side, a paragraph whose maxWidth oscillates every frame (driving createTextShapingEcsSystem to reflow it live), a paragraph styled with and rich text tags, an interactive playground you can type into using the controls above, and - at the bottom - outline/soft-shadow effects at a conservative, documented-safe size (see the Text Effects guide for why). Every guide box/line is sized from shapeText's own computed bounds, not guessed."
createGame={createGame}
interactions={
128 && g < 100 && b < 100) {
+ return 'red';
+ }
+
+ if (g > 128 && r < 100 && b < 100) {
+ return 'green';
+ }
+
+ return null;
+}
+
+/**
+ * Builds a minimal scene with one text entity drawn from a synthetic,
+ * analytically generated MSDF atlas whose only glyph, "A", is a filled
+ * square. The base text color is red, so a `` tag shows up
+ * as green ink and `` as a wider square.
+ * @param container - The element to render the scene's canvas into.
+ * @returns The scene's handle.
+ */
+export const createScene: CreateScene = async (
+ container: HTMLElement,
+): Promise => {
+ const time = new Time();
+ const world = new EcsWorld();
+ const canvas = createCanvas(container);
+
+ canvas.width = 700;
+ canvas.height = 200;
+
+ const renderContext = createRenderContext(canvas, {
+ preserveDrawingBuffer: true,
+ });
+
+ createCamera(world, {
+ isStatic: true,
+ clearColor: Color.black,
+ verticalWorldUnits: canvas.height,
+ });
+
+ const glyphImage = await createSyntheticMsdfGlyphImage();
+
+ const fontAtlas: FontAtlas = {
+ data: {
+ formatVersion: CURRENT_FONT_ATLAS_FORMAT_VERSION,
+ type: 'msdf',
+ atlasSize: {
+ width: SYNTHETIC_GLYPH_TILE_SIZE,
+ height: SYNTHETIC_GLYPH_TILE_SIZE,
+ },
+ distanceRange: SYNTHETIC_GLYPH_DISTANCE_RANGE,
+ metrics: {
+ lineHeight: 1,
+ ascender: 0.5,
+ descender: 0.5,
+ capHeight: 0.5,
+ },
+ glyphs: new Map([
+ [
+ ATLAS_A_CODE_POINT,
+ {
+ codePoint: ATLAS_A_CODE_POINT,
+ advance: 1,
+ planeBounds: glyphBounds,
+ atlasBounds: glyphBounds,
+ },
+ ],
+ ]),
+ kerning: new Map(),
+ },
+ image: glyphImage,
+ };
+
+ const textEntity = world.createEntity();
+
+ // Three glyphs at 1 em each, centered on the canvas. The synthetic
+ // glyph's ink is centered half an em above its baseline, so anchoring the
+ // baseline half an em below y = 0 centers the ink on the scanned row.
+ addPositionComponent(world, textEntity, {
+ local: { x: -1.5 * SIZE, y: -0.5 * SIZE },
+ });
+
+ const textComponent = addTextComponent(world, textEntity, {
+ text: 'AAA',
+ fontAtlas,
+ size: SIZE,
+ color: Color.red,
+ verticalAlign: 'baseline',
+ });
+
+ world.addSystem(createTransformEcsSystem());
+ world.addSystem(createTextShapingEcsSystem(renderContext));
+ world.addSystem(createRenderEcsSystem(renderContext));
+ world.addSystem(createPresentEcsSystem(renderContext));
+
+ let clockInMilliseconds = 0;
+
+ return {
+ step(deltaMilliseconds: number = defaultStepDeltaMilliseconds): void {
+ clockInMilliseconds += deltaMilliseconds;
+ time.update(clockInMilliseconds);
+ world.update();
+ },
+
+ setText(text: string): void {
+ textComponent.text = text;
+ },
+
+ boldWidening: 2 * FAUX_BOLD_EMBOLDEN * SIZE,
+
+ measureInkSpans(): InkSpan[] {
+ const sampleCanvas = document.createElement('canvas');
+
+ sampleCanvas.width = canvas.width;
+ sampleCanvas.height = canvas.height;
+
+ const context2d = sampleCanvas.getContext('2d');
+
+ if (!context2d) {
+ throw new Error('2D canvas context not available');
+ }
+
+ context2d.drawImage(canvas, 0, 0);
+
+ const y = Math.floor(canvas.height / 2);
+ const { data } = context2d.getImageData(0, y, canvas.width, 1);
+ const spans: InkSpan[] = [];
+ let current: InkSpan | null = null;
+
+ for (let x = 0; x < canvas.width; x++) {
+ const offset = x * 4;
+ const color = classifyInk(
+ data[offset],
+ data[offset + 1],
+ data[offset + 2],
+ );
+
+ if (current && color === current.color) {
+ current.right = x;
+
+ continue;
+ }
+
+ if (current) {
+ spans.push(current);
+ current = null;
+ }
+
+ if (color) {
+ current = { left: x, right: x, color };
+ }
+ }
+
+ if (current) {
+ spans.push(current);
+ }
+
+ return spans;
+ },
+ };
+};
diff --git a/e2e/specs/rich-text-tags.spec.ts b/e2e/specs/rich-text-tags.spec.ts
new file mode 100644
index 00000000..7640dd49
--- /dev/null
+++ b/e2e/specs/rich-text-tags.spec.ts
@@ -0,0 +1,103 @@
+import { expect, test } from '@playwright/test';
+import type {
+ InkSpan,
+ RichTextTagsSceneHandle,
+} from '../fixtures/scenes/rich-text-tags.js';
+
+type Hooks = RichTextTagsSceneHandle;
+
+const width = (span: InkSpan): number => span.right - span.left + 1;
+
+test.describe('rich text tags', () => {
+ test.beforeEach(async ({ page }) => {
+ let pageError: Error | undefined;
+
+ page.once('pageerror', (error) => {
+ pageError = error;
+ });
+
+ await page.goto('/?scene=rich-text-tags');
+
+ try {
+ await page.waitForFunction(() => Boolean(window.__forgeTestHooks));
+ } catch (timeoutError) {
+ throw pageError ?? timeoutError;
+ }
+ });
+
+ test('draws a color tag in its color, laid out exactly like the untagged text', async ({
+ page,
+ }) => {
+ const { untagged, tagged } = await page.evaluate(() => {
+ const scene = window.__forgeTestHooks as unknown as Hooks;
+
+ scene.setText('AAA');
+ scene.step();
+ const untaggedSpans = scene.measureInkSpans();
+
+ scene.setText('AAA');
+ scene.step();
+
+ return { untagged: untaggedSpans, tagged: scene.measureInkSpans() };
+ });
+
+ expect(untagged.map((span) => span.color)).toEqual(['red', 'red', 'red']);
+ expect(tagged.map((span) => span.color)).toEqual(['red', 'green', 'red']);
+
+ // The tag changes the middle glyph's color, never where any glyph sits.
+ for (let i = 0; i < 3; i++) {
+ expect(Math.abs(tagged[i].left - untagged[i].left)).toBeLessThanOrEqual(
+ 1,
+ );
+ expect(Math.abs(tagged[i].right - untagged[i].right)).toBeLessThanOrEqual(
+ 1,
+ );
+ }
+ });
+
+ test('draws a bold glyph wider and pushes the glyphs after it along', async ({
+ page,
+ }) => {
+ const { regular, bold, boldWidening } = await page.evaluate(() => {
+ const scene = window.__forgeTestHooks as unknown as Hooks;
+
+ scene.setText('AAA');
+ scene.step();
+ const regularSpans = scene.measureInkSpans();
+
+ scene.setText('AAA');
+ scene.step();
+
+ return {
+ regular: regularSpans,
+ bold: scene.measureInkSpans(),
+ boldWidening: scene.boldWidening,
+ };
+ });
+
+ expect(regular).toHaveLength(3);
+ expect(bold).toHaveLength(3);
+
+ // Pixel quantization and anti-aliased edges allow about a pixel either
+ // way; the widening itself is several pixels.
+ const tolerance = 1.5;
+
+ expect(boldWidening).toBeGreaterThan(3);
+ expect(width(bold[0]) - width(regular[0])).toBeGreaterThan(
+ boldWidening - tolerance,
+ );
+ expect(width(bold[0]) - width(regular[0])).toBeLessThan(
+ boldWidening + tolerance,
+ );
+
+ // The bold glyph's advance grew by the same widening, so the regular
+ // glyphs after it keep their width and move right by it.
+ expect(Math.abs(width(bold[1]) - width(regular[1]))).toBeLessThanOrEqual(1);
+ expect(bold[1].left - regular[1].left).toBeGreaterThan(
+ boldWidening - tolerance,
+ );
+ expect(bold[1].left - regular[1].left).toBeLessThan(
+ boldWidening + tolerance,
+ );
+ });
+});
diff --git a/src/rendering/renderable.ts b/src/rendering/renderable.ts
index 08b9b123..7658206b 100644
--- a/src/rendering/renderable.ts
+++ b/src/rendering/renderable.ts
@@ -70,6 +70,13 @@ export interface InstanceComponents {
* sprites.
*/
textEffects?: TextEffectsInstanceData;
+
+ /**
+ * The glyph's faux-bold edge shift (`GlyphQuad.embolden`), if this
+ * instance is a glyph quad pushed by `pushTextRenderCommands`.
+ * `undefined` for ordinary sprites.
+ */
+ textEmbolden?: number;
}
/**
diff --git a/src/rendering/systems/render-system.test.ts b/src/rendering/systems/render-system.test.ts
index 5c993f62..ca064404 100644
--- a/src/rendering/systems/render-system.test.ts
+++ b/src/rendering/systems/render-system.test.ts
@@ -816,12 +816,14 @@ describe('createRenderEcsSystem', () => {
size: { x: 1, y: 1 },
uvOffset: { x: 0, y: 0 },
uvScale: { x: 0.1, y: 0.1 },
+ embolden: 0,
},
{
offset: { x: 1, y: 0 },
size: { x: 1, y: 1 },
uvOffset: { x: 0.1, y: 0 },
uvScale: { x: 0.1, y: 0.1 },
+ embolden: 0,
},
],
});
@@ -858,6 +860,7 @@ describe('createRenderEcsSystem', () => {
size: { x: 1, y: 1 },
uvOffset: Vec2.zero,
uvScale: Vec2.one,
+ embolden: 0,
},
],
});
@@ -1062,6 +1065,7 @@ describe('createRenderEcsSystem', () => {
size: { x: 1, y: 1 },
uvOffset: Vec2.zero,
uvScale: Vec2.one,
+ embolden: 0,
});
addTextEntity(renderable, 0, {
@@ -1092,6 +1096,7 @@ describe('createRenderEcsSystem', () => {
size: { x: 1, y: 1 },
uvOffset: Vec2.zero,
uvScale: Vec2.one,
+ embolden: 0,
},
],
},
diff --git a/src/rendering/utilities/create-shader-cache.ts b/src/rendering/utilities/create-shader-cache.ts
index 4f82128c..c6f460c1 100644
--- a/src/rendering/utilities/create-shader-cache.ts
+++ b/src/rendering/utilities/create-shader-cache.ts
@@ -35,6 +35,7 @@ import {
import {
msdfEffectsFragmentShader,
msdfFillFragmentShader,
+ msdfFillVertexShader,
msdfVertexShader,
} from '../../text/rendering/shaders/index.js';
@@ -85,6 +86,7 @@ export function createShaderCache(): ShaderCache {
.addShader(new ForgeShaderSource(toneMappingFragmentShader))
.addShader(new ForgeShaderSource(terrainVertexShader))
.addShader(new ForgeShaderSource(terrainFragmentShader))
+ .addShader(new ForgeShaderSource(msdfFillVertexShader))
.addShader(new ForgeShaderSource(msdfFillFragmentShader))
.addShader(new ForgeShaderSource(msdfEffectsFragmentShader))
.addShader(new ForgeShaderSource(msdfVertexShader));
diff --git a/src/text/components/text-mesh-component.ts b/src/text/components/text-mesh-component.ts
index 749f8f19..ffd83432 100644
--- a/src/text/components/text-mesh-component.ts
+++ b/src/text/components/text-mesh-component.ts
@@ -1,5 +1,6 @@
import { createComponentId } from '../../ecs/ecs-component.js';
import type { Vector2 } from '../../math/index.js';
+import type { Color } from '../../rendering/color.js';
import type { Renderable } from '../../rendering/renderable.js';
/**
@@ -23,6 +24,21 @@ export interface GlyphQuad {
/** The width/height of this glyph's texture rect in the atlas, 0 to 1. */
uvScale: Vector2;
+
+ /**
+ * This glyph's fill color from a `` rich text tag, replacing
+ * `TextEcsComponent.color` (alpha included). `undefined` outside a
+ * `` tag, where the glyph draws in `TextEcsComponent.color`.
+ */
+ color?: Color;
+
+ /**
+ * How far this glyph's ink is thickened by a `` rich text tag (faux
+ * bold), as a shift of the distance field's edge threshold in the
+ * field's own units (`0.5` is the field's full encoded range). `0` for
+ * regular-weight glyphs.
+ */
+ embolden: number;
}
/**
diff --git a/src/text/rendering/create-text-renderable.test.ts b/src/text/rendering/create-text-renderable.test.ts
index a745f2a0..82ccbfe7 100644
--- a/src/text/rendering/create-text-renderable.test.ts
+++ b/src/text/rendering/create-text-renderable.test.ts
@@ -6,13 +6,13 @@ import {
RenderContext,
ShaderCache,
spriteFragmentShader,
- spriteVertexShader,
} from '../../rendering/index.js';
import type { FontAtlas } from '../font-atlas/font-atlas.js';
import { createTextRenderable } from './create-text-renderable.js';
import {
msdfEffectsFragmentShader,
msdfFillFragmentShader,
+ msdfFillVertexShader,
msdfVertexShader,
} from './shaders/index.js';
@@ -136,9 +136,9 @@ describe('createTextRenderable', () => {
vi.spyOn(canvas, 'getContext').mockReturnValue(mockGl);
const shaderCache = new ShaderCache([])
- .addShader(new ForgeShaderSource(spriteVertexShader))
.addShader(new ForgeShaderSource(spriteFragmentShader))
.addShader(new ForgeShaderSource(msdfVertexShader))
+ .addShader(new ForgeShaderSource(msdfFillVertexShader))
.addShader(new ForgeShaderSource(msdfFillFragmentShader))
.addShader(new ForgeShaderSource(msdfEffectsFragmentShader));
@@ -189,7 +189,7 @@ describe('createTextRenderable', () => {
expect(calls[1][1]).toBe(512);
});
- it('assigns the plain sprite instance data layout to fillRenderable and the combined sprite + text-effects layout to effectsRenderable', () => {
+ it('assigns the sprite + embolden instance data layout to fillRenderable and the sprite + embolden + text-effects layout to effectsRenderable', () => {
const { fillRenderable, effectsRenderable } = createTextRenderable(
renderContext,
fontAtlas,
@@ -197,13 +197,13 @@ describe('createTextRenderable', () => {
);
// Sprite: position(2) + rotation(1) + scale(2) + size(2) + pivot(2) +
- // texOffset(2) + texSize(2) + tint(4) = 17.
- expect(fillRenderable.floatsPerInstance).toBe(17);
+ // texOffset(2) + texSize(2) + tint(4) = 17, plus embolden(1) = 18.
+ expect(fillRenderable.floatsPerInstance).toBe(18);
- // Sprite (17) + text effects: outlineColor(4) + outlineWidth(1) +
- // shadowColor(4) + shadowOffset(2) + shadowSoftness(1) = 12, for a
- // total of 29.
- expect(effectsRenderable.floatsPerInstance).toBe(29);
+ // Sprite + embolden (18) + text effects: outlineColor(4) +
+ // outlineWidth(1) + shadowColor(4) + shadowOffset(2) +
+ // shadowSoftness(1) = 12, for a total of 30.
+ expect(effectsRenderable.floatsPerInstance).toBe(30);
});
it('shares a single GPU texture between both renderables', () => {
diff --git a/src/text/rendering/create-text-renderable.ts b/src/text/rendering/create-text-renderable.ts
index 7fdf6c80..09b54dfa 100644
--- a/src/text/rendering/create-text-renderable.ts
+++ b/src/text/rendering/create-text-renderable.ts
@@ -9,6 +9,7 @@ import {
} from '../../rendering/index.js';
import type { FontAtlas } from '../font-atlas/font-atlas.js';
import { textEffectsInstanceDataSegment } from './text-effects-instance-data-segment.js';
+import { textEmboldenInstanceDataSegment } from './text-embolden-instance-data-segment.js';
/**
* The default rendering category a text entity's glyphs are drawn with when
@@ -29,7 +30,8 @@ export const TEXT_RENDER_CATEGORY = 1;
export interface TextRenderables {
/**
* Draws only a glyph's own anti-aliased ink (`msdf-fill.frag`), using the
- * plain sprite vertex layout - no outline/shadow instance data. Always
+ * sprite vertex layout plus the glyph's faux-bold embolden - no
+ * outline/shadow instance data. Always
* drawn *after* `effectsRenderable` for the same glyphs (see
* `pushTextRenderCommands` in `glyph-quad.ts`), so a glyph's fill can
* never be painted over by a neighboring glyph's outline/shadow.
@@ -88,7 +90,7 @@ export function createTextRenderable(
const atlasTexture = createTextureFromImage(gl, fontAtlas.image);
const fillMaterial = new Material(
- shaderCache.getShader('sprite.vert'),
+ shaderCache.getShader('msdf-fill.vert'),
shaderCache.getShader('msdf-fill.frag'),
gl,
);
@@ -101,7 +103,10 @@ export function createTextRenderable(
floatsPerInstance: fillFloatsPerInstance,
bindInstanceData: fillBindInstanceData,
setupInstanceAttributes: fillSetupInstanceAttributes,
- } = combineInstanceDataSegments(spriteInstanceDataSegment);
+ } = combineInstanceDataSegments(
+ spriteInstanceDataSegment,
+ textEmboldenInstanceDataSegment,
+ );
const fillRenderable = new Renderable(
createQuadGeometry(gl),
@@ -112,9 +117,9 @@ export function createTextRenderable(
fillSetupInstanceAttributes,
);
- // `msdf.vert` extends `sprite.vert`'s positioning/pivot/rotation/
- // projection math verbatim, adding only the per-instance forwarding
- // outline/shadow effects need (see `msdf.vert.glsl`).
+ // `msdf.vert` extends `msdf-fill.vert` verbatim, adding only the
+ // per-instance forwarding outline/shadow effects need (see
+ // `msdf.vert.glsl`).
const effectsMaterial = new Material(
shaderCache.getShader('msdf.vert'),
shaderCache.getShader('msdf-effects.frag'),
@@ -131,6 +136,7 @@ export function createTextRenderable(
setupInstanceAttributes: effectsSetupInstanceAttributes,
} = combineInstanceDataSegments(
spriteInstanceDataSegment,
+ textEmboldenInstanceDataSegment,
textEffectsInstanceDataSegment,
);
diff --git a/src/text/rendering/glyph-quad.test.ts b/src/text/rendering/glyph-quad.test.ts
index 583b11ad..26a57594 100644
--- a/src/text/rendering/glyph-quad.test.ts
+++ b/src/text/rendering/glyph-quad.test.ts
@@ -62,6 +62,7 @@ const glyph: GlyphQuad = {
size: { x: 5, y: 7 },
uvOffset: { x: 0.1, y: 0.2 },
uvScale: { x: 0.3, y: 0.4 },
+ embolden: 0,
};
describe('pushTextRenderCommands', () => {
@@ -127,6 +128,40 @@ describe('pushTextRenderCommands', () => {
});
});
+ it("tints a glyph with its own rich text color in place of the text's color", () => {
+ const commands: RenderCommand[] = [];
+ const glyphColor = new Color(0, 1, 0, 0.5);
+
+ pushTextRenderCommands(
+ commands,
+ buildTextComponent({ color: Color.white }),
+ buildTextMesh([{ ...glyph, color: glyphColor }, glyph]),
+ { local: { x: 0, y: 0 }, world: { x: 0, y: 0 } },
+ null,
+ null,
+ );
+
+ expect(commands[0].components.sprite.tintColor).toBe(glyphColor);
+ expect(commands[1].components.sprite.tintColor).toBe(Color.white);
+ });
+
+ it("passes each glyph's embolden to both its effects and fill commands", () => {
+ const commands: RenderCommand[] = [];
+
+ pushTextRenderCommands(
+ commands,
+ buildTextComponent({ outlineWidth: 1 }),
+ buildTextMesh([{ ...glyph, embolden: 0.1 }, glyph]),
+ { local: { x: 0, y: 0 }, world: { x: 0, y: 0 } },
+ null,
+ null,
+ );
+
+ expect(commands.map((command) => command.components.textEmbolden)).toEqual([
+ 0.1, 0, 0.1, 0,
+ ]);
+ });
+
it("builds textEffects from the text component's outline/shadow fields", () => {
const commands: RenderCommand[] = [];
const outlineColor = new Color(0, 1, 0, 1);
diff --git a/src/text/rendering/glyph-quad.ts b/src/text/rendering/glyph-quad.ts
index a415b4d9..60d03d82 100644
--- a/src/text/rendering/glyph-quad.ts
+++ b/src/text/rendering/glyph-quad.ts
@@ -111,6 +111,7 @@ function pushTextEffectsRenderCommands(
sprite: glyphSprite,
flip: null,
textEffects,
+ textEmbolden: glyph.embolden,
},
});
}
@@ -122,7 +123,7 @@ function pushTextEffectsRenderCommands(
* entity (see `pushTextRenderCommands`) so a glyph's fill can never be
* painted over by a neighboring glyph's outline/shadow.
* @param commands - The render command buffer to push into.
- * @param textComponent - The entity's `TextEcsComponent` (for `layer` and `color`).
+ * @param textComponent - The entity's `TextEcsComponent` (for `layer`, and `color` for glyphs outside a `` tag).
* @param textMesh - The entity's shaped glyph quads to push commands for.
* @param entityPosition - The entity's position; each glyph is offset from it.
* @param rotationComponent - The entity's rotation, if it has one.
@@ -147,7 +148,7 @@ function pushTextFillRenderCommands(
pivot: { x: 0.5, y: 0.5 },
uvOffset: glyph.uvOffset,
uvScale: glyph.uvScale,
- tintColor: color,
+ tintColor: glyph.color ?? color,
opacityMultiplier: textComponent.opacityMultiplier,
renderable: fillRenderable,
enabled: true,
@@ -164,6 +165,7 @@ function pushTextFillRenderCommands(
scale: scaleComponent,
sprite: glyphSprite,
flip: null,
+ textEmbolden: glyph.embolden,
},
});
}
diff --git a/src/text/rendering/index.ts b/src/text/rendering/index.ts
index 8f25ccc6..bf17cfdd 100644
--- a/src/text/rendering/index.ts
+++ b/src/text/rendering/index.ts
@@ -1,3 +1,4 @@
export * from './create-text-renderable.js';
export * from './glyph-quad.js';
export * from './text-effects-instance-data-segment.js';
+export * from './text-embolden-instance-data-segment.js';
diff --git a/src/text/rendering/shaders/index.ts b/src/text/rendering/shaders/index.ts
index 901017f3..51628ace 100644
--- a/src/text/rendering/shaders/index.ts
+++ b/src/text/rendering/shaders/index.ts
@@ -1,7 +1,9 @@
import msdfEffectsFragmentShaderSource from './msdf-effects.frag.glsl?raw';
import msdfFillFragmentShaderSource from './msdf-fill.frag.glsl?raw';
+import msdfFillVertexShaderSource from './msdf-fill.vert.glsl?raw';
import msdfVertexShaderSource from './msdf.vert.glsl?raw';
export const msdfFillFragmentShader = msdfFillFragmentShaderSource;
+export const msdfFillVertexShader = msdfFillVertexShaderSource;
export const msdfEffectsFragmentShader = msdfEffectsFragmentShaderSource;
export const msdfVertexShader = msdfVertexShaderSource;
diff --git a/src/text/rendering/shaders/msdf-effects.frag.glsl b/src/text/rendering/shaders/msdf-effects.frag.glsl
index 28ded1de..66cfc7e7 100644
--- a/src/text/rendering/shaders/msdf-effects.frag.glsl
+++ b/src/text/rendering/shaders/msdf-effects.frag.glsl
@@ -9,6 +9,7 @@ uniform float u_distanceRange; // FontAtlasData.distanceRange
uniform float u_atlasSize; // FontAtlasData.atlasSize.height (assumes square texels)
in vec2 v_texCoord;
+in float v_embolden;
in vec4 v_outlineColor;
in float v_outlineWidth;
in vec4 v_shadowColor;
@@ -78,7 +79,6 @@ void main() {
vec2 uvPerScreenPx = fwidth(v_texCoord);
vec2 screenTexSize = vec2(1.0) / uvPerScreenPx;
float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0);
- float screenPxDistance = signedDistance * screenPxRange;
// The atlas's own encoded budget: the distance field only carries graded
// (non-saturated) data up to roughly `screenPxRange / 2` screen pixels
@@ -90,7 +90,15 @@ void main() {
// doesn't need anything more than this either.
float atlasSafeDistance = max(screenPxRange * 0.5 - 0.5, 0.0);
- float clampedOutlineWidth = min(v_outlineWidth, atlasSafeDistance);
+ // A faux-bold (``) glyph's edge sits `emboldenPx` further out, clamped
+ // exactly as `msdf-fill.frag` clamps it. Both effects are measured from
+ // that bold edge, so the outline wraps the thickened ink, and the bold
+ // shift spends part of the atlas budget the effects have left.
+ float emboldenPx = min(v_embolden * screenPxRange, atlasSafeDistance);
+ float screenPxDistance = signedDistance * screenPxRange + emboldenPx;
+ float effectsSafeDistance = atlasSafeDistance - emboldenPx;
+
+ float clampedOutlineWidth = min(v_outlineWidth, effectsSafeDistance);
float outlineCoverage = v_outlineWidth > 0.0
? clamp(screenPxDistance + 0.5 + clampedOutlineWidth, 0.0, 1.0)
: 0.0;
@@ -103,8 +111,8 @@ void main() {
vec2 shadowUv = v_texCoord - clampedShadowOffset * uvPerScreenPx;
vec3 shadowMsdf = texture(u_atlas, shadowUv).rgb;
float shadowSignedDistance = median(shadowMsdf.r, shadowMsdf.g, shadowMsdf.b) - 0.5;
- float shadowScreenPxDistance = shadowSignedDistance * screenPxRange;
- float shadowReach = max(min(v_shadowSoftness, atlasSafeDistance), 0.001);
+ float shadowScreenPxDistance = shadowSignedDistance * screenPxRange + emboldenPx;
+ float shadowReach = max(min(v_shadowSoftness, effectsSafeDistance), 0.001);
float shadowCoverage = clamp(1.0 - (-shadowScreenPxDistance) / shadowReach, 0.0, 1.0);
vec4 shadowLayer = vec4(v_shadowColor.rgb, shadowCoverage * v_shadowColor.a);
diff --git a/src/text/rendering/shaders/msdf-fill.frag.glsl b/src/text/rendering/shaders/msdf-fill.frag.glsl
index 709209e9..90c8d176 100644
--- a/src/text/rendering/shaders/msdf-fill.frag.glsl
+++ b/src/text/rendering/shaders/msdf-fill.frag.glsl
@@ -10,6 +10,7 @@ uniform float u_atlasSize; // FontAtlasData.atlasSize.height (assumes squa
in vec2 v_texCoord;
in vec4 v_tint;
+in float v_embolden;
out vec4 fragColor;
float median(float r, float g, float b) {
@@ -19,11 +20,11 @@ float median(float r, float g, float b) {
// Draws only a glyph's own anti-aliased ink - the same computation
// `msdf-effects.frag`'s `fillLayer` used to do as one layer of a combined
// shader, now its own draw pass. Kept in its own shader (rather than a
-// uniform "mode" switch on one shared program) so it can reuse the plain
-// `sprite.vert` and the base `spriteInstanceDataSegment` verbatim - a fill
-// quad needs none of the outline/shadow per-instance data `msdf.vert`
-// forwards, so glyphs with no effects at all (the common case) never pay
-// for it.
+// uniform "mode" switch on one shared program) so its vertex shader
+// (`msdf-fill.vert`) only forwards the sprite data plus the faux-bold
+// embolden - a fill quad needs none of the outline/shadow per-instance data
+// `msdf.vert` forwards, so glyphs with no effects at all (the common case)
+// never pay for it.
//
// This pass is always drawn *after* every glyph's outline/shadow ("effects"
// pass, see `msdf-effects.frag`) for the same text entity - see
@@ -43,6 +44,14 @@ void main() {
float screenPxRange = max(0.5 * dot(unitRange, screenTexSize), 1.0);
float screenPxDistance = signedDistance * screenPxRange;
- float glyphAlpha = clamp(screenPxDistance + 0.5, 0.0, 1.0);
+ // Faux bold (``) pushes the glyph's edge outwards by `v_embolden`
+ // distance-field units. The field only carries graded data up to about
+ // `screenPxRange / 2` screen pixels past the edge, so the shift is
+ // clamped to that budget - the same clamp `msdf-effects.frag` applies, so
+ // both passes agree on where the bold edge is.
+ float atlasSafeDistance = max(screenPxRange * 0.5 - 0.5, 0.0);
+ float emboldenPx = min(v_embolden * screenPxRange, atlasSafeDistance);
+
+ float glyphAlpha = clamp(screenPxDistance + emboldenPx + 0.5, 0.0, 1.0);
fragColor = vec4(v_tint.rgb, v_tint.a * glyphAlpha);
}
diff --git a/src/text/rendering/shaders/msdf-fill.vert.glsl b/src/text/rendering/shaders/msdf-fill.vert.glsl
new file mode 100644
index 00000000..82948044
--- /dev/null
+++ b/src/text/rendering/shaders/msdf-fill.vert.glsl
@@ -0,0 +1,57 @@
+#version 300 es
+
+#pragma forge name(msdf-fill.vert)
+
+in vec2 a_position; // Vertex position (e.g., quad corners)
+in vec2 a_texCoord; // Texture coordinate
+
+// Per-instance attributes (base sprite segment, see `sprite.vert.glsl`):
+in vec2 a_instancePos; // Glyph position
+in float a_instanceRot; // Glyph rotation (radians)
+in vec2 a_instanceScale; // Glyph scale
+in vec2 a_instanceSize; // Glyph width/height, in world units
+in vec2 a_instancePivot; // Glyph pivot (origin offset)
+in vec2 a_instanceTexOffset; // Texture region offset (UV)
+in vec2 a_instanceTexSize; // Texture region size (UV)
+in vec4 a_instanceTint; // Fill color
+
+// Per-instance attribute (text embolden segment, see
+// `textEmboldenInstanceDataSegment`): the faux-bold edge shift, in
+// distance-field units.
+in float a_instanceEmbolden;
+
+// Uniforms for projection/camera:
+uniform mat3 u_projection; // 2D projection/camera matrix
+
+out vec2 v_texCoord;
+out vec4 v_tint;
+out float v_embolden;
+
+void main() {
+ // Identical to `sprite.vert` - glyph quads are positioned, pivoted,
+ // rotated, and projected exactly like a sprite region already is (see
+ // `sprite.vert.glsl` for the derivation of each step below).
+ vec2 normalizedPivot = vec2(
+ (a_instancePivot.x - 0.5) * 2.0,
+ -(a_instancePivot.y - 0.5) * 2.0
+ );
+
+ vec2 pivoted = a_position - normalizedPivot;
+ vec2 scaled = pivoted * a_instanceSize * a_instanceScale * 0.5;
+
+ float c = cos(a_instanceRot);
+ float s = sin(a_instanceRot);
+ vec2 rotated = vec2(
+ c * scaled.x - s * scaled.y,
+ s * scaled.x + c * scaled.y
+ );
+
+ vec2 world = rotated + a_instancePos;
+
+ vec3 projected = u_projection * vec3(world, 1.0);
+
+ gl_Position = vec4(projected.xy, 0.0, 1.0);
+ v_texCoord = a_instanceTexOffset + a_texCoord * a_instanceTexSize;
+ v_tint = a_instanceTint;
+ v_embolden = a_instanceEmbolden;
+}
diff --git a/src/text/rendering/shaders/msdf.vert.glsl b/src/text/rendering/shaders/msdf.vert.glsl
index 8bd496d5..5e7c2bae 100644
--- a/src/text/rendering/shaders/msdf.vert.glsl
+++ b/src/text/rendering/shaders/msdf.vert.glsl
@@ -15,6 +15,11 @@ in vec2 a_instanceTexOffset; // Texture region offset (UV)
in vec2 a_instanceTexSize; // Texture region size (UV)
in vec4 a_instanceTint; // tint color
+// Per-instance attribute (text embolden segment, see
+// `textEmboldenInstanceDataSegment`): the faux-bold edge shift, in
+// distance-field units.
+in float a_instanceEmbolden;
+
// Per-instance attributes (text effects segment, see
// `textEffectsInstanceDataSegment`):
in vec4 a_instanceOutlineColor;
@@ -28,6 +33,7 @@ uniform mat3 u_projection; // 2D projection/camera matrix
out vec2 v_texCoord;
out vec4 v_tint;
+out float v_embolden;
out vec4 v_outlineColor;
out float v_outlineWidth;
out vec4 v_shadowColor;
@@ -60,6 +66,7 @@ void main() {
gl_Position = vec4(projected.xy, 0.0, 1.0);
v_texCoord = a_instanceTexOffset + a_texCoord * a_instanceTexSize;
v_tint = a_instanceTint;
+ v_embolden = a_instanceEmbolden;
// Passed through unchanged - every vertex of a glyph's quad shares the
// same per-instance effect parameters, so no per-vertex computation is
diff --git a/src/text/rendering/text-embolden-instance-data-segment.test.ts b/src/text/rendering/text-embolden-instance-data-segment.test.ts
new file mode 100644
index 00000000..00d94e91
--- /dev/null
+++ b/src/text/rendering/text-embolden-instance-data-segment.test.ts
@@ -0,0 +1,61 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import { describe, expect, it, vi } from 'vitest';
+import type { InstanceComponents, Renderable } from '../../rendering/index.js';
+import { textEmboldenInstanceDataSegment } from './text-embolden-instance-data-segment.js';
+
+describe('textEmboldenInstanceDataSegment', () => {
+ it('writes the embolden at the segment offset', () => {
+ const buffer = new Float32Array(4);
+
+ textEmboldenInstanceDataSegment.bindInstanceData(
+ { textEmbolden: 0.25 } as InstanceComponents,
+ buffer,
+ 2,
+ );
+
+ expect(buffer[2]).toBe(0.25);
+ });
+
+ it('throws when the instance has no embolden', () => {
+ expect(() =>
+ textEmboldenInstanceDataSegment.bindInstanceData(
+ {} as InstanceComponents,
+ new Float32Array(1),
+ 0,
+ ),
+ ).toThrow(/textEmbolden/);
+ });
+
+ it('points a_instanceEmbolden at the segment offset within the stride', () => {
+ const gl = {
+ FLOAT: 0x1406,
+ getAttribLocation: vi.fn().mockReturnValue(7),
+ enableVertexAttribArray: vi.fn(),
+ vertexAttribPointer: vi.fn(),
+ vertexAttribDivisor: vi.fn(),
+ };
+ const renderable = {
+ material: { program: {} },
+ floatsPerInstance: 18,
+ } as unknown as Renderable;
+
+ textEmboldenInstanceDataSegment.setupInstanceAttributes(
+ gl as unknown as WebGL2RenderingContext,
+ renderable,
+ 17,
+ );
+
+ expect(gl.getAttribLocation).toHaveBeenCalledWith(
+ renderable.material.program,
+ 'a_instanceEmbolden',
+ );
+ expect(gl.vertexAttribPointer).toHaveBeenCalledWith(
+ 7,
+ 1,
+ gl.FLOAT,
+ false,
+ 18 * 4,
+ 17 * 4,
+ );
+ });
+});
diff --git a/src/text/rendering/text-embolden-instance-data-segment.ts b/src/text/rendering/text-embolden-instance-data-segment.ts
new file mode 100644
index 00000000..22b21c52
--- /dev/null
+++ b/src/text/rendering/text-embolden-instance-data-segment.ts
@@ -0,0 +1,59 @@
+import type {
+ InstanceComponents,
+ InstanceDataSegment,
+ Renderable,
+} from '../../rendering/index.js';
+import { setupInstanceAttribute } from '../../rendering/index.js';
+
+/** The number of floats occupied by a glyph's faux-bold edge shift. */
+export const TEXT_EMBOLDEN_INSTANCE_DATA_FLOATS_PER_INSTANCE = 1;
+
+function bindTextEmboldenInstanceData(
+ components: InstanceComponents,
+ instanceDataBufferArray: Float32Array,
+ offset: number,
+): void {
+ const { textEmbolden } = components;
+
+ if (textEmbolden === undefined) {
+ throw new Error(
+ 'textEmboldenInstanceDataSegment requires InstanceComponents.textEmbolden to be set - only text glyph instances (pushTextRenderCommands) should be bound through a Renderable using this segment.',
+ );
+ }
+
+ instanceDataBufferArray[offset] = textEmbolden;
+}
+
+function setupTextEmboldenInstanceAttributes(
+ gl: WebGL2RenderingContext,
+ renderable: Renderable,
+ offset: number,
+): void {
+ const { program } = renderable.material;
+ const stride = renderable.floatsPerInstance * 4;
+
+ setupInstanceAttribute(
+ gl.getAttribLocation(program, 'a_instanceEmbolden'),
+ gl,
+ 1,
+ stride,
+ offset * 4,
+ );
+}
+
+/**
+ * The instance data segment for a glyph's faux-bold edge shift
+ * (`GlyphQuad.embolden`, set by a `` rich text tag), read by both MSDF
+ * vertex shaders (`msdf-fill.vert` and `msdf.vert`) as
+ * `a_instanceEmbolden`.
+ *
+ * Binds `InstanceComponents.textEmbolden`, set by `pushTextRenderCommands`
+ * for every glyph instance. Per instance rather than a material uniform, so
+ * bold and regular glyphs of the same `FontAtlas` still batch into one draw
+ * call.
+ */
+export const textEmboldenInstanceDataSegment: InstanceDataSegment = {
+ floatsPerInstance: TEXT_EMBOLDEN_INSTANCE_DATA_FLOATS_PER_INSTANCE,
+ bindInstanceData: bindTextEmboldenInstanceData,
+ setupInstanceAttributes: setupTextEmboldenInstanceAttributes,
+};
diff --git a/src/text/systems/text-shaping-system.test.ts b/src/text/systems/text-shaping-system.test.ts
index 54d686b8..35b0738b 100644
--- a/src/text/systems/text-shaping-system.test.ts
+++ b/src/text/systems/text-shaping-system.test.ts
@@ -7,7 +7,6 @@ import {
RenderContext,
ShaderCache,
spriteFragmentShader,
- spriteVertexShader,
} from '../../rendering/index.js';
import { addTextComponent } from '../components/text-component.js';
import {
@@ -18,6 +17,7 @@ import type { FontAtlas } from '../font-atlas/font-atlas.js';
import {
msdfEffectsFragmentShader,
msdfFillFragmentShader,
+ msdfFillVertexShader,
msdfVertexShader,
} from '../rendering/shaders/index.js';
import { createTextShapingEcsSystem } from './text-shaping-system.js';
@@ -139,9 +139,9 @@ describe('createTextShapingEcsSystem', () => {
vi.spyOn(canvas, 'getContext').mockReturnValue(mockGl);
const shaderCache = new ShaderCache([])
- .addShader(new ForgeShaderSource(spriteVertexShader))
.addShader(new ForgeShaderSource(spriteFragmentShader))
.addShader(new ForgeShaderSource(msdfVertexShader))
+ .addShader(new ForgeShaderSource(msdfFillVertexShader))
.addShader(new ForgeShaderSource(msdfFillFragmentShader))
.addShader(new ForgeShaderSource(msdfEffectsFragmentShader));
diff --git a/src/text/utilities/index.ts b/src/text/utilities/index.ts
index 5807b5a7..2acc6ebf 100644
--- a/src/text/utilities/index.ts
+++ b/src/text/utilities/index.ts
@@ -1 +1,2 @@
export * from './shape-text.js';
+export * from './parse-rich-text.js';
diff --git a/src/text/utilities/parse-rich-text.test.ts b/src/text/utilities/parse-rich-text.test.ts
new file mode 100644
index 00000000..af4a4aa9
--- /dev/null
+++ b/src/text/utilities/parse-rich-text.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it } from 'vitest';
+import { Color } from '../../rendering/color.js';
+import { parseRichText } from './parse-rich-text.js';
+
+describe('parseRichText', () => {
+ it('returns plain text unchanged with no runs', () => {
+ expect(parseRichText('plain text')).toEqual({
+ text: 'plain text',
+ colorRuns: [],
+ boldRuns: [],
+ });
+ });
+
+ it('strips a color tag into a run over the stripped text', () => {
+ const { text, colorRuns } = parseRichText('a red b');
+
+ expect(text).toBe('a red b');
+ expect(colorRuns).toEqual([
+ { start: 2, end: 5, color: new Color(1, 0, 0, 1) },
+ ]);
+ });
+
+ it('strips a bold tag into a run', () => {
+ const { text, boldRuns } = parseRichText('bold regular');
+
+ expect(text).toBe('bold regular');
+ expect(boldRuns).toEqual([{ start: 0, end: 4 }]);
+ });
+
+ it('keeps sequential color runs separate', () => {
+ const { text, colorRuns } = parseRichText(
+ 'red and green',
+ );
+
+ expect(text).toBe('red and green');
+ expect(colorRuns).toEqual([
+ { start: 0, end: 3, color: new Color(1, 0, 0, 1) },
+ { start: 8, end: 13, color: new Color(0, 1, 0, 1) },
+ ]);
+ });
+
+ it('flattens nested colors, the innermost winning', () => {
+ const { text, colorRuns } = parseRichText(
+ 'abc',
+ );
+
+ expect(text).toBe('abc');
+ expect(colorRuns).toEqual([
+ { start: 0, end: 1, color: new Color(1, 0, 0, 1) },
+ { start: 1, end: 2, color: new Color(0, 0, 1, 1) },
+ { start: 2, end: 3, color: new Color(1, 0, 0, 1) },
+ ]);
+ });
+
+ it('merges nested bold tags into one run', () => {
+ expect(parseRichText('abc').boldRuns).toEqual([
+ { start: 0, end: 3 },
+ ]);
+ });
+
+ it('applies bold and color independently over overlapping ranges', () => {
+ const { text, colorRuns, boldRuns } = parseRichText(
+ 'abc',
+ );
+
+ expect(text).toBe('abc');
+ expect(boldRuns).toEqual([{ start: 0, end: 2 }]);
+ expect(colorRuns).toEqual([
+ { start: 1, end: 3, color: new Color(0, 1, 0, 1) },
+ ]);
+ });
+
+ it.each([
+ ['#f00', new Color(1, 0, 0, 1)],
+ ['#f008', new Color(1, 0, 0, 0x88 / 255)],
+ ['#00FF00', new Color(0, 1, 0, 1)],
+ ['#0000ff80', new Color(0, 0, 1, 0x80 / 255)],
+ ])('parses the color value %s, alpha included', (value, expected) => {
+ expect(parseRichText(`x`).colorRuns).toEqual([
+ { start: 0, end: 1, color: expected },
+ ]);
+ });
+
+ it('runs an unclosed tag to the end of the string', () => {
+ const { text, colorRuns, boldRuns } = parseRichText(
+ 'a b c',
+ );
+
+ expect(text).toBe('a b c');
+ expect(boldRuns).toEqual([{ start: 2, end: 5 }]);
+ expect(colorRuns).toEqual([
+ { start: 4, end: 5, color: new Color(1, 0, 0, 1) },
+ ]);
+ });
+
+ it.each(['HP < 50%', 'a<3', 'x ', '<>', '>', 'tail <'])(
+ 'keeps "%s" as literal text',
+ (markup) => {
+ expect(parseRichText(markup)).toEqual({
+ text: markup,
+ colorRuns: [],
+ boldRuns: [],
+ });
+ },
+ );
+
+ it.each([
+ ['an unknown tag', 'x'],
+ ['an uppercase tag name', 'x'],
+ ['a color without a value', 'x'],
+ ['an invalid color value', 'x'],
+ ['a malformed hex color', 'x'],
+ ['bold with a value', 'x'],
+ ['a closing tag with nothing open', 'x'],
+ ])('keeps %s as literal text', (_, markup) => {
+ expect(parseRichText(markup)).toEqual({
+ text: markup,
+ colorRuns: [],
+ boldRuns: [],
+ });
+ });
+
+ it('keeps a stray closing tag literal without closing other tags', () => {
+ const { text, boldRuns } = parseRichText('ab');
+
+ expect(text).toBe('ab');
+ expect(boldRuns).toEqual([{ start: 0, end: 10 }]);
+ });
+
+ it('indexes runs by UTF-16 code unit', () => {
+ const { text, colorRuns } = parseRichText('😀a');
+
+ expect(text).toBe('😀a');
+ expect(colorRuns).toEqual([
+ { start: 2, end: 3, color: new Color(1, 0, 0, 1) },
+ ]);
+ });
+});
diff --git a/src/text/utilities/parse-rich-text.ts b/src/text/utilities/parse-rich-text.ts
new file mode 100644
index 00000000..b8952156
--- /dev/null
+++ b/src/text/utilities/parse-rich-text.ts
@@ -0,0 +1,243 @@
+import { Color } from '../../rendering/color.js';
+
+/**
+ * A range of characters in {@link RichText.text}, as UTF-16 code unit
+ * indices: `start` is inclusive and `end` is exclusive.
+ */
+export interface TextRun {
+ /** Index of the run's first character. */
+ start: number;
+
+ /** Index one past the run's last character. */
+ end: number;
+}
+
+/** A range of characters drawn in a `` tag's color. */
+export interface ColorTextRun extends TextRun {
+ /** The fill color, replacing `TextEcsComponent.color` (alpha included). */
+ color: Color;
+}
+
+/**
+ * A markup string split into its plain text and the style runs its tags
+ * apply. Each run list is sorted and non-overlapping: nested tags of the
+ * same kind are flattened, the innermost one winning.
+ */
+export interface RichText {
+ /** The string with every tag removed. This is the text that's shaped. */
+ text: string;
+
+ /** The ranges of `text` inside a `` tag. */
+ colorRuns: ColorTextRun[];
+
+ /** The ranges of `text` inside a `` tag. */
+ boldRuns: TextRun[];
+}
+
+/**
+ * Matches one complete tag starting at the current position: ``,
+ * `` or ``. Anything else starting with `<` (a lone `<`,
+ * `<` followed by a space or digit, an unterminated `]*))?>/y;
+
+const hexColorPattern = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
+
+/**
+ * Parses a `` tag's value. Accepts `#rgb`, `#rgba`, `#rrggbb` and
+ * `#rrggbbaa`; a value without alpha is fully opaque.
+ * @param value - The tag's value, after the `=`.
+ * @returns The color the value names, or `null` if it isn't one of the
+ * accepted hex forms.
+ */
+function parseHexColor(value: string): Color | null {
+ if (!hexColorPattern.test(value)) {
+ return null;
+ }
+
+ const digits = value.slice(1);
+ const isShortForm = digits.length <= 4;
+ const channelLength = isShortForm ? 1 : 2;
+ const channels: number[] = [];
+
+ for (let i = 0; i < digits.length; i += channelLength) {
+ const channel = digits.slice(i, i + channelLength);
+ const expanded = isShortForm ? channel + channel : channel;
+
+ channels.push(Number.parseInt(expanded, 16) / 255);
+ }
+
+ const [r, g, b, a = 1] = channels;
+
+ return new Color(r, g, b, a);
+}
+
+/** A tag that's been opened and not yet closed. */
+interface OpenTag {
+ name: 'b' | 'color';
+ color?: Color;
+}
+
+/**
+ * Appends `run` to `runs`, extending the last run instead when the two
+ * touch and carry the same style, so a style split only by a tag that
+ * didn't change it (`ab`) stays one run.
+ * @param runs - The run list to append to.
+ * @param run - The run to append.
+ * @param isSameStyle - Whether two runs carry the same style.
+ */
+function appendRun(
+ runs: T[],
+ run: T,
+ isSameStyle: (a: T, b: T) => boolean,
+): void {
+ if (run.end <= run.start) {
+ return;
+ }
+
+ const previous = runs.at(-1);
+
+ if (previous?.end === run.start && isSameStyle(previous, run)) {
+ previous.end = run.end;
+
+ return;
+ }
+
+ runs.push(run);
+}
+
+/**
+ * Applies one tag-shaped piece of markup to the stack of open tags.
+ * @param isClosing - Whether the tag is a closing tag (``).
+ * @param name - The tag's name.
+ * @param value - The tag's value, if it has one.
+ * @param openTags - The stack of currently open tags, modified in place.
+ * @returns `false` if the markup isn't a tag this parser accepts (an
+ * unknown name, a missing or invalid value, or a closing tag with nothing
+ * of that name open), in which case it's literal text and `openTags` is
+ * unchanged.
+ */
+function applyTag(
+ isClosing: boolean,
+ name: string,
+ value: string | undefined,
+ openTags: OpenTag[],
+): boolean {
+ if (name !== 'b' && name !== 'color') {
+ return false;
+ }
+
+ if (isClosing) {
+ // Closes the innermost open tag of the same name, even when another
+ // kind of tag was opened inside it: bold and color are independent, so
+ // `ab` still has a clear meaning.
+ const openIndex = openTags.findLastIndex((tag) => tag.name === name);
+
+ if (value !== undefined || openIndex === -1) {
+ return false;
+ }
+
+ openTags.splice(openIndex, 1);
+
+ return true;
+ }
+
+ if (name === 'b') {
+ if (value !== undefined) {
+ return false;
+ }
+
+ openTags.push({ name });
+
+ return true;
+ }
+
+ const color = value === undefined ? null : parseHexColor(value);
+
+ if (!color) {
+ return false;
+ }
+
+ openTags.push({ name, color });
+
+ return true;
+}
+
+/**
+ * Splits a string containing rich text tags into its plain text and the
+ * style runs the tags apply over that text's character indices.
+ *
+ * Two tags are supported: `...` draws its text bold and
+ * `...` draws it in a color (`#rgb`, `#rgba` and
+ * `#rrggbbaa` work too). Tags nest, and the innermost color wins.
+ *
+ * Markup that isn't one of those tags is kept as literal text, never an
+ * error, since text often comes from players or translations: a `<` that
+ * doesn't start a complete ``, `` or ``
+ * (`HP < 50%`, `a<3`), an unknown tag (``), an invalid value
+ * (``) and a closing tag with nothing of its name open. There's
+ * no escape syntax, so the exact text `` can't be shown. A tag left open
+ * runs to the end of the string.
+ * @param markup - The string to parse.
+ * @returns The plain text and its style runs.
+ */
+export function parseRichText(markup: string): RichText {
+ const colorRuns: ColorTextRun[] = [];
+ const boldRuns: TextRun[] = [];
+ const openTags: OpenTag[] = [];
+ let text = '';
+ let segmentStart = 0;
+ let index = 0;
+
+ // Closes the run of plain text written since the last tag, under the
+ // style the open tags gave it.
+ const flushSegment = (): void => {
+ const run = { start: segmentStart, end: text.length };
+ const color = openTags.findLast((tag) => tag.color)?.color;
+
+ if (color) {
+ appendRun(colorRuns, { ...run, color }, (a, b) => a.color === b.color);
+ }
+
+ if (openTags.some((tag) => tag.name === 'b')) {
+ appendRun(boldRuns, run, () => true);
+ }
+
+ segmentStart = text.length;
+ };
+
+ while (index < markup.length) {
+ const nextTagStart = markup.indexOf('<', index);
+
+ if (nextTagStart === -1) {
+ text += markup.slice(index);
+
+ break;
+ }
+
+ tagPattern.lastIndex = nextTagStart;
+ const match = tagPattern.exec(markup);
+
+ text += markup.slice(index, nextTagStart);
+ flushSegment();
+
+ if (!match) {
+ text += '<';
+ index = nextTagStart + 1;
+
+ continue;
+ }
+
+ const [tag, closingSlash, name, value] = match;
+
+ if (!applyTag(closingSlash === '/', name, value, openTags)) {
+ text += tag;
+ }
+
+ index = nextTagStart + tag.length;
+ }
+
+ flushSegment();
+
+ return { text, colorRuns, boldRuns };
+}
diff --git a/src/text/utilities/shape-text.test.ts b/src/text/utilities/shape-text.test.ts
index 98edf424..492b45d6 100644
--- a/src/text/utilities/shape-text.test.ts
+++ b/src/text/utilities/shape-text.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
+import { Color } from '../../rendering/color.js';
import type { FontAtlasData } from '../font-atlas/font-atlas-data.js';
-import { shapeText } from './shape-text.js';
+import { FAUX_BOLD_EMBOLDEN, shapeText } from './shape-text.js';
const A_CODE_POINT = 65;
const V_CODE_POINT = 86;
@@ -648,4 +649,125 @@ describe('shapeText', () => {
expect(glyphs[4].offset.y).toBeCloseTo(3.5 - 48 - 9);
});
});
+
+ describe('rich text tags', () => {
+ const red = new Color(1, 0, 0, 1);
+
+ it('leaves untagged glyphs without a color or embolden', () => {
+ const { glyphs } = shapeText('AV', buildFixtureFontAtlasData(), {
+ size: 10,
+ });
+
+ expect(glyphs.map((glyph) => glyph.color)).toEqual([
+ undefined,
+ undefined,
+ ]);
+ expect(glyphs.map((glyph) => glyph.embolden)).toEqual([0, 0]);
+ });
+
+ it('colors the glyphs inside a color tag without moving any glyph', () => {
+ const untagged = shapeText('AV AV', buildFixtureFontAtlasData(), {
+ size: 10,
+ });
+ const tagged = shapeText(
+ 'AV AV',
+ buildFixtureFontAtlasData(),
+ { size: 10 },
+ );
+
+ expect(tagged.glyphs.map((glyph) => glyph.color)).toEqual([
+ undefined,
+ red,
+ red,
+ undefined,
+ ]);
+ expect(tagged.glyphs.map((glyph) => glyph.offset)).toEqual(
+ untagged.glyphs.map((glyph) => glyph.offset),
+ );
+ expect(tagged.bounds).toEqual(untagged.bounds);
+ });
+
+ it('kerns across a tag boundary exactly as without the tag', () => {
+ const untagged = shapeText('AV', buildFixtureFontAtlasData(), {
+ size: 10,
+ });
+ const tagged = shapeText(
+ 'AV',
+ buildFixtureFontAtlasData(),
+ { size: 10 },
+ );
+
+ expect(tagged.glyphs[1].offset.x).toBeCloseTo(
+ untagged.glyphs[1].offset.x,
+ );
+ });
+
+ it("wraps a tagged string as if its tags weren't there", () => {
+ const options = { size: 10, maxWidth: 20 };
+ const untagged = shapeText(
+ 'AV AV AV',
+ buildFixtureFontAtlasData(),
+ options,
+ );
+ const tagged = shapeText(
+ 'AV AV AV',
+ buildFixtureFontAtlasData(),
+ options,
+ );
+
+ expect(tagged.glyphs.map((glyph) => glyph.offset)).toEqual(
+ untagged.glyphs.map((glyph) => glyph.offset),
+ );
+ expect(tagged.glyphs.map((glyph) => glyph.color)).toEqual([
+ undefined,
+ undefined,
+ red,
+ red,
+ red,
+ red,
+ ]);
+ });
+
+ it('emboldens bold glyphs in the distance field units of the atlas', () => {
+ const { glyphs } = shapeText('AV', buildFixtureFontAtlasData(), {
+ size: 10,
+ });
+
+ // "A"'s atlas rect is 0.1 * 256 = 25.6 atlas pixels wide for 0.5em of
+ // plane bounds, so 51.2 atlas pixels per em, over a 4 pixel range.
+ expect(glyphs[0].embolden).toBeCloseTo((FAUX_BOLD_EMBOLDEN * 51.2) / 4);
+ expect(glyphs[1].embolden).toBe(0);
+ });
+
+ it('widens a bold glyph by the embolden on both sides', () => {
+ const regular = shapeText('AV', buildFixtureFontAtlasData(), {
+ size: 10,
+ });
+ const bold = shapeText('AV', buildFixtureFontAtlasData(), {
+ size: 10,
+ });
+ const emboldenWidth = FAUX_BOLD_EMBOLDEN * 10;
+
+ expect(bold.glyphs[0].offset.x).toBeCloseTo(
+ regular.glyphs[0].offset.x + emboldenWidth,
+ );
+ expect(bold.glyphs[1].offset.x).toBeCloseTo(
+ regular.glyphs[1].offset.x + 2 * emboldenWidth,
+ );
+ expect(bold.bounds.width).toBeCloseTo(
+ regular.bounds.width + 2 * emboldenWidth,
+ );
+ });
+
+ it('shapes markup it does not recognize as literal text', () => {
+ const { glyphs } = shapeText('A', buildFixtureFontAtlasData(), {
+ size: 10,
+ });
+
+ // "<", "i", ">" and "/" aren't in the fixture atlas, so only "A" draws -
+ // and it isn't styled.
+ expect(glyphs).toHaveLength(1);
+ expect(glyphs[0].color).toBeUndefined();
+ });
+ });
});
diff --git a/src/text/utilities/shape-text.ts b/src/text/utilities/shape-text.ts
index b82d148c..e874f5b4 100644
--- a/src/text/utilities/shape-text.ts
+++ b/src/text/utilities/shape-text.ts
@@ -1,8 +1,18 @@
+import type { Color } from '../../rendering/color.js';
import type { GlyphQuad } from '../components/text-mesh-component.js';
import {
type FontAtlasData,
getKerningPairKey,
} from '../font-atlas/font-atlas-data.js';
+import { parseRichText } from './parse-rich-text.js';
+
+/**
+ * How far a `` glyph's ink is thickened on every side, in ems - the
+ * same synthetic ("faux") bold browsers and FreeType apply when a font has
+ * no bold cut: the glyph's edge is pushed outwards, and its advance grows
+ * by the added width so bold letters don't run into each other.
+ */
+export const FAUX_BOLD_EMBOLDEN = 0.02;
/**
* Options for {@link shapeText}. Mirrors the shape-relevant subset of
@@ -83,6 +93,86 @@ const defaultShapeTextOptions = {
horizontalAlignPivot: 0,
};
+/**
+ * The style each character of the shaped (tag-free) string is drawn with,
+ * indexed by UTF-16 code unit.
+ */
+interface CharacterStyles {
+ /** The `` color of each character, or `undefined` outside one. */
+ colors: (Color | undefined)[];
+
+ /** Whether each character is inside a `` tag. */
+ bold: boolean[];
+
+ /**
+ * The `GlyphQuad.embolden` of a bold glyph (see
+ * {@link getFauxBoldEmbolden}), or `0` if the string has no bold text.
+ */
+ boldEmbolden: number;
+}
+
+/**
+ * Parses `text`'s rich text tags into the plain string that's shaped and
+ * the style of each of its characters.
+ * @param text - The string to parse.
+ * @param fontAtlasData - The font atlas the string is shaped against.
+ * @returns The plain string and its per-character styles.
+ */
+function resolveCharacterStyles(
+ text: string,
+ fontAtlasData: FontAtlasData,
+): {
+ plainText: string;
+ styles: CharacterStyles;
+} {
+ const { text: plainText, colorRuns, boldRuns } = parseRichText(text);
+ const colors = new Array(plainText.length);
+ const bold = new Array(plainText.length).fill(false);
+
+ for (const { start, end, color } of colorRuns) {
+ colors.fill(color, start, end);
+ }
+
+ for (const { start, end } of boldRuns) {
+ bold.fill(true, start, end);
+ }
+
+ const boldEmbolden =
+ boldRuns.length > 0 ? getFauxBoldEmbolden(fontAtlasData) : 0;
+
+ return { plainText, styles: { colors, bold, boldEmbolden } };
+}
+
+/**
+ * Converts {@link FAUX_BOLD_EMBOLDEN} into the distance field's own units:
+ * how far the MSDF shaders shift a bold glyph's edge threshold. The field
+ * spans `distanceRange` atlas pixels, and the generator bakes every glyph
+ * at the same size, so any glyph's atlas rect against its plane bounds
+ * gives the atlas's pixels per em.
+ * @param fontAtlasData - The font atlas to convert for.
+ * @returns The threshold shift for a bold glyph, or `0` if the atlas has
+ * no glyph with ink to measure it from.
+ */
+function getFauxBoldEmbolden(fontAtlasData: FontAtlasData): number {
+ for (const { planeBounds, atlasBounds } of fontAtlasData.glyphs.values()) {
+ const planeWidth = planeBounds ? planeBounds.right - planeBounds.left : 0;
+
+ if (!atlasBounds || planeWidth <= 0) {
+ continue;
+ }
+
+ const atlasPixelsPerEm =
+ ((atlasBounds.right - atlasBounds.left) * fontAtlasData.atlasSize.width) /
+ planeWidth;
+
+ return (
+ (FAUX_BOLD_EMBOLDEN * atlasPixelsPerEm) / fontAtlasData.distanceRange
+ );
+ }
+
+ return 0;
+}
+
/** A single word's shaped glyphs, positioned relative to the word's own start (x = 0). */
interface ShapedWord {
glyphs: GlyphQuad[];
@@ -112,7 +202,15 @@ interface ShapedLine {
* what lets {@link wrapIntoLines} reuse this same walk for both the
* word-wrap width check and the glyphs it ultimately emits, with no second
* measurement pass.
+ *
+ * Each glyph takes its style from the character it was shaped from:
+ * kerning and measurement only ever see the tag-free string, so a tag
+ * boundary inside a word changes how a glyph is drawn, never where it
+ * sits - except that a bold glyph is widened by {@link FAUX_BOLD_EMBOLDEN}
+ * on both sides.
* @param word - The word's code points, with no whitespace.
+ * @param wordStart - The index of the word's first character in the shaped string, to look its style up by.
+ * @param styles - The style of every character in the shaped string.
* @param fontAtlasData - The font atlas metrics to shape against.
* @param size - Font size, in world units.
* @param letterSpacing - Extra spacing between adjacent glyphs, in ems.
@@ -120,6 +218,8 @@ interface ShapedLine {
*/
function shapeWord(
word: string,
+ wordStart: number,
+ styles: CharacterStyles,
fontAtlasData: FontAtlasData,
size: number,
letterSpacing: number,
@@ -128,10 +228,14 @@ function shapeWord(
let penX = 0;
let previousCodePoint: number | null = null;
let hasPreviousGlyph = false;
+ let characterIndex = wordStart;
for (const character of word) {
const codePoint = character.codePointAt(0) as number;
const glyph = fontAtlasData.glyphs.get(codePoint);
+ const styleIndex = characterIndex;
+
+ characterIndex += character.length;
if (!glyph) {
previousCodePoint = null;
@@ -139,6 +243,9 @@ function shapeWord(
continue;
}
+ const isBold = styles.bold[styleIndex];
+ const emboldenWidth = isBold ? FAUX_BOLD_EMBOLDEN * size : 0;
+
// Letter spacing goes *between* glyphs, so it's added before every
// glyph but the first rather than after every glyph: spacing after the
// last glyph would count towards the word's width, pushing centered
@@ -179,9 +286,12 @@ function shapeWord(
const atlasBottom = atlasBounds.bottom + insetY;
const atlasTop = atlasBounds.top - insetY;
+ // The quad doesn't grow with the bold ink: plane bounds already
+ // include `distanceRange / 2` atlas pixels of padding around the ink,
+ // which is where the thickened edge is drawn.
glyphs.push({
offset: {
- x: penX + (planeBounds.left * size + glyphWidth / 2),
+ x: penX + emboldenWidth + (planeBounds.left * size + glyphWidth / 2),
y: planeBounds.bottom * size + glyphHeight / 2,
},
size: { x: glyphWidth, y: glyphHeight },
@@ -195,10 +305,12 @@ function shapeWord(
x: atlasRight - atlasLeft,
y: atlasTop - atlasBottom,
},
+ color: styles.colors[styleIndex],
+ embolden: isBold ? styles.boldEmbolden : 0,
});
}
- penX += glyph.advance * size;
+ penX += glyph.advance * size + 2 * emboldenWidth;
previousCodePoint = codePoint;
hasPreviousGlyph = true;
}
@@ -241,8 +353,9 @@ function getWhitespaceAdvance(
* already has at least one word, in which case a new line starts with that
* word instead. A single word wider than `maxWidth` on its own is never
* split mid-word - it simply overflows its own line.
- * @param text - The full string to wrap. A single call always returns at
- * least one (possibly empty) line.
+ * @param text - The full, tag-free string to wrap. A single call always
+ * returns at least one (possibly empty) line.
+ * @param styles - The style of every character in `text`.
* @param fontAtlasData - The font atlas metrics to shape against.
* @param size - Font size, in world units.
* @param letterSpacing - Extra spacing between adjacent glyphs, in ems.
@@ -252,6 +365,7 @@ function getWhitespaceAdvance(
*/
function wrapIntoLines(
text: string,
+ styles: CharacterStyles,
fontAtlasData: FontAtlasData,
size: number,
letterSpacing: number,
@@ -271,14 +385,27 @@ function wrapIntoLines(
lineContentWidth = 0;
};
+ let tokenStart = 0;
+
for (const token of tokens) {
+ const wordStart = tokenStart;
+
+ tokenStart += token.length;
+
if (/^\s/.test(token)) {
penX += getWhitespaceAdvance(token, fontAtlasData, size);
continue;
}
- const word = shapeWord(token, fontAtlasData, size, letterSpacing);
+ const word = shapeWord(
+ token,
+ wordStart,
+ styles,
+ fontAtlasData,
+ size,
+ letterSpacing,
+ );
// A word whose code points are all missing from the atlas draws nothing
// and takes up no space, so it mustn't add a letter space either.
@@ -421,14 +548,20 @@ function getVerticalAlignOffset(
* single word (nothing to stretch). It has no effect when `maxWidth` is
* unset, since every line is then already exactly as wide as the block.
*
+ * `text` may contain rich text tags - `...` and
+ * `...`, see {@link parseRichText} - which are
+ * stripped before shaping, so they never affect kerning or wrapping, and
+ * set the style of the glyphs between them.
+ *
* Code points not present in `fontAtlasData.glyphs` are silently skipped
* (no glyph quad, no advance) rather than throwing - a missing glyph in
* player-supplied or localized text is a content problem, not a programming
* error.
- * @param text - The string to shape.
+ * @param text - The string to shape, which may contain rich text tags.
* @param fontAtlasData - The font atlas metrics to shape against.
* @param options - Shaping options.
* @returns The shaped glyph quads and the block's bounds.
+ * @throws An error if `text` contains a malformed tag (see {@link parseRichText}).
*/
export function shapeText(
text: string,
@@ -445,8 +578,10 @@ export function shapeText(
horizontalAlignPivot,
} = { ...defaultShapeTextOptions, ...options };
+ const { plainText, styles } = resolveCharacterStyles(text, fontAtlasData);
const lines = wrapIntoLines(
- text,
+ plainText,
+ styles,
fontAtlasData,
size,
letterSpacing,