diff --git a/docs/RTL/index.md b/docs/RTL/index.md new file mode 100644 index 00000000..bc8f0c55 --- /dev/null +++ b/docs/RTL/index.md @@ -0,0 +1,110 @@ +# 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. + +The `rtl` flag will also mirror the text alignment: `left` and `right` alignment are automatically reversed. Note that this +alone doesn't mean RTL text is correctly rendered - see "Bidirectional text layout" below. + +### 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 bidirectional text layout works + +When working with RTL languages, we must support any combinations of LTR and RTL text: numbers and some words aren't translated; you may even have entire sentences untranslated. + +Correctly rendering RTL text requires to support "bidirectional text layout", which is an advanced feature you must opt-in to. + +```typescript +import { TextTexture, TextTokenizer } from '@lightningjs/core'; +import { getBidiTokenizer } from '@lightningjs/core/bidiTokenizer'; + +// Initialize bidi text support +TextTokenizer.setCustomTokenizer(getBidiTokenizer()); + +// Only the "advanced renderer" supports bidi layout +TextTexture.forceAdvancedRenderer = true; +``` + +### Text direction detection, control and isolation + +#### Direction detection + +By default the text renderer behaves as HTML `dir=auto`, which means it will detect the primary text direction to render text from the left or the right, while the text can be a combination of different directions. + +This detection will consider strongly directional characters, which means that text starting with numbers (weak LTR) followed by RTL letters will be considered all RTL (numbers will still be rendered LTR themselves). + +#### Isolation + +Sometimes a text is a concatenation of multiple labels or sentences, like `{title} {description}` or `{tag 1} {tag 2} {tag 3}`, where each part could be either LTR or RTL text. + +Problems which can happen: + +- By default, the primary text direction will be detected and will determine how the whole will be rendered; if the 1st part is detected LTR, the text will look incorrectly rendered in a RTL app layout, +- Parts can interact between each other: `90 {minutes} 90 {minutes in Hebrew}` would consider the 2nd `90` to be part of the initial LTR text. + +The solution is to use: + +- A strong direction isolate, to ensure the text is generally layed out in the UI direction, +- An auto-detection isolate wrapping each part, to ensure each part is individually detected and rendered correctly. + +Sample code: + +```typescript +/** Left-to-Right Isolate ('ltr') */ +export const LRI = '\u2066'; +/** Right-to-Left Isolate ('rtl') */ +export const RLI = '\u2067'; +/** First Strong Isolate ('auto') */ +export const FSI = '\u2068'; +/** Pop Directional Isolate */ +export const PDI = '\u2069'; + +/** + * Isolate text to avoid interactions with surrounding + * @param rtl - App direction + * @param text - label to isolate + */ +export function isolateText(rtl: boolean, text: string): string { + if (rtl) { + return `${FSI}${text}${PDI}`; + } + return text; +} + +/** + * Concatenate isolated bidirectional text parts, while enforcing a general layout direction + * @param rtl - App direction + * @param parts - labels to isolate and concatenate + */ +export function concatenateIsolates(rtl: boolean, parts: (string | undefined)[]): string { + if (rtl) { + return RLI + FSI + parts.filter(Boolean).join(PDI + ' ' + FSI) + PDI + PDI; + } + // When app is LTR we don't load the bidi-tokenizer and don't expect RTL sentences, + // otherwise, follow the code above with a LRI isolate + return parts.filter(Boolean).join(' '); +} +``` diff --git a/docs/RTL/ltr-rtl.png b/docs/RTL/ltr-rtl.png new file mode 100644 index 00000000..8a6b323e Binary files /dev/null and b/docs/RTL/ltr-rtl.png differ diff --git a/docs/RenderEngine/Elements/Rendering.md b/docs/RenderEngine/Elements/Rendering.md index f4188f68..e55634a1 100644 --- a/docs/RenderEngine/Elements/Rendering.md +++ b/docs/RenderEngine/Elements/Rendering.md @@ -33,6 +33,18 @@ You can set the visibility of an element in the following ways: * Using the `visible` property. If the value of this property is set to 'false', the element is not rendered (which saves performance). If an element is invisible, the off-screen elements are invisible as well, so you do not have to hide those manually to maintain a good performance. * Using the `alpha` property, which defines the opacity of an element and its descendants. If the value of this property is set to 0 (zero), the element is not rendered. +### Alpha-based Performance Optimization + +Lightning automatically optimizes rendering performance by skipping elements with very low alpha values. Elements with an effective alpha of `0.001` or less are considered invisible and are automatically excluded from: + +* Rendering operations +* Mouse hit testing and interaction detection + +This optimization helps improve performance by avoiding unnecessary rendering work for elements that are effectively invisible to users, while still allowing for texture loading and smooth fade-in/fade-out animations. + +Note: actual alpha test is `< 0.002` to avoid floating point errors when animating to `0.001`. + + ## Color diff --git a/docs/RenderEngine/Textures/Text.md b/docs/RenderEngine/Textures/Text.md index f6a58b7b..4c37a935 100644 --- a/docs/RenderEngine/Textures/Text.md +++ b/docs/RenderEngine/Textures/Text.md @@ -47,18 +47,29 @@ You can use various properties to control the way in which you want to render te ## Word Wrap in Non-Latin Based Languages +(or long URLs!) + Enabling the `wordWrap` option causes lines of text that are too long for the specified `wordWrapWidth` to be broken into multiple lines. Lines are broken only at word boundaries. In most latin script based languages (i.e. English, -Dutch, French, etc) the space " " character is the primary separator of word +Dutch, French, etc) the space `" "` character is the primary separator of word boundaries. Many non-latin based languages (i.e. Chinese, Japanese, Thai and more) do not use spaces to separate words. Instead there is an assortment of rules that determine where -word boundaries, for the purpose of line breaking, are allowed. Lightning -currently does not implement these rules as there are many languages and writing -systems to consider when implementing them. However, we do offer a work around -that can be employed in your application as needed. +word boundaries are, for the purpose of line breaking, are allowed. Lightning +does not implement these rules as there are many languages and writing +systems to consider when implementing them. However, we do offer solutions which +can be employed in your application as needed. + +See [this GitHub issue](https://github.com/rdkcentral/Lightning/issues/450) for +more information. + +### Tokenization + +Tokenization is the process of taking one text string and separating it in individual +words which can be wrapped. By default Lightning will break the text on spaces, but +also zero-width spaces. ### Zero-Width Spaces @@ -67,33 +78,40 @@ Lightning supports line breaking at [Zero-Width Space](https://en.wikipedia.org/ take up no actual space between visible characers. You can use them in your text strings and Lightning will line break on them when it needs to. -You may want to write a function that you funnel all of your application's -text strings into: +You may pre-process text and add zero-width space characters to allow Lightning +to wrap these texts. -```js -function addZeroWidthSpaces(text) { - // Code that inserts Zero-Width Spaces into text and returns the new text -} +### Custom tokenizer -class ZeroWidthSpaceTextDemo extends lng.Application { - static _template() { - return { - Text: { - text: { - text: addZeroWidthSpaces('こんにちは。初めまして!') - } - } - } - } -} +Another approach is to override Lightning's default tokenizer. + +```typescript +import { TextTokenizer } from '@lightningjs/core'; + +// `budoux` is a tokenization library for Asian languages (Chinese, Thai...) +import { loadDefaultSimplifiedChineseParser } from 'budoux'; + +const getSimplifiedChineseTokenizer = (): TextTokenizer.ITextTokenizerFunction => { + const parser = loadDefaultSimplifiedChineseParser(); + return (text) => [{ tokens: parser.parse(text) }]; +}; + +TextTokenizer.setCustomTokenizer(getSimplifiedChineseTokenizer(), true); +// This Chinese tokenizer is very efficient but doesn't correctly tokenize English, +// so the second `true` parameter hints `TextTokenizer` to handle 100% English text +// with the default tokenizer. ``` -See [this GitHub issue](https://github.com/rdkcentral/Lightning/issues/450) for -more information. +### Right-to-Left (RTL) support -## Live Demo +Languages like Arabic or Hebrew require special effort to be wrapped and rendered correctly. + +See [Right-to-left (RTL) support](../../RTL/index.md) + +## Live Demo + ``` class TextDemo extends lng.Application { static _template() { 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/package-lock.json b/package-lock.json index db99c711..0ebae9f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "@lightningjs/core", - "version": "2.14.1", + "version": "2.16.0-beta.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@lightningjs/core", - "version": "2.14.1", + "version": "2.16.0-beta.6", "license": "Apache-2.0", + "dependencies": { + "bidi-js": "^1.0.3" + }, "devDependencies": { "@babel/core": "^7.8.3", "@babel/plugin-transform-parameters": "^7.8.3", @@ -21,6 +24,7 @@ "concurrently": "^7.6.0", "cross-env": "^7.0.3", "local-web-server": "^5.4.0", + "looks-same": "^9.0.1", "mocha": "^6.2.1", "rollup-plugin-cleanup": "^3.1.1", "shelljs": "^0.8.5", @@ -2300,6 +2304,13 @@ "node": "*" } }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/babel-plugin-dynamic-import-node": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", @@ -2345,6 +2356,104 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/bare-fs": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.2.tgz", + "integrity": "sha512-8wSeOia5B7LwD4+h465y73KOdj5QHsbbuoUfPBi+pXgFJIPuG7SsiOdJuijWMyfid49eD+WivpfY7KT8gbAzBA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -2365,6 +2474,42 @@ "dev": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2412,6 +2557,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2675,6 +2845,13 @@ "node": "*" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, "node_modules/cliui": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", @@ -2749,6 +2926,20 @@ "node": ">=8.0.0" } }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -2758,12 +2949,50 @@ "color-name": "1.1.3" } }, + "node_modules/color-diff": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/color-diff/-/color-diff-1.4.0.tgz", + "integrity": "sha512-4oDB/o78lNdppbaqrg0HjOp7pHmUc+dfCxWKWFnQg6AB/1dkjtBDop3RZht5386cq9xBUDRvDvSCA7WUlM9Jqw==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/colorette": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", @@ -3316,6 +3545,22 @@ "node": ">=0.10.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", @@ -3395,6 +3640,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", @@ -3470,6 +3725,16 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3830,6 +4095,23 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -3919,6 +4201,28 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4015,6 +4319,13 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, "node_modules/glob": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", @@ -4086,6 +4397,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/growl": { "version": "1.10.5", "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", @@ -4277,6 +4595,27 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", @@ -4321,6 +4660,13 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -4696,6 +5042,16 @@ "node": "^10.14.2 || >=12.0.0" } }, + "node_modules/js-graph-algorithms": { + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/js-graph-algorithms/-/js-graph-algorithms-1.0.18.tgz", + "integrity": "sha512-Gu1wtWzXBzGeye/j9BuyplGHscwqKRZodp/0M1vyBc19RJpblSwKGu099KwwaTx9cRIV+Qupk8xUMfEiGfFqSA==", + "dev": true, + "license": "MIT", + "bin": { + "js-graphs": "src/jsgraphs.js" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4761,6 +5117,16 @@ "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", "dev": true }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -5149,6 +5515,25 @@ "integrity": "sha512-gKO5uExCXvSm6zbF562EvM+rd1kQDnB9AZBbiQVzf1ZmdDpxUSvpnAaVOP83N/31mRK8Ml8/VE8DMvsAZQ+7wg==", "dev": true }, + "node_modules/looks-same": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/looks-same/-/looks-same-9.0.1.tgz", + "integrity": "sha512-V+vsT22nLIUdmvxr6jxsbafpJaZvLFnwZhV7BbmN38+v6gL+/BaHnwK9z5UURhDNSOrj3baOgbwzpjINqoZCpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-diff": "^1.1.0", + "fs-extra": "^8.1.0", + "js-graph-algorithms": "1.0.18", + "lodash": "^4.17.3", + "nested-error-stacks": "^2.1.0", + "parse-color": "^1.0.0", + "sharp": "0.32.6" + }, + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/loupe": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", @@ -5626,6 +6011,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5679,6 +6077,13 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, "node_modules/mlly": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.1.0.tgz", @@ -5860,6 +6265,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -5870,6 +6282,13 @@ "node": ">= 0.6" } }, + "node_modules/nested-error-stacks": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz", + "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==", + "dev": true, + "license": "MIT" + }, "node_modules/nise": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz", @@ -5892,6 +6311,39 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true, + "license": "MIT" + }, "node_modules/node-environment-flags": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.5.tgz", @@ -6087,6 +6539,22 @@ "node": ">=6" } }, + "node_modules/parse-color": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-color/-/parse-color-1.0.0.tgz", + "integrity": "sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "~0.5.0" + } + }, + "node_modules/parse-color/node_modules/color-convert": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", + "integrity": "sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==", + "dev": true + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -6300,6 +6768,78 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -6335,6 +6875,17 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -6442,6 +6993,22 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -6716,6 +7283,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -7002,6 +7578,43 @@ "dev": true, "license": "ISC" }, + "node_modules/sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "engines": { + "node": ">=14.15.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7147,6 +7760,70 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sinon": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.5.0.tgz", @@ -7406,6 +8083,20 @@ "readable-stream": "2" } }, + "node_modules/streamx": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -7584,6 +8275,33 @@ "node": ">=12.17" } }, + "node_modules/tar-fs": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", + "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/terser": { "version": "5.16.8", "resolved": "https://registry.npmjs.org/terser/-/terser-5.16.8.tgz", @@ -7614,6 +8332,16 @@ "node": ">=0.4.0" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -7741,6 +8469,19 @@ "node": ">=0.6.x" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -7921,6 +8662,16 @@ "node": ">=4" } }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -10032,6 +10783,12 @@ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, + "b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "dev": true + }, "babel-plugin-dynamic-import-node": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz", @@ -10077,6 +10834,58 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "dev": true, + "optional": true + }, + "bare-fs": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.2.tgz", + "integrity": "sha512-8wSeOia5B7LwD4+h465y73KOdj5QHsbbuoUfPBi+pXgFJIPuG7SsiOdJuijWMyfid49eD+WivpfY7KT8gbAzBA==", + "dev": true, + "optional": true, + "requires": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4" + } + }, + "bare-os": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", + "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "dev": true, + "optional": true + }, + "bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "optional": true, + "requires": { + "bare-os": "^3.0.1" + } + }, + "bare-stream": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", + "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "dev": true, + "optional": true, + "requires": { + "streamx": "^2.21.0" + } + }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true + }, "basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -10092,6 +10901,38 @@ "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", "dev": true }, + "bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "requires": { + "require-from-string": "^2.0.2" + } + }, + "bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "requires": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + } + } + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -10130,6 +10971,16 @@ "node-releases": "^1.1.71" } }, + "buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -10310,6 +11161,12 @@ "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", "dev": true }, + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, "cliui": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", @@ -10368,6 +11225,33 @@ "type-is": "^1.6.16" } }, + "color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dev": true, + "requires": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "dependencies": { + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + } + } + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -10377,12 +11261,28 @@ "color-name": "1.1.3" } }, + "color-diff": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/color-diff/-/color-diff-1.4.0.tgz", + "integrity": "sha512-4oDB/o78lNdppbaqrg0HjOp7pHmUc+dfCxWKWFnQg6AB/1dkjtBDop3RZht5386cq9xBUDRvDvSCA7WUlM9Jqw==", + "dev": true + }, "color-name": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", "dev": true }, + "color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dev": true, + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, "colorette": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", @@ -10766,6 +11666,15 @@ } } }, + "decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "requires": { + "mimic-response": "^3.1.0" + } + }, "deep-eql": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", @@ -10820,6 +11729,12 @@ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "dev": true }, + "detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true + }, "diff": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", @@ -10876,6 +11791,15 @@ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", "dev": true }, + "end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "requires": { + "once": "^1.4.0" + } + }, "entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -11140,6 +12064,18 @@ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true }, + "expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true + }, + "fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true + }, "fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -11202,6 +12138,23 @@ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "dev": true }, + "fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -11267,6 +12220,12 @@ "es-object-atoms": "^1.0.0" } }, + "github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true + }, "glob": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", @@ -11316,6 +12275,12 @@ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, "growl": { "version": "1.10.5", "resolved": "https://registry.npmjs.org/growl/-/growl-1.10.5.tgz", @@ -11449,6 +12414,12 @@ "safer-buffer": ">= 2.1.2 < 3" } }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true + }, "ignore": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz", @@ -11483,6 +12454,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, "interpret": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", @@ -11740,6 +12717,12 @@ "skip-regex": "^1.0.2" } }, + "js-graph-algorithms": { + "version": "1.0.18", + "resolved": "https://registry.npmjs.org/js-graph-algorithms/-/js-graph-algorithms-1.0.18.tgz", + "integrity": "sha512-Gu1wtWzXBzGeye/j9BuyplGHscwqKRZodp/0M1vyBc19RJpblSwKGu099KwwaTx9cRIV+Qupk8xUMfEiGfFqSA==", + "dev": true + }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -11789,6 +12772,15 @@ "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==", "dev": true }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.6" + } + }, "jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -12100,6 +13092,21 @@ "integrity": "sha512-gKO5uExCXvSm6zbF562EvM+rd1kQDnB9AZBbiQVzf1ZmdDpxUSvpnAaVOP83N/31mRK8Ml8/VE8DMvsAZQ+7wg==", "dev": true }, + "looks-same": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/looks-same/-/looks-same-9.0.1.tgz", + "integrity": "sha512-V+vsT22nLIUdmvxr6jxsbafpJaZvLFnwZhV7BbmN38+v6gL+/BaHnwK9z5UURhDNSOrj3baOgbwzpjINqoZCpA==", + "dev": true, + "requires": { + "color-diff": "^1.1.0", + "fs-extra": "^8.1.0", + "js-graph-algorithms": "1.0.18", + "lodash": "^4.17.3", + "nested-error-stacks": "^2.1.0", + "parse-color": "^1.0.0", + "sharp": "0.32.6" + } + }, "loupe": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", @@ -12449,6 +13456,12 @@ } } }, + "mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true + }, "min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -12490,6 +13503,12 @@ "minimist": "^1.2.5" } }, + "mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "mlly": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.1.0.tgz", @@ -12636,12 +13655,24 @@ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true }, + "napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true + }, "negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "dev": true }, + "nested-error-stacks": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz", + "integrity": "sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==", + "dev": true + }, "nise": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/nise/-/nise-1.5.3.tgz", @@ -12666,6 +13697,29 @@ } } }, + "node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "dev": true, + "requires": { + "semver": "^7.3.5" + }, + "dependencies": { + "semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true + } + } + }, + "node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", + "dev": true + }, "node-environment-flags": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/node-environment-flags/-/node-environment-flags-1.0.5.tgz", @@ -12813,6 +13867,23 @@ "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "dev": true }, + "parse-color": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-color/-/parse-color-1.0.0.tgz", + "integrity": "sha512-fuDHYgFHJGbpGMgw9skY/bj3HL/Jrn4l/5rSspy00DoT4RyLnDcRvPxdZ+r6OFwIsgAuhDh4I09tAId4mI12bw==", + "dev": true, + "requires": { + "color-convert": "~0.5.0" + }, + "dependencies": { + "color-convert": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-0.5.3.tgz", + "integrity": "sha512-RwBeO/B/vZR3dfKL1ye/vx8MHZ40ugzpyfeVG5GsiuGnrlMWe2o8wxBbLCpw9CsxV+wHuzYlCiWnybrIA0ling==", + "dev": true + } + } + }, "parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -12955,6 +14026,64 @@ "source-map-js": "^1.2.1" } }, + "prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "dev": true, + "requires": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "dependencies": { + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "dev": true, + "requires": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "requires": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + } + } + } + }, "pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -12980,6 +14109,16 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true }, + "pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dev": true, + "requires": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -13046,6 +14185,18 @@ } } }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, "react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -13270,6 +14421,11 @@ "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", "dev": true }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, "require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -13484,6 +14640,30 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "dev": true }, + "sharp": { + "version": "0.32.6", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", + "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "dev": true, + "requires": { + "color": "^4.2.3", + "detect-libc": "^2.0.2", + "node-addon-api": "^6.1.0", + "prebuild-install": "^7.1.1", + "semver": "^7.5.4", + "simple-get": "^4.0.1", + "tar-fs": "^3.0.4", + "tunnel-agent": "^0.6.0" + }, + "dependencies": { + "semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true + } + } + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13580,6 +14760,40 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true + }, + "simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "requires": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dev": true, + "requires": { + "is-arrayish": "^0.3.1" + }, + "dependencies": { + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "dev": true + } + } + }, "sinon": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.5.0.tgz", @@ -13787,6 +15001,17 @@ "readable-stream": "2" } }, + "streamx": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "dev": true, + "requires": { + "bare-events": "^2.2.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -13925,6 +15150,29 @@ "wordwrapjs": "^5.1.0" } }, + "tar-fs": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz", + "integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==", + "dev": true, + "requires": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0", + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + } + }, + "tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "requires": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "terser": { "version": "5.16.8", "resolved": "https://registry.npmjs.org/terser/-/terser-5.16.8.tgz", @@ -13945,6 +15193,15 @@ } } }, + "text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "requires": { + "b4a": "^1.6.4" + } + }, "through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -14037,6 +15294,15 @@ "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", "dev": true }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, "type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -14162,6 +15428,12 @@ "integrity": "sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==", "dev": true }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 622309ef..b18f37bc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "author": "Metrological, Bas van Meurs ", "name": "@lightningjs/core", - "version": "2.14.1", + "version": "2.16.0-beta.6", "license": "Apache-2.0", "type": "module", "types": "dist/src/index.d.ts", @@ -16,7 +16,12 @@ "./inspector": { "types": "./devtools/lightning-inspect.d.ts", "import": "./devtools/lightning-inspect.js", - "require": "./devtools/lightning-inspect.js" + "require": "./devtools/lightning-inspect.es5.js" + }, + "./bidiTokenizer": { + "types": "./dist/src/textures/bidiTokenizer.d.ts", + "import": "./dist/src/textures/bidiTokenizer.js", + "require": "./dist/bidiTokenizer.es5.js" }, "./package.json": "./package.json" }, @@ -29,12 +34,13 @@ "devtools/**" ], "scripts": { - "build": "shx mkdir -p dist && shx rm -fr dist/* && concurrently -c \"auto\" \"npm:build:lightning\" \"npm:build:lightning.min\" \"npm:build:lightning.es5\" \"npm:build:lightning.es5.min\" \"npm:build:lightning-inspect.es5\" && npm run src-to-dist", + "build": "shx mkdir -p dist && shx rm -fr dist/* && concurrently -c \"auto\" \"npm:build:lightning\" \"npm:build:lightning.min\" \"npm:build:lightning.es5\" \"npm:build:lightning.es5.min\" \"npm:build:lightning-inspect.es5\" \"npm:build:bidi-tokenizer.es5\" && npm run src-to-dist", "release": "npm run build && npm publish --access public", "typedoc": "typedoc --tsconfig tsconfig.typedoc.json", "tsd": "tsd", "src-to-dist": "node ./scripts/src-to-dist.cjs", "build:lightning-inspect.es5": "cross-env BUILD_INSPECTOR=true BUILD_ES5=true vite build --mode production", + "build:bidi-tokenizer.es5": "cross-env BUILD_BIDI_TOKENIZER=true BUILD_ES5=true vite build --mode production", "build:lightning": "vite build --mode production", "build:lightning.min": "cross-env BUILD_MINIFY=true vite build --mode production", "build:lightning.es5": "cross-env BUILD_ES5=true vite build --mode production", @@ -64,6 +70,7 @@ "concurrently": "^7.6.0", "cross-env": "^7.0.3", "local-web-server": "^5.4.0", + "looks-same": "^9.0.1", "mocha": "^6.2.1", "rollup-plugin-cleanup": "^3.1.1", "shelljs": "^0.8.5", @@ -75,5 +82,8 @@ "typescript": "~5.3.3", "vite": "^4.0.4", "vitest": "^0.27.2" + }, + "dependencies": { + "bidi-js": "^1.0.3" } } 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/index.ts b/src/index.ts index dbb427d3..b4b2c712 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,9 @@ import ObjectListWrapper from "./tools/ObjectListWrapper.mjs"; import RectangleTexture from "./textures/RectangleTexture.mjs"; import NoiseTexture from "./textures/NoiseTexture.mjs"; import TextTexture from "./textures/TextTexture.mjs"; +import TextTokenizer from "./textures/TextTokenizer.js"; +import TextTextureRenderer from "./textures/TextTextureRenderer.js"; +import TextTextureRendererAdvanced from "./textures/TextTextureRendererAdvanced.js"; import ImageTexture from "./textures/ImageTexture.mjs"; import HtmlTexture from "./textures/HtmlTexture.mjs"; import StaticTexture from "./textures/StaticTexture.mjs"; @@ -123,6 +126,9 @@ export { RectangleTexture, NoiseTexture, TextTexture, + TextTextureRenderer, + TextTextureRendererAdvanced, + TextTokenizer, ImageTexture, HtmlTexture, StaticTexture, diff --git a/src/renderer/c2d/shaders/DefaultShader.mjs b/src/renderer/c2d/shaders/DefaultShader.mjs index 0534f895..f5ce48db 100644 --- a/src/renderer/c2d/shaders/DefaultShader.mjs +++ b/src/renderer/c2d/shaders/DefaultShader.mjs @@ -76,6 +76,31 @@ export default class DefaultShader extends C2dShader { const sourceH = (stc ? 1 : (vc._bry - vc._uly)) * tx.h; let colorize = !white; + + // Handle horizontal and/or vertical mirroring + let drawSourceX = sourceX; + let drawSourceW = sourceW; + let destX = 0; + let drawSourceY = sourceY; + let drawSourceH = sourceH; + let destY = 0; + let mirroring = sourceW < 0 || sourceH < 0; + if (mirroring) { + ctx.save(); + if (sourceW < 0) { + drawSourceX = sourceX + sourceW; + drawSourceW = -sourceW; + ctx.scale(-1, 1); + destX = -vc.w; + } + if (sourceH < 0) { + drawSourceY = sourceY + sourceH; + drawSourceH = -sourceH; + ctx.scale(1, -1); + destY = -vc.h; + } + } + if (colorize) { // @todo: cache the tint texture for better performance. @@ -96,10 +121,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, drawSourceY, drawSourceW, drawSourceH, destX, destY, 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, drawSourceY, drawSourceW, drawSourceH, destX, destY, vc.w, vc.h); + } + + // cancel mirroring transform + if (mirroring) { + ctx.restore(); } this._afterDrawEl(info); ctx.globalAlpha = 1.0; diff --git a/src/textures/TextTexture.d.mts b/src/textures/TextTexture.d.mts index d688578f..d7ccd7e5 100644 --- a/src/textures/TextTexture.d.mts +++ b/src/textures/TextTexture.d.mts @@ -18,8 +18,8 @@ */ import Stage from "../tree/Stage.mjs"; import Texture from "../tree/Texture.mjs"; -import TextTextureRenderer from "./TextTextureRenderer.mjs"; -import TextTextureRendererAdvanced from "./TextTextureRendererAdvanced.mjs"; +import TextTextureRenderer from "./TextTextureRenderer.js"; +import TextTextureRendererAdvanced from "./TextTextureRendererAdvanced.js"; declare namespace TextTexture { /** @@ -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 * @@ -454,7 +461,10 @@ declare namespace TextTexture { declare class TextTexture extends Texture implements Required> { constructor(stage: Stage); - protected static renderer( + public static forceAdvancedRenderer: boolean; + public static allowTextTruncation: boolean; + + public static renderer( stage: Stage, canvas: HTMLCanvasElement, settings: TextTexture.Settings @@ -469,6 +479,9 @@ declare class TextTexture extends Texture implements Required; - /** - * realNewLines - length of each resulting line - */ - n: Array; - } -} - -declare class TextTextureRenderer { - constructor( - stage: Stage, - canvas: HTMLCanvasElement, - settings: Required, - ); - - private _settings: Required; - renderInfo?: TextureSource.RenderInfo; - - _calculateRenderInfo(): TextureSource.RenderInfo; - draw(): Promise | void; - getPrecision(): number; - measureText(word: string, space?: number): number; - setFontProperties(): void; - wrapText( - text: string, - wordWrapWidth: number, - letterSpacing: number, - indent: number, - ): TextTextureRenderer.LineInfo; - wrapWord(text: string, wordWrapWidth: number, suffix?: string): string; -} - -export default TextTextureRenderer; diff --git a/src/textures/TextTextureRenderer.mjs b/src/textures/TextTextureRenderer.mjs deleted file mode 100644 index c2201619..00000000 --- a/src/textures/TextTextureRenderer.mjs +++ /dev/null @@ -1,441 +0,0 @@ -/* - * 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 StageUtils from "../tree/StageUtils.mjs"; -import Utils from "../tree/Utils.mjs"; -import { getFontSetting, measureText, wrapText } from "./TextTextureRendererUtils.mjs"; - -export default class TextTextureRenderer { - - constructor(stage, canvas, settings) { - this._stage = stage; - this._canvas = canvas; - this._context = this._canvas.getContext('2d'); - this._settings = settings; - } - - getPrecision() { - return this._settings.precision; - }; - - setFontProperties() { - this._context.font = getFontSetting( - this._settings.fontFace, - this._settings.fontStyle, - this._settings.fontSize, - this.getPrecision(), - this._stage.getOption('defaultFontFace'), - ); - this._context.textBaseline = this._settings.textBaseline; - this._context.direction = this._settings.rtl ? "rtl" : "ltr"; - }; - - _load() { - if (Utils.isWeb && document.fonts) { - const fontSetting = getFontSetting( - this._settings.fontFace, - this._settings.fontStyle, - this._settings.fontSize, - this.getPrecision(), - this._stage.getOption('defaultFontFace') - ); - try { - if (!document.fonts.check(fontSetting, this._settings.text)) { - // Use a promise that waits for loading. - return document.fonts.load(fontSetting, this._settings.text).catch(err => { - // Just load the fallback font. - console.warn('[Lightning] Font load error', err, fontSetting); - }).then(() => { - if (!document.fonts.check(fontSetting, this._settings.text)) { - console.warn('[Lightning] Font not found', fontSetting); - } - }); - } - } catch(e) { - console.warn("[Lightning] Can't check font loading for " + fontSetting); - } - } - } - - draw() { - // We do not use a promise so that loading is performed syncronous when possible. - const loadPromise = this._load(); - if (!loadPromise) { - return Utils.isSpark ? this._stage.platform.drawText(this) : this._draw(); - } else { - return loadPromise.then(() => { - return Utils.isSpark ? this._stage.platform.drawText(this) : this._draw(); - }); - } - } - - _calculateRenderInfo() { - let renderInfo = {}; - - const precision = this.getPrecision(); - - const paddingLeft = this._settings.paddingLeft * precision; - const paddingRight = this._settings.paddingRight * precision; - const fontSize = this._settings.fontSize * precision; - let offsetY = this._settings.offsetY === null ? null : (this._settings.offsetY * precision); - let lineHeight = this._settings.lineHeight * precision; - const w = this._settings.w * precision; - const h = this._settings.h * precision; - let wordWrapWidth = this._settings.wordWrapWidth * precision; - const cutSx = this._settings.cutSx * precision; - const cutEx = this._settings.cutEx * precision; - const cutSy = this._settings.cutSy * precision; - const cutEy = this._settings.cutEy * precision; - const letterSpacing = (this._settings.letterSpacing || 0) * precision; - const textIndent = this._settings.textIndent * precision; - - // Set font properties. - this.setFontProperties(); - - // Total width. - let width = w || this._stage.getOption('w'); - - // Inner width. - let innerWidth = width - (paddingLeft); - if (innerWidth < 10) { - width += (10 - innerWidth); - innerWidth = 10; - } - - if (!wordWrapWidth) { - wordWrapWidth = innerWidth - } - - // Text overflow - if (this._settings.textOverflow && !this._settings.wordWrap) { - let suffix; - switch (this._settings.textOverflow) { - case 'clip': - suffix = ''; - break; - case 'ellipsis': - suffix = this._settings.maxLinesSuffix; - break; - default: - suffix = this._settings.textOverflow; - } - this._settings.text = this.wrapWord(this._settings.text, wordWrapWidth - textIndent, suffix) - } - - // word wrap - // preserve original text - let linesInfo; - if (this._settings.wordWrap) { - linesInfo = this.wrapText(this._settings.text, wordWrapWidth, letterSpacing, textIndent); - } else { - linesInfo = {l: this._settings.text.split(/(?:\r\n|\r|\n)/), n: []}; - let i, n = linesInfo.l.length; - for (let i = 0; i < n - 1; i++) { - linesInfo.n.push(i); - } - } - let lines = linesInfo.l; - - if (this._settings.maxLines && lines.length > this._settings.maxLines) { - let usedLines = lines.slice(0, this._settings.maxLines); - - let otherLines = null; - if (this._settings.maxLinesSuffix) { - // Wrap again with max lines suffix enabled. - let w = this._settings.maxLinesSuffix ? this.measureText(this._settings.maxLinesSuffix) : 0; - let al = this.wrapText(usedLines[usedLines.length - 1], wordWrapWidth - w, letterSpacing, textIndent); - usedLines[usedLines.length - 1] = al.l[0] + this._settings.maxLinesSuffix; - otherLines = [al.l.length > 1 ? al.l[1] : '']; - } else { - otherLines = ['']; - } - - // Re-assemble the remaining text. - let i, n = lines.length; - let j = 0; - let m = linesInfo.n.length; - for (i = this._settings.maxLines; i < n; i++) { - otherLines[j] += (otherLines[j] ? " " : "") + lines[i]; - if (i + 1 < m && linesInfo.n[i + 1]) { - j++; - } - } - - renderInfo.remainingText = otherLines.join("\n"); - - renderInfo.moreTextLines = true; - - lines = usedLines; - } else { - renderInfo.moreTextLines = false; - renderInfo.remainingText = ""; - } - - // calculate text width - let maxLineWidth = 0; - let lineWidths = []; - for (let i = 0; i < lines.length; i++) { - let lineWidth = this.measureText(lines[i], letterSpacing) + (i === 0 ? textIndent : 0); - lineWidths.push(lineWidth); - maxLineWidth = Math.max(maxLineWidth, lineWidth); - } - - renderInfo.lineWidths = lineWidths; - - if (!w) { - // Auto-set width to max text length. - width = maxLineWidth + paddingLeft + paddingRight; - innerWidth = maxLineWidth; - } - - // calculate text height - lineHeight = lineHeight || fontSize; - - let height; - if (h) { - height = h; - } else { - const baselineOffset = (this._settings.textBaseline != 'bottom') ? 0.5 * fontSize : 0; - height = lineHeight * (lines.length - 1) + baselineOffset + Math.max(lineHeight, fontSize) + offsetY; - } - - if (offsetY === null) { - offsetY = fontSize; - } - - renderInfo.w = width; - renderInfo.h = height; - renderInfo.lines = lines; - renderInfo.precision = precision; - - if (!width) { - // To prevent canvas errors. - width = 1; - } - - if (!height) { - // To prevent canvas errors. - height = 1; - } - - if (cutSx || cutEx) { - width = Math.min(width, cutEx - cutSx); - } - - if (cutSy || cutEy) { - height = Math.min(height, cutEy - cutSy); - } - - renderInfo.width = width; - renderInfo.innerWidth = innerWidth; - renderInfo.height = height; - renderInfo.fontSize = fontSize; - renderInfo.cutSx = cutSx; - renderInfo.cutSy = cutSy; - renderInfo.cutEx = cutEx; - renderInfo.cutEy = cutEy; - renderInfo.lineHeight = lineHeight; - renderInfo.lineWidths = lineWidths; - renderInfo.offsetY = offsetY; - renderInfo.paddingLeft = paddingLeft; - renderInfo.paddingRight = paddingRight; - renderInfo.letterSpacing = letterSpacing; - renderInfo.textIndent = textIndent; - - return renderInfo; - } - - _draw() { - const renderInfo = this._calculateRenderInfo(); - const precision = this.getPrecision(); - - // Add extra margin to prevent issue with clipped text when scaling. - this._canvas.width = Math.ceil(renderInfo.width + this._stage.getOption('textRenderIssueMargin')); - this._canvas.height = Math.ceil(renderInfo.height); - - // Canvas context has been reset. - this.setFontProperties(); - - if (renderInfo.fontSize >= 128) { - // WpeWebKit bug: must force compositing because cairo-traps-compositor will not work with text first. - this._context.globalAlpha = 0.01; - this._context.fillRect(0, 0, 0.01, 0.01); - this._context.globalAlpha = 1.0; - } - - if (renderInfo.cutSx || renderInfo.cutSy) { - this._context.translate(-renderInfo.cutSx, -renderInfo.cutSy); - } - - let linePositionX; - let linePositionY; - - let drawLines = []; - - // Draw lines line by line. - for (let i = 0, n = renderInfo.lines.length; i < n; i++) { - linePositionX = i === 0 ? renderInfo.textIndent : 0; - - // By default, text is aligned to top - linePositionY = (i * renderInfo.lineHeight) + renderInfo.offsetY; - - if (this._settings.verticalAlign == 'middle') { - linePositionY += (renderInfo.lineHeight - renderInfo.fontSize) / 2; - } else if (this._settings.verticalAlign == 'bottom') { - linePositionY += renderInfo.lineHeight - renderInfo.fontSize; - } - - if (this._settings.textAlign === 'right') { - linePositionX += (renderInfo.innerWidth - renderInfo.lineWidths[i]); - } else if (this._settings.textAlign === 'center') { - linePositionX += ((renderInfo.innerWidth - renderInfo.lineWidths[i]) / 2); - } - linePositionX += renderInfo.paddingLeft; - if (this._settings.rtl) { - linePositionX += renderInfo.lineWidths[i]; - } - - drawLines.push({text: renderInfo.lines[i], x: linePositionX, y: linePositionY, w: renderInfo.lineWidths[i]}); - } - - // Highlight. - if (this._settings.highlight) { - let color = this._settings.highlightColor || 0x00000000; - - let hlHeight = (this._settings.highlightHeight * precision || renderInfo.fontSize * 1.5); - const offset = this._settings.highlightOffset * precision; - const hlPaddingLeft = (this._settings.highlightPaddingLeft !== null ? this._settings.highlightPaddingLeft * precision : renderInfo.paddingLeft); - const hlPaddingRight = (this._settings.highlightPaddingRight !== null ? this._settings.highlightPaddingRight * precision : renderInfo.paddingRight); - - this._context.fillStyle = StageUtils.getRgbaString(color); - for (let i = 0; i < drawLines.length; i++) { - let drawLine = drawLines[i]; - this._context.fillRect((drawLine.x - hlPaddingLeft), (drawLine.y - renderInfo.offsetY + offset), (drawLine.w + hlPaddingRight + hlPaddingLeft), hlHeight); - } - } - - // Text shadow. - let prevShadowSettings = null; - if (this._settings.shadow) { - prevShadowSettings = [this._context.shadowColor, this._context.shadowOffsetX, this._context.shadowOffsetY, this._context.shadowBlur]; - - this._context.shadowColor = StageUtils.getRgbaString(this._settings.shadowColor); - this._context.shadowOffsetX = this._settings.shadowOffsetX * precision; - this._context.shadowOffsetY = this._settings.shadowOffsetY * precision; - this._context.shadowBlur = this._settings.shadowBlur * precision; - } - - this._context.fillStyle = StageUtils.getRgbaString(this._settings.textColor); - for (let i = 0, n = drawLines.length; i < n; i++) { - let drawLine = drawLines[i]; - - if (renderInfo.letterSpacing === 0) { - this._context.fillText(drawLine.text, drawLine.x, drawLine.y); - } else { - const textSplit = drawLine.text.split(''); - let x = drawLine.x; - for (let i = 0, j = textSplit.length; i < j; i++) { - this._context.fillText(textSplit[i], x, drawLine.y); - x += this.measureText(textSplit[i], renderInfo.letterSpacing); - } - } - } - - if (prevShadowSettings) { - this._context.shadowColor = prevShadowSettings[0]; - this._context.shadowOffsetX = prevShadowSettings[1]; - this._context.shadowOffsetY = prevShadowSettings[2]; - this._context.shadowBlur = prevShadowSettings[3]; - } - - if (renderInfo.cutSx || renderInfo.cutSy) { - this._context.translate(renderInfo.cutSx, renderInfo.cutSy); - } - - this.renderInfo = renderInfo; - }; - - wrapWord(word, wordWrapWidth, suffix) { - const suffixWidth = this.measureText(suffix); - const wordLen = word.length - const wordWidth = this.measureText(word); - - /* If word fits wrapWidth, do nothing */ - if (wordWidth <= wordWrapWidth) { - return word; - } - - /* Make initial guess for text cuttoff */ - let cutoffIndex = Math.floor((wordWrapWidth * wordLen) / wordWidth); - let truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth; - - /* In case guess was overestimated, shrink it letter by letter. */ - if (truncWordWidth > wordWrapWidth) { - while (cutoffIndex > 0) { - truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth; - if (truncWordWidth > wordWrapWidth) { - cutoffIndex -= 1; - } else { - break; - } - } - - /* In case guess was underestimated, extend it letter by letter. */ - } else { - while (cutoffIndex < wordLen) { - truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth; - if (truncWordWidth < wordWrapWidth) { - cutoffIndex += 1; - } else { - // Finally, when bound is crossed, retract last letter. - cutoffIndex -=1; - break; - } - } - } - - /* If wrapWidth is too short to even contain suffix alone, return empty string */ - return word.substring(0, cutoffIndex) + (wordWrapWidth >= suffixWidth ? suffix : ''); - } - - /** - * See {@link wrapText} - * - * @param {string} text - * @param {number} wordWrapWidth - * @param {number} letterSpacing - * @param {number} indent - * @returns - */ - wrapText(text, wordWrapWidth, letterSpacing, indent = 0) { - return wrapText(this._context, text, wordWrapWidth, letterSpacing, indent); - }; - - /** - * See {@link measureText} - * - * @param {string} word - * @param {number} space - * @returns {number} - */ - measureText(word, space = 0) { - return measureText(this._context, word, space); - } - -} diff --git a/src/textures/TextTextureRenderer.ts b/src/textures/TextTextureRenderer.ts new file mode 100644 index 00000000..1801ca49 --- /dev/null +++ b/src/textures/TextTextureRenderer.ts @@ -0,0 +1,497 @@ +/* + * 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 StageUtils from "../tree/StageUtils.mjs"; +import type Stage from "../tree/Stage.mjs"; +import type TextTexture from "./TextTexture.mjs"; +import type { + IRenderInfo, + ILinesInfo, + ILineInfo, + IDrawLineInfo, +} from "./TextTextureRendererTypes.js"; +import { + getFontSetting, + getSuffix, + measureText, + wrapText, +} from "./TextTextureRendererUtils.js"; + +export default class TextTextureRenderer { + protected _stage: Stage; + protected _canvas: HTMLCanvasElement; + protected _context: CanvasRenderingContext2D; + protected _settings: Required; + protected prevShadowSettings: [string, number, number, number] | null = null; + public renderInfo: IRenderInfo | undefined; + + constructor( + stage: Stage, + canvas: HTMLCanvasElement, + settings: Required + ) { + this._stage = stage; + this._canvas = canvas; + this._context = this._canvas.getContext("2d")!; + this._settings = settings; + } + + setFontProperties() { + this._context.font = getFontSetting( + this._settings.fontFace, + this._settings.fontStyle, + this._settings.fontSize, + this._stage.getRenderPrecision(), + this._stage.getOption("defaultFontFace") + ); + this._context.textBaseline = this._settings.textBaseline; + } + + _load() { + if (/*Utils.isWeb &&*/ document.fonts) { + const fontSetting = getFontSetting( + this._settings.fontFace, + this._settings.fontStyle, + this._settings.fontSize, + this._stage.getRenderPrecision(), + this._stage.getOption("defaultFontFace") + ); + try { + if (!document.fonts.check(fontSetting, this._settings.text)) { + // Use a promise that waits for loading. + return document.fonts + .load(fontSetting, this._settings.text) + .catch((err) => { + // Just load the fallback font. + console.warn("[Lightning] Font load error", err, fontSetting); + }) + .then(() => { + if (!document.fonts.check(fontSetting, this._settings.text)) { + console.warn("[Lightning] Font not found", fontSetting); + } + }); + } + } catch (e) { + console.warn("[Lightning] Can't check font loading for " + fontSetting); + } + } + } + + draw() { + // We do not use a promise so that loading is performed syncronous when possible. + const loadPromise = this._load(); + if (!loadPromise) { + return /*Utils.isSpark ? this._stage.platform.drawText(this) :*/ this._draw(); + } else { + return loadPromise.then(() => { + return /*Utils.isSpark ? this._stage.platform.drawText(this) :*/ this._draw(); + }); + } + } + + _calculateRenderInfo(): IRenderInfo { + const renderInfo: Partial = {}; + + const precision = this._stage.getRenderPrecision(); + const paddingLeft = this._settings.paddingLeft * precision; + const paddingRight = this._settings.paddingRight * precision; + const fontSize = this._settings.fontSize * precision; + let offsetY = + this._settings.offsetY === null + ? null + : this._settings.offsetY * precision; + let lineHeight = (this._settings.lineHeight || fontSize) * precision; + const w = this._settings.w * precision; + const h = this._settings.h * precision; + let wordWrapWidth = this._settings.wordWrapWidth * precision; + const cutSx = this._settings.cutSx * precision; + const cutEx = this._settings.cutEx * precision; + const cutSy = this._settings.cutSy * precision; + const cutEy = this._settings.cutEy * precision; + const letterSpacing = (this._settings.letterSpacing || 0) * precision; + const textIndent = this._settings.textIndent * precision; + const text = this._settings.text; + const maxLines = this._settings.maxLines; + + // Set font properties. + this.setFontProperties(); + + // Total width. + let width = w || this._stage.getOption("w"); + + // Inner width. + let innerWidth = width - paddingLeft; + if (innerWidth < 10) { + width += 10 - innerWidth; + innerWidth = 10; + } + + if (!wordWrapWidth) { + wordWrapWidth = innerWidth; + } + + // shape text + let linesInfo: ILinesInfo; + if (this._settings.wordWrap || this._settings.textOverflow) { + linesInfo = this.wrapText(text, wordWrapWidth); + } else { + const textLines = text.split(/(?:\r\n|\r|\n)/); + if (maxLines && textLines.length > maxLines) { + linesInfo = { + l: this.measureLines(textLines.slice(0, maxLines)), + r: textLines.slice(maxLines), + }; + } else { + linesInfo = { + l: this.measureLines(textLines), + r: [], + }; + } + } + + if (linesInfo.r?.length) { + renderInfo.remainingText = linesInfo.r.join("\n"); + renderInfo.moreTextLines = true; + } else { + renderInfo.remainingText = ""; + renderInfo.moreTextLines = false; + } + + // calculate text width + const lines = linesInfo.l; + let maxLineWidth = 0; + let lineWidths = []; + for (let i = 0; i < lines.length; i++) { + const width = lines[i]!.width; + lineWidths.push(width); + maxLineWidth = Math.max(maxLineWidth, width); + } + + renderInfo.lineWidths = lineWidths; + + if (!w) { + // Auto-set width to max text length. + width = Math.min(maxLineWidth + paddingLeft + paddingRight, 2048); + innerWidth = maxLineWidth; + } + + // calculate canvas height + const textBaseline = this._settings.textBaseline; + const verticalAlign = this._settings.verticalAlign; + let height; + if (h) { + height = h; + } else { + const baselineOffset = + this._settings.textBaseline !== "bottom" ? fontSize * 0.5 : 0; + height = + lineHeight * (lines.length - 1) + + baselineOffset + + Math.max(lineHeight, fontSize) + + (offsetY || 0); + } + + // calculate vertical draw offset + if (offsetY === null) { + if (textBaseline === "top") offsetY = 0; + else if (textBaseline === "alphabetic") offsetY = fontSize; + else offsetY = fontSize; + } + if (verticalAlign === "middle") { + offsetY += (lineHeight - fontSize) / 2; + } else if (verticalAlign === "bottom") { + offsetY += lineHeight - fontSize; + } + + const rtl = this._settings.rtl; + let textAlign = this._settings.textAlign; + if (rtl) { + if (textAlign === "left") textAlign = "right"; + else if (textAlign === "right") textAlign = "left"; + } + + let linePositionX; + let linePositionY; + const drawLines: IDrawLineInfo[] = []; + + // Layout lines + for (let i = 0, n = lines.length; i < n; i++) { + const lineWidth = lineWidths[i] || 0; + + linePositionX = rtl ? paddingRight : paddingLeft; + if (i === 0 && !rtl) { + linePositionX += textIndent; + } + + if (textAlign === "right") { + linePositionX += innerWidth - lineWidth; + } else if (textAlign === "center") { + linePositionX += (innerWidth - lineWidth) / 2; + } + + linePositionY = i * lineHeight + offsetY; + + drawLines.push({ + info: lines[i]!, + x: linePositionX, + y: linePositionY, + w: lineWidth, + }); + } + + renderInfo.w = width; + renderInfo.h = height; + renderInfo.lines = drawLines; + renderInfo.precision = precision; + + if (!width) { + // To prevent canvas errors. + width = 1; + } + + if (!height) { + // To prevent canvas errors. + height = 1; + } + + if (cutSx || cutEx) { + width = Math.min(width, cutEx - cutSx); + } + + if (cutSy || cutEy) { + height = Math.min(height, cutEy - cutSy); + } + + renderInfo.width = width; + renderInfo.innerWidth = innerWidth; + renderInfo.height = height; + renderInfo.fontSize = fontSize; + renderInfo.cutSx = cutSx; + renderInfo.cutSy = cutSy; + renderInfo.cutEx = cutEx; + renderInfo.cutEy = cutEy; + renderInfo.lineHeight = lineHeight; + renderInfo.lineWidths = lineWidths; + renderInfo.offsetY = offsetY; + renderInfo.paddingLeft = paddingLeft; + renderInfo.paddingRight = paddingRight; + renderInfo.letterSpacing = letterSpacing; + renderInfo.textIndent = textIndent; + + return renderInfo as IRenderInfo; + } + + _draw() { + const renderInfo = this._calculateRenderInfo(); + const precision = renderInfo.precision; + + // Add extra margin to prevent issue with clipped text when scaling. + this._canvas.width = Math.ceil( + renderInfo.width + this._stage.getOption("textRenderIssueMargin") + ); + this._canvas.height = Math.ceil(renderInfo.height); + + // Canvas context has been reset. + this.setFontProperties(); + + if (renderInfo.fontSize >= 128) { + // WpeWebKit bug: must force compositing because cairo-traps-compositor will not work with text first. + this._context.globalAlpha = 0.01; + this._context.fillRect(0, 0, 0.01, 0.01); + this._context.globalAlpha = 1.0; + } + + if (renderInfo.cutSx || renderInfo.cutSy) { + this._context.translate(-renderInfo.cutSx, -renderInfo.cutSy); + } + + + if (this._settings.highlight) { + this._drawHighlight(precision, renderInfo); + } + + if (this._settings.shadow) { + this._drawShadow(precision); + } + + this._drawLines(renderInfo.lines, renderInfo.letterSpacing); + + if (this._settings.shadow) { + this._restoreShadow(); + } + + if (renderInfo.cutSx || renderInfo.cutSy) { + this._context.translate(renderInfo.cutSx, renderInfo.cutSy); + } + + this.renderInfo = renderInfo; + } + + protected _drawLines(drawLines: IDrawLineInfo[], letterSpacing: number) { + const ctx = this._context; + ctx.fillStyle = StageUtils.getRgbaString(this._settings.textColor); + + for (let i = 0, n = drawLines.length; i < n; i++) { + const drawLine = drawLines[i]!; + const y = drawLine.y; + let x = drawLine.x; + const text = drawLine.info.text; + + if (letterSpacing === 0) { + ctx.fillText(text, x, y); + } else { + this._fillTextWithLetterSpacing(ctx, text, x, y, letterSpacing); + } + } + } + + protected _fillTextWithLetterSpacing( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + letterSpacing: number + ) { + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + ctx.fillText(c, x, y); + x += measureText(ctx, c, letterSpacing); + } + } + + protected _restoreShadow() { + const settings = this.prevShadowSettings!; + const ctx = this._context; + ctx.shadowColor = settings[0]; + ctx.shadowOffsetX = settings[1]; + ctx.shadowOffsetY = settings[2]; + ctx.shadowBlur = settings[3]; + this.prevShadowSettings = null; + } + + protected _drawShadow(precision: number) { + const ctx = this._context; + this.prevShadowSettings = [ + ctx.shadowColor, + ctx.shadowOffsetX, + ctx.shadowOffsetY, + ctx.shadowBlur, + ]; + + ctx.shadowColor = StageUtils.getRgbaString(this._settings.shadowColor); + ctx.shadowOffsetX = this._settings.shadowOffsetX * precision; + ctx.shadowOffsetY = this._settings.shadowOffsetY * precision; + ctx.shadowBlur = this._settings.shadowBlur * precision; + } + + protected _drawHighlight( + precision: number, + renderInfo: IRenderInfo + ) { + let color = this._settings.highlightColor || 0x00000000; + + let hlHeight = + this._settings.highlightHeight * precision || renderInfo.fontSize * 1.5; + const offset = this._settings.highlightOffset * precision; + const hlPaddingLeft = + this._settings.highlightPaddingLeft !== null + ? this._settings.highlightPaddingLeft * precision + : renderInfo.paddingLeft; + const hlPaddingRight = + this._settings.highlightPaddingRight !== null + ? this._settings.highlightPaddingRight * precision + : renderInfo.paddingRight; + + this._context.fillStyle = StageUtils.getRgbaString(color); + for (let i = 0; i < renderInfo.lines.length; i++) { + const drawLine = renderInfo.lines[i]!; + this._context.fillRect( + drawLine.x - hlPaddingLeft, + drawLine.y - renderInfo.offsetY + offset, + drawLine.w + hlPaddingRight + hlPaddingLeft, + hlHeight + ); + } + } + + /** + * Simple line measurement + */ + measureLines(lines: string[]): ILineInfo[] { + return lines.map((line) => ({ + text: line, + width: measureText(this._context, line), + })); + } + + /** + * Simple text wrapping + */ + wrapText(text: string, wordWrapWidth: number): ILinesInfo { + const lines = text.split(/(?:\r\n|\r|\n)/); + + const renderLines: ILineInfo[] = []; + let maxLines = this._settings.maxLines; + const { suffix, nowrap } = getSuffix( + this._settings.maxLinesSuffix, + this._settings.textOverflow, + this._settings.wordWrap + ); + const wordBreak = this._settings.wordBreak; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const tempLines = wrapText( + this._context, + line, + wordWrapWidth, + this._settings.letterSpacing, + i === 0 ? this._settings.textIndent : 0, + nowrap ? 1 : maxLines, + suffix, + wordBreak + ); + + if (maxLines === 0) { + // add all + renderLines.push(...tempLines); + } else { + // add up to + while (maxLines > 0 && tempLines.length > 0) { + renderLines.push(tempLines.shift()!); + maxLines--; + } + if (maxLines === 0) { + if (i < lines.length - 1) { + const lastLine = renderLines[renderLines.length - 1]!; + if (suffix && !lastLine.text.endsWith(suffix)) { + lastLine.text += suffix; + } + } + break; + } + } + } + + return { + l: renderLines, + r: [], + }; + } +} diff --git a/src/textures/TextTextureRendererAdvanced.d.mts b/src/textures/TextTextureRendererAdvanced.d.mts deleted file mode 100644 index 9bc7eeab..00000000 --- a/src/textures/TextTextureRendererAdvanced.d.mts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * If not stated otherwise in this file or this component's LICENSE file the - * following copyright and licenses apply: - * - * Copyright 2022 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 Stage from "../tree/Stage.mjs"; -import TextureSource from "../tree/TextureSource.mjs"; -import TextTexture from "./TextTexture.mjs"; - -declare namespace TextTextureRendererAdvanced { - export interface WordInfo { - text: string; - bold: boolean; - italic: boolean; - color: string; - width?: number; - } -} - -declare class TextTextureRendererAdvanced { - constructor( - stage: Stage, - canvas: HTMLCanvasElement, - settings: Required, - ); - - private _settings: Required; - renderInfo?: TextureSource.RenderInfo; - - _calculateRenderInfo(): TextureSource.RenderInfo; - _context: CanvasRenderingContext2D; - getPrecision(): number; - setFontProperties(): void; - _load(): Promise | undefined; - draw(): Promise | undefined; - _draw(): void; - measureText(word: string, space?: number): number; - tokenize(text: string): string[]; - parse(tokens: string[]): string[]; - applyFontStyle(word: string, baseFont: string): void; - resetFontStyle(baseFont: string): void; - measure( - parsed: TextTextureRendererAdvanced.WordInfo[], - letterSpacing: number, - baseFont: string - ): TextTextureRendererAdvanced.WordInfo[]; - indent(parsed: TextTextureRendererAdvanced.WordInfo[], textIndent: number): TextTextureRendererAdvanced.WordInfo[]; -} - -export default TextTextureRendererAdvanced; diff --git a/src/textures/TextTextureRendererAdvanced.mjs b/src/textures/TextTextureRendererAdvanced.mjs deleted file mode 100644 index 542f219d..00000000 --- a/src/textures/TextTextureRendererAdvanced.mjs +++ /dev/null @@ -1,683 +0,0 @@ -/* - * 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 StageUtils from "../tree/StageUtils.mjs"; -import Utils from "../tree/Utils.mjs"; -import { getFontSetting, isSpace, measureText, tokenizeString } from "./TextTextureRendererUtils.mjs"; - -export default class TextTextureRendererAdvanced { - - constructor(stage, canvas, settings) { - this._stage = stage; - this._canvas = canvas; - this._context = this._canvas.getContext('2d'); - this._settings = settings; - } - - getPrecision() { - return this._settings.precision; - }; - - setFontProperties() { - const font = getFontSetting( - this._settings.fontFace, - this._settings.fontStyle, - this._settings.fontSize, - this.getPrecision(), - this._stage.getOption('defaultFontFace') - ); - this._context.font = font; - this._context.textBaseline = this._settings.textBaseline; - return font; - }; - - _load() { - if (Utils.isWeb && document.fonts) { - const fontSetting = getFontSetting( - this._settings.fontFace, - this._settings.fontStyle, - this._settings.fontSize, - this.getPrecision(), - this._stage.getOption('defaultFontFace') - ); - try { - if (!document.fonts.check(fontSetting, this._settings.text)) { - // Use a promise that waits for loading. - return document.fonts.load(fontSetting, this._settings.text).catch(err => { - // Just load the fallback font. - console.warn('Font load error', err, fontSetting); - }).then(() => { - if (!document.fonts.check(fontSetting, this._settings.text)) { - console.warn('Font not found', fontSetting); - } - }); - } - } catch(e) { - console.warn("Can't check font loading for " + fontSetting); - } - } - } - - draw() { - // We do not use a promise so that loading is performed syncronous when possible. - const loadPromise = this._load(); - if (!loadPromise) { - return Utils.isSpark ? this._stage.platform.drawText(this) : this._draw(); - } else { - return loadPromise.then(() => { - return Utils.isSpark ? this._stage.platform.drawText(this) : this._draw(); - }); - } - } - - _calculateRenderInfo() { - let renderInfo = {}; - - const precision = this.getPrecision(); - - const paddingLeft = this._settings.paddingLeft * precision; - const paddingRight = this._settings.paddingRight * precision; - const fontSize = this._settings.fontSize * precision; - // const offsetY = this._settings.offsetY === null ? null : (this._settings.offsetY * precision); - const lineHeight = this._settings.lineHeight * precision || fontSize; - const w = this._settings.w != 0 ? this._settings.w * precision : this._stage.getOption('w'); - // const h = this._settings.h * precision; - const wordWrapWidth = this._settings.wordWrapWidth * precision; - const cutSx = this._settings.cutSx * precision; - const cutEx = this._settings.cutEx * precision; - const cutSy = this._settings.cutSy * precision; - const cutEy = this._settings.cutEy * precision; - const letterSpacing = this._settings.letterSpacing || 0; - - // Set font properties. - renderInfo.baseFont = this.setFontProperties(); - - renderInfo.w = w; - renderInfo.width = w; - renderInfo.text = this._settings.text; - renderInfo.precision = precision; - renderInfo.fontSize = fontSize; - renderInfo.fontBaselineRatio = this._settings.fontBaselineRatio; - renderInfo.lineHeight = lineHeight; - renderInfo.letterSpacing = letterSpacing; - renderInfo.textAlign = this._settings.textAlign; - renderInfo.textColor = this._settings.textColor; - renderInfo.verticalAlign = this._settings.verticalAlign; - renderInfo.highlight = this._settings.highlight; - renderInfo.highlightColor = this._settings.highlightColor; - renderInfo.highlightHeight = this._settings.highlightHeight; - renderInfo.highlightPaddingLeft = this._settings.highlightPaddingLeft; - renderInfo.highlightPaddingRight = this._settings.highlightPaddingRight; - renderInfo.highlightOffset = this._settings.highlightOffset; - renderInfo.paddingLeft = this._settings.paddingLeft; - renderInfo.paddingRight = this._settings.paddingRight; - renderInfo.maxLines = this._settings.maxLines; - renderInfo.maxLinesSuffix = this._settings.maxLinesSuffix; - renderInfo.textOverflow = this._settings.textOverflow; - renderInfo.wordWrap = this._settings.wordWrap; - renderInfo.wordWrapWidth = wordWrapWidth; - renderInfo.shadow = this._settings.shadow; - renderInfo.shadowColor = this._settings.shadowColor; - renderInfo.shadowOffsetX = this._settings.shadowOffsetX; - renderInfo.shadowOffsetY = this._settings.shadowOffsetY; - renderInfo.shadowBlur = this._settings.shadowBlur; - renderInfo.cutSx = cutSx; - renderInfo.cutEx = cutEx; - renderInfo.cutSy = cutSy; - renderInfo.cutEy = cutEy; - renderInfo.textIndent = this._settings.textIndent * precision; - renderInfo.wordBreak = this._settings.wordBreak; - - let text = renderInfo.text; - let wrapWidth = renderInfo.wordWrap ? (renderInfo.wordWrapWidth || renderInfo.width) : renderInfo.width; - - // Text overflow - if (renderInfo.textOverflow && !renderInfo.wordWrap) { - let suffix; - switch (this._settings.textOverflow) { - case 'clip': - suffix = ''; - break; - case 'ellipsis': - suffix = this._settings.maxLinesSuffix; - break; - default: - suffix = this._settings.textOverflow; - } - text = this.wrapWord(text, wordWrapWidth || renderInfo.w, suffix); - } - - text = this.tokenize(text); - text = this.parse(text); - text = this.measure(text, letterSpacing, renderInfo.baseFont); - - if (renderInfo.textIndent) { - text = this.indent(text, renderInfo.textIndent); - } - - if (renderInfo.wordBreak) { - text = text.reduce((acc, t) => acc.concat(this.wordBreak(t, wrapWidth, renderInfo.baseFont)), []) - this.resetFontStyle() - } - - // Calculate detailed drawing information - let x = paddingLeft; - let lineNo = 0; - - for (const t of text) { - // Wrap text - if (renderInfo.wordWrap && x + t.width > wrapWidth || t.text == '\n') { - x = paddingLeft; - lineNo += 1; - } - t.lineNo = lineNo; - - if (t.text == '\n') { - continue; - } - - t.x = x; - x += t.width; - } - renderInfo.lineNum = lineNo + 1; - - if (this._settings.h) { - renderInfo.h = this._settings.h; - } else if (renderInfo.maxLines && renderInfo.maxLines < renderInfo.lineNum) { - renderInfo.h = renderInfo.maxLines * renderInfo.lineHeight + fontSize / 2; - } else { - renderInfo.h = renderInfo.lineNum * renderInfo.lineHeight + fontSize / 2; - } - - // This calculates the baseline offset in pixels from the font size. - // To retrieve this ratio, you would do this calculation: - // (FontUnitsPerEm − hhea.Ascender − hhea.Descender) / (2 × FontUnitsPerEm) - // - // This give you the ratio for the baseline, which is then used to figure out - // where the baseline is relative to the bottom of the text bounding box. - const baselineOffsetInPx = renderInfo.fontBaselineRatio * renderInfo.fontSize; - - // Vertical align - let vaOffset = 0; - if (renderInfo.verticalAlign == 'top' && this._context.textBaseline == 'alphabetic') { - vaOffset = -baselineOffsetInPx; - } else if (renderInfo.verticalAlign == 'middle') { - vaOffset = (renderInfo.lineHeight - renderInfo.fontSize - baselineOffsetInPx) / 2; - } else if (this._settings.verticalAlign == 'bottom') { - vaOffset = renderInfo.lineHeight - renderInfo.fontSize; - } - - // Calculate lines information - renderInfo.lines = [] - for (let i = 0; i < renderInfo.lineNum; i++) { - renderInfo.lines[i] = { - width: 0, - x: 0, - y: renderInfo.lineHeight * i + vaOffset, - text: [], - } - } - - for (let t of text) { - renderInfo.lines[t.lineNo].text.push(t); - } - - // Filter out white spaces at beginning and end of each line - for (const l of renderInfo.lines) { - if (l.text.length == 0) { - continue; - } - - const firstWord = l.text[0].text; - const lastWord = l.text[l.text.length - 1].text; - - if (firstWord == '\n') { - l.text.shift(); - } - if (isSpace(lastWord) || lastWord == '\n') { - l.text.pop(); - } - } - - - // Calculate line width - for (let l of renderInfo.lines) { - l.width = l.text.reduce((acc, t) => acc + t.width, 0); - } - - renderInfo.width = this._settings.w != 0 ? this._settings.w * precision : Math.max(...renderInfo.lines.map((l) => l.width)) + paddingRight; - renderInfo.w = renderInfo.width; - - // Apply maxLinesSuffix - if (renderInfo.maxLines && renderInfo.lineNum > renderInfo.maxLines && renderInfo.maxLinesSuffix) { - const index = renderInfo.maxLines - 1; - let lastLineText = text.filter((t) => t.lineNo == index) - let suffix = renderInfo.maxLinesSuffix; - suffix = this.tokenize(suffix); - suffix = this.parse(suffix); - suffix = this.measure(suffix, renderInfo.letterSpacing, renderInfo.baseFont); - for (const s of suffix) { - s.lineNo = index; - s.x = 0; - lastLineText.push(s) - } - - const spl = suffix.length + 1 - let _w = lastLineText.reduce((acc, t) => acc + t.width, 0); - while (_w > renderInfo.width || isSpace(lastLineText[lastLineText.length - spl].text)) { - lastLineText.splice(lastLineText.length - spl, 1); - _w = lastLineText.reduce((acc, t) => acc + t.width, 0); - if (lastLineText.length < spl) { - break; - } - } - this.alignLine(lastLineText, lastLineText[0].x) - - renderInfo.lines[index].text = lastLineText; - renderInfo.lines[index].width = _w; - } - - // Horizontal alignment offset - if (renderInfo.textAlign == 'center') { - for (let l of renderInfo.lines) { - l.x = (renderInfo.width - l.width - paddingLeft) / 2; - } - } else if (renderInfo.textAlign == 'right') { - for (let l of renderInfo.lines) { - l.x = renderInfo.width - l.width - paddingLeft; - } - } - - return renderInfo; - } - - _draw() { - const renderInfo = this._calculateRenderInfo(); - const precision = this.getPrecision(); - const paddingLeft = renderInfo.paddingLeft * precision; - - // Set canvas dimensions - let canvasWidth = renderInfo.w || renderInfo.width; - if (renderInfo.cutSx || renderInfo.cutEx) { - canvasWidth = Math.min(renderInfo.w, renderInfo.cutEx - renderInfo.cutSx); - } - - let canvasHeight = renderInfo.h; - if (renderInfo.cutSy || renderInfo.cutEy) { - canvasHeight = Math.min(renderInfo.h, renderInfo.cutEy - renderInfo.cutSy); - } - - this._canvas.width = Math.ceil(canvasWidth + this._stage.getOption('textRenderIssueMargin')); - this._canvas.height = Math.ceil(canvasHeight); - - // Canvas context has been reset. - this.setFontProperties(); - - if (renderInfo.fontSize >= 128) { - // WpeWebKit bug: must force compositing because cairo-traps-compositor will not work with text first. - this._context.globalAlpha = 0.01; - this._context.fillRect(0, 0, 0.01, 0.01); - this._context.globalAlpha = 1.0; - } - - // Cut - if (renderInfo.cutSx || renderInfo.cutSy) { - this._context.translate(-renderInfo.cutSx, -renderInfo.cutSy); - } - - // Highlight - if (renderInfo.highlight) { - const hlColor = renderInfo.highlightColor || 0x00000000; - const hlHeight = renderInfo.highlightHeight ? renderInfo.highlightHeight * precision : renderInfo.fontSize * 1.5; - const hlOffset = renderInfo.highlightOffset ? renderInfo.highlightOffset * precision : 0; - const hlPaddingLeft = (renderInfo.highlightPaddingLeft !== null ? renderInfo.highlightPaddingLeft * precision : renderInfo.paddingLeft); - const hlPaddingRight = (renderInfo.highlightPaddingRight !== null ? renderInfo.highlightPaddingRight * precision : renderInfo.paddingRight); - - this._context.fillStyle = StageUtils.getRgbaString(hlColor); - const lineNum = renderInfo.maxLines ? Math.min(renderInfo.maxLines, renderInfo.lineNum) : renderInfo.lineNum; - for (let i = 0; i < lineNum; i++) { - const l = renderInfo.lines[i]; - this._context.fillRect(l.x - hlPaddingLeft + paddingLeft, l.y + hlOffset, l.width + hlPaddingLeft + hlPaddingRight, hlHeight); - } - } - - // Text shadow. - let prevShadowSettings = null; - if (this._settings.shadow) { - prevShadowSettings = [this._context.shadowColor, this._context.shadowOffsetX, this._context.shadowOffsetY, this._context.shadowBlur]; - - this._context.shadowColor = StageUtils.getRgbaString(this._settings.shadowColor); - this._context.shadowOffsetX = this._settings.shadowOffsetX * precision; - this._context.shadowOffsetY = this._settings.shadowOffsetY * precision; - this._context.shadowBlur = this._settings.shadowBlur * precision; - } - - // Draw text - const defaultColor = StageUtils.getRgbaString(this._settings.textColor); - let currentColor = defaultColor; - this._context.fillStyle = defaultColor; - for (const line of renderInfo.lines) { - for (const t of line.text) { - let lx = 0; - - if (t.text == '\n') { - continue; - } - - if (renderInfo.maxLines && t.lineNo >= renderInfo.maxLines) { - continue; - } - - if (t.color != currentColor) { - currentColor = t.color; - this._context.fillStyle = currentColor; - } - - this._context.font = t.fontStyle; - - // Draw with letter spacing - if (t.letters) { - for (let l of t.letters) { - const _x = renderInfo.lines[t.lineNo].x + t.x + lx; - this._context.fillText(l.text, _x, renderInfo.lines[t.lineNo].y + renderInfo.fontSize); - lx += l.width; - } - // Standard drawing - } else { - const _x = renderInfo.lines[t.lineNo].x + t.x; - this._context.fillText(t.text, _x, renderInfo.lines[t.lineNo].y + renderInfo.fontSize); - } - } - } - - // Reset text shadow - if (prevShadowSettings) { - this._context.shadowColor = prevShadowSettings[0]; - this._context.shadowOffsetX = prevShadowSettings[1]; - this._context.shadowOffsetY = prevShadowSettings[2]; - this._context.shadowBlur = prevShadowSettings[3]; - } - - // Reset cut translation - if (renderInfo.cutSx || renderInfo.cutSy) { - this._context.translate(renderInfo.cutSx, renderInfo.cutSy); - } - - // Postprocess renderInfo.lines to be compatible with standard version - renderInfo.lines = renderInfo.lines.map((l) => l.text.reduce((acc, v) => acc + v.text, '')); - if (renderInfo.maxLines) { - renderInfo.lines = renderInfo.lines.slice(0, renderInfo.maxLines); - } - - - this.renderInfo = renderInfo; - - }; - - /** - * See {@link measureText} - * - * @param {string} word - * @param {number} space - * @returns {number} - */ - measureText(word, space = 0) { - return measureText(this._context, word, space); - } - - tokenize(text) { - return tokenizeString(/ |\u200B|\n||<\/i>||<\/b>||<\/color>/g, text); - } - - parse(tokens) { - let italic = 0; - let bold = 0; - let colorStack = [StageUtils.getRgbaString(this._settings.textColor)]; - let color = 0; - - const colorRegexp = //; - - return tokens.map((t) => { - if (t == '') { - italic += 1; - t = ''; - } else if (t == '' && italic > 0) { - italic -= 1; - t = ''; - } else if (t == '') { - bold += 1; - t = ''; - } else if (t == '' && bold > 0) { - bold -= 1; - t = ''; - } else if (t == '') { - if (colorStack.length > 1) { - color -= 1; - colorStack.pop(); - } - t = ''; - } else if (colorRegexp.test(t)) { - const matched = colorRegexp.exec(t); - colorStack.push( - StageUtils.getRgbaString(parseInt(matched[1])) - ); - color += 1; - t = ''; - - } - - return { - text: t, - italic: italic, - bold: bold, - color: colorStack[color], - } - }) - .filter((o) => o.text != ''); - } - - applyFontStyle(word, baseFont) { - let font = baseFont; - if (word.bold) { - font = 'bold ' + font; - } - if (word.italic) { - font = 'italic ' + font; - } - this._context.font = font - word.fontStyle = font; - } - - resetFontStyle(baseFont) { - this._context.font = baseFont; - } - - measure(parsed, letterSpacing = 0, baseFont) { - for (const p of parsed) { - this.applyFontStyle(p, baseFont); - p.width = this.measureText(p.text, letterSpacing); - - // Letter by letter detail for letter spacing - if (letterSpacing > 0) { - p.letters = p.text.split('').map((l) => {return {text: l}}); - for (let l of p.letters) { - l.width = this.measureText(l.text, letterSpacing); - } - } - - } - this.resetFontStyle(baseFont); - return parsed; - } - - indent(parsed, textIndent) { - parsed.splice(0, 0, {text: "", width: textIndent}); - return parsed; - } - - wrapWord(word, wordWrapWidth, suffix) { - const suffixWidth = this.measureText(suffix); - const wordLen = word.length - const wordWidth = this.measureText(word); - - /* If word fits wrapWidth, do nothing */ - if (wordWidth <= wordWrapWidth) { - return word; - } - - /* Make initial guess for text cuttoff */ - let cutoffIndex = Math.floor((wordWrapWidth * wordLen) / wordWidth); - let truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth; - - /* In case guess was overestimated, shrink it letter by letter. */ - if (truncWordWidth > wordWrapWidth) { - while (cutoffIndex > 0) { - truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth; - if (truncWordWidth > wordWrapWidth) { - cutoffIndex -= 1; - } else { - break; - } - } - - /* In case guess was underestimated, extend it letter by letter. */ - } else { - while (cutoffIndex < wordLen) { - truncWordWidth = this.measureText(word.substring(0, cutoffIndex)) + suffixWidth; - if (truncWordWidth < wordWrapWidth) { - cutoffIndex += 1; - } else { - // Finally, when bound is crossed, retract last letter. - cutoffIndex -=1; - break; - } - } - } - - /* If wrapWidth is too short to even contain suffix alone, return empty string */ - return word.substring(0, cutoffIndex) + (wordWrapWidth >= suffixWidth ? suffix : '') - } - - _getBreakIndex(word, width) { - const wordLen = word.length; - const wordWidth = this.measureText(word); - - if (wordWidth <= width) { - return {breakIndex: word.length, truncWordWidth: wordWidth}; - } - - let breakIndex = Math.floor((width * wordLen) / wordWidth); - let truncWordWidth = this.measureText(word.substring(0, breakIndex)) - - /* In case guess was overestimated, shrink it letter by letter. */ - if (truncWordWidth > width) { - while (breakIndex > 0) { - truncWordWidth = this.measureText(word.substring(0, breakIndex)); - if (truncWordWidth > width) { - breakIndex -= 1; - } else { - break; - } - } - - /* In case guess was underestimated, extend it letter by letter. */ - } else { - while (breakIndex < wordLen) { - truncWordWidth = this.measureText(word.substring(0, breakIndex)); - if (truncWordWidth < width) { - breakIndex += 1; - } else { - // Finally, when bound is crossed, retract last letter. - breakIndex -=1; - truncWordWidth = this.measureText(word.substring(0, breakIndex)); - break; - } - } - } - return {breakIndex, truncWordWidth}; - - } - - wordBreak(word, width, baseFont) { - if (!word.text) { - return word - } - this.applyFontStyle(word, baseFont) - const parts = []; - let text = word.text; - if (!word.letters) { - while (true) { - const {breakIndex, truncWordWidth} = this._getBreakIndex(text, width); - parts.push({...word}); - parts[parts.length - 1].text = text.slice(0, breakIndex); - parts[parts.length - 1].width = truncWordWidth; - - if (breakIndex === text.length) { - break; - } - - text = text.slice(breakIndex); - } - } else { - let totalWidth = 0; - let letters = []; - let breakIndex = 0; - for (const l of word.letters) { - if (totalWidth + l.width >= width) { - parts.push({...word}); - parts[parts.length - 1].text = text.slice(0, breakIndex); - parts[parts.length - 1].width = totalWidth; - parts[parts.length - 1].letters = letters; - text = text.slice(breakIndex); - totalWidth = 0; - letters = []; - breakIndex = 0; - - } else { - breakIndex += 1; - letters.push(l); - totalWidth += l.width; - } - } - - if (totalWidth > 0) { - parts.push({...word}); - parts[parts.length - 1].text = text.slice(0, breakIndex); - parts[parts.length - 1].width = totalWidth; - parts[parts.length - 1].letters = letters; - } - } - - return parts; - } - - alignLine(parsed, initialX = 0) { - let prevWidth = 0; - let prevX = initialX; - for (const word of parsed) { - if (word.text == '\n') { - continue; - } - word.x = prevX + prevWidth; - prevX = word.x; - prevWidth = word.width; - } - - } -} \ No newline at end of file diff --git a/src/textures/TextTextureRendererAdvanced.ts b/src/textures/TextTextureRendererAdvanced.ts new file mode 100644 index 00000000..340a04db --- /dev/null +++ b/src/textures/TextTextureRendererAdvanced.ts @@ -0,0 +1,143 @@ +/* + * 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 { + createLineStyle, + extractTags, + layoutSpans, + type LineLayout, +} from "./TextTextureRendererAdvancedUtils.js"; +import TextTextureRenderer from "./TextTextureRenderer.js"; +import type { + IDrawLineInfo, + ILinesInfo, + ILineWordStyle, +} from "./TextTextureRendererTypes.js"; +import { + getFontSetting, + getSuffix, +} from "./TextTextureRendererUtils.js"; +import StageUtils from "../tree/StageUtils.mjs"; +import TextTokenizer from "./TextTokenizer.js"; +import TextTexture from "./TextTexture.mjs"; + +export default class TextTextureRendererAdvanced extends TextTextureRenderer { + override wrapText(text: string, wordWrapWidth: number): ILinesInfo { + const styled = this._settings.advancedRenderer; + + // styled renderer' base font should not include styling + const baseFont = getFontSetting( + this._settings.fontFace, + styled ? "" : this._settings.fontStyle, + this._settings.fontSize, + this._stage.getRenderPrecision(), + this._stage.getOption("defaultFontFace") + ); + + const { suffix, nowrap } = getSuffix( + this._settings.maxLinesSuffix, + this._settings.textOverflow, + this._settings.wordWrap + ); + const wordBreak = this._settings.wordBreak; + const letterSpacing = this._settings.letterSpacing; + const allowTextTruncation = TextTexture.allowTextTruncation; + + let tags: string[]; + if (styled) { + const extract = extractTags(text); + tags = extract.tags; + text = extract.output; + } else { + tags = []; + } + + const lineStyle = createLineStyle(tags, baseFont, this._settings.textColor); + const tokenize = TextTokenizer.getTokenizer(); + + const sourceLines = text.split(/[\r\n]/g); + const wrappedLines: LineLayout[] = []; + let remainingLines = this._settings.maxLines; + + for (let i = 0; i < sourceLines.length; i++) { + const line = sourceLines[i]!; + const spans = tokenize(line); + + const lines = layoutSpans( + this._context, + spans, + lineStyle, + wordWrapWidth, + i === 0 ? this._settings.textIndent : 0, + nowrap ? 1 : remainingLines, + suffix, + wordBreak, + letterSpacing, + allowTextTruncation + ); + + wrappedLines.push(...lines); + + if (remainingLines > 0) { + remainingLines -= lines.length; + if (remainingLines <= 0) break; + } + } + + return { + l: wrappedLines, + r: [], + }; + } + + override _drawLines(drawLines: IDrawLineInfo[], letterSpacing: number) { + // letter spacing is not supported in advanced renderer + const ctx = this._context; + ctx.fillStyle = StageUtils.getRgbaString(this._settings.textColor); + let currentStyle: ILineWordStyle | undefined; + + for (let i = 0, n = drawLines.length; i < n; i++) { + const drawLine = drawLines[i]!; + const words = drawLine.info.words ?? []; + + const y = drawLine.y; + let x = drawLine.x; + for (let j = 0; j < words.length; j++) { + const { text, style, width } = words[j]!; + + if (style !== currentStyle) { + currentStyle = style; + if (currentStyle) { + const { font, color } = currentStyle; + ctx.font = font; + ctx.fillStyle = color; + } + } + + if (letterSpacing === 0) { + ctx.fillText(text, x, y); + } else { + this._fillTextWithLetterSpacing(ctx, text, x, y, letterSpacing); + } + + x += width; + } + } + } +} diff --git a/src/textures/TextTextureRendererAdvancedUtils.test.ts b/src/textures/TextTextureRendererAdvancedUtils.test.ts new file mode 100644 index 00000000..0fdb9f56 --- /dev/null +++ b/src/textures/TextTextureRendererAdvancedUtils.test.ts @@ -0,0 +1,286 @@ +import { + extractTags, + createLineStyle, + layoutSpans, + trimWordEnd, + trimWordStart, + renderLines, +} from "./TextTextureRendererAdvancedUtils"; +import { describe, it, expect, vi, beforeAll } from "vitest"; + +// Mocking CanvasRenderingContext2D for layoutSpans and renderLines tests +const mockCtx = { + measureText: vi.fn((text) => ({ 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, + "...", + false, + 0, + false + ); + 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, + "...", + false, + 0, + false + ); + 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 new file mode 100644 index 00000000..c5dd56b8 --- /dev/null +++ b/src/textures/TextTextureRendererAdvancedUtils.ts @@ -0,0 +1,512 @@ +/* + * 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 type { + ILineInfo, + ILineWord, + ILineWordStyle, +} from "./TextTextureRendererTypes.js"; +import StageUtils from "../tree/StageUtils.mjs"; +import { breakWord, measureText } from "./TextTextureRendererUtils.js"; + +export interface DirectedSpan { + rtl?: boolean; + tokens: string[]; +} +export interface WordLayout extends ILineWord { + rtl: boolean; +} +export interface LineLayout extends ILineInfo { + rtl: boolean; + words: WordLayout[]; +} + +const TAG_DEFAULTS: Record = { + "": 0x2460, + "": 0x2461, + "": 0x2462, + "": 0x2463, + "": 0x2464, +}; + +/** + * Extract HTML tags, replacing them with direction-weak characters, so they don't affect bidi parsing + */ +export function extractTags(source: string): { + tags: string[]; + output: string; +} { + const tags: string[] = ["", "", "", "", ""]; + const reTag = /|<\/i>||<\/b>||<\/color>/g; + let output = ""; + let m: RegExpMatchArray | null; + let lastIndex = 0; + while ((m = reTag.exec(source))) { + output += source.substring(lastIndex, m.index); + let code = TAG_DEFAULTS[m[0]]; + if (code === undefined) { + code = 0x2460 + tags.length; + tags.push(m[0]); + } + output += `\u200B${String.fromCharCode(code)}\u200B`; + lastIndex = m.index! + m[0].length; + } + output += source.substring(lastIndex); + return { tags, output }; +} + +/** + * Drop trailing space and measure total line width + */ +function measureLine(line: LineLayout): void { + if (line.words[line.words.length - 1]?.text === " ") { + line.words.pop(); + } + line.words.forEach((token) => (line.width += token.width)); +} + +/** + * Style helper + */ +export function createLineStyle( + tags: string[], + baseFont: string, + color: number +) { + const isStyled = tags.length > 0; + let bold = 0; + let italic = 0; + const colors = [StageUtils.getRgbaString(color)]; + + const updateStyle = (code: number): boolean => { + const tag = tags[code - 0x2460]; + if (!tag) return false; + + if (tag === "") { + bold++; + } else if (tag === "") { + if (bold > 0) bold--; + } else if (tag === "") { + italic++; + } else if (tag === "") { + if (italic > 0) italic--; + } else if (tag === "") { + if (colors.length > 0) colors.pop(); + } else if (tag.startsWith(" ({ + font: (bold ? "bold " : "") + (italic ? "italic " : "") + baseFont, + color: colors[colors.length - 1]!, + }); + + const baseStyle = getStyle(); + + return { isStyled, baseStyle, updateStyle, getStyle }; +} + +/** + * Layout text into lines + */ +export function layoutSpans( + ctx: CanvasRenderingContext2D, + spans: DirectedSpan[], + lineStyle: ReturnType, + wrapWidth: number, + textIndent: number, + maxLines: number, + suffix: string, + wordBreak: boolean, + letterSpacing: number, + allowTruncation: boolean +): LineLayout[] { + // styling + const { isStyled, baseStyle, updateStyle, getStyle } = lineStyle; + const initialStyle = getStyle(); + ctx.font = initialStyle.font; + let style: ILineWordStyle | undefined = isStyled ? initialStyle : undefined; + + // cached metrics + const spaceWidth = measureText(ctx, " ", letterSpacing); + const suffixWidth = measureText(ctx, suffix, letterSpacing); + + // layout state + let rtl = Boolean(spans[0]?.rtl); + const primaryRtl = rtl; + let line: LineLayout = { + rtl, + width: textIndent, + text: "", + words: [], + }; + const lines: LineLayout[] = [line]; + let words: WordLayout[]; + let lineN = 1; + let endReached = false; + let overflow = false; + let x = textIndent; + + // concatenate words + const appendWords = (): void => { + if (rtl !== primaryRtl) { + words.reverse(); + } + // drop double space when changing direction + if (line.words.length > 1) { + if ( + words[0]?.text === " " && + line.words[line.words.length - 1]?.text === " " + ) { + words.shift(); + } + } + line.words.push(...words); + words = []; + }; + + const newLine = (): void => { + line = { + rtl, + width: 0, + text: "", + words: [], + }; + lines.push(line); + lineN++; + x = 0; + }; + + // process tokens + for (let si = 0; si < spans.length; si++) { + const span = spans[si]!; + rtl = Boolean(span.rtl); + const tokens = span.tokens; + 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 && text.length === 1) { + const c = text.charCodeAt(0); + if (c >= 0x2460 && c <= 0x2473) { + // word is a style tag + if (updateStyle(c)) { + style = getStyle(); + ctx.font = style!.font; + } + continue; + } + } + + // measure word + let width = isSpace ? spaceWidth : measureText(ctx, text, letterSpacing); + x += width; + + // end of line + if (x > wrapWidth) { + // last word of last line - ellipsis will be applied later + if (lineN === maxLines) { + words.push({ text, width, style, rtl }); + overflow = true; + endReached = true; + break; + } + + // 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; + } + + // finalize line + appendWords(); + newLine(); + if (text === " ") { + // 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(); + if (endReached) break; + } + + // prevent exceeding maxLines + if (maxLines > 0 && lines.length >= maxLines) { + lines.length = maxLines; + } + + // finalize + lines.forEach((line) => { + measureLine(line); + }); + + // ellipsis + if (overflow) { + line = lines[lines.length - 1]!; + const maxLineWidth = wrapWidth - suffixWidth; + + if (line.width > maxLineWidth) { + // 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; + + // 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; + } + // repeat? + lastIndex = line.words.length - 1; + word = line.words[lastIndex]!; + index = lastIndex; + removeOppositeEnd = allowTruncation && word.width < suffixWidth * 2; + } + } + + // shorten last word to fit ellipsis + while (line.width > maxLineWidth) { + let last = line.words.pop()!; + line.width -= last.width; + const maxWidth = maxLineWidth - line.width; + + if (allowTruncation && maxWidth > 0) { + let { text, width, style, rtl } = last; + if (style) { + ctx.font = style.font; + } + const reversed = primaryRtl !== rtl; + do { + text = reversed ? trimWordStart(text, rtl) : trimWordEnd(text, rtl); + width = ctx.measureText(text).width; + } while (width > maxWidth); + if (width > suffixWidth) { + last = { + ...last, + text, + width, + }; + line.words.push(last); + line.width += width; + break; + } + } + } + } + + // drop space before ellipsis + if (line.words[line.words.length - 1]!.text === " ") { + line.words.pop(); + line.width -= spaceWidth; + } + + // add ellipsis + line.words.push({ + text: suffix, + width: suffixWidth, + style: baseStyle, + rtl: false, + }); + line.width += suffixWidth; + } + + // reverse words of RTL text because we render left to right + if (primaryRtl) { + for (const line of lines) { + line.words.reverse(); + } + } + 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 + */ +export function renderLines( + ctx: CanvasRenderingContext2D, + lines: LineLayout[], + lineStyle: ReturnType, + align: "left" | "right" | "center", + lineHeight: number, + wrapWidth: number, + indent: number +) { + const { baseStyle } = lineStyle; + ctx.font = baseStyle.font; + ctx.fillStyle = baseStyle.color; + let currentStyle: ILineWordStyle | undefined = baseStyle; + + // get text metrics for vertical layout + const metrics = ctx.measureText(" "); + const fontLineHeight = + metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; + const leading = lineHeight - fontLineHeight; + let y = leading / 2 + metrics.fontBoundingBoxAscent; + + for (const line of lines) { + let x = + align === "left" + ? indent + : align === "right" + ? wrapWidth - indent - line.width + : (wrapWidth - line.width) / 2; + + for (const word of line.words) { + if (word.style !== currentStyle) { + currentStyle = word.style; + if (currentStyle) { + const { font, color } = currentStyle; + ctx.font = font; + ctx.fillStyle = color; + } + } + if (word.text !== " ") { + ctx.fillText(word.text, x, y); + } + x += word.width; + } + y += lineHeight; + } +} diff --git a/src/textures/TextTextureRendererTypes.ts b/src/textures/TextTextureRendererTypes.ts new file mode 100644 index 00000000..0c993945 --- /dev/null +++ b/src/textures/TextTextureRendererTypes.ts @@ -0,0 +1,104 @@ +/* + * 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. + */ + +/** + * @internal + * Text overflow settings + */ +export interface ISuffixInfo { + suffix: string; + nowrap: boolean; +} + +/** + * @internal + * Text layout information + */ +export interface ILinesInfo { + l: ILineInfo[]; + r?: string[]; +} + +/** + * @internal + * Word styling + */ +export interface ILineWordStyle { + font: string; + color: string; +} + +/** + * @internal + * Layed out word information + */ +export interface ILineWord { + text: string; + width: number; + style?: ILineWordStyle; +} + +/** + * @internal + * Layed out line information + */ +export interface ILineInfo { + text: string; + words?: ILineWord[]; + width: number; +} + +/** + * @internal + * Complete text layout information + */ +export interface IRenderInfo { + lines: IDrawLineInfo[]; + remainingText: string; + moreTextLines: boolean; + precision: number; + w: number; + h: number; + width: number; + innerWidth: number; + height: number; + fontSize: number; + cutSx: number; + cutSy: number; + cutEx: number; + cutEy: number; + lineHeight: number; + lineWidths: number[]; + offsetY: number; + paddingLeft: number; + paddingRight: number; + letterSpacing: number; + textIndent: number; +} + +/** + * @internal + * Individual line render info + */ +export interface IDrawLineInfo { + info: ILineInfo; + x: number; + y: number; + w: number; +} diff --git a/src/textures/TextTextureRendererUtils.mts b/src/textures/TextTextureRendererUtils.mts deleted file mode 100644 index 86630e0a..00000000 --- a/src/textures/TextTextureRendererUtils.mts +++ /dev/null @@ -1,185 +0,0 @@ -/* - * 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. - */ - -/** - * Returns CSS font setting string for use in canvas context. - * - * @param fontFace - * @param fontStyle - * @param fontSize - * @param precision - * @param defaultFontFace - * @returns - */ -export function getFontSetting( - fontFace: string | string[], - fontStyle: string, - fontSize: number, - precision: number, - defaultFontFace: string -): string { - let ff = fontFace; - - if (!Array.isArray(ff)) { - ff = [ff]; - } - - let ffs = []; - for (let i = 0, n = ff.length; i < n; i++) { - let curFf = ff[i]; - // Replace the default font face `null` with the actual default font face set - // on the stage. - if (curFf == null) { - curFf = defaultFontFace; - } - if (curFf.indexOf(' ') < 0) { - ffs.push(curFf); - } else { - ffs.push(`"${curFf}"`); - } - } - - return `${fontStyle} ${fontSize * precision}px ${ffs.join(",")}` -} - -/** - * Returns true if the given character is a zero-width space. - * - * @param space - */ -export function isZeroWidthSpace(space: string): boolean { - return space === '' || space === '\u200B'; -} - -/** - * Returns true if the given character is a zero-width space or a regular space. - * - * @param space - */ -export function isSpace(space: string): boolean { - return isZeroWidthSpace(space) || space === ' '; -} - -/** - * Converts a string into an array of tokens and the words between them. - * - * @param tokenRegex - * @param text - */ -export function tokenizeString(tokenRegex: RegExp, text: string): string[] { - const delimeters = text.match(tokenRegex) || []; - const words = text.split(tokenRegex) || []; - - let final: string[] = []; - for (let i = 0; i < words.length; i++) { - final.push(words[i]!, delimeters[i]!) - } - final.pop() - return final.filter((word) => word != ''); -} - -/** - * Measure the width of a string accounting for letter spacing. - * - * @param context - * @param word - * @param space - */ -export function measureText(context: CanvasRenderingContext2D, word: string, space: number = 0): number { - if (!space) { - return context.measureText(word).width; - } - return word.split('').reduce((acc, char) => { - // Zero-width spaces should not include letter spacing. - // And since we know the width of a zero-width space is 0, we can skip - // measuring it. - if (isZeroWidthSpace(char)) { - return acc; - } - return acc + context.measureText(char).width + space; - }, 0); -} - -export interface WrapTextResult { - l: string[]; - n: number[]; -} - -/** - * Applies newlines to a string to have it optimally fit into the horizontal - * bounds set by the Text object's wordWrapWidth property. - * - * @param context - * @param text - * @param wordWrapWidth - * @param letterSpacing - * @param indent - */ -export function wrapText( - context: CanvasRenderingContext2D, - text: string, - wordWrapWidth: number, - letterSpacing: number, - indent: number -): WrapTextResult { - // Greedy wrapping algorithm that will wrap words as the line grows longer. - // than its horizontal bounds. - const spaceRegex = / |\u200B/g; - let lines = text.split(/\r?\n/g); - let allLines: string[] = []; - let realNewlines: number[] = []; - for (let i = 0; i < lines.length; i++) { - let resultLines: string[] = []; - let result = ''; - let spaceLeft = wordWrapWidth - indent; - let words = lines[i]!.split(spaceRegex); - let spaces = lines[i]!.match(spaceRegex) || []; - for (let j = 0; j < words.length; j++) { - const space = spaces[j - 1] || ''; - const word = words[j]!; - const wordWidth = measureText(context, word, letterSpacing); - const wordWidthWithSpace = wordWidth + measureText(context, space, letterSpacing); - if (j === 0 || wordWidthWithSpace > spaceLeft) { - // Skip printing the newline if it's the first word of the line that is. - // greater than the word wrap width. - if (j > 0) { - resultLines.push(result); - result = ''; - } - result += word; - spaceLeft = wordWrapWidth - wordWidth - (j === 0 ? indent : 0); - } - else { - spaceLeft -= wordWidthWithSpace; - result += space + word; - } - } - - resultLines.push(result); - result = ''; - - allLines = allLines.concat(resultLines); - - if (i < lines.length - 1) { - realNewlines.push(allLines.length); - } - } - - return {l: allLines, n: realNewlines}; -} diff --git a/src/textures/TextTextureRendererUtils.test.mjs b/src/textures/TextTextureRendererUtils.test.mjs deleted file mode 100644 index 506f97d6..00000000 --- a/src/textures/TextTextureRendererUtils.test.mjs +++ /dev/null @@ -1,271 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { getFontSetting, tokenizeString, isSpace, isZeroWidthSpace, wrapText, measureText } from './TextTextureRendererUtils.mjs'; - -describe('TextTextureRendererUtils', () => { - describe('getFontSetting', () => { - it('should form a valid CSS font string', () => { - expect(getFontSetting('Arial', 'normal', 12, 1, 'Default')).toBe('normal 12px Arial'); - expect(getFontSetting('Times New Roman', 'bold', 30, 1, 'Default')).toBe('bold 30px "Times New Roman"'); - }); - it('should adjust font size for precision', () => { - expect(getFontSetting('Arial', 'normal', 12, 2, 'Default')).toBe('normal 24px Arial'); - }); - it('should support "serif" and "sans-serif" specially', () => { - expect(getFontSetting('serif', 'italic', 12, 1, 'Default')).toBe('italic 12px serif'); - expect(getFontSetting('sans-serif', 'normal', 12, 1, 'Default')).toBe('normal 12px sans-serif'); - }); - it('should default to the defaultFontFace if fontFace is null', () => { - expect(getFontSetting(null, 'normal', 12, 1, 'Default')).toBe('normal 12px Default'); - expect(getFontSetting([null], 'normal', 12, 1, 'Default')).toBe('normal 12px Default'); - }); - it('should defaultFontFace should also handle "serif" and "sans-serif" specially', () => { - expect(getFontSetting(null, 'normal', 12, 1, 'serif')).toBe('normal 12px serif'); - expect(getFontSetting([null], 'normal', 12, 1, 'sans-serif')).toBe('normal 12px sans-serif'); - }); - it('should support an array of fonts', () => { - expect(getFontSetting(['Arial'], 'normal', 12, 1, 'Default')).toBe('normal 12px Arial'); - expect(getFontSetting(['serif', 'Arial'], 'italic', 12, 1, 'Default')).toBe('italic 12px serif,Arial'); - expect(getFontSetting(['serif', 'Arial', null], 'bold', 12, 1, 'Default')).toBe('bold 12px serif,Arial,Default'); - }); - }); - - describe('isZeroWidthSpace', () => { - it('should return true for empty string', () => { - expect(isZeroWidthSpace('')).toBe(true); - }); - it('should return true for zero-width space', () => { - expect(isZeroWidthSpace('\u200B')).toBe(true); - }); - it('should return false for non-zero-width space', () => { - expect(isZeroWidthSpace(' ')).toBe(false); - expect(isZeroWidthSpace('a')).toBe(false); - }); - }); - - describe('isSpace', () => { - it('should return true for empty string', () => { - expect(isSpace('')).toBe(true); - }); - it('should return true for zero-width space', () => { - expect(isSpace('\u200B')).toBe(true); - }); - it('should return true for regular space', () => { - expect(isSpace(' ')).toBe(true); - }); - it('should return false for non-space', () => { - expect(isSpace('a')).toBe(false); - }); - }); - - describe('tokenizeString', () => { - it('should split text into an array of specific tokens', () => { - const tokenRegex = / +|\n||<\/b>/g; - expect(tokenizeString(tokenRegex, "Hello there world.\n")).toEqual(['Hello', ' ', '', 'there', '', ' ', 'world.', '\n']); - }) - }); - - /** - * Mock context for testing measureText / wrapText - */ - const contextMock = { - measureText: (text) => { - return { - width: text.split('').reduce((acc, char) => { - if (!isZeroWidthSpace(char)) { - acc += 10; - } - return acc; - }, 0) - } - } - } - - describe('measureText', () => { - it('should return 0 for an empty string', () => { - expect(measureText(contextMock, '', 0)).toBe(0); - expect(measureText(contextMock, '', 10)).toBe(0); - }); - - it('should return the width of a string', () => { - expect(measureText(contextMock, 'abc', 0)).toBe(30); - expect(measureText(contextMock, 'a b c', 0)).toBe(50); - }); - - it('should return the width of a string with letter spacing', () => { - expect(measureText(contextMock, 'abc', 1)).toBe(33); - expect(measureText(contextMock, 'a b c', 1)).toBe(55); - }); - - it('should not add letter spacing to zero-width spaces', () => { - expect(measureText(contextMock, '\u200B', 1)).toBe(0); - expect(measureText(contextMock, '\u200B\u200B', 1)).toBe(0); - expect(measureText(contextMock, 'a\u200Bb\u200Bc', 1)).toBe(33); - }); - }); - - describe('wrapText', () => { - it('should not break up text if it fits', () => { - // No indent / no letter spacing - expect(wrapText(contextMock, 'Hello World', 110, 0, 0)).to.deep.equal({ - l: ['Hello World'], - n: [] - }); - // With indent - expect(wrapText(contextMock, 'Hello World', 110 + 10, 0, 10)).to.deep.equal({ - l: ['Hello World'], - n: [] - }); - // With letter spacing - expect(wrapText(contextMock, 'Hello World', 110 + 11, 1, 0)).to.deep.equal({ - l: ['Hello World'], - n: [] - }); - }); - it('should break up text if it doesn\'t fit on one line (1 pixel edge case)', () => { - // No indent / no letter spacing - expect(wrapText(contextMock, 'Hello World', 110 - 1 /* 1 less */, 0, 0)).to.deep.equal({ - l: ['Hello', 'World'], - n: [] - }); - // With indent - expect(wrapText(contextMock, 'Hello World', 110 + 10 - 1 /* 1 less */, 0, 10)).to.deep.equal({ - l: ['Hello', 'World'], - n: [] - }); - // With letter spacing - expect(wrapText(contextMock, 'Hello World', 110 + 11 - 1 /* 1 less */, 1, 0)).to.deep.equal({ - l: ['Hello', 'World'], - n: [] - }); - }); - it('should produce indexes to real line breaks', () => { - expect(wrapText(contextMock, 'Hello World', 50, 0, 0)).to.deep.equal({ - l: ['Hello', 'World'], - n: [] - }); - - expect(wrapText(contextMock, 'Hello\nWorld', 50, 0, 0)).to.deep.equal({ - l: ['Hello', 'World'], - n: [1] - }); - - expect(wrapText(contextMock, 'Hello There\nWorld', 50, 0, 0)).to.deep.equal({ - l: ['Hello', 'There', 'World'], - n: [2] - }); - - expect(wrapText(contextMock, 'Hello\nThere\nWorld', 50, 0, 0)).to.deep.equal({ - l: ['Hello', 'There', 'World'], - n: [1, 2] - }); - }); - - it('should make the first line an empty string if the first character is a space or a line break', () => { - expect(wrapText(contextMock, '\nHello\nThere\nWorld', 50, 0, 0)).to.deep.equal({ - l: ['', 'Hello', 'There', 'World'], - n: [1, 2, 3] - }); - - expect(wrapText(contextMock, ' Hello\nThere\nWorld', 50, 0, 0)).to.deep.equal({ - l: ['', 'Hello', 'There', 'World'], - n: [2, 3] - }); - }); - - it('should REMOVE one of the spaces in a sequence of spaces if a line is broken across it', () => { - // Left - expect(wrapText(contextMock, ' Hello', 50, 0, 0)).to.deep.equal({ - l: ['', 'Hello'], - n: [] - }); - - expect(wrapText(contextMock, ' Hello', 50, 0, 0)).to.deep.equal({ - l: [' ', 'Hello'], - n: [] - }); - - // Middle - expect(wrapText(contextMock, 'Hello World', 50, 0, 0)).to.deep.equal({ - l: ['Hello', 'World'], - n: [] - }); - - expect(wrapText(contextMock, 'Hello World', 50, 0, 0)).to.deep.equal({ - l: ['Hello', '', 'World'], // Since ther are two breaks 2 spaces are removed - n: [] - }); - - expect(wrapText(contextMock, 'Hello World', 50, 0, 0)).to.deep.equal({ - l: ['Hello', ' ', 'World'], // Since ther are two breaks 2 spaces are removed - n: [] - }); - - // Right - expect(wrapText(contextMock, 'World ', 50, 0, 0)).to.deep.equal({ - l: ['World', ''], - n: [] - }); - - expect(wrapText(contextMock, 'World ', 50, 0, 0)).to.deep.equal({ - l: ['World', ' '], - n: [] - }); - }); - - it('should break up a single line of text into many lines based on varying wrapWidth, letterSpacing and indent', () => { - // No indent / no letter spacing - expect(wrapText(contextMock, " Let's start Building! ", 160, 0, 0)).to.deep.equal({ - l: [ " Let's ", 'start ', 'Building! ', ' ' ], - n: [] - }); - expect(wrapText(contextMock, " Let's start Building! ", 120, 0, 0)).to.deep.equal({ - l: [ " Let's ", " start ", "Building! ", " " ], - n: [] - }); - expect(wrapText(contextMock, " Let's start Building! ", 80, 0, 0)).to.deep.equal({ - l: [ " ", "Let's ", " start ", " ", "Building!", " ", " " ], - n: [] - }); - // With indent - expect(wrapText(contextMock, " Let's start Building! ", 160, 0, 10)).to.deep.equal({ - l: [ " Let's ", "start ", "Building! ", " " ], - n: [] - }); - expect(wrapText(contextMock, " Let's start Building! ", 160, 0, 20)).to.deep.equal({ - l: [ " Let's ", "start ", "Building! ", " " ], - n: [] - }); - expect(wrapText(contextMock, " Let's start Building! ", 160, 0, 30)).to.deep.equal({ - l: [ " Let's ", "start ", "Building! ", " " ], - n: [] - }); - // With letter spacing - expect(wrapText(contextMock, " Let's start Building! ", 160, 1, 0)).to.deep.equal({ - l: [ " Let's ", "start ", "Building! ", " " ], - n: [] - }); - expect(wrapText(contextMock, " Let's start Building! ", 160, 5, 0)).to.deep.equal({ - l: [ " Let's", " start ", " ", "Building! ", " " ], - n: [] - }); - }); - - it('should support wrapping on zero-width spaces', () => { - expect(wrapText(contextMock, 'H\u200Be\u200Bl\u200Bl\u200Bo\u200BW\u200Bo\u200Br\u200Bl\u200Bd', 10, 0, 0)).to.deep.equal({ - l: ['H', 'e', 'l', 'l', 'o', 'W', 'o', 'r', 'l' , 'd'], - n: [] - }); - - expect(wrapText(contextMock, 'H\u200Be\u200Bl\u200Bl\u200Bo\u200BW\u200Bo\u200Br\u200Bl\u200Bd', 20, 0, 0)).to.deep.equal({ - l: ['H\u200Be', 'l\u200Bl', 'o\u200BW', 'o\u200Br', 'l\u200Bd'], - n: [] - }); - - expect(wrapText(contextMock, 'H\u200Be\u200Bl\u200Bl\u200Bo\u200BW\u200Bo\u200Br\u200Bl\u200Bd', 50, 0, 0)).to.deep.equal({ - l: ['H\u200Be\u200Bl\u200Bl\u200Bo', 'W\u200Bo\u200Br\u200Bl\u200Bd'], - n: [] - }); - }); - }); - -}); 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 new file mode 100644 index 00000000..08a95ee9 --- /dev/null +++ b/src/textures/TextTextureRendererUtils.ts @@ -0,0 +1,280 @@ +/* + * 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 type { + ILineInfo, + ILineWord, + ISuffixInfo, +} from "./TextTextureRendererTypes.js"; +import TextTokenizer from "./TextTokenizer.js"; + +/** + * Returns CSS font setting string for use in canvas context. + * + * @param fontFace + * @param fontStyle + * @param fontSize + * @param precision + * @param defaultFontFace + * @returns + */ +export function getFontSetting( + fontFace: string | (string | null)[] | null, + fontStyle: string, + fontSize: number, + precision: number, + defaultFontFace: string +): string { + let ff = fontFace; + + if (!Array.isArray(ff)) { + ff = [ff]; + } + + let ffs = []; + for (let i = 0, n = ff.length; i < n; i++) { + let curFf = ff[i]; + // Replace the default font face `null` with the actual default font face set + // on the stage. + if (curFf == null) { + curFf = defaultFontFace; + } + if (curFf.indexOf(" ") < 0) { + ffs.push(curFf); + } else { + ffs.push(`"${curFf}"`); + } + } + + return `${fontStyle} ${fontSize * precision}px ${ffs.join(",")}`; +} + +/** + * Wrap a single line of text + */ +export function wrapText( + context: CanvasRenderingContext2D, + text: string, + wrapWidth: number, + letterSpacing: number, + textIndent: number, + maxLines: number, + suffix: string, + wordBreak: boolean +): ILineInfo[] { + // Greedy wrapping algorithm that will wrap words as the line grows longer. + // than its horizontal bounds. + const tokenize = TextTokenizer.getTokenizer(); + const words = tokenize(text)[0]!.tokens; + const spaceWidth = measureText(context, " ", letterSpacing); + const resultLines: ILineInfo[] = []; + let result = ""; + 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) { + // last word of last line overflows + if (maxLines && resultLines.length >= maxLines - 1) { + result += word; + totalWidth += wordWidth; + overflow = true; + break; + } + + // commit line + if (j > 0 && result.length > 0) { + resultLines.push({ + text: result, + width: totalWidth, + }); + result = ""; + } + + // move word to next line, but drop a trailing space + if (j > 0 && word === " ") wordWidth = 0; + else result = word; + + // 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, + width: k.width, + }); + } + result = last.text; + wordWidth = last.width; + } + + totalWidth = 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) { + const suffixWidth = suffix + ? measureText(context, suffix, letterSpacing) + : 0; + + while (totalWidth + suffixWidth > wrapWidth) { + result = result.substring(0, result.length - 1); + totalWidth = measureText(context, result, letterSpacing); + } + + if (suffix) { + while (result.endsWith(" ")) { + result = result.substring(0, result.length - 1); + totalWidth -= spaceWidth; + } + result += suffix; + totalWidth += suffixWidth; + } + } + + resultLines.push({ + text: result, + width: totalWidth, + }); + + return resultLines; +} + +/** + * Determine how to handle overflow, and what suffix (e.g. ellipsis) to render + */ +export function getSuffix( + maxLinesSuffix: string, + textOverflow: string | null, + wordWrap: boolean +): ISuffixInfo { + if (wordWrap) { + return { + suffix: maxLinesSuffix, + nowrap: false, + }; + } + + if (!textOverflow) { + return { + suffix: "", + nowrap: false, + }; + } + + switch (textOverflow) { + case "clip": + return { + suffix: "", + nowrap: true, + }; + case "ellipsis": + return { + suffix: maxLinesSuffix, + nowrap: true, + }; + default: + return { + suffix: textOverflow || maxLinesSuffix, + nowrap: true, + }; + } +} + +/** + * Measure the width of a string accounting for letter spacing. + * + * @param context + * @param word + * @param space + */ +export function measureText( + context: CanvasRenderingContext2D, + word: string, + space: number = 0 +): number { + 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/TextTokenizer.test.ts b/src/textures/TextTokenizer.test.ts new file mode 100644 index 00000000..d5f1be50 --- /dev/null +++ b/src/textures/TextTokenizer.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import TextTokenizer from './TextTokenizer'; + +describe('TextTokenizer', () => { + beforeEach(() => { + TextTokenizer.setCustomTokenizer(); + }); + + it('should tokenize simple ASCII text into words and spaces', () => { + const input = 'Hello world!'; + const result = TextTokenizer.defaultTokenizer(input); + expect(result).toEqual([ + { tokens: ['Hello', ' ', 'world!'] } + ]); + }); + + it('should handle text with only spaces', () => { + const input = ' '; + const result = TextTokenizer.defaultTokenizer(input); + expect(result).toEqual([ + { tokens: [' ', ' ', ' '] } + ]); + }); + + it('should handle empty string', () => { + const input = ''; + const result = TextTokenizer.defaultTokenizer(input); + expect(result).toEqual([ + { tokens: [] } + ]); + }); + + it('should use custom tokenizer if set', () => { + const custom = vi.fn((text) => [{ tokens: ['custom', text] }]); + TextTokenizer.setCustomTokenizer(custom); + const tokenizer = TextTokenizer.getTokenizer(); + const result = tokenizer('abc'); + expect(custom).toHaveBeenCalledWith('abc'); + expect(result).toEqual([{ tokens: ['custom', 'abc'] }]); + }); + + it('should fallback to default tokenizer for ASCII when detectASCII is true', () => { + const custom = vi.fn((text) => [{ tokens: ['custom', text] }]); + TextTokenizer.setCustomTokenizer(custom, true); + const tokenizer = TextTokenizer.getTokenizer(); + // ASCII input uses default + const asciiResult = tokenizer('Hello world'); + expect(asciiResult).toEqual([ + { tokens: ['Hello', ' ', 'world'] } + ]); + // Non-ASCII input uses custom + const nonAsciiResult = tokenizer('مرحبا'); + expect(nonAsciiResult).toEqual([{ tokens: ['custom', 'مرحبا'] }]); + }); + + it('should detect only ASCII text', () => { + expect(TextTokenizer.containsOnlyASCII('Hello')).toBe(true); + expect(TextTokenizer.containsOnlyASCII('Hello world!')).toBe(true); + expect(TextTokenizer.containsOnlyASCII('مرحبا')).toBe(false); + expect(TextTokenizer.containsOnlyASCII('你好')).toBe(false); + expect(TextTokenizer.containsOnlyASCII('Hello – “quotes”')).toBe(true); + expect(TextTokenizer.containsOnlyASCII('Hello 好')).toBe(false); + }); + + it('should tokenize text with zero-width space', () => { + const input = 'foo\u200Bbar'; + const result = TextTokenizer.defaultTokenizer(input); + expect(result).toEqual([ + { tokens: ['foo', 'bar'] } + ]); + }); +}); \ No newline at end of file diff --git a/src/textures/TextTokenizer.ts b/src/textures/TextTokenizer.ts new file mode 100644 index 00000000..c5244f4a --- /dev/null +++ b/src/textures/TextTokenizer.ts @@ -0,0 +1,115 @@ +/* + * 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. + */ + +namespace TextTokenizer { + /** + * Tokenizer may produce one or more spans of tokens with different writing directions. + */ + export interface ITextTokenizerSpan { + /** Hints the primary direction of this group of tokens (default LTR) */ + rtl?: boolean; + /** Text separated into tokens, with individual tokens for spaces */ + tokens: string[]; + } + + /** + * Signature of text tokenizer function + * + * Note: space characters should be their own token. + */ + export type ITextTokenizerFunction = (text: string) => ITextTokenizerSpan[]; +} + +/** + * Split a text string into an array of words and spaces. + * e.g. "Hello world!" -> ["Hello", " ", "world!"] + * @param text + * @returns + */ +class TextTokenizer { + // current custom tokenizer + static _customTokenizer: TextTokenizer.ITextTokenizerFunction | undefined; + + /** + * Get the active tokenizer function + * @returns + */ + static getTokenizer(): TextTokenizer.ITextTokenizerFunction { + return this._customTokenizer || this.defaultTokenizer; + } + + /** + * Inject or clears the custom text tokenizer. + * @param tokenizer + * @param detectASCII - when 100% ASCII text is tokenized, the default tokenizer should be used + */ + static setCustomTokenizer(tokenizer?: TextTokenizer.ITextTokenizerFunction, detectASCII: boolean = false): void { + if (!tokenizer || !detectASCII) { + this._customTokenizer = tokenizer; + } else { + this._customTokenizer = (text) => TextTokenizer.containsOnlyASCII(text) ? this.defaultTokenizer(text) : tokenizer(text); + } + } + + /** + * Returns true when `text` contains only ASCII characters. + **/ + static containsOnlyASCII(text: string): boolean { + // It checks the first char to fail fast for most non-English strings + // The regex will match any character that is not in ASCII + // - first, matching all characters between space (32) and ~ (127) + // - second, matching all unicode quotation marks (see https://hexdocs.pm/ex_unicode/Unicode.Category.QuoteMarks.html) + // - third, matching the unicode en/em dashes (see https://en.wikipedia.org/wiki/Dash#Unicode) + return text.charAt(0) <= 'z' && !/[^ -~'-›–—]/.test(text); + } + + /** + * Default tokenizer implementation, suitable for most languages + * @param text + * @returns + */ + static defaultTokenizer(text: string): TextTokenizer.ITextTokenizerSpan[] { + const words: string[] = []; + const len = text.length; + let startIndex = 0; + let i = 0; + for (; i < len; i++) { + const c = text.charAt(i); + if (c === " " || c === "\u200B") { + if (i - startIndex > 0) { + words.push(text.substring(startIndex, i)); + } + startIndex = i + 1; + if (c === " ") { + words.push(" "); + } + } + } + if (i - startIndex > 0) { + words.push(text.substring(startIndex, len)); + } + return [ + { + tokens: words, + }, + ]; + } +} + +export default TextTokenizer; diff --git a/src/textures/bidi.d.mts b/src/textures/bidi.d.mts new file mode 100644 index 00000000..94e983c8 --- /dev/null +++ b/src/textures/bidi.d.mts @@ -0,0 +1,18 @@ +/** + * Minimal TypeScript declaration for bidi-js + */ +declare module "bidi-js" { + export interface BidiAPI { + getEmbeddingLevels( + text: string, + baseDirection?: "ltr" | "rtl" | "auto" + ): { + levels: number[]; + }; + getBidiCharTypeName(c: string): "L" | "R" | "AL" | "EN" | "ES" | "CS" | "ON" | "NSM" | "BN" | "B" | "S" | "WS" | "PDF" | "LRE" | "RLE" | "LRO" | "RLO"; + } + + declare function bidiFactory(): BidiAPI; + + export default bidiFactory; +} diff --git a/src/textures/bidiTokenizer.ts b/src/textures/bidiTokenizer.ts new file mode 100644 index 00000000..8648006e --- /dev/null +++ b/src/textures/bidiTokenizer.ts @@ -0,0 +1,261 @@ +/* + * 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 bidiFactory, { type BidiAPI } from "bidi-js"; +import type { DirectedSpan } from "./TextTextureRendererAdvancedUtils.js"; + +let bidi: BidiAPI; + +type Direction = 'lri' | 'rli' | 'fsi' | 'default'; + +// https://www.unicode.org/reports/tr9/ +const reZeroWidthSpace = /[\u200B\u200E\u200F\u061C]/; +const reDirectionalFormat = /[\u202A\u202B\u202C\u202D\u202E\u202E\u2066\u2067\u2068\u2069]/; + +const LRI = '\u2066'; // Left-to-Right Isolate ('ltr') +const RLI = '\u2067'; // Right-to-Left Isolate ('rtl') +const FSI = '\u2068'; // First Strong Isolate ('auto') +const PDI = '\u2069'; // Pop Directional Isolate + +const reQuoteStart = /^["“”«»]/; +const reQuoteEnd = /["“”«»]$/; +const rePunctuationStart = /^[.,،:;!?()"-]+/; +const rePunctuationEnd = /[.,،:;!?()"-]+$/; + +/** + * Reverse punctuation characters, mirroring braces + */ +function mirrorPunctuation(punctuation: string): string { + let result = ""; + for (let i = 0; i < punctuation.length; i++) { + let c = punctuation.charAt(i); + if (c === "(") c = ")"; + else if (c === ")") c = "("; + result = c + result; + } + return result; +} + +/** + * Mirror directional single character + */ +function mirrorSingle(char: string): string { + if (char === '"') return '"'; + else if (char === "(") return ")"; + else if (char === ")") return "("; + else if (char === "“") return "”"; + else if (char === "”") return "“"; + else if (char === "«") return "»"; + else if (char === "»") return "«"; + return char; +} + +/** + * Reverse punctuation surrounding a token + */ +function mirrorTokenPunctuation(token: string): string { + // single character could be a punctuation + if (token.length <= 1) { + return mirrorSingle(token); + } + + // extract quotes + const startQuote = token.match(reQuoteStart); + const endQuote = token.match(reQuoteEnd); + if (startQuote) { + token = token.substring(1); + } + if (endQuote) { + token = token.substring(0, token.length - 1); + } + + // has punctuation at the start + const start = token.match(rePunctuationStart); + if (start) { + token = token.substring(start[0].length); + } + + if (token.length > 1) { + // has punctuation at the end + const end = token.match(rePunctuationEnd); + if (end) { + token = token.substring(0, token.length - end[0].length); + token = mirrorPunctuation(end[0]) + token; + } + } + + if (start) { + token = token + mirrorPunctuation(start[0]); + } + + // add quotes back + if (startQuote) { + token = token + mirrorSingle(startQuote[0]); + } + if (endQuote) { + token = mirrorSingle(endQuote[0]) + token; + } + return token; +} + +/** + * RTL aware tokenizer + */ +export function getBidiTokenizer() { + if (!bidi) { + bidi = bidiFactory(); + } + + function tokenize(text: string): DirectedSpan[] { + // initial direction + const dir = text.startsWith(LRI) ? 'ltr' : text.startsWith(RLI) ? 'rtl' : undefined; + const { levels } = bidi.getEmbeddingLevels(text, dir); + let prevLevel = levels[0]!; + let rtl = (prevLevel & 1) > 0; + + const dirs: Direction[] = ['fsi']; + const spans: (DirectedSpan & { dir?: Direction })[] = []; + let tokens: string[] = []; + let span: DirectedSpan & { dir?: Direction } = { + dir: dir === undefined ? 'fsi' : dir === 'ltr' ? 'lri' : 'rli', + rtl, + tokens, + }; + spans.push(span); + let t = ""; + + // test whether the token has a strong direction + const detectDirection = (token: string): void => { + for (let i = 0; i < token.length; i++) { + const type = bidi.getBidiCharTypeName(token.charAt(i)); + if (type === 'L') { + dirs[dirs.length - 1] = 'lri'; + span.dir = 'lri'; + span.rtl = false; + rtl = false; + break; + } + if (type === 'R' || type === 'AL') { + dirs[dirs.length - 1] = 'rli'; + span.dir = 'rli'; + span.rtl = true; + rtl = true; + break; + } + } + } + + const commit = () => { + if (!t.length) return; + + // auto direction + if (span.dir === 'fsi') { + detectDirection(t); + } + + if (rtl) { + t = mirrorTokenPunctuation(t); + } + tokens.push(t); + t = ""; + }; + + // start new span + const flip = () => { + tokens = []; + span = { + dir: dirs[dirs.length - 1]!, + rtl, + tokens: tokens, + }; + spans.push(span); + }; + + const enterIsolate = (dir: Direction) => { + dirs.push(dir); + if (!tokens.length) { + if (dir !== 'fsi') span.dir = dir; + } else { + flip(); + } + }; + + const endIsolate = () => { + dirs.pop(); + if (dirs.length === 0) { + dirs.push('fsi'); + } + }; + + for (let i = 0; i < text.length; i++) { + const c = text.charAt(i); + + // control characters + if (reDirectionalFormat.test(c)) { + commit(); + // direction isolates create an isolated span of text + if (c === LRI) { + enterIsolate('lri'); + } else if (c === RLI) { + enterIsolate('rli'); + } else if (c === FSI) { + enterIsolate('fsi'); + } else if (c === PDI) { + endIsolate(); + } + continue; + } + + // level change means direction change + if (levels[i] !== prevLevel) { + commit(); + prevLevel = levels[i]!; + const _rtl = (prevLevel & 1) > 0; + if (rtl !== _rtl) { + rtl = _rtl; + if (span.dir === 'fsi') { + // append to auto-direction span + span.rtl = rtl; + } else { + flip(); + } + } + } + + if (c === " ") { + commit(); + tokens.push(c); + } else if (reZeroWidthSpace.test(c)) { + commit(); + } else { + t += c; + } + } + commit(); + + // remove dir, not needed + spans.forEach((span) => { + delete span.dir; + }); + + return spans; + } + + return tokenize; +} diff --git a/src/tree/Element.d.mts b/src/tree/Element.d.mts index bbf71d1b..08e36e06 100644 --- a/src/tree/Element.d.mts +++ b/src/tree/Element.d.mts @@ -549,6 +549,16 @@ declare namespace Element { */ boundsMargin: [number, number, number, number] | null; + + /** + * Set RTL (right-to-left) flag on the element + * + * Unless RTL is set, it is inherited from the parent element + * + * @defaultValue null + */ + rtl: boolean | null; + /** * X position of this Element * @@ -1333,6 +1343,9 @@ declare class Element< _onResize(): void; + /** RTL direction flag changed */ + _onDirectionChanged(): void; + readonly renderWidth: number; readonly renderHeight: number; @@ -1516,6 +1529,14 @@ declare class Element< boundsMargin: [number, number, number, number] | null; + /** + * RTL (right-to-left) layout direction flag + * + * Set `null` to inherit from parent + */ + get rtl(): boolean; + set rtl(value: boolean); + /** * X position of this Element * diff --git a/src/tree/Element.mjs b/src/tree/Element.mjs index 2957b4a6..a47b56ff 100644 --- a/src/tree/Element.mjs +++ b/src/tree/Element.mjs @@ -281,6 +281,16 @@ export default class Element { return this._isEnabled() && this.withinBoundsMargin; }; + /** + * RTL direction flag changed + */ + _updateDirection(rtl) { + if (this.texture instanceof TextTexture) { + this.texture.rtl = rtl; + } + this._onDirectionChanged(); + } + /** * Updates the 'attached' flag for this branch. */ @@ -433,6 +443,9 @@ export default class Element { _onResize() { } + _onDirectionChanged() { + } + _getRenderWidth() { if (this._w) { return this._w; @@ -1371,6 +1384,14 @@ export default class Element { return this.__core.boundsMargin; } + set rtl(value) { + this.__core.rtl = value; + } + + get rtl() { + return this.__core.rtl; + } + get x() { return this.__core.offsetX; } @@ -1748,6 +1769,7 @@ export default class Element { enableTextTexture() { if (!this.texture || !(this.texture instanceof TextTexture)) { this.texture = new TextTexture(this.stage); + this.texture.rtl = this.__core._rtl; // quick flag access if (!this.texture.w && !this.texture.h) { // Inherit dimensions from element. diff --git a/src/tree/Texture.mjs b/src/tree/Texture.mjs index 39ca10e0..6bd42ba9 100644 --- a/src/tree/Texture.mjs +++ b/src/tree/Texture.mjs @@ -607,7 +607,7 @@ export default class Texture { } // If dimensions are unknown (texture not yet loaded), use maximum width as a fallback as render width to allow proper bounds checking. - return (this._w || (this._source ? this._source.getRenderWidth() - this._x : 0)) / this._precision; + return (Math.abs(this._w) || (this._source ? this._source.getRenderWidth() - this._x : 0)) / this._precision; } getRenderHeight() { @@ -616,7 +616,7 @@ export default class Texture { return 0; } - return (this._h || (this._source ? this._source.getRenderHeight() - this._y : 0)) / this._precision; + return (Math.abs(this._h) || (this._source ? this._source.getRenderHeight() - this._y : 0)) / this._precision; } patch(settings) { diff --git a/src/tree/core/ElementCore.d.mts b/src/tree/core/ElementCore.d.mts index ebef0366..602dae6b 100644 --- a/src/tree/core/ElementCore.d.mts +++ b/src/tree/core/ElementCore.d.mts @@ -104,6 +104,8 @@ declare class ElementCore { get renderContext(): ElementCore.RenderContext; get rotation(): number; set rotation(v: number); + get rtl(): boolean; + set rtl(value: boolean); get scale(): number; set scale(v: number); get scaleX(): number; diff --git a/src/tree/core/ElementCore.mjs b/src/tree/core/ElementCore.mjs index 098bb64d..2cae2037 100644 --- a/src/tree/core/ElementCore.mjs +++ b/src/tree/core/ElementCore.mjs @@ -19,6 +19,8 @@ import FlexTarget from "../../flex/FlexTarget.mjs"; +const MIN_ALPHA_RENDER = 0.002; + export default class ElementCore { constructor(element) { @@ -95,6 +97,9 @@ export default class ElementCore { this._colorUl = this._colorUr = this._colorBl = this._colorBr = 0xFFFFFFFF; + this._ownRtl = null; // inherit + this._rtl = null; + this._x = 0; this._y = 0; this._w = 0; @@ -161,6 +166,23 @@ export default class ElementCore { this._layout = null; } + set rtl(value) { + if (value !== this._rtl) { + if (typeof value !== 'boolean') { + this._ownRtl = null; + } else { + this._ownRtl = value; + } + this.updateDirection(false); + } + } + + get rtl() { + return typeof this._ownRtl === 'boolean' ? + this._ownRtl : + this._parent ? this._parent.rtl : false; + } + get offsetX() { if (this._funcX) { return this._funcX; @@ -611,6 +633,8 @@ export default class ElementCore { this._setRecalc(1 + 2 + 4); if (this._parent) { + // Align direction with parent + this.updateDirection(!prevParent); // Force parent to propagate hasUpdates flag. this._parent._setHasUpdates(); } @@ -1304,6 +1328,31 @@ export default class ElementCore { return this._boundsMargin; } + updateDirection(initial) { + // `initial` indicates that the element was just created + + // Inherit RTL flag, unless locally overriden + const rtl = typeof this._ownRtl === 'boolean' ? this._ownRtl : this._parent._rtl; + if (rtl === this._rtl) { + return; + } + this._rtl = rtl; + + // update element when the direction changes + if (!initial) { + this._triggerRecalcTranslate(); + } + // allow side effect + this._element._updateDirection(rtl); + + // propagate + if (this._children) { + for (let i = 0, n = this._children.length; i < n; i++) { + this._children[i].updateDirection(initial); + } + } + } + update() { this._recalc |= this._parent._pRecalc; @@ -1349,10 +1398,17 @@ export default class ElementCore { } if (recalc & 6) { - w.px = pw.px + this._localPx * pw.ta; - w.py = pw.py + this._localPy * pw.td; - if (pw.tb !== 0) w.px += this._localPy * pw.tb; - if (pw.tc !== 0) w.py += this._localPx * pw.tc; + let px = this._localPx; + const py = this._localPy; + const parent = this._parent; + if (parent && parent._rtl && parent._w) { + // RTL parent: position is mirrored, starting from the right. + px = parent._w - this._w * this._scaleX - px; + } + w.px = pw.px + px * pw.ta; + w.py = pw.py + py * pw.td; + if (pw.tb !== 0) w.px += py * pw.tb; + if (pw.tc !== 0) w.py += px * pw.tc; } if (recalc & 4) { @@ -1767,7 +1823,7 @@ export default class ElementCore { this.sortZIndexedChildren(); } - if (this._outOfBounds < 2 && this._renderContext.alpha) { + if (this._outOfBounds < 2 && this._renderContext.alpha >= MIN_ALPHA_RENDER) { let renderState = this.renderState; if ((this._outOfBounds === 0) && this._displayedTextureSource) { @@ -1807,7 +1863,7 @@ export default class ElementCore { this.sortZIndexedChildren(); } - if (this._outOfBounds < 2 && this._renderContext.alpha) { + if (this._outOfBounds < 2 && this._renderContext.alpha >= MIN_ALPHA_RENDER) { let renderState = this.renderState; let mustRenderChildren = true; @@ -2143,7 +2199,7 @@ export default class ElementCore { collectAtCoord(x, y, children) { // return when branch is hidden - if (this._renderContext.alpha === 0) { + if (this._renderContext.alpha < MIN_ALPHA_RENDER) { return; } diff --git a/src/types/lng.types.namespace.d.mts b/src/types/lng.types.namespace.d.mts index 34de5543..2379d198 100644 --- a/src/types/lng.types.namespace.d.mts +++ b/src/types/lng.types.namespace.d.mts @@ -34,8 +34,8 @@ import C2dRenderer from "../renderer/c2d/C2dRenderer.mjs"; import WebGLCoreQuadList from "../renderer/webgl/WebGLCoreQuadList.mjs"; import WebGLCoreQuadOperation from "../renderer/webgl/WebGLCoreQuadOperation.mjs"; import WebGLRenderer from "../renderer/webgl/WebGLRenderer.mjs"; -import TextTextureRenderer from "../textures/TextTextureRenderer.mjs"; -import TextTextureRendererAdvanced from "../textures/TextTextureRendererAdvanced.mjs"; +import TextTextureRenderer from "../textures/TextTextureRenderer.js"; +import TextTextureRendererAdvanced from "../textures/TextTextureRendererAdvanced.js"; import CoreContext from "../tree/core/CoreContext.mjs"; import CoreQuadList from "../tree/core/CoreQuadList.mjs"; import CoreQuadOperation from "../tree/core/CoreQuadOperation.mjs"; diff --git a/tests/automation.spec.ts b/tests/automation.spec.ts index 7fe5a1ca..1d1eaaa4 100644 --- a/tests/automation.spec.ts +++ b/tests/automation.spec.ts @@ -1,3 +1,22 @@ +/* + * 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'; function promiseWithResolver() { @@ -24,12 +43,16 @@ async function testCase(page: Page, name: string): Promise { return promise; } -test('Texture', async ({ page }) => { - expect(await testCase(page, 'texture')).toBe('SUCCESS'); +test('Textures', async ({ page }) => { + expect(await testCase(page, 'Textures')).toBe('SUCCESS'); +}); + +test('Texture mirroring', async ({ page }) => { + expect(await testCase(page, 'Texture mirroring')).toBe('SUCCESS'); }); test('Text', async ({ page }) => { - expect(await testCase(page, 'text')).toBe('SUCCESS'); + expect(await testCase(page, 'Text')).toBe('SUCCESS'); }); test('Key handling', async ({ page }) => { @@ -107,3 +130,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..06fdd0cd --- /dev/null +++ b/tests/rtl/test.rtl.js @@ -0,0 +1,369 @@ +/* + * 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}`, + 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, + }); + // texture was rendered without RTL flag - initial/default state + chai.assert(!label.texture.source.lookupId.includes("|rtl")); + }); + + 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, + }); + // texture was rendered without RTL flag - remain in default state + chai.assert(!b0Label.texture.source.lookupId.includes("|rtl")); + }); + }); + + 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, + }); + // texture was rendered with RTL flag, propagated after the scroller item is attached + chai.assert(label.texture.source.lookupId.includes("|rtl")); + }); + + 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, + }); + // texture was rendered with RTL flag, propagated after the scroller item is attached + chai.assert(b0Label.texture.source.lookupId.includes("|rtl")); + }); + + 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, + }); + }); + + it("Should propagate flag after attachment", () => { + const parent = new lng.Element(app.stage); + parent.patch({ + y: 200, + Label: { + w: 600, // text should be visually aligned to the right at the end of the blue box + text: { + text: "Dynamic attachment", + fontSize: 40, + textColor: 0xff000000, + }, + }, + LabelAdvanced: { + y: 40, + w: 600, // text should be visually aligned to the right at the end of the blue box + text: { + advancedRenderer: true, + text: "Dynamic attachment", + fontSize: 40, + textColor: 0xff000000, + }, + }, + }); + + // before attachment to the app (and stage), the texture doesn't have the RTL flag (like its parent) + chai.assert(!parent.tag("Label").texture.source.lookupId.includes("|rtl")); + chai.assert(!parent.tag("LabelAdvanced").texture.source.lookupId.includes("|rtl")); + + app.childList.add(parent); + stage.drawFrame(); + + // after attachment, the texture should have the RTL flag + chai.assert(parent.tag("Label").texture.source.lookupId.includes("|rtl")); + }); + }); +}); diff --git a/tests/test.html b/tests/test.html index 1493036d..0c2a3610 100644 --- a/tests/test.html +++ b/tests/test.html @@ -17,6 +17,7 @@ limitations under the License. --> + @@ -35,6 +36,7 @@ + @@ -53,6 +55,8 @@ + + + + + + + + diff --git a/tests/text-rendering.spec.ts b/tests/text-rendering.spec.ts new file mode 100644 index 00000000..87b79648 --- /dev/null +++ b/tests/text-rendering.spec.ts @@ -0,0 +1,231 @@ +/* + * 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 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 DETECTION_TESTS = BIDI_TESTS + BIDI_COUNT; +const DETECTION_COUNT = 3; +const COMPLEX_HEBREW = BIDI_TESTS + BIDI_COUNT + DETECTION_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 < COMPLEX_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 >= BIDI_TESTS ? 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 < STYLED_TESTS + STYLED_COUNT; 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 compareDetection(page: Page, width: number, start: number, count: number) { + await page.setViewportSize({ width, height: 4000 }); + await page.goto("/tests/text-rendering.html?playwright"); + + for (let i = start; i < start + count; i++) { + await page + .locator(`#preview${i}`) + .screenshot({ path: `temp/detection-${width}-test${i}-html.png` }); + await page + .locator(`#canvas${i}`) + .screenshot({ path: `temp/detection-${width}-test${i}-canvas.png` }); + + const { equal, diffImage, differentPixels } = await looksSame( + `temp/detection-${width}-test${i}-html.png`, + `temp/detection-${width}-test${i}-canvas.png`, + { + createDiffImage: true, + strict: false, + } + ); + diffImage?.save(`temp/detection-${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 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 = start; i < start + count; i++) { + await page + .locator(`#preview${i}`) + .screenshot({ path: `temp/complex-${width}-test${i}-html.png` }); + await page + .locator(`#canvas${i}`) + .screenshot({ path: `temp/complex-${width}-test${i}-canvas.png` }); + + const { equal, diffImage, differentPixels } = await looksSame( + `temp/complex-${width}-test${i}-html.png`, + `temp/complex-${width}-test${i}-canvas.png`, + { + createDiffImage: true, + strict: false, + } + ); + diffImage?.save(`temp/complex-${width}-diff${i}.png`); + + if (differentPixels) { + console.log( + `Test ${i} - ${width}px - different pixels: ${differentPixels}` + ); + } + 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, 1900); +}); + +test("wrap 840", async ({ page }) => { + await compareWrapping(page, 840); +}); + +test("wrap 720", async ({ page }) => { + await compareWrapping(page, 720); +}); + +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); +}); + +test("letter spacing 2", async ({ page }) => { + await compareLetterSpacing(page, 550); +}); + +test("direction detection", async ({ page }) => { + await compareDetection(page, 1000, DETECTION_TESTS, DETECTION_COUNT); +}); + +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 new file mode 100644 index 00000000..723a1d5f --- /dev/null +++ b/tests/text-rendering/index.js @@ -0,0 +1,391 @@ +/* + * 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 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"; + +const LRI = '\u2066'; // LRI - Left-to-Right Isolate ('ltr') +const RLI = '\u2067'; // RLI - Right-to-Left Isolate ('rtl') +const FSI = '\u2068'; // FSI - First Strong Isolate ('auto') +const PDI = '\u2069'; // PDI - Pop Directional Isolate + +const MAX_WIDTH = 2048; // max width of the canvas +let testN = 0; +let sectionTitle = ''; +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 = Math.min(window.innerWidth - 16, MAX_WIDTH); + +async function demo() { + // const t0 = performance.now(); + testN = 0; + root.innerHTML = ""; + root.style.width = renderWidth + "px"; + root.className = `spacing-${letterSpacing}`; + + // reset tokenizer + TextTokenizer.setCustomTokenizer(); + + // basic renderer + sectionTitle = 'Basic text renderer'; + + await renderText( + TextTextureRenderer, + "First line\nAnd a second line of some rather long text" + ); + await renderText( + TextTextureRenderer, + "One first line of some rather long text.\nAnd another quite long line; maybe longer!" + ); + + // styled rendering + sectionTitle = 'Advanced text renderer'; + + await renderText( + TextTextureRendererAdvanced, + "First line\nAnd a second line of some styled text" + ); + + // Bidi rendering + sectionTitle = 'Bidirectional text'; + + // `bidiTokenizer.es5.js` attaches declarations to global `lng` object + TextTokenizer.setCustomTokenizer(lng.getBidiTokenizer()); + + await renderText( + TextTextureRendererAdvanced, + "Something with arabic embedded (that: !أسباب لمشاهدة).", + "left", + 2, + false + ); + await renderText( + TextTextureRendererAdvanced, + "Something with hebrew embedded (that: !באמצעות מצלמת).", + "left", + ); + + await renderText( + TextTextureRendererAdvanced, + "خمسة أسباب ①لمشاهدة عرض ONE Fight② Night 21", + "right", + 2, + false + ); + + await renderText( + TextTextureRendererAdvanced, + 'أكبر الرابحين من عرض ONE Fight Night 21 من بطولة "ون"', + "right", + 2, + false + ); + + // Direction detection + sectionTitle = 'Direction detection'; + + // as numbers are weakly directional, the general direction should be RTL + await renderText( + TextTextureRendererAdvanced, + '1 2 ' + 'יום' + ' 3 4', + "right", + 2, + false + ); + + await renderText( + TextTextureRendererAdvanced, + '1 שעות ו-2.5 דקות', + // '1 2 ' + 'יום' + ' 3 4', + "right", + 2, + false + ); + + // this test enforces a general RTL direction, but each section is isolated with direction detection + // so the first part can be LTR but be rendered on the right side, and the RTL second part's `90` isn't + // considered to be part of the first LTR part + await renderText( + TextTextureRendererAdvanced, + RLI + FSI + '90 minutes' + PDI + ' ' + FSI + '90 דקות' + PDI + PDI, + "right", + 2, + false + ); + + // Complex tests + sectionTitle = 'Complex cases'; + + await renderText( + TextTextureRendererAdvanced, + "סרוק את קוד ה-QR באמצעות מצלמת הטלפון או הטאבלט שלך. (some english text)", + "right" + ); + + await renderText( + TextTextureRendererAdvanced, + "הגיע הזמן לעדכן את אפליקציית TheBrand ולקבל את התכונות (ביותר!) החדשות ביותר (והטובות ביותר!). סמוך עלינו - אתה תאהב אותן.", + "right" + ); + + await renderText( + TextTextureRendererAdvanced, + 'أيضًا، نُدرج الأرقام ١٢٣٤٥٦٧٨٩٠، ورابط إلكتروني: user@example.com، مع علامات ترقيم؟!، ونصوص مختلطة الاتجاه مثل: "Hello, مرحبًا".', + "right", + 3, + false + ); + + // 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 = Math.min(window.innerWidth - 16, MAX_WIDTH); + demo(); + }, 10); +}); + +async function renderText( + Renderer /*typeof TextTextureRenderer*/, + source /*string*/, + textAlign /*"left" | "right" | "center"*/, + maxLines /*number = 2*/, + allowTextTruncation /*boolean = true*/ +) { + 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, ""); + + const testCase = document.createElement("div"); + testCase.id = `test${testN}`; + root.appendChild(testCase); + + const title = document.createElement("h2"); + title.innerHTML = `Test ${testN} / ${sectionTitle}`; + 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}`; + // DOM doesn't seem to detect the bidi isolate tag at the start of the text + preview.dir = text.startsWith(LRI) ? 'ltr' : text.startsWith(RLI) ? 'rtl' : '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, + wordWrapWidth, + maxLines, + advancedRenderer: text.indexOf(" 0, + }; + TextTexture.allowTextTruncation = allowTextTruncation; + + try { + const drawCanvas = document.createElement("canvas"); + const renderer = new Renderer(stage, drawCanvas, settings); + await 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); + } +} + +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.mirroring.js b/tests/textures/test.mirroring.js new file mode 100644 index 00000000..115e3d76 --- /dev/null +++ b/tests/textures/test.mirroring.js @@ -0,0 +1,160 @@ +/* + * 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 + ); + const bl = toNumberColor(ctx.getImageData(1, canvas.height - 2, 1, 1).data); + const br = toNumberColor( + ctx.getImageData(canvas.width - 2, canvas.height - 2, 1, 1).data + ); + + return { tl, tr, bl, br }; + } + + function renderTest(canvas2d, mirrorX, mirrorY) { + 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(198, 198, [0, 30, 30, 30], 0, 0, true, 0xffff0000), + pivot: 0, + scale: 0.5 // ensure calculations work with scaling + }, + }); + app.children = [element]; + + const texture = app.tag("Item").texture; + if (mirrorX) { + texture.x = 200; + texture.w = -200; + } + if (mirrorY) { + texture.y = 200; + texture.h = -200; + } + + stage.drawFrame(); + } + + afterEach(() => { + stage.stop(); + stage.getCanvas().remove(); + }); + + it("non-mirrored control in webGl", () => { + renderTest(false, false); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.tl === 0x0000ff && capture.tr === 0xffffff, + "Top-left should be red, top-right should be white" + ); + }); + + it("can mirror horizontally in webGl", () => { + renderTest(false, true); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.tl === 0xffffff && capture.tr === 0x0000ff, + "Top-left should be white, top-right should be red" + ); + }); + + it("can mirror vertically in webGl", () => { + renderTest(false, false, true); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.tl === 0xffffff && capture.bl === 0x0000ff, + "Top-left should be white, bottom-left should be red" + ); + }); + + it("can mirror horizontally and vertically in webGl", () => { + renderTest(false, true, true); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.tl === 0xffffff && capture.br === 0x0000ff, + "Top-left should be white, bottom-right should be red" + ); + }); + + it("non-mirrored control in canvas2d", () => { + renderTest(true, false); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.tl === 0x0000ff && capture.tr === 0xffffff, + "Top-left should be red, top-right should be white" + ); + }); + + it("can mirror horizontally in canvas2d", () => { + renderTest(true, true); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.tl === 0xffffff && capture.tr === 0x0000ff, + "Top-left should be white, top-right should be red" + ); + }); + + it("can mirror vertically in canvas2d", () => { + renderTest(true, false, true); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.tl === 0xffffff && capture.bl === 0x0000ff, + "Top-left should be white, bottom-left should be red" + ); + }); + + it("can mirror horizontally and vertically in canvas2d", () => { + renderTest(true, true, true); + + const capture = captureCanvasCornerColors(stage.getCanvas()); + chai.assert( + capture.tl === 0xffffff && capture.br === 0x0000ff, + "Top-left should be white, bottom-right should be red" + ); + }); +}); diff --git a/tests/textures/test.text.js b/tests/textures/test.text.js index a37adf7b..c4fe157e 100644 --- a/tests/textures/test.text.js +++ b/tests/textures/test.text.js @@ -29,6 +29,13 @@ 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 +/** @return {string} */ +function getLineText(info) { + if (info.words) return info.words.map(w => w.text).join('').trimEnd(); + return info.text.trimEnd(); +} + describe('text', function() { this.timeout(0); @@ -100,7 +107,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 +126,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 +148,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 +168,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 +202,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 +232,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 +263,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() { @@ -289,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(() => { diff --git a/tests/textures/test.textures.js b/tests/textures/test.textures.js index 0af897e5..3d1da282 100644 --- a/tests/textures/test.textures.js +++ b/tests/textures/test.textures.js @@ -17,7 +17,7 @@ * limitations under the License. */ -describe('textures', function() { +describe('Textures', function() { this.timeout(0); let app; diff --git a/vite.config.js b/vite.config.js index e8587afc..468845c1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -11,9 +11,10 @@ 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/lightning.mjs'); +let entry = resolve(__dirname, 'src/index.ts'); let outputBase = 'lightning'; let sourcemap = true; let useDts = true; @@ -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: [ @@ -91,6 +100,7 @@ export default defineConfig(() => { }, test: { exclude: [ + './dist/**', './node_modules/**', './tests/**' ]