Skip to content

Commit 70c854b

Browse files
fix(desktop): stop expanding $ replacement patterns in export titles
injectTitle() in pdf-export.ts and artifact-export.ts passed the user-derived <title> tag as the string replacement argument of String.replace(), so GetSubstitution expanded $$, $&, $`, $' sequences from the artifact title -- dropping characters or splicing document HTML into the title. Use a function replacement like the sibling injectBaseHref already does, so the tag is inserted literally.
1 parent 028bde5 commit 70c854b

3 files changed

Lines changed: 148 additions & 2 deletions

File tree

apps/desktop/src/main/artifact-export.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,9 @@ function injectBaseHref(doc: string, baseHref: string | undefined): string {
114114

115115
function injectTitle(doc: string, title: string): string {
116116
const tag = `<title>${escapeText(title)}</title>`;
117-
if (/<title[^>]*>.*?<\/title>/is.test(doc)) return doc.replace(/<title[^>]*>.*?<\/title>/is, tag);
117+
// Function replacement: a string replacement would expand the title's
118+
// $$, $&, $`, $' substitution patterns, corrupting it (issue #6795).
119+
if (/<title[^>]*>.*?<\/title>/is.test(doc)) return doc.replace(/<title[^>]*>.*?<\/title>/is, () => tag);
118120
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (m) => `${m}${tag}`);
119121
return doc;
120122
}

