diff --git a/CHANGELOG.md b/CHANGELOG.md index f316a7c9b..e5459388c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- **Fix**: a pdf mark cut the tails off the text it marked and broke apart at + every word gap, because it took the selection layer's em box. It now covers + the glyphs under the selection, as one box per line. + - Fitting the width states a `minimum-scale` where the content is more than four screens wide, which the browser's own floor of 0.25 cannot reach. An A0 pdf page now zooms out to fit; narrower content is unchanged. diff --git a/src/odr/internal/html/frontend/pdf-annotation.js b/src/odr/internal/html/frontend/pdf-annotation.js index 1bb0a3143..92b523858 100644 --- a/src/odr/internal/html/frontend/pdf-annotation.js +++ b/src/odr/internal/html/frontend/pdf-annotation.js @@ -35,7 +35,7 @@ function pages() { return Array.prototype.slice.call( - document.querySelectorAll("[data-odr-space]") + document.querySelectorAll("[data-odr-space]"), ); } @@ -64,19 +64,14 @@ /// Page-box points to pdf user space, through the page's own inverse. function toUserSpace(page, x, y) { var m = page.getAttribute("data-odr-space").split(",").map(Number); - return [ - m[0] * x + m[2] * y + m[4], - m[1] * x + m[3] * y + m[5], - ]; + return [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]; } /// Two overlays per page: `multiply` for the washes that have to let the /// text through, and a normal one for the marks drawn on top of it. function overlay(page, multiply) { var name = multiply ? "an an-m" : "an"; - var svg = page.querySelector( - ':scope > svg[class="' + name + '"]' - ); + var svg = page.querySelector(':scope > svg[class="' + name + '"]'); if (!svg) { svg = document.createElementNS(SVG, "svg"); svg.setAttribute("class", name); @@ -85,7 +80,7 @@ } svg.setAttribute( "viewBox", - "0 0 " + page.offsetWidth * 0.75 + " " + page.offsetHeight * 0.75 + "0 0 " + page.offsetWidth * 0.75 + " " + page.offsetHeight * 0.75, ); return svg; } @@ -129,7 +124,10 @@ node.setAttribute("stroke-linecap", "round"); node.setAttribute("stroke-linejoin", "round"); } else { - node.setAttribute("d", annotation.boxes.map(barPath(annotation.type)).join(" ")); + node.setAttribute( + "d", + annotation.boxes.map(barPath(annotation.type)).join(" "), + ); if (annotation.type === "squiggly") { node.setAttribute("fill", "none"); node.setAttribute("stroke", css(annotation.color)); @@ -197,7 +195,11 @@ (byPage[index] = byPage[index] || []).push([a[0], a[1], b[0], b[1]]); } - /// The selection-layer runs a range touches; a spacer carries no text. + /// The selection-layer nodes a range touches. The gap spacers count: a word + /// break is inside what the reader marked, and leaving it out breaks one + /// mark into a bar per word. + var RUNS = ".sr,.sg,.sw"; + function selectedRuns(selection, range) { var scope = range.commonAncestorContainer; if (!scope.querySelectorAll) { @@ -206,21 +208,92 @@ if (!scope) { return []; } - var self = scope.closest ? scope.closest(".sr") : null; + var self = scope.closest ? scope.closest(RUNS) : null; if (self) { - return self.textContent.length > 0 ? [self] : []; + return [self]; } return Array.prototype.filter.call( - scope.querySelectorAll(".sr"), + scope.querySelectorAll(RUNS), function (run) { - return run.textContent.length > 0 && selection.containsNode(run, true); - } + return selection.containsNode(run, true); + }, ); } + /// Every glyph-layer rect on the page, read once per mark. `runBox` walks + /// this rather than the DOM, which would be a layout per run. + function glyphRects() { + var out = []; + var glyphs = document.querySelectorAll(".g"); + for (var i = 0; i < glyphs.length; ++i) { + if (glyphs[i].textContent.length === 0) { + continue; + } + var r = glyphs[i].getBoundingClientRect(); + if (r.width >= 0.2 && r.height >= 0.2) { + out.push(r); + } + } + return out; + } + + /// @p box grown to the glyphs it stands over. The selection layer states one + /// em of a substituted font, and a descender falls out of it, so a mark that + /// takes that box alone cuts the tails off the text it marks. + function overInk(box, glyphs) { + var top = box.top; + var bottom = box.bottom; + var reach = (box.bottom - box.top) / 2; + for (var i = 0; i < glyphs.length; ++i) { + var g = glyphs[i]; + if (g.right <= box.left || g.left >= box.right) { + continue; + } + var middle = (g.top + g.bottom) / 2; + if (middle < box.top - reach || middle > box.bottom + reach) { + continue; + } + top = Math.min(top, g.top); + bottom = Math.max(bottom, g.bottom); + } + return [top, bottom]; + } + + /// Client boxes of one line that touch become one: a pdf quad is per line, + /// and two of them meeting leaves a seam in the paint. + function joined(boxes) { + var out = []; + boxes + .slice() + .sort(function (a, b) { + return a.top - b.top || a.left - b.left; + }) + .forEach(function (b) { + var last = out[out.length - 1]; + if ( + last && + b.left - last.right < 0.6 && + b.top < last.bottom && + b.bottom > last.top + ) { + last.right = Math.max(last.right, b.right); + last.top = Math.min(last.top, b.top); + last.bottom = Math.max(last.bottom, b.bottom); + return; + } + out.push({ + left: b.left, + top: b.top, + right: b.right, + bottom: b.bottom, + }); + }); + return out; + } + /// One run's covered box; a partly selected run takes its horizontal edges - /// from the rects, clamped to the run. - function runBox(byPage, run, rects, selection) { + /// from the rects, clamped to the run, and its vertical ones from the ink. + function runBox(run, rects, selection, glyphs) { var box = run.getBoundingClientRect(); var left = box.left; var right = box.right; @@ -242,10 +315,14 @@ right = Math.max(right, Math.min(rect.right, box.right)); } } - pushBox(byPage, left, box.top, right, box.bottom); + if (right - left < 0.5) { + return null; + } + var ink = overInk(box, glyphs); + return { left: left, top: ink[0], right: right, bottom: ink[1] }; } - /// The boxes a selection covers, per page, in page-box points. Vertically + /// The boxes a selection covers, per page, in page-box points. Horizontally /// the run's box, not the range's rect: that rect follows whatever font the /// browser substituted for the layer. function selectionBoxes() { @@ -254,20 +331,28 @@ if (!selection || selection.isCollapsed) { return byPage; } + var glyphs = glyphRects(); + var boxes = []; for (var r = 0; r < selection.rangeCount; ++r) { var range = selection.getRangeAt(r); var rects = range.getClientRects(); var runs = selectedRuns(selection, range); for (var i = 0; i < runs.length; ++i) { - runBox(byPage, runs[i], rects, selection); + var box = runBox(runs[i], rects, selection, glyphs); + if (box !== null) { + boxes.push(box); + } } if (runs.length === 0) { // no selection layer under it for (var k = 0; k < rects.length; ++k) { - pushBox(byPage, rects[k].left, rects[k].top, rects[k].right, rects[k].bottom); + boxes.push(rects[k]); } } } + joined(boxes).forEach(function (b) { + pushBox(byPage, b.left, b.top, b.right, b.bottom); + }); return byPage; } @@ -275,7 +360,12 @@ var all = pages(); for (var i = 0; i < all.length; ++i) { var rect = all[i].getBoundingClientRect(); - if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + if ( + x >= rect.left && + x <= rect.right && + y >= rect.top && + y <= rect.bottom + ) { return all[i]; } } diff --git a/test/browser/annotation/tests.html b/test/browser/annotation/tests.html index a194a3d53..7259bb11f 100644 --- a/test/browser/annotation/tests.html +++ b/test/browser/annotation/tests.html @@ -39,9 +39,18 @@ } /* as the real selection layer: an inline-block whose box is the laid-out line, taller than the rect a range gets from the substituted font */ - .sr { + .sr, + .sg { display: inline-block; line-height: 1.6; + vertical-align: top; + } + /* the glyph layer draws at its own size, so its ink runs below the box the + selection layer states - which is where a descender is lost */ + .g { + display: inline-block; + height: 26pt; + vertical-align: top; } @@ -59,6 +68,17 @@ Selectable text on page two + +
Text on no page
@@ -453,6 +473,46 @@ api.list()[0].color ); + // --- a mark covers the text it marks --------------------------------- + + api.clear(); + api.setTool("highlight"); + const dual = document.querySelector('[data-odr-page="2"]'); + const both = document.createRange(); + both.setStart(dual.querySelectorAll(".sr")[0].firstChild, 0); + both.setEnd(dual.querySelectorAll(".sr")[1].firstChild, 5); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(both); + api.mark(); + + const line = api.list()[0]; + check( + "a mark over a word break is one box, not one per word", + line.boxes.length === 1, + line.boxes + ); + + // the glyphs are 26pt tall where the selection layer states 1.6em = 19.2pt + const srBox = dual.querySelector(".sr").getBoundingClientRect(); + const glyph = dual.querySelector(".g").getBoundingClientRect(); + const zoom2 = dual.getBoundingClientRect().width / dual.offsetWidth; + check( + "the glyphs run below the selection layer's box", + glyph.bottom > srBox.bottom + 1, + [glyph.bottom, srBox.bottom] + ); + check( + "and the box reaches them, so a descender is not cut off", + near((line.boxes[0][3] - line.boxes[0][1]) * zoom2, glyph.height * 0.75, 1), + [(line.boxes[0][3] - line.boxes[0][1]) * zoom2, glyph.height * 0.75] + ); + check( + "the box spans both runs and the spacer", + near(line.boxes[0][2] - line.boxes[0][0], + (dual.querySelectorAll(".sr")[1].getBoundingClientRect().right - srBox.left) / zoom2 * 0.75, 1), + line.boxes[0] + ); + window.odr.onAnnotationChange = () => {}; api.clear(); api.setTool(null);