visualproof 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.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/extractor/Extract.lean +1180 -0
  9. package/dist/extractor/Usage.lean +900 -0
  10. package/dist/mcp/visualproof-mcp.mjs +476 -0
  11. package/dist/server/access.js +95 -0
  12. package/dist/server/agent.js +3235 -0
  13. package/dist/server/agentdocs.js +35 -0
  14. package/dist/server/agentlibrary.js +202 -0
  15. package/dist/server/agentnotes.js +701 -0
  16. package/dist/server/agentviews.js +703 -0
  17. package/dist/server/askfollow.js +286 -0
  18. package/dist/server/boardspots.js +159 -0
  19. package/dist/server/dirs.js +175 -0
  20. package/dist/server/doctor.js +186 -0
  21. package/dist/server/events.js +198 -0
  22. package/dist/server/export.js +420 -0
  23. package/dist/server/extract.js +204 -0
  24. package/dist/server/fetchurl.js +321 -0
  25. package/dist/server/figures.js +158 -0
  26. package/dist/server/history.js +741 -0
  27. package/dist/server/historyapi.js +304 -0
  28. package/dist/server/hover.js +595 -0
  29. package/dist/server/identity.js +114 -0
  30. package/dist/server/invites.js +241 -0
  31. package/dist/server/layoutthread.js +51 -0
  32. package/dist/server/library.js +600 -0
  33. package/dist/server/libraryroute.js +211 -0
  34. package/dist/server/live.js +1101 -0
  35. package/dist/server/lostfound.js +199 -0
  36. package/dist/server/main.js +1394 -0
  37. package/dist/server/mcptools.js +497 -0
  38. package/dist/server/pastdoc.js +73 -0
  39. package/dist/server/pipeline.js +156 -0
  40. package/dist/server/poppler.js +251 -0
  41. package/dist/server/presence.js +252 -0
  42. package/dist/server/project.js +222 -0
  43. package/dist/server/prompt.js +53 -0
  44. package/dist/server/readonly.js +152 -0
  45. package/dist/server/readonlygate.js +123 -0
  46. package/dist/server/renames.js +268 -0
  47. package/dist/server/review.js +334 -0
  48. package/dist/server/reviewapi.js +188 -0
  49. package/dist/server/snapshot.js +49 -0
  50. package/dist/server/sourcestore.js +473 -0
  51. package/dist/server/status.js +360 -0
  52. package/dist/server/talk.js +476 -0
  53. package/dist/server/tokens.js +221 -0
  54. package/dist/server/usage.js +501 -0
  55. package/dist/server/versions.js +82 -0
  56. package/dist/server/view.js +157 -0
  57. package/dist/server/views.js +1167 -0
  58. package/dist/shared/askfollow.js +46 -0
  59. package/dist/shared/board.js +332 -0
  60. package/dist/shared/build.js +2 -0
  61. package/dist/shared/collab.js +148 -0
  62. package/dist/shared/comment.js +285 -0
  63. package/dist/shared/context.js +157 -0
  64. package/dist/shared/doc/authors.js +98 -0
  65. package/dist/shared/doc/build.js +358 -0
  66. package/dist/shared/doc/latex.js +455 -0
  67. package/dist/shared/doc/markdown.js +159 -0
  68. package/dist/shared/doc/model.js +37 -0
  69. package/dist/shared/doc/order.js +107 -0
  70. package/dist/shared/doc/proof.js +272 -0
  71. package/dist/shared/doc/sections.js +93 -0
  72. package/dist/shared/doc/settings.js +75 -0
  73. package/dist/shared/edgecodec.js +102 -0
  74. package/dist/shared/editor.js +64 -0
  75. package/dist/shared/excerpt.js +87 -0
  76. package/dist/shared/graph.js +16 -0
  77. package/dist/shared/items.js +92 -0
  78. package/dist/shared/keys.js +203 -0
  79. package/dist/shared/layout.js +926 -0
  80. package/dist/shared/lexer.js +130 -0
  81. package/dist/shared/library.js +711 -0
  82. package/dist/shared/lostfound.js +150 -0
  83. package/dist/shared/moves.js +243 -0
  84. package/dist/shared/notecard.js +295 -0
  85. package/dist/shared/noteplace.js +505 -0
  86. package/dist/shared/palette.js +46 -0
  87. package/dist/shared/pastgraph.js +95 -0
  88. package/dist/shared/places.js +80 -0
  89. package/dist/shared/projectnotes.js +244 -0
  90. package/dist/shared/query.js +810 -0
  91. package/dist/shared/review.js +642 -0
  92. package/dist/shared/reviewqueue.js +333 -0
  93. package/dist/shared/ring.js +366 -0
  94. package/dist/shared/scope.js +255 -0
  95. package/dist/shared/scopeedit.js +81 -0
  96. package/dist/shared/search.js +191 -0
  97. package/dist/shared/separate.js +178 -0
  98. package/dist/shared/sharesnap.js +280 -0
  99. package/dist/shared/sticky.js +501 -0
  100. package/dist/shared/timeline.js +73 -0
  101. package/dist/shared/titles.js +298 -0
  102. package/dist/shared/viewgraph.js +139 -0
  103. package/dist/shared/views.js +859 -0
  104. package/dist/web/bundle/app.js +48733 -0
  105. package/dist/web/bundle/edges.js +409 -0
  106. package/dist/web/bundle/layout.js +1010 -0
  107. package/dist/web/bundle/pdfworker.js +48322 -0
  108. package/dist/web/index.html +239 -0
  109. package/dist/web/src/motion.js +430 -0
  110. package/dist/web/src/past.js +224 -0
  111. package/dist/web/src/scene.js +787 -0
  112. package/dist/web/style.css +2312 -0
  113. package/docs/agent-api/README.md +91 -0
  114. package/docs/agent-api/advice.md +108 -0
  115. package/docs/agent-api/export.md +120 -0
  116. package/docs/agent-api/following.md +140 -0
  117. package/docs/agent-api/history.md +142 -0
  118. package/docs/agent-api/library.md +65 -0
  119. package/docs/agent-api/mcp.md +104 -0
  120. package/docs/agent-api/notes.md +93 -0
  121. package/docs/agent-api/reading.md +166 -0
  122. package/docs/agent-api/requests.md +94 -0
  123. package/docs/agent-api/talking.md +100 -0
  124. package/docs/agent-api/tcb.md +135 -0
  125. package/docs/agent-api/views.md +124 -0
  126. package/docs/agent-api/words.md +56 -0
  127. package/package.json +39 -4
  128. package/test/toolchains.json +5 -0