apps/desktop/src/main/pdf-export.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,9 @@ function injectBaseHref(doc: string, baseHref: string | undefined): string {
266266

267267
function injectTitle(doc: string, title: string): string {
268268
const tag = `<title>${escapeHtmlText(title)}</title>`;
269-
if (/<title[^>]*>.*?<\/title>/is.test(doc)) return doc.replace(/<title[^>]*>.*?<\/title>/is, tag);
269+
// Function replacement: a string replacement would expand the title's
270+
// $$, $&, $`, $' substitution patterns, corrupting it (issue #6795).
271+
if (/<title[^>]*>.*?<\/title>/is.test(doc)) return doc.replace(/<title[^>]*>.*?<\/title>/is, () => tag);
270272
if (/<head[^>]*>/i.test(doc)) return doc.replace(/<head[^>]*>/i, (match) => `${match}${tag}`);
271273
if (/<html[^>]*>/i.test(doc)) return doc.replace(/<html[^>]*>/i, (match) => `${match}<head>${tag}</head>`);
272274
return `<!doctype html><html><head>${tag}</head><body>${doc}</body></html>`;
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Issue #6795 — injectTitle() in both export paths passed the user-derived
2+
// `<title>` tag as the *string* replacement argument of String.replace(), so
3+
// ECMA-262 GetSubstitution expanded `$$`, `$&`, `` $` ``, `$'` sequences from
4+
// the artifact title: `Save $$$ This Quarter` lost a `$`, `$&`/`$'` spliced
5+
// the matched tag or the whole document tail into the title (leaking visible
6+
// text into the exported PDF/image). The sibling injectBaseHref/injectStyle
7+
// helpers already used function replacements and were unaffected.
8+
//
9+
// These tests pin the "replace an existing <title>" branch — the branch that
10+
// runs for virtually every generated artifact — by capturing the document each
11+
// exporter loads into its hidden render window and asserting the title landed
12+
// verbatim (HTML-escaped only) with no duplicated document content.
13+
14+
import { mkdtemp, rm } from 'node:fs/promises';
15+
import { tmpdir } from 'node:os';
16+
import { dirname, join } from 'node:path';
17+
18+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
19+
20+
const rendererState = vi.hoisted(() => ({
21+
loadedUrls: [] as string[],
22+
savePath: '' as string,
23+
}));
24+
25+
vi.mock('electron', () => {
26+
const image = {
27+
getSize: () => ({ height: 1, width: 1 }),
28+
toBitmap: () => Buffer.alloc(4),
29+
toJPEG: () => Buffer.from('jpeg'),
30+
toPNG: () => Buffer.from('png'),
31+
};
32+
33+
class BrowserWindow {
34+
readonly webContents = {
35+
capturePage: async () => image,
36+
executeJavaScript: async (source: string): Promise<unknown> => {
37+
if (source.includes('document.documentElement.scrollHeight')) return 1;
38+
return true;
39+
},
40+
on: () => undefined,
41+
printToPDF: async () => Buffer.from('pdf'),
42+
setWindowOpenHandler: () => undefined,
43+
};
44+
45+
async loadURL(url: string): Promise<void> {
46+
rendererState.loadedUrls.push(url);
47+
}
48+
49+
destroy(): void {}
50+
getContentSize(): [number, number] { return [1440, 900]; }
51+
isDestroyed(): boolean { return false; }
52+
setContentSize(): void {}
53+
}
54+
55+
return {
56+
BrowserWindow,
57+
dialog: {
58+
showSaveDialog: async () => ({ canceled: false, filePath: rendererState.savePath }),
59+
},
60+
};
61+
});
62+
63+
import { exportArtifact } from '../../src/main/artifact-export.js';
64+
import { exportPdfFromHtml } from '../../src/main/pdf-export.js';
65+
66+
const sourceHtml =
67+
'<!doctype html><html><head><title>Old</title></head><body><p>BODY MARKER</p></body></html>';
68+
69+
// One title per GetSubstitution pattern a string replacement would expand.
70+
// `expectedTitleTag` is the verbatim, HTML-escaped-only insertion the escape
71+
// helpers around injectTitle clearly intend.
72+
const titles = [
73+
{ expectedTitleTag: '<title>Save $$$ This Quarter</title>', title: 'Save $$$ This Quarter' },
74+
{ expectedTitleTag: '<title>Before $&amp; After</title>', title: 'Before $& After' },
75+
{ expectedTitleTag: "<title>Rock $'n Roll Tour</title>", title: "Rock $'n Roll Tour" },
76+
];
77+
78+
let workDir: string;
79+
80+
beforeEach(async () => {
81+
workDir = await mkdtemp(join(tmpdir(), 'od-title-patterns-'));
82+
rendererState.savePath = join(workDir, 'out.pdf');
83+
});
84+
85+
afterEach(async () => {
86+
rendererState.loadedUrls.length = 0;
87+
await rm(workDir, { force: true, recursive: true });
88+
});
89+
90+
describe('export titles containing replacement patterns', () => {
91+
it.each(titles)(
92+
'exportPdfFromHtml renders the title $title verbatim',
93+
async ({ expectedTitleTag, title }) => {
94+
const result = await exportPdfFromHtml({
95+
deck: false,
96+
defaultFilename: 'artifact.pdf',
97+
html: sourceHtml,
98+
title,
99+
});
100+
101+
expect(result.ok).toBe(true);
102+
expect(loadedDocument()).toContain(expectedTitleTag);
103+
expect(countBodyMarkers(loadedDocument())).toBe(1);
104+
},
105+
);
106+
107+
it.each(titles)(
108+
'exportArtifact renders the title $title verbatim',
109+
async ({ expectedTitleTag, title }) => {
110+
const result = await exportArtifact({
111+
deck: false,
112+
format: 'image',
113+
html: sourceHtml,
114+
imageFormat: 'png',
115+
title,
116+
});
117+
118+
try {
119+
expect(result.ok).toBe(true);
120+
expect(loadedDocument()).toContain(expectedTitleTag);
121+
expect(countBodyMarkers(loadedDocument())).toBe(1);
122+
} finally {
123+
if (result.path) await rm(dirname(result.path), { force: true, recursive: true });
124+
}
125+
},
126+
);
127+
});
128+
129+
function loadedDocument(): string {
130+
expect(rendererState.loadedUrls).toHaveLength(1);
131+
const url = rendererState.loadedUrls[0];
132+
if (!url) throw new Error('renderer did not load a document');
133+
const prefix = 'data:text/html;charset=utf-8,';
134+
expect(url.startsWith(prefix)).toBe(true);
135+
return decodeURIComponent(url.slice(prefix.length));
136+
}
137+
138+
// A `$'`/`$&` expansion splices document content into the <title>, so the
139+
// corrupted output carries the body text more than once.
140+
function countBodyMarkers(doc: string): number {
141+
return doc.split('BODY MARKER').length - 1;
142+
}

0 commit comments

Comments
 (0)