diff --git a/src/Textbox.ts b/src/Textbox.ts index 943b683..19219f3 100644 --- a/src/Textbox.ts +++ b/src/Textbox.ts @@ -2,7 +2,7 @@ import { linebreak } from './linebreak.js'; import { renderSVG } from './renderSVG.js'; import { renderCanvas } from './renderCanvas.ts'; -import { measureText, setMeasureCanvas } from './measureText.js'; +import { measureText, setMeasureCanvas } from './measureText/measureText.ts'; import { createElement } from './createElement.ts'; import { textparser } from './parser/textparser.ts'; import { htmlparser } from './parser/htmlparser.ts'; diff --git a/src/index.ts b/src/index.ts index f0c5efa..8f26ca2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,8 @@ import { Textbox } from './Textbox.ts'; export { Textbox } from './Textbox.ts'; export { Rotator } from './Rotator.ts'; -export { measureText } from './measureText.ts'; -export { setMeasureCanvas } from './measureText.ts'; +export { measureText } from './measureText/measureText.ts'; +export { setMeasureCanvas } from './measureText/measureText.ts'; export { textparser } from './parser/textparser.ts'; export { htmlparser } from './parser/htmlparser.ts'; diff --git a/src/linebreak.ts b/src/linebreak.ts index ad83a04..f822ae5 100644 --- a/src/linebreak.ts +++ b/src/linebreak.ts @@ -1,6 +1,6 @@ import { WHITESPACE } from './constants.ts'; import { fontStringParser } from './fontStringParser.ts'; -import { measureText } from './measureText.ts'; +import { measureText } from './measureText/measureText.ts'; import { Token, Break, LineBreak, SoftHyphen } from './parser/tokens.ts'; import type { FontProps, LayoutOptions, Line, Lines, NumberFunc, NumberLineFunc } from './types.ts'; diff --git a/src/measureText.ts b/src/measureText.ts deleted file mode 100644 index 197e806..0000000 --- a/src/measureText.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* globals document OffscreenCanvas HTMLCanvasElement */ -import { WHITESPACE } from './constants.ts'; -import { fontStringParser } from './fontStringParser.ts'; -import { fontToString } from './fontToString.ts'; -import type { Token } from './parser/tokens.ts'; -import type { MinimalCanvas, FontProps, MeasureOptions } from './types.ts'; - -const defaultFont = fontStringParser('12px/14px sans-serif'); - -const defaultOptions = { - trim: true, - collapse: true -}; - -const wCache = {}; - -type MeasureFn = (text: string, font: string | FontProps) => number; - -// This is just guesswork but works surprisingly well. -// Intended to be used to renderer for tests, or as a last -// resort in a server env. -export function getDumbHandler (): MeasureFn { - return (text: string, font: string | FontProps) => { - const f = typeof font === 'string' ? fontStringParser(font) : font; - let size = f.size ?? 12; - if (f.family && /\bmonospace\b/.test(f.family)) { - size *= 0.6; - } - else { - size *= 0.45; - if (f.weight && f.weight > 400) { - size *= 1.18; - } - } - return text.length * size; - }; -} - -function getMeasureFromCanvas (canvas?: MinimalCanvas | null): MeasureFn | void { - if (canvas && canvas.getContext) { - const context = canvas.getContext('2d'); - if (context && typeof context.measureText === 'function') { - return (text, font) => { - context.font = String(font); - return context.measureText(text).width; - }; - } - } -} - -export function getBrowserCanvas () { - const doc = typeof document !== 'undefined' ? document : null; - return ( - (typeof OffscreenCanvas !== 'undefined' && new OffscreenCanvas(100, 100)) || - (doc && doc.createElement && doc.createElement('canvas')) || - null - ); -} - -let measure: MeasureFn = getMeasureFromCanvas(getBrowserCanvas()) || getDumbHandler(); -/** - * Set the canvas to use when measuring text. This will be needed when using {@link measureText} - * in a non-browser environment. - * @param canvas The canvas interface to use. - */ -export function setMeasureCanvas (canvas: MinimalCanvas | null) { - if (canvas == null) { - measure = getMeasureFromCanvas(getBrowserCanvas()) || getDumbHandler(); - } - else if (canvas) { - measure = getMeasureFromCanvas(canvas) || getDumbHandler(); - } - else { - throw new Error('setMeasureCanvas argument is not a canvas or null'); - } -} - -/** - * Measure a string of text as printed with a specified font and return its width. - * - * Be careful that in non-browser environments you may want to supply an alternative - * canvas using {@link setMeasureCanvas} or this method will default to a crude - * "best guess" method. - * - * @param token A string or token of text to measure. - * @param font A CSS font shorthand string or a collection of font properties. - * @param options Text handling options. - */ -export function measureText ( - token: string | Token, - font: string | FontProps, - options: MeasureOptions = defaultOptions -): number { - if (typeof font === 'string') { - font = fontStringParser(font); - } - else { - font = { ...defaultFont, ...font }; - } - - const opts = Object.assign({}, defaultOptions, options); - let s = String(token); - // empty - if (!s) { - return 0; - } - // whitespace - if (s in WHITESPACE) { - const cacheId = fontToString(font, true) + '/' + s; - if (!(cacheId in wCache)) { - wCache[cacheId] = measure(`_${s}_`, fontToString(font)) - measure('__', fontToString(font)); - } - return wCache[cacheId]; - } - // When there are line breaks in the string but we're either not trimming - // whitespace or not collapsing whitespace, do what the input element does - // and convert all "\n" to " ". - if (!opts.trim || !opts.collapse) { - s = s.replace(/\n/g, ' '); - } - else if (opts.trim) { - s = s.replace(/\n/g, ' ').trim(); - } - else if (opts.collapse) { - s = s.replace(/\s+/g, ' '); - } - const tracking = typeof token === 'string' ? 0 : token.font.tracking || 0; - return measure(s, fontToString(font)) + (font.size ?? 12) * tracking; -} diff --git a/src/measureText/getBrowserCanvas.ts b/src/measureText/getBrowserCanvas.ts new file mode 100644 index 0000000..0c54d0b --- /dev/null +++ b/src/measureText/getBrowserCanvas.ts @@ -0,0 +1,11 @@ +/* globals document OffscreenCanvas */ +import type { MinimalCanvas } from '../types.ts'; + +export function getBrowserCanvas (): MinimalCanvas | undefined { + const doc = typeof document !== 'undefined' ? document : null; + return ( + (typeof OffscreenCanvas !== 'undefined' && new OffscreenCanvas(100, 100)) || + (doc && doc.createElement && doc.createElement('canvas')) || + undefined + ); +} diff --git a/src/measureText/getDummyCanvas.ts b/src/measureText/getDummyCanvas.ts new file mode 100644 index 0000000..da9d9c2 --- /dev/null +++ b/src/measureText/getDummyCanvas.ts @@ -0,0 +1,79 @@ +import { WHITESPACE } from '../constants.ts'; +import { fontStringParser } from '../fontStringParser.ts'; +import type { MinimalCanvas, MinimalCanvasContext } from '../types.ts'; + +// This is just guesswork but works surprisingly well. +// Intended to be used to renderer for tests, or as a last resort in a server env. + +// Character width multipliers for Excel formula-based calculation +const CHAR_WIDTH_MULTIPLIERS: Record = { + ...WHITESPACE, + // Wide characters + 'W': 1.5, + 'M': 1.4, + '@': 1.4, + 'm': 1.2, + 'w': 1.2, + // Narrow characters + 'i': 0.4, + 'l': 0.5, + 'I': 0.4, + 'j': 0.4, + // Punctuation + ' ': 0.5, + '.': 0.5, + ',': 0.5, + ':': 0.5, + ';': 0.5 + // Default for unmapped characters: 1.0 +}; + +function isUppercase (str: string) { + return /^\p{Lu}+$/u.test(str); +} + +export function measureText (text: string, font: string): number { + if (!text) { + return 0; + } + + const f = fontStringParser(font); + const isMonospace = /\bmonospace\b/.test(f.family ?? font); + const isNarrow = /\b(narrow|condensed|compressed)\b/i.test(f.family ?? font); + + // approximate the font size + let size = f.size ?? 12; + if (isMonospace) { + size *= 0.6; + } + else { + size *= 0.45; + if (f.weight && f.weight > 400) { + size *= 1.18; + } + } + + let totalWidth = 0; + for (const char of text) { + const multiplier = CHAR_WIDTH_MULTIPLIERS[char] || (isUppercase(char) ? 1.2 : 1); + totalWidth += multiplier; + } + + // Generally, we overestimate condensed fonts like "Aptos Narrow" by ~25-30%. Scaling down by 0.88 for + // known narrow/condensed fonts brings the estimate back in line with actual rendered widths. + const condensedFactor = isNarrow ? 0.88 : 1; + + return totalWidth * size * condensedFactor; +} + +const ctx: MinimalCanvasContext = { + font: '', + measureText: (text: string) => { + const width = measureText(text, ctx.font); + return { width }; + } +}; + +export function getDummyCanvas (): MinimalCanvas { + return { getContext: () => ctx }; +} diff --git a/src/measureText.spec.ts b/src/measureText/measureText.spec.ts similarity index 78% rename from src/measureText.spec.ts rename to src/measureText/measureText.spec.ts index 9a736b1..95835ec 100644 --- a/src/measureText.spec.ts +++ b/src/measureText/measureText.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { measureText, setMeasureCanvas, getDumbHandler } from './measureText.ts'; -import { Token } from './parser/tokens.ts'; +import { measureText, setMeasureCanvas } from './measureText.ts'; +import { Token } from '../parser/tokens.ts'; describe('setMeasureCanvas()', () => { afterEach(() => { @@ -11,23 +11,11 @@ describe('setMeasureCanvas()', () => { // faking the canvas interface const mockCanvas = { getContext: () => { - return { font: '', measureText: () => ({ width: 36 }) }; + return { font: '', measureText: () => ({ width: 0.36 }) }; } }; setMeasureCanvas(mockCanvas); - expect(measureText('TEST', '20px sans-serif')).toBe(36); - }); -}); - -describe('measureText.getDumbHandler()', () => { - it('returns function', () => { - const c = getDumbHandler(); - expect(typeof c).toBe('function'); - }); - - it('measures text', () => { - const c = getDumbHandler(); - expect(c('TEST', '20px sans-serif')).toBe(36); + expect(measureText('TEST', '20px sans-serif')).toBe(0.36); }); }); @@ -35,7 +23,7 @@ describe('measureText()', () => { it('measures given token', () => { const token = new Token('TEST'); const font = '20px sans-serif'; - expect(measureText(token, font)).toBe(36); + expect(measureText(token, font)).toBe(43.199999999999996); }); it('measures given string', () => { @@ -58,9 +46,9 @@ describe('measureText() without whitespace trimming', () => { const token = 'TEST'; const tokenWithWhitespace = ' TEST '; const font = '20px sans-serif'; - expect(measureText(token, font)).toBeLessThan( - measureText(tokenWithWhitespace, font, { trim: false }) - ); + const measureBasic = measureText(token, font); + const measureNoTrim = measureText(tokenWithWhitespace, font, { trim: false }); + expect(measureBasic).toBeLessThan(measureNoTrim); }); }); diff --git a/src/measureText/measureText.ts b/src/measureText/measureText.ts new file mode 100644 index 0000000..0fd4b14 --- /dev/null +++ b/src/measureText/measureText.ts @@ -0,0 +1,109 @@ +import { fontStringParser } from '../fontStringParser.ts'; +import { fontToString } from '../fontToString.ts'; +import type { Token } from '../parser/tokens.ts'; +import type { MinimalCanvas, FontProps, MeasureOptions } from '../types.ts'; +import { getBrowserCanvas } from './getBrowserCanvas.ts'; +import { getDummyCanvas } from './getDummyCanvas.ts'; + +const DEFAULT_FONT: FontProps = fontStringParser('12px/14px sans-serif'); +const DEFAULT_OPTIONS: MeasureOptions = { trim: true, collapse: true }; + +const whitespaceCache = new Map(); + +let measureCanvas: MinimalCanvas = getBrowserCanvas() || getDummyCanvas(); + +// This misses MONGOLIAN-VOWEL-SEPARATOR and ZERO-WIDTH-SPACE but they +// are both zero width, very uncommon, and can be measured normally +function isPureWhitespace (s: string) { + return !/\S/.test(s); +} + +/** + * Set the canvas to use when measuring text. This will be needed when using {@link measureText} + * in a non-browser environment. + * @param canvas The canvas interface to use. + */ +export function setMeasureCanvas (canvas: MinimalCanvas | null) { + if (canvas == null) { + measureCanvas = getBrowserCanvas() || getDummyCanvas(); + } + else if (canvas && typeof canvas.getContext === 'function') { + measureCanvas = canvas; + } + else { + throw new Error('setMeasureCanvas argument must be a minimal canvas or a null'); + } +} + +function measureTextCanvas (text: string, font: FontProps, tracking: number = 0): number { + const context = measureCanvas.getContext('2d'); + if (!context || typeof context.measureText !== 'function') { + throw new Error('Canvas did not return a valid context'); + } + context.font = fontToString(font); + const width = context.measureText(text).width; + const trackingExtra = (font.size ?? 12) * tracking; + + return width + trackingExtra; +} + +/** + * Measure a string of text as printed with a specified font and return its width. + * + * Be careful that in non-browser environments you may want to supply an alternative + * canvas using {@link setMeasureCanvas} or this method will default to a crude + * "best guess" method. + * + * @param token A string or token of text to measure. + * @param font A CSS font shorthand string or a collection of font properties. + * @param options Text handling options. + */ +export function measureText ( + token: string | Token, + font: string | FontProps, + options?: MeasureOptions +): number { + if (typeof font === 'string') { + font = fontStringParser(font); + } + else { + font = { ...DEFAULT_FONT, ...font }; + } + + const tracking = typeof token === 'string' ? 0 : token.font.tracking || 0; + + const trim = options?.trim ?? DEFAULT_OPTIONS.trim; + const collapse = options?.collapse ?? DEFAULT_OPTIONS.collapse; + + let s = String(token); + // empty string + if (!s) { + return 0; + } + + // pure-whitespace handling + if (isPureWhitespace(s)) { + const cacheId = fontToString(font, true) + '/' + tracking + '/' + s; + if (whitespaceCache.has(cacheId)) { + return whitespaceCache.get(cacheId)!; + } + const w = measureTextCanvas(`_${s}_`, font, tracking) - measureTextCanvas('__', font, tracking); + whitespaceCache.set(cacheId, w); + return w; + } + + // When there are line breaks in the string but we're either not trimming + // whitespace or not collapsing whitespace, do what the input element does + // and convert all "\n" to " ". + if (!trim || !collapse) { + s = s.replace(/\n/g, ' '); + } + else if (trim) { + s = s.replace(/\n/g, ' ').trim(); + } + else if (collapse) { + s = s.replace(/\s+/g, ' '); + } + + return measureTextCanvas(s, font, tracking); +}