/**
* @jest-environment node
*
* OdtStyling.js tests: page-size patching and header/footer injection into a
* Pandoc-style ODT package. Fixtures are minimal zips built with PizZip,
* mirroring the structure Pandoc's ODT writer emits (styles.xml with a "pm1"
* page layout and a "Standard" master page).
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const PizZip = require('pizzip');
const {
setOdtPageSize,
addHeaderFooterToOdt,
parseDimensionsMm,
escapeOdtText,
} = require('../../src/main/OdtStyling');
// Minimal Pandoc-like styles.xml: page layout pm1 + Standard master page
const STYLES_XML = `
`;
/**
* Write a minimal .odt fixture containing just styles.xml.
* @param {string} filePath destination
* @param {string} [stylesXml] styles.xml content (defaults to STYLES_XML)
*/
function writeOdtFixture(filePath, stylesXml = STYLES_XML) {
const zip = new PizZip();
zip.file('mimetype', 'application/vnd.oasis.opendocument.text');
zip.file('styles.xml', stylesXml);
zip.file('content.xml', '');
fs.writeFileSync(filePath, zip.generate({ type: 'nodebuffer' }));
}
/** Read styles.xml back out of a written fixture. */
function readStylesXml(filePath) {
return new PizZip(fs.readFileSync(filePath)).file('styles.xml').asText();
}
describe('OdtStyling', () => {
let tmpDir, odtPath;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'odtstyle_'));
odtPath = path.join(tmpDir, 'doc.odt');
writeOdtFixture(odtPath);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('parseDimensionsMm', () => {
it('parses "210×297mm" (U+00D7 separator) into numbers', () => {
expect(parseDimensionsMm('210×297mm')).toEqual({ widthMm: 210, heightMm: 297 });
});
it('returns null for unparseable input', () => {
expect(parseDimensionsMm('8.5in')).toBeNull();
expect(parseDimensionsMm(undefined)).toBeNull();
});
});
describe('escapeOdtText', () => {
it('escapes XML-significant characters', () => {
expect(escapeOdtText('a&"c"')).toBe('a<b>&"c"');
});
});
describe('setOdtPageSize', () => {
it('rewrites width/height/orientation on the existing page layout (A4 portrait)', async () => {
await setOdtPageSize(odtPath, {
size: 'a4',
orientation: 'portrait',
pageSizes: { a4: { dimensions: '210×297mm' } },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('fo:page-width="210mm"');
expect(xml).toContain('fo:page-height="297mm"');
expect(xml).toContain('style:print-orientation="portrait"');
// Old inch-based attributes must be gone (no duplicate size attrs)
expect(xml).not.toContain('fo:page-width="8.267in"');
});
it('swaps dimensions for landscape', async () => {
await setOdtPageSize(odtPath, {
size: 'a4',
orientation: 'landscape',
pageSizes: { a4: { dimensions: '210×297mm' } },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('fo:page-width="297mm"');
expect(xml).toContain('fo:page-height="210mm"');
expect(xml).toContain('style:print-orientation="landscape"');
});
it('falls back to custom mm dimensions and A4 default', async () => {
await setOdtPageSize(odtPath, { customWidth: '148', customHeight: '210' });
expect(readStylesXml(odtPath)).toContain('fo:page-width="148mm"');
writeOdtFixture(odtPath);
await setOdtPageSize(odtPath, {});
expect(readStylesXml(odtPath)).toContain('fo:page-width="210mm"');
});
});
describe('addHeaderFooterToOdt', () => {
it('is a no-op when disabled', async () => {
await addHeaderFooterToOdt(odtPath, { enabled: false, header: { left: 'X' } });
expect(readStylesXml(odtPath)).not.toContain(' {
await addHeaderFooterToOdt(odtPath, {
enabled: true,
header: { left: 'My Doc', right: 'Draft ' },
footer: { center: 'Page $PAGE$ of $TOTAL$' },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('');
expect(xml).toContain('');
expect(xml).toContain('');
// The self-closing master page must have been expanded to hold children
expect(xml).toMatch(/]*>[\s\S]*<\/style:master-page>/);
});
it('does nothing when all regions are empty', async () => {
await addHeaderFooterToOdt(odtPath, {
enabled: true,
header: { left: '', center: '', right: '' },
footer: {},
});
expect(readStylesXml(odtPath)).toBe(STYLES_XML);
});
it('creates a master page when the package has none', async () => {
const bareXml = STYLES_XML.replace(
/ [\s\S]*<\/office:master-styles>/,
''
);
writeOdtFixture(odtPath, bareXml);
await addHeaderFooterToOdt(odtPath, {
enabled: true,
footer: { center: 'hi' },
});
const xml = readStylesXml(odtPath);
expect(xml).toContain('');
expect(xml).toContain('style:name="Standard"');
expect(xml).toContain('hi');
});
it('replaces existing header/footer content on re-export', async () => {
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'first' } });
await addHeaderFooterToOdt(odtPath, { enabled: true, header: { left: 'second' } });
const xml = readStylesXml(odtPath);
expect(xml).toContain('second');
expect(xml).not.toContain('first');
// Exactly one header element, not nested duplicates
expect(xml.match(//g)).toHaveLength(1);
});
});
});