unknown-knowledge 2.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/LICENSE +202 -0
- package/NOTICE +5 -0
- package/README.md +167 -0
- package/cli/.gitkeep +0 -0
- package/cli/commands/init-copy.js +90 -0
- package/cli/commands/init.js +386 -0
- package/cli/init-copy.js +24 -0
- package/cli/init.js +24 -0
- package/cli/kit.manifest.yaml +256 -0
- package/cli/lib/copy-payload.js +334 -0
- package/cli/lib/generate-wrappers.js +162 -0
- package/package.json +36 -0
- package/payload/adapter-fixtures/README.md +53 -0
- package/payload/adapter-fixtures/html/EXPECTED.yaml +50 -0
- package/payload/adapter-fixtures/html/sample.html +38 -0
- package/payload/adapter-fixtures/md/EXPECTED.yaml +65 -0
- package/payload/adapter-fixtures/md/sample.md +32 -0
- package/payload/adapter-fixtures/pdf/EXPECTED.yaml +45 -0
- package/payload/adapter-fixtures/pdf/sample.pdf +0 -0
- package/payload/adapter-fixtures/txt/EXPECTED.yaml +31 -0
- package/payload/adapter-fixtures/txt/sample.txt +18 -0
- package/payload/docs/README.md +102 -0
- package/payload/docs/boundaries.md +60 -0
- package/payload/docs/ci-wiring.md +109 -0
- package/payload/docs/steward-guide.md +238 -0
- package/payload/engine/audit.js +34 -0
- package/payload/engine/commands/audit.js +289 -0
- package/payload/engine/commands/derive.js +334 -0
- package/payload/engine/commands/ingest.js +124 -0
- package/payload/engine/commands/log-entry.js +85 -0
- package/payload/engine/commands/phoenix.js +206 -0
- package/payload/engine/commands/preflight.js +530 -0
- package/payload/engine/commands/resolve.js +1678 -0
- package/payload/engine/commands/survey-map.js +351 -0
- package/payload/engine/commands/validate-values.js +315 -0
- package/payload/engine/commands/validate.js +1426 -0
- package/payload/engine/derive.js +34 -0
- package/payload/engine/ingest.js +34 -0
- package/payload/engine/lib/anchor-signatures.js +126 -0
- package/payload/engine/lib/boot.js +39 -0
- package/payload/engine/lib/call-numbers.js +133 -0
- package/payload/engine/lib/cli.js +147 -0
- package/payload/engine/lib/coverage.js +849 -0
- package/payload/engine/lib/decomposition.js +225 -0
- package/payload/engine/lib/derived.js +494 -0
- package/payload/engine/lib/engine-refusal.js +40 -0
- package/payload/engine/lib/exit-codes.js +14 -0
- package/payload/engine/lib/extractor-kinds.js +955 -0
- package/payload/engine/lib/format-adapters.js +802 -0
- package/payload/engine/lib/id-grammars.js +178 -0
- package/payload/engine/lib/iso-date.js +55 -0
- package/payload/engine/lib/kit-root.js +101 -0
- package/payload/engine/lib/load-stores.js +1624 -0
- package/payload/engine/lib/log-entry.js +196 -0
- package/payload/engine/lib/phoenix.js +628 -0
- package/payload/engine/lib/scoring.js +150 -0
- package/payload/engine/lib/suppressions.js +172 -0
- package/payload/engine/lib/time-verdicts.js +282 -0
- package/payload/engine/lib/usage-error.js +14 -0
- package/payload/engine/lib/validate-record.js +504 -0
- package/payload/engine/log-entry.js +34 -0
- package/payload/engine/phoenix.js +39 -0
- package/payload/engine/preflight.js +34 -0
- package/payload/engine/resolve.js +34 -0
- package/payload/engine/survey-map.js +34 -0
- package/payload/engine/validate-values.js +34 -0
- package/payload/engine/validate.js +34 -0
- package/payload/extractor-fixtures/.gitkeep +0 -0
- package/payload/extractor-fixtures/README.md +29 -0
- package/payload/extractor-fixtures/swift/strings-keys/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/strings-keys/sample.strings +15 -0
- package/payload/extractor-fixtures/swift/swift-const-array/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/swift-const-array/sample.swift +21 -0
- package/payload/extractor-fixtures/swift/swift-enum/EXPECTED.yaml +8 -0
- package/payload/extractor-fixtures/swift/swift-enum/sample.swift +30 -0
- package/payload/extractor-fixtures/swift/yaml-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/swift/yaml-keys/sample.yaml +23 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/swift/yaml-map-keys/sample.yaml +15 -0
- package/payload/extractor-fixtures/ts/dir-modules/EXPECTED.yaml +12 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/alpha.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/beta.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/gamma.widget.ts +1 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/helpers/format.ts +3 -0
- package/payload/extractor-fixtures/ts/dir-modules/sample-modules/widgets.test.ts +2 -0
- package/payload/extractor-fixtures/ts/json-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-keys/sample.json +5 -0
- package/payload/extractor-fixtures/ts/json-map-keys/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/json-map-keys/sample.json +13 -0
- package/payload/extractor-fixtures/ts/ts-const-array/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-const-array/sample.ts +13 -0
- package/payload/extractor-fixtures/ts/ts-enum/EXPECTED.yaml +7 -0
- package/payload/extractor-fixtures/ts/ts-enum/sample.ts +11 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/EXPECTED.yaml +6 -0
- package/payload/extractor-fixtures/ts/ts-object-keys/sample.tsx +23 -0
- package/payload/extractor-fixtures/ts/ts-union/EXPECTED.yaml +5 -0
- package/payload/extractor-fixtures/ts/ts-union/sample.ts +9 -0
- package/payload/hooks/pre-commit +37 -0
- package/payload/hooks/reverse-lookup +66 -0
- package/payload/package.json +3 -0
- package/payload/protocol/.gitkeep +0 -0
- package/payload/protocol/AGENTS.md +239 -0
- package/payload/protocol/derived-layer.md +174 -0
- package/payload/protocol/new-kind-pipeline.md +179 -0
- package/payload/protocol/registry-warrant.md +162 -0
- package/payload/protocol/skills/kb-build.md +303 -0
- package/payload/protocol/skills/knowledge-audit.md +183 -0
- package/payload/protocol/skills/knowledge-bootstrap.md +229 -0
- package/payload/protocol/skills/knowledge-reflect.md +397 -0
- package/payload/schemas/catalog.schema.json +32 -0
- package/payload/schemas/decision-entry.schema.json +122 -0
- package/payload/schemas/finding.schema.json +77 -0
- package/payload/schemas/gap.schema.json +52 -0
- package/payload/schemas/graduation-categories.schema.json +64 -0
- package/payload/schemas/knowledge-leaf.schema.json +194 -0
- package/payload/schemas/miss.schema.json +45 -0
- package/payload/schemas/ontology-concept.schema.json +115 -0
- package/payload/schemas/phoenix-event.schema.json +76 -0
- package/payload/schemas/registry.schema.json +57 -0
- package/payload/schemas/rules.schema.json +14 -0
- package/payload/schemas/survey-scope.schema.json +23 -0
- package/payload/templates/decisions/_catalog.yaml +7 -0
- package/payload/templates/decisions/_registries/graduation-categories.yaml +42 -0
- package/payload/templates/decisions/phoenix-event.yaml +74 -0
- package/payload/templates/decisions/reflect-mint-proposal.yaml +100 -0
- package/payload/templates/decisions/registry-minting.yaml +58 -0
- package/payload/templates/decisions/trust-graduation.yaml +120 -0
- package/payload/templates/decisions/trust-revocation.yaml +106 -0
- package/payload/templates/knowledge/_catalog.yaml +9 -0
- package/payload/templates/knowledge/_registries/anchor.yaml +42 -0
- package/payload/templates/knowledge/_registries/authority-tiers.yaml +32 -0
- package/payload/templates/knowledge/_registries/domains.yaml +43 -0
- package/payload/templates/knowledge/_registries/form.yaml +38 -0
- package/payload/templates/knowledge/_registries/jurisdictions.yaml +20 -0
- package/payload/templates/knowledge/_registries/operations.yaml +18 -0
- package/payload/templates/knowledge/_registries/stage.yaml +53 -0
- package/payload/templates/knowledge/_rules.yaml +6 -0
- package/payload/templates/new-kind/README.md +107 -0
- package/payload/templates/new-kind/descriptor.example.yaml +18 -0
- package/payload/templates/new-kind/fixture/EXPECTED.yaml +6 -0
- package/payload/templates/new-kind/fixture/demo-anchor.list +2 -0
- package/payload/templates/new-kind/fixture/sample.list +7 -0
- package/payload/templates/new-kind/parser.example.js +98 -0
- package/payload/templates/ontology/_catalog.yaml +6 -0
- package/payload/templates/ontology/_rules.yaml +6 -0
- package/payload/wrappers/cursor.mdc +15 -0
- package/payload/wrappers/pointer.md +10 -0
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format-adapter registry (UCS-1153) — every submission becomes ONE IR.
|
|
3
|
+
*
|
|
4
|
+
* The pipeline ingests documents in several shapes (md, txt, html, pdf) and
|
|
5
|
+
* must reason over all of them identically. An adapter is the seam that makes
|
|
6
|
+
* that possible: a small deterministic recipe `adapt(text|bytes) -> Block[]`
|
|
7
|
+
* that normalizes a source document into ORDERED BLOCKS WITH KINDS AND SOURCE
|
|
8
|
+
* LOCATORS, and nothing else. Everything downstream (UCS-1156's coverage map)
|
|
9
|
+
* reads the IR, never the original bytes.
|
|
10
|
+
*
|
|
11
|
+
* The contract is the extractor-kind contract, deliberately (lib/extractor-
|
|
12
|
+
* kinds.js teaches the original; the shipped adapter pairs carry the shape):
|
|
13
|
+
*
|
|
14
|
+
* 1. PURE + DETERMINISTIC — source in, blocks out. Same input bytes produce
|
|
15
|
+
* the same IR bytes, every run: no wall clock, no filesystem, no
|
|
16
|
+
* enumeration order, no randomness. Pinned by repeated-run tests.
|
|
17
|
+
* 2. LEXICAL ONLY — no client-code execution, no subprocess, no network,
|
|
18
|
+
* no eval (D-014). An adapter reads bytes and returns data. `zlib` is
|
|
19
|
+
* the one decode primitive used (pdf FlateDecode); it is stdlib
|
|
20
|
+
* decompression, not execution.
|
|
21
|
+
* 3. HARD-ERROR, NEVER A PARTIAL — a format with no adapter, or content
|
|
22
|
+
* outside an adapter's declared envelope, throws. A silent partial parse
|
|
23
|
+
* poisons what the team believes was reviewed: the coverage map would
|
|
24
|
+
* report a document as covered when half of it was never read. That is
|
|
25
|
+
* the D-005/D-012 false-all-clear failure class, and it is worse than no
|
|
26
|
+
* check at all.
|
|
27
|
+
* 4. VERSIONED — each adapter carries a VERSION surfaced in the IR
|
|
28
|
+
* (`adapter: "md@1"`), so a map's provenance names the exact recipe that
|
|
29
|
+
* produced it (D-005-style trust). Bump rules below.
|
|
30
|
+
*
|
|
31
|
+
* SCOPE: deterministic text only. Scanned pages and images are converted
|
|
32
|
+
* UPSTREAM by the agent (a probabilistic act) and resubmitted as a text
|
|
33
|
+
* artifact — this pipeline never guesses at pixels.
|
|
34
|
+
*
|
|
35
|
+
* VERSION BUMP RULES. The version is a promise about IR bytes:
|
|
36
|
+
* - BUMP when the same input would now produce different IR — a new block
|
|
37
|
+
* kind, a changed locator scheme, different splitting or ordering. A
|
|
38
|
+
* coverage map built under md@1 and one built under md@2 are not
|
|
39
|
+
* comparable, and the version is what says so.
|
|
40
|
+
* - DO NOT bump for a widened envelope that leaves previously-accepted
|
|
41
|
+
* input byte-identical (accepting a shape that used to hard-error is
|
|
42
|
+
* additive: nothing that parsed before changes).
|
|
43
|
+
* - Bumping requires updating the adapter's fixture pair in the same
|
|
44
|
+
* commit; tests pin sample -> EXPECTED.yaml, so the pair can never rot
|
|
45
|
+
* apart from the recipe.
|
|
46
|
+
*/
|
|
47
|
+
import { inflateSync } from 'node:zlib';
|
|
48
|
+
import { EngineRefusal } from './engine-refusal.js';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Both adapter failures are ANTICIPATED REFUSALS, not bugs, so both extend
|
|
52
|
+
* `EngineRefusal` (lib/engine-refusal.js): the surface reports them as a clean
|
|
53
|
+
* message with conduct and exits 2, rather than letting the harness print a
|
|
54
|
+
* stack trace. An unsupported format is a condition the engine reached on
|
|
55
|
+
* purpose — the refusal IS the feature — and a stack trace would bury the
|
|
56
|
+
* conduct the submitter needs to read.
|
|
57
|
+
*/
|
|
58
|
+
/** The submission's format has no adapter, or its content is out of envelope. */
|
|
59
|
+
export class UnsupportedFormatError extends EngineRefusal {
|
|
60
|
+
name = 'UnsupportedFormatError';
|
|
61
|
+
}
|
|
62
|
+
/** The adapter could not read a document out of the source at all. */
|
|
63
|
+
export class AdaptError extends EngineRefusal {
|
|
64
|
+
name = 'AdaptError';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The conduct an out-of-envelope submission must state (PRD §5.1). A refusal
|
|
69
|
+
* that only says "no" leaves the submitter stuck, so the error names every
|
|
70
|
+
* legitimate way forward — convert, author an adapter, or (for scanned input)
|
|
71
|
+
* convert upstream and resubmit the text. This text is a golden: tests pin it,
|
|
72
|
+
* because the conduct IS the deliverable of the hard error.
|
|
73
|
+
*/
|
|
74
|
+
export const CONDUCT = [
|
|
75
|
+
'conduct:',
|
|
76
|
+
' 1. convert to md / txt / html / pdf and resubmit',
|
|
77
|
+
' 2. or author an adapter — versioned code + fixture (sample -> expected IR),',
|
|
78
|
+
' same pattern as extractor kinds; the miss feeds the adapter backlog',
|
|
79
|
+
' 3. scanned/image content: convert upstream (probabilistic), then resubmit',
|
|
80
|
+
' the text artifact — this pipeline only ingests deterministic text',
|
|
81
|
+
].join('\n');
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The IR block kinds — the whole taxonomy, closed on purpose.
|
|
85
|
+
*
|
|
86
|
+
* These are the distinctions a coverage map needs to reason about richness:
|
|
87
|
+
* a heading anchors a section, a paragraph carries prose, a list-item is a
|
|
88
|
+
* discrete assertion, code is content no lexicon should tokenize as prose,
|
|
89
|
+
* and a table-row is tabular data. A format that cannot distinguish some of
|
|
90
|
+
* these (plain text has no headings) simply emits fewer kinds — it never
|
|
91
|
+
* invents structure it cannot see. Adding a kind is an IR change: every
|
|
92
|
+
* adapter's version bumps, because downstream consumers keyed on the old set.
|
|
93
|
+
*/
|
|
94
|
+
export const BLOCK_KINDS = Object.freeze(['heading', 'paragraph', 'list-item', 'code', 'table-row']);
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Normalize line endings without changing the line COUNT: locators are
|
|
98
|
+
* line-based and must index the source the reader will open. A lone \r
|
|
99
|
+
* (classic Mac) is a line break the reader's editor shows as one, so it
|
|
100
|
+
* counts; \r\n is one break, not two.
|
|
101
|
+
*/
|
|
102
|
+
const splitLines = (text) => text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A block, canonical shape. Keys are emitted in this fixed order so the IR
|
|
106
|
+
* serializes byte-identically regardless of construction order.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} kind one of BLOCK_KINDS
|
|
109
|
+
* @param {string} text the block's text content, already normalized
|
|
110
|
+
* @param {object} locator where in the source it came from
|
|
111
|
+
*/
|
|
112
|
+
function block(kind, text, locator) {
|
|
113
|
+
return { kind, text, locator };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Collapse a block's inline whitespace to single spaces and trim.
|
|
118
|
+
*
|
|
119
|
+
* The IR is for lexical scanning, not re-rendering: a paragraph wrapped across
|
|
120
|
+
* four source lines is ONE assertion, and a downstream phrase match must not
|
|
121
|
+
* miss it because a newline fell mid-phrase. The locator preserves where the
|
|
122
|
+
* text lives, so nothing about the source is lost — only its line wrapping,
|
|
123
|
+
* which was never content.
|
|
124
|
+
*/
|
|
125
|
+
const flatten = (text) => text.replace(/\s+/g, ' ').trim();
|
|
126
|
+
|
|
127
|
+
// ------------------------------------------------------------------ markdown
|
|
128
|
+
|
|
129
|
+
/** Closing fences, by opening marker. Fixed patterns, compiled once. */
|
|
130
|
+
const CLOSING_BACKTICK_FENCE = /^\s*`{3,}\s*$/;
|
|
131
|
+
const CLOSING_TILDE_FENCE = /^\s*~{3,}\s*$/;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `md@1` — CommonMark-subset structure: ATX headings, fenced code, list items,
|
|
135
|
+
* table rows, paragraphs. Locator: `{ line, endLine }`, 1-based inclusive,
|
|
136
|
+
* indexing the ORIGINAL source lines.
|
|
137
|
+
*
|
|
138
|
+
* Envelope (what this adapter claims to read):
|
|
139
|
+
* - ATX headings (`#`..`######`) — the `level` rides on the block.
|
|
140
|
+
* - Fenced code blocks (``` or ~~~). Content is emitted verbatim, NOT
|
|
141
|
+
* flattened: whitespace is semantic in code, and a lexicon must be able
|
|
142
|
+
* to tell code from prose. An unterminated fence is a hard error — the
|
|
143
|
+
* rest of the document would silently become code, which is exactly the
|
|
144
|
+
* kind of confident wrong parse that poisons a coverage map.
|
|
145
|
+
* - List items (`-`, `*`, `+`, or `1.`), one block each, nesting flattened:
|
|
146
|
+
* the IR records the assertion, not the outline depth.
|
|
147
|
+
* - Table rows (`| a | b |`), one block each; the delimiter row (`|---|`)
|
|
148
|
+
* is presentation and is dropped.
|
|
149
|
+
* - Everything else is a paragraph, broken on blank lines.
|
|
150
|
+
* Setext headings (underlined with === or ---) are deliberately NOT read as
|
|
151
|
+
* headings: `---` is also a thematic break and a frontmatter fence, and
|
|
152
|
+
* guessing between them is the confident-wrong-parse failure class. They
|
|
153
|
+
* parse as paragraphs — visible content, honestly labeled, never invented
|
|
154
|
+
* structure.
|
|
155
|
+
*/
|
|
156
|
+
function adaptMarkdown(text) {
|
|
157
|
+
const lines = splitLines(text);
|
|
158
|
+
const blocks = [];
|
|
159
|
+
let paragraph = null;
|
|
160
|
+
|
|
161
|
+
const flushParagraph = () => {
|
|
162
|
+
if (paragraph === null) return;
|
|
163
|
+
const body = flatten(paragraph.text);
|
|
164
|
+
// A "paragraph" of only whitespace is not content; dropping it invents
|
|
165
|
+
// nothing and hides nothing.
|
|
166
|
+
if (body !== '') blocks.push(block('paragraph', body, { line: paragraph.line, endLine: paragraph.endLine }));
|
|
167
|
+
paragraph = null;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
const at = i + 1;
|
|
173
|
+
|
|
174
|
+
const fence = /^\s*(```+|~~~+)(.*)$/.exec(line);
|
|
175
|
+
if (fence) {
|
|
176
|
+
flushParagraph();
|
|
177
|
+
// The two closing patterns are fixed, so they are built once at module
|
|
178
|
+
// load rather than recompiled for every line of every fenced block.
|
|
179
|
+
const closing = fence[1][0] === '`' ? CLOSING_BACKTICK_FENCE : CLOSING_TILDE_FENCE;
|
|
180
|
+
const body = [];
|
|
181
|
+
let j = i + 1;
|
|
182
|
+
let closed = false;
|
|
183
|
+
for (; j < lines.length; j += 1) {
|
|
184
|
+
if (closing.test(lines[j])) { closed = true; break; }
|
|
185
|
+
body.push(lines[j]);
|
|
186
|
+
}
|
|
187
|
+
if (!closed) {
|
|
188
|
+
throw new AdaptError(`md: unterminated code fence opened at line ${at} — the rest of the document would silently become code, and a partial IR poisons what the reader believes was reviewed (PRD §5.1)`);
|
|
189
|
+
}
|
|
190
|
+
blocks.push(block('code', body.join('\n'), { line: at, endLine: j + 1 }));
|
|
191
|
+
i = j;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const heading = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
196
|
+
if (heading) {
|
|
197
|
+
flushParagraph();
|
|
198
|
+
const b = block('heading', flatten(heading[2]), { line: at, endLine: at });
|
|
199
|
+
b.level = heading[1].length;
|
|
200
|
+
blocks.push(b);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (/^\s*$/.test(line)) { flushParagraph(); continue; }
|
|
205
|
+
|
|
206
|
+
const item = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
207
|
+
if (item) {
|
|
208
|
+
flushParagraph();
|
|
209
|
+
blocks.push(block('list-item', flatten(item[1]), { line: at, endLine: at }));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (/^\s*\|.*\|\s*$/.test(line)) {
|
|
214
|
+
flushParagraph();
|
|
215
|
+
// The delimiter row carries no content — it is table syntax.
|
|
216
|
+
if (!/^\s*\|[\s:|-]+\|\s*$/.test(line)) {
|
|
217
|
+
const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(flatten);
|
|
218
|
+
blocks.push(block('table-row', cells.join(' | '), { line: at, endLine: at }));
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (paragraph === null) paragraph = { text: line, line: at, endLine: at };
|
|
224
|
+
else { paragraph.text += ` ${line}`; paragraph.endLine = at; }
|
|
225
|
+
}
|
|
226
|
+
flushParagraph();
|
|
227
|
+
return blocks;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------- plain text
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* `txt@1` — blank-line-separated paragraphs, and nothing else.
|
|
234
|
+
*
|
|
235
|
+
* Plain text has no marked-up structure, so this adapter INVENTS NONE: every
|
|
236
|
+
* block is a paragraph. Reading indented lines as code, or short lines as
|
|
237
|
+
* headings, would be a guess dressed as structure, and a coverage map built on
|
|
238
|
+
* guessed headings reports sections that do not exist. Degraded structure,
|
|
239
|
+
* identical process — the map is bounded by content richness, and txt is the
|
|
240
|
+
* honest floor. Locator: `{ line, endLine }`, 1-based inclusive.
|
|
241
|
+
*/
|
|
242
|
+
function adaptText(text) {
|
|
243
|
+
const lines = splitLines(text);
|
|
244
|
+
const blocks = [];
|
|
245
|
+
let current = null;
|
|
246
|
+
const flush = () => {
|
|
247
|
+
if (current === null) return;
|
|
248
|
+
const body = flatten(current.text);
|
|
249
|
+
if (body !== '') blocks.push(block('paragraph', body, { line: current.line, endLine: current.endLine }));
|
|
250
|
+
current = null;
|
|
251
|
+
};
|
|
252
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
253
|
+
if (/^\s*$/.test(lines[i])) { flush(); continue; }
|
|
254
|
+
if (current === null) current = { text: lines[i], line: i + 1, endLine: i + 1 };
|
|
255
|
+
else { current.text += ` ${lines[i]}`; current.endLine = i + 1; }
|
|
256
|
+
}
|
|
257
|
+
flush();
|
|
258
|
+
return blocks;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ----------------------------------------------------------------------- html
|
|
262
|
+
|
|
263
|
+
/** Elements whose CONTENT is not document text — never emitted, never scanned. */
|
|
264
|
+
const HTML_SKIP = new Set(['script', 'style', 'noscript', 'template', 'svg', 'head']);
|
|
265
|
+
|
|
266
|
+
/** Tag -> block kind. Everything else is structural or inline. */
|
|
267
|
+
const HTML_BLOCK_TAGS = new Map([
|
|
268
|
+
['h1', 'heading'], ['h2', 'heading'], ['h3', 'heading'],
|
|
269
|
+
['h4', 'heading'], ['h5', 'heading'], ['h6', 'heading'],
|
|
270
|
+
['p', 'paragraph'], ['li', 'list-item'],
|
|
271
|
+
['pre', 'code'], ['tr', 'table-row'],
|
|
272
|
+
['blockquote', 'paragraph'],
|
|
273
|
+
]);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The five XML/HTML named entities plus the numeric forms — the whole set this
|
|
277
|
+
* adapter decodes. A document using any other named entity (` `,
|
|
278
|
+
* `—`) is OUT OF ENVELOPE rather than silently passed through as literal
|
|
279
|
+
* ` ` text: the block's text would not be the text a reader sees, and a
|
|
280
|
+
* lexicon matching against it would silently miss. The full HTML5 entity table
|
|
281
|
+
* is ~2200 names; shipping a partial one that quietly mis-decodes is worse
|
|
282
|
+
* than refusing, and this is a zero-dependency engine (D-022).
|
|
283
|
+
*/
|
|
284
|
+
function decodeEntities(text, context) {
|
|
285
|
+
return text.replace(/&(#\d+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (whole, body) => {
|
|
286
|
+
if (body[0] === '#') {
|
|
287
|
+
const code = body[1] === 'x' || body[1] === 'X'
|
|
288
|
+
? Number.parseInt(body.slice(2), 16)
|
|
289
|
+
: Number.parseInt(body.slice(1), 10);
|
|
290
|
+
if (!Number.isInteger(code) || code < 0 || code > 0x10FFFF) {
|
|
291
|
+
throw new UnsupportedFormatError(`html: ${context}: numeric entity "${whole}" is not a valid code point — out of the html adapter's envelope`);
|
|
292
|
+
}
|
|
293
|
+
return String.fromCodePoint(code);
|
|
294
|
+
}
|
|
295
|
+
switch (body) {
|
|
296
|
+
case 'amp': return '&';
|
|
297
|
+
case 'lt': return '<';
|
|
298
|
+
case 'gt': return '>';
|
|
299
|
+
case 'quot': return '"';
|
|
300
|
+
case 'apos': return "'";
|
|
301
|
+
default:
|
|
302
|
+
throw new UnsupportedFormatError(`html: ${context}: named entity "${whole}" is outside this adapter's envelope (& < > " ' and numeric forms only) — passing it through as literal text would make the block's text differ from what a reader sees, so a lexicon would silently miss it; convert the document or author a widened adapter (PRD §5.1)`);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* `html@1` — lexical tag walk, no DOM, no execution. Locator: `{ line,
|
|
309
|
+
* endLine }`, 1-based inclusive, from the byte offset of the element's open
|
|
310
|
+
* and close tags.
|
|
311
|
+
*
|
|
312
|
+
* Envelope: the block elements in HTML_BLOCK_TAGS, whose text content becomes
|
|
313
|
+
* one block each. Content of script/style/head is dropped entirely — it is
|
|
314
|
+
* code and metadata, never document prose, and feeding it to a lexicon would
|
|
315
|
+
* manufacture matches from CSS selectors.
|
|
316
|
+
*
|
|
317
|
+
* NESTING: CHILD KIND WINS. A block element opening inside another closes its
|
|
318
|
+
* parent, and the text belongs to the innermost element — so `<li><p>x</p></li>`
|
|
319
|
+
* emits ONE block of kind `paragraph`, not `list-item`. The block IS a
|
|
320
|
+
* paragraph; its list membership is outline structure, and the IR flattens
|
|
321
|
+
* outline structure by design (the md adapter flattens list nesting the same
|
|
322
|
+
* way). Emitting the parent's kind would mean choosing an ancestor's label for
|
|
323
|
+
* text it does not directly contain, and with arbitrary nesting there is no
|
|
324
|
+
* principled stopping point up that chain. A list item whose text is direct
|
|
325
|
+
* (`<li>x</li>`) still emits `list-item` — the common case is unaffected.
|
|
326
|
+
*
|
|
327
|
+
* D-014, restated for the format most likely to carry it: this adapter NEVER
|
|
328
|
+
* executes anything. `<script>` bodies are discarded as text; no DOM is built;
|
|
329
|
+
* no resource is fetched; a `src`/`href` is inert. The document is bytes.
|
|
330
|
+
*
|
|
331
|
+
* Out of envelope: an unclosed block element (the block's extent would be a
|
|
332
|
+
* guess), and any named entity outside the five XML ones (see decodeEntities).
|
|
333
|
+
*/
|
|
334
|
+
function adaptHtml(text) {
|
|
335
|
+
const source = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
336
|
+
|
|
337
|
+
// Newline offsets ONCE, then binary search per lookup. Rescanning from the
|
|
338
|
+
// start for every locator is O(document x blocks) — quadratic on exactly the
|
|
339
|
+
// large documents this seam exists to handle.
|
|
340
|
+
const newlines = [];
|
|
341
|
+
for (let i = source.indexOf('\n'); i !== -1; i = source.indexOf('\n', i + 1)) newlines.push(i);
|
|
342
|
+
/** 1-based line containing `offset`: 1 + how many newlines precede it. */
|
|
343
|
+
const lineAt = (offset) => {
|
|
344
|
+
let low = 0;
|
|
345
|
+
let high = newlines.length;
|
|
346
|
+
while (low < high) {
|
|
347
|
+
const mid = (low + high) >> 1;
|
|
348
|
+
if (newlines[mid] < offset) low = mid + 1;
|
|
349
|
+
else high = mid;
|
|
350
|
+
}
|
|
351
|
+
return low + 1;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const blocks = [];
|
|
355
|
+
const open = []; // stack of { tag, kind, textStart, tagStart }
|
|
356
|
+
let i = 0;
|
|
357
|
+
let skipDepth = 0;
|
|
358
|
+
let skipTag = null;
|
|
359
|
+
|
|
360
|
+
const emit = (frame, closeAt) => {
|
|
361
|
+
const raw = source.slice(frame.textStart, closeAt);
|
|
362
|
+
// Cell boundaries are content in a table row: without them "Field Meaning"
|
|
363
|
+
// reads as one phrase. The separator matches the markdown adapter's, so a
|
|
364
|
+
// table row means the same thing in the IR whatever format it arrived in.
|
|
365
|
+
const celled = frame.kind === 'table-row'
|
|
366
|
+
? raw.replace(/<\/t[dh]>\s*<t[dh][^>]*>/gi, ' | ')
|
|
367
|
+
: raw;
|
|
368
|
+
// Strip any nested inline markup: the IR carries text, not markup. The
|
|
369
|
+
// space keeps `a<br>b` from fusing into "ab"; in CODE it would instead
|
|
370
|
+
// introduce indentation the source never had, so a code block strips the
|
|
371
|
+
// tag outright and its whitespace stays byte-exact.
|
|
372
|
+
const stripped = frame.kind === 'code'
|
|
373
|
+
? celled.replace(/<[^>]*>/g, '')
|
|
374
|
+
: celled.replace(/<[^>]*>/g, ' ');
|
|
375
|
+
const openLine = lineAt(frame.tagStart);
|
|
376
|
+
const decoded = decodeEntities(stripped, `<${frame.tag}> at line ${openLine}`);
|
|
377
|
+
// Code keeps its interior whitespace — it is content — so only the
|
|
378
|
+
// surrounding blank lines the markup introduced come off.
|
|
379
|
+
const body = frame.kind === 'code' ? decoded.replace(/^\n+|\s+$/g, '') : flatten(decoded);
|
|
380
|
+
if (body === '') return;
|
|
381
|
+
const b = block(frame.kind, body, { line: openLine, endLine: lineAt(closeAt) });
|
|
382
|
+
if (frame.kind === 'heading' && /^h[1-6]$/.test(frame.tag)) b.level = Number(frame.tag[1]);
|
|
383
|
+
blocks.push(b);
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
while (i < source.length) {
|
|
387
|
+
const lt = source.indexOf('<', i);
|
|
388
|
+
if (lt === -1) break;
|
|
389
|
+
|
|
390
|
+
if (source.startsWith('<!--', lt)) {
|
|
391
|
+
const end = source.indexOf('-->', lt + 4);
|
|
392
|
+
if (end === -1) break; // an unterminated comment ends the document
|
|
393
|
+
i = end + 3;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (source.startsWith('<!', lt)) { // doctype
|
|
397
|
+
const end = source.indexOf('>', lt);
|
|
398
|
+
if (end === -1) break;
|
|
399
|
+
i = end + 1;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const gt = source.indexOf('>', lt);
|
|
404
|
+
if (gt === -1) break;
|
|
405
|
+
const inner = source.slice(lt + 1, gt);
|
|
406
|
+
const closing = inner.startsWith('/');
|
|
407
|
+
const name = /^\/?\s*([a-zA-Z][\w-]*)/.exec(inner)?.[1]?.toLowerCase();
|
|
408
|
+
if (!name) { i = gt + 1; continue; }
|
|
409
|
+
const selfClosing = inner.endsWith('/');
|
|
410
|
+
|
|
411
|
+
if (skipDepth > 0) {
|
|
412
|
+
if (name === skipTag) skipDepth += closing ? -1 : 1;
|
|
413
|
+
if (skipDepth === 0) skipTag = null;
|
|
414
|
+
i = gt + 1;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (!closing && HTML_SKIP.has(name) && !selfClosing) {
|
|
418
|
+
skipDepth = 1;
|
|
419
|
+
skipTag = name;
|
|
420
|
+
i = gt + 1;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const kind = HTML_BLOCK_TAGS.get(name);
|
|
425
|
+
if (kind && !selfClosing) {
|
|
426
|
+
if (closing) {
|
|
427
|
+
// Close the matching frame; anything opened inside it and never closed
|
|
428
|
+
// is a malformed nesting the walk cannot honestly resolve.
|
|
429
|
+
const idx = open.findLastIndex((f) => f.tag === name);
|
|
430
|
+
if (idx === -1) { i = gt + 1; continue; } // stray close: ignorable
|
|
431
|
+
emit(open[idx], lt);
|
|
432
|
+
open.length = idx;
|
|
433
|
+
} else {
|
|
434
|
+
// A block element opening inside another closes it: `<li><p>x</p></li>`
|
|
435
|
+
// must not emit the list item twice.
|
|
436
|
+
if (open.length) {
|
|
437
|
+
const parent = open[open.length - 1];
|
|
438
|
+
emit(parent, lt);
|
|
439
|
+
open.pop();
|
|
440
|
+
}
|
|
441
|
+
open.push({ tag: name, kind, textStart: gt + 1, tagStart: lt });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
i = gt + 1;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (open.length) {
|
|
448
|
+
const frame = open[open.length - 1];
|
|
449
|
+
throw new AdaptError(`html: <${frame.tag}> opened at line ${lineAt(frame.tagStart)} is never closed — the block's extent would be a guess, and a partial IR poisons what the reader believes was reviewed (PRD §5.1)`);
|
|
450
|
+
}
|
|
451
|
+
return blocks;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ------------------------------------------------------------------------ pdf
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* `pdf@1` — a minimal, honest, LEXICAL PDF text extractor.
|
|
458
|
+
*
|
|
459
|
+
* There is no PDF dependency here and there never will be one: js-yaml is the
|
|
460
|
+
* engine's only runtime dependency (D-022), and shelling out to `pdftotext`
|
|
461
|
+
* would be a subprocess — exactly what D-014 forbids. So this adapter parses
|
|
462
|
+
* the file format itself, and its envelope is deliberately NARROW: it supports
|
|
463
|
+
* only what the shipped fixture demonstrates, and everything else is a HARD
|
|
464
|
+
* ERROR that names what it hit.
|
|
465
|
+
*
|
|
466
|
+
* IN ENVELOPE:
|
|
467
|
+
* - Uncompressed content streams, and FlateDecode streams (inflated with
|
|
468
|
+
* node:zlib — stdlib DECOMPRESSION, not execution: no code from the
|
|
469
|
+
* document is ever run, and zlib cannot run any).
|
|
470
|
+
* - Text-showing operators `Tj`, `TJ`, `'`, `"` inside BT/ET blocks, in
|
|
471
|
+
* content-stream order.
|
|
472
|
+
* - Literal `(...)` strings with the standard backslash escapes, and hex
|
|
473
|
+
* `<...>` strings, in the document's default (Latin) encoding.
|
|
474
|
+
*
|
|
475
|
+
* OUT OF ENVELOPE — each a hard error naming what was encountered, because the
|
|
476
|
+
* out-of-envelope discipline applies WITHIN an adapter, not just between
|
|
477
|
+
* formats. A PDF that is 90% extractable is the most dangerous input in this
|
|
478
|
+
* whole pipeline: the coverage map would look complete.
|
|
479
|
+
* - ENCRYPTED documents (`/Encrypt`): the bytes are not the text.
|
|
480
|
+
* - Any stream filter other than FlateDecode (LZW, DCT, JBIG2, ASCII85, …).
|
|
481
|
+
* - A document with NO extractable text (the scanned-page case): scanned
|
|
482
|
+
* content is converted UPSTREAM by the agent and resubmitted as text.
|
|
483
|
+
* - Custom font encodings via /Differences: the glyph codes would not be
|
|
484
|
+
* the characters, so extracted "text" would be plausible mojibake.
|
|
485
|
+
*
|
|
486
|
+
* LOCATOR: `{ page, object }` — the 1-based page ordinal in the document's
|
|
487
|
+
* page order, and the 1-based ordinal of the text-showing operation within
|
|
488
|
+
* that page's content stream. A byte offset would be useless to a reader (a
|
|
489
|
+
* PDF is not line-addressable and the bytes may be compressed), while
|
|
490
|
+
* page + object is what a human opening the file can actually find, and it is
|
|
491
|
+
* fully deterministic: it derives from content-stream order, never from
|
|
492
|
+
* enumeration order or object numbering.
|
|
493
|
+
*/
|
|
494
|
+
function adaptPdf(bytes) {
|
|
495
|
+
const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
496
|
+
// `latin1` is a byte-preserving round trip: every byte maps to one code unit,
|
|
497
|
+
// so the structure scan below is exact on binary content.
|
|
498
|
+
const raw = buffer.toString('latin1');
|
|
499
|
+
|
|
500
|
+
if (!raw.startsWith('%PDF-')) {
|
|
501
|
+
throw new UnsupportedFormatError('pdf: the file does not begin with the %PDF- header — it is not a PDF, and guessing at its structure would be a confident wrong parse (PRD §5.1)');
|
|
502
|
+
}
|
|
503
|
+
if (/\/Encrypt\b/.test(raw)) {
|
|
504
|
+
throw new UnsupportedFormatError(`pdf: the document is ENCRYPTED (/Encrypt) — its content streams are ciphertext, so any "text" extracted from them would be noise presented as content. Out of the pdf@${ADAPTERS.pdf.version} envelope.\n${CONDUCT}`);
|
|
505
|
+
}
|
|
506
|
+
if (/\/Differences\b/.test(raw)) {
|
|
507
|
+
throw new UnsupportedFormatError(`pdf: the document declares a custom font encoding (/Differences) — glyph codes do not map to their characters, so extracted text would be plausible mojibake: exactly the confident wrong parse a coverage map must never carry. Out of the pdf@${ADAPTERS.pdf.version} envelope.\n${CONDUCT}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---- objects: `N G obj ... endobj`, scanned lexically in file order.
|
|
511
|
+
const objects = new Map();
|
|
512
|
+
const objectRe = /(\d+)\s+(\d+)\s+obj\b/g;
|
|
513
|
+
let match;
|
|
514
|
+
while ((match = objectRe.exec(raw)) !== null) {
|
|
515
|
+
const id = Number(match[1]);
|
|
516
|
+
const bodyStart = match.index + match[0].length;
|
|
517
|
+
const end = raw.indexOf('endobj', bodyStart);
|
|
518
|
+
if (end === -1) {
|
|
519
|
+
throw new AdaptError(`pdf: object ${id} is never terminated by "endobj" — the file is truncated or malformed; a partial parse would silently drop content (PRD §5.1)`);
|
|
520
|
+
}
|
|
521
|
+
// Last definition wins: an incrementally-updated PDF appends newer
|
|
522
|
+
// revisions of an object, and the later one is the live one.
|
|
523
|
+
objects.set(id, { id, body: raw.slice(bodyStart, end), start: bodyStart });
|
|
524
|
+
}
|
|
525
|
+
if (objects.size === 0) {
|
|
526
|
+
throw new AdaptError('pdf: no PDF objects found — the file is not a readable PDF document');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Inflate or pass through one stream body, refusing every other filter. */
|
|
530
|
+
const streamOf = (object) => {
|
|
531
|
+
const streamAt = object.body.indexOf('stream');
|
|
532
|
+
if (streamAt === -1) return null;
|
|
533
|
+
const filter = /\/Filter\s*(\/\w+|\[[^\]]*\])/.exec(object.body.slice(0, streamAt));
|
|
534
|
+
let start = streamAt + 'stream'.length;
|
|
535
|
+
if (raw[object.start + start] === '\r') start += 1;
|
|
536
|
+
if (raw[object.start + start] === '\n') start += 1;
|
|
537
|
+
const endAt = object.body.indexOf('endstream', start);
|
|
538
|
+
if (endAt === -1) {
|
|
539
|
+
throw new AdaptError(`pdf: object ${object.id} opens a stream that is never closed by "endstream" — malformed; a partial parse would silently drop content`);
|
|
540
|
+
}
|
|
541
|
+
const slice = buffer.subarray(object.start + start, object.start + endAt);
|
|
542
|
+
if (!filter) return slice.toString('latin1');
|
|
543
|
+
const name = filter[1].replace(/[[\]\s]/g, '');
|
|
544
|
+
if (name !== '/FlateDecode' && name !== '/Fl') {
|
|
545
|
+
throw new UnsupportedFormatError(`pdf: object ${object.id} uses the stream filter ${name} — the pdf@${ADAPTERS.pdf.version} adapter decodes uncompressed and /FlateDecode streams only. Decoding it wrongly would yield noise presented as document text.\n${CONDUCT}`);
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
return inflateSync(slice).toString('latin1');
|
|
549
|
+
} catch (error) {
|
|
550
|
+
throw new AdaptError(`pdf: object ${object.id}: FlateDecode stream could not be inflated (${error.message}) — the file is corrupt or the filter is misdeclared; a partial parse would silently drop content`);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
// ---- pages, in document order. /Type /Page objects are scanned in file
|
|
555
|
+
// order, which for a hand-authored/linearized PDF IS page order; the ordinal
|
|
556
|
+
// is the page's position in that scan, so it is stable for identical bytes.
|
|
557
|
+
const pages = [];
|
|
558
|
+
for (const object of objects.values()) {
|
|
559
|
+
if (!/\/Type\s*\/Page\b/.test(object.body)) continue;
|
|
560
|
+
const contents = /\/Contents\s+(\d+)\s+\d+\s+R/.exec(object.body);
|
|
561
|
+
if (!contents) {
|
|
562
|
+
// A page with inline or array contents is a shape this envelope does not
|
|
563
|
+
// read; refusing names it rather than emitting a page with no text.
|
|
564
|
+
throw new UnsupportedFormatError(`pdf: a /Page object references its /Contents in a form the pdf@${ADAPTERS.pdf.version} adapter does not read (a single indirect reference is the declared envelope) — refusing rather than emitting a page whose text was never read.\n${CONDUCT}`);
|
|
565
|
+
}
|
|
566
|
+
pages.push(Number(contents[1]));
|
|
567
|
+
}
|
|
568
|
+
if (pages.length === 0) {
|
|
569
|
+
throw new AdaptError('pdf: no /Type /Page objects found — the document has no pages this adapter can read');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const blocks = [];
|
|
573
|
+
for (let p = 0; p < pages.length; p += 1) {
|
|
574
|
+
const content = objects.get(pages[p]);
|
|
575
|
+
if (!content) {
|
|
576
|
+
throw new AdaptError(`pdf: page ${p + 1} references content object ${pages[p]}, which does not exist — the file is malformed`);
|
|
577
|
+
}
|
|
578
|
+
const stream = streamOf(content);
|
|
579
|
+
if (stream === null) {
|
|
580
|
+
throw new AdaptError(`pdf: page ${p + 1}'s content object ${pages[p]} carries no stream — nothing to read`);
|
|
581
|
+
}
|
|
582
|
+
for (const text of showTextOperations(stream, p + 1)) {
|
|
583
|
+
blocks.push(block('paragraph', text.value, { page: p + 1, object: text.ordinal }));
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (blocks.length === 0) {
|
|
588
|
+
throw new UnsupportedFormatError(`pdf: the document carries NO extractable text — every page is likely scanned images. This pipeline ingests deterministic text only; image conversion is upstream and probabilistic, so it is out of the pdf@${ADAPTERS.pdf.version} envelope rather than a best-effort partial.\n${CONDUCT}`);
|
|
589
|
+
}
|
|
590
|
+
return blocks;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Walk one content stream and yield its text-showing operations in order.
|
|
595
|
+
*
|
|
596
|
+
* A content stream is postfix: operands precede the operator. This walk tracks
|
|
597
|
+
* only what it needs — the most recent string operands — and recognizes the
|
|
598
|
+
* four text-showing operators. Everything else (positioning, graphics state)
|
|
599
|
+
* is skipped: it affects layout, and the IR carries text, not layout.
|
|
600
|
+
*
|
|
601
|
+
* Consecutive shows are joined into one block per `Tj`/`TJ`/`'`/`"` operation,
|
|
602
|
+
* which is the finest granularity the format offers honestly: PDF has no
|
|
603
|
+
* paragraph concept, so inventing one by clustering coordinates would be a
|
|
604
|
+
* guess. One operation, one block, one locator ordinal.
|
|
605
|
+
*/
|
|
606
|
+
function showTextOperations(stream, page) {
|
|
607
|
+
const out = [];
|
|
608
|
+
const operands = [];
|
|
609
|
+
let i = 0;
|
|
610
|
+
let ordinal = 0;
|
|
611
|
+
|
|
612
|
+
while (i < stream.length) {
|
|
613
|
+
const ch = stream[i];
|
|
614
|
+
if (ch === '%') { // comment to end of line
|
|
615
|
+
const nl = stream.indexOf('\n', i);
|
|
616
|
+
i = nl === -1 ? stream.length : nl + 1;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (/\s/.test(ch)) { i += 1; continue; }
|
|
620
|
+
|
|
621
|
+
if (ch === '(') {
|
|
622
|
+
const { value, end } = readLiteralString(stream, i, page);
|
|
623
|
+
operands.push(value);
|
|
624
|
+
i = end;
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
if (ch === '<' && stream[i + 1] !== '<') {
|
|
628
|
+
const close = stream.indexOf('>', i);
|
|
629
|
+
if (close === -1) throw new AdaptError(`pdf: page ${page}: unterminated hex string`);
|
|
630
|
+
const hex = stream.slice(i + 1, close).replace(/\s/g, '');
|
|
631
|
+
if (!/^[0-9a-fA-F]*$/.test(hex)) {
|
|
632
|
+
throw new AdaptError(`pdf: page ${page}: malformed hex string — a partial decode would present noise as text`);
|
|
633
|
+
}
|
|
634
|
+
const padded = hex.length % 2 ? `${hex}0` : hex;
|
|
635
|
+
operands.push(Buffer.from(padded, 'hex').toString('latin1'));
|
|
636
|
+
i = close + 1;
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (ch === '<' || ch === '[' || ch === ']' || ch === '{' || ch === '}') {
|
|
640
|
+
// `<<` dictionaries and array delimiters: `[` and `]` bracket a TJ array,
|
|
641
|
+
// whose string operands we have already collected in order.
|
|
642
|
+
if (ch === '<' && stream[i + 1] === '<') { i += 2; continue; }
|
|
643
|
+
i += 1;
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// A token: an operator, a name (/Foo), or a number.
|
|
648
|
+
const token = /^[^\s()<>[\]{}/%]+|^\/[^\s()<>[\]{}/%]*/.exec(stream.slice(i));
|
|
649
|
+
if (!token) { i += 1; continue; }
|
|
650
|
+
const word = token[0];
|
|
651
|
+
i += word.length;
|
|
652
|
+
|
|
653
|
+
if (word === 'Tj' || word === 'TJ' || word === "'" || word === '"') {
|
|
654
|
+
const value = flatten(decodePdfText(operands.join('')));
|
|
655
|
+
ordinal += 1;
|
|
656
|
+
if (value !== '') out.push({ value, ordinal });
|
|
657
|
+
operands.length = 0;
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
// Any other operator consumes its operands.
|
|
661
|
+
if (!word.startsWith('/') && !/^[-+.\d]/.test(word)) operands.length = 0;
|
|
662
|
+
}
|
|
663
|
+
return out;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** PDF literal string: balanced parens, backslash escapes, `\ddd` octal. */
|
|
667
|
+
function readLiteralString(stream, start, page) {
|
|
668
|
+
let depth = 0;
|
|
669
|
+
let out = '';
|
|
670
|
+
let i = start;
|
|
671
|
+
while (i < stream.length) {
|
|
672
|
+
const ch = stream[i];
|
|
673
|
+
if (ch === '\\') {
|
|
674
|
+
const next = stream[i + 1];
|
|
675
|
+
const simple = { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f', '(': '(', ')': ')', '\\': '\\' };
|
|
676
|
+
if (next in simple) { out += simple[next]; i += 2; continue; }
|
|
677
|
+
const octal = /^[0-7]{1,3}/.exec(stream.slice(i + 1));
|
|
678
|
+
if (octal) {
|
|
679
|
+
out += String.fromCharCode(Number.parseInt(octal[0], 8));
|
|
680
|
+
i += 1 + octal[0].length;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (next === '\n') { i += 2; continue; } // line continuation
|
|
684
|
+
i += 1; // stray backslash: the spec says drop it
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (ch === '(') { depth += 1; if (depth > 1) out += ch; i += 1; continue; }
|
|
688
|
+
if (ch === ')') {
|
|
689
|
+
depth -= 1;
|
|
690
|
+
if (depth === 0) return { value: out, end: i + 1 };
|
|
691
|
+
out += ch;
|
|
692
|
+
i += 1;
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
out += ch;
|
|
696
|
+
i += 1;
|
|
697
|
+
}
|
|
698
|
+
throw new AdaptError(`pdf: page ${page}: unterminated literal string — the file is malformed; a partial parse would silently drop content`);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* PDF text bytes are latin1 code units under the default encodings this
|
|
703
|
+
* envelope accepts. A byte above 0x7F that is not valid PDFDocEncoding-as-
|
|
704
|
+
* latin1 text is left as-is: it round-trips its own byte. UTF-16BE strings
|
|
705
|
+
* (BOM-prefixed, per the spec) are decoded — that spelling is unambiguous.
|
|
706
|
+
*/
|
|
707
|
+
function decodePdfText(value) {
|
|
708
|
+
if (value.charCodeAt(0) === 0xFE && value.charCodeAt(1) === 0xFF) {
|
|
709
|
+
return Buffer.from(value.slice(2), 'latin1').swap16().toString('utf16le');
|
|
710
|
+
}
|
|
711
|
+
return value;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// -------------------------------------------------------------- the registry
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Format name -> adapter. Each entry is frozen and carries:
|
|
718
|
+
* - `version` the recipe's version, surfaced in the IR as `<name>@<n>`
|
|
719
|
+
* - `extensions` the file extensions that dispatch to it
|
|
720
|
+
* - `binary` whether `adapt` receives a Buffer (pdf) or a string
|
|
721
|
+
* - `adapt` the pure recipe: source -> Block[]
|
|
722
|
+
*
|
|
723
|
+
* Clients author later adapters exactly as they author extractor kinds: a
|
|
724
|
+
* versioned module here plus a shipped sample/expected pair beside the others,
|
|
725
|
+
* pinned by a test (D-005 — only vendored, versioned, test-covered code runs).
|
|
726
|
+
*/
|
|
727
|
+
export const ADAPTERS = Object.freeze({
|
|
728
|
+
md: Object.freeze({ version: 1, extensions: ['.md', '.markdown'], binary: false, adapt: adaptMarkdown }),
|
|
729
|
+
txt: Object.freeze({ version: 1, extensions: ['.txt', '.text'], binary: false, adapt: adaptText }),
|
|
730
|
+
html: Object.freeze({ version: 1, extensions: ['.html', '.htm'], binary: false, adapt: adaptHtml }),
|
|
731
|
+
pdf: Object.freeze({ version: 1, extensions: ['.pdf'], binary: true, adapt: adaptPdf }),
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
/** The formats an out-of-envelope message offers, in a stable order. */
|
|
735
|
+
export const SUPPORTED = Object.freeze(Object.keys(ADAPTERS).sort());
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Dispatch BY FILE EXTENSION, never by content sniffing.
|
|
739
|
+
*
|
|
740
|
+
* The grammars are disjoint, and guessing one from lookalike bytes is the
|
|
741
|
+
* confident-wrong-parse failure class (the same call `strings-keys` makes in
|
|
742
|
+
* lib/extractor-kinds.js). A `.docx` renamed to `.md` should fail loudly on
|
|
743
|
+
* its content, not be silently misread — and an extension nobody adapted is
|
|
744
|
+
* the hard error this ticket exists to guarantee.
|
|
745
|
+
*
|
|
746
|
+
* @param {string} path the submission's path (only its extension is read)
|
|
747
|
+
* @returns {{ name: string, version: number, binary: boolean, adapt: Function }}
|
|
748
|
+
*/
|
|
749
|
+
export function adapterFor(path) {
|
|
750
|
+
const dot = path.lastIndexOf('.');
|
|
751
|
+
const slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
|
752
|
+
const extension = dot > slash ? path.slice(dot).toLowerCase() : '';
|
|
753
|
+
for (const [name, adapter] of Object.entries(ADAPTERS)) {
|
|
754
|
+
if (adapter.extensions.includes(extension)) return { name, ...adapter };
|
|
755
|
+
}
|
|
756
|
+
const named = extension === '' ? 'a file with no extension' : `'${extension}'`;
|
|
757
|
+
throw new UnsupportedFormatError(`no format adapter for ${named} — out-of-envelope input.\nA best-effort partial parse is never offered: it would poison what the team believes was reviewed (PRD §5.1). Adapters: ${SUPPORTED.join(', ')}.\n${CONDUCT}`);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Adapt one submission into the IR envelope.
|
|
762
|
+
*
|
|
763
|
+
* The returned object is the whole contract UCS-1156 builds on: the adapter's
|
|
764
|
+
* provenance, a content hash of the SOURCE BYTES (so identical submissions
|
|
765
|
+
* dedupe), and the ordered blocks. Deterministic in full — no timestamps, no
|
|
766
|
+
* paths beyond the caller's own, no enumeration order.
|
|
767
|
+
*
|
|
768
|
+
* @param {string} path the submission's path, for dispatch and provenance
|
|
769
|
+
* @param {Buffer} bytes the submission's bytes
|
|
770
|
+
* @returns {{ adapter: string, hash: string, blocks: object[] }}
|
|
771
|
+
*/
|
|
772
|
+
export function adapt(path, bytes) {
|
|
773
|
+
const adapter = adapterFor(path);
|
|
774
|
+
const source = adapter.binary ? bytes : bytes.toString('utf8');
|
|
775
|
+
const blocks = adapter.adapt(source);
|
|
776
|
+
if (blocks.length === 0) {
|
|
777
|
+
throw new AdaptError(`${adapter.name}: the document produced no blocks — it is empty, or its content is in a shape this adapter cannot see. An empty IR would report a reviewed document as covered, so it is a hard error (PRD §5.1).\n${CONDUCT}`);
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
adapter: `${adapter.name}@${adapter.version}`,
|
|
781
|
+
hash: `fnv1a64:${fnv1a64(bytes)}`,
|
|
782
|
+
blocks,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* FNV-1a (64-bit) over the source bytes, as 16 lowercase hex digits.
|
|
788
|
+
*
|
|
789
|
+
* A content hash, not a cryptographic one: its job is to say "these two
|
|
790
|
+
* submissions are the same bytes" so resubmissions dedupe. Hand-rolled over
|
|
791
|
+
* BigInt because the engine has no crypto budget to spend and `node:crypto`
|
|
792
|
+
* would be a heavier dependency on a hot path than this arithmetic.
|
|
793
|
+
*/
|
|
794
|
+
function fnv1a64(bytes) {
|
|
795
|
+
const prime = 0x100000001b3n;
|
|
796
|
+
const mask = 0xFFFFFFFFFFFFFFFFn;
|
|
797
|
+
let hash = 0xcbf29ce484222325n;
|
|
798
|
+
for (const byte of bytes) {
|
|
799
|
+
hash = ((hash ^ BigInt(byte)) * prime) & mask;
|
|
800
|
+
}
|
|
801
|
+
return hash.toString(16).padStart(16, '0');
|
|
802
|
+
}
|