跳到正文
DEV Community· Lanxk·· 3 小時前AI 評分57

MD-to.com PDF Converter 如何在瀏覽器中將 PDF 轉為 Markdown

How I Convert PDF to Markdown in the Browser

AI 導讀

作者 Lanxk 建立了瀏覽器端 PDF 轉 Markdown 工具,並分享利用 PDF.js 擷取文字、按座標推斷文件結構的方法。流程會重建段落、標題和清單,並透過手寫座標案例及實際 PDF.js 測試修正錯誤,逐頁處理以免跨頁段落誤合併。

正文

I built a browser-based PDF to Markdown converter. I expected parsing the PDF to be the hard part. It wasn't: PDF.js handles that.

The hard part is that a PDF has no paragraphs, headings or lists. Word stores "this is Heading 2". HTML stores <h2>. A PDF stores "draw these glyphs at 16pt at (89.8, 751.4)". It's a list of drawing instructions for a printer.

So PDF to Markdown is reverse engineering: look at where text is drawn and how big it is, and guess the structure the author had in mind. This post walks through the rules I ended up with, with the real code, and the bugs that forced each one.

What PDF.js gives you

page.getTextContent() returns text items. Here's a simplified excerpt from page 1 of a real 4-page PDF:

{ "str": "Academic & Creative", "transform": [14, 0, 0, 14, 89.8, 188.6] }
{ "str": "", "transform": [16, 0, 0, 16, 89.8, 751.4], "hasEOL": true }
{ "str": "MD-to.com PDF Converter — Feature Guide", "transform": [16, 0, 0, 16, 89.8, 751.4] }
{ "str": "Download PDF", "transform": [11, 0, 0, 11, 376.7, 689.2] }
{ "str": "Try it now: paste any Markdown on the left and click", "transform": [11, 0, 0, 11, 89.8, 689.2] }

transform is a 2D matrix: the last two values are x and y, and the font size is in the first four. Things to notice:

  1. Item order isn't reading order. The first item is near the bottom of the page. "Download PDF" comes before "Try it now", but it's to its right on the same line.
  2. y points up. The origin is the bottom-left corner.
  3. Empty items carry nothing but a hasEOL flag.
  4. Font names are useless. fontName is an internal ID like g_d0_f1, so at this level you can't tell whether text is bold.

The pipeline

PDF.js (parsing)
  └─ pdfjs-adapter: normalize coordinates → { str, x, y, width, height }
      └─ pdf-reader: per page, progress, cancel, isolate failed pages
          └─ pdf-extraction (pure functions):
               items → lines → drop headers/footers → body font size
               → headings / paragraphs / lists → escape → Markdown

The key design decision: the structure inference doesn't know about PDF.js. It takes plain { str, x, y, width, height } objects and returns a string. That made it testable with hand-written coordinates, which turned out to be the most valuable thing in the whole project.

The adapter flips the y axis to screen space (y grows downward) and takes the font size from the matrix, so scaled text gets its real size:

const viewport = page.getViewport({ scale: 1 });
const transform = pdfjs.Util.transform(viewport.transform, item.transform);
return {
  str: item.str,
  x: transform[4],
  y: transform[5],
  width: item.width,
  height: Math.hypot(transform[2], transform[3]) || item.height,
};

Step 1: Items → lines

Drop empty items, sort by y then x, and put items on the same line when their y values are close:

Math.abs(last.y - item.y) <= Math.max(2, Math.min(last.height, item.height) * 0.3)

30% of the font size absorbs superscripts, baseline differences between fonts and floating-point noise, without merging two real lines.

When do you insert a space?

Generators split text however they like: per word, per glyph, or mid-word because of kerning or a font change. Joining with '' glues English words together. Joining with ' ' breaks words in half. So the joiner looks at the actual gap:

// Scripts written without spaces between words. Korean (Hangul) separates
// words with spaces, so it follows the same gap rules as Latin text.
const cjk = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u;

