diff --git a/bundled-addons/pdf-editor/addon.json b/bundled-addons/pdf-editor/addon.json index 157e1871..3b1d9fb0 100644 --- a/bundled-addons/pdf-editor/addon.json +++ b/bundled-addons/pdf-editor/addon.json @@ -1,7 +1,7 @@ { "id": "pdf-editor", "name": "PDF Editor", - "version": "0.8.1", + "version": "0.9.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. Build a PDF from pictures, one page each, or join several PDFs into one. Marks are written into the saved file as real PDF content. The original is never touched.", "author": "Silent Mode", "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMmg4bDYgNnYxMmEyIDIgMCAwIDEtMiAySDZhMiAyIDAgMCAxLTItMlY0YTIgMiAwIDAgMSAyLTJ6IiBmaWxsPSIjZGMyNjI2Ii8+PHBhdGggZD0iTTE0IDJ2Nmg2eiIgZmlsbD0iIzk5MWIxYiIvPjx0ZXh0IHg9IjEyIiB5PSIxNyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZm9udC1mYW1pbHk9InN5c3RlbS11aSwtYXBwbGUtc3lzdGVtLHNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iNS41IiBmb250LXdlaWdodD0iODAwIiBmaWxsPSIjZmZmIj5QREY8L3RleHQ+PC9zdmc+", diff --git a/bundled-addons/pdf-editor/editor.css b/bundled-addons/pdf-editor/editor.css index c35120ac..b9185e6f 100644 --- a/bundled-addons/pdf-editor/editor.css +++ b/bundled-addons/pdf-editor/editor.css @@ -296,6 +296,24 @@ body[data-mode="select"] .smOverlay .mark[data-fill="1"] { pointer-events: all; vector-effect: non-scaling-stroke; } .smOverlay .handle { fill: var(--acid); stroke: #101418; stroke-width: 1; vector-effect: non-scaling-stroke; cursor: pointer; pointer-events: all; } +/* The rotate grip. A turning arrow on a disc, and a cursor that says turn + rather than the open hand the browser gives `grab`, which reads as "drag + the page". No standard cursor means turn, so it is drawn here. */ +.smOverlay .handle.rot { fill: none; stroke: none; } +.smOverlay .rotdisc { fill: var(--acid); stroke: #101418; stroke-width: 1; + vector-effect: non-scaling-stroke; } +.smOverlay .rotmark { fill: none; stroke: #101418; stroke-width: 1.4; + stroke-linecap: round; stroke-linejoin: round; + vector-effect: non-scaling-stroke; } +.smOverlay .rotarm { stroke: var(--acid); stroke-width: 1; stroke-dasharray: 2 2; + vector-effect: non-scaling-stroke; } +.smOverlay .handle.rot, +body[data-rotating="1"] { cursor: url("data:image/svg+xml;utf8,\ +%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 26 26'%3E\ +%3Cg fill='none' stroke='%23101418' stroke-width='3.6' stroke-linecap='round' stroke-linejoin='round'%3E\ +%3Cpath d='M19 13a6 6 0 1 1-1.8-4.2'/%3E%3Cpath d='M17.4 4.2v4.6h-4.6'/%3E%3C/g%3E\ +%3Cg fill='none' stroke='%23ffffff' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E\ +%3Cpath d='M19 13a6 6 0 1 1-1.8-4.2'/%3E%3Cpath d='M17.4 4.2v4.6h-4.6'/%3E%3C/g%3E%3C/svg%3E") 13 13, grab; } /* pdf.js renders form widgets with a coloured backdrop; keep it, it is the only cue that a field is fillable. */ diff --git a/bundled-addons/pdf-editor/editor.html b/bundled-addons/pdf-editor/editor.html index 72ef04b0..d56555af 100644 --- a/bundled-addons/pdf-editor/editor.html +++ b/bundled-addons/pdf-editor/editor.html @@ -259,28 +259,6 @@ -
A PDF stores glyphs with coordinates, not paragraphs. Converting means working out diff --git a/bundled-addons/pdf-editor/editor.js b/bundled-addons/pdf-editor/editor.js index d60b3a45..5bd9b298 100644 --- a/bundled-addons/pdf-editor/editor.js +++ b/bundled-addons/pdf-editor/editor.js @@ -21,7 +21,7 @@ import { Tools } from "./lib/tools.js"; import { SignaturePad } from "./lib/signature.js"; import { InlineText } from "./lib/inline.js"; import { buildPdf, unencodableStamps, replacedRuns } from "./lib/save.js"; -import { boundsOf, clampFont, translatePatch, unionOf } from "./lib/shape.js"; +import { ASCENT, boundsOf, clampFont, translatePatch, unionOf } from "./lib/shape.js"; import { reflow } from "./lib/reflow.js"; import { buildDocx } from "./lib/docx.js"; import { imagesToPdf, mergePdfs } from "./lib/create.js"; @@ -202,7 +202,6 @@ async function loadBytes(bytes, name, scratchId = null) { selectTool, onSelectionChange: paintSelectionBar, beginText, - editText: onTextModal, editRun: onEditRun, chooseSignature, signature: () => savedSignature, @@ -562,6 +561,38 @@ $("zoom").addEventListener("change", (e) => { }); $("zoom-in").addEventListener("click", () => { session.strip?.stepScale(1); syncZoomSelect(); }); $("zoom-out").addEventListener("click", () => { session.strip?.stepScale(-1); syncZoomSelect(); }); + +/** + * Ctrl/⌘ + wheel zooms, and keeps the page under the pointer under it. + * + * Plain wheel still scrolls, because that is what a wheel does in every + * reader. Without `passive: false` the preventDefault is ignored and the + * browser zooms the whole tab instead of the document — which is why this + * listener is on the container and not on the window. + * + * Zooming about the pointer rather than the top-left is the part that makes + * it usable: the words you were reading stay where they were instead of + * sliding off the screen as the page grows. + */ +$("viewerContainer").addEventListener("wheel", (e) => { + if (!(e.ctrlKey || e.metaKey) || !session.strip) return; + e.preventDefault(); + const strip = session.strip; + const before = strip.scale; + strip.stepScale(e.deltaY < 0 ? 1 : -1); + const after = strip.scale; + if (after === before) return; + const c = $("viewerContainer"); + const r = c.getBoundingClientRect(); + const k = after / before; + // The point under the cursor, in content coordinates, has to land back + // under the cursor once everything has grown by k. + const px = c.scrollLeft + (e.clientX - r.left); + const py = c.scrollTop + (e.clientY - r.top); + c.scrollLeft = px * k - (e.clientX - r.left); + c.scrollTop = py * k - (e.clientY - r.top); + syncZoomSelect(); +}, { passive: false }); function syncZoomSelect() { positionSelectionBar(); if (!session.strip) return; @@ -654,31 +685,34 @@ function sampleRun(run) { } catch { return fallback; } } -async function onEditRun(run) { +/** + * Edit a line of the document's own text, in place. + * + * This used to open a dialog holding a copy of the line. That is the wrong + * shape for the job twice over: you edit words while looking at a box + * instead of at the page, and what you are really doing — covering the + * original and drawing over it — is invisible until you commit, at which + * point a replacement that does not quite line up reads as a copy pasted on + * top of the original. + * + * So the cover goes down FIRST, carrying the line's own words in the page's + * own size and colour, and the caret opens on it. Nothing appears to happen + * except that the line becomes editable, which is what the user asked for. + * Escape with nothing changed takes the cover away again. + */ +function onEditRun(run) { const look = sampleRun(run); - $("modal-text-title").textContent = "Replace text"; - $("text-body").value = run.text; - $("text-size-field").hidden = true; // the size comes from the document - $("text-bold").classList.remove("active"); - $("text-italic").classList.remove("active"); - $("text-warn").hidden = true; - const note = $("text-note"); - note.hidden = false; - note.textContent = `${run.size.toFixed(1)} pt from the document. The replacement is drawn over the original, ` - + `which stays in the file underneath — use Redact if it has to be gone.`; - const ok = await openModal("modal-text"); - $("text-size-field").hidden = false; - note.hidden = true; - if (!ok) return null; const a = session.tools.replaceRun(run, { - text: $("text-body").value, + text: run.text, size: run.size, color: look.ink, cover: look.bg, - bold: $("text-bold").classList.contains("active"), - italic: $("text-italic").classList.contains("active"), }); - if (a && !look.sampled) toast("Could not read the page's colours; used black on white.", true); + if (!a) return null; + if (!look.sampled) toast("Could not read the page's colours; used black on white.", true); + beginText({ id: a.id, fresh: true }); + status(`${run.size.toFixed(1)} pt from the document — the original stays in the file underneath. ` + + `Escape leaves it as it was.`); return a; } @@ -804,7 +838,7 @@ $("sb-edit").addEventListener("click", () => { const id = session.tools?.selected; if (!id) return; const a = session.model?.annot(id); - if (a?.kind === "text") beginText({ id }); else onTextModal(id, null); + if (a?.kind === "text" || a?.kind === "textedit") beginText({ id }); }); // Pressing a style button must not take the caret away: focus leaving the // editor is how a click elsewhere finishes the edit, and these are not @@ -872,7 +906,17 @@ function inlineEditor() { // A mark being re-edited was hidden, not removed; put it back as it // was. A brand-new one was never added, so there is nothing to undo. const a = s.id ? session.model?.annot(s.id) : null; - if (a) { delete a._editing; session.strip?.refreshOverlay(s.uid, s.id); } + if (a) delete a._editing; + // ...except a cover laid down just to open this caret. Escaping out + // of it should leave the page exactly as it was found, not leave a + // replacement of a line by itself. + if (a && s.fresh) { + session.tools.select(a.id); + session.tools.deleteSelected(); + paintChrome(); + return; + } + if (a) session.strip?.refreshOverlay(s.uid, s.id); paintSelectionBar(session.tools?.selected || null); status(""); }, @@ -892,19 +936,29 @@ function beginText(where) { const ed = inlineEditor(); const existing = where.id ? session.model.annot(where.id) : null; if (where.id && !existing) return; + // A stamp carries the top of its block; a replaced line carries its + // baseline, because that is what it has to keep sharing with the line it + // sits on. The caret wants the top either way. + const isRun = existing?.kind === "textedit"; const seed = existing - ? { uid: existing.page, x: existing.x, y: existing.y, id: existing.id, + ? { uid: existing.page, id: existing.id, + x: existing.x, + y: isRun ? existing.baseline + (existing.size || 12) * ASCENT : existing.y, text: existing.text, size: existing.size || 12, - bold: !!existing.bold, italic: !!existing.italic, color: existing.color } + bold: !!existing.bold, italic: !!existing.italic, color: existing.color, + fresh: !!where.fresh, was: existing.text } : { uid: where.uid, x: where.x, y: where.y, text: "", size: lastTextSize, bold: lastTextBold, italic: lastTextItalic, color: session.tools.ink }; // While it is being typed, the stamp itself is hidden: seeing the old words - // underneath the new ones reads as a rendering fault. + // underneath the new ones reads as a rendering fault. A replaced line keeps + // its cover — that is what is holding the original out of sight. if (existing) { existing._editing = true; session.strip.refreshOverlay(existing.page, existing.id); } if (!ed.start(seed)) { if (existing) delete existing._editing; return; } paintInlineBar(); - status(existing ? "Editing text — Escape to leave it as it was." : "Type. Click elsewhere when you are done."); + if (!isRun) { + status(existing ? "Editing text — Escape to leave it as it was." : "Type. Click elsewhere when you are done."); + } } function finishInlineText(s) { @@ -914,13 +968,24 @@ function finishInlineText(s) { const existing = s.id ? model.annot(s.id) : null; if (existing) delete existing._editing; const text = String(s.text || ""); + // A cover laid down only to open this caret, closed without a word being + // changed, is not an edit and should not be one on the undo stack. + if (existing && s.fresh && text === s.was) { + session.tools.select(existing.id); + session.tools.deleteSelected(); + paintChrome(); + status(""); + return; + } if (existing) { // updateText deletes the mark when the text is emptied, which is the // right reading of "select it all and press delete". session.tools.updateText(s.id, { text, size: s.size, bold: s.bold, italic: s.italic }); + warnUnencodable(text); } else if (text.trim()) { session.tools.placeText({ uid: s.uid, x: s.x, y: s.y }, { text, size: s.size, bold: s.bold, italic: s.italic }); + warnUnencodable(text); } else { // Typed nothing. Nothing was ever added, so nothing has to be taken away. session.strip?.refreshOverlay(s.uid, session.tools?.selected || null); @@ -1019,51 +1084,17 @@ function positionInlineBar() { let lastTextSize = 12, lastTextBold = false, lastTextItalic = false; -async function onTextModal(id, at) { - const existing = id ? session.model?.annot(id) : null; - $("modal-text-title").textContent = existing ? "Edit text" : "Text stamp"; - $("text-body").value = existing ? existing.text : ""; - // A run lifted out of a document is whatever size the document set — 11 pt, - // 9.5 pt — and the picker only lists round numbers. Without this the select - // falls back to empty and the size silently becomes 12 on the way out. - const wanted = existing ? existing.size || 12 : 12; - const sizeSel = $("text-size"); - if (![...sizeSel.options].some((o) => Number(o.value) === Number(wanted))) { - const opt = document.createElement("option"); - opt.value = String(wanted); - opt.textContent = String(Math.round(wanted * 10) / 10); - opt.dataset.adhoc = "1"; - sizeSel.append(opt); +// Typing happens on the page now, so the dialog that used to hold a copy of +// the words is gone. What it did that the caret does not is warn about +// characters the built-in font cannot write — so the caret says it on the way +// out instead, once, rather than as you type. +function warnUnencodable(text) { + const n = unencodableStampsIn(text); + if (n) { + toast(`${n} character${n === 1 ? "" : "s"} cannot be written with the built-in font ` + + `and will be saved as "?".`, true); } - sizeSel.value = String(wanted); - $("text-bold").classList.toggle("active", !!existing?.bold); - $("text-italic").classList.toggle("active", !!existing?.italic); - $("text-warn").hidden = true; - const p = openModal("modal-text"); - setTimeout(() => { $("text-body").focus(); $("text-body").select(); }, 30); - const ok = await p; - const props = { - text: $("text-body").value, - size: parseFloat($("text-size").value) || wanted, - bold: $("text-bold").classList.contains("active"), - italic: $("text-italic").classList.contains("active"), - }; - for (const o of [...$("text-size").options]) if (o.dataset.adhoc) o.remove(); - if (!ok) return null; - return existing ? session.tools.updateText(id, props) : session.tools.placeText(at, props); } -for (const b of [$("text-bold"), $("text-italic")]) { - b?.addEventListener("click", () => b.classList.toggle("active")); -} -$("text-ok").addEventListener("click", () => closeModal(true)); -$("text-cancel").addEventListener("click", () => closeModal(null)); -$("text-body").addEventListener("input", () => { - const n = unencodableStampsIn($("text-body").value); - const w = $("text-warn"); - w.hidden = n === 0; - w.classList.add("warn"); - if (n) w.textContent = `${n} character${n === 1 ? "" : "s"} cannot be written with the built-in font and will be saved as "?".`; -}); function unencodableStampsIn(text) { // Same WinAnsi test the writer uses, applied live so the user finds out // before placing the stamp rather than after saving. @@ -1566,7 +1597,7 @@ window.addEventListener("keydown", (e) => { if (session.tools?.selection?.size > 1) return; // one caret, one mark const a = sel && session.model?.annot(sel); if (a?.kind === "text") { beginText({ id: sel }); e.preventDefault(); return; } - if (a?.kind === "textedit") { onTextModal(sel, null); e.preventDefault(); } + if (a?.kind === "textedit") { beginText({ id: sel }); e.preventDefault(); } return; } if (e.key === "+" || e.key === "=") { session.strip?.stepScale(1); syncZoomSelect(); e.preventDefault(); return; } diff --git a/bundled-addons/pdf-editor/lib/inline.js b/bundled-addons/pdf-editor/lib/inline.js index 0000dd52..6b8bec5e 100644 --- a/bundled-addons/pdf-editor/lib/inline.js +++ b/bundled-addons/pdf-editor/lib/inline.js @@ -87,13 +87,22 @@ export class InlineText { * @param {number} o.y user-space TOP of the text block * @param {string} [o.id] the mark being re-edited, if any */ - start({ uid, x, y, id = null, text = "", size = 12, bold = false, italic = false, color = "#1a1f2b" }) { + /** + * `fresh` marks a mark that exists only to hold this caret — the cover laid + * over a line of the document's own text — and `was` is what it said when + * the caret opened. Together they let the caller undo the whole thing when + * nothing ends up changed, so that opening a line and closing it again + * leaves no trace. They are carried here rather than looked up later + * because by the time the caret closes the mark may be gone. + */ + start({ uid, x, y, id = null, text = "", size = 12, bold = false, italic = false, + color = "#1a1f2b", fresh = false, was = null }) { this.close(true); const view = this.strip.views.get(uid); const ov = this.strip.overlayFor(uid); if (!view || !ov?.viewport) return false; - this.state = { uid, x, y, id, size, bold, italic, color }; + this.state = { uid, x, y, id, size, bold, italic, color, fresh, was }; const el = document.createElement("div"); el.className = "inline-text"; el.contentEditable = "plaintext-only"; diff --git a/bundled-addons/pdf-editor/lib/overlay.js b/bundled-addons/pdf-editor/lib/overlay.js index 7383a64a..fede1e2e 100644 --- a/bundled-addons/pdf-editor/lib/overlay.js +++ b/bundled-addons/pdf-editor/lib/overlay.js @@ -17,10 +17,10 @@ const NS = "http://www.w3.org/2000/svg"; const HANDLE = 9; // grab-handle size in CSS px, deliberately not scaled with zoom let clipSeq = 0; // clipPath ids must be unique across every page in the document const HIT_TOLERANCE = 14; // CSS px of grab room around a thin stroke -const ROTATE_ARM = 22; // CSS px between the box and its rotate grip -/** Kinds that can be turned. A signature, because signing lines are not - * always square to the page; nothing else has asked for it. */ -const ROTATABLE = new Set(["signature"]); +const ROTATE_ARM = 22; // CSS px between the box and its rotate grip +/** Kinds that can be turned. A signature, because signing lines are not + * always square to the page; nothing else has asked for it. */ +const ROTATABLE = new Set(["signature"]); function el(name, attrs) { const n = document.createElementNS(NS, name); for (const k in attrs) if (attrs[k] != null) n.setAttribute(k, attrs[k]); @@ -80,8 +80,14 @@ export class Overlay { for (const a of annots) { // A stamp being typed is drawn by the caret instead; two copies of the // same words, one stale, reads as a rendering fault. - if (a._editing) continue; - const node = this._draw(a); + // + // A replaced line keeps its cover while being typed, and loses only its + // words: the cover is the thing holding the document's own line out of + // sight, and dropping it would put the original back under the caret — + // which is precisely the "a copy on top of the original" that editing + // in place exists to avoid. + if (a._editing && a.kind !== "textedit") continue; + const node = this._draw(a._editing ? { ...a, text: "" } : a); if (!node) continue; this._addHitArea(node, a); node.classList.add("mark"); @@ -346,8 +352,7 @@ export class Overlay { if (ROTATABLE.has(sel.kind)) { const ry = T - ROTATE_ARM; this.chrome.append(el("line", { class: "rotarm", x1: MX, y1: T, x2: MX, y2: ry })); - const g = this._handle("rotate", MX, ry, "grab"); - if (g) g.classList.add("rot"); + this._rotateHandle(MX, ry); } } @@ -396,6 +401,35 @@ export class Overlay { return h; } + /** + * The rotate grip: a round button with a turning arrow on it. + * + * It was a plain square like the resize handles, which said "drag me to + * make this bigger" — the one thing it does not do — and the cursor over + * it was an open hand, which says "drag the page". A circle with an arrow + * curving round it says turn, in every drawing program there has ever + * been. + */ + _rotateHandle(cx, cy) { + // No inline cursor: an inline style beats the stylesheet, and the + // stylesheet is where the turning-arrow cursor lives. + const g = el("g", { class: "handle rot" }); + g.dataset.handle = "rotate"; + g.append(el("circle", { cx, cy, r: 7.5, class: "rotdisc" })); + // Three-quarters of a circle with an arrowhead at the open end. + const r = 4.2; + g.append(el("path", { + class: "rotmark", + d: `M ${cx + r} ${cy} A ${r} ${r} 0 1 1 ${(cx - r * 0.7).toFixed(2)} ${(cy - r * 0.7).toFixed(2)}`, + })); + g.append(el("path", { + class: "rotmark", + d: `M ${(cx - r * 1.5).toFixed(2)} ${(cy - r * 0.55).toFixed(2)} L ${(cx - r * 0.55).toFixed(2)} ${(cy - r * 1.0).toFixed(2)} L ${(cx - r * 0.2).toFixed(2)} ${(cy - r * 0.05).toFixed(2)}`, + })); + this.chrome.append(g); + return g; + } + // Stroke and fill for a shape, honouring its fill and opacity. _paint(a) { return { diff --git a/bundled-addons/pdf-editor/lib/tools.js b/bundled-addons/pdf-editor/lib/tools.js index e6dd752a..f11879dc 100644 --- a/bundled-addons/pdf-editor/lib/tools.js +++ b/bundled-addons/pdf-editor/lib/tools.js @@ -50,7 +50,7 @@ export class Tools { this.strip = strip; this.model = model; // host (editor.js): { toast, onChange, selectTool, onSelectionChange, - // beginText, editText, editRun, chooseSignature, + // beginText, editRun, chooseSignature, // signature } this.host = host; this.tool = "select"; @@ -340,7 +340,7 @@ export class Tools { // A stamp is edited where it sits; a replaced run keeps its dialog, // which is where it explains what it is replacing. if (a?.kind === "text") { this.host.beginText?.({ id }); return; } - if (a?.kind === "textedit") { this.host.editText?.(id, null); return; } + if (a?.kind === "textedit") { this.host.beginText?.({ id }); return; } } this.drag = this._beginMove(hit.uid, id, x, y, e); return; @@ -612,12 +612,68 @@ export class Tools { * — so the size survives zooming, which reading it off the rendered box * would not. */ + /** + * The whole visual line the clicked span belongs to. + * + * A line of a PDF is rarely one span. pdf.js splits it wherever the file + * does — a font change, a kerning adjustment, a ligature — so "Quarterly + * Report" can be three spans and a real invoice line can be ten. Replacing + * one of them covered a fragment and left the rest of the line standing, + * which is what a replacement that looks like a copy laid on top of the + * original actually is. + * + * Same line means: the vertical middles agree, and the horizontal gaps are + * small enough to be spacing rather than a second column. + */ + _lineSpans(span) { + const layer = span.closest(".textLayer"); + if (!layer) return [span]; + const r0 = span.getBoundingClientRect(); + if (!(r0.height > 0)) return [span]; + const mid = r0.top + r0.height / 2; + const tol = Math.max(3, r0.height * 0.4); + const row = [...layer.querySelectorAll("span")] + .map((el) => ({ el, r: el.getBoundingClientRect() })) + .filter((s) => s.r.width > 0 && s.r.height > 0 && + Math.abs(s.r.top + s.r.height / 2 - mid) <= tol) + .sort((a, b) => a.r.left - b.r.left); + const i = row.findIndex((s) => s.el === span); + if (i < 0) return [span]; + // A gap wider than a couple of spaces is another block of text, not the + // rest of this line. + const maxGap = Math.max(8, r0.height * 1.2); + let lo = i, hi = i; + while (lo > 0 && row[lo].r.left - row[lo - 1].r.right <= maxGap) lo--; + while (hi < row.length - 1 && row[hi + 1].r.left - row[hi].r.right <= maxGap) hi++; + return row.slice(lo, hi + 1).map((s) => s.el); + } + describeRun(span, uid) { const ov = this.strip.overlayFor(uid); const view = this.strip.views.get(uid); if (!ov?.viewport || !view) return null; const pr = view.pv.div.getBoundingClientRect(); - const r = span.getBoundingClientRect(); + const spans = this._lineSpans(span); + const rects = spans.map((el) => el.getBoundingClientRect()); + // The line's own box, and its text with the spaces the geometry implies: + // pdf.js puts no space between spans, the gap between them IS the space. + const r = { + left: Math.min(...rects.map((b) => b.left)), + right: Math.max(...rects.map((b) => b.right)), + top: Math.min(...rects.map((b) => b.top)), + bottom: Math.max(...rects.map((b) => b.bottom)), + }; + r.width = r.right - r.left; + r.height = r.bottom - r.top; + let lineText = ""; + for (let k = 0; k < spans.length; k++) { + const t = spans[k].textContent; + if (k > 0) { + const gap = rects[k].left - rects[k - 1].right; + if (gap > rects[k].height * 0.18 && !/\s$/.test(lineText) && !/^\s/.test(t)) lineText += " "; + } + lineText += t; + } if (r.width < 1 || r.height < 1) return null; const [ax, ay] = ov.toPdf(r.left - pr.left, r.bottom - pr.top); const [bx, by] = ov.toPdf(r.right - pr.left, r.top - pr.top); @@ -636,7 +692,8 @@ export class Tools { const top = box.y + box.h + size * 0.06; return { uid, - text: span.textContent, + text: lineText, + spans: spans.length, box: { x: box.x - 0.6, y: bottom, w: box.w + 1.2, h: top - bottom }, size, baseline,