// Guessing a document's structure back out of a PDF. // // This is the whole difficulty of converting a PDF to Word, and it is worth // being blunt about why. A PDF does not contain paragraphs. It contains glyphs // with coordinates. There is no heading, no list, no table and no guaranteed // reading order — only runs of characters that happen to sit next to each // other. Everything below is inference from geometry, and inference is // sometimes wrong. // // What it does well: single-column, digitally-generated, text-first documents. // Letters, reports, articles. What it does badly, and reports rather than // pretending otherwise: tables, multiple columns, and scans, which have no // text to find at all. // // The output is deliberately plain — paragraphs of styled runs — because that // is the part that can be recovered honestly. Inventing a table because six // runs happened to line up produces a document that is harder to fix than one // that never claimed to have a table. /** A gap wider than this many times the font size starts a new word. */ const WORD_GAP = 0.22; /** Lines whose baselines differ by less than this share a line. */ const LINE_TOL = 0.45; /** A vertical gap this much bigger than the usual one starts a paragraph. */ const PARA_GAP = 1.6; /** An indent this many times the font size starts a paragraph. */ const INDENT = 0.9; /** A line ending before this fraction of the block width ends a paragraph. */ const SHORT_LINE = 0.86; /** Text this much larger than the body is a heading. */ const HEADING_RATIO = [1.65, 1.32, 1.14]; function median(values) { if (!values.length) return 0; const v = values.slice().sort((a, b) => a - b); return v[Math.floor(v.length / 2)]; } /** Bold and italic are only knowable from the font's name in a PDF. */ function styleOf(fontName) { const n = String(fontName || "").toLowerCase(); return { bold: /bold|black|heavy|semibold|demi/.test(n), italic: /italic|oblique/.test(n), family: /times|serif|georgia|garamond|roman/.test(n) ? "Cambria" : /courier|mono/.test(n) ? "Consolas" : "Calibri", }; } /** * One page's text items, grouped into lines. * Items arrive in content-stream order, which is not reading order. */ function toLines(items, fontMap) { const placed = []; for (const it of items) { const str = it.str ?? ""; if (!str || !it.transform) continue; const [a, b, , d, x, y] = it.transform; // The text matrix carries the size; height is unreliable on its own. const size = Math.abs(d) || Math.hypot(a, b) || it.height || 0; if (!size) continue; if (!str.trim()) { placed.push({ x, y, size, str, blank: true, width: it.width || 0 }); continue; } const look = styleOf(fontMap ? fontMap.get(it.fontName) || it.fontName : it.fontName); placed.push({ x, y, size, str, width: it.width || 0, ...look }); } if (!placed.length) return []; placed.sort((p, q) => (q.y - p.y) || (p.x - q.x)); const lines = []; for (const it of placed) { const line = lines[lines.length - 1]; if (line && Math.abs(line.y - it.y) <= LINE_TOL * Math.max(line.size, it.size)) { line.items.push(it); line.size = Math.max(line.size, it.size); } else { lines.push({ y: it.y, size: it.size, items: [it] }); } } for (const line of lines) { line.items.sort((p, q) => p.x - q.x); line.x0 = line.items[0].x; const last = line.items[line.items.length - 1]; line.x1 = last.x + (last.width || 0); } return lines.filter((l) => l.items.some((i) => !i.blank)); } /** Merge a line's items into runs, inserting the spaces the PDF only implies. */ function lineRuns(line) { const runs = []; let prev = null; for (const it of line.items) { if (it.blank) { prev = it; continue; } let text = it.str; if (prev) { const gap = it.x - (prev.x + (prev.width || 0)); const needsSpace = gap > WORD_GAP * Math.max(it.size, prev.size) && !/\s$/.test(runs.length ? runs[runs.length - 1].text : "") && !/^\s/.test(text); if (needsSpace) text = " " + text; } const last = runs[runs.length - 1]; if (last && last.bold === it.bold && last.italic === it.italic && last.family === it.family && Math.abs(last.size - it.size) < 0.4) { last.text += text; } else { runs.push({ text, size: it.size, bold: it.bold, italic: it.italic, family: it.family }); } prev = it; } return runs.filter((r) => r.text.length); } /** * Turn one page's lines into paragraphs. * A paragraph ends when the next line is unusually far below, is indented, or * when this line stopped well short of where the others reach. */ function toParagraphs(lines, bodySize) { const paragraphs = []; if (!lines.length) return paragraphs; const gaps = []; for (let i = 1; i < lines.length; i++) gaps.push(lines[i - 1].y - lines[i].y); const usualGap = median(gaps.filter((g) => g > 0)) || bodySize * 1.2; const right = Math.max(...lines.map((l) => l.x1)); const left = Math.min(...lines.map((l) => l.x0)); const blockWidth = Math.max(1, right - left); let current = null; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const prev = lines[i - 1]; let starts = !current; if (prev) { const gap = prev.y - line.y; if (gap > usualGap * PARA_GAP) starts = true; if (line.x0 - prev.x0 > INDENT * line.size) starts = true; if (prev.x1 - left < blockWidth * SHORT_LINE) starts = true; if (Math.abs(line.size - prev.size) > 0.35 * Math.max(line.size, prev.size)) starts = true; } const runs = lineRuns(line); if (!runs.length) continue; if (starts) { current = { runs: [], size: line.size, x0: line.x0, x1: line.x1 }; paragraphs.push(current); } else { // Continuing a wrapped line: the space at the break is implied. const last = current.runs[current.runs.length - 1]; if (last && !/\s$/.test(last.text) && !/^\s/.test(runs[0].text)) last.text += " "; current.x1 = Math.max(current.x1, line.x1); } current.runs.push(...runs); current.size = Math.max(current.size, line.size); } // Headings, decided against the body size of the document as a whole. for (const p of paragraphs) { const ratio = bodySize > 0 ? p.size / bodySize : 1; const words = p.runs.reduce((n, r) => n + r.text.trim().split(/\s+/).length, 0); // A long passage is not a heading however big it is set. if (words <= 18) { for (let lvl = 0; lvl < HEADING_RATIO.length; lvl++) { if (ratio >= HEADING_RATIO[lvl]) { p.heading = lvl + 1; break; } } } const centred = Math.abs((p.x0 - 0) - 0) > 0 && p.heading; void centred; } return paragraphs; } /** * Does this page look like it is set in more than one column? * * Checked, not corrected. Reading a two-column page in the order the glyphs * happen to appear interleaves the columns line by line, and silently handing * someone that mess is worse than telling them it happened. */ function looksMultiColumn(lines, pageWidth) { if (lines.length < 8) return false; const mid = pageWidth / 2; let leftOnly = 0, rightOnly = 0; for (const l of lines) { if (l.x1 < mid) leftOnly++; else if (l.x0 > mid) rightOnly++; } // Both halves carry a real share of the lines, and few lines cross over. const crossing = lines.length - leftOnly - rightOnly; return leftOnly >= 3 && rightOnly >= 3 && crossing < lines.length * 0.25; } /** * Convert a document's pages into paragraphs. * * @param {{index:number, label:number, items:Array, widthPt:number, heightPt:number, * replacements?:{x:number,y:number,w:number,h:number,text:string}[]}[]} pages * @returns {{paragraphs:Array, warnings:string[], stats:object}} */ export function reflow(pages) { const warnings = []; const perPage = []; const sizes = []; for (const page of pages) { const lines = toLines(page.items, page.fontMap); for (const l of lines) for (const it of l.items) if (!it.blank) sizes.push(Math.round(it.size * 2) / 2); perPage.push({ page, lines }); } // The body size is the most common size on the page, not the average: a // page of 11 pt text under a 28 pt title averages to something that is // neither. const counts = new Map(); for (const s of sizes) counts.set(s, (counts.get(s) || 0) + 1); let bodySize = 11, bodyN = -1; for (const [s, n] of counts) if (n > bodyN) { bodyN = n; bodySize = s; } const paragraphs = []; let emptyPages = 0; for (const { page, lines } of perPage) { if (!lines.length) { emptyPages++; warnings.push(`Page ${page.label} has no text to extract — if it looks like a page, it is an image of one.`); continue; } if (looksMultiColumn(lines, page.widthPt)) { warnings.push(`Page ${page.label} looks like it is set in columns; its text may come out interleaved.`); } const replaced = applyReplacements(lines, page.replacements); const got = toParagraphs(lines, bodySize); if (got.length) { if (paragraphs.length) paragraphs.push({ pageBreak: true, runs: [] }); paragraphs.push(...got); } if (replaced) page.replacedCount = replaced; } return { paragraphs: paragraphs.filter((p) => p.pageBreak || p.runs.some((r) => r.text.trim())), warnings, stats: { pages: pages.length, emptyPages, bodySize, paragraphs: paragraphs.filter((p) => !p.pageBreak).length, headings: paragraphs.filter((p) => p.heading).length, }, }; } /** * Where a run has been replaced in the editor, convert what is on screen * rather than what is underneath. Anything else would hand back the words the * user just edited away. */ function applyReplacements(lines, replacements) { if (!replacements || !replacements.length) return 0; let n = 0; for (const line of lines) { for (const rep of replacements) { const hit = line.items.filter((it) => !it.blank && it.x + (it.width || 0) > rep.x && it.x < rep.x + rep.w && it.y > rep.y - 1 && it.y < rep.y + rep.h + 1); if (!hit.length) continue; // The replacement takes the first covered item's place; the rest go. const first = hit[0]; first.str = rep.text; first.width = rep.w; for (const it of hit.slice(1)) { it.str = ""; it.blank = true; } n++; } } return n; }