OpenCut/apps/web/src/text/primitives.ts

242 lines
5.8 KiB
TypeScript

import type { TextCanvasContext, TextBlockMeasurement } from "@/text/layout";
import { DEFAULTS } from "@/timeline/defaults";
import { clamp } from "@/utils/math";
import { CORNER_RADIUS_MAX, CORNER_RADIUS_MIN } from "./background";
import {
drawTextDecoration,
getTextBackgroundRect,
measureTextBlock,
setCanvasLetterSpacing,
} from "./layout";
import { FONT_SIZE_SCALE_REFERENCE } from "./typography";
export type TextAlign = "left" | "center" | "right";
export type TextFontWeight = "normal" | "bold";
export type TextFontStyle = "normal" | "italic";
export type TextDecoration = "none" | "underline" | "line-through";
export interface TextLayoutParams {
content: string;
fontSize: number;
fontFamily: string;
fontWeight: TextFontWeight;
fontStyle: TextFontStyle;
textAlign: TextAlign;
textDecoration?: TextDecoration;
letterSpacing?: number;
lineHeight?: number;
}
export interface ResolvedTextLayout {
scaledFontSize: number;
fontString: string;
letterSpacing: number;
lineHeightPx: number;
fontSizeRatio: number;
textAlign: TextAlign;
textDecoration: TextDecoration;
}
export interface MeasuredTextLayout extends ResolvedTextLayout {
lines: string[];
lineMetrics: TextMetrics[];
block: TextBlockMeasurement;
}
export interface ResolvedTextBackgroundLike {
enabled: boolean;
color: string;
paddingX: number;
paddingY: number;
offsetX: number;
offsetY: number;
cornerRadius: number;
}
export function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
return `"${fontFamily.replace(/"/g, '\\"')}"`;
}
export function buildTextFontString({
fontFamily,
fontWeight,
fontStyle,
scaledFontSize,
}: {
fontFamily: string;
fontWeight: TextFontWeight;
fontStyle: TextFontStyle;
scaledFontSize: number;
}): string {
return `${fontStyle} ${fontWeight} ${scaledFontSize}px ${quoteFontFamily({ fontFamily })}, sans-serif`;
}
export function resolveTextLayout({
text,
canvasHeight,
}: {
text: TextLayoutParams;
canvasHeight: number;
}): ResolvedTextLayout {
const scaledFontSize =
text.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
const fontWeight = text.fontWeight === "bold" ? "bold" : "normal";
const fontStyle = text.fontStyle === "italic" ? "italic" : "normal";
const letterSpacing = text.letterSpacing ?? DEFAULTS.text.letterSpacing;
const lineHeightPx =
scaledFontSize * (text.lineHeight ?? DEFAULTS.text.lineHeight);
const fontSizeRatio = text.fontSize / 15;
return {
scaledFontSize,
fontString: buildTextFontString({
fontFamily: text.fontFamily,
fontWeight,
fontStyle,
scaledFontSize,
}),
letterSpacing,
lineHeightPx,
fontSizeRatio,
textAlign: text.textAlign,
textDecoration: text.textDecoration ?? "none",
};
}
export function measureTextLayout({
text,
canvasHeight,
ctx,
}: {
text: TextLayoutParams;
canvasHeight: number;
ctx: TextCanvasContext;
}): MeasuredTextLayout {
const resolvedLayout = resolveTextLayout({ text, canvasHeight });
const lines = text.content.split("\n");
ctx.save();
ctx.font = resolvedLayout.fontString;
ctx.textBaseline = "middle";
setCanvasLetterSpacing({
ctx,
letterSpacingPx: resolvedLayout.letterSpacing,
});
const lineMetrics = lines.map((line) => ctx.measureText(line));
ctx.restore();
const block = measureTextBlock({
lineMetrics,
lineHeightPx: resolvedLayout.lineHeightPx,
});
return {
...resolvedLayout,
lines,
lineMetrics,
block,
};
}
export function drawMeasuredTextLayout({
ctx,
layout,
textColor,
background,
backgroundColor,
textBaseline = "middle",
}: {
ctx: TextCanvasContext;
layout: MeasuredTextLayout;
textColor: string;
background?: ResolvedTextBackgroundLike | null;
backgroundColor?: string;
textBaseline?: CanvasTextBaseline;
}): void {
ctx.font = layout.fontString;
ctx.textAlign = layout.textAlign;
ctx.textBaseline = textBaseline;
ctx.fillStyle = textColor;
setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
if (
background?.enabled &&
backgroundColor &&
backgroundColor !== "transparent" &&
layout.lines.length > 0
) {
const backgroundRect = getTextBackgroundRect({
textAlign: layout.textAlign,
block: layout.block,
background: {
...background,
color: backgroundColor,
},
fontSizeRatio: layout.fontSizeRatio,
});
if (backgroundRect) {
const p =
clamp({
value: background.cornerRadius,
min: CORNER_RADIUS_MIN,
max: CORNER_RADIUS_MAX,
}) / 100;
const radius =
(Math.min(backgroundRect.width, backgroundRect.height) / 2) * p;
ctx.fillStyle = backgroundColor;
ctx.beginPath();
ctx.roundRect(
backgroundRect.left,
backgroundRect.top,
backgroundRect.width,
backgroundRect.height,
radius,
);
ctx.fill();
ctx.fillStyle = textColor;
}
}
for (let index = 0; index < layout.lines.length; index++) {
const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
ctx.fillText(layout.lines[index], 0, lineY);
drawTextDecoration({
ctx,
textDecoration: layout.textDecoration,
lineWidth: layout.lineMetrics[index].width,
lineY,
metrics: layout.lineMetrics[index],
scaledFontSize: layout.scaledFontSize,
textAlign: layout.textAlign,
});
}
}
export function strokeMeasuredTextLayout({
ctx,
layout,
strokeColor,
strokeWidth,
textBaseline = "middle",
}: {
ctx: TextCanvasContext;
layout: MeasuredTextLayout;
strokeColor: string;
strokeWidth: number;
textBaseline?: CanvasTextBaseline;
}): void {
ctx.font = layout.fontString;
ctx.textAlign = layout.textAlign;
ctx.textBaseline = textBaseline;
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.lineJoin = "round";
ctx.lineCap = "round";
setCanvasLetterSpacing({ ctx, letterSpacingPx: layout.letterSpacing });
for (let index = 0; index < layout.lines.length; index++) {
const lineY = index * layout.lineHeightPx - layout.block.visualCenterOffset;
ctx.strokeText(layout.lines[index], 0, lineY);
}
}