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", }; }