Files
pulse-signage/src/web/lib/media/font-library.js
T
lzstealth f0177e6628
Publish Docker Image / build-and-push (./build/Dockerfile, git.lzstealth.com/lzstealth/pulse-signage-web, web) (push) Successful in 1m15s
Publish Docker Image / build-and-push (./build/Dockerfile.player, git.lzstealth.com/lzstealth/pulse-signage-player, player) (push) Successful in 32s
Release 2.7.3
2026-08-15 11:43:07 +01:00

408 lines
14 KiB
JavaScript

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
};