diff --git a/web/modules/custom/leshed_svg_export/README.md b/web/modules/custom/leshed_svg_export/README.md index 87323e6..827caba 100644 --- a/web/modules/custom/leshed_svg_export/README.md +++ b/web/modules/custom/leshed_svg_export/README.md @@ -119,23 +119,54 @@ Comportement Drupal (`Drupal.behaviors.leshedSvgExport`) : demanderait soit de modifier le CSS du thème, soit de recouvrir le titre d'un calque — les deux sont exclus. Sur tout titre sans cette règle (l'immense majorité), le survol fonctionne normalement. -- Au clic : capture la largeur du conteneur du titre, puis pour chaque - `.char` dans l'ordre du DOM — position (`getBoundingClientRect()`), - style calculé (`font-family`, `font-style`, tous les axes de - `font-variation-settings`, `text-transform`) — et convertit `.char.textContent` - selon le `text-transform` réellement appliqué (uppercase / lowercase / - capitalize, ce dernier basé sur le premier `.char` de chaque `.word`) - avant de demander le tracé du bon caractère. +- Au clic : pour chaque `.char` dans l'ordre du DOM — position + (`getBoundingClientRect()`), style calculé (`font-family`, `font-style`, + tous les axes de `font-variation-settings`, `text-transform`) — et + convertit `.char.textContent` selon le `text-transform` réellement + appliqué (uppercase / lowercase / capitalize, ce dernier basé sur le + premier `.char` de chaque `.word`) avant de demander le tracé du bon + caractère. **Les dimensions du canevas sont mesurées à partir de + l'étendue réelle de ces `.char`** (min/max de leurs `getBoundingClientRect()`), + pas depuis `titleEl.getBoundingClientRect()` : certains titres du thème + ont un `max-height` plus petit que leur contenu une fois retourné à la + ligne, avec `overflow: visible` (rien n'est coupé visuellement à + l'écran) — dans ce cas, la boîte du titre lui-même ne reflète que la + hauteur contrainte, pas l'étendue réelle du texte qui déborde ; s'y fier + tronquait les titres sur plusieurs lignes. +- **L'échelle de rendu tient compte de la transformation CSS du titre** + (`getScaleFactor()`) : le thème anime certains titres en + `transform: scale()` au scroll (`initPageFull()` dans `main.js`), et + `getComputedStyle().fontSize` ne reflète jamais cette transformation, + contrairement à `getBoundingClientRect()` — utiliser `font-size` seul + rendait les glyphes trop grands par rapport à leur espacement mesuré, un + écart qui s'accumulait lettre après lettre et débordait du canevas, en + bas et à droite (fin de ligne) surtout. Le facteur d'échelle est lu + directement depuis la matrice `transform` calculée du titre (`DOMMatrix`), + **pas déduit** en comparant la largeur mesurée d'un `.char` à son avance + de glyphe HarfBuzz — une première version faisait ça, mais le thème pose + aussi `letter-spacing: -0.1em` sur chaque lettre, inclus dans la largeur + mesurée, ce qui faussait le calcul sur tous les caractères (pas + seulement sous transformation active), jusqu'à rendre certaines lettres + invisibles. - Assemble le SVG (fond transparent, remplissage noir uniquement — décision volontaire, pas configurable) et déclenche le téléchargement (`Blob` + - ``). Une marge égale à 30 % de la plus grande taille de police + ``). Une marge égale à 30 % de la plus grande taille de + police *effective* (issue du point précédent, pas de `font-size` brut) du titre (`ITALIC_OVERFLOW_MARGIN_RATIO`) est ajoutée tout autour du canevas : les lettres italiques débordent de leur boîte d'avance nominale (surtout en haut des hampes), et `getBoundingClientRect()` du titre ne tient pas compte de ce débordement — sans cette marge, un cadrage au plus juste coupe parfois ces lettres. - Pour le PNG : rasterise le SVG assemblé via un `` à ×4 la taille - affichée (`PNG_SCALE`), puis `canvas.toBlob('image/png')`. + affichée (`PNG_SCALE`), puis `canvas.toBlob('image/png')`. Cette échelle + est réduite (jamais augmentée) pour qu'aucune dimension du canevas ne + dépasse `MAX_CANVAS_DIMENSION` (3000px, largement suffisant pour un usage + print réel — environ 25cm à 300 DPI). Nécessaire pour un titre long sur + plusieurs lignes en police énorme (12em) : ×4 la taille CSS affichée n'a + de sens que pour un texte court comme le logo — pour un long titre, la + taille "à l'écran" est déjà immense, donc ×4 produisait des fichiers + démesurés (et pouvait dépasser la taille de `` que le navigateur + autorise). ### `js/harfbuzz-export.js` diff --git a/web/modules/custom/leshed_svg_export/js/svg-export.js b/web/modules/custom/leshed_svg_export/js/svg-export.js index 6c73586..2f87236 100644 --- a/web/modules/custom/leshed_svg_export/js/svg-export.js +++ b/web/modules/custom/leshed_svg_export/js/svg-export.js @@ -12,6 +12,15 @@ const SLUG_MAX_LENGTH = 60; const PNG_SCALE = 4; + // Target cap for the PNG's largest side, in pixels — plenty for real + // print use (e.g. ~25cm at 300 DPI) regardless of how long/tall the + // source title is on screen. Scaling PNG_SCALE off the on-screen CSS + // size alone made sense for a short title like the logo, but for a + // long multi-line title in a huge font it produced needlessly massive + // files (and could exceed the browser's own size limit). The + // scale is reduced — never increased — only when ×PNG_SCALE would + // exceed this cap. + const MAX_CANVAS_DIMENSION = 3000; // U+0300-U+036F: combining diacritical marks left over after NFD // normalization (e.g. splitting "é" into "e" + a combining acute accent). @@ -67,26 +76,63 @@ } /** - * Reads the DOM exactly as currently rendered (positions, weights, - * italics, line wraps, text-transform) and turns every `.char` into a - * positioned glyph path. Nothing here recomputes layout — it only reads - * what the browser already laid out at the moment of the click. + * Reads the current CSS `transform` matrix on `el` and returns its scale + * factor. `font-size` never reflects an ancestor `transform: scale()` + * (this theme animates title scale on scroll, in initPageFull()), while + * `getBoundingClientRect()` always does — without correcting for it, + * glyphs render at their nominal font-size while being positioned from + * transform-scaled coordinates, overflowing past the canvas once the + * animation has shrunk the title. + * + * Deliberately NOT derived from comparing a character's measured + * `getBoundingClientRect().width` to its HarfBuzz advance: this theme + * also sets `letter-spacing` on every `.char`, which is included in the + * measured width and would throw that comparison off per-character + * (not just under an active transform) — reading the transform directly + * sidesteps that entirely. */ - // Fraction of the largest font size on the title, added as padding around - // the whole canvas. Italic glyphs slant past their own nominal advance - // box (most visibly at the top of ascenders), and the title's own - // getBoundingClientRect() doesn't account for that overflow — a tightly - // fitted viewBox clips them. This margin is a generous, font-agnostic - // safety net rather than measuring each glyph's exact ink bounds. + function getScaleFactor(el) { + const transform = getComputedStyle(el).transform; + if (!transform || transform === 'none') return 1; + try { + const matrix = new DOMMatrix(transform); + // GSAP's `scale` produces a uniform matrix (a === d, b === c === 0); + // averaging the two axes also covers non-uniform cases reasonably. + return (Math.hypot(matrix.a, matrix.b) + Math.hypot(matrix.c, matrix.d)) / 2; + } + catch (error) { + return 1; + } + } + + // Fraction of the largest effective font size on the title, added as + // padding around the whole canvas. Italic glyphs slant past their own + // nominal advance box (most visibly at the top of ascenders), and the + // title's own getBoundingClientRect() doesn't account for that overflow + // — a tightly fitted viewBox clips them. This margin is a generous, + // font-agnostic safety net rather than measuring each glyph's exact ink + // bounds. const ITALIC_OVERFLOW_MARGIN_RATIO = 0.3; async function buildSvg(titleEl, glyphFor, hb) { - const containerRect = titleEl.getBoundingClientRect(); const chars = Array.from(titleEl.querySelectorAll('.char')); + const titleScale = getScaleFactor(titleEl); + // Canvas bounds are measured from the actual `.char` rects, not from + // titleEl.getBoundingClientRect(): some titles in this theme are given + // a `max-height` smaller than their wrapped content plus + // `overflow: visible` (so nothing is visually clipped on the page), + // and in that case the container's own rect reports the *constrained* + // box, not the full extent of the overflowing lines — using it here + // silently cropped multi-line titles. const seenWords = new Set(); - const glyphNodes = []; - let maxFontSizePx = 0; + const entries = []; + let minLeft = Infinity; + let minTop = Infinity; + let maxRight = -Infinity; + let maxBottom = -Infinity; + let maxEffectiveFontSizePx = 0; + for (const charEl of chars) { const rawText = charEl.textContent; if (!rawText || !rawText.trim()) continue; @@ -95,28 +141,45 @@ const computed = getComputedStyle(charEl); const fontSizePx = parseFloat(computed.fontSize); if (!fontSizePx) continue; - maxFontSizePx = Math.max(maxFontSizePx, fontSizePx); + + maxEffectiveFontSizePx = Math.max(maxEffectiveFontSizePx, fontSizePx * titleScale); + minLeft = Math.min(minLeft, rect.left); + minTop = Math.min(minTop, rect.top); + maxRight = Math.max(maxRight, rect.right); + maxBottom = Math.max(maxBottom, rect.bottom); const text = applyTextTransform(rawText, computed.textTransform, charEl, seenWords); + entries.push({ text, computed, rect, fontSizePx }); + } + if (!entries.length) { + return { svg: '', width: 0, height: 0 }; + } + + const margin = maxEffectiveFontSizePx * ITALIC_OVERFLOW_MARGIN_RATIO; + const glyphNodes = []; + for (const { text, computed, rect, fontSizePx } of entries) { const { path, upem, ascender } = await glyphFor(hb, text, computed); if (!path) continue; - const scale = fontSizePx / upem; - const x = rect.left - containerRect.left; - const baselineY = (rect.top - containerRect.top) + (ascender / upem) * fontSizePx; + const effectiveScale = (fontSizePx / upem) * titleScale; + const x = (rect.left - minLeft) + margin; + const baselineY = (rect.top - minTop) + ascender * effectiveScale + margin; glyphNodes.push( - ``, + ``, ); } - const margin = maxFontSizePx * ITALIC_OVERFLOW_MARGIN_RATIO; - const width = containerRect.width + margin * 2; - const height = containerRect.height + margin * 2; + const width = (maxRight - minLeft) + margin * 2; + const height = (maxBottom - minTop) + margin * 2; const svg = [ ``, - ``, + // No extra translate here: `margin` is already baked into each + // glyph's own x/baselineY above — adding it again here shifted + // everything by 2×margin, eating into the right/bottom margin + // entirely while doubling it on the left/top. + '', ...glyphNodes, '', '', @@ -125,7 +188,13 @@ return { svg, width, height }; } - async function rasterizeToPng(svgString, width, height, scale) { + async function rasterizeToPng(svgString, width, height, targetScale) { + const scale = Math.min( + targetScale, + MAX_CANVAS_DIMENSION / width, + MAX_CANVAS_DIMENSION / height, + ); + const svgBlob = new Blob([svgString], { type: 'image/svg+xml' }); const url = URL.createObjectURL(svgBlob); try {