From 7b8d66edb72cf64f9f1b4349e387262a8d32fc02 Mon Sep 17 00:00:00 2001 From: bach Date: Tue, 1 Sep 2026 15:11:05 +0200 Subject: [PATCH] created variable title svg & png export module --- composer.json | 15 +- composer.lock | 72 ++++- config/sync/core.extension.yml | 1 + config/sync/user.role.admin.yml | 2 + config/sync/user.role.editeur.yml | 2 + .../custom/leshed_svg_export/README.md | 185 ++++++++++++ .../leshed_svg_export/css/svg-export.css | 86 ++++++ .../leshed_svg_export/js/harfbuzz-export.js | 156 ++++++++++ .../custom/leshed_svg_export/js/svg-export.js | 273 ++++++++++++++++++ .../leshed_svg_export.info.yml | 5 + .../leshed_svg_export.libraries.yml | 9 + .../leshed_svg_export.module | 35 +++ .../leshed_svg_export.permissions.yml | 3 + .../custom/leshed/assets/scss/main.scss | 3 + 14 files changed, 845 insertions(+), 2 deletions(-) create mode 100644 web/modules/custom/leshed_svg_export/README.md create mode 100644 web/modules/custom/leshed_svg_export/css/svg-export.css create mode 100644 web/modules/custom/leshed_svg_export/js/harfbuzz-export.js create mode 100644 web/modules/custom/leshed_svg_export/js/svg-export.js create mode 100644 web/modules/custom/leshed_svg_export/leshed_svg_export.info.yml create mode 100644 web/modules/custom/leshed_svg_export/leshed_svg_export.libraries.yml create mode 100644 web/modules/custom/leshed_svg_export/leshed_svg_export.module create mode 100644 web/modules/custom/leshed_svg_export/leshed_svg_export.permissions.yml diff --git a/composer.json b/composer.json index d9adee6..c31e7b3 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,10 @@ { "type": "composer", "url": "https://packages.drupal.org/8" + }, + { + "type": "composer", + "url": "https://asset-packagist.org" } ], "require": { @@ -32,6 +36,8 @@ "drupal/shield": "^1.8", "drupal/structure_sync": "^2.0", "league/commonmark": "^2.8", + "npm-asset/harfbuzzjs": "^1.6", + "oomphinc/composer-installers-extender": "^2.0", "wikimedia/composer-merge-plugin": "^2.1" }, "conflict": { @@ -47,6 +53,7 @@ "drupal/core-composer-scaffold": true, "drupal/core-project-message": true, "drupal/core-recipe-unpack": true, + "oomphinc/composer-installers-extender": true, "php-http/discovery": true, "php-tuf/composer-integration": true, "phpstan/extension-installer": true, @@ -63,12 +70,18 @@ "web-root": "web/" } }, + "installer-types": [ + "npm-asset", + "bower-asset" + ], "installer-paths": { "web/core": [ "type:drupal-core" ], "web/libraries/{$name}": [ - "type:drupal-library" + "type:drupal-library", + "type:npm-asset", + "type:bower-asset" ], "web/modules/contrib/{$name}": [ "type:drupal-module" diff --git a/composer.lock b/composer.lock index 2a383a7..f8fbbcf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "883e680c8ddbbc1fde2b177cfdc92db5", + "content-hash": "d1c180e38e51750e97dde8fdab941ed5", "packages": [ { "name": "asm89/stack-cors", @@ -8882,6 +8882,76 @@ }, "time": "2026-07-04T14:30:18+00:00" }, + { + "name": "npm-asset/harfbuzzjs", + "version": "1.6.0", + "dist": { + "type": "tar", + "url": "https://registry.npmjs.org/harfbuzzjs/-/harfbuzzjs-1.6.0.tgz" + }, + "type": "npm-asset", + "license": [ + "MIT" + ] + }, + { + "name": "oomphinc/composer-installers-extender", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/oomphinc/composer-installers-extender.git", + "reference": "cbf4b6f9a24153b785d09eee755b995ba87bd5f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/oomphinc/composer-installers-extender/zipball/cbf4b6f9a24153b785d09eee755b995ba87bd5f9", + "reference": "cbf4b6f9a24153b785d09eee755b995ba87bd5f9", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.1 || ^2.0", + "composer/installers": "^1.0 || ^2.0", + "php": ">=7.1" + }, + "require-dev": { + "composer/composer": "^2.0", + "phpunit/phpunit": "^7.2", + "squizlabs/php_codesniffer": "^3.3" + }, + "type": "composer-plugin", + "extra": { + "class": "OomphInc\\ComposerInstallersExtender\\Plugin" + }, + "autoload": { + "psr-4": { + "OomphInc\\ComposerInstallersExtender\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Stephen Beemsterboer", + "email": "stephen@oomphinc.com", + "homepage": "https://github.com/balbuf" + }, + { + "name": "Nathan Dentzau", + "email": "nate@oomphinc.com", + "homepage": "http://oomph.is/ndentzau" + } + ], + "description": "Extend the composer/installers plugin to accept any arbitrary package type.", + "homepage": "http://www.oomphinc.com/", + "support": { + "issues": "https://github.com/oomphinc/composer-installers-extender/issues", + "source": "https://github.com/oomphinc/composer-installers-extender/tree/2.0.1" + }, + "abandoned": true, + "time": "2021-12-15T12:32:42+00:00" + }, { "name": "pear/archive_tar", "version": "1.6.0", diff --git a/config/sync/core.extension.yml b/config/sync/core.extension.yml index ad24552..1d8f587 100644 --- a/config/sync/core.extension.yml +++ b/config/sync/core.extension.yml @@ -60,6 +60,7 @@ module: language: 0 layout_builder: 0 layout_discovery: 0 + leshed_svg_export: 0 link: 0 link_attributes: 0 linkit: 0 diff --git a/config/sync/user.role.admin.yml b/config/sync/user.role.admin.yml index 0e534e7..860a8ae 100644 --- a/config/sync/user.role.admin.yml +++ b/config/sync/user.role.admin.yml @@ -17,6 +17,7 @@ dependencies: - content_translation - devel - file + - leshed_svg_export - node - system - taxonomy @@ -77,6 +78,7 @@ permissions: - 'execute user_unblock_user_action user' - 'execute vbo_cancel_user_action user' - 'execute views_bulk_edit all' + - 'export leshed typography' - 'revert all revisions' - 'revert all taxonomy revisions' - 'revert evenement revisions' diff --git a/config/sync/user.role.editeur.yml b/config/sync/user.role.editeur.yml index 485d507..be43162 100644 --- a/config/sync/user.role.editeur.yml +++ b/config/sync/user.role.editeur.yml @@ -17,6 +17,7 @@ dependencies: - actions_permissions - content_translation - filter + - leshed_svg_export - locale - node - system @@ -58,6 +59,7 @@ permissions: - 'execute node_make_unsticky_action node' - 'execute node_promote_action node' - 'execute node_unpromote_action node' + - 'export leshed typography' - 'revert evenement revisions' - 'revert lieu revisions' - 'revert partenaire revisions' diff --git a/web/modules/custom/leshed_svg_export/README.md b/web/modules/custom/leshed_svg_export/README.md new file mode 100644 index 0000000..5c666b2 --- /dev/null +++ b/web/modules/custom/leshed_svg_export/README.md @@ -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` + + ``). +- Pour le PNG : rasterise le SVG assemblé via un `` à ×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. diff --git a/web/modules/custom/leshed_svg_export/css/svg-export.css b/web/modules/custom/leshed_svg_export/css/svg-export.css new file mode 100644 index 0000000..d1c3d47 --- /dev/null +++ b/web/modules/custom/leshed_svg_export/css/svg-export.css @@ -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; +} diff --git a/web/modules/custom/leshed_svg_export/js/harfbuzz-export.js b/web/modules/custom/leshed_svg_export/js/harfbuzz-export.js new file mode 100644 index 0000000..4669ac1 --- /dev/null +++ b/web/modules/custom/leshed_svg_export/js/harfbuzz-export.js @@ -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 }; +} diff --git a/web/modules/custom/leshed_svg_export/js/svg-export.js b/web/modules/custom/leshed_svg_export/js/svg-export.js new file mode 100644 index 0000000..d928a05 --- /dev/null +++ b/web/modules/custom/leshed_svg_export/js/svg-export.js @@ -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( + ``, + ); + } + + const width = containerRect.width; + const height = containerRect.height; + const svg = [ + ``, + '', + ...glyphNodes, + '', + '', + ].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); diff --git a/web/modules/custom/leshed_svg_export/leshed_svg_export.info.yml b/web/modules/custom/leshed_svg_export/leshed_svg_export.info.yml new file mode 100644 index 0000000..f9871c0 --- /dev/null +++ b/web/modules/custom/leshed_svg_export/leshed_svg_export.info.yml @@ -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' diff --git a/web/modules/custom/leshed_svg_export/leshed_svg_export.libraries.yml b/web/modules/custom/leshed_svg_export/leshed_svg_export.libraries.yml new file mode 100644 index 0000000..a67baec --- /dev/null +++ b/web/modules/custom/leshed_svg_export/leshed_svg_export.libraries.yml @@ -0,0 +1,9 @@ +svg-export: + js: + js/svg-export.js: {} + css: + theme: + css/svg-export.css: {} + dependencies: + - core/once + - core/drupalSettings diff --git a/web/modules/custom/leshed_svg_export/leshed_svg_export.module b/web/modules/custom/leshed_svg_export/leshed_svg_export.module new file mode 100644 index 0000000..dd282eb --- /dev/null +++ b/web/modules/custom/leshed_svg_export/leshed_svg_export.module @@ -0,0 +1,35 @@ +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'; +} diff --git a/web/modules/custom/leshed_svg_export/leshed_svg_export.permissions.yml b/web/modules/custom/leshed_svg_export/leshed_svg_export.permissions.yml new file mode 100644 index 0000000..9fa56cd --- /dev/null +++ b/web/modules/custom/leshed_svg_export/leshed_svg_export.permissions.yml @@ -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.' diff --git a/web/themes/custom/leshed/assets/scss/main.scss b/web/themes/custom/leshed/assets/scss/main.scss index 534f524..9561b89 100644 --- a/web/themes/custom/leshed/assets/scss/main.scss +++ b/web/themes/custom/leshed/assets/scss/main.scss @@ -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; }