@procedures/pdf 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +67 -2
  4. package/dist/.tsbuildinfo +1 -0
  5. package/dist/browser.d.ts +34 -0
  6. package/dist/browser.d.ts.map +1 -0
  7. package/dist/browser.js +81 -0
  8. package/dist/browser.js.map +1 -0
  9. package/dist/index.d.ts +6 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +7 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/module.d.ts +16 -0
  14. package/dist/module.d.ts.map +1 -0
  15. package/dist/module.js +34 -0
  16. package/dist/module.js.map +1 -0
  17. package/dist/procedures/pdf/index.d.ts +2 -0
  18. package/dist/procedures/pdf/index.d.ts.map +1 -0
  19. package/dist/procedures/pdf/index.js +2 -0
  20. package/dist/procedures/pdf/index.js.map +1 -0
  21. package/dist/procedures/pdf/render.d.ts +16 -0
  22. package/dist/procedures/pdf/render.d.ts.map +1 -0
  23. package/dist/procedures/pdf/render.js +25 -0
  24. package/dist/procedures/pdf/render.js.map +1 -0
  25. package/dist/register.d.ts +6 -0
  26. package/dist/register.d.ts.map +1 -0
  27. package/dist/register.js +8 -0
  28. package/dist/register.js.map +1 -0
  29. package/dist/render.d.ts +52 -0
  30. package/dist/render.d.ts.map +1 -0
  31. package/dist/render.js +121 -0
  32. package/dist/render.js.map +1 -0
  33. package/dist/types.d.ts +22 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +20 -0
  36. package/dist/types.js.map +1 -0
  37. package/package.json +78 -3
  38. package/src/browser.ts +96 -0
  39. package/src/index.ts +6 -0
  40. package/src/module.ts +48 -0
  41. package/src/procedures/pdf/index.ts +1 -0
  42. package/src/procedures/pdf/render.ts +31 -0
  43. package/src/register.ts +10 -0
  44. package/src/render.ts +151 -0
  45. package/src/types.ts +43 -0
