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:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-27 09:34:39 -04:00
1 parent d95acfaf17
commit b890f78f1f
1 file changed
+110 -1
+110 -1
View File
@@ -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();
}