function separator(left: string, right: string, gap?: number, height = 12) {
  if (!left || !right || /\s$/.test(left) || /^\s/.test(right)) return '';
  // Gap under 12% of the font size: one word split into two runs
  if (gap !== undefined && gap <= Math.max(0.5, height * 0.12)) return '';
  // No space before punctuation or after an opening bracket
  if (/^[,。!?、;:)》」』】〕,.!?;:)]/u.test(right) || /[(《「『【〔(]$/u.test(left))
    return '';
  if (cjk.test(left.at(-1)!) && cjk.test(right[0])) return '';
  return ' ';
}

Hel + lo with gap 0 → Hello. Hello + world with gap 6 → Hello world. Two Chinese runs → no space.

That comment about Korean is there because of a bug. The first version used "CJK" to mean Chinese, Japanese and Korean. But Korean separates words with spaces. I only caught it while testing a Korean PDF: 보고서를 + 작성합니다. came out as 보고서를작성합니다.

Even after the fix, one case can't be solved. Korean text can wrap between words or between syllables, and I generated both kinds of PDF in Chrome to compare. In both, the line ends with no trailing space and nothing in the data says "this break was mid-word". I chose to add a space across line breaks, because wrapping between words is more common. PDFs that wrap mid-word get an extra space at the break.

Step 2: Headers and footers

My first version deleted any line that was 1–4 digits or looked like Page 3 of 10. It deleted the year 2026 from body text and numbers from tables, and missed the actual running header, which wasn't a number.

Now each line gets a "margin key", and a key is only removed if it repeats across pages:

function marginKey(line: Line, page: PdfPageText): string | undefined {
  const relative = line.y / page.height;
  // Only the top or bottom 5% of the page, and only short lines
  if (!(relative < 0.05 || relative > 0.95) || line.text.length > 80) return;
  const edge = relative < 0.05 ? 'top' : 'bottom';
  const number =
    line.text.match(/^(?:page\s+)?(\d+)(?:\s+of\s+\d+)?$/i) ||
    line.text.match(/^-\s*(\d+)\s*-$/);
  if (number) {
    // Ambiguous numbers, years and amounts survive, even near a page edge.
    return Number(number[1]) === page.number ? `${edge}:page-number` : undefined;
  }
  return `${edge}:${Math.round(relative * 100)}:${line.text}`;
}

A key is removed only if it appears on at least max(2, ceil(pages × 0.6)) pages. Page numbers 3 on page 3 and 4 on page 4 share the key bottom:page-number, so they're removed together. A 2026 near the edge doesn't match the page number, so it stays.

This is deliberately conservative. Roman numerals and numbering that starts after the cover aren't removed. A leftover page number is obvious. A deleted sentence may never be noticed. The UI also tells the user how many header and footer lines were removed.

Step 3: Headings from font size

First, find the body font size, weighting each size by character count rather than line count, across the whole document:

function bodyFontSize(lines: Line[]) {
  const weights = new Map<number, number>();
  for (const line of lines) {
    const size = Math.round(line.height);
    weights.set(size, (weights.get(size) || 0) + line.text.length);
  }
  return [...weights].sort((a, b) => b[1] - a[1])[0]?.[0] || 12;
}

Counting lines would let short headings and page numbers outvote long paragraphs. In the 4-page sample, 11pt had 2,146 characters, 10pt (code) 350, 16pt 184 and 14pt 119, so body text is 11pt.

Each line's size divided by the body size gives the heading level:

Ratio to body size Output
≥ 2 #
≥ 1.6 ##
> 1.3 ###
≤ 1.3 paragraph

In the sample, 16pt headings (ratio 1.45) became ###. The 14pt sub-headings (1.27) stayed as paragraphs. A lower threshold would catch them, but in another document with 10pt body and 12pt quotes, every quote would become a heading. No threshold fits every PDF, so this one leans careful, and the UI has a switch to turn heading detection off.

