snowkit 0.0.0-stage → 0.1.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.
package/src/files.js ADDED
@@ -0,0 +1,132 @@
1
+ import { readFile, writeFile, rename, stat, unlink } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, extname, join, resolve } from "node:path";
4
+ import { randomBytes } from "node:crypto";
5
+
6
+ export const MAX_BYTES = 256 * 1024 * 1024;
7
+
8
+ export class ToolError extends Error {
9
+ constructor(message) {
10
+ super(message);
11
+ this.name = "ToolError";
12
+ }
13
+ }
14
+
15
+ export function fullPath(value, label = "file path") {
16
+ if (typeof value !== "string" || !value.trim()) throw new ToolError("Give a " + label + ".");
17
+ let p = value.trim();
18
+ if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) p = join(homedir(), p.slice(1));
19
+ return resolve(p);
20
+ }
21
+
22
+ function fsMessage(e, path) {
23
+ switch (e && e.code) {
24
+ case "ENOENT":
25
+ return "Nothing found at " + path + ".";
26
+ case "EACCES":
27
+ case "EPERM":
28
+ return "Permission denied for " + path + ".";
29
+ case "EISDIR":
30
+ return path + " is a folder, not a file.";
31
+ case "ENOSPC":
32
+ return "The disk is full, so " + path + " could not be written.";
33
+ case "EBUSY":
34
+ return path + " is open in another program. Close it and try again.";
35
+ case "EROFS":
36
+ return path + " is on a read-only drive.";
37
+ default:
38
+ return "Could not use " + path + ": " + ((e && e.message) || e);
39
+ }
40
+ }
41
+
42
+ export async function readInput(value, label = "file path") {
43
+ const path = fullPath(value, label);
44
+ let info;
45
+ try {
46
+ info = await stat(path);
47
+ } catch (e) {
48
+ throw new ToolError(e.code === "ENOENT" ? "File not found: " + path : fsMessage(e, path));
49
+ }
50
+ if (info.isDirectory()) throw new ToolError(path + " is a folder, not a file.");
51
+ if (info.size > MAX_BYTES) throw new ToolError(path + " is larger than " + MAX_BYTES / 1024 / 1024 + " MB, which is more than Snowkit can open at once.");
52
+ try {
53
+ return { path, bytes: await readFile(path), size: info.size };
54
+ } catch (e) {
55
+ throw new ToolError(fsMessage(e, path));
56
+ }
57
+ }
58
+
59
+ export async function readTextInput(value, label = "file path") {
60
+ const file = await readInput(value, label);
61
+ return { path: file.path, text: file.bytes.toString("utf8").replace(/^/, "") };
62
+ }
63
+
64
+ export function besideInput(inputPath, suffix, ext) {
65
+ const name = basename(inputPath, extname(inputPath));
66
+ return join(dirname(inputPath), name + suffix + (ext === undefined ? extname(inputPath) : ext));
67
+ }
68
+
69
+ export async function existingFolder(value, label = "folder") {
70
+ const path = fullPath(value, label);
71
+ let info;
72
+ try {
73
+ info = await stat(path);
74
+ } catch (e) {
75
+ throw new ToolError(e.code === "ENOENT" ? "The folder " + path + " does not exist." : fsMessage(e, path));
76
+ }
77
+ if (!info.isDirectory()) throw new ToolError(path + " is a file, not a folder.");
78
+ return path;
79
+ }
80
+
81
+ export async function exists(path) {
82
+ try {
83
+ await stat(path);
84
+ return true;
85
+ } catch (e) {
86
+ if (e.code === "ENOENT") return false;
87
+ throw new ToolError(fsMessage(e, path));
88
+ }
89
+ }
90
+
91
+ export async function checkTargets(paths, overwrite) {
92
+ for (const p of paths) {
93
+ await existingFolder(dirname(p));
94
+ let info = null;
95
+ try {
96
+ info = await stat(p);
97
+ } catch (e) {
98
+ if (e.code !== "ENOENT") throw new ToolError(fsMessage(e, p));
99
+ }
100
+ if (info && info.isDirectory()) throw new ToolError(p + " is a folder, not a file.");
101
+ if (info && !overwrite) throw new ToolError(p + " already exists. Choose another output path, or set overwrite to true to replace it.");
102
+ }
103
+ }
104
+
105
+ export async function writeOutput(value, data, { overwrite = false } = {}) {
106
+ const path = fullPath(value, "output path");
107
+ await checkTargets([path], overwrite);
108
+ if (!overwrite) {
109
+ try {
110
+ await writeFile(path, data, { flag: "wx" });
111
+ } catch (e) {
112
+ if (e.code === "EEXIST") throw new ToolError(path + " already exists. Choose another output path, or set overwrite to true to replace it.");
113
+ throw new ToolError(fsMessage(e, path));
114
+ }
115
+ return path;
116
+ }
117
+ const temp = join(dirname(path), "." + basename(path) + "." + randomBytes(4).toString("hex") + ".tmp");
118
+ try {
119
+ await writeFile(temp, data, { flag: "wx" });
120
+ await rename(temp, path);
121
+ } catch (e) {
122
+ await unlink(temp).catch(() => {});
123
+ throw new ToolError(fsMessage(e, path));
124
+ }
125
+ return path;
126
+ }
127
+
128
+ export function formatBytes(n) {
129
+ if (n < 1024) return n + " B";
130
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(/\.0$/, "") + " KB";
131
+ return (n / 1024 / 1024).toFixed(1).replace(/\.0$/, "") + " MB";
132
+ }
package/src/pdf.js ADDED
@@ -0,0 +1,297 @@
1
+ import * as PDFLib from "pdf-lib";
2
+ import { basename, dirname, extname, join } from "node:path";
3
+ import { ToolError, readInput, readTextInput, besideInput, existingFolder, checkTargets, writeOutput, formatBytes } from "./files.js";
4
+ import { markdownPdf, pageSizeFor } from "./engine/mdpdf.js";
5
+ import { markdownTitle } from "./engine/md-render.js";
6
+
7
+ const { PDFDocument, degrees } = PDFLib;
8
+
9
+ const NAMED_SIZES = [
10
+ ["A3", 841.89, 1190.55],
11
+ ["A4", 595.28, 841.89],
12
+ ["A5", 419.53, 595.28],
13
+ ["Letter", 612, 792],
14
+ ["Legal", 612, 1008],
15
+ ["Tabloid", 792, 1224],
16
+ ];
17
+
18
+ const plural = (n, one, many) => n + " " + (n === 1 ? one : many || one + "s");
19
+
20
+ export async function loadPdf(file, options = {}) {
21
+ try {
22
+ return await PDFDocument.load(file.bytes, { updateMetadata: false, ...options });
23
+ } catch (e) {
24
+ const message = String((e && e.message) || e);
25
+ if (/encrypt/i.test(message)) throw new ToolError(file.path + " is password-protected. Remove the password first, then try again.");
26
+ throw new ToolError(file.path + " could not be read as a PDF. It may be damaged or not a PDF at all.");
27
+ }
28
+ }
29
+
30
+ async function readPdf(value) {
31
+ const file = await readInput(value);
32
+ return { file, doc: await loadPdf(file) };
33
+ }
34
+
35
+ function newDocument() {
36
+ return PDFDocument.create().then((doc) => {
37
+ doc.setCreator("Snowkit");
38
+ doc.setProducer("Snowkit");
39
+ return doc;
40
+ });
41
+ }
42
+
43
+ async function save(doc) {
44
+ try {
45
+ return await doc.save();
46
+ } catch (e) {
47
+ throw new ToolError("The PDF could not be saved: " + String((e && e.message) || e));
48
+ }
49
+ }
50
+
51
+ export function parsePages(spec, count) {
52
+ const text = String(spec == null ? "" : spec).trim().toLowerCase();
53
+ if (!text) throw new ToolError("Give the pages to use, such as 1-3, 5, 8-.");
54
+ const out = [];
55
+ const num = (raw, part) => {
56
+ if (raw === "last" || raw === "end") return count;
57
+ if (!/^\d+$/.test(raw)) throw new ToolError("“" + part + "” is not a page or a range. Use forms like 4, 2-6, 9- or last.");
58
+ const n = Number(raw);
59
+ if (n < 1 || n > count) throw new ToolError("Page " + n + " does not exist. This PDF has " + plural(count, "page") + ".");
60
+ return n;
61
+ };
62
+ for (const part of text.split(/[,;\s]+/).filter(Boolean)) {
63
+ if (part === "all") {
64
+ for (let i = 0; i < count; i++) out.push(i);
65
+ continue;
66
+ }
67
+ if (part === "odd" || part === "even") {
68
+ for (let i = part === "odd" ? 0 : 1; i < count; i += 2) out.push(i);
69
+ continue;
70
+ }
71
+ const m = part.match(/^([^-]*)-([^-]*)$/);
72
+ if (!m) {
73
+ out.push(num(part, part) - 1);
74
+ continue;
75
+ }
76
+ const from = m[1] ? num(m[1], part) : 1;
77
+ const to = m[2] ? num(m[2], part) : count;
78
+ const step = from <= to ? 1 : -1;
79
+ for (let p = from; p !== to + step; p += step) out.push(p - 1);
80
+ }
81
+ if (!out.length) throw new ToolError("Those pages select nothing in this PDF.");
82
+ return out;
83
+ }
84
+
85
+ function sizeName(w, h) {
86
+ const a = Math.min(w, h);
87
+ const b = Math.max(w, h);
88
+ const hit = NAMED_SIZES.find(([, x, y]) => Math.abs(x - a) < 3 && Math.abs(y - b) < 3);
89
+ if (!hit) return "";
90
+ return hit[0] + (w > h ? " landscape" : "");
91
+ }
92
+
93
+ function describeSize(w, h) {
94
+ const mm = (pt) => Math.round((pt / 72) * 25.4);
95
+ const inch = (pt) => Math.round((pt / 72) * 100) / 100;
96
+ const name = sizeName(w, h);
97
+ return (name ? name + ", " : "") + mm(w) + " × " + mm(h) + " mm (" + inch(w) + " × " + inch(h) + " in)";
98
+ }
99
+
100
+ export async function mergePdfs({ inputs, output, overwrite = false }) {
101
+ if (!Array.isArray(inputs) || inputs.length < 2) throw new ToolError("Give at least two PDF files to merge, in the order you want them.");
102
+ const files = [];
103
+ for (const value of inputs) files.push(await readPdf(value));
104
+ const target = output || besideInput(files[0].file.path, "-merged", ".pdf");
105
+ const merged = await newDocument();
106
+ let pages = 0;
107
+ for (const { doc } of files) {
108
+ const copied = await merged.copyPages(doc, doc.getPageIndices());
109
+ for (const page of copied) merged.addPage(page);
110
+ pages += copied.length;
111
+ }
112
+ const bytes = await save(merged);
113
+ const path = await writeOutput(target, bytes, { overwrite });
114
+ return {
115
+ text: "Merged " + plural(files.length, "file") + " into " + plural(pages, "page") + ".\nSaved to " + path + " (" + formatBytes(bytes.length) + ").",
116
+ data: { output: path, pages, files: files.map((f) => f.file.path), bytes: bytes.length },
117
+ };
118
+ }
119
+
120
+ export async function splitPdf({ input, ranges, every, output_folder, overwrite = false }) {
121
+ const { file, doc } = await readPdf(input);
122
+ const count = doc.getPageCount();
123
+ const folder = output_folder ? await existingFolder(output_folder, "output folder") : dirname(file.path);
124
+ const base = basename(file.path, extname(file.path));
125
+ const parts = [];
126
+ if (ranges !== undefined && every !== undefined) throw new ToolError("Use either ranges or every, not both.");
127
+ if (ranges !== undefined) {
128
+ const list = Array.isArray(ranges) ? ranges : [ranges];
129
+ if (!list.length) throw new ToolError("Give at least one range, such as 1-3.");
130
+ for (const r of list) {
131
+ const label = String(r).trim().toLowerCase().replace(/[^a-z0-9-]+/g, "_").replace(/^_+|_+$/g, "") || "pages";
132
+ parts.push({ pages: parsePages(r, count), name: base + "-pages-" + label + ".pdf" });
133
+ }
134
+ } else {
135
+ const size = every === undefined ? 1 : Number(every);
136
+ if (!Number.isInteger(size) || size < 1) throw new ToolError("every must be a whole number of pages, 1 or more.");
137
+ const total = Math.ceil(count / size);
138
+ const width = String(total).length;
139
+ for (let i = 0, n = 1; i < count; i += size, n++) {
140
+ const pages = [];
141
+ for (let p = i; p < Math.min(count, i + size); p++) pages.push(p);
142
+ parts.push({ pages, name: base + (size === 1 ? "-page-" : "-part-") + String(n).padStart(width, "0") + ".pdf" });
143
+ }
144
+ }
145
+ if (parts.length > 2000) throw new ToolError("That would make " + parts.length + " files. Split into fewer, larger parts.");
146
+ const names = new Set();
147
+ for (const part of parts) {
148
+ if (names.has(part.name)) throw new ToolError("Two ranges would write the same file name, " + part.name + ".");
149
+ names.add(part.name);
150
+ part.path = join(folder, part.name);
151
+ }
152
+ await checkTargets(parts.map((p) => p.path), overwrite);
153
+ const written = [];
154
+ for (const part of parts) {
155
+ const out = await newDocument();
156
+ for (const page of await out.copyPages(doc, part.pages)) out.addPage(page);
157
+ const bytes = await save(out);
158
+ written.push({ output: await writeOutput(part.path, bytes, { overwrite }), pages: part.pages.length });
159
+ }
160
+ const list = written.map((w) => " " + w.output + " (" + plural(w.pages, "page") + ")").join("\n");
161
+ return {
162
+ text: "Split " + plural(count, "page") + " into " + plural(written.length, "file") + ":\n" + list,
163
+ data: { input: file.path, files: written },
164
+ };
165
+ }
166
+
167
+ export async function extractPages({ input, pages, output, overwrite = false }) {
168
+ const { file, doc } = await readPdf(input);
169
+ const indices = parsePages(pages, doc.getPageCount());
170
+ const target = output || besideInput(file.path, "-extracted", ".pdf");
171
+ const out = await newDocument();
172
+ for (const page of await out.copyPages(doc, indices)) out.addPage(page);
173
+ const bytes = await save(out);
174
+ const path = await writeOutput(target, bytes, { overwrite });
175
+ return {
176
+ text: "Copied " + plural(indices.length, "page") + " from " + file.path + ".\nSaved to " + path + " (" + formatBytes(bytes.length) + ").",
177
+ data: { input: file.path, output: path, pages: indices.map((i) => i + 1) },
178
+ };
179
+ }
180
+
181
+ export async function rotatePdf({ input, angle, pages, output, overwrite = false }) {
182
+ const turn = Number(angle);
183
+ if (![90, 180, 270, -90].includes(turn)) throw new ToolError("angle must be 90, 180, 270 or -90.");
184
+ const { file, doc } = await readPdf(input);
185
+ const indices = [...new Set(parsePages(pages === undefined ? "all" : pages, doc.getPageCount()))];
186
+ for (const i of indices) {
187
+ const page = doc.getPage(i);
188
+ page.setRotation(degrees((((page.getRotation().angle + turn) % 360) + 360) % 360));
189
+ }
190
+ const target = output || besideInput(file.path, "-rotated", ".pdf");
191
+ const bytes = await save(doc);
192
+ const path = await writeOutput(target, bytes, { overwrite });
193
+ const which = indices.length === doc.getPageCount() ? "every page" : plural(indices.length, "page");
194
+ return {
195
+ text: "Turned " + which + " by " + turn + " degrees" + (turn < 0 ? " (anticlockwise)" : " clockwise") + ".\nSaved to " + path + ".",
196
+ data: { input: file.path, output: path, angle: turn, pages: indices.map((i) => i + 1) },
197
+ };
198
+ }
199
+
200
+ function safeDate(get) {
201
+ try {
202
+ const d = get();
203
+ return d instanceof Date && !isNaN(d) ? d.toISOString() : null;
204
+ } catch (e) {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ function safeText(get) {
210
+ try {
211
+ const v = get();
212
+ return v == null || v === "" ? null : String(v);
213
+ } catch (e) {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ export async function pdfInfo({ input }) {
219
+ const file = await readInput(input);
220
+ const doc = await loadPdf(file, { ignoreEncryption: true });
221
+ const header = file.bytes.subarray(0, 1024).toString("latin1").match(/%PDF-(\d\.\d)/);
222
+ const sizes = new Map();
223
+ doc.getPages().forEach((page) => {
224
+ const { width, height } = page.getSize();
225
+ const rot = page.getRotation().angle % 180 !== 0;
226
+ const label = rot ? describeSize(height, width) : describeSize(width, height);
227
+ sizes.set(label, (sizes.get(label) || 0) + 1);
228
+ });
229
+ let fields = 0;
230
+ try {
231
+ fields = doc.getForm().getFields().length;
232
+ } catch (e) {
233
+ fields = 0;
234
+ }
235
+ const meta = {
236
+ title: safeText(() => doc.getTitle()),
237
+ author: safeText(() => doc.getAuthor()),
238
+ subject: safeText(() => doc.getSubject()),
239
+ keywords: safeText(() => doc.getKeywords()),
240
+ creator: safeText(() => doc.getCreator()),
241
+ producer: safeText(() => doc.getProducer()),
242
+ created: safeDate(() => doc.getCreationDate()),
243
+ modified: safeDate(() => doc.getModificationDate()),
244
+ };
245
+ const count = doc.getPageCount();
246
+ const lines = [file.path, "Pages: " + count, "File size: " + formatBytes(file.size)];
247
+ if (header) lines.push("PDF version: " + header[1]);
248
+ lines.push("Password-protected: " + (doc.isEncrypted ? "yes" : "no"));
249
+ if (fields) lines.push("Form fields: " + fields);
250
+ lines.push("Page sizes:");
251
+ for (const [label, n] of sizes) lines.push(" " + label + (sizes.size > 1 ? " – " + plural(n, "page") : ""));
252
+ const labels = { title: "Title", author: "Author", subject: "Subject", keywords: "Keywords", creator: "Created with", producer: "Producer", created: "Created", modified: "Modified" };
253
+ const shown = Object.entries(meta).filter(([, v]) => v);
254
+ if (shown.length) {
255
+ lines.push("Details:");
256
+ for (const [k, v] of shown) lines.push(" " + labels[k] + ": " + v);
257
+ }
258
+ return {
259
+ text: lines.join("\n"),
260
+ data: { input: file.path, pages: count, bytes: file.size, version: header ? header[1] : null, encrypted: !!doc.isEncrypted, form_fields: fields, page_sizes: [...sizes].map(([size, pages]) => ({ size, pages })), metadata: meta },
261
+ };
262
+ }
263
+
264
+ const WIN_ANSI_EXTRA = new Set([0x152, 0x153, 0x160, 0x161, 0x178, 0x17d, 0x17e, 0x192, 0x2c6, 0x2dc, 0x2013, 0x2014, 0x2018, 0x2019, 0x201a, 0x201c, 0x201d, 0x201e, 0x2020, 0x2021, 0x2022, 0x2026, 0x2030, 0x2039, 0x203a, 0x20ac, 0x2122]);
265
+
266
+ function outsideLatin(text) {
267
+ for (const ch of text) {
268
+ const code = ch.codePointAt(0);
269
+ if (code > 0xff && !WIN_ANSI_EXTRA.has(code)) return true;
270
+ }
271
+ return false;
272
+ }
273
+
274
+ export async function markdownToPdf({ input, markdown, output, page, title, page_numbers = false, overwrite = false }) {
275
+ if ((input === undefined) === (markdown === undefined)) throw new ToolError("Give either input (a Markdown file) or markdown (the text itself), not both.");
276
+ let source;
277
+ let from = null;
278
+ if (input !== undefined) {
279
+ const file = await readTextInput(input);
280
+ source = file.text;
281
+ from = file.path;
282
+ } else source = String(markdown);
283
+ if (!source.trim()) throw new ToolError("The Markdown is empty.");
284
+ if (!output && !from) throw new ToolError("Give an output path for the PDF: a full file path ending in .pdf.");
285
+ const target = output || besideInput(from, "", ".pdf");
286
+ const paper = page || pageSizeFor(Intl.DateTimeFormat().resolvedOptions().locale, null);
287
+ const heading = title || markdownTitle(source) || (from ? basename(from, extname(from)) : "");
288
+ const result = await markdownPdf(source, { page: paper, title: heading, numbers: !!page_numbers }, { L: PDFLib, fontkit: null, fontBytes: null });
289
+ const path = await writeOutput(target, result.bytes, { overwrite });
290
+ const notes = [];
291
+ if (outsideLatin(source)) notes.push("Some characters outside Western European alphabets could not be drawn with the built-in fonts and show as question marks.");
292
+ if (result.skipped) notes.push(plural(result.skipped, "image") + " left out, because images are not added from Markdown here.");
293
+ return {
294
+ text: "Made a " + plural(result.pages, "page") + " PDF (" + paper.toUpperCase() + ").\nSaved to " + path + "." + (notes.length ? "\n" + notes.join("\n") : ""),
295
+ data: { input: from, output: path, pages: result.pages, page_size: paper, title: heading || null },
296
+ };
297
+ }
package/src/qr.js ADDED
@@ -0,0 +1,100 @@
1
+ import { deflateSync } from "node:zlib";
2
+ import { extname } from "node:path";
3
+ import { ToolError, writeOutput, formatBytes } from "./files.js";
4
+ import { encode, qrScene, sceneSvg } from "./engine/qrkit.js";
5
+
6
+ const MARGIN = 4;
7
+
8
+ const CRC_TABLE = (() => {
9
+ const table = new Uint32Array(256);
10
+ for (let n = 0; n < 256; n++) {
11
+ let c = n;
12
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
13
+ table[n] = c >>> 0;
14
+ }
15
+ return table;
16
+ })();
17
+
18
+ function crc32(buf) {
19
+ let c = 0xffffffff;
20
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
21
+ return (c ^ 0xffffffff) >>> 0;
22
+ }
23
+
24
+ function chunk(type, data) {
25
+ const head = Buffer.alloc(8);
26
+ head.writeUInt32BE(data.length, 0);
27
+ head.write(type, 4, "latin1");
28
+ const tail = Buffer.alloc(4);
29
+ tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
30
+ return Buffer.concat([head, data, tail]);
31
+ }
32
+
33
+ export function grayPng(width, height, pixel) {
34
+ const raw = Buffer.alloc((width + 1) * height);
35
+ for (let y = 0; y < height; y++) {
36
+ const row = y * (width + 1);
37
+ raw[row] = 0;
38
+ for (let x = 0; x < width; x++) raw[row + 1 + x] = pixel(x, y);
39
+ }
40
+ const ihdr = Buffer.alloc(13);
41
+ ihdr.writeUInt32BE(width, 0);
42
+ ihdr.writeUInt32BE(height, 4);
43
+ ihdr[8] = 8;
44
+ ihdr[9] = 0;
45
+ ihdr[10] = 0;
46
+ ihdr[11] = 0;
47
+ ihdr[12] = 0;
48
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0))]);
49
+ }
50
+
51
+ export function makeCode(text, level = "M") {
52
+ const value = String(text == null ? "" : text);
53
+ if (!value) throw new ToolError("Give the text or link to put in the QR code.");
54
+ const ecl = String(level).toUpperCase();
55
+ if (!["L", "M", "Q", "H"].includes(ecl)) throw new ToolError("level must be L, M, Q or H.");
56
+ try {
57
+ return encode(value, ecl);
58
+ } catch (e) {
59
+ if (e && e.kind === "long") throw new ToolError("That is too long for one QR code. At level " + ecl + " it holds about " + e.max + (e.bytes ? " bytes" : " characters") + ". Shorten it or use level L.");
60
+ throw new ToolError("The QR code could not be made: " + String((e && e.message) || e));
61
+ }
62
+ }
63
+
64
+ export function qrSvg(code, size) {
65
+ return sceneSvg(qrScene(code, { margin: MARGIN }), { width: size });
66
+ }
67
+
68
+ export function qrPng(code, size) {
69
+ const total = code.size + MARGIN * 2;
70
+ const scale = Math.max(1, Math.round(size / total));
71
+ const width = total * scale;
72
+ return grayPng(width, width, (x, y) => {
73
+ const mx = Math.floor(x / scale) - MARGIN;
74
+ const my = Math.floor(y / scale) - MARGIN;
75
+ if (mx < 0 || my < 0 || mx >= code.size || my >= code.size) return 255;
76
+ return code.modules[my * code.size + mx] === 1 ? 0 : 255;
77
+ });
78
+ }
79
+
80
+ export async function makeQr({ text, output, format, level = "M", size = 512, overwrite = false }) {
81
+ const px = Number(size);
82
+ if (!Number.isInteger(px) || px < 64 || px > 4096) throw new ToolError("size must be a whole number from 64 to 4096.");
83
+ const ext = output ? extname(String(output)).toLowerCase() : "";
84
+ const kind = (format || (ext === ".png" ? "png" : "svg")).toLowerCase();
85
+ if (kind !== "svg" && kind !== "png") throw new ToolError("format must be svg or png.");
86
+ if (output && ext && ext !== "." + kind) throw new ToolError("The output name ends in " + ext + " but the format is " + kind + ". Make them match.");
87
+ const code = makeCode(text, level);
88
+ const facts = "Version " + code.ver + ", level " + code.level + ", " + code.size + " × " + code.size + " modules.";
89
+ if (!output) {
90
+ if (kind === "png") throw new ToolError("Give an output path to save a PNG: a full file path ending in .png.");
91
+ const svg = qrSvg(code, px);
92
+ return { text: facts, output: svg, data: { svg, version: code.ver, level: code.level, modules: code.size } };
93
+ }
94
+ const bytes = kind === "png" ? qrPng(code, px) : Buffer.from(qrSvg(code, px), "utf8");
95
+ const path = await writeOutput(output, bytes, { overwrite });
96
+ return {
97
+ text: "Saved a " + kind.toUpperCase() + " QR code to " + path + " (" + formatBytes(bytes.length) + ").\n" + facts + "\nThe text lives in the code itself, so it never expires.",
98
+ data: { output: path, format: kind, version: code.ver, level: code.level, modules: code.size, bytes: bytes.length },
99
+ };
100
+ }
package/src/server.js ADDED
@@ -0,0 +1,50 @@
1
+ import { z } from "zod";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { TOOLS, toolLink, ToolError } from "./tools.js";
5
+
6
+ const INSTRUCTIONS =
7
+ "Snowkit tools run on this computer and never send files or text anywhere. Pass full file paths. " +
8
+ "Outputs are saved next to the input file unless an output path is given, and existing files are never replaced unless overwrite is true. " +
9
+ "Use pdf_info before splitting or extracting pages when the page count is unknown.";
10
+
11
+ function message(e) {
12
+ if (e instanceof ToolError) return e.message;
13
+ return "Something went wrong: " + String((e && e.message) || e);
14
+ }
15
+
16
+ export function createServer(version) {
17
+ const server = new McpServer(
18
+ { name: "snowkit", title: "Snowkit", version, description: "Private tools that run on your computer.", websiteUrl: "https://snowkit.io" },
19
+ { instructions: INSTRUCTIONS }
20
+ );
21
+ for (const tool of TOOLS) {
22
+ server.registerTool(
23
+ tool.name,
24
+ {
25
+ title: tool.title,
26
+ description: tool.description,
27
+ inputSchema: z.object(tool.schema).strict(),
28
+ annotations: { title: tool.title, readOnlyHint: !!tool.readOnly, destructiveHint: false, idempotentHint: !!tool.readOnly, openWorldHint: false },
29
+ },
30
+ async (args) => {
31
+ try {
32
+ const result = await tool.run(args || {});
33
+ const parts = [result.text];
34
+ if (result.output !== undefined) parts.push(result.output.replace(/\n$/, ""));
35
+ parts.push("Open in Snowkit: " + toolLink(tool, args));
36
+ return { content: [{ type: "text", text: parts.join("\n\n") }] };
37
+ } catch (e) {
38
+ return { content: [{ type: "text", text: message(e) }], isError: true };
39
+ }
40
+ }
41
+ );
42
+ }
43
+ return server;
44
+ }
45
+
46
+ export async function startServer(version) {
47
+ const server = createServer(version);
48
+ await server.connect(new StdioServerTransport());
49
+ return server;
50
+ }