Files
dpage-editor/src/pages/editor/utils/textStyle.ts
T
hamid zarghami b7ffb2b857
deploy to danak / build_and_deploy (push) Has been cancelled
fonts
2026-06-15 09:35:24 +03:30

216 lines
5.9 KiB
TypeScript

import { getFontFamily } from "@/pages/editor/utils/fontFamily";
/** CSS font-weight values for canvas/Konva text rendering. */
export const getCssFontWeight = (fontWeight?: string): number => {
if (!fontWeight || fontWeight === "normal") return 400;
if (fontWeight === "bold" || fontWeight === "bolder") return 700;
const n = parseInt(fontWeight, 10);
if (!Number.isNaN(n)) return n;
return 400;
};
/** Konva.Text uses `fontStyle: bold` (canvas keyword), not numeric weight. */
export const usesKonvaBoldStyle = (fontWeight?: string): boolean => {
if (!fontWeight || fontWeight === "normal") return false;
if (fontWeight === "bold" || fontWeight === "bolder") return true;
const n = parseInt(fontWeight, 10);
return !Number.isNaN(n) && n >= 600;
};
/** Canvas/CSS `font` string shared by editor measurement and viewer layout. */
export const buildCanvasFont = (
fontSize: number,
fontFamily?: string,
fontWeight?: string,
): string => {
const weight = getCssFontWeight(fontWeight);
const family = getFontFamily(fontFamily);
return `${weight} ${fontSize}px ${family}`;
};
/** Canvas font string that matches Konva.Text (`fontStyle: bold`). */
export const buildKonvaCanvasFont = (
fontSize: number,
fontFamily?: string,
fontWeight?: string,
): string => {
const family = getFontFamily(fontFamily);
const style = usesKonvaBoldStyle(fontWeight) ? "bold" : "normal";
return `${style} ${fontSize}px ${family}`;
};
const LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
export const isSingleLineText = (text?: string): boolean =>
!text || !LINE_BREAK_RE.test(text);
/**
* Konva can wrap text inside a fixed width without inserting `\n`.
* Use stored height to detect multi-line layout.
*/
export const usesWrappedLayout = (
text: string | undefined,
height: number | undefined,
fontSize: number,
lineHeight = 1.2,
): boolean => {
if (!isSingleLineText(text)) return true;
const linePx = fontSize * lineHeight;
return (height ?? 0) > linePx * 1.35;
};
/** Extra px so HTML layout does not wrap tighter than Konva/canvas metrics. */
export const SINGLE_LINE_WIDTH_SLACK_PX = 12;
export type TextMeasureOptions = {
fontSize: number;
fontFamily?: string;
fontWeight?: string;
letterSpacing?: number;
lineHeight?: number;
};
const measureLineWidth = (
ctx: CanvasRenderingContext2D,
line: string,
letterSpacing: number,
): number => {
let lineWidth = ctx.measureText(line).width;
if (letterSpacing && line.length > 1) {
lineWidth += letterSpacing * (line.length - 1);
}
return lineWidth;
};
/** Measure text using CSS and Konva font strings; returns the wider result. */
export const measureTextBlock = (
text: string,
options: TextMeasureOptions,
): { width: number; height: number; lineCount: number } => {
if (!text) return { width: 0, height: 0, lineCount: 0 };
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return { width: 0, height: 0, lineCount: 1 };
const {
fontSize,
fontFamily,
fontWeight,
letterSpacing = 0,
lineHeight = 1.2,
} = options;
const lines = text.split(LINE_BREAK_RE);
const fonts = [
buildCanvasFont(fontSize, fontFamily, fontWeight),
buildKonvaCanvasFont(fontSize, fontFamily, fontWeight),
];
let maxWidth = 0;
for (const font of fonts) {
ctx.font = font;
for (const line of lines) {
maxWidth = Math.max(maxWidth, measureLineWidth(ctx, line, letterSpacing));
}
}
return {
width: Math.ceil(maxWidth),
height: Math.ceil(lines.length * fontSize * lineHeight),
lineCount: lines.length,
};
};
/** Width used for layout: at least stored Konva width and canvas measurement. */
export const getEffectiveTextWidth = (
storedWidth: number | undefined,
text: string | undefined,
options: TextMeasureOptions,
singleLine: boolean,
): number | undefined => {
if (!text && !storedWidth) return undefined;
const measured = measureTextBlock(text || "", options).width;
const base = Math.max(storedWidth || 0, measured);
if (!singleLine) return base || undefined;
return Math.max(1, base + SINGLE_LINE_WIDTH_SLACK_PX);
};
/** Scaled width for viewer text boxes (includes slack to avoid tighter CSS wrapping than Konva). */
export const getScaledTextWidth = (
width: number | undefined,
scale: number,
): number | undefined => {
if (!width) return undefined;
return Math.ceil(width * scale) + Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale);
};
export type TextAlignValue = "left" | "center" | "right";
export type ViewerTextLayout = {
leftPx: number;
widthPx: number | undefined;
textAlign: TextAlignValue;
transform: string | undefined;
transformOrigin: "top right";
};
/**
* Viewer HTML layout matching Konva TextShape: (x, y) is the top-right anchor of the text box.
* Text alignment is applied inside a fixed-width box that extends left from the anchor.
*/
export function getViewerTextLayout(args: {
x: number;
width?: number;
text?: string;
textAlign?: TextAlignValue;
rotation?: number;
scale: number;
measureOpts: TextMeasureOptions;
wrapped: boolean;
}): ViewerTextLayout {
const {
x,
width: storedWidth,
text,
textAlign = "right",
rotation = 0,
scale,
measureOpts,
wrapped,
} = args;
const layoutWidth = getEffectiveTextWidth(
storedWidth,
text,
measureOpts,
!wrapped,
);
const widthPx = getScaledTextWidth(layoutWidth, scale);
const slackPx =
widthPx !== undefined ? Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale) : 0;
const rightPx = x * scale;
const boxWidthUnscaled = layoutWidth ?? storedWidth ?? 0;
let leftPx: number;
if (widthPx !== undefined && boxWidthUnscaled > 0) {
leftPx = Math.round(rightPx - boxWidthUnscaled * scale) - slackPx;
} else {
leftPx = Math.round(rightPx);
}
const rotationTransform = rotation ? `rotate(${rotation}deg)` : undefined;
return {
leftPx,
widthPx,
textAlign,
transform: rotationTransform,
transformOrigin: "top right",
};
}