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,185 @@
# Le Shed SVG Export
Exporte en SVG (vectoriel) et PNG (haute définition) la typographie
générative lettre par lettre du thème `leshed` : le logo du site et tout
titre marqué `.variable-title` — tel qu'affiché à l'instant précis du clic
(tirage de graisses/italiques en cours, retours à ligne, mise en majuscule
CSS…), pas une version figée/officielle.
## Contexte
Le thème `leshed` découpe ces textes en spans par caractère (Splitting.js)
et tire aléatoirement, à chaque affichage, une graisse (`font-variation-settings:
'wght'`, police variable Epilogue) et un état italique par lettre — voir
`styleWordChars()` dans `assets/js/main.js` du thème. Rien n'est jamais
figé côté thème ; ce module permet de capturer et exporter un tirage
précis pour un usage print.
## Ce que ce module ne fait pas
- Il ne modifie rien dans le thème `leshed`. Il consomme uniquement un
contrat DOM déjà exposé par le thème : la classe `.variable-title` sur
un élément, et des spans `.char` à l'intérieur, stylés inline en
`font-variation-settings` / `font-style`. Si le thème actif n'expose
jamais cette structure, le module reste installable mais n'a aucun
effet visible.
- Il n'exporte pas de texte libre/arbitraire — seulement ce que le thème a
déjà marqué `.variable-title`. Aujourd'hui, ça se limite au logo du site
et aux titres de node "projet" (voir le sélecteur dans `initTitles()` du
thème) : pas encore les pages de taxonomie ni les autres types de
contenu, faute de marquage côté thème.
- Il ne vendorise aucune police. Voir "Résolution dynamique de la police"
ci-dessous.
- Il ne vendorise pas non plus son moteur de rendu de police (harfbuzzjs) :
installé via Composer, pas commité dans les sources du module. Voir
"Installation" ci-dessous.
## Architecture
Toute la conversion texte → tracés vectoriels se fait **côté client**,
dans le navigateur. Le module Drupal ne fait que :
1. Déclarer la permission `export leshed typography`.
2. Décider, dans `hook_page_attachments()` (`leshed_svg_export.module`),
si la librairie JS est attachée à la page — uniquement si l'utilisateur
courant a la permission. Un utilisateur non autorisé ne charge donc
strictement aucun asset (wasm, police, JS) de ce module.
3. Fournir ses propres fichiers JS/CSS et l'URL vers le moteur `harfbuzzjs`
installé par ailleurs via Composer (voir "Installation").
## Installation
Le moteur `harfbuzzjs` (WebAssembly, MIT) n'est **pas commité** dans ce
module — ça paraîtrait bizarre de vendoriser un moteur générique dans les
sources d'un module métier, et ça découplerait sa mise à jour du reste du
projet. Il est déclaré comme dépendance Composer, exactement comme les
modules contrib :
- `composer.json` déclare le dépôt `https://asset-packagist.org` (miroir
Composer du registre npm) et requiert `npm-asset/harfbuzzjs`, ainsi que
`oomphinc/composer-installers-extender` (nécessaire pour que
`composer/installers` sache installer un paquet de type `npm-asset`
package marqué "abandonné" sur Packagist mais toujours fonctionnel ;
aucun remplaçant n'est proposé à ce jour, à surveiller).
- `extra.installer-types`/`installer-paths` mappent `type:npm-asset` vers
`web/libraries/{$name}`, comme le fait déjà `type:drupal-library`.
- Résultat : `composer install`/`composer update` installe `harfbuzzjs`
dans `web/libraries/harfbuzzjs/` — **aucune étape d'installation
supplémentaire** par rapport au reste du projet.
- `leshed_svg_export.module` expose l'URL correspondante
(`web/libraries/harfbuzzjs/dist/index.mjs`) via
`drupalSettings.leshedSvgExport.harfbuzzUrl`.
**Prérequis serveur (nginx)** : le fichier `dist/index.mjs` du paquet doit
être servi avec un type MIME JavaScript. Beaucoup de configurations nginx
(dont celle de ce projet à l'origine) n'ont pas de mapping pour l'extension
`.mjs` et répondent `application/octet-stream`, que les navigateurs
refusent de charger comme module ES (Firefox notamment). Ajouter dans
`Docker/nginx/default.conf` :
```nginx
location ~ \.mjs$ {
default_type application/javascript;
}
```
### `js/svg-export.js`
Comportement Drupal (`Drupal.behaviors.leshedSvgExport`) :
- Surveille le DOM via `MutationObserver` pour repérer tout élément
`.variable-title` (existant ou ajouté/marqué plus tard — pas d'hypothèse
d'ordre de chargement avec le script du thème).
- Pour chaque titre trouvé, crée un petit badge avec deux boutons ("SVG",
"PNG") et l'ajoute comme **enfant DOM du titre lui-même**
(`titleEl.appendChild(widget)`), positionné en `position: absolute`
(`top: 0; left: 0` — superposé au coin haut-gauche du titre, jamais au
reste). Le titre reçoit aussi la classe `leshed-svg-export-target` (marqueur
propre au module, indépendant de `.variable-title` — utilisé par le CSS
ci-dessous, et disponible pour cibler "un titre équipé du bouton export"
sans dépendre du nom de classe du thème). Il reçoit `position: relative`
en JS uniquement s'il ne l'a pas déjà (visuellement neutre, ne bouge/ne
redimensionne rien) pour servir de contexte de positionnement. Étant en
`position: absolute`, le badge est retiré du flux normal : il ne peut
donc jamais influencer la mise en page du front, et le navigateur le
garde attaché au titre automatiquement au scroll/resize — aucune
synchronisation JS de position n'est nécessaire.
- Visibilité **entièrement en CSS**
(`.leshed-svg-export-target:hover > .leshed-svg-export`, `:focus-within`,
voir `css/svg-export.css`), sans aucun JS de détection de
survol, et **sans jamais recouvrir le reste du titre** d'un calque
invisible — un titre peut être un vrai lien ailleurs (le logo, un
teaser) et doit rester entièrement cliquable.
**Limite connue, assumée** : ce hover ne peut pas se déclencher sur un
titre que le thème a lui-même rendu non interactif
(`pointer-events: none` — ex. le h2 décoratif surdimensionné d'une page
"projet" en vue complète). Un élément à `pointer-events: none` ne reçoit
jamais d'événement de survol, quoi qu'on écrive en CSS ; le contourner
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.
- Assemble le SVG (fond transparent, remplissage noir uniquement — décision
volontaire, pas configurable) et déclenche le téléchargement (`Blob` +
`<a download>`).
- Pour le PNG : rasterise le SVG assemblé via un `<canvas>` à ×4 la taille
affichée (`PNG_SCALE`), puis `canvas.toBlob('image/png')`.
### `js/harfbuzz-export.js`
Résolution dynamique de la police et extraction des tracés, via
[harfbuzzjs](https://github.com/harfbuzz/harfbuzzjs) (HarfBuzz compilé en
WebAssembly, MIT — installé via Composer, voir "Installation" ci-dessus).
`loadHarfbuzz(harfbuzzUrl)` importe directement l'URL fournie par le module
PHP (`web/libraries/harfbuzzjs/dist/index.mjs`).
**Aucune police n'est embarquée dans ce module.** Pour chaque caractère :
1. Lit son style calculé (`font-family`, `font-style`, les axes de
`font-variation-settings`).
2. Cherche, dans `document.styleSheets` (CSSOM), la règle `@font-face`
dont `font-family`/`font-style` correspondent, et en extrait l'URL du
descripteur `src`.
3. `fetch()` cette URL (peu importe le conteneur réel — ttf/otf/woff2 —
HarfBuzz travaille sur les octets bruts), met en cache par URL.
4. Instancie la police à la variation exacte lue (`font.setVariations()`),
shape le caractère, extrait son tracé (`font.glyphToPath()`).
Conséquence : si le thème change de police demain (nouvelle famille,
nouveaux axes variables, retour à une police statique), l'export suit
automatiquement — rien à modifier dans ce module.
## Permission
`export leshed typography` — à assigner aux rôles voulus (aucun rôle par
défaut). Contrôle uniquement l'attachement de la librairie ; ne dépend
d'aucune configuration de thème.
## Style du widget
Volontairement **non harmonisé avec le thème front** : le widget imite le
thème d'administration Gin en mode sombre (`css/svg-export.css`), avec les
valeurs de couleur copiées directement des tokens Gin (`--gin-bg-app`,
accent "blue" en mode sombre, etc. — voir
`web/themes/contrib/gin/dist/css/theme/{variables,accent}.css`), en dur,
puisque le CSS de Gin n'est pas chargé sur les pages front.
## Limites connues
- Sur un titre que le thème a rendu non interactif (`pointer-events: none`
— ex. le h2 décoratif de la page "projet" en vue complète), le bouton
n'apparaît jamais au survol souris (il reste accessible au clavier via
Tab/`:focus-within`). Voir la note dans `js/svg-export.js`.
- Les césures logicielles insérées par le thème (`hyphenateWord()`, U+00AD)
ne sont pas des `.char` et ne sont donc jamais exportées.
- Positionnement du texte basé sur `getBoundingClientRect()` +
métriques de police (ascender/unitsPerEm) plutôt que sur une mesure
exacte de ligne de base — fidèle en pratique mais pas garanti au pixel
près sur toutes les combinaisons police/navigateur.
@@ -0,0 +1,86 @@
/**
* Deliberately does NOT match the front-end theme: this is an editor tool
* floating over public pages, so it's styled after the Gin admin theme's
* dark mode instead, to read unambiguously as an admin control regardless
* of which front-end theme/page it's injected into.
*
* Values below are copied from Gin's own dark-mode tokens (theme/variables.css
* .gin--dark-mode, theme/accent.css .gin--dark-mode[data-gin-accent=blue] —
* Gin's default accent) rather than referencing Gin's CSS custom properties,
* since Gin's admin stylesheet isn't loaded on front-end pages.
*/
.leshed-svg-export {
/* Anchored to the title element itself (its direct DOM parent — see
ensurePositioningContext() in svg-export.js, which makes the title a
positioning context if it isn't one already). `position: absolute`
takes it out of flow, so it cannot shift the surrounding layout, and
it tracks the title automatically on scroll/resize/reflow — no JS
position syncing needed. Deliberately does NOT cover the rest of the
title with any invisible layer — a title can be a real link elsewhere
(the site logo, a teaser), and it must stay fully clickable. */
position: absolute;
top: 0;
left: 0;
z-index: 10;
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
background: #1b1b1d; /* --gin-bg-app (dark mode) */
border: 1px solid #43454a; /* --gin-border-color (dark mode) */
border-radius: 0.625rem; /* --gin-border-l */
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 5px 8px rgba(0, 0, 0, 0.3);
/* Hidden until the title is hovered/focused — pure CSS, no JS.
Known limitation: this can never trigger on a title the theme itself
made non-interactive (`pointer-events: none`, e.g. the oversized
decorative h2 on a "projet" full page) — an element with
`pointer-events: none` never receives hover at all. Overriding that
would mean either touching the theme's CSS or covering the title with
our own layer, both ruled out. On every other title (no such rule),
hover works normally. */
opacity: 0;
pointer-events: none;
transition: opacity 0.15s cubic-bezier(0.19, 1, 0.22, 1);
}
.leshed-svg-export-target:hover > .leshed-svg-export,
.leshed-svg-export-target:focus-within > .leshed-svg-export {
opacity: 1;
pointer-events: auto;
}
.leshed-svg-export__btn {
font-family: Ginter, Inter, "Helvetica Neue", BlinkMacSystemFont, -apple-system, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, sans-serif;
font-size: 0.75rem; /* --gin-font-size-xxs */
font-weight: 525; /* --gin-font-weight-semibold */
line-height: 1;
color: #90aeef; /* --gin-color-primary, dark mode "blue" accent */
background: transparent;
border: 2px solid #90aeef;
border-radius: 0.375rem; /* --gin-border-s */
padding: 0.4375rem 0.625rem; /* --gin-spacing-xxs+2px / --gin-spacing-s */
cursor: pointer;
transition: 0.15s cubic-bezier(0.19, 1, 0.22, 1);
}
.leshed-svg-export__btn:hover {
background-color: #9bb6f1; /* --gin-color-primary-hover, dark mode */
border-color: #9bb6f1;
color: #111; /* --gin-color-button-text, dark mode */
}
.leshed-svg-export__btn:active,
.leshed-svg-export__btn:focus {
background-color: #a1baf1; /* --gin-color-primary-active, dark mode */
border-color: #a1baf1;
color: #111;
outline: none;
}
.leshed-svg-export__btn:disabled {
cursor: progress;
color: #919191; /* --gin-color-disabled, dark mode */
border-color: #646464; /* --gin-color-disabled-border, dark mode */
background: transparent;
}
@@ -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);
@@ -0,0 +1,5 @@
type: module
name: 'Le Shed SVG Export'
description: 'Exporte en SVG/PNG la typographie générative (lettre par lettre) affichée par le thème : logo du site et titres marqués .variable-title.'
package: leshed
core_version_requirement: '^10.5 || ^11'
@@ -0,0 +1,9 @@
svg-export:
js:
js/svg-export.js: {}
css:
theme:
css/svg-export.css: {}
dependencies:
- core/once
- core/drupalSettings
@@ -0,0 +1,35 @@
<?php
/**
* @file
* Attaches the SVG/PNG typography export button for permitted users.
*
* Consumes a DOM contract exposed by the active theme (currently the
* "leshed" theme): any element carrying the `.variable-title` class is
* expected to contain `.char` spans, one per letter, each styled inline
* with `font-variation-settings` / `font-style` (see
* styleWordChars() in the leshed theme's assets/js/main.js). This module
* does not declare a hard dependency on that theme it simply has no
* visible effect if the active theme never produces such markup.
*/
/**
* Implements hook_page_attachments().
*/
function leshed_svg_export_page_attachments(array &$page) {
// The attached library/button depend on the current user's permission,
// so page caches must vary on it.
$page['#cache']['contexts'][] = 'user.permissions';
if (!\Drupal::currentUser()->hasPermission('export leshed typography')) {
return;
}
$page['#attached']['library'][] = 'leshed_svg_export/svg-export';
$page['#attached']['drupalSettings']['leshedSvgExport']['basePath'] = base_path()
. \Drupal::service('extension.list.module')->getPath('leshed_svg_export');
// harfbuzzjs engine installed via Composer (npm-asset/harfbuzzjs, see
// composer.json) rather than vendored in this module — no copy to
// maintain, updated like any other project dependency.
$page['#attached']['drupalSettings']['leshedSvgExport']['harfbuzzUrl'] = base_path() . 'libraries/harfbuzzjs/dist/index.mjs';
}
@@ -0,0 +1,3 @@
export leshed typography:
title: 'Exporter la typographie Le Shed en SVG/PNG'
description: 'Voir et utiliser le bouton d''export SVG/PNG sur le logo du site et les titres portant la classe .variable-title.'
@@ -332,6 +332,9 @@ article.node-type-projet.view-mode-full{
z-index: 100;
width: 205%;//calc(50% - $colsgap / 2);
pointer-events: none;
&.leshed-svg-export-target{
pointer-events: painted;
}
transform-origin: 0 0;
}