Photos on an activity are shrunk with the form-builder compressor and stored only when they fit under 200KB. Co-authored-by: Cursor <cursoragent@cursor.com>
147 lines
4.3 KiB
TypeScript
147 lines
4.3 KiB
TypeScript
/** سقف حجم فایل فیلد فرم عمومی پس از فشردهسازی */
|
|
export const FORM_FILE_MAX_BYTES = 200 * 1024;
|
|
|
|
export class FormFileTooLargeError extends Error {
|
|
constructor(message = 'حجم فایل زیاد است') {
|
|
super(message);
|
|
this.name = 'FormFileTooLargeError';
|
|
}
|
|
}
|
|
|
|
function toBlob(canvas: HTMLCanvasElement, type: string, quality?: number): Promise<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
canvas.toBlob(
|
|
(b) => (b ? resolve(b) : reject(new Error('فشردهسازی تصویر ناموفق بود.'))),
|
|
type,
|
|
quality,
|
|
);
|
|
});
|
|
}
|
|
|
|
function fileToDataUrl(file: Blob): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => resolve(String(reader.result || ''));
|
|
reader.onerror = () => reject(new Error('خواندن فایل ناموفق بود.'));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
async function compressImageUnder(file: File, maxBytes: number): Promise<File> {
|
|
if (file.size <= maxBytes) return file;
|
|
|
|
let bitmap: ImageBitmap;
|
|
try {
|
|
bitmap = await createImageBitmap(file);
|
|
} catch {
|
|
throw new FormFileTooLargeError();
|
|
}
|
|
|
|
let edge = Math.max(bitmap.width, bitmap.height);
|
|
edge = Math.min(edge, 1600);
|
|
let quality = 0.85;
|
|
let best: File | null = null;
|
|
|
|
try {
|
|
for (let attempt = 0; attempt < 14; attempt += 1) {
|
|
const ratio = Math.min(edge / bitmap.width, edge / bitmap.height, 1);
|
|
const width = Math.max(1, Math.round(bitmap.width * ratio));
|
|
const height = Math.max(1, Math.round(bitmap.height * ratio));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) throw new FormFileTooLargeError();
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
|
|
let blob: Blob;
|
|
try {
|
|
blob = await toBlob(canvas, 'image/jpeg', quality);
|
|
} catch {
|
|
blob = await toBlob(canvas, 'image/webp', quality);
|
|
}
|
|
|
|
const base = file.name.replace(/\.[^/.]+$/, '') || 'file';
|
|
const ext = blob.type === 'image/webp' ? 'webp' : 'jpg';
|
|
const out = new File([blob], `${base}.${ext}`, {
|
|
type: blob.type,
|
|
lastModified: Date.now(),
|
|
});
|
|
if (!best || out.size < best.size) best = out;
|
|
if (out.size <= maxBytes) return out;
|
|
|
|
if (quality > 0.45) {
|
|
quality = Math.max(0.4, quality - 0.1);
|
|
} else {
|
|
quality = 0.72;
|
|
edge = Math.round(edge * 0.72);
|
|
if (edge < 320) break;
|
|
}
|
|
}
|
|
} finally {
|
|
bitmap.close();
|
|
}
|
|
|
|
if (best && best.size <= maxBytes) return best;
|
|
throw new FormFileTooLargeError();
|
|
}
|
|
|
|
/**
|
|
* فایل را تا سقف ۲۰۰ کیلوبایت فشرده میکند (تصاویر با کاهش کیفیت/ابعاد).
|
|
* اگر ممکن نباشد، خطای «حجم فایل زیاد است» میدهد.
|
|
*/
|
|
export async function prepareFormFieldFile(file: File): Promise<{
|
|
name: string;
|
|
mime: string;
|
|
size: number;
|
|
dataUrl: string;
|
|
file: File;
|
|
}> {
|
|
let prepared = file;
|
|
if (file.type.startsWith('image/')) {
|
|
prepared = await compressImageUnder(file, FORM_FILE_MAX_BYTES);
|
|
} else if (file.size > FORM_FILE_MAX_BYTES) {
|
|
throw new FormFileTooLargeError();
|
|
}
|
|
|
|
if (prepared.size > FORM_FILE_MAX_BYTES) {
|
|
throw new FormFileTooLargeError();
|
|
}
|
|
|
|
const dataUrl = await fileToDataUrl(prepared);
|
|
return {
|
|
name: prepared.name || file.name || 'file',
|
|
mime: prepared.type || file.type || 'application/octet-stream',
|
|
size: prepared.size,
|
|
dataUrl,
|
|
file: prepared,
|
|
};
|
|
}
|
|
|
|
/** مقدار ذخیرهشده در پاسخ فرم: نام + data URL */
|
|
export function encodeFormFileValue(meta: {
|
|
name: string;
|
|
mime: string;
|
|
dataUrl: string;
|
|
}): string {
|
|
return JSON.stringify({
|
|
name: meta.name,
|
|
mime: meta.mime,
|
|
dataUrl: meta.dataUrl,
|
|
});
|
|
}
|
|
|
|
export function formFileDisplayName(raw: string | undefined | null): string {
|
|
if (!raw) return '';
|
|
try {
|
|
const parsed = JSON.parse(raw) as { name?: string };
|
|
if (parsed?.name) return parsed.name;
|
|
} catch {
|
|
/* plain string */
|
|
}
|
|
if (raw.startsWith('data:')) return 'فایل پیوست';
|
|
return raw.length > 48 ? `${raw.slice(0, 48)}…` : raw;
|
|
}
|