diff --git a/partials/landingpage/accountsetup/bridge.php b/partials/landingpage/accountsetup/bridge.php index 648f3b7..d046eab 100644 --- a/partials/landingpage/accountsetup/bridge.php +++ b/partials/landingpage/accountsetup/bridge.php @@ -116,6 +116,33 @@ require dirname(__DIR__) . '/../structure/layout_start.php';
Noch nicht gespeichert.
+ +
+

Schriftarten (optional)

+

+ Wenn du eigene Fonts vom Hoster nutzen willst, trage hier entweder einen lokalen Schriftarten-Ordner + (inkl. oeffentlicher URL) ein oder gib direkte Font-URLs an. Das System erzeugt automatisch @font-face + und stellt die Fonts im Editor bereit. +

+
+ + + +

+ Hinweis: woff2/woff ist empfohlen, ttf/otf funktioniert meist, wird aber nicht von allen Mail-Clients geladen. +

+
+
diff --git a/public/assets/fonts/KidsHandwriting-Regular.ttf b/public/assets/fonts/KidsHandwriting-Regular.ttf new file mode 100644 index 0000000..a9c8ba9 Binary files /dev/null and b/public/assets/fonts/KidsHandwriting-Regular.ttf differ diff --git a/public/assets/fonts/KidsHandwriting-Regular.woff b/public/assets/fonts/KidsHandwriting-Regular.woff new file mode 100644 index 0000000..5287318 Binary files /dev/null and b/public/assets/fonts/KidsHandwriting-Regular.woff differ diff --git a/public/assets/fonts/KidsHandwriting-Regular.woff2 b/public/assets/fonts/KidsHandwriting-Regular.woff2 new file mode 100644 index 0000000..2bb56ad Binary files /dev/null and b/public/assets/fonts/KidsHandwriting-Regular.woff2 differ diff --git a/public/assets/js/bridge-setup-page.js b/public/assets/js/bridge-setup-page.js index fe6abcd..d9a8f98 100644 --- a/public/assets/js/bridge-setup-page.js +++ b/public/assets/js/bridge-setup-page.js @@ -86,6 +86,11 @@ function defaultSetup() { password_key: '', charset_key: '', }, + fonts: { + dir: '', + url_base: '', + urls: '', + }, }; } @@ -148,6 +153,19 @@ function fillForm(setup, options = {}) { if (input) input.value = value; }); } + + const fontFields = document.getElementById('bridgeFontsForm'); + if (fontFields) { + const fontMap = { + fonts_dir: data.fonts.dir || '', + fonts_url_base: data.fonts.url_base || '', + fonts_urls: data.fonts.urls || '', + }; + Object.entries(fontMap).forEach(([name, value]) => { + const input = fontFields.querySelector(`[name="${name}"]`); + if (input) input.value = value; + }); + } } function applyModeVisibility(mode) { @@ -224,6 +242,9 @@ async function submitBridgeSetup(ev) { config_user_key: form.config_user_key?.value.trim() || '', config_password_key: form.config_password_key?.value.trim() || '', config_charset_key: form.config_charset_key?.value.trim() || '', + fonts_dir: document.querySelector('[name="fonts_dir"]')?.value.trim() || '', + fonts_url_base: document.querySelector('[name="fonts_url_base"]')?.value.trim() || '', + fonts_urls: document.querySelector('[name="fonts_urls"]')?.value || '', }; try { @@ -324,5 +345,9 @@ function normalizeSetupInput(input) { direct.port = Number(direct.port || 3306) || 3306; direct.charset = direct.charset || 'utf8mb4'; const config = { ...base.config, ...(input.config || {}) }; - return { tables, mode: validMode, direct, config }; + const fonts = { ...base.fonts, ...(input.fonts || {}) }; + fonts.dir = String(fonts.dir || '').trim(); + fonts.url_base = String(fonts.url_base || '').trim(); + fonts.urls = String(fonts.urls || ''); + return { tables, mode: validMode, direct, config, fonts }; } diff --git a/public/assets/js/bridge/blocks-api.js b/public/assets/js/bridge/blocks-api.js index 32c5488..7e81510 100644 --- a/public/assets/js/bridge/blocks-api.js +++ b/public/assets/js/bridge/blocks-api.js @@ -464,7 +464,11 @@ } // 1. Daten extrahieren - const htmlContent = editor.getHtml() + ''; + const fontCss = (B && typeof B.RTE_FONT_FACE_CSS === 'string' && B.RTE_FONT_FACE_CSS.trim()) + ? B.RTE_FONT_FACE_CSS.trim() + : ''; + const cssPayload = (fontCss ? fontCss + '\n' : '') + editor.getCss(); + const htmlContent = editor.getHtml() + ''; // 2. KRITISCH: Holt die JSON-Repräsentation des Editors let jsonProjectDataRaw = ''; try { diff --git a/public/editor/bridge-core.js b/public/editor/bridge-core.js index febbb96..6082a9d 100644 --- a/public/editor/bridge-core.js +++ b/public/editor/bridge-core.js @@ -263,7 +263,7 @@ autosave: false, }; - const execRteCommand = (rte, cmd, value) => { + const execRteCommand = (rte, cmd, value, docOverride) => { try { if (rte && typeof rte.exec === 'function') { rte.exec(cmd, value); @@ -271,9 +271,16 @@ } } catch {} try { - const ok = document.execCommand(cmd, false, value); + const doc = docOverride + || rte?.doc + || rte?.el?.ownerDocument + || document; + if (rte?.el && typeof rte.el.focus === 'function') { + rte.el.focus(); + } + const ok = doc.execCommand(cmd, false, value); if (ok === false && cmd === 'insertText') { - document.execCommand('insertHTML', false, String(value || '').replace(//g, '>')); + doc.execCommand('insertHTML', false, String(value || '').replace(//g, '>')); } return true; } catch {} @@ -344,10 +351,10 @@ || ''; content.innerHTML = initialHtml; - const addButton = (label, title, cmd, valueGetter) => { + const addButton = (labelHtml, title, cmd, valueGetter) => { const btn = doc.createElement('button'); btn.type = 'button'; - btn.textContent = label; + btn.innerHTML = labelHtml; btn.title = title; btn.style.padding = '4px 8px'; btn.style.border = '1px solid #cbd5f5'; @@ -359,7 +366,7 @@ const value = typeof valueGetter === 'function' ? valueGetter() : valueGetter; if (value === null || value === undefined) return; if (cmd === 'createLink' && !value) return; - execRteCommand(null, cmd, value); + execRteCommand(null, cmd, value, content.ownerDocument); }); toolbar.appendChild(btn); }; @@ -393,32 +400,41 @@ } }; - addButton('B', 'Fett', 'bold'); - addButton('I', 'Kursiv', 'italic'); - addButton('U', 'Unterstrichen', 'underline'); - addButton('S', 'Durchgestrichen', 'strikethrough'); + const icon = (path) => ``; + addButton('B', 'Fett', 'bold'); + addButton('I', 'Kursiv', 'italic'); + addButton('U', 'Unterstrichen', 'underline'); + addButton('S', 'Durchgestrichen', 'strikethrough'); + addButton(icon('M4 7h10v2H4zM4 11h16v2H4zM4 15h10v2H4z'), 'Liste (ungeordnet)', 'insertUnorderedList'); + addButton(icon('M4 7h14v2H4zM4 11h14v2H4zM4 15h14v2H4z') + '1.', 'Liste (geordnet)', 'insertOrderedList'); + addButton(icon('M4 7h10v2H4zM4 11h16v2H4zM4 15h12v2H4z'), 'Linksbundig', 'justifyLeft'); + addButton(icon('M5 7h14v2H5zM4 11h16v2H4zM5 15h14v2H5z'), 'Zentriert', 'justifyCenter'); + addButton(icon('M10 7h10v2H10zM4 11h16v2H4zM8 15h12v2H8z'), 'Rechtsbundig', 'justifyRight'); + addButton(icon('M4 7h16v2H4zM4 11h16v2H4zM4 15h16v2H4z'), 'Blocksatz', 'justifyFull'); addButton('Link', 'Link einfuegen', 'createLink', () => prompt('Link-URL eingeben', 'https://')); addButton('Unlink', 'Link entfernen', 'unlink'); - addButton('UL', 'Liste (ungeordnet)', 'insertUnorderedList'); - addButton('OL', 'Liste (geordnet)', 'insertOrderedList'); - addButton('L', 'Linksbundig', 'justifyLeft'); - addButton('C', 'Zentriert', 'justifyCenter'); - addButton('R', 'Rechtsbundig', 'justifyRight'); - addButton('J', 'Blocksatz', 'justifyFull'); addButton('Sub', 'Tiefgestellt', 'subscript'); addButton('Sup', 'Hochgestellt', 'superscript'); addButton('Einr.', 'Einzug', 'indent'); addButton('Aus.', 'Ausruecken', 'outdent'); addButton('Clear', 'Formatierung entfernen', 'removeFormat'); + const fontOptions = (B.RTE_FONTS && Array.isArray(B.RTE_FONTS) && B.RTE_FONTS.length) + ? B.RTE_FONTS + : [ + { label: 'Arial', value: 'Arial, sans-serif' }, + { label: 'Calibri', value: 'Calibri, sans-serif' }, + { label: 'Cambria', value: 'Cambria, serif' }, + { label: 'Georgia', value: 'Georgia, serif' }, + { label: 'Tahoma', value: 'Tahoma, sans-serif' }, + { label: 'Times New Roman', value: 'Times New Roman, serif' }, + { label: 'Trebuchet MS', value: 'Trebuchet MS, sans-serif' }, + { label: 'Verdana', value: 'Verdana, sans-serif' }, + ]; addSelect([ { label: 'Schriftart', value: '' }, - { label: 'Arial', value: 'Arial, sans-serif' }, - { label: 'Georgia', value: 'Georgia, serif' }, - { label: 'Tahoma', value: 'Tahoma, sans-serif' }, - { label: 'Times New Roman', value: 'Times New Roman, serif' }, - { label: 'Verdana', value: 'Verdana, sans-serif' }, - ], 'Schriftart', (value) => execRteCommand(null, 'fontName', value)); + ...fontOptions, + ], 'Schriftart', (value) => execRteCommand(null, 'fontName', value, content.ownerDocument)); addSelect([ { label: 'Groesse', value: '' }, @@ -429,7 +445,7 @@ { label: '18px', value: '5' }, { label: '24px', value: '6' }, { label: '32px', value: '7' }, - ], 'Schriftgroesse', (value) => execRteCommand(null, 'fontSize', value)); + ], 'Schriftgroesse', (value) => execRteCommand(null, 'fontSize', value, content.ownerDocument)); const emojiBtn = doc.createElement('button'); emojiBtn.type = 'button'; @@ -498,6 +514,19 @@ const setupRichTextEditor = (editor) => { if (!editor || !editor.RichTextEditor) return; const rte = editor.RichTextEditor; + const icon = (path) => ``; + const resolveFontOptions = () => (B.RTE_FONTS && Array.isArray(B.RTE_FONTS) && B.RTE_FONTS.length) + ? B.RTE_FONTS + : [ + { label: 'Arial', value: 'Arial, sans-serif' }, + { label: 'Calibri', value: 'Calibri, sans-serif' }, + { label: 'Cambria', value: 'Cambria, serif' }, + { label: 'Georgia', value: 'Georgia, serif' }, + { label: 'Tahoma', value: 'Tahoma, sans-serif' }, + { label: 'Times New Roman', value: 'Times New Roman, serif' }, + { label: 'Trebuchet MS', value: 'Trebuchet MS, sans-serif' }, + { label: 'Verdana', value: 'Verdana, sans-serif' }, + ]; const addAction = (name, icon, title, command, valueGetter) => { if (rte.get && rte.get(name)) return; @@ -515,14 +544,18 @@ }); }; - addAction('bridge-align-left', 'L', 'Linksbundig', 'justifyLeft'); - addAction('bridge-align-center', 'C', 'Zentriert', 'justifyCenter'); - addAction('bridge-align-right', 'R', 'Rechtsbundig', 'justifyRight'); - addAction('bridge-align-justify', 'J', 'Blocksatz', 'justifyFull'); - addAction('bridge-ul', 'UL', 'Liste (ungeordnet)', 'insertUnorderedList'); - addAction('bridge-ol', 'OL', 'Liste (geordnet)', 'insertOrderedList'); + addAction('bridge-align-left', icon('M4 7h10v2H4zM4 11h16v2H4zM4 15h12v2H4z'), 'Linksbundig', 'justifyLeft'); + addAction('bridge-align-center', icon('M5 7h14v2H5zM4 11h16v2H4zM5 15h14v2H5z'), 'Zentriert', 'justifyCenter'); + addAction('bridge-align-right', icon('M10 7h10v2H10zM4 11h16v2H4zM8 15h12v2H8z'), 'Rechtsbundig', 'justifyRight'); + addAction('bridge-align-justify', icon('M4 7h16v2H4zM4 11h16v2H4zM4 15h16v2H4z'), 'Blocksatz', 'justifyFull'); + addAction('bridge-ul', icon('M4 7h10v2H4zM4 11h16v2H4zM4 15h10v2H4z'), 'Liste (ungeordnet)', 'insertUnorderedList'); + addAction('bridge-ol', icon('M4 7h16v2H4zM4 11h16v2H4zM4 15h16v2H4z'), 'Liste (geordnet)', 'insertOrderedList'); addAction('bridge-emoji', ':-)', 'Emoticon einfuegen', 'insertText', () => prompt('Emoticon eingeben', ':)')); - addAction('bridge-font-family', 'F', 'Schriftart', 'fontName', () => prompt('Schriftart (z.B. Arial, Georgia)', 'Arial')); + addAction('bridge-font-family', 'F', 'Schriftart', 'fontName', () => { + const fonts = resolveFontOptions(); + const example = fonts.map((f) => f.label).slice(0, 5).join(', '); + return prompt(`Schriftart (z.B. ${example})`, fonts[0]?.label || 'Arial'); + }); addAction('bridge-font-size', 'Px', 'Schriftgroesse', 'fontSize', () => { const raw = prompt('Schriftgroesse in px (10-32)', '14'); const val = Number(raw || 14); @@ -542,11 +575,16 @@ }); return best.cmd; }); - addAction('bridge-open-richtext', 'RTE', 'Richtext Editor', 'execCommand', () => { - const component = editor.getSelected && editor.getSelected(); - openRichTextModal(editor, component); - return null; - }); + if (!(rte.get && rte.get('bridge-open-richtext'))) { + rte.add('bridge-open-richtext', { + icon: 'RTE', + attributes: { title: 'Richtext Editor' }, + result: () => { + const component = editor.getSelected && editor.getSelected(); + openRichTextModal(editor, component); + }, + }); + } if (!editor.Commands.get('bridge-open-richtext')) { editor.Commands.add('bridge-open-richtext', { @@ -576,6 +614,46 @@ editor.on('component:add', (model) => ensureTextToolbarButton(editor, model)); }; + const loadDynamicFonts = async () => { + try { + const base = B.API_KERNEL_URL || '/api.php'; + const sep = base.includes('?') ? '&' : '?'; + const res = await fetch(`${base}${sep}action=account.fonts.list`, { credentials: 'include' }); + if (!res.ok) return; + const data = await res.json(); + if (!data || !data.ok) return; + const incoming = Array.isArray(data.fonts) ? data.fonts : []; + if (incoming.length) { + const merged = []; + const seen = new Set(); + const addFont = (item) => { + if (!item || !item.label || !item.value) return; + const key = String(item.label).toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + merged.push({ label: String(item.label), value: String(item.value) }); + }; + (B.RTE_FONTS || []).forEach(addFont); + incoming.forEach(addFont); + B.RTE_FONTS = merged; + } + if (typeof data.font_face_css === 'string' && data.font_face_css.trim()) { + const existing = typeof B.RTE_FONT_FACE_CSS === 'string' ? B.RTE_FONT_FACE_CSS.trim() : ''; + B.RTE_FONT_FACE_CSS = existing + ? `${existing}\n${data.font_face_css.trim()}` + : data.font_face_css.trim(); + const styleId = 'bridge-font-faces'; + let styleEl = document.getElementById(styleId); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = styleId; + document.head.appendChild(styleEl); + } + styleEl.textContent = B.RTE_FONT_FACE_CSS; + } + } catch {} + }; + var ed = grapesjs.init({ container: '#gjs', height: '100vh', @@ -623,6 +701,7 @@ } setupRichTextEditor(ed); + loadDynamicFonts(); // Entfernt: jegliche Blur/RTE-Handler, die Inhalte verändern. diff --git a/public/editor/editor-core.php b/public/editor/editor-core.php index 164805d..63d0246 100644 --- a/public/editor/editor-core.php +++ b/public/editor/editor-core.php @@ -2,6 +2,27 @@ $mode = strtolower($_GET['mode'] ?? 'templates'); $id = (int)($_GET['id'] ?? 0); $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time(); +$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; +$host = $_SERVER['HTTP_HOST'] ?? 'localhost'; +$appBaseUrl = rtrim($GLOBALS['app_base_url'] ?? ($scheme . '://' . $host), '/'); +$fontDir = __DIR__ . '/../assets/fonts'; +$fontBase = $appBaseUrl . '/assets/fonts'; +$customFontName = 'Kids Handwriting'; +$customFontFiles = [ + 'woff2' => 'KidsHandwriting-Regular.woff2', + 'woff' => 'KidsHandwriting-Regular.woff', + 'ttf' => 'KidsHandwriting-Regular.ttf', +]; +$fontSources = []; +foreach ($customFontFiles as $format => $file) { + if (is_file($fontDir . '/' . $file)) { + $fontSources[] = "url('" . htmlspecialchars($fontBase . '/' . $file, ENT_QUOTES) . "') format('{$format}')"; + } +} +$fontFaceCss = ''; +if ($fontSources) { + $fontFaceCss = "@font-face{font-family:'{$customFontName}';font-style:normal;font-weight:400;src:" . implode(',', $fontSources) . ";}"; +} ?> @@ -15,6 +36,7 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time(); .gjs-one-bg{background-color:#fff!important}.gjs-two-color{color:#0f172a!important} .gjs-three-bg{background-color:#f8fafc!important}.gjs-four-color{color:#334155!important} #badge{position:fixed;right:8px;top:8px;background:#eef2ff;color:#1e3a8a;border:1px solid #c7d2fe;border-radius:999px;padding:4px 10px;font:12px system-ui;z-index:2147483647;opacity:.9} + @@ -31,6 +53,18 @@ $assetVersion = defined('ASSET_VERSION') ? ASSET_VERSION : time(); window.BridgeParts.API_KERNEL_URL = window.BridgeParts.API_KERNEL_URL || '/api.php'; window.BridgeParts.API_BASE = window.BridgeParts.API_BASE || window.BridgeParts.API_KERNEL_URL; window.BridgeParts.STORAGE_URL_BASE = window.BridgeParts.STORAGE_URL_BASE || window.BridgeParts.API_BASE; + window.BridgeParts.RTE_FONTS = window.BridgeParts.RTE_FONTS || [ + { label: 'Kids Handwriting', value: "'Kids Handwriting', 'Comic Sans MS', cursive" }, + { label: 'Arial', value: 'Arial, sans-serif' }, + { label: 'Calibri', value: 'Calibri, sans-serif' }, + { label: 'Cambria', value: 'Cambria, serif' }, + { label: 'Georgia', value: 'Georgia, serif' }, + { label: 'Tahoma', value: 'Tahoma, sans-serif' }, + { label: 'Times New Roman', value: 'Times New Roman, serif' }, + { label: 'Trebuchet MS', value: 'Trebuchet MS, sans-serif' }, + { label: 'Verdana', value: 'Verdana, sans-serif' }, + ]; + window.BridgeParts.RTE_FONT_FACE_CSS = window.BridgeParts.RTE_FONT_FACE_CSS || ; function logToParent(type, detail){ try{ parent.postMessage({source:'editor-core',type:type,detail:String(detail||'')},'*'); }catch(e){} } window.addEventListener('error', function(e){ diff --git a/src/ApiKernel.php b/src/ApiKernel.php index 49bf361..6442a77 100644 --- a/src/ApiKernel.php +++ b/src/ApiKernel.php @@ -1081,6 +1081,9 @@ class ApiKernel case 'account.bridge.test': $this->handleAccountBridgeTest(); break; + case 'account.fonts.list': + $this->handleAccountFontsList(); + break; case 'placeholders.status': $this->handlePlaceholderStatus(); break; @@ -2072,12 +2075,18 @@ class ApiKernel 'password_key' => (string)($this->in['config_password_key'] ?? ''), 'charset_key' => (string)($this->in['config_charset_key'] ?? ''), ]; + $fonts = [ + 'dir' => (string)($this->in['fonts_dir'] ?? ''), + 'url_base' => (string)($this->in['fonts_url_base'] ?? ''), + 'urls' => (string)($this->in['fonts_urls'] ?? ''), + ]; $setup = $this->sanitizeBridgeSetup([ 'tables' => $tables, 'mode' => $mode, 'direct' => $direct, 'config' => $config, + 'fonts' => $fonts, ]); $stored = $this->saveBridgeSetupData($customerId, $setup); @@ -2114,6 +2123,20 @@ class ApiKernel ]); } + private function handleAccountFontsList(): void + { + $user = $this->requireAuth(); + $customerId = (int)($user['customer_id'] ?? 0); + $setup = $this->getBridgeSetupData($customerId); + $fonts = $setup['fonts'] ?? []; + $payload = $this->buildFontCatalog($fonts); + $this->respond([ + 'ok' => true, + 'fonts' => $payload['fonts'], + 'font_face_css' => $payload['font_face_css'], + ]); + } + private function handleDebugPhpInfo(): void { $user = $this->requireAuth(); @@ -2315,6 +2338,11 @@ class ApiKernel 'password_key' => '', 'charset_key' => '', ], + 'fonts' => [ + 'dir' => '', + 'url_base' => '', + 'urls' => '', + ], ]; } @@ -2331,11 +2359,28 @@ class ApiKernel $tables = $this->normalizeBridgeTables($input['tables'] ?? []); $direct = $input['direct'] ?? []; $config = $input['config'] ?? []; + $fonts = $input['fonts'] ?? []; $sanitizePath = function ($value) { $value = trim((string)$value); if ($value === '') return ''; return preg_replace('/[^a-zA-Z0-9_\.\-]/', '', $value) ?: ''; }; + $sanitizeDir = function ($value) { + $value = trim((string)$value); + if ($value === '') return ''; + return trim(preg_replace('/[^a-zA-Z0-9_\.\-\/\\\\:\s]/', '', $value)) ?: ''; + }; + $sanitizeUrl = function ($value) { + $value = trim((string)$value); + if ($value === '') return ''; + $value = preg_replace('/[\x00-\x1f]/', '', $value); + return $value; + }; + $sanitizeText = function ($value) { + $value = (string)$value; + $value = preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f]/', '', $value); + return $value; + }; $result = [ 'tables' => $tables, 'mode' => $mode, @@ -2357,6 +2402,11 @@ class ApiKernel 'password_key' => $sanitizePath($config['password_key'] ?? ''), 'charset_key' => $sanitizePath($config['charset_key'] ?? ''), ], + 'fonts' => [ + 'dir' => $sanitizeDir($fonts['dir'] ?? ''), + 'url_base' => $sanitizeUrl($fonts['url_base'] ?? ''), + 'urls' => $sanitizeText($fonts['urls'] ?? ''), + ], ]; if ($result['direct']['port'] <= 0) { $result['direct']['port'] = 3306; @@ -2364,6 +2414,93 @@ class ApiKernel return $result; } + private function buildFontCatalog(array $fonts): array + { + $items = []; + $faces = []; + $seen = []; + $groups = []; + $allowed = ['woff2', 'woff', 'ttf', 'otf']; + + $addGroup = function (string $family, array $sources) use (&$items, &$faces, &$seen, $allowed) { + $family = trim($family); + if ($family === '') return; + if (!empty($seen[strtolower($family)])) return; + $srcParts = []; + foreach ($allowed as $ext) { + if (!empty($sources[$ext])) { + $url = $sources[$ext]; + $srcParts[] = "url('{$url}') format('{$ext}')"; + } + } + if (!$srcParts) return; + $safeFamily = str_replace("'", "\\'", $family); + $faces[] = "@font-face{font-family:'{$safeFamily}';font-style:normal;font-weight:400;src:" . implode(',', $srcParts) . ";}"; + $items[] = [ + 'label' => $family, + 'value' => "'" . $safeFamily . "', sans-serif", + ]; + $seen[strtolower($family)] = true; + }; + + $inferFamily = function (string $name): string { + $name = preg_replace('/[-_]+/', ' ', $name); + $name = preg_replace('/\s+/', ' ', $name); + return trim($name); + }; + + $dir = trim((string)($fonts['dir'] ?? '')); + $base = trim((string)($fonts['url_base'] ?? '')); + if ($dir !== '' && $base !== '' && is_dir($dir)) { + $base = rtrim($base, '/'); + $pattern = $dir . '/*.{woff2,woff,ttf,otf,WOFF2,WOFF,TTF,OTF}'; + $files = glob($pattern, GLOB_BRACE) ?: []; + foreach ($files as $file) { + $ext = strtolower(pathinfo($file, PATHINFO_EXTENSION)); + if (!in_array($ext, $allowed, true)) continue; + $name = pathinfo($file, PATHINFO_FILENAME); + $groups[$name][$ext] = $base . '/' . basename($file); + } + } + + $rawUrls = (string)($fonts['urls'] ?? ''); + if ($rawUrls !== '') { + $lines = preg_split('/\r?\n/', $rawUrls) ?: []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '') continue; + $family = ''; + $url = ''; + if (strpos($line, '|') !== false) { + [$family, $url] = array_map('trim', explode('|', $line, 2)); + } elseif (strpos($line, '=') !== false) { + [$family, $url] = array_map('trim', explode('=', $line, 2)); + } else { + $url = $line; + } + if ($url === '') continue; + $path = parse_url($url, PHP_URL_PATH); + if (!$path) continue; + $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if (!in_array($ext, $allowed, true)) continue; + $name = pathinfo($path, PATHINFO_FILENAME); + $family = $family !== '' ? $family : $inferFamily($name); + if ($family === '') continue; + $groups[$family][$ext] = $url; + } + } + + foreach ($groups as $family => $sources) { + $display = $inferFamily($family); + $addGroup($display, $sources); + } + + return [ + 'fonts' => $items, + 'font_face_css' => implode("\n", $faces), + ]; + } + private function customerSettingsTable(): string { return 'emailtemplate_customer_settings';