126 lines
5.0 KiB
TypeScript
126 lines
5.0 KiB
TypeScript
// 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]));
|
||
};
|