├── .gitignore ├── demo ├── inter-v18-latin-regular.woff2 ├── canvas-txt.mjs ├── canvas-hypertxt.mjs ├── index.html └── txtgen.mjs ├── tsconfig.json ├── dist ├── uWrap.d.ts ├── uWrap.iife.min.js ├── uWrap.mjs └── uWrap.iife.js ├── LICENSE ├── package.json ├── rollup.config.mjs ├── README.md ├── src └── uWrap.ts └── test └── uWrap.test.mjs /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /demo/inter-v18-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leeoniya/uWrap/HEAD/demo/inter-v18-latin-regular.woff2 -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "esModuleInterop": true, 5 | "moduleResolution": "node", 6 | "module": "ESNext", 7 | "noImplicitAny": true, 8 | "noUnusedLocals": true, 9 | "skipLibCheck": true, 10 | "noUnusedParameters": true, 11 | "strict": true, 12 | "target": "ES2022", 13 | }, 14 | "include": [ 15 | "./src/**/*.ts" 16 | ] 17 | } -------------------------------------------------------------------------------- /dist/uWrap.d.ts: -------------------------------------------------------------------------------- 1 | /** may return false to exit loop early */ 2 | export type LineCallback = (idx0: number, idx1: number, width: number) => void | boolean; 3 | 4 | /** invoke callback for each line with start/end idxs */ 5 | export type Each = (text: string, width: number, cb: LineCallback) => void; 6 | 7 | /** split into lines array */ 8 | export type Split = (text: string, width: number, limit?: number) => string[]; 9 | 10 | /** count lines */ 11 | export type Count = (text: string, width: number) => number; 12 | 13 | /** test whether text will wrap (line count > 1) */ 14 | export type Test = (text: string, width: number) => boolean; 15 | 16 | export interface uWrap { 17 | each: Each; 18 | split: Split; 19 | count: Count; 20 | test: Test; 21 | } 22 | 23 | /** wrap text with a variable width font using pre-line whitespace strategy */ 24 | export function varPreLine(ctx: CanvasRenderingContext2D): uWrap; 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Leon Sorokin 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uwrap", 3 | "version": "0.1.2", 4 | "description": "A very fast and accurate text and line wrapping util", 5 | "homepage": "https://github.com/leeoniya/uWrap#readme", 6 | "bugs": { 7 | "url": "https://github.com/leeoniya/uWrap/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/leeoniya/uWrap.git" 12 | }, 13 | "license": "MIT", 14 | "author": "", 15 | "type": "module", 16 | "module": "./dist/uWrap.mjs", 17 | "types": "./dist/uWrap.d.ts", 18 | "files": [ 19 | "package.json", 20 | "README.md", 21 | "LICENSE", 22 | "dist" 23 | ], 24 | "keywords": [ 25 | "text", 26 | "line", 27 | "word", 28 | "wrap", 29 | "wrapping", 30 | "split", 31 | "virtualization", 32 | "virtualize", 33 | "canvas", 34 | "measure" 35 | ], 36 | "scripts": { 37 | "test": "node ./test/uWrap.test.mjs", 38 | "build": "rollup -c" 39 | }, 40 | "devDependencies": { 41 | "@rollup/plugin-terser": "^0.4.4", 42 | "@rollup/plugin-typescript": "^12.1.4", 43 | "rollup": "^4.46.2", 44 | "skia-canvas": "^2.0.2", 45 | "tslib": "^2.8.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /dist/uWrap.iife.min.js: -------------------------------------------------------------------------------- 1 | /*! https://github.com/leeoniya/uWrap (v0.1.2) */ 2 | var uWrap=function(t){"use strict";const e="ABCDEFGHIJKLMNOPQRSTUVWXYZ",r=e+"abcdefghijklmnopqrstuvwxyz1234567890`~!@#$%^&*()_+-=[]\\{}|;':\",./<>? \t";return t.varPreLine=function(t){const n=function(t){const e=t.measureText("W").width,r=t.letterSpacing;t.letterSpacing="101px";const n=t.measureText("W").width;return t.letterSpacing=r,n>e}(t)?0:parseFloat(t.letterSpacing),l={};for(let e=0;r.length>e;e++)l[r.charCodeAt(e)]=t.measureText(r[e]).width+n;const o=parseFloat(t.wordSpacing);o>0&&(l[32]=o);const c={};for(let o=0;26>o;o++){let i=e.charCodeAt(o);c[i]={};for(let s=0;r.length>s;s++){let a=r.charCodeAt(s),u=t.measureText(`${e[o]}${r[s]}`).width-l[a]+n;c[i][a]=u}}const i=()=>{};function s(e,r,n=i){let o=0;for(;32===e.charCodeAt(o);)o++;let s=e.length-1;for(;32===e.charCodeAt(s);)s--;let a=o,u=0,h=0,f=-1,d=0,p=!1;for(let i=o;s>=i;i++){let o=e.charCodeAt(i),s=0;if(o in c){let t=e.charCodeAt(i+1);t in c[o]&&(s=c[o][t])}if(0===s&&(s=l[o]??(l[o]=t.measureText(e[i]).width)),32===o)e.charCodeAt(i+1)!==o&&(f=i+1,d=0),!p&&h>0&&(h+=s,u=i),p=!0;else if(10===o){if(!1===n(a,i,h))return;a=u=i+1,h=d=0,d=0,f=-1}else{if(u>a&&h+s>r){if(!1===n(a,u,h))return;h=d+s,a=u=f,d=0,f=-1}else 45===o&&e.charCodeAt(i+1)!==o&&(f=u=i+1,d=0),h+=s,d+=s;p=!1}}n(a,s+1,h)}let a=/\s|-/;return{each:s,split:(t,e,r=1/0)=>{let n=[];return a.test(t)?s(t,e,((e,l)=>{if(n.push(t.slice(e,l)),n.length===r)return!1})):n.push(t),n},count:(t,e)=>{let r=0;return a.test(t)?s(t,e,(()=>{r++})):r=1,r},test:(t,e)=>{let r=0;return a.test(t)?s(t,e,(()=>{if(2==++r)return!1})):r=1,2===r}}},t}({}); 3 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import terser from '@rollup/plugin-terser'; 3 | import fs from 'fs'; 4 | 5 | const pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); 6 | const ver = "v" + pkg.version; 7 | const urlVer = "https://github.com/leeoniya/uWrap (" + ver + ")"; 8 | const banner = [ 9 | "/**", 10 | "* Copyright (c) " + new Date().getFullYear() + ", Leon Sorokin", 11 | "* All rights reserved. (MIT Licensed)", 12 | "*", 13 | "* uWrap.js", 14 | "* A small, fast line wrapping thing for Canvas2D", 15 | "* " + urlVer, 16 | "*/", 17 | "", 18 | ].join("\n"); 19 | 20 | function bannerlessESM() { 21 | return { 22 | name: 'stripBanner', 23 | resolveId(importee) { 24 | if (importee == 'uWrap') 25 | return importee; 26 | return null; 27 | }, 28 | load(id) { 29 | if (id == 'uWrap') 30 | return fs.readFileSync('./dist/uWrap.mjs', 'utf8').replace(/\/\*\*.*?\*\//gms, ''); 31 | return null; 32 | } 33 | }; 34 | } 35 | 36 | const terserOpts = { 37 | compress: { 38 | inline: 0, 39 | passes: 2, 40 | keep_fargs: false, 41 | pure_getters: true, 42 | unsafe: true, 43 | unsafe_comps: true, 44 | unsafe_math: true, 45 | unsafe_undefined: true, 46 | }, 47 | output: { 48 | comments: /^!/ 49 | } 50 | }; 51 | 52 | export default [ 53 | { 54 | input: 'src/uWrap.ts', 55 | output: { 56 | file: 'dist/uWrap.mjs', 57 | format: 'es', 58 | banner, 59 | }, 60 | plugins: [ 61 | typescript(), 62 | ], 63 | }, 64 | { 65 | input: 'dist/uWrap.mjs', 66 | output: { 67 | name: 'uWrap', 68 | file: 'dist/uWrap.iife.js', 69 | format: 'iife', 70 | }, 71 | plugins: [ 72 | bannerlessESM(), 73 | ], 74 | }, 75 | { 76 | input: 'dist/uWrap.mjs', 77 | output: { 78 | name: 'uWrap', 79 | file: 'dist/uWrap.iife.min.js', 80 | format: 'iife', 81 | banner: "/*! " + urlVer + " */", 82 | }, 83 | plugins: [ 84 | bannerlessESM(), 85 | terser(terserOpts), 86 | ], 87 | } 88 | ]; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ⏎ μWrap 2 | 3 | A [10x faster](#performance) and more accurate text wrapping util in [< 2KB (min)](https://github.com/leeoniya/uWrap/blob/main/dist/uWrap.iife.min.js) _(MIT Licensed)_ 4 | 5 | --- 6 | ### Introduction 7 | 8 | uWrap exists to efficiently predict varying row heights for list and grid [virtualization](https://www.patterns.dev/vanilla/virtual-lists/), a technique for UI performance optimization when rendering large, scrollable datasets. 9 | Doing this both quickly and accurately turns out to be a non-trivial task since Canvas2D provides no API for text wrapping, and `measureText()` is quite expensive; 10 | measuring via DOM is also a non-starter due to poor performance. 11 | Additionally, font size, variable-width [kerning](https://www.canva.com/learn/kerning/), `letter-spacing`, explicit line breaks, and different `white-space` choices affect the wrapping locations. 12 | 13 | Notes: 14 | 15 | - Currently works most accurately with Latin charsets 16 | - Does not yet handle Windows-style `\r\n` explicit line breaks 17 | - Only `pre-line` wrapping strategy is implemented so far 18 | 19 | --- 20 | ### Performance 21 | 22 | uWrap outperforms [canvas-hypertxt](https://github.com/glideapps/canvas-hypertxt) by a wide margin in both CPU and memory usage while being significantly more accurate. 23 | 24 | The benchmark below wraps 100,000 random sentences into boxes of random widths between 50px and 250px. 25 | You can see this live in DevTools console of the [demo page](https://leeoniya.github.io/uWrap/demo/). 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
Chrome 135Firefox 137Safari 18.1
uWrap82ms90ms185ms
canvas-hypertxt770ms1660ms1430ms
47 | 48 | --- 49 | ### Installation 50 | 51 | ``` 52 | npm i uwrap 53 | ``` 54 | 55 | or 56 | 57 | ```html 58 | 59 | ``` 60 | 61 | --- 62 | ### API 63 | 64 | See [uWrap.d.ts](https://github.com/leeoniya/uWrap/blob/main/dist/uWrap.d.ts) TypeScript def. 65 | 66 | --- 67 | ### Usage 68 | 69 | ```js 70 | // import fn for wrapping variable-width fonts using pre-line strategy 71 | import { varPreLine } from 'uwrap'; 72 | 73 | // create a Canvas2D context with desired font settings 74 | let ctx = document.createElement('canvas').getContext('2d'); 75 | ctx.font = "14px Inter, sans-serif"; 76 | ctx.letterSpacing = '0.15px'; 77 | 78 | // init util fns 79 | const { count, test, split } = varPreLine(ctx); 80 | 81 | // example text 82 | let text = 'The quick brown fox jumps over the lazy dog.'; 83 | 84 | // count lines 85 | let numLines = count(text, width); 86 | 87 | // test if text will wrap 88 | let willWrap = test(text, width); 89 | 90 | // split lines (with optional limit) 91 | let lines = split(text, width, 3); 92 | ``` -------------------------------------------------------------------------------- /demo/canvas-txt.mjs: -------------------------------------------------------------------------------- 1 | function B({ 2 | ctx: e, 3 | line: c, 4 | spaceWidth: p, 5 | spaceChar: n, 6 | width: a 7 | }) { 8 | const i = c.trim(), o = i.split(/\s+/), s = o.length - 1; 9 | if (s === 0) 10 | return i; 11 | const m = e.measureText(o.join("")).width, d = (a - m) / p, b = Math.floor(d / s); 12 | if (d < 1) 13 | return i; 14 | const r = n.repeat(b); 15 | return o.join(r); 16 | } 17 | const W = " "; 18 | function k({ 19 | ctx: e, 20 | text: c, 21 | justify: p, 22 | width: n 23 | }) { 24 | const a = /* @__PURE__ */ new Map(), i = (r) => { 25 | let g = a.get(r); 26 | return g !== void 0 || (g = e.measureText(r).width, a.set(r, g)), g; 27 | }; 28 | let o = [], s = c.split(` 29 | `); 30 | const m = p ? i(W) : 0; 31 | let d = 0, b = 0; 32 | for (const r of s) { 33 | let g = i(r); 34 | const y = r.length; 35 | if (g <= n) { 36 | o.push(r); 37 | continue; 38 | } 39 | let h = r, t, f, l = ""; 40 | for (; g > n; ) { 41 | if (d++, t = b, f = t === 0 ? 0 : i(r.substring(0, t)), f < n) 42 | for (; f < n && t < y && (t++, f = i(h.substring(0, t)), t !== y); ) 43 | ; 44 | else if (f > n) 45 | for (; f > n && (t = Math.max(1, t - 1), f = i(h.substring(0, t)), !(t === 0 || t === 1)); ) 46 | ; 47 | if (b = Math.round( 48 | b + (t - b) / d 49 | ), t--, t > 0) { 50 | let u = t; 51 | if (h.substring(u, u + 1) != " ") { 52 | for (; h.substring(u, u + 1) != " " && u >= 0; ) 53 | u--; 54 | u > 0 && (t = u); 55 | } 56 | } 57 | t === 0 && (t = 1), l = h.substring(0, t), l = p ? B({ 58 | ctx: e, 59 | line: l, 60 | spaceWidth: m, 61 | spaceChar: W, 62 | width: n 63 | }) : l, o.push(l), h = h.substring(t), g = i(h); 64 | } 65 | g > 0 && (l = p ? B({ 66 | ctx: e, 67 | line: h, 68 | spaceWidth: m, 69 | spaceChar: W, 70 | width: n 71 | }) : h, o.push(l)); 72 | } 73 | return o; 74 | } 75 | function H({ 76 | ctx: e, 77 | text: c, 78 | style: p 79 | }) { 80 | const n = e.textBaseline, a = e.font; 81 | e.textBaseline = "bottom", e.font = p; 82 | const { actualBoundingBoxAscent: i } = e.measureText(c); 83 | return e.textBaseline = n, e.font = a, i; 84 | } 85 | const C = { 86 | debug: !1, 87 | align: "center", 88 | vAlign: "middle", 89 | fontSize: 14, 90 | fontWeight: "", 91 | fontStyle: "", 92 | fontVariant: "", 93 | font: "Arial", 94 | lineHeight: null, 95 | justify: !1 96 | }; 97 | function E(e, c, p) { 98 | const { width: n, height: a, x: i, y: o } = p, s = { ...C, ...p }; 99 | if (n <= 0 || a <= 0 || s.fontSize <= 0) 100 | return { height: 0 }; 101 | const m = i + n, d = o + a, { fontStyle: b, fontVariant: r, fontWeight: g, fontSize: y, font: h } = s, t = `${b} ${r} ${g} ${y}px ${h}`; 102 | e.font = t; 103 | let f = o + a / 2 + s.fontSize / 2, l; 104 | s.align === "right" ? (l = m, e.textAlign = "right") : s.align === "left" ? (l = i, e.textAlign = "left") : (l = i + n / 2, e.textAlign = "center"); 105 | const u = k({ 106 | ctx: e, 107 | text: c, 108 | justify: s.justify, 109 | width: n 110 | }), S = s.lineHeight ? s.lineHeight : H({ ctx: e, text: "M", style: t }), v = S * (u.length - 1), P = v / 2; 111 | let A = o; 112 | if (s.vAlign === "top" ? (e.textBaseline = "top", f = o) : s.vAlign === "bottom" ? (e.textBaseline = "bottom", f = d - v, A = d) : (e.textBaseline = "bottom", A = o + a / 2, f -= P), u.forEach((T) => { 113 | T = T.trim(), e.fillText(T, l, f), f += S; 114 | }), s.debug) { 115 | const T = "#0C8CE9"; 116 | e.lineWidth = 1, e.strokeStyle = T, e.strokeRect(i, o, n, a), e.lineWidth = 1, e.strokeStyle = T, e.beginPath(), e.moveTo(l, o), e.lineTo(l, d), e.stroke(), e.strokeStyle = T, e.beginPath(), e.moveTo(i, A), e.lineTo(m, A), e.stroke(); 117 | } 118 | return { height: v + S }; 119 | } 120 | export { 121 | E as drawText, 122 | H as getTextHeight, 123 | k as splitText 124 | }; 125 | -------------------------------------------------------------------------------- /src/uWrap.ts: -------------------------------------------------------------------------------- 1 | // BREAKS 2 | const D = "-".charCodeAt(0); 3 | const S = " ".charCodeAt(0); 4 | const N = "\n".charCodeAt(0); 5 | // const R = "\r".charCodeAt(0); (TODO: support \r\n breaks) 6 | // const T = "\t".charCodeAt(0); 7 | 8 | const SYMBS = `\`~!@#$%^&*()_+-=[]\\{}|;':",./<>? \t`; 9 | const NUMS = "1234567890"; 10 | const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 11 | const LOWER = "abcdefghijklmnopqrstuvwxyz"; 12 | const CHARS = `${UPPER}${LOWER}${NUMS}${SYMBS}`; 13 | 14 | function supportsLetterSpacing(ctx: CanvasRenderingContext2D) { 15 | const _w = ctx.measureText('W').width; 16 | const _letterSpacing = ctx.letterSpacing; 17 | ctx.letterSpacing = '101px'; 18 | const w = ctx.measureText('W').width; 19 | ctx.letterSpacing = _letterSpacing; 20 | return w > _w; 21 | } 22 | 23 | export function varPreLine(ctx: CanvasRenderingContext2D) { 24 | // Safari pre-18.4 does not support Canvas letterSpacing, and measureText() does not account for it 25 | // so we have to add it manually. https://caniuse.com/mdn-api_canvasrenderingcontext2d_letterspacing 26 | const fauxLetterSpacing = !supportsLetterSpacing(ctx) ? parseFloat(ctx.letterSpacing) : 0; 27 | 28 | // single-char widths in isolation 29 | const WIDTHS: Record = {}; 30 | 31 | for (let i = 0; i < CHARS.length; i++) 32 | WIDTHS[CHARS.charCodeAt(i)] = ctx.measureText(CHARS[i]).width + fauxLetterSpacing; 33 | 34 | const wordSpacing = parseFloat(ctx.wordSpacing); 35 | 36 | if (wordSpacing > 0) 37 | WIDTHS[S] = wordSpacing; 38 | 39 | // build kerning/spacing LUT of upper+lower, upper+sym, upper+upper pairs. (this includes letterSpacing) 40 | // holds kerning-adjusted width of the uppers 41 | const PAIRS: Record> = {}; 42 | 43 | for (let i = 0; i < UPPER.length; i++) { 44 | let uc = UPPER.charCodeAt(i); 45 | PAIRS[uc] = {}; 46 | 47 | for (let j = 0; j < CHARS.length; j++) { 48 | let ch = CHARS.charCodeAt(j); 49 | let wid = ctx.measureText(`${UPPER[i]}${CHARS[j]}`).width - WIDTHS[ch] + fauxLetterSpacing; 50 | PAIRS[uc][ch] = wid; 51 | } 52 | } 53 | 54 | type LineCallback = (idx0: number, idx1: number, width: number) => void | boolean; 55 | 56 | const eachLine: LineCallback = () => {}; 57 | 58 | function each(text: string, width: number, cb = eachLine) { 59 | let fr = 0; 60 | while (text.charCodeAt(fr) === S) 61 | fr++; 62 | 63 | let to = text.length - 1; 64 | while (text.charCodeAt(to) === S) 65 | to--; 66 | 67 | let headIdx = fr; 68 | let headEnd = 0; 69 | let headWid = 0; 70 | 71 | let tailIdx = -1; // wrap candidate 72 | let tailWid = 0; 73 | 74 | let inWS = false; 75 | 76 | for (let i = fr; i <= to; i++) { 77 | let c = text.charCodeAt(i); 78 | 79 | let w = 0; 80 | 81 | if (c in PAIRS) { 82 | let n = text.charCodeAt(i + 1); 83 | 84 | if (n in PAIRS[c]) 85 | w = PAIRS[c][n]; 86 | } 87 | 88 | if (w === 0) 89 | w = WIDTHS[c] ?? (WIDTHS[c] = ctx.measureText(text[i]).width); 90 | 91 | if (c === S) { // || c === T || c === N || c === R 92 | // set possible wrap point 93 | if (text.charCodeAt(i + 1) !== c) { 94 | tailIdx = i + 1; 95 | tailWid = 0; 96 | } 97 | 98 | if (!inWS && headWid > 0) { 99 | headWid += w; 100 | headEnd = i; 101 | } 102 | 103 | inWS = true; 104 | } 105 | else if (c === N) { 106 | if (cb(headIdx, i, headWid) === false) 107 | return; 108 | 109 | headIdx = headEnd = i + 1; 110 | headWid = tailWid = 0; 111 | tailWid = 0; 112 | tailIdx = -1; 113 | } 114 | else { 115 | if (headEnd > headIdx && headWid + w > width) { 116 | if (cb(headIdx, headEnd, headWid) === false) 117 | return; 118 | 119 | headWid = tailWid + w; 120 | headIdx = headEnd = tailIdx; 121 | tailWid = 0; 122 | tailIdx = -1; 123 | } else { 124 | if (c === D) { 125 | // set possible wrap point 126 | if (text.charCodeAt(i + 1) !== c) { 127 | tailIdx = headEnd = i + 1; 128 | tailWid = 0; 129 | } 130 | } 131 | 132 | headWid += w; 133 | tailWid += w; 134 | } 135 | 136 | inWS = false; 137 | } 138 | } 139 | 140 | cb(headIdx, to + 1, headWid); 141 | } 142 | 143 | let mayWrap = /\s|-/; 144 | 145 | return { 146 | each, 147 | split: (text: string, width: number, limit = Infinity) => { 148 | let out: string[] = []; 149 | 150 | if (mayWrap.test(text)) { 151 | each(text, width, (idx0: number, idx1: number) => { 152 | out.push(text.slice(idx0, idx1)); 153 | 154 | if (out.length === limit) 155 | return false; 156 | }); 157 | } else { 158 | out.push(text); 159 | } 160 | 161 | return out; 162 | }, 163 | count: (text: string, width: number) => { 164 | let count = 0; 165 | 166 | if (mayWrap.test(text)) { 167 | each(text, width, () => { count++; }); 168 | } else { 169 | count = 1; 170 | } 171 | 172 | return count; 173 | }, 174 | test: (text: string, width: number) => { 175 | let count = 0; 176 | 177 | if (mayWrap.test(text)) { 178 | each(text, width, () => { 179 | if (++count === 2) 180 | return false; 181 | }); 182 | } else { 183 | count = 1; 184 | } 185 | 186 | return count === 2; 187 | }, 188 | }; 189 | } 190 | 191 | /* 192 | function isMonospace(ctx: CanvasRenderingContext2D) { 193 | let w = ctx.measureText('.').width; 194 | return ctx.measureText('i').width === w && ctx.measureText('.').width === w; 195 | } 196 | */ 197 | -------------------------------------------------------------------------------- /dist/uWrap.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2025, Leon Sorokin 3 | * All rights reserved. (MIT Licensed) 4 | * 5 | * uWrap.js 6 | * A small, fast line wrapping thing for Canvas2D 7 | * https://github.com/leeoniya/uWrap (v0.1.2) 8 | */ 9 | 10 | // BREAKS 11 | const D = "-".charCodeAt(0); 12 | const S = " ".charCodeAt(0); 13 | const N = "\n".charCodeAt(0); 14 | // const R = "\r".charCodeAt(0); (TODO: support \r\n breaks) 15 | // const T = "\t".charCodeAt(0); 16 | const SYMBS = `\`~!@#$%^&*()_+-=[]\\{}|;':",./<>? \t`; 17 | const NUMS = "1234567890"; 18 | const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 19 | const LOWER = "abcdefghijklmnopqrstuvwxyz"; 20 | const CHARS = `${UPPER}${LOWER}${NUMS}${SYMBS}`; 21 | function supportsLetterSpacing(ctx) { 22 | const _w = ctx.measureText('W').width; 23 | const _letterSpacing = ctx.letterSpacing; 24 | ctx.letterSpacing = '101px'; 25 | const w = ctx.measureText('W').width; 26 | ctx.letterSpacing = _letterSpacing; 27 | return w > _w; 28 | } 29 | function varPreLine(ctx) { 30 | // Safari pre-18.4 does not support Canvas letterSpacing, and measureText() does not account for it 31 | // so we have to add it manually. https://caniuse.com/mdn-api_canvasrenderingcontext2d_letterspacing 32 | const fauxLetterSpacing = !supportsLetterSpacing(ctx) ? parseFloat(ctx.letterSpacing) : 0; 33 | // single-char widths in isolation 34 | const WIDTHS = {}; 35 | for (let i = 0; i < CHARS.length; i++) 36 | WIDTHS[CHARS.charCodeAt(i)] = ctx.measureText(CHARS[i]).width + fauxLetterSpacing; 37 | const wordSpacing = parseFloat(ctx.wordSpacing); 38 | if (wordSpacing > 0) 39 | WIDTHS[S] = wordSpacing; 40 | // build kerning/spacing LUT of upper+lower, upper+sym, upper+upper pairs. (this includes letterSpacing) 41 | // holds kerning-adjusted width of the uppers 42 | const PAIRS = {}; 43 | for (let i = 0; i < UPPER.length; i++) { 44 | let uc = UPPER.charCodeAt(i); 45 | PAIRS[uc] = {}; 46 | for (let j = 0; j < CHARS.length; j++) { 47 | let ch = CHARS.charCodeAt(j); 48 | let wid = ctx.measureText(`${UPPER[i]}${CHARS[j]}`).width - WIDTHS[ch] + fauxLetterSpacing; 49 | PAIRS[uc][ch] = wid; 50 | } 51 | } 52 | const eachLine = () => { }; 53 | function each(text, width, cb = eachLine) { 54 | let fr = 0; 55 | while (text.charCodeAt(fr) === S) 56 | fr++; 57 | let to = text.length - 1; 58 | while (text.charCodeAt(to) === S) 59 | to--; 60 | let headIdx = fr; 61 | let headEnd = 0; 62 | let headWid = 0; 63 | let tailIdx = -1; // wrap candidate 64 | let tailWid = 0; 65 | let inWS = false; 66 | for (let i = fr; i <= to; i++) { 67 | let c = text.charCodeAt(i); 68 | let w = 0; 69 | if (c in PAIRS) { 70 | let n = text.charCodeAt(i + 1); 71 | if (n in PAIRS[c]) 72 | w = PAIRS[c][n]; 73 | } 74 | if (w === 0) 75 | w = WIDTHS[c] ?? (WIDTHS[c] = ctx.measureText(text[i]).width); 76 | if (c === S) { // || c === T || c === N || c === R 77 | // set possible wrap point 78 | if (text.charCodeAt(i + 1) !== c) { 79 | tailIdx = i + 1; 80 | tailWid = 0; 81 | } 82 | if (!inWS && headWid > 0) { 83 | headWid += w; 84 | headEnd = i; 85 | } 86 | inWS = true; 87 | } 88 | else if (c === N) { 89 | if (cb(headIdx, i, headWid) === false) 90 | return; 91 | headIdx = headEnd = i + 1; 92 | headWid = tailWid = 0; 93 | tailWid = 0; 94 | tailIdx = -1; 95 | } 96 | else { 97 | if (headEnd > headIdx && headWid + w > width) { 98 | if (cb(headIdx, headEnd, headWid) === false) 99 | return; 100 | headWid = tailWid + w; 101 | headIdx = headEnd = tailIdx; 102 | tailWid = 0; 103 | tailIdx = -1; 104 | } 105 | else { 106 | if (c === D) { 107 | // set possible wrap point 108 | if (text.charCodeAt(i + 1) !== c) { 109 | tailIdx = headEnd = i + 1; 110 | tailWid = 0; 111 | } 112 | } 113 | headWid += w; 114 | tailWid += w; 115 | } 116 | inWS = false; 117 | } 118 | } 119 | cb(headIdx, to + 1, headWid); 120 | } 121 | let mayWrap = /\s|-/; 122 | return { 123 | each, 124 | split: (text, width, limit = Infinity) => { 125 | let out = []; 126 | if (mayWrap.test(text)) { 127 | each(text, width, (idx0, idx1) => { 128 | out.push(text.slice(idx0, idx1)); 129 | if (out.length === limit) 130 | return false; 131 | }); 132 | } 133 | else { 134 | out.push(text); 135 | } 136 | return out; 137 | }, 138 | count: (text, width) => { 139 | let count = 0; 140 | if (mayWrap.test(text)) { 141 | each(text, width, () => { count++; }); 142 | } 143 | else { 144 | count = 1; 145 | } 146 | return count; 147 | }, 148 | test: (text, width) => { 149 | let count = 0; 150 | if (mayWrap.test(text)) { 151 | each(text, width, () => { 152 | if (++count === 2) 153 | return false; 154 | }); 155 | } 156 | else { 157 | count = 1; 158 | } 159 | return count === 2; 160 | }, 161 | }; 162 | } 163 | /* 164 | function isMonospace(ctx: CanvasRenderingContext2D) { 165 | let w = ctx.measureText('.').width; 166 | return ctx.measureText('i').width === w && ctx.measureText('.').width === w; 167 | } 168 | */ 169 | 170 | export { varPreLine }; 171 | -------------------------------------------------------------------------------- /dist/uWrap.iife.js: -------------------------------------------------------------------------------- 1 | var uWrap = (function (exports) { 2 | 'use strict'; 3 | 4 | /** 5 | * Copyright (c) 2025, Leon Sorokin 6 | * All rights reserved. (MIT Licensed) 7 | * 8 | * uWrap.js 9 | * A small, fast line wrapping thing for Canvas2D 10 | * https://github.com/leeoniya/uWrap (v0.1.2) 11 | */ 12 | 13 | // BREAKS 14 | const D = "-".charCodeAt(0); 15 | const S = " ".charCodeAt(0); 16 | const N = "\n".charCodeAt(0); 17 | // const R = "\r".charCodeAt(0); (TODO: support \r\n breaks) 18 | // const T = "\t".charCodeAt(0); 19 | const SYMBS = `\`~!@#$%^&*()_+-=[]\\{}|;':",./<>? \t`; 20 | const NUMS = "1234567890"; 21 | const UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 22 | const LOWER = "abcdefghijklmnopqrstuvwxyz"; 23 | const CHARS = `${UPPER}${LOWER}${NUMS}${SYMBS}`; 24 | function supportsLetterSpacing(ctx) { 25 | const _w = ctx.measureText('W').width; 26 | const _letterSpacing = ctx.letterSpacing; 27 | ctx.letterSpacing = '101px'; 28 | const w = ctx.measureText('W').width; 29 | ctx.letterSpacing = _letterSpacing; 30 | return w > _w; 31 | } 32 | function varPreLine(ctx) { 33 | // Safari pre-18.4 does not support Canvas letterSpacing, and measureText() does not account for it 34 | // so we have to add it manually. https://caniuse.com/mdn-api_canvasrenderingcontext2d_letterspacing 35 | const fauxLetterSpacing = !supportsLetterSpacing(ctx) ? parseFloat(ctx.letterSpacing) : 0; 36 | // single-char widths in isolation 37 | const WIDTHS = {}; 38 | for (let i = 0; i < CHARS.length; i++) 39 | WIDTHS[CHARS.charCodeAt(i)] = ctx.measureText(CHARS[i]).width + fauxLetterSpacing; 40 | const wordSpacing = parseFloat(ctx.wordSpacing); 41 | if (wordSpacing > 0) 42 | WIDTHS[S] = wordSpacing; 43 | // build kerning/spacing LUT of upper+lower, upper+sym, upper+upper pairs. (this includes letterSpacing) 44 | // holds kerning-adjusted width of the uppers 45 | const PAIRS = {}; 46 | for (let i = 0; i < UPPER.length; i++) { 47 | let uc = UPPER.charCodeAt(i); 48 | PAIRS[uc] = {}; 49 | for (let j = 0; j < CHARS.length; j++) { 50 | let ch = CHARS.charCodeAt(j); 51 | let wid = ctx.measureText(`${UPPER[i]}${CHARS[j]}`).width - WIDTHS[ch] + fauxLetterSpacing; 52 | PAIRS[uc][ch] = wid; 53 | } 54 | } 55 | const eachLine = () => { }; 56 | function each(text, width, cb = eachLine) { 57 | let fr = 0; 58 | while (text.charCodeAt(fr) === S) 59 | fr++; 60 | let to = text.length - 1; 61 | while (text.charCodeAt(to) === S) 62 | to--; 63 | let headIdx = fr; 64 | let headEnd = 0; 65 | let headWid = 0; 66 | let tailIdx = -1; // wrap candidate 67 | let tailWid = 0; 68 | let inWS = false; 69 | for (let i = fr; i <= to; i++) { 70 | let c = text.charCodeAt(i); 71 | let w = 0; 72 | if (c in PAIRS) { 73 | let n = text.charCodeAt(i + 1); 74 | if (n in PAIRS[c]) 75 | w = PAIRS[c][n]; 76 | } 77 | if (w === 0) 78 | w = WIDTHS[c] ?? (WIDTHS[c] = ctx.measureText(text[i]).width); 79 | if (c === S) { // || c === T || c === N || c === R 80 | // set possible wrap point 81 | if (text.charCodeAt(i + 1) !== c) { 82 | tailIdx = i + 1; 83 | tailWid = 0; 84 | } 85 | if (!inWS && headWid > 0) { 86 | headWid += w; 87 | headEnd = i; 88 | } 89 | inWS = true; 90 | } 91 | else if (c === N) { 92 | if (cb(headIdx, i, headWid) === false) 93 | return; 94 | headIdx = headEnd = i + 1; 95 | headWid = tailWid = 0; 96 | tailWid = 0; 97 | tailIdx = -1; 98 | } 99 | else { 100 | if (headEnd > headIdx && headWid + w > width) { 101 | if (cb(headIdx, headEnd, headWid) === false) 102 | return; 103 | headWid = tailWid + w; 104 | headIdx = headEnd = tailIdx; 105 | tailWid = 0; 106 | tailIdx = -1; 107 | } 108 | else { 109 | if (c === D) { 110 | // set possible wrap point 111 | if (text.charCodeAt(i + 1) !== c) { 112 | tailIdx = headEnd = i + 1; 113 | tailWid = 0; 114 | } 115 | } 116 | headWid += w; 117 | tailWid += w; 118 | } 119 | inWS = false; 120 | } 121 | } 122 | cb(headIdx, to + 1, headWid); 123 | } 124 | let mayWrap = /\s|-/; 125 | return { 126 | each, 127 | split: (text, width, limit = Infinity) => { 128 | let out = []; 129 | if (mayWrap.test(text)) { 130 | each(text, width, (idx0, idx1) => { 131 | out.push(text.slice(idx0, idx1)); 132 | if (out.length === limit) 133 | return false; 134 | }); 135 | } 136 | else { 137 | out.push(text); 138 | } 139 | return out; 140 | }, 141 | count: (text, width) => { 142 | let count = 0; 143 | if (mayWrap.test(text)) { 144 | each(text, width, () => { count++; }); 145 | } 146 | else { 147 | count = 1; 148 | } 149 | return count; 150 | }, 151 | test: (text, width) => { 152 | let count = 0; 153 | if (mayWrap.test(text)) { 154 | each(text, width, () => { 155 | if (++count === 2) 156 | return false; 157 | }); 158 | } 159 | else { 160 | count = 1; 161 | } 162 | return count === 2; 163 | }, 164 | }; 165 | } 166 | 167 | exports.varPreLine = varPreLine; 168 | 169 | return exports; 170 | 171 | })({}); 172 | -------------------------------------------------------------------------------- /demo/canvas-hypertxt.mjs: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // src/multi-line.ts 4 | var resultCache = /* @__PURE__ */ new Map(); 5 | var metrics = /* @__PURE__ */ new Map(); 6 | var hyperMaps = /* @__PURE__ */ new Map(); 7 | function clearMultilineCache() { 8 | resultCache.clear(); 9 | hyperMaps.clear(); 10 | metrics.clear(); 11 | } 12 | function backProp(text, realWidth, keyMap, temperature, avgSize) { 13 | var _a, _b, _c; 14 | let guessWidth = 0; 15 | const contribMap = {}; 16 | for (const char of text) { 17 | const v = (_a = keyMap.get(char)) != null ? _a : avgSize; 18 | guessWidth += v; 19 | contribMap[char] = ((_b = contribMap[char]) != null ? _b : 0) + 1; 20 | ``; 21 | } 22 | const diff = realWidth - guessWidth; 23 | for (const key of Object.keys(contribMap)) { 24 | const numContribution = contribMap[key]; 25 | const contribWidth = (_c = keyMap.get(key)) != null ? _c : avgSize; 26 | const contribAmount = contribWidth * numContribution / guessWidth; 27 | const adjustment = diff * contribAmount * temperature / numContribution; 28 | const newVal = contribWidth + adjustment; 29 | keyMap.set(key, newVal); 30 | } 31 | } 32 | function makeHyperMap(ctx, avgSize) { 33 | var _a; 34 | const result = /* @__PURE__ */ new Map(); 35 | let total = 0; 36 | for (const char of "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890,.-+=?") { 37 | const w = ctx.measureText(char).width; 38 | result.set(char, w); 39 | total += w; 40 | } 41 | const avg = total / result.size; 42 | const damper = 3; 43 | const scaler = (avgSize / avg + damper) / (damper + 1); 44 | const keys = result.keys(); 45 | for (const key of keys) { 46 | result.set(key, ((_a = result.get(key)) != null ? _a : avg) * scaler); 47 | } 48 | return result; 49 | } 50 | function measureText(ctx, text, fontStyle, hyperMode) { 51 | var _a, _b; 52 | const current = metrics.get(fontStyle); 53 | if (hyperMode && current !== void 0 && current.count > 2e4) { 54 | let hyperMap = hyperMaps.get(fontStyle); 55 | if (hyperMap === void 0) { 56 | hyperMap = makeHyperMap(ctx, current.size); 57 | hyperMaps.set(fontStyle, hyperMap); 58 | } 59 | if (current.count > 5e5) { 60 | let final = 0; 61 | for (const char of text) { 62 | final += (_a = hyperMap.get(char)) != null ? _a : current.size; 63 | } 64 | return final * 1.01; 65 | } 66 | const result2 = ctx.measureText(text); 67 | backProp(text, result2.width, hyperMap, Math.max(0.05, 1 - current.count / 2e5), current.size); 68 | metrics.set(fontStyle, { 69 | count: current.count + text.length, 70 | size: current.size 71 | }); 72 | return result2.width; 73 | } 74 | const result = ctx.measureText(text); 75 | const avg = result.width / text.length; 76 | if (((_b = current == null ? void 0 : current.count) != null ? _b : 0) > 2e4) { 77 | return result.width; 78 | } 79 | if (current === void 0) { 80 | metrics.set(fontStyle, { 81 | count: text.length, 82 | size: avg 83 | }); 84 | } else { 85 | const diff = avg - current.size; 86 | const contribution = text.length / (current.count + text.length); 87 | const newVal = current.size + diff * contribution; 88 | metrics.set(fontStyle, { 89 | count: current.count + text.length, 90 | size: newVal 91 | }); 92 | } 93 | return result.width; 94 | } 95 | function getSplitPoint(ctx, text, width, fontStyle, totalWidth, measuredChars, hyperMode, getBreakOpportunities) { 96 | if (text.length <= 1) 97 | return text.length; 98 | if (totalWidth < width) 99 | return -1; 100 | let guess = Math.floor(width / totalWidth * measuredChars); 101 | let guessWidth = measureText(ctx, text.slice(0, Math.max(0, guess)), fontStyle, hyperMode); 102 | const oppos = getBreakOpportunities == null ? void 0 : getBreakOpportunities(text); 103 | if (guessWidth === width) { 104 | } else if (guessWidth < width) { 105 | while (guessWidth < width) { 106 | guess++; 107 | guessWidth = measureText(ctx, text.slice(0, Math.max(0, guess)), fontStyle, hyperMode); 108 | } 109 | guess--; 110 | } else { 111 | while (guessWidth > width) { 112 | const lastSpace = oppos !== void 0 ? 0 : text.lastIndexOf(" ", guess - 1); 113 | if (lastSpace > 0) { 114 | guess = lastSpace; 115 | } else { 116 | guess--; 117 | } 118 | guessWidth = measureText(ctx, text.slice(0, Math.max(0, guess)), fontStyle, hyperMode); 119 | } 120 | } 121 | if (text[guess] !== " ") { 122 | let greedyBreak = 0; 123 | if (oppos === void 0) { 124 | greedyBreak = text.lastIndexOf(" ", guess); 125 | } else { 126 | for (const o of oppos) { 127 | if (o > guess) 128 | break; 129 | greedyBreak = o; 130 | } 131 | } 132 | if (greedyBreak > 0) { 133 | guess = greedyBreak; 134 | } 135 | } 136 | return guess; 137 | } 138 | function splitMultilineText(ctx, value, fontStyle, width, hyperWrappingAllowed, getBreakOpportunities) { 139 | const key = `${value}_${fontStyle}_${width}px`; 140 | const cacheResult = resultCache.get(key); 141 | if (cacheResult !== void 0) 142 | return cacheResult; 143 | if (width <= 0) { 144 | return []; 145 | } 146 | let result = []; 147 | const encodedLines = value.split("\n"); 148 | const fontMetrics = metrics.get(fontStyle); 149 | const safeLineGuess = fontMetrics === void 0 ? value.length : width / fontMetrics.size * 1.5; 150 | const hyperMode = hyperWrappingAllowed && fontMetrics !== void 0 && fontMetrics.count > 2e4; 151 | for (let line of encodedLines) { 152 | let textWidth = measureText(ctx, line.slice(0, Math.max(0, safeLineGuess)), fontStyle, hyperMode); 153 | let measuredChars = Math.min(line.length, safeLineGuess); 154 | if (textWidth <= width) { 155 | result.push(line); 156 | } else { 157 | while (textWidth > width) { 158 | const splitPoint = getSplitPoint(ctx, line, width, fontStyle, textWidth, measuredChars, hyperMode, getBreakOpportunities); 159 | const subLine = line.slice(0, Math.max(0, splitPoint)); 160 | line = line.slice(subLine.length); 161 | result.push(subLine); 162 | textWidth = measureText(ctx, line.slice(0, Math.max(0, safeLineGuess)), fontStyle, hyperMode); 163 | measuredChars = Math.min(line.length, safeLineGuess); 164 | } 165 | if (textWidth > 0) { 166 | result.push(line); 167 | } 168 | } 169 | } 170 | result = result.map((l, i) => i === 0 ? l.trimEnd() : l.trim()); 171 | resultCache.set(key, result); 172 | if (resultCache.size > 500) { 173 | resultCache.delete(resultCache.keys().next().value); 174 | } 175 | return result; 176 | } 177 | export { 178 | clearMultilineCache as clearCache, 179 | splitMultilineText as split 180 | }; 181 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | uWrap Demo 5 | 6 | 7 | 8 | 52 | 53 | 54 | 189 | 190 |
The quick brown fox jumps over the lazy dog.
191 |
Rodney Dangerfield, Keith Gordon, Sally Kellerman, Robert Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
192 | 193 |
Rodney Dangerfield, Keith Gordon, Sally Kellerman, Robert Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
194 | 195 |
Bugs Bunny's Third Movie: 1001 Rabbit Tales
196 |
Rodney Dangerfield, Keith Gordon, Sally Kellerman, Robert Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
197 |
Rodney-Dangerfield, Keith Gordon, Sally Kellerman, Robert-Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
198 |
Rodney-Dangerfield, Keith Gordon, Sally Kellerman, Robert3-Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
199 | 200 |
Rodney Dangerfield, Keith Gordon, Sally Kellerman, Robert Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
201 | 202 |
Rodney Dangerfield, Keith Gordon, Sally Kellerman, Robert Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
203 | 204 |
Rodney Dangerfield, Keith Gordon, Sally Kellerman, Robert Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
205 | 206 |
Rodney Dangerfield, Keith Gordon, Sally Kellerman, Robert Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
207 |
Rodney Dangerfield, Keith Gordon, Sally Kellerman, Robert-Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
208 |
RodneyDangerfield,KeithGordon,SallyKellerman,RobertDowneyjr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau
209 | 210 |
211 | 212 | -------------------------------------------------------------------------------- /test/uWrap.test.mjs: -------------------------------------------------------------------------------- 1 | import test from 'node:test'; 2 | import assert from 'node:assert/strict'; 3 | 4 | import { varPreLine } from '../dist/uWrap.mjs'; 5 | 6 | import { Canvas, FontLibrary } from 'skia-canvas'; 7 | 8 | FontLibrary.use([import.meta.dirname + '/../demo/inter-v18-latin-regular.woff2']); 9 | let font = "14px Inter, sans-serif"; 10 | 11 | let can = new Canvas(); 12 | let ctx = can.getContext("2d"); 13 | ctx.font = font; 14 | ctx.letterSpacing = '0.15px'; 15 | // ctx.wordSpacing = '100px'; 16 | 17 | // console.log(can.engine); 18 | 19 | const { each, split, count, test: utest } = varPreLine(ctx); 20 | 21 | test('quick brown', async (t) => { 22 | const text = 'The quick brown fox jumps over the lazy dog.'; 23 | let expect = [ 24 | 'The quick', 25 | 'brown fox', 26 | 'jumps over', 27 | 'the lazy dog.', 28 | ]; 29 | 30 | const width = 100; 31 | 32 | await t.test('width 100, each()', () => { 33 | let lines = []; 34 | each(text, width, (idx0, idx1) => { 35 | lines.push(text.slice(idx0, idx1)); 36 | }); 37 | 38 | assert.deepEqual(lines, expect); 39 | }); 40 | 41 | await t.test('width 100, each(), early halt', () => { 42 | let lines = []; 43 | each(text, width, (idx0, idx1) => { 44 | lines.push(text.slice(idx0, idx1)); 45 | 46 | if (lines.length == 2) 47 | return false; 48 | }); 49 | 50 | assert.deepEqual(lines, [ 51 | 'The quick', 52 | 'brown fox', 53 | ]); 54 | }); 55 | 56 | await t.test('width 100, split()', () => { 57 | assert.deepEqual(split(text, width), expect); 58 | }); 59 | 60 | await t.test('width 100, split(), limit', () => { 61 | assert.deepEqual(split(text, width, 3), [ 62 | 'The quick', 63 | 'brown fox', 64 | 'jumps over', 65 | ]); 66 | }); 67 | 68 | 69 | await t.test('width 100, count()', () => { 70 | assert.deepEqual(count(text, width), 4); 71 | }); 72 | 73 | await t.test('width 100, test(), true', () => { 74 | assert.deepEqual(utest(text, width), true); 75 | }); 76 | 77 | await t.test('width 100, test(), false', () => { 78 | assert.deepEqual(utest('abc', width), false); 79 | }); 80 | 81 | await t.test('width 100, trailing and leading whitespace', () => { 82 | const text2 = ` ${text} `; 83 | 84 | let lines = []; 85 | each(text2, width, (idx0, idx1) => { 86 | lines.push(text2.slice(idx0, idx1)); 87 | }); 88 | 89 | assert.deepEqual(lines, expect); 90 | }); 91 | 92 | await t.test('width 105, explicit \\n', () => { 93 | const text2 = 'The\nquick\n\nbrown fox jumps over the lazy dog.'; 94 | const width = 105; 95 | let expect = [ 96 | 'The', 97 | 'quick', 98 | '', 99 | 'brown fox', 100 | 'jumps over the', 101 | 'lazy dog.' 102 | ]; 103 | 104 | let lines = []; 105 | each(text2, width, (idx0, idx1) => { 106 | lines.push(text2.slice(idx0, idx1)); 107 | }); 108 | 109 | assert.deepEqual(lines, expect); 110 | }); 111 | }); 112 | 113 | test('actors', async (t) => { 114 | const text = 'Rodney Dangerfield, Keith Gordon, Sally Kellerman, Robert Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau'; 115 | 116 | let width = 101; 117 | let expect = [ 118 | 'Rodney', 119 | 'Dangerfield,', 120 | 'Keith Gordon,', 121 | 'Sally', 122 | 'Kellerman,', 123 | 'Robert', 124 | 'Downey jr.,', 125 | 'Burt Young,', 126 | 'Ned Beatty,', 127 | 'Terry Farrell,', 128 | 'Paxton', 129 | 'Whitehead, M.', 130 | 'Emmet Walsh,', 131 | 'Adrienne', 132 | 'Barbeau' 133 | ]; 134 | 135 | await t.test(`width ${width}`, () => { 136 | let lines = []; 137 | each(text, width, (idx0, idx1) => { 138 | lines.push(text.slice(idx0, idx1)); 139 | }); 140 | assert.deepEqual(lines, expect); 141 | }); 142 | 143 | 144 | width = 121; 145 | expect = [ 146 | 'Rodney', 147 | 'Dangerfield,', 148 | 'Keith Gordon,', 149 | 'Sally Kellerman,', 150 | 'Robert Downey', 151 | 'jr., Burt Young,', 152 | 'Ned Beatty, Terry', 153 | 'Farrell, Paxton', 154 | 'Whitehead, M.', 155 | 'Emmet Walsh,', 156 | 'Adrienne', 157 | 'Barbeau' 158 | ]; 159 | 160 | await t.test(`width ${width}`, () => { 161 | let lines = []; 162 | each(text, width, (idx0, idx1) => { 163 | lines.push(text.slice(idx0, idx1)); 164 | }); 165 | assert.deepEqual(lines, expect); 166 | }); 167 | 168 | width = 134; 169 | expect = [ 170 | 'Rodney', 171 | 'Dangerfield, Keith', 172 | 'Gordon, Sally', 173 | 'Kellerman, Robert', 174 | 'Downey jr., Burt', 175 | 'Young, Ned Beatty,', 176 | 'Terry Farrell,', 177 | 'Paxton Whitehead,', 178 | 'M. Emmet Walsh,', 179 | 'Adrienne Barbeau' 180 | ]; 181 | 182 | await t.test(`width ${width}`, () => { 183 | let lines = []; 184 | each(text, width, (idx0, idx1) => { 185 | lines.push(text.slice(idx0, idx1)); 186 | }); 187 | assert.deepEqual(lines, expect); 188 | }); 189 | 190 | width = 152; 191 | expect = [ 192 | 'Rodney Dangerfield,', 193 | 'Keith Gordon, Sally', 194 | 'Kellerman, Robert', 195 | 'Downey jr., Burt', 196 | 'Young, Ned Beatty,', 197 | 'Terry Farrell, Paxton', 198 | 'Whitehead, M. Emmet', 199 | 'Walsh, Adrienne', 200 | 'Barbeau' 201 | ]; 202 | 203 | await t.test(`width ${width}`, () => { 204 | let lines = []; 205 | each(text, width, (idx0, idx1) => { 206 | lines.push(text.slice(idx0, idx1)); 207 | }); 208 | assert.deepEqual(lines, expect); 209 | }); 210 | 211 | width = 205; 212 | expect = [ 213 | 'Rodney Dangerfield, Keith', 214 | 'Gordon, Sally Kellerman,', 215 | 'Robert Downey jr., Burt', 216 | 'Young, Ned Beatty, Terry', 217 | 'Farrell, Paxton Whitehead, M.', 218 | 'Emmet Walsh, Adrienne', 219 | 'Barbeau' 220 | ]; 221 | 222 | await t.test(`width ${width}`, () => { 223 | let lines = []; 224 | each(text, width, (idx0, idx1) => { 225 | lines.push(text.slice(idx0, idx1)); 226 | }); 227 | assert.deepEqual(lines, expect); 228 | }); 229 | 230 | width = 300; 231 | expect = [ 232 | 'Rodney Dangerfield, Keith Gordon, Sally', 233 | 'Kellerman, Robert Downey jr., Burt Young,', 234 | 'Ned Beatty, Terry Farrell, Paxton', 235 | 'Whitehead, M. Emmet Walsh, Adrienne', 236 | 'Barbeau' 237 | ]; 238 | 239 | await t.test(`width ${width}`, () => { 240 | let lines = []; 241 | each(text, width, (idx0, idx1) => { 242 | lines.push(text.slice(idx0, idx1)); 243 | }); 244 | assert.deepEqual(lines, expect); 245 | }); 246 | 247 | width = 407; 248 | expect = [ 249 | 'Rodney Dangerfield, Keith Gordon, Sally Kellerman, Robert', 250 | 'Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton', 251 | 'Whitehead, M. Emmet Walsh, Adrienne Barbeau' 252 | ]; 253 | 254 | await t.test(`width ${width}`, () => { 255 | let lines = []; 256 | each(text, width, (idx0, idx1) => { 257 | lines.push(text.slice(idx0, idx1)); 258 | }); 259 | assert.deepEqual(lines, expect); 260 | }); 261 | }); 262 | 263 | test('actors (wrap after dash)', async (t) => { 264 | const text = 'Rodney-Dangerfield, Keith Gordon, Sally Kellerman, Robert-Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau'; 265 | 266 | let width = 134; 267 | let expect = [ 268 | 'Rodney-', 269 | 'Dangerfield, Keith', 270 | 'Gordon, Sally', 271 | 'Kellerman, Robert-', 272 | 'Downey jr., Burt', 273 | 'Young, Ned Beatty,', 274 | 'Terry Farrell,', 275 | 'Paxton Whitehead,', 276 | 'M. Emmet Walsh,', 277 | 'Adrienne Barbeau' 278 | ]; 279 | 280 | await t.test(`width ${width}`, () => { 281 | let lines = []; 282 | each(text, width, (idx0, idx1) => { 283 | lines.push(text.slice(idx0, idx1)); 284 | }); 285 | assert.deepEqual(lines, expect); 286 | }); 287 | }); 288 | 289 | test('actors (wrap includes dash)', async (t) => { 290 | const text = 'Rodney-Dangerfield, Keith Gordon, Sally Kellerman, Robert3-Downey jr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau'; 291 | 292 | let width = 135; 293 | let expect = [ 294 | 'Rodney-', 295 | 'Dangerfield, Keith', 296 | 'Gordon, Sally', 297 | 'Kellerman,', 298 | 'Robert3-Downey', 299 | 'jr., Burt Young, Ned', 300 | 'Beatty, Terry', 301 | 'Farrell, Paxton', 302 | 'Whitehead, M.', 303 | 'Emmet Walsh,', 304 | 'Adrienne Barbeau' 305 | ]; 306 | 307 | await t.test(`width ${width}`, () => { 308 | let lines = []; 309 | each(text, width, (idx0, idx1) => { 310 | lines.push(text.slice(idx0, idx1)); 311 | }); 312 | assert.deepEqual(lines, expect); 313 | }); 314 | }); 315 | 316 | test('actors (long unbreakable line)', async (t) => { 317 | const text = 'RodneyDangerfield,KeithGordon,SallyKellerman,RobertDowneyjr., Burt Young, Ned Beatty, Terry Farrell, Paxton Whitehead, M. Emmet Walsh, Adrienne Barbeau'; 318 | 319 | let width = 300; 320 | let expect = [ 321 | 'RodneyDangerfield,KeithGordon,SallyKellerman,RobertDowneyjr.,', 322 | 'Burt Young, Ned Beatty, Terry Farrell,', 323 | 'Paxton Whitehead, M. Emmet Walsh,', 324 | 'Adrienne Barbeau' 325 | ]; 326 | 327 | await t.test(`width ${width}`, () => { 328 | let lines = []; 329 | each(text, width, (idx0, idx1) => { 330 | lines.push(text.slice(idx0, idx1)); 331 | }); 332 | assert.deepEqual(lines, expect); 333 | }); 334 | }); 335 | 336 | test('movie', async (t) => { 337 | const text = "Bugs Bunny's Third Movie: 1001 Rabbit Tales"; 338 | 339 | let width = 136; 340 | let expect = [ 341 | "Bugs Bunny's Third", 342 | 'Movie: 1001 Rabbit', 343 | 'Tales' 344 | ]; 345 | 346 | await t.test(`width ${width}`, () => { 347 | let lines = []; 348 | each(text, width, (idx0, idx1) => { 349 | lines.push(text.slice(idx0, idx1)); 350 | }); 351 | assert.deepEqual(lines, expect); 352 | }); 353 | }); 354 | 355 | test('test', async (t) => { 356 | const text = "They were lost without the knowledgeable pig that composed their prune."; 357 | 358 | let width = 234; 359 | let expect = [ 360 | "They were lost without the", 361 | 'knowledgeable pig that composed', // should not wrap "composed" to next line 362 | 'their prune.' 363 | ]; 364 | 365 | await t.skip(`width ${width}`, () => { 366 | let lines = []; 367 | each(text, width, (idx0, idx1) => { 368 | lines.push(text.slice(idx0, idx1)); 369 | }); 370 | assert.deepEqual(lines, expect); 371 | }); 372 | }); -------------------------------------------------------------------------------- /demo/txtgen.mjs: -------------------------------------------------------------------------------- 1 | // utils -> helper.ts 2 | const randint = (min = 0, max = 1e6) => { 3 | min = Math.ceil(min); 4 | max = Math.floor(max); 5 | return Math.floor(Math.random() * (max - min + 1)) + min; 6 | }; 7 | const randfloat = () => { 8 | return randint(1, 999) / 1000; 9 | }; 10 | const rand = (a) => { 11 | let w; 12 | while (!w) { 13 | w = a[randint(0, a.length - 1)]; 14 | } 15 | return w; 16 | }; 17 | const pickLastPunc = () => { 18 | const a = ".......!?!?;...".split(""); 19 | return rand(a); 20 | }; 21 | const pluralize = (word) => { 22 | if (word.endsWith("s")) { 23 | return word; 24 | } 25 | if (word.match(/(ss|ish|ch|x|us)$/)) { 26 | word += "e"; 27 | } 28 | else if (word.endsWith("y") && !vowels.includes(word.charAt(word.length - 2))) { 29 | word = word.slice(0, word.length - 1); 30 | word += "ie"; 31 | } 32 | return word + "s"; 33 | }; 34 | const normalize = (word) => { 35 | let a = "a"; 36 | if (word.match(/^(a|e|heir|herb|hour|i|o)/)) { 37 | a = "an"; 38 | } 39 | return `${a} ${word}`; 40 | }; 41 | 42 | // utils -> sample.ts 43 | let _nouns = [ 44 | "alligator", 45 | "ant", 46 | "bear", 47 | "bee", 48 | "bird", 49 | "camel", 50 | "cat", 51 | "cheetah", 52 | "chicken", 53 | "chimpanzee", 54 | "cow", 55 | "crocodile", 56 | "deer", 57 | "dog", 58 | "dolphin", 59 | "duck", 60 | "eagle", 61 | "elephant", 62 | "fish", 63 | "fly", 64 | "fox", 65 | "frog", 66 | "giraffe", 67 | "goat", 68 | "goldfish", 69 | "hamster", 70 | "hippopotamus", 71 | "horse", 72 | "kangaroo", 73 | "kitten", 74 | "lion", 75 | "lobster", 76 | "monkey", 77 | "octopus", 78 | "owl", 79 | "panda", 80 | "pig", 81 | "puppy", 82 | "rabbit", 83 | "rat", 84 | "scorpion", 85 | "seal", 86 | "shark", 87 | "sheep", 88 | "snail", 89 | "snake", 90 | "spider", 91 | "squirrel", 92 | "tiger", 93 | "turtle", 94 | "wolf", 95 | "zebra", 96 | "apple", 97 | "apricot", 98 | "banana", 99 | "blackberry", 100 | "blueberry", 101 | "cherry", 102 | "cranberry", 103 | "currant", 104 | "fig", 105 | "grape", 106 | "grapefruit", 107 | "grapes", 108 | "kiwi", 109 | "kumquat", 110 | "lemon", 111 | "lime", 112 | "melon", 113 | "nectarine", 114 | "orange", 115 | "peach", 116 | "pear", 117 | "persimmon", 118 | "pineapple", 119 | "plum", 120 | "pomegranate", 121 | "prune", 122 | "raspberry", 123 | "strawberry", 124 | "tangerine", 125 | "watermelon", 126 | ]; 127 | let adjectives = [ 128 | "adaptable", 129 | "adventurous", 130 | "affable", 131 | "affectionate", 132 | "agreeable", 133 | "alert", 134 | "alluring", 135 | "ambitious", 136 | "ambitious", 137 | "amiable", 138 | "amicable", 139 | "amused", 140 | "amusing", 141 | "boundless", 142 | "brave", 143 | "brave", 144 | "bright", 145 | "bright", 146 | "broad-minded", 147 | "calm", 148 | "calm", 149 | "capable", 150 | "careful", 151 | "charming", 152 | "charming", 153 | "cheerful", 154 | "coherent", 155 | "comfortable", 156 | "communicative", 157 | "compassionate", 158 | "confident", 159 | "conscientious", 160 | "considerate", 161 | "convivial", 162 | "cooperative", 163 | "courageous", 164 | "courageous", 165 | "courteous", 166 | "creative", 167 | "credible", 168 | "cultured", 169 | "dashing", 170 | "dazzling", 171 | "debonair", 172 | "decisive", 173 | "decisive", 174 | "decorous", 175 | "delightful", 176 | "detailed", 177 | "determined", 178 | "determined", 179 | "diligent", 180 | "diligent", 181 | "diplomatic", 182 | "discreet", 183 | "discreet", 184 | "dynamic", 185 | "dynamic", 186 | "eager", 187 | "easygoing", 188 | "efficient", 189 | "elated", 190 | "eminent", 191 | "emotional", 192 | "enchanting", 193 | "encouraging", 194 | "endurable", 195 | "energetic", 196 | "energetic", 197 | "entertaining", 198 | "enthusiastic", 199 | "enthusiastic", 200 | "excellent", 201 | "excited", 202 | "exclusive", 203 | "exuberant", 204 | "exuberant", 205 | "fabulous", 206 | "fair", 207 | "fair-minded", 208 | "faithful", 209 | "faithful", 210 | "fantastic", 211 | "fearless", 212 | "fearless", 213 | "fine", 214 | "forceful", 215 | "frank", 216 | "frank", 217 | "friendly", 218 | "friendly", 219 | "funny", 220 | "funny", 221 | "generous", 222 | "generous", 223 | "gentle", 224 | "gentle", 225 | "glorious", 226 | "good", 227 | "good", 228 | "gregarious", 229 | "happy", 230 | "hard-working", 231 | "harmonious", 232 | "helpful", 233 | "helpful", 234 | "hilarious", 235 | "honest", 236 | "honorable", 237 | "humorous", 238 | "imaginative", 239 | "impartial", 240 | "impartial", 241 | "independent", 242 | "industrious", 243 | "instinctive", 244 | "intellectual", 245 | "intelligent", 246 | "intuitive", 247 | "inventive", 248 | "jolly", 249 | "joyous", 250 | "kind", 251 | "kind", 252 | "kind-hearted", 253 | "knowledgeable", 254 | "level", 255 | "likeable", 256 | "lively", 257 | "lovely", 258 | "loving", 259 | "loving", 260 | "loyal", 261 | "lucky", 262 | "mature", 263 | "modern", 264 | "modest", 265 | "neat", 266 | "nice", 267 | "nice", 268 | "obedient", 269 | "optimistic", 270 | "painstaking", 271 | "passionate", 272 | "patient", 273 | "peaceful", 274 | "perfect", 275 | "persistent", 276 | "philosophical", 277 | "pioneering", 278 | "placid", 279 | "placid", 280 | "plausible", 281 | "pleasant", 282 | "plucky", 283 | "plucky", 284 | "polite", 285 | "powerful", 286 | "practical", 287 | "pro-active", 288 | "productive", 289 | "protective", 290 | "proud", 291 | "punctual", 292 | "quick-witted", 293 | "quiet", 294 | "quiet", 295 | "rational", 296 | "receptive", 297 | "reflective", 298 | "reliable", 299 | "relieved", 300 | "reserved", 301 | "resolute", 302 | "resourceful", 303 | "responsible", 304 | "rhetorical", 305 | "righteous", 306 | "romantic", 307 | "romantic", 308 | "sedate", 309 | "seemly", 310 | "selective", 311 | "self-assured", 312 | "self-confident", 313 | "self-disciplined", 314 | "sensible", 315 | "sensitive", 316 | "sensitive", 317 | "shrewd", 318 | "shy", 319 | "silly", 320 | "sincere", 321 | "sincere", 322 | "skillful", 323 | "smiling", 324 | "sociable", 325 | "splendid", 326 | "steadfast", 327 | "stimulating", 328 | "straightforward", 329 | "successful", 330 | "succinct", 331 | "sympathetic", 332 | "talented", 333 | "thoughtful", 334 | "thoughtful", 335 | "thrifty", 336 | "tidy", 337 | "tough", 338 | "tough", 339 | "trustworthy", 340 | "unassuming", 341 | "unbiased", 342 | "understanding", 343 | "unusual", 344 | "upbeat", 345 | "versatile", 346 | "vigorous", 347 | "vivacious", 348 | "warm", 349 | "warmhearted", 350 | "willing", 351 | "willing", 352 | "wise", 353 | "witty", 354 | "witty", 355 | "wonderful", 356 | ]; 357 | const vowels = [ 358 | "a", 359 | "e", 360 | "i", 361 | "o", 362 | "u", 363 | "y", 364 | ]; 365 | const noun = () => rand(_nouns); 366 | const a_noun = () => normalize(rand(_nouns)); 367 | const nouns = () => pluralize(rand(_nouns)); 368 | const adjective = () => rand(adjectives); 369 | const an_adjective = () => normalize(rand(adjectives)); 370 | let sentenceTemplates = [ 371 | () => `however, ${nouns()} have begun to rent ${nouns()} over the past few months, specifically for ${nouns()} associated with their ${nouns()}`, 372 | () => `the ${noun()} is ${a_noun()}`, 373 | () => `${a_noun()} is ${an_adjective()} ${noun()}`, 374 | () => `the first ${adjective()} ${noun()} is, in its own way, ${a_noun()}`, 375 | () => `their ${noun()} was, in this moment, ${an_adjective()} ${noun()}`, 376 | () => `${a_noun()} is ${a_noun()} from the right perspective`, 377 | () => `the literature would have us believe that ${an_adjective()} ${noun()} is not but ${a_noun()}`, 378 | () => `${an_adjective()} ${noun()} is ${a_noun()} of the mind`, 379 | () => `the ${adjective()} ${noun()} reveals itself as ${an_adjective()} ${noun()} to those who look`, 380 | () => `authors often misinterpret the ${noun()} as ${an_adjective()} ${noun()}, when in actuality it feels more like ${an_adjective()} ${noun()}`, 381 | () => `we can assume that any instance of ${a_noun()} can be construed as ${an_adjective()} ${noun()}`, 382 | () => `they were lost without the ${adjective()} ${noun()} that composed their ${noun()}`, 383 | () => `the ${adjective()} ${noun()} comes from ${an_adjective()} ${noun()}`, 384 | () => `${a_noun()} can hardly be considered ${an_adjective()} ${noun()} without also being ${a_noun()}`, 385 | () => `few can name ${an_adjective()} ${noun()} that isn't ${an_adjective()} ${noun()}`, 386 | () => `some posit the ${adjective()} ${noun()} to be less than ${adjective()}`, 387 | () => `${a_noun()} of the ${noun()} is assumed to be ${an_adjective()} ${noun()}`, 388 | () => `${a_noun()} sees ${a_noun()} as ${an_adjective()} ${noun()}`, 389 | () => `the ${noun()} of ${a_noun()} becomes ${an_adjective()} ${noun()}`, 390 | () => `${a_noun()} is ${a_noun()}'s ${noun()}`, 391 | () => `${a_noun()} is the ${noun()} of ${a_noun()}`, 392 | () => `${an_adjective()} ${noun()}'s ${noun()} comes with it the thought that the ${adjective()} ${noun()} is ${a_noun()}`, 393 | () => `${nouns()} are ${adjective()} ${nouns()}`, 394 | () => `${adjective()} ${nouns()} show us how ${nouns()} can be ${nouns()}`, 395 | () => `before ${nouns()}, ${nouns()} were only ${nouns()}`, 396 | () => `those ${nouns()} are nothing more than ${nouns()}`, 397 | () => `some ${adjective()} ${nouns()} are thought of simply as ${nouns()}`, 398 | () => `one cannot separate ${nouns()} from ${adjective()} ${nouns()}`, 399 | () => `the ${nouns()} could be said to resemble ${adjective()} ${nouns()}`, 400 | () => `${an_adjective()} ${noun()} without ${nouns()} is truly a ${noun()} of ${adjective()} ${nouns()}`, 401 | ]; 402 | const phrases = [ 403 | "to be more specific, ", 404 | "in recent years, ", 405 | "however, ", 406 | "by the way", 407 | "of course, ", 408 | "some assert that ", 409 | "if this was somewhat unclear, ", 410 | "unfortunately, that is wrong; on the contrary, ", 411 | "it's very tricky, if not impossible, ", 412 | "this could be, or perhaps ", 413 | "this is not to discredit the idea that ", 414 | "we know that ", 415 | "it's an undeniable fact, really; ", 416 | "framed in a different way, ", 417 | "what we don't know for sure is whether or not ", 418 | "as far as we can estimate, ", 419 | "as far as he is concerned, ", 420 | "the zeitgeist contends that ", 421 | "though we assume the latter, ", 422 | "far from the truth, ", 423 | "extending this logic, ", 424 | "nowhere is it disputed that ", 425 | "in modern times ", 426 | "in ancient times ", 427 | "recent controversy aside, ", 428 | "washing and polishing the car,", 429 | "having been a gymnast, ", 430 | "after a long day at school and work, ", 431 | "waking to the buzz of the alarm clock, ", 432 | "draped neatly on a hanger, ", 433 | "shouting with happiness, ", 434 | ]; 435 | 436 | // utils -> lorem.ts 437 | const WordDict = "a ac accumsan adipiscing aenean aliqua aliquam aliquet amet arcu at auctor augue bibendum blandit commodo condimentum consectetur consequat convallis cras cum curabitur cursus dapibus diam dictum dictumst dignissim dis do dolor dolore donec dui duis egestas eget eiusmod elementum elit enim erat eros est et etiam eu euismod facilisis faucibus felis fermentum feugiat fringilla gravida habitant habitasse hac hendrerit iaculis id imperdiet in incididunt integer ipsum justo labore lacinia lacus laoreet lectus leo libero lobortis lorem magna magnis massa mattis mauris mi molestie montes morbi mus nam nascetur natoque nec neque netus nibh nisi nisl non nulla nullam nunc odio orci ornare parturient pellentesque penatibus pharetra phasellus placerat platea porta porttitor praesent pretium proin pulvinar purus quam quis quisque ridiculus risus sagittis scelerisque sed sem semper senectus sit sociis sodales sollicitudin suscipit suspendisse tellus tempor tempus tincidunt tortor tristique turpis ullamcorper ultrices ultricies urna ut varius vel velit venenatis vestibulum vitae viverra volutpat" 438 | .split(" "); 439 | const WordDictSize = WordDict.length; 440 | /** 441 | * Generate a lorem ipsum 442 | * 443 | * @param min Minimal words count, default 2 444 | * @param max Maximum words count, default 24 445 | * @returns A sentence 446 | */ 447 | const generate = (min = 2, max = 24) => { 448 | const size = randint(min, max); 449 | const words = []; 450 | while (words.length < size) { 451 | const r = randint(0, WordDictSize); 452 | const w = WordDict[r]; 453 | if (w && !words.includes(w)) { 454 | words.push(w); 455 | } 456 | } 457 | return words.join(" "); 458 | }; 459 | 460 | // mod.ts 461 | const randomStartingPhrase = () => { 462 | if (randfloat() < 0.33) { 463 | return rand(phrases); 464 | } 465 | return ""; 466 | }; 467 | const makeSentenceFromTemplate = () => { 468 | return rand(sentenceTemplates)(); 469 | }; 470 | /** 471 | * Generate a sentence with or without starting phrase 472 | * 473 | * @param ignoreStartingPhrase. Set to true to add a short phrase at the begining 474 | * @returns A sentence 475 | */ 476 | const sentence = (ignoreStartingPhrase = false) => { 477 | const phrase = ignoreStartingPhrase ? "" : randomStartingPhrase(); 478 | let s = phrase + makeSentenceFromTemplate(); 479 | s = s.charAt(0).toUpperCase() + s.slice(1); 480 | s += pickLastPunc(); 481 | return s; 482 | }; 483 | /** 484 | * Generate a paragraph with given sentence count 485 | * 486 | * @param len Sentence count, 3 to 15 487 | * @returns A paragraph 488 | */ 489 | const paragraph = (len = 0) => { 490 | if (!len) { 491 | len = randint(3, 10); 492 | } 493 | const t = Math.min(len, 15); 494 | const a = []; 495 | while (a.length < t) { 496 | const s = sentence(); 497 | a.push(s); 498 | } 499 | return a.join(" "); 500 | }; 501 | /** 502 | * Generate an article with given paragraph count 503 | * 504 | * @param len Paragraph count, 3 to 15 505 | * @returns An article 506 | */ 507 | const article = (len = 0) => { 508 | if (!len) { 509 | len = randint(3, 10); 510 | } 511 | const t = Math.min(len, 15); 512 | const a = []; 513 | while (a.length < t) { 514 | const s = paragraph(); 515 | a.push(s); 516 | } 517 | return a.join("\n\n"); 518 | }; 519 | 520 | export { article, generate as lorem, paragraph, sentence }; 521 | --------------------------------------------------------------------------------