Files
CRM_Montzar/apps/web/src/lib/form-file-compress.ts
T
MahdiandCursor 1d3588a69d feat(activities): attach compressed photos to activity notes
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>
2026-10-27 21:30:45 +03:30

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