1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56import { basename, extname } from "node:path";
// Output filename helpers. Adapted from PR #10 (filename preservation, by
// @Alcray); the fillable-PDF portion of that PR is intentionally not included.
/** Strip the extension and sanitize a name for safe use as a filename. */
export function sanitizeBasename(name: string): string {
const stem = basename(name, extname(name));
return stem.replace(/[^A-Za-z0-9._-]/g, "_") || "document";
}
/** Derive the output basename from uploaded files (first file wins). */
export function outputBasenameFromUploads(files: { originalname: string }[]): string {
if (files.length === 0) return "document";
return sanitizeBasename(files[0].originalname);
}
/** The downloadable HTML filename for a given source basename. */
export function convertedHtmlFilename(base: string): string {
return `${base}_converted.html`;
}
// The delivered document's title mirrors the uploaded file's name, so what a screen reader
// announces on arrival โ and what a browser puts in the tab and in a bookmark โ is the document
// the reader asked for rather than the shell's placeholder (WCAG 2.4.2).
//
// The element's attributes are kept, except for the one this function invalidates. A pattern that
// matched only a bare `<title>` silently did nothing on a document whose title carries `lang="en"`,
// which is how the shell labels it when the document's own language is not English (#163) โ so
// exactly the documents that had just been given a truthful root language were the ones delivered
// with no name at all.
//
// And `lang` is dropped as the text is replaced, because the shell's claim was about the shell's own
// string. `lang="en"` vouched for "Accessible document"; the name of an uploaded file is in whatever
// language the person who named it used, and on a Korean document it is usually Korean โ so keeping
// the label would assert English over a Korean title in the one place a reader hears the document's
// name. Dropping it leaves the title inheriting the root, which is the same policy the root itself
// follows: fall back to the containing default rather than assert a language nobody can vouch for.
// One attribute at a time, matched from its leading whitespace so that each match begins where the
// last one ended and the scan can never land inside a value. Searching the attribute string for
// ` lang=` instead would find the string in another attribute's value and edit that โ dropping two
// words out of a `data-note` while leaving the claim it meant to drop in place.
const ATTRIBUTE = /\s+([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*))?/g;
function withoutLang(attrs: string): string {
return attrs.replace(ATTRIBUTE, (whole, name: string) => (name.toLowerCase() === "lang" ? "" : whole));
}
export function titledAs(html: string, base: string): string {
return html.replace(
/<title([^>]*)>[^<]*<\/title>/,
// A function rather than a replacement string: `$&` in a filename is a filename, not a
// backreference, and `base` is user input.
(_m, attrs: string) =>
`<title${withoutLang(attrs)}>${base.replace(/&/g, "&").replace(/</g, "<")}</title>`,
);
}