@@ -0,0 +1,321 @@
1
+ // A file of the library fetched by URL (AttachmentSpec decision 1, "Implementation notes"): the
2
+ // server fetches it once and keeps it. Whoever can write a note can make the server fetch an
3
+ // address, so it must not become a way into the server's own network: only http and https, never
4
+ // an address that is loopback, private, link-local, reserved or the server's own — checked on the
5
+ // addresses the name resolves to, every one of them, and the connection made to the address
6
+ // checked, so that a name answering differently the second time (DNS rebinding) reaches nothing
7
+ // else; the same again at every redirect. At most MAX_FILE bytes, within a time limit. An arXiv
8
+ // abstract's address gives its PDF; a paper on arXiv, by its abstract's address or its PDF's, is
9
+ // titled as its abstract page says, a page fetched with the same guard (`fetchPage`, `arxivTitle`).
10
+ import { lookup } from "node:dns/promises";
11
+ import { request as httpRequest } from "node:http";
12
+ import { request as httpsRequest } from "node:https";
13
+ import { isIP } from "node:net";
14
+ import { networkInterfaces } from "node:os";
15
+ import { MAX_FILE } from "../shared/library.js";
16
+ const DEFAULTS = { maxBytes: MAX_FILE, timeoutMs: 60_000, maxRedirects: 5 };
17
+ /** the most of a page that is read, and the longest it may take: an HTML page is far smaller than a
18
+ * file, and what it says of one is not worth a long wait */
19
+ export const MAX_PAGE = 2 * 1024 * 1024;
20
+ const PAGE_MS = 15_000;
21
+ export const ARXIV = "https://arxiv.org";
22
+ /** The identifier of the paper an arXiv address is of: its abstract's (`/abs/2401.01234v2`) or its
23
+ * PDF's (`/pdf/2401.01234v2`, `.pdf` or not), at arxiv.org, www. or export. too; null for any other
24
+ * address. */
25
+ export function arxivId(url) {
26
+ return /^https?:\/\/(?:www\.|export\.)?arxiv\.org\/(?:abs|pdf)\/([^?#]+?)(?:\.pdf)?\/?(?:[?#].*)?$/i.exec(url.trim())?.[1] ?? null;
27
+ }
28
+ /** An arXiv address as its PDF's at `origin` (`/abs/2401.01234v2` → `/pdf/2401.01234v2`); any other
29
+ * address as it is. */
30
+ export function arxivPdf(url, origin = ARXIV) {
31
+ const id = arxivId(url);
32
+ return id ? `${origin.replace(/\/+$/, "")}/pdf/${id}` : url.trim();
33
+ }
34
+ /** The parts of an IPv6 address, eight 16-bit numbers; null when it is not one. */
35
+ function ipv6Parts(a) {
36
+ let s = a.toLowerCase().replace(/%.*$/, "");
37
+ // an IPv4 address at its end stands for its last two parts
38
+ const v4 = /(\d+\.\d+\.\d+\.\d+)$/.exec(s);
39
+ if (v4) {
40
+ const b = v4[1].split(".").map(Number);
41
+ if (b.some((x) => x > 255))
42
+ return null;
43
+ s = s.slice(0, -v4[1].length) + `${((b[0] << 8) | b[1]).toString(16)}:${((b[2] << 8) | b[3]).toString(16)}`;
44
+ }
45
+ const halves = s.split("::");
46
+ if (halves.length > 2)
47
+ return null;
48
+ const head = halves[0] ? halves[0].split(":") : [];
49
+ const tail = halves.length === 2 && halves[1] ? halves[1].split(":") : [];
50
+ const fill = halves.length === 2 ? 8 - head.length - tail.length : 0;
51
+ if (fill < 0 || (halves.length === 1 && head.length !== 8))
52
+ return null;
53
+ const parts = [...head, ...Array(fill).fill("0"), ...tail].map((x) => parseInt(x, 16));
54
+ return parts.length === 8 && parts.every((x) => Number.isInteger(x) && x >= 0 && x <= 0xffff) ? parts : null;
55
+ }
56
+ /** Whether an IPv4 address (four bytes) is one the server never fetches from. */
57
+ function blockedV4(b) {
58
+ const [a, c] = b;
59
+ return (a === 0 || // this network
60
+ a === 10 || // private
61
+ a === 127 || // loopback
62
+ (a === 100 && c >= 64 && c <= 127) || // carrier-grade NAT
63
+ (a === 169 && c === 254) || // link-local
64
+ (a === 172 && c >= 16 && c <= 31) || // private
65
+ (a === 192 && c === 168) || // private
66
+ (a === 192 && c === 0 && b[2] === 0) || // IETF protocol assignments
67
+ (a === 198 && (c === 18 || c === 19)) || // benchmarking
68
+ a >= 224 // multicast, reserved, broadcast
69
+ );
70
+ }
71
+ /** Whether the server refuses to fetch from an address: loopback, private, link-local, unique-local,
72
+ * multicast, reserved or unspecified, an IPv4 one written as IPv6 (mapped, NAT64) included. Anything
73
+ * that is not an address at all is refused too. */
74
+ export function blockedAddress(address) {
75
+ const kind = isIP(address.replace(/%.*$/, ""));
76
+ if (kind === 4)
77
+ return blockedV4(address.split(".").map(Number));
78
+ if (kind !== 6)
79
+ return true;
80
+ const p = ipv6Parts(address);
81
+ if (!p)
82
+ return true;
83
+ const v4 = (hi, lo) => [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff];
84
+ if (p.every((x) => x === 0))
85
+ return true; // unspecified
86
+ if (p.slice(0, 7).every((x) => x === 0) && p[7] === 1)
87
+ return true; // loopback
88
+ if (p.slice(0, 5).every((x) => x === 0) && p[5] === 0xffff)
89
+ return blockedV4(v4(p[6], p[7])); // IPv4-mapped
90
+ if (p.slice(0, 6).every((x) => x === 0))
91
+ return true; // IPv4-compatible, deprecated
92
+ if (p[0] === 0x64 && p[1] === 0xff9b && p.slice(2, 6).every((x) => x === 0))
93
+ return blockedV4(v4(p[6], p[7])); // NAT64
94
+ if ((p[0] & 0xfe00) === 0xfc00)
95
+ return true; // unique local
96
+ if ((p[0] & 0xffc0) === 0xfe80)
97
+ return true; // link-local
98
+ if ((p[0] & 0xffc0) === 0xfec0)
99
+ return true; // site-local, deprecated
100
+ if ((p[0] & 0xff00) === 0xff00)
101
+ return true; // multicast
102
+ if (p[0] === 0x2001 && p[1] === 0x0db8)
103
+ return true; // documentation
104
+ return false;
105
+ }
106
+ /** The addresses of the server's own interfaces, whatever range they are in. */
107
+ function ownAddresses() {
108
+ const out = new Set();
109
+ for (const list of Object.values(networkInterfaces()))
110
+ for (const i of list ?? [])
111
+ out.add(i.address.replace(/%.*$/, "").toLowerCase());
112
+ return out;
113
+ }
114
+ /** Whether the server may connect to an address by default: not refused, and none of its own. */
115
+ export function defaultAllowed(address) {
116
+ return !blockedAddress(address) && !ownAddresses().has(address.toLowerCase());
117
+ }
118
+ /** What is fetched — a file of the library, or a page read for what it says of one: what the request
119
+ * asks for, and what the errors call it. */
120
+ const KINDS = {
121
+ file: { accept: "application/pdf, image/png, image/jpeg, image/webp, image/gif;q=0.9, */*;q=0.1", noun: "file", most: "a file of the library" },
122
+ page: { accept: "text/html, application/xhtml+xml;q=0.9, */*;q=0.1", noun: "page", most: "a page the server reads" },
123
+ };
124
+ /** Fetch an address for the library: its bytes, the address they came from in the end, and the name
125
+ * its answer gives the file (Content-Disposition), else its path's last part. Refused, with why,
126
+ * where the address is not http or https, resolves to an address the server does not fetch from
127
+ * (at any redirect), answers other than 200, sends more than the most a file may be, or takes too
128
+ * long. An arXiv address is fetched as its PDF at `opts.arxivOrigin`. */
129
+ export async function fetchUrl(raw, opts = {}) {
130
+ const got = await guardedFetch(arxivPdf(raw, opts.arxivOrigin), "file", { ...DEFAULTS, ...opts }, opts.allowed ?? defaultAllowed);
131
+ if (!got.ok)
132
+ return got;
133
+ return { ok: true, bytes: got.bytes, url: got.url.toString(), name: nameOf(got.disposition, got.url) };
134
+ }
135
+ /** Fetch a page's text, read as UTF-8 (as arXiv's are), with the same guard as a file: at most
136
+ * MAX_PAGE bytes within PAGE_MS, or less where the options say less. */
137
+ export async function fetchPage(raw, opts = {}) {
138
+ const limits = { maxBytes: Math.min(opts.maxBytes ?? MAX_PAGE, MAX_PAGE), timeoutMs: Math.min(opts.timeoutMs ?? PAGE_MS, PAGE_MS), maxRedirects: opts.maxRedirects ?? DEFAULTS.maxRedirects };
139
+ const got = await guardedFetch(raw.trim(), "page", limits, opts.allowed ?? defaultAllowed);
140
+ if (!got.ok)
141
+ return got;
142
+ return { ok: true, text: got.bytes.toString("utf8"), url: got.url.toString() };
143
+ }
144
+ /** The title arXiv gives a paper (by its identifier, `arxivId`): its abstract page's `citation_title`,
145
+ * the page fetched from `opts.arxivOrigin`. Null where the page cannot be fetched or has none; it
146
+ * never throws, since a paper without it is still added. */
147
+ export async function arxivTitle(id, opts = {}) {
148
+ try {
149
+ const page = await fetchPage(`${(opts.arxivOrigin ?? ARXIV).replace(/\/+$/, "")}/abs/${id}`, opts);
150
+ return page.ok ? citationTitle(page.text) : null;
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ /** A page's `<meta name="citation_title" content="…">`, as arXiv's abstract pages carry it (and most
157
+ * publishers', for the indexers): its content with HTML's character references decoded and its
158
+ * whitespace made single. Null where there is none, or it is empty. */
159
+ export function citationTitle(html) {
160
+ // a tag's quoted values may hold a ">" of their own
161
+ for (const [tag] of html.matchAll(/<meta\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi)) {
162
+ const attrs = new Map();
163
+ for (const a of tag.matchAll(/([^\s=<>"'\/]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g))
164
+ attrs.set(a[1].toLowerCase(), a[2] ?? a[3] ?? a[4]);
165
+ if (attrs.get("name")?.trim().toLowerCase() !== "citation_title")
166
+ continue;
167
+ const t = decodeHtml(attrs.get("content") ?? "").replace(/[\u0000-\u001f\u007f\s]+/g, " ").trim();
168
+ if (t)
169
+ return t;
170
+ }
171
+ return null;
172
+ }
173
+ const HTML_ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
174
+ /** HTML's character references decoded: by number, and the few names a template escapes with; any
175
+ * other name left as it is, and a number that is no character read as U+FFFD. */
176
+ function decodeHtml(s) {
177
+ return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (all, e) => {
178
+ if (e[0] !== "#")
179
+ return HTML_ENTITIES[e.toLowerCase()] ?? all;
180
+ const n = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : Number(e.slice(1));
181
+ return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : "\ufffd";
182
+ });
183
+ }
184
+ /** The fetch both kinds share: every address checked, the connection pinned to the address checked,
185
+ * redirects followed by hand, within the bytes and the time given. The bytes and where they came
186
+ * from in the end, or why not. */
187
+ async function guardedFetch(raw, kind, o, allowed) {
188
+ const { accept, noun, most } = KINDS[kind];
189
+ const deadline = Date.now() + o.timeoutMs;
190
+ let url;
191
+ try {
192
+ url = new URL(raw);
193
+ }
194
+ catch {
195
+ return { ok: false, status: 400, error: "That is not an address: give one that starts with http:// or https://." };
196
+ }
197
+ for (let hops = 0;; hops++) {
198
+ if (url.protocol !== "http:" && url.protocol !== "https:")
199
+ return { ok: false, status: 400, error: `Only http and https addresses are fetched, not ${url.protocol.replace(/:$/, "")}.` };
200
+ if (url.username || url.password)
201
+ return { ok: false, status: 400, error: "An address with a name and password in it is not fetched." };
202
+ const host = url.hostname.replace(/^\[|\]$/g, "");
203
+ let addresses;
204
+ try {
205
+ addresses = isIP(host) ? [{ address: host, family: isIP(host) }] : await lookup(host, { all: true, verbatim: true });
206
+ }
207
+ catch {
208
+ return { ok: false, status: 400, error: `${host} cannot be found.` };
209
+ }
210
+ if (!addresses.length)
211
+ return { ok: false, status: 400, error: `${host} cannot be found.` };
212
+ const refused = addresses.find((a) => !allowed(a.address));
213
+ if (refused)
214
+ return { ok: false, status: 403, error: `${host} is ${refused.address}, an address of a private or local network or of this server: the server does not fetch from there.` };
215
+ const left = deadline - Date.now();
216
+ if (left <= 0)
217
+ return { ok: false, status: 504, error: "The address took too long to answer." };
218
+ const pinned = addresses[0];
219
+ let res;
220
+ try {
221
+ res = await connect(url, pinned, left, accept);
222
+ }
223
+ catch (e) {
224
+ return { ok: false, status: 502, error: `The address could not be fetched: ${e instanceof Error ? e.message : String(e)}.` };
225
+ }
226
+ const status = res.statusCode ?? 0;
227
+ if ([301, 302, 303, 307, 308].includes(status)) {
228
+ res.resume();
229
+ const to = res.headers.location;
230
+ if (!to)
231
+ return { ok: false, status: 502, error: `The address answered ${status} without saying where to.` };
232
+ if (hops >= o.maxRedirects)
233
+ return { ok: false, status: 502, error: `The address redirected more than ${o.maxRedirects} times.` };
234
+ try {
235
+ url = new URL(to, url);
236
+ }
237
+ catch {
238
+ return { ok: false, status: 502, error: "The address redirected to something that is not an address." };
239
+ }
240
+ continue;
241
+ }
242
+ if (status !== 200) {
243
+ res.resume();
244
+ return { ok: false, status: 502, error: `The address answered ${status}, not the ${noun}.` };
245
+ }
246
+ const length = Number(res.headers["content-length"] ?? NaN);
247
+ if (Number.isFinite(length) && length > o.maxBytes) {
248
+ res.destroy();
249
+ return { ok: false, status: 413, error: `The ${noun} is ${Math.round(length / 1e6)} MB: ${most} is at most ${Math.round(o.maxBytes / 1e6)} MB.` };
250
+ }
251
+ const body = await readCapped(res, o.maxBytes, deadline);
252
+ if (typeof body === "string")
253
+ return { ok: false, status: body === "large" ? 413 : 504, error: body === "large" ? `The ${noun} is larger than ${Math.round(o.maxBytes / 1e6)} MB, the most ${most} may be.` : `The address took too long to send the ${noun}.` };
254
+ const disposition = res.headers["content-disposition"];
255
+ return { ok: true, bytes: body, url, disposition };
256
+ }
257
+ }
258
+ /** A request to `url`, connected to the address checked for it and to no other; its answer's head. */
259
+ function connect(url, at, ms, accept) {
260
+ return new Promise((ok, no) => {
261
+ const make = url.protocol === "https:" ? httpsRequest : httpRequest;
262
+ const req = make(url, {
263
+ method: "GET",
264
+ headers: { "user-agent": "VisualProof (adds a file to a project's library)", accept },
265
+ // the connection goes to the address checked, whatever the name resolves to now; the
266
+ // name stays the request's for TLS and the Host header
267
+ lookup: ((_host, options, cb) => {
268
+ if (options?.all)
269
+ cb(null, [{ address: at.address, family: at.family }]);
270
+ else
271
+ cb(null, at.address, at.family);
272
+ }),
273
+ timeout: ms,
274
+ }, ok);
275
+ req.on("timeout", () => req.destroy(new Error("no answer in time")));
276
+ req.on("error", no);
277
+ req.end();
278
+ });
279
+ }
280
+ /** An answer's body, at most `max` bytes, before `deadline`: the bytes, or why not. */
281
+ function readCapped(res, max, deadline) {
282
+ return new Promise((ok) => {
283
+ const parts = [];
284
+ let n = 0;
285
+ const timer = setTimeout(() => {
286
+ res.destroy();
287
+ ok("slow");
288
+ }, Math.max(0, deadline - Date.now()));
289
+ res.on("data", (b) => {
290
+ n += b.length;
291
+ if (n > max) {
292
+ clearTimeout(timer);
293
+ res.destroy();
294
+ return ok("large");
295
+ }
296
+ parts.push(b);
297
+ });
298
+ res.on("end", () => {
299
+ clearTimeout(timer);
300
+ ok(Buffer.concat(parts));
301
+ });
302
+ res.on("error", () => {
303
+ clearTimeout(timer);
304
+ ok("slow");
305
+ });
306
+ });
307
+ }
308
+ /** The name an answer gives its file, else the address's last part, else its host. */
309
+ function nameOf(disposition, url) {
310
+ const star = disposition ? /filename\*\s*=\s*(?:UTF-8'')?([^;]+)/i.exec(disposition)?.[1] : undefined;
311
+ const plain = disposition ? /filename\s*=\s*"?([^";]+)"?/i.exec(disposition)?.[1] : undefined;
312
+ let name = star ?? plain ?? url.pathname.split("/").filter(Boolean).pop() ?? url.hostname;
313
+ try {
314
+ name = decodeURIComponent(name);
315
+ }
316
+ catch {
317
+ // as it is
318
+ }
319
+ name = name.replace(/[\u0000-\u001f\u007f]/g, "").trim();
320
+ return name ? name.slice(0, 300) : null;
321
+ }
@@ -0,0 +1,158 @@
1
+ // The figures of an exported document (the owner's decisions of 2026-10-12): the images and the PDF
2
+ // clips the notes it prints carry. An image comes from the library's bytes, a PNG or a JPEG as it is
3
+ // — what XeLaTeX reads — and a WebP or a GIF turned into a PNG by ImageMagick where the server has it
4
+ // (its first frame, for a GIF that moves), and so is a PNG of 16 bits a channel, which XeLaTeX's PDF
5
+ // driver draws blank where it has transparency (TeX Live 2023's xdvipdfmx, seen on a whiteboard
6
+ // photo); a clip from the server's clip renderer (server/poppler.ts, the one GET /api/library/clip
7
+ // answers from, with its cache) at print resolution. What cannot be drawn — a file whose bytes this
8
+ // server lacks, an image no tool here can turn into a PNG — prints as a reference instead, and the
9
+ // export's notes say so.
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { closeSync, mkdtempSync, openSync, readFileSync, readSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { figureAssets } from "../shared/doc/latex.js";
15
+ import { fileOf } from "../shared/library.js";
16
+ /** how long turning one image into a PNG may take */
17
+ const CONVERT_MS = 20_000;
18
+ let tool;
19
+ /** ImageMagick's command where the server has it (`magick`, else `convert`), asked once; null
20
+ * without it. */
21
+ export function imageTool() {
22
+ if (tool === undefined) {
23
+ tool = null;
24
+ for (const t of ["magick", "convert"]) {
25
+ const r = spawnSync(t, ["-version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 });
26
+ if (r.status === 0 && /ImageMagick/i.test(r.stdout?.toString() ?? "")) {
27
+ tool = t;
28
+ break;
29
+ }
30
+ }
31
+ }
32
+ return tool;
33
+ }
34
+ /** Whether the PNG at `path` has 16 bits a channel (its header's bit depth). */
35
+ function deepPng(path) {
36
+ try {
37
+ const fd = openSync(path, "r");
38
+ try {
39
+ const head = Buffer.alloc(25);
40
+ readSync(fd, head, 0, 25, 0);
41
+ return head.toString("latin1", 12, 16) === "IHDR" && head[24] === 16;
42
+ }
43
+ finally {
44
+ closeSync(fd);
45
+ }
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ /** Whether an image needs turning into a PNG before XeLaTeX reads it: a WebP, a GIF, a PNG of 16 bits
52
+ * a channel. */
53
+ const needsTurning = (path, type) => type === "webp" || type === "gif" || (type === "png" && deepPng(path));
54
+ /** Whether the image `sha` of the library (`path`), of `type`, can be drawn into a document here. */
55
+ export function drawable(path, type) {
56
+ if (type !== "png" && type !== "jpeg" && type !== "webp" && type !== "gif")
57
+ return false;
58
+ return !needsTurning(path, type) || imageTool() !== null;
59
+ }
60
+ /** What the builder needs to know of the library's files for the figures of the notes `carried`
61
+ * (every note a document may print): whether the server has each file, whether an image can be
62
+ * drawn, and the size of each page a clip is of, read with `pdfinfo` (cached per file) before the
63
+ * document is built. */
64
+ export async function docFiles(library, carried) {
65
+ const sizes = new Map();
66
+ if (library) {
67
+ const pdfs = new Set();
68
+ for (const cs of carried)
69
+ for (const c of cs ?? [])
70
+ if (c.region && library.has(c.file))
71
+ pdfs.add(c.file);
72
+ await Promise.all([...pdfs].map(async (sha) => {
73
+ try {
74
+ sizes.set(sha, (await library.pdf.info(sha, library.path(sha))).sizes);
75
+ }
76
+ catch {
77
+ // a PDF poppler cannot read: its clips print at the width of the text, or fail as figures
78
+ }
79
+ }));
80
+ }
81
+ return {
82
+ has: (sha) => library?.has(sha) ?? false,
83
+ drawable: (sha, type) => !!library?.has(sha) && drawable(library.path(sha), type),
84
+ pageSize: (sha, page) => sizes.get(sha)?.[page - 1] ?? null,
85
+ };
86
+ }
87
+ /** The figures document `doc` prints, each as the file the .tex names (shared/doc/latex.ts,
88
+ * figureAssets), made from the library when it is typeset. */
89
+ export function figureFiles(doc, library) {
90
+ return figureAssets(doc).map((a) => ({
91
+ name: a.name,
92
+ make: async () => {
93
+ if (!library?.has(a.file))
94
+ throw new Error("this server does not have the file");
95
+ const path = library.path(a.file);
96
+ if (a.region && a.page) {
97
+ const info = await library.pdf.info(a.file, path);
98
+ return library.pdf.clip(a.file, path, a.page, info.sizes[a.page - 1] ?? { w: 612, h: 792 }, a.region, a.px ?? 2000);
99
+ }
100
+ return needsTurning(path, a.type) ? toPng(path, a.type) : readFileSync(path);
101
+ },
102
+ }));
103
+ }
104
+ /** An image of the library turned into a PNG of 8 bits a channel by ImageMagick: read as the type
105
+ * the library found in its bytes, never as what its content might claim (`webp:`, `gif:`, `png:`),
106
+ * its first frame alone. */
107
+ async function toPng(path, type) {
108
+ const t = imageTool();
109
+ if (!t || (type !== "webp" && type !== "gif" && type !== "png"))
110
+ throw new Error(`a ${type} image cannot be drawn here`);
111
+ const dir = mkdtempSync(join(tmpdir(), "vp-fig-"));
112
+ const out = join(dir, "out.png");
113
+ try {
114
+ await new Promise((ok, no) => {
115
+ const p = spawn(t, ["-limit", "memory", "256MiB", "-limit", "disk", "1GiB", `${type}:${path}[0]`, "-depth", "8", `png:${out}`], { stdio: ["ignore", "ignore", "ignore"] });
116
+ const timer = setTimeout(() => p.kill("SIGKILL"), CONVERT_MS);
117
+ p.on("error", (e) => {
118
+ clearTimeout(timer);
119
+ no(e);
120
+ });
121
+ p.on("close", (code) => {
122
+ clearTimeout(timer);
123
+ if (code === 0)
124
+ ok();
125
+ else
126
+ no(new Error(`ImageMagick could not read the ${type} image`));
127
+ });
128
+ });
129
+ return readFileSync(out);
130
+ }
131
+ finally {
132
+ rmSync(dir, { recursive: true, force: true });
133
+ }
134
+ }
135
+ /** What an export's notes say of the images and clips the notes it prints carry (`notes`, those its
136
+ * view shows, every one of which it prints) that print as references rather than figures: those
137
+ * whose file this server lacks, and the images it cannot draw. */
138
+ export function figureNotes(notes, d, files) {
139
+ const lacks = new Set();
140
+ const cannot = new Set();
141
+ for (const { note } of notes)
142
+ for (const c of note.carries ?? []) {
143
+ const f = fileOf(d, c.file);
144
+ // a whole PDF prints as a reference anyway
145
+ if (!f || (f.type === "pdf" && !c.region))
146
+ continue;
147
+ if (!files.has(f.sha))
148
+ lacks.add(f.title);
149
+ else if (f.type !== "pdf" && !files.drawable(f.sha, f.type))
150
+ cannot.add(f.title);
151
+ }
152
+ const out = [];
153
+ if (lacks.size)
154
+ out.push(`This server does not have the file${lacks.size === 1 ? "" : "s"} of ${[...lacks].join(", ")}: the images and clips the notes carry of ${lacks.size === 1 ? "it are" : "them are"} printed as references, not as figures.`);
155
+ if (cannot.size)
156
+ out.push(`${[...cannot].join(", ")} cannot be drawn into the document here (turning ${cannot.size === 1 ? "it" : "them"} into a PNG needs ImageMagick): printed as a reference, not as a figure.`);
157
+ return out;
158
+ }