Fix organic exports shadows and file textures

This commit is contained in:
chris 2025-12-01 10:58:43 -05:00
parent 7a2583c06a
commit f22319737e

View File

@ -1429,15 +1429,24 @@
async function imageUrlToDataUrl(src) { async function imageUrlToDataUrl(src) {
if (!src || src.startsWith('data:')) return src; if (!src || src.startsWith('data:')) return src;
if (DATA_URL_CACHE.has(src)) return DATA_URL_CACHE.get(src); if (DATA_URL_CACHE.has(src)) return DATA_URL_CACHE.get(src);
const abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })();
const urlObj = (() => { try { return new URL(abs); } catch { return null; } })();
const isFile = urlObj?.protocol === 'file:';
if (isFile) { // file:// cannot be safely read for data URLs in-browser
DATA_URL_CACHE.set(src, null);
return null;
}
const cachedImg = IMG_CACHE.get(src); const cachedImg = IMG_CACHE.get(src);
const cachedUrl = imageToDataUrl(cachedImg); const cachedUrl = imageToDataUrl(cachedImg);
if (cachedUrl) { if (cachedUrl) {
DATA_URL_CACHE.set(src, cachedUrl); DATA_URL_CACHE.set(src, cachedUrl);
return cachedUrl; return cachedUrl;
} }
const abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })();
let dataUrl = null; let dataUrl = null;
try { try {
if (isFile) throw new Error('Skip fetch for file://');
const resp = await fetch(abs); const resp = await fetch(abs);
if (!resp.ok) throw new Error(`Status ${resp.status}`); if (!resp.ok) throw new Error(`Status ${resp.status}`);
dataUrl = await blobToDataUrl(await resp.blob()); dataUrl = await blobToDataUrl(await resp.blob());
@ -1446,7 +1455,7 @@
// Fallback: draw to a canvas to capture even when fetch is blocked (e.g., file://) // Fallback: draw to a canvas to capture even when fetch is blocked (e.g., file://)
dataUrl = await new Promise(resolve => { dataUrl = await new Promise(resolve => {
const img = new Image(); const img = new Image();
img.crossOrigin = 'anonymous'; if (!isFile) img.crossOrigin = 'anonymous';
img.onload = () => { img.onload = () => {
try { try {
const c = document.createElement('canvas'); const c = document.createElement('canvas');
@ -1491,7 +1500,7 @@
await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url)))); await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url))));
const bounds = balloonsBounds(); const bounds = balloonsBounds();
const pad = 20; const pad = 50; // extra room to avoid clipping drop-shadows
const width = bounds.w + pad * 2; const width = bounds.w + pad * 2;
const height = bounds.h + pad * 2; const height = bounds.h + pad * 2;
const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' '); const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' ');
@ -1499,12 +1508,33 @@
let defs = ''; let defs = '';
let elements = ''; let elements = '';
const patterns = new Map(); const patterns = new Map();
const shadowFilters = new Map();
const ensureShadowFilter = (dx, dy, blurPx, alpha) => {
const key = `${dx}|${dy}|${blurPx}|${alpha}`;
if (!shadowFilters.has(key)) {
const id = `shadow-${shadowFilters.size}`;
const stdDev = Math.max(0.01, blurPx * 0.5);
const clampedAlpha = clamp01(alpha);
const flood = `<feFlood flood-color="#000000" flood-opacity="${clampedAlpha}" />`;
const blur = `<feGaussianBlur in="SourceAlpha" stdDeviation="${stdDev}" result="blur" />`;
const offset = `<feOffset dx="${dx}" dy="${dy}" in="blur" result="shadow" />`;
const composite = `<feComposite in="shadow" in2="SourceAlpha" operator="in" result="shadow" />`;
const merge = `<feMerge><feMergeNode in="shadow" /><feMergeNode in="SourceGraphic" /></feMerge>`;
defs += `<filter id="${id}" x="-50%" y="-50%" width="200%" height="200%" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">${flood}${blur}${offset}${composite}${merge}</filter>`;
shadowFilters.set(key, id);
}
return shadowFilters.get(key);
};
const shineShadowId = ensureShadowFilter(0, 0, 3, 0.1);
balloons.forEach(b => { balloons.forEach(b => {
const meta = FLAT_COLORS[b.colorIdx] || {};
let fill = b.color; let fill = b.color;
if (b.image) { if (b.image) {
const patternKey = `${b.colorIdx}|${b.image}`; const patternKey = `${b.colorIdx}|${b.image}`;
if (!patterns.has(patternKey)) { const imageHref = dataUrlMap.get(b.image);
if (!patterns.has(patternKey) && imageHref) {
const patternId = `p${patterns.size}`; const patternId = `p${patterns.size}`;
patterns.set(patternKey, patternId); patterns.set(patternKey, patternId);
const meta = FLAT_COLORS[b.colorIdx] || {}; const meta = FLAT_COLORS[b.colorIdx] || {};
@ -1514,15 +1544,29 @@
const imgW = zoom, imgH = zoom; const imgW = zoom, imgH = zoom;
const imgX = 0.5 - (fx * zoom); const imgX = 0.5 - (fx * zoom);
const imgY = 0.5 - (fy * zoom); const imgY = 0.5 - (fy * zoom);
const imageHref = dataUrlMap.get(b.image) || b.image;
defs += `<pattern id="${patternId}" patternContentUnits="objectBoundingBox" width="1" height="1"> defs += `<pattern id="${patternId}" patternContentUnits="objectBoundingBox" width="1" height="1">
<image href="${imageHref}" x="${imgX}" y="${imgY}" width="${imgW}" height="${imgH}" preserveAspectRatio="xMidYMid slice" /> <image href="${imageHref}" x="${imgX}" y="${imgY}" width="${imgW}" height="${imgH}" preserveAspectRatio="xMidYMid slice" />
</pattern>`; </pattern>`;
} }
fill = `url(#${patterns.get(patternKey)})`; if (patterns.has(patternKey)) fill = `url(#${patterns.get(patternKey)})`;
}
let filterAttr = '';
if (b.image) {
const lum = luminance(meta.hex || b.color);
if (lum > 0.6) {
const strength = clamp01((lum - 0.6) / 0.4);
const blur = 4 + 4 * strength;
const offsetY = 1 + 2 * strength;
const alpha = 0.05 + 0.07 * strength;
const id = ensureShadowFilter(0, offsetY, blur, alpha);
filterAttr = ` filter="url(#${id})"`;
}
} else {
const id = ensureShadowFilter(0, 0, 10, 0.2);
filterAttr = ` filter="url(#${id})"`;
} }
const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`; const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`;
elements += `<circle cx="${b.x}" cy="${b.y}" r="${b.radius}" fill="${fill}"${strokeAttr} />`; elements += `<circle cx="${b.x}" cy="${b.y}" r="${b.radius}" fill="${fill}"${strokeAttr}${filterAttr} />`;
if (isShineEnabled) { if (isShineEnabled) {
const sx = b.x - b.radius * SHINE_OFFSET; const sx = b.x - b.radius * SHINE_OFFSET;
@ -1531,7 +1575,8 @@
const ry = b.radius * SHINE_RY; const ry = b.radius * SHINE_RY;
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color); const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1"` : ''; const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1"` : '';
elements += `<ellipse cx="${sx}" cy="${sy}" rx="${rx}" ry="${ry}" fill="${shineFill}"${stroke} transform="rotate(${SHINE_ROT} ${sx} ${sy})" />`; const shineFilter = shineShadowId ? ` filter="url(#${shineShadowId})"` : '';
elements += `<ellipse cx="${sx}" cy="${sy}" rx="${rx}" ry="${ry}" fill="${shineFill}"${stroke}${shineFilter} transform="rotate(${SHINE_ROT} ${sx} ${sy})" />`;
} }
}); });