Pull the figures, tables and metadata out of a PDF
You have been sent a paper, a report or a datasheet. You want Figure 3 at the resolution it was actually embedded at — not a screenshot. You want the numbers in Table 2 somewhere you can compute with them. And before you forward the file, you want to know what it says about you: PDFs quietly carry an author name, a timestamp and the name of every tool that touched them. All three are in the file already. Here is how to get them out, and the code that does it.
What you get
Drop a PDF on omniviewer.org/pdf and three tabs answer the three questions above:
- IMAGES — every embedded picture, previewed, with its real dimensions, bit depth, colour space and codec. Download one, or all of them as a
.zip. - TEXT — the words of every page, tables included, laid out from the page geometry.
- METADATA — the Info dictionary and the XMP packet the file carries, plus a cleaner that hands you back a copy with them physically removed.
The sample above is a 14-page open-access study. Opening it reports: 7 images (5 JPEG, 2 PNG, 719 KB in total), 14 of 14 pages carrying a real text layer, 45,593 characters, 556 objects — and an Info dictionary that names both the typesetting system and the tool that touched the file afterwards. All of it computed in a Web Worker in your browser; nothing is uploaded, because there is no server to upload to.
Where the pictures live
A PDF’s images are image XObjects: indirect objects whose stream holds the pixels and whose dictionary says how to read them.
88 0 obj
<< /Type /XObject /Subtype /Image
/Width 1001 /Height 593
/BitsPerComponent 8 /ColorSpace /DeviceRGB
/Filter /DCTDecode /Length 142296 >>
stream
…JPEG bytes…
endstream
endobj
Finding them means walking each page’s /Resources /XObject dictionary — and then walking one level down, because a /Subtype /Form XObject is a reusable fragment with resources of its own, and the logo on every page usually lives exactly there. Two things matter in that walk:
const num = ref instanceof Ref ? ref.num : null;
const key = num != null ? `o${num}` : `p${pageIndex}:${name}`;
if (seen.has(key)) continue; // dedupe by OBJECT, not by page
…
} else if (subtype === 'Form') {
// A form XObject is a reusable fragment with resources of its own — the
// logo on every page usually lives one level down like this.
await collectFromResources(doc, inner, pageIndex, out, seen, maxImages, depth + 1);
}
Deduplicating by object number rather than by occurrence is why a journal logo drawn on all 14 pages is extracted once, not fourteen times. It is also why the image count is a fact about the file rather than about how many pages you looked at.
From there, extraction splits by /Filter into two genuinely different jobs.
Case one: the payload already is an image file
/DCTDecode is JPEG. /JPXDecode is JPEG 2000. When the last filter is one of those, the stream payload is a complete, standalone image file — so the right thing to do is nothing at all:
// Already a standalone image file: hand the payload over untouched.
if (last === 'DCTDecode' || last === 'DCT') {
return { ...base, kind: 'jpeg', mime: 'image/jpeg', bytes: await codecPayload(doc, stream) };
}
“Untouched” is the whole point. Screenshotting a figure re-samples it at whatever zoom your screen happened to be at; re-encoding it loses a generation of quality. Handing over the payload gives you byte-for-byte the picture the author embedded, at its stored resolution — which is routinely far larger than what the printed page shows. In the sample, the four figures come out at 1001×593, 1001×601, 902×1202 and 1012×723 pixels; on the page each is printed a few inches wide.
One subtlety hides in codecPayload: a stream can have a chain of filters, e.g. [/ASCII85Decode /DCTDecode]. The decoder applies every filter before the image codec — and decryption, for an encrypted file — then stops, leaving exactly the JPEG bytes:
// decodeStream applies decryption and every filter *before* an image codec
// (so an ASCII85-wrapped JPEG is unwrapped) and then stops.
async function codecPayload(doc, stream) {
const bytes = await doc.streamBytes(stream);
return bytes && bytes.length ? bytes.slice() : stream.raw.slice();
}
/CCITTFaxDecode and /JBIG2Decode — fax and bilevel codecs, common in scanned documents — would each need a decoder of their own. Those images are listed with their full metadata and their raw payload offered for download, with a note saying they are not previewed. Saying so beats pretending.
Case two: raw samples, which become PNGs
With /FlateDecode, /LZWDecode, /RunLengthDecode or no filter at all, the stream is not an image file — it is a bare grid of component values. Turning that into something a browser can show is a pipeline:
- Unpack 1, 2, 4, 8 or 16 bits per component into bytes.
- Convert the colour space: Gray, RGB, CMYK, an Indexed palette, ICCBased (by component count), Separation and DeviceN (approximated as tint).
- Apply
/Decode, the per-component range inversion — most often[1 0]on a 1-bit image, which flips black and white. - Composite the soft mask (
/SMask) into an alpha channel, so a logo with transparency stays transparent. - Encode a PNG: an IHDR, one IDAT and an IEND, with a filter-type byte per row.
const ihdr = new Uint8Array(13);
const dv = new DataView(ihdr.buffer);
dv.setUint32(0, width);
dv.setUint32(4, height);
ihdr[8] = 8; // bit depth
ihdr[9] = hasAlpha ? 6 : 2; // colour type: RGBA / RGB
const idat = await zlibDeflate(rows);
const parts = [PNG_SIGNATURE, buildChunk('IHDR', ihdr), buildChunk('IDAT', idat),
buildChunk('IEND', new Uint8Array(0))];
The deliberate architectural choice is in zlibDeflate. PNG’s IDAT is a zlib stream, and that is exactly what the platform’s own CompressionStream('deflate') produces — so no deflate implementation ships with this module, in keeping with the zero-dependency rule for the whole parsing path:
async function zlibDeflate(bytes) {
if (typeof CompressionStream === 'function') {
try {
const cs = new CompressionStream('deflate');
return new Uint8Array(await new Response(
new Blob([bytes]).stream().pipeThrough(cs)).arrayBuffer());
} catch { /* fall through to stored blocks */ }
}
return zlibStored(bytes); // valid zlib of uncompressed blocks — bigger, always works
}
The fallback matters more than it looks: a hand-written emitter of stored deflate blocks plus an Adler-32 checksum is about thirty lines, always produces a valid PNG, and keeps image extraction alive on a platform without CompressionStream. Bigger files, never a broken one.
In the sample, this path handles the journal’s logos, which are stored as Indexed(DeviceCMYK) palettes under Flate — palette lookup, CMYK → RGB, PNG out — while the photographic figures take the verbatim JPEG path. One document, both cases, which is exactly the normal state of affairs.
Tables are geometry, not structure
Here is the thing that surprises people about tables in PDFs: there is no table. No row object, no cell, no column. A table is some text drawn at aligned coordinates, and maybe some lines drawn near it. Whatever structure you get back has to be inferred from where the glyphs landed.
That is precisely what the text layer already computes — every shown string is a positioned item, lines are baseline groups, and a gap wider than a fifth of the type size becomes a space (the text-extraction article covers the arithmetic). Run it over a table and the columns fall out on their own. This is verbatim output from the sample paper:
Table 2. PERMANOVA analysis of personal factors with the unweighted UniFrac distance matrix.
Factor F statistic P-value
Main Analysis
Sex 1.49 0.001
2
BMI 1.12 0.05
3
Total Fiber 1.08 0.14
3
Fruit & Vegetable Fiber 1.13 0.06
3
Bean Fiber 1.13 0.06
3
Grain Fiber 1.08 0.15
Men
2
BMI 1.04 0.29
Rows are intact, cells are separated, the numbers line up with their labels. That is enough to paste into a spreadsheet or hand to a script — and it comes from measuring gaps, not from any table structure in the file, because there isn’t any.
Where it frays, and why:
- Superscript footnote markers get lines of their own — those bare
2s and3s above. A marker set in 5 pt type and raised 3 pt sits further above its row’s baseline than the line-grouping tolerance allows, so it separates. Merging it back would mean loosening the tolerance until genuinely adjacent lines of dense type merge too, which is a worse failure. - Cells that wrap onto a second line become their own row: the geometry genuinely shows two lines, and knowing they are one cell needs column-band detection.
- Column boundaries are not recovered — you get a space run, not a delimiter, so a label containing a space is indistinguishable from two cells. Clustering items into vertical bands would fix this, and the position model is what makes it possible; it is not done yet.
What your PDF says about you
Every PDF carries provenance in two places: the trailer’s /Info dictionary and the catalog’s XMP packet — an RDF/XML blob usually sitting in the file as plain, uncompressed text. Here is what the sample admits to, read straight from its bytes:
/Title Sex, Body Mass Index, and Dietary Fiber Intake Influence the Human Gut Microbiome
/Author Christine Dominianni, Rashmi Sinha, James J. Goedert, …
/Creator Arbortext Advanced Print Publisher 11.0.2857/W Unicode-x64
/Producer Acrobat Distiller 10.0.0 (Windows); modified using iTextSharp 5.5.3 …
/CreationDate D:20150414100032+05'30'
/ModDate D:20150414100043+05'30'
XMP packet 4,017 bytes of RDF/XML
Read that /Producer line again. It names the typesetting system, the distiller, the operating system, and a second library that post-processed the file — and the +05'30' offsets in the timestamps put the machine that did it in a time zone five and a half hours ahead of UTC, eleven seconds apart. For a published paper that is harmless. For a CV, a contract redlined at 2 a.m., or a whistleblower’s document, the same fields are a disclosure — and they are invisible in every PDF reader unless you go looking.
Removing it for real: rewrite, don’t append
PDF has a built-in mechanism for changes: the incremental update. Append new objects and a new cross-reference at the end of the file, pointing back at the old one, and every conforming reader honours the newest version. It is how editors save quickly, and it is the obvious way to “remove” metadata.
It is also a lie. The old /Info dictionary and XMP packet are still in the file, byte for byte, one strings command away. Anything that claims to strip metadata that way is hiding it, not removing it.
So the cleaner does a real rewrite. A PDF is a graph of numbered objects, so you can walk everything worth keeping, renumber it densely, and emit a fresh file:
for (const [num] of doc.xref) {
if (num === infoNum) continue; // the Info dictionary: gone
if (num === encryptNum) continue; // …and /Encrypt with it
const value = await doc.getObject(num, 0);
if (isStream(value)) {
const type = nameOf(value.dict.get('Type'));
if (type === 'XRef' || type === 'ObjStm') continue; // regenerated / expanded
if (type === 'Metadata' || num === metadataNum) { // every XMP packet: gone
metadataStreams++; xmpBytes += value.raw.length; continue;
}
raw = await rawStreamBytes(doc, value); // verbatim: no re-encode
}
kept.push({ num, value, raw });
}
// Dense renumbering old→new (1..K), then serialize with a fresh classic xref
// and a trailer that carries no /Info.
Three properties fall out of doing it this way:
- The pages are untouched. Content streams are copied as their already-compressed raw bytes, so nothing is re-encoded and the document renders identically.
- The removal is verifiable. Search the cleaned file for the author’s name, for “Producer”, for
xpacket— nothing. That is a different claim from “your reader no longer displays it”. - An encrypted file comes out decrypted. Stream payloads are copied with the cipher peeled off and no
/Encryptis emitted, so “download a clean copy” also means “download an unlocked copy” — for a file you already opened with its password.
The rewrite needs every object in memory, which is fine for the overwhelming majority of real PDFs and not fine for a multi-gigabyte one. Rather than silently doing something weaker, the tool offers the incremental path as a separate, clearly-labelled option: it appends a small patch that unlinks the Info dictionary and the XMP stream, so every reader stops showing them — and it says outright that the original bytes are still there. The honesty is the point; it is also why the rewrite exists.
Password-protected files
If the PDF you were sent is encrypted, everything above still applies — after the password. The standard security handler (RC4 40/128-bit and AES-128/AES-256, revisions 2 through 6) is implemented in the same dependency-free way: MD5, RC4 and AES written out by hand, the key derived in the page, the password never leaving the browser. Once the document opens, text, images, objects and the cleaner all work normally, and the clean copy you download is decrypted as well as stripped.
The permission flags the author set — no printing, no copying — are shown in STATS as what they are: requests recorded in the file, not enforcement.
Architecture & limits
Image extraction runs on demand, in the same Web Worker that parsed the document, over the object graph that is already in memory — opening the IMAGES tab re-uses the parse rather than re-reading the file. It is bounded on every axis, because a PDF can always be pathological:
| Bound | Value | Why |
|---|---|---|
| images per document | 300 | a generated report can embed thousands of chart tiles |
| pixels per image | 40,000,000 | a 40 MP page scan is already enormous |
| total decoded bytes | 96 MB | keeps peak memory bounded regardless of file size |
| pages walked | 500 | the tail of a 5,000-page document is rarely the point |
| form nesting | depth 8 | a cycle of forms would otherwise recurse forever |
Whenever a bound bites, the result is flagged truncated rather than quietly shortened. Underneath, the document itself is read index-then-seek: a PDF’s cross-reference table sits at the tail, so files up to 64 MB are read whole and larger ones through a bounded head + tail window, with objects seeked to by offset — which is why a 20 GB PDF opens at all. That mechanism is the subject of the format deep-dive.
What it does not do: render pages. There is no thumbnail of page 7, because drawing a PDF page means implementing a full imaging model plus a font engine — a different project from reading one. You get the images that are in the file, the text that is in the file, and the truth about what else it carries.
Sample paper: Dominianni C, Sinha R, Goedert JJ, Pei Z, Yang L, Hayes RB, Ahn J (2015), Sex, Body Mass Index, and Dietary Fiber Intake Influence the Human Gut Microbiome, PLoS ONE 10(4): e0124599 — an open-access article free of all copyright. “Attention Is All You Need” (Vaswani et al., 2017, arXiv:1706.03762) is not hosted here: that button downloads it from arxiv.org straight into your browser.