Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
coopwire-hypothesis
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
孙灵跃 Leon Sun
coopwire-hypothesis
Commits
b802467d
Unverified
Commit
b802467d
authored
Apr 14, 2020
by
Robert Knight
Committed by
GitHub
Apr 14, 2020
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #2017 from hypothesis/pdf-highlights
Improve readability of PDF highlights
parents
18a829c8
d2fa03c5
Changes
3
Hide whitespace changes
Inline
Side-by-side
Showing
3 changed files
with
392 additions
and
1 deletion
+392
-1
highlighter.js
src/annotator/highlighter.js
+169
-1
highlighter-test.js
src/annotator/test/highlighter-test.js
+200
-0
highlights.scss
src/styles/annotator/highlights.scss
+23
-0
No files found.
src/annotator/highlighter.js
View file @
b802467d
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
();
}
}
}
...
...
src/annotator/test/highlighter-test.js
View file @
b802467d
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'
,
()
=>
{
...
...
src/styles/annotator/highlights.scss
View file @
b802467d
...
...
@@ -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.
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment