Unverified Commit b802467d authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #2017 from hypothesis/pdf-highlights

Improve readability of PDF highlights
parents 18a829c8 d2fa03c5
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
function isCSSPropertySupported(property, value) {
if (typeof CSS !== 'function' || typeof CSS.supports !== 'function') {
/* istanbul ignore next */
return false;
}
return CSS.supports(property, value);
}
/**
* Implementation of `element.closest(selector)`. This is used to support browsers
* (IE 11) that don't have a native implementation.
*/
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'
);
const isCssBlendSupported = isCSSPropertySupported(
'mix-blend-mode',
'multiply'
);
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%';
if (isCssBlendSupported) {
// 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, especially for overlapping highlights.
//
// This choice optimizes for the common case of dark text on a light background.
svgStyle.mixBlendMode = 'multiply';
} else {
// For older browsers (IE 11, Edge < 79) we draw all the highlights as
// opaque and then make the entire highlight layer transparent. This means
// that there is no visual indication of whether text has one or multiple
// highlights, but it preserves readability.
svgStyle.opacity = 0.3;
}
}
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);
if (isCssBlendSupported) {
rect.setAttribute('class', 'hypothesis-svg-highlight');
} else {
rect.setAttribute('class', 'hypothesis-svg-highlight is-opaque');
}
svgHighlightLayer.appendChild(rect);
return rect;
}
/**
* Wraps the DOM Nodes within the provided range with a highlight
* element of the specified class and returns the highlight Elements.
......@@ -12,6 +156,12 @@ export function highlightRange(normedRange, cssClass = 'hypothesis-highlight') {
// Find text nodes within the range to highlight.
const textNodes = normedRange.textNodes();
// Check if this range refers to a placeholder for not-yet-rendered text in
// a PDF. These highlights should be invisible.
const isPlaceholder =
textNodes.length > 0 &&
closest(textNodes[0].parentNode, '.annotator-placeholder') !== null;
// Group text nodes into spans of adjacent nodes. If a group of text nodes are
// adjacent, we only need to create one highlight element for the group.
let textNodeSpans = [];
......@@ -45,8 +195,22 @@ export function highlightRange(normedRange, cssClass = 'hypothesis-highlight') {
highlightEl.className = cssClass;
nodes[0].parentNode.replaceChild(highlightEl, nodes[0]);
nodes.forEach(node => highlightEl.appendChild(node));
if (!isPlaceholder) {
// 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);
});
......@@ -78,6 +242,10 @@ export function removeHighlights(highlights) {
const children = Array.from(h.childNodes);
replaceWith(h, children);
}
if (h.svgHighlight) {
h.svgHighlight.remove();
}
}
}
......
import { createElement, render } from 'preact';
import Range from '../anchoring/range';
import {
......@@ -6,6 +8,70 @@ import {
getBoundingClientRect,
} 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.
*/
// eslint-disable-next-line react/prop-types
function PdfPage({ showPlaceholder = false }) {
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 */}
{!showPlaceholder && (
<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>
)}
{showPlaceholder && (
<div className="annotator-placeholder testText">
{/* Placeholder created to anchor annotations to if the text layer has not finished
rendering. */}
Loading annotations
</div>
)}
</div>
);
}
/**
* Highlight the text in a fake PDF page.
*
* @param {HTMLElement} pageContainer - HTML element into which `PdfPage`
* component has been rendered
* @return {HTMLElement} - `<hypothesis-highlight>` element
*/
function highlightPdfRange(pageContainer) {
const textSpan = pageContainer.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('highlightRange', () => {
it('wraps a highlight span around the given range', () => {
......@@ -106,6 +172,128 @@ describe('annotator/highlighter', () => {
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);
});
it('does not create an SVG highlight for placeholder highlights', () => {
const container = document.createElement('div');
render(<PdfPage showPlaceholder={true} />, container);
const [highlight] = highlightPdfRange(container);
// If the highlight is a placeholder, the highlight element should still
// be created.
assert.ok(highlight);
assert.equal(highlight.textContent, 'Loading annotations');
// ...but the highlight should be visually hidden so the SVG should
// not be created.
assert.isNull(container.querySelector('rect'));
});
describe('CSS blend mode support testing', () => {
beforeEach(() => {
sinon.stub(CSS, 'supports');
});
afterEach(() => {
CSS.supports.restore();
});
it('renders highlights when mix-blend-mode is supported', () => {
const container = document.createElement('div');
render(<PdfPage />, container);
CSS.supports.withArgs('mix-blend-mode', 'multiply').returns(true);
highlightPdfRange(container);
// When mix blending is available, the highlight layer has default
// opacity and highlight rects are transparent.
const highlightLayer = container.querySelector(
'.hypothesis-highlight-layer'
);
assert.equal(highlightLayer.style.opacity, '');
const rect = container.querySelector('rect');
assert.equal(rect.getAttribute('class'), 'hypothesis-svg-highlight');
});
it('renders highlights when mix-blend-mode is not supported', () => {
const container = document.createElement('div');
render(<PdfPage />, container);
CSS.supports.withArgs('mix-blend-mode', 'multiply').returns(false);
highlightPdfRange(container);
// When mix blending is not available, highlight rects are opaque and
// the entire highlight layer is transparent.
const highlightLayer = container.querySelector(
'.hypothesis-highlight-layer'
);
assert.equal(highlightLayer.style.opacity, '0.3');
const rect = container.querySelector('rect');
assert.include(
rect.getAttribute('class'),
'hypothesis-svg-highlight is-opaque'
);
});
});
});
});
describe('removeHighlights', () => {
......@@ -131,6 +319,18 @@ describe('annotator/highlighter', () => {
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', () => {
......
......@@ -17,11 +17,31 @@
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
// of the annotated document when highlights are enabled/disabled.
.hypothesis-highlights-always-on {
.hypothesis-svg-highlight {
fill: var.$highlight-color;
&.is-opaque {
fill: yellow;
}
}
.hypothesis-highlight {
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;
// Make highlights visible to screen readers.
......@@ -41,6 +61,9 @@
// Give a highlight inside a larger highlight a different color to stand out.
& .hypothesis-highlight {
background-color: var.$highlight-color-second;
&.is-transparent {
background-color: transparent;
}
// 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.
......
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