Search / static /reader /pdf /reader-pdf-text.js
vomebook
refactor: organize project layout and Reader responsibilities
2c1d035
Raw History Blame Contribute Delete
9.6 kB
// 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 };
}