278 lines
10 KiB
JavaScript
278 lines
10 KiB
JavaScript
|
|
// 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;
|
||
|
|
}
|