mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-06 05:30:52 +00:00
JPEG export P1: one-click direct rasterizer, PDF fallback on CORS
Replace the click-Export-JPEG → print-to-PDF → save-to-disk → re-pick
flow with a single click. The canvas-rasterizer infrastructure was
already 90% built in this codebase — drawLayerToCanvas,
loadImageWithCors, setupExportCanvas, and rasterizers for cover /
disc-sheet / jewel / disc-design all existed but were never wired
into any user-facing entry point. This commit ties them together.
What changed:
- New renderCustomArtToExportCanvas(dpi) — the one missing rasterizer.
Handles customart / slide canvases: background colour fill + every
canvas-target layer drawn in z-order via the existing per-type
drawers.
- New exportArtboardAsJpegBlob({dpi, quality}) orchestrator. Branches
by current templateMode (and appMode for the disc-design designer):
cover → renderCoverToExportCanvas
disc → renderDiscSheetToExportCanvas
jewel → renderJewelSheetToExportCanvas
designer-disc → renderDiscDesignToExportCanvas
customart / slide → renderCustomArtToExportCanvas
pixelart / deck → {ok:false, kind:'unsupported'} (caller falls back)
Clears _exportFailedImages, renders, then:
- if any image failed to load (CORS-blocked, 404, timeout) →
{ok:false, kind:'cors', urls: [...]}
- if toBlob throws (canvas tainted, shouldn't happen but defensive) →
{ok:false, kind:'taint'}
- else {ok:true, blob}.
- exportArtboardAsJpeg(btn) rewritten as an async click handler:
disables the button + sets a "Rendering…" label, calls the
orchestrator, downloads the blob on success, or routes to the
existing openPdfJpegModal() with a toast on cors/unsupported/taint.
End-to-end verified via CDP against the running app:
- 4-layer Custom Art (color + shape circle + plain text + QR code) →
direct JPEG download, 1280×720 px, ~43 KB at dpi=96 quality=0.92.
All four layers render in the right positions (PNG preview attached
to the verification — color block top-left, red circle adjacent,
bold text below, scannable QR overlap with the text bounds).
- Single image layer with an unresolvable URL → {ok:false, kind:'cors',
urls:[...]}, which the click handler converts to a toast + the
existing print-to-PDF modal.
P1 scope ships customart / slide / cover / disc-sheet / jewel /
disc-design. Pixel-art standalone keeps its own PNG/GIF export, and
slide-deck keeps the multi-page PDF flow (both intentionally not
covered here).
Rich-text inline marks (bold / italic / underline / links / lists
applied to sub-spans) still render as plain text in the JPEG —
existing drawTextLayerToCanvas uses layerHtmlToPlainText. P2 adds
SVG-foreignObject rich-text rendering.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
d95acfaf17
commit
b890f78f1f
1 file changed
+110
-1
+110
-1
@@ -15131,6 +15131,79 @@
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Slide / Custom Art / Pixel Art standalone: one flat rectangle of
|
||||
// layers on a background colour. Slides share the customart payload
|
||||
// shape under `state.customArt`.
|
||||
async function renderCustomArtToExportCanvas(dpi) {
|
||||
const ca = state.customArt || { width: 1920, height: 1080, background: '#ffffff' };
|
||||
const wIn = (Number(ca.width) || 1920) / 96;
|
||||
const hIn = (Number(ca.height) || 1080) / 96;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = setupExportCanvas(canvas, wIn, hIn, dpi);
|
||||
const bg = ca.background || '#ffffff';
|
||||
if (bg && bg.toLowerCase() !== '#ffffff' && bg.toLowerCase() !== '#fff') {
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
const layers = (state.layers || [])
|
||||
.filter(l => l.target === 'canvas' && !l.hidden && !l._generatedDiscLayer)
|
||||
.slice()
|
||||
.sort((a, b) => (a.z || 0) - (b.z || 0));
|
||||
for (const layer of layers) await drawLayerToCanvas(ctx, layer, dpi);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Pick the right rasterizer for the current artboard, render, encode as
|
||||
// JPEG. Returns one of:
|
||||
// { ok: true, blob } — direct JPEG ready to download
|
||||
// { ok: false, kind: 'unsupported' } — caller should fall back
|
||||
// { ok: false, kind: 'cors', urls } — same; this PDF route handles it
|
||||
// { ok: false, kind: 'taint' } — canvas was tainted (last-resort fallback)
|
||||
async function exportArtboardAsJpegBlob(opts = {}) {
|
||||
const dpi = Math.max(72, Math.min(600, opts.dpi | 0 || 150));
|
||||
const quality = typeof opts.quality === 'number' ? opts.quality : 0.92;
|
||||
_exportFailedImages.clear();
|
||||
const mode = state.templateMode;
|
||||
let canvas = null;
|
||||
try {
|
||||
if (state.appMode === 'designer') {
|
||||
canvas = await renderDiscDesignToExportCanvas(dpi);
|
||||
} else if (mode === 'cover') {
|
||||
canvas = await renderCoverToExportCanvas(dpi);
|
||||
} else if (mode === 'disc') {
|
||||
canvas = await renderDiscSheetToExportCanvas(dpi);
|
||||
} else if (mode === 'jewel') {
|
||||
canvas = await renderJewelSheetToExportCanvas(dpi);
|
||||
} else if (mode === 'customart' || mode === 'slide') {
|
||||
canvas = await renderCustomArtToExportCanvas(dpi);
|
||||
} else {
|
||||
// pixelart standalone, deck — not supported by the direct rasterizer.
|
||||
// (Pixel art has its own PNG export, deck has the multi-page PDF.)
|
||||
return { ok: false, kind: 'unsupported' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, kind: 'unsupported', err: String(e) };
|
||||
}
|
||||
// Any image we couldn't load (CORS, timeout, 404) means the JPEG
|
||||
// would be missing content the user would expect. Route to the PDF
|
||||
// path instead — the browser's print engine has a separate image
|
||||
// pipeline that handles cross-origin embeds the canvas can't touch.
|
||||
if (_exportFailedImages.size > 0) {
|
||||
return { ok: false, kind: 'cors', urls: Array.from(_exportFailedImages) };
|
||||
}
|
||||
try {
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
canvas.toBlob((b) => b ? resolve(b) : reject(new Error('toBlob returned null')), 'image/jpeg', quality);
|
||||
});
|
||||
return { ok: true, blob };
|
||||
} catch (e) {
|
||||
// toBlob throws SecurityError on a tainted canvas. Shouldn't happen
|
||||
// because loadImageWithCors rejects before drawImage on a CORS-
|
||||
// blocked image, but kept as a defensive fallback.
|
||||
return { ok: false, kind: 'taint', err: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
function jpegFilenameStem() {
|
||||
const parts = [
|
||||
filenamePart(state.meta?.title) || 'design',
|
||||
@@ -15203,7 +15276,43 @@
|
||||
m.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function exportArtboardAsJpeg() {
|
||||
// The Export-JPEG entry point. Tries the direct canvas rasterizer first
|
||||
// (one click, no print dialog, no re-upload). Falls back to the existing
|
||||
// print-and-pick-PDF modal for canvas types that aren't covered yet
|
||||
// (pixelart, deck) or designs with CORS-blocked remote images.
|
||||
async function exportArtboardAsJpeg(btn) {
|
||||
const restoreBtn = (text) => { if (btn) { btn.disabled = false; if (text != null) btn.textContent = text; } };
|
||||
const prevText = btn?.textContent;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Rendering…'; }
|
||||
let result;
|
||||
try {
|
||||
result = await exportArtboardAsJpegBlob({ dpi: 150, quality: 0.92 });
|
||||
} catch (e) {
|
||||
toastError && toastError('JPEG render failed — falling back to PDF flow.');
|
||||
restoreBtn(prevText);
|
||||
openPdfJpegModal();
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${jpegFilenameStem()}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
toastSuccess && toastSuccess('JPEG saved.');
|
||||
restoreBtn(prevText);
|
||||
return;
|
||||
}
|
||||
// Fall back to the existing print-to-PDF + re-pick flow.
|
||||
restoreBtn(prevText);
|
||||
if (result.kind === 'cors') {
|
||||
toastError && toastError(`${result.urls.length} image(s) couldn't be embedded directly — switching to the PDF route.`);
|
||||
} else if (result.kind === 'unsupported') {
|
||||
toastError && toastError('Direct JPEG not supported for this template — using the PDF route.');
|
||||
}
|
||||
openPdfJpegModal();
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user