// PDF.js already emits word spaces. Adding a space after every item breaks // Chinese words and words split across fonts. Keep the source's explicit spaces. export function pdfTextContent(items) { return items.filter((item) => typeof item.str === "string") .map((item) => item.str + (item.hasEOL ? "\n" : "")).join(""); } // Embedded OCR often spaces individual Han characters, including vertical text. // Keep punctuation and Latin word boundaries, and map hits back to source offsets. const hanWordSpacing = /(?<=\p{Script=Han})\s+(?=\p{Script=Han})/gu; export function normalizePdfSearchText(raw) { return raw.replace(hanWordSpacing, ""); } export function pdfSourceOffset(mapping, index) { const offsets = mapping.offsets; if (!offsets) return index; let low = 0, high = offsets.length / 2; while (low < high) { const middle = (low + high) >> 1; if (offsets[middle * 2] <= index) low = middle + 1; else high = middle; } return index + (low ? offsets[(low - 1) * 2 + 1] : 0); } export function pdfSearchText(raw) { const offsets = [], chunks = []; let start = 0, removed = 0; for (const match of raw.matchAll(hanWordSpacing)) { chunks.push(raw.slice(start, match.index)); const boundary = match.index - removed; removed += match[0].length; offsets.push(boundary, removed); start = match.index + match[0].length; } if (!offsets.length) return { text: raw, offsets: null }; chunks.push(raw.slice(start)); return { text: chunks.join(""), offsets: new Uint32Array(offsets) }; } function textRun(layer, text) { const run = layer.ownerDocument.createElement("span"); run.className = "reader-pdf-text-run"; run.textContent = text; return run; } const measurements = new WeakMap(); function normalizeBox(value) { return Math.max(0, Math.min(1, Number(value) || 0)); } function measureRunScale(context, text, family, targetWidth) { context.font = `100px ${family}`; const measured = context.measureText(text).width / 100; return measured > 0 && targetWidth > 0 ? targetWidth / measured : 1; } function positionedRun(layer, text, x, y, height, width, family, angle = 0, vertical = false) { const box = layer.ownerDocument.createElement("span"); box.className = "reader-pdf-text-position reader-pdf-text-block"; box.style.left = `${x * 100}%`; box.style.top = `${y * 100}%`; // cqw uses the page width, so text scales with zoom and responsive layout. box.style.fontSize = `${height * 100}cqw`; box.style.fontFamily = family; if (angle) box.style.transform = `rotate(${angle}rad)`; const run = textRun(layer, text); let context = measurements.get(layer.ownerDocument); if (!context) { context = layer.ownerDocument.createElement("canvas").getContext("2d"); measurements.set(layer.ownerDocument, context); } if (vertical) { run.style.writingMode = "vertical-rl"; run.style.textOrientation = "upright"; // Estimate the scale until native PDF text is attached for measurement. const measured = Array.from(text).length; run._readerVerticalAdvance = width / height; if (width > 0 && measured) run.style.transform = `scaleY(${width / (height * measured)})`; } else { const scaleX = measureRunScale(context, text, family, width / height); run.style.setProperty("--reader-pdf-run-scale-x", String(scaleX)); run.style.transform = "scaleX(var(--reader-pdf-run-scale-x))"; } box.appendChild(run); return box; } export function populatePdfTextLayer(layer, items, viewport, styles = {}) { const fragment = layer.ownerDocument.createDocumentFragment(); const w = Math.max(1, viewport.width), h = Math.max(1, viewport.height); for (const item of items) { if (typeof item.str !== "string") continue; if (item.str) { const t = item.transform, v = viewport.transform; const point = t && viewport.convertToViewportPoint?.(t[4], t[5]); if (point && t.every(Number.isFinite)) { const style = styles[item.fontName] || {}; const family = style.fontFamily || "sans-serif"; const vertical = style.vertical || item.dir === "ttb"; const scale = viewport.scale || 1; const height = Math.hypot(t[2], t[3]) * scale || 12; const angle = v ? Math.atan2(v[1] * t[0] + v[3] * t[1], v[0] * t[0] + v[2] * t[1]) : 0; const ascent = height * (Number.isFinite(style.ascent) ? style.ascent : 0.8); const x = vertical ? point[0] - height / 2 : point[0] + ascent * Math.sin(angle); const y = vertical ? point[1] : point[1] - ascent * Math.cos(angle); fragment.appendChild(positionedRun(layer, item.str, x / w, y / h, height / w, (vertical ? item.height : item.width) * scale / w, family, angle, vertical)); } else { // Text without coordinates remains accessible, but never forms a visible // second text panel beneath the page image. fragment.appendChild(textRun(layer, item.str)); } } if (item.hasEOL) fragment.appendChild(layer.ownerDocument.createTextNode("\n")); } layer.replaceChildren(fragment); // Vertical spaces and fallback glyphs have font-dependent advances. Measure // the attached run so selection covers the PDF's actual vertical extent. for (const run of layer.querySelectorAll('.reader-pdf-text-run')) { if (!(run._readerVerticalAdvance > 0) || !run.offsetHeight) continue; const em = parseFloat(layer.ownerDocument.defaultView.getComputedStyle(run).fontSize); run.style.transform = `scaleY(${run._readerVerticalAdvance * em / run.offsetHeight})`; } } export function populateOcrTextLayer(layer, blocks, layout = {}) { const fragment = layer.ownerDocument.createDocumentFragment(); const ratio = layer.parentElement.getBoundingClientRect(); const vertical = layout?.writing_mode === "vertical-rl" || layout?.writing_mode === "vertical-lr"; let positioned = 0; const fallback = []; for (const block of blocks || []) { const text = String(block?.t || ""), b = block?.b; if (!text) continue; if (!Array.isArray(b) || b.length !== 4 || !b.every(Number.isFinite)) { fallback.push(text); continue; } const [x1, y1, x2, y2] = b.map(normalizeBox); const width = Math.abs(x2 - x1), height = Math.abs(y2 - y1); if (!width || !height) { fallback.push(text); continue; } fragment.appendChild(positionedRun(layer, text, Math.min(x1, x2), Math.min(y1, y2), height * ratio.height / Math.max(1, ratio.width), width, "sans-serif", 0, vertical)); fragment.appendChild(layer.ownerDocument.createTextNode("\n")); positioned++; } if (!positioned && fallback.length) { // Native-text extraction can provide correct text without coordinates. // Keep it selectable instead of dropping the entire page text layer. const box = layer.ownerDocument.createElement("div"); box.className = "reader-pdf-text-fallback"; if (vertical) { box.style.writingMode = layout.writing_mode; box.style.textOrientation = "upright"; } box.appendChild(textRun(layer, fallback.join("\n"))); fragment.appendChild(box); } layer.replaceChildren(fragment); } export function populateIndependentTextLayer(layer, payload) { const fragment = layer.ownerDocument.createDocumentFragment(); const chars = Array.from(payload.text), bounds = layer.parentElement.getBoundingClientRect(); const ratio = bounds.height / Math.max(1, bounds.width); let end = 0; for (const region of payload.regions) { fragment.appendChild(layer.ownerDocument.createTextNode(chars.slice(end, region.start).join(""))); const vertical = region.writing_mode.startsWith("vertical"); const q = region.quad; const dx = q[1][0] - q[0][0], dy = (q[1][1] - q[0][1]) * ratio; const angle = Math.atan2(dy, dx); const across = Math.hypot(dx, dy); const down = Math.hypot(q[3][0] - q[0][0], (q[3][1] - q[0][1]) * ratio); const height = vertical ? across : down; const width = vertical ? down : across; const positioned = positionedRun(layer, region.text, q[0][0], q[0][1], Math.max(height, .0001), width, "sans-serif", angle, vertical); const run = positioned.querySelector(".reader-pdf-text-run"); run.dir = ["rtl", "ltr"].includes(region.direction) ? region.direction : "auto"; run.style.unicodeBidi = "plaintext"; run.dataset.regionId = region.id; run.dataset.mappingPrecision = region.mapping_precision; if (vertical) run.style.writingMode = region.writing_mode === "vertical-lr" ? "vertical-lr" : "vertical-rl"; fragment.appendChild(positioned); end = region.end; } fragment.appendChild(layer.ownerDocument.createTextNode(chars.slice(end).join(""))); layer.replaceChildren(fragment); // Page surfaces disable selection; separators must be selectable along with runs. layer.style.userSelect = "text"; layer.style.webkitUserSelect = "text"; layer.dataset.textGeneration = payload.generation; } export function independentTextCaret(doc, x, y) { const position = doc.caretPositionFromPoint?.(x, y); const range = position ? null : doc.caretRangeFromPoint?.(x, y); const node = position?.offsetNode || range?.startContainer; let offset = position?.offset ?? range?.startOffset; if (node?.nodeType !== 3 || !node.parentElement?.closest( '.reader-pdf-text[data-text-generation] .reader-pdf-text-run[data-region-id]')) return null; if (offset > 0 && offset < node.length && /[\uDC00-\uDFFF]/.test(node.data[offset]) && /[\uD800-\uDBFF]/.test(node.data[offset - 1])) offset--; return { node, offset }; }