diff --git a/src/lib/badgeUtils.ts b/src/lib/badgeUtils.ts index eed8ae2..3c2c319 100644 --- a/src/lib/badgeUtils.ts +++ b/src/lib/badgeUtils.ts @@ -186,23 +186,26 @@ export function renderCleanBadgeSvg( // Formátovanie textu: ak obsahuje {cena} alebo {centy}, centy vložíme do ako horný index let contentSvg = ''; + let caronDx = ''; // posuny písmen za ľ/ť/ď (atribút dx na , bez vnorených tspan) if (priceMatch) { const [tokMain, tokSub] = badgePriceParts(priceMatch[1], product); const idx = priceMatch.index || 0; - const before = tightenCarons(escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product))); - const after = tightenCarons(escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), product))); + const before = escapeXml(replaceBadgeTextVars(rawText.slice(0, idx), product)); + const after = escapeXml(replaceBadgeTextVars(rawText.slice(idx + priceMatch[0].length), product)); contentSvg = `${before}${escapeXml(tokMain)}${escapeXml(tokSub)}${after ? `${after}` : ''}`; } else if (rawText.includes('{centy}')) { const parts = rawText.split('{centy}'); - const before = tightenCarons(escapeXml(parts[0] || '')); - const after = tightenCarons(escapeXml(parts[1] || '')); + const before = escapeXml(parts[0] || ''); + const after = escapeXml(parts[1] || ''); contentSvg = `${before}${escapeXml(pSub)}${after ? `${after}` : ''}`; } 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 - return `${contentSvg}`; + return `${contentSvg}`; }).join('\n '); return ` @@ -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) -function tightenCarons(text: string): string { - return text.replace(/([ľťď])([^\s<&])/g, '$1$2'); +// Mäkčeň pri ľ, ť, ď je v písme široký – písmeno za ním trochu pritiahneme (ako v návrhoch z Illustratora). +// Posun ide do zoznamu dx priamo na (po znakoch), nie do vnoreného – ten pri prevode karty +// 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 { diff --git a/src/lib/cardImages.ts b/src/lib/cardImages.ts index d28ea77..0a0106e 100644 --- a/src/lib/cardImages.ts +++ b/src/lib/cardImages.ts @@ -11,6 +11,45 @@ async function uploadJpeg(dataUrl: string, fileName: string): Promise { 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 { + const href = [...document.querySelectorAll('link[rel="stylesheet"]')].map((l) => l.href).find((h) => /use\.typekit\.net/.test(h)); + if (!href) return ''; + const used = new Set(); + 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((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( campaign: NewsletterCampaign, 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 } })); let failed = 0; + const fontEmbedCSS = jobs.length + ? await adobeFontEmbedCss(jobs.map((j) => document.querySelector(`[data-card-visual="${j.key}"]`)).filter((n): n is HTMLElement => !!n)) + : ''; for (let i = 0; i < jobs.length; i++) { const job = jobs[i]; onProgress?.(i, jobs.length); @@ -50,7 +92,9 @@ export async function prepareCardImages( height, 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 - 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 url = await uploadJpeg(dataUrl, `karta-${product.id}.jpg`); diff --git a/src/lib/productCard.ts b/src/lib/productCard.ts index 5c9ecc4..68cb85d 100644 --- a/src/lib/productCard.ts +++ b/src/lib/productCard.ts @@ -215,6 +215,7 @@ function renderStores(p: CardProduct, mode: RenderMode, tagHeight: number, badge export function cardVisualKey(p: CardProduct, ctx: CardContext): string { const t = p.badgeCustomTemplate; 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, p.imageUrl, p.imageScale ?? DEFAULT_IMAGE_SCALE, p.imageOffsetX || 0, p.imageOffsetY || 0, p.customBadgePngUrl, badgeWidthFor(p, ctx.variant),