Commit 55e4ad5a authored by Robert Knight's avatar Robert Knight

Improve readability of PDF highlights

Improve the readability of highlights on PDFs by creating the highlights
in an SVG layer overlaid on top of the page's `<canvas>` instead of
using the CSS `background-color` property on the
`<hypothesis-highlight>` elements in the page's text layer.

Using an SVG placed in the DOM like this allows us to control how
the highlight is blended with the content underneath using CSS
`mix-blend-mode`. Using the `multiply` blend mode [2] means that highlights
will darken the content below rather than making dark text in the
canvas appear lighter and muddier. Additionally this approach gives us
more control over the appearance of overlapping highlights. Note that
for the custom blending to work, it is important that the SVG is in the
same stacking context as the canvas [1]

We still need to keep the `<hypothesis-highlight>` elements in the text layer
for interactive functionality (eg. interacting with highlights using the
keyboard or pointer). The SVG highlight is associated with the
`<hypothesis-highlight>` via an `svgHighlight` property so that the SVG
can be removed when the highlight itself is removed.

[1] https://drafts.fxtf.org/compositing-1/#csscompositingrules_CSS
[2] https://drafts.fxtf.org/compositing-1/#valdef-blend-mode-multiply
parent 2e287c68
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
/**
* Polyfill for `element.closest(selector)`, only needed for IE 11.
*/
function closest(element, selector) {
while (element) {
if (element.matches(selector)) {
return element;
}
element = element.parentElement;
}
return null;
}
/**
* Return the canvas element underneath a highlight element in a PDF page's
* text layer.
*
* Returns `null` if the highlight is not above a PDF canvas.
*
* @param {HTMLElement} highlightEl -
* A `<hypothesis-highlight>` element in the page's text layer
* @return {HTMLCanvasElement|null}
*/
function getPdfCanvas(highlightEl) {
// This code assumes that PDF.js renders pages with a structure like:
//
// <div class="page">
// <div class="canvasWrapper">
// <canvas></canvas> <!-- The rendered PDF page -->
// </div>
// <div class="textLayer">
// <!-- Transparent text layer with text spans used to enable text selection -->
// </div>
// </div>
//
// It also assumes that the `highlightEl` element is somewhere under
// the `.textLayer` div.
const pageEl = closest(highlightEl, '.page');
if (!pageEl) {
return null;
}
const canvasEl = pageEl.querySelector('.canvasWrapper > canvas');
if (!canvasEl) {
return null;
}
return canvasEl;
}
/**
* Draw highlights in an SVG layer overlaid on top of a PDF.js canvas.
*
* Returns `null` if `highlightEl` is not above a PDF.js page canvas.
*
* @param {HTMLElement} highlightEl -
* An element that wraps the highlighted text in the transparent text layer
* above the PDF.
* @return {SVGElement|null} -
* The SVG graphic element that corresponds to the highlight or `null` if
* no PDF page was found below the highlight.
*/
function drawHighlightsAbovePdfCanvas(highlightEl) {
const canvasEl = getPdfCanvas(highlightEl);
if (!canvasEl) {
return null;
}
let svgHighlightLayer = canvasEl.parentElement.querySelector(
'.hypothesis-highlight-layer'
);
if (!svgHighlightLayer) {
// Create SVG layer. This must be in the same stacking context as
// the canvas so that CSS `mix-blend-mode` can be used to control how SVG
// content blends with the canvas below.
svgHighlightLayer = document.createElementNS(SVG_NAMESPACE, 'svg');
svgHighlightLayer.setAttribute('class', 'hypothesis-highlight-layer');
canvasEl.parentElement.appendChild(svgHighlightLayer);
// Overlay SVG layer above canvas.
canvasEl.parentElement.style.position = 'relative';
const svgStyle = svgHighlightLayer.style;
svgStyle.position = 'absolute';
svgStyle.left = 0;
svgStyle.top = 0;
svgStyle.width = '100%';
svgStyle.height = '100%';
// Use multiply blending so that highlights drawn on top of text darken it
// rather than making it lighter. This improves contrast and thus readability
// of highlighted text. This choice optimizes for dark text on a light
// background, as the most common case.
//
// Browsers which don't support the `mix-blend-mode` property (IE 11, Edge < 79)
// will use "normal" blending, which is still usable but has reduced contrast,
// especially for overlapping highlights.
svgStyle.mixBlendMode = 'multiply';
}
const canvasRect = canvasEl.getBoundingClientRect();
const highlightRect = highlightEl.getBoundingClientRect();
// Create SVG element for the current highlight element.
const rect = document.createElementNS(SVG_NAMESPACE, 'rect');
rect.setAttribute('x', highlightRect.left - canvasRect.left);
rect.setAttribute('y', highlightRect.top - canvasRect.top);
rect.setAttribute('width', highlightRect.width);
rect.setAttribute('height', highlightRect.height);
rect.setAttribute('class', 'hypothesis-svg-highlight');
svgHighlightLayer.appendChild(rect);
return rect;
}
/** /**
* Wraps the DOM Nodes within the provided range with a highlight * Wraps the DOM Nodes within the provided range with a highlight
* element of the specified class and returns the highlight Elements. * element of the specified class and returns the highlight Elements.
...@@ -45,8 +164,20 @@ export function highlightRange(normedRange, cssClass = 'hypothesis-highlight') { ...@@ -45,8 +164,20 @@ export function highlightRange(normedRange, cssClass = 'hypothesis-highlight') {
highlightEl.className = cssClass; highlightEl.className = cssClass;
nodes[0].parentNode.replaceChild(highlightEl, nodes[0]); nodes[0].parentNode.replaceChild(highlightEl, nodes[0]);
nodes.forEach(node => highlightEl.appendChild(node)); nodes.forEach(node => highlightEl.appendChild(node));
// For PDF highlights, create the highlight effect by using an SVG placed
// above the page's canvas rather than CSS `background-color` on the
// highlight element. This enables more control over blending of the
// highlight with the content below.
const svgHighlight = drawHighlightsAbovePdfCanvas(highlightEl);
if (svgHighlight) {
highlightEl.className += ' is-transparent';
// Associate SVG element with highlight for use by `removeHighlights`.
highlightEl.svgHighlight = svgHighlight;
}
highlights.push(highlightEl); highlights.push(highlightEl);
}); });
...@@ -78,6 +209,10 @@ export function removeHighlights(highlights) { ...@@ -78,6 +209,10 @@ export function removeHighlights(highlights) {
const children = Array.from(h.childNodes); const children = Array.from(h.childNodes);
replaceWith(h, children); replaceWith(h, children);
} }
if (h.svgHighlight) {
h.svgHighlight.remove();
}
} }
} }
......
import { createElement, render } from 'preact';
import Range from '../anchoring/range'; import Range from '../anchoring/range';
import { import {
...@@ -6,6 +8,60 @@ import { ...@@ -6,6 +8,60 @@ import {
getBoundingClientRect, getBoundingClientRect,
} from '../highlighter'; } from '../highlighter';
/**
* Preact component that renders a simplified version of the DOM structure
* of PDF.js pages.
*
* This is used to test PDF-specific highlighting behavior.
*/
function PdfPage() {
return (
<div className="page">
<div className="canvasWrapper">
{/* Canvas where PDF.js renders the visual PDF output. */}
<canvas />
</div>
{/* Transparent text layer created by PDF.js to enable text selection */}
<div className="textLayer">
{/* Text span created to correspond to some text rendered into the canvas.
Hypothesis creates `<hypothesis-highlight>` elements here. */}
<span className="testText">Text to highlight</span>
</div>
</div>
);
}
/**
* Highlight the text in a fake PDF page.
*
* @param {HTMLElement} - HTML element into which `PdfPage` component has been
* rendered
* @return {HTMLElement} - `<hypothesis-highlight>` element
*/
function highlightPdfRange(pdfPage) {
const textSpan = pdfPage.querySelector('.testText');
const r = new Range.NormalizedRange({
commonAncestor: textSpan,
start: textSpan.childNodes[0],
end: textSpan.childNodes[0],
});
return highlightRange(r);
}
/**
* Render a fake PDF.js page (`PdfPage`) and return its container.
*
* @return {HTMLElement}
*/
function createPdfPageWithHighlight() {
const container = document.createElement('div');
render(<PdfPage />, container);
highlightPdfRange(container);
return container;
}
describe('annotator/highlighter', () => { describe('annotator/highlighter', () => {
describe('highlightRange', () => { describe('highlightRange', () => {
it('wraps a highlight span around the given range', () => { it('wraps a highlight span around the given range', () => {
...@@ -106,6 +162,66 @@ describe('annotator/highlighter', () => { ...@@ -106,6 +162,66 @@ describe('annotator/highlighter', () => {
assert.equal(result.length, 0); assert.equal(result.length, 0);
}); });
context('when the highlighted text is part of a PDF.js text layer', () => {
it("removes the highlight element's background color", () => {
const page = createPdfPageWithHighlight();
const highlight = page.querySelector('hypothesis-highlight');
assert.isTrue(highlight.classList.contains('is-transparent'));
});
it('creates an SVG layer above the PDF canvas and draws a highlight in that', () => {
const page = createPdfPageWithHighlight();
const canvas = page.querySelector('canvas');
const svgLayer = page.querySelector('svg');
// Verify SVG layer was created.
assert.ok(svgLayer);
assert.equal(svgLayer.previousElementSibling, canvas);
// Check that an SVG graphic element was created for the highlight.
const highlight = page.querySelector('hypothesis-highlight');
const svgRect = page.querySelector('rect');
assert.ok(svgRect);
assert.equal(highlight.svgHighlight, svgRect);
});
it('re-uses the existing SVG layer for the page if present', () => {
// Create a PDF page with a single highlight.
const page = createPdfPageWithHighlight();
// Create a second highlight on the same page.
highlightPdfRange(page);
// There should be multiple highlights.
assert.equal(page.querySelectorAll('hypothesis-highlight').length, 2);
// ... but only one SVG layer.
assert.equal(page.querySelectorAll('svg').length, 1);
// ... with multiple <rect>s
assert.equal(
page.querySelector('svg').querySelectorAll('rect').length,
2
);
});
it('does not create an SVG highlight if the canvas is not found', () => {
const container = document.createElement('div');
render(<PdfPage />, container);
// Remove canvas. This might be missing if the DOM structure looks like
// PDF.js but isn't, or perhaps a future PDF.js update or fork changes
// the DOM structure significantly. In that case, we'll fall back to
// regular CSS-based highlighting.
container.querySelector('canvas').remove();
const [highlight] = highlightPdfRange(container);
assert.isFalse(highlight.classList.contains('is-transparent'));
assert.isNull(container.querySelector('rect'));
assert.notOk(highlight.svgHighlight);
});
});
}); });
describe('removeHighlights', () => { describe('removeHighlights', () => {
...@@ -131,6 +247,18 @@ describe('annotator/highlighter', () => { ...@@ -131,6 +247,18 @@ describe('annotator/highlighter', () => {
removeHighlights([hl]); removeHighlights([hl]);
}); });
it('removes any associated SVG elements external to the highlight element', () => {
const page = createPdfPageWithHighlight();
const highlight = page.querySelector('hypothesis-highlight');
assert.instanceOf(highlight.svgHighlight, SVGElement);
assert.equal(page.querySelectorAll('rect').length, 1);
removeHighlights([highlight]);
assert.equal(page.querySelectorAll('rect').length, 0);
});
}); });
describe('getBoundingClientRect', () => { describe('getBoundingClientRect', () => {
......
...@@ -17,11 +17,27 @@ ...@@ -17,11 +17,27 @@
overflow: hidden; overflow: hidden;
} }
// SVG highlights when the "Show Highlights" toggle is turned off.
.hypothesis-svg-highlight {
fill: transparent;
}
// `hypothesis-highlights-always-on` is a class that is toggled on the root // `hypothesis-highlights-always-on` is a class that is toggled on the root
// of the annotated document when highlights are enabled/disabled. // of the annotated document when highlights are enabled/disabled.
.hypothesis-highlights-always-on { .hypothesis-highlights-always-on {
.hypothesis-svg-highlight {
fill: var.$highlight-color;
}
.hypothesis-highlight { .hypothesis-highlight {
background-color: var.$highlight-color; background-color: var.$highlight-color;
// For PDFs, we still create highlight elements to wrap the text but the
// highlight effect is created by another element.
&.is-transparent {
background-color: transparent;
}
cursor: pointer; cursor: pointer;
// Make highlights visible to screen readers. // Make highlights visible to screen readers.
...@@ -41,6 +57,9 @@ ...@@ -41,6 +57,9 @@
// Give a highlight inside a larger highlight a different color to stand out. // Give a highlight inside a larger highlight a different color to stand out.
& .hypothesis-highlight { & .hypothesis-highlight {
background-color: var.$highlight-color-second; background-color: var.$highlight-color-second;
&.is-transparent {
background-color: transparent;
}
// In document viewers where the highlight is drawn _on top of_ the text // In document viewers where the highlight is drawn _on top of_ the text
// (eg. PDF.js) too many nested highlights can make the underlying text unreadable. // (eg. PDF.js) too many nested highlights can make the underlying text unreadable.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment