diff --git a/src/App.tsx b/src/App.tsx index 2dba6ea..9576214 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -55,9 +55,10 @@ const ProductAnalyticsChart = lazy(() => import('./components/ProductAnalyticsCh import { getInitialTemplateLayers, getBlankCanvasLayers, DEFAULT_INFOGRAPHIC_ROWS } from './data/studioTemplates'; import { useStudioStore, onPersistError } from './store'; import { parseProjectFile, MAX_PROJECT_FILE_BYTES } from './store/projectFile'; -import { getAssetSpec, MARKETPLACES } from './features/marketplace/specs'; +import { getAssetSpec, MARKETPLACES, type AssetId } from './features/marketplace/specs'; import { checkCompliance, type ComplianceReport } from './features/marketplace/compliance'; import { measureLayers } from './features/marketplace/measure'; +import { getExportAssetKey, type ExportDimensions, type ExportTarget } from './utils/exportDimensions'; import { parseCsv } from './features/batch/csv'; import { useTemplateVars } from './features/batch/template'; import { BrandKit, DEFAULT_BRAND_KIT, applyBrandToLayers, brandGradient, loadBrandKit, saveBrandKit } from './features/brand/brandKit'; @@ -105,6 +106,11 @@ export function App() { return { width, height, label }; }; + const getAssetExportDimensions = (assetId: AssetId): ExportDimensions => { + const { width, height } = getAssetDimensions(targetPlatform, assetId); + return { width, height }; + }; + // Real compliance check of the active asset against the target marketplace spec. // Measured from the live canvas, so it must run while the studio canvas is mounted. const [complianceReport, setComplianceReport] = useState<(ComplianceReport & { assetLabel: string; platform: string }) | null>(null); @@ -1359,12 +1365,11 @@ export function App() { // 2. Allow React to re-render without selection ring await new Promise((resolve) => setTimeout(resolve, 80)); - // 3. Compute appropriate pixelRatio for high-resolution output matching Zhaket specs - const { width: realW } = getAssetDimensions(targetPlatform, activeAsset); - const { displayWidth } = getWorkspaceDisplaySize(targetPlatform, activeAsset); - const pixelRatio = (realW / displayWidth) * 2; // 2x crispiness for high-res HD output - - await (await loadExportUtils()).exportToPng(el, `zhaket-${activeAsset}-${Date.now()}.png`, pixelRatio); + await (await loadExportUtils()).exportToPng( + el, + `zhaket-${activeAsset}-${Date.now()}.png`, + getAssetExportDimensions(activeAsset), + ); setToastMessage('فایل PNG دقیقاً عینا مطابق بوم طراحی با کیفیت عالی دانلود شد.'); } catch (err: any) { console.error(err); @@ -1391,7 +1396,12 @@ export function App() { try { await new Promise((resolve) => setTimeout(resolve, 80)); - await (await loadExportUtils()).exportToPdf(el, `zhaket-${activeAsset}-${Date.now()}.pdf`); + await (await loadExportUtils()).exportToPdf( + el, + `zhaket-${activeAsset}-${Date.now()}.pdf`, + undefined, + getAssetExportDimensions(activeAsset), + ); setToastMessage('فایل PDF با آخرین تغییرات بوم دانلود شد.'); } catch (err: any) { console.error(err); @@ -1418,7 +1428,11 @@ export function App() { try { await new Promise((resolve) => setTimeout(resolve, 80)); - await (await loadExportUtils()).exportToPsd(el, `zhaket-${activeAsset}-${Date.now()}.psd`); + await (await loadExportUtils()).exportToPsd( + el, + `zhaket-${activeAsset}-${Date.now()}.psd`, + getAssetExportDimensions(activeAsset), + ); setToastMessage('فایل لایه‌باز PSD فتوشاپ با موفقیت دانلود شد.'); } catch (err: any) { console.error(err); @@ -1431,35 +1445,42 @@ export function App() { }; const collectExportMap = () => { - const map: { [key: string]: HTMLElement } = {}; + const map: { [key: string]: ExportTarget } = {}; + const add = (prefix: string, assetId: AssetId, element: HTMLElement | null) => { + if (!element) return; + const dimensions = getAssetExportDimensions(assetId); + const key = getExportAssetKey(prefix, dimensions); + map[key] = { element, dimensions }; + }; + if (activeAsset === 'logo80' && activeLiveCanvasRef.current) { - map['1_logo80x80'] = activeLiveCanvasRef.current; - } else if (exportLogo80Ref.current) { - map['1_logo80x80'] = exportLogo80Ref.current; + add('1_logo', 'logo80', activeLiveCanvasRef.current); + } else { + add('1_logo', 'logo80', exportLogo80Ref.current); } if (activeAsset === 'cover400' && activeLiveCanvasRef.current) { - map['2_cover400x400'] = activeLiveCanvasRef.current; - } else if (exportCover400Ref.current) { - map['2_cover400x400'] = exportCover400Ref.current; + add('2_cover400', 'cover400', activeLiveCanvasRef.current); + } else { + add('2_cover400', 'cover400', exportCover400Ref.current); } if (activeAsset === 'cover700_1' && activeLiveCanvasRef.current) { - map['3_cover700_feature1'] = activeLiveCanvasRef.current; - } else if (exportCover700_1Ref.current) { - map['3_cover700_feature1'] = exportCover700_1Ref.current; + add('3_cover700_feature1', 'cover700_1', activeLiveCanvasRef.current); + } else { + add('3_cover700_feature1', 'cover700_1', exportCover700_1Ref.current); } if (activeAsset === 'cover700_2' && activeLiveCanvasRef.current) { - map['4_cover700_feature2'] = activeLiveCanvasRef.current; - } else if (exportCover700_2Ref.current) { - map['4_cover700_feature2'] = exportCover700_2Ref.current; + add('4_cover700_feature2', 'cover700_2', activeLiveCanvasRef.current); + } else { + add('4_cover700_feature2', 'cover700_2', exportCover700_2Ref.current); } if (exportInfographicRef.current) { - map['5_infographic_594x4000'] = exportInfographicRef.current; + add('5_infographic', 'infographic', exportInfographicRef.current); } else if (activeAsset === 'infographic' && activeLiveCanvasRef.current) { - map['5_infographic_594x4000'] = activeLiveCanvasRef.current; + add('5_infographic', 'infographic', activeLiveCanvasRef.current); } return map; @@ -1546,6 +1567,10 @@ export function App() { const { displayWidth: activeDispW, displayHeight: activeDispH, scale: activeScale } = getWorkspaceDisplaySize(targetPlatform, activeAsset); const { width: activeRealW, height: activeRealH, label: activeRealLabel } = getAssetDimensions(targetPlatform, activeAsset); + const exportLogoDisplay = getWorkspaceDisplaySize(targetPlatform, 'logo80'); + const exportCover400Display = getWorkspaceDisplaySize(targetPlatform, 'cover400'); + const exportCover700_1Display = getWorkspaceDisplaySize(targetPlatform, 'cover700_1'); + const exportCover700_2Display = getWorkspaceDisplaySize(targetPlatform, 'cover700_2'); // UI always uses Vazirmatn; the user-selected/uploaded font applies only to canvases and exports. return ( @@ -1575,60 +1600,60 @@ export function App() { {/* 1. Logo 80 */}
{canvasLayers.logo80?.filter(l => l.type !== 'background').map(l => ( - + ))}
- {/* 2. Cover 400 (Matches live 340x340 on-screen proportions with exact p-5 padding) */} + {/* 2. Cover 400 */}
{canvasLayers.cover400?.filter(l => l.type !== 'background').map(l => ( - + ))}
- {/* 3. Cover 700_1 (Matches live 380x380 on-screen proportions with exact p-6 padding) */} + {/* 3. Cover 700_1 */}
{canvasLayers.cover700_1?.filter(l => l.type !== 'background').map(l => ( - + ))}
- {/* 4. Cover 700_2 (Matches live 380x380 on-screen proportions with exact p-6 padding) */} + {/* 4. Cover 700_2 */}
{canvasLayers.cover700_2?.filter(l => l.type !== 'background').map(l => ( - + ))}
- {/* 5. Infographic (Official 594px Zhaket width standard, full height without scrollbars or settings controls) */} + {/* 5. Infographic export stage adapts to the selected marketplace dimensions. */}
diff --git a/src/features/marketplace/compliance.test.ts b/src/features/marketplace/compliance.test.ts index 6316590..854f7a7 100644 --- a/src/features/marketplace/compliance.test.ts +++ b/src/features/marketplace/compliance.test.ts @@ -56,4 +56,10 @@ describe('getAssetSpec', () => { expect(getAssetSpec('custom', 'cover400', { width: 300, height: 200 })).toMatchObject({ width: 300, height: 200 }); expect(getAssetSpec('unknown', 'nope')).toMatchObject({ width: 400, height: 400 }); }); + + it('resolves infographic export dimensions for each selected target', () => { + expect(getAssetSpec('rastchin', 'infographic')).toMatchObject({ width: 800, height: 400 }); + expect(getAssetSpec('bazaar', 'infographic')).toMatchObject({ width: 1200, height: 628 }); + expect(getAssetSpec('custom', 'infographic', { width: 900, height: 500 })).toMatchObject({ width: 900, height: 500 }); + }); }); diff --git a/src/utils/exportDimensions.test.ts b/src/utils/exportDimensions.test.ts new file mode 100644 index 0000000..810a0e8 --- /dev/null +++ b/src/utils/exportDimensions.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getCapturePixelRatio, getExportAssetKey, resizeCanvasToDimensions, type ExportDimensions } from './exportDimensions'; + +describe('export dimensions', () => { + it('calculates the scale needed to reach the requested pixel dimensions', () => { + expect(getCapturePixelRatio(380, 380, { width: 400, height: 400 })).toBeCloseTo(400 / 380); + expect(getCapturePixelRatio(380, 279, { width: 450, height: 330 })).toBeCloseTo(450 / 380); + }); + + it('uses resolved dimensions in packaged asset names', () => { + expect(getExportAssetKey('5_infographic', { width: 800, height: 400 })).toBe('5_infographic_800x400'); + expect(getExportAssetKey('5_infographic', { width: 1200, height: 628 })).toBe('5_infographic_1200x628'); + }); + + it('normalizes the rendered canvas to the exact requested dimensions', () => { + const source = { width: 800, height: 600 } as HTMLCanvasElement; + const drawImage = vi.fn(); + const context = { + imageSmoothingEnabled: false, + imageSmoothingQuality: 'low', + drawImage, + } as unknown as CanvasRenderingContext2D; + const output = { + width: 0, + height: 0, + getContext: () => context, + } as unknown as HTMLCanvasElement; + const target: ExportDimensions = { width: 450, height: 330 }; + + expect(resizeCanvasToDimensions(source, target, () => output)).toBe(output); + expect(output).toMatchObject({ width: 450, height: 330 }); + expect(context.imageSmoothingEnabled).toBe(true); + expect(context.imageSmoothingQuality).toBe('high'); + expect(drawImage).toHaveBeenCalledWith(source, 0, 0, 450, 330); + }); + + it('keeps a canvas that already matches the target dimensions', () => { + const source = { width: 80, height: 80 } as HTMLCanvasElement; + const createCanvas = vi.fn(); + + expect(resizeCanvasToDimensions(source, { width: 80, height: 80 }, createCanvas)).toBe(source); + expect(createCanvas).not.toHaveBeenCalled(); + }); + + it('rejects invalid source or target dimensions', () => { + expect(() => getCapturePixelRatio(0, 80, { width: 80, height: 80 })).toThrow(); + expect(() => resizeCanvasToDimensions({ width: 80, height: 80 } as HTMLCanvasElement, { width: 0, height: 80 })).toThrow(); + }); +}); diff --git a/src/utils/exportDimensions.ts b/src/utils/exportDimensions.ts new file mode 100644 index 0000000..56f2391 --- /dev/null +++ b/src/utils/exportDimensions.ts @@ -0,0 +1,60 @@ +export interface ExportDimensions { + width: number; + height: number; +} + +export interface ExportTarget { + element: HTMLElement; + dimensions?: ExportDimensions; +} + +export function getExportAssetKey(prefix: string, dimensions: ExportDimensions): string { + return `${prefix}_${dimensions.width}x${dimensions.height}`; +} + +const isPositiveFinite = (value: number): boolean => Number.isFinite(value) && value > 0; + +export function getCapturePixelRatio( + sourceWidth: number, + sourceHeight: number, + target: ExportDimensions, +): number { + if ( + !isPositiveFinite(sourceWidth) || + !isPositiveFinite(sourceHeight) || + !isPositiveFinite(target.width) || + !isPositiveFinite(target.height) + ) { + throw new Error('ابعاد تصویر برای خروجی معتبر نیست.'); + } + + return Math.max(target.width / sourceWidth, target.height / sourceHeight); +} + +export function resizeCanvasToDimensions( + source: HTMLCanvasElement, + target: ExportDimensions, + createCanvas: () => HTMLCanvasElement = () => document.createElement('canvas'), +): HTMLCanvasElement { + if ( + !Number.isInteger(target.width) || + !Number.isInteger(target.height) || + !isPositiveFinite(target.width) || + !isPositiveFinite(target.height) + ) { + throw new Error('ابعاد نهایی تصویر باید اعداد صحیح و مثبت باشند.'); + } + + if (source.width === target.width && source.height === target.height) return source; + + const output = createCanvas(); + output.width = target.width; + output.height = target.height; + const context = output.getContext('2d'); + if (!context) throw new Error('ساخت بوم نهایی برای تغییر ابعاد تصویر ممکن نشد.'); + + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = 'high'; + context.drawImage(source, 0, 0, target.width, target.height); + return output; +} diff --git a/src/utils/exportUtils.ts b/src/utils/exportUtils.ts index 4816f3e..5fcad5f 100644 --- a/src/utils/exportUtils.ts +++ b/src/utils/exportUtils.ts @@ -1,7 +1,13 @@ -import { toPng, toCanvas, toBlob } from 'html-to-image'; +import { toCanvas } from 'html-to-image'; import { jsPDF } from 'jspdf'; import JSZip from 'jszip'; import { writePsd } from 'ag-psd'; +import { + getCapturePixelRatio, + resizeCanvasToDimensions, + type ExportDimensions, + type ExportTarget, +} from './exportDimensions'; /** * Converts any oklch(...) or modern color space string to standard rgb(...) / rgba(...) @@ -168,11 +174,18 @@ export async function validateAndPreloadElement(element: HTMLElement, timeoutMs: * Capture HTML element to high-res Canvas using native browser rasterization * with rigorous pre-validation and oklch-to-rgb sanitization. */ -export async function captureElementToCanvas(element: HTMLElement, pixelRatio: number = 2): Promise { +export async function captureElementToCanvas( + element: HTMLElement, + pixelRatio: number = 2, + outputDimensions?: ExportDimensions, +): Promise { await validateAndPreloadElement(element); - return await toCanvas(element, { - pixelRatio, + const targetPixelRatio = outputDimensions + ? getCapturePixelRatio(element.offsetWidth, element.offsetHeight, outputDimensions) + : pixelRatio; + const canvas = await toCanvas(element, { + pixelRatio: targetPixelRatio, cacheBust: true, backgroundColor: undefined, style: { @@ -192,6 +205,8 @@ export async function captureElementToCanvas(element: HTMLElement, pixelRatio: n return true; }, }); + + return outputDimensions ? resizeCanvasToDimensions(canvas, outputDimensions) : canvas; } /** @@ -199,32 +214,11 @@ export async function captureElementToCanvas(element: HTMLElement, pixelRatio: n */ export async function exportToPng( element: HTMLElement, - filename: string = 'zhaket-asset.png', - pixelRatio: number = 2 + filename: string = 'zhaket-asset.png', + outputDimensions?: ExportDimensions, ): Promise { - await validateAndPreloadElement(element); - - const dataUrl = await toPng(element, { - pixelRatio, - cacheBust: true, - backgroundColor: undefined, - style: { - border: 'none', - boxShadow: 'none', - outline: 'none', - overflow: 'hidden', - }, - filter: (domNode: HTMLElement) => { - if (domNode.getAttribute && domNode.getAttribute('data-export-ignore') === 'true') return false; - const classStr = domNode.className || ''; - if (typeof classStr === 'string') { - if (classStr.includes('export-ignore')) return false; - if (classStr.includes('smart-snap-guide')) return false; - if (classStr.includes('precision-drag-badge')) return false; - } - return true; - }, - }); + const canvas = await captureElementToCanvas(element, 2, outputDimensions); + const dataUrl = canvas.toDataURL('image/png'); const link = document.createElement('a'); link.download = filename.endsWith('.png') ? filename : `${filename}.png`; @@ -240,11 +234,10 @@ export async function exportToPng( export async function exportToPdf( element: HTMLElement, filename: string = 'zhaket-asset.pdf', - title: string = 'Zhaket Yar Asset Document' + title: string = 'Zhaket Yar Asset Document', + outputDimensions?: ExportDimensions, ): Promise { - await validateAndPreloadElement(element); - - const canvas = await captureElementToCanvas(element, 2); + const canvas = await captureElementToCanvas(element, 2, outputDimensions); const imgData = canvas.toDataURL('image/png', 1.0); const isLandscape = canvas.width > canvas.height; @@ -268,11 +261,10 @@ export async function exportToPdf( */ export async function exportToPsd( element: HTMLElement, - filename: string = 'zhaket-asset.psd' + filename: string = 'zhaket-asset.psd', + outputDimensions?: ExportDimensions, ): Promise { - await validateAndPreloadElement(element); - - const canvas = await captureElementToCanvas(element, 2); + const canvas = await captureElementToCanvas(element, 2, outputDimensions); // Convert HTMLCanvas to PSD structure const psd = { @@ -304,9 +296,9 @@ export async function exportToPsd( * with rigorous pre-export validation on each asset. */ export async function exportFullPackageZip( - elementsMap: { [key: string]: HTMLElement }, + elementsMap: { [key: string]: ExportTarget }, productName: string, - onProgress?: (progress: number, statusText: string) => void + onProgress?: (progress: number, statusText: string) => void, ): Promise { const zip = new JSZip(); const safeName = productName.replace(/[^a-zA-Z0-9_\u0600-\u06FF]/g, '_'); @@ -316,7 +308,8 @@ export async function exportFullPackageZip( const entries = Object.entries(elementsMap); let completed = 0; - for (const [key, element] of entries) { + for (const [key, target] of entries) { + const { element, dimensions } = target; if (!element) continue; onProgress?.( @@ -325,40 +318,17 @@ export async function exportFullPackageZip( ); try { - // 1. Run Pre-export Validation - await validateAndPreloadElement(element); - onProgress?.( Math.round((completed / entries.length) * 100) + 5, `تولید خروجی PNG، PDF و PSD برای ${key}...` ); - // 2. Generate PNG data url directly - const pngDataUrl = await toPng(element, { - pixelRatio: 2, - cacheBust: true, - style: { - border: 'none', - boxShadow: 'none', - outline: 'none', - overflow: 'hidden', - }, - filter: (domNode: HTMLElement) => { - if (domNode.getAttribute && domNode.getAttribute('data-export-ignore') === 'true') return false; - const classStr = domNode.className || ''; - if (typeof classStr === 'string') { - if (classStr.includes('export-ignore')) return false; - if (classStr.includes('smart-snap-guide')) return false; - if (classStr.includes('precision-drag-badge')) return false; - } - return true; - }, - }); + const canvas = await captureElementToCanvas(element, 2, dimensions); + const pngDataUrl = canvas.toDataURL('image/png', 1.0); const pngBase64 = pngDataUrl.split(',')[1]; folder.file(`${key}.png`, pngBase64, { base64: true }); // 3. Generate PDF - const canvas = await captureElementToCanvas(element, 2); const isLandscape = canvas.width > canvas.height; const pdf = new jsPDF({ orientation: isLandscape ? 'landscape' : 'portrait', @@ -410,7 +380,7 @@ export async function exportFullPackageZip( export async function exportBatchZip( rows: T[], folderName: (row: T, index: number) => string, - renderRow: (row: T) => Promise<{ [key: string]: HTMLElement }>, + renderRow: (row: T) => Promise<{ [key: string]: ExportTarget }>, onProgress?: (done: number, total: number) => void, signal?: AbortSignal, ): Promise { @@ -428,15 +398,9 @@ export async function exportBatchZip( const folder = zip.folder(name)!; const elements = await renderRow(rows[i]); - for (const [key, element] of Object.entries(elements)) { - await validateAndPreloadElement(element); - const dataUrl = await toPng(element, { - pixelRatio: 2, - cacheBust: true, - filter: (node: HTMLElement) => - !(node.getAttribute?.('data-export-ignore') === 'true' || - (typeof node.className === 'string' && /export-ignore|smart-snap-guide|precision-drag-badge/.test(node.className))), - }); + for (const [key, target] of Object.entries(elements)) { + const canvas = await captureElementToCanvas(target.element, 2, target.dimensions); + const dataUrl = canvas.toDataURL('image/png', 1.0); folder.file(`${key}.png`, dataUrl.split(',')[1], { base64: true }); } exported++;