The feature that never worked

The first version also had bold detection: same size as body, short, bold → **sub-heading**. It checked whether the font name contained bold, heavy or black.

During the rewrite I realized this had never fired. Font names are g_d0_f1. I deleted it. A feature that looks like it works but doesn't is worse than no feature. Reading real font names from PDF.js font objects is on the list for later.

Step 4: Paragraphs and lists

Two signals start a new paragraph:

const verticalBreak = gap > Math.max(line.height, previous?.height || 0) * 1.8;
// Only a rightward shift is a first-line indent; returning to the left
// margin is the rest of that paragraph.
const paragraph = verticalBreak || (previous && line.x - previous.x > line.height);

Line spacing in body text is usually 1.2–1.5× the line height, and there's extra space between paragraphs, so 1.8× sits in between. Many documents, especially Chinese ones, use first-line indentation and no spacing at all. Only a shift to the right counts. The line after an indented first line moves left again, and that's the same paragraph. One version split every indented paragraph in two before I added that comment.

Lists start with •, ◦, -, *, 1., 1) and so on. The hard part is wrapped items:

• First item that wraps
  onto a second line        ← hanging indent: same item
• Second item
Independent text            ← back at the margin: not a list item

I got this wrong in both directions. Version one appended any line without a paragraph gap to the previous block, so Independent text was swallowed into the last list item. A later version split onto a second line into its own paragraph and broke the list in two.

