122 lines
4.8 KiB
TypeScript
122 lines
4.8 KiB
TypeScript
|
|
// 比例 / 尺寸相关的纯计算工具。
|
|||
|
|
// 从 EcommercePage.tsx 抽出,逻辑零改动,仅加 export 以便单测。
|
|||
|
|
// normalizeRatioForPlatform / formatUploadedImageRatio 因依赖平台规格表与 CloneImageItem,
|
|||
|
|
// 暂留在 EcommercePage.tsx,后续随 platformSpec 一起整理。
|
|||
|
|
|
|||
|
|
export const normalizeRatioToken = (value: string) =>
|
|||
|
|
value
|
|||
|
|
.replaceAll("\u00a0", " ")
|
|||
|
|
.replaceAll("脳", "×")
|
|||
|
|
.replaceAll("*", "×")
|
|||
|
|
.replaceAll(":", ":")
|
|||
|
|
.replace(/锛\?/g, ":")
|
|||
|
|
.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("鍟嗗搧鍥?", "商品图")
|
|||
|
|
.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]));
|
|||
|
|
};
|