Skip to content
Closed
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
15 changes: 12 additions & 3 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -488,7 +489,13 @@ export async function convert(options: ConvertOptions): Promise<ConvertResult> {
});

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 <span class="title"> 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 && !/<h1\b[^>]*>/i.test(finalHtml)) {
const escapeHtml = (str: string) => str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
Expand Down Expand Up @@ -641,13 +648,15 @@ export async function convert(options: ConvertOptions): Promise<ConvertResult> {
}
}

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 = '<span></span>';
if (!footerTemplate) footerTemplate = '<span></span>';
headerTemplate = fillTitle(headerTemplate);
footerTemplate = fillTitle(footerTemplate);
}

// Inject base URL so Playwright resolves local images & assets correctly without page.goto
Expand Down
29 changes: 29 additions & 0 deletions src/utils/header-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Fill the `<span class="title"></span>` 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 `<span class="title"></span>` 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
return template.replace(
/<span class="title"><\/span>/g,
`<span class="title">${escapeHtml(title)}</span>`
);
}
43 changes: 43 additions & 0 deletions tests/contract/header-title.test.ts
Original file line number Diff line number Diff line change
@@ -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 <span class="title"></span> with the resolved document title', () => {
const tpl = '<div style="text-align:right;"><span class="title"></span></div>';
const out = fillTitlePlaceholder(tpl, 'My Frontmatter Title');
expect(out).toContain('<span class="title">My Frontmatter Title</span>');
expect(out).not.toContain('<span class="title"></span>');
});

it('fills the title inside a styled header alongside page numbers', () => {
const tpl =
'<div><span class="title"></span> — <span class="pageNumber"></span>/<span class="totalPages"></span></div>';
const out = fillTitlePlaceholder(tpl, 'Laporan Akhir');
expect(out).toContain('<span class="title">Laporan Akhir</span>');
// untouched Chromium tokens remain intact
expect(out).toContain('<span class="pageNumber"></span>');
expect(out).toContain('<span class="totalPages"></span>');
});

it('works on footer templates too', () => {
const tpl = '<div><span class="title"></span></div>';
const out = fillTitlePlaceholder(tpl, 'Bab 3');
expect(out).toContain('<span class="title">Bab 3</span>');
});

it('HTML-escapes the title to prevent markup injection', () => {
const tpl = '<div><span class="title"></span></div>';
const out = fillTitlePlaceholder(tpl, '<img src=x onerror=alert(1)>');
expect(out).toContain('&lt;img');
expect(out).not.toContain('<img src=x');
});

it('returns undefined when the template is undefined', () => {
expect(fillTitlePlaceholder(undefined, 'Any Title')).toBeUndefined();
});

it('returns the template unchanged when it has no title placeholder', () => {
const tpl = '<div><span class="pageNumber"></span></div>';
expect(fillTitlePlaceholder(tpl, 'Ignored')).toBe(tpl);
});
});
Loading