Save a copy
@@ -212,6 +216,22 @@
+
+
Convert to Word
+
A PDF stores glyphs with coordinates, not paragraphs. Converting means working out
+ where the paragraphs were, so the result is a rebuilt document that reads
+ the same rather than a copy that looks the same.
+
File name
+
+
+
+
+
+ Cancel
+ Convert
+
+
+
Save a copy
File name
diff --git a/bundled-addons/pdf-editor/editor.js b/bundled-addons/pdf-editor/editor.js
index 9c2b4f5..6f258e0 100644
--- a/bundled-addons/pdf-editor/editor.js
+++ b/bundled-addons/pdf-editor/editor.js
@@ -21,6 +21,8 @@ import { Tools } from "./lib/tools.js";
import { SignaturePad } from "./lib/signature.js";
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
import { boundsOf, clampFont } from "./lib/shape.js";
+import { reflow } from "./lib/reflow.js";
+import { buildDocx } from "./lib/docx.js";
const $ = (id) => document.getElementById(id);
const sm = window.silentmode || null;
@@ -83,6 +85,7 @@ function setBusy(on, why) {
function paintChrome() {
const m = session.model;
$("save").disabled = !m;
+ $("convert").disabled = !m;
$("undo").disabled = !m || !m.canUndo();
$("redo").disabled = !m || !m.canRedo();
const nameEl = $("docname");
@@ -843,6 +846,140 @@ $("save").addEventListener("click", doSave);
$("save-go").addEventListener("click", () => closeModal(true));
$("save-cancel").addEventListener("click", () => closeModal(null));
+// ---- convert to Word --------------------------------------------------
+//
+// Named a conversion, not an edit, because that is what it is. A PDF holds
+// glyphs with coordinates; Word wants paragraphs. Everything between is
+// inferred from geometry, so the layout is rebuilt rather than carried over,
+// and the dialog says so before anything is written.
+
+/** Text items and page geometry, in the order the pages currently read. */
+async function gatherPages() {
+ const m = session.model, strip = session.strip;
+ const pages = [];
+ const visible = m.visible();
+ for (let i = 0; i < visible.length; i++) {
+ const uid = visible[i];
+ const view = strip.views.get(uid);
+ if (!view) continue;
+ if (!view.textContent) {
+ try { view.textContent = await view.pdfPage.getTextContent(); }
+ catch { view.textContent = { items: [], styles: {} }; }
+ }
+ const tc = view.textContent;
+ // pdf.js names fonts by an internal id. The real name — the only place a
+ // PDF records bold or italic — is on the font object when it has loaded,
+ // with the style block as a weaker fallback.
+ const fontMap = new Map();
+ for (const id of Object.keys(tc.styles || {})) {
+ let name = tc.styles[id]?.fontFamily || id;
+ try {
+ if (view.pdfPage.commonObjs.has(id)) name = view.pdfPage.commonObjs.get(id)?.name || name;
+ } catch {}
+ fontMap.set(id, name);
+ }
+ const info = m.size(uid);
+ pages.push({
+ index: i,
+ label: i + 1,
+ items: tc.items || [],
+ styles: tc.styles || {},
+ fontMap,
+ widthPt: info.width,
+ heightPt: info.height,
+ replacements: m.annotsFor(uid)
+ .filter((a) => a.kind === "textedit")
+ .map((a) => ({ x: a.x, y: a.y, w: a.w, h: a.h, text: a.text })),
+ });
+ }
+ return pages;
+}
+
+function defaultDocxName() {
+ const base = String(session.name || "document.pdf").replace(/\.pdf$/i, "");
+ return `${base}.docx`;
+}
+
+async function convertToWord() {
+ const m = session.model;
+ if (!m) return;
+ setBusy(true, "Reading the text…");
+ let result;
+ try {
+ result = reflow(await gatherPages());
+ } catch (e) {
+ setBusy(false);
+ console.error("convert failed:", e);
+ status(`Could not read this document's text: ${e?.message || e}`, true);
+ toast(`Could not read this document's text: ${e?.message || e}`, true);
+ return;
+ }
+ setBusy(false);
+
+ const { paragraphs, warnings, stats } = result;
+ $("convert-name").value = defaultDocxName();
+ const list = $("convert-summary");
+ list.textContent = "";
+ const add = (text, warn) => {
+ const li = document.createElement("li");
+ li.textContent = text;
+ if (warn) li.className = "warn";
+ list.append(li);
+ };
+ if (!stats.paragraphs) {
+ add("No text was found in this document at all. If it looks like pages of writing, "
+ + "they are images of writing, and turning those into text needs character recognition, "
+ + "which this editor does not do.", true);
+ } else {
+ add(`${stats.paragraphs} paragraph${stats.paragraphs === 1 ? "" : "s"} across `
+ + `${stats.pages} page${stats.pages === 1 ? "" : "s"}, body text read as ${stats.bodySize} pt`);
+ if (stats.headings) add(`${stats.headings} of them look like headings and will carry Word's heading styles`);
+ }
+ for (const w of warnings) add(w, true);
+ add("Bold, italic, size and colour carry over. The layout is rebuilt, so line breaks and "
+ + "page breaks will not land where they do in the PDF. Tables become plain paragraphs, "
+ + "and images, forms and your annotations are not included.");
+
+ $("convert-go").disabled = !stats.paragraphs;
+ const go = await openModal("modal-convert");
+ $("convert-go").disabled = false;
+ if (!go) return;
+
+ let filename = String($("convert-name").value || "").trim() || defaultDocxName();
+ if (!/\.docx$/i.test(filename)) filename += ".docx";
+
+ setBusy(true, "Writing…");
+ try {
+ const first = (await gatherPages())[0];
+ const bytes = buildDocx({
+ paragraphs,
+ page: { widthPt: first?.widthPt || 612, heightPt: first?.heightPt || 792 },
+ defaults: { size: stats.bodySize || 11, family: "Calibri" },
+ });
+ const blob = new Blob([bytes], {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ });
+ const url = URL.createObjectURL(blob);
+ const a = $("download-link");
+ a.href = url;
+ a.download = filename;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 8000);
+ const kb = (bytes.length / 1024).toFixed(0);
+ status(`Converted to ${filename} · ${kb} KB · ${stats.paragraphs} paragraphs`);
+ toast(`Converted to ${filename} (${kb} KB)`);
+ } catch (e) {
+ console.error("docx write failed:", e);
+ status(`Could not write the Word file: ${e?.message || e}`, true);
+ toast(`Could not write the Word file: ${e?.message || e}`, true);
+ } finally {
+ setBusy(false);
+ }
+}
+$("convert").addEventListener("click", convertToWord);
+$("convert-go").addEventListener("click", () => closeModal(true));
+$("convert-cancel").addEventListener("click", () => closeModal(null));
+
// ---- discard / close --------------------------------------------------
async function confirmDiscard() {
if (!session.model || !session.model.isDirty()) return true;
diff --git a/bundled-addons/pdf-editor/lib/docx.js b/bundled-addons/pdf-editor/lib/docx.js
new file mode 100644
index 0000000..781229a
--- /dev/null
+++ b/bundled-addons/pdf-editor/lib/docx.js
@@ -0,0 +1,189 @@
+// A very small .docx writer.
+//
+// A Word document is a ZIP of XML parts, and the subset this editor can
+// honestly produce — paragraphs of styled runs — needs five of them. Writing
+// those by hand costs about two hundred lines. Vendoring a document builder to
+// do it would cost 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, not deflated. Word accepts them, the files are small
+// enough that compression buys little, and it keeps a compressor out of the
+// add-on. The one thing that cannot be skipped is the CRC of each entry: get
+// it wrong and Word reports the file as corrupt rather than telling you which
+// part upset it.
+
+const enc = new TextEncoder();
+
+// ---- zip --------------------------------------------------------------
+let CRC_TABLE = null;
+function crcTable() {
+ if (CRC_TABLE) return CRC_TABLE;
+ CRC_TABLE = new Uint32Array(256);
+ for (let n = 0; n < 256; n++) {
+ let c = n;
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ CRC_TABLE[n] = c >>> 0;
+ }
+ return CRC_TABLE;
+}
+function crc32(bytes) {
+ const t = crcTable();
+ let c = 0xffffffff;
+ for (let i = 0; i < bytes.length; i++) c = t[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
+ return (c ^ 0xffffffff) >>> 0;
+}
+
+class Writer {
+ constructor() { this.parts = []; this.length = 0; }
+ bytes(b) { this.parts.push(b); this.length += b.length; }
+ u16(n) { this.bytes(new Uint8Array([n & 255, (n >>> 8) & 255])); }
+ u32(n) { this.bytes(new Uint8Array([n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255])); }
+ concat() {
+ const out = new Uint8Array(this.length);
+ let at = 0;
+ for (const p of this.parts) { out.set(p, at); at += p.length; }
+ return out;
+ }
+}
+
+/** @param {{name:string, data:Uint8Array}[]} files */
+export function zip(files) {
+ const w = new Writer();
+ const central = [];
+ for (const f of files) {
+ const name = enc.encode(f.name);
+ const crc = crc32(f.data);
+ const offset = w.length;
+ w.u32(0x04034b50);
+ w.u16(20); w.u16(0); w.u16(0); // version, flags, method 0 = stored
+ w.u16(0); w.u16(0); // mod time / date, left at zero
+ w.u32(crc); w.u32(f.data.length); w.u32(f.data.length);
+ w.u16(name.length); w.u16(0);
+ w.bytes(name);
+ w.bytes(f.data);
+ central.push({ name, crc, size: f.data.length, offset });
+ }
+ const dirStart = w.length;
+ for (const c of central) {
+ w.u32(0x02014b50);
+ w.u16(20); w.u16(20); w.u16(0); w.u16(0);
+ w.u16(0); w.u16(0);
+ w.u32(c.crc); w.u32(c.size); w.u32(c.size);
+ w.u16(c.name.length); w.u16(0); w.u16(0);
+ w.u16(0); w.u16(0); w.u32(0);
+ w.u32(c.offset);
+ w.bytes(c.name);
+ }
+ const dirSize = w.length - dirStart;
+ w.u32(0x06054b50);
+ w.u16(0); w.u16(0);
+ w.u16(central.length); w.u16(central.length);
+ w.u32(dirSize); w.u32(dirStart);
+ w.u16(0);
+ return w.concat();
+}
+
+// ---- xml --------------------------------------------------------------
+export function xmlEscape(s) {
+ return String(s ?? "")
+ .replace(/&/g, "&").replace(//g, ">")
+ .replace(/"/g, """)
+ // XML 1.0 has no way to represent these at all, and Word rejects the file
+ // rather than ignoring them. PDFs do contain them — stray control bytes
+ // from odd encodings — so they are dropped here rather than at the source.
+ .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
+}
+
+const HEADING_STYLE = { 1: "Heading1", 2: "Heading2", 3: "Heading3" };
+
+function runXml(run, defaults) {
+ const props = [];
+ const family = run.family || defaults.family;
+ if (family) props.push(` `);
+ if (run.bold) props.push(" ");
+ if (run.italic) props.push(" ");
+ // Word measures type in half-points.
+ const size = Math.max(2, Math.round((run.size || defaults.size) * 2));
+ props.push(` `);
+ const colour = (run.color || "").replace("#", "").toUpperCase();
+ if (/^[0-9A-F]{6}$/.test(colour) && colour !== "000000") props.push(` `);
+ const rPr = props.length ? `${props.join("")} ` : "";
+ return `${rPr}${xmlEscape(run.text)} `;
+}
+
+function paragraphXml(p, defaults) {
+ const props = [];
+ if (p.heading && HEADING_STYLE[p.heading]) props.push(` `);
+ if (p.align && p.align !== "left") props.push(` `);
+ const pPr = props.length ? `${props.join("")} ` : "";
+ const runs = (p.runs || []).map((r) => runXml(r, defaults)).join("");
+ return `${pPr}${runs} `;
+}
+
+const CONTENT_TYPES = `
+
+
+
+
+
+ `;
+
+const ROOT_RELS = `
+
+
+ `;
+
+const DOC_RELS = `
+
+
+ `;
+
+function stylesXml(defaults) {
+ const size = Math.max(2, Math.round(defaults.size * 2));
+ const heading = (n, pts) => `
+
+
+ `;
+ return `
+
+
+
+
+
+
+${heading(1, 20)}${heading(2, 16)}${heading(3, 13)}
+ `;
+}
+
+/**
+ * Build a .docx from paragraphs.
+ *
+ * @param {object} doc
+ * @param {{heading?:number, align?:string, runs:{text:string,size?:number,bold?:boolean,italic?:boolean,color?:string,family?:string}[]}[]} doc.paragraphs
+ * @param {{widthPt:number, heightPt:number}} doc.page page size, in points
+ * @param {{size:number, family:string}} [doc.defaults]
+ * @returns {Uint8Array} the .docx bytes
+ */
+export function buildDocx({ paragraphs, page, defaults }) {
+ const def = { size: 11, family: "Calibri", ...(defaults || {}) };
+ // Word works in twips: 20 to the point.
+ const tw = (pt) => Math.max(1, Math.round(pt * 20));
+ const body = paragraphs.map((p) => paragraphXml(p, def)).join("\n");
+ const sect = ` `
+ + ` `;
+ const document = `
+
+
+${body}
+${sect}
+
+ `;
+
+ return zip([
+ { name: "[Content_Types].xml", data: enc.encode(CONTENT_TYPES) },
+ { name: "_rels/.rels", data: enc.encode(ROOT_RELS) },
+ { name: "word/_rels/document.xml.rels", data: enc.encode(DOC_RELS) },
+ { name: "word/styles.xml", data: enc.encode(stylesXml(def)) },
+ { name: "word/document.xml", data: enc.encode(document) },
+ ]);
+}
diff --git a/bundled-addons/pdf-editor/lib/reflow.js b/bundled-addons/pdf-editor/lib/reflow.js
new file mode 100644
index 0000000..da5efcf
--- /dev/null
+++ b/bundled-addons/pdf-editor/lib/reflow.js
@@ -0,0 +1,277 @@
+// 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;
+}