├── .npmignore ├── .husky ├── commit-msg └── pre-commit ├── src ├── index.ts ├── tests │ ├── helpers │ │ ├── heydings_controls.ttf │ │ ├── takeSnapshot.ts │ │ ├── readImageData.test.ts │ │ ├── extractColors.ts │ │ ├── readImageData.ts │ │ └── longInput.ts │ ├── bubbleTail.test.ts │ ├── fileWriter.test.ts │ └── textToImage.test.ts ├── @types │ ├── readimage │ │ └── index.d.ts │ └── index.ts ├── extensions │ ├── fileWriter.ts │ └── bubbleTail.ts └── textToImage.ts ├── .prettierrc ├── commitlint.config.js ├── .github ├── pr-labeler.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── pr-labeler.yml │ ├── lint-pr.yml │ └── node-ci.yml └── dependabot.yml ├── tsconfig.json ├── jest.config.js ├── LICENSE ├── .gitignore ├── CONTRIBUTING.md ├── eslint.config.js ├── package.json ├── CHANGELOG.md └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | **/tests/* 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | npx --no-install lint-staged -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './textToImage'; 2 | export * from './@types'; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /.github/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | feature: ['feature/*', 'feat/*', 'features/*'] 2 | fix: fix/* 3 | chore: chore/* 4 | security: security/* 5 | -------------------------------------------------------------------------------- /src/tests/helpers/heydings_controls.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bostrom/text-to-image/HEAD/src/tests/helpers/heydings_controls.ttf -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Request new features 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the new feature** 10 | A clear and concise description of what the feature is. 11 | 12 | **Suggest a solution** 13 | If you have ideas for a potential solution, feel free to share them. 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "compilerOptions": { 4 | // Emit 5 | "declaration": true, 6 | "outDir": "dist", 7 | "removeComments": true, 8 | 9 | // Type Checking 10 | "strict": true, 11 | "noImplicitAny": true, 12 | 13 | // Interop Constraints 14 | "isolatedModules": true 15 | }, 16 | "include": ["src/**/*"] 17 | } 18 | -------------------------------------------------------------------------------- /src/@types/readimage/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'readimage' { 2 | export interface Frame { 3 | data: number[]; 4 | delay?: number; 5 | } 6 | export interface ReadimageData { 7 | height: number; 8 | width: number; 9 | frames: Frame[]; 10 | } 11 | export default function readimage( 12 | buf: Buffer, 13 | cb: (err: Error | undefined, img: ReadimageData) => void, 14 | ): void; 15 | } 16 | -------------------------------------------------------------------------------- /.github/workflows/pr-labeler.yml: -------------------------------------------------------------------------------- 1 | name: PR Labeler 2 | on: 3 | pull_request: 4 | types: [opened] 5 | 6 | jobs: 7 | pr-labeler: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - uses: TimonVS/pr-labeler-action@v5 13 | with: 14 | configuration-path: .github/pr-labeler.yml 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /src/tests/helpers/takeSnapshot.ts: -------------------------------------------------------------------------------- 1 | // For development purposes only. 2 | // We can't use the snapshots in the CI environment since they 3 | // will differ ever so slightly from the development env 4 | // and fail our tests. So we use the snapshots only as 5 | // regression detectors when developing. 6 | export default function takeSnapshot(data: string) { 7 | if (process.env.SNAPSHOTS === 'true') { 8 | expect(data).toMatchSnapshot(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/extensions/fileWriter.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve } from 'path'; 2 | import { writeFileSync, mkdirSync } from 'fs'; 3 | import { Canvas } from 'canvas'; 4 | import { fileWriterOptions } from '../@types'; 5 | 6 | export default (options?: fileWriterOptions) => (canvas: Canvas) => { 7 | const fileName = 8 | options?.fileName ?? 9 | `${new Date().toISOString().replace(/[\W.]/g, '')}.png`; 10 | 11 | mkdirSync(resolve(dirname(fileName)), { recursive: true }); 12 | writeFileSync(fileName, canvas.toBuffer()); 13 | 14 | return canvas; 15 | }; 16 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | const config = { 3 | testEnvironment: 'node', 4 | preset: 'ts-jest', 5 | // Indicates whether the coverage information should be collected while executing the test 6 | collectCoverage: true, 7 | coverageDirectory: 'coverage', 8 | testPathIgnorePatterns: ['node_modules/', 'dist/'], 9 | coveragePathIgnorePatterns: [ 10 | 'node_modules/', 11 | 'src/tests', 12 | 'src/@types', 13 | 'src/index.ts', 14 | ], 15 | coverageProvider: 'v8', 16 | }; 17 | 18 | module.exports = config; 19 | -------------------------------------------------------------------------------- /src/tests/helpers/readImageData.test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '../../textToImage'; 2 | import { readImageData, uriToBuf } from './readImageData'; 3 | 4 | describe('readImageData', () => { 5 | it('should reject if the data is not an image', async () => { 6 | await expect(readImageData('asdf' as unknown as Buffer)).rejects.toEqual( 7 | 'SOI not found', 8 | ); 9 | }); 10 | it('should resolve with image data', async () => { 11 | const imguri = await generate('asdf'); 12 | const imageData = await readImageData(uriToBuf(imguri)); 13 | expect(imageData.width).toBeDefined(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Version of the library** 10 | What version of the library are you using? 11 | 12 | **Describe the bug** 13 | A clear and concise description of what the bug is. 14 | 15 | **To Reproduce** 16 | Link to codesandbox.io or a snippet that will showcase the issue. Use [this sandbox](https://codesandbox.io/p/sandbox/gtcmdy) as base. 17 | 18 | **Expected behavior** 19 | A clear and concise description of what you expected to happen. 20 | 21 | **Screenshots** 22 | If applicable, add screenshots to help explain your problem. 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr.yml: -------------------------------------------------------------------------------- 1 | name: 'Lint PR' 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - synchronize 9 | 10 | jobs: 11 | main: 12 | name: Validate PR title 13 | runs-on: ubuntu-latest 14 | permissions: 15 | pull-requests: read 16 | steps: 17 | - uses: amannn/action-semantic-pull-request@v6 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | subjectPattern: ^(?![A-Z]).+$ 22 | subjectPatternError: | 23 | The subject "{subject}" found in the pull request title "{title}" 24 | didn't match the configured pattern. Please ensure that the subject 25 | doesn't start with an uppercase character. 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | 3 | Copyright (c) 2016, Fredrik Boström 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | dist 29 | 30 | # Dependency directories 31 | node_modules 32 | jspm_packages 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | # IntelliJ 41 | .idea 42 | 43 | # VSCode 44 | .vscode 45 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Fork, then clone the repo: 4 | 5 | git clone git@github.com:your-username/text-to-image.git 6 | 7 | Install dependencies 8 | 9 | npm install 10 | 11 | Create a development branch for your fix/feature. 12 | 13 | git co -b my_fix 14 | 15 | Code away, write tests, make the tests pass 16 | 17 | npm test 18 | 19 | Push to your fork and [submit a pull request][pr]. 20 | 21 | [pr]: https://github.com/bostrom/text-to-image/compare/ 22 | 23 | At this point you're waiting on us. We may suggest some changes or 24 | improvements or alternatives. The process is smoother if you 25 | 26 | * Write tests. 27 | * Follow the code style used in the project. 28 | * Write a [good commit message][commit]. 29 | 30 | [commit]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 31 | -------------------------------------------------------------------------------- /src/tests/helpers/extractColors.ts: -------------------------------------------------------------------------------- 1 | import { ReadimageData } from 'readimage'; 2 | 3 | function paddedHex(intVal: number) { 4 | let s = intVal.toString(16); 5 | if (s.length === 1) { 6 | s = `0${s}`; 7 | } 8 | 9 | return s; 10 | } 11 | 12 | export default function extractColors(image: ReadimageData) { 13 | const pixels = image.frames[0].data; 14 | const colorMap: Record = {}; 15 | for (let i = 0; i < pixels.length; i += 4) { 16 | const r = pixels[i]; 17 | const g = pixels[i + 1]; 18 | const b = pixels[i + 2]; 19 | // a = pixels[i + 3] 20 | 21 | const hexNotation = `#${paddedHex(r)}${paddedHex(g)}${paddedHex(b)}`; 22 | let currValue = colorMap[hexNotation]; 23 | if (currValue) { 24 | currValue += 1; 25 | } else { 26 | currValue = 1; 27 | } 28 | colorMap[hexNotation] = currValue; 29 | } 30 | 31 | return colorMap; 32 | } 33 | 34 | module.exports = extractColors; 35 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' 9 | directory: '/' 10 | schedule: 11 | interval: 'weekly' 12 | groups: 13 | production-dependencies: 14 | dependency-type: production 15 | major-updates: 16 | update-types: 17 | - major 18 | minor-and-patch-updates: 19 | update-types: 20 | - minor 21 | - patch 22 | commit-message: 23 | prefix: 'chore' 24 | include: 'scope' 25 | - package-ecosystem: 'github-actions' 26 | directory: '/' 27 | schedule: 28 | interval: 'weekly' 29 | groups: 30 | all: 31 | patterns: 32 | - '*' 33 | commit-message: 34 | prefix: 'chore' 35 | include: 'scope' 36 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const eslint = require('@eslint/js'); 2 | const tseslint = require('typescript-eslint'); 3 | const eslintConfigPrettier = require('eslint-config-prettier/flat'); 4 | const jestPlugin = require('eslint-plugin-jest'); 5 | const importPlugin = require('eslint-plugin-import'); 6 | 7 | module.exports = tseslint.config( 8 | { 9 | ignores: ['**/dist/**', '**/coverage/**', '**/*.js'], 10 | }, 11 | eslint.configs.recommended, 12 | tseslint.configs.strictTypeChecked, 13 | tseslint.configs.stylisticTypeChecked, 14 | eslintConfigPrettier, 15 | importPlugin.flatConfigs.recommended, 16 | importPlugin.flatConfigs.typescript, 17 | { 18 | languageOptions: { 19 | parserOptions: { 20 | projectService: true, 21 | tsconfigRootDir: __dirname, 22 | }, 23 | }, 24 | }, 25 | { 26 | rules: { 27 | curly: ['error', 'all'], 28 | }, 29 | }, 30 | { 31 | // disable type-aware linting on JS files 32 | files: ['**/*.js', '**/*.cjs', '**/*.mjs'], 33 | extends: [eslint.configs.recommended], 34 | }, 35 | { 36 | // enable jest rules on test files 37 | files: ['test/**'], 38 | extends: [jestPlugin.configs['flat/recommended']], 39 | }, 40 | ); 41 | -------------------------------------------------------------------------------- /src/extensions/bubbleTail.ts: -------------------------------------------------------------------------------- 1 | import { createCanvas, Canvas } from 'canvas'; 2 | import { ComputedOptions } from '../@types'; 3 | 4 | export interface BubbleTailOptions { 5 | width: number; 6 | height: number; 7 | } 8 | 9 | export default ({ width, height }: BubbleTailOptions) => 10 | (canvas: Canvas, conf: ComputedOptions) => { 11 | if (width <= 0 || height <= 0) { 12 | return canvas; 13 | } 14 | 15 | // create a new bigger canvas to accommodate the tail 16 | const tailedCanvas = createCanvas(canvas.width, canvas.height + height); 17 | const tailedCtx = tailedCanvas.getContext('2d'); 18 | 19 | // copy the original image onto the new canvas 20 | tailedCtx.drawImage(canvas, 0, 0); 21 | 22 | // draw the tail 23 | tailedCtx.beginPath(); 24 | tailedCtx.moveTo( 25 | tailedCanvas.width / 2 - width / 2, 26 | tailedCanvas.height - height, 27 | ); 28 | tailedCtx.lineTo(tailedCanvas.width / 2, tailedCanvas.height); 29 | tailedCtx.lineTo( 30 | tailedCanvas.width / 2 + width / 2, 31 | tailedCanvas.height - height, 32 | ); 33 | tailedCtx.closePath(); 34 | tailedCtx.fillStyle = conf.bgColor; 35 | tailedCtx.fill(); 36 | 37 | return tailedCanvas; 38 | }; 39 | -------------------------------------------------------------------------------- /src/tests/helpers/readImageData.ts: -------------------------------------------------------------------------------- 1 | import readimage, { ReadimageData } from 'readimage'; 2 | 3 | export const uriToBuf = (imageUri: string) => 4 | Buffer.from(imageUri.split(',')[1], 'base64'); 5 | 6 | export const readImageData = (imageData: Buffer) => 7 | new Promise((resolve, reject) => { 8 | readimage(imageData, (err, img) => { 9 | if (err) { 10 | reject(err); 11 | } else { 12 | resolve(img); 13 | } 14 | }); 15 | }); 16 | 17 | export const countWhitePixels = ( 18 | imageData: ReadimageData, 19 | fromCol: number, 20 | fromRow: number, 21 | toCol: number, 22 | toRow: number, 23 | ) => 24 | imageData.frames[0].data.reduce((acc, cur, index) => { 25 | const alpha = (index + 1) % 4 === 0; 26 | const col = (index / 4) % imageData.width; 27 | const row = index / 4 / imageData.width; 28 | 29 | // each pixel has 4 values (RGBA), skip every 4th value (i.e. the alpha) 30 | return !alpha && 31 | // only include values for pixels within the ranges 32 | col >= fromCol && 33 | col < toCol && 34 | row >= fromRow && 35 | row < toRow 36 | ? acc + cur / 255 37 | : acc; 38 | }, 0) / 3; 39 | 40 | module.exports = { 41 | uriToBuf, 42 | readImageData, 43 | countWhitePixels, 44 | }; 45 | -------------------------------------------------------------------------------- /src/@types/index.ts: -------------------------------------------------------------------------------- 1 | import { Canvas, CanvasGradient, CanvasPattern, CanvasTextAlign } from 'canvas'; 2 | 3 | export type * from 'canvas'; 4 | 5 | export type GenerateFunction = ( 6 | content: string, 7 | config?: T, 8 | ) => Promise; 9 | 10 | export type GenerateFunctionSync = ( 11 | content: string, 12 | config?: T, 13 | ) => string; 14 | 15 | export interface GenerateOptions { 16 | bgColor?: string | CanvasGradient | CanvasPattern; 17 | customHeight?: number; 18 | fontFamily?: string; 19 | fontPath?: string; 20 | fontSize?: number; 21 | fontWeight?: string | number; 22 | lineHeight?: number; 23 | margin?: number; 24 | maxWidth?: number; 25 | textAlign?: CanvasTextAlign; 26 | textColor?: string; 27 | verticalAlign?: string; 28 | extensions?: Extension[]; 29 | } 30 | 31 | export interface GenerateOptionsAsync extends GenerateOptions { 32 | extensions?: Extension[]; 33 | } 34 | 35 | export interface GenerateOptionsSync extends GenerateOptions { 36 | extensions?: SyncExtension[]; 37 | } 38 | 39 | export type SyncExtension = (canvas: Canvas, config: ComputedOptions) => Canvas; 40 | 41 | export type AsyncExtension = ( 42 | canvas: Canvas, 43 | config: ComputedOptions, 44 | ) => Promise; 45 | 46 | export type Extension = SyncExtension | AsyncExtension; 47 | 48 | export type ComputedOptions = 49 | Required; 50 | 51 | export interface fileWriterOptions { 52 | fileName?: string; 53 | } 54 | -------------------------------------------------------------------------------- /src/tests/bubbleTail.test.ts: -------------------------------------------------------------------------------- 1 | import { generate } from '..'; 2 | import { 3 | countWhitePixels, 4 | readImageData, 5 | uriToBuf, 6 | } from './helpers/readImageData'; 7 | import bubbleTail from '../extensions/bubbleTail'; 8 | 9 | describe('bubbleTail extension', () => { 10 | it('should not print a speech bubble tail if zero height or width given', async () => { 11 | expect.assertions(1); 12 | const height = 300; 13 | await generate('This is Speech bubble', { 14 | maxWidth: 300, 15 | customHeight: height, 16 | extensions: [ 17 | bubbleTail({ height: 0, width: 0 }), 18 | (canvas) => { 19 | // canvas height should not change 20 | expect(canvas.height).toBe(height); 21 | return canvas; 22 | }, 23 | ], 24 | }); 25 | }); 26 | 27 | it('should not print a speech bubble tail if negative height or width given', async () => { 28 | expect.assertions(1); 29 | const height = 300; 30 | await generate('This is Speech bubble', { 31 | maxWidth: 300, 32 | customHeight: height, 33 | extensions: [ 34 | bubbleTail({ height: -1, width: -1 }), 35 | (canvas) => { 36 | // canvas height should not change 37 | expect(canvas.height).toBe(height); 38 | return canvas; 39 | }, 40 | ], 41 | }); 42 | }); 43 | 44 | it('should support speech bubble tail', async () => { 45 | const width = 300; 46 | const height = 50; 47 | const bubbleTailConf = { width: 50, height: 30 }; 48 | 49 | const uri = await generate('This is Speech bubble', { 50 | maxWidth: width, 51 | extensions: [bubbleTail(bubbleTailConf)], 52 | }); 53 | 54 | const imageData = await readImageData(uriToBuf(uri)); 55 | 56 | const center = width / 2; 57 | 58 | // Check if there's a tail under the square. 59 | const whitePixels = countWhitePixels( 60 | imageData, 61 | center - 0.5, 62 | height, 63 | center + 0.5, 64 | height + bubbleTailConf.height, 65 | ); 66 | 67 | // The alpha at the bottom vertex of 2 pixels is not 255. 68 | expect(whitePixels).toBe(bubbleTailConf.height - 2); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "text-to-image", 3 | "version": "0.0.0-development", 4 | "description": "A library for generating an image data URI representing an image containing the text of your choice.", 5 | "author": "Fredrik Boström", 6 | "homepage": "https://github.com/bostrom/text-to-image#readme", 7 | "repository": "https://github.com/bostrom/text-to-image", 8 | "bugs": { 9 | "url": "https://github.com/bostrom/text-to-image/issues" 10 | }, 11 | "keywords": [ 12 | "text", 13 | "image", 14 | "image generator", 15 | "text generator", 16 | "canvas", 17 | "twitter" 18 | ], 19 | "license": "ISC", 20 | "type": "commonjs", 21 | "main": "index.js", 22 | "exports": { 23 | ".": "./index.js", 24 | "./extensions/fileWriter": "./extensions/fileWriter.js", 25 | "./extensions/bubbleTail": "./extensions/bubbleTail.js" 26 | }, 27 | "scripts": { 28 | "prepare": "husky", 29 | "clean": "rimraf dist", 30 | "build": "npm run clean && tsc", 31 | "package": "npm run build && cp package.json README.md LICENSE .npmignore dist/", 32 | "test": "jest", 33 | "semantic-release": "semantic-release" 34 | }, 35 | "release": { 36 | "pkgRoot": "dist" 37 | }, 38 | "dependencies": { 39 | "canvas": "^3.1.0" 40 | }, 41 | "devDependencies": { 42 | "@commitlint/cli": "^20.1.0", 43 | "@commitlint/config-conventional": "^20.0.0", 44 | "@eslint/js": "^9.29.0", 45 | "@tsconfig/node20": "^20.1.6", 46 | "@types/jest": "^30.0.0", 47 | "eslint": "^9.29.0", 48 | "eslint-config-prettier": "^10.1.5", 49 | "eslint-plugin-import": "^2.30.0", 50 | "eslint-plugin-jest": "^29.0.1", 51 | "glob": "^13.0.0", 52 | "husky": "^9.1.6", 53 | "image-size": "^2.0.2", 54 | "jest": "^30.0.3", 55 | "lint-staged": "^16.1.2", 56 | "prettier": "^3.3.3", 57 | "readimage": "^1.1.1", 58 | "rimraf": "^6.0.1", 59 | "semantic-release": "^25.0.1", 60 | "ts-jest": "^29.2.5", 61 | "ts-node": "^10.9.2", 62 | "typescript": "^5.8.3", 63 | "typescript-eslint": "^8.35.0" 64 | }, 65 | "lint-staged": { 66 | "*.{js,ts}": [ 67 | "eslint", 68 | "prettier --write" 69 | ], 70 | "*.{json,graphql,md,css,scss,less}": [ 71 | "prettier --write" 72 | ] 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/node-ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: read 11 | strategy: 12 | matrix: 13 | node-version: [20.x, 22.x, 24.x] 14 | steps: 15 | - name: Install canvas dependencies 16 | run: sudo apt-get install build-essential libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev 17 | - uses: actions/checkout@v6 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v6 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - run: npm ci 23 | - run: npm test 24 | - run: npm run build 25 | 26 | - name: Coveralls Parallel 27 | uses: coverallsapp/github-action@v2 28 | with: 29 | github-token: ${{ secrets.GITHUB_TOKEN }} 30 | flag-name: run-${{ matrix.node-version }} 31 | parallel: true 32 | 33 | coveralls: 34 | name: Coveralls report 35 | needs: test 36 | runs-on: ubuntu-latest 37 | permissions: 38 | pull-requests: write 39 | steps: 40 | - name: Coveralls Finished 41 | uses: coverallsapp/github-action@v2 42 | with: 43 | github-token: ${{ secrets.GITHUB_TOKEN }} 44 | parallel-finished: true 45 | 46 | release: 47 | name: Release 48 | needs: coveralls 49 | if: ${{ success() && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next' || github.ref == 'refs/heads/beta') }} 50 | runs-on: ubuntu-latest 51 | permissions: 52 | contents: write # to be able to publish a GitHub release 53 | issues: write # to be able to comment on released issues 54 | pull-requests: write # to be able to comment on released pull requests 55 | id-token: write # to enable use of OIDC for npm provenance 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v6 59 | with: 60 | fetch-depth: 0 61 | - name: Setup Node.js 62 | uses: actions/setup-node@v6 63 | with: 64 | node-version: 24 65 | - name: Install dependencies 66 | run: npm ci 67 | - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies 68 | run: npm audit signatures 69 | - name: Build 70 | run: npm run package 71 | - name: Release 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 75 | run: npx semantic-release 76 | -------------------------------------------------------------------------------- /src/tests/fileWriter.test.ts: -------------------------------------------------------------------------------- 1 | import { glob } from 'glob'; 2 | import fs from 'fs'; 3 | import path from 'path'; 4 | import { generate, generateSync } from '..'; 5 | import fileWriter from '../extensions/fileWriter'; 6 | 7 | describe('fileWriter extension', () => { 8 | afterEach(async () => { 9 | // Remove all pngs created by the fileWriter 10 | const pngs = glob.sync(path.join(process.cwd(), '*.png')); 11 | await Promise.all(pngs.map((item) => fs.promises.unlink(item))); 12 | }); 13 | 14 | it('should not create a file if fileWriter is not used', async () => { 15 | await generate('Hello world'); 16 | 17 | const files = glob.sync(path.join(process.cwd(), '*.png')); 18 | expect(files.length).toEqual(0); 19 | }); 20 | 21 | it('should create a png file', async () => { 22 | await generate('Hello world', { extensions: [fileWriter()] }); 23 | 24 | const files = glob.sync(path.join(process.cwd(), '*.png')); 25 | expect(files.length).toEqual(1); 26 | }); 27 | 28 | it('should work in sync mode', () => { 29 | generateSync('Hello world', { 30 | extensions: [ 31 | fileWriter({ 32 | fileName: '1_sync_debug.png', 33 | }), 34 | ], 35 | }); 36 | 37 | const images = glob.sync(path.join(process.cwd(), '1_sync_debug.png')); 38 | expect(images.length).toBe(1); 39 | }); 40 | 41 | it('should support custom filepaths in sync mode', async () => { 42 | const baseDir = path.join(process.cwd(), 'src', 'tests', 'custom_path'); 43 | const filePath = path.join(baseDir, 'to', '1_path_debug.png'); 44 | generateSync('Hello world', { 45 | extensions: [ 46 | fileWriter({ 47 | fileName: filePath, 48 | }), 49 | ], 50 | }); 51 | 52 | const images = glob.sync(filePath); 53 | expect(images.length).toBe(1); 54 | 55 | await fs.promises.rm(baseDir, { 56 | recursive: true, 57 | // force: true, 58 | }); 59 | }); 60 | 61 | it('should support custom filepaths in async mode', async () => { 62 | const baseDir = path.join(process.cwd(), 'src', 'tests', 'custom_path'); 63 | const filePath = path.join(baseDir, 'to', '1_path_debug.png'); 64 | await generate('Hello world', { 65 | extensions: [ 66 | fileWriter({ 67 | fileName: filePath, 68 | }), 69 | ], 70 | }); 71 | 72 | const images = glob.sync(filePath); 73 | expect(images.length).toBe(1); 74 | 75 | await fs.promises.rm(baseDir, { 76 | recursive: true, 77 | }); 78 | }); 79 | 80 | it('should support default filename in sync mode', () => { 81 | generateSync('Hello world', { extensions: [fileWriter()] }); 82 | 83 | const images = glob.sync(path.join(process.cwd(), '*.png')); 84 | expect(images.length).toBe(1); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Changelog moved to Github Releases 4 | 5 | See the [Releases page](https://github.com/bostrom/text-to-image/releases) for newer versions. 6 | 7 | 8 | 9 | # [2.3.0](https://github.com/bostrom/text-to-image/compare/v2.2.1...v2.3.0) (2020-04-12) 10 | 11 | ### Features 12 | 13 | - Add synchronous API, thanks [alyip98](https://github.com/alyip98) ([#34](https://github.com/bostrom/text-to-image/pull/34)) 14 | 15 | ### Chores 16 | 17 | - Update dependencies 18 | 19 | 20 | 21 | # [2.2.0](https://github.com/bostrom/text-to-image/compare/v2.1.1...v2.2.0) (2020-02-25) 22 | 23 | ### Features 24 | 25 | - Add `textAlign` option thanks [dizzyluo](https://github.com/dizzyluo) ([a395105](https://github.com/bostrom/text-to-image/commit/a395105)) 26 | 27 | ### Chores 28 | 29 | - Update dependencies 30 | 31 | 32 | 33 | # [2.1.1](https://github.com/bostrom/text-to-image/compare/v2.1.0...v2.1.1) (2019-11-25) 34 | 35 | ### Fixes 36 | 37 | - Library file not exported correctly from index.js, thanks [stephenkillingsworth](https://github.com/stephenkillingsworth) 38 | 39 | 40 | 41 | # [2.1.0](https://github.com/bostrom/text-to-image/compare/v2.0.0...v2.1.0) (2019-11-13) 42 | 43 | ### Features 44 | 45 | - Add `fontWeight` and `customHeight` options, thanks [gtallest](https://github.com/gtallest) 46 | 47 | 48 | 49 | # [2.0.0](https://github.com/bostrom/text-to-image/compare/v1.2.0...v2.0.0) (2019-11-12) 50 | 51 | - Upgrade dependencies, thanks [jlei523](https://github.com/jlei523) 52 | - Update code base to 2019 standard 53 | - ES6 54 | - Jest 55 | - Eslint 56 | - Prettier 57 | 58 | ### Breaking changes 59 | 60 | - Dropped support for nodejs < 6 61 | 62 | 63 | 64 | # [1.2.0](https://github.com/bostrom/text-to-image/compare/v1.1.0...v1.2.0) (2018-02-13) 65 | 66 | ### Features 67 | 68 | - Add font family support, thanks [nikonieminen](https://github.com/nikonieminen) ([da79f11](https://github.com/bostrom/text-to-image/commit/da79f11)) 69 | 70 | 71 | 72 | # [1.1.0](https://github.com/bostrom/text-to-image/compare/v1.0.1...v1.1.0) (2016-11-19) 73 | 74 | ### Features 75 | 76 | - Add newline support ([f50d0b7](https://github.com/bostrom/text-to-image/commit/f50d0b7)), closes [#3](https://github.com/bostrom/text-to-image/issues/3) 77 | 78 | - Add support for background color and text color ([21349e2](https://github.com/bostrom/text-to-image/commit/21349e2)) 79 | 80 | 81 | 82 | ## [1.0.1](https://github.com/bostrom/text-to-image/compare/v1.0.0...v1.0.1) (2016-08-13) 83 | 84 | - Upgrade dependencies, add Travis and Coverall integration ([43b9cea](https://github.com/bostrom/text-to-image/commit/43b9cea)) 85 | 86 | 87 | 88 | # 1.0.0 (2016-08-13) 89 | 90 | - Initial release ([deec374](https://github.com/bostrom/text-to-image/commit/deec374)) 91 | -------------------------------------------------------------------------------- /src/tests/helpers/longInput.ts: -------------------------------------------------------------------------------- 1 | export default `Cras justo odio, dapibus ac facilisis in, egestas eget quam. Aenean lacinia bibendum nulla sed consectetur. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Sed posuere consectetur est at lobortis. Maecenas sed diam eget risus varius blandit sit amet non magna. Sed posuere consectetur est at lobortis. Sed posuere consectetur est at lobortis. 2 | 3 | Donec id elit non mi porta gravida at eget metus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed posuere consectetur est at lobortis. Donec id elit non mi porta gravida at eget metus. 4 | 5 | Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Curabitur blandit tempus porttitor. 6 | 7 | Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Maecenas faucibus mollis interdum. Donec id elit non mi porta gravida at eget metus. Curabitur blandit tempus porttitor. Nullam id dolor id nibh ultricies vehicula ut id elit. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Nullam id dolor id nibh ultricies vehicula ut id elit. 8 | 9 | Cras justo odio, dapibus ac facilisis in, egestas eget quam. Aenean lacinia bibendum nulla sed consectetur. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Sed posuere consectetur est at lobortis. Maecenas sed diam eget risus varius blandit sit amet non magna. Sed posuere consectetur est at lobortis. Sed posuere consectetur est at lobortis. 10 | 11 | Donec id elit non mi porta gravida at eget metus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed posuere consectetur est at lobortis. Donec id elit non mi porta gravida at eget metus. 12 | 13 | Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Curabitur blandit tempus porttitor. 14 | 15 | Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Maecenas faucibus mollis interdum. Donec id elit non mi porta gravida at eget metus. Curabitur blandit tempus porttitor. Nullam id dolor id nibh ultricies vehicula ut id elit. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Nullam id dolor id nibh ultricies vehicula ut id elit. 16 | 17 | Cras justo odio, dapibus ac facilisis in, egestas eget quam. Aenean lacinia bibendum nulla sed consectetur. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Sed posuere consectetur est at lobortis. Maecenas sed diam eget risus varius blandit sit amet non magna. Sed posuere consectetur est at lobortis. Sed posuere consectetur est at lobortis. 18 | 19 | Donec id elit non mi porta gravida at eget metus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed posuere consectetur est at lobortis. Donec id elit non mi porta gravida at eget metus. 20 | 21 | Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Curabitur blandit tempus porttitor. 22 | 23 | Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Maecenas faucibus mollis interdum. Donec id elit non mi porta gravida at eget metus. Curabitur blandit tempus porttitor. Nullam id dolor id nibh ultricies vehicula ut id elit. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Nullam id dolor id nibh ultricies vehicula ut id elit. 24 | 25 | Cras justo odio, dapibus ac facilisis in, egestas eget quam. Aenean lacinia bibendum nulla sed consectetur. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Sed posuere consectetur est at lobortis. Maecenas sed diam eget risus varius blandit sit amet non magna. Sed posuere consectetur est at lobortis. Sed posuere consectetur est at lobortis. 26 | 27 | Donec id elit non mi porta gravida at eget metus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed posuere consectetur est at lobortis. Donec id elit non mi porta gravida at eget metus. 28 | 29 | Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Curabitur blandit tempus porttitor. 30 | 31 | Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Maecenas faucibus mollis interdum. Donec id elit non mi porta gravida at eget metus. Curabitur blandit tempus porttitor. Nullam id dolor id nibh ultricies vehicula ut id elit. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Nullam id dolor id nibh ultricies vehicula ut id elit. 32 | 33 | Cras justo odio, dapibus ac facilisis in, egestas eget quam. Aenean lacinia bibendum nulla sed consectetur. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Sed posuere consectetur est at lobortis. Maecenas sed diam eget risus varius blandit sit amet non magna. Sed posuere consectetur est at lobortis. Sed posuere consectetur est at lobortis. 34 | 35 | Donec id elit non mi porta gravida at eget metus. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Sed posuere consectetur est at lobortis. Donec id elit non mi porta gravida at eget metus. 36 | 37 | Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus. Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Curabitur blandit tempus porttitor. 38 | 39 | Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Maecenas faucibus mollis interdum. Donec id elit non mi porta gravida at eget metus. Curabitur blandit tempus porttitor. Nullam id dolor id nibh ultricies vehicula ut id elit. Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum. Nullam id dolor id nibh ultricies vehicula ut id elit. 40 | `; 41 | -------------------------------------------------------------------------------- /src/textToImage.ts: -------------------------------------------------------------------------------- 1 | import { createCanvas, registerFont, Canvas } from 'canvas'; 2 | import { 3 | GenerateFunction, 4 | ComputedOptions, 5 | GenerateOptionsAsync, 6 | GenerateOptionsSync, 7 | GenerateFunctionSync, 8 | } from './@types'; 9 | 10 | const defaults = { 11 | bgColor: '#fff', 12 | customHeight: 0, 13 | fontFamily: 'Helvetica', 14 | fontPath: '', 15 | fontSize: 18, 16 | fontWeight: 'normal', 17 | lineHeight: 28, 18 | margin: 10, 19 | maxWidth: 400, 20 | textAlign: 'left' as const, 21 | textColor: '#000', 22 | verticalAlign: 'top', 23 | extensions: [], 24 | }; 25 | 26 | const createTextData = ( 27 | text: string, 28 | config: ComputedOptions, 29 | canvas?: Canvas, 30 | ) => { 31 | const { 32 | bgColor, 33 | fontFamily, 34 | fontPath, 35 | fontSize, 36 | fontWeight, 37 | lineHeight, 38 | maxWidth, 39 | textAlign, 40 | textColor, 41 | } = config; 42 | 43 | // Register a custom font 44 | if (fontPath) { 45 | registerFont(fontPath, { family: fontFamily }); 46 | } 47 | 48 | // Use the supplied canvas (which should have a suitable width and height) 49 | // for the final image. 50 | // Or create a temporary canvas just for measuring how long the canvas 51 | // needs to be. 52 | const textCanvas = canvas ?? createCanvas(maxWidth, 100); 53 | const textContext = textCanvas.getContext('2d'); 54 | 55 | // Set the text alignment and start position 56 | let textX = 0; 57 | let textY = 0; 58 | 59 | if (['center'].includes(textAlign.toLowerCase())) { 60 | textX = maxWidth / 2; 61 | } 62 | if (['right', 'end'].includes(textAlign.toLowerCase())) { 63 | textX = maxWidth; 64 | } 65 | textContext.textAlign = textAlign; 66 | 67 | // Set background color 68 | textContext.fillStyle = bgColor; 69 | textContext.fillRect(0, 0, textCanvas.width, textCanvas.height); 70 | 71 | // Set text styles 72 | textContext.fillStyle = textColor; 73 | textContext.font = `${fontWeight.toString()} ${fontSize.toString()}px ${fontFamily}`; 74 | textContext.textBaseline = 'top'; 75 | 76 | // Split the text into words 77 | const words = text.split(' '); 78 | let wordCount = words.length; 79 | 80 | // The start of the first line 81 | let line = ''; 82 | const addNewLines = []; 83 | 84 | for (let n = 0; n < wordCount; n += 1) { 85 | let word: string = words[n]; 86 | 87 | if (words[n].includes('\n')) { 88 | const parts = words[n].split('\n'); 89 | // Use the first word before the newline(s) 90 | // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing 91 | word = parts.shift() || ''; 92 | // Mark the next word as beginning with newline 93 | addNewLines.push(n + 1); 94 | // Return the rest of the parts to the words array at the same index 95 | words.splice(n + 1, 0, parts.join('\n')); 96 | wordCount += 1; 97 | } 98 | 99 | // Append one word to the line and see if its width exceeds the maxWidth. 100 | // Also trim the testLine since `line` will be empty in the 101 | // beginning, causing a leading white space character otherwise. 102 | // Use a negative lookbehind in the regex due to 103 | // https://owasp.org/www-community/attacks/Regular_expression_Denial_of_Service_-_ReDoS. 104 | const testLine = `${line} ${word}`.replace(/^ +|(? maxWidth && n > 0)) { 110 | // If the line exceeded the width with one additional word, just 111 | // paint the line without the word 112 | textContext.fillText(line, textX, textY); 113 | 114 | // Start a new line with the last word and add the following (if 115 | // this word was a newline word) 116 | line = word; 117 | 118 | // Move the pen down 119 | textY += lineHeight; 120 | } else { 121 | // If not exceeded, just continue 122 | line = testLine; 123 | } 124 | } 125 | 126 | // Paint the last line 127 | textContext.fillText(line, textX, textY); 128 | 129 | // Increase the size of the text layer by the line height 130 | // But in case the line height is less than the font size, we increase 131 | // by font size in order to prevent clipping. 132 | const height = textY + Math.max(lineHeight, fontSize); 133 | 134 | return { 135 | textHeight: height, 136 | textData: textContext.getImageData(0, 0, maxWidth, height), 137 | }; 138 | }; 139 | 140 | const createImageCanvas = (content: string, conf: ComputedOptions) => { 141 | // First pass: Measure the text so we can create a canvas big enough 142 | // to fit the text. 143 | // This has to be done since we can't resize the canvas on the fly 144 | // without losing the settings of the 2D context 145 | // https://github.com/Automattic/node-canvas/issues/1625 146 | const { textHeight } = createTextData( 147 | content, 148 | // Max width of text itself must be the image max width reduced by 149 | // left-right margins 150 | { 151 | maxWidth: conf.maxWidth - conf.margin * 2, 152 | fontSize: conf.fontSize, 153 | lineHeight: conf.lineHeight, 154 | bgColor: conf.bgColor, 155 | textColor: conf.textColor, 156 | fontFamily: conf.fontFamily, 157 | fontPath: conf.fontPath, 158 | fontWeight: conf.fontWeight, 159 | textAlign: conf.textAlign, 160 | } as ComputedOptions, 161 | ); 162 | 163 | const textHeightWithMargins = textHeight + conf.margin * 2; 164 | 165 | if (conf.customHeight && conf.customHeight < textHeightWithMargins) { 166 | console.warn('Text is longer than customHeight, clipping will occur.'); 167 | } 168 | 169 | // Second pass: We now know the height of the text on the canvas, so 170 | // let's create the final canvas with the given height and width and 171 | // pass that to createTextData so we can get the text data from it 172 | const height = conf.customHeight || textHeightWithMargins; 173 | const canvas = createCanvas(conf.maxWidth, height); 174 | 175 | const { textData } = createTextData( 176 | content, 177 | // Max width of text itself must be the image max width reduced by 178 | // left-right margins 179 | { 180 | maxWidth: conf.maxWidth - conf.margin * 2, 181 | fontSize: conf.fontSize, 182 | lineHeight: conf.lineHeight, 183 | bgColor: conf.bgColor, 184 | textColor: conf.textColor, 185 | fontFamily: conf.fontFamily, 186 | fontPath: conf.fontPath, 187 | fontWeight: conf.fontWeight, 188 | textAlign: conf.textAlign, 189 | } as ComputedOptions, 190 | canvas, 191 | ); 192 | const ctx = canvas.getContext('2d'); 193 | 194 | // The canvas will have the text from the first pass on it, so start by 195 | // clearing the whole canvas and start from a clean slate 196 | ctx.clearRect(0, 0, canvas.width, canvas.height); 197 | ctx.globalAlpha = 1; 198 | ctx.fillStyle = conf.bgColor; 199 | ctx.fillRect(0, 0, canvas.width, height); 200 | 201 | const textX = conf.margin; 202 | let textY = conf.margin; 203 | if (conf.customHeight && conf.verticalAlign === 'center') { 204 | textY = 205 | // Divide the leftover whitespace by 2 206 | (conf.customHeight - textData.height) / 2 + 207 | // Offset for the extra space under the last line to make bottom 208 | // and top whitespace equal. 209 | // But only up until the bottom of the text (i.e. don't consider a 210 | // line height less than the font size) 211 | Math.max(0, (conf.lineHeight - conf.fontSize) / 2); 212 | } 213 | 214 | ctx.putImageData(textData, textX, textY); 215 | 216 | return canvas; 217 | }; 218 | 219 | export const generate: GenerateFunction = async ( 220 | content, 221 | config, 222 | ) => { 223 | const conf: ComputedOptions = { 224 | ...defaults, 225 | ...config, 226 | }; 227 | const canvas = createImageCanvas(content, conf); 228 | 229 | const finalCanvas = await conf.extensions.reduce>( 230 | async (prevCanvasPromise, extension) => { 231 | const resolvedPrev = await prevCanvasPromise; 232 | return extension(resolvedPrev, conf); 233 | }, 234 | Promise.resolve(canvas), 235 | ); 236 | 237 | const dataUrl = finalCanvas.toDataURL(); 238 | return dataUrl; 239 | }; 240 | 241 | export const generateSync: GenerateFunctionSync = ( 242 | content, 243 | config, 244 | ) => { 245 | const conf: ComputedOptions = { ...defaults, ...config }; 246 | const canvas = createImageCanvas(content, conf); 247 | 248 | const finalCanvas = conf.extensions.reduce( 249 | (prevCanvas, extension) => { 250 | return extension(prevCanvas, conf); 251 | }, 252 | canvas, 253 | ); 254 | 255 | const dataUrl = finalCanvas.toDataURL(); 256 | return dataUrl; 257 | }; 258 | -------------------------------------------------------------------------------- /src/tests/textToImage.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import sizeOf from 'image-size'; 3 | import extractColors from './helpers/extractColors'; 4 | import { 5 | uriToBuf, 6 | readImageData, 7 | countWhitePixels, 8 | } from './helpers/readImageData'; 9 | import longInput from './helpers/longInput'; 10 | import { generate, generateSync } from '..'; 11 | import takeSnapshot from './helpers/takeSnapshot'; 12 | import { Canvas } from 'canvas'; 13 | import { ComputedOptions } from '../@types'; 14 | 15 | describe('the text-to-image generator', () => { 16 | it('should return a promise', () => { 17 | expect(generate('Hello world')).toBeInstanceOf(Promise); 18 | }); 19 | 20 | it('should have a sync version', () => { 21 | expect(typeof generateSync('Hello world')).toEqual('string'); 22 | }); 23 | 24 | it('should generate an image data url', async () => { 25 | const dataUri = await generate('Hello world'); 26 | 27 | expect(dataUri).toMatch(/^data:image\/png;base64/); 28 | 29 | takeSnapshot(dataUri); 30 | }); 31 | 32 | it("should generate equal width but longer png when there's plenty of text", async () => { 33 | const uri1 = await generate('Hello world'); 34 | const uri2 = await generate( 35 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut dolor eros, lobortis ac orci a, molestie sagittis libero.', 36 | ); 37 | 38 | const image1 = uriToBuf(uri1); 39 | const image2 = uriToBuf(uri2); 40 | 41 | const dimensions1 = sizeOf(image1); 42 | const dimensions2 = sizeOf(image2); 43 | 44 | expect(dimensions1.height).toBeGreaterThan(0); 45 | expect(dimensions1.height).toBeLessThan(dimensions2.height || 0); 46 | expect(dimensions1.width).toEqual(dimensions2.width); 47 | 48 | takeSnapshot(uri1); 49 | takeSnapshot(uri2); 50 | }); 51 | 52 | it('should create a new lines when a \\n occurrs', async () => { 53 | const uri1 = await generate('Hello world'); 54 | const uri2 = await generate('Hello world\nhello again'); 55 | 56 | const dimensions1 = sizeOf(uriToBuf(uri1)); 57 | const dimensions2 = sizeOf(uriToBuf(uri2)); 58 | 59 | expect(dimensions1.height).toBeGreaterThan(0); 60 | expect(dimensions1.height).toBeLessThan(dimensions2.height || 0); 61 | expect(dimensions1.width).toEqual(dimensions2.width); 62 | 63 | takeSnapshot(uri1); 64 | takeSnapshot(uri2); 65 | }); 66 | 67 | it('should create a new lines when a multiple \\n occurrs', async () => { 68 | const uri1 = await generate('Hello world\nhello again'); 69 | const uri2 = await generate('Hello world\n\n\nhello again'); 70 | 71 | const dimensions1 = sizeOf(uriToBuf(uri1)); 72 | const dimensions2 = sizeOf(uriToBuf(uri2)); 73 | 74 | expect(dimensions1.height).toBeGreaterThan(0); 75 | expect(dimensions1.height).toBeLessThan(dimensions2.height || 0); 76 | expect(dimensions1.width).toEqual(dimensions2.width); 77 | 78 | takeSnapshot(uri1); 79 | takeSnapshot(uri2); 80 | }); 81 | 82 | it('should default to a 400 px wide image', async () => { 83 | const uri = await generate('Lorem ipsum dolor sit amet.'); 84 | 85 | const dimensions = sizeOf(uriToBuf(uri)); 86 | 87 | expect(dimensions.width).toEqual(400); 88 | 89 | takeSnapshot(uri); 90 | }); 91 | 92 | it('should be configurable to use another image width', async () => { 93 | const uri = await generate('Lorem ipsum dolor sit amet.', { 94 | maxWidth: 500, 95 | }); 96 | 97 | const dimensions = sizeOf(uriToBuf(uri)); 98 | expect(dimensions.width).toEqual(500); 99 | 100 | takeSnapshot(uri); 101 | }); 102 | 103 | it('should default to a white background no transparency', async () => { 104 | const uri = await generate('Lorem ipsum dolor sit amet.'); 105 | 106 | const image = await readImageData(uriToBuf(uri)); 107 | 108 | expect(image.frames.length).toEqual(1); 109 | expect(image.frames[0].data[0]).toEqual(0xff); 110 | expect(image.frames[0].data[1]).toEqual(0xff); 111 | expect(image.frames[0].data[2]).toEqual(0xff); 112 | expect(image.frames[0].data[3]).toEqual(0xff); 113 | 114 | takeSnapshot(uri); 115 | }); 116 | 117 | it('should use the background color specified with no transparency', async () => { 118 | const uri = await generate('Lorem ipsum dolor sit amet.', { 119 | bgColor: '#001122', 120 | }); 121 | 122 | const image = await readImageData(uriToBuf(uri)); 123 | 124 | expect(image.frames.length).toEqual(1); 125 | expect(image.frames[0].data[0]).toEqual(0x00); 126 | expect(image.frames[0].data[1]).toEqual(0x11); 127 | expect(image.frames[0].data[2]).toEqual(0x22); 128 | expect(image.frames[0].data[3]).toEqual(0xff); 129 | 130 | takeSnapshot(uri); 131 | }); 132 | 133 | it('should default to a black text color', async () => { 134 | const WIDTH = 720; 135 | const HEIGHT = 220; 136 | 137 | const uri = await generate('Lorem ipsum dolor sit amet.', { 138 | maxWidth: WIDTH, 139 | fontSize: 100, 140 | lineHeight: 100, 141 | }); 142 | 143 | const imageData = uriToBuf(uri); 144 | 145 | const dimensions = sizeOf(imageData); 146 | expect(dimensions.width).toEqual(WIDTH); 147 | expect(dimensions.height).toEqual(HEIGHT); 148 | 149 | const image = await readImageData(imageData); 150 | const map = extractColors(image); 151 | 152 | // GIMP reports 256 colors on this image 153 | expect(Object.keys(map).length).toBeGreaterThanOrEqual(2); 154 | expect(Object.keys(map).length).toBeLessThanOrEqual(256); 155 | expect(map['#000000']).toBeGreaterThan(10); 156 | expect(map['#ffffff']).toBeGreaterThan(100); 157 | 158 | takeSnapshot(uri); 159 | }); 160 | 161 | it('should use the text color specified', async () => { 162 | const WIDTH = 720; 163 | const HEIGHT = 220; 164 | 165 | const uri = await generate('Lorem ipsum dolor sit amet.', { 166 | maxWidth: WIDTH, 167 | fontSize: 100, 168 | lineHeight: 100, 169 | textColor: '#112233', 170 | }); 171 | 172 | const imageData = uriToBuf(uri); 173 | 174 | const dimensions = sizeOf(imageData); 175 | expect(dimensions.width).toEqual(WIDTH); 176 | expect(dimensions.height).toEqual(HEIGHT); 177 | 178 | const image = await readImageData(imageData); 179 | const map = extractColors(image); 180 | 181 | // GIMP reports 256 colors on this image 182 | expect(Object.keys(map).length).toBeGreaterThanOrEqual(2); 183 | expect(Object.keys(map).length).toBeLessThanOrEqual(256); 184 | expect(map['#000000']).toBeUndefined(); 185 | expect(map['#112233']).toBeGreaterThan(10); 186 | expect(map['#ffffff']).toBeGreaterThan(100); 187 | 188 | takeSnapshot(uri); 189 | }); 190 | 191 | it('should use the font weight specified', async () => { 192 | const uri1 = await generate('Lorem ipsum dolor sit amet.', { 193 | fontWeight: 'bold', 194 | }); 195 | const uri2 = await generate('Lorem ipsum dolor sit amet.', { 196 | fontWeight: 'normal', 197 | }); 198 | 199 | const boldImg = await readImageData(uriToBuf(uri1)); 200 | const normalImg = await readImageData(uriToBuf(uri2)); 201 | const boldMap = extractColors(boldImg); 202 | const normalMap = extractColors(normalImg); 203 | 204 | // Check that we have more black and less white in the bold text image 205 | expect(boldMap['#000000']).toBeGreaterThan(normalMap['#000000']); 206 | expect(boldMap['#ffffff']).toBeLessThan(normalMap['#ffffff']); 207 | 208 | takeSnapshot(uri1); 209 | takeSnapshot(uri2); 210 | }); 211 | 212 | it('should support right aligning text', async () => { 213 | const uri = await generate('Lorem ipsum dolor sit amet.', { 214 | textAlign: 'right', 215 | }); 216 | 217 | const rightAlignData = await readImageData(uriToBuf(uri)); 218 | 219 | // Expect all pixels on left side (up to 150px) to be white 220 | const whitePixels = countWhitePixels( 221 | rightAlignData, 222 | 0, 223 | 0, 224 | 150, 225 | rightAlignData.height, 226 | ); 227 | expect(whitePixels).toBe(rightAlignData.height * 150); 228 | 229 | // Expect some pixels on right side (from 150px) include non-white 230 | const nonWhitePixels = countWhitePixels( 231 | rightAlignData, 232 | 150, 233 | 0, 234 | rightAlignData.width, 235 | rightAlignData.height, 236 | ); 237 | expect(nonWhitePixels).toBeLessThan(rightAlignData.height * 250); 238 | 239 | takeSnapshot(uri); 240 | }); 241 | 242 | it('should support left aligning text', async () => { 243 | const uri = await generate('Lorem ipsum dolor sit amet.', { 244 | textAlign: 'left', 245 | }); 246 | 247 | const leftAlignData = await readImageData(uriToBuf(uri)); 248 | 249 | // Expect all pixels on right side (from 250px) to be white 250 | const whitePixels = countWhitePixels( 251 | leftAlignData, 252 | 250, 253 | 0, 254 | leftAlignData.width, 255 | leftAlignData.height, 256 | ); 257 | expect(whitePixels).toBe(leftAlignData.height * 150); 258 | 259 | // Expect some pixels on left side (up to 250px) to be non-white 260 | const nonWhitePixels = countWhitePixels( 261 | leftAlignData, 262 | 0, 263 | 0, 264 | 250, 265 | leftAlignData.height, 266 | ); 267 | expect(nonWhitePixels).toBeLessThan(leftAlignData.height * 250); 268 | 269 | takeSnapshot(uri); 270 | }); 271 | 272 | it('should support center aligning text', async () => { 273 | const uri = await generate('Lorem ipsum dolor sit amet.', { 274 | textAlign: 'center', 275 | }); 276 | 277 | const centerAlignData = await readImageData(uriToBuf(uri)); 278 | 279 | // Expect all pixels on left side (up to 80px) to be white 280 | const leftWhitePixels = countWhitePixels( 281 | centerAlignData, 282 | 0, 283 | 0, 284 | 80, 285 | centerAlignData.height, 286 | ); 287 | expect(leftWhitePixels).toBe(centerAlignData.height * 80); 288 | 289 | // Expect all pixels on right side (last 80px) to be white 290 | const rightWhitePixels = countWhitePixels( 291 | centerAlignData, 292 | centerAlignData.width - 80, 293 | 0, 294 | centerAlignData.width, 295 | centerAlignData.height, 296 | ); 297 | expect(rightWhitePixels).toBe(centerAlignData.height * 80); 298 | 299 | // Expect some pixels in the center (between 80 and width-80) to be non-white 300 | const centerWhitePixels = countWhitePixels( 301 | centerAlignData, 302 | 80, 303 | 0, 304 | centerAlignData.width - 80, 305 | centerAlignData.height, 306 | ); 307 | expect(centerWhitePixels).toBeLessThan( 308 | centerAlignData.height * (centerAlignData.width - 160), 309 | ); 310 | 311 | takeSnapshot(uri); 312 | }); 313 | 314 | it('should support custom height', async () => { 315 | const uri = await generate('Lorem ipsum dolor sit amet.', { 316 | customHeight: 100, 317 | }); 318 | 319 | const customHeight = await readImageData(uriToBuf(uri)); 320 | 321 | expect(customHeight.height).toEqual(100); 322 | 323 | takeSnapshot(uri); 324 | }); 325 | 326 | it('should warn if the text is longer than customHeight', async () => { 327 | const consoleSpy = jest.spyOn(console, 'warn'); 328 | consoleSpy.mockImplementation(() => undefined); 329 | 330 | await generate( 331 | 'Lorem ipsum dolor sit amet. Saturation point fluidity ablative weathered sunglasses soul-delay vehicle dolphin neon fetishism 3D-printed gang.', 332 | { 333 | customHeight: 20, 334 | }, 335 | ); 336 | 337 | expect(consoleSpy).toHaveBeenCalledWith( 338 | 'Text is longer than customHeight, clipping will occur.', 339 | ); 340 | }); 341 | 342 | it('should support vertical align', async () => { 343 | const uri = await generate('Lorem ipsum dolor sit amet.', { 344 | textAlign: 'center', 345 | verticalAlign: 'center', 346 | customHeight: 100, 347 | }); 348 | 349 | const verticalCenter = await readImageData(uriToBuf(uri)); 350 | 351 | // First 35 pixel rows should be white 352 | const topWhitePixels = countWhitePixels( 353 | verticalCenter, 354 | 0, 355 | 0, 356 | verticalCenter.width, 357 | 35, 358 | ); 359 | expect(topWhitePixels).toBe(verticalCenter.width * 35); 360 | 361 | // Middle pixel rows should contain non-whites 362 | const centerWhitePixels = countWhitePixels( 363 | verticalCenter, 364 | 0, 365 | 35, 366 | verticalCenter.width, 367 | verticalCenter.height - 35, 368 | ); 369 | expect(centerWhitePixels).toBeLessThan( 370 | verticalCenter.width * (verticalCenter.height - 70), 371 | ); 372 | 373 | // Bottom 35 rows should be white 374 | const bottomWhitePixels = countWhitePixels( 375 | verticalCenter, 376 | 0, 377 | verticalCenter.height - 35, 378 | verticalCenter.width, 379 | verticalCenter.height, 380 | ); 381 | expect(bottomWhitePixels).toBe(verticalCenter.width * 35); 382 | 383 | takeSnapshot(uri); 384 | }); 385 | 386 | it('should support custom font paths', async () => { 387 | const uri = await generate('S', { 388 | // Use a font that renders a black square with the 'S' character 389 | fontPath: path.resolve(__dirname, 'helpers', 'heydings_controls.ttf'), 390 | fontFamily: 'Heydings Controls', 391 | margin: 0, 392 | }); 393 | 394 | const customFontData = await readImageData(uriToBuf(uri)); 395 | // Check that we only have black pixels in the rendered square 396 | const whitePixels = countWhitePixels(customFontData, 5, 9, 13, 17); 397 | expect(whitePixels).toBe(0); 398 | 399 | takeSnapshot(uri); 400 | }); 401 | 402 | it('should support very long inputs', async () => { 403 | const uri = await generate(longInput, {}); 404 | 405 | const imageData = await readImageData(uriToBuf(uri)); 406 | expect(imageData.height).toBeGreaterThan(3000); 407 | 408 | takeSnapshot(uri); 409 | }); 410 | 411 | it('should support leading tabs', async () => { 412 | const uri = await generate( 413 | `\tDuis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. Maecenas sed diam eget risus varius blandit sit amet non magna. Donec id elit non mi porta gravida at eget metus. \n\tAenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum.`, 414 | ); 415 | 416 | const imageData = await readImageData(uriToBuf(uri)); 417 | // Check that we only have only white pixels in the top left corner 418 | const whitePixels1 = countWhitePixels(imageData, 0, 0, 35, 35); 419 | expect(whitePixels1).toBe(35 * 35); 420 | 421 | const whitePixels2 = countWhitePixels(imageData, 0, 145, 35, 175); 422 | expect(whitePixels2).toBe(35 * (175 - 145)); 423 | 424 | takeSnapshot(uri); 425 | }); 426 | 427 | it('should support leading non-breaking spaces', async () => { 428 | const uri = await generate( 429 | `\xA0\xA0\xA0Duis mollis, est non commodo luctus, nisi erat porttitor ligula, eget lacinia odio sem nec elit. \n\xA0\xA0\xA0\xA0\xA0Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis vestibulum.`, 430 | ); 431 | 432 | const imageData = await readImageData(uriToBuf(uri)); 433 | // Check that we only have only white pixels in the top left corner 434 | const whitePixels1 = countWhitePixels(imageData, 0, 0, 20, 30); 435 | expect(whitePixels1).toBe(20 * 30); 436 | 437 | const whitePixels2 = countWhitePixels(imageData, 0, 90, 30, 120); 438 | expect(whitePixels2).toBe(30 * 30); 439 | 440 | takeSnapshot(uri); 441 | }); 442 | 443 | it('should trim spaces between words', async () => { 444 | const spacesURI = await generate( 445 | 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt', 446 | { 447 | maxWidth: 400, 448 | }, 449 | ); 450 | const nonBreakingSpacesURI = await generate( 451 | `Lorem\xA0\xA0\xA0\xA0\xA0ipsum\xA0\xA0\xA0\xA0\xA0dolor\xA0\xA0\xA0\xA0\xA0sit\xA0\xA0\xA0\xA0\xA0amet\xA0\xA0\xA0\xA0\xA0consectetur\xA0\xA0\xA0\xA0\xA0adipiscing\xA0\xA0\xA0\xA0\xA0elit\xA0\xA0\xA0\xA0\xA0sed\xA0\xA0\xA0\xA0\xA0do\xA0\xA0\xA0\xA0\xA0eiusmod\xA0\xA0\xA0\xA0\xA0tempor\xA0\xA0\xA0\xA0\xA0incididunt`, 452 | { 453 | maxWidth: 400, 454 | }, 455 | ); 456 | 457 | const imageData1 = await readImageData(uriToBuf(spacesURI)); 458 | const imageData2 = await readImageData(uriToBuf(nonBreakingSpacesURI)); 459 | // Expect image1 to be higher than image 2, since we have 460 | // a fixed max width image and non breaking spaces 461 | // won't wrap onto multiple lines 462 | expect(imageData1.height).toBeGreaterThan(imageData2.height); 463 | 464 | // The image with non breaking spaces should have more non-white pixels 465 | // than the image with regular spaces, since they're removed 466 | const whitePixels1 = countWhitePixels(imageData1, 0, 0, 85, 25); 467 | const whitePixels2 = countWhitePixels(imageData2, 0, 0, 85, 25); 468 | 469 | expect(whitePixels2).toBeGreaterThan(whitePixels1); 470 | 471 | takeSnapshot(spacesURI); 472 | takeSnapshot(nonBreakingSpacesURI); 473 | }); 474 | 475 | it('should not duplicate text with transparent background', async () => { 476 | const uri = await generate(`Cases in Kanyakum district`, { 477 | customHeight: 900, 478 | verticalAlign: 'center', 479 | bgColor: 'transparent', 480 | }); 481 | 482 | const { 483 | frames: [{ data }], 484 | } = await readImageData(uriToBuf(uri)); 485 | // The top 100 pixel rows should not have any data 486 | const topRowsData = data.slice(0, 400 * 100 * 4); // 400px wide, 100px high, 4 values per pixel 487 | const rgbaSum = topRowsData.reduce((acc, cur) => acc + cur, 0); 488 | 489 | expect(rgbaSum).toBe(0); 490 | }); 491 | 492 | it('should support extensions', async () => { 493 | expect.assertions(2); 494 | await generate('Lorem ipsum dolor sit amet.', { 495 | customHeight: 200, 496 | extensions: [ 497 | (canvas: Canvas, conf: ComputedOptions) => { 498 | expect(canvas.height).toBe(200); 499 | expect(conf.customHeight).toBe(200); 500 | return canvas; 501 | }, 502 | ], 503 | }); 504 | }); 505 | }); 506 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Text to image 2 | 3 | [![Code Coverage](https://img.shields.io/coveralls/bostrom/text-to-image.svg)](https://coveralls.io/github/bostrom/text-to-image) 4 | [![Npm Version](https://img.shields.io/npm/v/text-to-image.svg)](https://www.npmjs.com/package/text-to-image) 5 | [![License](https://img.shields.io/badge/license-ISC-blue.svg)](https://opensource.org/licenses/ISC) 6 | 7 | A library for generating an image data URI representing an image containing the text of your choice. 8 | 9 | Originally part of a Twitter bot for publishing tweets longer than 140 characters, the generator takes a string, an optional configuration object, and an optional callback function as arguments and produces an image data URI (`data:image/png;base64,iVBORw0KGgoAAAA...`). 10 | 11 | ## Table of contents 12 | 13 | - [Try it](#try-it) 14 | - [Installation](#installation) 15 | - [Function signature](#function-signature) 16 | - [Usage](#usage) 17 | - [Text formatting rules](#text-formatting-rules) 18 | - [Configuring (GenerateOptions)](#configuring-generateoptions) 19 | - [Extensions](#extensions) 20 | - [Debugging with the `fileWriter` extension](#debugging-with-the-filewriter-extension) 21 | - [Creating speech bubbles with the `bubbleTail` extension](#creating-speech-bubbles-with-the-bubbletail-extension) 22 | - [Typescript](#typescript) 23 | - [Test](#test) 24 | - [Contributing](#contributing) 25 | - [License](#license) 26 | 27 | ## Try it 28 | 29 | Use [this CodeSandbox](https://codesandbox.io/p/sandbox/gtcmdy) to try out text-to-image in your browser. 30 | 31 | ## Installation 32 | 33 | npm i text-to-image 34 | 35 | > Note that text-to-image uses [node-canvas](https://github.com/Automattic/node-canvas) to generate the images. For text-to-image to install, you might have to fulfill the [installation requirements for node-canvas](https://github.com/Automattic/node-canvas#installation). Please refer to their documentation for instructions. 36 | 37 | ## Function signature 38 | 39 | The signature for both the syncronous and asyncronous functions is 40 | 41 | ```typescript 42 | (content: string, config?: GenerateOptions) => string | Promise; 43 | ``` 44 | 45 | See [below](#generateoptions) for documentation on the function arguments. 46 | 47 | ## Usage 48 | 49 | ```typescript 50 | import { generate, generateSync } from 'text-to-image'; 51 | 52 | // using the asynchronous API with await 53 | const dataUri = await generate('Lorem ipsum dolor sit amet'); 54 | 55 | // using the asynchronous API with .then 56 | generate('Lorem ipsum dolor sit amet').then(function (dataUri) { 57 | // use the dataUri 58 | }); 59 | 60 | // using the synchronous API 61 | const dataUri = generateSync('Lorem ipsum dolor sit amet'); 62 | ``` 63 | 64 | This is an example of a full dataUri generated by the above examples: 65 | 66 | ``` 67 | data:image/png;base64,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 68 | ``` 69 | 70 | Copy that line into the address field of your browser and you should see the generated image. 71 | 72 | With the default options the image generator will adjust the height of the image automatically to fit all text, while the width of the image is fixed to the specified width. 73 | 74 | ## Text formatting rules 75 | 76 | Normal spaces are stripped from the beginning of paragraphs. To add leading spaces, either use tab or non-breaking space characters. 77 | 78 | Line breaks can be added with `\n`. 79 | 80 | Example: 81 | 82 | ```typescript 83 | import { generate } from 'text-to-image'; 84 | 85 | // Add indent as tabs 86 | const tabbedText = await generate( 87 | '\tDonec id elit non mi porta gravida at eget metus. \n\tSed posuere consectetur est at lobortis.', 88 | ); 89 | 90 | // Add indent as non-breaking spaces 91 | const spacedText = await generate( 92 | '\xA0\xA0Donec id elit non mi porta gravida at eget metus. \n\xA0\xA0Sed posuere consectetur est at lobortis.', 93 | ); 94 | ``` 95 | 96 | ## Configuring (GenerateOptions) 97 | 98 | The `generate` and `generateSync` functions take an optional second parameter containing configuration options for the image generation. All configuraion parameters are optional. 99 | 100 | > Note that the supplied configuration values are **not validated**. Invalid values may lead to unexpected results or the image not getting generated at all. For color value validations, consider using a library like [validate-color](https://github.com/dreamyguy/validate-color) before passing the value to this library. 101 | 102 | The available options are as follows. 103 | 104 | | Name | Type | Default value | Description | 105 | | ------------- | ----------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 106 | | bgColor | string \| CanvasGradient \| CanvasPattern | #FFFFFF | Sets the background color of the image. See [CanvasRenderingContext2D.fillStyle](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillStyle) for valid values, or use a color value validator (see note above). | 107 | | customHeight | number | 0 | Sets the height of the generated image in pixels. If falsy, will automatically calculate the height based on the amount of text. | 108 | | extensions | Array<Extension> | [] | An array of [Extensions](#extensions). | 109 | | fontFamily | string | Helvetica | The font family to use for the text in the image. See [CSS font-family](https://developer.mozilla.org/en-US/docs/Web/CSS/font-family) for valid values. | 110 | | fontPath | string | | The file system path to a font file to use, also specify `fontFamily` if you use this. | 111 | | fontSize | number | 18 | The font size to use for the text in the image. See [CSS font-size](https://developer.mozilla.org/en-US/docs/Web/CSS/font-size) for valid values. | 112 | | fontWeight | string \| number | normal | The font weight to use for the text in the image. See [CSS font-weight](https://developer.mozilla.org/en-US/docs/Web/CSS/font-weight) for valid values. | 113 | | lineHeight | number | 28 | The line height for the generated text. | 114 | | margin | number | 10 | The margin (all sides) between the text and the border of the image. | 115 | | maxWidth | number | 400 | Sets the width of the generated image in pixels. | 116 | | textAlign | string | left | The text alignment for the generated text. See [CanvasRenderingContext2D.textAlign](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/textAlign) for valid values. | 117 | | textColor | string | #000000 | Sets the text color. See [CanvasRenderingContext2D.fillStyle](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/fillStyle) for valid values, or use a color value validator (see note above). | 118 | | verticalAlign | string | top | Use to set center height with `customHeight` (possible values: `top`, `center`). | 119 | 120 | Example: 121 | 122 | ```typescript 123 | import { generate } from 'text-to-image'; 124 | 125 | const dataUri = await generate('Lorem ipsum dolor sit amet', { 126 | maxWidth: 720, 127 | fontSize: 18, 128 | fontFamily: 'Arial', 129 | lineHeight: 30, 130 | margin: 5, 131 | bgColor: 'blue', 132 | textColor: 'red', 133 | }); 134 | ``` 135 | 136 | ## Extensions 137 | 138 | Extensions are a form of middleware that can be used to produce side-effects or manipulate the image beyond the core functionality _before_ the final data URL is formed and returned. An extension is a function that takes the `Canvas` instance and a `ComputedOptions` object (the original configuration object augmented with defaults for omitted values) as arguments and returns a `Canvas`. 139 | 140 | ```typescript 141 | (canvas: Canvas, conf: ComputedOptions) => Canvas | Promise; 142 | ``` 143 | 144 | Each extension will receive the canvas returned by the previous extension. The canvas returned by the last extension will be used when producing the final data URL. 145 | 146 | > Note that all extensions for `generateSync` must be synchronous, too. If your extension contains asyncronous code, use the async `generate` function instead. 147 | 148 | The following example extension draws a border around the image 5px from the edge using the configured text color. 149 | 150 | ```typescript 151 | // All types from 'node-canvas' are re-exported for convenience 152 | import { Canvas, ComputedOptions, generate } from 'text-to-image'; 153 | 154 | const makeBorder = (canvas: Canvas, conf: ComputedOptions) => { 155 | const { width, height } = canvas; 156 | const ctx = canvas.getContext('2d'); 157 | ctx.strokeStyle = conf.textColor; 158 | ctx.strokeRect(5, 5, width - 10, height - 10); 159 | return canvas; 160 | }; 161 | 162 | const uri = await generate('Lorem ipsum dolor sit amet', { 163 | textColor: 'green', 164 | extensions: [makeBorder], 165 | }); 166 | ``` 167 | 168 | If your extension needs to take some configuration parameters, then it's advisable to create an extension factory function. The following example extension factory takes the padding size as argument and returns an extension. 169 | 170 | ```typescript 171 | const makeBorder = 172 | (padding: number) => (canvas: Canvas, conf: ComputedOptions) => { 173 | const { width, height } = canvas; 174 | const ctx = canvas.getContext('2d'); 175 | ctx.strokeStyle = conf.textColor; 176 | ctx.strokeRect(padding, padding, width - padding * 2, height - padding * 2); 177 | return canvas; 178 | }; 179 | 180 | const uri = await generate('Lorem ipsum dolor sit amet', { 181 | textColor: 'green', 182 | extensions: [makeBorder(2)], 183 | }); 184 | ``` 185 | 186 | ### Debugging with the `fileWriter` extension 187 | 188 | Without configuration the `fileWriter` extension saves the current canvas as a PNG in the current working directory (where the process was started). The name of the image will be the current date and time. This facilitates debugging the image generation as the produced image can be viewed as an image file instead of a data URL. 189 | 190 | ```typescript 191 | import { generate } from 'text-to-image'; 192 | import fileWriter from 'text-to-image/extensions/fileWriter'; 193 | 194 | const dataUri = await generate('Lorem ipsum dolor sit amet', { 195 | textColor: 'red', 196 | extensions: [fileWriter()], 197 | }); 198 | ``` 199 | 200 | For more control over the file name and location, specify the `fileName` option to the `fileWriter`. The `fileName` can include path segments in addition to the file name. A relative path (or only a file name without path) will be resolved starting from the current working directory. Any missing parent directories to the file will be created as needed. 201 | 202 | Example: 203 | 204 | ```typescript 205 | import path from 'path'; 206 | import { generate } from 'text-to-image'; 207 | import fileWriter from 'text-to-image/extensions/fileWriter'; 208 | 209 | const dataUri = await generate('Lorem ipsum dolor sit amet', { 210 | textColor: 'red', 211 | extensions: [ 212 | fileWriter({ 213 | fileName: path.join('some', 'custom', 'path', 'to', 'debug_file.png'), 214 | }), 215 | ], 216 | }); 217 | ``` 218 | 219 | This will create the debug file `some/custom/path/to/debug_file.png` in the current working directory. 220 | 221 | ### Creating speech bubbles with the `bubbleTail` extension 222 | 223 | The `bubbleTail` extension will draw a speech bubble "tail", a triangle at the bottom of the image. It takes the desired width and height of the tail as configuration parameters. 224 | 225 | ```typescript 226 | import { generate } from 'text-to-image'; 227 | import bubbleTail from 'text-to-image/extensions/bubbleTail'; 228 | 229 | const dataUri = await generate('Lorem ipsum dolor sit amet', { 230 | textColor: 'red', 231 | extensions: [bubbleTail({ width: 30, height: 20 })], 232 | }); 233 | ``` 234 | 235 | ## Typescript 236 | 237 | For imports to work correctly with TypeScript, make sure you're using **typescript version 4.7 or higher** and have the following configuration in your `tsconfig.json`: 238 | 239 | ```json 240 | { 241 | "compilerOptions": { 242 | "module": "Node16", 243 | "moduleResolution": "Node16" 244 | } 245 | } 246 | ``` 247 | 248 | See discussion in https://github.com/microsoft/TypeScript/issues/33079 for more information. 249 | 250 | ## Test 251 | 252 | The library is tested using Jest. Run the test suit by executing 253 | 254 | ``` 255 | npm test 256 | ``` 257 | 258 | A coverage report will be generated in `coverage/`. 259 | 260 | ## Contributing 261 | 262 | Pull requests are welcome. Read the [Contributing guidelines](./CONTRIBUTING.md). 263 | 264 | ## License 265 | 266 | [ISC License (ISC)](./LICENSE) 267 | --------------------------------------------------------------------------------