theseus/bundled-addons/pdf-editor/lib/reflow.js
Local Dev c4f7251860 feat(pdf-editor): convert a PDF to Word, and say what that costs
A PDF does not contain paragraphs. It contains glyphs with coordinates, and
there is no heading, no list, no table and no guaranteed reading order —
only runs of characters that happen to sit near each other. Converting to
Word means working out where the paragraphs were, from geometry. That
inference is the whole feature, and it is sometimes wrong, so this is called
a conversion and never an edit, and the dialog reports what it found before
anything is written.

Lines are grouped by baseline, runs joined with the spaces a PDF only implies
by leaving a gap, and paragraphs ended where the next line sits unusually far
below, is indented, or where the previous one stopped short of the measure.
Headings come from size relative to the body — which is the most common size
on the page, not the average, because a page of 11 pt under a 28 pt title
averages to something that is neither. Bold and italic come from the font's
name, the only place a PDF records them.

What it refuses to fake is as important. A page set in columns is reported,
not silently interleaved. A page with no text says so, and says why: it is an
image of writing, and reading that needs character recognition this editor
does not have. Tables become plain paragraphs rather than an invented grid,
because a wrong table is harder to repair than no table.

The .docx is written here rather than by a vendored builder: a Word file is a
zip of five XML parts, and the subset that can honestly be produced —
paragraphs of styled runs — is about two hundred lines. Vendoring a document
library would have added another megabyte on top of the four pdf.js and
pdf-lib already weigh, to generate markup we would still have to get right.
Entries are stored rather than deflated, which keeps a compressor out of the
add-on; the CRCs are the part that cannot be skipped, since Word calls the
file corrupt rather than naming the part that upset it.

Text replaced in place converts as replaced. Converting would otherwise hand
back the words the user had just edited away.

Checked by taking the output apart — every CRC verified, both XML parts run
through a real parser — and then, because that is still marking my own
homework, by opening the result in the Word editor extension, where mammoth
reads it with none of my code involved.
2026-09-22 22:06:54 +02:00

277 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;
}