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.
This commit is contained in:
parent
870986de21
commit
c4f7251860
5 changed files with 624 additions and 1 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"id": "pdf-editor",
|
"id": "pdf-editor",
|
||||||
"name": "PDF Editor",
|
"name": "PDF Editor",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Marks are written into the saved file as real PDF content. The original is never touched.",
|
"description": "Open a PDF in a full tab: read it, mark it up with highlight, underline, strike-through, pen, shapes, arrows, text and a drawn signature, then move, resize, restyle or delete anything you placed. Reorder, rotate or drop pages, fill in form fields, redact for real, and save a new copy. Marks are written into the saved file as real PDF content. The original is never touched.",
|
||||||
"author": "Silent Mode",
|
"author": "Silent Mode",
|
||||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
|
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@
|
||||||
<span class="sep"></span>
|
<span class="sep"></span>
|
||||||
<span class="docname" id="docname">No document</span>
|
<span class="docname" id="docname">No document</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
|
<button class="btn wide" id="convert" title="Convert this document to an editable Word file" disabled>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2h6l4 4v8H3z"/><path d="M9 2v4h4"/><path d="M5.5 8.5l1 3 1.5-3 1.5 3 1-3"/></svg>
|
||||||
|
<span>Convert to Word…</span>
|
||||||
|
</button>
|
||||||
<button class="btn wide primary" id="save" title="Save a copy (Ctrl+S)" disabled>
|
<button class="btn wide primary" id="save" title="Save a copy (Ctrl+S)" disabled>
|
||||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2v9M4 7l4 4 4-4M2 14h12"/></svg>
|
||||||
<span>Save a copy</span>
|
<span>Save a copy</span>
|
||||||
|
|
@ -212,6 +216,22 @@
|
||||||
<p class="mhint" id="text-warn" hidden></p>
|
<p class="mhint" id="text-warn" hidden></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="modal-convert" hidden>
|
||||||
|
<h2>Convert to Word</h2>
|
||||||
|
<p>A PDF stores glyphs with coordinates, not paragraphs. Converting means working out
|
||||||
|
where the paragraphs were, so the result is a <strong>rebuilt</strong> document that reads
|
||||||
|
the same rather than a copy that looks the same.</p>
|
||||||
|
<label class="mfield block">File name
|
||||||
|
<input type="text" id="convert-name" spellcheck="false">
|
||||||
|
</label>
|
||||||
|
<ul class="summary" id="convert-summary"></ul>
|
||||||
|
<div class="mrow">
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="btn wide" id="convert-cancel">Cancel</button>
|
||||||
|
<button class="btn wide primary" id="convert-go">Convert</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal" id="modal-save" hidden>
|
<div class="modal" id="modal-save" hidden>
|
||||||
<h2>Save a copy</h2>
|
<h2>Save a copy</h2>
|
||||||
<label class="mfield block">File name
|
<label class="mfield block">File name
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import { Tools } from "./lib/tools.js";
|
||||||
import { SignaturePad } from "./lib/signature.js";
|
import { SignaturePad } from "./lib/signature.js";
|
||||||
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
|
import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js";
|
||||||
import { boundsOf, clampFont } from "./lib/shape.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 $ = (id) => document.getElementById(id);
|
||||||
const sm = window.silentmode || null;
|
const sm = window.silentmode || null;
|
||||||
|
|
@ -83,6 +85,7 @@ function setBusy(on, why) {
|
||||||
function paintChrome() {
|
function paintChrome() {
|
||||||
const m = session.model;
|
const m = session.model;
|
||||||
$("save").disabled = !m;
|
$("save").disabled = !m;
|
||||||
|
$("convert").disabled = !m;
|
||||||
$("undo").disabled = !m || !m.canUndo();
|
$("undo").disabled = !m || !m.canUndo();
|
||||||
$("redo").disabled = !m || !m.canRedo();
|
$("redo").disabled = !m || !m.canRedo();
|
||||||
const nameEl = $("docname");
|
const nameEl = $("docname");
|
||||||
|
|
@ -843,6 +846,140 @@ $("save").addEventListener("click", doSave);
|
||||||
$("save-go").addEventListener("click", () => closeModal(true));
|
$("save-go").addEventListener("click", () => closeModal(true));
|
||||||
$("save-cancel").addEventListener("click", () => closeModal(null));
|
$("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 --------------------------------------------------
|
// ---- discard / close --------------------------------------------------
|
||||||
async function confirmDiscard() {
|
async function confirmDiscard() {
|
||||||
if (!session.model || !session.model.isDirty()) return true;
|
if (!session.model || !session.model.isDirty()) return true;
|
||||||
|
|
|
||||||
189
bundled-addons/pdf-editor/lib/docx.js
Normal file
189
bundled-addons/pdf-editor/lib/docx.js
Normal file
|
|
@ -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, ">")
|
||||||
|
.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(`<w:rFonts w:ascii="${xmlEscape(family)}" w:hAnsi="${xmlEscape(family)}"/>`);
|
||||||
|
if (run.bold) props.push("<w:b/>");
|
||||||
|
if (run.italic) props.push("<w:i/>");
|
||||||
|
// Word measures type in half-points.
|
||||||
|
const size = Math.max(2, Math.round((run.size || defaults.size) * 2));
|
||||||
|
props.push(`<w:sz w:val="${size}"/><w:szCs w:val="${size}"/>`);
|
||||||
|
const colour = (run.color || "").replace("#", "").toUpperCase();
|
||||||
|
if (/^[0-9A-F]{6}$/.test(colour) && colour !== "000000") props.push(`<w:color w:val="${colour}"/>`);
|
||||||
|
const rPr = props.length ? `<w:rPr>${props.join("")}</w:rPr>` : "";
|
||||||
|
return `<w:r>${rPr}<w:t xml:space="preserve">${xmlEscape(run.text)}</w:t></w:r>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paragraphXml(p, defaults) {
|
||||||
|
const props = [];
|
||||||
|
if (p.heading && HEADING_STYLE[p.heading]) props.push(`<w:pStyle w:val="${HEADING_STYLE[p.heading]}"/>`);
|
||||||
|
if (p.align && p.align !== "left") props.push(`<w:jc w:val="${p.align}"/>`);
|
||||||
|
const pPr = props.length ? `<w:pPr>${props.join("")}</w:pPr>` : "";
|
||||||
|
const runs = (p.runs || []).map((r) => runXml(r, defaults)).join("");
|
||||||
|
return `<w:p>${pPr}${runs}</w:p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||||
|
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||||
|
<Default Extension="xml" ContentType="application/xml"/>
|
||||||
|
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||||
|
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||||
|
</Types>`;
|
||||||
|
|
||||||
|
const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||||
|
</Relationships>`;
|
||||||
|
|
||||||
|
const DOC_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||||
|
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||||
|
</Relationships>`;
|
||||||
|
|
||||||
|
function stylesXml(defaults) {
|
||||||
|
const size = Math.max(2, Math.round(defaults.size * 2));
|
||||||
|
const heading = (n, pts) => `<w:style w:type="paragraph" w:styleId="Heading${n}">
|
||||||
|
<w:name w:val="heading ${n}"/><w:basedOn w:val="Normal"/><w:qFormat/>
|
||||||
|
<w:pPr><w:keepNext/><w:spacing w:before="${240 - n * 40}" w:after="${120 - n * 20}"/><w:outlineLvl w:val="${n - 1}"/></w:pPr>
|
||||||
|
<w:rPr><w:b/><w:sz w:val="${pts * 2}"/><w:szCs w:val="${pts * 2}"/></w:rPr></w:style>`;
|
||||||
|
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||||
|
<w:docDefaults><w:rPrDefault><w:rPr>
|
||||||
|
<w:rFonts w:ascii="${xmlEscape(defaults.family)}" w:hAnsi="${xmlEscape(defaults.family)}"/>
|
||||||
|
<w:sz w:val="${size}"/><w:szCs w:val="${size}"/>
|
||||||
|
</w:rPr></w:rPrDefault></w:docDefaults>
|
||||||
|
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
|
||||||
|
${heading(1, 20)}${heading(2, 16)}${heading(3, 13)}
|
||||||
|
</w:styles>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = `<w:sectPr><w:pgSz w:w="${tw(page.widthPt)}" w:h="${tw(page.heightPt)}"/>`
|
||||||
|
+ `<w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="709" w:footer="709" w:gutter="0"/></w:sectPr>`;
|
||||||
|
const document = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||||
|
<w:body>
|
||||||
|
${body}
|
||||||
|
${sect}
|
||||||
|
</w:body>
|
||||||
|
</w:document>`;
|
||||||
|
|
||||||
|
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) },
|
||||||
|
]);
|
||||||
|
}
|
||||||
277
bundled-addons/pdf-editor/lib/reflow.js
Normal file
277
bundled-addons/pdf-editor/lib/reflow.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue