├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── bidi-engine │ ├── index.js │ └── package.json ├── core │ ├── package.json │ ├── src │ │ ├── geom │ │ │ ├── BBox.js │ │ │ ├── Path.js │ │ │ ├── Point.js │ │ │ ├── Polygon.js │ │ │ ├── Rect.js │ │ │ └── index.js │ │ ├── index.js │ │ ├── layout │ │ │ ├── GlyphGenerator.js │ │ │ ├── LayoutEngine.js │ │ │ ├── Typesetter.js │ │ │ ├── flattenRuns.js │ │ │ └── injectEngines.js │ │ └── models │ │ │ ├── Attachment.js │ │ │ ├── AttributedString.js │ │ │ ├── Block.js │ │ │ ├── Container.js │ │ │ ├── DecorationLine.js │ │ │ ├── FontDescriptor.js │ │ │ ├── GlyphRun.js │ │ │ ├── GlyphString.js │ │ │ ├── LineFragment.js │ │ │ ├── ParagraphStyle.js │ │ │ ├── Range.js │ │ │ ├── Run.js │ │ │ ├── RunStyle.js │ │ │ ├── TabStop.js │ │ │ └── index.js │ └── test │ │ ├── geom │ │ ├── BBox.test.js │ │ ├── Path.test.js │ │ ├── Point.test.js │ │ ├── Polygon.test.js │ │ └── Rect.test.js │ │ ├── layout │ │ ├── LayoutEngine.test.js │ │ ├── Typesetter.test.js │ │ └── flattenRuns.test.js │ │ ├── models │ │ ├── Attachment.test.js │ │ ├── AttributedString.test.js │ │ ├── Block.test.js │ │ ├── GlyphRun.test.js │ │ ├── GlyphString.test.js │ │ ├── Range.test.js │ │ ├── Run.test.js │ │ └── TabStop.test.js │ │ └── utils │ │ ├── container.js │ │ ├── font.js │ │ ├── glyphRuns.js │ │ ├── glyphStrings.js │ │ └── lorem.js ├── font-substitution-engine │ ├── index.js │ ├── package.json │ └── test │ │ ├── index.test.js │ │ └── setup.js ├── justification-engine │ ├── index.js │ └── package.json ├── line-fragment-generator │ ├── index.js │ └── package.json ├── linebreaker │ ├── index.js │ └── package.json ├── pdf-renderer │ ├── index.js │ └── package.json ├── script-itemizer │ ├── index.js │ ├── package.json │ └── test │ │ └── index.test.js ├── tab-engine │ ├── index.js │ └── package.json ├── text-decoration-engine │ ├── index.js │ └── package.json ├── textkit │ ├── index.js │ └── package.json └── truncation-engine │ ├── index.js │ └── package.json ├── temp.js └── yarn-error.log /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { "loose": true }] 4 | ], 5 | "plugins": [ 6 | "transform-runtime", 7 | "transform-export-extensions", 8 | ["transform-class-properties", { "loose": true }] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'babel-eslint', 3 | extends: ['airbnb-base', 'prettier'], 4 | plugins: ['jest'], 5 | env: { 6 | 'jest/globals': true 7 | }, 8 | rules: { 9 | 'no-bitwise': 0, 10 | 'no-continue': 0, 11 | 'no-new-func': 0, 12 | 'no-plusplus': 0, 13 | 'comma-dangle': 0, 14 | 'no-cond-assign': 0, 15 | 'no-mixed-operators': 0, 16 | 'no-use-before-define': 0, 17 | 'no-case-declarations': 0, 18 | 'no-underscore-dangle': 0, 19 | 'no-restricted-syntax': 0, 20 | 'function-paren-newline': 0, 21 | 'class-methods-use-this': 0, 22 | 'no-multi-assign': ['warn'], 23 | 'no-nested-ternary': ['warn'], 24 | 'no-param-reassign': ['warn'], 25 | 'prefer-destructuring': ['warn'], 26 | 'max-len': ['error', { code: 100, ignoreComments: true }] 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn.lock 3 | .DS_Store 4 | dist 5 | *.pdf 6 | lerna-debug.log 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testURL: 'http://localhost/', 3 | modulePathIgnorePatterns: ['/node_modules/'], 4 | moduleDirectories: ['packages', 'node_modules'] 5 | }; 6 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "lerna": "2.8.0", 3 | "packages": [ 4 | "packages/*" 5 | ], 6 | "version": "independent" 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "description": "An advanced text layout framework", 4 | "main": "dist/index.js", 5 | "scripts": { 6 | "prebuild": "rimraf dist", 7 | "prepublish": "npm run build", 8 | "build": "lerna run build", 9 | "build:watch": "lerna run build:watch --concurrency=1000 --stream", 10 | "precommit": "lint-staged", 11 | "test": "jest" 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "author": "Devon Govett ", 17 | "license": "MIT", 18 | "publishConfig": { 19 | "access": "public" 20 | }, 21 | "dependencies": { 22 | "babel-runtime": "^6.26.0", 23 | "pdfkit": "^0.8.0" 24 | }, 25 | "devDependencies": { 26 | "babel-cli": "^6.26.0", 27 | "babel-core": "^6.26.0", 28 | "babel-eslint": "^8.2.2", 29 | "babel-plugin-transform-class-properties": "^6.24.1", 30 | "babel-plugin-transform-export-extensions": "^6.22.0", 31 | "babel-plugin-transform-runtime": "^6.23.0", 32 | "babel-preset-env": "^1.7.0", 33 | "eslint": "^4.18.2", 34 | "eslint-config-airbnb-base": "^12.1.0", 35 | "eslint-config-prettier": "^2.9.0", 36 | "eslint-plugin-import": "^2.9.0", 37 | "eslint-plugin-jest": "^21.14.0", 38 | "husky": "^0.14.3", 39 | "jest": "^22.4.2", 40 | "lerna": "^2.8.0", 41 | "lint-staged": "^7.0.0", 42 | "prettier": "^1.11.1", 43 | "rimraf": "^2.6.2" 44 | }, 45 | "lint-staged": { 46 | "*.js": [ 47 | "prettier --write", 48 | "git add" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/bidi-engine/index.js: -------------------------------------------------------------------------------- 1 | import bidi from 'bidi'; 2 | 3 | export default () => () => 4 | class BidiEngine { 5 | getRuns(string) { 6 | const classes = bidi.getClasses(string); 7 | return bidi.getLevelRuns(classes); 8 | } 9 | 10 | reorderLine(glyphs, runs, paragraphLevel) { 11 | return bidi.reorder(glyphs, runs, paragraphLevel); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /packages/bidi-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/bidi-engine", 3 | "version": "0.1.9", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT" 19 | } 20 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/core", 3 | "version": "0.1.17", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel ./src --out-dir ./dist", 10 | "build:watch": "babel ./src --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "cubic2quad": "^1.1.0", 21 | "iconv-lite": "^0.4.13", 22 | "svgpath": "^2.2.0", 23 | "unicode-properties": "^1.1.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/core/src/geom/BBox.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a glyph bounding box 3 | */ 4 | class BBox { 5 | constructor(minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity) { 6 | /** 7 | * The minimum X position in the bounding box 8 | * @type {number} 9 | */ 10 | this.minX = minX; 11 | 12 | /** 13 | * The minimum Y position in the bounding box 14 | * @type {number} 15 | */ 16 | this.minY = minY; 17 | 18 | /** 19 | * The maxmimum X position in the bounding box 20 | * @type {number} 21 | */ 22 | this.maxX = maxX; 23 | 24 | /** 25 | * The maxmimum Y position in the bounding box 26 | * @type {number} 27 | */ 28 | this.maxY = maxY; 29 | } 30 | 31 | /** 32 | * The width of the bounding box 33 | * @type {number} 34 | */ 35 | get width() { 36 | return this.maxX - this.minX; 37 | } 38 | 39 | /** 40 | * The height of the bounding box 41 | * @type {number} 42 | */ 43 | get height() { 44 | return this.maxY - this.minY; 45 | } 46 | 47 | addPoint(x, y) { 48 | if (x < this.minX) { 49 | this.minX = x; 50 | } 51 | 52 | if (y < this.minY) { 53 | this.minY = y; 54 | } 55 | 56 | if (x > this.maxX) { 57 | this.maxX = x; 58 | } 59 | 60 | if (y > this.maxY) { 61 | this.maxY = y; 62 | } 63 | } 64 | 65 | addRect(rect) { 66 | this.addPoint(rect.x, rect.y); 67 | this.addPoint(rect.maxX, rect.maxY); 68 | } 69 | 70 | copy() { 71 | return new BBox(this.minX, this.minY, this.maxX, this.maxY); 72 | } 73 | } 74 | 75 | export default BBox; 76 | -------------------------------------------------------------------------------- /packages/core/src/geom/Path.js: -------------------------------------------------------------------------------- 1 | import cubic2quad from 'cubic2quad'; 2 | import BBox from './BBox'; 3 | import Polygon from './Polygon'; 4 | import Point from './Point'; 5 | 6 | const SVG_COMMANDS = { 7 | moveTo: 'M', 8 | lineTo: 'L', 9 | quadraticCurveTo: 'Q', 10 | bezierCurveTo: 'C', 11 | closePath: 'Z' 12 | }; 13 | 14 | // This constant is used to approximate a symmetrical arc using a cubic Bezier curve. 15 | const KAPPA = 4.0 * ((Math.sqrt(2) - 1.0) / 3.0); 16 | 17 | /** 18 | * Path objects are returned by glyphs and represent the actual 19 | * vector outlines for each glyph in the font. Paths can be converted 20 | * to SVG path data strings, or to functions that can be applied to 21 | * render the path to a graphics context. 22 | */ 23 | class Path { 24 | constructor() { 25 | this.commands = []; 26 | this._bbox = null; 27 | this._cbox = null; 28 | this._bezier = false; 29 | this._quadratic = false; 30 | } 31 | 32 | rect(x, y, width, height) { 33 | this.moveTo(x, y); 34 | this.lineTo(x + width, y); 35 | this.lineTo(x + width, y + height); 36 | this.lineTo(x, y + height); 37 | this.closePath(); 38 | return this; 39 | } 40 | 41 | ellipse(x, y, r1, r2 = r1) { 42 | // based on http://stackoverflow.com/questions/2172798/how-to-draw-an-oval-in-html5-canvas/2173084#2173084 43 | x -= r1; 44 | y -= r2; 45 | const ox = r1 * KAPPA; 46 | const oy = r2 * KAPPA; 47 | const xe = x + r1 * 2; 48 | const ye = y + r2 * 2; 49 | const xm = x + r1; 50 | const ym = y + r2; 51 | 52 | this.moveTo(x, ym); 53 | this.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y); 54 | this.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym); 55 | this.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye); 56 | this.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym); 57 | this.closePath(); 58 | return this; 59 | } 60 | 61 | circle(x, y, radius) { 62 | this.ellipse(x, y, radius); 63 | } 64 | 65 | append(path) { 66 | for (const { command, args } of path.commands) { 67 | this[command](...args); 68 | } 69 | 70 | return this; 71 | } 72 | 73 | /** 74 | * Compiles the path to a JavaScript function that can be applied with 75 | * a graphics context in order to render the path. 76 | * @return {string} 77 | */ 78 | toFunction() { 79 | const cmds = this.commands.map(c => ` ctx.${c.command}(${c.args.join(', ')});`); 80 | return new Function('ctx', cmds.join('\n')); 81 | } 82 | 83 | /** 84 | * Converts the path to an SVG path data string 85 | * @return {string} 86 | */ 87 | toSVG() { 88 | const cmds = this.commands.map(c => { 89 | const args = c.args.map(arg => Math.round(arg * 100) / 100); 90 | return `${SVG_COMMANDS[c.command]}${args.join(' ')}`; 91 | }); 92 | 93 | return cmds.join(''); 94 | } 95 | 96 | /** 97 | * Gets the 'control box' of a path. 98 | * This is like the bounding box, but it includes all points including 99 | * control points of bezier segments and is much faster to compute than 100 | * the real bounding box. 101 | * @type {BBox} 102 | */ 103 | get cbox() { 104 | if (!this._cbox) { 105 | const cbox = new BBox(); 106 | for (const command of this.commands) { 107 | for (let i = 0; i < command.args.length; i += 2) { 108 | cbox.addPoint(command.args[i], command.args[i + 1]); 109 | } 110 | } 111 | 112 | this._cbox = Object.freeze(cbox); 113 | } 114 | 115 | return this._cbox; 116 | } 117 | 118 | /** 119 | * Gets the exact bounding box of the path by evaluating curve segments. 120 | * Slower to compute than the control box, but more accurate. 121 | * @type {BBox} 122 | */ 123 | get bbox() { 124 | if (this._bbox) { 125 | return this._bbox; 126 | } 127 | 128 | const bbox = new BBox(); 129 | let cx = 0; 130 | let cy = 0; 131 | 132 | const f = t => 133 | Math.pow(1 - t, 3) * p0[i] + 134 | 3 * Math.pow(1 - t, 2) * t * p1[i] + 135 | 3 * (1 - t) * Math.pow(t, 2) * p2[i] + 136 | Math.pow(t, 3) * p3[i]; 137 | 138 | for (let { command, args } of this.commands) { 139 | switch (command) { 140 | case 'moveTo': 141 | case 'lineTo': 142 | const [x, y] = args; 143 | bbox.addPoint(x, y); 144 | cx = x; 145 | cy = y; 146 | break; 147 | 148 | case 'quadraticCurveTo': 149 | args = quadraticToBezier(cx, cy, ...args); 150 | // fall through 151 | 152 | case 'bezierCurveTo': 153 | const [cp1x, cp1y, cp2x, cp2y, p3x, p3y] = args; 154 | 155 | // http://blog.hackers-cafe.net/2009/06/how-to-calculate-bezier-curves-bounding.html 156 | bbox.addPoint(p3x, p3y); 157 | 158 | const p0 = [cx, cy]; 159 | const p1 = [cp1x, cp1y]; 160 | const p2 = [cp2x, cp2y]; 161 | const p3 = [p3x, p3y]; 162 | 163 | for (let i = 0; i <= 1; i++) { 164 | const b = 6 * p0[i] - 12 * p1[i] + 6 * p2[i]; 165 | const a = -3 * p0[i] + 9 * p1[i] - 9 * p2[i] + 3 * p3[i]; 166 | const c = 3 * p1[i] - 3 * p0[i]; 167 | 168 | if (a === 0) { 169 | if (b === 0) { 170 | continue; 171 | } 172 | 173 | let t = -c / b; 174 | if (0 < t && t < 1) { 175 | if (i === 0) { 176 | bbox.addPoint(f(t), bbox.maxY); 177 | } else if (i === 1) { 178 | bbox.addPoint(bbox.maxX, f(t)); 179 | } 180 | } 181 | 182 | continue; 183 | } 184 | 185 | let b2ac = Math.pow(b, 2) - 4 * c * a; 186 | if (b2ac < 0) { 187 | continue; 188 | } 189 | 190 | let t1 = (-b + Math.sqrt(b2ac)) / (2 * a); 191 | if (0 < t1 && t1 < 1) { 192 | if (i === 0) { 193 | bbox.addPoint(f(t1), bbox.maxY); 194 | } else if (i === 1) { 195 | bbox.addPoint(bbox.maxX, f(t1)); 196 | } 197 | } 198 | 199 | let t2 = (-b - Math.sqrt(b2ac)) / (2 * a); 200 | if (0 < t2 && t2 < 1) { 201 | if (i === 0) { 202 | bbox.addPoint(f(t2), bbox.maxY); 203 | } else if (i === 1) { 204 | bbox.addPoint(bbox.maxX, f(t2)); 205 | } 206 | } 207 | } 208 | 209 | cx = p3x; 210 | cy = p3y; 211 | break; 212 | default: 213 | break; 214 | } 215 | } 216 | 217 | return (this._bbox = Object.freeze(bbox)); 218 | } 219 | 220 | mapPoints(fn) { 221 | const path = new Path(); 222 | 223 | for (const c of this.commands) { 224 | const args = []; 225 | for (let i = 0; i < c.args.length; i += 2) { 226 | const [x, y] = fn(c.args[i], c.args[i + 1]); 227 | args.push(x, y); 228 | } 229 | 230 | path[c.command](...args); 231 | } 232 | 233 | return path; 234 | } 235 | 236 | transform(m0, m1, m2, m3, m4, m5) { 237 | return this.mapPoints((x, y) => { 238 | x = m0 * x + m2 * y + m4; 239 | y = m1 * x + m3 * y + m5; 240 | return [x, y]; 241 | }); 242 | } 243 | 244 | translate(x, y) { 245 | return this.transform(1, 0, 0, 1, x, y); 246 | } 247 | 248 | rotate(angle) { 249 | const cos = Math.cos(angle); 250 | const sin = Math.sin(angle); 251 | return this.transform(cos, sin, -sin, cos, 0, 0); 252 | } 253 | 254 | scale(scaleX, scaleY = scaleX) { 255 | return this.transform(scaleX, 0, 0, scaleY, 0, 0); 256 | } 257 | 258 | quadraticToBezier() { 259 | if (!this._quadratic) { 260 | return this; 261 | } 262 | 263 | const path = new Path(); 264 | let x = 0; 265 | let y = 0; 266 | 267 | for (const c of this.commands) { 268 | if (c.command === 'quadraticCurveTo') { 269 | const quads = quadraticToBezier(x, y, ...c.args); 270 | 271 | for (let i = 2; i < quads.length; i += 6) { 272 | path.bezierCurveTo( 273 | quads[i], 274 | quads[i + 1], 275 | quads[i + 2], 276 | quads[i + 3], 277 | quads[i + 4], 278 | quads[i + 5] 279 | ); 280 | } 281 | } else { 282 | path[c.command](...c.args); 283 | x = c.args[c.args.length - 2] || 0; 284 | y = c.args[c.args.length - 1] || 0; 285 | } 286 | } 287 | 288 | return path; 289 | } 290 | 291 | bezierToQuadratic() { 292 | if (!this._bezier) { 293 | return this; 294 | } 295 | 296 | const path = new Path(); 297 | let x = 0; 298 | let y = 0; 299 | 300 | for (const c of this.commands) { 301 | if (c.command === 'bezierCurveTo') { 302 | const quads = cubic2quad(x, y, ...c.args, 0.1); 303 | 304 | for (let i = 2; i < quads.length; i += 4) { 305 | path.quadraticCurveTo(quads[i], quads[i + 1], quads[i + 2], quads[i + 3]); 306 | } 307 | } else { 308 | path[c.command](...c.args); 309 | x = c.args[c.args.length - 2] || 0; 310 | y = c.args[c.args.length - 1] || 0; 311 | } 312 | } 313 | 314 | return path; 315 | } 316 | 317 | get isFlat() { 318 | return !this._bezier && !this._quadratic; 319 | } 320 | 321 | flatten() { 322 | if (this.isFlat) { 323 | return this; 324 | } 325 | 326 | const res = new Path(); 327 | let cx = 0; 328 | let cy = 0; 329 | let sx = 0; 330 | let sy = 0; 331 | 332 | for (let { command, args } of this.commands) { 333 | switch (command) { 334 | case 'moveTo': 335 | res.moveTo(...args); 336 | cx = sx = args[0]; 337 | cy = sy = args[1]; 338 | break; 339 | 340 | case 'lineTo': 341 | res.lineTo(...args); 342 | cx = args[0]; 343 | cy = args[1]; 344 | break; 345 | 346 | case 'quadraticCurveTo': 347 | args = quadraticToBezier(cx, cy, ...args); 348 | // fall through! 349 | 350 | case 'bezierCurveTo': 351 | subdivideBezierWithFlatness(res, 0.6, cx, cy, ...args); 352 | cx = args[4]; 353 | cy = args[5]; 354 | break; 355 | 356 | case 'closePath': 357 | cx = sx; 358 | cy = sy; 359 | res.closePath(); 360 | break; 361 | 362 | default: 363 | throw new Error(`Unknown path command: ${command}`); 364 | } 365 | } 366 | 367 | return res; 368 | } 369 | 370 | get isClockwise() { 371 | // Source: http://stackoverflow.com/questions/1165647/how-to-determine-if-a-list-of-polygon-points-are-in-clockwise-order 372 | // Original solution define that f the sum is positive, the points are in clockwise order. 373 | // We check for the opposite condition because we are in an inverted cartesian coordinate system 374 | let sx = 0; 375 | let sy = 0; 376 | let cx = 0; 377 | let cy = 0; 378 | let sum = 0; 379 | 380 | const path = this.flatten(); 381 | 382 | for (const { command, args } of path.commands) { 383 | const [x, y] = args; 384 | switch (command) { 385 | case 'moveTo': 386 | cx = x; 387 | cy = y; 388 | sx = x; 389 | sy = y; 390 | break; 391 | 392 | case 'lineTo': 393 | sum += (x - cx) * (cy + y); 394 | 395 | cx = x; 396 | cy = y; 397 | break; 398 | 399 | case 'closePath': 400 | sum += (sx - cx) * (sy + cy); 401 | break; 402 | 403 | default: 404 | throw new Error(`Unknown path command: ${command}`); 405 | } 406 | } 407 | 408 | return sum < 0; 409 | } 410 | 411 | reverse() { 412 | const res = new Path(); 413 | const commands = this.commands; 414 | let start = commands[0]; 415 | 416 | for (let i = 1; i < commands.length; i++) { 417 | let { command, args } = commands[i]; 418 | if (command !== 'moveTo' && i + 1 < commands.length) { 419 | continue; 420 | } 421 | 422 | let closed = false; 423 | let j = i; 424 | 425 | if (command === 'moveTo') { 426 | j--; 427 | } 428 | 429 | const move = commands[j].command === 'closePath' ? start : commands[j]; 430 | res.moveTo(move.args[0], move.args[1]); 431 | 432 | for (; commands[j].command !== 'moveTo'; j--) { 433 | const prev = commands[j - 1]; 434 | const cur = commands[j]; 435 | const px = prev.args[prev.args.length - 2]; 436 | const py = prev.args[prev.args.length - 1]; 437 | 438 | switch (cur.command) { 439 | case 'lineTo': 440 | if (closed && prev.command === 'moveTo') { 441 | res.closePath(); 442 | } else { 443 | res.lineTo(px, py); 444 | } 445 | break; 446 | 447 | case 'bezierCurveTo': 448 | res.bezierCurveTo(cur.args[2], cur.args[3], cur.args[0], cur.args[1], px, py); 449 | if (closed && prev.command === 'moveTo') { 450 | prev.closePath(); 451 | } 452 | break; 453 | 454 | case 'quadraticCurveTo': 455 | res.quadraticCurveTo(cur.args[0], cur.args[1], px, py); 456 | if (closed && prev.command === 'moveTo') { 457 | prev.closePath(); 458 | } 459 | break; 460 | 461 | case 'closePath': 462 | closed = true; 463 | res.lineTo(px, py); 464 | break; 465 | 466 | default: 467 | throw new Error(`Unknown path command: ${command}`); 468 | } 469 | } 470 | 471 | start = commands[i]; 472 | } 473 | 474 | return res; 475 | } 476 | 477 | toPolygon() { 478 | // Flatten and canonicalize the path. 479 | let path = this.flatten(); 480 | if (!path.isClockwise) { 481 | path = path.reverse(); 482 | } 483 | 484 | let contour = []; 485 | const polygon = new Polygon(); 486 | 487 | for (let { command, args } of path.commands) { 488 | switch (command) { 489 | case 'moveTo': 490 | if (contour.length) { 491 | polygon.addContour(contour); 492 | contour = []; 493 | } 494 | 495 | contour.push(new Point(args[0], args[1])); 496 | break; 497 | 498 | case 'lineTo': 499 | contour.push(new Point(args[0], args[1])); 500 | break; 501 | 502 | case 'closePath': 503 | if (contour.length) { 504 | polygon.addContour(contour); 505 | contour = []; 506 | } 507 | break; 508 | 509 | default: 510 | throw new Error(`Unsupported path command: ${command}`); 511 | } 512 | } 513 | 514 | return polygon; 515 | } 516 | } 517 | 518 | for (const command of Object.keys(SVG_COMMANDS)) { 519 | Path.prototype[command] = function(...args) { 520 | this._bbox = this._cbox = null; 521 | this.commands.push({ 522 | command, 523 | args 524 | }); 525 | 526 | if (command === 'bezierCurveTo') { 527 | this._bezier = true; 528 | } else if (command === 'quadraticCurveTo') { 529 | this._quadratic = true; 530 | } 531 | 532 | return this; 533 | }; 534 | } 535 | 536 | function quadraticToBezier(cx, cy, qp1x, qp1y, x, y) { 537 | // http://fontforge.org/bezier.html 538 | const cp1x = cx + 2 / 3 * (qp1x - cx); // CP1 = QP0 + 2/3 * (QP1-QP0) 539 | const cp1y = cy + 2 / 3 * (qp1y - cy); 540 | const cp2x = x + 2 / 3 * (qp1x - x); // CP2 = QP2 + 2/3 * (QP1-QP2) 541 | const cp2y = y + 2 / 3 * (qp1y - y); 542 | return [cp1x, cp1y, cp2x, cp2y, x, y]; 543 | } 544 | 545 | function subdivideBezierWithFlatness(path, flatness, cx, cy, cp1x, cp1y, cp2x, cp2y, x, y) { 546 | const dx1 = cp1x - cx; 547 | const dx2 = cp2x - cp1x; 548 | const dx3 = x - cp2x; 549 | const dx4 = dx2 - dx1; 550 | const dx5 = dx3 - dx2; 551 | const dx6 = dx5 - dx4; 552 | 553 | const dy1 = cp1y - cy; 554 | const dy2 = cp2y - cp1y; 555 | const dy3 = y - cp2y; 556 | const dy4 = dy2 - dy1; 557 | const dy5 = dy3 - dy2; 558 | const dy6 = dy5 - dy4; 559 | 560 | const d1 = dx4 * dx4 + dy4 * dy4; 561 | const d2 = dx5 * dx5 + dy5 * dy5; 562 | const flatnessSqr = flatness * flatness; 563 | let wat = 9 * Math.max(d1, d2) / 16; 564 | 565 | let wat2 = 6 * dx6; 566 | let wat3 = 6 * (dx4 + dx6); 567 | let wat4 = 3 * (dx1 + dx4) + dx6; 568 | 569 | let wat5 = 6 * dy6; 570 | let wat6 = 6 * (dy4 + dy6); 571 | let wat7 = 3 * (dy1 + dy4) + dy6; 572 | 573 | let f = 1; 574 | 575 | while (wat > flatnessSqr && f <= 65535) { 576 | wat2 /= 8; 577 | wat3 = wat3 / 4 - wat2; 578 | wat4 = wat4 / 2 - wat3 / 2; 579 | 580 | wat5 /= 8; 581 | wat6 = wat6 / 4 - wat5; 582 | wat7 = wat7 / 2 - wat6 / 2; 583 | 584 | wat /= 16; 585 | f <<= 1; 586 | } 587 | 588 | while (--f > 0) { 589 | cx += wat4; 590 | wat4 += wat3; 591 | wat3 += wat2; 592 | 593 | cy += wat7; 594 | wat7 += wat6; 595 | wat6 += wat5; 596 | 597 | path.lineTo(cx, cy); 598 | } 599 | 600 | path.lineTo(x, y); 601 | } 602 | 603 | export default Path; 604 | -------------------------------------------------------------------------------- /packages/core/src/geom/Point.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents a 2d point 3 | */ 4 | class Point { 5 | /** @public */ 6 | constructor(x = 0, y = 0) { 7 | /** 8 | * The x-coordinate of the point 9 | * @type {number} 10 | */ 11 | this.x = x; 12 | 13 | /** 14 | * The y-coordinate of the point 15 | * @type {number} 16 | */ 17 | this.y = y; 18 | } 19 | 20 | /** 21 | * Returns a copy of this point 22 | * @return {Point} 23 | */ 24 | copy() { 25 | return new Point(this.x, this.y); 26 | } 27 | 28 | transform(m0, m1, m2, m3, m4, m5) { 29 | const x = m0 * this.x + m2 * this.y + m4; 30 | const y = m1 * this.x + m3 * this.y + m5; 31 | return new Point(x, y); 32 | } 33 | } 34 | 35 | export default Point; 36 | -------------------------------------------------------------------------------- /packages/core/src/geom/Polygon.js: -------------------------------------------------------------------------------- 1 | class Polygon { 2 | constructor() { 3 | this.contours = []; 4 | } 5 | 6 | addContour(contour) { 7 | this.contours.push(contour); 8 | } 9 | } 10 | 11 | export default Polygon; 12 | -------------------------------------------------------------------------------- /packages/core/src/geom/Rect.js: -------------------------------------------------------------------------------- 1 | import Point from './Point'; 2 | 3 | const CORNERS = ['topLeft', 'topRight', 'bottomLeft', 'bottomRight']; 4 | 5 | /** 6 | * Represents a rectangle 7 | */ 8 | class Rect { 9 | /** @public */ 10 | constructor(x = 0, y = 0, width = 0, height = 0) { 11 | /** 12 | * The x-coordinate of the rectangle 13 | * @type {number} 14 | */ 15 | this.x = x; 16 | 17 | /** 18 | * The y-coordinate of the rectangle 19 | * @type {number} 20 | */ 21 | this.y = y; 22 | 23 | /** 24 | * The width of the rectangle 25 | * @type {number} 26 | */ 27 | this.width = width; 28 | 29 | /** 30 | * The height of the rectangle 31 | * @type {number} 32 | */ 33 | this.height = height; 34 | } 35 | 36 | /** 37 | * The maximum x-coordinate in the rectangle 38 | * @type {number} 39 | */ 40 | get maxX() { 41 | return this.x + this.width; 42 | } 43 | 44 | /** 45 | * The maximum y-coordinate in the rectangle 46 | * @type {number} 47 | */ 48 | get maxY() { 49 | return this.y + this.height; 50 | } 51 | 52 | /** 53 | * The area of the rectangle 54 | * @type {number} 55 | */ 56 | get area() { 57 | return this.width * this.height; 58 | } 59 | 60 | /** 61 | * The top left corner of the rectangle 62 | * @type {Point} 63 | */ 64 | get topLeft() { 65 | return new Point(this.x, this.y); 66 | } 67 | 68 | /** 69 | * The top right corner of the rectangle 70 | * @type {Point} 71 | */ 72 | get topRight() { 73 | return new Point(this.maxX, this.y); 74 | } 75 | 76 | /** 77 | * The bottom left corner of the rectangle 78 | * @type {Point} 79 | */ 80 | get bottomLeft() { 81 | return new Point(this.x, this.maxY); 82 | } 83 | 84 | /** 85 | * The bottom right corner of the rectangle 86 | * @type {Point} 87 | */ 88 | get bottomRight() { 89 | return new Point(this.maxX, this.maxY); 90 | } 91 | 92 | /** 93 | * Returns whether this rectangle intersects another rectangle 94 | * @param {Rect} rect - The rectangle to check 95 | * @return {boolean} 96 | */ 97 | intersects(rect) { 98 | return ( 99 | this.x <= rect.x + rect.width && 100 | rect.x <= this.x + this.width && 101 | this.y <= rect.y + rect.height && 102 | rect.y <= this.y + this.height 103 | ); 104 | } 105 | 106 | /** 107 | * Returns whether this rectangle fully contains another rectangle 108 | * @param {Rect} rect - The rectangle to check 109 | * @return {boolean} 110 | */ 111 | containsRect(rect) { 112 | return this.x <= rect.x && this.y <= rect.y && this.maxX >= rect.maxX && this.maxY >= rect.maxY; 113 | } 114 | 115 | /** 116 | * Returns whether the rectangle contains the given point 117 | * @param {Point} point - The point to check 118 | * @return {boolean} 119 | */ 120 | containsPoint(point) { 121 | return this.x <= point.x && this.y <= point.y && this.maxX >= point.x && this.maxY >= point.y; 122 | } 123 | 124 | /** 125 | * Returns the first corner of this rectangle (from top to bottom, left to right) 126 | * that is contained in the given rectangle, or null of the rectangles do not intersect. 127 | * @param {Rect} rect - The rectangle to check 128 | * @return {string} 129 | */ 130 | getCornerInRect(rect) { 131 | for (const key of CORNERS) { 132 | if (rect.containsPoint(this[key])) { 133 | return key; 134 | } 135 | } 136 | 137 | return null; 138 | } 139 | 140 | equals(rect) { 141 | return ( 142 | rect.x === this.x && 143 | rect.y === this.y && 144 | rect.width === this.width && 145 | rect.height === this.height 146 | ); 147 | } 148 | 149 | pointEquals(point) { 150 | return this.x === point.x && this.y === point.y; 151 | } 152 | 153 | sizeEquals(size) { 154 | return this.width === size.width && this.height === size.height; 155 | } 156 | 157 | /** 158 | * Returns a copy of this rectangle 159 | * @return {Rect} 160 | */ 161 | copy() { 162 | return new Rect(this.x, this.y, this.width, this.height); 163 | } 164 | } 165 | 166 | export default Rect; 167 | -------------------------------------------------------------------------------- /packages/core/src/geom/index.js: -------------------------------------------------------------------------------- 1 | export BBox from './BBox'; 2 | export Path from './Path'; 3 | export Rect from './Rect'; 4 | export Point from './Point'; 5 | export Polygon from './Polygon'; 6 | -------------------------------------------------------------------------------- /packages/core/src/index.js: -------------------------------------------------------------------------------- 1 | export { BBox, Path, Rect, Point, Polygon } from './geom'; 2 | export { 3 | Run, 4 | Block, 5 | Range, 6 | TabStop, 7 | RunStyle, 8 | GlyphRun, 9 | Container, 10 | Attachment, 11 | GlyphString, 12 | LineFragment, 13 | ParagraphStyle, 14 | DecorationLine, 15 | FontDescriptor, 16 | AttributedString 17 | } from './models'; 18 | export LayoutEngine from './layout/LayoutEngine'; 19 | -------------------------------------------------------------------------------- /packages/core/src/layout/GlyphGenerator.js: -------------------------------------------------------------------------------- 1 | import GlyphRun from '../models/GlyphRun'; 2 | import GlyphString from '../models/GlyphString'; 3 | import Run from '../models/Run'; 4 | import RunStyle from '../models/RunStyle'; 5 | import flattenRuns from './flattenRuns'; 6 | 7 | /** 8 | * A GlyphGenerator is responsible for mapping characters in 9 | * an AttributedString to glyphs in a GlyphString. It resolves 10 | * style attributes such as the font and Unicode script and 11 | * directionality properties, and creates GlyphRuns using fontkit. 12 | */ 13 | export default class GlyphGenerator { 14 | constructor(engines = {}) { 15 | this.resolvers = [engines.fontSubstitutionEngine, engines.scriptItemizer]; 16 | } 17 | 18 | generateGlyphs(attributedString) { 19 | // Resolve runs 20 | const runs = this.resolveRuns(attributedString); 21 | 22 | // Generate glyphs 23 | let glyphIndex = 0; 24 | const glyphRuns = runs.map(run => { 25 | const str = attributedString.string.slice(run.start, run.end); 26 | const glyphRun = run.attributes.font.layout( 27 | str, 28 | run.attributes.features, 29 | run.attributes.script 30 | ); 31 | const end = glyphIndex + glyphRun.glyphs.length; 32 | const glyphIndices = this.resolveGlyphIndices(str, glyphRun.stringIndices); 33 | 34 | const res = new GlyphRun( 35 | glyphIndex, 36 | end, 37 | run.attributes, 38 | glyphRun.glyphs, 39 | glyphRun.positions, 40 | glyphRun.stringIndices, 41 | glyphIndices 42 | ); 43 | 44 | this.resolveAttachments(res); 45 | this.resolveYOffset(res); 46 | 47 | glyphIndex = end; 48 | return res; 49 | }); 50 | 51 | return new GlyphString(attributedString.string, glyphRuns); 52 | } 53 | 54 | resolveGlyphIndices(string, stringIndices) { 55 | const glyphIndices = []; 56 | 57 | for (let i = 0; i < string.length; i++) { 58 | for (let j = 0; j < stringIndices.length; j++) { 59 | if (stringIndices[j] >= i) { 60 | glyphIndices[i] = j; 61 | break; 62 | } 63 | 64 | glyphIndices[i] = undefined; 65 | } 66 | } 67 | 68 | let lastValue = glyphIndices[glyphIndices.length - 1]; 69 | for (let i = glyphIndices.length - 1; i >= 0; i--) { 70 | if (glyphIndices[i] === undefined) { 71 | glyphIndices[i] = lastValue; 72 | } else { 73 | lastValue = glyphIndices[i]; 74 | } 75 | } 76 | 77 | lastValue = glyphIndices[0]; 78 | for (let i = 0; i < glyphIndices.length; i++) { 79 | if (glyphIndices[i] === undefined) { 80 | glyphIndices[i] = lastValue; 81 | } else { 82 | lastValue = glyphIndices[i]; 83 | } 84 | } 85 | 86 | return glyphIndices; 87 | } 88 | 89 | resolveRuns(attributedString) { 90 | // Map attributes to RunStyle objects 91 | const r = attributedString.runs.map( 92 | run => new Run(run.start, run.end, new RunStyle(run.attributes)) 93 | ); 94 | 95 | // Resolve run ranges and additional attributes 96 | const runs = []; 97 | for (const resolver of this.resolvers) { 98 | const resolved = resolver.getRuns(attributedString.string, r); 99 | runs.push(...resolved); 100 | } 101 | 102 | // Ignore resolved properties 103 | const styles = attributedString.runs.map(run => { 104 | const attrs = Object.assign({}, run.attributes); 105 | delete attrs.font; 106 | delete attrs.fontDescriptor; 107 | return new Run(run.start, run.end, attrs); 108 | }); 109 | 110 | // Flatten runs 111 | const resolvedRuns = flattenRuns([...styles, ...runs]); 112 | for (const run of resolvedRuns) { 113 | run.attributes = new RunStyle(run.attributes); 114 | } 115 | 116 | return resolvedRuns; 117 | } 118 | 119 | resolveAttachments(glyphRun) { 120 | const { font, attachment } = glyphRun.attributes; 121 | 122 | if (!attachment) { 123 | return; 124 | } 125 | 126 | const objectReplacement = font.glyphForCodePoint(0xfffc); 127 | 128 | for (let i = 0; i < glyphRun.length; i++) { 129 | const glyph = glyphRun.glyphs[i]; 130 | const position = glyphRun.positions[i]; 131 | 132 | if (glyph === objectReplacement) { 133 | position.xAdvance = attachment.width; 134 | } 135 | } 136 | } 137 | 138 | resolveYOffset(glyphRun) { 139 | const { font, yOffset } = glyphRun.attributes; 140 | 141 | if (!yOffset) { 142 | return; 143 | } 144 | 145 | for (let i = 0; i < glyphRun.length; i++) { 146 | glyphRun.positions[i].yOffset += yOffset * font.unitsPerEm; 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /packages/core/src/layout/LayoutEngine.js: -------------------------------------------------------------------------------- 1 | import ParagraphStyle from '../models/ParagraphStyle'; 2 | import Rect from '../geom/Rect'; 3 | import Block from '../models/Block'; 4 | import GlyphGenerator from './GlyphGenerator'; 5 | import Typesetter from './Typesetter'; 6 | import injectEngines from './injectEngines'; 7 | 8 | // 1. split into paragraphs 9 | // 2. get bidi runs and paragraph direction 10 | // 3. font substitution - map to resolved font runs 11 | // 4. script itemization 12 | // 5. font shaping - text to glyphs 13 | // 6. line breaking 14 | // 7. bidi reordering 15 | // 8. justification 16 | 17 | // 1. get a list of rectangles by intersecting path, line, and exclusion paths 18 | // 2. perform line breaking to get acceptable break points for each fragment 19 | // 3. ellipsize line if necessary 20 | // 4. bidi reordering 21 | // 5. justification 22 | 23 | /** 24 | * A LayoutEngine is the main object that performs text layout. 25 | * It accepts an AttributedString and a list of Container objects 26 | * to layout text into, and uses several helper objects to perform 27 | * various layout tasks. These objects can be overridden to customize 28 | * layout behavior. 29 | */ 30 | export default class LayoutEngine { 31 | constructor(engines) { 32 | const injectedEngines = injectEngines(engines); 33 | this.glyphGenerator = new GlyphGenerator(injectedEngines); 34 | this.typesetter = new Typesetter(injectedEngines); 35 | } 36 | 37 | layout(attributedString, containers) { 38 | let start = 0; 39 | 40 | for (let i = 0; i < containers.length && start < attributedString.length; i++) { 41 | const container = containers[i]; 42 | const { bbox, columns, columnGap } = container; 43 | const isLastContainer = i === containers.length - 1; 44 | const columnWidth = (bbox.width - columnGap * (columns - 1)) / columns; 45 | const rect = new Rect(bbox.minX, bbox.minY, columnWidth, bbox.height); 46 | 47 | for (let j = 0; j < container.columns && start < attributedString.length; j++) { 48 | start = this.layoutColumn(attributedString, start, container, rect.copy(), isLastContainer); 49 | rect.x += columnWidth + container.columnGap; 50 | } 51 | } 52 | } 53 | 54 | layoutColumn(attributedString, start, container, rect, isLastContainer) { 55 | while (start < attributedString.length && rect.height > 0) { 56 | let next = attributedString.string.indexOf('\n', start); 57 | if (next === -1) next = attributedString.string.length; 58 | 59 | const paragraph = attributedString.slice(start, next); 60 | const block = this.layoutParagraph(paragraph, container, rect, start, isLastContainer); 61 | const paragraphHeight = block.bbox.height + block.style.paragraphSpacing; 62 | 63 | container.blocks.push(block); 64 | 65 | rect.y += paragraphHeight; 66 | rect.height -= paragraphHeight; 67 | start += paragraph.length + 1; 68 | 69 | // If entire paragraph did not fit, move on to the next column or container. 70 | if (start < next) break; 71 | } 72 | 73 | return start; 74 | } 75 | 76 | layoutParagraph(attributedString, container, rect, stringOffset, isLastContainer) { 77 | const glyphString = this.glyphGenerator.generateGlyphs(attributedString); 78 | const paragraphStyle = new ParagraphStyle(attributedString.runs[0].attributes); 79 | const { marginLeft, marginRight, indent, maxLines, lineSpacing } = paragraphStyle; 80 | 81 | const lineRect = new Rect( 82 | rect.x + marginLeft + indent, 83 | rect.y, 84 | rect.width - marginLeft - indent - marginRight, 85 | glyphString.height 86 | ); 87 | 88 | let pos = 0; 89 | let lines = 0; 90 | let firstLine = true; 91 | const fragments = []; 92 | 93 | while (lineRect.y < rect.maxY && pos < glyphString.length && lines < maxLines) { 94 | const lineFragments = this.typesetter.layoutLineFragments( 95 | pos, 96 | lineRect, 97 | glyphString, 98 | container, 99 | paragraphStyle, 100 | stringOffset 101 | ); 102 | 103 | lineRect.y += lineRect.height + lineSpacing; 104 | 105 | if (lineFragments.length > 0) { 106 | fragments.push(...lineFragments); 107 | pos = lineFragments[lineFragments.length - 1].end; 108 | lines++; 109 | 110 | if (firstLine) { 111 | lineRect.x -= indent; 112 | lineRect.width += indent; 113 | firstLine = false; 114 | } 115 | } 116 | } 117 | 118 | // Add empty line fragment for empty glyph strings 119 | if (glyphString.length === 0) { 120 | const newLineFragment = this.typesetter.layoutLineFragments( 121 | pos, 122 | lineRect, 123 | glyphString, 124 | container, 125 | paragraphStyle 126 | ); 127 | 128 | fragments.push(...newLineFragment); 129 | } 130 | 131 | const isTruncated = isLastContainer && pos < glyphString.length; 132 | fragments.forEach((fragment, i) => { 133 | const isLastFragment = i === fragments.length - 1 && pos === glyphString.length; 134 | 135 | this.typesetter.finalizeLineFragment(fragment, paragraphStyle, isLastFragment, isTruncated); 136 | }); 137 | 138 | return new Block(fragments, paragraphStyle); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /packages/core/src/layout/Typesetter.js: -------------------------------------------------------------------------------- 1 | import LineFragment from '../models/LineFragment'; 2 | 3 | const ALIGNMENT_FACTORS = { 4 | left: 0, 5 | center: 0.5, 6 | right: 1, 7 | justify: 0 8 | }; 9 | 10 | /** 11 | * A Typesetter performs glyph line layout, including line breaking, 12 | * hyphenation, justification, truncation, hanging punctuation, 13 | * and text decoration. It uses several underlying objects to perform 14 | * these tasks, which could be overridden in order to customize the 15 | * typesetter's behavior. 16 | */ 17 | export default class Typesetter { 18 | constructor(engines = {}) { 19 | this.lineBreaker = engines.lineBreaker; 20 | this.lineFragmentGenerator = engines.lineFragmentGenerator; 21 | this.justificationEngine = engines.justificationEngine; 22 | this.truncationEngine = engines.truncationEngine; 23 | this.decorationEngine = engines.decorationEngine; 24 | this.tabEngine = engines.tabEngine; 25 | } 26 | 27 | layoutLineFragments(start, lineRect, glyphString, container, paragraphStyle, stringOffset) { 28 | const lineString = glyphString.slice(start, glyphString.length); 29 | 30 | // Guess the line height using the full line before intersecting with the container. 31 | lineRect.height = lineString.slice(0, lineString.glyphIndexAtOffset(lineRect.width)).height; 32 | 33 | // Generate line fragment rectangles by intersecting with the container. 34 | const fragmentRects = this.lineFragmentGenerator.generateFragments(lineRect, container); 35 | 36 | if (fragmentRects.length === 0) return []; 37 | 38 | let pos = 0; 39 | const lineFragments = []; 40 | let lineHeight = paragraphStyle.lineHeight; 41 | 42 | for (const fragmentRect of fragmentRects) { 43 | const line = lineString.slice(pos, lineString.length); 44 | 45 | if (this.tabEngine) { 46 | this.tabEngine.processLineFragment(line, container); 47 | } 48 | 49 | const bk = this.lineBreaker.suggestLineBreak(line, fragmentRect.width, paragraphStyle); 50 | 51 | if (bk) { 52 | bk.position += pos; 53 | 54 | const lineFragment = new LineFragment(fragmentRect, lineString.slice(pos, bk.position)); 55 | 56 | lineFragment.stringStart = 57 | stringOffset + glyphString.stringIndexForGlyphIndex(lineFragment.start); 58 | lineFragment.stringEnd = 59 | stringOffset + glyphString.stringIndexForGlyphIndex(lineFragment.end); 60 | 61 | lineFragments.push(lineFragment); 62 | lineHeight = Math.max(lineHeight, lineFragment.height); 63 | 64 | pos = bk.position; 65 | if (pos >= lineString.length) { 66 | break; 67 | } 68 | } 69 | } 70 | 71 | // Update the fragments on this line with the computed line height 72 | if (lineHeight !== 0) lineRect.height = lineHeight; 73 | 74 | for (const fragment of lineFragments) { 75 | fragment.rect.height = lineHeight; 76 | } 77 | 78 | return lineFragments; 79 | } 80 | 81 | finalizeLineFragment(lineFragment, paragraphStyle, isLastFragment, isTruncated) { 82 | const align = 83 | isLastFragment && !isTruncated ? paragraphStyle.alignLastLine : paragraphStyle.align; 84 | 85 | if (isLastFragment && isTruncated && paragraphStyle.truncationMode) { 86 | this.truncationEngine.truncate(lineFragment, paragraphStyle.truncationMode); 87 | } 88 | 89 | this.adjustLineFragmentRectangle(lineFragment, paragraphStyle, align); 90 | 91 | if (align === 'justify' || lineFragment.advanceWidth > lineFragment.rect.width) { 92 | this.justificationEngine.justify(lineFragment, { 93 | factor: paragraphStyle.justificationFactor 94 | }); 95 | } 96 | 97 | this.decorationEngine.createDecorationLines(lineFragment); 98 | } 99 | 100 | adjustLineFragmentRectangle(lineFragment, paragraphStyle, align) { 101 | let start = 0; 102 | let end = lineFragment.length; 103 | 104 | // Ignore whitespace at the start and end of a line for alignment 105 | while (lineFragment.isWhiteSpace(start)) { 106 | lineFragment.overflowLeft += lineFragment.getGlyphWidth(start++); 107 | } 108 | 109 | while (lineFragment.isWhiteSpace(end - 1)) { 110 | lineFragment.overflowRight += lineFragment.getGlyphWidth(--end); 111 | } 112 | 113 | // Adjust line rect for hanging punctuation 114 | if (paragraphStyle.hangingPunctuation) { 115 | if (align === 'left' || align === 'justify') { 116 | if (lineFragment.isHangingPunctuationStart(start)) { 117 | lineFragment.overflowLeft += lineFragment.getGlyphWidth(start++); 118 | } 119 | } 120 | 121 | if (align === 'right' || align === 'justify') { 122 | if (lineFragment.isHangingPunctuationEnd(end - 1)) { 123 | lineFragment.overflowRight += lineFragment.getGlyphWidth(--end); 124 | } 125 | } 126 | } 127 | 128 | lineFragment.rect.x -= lineFragment.overflowLeft; 129 | lineFragment.rect.width += lineFragment.overflowLeft + lineFragment.overflowRight; 130 | 131 | // Adjust line offset for alignment 132 | const remainingWidth = lineFragment.rect.width - lineFragment.advanceWidth; 133 | lineFragment.rect.x += remainingWidth * ALIGNMENT_FACTORS[align]; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /packages/core/src/layout/flattenRuns.js: -------------------------------------------------------------------------------- 1 | import Run from '../models/Run'; 2 | 3 | const flattenRuns = (runs = []) => { 4 | const regularRuns = runs.filter(run => run.start !== run.end); 5 | const emptyRuns = runs.filter(run => run.start === run.end); 6 | 7 | const regularFlattenRuns = flattenRegularRuns(regularRuns); 8 | const emptyFlattenRuns = flattenEmptyRuns(emptyRuns); 9 | const sortRuns = (a, b) => a.start - b.start || a.end - b.end; 10 | 11 | return [...regularFlattenRuns, ...emptyFlattenRuns].sort(sortRuns); 12 | }; 13 | 14 | const flattenEmptyRuns = runs => { 15 | const points = runs.reduce((acc, run) => { 16 | if (!acc.includes(run.start)) { 17 | return [...acc, run.start]; 18 | } 19 | 20 | return acc; 21 | }, []); 22 | 23 | return points.map(point => { 24 | const pointRuns = runs.filter(run => run.start === point); 25 | const attrs = pointRuns.reduce((acc, run) => Object.assign({}, acc, run.attributes), {}); 26 | 27 | return new Run(point, point, attrs); 28 | }); 29 | }; 30 | 31 | const flattenRegularRuns = runs => { 32 | const res = []; 33 | const points = []; 34 | 35 | for (let i = 0; i < runs.length; i++) { 36 | const run = runs[i]; 37 | points.push(['start', run.start, run.attributes, i]); 38 | points.push(['end', run.end, run.attributes, i]); 39 | } 40 | 41 | points.sort((a, b) => a[1] - b[1] || a[3] - b[3]); 42 | 43 | let start = -1; 44 | let attrs = {}; 45 | const stack = []; 46 | 47 | for (const [type, offset, attributes] of points) { 48 | if (start !== -1 && start < offset) { 49 | res.push(new Run(start, offset, attrs)); 50 | } 51 | 52 | if (type === 'start') { 53 | stack.push(attributes); 54 | attrs = Object.assign({}, attrs, attributes); 55 | } else { 56 | attrs = {}; 57 | 58 | for (let i = 0; i < stack.length; i++) { 59 | if (stack[i] === attributes) { 60 | stack.splice(i--, 1); 61 | } else { 62 | Object.assign(attrs, stack[i]); 63 | } 64 | } 65 | } 66 | 67 | start = offset; 68 | } 69 | 70 | return res; 71 | }; 72 | 73 | export default flattenRuns; 74 | -------------------------------------------------------------------------------- /packages/core/src/layout/injectEngines.js: -------------------------------------------------------------------------------- 1 | import * as Geom from '../geom'; 2 | import * as Models from '../models'; 3 | 4 | const Textkit = Object.assign({}, Geom, Models); 5 | 6 | const generateEngine = (name, callback) => { 7 | if (!callback) { 8 | console.warn(`Warning: You must provide a ${name} engine`); 9 | return null; 10 | } 11 | 12 | const Engine = callback(Textkit); 13 | 14 | return new Engine(); 15 | }; 16 | 17 | const injectEngines = engines => { 18 | const engineNames = Object.keys(engines); 19 | 20 | return engineNames.reduce( 21 | (acc, name) => Object.assign({}, acc, { [name]: generateEngine(name, engines[name]) }), 22 | {} 23 | ); 24 | }; 25 | 26 | export default injectEngines; 27 | -------------------------------------------------------------------------------- /packages/core/src/models/Attachment.js: -------------------------------------------------------------------------------- 1 | class Attachment { 2 | static CODEPOINT = 0xfffc; 3 | static CHARACTER = '\ufffc'; 4 | 5 | constructor(width, height, options = {}) { 6 | this.width = width; 7 | this.height = height; 8 | this.image = options.image || null; 9 | this.render = options.render || null; 10 | this.xOffset = options.xOffset || 0; 11 | this.yOffset = options.yOffset || 0; 12 | } 13 | } 14 | 15 | export default Attachment; 16 | -------------------------------------------------------------------------------- /packages/core/src/models/AttributedString.js: -------------------------------------------------------------------------------- 1 | import Run from './Run'; 2 | 3 | class AttributedString { 4 | constructor(string = '', runs = []) { 5 | this.string = string; 6 | this.runs = runs; 7 | this.length = string.length; 8 | } 9 | 10 | static fromFragments(fragments = []) { 11 | let string = ''; 12 | let offset = 0; 13 | const runs = []; 14 | 15 | fragments.forEach(fragment => { 16 | string += fragment.string; 17 | runs.push(new Run(offset, offset + fragment.string.length, fragment.attributes)); 18 | offset += fragment.string.length; 19 | }); 20 | 21 | return new AttributedString(string, runs); 22 | } 23 | 24 | runIndexAt(index) { 25 | for (let i = 0; i < this.runs.length; i++) { 26 | if (this.runs[i].start <= index && index < this.runs[i].end) { 27 | return i; 28 | } 29 | } 30 | 31 | return this.runs.length - 1; 32 | } 33 | 34 | trim() { 35 | let i; 36 | for (i = this.string.length - 1; i >= 0; i--) { 37 | if (this.string[i] !== ' ') { 38 | break; 39 | } 40 | } 41 | 42 | return this.slice(0, i + 1); 43 | } 44 | 45 | slice(start, end) { 46 | if (this.string.length === 0) return this; 47 | 48 | const startRunIndex = this.runIndexAt(start); 49 | const endRunIndex = Math.max(this.runIndexAt(Math.max(end - 1, 0)), startRunIndex); 50 | const startRun = this.runs[startRunIndex]; 51 | const endRun = this.runs[endRunIndex]; 52 | const runs = []; 53 | 54 | runs.push(startRun.slice(start - startRun.start, end - startRun.start)); 55 | 56 | if (startRunIndex !== endRunIndex) { 57 | runs.push(...this.runs.slice(startRunIndex + 1, endRunIndex).map(r => r.copy())); 58 | 59 | if (endRun.start !== 0) { 60 | runs.push(endRun.slice(0, end - endRun.start)); 61 | } 62 | } 63 | 64 | for (const run of runs) { 65 | run.start -= start; 66 | run.end -= start; 67 | } 68 | 69 | return new AttributedString(this.string.slice(start, end), runs); 70 | } 71 | } 72 | 73 | export default AttributedString; 74 | -------------------------------------------------------------------------------- /packages/core/src/models/Block.js: -------------------------------------------------------------------------------- 1 | import BBox from '../geom/BBox'; 2 | 3 | export default class Block { 4 | constructor(lines = [], style = {}) { 5 | this.lines = lines; 6 | this.style = style; 7 | } 8 | 9 | get bbox() { 10 | const bbox = new BBox(); 11 | for (const line of this.lines) { 12 | bbox.addRect(line.rect); 13 | } 14 | 15 | return bbox; 16 | } 17 | 18 | get height() { 19 | let height = 0; 20 | for (const line of this.lines) { 21 | height += line.height; 22 | } 23 | 24 | return height; 25 | } 26 | 27 | get stringLength() { 28 | let length = 0; 29 | for (const line of this.lines) { 30 | length += line.string.length; 31 | } 32 | 33 | return length; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/core/src/models/Container.js: -------------------------------------------------------------------------------- 1 | import Path from '../geom/Path'; 2 | 3 | export default class Container { 4 | constructor(path, options = {}) { 5 | this.path = path; 6 | this.exclusionPaths = options.exclusionPaths || []; 7 | this.tabStops = options.tabStops || []; 8 | this.tabStopInterval = options.tabStopInterval || 80; 9 | this.columns = options.columns || 1; 10 | this.columnGap = options.columnGap || 18; // 1/4 inch 11 | this.blocks = []; 12 | } 13 | 14 | get bbox() { 15 | return this.path.bbox; 16 | } 17 | 18 | get polygon() { 19 | return this.path.toPolygon(); 20 | } 21 | 22 | get exclusionPolygon() { 23 | if (!this.exclusionPaths.length) { 24 | return null; 25 | } 26 | 27 | const excluded = new Path(); 28 | for (const p of this.exclusionPaths) { 29 | excluded.append(p); 30 | } 31 | 32 | return excluded.toPolygon(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/core/src/models/DecorationLine.js: -------------------------------------------------------------------------------- 1 | import Rect from '../geom/Rect'; 2 | 3 | export default class DecorationLine { 4 | constructor(rect, color, style) { 5 | this.rect = rect; 6 | this.color = color || 'black'; 7 | this.style = style || 'solid'; 8 | } 9 | 10 | merge(line) { 11 | if (this.rect.maxX === line.rect.x && this.rect.y === line.rect.y) { 12 | this.rect.height = line.rect.height = Math.max(this.rect.height, line.rect.height); 13 | 14 | if (this.color === line.color) { 15 | this.rect.width += line.rect.width; 16 | return true; 17 | } 18 | } 19 | 20 | return false; 21 | } 22 | 23 | slice(startX, endX) { 24 | const rect = new Rect(startX, this.rect.y, endX - startX, this.rect.height); 25 | return new DecorationLine(rect, this.color, this.style); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/core/src/models/FontDescriptor.js: -------------------------------------------------------------------------------- 1 | const FONT_WEIGHTS = { 2 | thin: 100, 3 | ultralight: 200, 4 | light: 300, 5 | normal: 400, 6 | medium: 500, 7 | semibold: 600, 8 | bold: 700, 9 | ultrabold: 800, 10 | heavy: 900 11 | }; 12 | 13 | const FONT_WIDTHS = { 14 | ultracondensed: 1, 15 | extracondensed: 2, 16 | condensed: 3, 17 | semicondensed: 4, 18 | normal: 5, 19 | semiexpanded: 6, 20 | expanded: 7, 21 | extraexpanded: 8, 22 | ultraexpanded: 9 23 | }; 24 | 25 | function parseFont(font) { 26 | // When we pass a fontkit TTFFont directly to texkit 27 | if (typeof font === 'object') { 28 | return { 29 | family: font.familyName, 30 | postscriptName: font.postscriptName 31 | }; 32 | } 33 | 34 | if (typeof font !== 'string') { 35 | return {}; 36 | } 37 | 38 | const parts = font.match(/(.+\.(?:ttf|otf|ttc|dfont|woff|woff2))(?:#(.+))$/); 39 | if (parts) { 40 | return { 41 | path: parts[1], 42 | postscriptName: parts[2] 43 | }; 44 | } 45 | 46 | return { family: font }; 47 | } 48 | 49 | function getFontWeight(weight) { 50 | if (typeof weight === 'number') { 51 | return Math.max(100, Math.min(900, Math.floor(weight / 100) * 100)); 52 | } 53 | 54 | if (typeof weight === 'string') { 55 | return FONT_WEIGHTS[weight.toLowerCase().replace(/-/g, '')]; 56 | } 57 | 58 | return null; 59 | } 60 | 61 | function getFontWidth(width) { 62 | if (typeof width === 'number') { 63 | return Math.max(1, Math.min(9, width)); 64 | } 65 | 66 | if (typeof width === 'string') { 67 | return FONT_WIDTHS[width.toLowerCase().replace(/-/g, '')]; 68 | } 69 | 70 | return null; 71 | } 72 | 73 | export default class FontDescriptor { 74 | constructor(attributes = {}) { 75 | this.path = attributes.path; 76 | this.postscriptName = attributes.postscriptName; 77 | this.family = attributes.family; 78 | this.style = attributes.style; 79 | this.weight = attributes.weight; 80 | this.width = attributes.width; 81 | this.italic = attributes.italic; 82 | this.monospace = attributes.monospace; 83 | } 84 | 85 | static fromAttributes(attributes = {}) { 86 | if (attributes.fontDescriptor) { 87 | return new FontDescriptor(attributes.fontDescriptor); 88 | } 89 | 90 | const font = parseFont(attributes.font || 'Helvetica'); 91 | return new FontDescriptor({ 92 | path: font.path, 93 | postscriptName: attributes.fontPostscriptName || font.postscriptName, 94 | family: attributes.fontFamily || font.family, 95 | style: attributes.fontStyle, 96 | weight: getFontWeight(attributes.fontWeight) || (attributes.bold ? FONT_WEIGHTS.bold : null), 97 | width: getFontWidth(attributes.fontWidth), 98 | italic: attributes.italic, 99 | monospace: attributes.monospace 100 | }); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/core/src/models/GlyphRun.js: -------------------------------------------------------------------------------- 1 | import Run from './Run'; 2 | 3 | class GlyphRun extends Run { 4 | constructor(start, end, attributes, glyphs, positions, stringIndices, glyphIndices, preScaled) { 5 | super(start, end, attributes); 6 | 7 | this.glyphs = glyphs || []; 8 | this.positions = positions || []; 9 | this.glyphIndices = glyphIndices || []; 10 | this.stringIndices = stringIndices || []; 11 | this.scale = attributes.fontSize / attributes.font.unitsPerEm; 12 | 13 | if (!preScaled) { 14 | this.positions.forEach((pos, index) => { 15 | const xAdvance = 16 | index === this.positions.length - 1 17 | ? pos.xAdvance * this.scale 18 | : pos.xAdvance * this.scale + attributes.characterSpacing; 19 | 20 | pos.xAdvance = xAdvance; 21 | pos.yAdvance *= this.scale; 22 | pos.xOffset *= this.scale; 23 | pos.yOffset *= this.scale; 24 | }); 25 | } 26 | } 27 | 28 | get length() { 29 | return this.end - this.start; 30 | } 31 | 32 | get stringStart() { 33 | return 0; 34 | } 35 | 36 | get stringEnd() { 37 | return this.glyphIndices.length - 1; 38 | } 39 | 40 | get advanceWidth() { 41 | let width = 0; 42 | for (const position of this.positions) { 43 | width += position.xAdvance; 44 | } 45 | 46 | return width; 47 | } 48 | 49 | get ascent() { 50 | const ascent = this.attributes.font.ascent * this.scale; 51 | 52 | if (this.attributes.attachment) { 53 | return Math.max(ascent, this.attributes.attachment.height); 54 | } 55 | 56 | return ascent; 57 | } 58 | 59 | get descent() { 60 | return this.attributes.font.descent * this.scale; 61 | } 62 | 63 | get lineGap() { 64 | return this.attributes.font.lineGap * this.scale; 65 | } 66 | 67 | get height() { 68 | return this.attributes.lineHeight || this.ascent - this.descent + this.lineGap; 69 | } 70 | 71 | slice(start, end) { 72 | const glyphs = this.glyphs.slice(start, end); 73 | const positions = this.positions.slice(start, end); 74 | let stringIndices = this.stringIndices.slice(start, end); 75 | let glyphIndices = this.glyphIndices.filter(i => i >= start && i < end); 76 | 77 | glyphIndices = glyphIndices.map(index => index - start); 78 | stringIndices = stringIndices.map(index => index - this.stringIndices[start]); 79 | 80 | start += this.start; 81 | end += this.start; 82 | end = Math.min(end, this.end); 83 | 84 | return new GlyphRun( 85 | start, 86 | end, 87 | this.attributes, 88 | glyphs, 89 | positions, 90 | stringIndices, 91 | glyphIndices, 92 | true 93 | ); 94 | } 95 | 96 | copy() { 97 | return new GlyphRun( 98 | this.start, 99 | this.end, 100 | this.attributes, 101 | this.glyphs, 102 | this.positions, 103 | this.stringIndices, 104 | this.glyphIndices, 105 | true 106 | ); 107 | } 108 | } 109 | 110 | export default GlyphRun; 111 | -------------------------------------------------------------------------------- /packages/core/src/models/GlyphString.js: -------------------------------------------------------------------------------- 1 | import unicode from 'unicode-properties'; 2 | 3 | // https://www.w3.org/TR/css-text-3/#hanging-punctuation 4 | const HANGING_PUNCTUATION_START_CATEGORIES = new Set(['Ps', 'Pi', 'Pf']); 5 | const HANGING_PUNCTUATION_END_CATEGORIES = new Set(['Pe', 'Pi', 'Pf']); 6 | const HANGING_PUNCTUATION_END_CODEPOINTS = new Set([ 7 | 0x002c, // COMMA 8 | 0x002e, // FULL STOP 9 | 0x060c, // ARABIC COMMA 10 | 0x06d4, // ARABIC FULL STOP 11 | 0x3001, // IDEOGRAPHIC COMMA 12 | 0x3002, // IDEOGRAPHIC FULL STOP 13 | 0xff0c, // FULLWIDTH COMMA 14 | 0xff0e, // FULLWIDTH FULL STOP 15 | 0xfe50, // SMALL COMMA 16 | 0xfe51, // SMALL IDEOGRAPHIC COMMA 17 | 0xfe52, // SMALL FULL STOP 18 | 0xff61, // HALFWIDTH IDEOGRAPHIC FULL STOP 19 | 0xff64, // HALFWIDTH IDEOGRAPHIC COMMA 20 | 0x002d // HYPHEN 21 | ]); 22 | 23 | class GlyphString { 24 | constructor(string, glyphRuns, start, end) { 25 | this.string = string; 26 | this._glyphRuns = glyphRuns; 27 | this.start = start || 0; 28 | this._end = end; 29 | this._glyphRunsCache = null; 30 | this._glyphRunsCacheEnd = null; 31 | } 32 | 33 | get end() { 34 | if (this._glyphRuns.length === 0) { 35 | return 0; 36 | } 37 | 38 | const glyphEnd = this._glyphRuns[this._glyphRuns.length - 1].end; 39 | 40 | if (this._end) { 41 | return Math.min(this._end, glyphEnd); 42 | } 43 | 44 | return this._glyphRuns.length > 0 ? glyphEnd : 0; 45 | } 46 | 47 | get length() { 48 | return this.end - this.start; 49 | } 50 | 51 | get advanceWidth() { 52 | return this.glyphRuns.reduce((acc, run) => acc + run.advanceWidth, 0); 53 | } 54 | 55 | get height() { 56 | return this.glyphRuns.reduce((acc, run) => Math.max(acc, run.height), 0); 57 | } 58 | 59 | get ascent() { 60 | return this.glyphRuns.reduce((acc, run) => Math.max(acc, run.ascent), 0); 61 | } 62 | 63 | get descent() { 64 | return this.glyphRuns.reduce((acc, run) => Math.min(acc, run.descent), 0); 65 | } 66 | 67 | get glyphRuns() { 68 | if (this._glyphRunsCache && this._glyphRunsCacheEnd === this.end) { 69 | return this._glyphRunsCache; 70 | } 71 | 72 | if (this._glyphRuns.length === 0) { 73 | this._glyphRunsCache = []; 74 | this._glyphRunsCacheEnd = this.end; 75 | return []; 76 | } 77 | 78 | const startRunIndex = this.runIndexAtGlyphIndex(0); 79 | const endRunIndex = this.runIndexAtGlyphIndex(this.length); 80 | const startRun = this._glyphRuns[startRunIndex]; 81 | const endRun = this._glyphRuns[endRunIndex]; 82 | const runs = []; 83 | 84 | runs.push(startRun.slice(this.start - startRun.start, this.end - startRun.start)); 85 | 86 | if (endRunIndex !== startRunIndex) { 87 | runs.push(...this._glyphRuns.slice(startRunIndex + 1, endRunIndex)); 88 | 89 | if (this.end - endRun.start !== 0) { 90 | runs.push(endRun.slice(0, this.end - endRun.start)); 91 | } 92 | } 93 | 94 | this._glyphRunsCache = runs; 95 | this._glyphRunsCacheEnd = this.end; 96 | return runs; 97 | } 98 | 99 | slice(start, end) { 100 | const stringStart = this.stringIndexForGlyphIndex(start); 101 | const stringEnd = this.stringIndexForGlyphIndex(end); 102 | 103 | return new GlyphString( 104 | this.string.slice(stringStart, stringEnd), 105 | this._glyphRuns, 106 | start + this.start, 107 | end + this.start 108 | ); 109 | } 110 | 111 | runIndexAtGlyphIndex(index) { 112 | index += this.start; 113 | let count = 0; 114 | 115 | for (let i = 0; i < this._glyphRuns.length; i++) { 116 | const run = this._glyphRuns[i]; 117 | 118 | if (count <= index && index < count + run.glyphs.length) { 119 | return i; 120 | } 121 | 122 | count += run.glyphs.length; 123 | } 124 | 125 | return this._glyphRuns.length - 1; 126 | } 127 | 128 | runAtGlyphIndex(index) { 129 | index += this.start; 130 | 131 | for (let i = 0; i < this.glyphRuns.length; i++) { 132 | const run = this.glyphRuns[i]; 133 | 134 | if (run.start <= index && run.end > index) { 135 | return run; 136 | } 137 | } 138 | 139 | return this.glyphRuns[this.glyphRuns.length - 1]; 140 | } 141 | 142 | runIndexAtStringIndex(index) { 143 | let offset = 0; 144 | 145 | for (let i = 0; i < this.glyphRuns.length; i++) { 146 | const run = this.glyphRuns[i]; 147 | 148 | if (offset + run.stringStart <= index && offset + run.stringEnd >= index) { 149 | return i; 150 | } 151 | 152 | offset += run.stringEnd; 153 | } 154 | 155 | return this._glyphRuns.length - 1; 156 | } 157 | 158 | runAtStringIndex(index) { 159 | return this.glyphRuns[this.runIndexAtStringIndex(index)]; 160 | } 161 | 162 | glyphAtIndex(index) { 163 | const run = this.runAtGlyphIndex(index); 164 | return run.glyphs[this.start + index - run.start]; 165 | } 166 | 167 | positionAtIndex(index) { 168 | let run; 169 | let count = 0; 170 | 171 | for (let i = 0; i < this.glyphRuns.length; i++) { 172 | run = this.glyphRuns[i]; 173 | 174 | if (count <= index && index < count + run.positions.length) { 175 | return run.positions[index - count]; 176 | } 177 | 178 | count += run.positions.length; 179 | } 180 | 181 | return run.positions[run.positions.length - 1]; 182 | } 183 | 184 | getGlyphWidth(index) { 185 | return this.positionAtIndex(index).xAdvance; 186 | } 187 | 188 | glyphIndexAtOffset(width) { 189 | let offset = 0; 190 | let index = 0; 191 | 192 | for (const run of this.glyphRuns) { 193 | if (offset + run.advanceWidth > width) { 194 | for (const position of run.positions) { 195 | const w = position.xAdvance; 196 | if (offset + w > width) { 197 | return index; 198 | } 199 | 200 | offset += w; 201 | index++; 202 | } 203 | } else { 204 | offset += run.advanceWidth; 205 | index += run.glyphs.length; 206 | } 207 | } 208 | 209 | return index; 210 | } 211 | 212 | stringIndexForGlyphIndex(index) { 213 | let run; 214 | let count = 0; 215 | let offset = 0; 216 | 217 | for (let i = 0; i < this.glyphRuns.length; i++) { 218 | run = this.glyphRuns[i]; 219 | 220 | if (offset <= index && offset + run.length > index) { 221 | return count + run.stringIndices[index + this.start - run.start]; 222 | } 223 | 224 | offset += run.length; 225 | count += run.glyphIndices.length; 226 | } 227 | 228 | return count; 229 | } 230 | 231 | glyphIndexForStringIndex(index) { 232 | let run; 233 | let count = 0; 234 | let offset = 0; 235 | 236 | for (let i = 0; i < this.glyphRuns.length; i++) { 237 | run = this.glyphRuns[i]; 238 | 239 | if (offset <= index && index < offset + run.stringEnd + 1) { 240 | return count + run.glyphIndices[index - offset]; 241 | } 242 | 243 | count += run.glyphs.length; 244 | offset += run.stringEnd + 1; 245 | } 246 | 247 | return offset; 248 | } 249 | 250 | codePointAtGlyphIndex(glyphIndex) { 251 | return this.string.codePointAt(this.stringIndexForGlyphIndex(glyphIndex)); 252 | } 253 | 254 | charAtGlyphIndex(glyphIndex) { 255 | return this.string.charAt(this.stringIndexForGlyphIndex(glyphIndex)); 256 | } 257 | 258 | offsetAtGlyphIndex(glyphIndex) { 259 | let offset = 0; 260 | let count = glyphIndex; 261 | 262 | for (const run of this.glyphRuns) { 263 | for (let i = 0; i < run.glyphs.length; i++) { 264 | if (count === 0) { 265 | return offset; 266 | } 267 | 268 | offset += run.positions[i].xAdvance; 269 | count -= 1; 270 | } 271 | } 272 | 273 | return offset; 274 | } 275 | 276 | indexOf(string, index = 0) { 277 | const stringIndex = this.stringIndexForGlyphIndex(index); 278 | const nextIndex = this.string.indexOf(string, stringIndex); 279 | 280 | if (nextIndex === -1) { 281 | return -1; 282 | } 283 | 284 | return this.glyphIndexForStringIndex(nextIndex); 285 | } 286 | 287 | getUnicodeCategory(index) { 288 | const codePoint = this.codePointAtGlyphIndex(index); 289 | return codePoint ? unicode.getCategory(codePoint) : null; 290 | } 291 | 292 | isWhiteSpace(index) { 293 | const codePoint = this.codePointAtGlyphIndex(index); 294 | return codePoint ? unicode.isWhiteSpace(codePoint) : false; 295 | } 296 | 297 | isHangingPunctuationStart(index) { 298 | return HANGING_PUNCTUATION_START_CATEGORIES.has(this.getUnicodeCategory(index)); 299 | } 300 | 301 | isHangingPunctuationEnd(index) { 302 | return ( 303 | HANGING_PUNCTUATION_END_CATEGORIES.has(this.getUnicodeCategory(index)) || 304 | HANGING_PUNCTUATION_END_CODEPOINTS.has(this.codePointAtGlyphIndex(index)) 305 | ); 306 | } 307 | 308 | insertGlyph(index, codePoint) { 309 | const runIndex = this.runIndexAtGlyphIndex(index); 310 | const run = this._glyphRuns[runIndex]; 311 | const { font, fontSize } = run.attributes; 312 | const glyph = run.attributes.font.glyphForCodePoint(codePoint); 313 | const scale = fontSize / font.unitsPerEm; 314 | const glyphIndex = this.start + index - run.start; 315 | 316 | if (this._end) { 317 | this._end += 1; 318 | } 319 | 320 | run.glyphs.splice(glyphIndex, 0, glyph); 321 | run.stringIndices.splice(glyphIndex, 0, run.stringIndices[glyphIndex]); 322 | 323 | for (let i = 0; i < run.glyphIndices.length; i++) { 324 | if (run.glyphIndices[i] >= glyphIndex) { 325 | run.glyphIndices[i] += 1; 326 | } 327 | } 328 | 329 | run.positions.splice(glyphIndex, 0, { 330 | xAdvance: glyph.advanceWidth * scale, 331 | yAdvance: 0, 332 | xOffset: 0, 333 | yOffset: run.attributes.yOffset * font.unitsPerEm 334 | }); 335 | 336 | run.end += 1; 337 | 338 | for (let i = runIndex + 1; i < this._glyphRuns.length; i++) { 339 | this._glyphRuns[i].start += 1; 340 | this._glyphRuns[i].end += 1; 341 | } 342 | 343 | this._glyphRunsCache = null; 344 | } 345 | 346 | deleteGlyph(index) { 347 | if (index < 0 || index >= this.length) { 348 | return; 349 | } 350 | 351 | const runIndex = this.runIndexAtGlyphIndex(index); 352 | const run = this._glyphRuns[runIndex]; 353 | const glyphIndex = this.start + index - run.start; 354 | 355 | run.glyphs.splice(glyphIndex, 1); 356 | run.positions.splice(glyphIndex, 1); 357 | run.stringIndices.splice(glyphIndex, 1); 358 | 359 | for (let i = 0; i < run.glyphIndices.length; i++) { 360 | if (run.glyphIndices[i] >= glyphIndex) { 361 | run.glyphIndices[i] -= 1; 362 | } 363 | } 364 | 365 | run.end--; 366 | 367 | for (let i = runIndex + 1; i < this._glyphRuns.length; i++) { 368 | this._glyphRuns[i].start--; 369 | this._glyphRuns[i].end--; 370 | } 371 | 372 | this._glyphRunsCache = null; 373 | } 374 | 375 | *[Symbol.iterator]() { 376 | let x = 0; 377 | for (const run of this.glyphRuns) { 378 | for (let i = 0; i < run.glyphs.length; i++) { 379 | yield { 380 | glyph: run.glyphs[i], 381 | position: run.positions[i], 382 | run, 383 | x, 384 | index: run.start + i 385 | }; 386 | 387 | x += run.positions[i].xAdvance; 388 | } 389 | } 390 | } 391 | } 392 | 393 | export default GlyphString; 394 | -------------------------------------------------------------------------------- /packages/core/src/models/LineFragment.js: -------------------------------------------------------------------------------- 1 | import GlyphString from './GlyphString'; 2 | 3 | export default class LineFragment extends GlyphString { 4 | constructor(rect, glyphString) { 5 | super(glyphString.string, glyphString._glyphRuns, glyphString.start, glyphString.end); 6 | this.rect = rect; 7 | this.decorationLines = []; 8 | this.overflowLeft = 0; 9 | this.overflowRight = 0; 10 | this.stringStart = null; 11 | this.stringEnd = null; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/core/src/models/ParagraphStyle.js: -------------------------------------------------------------------------------- 1 | export default class ParagraphStyle { 2 | constructor(attributes = {}) { 3 | this.indent = attributes.indent || 0; 4 | this.bullet = attributes.bullet || null; 5 | this.paddingTop = attributes.paddingTop || attributes.padding || 0; 6 | this.paragraphSpacing = attributes.paragraphSpacing || 0; 7 | this.marginLeft = attributes.marginLeft || attributes.margin || 0; 8 | this.marginRight = attributes.marginRight || attributes.margin || 0; 9 | this.align = attributes.align || 'left'; 10 | this.alignLastLine = 11 | attributes.alignLastLine || (this.align === 'justify' ? 'left' : this.align); 12 | this.justificationFactor = attributes.justificationFactor || 1; 13 | this.hyphenationFactor = attributes.hyphenationFactor || 0; 14 | this.shrinkFactor = attributes.shrinkFactor || 0; 15 | this.lineSpacing = attributes.lineSpacing || 0; 16 | this.lineHeight = attributes.lineHeight || 0; 17 | this.hangingPunctuation = attributes.hangingPunctuation || false; 18 | this.truncationMode = attributes.truncationMode || (attributes.truncate ? 'right' : null); 19 | this.maxLines = attributes.maxLines || Infinity; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/src/models/Range.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This class represents a numeric range between 3 | * a starting and ending value, inclusive. 4 | */ 5 | class Range { 6 | /** 7 | * Creates a new Range 8 | * @param {number} start the starting index of the range 9 | * @param {number} end the ending index of the range, inclusive 10 | */ 11 | constructor(start, end) { 12 | /** 13 | * The starting index of the range 14 | * @type {number} 15 | */ 16 | this.start = start; 17 | 18 | /** 19 | * The ending index of the range, inclusive 20 | * @type {number} 21 | */ 22 | this.end = end; 23 | } 24 | 25 | /** 26 | * The length of the range 27 | * @type {number} 28 | */ 29 | get length() { 30 | return this.end - this.start + 1; 31 | } 32 | 33 | /** 34 | * Returns whether this range is equal to the given range 35 | * @param {Range} other the range to compare 36 | * @return {boolean} 37 | */ 38 | equals(other) { 39 | return other.start === this.start && other.end === this.end; 40 | } 41 | 42 | /** 43 | * Returns a copy of the range 44 | * @return {Range} 45 | */ 46 | copy() { 47 | return new Range(this.start, this.end); 48 | } 49 | 50 | /** 51 | * Returns whether the given value is in the range 52 | * @param {number} index the index to check 53 | * @return {boolean} 54 | */ 55 | contains(index) { 56 | return index >= this.start && index <= this.end; 57 | } 58 | 59 | /** 60 | * Extends the range to include the given index 61 | * @param {number} index the index to ad 62 | */ 63 | extend(index) { 64 | this.start = Math.min(this.start, index); 65 | this.end = Math.max(this.end, index); 66 | } 67 | 68 | /** 69 | * Merge intersecting ranges 70 | * @param {number} ranges array of valid Range objects 71 | * @return {array} 72 | */ 73 | static merge(ranges) { 74 | ranges.sort((a, b) => a.start - b.start); 75 | 76 | const merged = [ranges[0]]; 77 | 78 | for (let i = 1; i < ranges.length; i++) { 79 | const last = merged[merged.length - 1]; 80 | const next = ranges[i]; 81 | 82 | if (next.start <= last.end && next.end <= last.end) { 83 | // Ignore this range completely. 84 | // Next is contained inside last 85 | continue; 86 | } else if (next.start <= last.end) { 87 | last.end = next.end; 88 | } else { 89 | merged.push(next); 90 | } 91 | } 92 | 93 | return merged; 94 | } 95 | } 96 | 97 | export default Range; 98 | -------------------------------------------------------------------------------- /packages/core/src/models/Run.js: -------------------------------------------------------------------------------- 1 | import Range from './Range'; 2 | 3 | class Run extends Range { 4 | constructor(start, end, attributes = {}) { 5 | super(start, end); 6 | 7 | this.attributes = attributes; 8 | } 9 | 10 | slice(start, end) { 11 | return new Run(start + this.start, Math.min(this.end, end + this.start), this.attributes); 12 | } 13 | 14 | copy() { 15 | return new Run(this.start, this.end, this.attributes); 16 | } 17 | } 18 | 19 | export default Run; 20 | -------------------------------------------------------------------------------- /packages/core/src/models/RunStyle.js: -------------------------------------------------------------------------------- 1 | import FontDescriptor from './FontDescriptor'; 2 | 3 | export default class RunStyle { 4 | constructor(attributes = {}) { 5 | this.color = attributes.color || 'black'; 6 | this.backgroundColor = attributes.backgroundColor || null; 7 | this.fontDescriptor = FontDescriptor.fromAttributes(attributes); 8 | this.font = attributes.font || null; 9 | this.fontSize = attributes.fontSize || 12; 10 | this.lineHeight = attributes.lineHeight || null; 11 | this.underline = attributes.underline || false; 12 | this.underlineColor = attributes.underlineColor || this.color; 13 | this.underlineStyle = attributes.underlineStyle || 'solid'; 14 | this.strike = attributes.strike || false; 15 | this.strikeColor = attributes.strikeColor || this.color; 16 | this.strikeStyle = attributes.strikeStyle || 'solid'; 17 | this.link = attributes.link || null; 18 | this.fill = attributes.fill !== false; 19 | this.stroke = attributes.stroke || false; 20 | this.features = attributes.features || []; 21 | this.wordSpacing = attributes.wordSpacing || 0; 22 | this.yOffset = attributes.yOffset || 0; 23 | this.characterSpacing = attributes.characterSpacing || 0; 24 | this.attachment = attributes.attachment || null; 25 | this.script = attributes.script || null; 26 | this.bidiLevel = attributes.bidiLevel || null; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/core/src/models/TabStop.js: -------------------------------------------------------------------------------- 1 | export default class TabStop { 2 | constructor(x, align = 'left') { 3 | this.x = x; 4 | this.align = align; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/src/models/index.js: -------------------------------------------------------------------------------- 1 | export Run from './Run'; 2 | export Block from './Block'; 3 | export Range from './Range'; 4 | export TabStop from './TabStop'; 5 | export RunStyle from './RunStyle'; 6 | export GlyphRun from './GlyphRun'; 7 | export Container from './Container'; 8 | export Attachment from './Attachment'; 9 | export GlyphString from './GlyphString'; 10 | export LineFragment from './LineFragment'; 11 | export ParagraphStyle from './ParagraphStyle'; 12 | export DecorationLine from './DecorationLine'; 13 | export FontDescriptor from './FontDescriptor'; 14 | export AttributedString from './AttributedString'; 15 | -------------------------------------------------------------------------------- /packages/core/test/geom/BBox.test.js: -------------------------------------------------------------------------------- 1 | import BBox from '../../src/geom/BBox'; 2 | import Rect from '../../src/geom/Rect'; 3 | 4 | describe('BBox', () => { 5 | test('should construct infinite bbox as default', () => { 6 | const box = new BBox(); 7 | 8 | expect(box).toHaveProperty('minX', Infinity); 9 | expect(box).toHaveProperty('minY', Infinity); 10 | expect(box).toHaveProperty('maxX', -Infinity); 11 | expect(box).toHaveProperty('maxY', -Infinity); 12 | }); 13 | 14 | test('should get width', () => { 15 | const box = new BBox(5, 5, 10, 20); 16 | 17 | expect(box.width).toBe(5); 18 | }); 19 | 20 | test('should get height', () => { 21 | const box = new BBox(5, 5, 10, 20); 22 | 23 | expect(box.height).toBe(15); 24 | }); 25 | 26 | test('should add inner point', () => { 27 | const box = new BBox(5, 5, 10, 20); 28 | 29 | box.addPoint(7, 10); 30 | 31 | expect(box).toHaveProperty('minX', 5); 32 | expect(box).toHaveProperty('minY', 5); 33 | expect(box).toHaveProperty('maxX', 10); 34 | expect(box).toHaveProperty('maxY', 20); 35 | }); 36 | 37 | test('should add x exceeding point', () => { 38 | const box = new BBox(5, 5, 10, 20); 39 | 40 | box.addPoint(15, 10); 41 | 42 | expect(box).toHaveProperty('minX', 5); 43 | expect(box).toHaveProperty('minY', 5); 44 | expect(box).toHaveProperty('maxX', 15); 45 | expect(box).toHaveProperty('maxY', 20); 46 | }); 47 | 48 | test('should add y exceeding point', () => { 49 | const box = new BBox(5, 5, 10, 20); 50 | 51 | box.addPoint(7, 25); 52 | 53 | expect(box).toHaveProperty('minX', 5); 54 | expect(box).toHaveProperty('minY', 5); 55 | expect(box).toHaveProperty('maxX', 10); 56 | expect(box).toHaveProperty('maxY', 25); 57 | }); 58 | 59 | test('should add x & y exceeding point', () => { 60 | const box = new BBox(5, 5, 10, 20); 61 | 62 | box.addPoint(20, 25); 63 | 64 | expect(box).toHaveProperty('minX', 5); 65 | expect(box).toHaveProperty('minY', 5); 66 | expect(box).toHaveProperty('maxX', 20); 67 | expect(box).toHaveProperty('maxY', 25); 68 | }); 69 | 70 | test('should add inner rect', () => { 71 | const box = new BBox(5, 5, 20, 20); 72 | const rect = new Rect(10, 10, 5, 5); 73 | 74 | box.addRect(rect); 75 | 76 | expect(box).toHaveProperty('minX', 5); 77 | expect(box).toHaveProperty('minY', 5); 78 | expect(box).toHaveProperty('maxX', 20); 79 | expect(box).toHaveProperty('maxY', 20); 80 | }); 81 | 82 | test('should add x exceeding rect', () => { 83 | const box = new BBox(5, 5, 20, 20); 84 | const rect = new Rect(10, 10, 15, 5); 85 | 86 | box.addRect(rect); 87 | 88 | expect(box).toHaveProperty('minX', 5); 89 | expect(box).toHaveProperty('minY', 5); 90 | expect(box).toHaveProperty('maxX', 25); 91 | expect(box).toHaveProperty('maxY', 20); 92 | }); 93 | 94 | test('should add y exceeding rect', () => { 95 | const box = new BBox(5, 5, 20, 20); 96 | const rect = new Rect(10, 10, 5, 15); 97 | 98 | box.addRect(rect); 99 | 100 | expect(box).toHaveProperty('minX', 5); 101 | expect(box).toHaveProperty('minY', 5); 102 | expect(box).toHaveProperty('maxX', 20); 103 | expect(box).toHaveProperty('maxY', 25); 104 | }); 105 | 106 | test('should copy bbox', () => { 107 | const box = new BBox(5, 5, 10, 20); 108 | const newBox = box.copy(); 109 | 110 | expect(newBox.minX).toBe(box.minX); 111 | expect(newBox.minY).toBe(box.minY); 112 | expect(newBox.maxX).toBe(box.maxX); 113 | expect(newBox.maxY).toBe(box.maxY); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /packages/core/test/geom/Path.test.js: -------------------------------------------------------------------------------- 1 | import Path from '../../src/geom/Path'; 2 | 3 | const assertPath = (path, expected) => { 4 | const value = path.commands.map(({ command, args }) => `${command}:${args.join(',')}`); 5 | 6 | expect(value.join(`|`)).toBe(expected.replace(/\n| /g, '')); 7 | }; 8 | 9 | describe('Path', () => { 10 | test('should construct empty path as default', () => { 11 | const path = new Path(); 12 | 13 | expect(path.commands).toHaveLength(0); 14 | }); 15 | 16 | test('should correctly inject moveTo function', () => { 17 | const path = new Path(); 18 | 19 | expect(typeof path.moveTo).toEqual('function'); 20 | }); 21 | 22 | test('should correctly inject lineTo function', () => { 23 | const path = new Path(); 24 | 25 | expect(typeof path.lineTo).toEqual('function'); 26 | }); 27 | 28 | test('should correctly inject quadraticCurveTo function', () => { 29 | const path = new Path(); 30 | 31 | expect(typeof path.quadraticCurveTo).toEqual('function'); 32 | }); 33 | 34 | test('should correctly inject bezierCurveTo function', () => { 35 | const path = new Path(); 36 | 37 | expect(typeof path.bezierCurveTo).toEqual('function'); 38 | }); 39 | 40 | test('should correctly inject closePath function', () => { 41 | const path = new Path(); 42 | 43 | expect(typeof path.closePath).toEqual('function'); 44 | }); 45 | 46 | test('should correctly add rect commands', () => { 47 | const path = new Path(); 48 | path.rect(5, 5, 10, 20); 49 | 50 | assertPath(path, 'moveTo:5,5|lineTo:15,5|lineTo:15,25|lineTo:5,25|closePath:'); 51 | }); 52 | 53 | test('should correctly add ellipse commands', () => { 54 | const path = new Path(); 55 | path.ellipse(5, 5, 10, 20); 56 | 57 | assertPath( 58 | path, 59 | `moveTo:-5,5|bezierCurveTo:-5,-6.045694996615872,-0.5228474983079359, 60 | -15,5,-15|bezierCurveTo:10.522847498307936,-15,15,-6.045694996615872, 61 | 15,5|bezierCurveTo:15,16.04569499661587,10.522847498307936,25,5,25 62 | |bezierCurveTo:-0.5228474983079359,25,-5,16.04569499661587, 63 | -5,5|closePath:` 64 | ); 65 | }); 66 | 67 | test('should correctly add cicle commands', () => { 68 | const path = new Path(); 69 | path.circle(5, 5, 10); 70 | 71 | assertPath( 72 | path, 73 | `moveTo:-5,5|bezierCurveTo:-5,-0.5228474983079359,-0.5228474983079359, 74 | -5,5,-5|bezierCurveTo:10.522847498307936,-5,15,-0.5228474983079359,15,5| 75 | bezierCurveTo:15,10.522847498307936,10.522847498307936,15,5,15| 76 | bezierCurveTo:-0.5228474983079359,15,-5,10.522847498307936, 77 | -5,5|closePath:` 78 | ); 79 | }); 80 | 81 | test('should isClockwise return true if valid', () => { 82 | const path = new Path(); 83 | path.moveTo(5, 5); 84 | path.lineTo(10, 5); 85 | path.lineTo(10, 10); 86 | path.lineTo(5, 10); 87 | path.closePath(); 88 | 89 | expect(path.isClockwise).toBeTruthy(); 90 | }); 91 | 92 | test('should isClockwise return true if valid for non rect paths', () => { 93 | const path = new Path(); 94 | path.moveTo(5, 0); 95 | path.lineTo(6, 4); 96 | path.lineTo(4, 5); 97 | path.lineTo(1, 5); 98 | path.lineTo(1, 0); 99 | path.closePath(); 100 | 101 | expect(path.isClockwise).toBeTruthy(); 102 | }); 103 | 104 | test('should isClockwise return true if valid for large numbers', () => { 105 | const path = new Path(); 106 | path.moveTo(1000, 20); 107 | path.lineTo(1350, 20); 108 | path.lineTo(1350, 420); 109 | path.lineTo(1000, 420); 110 | path.closePath(); 111 | 112 | expect(path.isClockwise).toBeTruthy(); 113 | }); 114 | 115 | test('should isClockwise return false if invalid', () => { 116 | const path = new Path(); 117 | path.moveTo(5, 5); 118 | path.lineTo(5, 10); 119 | path.lineTo(10, 10); 120 | path.lineTo(10, 5); 121 | path.closePath(); 122 | 123 | expect(path.isClockwise).toBeFalsy(); 124 | }); 125 | 126 | test('should isClockwise return false if invalid for non rect paths', () => { 127 | const path = new Path(); 128 | path.moveTo(5, 0); 129 | path.lineTo(1, 0); 130 | path.lineTo(1, 5); 131 | path.lineTo(4, 5); 132 | path.lineTo(6, 4); 133 | path.closePath(); 134 | 135 | expect(path.isClockwise).toBeFalsy(); 136 | }); 137 | 138 | test('should isClockwise return false if invalid for large numbers', () => { 139 | const path = new Path(); 140 | path.moveTo(1000, 20); 141 | path.lineTo(20, 1350); 142 | path.lineTo(1350, 420); 143 | path.lineTo(420, 1000); 144 | path.closePath(); 145 | 146 | expect(path.isClockwise).toBeFalsy(); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /packages/core/test/geom/Point.test.js: -------------------------------------------------------------------------------- 1 | import Point from '../../src/geom/Point'; 2 | 3 | describe('Point', () => { 4 | test('should construct origin point as default', () => { 5 | const point = new Point(); 6 | 7 | expect(point).toHaveProperty('x', 0); 8 | expect(point).toHaveProperty('y', 0); 9 | }); 10 | 11 | test('should apply translation matrix', () => { 12 | const point = new Point(5, 5); 13 | const transformed = point.transform(1, 0, 0, 1, 5, 5); 14 | 15 | expect(transformed).toHaveProperty('x', 10); 16 | expect(transformed).toHaveProperty('y', 10); 17 | }); 18 | 19 | test('should apply scaling matrix', () => { 20 | const point = new Point(5, 5); 21 | const transformed = point.transform(0.5, 0, 0, 2, 0, 0); 22 | 23 | expect(transformed).toHaveProperty('x', 2.5); 24 | expect(transformed).toHaveProperty('y', 10); 25 | }); 26 | 27 | test('should apply rotation matrix', () => { 28 | const point = new Point(0, -5); 29 | const sin = Math.sin(Math.PI / 2); 30 | const cos = Math.cos(Math.PI / 2); 31 | const transformed = point.transform(cos, sin, -sin, cos, 0, 0); 32 | 33 | expect(transformed.x).toBeCloseTo(5); 34 | expect(transformed.y).toBeCloseTo(0); 35 | }); 36 | 37 | test('should copy point', () => { 38 | const point = new Point(5, 5); 39 | const newPoint = point.copy(); 40 | 41 | expect(newPoint.x).toBe(point.x); 42 | expect(newPoint.y).toBe(point.y); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/core/test/geom/Polygon.test.js: -------------------------------------------------------------------------------- 1 | import Polygon from '../../src/geom/Polygon'; 2 | 3 | describe('Polygon', () => { 4 | test('should construct empty polygon as default', () => { 5 | const polygon = new Polygon(); 6 | 7 | expect(polygon.contours).toHaveLength(0); 8 | }); 9 | 10 | test('should add contour', () => { 11 | const contour = 'some contour'; 12 | const polygon = new Polygon(); 13 | 14 | polygon.addContour(contour); 15 | 16 | expect(polygon.contours).toHaveLength(1); 17 | expect(polygon.contours[0]).toBe(contour); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /packages/core/test/geom/Rect.test.js: -------------------------------------------------------------------------------- 1 | import Rect from '../../src/geom/Rect'; 2 | import Point from '../../src/geom/Point'; 3 | 4 | describe('Rect', () => { 5 | test('should construct origin rect as default', () => { 6 | const rect = new Rect(); 7 | 8 | expect(rect).toHaveProperty('x', 0); 9 | expect(rect).toHaveProperty('y', 0); 10 | expect(rect).toHaveProperty('width', 0); 11 | expect(rect).toHaveProperty('height', 0); 12 | }); 13 | 14 | test('should correctly get maxX', () => { 15 | const rect = new Rect(5, 5, 10, 10); 16 | 17 | expect(rect.maxX).toBe(15); 18 | }); 19 | 20 | test('should correctly get maxY', () => { 21 | const rect = new Rect(0, 0, 10, 10); 22 | 23 | expect(rect.maxY).toBe(10); 24 | }); 25 | 26 | test('should correctly get area', () => { 27 | const rect = new Rect(0, 0, 5, 10); 28 | 29 | expect(rect.area).toBe(50); 30 | }); 31 | 32 | test('should correctly get topLeft point', () => { 33 | const rect = new Rect(5, 5, 5, 10); 34 | const { topLeft } = rect; 35 | 36 | expect(topLeft.x).toBe(5); 37 | expect(topLeft.y).toBe(5); 38 | }); 39 | 40 | test('should correctly get topRight point', () => { 41 | const rect = new Rect(5, 5, 5, 10); 42 | const { topRight } = rect; 43 | 44 | expect(topRight.x).toBe(10); 45 | expect(topRight.y).toBe(5); 46 | }); 47 | 48 | test('should correctly get bottomLeft point', () => { 49 | const rect = new Rect(5, 5, 5, 10); 50 | const { bottomLeft } = rect; 51 | 52 | expect(bottomLeft.x).toBe(5); 53 | expect(bottomLeft.y).toBe(15); 54 | }); 55 | 56 | test('should correctly get bottomRight point', () => { 57 | const rect = new Rect(5, 5, 5, 10); 58 | const { bottomRight } = rect; 59 | 60 | expect(bottomRight.x).toBe(10); 61 | expect(bottomRight.y).toBe(15); 62 | }); 63 | 64 | test('should intersects return true for intersecting rect', () => { 65 | const rect1 = new Rect(5, 5, 5, 10); 66 | const rect2 = new Rect(7, 10, 10, 10); 67 | 68 | expect(rect1.intersects(rect2)).toBeTruthy(); 69 | }); 70 | 71 | test('should intersects return false for non-intersecting rect', () => { 72 | const rect1 = new Rect(5, 5, 5, 10); 73 | const rect2 = new Rect(20, 20, 5, 5); 74 | 75 | expect(rect1.intersects(rect2)).toBeFalsy(); 76 | }); 77 | 78 | test('should contains be true for containing rect', () => { 79 | const rect1 = new Rect(5, 5, 10, 10); 80 | const rect2 = new Rect(10, 10, 3, 3); 81 | 82 | expect(rect1.containsRect(rect2)).toBeTruthy(); 83 | }); 84 | 85 | test('should contains be false for non-containing intersecting rect', () => { 86 | const rect1 = new Rect(5, 5, 5, 10); 87 | const rect2 = new Rect(7, 10, 10, 10); 88 | 89 | expect(rect1.containsRect(rect2)).toBeFalsy(); 90 | }); 91 | 92 | test('should contains be false for non-containing non-intersec. rect', () => { 93 | const rect1 = new Rect(5, 5, 5, 10); 94 | const rect2 = new Rect(20, 20, 10, 10); 95 | 96 | expect(rect1.containsRect(rect2)).toBeFalsy(); 97 | }); 98 | 99 | test('should containsPoint be true for inner point', () => { 100 | const rect = new Rect(5, 5, 5, 10); 101 | const point = new Point(10, 10); 102 | 103 | expect(rect.containsPoint(point)).toBeTruthy(); 104 | }); 105 | 106 | test('should containsPoint be true for edge point', () => { 107 | const rect = new Rect(5, 5, 5, 10); 108 | const point = new Point(5, 7); 109 | 110 | expect(rect.containsPoint(point)).toBeTruthy(); 111 | }); 112 | 113 | test('should containsPoint be false for outside point', () => { 114 | const rect = new Rect(5, 5, 5, 10); 115 | const point = new Point(20, 20); 116 | 117 | expect(rect.containsPoint(point)).toBeFalsy(); 118 | }); 119 | 120 | test('should equals be true for equal rect', () => { 121 | const rect1 = new Rect(5, 5, 5, 10); 122 | const rect2 = new Rect(5, 5, 5, 10); 123 | 124 | expect(rect1.equals(rect2)).toBeTruthy(); 125 | }); 126 | 127 | test('should equals be false for outside point', () => { 128 | const rect1 = new Rect(5, 5, 5, 10); 129 | const rect2 = new Rect(10, 10, 5, 10); 130 | 131 | expect(rect1.equals(rect2)).toBeFalsy(); 132 | }); 133 | 134 | test('should pointEquals be true for equal point', () => { 135 | const rect = new Rect(5, 5, 5, 10); 136 | const point = new Point(5, 5); 137 | 138 | expect(rect.pointEquals(point)).toBeTruthy(); 139 | }); 140 | 141 | test('should pointEquals be false for non-equal point', () => { 142 | const rect = new Rect(5, 5, 5, 10); 143 | const point = new Point(0, 0); 144 | 145 | expect(rect.pointEquals(point)).toBeFalsy(); 146 | }); 147 | 148 | test('should sizeEquals be true', () => { 149 | const rect = new Rect(5, 5, 5, 10); 150 | const size = { width: 5, height: 10 }; 151 | 152 | expect(rect.sizeEquals(size)).toBeTruthy(); 153 | }); 154 | 155 | test('should sizeEquals be false', () => { 156 | const rect = new Rect(5, 5, 5, 10); 157 | const size = { width: 10, height: 10 }; 158 | 159 | expect(rect.sizeEquals(size)).toBeFalsy(); 160 | }); 161 | 162 | test('should copy rect', () => { 163 | const rect = new Rect(5, 5, 10, 10); 164 | const newRect = rect.copy(); 165 | 166 | expect(newRect.x).toBe(rect.x); 167 | expect(newRect.y).toBe(rect.y); 168 | expect(newRect.width).toBe(rect.width); 169 | expect(newRect.height).toBe(rect.height); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /packages/core/test/layout/LayoutEngine.test.js: -------------------------------------------------------------------------------- 1 | import LayoutEngine from '../../src/layout/LayoutEngine'; 2 | import AttributedString from '../../src/models/AttributedString'; 3 | import { createRectContainer } from '../utils/container'; 4 | import lorem from '../utils/lorem'; 5 | 6 | class TestEngine {} 7 | const createEngine = () => TestEngine; 8 | 9 | const { layoutColumn, layoutParagraph } = LayoutEngine.prototype; 10 | const layoutColumnMock = jest.fn().mockImplementation(() => 300); 11 | const layoutParagraphMock = jest.fn().mockImplementation(string => ({ 12 | bbox: { 13 | height: 20 14 | }, 15 | style: { 16 | paragraphSpacing: 0 17 | }, 18 | stringLength: string.length 19 | })); 20 | 21 | describe('LayoutEngine', () => { 22 | beforeEach(() => { 23 | layoutColumnMock.mockClear(); 24 | layoutParagraphMock.mockClear(); 25 | 26 | LayoutEngine.prototype.layoutColumn = layoutColumn; 27 | LayoutEngine.prototype.layoutParagraph = layoutParagraph; 28 | }); 29 | 30 | test('should layout 1 column correctly', () => { 31 | // Mock layoutColumn class function 32 | LayoutEngine.prototype.layoutColumn = layoutColumnMock; 33 | 34 | // Create instances 35 | const layout = new LayoutEngine({}); 36 | const string = AttributedString.fromFragments([{ string: lorem }]); 37 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 38 | 39 | // Call layout 40 | layout.layout(string, [container]); 41 | 42 | expect(layoutColumnMock.mock.calls).toHaveLength(1); 43 | expect(layoutColumnMock.mock.calls[0][3].width).toBe(300); 44 | }); 45 | 46 | test('should layout 2 column correctly', () => { 47 | // Mock layoutColumn class function 48 | LayoutEngine.prototype.layoutColumn = layoutColumnMock; 49 | 50 | // Create instances 51 | const layout = new LayoutEngine({}); 52 | const string = AttributedString.fromFragments([{ string: lorem }]); 53 | const container = createRectContainer(0, 0, 300, 200, { columns: 2, columnGap: 20 }); 54 | 55 | // Call layout 56 | layout.layout(string, [container]); 57 | 58 | expect(layoutColumnMock.mock.calls).toHaveLength(2); 59 | expect(layoutColumnMock.mock.calls[0][3].width).toBe(140); 60 | expect(layoutColumnMock.mock.calls[1][3].width).toBe(140); 61 | }); 62 | 63 | test('should layout single paragraph string', () => { 64 | // Mock layoutColumn class function 65 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 66 | 67 | // Create instances 68 | const layout = new LayoutEngine({}); 69 | const string = AttributedString.fromFragments([{ string: lorem }]); 70 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 71 | 72 | // Call layout 73 | layout.layout(string, [container]); 74 | 75 | expect(layoutParagraphMock.mock.calls).toHaveLength(1); 76 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe(lorem); 77 | }); 78 | 79 | test('should layout paragraph starting with \n', () => { 80 | // Mock layoutColumn class function 81 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 82 | 83 | // Create instances 84 | const layout = new LayoutEngine({}); 85 | const string = AttributedString.fromFragments([{ string: '\nLorem' }]); 86 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 87 | 88 | // Call layout 89 | layout.layout(string, [container]); 90 | 91 | expect(layoutParagraphMock.mock.calls).toHaveLength(2); 92 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe(''); 93 | expect(layoutParagraphMock.mock.calls[0][0].runs).toHaveLength(1); 94 | expect(layoutParagraphMock.mock.calls[0][0].runs[0].start).toBe(0); 95 | expect(layoutParagraphMock.mock.calls[0][0].runs[0].end).toBe(0); 96 | expect(layoutParagraphMock.mock.calls[1][0].string).toBe('Lorem'); 97 | }); 98 | 99 | test('should layout paragraph starting with \n on different runs', () => { 100 | // Mock layoutColumn class function 101 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 102 | 103 | // Create instances 104 | const layout = new LayoutEngine({}); 105 | const string = AttributedString.fromFragments([{ string: '\n' }, { string: 'Lorem' }]); 106 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 107 | 108 | // Call layout 109 | layout.layout(string, [container]); 110 | 111 | expect(layoutParagraphMock.mock.calls).toHaveLength(2); 112 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe(''); 113 | expect(layoutParagraphMock.mock.calls[0][0].runs).toHaveLength(1); 114 | expect(layoutParagraphMock.mock.calls[0][0].runs[0].start).toBe(0); 115 | expect(layoutParagraphMock.mock.calls[0][0].runs[0].end).toBe(0); 116 | expect(layoutParagraphMock.mock.calls[1][0].string).toBe('Lorem'); 117 | }); 118 | 119 | test('should layout two paragraph strings', () => { 120 | // Mock layoutColumn class function 121 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 122 | 123 | // Create instances 124 | const layout = new LayoutEngine({}); 125 | const string = AttributedString.fromFragments([{ string: 'Lorem\nipsum' }]); 126 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 127 | 128 | // Call layout 129 | layout.layout(string, [container]); 130 | 131 | expect(layoutParagraphMock.mock.calls).toHaveLength(2); 132 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe('Lorem'); 133 | expect(layoutParagraphMock.mock.calls[1][0].string).toBe('ipsum'); 134 | }); 135 | 136 | test('should layout break lines at the beggining of fragment', () => { 137 | // Mock layoutColumn class function 138 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 139 | 140 | // Create instances 141 | const layout = new LayoutEngine({}); 142 | const string = AttributedString.fromFragments([{ string: '\nLorem ipsum' }]); 143 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 144 | 145 | // Call layout 146 | layout.layout(string, [container]); 147 | 148 | expect(layoutParagraphMock.mock.calls).toHaveLength(2); 149 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe(''); 150 | expect(layoutParagraphMock.mock.calls[0][0].runs).toHaveLength(1); 151 | expect(layoutParagraphMock.mock.calls[1][0].string).toBe('Lorem ipsum'); 152 | expect(layoutParagraphMock.mock.calls[1][0].runs).toHaveLength(1); 153 | }); 154 | 155 | test('should layout two consecutive break lines at the beggining of fragment', () => { 156 | // Mock layoutColumn class function 157 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 158 | 159 | // Create instances 160 | const layout = new LayoutEngine({}); 161 | const string = AttributedString.fromFragments([{ string: '\n\nLorem ipsum' }]); 162 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 163 | 164 | // Call layout 165 | layout.layout(string, [container]); 166 | 167 | expect(layoutParagraphMock.mock.calls).toHaveLength(3); 168 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe(''); 169 | expect(layoutParagraphMock.mock.calls[0][0].runs).toHaveLength(1); 170 | expect(layoutParagraphMock.mock.calls[1][0].string).toBe(''); 171 | expect(layoutParagraphMock.mock.calls[1][0].runs).toHaveLength(1); 172 | expect(layoutParagraphMock.mock.calls[2][0].string).toBe('Lorem ipsum'); 173 | expect(layoutParagraphMock.mock.calls[2][0].runs).toHaveLength(1); 174 | }); 175 | 176 | test('should layout break lines in between fragment', () => { 177 | // Mock layoutColumn class function 178 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 179 | 180 | // Create instances 181 | const layout = new LayoutEngine({}); 182 | const string = AttributedString.fromFragments([{ string: 'Lorem\nipsum' }]); 183 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 184 | 185 | // Call layout 186 | layout.layout(string, [container]); 187 | 188 | expect(layoutParagraphMock.mock.calls).toHaveLength(2); 189 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe('Lorem'); 190 | expect(layoutParagraphMock.mock.calls[0][0].runs).toHaveLength(1); 191 | expect(layoutParagraphMock.mock.calls[1][0].string).toBe('ipsum'); 192 | expect(layoutParagraphMock.mock.calls[1][0].runs).toHaveLength(1); 193 | }); 194 | 195 | test('should layout two consecutive break lines in between fragment', () => { 196 | // Mock layoutColumn class function 197 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 198 | 199 | // Create instances 200 | const layout = new LayoutEngine({}); 201 | const string = AttributedString.fromFragments([{ string: 'Lorem\n\nipsum' }]); 202 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 203 | 204 | // Call layout 205 | layout.layout(string, [container]); 206 | 207 | expect(layoutParagraphMock.mock.calls).toHaveLength(3); 208 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe('Lorem'); 209 | expect(layoutParagraphMock.mock.calls[0][0].runs).toHaveLength(1); 210 | expect(layoutParagraphMock.mock.calls[1][0].string).toBe(''); 211 | expect(layoutParagraphMock.mock.calls[1][0].runs).toHaveLength(1); 212 | expect(layoutParagraphMock.mock.calls[2][0].string).toBe('ipsum'); 213 | expect(layoutParagraphMock.mock.calls[2][0].runs).toHaveLength(1); 214 | }); 215 | 216 | test('should ignore break line at the end of fragment', () => { 217 | // Mock layoutColumn class function 218 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 219 | 220 | // Create instances 221 | const layout = new LayoutEngine({}); 222 | const string = AttributedString.fromFragments([{ string: 'Lorem ipsum\n' }]); 223 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 224 | 225 | // Call layout 226 | layout.layout(string, [container]); 227 | 228 | expect(layoutParagraphMock.mock.calls).toHaveLength(1); 229 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe('Lorem ipsum'); 230 | expect(layoutParagraphMock.mock.calls[0][0].runs).toHaveLength(1); 231 | }); 232 | 233 | test('should layout two consecutive break lines at the end of fragment', () => { 234 | // Mock layoutColumn class function 235 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 236 | 237 | // Create instances 238 | const layout = new LayoutEngine({}); 239 | const string = AttributedString.fromFragments([{ string: 'Lorem ipsum\n\n' }]); 240 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 241 | 242 | // Call layout 243 | layout.layout(string, [container]); 244 | 245 | expect(layoutParagraphMock.mock.calls).toHaveLength(2); 246 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe('Lorem ipsum'); 247 | expect(layoutParagraphMock.mock.calls[0][0].runs).toHaveLength(1); 248 | expect(layoutParagraphMock.mock.calls[1][0].string).toBe(''); 249 | expect(layoutParagraphMock.mock.calls[1][0].runs).toHaveLength(1); 250 | }); 251 | 252 | test('should layout two consecutive break lines in different runs', () => { 253 | // Mock layoutColumn class function 254 | LayoutEngine.prototype.layoutParagraph = layoutParagraphMock; 255 | 256 | // Create instances 257 | const layout = new LayoutEngine({}); 258 | const string = AttributedString.fromFragments([ 259 | { string: 'Lorem' }, 260 | { string: '\n' }, 261 | { string: '\n' }, 262 | { string: 'ipsum' }, 263 | { string: '\n' }, 264 | { string: '\n' }, 265 | { string: 'dolor' } 266 | ]); 267 | 268 | const container = createRectContainer(0, 0, 300, 200, { columns: 1, columnGap: 20 }); 269 | 270 | // Call layout 271 | layout.layout(string, [container]); 272 | 273 | expect(layoutParagraphMock.mock.calls).toHaveLength(5); 274 | expect(layoutParagraphMock.mock.calls[0][0].string).toBe('Lorem'); 275 | expect(layoutParagraphMock.mock.calls[0][0].runs).toHaveLength(1); 276 | expect(layoutParagraphMock.mock.calls[1][0].string).toBe(''); 277 | expect(layoutParagraphMock.mock.calls[1][0].runs).toHaveLength(1); 278 | expect(layoutParagraphMock.mock.calls[2][0].string).toBe('ipsum'); 279 | expect(layoutParagraphMock.mock.calls[2][0].runs).toHaveLength(1); 280 | expect(layoutParagraphMock.mock.calls[3][0].string).toBe(''); 281 | expect(layoutParagraphMock.mock.calls[3][0].runs).toHaveLength(1); 282 | expect(layoutParagraphMock.mock.calls[4][0].string).toBe('dolor'); 283 | expect(layoutParagraphMock.mock.calls[4][0].runs).toHaveLength(1); 284 | }); 285 | 286 | test('should be able to inject custom lineBreaker engine', () => { 287 | const layout = new LayoutEngine({ 288 | lineBreaker: createEngine 289 | }); 290 | 291 | expect(layout.typesetter.lineBreaker.constructor.name).toBe(TestEngine.name); 292 | }); 293 | 294 | test('should be able to inject custom lineFragmentGenerator engine', () => { 295 | const layout = new LayoutEngine({ 296 | lineFragmentGenerator: createEngine 297 | }); 298 | 299 | expect(layout.typesetter.lineFragmentGenerator.constructor.name).toBe(TestEngine.name); 300 | }); 301 | 302 | test('should be able to inject custom justificationEngine engine', () => { 303 | const layout = new LayoutEngine({ 304 | justificationEngine: createEngine 305 | }); 306 | 307 | expect(layout.typesetter.justificationEngine.constructor.name).toBe(TestEngine.name); 308 | }); 309 | 310 | test('should be able to inject custom truncationEngine engine', () => { 311 | const layout = new LayoutEngine({ 312 | truncationEngine: createEngine 313 | }); 314 | 315 | expect(layout.typesetter.truncationEngine.constructor.name).toBe(TestEngine.name); 316 | }); 317 | 318 | test('should be able to inject custom decorationEngine engine', () => { 319 | const layout = new LayoutEngine({ 320 | decorationEngine: createEngine 321 | }); 322 | 323 | expect(layout.typesetter.decorationEngine.constructor.name).toBe(TestEngine.name); 324 | }); 325 | 326 | test('should be able to inject custom tabEngine engine', () => { 327 | const layout = new LayoutEngine({ 328 | tabEngine: createEngine 329 | }); 330 | 331 | expect(layout.typesetter.tabEngine.constructor.name).toBe(TestEngine.name); 332 | }); 333 | }); 334 | -------------------------------------------------------------------------------- /packages/core/test/layout/Typesetter.test.js: -------------------------------------------------------------------------------- 1 | import Typesetter from '../../src/layout/Typesetter'; 2 | 3 | class TestEngine {} 4 | 5 | describe('Typesetter', () => { 6 | test('should be able to inject custom lineBreaker engine', () => { 7 | const typesetter = new Typesetter({ 8 | lineBreaker: new TestEngine() 9 | }); 10 | 11 | expect(typesetter.lineBreaker.constructor.name).toBe(TestEngine.name); 12 | }); 13 | 14 | test('should be able to inject custom lineFragmentGenerator engine', () => { 15 | const typesetter = new Typesetter({ 16 | lineFragmentGenerator: new TestEngine() 17 | }); 18 | 19 | expect(typesetter.lineFragmentGenerator.constructor.name).toBe(TestEngine.name); 20 | }); 21 | 22 | test('should be able to inject custom justificationEngine engine', () => { 23 | const typesetter = new Typesetter({ 24 | justificationEngine: new TestEngine() 25 | }); 26 | 27 | expect(typesetter.justificationEngine.constructor.name).toBe(TestEngine.name); 28 | }); 29 | 30 | test('should be able to inject custom truncationEngine engine', () => { 31 | const typesetter = new Typesetter({ 32 | truncationEngine: new TestEngine() 33 | }); 34 | 35 | expect(typesetter.truncationEngine.constructor.name).toBe(TestEngine.name); 36 | }); 37 | 38 | test('should be able to inject custom decorationEngine engine', () => { 39 | const typesetter = new Typesetter({ 40 | decorationEngine: new TestEngine() 41 | }); 42 | 43 | expect(typesetter.decorationEngine.constructor.name).toBe(TestEngine.name); 44 | }); 45 | 46 | test('should be able to inject custom tabEngine engine', () => { 47 | const typesetter = new Typesetter({ 48 | tabEngine: new TestEngine() 49 | }); 50 | 51 | expect(typesetter.tabEngine.constructor.name).toBe(TestEngine.name); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /packages/core/test/layout/flattenRuns.test.js: -------------------------------------------------------------------------------- 1 | import Run from '../../src/models/Run'; 2 | import flattenRuns from '../../src/layout/flattenRuns'; 3 | 4 | describe('flattenRuns', () => { 5 | test('should return empty array if no runs passed', () => { 6 | const runs = flattenRuns(); 7 | 8 | expect(runs).toHaveLength(0); 9 | }); 10 | 11 | test('should return empty run', () => { 12 | const runs = flattenRuns([new Run(0, 0, { strike: true })]); 13 | 14 | expect(runs).toHaveLength(1); 15 | expect(runs[0].attributes).toEqual({ strike: true }); 16 | }); 17 | 18 | test('should merge two empty runs', () => { 19 | const runs = flattenRuns([new Run(0, 0, { strike: true }), new Run(0, 0, { color: 'red' })]); 20 | 21 | expect(runs).toHaveLength(1); 22 | expect(runs[0].attributes).toEqual({ strike: true, color: 'red' }); 23 | }); 24 | 25 | test('should merge two equal runs into one', () => { 26 | const runs = flattenRuns([new Run(0, 10, { strike: true }), new Run(0, 10, { color: 'red' })]); 27 | 28 | expect(runs).toHaveLength(1); 29 | expect(runs[0]).toHaveProperty('start', 0); 30 | expect(runs[0]).toHaveProperty('end', 10); 31 | expect(runs[0].attributes).toEqual({ strike: true, color: 'red' }); 32 | }); 33 | 34 | test('should split containing runs in two', () => { 35 | const runs = flattenRuns([new Run(0, 10, { strike: true }), new Run(0, 15, { color: 'red' })]); 36 | 37 | expect(runs).toHaveLength(2); 38 | 39 | expect(runs[0]).toHaveProperty('start', 0); 40 | expect(runs[0]).toHaveProperty('end', 10); 41 | expect(runs[0].attributes).toEqual({ strike: true, color: 'red' }); 42 | 43 | expect(runs[1]).toHaveProperty('start', 10); 44 | expect(runs[1]).toHaveProperty('end', 15); 45 | expect(runs[1].attributes).toEqual({ color: 'red' }); 46 | }); 47 | 48 | test('should split containing runs in three', () => { 49 | const runs = flattenRuns([new Run(0, 10, { strike: true }), new Run(5, 15, { color: 'red' })]); 50 | 51 | expect(runs).toHaveLength(3); 52 | 53 | expect(runs[0]).toHaveProperty('start', 0); 54 | expect(runs[0]).toHaveProperty('end', 5); 55 | expect(runs[0].attributes).toEqual({ strike: true }); 56 | 57 | expect(runs[1]).toHaveProperty('start', 5); 58 | expect(runs[1]).toHaveProperty('end', 10); 59 | expect(runs[1].attributes).toEqual({ strike: true, color: 'red' }); 60 | 61 | expect(runs[2]).toHaveProperty('start', 10); 62 | expect(runs[2]).toHaveProperty('end', 15); 63 | expect(runs[2].attributes).toEqual({ color: 'red' }); 64 | }); 65 | 66 | test('should leave disjoint runs as they are', () => { 67 | const runs = flattenRuns([new Run(0, 10, { strike: true }), new Run(10, 20, { color: 'red' })]); 68 | 69 | expect(runs).toHaveLength(2); 70 | 71 | expect(runs[0]).toHaveProperty('start', 0); 72 | expect(runs[0]).toHaveProperty('end', 10); 73 | expect(runs[0].attributes).toEqual({ strike: true }); 74 | 75 | expect(runs[1]).toHaveProperty('start', 10); 76 | expect(runs[1]).toHaveProperty('end', 20); 77 | expect(runs[1].attributes).toEqual({ color: 'red' }); 78 | }); 79 | 80 | test('should fill empty spaces with empty runs', () => { 81 | const runs = flattenRuns([new Run(0, 10, { strike: true }), new Run(15, 20, { color: 'red' })]); 82 | 83 | expect(runs).toHaveLength(3); 84 | 85 | expect(runs[1]).toHaveProperty('start', 10); 86 | expect(runs[1]).toHaveProperty('end', 15); 87 | expect(runs[1].attributes).toEqual({}); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /packages/core/test/models/Attachment.test.js: -------------------------------------------------------------------------------- 1 | import Attachment from '../../src/models/Attachment'; 2 | 3 | describe('Attachment', () => { 4 | test('should handle passed width', () => { 5 | const attachment = new Attachment(5); 6 | 7 | expect(attachment).toHaveProperty('width', 5); 8 | }); 9 | 10 | test('should handle passed height', () => { 11 | const attachment = new Attachment(5, 10); 12 | 13 | expect(attachment).toHaveProperty('height', 10); 14 | }); 15 | 16 | test('should handle passed image', () => { 17 | const opts = { image: 'blah' }; 18 | const attachment = new Attachment(5, 10, opts); 19 | 20 | expect(attachment).toHaveProperty('image', opts.image); 21 | }); 22 | 23 | test('should handle passed render', () => { 24 | const opts = { render: 'blah' }; 25 | const attachment = new Attachment(5, 10, opts); 26 | 27 | expect(attachment).toHaveProperty('render', opts.render); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/core/test/models/AttributedString.test.js: -------------------------------------------------------------------------------- 1 | import Run from '../../src/models/Run'; 2 | import AttributedString from '../../src/models/AttributedString'; 3 | 4 | const testString = 'Lorem ipsum'; 5 | const testRuns = [new Run(0, 6, { attr: 1 }), new Run(6, 11, { attr: 2 })]; 6 | 7 | describe('AttributedString', () => { 8 | test('should handle passed string', () => { 9 | const attributedString = new AttributedString(testString); 10 | 11 | expect(attributedString.string).toBe(testString); 12 | expect(attributedString.length).toBe(testString.length); 13 | }); 14 | 15 | test('should not have runs when created', () => { 16 | const string = new AttributedString(); 17 | 18 | expect(string.runs).toHaveLength(0); 19 | }); 20 | 21 | test('should handle passed runs', () => { 22 | const attributedString = new AttributedString(testString, testRuns); 23 | 24 | expect(attributedString.runs).toBe(testRuns); 25 | }); 26 | 27 | test('should be constructed by fragments', () => { 28 | const attributedString = AttributedString.fromFragments([{ string: 'Hey' }, { string: ' ho' }]); 29 | 30 | const expectedString = 'Hey ho'; 31 | 32 | expect(attributedString.string).toBe(expectedString); 33 | expect(attributedString.length).toBe(expectedString.length); 34 | expect(attributedString.runs[0].start).toBe(0); 35 | expect(attributedString.runs[1].start).toBe(3); 36 | }); 37 | 38 | test('should slice with one run', () => { 39 | const run = [new Run(0, 11, { attr: 1 })]; 40 | const attributedString = new AttributedString(testString, run); 41 | const splittedString = attributedString.slice(2, 8); 42 | 43 | expect(splittedString.length).toBe(6); 44 | expect(splittedString.string).toBe('rem ip'); 45 | expect(splittedString.runs[0]).toHaveProperty('start', 0); 46 | expect(splittedString.runs[0]).toHaveProperty('end', 6); 47 | expect(splittedString.runs[0]).toHaveProperty('attributes', { attr: 1 }); 48 | }); 49 | 50 | test('should return correct run index', () => { 51 | const attributedString = new AttributedString(testString, testRuns); 52 | 53 | expect(attributedString.runIndexAt(0)).toBe(0); 54 | expect(attributedString.runIndexAt(5)).toBe(0); 55 | expect(attributedString.runIndexAt(6)).toBe(1); 56 | expect(attributedString.runIndexAt(10)).toBe(1); 57 | }); 58 | 59 | test('should slice with two runs', () => { 60 | const attributedString = new AttributedString(testString, testRuns); 61 | const splittedString = attributedString.slice(2, 8); 62 | 63 | expect(splittedString.length).toBe(6); 64 | expect(splittedString.string).toBe('rem ip'); 65 | expect(splittedString.runs[0]).toHaveProperty('start', 0); 66 | expect(splittedString.runs[0]).toHaveProperty('end', 4); 67 | expect(splittedString.runs[0]).toHaveProperty('attributes', { attr: 1 }); 68 | expect(splittedString.runs[1]).toHaveProperty('start', 4); 69 | expect(splittedString.runs[1]).toHaveProperty('end', 6); 70 | expect(splittedString.runs[1]).toHaveProperty('attributes', { attr: 2 }); 71 | }); 72 | 73 | test('should slice with several runs', () => { 74 | const runs = [ 75 | new Run(0, 3, { attr: 1 }), 76 | new Run(3, 6, { attr: 2 }), 77 | new Run(6, 11, { attr: 3 }) 78 | ]; 79 | const attributedString = new AttributedString(testString, runs); 80 | const splittedString = attributedString.slice(2, 8); 81 | 82 | expect(splittedString.length).toBe(6); 83 | expect(splittedString.string).toBe('rem ip'); 84 | expect(splittedString.runs[0]).toHaveProperty('start', 0); 85 | expect(splittedString.runs[0]).toHaveProperty('end', 1); 86 | expect(splittedString.runs[0]).toHaveProperty('attributes', { attr: 1 }); 87 | expect(splittedString.runs[1]).toHaveProperty('start', 1); 88 | expect(splittedString.runs[1]).toHaveProperty('end', 4); 89 | expect(splittedString.runs[1]).toHaveProperty('attributes', { attr: 2 }); 90 | expect(splittedString.runs[2]).toHaveProperty('start', 4); 91 | expect(splittedString.runs[2]).toHaveProperty('end', 6); 92 | expect(splittedString.runs[2]).toHaveProperty('attributes', { attr: 3 }); 93 | }); 94 | 95 | test('should ignore unnecesary leading runs when slice', () => { 96 | const attributedString = new AttributedString(testString, testRuns); 97 | const splittedString = attributedString.slice(6, 11); 98 | 99 | expect(splittedString.length).toBe(5); 100 | expect(splittedString.runs.length).toBe(1); 101 | expect(splittedString.string).toBe('ipsum'); 102 | expect(splittedString.runs[0]).toHaveProperty('start', 0); 103 | expect(splittedString.runs[0]).toHaveProperty('end', 5); 104 | expect(splittedString.runs[0]).toHaveProperty('attributes', { attr: 2 }); 105 | }); 106 | 107 | test('should ignore unnecesary trailing runs when slice', () => { 108 | const attributedString = new AttributedString(testString, testRuns); 109 | const splittedString = attributedString.slice(1, 6); 110 | 111 | expect(splittedString.length).toBe(5); 112 | expect(splittedString.runs.length).toBe(1); 113 | expect(splittedString.string).toBe('orem '); 114 | expect(splittedString.runs[0]).toHaveProperty('start', 0); 115 | expect(splittedString.runs[0]).toHaveProperty('end', 5); 116 | expect(splittedString.runs[0]).toHaveProperty('attributes', { attr: 1 }); 117 | }); 118 | 119 | test('should trim string with no trailing spaces', () => { 120 | const runs = [ 121 | new Run(0, 3, { attr: 1 }), 122 | new Run(3, 6, { attr: 2 }), 123 | new Run(6, 11, { attr: 3 }) 124 | ]; 125 | const string = new AttributedString(testString, runs).trim(); 126 | 127 | expect(string.length).toBe(11); 128 | expect(string.string).toBe(testString); 129 | expect(string.runs.length).toBe(3); 130 | }); 131 | 132 | test('should trim string with trailing spaces', () => { 133 | const runs = [ 134 | new Run(0, 3, { attr: 1 }), 135 | new Run(3, 6, { attr: 2 }), 136 | new Run(6, 11, { attr: 3 }) 137 | ]; 138 | const string = new AttributedString('Lorem ', runs).trim(); 139 | 140 | expect(string.length).toBe(5); 141 | expect(string.string).toBe('Lorem'); 142 | expect(string.runs.length).toBe(2); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /packages/core/test/models/Block.test.js: -------------------------------------------------------------------------------- 1 | import Block from '../../src/models/Block'; 2 | import BBox from '../../src/geom/BBox'; 3 | import Rect from '../../src/geom/Rect'; 4 | 5 | const testLines = [ 6 | { string: 'Lorem ipsum dolor sit amet, ', rect: new Rect(0, 0, 20, 20) }, 7 | { string: 'consectetur adipiscing elit, ', rect: new Rect(10, 10, 20, 20) } 8 | ]; 9 | 10 | describe('Block', () => { 11 | test('should have empty style by default', () => { 12 | const block = new Block(); 13 | 14 | expect(block.style).toEqual({}); 15 | }); 16 | 17 | test('should have no lines by default', () => { 18 | const block = new Block(); 19 | 20 | expect(block.lines).toHaveLength(0); 21 | }); 22 | 23 | test('should return string length 0 by default', () => { 24 | const block = new Block(); 25 | 26 | expect(block.stringLength).toBe(0); 27 | }); 28 | 29 | test('should have empty bbox by default', () => { 30 | const block = new Block(); 31 | 32 | expect(block.bbox).toEqual(new BBox()); 33 | }); 34 | 35 | test('should return passed styles', () => { 36 | const style = { someStyle: 'someValue' }; 37 | const block = new Block([], style); 38 | 39 | expect(block.style).toEqual(style); 40 | }); 41 | 42 | test('should return lines string length', () => { 43 | const block = new Block(testLines); 44 | 45 | expect(block.stringLength).toBe(28 + 29); 46 | }); 47 | 48 | test('should return lines bounding box', () => { 49 | const block = new Block(testLines); 50 | 51 | expect(block.bbox.minX).toBe(0); 52 | expect(block.bbox.minY).toBe(0); 53 | expect(block.bbox.maxX).toBe(30); 54 | expect(block.bbox.maxY).toBe(30); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/core/test/models/GlyphRun.test.js: -------------------------------------------------------------------------------- 1 | import Attachment from '../../src/models/Attachment'; 2 | import testFont from '../utils/font'; 3 | import { createLatinTestRun, createCamboyanTestRun } from '../utils/glyphRuns'; 4 | 5 | const round = num => Math.round(num * 100) / 100; 6 | 7 | describe('GlyphRun', () => { 8 | test('should get correct length', () => { 9 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 10 | 11 | expect(glyphRun.length).toBe(11); 12 | }); 13 | 14 | test('should get correct start', () => { 15 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 16 | 17 | expect(glyphRun.start).toBe(0); 18 | }); 19 | 20 | test('should get correct start (non latin)', () => { 21 | const glyphRun = createCamboyanTestRun({ value: 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន' }); 22 | 23 | expect(glyphRun.start).toBe(0); 24 | }); 25 | 26 | test('should get correct end', () => { 27 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 28 | 29 | expect(glyphRun.end).toBe(11); 30 | }); 31 | 32 | test('should get correct end (non latin)', () => { 33 | const glyphRun = createCamboyanTestRun({ value: 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន' }); 34 | 35 | expect(glyphRun.end).toBe(16); 36 | }); 37 | 38 | test('should get ascent correctly when no attachments', () => { 39 | const fontSize = 20; 40 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum', attributes: { fontSize } }); 41 | const scale = fontSize / testFont.unitsPerEm; 42 | 43 | expect(glyphRun.ascent).toBe(testFont.ascent * scale); 44 | }); 45 | 46 | test('should get ascent correctly when higher attachments', () => { 47 | const attachment = new Attachment(20, 50); 48 | const glyphRun = createLatinTestRun({ attributes: { attachment } }); 49 | 50 | expect(glyphRun.ascent).toBe(50); 51 | }); 52 | 53 | test('should get ascent correctly when lower attachments', () => { 54 | const fontSize = 30; 55 | const attachment = new Attachment(20, 10); 56 | const glyphRun = createLatinTestRun({ attributes: { attachment, fontSize } }); 57 | const scale = fontSize / testFont.unitsPerEm; 58 | 59 | expect(glyphRun.ascent).toBe(testFont.ascent * scale); 60 | }); 61 | 62 | test('should get descent correctly when no attachments', () => { 63 | const fontSize = 20; 64 | const glyphRun = createLatinTestRun({ attributes: { fontSize } }); 65 | const scale = fontSize / testFont.unitsPerEm; 66 | 67 | expect(glyphRun.descent).toBe(testFont.descent * scale); 68 | }); 69 | 70 | test('should get descent correctly when no attachments', () => { 71 | const fontSize = 20; 72 | const glyphRun = createLatinTestRun({ attributes: { fontSize } }); 73 | const scale = fontSize / testFont.unitsPerEm; 74 | 75 | expect(glyphRun.descent).toBe(testFont.descent * scale); 76 | }); 77 | 78 | test('should get lineGap correctly when no attachments', () => { 79 | const fontSize = 20; 80 | const glyphRun = createLatinTestRun({ attributes: { fontSize } }); 81 | const scale = fontSize / testFont.unitsPerEm; 82 | 83 | expect(glyphRun.lineGap).toBe(testFont.lineGap * scale); 84 | }); 85 | 86 | test('should get height correctly when no attachments', () => { 87 | const fontSize = 20; 88 | const glyphRun = createLatinTestRun({ attributes: { fontSize } }); 89 | const { ascent, descent, lineGap, unitsPerEm } = testFont; 90 | 91 | const scale = fontSize / unitsPerEm; 92 | const expectedHeight = (ascent - descent + lineGap) * scale; 93 | 94 | expect(glyphRun.height).toBe(expectedHeight); 95 | }); 96 | 97 | test('should get character spacing correctly', () => { 98 | // xAdvances without character spacing: 99 | // L o r e m 100 | // 10 10 10 10 10 101 | 102 | const fontSize = 20; 103 | const characterSpacing = 10; 104 | const glyphRun = createLatinTestRun({ 105 | value: 'Lorem', 106 | attributes: { fontSize, characterSpacing } 107 | }); 108 | 109 | expect(glyphRun.positions.map(p => p.xAdvance)).toEqual([20, 20, 20, 20, 10]); 110 | }); 111 | 112 | test('should exact slice range return same run', () => { 113 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 114 | const sliced = glyphRun.slice(0, 11); 115 | 116 | expect(sliced.start).toBe(0); 117 | expect(sliced.end).toBe(11); 118 | }); 119 | 120 | test('should slice containing range', () => { 121 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 122 | const sliced = glyphRun.slice(2, 5); 123 | 124 | expect(sliced.start).toBe(2); 125 | expect(sliced.end).toBe(5); 126 | }); 127 | 128 | test('should slice exceeding range', () => { 129 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 130 | const sliced = glyphRun.slice(2, 20); 131 | 132 | expect(sliced.start).toBe(2); 133 | expect(sliced.end).toBe(11); 134 | }); 135 | 136 | test('should slice containing range when start not zero', () => { 137 | const glyphRun = createLatinTestRun({ start: 5 }); 138 | const sliced = glyphRun.slice(2, 5); 139 | 140 | expect(sliced.start).toBe(7); 141 | expect(sliced.end).toBe(10); 142 | }); 143 | 144 | test('should slice exceeding range when start not zero', () => { 145 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum', start: 5 }); 146 | const sliced = glyphRun.slice(2, 20); 147 | 148 | expect(sliced.start).toBe(7); 149 | expect(sliced.end).toBe(11); 150 | }); 151 | 152 | test('should correctly slice glyphs', () => { 153 | // 76 111 114 101 109 32 73 112 115 117 109 154 | // l o r e m i p s u m 155 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 156 | const { glyphs } = glyphRun.slice(2, 8); 157 | 158 | expect(glyphs.map(g => g.id)).toEqual([114, 101, 109, 32, 73, 112]); 159 | }); 160 | 161 | test('should correctly slice glyphs (non latin)', () => { 162 | const glyphRun = createCamboyanTestRun({ value: 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន' }); 163 | const { glyphs } = glyphRun.slice(1, 8); 164 | 165 | expect(glyphs.map(g => g.id)).toEqual([6098, 6025, 6075, 6086, 6050, 6070, 6021]); 166 | }); 167 | 168 | test('should exact slice return same glyphs', () => { 169 | // 76 111 114 101 109 32 73 112 115 117 109 170 | // l o r e m i p s u m 171 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 172 | const { glyphs } = glyphRun.slice(0, 11); 173 | 174 | expect(glyphs.map(g => g.id)).toEqual([76, 111, 114, 101, 109, 32, 73, 112, 115, 117, 109]); 175 | }); 176 | 177 | test('should exact slice return same glyphs (non latin)', () => { 178 | const glyphRun = createCamboyanTestRun({ value: 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន' }); 179 | const { glyphs } = glyphRun.slice(0, 21); 180 | 181 | expect(glyphs.map(g => g.id)).toEqual([ 182 | 6017, 183 | 6098, 184 | 6025, 185 | 6075, 186 | 6086, 187 | 6050, 188 | 6070, 189 | 6021, 190 | 6025, 191 | 6089, 192 | 6070, 193 | 6086, 194 | 6016, 195 | 6025, 196 | 6098, 197 | 6021 198 | ]); 199 | }); 200 | 201 | test('should correctly slice positions', () => { 202 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 203 | const sliced = glyphRun.slice(2, 8); 204 | const positions = sliced.positions.map(p => round(p.xAdvance)); 205 | 206 | expect(positions).toEqual([6, 6, 6, 3, 6, 6]); 207 | }); 208 | 209 | test('should correctly slice positions (non latin)', () => { 210 | const glyphRun = createCamboyanTestRun({ value: 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន' }); 211 | const sliced = glyphRun.slice(1, 8); 212 | const positions = sliced.positions.map(p => round(p.xAdvance)); 213 | 214 | expect(positions).toEqual([0, 0, 0, 9.08, 4.54, 9.08, 18.16]); 215 | }); 216 | 217 | test('should exact slice return same positions', () => { 218 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 219 | const sliced = glyphRun.slice(0, 11); 220 | const positions = sliced.positions.map(p => round(p.xAdvance)); 221 | 222 | expect(positions).toEqual([6, 6, 6, 6, 6, 3, 6, 6, 6, 6, 6]); 223 | }); 224 | 225 | test('should exact slice return same positions (non latin)', () => { 226 | const glyphRun = createCamboyanTestRun({ value: 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន' }); 227 | const sliced = glyphRun.slice(0, 21); 228 | const positions = sliced.positions.map(p => round(p.xAdvance)); 229 | 230 | expect(positions).toEqual([ 231 | 9.08, 232 | 0, 233 | 0, 234 | 0, 235 | 9.08, 236 | 4.54, 237 | 9.08, 238 | 18.16, 239 | 9.08, 240 | 13.62, 241 | 0, 242 | 9.08, 243 | 0, 244 | 9.08, 245 | 4.54, 246 | 9.08 247 | ]); 248 | }); 249 | 250 | test('should correctly slice string indices', () => { 251 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 252 | const { stringIndices } = glyphRun.slice(2, 8); 253 | 254 | expect(stringIndices).toEqual([0, 1, 2, 3, 4, 5]); 255 | }); 256 | 257 | test('should correctly slice glyph indices', () => { 258 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 259 | const { glyphIndices } = glyphRun.slice(2, 8); 260 | 261 | expect(glyphIndices).toEqual([0, 1, 2, 3, 4, 5]); 262 | }); 263 | 264 | test('should correctly slice string indices (non latin)', () => { 265 | const glyphRun = createCamboyanTestRun({ value: 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន' }); 266 | const { stringIndices } = glyphRun.slice(1, 8); 267 | 268 | expect(stringIndices).toEqual([0, 2, 3, 4, 5, 6, 7]); 269 | }); 270 | 271 | test('should correctly slice glyph indices (non latin)', () => { 272 | const glyphRun = createCamboyanTestRun({ value: 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន' }); 273 | const { glyphIndices } = glyphRun.slice(1, 8); 274 | 275 | expect(glyphIndices).toEqual([0, 1, 1, 2, 3, 4, 5, 6]); 276 | }); 277 | 278 | test('should exact slice return same string indices', () => { 279 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 280 | const { stringIndices } = glyphRun.slice(0, 11); 281 | 282 | expect(stringIndices).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); 283 | }); 284 | 285 | test('should exact slice return same glyph indices', () => { 286 | const glyphRun = createLatinTestRun({ value: 'Lorem Ipsum' }); 287 | const { glyphIndices } = glyphRun.slice(0, 11); 288 | 289 | expect(glyphIndices).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); 290 | }); 291 | 292 | test('should exact slice return same string indices (non latin)', () => { 293 | const glyphRun = createCamboyanTestRun({ value: 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន' }); 294 | const { stringIndices } = glyphRun.slice(0, 21); 295 | 296 | expect(stringIndices).toEqual([0, 1, 3, 4, 5, 6, 7, 8, 12, 13, 14, 16, 17, 18, 19, 20]); 297 | }); 298 | 299 | test('should exact slice return same glyph indices (non latin)', () => { 300 | const glyphRun = createCamboyanTestRun({ value: 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន' }); 301 | const { glyphIndices } = glyphRun.slice(0, 21); 302 | 303 | expect(glyphIndices).toEqual([ 304 | 0, 305 | 1, 306 | 2, 307 | 2, 308 | 3, 309 | 4, 310 | 5, 311 | 6, 312 | 7, 313 | 8, 314 | 8, 315 | 8, 316 | 8, 317 | 9, 318 | 10, 319 | 11, 320 | 11, 321 | 12, 322 | 13, 323 | 14, 324 | 15 325 | ]); 326 | }); 327 | }); 328 | -------------------------------------------------------------------------------- /packages/core/test/models/Range.test.js: -------------------------------------------------------------------------------- 1 | import Range from '../../src/models/Range'; 2 | 3 | describe('Range', () => { 4 | test('should handle passed start and end', () => { 5 | const range = new Range(5, 10); 6 | 7 | expect(range).toHaveProperty('start', 5); 8 | expect(range).toHaveProperty('end', 10); 9 | }); 10 | 11 | test('should calculate length correctly', () => { 12 | const range = new Range(5, 10); 13 | 14 | expect(range.length).toBe(6); 15 | }); 16 | 17 | test('should equals with equal range', () => { 18 | const range1 = new Range(5, 10); 19 | const range2 = new Range(5, 10); 20 | 21 | expect(range1.equals(range2)).toBeTruthy(); 22 | }); 23 | 24 | test('should not equals with different range', () => { 25 | const range1 = new Range(5, 10); 26 | const range2 = new Range(0, 5); 27 | 28 | expect(range1.equals(range2)).toBeFalsy(); 29 | }); 30 | 31 | test('should contain valid index', () => { 32 | const range = new Range(5, 10); 33 | 34 | expect(range.contains(6)).toBeTruthy(); 35 | }); 36 | 37 | test('should contain lower inclusive index', () => { 38 | const range = new Range(5, 10); 39 | 40 | expect(range.contains(5)).toBeTruthy(); 41 | }); 42 | 43 | test('should contain upper inclusive index', () => { 44 | const range = new Range(5, 10); 45 | 46 | expect(range.contains(10)).toBeTruthy(); 47 | }); 48 | 49 | test('should not contain invalid index', () => { 50 | const range = new Range(5, 10); 51 | 52 | expect(range.contains(11)).toBeFalsy(); 53 | }); 54 | 55 | test('should extend for lower values', () => { 56 | const range = new Range(5, 10); 57 | 58 | range.extend(2); 59 | 60 | expect(range).toHaveProperty('start', 2); 61 | expect(range).toHaveProperty('end', 10); 62 | }); 63 | 64 | test('should extend for inner values', () => { 65 | const range = new Range(5, 10); 66 | 67 | range.extend(7); 68 | 69 | expect(range).toHaveProperty('start', 5); 70 | expect(range).toHaveProperty('end', 10); 71 | }); 72 | 73 | test('should extend for upper values', () => { 74 | const range = new Range(5, 10); 75 | 76 | range.extend(12); 77 | 78 | expect(range).toHaveProperty('start', 5); 79 | expect(range).toHaveProperty('end', 12); 80 | }); 81 | 82 | test('should merge containing ranges', () => { 83 | const rangeA = new Range(5, 10); 84 | const rangeB = new Range(6, 9); 85 | 86 | const merged = Range.merge([rangeA, rangeB]); 87 | 88 | expect(merged).toHaveLength(1); 89 | expect(merged[0].start).toBe(5); 90 | expect(merged[0].end).toBe(10); 91 | }); 92 | 93 | test('should merge containing ranges', () => { 94 | const rangeA = new Range(5, 10); 95 | const rangeB = new Range(6, 9); 96 | 97 | const merged = Range.merge([rangeA, rangeB]); 98 | 99 | expect(merged).toHaveLength(1); 100 | expect(merged[0].start).toBe(5); 101 | expect(merged[0].end).toBe(10); 102 | }); 103 | 104 | test('should merge intersecting ranges', () => { 105 | const rangeA = new Range(5, 10); 106 | const rangeB = new Range(7, 12); 107 | 108 | const merged = Range.merge([rangeA, rangeB]); 109 | 110 | expect(merged).toHaveLength(1); 111 | expect(merged[0].start).toBe(5); 112 | expect(merged[0].end).toBe(12); 113 | }); 114 | 115 | test('should merge non-intersecting ranges', () => { 116 | const rangeA = new Range(5, 10); 117 | const rangeB = new Range(15, 25); 118 | 119 | const merged = Range.merge([rangeA, rangeB]); 120 | 121 | expect(merged).toHaveLength(2); 122 | expect(merged[0].start).toBe(5); 123 | expect(merged[0].end).toBe(10); 124 | expect(merged[1].start).toBe(15); 125 | expect(merged[1].end).toBe(25); 126 | }); 127 | 128 | test('should merge many unsorted ranges', () => { 129 | const rangeA = new Range(8, 12); 130 | const rangeB = new Range(5, 10); 131 | const rangeC = new Range(0, 2); 132 | 133 | const merged = Range.merge([rangeA, rangeB, rangeC]); 134 | 135 | expect(merged).toHaveLength(2); 136 | expect(merged[0].start).toBe(0); 137 | expect(merged[0].end).toBe(2); 138 | expect(merged[1].start).toBe(5); 139 | expect(merged[1].end).toBe(12); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /packages/core/test/models/Run.test.js: -------------------------------------------------------------------------------- 1 | import Run from '../../src/models/Run'; 2 | 3 | describe('Run', () => { 4 | test('should handle passed start and end', () => { 5 | const run = new Run(5, 10); 6 | 7 | expect(run).toHaveProperty('start', 5); 8 | expect(run).toHaveProperty('end', 10); 9 | }); 10 | 11 | test('should handle passed attributes', () => { 12 | const attrs = { something: 'blah' }; 13 | const run = new Run(5, 10, attrs); 14 | 15 | expect(run).toHaveProperty('attributes', attrs); 16 | }); 17 | 18 | test('should handle passed attributes', () => { 19 | const attrs = { something: 'blah' }; 20 | const run = new Run(5, 10, attrs); 21 | 22 | expect(run).toHaveProperty('attributes', attrs); 23 | }); 24 | 25 | test('should slice containing range', () => { 26 | const run = new Run(5, 15); 27 | const slice = run.slice(2, 5); 28 | 29 | expect(slice).toHaveProperty('start', 7); 30 | expect(slice).toHaveProperty('end', 10); 31 | }); 32 | 33 | test('should slice exceeding range', () => { 34 | const run = new Run(5, 15); 35 | const slice = run.slice(8, 13); 36 | 37 | expect(slice).toHaveProperty('start', 13); 38 | expect(slice).toHaveProperty('end', 15); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /packages/core/test/models/TabStop.test.js: -------------------------------------------------------------------------------- 1 | import TabStop from '../../src/models/TabStop'; 2 | 3 | describe('TabStop', () => { 4 | test('should handle have left alignment by default', () => { 5 | const tab = new TabStop(); 6 | 7 | expect(tab).toHaveProperty('align', 'left'); 8 | }); 9 | 10 | test('should handle passed x', () => { 11 | const tab = new TabStop(5); 12 | 13 | expect(tab).toHaveProperty('x', 5); 14 | }); 15 | 16 | test('should handle passed align', () => { 17 | const tab = new TabStop(5, 'center'); 18 | 19 | expect(tab).toHaveProperty('align', 'center'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/core/test/utils/container.js: -------------------------------------------------------------------------------- 1 | import Path from '../../src/geom/Path'; 2 | import Container from '../../src/models/Container'; 3 | 4 | /* eslint-disable-next-line */ 5 | export const createRectContainer = (x, y, width, height, attrs) => { 6 | const path = new Path(); 7 | path.rect(x, y, width, height); 8 | return new Container(path, attrs); 9 | }; 10 | -------------------------------------------------------------------------------- /packages/core/test/utils/font.js: -------------------------------------------------------------------------------- 1 | /* 2 | Test dummy font object. 3 | Values based on Roboto font 4 | https://fonts.google.com/specimen/Roboto 5 | */ 6 | const testFont = { 7 | ascent: 1900, 8 | descent: -500, 9 | unitsPerEm: 2048, 10 | lineGap: 0, 11 | glyphForCodePoint: id => ({ id }) 12 | }; 13 | 14 | export default testFont; 15 | -------------------------------------------------------------------------------- /packages/core/test/utils/glyphRuns.js: -------------------------------------------------------------------------------- 1 | import RunStyle from '../../src/models/RunStyle'; 2 | import GlyphRun from '../../src/models/GlyphRun'; 3 | import testFont from './font'; 4 | 5 | /* 6 | Returns a glyph index based on a string index and a string indices array 7 | */ 8 | const getGlyphIndex = stringIndices => index => { 9 | let res = 0; 10 | while (stringIndices[res] < index) res += 1; 11 | return res; 12 | }; 13 | 14 | const resolveGlyphIndices = (string, stringIndices) => { 15 | const glyphIndices = []; 16 | 17 | for (let i = 0; i < string.length; i++) { 18 | for (let j = 0; j < stringIndices.length; j++) { 19 | if (stringIndices[j] >= i) { 20 | glyphIndices[i] = j; 21 | break; 22 | } 23 | 24 | glyphIndices[i] = undefined; 25 | } 26 | } 27 | 28 | let lastValue = glyphIndices[glyphIndices.length - 1]; 29 | for (let i = glyphIndices.length - 1; i >= 0; i--) { 30 | if (glyphIndices[i] === undefined) { 31 | glyphIndices[i] = lastValue; 32 | } else { 33 | lastValue = glyphIndices[i]; 34 | } 35 | } 36 | 37 | lastValue = glyphIndices[0]; 38 | for (let i = 0; i < glyphIndices.length; i++) { 39 | if (glyphIndices[i] === undefined) { 40 | glyphIndices[i] = lastValue; 41 | } else { 42 | lastValue = glyphIndices[i]; 43 | } 44 | } 45 | 46 | return glyphIndices; 47 | }; 48 | 49 | /* 50 | Text layout generator. 51 | Calculates chars id based on the glyph code point only for testing purposes 52 | Calculates position with fixed value based on if it's white space or not 53 | */ 54 | export const layout = value => { 55 | const chars = value.replace(/ft/, 'f').split(''); 56 | const glyphs = chars.map(char => ({ id: char.charCodeAt(0) })); 57 | const stringIndices = chars.map((_, index) => value.indexOf(chars[index], index)); 58 | const glyphIndices = resolveGlyphIndices(value, stringIndices); 59 | const positions = chars.map(char => ({ 60 | xAdvance: char === ' ' ? 512 : 1024 61 | })); 62 | 63 | return { 64 | glyphs, 65 | positions, 66 | stringIndices, 67 | glyphIndices 68 | }; 69 | }; 70 | 71 | /* 72 | Dummy Latin language GlyphRun constructor that simulates fontkit's work. 73 | We don't use fontkit here because string indices are not merged into master yet 74 | */ 75 | export const createLatinTestRun = ({ 76 | value = 'Lorem Ipsum', 77 | attributes = {}, 78 | start = 0, 79 | end = value.length 80 | } = {}) => { 81 | const string = value.slice(start, end); 82 | const attrs = new RunStyle(Object.assign({}, { font: testFont }, attributes)); 83 | const { glyphs, positions, stringIndices, glyphIndices } = layout(string); 84 | 85 | return new GlyphRun( 86 | start, 87 | start + glyphs.length, 88 | attrs, 89 | glyphs, 90 | positions, 91 | stringIndices, 92 | glyphIndices 93 | ); 94 | }; 95 | 96 | /* 97 | Dummy Non-Latin language GlyphRun constructor that simulates fontkit's work. 98 | We don't use fontkit here because string indices are not merged into master yet 99 | Values were taken by using Khmer font 100 | */ 101 | export const createCamboyanTestRun = ({ 102 | value = 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន', 103 | attributes = {}, 104 | start = 0, 105 | end = value.length 106 | } = {}) => { 107 | const stringIndices = [0, 1, 3, 4, 5, 6, 7, 8, 12, 13, 14, 16, 17, 18, 19, 20]; 108 | const glyphIndices = [0, 1, 2, 2, 3, 4, 5, 6, 7, 8, 8, 8, 8, 9, 10, 11, 11, 12, 13, 14, 15]; 109 | const getIndex = getGlyphIndex(stringIndices); 110 | const startGlyphIndex = getIndex(start); 111 | const endGlyphIndex = getIndex(end); 112 | const string = value.slice(startGlyphIndex, endGlyphIndex); 113 | const attrs = new RunStyle(Object.assign({}, { font: testFont }, attributes)); 114 | const positions = [ 115 | { xAdvance: 1549 }, 116 | { xAdvance: 0 }, 117 | { xAdvance: 0 }, 118 | { xAdvance: 0 }, 119 | { xAdvance: 1549 }, 120 | { xAdvance: 775 }, 121 | { xAdvance: 1549 }, 122 | { xAdvance: 3099 }, 123 | { xAdvance: 1549 }, 124 | { xAdvance: 2324 }, 125 | { xAdvance: 0 }, 126 | { xAdvance: 1549 }, 127 | { xAdvance: 0 }, 128 | { xAdvance: 1549 }, 129 | { xAdvance: 775 }, 130 | { xAdvance: 1549 } 131 | ]; 132 | 133 | return new GlyphRun( 134 | startGlyphIndex, 135 | endGlyphIndex, 136 | attrs, 137 | layout(string).glyphs, 138 | positions.slice(startGlyphIndex, endGlyphIndex), 139 | stringIndices.slice(startGlyphIndex, endGlyphIndex), 140 | glyphIndices.slice(start, end).map(i => i - glyphIndices[start]) 141 | ); 142 | }; 143 | -------------------------------------------------------------------------------- /packages/core/test/utils/glyphStrings.js: -------------------------------------------------------------------------------- 1 | import RunStyle from '../../src/models/RunStyle'; 2 | import GlyphRun from '../../src/models/GlyphRun'; 3 | import GlyphString from '../../src/models/GlyphString'; 4 | import { layout, createCamboyanTestRun } from './glyphRuns'; 5 | import testFont from './font'; 6 | 7 | export const createLatinTestString = ({ 8 | value = 'Lorem ipsum', 9 | runs = [[0, value.length]] 10 | } = {}) => { 11 | let glyphIndex = 0; 12 | const attrs = new RunStyle(Object.assign({}, { font: testFont })); 13 | 14 | const glyphRuns = runs.map(run => { 15 | const { glyphs, positions, stringIndices, glyphIndices } = layout(value.slice(run[0], run[1])); 16 | 17 | const glyphRun = new GlyphRun( 18 | glyphIndex, 19 | glyphIndex + glyphs.length, 20 | attrs, 21 | glyphs, 22 | positions, 23 | stringIndices, 24 | glyphIndices 25 | ); 26 | 27 | glyphIndex += glyphRun.length; 28 | 29 | return glyphRun; 30 | }); 31 | 32 | return new GlyphString(value, glyphRuns, 0, value.length); 33 | }; 34 | 35 | export const createCamboyanTestString = ({ 36 | value = 'ខ្ញុំអាចញ៉ាំកញ្ចក់បាន', 37 | runs = [[0, value.length]] 38 | } = {}) => { 39 | const glyphRuns = runs.map(run => { 40 | const glyphRun = createCamboyanTestRun({ 41 | value, 42 | end: run[1], 43 | start: run[0] 44 | }); 45 | 46 | return glyphRun; 47 | }); 48 | 49 | return new GlyphString(value, glyphRuns, 0, value.length); 50 | }; 51 | -------------------------------------------------------------------------------- /packages/core/test/utils/lorem.js: -------------------------------------------------------------------------------- 1 | const lorem = 2 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean pharetra pulvinar ante, eget commodo orci euismod vulputate. Pellentesque in ante consectetur, mollis neque quis, placerat augue. Ut a felis et enim convallis viverra. Aliquam feugiat sodales nunc, id vulputate enim rutrum quis. Nunc rutrum ipsum ullamcorper justo blandit venenatis. Etiam et tincidunt magna. Aenean laoreet orci nec felis auctor laoreet. Maecenas enim mauris, interdum quis vehicula non, cursus ut nibh. Cras fringilla nunc tristique risus sodales commodo. Suspendisse potenti. Ut posuere eleifend viverra. Praesent a viverra quam. Sed hendrerit interdum felis vel suscipit. Ut imperdiet nisl convallis maximus vestibulum. Vestibulum aliquet faucibus nibh ut feugiat. Donec eu quam dolor. Praesent ac ornare metus, at imperdiet nulla. Ut ac consectetur nunc. In nisl quam, tincidunt in diam eu, interdum porta risus. Suspendisse porta pellentesque risus vel auctor. tique, lectus ac vehicula eleifend, elit orci sagittis leo, eu semper tortor magna ac nunc. Pellentesque tempor est eget dui condimentum, vitae sollicitudin nibh viverra. Mauris porta, dolor vitae vestibulum malesuada, orci nisi luctus ipsum, vel vehicula orci nibh a ipsum. Proin vulputate vulputate metus, a imperdiet nibh dictum sed. Interdum et malesuada fames ac ante ipsum primis in faucibus. Ut posuere quam nibh, non congue ipsum convallis non. Cras eget laoreet magna. Morbi bibendum quam nec elit sodales, ac blandit leo posuere. Vestibulum blandit nunc quis risus auctor tincidunt. Proin cursus, mi pharetra euismod mattis, dolor ipsum pellentesque nisl, nec ultrices ex ex at augue. Phasellus blandit laoreet libero, at sodales purus maximus ac. Donec id nisl ornare, posuere diam in, scelerisque nisi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam pretium lectus velit, tempus venenatis neque luctus et. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Nulla quis nisl ac felis molestie tempor. Maecenas tincidunt felis sit amet quam vulputate, eu mollis metus imperdiet. Fusce aliquam pulvinar erat, id hendrerit ex efficitur in. Curabitur maximus nibh diam, vitae malesuada nunc scelerisque quis. Pellentesque mollis lacinia tempus. Nulla vulputate dapibus dolor a tristique. Aenean pharetra sem at leo placerat, vitae hendrerit ligula auctor. Curabitur eu placerat mauris. Nunc mollis sem sit amet ante porttitor, non pellentesque tortor facilisis. Donec scelerisque nisi vitae aliquet tristique. In interdum ipsum eu libero molestie, sed ullamcorper eros accumsan. Mauris rhoncus volutpat est id tempus.'; 3 | 4 | export default lorem; 5 | -------------------------------------------------------------------------------- /packages/font-substitution-engine/index.js: -------------------------------------------------------------------------------- 1 | import FontManager from 'font-manager'; 2 | import fontkit from 'fontkit'; 3 | 4 | /** 5 | * A FontSubstitutionEngine is used by a GlyphGenerator to resolve 6 | * font runs in an AttributedString, performing font substitution 7 | * where necessary. 8 | */ 9 | export default () => ({ Run }) => 10 | class FontSubstitutionEngine { 11 | constructor() { 12 | this.fontCache = new Map(); 13 | } 14 | 15 | getFont(fontDescriptor) { 16 | if (this.fontCache.has(fontDescriptor.postscriptName)) { 17 | return this.fontCache.get(fontDescriptor.postscriptName); 18 | } 19 | let font = fontkit.openSync(fontDescriptor.path); 20 | if (font.postscriptName !== fontDescriptor.postscriptName) { 21 | font = font.getFont(fontDescriptor.postscriptName); 22 | } 23 | 24 | this.fontCache.set(font.postscriptName, font); 25 | return font; 26 | } 27 | 28 | getRuns(string, runs) { 29 | const res = []; 30 | let lastDescriptor = null; 31 | let lastFont = null; 32 | let lastIndex = 0; 33 | let index = 0; 34 | 35 | for (const run of runs) { 36 | let defaultFont; 37 | const defaultDescriptor = FontManager.findFontSync(run.attributes.fontDescriptor); 38 | 39 | if (typeof run.attributes.font === 'string') { 40 | defaultFont = this.getFont(defaultDescriptor); 41 | } else { 42 | defaultFont = run.attributes.font; 43 | } 44 | 45 | for (const char of string.slice(run.start, run.end)) { 46 | const codePoint = char.codePointAt(); 47 | let descriptor = null; 48 | let font = null; 49 | 50 | if (defaultFont.hasGlyphForCodePoint(codePoint)) { 51 | descriptor = defaultDescriptor; 52 | font = defaultFont; 53 | } else { 54 | descriptor = FontManager.substituteFontSync(defaultDescriptor.postscriptName, char); 55 | font = this.getFont(descriptor); 56 | } 57 | 58 | if (font !== lastFont) { 59 | if (lastFont) { 60 | res.push( 61 | new Run(lastIndex, index, { 62 | font: lastFont, 63 | fontDescriptor: lastDescriptor 64 | }) 65 | ); 66 | } 67 | 68 | lastFont = font; 69 | lastDescriptor = descriptor; 70 | lastIndex = index; 71 | } 72 | 73 | index += char.length; 74 | } 75 | } 76 | 77 | if (lastIndex < string.length) { 78 | res.push( 79 | new Run(lastIndex, string.length, { 80 | font: lastFont, 81 | fontDescriptor: lastDescriptor 82 | }) 83 | ); 84 | } 85 | 86 | return res; 87 | } 88 | }; 89 | -------------------------------------------------------------------------------- /packages/font-substitution-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/font-substitution-engine", 3 | "version": "0.1.9", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest --config ./jest.config.js" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "font-manager": "^0.2.2", 21 | "fontkit": "^1.7.7" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/font-substitution-engine/test/index.test.js: -------------------------------------------------------------------------------- 1 | import fontkit from 'fontkit'; 2 | import FontManager from 'font-manager'; 3 | import { Run, RunStyle } from '../../core/src'; 4 | import createFontSubstitutionEngine from '../index'; 5 | 6 | // Mock dependencies 7 | jest.mock('fontkit'); 8 | jest.mock('font-manager'); 9 | 10 | // We mock dummy fonts in order to make font equals work 11 | const fontCache = {}; 12 | 13 | // Return simple object with dummy path and postscriptName 14 | FontManager.findFontSync.mockImplementation(({ family }) => ({ 15 | path: family, 16 | postscriptName: family 17 | })); 18 | 19 | // Return simple object with dummy postscriptName 20 | FontManager.substituteFontSync.mockImplementation(postscriptName => ({ 21 | postscriptName: `${postscriptName}_replace` 22 | })); 23 | 24 | // Return dummy font object 25 | fontkit.openSync.mockImplementation(path => { 26 | const hasGlyphForCodePoint = codePoint => codePoint !== 32; 27 | 28 | return { 29 | postscriptName: path, 30 | hasGlyphForCodePoint, 31 | getFont: () => { 32 | if (!fontCache[path]) { 33 | fontCache[path] = { 34 | postscriptName: path, 35 | hasGlyphForCodePoint 36 | }; 37 | } 38 | 39 | return fontCache[path]; 40 | } 41 | }; 42 | }); 43 | 44 | // Test implementation 45 | const FontSubstitution = createFontSubstitutionEngine()({ Run }); 46 | const instance = new FontSubstitution(); 47 | 48 | describe('FontSubstitutionEngine', () => { 49 | test('should return empty array if no runs passed', () => { 50 | const runs = instance.getRuns('', []); 51 | 52 | expect(runs).toEqual([]); 53 | }); 54 | 55 | test('should return empty array for empty string', () => { 56 | const run = new Run(0, 0, new RunStyle()); 57 | const runs = instance.getRuns('', [run]); 58 | 59 | expect(runs).toEqual([]); 60 | }); 61 | 62 | test('should merge equal runs', () => { 63 | const run1 = new Run(0, 3, new RunStyle({ font: 'Helvetica' })); 64 | const run2 = new Run(3, 5, new RunStyle({ font: 'Helvetica' })); 65 | const runs = instance.getRuns('Lorem', [run1, run2]); 66 | 67 | expect(runs).toHaveLength(1); 68 | expect(runs[0].start).toBe(0); 69 | expect(runs[0].end).toBe(5); 70 | }); 71 | 72 | test('should substitute many runs', () => { 73 | const run1 = new Run(0, 3, new RunStyle({ font: 'Courier' })); 74 | const run2 = new Run(3, 5, new RunStyle({ font: 'Helvetica' })); 75 | const runs = instance.getRuns('Lorem', [run1, run2]); 76 | 77 | expect(runs).toHaveLength(2); 78 | expect(runs[0].start).toBe(0); 79 | expect(runs[0].end).toBe(3); 80 | expect(runs[1].start).toBe(3); 81 | expect(runs[1].end).toBe(5); 82 | }); 83 | 84 | test.only('should use fallback font if char not present', () => { 85 | const run = new Run(0, 11, new RunStyle({ font: 'Helvetica' })); 86 | const runs = instance.getRuns('Lorem ipsum', [run]); 87 | 88 | expect(runs).toHaveLength(3); 89 | expect(runs[0].start).toBe(0); 90 | expect(runs[0].end).toBe(5); 91 | expect(runs[1].start).toBe(5); 92 | expect(runs[1].end).toBe(6); 93 | expect(runs[2].start).toBe(6); 94 | expect(runs[2].end).toBe(11); 95 | expect(FontManager.substituteFontSync.mock.calls).toHaveLength(1); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /packages/font-substitution-engine/test/setup.js: -------------------------------------------------------------------------------- 1 | import fontkit from 'fontkit'; 2 | import FontManager from 'font-manager'; 3 | 4 | jest.mock('fontkit'); 5 | jest.mock('font-manager'); 6 | 7 | FontManager.findFontSync.mockImplementation(({ family }) => ({ 8 | path: family, 9 | postscriptName: family 10 | })); 11 | 12 | FontManager.substituteFontSync.mockImplementation(postscriptName => ({ 13 | postscriptName: `${postscriptName}_replace` 14 | })); 15 | 16 | const fontCache = {}; 17 | fontkit.openSync.mockImplementation(path => { 18 | const hasGlyphForCodePoint = codePoint => codePoint !== 32; 19 | 20 | return { 21 | postscriptName: path, 22 | hasGlyphForCodePoint, 23 | getFont: () => { 24 | if (!fontCache[path]) { 25 | fontCache[path] = { 26 | postscriptName: path, 27 | hasGlyphForCodePoint 28 | }; 29 | } 30 | 31 | return fontCache[path]; 32 | } 33 | }; 34 | }); 35 | -------------------------------------------------------------------------------- /packages/justification-engine/index.js: -------------------------------------------------------------------------------- 1 | import clone from 'lodash.clone'; 2 | import merge from 'lodash.merge'; 3 | 4 | const KASHIDA_PRIORITY = 0; 5 | const WHITESPACE_PRIORITY = 1; 6 | const LETTER_PRIORITY = 2; 7 | const NULL_PRIORITY = 3; 8 | 9 | const EXPAND_WHITESPACE_FACTOR = { 10 | before: 0.5, 11 | after: 0.5, 12 | priority: WHITESPACE_PRIORITY, 13 | unconstrained: false 14 | }; 15 | 16 | const EXPAND_CHAR_FACTOR = { 17 | before: 0.14453125, // 37/256 18 | after: 0.14453125, 19 | priority: LETTER_PRIORITY, 20 | unconstrained: false 21 | }; 22 | 23 | const SHRINK_WHITESPACE_FACTOR = { 24 | before: -0.04296875, // -11/256 25 | after: -0.04296875, 26 | priority: WHITESPACE_PRIORITY, 27 | unconstrained: false 28 | }; 29 | 30 | const SHRINK_CHAR_FACTOR = { 31 | before: -0.04296875, 32 | after: -0.04296875, 33 | priority: LETTER_PRIORITY, 34 | unconstrained: false 35 | }; 36 | 37 | /** 38 | * A JustificationEngine is used by a Typesetter to perform line fragment 39 | * justification. This implementation is based on a description of Apple's 40 | * justification algorithm from a PDF in the Apple Font Tools package. 41 | */ 42 | export default ({ 43 | expandCharFactor = {}, 44 | expandWhitespaceFactor = {}, 45 | shrinkCharFactor = {}, 46 | shrinkWhitespaceFactor = {} 47 | } = {}) => () => 48 | class JustificationEngine { 49 | constructor() { 50 | this.expandCharFactor = merge(EXPAND_CHAR_FACTOR, expandCharFactor); 51 | this.expandWhitespaceFactor = merge(EXPAND_WHITESPACE_FACTOR, expandWhitespaceFactor); 52 | this.shrinkCharFactor = merge(SHRINK_CHAR_FACTOR, shrinkCharFactor); 53 | this.shrinkWhitespaceFactor = merge(SHRINK_WHITESPACE_FACTOR, shrinkWhitespaceFactor); 54 | } 55 | 56 | justify(line, options = {}) { 57 | const factor = options.factor || 1; 58 | if (factor < 0 || factor > 1) { 59 | throw new Error(`Invalid justification factor: ${factor}`); 60 | } 61 | 62 | const gap = line.rect.width - line.advanceWidth; 63 | if (gap === 0) { 64 | return; 65 | } 66 | 67 | const factors = []; 68 | let start = 0; 69 | for (const run of line.glyphRuns) { 70 | factors.push(...this.factor(line, start, run.glyphs, gap > 0 ? 'GROW' : 'SHRINK')); 71 | start += run.glyphs.length; 72 | } 73 | 74 | factors[0].before = 0; 75 | factors[factors.length - 1].after = 0; 76 | 77 | const distances = this.assign(gap, factors); 78 | 79 | let index = 0; 80 | for (const run of line.glyphRuns) { 81 | for (const position of run.positions) { 82 | position.xAdvance += distances[index++]; 83 | } 84 | } 85 | } 86 | 87 | factor(line, start, glyphs, direction) { 88 | let charFactor; 89 | let whitespaceFactor; 90 | 91 | if (direction === 'GROW') { 92 | charFactor = clone(this.expandCharFactor); 93 | whitespaceFactor = clone(this.expandWhitespaceFactor); 94 | } else { 95 | charFactor = clone(this.shrinkCharFactor); 96 | whitespaceFactor = clone(this.shrinkWhitespaceFactor); 97 | } 98 | 99 | const factors = []; 100 | for (let index = 0; index < glyphs.length; index++) { 101 | let factor; 102 | const glyph = glyphs[index]; 103 | if (line.isWhiteSpace(start + index)) { 104 | factor = clone(whitespaceFactor); 105 | 106 | if (index === glyphs.length - 1) { 107 | factor.before = 0; 108 | 109 | if (index > 0) { 110 | factors[index - 1].after = 0; 111 | } 112 | } 113 | } else if (glyph.isMark && index > 0) { 114 | factor = clone(factors[index - 1]); 115 | factor.before = 0; 116 | factors[index - 1].after = 0; 117 | } else { 118 | factor = clone(charFactor); 119 | } 120 | 121 | factors.push(factor); 122 | } 123 | 124 | return factors; 125 | } 126 | 127 | assign(gap, factors) { 128 | let total = 0; 129 | const priorities = []; 130 | const unconstrained = []; 131 | 132 | for (let priority = KASHIDA_PRIORITY; priority <= NULL_PRIORITY; priority++) { 133 | priorities[priority] = unconstrained[priority] = 0; 134 | } 135 | 136 | // sum the factors at each priority 137 | for (let j = 0; j < factors.length; j++) { 138 | const factor = factors[j]; 139 | const sum = factor.before + factor.after; 140 | total += sum; 141 | priorities[factor.priority] += sum; 142 | if (factor.unconstrained) { 143 | unconstrained[factor.priority] += sum; 144 | } 145 | } 146 | 147 | // choose the priorities that need to be applied 148 | let highestPriority = -1; 149 | let highestPrioritySum = 0; 150 | let remainingGap = gap; 151 | let priority; 152 | for (priority = KASHIDA_PRIORITY; priority <= NULL_PRIORITY; priority++) { 153 | const prioritySum = priorities[priority]; 154 | if (prioritySum !== 0) { 155 | if (highestPriority === -1) { 156 | highestPriority = priority; 157 | highestPrioritySum = prioritySum; 158 | } 159 | 160 | // if this priority covers the remaining gap, we're done 161 | if (Math.abs(remainingGap) <= Math.abs(prioritySum)) { 162 | priorities[priority] = remainingGap / prioritySum; 163 | unconstrained[priority] = 0; 164 | remainingGap = 0; 165 | break; 166 | } 167 | 168 | // mark that we need to use 100% of the adjustment from 169 | // this priority, and subtract the space that it consumes 170 | priorities[priority] = 1; 171 | remainingGap -= prioritySum; 172 | 173 | // if this priority has unconstrained glyphs, let them consume the remaining space 174 | if (unconstrained[priority] !== 0) { 175 | unconstrained[priority] = remainingGap / unconstrained[priority]; 176 | remainingGap = 0; 177 | break; 178 | } 179 | } 180 | } 181 | 182 | // zero out remaining priorities (if any) 183 | for (let p = priority + 1; p <= NULL_PRIORITY; p++) { 184 | priorities[p] = 0; 185 | unconstrained[p] = 0; 186 | } 187 | 188 | // if there is still space left over, assign it to the highest priority that we saw. 189 | // this violates their factors, but it only happens in extreme cases 190 | if (remainingGap > 0 && highestPriority > -1) { 191 | priorities[highestPriority] = (highestPrioritySum + (gap - total)) / highestPrioritySum; 192 | } 193 | 194 | // create and return an array of distances to add to each glyph's advance 195 | const distances = []; 196 | for (let index = 0; index < factors.length; index++) { 197 | // the distance to add to this glyph is the sum of the space to add 198 | // after this glyph, and the space to add before the next glyph 199 | const factor = factors[index]; 200 | const next = factors[index + 1]; 201 | let dist = factor.after * priorities[factor.priority]; 202 | 203 | if (next) { 204 | dist += next.before * priorities[next.priority]; 205 | } 206 | 207 | // if this glyph is unconstrained, add the unconstrained distance as well 208 | if (factor.unconstrained) { 209 | dist += factor.after * unconstrained[factor.priority]; 210 | if (next) { 211 | dist += next.before * unconstrained[next.priority]; 212 | } 213 | } 214 | 215 | distances.push(dist); 216 | } 217 | 218 | return distances; 219 | } 220 | 221 | postprocess() { 222 | // do nothing by default 223 | return false; 224 | } 225 | }; 226 | -------------------------------------------------------------------------------- /packages/justification-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/justification-engine", 3 | "version": "0.1.9", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "lodash.clone": "^4.5.0", 21 | "lodash.merge": "^4.6.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/line-fragment-generator/index.js: -------------------------------------------------------------------------------- 1 | const BELOW = 1; 2 | const INSIDE = 2; 3 | const ABOVE = 3; 4 | 5 | const BELOW_TO_INSIDE = (BELOW << 4) | INSIDE; 6 | const BELOW_TO_ABOVE = (BELOW << 4) | ABOVE; 7 | const INSIDE_TO_BELOW = (INSIDE << 4) | BELOW; 8 | const INSIDE_TO_ABOVE = (INSIDE << 4) | ABOVE; 9 | const ABOVE_TO_INSIDE = (ABOVE << 4) | INSIDE; 10 | const ABOVE_TO_BELOW = (ABOVE << 4) | BELOW; 11 | 12 | const LEFT = 0; 13 | const RIGHT = 1; 14 | 15 | /** 16 | * A LineFragmentGenerator splits line rectangles into fragments, 17 | * wrapping inside a container's polygon, and outside its exclusion polygon. 18 | */ 19 | export default () => ({ Rect }) => 20 | class LineFragmentGenerator { 21 | generateFragments(lineRect, container) { 22 | const rects = this.splitLineRect(lineRect, container.polygon, 'INTERIOR'); 23 | const exclusion = container.exclusionPolygon; 24 | 25 | if (exclusion) { 26 | const res = []; 27 | for (const rect of rects) { 28 | res.push(...this.splitLineRect(rect, exclusion, 'EXTERIOR')); 29 | } 30 | 31 | return res; 32 | } 33 | 34 | return rects; 35 | } 36 | 37 | splitLineRect(lineRect, polygon, type) { 38 | const minY = lineRect.y; 39 | const maxY = lineRect.maxY; 40 | const markers = []; 41 | let wrapState = BELOW; 42 | let min = Infinity; 43 | let max = -Infinity; 44 | 45 | for (let i = 0; i < polygon.contours.length; i++) { 46 | const contour = polygon.contours[i]; 47 | let index = -1; 48 | let state = -1; 49 | 50 | // Find the first point outside the line rect. 51 | do { 52 | const point = contour[++index]; 53 | state = point.y <= minY ? BELOW : point.y >= maxY ? ABOVE : INSIDE; 54 | } while (state === INSIDE && index < contour.length - 1); 55 | 56 | // Contour is entirely inside the line rect. Skip it. 57 | if (state === INSIDE) { 58 | continue; 59 | } 60 | 61 | const dir = type === 'EXTERIOR' ? 1 : -1; 62 | let idx = type === 'EXTERIOR' ? index : contour.length + index; 63 | let currentPoint; 64 | 65 | for (let index = 0; index <= contour.length; index++, idx += dir) { 66 | const point = contour[idx % contour.length]; 67 | 68 | if (index === 0) { 69 | currentPoint = point; 70 | state = point.y <= minY ? BELOW : point.y >= maxY ? ABOVE : INSIDE; 71 | continue; 72 | } 73 | 74 | const s = point.y <= minY ? BELOW : point.y >= maxY ? ABOVE : INSIDE; 75 | const x = point.x; 76 | 77 | if (s !== state) { 78 | const stateChangeType = (state << 4) | s; 79 | switch (stateChangeType) { 80 | case BELOW_TO_INSIDE: { 81 | // console.log('BELOW_TO_INSIDE') 82 | const xIntercept = xIntersection(minY, point, currentPoint); 83 | min = Math.min(xIntercept, x); 84 | max = Math.max(xIntercept, x); 85 | wrapState = BELOW; 86 | break; 87 | } 88 | 89 | case BELOW_TO_ABOVE: { 90 | // console.log('BELOW_TO_ABOVE') 91 | const x1 = xIntersection(minY, point, currentPoint); 92 | const x2 = xIntersection(maxY, point, currentPoint); 93 | markers.push({ 94 | type: LEFT, 95 | position: Math.max(x1, x2) 96 | }); 97 | break; 98 | } 99 | 100 | case ABOVE_TO_INSIDE: { 101 | // console.log('ABOVE_TO_INSIDE') 102 | const xIntercept = xIntersection(maxY, point, currentPoint); 103 | min = Math.min(xIntercept, x); 104 | max = Math.max(xIntercept, x); 105 | wrapState = ABOVE; 106 | break; 107 | } 108 | 109 | case ABOVE_TO_BELOW: { 110 | // console.log('ABOVE_TO_BELOW') 111 | const x1 = xIntersection(minY, point, currentPoint); 112 | const x2 = xIntersection(maxY, point, currentPoint); 113 | markers.push({ 114 | type: RIGHT, 115 | position: Math.min(x1, x2) 116 | }); 117 | break; 118 | } 119 | 120 | case INSIDE_TO_ABOVE: { 121 | // console.log('INSIDE_TO_ABOVE') 122 | const x1 = xIntersection(maxY, point, currentPoint); 123 | max = Math.max(max, x1); 124 | 125 | markers.push({ type: LEFT, position: max }); 126 | 127 | if (wrapState === ABOVE) { 128 | min = Math.min(min, x1); 129 | markers.push({ type: RIGHT, position: min }); 130 | } 131 | 132 | break; 133 | } 134 | 135 | case INSIDE_TO_BELOW: { 136 | // console.log('INSIDE_TO_BELOW') 137 | const x1 = xIntersection(minY, point, currentPoint); 138 | min = Math.min(min, x1); 139 | 140 | markers.push({ type: RIGHT, position: min }); 141 | 142 | if (wrapState === BELOW) { 143 | max = Math.max(max, x1); 144 | markers.push({ type: LEFT, position: max }); 145 | } 146 | 147 | break; 148 | } 149 | 150 | default: 151 | throw new Error('Unknown state change'); 152 | } 153 | state = s; 154 | } else if (s === INSIDE) { 155 | min = Math.min(min, x); 156 | max = Math.max(max, x); 157 | } 158 | 159 | currentPoint = point; 160 | } 161 | } 162 | 163 | markers.sort((a, b) => a.position - b.position); 164 | // console.log(markers); 165 | 166 | let G = 0; 167 | if (type === 'INTERIOR' || (markers.length > 0 && markers[0].type === LEFT)) { 168 | G++; 169 | } 170 | 171 | // console.log(G) 172 | 173 | let minX = lineRect.x; 174 | const { maxX } = lineRect; 175 | const { height } = lineRect; 176 | const rects = []; 177 | 178 | for (const marker of markers) { 179 | if (marker.type === RIGHT) { 180 | if (G === 0) { 181 | const p = Math.min(maxX, marker.position); 182 | if (p >= minX) { 183 | rects.push(new Rect(minX, minY, p - minX, height)); 184 | } 185 | } 186 | 187 | G++; 188 | } else { 189 | G--; 190 | if (G === 0 && marker.position > minX) { 191 | minX = marker.position; 192 | } 193 | } 194 | } 195 | 196 | // console.log(G, maxX, minX) 197 | if (G === 0 && maxX >= minX) { 198 | rects.push(new Rect(minX, minY, maxX - minX, height)); 199 | } 200 | 201 | // console.log(rects) 202 | return rects; 203 | } 204 | }; 205 | 206 | function xIntersection(e, t, n) { 207 | const r = e - n.y; 208 | const i = t.y - n.y; 209 | 210 | return r / i * (t.x - n.x) + n.x; 211 | } 212 | -------------------------------------------------------------------------------- /packages/line-fragment-generator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/line-fragment-generator", 3 | "version": "0.1.9", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT" 19 | } 20 | -------------------------------------------------------------------------------- /packages/linebreaker/index.js: -------------------------------------------------------------------------------- 1 | import LineBreak from 'linebreak'; 2 | import Hyphenator from 'hypher'; 3 | import enUS from 'hyphenation.en-us'; 4 | 5 | const hyphenator = new Hyphenator(enUS); 6 | const HYPHEN = 0x002d; 7 | const SHRINK_FACTOR = 0.04; 8 | 9 | /** 10 | * A LineBreaker is used by the Typesetter to perform 11 | * Unicode line breaking and hyphenation. 12 | */ 13 | export default () => () => 14 | class LineBreaker { 15 | suggestLineBreak(glyphString, width, paragraphStyle) { 16 | const hyphenationFactor = paragraphStyle.hyphenationFactor || 0; 17 | const glyphIndex = glyphString.glyphIndexAtOffset(width); 18 | 19 | if (glyphIndex === -1) return null; 20 | 21 | if (glyphIndex === glyphString.length) { 22 | return { position: glyphString.length, required: true }; 23 | } 24 | 25 | let stringIndex = glyphString.stringIndexForGlyphIndex(glyphIndex); 26 | const bk = this.findBreakPreceeding(glyphString.string, stringIndex); 27 | 28 | if (bk) { 29 | let breakIndex = glyphString.glyphIndexForStringIndex(bk.position); 30 | 31 | if ( 32 | bk.next != null && 33 | this.shouldHyphenate(glyphString, breakIndex, width, hyphenationFactor) 34 | ) { 35 | const lineWidth = glyphString.offsetAtGlyphIndex(glyphIndex); 36 | const shrunk = lineWidth + lineWidth * SHRINK_FACTOR; 37 | 38 | const shrunkIndex = glyphString.glyphIndexAtOffset(shrunk); 39 | stringIndex = Math.min(bk.next, glyphString.stringIndexForGlyphIndex(shrunkIndex)); 40 | 41 | const point = this.findHyphenationPoint( 42 | glyphString.string.slice(bk.position, bk.next), 43 | stringIndex - bk.position 44 | ); 45 | 46 | if (point > 0) { 47 | bk.position += point; 48 | breakIndex = glyphString.glyphIndexForStringIndex(bk.position); 49 | 50 | if (bk.position < bk.next) { 51 | glyphString.insertGlyph(breakIndex++, HYPHEN); 52 | } 53 | } 54 | } 55 | 56 | bk.position = breakIndex; 57 | } 58 | 59 | return bk; 60 | } 61 | 62 | findBreakPreceeding(string, index) { 63 | const breaker = new LineBreak(string); 64 | let last = null; 65 | let bk = null; 66 | 67 | while ((bk = breaker.nextBreak())) { 68 | // console.log(bk); 69 | if (bk.position > index) { 70 | if (last) { 71 | last.next = bk.position; 72 | } 73 | 74 | return last; 75 | } 76 | 77 | if (bk.required) { 78 | return bk; 79 | } 80 | 81 | last = bk; 82 | } 83 | 84 | return null; 85 | } 86 | 87 | shouldHyphenate(glyphString, glyphIndex, width, hyphenationFactor) { 88 | const lineWidth = glyphString.offsetAtGlyphIndex(glyphIndex); 89 | return lineWidth / width < hyphenationFactor; 90 | } 91 | 92 | findHyphenationPoint(string, index) { 93 | const parts = hyphenator.hyphenate(string); 94 | let count = 0; 95 | 96 | for (const part of parts) { 97 | if (count + part.length > index) { 98 | break; 99 | } 100 | 101 | count += part.length; 102 | } 103 | 104 | return count; 105 | } 106 | }; 107 | -------------------------------------------------------------------------------- /packages/linebreaker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/linebreaker", 3 | "version": "0.1.9", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "hyphenation.en-us": "^0.2.1", 21 | "hypher": "^0.2.4", 22 | "linebreak": "^0.1.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/pdf-renderer/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A TextRenderer renders text layout objects to a graphics context. 3 | */ 4 | export default ({ Rect }) => 5 | class PDFRenderer { 6 | constructor(ctx, options = {}) { 7 | this.ctx = ctx; 8 | this.outlineBlocks = options.outlineBlocks || false; 9 | this.outlineLines = options.outlineLines || false; 10 | this.outlineRuns = options.outlineRuns || false; 11 | this.outlineAttachments = options.outlineAttachments || false; 12 | } 13 | 14 | render(container) { 15 | for (const block of container.blocks) { 16 | this.renderBlock(block); 17 | } 18 | } 19 | 20 | renderBlock(block) { 21 | if (this.outlineBlocks) { 22 | const { minX, minY, width, height } = block.bbox; 23 | this.ctx.rect(minX, minY, width, height).stroke(); 24 | } 25 | 26 | for (const line of block.lines) { 27 | this.renderLine(line); 28 | } 29 | } 30 | 31 | renderLine(line) { 32 | if (this.outlineLines) { 33 | this.ctx.rect(line.rect.x, line.rect.y, line.rect.width, line.rect.height).stroke(); 34 | } 35 | 36 | this.ctx.save(); 37 | this.ctx.translate(line.rect.x, line.rect.y + line.ascent); 38 | 39 | for (const run of line.glyphRuns) { 40 | if (run.attributes.backgroundColor) { 41 | const backgroundRect = new Rect(0, -line.ascent, run.advanceWidth, line.rect.height); 42 | this.renderBackground(backgroundRect, run.attributes.backgroundColor); 43 | } 44 | 45 | this.renderRun(run); 46 | } 47 | 48 | this.ctx.restore(); 49 | this.ctx.save(); 50 | this.ctx.translate(line.rect.x, line.rect.y); 51 | 52 | for (const decorationLine of line.decorationLines) { 53 | this.renderDecorationLine(decorationLine); 54 | } 55 | 56 | this.ctx.restore(); 57 | } 58 | 59 | renderRun(run) { 60 | const { font, fontSize, color, link, opacity } = run.attributes; 61 | 62 | if (this.outlineRuns) { 63 | this.ctx.rect(0, 0, run.advanceWidth, run.height).stroke(); 64 | } 65 | 66 | this.ctx.fillColor(color); 67 | this.ctx.fillOpacity(opacity); 68 | 69 | if (link) { 70 | this.ctx.link(0, -run.height - run.descent, run.advanceWidth, run.height, link); 71 | } 72 | 73 | this.renderAttachments(run); 74 | 75 | if (font.sbix || (font.COLR && font.CPAL)) { 76 | this.ctx.save(); 77 | this.ctx.translate(0, -run.ascent); 78 | 79 | for (let i = 0; i < run.glyphs.length; i++) { 80 | const position = run.positions[i]; 81 | const glyph = run.glyphs[i]; 82 | 83 | this.ctx.save(); 84 | this.ctx.translate(position.xOffset, position.yOffset); 85 | 86 | glyph.render(this.ctx, fontSize); 87 | 88 | this.ctx.restore(); 89 | this.ctx.translate(position.xAdvance, position.yAdvance); 90 | } 91 | 92 | this.ctx.restore(); 93 | } else { 94 | this.ctx.font(typeof font.name === 'string' ? font.name : font, fontSize); 95 | this.ctx._addGlyphs(run.glyphs, run.positions, 0, 0); 96 | } 97 | 98 | this.ctx.translate(run.advanceWidth, 0); 99 | } 100 | 101 | renderBackground(rect, backgroundColor) { 102 | this.ctx.rect(rect.x, rect.y, rect.width, rect.height); 103 | this.ctx.fill(backgroundColor); 104 | } 105 | 106 | renderAttachments(run) { 107 | this.ctx.save(); 108 | 109 | const { font } = run.attributes; 110 | const space = font.glyphForCodePoint(0x20); 111 | const objectReplacement = font.glyphForCodePoint(0xfffc); 112 | 113 | for (let i = 0; i < run.glyphs.length; i++) { 114 | const position = run.positions[i]; 115 | const glyph = run.glyphs[i]; 116 | 117 | this.ctx.translate(position.xAdvance, position.yOffset); 118 | 119 | if (glyph.id === objectReplacement.id && run.attributes.attachment) { 120 | this.renderAttachment(run.attributes.attachment); 121 | run.glyphs[i] = space; 122 | } 123 | } 124 | 125 | this.ctx.restore(); 126 | } 127 | 128 | renderAttachment(attachment) { 129 | const { xOffset = 0, yOffset = 0 } = attachment; 130 | 131 | this.ctx.translate(-attachment.width + xOffset, -attachment.height + yOffset); 132 | 133 | if (this.outlineAttachments) { 134 | this.ctx.rect(0, 0, attachment.width, attachment.height).stroke(); 135 | } 136 | 137 | if (typeof attachment.render === 'function') { 138 | this.ctx.rect(0, 0, attachment.width, attachment.height); 139 | this.ctx.clip(); 140 | attachment.render(this.ctx); 141 | } else if (attachment.image) { 142 | this.ctx.image(attachment.image, 0, 0, { 143 | fit: [attachment.width, attachment.height], 144 | align: 'center', 145 | valign: 'bottom' 146 | }); 147 | } 148 | } 149 | 150 | renderDecorationLine(line) { 151 | this.ctx.lineWidth(line.rect.height); 152 | 153 | if (/dashed/.test(line.style)) { 154 | this.ctx.dash(3 * line.rect.height); 155 | } else if (/dotted/.test(line.style)) { 156 | this.ctx.dash(line.rect.height); 157 | } 158 | 159 | if (/wavy/.test(line.style)) { 160 | const dist = Math.max(2, line.rect.height); 161 | let step = 1.1 * dist; 162 | const stepCount = Math.floor(line.rect.width / (2 * step)); 163 | 164 | // Adjust step to fill entire width 165 | const remainingWidth = line.rect.width - stepCount * 2 * step; 166 | const adjustment = remainingWidth / stepCount / 2; 167 | step += adjustment; 168 | 169 | const cp1y = line.rect.y + dist; 170 | const cp2y = line.rect.y - dist; 171 | let { x } = line.rect; 172 | 173 | this.ctx.moveTo(line.rect.x, line.rect.y); 174 | 175 | for (let i = 0; i < stepCount; i++) { 176 | this.ctx.bezierCurveTo(x + step, cp1y, x + step, cp2y, x + 2 * step, line.rect.y); 177 | x += 2 * step; 178 | } 179 | } else { 180 | this.ctx.moveTo(line.rect.x, line.rect.y); 181 | this.ctx.lineTo(line.rect.maxX, line.rect.y); 182 | 183 | if (/double/.test(line.style)) { 184 | this.ctx.moveTo(line.rect.x, line.rect.y + line.rect.height * 2); 185 | this.ctx.lineTo(line.rect.maxX, line.rect.y + line.rect.height * 2); 186 | } 187 | } 188 | 189 | this.ctx.stroke(line.color); 190 | } 191 | }; 192 | -------------------------------------------------------------------------------- /packages/pdf-renderer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/pdf-renderer", 3 | "version": "0.1.13", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT" 19 | } 20 | -------------------------------------------------------------------------------- /packages/script-itemizer/index.js: -------------------------------------------------------------------------------- 1 | import unicode from 'unicode-properties'; 2 | 3 | const ignoredScripts = ['Common', 'Inherited', 'Unknown']; 4 | 5 | /** 6 | * A ScriptItemizer is used by a GlyphGenerator to resolve 7 | * Unicode script runs in an AttributedString. 8 | */ 9 | export default () => ({ Run }) => 10 | class ScriptItemizer { 11 | getRuns(string) { 12 | let lastIndex = 0; 13 | let lastScript = 'Unknown'; 14 | let index = 0; 15 | const runs = []; 16 | 17 | if (!string) { 18 | return []; 19 | } 20 | 21 | for (const char of string) { 22 | const codePoint = char.codePointAt(); 23 | const script = unicode.getScript(codePoint); 24 | 25 | if (script !== lastScript && !ignoredScripts.includes(script)) { 26 | if (lastScript !== 'Unknown') { 27 | runs.push(new Run(lastIndex, index - 1, { script: lastScript })); 28 | } 29 | 30 | lastIndex = index; 31 | lastScript = script; 32 | } 33 | 34 | index += char.length; 35 | } 36 | 37 | if (lastIndex < string.length) { 38 | runs.push(new Run(lastIndex, string.length, { script: lastScript })); 39 | } 40 | 41 | return runs; 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /packages/script-itemizer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/script-itemizer", 3 | "version": "0.1.9", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "unicode-properties": "^1.1.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/script-itemizer/test/index.test.js: -------------------------------------------------------------------------------- 1 | import { Run } from '../../core/src'; 2 | import createScriptItemizerEngine from '../index'; 3 | 4 | const ScriptItemizer = createScriptItemizerEngine()({ Run }); 5 | const instance = new ScriptItemizer(); 6 | 7 | describe('ScriptItemizerEngine', () => { 8 | test('should return empty array for empty string', () => { 9 | const runs = instance.getRuns(''); 10 | 11 | expect(runs).toEqual([]); 12 | }); 13 | 14 | test('should return empty array for null string', () => { 15 | const runs = instance.getRuns(null); 16 | 17 | expect(runs).toEqual([]); 18 | }); 19 | 20 | test('should return run with correct script', () => { 21 | const runs = instance.getRuns('Lorem'); 22 | 23 | expect(runs).toHaveLength(1); 24 | expect(runs[0].attributes.script).toBe('Latin'); 25 | }); 26 | 27 | test('should return runs with correct script for many scripts', () => { 28 | const runs = instance.getRuns('Lorem ្ញុំអាចញ៉ាំកញ្'); 29 | 30 | expect(runs).toHaveLength(2); 31 | expect(runs[0].attributes.script).toBe('Latin'); 32 | expect(runs[1].attributes.script).toBe('Khmer'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/tab-engine/index.js: -------------------------------------------------------------------------------- 1 | const TAB = 9; // Unicode/ASCII tab character code point 2 | const ALIGN_FACTORS = { 3 | left: 0, 4 | center: 0.5, 5 | right: 1, 6 | decimal: 1 7 | }; 8 | 9 | const ALIGN_TERMINATORS = { 10 | center: '\t', 11 | right: '\t', 12 | decimal: '.' 13 | }; 14 | 15 | /** 16 | * A TabEngine handles aligning lines containing tab characters 17 | * with tab stops defined on a container. Text can be left, center, 18 | * right, or decimal point aligned with tab stops. 19 | */ 20 | export default () => ({ TabStop }) => 21 | class TabEngine { 22 | processLineFragment(glyphString, container) { 23 | for (const { position, x, index } of glyphString) { 24 | if (glyphString.codePointAtGlyphIndex(index) === TAB) { 25 | // Find the next tab stop and adjust x-advance 26 | const tabStop = this.getTabStopAfter(container, x); 27 | position.xAdvance = tabStop.x - x; 28 | 29 | // Adjust based on tab stop alignment 30 | const terminator = ALIGN_TERMINATORS[tabStop.align]; 31 | if (terminator) { 32 | const next = glyphString.indexOf(terminator, index + 1); 33 | let nextX = glyphString.offsetAtGlyphIndex(next); 34 | 35 | // Center the decimal point at the tab stop 36 | if (tabStop.align === 'decimal' && next < glyphString.length) { 37 | nextX += glyphString.getGlyphWidth(next) / 2; 38 | } 39 | 40 | position.xAdvance -= (nextX - tabStop.x) * ALIGN_FACTORS[tabStop.align]; 41 | } 42 | } 43 | } 44 | } 45 | 46 | getTabStopAfter(container, x) { 47 | let low = 0; 48 | let high = container.tabStops.length - 1; 49 | const maxX = container.tabStops.length === 0 ? 0 : container.tabStops[high].x; 50 | 51 | // If the x position is greater than the last defined tab stop, 52 | // find the next tab stop using the tabStopInterval. 53 | if (Math.round(x) >= maxX) { 54 | const xOffset = 55 | (Math.ceil((x - maxX) / container.tabStopInterval) + 1) * container.tabStopInterval; 56 | return new TabStop(Math.min(maxX + xOffset, container.bbox.width), 'left'); 57 | } 58 | 59 | // Binary search for the closest tab stop 60 | while (low <= high) { 61 | const mid = (low + high) >> 1; 62 | const tabStop = container.tabStops[mid]; 63 | 64 | if (x < tabStop.x) { 65 | high = mid - 1; 66 | } else if (x > tabStop.x) { 67 | low = mid + 1; 68 | } else { 69 | return tabStop; 70 | } 71 | } 72 | 73 | return container.tabStops[low]; 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /packages/tab-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/tab-engine", 3 | "version": "0.1.9", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT" 19 | } 20 | -------------------------------------------------------------------------------- /packages/text-decoration-engine/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A TextDecorationEngine is used by a Typesetter to generate 3 | * DecorationLines for a line fragment, including underlines 4 | * and strikes. 5 | */ 6 | 7 | export default () => ({ Rect, Range, DecorationLine }) => { 8 | // The base font size used for calculating underline thickness. 9 | const BASE_FONT_SIZE = 16; 10 | 11 | return class TextDecorationEngine { 12 | createDecorationLines(lineFragment) { 13 | // Create initial underline and strikethrough lines 14 | let x = lineFragment.overflowLeft; 15 | const maxX = lineFragment.advanceWidth - lineFragment.overflowRight; 16 | const underlines = []; 17 | 18 | for (const run of lineFragment.glyphRuns) { 19 | const width = Math.min(maxX - x, run.advanceWidth); 20 | const thickness = Math.max(0.5, Math.floor(run.attributes.fontSize / BASE_FONT_SIZE)); 21 | 22 | if (run.attributes.underline) { 23 | const rect = new Rect(x, lineFragment.ascent, width, thickness); 24 | const line = new DecorationLine( 25 | rect, 26 | run.attributes.underlineColor, 27 | run.attributes.underlineStyle 28 | ); 29 | this.addDecorationLine(line, underlines); 30 | } 31 | 32 | if (run.attributes.strike) { 33 | const y = lineFragment.ascent - run.ascent / 3; 34 | const rect = new Rect(x, y, width, thickness); 35 | const line = new DecorationLine( 36 | rect, 37 | run.attributes.strikeColor, 38 | run.attributes.strikeStyle 39 | ); 40 | this.addDecorationLine(line, lineFragment.decorationLines); 41 | } 42 | 43 | x += width; 44 | } 45 | 46 | // Adjust underline y positions, and intersect with glyph descenders. 47 | for (const line of underlines) { 48 | line.rect.y += line.rect.height * 2; 49 | lineFragment.decorationLines.push(...this.intersectWithGlyphs(line, lineFragment)); 50 | } 51 | } 52 | 53 | addDecorationLine(line, lines) { 54 | const last = lines[lines.length - 1]; 55 | if (!last || !last.merge(line)) { 56 | lines.push(line); 57 | } 58 | } 59 | 60 | /** 61 | * Computes the intersections between an underline and the glyphs in 62 | * a line fragment. Returns an array of DecorationLines omitting the 63 | * intersections. 64 | */ 65 | intersectWithGlyphs(line, lineFragment) { 66 | // Find intersection ranges between underline and glyphs 67 | let x = 0; 68 | let y = lineFragment.ascent; 69 | const ranges = []; 70 | 71 | for (const run of lineFragment.glyphRuns) { 72 | if (!run.attributes.underline) { 73 | x += run.advanceWidth; 74 | continue; 75 | } 76 | 77 | for (let i = 0; i < run.glyphs.length; i++) { 78 | const position = run.positions[i]; 79 | 80 | if (x >= line.rect.x && x <= line.rect.maxX) { 81 | const gx = x + position.xOffset; 82 | const gy = y + position.yOffset; 83 | 84 | // Standard fonts may not have a path to intersect with 85 | if (run.glyphs[i].path) { 86 | const path = run.glyphs[i].path.scale(run.scale, -run.scale).translate(gx, gy); 87 | const range = this.findPathIntersections(path, line.rect); 88 | 89 | if (range) { 90 | ranges.push(range); 91 | } 92 | } 93 | } 94 | 95 | x += position.xAdvance; 96 | y += position.yAdvance; 97 | } 98 | } 99 | 100 | if (ranges.length === 0) { 101 | // No intersections. Return the original line. 102 | return [line]; 103 | } 104 | 105 | const merged = Range.merge(ranges); 106 | 107 | // Generate underline segments omitting the intersections, 108 | // but only if the space warrents an underline. 109 | const lines = []; 110 | x = line.rect.x; 111 | for (const { start, end } of merged) { 112 | if (start - x > line.rect.height) { 113 | lines.push(line.slice(x, start)); 114 | } 115 | 116 | x = end; 117 | } 118 | 119 | if (line.rect.maxX - x > line.rect.height) { 120 | lines.push(line.slice(x, line.rect.maxX)); 121 | } 122 | 123 | return lines; 124 | } 125 | 126 | /** 127 | * Finds the intersections between a glyph path and an underline rectangle. 128 | * It models each contour of the path a straight line, and returns a range 129 | * containing the leftmost and rightmost intersection points, if any. 130 | */ 131 | findPathIntersections(path, rect) { 132 | let sx = 0; 133 | let sy = 0; 134 | let cx = 0; 135 | let cy = 0; 136 | let px = 0; 137 | let py = 0; 138 | const range = new Range(Infinity, -Infinity); 139 | const y1 = rect.y; 140 | const y2 = rect.maxY; 141 | const dialation = Math.ceil(rect.height); 142 | 143 | for (const { command, args } of path.commands) { 144 | switch (command) { 145 | case 'moveTo': 146 | sx = cx = args[0]; 147 | sy = cy = args[1]; 148 | continue; 149 | 150 | case 'lineTo': 151 | px = args[0]; 152 | py = args[1]; 153 | break; 154 | 155 | case 'quadraticCurveTo': 156 | px = args[2]; 157 | py = args[3]; 158 | break; 159 | 160 | case 'bezierCurveTo': 161 | px = args[4]; 162 | py = args[5]; 163 | break; 164 | 165 | case 'closePath': 166 | px = sx; 167 | py = sy; 168 | break; 169 | 170 | default: 171 | break; 172 | } 173 | 174 | this.findIntersectionPoint(y1, cx, cy, px, py, range); 175 | this.findIntersectionPoint(y2, cx, cy, px, py, range); 176 | 177 | if ((cy >= y1 && cy <= y2) || (cy <= y1 && cy >= y2)) { 178 | range.extend(cx); 179 | } 180 | 181 | cx = px; 182 | cy = py; 183 | } 184 | 185 | if (range.start < range.end) { 186 | range.start -= dialation; 187 | range.end += dialation; 188 | return range; 189 | } 190 | 191 | return null; 192 | } 193 | 194 | findIntersectionPoint(y, x1, y1, x2, y2, range) { 195 | if ((y1 < y && y2 > y) || (y1 > y && y2 < y)) { 196 | const x = x1 + (y - y1) * (x2 - x1) / (y2 - y1); 197 | range.extend(x); 198 | } 199 | } 200 | }; 201 | }; 202 | -------------------------------------------------------------------------------- /packages/text-decoration-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/text-decoration-engine", 3 | "version": "0.1.10", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT" 19 | } 20 | -------------------------------------------------------------------------------- /packages/textkit/index.js: -------------------------------------------------------------------------------- 1 | import tabEngine from '@textkit/tab-engine'; 2 | import lineBreaker from '@textkit/linebreaker'; 3 | import scriptItemizer from '@textkit/script-itemizer'; 4 | import truncationEngine from '@textkit/truncation-engine'; 5 | import justificationEngine from '@textkit/justification-engine'; 6 | import textDecorationEngine from '@textkit/text-decoration-engine'; 7 | import lineFragmentGenerator from '@textkit/line-fragment-generator'; 8 | import fontSubstitutionEngine from '@textkit/font-substitution-engine'; 9 | import { LayoutEngine as BaseLayoutEngine } from '@textkit/core'; 10 | 11 | const defaultEngines = { 12 | tabEngine: tabEngine(), 13 | lineBreaker: lineBreaker(), 14 | scriptItemizer: scriptItemizer(), 15 | truncationEngine: truncationEngine(), 16 | decorationEngine: textDecorationEngine(), 17 | justificationEngine: justificationEngine(), 18 | lineFragmentGenerator: lineFragmentGenerator(), 19 | fontSubstitutionEngine: fontSubstitutionEngine() 20 | }; 21 | 22 | export class LayoutEngine extends BaseLayoutEngine { 23 | constructor(engines = {}) { 24 | super(Object.assign({}, engines, defaultEngines)); 25 | } 26 | } 27 | 28 | export { Path, TabStop, Container, Attachment, AttributedString } from '@textkit/core'; 29 | -------------------------------------------------------------------------------- /packages/textkit/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/textkit", 3 | "version": "0.1.13", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "@textkit/core": "^0.1.17", 21 | "@textkit/font-substitution-engine": "^0.1.9", 22 | "@textkit/justification-engine": "^0.1.9", 23 | "@textkit/line-fragment-generator": "^0.1.9", 24 | "@textkit/linebreaker": "^0.1.9", 25 | "@textkit/script-itemizer": "^0.1.9", 26 | "@textkit/tab-engine": "^0.1.9", 27 | "@textkit/text-decoration-engine": "^0.1.10", 28 | "@textkit/truncation-engine": "^0.1.9" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/truncation-engine/index.js: -------------------------------------------------------------------------------- 1 | import GraphemeBreaker from 'grapheme-breaker'; 2 | 3 | const ELLIPSIS = 0x2026; 4 | const OFFSET_FACTORS = { 5 | left: 0, 6 | center: 0.5, 7 | right: 1 8 | }; 9 | 10 | /** 11 | * A TruncationEngine is used by a Typesetter to perform 12 | * line truncation and ellipsization. 13 | */ 14 | class TruncationEngine { 15 | truncate(lineFragment, mode = 'right') { 16 | let glyphIndex = Math.floor(lineFragment.length * OFFSET_FACTORS[mode]); 17 | 18 | // If mode is center, get the visual center instead of the index center. 19 | if (mode === 'center') { 20 | const offset = lineFragment.rect.width * OFFSET_FACTORS[mode]; 21 | glyphIndex = lineFragment.glyphIndexAtOffset(offset); 22 | } 23 | 24 | let stringIndex = lineFragment.stringIndexForGlyphIndex(glyphIndex); 25 | const run = lineFragment.runAtGlyphIndex(glyphIndex); 26 | const font = run.attributes.font; 27 | const ellipsisGlyph = font.glyphForCodePoint(ELLIPSIS); 28 | const ellipsisWidth = ellipsisGlyph.advanceWidth; 29 | 30 | while (lineFragment.advanceWidth + ellipsisWidth > lineFragment.rect.width) { 31 | let nextGlyph; 32 | 33 | // Find the next grapheme cluster break 34 | if (mode === 'right') { 35 | stringIndex = GraphemeBreaker.previousBreak(lineFragment.string, stringIndex); 36 | nextGlyph = lineFragment.glyphIndexForStringIndex(stringIndex); 37 | } else { 38 | const nextStringIndex = GraphemeBreaker.nextBreak(lineFragment.string, stringIndex); 39 | nextGlyph = lineFragment.glyphIndexForStringIndex(nextStringIndex) - 1; 40 | } 41 | 42 | // Delete the cluster 43 | const min = Math.min(glyphIndex, nextGlyph, lineFragment.length - 1); 44 | const max = Math.min(Math.max(glyphIndex, nextGlyph), lineFragment.length - 1); 45 | 46 | for (let i = max; i >= min; i--) { 47 | lineFragment.deleteGlyph(i); 48 | } 49 | 50 | if (mode === 'right') { 51 | glyphIndex = nextGlyph; 52 | } 53 | } 54 | 55 | // Insert ellpisis 56 | lineFragment.insertGlyph(glyphIndex, ELLIPSIS); 57 | 58 | // Remove whitespace on either side of the ellipsis 59 | for (let i = glyphIndex + 1; lineFragment.isWhiteSpace(i); i++) { 60 | lineFragment.deleteGlyph(i); 61 | } 62 | 63 | for (let i = glyphIndex - 1; lineFragment.isWhiteSpace(i); i--) { 64 | lineFragment.deleteGlyph(i); 65 | } 66 | } 67 | } 68 | 69 | export default () => () => TruncationEngine; 70 | -------------------------------------------------------------------------------- /packages/truncation-engine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textkit/truncation-engine", 3 | "version": "0.1.9", 4 | "description": "An advanced text layout framework", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "prebuild": "rimraf dist", 8 | "prepublish": "npm run build", 9 | "build": "babel index.js --out-dir ./dist", 10 | "build:watch": "babel index.js --out-dir ./dist --watch", 11 | "precommit": "lint-staged", 12 | "test": "jest" 13 | }, 14 | "files": [ 15 | "dist" 16 | ], 17 | "author": "Devon Govett ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "grapheme-breaker": "^0.3.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /temp.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import PDFDocument from 'pdfkit'; 3 | import PDFRenderer from '@textkit/pdf-renderer'; 4 | import { 5 | Path, 6 | Rect, 7 | LayoutEngine, 8 | AttributedString, 9 | Container, 10 | Attachment 11 | } from '@textkit/textkit'; 12 | 13 | const path = new Path(); 14 | 15 | path.rect(30, 30, 300, 400); 16 | 17 | // const exclusion = new Path(); 18 | // exclusion.circle(140, 160, 50); 19 | 20 | const doc = new PDFDocument(); 21 | doc.pipe(fs.createWriteStream('out.pdf')); 22 | 23 | path.toFunction()(doc); 24 | // exclusion.toFunction()(doc); 25 | 26 | doc.stroke('green'); 27 | doc.stroke(); 28 | 29 | const string = AttributedString.fromFragments([ 30 | { 31 | string: 'Lorem ipsum dolor sit amet, ', 32 | attributes: { 33 | font: 'Arial', 34 | fontSize: 14, 35 | bold: true, 36 | align: 'justify', 37 | hyphenationFactor: 0.9, 38 | hangingPunctuation: true, 39 | lineSpacing: 5, 40 | truncate: true 41 | } 42 | }, 43 | { 44 | string: 'consectetur adipiscing elit, ', 45 | attributes: { 46 | font: 'Arial', 47 | fontSize: 18, 48 | color: 'red', 49 | align: 'justify' 50 | } 51 | }, 52 | { 53 | string: 54 | 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea volupt\u0301ate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?”', 55 | attributes: { 56 | font: 'Comic Sans MS', 57 | fontSize: 14, 58 | align: 'justify', 59 | hyphenationFactor: 0.9, 60 | hangingPunctuation: true, 61 | lineSpacing: 5, 62 | truncate: true 63 | } 64 | } 65 | ]); 66 | 67 | const l = new LayoutEngine(); 68 | const container = new Container(path); 69 | 70 | l.layout(string, [container]); 71 | 72 | const Renderer = PDFRenderer({ Rect }); 73 | const rendererInstance = new Renderer(doc, { outlineLines: false }); 74 | rendererInstance.render(container); 75 | doc.end(); 76 | --------------------------------------------------------------------------------