The rule now: a line continues a list item if there's no vertical break and it starts to the right of the bullet:

} else if (
  last?.kind === 'list' &&
  !verticalBreak &&
  line.x - last.x! > Math.max(1, line.height * 0.2)
) {
  // Hanging-indent line continues the current list item.

Output:

- First item that wraps onto a second line
- Second item

Independent text

Step 5: Escaping, but not too much

Text in a PDF is literal. # tag at the start of a line is a hashtag, not a heading. The first version escaped nothing, so lines starting with # became headings, <b>literal</b> rendered as bold, and a line like --- | --- turned the line above it into a table header.

Escaping every Markdown character caused a different bug: citations like [1] rendered as math. My renderer supports KaTeX, and \[ ... \] is the display math delimiter. A standard escaper turns [1] into \[1\], which most renderers show as [1]. Mine showed an equation.

So the escaper only touches characters in positions where they would trigger syntax:

export function escapePdfText(text: string) {
  const escaped = text
    .replace(/[\\`*$]/g, '\\$&')
    // Only underscores that could start emphasis; snake_case stays as is
    .replace(/(?<![\p{L}\p{N}])_|_(?![\p{L}\p{N}])/gu, '\\_')
    .replace(/~(?=~)|(?<=~)~/g, '\\~')
    // `]` only before ( [ : — links, images, references. `[` is never escaped.
    .replace(/\](?=[([:])/g, '\\]')
    .replace(/\^(?=\[)/g, '\\^')
    .replace(/<(?=[\p{L}/!?])/gu, '\\<')
    .replace(/&(?=#\d+;|#x[\da-f]+;|[a-z][\da-z]*;)/gi, '\\&')
    // Line-start blocks: headings, quotes, lists, setext underlines and rules
    .replace(/^(?:#|>|[-+](?=\s|$)|[-=](?=[-=\s]*$))/, '\\$&')
    .replace(/^(\d+)([.)])(?=\s|$)/, '$1\\$2');
  // A delimiter row such as `--- | ---` would turn the previous line into a table
  return /^[\s|:\\-]*\|[\s|:\\-]*$/.test(escaped) && escaped.includes('-')
    ? escaped.replace(/\|/g, '\\|')
    : escaped;
}

snake_case, C#, AT&T, 5 > 3 and [1] pass through unchanged. A line starting with 1. becomes 1\. so it doesn't turn into a list.

The test for this renders escaped strings with the site's own Markdown renderer and checks that the output text matches the input. Testing against a generic renderer wouldn't have caught [1], because that bug only exists with KaTeX enabled.

Step 6: Never compare coordinates across pages

The first version concatenated lines from all pages and then grouped paragraphs. But every page's coordinates start from zero. If the last line of page 1 was at y = 680 and the first line of page 2 was also at 680, the gap was 0, and the two pages became one paragraph.

Now every page is rendered on its own and joined with a blank line:

// Never compare Y coordinates or merge paragraphs across page boundaries.
markdown: filtered
  .map((page) => renderPage(page, bodySize, options))
  .filter(Boolean)
  .join('\n\n'),

The trade-off: a paragraph that continues on the next page gets split. Deleting one blank line fixes that. Two unrelated pages merged into one paragraph is much harder to spot.

Testing a guessing algorithm

Heuristics have one big risk: fixing one case breaks another. Since the inference is pure, every bug above became a test with hand-written coordinates, run with Node's built-in node:test:

const item = (str, y = 100, x = 30, width = str.length * 6) =>
  ({ str, y, x, width, height: 12 });

test('glyph runs infer word gaps without splitting adjacent characters', () => {
  assert.equal(
    extractPdfLines([item('Hello', 100, 30, 30), item('world', 100, 66)])[0].text,
    'Hello world',
  );
  assert.equal(
    extractPdfLines([item('Hel', 100, 30, 18), item('lo', 100, 48)])[0].text,
    'Hello',
  );
});

There are tests for each bug in this post: years surviving the margin filter, Korean spacing, first-line indents, list items swallowing paragraphs, [1] turning into math, --- | --- creating a table, and equal coordinates on different pages.

A second layer builds tiny but valid PDF files in code (a few objects plus an xref table) and runs them through the real PDF.js. That catches the other class of bug, where the pure function is right but PDF.js returns data different from what I assumed.

A few engineering details

  • One bad page doesn't fail the document. A page that throws is marked failed, the rest still convert, and the status bar lists failed pages and pages with no text (scans or blank pages) instead of a generic error.
  • Cancellation. Every async step races against a cancel promise, the PDF.js loading task is destroyed exactly once, and the loop yields with setTimeout(0) after each page so large files don't freeze the tab.
  • The worker is bundled, not loaded from a CDN, so its version always matches the installed PDF.js.

What coordinates can't tell you

  • Tables come out as rows of text. There's no border or column-alignment analysis.
  • Multi-column layouts get interleaved, because items at the same y become one line.
  • Hyphenated line breaks like inter- / national come out as inter- national. There's no reliable way to tell layout hyphens from real ones, so I don't guess.
  • Code indentation, formula structure and images aren't in the text layer in a recoverable form.
  • Scanned PDFs have no text layer and need OCR.

Marker, MinerU and Docling solve these with layout and OCR models, and they do it better. They also need a Python environment or a server upload. This converter goes the other way: rules only, in the browser, no upload, results in seconds for ordinary text PDFs. They're tools for different jobs.

Next on the list, each only once there are enough counter-examples in the tests: real font weights for bold, merging paragraphs across pages when the first page's line has no closing punctuation and the next starts lowercase, column detection from vertical whitespace bands, and simple table recovery from stable x alignment.

The question behind every rule

Almost every bug came from one wrong assumption: reading a PDF as a structured document. It's drawing instructions. So every rule answers one question: if this guess is wrong, what does it cost the user?

  • Headers and footers: keep a page number rather than delete a sentence
  • Headings: miss a heading rather than promote every quote
  • Pages: split a paragraph rather than merge two pages
  • Escaping: escape only what triggers syntax, so [1] stays [1]
  • Bold detection: a broken feature is worse than none

The converter is free at https://md-to.com/pdf-to-markdown and runs entirely in your browser. If you have a text PDF it gets wrong, tell me about it in the comments. Every counter-example becomes a test case.

來源:DEV Community · dev.to