# Extract Table from PDF in JavaScript: A Node Walkthrough

- Author: FastBankConvert (https://www.fastbankconvert.com)
- Published: 2026-08-30
- Updated: 2026-08-30
- Category: Tables and data from PDFs
- Canonical: https://www.fastbankconvert.com/blog/extract-table-from-pdf-in-javascript

> Extract a table from a PDF in JavaScript with Node: read text items with positions, group them into rows and columns, and write a CSV file.
To extract a table from a PDF in JavaScript, load the file with pdf.js in Node, read every text item on each page with its x and y position, group items with nearly the same y into rows, assign each item to a column by its x position, and write the rows out as a quoted CSV file.

## Setting up pdf.js in a Node project for table extraction

pdf.js is Mozilla's PDF library, and its npm package, pdfjs-dist, runs in Node as well as in the browser. For table extraction you need only its document loader and its text reader. No rendering, canvas or browser is involved, because the script reads text and coordinates, not pixels.

### Install and import

Create a folder, run `npm init -y`, then `npm install pdfjs-dist`. Set `"type": "module"` in package.json so you can use `import`. Recent versions of pdfjs-dist ship a build meant for environments without a browser, under a `legacy` path; check the package's own readme for the exact path in the version you installed, since it has moved between major releases.

```js
import { readFile, writeFile } from "node:fs/promises";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
```

### Loading the file

[Mozilla's pdf.js API reference](https://mozilla.github.io/pdf.js/api/draft/module-pdfjsLib.html) describes `getDocument` as the main entry point for loading a PDF, and lists a `data` option that accepts a typed array or an ArrayBuffer, plus a `password` option. In Node, reading the file yourself and passing its bytes is the simplest route:

```js
const bytes = new Uint8Array(await readFile(process.argv[2]));
const pdf = await getDocument({ data: bytes }).promise;
console.log(`pages: ${pdf.numPages}`);
```

Run it with `node extract.mjs report.pdf`. If it prints a page count, the library is working, and every later problem is about the table, not the setup.

### Choosing the pages

Most PDFs hold one table across a known page range, with a cover, a narrative and appendices around it. Pass the first and last page on the command line rather than reading every page. Fewer pages means fewer stray headings, page numbers and footnotes for the grouping logic to sort out later.

## What does getTextContent return for a PDF table?

`getTextContent` returns an object whose `items` array holds one entry per run of text on the page. Each entry carries the characters in `str` and a six-number `transform` matrix. The fifth and sixth numbers of that matrix are the run's x and y position, which are all a table script needs.

### The fields that matter

The pdf.js reference lists these fields on each text item:

| Field | What it holds | Use in a table script |
| --- | --- | --- |
| `str` | The text of the run | The cell value |
| `transform` | A transformation matrix | `transform[4]` is x, `transform[5]` is y |
| `width` | Width of the run | Right edge, for right-aligned numbers |
| `height` | Height of the run | Row tolerance, roughly one line |
| `hasEOL` | Whether a line break follows | A hint, not a rule |
| `fontName` | The font pdf.js used | Spotting bold header rows |

### A run is not a word or a cell

A text run is whatever the program that made the PDF chose to draw in one go. Sometimes that is one cell, sometimes one word, and sometimes a whole row with spaces in it. Some generators even write each character separately. Print the first twenty items of a page before writing any grouping logic:

```js
const page = await pdf.getPage(1);
const { items } = await page.getTextContent();
for (const it of items.slice(0, 20))
  console.log(JSON.stringify(it.str), it.transform[4].toFixed(1), it.transform[5].toFixed(1));
```

If every run is one character, join neighbours whose x positions touch before doing anything else. If runs already hold whole cells, you can skip straight to rows.

### Which way is up

PDF coordinates start at the bottom left of the page, so y grows upward. The first row of a table has the largest y. Sort by y descending to read top to bottom.

## Grouping pdf.js text items into table rows

Rows come from y positions: items whose y values differ by less than a small tolerance belong to the same row. Sort the items from top to bottom, start a new row whenever the next item's y drops by more than the tolerance, and you have the table's lines in order.

### The row step

```js
function toRows(items, tolerance = 3) {
  const runs = items
    .filter((it) => it.str.trim() !== "")
    .map((it) => ({ text: it.str.trim(), x: it.transform[4], y: it.transform[5], w: it.width }))
    .sort((a, b) => b.y - a.y || a.x - b.x);
  const rows = [];
  for (const run of runs) {
    const last = rows.at(-1);
    if (last && Math.abs(last.y - run.y) <= tolerance) last.cells.push(run);
    else rows.push({ y: run.y, cells: [run] });
  }
  for (const row of rows) row.cells.sort((a, b) => a.x - b.x);
  return rows;
}
```

### Choosing the tolerance

A tolerance of about a third of the text height is a reasonable start. Too small, and a number printed in a slightly smaller font, or nudged by a superscript, splits off into its own row. Too large, and two tightly spaced lines fuse together. Print each row's y and its joined text, then adjust until the row count matches what you see on the page.

### Wrapped cells

A description that wraps onto a second line produces a second row with text in one column and nothing else. Detect it after column assignment, not here: a row whose only filled cell is the description column is almost always a continuation of the row above. Merge it upward and drop it. The general reasons this happens, and the other places row detection goes wrong, are set out in [how table detection decides rows and columns](/blog/extract-table-from-pdf#how-are-table-rows-found-in-a-pdf).

## Assigning pdf.js row items to table columns

Columns come from x positions. Find the x where each column starts, usually from the header row, and assign every run to the last column boundary that sits to its left. Right-aligned numbers need their right edge, x plus width, compared against the column's right edge instead.

### Boundaries from the header row

The header row is the most reliable place to read column starts, because every column has a label and the labels rarely wrap. Find it by its text, then record each label's x:

```js
function columnsFrom(header) {
  return header.cells.map((c) => ({ name: c.text, left: c.x }));
}

function assign(row, columns) {
  const out = new Array(columns.length).fill("");
  for (const cell of row.cells) {
    let i = columns.length - 1;
    while (i > 0 && cell.x < columns[i].left - 2) i--;
    out[i] = out[i] ? `${out[i]} ${cell.text}` : cell.text;
  }
  return out;
}
```

The two-unit allowance on the left edge absorbs small differences between a header label's position and the values beneath it.

### Right-aligned amounts

Money columns are usually right-aligned, so a short amount such as "9.00" starts well to the right of a long one such as "12,480.00". If the header label is left-aligned over the column, short amounts can drift into the next column under a left-edge rule. For those columns, compare the run's right edge (`x + w`) with the header label's right edge instead. Mark which columns are numeric and switch rule per column.

### Pages printed sideways

Wide tables are often printed on landscape pages or rotated onto portrait ones. A rotated page changes the transform, so x and y swap roles, and the logic above sorts the table into nonsense. Check the page's rotation and the transform before trusting the positions. The traps of [wide tables on rotated landscape pages](/blog/landscape-pdf-to-excel) apply to scripts as much as to converters.

## Writing the JavaScript table rows to a CSV file

Write the CSV by quoting every field in double quotes, doubling any double quote inside a value, joining fields with commas and joining rows with a line break. Quoting every field costs nothing and protects amounts such as "1,250.00", whose comma would otherwise split them into two columns.

### The writer

```js
const quote = (v) => `"${String(v).replaceAll('"', '""')}"`;
const csv = table.map((r) => r.map(quote).join(",")).join("\r\n");
await writeFile("table.csv", "\uFEFF" + csv, "utf8");
```

The `\uFEFF` at the start is a byte order mark. Excel uses it to recognise the file as UTF-8, so accented characters and currency symbols such as the euro sign survive. Without it, a double-click open on Windows may show garbled characters.

### Keep numbers as text in the file

A CSV has no types. Write amounts exactly as they appear, and let the spreadsheet decide how to read them. If you strip the thousands separators and convert brackets to minus signs in the script, do it consistently and note it in a comment, so the next person reading the CSV knows the values were changed.

### A sample of input and output

The values here are examples. One printed row on the page arrives from pdf.js as four runs:

| Run `str` | x | y |
| --- | --- | --- |
| `2026-04-03` | 52.0 | 611.4 |
| `Office chairs (2)` | 118.5 | 611.4 |
| `2` | 402.1 | 611.2 |
| `318.00` | 488.7 | 611.4 |

After row grouping, column assignment and quoting, the same row is one CSV line:

`"2026-04-03","Office chairs (2)","2","318.00"`

The quantity run sits 0.2 units lower than its neighbours, well inside a three-unit tolerance, so it joins the same row.

### Other languages, same steps

None of this is specific to JavaScript. The read, group, assign and write steps are the same in every language, and the [R walkthrough for PDF tables](/blog/extract-table-from-pdf-in-r) shows them with that language's packages if your analysis already lives there.

## Why does my JavaScript PDF table come back empty or jumbled?

An empty table almost always means the page has no text to read: it is a scanned image, or the text was converted to drawn outlines. A jumbled table means the grouping is off, usually a wrong tolerance, a missed rotation, or columns found from a row that is not the header.

### Empty output

Log `items.length` for each page. Zero means pdf.js found no text. Open the PDF and try to select a word. If you cannot, the page is a picture of a table, and no text-based script will help until text recognition has run on it. A small count on a page full of numbers can mean the same thing for part of the page, such as a pasted-in image of a table inside a text document.

### Jumbled output

Work through the likely causes in order:

1. **Rows split or merged.** Print row y values. Gaps that vary by a fraction of a unit suggest the tolerance is too tight; rows holding two lines of text suggest it is too loose.
2. **Values in the wrong column.** Check whether the column is right-aligned and switch to the right-edge rule.
3. **Everything shifted on one page.** That page may have a different layout, a missing header, or a rotation.
4. **Junk rows at page breaks.** Page numbers and running titles become rows. Drop rows above the header and below the last data row.

### Password-protected files

An encrypted PDF fails at `getDocument`. If you have the password, pass it in the `password` option. If you do not, the script cannot read the file, and that is by design.

### When a script is the wrong tool

Writing and tuning a script pays off for a layout you will see again and again. For a single table you need once, an online tool with a table preview is faster; the guide to [picking the table area in an online extractor](/blog/extract-table-from-pdf-online) covers what to check before downloading. For bank statements, FastBankConvert is being built to read the statement layout and hand back Excel, CSV or JSON, so a script can start from clean rows instead of raw coordinates. It is not open yet, and you can [ask to be told when it is](/get-started).
