PDF Dictionary-Text Extractor
Pull the text a PDF STORES but never draws — the AcroForm field values (/V) and annotation notes (/Contents) that a content-stream extractor skips — with no dependencies, in Node or the browser. parsePdfDict(bytes) validates the %PDF- header, walks the indirect objects, and decodes literal ( ) and hex < > strings, resolving a one-level indirect /V and pairing each value with its /T field name. The companion to ratchet-pdf-text: that reads the drawn text, this reads the dictionary text — together, every text surface in a PDF.
The honest edge
It splits its contract by scale, on purpose: document-level failure (not a PDF, bad input) THROWS like its ratchet-pdf-text twin, but a single malformed value is RECORDED as {malformed, reason} and the walk continues rather than losing the good fields after it — a malformed value is stamped, never returned as clean. It reads the TOP-LEVEL dictionary of each object: a /V or /Contents in a nested sub-dict, or a value inherited through /Kids, is not a target. It resolves an indirect /V one level only. It returns the string as WRITTEN — no /Encoding or /ToUnicode CMap mapping, correct for WinAnsi and honestly wrong for a subsetted CID font. It does not decrypt, decode /ObjStm or xref streams, or repair a broken file.
Run it
node ratchet-pdf-dict.js form.pdf
test_ratchet-pdf-dict.js (14/14: hand-built PDF byte fixtures with out-of-band oracles — field /V + /T name, annotation /Contents, the /Contents page-ref disambiguation, one-level indirect-ref resolution, hex + escaped-literal decode, malformed-value-recorded-walk-continues, the /TU opt-in flag, a widget carrying both surfaces, 2 ratchet-refusal cases)
Node / browser, no dependencies
The code — every file that ships
ratchet-pdf-dict.js354 lineson GitHub →
#!/usr/bin/env node
/* ratchet-pdf-dict.js — a pure, dependency-free, STRICT extractor of the text a
PDF carries in its OBJECT DICTIONARIES rather than its content streams: the
AcroForm field values (/V) and annotation notes (/Contents) that a content-
stream extractor deliberately skips. The companion to ratchet-pdf-text. Runs
identically in a browser and in Node (no DOM, no dependencies).
WHY THIS EXISTS. ratchet-pdf-text pulls the text a PDF *draws* — the operands
of Tj/TJ operators inside content streams. But a PDF also carries text that is
never drawn into a stream: the value a user typed into a form field lives in
that field's /V entry, and the note behind a sticky annotation lives in that
annotation's /Contents entry. Those are dictionary entries on indirect objects
that are NOT stream-adjacent, so ratchet's stream scan never sees them. This
tool walks the indirect objects and pulls exactly those two text surfaces.
Together the two tools cover every text surface in a PDF, honestly.
WHAT "RATCHET" MEANS HERE (the same posture as the twin, at the RIGHT scale).
ratchet-pdf-text extracts ONE content stream as a whole, so any malformed byte
is a thrown Error — correct for a single-shot read. This tool walks MANY
indirect objects, so it splits the contract across two levels, deliberately:
• DOCUMENT level — throws. Input that is not a Uint8Array/ArrayBuffer, or a
buffer with no `%PDF-` header, is a thrown Error. If it isn't a PDF at all,
you get told, not a guess.
• OBJECT level — records, does not throw. A single malformed value (an
unterminated string, a bad hex byte) does not abort the walk and lose the
200 good fields after it. It is emitted as an explicit `{ malformed:true,
reason }` record and the walk continues. This KEEPS ratchet's real vow —
"never hand back a guess as if it were clean text" — because a malformed
value is STAMPED malformed, never returned as clean. The string tokenizer
itself still throws on an unterminable token (you genuinely cannot know
where it ends); the walk catches that at the object boundary and records it.
THE /Contents DISAMBIGUATION (the load-bearing correctness choice). The key
`/Contents` names TWO unrelated things: an annotation's text (a string) AND a
page's content-stream pointer (an indirect reference, `12 0 R`). This tool
pulls `/Contents` ONLY when its value is a string, and skips it when the value
is a reference — so a page's content pointer is never mistaken for annotation
text. The disambiguation is by VALUE TYPE, not by trying to classify the object.
WHAT IT EXTRACTS (the whole contract — a parser that hides its scope lies):
• Field values — a /V entry whose value is a string (literal `( )` with
escapes or hex `< >`), paired with the field's /T partial name as the
record `name`. A /V given as an indirect reference to a string object is
resolved one level and reported with encoding "ref".
• Annotation text — a /Contents entry whose value is a string.
• Field labels — a /TU (user-facing) entry, ONLY when you pass
`{ labels:true }`. Off by default: the covenant is field values + notes;
labels are an opt-in surface, named here so their absence isn't a surprise.
• Records carry their provenance so nothing is hidden:
{ obj:"<num> <gen>", kind:"field"|"annotation"|"label",
key:"V"|"Contents"|"TU", name:<string|null>, text:<string>,
encoding:"literal"|"hex"|"ref" }
Malformed values are surfaced separately:
{ obj, key, malformed:true, reason:<string> }
WHAT IT DOES NOT DO (stated on purpose — see the README's "edge"):
It does not decrypt encrypted PDFs, decode object streams (/ObjStm) or
compressed cross-reference streams, or resolve field hierarchies through
/Kids (it reads /V where it sits, not inherited values). It reads the
top-level dictionary of each indirect object — a /V or /Contents buried in a
nested sub-dictionary is an honest edge, not a target. It does not map
character codes through /Encoding or /ToUnicode CMaps — it returns the string
as written, correct for the common WinAnsi case and honestly wrong for a
subsetted CID font. It does not repair a broken file.
API
parsePdfDict(bytes[, options]) -> { records: Array<Record>, text: string,
malformed: Array<Record> }
`bytes` a Uint8Array (a Node Buffer is one) or an ArrayBuffer.
`options.labels` optional bool; when true, also pull /TU field labels.
`.records` the clean text records, in object order (possibly empty).
`.text` `.records` texts joined with "\n" — the quick "give me the
dictionary text" answer.
`.malformed` the per-object malformed records (possibly empty).
THROWS an Error only on document-level failure (bad input type, no header).
Pure function of its input. Same code in a browser
(window.LoopGifts.parsePdfDict) or Node (this CLI / require()).
USAGE
node ratchet-pdf-dict.js form.pdf # prints the dictionary text
node ratchet-pdf-dict.js --records form.pdf # one provenance line per record
node ratchet-pdf-dict.js --labels form.pdf # also include /TU labels
node ratchet-pdf-dict.js --help
*/
(function (root, factory) {
var api = factory();
if (typeof module === "object" && module.exports) module.exports = api;
if (typeof window !== "undefined") {
window.LoopGifts = window.LoopGifts || {};
window.LoopGifts.parsePdfDict = api.parsePdfDict;
}
root.__ratchetPdfDict = api;
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
"use strict";
// ---- byte helpers (shared posture with ratchet-pdf-text) ----------------
function toU8(bytes) {
if (bytes instanceof Uint8Array) return bytes;
if (bytes instanceof ArrayBuffer) return new Uint8Array(bytes);
if (bytes && bytes.buffer instanceof ArrayBuffer)
return new Uint8Array(bytes.buffer, bytes.byteOffset || 0, bytes.byteLength);
throw new Error("ratchet-pdf-dict: input must be a Uint8Array or ArrayBuffer");
}
function latin1(u8, start, end) {
var s = "";
for (var i = start; i < end; i++) s += String.fromCharCode(u8[i]);
return s;
}
function isWS(c) {
return c === " " || c === "\n" || c === "\r" || c === "\t" || c === "\f" || c === "\0";
}
// ---- string tokenizer (copied verbatim from ratchet-pdf-text; the reuse
// core the RCR named — literal `( )` with escapes/octal, hex `< >`) -----
function decodeLiteral(s, i) {
// s[i] === '(' has already been consumed by the caller; parse from i.
var out = "", depth = 0, N = s.length;
for (; i < N; i++) {
var c = s[i];
if (c === "\\") {
var nx = s[i + 1];
if (nx === "n") { out += "\n"; i++; }
else if (nx === "r") { out += "\r"; i++; }
else if (nx === "t") { out += "\t"; i++; }
else if (nx === "b") { out += "\b"; i++; }
else if (nx === "f") { out += "\f"; i++; }
else if (nx === "(") { out += "("; i++; }
else if (nx === ")") { out += ")"; i++; }
else if (nx === "\\") { out += "\\"; i++; }
else if (nx >= "0" && nx <= "7") {
var oct = nx; i++;
for (var k = 0; k < 2 && s[i + 1] >= "0" && s[i + 1] <= "7"; k++) { oct += s[++i]; }
out += String.fromCharCode(parseInt(oct, 8) & 0xff);
} else if (nx === "\n") { i++; }
else if (nx === "\r") { i++; if (s[i + 1] === "\n") i++; }
else { out += nx; i++; }
} else if (c === "(") { depth++; out += c; }
else if (c === ")") {
if (depth === 0) return [out, i + 1];
depth--; out += c;
} else out += c;
}
throw new Error("ratchet-pdf-dict: unterminated literal string");
}
function decodeHex(s, i) {
// s[i] === '<' ; returns [decodedString, indexAfterClosingAngle]
var hex = "", N = s.length;
for (i = i + 1; i < N; i++) {
var c = s[i];
if (c === ">") {
if (hex.length % 2 === 1) hex += "0";
var out = "";
for (var k = 0; k < hex.length; k += 2) out += String.fromCharCode(parseInt(hex.substr(k, 2), 16));
return [out, i + 1];
}
if (/[0-9a-fA-F]/.test(c)) hex += c;
else if (/\s/.test(c)) { /* skip */ }
else throw new Error("ratchet-pdf-dict: bad character in hex string");
}
throw new Error("ratchet-pdf-dict: unterminated hex string");
}
// ---- indirect-object scan (structure-tolerant, xref-free) ---------------
// Scan for `N M obj ... endobj` blocks. We do NOT trust the xref table; we
// read each object's top-level dictionary directly. This is the new surface
// ratchet-pdf-text never walks (it only reads the dict that precedes a stream).
var OBJ_HEAD = /(\d+)\s+(\d+)\s+obj\b/g;
function collectObjects(u8) {
var full = latin1(u8, 0, u8.length);
var objs = [], map = {}, m;
OBJ_HEAD.lastIndex = 0;
while ((m = OBJ_HEAD.exec(full)) !== null) {
var num = m[1], gen = m[2];
var bodyStart = m.index + m[0].length;
var endIdx = full.indexOf("endobj", bodyStart);
var bodyEnd = endIdx < 0 ? full.length : endIdx; // tolerate a missing endobj
var body = full.slice(bodyStart, bodyEnd);
var rec = { num: num, gen: gen, id: num + " " + gen, body: body };
objs.push(rec);
map[rec.id] = rec; // last-wins on a duplicated object number (updated PDFs)
if (endIdx >= 0) OBJ_HEAD.lastIndex = endIdx + 6;
}
return { objs: objs, map: map };
}
// The top-level dictionary region of an object body: the first depth-matched
// `<< ... >>`. Restricting to this avoids matching a `/V (...)` that appears
// inside a stream's binary content or as ASCII noise after `endobj`.
function dictRegion(body) {
var start = body.indexOf("<<");
if (start < 0) return body; // some indirect objects are a bare value (e.g. a string)
var depth = 0, i = start;
for (; i < body.length - 1; i++) {
if (body[i] === "<" && body[i + 1] === "<") { depth++; i++; }
else if (body[i] === ">" && body[i + 1] === ">") { depth--; i++; if (depth === 0) return body.slice(start, i + 1); }
}
return body.slice(start); // unbalanced dict — hand back what we have; the value read is bounded
}
// Find the value token that follows `/<name>` at the TOP level of `region`.
// Returns { kind:"string"|"hex"|"ref"|"name"|"other"|"none", value, ... }.
function readKeyValue(region, key) {
// Match the key as a full token (not a prefix of a longer key like /VE).
var re = new RegExp("/" + key + "(?![A-Za-z0-9])");
var km = re.exec(region);
if (!km) return { kind: "none" };
var i = km.index + km[0].length;
var N = region.length;
while (i < N && isWS(region[i])) i++;
if (i >= N) return { kind: "none" };
var c = region[i];
if (c === "(") {
var lit = decodeLiteral(region, i + 1); // may throw -> caught by caller (object level)
return { kind: "string", value: lit[0], encoding: "literal" };
}
if (c === "<" && region[i + 1] !== "<") {
var hx = decodeHex(region, i); // may throw -> caught by caller
return { kind: "string", value: hx[0], encoding: "hex" };
}
if (c === "<" && region[i + 1] === "<") return { kind: "other" }; // value is a sub-dict
if (c === "/") return { kind: "name" };
if (c === "[") return { kind: "other" };
// number, or an indirect reference `N M R`
var rest = region.slice(i);
var refM = /^(\d+)\s+(\d+)\s+R\b/.exec(rest);
if (refM) return { kind: "ref", value: refM[1] + " " + refM[2] };
if (/^-?\d/.test(rest)) return { kind: "other" }; // a plain number
return { kind: "other" };
}
// Resolve one level: given a ref id "N M", find that object and, if its body is
// (or begins with) a bare string, decode it. Returns { value, encoding } or null.
function resolveRefString(map, id) {
var target = map[id];
if (!target) return null;
var body = target.body;
var i = 0, N = body.length;
while (i < N && isWS(body[i])) i++;
if (body[i] === "(") { var lit = decodeLiteral(body, i + 1); return { value: lit[0], encoding: "ref" }; }
if (body[i] === "<" && body[i + 1] !== "<") { var hx = decodeHex(body, i); return { value: hx[0], encoding: "ref" }; }
return null; // referenced object is not a bare string — out of one-level scope
}
// ---- top-level ----------------------------------------------------------
function parsePdfDict(bytes, options) {
options = options || {};
var u8 = toU8(bytes); // throws on bad input type (document level)
if (u8.length < 5 || latin1(u8, 0, 5) !== "%PDF-")
throw new Error("ratchet-pdf-dict: not a PDF (missing %PDF- header)");
var scan = collectObjects(u8);
var records = [], malformed = [], texts = [];
for (var o = 0; o < scan.objs.length; o++) {
var obj = scan.objs[o];
var region = dictRegion(obj.body);
// The field's partial name (/T) — the record key for a /V value.
var name = null;
try {
var t = readKeyValue(region, "T");
if (t.kind === "string") name = t.value;
} catch (e) { /* a malformed /T is not itself a text surface — leave name null */ }
// /V — field value.
pullTextKey(region, obj, "V", "field", name, scan.map, records, texts, malformed);
// /Contents — annotation text ONLY when the value is a string (a ref is a
// page's content-stream pointer; skipped by the value-type disambiguation).
pullTextKey(region, obj, "Contents", "annotation", null, scan.map, records, texts, malformed);
// /TU — field label, opt-in only.
if (options.labels)
pullTextKey(region, obj, "TU", "label", name, scan.map, records, texts, malformed);
}
return { records: records, text: texts.join("\n"), malformed: malformed };
}
function pullTextKey(region, obj, key, kind, name, map, records, texts, malformed) {
var kv;
try {
kv = readKeyValue(region, key);
} catch (e) {
malformed.push({ obj: obj.id, key: key, malformed: true, reason: e.message });
return;
}
if (kv.kind === "string") {
records.push({ obj: obj.id, kind: kind, key: key, name: name, text: kv.value, encoding: kv.encoding });
if (kv.value) texts.push(kv.value);
} else if (kv.kind === "ref") {
// Only /V resolves a ref to a string. A /Contents ref is a page content
// pointer — the disambiguation says skip it.
if (key !== "V") return;
var r;
try { r = resolveRefString(map, kv.value); }
catch (e) { malformed.push({ obj: obj.id, key: key, malformed: true, reason: e.message }); return; }
if (r) {
records.push({ obj: obj.id, kind: kind, key: key, name: name, text: r.value, encoding: r.encoding });
if (r.value) texts.push(r.value);
}
}
// name / other / none -> not a text surface; nothing recorded.
}
return { parsePdfDict: parsePdfDict };
});
// ---- CLI (Node only) ------------------------------------------------------
if (typeof require !== "undefined" && typeof module !== "undefined" && require.main === module) {
var api = (typeof globalThis !== "undefined" ? globalThis : this).__ratchetPdfDict;
var args = process.argv.slice(2);
if (!args.length || args.indexOf("--help") !== -1) {
process.stdout.write(
"ratchet-pdf-dict — strict, zero-dep PDF dictionary-text extractor (/V + annotation /Contents)\n" +
" node ratchet-pdf-dict.js form.pdf print the dictionary text\n" +
" node ratchet-pdf-dict.js --records form.pdf one provenance line per record\n" +
" node ratchet-pdf-dict.js --labels form.pdf also include /TU field labels\n" +
" node ratchet-pdf-dict.js --help\n"
);
process.exit(0);
}
var recordsMode = false, labels = false, file = null;
for (var i = 0; i < args.length; i++) {
if (args[i] === "--records") recordsMode = true;
else if (args[i] === "--labels") labels = true;
else file = args[i];
}
try {
if (!file) throw new Error("no input file");
var fs = require("fs");
var buf = fs.readFileSync(file);
var res = api.parsePdfDict(buf, { labels: labels });
if (recordsMode) {
res.records.forEach(function (r) {
process.stdout.write(
"obj " + r.obj + "\tkind=" + r.kind + "\tkey=/" + r.key +
"\tname=" + (r.name === null ? "-" : JSON.stringify(r.name)) +
"\tenc=" + r.encoding + "\ttextLen=" + r.text.length + "\n"
);
});
res.malformed.forEach(function (r) {
process.stderr.write("obj " + r.obj + "\tkey=/" + r.key + "\tMALFORMED — " + r.reason + "\n");
});
} else {
process.stdout.write(res.text + (res.text ? "\n" : ""));
}
process.exit(0);
} catch (e) {
var msg = e && e.message ? e.message : String(e);
if (msg.indexOf("ratchet-pdf-dict:") !== 0) msg = "ratchet-pdf-dict: " + msg;
process.stderr.write(msg + "\n");
process.exit(1);
}
}
test_ratchet-pdf-dict.js167 lineson GitHub →
#!/usr/bin/env node
/* test_ratchet-pdf-dict.js — known-answer battery for ratchet-pdf-dict.
The oracle is OUT OF BAND: every expected value below is a literal fact written
by hand, never the output of a second PDF parser. Each PDF fixture is assembled
from raw bytes so the expected records are known by construction. No zlib, no
dependencies — dictionary text is not compressed, so no inflater is needed.
Run: node test_ratchet-pdf-dict.js (exit 0 = all pass, nonzero = failure)
*/
"use strict";
var assert = require("assert");
var { parsePdfDict } = require("./ratchet-pdf-dict.js");
var pass = 0, fail = 0;
function ok(name, fn) {
try { fn(); pass++; console.log(" ok " + name); }
catch (e) { fail++; console.log(" FAIL " + name + " — " + e.message); }
}
function bytes(str) { return Buffer.from(str, "latin1"); }
// Wrap a set of object bodies into a minimal, header-correct PDF. Structure is
// spartan on purpose — the extractor is xref-free and scans for `N M obj`.
function pdf(objs) { return bytes("%PDF-1.7\n" + objs.join("\n") + "\n%%EOF\n"); }
// ---- 1. a text field: /V value paired with its /T name -------------------
ok("1 text field /V + /T name", function () {
var doc = pdf(["12 0 obj\n<< /FT /Tx /T (fullname) /V (John Smith) >>\nendobj"]);
var r = parsePdfDict(doc);
assert.strictEqual(r.records.length, 1); // ORACLE: exactly one
assert.strictEqual(r.records[0].kind, "field");
assert.strictEqual(r.records[0].key, "V");
assert.strictEqual(r.records[0].name, "fullname");
assert.strictEqual(r.records[0].text, "John Smith");
assert.strictEqual(r.records[0].encoding, "literal");
assert.strictEqual(r.malformed.length, 0);
});
// ---- 2. an annotation: /Contents string ----------------------------------
ok("2 annotation /Contents string", function () {
var doc = pdf(["5 0 obj\n<< /Type /Annot /Subtype /Text /Contents (A sticky note) >>\nendobj"]);
var r = parsePdfDict(doc);
assert.strictEqual(r.records.length, 1);
assert.strictEqual(r.records[0].kind, "annotation");
assert.strictEqual(r.records[0].key, "Contents");
assert.strictEqual(r.records[0].name, null);
assert.strictEqual(r.records[0].text, "A sticky note");
});
// ---- 3. the /Contents disambiguation: a PAGE content ref is NOT text ------
ok("3 page /Contents ref is skipped (not annotation text)", function () {
var doc = pdf([
"3 0 obj\n<< /Type /Page /Contents 4 0 R /MediaBox [0 0 612 792] >>\nendobj",
"4 0 obj\n<< /Length 20 >>\nstream\nBT (drawn) Tj ET\nendstream\nendobj"
]);
var r = parsePdfDict(doc);
// Neither object yields a dictionary-text record: obj 3's /Contents is a ref
// (page content pointer), obj 4 has no /V or string /Contents.
assert.strictEqual(r.records.length, 0); // ORACLE: the trap
assert.strictEqual(r.malformed.length, 0);
});
// ---- 4. an indirect /V reference is resolved one level -------------------
ok("4 indirect /V ref resolved to a string object", function () {
var doc = pdf([
"12 0 obj\n<< /FT /Tx /T (comment) /V 9 0 R >>\nendobj",
"9 0 obj\n(deferred value)\nendobj"
]);
var r = parsePdfDict(doc);
assert.strictEqual(r.records.length, 1);
assert.strictEqual(r.records[0].text, "deferred value");
assert.strictEqual(r.records[0].encoding, "ref"); // provenance is honest
assert.strictEqual(r.records[0].name, "comment");
});
// ---- 5. a hex-string value decodes ---------------------------------------
ok("5 hex-string /V value", function () {
var doc = pdf(["7 0 obj\n<< /T (code) /V <48656C6C6F> >>\nendobj"]); // "Hello"
var r = parsePdfDict(doc);
assert.strictEqual(r.records.length, 1);
assert.strictEqual(r.records[0].text, "Hello");
assert.strictEqual(r.records[0].encoding, "hex");
});
// ---- 6. literal escapes: balanced parens and \( \) ------------------------
ok("6 literal escapes decode", function () {
var doc = pdf(["8 0 obj\n<< /T (note) /V (a \\(b\\) c) >>\nendobj"]);
var r = parsePdfDict(doc);
assert.strictEqual(r.records[0].text, "a (b) c"); // ORACLE by hand
});
// ---- 7. a malformed value is RECORDED, and the walk CONTINUES -------------
ok("7 malformed /V is recorded, walk continues (two-level contract)", function () {
var doc = pdf([
"1 0 obj\n<< /T (bad) /V (unterminated string >>\nendobj", // no closing )
"2 0 obj\n<< /T (good) /V (survivor) >>\nendobj"
]);
var r = parsePdfDict(doc);
// The good field after the bad one is still extracted — the walk did NOT abort.
var texts = r.records.map(function (x) { return x.text; });
assert.ok(texts.indexOf("survivor") !== -1, "good field survived the malformed one");
assert.strictEqual(r.malformed.length, 1); // the bad one is stamped
assert.strictEqual(r.malformed[0].obj, "1 0");
assert.ok(/unterminated/.test(r.malformed[0].reason));
});
// ---- 8. NOT a PDF throws (document-level, ratchet parity) -----------------
ok("8 missing %PDF- header throws", function () {
assert.throws(function () { parsePdfDict(bytes("<< /V (x) >>")); }, /not a PDF/);
});
// ---- 9. bad input type throws --------------------------------------------
ok("9 non-buffer input throws", function () {
assert.throws(function () { parsePdfDict("a string, not bytes"); }, /Uint8Array or ArrayBuffer/);
});
// ---- 10. an empty/field-less PDF yields no records, no throw --------------
ok("10 no dictionary text -> empty records, no throw", function () {
var doc = pdf(["1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj"]);
var r = parsePdfDict(doc);
assert.strictEqual(r.records.length, 0);
assert.strictEqual(r.text, "");
});
// ---- 11. /TU labels are OFF by default, ON with {labels:true} -------------
ok("11 /TU label gated behind options.labels", function () {
var doc = pdf(["4 0 obj\n<< /T (dob) /TU (Date of birth) /V (1978) >>\nendobj"]);
var off = parsePdfDict(doc);
assert.strictEqual(off.records.length, 1); // only /V, not /TU
assert.strictEqual(off.records[0].text, "1978");
var on = parsePdfDict(doc, { labels: true });
var labels = on.records.filter(function (x) { return x.kind === "label"; });
assert.strictEqual(labels.length, 1);
assert.strictEqual(labels[0].text, "Date of birth");
assert.strictEqual(labels[0].name, "dob");
});
// ---- 12. one object with BOTH /V and /Contents yields two records ---------
ok("12 widget with /V and /Contents -> field + annotation records", function () {
var doc = pdf(["6 0 obj\n<< /Subtype /Widget /T (sig) /V (signed) /Contents (tooltip) >>\nendobj"]);
var r = parsePdfDict(doc);
var kinds = r.records.map(function (x) { return x.kind; }).sort();
assert.deepStrictEqual(kinds, ["annotation", "field"]); // both surfaces pulled
assert.strictEqual(r.records.length, 2);
});
// ---- 13. .text joins record texts in order --------------------------------
ok("13 .text joins records with newline, in object order", function () {
var doc = pdf([
"1 0 obj\n<< /T (a) /V (one) >>\nendobj",
"2 0 obj\n<< /Subtype /Text /Contents (two) >>\nendobj"
]);
var r = parsePdfDict(doc);
assert.strictEqual(r.text, "one\ntwo"); // ORACLE by construction
});
// ---- 14. /VE (a longer key) is NOT mistaken for /V ------------------------
ok("14 key match is a full token, /VE does not match /V", function () {
var doc = pdf(["1 0 obj\n<< /VE [ (a) ] /V (real) >>\nendobj"]);
var r = parsePdfDict(doc);
// Only the real /V string is pulled; /VE (an array) is not a /V match.
assert.strictEqual(r.records.length, 1);
assert.strictEqual(r.records[0].text, "real");
});
console.log("\n" + pass + " passed, " + fail + " failed");
process.exit(fail ? 1 : 0);