// Ratio and dimension formatting helpers. // Keep compatibility with a few legacy mojibake tokens, but never emit them. // normalizeRatioForPlatform / formatUploadedImageRatio 因依赖平台规格表与 CloneImageItem, // 暂留在 EcommercePage.tsx,后续随 platformSpec 一起整理。 const LEGACY_MULTIPLY_SIGN = "\u8133"; const LEGACY_FULLWIDTH_COLON = "\u951b?"; const LEGACY_PRODUCT_IMAGE_LABEL = "\u935f\u55d7\u6427\u9365?"; export const normalizeRatioToken = (value: string) => value .replaceAll("\u00a0", " ") .replaceAll(LEGACY_MULTIPLY_SIGN, "×") .replaceAll("*", "×") .replaceAll(":", ":") .replaceAll(LEGACY_FULLWIDTH_COLON, ":") .replace(/\s+/g, " ") .trim(); export const quickSetRatioOptions = ["1:1", "3:4", "4:3", "9:16", "16:9"]; export const greatestCommonDivisor = (left: number, right: number): number => { let a = Math.abs(left); let b = Math.abs(right); while (b) { [a, b] = [b, a % b]; } return a || 1; }; export const formatAspectRatio = (width: number, height: number) => { const divisor = greatestCommonDivisor(width, height); return `${Math.round(width / divisor)}:${Math.round(height / divisor)}`; }; export const getQuickSetRatioValue = (value: string) => { const normalizedValue = normalizeRatioToken(value); if (quickSetRatioOptions.includes(normalizedValue)) return normalizedValue; const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)/u); if (sizeMatch) { const width = Number(sizeMatch[1]); const height = Number(sizeMatch[2]); if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { const aspect = formatAspectRatio(width, height); if (quickSetRatioOptions.includes(aspect)) return aspect; } } const ratioMatch = normalizedValue.match(/(\d+)\s*[::]\s*(\d+)/u); if (ratioMatch) { const aspect = `${Number(ratioMatch[1])}:${Number(ratioMatch[2])}`; if (quickSetRatioOptions.includes(aspect)) return aspect; } return quickSetRatioOptions[0]!; }; export const formatRatioDisplayValue = (value: string) => { const normalizedValue = normalizeRatioToken(value); const sizeMatch = normalizedValue.match(/(\d+)\s*[×xX]\s*(\d+)\s*px?/u); if (sizeMatch) { const width = Number(sizeMatch[1]); const height = Number(sizeMatch[2]); return `${width}×${height}px\u00a0\u00a0\u00a0${formatAspectRatio(width, height)}`; } return normalizedValue .replace("淘宝主图 / SKU 图 ", "淘宝主图 / SKU 图 ") .replace("京东主图 / SKU 图 ", "京东主图 / SKU 图 ") .replace("详情页宽", "详情页宽") .replace("短视频", "短视频") .replace("主图", "主图") .replace("商品主图", "商品主图") .replace(LEGACY_PRODUCT_IMAGE_LABEL, "商品图") .replace(/\s+:/g, ":") .replace(/:\s+/g, ":"); }; export const getRatioDisplayParts = (value: string) => { const display = formatRatioDisplayValue(value).replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim(); const aspectMatch = display.match(/(\d+\s*[::]\s*\d+)(?!.*\d+\s*[::]\s*\d+)/u); const aspect = aspectMatch?.[1]?.replace(/\s+/g, "") ?? "自适应"; const size = aspectMatch ? display.replace(aspectMatch[0], "").trim() : display; return { size: size || "原图比例", aspect, }; }; /** Extract CSS aspect-ratio from a ratio string like "1000x1000px 1:1" -> "1 / 1" */ export const parseRatioToAspectCss = (ratioStr: string): string => { const match = ratioStr.match(/(\d+)\D+(\d+)/u); if (!match) return "1 / 1"; return `${match[1]} / ${match[2]}`; }; export const supportedImageApiRatios = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const; export type SupportedImageApiRatio = typeof supportedImageApiRatios[number]; export const toSupportedImageApiRatio = (width: number, height: number): SupportedImageApiRatio => { if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return "1:1"; let bestRatio: SupportedImageApiRatio = "1:1"; let bestScore = Number.POSITIVE_INFINITY; const target = Math.log(width / height); for (const ratio of supportedImageApiRatios) { const [left, right] = ratio.split(":").map(Number); const score = Math.abs(target - Math.log(left / right)); if (score < bestScore) { bestRatio = ratio; bestScore = score; } } return bestRatio; }; /** Normalize ratio display string ("1000×1000px 1:1") to an image API aspect ratio ("1:1"). */ export const normalizeRatioForApi = (ratioStr: string): string => { const normalizedValue = normalizeRatioToken(ratioStr); const explicitRatios = Array.from(normalizedValue.matchAll(/(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)/g)); const explicitRatio = explicitRatios.at(-1); if (explicitRatio) { return toSupportedImageApiRatio(Number(explicitRatio[1]), Number(explicitRatio[2])); } const sizeMatch = normalizedValue.match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)/u); if (!sizeMatch) return "1:1"; return toSupportedImageApiRatio(Number(sizeMatch[1]), Number(sizeMatch[2])); };