diff --git a/CHANGELOG.md b/CHANGELOG.md
index 597c0b7..41e9da7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
- Fixed playlist schedule summaries showing weekday text and omitting boundary times when dates are returned as `Date` objects.
- Preserved saved API region items-path overrides when reopening the slide editor instead of replacing them with the source default.
+- Fixed managed-font loading in thumbnail popup previews and preserved entity-encoded font-family quotes across preview sanitization.
## 2.20.4 - 2026-10-25
diff --git a/src/web/public/js/regions/region-utils.js b/src/web/public/js/regions/region-utils.js
index 21857fd..b86bd68 100644
--- a/src/web/public/js/regions/region-utils.js
+++ b/src/web/public/js/regions/region-utils.js
@@ -14,6 +14,12 @@
.replace(/'/g, ''');
}
+ function decodeAttributeValue(value) {
+ var decoder = document.createElement('textarea');
+ decoder.innerHTML = String(value || '').replace(//gi, '');
@@ -123,7 +129,7 @@
return '';
}
- var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
+ var value = decodeAttributeValue(doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '');
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
@@ -185,7 +191,7 @@
return '';
}
- var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
+ var value = decodeAttributeValue(doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '');
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
diff --git a/src/web/routes/register.js b/src/web/routes/register.js
index 29554ac..e03a3b4 100644
--- a/src/web/routes/register.js
+++ b/src/web/routes/register.js
@@ -102,6 +102,7 @@ function registerAuthAndAccountRoutes(app, deps) {
pool: deps.pool,
common: deps.common,
pages: deps.pages,
+ mediaDir: deps.mediaDir,
requirePermission: deps.requirePermission,
buildDashboardState: deps.buildDashboardState,
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
diff --git a/test/slide-preview-renderer.test.js b/test/slide-preview-renderer.test.js
index fbd564d..73a5a92 100644
--- a/test/slide-preview-renderer.test.js
+++ b/test/slide-preview-renderer.test.js
@@ -35,6 +35,57 @@ function loadEditorTimeDateModule() {
return modules.get('time-date');
}
+test('preview sanitizers preserve entity-quoted font families without double escaping', () => {
+ const entities = { apos: "'", quot: '"', amp: '&', lt: '<', gt: '>' };
+ const sandbox = {
+ window: {},
+ document: {
+ createElement(tag) {
+ assert.equal(tag, 'textarea');
+ return {
+ set innerHTML(value) {
+ this.value = value.replace(/&(#x[\da-f]+|#\d+|apos|quot|amp|lt|gt);/gi, (_match, entity) => {
+ if (entity[0] !== '#') return entities[entity];
+ return String.fromCodePoint(entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : Number(entity.slice(1)));
+ });
+ }
+ };
+ }
+ }
+ };
+ vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/public/js/regions/region-utils'), 'utf8'), sandbox);
+
+ for (const sanitize of [sandbox.window.pulseRegionUtils.sanitizePreviewHtml, sandbox.window.pulseRegionUtils.sanitizeRichText]) {
+ for (const quotes of ["'Ubuntu Regular'", ''Ubuntu Regular'', '"Ubuntu Regular"']) {
+ const markup = sanitize('Player');
+ assert.doesNotMatch(markup, /'|"/);
+ assert.equal(sanitize(markup), markup);
+ assert.match(markup, /font-family: (?:'Ubuntu Regular'|"Ubuntu Regular");/);
+ }
+ const unsafe = sanitize('Player');
+ assert.doesNotMatch(unsafe, /href=|style=/);
+ }
+});
+
+test('page route wiring preserves the configured media directory for popup font stylesheets', () => {
+ let pageDependencies;
+ const sandbox = {
+ module: { exports: {} },
+ require(name) {
+ if (name === '#src/rbac') return { PERMISSIONS: [], normalizePermissionKeys: value => value };
+ return function (_app, dependencies) {
+ if (name === '#src/web/routes/admin/pages') pageDependencies = dependencies;
+ };
+ }
+ };
+ vm.runInNewContext(fs.readFileSync(require.resolve('../src/web/routes/register'), 'utf8'), sandbox);
+ sandbox.module.exports.registerRoutes({}, {
+ mediaDir: '/configured/signage-media',
+ playerActionService: {}
+ });
+ assert.equal(pageDependencies.mediaDir, '/configured/signage-media');
+});
+
test('thumbnail payload passes saved region geometry and content to the popup renderer', () => {
const payload = buildThumbnailPreviewPayload({
template: {