This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
What Text Does This PDF Store?transform← all gifts

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);
Take the whole folder → MIT Node / browser, no dependencies