From 6f9b72b0f03a798e476f696bb10665dfd85d264a Mon Sep 17 00:00:00 2001 From: Philippe Elsass Date: Tue, 8 Apr 2025 15:31:18 +0200 Subject: [PATCH 01/32] RTL layout support --- docs/RTL/index.md | 37 ++++ docs/RTL/ltr-rtl.png | Bin 0 -> 96535 bytes docs/index.md | 1 + src/application/Application.d.mts | 5 +- src/application/Application.mjs | 75 +++++-- src/textures/TextTexture.d.mts | 10 + src/textures/TextTexture.mjs | 5 +- src/textures/TextTextureRenderer.mjs | 14 +- src/tree/Element.d.mts | 21 ++ src/tree/Element.mjs | 22 ++ src/tree/core/ElementCore.d.mts | 2 + src/tree/core/ElementCore.mjs | 62 +++++- tests/automation.spec.ts | 23 ++ tests/rtl/src/Button.mjs | 59 +++++ tests/rtl/test.rtl.js | 320 +++++++++++++++++++++++++++ tests/test.html | 2 + 16 files changed, 629 insertions(+), 29 deletions(-) create mode 100644 docs/RTL/index.md create mode 100644 docs/RTL/ltr-rtl.png create mode 100644 tests/rtl/src/Button.mjs create mode 100644 tests/rtl/test.rtl.js diff --git a/docs/RTL/index.md b/docs/RTL/index.md new file mode 100644 index 00000000..bca5162f --- /dev/null +++ b/docs/RTL/index.md @@ -0,0 +1,37 @@ +# Right-to-left (RTL) support + +Lightning applications may have to be localised for regions where the language is written from right to left, like Hebrew or Arabic. Users expect not only text to be correctly rendered, but also expect the whole application layout to be mirrored. For instance rails would be populated from right to left, and a side navigation on the left would appear on the right instead. + +By opposition, the default application layout and text direction is called "left-to-right" (LTR). + +RTL support encompasses 2 aspects: + +- RTL layout support; which means mirroring the application layout, +- RTL text rendering support; which means accurately rendering (and wrapping) RTL text. + +## How RTL layout works + +To limit adaption effort for the application developer, Lightning has built-in and transparent support for RTL layout mirroring: leave `x` and flexbox directions as they are for LTR, and they will be interpreted automatically when RTL layout is enabled. + +**There is however an important caveat:** in a LTR only application it is often possible to omit specifying a `w` for containers, but for automatic RTL mirroring to function, the widths need to be known, either through an explicit `w` or horizontal flexbox layout. + +Here's a simplified diagram of the calculations: +![LTR vs RTL layout calculations](./ltr-rtl.png) + +Lightning elements (and components) have a `rtl` property to hint whether the elements children layout should be mirrored. + +In practice, setting the application's `rtl` flag will mirror the entire application, as the property is inherited. It is however possible to set some element's `rtl` to an explicit `false` to prevent mirroring of a sub-tree of the application. + +### How input works in RTL + +A consequence of the choice of transparent mirroring is that the Left and Right key shoud be interpreted in accordance to the layout direction. + +This is also automatic, and pressing a Left or Right key will result in the opposite Right or Left key event to be received by components when their layout is mirrored. + +### How RTL text works + +When the RTL flag is set, text alignement is mirrored, so left-aligned text becomes right-aligned. + +But RTL text support also requires to properly wrap text and render punctuation at the right place. Text also may be a combination of RTL and LTR text. + +TODO diff --git a/docs/RTL/ltr-rtl.png b/docs/RTL/ltr-rtl.png new file mode 100644 index 0000000000000000000000000000000000000000..8a6b323e6b4750b4ba79639962549ea9d6460246 GIT binary patch literal 96535 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 literal 0 HcmV?d00001 diff --git a/docs/index.md b/docs/index.md index 2d54c2e1..f4fd2f70 100644 --- a/docs/index.md +++ b/docs/index.md @@ -69,6 +69,7 @@ The Reference Documentation for Lightning Core contains detailed descriptions ab * [Signal](Communication/Signal.md) * [Fire Ancestors](Communication/FireAncestors.md) * [Accessibility](Accessibility/index.md) +* [Right-to-left support](RTL/index.md) * [TypeScript](TypeScript/index.md) * [Components](TypeScript/Components/index.md) * [Template Specs](TypeScript/Components/TemplateSpecs.md) diff --git a/src/application/Application.d.mts b/src/application/Application.d.mts index bef27fec..0525b71b 100644 --- a/src/application/Application.d.mts +++ b/src/application/Application.d.mts @@ -186,8 +186,9 @@ declare class Application< */ get focusPath(): Component[] | undefined; - // focusTopDownEvent(events: any, ...args: any[]): any; - // focusBottomUpEvent(events: any, ...args: any[]): any; + // getDirectionAwareEvents(events: string[], ...args: any[]): { eventsLtr: string[], eventsRtl: string[], isHorizontalDirection }; + // focusTopDownEvent(events: string[], ...args: any[]): any; + // focusBottomUpEvent(events: string[], ...args: any[]): any; // _receiveKeydown(e: KeyboardEvent): void; // _receiveKeyup(e: KeyboardEvent): void; // _startLongpressTimer(key: any, element: any): void; diff --git a/src/application/Application.mjs b/src/application/Application.mjs index acdd3e31..02961e9c 100644 --- a/src/application/Application.mjs +++ b/src/application/Application.mjs @@ -36,6 +36,9 @@ export default class Application extends Component { this.__keypressTimers = new Map(); this.__hoveredChild = null; + // Default to LTR direction + this.core._ownRtl = false; + // We must construct while the application is not yet attached. // That's why we 'init' the stage later (which actually emits the attach event). this.stage.init(); @@ -261,6 +264,36 @@ export default class Application extends Component { return this._focusPath; } + /** + * Return direction aware events: if the 1st event includes `Left` or `Right`, + * this returns 2 different sets of events, where one is LTR (original) and one is RTL (reversed directions). + * + * Using the LTR or RTL variant of the events will depend on a component's direction. + * @returns + */ + getDirectionAwareEvents(events) { + if (events.length > 0) { + if (events[0].indexOf('Left') > 0) { + return { + eventsLtr: events, + eventsRtl: [events[0].replace('Left', 'Right'), ...events.slice(1)], + isHorizontalDirection: true + } + } else if (events[0].indexOf('Right') > 0) { + return { + eventsLtr: events, + eventsRtl: [events[0].replace('Right', 'Left'), ...events.slice(1)], + isHorizontalDirection: true + } + } + } + return { + eventsLtr: events, + eventsRtl: events, + isHorizontalDirection: false + } + } + /** * Injects an event in the state machines, top-down from application to focused component. */ @@ -268,11 +301,16 @@ export default class Application extends Component { const path = this.focusPath; const n = path.length; + // RTL support + const { eventsLtr, eventsRtl, isHorizontalDirection } = this.getDirectionAwareEvents(events); + // Multiple events. for (let i = 0; i < n; i++) { - const event = path[i]._getMostSpecificHandledMember(events); + const target = path[i]; + const events = isHorizontalDirection && target.rtl ? eventsRtl : eventsLtr; + const event = target._getMostSpecificHandledMember(events); if (event !== undefined) { - const returnValue = path[i][event](...args); + const returnValue = target[event](...args); if (returnValue !== false) { return true; } @@ -289,11 +327,16 @@ export default class Application extends Component { const path = this.focusPath; const n = path.length; + // RTL support + const { eventsLtr, eventsRtl, isHorizontalDirection } = this.getDirectionAwareEvents(events); + // Multiple events. for (let i = n - 1; i >= 0; i--) { - const event = path[i]._getMostSpecificHandledMember(events); + const target = path[i]; + const events = isHorizontalDirection && target.rtl ? eventsRtl : eventsLtr; + const event = target._getMostSpecificHandledMember(events); if (event !== undefined) { - const returnValue = path[i][event](...args); + const returnValue = target[event](...args); if (returnValue !== false) { return true; } @@ -315,19 +358,20 @@ export default class Application extends Component { if (keys) { for (let i = 0, n = keys.length; i < n; i++) { - const hasTimer = this.__keypressTimers.has(keys[i]); + const key = keys[i]; + const hasTimer = this.__keypressTimers.has(key); // prevent event from getting fired when the timeout is still active if (path[path.length - 1].longpress && hasTimer) { return; } - if (!this.stage.application.focusTopDownEvent([`_capture${keys[i]}`, "_captureKey"], obj)) { - this.stage.application.focusBottomUpEvent([`_handle${keys[i]}`, "_handleKey"], obj); + if (!this.focusTopDownEvent([`_capture${key}`, "_captureKey"], obj)) { + this.focusBottomUpEvent([`_handle${key}`, "_handleKey"], obj); } } } else { - if (!this.stage.application.focusTopDownEvent(["_captureKey"], obj)) { - this.stage.application.focusBottomUpEvent(["_handleKey"], obj); + if (!this.focusTopDownEvent(["_captureKey"], obj)) { + this.focusBottomUpEvent(["_handleKey"], obj); } } @@ -361,13 +405,14 @@ export default class Application extends Component { if (keys) { for (let i = 0, n = keys.length; i < n; i++) { - if (!this.stage.application.focusTopDownEvent([`_capture${keys[i]}Release`, "_captureKeyRelease"], obj)) { - this.stage.application.focusBottomUpEvent([`_handle${keys[i]}Release`, "_handleKeyRelease"], obj); + const key = keys[i]; + if (!this.focusTopDownEvent([`_capture${key}Release`, "_captureKeyRelease"], obj)) { + this.focusBottomUpEvent([`_handle${key}Release`, "_handleKeyRelease"], obj); } } } else { - if (!this.stage.application.focusTopDownEvent(["_captureKeyRelease"], obj)) { - this.stage.application.focusBottomUpEvent(["_handleKeyRelease"], obj); + if (!this.focusTopDownEvent(["_captureKeyRelease"], obj)) { + this.focusBottomUpEvent(["_handleKeyRelease"], obj); } } @@ -417,8 +462,8 @@ export default class Application extends Component { element._throwError("config value for longpress must be a number"); } else { this.__keypressTimers.set(key, setTimeout(() => { - if (!this.stage.application.focusTopDownEvent([`_capture${key}Long`, "_captureKey"], {})) { - this.stage.application.focusBottomUpEvent([`_handle${key}Long`, "_handleKey"], {}); + if (!this.focusTopDownEvent([`_capture${key}Long`, "_captureKey"], {})) { + this.focusBottomUpEvent([`_handle${key}Long`, "_handleKey"], {}); } this.__keypressTimers.delete(key); diff --git a/src/textures/TextTexture.d.mts b/src/textures/TextTexture.d.mts index d688578f..04ed8adb 100644 --- a/src/textures/TextTexture.d.mts +++ b/src/textures/TextTexture.d.mts @@ -59,6 +59,13 @@ declare namespace TextTexture { * @defaultValue `""` */ text?: string; + /** + * Element has RTL (right-to-left) direction hint. + * When true, left/right alignement is reversed. + * + * @defaultValue `false` + */ + rtl?: boolean; /** * Font style * @@ -469,6 +476,9 @@ declare class TextTexture extends Texture implements Required() { @@ -107,3 +126,7 @@ test('Layout flexbox comparison alignContent', async ({ page }) => { test('Layout flexbox comparison alignSelf', async ({ page }) => { expect(await testCase(page, 'layout flexbox comparison alignSelf')).toBe('SUCCESS'); }); + +test('RTL layout', async ({ page }) => { + expect(await testCase(page, 'Right-to-Left layout')).toBe('SUCCESS'); +}); diff --git a/tests/rtl/src/Button.mjs b/tests/rtl/src/Button.mjs new file mode 100644 index 00000000..f8d0931e --- /dev/null +++ b/tests/rtl/src/Button.mjs @@ -0,0 +1,59 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2020 Metrological + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export default class Button extends lng.Component { + static _template() { + return { + h: 50, + rect: true, + color: 0xff333333, + flex: { padding: 10 }, + flexItem: { + marginRight: 20, + }, + Label: { + text: { + fontFace: "Arial", + fontSize: 40, + textColor: 0xbbffffff, + }, + }, + }; + } + + _construct() { + this.directionUpdatesCount = 0; + } + + _init() { + this.tag("Label").text.text = this.label; + } + + _focus() { + this.color = 0xffff3333; + } + + _unfocus() { + this.color = 0xff333333; + } + + _onDirectionChanged() { + this.directionUpdatesCount++; + } +} diff --git a/tests/rtl/test.rtl.js b/tests/rtl/test.rtl.js new file mode 100644 index 00000000..77f575f0 --- /dev/null +++ b/tests/rtl/test.rtl.js @@ -0,0 +1,320 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2020 Metrological + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Button from './src/Button.mjs'; + +describe('Right-to-Left layout', function () { + this.timeout(0); + + let app; + let stage; + + after(() => { + stage.stop(); + stage.getCanvas().remove(); + }); + + function assertCoordinates(target, expected) { + const { px, py } = target.__core._renderContext; + chai.assert(px === expected.px, `${target.ref}.px !== ${expected.px} (${px})`); + chai.assert(py === expected.py, `${target.ref}.py !== ${expected.py} (${py})`); + } + + before(() => { + const arabicLabel = "أظهر المزيد"; + class TestApp extends lng.Application { + static _template() { + return { + Rect1: { + rect: true, + w: 600, + h: 200, + color: 0xff0000ff, + Rect2: { + rect: true, + x: 100, + y: 10, + w: 500, + h: 180, + color: 0xffff0000, + Label: { + x: 20, + y: 50, + text: { + text: `RTL Lightning\n${arabicLabel}`, + }, + }, + }, + }, + Rect3: { + rect: true, + x: 300, + w: 600, + h: 200, + y: 300, + color: 0xff660066, + Scroller: { + rect: true, + h: 180, + w: 1080, + y: 10, + color: 0x99999999, + }, + }, + Rect4: { + rtl: false, + rect: true, + x: 700, + w: 400, + h: 200, + color: 0xff00ffff, + NonRtlLabel: { + x: 20, + y: 50, + text: { + text: 'Always LTR', + }, + } + }, + Flexed: { + w: 1280, + y: 600, + flex: {}, + Button0: { + type: Button, + label: "One", + }, + Button1: { + type: Button, + label: "Two (2)", + }, + Button2: { + type: Button, + label: "Third one's the charm", + }, + }, + }; + } + + _init() { + this.createScrollerItems('1'); + } + + createScrollerItems(id) { + const items = []; + for (let i = 0; i < 6; i++) { + items.push({ + ref: `Item${i}-${id}`, + rect: true, + x: i * 180, + w: 160, + h: 160, + y: 10, + color: 0x800000ff, + Label: { + x: 80, + y: 80, + mountX: 0.5, + mountY: 0.5, + text: { + text: `#${i}`, + fontFace: "Arial", + fontSize: 40, + textColor: 0xff000000, + }, + }, + }); + } + const scroller = this.tag("Scroller"); + scroller.children = items; + } + + scrollTo(index) { + const scroller = this.tag("Scroller"); + if (this.prevIndex) { + scroller.children[this.prevIndex].scale = 1; + scroller.children[this.prevIndex].tag("Label").scale = 1; + } + this.prevIndex = index; + scroller.x = -180 * index; + scroller.children[index].scale = 1.5; + scroller.children[index].tag("Label").scale = 1.5; + } + } + + app = new TestApp(); + stage = app.stage; + document.body.appendChild(stage.getCanvas()); + }); + + describe('RTL off', function () { + before(() => { + app.stage.drawFrame(); + }); + + it('Should default to LTR and propagate flag', function () { + chai.assert(app.rtl === false); + chai.assert(app.tag('Scroller.Item0-1').rtl === false); + }); + + it('Should not trigger direction updates', function () { + chai.assert(app.tag('Flexed.Button0').directionUpdatesCount === 0); + }); + + it('Should layout Label correctly', function () { + const rect2 = app.tag('Rect2'); + const label = app.tag('Label'); + + assertCoordinates(label, { + px: rect2.x + label.x, + py: rect2.y + label.y + }); + }); + + it('Should layout flex items correctly', function () { + const flexed = app.tag('Flexed'); + const b0 = app.tag('Button0'); + const b1 = app.tag('Button1'); + const b2 = app.tag('Button2'); + const py = flexed.y; + const spacing = b0.flexItem.marginRight; + + assertCoordinates(b0, { + px: 0, + py + }); + assertCoordinates(b1, { + px: b0.finalW + spacing, + py + }); + assertCoordinates(b2, { + px: b0.finalW + spacing + b1.finalW + spacing, + py + }); + }); + + it('Should layout the button label correctly', function () {const flexed = app.tag('Flexed'); + const b0 = app.tag('Button0'); + const b0Label = b0.tag('Label'); + const padding = b0.flex.padding; + const py = flexed.y + padding; + + assertCoordinates(b0Label, { + px: padding, + py + }); + }); + }); + + describe('RTL on', function () { + before(() => { + app.rtl = true; + app.stage.drawFrame(); + }); + + it('Should propagate flag', function () { + chai.assert(app.tag('Label').rtl === true); + chai.assert(app.tag('Scroller.Item0-1').rtl === true); + }); + + it('Should trigger direction updates', function () { + chai.assert(app.tag('Flexed.Button0').directionUpdatesCount === 1); + }); + + it('Should apply flag to new children', function () { + app.createScrollerItems('2'); + chai.assert(app.tag('Scroller.Item0-2').rtl === true); + }); + + it('Should layout Label correctly', function () { + const rect1 = app.tag('Rect1'); + const rect2 = app.tag('Rect2'); + const label = app.tag('Label'); + + assertCoordinates(label, { + px: rect1.finalW - rect2.x - label.x - label.finalW, + py: rect2.y + label.y + }); + }); + + it('Should layout flex items correctly', function () { + const flexed = app.tag('Flexed'); + const b0 = app.tag('Button0'); + const b1 = app.tag('Button1'); + const b2 = app.tag('Button2'); + const py = flexed.y; + const spacing = b0.flexItem.marginRight; + + assertCoordinates(b0, { + px: flexed.w - b0.finalW, + py + }); + assertCoordinates(b1, { + px: flexed.w - b0.finalW - spacing - b1.finalW, + py + }); + assertCoordinates(b2, { + px: flexed.w - b0.finalW - spacing - b1.finalW - spacing - b2.finalW, + py + }); + }); + + it('Should layout the button label correctly', function () { + const flexed = app.tag('Flexed'); + const b0 = app.tag('Button0'); + const b0Label = b0.tag('Label'); + const padding = b0.flex.padding; + const py = flexed.y + padding; + + assertCoordinates(b0Label, { + px: flexed.w - padding - b0Label.finalW, + py + }); + }); + + it('Should handle scale correctly', () => { + const rect3 = app.tag('Rect3'); + const scroller = app.tag('Scroller'); + assertCoordinates(scroller, { + px: rect3.x + rect3.w - scroller.w, + py: rect3.y + scroller.y + }); + + app.scrollTo(1); + stage.drawFrame(); + app.scrollTo(2); + stage.drawFrame(); + + const item2 = scroller.children[2]; + assertCoordinates(item2, { + px: rect3.x + rect3.w - item2.w / 2 - item2.w * 1.5 / 2, + py: rect3.y + scroller.y + item2.y + item2.h / 2 - item2.h * 1.5 / 2 + }); + }); + + it('Should not mirror elements with rtl=false', () => { + const rect4 = app.tag('Rect4'); + const nonRtl = app.tag('NonRtlLabel'); + assertCoordinates(nonRtl, { + px: rect4.x + nonRtl.x, + py: rect4.y + nonRtl.y + }); + }) + }); +}); diff --git a/tests/test.html b/tests/test.html index 1493036d..c93e45d7 100644 --- a/tests/test.html +++ b/tests/test.html @@ -53,6 +53,8 @@ + + + + + + + + diff --git a/tests/text-rendering.spec.ts b/tests/text-rendering.spec.ts new file mode 100644 index 00000000..f84a6696 --- /dev/null +++ b/tests/text-rendering.spec.ts @@ -0,0 +1,175 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2020 Metrological + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { test, expect, type Page } from "@playwright/test"; +import looksSame from "looks-same"; + +const FIRST_TEST = 1; +const TEST_STYLED = 3; +const TEST_BIDI = 4; +const TEST_HEBREW = 7 +const TEST_ARABIC = 10; +const LAST_TEST = 11; + +async function compareWrapping(page: Page, width: number) { + page.setDefaultTimeout(2000); + await page.setViewportSize({ width, height: 4000 }); + await page.goto("/tests/text-rendering.html?playwright"); + + for (let i = FIRST_TEST; i < TEST_HEBREW; i++) { + await page + .locator(`#preview${i}`) + .screenshot({ path: `temp/wrap-${width}-test${i}-html.png` }); + await page + .locator(`#canvas${i}`) + .screenshot({ path: `temp/wrap-${width}-test${i}-canvas.png` }); + + const { equal, diffImage, differentPixels } = await looksSame( + `temp/wrap-${width}-test${i}-html.png`, + `temp/wrap-${width}-test${i}-canvas.png`, + { + createDiffImage: true, + strict: false, + } + ); + diffImage?.save(`temp/wrap-${width}-diff${i}.png`); + + if (differentPixels) { + console.log( + `Test ${i} - ${width}px - different pixels: ${differentPixels}` + ); + } + const maxDiff = i >= TEST_BIDI ? 150 : 50; // Arabic needs more tolerance + expect( + equal || differentPixels < maxDiff, + `[Test ${i}] HTML and canvas rendering do not match (${differentPixels} pixels differ)` + ).toBe(true); + } +} + +async function compareLetterSpacing(page: Page, width: number) { + page.setDefaultTimeout(2000); + await page.setViewportSize({ width, height: 4000 }); + await page.goto("/tests/text-rendering.html?playwright&letterSpacing=5"); + + for (let i = FIRST_TEST; i < TEST_STYLED; i++) { + await page + .locator(`#preview${i}`) + .screenshot({ path: `temp/spacing-${width}-test${i}-html.png` }); + await page + .locator(`#canvas${i}`) + .screenshot({ path: `temp/spacing-${width}-test${i}-canvas.png` }); + + const { equal, diffImage, differentPixels } = await looksSame( + `temp/spacing-${width}-test${i}-html.png`, + `temp/spacing-${width}-test${i}-canvas.png`, + { + createDiffImage: true, + strict: false, + } + ); + diffImage?.save(`temp/spacing-${width}-diff${i}.png`); + + if (differentPixels) { + console.log( + `Test ${i} - ${width}px - different pixels: ${differentPixels}` + ); + } + expect( + equal || differentPixels < 50, + `[Test ${i}] HTML and canvas rendering do not match (${differentPixels} pixels differ)` + ).toBe(true); + } +} + +async function comparePunctuation(page: Page, width: number) { + await page.setViewportSize({ width, height: 4000 }); + await page.goto("/tests/text-rendering.html?playwright"); + + for (let i = TEST_HEBREW; i <= LAST_TEST; i++) { + await page + .locator(`#preview${i}`) + .screenshot({ path: `temp/punctuation-${width}-test${i}-html.png` }); + await page + .locator(`#canvas${i}`) + .screenshot({ path: `temp/punctuation-${width}-test${i}-canvas.png` }); + + const { equal, diffImage, differentPixels } = await looksSame( + `temp/punctuation-${width}-test${i}-html.png`, + `temp/punctuation-${width}-test${i}-canvas.png`, + { + createDiffImage: true, + strict: false, + } + ); + diffImage?.save(`temp/punctuation-${width}-diff${i}.png`); + + if (differentPixels) { + console.log( + `Test ${i} - ${width}px - different pixels: ${differentPixels}` + ); + } + const maxDiff = i >= TEST_ARABIC ? 350 : 150; // Arabic needs more tolerance + expect( + equal || differentPixels < maxDiff, + `[Test ${i}] HTML and canvas rendering do not match (${differentPixels} pixels differ)` + ).toBe(true); + } +} + +/* +* The following tests compare HTML and LightningJS canvas rendering of text. +* +* The tests compare the two renderings and check if they match within a certain tolerance. +* More tolerance is given to Arabic due to the amount of details in the script. +* +* The tests are run at different viewport widths and letter spacings. +* +* Note: we don't expect that HTML and canvas rendering will always match exactly, especially +* when it comes to wrapping and ellipsis logic. The viewport widths have been chosen where +* wrapping and ellipsis matched the best. +*/ + +test("no wrap", async ({ page }) => { + await compareWrapping(page, 1000); +}); + +test("wrap 1", async ({ page }) => { + await compareWrapping(page, 800); +}); + +test("wrap 2", async ({ page }) => { + await compareWrapping(page, 640); +}); + +test("wrap 3", async ({ page }) => { + await compareWrapping(page, 520); +}); + +test("letter spacing 1", async ({ page }) => { + await compareLetterSpacing(page, 1000); +}); + +test("letter spacing 2", async ({ page }) => { + await compareLetterSpacing(page, 550); +}); + +test("punctuation", async ({ page }) => { + await comparePunctuation(page, 930); +}); diff --git a/tests/text-rendering/index.js b/tests/text-rendering/index.js new file mode 100644 index 00000000..6bb6301b --- /dev/null +++ b/tests/text-rendering/index.js @@ -0,0 +1,360 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2020 Metrological + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import TextTextureRendererAdvanced from "../../dist/src/textures/TextTextureRendererAdvanced.js"; +import TextTextureRenderer from "../../dist/src/textures/TextTextureRenderer.js"; +import TextTokenizer from "../../dist/src/textures/TextTokenizer.js"; + +let testN = 0; +let stopRendering = false; +let letterSpacing = 0; +if (location.search.indexOf("letterSpacing") > 0) { + const match = location.search.match(/letterSpacing=(\d+)/); + if (match) { + letterSpacing = parseInt(match[1], 10); + } +} + +const root = document.createElement("div"); +root.id = "root"; +document.body.appendChild(root); +let renderWidth = window.innerWidth - 16; + +function demo() { + // const t0 = performance.now(); + testN = 0; + stopRendering = false; + root.innerHTML = ""; + root.style.width = renderWidth + "px"; + root.className = `spacing-${letterSpacing}`; + + TextTokenizer.setCustomTokenizer(); + + // basic renderer + renderText( + TextTextureRenderer, + "First line\nAnd a second line of some rather long text", + "left" + ); + renderText( + TextTextureRenderer, + "One first line of some rather long text.\nAnd another quite long line; maybe longer!", + "left" + ); + + // advanced renderer + + renderText( + TextTextureRendererAdvanced, + "First line\nAnd a second line of some rather long text", + "left" + ); + + // Bidi + + // `bidiTokenizer.es5.js` attaches declarations to global `lng` object + TextTokenizer.setCustomTokenizer(lng.getBidiTokenizer()); + + renderText( + TextTextureRendererAdvanced, + "Something with arabic (that: أسباب لمشاهدة) in it.", + "left" + ); + renderText( + TextTextureRendererAdvanced, + "خمسة أسباب ①لمشاهدة عرض ONE Fight② Night 21", + "right" + ); + renderText( + TextTextureRendererAdvanced, + 'أكبر الرابحين من عرض ONE Fight Night 21 من بطولة "ون"', + "right" + ); + + // Punctuation + + renderText( + TextTextureRendererAdvanced, + "הקש כדי להוסיף סרטים, תוכניות ועוד לרשימה שלי. לאחר מכן תוכל לצפות בהם מכאן בכל המכשירים שלך.", + "right" + ); + + renderText( + TextTextureRendererAdvanced, + "הגיע הזמן לעדכן את אפליקציית MyApp ולקבל את התכונות החדשות ביותר (והטובות ביותר!). סמוך עלינו - אתה תאהב אותן.", + "right" + ); + + renderText( + TextTextureRendererAdvanced, + "סרוק את קוד ה-QR באמצעות מצלמת הטלפון או הטאבלט שלך.", + "right" + ); + + renderText( + TextTextureRendererAdvanced, + "وسار فان دايك (33 عاما) الذي أصبح ركيزة أساسية في صفوف ليفربول منذ انضمامه عام 2018، على خطى المهاجم الدولي المصري محمد صلاح الذي مدد عقده قبل ستة أيام لعامين أيضا، منهيا أشهرا من التكهنات من خلال تمديد بقائه في أنفيلد؟", + "right", + 4 + ); + + renderText( + TextTextureRendererAdvanced, + 'أيضًا، نُدرج الأرقام ١٢٣٤٥٦٧٨٩٠، ورابط إلكتروني: user@example.com، مع علامات ترقيم؟!، ونصوص مختلطة الاتجاه مثل: "Hello, مرحبًا".', + "right", + 4 + ); + + // console.log("done in", performance.now() - t0, "ms"); +} + +let timer = 0; +window.addEventListener("resize", () => { + if (timer) return; + window.clearTimeout(timer); + timer = window.setTimeout(() => { + timer = 0; + renderWidth = window.innerWidth - 16; + demo(); + }, 10); +}); + +async function renderText( + Renderer /*typeof TextTextureRenderer*/, + source /*string*/, + textAlign /*"left" | "right" | "center"*/, + maxLines /*number*/ = 2, + textOverflow /*string*/ = "" +) { + if (stopRendering) return; + testN++; + + // re-add tags + let text = source.replace(/①/g, "").replace(/②/g, ""); + + const testCase = document.createElement("div"); + testCase.id = `test${testN}`; + root.appendChild(testCase); + + const title = document.createElement("h2"); + title.innerText = `Test ${testN}`; + testCase.appendChild(title); + + // PREVIEW + + const hintHtml = document.createElement("div"); + hintHtml.className = "hint-html"; + hintHtml.innerText = "html"; + testCase.appendChild(hintHtml); + + const previewText = text + .replace(/\n/g, "
") + .replace("", '') + .replace("", ""); + const preview = document.createElement("p"); + preview.id = `preview${testN}`; + preview.className = `lines-${maxLines}`; + preview.dir = "auto"; + testCase.appendChild(preview); + preview.innerHTML = previewText; + preview.style.height = maxLines * 50 + "px"; + + // CANVAS + + const hintCanvas = document.createElement("div"); + hintCanvas.className = "hint-canvas"; + hintCanvas.innerText = "canvas"; + testCase.appendChild(hintCanvas); + + const wrapper = document.createElement("div"); + wrapper.style.textAlign = textAlign; + testCase.appendChild(wrapper); + + const canvas = document.createElement("canvas"); + canvas.id = `canvas${testN}`; + canvas.width = renderWidth; + canvas.height = maxLines * 50; + wrapper.appendChild(canvas); + + const wordWrapWidth = canvas.width; + + // OPTIONS + + const options = { + w: 1920, + h: 1080, + textRenderIssueMargin: 0, + defaultFontFace: "Arial", + }; + + const stage = { + getRenderPrecision() { + return 1; + }, + getOption(name) { + return options[name]; + }, + }; + + const settings = { + ...getDefaultSettings(), + rtl: textAlign === "right", + text, + // w: wordWrapWidth, + wordWrapWidth, + textOverflow, + maxLines, + advancedRenderer: text.indexOf(" 0, + }; + + try { + const drawCanvas = document.createElement("canvas"); + const renderer = new Renderer(stage, drawCanvas, settings); + await renderer._load(); + renderer._draw(); + + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "white"; + const dx = textAlign === "right" ? canvas.width - drawCanvas.width : 0; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(drawCanvas, dx, -2); // adjust for HTML rendering + + if (location.search.indexOf("playwright") < 0) { + ctx.strokeStyle = "red"; + ctx.rect( + dx + 0.5, + 0.5, + drawCanvas.width - 1, + Math.min(drawCanvas.height, canvas.height) - 1 + ); + ctx.stroke(); + } + } catch (error) { + console.error(error); + } +} + +renderText.only = function ( + Renderer /*typeof TextTextureRenderer*/, + source /*string*/, + textAlign /*"left" | "right" | "center"*/, + maxLines /*number = 2*/, + textOverflow /*string = ""*/ +) { + root.innerHTML = ""; + renderText(Renderer, source, textAlign, maxLines, textOverflow); + stopRendering = true; +}; + +function getDefaultSettings() { + return { + rtl: false, + advancedRenderer: false, + textColor: 0xff000000, + textBaseline: "alphabetic", + verticalAlign: "top", + fontFace: null, + fontStyle: "", + fontSize: 40, + lineHeight: 48, + wordWrap: true, + letterSpacing, + textAlign: "left", + textIndent: 40, + textOverflow: "", + maxLines: 2, + maxLinesSuffix: "…", + paddingLeft: 0, + paddingRight: 0, + offsetY: null, + cutSx: 0, + cutSy: 0, + cutEx: 0, + cutEy: 0, + w: 0, + h: 0, + highlight: false, + highlightColor: 0, + highlightHeight: 0, + highlightOffset: 0, + highlightPaddingLeft: 0, + highlightPaddingRight: 0, + shadow: false, + shadowColor: 0, + shadowHeight: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, + shadowBlur: 0, + }; +} + +// SCROLLING + +let scrollN = 1; +if (location.hash.length) { + const match = location.hash.match(/#test(\d+)/); + if (match) { + scrollN = parseInt(match[1], 10); + } +} + +document.addEventListener("keydown", (e) => { + let reRender = false; + if (e.key === "ArrowLeft") { + if (renderWidth > 200) renderWidth -= 100; + reRender = true; + } else if (e.key === "ArrowRight") { + if (renderWidth < 1820) renderWidth += 100; + reRender = true; + } else if (["0", "1", "2", "3", "4", "5"].includes(e.key)) { + letterSpacing = parseInt(e.key, 10); + reRender = true; + } + + if (reRender) { + e.preventDefault(); + demo(); + return; + } + + if (e.key === "ArrowDown") { + if (scrollN < testN) scrollN++; + } else if (e.key === "ArrowUp") { + if (scrollN > 1) scrollN--; + } + location.hash = `#test${scrollN}`; + + e.preventDefault(); +}); + +let lastWheel = 0; +document.addEventListener("wheel", (e) => { + const now = Date.now(); + if (now - lastWheel < 300) return; + lastWheel = now; + + if (e.deltaY > 0) { + if (scrollN < testN) scrollN++; + } else { + if (scrollN > 1) scrollN--; + } + location.hash = `#test${scrollN}`; +}); + +demo(); diff --git a/tests/textures/test.text.js b/tests/textures/test.text.js index a37adf7b..e2bd1a47 100644 --- a/tests/textures/test.text.js +++ b/tests/textures/test.text.js @@ -29,6 +29,12 @@ consequat, purus sapien ultricies dolor, et mollis pede metus eget nisi. Praesen sodales velit quis augue. Cras suscipit, urna at aliquam rhoncus, urna quam viverra \ nisi, in interdum massa nibh nec erat.'; +// With advanced renderer, `renderInfo` lines are objects with `words` array +function getLineText(info) { + if (info.words) return info.words.map(w => w.text).join(''); + return info.text; +} + describe('text', function() { this.timeout(0); @@ -100,7 +106,7 @@ describe('text', function() { const texture = app.tag("Item").texture; stage.drawFrame(); chai.assert(texture.source.renderInfo.lines.length > 1); - chai.assert(texture.source.renderInfo.lines.slice(-1)[0].substr(-5) == 'erat.'); + chai.assert(getLineText(texture.source.renderInfo.lines.slice(-1)[0]).substr(-5) == 'erat.'); }); it('wrap paragraph [maxLines=10]', function() { @@ -119,7 +125,7 @@ describe('text', function() { const texture = app.tag("Item").texture; stage.drawFrame(); chai.assert(texture.source.renderInfo.lines.length === 10); - chai.assert(texture.source.renderInfo.lines.slice(-1)[0].substr(-6) == 'eget..'); + chai.assert(getLineText(texture.source.renderInfo.lines.slice(-1)[0]).substr(-5) == 'neq..'); }); }); @@ -141,7 +147,7 @@ describe('text', function() { const texture = app.tag("Item").texture; stage.drawFrame(); chai.assert(texture.source.renderInfo.lines.length === 1); - chai.assert(texture.source.renderInfo.lines[0].substr(-5) == 'erat.'); + chai.assert(getLineText(texture.source.renderInfo.lines[0]).substr(-5) == 'erat.'); }); it('should ignore textOverflow when wordWrap is enabled (by default)', function() { @@ -161,7 +167,7 @@ describe('text', function() { const texture = app.tag("Item").texture; stage.drawFrame(); chai.assert(texture.source.renderInfo.lines.length === 5); - chai.assert(texture.source.renderInfo.lines.slice(-1)[0].substr(-2) == '..'); + chai.assert(getLineText(texture.source.renderInfo.lines.slice(-1)[0]).substr(-2) == '..'); }); [ @@ -195,7 +201,7 @@ describe('text', function() { chai.assert(texture.source.renderInfo.lines.length === 1); chai.assert(texture.source.renderInfo.w < WRAP_WIDTH); chai.assert(texture.source.renderInfo.w > 0); - chai.assert(texture.source.renderInfo.lines[0].substr(-2) == '..'); + chai.assert(getLineText(texture.source.renderInfo.lines[0]).substr(-2) == '..'); }); }); @@ -225,7 +231,7 @@ describe('text', function() { chai.assert(texture.source.renderInfo.w < WRAP_WIDTH); chai.assert(texture.source.renderInfo.w > 0); if (t.suffix !== null) { - chai.assert(texture.source.renderInfo.lines[0].substr(-t.suffix.length) == t.suffix); + chai.assert(getLineText(texture.source.renderInfo.lines[0]).substr(-t.suffix.length) == t.suffix); } }); @@ -256,7 +262,7 @@ describe('text', function() { chai.assert(texture.source.renderInfo.lines.length === 1); chai.assert(texture.source.renderInfo.w < WRAP_WIDTH); chai.assert(texture.source.renderInfo.w > 0); - chai.assert(texture.source.renderInfo.lines[0].substr(-5) == 'Hello'); + chai.assert(getLineText(texture.source.renderInfo.lines[0]).substr(-5) == 'Hello'); }); it(`should work with empty strings [overflow=${t.textOverflow}]`, function() { diff --git a/vite.config.js b/vite.config.js index c55a2cec..7a8f0fe3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -11,6 +11,7 @@ import { fixTsImportsFromJs } from './fixTsImportsFromJs.vite-plugin'; const isEs5Build = process.env.BUILD_ES5 === 'true'; const isMinifiedBuild = process.env.BUILD_MINIFY === 'true'; const isInspectorBuild = process.env.BUILD_INSPECTOR === 'true'; +const isBidiTokenizerBuild = process.env.BUILD_BIDI_TOKENIZER === 'true'; let outDir = 'dist'; let entry = resolve(__dirname, 'src/index.ts'); @@ -26,6 +27,14 @@ if (isInspectorBuild) { useDts = false; } +if (isBidiTokenizerBuild) { + outDir = 'dist'; + entry = resolve(__dirname, 'src/textures/bidiTokenizer.ts'); + outputBase = 'bidiTokenizer'; + sourcemap = true; + useDts = true; +} + export default defineConfig(() => { return { plugins: [ From 19caa4ac4a0ded7b3034da329e105a7260d1740d Mon Sep 17 00:00:00 2001 From: Philippe Elsass Date: Wed, 7 May 2025 17:27:46 +0200 Subject: [PATCH 08/32] Improvements - allow disabling truncation (Arabic) - WIP wordBreak (normal renderer) - WIP unit tests --- src/textures/TextTexture.d.mts | 1 + src/textures/TextTexture.mjs | 1 + src/textures/TextTextureRenderer.ts | 4 +- src/textures/TextTextureRendererAdvanced.ts | 5 +- .../TextTextureRendererAdvancedUtils.test.ts | 280 ++++++++++++++++++ .../TextTextureRendererAdvancedUtils.ts | 118 ++++++-- src/textures/TextTextureRendererUtils.test.ts | 110 +++++++ src/textures/TextTextureRendererUtils.ts | 78 ++++- src/textures/bidiTokenizer.ts | 12 +- tests/text-rendering.spec.ts | 66 +++-- tests/text-rendering/index.js | 85 +++--- vite.config.js | 1 + 12 files changed, 651 insertions(+), 110 deletions(-) create mode 100644 src/textures/TextTextureRendererAdvancedUtils.test.ts create mode 100644 src/textures/TextTextureRendererUtils.test.ts diff --git a/src/textures/TextTexture.d.mts b/src/textures/TextTexture.d.mts index 4aa72b95..d7ccd7e5 100644 --- a/src/textures/TextTexture.d.mts +++ b/src/textures/TextTexture.d.mts @@ -462,6 +462,7 @@ declare class TextTexture extends Texture implements Required ({ width: text.length * 10 })), + fillText: vi.fn(), + font: "", + fillStyle: "", +}; + +// Test extractTags +describe("extractTags", () => { + it("should extract tags and replace them with direction-weak characters", () => { + const input = "Hello World"; + const { tags, output } = extractTags(input); + expect(tags).toEqual([ + "", + "", + "", + "", + "", + "", + ]); + expect(output).toBe( + "\u200B\u2462\u200BHello\u200B\u2463\u200B \u200B\u2465\u200BWorld\u200B\u2464\u200B" + ); + }); +}); + +// Test createLineStyle +describe("createLineStyle", () => { + let lineStyle: ReturnType; + + beforeAll(() => { + lineStyle = createLineStyle( + [ + "", + "", + "", + "", + "", + "", + "", + ], + "Arial", + 0xffff0000 + ); + }); + + it('should report if styling is enabled', () => { + expect(lineStyle.isStyled).toBe(true); + + const unstyled = createLineStyle([], "Arial", 0xffff0000); + expect(unstyled.isStyled).toBe(false); + }); + + it("should provide a default style", () => { + expect(lineStyle.baseStyle.font).toBe("Arial"); + expect(lineStyle.baseStyle.color).toBe("rgba(255,0,0,1.0000)"); + }); + + it("should allow setting bold style", () => { + lineStyle.updateStyle(0x2460 + 2); // + expect(lineStyle.getStyle().font).toBe("bold Arial"); + + lineStyle.updateStyle(0x2460 + 2); // + expect(lineStyle.getStyle().font).toBe("bold Arial"); + + lineStyle.updateStyle(0x2460 + 3); // - but we are still inside a tag + expect(lineStyle.getStyle().font).toBe("bold Arial"); + + lineStyle.updateStyle(0x2460 + 3); // + expect(lineStyle.getStyle().font).toBe("Arial"); + }); + + it("should allow setting italic style", () => { + lineStyle.updateStyle(0x2460 + 0); // + expect(lineStyle.getStyle().font).toBe("italic Arial"); + + lineStyle.updateStyle(0x2460 + 0); // + expect(lineStyle.getStyle().font).toBe("italic Arial"); + + lineStyle.updateStyle(0x2460 + 1); // - but we are still inside a tag + expect(lineStyle.getStyle().font).toBe("italic Arial"); + + lineStyle.updateStyle(0x2460 + 1); // + expect(lineStyle.getStyle().font).toBe("Arial"); + }); + + it("should allow setting both italic and bold styles", () => { + lineStyle.updateStyle(0x2460 + 0); // + expect(lineStyle.getStyle().font).toBe("italic Arial"); + + lineStyle.updateStyle(0x2460 + 2); // + expect(lineStyle.getStyle().font).toBe("bold italic Arial"); + + lineStyle.updateStyle(0x2460 + 1); // + expect(lineStyle.getStyle().font).toBe("bold Arial"); + + lineStyle.updateStyle(0x2460 + 3); // + expect(lineStyle.getStyle().font).toBe("Arial"); + }); + + it("should allow setting color", () => { + lineStyle.updateStyle(0x2460 + 5); // + expect(lineStyle.getStyle().color).toBe("rgba(204,102,0,1.0000)"); + + lineStyle.updateStyle(0x2460 + 6); // + expect(lineStyle.getStyle().color).toBe("rgba(0,102,204,0.5020)"); + + lineStyle.updateStyle(0x2460 + 4); // + expect(lineStyle.getStyle().color).toBe("rgba(204,102,0,1.0000)"); + + lineStyle.updateStyle(0x2460 + 4); // + expect(lineStyle.getStyle().color).toBe("rgba(255,0,0,1.0000)"); + }); +}); + +// Test layoutSpans +describe("layoutSpans", () => { + it("should layout spans into lines", () => { + const spans = [{ tokens: ["Hello", " ", "World"] }]; + const lineStyle = createLineStyle([], "Arial", 0xffff0000); + const lines = layoutSpans( + mockCtx as unknown as CanvasRenderingContext2D, + spans, + lineStyle, + 200, + 0, + 1, + "..." + ); + expect(lines.length).toBe(1); + expect(lines[0]!.words[0]!.style).toBeUndefined(); + expect(lines[0]!.words[0]!.text).toBe("Hello"); + expect(lines[0]!.words[1]!.text).toBe(" "); + expect(lines[0]!.words[2]!.text).toBe("World"); + expect(lines[0]!.words.length).toBe(3); + expect(lines[0]!.width).toBe(110); + }); + + it("should layout spans into lines with styling", () => { + const spans = [{ tokens: ["\u2460", "Hello", "\u2461", " ", "World"] }]; + const lineStyle = createLineStyle(["", ""], "Arial", 0xffff0000); + const lines = layoutSpans( + mockCtx as unknown as CanvasRenderingContext2D, + spans, + lineStyle, + 200, + 0, + 1, + "..." + ); + expect(lines.length).toBe(1); + + expect(lines[0]!.words[0]!.style).toMatchObject({ + font: "italic Arial", + color: "rgba(255,0,0,1.0000)", + }); + expect(lines[0]!.words[0]!.text).toBe("Hello"); + + expect(lines[0]!.words[1]!.style).toMatchObject({ + font: "Arial", + color: "rgba(255,0,0,1.0000)", + }); + expect(lines[0]!.words[1]!.text).toBe(" "); + + expect(lines[0]!.words[2]!.text).toBe("World"); + expect(lines[0]!.words.length).toBe(3); + expect(lines[0]!.width).toBe(110); + }); +}); + +// Test trimWordEnd +describe("trimWordEnd", () => { + it("should trim the end of a word", () => { + let result = trimWordEnd("Hello", false); + expect(result).toBe("Hell"); + result = trimWordEnd(result, false); + expect(result).toBe("Hel"); + result = trimWordEnd(result, false); + expect(result).toBe("He"); + result = trimWordEnd(result, false); + expect(result).toBe("H"); + result = trimWordEnd(result, false); + expect(result).toBe(""); + result = trimWordEnd(result, false); + expect(result).toBe(""); + }); + + it("should trim the end of a RTL word", () => { + let result = trimWordEnd(".(!ביותר", true); + expect(result).toBe("(!ביותר"); + result = trimWordEnd(result, true); + expect(result).toBe("!ביותר"); + result = trimWordEnd(result, true); + expect(result).toBe("ביותר"); + result = trimWordEnd(result, true); + expect(result).toBe("ביות"); + result = trimWordEnd(result, true); + expect(result).toBe("ביו"); + result = trimWordEnd(result, true); + expect(result).toBe("בי"); + result = trimWordEnd(result, true); + expect(result).toBe("ב"); + result = trimWordEnd(result, true); + expect(result).toBe(""); + result = trimWordEnd(result, true); + expect(result).toBe(""); + }); +}); + +// Test trimWordStart +describe("trimWordStart", () => { + it("should trim the start of a word", () => { + let result = trimWordStart("Hello", false); + expect(result).toBe("ello"); + result = trimWordStart(result, false); + expect(result).toBe("llo"); + result = trimWordStart(result, false); + expect(result).toBe("lo"); + result = trimWordStart(result, false); + expect(result).toBe("o"); + result = trimWordStart(result, false); + expect(result).toBe(""); + result = trimWordStart(result, false); + }); + + it("should trim the start of a RTL word", () => { + let result = trimWordStart('("Hello', true); + expect(result).toBe('("ello'); + result = trimWordStart(result, true); + expect(result).toBe('("llo'); + result = trimWordStart(result, true); + expect(result).toBe('("lo'); + result = trimWordStart(result, true); + expect(result).toBe('("o'); + result = trimWordStart(result, true); + expect(result).toBe('("'); + result = trimWordStart(result, true); + expect(result).toBe('"'); + result = trimWordStart(result, true); + expect(result).toBe(""); + result = trimWordStart(result, true); + expect(result).toBe(""); + }); +}); + +// Test renderLines +describe("renderLines", () => { + it("should render lines of text", () => { + const lines = [ + { + rtl: false, + width: 50, + text: "", + words: [{ text: "Hello", width: 50, style: undefined, rtl: false }], + }, + ]; + const lineStyle = createLineStyle([], "Arial", 0xff0000); + renderLines( + mockCtx as unknown as CanvasRenderingContext2D, + lines, + lineStyle, + "left", + 20, + 100, + 0 + ); + // expect(mockCtx.fillText).toHaveBeenCalledWith('Hello', 0, 10); + }); +}); diff --git a/src/textures/TextTextureRendererAdvancedUtils.ts b/src/textures/TextTextureRendererAdvancedUtils.ts index 090bc887..bb92fe36 100644 --- a/src/textures/TextTextureRendererAdvancedUtils.ts +++ b/src/textures/TextTextureRendererAdvancedUtils.ts @@ -137,7 +137,8 @@ export function layoutSpans( wrapWidth: number, textIndent: number, maxLines: number, - suffix: string + suffix: string, + allowTruncation = false ): LineLayout[] { // styling const { isStyled, baseStyle, updateStyle, getStyle } = lineStyle; @@ -219,6 +220,7 @@ export function layoutSpans( endReached = true; break; } + // else TODO break word continue; } @@ -267,36 +269,41 @@ export function layoutSpans( const maxLineWidth = wrapWidth - suffixWidth; if (line.width > maxLineWidth) { - // if we have a sub-expression (suite of words) in opposite direction, - // remove the first word, to ensure we don't lose the meaningful last word + // if we have a sub-expression (suite of words) not in the primary direction (embedded RTL in LTR or vice versa), + // remove the first word of this sequence, to ensure we don't lose the meaningful last word, unless it can be truncated let lastIndex = line.words.length - 1; let word = line.words[lastIndex]!; let index = lastIndex; - let removeOppositeEnd = true; - while (word.rtl !== primaryRtl && removeOppositeEnd) { - removeOppositeEnd = false; - while (index > 0 && word.rtl !== primaryRtl) { - word = line.words[--index]!; - } - if (word.text === " ") { - word = line.words[++index]!; - } - if (index >= 0 && index !== lastIndex) { + + // TODO: this works well for English but not for embedded RTL + if (primaryRtl && !word.rtl) { + let removeOppositeEnd = true; + while (word.rtl !== primaryRtl && removeOppositeEnd) { + removeOppositeEnd = false; + // find direction change + while (index > 0 && word.rtl !== primaryRtl) { + word = line.words[--index]!; + } + ++index; + if (index < 0 || index === lastIndex) { + break; + } + // remove word + word = line.words[index]!; line.words.splice(index, 1); line.width -= word.width; + // remove extra space word = line.words[index]!; if (word.text === " ") { line.words.splice(index, 1); line.width -= word.width; } - } else { - break; + // repeat? + lastIndex = line.words.length - 1; + word = line.words[lastIndex]!; + index = lastIndex; + removeOppositeEnd = allowTruncation && word.width < suffixWidth * 2; } - // repeat? - lastIndex = line.words.length - 1; - word = line.words[lastIndex]!; - index = lastIndex; - removeOppositeEnd = word.width < suffixWidth * 2; } // shorten last word to fit ellipsis @@ -305,16 +312,16 @@ export function layoutSpans( line.width -= last.width; const maxWidth = maxLineWidth - line.width; - if (maxWidth > 0) { - let { text, width, style } = last; + if (allowTruncation && maxWidth > 0) { + let { text, width, style, rtl } = last; if (style) { ctx.font = style.font; } - const reversed = primaryRtl !== last.rtl; + const reversed = primaryRtl !== rtl; do { text = reversed - ? text.substring(1) - : text.substring(0, text.length - 1); + ? trimWordStart(text, rtl) + : trimWordEnd(text, rtl); width = ctx.measureText(text).width; } while (width > maxWidth); if (width > suffixWidth) { @@ -356,6 +363,67 @@ export function layoutSpans( return lines; } +const rePunctuationStart = /^[.,،:;!?؟()"“”«»-]+/ +const rePunctuationEnd = /[.,،:;!?؟()"“”«»-]+$/ + +export function trimWordEnd(text: string, rtl: boolean): string { + if (rtl) { + return trimRtlWordEnd(text); + } + return text.substring(0, text.length - 1); +} + +export function trimWordStart(text: string, rtl: boolean): string { + if (rtl) { + return trimRtlWordStart(text); + } + return text.substring(1); +} + +/** + * Trim RTL word end, preserving end punctuation + * @param text + * @returns + */ +function trimRtlWordEnd(text: string): string { + let match = text.match(rePunctuationStart); + if (match) { + const punctuation = match[0]; + text = text.substring(punctuation.length); + return punctuation.substring(1) + text; + } + match = text.match(rePunctuationEnd); + if (match) { + const punctuation = match[0]; + text = text.substring(0, text.length - punctuation.length); + if (text.length > 0) { + return text.substring(0, text.length - 1) + punctuation; + } else { + return punctuation.substring(1); + } + } + return text.substring(0, text.length - 1); +} + +/** + * Trim RTL word start, preserving start punctuation + * @param text + * @returns + */ +function trimRtlWordStart(text: string): string { + const match = text.match(rePunctuationStart); + if (match) { + const punctuation = match[0]; + text = text.substring(punctuation.length); + if (text.length > 0) { + return punctuation + text.substring(1); + } else { + return punctuation.substring(1); + } + } + return text.substring(1); +} + /** * Render text lines */ diff --git a/src/textures/TextTextureRendererUtils.test.ts b/src/textures/TextTextureRendererUtils.test.ts new file mode 100644 index 00000000..9da755a3 --- /dev/null +++ b/src/textures/TextTextureRendererUtils.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + getFontSetting, + wrapText, + getSuffix, + measureText, + breakWord, +} from './TextTextureRendererUtils'; + +// Mocking CanvasRenderingContext2D for testing +const mockContext = { + measureText: vi.fn((text) => ({ width: text.length * 10 })), +} as unknown as CanvasRenderingContext2D; + +describe('TextTextureRendererUtils', () => { + describe('getFontSetting', () => { + it('should return correct font setting string', () => { + const result = getFontSetting(['Arial'], 'normal', 16, 1, 'sans-serif'); + expect(result).toBe('normal 16px Arial'); + }); + + it('should return correct font setting quoted string', () => { + const result = getFontSetting(['My Font'], 'normal', 16, 1, 'sans-serif'); + expect(result).toBe('normal 16px "My Font"'); + }); + + it('should handle null fontFace and use default', () => { + const result = getFontSetting(null, 'italic', 20, 1, 'sans-serif'); + expect(result).toBe('italic 20px sans-serif'); + }); + }); + + describe('wrapText', () => { + it('should wrap text correctly within the given width', () => { + const result = wrapText(mockContext, 'This is a test', 50, 0, 0, 0, '', false); + expect(result).toEqual([ + { text: 'This ', width: 50 }, + { text: 'is a ', width: 50 }, + { text: 'test', width: 40 }, + ]); + }); + + describe('long words', () => { + it('should let words overflow without wordBreak', () => { + const result = wrapText(mockContext, 'A longword !', 30, 0, 0, 0, '', false); + expect(result).toEqual([ + { text: 'A ', width: 20 }, + { text: 'longword', width: 80 }, + { text: '!', width: 10 }, + ]); + }); + + it('should break long words with wordBreak', () => { + const result = wrapText(mockContext, 'A longword !', 30, 0, 0, 0, '', true); + expect(result).toEqual([ + { text: 'A ', width: 20 }, + { text: 'lon', width: 30 }, + { text: 'gwo', width: 30 }, + { text: 'rd ', width: 30 }, + { text: '!', width: 10 }, + ]); + }); + }); + }); + + describe('getSuffix', () => { + it('should return correct suffix for wordWrap', () => { + const result = getSuffix('...', null, true); + expect(result).toEqual({ suffix: '...', nowrap: false }); + }); + + it('should return correct suffix for textOverflow', () => { + const result = getSuffix('...', 'ellipsis', false); + expect(result).toEqual({ suffix: '...', nowrap: true }); + }); + + it('should return correct suffix for textOverflow', () => { + const result = getSuffix('...', 'ellipsis', true); + expect(result).toEqual({ suffix: '...', nowrap: false }); + }); + + it('should return correct suffix for textOverflow', () => { + const result = getSuffix('...', '???', false); + expect(result).toEqual({ suffix: '???', nowrap: true }); + }); + + it('should return correct suffix for textOverflow', () => { + const result = getSuffix('...', '???', true); + expect(result).toEqual({ suffix: '...', nowrap: false }); + }); + }); + + describe('measureText', () => { + it('should measure text width correctly', () => { + const result = measureText(mockContext, 'test', 2); + expect(result).toBe(40 + 8); // 40 for text + 8 for spacing + }); + }); + + describe('breakWord', () => { + it('should break a word into smaller parts if it exceeds max width', () => { + const result = breakWord(mockContext, 'longword', 30, 0); + expect(result).toEqual([ + { text: 'lon', width: 30 }, + { text: 'gwo', width: 30 }, + { text: 'rd', width: 20 }, + ]); + }); + }); +}); \ No newline at end of file diff --git a/src/textures/TextTextureRendererUtils.ts b/src/textures/TextTextureRendererUtils.ts index bef688e6..bc742f93 100644 --- a/src/textures/TextTextureRendererUtils.ts +++ b/src/textures/TextTextureRendererUtils.ts @@ -17,7 +17,11 @@ * limitations under the License. */ -import type { ILineInfo, ISuffixInfo } from "./TextTextureRendererTypes.js"; +import type { + ILineInfo, + ILineWord, + ISuffixInfo, +} from "./TextTextureRendererTypes.js"; import TextTokenizer from "./TextTokenizer.js"; /** @@ -71,7 +75,8 @@ export function wrapText( letterSpacing: number, textIndent: number, maxLines: number, - suffix: string + suffix: string, + wordBreak: boolean ): ILineInfo[] { // Greedy wrapping algorithm that will wrap words as the line grows longer. // than its horizontal bounds. @@ -89,6 +94,7 @@ export function wrapText( word = words[j]!; wordWidth = word === " " ? spaceWidth : measureText(context, word, letterSpacing); + if (wordWidth > spaceLeft) { // early stop? if (maxLines > 0 && resultLines.length >= maxLines - 1) { @@ -97,8 +103,8 @@ export function wrapText( overflow = true; break; } - // Skip printing the newline if it's the first word of the line that is. - // greater than the word wrap width. + + // commit line if (j > 0 && result.length > 0) { resultLines.push({ text: result, @@ -106,8 +112,25 @@ export function wrapText( }); result = ""; } + + // move word to next line, but drop a trailing space if (j > 0 && word === " ") wordWidth = 0; - else result += word; + else result = word; + + // if word is too long, break it + if (wordBreak && wordWidth > wordWrapWidth) { + const broken = breakWord(context, word, wordWrapWidth, letterSpacing); + const last = broken.pop()!; + for (const k of broken) { + resultLines.push({ + text: k.text, + width: k.width, + }); + } + result = last.text; + wordWidth = last.width; + } + totalWidth = wordWidth; spaceLeft = wordWrapWidth - wordWidth; } else { @@ -160,7 +183,7 @@ export function getSuffix( nowrap: false, }; } - + if (!textOverflow) { return { suffix: "", @@ -202,3 +225,46 @@ export function measureText( const { width } = context.measureText(word); return space > 0 ? width + word.length * space : width; } + +/** + * Break a word into smaller parts if it exceeds the maximum width. + * + * @param context + * @param word + * @param wordWrapWidth + * @param space + */ +export function breakWord( + context: CanvasRenderingContext2D, + word: string, + wordWrapWidth: number, + space: number = 0 +): ILineWord[] { + const result: ILineWord[] = []; + let token = ""; + let prevWidth = 0; + // parts of the word fitting exactly wordWrapWidth + for (let i = 0; i < word.length; i++) { + const c = word.charAt(i); + token += c; + const width = measureText(context, token, space); + if (width > wordWrapWidth) { + result.push({ + text: token.substring(0, token.length - 1), + width: prevWidth, + }); + token = c; + prevWidth = measureText(context, token, space); + } else { + prevWidth = width; + } + } + // remaining text + if (token.length > 0) { + result.push({ + text: token, + width: prevWidth, + }); + } + return result; +} diff --git a/src/textures/bidiTokenizer.ts b/src/textures/bidiTokenizer.ts index 29b78902..b8831579 100644 --- a/src/textures/bidiTokenizer.ts +++ b/src/textures/bidiTokenizer.ts @@ -22,10 +22,14 @@ import type { DirectedSpan } from "./TextTextureRendererAdvancedUtils.js"; let bidi: BidiAPI; +// https://www.unicode.org/reports/tr9/ +const reZeroWidthSpace = /[\u200B\u200E\u200F\u061C]/g; +const reDirectionalFormat = /[\u202A\u202B\u202C\u202D\u202E\u202E\u2066\u2067\u2068\u2069]/g; + const reQuoteStart = /^["“”«»]/; const reQuoteEnd = /["“”«»]$/; -const rePunctuationStart = /^[.,،:;!?\(\)"-]+/; -const rePunctuationEnd = /[.,،:;!?\(\)"-]+$/; +const rePunctuationStart = /^[.,،:;!?()"-]+/; +const rePunctuationEnd = /[.,،:;!?()"-]+$/; /** * Reverse punctuation characters, mirroring braces @@ -153,9 +157,9 @@ export function getBidiTokenizer() { if (c === " ") { commit(); tokens.push(c); - } else if (c === "\u200B") { + } else if (reZeroWidthSpace.test(c)) { commit(); - } else { + } else if (!reDirectionalFormat.test(c)) { t += c; } } diff --git a/tests/text-rendering.spec.ts b/tests/text-rendering.spec.ts index f84a6696..086814ff 100644 --- a/tests/text-rendering.spec.ts +++ b/tests/text-rendering.spec.ts @@ -21,18 +21,22 @@ import { test, expect, type Page } from "@playwright/test"; import looksSame from "looks-same"; const FIRST_TEST = 1; -const TEST_STYLED = 3; -const TEST_BIDI = 4; -const TEST_HEBREW = 7 -const TEST_ARABIC = 10; -const LAST_TEST = 11; +const BASIC_COUNT = 2; +const STYLED_TESTS = FIRST_TEST + BASIC_COUNT; +const STYLED_COUNT = 1; +const BIDI_TESTS = STYLED_TESTS + STYLED_COUNT; +const BIDI_COUNT = 4; +const COMPLEX_HEBREW = BIDI_TESTS + BIDI_COUNT; +const COMPLEX_HEBREW_COUNT = 2; +const COMPLEX_ARABIC = COMPLEX_HEBREW + COMPLEX_HEBREW_COUNT; +const COMPLEX_ARABIC_COUNT = 1; async function compareWrapping(page: Page, width: number) { page.setDefaultTimeout(2000); await page.setViewportSize({ width, height: 4000 }); await page.goto("/tests/text-rendering.html?playwright"); - for (let i = FIRST_TEST; i < TEST_HEBREW; i++) { + for (let i = FIRST_TEST; i < COMPLEX_HEBREW; i++) { await page .locator(`#preview${i}`) .screenshot({ path: `temp/wrap-${width}-test${i}-html.png` }); @@ -55,7 +59,7 @@ async function compareWrapping(page: Page, width: number) { `Test ${i} - ${width}px - different pixels: ${differentPixels}` ); } - const maxDiff = i >= TEST_BIDI ? 150 : 50; // Arabic needs more tolerance + const maxDiff = i >= BIDI_TESTS ? 150 : 50; // Arabic needs more tolerance expect( equal || differentPixels < maxDiff, `[Test ${i}] HTML and canvas rendering do not match (${differentPixels} pixels differ)` @@ -68,7 +72,7 @@ async function compareLetterSpacing(page: Page, width: number) { await page.setViewportSize({ width, height: 4000 }); await page.goto("/tests/text-rendering.html?playwright&letterSpacing=5"); - for (let i = FIRST_TEST; i < TEST_STYLED; i++) { + for (let i = FIRST_TEST; i < STYLED_TESTS; i++) { await page .locator(`#preview${i}`) .screenshot({ path: `temp/spacing-${width}-test${i}-html.png` }); @@ -98,34 +102,33 @@ async function compareLetterSpacing(page: Page, width: number) { } } -async function comparePunctuation(page: Page, width: number) { +async function compareComplex(page: Page, width: number, start: number, count: number, maxDiff = 100) { await page.setViewportSize({ width, height: 4000 }); await page.goto("/tests/text-rendering.html?playwright"); - for (let i = TEST_HEBREW; i <= LAST_TEST; i++) { + for (let i = start; i < start + count; i++) { await page .locator(`#preview${i}`) - .screenshot({ path: `temp/punctuation-${width}-test${i}-html.png` }); + .screenshot({ path: `temp/complex-${width}-test${i}-html.png` }); await page .locator(`#canvas${i}`) - .screenshot({ path: `temp/punctuation-${width}-test${i}-canvas.png` }); + .screenshot({ path: `temp/complex-${width}-test${i}-canvas.png` }); const { equal, diffImage, differentPixels } = await looksSame( - `temp/punctuation-${width}-test${i}-html.png`, - `temp/punctuation-${width}-test${i}-canvas.png`, + `temp/complex-${width}-test${i}-html.png`, + `temp/complex-${width}-test${i}-canvas.png`, { createDiffImage: true, strict: false, } ); - diffImage?.save(`temp/punctuation-${width}-diff${i}.png`); + diffImage?.save(`temp/complex-${width}-diff${i}.png`); if (differentPixels) { console.log( `Test ${i} - ${width}px - different pixels: ${differentPixels}` ); } - const maxDiff = i >= TEST_ARABIC ? 350 : 150; // Arabic needs more tolerance expect( equal || differentPixels < maxDiff, `[Test ${i}] HTML and canvas rendering do not match (${differentPixels} pixels differ)` @@ -147,21 +150,26 @@ async function comparePunctuation(page: Page, width: number) { */ test("no wrap", async ({ page }) => { - await compareWrapping(page, 1000); + await compareWrapping(page, 1900); }); -test("wrap 1", async ({ page }) => { - await compareWrapping(page, 800); +test("wrap 840", async ({ page }) => { + await compareWrapping(page, 840); }); -test("wrap 2", async ({ page }) => { - await compareWrapping(page, 640); +test("wrap 720", async ({ page }) => { + await compareWrapping(page, 720); }); -test("wrap 3", async ({ page }) => { - await compareWrapping(page, 520); +test("wrap 630", async ({ page }) => { + await compareWrapping(page, 630); }); +// TODO: fix embedded RTL in LTR +// test("wrap 510", async ({ page }) => { +// await compareWrapping(page, 510); +// }); + test("letter spacing 1", async ({ page }) => { await compareLetterSpacing(page, 1000); }); @@ -170,6 +178,14 @@ test("letter spacing 2", async ({ page }) => { await compareLetterSpacing(page, 550); }); -test("punctuation", async ({ page }) => { - await comparePunctuation(page, 930); +test("complex Hebrew 660", async ({ page }) => { + await compareComplex(page, 660, COMPLEX_HEBREW, COMPLEX_HEBREW_COUNT); +}); + +test("complex Hebrew 880", async ({ page }) => { + await compareComplex(page, 880, COMPLEX_HEBREW, COMPLEX_HEBREW_COUNT); }); + +test("complex Arabic 900", async ({ page }) => { + await compareComplex(page, 900, COMPLEX_ARABIC, COMPLEX_ARABIC_COUNT, 300); +}); \ No newline at end of file diff --git a/tests/text-rendering/index.js b/tests/text-rendering/index.js index 6bb6301b..68c553a5 100644 --- a/tests/text-rendering/index.js +++ b/tests/text-rendering/index.js @@ -17,12 +17,12 @@ * limitations under the License. */ +import TextTexture from "../../dist/src/textures/TextTexture.mjs"; import TextTextureRendererAdvanced from "../../dist/src/textures/TextTextureRendererAdvanced.js"; import TextTextureRenderer from "../../dist/src/textures/TextTextureRenderer.js"; import TextTokenizer from "../../dist/src/textures/TextTokenizer.js"; let testN = 0; -let stopRendering = false; let letterSpacing = 0; if (location.search.indexOf("letterSpacing") > 0) { const match = location.search.match(/letterSpacing=(\d+)/); @@ -36,10 +36,9 @@ root.id = "root"; document.body.appendChild(root); let renderWidth = window.innerWidth - 16; -function demo() { +async function demo() { // const t0 = performance.now(); testN = 0; - stopRendering = false; root.innerHTML = ""; root.style.width = renderWidth + "px"; root.className = `spacing-${letterSpacing}`; @@ -71,56 +70,57 @@ function demo() { // `bidiTokenizer.es5.js` attaches declarations to global `lng` object TextTokenizer.setCustomTokenizer(lng.getBidiTokenizer()); - renderText( + await renderText( TextTextureRendererAdvanced, - "Something with arabic (that: أسباب لمشاهدة) in it.", - "left" + "Something with arabic embedded (that: !أسباب لمشاهدة).", + "left", + 2, + false ); - renderText( + await renderText( TextTextureRendererAdvanced, - "خمسة أسباب ①لمشاهدة عرض ONE Fight② Night 21", - "right" + "Something with hebrew embedded (that: !באמצעות מצלמת).", + "left", ); - renderText( - TextTextureRendererAdvanced, - 'أكبر الرابحين من عرض ONE Fight Night 21 من بطولة "ون"', - "right" - ); - - // Punctuation - renderText( + await renderText( TextTextureRendererAdvanced, - "הקש כדי להוסיף סרטים, תוכניות ועוד לרשימה שלי. לאחר מכן תוכל לצפות בהם מכאן בכל המכשירים שלך.", - "right" + "خمسة أسباب ①لمشاهدة عرض ONE Fight② Night 21", + "right", + 2, + false ); - renderText( + await renderText( TextTextureRendererAdvanced, - "הגיע הזמן לעדכן את אפליקציית MyApp ולקבל את התכונות החדשות ביותר (והטובות ביותר!). סמוך עלינו - אתה תאהב אותן.", - "right" + 'أكبر الرابحين من عرض ONE Fight Night 21 من بطولة "ون"', + "right", + 2, + false ); - renderText( + // Complex tests + + await renderText( TextTextureRendererAdvanced, - "סרוק את קוד ה-QR באמצעות מצלמת הטלפון או הטאבלט שלך.", + "סרוק את קוד ה-QR באמצעות מצלמת הטלפון או הטאבלט שלך. (some english text)", "right" ); - renderText( + await renderText( TextTextureRendererAdvanced, - "وسار فان دايك (33 عاما) الذي أصبح ركيزة أساسية في صفوف ليفربول منذ انضمامه عام 2018، على خطى المهاجم الدولي المصري محمد صلاح الذي مدد عقده قبل ستة أيام لعامين أيضا، منهيا أشهرا من التكهنات من خلال تمديد بقائه في أنفيلد؟", - "right", - 4 + "הגיע הזמן לעדכן את אפליקציית TheBrand ולקבל את התכונות (ביותר!) החדשות ביותר (והטובות ביותר!). סמוך עלינו - אתה תאהב אותן.", + "right" ); - renderText( + await renderText( TextTextureRendererAdvanced, 'أيضًا، نُدرج الأرقام ١٢٣٤٥٦٧٨٩٠، ورابط إلكتروني: user@example.com، مع علامات ترقيم؟!، ونصوص مختلطة الاتجاه مثل: "Hello, مرحبًا".', "right", - 4 + 3, + false ); - + // console.log("done in", performance.now() - t0, "ms"); } @@ -139,11 +139,13 @@ async function renderText( Renderer /*typeof TextTextureRenderer*/, source /*string*/, textAlign /*"left" | "right" | "center"*/, - maxLines /*number*/ = 2, - textOverflow /*string*/ = "" + maxLines /*number = 2*/, + allowTextTruncation /*boolean = true*/ ) { - if (stopRendering) return; testN++; + if (maxLines === undefined) maxLines = 2; + if (textAlign === undefined) textAlign = "left"; + if (allowTextTruncation === undefined) allowTextTruncation = true; // re-add tags let text = source.replace(/①/g, "").replace(/②/g, ""); @@ -216,12 +218,11 @@ async function renderText( ...getDefaultSettings(), rtl: textAlign === "right", text, - // w: wordWrapWidth, wordWrapWidth, - textOverflow, maxLines, advancedRenderer: text.indexOf(" 0, }; + TextTexture.allowTextTruncation = allowTextTruncation; try { const drawCanvas = document.createElement("canvas"); @@ -250,18 +251,6 @@ async function renderText( } } -renderText.only = function ( - Renderer /*typeof TextTextureRenderer*/, - source /*string*/, - textAlign /*"left" | "right" | "center"*/, - maxLines /*number = 2*/, - textOverflow /*string = ""*/ -) { - root.innerHTML = ""; - renderText(Renderer, source, textAlign, maxLines, textOverflow); - stopRendering = true; -}; - function getDefaultSettings() { return { rtl: false, diff --git a/vite.config.js b/vite.config.js index 7a8f0fe3..468845c1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -100,6 +100,7 @@ export default defineConfig(() => { }, test: { exclude: [ + './dist/**', './node_modules/**', './tests/**' ] From db8b200d0c02a783a7315494e433b7bd948d9e1e Mon Sep 17 00:00:00 2001 From: Philippe Elsass Date: Fri, 16 May 2025 14:59:10 +0200 Subject: [PATCH 09/32] wordBreak --- src/textures/TextTextureRendererAdvanced.ts | 2 + .../TextTextureRendererAdvancedUtils.test.ts | 8 +- .../TextTextureRendererAdvancedUtils.ts | 120 ++++++++++++------ src/textures/TextTextureRendererUtils.ts | 30 +++-- tests/text-rendering/index.js | 29 +++-- tests/textures/test.text.js | 93 +++++++++++++- 6 files changed, 215 insertions(+), 67 deletions(-) diff --git a/src/textures/TextTextureRendererAdvanced.ts b/src/textures/TextTextureRendererAdvanced.ts index 868ad049..fdd96c8e 100644 --- a/src/textures/TextTextureRendererAdvanced.ts +++ b/src/textures/TextTextureRendererAdvanced.ts @@ -55,6 +55,7 @@ export default class TextTextureRendererAdvanced extends TextTextureRenderer { this._settings.textOverflow, this._settings.wordWrap ); + const wordBreak = this._settings.wordBreak; const allowTextTruncation = TextTexture.allowTextTruncation; let tags: string[]; @@ -85,6 +86,7 @@ export default class TextTextureRendererAdvanced extends TextTextureRenderer { i === 0 ? this._settings.textIndent : 0, nowrap ? 1 : remainingLines, suffix, + wordBreak, allowTextTruncation ); diff --git a/src/textures/TextTextureRendererAdvancedUtils.test.ts b/src/textures/TextTextureRendererAdvancedUtils.test.ts index 300f4f4d..2e1d1fc5 100644 --- a/src/textures/TextTextureRendererAdvancedUtils.test.ts +++ b/src/textures/TextTextureRendererAdvancedUtils.test.ts @@ -136,7 +136,9 @@ describe("layoutSpans", () => { 200, 0, 1, - "..." + "...", + false, + false ); expect(lines.length).toBe(1); expect(lines[0]!.words[0]!.style).toBeUndefined(); @@ -157,7 +159,9 @@ describe("layoutSpans", () => { 200, 0, 1, - "..." + "...", + false, + false ); expect(lines.length).toBe(1); diff --git a/src/textures/TextTextureRendererAdvancedUtils.ts b/src/textures/TextTextureRendererAdvancedUtils.ts index bb92fe36..dd6ccb18 100644 --- a/src/textures/TextTextureRendererAdvancedUtils.ts +++ b/src/textures/TextTextureRendererAdvancedUtils.ts @@ -23,6 +23,7 @@ import type { ILineWordStyle, } from "./TextTextureRendererTypes.js"; import StageUtils from "../tree/StageUtils.mjs"; +import { breakWord } from "./TextTextureRendererUtils.js"; export interface DirectedSpan { rtl?: boolean; @@ -138,7 +139,8 @@ export function layoutSpans( textIndent: number, maxLines: number, suffix: string, - allowTruncation = false + wordBreak: boolean, + allowTruncation: boolean ): LineLayout[] { // styling const { isStyled, baseStyle, updateStyle, getStyle } = lineStyle; @@ -160,13 +162,14 @@ export function layoutSpans( words: [], }; const lines: LineLayout[] = [line]; + let words: WordLayout[]; let lineN = 1; let endReached = false; - let addEllipsis = false; + let overflow = false; let x = textIndent; // concatenate words - const appendWords = (words: WordLayout[]): void => { + const appendWords = (): void => { if (rtl !== primaryRtl) { words.reverse(); } @@ -180,6 +183,19 @@ export function layoutSpans( } } line.words.push(...words); + words = []; + }; + + const newLine = (): void => { + line = { + rtl, + width: 0, + text: "", + words: [], + }; + lines.push(line); + lineN++; + x = 0; }; // process tokens @@ -187,14 +203,20 @@ export function layoutSpans( const span = spans[si]!; rtl = Boolean(span.rtl); const tokens = span.tokens; - let words: WordLayout[] = []; + words = []; for (let ti = 0; ti < tokens.length; ti++) { + // overflow? + if (maxLines && lineN > maxLines) { + endReached = true; + overflow = true; + break; + } let text = tokens[ti]!; const isSpace = text === " "; // update style? - if (isStyled && !isSpace) { + if (isStyled && !isSpace && text.length === 1) { const c = text.charCodeAt(0); if (c >= 0x2460 && c <= 0x2473) { // word is a style tag @@ -207,64 +229,80 @@ export function layoutSpans( } // measure word - const width = isSpace ? spaceWidth : ctx.measureText(text).width; + let width = isSpace ? spaceWidth : ctx.measureText(text).width; x += width; // end of line if (x > wrapWidth) { - // single word longer than max size - if (words.length === 0 && line.words.length === 0) { - words.push({ text, width, style, rtl }); - if (lineN === maxLines) { - addEllipsis = true; - endReached = true; - break; - } - // else TODO break word - continue; - } - // last word of last line - ellipsis will be applied later if (lineN === maxLines) { words.push({ text, width, style, rtl }); - addEllipsis = true; + overflow = true; endReached = true; break; } - // finalize line - appendWords(words); - measureLine(line); + // if word is wider than the line + if (width > wrapWidth) { + // commit line + if (line.words.length > 0 || words.length > 0) { + appendWords(); + newLine(); + x = width; + } + // either break the word, or push to new line + if (wordBreak) { + const broken = breakWord(ctx, text, wrapWidth, 0); + const last = broken.pop()!; + for (const k of broken) { + words.push({ + text: k.text, + width: k.width, + style, + rtl, + }); + appendWords(); + newLine(); + } + text = last.text; + x = width = last.width; + } + // add remaining/full word + words.push({ text, width, style, rtl }); + continue; + } - // new line - x = width; - words = []; - line = { - rtl, - width: 0, - text: "", - words: [], - }; - lines.push(line); - lineN++; + // finalize line + appendWords(); + newLine(); if (text === " ") { - // don't insert trailing space + // don't insert trailing space to the new line continue; } + // we will insert the word to the new line + x = width; } words.push({ text, width, style, rtl }); } // append and continue? - appendWords(words); + appendWords(); if (endReached) break; } + + // prevent exceeding maxLines + if (maxLines > 0 && lines.length >= maxLines) { + lines.length = maxLines; + } + // finalize - measureLine(line); + lines.forEach((line) => { + measureLine(line); + }); // ellipsis - if (addEllipsis) { + if (overflow) { line = lines[lines.length - 1]!; const maxLineWidth = wrapWidth - suffixWidth; @@ -319,9 +357,7 @@ export function layoutSpans( } const reversed = primaryRtl !== rtl; do { - text = reversed - ? trimWordStart(text, rtl) - : trimWordEnd(text, rtl); + text = reversed ? trimWordStart(text, rtl) : trimWordEnd(text, rtl); width = ctx.measureText(text).width; } while (width > maxWidth); if (width > suffixWidth) { @@ -363,8 +399,8 @@ export function layoutSpans( return lines; } -const rePunctuationStart = /^[.,،:;!?؟()"“”«»-]+/ -const rePunctuationEnd = /[.,،:;!?؟()"“”«»-]+$/ +const rePunctuationStart = /^[.,،:;!?؟()"“”«»-]+/; +const rePunctuationEnd = /[.,،:;!?؟()"“”«»-]+$/; export function trimWordEnd(text: string, rtl: boolean): string { if (rtl) { diff --git a/src/textures/TextTextureRendererUtils.ts b/src/textures/TextTextureRendererUtils.ts index bc742f93..08a95ee9 100644 --- a/src/textures/TextTextureRendererUtils.ts +++ b/src/textures/TextTextureRendererUtils.ts @@ -71,7 +71,7 @@ export function getFontSetting( export function wrapText( context: CanvasRenderingContext2D, text: string, - wordWrapWidth: number, + wrapWidth: number, letterSpacing: number, textIndent: number, maxLines: number, @@ -85,19 +85,24 @@ export function wrapText( const spaceWidth = measureText(context, " ", letterSpacing); const resultLines: ILineInfo[] = []; let result = ""; - let spaceLeft = wordWrapWidth - textIndent; + let spaceLeft = wrapWidth - textIndent; let word = ""; let wordWidth = 0; let totalWidth = textIndent; let overflow = false; for (let j = 0; j < words.length; j++) { + // overflow? + if (maxLines && resultLines.length > maxLines) { + overflow = true; + break; + } word = words[j]!; wordWidth = word === " " ? spaceWidth : measureText(context, word, letterSpacing); if (wordWidth > spaceLeft) { - // early stop? - if (maxLines > 0 && resultLines.length >= maxLines - 1) { + // last word of last line overflows + if (maxLines && resultLines.length >= maxLines - 1) { result += word; totalWidth += wordWidth; overflow = true; @@ -117,10 +122,10 @@ export function wrapText( if (j > 0 && word === " ") wordWidth = 0; else result = word; - // if word is too long, break it - if (wordBreak && wordWidth > wordWrapWidth) { - const broken = breakWord(context, word, wordWrapWidth, letterSpacing); - const last = broken.pop()!; + // if word is too long, break it (caution: it could produce more than maxLines) + if (wordBreak && wordWidth > wrapWidth) { + const broken = breakWord(context, word, wrapWidth, letterSpacing); + let last = broken.pop()!; for (const k of broken) { resultLines.push({ text: k.text, @@ -132,13 +137,18 @@ export function wrapText( } totalWidth = wordWidth; - spaceLeft = wordWrapWidth - wordWidth; + spaceLeft = wrapWidth - wordWidth; } else { spaceLeft -= wordWidth; totalWidth += wordWidth; result += word; } } + + // prevent exceeding maxLines + if (maxLines > 0 && resultLines.length >= maxLines) { + resultLines.length = maxLines; + } // shorten and append ellipsis, if any if (overflow) { @@ -146,7 +156,7 @@ export function wrapText( ? measureText(context, suffix, letterSpacing) : 0; - while (totalWidth + suffixWidth > wordWrapWidth) { + while (totalWidth + suffixWidth > wrapWidth) { result = result.substring(0, result.length - 1); totalWidth = measureText(context, result, letterSpacing); } diff --git a/tests/text-rendering/index.js b/tests/text-rendering/index.js index 68c553a5..9e70938b 100644 --- a/tests/text-rendering/index.js +++ b/tests/text-rendering/index.js @@ -43,29 +43,36 @@ async function demo() { root.style.width = renderWidth + "px"; root.className = `spacing-${letterSpacing}`; + // TextTokenizer.setCustomTokenizer(lng.getBidiTokenizer()); + // await renderText( + // TextTextureRendererAdvanced, + // "Something with hebrew (that: מכאן בכל המכשירים שלך!) in it.", + // "left", + // 2 + // ); + // return; + TextTokenizer.setCustomTokenizer(); // basic renderer - renderText( + + await renderText( TextTextureRenderer, - "First line\nAnd a second line of some rather long text", - "left" + "First line\nAnd a second line of some rather long text" ); - renderText( + await renderText( TextTextureRenderer, - "One first line of some rather long text.\nAnd another quite long line; maybe longer!", - "left" + "One first line of some rather long text.\nAnd another quite long line; maybe longer!" ); - // advanced renderer + // styled rendering - renderText( + await renderText( TextTextureRendererAdvanced, - "First line\nAnd a second line of some rather long text", - "left" + "First line\nAnd a second line of some styled text" ); - // Bidi + // Bidi rendering // `bidiTokenizer.es5.js` attaches declarations to global `lng` object TextTokenizer.setCustomTokenizer(lng.getBidiTokenizer()); diff --git a/tests/textures/test.text.js b/tests/textures/test.text.js index e2bd1a47..c4fe157e 100644 --- a/tests/textures/test.text.js +++ b/tests/textures/test.text.js @@ -30,9 +30,10 @@ sodales velit quis augue. Cras suscipit, urna at aliquam rhoncus, urna quam vive nisi, in interdum massa nibh nec erat.'; // With advanced renderer, `renderInfo` lines are objects with `words` array +/** @return {string} */ function getLineText(info) { - if (info.words) return info.words.map(w => w.text).join(''); - return info.text; + if (info.words) return info.words.map(w => w.text).join('').trimEnd(); + return info.text.trimEnd(); } describe('text', function() { @@ -295,6 +296,94 @@ describe('text', function() { }); }); + describe('wordBreak', function() { + it('should not break 1st word without flag', function() { + const element = app.stage.createElement({ + Item: { + texture: { + type: TestTexture, + wordWrapWidth: 300, + text: 'EXTRA-LONG-WORD lorem ipsum dolor sit amet.', + async: false, + ...SETTINGS + }, visible: true}, + }); + app.children = [element]; + const texture = app.tag("Item").texture; + stage.drawFrame(); + console.log(texture.source.renderInfo.lines); + chai.assert(texture.source.renderInfo.lines.length === 3); + chai.assert(getLineText(texture.source.renderInfo.lines[0]) === 'EXTRA-LONG-WORD'); + }); + + it('should not break 2nd word without flag', function() { + const element = app.stage.createElement({ + Item: { + texture: { + type: TestTexture, + wordWrapWidth: 300, + text: 'Sit EXTRA-LONG-WORD ipsum dolor sit amet.', + async: false, + ...SETTINGS + }, visible: true}, + }); + app.children = [element]; + const texture = app.tag("Item").texture; + stage.drawFrame(); + console.log(texture.source.renderInfo.lines); + chai.assert(texture.source.renderInfo.lines.length === 4); + chai.assert(getLineText(texture.source.renderInfo.lines[0]) === 'Sit'); + chai.assert(getLineText(texture.source.renderInfo.lines[1]) === 'EXTRA-LONG-WORD'); + }); + + it('should break 1st word', function() { + const element = app.stage.createElement({ + Item: { + texture: { + type: TestTexture, + wordWrapWidth: 120, + wordBreak: true, + text: 'EXTRA-LONG-WORD lorem ipsum dolor sit amet.', + async: false, + ...SETTINGS + }, visible: true}, + }); + app.children = [element]; + const texture = app.tag("Item").texture; + stage.drawFrame(); + console.log(texture.source.renderInfo.lines); + chai.assert(texture.source.renderInfo.lines.length === 9); + chai.assert(getLineText(texture.source.renderInfo.lines[0]) === 'EXTR'); + chai.assert(getLineText(texture.source.renderInfo.lines[1]) === 'A-LO'); + chai.assert(getLineText(texture.source.renderInfo.lines[2]) === 'NG-W'); + chai.assert(getLineText(texture.source.renderInfo.lines[3]) === 'ORD'); + }); + + it('should break 2nd word', function() { + const element = app.stage.createElement({ + Item: { + texture: { + type: TestTexture, + wordWrapWidth: 120, + wordBreak: true, + text: 'Sit EXTRA-LONG-WORD lorem ipsum dolor sit amet.', + async: false, + ...SETTINGS + }, visible: true}, + }); + app.children = [element]; + const texture = app.tag("Item").texture; + stage.drawFrame(); + console.log(texture.source.renderInfo.lines); + chai.assert(texture.source.renderInfo.lines.length === 10); + chai.assert(getLineText(texture.source.renderInfo.lines[0]) === 'Sit'); + chai.assert(getLineText(texture.source.renderInfo.lines[1]) === 'EXTR'); + chai.assert(getLineText(texture.source.renderInfo.lines[2]) === 'A-LO'); + chai.assert(getLineText(texture.source.renderInfo.lines[3]) === 'NG-W'); + chai.assert(getLineText(texture.source.renderInfo.lines[4]) === 'ORD'); + }); + }); + describe('regression', function() { afterEach(() => { From 7588e9d8c2a96b26f40dfc53040475059502cdb5 Mon Sep 17 00:00:00 2001 From: Philippe Elsass Date: Fri, 16 May 2025 15:25:56 +0200 Subject: [PATCH 10/32] letter spacing advanced --- src/textures/TextTextureRendererAdvanced.ts | 18 +++++++++++++----- .../TextTextureRendererAdvancedUtils.ts | 9 +++++---- tests/text-rendering.spec.ts | 2 +- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/textures/TextTextureRendererAdvanced.ts b/src/textures/TextTextureRendererAdvanced.ts index fdd96c8e..340a04db 100644 --- a/src/textures/TextTextureRendererAdvanced.ts +++ b/src/textures/TextTextureRendererAdvanced.ts @@ -56,6 +56,7 @@ export default class TextTextureRendererAdvanced extends TextTextureRenderer { this._settings.wordWrap ); const wordBreak = this._settings.wordBreak; + const letterSpacing = this._settings.letterSpacing; const allowTextTruncation = TextTexture.allowTextTruncation; let tags: string[]; @@ -87,6 +88,7 @@ export default class TextTextureRendererAdvanced extends TextTextureRenderer { nowrap ? 1 : remainingLines, suffix, wordBreak, + letterSpacing, allowTextTruncation ); @@ -117,9 +119,10 @@ export default class TextTextureRendererAdvanced extends TextTextureRenderer { const y = drawLine.y; let x = drawLine.x; for (let j = 0; j < words.length; j++) { - const word = words[j]!; - if (word.style !== currentStyle) { - currentStyle = word.style; + const { text, style, width } = words[j]!; + + if (style !== currentStyle) { + currentStyle = style; if (currentStyle) { const { font, color } = currentStyle; ctx.font = font; @@ -127,8 +130,13 @@ export default class TextTextureRendererAdvanced extends TextTextureRenderer { } } - ctx.fillText(word.text, x, y); - x += word.width; + if (letterSpacing === 0) { + ctx.fillText(text, x, y); + } else { + this._fillTextWithLetterSpacing(ctx, text, x, y, letterSpacing); + } + + x += width; } } } diff --git a/src/textures/TextTextureRendererAdvancedUtils.ts b/src/textures/TextTextureRendererAdvancedUtils.ts index dd6ccb18..c5dd56b8 100644 --- a/src/textures/TextTextureRendererAdvancedUtils.ts +++ b/src/textures/TextTextureRendererAdvancedUtils.ts @@ -23,7 +23,7 @@ import type { ILineWordStyle, } from "./TextTextureRendererTypes.js"; import StageUtils from "../tree/StageUtils.mjs"; -import { breakWord } from "./TextTextureRendererUtils.js"; +import { breakWord, measureText } from "./TextTextureRendererUtils.js"; export interface DirectedSpan { rtl?: boolean; @@ -140,6 +140,7 @@ export function layoutSpans( maxLines: number, suffix: string, wordBreak: boolean, + letterSpacing: number, allowTruncation: boolean ): LineLayout[] { // styling @@ -149,8 +150,8 @@ export function layoutSpans( let style: ILineWordStyle | undefined = isStyled ? initialStyle : undefined; // cached metrics - const spaceWidth = ctx.measureText(" ").width; - const suffixWidth = ctx.measureText(suffix).width; + const spaceWidth = measureText(ctx, " ", letterSpacing); + const suffixWidth = measureText(ctx, suffix, letterSpacing); // layout state let rtl = Boolean(spans[0]?.rtl); @@ -229,7 +230,7 @@ export function layoutSpans( } // measure word - let width = isSpace ? spaceWidth : ctx.measureText(text).width; + let width = isSpace ? spaceWidth : measureText(ctx, text, letterSpacing); x += width; // end of line diff --git a/tests/text-rendering.spec.ts b/tests/text-rendering.spec.ts index 086814ff..8275affa 100644 --- a/tests/text-rendering.spec.ts +++ b/tests/text-rendering.spec.ts @@ -72,7 +72,7 @@ async function compareLetterSpacing(page: Page, width: number) { await page.setViewportSize({ width, height: 4000 }); await page.goto("/tests/text-rendering.html?playwright&letterSpacing=5"); - for (let i = FIRST_TEST; i < STYLED_TESTS; i++) { + for (let i = FIRST_TEST; i < STYLED_TESTS + STYLED_COUNT; i++) { await page .locator(`#preview${i}`) .screenshot({ path: `temp/spacing-${width}-test${i}-html.png` }); From 1487e66ad8780be1e00e2708e9c6c718513e434a Mon Sep 17 00:00:00 2001 From: Philippe Elsass Date: Fri, 16 May 2025 15:59:30 +0200 Subject: [PATCH 11/32] linter --- src/textures/TextTextureRendererAdvancedUtils.test.ts | 2 ++ src/textures/TextTokenizer.ts | 2 +- tests/text-rendering/index.js | 3 +-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/textures/TextTextureRendererAdvancedUtils.test.ts b/src/textures/TextTextureRendererAdvancedUtils.test.ts index 2e1d1fc5..0fdb9f56 100644 --- a/src/textures/TextTextureRendererAdvancedUtils.test.ts +++ b/src/textures/TextTextureRendererAdvancedUtils.test.ts @@ -138,6 +138,7 @@ describe("layoutSpans", () => { 1, "...", false, + 0, false ); expect(lines.length).toBe(1); @@ -161,6 +162,7 @@ describe("layoutSpans", () => { 1, "...", false, + 0, false ); expect(lines.length).toBe(1); diff --git a/src/textures/TextTokenizer.ts b/src/textures/TextTokenizer.ts index 3fbc350f..94d11ab8 100644 --- a/src/textures/TextTokenizer.ts +++ b/src/textures/TextTokenizer.ts @@ -88,7 +88,7 @@ class TextTokenizer { const len = text.length; let startIndex = 0; let i = 0; - for (i; i < len; i++) { + for (; i < len; i++) { const c = text.charAt(i); if (c === " " || c === "\u200B") { if (i - startIndex > 0) { diff --git a/tests/text-rendering/index.js b/tests/text-rendering/index.js index 9e70938b..aee39b07 100644 --- a/tests/text-rendering/index.js +++ b/tests/text-rendering/index.js @@ -234,8 +234,7 @@ async function renderText( try { const drawCanvas = document.createElement("canvas"); const renderer = new Renderer(stage, drawCanvas, settings); - await renderer._load(); - renderer._draw(); + await renderer.draw(); const ctx = canvas.getContext("2d"); ctx.fillStyle = "white"; From af9585ab6e0ab55a7f7095a709650e37ba1c9873 Mon Sep 17 00:00:00 2001 From: Philippe Elsass Date: Wed, 2 Jul 2025 19:22:26 +0200 Subject: [PATCH 12/32] Keep under max texture width --- tests/text-rendering/index.js | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/text-rendering/index.js b/tests/text-rendering/index.js index aee39b07..bdcc8b3b 100644 --- a/tests/text-rendering/index.js +++ b/tests/text-rendering/index.js @@ -22,6 +22,7 @@ import TextTextureRendererAdvanced from "../../dist/src/textures/TextTextureRend import TextTextureRenderer from "../../dist/src/textures/TextTextureRenderer.js"; import TextTokenizer from "../../dist/src/textures/TextTokenizer.js"; +const MAX_WIDTH = 2048; // max width of the canvas let testN = 0; let letterSpacing = 0; if (location.search.indexOf("letterSpacing") > 0) { @@ -34,7 +35,7 @@ if (location.search.indexOf("letterSpacing") > 0) { const root = document.createElement("div"); root.id = "root"; document.body.appendChild(root); -let renderWidth = window.innerWidth - 16; +let renderWidth = Math.min(window.innerWidth - 16, MAX_WIDTH); async function demo() { // const t0 = performance.now(); @@ -43,15 +44,7 @@ async function demo() { root.style.width = renderWidth + "px"; root.className = `spacing-${letterSpacing}`; - // TextTokenizer.setCustomTokenizer(lng.getBidiTokenizer()); - // await renderText( - // TextTextureRendererAdvanced, - // "Something with hebrew (that: מכאן בכל המכשירים שלך!) in it.", - // "left", - // 2 - // ); - // return; - + // reset tokenizer TextTokenizer.setCustomTokenizer(); // basic renderer @@ -137,7 +130,7 @@ window.addEventListener("resize", () => { window.clearTimeout(timer); timer = window.setTimeout(() => { timer = 0; - renderWidth = window.innerWidth - 16; + renderWidth = Math.min(window.innerWidth - 16, MAX_WIDTH); demo(); }, 10); }); From 9e3b73faac219301ee511adb75f73e6e2459dd84 Mon Sep 17 00:00:00 2001 From: Philippe Elsass Date: Thu, 5 Jun 2025 11:19:50 +0200 Subject: [PATCH 13/32] support negative clipping coordinates in c2d renderer --- src/renderer/c2d/shaders/DefaultShader.mjs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/renderer/c2d/shaders/DefaultShader.mjs b/src/renderer/c2d/shaders/DefaultShader.mjs index 0534f895..c3914c6a 100644 --- a/src/renderer/c2d/shaders/DefaultShader.mjs +++ b/src/renderer/c2d/shaders/DefaultShader.mjs @@ -76,6 +76,19 @@ export default class DefaultShader extends C2dShader { const sourceH = (stc ? 1 : (vc._bry - vc._uly)) * tx.h; let colorize = !white; + + // Handle horizontal mirroring if sourceW is negative + let drawSourceX = sourceX; + let drawSourceW = sourceW; + let destX = 0; + if (sourceW < 0) { + drawSourceX = sourceX + sourceW; + drawSourceW = -sourceW; + ctx.save(); + ctx.scale(-1, 1); + destX = -vc.w; + } + if (colorize) { // @todo: cache the tint texture for better performance. @@ -96,10 +109,15 @@ export default class DefaultShader extends C2dShader { // Actually draw result. ctx.fillStyle = 'white'; - ctx.drawImage(tintTexture, sourceX, sourceY, sourceW, sourceH, 0, 0, vc.w, vc.h); + ctx.drawImage(tintTexture, drawSourceX, sourceY, drawSourceW, sourceH, destX, 0, vc.w, vc.h); } else { ctx.fillStyle = 'white'; - ctx.drawImage(tx, sourceX, sourceY, sourceW, sourceH, 0, 0, vc.w, vc.h); + ctx.drawImage(tx, drawSourceX, sourceY, drawSourceW, sourceH, destX, 0, vc.w, vc.h); + } + + if (sourceW < 0) { + // cancel mirroring transform + ctx.restore(); } this._afterDrawEl(info); ctx.globalAlpha = 1.0; From 3b99c7415f046ae62dd1eded0d5dfa8aff9af46e Mon Sep 17 00:00:00 2001 From: Philippe Elsass Date: Thu, 5 Jun 2025 16:03:26 +0200 Subject: [PATCH 14/32] integration tests --- tests/rtl/test.mirroring.js | 111 ++++++++++++++++++++++++++++++++++++ tests/test.html | 1 + 2 files changed, 112 insertions(+) create mode 100644 tests/rtl/test.mirroring.js diff --git a/tests/rtl/test.mirroring.js b/tests/rtl/test.mirroring.js new file mode 100644 index 00000000..a89e69f3 --- /dev/null +++ b/tests/rtl/test.mirroring.js @@ -0,0 +1,111 @@ +/* + * If not stated otherwise in this file or this component's LICENSE file the + * following copyright and licenses apply: + * + * Copyright 2020 Metrological + * + * Licensed under the Apache License, Version 2.0 (the License); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe("Texture mirroring", function () { + let stage; + + function toNumberColor(bytes) { + return (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]; + } + + function captureCanvasCornerColors(canvas) { + const temp = document.createElement("canvas"); + temp.width = canvas.width; + temp.height = canvas.height; + const ctx = temp.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(canvas, 0, 0); + // read TL and TR pixels and extract colors (without alpha) + const tl = toNumberColor(ctx.getImageData(1, 1, 1, 1).data); + const tr = toNumberColor( + ctx.getImageData(canvas.width - 2, 1, 1, 1).data + ); + + return { left: tl, right: tr }; + } + + function renderTest(canvas2d, radius, mirror) { + class TestApplication extends lng.Application {} + const app = new TestApplication({ + stage: { w: 100, h: 100, clearColor: 0xffffffff, autostart: false, canvas2d }, + }); + stage = app.stage; + document.body.appendChild(stage.getCanvas()); + + const element = app.stage.createElement({ + Item: { + texture: lng.Tools.getRoundRect(98, 98, radius, 0, 0, true, 0xffff0000), + }, + }); + app.children = [element]; + + if (mirror) { + const item = app.tag("Item"); + item.texture.enableClipping(100, 0, -100, 100); + item.w = 100; + } + + stage.drawFrame(); + } + + afterEach(() => { + stage.stop(); + stage.getCanvas().remove(); + }); + + it("non-mirrored control in webGl", () => { + renderTest(false, [0, 30, 30, 30], false); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.left === 0x0000ff && capture.right === 0xffffff, + "Left should be red, right should be white" + ); + }); + + it("can mirror in webGl", () => { + renderTest(false, [0, 30, 30, 30], true); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.left === 0xffffff && capture.right === 0x0000ff, + "Left should be white, right should be red" + ); + }); + + it("non-mirrored control in canvas2d", () => { + renderTest(true, [0, 30, 30, 30], false); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.left === 0x0000ff && capture.right === 0xffffff, + "Left should be red, right should be white" + ); + }); + + + it("can mirror in canvas2d", () => { + renderTest(true, [0, 30, 30, 30], true); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.left === 0xffffff && capture.right === 0x0000ff, + "Left should be white, right should be red" + ); + }); +}); diff --git a/tests/test.html b/tests/test.html index 2f732acb..1ee3f131 100644 --- a/tests/test.html +++ b/tests/test.html @@ -55,6 +55,7 @@ + + @@ -55,7 +56,6 @@ -