created variable title svg & png export module
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user