Skip to content

Instantly share code, notes, and snippets.

@aleclarson
Created August 19, 2024 17:48
Show Gist options
  • Save aleclarson/55a580acfa5ba7817d62fb8c9e307228 to your computer and use it in GitHub Desktop.
Save aleclarson/55a580acfa5ba7817d62fb8c9e307228 to your computer and use it in GitHub Desktop.
import { LeastRecentlyUsedCache } from './lru'
const cache = new LeastRecentlyUsedCache<string, number>(500)
const canvasElem = document.createElement('canvas')
canvasElem.setAttribute(
'style',
'position: absolute; top: -2000px; left: -2000px; overflow: hidden; clip: rect(0 0 0 0); z-index: -100',
)
document.body.appendChild(canvasElem)
const normalizeFontWeight = (weight: string | number | undefined) =>
weight == null || weight == 'normal' ? 400 : weight == 'bold' ? 700 : +weight
const toCacheKey = (
word: string,
fontFamily: string,
fontSize: number,
fontWeight: string | number | undefined,
) => `${fontFamily};${fontSize};${normalizeFontWeight(fontWeight)};${word}`
export interface TextStyle {
fontFamily: string
fontSize: number
fontWeight?: string | number
letterSpacing?: number
}
export type MeasuredText = ReturnType<typeof measureText>
export function measureText(
text: string,
style: TextStyle,
maxLineWidth = Infinity,
firstLineMaxWidth = maxLineWidth,
) {
const words = text.split(/([ \-\n])/)
const wordWidths: number[] = []
const lines: string[] = []
const lineWidths: number[] = []
const removeTrailingSpace = (line: number) => {
const text = lines[line]
if (text && text[text.length - 1] == ' ') {
lines[line] = text.slice(0, -1)
lineWidths[line] -= measureWord(' ', style)
}
}
let currentLine = ''
let currentLineWidth = 0
let currentLineMaxWidth = firstLineMaxWidth
const letterSpacing = style.letterSpacing || 0
const getNextLineWidth = (word: string, width: number) => {
return currentLineWidth + width + letterSpacing * (currentLine.length + word.length)
}
const finishLine = () => {
lines.push(currentLine)
lineWidths.push(getNextLineWidth('', 0))
removeTrailingSpace(lines.length - 1)
}
for (let i = 0; i < words.length; i++) {
let word = words[i]
if (word == '\n') {
finishLine()
currentLine = ''
currentLineWidth = 0
continue
}
let width = measureWord(word, style)
wordWidths[i] = width
if (getNextLineWidth(word, width) > currentLineMaxWidth) {
// If a word is longer than the maximum line width, split it into
// multiple lines.
if (currentLineWidth == 0 || width > currentLineMaxWidth) {
const chars = word.split('')
const charWidths = chars.map(char => {
return measureWord(char, style)
})
for (let i = 0; i < chars.length; i++) {
const char = chars[i]
const charWidth = charWidths[i]
if (getNextLineWidth(char, charWidth) > currentLineMaxWidth) {
finishLine()
if (i == 0) {
currentLine = char
currentLineWidth = charWidth
} else {
const prevChar = chars[i - 1]
const prevCharWidth = charWidths[i - 1]
// Replace previous char with a hyphen.
lines[lines.length - 1] = lines.at(-1)!.slice(0, -1) + '-'
lineWidths[lineWidths.length - 1] += -prevCharWidth + measureWord('-', style)
// Move previous char and current char to next line.
currentLine = prevChar + char
currentLineWidth = prevCharWidth + charWidth
}
} else {
currentLine += char
currentLineWidth += charWidth
}
}
} else {
if (word != ' ') {
i-- // Add to next line, unless this is a space.
}
finishLine()
currentLine = ''
currentLineWidth = 0
currentLineMaxWidth = maxLineWidth
}
} else {
currentLine += word
currentLineWidth += width
}
}
if (currentLineWidth > 0) {
finishLine()
}
return { words, wordWidths, lines, lineWidths }
}
export function measureWord(word: string, { fontFamily, fontSize, fontWeight }: TextStyle): number {
if (!word.length) {
return 0
}
// fontSize =
// Math.round(fontSize * window.devicePixelRatio) / window.devicePixelRatio
const key = toCacheKey(word, fontFamily, fontSize, fontWeight)
const cached = cache.get(key)
if (cached != null) {
return cached
}
const ctx = canvasElem.getContext('2d')!
ctx.font = `${fontWeight} ${fontSize}px "${fontFamily}"`
const { width } = ctx.measureText(word)
cache.set(key, width)
return width
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment