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