├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── beta.js.yml │ ├── main.js.yml │ ├── release.js.yml │ └── storybook.js.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .prettierrc ├── .storybook ├── main.js ├── package.json └── preview.js ├── LICENSE ├── README.md ├── build.cjs ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── index.ts ├── multi-line.ts ├── setupTests.ts └── stories │ ├── benchmark.stories.tsx │ ├── linebreak.d.ts │ └── main.css ├── test └── multi-line.test.tsx ├── tsconfig.json └── tsconfig.types.json /.eslintignore: -------------------------------------------------------------------------------- 1 | build.cjs -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:import/recommended", 7 | "plugin:import/typescript", 8 | "plugin:react-hooks/recommended", 9 | "plugin:sonarjs/recommended", 10 | "plugin:unicorn/recommended" 11 | ], 12 | "plugins": ["react", "@typescript-eslint", "import", "sonarjs", "unicorn"], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "project": ["./tsconfig.json"] 16 | }, 17 | "rules": { 18 | "no-console": "warn", 19 | "guard-for-in": "error", 20 | "no-empty": "error", 21 | "eqeqeq": ["error", "always"], 22 | "no-shadow": "off", 23 | "import/no-cycle": "off", 24 | "import/namespace": "off", 25 | "import/named": "off", 26 | "import/no-unresolved": "off", // FIXME: Figure out how to make this enabled 27 | "@typescript-eslint/no-unused-vars": "off", 28 | "sonarjs/cognitive-complexity": "off", 29 | "sonarjs/no-inverted-boolean-check": "error", 30 | "@typescript-eslint/explicit-function-return-type": "off", 31 | "@typescript-eslint/no-empty-interface": "off", 32 | "@typescript-eslint/no-inferrable-types": "off", 33 | "@typescript-eslint/no-explicit-any": "off", 34 | "@typescript-eslint/ban-ts-ignore": "off", 35 | "@typescript-eslint/camelcase": "off", 36 | "@typescript-eslint/interface-name-prefix": "off", 37 | "@typescript-eslint/no-floating-promises": "error", 38 | "@typescript-eslint/no-shadow": "error", 39 | "unicorn/no-nested-ternary": "off", 40 | "unicorn/prevent-abbreviations": "off", 41 | "unicorn/no-useless-undefined": "off", 42 | "unicorn/prefer-query-selector": "off", 43 | "unicorn/filename-case": "off", 44 | "unicorn/prefer-top-level-await": "off", 45 | "unicorn/no-array-callback-reference": "off", 46 | "unicorn/no-null": "off", 47 | "unicorn/prefer-dom-node-text-content": "off", 48 | "unicorn/no-array-reduce": "off", 49 | "@typescript-eslint/no-misused-promises": [ 50 | "error", 51 | { 52 | "checksVoidReturn": false 53 | } 54 | ], 55 | "@typescript-eslint/strict-boolean-expressions": "error", 56 | "@typescript-eslint/ban-ts-comment": "off", 57 | "@typescript-eslint/explicit-module-boundary-types": "off", 58 | "@typescript-eslint/ban-types": "off" 59 | }, 60 | "ignorePatterns": ["**/node_modules/*", "**/dist/*"] 61 | } -------------------------------------------------------------------------------- /.github/workflows/beta.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Beta CI 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*-*" 10 | 11 | jobs: 12 | publish: 13 | defaults: 14 | run: 15 | working-directory: ./ 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v1 19 | - uses: actions/setup-node@v1 20 | with: 21 | node-version: 16 22 | - run: npm install 23 | - run: npm run build 24 | - uses: JS-DevTools/npm-publish@v1 25 | with: 26 | token: ${{ secrets.NPM_TOKEN }} 27 | access: public 28 | tag: beta 29 | package: ./package.json 30 | -------------------------------------------------------------------------------- /.github/workflows/main.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: push 7 | 8 | jobs: 9 | build: 10 | defaults: 11 | run: 12 | working-directory: ./ 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v1 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: 16 19 | - run: npm install 20 | - run: npm run build 21 | - run: npm run lint 22 | - run: npm run test -- --coverage 23 | - name: Coveralls 24 | uses: coverallsapp/github-action@master 25 | with: 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | path-to-lcov: ./coverage/lcov.info 28 | base-path: ./ 29 | -------------------------------------------------------------------------------- /.github/workflows/release.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Release CI 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | - "!v*-*" 11 | 12 | jobs: 13 | publish: 14 | defaults: 15 | run: 16 | working-directory: ./ 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v1 20 | - uses: actions/setup-node@v1 21 | with: 22 | node-version: 16 23 | - run: npm install 24 | - run: npm run build 25 | - uses: JS-DevTools/npm-publish@v1 26 | with: 27 | token: ${{ secrets.NPM_TOKEN }} 28 | access: public 29 | package: ./package.json 30 | -------------------------------------------------------------------------------- /.github/workflows/storybook.js.yml: -------------------------------------------------------------------------------- 1 | name: Storybook Build and Deploy 2 | on: 3 | push: 4 | branches: ["main"] 5 | paths: ["src/**"] 6 | jobs: 7 | build-and-deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 🛎️ 11 | uses: actions/checkout@v2.3.1 12 | with: 13 | persist-credentials: false 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: 16 17 | - name: Install and Build 🔧 18 | run: | 19 | npm install 20 | npm run build-storybook 21 | - name: Deploy 🚀 22 | uses: JamesIves/github-pages-deploy-action@3.6.2 23 | with: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | BRANCH: gh-pages 26 | FOLDER: storybook-build 27 | CLEAN: true 28 | TARGET_FOLDER: docs 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig* 3 | coverage/* -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.16.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "printWidth": 120, 4 | "semi": true, 5 | "jsxBracketSameLine": true, 6 | "arrowParens": "avoid", 7 | "bracketSpacing": true, 8 | "cursorOffset": -1, 9 | "endOfLine": "lf", 10 | "htmlWhitespaceSensitivity": "css", 11 | "insertPragma": false, 12 | "jsxSingleQuote": false, 13 | "proseWrap": "preserve", 14 | "quoteProps": "as-needed", 15 | "rangeStart": 0, 16 | "requirePragma": false, 17 | "singleQuote": false, 18 | "trailingComma": "es5", 19 | "useTabs": false, 20 | "vueIndentScriptAndStyle": false 21 | } 22 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], 3 | addons: ["@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-interactions"], 4 | framework: "@storybook/react", 5 | }; 6 | -------------------------------------------------------------------------------- /.storybook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "commonjs" 3 | } -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Glide 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Canvas HyperTxt 🚀📐✍ 2 | 3 | A zero dependency featherweight library to layout text on a canvas. 4 | 5 | [![Version](https://img.shields.io/npm/v/canvas-hypertxt?color=blue&label=latest&style=for-the-badge)](https://github.com/glideapps/canvas-hypertxt/releases) 6 | [![npm bundle size](https://img.shields.io/bundlephobia/minzip/canvas-hypertxt?color=success&label=bundle&style=for-the-badge)](https://bundlephobia.com/package/canvas-hypertxt) 7 | [![Code Coverage](https://img.shields.io/coverallsCoverage/github/glideapps/canvas-hypertxt?color=457aba&label=Cover&style=for-the-badge)](https://coveralls.io/github/glideapps/canvas-hypertxt) 8 | [![License](https://img.shields.io/github/license/glideapps/canvas-hypertxt?color=red&style=for-the-badge)](https://github.com/glideapps/canvas-hypertxt/blob/main/LICENSE) 9 | [![Made By Glide](https://img.shields.io/badge/❤_Made_by-Glide-11CCE5?style=for-the-badge&logo=none)](https://www.glideapps.com/jobs) 10 | 11 | ## Quickstart ⚡ 12 | 13 | ```ts 14 | import { split } from "canvas-hypertxt"; 15 | 16 | function renderWrappedText(ctx: CanvasRenderingContext2D, value: string, width: number, x: number, y: number) { 17 | ctx.font = "12px sans-serif"; // ideally don't do this every time, it is really slow. 18 | ctx.textBaseline = "top"; // just makes positioning easier to predict, not essential 19 | const lines = split(ctx, value, "12px sans-serif", width); 20 | for (const line of lines) { 21 | ctx.fillText(line, x, y); 22 | y += 15; 23 | } 24 | } 25 | 26 | function renderWrappedTextCentered(ctx: CanvasRenderingContext2D, value: string, width: number, x: number, y: number) { 27 | // ideally don't do this every time, it is really slow. 28 | ctx.font = "12px sans-serif"; 29 | ctx.textAlign = "center"; 30 | ctx.textBaseline = "top"; 31 | const lines = split(ctx, value, "12px sans-serif", width); 32 | for (const line of lines) { 33 | ctx.fillText(line, x + width / 2, y); 34 | y += 15; 35 | } 36 | } 37 | ``` 38 | 39 | ## Who is this for? 40 | 41 | This library is inspired by the excellent [canvas-txt](https://canvas-txt.geongeorge.com) but focuses instead on being a part of the rendering pipeline instead of drawing the text for you. This offers greater flexibility for those who need it. Additionally canvas-hypertxt focuses on layout performance, allowing for much faster overall layout and rendering performance compared to the original library. Some sacrifices are made in the name of performance (justify). This library is internally integrated and used in [glide-data-grid](https://grid.glideapps.com) and now is available as a standalone. 42 | 43 | ## Comparison vs canvas-txt 44 | 45 | While canvas-hypertxt does not set out to be a drop-in replacement to canvas-txt, we can drag race them. 46 | 47 | All tests were done with 5000 iterations. To ensure a fair comparison times include font rendering time. 48 | 49 | | | canvas-txt | canvas-hypertxt | canvas-hypertxt w/ HyperWrapping | 50 | | --------------------- | ---------: | --------------: | -------------------------------: | 51 | | 20 char (no wrapping) | 0.05 sec | 0.05 sec | 0.05 sec | 52 | | 100 char | 0.59 sec | 0.11 sec | 0.11 sec | 53 | | 300 char | 2.63 sec | 0.40 sec | 0.26 sec | 54 | | 600 char | 6.17 sec | 0.81 sec | 0.47 sec | 55 | | 1000 char | 11.19 sec | 1.43 sec | 0.77 sec | 56 | | 1800 char (overflow) | 22.47 sec | 2.29 sec | 1.19 sec | 57 | 58 | Benchmark code can be found [here](https://github.com/glideapps/canvas-hypertxt/blob/main/src/stories/benchmark.stories.tsx). You can run benchmarks on your machine [here](https://glideapps.github.io/canvas-hypertxt/?path=/story/benchmark--benchmark) 59 | 60 | `canvas-multiline-text` is not included in this chart because it fails to pass a basic correctness test. It can't handle wrapping correctly if there are no words to break at, nor does it handle newlines. Due to this it ends up making fewer draw calls and a significant amount of rendering happens off-canvas reducing drawing overhead further due to the correctness errors. 61 | 62 | That said canvas-hypertxt tends to be around 20-30% faster than canvas-multiline-text without hyper wrapping, and about 1.2x faster with. 63 | 64 | ## How is this so much faster? 65 | 66 | Canvas-txt is an excellent library but takes a very inefficient approach to finding wrap points. It is clearly not written with performance in mind, but rather with features and bundle size. Overall it is a fantastic library if raw speed is not important to your use case. 67 | 68 | ## HyperWrapping 69 | 70 | One of the major items introduced by `canvas-hypertxt` is the concept of hyper wrapping. When enabled the font engine will train a weighting model to provide estimates for string sizes. Once the model is sufficiently trained it will perform string wrapping without calling `ctx.measureText` once. This leads to massive performance gains at the cost of accuracy. In practice with most fonts and text bodies, once trained the hyper wrap guesses will be within 1% of the actual measured size. A buffer is added to ensure the text wraps slightly too early instead of clipping. 71 | 72 | The end result is text that is correctly wrapped the vast majority of the time, with a very small number of errors where the text wraps too early by a single word. The performance gains in the measure pass are over 100x, with hyper wrapped text basically having zero cost vs unwrapped text of the same size. 73 | 74 | ## What am I missing using canvas-hypertxt? 75 | 76 | You miss some features. 77 | 78 | ### Managed rendering 79 | 80 | canvas-txt will render your string for you, figure out line heights, etc. With canvas-hypertxt this is on you. It's not hard to do but it is a bit of extra lifting. You could easily wrap the canvas-hypertxt split function to do the same thing canvas-txt does. Examples provided in the quickstart. 81 | 82 | ### Justify 83 | 84 | I didn't feel like implementing it, patches welcome. This will 100% be slower than non-justified text due to the large amounts of string manipulation and extra measurement required. If you need justification canvas-txt can do it. 85 | 86 | ### Debug mode 87 | 88 | Because canvas-hypertxt doesn't actually render the text, it also can't render debug boxes for you. 89 | 90 | ### Automatic text alignment 91 | 92 | Text alignment with canvas-hypertxt is not as simple as setting a flag, though it's close. Set the `ctx.textAlign` appropriately and change the `x` value you pass to `fillText` to correspond to the left, center, or right edge of the bounding box. 93 | 94 | ### Why are these all missing? 95 | 96 | canvas-hypertxt is intended to be used as part of larger libraries which need wrapping text. In these cases text-alignment or actual text rendering is handled by existing functions which may provide additional functionality. By not rendering the text for the consumer, more flexibility is granted, however it comes at the cost of simplicity. 97 | 98 | ## Usage 99 | 100 | This library consists of two methods. 101 | 102 | ```ts 103 | export function split( 104 | ctx: CanvasRenderingContext2D, 105 | value: string, 106 | fontStyle: string, 107 | width: number, 108 | hyperWrappingAllowed: boolean 109 | ): readonly string[]; 110 | ``` 111 | 112 | split takes the following parameters 113 | 114 | | Name | Usage | 115 | | -------------------- | ------------------------------------------------------------------------------- | 116 | | ctx | A CanvasRenderingContext2D | 117 | | value | The string which needs to be wrapped | 118 | | fontStyle | A unique key which represents the font configuration currently applied to `ctx` | 119 | | width | The maximum width of any line | 120 | | hyperWrappingAllowed | Whether or not to allow hyper wrapping | 121 | 122 | ```ts 123 | export function clearCache(): void; 124 | ``` 125 | 126 | Clear all size caches the library has collected so far. Ideally do this when fonts have finished loading. 127 | 128 | ```ts 129 | async function clearCacheOnLoad() { 130 | if (document?.fonts?.ready === undefined) return; 131 | await document.fonts.ready; 132 | clearCache(); 133 | } 134 | 135 | void clearCacheOnLoad(); 136 | ``` 137 | -------------------------------------------------------------------------------- /build.cjs: -------------------------------------------------------------------------------- 1 | const { build } = require("esbuild"); 2 | 3 | const shared = { 4 | entryPoints: ["src/index.ts"], 5 | bundle: true, 6 | minify: true, 7 | sourcemap: true, 8 | }; 9 | 10 | build({ 11 | ...shared, 12 | outfile: "dist/cjs/index.cjs", 13 | format: "cjs", 14 | }); 15 | 16 | build({ 17 | ...shared, 18 | outfile: "dist/js/index.js", 19 | format: "esm", 20 | }); 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | preset: "ts-jest", 3 | roots: ["/"], 4 | testEnvironment: "jsdom", 5 | moduleDirectories: ["node_modules", "../node_modules"], 6 | setupFilesAfterEnv: ["/src/setupTests.ts"], 7 | testPathIgnorePatterns: ["/node_modules/", "/dist/"], 8 | transform: { 9 | "^.+\\.(ts|tsx)?$": "ts-jest", 10 | "^.+\\.(js|jsx)$": "babel-jest", 11 | }, 12 | globals: { 13 | __PATH_PREFIX__: ``, 14 | "ts-jest": { 15 | babelConfig: { 16 | presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-react"], 17 | }, 18 | }, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas-hypertxt", 3 | "version": "1.0.3", 4 | "description": "The fastest way to layout wrapped text on a HTML5 canvas", 5 | "sideEffects": false, 6 | "type": "module", 7 | "files": [ 8 | "dist", 9 | "test" 10 | ], 11 | "browser": "dist/js/index.js", 12 | "main": "dist/cjs/index.cjs", 13 | "module": "dist/js/index.js", 14 | "types": "dist/ts/src/index.d.ts", 15 | "exports": { 16 | "import": "./dist/js/index.js", 17 | "require": "./dist/cjs/index.cjs", 18 | "types": "./dist/ts/src/index.d.ts" 19 | }, 20 | "scripts": { 21 | "build": "node build.cjs && tsc -p tsconfig.types.json", 22 | "test": "jest", 23 | "lint": "eslint src --ext .ts,.tsx", 24 | "storybook": "start-storybook -p 6006", 25 | "build-storybook": "build-storybook -o storybook-build/" 26 | }, 27 | "repository": { 28 | "type": "git", 29 | "url": "git+https://github.com/glideapps/canvas-hypertxt.git" 30 | }, 31 | "keywords": [ 32 | "html5", 33 | "canvas", 34 | "text", 35 | "font", 36 | "layout", 37 | "multiline", 38 | "wrapping", 39 | "wrap", 40 | "alignment" 41 | ], 42 | "author": "Glide", 43 | "license": "MIT", 44 | "bugs": { 45 | "url": "https://github.com/glideapps/canvas-hypertxt/issues" 46 | }, 47 | "homepage": "https://github.com/glideapps/canvas-hypertxt#readme", 48 | "devDependencies": { 49 | "@babel/core": "^7.18.6", 50 | "@babel/preset-env": "^7.18.6", 51 | "@babel/preset-react": "^7.18.6", 52 | "@storybook/addon-actions": "^6.5.9", 53 | "@storybook/addon-essentials": "^6.5.9", 54 | "@storybook/addon-interactions": "^6.5.9", 55 | "@storybook/addon-links": "^6.5.9", 56 | "@storybook/builder-webpack4": "^6.5.9", 57 | "@storybook/manager-webpack4": "^6.5.9", 58 | "@storybook/react": "^6.5.9", 59 | "@storybook/testing-library": "0.0.13", 60 | "@testing-library/jest-dom": "^5.16.4", 61 | "@testing-library/react": "^13.3.0", 62 | "@types/canvas-multiline-text": "^1.0.0", 63 | "@types/canvas-txt": "^3.0.0", 64 | "@types/jest": "^28.1.3", 65 | "@typescript-eslint/eslint-plugin": "^5.30.5", 66 | "@typescript-eslint/parser": "^5.30.5", 67 | "@typescript-eslint/typescript-estree": "^5.30.5", 68 | "babel-loader": "^8.2.5", 69 | "canvas-multiline-text": "^1.0.3", 70 | "canvas-txt": "^3.0.0", 71 | "esbuild": "^0.14.47", 72 | "eslint": "^8.19.0", 73 | "eslint-plugin-import": "^2.26.0", 74 | "eslint-plugin-react": "^7.30.1", 75 | "eslint-plugin-react-hooks": "^4.6.0", 76 | "eslint-plugin-sonarjs": "^0.13.0", 77 | "eslint-plugin-unicorn": "^43.0.1", 78 | "jest": "^28.1.1", 79 | "jest-canvas-mock": "^2.4.0", 80 | "jest-environment-jsdom": "^28.1.1", 81 | "linebreak": "^1.1.0", 82 | "react": "^18.2.0", 83 | "react-dom": "^18.2.0", 84 | "ts-jest": "^28.0.5", 85 | "typescript": "^4.7.4" 86 | }, 87 | "dependencies": {} 88 | } 89 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { splitMultilineText as split, clearMultilineCache as clearCache } from "./multi-line"; 2 | -------------------------------------------------------------------------------- /src/multi-line.ts: -------------------------------------------------------------------------------- 1 | const resultCache: Map = new Map(); 2 | 3 | // font -> avg pixels per char 4 | const metrics: Map = new Map(); 5 | 6 | const hyperMaps: Map> = new Map(); 7 | 8 | type BreakCallback = (str: string) => readonly number[]; 9 | 10 | export function clearMultilineCache() { 11 | resultCache.clear(); 12 | hyperMaps.clear(); 13 | metrics.clear(); 14 | } 15 | 16 | export function backProp( 17 | text: string, 18 | realWidth: number, 19 | keyMap: Map, 20 | temperature: number, 21 | avgSize: number 22 | ) { 23 | let guessWidth = 0; 24 | const contribMap: Record = {}; 25 | for (const char of text) { 26 | const v = keyMap.get(char) ?? avgSize; 27 | guessWidth += v; 28 | contribMap[char] = (contribMap[char] ?? 0) + 1; 29 | ``; 30 | } 31 | 32 | const diff = realWidth - guessWidth; 33 | 34 | for (const key of Object.keys(contribMap)) { 35 | const numContribution = contribMap[key]; 36 | const contribWidth = keyMap.get(key) ?? avgSize; 37 | const contribAmount = (contribWidth * numContribution) / guessWidth; 38 | const adjustment = (diff * contribAmount * temperature) / numContribution; 39 | const newVal = contribWidth + adjustment; 40 | keyMap.set(key, newVal); 41 | } 42 | } 43 | 44 | function makeHyperMap(ctx: CanvasRenderingContext2D, avgSize: number): Map { 45 | const result: Map = new Map(); 46 | let total = 0; 47 | for (const char of "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890,.-+=?") { 48 | const w = ctx.measureText(char).width; 49 | result.set(char, w); 50 | total += w; 51 | } 52 | 53 | const avg = total / result.size; 54 | 55 | // Artisnal hand-tuned constants that have no real meaning other than they make it work better for most fonts 56 | // These don't really need to be accurate, we are going to be adjusting the weights. It just converges faster 57 | // if they start somewhere close. 58 | const damper = 3; 59 | const scaler = (avgSize / avg + damper) / (damper + 1); 60 | const keys = result.keys(); 61 | for (const key of keys) { 62 | result.set(key, (result.get(key) ?? avg) * scaler); 63 | } 64 | return result; 65 | } 66 | 67 | function measureText(ctx: CanvasRenderingContext2D, text: string, fontStyle: string, hyperMode: boolean): number { 68 | const current = metrics.get(fontStyle); 69 | 70 | if (hyperMode && current !== undefined && current.count > 20_000) { 71 | let hyperMap = hyperMaps.get(fontStyle); 72 | if (hyperMap === undefined) { 73 | hyperMap = makeHyperMap(ctx, current.size); 74 | hyperMaps.set(fontStyle, hyperMap); 75 | } 76 | 77 | if (current.count > 500_000) { 78 | let final = 0; 79 | for (const char of text) { 80 | final += hyperMap.get(char) ?? current.size; 81 | } 82 | return final * 1.01; //safety margin 83 | } 84 | 85 | const result = ctx.measureText(text); 86 | backProp(text, result.width, hyperMap, Math.max(0.05, 1 - current.count / 200_000), current.size); 87 | metrics.set(fontStyle, { 88 | count: current.count + text.length, 89 | size: current.size, 90 | }); 91 | return result.width; 92 | } 93 | 94 | const result = ctx.measureText(text); 95 | 96 | const avg = result.width / text.length; 97 | 98 | // we've collected enough data 99 | if ((current?.count ?? 0) > 20_000) { 100 | return result.width; 101 | } 102 | 103 | if (current === undefined) { 104 | metrics.set(fontStyle, { 105 | count: text.length, 106 | size: avg, 107 | }); 108 | } else { 109 | const diff = avg - current.size; 110 | const contribution = text.length / (current.count + text.length); 111 | const newVal = current.size + diff * contribution; 112 | metrics.set(fontStyle, { 113 | count: current.count + text.length, 114 | size: newVal, 115 | }); 116 | } 117 | 118 | return result.width; 119 | } 120 | 121 | function getSplitPoint( 122 | ctx: CanvasRenderingContext2D, 123 | text: string, 124 | width: number, 125 | fontStyle: string, 126 | totalWidth: number, 127 | measuredChars: number, 128 | hyperMode: boolean, 129 | getBreakOpportunities?: BreakCallback 130 | ): number { 131 | if (text.length <= 1) return text.length; 132 | 133 | // this should never happen, but we are protecting anyway 134 | if (totalWidth < width) return -1; 135 | 136 | let guess = Math.floor((width / totalWidth) * measuredChars); 137 | let guessWidth = measureText(ctx, text.slice(0, Math.max(0, guess)), fontStyle, hyperMode); 138 | 139 | const oppos = getBreakOpportunities?.(text); 140 | 141 | if (guessWidth === width) { 142 | // NAILED IT 143 | } else if (guessWidth < width) { 144 | while (guessWidth < width) { 145 | guess++; 146 | guessWidth = measureText(ctx, text.slice(0, Math.max(0, guess)), fontStyle, hyperMode); 147 | } 148 | guess--; 149 | } else { 150 | // we only need to check for spaces as we go back 151 | while (guessWidth > width) { 152 | const lastSpace = oppos !== undefined ? 0 : text.lastIndexOf(" ", guess - 1); 153 | if (lastSpace > 0) { 154 | guess = lastSpace; 155 | } else { 156 | guess--; 157 | } 158 | guessWidth = measureText(ctx, text.slice(0, Math.max(0, guess)), fontStyle, hyperMode); 159 | } 160 | } 161 | 162 | if (text[guess] !== " ") { 163 | let greedyBreak = 0; 164 | if (oppos === undefined) { 165 | greedyBreak = text.lastIndexOf(" ", guess); 166 | } else { 167 | for (const o of oppos) { 168 | if (o > guess) break; 169 | greedyBreak = o; 170 | } 171 | } 172 | if (greedyBreak > 0) { 173 | guess = greedyBreak; 174 | } 175 | } 176 | 177 | return guess; 178 | } 179 | 180 | // Algorithm improved from https://github.com/geongeorge/Canvas-Txt/blob/master/src/index.js 181 | export function splitMultilineText( 182 | ctx: CanvasRenderingContext2D, 183 | value: string, 184 | fontStyle: string, 185 | width: number, 186 | hyperWrappingAllowed: boolean, 187 | getBreakOpportunities?: BreakCallback 188 | ): readonly string[] { 189 | const key = `${value}_${fontStyle}_${width}px`; 190 | const cacheResult = resultCache.get(key); 191 | if (cacheResult !== undefined) return cacheResult; 192 | 193 | if (width <= 0) { 194 | // dont render 0 width stuff 195 | return []; 196 | } 197 | 198 | let result: string[] = []; 199 | const encodedLines: string[] = value.split("\n"); 200 | 201 | const fontMetrics = metrics.get(fontStyle); 202 | const safeLineGuess = fontMetrics === undefined ? value.length : (width / fontMetrics.size) * 1.5; 203 | const hyperMode = hyperWrappingAllowed && fontMetrics !== undefined && fontMetrics.count > 20_000; 204 | 205 | for (let line of encodedLines) { 206 | let textWidth = measureText(ctx, line.slice(0, Math.max(0, safeLineGuess)), fontStyle, hyperMode); 207 | let measuredChars = Math.min(line.length, safeLineGuess); 208 | if (textWidth <= width) { 209 | // line fits, just push it 210 | result.push(line); 211 | } else { 212 | while (textWidth > width) { 213 | const splitPoint = getSplitPoint( 214 | ctx, 215 | line, 216 | width, 217 | fontStyle, 218 | textWidth, 219 | measuredChars, 220 | hyperMode, 221 | getBreakOpportunities 222 | ); 223 | const subLine = line.slice(0, Math.max(0, splitPoint)); 224 | 225 | line = line.slice(subLine.length); 226 | result.push(subLine); 227 | textWidth = measureText(ctx, line.slice(0, Math.max(0, safeLineGuess)), fontStyle, hyperMode); 228 | measuredChars = Math.min(line.length, safeLineGuess); 229 | } 230 | if (textWidth > 0) { 231 | result.push(line); 232 | } 233 | } 234 | } 235 | 236 | result = result.map((l, i) => (i === 0 ? l.trimEnd() : l.trim())); 237 | resultCache.set(key, result); 238 | if (resultCache.size > 500) { 239 | // this is not technically LRU behavior but it works "close enough" and is much cheaper 240 | resultCache.delete(resultCache.keys().next().value); 241 | } 242 | return result; 243 | } 244 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | import "jest-canvas-mock"; 3 | -------------------------------------------------------------------------------- /src/stories/benchmark.stories.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable sonarjs/no-duplicate-string */ 2 | import React from "react"; 3 | import canvasTxt from "canvas-txt"; 4 | import canvasMultilineText from "canvas-multiline-text"; 5 | import { split, clearCache } from "../index"; 6 | import Breaker from "linebreak"; 7 | 8 | import "./main.css"; 9 | 10 | // More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export 11 | export default { 12 | title: "Benchmark", 13 | }; 14 | 15 | function makeString(input: number, length: number): string { 16 | const r = `${input}: Now, this is a story all about how My life got flipped-turned upside down And I'd like to take a minute Just sit right there I'll tell you how I became the prince of a town called Bel-Air In West Philadelphia born and raised On the playground was where I spent most of my days Chillin' out, maxin', relaxin', all cool And all shootin' some b-ball outside of the school When a couple of guys who were up to no good Started making trouble in my neighborhood I got in one little fight and my mom got scared She said, "You're movin' with your auntie and uncle in Bel-Air" I begged and pleaded with her day after day But she packed my suitcase and sent me on my way She gave me a kiss and then she gave me my ticket I put my Walkman on and said, "I might as well kick it" First class, yo this is bad Drinking orange juice out of a champagne glass Is this what the people of Bel-Air living like? Hmm, this might be alright But wait, I hear they're prissy, bourgeois, all that Is this the type of place that they just send this cool cat? I don't think so I'll see when I get there I hope they're prepared for the prince of Bel-Air Well, the plane landed and when I came out There was a dude who looked like a cop standing there with my name out I ain't trying to get arrested yet, I just got here I sprang with the quickness like lightning, disappeared I whistled for a cab and when it came near The license plate said, "Fresh" and it had dice in the mirror If anything I could say that this cab was rare But I thought "Nah, forget it, yo, holmes to Bel Air" I pulled up to the house about seven or eight And I yelled to the cabbie, "Yo holmes, smell ya later" I looked at my kingdom I was finally there To sit on my throne as the prince of Bel-Air`; 17 | 18 | return r.slice(0, Math.max(0, length)); 19 | } 20 | 21 | const correctnessString = 22 | "This string should have a natural wrap due to this s-entence being a bit too long. Now we will do some new lines.\n\n- First\n- Second\n\nAndnowwewillseehowwell-thishandleswhenthereareno-spacesandthealgorithm-mustfigureoutwhattodo.\n\n政鮮山案表三搬手神一公輩越船断。歳五京政子球納給亡世人陳邸真面観。合内勢美一舞検科続最治持紙。全巡民学働毎満緊用学勢都味補造枝換先。新任海三格季隣抗権住応化止部属話。中党致窓未止用質会図期現。原者減過玉歴試特講向谷浩野案載文東。寿引害近銅大出怒水病噴績座番摂渡。率当来注情場問引社愛配成少読。力機道経動郎遂際検覧前認方"; 23 | 24 | const iterations = 5000; 25 | 26 | export const Benchmark = () => { 27 | const txtRef = React.useRef(null); 28 | const hyperRef = React.useRef(null); 29 | const multiRef = React.useRef(null); 30 | 31 | const [txtResult, setTxtResult] = React.useState(); 32 | const [hyperResult, setHyperResult] = React.useState(); 33 | const [multiResult, setMultiResult] = React.useState(); 34 | const [allowHyper, setAllowHyper] = React.useState(false); 35 | const [length, setLength] = React.useState(400); 36 | 37 | const onBenchmark = React.useCallback(async () => { 38 | setTxtResult(undefined); 39 | setHyperResult(undefined); 40 | setMultiResult(undefined); 41 | const txtCanvas = txtRef.current; 42 | const hyperCanvas = hyperRef.current; 43 | const multiCanvas = multiRef.current; 44 | 45 | if (txtCanvas === null || hyperCanvas === null || multiCanvas === null) return; 46 | 47 | const txtCtx = txtCanvas.getContext("2d", { 48 | alpha: false, 49 | }); 50 | 51 | const hyperCtx = hyperCanvas.getContext("2d", { 52 | alpha: false, 53 | }); 54 | 55 | const multiCtx = multiCanvas.getContext("2d", { 56 | alpha: false, 57 | }); 58 | 59 | if (txtCtx === null || hyperCtx === null || multiCtx === null) return; 60 | 61 | let total = 0; 62 | canvasTxt.font = "sans-serif"; 63 | canvasTxt.fontSize = 20; 64 | canvasTxt.align = "center"; 65 | canvasTxt.vAlign = "top"; 66 | 67 | txtCtx.fillStyle = "#eee"; 68 | txtCtx.fillRect(0, 0, 500, 500); 69 | hyperCtx.fillStyle = "#eee"; 70 | hyperCtx.fillRect(0, 0, 500, 500); 71 | multiCtx.fillStyle = "#eee"; 72 | multiCtx.fillRect(0, 0, 500, 500); 73 | 74 | for (let i = 0; i < iterations; i++) { 75 | txtCtx.fillStyle = "#eee"; 76 | txtCtx.fillRect(0, 0, 500, 500); 77 | 78 | txtCtx.fillStyle = "black"; 79 | const start = performance.now(); 80 | canvasTxt.drawText(txtCtx, makeString(i, length), 25, 25, 450, 475); 81 | const end = performance.now(); 82 | total += end - start; 83 | 84 | // just to make sure things update 85 | if (i % 100 === 0) { 86 | await new Promise(r => window.requestAnimationFrame(r)); 87 | } 88 | } 89 | setTxtResult(total); 90 | 91 | total = 0; 92 | multiCtx.textAlign = "center"; 93 | for (let i = 0; i < iterations; i++) { 94 | multiCtx.fillStyle = "#eee"; 95 | multiCtx.fillRect(0, 0, 500, 500); 96 | 97 | multiCtx.fillStyle = "black"; 98 | const start = performance.now(); 99 | canvasMultilineText(multiCtx, makeString(i, length), { 100 | font: "sans-serif", 101 | lineHeight: 1, 102 | minFontSize: 20, 103 | maxFontSize: 20, 104 | rect: { 105 | x: 25 + 450 / 2, 106 | y: 25, 107 | width: 450, 108 | height: 475, 109 | }, 110 | }); 111 | const end = performance.now(); 112 | total += end - start; 113 | 114 | // just to make sure things update 115 | if (i % 100 === 0) { 116 | await new Promise(r => window.requestAnimationFrame(r)); 117 | } 118 | } 119 | setMultiResult(total); 120 | 121 | // keep things fair 122 | clearCache(); 123 | total = 0; 124 | 125 | hyperCtx.font = "20px sans-serif"; 126 | hyperCtx.textBaseline = "top"; 127 | hyperCtx.textAlign = "center"; 128 | for (let i = 0; i < iterations; i++) { 129 | hyperCtx.fillStyle = "#eee"; 130 | hyperCtx.fillRect(0, 0, 500, 500); 131 | 132 | hyperCtx.fillStyle = "black"; 133 | const start = performance.now(); 134 | const lines = split(hyperCtx, makeString(i, length), "marker", 450, allowHyper); 135 | 136 | let y = 25; 137 | for (const l of lines) { 138 | hyperCtx.fillText(l, 250, y); 139 | y += 20; 140 | 141 | if (y > 500) break; 142 | } 143 | 144 | const end = performance.now(); 145 | total += end - start; 146 | 147 | // just to make sure things update 148 | if (i % 100 === 0) { 149 | await new Promise(r => window.requestAnimationFrame(r)); 150 | } 151 | } 152 | setHyperResult(total); 153 | }, [allowHyper, length]); 154 | 155 | return ( 156 |
157 |
158 | 159 | Enable HyperWrapping: 160 | setAllowHyper(e.currentTarget.checked)} /> 161 |
162 | Length:{" "} 163 | setLength(e.currentTarget.valueAsNumber)} 170 | /> 171 | {" " + length} 172 |
173 |
174 | Canvas-Txt 175 | 176 |
177 |
178 | canvas-multiline-text 179 | 180 |
181 |
182 | Canvas-HyperTxt 183 | 184 |
185 |
186 | {txtResult !== undefined && ( 187 |
188 | canvas-txt time: {Math.round(txtResult / 10) / 100}s 189 |
190 | )} 191 | {multiResult !== undefined && ( 192 |
193 | canvas-multiline-text time: {Math.round(multiResult / 10) / 100}s 194 |
195 | )} 196 | {hyperResult !== undefined && ( 197 |
198 | canvas-hypertxt time: {Math.round(hyperResult / 10) / 100}s 199 |
200 | )} 201 |
202 | ); 203 | }; 204 | 205 | export const Correctness = () => { 206 | const txtRef = React.useRef(null); 207 | const hyperRef = React.useRef(null); 208 | const multiRef = React.useRef(null); 209 | 210 | const onDraw = React.useCallback(async () => { 211 | const txtCanvas = txtRef.current; 212 | const hyperCanvas = hyperRef.current; 213 | const multiCanvas = multiRef.current; 214 | 215 | if (txtCanvas === null || hyperCanvas === null || multiCanvas === null) return; 216 | 217 | const txtCtx = txtCanvas.getContext("2d", { 218 | alpha: false, 219 | }); 220 | 221 | const hyperCtx = hyperCanvas.getContext("2d", { 222 | alpha: false, 223 | }); 224 | 225 | const multiCtx = multiCanvas.getContext("2d", { 226 | alpha: false, 227 | }); 228 | 229 | if (txtCtx === null || hyperCtx === null || multiCtx === null) return; 230 | 231 | canvasTxt.font = "sans-serif"; 232 | canvasTxt.fontSize = 20; 233 | canvasTxt.align = "center"; 234 | canvasTxt.vAlign = "top"; 235 | 236 | txtCtx.fillStyle = "#eee"; 237 | txtCtx.fillRect(0, 0, 500, 500); 238 | hyperCtx.fillStyle = "#eee"; 239 | hyperCtx.fillRect(0, 0, 500, 500); 240 | multiCtx.fillStyle = "#eee"; 241 | multiCtx.fillRect(0, 0, 500, 500); 242 | 243 | txtCtx.fillStyle = "#eee"; 244 | txtCtx.fillRect(0, 0, 500, 500); 245 | 246 | txtCtx.fillStyle = "black"; 247 | canvasTxt.drawText(txtCtx, correctnessString, 25, 25, 450, 475); 248 | 249 | multiCtx.textAlign = "center"; 250 | multiCtx.fillStyle = "#eee"; 251 | multiCtx.fillRect(0, 0, 500, 500); 252 | 253 | multiCtx.fillStyle = "black"; 254 | canvasMultilineText(multiCtx, correctnessString, { 255 | font: "sans-serif", 256 | lineHeight: 1, 257 | minFontSize: 20, 258 | maxFontSize: 20, 259 | rect: { 260 | x: 25 + 450 / 2, 261 | y: 25, 262 | width: 450, 263 | height: 475, 264 | }, 265 | }); 266 | 267 | hyperCtx.font = "20px sans-serif"; 268 | hyperCtx.textBaseline = "top"; 269 | hyperCtx.textAlign = "center"; 270 | hyperCtx.fillStyle = "#eee"; 271 | hyperCtx.fillRect(0, 0, 500, 500); 272 | 273 | hyperCtx.fillStyle = "black"; 274 | const lines = split(hyperCtx, correctnessString, "marker", 450, false, s => { 275 | const r: number[] = []; 276 | 277 | const b = new Breaker(s); 278 | let br = b.nextBreak(); 279 | while (br !== null) { 280 | r.push(br.position); 281 | br = b.nextBreak(); 282 | } 283 | 284 | return r; 285 | }); 286 | 287 | let y = 25; 288 | for (const l of lines) { 289 | hyperCtx.fillText(l, 250, y); 290 | y += 20; 291 | 292 | if (y > 500) break; 293 | } 294 | }, []); 295 | 296 | React.useEffect(() => void onDraw(), [onDraw]); 297 | 298 | return ( 299 |
300 |
301 |
302 | Canvas-Txt 303 | 304 |
305 |
306 | canvas-multiline-text 307 | 308 |
309 |
310 | Canvas-HyperTxt 311 | 312 |
313 |
314 |
315 | ); 316 | }; 317 | -------------------------------------------------------------------------------- /src/stories/linebreak.d.ts: -------------------------------------------------------------------------------- 1 | declare module "linebreak" { 2 | declare class Break { 3 | constructor(public position: number, public required = false); 4 | } 5 | declare class Breaker { 6 | constructor(val: string); 7 | nextBreak(): Break | null; 8 | } 9 | export default Breaker; 10 | } 11 | -------------------------------------------------------------------------------- /src/stories/main.css: -------------------------------------------------------------------------------- 1 | .benchmark-container { 2 | display: grid; 3 | grid-template-columns: 500px 500px 500px; 4 | column-gap: 4px; 5 | } 6 | 7 | .stack > * { 8 | margin-bottom: 12px; 9 | } 10 | 11 | .bar span { 12 | margin-left: 24px; 13 | } 14 | 15 | body { 16 | font-family: Arial, Helvetica, sans-serif; 17 | } -------------------------------------------------------------------------------- /test/multi-line.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import { clearMultilineCache, splitMultilineText } from "../src/multi-line"; 4 | import Breaker from "linebreak"; 5 | 6 | // 1 char === 1 px according to the testing library. This is not realistic but nice for testing. 7 | 8 | const longStr = 9 | "This is a quite long string that will need to wrap at least a couple times in order to fit on the screen. Who knows how many times?"; 10 | const newlineStr = 11 | "This is a quite long string \nthat will need to wrap at least a \ncouple times in order to \nfit on the screen. Who knows how many times?"; 12 | 13 | describe("multi-line-layout", () => { 14 | beforeEach(() => { 15 | clearMultilineCache(); 16 | CanvasRenderingContext2D.prototype.measureText = jest.fn((str: string) => { 17 | let width = 0; 18 | for (const char of str) { 19 | // eslint-disable-next-line unicorn/prefer-code-point 20 | width += char.charCodeAt(0) / 12; 21 | } 22 | return { 23 | width, 24 | actualBoundingBoxAscent: 1, 25 | actualBoundingBoxDescent: 1, 26 | actualBoundingBoxLeft: 1, 27 | actualBoundingBoxRight: width, 28 | fontBoundingBoxAscent: 1, 29 | fontBoundingBoxDescent: 1, 30 | }; 31 | }); 32 | }); 33 | 34 | test("short-sentence", () => { 35 | render(); 36 | 37 | const canvas = screen.getByTestId("canvas") as HTMLCanvasElement; 38 | const ctx = canvas.getContext("2d", { 39 | alpha: false, 40 | }); 41 | 42 | expect(ctx).not.toBeNull(); 43 | 44 | if (ctx === null) { 45 | throw new Error("Error"); 46 | } 47 | 48 | const spanned = splitMultilineText(ctx, "Test this short string", "12px bold", 400, false); 49 | expect(spanned[0]).toEqual("Test this short string"); 50 | }); 51 | 52 | test("long-sentence", () => { 53 | render(); 54 | 55 | const canvas = screen.getByTestId("canvas") as HTMLCanvasElement; 56 | const ctx = canvas.getContext("2d", { 57 | alpha: false, 58 | }); 59 | 60 | expect(ctx).not.toBeNull(); 61 | 62 | if (ctx === null) { 63 | throw new Error("Error"); 64 | } 65 | 66 | const spanned = splitMultilineText(ctx, longStr, "12px bold", 400, false); 67 | expect(spanned).toEqual([ 68 | "This is a quite long string that will need to wrap", 69 | "at least a couple times in order to fit on the", 70 | "screen. Who knows how many times?", 71 | ]); 72 | }); 73 | 74 | test("dash-sentence", () => { 75 | render(); 76 | 77 | const canvas = screen.getByTestId("canvas") as HTMLCanvasElement; 78 | const ctx = canvas.getContext("2d", { 79 | alpha: false, 80 | }); 81 | 82 | expect(ctx).not.toBeNull(); 83 | 84 | if (ctx === null) { 85 | throw new Error("Error"); 86 | } 87 | 88 | const spanned = splitMultilineText(ctx, longStr.split(" ").join("-"), "12px bold", 400, false, s => { 89 | const r: number[] = []; 90 | 91 | const b = new Breaker(s); 92 | let br = b.nextBreak(); 93 | while (br !== null) { 94 | r.push(br.position); 95 | br = b.nextBreak(); 96 | } 97 | 98 | return r; 99 | }); 100 | expect(spanned).toEqual([ 101 | "This-is-a-quite-long-string-that-will-need-to-", 102 | "wrap-at-least-a-couple-times-in-order-to-fit-on-", 103 | "the-screen.-Who-knows-how-many-times?", 104 | ]); 105 | }); 106 | 107 | test("zero width", () => { 108 | render(); 109 | ``; 110 | 111 | const canvas = screen.getByTestId("canvas") as HTMLCanvasElement; 112 | const ctx = canvas.getContext("2d", { 113 | alpha: false, 114 | }); 115 | 116 | expect(ctx).not.toBeNull(); 117 | 118 | if (ctx === null) { 119 | throw new Error("Error"); 120 | } 121 | 122 | const spanned = splitMultilineText(ctx, newlineStr, "12px bold", 0, false); 123 | expect(spanned).toEqual([]); 124 | }); 125 | 126 | test("newlines", () => { 127 | render(); 128 | ``; 129 | 130 | const canvas = screen.getByTestId("canvas") as HTMLCanvasElement; 131 | const ctx = canvas.getContext("2d", { 132 | alpha: false, 133 | }); 134 | 135 | expect(ctx).not.toBeNull(); 136 | 137 | if (ctx === null) { 138 | throw new Error("Error"); 139 | } 140 | 141 | const spanned = splitMultilineText(ctx, newlineStr, "12px bold", 400, false); 142 | expect(spanned).toEqual([ 143 | "This is a quite long string", 144 | "that will need to wrap at least a", 145 | "couple times in order to", 146 | "fit on the screen. Who knows how many times?", 147 | ]); 148 | }); 149 | 150 | test("hyperwrap", () => { 151 | render(); 152 | 153 | const canvas = screen.getByTestId("canvas") as HTMLCanvasElement; 154 | const ctx = canvas.getContext("2d", { 155 | alpha: false, 156 | }); 157 | 158 | expect(ctx).not.toBeNull(); 159 | 160 | if (ctx === null) { 161 | throw new Error("Error"); 162 | } 163 | 164 | for (let i = 0; i < 1_000_000; i++) { 165 | splitMultilineText(ctx, longStr + i, "12px bold", 400, true); 166 | } 167 | 168 | const spanned = splitMultilineText(ctx, longStr, "12px bold", 400, true); 169 | expect(spanned).toEqual([ 170 | "This is a quite long string that will need to wrap", 171 | "at least a couple times in order to fit on the", 172 | "screen. Who knows how many times?", 173 | ]); 174 | }); 175 | }); 176 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "jsx": "react", 6 | "moduleResolution": "node", 7 | "module": "esnext", 8 | "noImplicitAny": true, 9 | "noUnusedLocals": true, 10 | "skipLibCheck": true, 11 | "noUnusedParameters": true, 12 | "strict": true, 13 | "target": "es6", 14 | "typeRoots": ["./node_modules/@types", "../node_modules/@types"] 15 | }, 16 | "include": ["./src/**/*.ts", "./src/**/*.tsx", "./test/**/*.tsx"] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": false, 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "isolatedModules": false, 8 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 9 | "noEmit": false, 10 | "outDir": "dist/ts", 11 | "sourceMap": false, 12 | "types": ["react", "jest"] 13 | }, 14 | "exclude": [ 15 | "./src/**/*.stories.tsx", 16 | "./src/stories/*.tsx", 17 | "./src/docs/*.tsx", 18 | "./src/setupTests.ts", 19 | "./src/**/*.test.ts", 20 | "./src/**/*.test.tsx" 21 | ] 22 | } 23 | --------------------------------------------------------------------------------