fix: Aptly v obrazkoch kariet pre e-mail, pismeno za l s makcenom na riadku
- export vlozi pisma z Adobe Fonts do html-to-image (fontEmbedCSS); kniznica ich sama z CSS na inej domene s @import nenacitala a karty mali nahradne pismo - pritiahnutie za l/t/d cez zoznam dx na <text> namiesto vnoreneho <tspan> - novy kluc obrazkov kariet (v2), aby sa pri dalsom exporte pretvorili Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
31dfa7d336
commit
50af94f954
3 files changed
+63
-10
No files matched your search
+17
-9
@@ -186,23 +186,26 @@ export function renderCleanBadgeSvg(
|
|||||||
|
|
||||||
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
// Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do <tspan> ako horný index
|
||||||
let contentSvg = '';
|
let contentSvg = '';
|
||||||
|
let caronDx = ''; // posuny písmen za ľ/ť/ď (atribút dx na <text>, bez vnorených tspan)
|
||||||
if (priceMatch) {
|
if (priceMatch) {
|
||||||
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
|
const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product);
|
||||||
const idx = priceMatch.index || 0;
|
const idx = priceMatch.index || 0;
|
||||||
const before = tightenCarons(escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product)));
|
const before = escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product));
|
||||||
const after = tightenCarons(escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), product)));
|
const after = escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), product));
|
||||||
contentSvg = `${before}${escapeXml(tokMain)}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(tokSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
contentSvg = `${before}${escapeXml(tokMain)}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(tokSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
||||||
} else if (rawText.includes('{centy}')) {
|
} else if (rawText.includes('{centy}')) {
|
||||||
const parts = rawText.split('{centy}');
|
const parts = rawText.split('{centy}');
|
||||||
const before = tightenCarons(escapeXml(parts[0] || ''));
|
const before = escapeXml(parts[0] || '');
|
||||||
const after = tightenCarons(escapeXml(parts[1] || ''));
|
const after = escapeXml(parts[1] || '');
|
||||||
contentSvg = `${before}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
contentSvg = `${before}<tspan font-size="${subFontSize}px" dx="${subDx}" dy="${subDy}" font-weight="${fontWeight}">${escapeXml(pSub)}</tspan>${after ? `<tspan dy="${revertDy}">${after}</tspan>` : ''}`;
|
||||||
} else {
|
} else {
|
||||||
contentSvg = tightenCarons(escapeXml(replaceBadgeTextVars(rawText, product)));
|
const plain = replaceBadgeTextVars(rawText, product);
|
||||||
|
contentSvg = escapeXml(plain);
|
||||||
|
caronDx = caronOffsets(plain, fontSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// data-layer-index: editor cenoviek podľa neho nájde a odmeria vrstvu na plátne
|
// data-layer-index: editor cenoviek podľa neho nájde a odmeria vrstvu na plátne
|
||||||
return `<text data-layer-index="${layerIndex}" x="${layer.x}" y="${layerY}" text-anchor="${anchor}" dominant-baseline="central"${layer.letterSpacing ? ` letter-spacing="${layer.letterSpacing}em"` : ''} font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
return `<text data-layer-index="${layerIndex}" x="${layer.x}" y="${layerY}" text-anchor="${anchor}" dominant-baseline="central"${caronDx ? ` dx="${caronDx}"` : ''}${layer.letterSpacing ? ` letter-spacing="${layer.letterSpacing}em"` : ''} font-family="${fontFamily}" font-size="${fontSize}px" font-weight="${fontWeight}" fill="${fill}">${contentSvg}</text>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vb}" width="${displayWidth}" height="${displayHeight}" style="display:block;max-width:100%;height:auto;">
|
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${vb}" width="${displayWidth}" height="${displayHeight}" style="display:block;max-width:100%;height:auto;">
|
||||||
@@ -263,9 +266,14 @@ export async function svgToCleanPng(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mäkčeň pri ľ, ť, ď je v písme široký – písmeno za ním trochu pritiahneme (ako v návrhoch z Illustratora)
|
// Mäkčeň pri ľ, ť, ď je v písme široký – písmeno za ním trochu pritiahneme (ako v návrhoch z Illustratora).
|
||||||
function tightenCarons(text: string): string {
|
// Posun ide do zoznamu dx priamo na <text> (po znakoch), nie do vnoreného <tspan> – ten pri prevode karty
|
||||||
return text.replace(/([ľťď])([^\s<&])/g, '$1<tspan dx="-0.05em">$2</tspan>');
|
// na obrázok strácal zarovnanie na strednú účiaru a písmeno vyskočilo z riadku.
|
||||||
|
function caronOffsets(text: string, fontSize: number): string {
|
||||||
|
const chars = [...text];
|
||||||
|
const shift = -Math.round(fontSize * 0.05 * 100) / 100;
|
||||||
|
const dx = chars.map((c, i) => (i > 0 && /[ľťď]/.test(chars[i - 1]) && !/\s/.test(c) ? shift : 0));
|
||||||
|
return dx.some(Boolean) ? dx.join(' ') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeXml(unsafe: string): string {
|
function escapeXml(unsafe: string): string {
|
||||||
|
|||||||
+45
-1
@@ -11,6 +11,45 @@ async function uploadJpeg(dataUrl: string, fileName: string): Promise<string> {
|
|||||||
return uploadFile(blob, fileName);
|
return uploadFile(blob, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Písma z Adobe Fonts (Aptly v cenovkách) pre html-to-image: ich CSS je na inej doméne a obsahuje @import,
|
||||||
|
// takže knižnica ho sama neprečíta a karta by sa uložila s náhradným písmom. CSS stiahneme sami, vyberieme
|
||||||
|
// rezy písiem, ktoré karty naozaj používajú, a súbory vložíme ako data URL.
|
||||||
|
async function adobeFontEmbedCss(nodes: HTMLElement[]): Promise<string> {
|
||||||
|
const href = [...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')].map((l) => l.href).find((h) => /use\.typekit\.net/.test(h));
|
||||||
|
if (!href) return '';
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
node.querySelectorAll('[font-family]').forEach((el) => {
|
||||||
|
const first = (el.getAttribute('font-family') || '').split(',')[0].replace(/['"]/g, '').trim().toLowerCase();
|
||||||
|
if (first) used.add(first);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const css = await (await fetch(href)).text();
|
||||||
|
const faces = (css.match(/@font-face\s*\{[^}]*\}/g) || []).filter((face) => {
|
||||||
|
const family = face.match(/font-family:\s*"?([^";]+)"?/)?.[1].trim().toLowerCase();
|
||||||
|
return !!family && used.has(family) && !/font-style:\s*italic/.test(face);
|
||||||
|
});
|
||||||
|
const out = await Promise.all(
|
||||||
|
faces.map(async (face) => {
|
||||||
|
const src = face.match(/url\("?([^")]+)"?\)\s*format\("?woff2"?\)/)?.[1];
|
||||||
|
if (!src) return '';
|
||||||
|
const blob = await (await fetch(src)).blob();
|
||||||
|
const dataUrl = await new Promise<string>((res) => {
|
||||||
|
const fr = new FileReader();
|
||||||
|
fr.onload = () => res(fr.result as string);
|
||||||
|
fr.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
return face.replace(/src:[^;]+;/, `src:url(${dataUrl}) format("woff2");`);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return out.filter(Boolean).join(' ');
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Písma z Adobe Fonts sa nepodarilo pripraviť pre obrázky kariet:', err);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function prepareCardImages(
|
export async function prepareCardImages(
|
||||||
campaign: NewsletterCampaign,
|
campaign: NewsletterCampaign,
|
||||||
onProgress?: (done: number, total: number) => void
|
onProgress?: (done: number, total: number) => void
|
||||||
@@ -31,6 +70,9 @@ export async function prepareCardImages(
|
|||||||
|
|
||||||
const modules = campaign.modules.map((m) => ({ ...m, data: { ...m.data, products: m.data.products ? [...m.data.products] : m.data.products } }));
|
const modules = campaign.modules.map((m) => ({ ...m, data: { ...m.data, products: m.data.products ? [...m.data.products] : m.data.products } }));
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
const fontEmbedCSS = jobs.length
|
||||||
|
? await adobeFontEmbedCss(jobs.map((j) => document.querySelector<HTMLElement>(`[data-card-visual="${j.key}"]`)).filter((n): n is HTMLElement => !!n))
|
||||||
|
: '';
|
||||||
for (let i = 0; i < jobs.length; i++) {
|
for (let i = 0; i < jobs.length; i++) {
|
||||||
const job = jobs[i];
|
const job = jobs[i];
|
||||||
onProgress?.(i, jobs.length);
|
onProgress?.(i, jobs.length);
|
||||||
@@ -50,7 +92,9 @@ export async function prepareCardImages(
|
|||||||
height,
|
height,
|
||||||
style: { width: `${job.width}px`, height: `${height}px` },
|
style: { width: `${job.width}px`, height: `${height}px` },
|
||||||
// obrázky idú cez /api/img?url=… – bez parametrov by si html-to-image pomýlil rôzne obrázky
|
// obrázky idú cez /api/img?url=… – bez parametrov by si html-to-image pomýlil rôzne obrázky
|
||||||
includeQueryParams: true
|
includeQueryParams: true,
|
||||||
|
// písma vložené vopred (Aptly z Adobe Fonts); bez nich by si knižnica písma hľadala sama a zlyhala
|
||||||
|
...(fontEmbedCSS ? { fontEmbedCSS } : {})
|
||||||
});
|
});
|
||||||
const product = modules[job.mIdx].data.products[job.pIdx];
|
const product = modules[job.mIdx].data.products[job.pIdx];
|
||||||
const url = await uploadJpeg(dataUrl, `karta-${product.id}.jpg`);
|
const url = await uploadJpeg(dataUrl, `karta-${product.id}.jpg`);
|
||||||
|
|||||||
@@ -215,6 +215,7 @@ function renderStores(p: CardProduct, mode: RenderMode, tagHeight: number, badge
|
|||||||
export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
|
export function cardVisualKey(p: CardProduct, ctx: CardContext): string {
|
||||||
const t = p.badgeCustomTemplate;
|
const t = p.badgeCustomTemplate;
|
||||||
const relevant = [
|
const relevant = [
|
||||||
|
'v2', // verzia vykreslenia – zvýšiť, keď sa zmení spôsob prípravy obrázkov (v2: písma z Adobe Fonts)
|
||||||
ctx.variant, ctx.cardWidth, p.cardBgColor || ctx.cardBg, ctx.variant === 'standard' ? ctx.defaultBadge : null,
|
ctx.variant, ctx.cardWidth, p.cardBgColor || ctx.cardBg, ctx.variant === 'standard' ? ctx.defaultBadge : null,
|
||||||
p.imageUrl, p.imageScale ?? DEFAULT_IMAGE_SCALE, p.imageOffsetX || 0, p.imageOffsetY || 0,
|
p.imageUrl, p.imageScale ?? DEFAULT_IMAGE_SCALE, p.imageOffsetX || 0, p.imageOffsetY || 0,
|
||||||
p.customBadgePngUrl, badgeWidthFor(p, ctx.variant),
|
p.customBadgePngUrl, badgeWidthFor(p, ctx.variant),
|
||||||
|
|||||||
Reference in new issue
Block a user