diff --git a/docs/RTL/index.md b/docs/RTL/index.md new file mode 100644 index 0000000..6f0a19c --- /dev/null +++ b/docs/RTL/index.md @@ -0,0 +1,176 @@ +# Right-to-left (RTL) support + +Applications may need to be localised for regions where the language is written +from right to left, such as Hebrew or Arabic. Users expect not only the text to +be correctly rendered, but also the whole layout to be mirrored: rails populate +from right to left, a side navigation on the left appears on the right instead, +and so on. + +By contrast, the default layout and text direction is called "left-to-right" +(LTR). + +RTL support has two independent aspects: + +- **RTL layout** — mirroring the structure of the scene graph, and +- **RTL text** — correctly ordering bidirectional (mixed LTR/RTL) text. + +They can be used separately. Layout mirroring works with any text; bidirectional +text rendering is currently a **Canvas-only** feature (see the +[limitations](#limitations) below). + +## RTL layout + +Every node has an `rtl` property that hints whether the node's **children** +should be laid out mirrored: + +```ts +const container = renderer.createNode({ + x: 20, + y: 20, + w: 700, // a width is required — see the caveat below + h: 200, + rtl: true, + parent: root, +}); + +// Children use the same `x` you'd use for LTR; it is interpreted from the +// right edge automatically. +renderer.createNode({ x: 0, w: 200, h: 200, parent: container }); // rightmost +renderer.createNode({ x: 220, w: 200, h: 200, parent: container }); // middle +renderer.createNode({ x: 440, w: 200, h: 200, parent: container }); // leftmost +``` + +The `rtl` flag is **inherited**: setting it on a node mirrors that node's whole +sub-tree. In practice, setting `rtl` on the application root mirrors the entire +app: + +```ts +renderer.root.rtl = true; +``` + +To opt a sub-tree back out of mirroring, set an explicit `rtl: false` on it: + +```ts +// Inside an RTL app, this branch lays its children out left-to-right again. +renderer.createNode({ rtl: false, parent: someRtlBranch }); +``` + +`rtl` accepts three values: + +| Value | Meaning | +| ------- | ------------------------------------- | +| `true` | Mirror this node's children | +| `false` | Force left-to-right for this sub-tree | +| `null` | Inherit from the parent (the default) | + +A node's own position is governed by its **parent's** resolved direction, while +its own `rtl` value governs how **its children** are placed. So `rtl: false` on +a leaf node has no visible effect — it only matters for nodes that have children. + +### How the mirroring is calculated + +When a parent is RTL, a child's horizontal position is measured from the +parent's right edge instead of its left: + +![LTR vs RTL layout calculations](./ltr-rtl.png) + +- **LTR:** `x = xa + xb` +- **RTL:** `x = xa + wa - xb - wb` + +where `wa` is the parent width, `wb` the child width, and `xb` the child's `x`. +Scale is taken into account (`wb` is the scaled width). + +> **Important caveat:** because the calculation needs `wa`, **the parent must +> have a known width** (`w`). In an LTR-only app it is often possible to omit +> `w` on containers, but for automatic RTL mirroring to work, the width must be +> set. A parent without a width lays its children out left-to-right. + +### Text alignment + +The `rtl` flag also mirrors text alignment: a text node's `textAlign` of `left` +and `right` are automatically reversed when the node resolves to RTL. So +`textAlign: 'left'` (the default) becomes right-aligned, which is what you want +for RTL UIs. `center` is unchanged. + +Note that flipping the alignment alone does **not** reorder the characters of +mixed LTR/RTL text — see [RTL text](#rtl-text) below for that. + +## RTL text + +Correctly rendering RTL text requires _bidirectional_ (bidi) layout: a single +string may combine LTR and RTL runs — numbers and untranslated words stay +left-to-right, while Hebrew/Arabic runs go right-to-left. + +On the **Canvas** text renderer this works automatically by leaning on the +browser's built-in text engine. Force the canvas renderer and mark the node (or +an ancestor) as `rtl`: + +```ts +renderer.createTextNode({ + w: 560, + rtl: true, // or inherit from an rtl ancestor + textRendererOverride: 'canvas', + fontFamily: 'NotoSansHebrew', // a font that actually covers the script + fontSize: 40, + text: 'שלום world 123', + parent: root, +}); +``` + +With `rtl` set, the line is given an RTL base direction and the browser reorders +the runs: `שלום world 123` renders as `world 123 שלום`, `90 דקות` renders as +`דקות 90`, each right-aligned within the node. + +The text renderer detects direction from the node's resolved `rtl` flag; there +is no separate tokenizer to configure and no extra dependency to install. + +> **Use a font that covers the script.** The Canvas renderer draws with the font +> you load via `stage.loadFont('canvas', …)`. If that font lacks Hebrew (or +> Arabic) glyphs, the browser falls back to a system font, which varies between +> machines. Load a script-covering font (e.g. Noto Sans Hebrew) and reference it +> by `fontFamily` so rendering is consistent everywhere. + +### Bidirectional concatenation + +When a label is built by concatenating several parts — `{title} {description}` +or `{tag1} {tag2} {tag3}` — the parts can interact: a leading LTR part can flip +how the whole reads, and an LTR number in one part can attach to a neighbouring +part. To keep each part self-contained, wrap it in Unicode _isolate_ characters: + +```ts +const FSI = '\u2068'; // First Strong Isolate (auto-detect each part) +const PDI = '\u2069'; // Pop Directional Isolate + +const label = parts.map((p) => `${FSI}${p}${PDI}`).join(' '); +``` + +This is plain Unicode handled by the browser's bidi engine — no API is required. + +## Input + +This renderer does not handle remote/keyboard input. If your application mirrors +its layout, remember that the _visual_ meaning of Left and Right is reversed, so +your input layer should swap Left/Right key handling for mirrored sub-trees. That +logic lives in the application, not the renderer. + +## Limitations + +- **RTL text requires the Canvas renderer.** Bidi rendering relies on the + browser's text engine, which the SDF (WebGL) renderer does not use — SDF draws + glyphs in logical order, so mixed/RTL strings render incorrectly. This is a + design decision, not a pending feature: render RTL text nodes with + `textRendererOverride: 'canvas'`. (Layout mirroring and alignment still apply + to SDF nodes; only the character ordering is wrong.) +- **Arabic shaping** (contextual letter joining) is out of scope. Hebrew and + other non-joining scripts, plus mixed LTR, are supported on Canvas. +- **`letterSpacing` is ignored for RTL text.** Per-character spacing is drawn + glyph-by-glyph, which defeats bidi reordering, so it is forced to `0` for RTL + nodes. +- **Layout mirroring requires known widths** (see the caveat above). + +## Browser support + +RTL text relies on the browser's built-in `fillText` bidi, which is available on +all supported targets. The base direction is forced using Unicode control +characters (RLE/PDF) rather than the `CanvasRenderingContext2D.direction` +property, so it works down to the engine's Chrome 38 floor. diff --git a/docs/RTL/ltr-rtl.png b/docs/RTL/ltr-rtl.png new file mode 100644 index 0000000..8a6b323 Binary files /dev/null and b/docs/RTL/ltr-rtl.png differ diff --git a/examples/common/installFonts.ts b/examples/common/installFonts.ts index a3c619a..eee83c9 100644 --- a/examples/common/installFonts.ts +++ b/examples/common/installFonts.ts @@ -42,6 +42,19 @@ export async function installFonts(stage: Stage) { metrics: ubuntuModifiedMetrics, }); + // Bundled Hebrew-capable font so RTL/bidi text tests don't depend on a + // non-deterministic system font fallback. Noto Sans Hebrew also covers Latin. + stage.loadFont('canvas', { + fontFamily: 'NotoSansHebrew', + fontUrl: './fonts/NotoSansHebrew-Regular.ttf', + metrics: { + ascender: 1069, + descender: -293, + lineGap: 0, + unitsPerEm: 1000, + }, + }); + // Load SDF fonts for WebGL renderer using the new unified API if (stage.renderer.mode === 'webgl') { stage.loadFont('sdf', { diff --git a/examples/public/fonts/NotoSansHebrew-OFL.txt b/examples/public/fonts/NotoSansHebrew-OFL.txt new file mode 100644 index 0000000..a2ce395 --- /dev/null +++ b/examples/public/fonts/NotoSansHebrew-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/hebrew) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/public/fonts/NotoSansHebrew-Regular.ttf b/examples/public/fonts/NotoSansHebrew-Regular.ttf new file mode 100644 index 0000000..ef33d06 Binary files /dev/null and b/examples/public/fonts/NotoSansHebrew-Regular.ttf differ diff --git a/examples/tests/rtl-text-canvas-wrap.ts b/examples/tests/rtl-text-canvas-wrap.ts new file mode 100644 index 0000000..8aa5e87 --- /dev/null +++ b/examples/tests/rtl-text-canvas-wrap.ts @@ -0,0 +1,89 @@ +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export async function automation(settings: ExampleSettings) { + await test(settings); + await settings.snapshot(); +} + +/** + * Canvas RTL text: word wrapping and overflow. + * + * Verifies that bidirectional Canvas text behaves correctly when wrapped across + * multiple lines and when truncated with an overflow suffix: + * + * - Wrapping happens in logical order, and each visual line is right-aligned and + * reordered (RTL base direction) for an `rtl` node. + * - With `contain: 'width'`, the block hugs the right edge of the `maxWidth` box. + * - `maxLines` + `overflowSuffix` truncates and appends the suffix at the + * logical end of the text. + * + * The left column is `rtl: false` (LTR) for contrast; the right column is + * `rtl: true`. Uses the bundled NotoSansHebrew font so the snapshot is + * deterministic across environments (no system font fallback). + */ +export default async function test({ renderer, testRoot }: ExampleSettings) { + // Long mixed Hebrew + Latin + number paragraph: + // "This is a long paragraph in Hebrew with numbers like 42 and English + // words like world that wraps across several lines in the layout." + const paragraph = + 'זהו פסקה ארוכה בעברית עם מספרים כמו 42 ומילים באנגלית like world ' + + 'שנמשכת על פני כמה שורות בתוך הפריסה הזאת.'; + + const COL_W = 520; + const MAX_W = 480; + + const labeled = ( + x: number, + y: number, + label: string, + rtl: boolean, + extra: Record, + ) => { + renderer.createTextNode({ + x, + y, + w: COL_W, + fontFamily: 'Ubuntu', + fontSize: 24, + color: 0xffffffff, + forceLoad: true, + text: label, + parent: testRoot, + }); + + renderer.createTextNode({ + x, + y: y + 36, + w: MAX_W, + maxWidth: MAX_W, + contain: 'width', + rtl, + fontFamily: 'NotoSansHebrew', + fontSize: 34, + lineHeight: 44, + color: 0xffd700ff, + forceLoad: true, + textRendererOverride: 'canvas', + text: paragraph, + parent: testRoot, + ...extra, + }); + }; + + const lx = 20; + const rx = 620; + + // Row 1: free wrapping across as many lines as needed. + labeled(lx, 20, 'Wrapped - LTR', false, {}); + labeled(rx, 20, 'Wrapped - RTL', true, {}); + + // Row 2: truncated to 2 lines with an ellipsis suffix. + labeled(lx, 360, 'maxLines: 2 + ellipsis - LTR', false, { + maxLines: 2, + overflowSuffix: '…', + }); + labeled(rx, 360, 'maxLines: 2 + ellipsis - RTL', true, { + maxLines: 2, + overflowSuffix: '…', + }); +} diff --git a/examples/tests/rtl-text-canvas.ts b/examples/tests/rtl-text-canvas.ts new file mode 100644 index 0000000..27a13cb --- /dev/null +++ b/examples/tests/rtl-text-canvas.ts @@ -0,0 +1,66 @@ +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export async function automation(settings: ExampleSettings) { + await test(settings); + await settings.snapshot(); +} + +/** + * Canvas bidirectional (RTL) text. + * + * The Canvas renderer leans on the browser's built-in `fillText` bidi: when the + * node is `rtl`, each line is given an RTL base direction (portably, via an + * RLE/PDF wrap) so mixed Hebrew/Latin/number runs reorder correctly and the + * paragraph reads right-to-left, right-aligned. + * + * Left column: `rtl: false` (LTR base) — Hebrew still shapes RTL within its run, + * but the overall order and alignment are left-to-right. + * Right column: `rtl: true` — RTL base direction, right-aligned, correct order. + * + * SDF text is intentionally not covered (deferred); these nodes force the + * 'canvas' renderer. + */ +export default async function test({ renderer, testRoot }: ExampleSettings) { + const samples = [ + 'שלום עולם', // "hello world" + 'שלום world 123', // mixed Hebrew + Latin + digits + '90 דקות', // "90 minutes" — leading number + 'מחיר: $42 לחודש', // "price: $42 per month" + ]; + + const COL_W = 560; + const ROW_H = 70; + + const makeColumn = (x: number, rtl: boolean, title: string) => { + renderer.createTextNode({ + x, + y: 20, + w: COL_W, + fontFamily: 'Ubuntu', + fontSize: 32, + color: 0xffffffff, + forceLoad: true, + text: title, + parent: testRoot, + }); + + for (let i = 0; i < samples.length; i++) { + renderer.createTextNode({ + x, + y: 80 + i * ROW_H, + w: COL_W, + rtl, + fontFamily: 'NotoSansHebrew', + fontSize: 40, + color: 0xffd700ff, + forceLoad: true, + textRendererOverride: 'canvas', + text: samples[i], + parent: testRoot, + }); + } + }; + + makeColumn(20, false, 'LTR base (rtl: false)'); + makeColumn(620, true, 'RTL base (rtl: true)'); +} diff --git a/src/core/text-rendering/CanvasTextRenderer.ts b/src/core/text-rendering/CanvasTextRenderer.ts index b8db141..7b93f51 100644 --- a/src/core/text-rendering/CanvasTextRenderer.ts +++ b/src/core/text-rendering/CanvasTextRenderer.ts @@ -12,6 +12,16 @@ import { mapTextLayout, resolveTextAlign } from './TextLayoutEngine.js'; const MAX_TEXTURE_DIMENSION = 4096; +// Bidi base-direction control characters. `ctx.direction = 'rtl'` is only +// available from Chrome 63+, but the language/runtime floor is Chrome 38, so we +// force the RTL base direction by wrapping the line in a Right-to-Left Embedding +// (RLE) ... Pop Directional Formatting (PDF) pair. The browser's built-in +// fillText bidi then reorders mixed LTR/RTL runs on every supported browser. +// RLE (embedding) keeps the Unicode Bidi Algorithm running inside the run, so +// Latin words and numbers stay left-to-right — unlike RLO (override). +const RLE = '\u202B'; +const PDF = '\u202C'; + const type = 'canvas' as const; const font: FontHandler = CanvasFontHandler; @@ -116,7 +126,12 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { const metrics = CanvasFontHandler.getFontMetrics(fontFamily, fontSize); - const letterSpacing = props.letterSpacing; + const isRtl = props.rtl === true; + // Per-character letterSpacing is drawn glyph-by-glyph left-to-right, which + // defeats the browser's bidi reordering. Native ctx.letterSpacing is Chrome + // 99+ (below our Chrome 38 floor), so letterSpacing is unsupported for RTL + // text: force it to 0 here so the measured layout matches the whole-line draw. + const letterSpacing = isRtl === true ? 0 : props.letterSpacing; const [ lines, @@ -154,6 +169,12 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { context.fillStyle = `rgba(${r},${g},${b},${a / 255})`; context.font = font; context.textBaseline = 'alphabetic'; + // Anchor every line by its left edge at line[3] regardless of direction + // ('start' would flip to the right edge under RTL). Set direction for modern + // browsers; the RLE/PDF wrap below covers the Chrome 38 floor. The context is + // shared across renders, so both are set unconditionally to reset prior state. + context.textAlign = 'left'; + context.direction = isRtl === true ? 'rtl' : 'ltr'; // Performance optimization for large fonts if (fontSize >= 128) { @@ -167,7 +188,10 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { const textLine = line[0]; let currentX = Math.ceil(line[3]); const currentY = Math.ceil(line[4]); - if (letterSpacing === 0) { + if (isRtl === true) { + // Force RTL base direction so the browser's bidi reorders mixed runs. + context.fillText(RLE + textLine + PDF, currentX, currentY); + } else if (letterSpacing === 0) { context.fillText(textLine, currentX, currentY); } else { const textLineLength = textLine.length; diff --git a/visual-regression/certified-snapshots/chromium-ci/rtl-text-canvas-1.png b/visual-regression/certified-snapshots/chromium-ci/rtl-text-canvas-1.png new file mode 100644 index 0000000..c870480 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/rtl-text-canvas-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/rtl-text-canvas-wrap-1.png b/visual-regression/certified-snapshots/chromium-ci/rtl-text-canvas-wrap-1.png new file mode 100644 index 0000000..24fd3ce Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/rtl-text-canvas-wrap-1.png differ