Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
136 changes: 113 additions & 23 deletions src/odr/internal/html/frontend/pdf-annotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@

function pages() {
return Array.prototype.slice.call(
document.querySelectorAll("[data-odr-space]")
document.querySelectorAll("[data-odr-space]"),
);
}

Expand Down Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand All @@ -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() {
Expand All @@ -254,28 +331,41 @@
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;
}

function pageAt(x, y) {
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];
}
}
Expand Down
62 changes: 61 additions & 1 deletion test/browser/annotation/tests.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
</style>

Expand All @@ -59,6 +68,17 @@
<span class="sr">Selectable text on page two</span>
</div>
</div>
<!-- page three carries both layers, as a pdf view writes them: the glyphs
with ink below the selection layer's box, and a gap spacer where the word
break is -->
<div class="p x0 y0" id="p3" data-odr-page="2" data-odr-space="1,0,0,-1,0,792">
<div class="t" style="left: 72pt; top: 92pt; font-size: 12pt">
<span class="g">page</span><span class="g"> </span><span class="g">gypsy</span>
</div>
<div class="t" style="left: 72pt; top: 92pt; font-size: 12pt">
<span class="sr">page</span><span class="sg"> </span><span class="sr">gypsy</span>
</div>
</div>
<p id="outside">Text on no page</p>

<script src="checks.js"></script>
Expand Down Expand Up @@ -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);
Expand Down
Loading