mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
PDF: add annotation layer and handle links
This commit is contained in:
@@ -114,6 +114,385 @@ const textLayerBuilderCSS = `
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
//https://github.com/mozilla/pdf.js/blob/d64f223d034ad74fb62571c3acff566d25eca413/web/annotation_layer_builder.css
|
||||||
|
const annotationLayerBuilderCSS = `
|
||||||
|
/* Copyright 2014 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--annotation-unfocused-field-background: url("data:image/svg+xml;charset=UTF-8,<svg width='1px' height='1px' xmlns='http://www.w3.org/2000/svg'><rect width='100%' height='100%' style='fill:rgba(0, 54, 255, 0.13);'/></svg>");
|
||||||
|
--input-focus-border-color: Highlight;
|
||||||
|
--input-focus-outline: 1px solid Canvas;
|
||||||
|
--input-unfocused-border-color: transparent;
|
||||||
|
--input-disabled-border-color: transparent;
|
||||||
|
--input-hover-border-color: black;
|
||||||
|
--link-outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (forced-colors: active) {
|
||||||
|
:root {
|
||||||
|
--input-focus-border-color: CanvasText;
|
||||||
|
--input-unfocused-border-color: ActiveText;
|
||||||
|
--input-disabled-border-color: GrayText;
|
||||||
|
--input-hover-border-color: Highlight;
|
||||||
|
--link-outline: 1.5px solid LinkText;
|
||||||
|
--hcm-highligh-filter: invert(100%);
|
||||||
|
}
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea):required,
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select:required,
|
||||||
|
.annotationLayer
|
||||||
|
.buttonWidgetAnnotation:is(.checkBox, .radioButton)
|
||||||
|
input:required {
|
||||||
|
outline: 1.5px solid selectedItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .linkAnnotation:hover {
|
||||||
|
backdrop-filter: var(--hcm-highligh-filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .linkAnnotation > a:hover {
|
||||||
|
opacity: 0 !important;
|
||||||
|
background: none !important;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popupAnnotation .popup {
|
||||||
|
outline: calc(1.5px * var(--scale-factor)) solid CanvasText !important;
|
||||||
|
background-color: ButtonFace !important;
|
||||||
|
color: ButtonText !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .highlightArea:hover::after {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
backdrop-filter: var(--hcm-highligh-filter);
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popupAnnotation.focused .popup {
|
||||||
|
outline: calc(3px * var(--scale-factor)) solid Highlight !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer[data-main-rotation="90"] .norotate {
|
||||||
|
transform: rotate(270deg) translateX(-100%);
|
||||||
|
}
|
||||||
|
.annotationLayer[data-main-rotation="180"] .norotate {
|
||||||
|
transform: rotate(180deg) translate(-100%, -100%);
|
||||||
|
}
|
||||||
|
.annotationLayer[data-main-rotation="270"] .norotate {
|
||||||
|
transform: rotate(90deg) translateY(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer canvas {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer section {
|
||||||
|
position: absolute;
|
||||||
|
text-align: initial;
|
||||||
|
pointer-events: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .linkAnnotation {
|
||||||
|
outline: var(--link-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer :is(.linkAnnotation, .buttonWidgetAnnotation.pushButton) > a {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 1em;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer
|
||||||
|
:is(.linkAnnotation, .buttonWidgetAnnotation.pushButton):not(.hasBorder)
|
||||||
|
> a:hover {
|
||||||
|
opacity: 0.2;
|
||||||
|
background-color: rgb(255 255 0);
|
||||||
|
box-shadow: 0 2px 10px rgb(255 255 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .linkAnnotation.hasBorder:hover {
|
||||||
|
background-color: rgb(255 255 0 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .hasBorder {
|
||||||
|
background-size: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textAnnotation img {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea),
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input {
|
||||||
|
background-image: var(--annotation-unfocused-field-background);
|
||||||
|
border: 2px solid var(--input-unfocused-border-color);
|
||||||
|
box-sizing: border-box;
|
||||||
|
font: calc(9px * var(--scale-factor)) sans-serif;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea):required,
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select:required,
|
||||||
|
.annotationLayer
|
||||||
|
.buttonWidgetAnnotation:is(.checkBox, .radioButton)
|
||||||
|
input:required {
|
||||||
|
outline: 1.5px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select option {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.radioButton input {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation textarea {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea)[disabled],
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select[disabled],
|
||||||
|
.annotationLayer
|
||||||
|
.buttonWidgetAnnotation:is(.checkBox, .radioButton)
|
||||||
|
input[disabled] {
|
||||||
|
background: none;
|
||||||
|
border: 2px solid var(--input-disabled-border-color);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea):hover,
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select:hover,
|
||||||
|
.annotationLayer
|
||||||
|
.buttonWidgetAnnotation:is(.checkBox, .radioButton)
|
||||||
|
input:hover {
|
||||||
|
border: 2px solid var(--input-hover-border-color);
|
||||||
|
}
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea):hover,
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select:hover,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:hover {
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation :is(input, textarea):focus,
|
||||||
|
.annotationLayer .choiceWidgetAnnotation select:focus {
|
||||||
|
background: none;
|
||||||
|
border: 2px solid var(--input-focus-border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: var(--input-focus-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) :focus {
|
||||||
|
background-image: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox :focus {
|
||||||
|
border: 2px solid var(--input-focus-border-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: var(--input-focus-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.radioButton :focus {
|
||||||
|
border: 2px solid var(--input-focus-border-color);
|
||||||
|
outline: var(--input-focus-outline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before {
|
||||||
|
background-color: CanvasText;
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before,
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after {
|
||||||
|
height: 80%;
|
||||||
|
left: 45%;
|
||||||
|
width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::before {
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.checkBox input:checked::after {
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation.radioButton input:checked::before {
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 50%;
|
||||||
|
left: 30%;
|
||||||
|
top: 20%;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation input.comb {
|
||||||
|
font-family: monospace;
|
||||||
|
padding-left: 2px;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .textWidgetAnnotation input.comb:focus {
|
||||||
|
/*
|
||||||
|
* Letter spacing is placed on the right side of each character. Hence, the
|
||||||
|
* letter spacing of the last character may be placed outside the visible
|
||||||
|
* area, causing horizontal scrolling. We avoid this by extending the width
|
||||||
|
* when the element has focus and revert this when it loses focus.
|
||||||
|
*/
|
||||||
|
width: 103%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .buttonWidgetAnnotation:is(.checkBox, .radioButton) input {
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .fileAttachmentAnnotation .popupTriggerArea {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popupAnnotation {
|
||||||
|
position: absolute;
|
||||||
|
font-size: calc(9px * var(--scale-factor));
|
||||||
|
pointer-events: none;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 45%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popup {
|
||||||
|
background-color: rgb(255 255 153);
|
||||||
|
box-shadow: 0 calc(2px * var(--scale-factor)) calc(5px * var(--scale-factor))
|
||||||
|
rgb(136 136 136);
|
||||||
|
border-radius: calc(2px * var(--scale-factor));
|
||||||
|
outline: 1.5px solid rgb(255 255 74);
|
||||||
|
padding: calc(6px * var(--scale-factor));
|
||||||
|
cursor: pointer;
|
||||||
|
font: message-box;
|
||||||
|
white-space: normal;
|
||||||
|
word-wrap: break-word;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popupAnnotation.focused .popup {
|
||||||
|
outline-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popup * {
|
||||||
|
font-size: calc(9px * var(--scale-factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popup > .header {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popup > .header h1 {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popup > .header .popupDate {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: calc(5px * var(--scale-factor));
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popupContent {
|
||||||
|
border-top: 1px solid rgb(51 51 51);
|
||||||
|
margin-top: calc(2px * var(--scale-factor));
|
||||||
|
padding-top: calc(2px * var(--scale-factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .richText > * {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-size: calc(9px * var(--scale-factor));
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .popupTriggerArea {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer section svg {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .annotationTextContent {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
color: transparent;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer .annotationTextContent span {
|
||||||
|
width: 100%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotationLayer svg.quadrilateralsContainer {
|
||||||
|
contain: strict;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
const renderPage = async (page, getImageBlob) => {
|
const renderPage = async (page, getImageBlob) => {
|
||||||
const scale = devicePixelRatio
|
const scale = devicePixelRatio
|
||||||
const viewport = page.getViewport({ scale })
|
const viewport = page.getViewport({ scale })
|
||||||
@@ -142,6 +521,16 @@ const renderPage = async (page, getImageBlob) => {
|
|||||||
container, viewport,
|
container, viewport,
|
||||||
}).promise
|
}).promise
|
||||||
|
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.classList.add('annotationLayer')
|
||||||
|
await new pdfjsLib.AnnotationLayer({ page, viewport, div }).render({
|
||||||
|
annotations: await page.getAnnotations(),
|
||||||
|
linkService: {
|
||||||
|
getDestinationHash: dest => JSON.stringify(dest),
|
||||||
|
addLinkAttributes: (link, url) => link.href = url,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const src = URL.createObjectURL(blob)
|
const src = URL.createObjectURL(blob)
|
||||||
const url = URL.createObjectURL(new Blob([`
|
const url = URL.createObjectURL(new Blob([`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -155,9 +544,11 @@ const renderPage = async (page, getImageBlob) => {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
${textLayerBuilderCSS}
|
${textLayerBuilderCSS}
|
||||||
|
${annotationLayerBuilderCSS}
|
||||||
</style>
|
</style>
|
||||||
<img src="${src}">
|
<img src="${src}">
|
||||||
${container.outerHTML}
|
${container.outerHTML}
|
||||||
|
${div.outerHTML}
|
||||||
`], { type: 'text/html' }))
|
`], { type: 'text/html' }))
|
||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
@@ -195,6 +586,7 @@ export const makePDF = async file => {
|
|||||||
},
|
},
|
||||||
size: 1000,
|
size: 1000,
|
||||||
}))
|
}))
|
||||||
|
book.isExternal = uri => /^\w+:/i.test(uri)
|
||||||
book.resolveHref = async href => {
|
book.resolveHref = async href => {
|
||||||
const parsed = JSON.parse(href)
|
const parsed = JSON.parse(href)
|
||||||
const dest = typeof parsed === 'string'
|
const dest = typeof parsed === 'string'
|
||||||
|
|||||||
Reference in New Issue
Block a user