libpag
Version:
Portable Animated Graphics
167 lines (147 loc) • 5.89 kB
text/typescript
import { measureText } from '../utils/measure-text';
import { defaultFontNames, getFontFamilies } from '../utils/font-family';
import { getCanvas2D } from '../utils/canvas';
import type { Rect } from '../types';
export class ScalerContext {
public static canvas: HTMLCanvasElement | OffscreenCanvas;
public static context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
public static setCanvas(canvas: HTMLCanvasElement | OffscreenCanvas) {
ScalerContext.canvas = canvas;
}
public static setContext(context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) {
ScalerContext.context = context;
}
public static isUnicodePropertyEscapeSupported(): boolean {
try {
// eslint-disable-next-line prefer-regex-literals
const regex = new RegExp("\\p{L}", "u");
return true;
} catch (e) {
return false;
}
}
public static isEmoji(text: string): boolean {
let emojiRegExp: RegExp;
if (this.isUnicodePropertyEscapeSupported()) {
emojiRegExp = /\p{Extended_Pictographic}|[#*0-9]\uFE0F?\u20E3|[\uD83C\uDDE6-\uD83C\uDDFF]/u;
} else {
emojiRegExp = /(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/;
}
return emojiRegExp.test(text);
}
private readonly fontName: string;
private readonly fontStyle: string;
private readonly size: number;
private readonly fauxBold: boolean;
private readonly fauxItalic: boolean;
private fontBoundingBoxMap: { key: string; value: Rect }[] = [];
public constructor(fontName: string, fontStyle: string, size: number, fauxBold = false, fauxItalic = false) {
this.fontName = fontName;
this.fontStyle = fontStyle;
this.size = size;
this.fauxBold = fauxBold;
this.fauxItalic = fauxItalic;
this.loadCanvas();
}
public fontString() {
const attributes = [];
// css font-style
if (this.fauxItalic) {
attributes.push('italic');
}
// css font-weight
if (this.fauxBold) {
attributes.push('bold');
}
// css font-size
attributes.push(`${this.size}px`);
// css font-family
const fallbackFontNames = defaultFontNames.concat();
fallbackFontNames.unshift(...getFontFamilies(this.fontName, this.fontStyle));
attributes.push(`${fallbackFontNames.join(',')}`);
return attributes.join(' ');
}
public getTextAdvance(text: string) {
const { context } = ScalerContext;
context.font = this.fontString();
return context.measureText(text).width;
}
public getTextBounds(text: string) {
const { context } = ScalerContext;
context.font = this.fontString();
const metrics = this.measureText(context, text);
const bounds: Rect = {
left: Math.floor(-metrics.actualBoundingBoxLeft),
top: Math.floor(-metrics.actualBoundingBoxAscent),
right: Math.ceil(metrics.actualBoundingBoxRight),
bottom: Math.ceil(metrics.actualBoundingBoxDescent),
};
return bounds;
}
public generateFontMetrics() {
const { context } = ScalerContext;
context.font = this.fontString();
const metrics = this.measureText(context, '中');
const capHeight = metrics.actualBoundingBoxAscent;
const xMetrics = this.measureText(context, 'x');
const xHeight = xMetrics.actualBoundingBoxAscent;
return {
ascent: -metrics.fontBoundingBoxAscent,
descent: metrics.fontBoundingBoxDescent,
xHeight,
capHeight,
};
}
public generateImage(text: string, bounds: Rect) {
const canvas = getCanvas2D(bounds.right - bounds.left, bounds.bottom - bounds.top);
const context = canvas.getContext('2d') as CanvasRenderingContext2D;
context.font = this.fontString();
context.fillText(text, -bounds.left, -bounds.top);
return canvas;
}
protected loadCanvas() {
if (!ScalerContext.canvas) {
ScalerContext.setCanvas(getCanvas2D(10, 10));
// https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently
ScalerContext.setContext(
(ScalerContext.canvas as HTMLCanvasElement | OffscreenCanvas).getContext('2d', { willReadFrequently: true }) as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D,
);
}
}
private measureText(ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, text: string): TextMetrics {
const metrics = ctx.measureText(text);
if (metrics?.actualBoundingBoxAscent) return metrics;
ctx.canvas.width = this.size * 1.5;
ctx.canvas.height = this.size * 1.5;
const pos = [0, this.size];
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.font = this.fontString();
ctx.fillText(text, pos[0], pos[1]);
const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
const { left, top, right, bottom } = measureText(imageData);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
let fontMeasure: Rect;
const fontBoundingBox = this.fontBoundingBoxMap.find((item) => item.key === this.fontName);
if (fontBoundingBox) {
fontMeasure = fontBoundingBox.value;
} else {
ctx.font = this.fontString();
ctx.fillText('测', pos[0], pos[1]);
const fontImageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
fontMeasure = measureText(fontImageData);
this.fontBoundingBoxMap.push({ key: this.fontName, value: fontMeasure });
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
return {
actualBoundingBoxAscent: pos[1] - top,
actualBoundingBoxRight: right - pos[0],
actualBoundingBoxDescent: bottom - pos[1],
actualBoundingBoxLeft: pos[0] - left,
fontBoundingBoxAscent: fontMeasure.bottom - fontMeasure.top,
fontBoundingBoxDescent: 0,
width: fontMeasure.right - fontMeasure.left,
};
}
}