Files
MookNote/assets/foliate-js/src/pdf.js
2026-06-20 00:54:40 +08:00

617 lines
16 KiB
JavaScript

/* global pdfjsLib */
// https://github.com/mozilla/pdf.js/blob/f04967017f22e46d70d11468dd928b4cdc2f6ea1/web/text_layer_builder.css
const textLayerBuilderCSS = `
/* 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 {
--highlight-bg-color: rgb(180 0 170);
--highlight-selected-bg-color: rgb(0 100 0);
}
@media screen and (forced-colors: active) {
:root {
--highlight-bg-color: Highlight;
--highlight-selected-bg-color: ButtonText;
}
}
.textLayer {
position: absolute;
text-align: initial;
inset: 0;
overflow: hidden;
opacity: 0.25;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
z-index: 2;
}
.textLayer :is(span, br) {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
/* Only necessary in Google Chrome, see issue 14205, and most unfortunately
* the problem doesn't show up in "text" reference tests. */
/*#if !MOZCENTRAL*/
.textLayer span.markedContent {
top: 0;
height: 0;
}
/*#endif*/
.textLayer .highlight {
margin: -1px;
padding: 1px;
background-color: var(--highlight-bg-color);
border-radius: 4px;
}
.textLayer .highlight.appended {
position: initial;
}
.textLayer .highlight.begin {
border-radius: 4px 0 0 4px;
}
.textLayer .highlight.end {
border-radius: 0 4px 4px 0;
}
.textLayer .highlight.middle {
border-radius: 0;
}
.textLayer .highlight.selected {
background-color: var(--highlight-selected-bg-color);
}
.textLayer ::selection {
/*#if !MOZCENTRAL*/
background: blue;
/*#endif*/
background: AccentColor; /* stylelint-disable-line declaration-block-no-duplicate-properties */
}
/* Avoids https://github.com/mozilla/pdf.js/issues/13840 in Chrome */
/*#if !MOZCENTRAL*/
.textLayer br::selection {
background: transparent;
}
/*#endif*/
.textLayer .endOfContent {
display: block;
position: absolute;
inset: 100% 0 0;
z-index: -1;
cursor: default;
user-select: none;
}
.textLayer .endOfContent.active {
top: 0;
}
`
//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 naturalPdfSize = page.getViewport({ scale: 1 })
const naturalPdfRatio = naturalPdfSize.width / naturalPdfSize.height
const appRatio = innerWidth / innerHeight
const pdfToAppResolutionRatio = appRatio / naturalPdfRatio
const scale = devicePixelRatio * pdfToAppResolutionRatio
const viewport = page.getViewport({ scale })
const canvas = document.createElement('canvas')
canvas.height = viewport.height
canvas.width = viewport.width
const canvasContext = canvas.getContext('2d')
await page.render({ canvasContext, viewport }).promise
const blob = await new Promise(resolve => canvas.toBlob(resolve))
if (getImageBlob) return blob
/*
// with the SVG backend
const operatorList = await page.getOperatorList()
const svgGraphics = new pdfjsLib.SVGGraphics(page.commonObjs, page.objs)
const svg = await svgGraphics.getSVG(operatorList, viewport)
const str = new XMLSerializer().serializeToString(svg)
const blob = new Blob([str], { type: 'image/svg+xml' })
*/
const container = document.createElement('div')
container.classList.add('textLayer')
await pdfjsLib.renderTextLayer({
textContentSource: await page.getTextContent(),
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([`
<!DOCTYPE html>
<meta charset="utf-8">
<style>
:root {
--scale-factor: ${scale};
}
html, body {
margin: 0;
padding: 0;
}
${textLayerBuilderCSS}
${annotationLayerBuilderCSS}
</style>
<img src="${src}">
${container.outerHTML}
${div.outerHTML}
`], { type: 'text/html' }))
return url
}
const makeTOCItem = item => ({
label: item.title,
href: JSON.stringify(item.dest),
subitems: item.items.length ? item.items.map(makeTOCItem) : null,
})
export const makePDF = async file => {
const data = new Uint8Array(await file.arrayBuffer())
const pdf = await pdfjsLib.getDocument({ data }).promise
const book = { rendition: { layout: 'pre-paginated' } }
const info = (await pdf.getMetadata())?.info
book.metadata = {
title: info?.Title,
author: info?.Author,
}
const outline = await pdf.getOutline()
book.toc = outline?.map(makeTOCItem)
const cache = new Map()
book.sections = Array.from({ length: pdf.numPages }).map((_, i) => ({
id: i,
load: async () => {
const cached = cache.get(i)
if (cached) return cached
const url = await renderPage(await pdf.getPage(i + 1))
cache.set(i, url)
return url
},
size: 1000,
}))
book.sections[0].pageSpread = 'right'
book.isExternal = uri => /^\w+:/i.test(uri)
book.resolveHref = async href => {
const parsed = JSON.parse(href)
const dest = typeof parsed === 'string'
? await pdf.getDestination(parsed) : parsed
if (!dest || !dest[0]) return { index: 0 }
const index = await pdf.getPageIndex(dest[0])
return { index }
}
book.splitTOCHref = async href => {
const parsed = JSON.parse(href)
const dest = typeof parsed === 'string'
? await pdf.getDestination(parsed) : parsed
if (!dest || !dest[0]) return [0, null]
const index = await pdf.getPageIndex(dest[0])
return [index, null]
}
book.getTOCFragment = doc => doc.documentElement
book.getCover = async () => renderPage(await pdf.getPage(1), true)
return book
}