From b890f78f1f6abe71cd7b1c61c0d5c69751be8df3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 27 May 2026 09:34:39 -0400 Subject: [PATCH] JPEG export P1: one-click direct rasterizer, PDF fallback on CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/index.html | 111 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 110 insertions(+), 1 deletion(-) diff --git a/src/index.html b/src/index.html index 95ede83..5b59269 100644 --- a/src/index.html +++ b/src/index.html @@ -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(); }