package/src/browser.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Find a Chromium browser that is already installed: Microsoft Edge, Google Chrome or Chromium.
3
+ * The package does not download a browser.
4
+ */
5
+
6
+ import { existsSync } from "node:fs";
7
+ import { posix, win32 } from "node:path";
8
+
9
+ /** The environment variable that names the browser program. It comes before the search. */
10
+ export const BROWSER_ENV = "PDF_BROWSER";
11
+
12
+ /** The second environment variable for the browser program. Other tools use this name too. */
13
+ export const CHROME_PATH_ENV = "CHROME_PATH";
14
+
15
+ /** The program names that the search looks for on the `PATH`, in order. */
16
+ export const BROWSER_NAMES: readonly string[] = [
17
+ "google-chrome",
18
+ "google-chrome-stable",
19
+ "chromium",
20
+ "chromium-browser",
21
+ "microsoft-edge",
22
+ "microsoft-edge-stable",
23
+ ];
24
+
25
+ /** The program names on the `PATH` of Windows, in order. */
26
+ export const WINDOWS_BROWSER_NAMES: readonly string[] = ["msedge.exe", "chrome.exe"];
27
+
28
+ /** The options of `findBrowser`. Each one has a default from the host. */
29
+ export interface FindBrowserOptions {
30
+ /** The browser program. When it is set, the search does not occur. */
31
+ readonly executable?: string | undefined;
32
+ /** The environment (default: `process.env`). */
33
+ readonly env?: Readonly<Record<string, string | undefined>> | undefined;
34
+ /** The operating system (default: `process.platform`). */
35
+ readonly platform?: NodeJS.Platform | undefined;
36
+ /** The function that tells if a file exists (default: `existsSync`). */
37
+ readonly exists?: ((path: string) => boolean) | undefined;
38
+ }
39
+
40
+ /** This function gives the folders of the `PATH`, in order. */
41
+ function pathFolders(env: Readonly<Record<string, string | undefined>>, windows: boolean): string[] {
42
+ const value = windows ? (env["Path"] ?? env["PATH"]) : env["PATH"];
43
+ if (value === undefined) return [];
44
+ return value.split(windows ? ";" : ":").filter((folder) => folder.length > 0);
45
+ }
46
+
47
+ /**
48
+ * This function gives the paths that the search examines on an operating system, in order. The
49
+ * install folders come first, then the folders of the `PATH`.
50
+ */
51
+ export function browserCandidates(platform: NodeJS.Platform, env: Readonly<Record<string, string | undefined>>): string[] {
52
+ if (platform === "win32") {
53
+ const programFiles = env["ProgramFiles"] ?? env["PROGRAMFILES"] ?? "C:\\Program Files";
54
+ const programFilesX86 = env["ProgramFiles(x86)"] ?? env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
55
+ const localAppData = env["LOCALAPPDATA"];
56
+ const installed = [
57
+ win32.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
58
+ win32.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
59
+ win32.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
60
+ win32.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
61
+ ...(localAppData !== undefined
62
+ ? [win32.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"), win32.join(localAppData, "Chromium", "Application", "chrome.exe")]
63
+ : []),
64
+ ];
65
+ const onPath = pathFolders(env, true).flatMap((folder) => WINDOWS_BROWSER_NAMES.map((name) => win32.join(folder, name)));
66
+ return [...installed, ...onPath];
67
+ }
68
+ const installed: string[] = [];
69
+ if (platform === "darwin") {
70
+ const bundles = [
71
+ "Google Chrome.app/Contents/MacOS/Google Chrome",
72
+ "Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
73
+ "Chromium.app/Contents/MacOS/Chromium",
74
+ ];
75
+ const home = env["HOME"];
76
+ installed.push(...bundles.map((bundle) => posix.join("/Applications", bundle)));
77
+ if (home !== undefined) installed.push(...bundles.map((bundle) => posix.join(home, "Applications", bundle)));
78
+ }
79
+ const onPath = pathFolders(env, false).flatMap((folder) => BROWSER_NAMES.map((name) => posix.join(folder, name)));
80
+ return [...installed, ...onPath];
81
+ }
82
+
83
+ /**
84
+ * This function finds the browser program. The order is `executable`, then `PDF_BROWSER`, then
85
+ * `CHROME_PATH`, then the install folders and the `PATH`. It gives `undefined` when it finds none.
86
+ */
87
+ export function findBrowser(options: FindBrowserOptions = {}): string | undefined {
88
+ if (options.executable !== undefined && options.executable.length > 0) return options.executable;
89
+ const env = options.env ?? process.env;
90
+ for (const name of [BROWSER_ENV, CHROME_PATH_ENV]) {
91
+ const value = env[name];
92
+ if (value !== undefined && value.length > 0) return value;
93
+ }
94
+ const exists = options.exists ?? existsSync;
95
+ return browserCandidates(options.platform ?? process.platform, env).find((candidate) => exists(candidate));
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ // The root registers nothing: import "<package>/register", or give the module to createRegistry()
2
+ export * from "./module.js";
3
+ export * from "./types.js";
4
+ export * from "./browser.js";
5
+ export * from "./render.js";
6
+ export { pdfRender } from "./procedures/pdf/index.js";
package/src/module.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The procedures of @procedures/pdf as data. An import of this file registers nothing.
3
+ *
4
+ * Scaffold a procedure with: mark procedure new <name> --path packages/pdf
5
+ */
6
+
7
+ import {
8
+ defineModule,
9
+ registerModules,
10
+ PROCEDURE_REGISTRY,
11
+ type AnyProcedure,
12
+ type ProcedureModule,
13
+ type ProcedureRegistry,
14
+ createProcedure,
15
+ zodAdapter,
16
+ } from "@procedures/client";
17
+ import { pdfRender } from "./procedures/pdf/render.js";
18
+ import { PdfRenderInputSchema, PdfRenderOutputSchema, type PdfRenderInput, type PdfRenderOutput } from "./types.js";
19
+
20
+ export const pdfRenderProcedure: AnyProcedure = createProcedure()
21
+ .path(["pdf", "render"])
22
+ .input(zodAdapter<PdfRenderInput>(PdfRenderInputSchema))
23
+ .output(zodAdapter<PdfRenderOutput>(PdfRenderOutputSchema))
24
+ .meta({
25
+ description: "Render HTML to a PDF file with a headless Chromium browser",
26
+ args: [],
27
+ shorts: { output: "o", timeout: "t" },
28
+ output: "json",
29
+ // The browser reads the page and the files and addresses that the page names. The caller can
30
+ // choose the browser program, so the procedure writes "process". It replaces the output file.
31
+ effects: { reads: ["fs", "network", "process"], writes: ["fs", "process"], destructive: true },
32
+ })
33
+ .handler(async (input: PdfRenderInput, ctx): Promise<PdfRenderOutput> => pdfRender(input, ctx))
34
+ .build() as AnyProcedure;
35
+
36
+ export const pdfProcedures: AnyProcedure[] = [pdfRenderProcedure];
37
+
38
+ export const pdfModule: ProcedureModule = defineModule("@procedures/pdf", pdfProcedures);
39
+
40
+ export default pdfModule;
41
+
42
+ /**
43
+ * This function registers the procedures of @procedures/pdf in a registry. The default is the
44
+ * global registry.
45
+ */
46
+ export function registerPdfProcedures(registry: ProcedureRegistry = PROCEDURE_REGISTRY): void {
47
+ registerModules(registry, [pdfModule]);
48
+ }
@@ -0,0 +1 @@
1
+ export { pdfRender } from "./render.js";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * pdf.render procedure
3
+ *
4
+ * This procedure prints an HTML page to a PDF file with a headless Chromium browser that is
5
+ * already installed.
6
+ */
7
+
8
+ import { renderPdf, type CommandRunner } from "../../render.js";
9
+ import type { PdfRenderInput, PdfRenderOutput } from "../../types.js";
10
+
11
+ /**
12
+ * This function prints the HTML text or the HTML file of the input to a PDF file. It gives the
13
+ * path and the size of the file.
14
+ */
15
+ export async function pdfRender(
16
+ input: PdfRenderInput,
17
+ ctx?: { signal?: AbortSignal | undefined },
18
+ runner?: CommandRunner
19
+ ): Promise<PdfRenderOutput> {
20
+ const result = await renderPdf({
21
+ html: input.html,
22
+ file: input.file,
23
+ output: input.output,
24
+ executable: input.executable,
25
+ args: input.args,
26
+ timeout: input.timeout,
27
+ signal: ctx?.signal,
28
+ runner,
29
+ });
30
+ return { path: result.path, bytes: result.bytes, browser: result.browser };
31
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * An import of this file registers the procedures of @procedures/pdf in the global registry:
3
+ * `import "@procedures/pdf/register"`. The package root registers nothing.
4
+ */
5
+
6
+ import { registerPdfProcedures } from "./module.js";
7
+
8
+ export * from "./module.js";
9
+
10
+ registerPdfProcedures();
package/src/render.ts ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * HTML to PDF with a headless Chromium browser that is already installed.
3
+ *
4
+ * The browser starts with argv, not through a shell, through `runCommand` of
5
+ * `@procedures/shell`. A time limit and the signal of the call stop its process tree.
6
+ */
7
+
8
+ import { closeSync, mkdirSync, mkdtempSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+ import { runCommand, type CommandOptions, type CommandResult } from "@procedures/shell/command";
13
+ import { BROWSER_ENV, findBrowser, type FindBrowserOptions } from "./browser.js";
14
+
15
+ /** The default time limit of one render, in milliseconds. */
16
+ export const DEFAULT_TIMEOUT_MS = 60_000;
17
+
18
+ /** The function that runs a program. The default is `runCommand` of `@procedures/shell`. */
19
+ export type CommandRunner = (command: string, options: CommandOptions) => Promise<CommandResult>;
20
+
21
+ /** The options of `renderPdf`. Give `html` or `file`, not the two. */
22
+ export interface RenderPdfOptions extends FindBrowserOptions {
23
+ /** The HTML text. The function writes it to a temporary file. */
24
+ readonly html?: string | undefined;
25
+ /** The path of an HTML file. */
26
+ readonly file?: string | undefined;
27
+ /** The path of the PDF file. The default is a file in a new temporary folder. */
28
+ readonly output?: string | undefined;
29
+ /** More flags for the browser, after the default flags. */
30
+ readonly args?: readonly string[] | undefined;
31
+ /** The time limit, in milliseconds (default: 60000). */
32
+ readonly timeout?: number | undefined;
33
+ /** When the signal aborts, the browser stops. */
34
+ readonly signal?: AbortSignal | undefined;
35
+ /** The function that runs the browser (default: `runCommand`). The tests give their own. */
36
+ readonly runner?: CommandRunner | undefined;
37
+ }
38
+
39
+ /** The result of `renderPdf`. */
40
+ export interface RenderPdfResult {
41
+ /** The absolute path of the PDF file. */
42
+ readonly path: string;
43
+ /** The size of the PDF file, in bytes. */
44
+ readonly bytes: number;
45
+ /** The browser program that made the file. */
46
+ readonly browser: string;
47
+ }
48
+
49
+ /**
50
+ * This function gives the argv of the browser. The argv has the flags of a headless print, the
51
+ * profile folder, the extra flags and the address of the page.
52
+ */
53
+ export function pdfArgs(page: string, output: string, profile: string, extra: readonly string[] = []): string[] {
54
+ return [
55
+ "--headless",
56
+ "--disable-gpu",
57
+ "--no-pdf-header-footer",
58
+ // A separate profile keeps the print apart from a browser that the user has open
59
+ `--user-data-dir=${profile}`,
60
+ `--print-to-pdf=${output}`,
61
+ ...extra,
62
+ page,
63
+ ];
64
+ }
65
+
66
+ /** This function tells if a file starts with the signature of a PDF file (`%PDF`). */
67
+ export function isPdfFile(path: string): boolean {
68
+ const fd = openSync(path, "r");
69
+ try {
70
+ const head = Buffer.alloc(4);
71
+ const read = readSync(fd, head, 0, 4, 0);
72
+ return read === 4 && head.toString("latin1") === "%PDF";
73
+ } finally {
74
+ closeSync(fd);
75
+ }
76
+ }
77
+
78
+ function fileSize(path: string): number | undefined {
79
+ try {
80
+ return statSync(path).size;
81
+ } catch {
82
+ return undefined;
83
+ }
84
+ }
85
+
86
+ function lastLines(text: string, count = 5): string {
87
+ return text.trim().split(/\r?\n/).slice(-count).join("\n");
88
+ }
89
+
90
+ /**
91
+ * This function prints an HTML page to a PDF file with a headless browser. It gives the path and
92
+ * the size of the file. It throws when it finds no browser, at the time limit, and when the file
93
+ * is not a PDF file.
94
+ */
95
+ export async function renderPdf(options: RenderPdfOptions): Promise<RenderPdfResult> {
96
+ const hasHtml = options.html !== undefined;
97
+ const hasFile = options.file !== undefined;
98
+ if (hasHtml === hasFile) throw new Error("renderPdf needs html or file, and not the two");
99
+ const browser = findBrowser(options);
100
+ if (browser === undefined) {
101
+ throw new Error(`No browser found. Install Microsoft Edge, Google Chrome or Chromium, or set ${BROWSER_ENV} to the browser program.`);
102
+ }
103
+
104
+ const work = mkdtempSync(join(tmpdir(), "procedures-pdf-"));
105
+ const profile = join(work, "profile");
106
+ const output = resolve(options.output ?? join(work, "page.pdf"));
107
+ let made = false;
108
+ try {
109
+ let page: string;
110
+ if (options.html !== undefined) {
111
+ const htmlFile = join(work, "page.html");
112
+ writeFileSync(htmlFile, options.html, "utf8");
113
+ page = pathToFileURL(htmlFile).href;
114
+ } else {
115
+ page = pathToFileURL(resolve(options.file!)).href;
116
+ }
117
+ mkdirSync(dirname(output), { recursive: true });
118
+ // An old file must not pass the check of the new one
119
+ rmSync(output, { force: true });
120
+
121
+ const run = options.runner ?? runCommand;
122
+ const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
123
+ const result = await run(browser, {
124
+ args: pdfArgs(page, output, profile, options.args),
125
+ timeout,
126
+ signal: options.signal,
127
+ maxOutputBytes: 64 * 1024,
128
+ keep: "tail",
129
+ });
130
+ if (result.error !== undefined) throw new Error(`The browser did not start: ${result.error}`);
131
+ if (result.timedOut === true) throw new Error(`The browser did not finish in ${timeout} ms`);
132
+ if (result.aborted === true) throw new Error("The render was cancelled");
133
+
134
+ const bytes = fileSize(output);
135
+ if (bytes === undefined || bytes === 0) {
136
+ const detail = lastLines(result.stderr || result.stdout);
137
+ throw new Error(`The browser made no PDF file (exit code ${result.exitCode})${detail ? `: ${detail}` : ""}`);
138
+ }
139
+ if (!isPdfFile(output)) throw new Error(`The file ${output} is not a PDF file`);
140
+ made = true;
141
+ return { path: output, bytes, browser };
142
+ } finally {
143
+ // The temporary page and profile go. A default output stays in its folder after a success.
144
+ if (made && options.output === undefined) {
145
+ rmSync(profile, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
146
+ rmSync(join(work, "page.html"), { force: true });
147
+ } else {
148
+ rmSync(work, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
149
+ }
150
+ }
151
+ }
package/src/types.ts ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The input and the output of the procedures of this package.
3
+ */
4
+
5
+ import { z } from "zod";
6
+
7
+ // =============================================================================
8
+ // pdf.render: HTML to a PDF file with a headless Chromium browser
9
+ // =============================================================================
10
+
11
+ /** The input of `pdf.render`. Give `html` or `file`, not the two. */
12
+ export interface PdfRenderInput {
13
+ html?: string | undefined;
14
+ file?: string | undefined;
15
+ output?: string | undefined;
16
+ executable?: string | undefined;
17
+ args?: string[] | undefined;
18
+ timeout?: number | undefined;
19
+ }
20
+
21
+ export const PdfRenderInputSchema: z.ZodType<PdfRenderInput> = z
22
+ .object({
23
+ html: z.string().optional().describe("The HTML text of the page"),
24
+ file: z.string().min(1).optional().describe("The path of an HTML file"),
25
+ output: z.string().min(1).optional().describe("The path of the PDF file (default: a file in a new temporary folder)"),
26
+ executable: z.string().min(1).optional().describe("The browser program (default: PDF_BROWSER, CHROME_PATH, then a search)"),
27
+ args: z.array(z.string()).optional().describe("More flags for the browser, for example --no-sandbox"),
28
+ timeout: z.number().int().positive().optional().describe("The time limit, in milliseconds (default: 60000)"),
29
+ })
30
+ .refine((input) => (input.html !== undefined) !== (input.file !== undefined), { message: "Give html or file, and not the two" });
31
+
32
+ /** The output of `pdf.render`. */
33
+ export interface PdfRenderOutput {
34
+ path: string;
35
+ bytes: number;
36
+ browser: string;
37
+ }
38
+
39
+ export const PdfRenderOutputSchema: z.ZodType<PdfRenderOutput> = z.object({
40
+ path: z.string().describe("The absolute path of the PDF file"),
41
+ bytes: z.number().describe("The size of the PDF file, in bytes"),
42
+ browser: z.string().describe("The browser program that made the file"),
43
+ });