@hraness/kb 0.17.1

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 (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,618 @@
1
+ import { sanitizeTerminalText } from "../clip/terminal.js";
2
+ import type {
3
+ PdfBounds,
4
+ PdfDocumentMetadata,
5
+ PdfImageCandidate,
6
+ PdfImageInterpretation,
7
+ PdfImageSemanticMetadata,
8
+ PdfPageLayout,
9
+ PdfTextFragment,
10
+ } from "./model.js";
11
+
12
+ const MAX_XML_ATTRIBUTE_CODE_UNITS = 64 * 1024;
13
+ const MAX_XML_TEXT_CODE_UNITS = 2 * 1024 * 1024;
14
+
15
+ type FontSpec = {
16
+ readonly size: number;
17
+ };
18
+
19
+ export type ParsedPdfImage = PdfBounds & {
20
+ readonly page: number;
21
+ readonly sourcePath: string;
22
+ };
23
+
24
+ export type ParsedPdfPage = {
25
+ readonly page: number;
26
+ readonly width: number;
27
+ readonly height: number;
28
+ readonly text: readonly PdfTextFragment[];
29
+ readonly images: readonly ParsedPdfImage[];
30
+ };
31
+
32
+ export type ParsedPopplerXml = {
33
+ readonly pages: readonly ParsedPdfPage[];
34
+ readonly popplerVersion: string | null;
35
+ readonly truncated: boolean;
36
+ };
37
+
38
+ export type PdfLayoutBlock =
39
+ | {
40
+ readonly kind: "heading";
41
+ readonly page: number;
42
+ readonly level: number;
43
+ readonly text: string;
44
+ }
45
+ | {
46
+ readonly kind: "paragraph";
47
+ readonly page: number;
48
+ readonly text: string;
49
+ }
50
+ | {
51
+ readonly kind: "list-item";
52
+ readonly page: number;
53
+ readonly text: string;
54
+ }
55
+ | {
56
+ readonly kind: "image";
57
+ readonly page: number;
58
+ readonly image: PdfImageCandidate;
59
+ };
60
+
61
+ type TextLine = PdfBounds & {
62
+ readonly page: number;
63
+ readonly text: string;
64
+ readonly fontSize: number;
65
+ readonly boldRatio: number;
66
+ };
67
+
68
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
69
+ typeof value === "object" && value !== null && !Array.isArray(value);
70
+
71
+ function bounded(value: string, maximum: number): string {
72
+ if (value.length <= maximum) return value;
73
+ let end = maximum - 1;
74
+ const final = value.charCodeAt(end - 1);
75
+ if (final >= 0xd800 && final <= 0xdbff) end -= 1;
76
+ return `${value.slice(0, Math.max(0, end))}…`;
77
+ }
78
+
79
+ function decodeXmlEntity(entity: string): string {
80
+ const named: Readonly<Record<string, string>> = {
81
+ amp: "&",
82
+ apos: "'",
83
+ gt: ">",
84
+ lt: "<",
85
+ quot: '"',
86
+ };
87
+ const numeric = /^#(?:x([0-9a-f]+)|(\d+))$/iu.exec(entity);
88
+ if (numeric !== null) {
89
+ const value = Number.parseInt(numeric[1] ?? numeric[2] ?? "", numeric[1] === undefined ? 10 : 16);
90
+ if (
91
+ Number.isSafeInteger(value)
92
+ && value > 0
93
+ && value <= 0x10ffff
94
+ && !(value >= 0xd800 && value <= 0xdfff)
95
+ ) return String.fromCodePoint(value);
96
+ return "\uFFFD";
97
+ }
98
+ return named[entity] ?? `&${entity};`;
99
+ }
100
+
101
+ export function decodePopplerText(value: string): string {
102
+ const withoutMarkup = value.replace(/<[^>]{0,65536}>/gu, "");
103
+ const decoded = withoutMarkup.replace(/&([a-z]+|#x[0-9a-f]+|#\d+);/giu, (_match, entity: string) =>
104
+ decodeXmlEntity(entity.toLowerCase()));
105
+ return sanitizeTerminalText(decoded)
106
+ .replace(/[fiflffffiffl]/gu, (ligature) => ({
107
+ "fi": "fi",
108
+ "fl": "fl",
109
+ "ff": "ff",
110
+ "ffi": "ffi",
111
+ "ffl": "ffl",
112
+ })[ligature] ?? ligature)
113
+ .normalize("NFC");
114
+ }
115
+
116
+ function attributes(value: string): Readonly<Record<string, string>> {
117
+ const result: Record<string, string> = {};
118
+ const pattern = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/gu;
119
+ for (const match of value.matchAll(pattern)) {
120
+ const key = match[1];
121
+ const raw = match[2];
122
+ if (key === undefined || raw === undefined || raw.length > MAX_XML_ATTRIBUTE_CODE_UNITS) continue;
123
+ result[key] = decodePopplerText(raw);
124
+ }
125
+ return result;
126
+ }
127
+
128
+ function nonNegativeNumber(value: string | undefined, label: string): number {
129
+ const parsed = Number(value);
130
+ if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100_000_000) {
131
+ throw new Error(`Poppler XML contains an invalid ${label}`);
132
+ }
133
+ return parsed;
134
+ }
135
+
136
+ function positiveInteger(value: string | undefined, label: string): number {
137
+ const parsed = Number(value);
138
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 1_000_000) {
139
+ throw new Error(`Poppler XML contains an invalid ${label}`);
140
+ }
141
+ return parsed;
142
+ }
143
+
144
+ /**
145
+ * Parse the small, line-oriented XML vocabulary emitted by `pdftohtml -xml`.
146
+ * Foreign PDF text is never interpreted as XML markup beyond Poppler's fixed
147
+ * page, fontspec, text, and image records.
148
+ */
149
+ export function parsePopplerXml(
150
+ xml: string,
151
+ limits: {
152
+ readonly maxPages: number;
153
+ readonly maxImages: number;
154
+ readonly maxTextFragments?: number;
155
+ },
156
+ ): ParsedPopplerXml {
157
+ const maxTextFragments = Math.max(
158
+ 1,
159
+ Math.min(limits.maxTextFragments ?? 500_000, 2_000_000),
160
+ );
161
+ const root = /<pdf2xml\b([^>]*)>/u.exec(xml);
162
+ const popplerVersion = root === null ? null : (attributes(root[1] ?? "").version ?? null);
163
+ const tokenPattern = /<page\b[^>]*>|<\/page>|<fontspec\b[^>]*\/>|<image\b[^>]*\/>|<text\b[^>]*>[\s\S]*?<\/text>/gu;
164
+ const fonts = new Map<string, FontSpec>();
165
+ const pages: ParsedPdfPage[] = [];
166
+ let current: {
167
+ page: number;
168
+ width: number;
169
+ height: number;
170
+ text: PdfTextFragment[];
171
+ images: ParsedPdfImage[];
172
+ } | null = null;
173
+ let imageCount = 0;
174
+ let textCount = 0;
175
+ let truncated = false;
176
+
177
+ for (const tokenMatch of xml.matchAll(tokenPattern)) {
178
+ const token = tokenMatch[0];
179
+ if (token.startsWith("<page")) {
180
+ if (current !== null) throw new Error("Poppler XML opened a page before closing the previous page");
181
+ if (pages.length >= limits.maxPages) {
182
+ truncated = true;
183
+ continue;
184
+ }
185
+ const parsed = attributes(token);
186
+ current = {
187
+ page: positiveInteger(parsed.number, "page number"),
188
+ width: nonNegativeNumber(parsed.width, "page width"),
189
+ height: nonNegativeNumber(parsed.height, "page height"),
190
+ text: [],
191
+ images: [],
192
+ };
193
+ continue;
194
+ }
195
+ if (token === "</page>") {
196
+ if (current === null) continue;
197
+ pages.push(current);
198
+ current = null;
199
+ continue;
200
+ }
201
+ if (current === null) continue;
202
+ if (token.startsWith("<fontspec")) {
203
+ const parsed = attributes(token);
204
+ const id = parsed.id;
205
+ if (id !== undefined && id.length <= 256) {
206
+ fonts.set(id, { size: nonNegativeNumber(parsed.size, "font size") });
207
+ }
208
+ continue;
209
+ }
210
+ if (token.startsWith("<image")) {
211
+ if (imageCount >= limits.maxImages) {
212
+ truncated = true;
213
+ continue;
214
+ }
215
+ const parsed = attributes(token);
216
+ const sourcePath = parsed.src;
217
+ if (sourcePath === undefined || sourcePath === "") continue;
218
+ current.images.push({
219
+ page: current.page,
220
+ top: nonNegativeNumber(parsed.top, "image top"),
221
+ left: nonNegativeNumber(parsed.left, "image left"),
222
+ width: nonNegativeNumber(parsed.width, "image width"),
223
+ height: nonNegativeNumber(parsed.height, "image height"),
224
+ sourcePath,
225
+ });
226
+ imageCount += 1;
227
+ continue;
228
+ }
229
+ if (textCount >= maxTextFragments) {
230
+ truncated = true;
231
+ continue;
232
+ }
233
+ const openEnd = token.indexOf(">");
234
+ const closeStart = token.lastIndexOf("</text>");
235
+ if (openEnd < 0 || closeStart <= openEnd) continue;
236
+ const parsed = attributes(token.slice(0, openEnd + 1));
237
+ const fontId = parsed.font ?? "";
238
+ const inner = token.slice(openEnd + 1, closeStart);
239
+ if (inner.length > MAX_XML_TEXT_CODE_UNITS) {
240
+ truncated = true;
241
+ continue;
242
+ }
243
+ const text = bounded(decodePopplerText(inner), MAX_XML_TEXT_CODE_UNITS);
244
+ current.text.push({
245
+ top: nonNegativeNumber(parsed.top, "text top"),
246
+ left: nonNegativeNumber(parsed.left, "text left"),
247
+ width: nonNegativeNumber(parsed.width, "text width"),
248
+ height: nonNegativeNumber(parsed.height, "text height"),
249
+ text,
250
+ fontId,
251
+ fontSize: fonts.get(fontId)?.size ?? nonNegativeNumber(parsed.height, "text height"),
252
+ bold: /<b(?:\s[^>]*)?>/iu.test(inner),
253
+ italic: /<i(?:\s[^>]*)?>/iu.test(inner),
254
+ });
255
+ textCount += 1;
256
+ }
257
+ if (current !== null) throw new Error("Poppler XML ended before the current page was closed");
258
+ if (pages.length === 0) throw new Error("Poppler XML contained no pages");
259
+ return { pages, popplerVersion, truncated };
260
+ }
261
+
262
+ function normalizedInfoKey(value: string): string {
263
+ return value.trim().toLowerCase().replace(/\s+/gu, " ");
264
+ }
265
+
266
+ function boundedMetadata(value: string | undefined, maximum: number): string | null {
267
+ const normalized = value?.trim();
268
+ return normalized === undefined || normalized === ""
269
+ ? null
270
+ : bounded(sanitizeTerminalText(normalized), maximum);
271
+ }
272
+
273
+ /** Parse `pdfinfo`'s C-locale key/value output without trusting unknown fields. */
274
+ export function parsePdfInfo(value: string): PdfDocumentMetadata {
275
+ const fields = new Map<string, string>();
276
+ for (const line of value.split(/\r?\n/gu)) {
277
+ const separator = line.indexOf(":");
278
+ if (separator <= 0) continue;
279
+ const key = normalizedInfoKey(line.slice(0, separator));
280
+ if (!fields.has(key)) fields.set(key, line.slice(separator + 1).trim());
281
+ }
282
+ const pageCount = Number(fields.get("pages"));
283
+ if (!Number.isSafeInteger(pageCount) || pageCount < 1 || pageCount > 1_000_000) {
284
+ throw new Error("pdfinfo did not report a valid positive page count");
285
+ }
286
+ return {
287
+ title: boundedMetadata(fields.get("title"), 2_048),
288
+ author: boundedMetadata(fields.get("author"), 1_024),
289
+ subject: boundedMetadata(fields.get("subject"), 8_192),
290
+ keywords: boundedMetadata(fields.get("keywords"), 8_192),
291
+ creator: boundedMetadata(fields.get("creator"), 1_024),
292
+ producer: boundedMetadata(fields.get("producer"), 1_024),
293
+ createdAt: boundedMetadata(fields.get("creationdate"), 256),
294
+ modifiedAt: boundedMetadata(fields.get("moddate"), 256),
295
+ pageCount,
296
+ encrypted: /^yes\b/iu.test(fields.get("encrypted") ?? ""),
297
+ };
298
+ }
299
+
300
+ function joinFragments(fragments: readonly PdfTextFragment[]): string {
301
+ const sorted = [...fragments].sort((left, right) => left.left - right.left);
302
+ let output = "";
303
+ let previousRight = 0;
304
+ for (const fragment of sorted) {
305
+ const text = fragment.text;
306
+ if (text === "") continue;
307
+ const gap = fragment.left - previousRight;
308
+ const needsSpace = output !== ""
309
+ && !/\s$/u.test(output)
310
+ && !/^\s|^[,.;:!?)}\]]/u.test(text)
311
+ && gap > Math.max(1, fragment.fontSize * 0.08);
312
+ output += `${needsSpace ? " " : ""}${text}`;
313
+ previousRight = Math.max(previousRight, fragment.left + fragment.width);
314
+ }
315
+ return output.replace(/[ \t]+/gu, " ").trim();
316
+ }
317
+
318
+ function splitVisualLine(
319
+ page: number,
320
+ pageWidth: number,
321
+ fragments: readonly PdfTextFragment[],
322
+ ): readonly TextLine[] {
323
+ const sorted = [...fragments].sort((left, right) => left.left - right.left);
324
+ const groups: PdfTextFragment[][] = [];
325
+ let current: PdfTextFragment[] = [];
326
+ let previousRight = 0;
327
+ for (const fragment of sorted) {
328
+ const gap = fragment.left - previousRight;
329
+ if (current.length > 0 && gap > Math.max(pageWidth * 0.2, fragment.fontSize * 8)) {
330
+ groups.push(current);
331
+ current = [];
332
+ }
333
+ current.push(fragment);
334
+ previousRight = Math.max(previousRight, fragment.left + fragment.width);
335
+ }
336
+ if (current.length > 0) groups.push(current);
337
+ return groups.flatMap((group) => {
338
+ const text = joinFragments(group);
339
+ if (text === "") return [];
340
+ const characters = group.reduce((sum, fragment) => sum + Math.max(1, fragment.text.trim().length), 0);
341
+ const boldCharacters = group.reduce(
342
+ (sum, fragment) => sum + (fragment.bold ? Math.max(1, fragment.text.trim().length) : 0),
343
+ 0,
344
+ );
345
+ const fontSize = group.reduce(
346
+ (sum, fragment) => sum + fragment.fontSize * Math.max(1, fragment.text.trim().length),
347
+ 0,
348
+ ) / Math.max(1, characters);
349
+ const top = Math.min(...group.map((fragment) => fragment.top));
350
+ const left = Math.min(...group.map((fragment) => fragment.left));
351
+ const right = Math.max(...group.map((fragment) => fragment.left + fragment.width));
352
+ const bottom = Math.max(...group.map((fragment) => fragment.top + fragment.height));
353
+ return [{
354
+ page,
355
+ top,
356
+ left,
357
+ width: right - left,
358
+ height: bottom - top,
359
+ text,
360
+ fontSize,
361
+ boldRatio: boldCharacters / Math.max(1, characters),
362
+ }];
363
+ });
364
+ }
365
+
366
+ function pageLines(page: PdfPageLayout): readonly TextLine[] {
367
+ const fragments = [...page.text].sort((left, right) => left.top - right.top || left.left - right.left);
368
+ const rows: PdfTextFragment[][] = [];
369
+ for (const fragment of fragments) {
370
+ const row = rows.at(-1);
371
+ const rowTop = row === undefined ? null : Math.min(...row.map((entry) => entry.top));
372
+ const tolerance = Math.max(2, fragment.height * 0.18);
373
+ if (row === undefined || rowTop === null || Math.abs(fragment.top - rowTop) > tolerance) {
374
+ rows.push([fragment]);
375
+ } else row.push(fragment);
376
+ }
377
+ return rows.flatMap((row) => splitVisualLine(page.page, page.width, row))
378
+ .sort((left, right) => left.top - right.top || left.left - right.left);
379
+ }
380
+
381
+ function weightedBodyFontSize(lines: readonly TextLine[]): number {
382
+ const weights = new Map<number, number>();
383
+ for (const line of lines) {
384
+ const rounded = Math.round(line.fontSize * 2) / 2;
385
+ weights.set(rounded, (weights.get(rounded) ?? 0) + line.text.length);
386
+ }
387
+ let selected = 12;
388
+ let greatestWeight = -1;
389
+ for (const [size, weight] of weights) {
390
+ if (weight > greatestWeight || (weight === greatestWeight && size < selected)) {
391
+ selected = size;
392
+ greatestWeight = weight;
393
+ }
394
+ }
395
+ return selected;
396
+ }
397
+
398
+ function median(values: readonly number[], fallback: number): number {
399
+ if (values.length === 0) return fallback;
400
+ const sorted = [...values].sort((left, right) => left - right);
401
+ return sorted[Math.floor(sorted.length / 2)] ?? fallback;
402
+ }
403
+
404
+ function normalizedMarginText(value: string): string {
405
+ return value.toLowerCase().replace(/\d+/gu, "#").replace(/\s+/gu, " ").trim();
406
+ }
407
+
408
+ function repeatedMarginLines(
409
+ pages: readonly PdfPageLayout[],
410
+ linesByPage: ReadonlyMap<number, readonly TextLine[]>,
411
+ ): ReadonlySet<string> {
412
+ const occurrences = new Map<string, Set<number>>();
413
+ for (const page of pages) {
414
+ for (const line of linesByPage.get(page.page) ?? []) {
415
+ const inMargin = line.top <= page.height * 0.06
416
+ || line.top + line.height >= page.height * 0.94;
417
+ if (!inMargin || line.text.length > 160) continue;
418
+ const normalized = normalizedMarginText(line.text);
419
+ if (normalized === "") continue;
420
+ const pageNumbers = occurrences.get(normalized) ?? new Set<number>();
421
+ pageNumbers.add(page.page);
422
+ occurrences.set(normalized, pageNumbers);
423
+ }
424
+ }
425
+ const minimum = Math.max(3, Math.ceil(pages.length * 0.4));
426
+ return new Set(
427
+ [...occurrences.entries()]
428
+ .filter(([, pageNumbers]) => pageNumbers.size >= minimum)
429
+ .map(([text]) => text),
430
+ );
431
+ }
432
+
433
+ function headingLevel(fontSize: number, bodySize: number, headingSizes: readonly number[]): number {
434
+ if (fontSize <= bodySize * 1.08) return 2;
435
+ const index = headingSizes.findIndex((size) => Math.abs(size - fontSize) < 0.25);
436
+ return Math.min(6, 2 + Math.max(0, index));
437
+ }
438
+
439
+ function bulletText(value: string): string | null {
440
+ const bullet = /^(?:[•●◦▪▫‣⁃*-]|\d{1,4}[.)]|[a-zA-Z][.)])\s+(.+)$/u.exec(value);
441
+ return bullet?.[1]?.trim() ?? null;
442
+ }
443
+
444
+ /** Convert ordered Poppler geometry into conservative Markdown-oriented blocks. */
445
+ export function layoutBlocks(pages: readonly PdfPageLayout[]): readonly PdfLayoutBlock[] {
446
+ const linesByPage = new Map(pages.map((page) => [page.page, pageLines(page)]));
447
+ const allLines = [...linesByPage.values()].flat();
448
+ const bodySize = weightedBodyFontSize(allLines);
449
+ const headingSizes = [...new Set(
450
+ allLines
451
+ .filter((line) => line.fontSize > bodySize * 1.08)
452
+ .map((line) => Math.round(line.fontSize * 2) / 2),
453
+ )].sort((left, right) => right - left);
454
+ const repeatedMargins = repeatedMarginLines(pages, linesByPage);
455
+ const output: PdfLayoutBlock[] = [];
456
+
457
+ for (const page of pages) {
458
+ const lines = (linesByPage.get(page.page) ?? []).filter(
459
+ (line) => !repeatedMargins.has(normalizedMarginText(line.text)),
460
+ );
461
+ const steps = lines.slice(1).map((line, index) =>
462
+ Math.max(0, line.top - (lines[index]?.top ?? line.top)));
463
+ const normalStep = median(steps.filter((step) => step > 0), Math.max(1, bodySize * 1.4));
464
+ const events: Array<
465
+ | { readonly kind: "line"; readonly top: number; readonly left: number; readonly line: TextLine; readonly index: number }
466
+ | { readonly kind: "image"; readonly top: number; readonly left: number; readonly image: PdfImageCandidate }
467
+ > = [
468
+ ...lines.map((line, index) => ({ kind: "line" as const, top: line.top, left: line.left, line, index })),
469
+ ...page.images.map((image) => ({ kind: "image" as const, top: image.top, left: image.left, image })),
470
+ ].sort((left, right) => left.top - right.top || left.left - right.left || (
471
+ left.kind === "line" ? -1 : 1
472
+ ));
473
+
474
+ let paragraph: string[] = [];
475
+ const flushParagraph = (): void => {
476
+ if (paragraph.length === 0) return;
477
+ output.push({
478
+ kind: "paragraph",
479
+ page: page.page,
480
+ text: paragraph.join(" ").replace(/\s+/gu, " ").trim(),
481
+ });
482
+ paragraph = [];
483
+ };
484
+
485
+ for (const event of events) {
486
+ if (event.kind === "image") {
487
+ flushParagraph();
488
+ output.push({ kind: "image", page: page.page, image: event.image });
489
+ continue;
490
+ }
491
+ const { line, index } = event;
492
+ const previous = lines[index - 1];
493
+ const gapBefore = previous === undefined ? Number.POSITIVE_INFINITY : line.top - previous.top;
494
+ const fontHeading = line.fontSize > bodySize * 1.08;
495
+ const boldHeading = line.boldRatio >= 0.78
496
+ && line.text.length <= 180
497
+ && gapBefore >= normalStep * 1.45;
498
+ if (line.text.length <= 240 && (fontHeading || boldHeading)) {
499
+ flushParagraph();
500
+ output.push({
501
+ kind: "heading",
502
+ page: page.page,
503
+ level: headingLevel(line.fontSize, bodySize, headingSizes),
504
+ text: line.text,
505
+ });
506
+ continue;
507
+ }
508
+ const item = bulletText(line.text);
509
+ if (item !== null) {
510
+ flushParagraph();
511
+ output.push({ kind: "list-item", page: page.page, text: item });
512
+ continue;
513
+ }
514
+ if (previous !== undefined && gapBefore >= normalStep * 1.55) flushParagraph();
515
+ paragraph.push(line.text);
516
+ }
517
+ flushParagraph();
518
+ }
519
+ return output.filter((block) => block.kind === "image" || block.text.trim() !== "");
520
+ }
521
+
522
+ export function parsePdfImageInterpretations(value: unknown): readonly PdfImageInterpretation[] {
523
+ if (!Array.isArray(value)) throw new Error("PDF image annotations must be an array");
524
+ if (value.length > 10_000) throw new Error("PDF image annotations exceed the 10000-item limit");
525
+ const output: PdfImageInterpretation[] = [];
526
+ for (const entry of value) {
527
+ if (!isRecord(entry)) throw new Error("each PDF image annotation must be an object");
528
+ const id = typeof entry.id === "string" ? entry.id.trim() : "";
529
+ const sha256 = typeof entry.sha256 === "string" ? entry.sha256.trim().toLowerCase() : "";
530
+ if (!/^[a-z0-9][a-z0-9._:-]{0,255}$/u.test(id)) throw new Error("PDF image annotation has an invalid id");
531
+ if (!/^[0-9a-f]{64}$/u.test(sha256)) throw new Error("PDF image annotation has an invalid sha256");
532
+ if (entry.kind !== "text" && entry.kind !== "mixed" && entry.kind !== "visual") {
533
+ throw new Error("PDF image annotation kind must be text, mixed, or visual");
534
+ }
535
+ if (
536
+ entry.method !== undefined
537
+ && entry.method !== "agent"
538
+ && entry.method !== "manual"
539
+ ) {
540
+ throw new Error("PDF image annotation method must be agent or manual");
541
+ }
542
+ const method = entry.method;
543
+ let metadata: PdfImageSemanticMetadata | undefined;
544
+ if (entry.metadata !== undefined) {
545
+ if (!isRecord(entry.metadata)) throw new Error("PDF image annotation metadata must be an object");
546
+ const metadataRecord = entry.metadata;
547
+ const optional = (name: string, maximum = 2_048): string | undefined => {
548
+ const candidate = metadataRecord[name];
549
+ if (candidate === undefined) return undefined;
550
+ if (typeof candidate !== "string" || candidate.trim() === "") {
551
+ throw new Error(`PDF image annotation metadata ${name} must be a non-empty string`);
552
+ }
553
+ return bounded(sanitizeTerminalText(candidate.trim()), maximum);
554
+ };
555
+ const participantsValue = metadataRecord.participants;
556
+ let participants: readonly string[] | undefined;
557
+ if (participantsValue !== undefined) {
558
+ if (!Array.isArray(participantsValue) || participantsValue.length > 256) {
559
+ throw new Error("PDF image annotation participants must be non-empty strings");
560
+ }
561
+ const parsedParticipants: string[] = [];
562
+ for (const participant of participantsValue as readonly unknown[]) {
563
+ if (typeof participant !== "string" || participant.trim() === "") {
564
+ throw new Error("PDF image annotation participants must be non-empty strings");
565
+ }
566
+ parsedParticipants.push(bounded(sanitizeTerminalText(participant.trim()), 2_048));
567
+ }
568
+ participants = parsedParticipants;
569
+ }
570
+ const platform = optional("platform", 256);
571
+ const contentType = optional("contentType", 256);
572
+ const channel = optional("channel");
573
+ const author = optional("author");
574
+ const timestamp = optional("timestamp", 512);
575
+ metadata = {
576
+ ...(platform === undefined ? {} : { platform }),
577
+ ...(contentType === undefined ? {} : { contentType }),
578
+ ...(channel === undefined ? {} : { channel }),
579
+ ...(author === undefined ? {} : { author }),
580
+ ...(timestamp === undefined ? {} : { timestamp }),
581
+ ...(participants === undefined ? {} : { participants }),
582
+ };
583
+ }
584
+ if (entry.kind === "text" || entry.kind === "mixed") {
585
+ if (typeof entry.markdown !== "string" || entry.markdown.trim() === "") {
586
+ throw new Error("text PDF image annotations require non-empty markdown");
587
+ }
588
+ output.push({
589
+ id,
590
+ sha256,
591
+ kind: entry.kind,
592
+ markdown: bounded(sanitizeTerminalText(entry.markdown.trim()), 2 * 1024 * 1024),
593
+ ...(metadata === undefined ? {} : { metadata }),
594
+ ...(method === undefined ? {} : { method }),
595
+ });
596
+ } else {
597
+ const alt = entry.alt === undefined
598
+ ? undefined
599
+ : typeof entry.alt === "string" && entry.alt.trim() !== ""
600
+ ? bounded(sanitizeTerminalText(entry.alt.trim()), 2_048)
601
+ : (() => { throw new Error("visual PDF image annotation alt must be a non-empty string"); })();
602
+ output.push({
603
+ id,
604
+ sha256,
605
+ kind: "visual",
606
+ ...(alt === undefined ? {} : { alt }),
607
+ ...(metadata === undefined ? {} : { metadata }),
608
+ ...(method === undefined ? {} : { method }),
609
+ });
610
+ }
611
+ }
612
+ const keys = new Set<string>();
613
+ for (const entry of output) {
614
+ if (keys.has(entry.id)) throw new Error(`duplicate PDF image annotation id: ${entry.id}`);
615
+ keys.add(entry.id);
616
+ }
617
+ return output;
618
+ }