Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion static/js/shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,46 @@ const fonts = require('../../fonts');

const fontFamily = tagAttribute({tags: fonts});

exports.collectContentPre = fontFamily.collectContentPre;
// Map a CSS font-family value (e.g. "Arial", "'Times New Roman'",
// "Helvetica Neue, sans-serif") back to one of the toolbar tag names
// (e.g. "fontarial", "fonttimes-new-roman"). The toolbar tag names
// are the prefix "font" plus the CSS family lower-cased and
// hyphen-spaced. Handles quoted values, the first font in a fallback
// list, and trailing CSS keywords like "monospace"/"sans-serif".
const FONT_NAMES = new Set(fonts);
const STYLE_FONT_FAMILY_RE = /(?:^|;|\s)font-family\s*:\s*([^;]+?)\s*(?:;|$)/i;

const normalizeCssFamily = (raw) => {
if (!raw) return null;
// Take the first family in a comma-separated fallback list.
const first = raw.split(',')[0].trim();
// Strip surrounding quotes.
const unquoted = first.replace(/^['"]|['"]$/g, '').trim();
if (!unquoted) return null;
// Normalize: lowercase, spaces → hyphens.
const normalized = unquoted.toLowerCase().replace(/\s+/g, '-');
const tagName = `font${normalized}`;
if (FONT_NAMES.has(tagName)) return tagName;
// Generic family keywords map to the closest available font.
if (normalized === 'monospace') return FONT_NAMES.has('fontmonospace') ? 'fontmonospace' : null;
return null;
};

const collectContentPreOrig = fontFamily.collectContentPre;
exports.collectContentPre = (hookName, context, cb) => {
collectContentPreOrig(hookName, context, () => {});
// ep_font_family's getLineHTMLForExport rewrites `<fontarial>` into
// `<span style="font-family:arial">` -- the standard CSS form. The
// tagAttribute factory only reads the original tag names, so any
// round-trip through HTML/DOCX would silently lose the font.
// Read the CSS form too and apply the matching tag attribute.
if (context.styl) {
const m = STYLE_FONT_FAMILY_RE.exec(context.styl);
if (m) {
const tag = normalizeCssFamily(m[1]);
if (tag) context.cc.doAttrib(context.state, tag);
}
}
if (cb) return cb();
};
exports.collectContentPost = fontFamily.collectContentPost;
61 changes: 61 additions & 0 deletions static/tests/backend/specs/exportHTML.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
'use strict';

import {init, generateJWTToken} from 'ep_etherpad-lite/tests/backend/common';
import {randomString} from 'ep_etherpad-lite/static/js/pad_utils';

let agent: any;
const apiVersion = 1;

const createPad = async (padID: string): Promise<string> => {
const res = await agent.get(`/api/${apiVersion}/createPad?padID=${padID}`)
.set('Authorization', await generateJWTToken());
if (res.body.code !== 0) throw new Error('Unable to create new Pad');
return padID;
};

const setHTML = async (padID: string, html: string): Promise<string> => {
const res = await agent.get(
`/api/${apiVersion}/setHTML?padID=${padID}&html=${encodeURIComponent(html)}`)
.set('Authorization', await generateJWTToken());
if (res.body.code !== 0) throw new Error('Unable to set pad HTML');
return padID;
};

const getHTMLEndPointFor = (padID: string) =>
`/api/${apiVersion}/getHTML?padID=${padID}`;

const buildHTML = (body: string) => `<html><body>${body}</body></html>`;

describe('ep_font_family — round-trip via inline style="font-family"', function () {
// External HTML (Word/DOCX via mammoth, pasted markup) uses the
// standard CSS form, not the tag form ep_font_family reads on import.
// Without the import-side style reader, font is dropped.
before(async function () { agent = await init(); });

const cases: Array<[string, string]> = [
['arial', 'Arial'],
['times-new-roman', "'Times New Roman'"],
['courier', 'courier'],
];

for (const [tag, cssValue] of cases) {
it(`preserves font-family:${cssValue} through round-trip`, async function () {
const padID = randomString(5);
await createPad(padID);
await setHTML(padID,
buildHTML(`<p>before <span style="font-family:${cssValue}">styled</span> after</p>`));
const res = await agent.get(getHTMLEndPointFor(padID))
.set('Authorization', await generateJWTToken());
const out: string = res.body.data.html;
// Re-export should contain a `font-family:<tag-without-font>` style
// (or the explicit tag form, depending on what
// getLineHTMLForExport produces).
const tagInner = tag;
const re = new RegExp(`font-family:${tagInner}|<font${tagInner}\\b`, 'i');
if (!re.test(out)) {
throw new Error(
`Font ${tag} not preserved on style-import round-trip. Got: ${out}`);
}
});
}
});
Loading