From 4f321563c7d2df7a621ea7912779753bdd774a2d Mon Sep 17 00:00:00 2001
From: John Factotum <50942278+johnfactotum@users.noreply.github.com>
Date: Mon, 9 Oct 2023 01:56:13 +0800
Subject: [PATCH] PDF: add annotation layer and handle links
---
pdf.js | 392 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 392 insertions(+)
diff --git a/pdf.js b/pdf.js
index fd88184..bcadc11 100644
--- a/pdf.js
+++ b/pdf.js
@@ -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,");
+ --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 scale = devicePixelRatio
const viewport = page.getViewport({ scale })
@@ -142,6 +521,16 @@ const renderPage = async (page, getImageBlob) => {
container, viewport,
}).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 url = URL.createObjectURL(new Blob([`
@@ -155,9 +544,11 @@ const renderPage = async (page, getImageBlob) => {
padding: 0;
}
${textLayerBuilderCSS}
+ ${annotationLayerBuilderCSS}
${container.outerHTML}
+ ${div.outerHTML}
`], { type: 'text/html' }))
return url
}
@@ -195,6 +586,7 @@ export const makePDF = async file => {
},
size: 1000,
}))
+ book.isExternal = uri => /^\w+:/i.test(uri)
book.resolveHref = async href => {
const parsed = JSON.parse(href)
const dest = typeof parsed === 'string'