From 8ec6ec7ea05176b003444b616cfc6fd19da214ec Mon Sep 17 00:00:00 2001 From: Stefan Cameron Date: Thu, 18 Jan 2024 15:32:34 -0600 Subject: [PATCH 01/15] Temporarily remove node-canvas reference causing node-gyp failures --- package.json | 1 - src/node-test.ts | 11 +- yarn.lock | 350 +---------------------------------------------- 3 files changed, 16 insertions(+), 346 deletions(-) diff --git a/package.json b/package.json index 022b41f2..5569c813 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "devDependencies": { "@types/lodash": "^4.14.182", "@vitejs/plugin-vue": "^3.0.1", - "canvas": "^2.11.2", "element-plus": "^2.2.12", "lodash": "^4.17.21", "tsup": "^7.2.0", diff --git a/src/node-test.ts b/src/node-test.ts index 0592d51d..98ba4fec 100644 --- a/src/node-test.ts +++ b/src/node-test.ts @@ -1,8 +1,17 @@ -import { createCanvas } from 'canvas' +// DEBUG import { createCanvas } from 'canvas' import { drawText } from './canvas-txt/index' // @ts-ignore import * as fs from 'fs' +// DEBUG REMOVE once figure out how to include canvas@^2.11.2 in devDependencies +// and have `yarn install` actually succeed past the node-gyp process that keeps +// failing even after reinstalling XCode Command Line Tools +// @ts-ignore +const createCanvas = (x: number, y: number) => ({ + // @ts-ignore + getContext(name: string) {} +}) + function main() { const canvas = createCanvas(400, 400) const ctx = canvas.getContext('2d') diff --git a/yarn.lock b/yarn.lock index 5a76656a..18b8a607 100644 --- a/yarn.lock +++ b/yarn.lock @@ -286,21 +286,6 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@mapbox/node-pre-gyp@^1.0.0": - version "1.0.11" - resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz#417db42b7f5323d79e93b34a6d7a2a12c0df43fa" - integrity sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ== - dependencies: - detect-libc "^2.0.0" - https-proxy-agent "^5.0.0" - make-dir "^3.1.0" - node-fetch "^2.6.7" - nopt "^5.0.0" - npmlog "^5.0.1" - rimraf "^3.0.2" - semver "^7.3.5" - tar "^6.1.11" - "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -469,11 +454,6 @@ dependencies: vue-demi "*" -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - acorn@^8.7.1, acorn@^8.8.0: version "8.8.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.0.tgz#88c0187620435c7f6015803f5539dae05a9dbea8" @@ -484,18 +464,6 @@ acorn@^8.9.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.10.0.tgz#8be5b3907a67221a81ab23c7889c4c5526b62ec5" integrity sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw== -agent-base@6: - version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - any-promise@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" @@ -509,19 +477,6 @@ anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -"aproba@^1.0.3 || ^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" - integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== - -are-we-there-yet@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" - integrity sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw== - dependencies: - delegates "^1.0.0" - readable-stream "^3.6.0" - array-union@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" @@ -576,15 +531,6 @@ cac@^6.7.12, cac@^6.7.14: resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== -canvas@^2.11.2: - version "2.11.2" - resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.11.2.tgz#553d87b1e0228c7ac0fc72887c3adbac4abbd860" - integrity sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw== - dependencies: - "@mapbox/node-pre-gyp" "^1.0.0" - nan "^2.17.0" - simple-get "^3.0.3" - chokidar@^3.5.1, chokidar@^3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" @@ -600,16 +546,6 @@ chokidar@^3.5.1, chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" -chownr@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" - integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== - -color-support@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" - integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== - commander@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" @@ -620,11 +556,6 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== -console-control-strings@^1.0.0, console-control-strings@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ== - cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -644,30 +575,13 @@ dayjs@^1.11.3: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.4.tgz#3b3c10ca378140d8917e06ebc13a4922af4f433e" integrity sha512-Zj/lPM5hOvQ1Bf7uAvewDaUcsJoI6JmNqmHhHl3nyumwe0XHwt8sWdOVAPACJzCebL8gQCi+K49w7iKWnGwX9g== -debug@4, debug@^4.3.1, debug@^4.3.4: +debug@^4.3.1, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== dependencies: ms "2.1.2" -decompress-response@^4.2.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986" - integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw== - dependencies: - mimic-response "^2.0.0" - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== - -detect-libc@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" - integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w== - dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -696,11 +610,6 @@ element-plus@^2.2.12: memoize-one "^6.0.0" normalize-wheel-es "^1.2.0" -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - esbuild@^0.18.10: version "0.18.12" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.18.12.tgz#861d37cc321ac797d059f221d9da12acfe555350" @@ -823,13 +732,6 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -fs-minipass@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" - integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== - dependencies: - minipass "^3.0.0" - fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -845,21 +747,6 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -gauge@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-3.0.2.tgz#03bf4441c044383908bcfa0656ad91803259b395" - integrity sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q== - dependencies: - aproba "^1.0.3 || ^2.0.0" - color-support "^1.1.2" - console-control-strings "^1.0.0" - has-unicode "^2.0.1" - object-assign "^4.1.1" - signal-exit "^3.0.0" - string-width "^4.2.3" - strip-ansi "^6.0.1" - wide-align "^1.1.2" - get-stream@^6.0.0: version "6.0.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" @@ -884,18 +771,6 @@ glob@7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.1.3: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - globby@^11.0.3: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" @@ -908,11 +783,6 @@ globby@^11.0.3: merge2 "^1.4.1" slash "^3.0.0" -has-unicode@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ== - has@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" @@ -920,14 +790,6 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" -https-proxy-agent@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" - integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== - dependencies: - agent-base "6" - debug "4" - human-signals@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" @@ -946,7 +808,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3: +inherits@2: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -970,11 +832,6 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - is-glob@^4.0.1, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -1052,13 +909,6 @@ lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - magic-string@^0.25.7: version "0.25.9" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c" @@ -1073,13 +923,6 @@ magic-string@^0.26.2: dependencies: sourcemap-codec "^1.4.8" -make-dir@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - memoize-one@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" @@ -1108,12 +951,7 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -mimic-response@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43" - integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA== - -minimatch@^3.0.4, minimatch@^3.1.1: +minimatch@^3.0.4: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -1127,31 +965,6 @@ minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" -minipass@^3.0.0: - version "3.3.6" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a" - integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw== - dependencies: - yallist "^4.0.0" - -minipass@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" - integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== - -minizlib@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" - integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== - dependencies: - minipass "^3.0.0" - yallist "^4.0.0" - -mkdirp@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" - integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== - mlly@^0.5.3, mlly@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/mlly/-/mlly-0.5.7.tgz#3b058c36268314a1670f89767d40eead66099b93" @@ -1185,11 +998,6 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.17.0: - version "2.17.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.17.0.tgz#c0150a2368a182f033e9aa5195ec76ea41a199cb" - integrity sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ== - nanoid@^3.3.4: version "3.3.4" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" @@ -1200,20 +1008,6 @@ nanoid@^3.3.6: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== -node-fetch@^2.6.7: - version "2.6.12" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.12.tgz#02eb8e22074018e3d5a83016649d04df0e348fba" - integrity sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g== - dependencies: - whatwg-url "^5.0.0" - -nopt@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88" - integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ== - dependencies: - abbrev "1" - normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" @@ -1231,22 +1025,12 @@ npm-run-path@^4.0.1: dependencies: path-key "^3.0.0" -npmlog@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-5.0.1.tgz#f06678e80e29419ad67ab964e0fa69959c1eb8b0" - integrity sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw== - dependencies: - are-we-there-yet "^2.0.0" - console-control-strings "^1.1.0" - gauge "^3.0.0" - set-blocking "^2.0.0" - -object-assign@^4.0.1, object-assign@^4.1.1: +object-assign@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -once@^1.3.0, once@^1.3.1: +once@^1.3.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== @@ -1359,15 +1143,6 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -readable-stream@^3.6.0: - version "3.6.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" - integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -1394,13 +1169,6 @@ reusify@^1.0.4: resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - rollup@^3.2.5: version "3.29.0" resolved "https://registry.yarnpkg.com/rollup/-/rollup-3.29.0.tgz#1b40e64818afc979c7e5bef93de675829288986b" @@ -1422,33 +1190,11 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - scule@^0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/scule/-/scule-0.3.2.tgz#472445cecd8357165a94a067f78cee40e700b596" integrity sha512-zIvPdjOH8fv8CgrPT5eqtxHQXmPNnV/vHJYffZhE43KZkvULvpCTvOt1HPlFaCZx287INL9qaqrZg34e8NgI4g== -semver@^6.0.0: - version "6.3.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^7.3.5: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== - dependencies: - lru-cache "^6.0.0" - -set-blocking@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -1461,25 +1207,11 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -signal-exit@^3.0.0, signal-exit@^3.0.3: +signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -simple-concat@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" - integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== - -simple-get@^3.0.3: - version "3.1.1" - resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.1.tgz#cc7ba77cfbe761036fbfce3d021af25fc5584d55" - integrity sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA== - dependencies: - decompress-response "^4.2.0" - once "^1.3.1" - simple-concat "^1.0.0" - slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -1507,29 +1239,6 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-final-newline@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" @@ -1560,18 +1269,6 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -tar@^6.1.11: - version "6.1.15" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.15.tgz#c9738b0b98845a3b344d334b8fa3041aaba53a69" - integrity sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^5.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" - thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" @@ -1600,11 +1297,6 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - tree-kill@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" @@ -1699,11 +1391,6 @@ unplugin@^0.8.1: webpack-sources "^3.2.3" webpack-virtual-modules "^0.4.4" -util-deprecate@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - vite-node@^0.33.0: version "0.33.0" resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.33.0.tgz#c6a3a527e0b8090da7436241bc875760ae0eef28" @@ -1743,11 +1430,6 @@ vue@^3.2.37: "@vue/server-renderer" "3.2.37" "@vue/shared" "3.2.37" -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -1763,14 +1445,6 @@ webpack-virtual-modules@^0.4.4: resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.4.4.tgz#a19fcf371923c59c4712d63d7d194b1e4d8262cc" integrity sha512-h9atBP/bsZohWpHnr+2sic8Iecb60GxftXsWNLLLSqewgIsGzByd2gcIID4nXcG+3tNe4GQG3dLcff3kXupdRA== -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - whatwg-url@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" @@ -1787,23 +1461,11 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -wide-align@^1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.5.tgz#df1d4c206854369ecf3c9a4898f1b23fbd9d15d3" - integrity sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg== - dependencies: - string-width "^1.0.2 || 2 || 3 || 4" - wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - yaml@^2.1.1: version "2.3.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.2.tgz#f522db4313c671a0ca963a75670f1c12ea909144" From f00f280cd79038fa1bd71b7886f826b23007a5f5 Mon Sep 17 00:00:00 2001 From: Stefan Cameron Date: Thu, 18 Jan 2024 15:32:43 -0600 Subject: [PATCH 02/15] Refactor library into concept of Words ahead of rich text support __WARNING:__ Still a WIP. See `// DEBUG` comments throughout. Instead of dealing with text as a string, introduce the concept of Words where a Word is either a visible, written word, or whitespace, and have the option to infer whitespace when an array of Words is given instead of a string. A Word then has its own formatting options aside from the "base" formatting set in the `CanvasTextConfig` object. If a Word doesn't have `format` options, it will simply use the "default" format in the 2D context, which may have been modified based on the `CanvasTextConfig` object provided to `drawText()`. To preserve the existing `drawText()` and `splitText()` APIs, `splitText()` splits the text into an array of Words and then calls the new `splitWords()` function that does all the work. For now, providing an array of Words to `drawText()` throws an error since this isn't supported yet. But everything else workds as before when providing a string: All supported alignments, including justification. Other fixes/changes: - Breaks cannot occur within a word anymore, even when the box width gets too narrow to fit anything. At this point, a minimum of one word is rendered per line, on top of any hard line breaks specified with newline characters. - The context's state is saved and restored in `drawText()`, meaning that after the call, the context's font, etc, styles/settings are back to what they were prior to calling it instead of being set to what was specified in the `CanvasTextConfig` object. --- src/canvas-txt/index.ts | 90 +++++---- src/canvas-txt/lib/get-style.ts | 25 +++ src/canvas-txt/lib/is-whitespace.ts | 8 + src/canvas-txt/lib/justify.ts | 73 +++++-- src/canvas-txt/lib/models.ts | 86 +++++++++ src/canvas-txt/lib/split-text.ts | 287 +++++++++++++++++++--------- src/canvas-txt/lib/text-height.ts | 4 +- src/canvas-txt/lib/trim-line.ts | 34 ++++ src/docs/AppCanvas.vue | 2 +- 9 files changed, 466 insertions(+), 143 deletions(-) create mode 100644 src/canvas-txt/lib/get-style.ts create mode 100644 src/canvas-txt/lib/is-whitespace.ts create mode 100644 src/canvas-txt/lib/models.ts create mode 100644 src/canvas-txt/lib/trim-line.ts diff --git a/src/canvas-txt/index.ts b/src/canvas-txt/index.ts index e8f27ffe..cddef053 100644 --- a/src/canvas-txt/index.ts +++ b/src/canvas-txt/index.ts @@ -1,58 +1,48 @@ -import splitText from './lib/split-text' -import getTextHeight from './lib/text-height' - -export interface CanvasTextConfig { - width: number - height: number - x: number - y: number - debug?: boolean - align?: 'left' | 'center' | 'right' - vAlign?: 'top' | 'middle' | 'bottom' - fontSize?: number - fontWeight?: string - fontStyle?: string - fontVariant?: string - font?: string - lineHeight?: number - justify?: boolean -} +import { splitWords, splitText } from './lib/split-text' +import { getTextHeight } from './lib/text-height' +import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, getStyle } from './lib/get-style' +import { CanvasTextConfig, Text } from './lib/models' -const defaultConfig = { +const defaultConfig: Omit = { debug: false, align: 'center', vAlign: 'middle', - fontSize: 14, + fontSize: DEFAULT_FONT_SIZE, fontWeight: '', fontStyle: '', fontVariant: '', - font: 'Arial', - lineHeight: null, + font: DEFAULT_FONT_FAMILY, justify: false, } function drawText( ctx: CanvasRenderingContext2D, - myText: string, + myText: Text, inputConfig: CanvasTextConfig ) { + if (Array.isArray(myText)) { + throw new Error('Word[] support not yet implemented') + } + const { width, height, x, y } = inputConfig const config = { ...defaultConfig, ...inputConfig } + const ctxFontSize = config.fontSize ?? DEFAULT_FONT_SIZE - if (width <= 0 || height <= 0 || config.fontSize <= 0) { - //width or height or font size cannot be 0 + if (width <= 0 || height <= 0 || ctxFontSize <= 0) { + // width or height or font size cannot be 0 return { height: 0 } } + ctx.save() + // End points const xEnd = x + width const yEnd = y + height - const { fontStyle, fontVariant, fontWeight, fontSize, font } = config - const style = `${fontStyle} ${fontVariant} ${fontWeight} ${fontSize}px ${font}` + const style = getStyle(config) ctx.font = style - let txtY = y + height / 2 + config.fontSize / 2 + let txtY = y + height / 2 + ctxFontSize / 2 let textAnchor: number @@ -67,17 +57,25 @@ function drawText( ctx.textAlign = 'center' } - const textArray = splitText({ + const charHeight = config.lineHeight + ? config.lineHeight + : getTextHeight({ ctx, text: 'M', style }) + + // DEBUG TODO: this is really ugly, could be more elegant; just a POC... + const textArray = Array.isArray(myText) ? undefined : splitText({ ctx, text: myText, - justify: config.justify, + justify: !!config.justify, width, }) + const richArray = Array.isArray(myText) ? splitWords({ + ctx, + words: myText, + justify: !!config.justify, + width, + }) : undefined; - const charHeight = config.lineHeight - ? config.lineHeight - : getTextHeight({ ctx, text: 'M', style }) - const vHeight = charHeight * (textArray.length - 1) + const vHeight = charHeight * (textArray ? textArray.length - 1 : richArray?.lines.length - 1) const negOffset = vHeight / 2 let debugY = y @@ -95,12 +93,17 @@ function drawText( debugY = y + height / 2 txtY -= negOffset } - //print all lines of text - textArray.forEach((txtline) => { - txtline = txtline.trim() - ctx.fillText(txtline, textAnchor, txtY) - txtY += charHeight - }) + + if (textArray) { + // print all lines of text + textArray.forEach((txtline) => { + txtline = txtline.trim() + ctx.fillText(txtline, textAnchor, txtY) + txtY += charHeight + }) + } else { + // DEBUG TODO: render richArray... + } if (config.debug) { const debugColor = '#0C8CE9' @@ -126,7 +129,10 @@ function drawText( } const textHeight = vHeight + charHeight + + ctx.restore() + return { height: textHeight } } -export { drawText, splitText, getTextHeight } +export { drawText, splitText, splitWords, getTextHeight } diff --git a/src/canvas-txt/lib/get-style.ts b/src/canvas-txt/lib/get-style.ts new file mode 100644 index 00000000..58965ee0 --- /dev/null +++ b/src/canvas-txt/lib/get-style.ts @@ -0,0 +1,25 @@ +import { TextFormat } from "./models"; + +export const DEFAULT_FONT_FAMILY = 'Arial' +export const DEFAULT_FONT_SIZE = 14 + +/** + * Generates a [CSS font](https://developer.mozilla.org/en-US/docs/Web/CSS/font) value. + * @param format + * @returns Style string to set on context's `font` property. + */ +export const getStyle = function({ + font, + fontSize, + fontStyle, + fontVariant, + fontWeight, +}: TextFormat) { + // per spec: + // - font-style, font-variant and font-weight must precede font-size + // - font-family must be the last value specified + // @see https://developer.mozilla.org/en-US/docs/Web/CSS/font + return `${fontStyle || ''} ${fontVariant || ''} ${ + fontWeight || '' + } ${fontSize ?? DEFAULT_FONT_SIZE}px ${font || DEFAULT_FONT_FAMILY}`.trim() +} diff --git a/src/canvas-txt/lib/is-whitespace.ts b/src/canvas-txt/lib/is-whitespace.ts new file mode 100644 index 00000000..fdf610bd --- /dev/null +++ b/src/canvas-txt/lib/is-whitespace.ts @@ -0,0 +1,8 @@ +/** + * Determines if a string is only whitespace (one or more characters of it). + * @param text + * @returns True if `text` is one or more characters of whitespace, only. + */ +export const isWhitespace = function(text: string) { + return !!text.match(/^\s+$/) +} diff --git a/src/canvas-txt/lib/justify.ts b/src/canvas-txt/lib/justify.ts index e1059553..3f8598c3 100644 --- a/src/canvas-txt/lib/justify.ts +++ b/src/canvas-txt/lib/justify.ts @@ -1,11 +1,60 @@ +import { isWhitespace } from "./is-whitespace" +import { Word } from "./models" +import { trimLine } from "./trim-line" + export interface JustifyLineProps { - ctx: CanvasRenderingContext2D - line: string + measureLine: (words: Word[]) => number + line: Word[] spaceWidth: number spaceChar: string width: number } +/** + * Extracts the __visible__ (i.e. non-whitespace) words from a line. + * @param line + * @returns New array with only non-whitespace words. + */ +const extractWords = function(line: Word[]) { + return line.filter((word) => !isWhitespace(word.text)) +} + +/** + * Deep-clones a Word. + * @param word + * @returns Deep-cloned Word. + */ +const cloneWord = function(word: Word) { + const clone = { ...word } + if (word.format) { + clone.format = { ...word.format } + } + return clone +} + +/** + * Joins Words together using another set of Words. + * @param words Words to join. + * @param joiner Words to use when joining `words`. These will be deep-cloned and inserted + * in between every word in `words`, similar to `Array.join(string)` where the `string` + * is inserted in between every element. + * @returns New array of Words. Empty if `words` is empty. New array of one Word if `words` + * contains only one Word. + */ +const joinWords = function(words: Word[], joiner: Word[]) { + if (words.length <= 1 || joiner.length < 1) { + return [...words] + } + + const phrase: Word[] = [] + words.forEach((word) => { + phrase.push(word) + joiner.forEach((jw) => (phrase.push(cloneWord(jw)))) + }) + + return phrase +} + /** * This function will insert spaces between words in a line in order * to raise the line width to the box width. @@ -14,29 +63,29 @@ export interface JustifyLineProps { * * It returns the justified text. */ -export default function justifyLine({ - ctx, +export function justifyLine({ + measureLine, line, spaceWidth, spaceChar, width, }: JustifyLineProps) { - const text = line.trim() - const words = text.split(/\s+/) + const trimmedLine = trimLine(line) + const words = extractWords(trimmedLine) const numOfWords = words.length - 1 - if (numOfWords === 0) return text + if (numOfWords <= 0) return trimmedLine - // Width without spaces - const lineWidth = ctx.measureText(words.join('')).width + // Width without whitespace + const lineWidth = measureLine(words) const noOfSpacesToInsert = (width - lineWidth) / spaceWidth const spacesPerWord = Math.floor(noOfSpacesToInsert / numOfWords) - if (noOfSpacesToInsert < 1) return text + if (noOfSpacesToInsert < 1) return trimmedLine - const spaces = spaceChar.repeat(spacesPerWord) + const spaces: Word[] = Array.from({ length: spacesPerWord }, () => ({ text: spaceChar })) // Return justified text - return words.join(spaces) + return joinWords(words, spaces) } diff --git a/src/canvas-txt/lib/models.ts b/src/canvas-txt/lib/models.ts new file mode 100644 index 00000000..0707f524 --- /dev/null +++ b/src/canvas-txt/lib/models.ts @@ -0,0 +1,86 @@ +export interface TextFormat { + font?: string // family + fontSize?: number // pixels only at this time + fontWeight?: string + fontStyle?: string + + // per spec, only CSS 2.1 values are supported + // @see https://developer.mozilla.org/en-US/docs/Web/CSS/font + fontVariant?: 'normal' | 'small-caps' | '' + + // NOTE: line height is only supported at the context level (i.e. one line height for all + // the text) even though Canvas2DContext.font supports it; see `CanvasTextConfig.lineHeight` +} + +export interface Word { + /** The word. Can be whitespace also. */ + text: string + + /** Optional formatting. If unspecified, `CanvasTextConfig` defaults will be used. */ + format?: TextFormat +} + +export type PlainText = string; + +export type Text = PlainText | Word[]; + +export interface CanvasTextConfig extends TextFormat { + width: number + height: number + x: number + y: number + debug?: boolean + align?: 'left' | 'center' | 'right' + vAlign?: 'top' | 'middle' | 'bottom' + justify?: boolean + + /** Desired line height when rendering text. Defaults to height based on font styles. */ + lineHeight?: number +} + +export interface SplitParams { + ctx: CanvasRenderingContext2D + justify: boolean + width: number +} + +export interface SplitTextProps extends SplitParams { + /** + * Text to render. Newlines are interpreted as hard breaks. Whitespace is preserved __only + * within the string__ (whitespace on either end is trimmed). Text will always wrap at max + * width regardless of newlines. + */ + text: PlainText +} + +export interface SplitWordsProps extends SplitParams { + /** For hard breaks, include words that are newline characters as their `text`. */ + words: Word[] + + /** + * True (default) indicates `words` contains _mostly_ visible words and whitespace should be + * inferred _unless a word is whitespace (e.g. a new line or tab)_, based on the context's + * general text formatting style (i.e. every space will use the font style set on the context). + * + * False indicates that `words` contains its own whitespace and it shouldn't be inferred. + */ + inferWhitespace?: boolean +} + +/** + * A `Word` along with its position along the X axis within a "line", always starting from `0` + * relative to the line. + * + * It's the caller's responsibility to render each line at its desired line height within + * the Canvas, as well as to calculate each word's location in the Canvas' absolute space. + * Therefore the caller provides the position along the Y axis. + */ +export interface PositionedWord { + word: Word + x: number + width: number +} + +export interface SplitWordsResults { + lines: PositionedWord[][] +} diff --git a/src/canvas-txt/lib/split-text.ts b/src/canvas-txt/lib/split-text.ts index ecfb0956..2dbfe0c0 100644 --- a/src/canvas-txt/lib/split-text.ts +++ b/src/canvas-txt/lib/split-text.ts @@ -1,132 +1,245 @@ -import justifyLine from './justify' +import { getStyle } from './get-style' +import { isWhitespace } from './is-whitespace' +import { justifyLine } from './justify' +import { PositionedWord, SplitTextProps, SplitWordsProps, SplitWordsResults, Word } from './models' +import { trimLine } from './trim-line' // Hair space character for precise justification -const SPACE = '\u{200a}' +const HAIR = '\u{200a}' + +// for when we're inferring whitespace between words +const SPACE = ' ' + +/** + * Splits words into lines based on words that are single newline characters. + * @param words + * @param inferWhitespace True if whitespace should be inferred (and injected) based on words; + * false if we're to assume the words already include all necessary whitespace. + * @returns Words expressed as lines. + */ +const splitIntoLines = function(words: Word[], inferWhitespace: boolean): Word[][] { + const lines: Word[][] = [[]] + + let wasWhitespace = false // true if previous word was whitespace + words.forEach((word, wordIdx) => { + // DEBUG TODO: this is likely a naive split (at least based on character?); should at least + // think about this more; text format shouldn't matter on a line break, right (hope not)? + if (word.text.match(/^\n+$/)) { + for (let i = 0; i < word.text.length; i++) { + lines.push([]) + } + wasWhitespace = true + return // next `word` + } + + if (isWhitespace(word.text)) { + // whitespace OTHER THAN newlines since we checked for newlines above + lines.at(-1)?.push(word) + wasWhitespace = true + return // next `word` + } + + if (word.text === '') { + return // skip to next `word` + } + + // looks like a non-empty, non-whitespace word at this point, so if it isn't the first + // word and the one before wasn't whitespace, insert a space + if (inferWhitespace && !wasWhitespace && wordIdx > 0) { + lines.at(-1)?.push({ text: SPACE }) + } + + lines.at(-1)?.push(word) + wasWhitespace = false + }) -export interface SplitTextProps { - ctx: CanvasRenderingContext2D - text: string - justify: boolean - width: number + return lines } -export default function splitText({ +export function splitWords({ ctx, - text, + words, justify, - width, -}: SplitTextProps): string[] { - const textMap = new Map() + width: boxWidth, // width of box inside canvas within which text is to be rendered (variable height) + inferWhitespace = true, +}: SplitWordsProps): SplitWordsResults { + // map of Word to measured width + const wordMap = new Map() + + const measureText = (word: Word): number => { + let textWidth = wordMap.get(word) + if (textWidth !== undefined) { + return textWidth + } + + if (word.format) { + ctx.save() + ctx.font = getStyle(word.format) + } + + textWidth = ctx.measureText(word.text).width + wordMap.set(word, textWidth) - const measureText = (text: string): number => { - let width = textMap.get(text) - if (width !== undefined) { - return width + if (word.format) { + ctx.restore() } - width = ctx.measureText(text).width - textMap.set(text, width) - return width + return textWidth } - let textArray: string[] = [] - let initialTextArray = text.split('\n') + const measureLine = (words: Word[]): number => + words.reduce((lineWidth, word) => lineWidth + measureText(word), 0) - const spaceWidth = justify ? measureText(SPACE) : 0 + const lines: Word[][] = [] + const initialLines = splitIntoLines(trimLine(words), inferWhitespace) - let index = 0 - let averageSplitPoint = 0 - for (const singleLine of initialTextArray) { - let textWidth = measureText(singleLine) - const singleLineLength = singleLine.length + const hairWidth = justify ? measureText({ text: HAIR }) : 0 - if (textWidth <= width) { - textArray.push(singleLine) + for (const singleLine of initialLines) { + let lineWidth = measureLine(singleLine) + + // if the line fits, we're done; else, we have to break it down further to fit + // as best as we can (i.e. minimum one word per line, no breaks within words, + // no leading/pending whitespace) + if (lineWidth <= boxWidth) { + lines.push(singleLine) continue } - let tempLine = singleLine + // shallow clone because we're going to break this line down further to get the best fit + let tempLine = singleLine.concat() - let splitPoint - let splitPointWidth - let textToPrint = '' + let splitPoint: number + let splitPointWidth: number + let lineToPrint: Word[] = [] - while (textWidth > width) { - index++ - splitPoint = averageSplitPoint + while (lineWidth > boxWidth) { + splitPoint = Math.floor(tempLine.length / 2) splitPointWidth = - splitPoint === 0 ? 0 : measureText(singleLine.substring(0, splitPoint)) + splitPoint === 0 ? 0 : measureLine(tempLine.slice(0, splitPoint)) - // if (splitPointWidth === width) Nailed - if (splitPointWidth < width) { - while (splitPointWidth < width && splitPoint < singleLineLength) { + if (splitPointWidth < boxWidth) { + // try to build it back up to fit the max Words we can + while (splitPointWidth < boxWidth && splitPoint < tempLine.length) { splitPoint++ - splitPointWidth = measureText(tempLine.substring(0, splitPoint)) - if (splitPoint === singleLineLength) break - } - } else if (splitPointWidth > width) { - while (splitPointWidth > width) { - splitPoint = Math.max(1, splitPoint - 1) - splitPointWidth = measureText(tempLine.substring(0, splitPoint)) - if (splitPoint === 1) break + splitPointWidth = measureLine(tempLine.slice(0, splitPoint)) } - } - averageSplitPoint = Math.round( - averageSplitPoint + (splitPoint - averageSplitPoint) / index - ) - - // Remove last character that was out of the box - splitPoint-- - - // Ensures a new line only happens at a space, and not amidst a word - if (splitPoint > 0) { - let tempSplitPoint = splitPoint - if (tempLine.substring(tempSplitPoint, tempSplitPoint + 1) != ' ') { - while (tempSplitPoint >= 0 - && tempLine.substring(tempSplitPoint, tempSplitPoint + 1) != ' ') { - tempSplitPoint-- - } - if (tempSplitPoint > 0) { - splitPoint = tempSplitPoint - } + if (splitPointWidth > boxWidth || splitPoint < tempLine.length) { + // back one because we blew past `boxWidth` either right on the last Word or before + splitPoint-- + } + } else if (splitPointWidth > boxWidth) { + // try to shrink it back down to fit (NOTE that if `splitPoint=1`, it means we're + // down to a single Word on the line because we're going to split BEFORE `splitPoint` + // per `Array.slice()`...) + while (splitPointWidth > boxWidth && splitPoint > 0) { + splitPoint-- + splitPointWidth = measureLine(tempLine.slice(0, splitPoint)) } + + // in this case, we don't need to correct for a missed Word if we reached the + // start of the `tempLine` before we found a width that fit because we'll + // correct that below in the fail safe against a `boxWidth` that's too narrow + // to fit anything } + // always print at least one word on a line (because the `splitPoint` is the + // word immediately AFTER where the split will take place with `Array.slice()`) + // NOTE: we could hit this if the `boxWidth` is too narrow to fit any Word, and + // we have to always consume _at least_ one Word per line, otherwise, we'll get + // into an infinite loop because we'll always have Words left to render if (splitPoint === 0) { splitPoint = 1 } - // Finally sets text to print - textToPrint = tempLine.substring(0, splitPoint) + // Finally sets line to print + lineToPrint = trimLine(tempLine.slice(0, splitPoint)) - textToPrint = justify + lineToPrint = justify ? justifyLine({ - ctx, - line: textToPrint, - spaceWidth, - spaceChar: SPACE, - width, + measureLine, + line: lineToPrint, + spaceWidth: hairWidth, + spaceChar: HAIR, + width: boxWidth, }) - : textToPrint - textArray.push(textToPrint) - tempLine = tempLine.substring(splitPoint) - textWidth = measureText(tempLine) + : lineToPrint + lines.push(lineToPrint) + tempLine = trimLine(tempLine.slice(splitPoint)) + lineWidth = measureLine(tempLine) } - if (textWidth > 0) { - textToPrint = justify + if (lineWidth > 0) { + lineToPrint = justify ? justifyLine({ - ctx, + measureLine, line: tempLine, - spaceWidth, - spaceChar: SPACE, - width, + spaceWidth: hairWidth, + spaceChar: HAIR, + width: boxWidth, }) : tempLine - textArray.push(textToPrint) + lines.push(lineToPrint) + } + } + + return { + lines: lines.map((line): PositionedWord[] => { + let nextX = 0 + return line.map((word): PositionedWord => { + const wordWidth = wordMap.get(word) ?? 0 + const x = nextX + nextX += wordWidth + return { word, x, width: wordWidth } + }) + }) + } +} + +/** + * Splits plain text into lines in the order in which they should be rendered, top-down, + * preserving whitespace __only within the text__ (whitespace on either end is trimmed). + */ +export function splitText({ + text, + ...params +}: SplitTextProps): string[] { + const textAsWords: Word[] = [] + + // split the `text` into a series of Words, preserving whitespace + let word: Word | undefined = undefined; + let wasWhitespace = false + Array.from(text.trim()).forEach((c) => { + const charIsWhitespace = isWhitespace(c) + if ((charIsWhitespace && !wasWhitespace) || (!charIsWhitespace && wasWhitespace)) { + // save current `word`, if any, and start new `word` + wasWhitespace = charIsWhitespace + if (word) { + textAsWords.push(word) + } + word = { text: c } + } else { + // accumulate into current `word` + if (!word) { + word = { text: '' } + } + word.text += c } + }) + + // make sure we have the last word! ;) + if (word) { + textAsWords.push(word) } - return textArray + + const results = splitWords({ + ...params, + words: textAsWords, + }) + + return results.lines.map( + (line) => line.map(({ word: { text } }) => text).join('') + ) } diff --git a/src/canvas-txt/lib/text-height.ts b/src/canvas-txt/lib/text-height.ts index 284ceb87..3efed073 100644 --- a/src/canvas-txt/lib/text-height.ts +++ b/src/canvas-txt/lib/text-height.ts @@ -1,10 +1,12 @@ interface GetTextHeightProps { ctx: CanvasRenderingContext2D text: string + + /** CSS font. Same syntax as CSS font specifier. */ style: string } -export default function getTextHeight({ +export function getTextHeight({ ctx, text, style, diff --git a/src/canvas-txt/lib/trim-line.ts b/src/canvas-txt/lib/trim-line.ts new file mode 100644 index 00000000..991c1448 --- /dev/null +++ b/src/canvas-txt/lib/trim-line.ts @@ -0,0 +1,34 @@ +import { isWhitespace } from "./is-whitespace"; +import { Word } from "./models"; + +/** + * Trims whitespace from the beginning and end of a `line`. + * @param line + * @returns New array representing the trimmed line. Empty array if all whitespace. + */ +export const trimLine = function(line: Word[]) { + let leftTrim = 0; + for (; leftTrim < line.length; leftTrim++) { + if (!isWhitespace(line[leftTrim].text)) { + break; + } + } + + let rightTrim = line.length - 1 + for (; rightTrim >= 0; rightTrim--) { + if (!isWhitespace(line[rightTrim].text)) { + break; + } + } + rightTrim++ // so we include this Word which is NOT whitespace + + if (leftTrim < 0) { + return line.slice(0, rightTrim) + } + + if (leftTrim < rightTrim) { + return line.slice(leftTrim, rightTrim) + } + + return [] // all whitespace +} diff --git a/src/docs/AppCanvas.vue b/src/docs/AppCanvas.vue index f637f1b0..37582752 100644 --- a/src/docs/AppCanvas.vue +++ b/src/docs/AppCanvas.vue @@ -20,7 +20,7 @@ const initialConfig = { size: 38, lineHeight: null, }, - debug: false, + debug: true, // DEBUG align: 'center', vAlign: 'middle', justify: false, From 1298a13c00beda95f02d18b3e07fd16623dc50f0 Mon Sep 17 00:00:00 2001 From: Stefan Cameron Date: Fri, 19 Jan 2024 16:58:02 -0600 Subject: [PATCH 03/15] BREAKING: Move text placement into splitWords() __WARNING:__ See remaining `// DEBUG` comments. In order to make it possible to use a library other than canvas-txt to actually draw the text, yet still leverage this library's text wrapping and rich text features, `splitWords()` now does all the positioning that `drawText()` use to do, which means that `splitText()`, which calls `splitWords()` internally, must now be given many more parameters than it used to. Since `splitText()` was already exported as a utility from this library, this must be a breaking change (major version update). So now, `splitWords()` returns the positioned words along with the required `textAlign` and `textBaseline` to set on the 2D context in order to render the text what is now a very simple loop. This makes it possible to call `splitWords()` directly and then use the output to render each word using another library such as Konva where each word could then be dragged or hit-tested, or do it without a library altogether. Plain text (single text format) works at this point, but rich text still does not render properly due to misalignment issues. --- src/canvas-txt/index.ts | 194 +++++++++----------- src/canvas-txt/lib/get-style.ts | 22 ++- src/canvas-txt/lib/justify.ts | 12 +- src/canvas-txt/lib/models.ts | 153 ++++++++++++++-- src/canvas-txt/lib/split-text.ts | 289 ++++++++++++++++++++++++------ src/canvas-txt/lib/text-height.ts | 44 ++++- src/docs/AppCanvas.vue | 22 ++- 7 files changed, 535 insertions(+), 201 deletions(-) diff --git a/src/canvas-txt/index.ts b/src/canvas-txt/index.ts index cddef053..8ea4cb15 100644 --- a/src/canvas-txt/index.ts +++ b/src/canvas-txt/index.ts @@ -1,111 +1,77 @@ -import { splitWords, splitText } from './lib/split-text' +import { splitWords, splitText, textToWords } from './lib/split-text' import { getTextHeight } from './lib/text-height' -import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, getStyle } from './lib/get-style' +import { getTextStyle, getTextFormat } from './lib/get-style' import { CanvasTextConfig, Text } from './lib/models' -const defaultConfig: Omit = { - debug: false, - align: 'center', - vAlign: 'middle', - fontSize: DEFAULT_FONT_SIZE, - fontWeight: '', - fontStyle: '', - fontVariant: '', - font: DEFAULT_FONT_FAMILY, - justify: false, -} - function drawText( ctx: CanvasRenderingContext2D, - myText: Text, - inputConfig: CanvasTextConfig + text: Text, + config: CanvasTextConfig ) { - if (Array.isArray(myText)) { - throw new Error('Word[] support not yet implemented') - } - - const { width, height, x, y } = inputConfig - const config = { ...defaultConfig, ...inputConfig } - const ctxFontSize = config.fontSize ?? DEFAULT_FONT_SIZE - - if (width <= 0 || height <= 0 || ctxFontSize <= 0) { - // width or height or font size cannot be 0 - return { height: 0 } - } - - ctx.save() - - // End points - const xEnd = x + width - const yEnd = y + height - - const style = getStyle(config) - ctx.font = style - - let txtY = y + height / 2 + ctxFontSize / 2 - - let textAnchor: number - - if (config.align === 'right') { - textAnchor = xEnd - ctx.textAlign = 'right' - } else if (config.align === 'left') { - textAnchor = x - ctx.textAlign = 'left' - } else { - textAnchor = x + width / 2 - ctx.textAlign = 'center' - } - - const charHeight = config.lineHeight - ? config.lineHeight - : getTextHeight({ ctx, text: 'M', style }) - - // DEBUG TODO: this is really ugly, could be more elegant; just a POC... - const textArray = Array.isArray(myText) ? undefined : splitText({ - ctx, - text: myText, - justify: !!config.justify, - width, + const format = getTextFormat({ + fontFamily: config.fontFamily, + fontSize: config.fontSize, + fontStyle: config.fontStyle, + fontVariant: config.fontVariant, + fontWeight: config.fontWeight, }) - const richArray = Array.isArray(myText) ? splitWords({ - ctx, - words: myText, - justify: !!config.justify, - width, - }) : undefined; - - const vHeight = charHeight * (textArray ? textArray.length - 1 : richArray?.lines.length - 1) - const negOffset = vHeight / 2 - let debugY = y - // Vertical Align - if (config.vAlign === 'top') { - ctx.textBaseline = 'top' - txtY = y - } else if (config.vAlign === 'bottom') { - ctx.textBaseline = 'bottom' - txtY = yEnd - vHeight - debugY = yEnd - } else { - //defaults to center - ctx.textBaseline = 'bottom' - debugY = y + height / 2 - txtY -= negOffset - } + const { lines: richLines, height: totalHeight, textBaseline, textAlign } = splitWords({ + ctx, + words: Array.isArray(text) ? text : textToWords(text), + inferWhitespace: config.inferWhitespace, + x: config.x, + y: config.y, + width: config.width, + height: config.height, + align: config.align, + vAlign: config.vAlign, + justify: config.justify, + lineHeight: config.lineHeight, + format, + }); - if (textArray) { - // print all lines of text - textArray.forEach((txtline) => { - txtline = txtline.trim() - ctx.fillText(txtline, textAnchor, txtY) - txtY += charHeight + ctx.save() + ctx.textAlign = textAlign + ctx.textBaseline = textBaseline + ctx.font = getTextStyle(format) + + richLines.forEach((line) => { + line.forEach((pw) => { + if (!pw.isWhitespace) { + if (pw.word.format) { + ctx.save() + ctx.font = getTextStyle(pw.word.format) + } + ctx.fillText(pw.word.text, pw.x, pw.y) + if (pw.word.format) { + ctx.restore() + } + } }) - } else { - // DEBUG TODO: render richArray... - } + }) if (config.debug) { + const { width, height, x, y } = config + const xEnd = x + width + const yEnd = y + height + + let textAnchor: number + if (config.align === 'right') { + textAnchor = xEnd + } else if (config.align === 'left') { + textAnchor = x + } else { + textAnchor = x + width / 2 + } + + let debugY = y + if (config.vAlign === 'bottom') { + debugY = yEnd + } else if (config.vAlign === 'middle') { + debugY = y + height / 2 + } + const debugColor = '#0C8CE9' // Text box @@ -114,25 +80,29 @@ function drawText( ctx.strokeRect(x, y, width, height) ctx.lineWidth = 1 - // Horizontal Center - ctx.strokeStyle = debugColor - ctx.beginPath() - ctx.moveTo(textAnchor, y) - ctx.lineTo(textAnchor, yEnd) - ctx.stroke() - // Vertical Center - ctx.strokeStyle = debugColor - ctx.beginPath() - ctx.moveTo(x, debugY) - ctx.lineTo(xEnd, debugY) - ctx.stroke() - } - const textHeight = vHeight + charHeight + if (!config.align || config.align === 'center') { + // Horizontal Center + ctx.strokeStyle = debugColor + ctx.beginPath() + ctx.moveTo(textAnchor, y) + ctx.lineTo(textAnchor, yEnd) + ctx.stroke() + } + + if (!config.vAlign || config.vAlign === 'middle') { + // Vertical Center + ctx.strokeStyle = debugColor + ctx.beginPath() + ctx.moveTo(x, debugY) + ctx.lineTo(xEnd, debugY) + ctx.stroke() + } + } ctx.restore() - return { height: textHeight } + return { height: totalHeight } } -export { drawText, splitText, splitWords, getTextHeight } +export { drawText, splitText, splitWords, textToWords, getTextHeight } diff --git a/src/canvas-txt/lib/get-style.ts b/src/canvas-txt/lib/get-style.ts index 58965ee0..68962942 100644 --- a/src/canvas-txt/lib/get-style.ts +++ b/src/canvas-txt/lib/get-style.ts @@ -3,13 +3,29 @@ import { TextFormat } from "./models"; export const DEFAULT_FONT_FAMILY = 'Arial' export const DEFAULT_FONT_SIZE = 14 +/** + * Generates a text format based on defaults and any provided overrides. + * @param format Overrides to `baseFormat` and default format. + * @param baseFormat Overrides to default format. + * @returns Full text format (all properties specified). + */ +export const getTextFormat = function(format?: TextFormat, baseFormat?: TextFormat): Required { + return Object.assign({}, { + fontFamily: DEFAULT_FONT_FAMILY, + fontSize: DEFAULT_FONT_SIZE, + fontWeight: '400', + fontStyle: '', + fontVariant: '', + }, baseFormat, format) +} + /** * Generates a [CSS font](https://developer.mozilla.org/en-US/docs/Web/CSS/font) value. * @param format * @returns Style string to set on context's `font` property. */ -export const getStyle = function({ - font, +export const getTextStyle = function({ + fontFamily, fontSize, fontStyle, fontVariant, @@ -21,5 +37,5 @@ export const getStyle = function({ // @see https://developer.mozilla.org/en-US/docs/Web/CSS/font return `${fontStyle || ''} ${fontVariant || ''} ${ fontWeight || '' - } ${fontSize ?? DEFAULT_FONT_SIZE}px ${font || DEFAULT_FONT_FAMILY}`.trim() + } ${fontSize ?? DEFAULT_FONT_SIZE}px ${fontFamily || DEFAULT_FONT_FAMILY}`.trim() } diff --git a/src/canvas-txt/lib/justify.ts b/src/canvas-txt/lib/justify.ts index 3f8598c3..18976bf9 100644 --- a/src/canvas-txt/lib/justify.ts +++ b/src/canvas-txt/lib/justify.ts @@ -47,14 +47,21 @@ const joinWords = function(words: Word[], joiner: Word[]) { } const phrase: Word[] = [] - words.forEach((word) => { + words.forEach((word, wordIdx) => { phrase.push(word) - joiner.forEach((jw) => (phrase.push(cloneWord(jw)))) + if (wordIdx < words.length - 1) { // don't append after last `word` + joiner.forEach((jw) => (phrase.push(cloneWord(jw)))) + } }) return phrase } +// DEBUG TODO: This isn't the greatest "justify" algorithm because it's not able to take +// the whole text into account and make smarter splits in order to spread words among lines +// in a better way; all it sees is one line, and it relies on splitWords() to decide which +// words go on which line, and splitWords() isn't taking justification into account. This +// algorithm will justify the line, but not the text as a whole... /** * This function will insert spaces between words in a line in order * to raise the line width to the box width. @@ -86,6 +93,5 @@ export function justifyLine({ const spaces: Word[] = Array.from({ length: spacesPerWord }, () => ({ text: spaceChar })) - // Return justified text return joinWords(words, spaces) } diff --git a/src/canvas-txt/lib/models.ts b/src/canvas-txt/lib/models.ts index 0707f524..5df3ad24 100644 --- a/src/canvas-txt/lib/models.ts +++ b/src/canvas-txt/lib/models.ts @@ -1,11 +1,16 @@ export interface TextFormat { - font?: string // family - fontSize?: number // pixels only at this time + /** Font family (CSS value). */ + fontFamily?: string + /** Font size (px). */ + fontSize?: number + /** Font weight (CSS value). */ fontWeight?: string + /** Font style (CSS value) */ fontStyle?: string // per spec, only CSS 2.1 values are supported // @see https://developer.mozilla.org/en-US/docs/Web/CSS/font + /** Font variant (CSS value). */ fontVariant?: 'normal' | 'small-caps' | '' // NOTE: line height is only supported at the context level (i.e. one line height for all @@ -13,10 +18,9 @@ export interface TextFormat { } export interface Word { - /** The word. Can be whitespace also. */ + /** The word. Can also be whitespace. */ text: string - - /** Optional formatting. If unspecified, `CanvasTextConfig` defaults will be used. */ + /** Optional formatting. If unspecified, base format defaults will be used. */ format?: TextFormat } @@ -25,26 +29,94 @@ export type PlainText = string; export type Text = PlainText | Word[]; export interface CanvasTextConfig extends TextFormat { + /** + * Width of box (px) at X/Y in 2D context within which text should be rendered. This will affect + * text wrapping, but will not necessarily constrain the text because, at minimum, one word, + * regardless of its width, will be rendered per line. + */ width: number + /** + * Height of box (px) at X/Y in 2D context within which text should be rendered. While this + * __will not constrain how the text is rendered__, it will determine how it's positioned + * given the alignment specified (`align` and `vAlign`). All the text is rendered, and may + * be rendered above/below the box defined in part by this dimension if it's too long to + * fit within the specified `boxWidth`. + */ height: number + /** Absolute X coordinate (px) in 2D context where text should be rendered. */ x: number + /** Absolute Y coordinate (px) in 2D context where text should be rendered. */ y: number + + /** True if debug lines should be rendered behind the text. */ debug?: boolean + + /** Horizontal alignment. Defaults to 'center'. */ align?: 'left' | 'center' | 'right' + /** Vertical alignment. Defaults to 'middle'. */ vAlign?: 'top' | 'middle' | 'bottom' + /** True if text should be justified within the `boxWidth` to fill the hole width. */ justify?: boolean /** Desired line height when rendering text. Defaults to height based on font styles. */ lineHeight?: number + + /** + * __NOTE:__ Applies only if `text`, given to `drawText()`, is a `Word[]`. Ignored if it's + * a `string`. + * + * True (default) indicates `text` is a `Word` array that contains _mostly_ visible words and + * whitespace should be inferred _unless a word is whitespace (e.g. a new line or tab)_, based + * on the context's general text formatting style (i.e. every space will use the font style set + * on the context). This makes it easier to provide a `Word[]` because whitespace can be omitted + * if it's just spaces, and only informative whitespace is necessary (e.g. hard line breaks + * as Words with `text="\n"`). + * + * False indicates that `words` contains its own whitespace and it shouldn't be inferred. + */ + inferWhitespace?: boolean } -export interface SplitParams { +export interface BaseSplitProps { ctx: CanvasRenderingContext2D - justify: boolean + + /** Absolute X coordinate (px) in 2D context where text should be rendered. */ + x: number + /** Absolute Y coordinate (px) in 2D context where text should be rendered. */ + y: number + /** + * Width of box (px) at X/Y in 2D context within which text should be rendered. This will affect + * text wrapping, but will not necessarily constrain the text because, at minimum, one word, + * regardless of its width, will be rendered per line. + */ width: number + /** + * Height of box (px) at X/Y in 2D context within which text should be rendered. While this + * __will not constrain how the text is rendered__, it will determine how it's positioned + * given the alignment specified (`align` and `vAlign`). All the text is rendered, and may + * be rendered above/below the box defined in part by this dimension if it's too long to + * fit within the specified `boxWidth`. + */ + height: number + + /** Horizontal alignment. Defaults to 'center'. */ + align?: 'left' | 'center' | 'right' + /** Vertical alignment. Defaults to 'middle'. */ + vAlign?: 'top' | 'middle' | 'bottom' + /** True if text should be justified within the `boxWidth` to fill the hole width. */ + justify?: boolean + + /** Desired line height when rendering text. Defaults to height based on font styles. */ + lineHeight?: number + + /** + * Base/default font styles. These will be used for any word that doesn't have specific + * formatting overrides. It's basically how "plain text" should be rendered. + */ + format?: TextFormat } -export interface SplitTextProps extends SplitParams { +export interface SplitTextProps extends BaseSplitProps { /** * Text to render. Newlines are interpreted as hard breaks. Whitespace is preserved __only * within the string__ (whitespace on either end is trimmed). Text will always wrap at max @@ -53,7 +125,7 @@ export interface SplitTextProps extends SplitParams { text: PlainText } -export interface SplitWordsProps extends SplitParams { +export interface SplitWordsProps extends BaseSplitProps { /** For hard breaks, include words that are newline characters as their `text`. */ words: Word[] @@ -67,20 +139,73 @@ export interface SplitWordsProps extends SplitParams { inferWhitespace?: boolean } +export type WordMap = Map + +export interface PositionWordsProps { + /** Words organized/wrapped into lines to be rendered. */ + wrappedLines: Word[][] + + /** Map of Word to measured dimensions (px) as it would be rendered. */ + wordMap: WordMap + + /** + * Details on where to render the Words onto canvas. These parameters ultimately come + * from `SplitWordsProps`, and they come from `CanvasTextConfig`. + */ + positioning: { + width: SplitWordsProps['width'] + // NOTE: height does NOT constrain the text; used only for vertical alignment + height: SplitWordsProps['height'] + x: SplitWordsProps['x'] + y: SplitWordsProps['y'] + align?: SplitWordsProps['align'] + vAlign?: SplitWordsProps['vAlign'] + } +} + /** - * A `Word` along with its position along the X axis within a "line", always starting from `0` - * relative to the line. + * A `Word` along with its __relative__ position along the X/Y axis within the bounding box + * in which it is to be drawn. * - * It's the caller's responsibility to render each line at its desired line height within - * the Canvas, as well as to calculate each word's location in the Canvas' absolute space. - * Therefore the caller provides the position along the Y axis. + * It's the caller's responsibility to render each Word onto the Canvas, as well as to calculate + * each Word's location in the Canvas' absolute space. */ export interface PositionedWord { + /** Reference to a `Word` given to `splitWords()`. */ word: Word + /** X position (px) relative to render box within 2D context. */ x: number + /** Y position (px) relative to render box within 2D context. */ + y: number + /** Width (px) used to render text. */ width: number + /** Height (px) used to render text. */ + height: number + /** + * True if this `word` is non-visible whitespace (per a Regex `^\s+$` match) and so + * __could be skipped when rendering__. + */ + isWhitespace: boolean } export interface SplitWordsResults { lines: PositionedWord[][] + + /** + * Baseline to use when rendering text based on alignment settings. + * + * 💬 Set this on the 2D context __before__ rendering the Words in the `lines`. + */ + textBaseline: CanvasTextBaseline + + /** + * Alignment to use when rendering text based on alignment settings. + * + * 💬 Set this on the 2D context __before__ rendering the Words in the `lines`. + */ + textAlign: CanvasTextAlign + + /** Total required height (px) to render all lines. */ + height: number } + diff --git a/src/canvas-txt/lib/split-text.ts b/src/canvas-txt/lib/split-text.ts index 2dbfe0c0..40474f97 100644 --- a/src/canvas-txt/lib/split-text.ts +++ b/src/canvas-txt/lib/split-text.ts @@ -1,7 +1,15 @@ -import { getStyle } from './get-style' +import { getTextFormat, getTextStyle } from './get-style' import { isWhitespace } from './is-whitespace' import { justifyLine } from './justify' -import { PositionedWord, SplitTextProps, SplitWordsProps, SplitWordsResults, Word } from './models' +import { + PositionWordsProps, + PositionedWord, + SplitTextProps, + SplitWordsProps, + SplitWordsResults, + Word, + WordMap +} from './models' import { trimLine } from './trim-line' // Hair space character for precise justification @@ -56,44 +64,189 @@ const splitIntoLines = function(words: Word[], inferWhitespace: boolean): Word[] return lines } +/** + * Helper for `splitWords()` that takes the words that have been wrapped into lines and + * determines their positions on canvas for future rendering based on alignment settings. + * @param params + * @returns Results to return via `splitWords()` + */ +const positionWords = function({ + wrappedLines, + wordMap, + positioning: { + width: boxWidth, + height: boxHeight, + x: boxX, + y: boxY, + align, + vAlign, + } +}: PositionWordsProps): SplitWordsResults { + const xEnd = boxX + boxWidth + const yEnd = boxY + boxHeight + + // max height per line + const lineHeights = wrappedLines.map( + (line) => + line.reduce( + (acc, word) => { + const metrics = wordMap.get(word)! // must exist as every `word` will have been measured + // NOTE: using __font__ ascent/descent to account for all possible characters in the font + // so that lines with ascenders but no descenders, or vice versa, are all properly + // aligned to the baseline, and so that lines aren't scrunched + return Math.max(acc, metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent) + }, + 0 + ) + ) + const totalHeight = lineHeights.reduce((acc, h) => acc + h, 0) + + // vertical alignment (defaults to middle) + let lineY: number + let textBaseline: CanvasTextBaseline + if (vAlign === 'top') { + textBaseline = 'top' + lineY = boxY + } else if (vAlign === 'bottom') { + textBaseline = 'bottom' + lineY = yEnd - totalHeight + } else { // middle + textBaseline = 'top' // YES, using 'top' baseline for 'middle' v-align + lineY = (boxY + boxHeight / 2) - (totalHeight / 2) + } + + const lines = wrappedLines.map((line, lineIdx): PositionedWord[] => { + const lineWidth = line.reduce( + (acc, word) => acc + (wordMap.get(word)?.width ?? 0), + 0 + ) + const lineHeight = lineHeights[lineIdx] + + // horizontal alignment (defaults to center) + let lineX: number + if (align === 'right') { + lineX = xEnd - lineWidth + } else if (align === 'left') { + lineX = boxX + } else { // center + lineX = (boxX + boxWidth / 2) - (lineWidth / 2) + } + + let wordX = lineX + const posWords = line.map((word): PositionedWord => { + const metrics = wordMap.get(word)! // must exist as every `word` will have been measured + const x = wordX + + // NOTE: even for middle vertical alignment, we want to use the __font__ ascent/descent + // so that words, per line, are still aligned to the baseline (as much as possible; if + // each word has a different font size, then things will still be offset, but for the + // same font size, the baseline should match from left to right) + const height = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent + + // vertical alignment (defaults to middle) + let y: number + if (vAlign === 'top') { + y = lineY + } else if (vAlign === 'bottom') { + y = lineY + lineHeight + } else { // middle + y = lineY + (lineHeight - height) / 2 + } + + wordX += metrics.width + return { + word, + x, + y, + width: metrics.width, + height, + isWhitespace: isWhitespace(word.text) + } + }) + + lineY += lineHeight + return posWords + }) + + return { + lines, + textBaseline, + textAlign: 'left', // always per current algorithm + height: totalHeight + } +} + +/** + * Splits Words into positioned lines of Words as they need to be rendred in 2D space, + * but does not render anything. + * @param config + * @returns Lines of positioned words to be rendered, and total height required to + * render all lines. + */ export function splitWords({ ctx, words, justify, - width: boxWidth, // width of box inside canvas within which text is to be rendered (variable height) + format: baseFormat, inferWhitespace = true, + ...positioning // rest of params are related to positioning }: SplitWordsProps): SplitWordsResults { - // map of Word to measured width - const wordMap = new Map() + const wordMap: WordMap = new Map() + const baseTextFormat = getTextFormat(baseFormat) + + //// text measurement const measureText = (word: Word): number => { - let textWidth = wordMap.get(word) - if (textWidth !== undefined) { - return textWidth + // DEBUG PERF: using the word as the key is very memory-intensive when justifying + // text (and also for a really, really long piece of text to render where there's + // a lot of repetitive whitespace, and probably repetitive words like 'the', etc); + // we should cache metrics based on combination of `word.text` and `word.format` + // instead of `word` (which will be different for every single, yet identical in + // text and format, justification `HAIR` character, and there will be thousands, + // if not millions, for a really long text to render) + if (wordMap.has(word)) { + return wordMap.get(word)!.width } if (word.format) { ctx.save() - ctx.font = getStyle(word.format) + ctx.font = getTextStyle(getTextFormat(word.format, baseTextFormat)) } - textWidth = ctx.measureText(word.text).width - wordMap.set(word, textWidth) + const metrics = ctx.measureText(word.text) + wordMap.set(word, metrics) if (word.format) { ctx.restore() } - return textWidth + return metrics.width } const measureLine = (words: Word[]): number => words.reduce((lineWidth, word) => lineWidth + measureText(word), 0) - const lines: Word[][] = [] + //// main + + ctx.save() + const initialLines = splitIntoLines(trimLine(words), inferWhitespace) + const { width: boxWidth } = positioning + + if ( + initialLines.length <= 0 || + boxWidth <= 0 || + positioning.height <= 0 || + (baseFormat && typeof baseFormat.fontSize === 'number' && baseFormat.fontSize <= 0) + ) { + // width or height or font size cannot be 0, or there are no lines after trimming + return { lines: [], textAlign: 'center', textBaseline: 'middle', height: 0 } + } + + ctx.font = getTextStyle(baseTextFormat) const hairWidth = justify ? measureText({ text: HAIR }) : 0 + const wrappedLines: Word[][] = [] for (const singleLine of initialLines) { let lineWidth = measureLine(singleLine) @@ -102,7 +255,7 @@ export function splitWords({ // as best as we can (i.e. minimum one word per line, no breaks within words, // no leading/pending whitespace) if (lineWidth <= boxWidth) { - lines.push(singleLine) + wrappedLines.push(singleLine) continue } @@ -153,60 +306,68 @@ export function splitWords({ splitPoint = 1 } - // Finally sets line to print lineToPrint = trimLine(tempLine.slice(0, splitPoint)) - lineToPrint = justify - ? justifyLine({ - measureLine, - line: lineToPrint, - spaceWidth: hairWidth, - spaceChar: HAIR, - width: boxWidth, - }) - : lineToPrint - lines.push(lineToPrint) + if (justify) { + lineToPrint = justifyLine({ + measureLine, + line: lineToPrint, + spaceWidth: hairWidth, + spaceChar: HAIR, + width: boxWidth, + }) + + // make sure any new Words used for justification get measured so we're able to + // position them later in positionWords() + measureLine(lineToPrint) + } + + wrappedLines.push(lineToPrint) tempLine = trimLine(tempLine.slice(splitPoint)) lineWidth = measureLine(tempLine) } if (lineWidth > 0) { - lineToPrint = justify - ? justifyLine({ - measureLine, - line: tempLine, - spaceWidth: hairWidth, - spaceChar: HAIR, - width: boxWidth, - }) - : tempLine - - lines.push(lineToPrint) + if (justify) { + lineToPrint = justifyLine({ + measureLine, + line: tempLine, + spaceWidth: hairWidth, + spaceChar: HAIR, + width: boxWidth, + }) + + // make sure any new Words used for justification get measured so we're able to + // position them later in positionWords() + measureLine(lineToPrint) + } else { + lineToPrint = tempLine + } + + wrappedLines.push(lineToPrint) } } - return { - lines: lines.map((line): PositionedWord[] => { - let nextX = 0 - return line.map((word): PositionedWord => { - const wordWidth = wordMap.get(word) ?? 0 - const x = nextX - nextX += wordWidth - return { word, x, width: wordWidth } - }) - }) - } + const results = positionWords({ + wrappedLines, + wordMap, + fontSize: baseTextFormat.fontSize, + ctx, + positioning, + }) + + ctx.restore() + return results } /** - * Splits plain text into lines in the order in which they should be rendered, top-down, - * preserving whitespace __only within the text__ (whitespace on either end is trimmed). + * Converts a string of text containing words and whitespace, as well as line breaks (newlines), + * into a `Word[]` that can be given to `splitWords()`. + * @param text String to convert into Words. + * @returns Converted text. */ -export function splitText({ - text, - ...params -}: SplitTextProps): string[] { - const textAsWords: Word[] = [] +export function textToWords(text: string) { + const words: Word[] = [] // split the `text` into a series of Words, preserving whitespace let word: Word | undefined = undefined; @@ -217,7 +378,7 @@ export function splitText({ // save current `word`, if any, and start new `word` wasWhitespace = charIsWhitespace if (word) { - textAsWords.push(word) + words.push(word) } word = { text: c } } else { @@ -231,12 +392,26 @@ export function splitText({ // make sure we have the last word! ;) if (word) { - textAsWords.push(word) + words.push(word) } + return words +} + +/** + * Splits plain text into lines in the order in which they should be rendered, top-down, + * preserving whitespace __only within the text__ (whitespace on either end is trimmed). + */ +export function splitText({ + text, + ...params +}: SplitTextProps): string[] { + const words = textToWords(text) + const results = splitWords({ ...params, - words: textAsWords, + words, + inferWhitespace: false }) return results.lines.map( diff --git a/src/canvas-txt/lib/text-height.ts b/src/canvas-txt/lib/text-height.ts index 3efed073..ff9a0fc1 100644 --- a/src/canvas-txt/lib/text-height.ts +++ b/src/canvas-txt/lib/text-height.ts @@ -1,26 +1,52 @@ +import { getTextStyle } from "./get-style" +import { Word } from "./models" + +interface GetWordHeightProps { + ctx: CanvasRenderingContext2D + word: Word +} + interface GetTextHeightProps { ctx: CanvasRenderingContext2D text: string - /** CSS font. Same syntax as CSS font specifier. */ - style: string + /** + * CSS font. Same syntax as CSS font specifier. If not specified, current `ctx` font + * settings/styles are used. + */ + style?: string } -export function getTextHeight({ - ctx, - text, - style, -}: GetTextHeightProps) { +const getHeight = function(ctx: CanvasRenderingContext2D, text: string, style?: string) { const previousTextBaseline = ctx.textBaseline const previousFont = ctx.font ctx.textBaseline = 'bottom' - ctx.font = style + if (style) { + ctx.font = style + } const { actualBoundingBoxAscent: height } = ctx.measureText(text) // Reset baseline ctx.textBaseline = previousTextBaseline - ctx.font = previousFont + if (style) { + ctx.font = previousFont + } return height } + +export function getWordHeight({ + ctx, + word, +}: GetWordHeightProps) { + return getHeight(ctx, word.text, word.format && getTextStyle(word.format)) +} + +export function getTextHeight({ + ctx, + text, + style, +}: GetTextHeightProps) { + return getHeight(ctx, text, style) +} diff --git a/src/docs/AppCanvas.vue b/src/docs/AppCanvas.vue index 37582752..e646714c 100644 --- a/src/docs/AppCanvas.vue +++ b/src/docs/AppCanvas.vue @@ -59,8 +59,8 @@ function renderText() { y: config.pos.y, width: config.size.w, height: config.size.h, - font: "Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue'", - fontSize: 24, + fontFamily: 'Arial', // DEBUG "Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue'", + fontSize: 12, // DEBUG 24, fontWeight: '100', // fontStyle: 'oblique', // fontVariant: 'small-caps', @@ -70,7 +70,23 @@ function renderText() { justify: config.justify, } - const { height } = drawText(ctx, config.text, myConfig) + // DEBUG keep only plain text for demo + const text = config.text + const richText = [ + { text: 'Lorem' }, + { text: 'ipsum', format: { fontStyle: 'italic' } }, + { text: 'dolor' }, + { text: 'sit' }, + { text: 'amet,' }, + { text: 'consectetur', format: { fontWeight: 'bold' } }, + { text: 'adipiscing' }, + { text: 'elit.' }, + { text: 'Proin' }, + { text: 'convallis' }, + { text: 'eros.' }, + ] && null + + const { height } = drawText(ctx, richText || text, myConfig) console.log(`Total height = ${height}`) } From 348c4041d4fd5a9b6d15877113b712ce31f5a23c Mon Sep 17 00:00:00 2001 From: Stefan Cameron Date: Fri, 19 Jan 2024 18:10:51 -0600 Subject: [PATCH 04/15] Fix rich text rendering __WARNING:__ See `// DEBUG` comments for what still needs doing/fixing. Works quite well now, especially when NOT using different font sizes for different words. If all words use the same size, just alter their style, weight, or variant, it renders nicely. Updated the demo app to always set "ipsum" to italic, and "consectetur" to bold, if either is found in the sample text to render. --- README.md | 6 ++--- src/canvas-txt/index.ts | 13 +++++---- src/canvas-txt/lib/models.ts | 33 +++++++++++++++-------- src/canvas-txt/lib/split-text.ts | 45 +++++++++++++++++++------------- src/docs/AppCanvas.vue | 38 ++++++++++----------------- 5 files changed, 72 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 0a0369bf..39232bbe 100644 --- a/README.md +++ b/README.md @@ -137,19 +137,19 @@ const { drawText, getTextHeight, splitText } = window.canvasTxt | `font` | `Arial` | Font family of the text | | `fontSize` | `14` | Font size of the text in px | | `fontStyle` | `''` | Font style, same as css font-style. Examples: `italic`, `oblique 40deg` | -| `fontVariant` | `''` | Font variant, same as css font-variant. Examples: `small-caps`, `slashed-zero` | +| `fontVariant` | `''` | Font variant, same as css font-variant. Examples: `small-caps` | | `fontWeight` | `''` | Font weight, same as css font-weight. Examples: `bold`, `100` | -| `lineHeight` | `null` | Line height of the text, if set to null it tries to auto-detect the value | | `justify` | `false` | Justify text if `true`, it will insert spaces between words when necessary. | ## Methods ```js -import { drawText, splitText, getTextHeight } from 'canvas-txt' +import { drawText, splitText, splitWords, textToWords, getTextHeight } from 'canvas-txt' ``` | Method | Description | | :---------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `// DEBUG TODO: update docs...` | | | `drawText(ctx,text, config)` | To draw the text to the canvas | | `splitText({ ctx, text, justify, width }` | To split the text `{ ctx: CanvasRenderingContext2D, text: string, justify: boolean, width: number }` | | `getTextHeight({ ctx, text, style })` | To get the height of the text `{ ctx: CanvasRenderingContext2D, text: string, style: string (font style we pass to ctx.font) }` [ctx.font docs](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/font) | diff --git a/src/canvas-txt/index.ts b/src/canvas-txt/index.ts index 8ea4cb15..e15c645e 100644 --- a/src/canvas-txt/index.ts +++ b/src/canvas-txt/index.ts @@ -8,7 +8,7 @@ function drawText( text: Text, config: CanvasTextConfig ) { - const format = getTextFormat({ + const baseFormat = getTextFormat({ fontFamily: config.fontFamily, fontSize: config.fontSize, fontStyle: config.fontStyle, @@ -27,24 +27,23 @@ function drawText( align: config.align, vAlign: config.vAlign, justify: config.justify, - lineHeight: config.lineHeight, - format, + format: baseFormat, }); ctx.save() ctx.textAlign = textAlign ctx.textBaseline = textBaseline - ctx.font = getTextStyle(format) + ctx.font = getTextStyle(baseFormat) richLines.forEach((line) => { line.forEach((pw) => { if (!pw.isWhitespace) { - if (pw.word.format) { + if (pw.format) { ctx.save() - ctx.font = getTextStyle(pw.word.format) + ctx.font = getTextStyle(pw.format) } ctx.fillText(pw.word.text, pw.x, pw.y) - if (pw.word.format) { + if (pw.format) { ctx.restore() } } diff --git a/src/canvas-txt/lib/models.ts b/src/canvas-txt/lib/models.ts index 5df3ad24..a13c5aac 100644 --- a/src/canvas-txt/lib/models.ts +++ b/src/canvas-txt/lib/models.ts @@ -1,8 +1,11 @@ export interface TextFormat { /** Font family (CSS value). */ fontFamily?: string + + // DEBUG TODO: rendering words at different sizes doesn't render well per baseline /** Font size (px). */ fontSize?: number + /** Font weight (CSS value). */ fontWeight?: string /** Font style (CSS value) */ @@ -13,8 +16,7 @@ export interface TextFormat { /** Font variant (CSS value). */ fontVariant?: 'normal' | 'small-caps' | '' - // NOTE: line height is only supported at the context level (i.e. one line height for all - // the text) even though Canvas2DContext.font supports it; see `CanvasTextConfig.lineHeight` + // NOTE: line height is not currently supported } export interface Word { @@ -58,9 +60,6 @@ export interface CanvasTextConfig extends TextFormat { /** True if text should be justified within the `boxWidth` to fill the hole width. */ justify?: boolean - /** Desired line height when rendering text. Defaults to height based on font styles. */ - lineHeight?: number - /** * __NOTE:__ Applies only if `text`, given to `drawText()`, is a `Word[]`. Ignored if it's * a `string`. @@ -106,9 +105,6 @@ export interface BaseSplitProps { /** True if text should be justified within the `boxWidth` to fill the hole width. */ justify?: boolean - /** Desired line height when rendering text. Defaults to height based on font styles. */ - lineHeight?: number - /** * Base/default font styles. These will be used for any word that doesn't have specific * formatting overrides. It's basically how "plain text" should be rendered. @@ -139,7 +135,12 @@ export interface SplitWordsProps extends BaseSplitProps { inferWhitespace?: boolean } -export type WordMap = Map +/** + * Maps a `Word` to its measured `metrics` and the font `format` used to measure it (if the + * `Word` specified a format to use; undefined means the base formatting, as set on the canvas + * 2D context, was used). + */ +export type WordMap = Map }> export interface PositionWordsProps { /** Words organized/wrapped into lines to be rendered. */ @@ -173,6 +174,15 @@ export interface PositionWordsProps { export interface PositionedWord { /** Reference to a `Word` given to `splitWords()`. */ word: Word + + /** + * Full formatting used to measure/position the `word`, __if a `word.format` partial + * was specified.__ + * + * ❗️ __Use this for actual rendering__ instead of the original `word.format`. + */ + format?: Required + /** X position (px) relative to render box within 2D context. */ x: number /** Y position (px) relative to render box within 2D context. */ @@ -181,6 +191,7 @@ export interface PositionedWord { width: number /** Height (px) used to render text. */ height: number + /** * True if this `word` is non-visible whitespace (per a Regex `^\s+$` match) and so * __could be skipped when rendering__. @@ -194,14 +205,14 @@ export interface SplitWordsResults { /** * Baseline to use when rendering text based on alignment settings. * - * 💬 Set this on the 2D context __before__ rendering the Words in the `lines`. + * ❗️ Set this on the 2D context __before__ rendering the Words in the `lines`. */ textBaseline: CanvasTextBaseline /** * Alignment to use when rendering text based on alignment settings. * - * 💬 Set this on the 2D context __before__ rendering the Words in the `lines`. + * ❗️ Set this on the 2D context __before__ rendering the Words in the `lines`. */ textAlign: CanvasTextAlign diff --git a/src/canvas-txt/lib/split-text.ts b/src/canvas-txt/lib/split-text.ts index 40474f97..d81c53f6 100644 --- a/src/canvas-txt/lib/split-text.ts +++ b/src/canvas-txt/lib/split-text.ts @@ -85,16 +85,23 @@ const positionWords = function({ const xEnd = boxX + boxWidth const yEnd = boxY + boxHeight + // NOTE: using __font__ ascent/descent to account for all possible characters in the font + // so that lines with ascenders but no descenders, or vice versa, are all properly + // aligned to the baseline, and so that lines aren't scrunched + // NOTE: even for middle vertical alignment, we want to use the __font__ ascent/descent + // so that words, per line, are still aligned to the baseline (as much as possible; if + // each word has a different font size, then things will still be offset, but for the + // same font size, the baseline should match from left to right) + const getHeight = (metrics: TextMetrics): number => + metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent + // max height per line const lineHeights = wrappedLines.map( (line) => line.reduce( (acc, word) => { - const metrics = wordMap.get(word)! // must exist as every `word` will have been measured - // NOTE: using __font__ ascent/descent to account for all possible characters in the font - // so that lines with ascenders but no descenders, or vice versa, are all properly - // aligned to the baseline, and so that lines aren't scrunched - return Math.max(acc, metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent) + const { metrics } = wordMap.get(word)! // must exist as every `word` MUST have been measured + return Math.max(acc, getHeight(metrics)) }, 0 ) @@ -117,7 +124,7 @@ const positionWords = function({ const lines = wrappedLines.map((line, lineIdx): PositionedWord[] => { const lineWidth = line.reduce( - (acc, word) => acc + (wordMap.get(word)?.width ?? 0), + (acc, word) => acc + wordMap.get(word)!.metrics.width, // must exist as every `word` MUST have been measured 0 ) const lineHeight = lineHeights[lineIdx] @@ -134,14 +141,9 @@ const positionWords = function({ let wordX = lineX const posWords = line.map((word): PositionedWord => { - const metrics = wordMap.get(word)! // must exist as every `word` will have been measured + const { metrics, format } = wordMap.get(word)! // must exist as every `word` MUST have been measured const x = wordX - - // NOTE: even for middle vertical alignment, we want to use the __font__ ascent/descent - // so that words, per line, are still aligned to the baseline (as much as possible; if - // each word has a different font size, then things will still be offset, but for the - // same font size, the baseline should match from left to right) - const height = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent + const height = getHeight(metrics) // vertical alignment (defaults to middle) let y: number @@ -156,6 +158,7 @@ const positionWords = function({ wordX += metrics.width return { word, + format, x, y, width: metrics.width, @@ -172,6 +175,12 @@ const positionWords = function({ lines, textBaseline, textAlign: 'left', // always per current algorithm + + // DEBUG TODO: `totalHeight` is actually ~5px MORE than it should be purely looking at pixels; + // not sure why that is, other than the use of `fontBounding*` metrics to calculate word + // heights, line heights, and ultimately this total height, which accounts for more than + // purely rendered pixels, but is also necessary for proper positioning and avoiding + // scrunching the text (e.g. if we were to use `actualBounding*` metrics instead height: totalHeight } } @@ -205,16 +214,18 @@ export function splitWords({ // text and format, justification `HAIR` character, and there will be thousands, // if not millions, for a really long text to render) if (wordMap.has(word)) { - return wordMap.get(word)!.width + return wordMap.get(word)!.metrics.width } + let format = undefined if (word.format) { ctx.save() - ctx.font = getTextStyle(getTextFormat(word.format, baseTextFormat)) + format = getTextFormat(word.format, baseTextFormat) + ctx.font = getTextStyle(format) } const metrics = ctx.measureText(word.text) - wordMap.set(word, metrics) + wordMap.set(word, { metrics, format }) if (word.format) { ctx.restore() @@ -351,8 +362,6 @@ export function splitWords({ const results = positionWords({ wrappedLines, wordMap, - fontSize: baseTextFormat.fontSize, - ctx, positioning, }) diff --git a/src/docs/AppCanvas.vue b/src/docs/AppCanvas.vue index e646714c..2eec274a 100644 --- a/src/docs/AppCanvas.vue +++ b/src/docs/AppCanvas.vue @@ -1,5 +1,5 @@