diff --git a/docs/RTL/index.md b/docs/RTL/index.md index bca5162f..17914324 100644 --- a/docs/RTL/index.md +++ b/docs/RTL/index.md @@ -22,16 +22,28 @@ Lightning elements (and components) have a `rtl` property to hint whether the el 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 RTL text works +### 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. -When the RTL flag is set, text alignement is mirrored, so left-aligned text becomes right-aligned. +```typescript +import { TextTexture, TextTokenizer } from '@lightningjs/core'; +import { getBidiTokenizer } from '@lightningjs/core/bidiTokenizer'; -But RTL text support also requires to properly wrap text and render punctuation at the right place. Text also may be a combination of RTL and LTR text. +// Initialize bidi text support +TextTokenizer.setCustomTokenizer(getBidiTokenizer()); -TODO +// Only the "advanced renderer" supports bidi layout +TextTexture.forceAdvancedRenderer = true; +``` 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/package-lock.json b/package-lock.json index 1718ee61..a7d0dd94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@lightningjs/core", "version": "2.16.0-beta.1", "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 285741b7..749fafeb 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,11 @@ "import": "./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" }, "sideEffects": [ @@ -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/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/textures/TextTexture.d.mts b/src/textures/TextTexture.d.mts index 04ed8adb..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 { /** @@ -461,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 diff --git a/src/textures/TextTexture.mjs b/src/textures/TextTexture.mjs index 838d6a63..10437c10 100644 --- a/src/textures/TextTexture.mjs +++ b/src/textures/TextTexture.mjs @@ -29,8 +29,11 @@ export default class TextTexture extends Texture { this._precision = this.stage.getOption('precision'); } + static forceAdvancedRenderer = false; + static allowTextTruncation = true; + static renderer(stage, canvas, settings) { - if (settings.advancedRenderer) { + if (settings.advancedRenderer || TextTexture.forceAdvancedRenderer) { return new TextTextureRendererAdvanced(stage, canvas, settings); } else { return new TextTextureRenderer(stage, canvas, settings); @@ -735,5 +738,5 @@ proto._advancedRenderer = false; proto._fontBaselineRatio = 0; -import TextTextureRenderer from "./TextTextureRenderer.mjs"; -import TextTextureRendererAdvanced from "./TextTextureRendererAdvanced.mjs"; +import TextTextureRenderer from "./TextTextureRenderer.js"; +import TextTextureRendererAdvanced from "./TextTextureRendererAdvanced.js"; diff --git a/src/textures/TextTextureRenderer.d.mts b/src/textures/TextTextureRenderer.d.mts deleted file mode 100644 index 83d6620a..00000000 --- a/src/textures/TextTextureRenderer.d.mts +++ /dev/null @@ -1,60 +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 TextTextureRenderer { - export interface LineInfo { - /** - * allLines - lines after wrapping - */ - l: Array; - /** - * 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 09895302..00000000 --- a/src/textures/TextTextureRenderer.mjs +++ /dev/null @@ -1,443 +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; - }; - - _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 = []; - - let textAlign = this._settings.textAlign; - if (this._settings.rtl) { - if (!textAlign || textAlign === 'left') textAlign = 'right'; - else if (textAlign === 'right') textAlign = 'left'; - } - - // 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 (textAlign === 'right') { - linePositionX += (renderInfo.innerWidth - renderInfo.lineWidths[i]); - } else if (textAlign === 'center') { - linePositionX += ((renderInfo.innerWidth - renderInfo.lineWidths[i]) / 2); - } - linePositionX += renderInfo.paddingLeft; - - 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..a2be1e10 --- /dev/null +++ b/src/textures/TextTextureRenderer.ts @@ -0,0 +1,500 @@ +/* + * 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; + } + + 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 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); + } + + const rtl = this._settings.rtl; + let textAlign = this._settings.textAlign; + if (rtl) { + if (textAlign === "left") textAlign = "right"; + else if (textAlign === "right") textAlign = "left"; + } + + const offsetY = renderInfo.offsetY; + + let linePositionX; + let linePositionY; + + const drawLines: IDrawLineInfo[] = []; + + // Layout lines + for (let i = 0, n = renderInfo.lines.length; i < n; i++) { + const lineWidth = renderInfo.lineWidths[i] || 0; + + linePositionX = rtl ? renderInfo.paddingRight : renderInfo.paddingLeft; + if (i === 0 && !rtl) { + linePositionX += renderInfo.textIndent; + } + + if (textAlign === "right") { + linePositionX += renderInfo.innerWidth - lineWidth; + } else if (textAlign === "center") { + linePositionX += (renderInfo.innerWidth - lineWidth) / 2; + } + + linePositionY = i * renderInfo.lineHeight + offsetY; + + drawLines.push({ + info: renderInfo.lines[i]!, + x: linePositionX, + y: linePositionY, + w: lineWidth, + }); + } + + if (this._settings.highlight) { + this._drawHighlight(precision, renderInfo, drawLines); + } + + if (this._settings.shadow) { + this._drawShadow(precision); + } + + this._drawLines(drawLines, 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, + drawLines: IDrawLineInfo[] + ) { + 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++) { + const drawLine = drawLines[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 32555bbf..00000000 --- a/src/textures/TextTextureRendererAdvanced.mjs +++ /dev/null @@ -1,689 +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(); - - let textAlign = this._settings.textAlign; - if (this._settings.rtl) { - if (!textAlign || textAlign === 'left') textAlign = 'right'; - else if (textAlign === 'right') textAlign = 'left'; - } - - 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 = 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..43860995 --- /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: ILineInfo[]; + 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.ts b/src/textures/TextTokenizer.ts new file mode 100644 index 00000000..94d11ab8 --- /dev/null +++ b/src/textures/TextTokenizer.ts @@ -0,0 +1,114 @@ +/* + * 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) ? tokenizer(text) : this.defaultTokenizer(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) + 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..7146334c --- /dev/null +++ b/src/textures/bidi.d.mts @@ -0,0 +1,12 @@ +/** + * Minimal TypeScript declaration for bidi-js + */ +declare module "bidi-js" { + export interface BidiAPI { + getEmbeddingLevels(text: string): { levels: number[] }; + } + + 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..b8831579 --- /dev/null +++ b/src/textures/bidiTokenizer.ts @@ -0,0 +1,172 @@ +/* + * 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; + +// https://www.unicode.org/reports/tr9/ +const reZeroWidthSpace = /[\u200B\u200E\u200F\u061C]/g; +const reDirectionalFormat = /[\u202A\u202B\u202C\u202D\u202E\u202E\u2066\u2067\u2068\u2069]/g; + +const reQuoteStart = /^["“”«»]/; +const reQuoteEnd = /["“”«»]$/; +const rePunctuationStart = /^[.,،:;!?()"-]+/; +const rePunctuationEnd = /[.,،:;!?()"-]+$/; + +/** + * 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[] { + const { levels } = bidi.getEmbeddingLevels(text); + let prevLevel = levels[0]!; + let rtl = (prevLevel & 1) > 0; + let t = ""; + + const spans: DirectedSpan[] = []; + let tokens: string[] = []; + spans.push({ + rtl, + tokens, + }); + + const commit = () => { + if (!t.length) return; + if (rtl) { + t = mirrorTokenPunctuation(t); + } + tokens.push(t); + t = ""; + }; + + const flip = () => { + rtl = !rtl; + tokens = []; + spans.push({ + rtl, + tokens, + }); + }; + + for (let i = 0; i < text.length; i++) { + if (levels[i] !== prevLevel) { + prevLevel = levels[i]!; + commit(); + flip(); + } + + const c = text.charAt(i); + if (c === " ") { + commit(); + tokens.push(c); + } else if (reZeroWidthSpace.test(c)) { + commit(); + } else if (!reDirectionalFormat.test(c)) { + t += c; + } + } + commit(); + + return spans; + } + + return tokenize; +} 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/test.html b/tests/test.html index c93e45d7..2f732acb 100644 --- a/tests/test.html +++ b/tests/test.html @@ -17,6 +17,7 @@ limitations under the License. --> + diff --git a/tests/text-rendering.html b/tests/text-rendering.html new file mode 100644 index 00000000..e27b5ffc --- /dev/null +++ b/tests/text-rendering.html @@ -0,0 +1,91 @@ + + + + + + + + + + + + + diff --git a/tests/text-rendering.spec.ts b/tests/text-rendering.spec.ts new file mode 100644 index 00000000..8275affa --- /dev/null +++ b/tests/text-rendering.spec.ts @@ -0,0 +1,191 @@ +/* + * 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 COMPLEX_HEBREW = BIDI_TESTS + BIDI_COUNT; +const COMPLEX_HEBREW_COUNT = 2; +const COMPLEX_ARABIC = COMPLEX_HEBREW + COMPLEX_HEBREW_COUNT; +const COMPLEX_ARABIC_COUNT = 1; + +async function compareWrapping(page: Page, width: number) { + page.setDefaultTimeout(2000); + await page.setViewportSize({ width, height: 4000 }); + await page.goto("/tests/text-rendering.html?playwright"); + + for (let i = FIRST_TEST; i < 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 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("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..aee39b07 --- /dev/null +++ b/tests/text-rendering/index.js @@ -0,0 +1,355 @@ +/* + * 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"; + +let testN = 0; +let letterSpacing = 0; +if (location.search.indexOf("letterSpacing") > 0) { + const match = location.search.match(/letterSpacing=(\d+)/); + if (match) { + letterSpacing = parseInt(match[1], 10); + } +} + +const root = document.createElement("div"); +root.id = "root"; +document.body.appendChild(root); +let renderWidth = window.innerWidth - 16; + +async function demo() { + // const t0 = performance.now(); + testN = 0; + root.innerHTML = ""; + root.style.width = renderWidth + "px"; + root.className = `spacing-${letterSpacing}`; + + // TextTokenizer.setCustomTokenizer(lng.getBidiTokenizer()); + // await renderText( + // TextTextureRendererAdvanced, + // "Something with hebrew (that: מכאן בכל המכשירים שלך!) in it.", + // "left", + // 2 + // ); + // return; + + TextTokenizer.setCustomTokenizer(); + + // basic renderer + + 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 + + await renderText( + TextTextureRendererAdvanced, + "First line\nAnd a second line of some styled text" + ); + + // Bidi rendering + + // `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 + ); + + // Complex tests + + 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 = window.innerWidth - 16; + 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.innerText = `Test ${testN}`; + testCase.appendChild(title); + + // PREVIEW + + const hintHtml = document.createElement("div"); + hintHtml.className = "hint-html"; + hintHtml.innerText = "html"; + testCase.appendChild(hintHtml); + + const previewText = text + .replace(/\n/g, "
") + .replace("", '') + .replace("", ""); + const preview = document.createElement("p"); + preview.id = `preview${testN}`; + preview.className = `lines-${maxLines}`; + preview.dir = "auto"; + testCase.appendChild(preview); + preview.innerHTML = previewText; + preview.style.height = maxLines * 50 + "px"; + + // CANVAS + + const hintCanvas = document.createElement("div"); + hintCanvas.className = "hint-canvas"; + hintCanvas.innerText = "canvas"; + testCase.appendChild(hintCanvas); + + const wrapper = document.createElement("div"); + wrapper.style.textAlign = textAlign; + testCase.appendChild(wrapper); + + const canvas = document.createElement("canvas"); + canvas.id = `canvas${testN}`; + canvas.width = renderWidth; + canvas.height = maxLines * 50; + wrapper.appendChild(canvas); + + const wordWrapWidth = canvas.width; + + // OPTIONS + + const options = { + w: 1920, + h: 1080, + textRenderIssueMargin: 0, + defaultFontFace: "Arial", + }; + + const stage = { + getRenderPrecision() { + return 1; + }, + getOption(name) { + return options[name]; + }, + }; + + const settings = { + ...getDefaultSettings(), + rtl: textAlign === "right", + text, + 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.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/vite.config.js b/vite.config.js index c55a2cec..468845c1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -11,6 +11,7 @@ import { fixTsImportsFromJs } from './fixTsImportsFromJs.vite-plugin'; const isEs5Build = process.env.BUILD_ES5 === 'true'; const isMinifiedBuild = process.env.BUILD_MINIFY === 'true'; const isInspectorBuild = process.env.BUILD_INSPECTOR === 'true'; +const isBidiTokenizerBuild = process.env.BUILD_BIDI_TOKENIZER === 'true'; let outDir = 'dist'; let entry = resolve(__dirname, 'src/index.ts'); @@ -26,6 +27,14 @@ if (isInspectorBuild) { useDts = false; } +if (isBidiTokenizerBuild) { + outDir = 'dist'; + entry = resolve(__dirname, 'src/textures/bidiTokenizer.ts'); + outputBase = 'bidiTokenizer'; + sourcemap = true; + useDts = true; +} + export default defineConfig(() => { return { plugins: [ @@ -91,6 +100,7 @@ export default defineConfig(() => { }, test: { exclude: [ + './dist/**', './node_modules/**', './tests/**' ]