diff --git a/.github/workflows/npm-publish-github-packages.yml b/.github/workflows/npm-publish-github-packages.yml new file mode 100644 index 00000000..af5a6005 --- /dev/null +++ b/.github/workflows/npm-publish-github-packages.yml @@ -0,0 +1,46 @@ +# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created +# For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages + +name: Publish Node.js Package on GitHub Packages + +on: + release: + types: [created] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm run trie + - run: npm run build + - uses: actions/upload-artifact@v4 + with: + name: build-output + path: | + dist/ + types/ + + publish-gpr: + needs: build + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://npm.pkg.github.com/ + scope: '@denkiyagi' + - uses: actions/download-artifact@v4 + with: + name: build-output + - run: npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..7e4e4016 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Test + +on: + push: + branches: ['denkiyagi-fork'] + pull_request: + branches: ['denkiyagi-fork'] + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + + strategy: + matrix: + node-version: [20.x, 22.x, 24.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Rebuild shaping tries + run: npm run trie + + - name: Run tests + run: npm test diff --git a/.gitignore b/.gitignore index 1528b2f1..dc929c91 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ indic.trie indic.json dist .parcel-cache +/types/ + +.npmrc diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..4e492e03 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,9 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "trailingComma": "all", + "bracketSpacing": true, + "arrowParens": "always" +} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 7404a954..00000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: node_js -node_js: - - stable - - "8" - - "6" -matrix: - include: - - os: osx - node_js: stable diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..abbcff54 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# AI Agent Briefing + +## Mission & Scope +- `@denkiyagi/fontkit` is our maintained fork of the upstream `fontkit`, powering typography in `yagisan-reports` (and downstream `@denkiyagi/pdf-lib`). +- Versioning follows `2.0.4-mod.YYYY.N`. Every intentional divergence must land in `MODIFICATIONS.md` so downstream agents understand the API surface we guarantee. +- Recent focus areas captured in `MODIFICATIONS.md`: + - Better Unicode variation selector (UVS) handling + - Richer shaping controls + - Vertical metrics accuracy + - Predictable type definitions for consumers + +## Repository Orientation +- `src/` – Main implementation, mostly ESM `.js` files with `@ts-check`. Key areas: `glyph/`, `layout/`, `opentype/` (shapers, GSUB/GPOS logic), `tables/`, `subset/`, and entrypoints `index.ts` (browser) & `node.ts` (Node/fs helpers). +- `types/` – Generated `.d.ts` output from `npm run build:types`. Never hand-edit. +- `dist/` – Parcel bundles emitted by `npm run build:js` (CJS/ESM/browser targets). Never hand-edit. +- `test/` – Mocha (ESM) regression suite plus `test/data/` font fixtures; keep fixtures stable and small. +- `MODIFICATIONS.md` – Single source of truth for fork-specific behavior; update whenever behavior changes. + +## Toolchain & Commands +- Use Node 20+ and npm (repo ships `package-lock.json`). Install with `npm install`. +- Fast feedback loop: + - `npm run build:js` → Parcel build into `dist/`. + - `npm run build:types` → `tsc --project tsconfig-types.json` (follows `src/index.ts` & `src/node.ts`, pulls in JS via `allowJs`). + - `npm run mocha` → run tests without rebuilding. + - `npm test` → full build + mocha (what CI runs). + - `npm run coverage` → `c8 mocha`. +- `npm run trie:*` → regenerate shaping tries (`src/opentype/shapers/*.trie` & companion `.json`) when Unicode data/scripts change; outputs are gitignored. +- `npm run clean` removes build outputs—rerun builds before publishing or testing. + +## Coding Guidelines & Pitfalls +- Preserve existing `// @ts-check` comments and JSDoc—they drive declaration output. +- Prefer editing sources under `src/`. Touch `types/` or `dist/` only via build scripts. +- When adding TS types or new exports, update `src/types.ts`, re-run `npm run build:types`, and ensure both `index.ts` and `node.ts` export the new surface. +- Keep performance-sensitive code (e.g., cmap processing, shapers) allocation-free; mimic existing patterns (caching decorators, typed arrays). + +## Testing & Validation +- Run `npm run mocha` only when `npm run build:*` has already refreshed `dist/` and `types/`; otherwise use `npm test` so the build step runs first (matches CI). +- Use `npm run coverage` only when you need c8 instrumentation (slower but catches missed branches). + +## Data & Fixture Workflow +- Unicode trie data (`src/opentype/shapers/*.trie` and `.json`) is generated; edit the source scripts (`generate-data.js`, `gen-use.js`, `gen-indic.js`) or `.machine` files, then re-run the matching `npm run trie:*` task locally (artifacts remain untracked per `.gitignore`). +- The repository intentionally vendors select fonts under `test/data/`; do not rename paths without updating tests. +- Avoid storing unrelated binaries in-tree—use temporary paths outside the repo for manual experiments. diff --git a/MODIFICATIONS.md b/MODIFICATIONS.md new file mode 100644 index 00000000..c389ff21 --- /dev/null +++ b/MODIFICATIONS.md @@ -0,0 +1,48 @@ +# Modifications + +## [Unreleased] + +- Remove WOFF format support + - Also removing the `tiny-inflate` dependency (the indirect dependency may remain) +- Remove WOFF2 format support + - Also removing the `brotli` dependency +- Remove DFont format support +- Simplify the published TypeScript types by inlining the concrete format exports (`TTFFont`/`TrueTypeCollection`) in place of the old aliases (`Font`/`FontCollection`). +- Remove the dependencies below by replacing them with new internal helpers (no API changes): + - `clone` (used by `TTFSubset`) + - `fast-deep-equal` (used by `CFFDict`) + +## [2.0.4-mod.2025.2] + +- Improve performance of `CmapProcessor#lookupNonDefaultUVS` by caching variation selector records from `cmap` format 14 subtable + +## [2.0.4-mod.2025.1] + +- Fix glyph mapping using the cmap format 14 subtable, improving support for UVS in methods like `TTFFont#glyphsForString` +- Add `TTFFont#nonDefaultUVSSet` + + +## [2.0.4-mod.2024.2] + +- Add properties to get the glyph's origin Y coordinate in the vertical writing mode: + - `TTFFont#defaultVertOriginY` + - `TTFFont#getVertOriginYMap` + - `Glyph#vertOriginY` +- Fix data type of `version` property in `vhea` table +- Fix properties `ascent`, `descent` and `lineGap` in `TTFFont` class so that they refer to `OS/2` table when `USE_TYPO_METRICS` flag is ON +- Improve class `DefaultShaper` and its sub-classes + - Omit fractional features: frac, dnom, numr + - Move directional features to `ArabicShaper`: ltra, ltrm, rtla, rtlm + - Expose class `DefaultShaper` from `fontkit` +- Improve `TTFFont#layout` + - Use named parameters + - Add parameter `shaper` to override shaping process + - Add parameter `skipPerGlyphPositioning` to skip calculating `GlyphRun#positions` +- Fix fields `logErrors` and `defaultLanguage` in the top-level API + - Change to getter functions: `isLoggingErrors()`, `getDefaultLanguage()` +- Expose type definitions + + +## [2.0.4-mod.2024.1] + +- Removed diff --git a/README.md b/README.md index 027b61ce..64d61c15 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,16 @@ +**A modified version of [fontkit](https://github.com/foliojs/fontkit) for [yagisan-reports](https://denkiyagi.jp/yagisan-reports/).** + +See also: [MODIFICATIONS.md](MODIFICATIONS.md) + +---- + # fontkit Fontkit is an advanced font engine for Node and the browser, used by [PDFKit](https://github.com/devongovett/pdfkit). It supports many font formats, advanced glyph substitution and layout features, glyph path extraction, color emoji glyphs, font subsetting, and more. ## Features -* Supports TrueType (.ttf), OpenType (.otf), WOFF, WOFF2, TrueType Collection (.ttc), and Datafork TrueType (.dfont) font files +* Supports TrueType (.ttf), OpenType (.otf), and TrueType Collection (.ttc) font files (WOFF/WOFF2/DFont inputs have been removed) * Supports mapping characters to glyphs, including support for ligatures and other advanced substitutions (see below) * Supports reading glyph metrics and laying out glyphs, including support for kerning and other advanced layout features (see below) * Advanced OpenType features including glyph substitution (GSUB) and positioning (GPOS) @@ -22,7 +28,7 @@ Fontkit is an advanced font engine for Node and the browser, used by [PDFKit](ht ## Example ```javascript -var fontkit = require('fontkit'); +var fontkit = require('@denkiyagi/fontkit'); // open a font synchronously var font = fontkit.openSync('font.ttf'); diff --git a/jsconfig.json b/jsconfig.json index a178f9dc..546bcbe9 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,5 +1,7 @@ { "compilerOptions": { + "strictNullChecks": true, + "target": "es2015", "experimentalDecorators": true } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index 0002abe3..b0d0e6af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,32 +1,30 @@ { - "name": "fontkit", - "version": "2.0.4", + "name": "@denkiyagi/fontkit", + "version": "2.0.4-mod.2025.2", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "fontkit", - "version": "2.0.4", + "name": "@denkiyagi/fontkit", + "version": "2.0.4-mod.2025.2", "license": "MIT", "dependencies": { "@swc/helpers": "^0.5.12", - "brotli": "^1.3.2", - "clone": "^2.1.2", "dfa": "^1.2.0", - "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", - "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" }, "devDependencies": { + "@types/unicode-properties": "^1.3.2", "c8": "^7.11.3", "codepoints": "^1.2.0", "concat-stream": "^2.0.0", "mocha": "^10.0.0", "npm-run-all": "^4.1.5", "parcel": "2.0.0-canary.1713", - "shx": "^0.3.4" + "shx": "^0.3.4", + "typescript": "^5.9.3" } }, "node_modules/@babel/code-frame": { @@ -2128,6 +2126,12 @@ "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", "dev": true }, + "node_modules/@types/unicode-properties": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@types/unicode-properties/-/unicode-properties-1.3.2.tgz", + "integrity": "sha512-1gq48yvPn+sdJqG4tARHoQbVYyFCrV92gZdl2100vcP9n/u4nGIeLnxYyrTLWHQRhJYpc/w4SNDXUrKTjvEfRA==", + "dev": true + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -2240,10 +2244,11 @@ "dev": true }, "node_modules/base-x": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.10.tgz", - "integrity": "sha512-7d0s06rR9rYaIWHkpfLIFICM/tkSVdoPC9qYAQRpxn9DdKNWNsKC0uk++akckyLq16Tx2WIinnZ6WRriAt6njQ==", + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", "dev": true, + "license": "MIT", "dependencies": { "safe-buffer": "^5.0.1" } @@ -2286,10 +2291,11 @@ "dev": true }, "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -2306,14 +2312,6 @@ "node": ">=8" } }, - "node_modules/brotli": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", - "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", - "dependencies": { - "base64-js": "^1.1.2" - } - }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -2508,6 +2506,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, "engines": { "node": ">=0.8" } @@ -2599,9 +2598,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "dependencies": { "path-key": "^3.1.0", @@ -3171,11 +3170,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -4397,9 +4391,9 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { "braces": "^3.0.3", @@ -4651,10 +4645,11 @@ } }, "node_modules/npm-run-all/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -4690,9 +4685,9 @@ "dev": true }, "node_modules/npm-run-all/node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dev": true, "dependencies": { "nice-try": "^1.0.4", @@ -5273,10 +5268,11 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5471,10 +5467,11 @@ } }, "node_modules/shelljs/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5827,10 +5824,11 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5988,6 +5986,20 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "dev": true }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", diff --git a/package.json b/package.json index dff6f0f8..738b597a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "fontkit", - "version": "2.0.4", - "description": "An advanced font engine for Node and the browser", + "name": "@denkiyagi/fontkit", + "version": "2.0.4-mod.2025.2", + "description": "A modified version of fontkit. An advanced font engine for Node and the browser", "keywords": [ "opentype", "font", @@ -13,39 +13,44 @@ ], "scripts": { "test": "run-s build mocha", - "mocha": "mocha", - "build": "parcel build", - "prepublish": "run-s clean trie:** build", + "mocha": "mocha \"test/**/*.js\"", + "build": "run-s build:*", + "build:js": "parcel build", + "build:types": "tsc --project tsconfig-types.json", + "trie": "run-s trie:**", "trie:data": "node src/opentype/shapers/generate-data.js", "trie:use": "node src/opentype/shapers/gen-use.js", "trie:indic": "node src/opentype/shapers/gen-indic.js", - "clean": "shx rm -rf src/opentype/shapers/data.trie src/opentype/shapers/use.trie src/opentype/shapers/use.json src/opentype/shapers/indic.trie src/opentype/shapers/indic.json dist", - "coverage": "c8 mocha" + "clean": "shx rm -rf src/opentype/shapers/data.trie src/opentype/shapers/use.trie src/opentype/shapers/use.json src/opentype/shapers/indic.trie src/opentype/shapers/indic.json dist types", + "coverage": "c8 mocha \"test/**/*.js\"" }, "type": "module", "main": "dist/main.cjs", "node-module": "dist/module.mjs", "browser": "dist/browser.cjs", "module": "dist/browser-module.mjs", - "source": "src/index.js", + "source": "src/index.ts", + "types": "types/index.d.ts", "exports": { "node": { "import": "./dist/module.mjs", - "require": "./dist/main.cjs" + "require": "./dist/main.cjs", + "types": "./types/node.d.ts" }, "import": "./dist/browser-module.mjs", - "require": "./dist/browser.cjs" + "require": "./dist/browser.cjs", + "types": "./types/index.d.ts" }, "targets": { "main": { - "source": "src/node.js", + "source": "src/node.ts", "context": "browser", "engines": { "browsers": "chrome >= 70" } }, "node-module": { - "source": "src/node.js", + "source": "src/node.ts", "isLibrary": true, "includeNodeModules": false, "engines": { @@ -53,13 +58,13 @@ } }, "module": { - "source": "src/index.js", + "source": "src/index.ts", "engines": { "browsers": "chrome >= 70" } }, "browser": { - "source": "src/index.js", + "source": "src/index.ts", "engines": { "browsers": "chrome >= 70" } @@ -67,32 +72,31 @@ }, "files": [ "src", - "dist" + "dist", + "types" ], - "author": "Devon Govett ", + "author": "DenkiYagi Inc. (modified version author), Devon Govett (orignal version author)", "license": "MIT", "repository": { "type": "git", - "url": "git://github.com/foliojs/fontkit.git" + "url": "git://github.com/DenkiYagi/fontkit.git" }, "dependencies": { "@swc/helpers": "^0.5.12", - "brotli": "^1.3.2", - "clone": "^2.1.2", "dfa": "^1.2.0", - "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", - "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" }, "devDependencies": { + "@types/unicode-properties": "^1.3.2", "c8": "^7.11.3", "codepoints": "^1.2.0", "concat-stream": "^2.0.0", "mocha": "^10.0.0", "npm-run-all": "^4.1.5", "parcel": "2.0.0-canary.1713", - "shx": "^0.3.4" + "shx": "^0.3.4", + "typescript": "^5.9.3" } } diff --git a/src/CmapProcessor.js b/src/CmapProcessor.js index 6acc5017..390cc944 100644 --- a/src/CmapProcessor.js +++ b/src/CmapProcessor.js @@ -1,7 +1,6 @@ -import { binarySearch } from './utils'; +import { binarySearch, range } from './utils/arrays'; import { encodingExists, getEncoding, getEncodingMapping } from './encodings'; import { cache } from './decorators'; -import { range } from './utils'; export default class CmapProcessor { constructor(cmapTable) { @@ -63,7 +62,7 @@ export default class CmapProcessor { // Otherwise, try to get a Unicode variation selector for this codepoint if one is provided. } else if (variationSelector) { - let gid = this.getVariationSelector(codepoint, variationSelector); + let gid = this.lookupNonDefaultUVS(codepoint, variationSelector); if (gid) { return gid; } @@ -137,32 +136,36 @@ export default class CmapProcessor { } case 14: - throw new Error('TODO: cmap format 14'); + // Format 14 is handled separately via the uvs property + throw new Error('Unexpected cmap format 14'); default: throw new Error(`Unknown cmap format ${cmap.version}`); } } - getVariationSelector(codepoint, variationSelector) { + /** + * Find the glyph ID for a non-default variation of a character. + * + * @param {number} codepoint Codepoint for the base character. + * @param {number} variationSelector Codepoint for the variation selector. + * @returns {number} The glyph ID for the non-default variation, or 0 if not found. + */ + lookupNonDefaultUVS(codepoint, variationSelector) { if (!this.uvs) { return 0; } - let selectors = this.uvs.varSelectors.toArray(); - let i = binarySearch(selectors, x => variationSelector - x.varSelector); - let sel = selectors[i]; + let sel = this._getVariationSelectorRecord(variationSelector); - if (i !== -1 && sel.defaultUVS) { - i = binarySearch(sel.defaultUVS, x => - codepoint < x.startUnicodeValue ? -1 : codepoint > x.startUnicodeValue + x.additionalCount ? +1 : 0 - ); + if (!sel) { + return 0; } - if (i !== -1 && sel.nonDefaultUVS) { - i = binarySearch(sel.nonDefaultUVS, x => codepoint - x.unicodeValue); - if (i !== -1) { - return sel.nonDefaultUVS[i].glyphID; + if (sel.nonDefaultUVS) { + let nonDefaultIndex = binarySearch(sel.nonDefaultUVS, x => codepoint - x.unicodeValue); + if (nonDefaultIndex !== -1) { + return sel.nonDefaultUVS[nonDefaultIndex].glyphID; } } @@ -206,13 +209,70 @@ export default class CmapProcessor { } case 14: - throw new Error('TODO: cmap format 14'); + // Format 14 is handled separately via the uvs property + throw new Error('Unexpected cmap format 14'); default: throw new Error(`Unknown cmap format ${cmap.version}`); } } + /** + * @returns {{ baseCharacter: number, variationSelector: number, glyphID: number }[]} + */ + @cache + getNonDefaultUVSSet() { + if (!this.uvs) { + return []; + } + + const variations = []; + for (const sel of this._variationSelectorRecordArray) { + if (sel.nonDefaultUVS) { + const { varSelector } = sel; + for (const uvsMapping of sel.nonDefaultUVS) { + variations.push({ + baseCharacter: uvsMapping.unicodeValue, + variationSelector: varSelector, + glyphID: uvsMapping.glyphID, + }); + } + } + } + + return variations; + } + + /** + * Get and cache the array of the `varSelectors` records from the UVS subtable (format 14). + * + * @see https://learn.microsoft.com/en-us/typography/opentype/spec/cmap + */ + @cache + get _variationSelectorRecordArray() { + if (!this.uvs) return []; + + return this.uvs.varSelectors.toArray(); + } + + /** + * Get a variation selector record by its codepoint. + * + * @param {number} variationSelector + * @returns The `VarSelectorRecord` instance, or `null` if not found. + */ + @cache + _getVariationSelectorRecord(variationSelector) { + let selectors = this._variationSelectorRecordArray; + let selectorIndex = binarySearch(selectors, x => variationSelector - x.varSelector); + + if (selectorIndex === -1) { + return null; + } + + return selectors[selectorIndex]; + } + @cache codePointsForGlyph(gid) { let cmap = this.cmap; diff --git a/src/DFont.js b/src/DFont.js deleted file mode 100644 index 5f606f60..00000000 --- a/src/DFont.js +++ /dev/null @@ -1,117 +0,0 @@ -import * as r from 'restructure'; -import TTFFont from './TTFFont'; - -let DFontName = new r.String(r.uint8); -let DFontData = new r.Struct({ - len: r.uint32, - buf: new r.Buffer('len') -}); - -let Ref = new r.Struct({ - id: r.uint16, - nameOffset: r.int16, - attr: r.uint8, - dataOffset: r.uint24, - handle: r.uint32 -}); - -let Type = new r.Struct({ - name: new r.String(4), - maxTypeIndex: r.uint16, - refList: new r.Pointer(r.uint16, new r.Array(Ref, t => t.maxTypeIndex + 1), { type: 'parent' }) -}); - -let TypeList = new r.Struct({ - length: r.uint16, - types: new r.Array(Type, t => t.length + 1) -}); - -let DFontMap = new r.Struct({ - reserved: new r.Reserved(r.uint8, 24), - typeList: new r.Pointer(r.uint16, TypeList), - nameListOffset: new r.Pointer(r.uint16, 'void') -}); - -let DFontHeader = new r.Struct({ - dataOffset: r.uint32, - map: new r.Pointer(r.uint32, DFontMap), - dataLength: r.uint32, - mapLength: r.uint32 -}); - -export default class DFont { - type = 'DFont'; - - static probe(buffer) { - let stream = new r.DecodeStream(buffer); - - try { - var header = DFontHeader.decode(stream); - } catch (e) { - return false; - } - - for (let type of header.map.typeList.types) { - if (type.name === 'sfnt') { - return true; - } - } - - return false; - } - - constructor(stream) { - this.stream = stream; - this.header = DFontHeader.decode(this.stream); - - for (let type of this.header.map.typeList.types) { - for (let ref of type.refList) { - if (ref.nameOffset >= 0) { - this.stream.pos = ref.nameOffset + this.header.map.nameListOffset; - ref.name = DFontName.decode(this.stream); - } else { - ref.name = null; - } - } - - if (type.name === 'sfnt') { - this.sfnt = type; - } - } - } - - getFont(name) { - if (!this.sfnt) { - return null; - } - - for (let ref of this.sfnt.refList) { - let pos = this.header.dataOffset + ref.dataOffset + 4; - let stream = new r.DecodeStream(this.stream.buffer.slice(pos)); - let font = new TTFFont(stream); - if ( - font.postscriptName === name || - ( - font.postscriptName instanceof Uint8Array && - name instanceof Uint8Array && - font.postscriptName.every((v, i) => name[i] === v) - ) - ) { - return font; - } - } - - return null; - } - - get fonts() { - let fonts = []; - for (let ref of this.sfnt.refList) { - let pos = this.header.dataOffset + ref.dataOffset + 4; - let stream = new r.DecodeStream(this.stream.buffer.slice(pos)); - fonts.push(new TTFFont(stream)); - } - - return fonts; - } -} diff --git a/src/TTFFont.js b/src/TTFFont.js index 6aa0937a..24d3c89d 100644 --- a/src/TTFFont.js +++ b/src/TTFFont.js @@ -1,8 +1,8 @@ import * as r from 'restructure'; import { cache } from './decorators'; -import * as fontkit from './base'; +import { isLoggingErrors, getDefaultLanguage as getGlobalDefaultLanguage } from './base'; import Directory from './tables/directory'; -import tables from './tables'; +import tables from './tables/index'; import CmapProcessor from './CmapProcessor'; import LayoutEngine from './layout/LayoutEngine'; import TTFGlyph from './glyph/TTFGlyph'; @@ -13,13 +13,16 @@ import GlyphVariationProcessor from './glyph/GlyphVariationProcessor'; import TTFSubset from './subset/TTFSubset'; import CFFSubset from './subset/CFFSubset'; import BBox from './glyph/BBox'; -import { asciiDecoder } from './utils'; +import { asciiDecoder } from './utils/decode'; /** * This is the base class for all SFNT-based font formats in fontkit. * It supports TrueType, and PostScript glyphs, and several color glyph formats. */ export default class TTFFont { + /** + * @type {'TTF'} + */ type = 'TTF'; static probe(buffer) { @@ -27,6 +30,27 @@ export default class TTFFont { return format === 'true' || format === 'OTTO' || format === String.fromCharCode(0, 1, 0, 0); } + // some tables that the font should/may have + /** @type {{}} */ + name; + /** @type {{ macStyle: { italic: boolean } }} */ + head; + /** @type {({ sFamilyClass: number } | undefined)} */ + 'OS/2'; + /** @type {({ isFixedPitch: boolean } | undefined)} */ + post; + /** @type {({} | undefined)} */ + GSUB; + /** @type {({} | undefined)} */ + GPOS; + /** @type {({} | undefined)} */ + morx; + /** @type {({} | undefined)} */ + kern; + + /** @type {*} */ + cff; // TODO: check if this is correct + constructor(stream, variationCoords = null) { this.defaultLanguage = null; this.stream = stream; @@ -57,7 +81,7 @@ export default class TTFFont { try { this._tables[table.tag] = this._decodeTable(table); } catch (e) { - if (fontkit.logErrors) { + if (isLoggingErrors()) { console.error(`Error decoding table ${table.tag}`); console.error(e.stack); } @@ -96,14 +120,14 @@ export default class TTFFont { * `lang` is a BCP-47 language code. * @return {string} */ - getName(key, lang = this.defaultLanguage || fontkit.defaultLanguage) { + getName(key, lang = this.defaultLanguage || getGlobalDefaultLanguage()) { let record = this.name && this.name.records[key]; if (record) { // Attempt to retrieve the entry, depending on which translation is available: return ( record[lang] || record[this.defaultLanguage] - || record[fontkit.defaultLanguage] + || record[getGlobalDefaultLanguage()] || record['en'] || record[Object.keys(record)[0]] // Seriously, ANY language would be fine || null @@ -166,7 +190,12 @@ export default class TTFFont { * @type {number} */ get ascent() { - return this.hhea.ascent; + const os2 = this['OS/2']; + if (os2?.fsSelection?.useTypoMetrics) { + return os2.typoAscender; + } else { + return this.hhea.ascent; + } } /** @@ -174,7 +203,12 @@ export default class TTFFont { * @type {number} */ get descent() { - return this.hhea.descent; + const os2 = this['OS/2']; + if (os2?.fsSelection?.useTypoMetrics) { + return os2.typoDescender; + } else { + return this.hhea.descent; + } } /** @@ -182,7 +216,12 @@ export default class TTFFont { * @type {number} */ get lineGap() { - return this.hhea.lineGap; + const os2 = this['OS/2']; + if (os2?.fsSelection?.useTypoMetrics) { + return os2.typoLineGap; + } else { + return this.hhea.lineGap; + } } /** @@ -260,7 +299,11 @@ export default class TTFFont { } /** - * An array of all of the unicode code points supported by the font. + * An array of all of the Unicode code points that the font supports via its main cmap subtable. + * + * Note: This does not include code points only present in the cmap format 14 subtable, such as variation selectors. + * See also `nonDefaultUVSSet` for those. + * * @type {number[]} */ @cache @@ -268,6 +311,25 @@ export default class TTFFont { return this._cmapProcessor.getCharacterSet(); } + /** + * An array of all of the non-default Unicode Variation Sequences + * that the font supports via its cmap format 14 subtable. + * + * Each entry in the array is an object with the following properties: + * - `baseCharacter`: the base character code point + * - `variationSelector`: the variation selector code point + * - `glyphID`: the glyph ID for the variation + * + * The array is not guaranteed to be in any particular order. + * + * @type {{ baseCharacter: number, variationSelector: number, glyphID: number }[]} + * @see {@link https://learn.microsoft.com/en-us/typography/opentype/spec/cmap#non-default-uvs-table} + */ + @cache + get nonDefaultUVSSet() { + return this._cmapProcessor.getNonDefaultUVSSet(); + } + /** * Returns whether there is glyph in the font for the given unicode code point. * @@ -283,7 +345,7 @@ export default class TTFFont { * Does not perform any advanced substitutions (there is no context to do so). * * @param {number} codePoint - * @return {Glyph} + * @return {import('./glyph/Glyph').default} */ glyphForCodePoint(codePoint) { return this.getGlyph(this._cmapProcessor.lookup(codePoint), [codePoint]); @@ -296,7 +358,7 @@ export default class TTFFont { * provides a much more advanced mapping supporting AAT and OpenType shaping. * * @param {string} string - * @return {Glyph[]} + * @return {import('./glyph/Glyph').default[]} */ glyphsForString(string) { let glyphs = []; @@ -350,14 +412,12 @@ export default class TTFFont { * Returns a GlyphRun object, which includes an array of Glyphs and GlyphPositions for the given string. * * @param {string} string - * @param {string[]} [userFeatures] - * @param {string} [script] - * @param {string} [language] - * @param {string} [direction] - * @return {GlyphRun} + * @param {string[] | Record} [userFeatures] + * @param {import('./types').LayoutAdvancedParams} [advancedParams] + * @return {import('./layout/GlyphRun').default} */ - layout(string, userFeatures, script, language, direction) { - return this._layoutEngine.layout(string, userFeatures, script, language, direction); + layout(string, userFeatures, advancedParams) { + return this._layoutEngine.layout(string, userFeatures, advancedParams); } /** @@ -380,6 +440,10 @@ export default class TTFFont { return this._layoutEngine.getAvailableFeatures(); } + /** + * @param {string} script + * @param {string} language + */ getAvailableFeatures(script, language) { return this._layoutEngine.getAvailableFeatures(script, language); } @@ -404,7 +468,7 @@ export default class TTFFont { * * @param {number} glyph * @param {number[]} characters - * @return {Glyph} + * @return {import('./glyph/Glyph').default} */ getGlyph(glyph, characters = []) { if (!this._glyphs[glyph]) { @@ -424,7 +488,7 @@ export default class TTFFont { /** * Returns a Subset for this font. - * @return {Subset} + * @return {(CFFSubset | TTFSubset)} */ createSubset() { if (this.directory.tables['CFF ']) { @@ -551,4 +615,38 @@ export default class TTFFont { getFont(name) { return this.getVariation(name); } + + /** + * The default origin Y coordinate of the glyphs in the vertical writing mode. + * + * `null` if `VORG` table does not exist. + * + * See also `Glyph#vertOriginY` for a value specific to a particular glyph. + * + * @type {number | null} + * @see VORG https://learn.microsoft.com/en-us/typography/opentype/spec/vorg + */ + get defaultVertOriginY() { + return this.VORG?.defaultVertOriginY ?? null; + } + + /** + * Returns a mapping from glyph IDs to the origin Y coordinate for each glyph in the vertical writing mode. + * + * Returns `null` if `VORG` table does not exist. + * + * @returns {Map | null} + */ + getVertOriginYMap() { + if (this._vertOriginYMap !== undefined) return this._vertOriginYMap; + + if (this.VORG?.metrics == null) return this._vertOriginYMap = null; + + const map = new Map(); + for (const entry of this.VORG.metrics) { + map.set(entry.glyphIndex, entry.vertOriginY); + } + + return this._vertOriginYMap = map; + } } diff --git a/src/TrueTypeCollection.js b/src/TrueTypeCollection.js index 2d273093..90540049 100644 --- a/src/TrueTypeCollection.js +++ b/src/TrueTypeCollection.js @@ -2,7 +2,7 @@ import * as r from 'restructure'; import TTFFont from './TTFFont'; import Directory from './tables/directory'; import tables from './tables'; -import { asciiDecoder } from './utils'; +import { asciiDecoder } from './utils/decode'; let TTCHeader = new r.VersionedStruct(r.uint32, { 0x00010000: { @@ -19,6 +19,9 @@ let TTCHeader = new r.VersionedStruct(r.uint32, { }); export default class TrueTypeCollection { + /** + * @type {'TTC'} + */ type = 'TTC'; static probe(buffer) { diff --git a/src/WOFF2Font.js b/src/WOFF2Font.js deleted file mode 100644 index 2cb327d8..00000000 --- a/src/WOFF2Font.js +++ /dev/null @@ -1,216 +0,0 @@ -import * as r from 'restructure'; -import brotli from 'brotli/decompress.js'; -import TTFFont from './TTFFont'; -import TTFGlyph, { Point } from './glyph/TTFGlyph'; -import WOFF2Glyph from './glyph/WOFF2Glyph'; -import WOFF2Directory from './tables/WOFF2Directory'; -import { asciiDecoder } from './utils'; - -/** - * Subclass of TTFFont that represents a TTF/OTF font compressed by WOFF2 - * See spec here: http://www.w3.org/TR/WOFF2/ - */ -export default class WOFF2Font extends TTFFont { - type = 'WOFF2'; - - static probe(buffer) { - return asciiDecoder.decode(buffer.slice(0, 4)) === 'wOF2'; - } - - _decodeDirectory() { - this.directory = WOFF2Directory.decode(this.stream); - this._dataPos = this.stream.pos; - } - - _decompress() { - // decompress data and setup table offsets if we haven't already - if (!this._decompressed) { - this.stream.pos = this._dataPos; - let buffer = this.stream.readBuffer(this.directory.totalCompressedSize); - - let decompressedSize = 0; - for (let tag in this.directory.tables) { - let entry = this.directory.tables[tag]; - entry.offset = decompressedSize; - decompressedSize += (entry.transformLength != null) ? entry.transformLength : entry.length; - } - - let decompressed = brotli(buffer, decompressedSize); - if (!decompressed) { - throw new Error('Error decoding compressed data in WOFF2'); - } - - this.stream = new r.DecodeStream(decompressed); - this._decompressed = true; - } - } - - _decodeTable(table) { - this._decompress(); - return super._decodeTable(table); - } - - // Override this method to get a glyph and return our - // custom subclass if there is a glyf table. - _getBaseGlyph(glyph, characters = []) { - if (!this._glyphs[glyph]) { - if (this.directory.tables.glyf && this.directory.tables.glyf.transformed) { - if (!this._transformedGlyphs) { this._transformGlyfTable(); } - return this._glyphs[glyph] = new WOFF2Glyph(glyph, characters, this); - - } else { - return super._getBaseGlyph(glyph, characters); - } - } - } - - _transformGlyfTable() { - this._decompress(); - this.stream.pos = this.directory.tables.glyf.offset; - let table = GlyfTable.decode(this.stream); - let glyphs = []; - - for (let index = 0; index < table.numGlyphs; index++) { - let glyph = {}; - let nContours = table.nContours.readInt16BE(); - glyph.numberOfContours = nContours; - - if (nContours > 0) { // simple glyph - let nPoints = []; - let totalPoints = 0; - - for (let i = 0; i < nContours; i++) { - let r = read255UInt16(table.nPoints); - totalPoints += r; - nPoints.push(totalPoints); - } - - glyph.points = decodeTriplet(table.flags, table.glyphs, totalPoints); - for (let i = 0; i < nContours; i++) { - glyph.points[nPoints[i] - 1].endContour = true; - } - - var instructionSize = read255UInt16(table.glyphs); - - } else if (nContours < 0) { // composite glyph - let haveInstructions = TTFGlyph.prototype._decodeComposite.call({ _font: this }, glyph, table.composites); - if (haveInstructions) { - var instructionSize = read255UInt16(table.glyphs); - } - } - - glyphs.push(glyph); - } - - this._transformedGlyphs = glyphs; - } -} - -// Special class that accepts a length and returns a sub-stream for that data -class Substream { - constructor(length) { - this.length = length; - this._buf = new r.Buffer(length); - } - - decode(stream, parent) { - return new r.DecodeStream(this._buf.decode(stream, parent)); - } -} - -// This struct represents the entire glyf table -let GlyfTable = new r.Struct({ - version: r.uint32, - numGlyphs: r.uint16, - indexFormat: r.uint16, - nContourStreamSize: r.uint32, - nPointsStreamSize: r.uint32, - flagStreamSize: r.uint32, - glyphStreamSize: r.uint32, - compositeStreamSize: r.uint32, - bboxStreamSize: r.uint32, - instructionStreamSize: r.uint32, - nContours: new Substream('nContourStreamSize'), - nPoints: new Substream('nPointsStreamSize'), - flags: new Substream('flagStreamSize'), - glyphs: new Substream('glyphStreamSize'), - composites: new Substream('compositeStreamSize'), - bboxes: new Substream('bboxStreamSize'), - instructions: new Substream('instructionStreamSize') -}); - -const WORD_CODE = 253; -const ONE_MORE_BYTE_CODE2 = 254; -const ONE_MORE_BYTE_CODE1 = 255; -const LOWEST_U_CODE = 253; - -function read255UInt16(stream) { - let code = stream.readUInt8(); - - if (code === WORD_CODE) { - return stream.readUInt16BE(); - } - - if (code === ONE_MORE_BYTE_CODE1) { - return stream.readUInt8() + LOWEST_U_CODE; - } - - if (code === ONE_MORE_BYTE_CODE2) { - return stream.readUInt8() + LOWEST_U_CODE * 2; - } - - return code; -} - -function withSign(flag, baseval) { - return flag & 1 ? baseval : -baseval; -} - -function decodeTriplet(flags, glyphs, nPoints) { - let y; - let x = y = 0; - let res = []; - - for (let i = 0; i < nPoints; i++) { - let dx = 0, dy = 0; - let flag = flags.readUInt8(); - let onCurve = !(flag >> 7); - flag &= 0x7f; - - if (flag < 10) { - dx = 0; - dy = withSign(flag, ((flag & 14) << 7) + glyphs.readUInt8()); - - } else if (flag < 20) { - dx = withSign(flag, (((flag - 10) & 14) << 7) + glyphs.readUInt8()); - dy = 0; - - } else if (flag < 84) { - var b0 = flag - 20; - var b1 = glyphs.readUInt8(); - dx = withSign(flag, 1 + (b0 & 0x30) + (b1 >> 4)); - dy = withSign(flag >> 1, 1 + ((b0 & 0x0c) << 2) + (b1 & 0x0f)); - - } else if (flag < 120) { - var b0 = flag - 84; - dx = withSign(flag, 1 + ((b0 / 12) << 8) + glyphs.readUInt8()); - dy = withSign(flag >> 1, 1 + (((b0 % 12) >> 2) << 8) + glyphs.readUInt8()); - - } else if (flag < 124) { - var b1 = glyphs.readUInt8(); - let b2 = glyphs.readUInt8(); - dx = withSign(flag, (b1 << 4) + (b2 >> 4)); - dy = withSign(flag >> 1, ((b2 & 0x0f) << 8) + glyphs.readUInt8()); - - } else { - dx = withSign(flag, glyphs.readUInt16BE()); - dy = withSign(flag >> 1, glyphs.readUInt16BE()); - } - - x += dx; - y += dy; - res.push(new Point(onCurve, false, x, y)); - } - - return res; -} diff --git a/src/WOFFFont.js b/src/WOFFFont.js deleted file mode 100644 index 6a2399a1..00000000 --- a/src/WOFFFont.js +++ /dev/null @@ -1,36 +0,0 @@ -import TTFFont from './TTFFont'; -import WOFFDirectory from './tables/WOFFDirectory'; -import tables from './tables'; -import inflate from 'tiny-inflate'; -import * as r from 'restructure'; -import { asciiDecoder } from './utils'; - -export default class WOFFFont extends TTFFont { - type = 'WOFF'; - - static probe(buffer) { - return asciiDecoder.decode(buffer.slice(0, 4)) === 'wOFF'; - } - - _decodeDirectory() { - this.directory = WOFFDirectory.decode(this.stream, { _startOffset: 0 }); - } - - _getTableStream(tag) { - let table = this.directory.tables[tag]; - if (table) { - this.stream.pos = table.offset; - - if (table.compLength < table.length) { - this.stream.pos += 2; // skip deflate header - let outBuffer = new Uint8Array(table.length); - let buf = inflate(this.stream.readBuffer(table.compLength - 2), outBuffer); - return new r.DecodeStream(buf); - } else { - return this.stream; - } - } - - return null; - } -} diff --git a/src/aat/AATLookupTable.js b/src/aat/AATLookupTable.js index 74f764c7..636a4166 100644 --- a/src/aat/AATLookupTable.js +++ b/src/aat/AATLookupTable.js @@ -1,5 +1,5 @@ import {cache} from '../decorators'; -import {range} from '../utils'; +import {range} from '../utils/arrays'; export default class AATLookupTable { constructor(table) { diff --git a/src/base.js b/src/base.js index 1f9d40b8..b2a7a70d 100644 --- a/src/base.js +++ b/src/base.js @@ -1,12 +1,37 @@ -import {DecodeStream} from 'restructure'; +// @ts-check -export let logErrors = false; +// @ts-ignore +import { DecodeStream } from 'restructure'; + +// ----------------------------------------------------------------------------- + +let loggingErrors = false; + +export function isLoggingErrors() { + return loggingErrors; +} + +/** + * @param {boolean} flag + */ +export function logErrors(flag) { + loggingErrors = flag; +} + +// ----------------------------------------------------------------------------- let formats = []; export function registerFormat(format) { formats.push(format); -}; +} +// ----------------------------------------------------------------------------- + +/** + * @param {ArrayBufferView} buffer + * @param {string} [postscriptName] + * @return {(import('./TTFFont.js').default | import('./TrueTypeCollection.js').default)} + */ export function create(buffer, postscriptName) { for (let i = 0; i < formats.length; i++) { let format = formats[i]; @@ -21,9 +46,18 @@ export function create(buffer, postscriptName) { } throw new Error('Unknown font format'); -}; +} + +// ----------------------------------------------------------------------------- -export let defaultLanguage = 'en'; +let defaultLanguage = 'en'; +export function getDefaultLanguage() { + return defaultLanguage; +} export function setDefaultLanguage(lang = 'en') { defaultLanguage = lang; -}; +} + +// ----------------------------------------------------------------------------- + +export { default as DefaultShaper } from './opentype/shapers/DefaultShaper'; diff --git a/src/cff/CFFCharsets.js b/src/cff/CFFCharsets.js index b7a98741..91888cb0 100644 --- a/src/cff/CFFCharsets.js +++ b/src/cff/CFFCharsets.js @@ -1,4 +1,4 @@ -export let ISOAdobeCharset = [ +export const ISOAdobeCharset = [ '.notdef', 'space', 'exclam', 'quotedbl', 'numbersign', 'dollar', 'percent', 'ampersand', 'quoteright', 'parenleft', 'parenright', 'asterisk', 'plus', 'comma', 'hyphen', 'period', 'slash', 'zero', @@ -35,7 +35,7 @@ export let ISOAdobeCharset = [ 'ugrave', 'yacute', 'ydieresis', 'zcaron' ]; -export let ExpertCharset = [ +export const ExpertCharset = [ '.notdef', 'space', 'exclamsmall', 'Hungarumlautsmall', 'dollaroldstyle', 'dollarsuperior', 'ampersandsmall', 'Acutesmall', 'parenleftsuperior', 'parenrightsuperior', 'twodotenleader', 'onedotenleader', 'comma', @@ -75,7 +75,7 @@ export let ExpertCharset = [ 'Ydieresissmall' ]; -export let ExpertSubsetCharset = [ +export const ExpertSubsetCharset = [ '.notdef', 'space', 'dollaroldstyle', 'dollarsuperior', 'parenleftsuperior', 'parenrightsuperior', 'twodotenleader', 'onedotenleader', 'comma', 'hyphen', 'period', 'fraction', diff --git a/src/cff/CFFDict.js b/src/cff/CFFDict.js index 569c1067..66fc0855 100644 --- a/src/cff/CFFDict.js +++ b/src/cff/CFFDict.js @@ -1,7 +1,6 @@ -import isEqual from 'fast-deep-equal'; -import * as r from 'restructure'; import CFFOperand from './CFFOperand'; import { PropertyDescriptor } from 'restructure'; +import { equalArray } from '../utils/deep-equal'; export default class CFFDict { constructor(ops = []) { @@ -108,7 +107,7 @@ export default class CFFDict { for (let k in this.fields) { let field = this.fields[k]; let val = dict[field[1]]; - if (val == null || isEqual(val, field[3])) { + if (val == null || equalArray(val, field[3])) { continue; } @@ -141,7 +140,7 @@ export default class CFFDict { for (let field of this.ops) { let val = dict[field[1]]; - if (val == null || isEqual(val, field[3])) { + if (val == null || equalArray(val, field[3])) { continue; } diff --git a/src/cff/CFFEncodings.js b/src/cff/CFFEncodings.js index 5465bb4f..aca8ea64 100644 --- a/src/cff/CFFEncodings.js +++ b/src/cff/CFFEncodings.js @@ -1,4 +1,4 @@ -export let StandardEncoding = [ +export const StandardEncoding = [ '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'space', 'exclam', 'quotedbl', 'numbersign', 'dollar', 'percent', 'ampersand', 'quoteright', 'parenleft', 'parenright', 'asterisk', 'plus', 'comma', 'hyphen', 'period', 'slash', 'zero', 'one', 'two', @@ -18,7 +18,7 @@ export let StandardEncoding = [ 'lslash', 'oslash', 'oe', 'germandbls' ]; -export let ExpertEncoding = [ +export const ExpertEncoding = [ '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', 'space', 'exclamsmall', 'Hungarumlautsmall', '', 'dollaroldstyle', 'dollarsuperior', 'ampersandsmall', 'Acutesmall', 'parenleftsuperior', 'parenrightsuperior', 'twodotenleader', 'onedotenleader', diff --git a/src/glyph/Glyph.js b/src/glyph/Glyph.js index 3592150f..ad7113ed 100644 --- a/src/glyph/Glyph.js +++ b/src/glyph/Glyph.js @@ -1,6 +1,6 @@ import { cache } from '../decorators'; import Path from './Path'; -import {isMark} from 'unicode-properties'; +import { isMark } from 'unicode-properties'; import StandardNames from './StandardNames'; /** @@ -99,7 +99,7 @@ export default class Glyph { * See [here](http://www.freetype.org/freetype2/docs/glyphs/glyphs-6.html#section-2) * for a more detailed description. * - * @type {BBox} + * @type {import('./BBox').default} */ @cache get cbox() { @@ -109,7 +109,7 @@ export default class Glyph { /** * The glyph’s bounding box, i.e. the rectangle that encloses the * glyph outline as tightly as possible. - * @type {BBox} + * @type {import('./BBox').default} */ @cache get bbox() { @@ -155,6 +155,25 @@ export default class Glyph { return this._getMetrics().advanceHeight; } + /** + * The glyph's origin Y coordinate in the vertical writing mode. + * + * `null` if no specific value is registered for this glyph. + * See also `TTFFont#defaultVertOriginY` in that case. + * + * @type {number | null} + * @see VORG https://learn.microsoft.com/en-us/typography/opentype/spec/vorg + */ + get vertOriginY() { + // No cache needed here since getVertOriginYMap() has cache mechanism + const map = this._font.getVertOriginYMap(); + if (map != null) { + return map.get(this.id) ?? null; + } else { + return null; + } + } + get ligatureCaretPositions() {} _getName() { @@ -194,7 +213,7 @@ export default class Glyph { /** * Renders the glyph to the given graphics context, at the specified font size. - * @param {CanvasRenderingContext2d} ctx + * @param {CanvasRenderingContext2D} ctx * @param {number} size */ render(ctx, size) { diff --git a/src/glyph/GlyphVariationProcessor.js b/src/glyph/GlyphVariationProcessor.js index 78662795..71c36878 100644 --- a/src/glyph/GlyphVariationProcessor.js +++ b/src/glyph/GlyphVariationProcessor.js @@ -181,6 +181,9 @@ export default class GlyphVariationProcessor { } } + /** + * @returns {Uint16Array} + */ decodePoints() { let stream = this.font.stream; let count = stream.readUInt8(); @@ -206,6 +209,10 @@ export default class GlyphVariationProcessor { return points; } + /** + * @param {number} count + * @returns {Int16Array} + */ decodeDeltas(count) { let stream = this.font.stream; let i = 0; diff --git a/src/glyph/WOFF2Glyph.js b/src/glyph/WOFF2Glyph.js deleted file mode 100644 index 9b451c1b..00000000 --- a/src/glyph/WOFF2Glyph.js +++ /dev/null @@ -1,17 +0,0 @@ -import TTFGlyph from './TTFGlyph'; - -/** - * Represents a TrueType glyph in the WOFF2 format, which compresses glyphs differently. - */ -export default class WOFF2Glyph extends TTFGlyph { - type = 'WOFF2'; - - _decode() { - // We have to decode in advance (in WOFF2Font), so just return the pre-decoded data. - return this._font._transformedGlyphs[this.id]; - } - - _getCBox() { - return this.path.bbox; - } -} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index e27a7b25..00000000 --- a/src/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import { registerFormat, create, defaultLanguage, setDefaultLanguage } from './base'; -import TTFFont from './TTFFont'; -import WOFFFont from './WOFFFont'; -import WOFF2Font from './WOFF2Font'; -import TrueTypeCollection from './TrueTypeCollection'; -import DFont from './DFont'; - -// Register font formats -registerFormat(TTFFont); -registerFormat(WOFFFont); -registerFormat(WOFF2Font); -registerFormat(TrueTypeCollection); -registerFormat(DFont); - -export * from './base'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..04c29161 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,26 @@ +import { registerFormat } from './base'; +import TTFFont from './TTFFont'; +import TrueTypeCollection from './TrueTypeCollection'; + +// Register font formats +registerFormat(TTFFont); +registerFormat(TrueTypeCollection); + +export * from './base'; +export { DefaultShaper } from './base'; // Explicit export for preventing tree-shaking + +export type { default as TTFFont } from './TTFFont'; +export type { default as TrueTypeCollection } from './TrueTypeCollection'; +export type { default as Glyph } from './glyph/Glyph'; +export type { default as BBox } from './glyph/BBox'; +export type { default as Path } from './glyph/Path'; +export type { default as GlyphPosition } from './layout/GlyphPosition'; +export type { default as GlyphRun } from './layout/GlyphRun'; +export type { default as Subset } from './subset/Subset'; + +export type { + GlyphInfo, + ShapingPlan, + LayoutAdvancedParams, + Shaper, +} from './types'; diff --git a/src/layout/GlyphRun.js b/src/layout/GlyphRun.js index 18e2f236..e44b647e 100644 --- a/src/layout/GlyphRun.js +++ b/src/layout/GlyphRun.js @@ -1,3 +1,5 @@ +// @ts-check + import BBox from '../glyph/BBox'; import * as Script from '../layout/Script'; @@ -6,36 +8,44 @@ import * as Script from '../layout/Script'; * Returned by the font layout method. */ export default class GlyphRun { + /** + * @param {import('../glyph/Glyph').default[]} glyphs + * @param {string[] | Record | null | undefined} features + * @param {string} [script] + * @param {string} [language] + * @param {('ltr' | 'rtl')} [direction] + */ constructor(glyphs, features, script, language, direction) { /** * An array of Glyph objects in the run - * @type {Glyph[]} + * @type {import('../glyph/Glyph').default[]} */ this.glyphs = glyphs; /** - * An array of GlyphPosition objects for each glyph in the run - * @type {GlyphPosition[]} + * An array of GlyphPosition objects for each glyph in the run. + * Initially `null` and may be assigned in the glyph positioning process. + * @type {(import('./GlyphPosition').default[] | null)} */ this.positions = null; /** * The script that was requested for shaping. This was either passed in or detected automatically. - * @type {string} + * @type {(string | null)} */ - this.script = script; + this.script = script || null; /** * The language requested for shaping, as passed in. If `null`, the default language for the * script was used. - * @type {string} + * @type {(string | null)} */ this.language = language || null; /** * The direction requested for shaping, as passed in (either ltr or rtl). * If `null`, the default direction of the script is used. - * @type {string} + * @type {('ltr' | 'rtl')} */ this.direction = direction || Script.direction(script); @@ -47,20 +57,24 @@ export default class GlyphRun { this.features = {}; // Convert features to an object - if (Array.isArray(features)) { - for (let tag of features) { - this.features[tag] = true; + if (features) { + if (Array.isArray(features)) { + for (let tag of features) { + this.features[tag] = true; + } + } else if (typeof features === 'object') { + this.features = features; } - } else if (typeof features === 'object') { - this.features = features; } } /** - * The total advance width of the run. - * @type {number} + * The total advance width of the run. `null` if `positions` are not calculated. + * @type {(number | null)} */ get advanceWidth() { + if (this.positions == null) return null; + let width = 0; for (let position of this.positions) { width += position.xAdvance; @@ -69,11 +83,13 @@ export default class GlyphRun { return width; } - /** - * The total advance height of the run. - * @type {number} - */ + /** + * The total advance height of the run. `null` if `positions` are not calculated. + * @type {(number | null)} + */ get advanceHeight() { + if (this.positions == null) return null; + let height = 0; for (let position of this.positions) { height += position.yAdvance; @@ -82,12 +98,14 @@ export default class GlyphRun { return height; } - /** - * The bounding box containing all glyphs in the run. - * @type {BBox} - */ + /** + * The bounding box containing all glyphs in the run. `null` if `positions` are not calculated. + * @type {(BBox | null)} + */ get bbox() { - let bbox = new BBox; + if (this.positions == null) return null; + + let bbox = new BBox(); let x = 0; let y = 0; diff --git a/src/layout/KernProcessor.js b/src/layout/KernProcessor.js index 4d4f6db5..075062a6 100644 --- a/src/layout/KernProcessor.js +++ b/src/layout/KernProcessor.js @@ -1,10 +1,23 @@ -import {binarySearch} from '../utils'; +// @ts-check + +import { binarySearch } from '../utils/arrays'; export default class KernProcessor { + /** + * @param {import('../TTFFont').default} font + */ constructor(font) { + /** + * @type {{tables: any[]}} + */ + // @ts-ignore this.kern = font.kern; } + /** + * @param {import('../glyph/Glyph').default[]} glyphs + * @param {import('./GlyphPosition').default[]} positions + */ process(glyphs, positions) { for (let glyphIndex = 0; glyphIndex < glyphs.length - 1; glyphIndex++) { let left = glyphs[glyphIndex].id; @@ -13,6 +26,10 @@ export default class KernProcessor { } } + /** + * @param {number} left + * @param {number} right + */ getKerning(left, right) { let res = 0; diff --git a/src/layout/LayoutEngine.js b/src/layout/LayoutEngine.js index 1b035a72..5c82f995 100644 --- a/src/layout/LayoutEngine.js +++ b/src/layout/LayoutEngine.js @@ -1,14 +1,24 @@ +// @ts-check + import KernProcessor from './KernProcessor'; import UnicodeLayoutEngine from './UnicodeLayoutEngine'; import GlyphRun from './GlyphRun'; -import GlyphPosition from './GlyphPosition'; import * as Script from './Script'; import AATLayoutEngine from '../aat/AATLayoutEngine'; import OTLayoutEngine from '../opentype/OTLayoutEngine'; +import GlyphPosition from './GlyphPosition'; export default class LayoutEngine { + /** + * @param {import('../TTFFont').default} font + */ constructor(font) { + /** + * @type {import('../TTFFont').default} + */ + // @ts-ignore this.font = font; + this.unicodeLayoutEngine = null; this.kernProcessor = null; @@ -22,14 +32,14 @@ export default class LayoutEngine { } } - layout(string, features, script, language, direction) { - // Make the features parameter optional - if (typeof features === 'string') { - direction = language; - language = script; - script = features; - features = []; - } + /** + * @param {string | import('../glyph/Glyph').default[]} string + * @param {string[] | Record} [features] + * @param {import('../types').LayoutAdvancedParams} [advancedParams] + * @returns {GlyphRun} + */ + layout(string, features, advancedParams = {}) { + let {script, language, direction, shaper, skipPerGlyphPositioning} = advancedParams; // Map string to glyphs if needed if (typeof string === 'string') { @@ -57,29 +67,35 @@ export default class LayoutEngine { // Return early if there are no glyphs if (glyphs.length === 0) { - glyphRun.positions = []; return glyphRun; } // Setup the advanced layout engine - if (this.engine && this.engine.setup) { - this.engine.setup(glyphRun); + if (this.engine instanceof OTLayoutEngine) { + this.engine.setup(glyphRun, shaper); } // Substitute and position the glyphs this.substitute(glyphRun); + if (!skipPerGlyphPositioning) { + // Assign initial glyph positions (FIXME: support vertical writing mode) + glyphRun.positions = glyphRun.glyphs.map(glyph => new GlyphPosition(glyph.advanceWidth)); + } this.position(glyphRun); - this.hideDefaultIgnorables(glyphRun.glyphs, glyphRun.positions); + this.hideDefaultIgnorables(glyphRun); // Let the layout engine clean up any state it might have - if (this.engine && this.engine.cleanup) { + if (this.engine instanceof OTLayoutEngine) { this.engine.cleanup(); } return glyphRun; } + /** + * @param {GlyphRun} glyphRun + */ substitute(glyphRun) { // Call the advanced layout engine to make substitutions if (this.engine && this.engine.substitute) { @@ -87,16 +103,24 @@ export default class LayoutEngine { } } + /** + * Includes: + * - Arrangement of the entire glyph array + * - Position adjustment per glyph (skipped if the flag `skipPerGlyphPositioning` is set) + * + * @param {GlyphRun} glyphRun + */ position(glyphRun) { - // Get initial glyph positions - glyphRun.positions = glyphRun.glyphs.map(glyph => new GlyphPosition(glyph.advanceWidth)); let positioned = null; // Call the advanced layout engine. Returns the features applied. - if (this.engine && this.engine.position) { + if (this.engine instanceof OTLayoutEngine) { positioned = this.engine.position(glyphRun); } + // The following process only takes effect if `glyphRun.positions` is not null. + if (glyphRun.positions == null) return; + // if there is no GPOS table, use unicode properties to position marks. if (!positioned && (!this.engine || this.engine.fallbackPosition)) { if (!this.unicodeLayoutEngine) { @@ -117,17 +141,28 @@ export default class LayoutEngine { } } - hideDefaultIgnorables(glyphs, positions) { + /** + * @param {GlyphRun} glyphRun + */ + hideDefaultIgnorables(glyphRun) { + const { glyphs, positions } = glyphRun; + let space = this.font.glyphForCodePoint(0x20); for (let i = 0; i < glyphs.length; i++) { if (this.isDefaultIgnorable(glyphs[i].codePoints[0])) { glyphs[i] = space; - positions[i].xAdvance = 0; - positions[i].yAdvance = 0; + if (positions != null) { + positions[i].xAdvance = 0; + positions[i].yAdvance = 0; + } } } } + /** + * @param {number} ch + * @returns {boolean} + */ isDefaultIgnorable(ch) { // From DerivedCoreProperties.txt in the Unicode database, // minus U+115F, U+1160, U+3164 and U+FFA0, which is what @@ -156,6 +191,11 @@ export default class LayoutEngine { } } + /** + * @param {string} [script] + * @param {string} [language] + * @returns {string[]} + */ getAvailableFeatures(script, language) { let features = []; @@ -170,6 +210,10 @@ export default class LayoutEngine { return features; } + /** + * @param {number} gid + * @returns {string[]} + */ stringsForGlyph(gid) { let result = new Set; @@ -178,7 +222,7 @@ export default class LayoutEngine { result.add(String.fromCodePoint(codePoint)); } - if (this.engine && this.engine.stringsForGlyph) { + if (this.engine instanceof AATLayoutEngine) { for (let string of this.engine.stringsForGlyph(gid)) { result.add(string); } diff --git a/src/layout/UnicodeLayoutEngine.js b/src/layout/UnicodeLayoutEngine.js index 68d5b9a0..b68a1629 100644 --- a/src/layout/UnicodeLayoutEngine.js +++ b/src/layout/UnicodeLayoutEngine.js @@ -1,4 +1,6 @@ -import {getCombiningClass} from 'unicode-properties'; +// @ts-check + +import { getCombiningClass } from 'unicode-properties'; /** * This class is used when GPOS does not define 'mark' or 'mkmk' features @@ -9,10 +11,18 @@ import {getCombiningClass} from 'unicode-properties'; * https://github.com/behdad/harfbuzz/blob/master/src/hb-ot-shape-fallback.cc */ export default class UnicodeLayoutEngine { + /** + * @param {import('../TTFFont').default} font + */ constructor(font) { this.font = font; } + /** + * @param {import('../glyph/Glyph').default[]} glyphs + * @param {import('./GlyphPosition').default[]} positions + * @returns {import('./GlyphPosition').default[]} + */ positionGlyphs(glyphs, positions) { // find each base + mark cluster, and position the marks relative to the base let clusterStart = 0; @@ -37,6 +47,12 @@ export default class UnicodeLayoutEngine { return positions; } + /** + * @param {import('../glyph/Glyph').default[]} glyphs + * @param {import('./GlyphPosition').default[]} positions + * @param {number} clusterStart + * @param {number} clusterEnd + */ positionCluster(glyphs, positions, clusterStart, clusterEnd) { let base = glyphs[clusterStart]; let baseBox = base.cbox.copy(); @@ -135,6 +151,9 @@ export default class UnicodeLayoutEngine { return; } + /** + * @param {number} codePoint + */ getCombiningClass(codePoint) { let combiningClass = getCombiningClass(codePoint); diff --git a/src/node.js b/src/node.js deleted file mode 100644 index f496ea02..00000000 --- a/src/node.js +++ /dev/null @@ -1,17 +0,0 @@ -import { registerFormat, create, defaultLanguage, setDefaultLanguage } from './base'; -import { open, openSync } from './fs'; -import TTFFont from './TTFFont'; -import WOFFFont from './WOFFFont'; -import WOFF2Font from './WOFF2Font'; -import TrueTypeCollection from './TrueTypeCollection'; -import DFont from './DFont'; - -// Register font formats -registerFormat(TTFFont); -registerFormat(WOFFFont); -registerFormat(WOFF2Font); -registerFormat(TrueTypeCollection); -registerFormat(DFont); - -export * from './base'; -export * from './fs'; diff --git a/src/node.ts b/src/node.ts new file mode 100644 index 00000000..d34de7e9 --- /dev/null +++ b/src/node.ts @@ -0,0 +1,28 @@ +import { registerFormat } from './base'; +import TTFFont from './TTFFont'; +import TrueTypeCollection from './TrueTypeCollection'; + +// Register font formats +registerFormat(TTFFont); +registerFormat(TrueTypeCollection); + +export * from './base'; +export { DefaultShaper } from './base'; // Explicit export for preventing tree-shaking + +export type { default as TTFFont } from './TTFFont'; +export type { default as TrueTypeCollection } from './TrueTypeCollection'; +export type { default as Glyph } from './glyph/Glyph'; +export type { default as BBox } from './glyph/BBox'; +export type { default as Path } from './glyph/Path'; +export type { default as GlyphPosition } from './layout/GlyphPosition'; +export type { default as GlyphRun } from './layout/GlyphRun'; +export type { default as Subset } from './subset/Subset'; + +export type { + GlyphInfo, + ShapingPlan, + LayoutAdvancedParams, + Shaper, +} from './types'; + +export * from './fs'; diff --git a/src/opentype/GPOSProcessor.js b/src/opentype/GPOSProcessor.js index 6a2c1f9f..c0852450 100644 --- a/src/opentype/GPOSProcessor.js +++ b/src/opentype/GPOSProcessor.js @@ -1,5 +1,11 @@ +import GlyphPosition from '../layout/GlyphPosition'; import OTProcessor from './OTProcessor'; +/** + * Null object for `GlyphPosition`. + */ +const positionNull = new GlyphPosition(); + export default class GPOSProcessor extends OTProcessor { applyPositionValue(sequenceIndex, value) { let position = this.positions[this.glyphIterator.peekIndex(sequenceIndex)]; @@ -123,8 +129,12 @@ export default class GPOSProcessor extends OTProcessor { let entry = this.getAnchor(nextRecord.entryAnchor); let exit = this.getAnchor(curRecord.exitAnchor); - let cur = this.positions[this.glyphIterator.index]; - let next = this.positions[nextIndex]; + let cur = positionNull; + let next = positionNull; + if (this.positions != null) { + cur = this.positions[this.glyphIterator.index]; + next = this.positions[nextIndex]; + } let d; switch (this.direction) { @@ -276,8 +286,10 @@ export default class GPOSProcessor extends OTProcessor { let baseCoords = this.getAnchor(baseAnchor); let markCoords = this.getAnchor(markRecord.markAnchor); - let basePos = this.positions[baseGlyphIndex]; - let markPos = this.positions[this.glyphIterator.index]; + let markPos = positionNull; + if (this.positions != null) { + markPos = this.positions[this.glyphIterator.index]; + } markPos.xOffset = baseCoords.x - markCoords.x; markPos.yOffset = baseCoords.y - markCoords.y; @@ -305,9 +317,16 @@ export default class GPOSProcessor extends OTProcessor { return { x, y }; } + /** + * @param {string[]} userFeatures + * @param {import('./GlyphInfo').default[]} glyphs + * @param {import('../layout/GlyphPosition').default[]} [advances] + */ applyFeatures(userFeatures, glyphs, advances) { super.applyFeatures(userFeatures, glyphs, advances); + if (this.positions == null) return; + for (var i = 0; i < this.glyphs.length; i++) { this.fixCursiveAttachment(i); } diff --git a/src/opentype/OTLayoutEngine.js b/src/opentype/OTLayoutEngine.js index 15cf3627..51d8b68c 100644 --- a/src/opentype/OTLayoutEngine.js +++ b/src/opentype/OTLayoutEngine.js @@ -1,10 +1,15 @@ +// @ts-check + import ShapingPlan from './ShapingPlan'; -import * as Shapers from './shapers'; +import * as Shapers from './shapers/index'; import GlyphInfo from './GlyphInfo'; import GSUBProcessor from './GSUBProcessor'; import GPOSProcessor from './GPOSProcessor'; export default class OTLayoutEngine { + /** + * @param {import('../TTFFont').default} font + */ constructor(font) { this.font = font; this.glyphInfos = null; @@ -22,7 +27,11 @@ export default class OTLayoutEngine { } } - setup(glyphRun) { + /** + * @param {import('../layout/GlyphRun').default} glyphRun + * @param {import('../types').Shaper} [shaper] + */ + setup(glyphRun, shaper) { // Map glyphs to GlyphInfo objects so data can be passed between // GSUB and GPOS without mutating the real (shared) Glyph objects. this.glyphInfos = glyphRun.glyphs.map(glyph => new GlyphInfo(this.font, glyph.id, [...glyph.codePoints])); @@ -37,9 +46,9 @@ export default class OTLayoutEngine { script = this.GSUBProcessor.selectScript(glyphRun.script, glyphRun.language, glyphRun.direction); } - // Choose a shaper based on the script, and setup a shaping plan. + // Choose a shaper based on the script (if not provided), and setup a shaping plan. // This determines which features to apply to which glyphs. - this.shaper = Shapers.choose(script); + this.shaper = shaper ?? Shapers.choose(script); this.plan = new ShapingPlan(this.font, script, glyphRun.direction); this.shaper.plan(this.plan, this.glyphInfos, glyphRun.features); @@ -49,7 +58,14 @@ export default class OTLayoutEngine { } } + /** + * @param {import('../layout/GlyphRun').default} glyphRun + */ substitute(glyphRun) { + if (this.glyphInfos == null || this.plan == null) { + throw new Error('setup() must be called before substitute()'); + } + if (this.GSUBProcessor) { this.plan.process(this.GSUBProcessor, this.glyphInfos); @@ -58,29 +74,51 @@ export default class OTLayoutEngine { } } + /** + * @param {import('../layout/GlyphRun').default} glyphRun + * @returns {(Record | null)} GPOSProcessor#features + */ position(glyphRun) { - if (this.shaper.zeroMarkWidths === 'BEFORE_GPOS') { - this.zeroMarkAdvances(glyphRun.positions); + if (this.glyphInfos == null || this.plan == null || this.shaper == null) { + throw new Error('setup() must be called before position()'); } - if (this.GPOSProcessor) { - this.plan.process(this.GPOSProcessor, this.glyphInfos, glyphRun.positions); - } + let appliedFeatures = null; + + if (glyphRun.positions != null) { + if (this.shaper.zeroMarkWidths === 'BEFORE_GPOS') { + this.zeroMarkAdvances(glyphRun.positions); + } + + if (this.GPOSProcessor) { + this.plan.process(this.GPOSProcessor, this.glyphInfos, glyphRun.positions); + } + + if (this.shaper.zeroMarkWidths === 'AFTER_GPOS') { + this.zeroMarkAdvances(glyphRun.positions); + } - if (this.shaper.zeroMarkWidths === 'AFTER_GPOS') { - this.zeroMarkAdvances(glyphRun.positions); + appliedFeatures = this.GPOSProcessor && this.GPOSProcessor.features; } // Reverse the glyphs and positions if the script is right-to-left if (glyphRun.direction === 'rtl') { glyphRun.glyphs.reverse(); - glyphRun.positions.reverse(); + if (glyphRun.positions != null) glyphRun.positions.reverse(); } - return this.GPOSProcessor && this.GPOSProcessor.features; + return appliedFeatures; } + /** + * + * @param {import('../layout/GlyphPosition').default[]} positions + */ zeroMarkAdvances(positions) { + if (this.glyphInfos == null) { + throw new Error('setup() must be called before zeroMarkAdvances()'); + } + for (let i = 0; i < this.glyphInfos.length; i++) { if (this.glyphInfos[i].isMark) { positions[i].xAdvance = 0; @@ -95,6 +133,11 @@ export default class OTLayoutEngine { this.shaper = null; } + /** + * @param {string} [script] + * @param {string} [language] + * @returns {string[]} + */ getAvailableFeatures(script, language) { let features = []; diff --git a/src/opentype/OTProcessor.js b/src/opentype/OTProcessor.js index 80d1ce69..1442b43d 100644 --- a/src/opentype/OTProcessor.js +++ b/src/opentype/OTProcessor.js @@ -26,8 +26,10 @@ export default class OTProcessor { this.selectScript(); // current context (set by applyFeatures) + /** @type {import('./GlyphInfo').default[]} */ this.glyphs = []; - this.positions = []; // only used by GPOS + /** @type {(import('../layout/GlyphPosition').default[] | undefined)} */ + this.positions = undefined; // only used by GPOS this.ligatureID = 1; this.currentFeature = null; } @@ -179,6 +181,11 @@ export default class OTProcessor { }); } + /** + * @param {string[]} userFeatures + * @param {import('./GlyphInfo').default[]} glyphs + * @param {import('../layout/GlyphPosition').default[]} [advances] + */ applyFeatures(userFeatures, glyphs, advances) { let lookups = this.lookupsForFeatures(userFeatures); this.applyLookups(lookups, glyphs, advances); @@ -212,7 +219,7 @@ export default class OTProcessor { } applyLookup(lookup, table) { - throw new Error("applyLookup must be implemented by subclasses"); + throw new Error('applyLookup must be implemented by subclasses'); } applyLookupList(lookupRecords) { diff --git a/src/opentype/ShapingPlan.js b/src/opentype/ShapingPlan.js index 121a7934..9717022d 100644 --- a/src/opentype/ShapingPlan.js +++ b/src/opentype/ShapingPlan.js @@ -1,4 +1,4 @@ -import * as Script from '../layout/Script'; +// @ts-check /** * ShapingPlans are used by the OpenType shapers to store which @@ -10,11 +10,21 @@ import * as Script from '../layout/Script'; * @private */ export default class ShapingPlan { + /** + * @param {import('../TTFFont').default} font + * @param {string} script + * @param {'ltr' | 'rtl'} direction + */ constructor(font, script, direction) { this.font = font; this.script = script; this.direction = direction; + + /** + * @type {import('../types').ShapingPlanStage[]} + */ this.stages = []; + this.globalFeatures = {}; this.allFeatures = {}; } @@ -22,24 +32,34 @@ export default class ShapingPlan { /** * Adds the given features to the last stage. * Ignores features that have already been applied. + * + * @param {string[]} features + * @param {boolean} global */ _addFeatures(features, global) { let stageIndex = this.stages.length - 1; let stage = this.stages[stageIndex]; - for (let feature of features) { - if (this.allFeatures[feature] == null) { - stage.push(feature); - this.allFeatures[feature] = stageIndex; - - if (global) { - this.globalFeatures[feature] = true; + if (Array.isArray(stage)) { + for (let feature of features) { + if (this.allFeatures[feature] == null) { + stage.push(feature); + this.allFeatures[feature] = stageIndex; + + if (global) { + this.globalFeatures[feature] = true; + } } } + } else { + throw new Error('Invalid data type of stage in ShapingPlan#stages'); } } /** * Add features to the last stage + * + * @param {string | string[] | {global?: string[], local?: string[]}} arg + * @param {boolean} [global] */ add(arg, global = true) { if (this.stages.length === 0) { @@ -56,12 +76,15 @@ export default class ShapingPlan { this._addFeatures(arg.global || [], true); this._addFeatures(arg.local || [], false); } else { - throw new Error("Unsupported argument to ShapingPlan#add"); + throw new Error('Unsupported argument to ShapingPlan#add'); } } /** * Add a new stage + * + * @param {import('../types').ShapingPlanStageFunction | string | string[] | {global?: string[], local?: string[]}} arg + * @param {boolean} [global] */ addStage(arg, global) { if (typeof arg === 'function') { @@ -72,6 +95,9 @@ export default class ShapingPlan { } } + /** + * @param {string[] | Record} features + */ setFeatureOverrides(features) { if (Array.isArray(features)) { this.add(features); @@ -81,9 +107,13 @@ export default class ShapingPlan { this.add(tag); } else if (this.allFeatures[tag] != null) { let stage = this.stages[this.allFeatures[tag]]; - stage.splice(stage.indexOf(tag), 1); - delete this.allFeatures[tag]; - delete this.globalFeatures[tag]; + if (Array.isArray(stage)) { + stage.splice(stage.indexOf(tag), 1); + delete this.allFeatures[tag]; + delete this.globalFeatures[tag]; + } else { + throw new Error('Invalid data type of stage in ShapingPlan#stages'); + } } } } @@ -91,6 +121,8 @@ export default class ShapingPlan { /** * Assigns the global features to the given glyphs + * + * @param {import('./GlyphInfo').default[]} glyphs */ assignGlobalFeatures(glyphs) { for (let glyph of glyphs) { @@ -102,6 +134,10 @@ export default class ShapingPlan { /** * Executes the planned stages using the given OTProcessor + * + * @param {import('./OTProcessor').default} processor + * @param {import('./GlyphInfo').default[]} glyphs + * @param {import('../layout/GlyphPosition').default[]} [positions] */ process(processor, glyphs, positions) { for (let stage of this.stages) { diff --git a/src/opentype/shapers/ArabicShaper.js b/src/opentype/shapers/ArabicShaper.js index fb6d20ef..d44ed33e 100644 --- a/src/opentype/shapers/ArabicShaper.js +++ b/src/opentype/shapers/ArabicShaper.js @@ -1,10 +1,14 @@ import DefaultShaper from './DefaultShaper'; import {getCategory} from 'unicode-properties'; import UnicodeTrie from 'unicode-trie'; -import { decodeBase64 } from '../../utils'; +import { decodeBase64 } from '../../utils/decode'; const trie = new UnicodeTrie(decodeBase64(require('fs').readFileSync(__dirname + '/data.trie', 'base64'))); const FEATURES = ['isol', 'fina', 'fin2', 'fin3', 'medi', 'med2', 'init']; +const DIRECTIONAL_FEATURES = { + ltr: ['ltra', 'ltrm'], + rtl: ['rtla', 'rtlm'] +}; const ShapingClasses = { Non_Joining: 0, @@ -60,7 +64,18 @@ const STATE_TABLE = [ * https://github.com/behdad/harfbuzz/blob/master/src/hb-ot-shape-complex-arabic.cc */ export default class ArabicShaper extends DefaultShaper { - static planFeatures(plan) { + /** + * @param {import('../ShapingPlan').default} plan + */ + planPreprocessing(plan) { + super.planPreprocessing(plan); + plan.add(DIRECTIONAL_FEATURES[plan.direction]); + } + + /** + * @param {import('../ShapingPlan').default} plan + */ + planFeatures(plan) { plan.add(['ccmp', 'locl']); for (let i = 0; i < FEATURES.length; i++) { let feature = FEATURES[i]; @@ -70,7 +85,11 @@ export default class ArabicShaper extends DefaultShaper { plan.addStage('mset'); } - static assignFeatures(plan, glyphs) { + /** + * @param {import('../ShapingPlan').default} plan + * @param {import('../GlyphInfo').default[]} glyphs + */ + assignFeatures(plan, glyphs) { super.assignFeatures(plan, glyphs); let prev = -1; diff --git a/src/opentype/shapers/DefaultShaper.js b/src/opentype/shapers/DefaultShaper.js index e02e4d36..796a52cc 100644 --- a/src/opentype/shapers/DefaultShaper.js +++ b/src/opentype/shapers/DefaultShaper.js @@ -1,18 +1,21 @@ -import {isDigit} from 'unicode-properties'; +// @ts-check const VARIATION_FEATURES = ['rvrn']; const COMMON_FEATURES = ['ccmp', 'locl', 'rlig', 'mark', 'mkmk']; -const FRACTIONAL_FEATURES = ['frac', 'numr', 'dnom']; const HORIZONTAL_FEATURES = ['calt', 'clig', 'liga', 'rclt', 'curs', 'kern']; -const VERTICAL_FEATURES = ['vert']; -const DIRECTIONAL_FEATURES = { - ltr: ['ltra', 'ltrm'], - rtl: ['rtla', 'rtlm'] -}; export default class DefaultShaper { - static zeroMarkWidths = 'AFTER_GPOS'; - static plan(plan, glyphs, features) { + /** + * @type {'NONE' | 'BEFORE_GPOS' | 'AFTER_GPOS'} + */ + zeroMarkWidths = 'AFTER_GPOS'; + + /** + * @param {import('../ShapingPlan').default} plan + * @param {import('../GlyphInfo').default[]} glyphs + * @param {string[] | Record} features + */ + plan(plan, glyphs, features) { // Plan the features we want to apply this.planPreprocessing(plan); this.planFeatures(plan); @@ -25,48 +28,34 @@ export default class DefaultShaper { this.assignFeatures(plan, glyphs); } - static planPreprocessing(plan) { - plan.add({ - global: [...VARIATION_FEATURES, ...DIRECTIONAL_FEATURES[plan.direction]], - local: FRACTIONAL_FEATURES - }); + /** + * @param {import('../ShapingPlan').default} plan + */ + planPreprocessing(plan) { + plan.add(VARIATION_FEATURES); } - static planFeatures(plan) { + /** + * @param {import('../ShapingPlan').default} plan + */ + planFeatures(plan) { // Do nothing by default. Let subclasses override this. } - static planPostprocessing(plan, userFeatures) { + /** + * @param {import('../ShapingPlan').default} plan + * @param {string[] | Record} userFeatures + */ + planPostprocessing(plan, userFeatures) { plan.add([...COMMON_FEATURES, ...HORIZONTAL_FEATURES]); plan.setFeatureOverrides(userFeatures); } - static assignFeatures(plan, glyphs) { - // Enable contextual fractions - for (let i = 0; i < glyphs.length; i++) { - let glyph = glyphs[i]; - if (glyph.codePoints[0] === 0x2044) { // fraction slash - let start = i; - let end = i + 1; - - // Apply numerator - while (start > 0 && isDigit(glyphs[start - 1].codePoints[0])) { - glyphs[start - 1].features.numr = true; - glyphs[start - 1].features.frac = true; - start--; - } - - // Apply denominator - while (end < glyphs.length && isDigit(glyphs[end].codePoints[0])) { - glyphs[end].features.dnom = true; - glyphs[end].features.frac = true; - end++; - } - - // Apply fraction slash - glyph.features.frac = true; - i = end - 1; - } - } + /** + * @param {import('../ShapingPlan').default} plan + * @param {import('../GlyphInfo').default[]} glyphs + */ + assignFeatures(plan, glyphs) { + // Do nothing by default. Let subclasses override this. } } diff --git a/src/opentype/shapers/HangulShaper.js b/src/opentype/shapers/HangulShaper.js index 789fb76f..497a3918 100644 --- a/src/opentype/shapers/HangulShaper.js +++ b/src/opentype/shapers/HangulShaper.js @@ -24,12 +24,16 @@ import GlyphInfo from '../GlyphInfo'; * - http://ktug.org/~nomos/harfbuzz-hangul/hangulshaper.pdf */ export default class HangulShaper extends DefaultShaper { - static zeroMarkWidths = 'NONE'; - static planFeatures(plan) { + /** + * @type {'NONE' | 'BEFORE_GPOS' | 'AFTER_GPOS'} + */ + zeroMarkWidths = 'NONE'; + + planFeatures(plan) { plan.add(['ljmo', 'vjmo', 'tjmo'], false); } - static assignFeatures(plan, glyphs) { + assignFeatures(plan, glyphs) { let state = 0; let i = 0; while (i < glyphs.length) { diff --git a/src/opentype/shapers/IndicShaper.js b/src/opentype/shapers/IndicShaper.js index 104198c5..bd5a3bea 100644 --- a/src/opentype/shapers/IndicShaper.js +++ b/src/opentype/shapers/IndicShaper.js @@ -14,7 +14,7 @@ import { HALANT_OR_COENG_FLAGS, INDIC_CONFIGS, INDIC_DECOMPOSITIONS } from './indic-data'; -import { decodeBase64 } from '../../utils'; +import { decodeBase64 } from '../../utils/decode'; const {decompositions} = useData; const trie = new UnicodeTrie(decodeBase64(require('fs').readFileSync(__dirname + '/indic.trie', 'base64'))); @@ -25,8 +25,12 @@ const stateMachine = new StateMachine(indicMachine); * Based on code from Harfbuzz: https://github.com/behdad/harfbuzz/blob/master/src/hb-ot-shape-complex-indic.cc */ export default class IndicShaper extends DefaultShaper { - static zeroMarkWidths = 'NONE'; - static planFeatures(plan) { + /** + * @type {'NONE' | 'BEFORE_GPOS' | 'AFTER_GPOS'} + */ + zeroMarkWidths = 'NONE'; + + planFeatures(plan) { plan.addStage(setupSyllables); plan.addStage(['locl', 'ccmp']); @@ -61,7 +65,11 @@ export default class IndicShaper extends DefaultShaper { // TODO: turn off kern (Khmer) and liga features. } - static assignFeatures(plan, glyphs) { + /** + * @param {import('../ShapingPlan').default} plan + * @param {import('../GlyphInfo').default[]} glyphs + */ + assignFeatures(plan, glyphs) { // Decompose split matras // TODO: do this in a more general unicode normalizer for (let i = glyphs.length - 1; i >= 0; i--) { diff --git a/src/opentype/shapers/UniversalShaper.js b/src/opentype/shapers/UniversalShaper.js index 3057dd14..ce28ecc6 100644 --- a/src/opentype/shapers/UniversalShaper.js +++ b/src/opentype/shapers/UniversalShaper.js @@ -3,7 +3,7 @@ import StateMachine from 'dfa'; import UnicodeTrie from 'unicode-trie'; import GlyphInfo from '../GlyphInfo'; import useData from './use.json'; -import { decodeBase64 } from '../../utils'; +import { decodeBase64 } from '../../utils/decode'; const {categories, decompositions} = useData; const trie = new UnicodeTrie(decodeBase64(require('fs').readFileSync(__dirname + '/use.trie', 'base64'))); @@ -15,8 +15,12 @@ const stateMachine = new StateMachine(useData); * See https://www.microsoft.com/typography/OpenTypeDev/USE/intro.htm. */ export default class UniversalShaper extends DefaultShaper { - static zeroMarkWidths = 'BEFORE_GPOS'; - static planFeatures(plan) { + /** + * @type {'NONE' | 'BEFORE_GPOS' | 'AFTER_GPOS'} + */ + zeroMarkWidths = 'BEFORE_GPOS'; + + planFeatures(plan) { plan.addStage(setupSyllables); // Default glyph pre-processing group @@ -42,7 +46,11 @@ export default class UniversalShaper extends DefaultShaper { plan.addStage(['abvs', 'blws', 'pres', 'psts', 'dist', 'abvm', 'blwm']); } - static assignFeatures(plan, glyphs) { + /** + * @param {import('../ShapingPlan').default} plan + * @param {import('../GlyphInfo').default[]} glyphs + */ + assignFeatures(plan, glyphs) { // Decompose split vowels // TODO: do this in a more general unicode normalizer for (let i = glyphs.length - 1; i >= 0; i--) { diff --git a/src/opentype/shapers/index.js b/src/opentype/shapers/index.js index b22ebdd3..a3a7be96 100644 --- a/src/opentype/shapers/index.js +++ b/src/opentype/shapers/index.js @@ -1,91 +1,103 @@ +// @ts-check + import DefaultShaper from './DefaultShaper'; import ArabicShaper from './ArabicShaper'; import HangulShaper from './HangulShaper'; import IndicShaper from './IndicShaper'; import UniversalShaper from './UniversalShaper'; +const defaultShaper = new DefaultShaper(); +const arabicShaper = new ArabicShaper(); +const hangulShaper = new HangulShaper(); +const indicShaper = new IndicShaper(); +const universalShaper = new UniversalShaper(); + const SHAPERS = { - arab: ArabicShaper, // Arabic - mong: ArabicShaper, // Mongolian - syrc: ArabicShaper, // Syriac - 'nko ': ArabicShaper, // N'Ko - phag: ArabicShaper, // Phags Pa - mand: ArabicShaper, // Mandaic - mani: ArabicShaper, // Manichaean - phlp: ArabicShaper, // Psalter Pahlavi + arab: arabicShaper, // Arabic + mong: arabicShaper, // Mongolian + syrc: arabicShaper, // Syriac + 'nko ': arabicShaper, // N'Ko + phag: arabicShaper, // Phags Pa + mand: arabicShaper, // Mandaic + mani: arabicShaper, // Manichaean + phlp: arabicShaper, // Psalter Pahlavi - hang: HangulShaper, // Hangul + hang: hangulShaper, // Hangul - bng2: IndicShaper, // Bengali - beng: IndicShaper, // Bengali - dev2: IndicShaper, // Devanagari - deva: IndicShaper, // Devanagari - gjr2: IndicShaper, // Gujarati - gujr: IndicShaper, // Gujarati - guru: IndicShaper, // Gurmukhi - gur2: IndicShaper, // Gurmukhi - knda: IndicShaper, // Kannada - knd2: IndicShaper, // Kannada - mlm2: IndicShaper, // Malayalam - mlym: IndicShaper, // Malayalam - ory2: IndicShaper, // Oriya - orya: IndicShaper, // Oriya - taml: IndicShaper, // Tamil - tml2: IndicShaper, // Tamil - telu: IndicShaper, // Telugu - tel2: IndicShaper, // Telugu - khmr: IndicShaper, // Khmer + bng2: indicShaper, // Bengali + beng: indicShaper, // Bengali + dev2: indicShaper, // Devanagari + deva: indicShaper, // Devanagari + gjr2: indicShaper, // Gujarati + gujr: indicShaper, // Gujarati + guru: indicShaper, // Gurmukhi + gur2: indicShaper, // Gurmukhi + knda: indicShaper, // Kannada + knd2: indicShaper, // Kannada + mlm2: indicShaper, // Malayalam + mlym: indicShaper, // Malayalam + ory2: indicShaper, // Oriya + orya: indicShaper, // Oriya + taml: indicShaper, // Tamil + tml2: indicShaper, // Tamil + telu: indicShaper, // Telugu + tel2: indicShaper, // Telugu + khmr: indicShaper, // Khmer - bali: UniversalShaper, // Balinese - batk: UniversalShaper, // Batak - brah: UniversalShaper, // Brahmi - bugi: UniversalShaper, // Buginese - buhd: UniversalShaper, // Buhid - cakm: UniversalShaper, // Chakma - cham: UniversalShaper, // Cham - dupl: UniversalShaper, // Duployan - egyp: UniversalShaper, // Egyptian Hieroglyphs - gran: UniversalShaper, // Grantha - hano: UniversalShaper, // Hanunoo - java: UniversalShaper, // Javanese - kthi: UniversalShaper, // Kaithi - kali: UniversalShaper, // Kayah Li - khar: UniversalShaper, // Kharoshthi - khoj: UniversalShaper, // Khojki - sind: UniversalShaper, // Khudawadi - lepc: UniversalShaper, // Lepcha - limb: UniversalShaper, // Limbu - mahj: UniversalShaper, // Mahajani - // mand: UniversalShaper, // Mandaic - // mani: UniversalShaper, // Manichaean - mtei: UniversalShaper, // Meitei Mayek - modi: UniversalShaper, // Modi - // mong: UniversalShaper, // Mongolian - // 'nko ': UniversalShaper, // N’Ko - hmng: UniversalShaper, // Pahawh Hmong - // phag: UniversalShaper, // Phags-pa - // phlp: UniversalShaper, // Psalter Pahlavi - rjng: UniversalShaper, // Rejang - saur: UniversalShaper, // Saurashtra - shrd: UniversalShaper, // Sharada - sidd: UniversalShaper, // Siddham - sinh: IndicShaper, // Sinhala - sund: UniversalShaper, // Sundanese - sylo: UniversalShaper, // Syloti Nagri - tglg: UniversalShaper, // Tagalog - tagb: UniversalShaper, // Tagbanwa - tale: UniversalShaper, // Tai Le - lana: UniversalShaper, // Tai Tham - tavt: UniversalShaper, // Tai Viet - takr: UniversalShaper, // Takri - tibt: UniversalShaper, // Tibetan - tfng: UniversalShaper, // Tifinagh - tirh: UniversalShaper, // Tirhuta + bali: universalShaper, // Balinese + batk: universalShaper, // Batak + brah: universalShaper, // Brahmi + bugi: universalShaper, // Buginese + buhd: universalShaper, // Buhid + cakm: universalShaper, // Chakma + cham: universalShaper, // Cham + dupl: universalShaper, // Duployan + egyp: universalShaper, // Egyptian Hieroglyphs + gran: universalShaper, // Grantha + hano: universalShaper, // Hanunoo + java: universalShaper, // Javanese + kthi: universalShaper, // Kaithi + kali: universalShaper, // Kayah Li + khar: universalShaper, // Kharoshthi + khoj: universalShaper, // Khojki + sind: universalShaper, // Khudawadi + lepc: universalShaper, // Lepcha + limb: universalShaper, // Limbu + mahj: universalShaper, // Mahajani + // mand: universalShaper, // Mandaic + // mani: universalShaper, // Manichaean + mtei: universalShaper, // Meitei Mayek + modi: universalShaper, // Modi + // mong: universalShaper, // Mongolian + // 'nko ': universalShaper, // N’Ko + hmng: universalShaper, // Pahawh Hmong + // phag: universalShaper, // Phags-pa + // phlp: universalShaper, // Psalter Pahlavi + rjng: universalShaper, // Rejang + saur: universalShaper, // Saurashtra + shrd: universalShaper, // Sharada + sidd: universalShaper, // Siddham + sinh: indicShaper, // Sinhala + sund: universalShaper, // Sundanese + sylo: universalShaper, // Syloti Nagri + tglg: universalShaper, // Tagalog + tagb: universalShaper, // Tagbanwa + tale: universalShaper, // Tai Le + lana: universalShaper, // Tai Tham + tavt: universalShaper, // Tai Viet + takr: universalShaper, // Takri + tibt: universalShaper, // Tibetan + tfng: universalShaper, // Tifinagh + tirh: universalShaper, // Tirhuta - latn: DefaultShaper, // Latin - DFLT: DefaultShaper // Default + latn: defaultShaper, // Latin + DFLT: defaultShaper // Default }; +/** + * @param {string | string[]} script + * @returns {import('../../types').Shaper} + */ export function choose(script) { if (!Array.isArray(script)) { script = [script]; @@ -98,5 +110,5 @@ export function choose(script) { } } - return DefaultShaper; + return defaultShaper; } diff --git a/src/subset/CFFSubset.js b/src/subset/CFFSubset.js index d4506e7b..1c865cab 100644 --- a/src/subset/CFFSubset.js +++ b/src/subset/CFFSubset.js @@ -4,6 +4,11 @@ import CFFPrivateDict from '../cff/CFFPrivateDict'; import standardStrings from '../cff/CFFStandardStrings'; export default class CFFSubset extends Subset { + /** + * @type {'CFF'} + */ + type = 'CFF'; + constructor(font) { super(font); diff --git a/src/subset/Subset.js b/src/subset/Subset.js index 06692b5a..a80ab599 100644 --- a/src/subset/Subset.js +++ b/src/subset/Subset.js @@ -1,17 +1,38 @@ -import * as r from 'restructure'; - -const resolved = Promise.resolve(); +// @ts-check export default class Subset { + /** + * @type {('TTF' | 'CFF' | 'UNKNOWN')} + */ + type = 'UNKNOWN'; + + /** + * @param {import('../TTFFont').default} font + */ constructor(font) { + /** + * @type {import('../TTFFont').default} + */ this.font = font; + + /** + * @type {number[]} + */ this.glyphs = []; + + /** + * @type {Record} + */ this.mapping = {}; // always include the missing glyph this.includeGlyph(0); } + /** + * @param {(number | import('../glyph/Glyph').default)} glyph + * @returns {number} + */ includeGlyph(glyph) { if (typeof glyph === 'object') { glyph = glyph.id; @@ -24,4 +45,11 @@ export default class Subset { return this.mapping[glyph]; } + + /** + * @returns {Uint8Array} + */ + encode() { + throw new Error('Not implemented'); + } } diff --git a/src/subset/TTFSubset.js b/src/subset/TTFSubset.js index f13e5ce5..a3034868 100644 --- a/src/subset/TTFSubset.js +++ b/src/subset/TTFSubset.js @@ -1,10 +1,15 @@ -import cloneDeep from 'clone'; +import { cloneDeep } from '../utils/clone'; import Subset from './Subset'; import Directory from '../tables/directory'; import Tables from '../tables'; import TTFGlyphEncoder from '../glyph/TTFGlyphEncoder'; export default class TTFSubset extends Subset { + /** + * @type {'TTF'} + */ + type = 'TTF'; + constructor(font) { super(font); this.glyphEncoder = new TTFGlyphEncoder; diff --git a/src/tables/EBDT.js b/src/tables/EBDT.js index c4aa5967..27196aa3 100644 --- a/src/tables/EBDT.js +++ b/src/tables/EBDT.js @@ -1,6 +1,6 @@ import * as r from 'restructure'; -export let BigMetrics = new r.Struct({ +export const BigMetrics = new r.Struct({ height: r.uint8, width: r.uint8, horiBearingX: r.int8, @@ -11,7 +11,7 @@ export let BigMetrics = new r.Struct({ vertAdvance: r.uint8 }); -export let SmallMetrics = new r.Struct({ +export const SmallMetrics = new r.Struct({ height: r.uint8, width: r.uint8, bearingX: r.int8, @@ -29,7 +29,7 @@ class ByteAligned {} class BitAligned {} -export let glyph = new r.VersionedStruct('version', { +export const glyph = new r.VersionedStruct('version', { 1: { metrics: SmallMetrics, data: ByteAligned diff --git a/src/tables/WOFF2Directory.js b/src/tables/WOFF2Directory.js deleted file mode 100644 index 91b10531..00000000 --- a/src/tables/WOFF2Directory.js +++ /dev/null @@ -1,74 +0,0 @@ -import * as r from 'restructure'; - -const Base128 = { - decode(stream) { - let result = 0; - let iterable = [0, 1, 2, 3, 4]; - for (let j = 0; j < iterable.length; j++) { - let i = iterable[j]; - let code = stream.readUInt8(); - - // If any of the top seven bits are set then we're about to overflow. - if (result & 0xe0000000) { - throw new Error('Overflow'); - } - - result = (result << 7) | (code & 0x7f); - if ((code & 0x80) === 0) { - return result; - } - } - - throw new Error('Bad base 128 number'); - } -}; - -let knownTags = [ - 'cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post', 'cvt ', - 'fpgm', 'glyf', 'loca', 'prep', 'CFF ', 'VORG', 'EBDT', 'EBLC', 'gasp', - 'hdmx', 'kern', 'LTSH', 'PCLT', 'VDMX', 'vhea', 'vmtx', 'BASE', 'GDEF', - 'GPOS', 'GSUB', 'EBSC', 'JSTF', 'MATH', 'CBDT', 'CBLC', 'COLR', 'CPAL', - 'SVG ', 'sbix', 'acnt', 'avar', 'bdat', 'bloc', 'bsln', 'cvar', 'fdsc', - 'feat', 'fmtx', 'fvar', 'gvar', 'hsty', 'just', 'lcar', 'mort', 'morx', - 'opbd', 'prop', 'trak', 'Zapf', 'Silf', 'Glat', 'Gloc', 'Feat', 'Sill' -]; - -let WOFF2DirectoryEntry = new r.Struct({ - flags: r.uint8, - customTag: new r.Optional(new r.String(4), t => (t.flags & 0x3f) === 0x3f), - tag: t => t.customTag || knownTags[t.flags & 0x3f],// || (() => { throw new Error(`Bad tag: ${flags & 0x3f}`); })(); }, - length: Base128, - transformVersion: t => (t.flags >>> 6) & 0x03, - transformed: t => (t.tag === 'glyf' || t.tag === 'loca') ? t.transformVersion === 0 : t.transformVersion !== 0, - transformLength: new r.Optional(Base128, t => t.transformed) -}); - -let WOFF2Directory = new r.Struct({ - tag: new r.String(4), // should be 'wOF2' - flavor: r.uint32, - length: r.uint32, - numTables: r.uint16, - reserved: new r.Reserved(r.uint16), - totalSfntSize: r.uint32, - totalCompressedSize: r.uint32, - majorVersion: r.uint16, - minorVersion: r.uint16, - metaOffset: r.uint32, - metaLength: r.uint32, - metaOrigLength: r.uint32, - privOffset: r.uint32, - privLength: r.uint32, - tables: new r.Array(WOFF2DirectoryEntry, 'numTables') -}); - -WOFF2Directory.process = function() { - let tables = {}; - for (let i = 0; i < this.tables.length; i++) { - let table = this.tables[i]; - tables[table.tag] = table; - } - - return this.tables = tables; -}; - -export default WOFF2Directory; diff --git a/src/tables/WOFFDirectory.js b/src/tables/WOFFDirectory.js deleted file mode 100644 index dfd5d55d..00000000 --- a/src/tables/WOFFDirectory.js +++ /dev/null @@ -1,38 +0,0 @@ -import * as r from 'restructure'; -import tables from './'; - -let WOFFDirectoryEntry = new r.Struct({ - tag: new r.String(4), - offset: new r.Pointer(r.uint32, 'void', {type: 'global'}), - compLength: r.uint32, - length: r.uint32, - origChecksum: r.uint32 -}); - -let WOFFDirectory = new r.Struct({ - tag: new r.String(4), // should be 'wOFF' - flavor: r.uint32, - length: r.uint32, - numTables: r.uint16, - reserved: new r.Reserved(r.uint16), - totalSfntSize: r.uint32, - majorVersion: r.uint16, - minorVersion: r.uint16, - metaOffset: r.uint32, - metaLength: r.uint32, - metaOrigLength: r.uint32, - privOffset: r.uint32, - privLength: r.uint32, - tables: new r.Array(WOFFDirectoryEntry, 'numTables') -}); - -WOFFDirectory.process = function() { - let tables = {}; - for (let table of this.tables) { - tables[table.tag] = table; - } - - this.tables = tables; -}; - -export default WOFFDirectory; diff --git a/src/tables/aat.js b/src/tables/aat.js index 299621ba..c4afe8c3 100644 --- a/src/tables/aat.js +++ b/src/tables/aat.js @@ -35,7 +35,7 @@ export class UnboundedArray extends r.Array { } } -export let LookupTable = function(ValueType = r.uint16) { +export const LookupTable = function(ValueType = r.uint16) { // Helper class that makes internal structures invisible to pointers class Shadow { constructor(type) { diff --git a/src/tables/index.js b/src/tables/index.js index 438bb48c..63ef09bc 100644 --- a/src/tables/index.js +++ b/src/tables/index.js @@ -1,4 +1,9 @@ -let tables = {}; +// @ts-check + +/** + * @type {Record} + */ +const tables = {}; export default tables; // Required Tables diff --git a/src/tables/opentype.js b/src/tables/opentype.js index 867cf5cb..daad2907 100644 --- a/src/tables/opentype.js +++ b/src/tables/opentype.js @@ -27,7 +27,7 @@ let ScriptRecord = new r.Struct({ script: new r.Pointer(r.uint16, Script, { type: 'parent' }) }); -export let ScriptList = new r.Array(ScriptRecord, r.uint16); +export const ScriptList = new r.Array(ScriptRecord, r.uint16); //####################### // Features and Lookups # @@ -38,7 +38,7 @@ let FeatureParams = new r.Struct({ nameID: r.uint16, //OT spec: UI Name ID or uiLabelNameId }); -export let Feature = new r.Struct({ +export const Feature = new r.Struct({ featureParams: new r.Pointer(r.uint16, FeatureParams), lookupCount: r.uint16, lookupListIndexes: new r.Array(r.uint16, 'lookupCount') @@ -49,7 +49,7 @@ let FeatureRecord = new r.Struct({ feature: new r.Pointer(r.uint16, Feature, { type: 'parent' }) }); -export let FeatureList = new r.Array(FeatureRecord, r.uint16); +export const FeatureList = new r.Array(FeatureRecord, r.uint16); let LookupFlags = new r.Struct({ markAttachmentType: r.uint8, @@ -81,7 +81,7 @@ let RangeRecord = new r.Struct({ startCoverageIndex: r.uint16 }); -export let Coverage = new r.VersionedStruct(r.uint16, { +export const Coverage = new r.VersionedStruct(r.uint16, { 1: { glyphCount: r.uint16, glyphs: new r.Array(r.uint16, 'glyphCount') @@ -102,7 +102,7 @@ let ClassRangeRecord = new r.Struct({ class: r.uint16 }); -export let ClassDef = new r.VersionedStruct(r.uint16, { +export const ClassDef = new r.VersionedStruct(r.uint16, { 1: { // Class array startGlyph: r.uint16, glyphCount: r.uint16, @@ -118,7 +118,7 @@ export let ClassDef = new r.VersionedStruct(r.uint16, { // Device Table # //############### -export let Device = new r.Struct({ +export const Device = new r.Struct({ a: r.uint16, // startSize for hinting Device, outerIndex for VariationIndex b: r.uint16, // endSize for Device, innerIndex for VariationIndex deltaFormat: r.uint16 @@ -151,7 +151,7 @@ let ClassRule = new r.Struct({ let ClassSet = new r.Array(new r.Pointer(r.uint16, ClassRule), r.uint16); -export let Context = new r.VersionedStruct(r.uint16, { +export const Context = new r.VersionedStruct(r.uint16, { 1: { // Simple context coverage: new r.Pointer(r.uint16, Coverage), ruleSetCount: r.uint16, @@ -188,7 +188,7 @@ let ChainRule = new r.Struct({ let ChainRuleSet = new r.Array(new r.Pointer(r.uint16, ChainRule), r.uint16); -export let ChainingContext = new r.VersionedStruct(r.uint16, { +export const ChainingContext = new r.VersionedStruct(r.uint16, { 1: { // Simple context glyph substitution coverage: new r.Pointer(r.uint16, Coverage), chainCount: r.uint16, diff --git a/src/tables/types.ts b/src/tables/types.ts new file mode 100644 index 00000000..dc00735b --- /dev/null +++ b/src/tables/types.ts @@ -0,0 +1,45 @@ +export type TableTag = + | 'cmap' + | 'head' + | 'hhea' + | 'hmtx' + | 'maxp' + | 'name' + | 'OS/2' + | 'post' + | 'fpgm' + | 'loca' + | 'prep' + | 'cvt ' + | 'glyf' + | 'CFF ' + | 'CFF2' + | 'VORG' + | 'EBLC' + | 'CBLC' + | 'sbix' + | 'COLR' + | 'CPAL' + | 'BASE' + | 'GDEF' + | 'GPOS' + | 'GSUB' + | 'JSTF' + | 'HVAR' + | 'DSIG' + | 'gasp' + | 'hdmx' + | 'kern' + | 'LTSH' + | 'PCLT' + | 'VDMX' + | 'vhea' + | 'vmtx' + | 'avar' + | 'bsln' + | 'feat' + | 'fvar' + | 'gvar' + | 'just' + | 'morx' + | 'opbd'; diff --git a/src/tables/variations.js b/src/tables/variations.js index 0aae61c3..f17d4d53 100644 --- a/src/tables/variations.js +++ b/src/tables/variations.js @@ -32,7 +32,7 @@ let ItemVariationData = new r.Struct({ deltaSets: new r.Array(DeltaSet, 'itemCount') }); -export let ItemVariationStore = new r.Struct({ +export const ItemVariationStore = new r.Struct({ format: r.uint16, variationRegionList: new r.Pointer(r.uint32, VariationRegionList), variationDataCount: r.uint16, @@ -73,7 +73,7 @@ let FeatureVariationRecord = new r.Struct({ featureTableSubstitution: new r.Pointer(r.uint32, FeatureTableSubstitution, {type: 'parent'}) }); -export let FeatureVariations = new r.Struct({ +export const FeatureVariations = new r.Struct({ majorVersion: r.uint16, minorVersion: r.uint16, featureVariationRecordCount: r.uint32, diff --git a/src/tables/vhea.js b/src/tables/vhea.js index 004cdf93..47312b2a 100644 --- a/src/tables/vhea.js +++ b/src/tables/vhea.js @@ -2,7 +2,7 @@ import * as r from 'restructure'; // Vertical Header Table export default new r.Struct({ - version: r.uint16, // Version number of the Vertical Header Table + version: r.uint32, // Version number of the Vertical Header Table ascent: r.int16, // The vertical typographic ascender for this font descent: r.int16, // The vertical typographic descender for this font lineGap: r.int16, // The vertical typographic line gap for this font diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 00000000..16d05f69 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,59 @@ +import type TTFFont from './TTFFont.js'; +import type GlyphInfo from './opentype/GlyphInfo.js'; +import type ShapingPlan from './opentype/ShapingPlan.js'; + +export type { GlyphInfo, ShapingPlan }; + +/** + * Advanced parameters for `TTFFont#layout` and `LayoutEngine#layout`. + */ +export type LayoutAdvancedParams = { + /** + * If not provided, `fontkit` attempts to detect the script from the string. + */ + script?: string; + + /** + * If not provided, `fontkit` uses the default language of the script. + */ + language?: string; + + /** + * If not provided, `fontkit` uses the default direction of the script. + */ + direction?: 'ltr' | 'rtl'; + + /** + * If not provided, `fontkit` chooses its own prepared `Shaper` based on the script. + */ + shaper?: Shaper; + + /** + * Set to `true` to skip position adjustment for each individual glyph. + * This results in `GlyphRun#positions` being `null`. + */ + skipPerGlyphPositioning?: boolean; +}; + +export interface Shaper { + zeroMarkWidths?: 'NONE' | 'BEFORE_GPOS' | 'AFTER_GPOS'; + + plan( + plan: ShapingPlan, + glyphs: GlyphInfo[], + userFeatures: string[] | Record + ): void; + + assignFeatures(plan: ShapingPlan, glyphs: GlyphInfo[]): void; +} + +/** + * Element of `ShapingPlan#stages` that is either an array of feature tags or a single `ShapingPlanStageFunction`. + */ +export type ShapingPlanStage = string[] | ShapingPlanStageFunction; + +export type ShapingPlanStageFunction = ( + font: TTFFont, + glyphs: GlyphInfo[], + plan: ShapingPlan +) => void; diff --git a/src/utils/arrays.js b/src/utils/arrays.js new file mode 100644 index 00000000..f5bf9874 --- /dev/null +++ b/src/utils/arrays.js @@ -0,0 +1,26 @@ +export function binarySearch(arr, cmp) { + let min = 0; + let max = arr.length - 1; + while (min <= max) { + let mid = (min + max) >> 1; + let res = cmp(arr[mid]); + + if (res < 0) { + max = mid - 1; + } else if (res > 0) { + min = mid + 1; + } else { + return mid; + } + } + + return -1; +} + +export function range(index, end) { + let range = []; + while (index < end) { + range.push(index++); + } + return range; +} diff --git a/src/utils/clone.js b/src/utils/clone.js new file mode 100644 index 00000000..a7da6ccc --- /dev/null +++ b/src/utils/clone.js @@ -0,0 +1,114 @@ +// @ts-check + +import { isPrimitive } from './primitive.js'; + +/** + * Deeply clones the value. + * + * - Supports primitives, arrays, and plain objects. + * - Handles circular references by tracking already-cloned objects. + * - Preserves object prototypes (Object.prototype or null). + * - Clones only enumerable properties (both string keys and symbols). + * - Does not support functions, class instances, or built-in objects + * (Date, RegExp, Map, Set, Buffer, etc.). + * + * @template T + * @param {T} value - The value to clone. + * @returns {T} A deep clone of the value. + * @throws {TypeError} If `value` contains unsupported types. + */ +export function cloneDeep(value) { + return cloneValue(value, new Map()); +} + +/** + * Recursively clones a value, tracking seen objects to handle circular references. + * + * @param {unknown} value + * @param {Map} seen + * @returns {any} + */ +function cloneValue(value, seen) { + if (isPrimitive(value)) { + return value; + } + + if (Array.isArray(value)) { + return cloneArray(value, seen); + } + + if (isCloneableObject(value)) { + return cloneObject(value, seen); + } + + throw new TypeError('cloneDeep only supports primitives, arrays, and plain objects'); +} + +/** + * Checks if a value is a plain object (prototype is Object.prototype or null). + * + * @param {unknown} value + * @returns {value is Record} + */ +function isCloneableObject(value) { + if (value === null || typeof value !== 'object') { + return false; + } + + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Clones an array and recursively clones all its elements. + * + * @param {any[]} value + * @param {Map} seen + * @returns {any[]} + */ +function cloneArray(value, seen) { + if (seen.has(value)) { + return seen.get(value); + } + + const result = new Array(value.length); + seen.set(value, result); + + for (let i = 0; i < value.length; i++) { + result[i] = cloneValue(value[i], seen); + } + + return result; +} + +/** + * Clones a plain object, preserving its prototype and + * recursively cloning all properties and enumerable symbols. + * + * @param {Record} value + * @param {Map} seen + * @returns {Record} + */ +function cloneObject(value, seen) { + if (seen.has(value)) { + return seen.get(value); + } + + const result = Object.create(Object.getPrototypeOf(value)); + seen.set(value, result); + + for (const key of Object.keys(value)) { + result[key] = cloneValue(value[key], seen); + } + + const symbols = Object.getOwnPropertySymbols(value); + for (const symbol of symbols) { + const descriptor = Object.getOwnPropertyDescriptor(value, symbol); + if (descriptor && !descriptor.enumerable) { + continue; + } + result[symbol] = cloneValue(value[symbol], seen); + } + + return result; +} diff --git a/src/utils.js b/src/utils/decode.js similarity index 70% rename from src/utils.js rename to src/utils/decode.js index 72db5760..4ca06118 100644 --- a/src/utils.js +++ b/src/utils/decode.js @@ -1,30 +1,3 @@ -export function binarySearch(arr, cmp) { - let min = 0; - let max = arr.length - 1; - while (min <= max) { - let mid = (min + max) >> 1; - let res = cmp(arr[mid]); - - if (res < 0) { - max = mid - 1; - } else if (res > 0) { - min = mid + 1; - } else { - return mid; - } - } - - return -1; -} - -export function range(index, end) { - let range = []; - while (index < end) { - range.push(index++); - } - return range; -} - export const asciiDecoder = new TextDecoder('ascii'); // Based on https://github.com/niklasvh/base64-arraybuffer. MIT license. diff --git a/src/utils/deep-equal.js b/src/utils/deep-equal.js new file mode 100644 index 00000000..483df2ce --- /dev/null +++ b/src/utils/deep-equal.js @@ -0,0 +1,50 @@ +// @ts-check + +import { isPrimitive } from './primitive.js'; + +/** + * @typedef {import('./primitive.js').Primitive} Primitive + */ + +/** + * Compares primitives (via `Object.is`) and arrays (recursively) for equality. + * Only accepts primitives or arrays composed of supported values. + * + * @param {Primitive | (Primitive | Primitive[])[]} left + * @param {Primitive | (Primitive | Primitive[])[]} right + * @returns {boolean} + */ +export function equalArray(left, right) { + if (isPrimitive(left)) { + if (isPrimitive(right)) { + return Object.is(left, right); + } + return false; + } else { + if (isPrimitive(right)) { + return false; + } + } + + if (Array.isArray(left)) { + if (Array.isArray(right)) { + if (left.length !== right.length) { + return false; + } + for (let i = 0; i < left.length; i++) { + if (!equalArray(left[i], right[i])) { + return false; + } + } + return true; + } else { + return false; + } + } else { + if (Array.isArray(right)) { + return false; + } + } + + throw new TypeError('equalArray only supports primitives and arrays'); +} diff --git a/src/utils/primitive.js b/src/utils/primitive.js new file mode 100644 index 00000000..79169cc4 --- /dev/null +++ b/src/utils/primitive.js @@ -0,0 +1,15 @@ +// @ts-check + +/** + * @typedef {null | undefined | string | number | boolean | bigint | symbol} Primitive + */ + +/** + * Returns true when the value is a primitive (including null/undefined). + * + * @param {unknown} value + * @returns {value is Primitive} + */ +export function isPrimitive(value) { + return value == null || (typeof value !== 'object' && typeof value !== 'function'); +} diff --git a/test/data/NotoSans/NotoSans.dfont b/test/data/NotoSans/NotoSans.dfont deleted file mode 100644 index 2ecbfd23..00000000 Binary files a/test/data/NotoSans/NotoSans.dfont and /dev/null differ diff --git a/test/data/SourceSansPro/SourceSansPro-Regular.otf.woff b/test/data/SourceSansPro/SourceSansPro-Regular.otf.woff deleted file mode 100644 index 94659aeb..00000000 Binary files a/test/data/SourceSansPro/SourceSansPro-Regular.otf.woff and /dev/null differ diff --git a/test/data/SourceSansPro/SourceSansPro-Regular.otf.woff2 b/test/data/SourceSansPro/SourceSansPro-Regular.otf.woff2 deleted file mode 100644 index e002972a..00000000 Binary files a/test/data/SourceSansPro/SourceSansPro-Regular.otf.woff2 and /dev/null differ diff --git a/test/data/SourceSansPro/SourceSansPro-Regular.ttf.woff b/test/data/SourceSansPro/SourceSansPro-Regular.ttf.woff deleted file mode 100644 index 5945501e..00000000 Binary files a/test/data/SourceSansPro/SourceSansPro-Regular.ttf.woff and /dev/null differ diff --git a/test/data/SourceSansPro/SourceSansPro-Regular.ttf.woff2 b/test/data/SourceSansPro/SourceSansPro-Regular.ttf.woff2 deleted file mode 100644 index df1d2115..00000000 Binary files a/test/data/SourceSansPro/SourceSansPro-Regular.ttf.woff2 and /dev/null differ diff --git a/test/data/SourceSansPro/SourceSansPro-Regular.woff b/test/data/SourceSansPro/SourceSansPro-Regular.woff deleted file mode 100755 index 3e03cc0f..00000000 Binary files a/test/data/SourceSansPro/SourceSansPro-Regular.woff and /dev/null differ diff --git a/test/data/SourceSansPro/SourceSansPro-Regular.woff2 b/test/data/SourceSansPro/SourceSansPro-Regular.woff2 deleted file mode 100755 index a6be1c24..00000000 Binary files a/test/data/SourceSansPro/SourceSansPro-Regular.woff2 and /dev/null differ diff --git a/test/directory.js b/test/directory.js index 34709dcf..cbe8e9d0 100644 --- a/test/directory.js +++ b/test/directory.js @@ -1,4 +1,4 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; import assert from 'assert'; describe('metadata', function () { diff --git a/test/glyph_mapping.js b/test/glyph_mapping.js index 02ed4c42..df24bfb5 100644 --- a/test/glyph_mapping.js +++ b/test/glyph_mapping.js @@ -1,4 +1,4 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; import assert from 'assert'; describe('character to glyph mapping', function () { @@ -29,12 +29,6 @@ describe('character to glyph mapping', function () { return assert.deepEqual(glyphs.map(g => g.codePoints), [[104], [101], [108], [108], [111]]); }); - it('should support unicode variation selectors', function () { - let font = fontkit.openSync(new URL('data/fonttest/TestCMAP14.otf', import.meta.url)); - let glyphs = font.glyphsForString('\u{82a6}\u{82a6}\u{E0100}\u{82a6}\u{E0101}'); - assert.deepEqual(glyphs.map(g => g.id), [1, 1, 2]); - }); - it('should support legacy encodings when no unicode cmap is found', function () { let font = fontkit.openSync(new URL('data/fonttest/TestCMAPMacTurkish.ttf', import.meta.url)); let glyphs = font.glyphsForString("“ABÇĞIİÖŞÜ”"); @@ -42,6 +36,57 @@ describe('character to glyph mapping', function () { }); }); + describe('cmap format 14 handling', function () { + let font = fontkit.openSync(new URL('data/fonttest/TestCMAP14.otf', import.meta.url)); + + it('should detect format 14 support', function () { + assert(font._cmapProcessor); + assert(font._cmapProcessor.uvs); + }); + + it('should get nonDefaultUVSSet', function () { + assert.deepEqual(font.nonDefaultUVSSet, [ + { baseCharacter: 0x2269, variationSelector: 0xFE00, glyphID: 3 }, // ≩ + VS1 + { baseCharacter: 0x82A6, variationSelector: 0xE0101, glyphID: 2 }, // 芦 + VS18 + ]); + }); + + it('should handle default UVS', function () { + const baseGlyphs = font.glyphsForString('\u{82a6}'); // 芦 + const ivsGlyphs = font.glyphsForString('\u{82a6}\u{E0100}'); // 芦 + VS17 + + // Should be the same as base glyph IDs + assert.deepEqual(ivsGlyphs.map((g) => g.id), baseGlyphs.map((g) => g.id)); + }); + + it('should handle non-default UVS', function () { + const svsGlyphs = font.glyphsForString('\u{2269}\u{FE00}'); // ≩ + VS1 + assert.deepEqual(svsGlyphs.map((g) => g.id), [3]); + + const ivsGlyphs = font.glyphsForString('\u{82a6}\u{E0101}'); // 芦 + VS18 + assert.deepEqual(ivsGlyphs.map((g) => g.id), [2]); + }); + + it('should ignore non-registered UVS', function () { + const baseGlyphs = font.glyphsForString('\u{82a6}'); // 芦 + const ivsGlyphs = font.glyphsForString('\u{82a6}\u{E01EF}'); // 芦 + VS256 + + // Should be the same as base glyph IDs + assert.deepEqual(ivsGlyphs.map((g) => g.id), baseGlyphs.map((g) => g.id)); + }); + + it('should handle mixed UVSes in the same string correctly', function () { + const glyphs = font.glyphsForString('\u{82a6}\u{82a6}\u{E0100}\u{82a6}\u{E0101}\u{82a6}\u{E01EF}'); + assert.deepEqual(glyphs.map(g => g.id), [1, 1, 2, 1]); + }); + + it('should preserve codePoints in glyphs with variation selectors', function () { + const glyphs = font.glyphsForString('\u{82a6}\u{E0101}'); // 芦 + VS18 + assert.equal(glyphs.length, 1); + assert.deepEqual(glyphs[0].codePoints, [0x82A6, 0xE0101]); + }); + }); + describe('opentype features', function () { let font = fontkit.openSync(new URL('data/SourceSansPro/SourceSansPro-Regular.otf', import.meta.url)); @@ -60,16 +105,6 @@ describe('character to glyph mapping', function () { assert.deepEqual(glyphs.map(g => g.id), [514, 36]); return assert.deepEqual(glyphs.map(g => g.codePoints), [[102, 102], [105]]); }); - - it('should enable fractions when using fraction slash', function () { - let { glyphs } = font.layout('123 1⁄16 123'); - return assert.deepEqual(glyphs.map(g => g.id), [1088, 1089, 1090, 1, 1617, 1724, 1603, 1608, 1, 1088, 1089, 1090]); - }); - - it('should not break if can’t enable fractions when using fraction slash', function () { - let { glyphs } = font.layout('a⁄b ⁄ 1⁄ ⁄2'); - return assert.deepEqual(glyphs.map(g => g.id), [28, 1724, 29, 1, 1724, 1, 1617, 1724, 1, 1724, 1604]); - }); }); describe('AAT features', function () { @@ -99,7 +134,7 @@ describe('character to glyph mapping', function () { }); it('should handle rtl direction', function () { - let { glyphs } = font.layout('ffi', [], null, null, "rtl"); + let { glyphs } = font.layout('ffi', [], {direction: 'rtl'}); assert.equal(glyphs.length, 3); assert.deepEqual(glyphs.map(g => g.id), [76, 73, 73]); return assert.deepEqual(glyphs.map(g => g.codePoints), [[105], [102], [102]]); diff --git a/test/glyph_mapping_custom.js b/test/glyph_mapping_custom.js new file mode 100644 index 00000000..abf21a61 --- /dev/null +++ b/test/glyph_mapping_custom.js @@ -0,0 +1,59 @@ +import * as fontkit from '@denkiyagi/fontkit'; +import { DefaultShaper } from '@denkiyagi/fontkit'; +import assert from 'assert'; +import { isDigit } from 'unicode-properties'; + +class FractionShaper extends DefaultShaper { + planPreprocessing(plan) { + super.planPreprocessing(plan); + plan.add({ + local: ['frac', 'numr', 'dnom'] + }); + } + + assignFeatures(plan, glyphs) { + // Enable contextual fractions + for (let i = 0; i < glyphs.length; i++) { + let glyph = glyphs[i]; + if (glyph.codePoints[0] === 0x2044) { // fraction slash + let start = i; + let end = i + 1; + + // Apply numerator + while (start > 0 && isDigit(glyphs[start - 1].codePoints[0])) { + glyphs[start - 1].features.numr = true; + glyphs[start - 1].features.frac = true; + start--; + } + + // Apply denominator + while (end < glyphs.length && isDigit(glyphs[end].codePoints[0])) { + glyphs[end].features.dnom = true; + glyphs[end].features.frac = true; + end++; + } + + // Apply fraction slash + glyph.features.frac = true; + i = end - 1; + } + } + } +} + +describe('character to glyph mapping with custom shaper', function () { + describe('opentype features', function () { + const font = fontkit.openSync(new URL('data/SourceSansPro/SourceSansPro-Regular.otf', import.meta.url)); + const fractionShaper = new FractionShaper(); + + it('should enable fractions when using fraction slash', function () { + const { glyphs } = font.layout('123 1⁄16 123', undefined, { shaper: fractionShaper }); + return assert.deepEqual(glyphs.map(g => g.id), [1088, 1089, 1090, 1, 1617, 1724, 1603, 1608, 1, 1088, 1089, 1090]); + }); + + it('should not break if can’t enable fractions when using fraction slash', function () { + const { glyphs } = font.layout('a⁄b ⁄ 1⁄ ⁄2', undefined, { shaper: fractionShaper }); + return assert.deepEqual(glyphs.map(g => g.id), [28, 1724, 29, 1, 1724, 1, 1617, 1724, 1, 1724, 1604]); + }); + }); +}); diff --git a/test/glyph_positioning.js b/test/glyph_positioning.js index 83742b94..8aaa60ca 100644 --- a/test/glyph_positioning.js +++ b/test/glyph_positioning.js @@ -1,4 +1,4 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; import assert from 'assert'; describe('glyph positioning', function () { @@ -13,13 +13,20 @@ describe('glyph positioning', function () { it('should apply opentype GPOS features', function () { let { positions } = font.layout('Twitter'); + if (positions == null) assert.fail('Failed to get glyph positions'); return assert.deepEqual(positions.map(p => p.xAdvance), [502, 718, 246, 318, 324, 496, 347]); }); it('should ignore duplicate features', function () { let { positions } = font.layout('Twitter', ['kern', 'kern']); + if (positions == null) assert.fail('Failed to get glyph positions'); return assert.deepEqual(positions.map(p => p.xAdvance), [502, 718, 246, 318, 324, 496, 347]); }); + + it('should skip per-glyph positioning according to the given flag', function () { + let { positions } = font.layout('Twitter', undefined, { skipPerGlyphPositioning: true }); + return assert.strictEqual(positions, null); + }); }); describe('AAT features', function () { @@ -27,7 +34,13 @@ describe('glyph positioning', function () { it('should apply kerning by default', function () { let { positions } = font.layout('Twitter'); + if (positions == null) assert.fail('Failed to get glyph positions'); return assert.deepEqual(positions.map(p => p.xAdvance), [535, 792, 246, 372, 402, 535, 351]); }); + + it('should skip per-glyph positioning according to the given flag', function () { + let { positions } = font.layout('Twitter', undefined, { skipPerGlyphPositioning: true }); + return assert.strictEqual(positions, null); + }); }); }); diff --git a/test/glyphs.js b/test/glyphs.js index 498004c1..3e5658f9 100644 --- a/test/glyphs.js +++ b/test/glyphs.js @@ -1,10 +1,11 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; import assert from 'assert'; describe('glyphs', function () { describe('truetype glyphs', function () { let font = fontkit.openSync(new URL('data/OpenSans/OpenSans-Regular.ttf', import.meta.url)); let mada = fontkit.openSync(new URL('data/Mada/Mada-VF.ttf', import.meta.url)); + let fontCJK = fontkit.openSync(new URL('data/NotoSansCJK/NotoSansCJKkr-Regular.otf', import.meta.url)); it('should get a TTFGlyph', function () { let glyph = font.getGlyph(39); // D @@ -70,11 +71,12 @@ describe('glyphs', function () { }); it('should get correct bbox for runs containing blanks', function () { - let r = font.layout('abc ef'); - assert.equal(r.bbox.minX, 94); - assert.equal(r.bbox.minY, -20); - assert.equal(r.bbox.maxX, 5832); - assert.equal(r.bbox.maxY, 1567); + let { bbox } = font.layout('abc ef'); + if (bbox == null) assert.fail('Failed to get bbox'); + assert.equal(bbox.minX, 94); + assert.equal(bbox.minY, -20); + assert.equal(bbox.maxX, 5832); + assert.equal(bbox.maxY, 1567); }); it('should get the advance width', function () { @@ -86,6 +88,11 @@ describe('glyphs', function () { let glyph = font.getGlyph(171); return assert.equal(glyph.name, 'eacute'); }); + + it('should get the vertical origin Y if a specific value exists', function () { + assert.strictEqual(fontCJK.getGlyph(34).vertOriginY, null); // glyph 'A' + assert.strictEqual(fontCJK.getGlyph(730).vertOriginY, 867); // glyph '‰', first glyph in VORG.metrics + }); }); describe('CFF glyphs', function () { @@ -254,112 +261,4 @@ describe('glyphs', function () { return assert.equal(glyph.name, 'stuckouttonguewinkingeye'); }); }); - - describe('WOFF ttf glyphs', function () { - let font = fontkit.openSync(new URL('data/SourceSansPro/SourceSansPro-Regular.ttf.woff', import.meta.url)); - let glyph = font.glyphsForString('D')[0]; - - it('should get the glyph name', function () { - return assert.equal(glyph.name, 'D'); - }); - - it('should get a TTFGlyph', function () { - return assert.equal(glyph.type, 'TTF'); - }); - - it('should get a quadratic path for the glyph', function () { - return assert.equal(glyph.path.toSVG(), 'M90 0L90 656L254 656Q406 656 485 571.5Q564 487 564 331Q564 174 485.5 87Q407 0 258 0ZM173 68L248 68Q363 68 420.5 137.5Q478 207 478 331Q478 455 420.5 521.5Q363 588 248 588L173 588Z'); - }); - }); - - describe('WOFF otf glyphs', function () { - let font = fontkit.openSync(new URL('data/SourceSansPro/SourceSansPro-Regular.otf.woff', import.meta.url)); - let glyph = font.glyphsForString('D')[0]; - - it('should get the glyph name', function () { - return assert.equal(glyph.name, 'D'); - }); - - it('should get a CFFGlyph', function () { - return assert.equal(glyph.type, 'CFF'); - }); - - it('should get a cubic path for the glyph', function () { - return assert.equal(glyph.path.toSVG(), 'M90 0L258 0C456 0 564 122 564 331C564 539 456 656 254 656L90 656ZM173 68L173 588L248 588C401 588 478 496 478 331C478 165 401 68 248 68Z'); - }); - }); - - describe('WOFF2 ttf glyph', function () { - let font = fontkit.openSync(new URL('data/SourceSansPro/SourceSansPro-Regular.ttf.woff2', import.meta.url)); - - let glyph = font.glyphsForString('D')[0]; - - it('should get the glyph name', function () { - return assert.equal(glyph.name, 'D'); - }); - - it('should get a WOFF2Glyph', function () { - return assert.equal(glyph.type, 'WOFF2'); - }); - - it('should get a path for the glyph', function () { - let tglyph = font.glyphsForString('T')[0]; - return assert.equal(tglyph.path.toSVG(), 'M226 0L226 586L28 586L28 656L508 656L508 586L310 586L310 0Z'); - }); - - it('should get a correct quadratic path for all contours', function () { - return assert.equal(glyph.path.toSVG(), 'M90 0L90 656L254 656Q406 656 485 571.5Q564 487 564 331Q564 174 485.5 87Q407 0 258 0ZM173 68L248 68Q363 68 420.5 137.5Q478 207 478 331Q478 455 420.5 521.5Q363 588 248 588L173 588Z'); - }); - - it('should get the ttf glyph cbox', function () { - assert.equal(glyph.cbox.minX, 90); - assert.equal(glyph.cbox.minY, 0); - assert.equal(glyph.cbox.maxX, 564); - assert.equal(glyph.cbox.maxY, 656); - }); - - it('should get the ttf glyph bbox', function () { - assert.equal(glyph.bbox.minX, 90); - assert.equal(glyph.bbox.minY, 0); - assert.equal(glyph.bbox.maxX, 564); - assert.equal(glyph.bbox.maxY, 656); - }); - }); - - describe('WOFF2 otf glyph', function () { - let font = fontkit.openSync(new URL('data/SourceSansPro/SourceSansPro-Regular.otf.woff2', import.meta.url)); - - let glyph = font.glyphsForString('D')[0]; - - it('should get the glyph name', function () { - return assert.equal(glyph.name, 'D'); - }); - - it('should get a CFFGlyph', function () { - return assert.equal(glyph.type, 'CFF'); - }); - - it('should get a path for the glyph', function () { - let tglyph = font.glyphsForString('T')[0]; - return assert.equal(tglyph.path.toSVG(), 'M226 0L310 0L310 586L508 586L508 656L28 656L28 586L226 586Z'); - }); - - it('should get a correct cubic path for all contours', function () { - return assert.equal(glyph.path.toSVG(), 'M90 0L258 0C456 0 564 122 564 331C564 539 456 656 254 656L90 656ZM173 68L173 588L248 588C401 588 478 496 478 331C478 165 401 68 248 68Z'); - }); - - it('should get the otf glyph cbox', function () { - assert.equal(glyph.cbox.minX, 90); - assert.equal(glyph.cbox.minY, 0); - assert.equal(glyph.cbox.maxX, 564); - assert.equal(glyph.cbox.maxY, 656); - }); - - it('should get the otf glyph bbox', function () { - assert.equal(glyph.bbox.minX, 90); - assert.equal(glyph.bbox.minY, 0); - assert.equal(glyph.bbox.maxX, 564); - assert.equal(glyph.bbox.maxY, 656); - }); - }); }); diff --git a/test/i18n.js b/test/i18n.js index 81e1d1a3..97a5aea2 100644 --- a/test/i18n.js +++ b/test/i18n.js @@ -1,5 +1,5 @@ import assert from 'assert'; -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; describe('i18n', function () { describe('fontkit.setDefaultLanguage', function () { @@ -23,7 +23,7 @@ describe('i18n', function () { it('can set global default language to "ar"', function () { fontkit.setDefaultLanguage('ar'); - assert.equal(fontkit.defaultLanguage, 'ar'); + assert.equal(fontkit.getDefaultLanguage(), 'ar'); }); it('font now has "ar" metadata properties', function () { @@ -37,7 +37,7 @@ describe('i18n', function () { it('can reset default language back to "en"', function () { fontkit.setDefaultLanguage(); - assert.equal(fontkit.defaultLanguage, "en"); + assert.equal(fontkit.getDefaultLanguage(), "en"); }); }); diff --git a/test/index.js b/test/index.js index baf40fa9..73e29390 100644 --- a/test/index.js +++ b/test/index.js @@ -1,4 +1,4 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; import assert from 'assert'; describe('fontkit', function () { @@ -24,18 +24,6 @@ describe('fontkit', function () { font = fontkit.openSync(new URL('data/NotoSans/NotoSans.ttc', import.meta.url), 'NotoSans'); assert.equal(font.type, 'TTF'); - - font = fontkit.openSync(new URL('data/NotoSans/NotoSans.dfont', import.meta.url)); - assert.equal(font.type, 'DFont'); - - font = fontkit.openSync(new URL('data/NotoSans/NotoSans.dfont', import.meta.url), 'NotoSans'); - assert.equal(font.type, 'TTF'); - - font = fontkit.openSync(new URL('data/SourceSansPro/SourceSansPro-Regular.woff', import.meta.url)); - assert.equal(font.type, 'WOFF'); - - font = fontkit.openSync(new URL('data/SourceSansPro/SourceSansPro-Regular.woff2', import.meta.url)); - assert.equal(font.type, 'WOFF2'); }); it('should open fonts lacking PostScript name', function () { @@ -65,14 +53,4 @@ describe('fontkit', function () { return assert.equal(font.postscriptName, 'NotoSans-Italic'); }); - it('should get collection objects for dfonts', function () { - let collection = fontkit.openSync(new URL('data/NotoSans/NotoSans.dfont', import.meta.url)); - assert.equal(collection.type, 'DFont'); - - let names = collection.fonts.map(f => f.postscriptName); - assert.deepEqual(names, ['NotoSans', 'NotoSans-Bold', 'NotoSans-Italic', 'NotoSans-BoldItalic']); - - let font = collection.getFont('NotoSans-Italic'); - return assert.equal(font.postscriptName, 'NotoSans-Italic'); - }); }); diff --git a/test/issues.js b/test/issues.js index ed3d5d5f..a9ec59a3 100644 --- a/test/issues.js +++ b/test/issues.js @@ -1,4 +1,4 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; describe('issues', function () { describe('#282 - ReferenceError: Cannot access \'c3x\' before initialization', function () { diff --git a/test/metadata.js b/test/metadata.js index 56597bf0..45e5061a 100644 --- a/test/metadata.js +++ b/test/metadata.js @@ -1,8 +1,9 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; import assert from 'assert'; describe('metadata', function () { let font = fontkit.openSync(new URL('data/NotoSans/NotoSans.ttc', import.meta.url), 'NotoSans'); + let fontCJK = fontkit.openSync(new URL('data/NotoSansCJK/NotoSansCJKkr-Regular.otf', import.meta.url)); it('has metadata properties', function () { assert.equal(font.fullName, 'Noto Sans'); @@ -28,6 +29,9 @@ describe('metadata', function () { assert.equal(font.bbox.minY, -600); assert.equal(font.bbox.maxX, 2952); assert.equal(font.bbox.maxY, 2189); + + assert.strictEqual(font.defaultVertOriginY, null); + assert.strictEqual(fontCJK.defaultVertOriginY, 880); }); it('exposes tables directly', function () { @@ -37,4 +41,53 @@ describe('metadata', function () { assert.equal(typeof font[table], 'object'); } }); + + it("exposes vertOriginYMetrics in VORG table", function () { + assert.strictEqual(font.getVertOriginYMap(), null); + + const vertOriginYMap = fontCJK.getVertOriginYMap(); + const sampleEntries = [ + // first three entries in VORG metrics + { glyphId: 730, expectedVertOriginY: 867 }, + { glyphId: 746, expectedVertOriginY: 868 }, + { glyphId: 747, expectedVertOriginY: 875 }, + ]; + for (const entry of sampleEntries) { + assert.strictEqual( + vertOriginYMap.get(entry.glyphId), + entry.expectedVertOriginY + ); + } + }); + + describe("ascent, descent and lineGap", function () { + const font = fontkit.openSync( + new URL("data/NotoSans/NotoSans.ttc", import.meta.url), + "NotoSans" + ); + const hhea = font.hhea; + const os2 = font["OS/2"]; + + // temporary values for testing + hhea.ascent = 111; + hhea.descent = 222; + hhea.lineGap = 333; + os2.typoAscender = 777; + os2.typoDescender = 888; + os2.typoLineGap = 999; + + it("expose values from OS/2 table if the USE_TYPO_METRICS flag is ON", function () { + os2.fsSelection.useTypoMetrics = true; + assert.strictEqual(font.ascent, 777); + assert.strictEqual(font.descent, 888); + assert.strictEqual(font.lineGap, 999); + }); + + it("expose values from hhea table if the USE_TYPO_METRICS flag is OFF", function () { + os2.fsSelection.useTypoMetrics = false; + assert.strictEqual(font.ascent, 111); + assert.strictEqual(font.descent, 222); + assert.strictEqual(font.lineGap, 333); + }); + }); }); diff --git a/test/opentype.js b/test/opentype.js index 8e39cdd2..8df2103b 100644 --- a/test/opentype.js +++ b/test/opentype.js @@ -1,4 +1,4 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; import assert from 'assert'; describe('opentype', function () { diff --git a/test/shaping.js b/test/shaping.js index dc005ea0..43c555d6 100644 --- a/test/shaping.js +++ b/test/shaping.js @@ -1,4 +1,4 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; import assert from 'assert'; describe('shaping', function () { @@ -7,6 +7,7 @@ describe('shaping', function () { it(description, function () { let f = fontCache[font] || (fontCache[font] = fontkit.openSync(new URL('data/' + font, import.meta.url))); let { glyphs, positions } = f.layout(text); + if (positions == null) assert.fail('Failed to get glyph positions'); // Generate a compact string representation of the results // in the same format as Harfbuzz, for comparison. @@ -31,12 +32,12 @@ describe('shaping', function () { let font = fontkit.openSync(new URL('data/amiri/amiri-regular.ttf', import.meta.url)); it('should use correct script and language when features are not specified', function () { - let { glyphs } = font.layout('۴', 'arab', 'URD'); + let { glyphs } = font.layout('۴', undefined, {script: 'arab', language: 'URD'}); return assert.deepEqual(glyphs.map(g => g.id), [1940]); }); it('should use specified left-to-right direction', function () { - let { glyphs } = font.layout('١٢٣', 'arab', 'ARA ', 'ltr'); + let { glyphs } = font.layout('١٢٣', undefined, {script: 'arab', language: 'ARA ', direction: 'ltr'}); return assert.deepEqual(glyphs.map(g => g.id), [446, 447, 448]); }); }); diff --git a/test/subset.js b/test/subset.js index bb01e062..521a8583 100644 --- a/test/subset.js +++ b/test/subset.js @@ -1,4 +1,4 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; import assert from 'assert'; import concat from 'concat-stream'; import * as r from 'restructure'; diff --git a/test/tables.js b/test/tables.js new file mode 100644 index 00000000..026a8912 --- /dev/null +++ b/test/tables.js @@ -0,0 +1,103 @@ +import * as fontkit from "@denkiyagi/fontkit"; +import assert from "assert"; + +describe("tables", function () { + const font = fontkit.openSync( + new URL("data/NotoSans/NotoSans.ttc", import.meta.url), + "NotoSans" + ); + const fontCJK = fontkit.openSync( + new URL("data/NotoSansCJK/NotoSansCJKkr-Regular.otf", import.meta.url) + ); + + it("should expose hhea table", function () { + assert.deepStrictEqual(font.hhea, { + version: 0x10000, // v1.0 + ascent: 2189, + descent: -600, + lineGap: 0, + advanceWidthMax: 2994, + minLeftSideBearing: 0, + minRightSideBearing: -1550, + xMaxExtent: 0, + caretSlopeRise: 1, + caretSlopeRun: 0, + caretOffset: 0, + metricDataFormat: 0, + numberOfMetrics: 8707, + }); + }); + + it("should expose vhea table", function () { + assert.deepStrictEqual(fontCJK.vhea, { + version: 0x11000, // v1.1 + ascent: 500, + descent: -500, + lineGap: 0, + advanceHeightMax: 3000, + minTopSideBearing: -1002, + minBottomSideBearing: -677, + yMaxExtent: 2928, + caretSlopeRise: 0, + caretSlopeRun: 1, + caretOffset: 0, + metricDataFormat: 0, + numberOfMetrics: 65167, + }); + }); + + it("should expose OS/2 table", function () { + assert.deepStrictEqual(font["OS/2"], { + version: 4, + xAvgCharWidth: 1224, + usWeightClass: 400, + usWidthClass: 5, + fsType: { + noEmbedding: false, + viewOnly: false, + editable: false, + noSubsetting: false, + bitmapOnly: false, + }, + ySubscriptXSize: 1434, + ySubscriptYSize: 1331, + ySubscriptXOffset: 0, + ySubscriptYOffset: 287, + ySuperscriptXSize: 1434, + ySuperscriptYSize: 1331, + ySuperscriptXOffset: 0, + ySuperscriptYOffset: 977, + yStrikeoutSize: 102, + yStrikeoutPosition: 512, + sFamilyClass: 2050, + panose: [2, 11, 5, 2, 4, 5, 4, 2, 2, 4], + ulCharRange: [3758097151, 1073772799, 41, 0], + vendorID: "GOOG", + fsSelection: { + italic: false, + underscore: false, + negative: false, + outlined: false, + strikeout: false, + bold: false, + regular: true, + useTypoMetrics: false, + wws: false, + oblique: false, + }, + usFirstCharIndex: 13, + usLastCharIndex: 65533, + typoAscender: 2189, + typoDescender: -600, + typoLineGap: 0, + winAscent: 2189, + winDescent: 600, + codePageRange: [536871327, 3755409408], + xHeight: 1098, + capHeight: 1462, + defaultChar: 0, + breakChar: 32, + maxContent: 24, + }); + }); +}); diff --git a/test/unit/utils/clone.test.js b/test/unit/utils/clone.test.js new file mode 100644 index 00000000..873d4321 --- /dev/null +++ b/test/unit/utils/clone.test.js @@ -0,0 +1,90 @@ +import assert from 'assert'; +import { cloneDeep } from '../../../src/utils/clone.js'; + +describe('unit test: clone', function () { + describe('cloneDeep', function () { + it('returns primitives as-is', function () { + assert.strictEqual(cloneDeep(42), 42); + assert.strictEqual(cloneDeep(null), null); + assert.strictEqual(cloneDeep('fontkit'), 'fontkit'); + }); + + it('clones nested arrays and objects', function () { + let source = [{ width: 123, names: ['a', 'b'] }, { width: 456, names: [] }]; + let copy = cloneDeep(source); + + assert.notStrictEqual(copy, source); + assert.notStrictEqual(copy[0], source[0]); + assert.notStrictEqual(copy[0].names, source[0].names); + assert.deepStrictEqual(copy, source); + + source[0].names.push('c'); + assert.deepStrictEqual(copy[0].names, ['a', 'b']); + }); + + it('preserves circular references', function () { + let source = { name: 'metrics' }; + source.self = source; + + let copy = cloneDeep(source); + assert.notStrictEqual(copy, source); + assert.strictEqual(copy.self, copy); + assert.deepStrictEqual({ name: copy.name }, { name: 'metrics' }); + }); + + it('preserves circular arrays', function () { + let source = []; + source[0] = source; + + let copy = cloneDeep(source); + assert.notStrictEqual(copy, source); + assert.strictEqual(copy[0], copy); + }); + + it('copies enumerable symbols and ignores hidden ones', function () { + let visible = Symbol('visible'); + let hidden = Symbol('hidden'); + + let head = {}; + Object.defineProperty(head, visible, { value: 'ok', enumerable: true }); + Object.defineProperty(head, hidden, { value: 'skip', enumerable: false }); + + let copy = cloneDeep(head); + assert.strictEqual(copy[visible], 'ok'); + assert.strictEqual(Object.prototype.hasOwnProperty.call(copy, hidden), false); + }); + + it('handles structures similar to font tables', function () { + let maxp = { + numGlyphs: 42, + version: 1.0, + stats: { + maxPoints: 120, + maxContours: 10 + } + }; + + let head = Object.assign(Object.create(null), { + indexToLocFormat: 0, + flags: { + baselineAtY0: true, + forcePPEMToInteger: false + }, + bbox: { xMin: -10, yMin: -20, xMax: 400, yMax: 800 } + }); + + let cloned = cloneDeep({ maxp, head }); + + assert.deepStrictEqual(cloned, { maxp, head }); + assert.notStrictEqual(cloned.maxp, maxp); + assert.notStrictEqual(cloned.head, head); + assert.notStrictEqual(cloned.head.bbox, head.bbox); + }); + + it('throws when encountering unsupported values', function () { + assert.throws(() => cloneDeep(new Date()), /only supports/); + assert.throws(() => cloneDeep(Buffer.from('a')), /only supports/); + assert.throws(() => cloneDeep(function noop() { }), /only supports/); + }); + }); +}); diff --git a/test/unit/utils/deep-equal.test.js b/test/unit/utils/deep-equal.test.js new file mode 100644 index 00000000..b3dc73d4 --- /dev/null +++ b/test/unit/utils/deep-equal.test.js @@ -0,0 +1,39 @@ +import assert from 'assert'; +import { equalArray } from '../../../src/utils/deep-equal.js'; + +describe('unit test: deep-equal', function () { + describe('equalArray', function () { + it('compares primitives via Object.is semantics', function () { + assert.strictEqual(equalArray(42, 42), true); + assert.strictEqual(equalArray(-0, 0), false); + assert.strictEqual(equalArray(NaN, NaN), true); + assert.strictEqual(equalArray(null, undefined), false); + }); + + it('compares nested arrays of supported values', function () { + assert.strictEqual(equalArray([1, 2, 3], [1, 2, 3]), true); + assert.strictEqual(equalArray([1, [2, 3], 4], [1, [2, 3], 4]), true); + assert.strictEqual(equalArray([1, [2, 3], 4], [1, [3, 2], 4]), false); + assert.strictEqual(equalArray([1, 2], [1, 2, 3]), false); + assert.strictEqual(equalArray([1, 2], 1), false); + }); + + it('returns false when only one operand is an array', function () { + assert.strictEqual(equalArray([1, 2], null), false); + assert.strictEqual(equalArray(undefined, [1, 2]), false); + assert.strictEqual(equalArray([1, 2], 1), false); + assert.strictEqual(equalArray(1, [1, 2]), false); + }); + + it('returns false when only one operand is primitive', function () { + assert.strictEqual(equalArray({ a: 1 }, null), false); + assert.strictEqual(equalArray(undefined, { a: 1 }), false); + }); + + it('throws on unsupported values', function () { + assert.throws(() => equalArray({ a: 1 }, { a: 1 }), /only supports primitives and arrays/); + assert.throws(() => equalArray([1, { a: 1 }], [1, { a: 1 }]), /only supports primitives and arrays/); + assert.throws(() => equalArray(Buffer.alloc(1), Buffer.alloc(1)), /only supports primitives and arrays/); + }); + }); +}); diff --git a/test/unit/utils/primitive.test.js b/test/unit/utils/primitive.test.js new file mode 100644 index 00000000..59852cd1 --- /dev/null +++ b/test/unit/utils/primitive.test.js @@ -0,0 +1,30 @@ +import assert from 'assert'; +import { isPrimitive } from '../../../src/utils/primitive.js'; + +describe('unit test: primitive', function () { + describe('isPrimitive', function () { + it('recognizes primitive values', function () { + assert.strictEqual(isPrimitive(null), true); + assert.strictEqual(isPrimitive(undefined), true); + assert.strictEqual(isPrimitive(0), true); + assert.strictEqual(isPrimitive(-0), true); + assert.strictEqual(isPrimitive(NaN), true); + assert.strictEqual(isPrimitive('fontkit'), true); + assert.strictEqual(isPrimitive(true), true); + assert.strictEqual(isPrimitive(Symbol('s')), true); + assert.strictEqual(isPrimitive(10n), true); + }); + + it('rejects arrays and objects', function () { + assert.strictEqual(isPrimitive({}), false); + assert.strictEqual(isPrimitive(Object.create(null)), false); + assert.strictEqual(isPrimitive([]), false); + }); + + it('rejects functions and class instances', function () { + assert.strictEqual(isPrimitive(() => { }), false); + class Foo { } + assert.strictEqual(isPrimitive(new Foo()), false); + }); + }); +}); diff --git a/test/variations.js b/test/variations.js index 4a325df6..ed73602b 100644 --- a/test/variations.js +++ b/test/variations.js @@ -1,4 +1,4 @@ -import * as fontkit from 'fontkit'; +import * as fontkit from '@denkiyagi/fontkit'; import assert from 'assert'; import fs from 'fs'; @@ -118,6 +118,7 @@ describe('variations', function () { it('should support adjusting GPOS mark anchor points for variations', function () { let font = fontkit.openSync(new URL('data/Mada/Mada-VF.ttf', import.meta.url), { wght: 900 }); let run = font.layout('ف'); + if (run.positions == null) assert.fail('Failed to get glyph positions'); assert.equal(Math.floor(run.positions[0].xOffset), 639); assert.equal(Math.floor(run.positions[0].yOffset), 542); }); diff --git a/tsconfig-types.json b/tsconfig-types.json new file mode 100644 index 00000000..7fa57e1e --- /dev/null +++ b/tsconfig-types.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "allowJs": true, + "target": "ESNext", + "outDir": "types", + }, + "include": [ + "src/index.ts", + "src/node.ts", + ] +}