variable titles exports fixed

This commit is contained in:
2026-09-10 16:10:50 +02:00
parent 8378de5124
commit 32319f19bd
2 changed files with 132 additions and 32 deletions
+40 -9
View File
@@ -119,23 +119,54 @@ Comportement Drupal (`Drupal.behaviors.leshedSvgExport`) :
demanderait soit de modifier le CSS du thème, soit de recouvrir le titre 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 d'un calque — les deux sont exclus. Sur tout titre sans cette règle
(l'immense majorité), le survol fonctionne normalement. (l'immense majorité), le survol fonctionne normalement.
- Au clic : capture la largeur du conteneur du titre, puis pour chaque - Au clic : pour chaque `.char` dans l'ordre du DOM — position
`.char` dans l'ordre du DOM — position (`getBoundingClientRect()`), (`getBoundingClientRect()`), style calculé (`font-family`, `font-style`,
style calculé (`font-family`, `font-style`, tous les axes de tous les axes de `font-variation-settings`, `text-transform`) — et
`font-variation-settings`, `text-transform`) — et convertit `.char.textContent` convertit `.char.textContent` selon le `text-transform` réellement
selon le `text-transform` réellement appliqué (uppercase / lowercase / appliqué (uppercase / lowercase / capitalize, ce dernier basé sur le
capitalize, ce dernier basé sur le premier `.char` de chaque `.word`) premier `.char` de chaque `.word`) avant de demander le tracé du bon
avant de demander le tracé du bon caractère. 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 - Assemble le SVG (fond transparent, remplissage noir uniquement — décision
volontaire, pas configurable) et déclenche le téléchargement (`Blob` + volontaire, pas configurable) et déclenche le téléchargement (`Blob` +
`<a download>`). Une marge égale à 30 % de la plus grande taille de police `<a download>`). 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 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 canevas : les lettres italiques débordent de leur boîte d'avance nominale
(surtout en haut des hampes), et `getBoundingClientRect()` du titre ne (surtout en haut des hampes), et `getBoundingClientRect()` du titre ne
tient pas compte de ce débordement — sans cette marge, un cadrage au plus tient pas compte de ce débordement — sans cette marge, un cadrage au plus
juste coupe parfois ces lettres. juste coupe parfois ces lettres.
- Pour le PNG : rasterise le SVG assemblé via un `<canvas>` à ×4 la taille - Pour le PNG : rasterise le SVG assemblé via un `<canvas>` à ×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 `<canvas>` que le navigateur
autorise).
### `js/harfbuzz-export.js` ### `js/harfbuzz-export.js`
@@ -12,6 +12,15 @@
const SLUG_MAX_LENGTH = 60; const SLUG_MAX_LENGTH = 60;
const PNG_SCALE = 4; 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 <canvas> 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 // U+0300-U+036F: combining diacritical marks left over after NFD
// normalization (e.g. splitting "é" into "e" + a combining acute accent). // normalization (e.g. splitting "é" into "e" + a combining acute accent).
@@ -67,26 +76,63 @@
} }
/** /**
* Reads the DOM exactly as currently rendered (positions, weights, * Reads the current CSS `transform` matrix on `el` and returns its scale
* italics, line wraps, text-transform) and turns every `.char` into a * factor. `font-size` never reflects an ancestor `transform: scale()`
* positioned glyph path. Nothing here recomputes layout — it only reads * (this theme animates title scale on scroll, in initPageFull()), while
* what the browser already laid out at the moment of the click. * `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 function getScaleFactor(el) {
// the whole canvas. Italic glyphs slant past their own nominal advance const transform = getComputedStyle(el).transform;
// box (most visibly at the top of ascenders), and the title's own if (!transform || transform === 'none') return 1;
// getBoundingClientRect() doesn't account for that overflow — a tightly try {
// fitted viewBox clips them. This margin is a generous, font-agnostic const matrix = new DOMMatrix(transform);
// safety net rather than measuring each glyph's exact ink bounds. // 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; const ITALIC_OVERFLOW_MARGIN_RATIO = 0.3;
async function buildSvg(titleEl, glyphFor, hb) { async function buildSvg(titleEl, glyphFor, hb) {
const containerRect = titleEl.getBoundingClientRect();
const chars = Array.from(titleEl.querySelectorAll('.char')); 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 seenWords = new Set();
const glyphNodes = []; const entries = [];
let maxFontSizePx = 0; let minLeft = Infinity;
let minTop = Infinity;
let maxRight = -Infinity;
let maxBottom = -Infinity;
let maxEffectiveFontSizePx = 0;
for (const charEl of chars) { for (const charEl of chars) {
const rawText = charEl.textContent; const rawText = charEl.textContent;
if (!rawText || !rawText.trim()) continue; if (!rawText || !rawText.trim()) continue;
@@ -95,28 +141,45 @@
const computed = getComputedStyle(charEl); const computed = getComputedStyle(charEl);
const fontSizePx = parseFloat(computed.fontSize); const fontSizePx = parseFloat(computed.fontSize);
if (!fontSizePx) continue; 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); const text = applyTextTransform(rawText, computed.textTransform, charEl, seenWords);
entries.push({ text, computed, rect, fontSizePx });
}
if (!entries.length) {
return { svg: '<svg xmlns="http://www.w3.org/2000/svg"></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); const { path, upem, ascender } = await glyphFor(hb, text, computed);
if (!path) continue; if (!path) continue;
const scale = fontSizePx / upem; const effectiveScale = (fontSizePx / upem) * titleScale;
const x = rect.left - containerRect.left; const x = (rect.left - minLeft) + margin;
const baselineY = (rect.top - containerRect.top) + (ascender / upem) * fontSizePx; const baselineY = (rect.top - minTop) + ascender * effectiveScale + margin;
glyphNodes.push( glyphNodes.push(
`<g transform="translate(${x.toFixed(2)}, ${baselineY.toFixed(2)}) scale(${scale.toFixed(5)}, ${(-scale).toFixed(5)})"><path d="${path}"/></g>`, `<g transform="translate(${x.toFixed(2)}, ${baselineY.toFixed(2)}) scale(${effectiveScale.toFixed(5)}, ${(-effectiveScale).toFixed(5)})"><path d="${path}"/></g>`,
); );
} }
const margin = maxFontSizePx * ITALIC_OVERFLOW_MARGIN_RATIO; const width = (maxRight - minLeft) + margin * 2;
const width = containerRect.width + margin * 2; const height = (maxBottom - minTop) + margin * 2;
const height = containerRect.height + margin * 2;
const svg = [ const svg = [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width.toFixed(2)} ${height.toFixed(2)}" width="${width.toFixed(2)}" height="${height.toFixed(2)}">`, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width.toFixed(2)} ${height.toFixed(2)}" width="${width.toFixed(2)}" height="${height.toFixed(2)}">`,
`<g fill="#000000" transform="translate(${margin.toFixed(2)}, ${margin.toFixed(2)})">`, // 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.
'<g fill="#000000">',
...glyphNodes, ...glyphNodes,
'</g>', '</g>',
'</svg>', '</svg>',
@@ -125,7 +188,13 @@
return { svg, width, height }; 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 svgBlob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(svgBlob); const url = URL.createObjectURL(svgBlob);
try { try {