const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const FONT_LIBRARY_DIR_NAME = 'fonts'; const FONT_MANIFEST_FILE = 'fonts.json'; const FONT_STYLESHEET_FILE = 'fonts.css'; const DEFAULT_FONT_FAMILY_FORMATS = [ 'Arial=Arial,Helvetica,sans-serif', 'Comic Sans MS=Comic Sans MS,cursive,sans-serif', 'Courier New=Courier New,Courier,monospace', 'Georgia=Georgia,serif', 'Helvetica=Helvetica,Arial,sans-serif', 'Impact=Impact,Charcoal,sans-serif', 'Lucida Sans Unicode=Lucida Sans Unicode,Lucida Grande,sans-serif', 'Palatino Linotype=Palatino Linotype,Book Antiqua,Palatino,serif', 'Tahoma=Tahoma,Geneva,sans-serif', 'Times New Roman=Times New Roman,Times,serif', 'Trebuchet MS=Trebuchet MS,Helvetica,sans-serif', 'Verdana=Verdana,Geneva,sans-serif' ]; const FONT_FILE_EXTENSIONS = new Map([ ['.woff2', 'woff2'], ['.woff', 'woff'], ['.ttf', 'truetype'], ['.otf', 'opentype'] ]); function normalizeText(value) { return String(value || '').replace(/\s+/g, ' ').trim(); } function quoteFontFamilyToken(token) { const normalizedToken = normalizeText(token); if (!normalizedToken) { return ''; } if (normalizedToken === 'inherit' || /^[a-zA-Z0-9_-]+$/.test(normalizedToken)) { return normalizedToken; } return `'${normalizedToken.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; } function normalizeFontFamilyFormat(format) { return String(format || '') .split(',') .map(quoteFontFamilyToken) .filter(Boolean) .join(','); } function normalizeFontFamilyFormatEntry(entry) { const value = String(entry || '').trim(); if (!value) { return ''; } const separatorIndex = value.indexOf('='); if (separatorIndex === -1) { return `${value}=${normalizeFontFamilyFormat(value)}`; } const label = value.slice(0, separatorIndex).trim(); const format = value.slice(separatorIndex + 1).trim(); return `${label}=${normalizeFontFamilyFormat(format || label)}`; } function resolveMediaRoot(mediaRootOrUploadDir) { const resolved = path.resolve(String(mediaRootOrUploadDir || '').trim()); return path.basename(resolved) === 'uploads' ? path.dirname(resolved) : resolved; } function getFontLibraryDir(mediaRootOrUploadDir) { return path.join(resolveMediaRoot(mediaRootOrUploadDir), FONT_LIBRARY_DIR_NAME); } function getFontManifestPath(mediaRootOrUploadDir) { return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_MANIFEST_FILE); } function getFontStylesheetPath(mediaRootOrUploadDir) { return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_STYLESHEET_FILE); } function getFontStylesheetHref(mediaRootOrUploadDir) { const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir); if (!fs.existsSync(stylesheetPath)) { return ''; } const stat = fs.statSync(stylesheetPath); return `/media/${FONT_LIBRARY_DIR_NAME}/${FONT_STYLESHEET_FILE}?v=${Number(stat.mtimeMs) || Date.now()}`; } function normalizeFontEntry(entry) { const fileName = normalizeText(entry && entry.fileName); const family = normalizeText(entry && (entry.family || entry.name || entry.displayName)); const extension = path.extname(fileName).toLowerCase(); return { id: normalizeText(entry && entry.id) || fileName, name: family || fileName.replace(/\.[^.]+$/, ''), family: family || fileName.replace(/\.[^.]+$/, ''), fileName: fileName, format: normalizeText(entry && entry.format) || FONT_FILE_EXTENSIONS.get(extension) || '', enabled: entry && entry.enabled === false ? false : true, createdAt: normalizeText(entry && entry.createdAt), modifiedAt: normalizeText(entry && entry.modifiedAt) }; } function sortFonts(left, right) { return String(left && left.name || '').localeCompare(String(right && right.name || ''), undefined, { sensitivity: 'base' }) || String(left && left.fileName || '').localeCompare(String(right && right.fileName || ''), undefined, { sensitivity: 'base' }); } function readManifest(mediaRootOrUploadDir) { const manifestPath = getFontManifestPath(mediaRootOrUploadDir); if (!fs.existsSync(manifestPath)) { return []; } try { const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8') || '[]'); const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed && parsed.fonts) ? parsed.fonts : []; return list.map(normalizeFontEntry).filter(function (entry) { return Boolean(entry.fileName) && Boolean(normalizeText(entry.family || entry.name)); }).sort(sortFonts); } catch (_error) { return []; } } function buildFontFaceRule(entry) { const family = normalizeText(entry.family || entry.name); const fileName = normalizeText(entry.fileName); if (!family || !fileName) { return ''; } const extension = path.extname(fileName).toLowerCase(); const format = FONT_FILE_EXTENSIONS.get(extension) || normalizeText(entry.format) || 'woff2'; const escapedFamily = family.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); return [ '@font-face {', ` font-family: '${escapedFamily}';`, ` src: url('/media/${FONT_LIBRARY_DIR_NAME}/${encodeURIComponent(fileName)}') format('${format}');`, ' font-style: normal;', ' font-weight: 400;', ' font-display: swap;', '}' ].join('\n'); } function buildFontStylesheet(fonts) { const rules = (Array.isArray(fonts) ? fonts : []) .map(buildFontFaceRule) .filter(Boolean); return rules.length ? `/* Managed fonts */\n\n${rules.join('\n\n')}\n` : '/* Managed fonts */\n'; } function buildFontFamilyFormats(fonts) { const formatEntries = Array.from(new Set(DEFAULT_FONT_FAMILY_FORMATS.map(normalizeFontFamilyFormatEntry).concat((Array.isArray(fonts) ? fonts : []) .filter(function (font) { return font && font.enabled !== false && normalizeText(font.family || font.name); }) .map(function (font) { const family = normalizeText(font.family || font.name); return `${family}=${normalizeFontFamilyFormat(family)}`; })))) .sort(function (left, right) { const leftLabel = String(left || '').split('=')[0]; const rightLabel = String(right || '').split('=')[0]; return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: 'base' }); }); return formatEntries.join(';'); } async function ensureFontLibrary(mediaRootOrUploadDir) { const fontDir = getFontLibraryDir(mediaRootOrUploadDir); const manifestPath = getFontManifestPath(mediaRootOrUploadDir); const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir); await fs.promises.mkdir(fontDir, { recursive: true }); if (!fs.existsSync(manifestPath)) { await fs.promises.writeFile(manifestPath, '[]\n', 'utf8'); } if (!fs.existsSync(stylesheetPath)) { await fs.promises.writeFile(stylesheetPath, buildFontStylesheet([]), 'utf8'); } return { fonts: [], fontDir: fontDir, manifestPath: manifestPath, stylesheetPath: stylesheetPath, stylesheetHref: getFontStylesheetHref(mediaRootOrUploadDir), fontFamilyFormats: buildFontFamilyFormats([]) }; } function loadFontLibrary(mediaRootOrUploadDir) { const fonts = readManifest(mediaRootOrUploadDir); return { fonts: fonts, fontDir: getFontLibraryDir(mediaRootOrUploadDir), manifestPath: getFontManifestPath(mediaRootOrUploadDir), stylesheetPath: getFontStylesheetPath(mediaRootOrUploadDir), stylesheetHref: getFontStylesheetHref(mediaRootOrUploadDir), fontFamilyFormats: buildFontFamilyFormats(fonts) }; } async function saveFontLibrary(mediaRootOrUploadDir, fonts) { const fontDir = getFontLibraryDir(mediaRootOrUploadDir); const manifestPath = getFontManifestPath(mediaRootOrUploadDir); const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir); const normalizedFonts = (Array.isArray(fonts) ? fonts : []).map(normalizeFontEntry).filter(function (entry) { return Boolean(entry.fileName) && Boolean(normalizeText(entry.family || entry.name)); }).sort(sortFonts); await fs.promises.mkdir(fontDir, { recursive: true }); await fs.promises.writeFile(manifestPath, JSON.stringify(normalizedFonts, null, 2) + '\n', 'utf8'); await fs.promises.writeFile(stylesheetPath, buildFontStylesheet(normalizedFonts), 'utf8'); return normalizedFonts; } function isSupportedFontUpload(file) { const extension = path.extname(String(file && file.originalname || file && file.filename || '')).toLowerCase(); return FONT_FILE_EXTENSIONS.has(extension); } async function addFontFile(mediaRootOrUploadDir, uploadedFile, requestedFamily) { if (!uploadedFile || !uploadedFile.path || !isSupportedFontUpload(uploadedFile)) { throw new Error('Unsupported font upload.'); } const fontDir = getFontLibraryDir(mediaRootOrUploadDir); const fonts = readManifest(mediaRootOrUploadDir); const family = normalizeText(requestedFamily) || normalizeText(path.basename(uploadedFile.originalname || uploadedFile.filename || '', path.extname(uploadedFile.originalname || uploadedFile.filename || ''))); if (!family) { throw new Error('Font family is required.'); } if (fonts.some(function (font) { return String(font.family || font.name || '').trim().toLowerCase() === family.toLowerCase(); })) { throw new Error('A font with that family name already exists.'); } const extension = path.extname(uploadedFile.originalname || uploadedFile.filename || '').toLowerCase(); const slug = family.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'font'; const fileName = `${slug}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}${extension}`; const destinationPath = path.join(fontDir, fileName); await fs.promises.mkdir(fontDir, { recursive: true }); await fs.promises.rename(uploadedFile.path, destinationPath); const now = new Date().toISOString(); const nextFonts = fonts.concat({ id: fileName, name: family, family: family, fileName: fileName, format: FONT_FILE_EXTENSIONS.get(extension) || 'woff2', enabled: true, createdAt: now, modifiedAt: now }); await saveFontLibrary(mediaRootOrUploadDir, nextFonts); return { font: normalizeFontEntry(nextFonts[nextFonts.length - 1]), fonts: loadFontLibrary(mediaRootOrUploadDir).fonts }; } async function deleteFontFile(mediaRootOrUploadDir, fontId) { const fontDir = getFontLibraryDir(mediaRootOrUploadDir); const fonts = readManifest(mediaRootOrUploadDir); const normalizedId = normalizeText(fontId); const index = fonts.findIndex(function (font) { return String(font.id || font.fileName || '').trim() === normalizedId || String(font.fileName || '').trim() === normalizedId; }); if (index === -1) { return null; } const [removed] = fonts.splice(index, 1); if (removed && removed.fileName) { try { await fs.promises.unlink(path.join(fontDir, removed.fileName)); } catch (_error) { // ignore missing file cleanup issues } } await saveFontLibrary(mediaRootOrUploadDir, fonts); return normalizeFontEntry(removed); } async function setFontEnabled(mediaRootOrUploadDir, fontId, enabled) { const fonts = readManifest(mediaRootOrUploadDir); const normalizedId = normalizeText(fontId); const index = fonts.findIndex(function (font) { return String(font.id || font.fileName || '').trim() === normalizedId || String(font.fileName || '').trim() === normalizedId; }); if (index === -1) { return null; } const nextFonts = fonts.slice(); nextFonts[index] = Object.assign({}, nextFonts[index], { enabled: Boolean(enabled), modifiedAt: new Date().toISOString() }); await saveFontLibrary(mediaRootOrUploadDir, nextFonts); return normalizeFontEntry(nextFonts[index]); } function collectFontLibraryUploadPaths(mediaRootOrUploadDir) { const fonts = readManifest(mediaRootOrUploadDir); const uploadPaths = new Set(); const manifestPath = getFontManifestPath(mediaRootOrUploadDir); const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir); const mediaRoot = resolveMediaRoot(mediaRootOrUploadDir); if (manifestPath) { uploadPaths.add('/media/' + path.relative(mediaRoot, manifestPath).replace(/\\/g, '/')); } if (stylesheetPath) { uploadPaths.add('/media/' + path.relative(mediaRoot, stylesheetPath).replace(/\\/g, '/')); } fonts.forEach(function (font) { if (font && font.fileName) { uploadPaths.add(`/media/${FONT_LIBRARY_DIR_NAME}/${font.fileName}`); } }); return Array.from(uploadPaths); } async function collectFontLibraryDirectoryUploadPaths(mediaRootOrUploadDir) { const fontDir = getFontLibraryDir(mediaRootOrUploadDir); try { const entries = await fs.promises.readdir(fontDir, { withFileTypes: true }); return entries .filter(function (entry) { return entry && entry.isFile(); }) .map(function (entry) { return `/media/${FONT_LIBRARY_DIR_NAME}/${entry.name}`; }); } catch (_error) { return []; } } function collectFontLibrarySyncOperations(mediaRootOrUploadDir) { const fonts = readManifest(mediaRootOrUploadDir); const operations = [ { type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_MANIFEST_FILE }, { type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_STYLESHEET_FILE } ]; fonts.forEach(function (font) { if (!font || !font.fileName) { return; } operations.push({ type: 'put', uploadPath: `/media/${FONT_LIBRARY_DIR_NAME}/${font.fileName}` }); }); return operations; } module.exports = { ensureFontLibrary: ensureFontLibrary, loadFontLibrary: loadFontLibrary, saveFontLibrary: saveFontLibrary, addFontFile: addFontFile, deleteFontFile: deleteFontFile, setFontEnabled: setFontEnabled, collectFontLibraryUploadPaths: collectFontLibraryUploadPaths, collectFontLibraryDirectoryUploadPaths: collectFontLibraryDirectoryUploadPaths, collectFontLibrarySyncOperations: collectFontLibrarySyncOperations, getFontStylesheetHref: getFontStylesheetHref, buildFontStylesheet: buildFontStylesheet, buildFontFamilyFormats: buildFontFamilyFormats, normalizeFontFamilyFormat: normalizeFontFamilyFormat, normalizeFontFamilyFormatEntry: normalizeFontFamilyFormatEntry, isSupportedFontUpload: isSupportedFontUpload, getFontLibraryDir: getFontLibraryDir, getFontManifestPath: getFontManifestPath, getFontStylesheetPath: getFontStylesheetPath };