created variable title svg & png export module

This commit is contained in:
2026-09-01 15:11:05 +02:00
parent 882164231f
commit 7b8d66edb7
14 changed files with 845 additions and 2 deletions
@@ -0,0 +1,156 @@
/**
* @file
* Font-agnostic glyph extraction.
*
* Resolves whichever font is *actually* serving a given element right now
* (by reading its computed style and matching it against the page's own
* @font-face rules), fetches that exact font file, and converts individual
* characters into SVG path data at their precise variation-axis values.
*
* Nothing here is tied to a specific typeface: if the active theme changes
* its font tomorrow, this keeps working without any change to this module,
* because the font is looked up live instead of being vendored.
*/
let hbModulePromise = null;
/**
* Loads the harfbuzzjs engine (WASM), once per page.
*
* @param {string} harfbuzzUrl URL to dist/index.mjs, installed via
* Composer (npm-asset/harfbuzzjs, see composer.json) into
* web/libraries/harfbuzzjs — provided by the PHP module
* (drupalSettings.leshedSvgExport.harfbuzzUrl), not vendored here.
*/
export function loadHarfbuzz(harfbuzzUrl) {
if (!hbModulePromise) {
hbModulePromise = import(harfbuzzUrl);
}
return hbModulePromise;
}
const faceCache = new Map();
function loadFace(hb, url) {
if (!faceCache.has(url)) {
faceCache.set(
url,
fetch(url, { credentials: 'same-origin' })
.then((response) => {
if (!response.ok) {
throw new Error(`impossible de charger la police (${url}).`);
}
return response.arrayBuffer();
})
.then((buffer) => {
const blob = new hb.Blob(buffer);
const face = new hb.Face(blob);
return { face, upem: face.upem };
}),
);
}
return faceCache.get(url);
}
function parseFontFamily(computedFontFamily) {
const first = (computedFontFamily || '').split(',')[0] || '';
return first.trim().replace(/^["']|["']$/g, '');
}
/**
* Turns a computed `font-variation-settings` value (e.g. `"wght" 550`)
* into [{tag, value}, ...]. Returns [] for `normal`/empty — the axis set is
* read as-is, nothing is hardcoded to a `wght`-only assumption.
*/
function parseVariationSettings(computedValue) {
const axes = [];
if (!computedValue || computedValue === 'normal') {
return axes;
}
const re = /["']?(\w{4})["']?\s+(-?[\d.]+)/g;
let match = re.exec(computedValue);
while (match !== null) {
axes.push({ tag: match[1], value: parseFloat(match[2]) });
match = re.exec(computedValue);
}
return axes;
}
/**
* Finds the @font-face `src` URL actually backing `family`/`style` by
* scanning the page's own stylesheets (CSSOM).
*/
function resolveFontFaceUrl(family, style) {
const wantItalic = style === 'italic' || style === 'oblique';
const normFamily = family.toLowerCase();
for (const sheet of document.styleSheets) {
let rules;
try {
rules = sheet.cssRules;
}
catch (e) {
// Cross-origin stylesheet: CSSOM access is blocked, skip it.
continue;
}
if (!rules) continue;
for (const rule of rules) {
if (typeof CSSFontFaceRule === 'undefined' || !(rule instanceof CSSFontFaceRule)) {
continue;
}
const ruleFamily = (rule.style.getPropertyValue('font-family') || '')
.trim()
.replace(/^["']|["']$/g, '')
.toLowerCase();
if (ruleFamily !== normFamily) continue;
const ruleStyle = (rule.style.getPropertyValue('font-style') || 'normal').trim().toLowerCase();
const ruleIsItalic = ruleStyle === 'italic' || ruleStyle === 'oblique';
if (ruleIsItalic !== wantItalic) continue;
const src = rule.style.getPropertyValue('src') || '';
const urlMatch = src.match(/url\(\s*["']?([^"')]+)["']?\s*\)/);
if (urlMatch) {
return new URL(urlMatch[1], sheet.href || document.baseURI).href;
}
}
}
return null;
}
/**
* Converts one character into its outline path, using the font actually
* applied to `computedStyle` (from `getComputedStyle()` on the character's
* element), at its exact variation-axis values.
*
* @return {Promise<{path: string, upem: number, ascender: number}>}
*/
export async function glyphFor(hb, char, computedStyle) {
const family = parseFontFamily(computedStyle.fontFamily);
const style = computedStyle.fontStyle;
const url = resolveFontFaceUrl(family, style);
if (!url) {
throw new Error(`aucune règle @font-face trouvée pour "${family}" (${style}).`);
}
const { face, upem } = await loadFace(hb, url);
const font = new hb.Font(face);
font.setScale(upem, upem);
const axes = parseVariationSettings(computedStyle.fontVariationSettings);
if (axes.length) {
font.setVariations(axes.map((axis) => new hb.Variation(axis.tag, axis.value)));
}
const buffer = new hb.Buffer();
buffer.addText(char);
buffer.guessSegmentProperties();
hb.shape(font, buffer);
const infos = buffer.getGlyphInfos();
const path = infos.length ? font.glyphToPath(infos[0].codepoint) : '';
const ascender = font.getMetricPositionWithFallback(hb.MetricsTag.HORIZONTAL_ASCENDER);
return { path, upem, ascender };
}
@@ -0,0 +1,273 @@
/**
* @file
* Injects an "Export SVG/PNG" button next to every `.variable-title`
* element (the DOM contract exposed by the active theme for its
* letter-by-letter generative typography — see the theme's
* styleWordChars()) and captures, on click, the exact rendering currently
* on screen: line wraps, per-letter weight/italic, container width.
*/
(function (Drupal, drupalSettings, once) {
'use strict';
const SLUG_MAX_LENGTH = 60;
const PNG_SCALE = 4;
// U+0300-U+036F: combining diacritical marks left over after NFD
// normalization (e.g. splitting "é" into "e" + a combining acute accent).
const COMBINING_DIACRITICS = new RegExp(
`[${String.fromCharCode(0x0300)}-${String.fromCharCode(0x036f)}]`,
'g',
);
function slugify(text) {
const slug = (text || '')
.normalize('NFD')
.replace(COMBINING_DIACRITICS, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, SLUG_MAX_LENGTH);
return slug || 'leshed-export';
}
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* Applies the character's computed `text-transform` (uppercase / lowercase
* / capitalize) so the exported glyph matches what's visually on screen,
* not the raw DOM text. "capitalize" only affects the first `.char` of
* each `.word` (Splitting.js's own word grouping — the same boundary the
* theme already uses for its per-letter styling).
*/
function applyTextTransform(rawText, transform, charEl, seenWords) {
switch (transform) {
case 'uppercase':
return rawText.toLocaleUpperCase();
case 'lowercase':
return rawText.toLocaleLowerCase();
case 'capitalize': {
const word = charEl.closest('.word') || charEl.parentElement;
const isFirstOfWord = !seenWords.has(word);
seenWords.add(word);
return isFirstOfWord ? rawText.toLocaleUpperCase() : rawText;
}
default:
return rawText;
}
}
/**
* 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.
*/
async function buildSvg(titleEl, glyphFor, hb) {
const containerRect = titleEl.getBoundingClientRect();
const chars = Array.from(titleEl.querySelectorAll('.char'));
const seenWords = new Set();
const glyphNodes = [];
for (const charEl of chars) {
const rawText = charEl.textContent;
if (!rawText || !rawText.trim()) continue;
const rect = charEl.getBoundingClientRect();
const computed = getComputedStyle(charEl);
const fontSizePx = parseFloat(computed.fontSize);
if (!fontSizePx) continue;
const text = applyTextTransform(rawText, computed.textTransform, charEl, seenWords);
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;
glyphNodes.push(
`<g transform="translate(${x.toFixed(2)}, ${baselineY.toFixed(2)}) scale(${scale.toFixed(5)}, ${(-scale).toFixed(5)})"><path d="${path}"/></g>`,
);
}
const width = containerRect.width;
const height = containerRect.height;
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)}">`,
'<g fill="#000000">',
...glyphNodes,
'</g>',
'</svg>',
].join('');
return { svg, width, height };
}
async function rasterizeToPng(svgString, width, height, scale) {
const svgBlob = new Blob([svgString], { type: 'image/svg+xml' });
const url = URL.createObjectURL(svgBlob);
try {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('le rendu du SVG en image a échoué.'));
img.src = url;
});
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
return await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
}
finally {
URL.revokeObjectURL(url);
}
}
function createExportWidget(titleEl, basePath, harfbuzzUrl) {
const wrapper = document.createElement('span');
wrapper.className = 'leshed-svg-export';
wrapper.setAttribute('contenteditable', 'false');
const svgBtn = document.createElement('button');
svgBtn.type = 'button';
svgBtn.className = 'leshed-svg-export__btn';
svgBtn.textContent = 'SVG';
svgBtn.title = 'Exporter en SVG (vectoriel)';
const pngBtn = document.createElement('button');
pngBtn.type = 'button';
pngBtn.className = 'leshed-svg-export__btn';
pngBtn.textContent = 'PNG';
pngBtn.title = 'Exporter en PNG (haute définition)';
let modulesPromise = null;
function loadModules() {
if (!modulesPromise) {
modulesPromise = import(`${basePath}/js/harfbuzz-export.js`).then(async (mod) => {
const hb = await mod.loadHarfbuzz(harfbuzzUrl);
return { hb, glyphFor: mod.glyphFor };
});
}
return modulesPromise;
}
async function handleExport(format, triggerBtn) {
const originalLabel = triggerBtn.textContent;
const originalTitle = triggerBtn.title;
const otherBtn = triggerBtn === svgBtn ? pngBtn : svgBtn;
triggerBtn.disabled = true;
otherBtn.disabled = true;
triggerBtn.textContent = '…';
try {
const { hb, glyphFor } = await loadModules();
const { svg, width, height } = await buildSvg(titleEl, glyphFor, hb);
// Not `titleEl.textContent`: the widget itself is now a child of
// titleEl (see attachButtons()), so that would also pick up the
// buttons' own labels ("SVG", "PNG"...).
const titleText = Array.from(titleEl.querySelectorAll('.char')).map((c) => c.textContent).join('');
const slug = slugify(titleText);
if (format === 'svg') {
triggerDownload(new Blob([svg], { type: 'image/svg+xml' }), `${slug}.svg`);
}
else {
const pngBlob = await rasterizeToPng(svg, width, height, PNG_SCALE);
triggerDownload(pngBlob, `${slug}.png`);
}
}
catch (error) {
// eslint-disable-next-line no-console
console.error('Le Shed SVG export:', error);
triggerBtn.textContent = 'Erreur';
triggerBtn.title = `Export impossible : ${error.message}`;
setTimeout(() => {
triggerBtn.textContent = originalLabel;
triggerBtn.title = originalTitle;
}, 4000);
triggerBtn.disabled = false;
otherBtn.disabled = false;
return;
}
triggerBtn.disabled = false;
otherBtn.disabled = false;
triggerBtn.textContent = originalLabel;
}
svgBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
handleExport('svg', svgBtn);
});
pngBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
handleExport('png', pngBtn);
});
wrapper.appendChild(svgBtn);
wrapper.appendChild(pngBtn);
return wrapper;
}
/**
* The widget is positioned via plain CSS (`position: absolute` anchored
* to the title), which only works if the title itself is a positioning
* context. Only touch that when it isn't already one (`position: relative`
* with no offset is visually inert — it doesn't move or resize the
* title), so an already-positioned title (e.g. one animated by the
* theme) is left untouched.
*/
function ensurePositioningContext(titleEl) {
if (getComputedStyle(titleEl).position === 'static') {
titleEl.style.position = 'relative';
}
}
function attachButtons(basePath, harfbuzzUrl, context) {
once('leshedSvgExport', '.variable-title', context).forEach((titleEl) => {
ensurePositioningContext(titleEl);
const widget = createExportWidget(titleEl, basePath, harfbuzzUrl);
titleEl.appendChild(widget);
titleEl.classList.add('leshed-svg-export-target');
});
}
Drupal.behaviors.leshedSvgExport = {
attach(context) {
const settings = drupalSettings.leshedSvgExport;
if (!settings || !settings.basePath || !settings.harfbuzzUrl) return;
attachButtons(settings.basePath, settings.harfbuzzUrl, context);
// `.variable-title` elements can appear (or gain that class) after
// this behavior first runs, since the theme's own script processes
// titles independently and asynchronously. Watch the DOM instead of
// assuming a load-order relationship with the theme's script.
once('leshedSvgExportObserver', 'body', context).forEach((body) => {
let scheduled = false;
const observer = new MutationObserver(() => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
attachButtons(settings.basePath, settings.harfbuzzUrl, document);
});
});
observer.observe(body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
});
},
};
})(Drupal, drupalSettings, once);