diff --git a/README.md b/README.md index 0a0369bf..cb568f4f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Transforming Your Canvas with Multiline Magic ✨ ## Features - [x] Multiline text +- [x] Rich text formatting (with the exception of words with different font _sizes_ not yet working well in terms of text baseline alignment) - [x] Auto line breaks - [x] Horizontal Align - [x] Vertical Align @@ -35,11 +36,9 @@ See Demo: [Here](https://canvas-txt.geongeorge.com) ## Install -``` +```bash yarn add canvas-txt - -or - +# or npm i canvas-txt ``` @@ -59,9 +58,9 @@ const ctx = c.getContext('2d') ctx.clearRect(0, 0, 500, 500) -const txt = 'Lorem ipsum dolor sit amet' +const text = 'Lorem ipsum dolor sit amet' -const { height } = drawText(ctx, txt, { +const { height } = drawText(ctx, text, { x: 100, y: 200, width: 200, @@ -69,7 +68,7 @@ const { height } = drawText(ctx, txt, { fontSize: 24, }) -console.log(`Total height = ${height}`) +console.log(`Total height = ${height}px`) ``` ## Node canvas @@ -89,9 +88,9 @@ const fs = require('fs') function main() { const canvas = createCanvas(400, 400) const ctx = canvas.getContext('2d') - const txt = 'Hello World!' + const text = 'Hello World!' - const { height } = drawText(ctx, txt, { + const { height } = drawText(ctx, text, { x: 100, y: 200, width: 200, @@ -102,7 +101,7 @@ function main() { // Convert the canvas to a buffer in PNG format const buffer = canvas.toBuffer('image/png') fs.writeFileSync('output.png', buffer) - console.log(`Total height = ${height}`) + console.log(`Total height = ${height}px`) } main() @@ -123,7 +122,7 @@ const { drawText, getTextHeight, splitText } = window.canvasTxt ![](./src/docs/canvas.jpg) -## Properties +## drawText config properties | Properties | Default | Description | | :-----------: | :----------: | :----------------------------------------------------------------------------- | @@ -137,19 +136,38 @@ const { drawText, getTextHeight, splitText } = window.canvasTxt | `font` | `Arial` | Font family of the text | | `fontSize` | `14` | Font size of the text in px | | `fontStyle` | `''` | Font style, same as css font-style. Examples: `italic`, `oblique 40deg` | -| `fontVariant` | `''` | Font variant, same as css font-variant. Examples: `small-caps`, `slashed-zero` | -| `fontWeight` | `''` | Font weight, same as css font-weight. Examples: `bold`, `100` | -| `lineHeight` | `null` | Line height of the text, if set to null it tries to auto-detect the value | +| `fontVariant` | `''` | Font variant, same as css font-variant. Examples: `small-caps` | +| `fontWeight` | `'400'` | Font weight, same as css font-weight. Examples: `bold`, `100` | +| `fontColor` | `'black'` | Font color, same as css color. Examples: `blue`, `#00ff00` | | `justify` | `false` | Justify text if `true`, it will insert spaces between words when necessary. | +| `inferWhitespace` | `true` | If whitespace in the text should be inferred. Only applies if the text given to `drawText()` is a `Word[]`. If the text is a `string`, this config setting is ignored. | ## Methods ```js -import { drawText, splitText, getTextHeight } from 'canvas-txt' +import { + drawText, + specToJson, + wordsToJson, + splitText, + splitWords, + textToWords, + getTextHeight, + getWordHeight, + getTextStyle, + getTextFormat, +} from 'canvas-txt' ``` -| Method | Description | -| :---------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `drawText(ctx,text, config)` | To draw the text to the canvas | -| `splitText({ ctx, text, justify, width }` | To split the text `{ ctx: CanvasRenderingContext2D, text: string, justify: boolean, width: number }` | -| `getTextHeight({ ctx, text, style })` | To get the height of the text `{ ctx: CanvasRenderingContext2D, text: string, style: string (font style we pass to ctx.font) }` [ctx.font docs](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/font) | +- `drawText()`: Draws text (`string` or `Word[]`) to a given Canvas. +- `specToJson()`: Converts a `RenderSpec` to a JSON string. Useful for sending it as a message through `Worker.postMessage()`. +- `wordsToJson()`: Converts a `Word[]` to a JSON string. Useful for sending it as a message to a Worker thread via `Worker.postMessage()`. +- `splitText()`: Splits a given `string` into wrapped lines. +- `splitWords()`: Splits a given `Word[]` into wrapped lines. +- `textToWords()`: Converts a `string` into a `Word[]`. Useful if you want to then apply rich formatting to certain words. +- `getTextHeight()`: Gets the measured height of a given `string` using a given text style. +- `getWordHeight()`: Gets the measured height of a given `Word` using its text style. +- `getTextStyle()`: Generates a CSS Font `string` from a given `TextFormat` for use with `canvas.getContext('2d').font` +- `getTextFormat()`: Generates a "full" `TextFormat` object (all properties specified) given one with only partial properties using prescribed defaults. + +TypeScript integration should provide helpful JSDocs for every function and each of its parameters to further help with their use. diff --git a/config/vite.config.ts b/config/vite.config.ts index cea52ed4..e97d2df4 100644 --- a/config/vite.config.ts +++ b/config/vite.config.ts @@ -4,10 +4,12 @@ export default defineConfig({ root: 'src', build: { outDir: '../dist', + sourcemap: true, lib: { entry: 'canvas-txt/index.ts', name: 'canvasTxt', - fileName: 'canvas-txt', + formats: ['es', 'umd'], + fileName: (format) => `canvas-txt${format === 'es' ? '.esm.min.js' : '.umd.min.js' }`, }, }, }) diff --git a/package.json b/package.json index 022b41f2..eefab705 100644 --- a/package.json +++ b/package.json @@ -3,23 +3,25 @@ "version": "4.1.1", "description": "Render multiline textboxes in HTML5 canvas with auto line breaks and better alignment system", "files": [ - "dist" + "dist", + "LICENSE", + "README.md" ], "scripts": { "dev": "vite serve --config config/vite.config.docs.ts", - "dev:node": "vite-node ./src/node-test.ts", - "build": "tsc && vite build --emptyOutDir --config config/vite.config.ts && yarn build:dts", + "dev:node": "vite-node ./src/node-test.ts", + "build": "tsc && vite build --emptyOutDir --config config/vite.config.ts && yarn build:dts && cp ./dist/canvas-txt.esm.min.js ./dist/canvas-txt.mjs", "build:dts": "tsup src/canvas-txt/index.ts --dts-only && mv dist/index.d.ts dist/canvas-txt.d.ts", "build:docs": "tsc && vite build --config config/vite.config.docs.ts", "prepare": "yarn build" }, - "main": "./dist/canvas-txt.umd.js", - "module": "./dist/canvas-txt.mjs", + "main": "./dist/canvas-txt.umd.min.js", + "module": "./dist/canvas-txt.esm.min.js", "types": "./dist/canvas-txt.d.ts", "exports": { ".": { - "import": "./dist/canvas-txt.mjs", - "require": "./dist/canvas-txt.umd.js", + "import": "./dist/canvas-txt.esm.min.js", + "require": "./dist/canvas-txt.umd.min.js", "types": "./dist/canvas-txt.d.ts" } }, @@ -43,6 +45,7 @@ ], "devDependencies": { "@types/lodash": "^4.14.182", + "@types/offscreencanvas": "^2019.7.3", "@vitejs/plugin-vue": "^3.0.1", "canvas": "^2.11.2", "element-plus": "^2.2.12", diff --git a/src/canvas-txt/index.ts b/src/canvas-txt/index.ts index e8f27ffe..82fb8919 100644 --- a/src/canvas-txt/index.ts +++ b/src/canvas-txt/index.ts @@ -1,108 +1,85 @@ -import splitText from './lib/split-text' -import getTextHeight from './lib/text-height' - -export interface CanvasTextConfig { - width: number - height: number - x: number - y: number - debug?: boolean - align?: 'left' | 'center' | 'right' - vAlign?: 'top' | 'middle' | 'bottom' - fontSize?: number - fontWeight?: string - fontStyle?: string - fontVariant?: string - font?: string - lineHeight?: number - justify?: boolean -} - -const defaultConfig = { - debug: false, - align: 'center', - vAlign: 'middle', - fontSize: 14, - fontWeight: '', - fontStyle: '', - fontVariant: '', - font: 'Arial', - lineHeight: null, - justify: false, -} +import { specToJson, splitWords, splitText, textToWords, wordsToJson } from './lib/split-text' +import { getTextHeight, getWordHeight } from './lib/text-height' +import { getTextStyle, getTextFormat, DEFAULT_FONT_COLOR } from './lib/get-style' +import { CanvasRenderContext, CanvasTextConfig, Text } from './lib/models' function drawText( - ctx: CanvasRenderingContext2D, - myText: string, - inputConfig: CanvasTextConfig + ctx: CanvasRenderContext, + text: Text, + config: CanvasTextConfig ) { - const { width, height, x, y } = inputConfig - const config = { ...defaultConfig, ...inputConfig } - - if (width <= 0 || height <= 0 || config.fontSize <= 0) { - //width or height or font size cannot be 0 - return { height: 0 } - } - - // End points - const xEnd = x + width - const yEnd = y + height - - const { fontStyle, fontVariant, fontWeight, fontSize, font } = config - const style = `${fontStyle} ${fontVariant} ${fontWeight} ${fontSize}px ${font}` - ctx.font = style - - let txtY = y + height / 2 + config.fontSize / 2 - - let textAnchor: number - - if (config.align === 'right') { - textAnchor = xEnd - ctx.textAlign = 'right' - } else if (config.align === 'left') { - textAnchor = x - ctx.textAlign = 'left' - } else { - textAnchor = x + width / 2 - ctx.textAlign = 'center' - } + const baseFormat = getTextFormat({ + fontFamily: config.fontFamily, + fontSize: config.fontSize, + fontStyle: config.fontStyle, + fontVariant: config.fontVariant, + fontWeight: config.fontWeight, + }) - const textArray = splitText({ + const { lines: richLines, height: totalHeight, textBaseline, textAlign } = splitWords({ ctx, - text: myText, + words: Array.isArray(text) ? text : textToWords(text), + inferWhitespace: Array.isArray(text) + ? (config.inferWhitespace === undefined || config.inferWhitespace) + : undefined, // ignore since `text` is a string; we assume it already has all the whitespace it needs + x: config.x, + y: config.y, + width: config.width, + height: config.height, + align: config.align, + vAlign: config.vAlign, justify: config.justify, - width, - }) - - const charHeight = config.lineHeight - ? config.lineHeight - : getTextHeight({ ctx, text: 'M', style }) - const vHeight = charHeight * (textArray.length - 1) - const negOffset = vHeight / 2 - - let debugY = y - // Vertical Align - if (config.vAlign === 'top') { - ctx.textBaseline = 'top' - txtY = y - } else if (config.vAlign === 'bottom') { - ctx.textBaseline = 'bottom' - txtY = yEnd - vHeight - debugY = yEnd - } else { - //defaults to center - ctx.textBaseline = 'bottom' - debugY = y + height / 2 - txtY -= negOffset - } - //print all lines of text - textArray.forEach((txtline) => { - txtline = txtline.trim() - ctx.fillText(txtline, textAnchor, txtY) - txtY += charHeight + format: baseFormat, + }); + + ctx.save() + ctx.textAlign = textAlign + ctx.textBaseline = textBaseline + ctx.font = getTextStyle(baseFormat) + ctx.fillStyle = baseFormat.fontColor || DEFAULT_FONT_COLOR + + richLines.forEach((line) => { + line.forEach((pw) => { + if (!pw.isWhitespace) { + // NOTE: don't use the `pw.word.format` as this could be incomplete; use `pw.format` + // if it exists as this will always be the __full__ TextFormat used to measure the + // Word, and so should be what is used to render it + if (pw.format) { + ctx.save() + ctx.font = getTextStyle(pw.format) + if (pw.format.fontColor) { + ctx.fillStyle = pw.format.fontColor + } + } + ctx.fillText(pw.word.text, pw.x, pw.y) + if (pw.format) { + ctx.restore() + } + } + }) }) if (config.debug) { + const { width, height, x, y } = config + const xEnd = x + width + const yEnd = y + height + + let textAnchor: number + if (config.align === 'right') { + textAnchor = xEnd + } else if (config.align === 'left') { + textAnchor = x + } else { + textAnchor = x + width / 2 + } + + let debugY = y + if (config.vAlign === 'bottom') { + debugY = yEnd + } else if (config.vAlign === 'middle') { + debugY = y + height / 2 + } + const debugColor = '#0C8CE9' // Text box @@ -111,22 +88,41 @@ function drawText( ctx.strokeRect(x, y, width, height) ctx.lineWidth = 1 - // Horizontal Center - ctx.strokeStyle = debugColor - ctx.beginPath() - ctx.moveTo(textAnchor, y) - ctx.lineTo(textAnchor, yEnd) - ctx.stroke() - // Vertical Center - ctx.strokeStyle = debugColor - ctx.beginPath() - ctx.moveTo(x, debugY) - ctx.lineTo(xEnd, debugY) - ctx.stroke() + + if (!config.align || config.align === 'center') { + // Horizontal Center + ctx.strokeStyle = debugColor + ctx.beginPath() + ctx.moveTo(textAnchor, y) + ctx.lineTo(textAnchor, yEnd) + ctx.stroke() + } + + if (!config.vAlign || config.vAlign === 'middle') { + // Vertical Center + ctx.strokeStyle = debugColor + ctx.beginPath() + ctx.moveTo(x, debugY) + ctx.lineTo(xEnd, debugY) + ctx.stroke() + } } - const textHeight = vHeight + charHeight - return { height: textHeight } + ctx.restore() + + return { height: totalHeight } } -export { drawText, splitText, getTextHeight } +export { + drawText, + specToJson, + splitText, + splitWords, + textToWords, + wordsToJson, + getTextHeight, + getWordHeight, + getTextStyle, + getTextFormat, +} +export * from './lib/models'; diff --git a/src/canvas-txt/lib/get-style.ts b/src/canvas-txt/lib/get-style.ts new file mode 100644 index 00000000..2be98fa5 --- /dev/null +++ b/src/canvas-txt/lib/get-style.ts @@ -0,0 +1,44 @@ +import { TextFormat } from "./models"; + +export const DEFAULT_FONT_FAMILY = 'Arial' +export const DEFAULT_FONT_SIZE = 14 +export const DEFAULT_FONT_COLOR = 'black' + +/** + * Generates a text format based on defaults and any provided overrides. + * @param format Overrides to `baseFormat` and default format. + * @param baseFormat Overrides to default format. + * @returns Full text format (all properties specified). + */ +export const getTextFormat = function(format?: TextFormat, baseFormat?: TextFormat): Required { + return Object.assign({}, { + fontFamily: DEFAULT_FONT_FAMILY, + fontSize: DEFAULT_FONT_SIZE, + fontWeight: '400', + fontStyle: '', + fontVariant: '', + fontColor: DEFAULT_FONT_COLOR, + }, baseFormat, format) +} + +/** + * Generates a [CSS font](https://developer.mozilla.org/en-US/docs/Web/CSS/font) value. + * @param format + * @returns Style string to set on context's `font` property. Note this __does not include + * the font color__ as that is not part of the CSS font value. Color must be handled separately. + */ +export const getTextStyle = function({ + fontFamily, + fontSize, + fontStyle, + fontVariant, + fontWeight, +}: TextFormat) { + // per spec: + // - font-style, font-variant and font-weight must precede font-size + // - font-family must be the last value specified + // @see https://developer.mozilla.org/en-US/docs/Web/CSS/font + return `${fontStyle || ''} ${fontVariant || ''} ${ + fontWeight || '' + } ${fontSize ?? DEFAULT_FONT_SIZE}px ${fontFamily || DEFAULT_FONT_FAMILY}`.trim() +} diff --git a/src/canvas-txt/lib/is-whitespace.ts b/src/canvas-txt/lib/is-whitespace.ts new file mode 100644 index 00000000..fdf610bd --- /dev/null +++ b/src/canvas-txt/lib/is-whitespace.ts @@ -0,0 +1,8 @@ +/** + * Determines if a string is only whitespace (one or more characters of it). + * @param text + * @returns True if `text` is one or more characters of whitespace, only. + */ +export const isWhitespace = function(text: string) { + return !!text.match(/^\s+$/) +} diff --git a/src/canvas-txt/lib/justify.ts b/src/canvas-txt/lib/justify.ts index e1059553..b4153024 100644 --- a/src/canvas-txt/lib/justify.ts +++ b/src/canvas-txt/lib/justify.ts @@ -1,42 +1,110 @@ +import { isWhitespace } from "./is-whitespace" +import { Word } from "./models" + export interface JustifyLineProps { - ctx: CanvasRenderingContext2D - line: string + /** Assumed to have already been trimmed on both ends. */ + line: Word[] + /** Width (px) of `spaceChar`. */ spaceWidth: number + /** + * Character used as a whitespace in justification. Will be injected in between Words in + * `line` in order to justify the text on the line within `lineWidth`. + */ spaceChar: string - width: number + /** Width (px) of the box containing the text (i.e. max `line` width). */ + boxWidth: number +} + +/** + * Extracts the __visible__ (i.e. non-whitespace) words from a line. + * @param line + * @returns New array with only non-whitespace words. + */ +const extractWords = function(line: Word[]) { + return line.filter((word) => !isWhitespace(word.text)) } /** - * This function will insert spaces between words in a line in order - * to raise the line width to the box width. - * The spaces are evenly spread in the line, and extra spaces (if any) are inserted - * between the first words. + * Deep-clones a Word. + * @param word + * @returns Deep-cloned Word. + */ +const cloneWord = function(word: Word) { + const clone = { ...word } + if (word.format) { + clone.format = { ...word.format } + } + return clone +} + +/** + * Joins Words together using another set of Words. + * @param words Words to join. + * @param joiner Words to use when joining `words`. These will be deep-cloned and inserted + * in between every word in `words`, similar to `Array.join(string)` where the `string` + * is inserted in between every element. + * @returns New array of Words. Empty if `words` is empty. New array of one Word if `words` + * contains only one Word. + */ +const joinWords = function(words: Word[], joiner: Word[]) { + if (words.length <= 1 || joiner.length < 1) { + return [...words] + } + + const phrase: Word[] = [] + words.forEach((word, wordIdx) => { + phrase.push(word) + if (wordIdx < words.length - 1) { // don't append after last `word` + joiner.forEach((jw) => (phrase.push(cloneWord(jw)))) + } + }) + + return phrase +} + +/** + * Inserts spaces between words in a line in order to raise the line width to the box width. + * The spaces are evenly spread in the line, and extra spaces (if any) are only inserted + * between words, not at either end of the `line`. * - * It returns the justified text. + * @returns New array containing original words from the `line` with additional whitespace + * for justification to `boxWidth`. */ -export default function justifyLine({ - ctx, +export function justifyLine({ line, spaceWidth, spaceChar, - width, + boxWidth, }: JustifyLineProps) { - const text = line.trim() - const words = text.split(/\s+/) - const numOfWords = words.length - 1 - - if (numOfWords === 0) return text - - // Width without spaces - const lineWidth = ctx.measureText(words.join('')).width - - const noOfSpacesToInsert = (width - lineWidth) / spaceWidth - const spacesPerWord = Math.floor(noOfSpacesToInsert / numOfWords) - - if (noOfSpacesToInsert < 1) return text + const words = extractWords(line) + if (words.length <= 1) { + return line.concat() + } - const spaces = spaceChar.repeat(spacesPerWord) + const wordsWidth = words.reduce((width, word) => width + (word.metrics?.width ?? 0), 0) + const noOfSpacesToInsert = (boxWidth - wordsWidth) / spaceWidth - // Return justified text - return words.join(spaces) + if (words.length > 2) { + // use CEILING so we spread the partial spaces throughout except between the second-last + // and last word so that the spacing is more even and as tight as we can get it to + // the `boxWidth` + const spacesPerWord = Math.ceil(noOfSpacesToInsert / (words.length - 1)) + const spaces: Word[] = Array.from({ length: spacesPerWord }, () => ({ text: spaceChar })) + const firstWords = words.slice(0, words.length - 1) // all but last word + const firstPart = joinWords(firstWords, spaces) + const remainingSpaces = spaces.slice( + 0, + Math.floor(noOfSpacesToInsert) - (firstWords.length - 1) * spaces.length + ) + const lastWord = words[words.length - 1] + return [...firstPart, ...remainingSpaces, lastWord] + } else { + // only 2 words so fill with spaces in between them: use FLOOR to make sure we don't + // go past `boxWidth` + const spaces: Word[] = Array.from( + { length: Math.floor(noOfSpacesToInsert) }, + () => ({ text: spaceChar }) + ) + return joinWords(words, spaces) + } } diff --git a/src/canvas-txt/lib/models.ts b/src/canvas-txt/lib/models.ts new file mode 100644 index 00000000..22281218 --- /dev/null +++ b/src/canvas-txt/lib/models.ts @@ -0,0 +1,273 @@ +export type CanvasRenderContext = CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D + +export interface TextFormat { + /** Font family (CSS value). */ + fontFamily?: string + + // TODO: rendering words at different sizes doesn't render well per baseline + /** + * Font size (px). + * + * ❗️ Rendering words at different sizes currently does not render well per text baseline. + * Prefer setting a common size as the base formatting for all text instead of setting + * a different size for a subset of Words. + */ + fontSize?: number + + /** Font weight (CSS value). */ + fontWeight?: string + /** Font style (CSS value) */ + fontStyle?: string + + // per spec, only CSS 2.1 values are supported + // @see https://developer.mozilla.org/en-US/docs/Web/CSS/font + /** Font variant (CSS value). */ + fontVariant?: 'normal' | 'small-caps' | '' + + fontColor?: string + + // NOTE: line height is not currently supported +} + +export interface Word { + /** The word. Can also be whitespace. */ + text: string + /** Optional formatting. If unspecified, base format defaults will be used. */ + format?: TextFormat + /** + * Optional metrics for this Word if it has __already been measured__ on canvas. + * + * ❗️ This is an optimization to increase performance of subsequent splitting of words. + * If specified, it's assumed that the `text` and `format` __have not changed__ since + * the last time the word was measured. Also that other aspects of the canvas related + * to rendering, such as aspect ratio, that could affect measurements __have not changed__. + * + * If not specified, this member __will be added__ by `splitWords()` once the word is measured + * so that it's easy to feed the same word back into `splitWords()` at a later time if its + * `text` and `format` remain the same. If they change, simply set this property to `undefined` + * to force it to be re-measured. + */ + metrics?: CanvasTextMetrics // NOTE: all property are flagged as `readonly` (good!) +} + +export type PlainText = string; + +export type Text = PlainText | Word[]; + +export interface CanvasTextConfig extends TextFormat { + /** + * Width of box (px) at X/Y in 2D context within which text should be rendered. This will affect + * text wrapping, but will not necessarily constrain the text because, at minimum, one word, + * regardless of its width, will be rendered per line. + */ + width: number + /** + * Height of box (px) at X/Y in 2D context within which text should be rendered. While this + * __will not constrain how the text is rendered__, it will determine how it's positioned + * given the alignment specified (`align` and `vAlign`). All the text is rendered, and may + * be rendered above/below the box defined in part by this dimension if it's too long to + * fit within the specified `boxWidth`. + */ + height: number + /** Absolute X coordinate (px) in 2D context where text should be rendered. */ + x: number + /** Absolute Y coordinate (px) in 2D context where text should be rendered. */ + y: number + + /** True if debug lines should be rendered behind the text. */ + debug?: boolean + + /** Horizontal alignment. Defaults to 'center'. */ + align?: 'left' | 'center' | 'right' + /** Vertical alignment. Defaults to 'middle'. */ + vAlign?: 'top' | 'middle' | 'bottom' + /** True if text should be justified within the `boxWidth` to fill the hole width. */ + justify?: boolean + + /** + * __NOTE:__ Applies only if `text`, given to `drawText()`, is a `Word[]`. Ignored if it's + * a `string`. + * + * True indicates `text` is a `Word` array that contains _mostly_ visible words and + * whitespace should be inferred _unless a word is whitespace (e.g. a new line or tab)_, based + * on the context's general text formatting style (i.e. every space will use the font style set + * on the context). This makes it easier to provide a `Word[]` because whitespace can be omitted + * if it's just spaces, and only informative whitespace is necessary (e.g. hard line breaks + * as Words with `text="\n"`). + * + * False indicates that `words` contains its own whitespace and it shouldn't be inferred. + */ + inferWhitespace?: boolean +} + +export interface BaseSplitProps { + ctx: CanvasRenderContext + + /** Absolute X coordinate (px) in 2D context where text should be rendered. */ + x: number + /** Absolute Y coordinate (px) in 2D context where text should be rendered. */ + y: number + /** + * Width of box (px) at X/Y in 2D context within which text should be rendered. This will affect + * text wrapping, but will not necessarily constrain the text because, at minimum, one word, + * regardless of its width, will be rendered per line. + */ + width: number + /** + * Height of box (px) at X/Y in 2D context within which text should be rendered. While this + * __will not constrain how the text is rendered__, it will determine how it's positioned + * given the alignment specified (`align` and `vAlign`). All the text is rendered, and may + * be rendered above/below the box defined in part by this dimension if it's too long to + * fit within the specified `boxWidth`. + */ + height: number + + /** Horizontal alignment. Defaults to 'center'. */ + align?: 'left' | 'center' | 'right' + /** Vertical alignment. Defaults to 'middle'. */ + vAlign?: 'top' | 'middle' | 'bottom' + /** True if text should be justified within the `boxWidth` to fill the hole width. */ + justify?: boolean + + /** + * Base/default font styles. These will be used for any word that doesn't have specific + * formatting overrides. It's basically how "plain text" should be rendered. + */ + format?: TextFormat +} + +export interface SplitTextProps extends BaseSplitProps { + /** + * Text to render. Newlines are interpreted as hard breaks. Whitespace is preserved __only + * within the string__ (whitespace on either end is trimmed). Text will always wrap at max + * width regardless of newlines. + */ + text: PlainText +} + +export interface SplitWordsProps extends BaseSplitProps { + /** For hard breaks, include words that are newline characters as their `text`. */ + words: Word[] + + /** + * True (default) indicates `words` contains _mostly_ visible words and whitespace should be + * inferred _unless a word is whitespace (e.g. a new line or tab)_, based on the context's + * general text formatting style (i.e. every space will use the font style set on the context). + * + * False indicates that `words` contains its own whitespace and it shouldn't be inferred. + */ + inferWhitespace?: boolean +} + +/** Hash representing a `Word` and its associated `TextFormat`. */ +export type WordHash = string; + +/** + * Identifies the minimum Canvas `TextMetrics` properties required by Canvas-Txt. This is + * important for serialization across the main thread to a Web Worker thread (or vice versa) + * as the native `TextMetrics` object fails to get serialized by `Worker.postMessage()`, + * causing an exception. + */ +export interface TextMetricsLike { + readonly fontBoundingBoxAscent: number + readonly fontBoundingBoxDescent: number + readonly width: number +} + +export type CanvasTextMetrics = TextMetrics | TextMetricsLike + +/** + * Maps a `Word` to its measured `metrics` and the font `format` used to measure it (if the + * `Word` specified a format to use; undefined means the base formatting, as set on the canvas + * 2D context, was used). + */ +export type WordMap = Map }> + +export interface GenerateSpecProps { + /** Words organized/wrapped into lines to be rendered. */ + wrappedLines: Word[][] + + /** Map of Word to measured dimensions (px) as it would be rendered. */ + wordMap: WordMap + + /** + * Details on where to render the Words onto canvas. These parameters ultimately come + * from `SplitWordsProps`, and they come from `CanvasTextConfig`. + */ + positioning: { + width: SplitWordsProps['width'] + // NOTE: height does NOT constrain the text; used only for vertical alignment + height: SplitWordsProps['height'] + x: SplitWordsProps['x'] + y: SplitWordsProps['y'] + align?: SplitWordsProps['align'] + vAlign?: SplitWordsProps['vAlign'] + } +} + +/** + * A `Word` along with its __relative__ position along the X/Y axis within the bounding box + * in which it is to be drawn. + * + * It's the caller's responsibility to render each Word onto the Canvas, as well as to calculate + * each Word's location in the Canvas' absolute space. + */ +export interface PositionedWord { + /** Reference to a `Word` given to `splitWords()`. */ + readonly word: Word + + /** + * Full formatting used to measure/position the `word`, __if a `word.format` partial + * was specified.__ + * + * ❗️ __Use this for actual rendering__ instead of the original `word.format`. + */ + readonly format?: Readonly> + + /** X position (px) relative to render box within 2D context. */ + readonly x: number + /** Y position (px) relative to render box within 2D context. */ + readonly y: number + /** Width (px) used to render text. */ + readonly width: number + /** Height (px) used to render text. */ + readonly height: number + + /** + * True if this `word` is non-visible whitespace (per a Regex `^\s+$` match) and so + * __could be skipped when rendering__. + */ + readonly isWhitespace: boolean +} + +export interface RenderSpec { + /** + * Words split into lines as they would be visually wrapped on canvas if rendered + * to their prescribed positions. + */ + readonly lines: PositionedWord[][] + + /** + * Baseline to use when rendering text based on alignment settings. + * + * ❗️ Set this on the 2D context __before__ rendering the Words in the `lines`. + */ + readonly textBaseline: CanvasTextBaseline + + /** + * Alignment to use when rendering text based on alignment settings. + * + * ❗️ Set this on the 2D context __before__ rendering the Words in the `lines`. + */ + readonly textAlign: CanvasTextAlign + + /** + * Total required width (px) to render all the lines as wrapped (i.e. the original + * `width` used to split the words. + */ + readonly width: number + + /** Total required height (px) to render all lines. */ + readonly height: number +} + diff --git a/src/canvas-txt/lib/split-text.ts b/src/canvas-txt/lib/split-text.ts index ecfb0956..66991fa9 100644 --- a/src/canvas-txt/lib/split-text.ts +++ b/src/canvas-txt/lib/split-text.ts @@ -1,132 +1,558 @@ -import justifyLine from './justify' +import { getTextFormat, getTextStyle } from './get-style' +import { isWhitespace } from './is-whitespace' +import { justifyLine } from './justify' +import { + GenerateSpecProps, + PositionedWord, + SplitTextProps, + SplitWordsProps, + RenderSpec, + Word, + WordMap, + CanvasTextMetrics, + TextFormat, + CanvasRenderContext +} from './models' +import { trimLine } from './trim-line' // Hair space character for precise justification -const SPACE = '\u{200a}' +const HAIR = '\u{200a}' -export interface SplitTextProps { - ctx: CanvasRenderingContext2D - text: string - justify: boolean - width: number +// for when we're inferring whitespace between words +const SPACE = ' ' + +/** + * Whether the canvas API being used supports the newer `fontBoundingBox*` properties or not. + * + * True if it does, false if not; undefined until we determine either way. + * + * Note about `fontBoundingBoxAscent/Descent`: Only later browsers support this and the Node-based + * `canvas` package does not. Having these properties will have a noticeable increase in performance + * on large pieces of text to render. Failing these, a fallback is used which involves + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/TextMetrics + * @see https://www.npmjs.com/package/canvas + */ +let fontBoundingBoxSupported: boolean + +/** + * @private + * Generates a word hash for use as a key in a `WordMap`. + * @param word + * @returns Hash. + */ +const getWordHash = function(word: Word) { + return `${word.text}${word.format ? JSON.stringify(word.format) : ''}` } -export default function splitText({ - ctx, - text, - justify, - width, -}: SplitTextProps): string[] { - const textMap = new Map() +/** + * @private + * Splits words into lines based on words that are single newline characters. + * @param words + * @param inferWhitespace True (default) if whitespace should be inferred (and injected) + * based on words; false if we're to assume the words already include all necessary whitespace. + * @returns Words expressed as lines. + */ +const splitIntoLines = function(words: Word[], inferWhitespace: boolean = true): Word[][] { + const lines: Word[][] = [[]] + + let wasWhitespace = false // true if previous word was whitespace + words.forEach((word, wordIdx) => { + // TODO: this is likely a naive split (at least based on character?); should at least + // think about this more; text format shouldn't matter on a line break, right (hope not)? + if (word.text.match(/^\n+$/)) { + for (let i = 0; i < word.text.length; i++) { + lines.push([]) + } + wasWhitespace = true + return // next `word` + } + + if (isWhitespace(word.text)) { + // whitespace OTHER THAN newlines since we checked for newlines above + lines.at(-1)?.push(word) + wasWhitespace = true + return // next `word` + } + + if (word.text === '') { + return // skip to next `word` + } - const measureText = (text: string): number => { - let width = textMap.get(text) - if (width !== undefined) { - return width + // looks like a non-empty, non-whitespace word at this point, so if it isn't the first + // word and the one before wasn't whitespace, insert a space + if (inferWhitespace && !wasWhitespace && wordIdx > 0) { + lines.at(-1)?.push({ text: SPACE }) } - width = ctx.measureText(text).width - textMap.set(text, width) - return width + lines.at(-1)?.push(word) + wasWhitespace = false + }) + + return lines +} + +/** + * @private + * Helper for `splitWords()` that takes the words that have been wrapped into lines and + * determines their positions on canvas for future rendering based on alignment settings. + * @param params + * @returns Results to return via `splitWords()` + */ +const generateSpec = function({ + wrappedLines, + wordMap, + positioning: { + width: boxWidth, + height: boxHeight, + x: boxX, + y: boxY, + align, + vAlign, } +}: GenerateSpecProps): RenderSpec { + const xEnd = boxX + boxWidth + const yEnd = boxY + boxHeight + + // NOTE: using __font__ ascent/descent to account for all possible characters in the font + // so that lines with ascenders but no descenders, or vice versa, are all properly + // aligned to the baseline, and so that lines aren't scrunched + // NOTE: even for middle vertical alignment, we want to use the __font__ ascent/descent + // so that words, per line, are still aligned to the baseline (as much as possible; if + // each word has a different font size, then things will still be offset, but for the + // same font size, the baseline should match from left to right) + const getHeight = (word: Word): number => + // NOTE: `metrics` must exist as every `word` MUST have been measured at this point + word.metrics!.fontBoundingBoxAscent + word.metrics!.fontBoundingBoxDescent - let textArray: string[] = [] - let initialTextArray = text.split('\n') + // max height per line + const lineHeights = wrappedLines.map( + (line) => + line.reduce( + (acc, word) => { + return Math.max(acc, getHeight(word)) + }, + 0 + ) + ) + const totalHeight = lineHeights.reduce((acc, h) => acc + h, 0) - const spaceWidth = justify ? measureText(SPACE) : 0 + // vertical alignment (defaults to middle) + let lineY: number + let textBaseline: CanvasTextBaseline + if (vAlign === 'top') { + textBaseline = 'top' + lineY = boxY + } else if (vAlign === 'bottom') { + textBaseline = 'bottom' + lineY = yEnd - totalHeight + } else { // middle + textBaseline = 'top' // YES, using 'top' baseline for 'middle' v-align + lineY = (boxY + boxHeight / 2) - (totalHeight / 2) + } - let index = 0 - let averageSplitPoint = 0 - for (const singleLine of initialTextArray) { - let textWidth = measureText(singleLine) - const singleLineLength = singleLine.length + const lines = wrappedLines.map((line, lineIdx): PositionedWord[] => { + const lineWidth = line.reduce( + // NOTE: `metrics` must exist as every `word` MUST have been measured at this point + (acc, word) => acc + word.metrics!.width, + 0 + ) + const lineHeight = lineHeights[lineIdx] - if (textWidth <= width) { - textArray.push(singleLine) - continue + // horizontal alignment (defaults to center) + let lineX: number + if (align === 'right') { + lineX = xEnd - lineWidth + } else if (align === 'left') { + lineX = boxX + } else { // center + lineX = (boxX + boxWidth / 2) - (lineWidth / 2) } - let tempLine = singleLine + let wordX = lineX + const posWords = line.map((word): PositionedWord => { + // NOTE: `word.metrics` and `wordMap.get(hash)` must exist as every `word` MUST have + // been measured at this point - let splitPoint - let splitPointWidth - let textToPrint = '' + const hash = getWordHash(word) + const { format } = wordMap.get(hash)! + const x = wordX + const height = getHeight(word) - while (textWidth > width) { - index++ - splitPoint = averageSplitPoint - splitPointWidth = - splitPoint === 0 ? 0 : measureText(singleLine.substring(0, splitPoint)) + // vertical alignment (defaults to middle) + let y: number + if (vAlign === 'top') { + y = lineY + } else if (vAlign === 'bottom') { + y = lineY + lineHeight + } else { // middle + y = lineY + (lineHeight - height) / 2 + } - // if (splitPointWidth === width) Nailed - if (splitPointWidth < width) { - while (splitPointWidth < width && splitPoint < singleLineLength) { - splitPoint++ - splitPointWidth = measureText(tempLine.substring(0, splitPoint)) - if (splitPoint === singleLineLength) break - } - } else if (splitPointWidth > width) { - while (splitPointWidth > width) { - splitPoint = Math.max(1, splitPoint - 1) - splitPointWidth = measureText(tempLine.substring(0, splitPoint)) - if (splitPoint === 1) break - } + wordX += word.metrics!.width + return { + word, + format, // undefined IF base formatting should be used when rendering (i.e. `word.format` is undefined) + x, + y, + width: word.metrics!.width, + height, + isWhitespace: isWhitespace(word.text) } + }) - averageSplitPoint = Math.round( - averageSplitPoint + (splitPoint - averageSplitPoint) / index - ) + lineY += lineHeight + return posWords + }) - // Remove last character that was out of the box - splitPoint-- - - // Ensures a new line only happens at a space, and not amidst a word - if (splitPoint > 0) { - let tempSplitPoint = splitPoint - if (tempLine.substring(tempSplitPoint, tempSplitPoint + 1) != ' ') { - while (tempSplitPoint >= 0 - && tempLine.substring(tempSplitPoint, tempSplitPoint + 1) != ' ') { - tempSplitPoint-- - } - if (tempSplitPoint > 0) { - splitPoint = tempSplitPoint - } + return { + lines, + textBaseline, + textAlign: 'left', // always per current algorithm + width: boxWidth, + height: totalHeight + } +} + +/** + * @private + * Replacer for use with `JSON.stringify()` to deal with `TextMetrics` objects which + * only have getters/setters instead of value-based properties. + * @param key Key being processed in `this`. + * @param value Value of `key` in `this`. + * @returns Processed value to be serialized, or `undefined` to omit the `key` from the + * serialized object. + */ +// CAREFUL: use a `function`, not an arrow function, as stringify() sets its context to +// the object being serialized on each call to the replacer +const jsonReplacer = function(key: string, value: any) { + if (key === 'metrics' && value && typeof value === 'object') { + // TODO: need better typings here, if possible, so that TSC warns if we aren't + // including a property we should be if a new one is needed in the future (i.e. if + // a new property is added to the `TextMetricsLike` type) + // NOTE: TextMetrics objects don't have own-enumerable properties; they only have getters, + // so we have to explicitly get the values we care about + return { + width: value.width, + fontBoundingBoxAscent: value.fontBoundingBoxAscent, + fontBoundingBoxDescent: value.fontBoundingBoxDescent, + } as CanvasTextMetrics; + } + + return value +} + +/** + * Serializes render specs to JSON for storage or for sending via `postMessage()` + * between the main thread and a Web Worker thread. + * + * This is primarily to help with the fact that `postMessage()` fails if given a native + * Canvas `TextMetrics` object to serialize somewhere in its `message` parameter. + * + * @param specs + * @returns Specs serialized as JSON. + */ +export function specToJson(specs: RenderSpec): string { + return JSON.stringify(specs, jsonReplacer) +} + +/** + * Serializes a list of Words to JSON for storage or for sending via `postMessage()` + * between the main thread and a Web Worker thread. + * + * This is primarily to help with the fact that `postMessage()` fails if given a native + * Canvas `TextMetrics` object to serialize somewhere in its `message` parameter. + * + * @param words + * @returns Words serialized as JSON. + */ +export function wordsToJson(words: Word[]): string { + return JSON.stringify(words, jsonReplacer) +} + +/** + * @private + * Measures a Word in a rendering context, assigning its `TextMetrics` to its `metrics` property. + * @returns The Word's width, in pixels. + */ +const measureWord = function({ ctx, word, wordMap, baseTextFormat }: { + ctx: CanvasRenderContext, + word: Word, + wordMap: WordMap, + baseTextFormat: TextFormat, +}): number { + const hash = getWordHash(word); + + if (word.metrics) { + // assume Word's text and format haven't changed since last measurement and metrics are good + + // make sure we have the metrics and full formatting cached for other identical Words + if (!wordMap.has(hash)) { + let format = undefined; + if (word.format) { + format = getTextFormat(word.format, baseTextFormat) + } + wordMap.set(hash, { metrics: word.metrics, format }) + } + + return word.metrics.width + } + + // check to see if we have already measured an identical Word + if (wordMap.has(hash)) { + const { metrics } = wordMap.get(hash)!; // will be there because of `if(has())` check + word.metrics = metrics; + return metrics.width + } + + let ctxSaved = false + + let format = undefined + if (word.format) { + ctx.save() + ctxSaved = true + format = getTextFormat(word.format, baseTextFormat) + ctx.font = getTextStyle(format) // `fontColor` is ignored as it has no effect on metrics + } + + if (!fontBoundingBoxSupported) { + // use fallback which comes close enough and still gives us properly-aligned text, albeit + // lines are a couple pixels tighter together + if (!ctxSaved) { + ctx.save() + ctxSaved = true + } + ctx.textBaseline = 'bottom' + } + + const metrics = ctx.measureText(word.text) + if (typeof metrics.fontBoundingBoxAscent === 'number') { + fontBoundingBoxSupported = true + } else { + fontBoundingBoxSupported = false + // @ts-ignore -- property doesn't exist; we need to polyfill it + metrics.fontBoundingBoxAscent = metrics.actualBoundingBoxAscent + // @ts-ignore -- property doesn't exist; we need to polyfill it + metrics.fontBoundingBoxDescent = 0 + } + + word.metrics = metrics + wordMap.set(hash, { metrics, format }) + + if (ctxSaved) { + ctx.restore() + } + + return metrics.width +} + +/** + * Splits Words into positioned lines of Words as they need to be rendred in 2D space, + * but does not render anything. + * @param config + * @returns Lines of positioned words to be rendered, and total height required to + * render all lines. + */ +export function splitWords({ + ctx, + words, + justify, + format: baseFormat, + inferWhitespace = true, + ...positioning // rest of params are related to positioning +}: SplitWordsProps): RenderSpec { + const wordMap: WordMap = new Map() + const baseTextFormat = getTextFormat(baseFormat) + + //// text measurement + + // measures an entire line's width up to the `boxWidth` as a max, unless `force=true`, + // in which case the entire line is measured regardless of `boxWidth`. + // + // - Returned `lineWidth` is width up to, but not including, the `splitPoint` (always <= `boxWidth` + // unless the first Word is too wide to fit, in which case `lineWidth` will be that Word's + // width even though it's > `boxWidth`). + // - If `force=true`, will be the full width of the line regardless of `boxWidth`. + // - Returned `splitPoint` is index into `words` of the Word immediately FOLLOWING the last + // Word included in the `lineWidth` (and is `words.length` if all Words were included); + // `splitPoint` could also be thought of as the number of `words` included in the `lineWidth`. + // - If `force=true`, will always be `words.length`. + const measureLine = (words: Word[], force: boolean = false): { + lineWidth: number, + splitPoint: number + } => { + let lineWidth = 0 + let splitPoint = 0 + words.every((word, idx) => { + const wordWidth = measureWord({ ctx, word, wordMap, baseTextFormat }) + if (!force && (lineWidth + wordWidth > boxWidth)) { + // at minimum, MUST include at least first Word, even if it's wider than box width + if (idx === 0) { + splitPoint = 1 + lineWidth = wordWidth } + // else, `lineWidth` already includes at least one Word so this current Word will + // be the `splitPoint` such that `lineWidth` remains < `boxWidth` + + return false // break } - if (splitPoint === 0) { - splitPoint = 1 + splitPoint++ + lineWidth += wordWidth + return true // next + }); + + return { lineWidth, splitPoint } + } + + //// main + + ctx.save() + + // start by trimming the `words` to remove any whitespace at either end, then split the `words` + // into an initial set of lines dictated by explicit hard breaks, if any (if none, we'll have + // one super long line) + const hardLines = splitIntoLines(trimLine(words).trimmedLine, inferWhitespace) + const { width: boxWidth } = positioning + + if ( + hardLines.length <= 0 || + boxWidth <= 0 || + positioning.height <= 0 || + (baseFormat && typeof baseFormat.fontSize === 'number' && baseFormat.fontSize <= 0) + ) { + // width or height or font size cannot be 0, or there are no lines after trimming + return { + lines: [], + textAlign: 'center', + textBaseline: 'middle', + width: positioning.width, + height: 0 + } + } + + ctx.font = getTextStyle(baseTextFormat) + + const hairWidth = justify + ? measureWord({ ctx, word: { text: HAIR }, wordMap, baseTextFormat }) + : 0 + const wrappedLines: Word[][] = [] + + // now further wrap every hard line to make sure it fits within the `boxWidth`, down to a + // MINIMUM of 1 Word per line + for (const hardLine of hardLines) { + let { splitPoint } = measureLine(hardLine) + + // if the line fits, we're done; else, we have to break it down further to fit + // as best as we can (i.e. MIN one word per line, no breaks within words, no + // leading/pending whitespace) + if (splitPoint >= hardLine.length) { + wrappedLines.push(hardLine) + } else { + // shallow clone because we're going to break this line down further to get the best fit + let softLine = hardLine.concat() + while (splitPoint < softLine.length) { + // right-trim what we split off in case we split just after some whitespace + const splitLine = trimLine(softLine.slice(0, splitPoint), 'right').trimmedLine + wrappedLines.push(splitLine) + + // left-trim what remains in case we split just before some whitespace + softLine = trimLine(softLine.slice(splitPoint), 'left').trimmedLine; + ({ splitPoint } = measureLine(softLine)) } - // Finally sets text to print - textToPrint = tempLine.substring(0, splitPoint) - - textToPrint = justify - ? justifyLine({ - ctx, - line: textToPrint, - spaceWidth, - spaceChar: SPACE, - width, - }) - : textToPrint - textArray.push(textToPrint) - tempLine = tempLine.substring(splitPoint) - textWidth = measureText(tempLine) + // get the last bit of the `softLine` + // NOTE: since we started by timming the entire line, and we just left-trimmed + // what remained of `softLine`, there should be no need to trim again + wrappedLines.push(softLine) } + } + + // never justify a single line because there's no other line to visually justify it to + if (justify && wrappedLines.length > 1) { + wrappedLines.forEach((wrappedLine, idx) => { + // never justify the last line (common in text editors) + if (idx < wrappedLines.length - 1) { + const justifiedLine = justifyLine({ + line: wrappedLine, + spaceWidth: hairWidth, + spaceChar: HAIR, + boxWidth, + }) + + // make sure any new Words used for justification get measured so we're able to + // position them later when we generate the render spec + measureLine(justifiedLine, true) + wrappedLines[idx] = justifiedLine + } + }) + } + + const spec = generateSpec({ + wrappedLines, + wordMap, + positioning, + }) + + ctx.restore() + return spec +} - if (textWidth > 0) { - textToPrint = justify - ? justifyLine({ - ctx, - line: tempLine, - spaceWidth, - spaceChar: SPACE, - width, - }) - : tempLine - - textArray.push(textToPrint) +/** + * Converts a string of text containing words and whitespace, as well as line breaks (newlines), + * into a `Word[]` that can be given to `splitWords()`. + * @param text String to convert into Words. + * @returns Converted text. + */ +export function textToWords(text: string) { + const words: Word[] = [] + + // split the `text` into a series of Words, preserving whitespace + let word: Word | undefined = undefined; + let wasWhitespace = false + Array.from(text.trim()).forEach((c) => { + const charIsWhitespace = isWhitespace(c) + if ((charIsWhitespace && !wasWhitespace) || (!charIsWhitespace && wasWhitespace)) { + // save current `word`, if any, and start new `word` + wasWhitespace = charIsWhitespace + if (word) { + words.push(word) + } + word = { text: c } + } else { + // accumulate into current `word` + if (!word) { + word = { text: '' } + } + word.text += c } + }) + + // make sure we have the last word! ;) + if (word) { + words.push(word) } - return textArray + + return words +} + +/** + * Splits plain text into lines in the order in which they should be rendered, top-down, + * preserving whitespace __only within the text__ (whitespace on either end is trimmed). + */ +export function splitText({ + text, + ...params +}: SplitTextProps): string[] { + const words = textToWords(text) + + const results = splitWords({ + ...params, + words, + inferWhitespace: false + }) + + return results.lines.map( + (line) => line.map(({ word: { text } }) => text).join('') + ) } diff --git a/src/canvas-txt/lib/text-height.ts b/src/canvas-txt/lib/text-height.ts index 284ceb87..51da2cd7 100644 --- a/src/canvas-txt/lib/text-height.ts +++ b/src/canvas-txt/lib/text-height.ts @@ -1,24 +1,52 @@ +import { getTextStyle } from "./get-style" +import { CanvasRenderContext, Word } from "./models" + +interface GetWordHeightProps { + ctx: CanvasRenderContext + word: Word +} + interface GetTextHeightProps { - ctx: CanvasRenderingContext2D + ctx: CanvasRenderContext text: string - style: string + + /** + * CSS font. Same syntax as CSS font specifier. If not specified, current `ctx` font + * settings/styles are used. + */ + style?: string } -export default function getTextHeight({ - ctx, - text, - style, -}: GetTextHeightProps) { +const getHeight = function(ctx: CanvasRenderContext, text: string, style?: string) { const previousTextBaseline = ctx.textBaseline const previousFont = ctx.font ctx.textBaseline = 'bottom' - ctx.font = style + if (style) { + ctx.font = style + } const { actualBoundingBoxAscent: height } = ctx.measureText(text) // Reset baseline ctx.textBaseline = previousTextBaseline - ctx.font = previousFont + if (style) { + ctx.font = previousFont + } return height } + +export function getWordHeight({ + ctx, + word, +}: GetWordHeightProps) { + return getHeight(ctx, word.text, word.format && getTextStyle(word.format)) +} + +export function getTextHeight({ + ctx, + text, + style, +}: GetTextHeightProps) { + return getHeight(ctx, text, style) +} diff --git a/src/canvas-txt/lib/trim-line.ts b/src/canvas-txt/lib/trim-line.ts new file mode 100644 index 00000000..d9f77fae --- /dev/null +++ b/src/canvas-txt/lib/trim-line.ts @@ -0,0 +1,61 @@ +import { isWhitespace } from "./is-whitespace"; +import { Word } from "./models"; + +/** + * Trims whitespace from the beginning and end of a `line`. + * @param line + * @param side Which side to trim. + * @returns `trimmedLine` is a new array representing the trimmed line, even if nothing + * gets trimmed. Empty array if all whitespace. `trimmedLeft` is a new array containing + * what was trimmed from the left (empty if none). `trimmedRight` is a new array containing + * what was trimmed from the right (empty if none). + */ +export const trimLine = function(line: Word[], side: 'left' | 'right' | 'both' = 'both'): { + trimmedLeft: Word[], + trimmedRight: Word[], + trimmedLine: Word[], +} { + let leftTrim = 0; + if (side === 'left' || side === 'both') { + for (; leftTrim < line.length; leftTrim++) { + if (!isWhitespace(line[leftTrim].text)) { + break; + } + } + + if (leftTrim >= line.length) { + // all whitespace + return { + trimmedLeft: line.concat(), + trimmedRight: [], + trimmedLine: [], + } + } + } + + let rightTrim = line.length + if (side === 'right' || side === 'both') { + rightTrim-- + for (; rightTrim >= 0; rightTrim--) { + if (!isWhitespace(line[rightTrim].text)) { + break; + } + } + rightTrim++ // back up one since we started one down for 0-based indexes + + if (rightTrim <= 0) { + // all whitespace + return { + trimmedLeft: [], + trimmedRight: line.concat(), + trimmedLine: [], + } + } + } + + return { + trimmedLeft: line.slice(0, leftTrim), + trimmedRight: line.slice(rightTrim), + trimmedLine: line.slice(leftTrim, rightTrim), + } +} diff --git a/src/docs/AppCanvas.vue b/src/docs/AppCanvas.vue index f637f1b0..ade5194c 100644 --- a/src/docs/AppCanvas.vue +++ b/src/docs/AppCanvas.vue @@ -1,12 +1,12 @@