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