@danielsimonjr/mathts-plot 0.2.0 → 0.3.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.
@@ -0,0 +1,42 @@
1
+ /** Thrown when an external tool is missing or a conversion fails. Deliberate
2
+ * exception to plot's never-throw rule: I/O and missing tools must surface. */
3
+ declare class PlotRenderError extends Error {
4
+ readonly missingTool?: string | undefined;
5
+ constructor(message: string, missingTool?: string | undefined);
6
+ }
7
+ interface RenderOptions {
8
+ tool?: string;
9
+ timeoutMs?: number;
10
+ density?: number;
11
+ background?: string;
12
+ /** Enable LaTeX \write18 shell-escape. UNSAFE for untrusted TeX — allows
13
+ * arbitrary command execution during compile. Default false. */
14
+ shellEscape?: boolean;
15
+ }
16
+ interface RunResult {
17
+ code: number | null;
18
+ stdout: Buffer;
19
+ stderr: string;
20
+ }
21
+ /** Spawn a command, feeding optional stdin, collecting stdout/stderr. Rejects
22
+ * with PlotRenderError(ENOENT) if the binary is not found. */
23
+ declare function runTool(cmd: string, args: string[], opts?: {
24
+ timeoutMs?: number;
25
+ input?: string;
26
+ }): Promise<RunResult>;
27
+ /** True if `name` responds to a version probe (i.e. is on PATH). */
28
+ declare function hasTool(name: string): Promise<boolean>;
29
+ /** SVG string → file. Extension of `outPath` selects the target: `.svg` writes
30
+ * the SVG through; `.png`/`.pdf` convert via rsvg-convert (preferred) or resvg.
31
+ * Rejects with PlotRenderError naming the tool to install if none is present. */
32
+ declare function renderToFile(svg: string, outPath: string, opts?: RenderOptions): Promise<void>;
33
+ /** Build the CLI args for a LaTeX engine. Shell-escape (\write18) is OFF unless
34
+ * `shellEscape` is true — enabling it is UNSAFE for untrusted TeX (arbitrary
35
+ * command execution during compile). */
36
+ declare function latexArgs(engine: string, workDir: string, texPath: string, shellEscape: boolean): string[];
37
+ /** Standalone LaTeX/TikZ source → PDF via pdflatex (preferred) or tectonic.
38
+ * Compiles in an OS temp dir and copies the resulting PDF to `outPath`.
39
+ * Rejects with PlotRenderError naming the engine to install if none is found. */
40
+ declare function latexToPdf(texSource: string, outPath: string, opts?: RenderOptions): Promise<void>;
41
+
42
+ export { PlotRenderError, type RenderOptions, hasTool, latexArgs, latexToPdf, renderToFile, runTool };
@@ -0,0 +1,155 @@
1
+ // src/render-file.ts
2
+ import { spawn } from "child_process";
3
+ import { writeFile, rm, rename, mkdtemp, readFile } from "fs/promises";
4
+ import { extname, join as joinPath } from "path";
5
+ import { tmpdir } from "os";
6
+ var PlotRenderError = class extends Error {
7
+ constructor(message, missingTool) {
8
+ super(message);
9
+ this.missingTool = missingTool;
10
+ this.name = "PlotRenderError";
11
+ }
12
+ missingTool;
13
+ };
14
+ function runTool(cmd, args, opts = {}) {
15
+ return new Promise((resolve, reject) => {
16
+ const child = spawn(cmd, args, { timeout: opts.timeoutMs ?? 3e4 });
17
+ const out = [];
18
+ let err = "";
19
+ child.stdout.on("data", (d) => out.push(d));
20
+ child.stderr.on("data", (d) => err += d.toString());
21
+ child.on(
22
+ "error",
23
+ (e) => reject(new PlotRenderError(`${cmd} could not be run: ${e.message}`, cmd))
24
+ );
25
+ child.on("close", (code) => resolve({ code, stdout: Buffer.concat(out), stderr: err }));
26
+ if (opts.input !== void 0) {
27
+ child.stdin.on("error", () => {
28
+ });
29
+ child.stdin.end(opts.input);
30
+ }
31
+ });
32
+ }
33
+ async function hasTool(name) {
34
+ try {
35
+ const r = await runTool(name, ["--version"], { timeoutMs: 5e3 });
36
+ return r.code === 0;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+ async function renderToFile(svg, outPath, opts = {}) {
42
+ const ext = extname(outPath).toLowerCase();
43
+ if (ext === ".svg") {
44
+ await writeFile(outPath, svg, "utf-8");
45
+ return;
46
+ }
47
+ if (ext !== ".png" && ext !== ".pdf") {
48
+ throw new PlotRenderError(
49
+ `Unsupported output extension '${ext}' (expected .svg, .png, or .pdf)`
50
+ );
51
+ }
52
+ const format = ext.slice(1);
53
+ const candidates = opts.tool ? [opts.tool] : ["rsvg-convert", "resvg"];
54
+ for (const tool of candidates) {
55
+ if (!await hasTool(tool)) continue;
56
+ const tmpOut = outPath + ".tmp";
57
+ if (tool === "rsvg-convert") {
58
+ const args = ["-f", format, "-o", tmpOut];
59
+ if (format === "png" && opts.density)
60
+ args.push("--dpi-x", String(opts.density), "--dpi-y", String(opts.density));
61
+ if (format === "png" && opts.background) args.push("--background-color", opts.background);
62
+ const r = await runTool("rsvg-convert", args, { timeoutMs: opts.timeoutMs, input: svg });
63
+ if (r.code !== 0) {
64
+ await rm(tmpOut, { force: true });
65
+ throw new PlotRenderError(`rsvg-convert failed: ${r.stderr}`);
66
+ }
67
+ await rename(tmpOut, outPath);
68
+ return;
69
+ }
70
+ const tmpSvg = outPath + ".tmp.svg";
71
+ await writeFile(tmpSvg, svg, "utf-8");
72
+ try {
73
+ const args = [tmpSvg, tmpOut];
74
+ const r = await runTool("resvg", args, { timeoutMs: opts.timeoutMs });
75
+ if (r.code !== 0) {
76
+ await rm(tmpOut, { force: true });
77
+ throw new PlotRenderError(`resvg failed: ${r.stderr}`);
78
+ }
79
+ } finally {
80
+ await rm(tmpSvg, { force: true });
81
+ }
82
+ if (format === "pdf") {
83
+ await rm(tmpOut, { force: true });
84
+ throw new PlotRenderError(
85
+ "resvg does not emit PDF; install rsvg-convert for SVG\u2192PDF",
86
+ "rsvg-convert"
87
+ );
88
+ }
89
+ await rename(tmpOut, outPath);
90
+ return;
91
+ }
92
+ throw new PlotRenderError(
93
+ `No SVG converter found for .${format}. Install rsvg-convert (librsvg) or resvg and ensure it is on PATH.`,
94
+ "rsvg-convert"
95
+ );
96
+ }
97
+ function latexArgs(engine, workDir, texPath, shellEscape) {
98
+ if (engine === "tectonic") {
99
+ return [...shellEscape ? ["-Z", "shell-escape"] : [], "--outdir", workDir, texPath];
100
+ }
101
+ return [
102
+ shellEscape ? "-shell-escape" : "-no-shell-escape",
103
+ "-interaction=nonstopmode",
104
+ "-halt-on-error",
105
+ "-output-directory",
106
+ workDir,
107
+ texPath
108
+ ];
109
+ }
110
+ async function latexToPdf(texSource, outPath, opts = {}) {
111
+ if (extname(outPath).toLowerCase() !== ".pdf") {
112
+ throw new PlotRenderError(`latexToPdf output must be a .pdf path (got '${outPath}')`);
113
+ }
114
+ const candidates = opts.tool ? [opts.tool] : ["pdflatex", "tectonic"];
115
+ let engine;
116
+ for (const c of candidates) {
117
+ if (await hasTool(c)) {
118
+ engine = c;
119
+ break;
120
+ }
121
+ }
122
+ if (!engine) {
123
+ throw new PlotRenderError(
124
+ "No LaTeX engine found. Install TeX Live (pdflatex) or tectonic and ensure it is on PATH.",
125
+ "pdflatex"
126
+ );
127
+ }
128
+ const work = await mkdtemp(joinPath(tmpdir(), "plot-tex-"));
129
+ try {
130
+ const texPath = joinPath(work, "doc.tex");
131
+ await writeFile(texPath, texSource, "utf-8");
132
+ const args = latexArgs(engine, work, texPath, opts.shellEscape === true);
133
+ const r = await runTool(engine, args, { timeoutMs: opts.timeoutMs ?? 6e4 });
134
+ const pdfPath = joinPath(work, "doc.pdf");
135
+ let pdf;
136
+ try {
137
+ pdf = await readFile(pdfPath);
138
+ } catch {
139
+ throw new PlotRenderError(
140
+ `${engine} produced no PDF (exit ${r.code}): ${r.stderr || r.stdout.toString().slice(-500)}`
141
+ );
142
+ }
143
+ await writeFile(outPath, pdf);
144
+ } finally {
145
+ await rm(work, { recursive: true, force: true });
146
+ }
147
+ }
148
+ export {
149
+ PlotRenderError,
150
+ hasTool,
151
+ latexArgs,
152
+ latexToPdf,
153
+ renderToFile,
154
+ runTool
155
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danielsimonjr/mathts-plot",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Headless SVG 2D/3D plotting for MathTS — dependency-light, expression-aware, built on core/functions/expression",
5
5
  "author": "Daniel Simon Jr.",
6
6
  "license": "MIT",
@@ -12,6 +12,10 @@
12
12
  ".": {
13
13
  "import": "./dist/index.js",
14
14
  "types": "./dist/index.d.ts"
15
+ },
16
+ "./render": {
17
+ "import": "./dist/render-file.js",
18
+ "types": "./dist/render-file.d.ts"
15
19
  }
16
20
  },
17
21
  "files": [
@@ -19,8 +23,8 @@
19
23
  "README.md"
20
24
  ],
21
25
  "scripts": {
22
- "build": "tsup src/index.ts --format esm --dts --clean",
23
- "dev": "tsup src/index.ts --format esm --dts --watch",
26
+ "build": "tsup src/index.ts src/render-file.ts --format esm --dts --clean",
27
+ "dev": "tsup src/index.ts src/render-file.ts --format esm --dts --watch",
24
28
  "test": "vitest run",
25
29
  "test:watch": "vitest",
26
30
  "test:coverage": "vitest run --coverage",
@@ -31,8 +35,8 @@
31
35
  },
32
36
  "dependencies": {
33
37
  "@danielsimonjr/mathts-core": "^0.6.0",
34
- "@danielsimonjr/mathts-functions": "^0.16.0",
35
- "@danielsimonjr/mathts-expression": "^0.5.2"
38
+ "@danielsimonjr/mathts-functions": "^0.16.1",
39
+ "@danielsimonjr/mathts-expression": "^0.6.0"
36
40
  },
37
41
  "devDependencies": {
38
42
  "@types/node": "^25.5.2",