diff --git a/src/core/index.ts b/src/core/index.ts index 4b83de4..18357d9 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -14,6 +14,7 @@ import { fetchSafeRemoteImage } from '../security/url-guard.js'; import matter from 'gray-matter'; import { injectMetadata } from '../pdf/metadata.js'; import { PluginRegistry } from '../plugins/registry.js'; +import { fillTitlePlaceholder } from '../utils/header-template.js'; import type { RenderContext } from '../types/context.js'; function sanitizeFrontmatterValue(val: unknown): string { @@ -488,7 +489,13 @@ export async function convert(options: ConvertOptions): Promise { }); title = (typeof options.title === 'string' ? options.title : false) || options.metadata?.title || frontmatter.title || (input === '-' ? 'Untitled Document' : path.basename(input, path.extname(input))); - + + // Chromium's header/footer only auto-fills .pageNumber/.totalPages/.date. + // The document placeholder must be filled manually, + // otherwise custom headers render an empty title. + const fillTitle = (tpl: string | undefined): string | undefined => + fillTitlePlaceholder(tpl, title); + let finalHtml = parsed.html; if (shouldAddTitle && !/]*>/i.test(finalHtml)) { const escapeHtml = (str: string) => str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); @@ -641,13 +648,15 @@ export async function convert(options: ConvertOptions): Promise { } } - const displayHeaderFooter = (headerEnabled && options.header !== undefined) || - (footerEnabled && options.footer !== undefined) || + const displayHeaderFooter = (headerEnabled && options.header !== undefined) || + (footerEnabled && options.footer !== undefined) || !!usePageNumbers || !!useDate || !!useTitle; if (displayHeaderFooter) { if (!headerTemplate) headerTemplate = ''; if (!footerTemplate) footerTemplate = ''; + headerTemplate = fillTitle(headerTemplate); + footerTemplate = fillTitle(footerTemplate); } // Inject base URL so Playwright resolves local images & assets correctly without page.goto diff --git a/src/utils/header-template.ts b/src/utils/header-template.ts new file mode 100644 index 0000000..1ba14cd --- /dev/null +++ b/src/utils/header-template.ts @@ -0,0 +1,29 @@ +/** + * Fill the `` placeholder inside a Chromium + * header/footer template with the resolved document title. + * + * Chromium's print header/footer only auto-fills `.pageNumber`, + * `.totalPages`, and `.date`. The document title is NOT injected, so a + * custom header that relies on a `` placeholder + * would otherwise render an empty title. We fill it manually here. + * + * The title is HTML-escaped so a hostile or accidental title cannot break + * the header markup (e.g. inject attributes or script). + */ +export function fillTitlePlaceholder( + template: string | undefined, + title: string +): string | undefined { + if (!template) return template; + const escapeHtml = (str: string) => + str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return template.replace( + /<\/span>/g, + `${escapeHtml(title)}` + ); +} diff --git a/tests/contract/header-title.test.ts b/tests/contract/header-title.test.ts new file mode 100644 index 0000000..9de1c8d --- /dev/null +++ b/tests/contract/header-title.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { fillTitlePlaceholder } from '../../src/utils/header-template.js'; + +describe('fillTitlePlaceholder (C#: header/footer title fill)', () => { + it('fills with the resolved document title', () => { + const tpl = '
'; + const out = fillTitlePlaceholder(tpl, 'My Frontmatter Title'); + expect(out).toContain('My Frontmatter Title'); + expect(out).not.toContain(''); + }); + + it('fills the title inside a styled header alongside page numbers', () => { + const tpl = + '
— /
'; + const out = fillTitlePlaceholder(tpl, 'Laporan Akhir'); + expect(out).toContain('Laporan Akhir'); + // untouched Chromium tokens remain intact + expect(out).toContain(''); + expect(out).toContain(''); + }); + + it('works on footer templates too', () => { + const tpl = '
'; + const out = fillTitlePlaceholder(tpl, 'Bab 3'); + expect(out).toContain('Bab 3'); + }); + + it('HTML-escapes the title to prevent markup injection', () => { + const tpl = '
'; + const out = fillTitlePlaceholder(tpl, ''); + expect(out).toContain('<img'); + expect(out).not.toContain(' { + expect(fillTitlePlaceholder(undefined, 'Any Title')).toBeUndefined(); + }); + + it('returns the template unchanged when it has no title placeholder', () => { + const tpl = '
'; + expect(fillTitlePlaceholder(tpl, 'Ignored')).toBe(tpl); + }); +});