Convert PDF.js code to TypeScript

GitOrigin-RevId: 840bc48816d78497131b927522b4f6f7940bb0c4
This commit is contained in:
Alf Eaton 2024-09-14 16:01:15 +01:00 committed by Copybot
parent be45aec5b5
commit df6147bb4d
11 changed files with 135 additions and 164 deletions

View file

@ -15,9 +15,9 @@ const FileViewPdf: FC<{
const handleContainer = useCallback( const handleContainer = useCallback(
async (element: HTMLDivElement | null) => { async (element: HTMLDivElement | null) => {
if (element) { if (element) {
const { PDFJS } = await import( const { loadPdfDocumentFromUrl } = await import(
'../../pdf-preview/util/pdf-js-versions' '@/features/pdf-preview/util/pdf-js'
).then(m => m.default as any) )
// bail out if loading PDF.js took too long // bail out if loading PDF.js took too long
if (!mountedRef.current) { if (!mountedRef.current) {
@ -33,10 +33,7 @@ const FileViewPdf: FC<{
return return
} }
const pdf = await PDFJS.getDocument({ const pdf = await loadPdfDocumentFromUrl(preview.url).promise
url: preview.url,
isEvalSupported: false,
}).promise
// bail out if loading the PDF took too long // bail out if loading the PDF took too long
if (!mountedRef.current) { if (!mountedRef.current) {
@ -61,7 +58,7 @@ const FileViewPdf: FC<{
element.append(canvas) element.append(canvas)
page.render({ page.render({
canvasContext: canvas.getContext('2d'), canvasContext: canvas.getContext('2d')!,
viewport, viewport,
}) })
} }

View file

@ -1,11 +1,4 @@
import { import { memo, useCallback, useEffect, useRef, useState } from 'react'
memo,
MouseEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { debounce, throttle } from 'lodash' import { debounce, throttle } from 'lodash'
import PdfViewerControlsToolbar from './pdf-viewer-controls-toolbar' import PdfViewerControlsToolbar from './pdf-viewer-controls-toolbar'
import { useProjectContext } from '../../../shared/context/project-context' import { useProjectContext } from '../../../shared/context/project-context'
@ -22,6 +15,7 @@ import { debugConsole } from '@/utils/debugging'
import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider' import { usePdfPreviewContext } from '@/features/pdf-preview/components/pdf-preview-provider'
import usePresentationMode from '../hooks/use-presentation-mode' import usePresentationMode from '../hooks/use-presentation-mode'
import useMouseWheelZoom from '../hooks/use-mouse-wheel-zoom' import useMouseWheelZoom from '../hooks/use-mouse-wheel-zoom'
import { PDFJS } from '../util/pdf-js'
type PdfJsViewerProps = { type PdfJsViewerProps = {
url: string url: string
@ -70,20 +64,18 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
const handleContainer = useCallback( const handleContainer = useCallback(
parent => { parent => {
if (parent) { if (parent) {
const wrapper = new PDFJSWrapper(parent.firstChild) let wrapper: PDFJSWrapper | undefined
wrapper try {
.init() wrapper = new PDFJSWrapper(parent.firstChild)
.then(() => {
setPdfJsWrapper(wrapper) setPdfJsWrapper(wrapper)
}) } catch (error) {
.catch(error => {
setLoadingError(true) setLoadingError(true)
captureException(error) captureException(error)
}) }
return () => { return () => {
setPdfJsWrapper(null) setPdfJsWrapper(null)
wrapper.destroy() wrapper?.destroy().catch(debugConsole.error)
} }
} }
}, },
@ -180,7 +172,7 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
const handleFetchError = (err: Error) => { const handleFetchError = (err: Error) => {
if (abortController.signal.aborted) return if (abortController.signal.aborted) return
// The error is already logged at the call-site with additional context. // The error is already logged at the call-site with additional context.
if (err instanceof pdfJsWrapper.PDFJS.MissingPDFException) { if (err instanceof PDFJS.MissingPDFException) {
setError('rendering-error-expected') setError('rendering-error-expected')
} else { } else {
setError('rendering-error') setError('rendering-error')
@ -254,7 +246,7 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
if (!textLayerDiv.dataset.listeningForDoubleClick) { if (!textLayerDiv.dataset.listeningForDoubleClick) {
textLayerDiv.dataset.listeningForDoubleClick = true textLayerDiv.dataset.listeningForDoubleClick = true
const doubleClickListener: MouseEventHandler = event => { const doubleClickListener = (event: MouseEvent) => {
const clickPosition = pdfJsWrapper.clickPosition( const clickPosition = pdfJsWrapper.clickPosition(
event, event,
textLayerDiv.closest('.page').querySelector('canvas'), textLayerDiv.closest('.page').querySelector('canvas'),
@ -355,7 +347,7 @@ function PdfJsViewer({ url, pdfFile }: PdfJsViewerProps) {
for (const highlight of highlights) { for (const highlight of highlights) {
try { try {
const element = buildHighlightElement(highlight, pdfJsWrapper) const element = buildHighlightElement(highlight, pdfJsWrapper.viewer)
elements.push(element) elements.push(element)
intersectionObserver.observe(element) intersectionObserver.observe(element)
} catch (error) { } catch (error) {

View file

@ -1,4 +1,4 @@
import { memo, Suspense } from 'react' import { ElementType, memo, Suspense } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import PdfLogsViewer from './pdf-logs-viewer' import PdfLogsViewer from './pdf-logs-viewer'
import PdfViewer from './pdf-viewer' import PdfViewer from './pdf-viewer'
@ -10,7 +10,10 @@ import CompileTimeWarningUpgradePrompt from './compile-time-warning-upgrade-prom
import { PdfPreviewProvider } from './pdf-preview-provider' import { PdfPreviewProvider } from './pdf-preview-provider'
import importOverleafModules from '../../../../macros/import-overleaf-module.macro' import importOverleafModules from '../../../../macros/import-overleaf-module.macro'
const pdfPreviewPromotions = importOverleafModules('pdfPreviewPromotions') const pdfPreviewPromotions = importOverleafModules('pdfPreviewPromotions') as {
import: { default: ElementType }
path: string
}[]
function PdfPreviewPane() { function PdfPreviewPane() {
const { pdfUrl, hasShortCompileTimeout } = useCompileContext() const { pdfUrl, hasShortCompileTimeout } = useCompileContext()

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import PDFJSWrapper from '../util/pdf-js-wrapper' import PDFJSWrapper from '../util/pdf-js-wrapper'
import { sendMB } from '@/infrastructure/event-tracking' import { sendMB } from '@/infrastructure/event-tracking'
import { debugConsole } from '@/utils/debugging'
type StoredPDFState = { type StoredPDFState = {
scrollMode?: number scrollMode?: number
@ -116,7 +117,9 @@ export default function usePresentationMode(
sendMB('pdf-viewer-enter-presentation-mode') sendMB('pdf-viewer-enter-presentation-mode')
if (pdfJsWrapper) { if (pdfJsWrapper) {
pdfJsWrapper.container.parentNode.requestFullscreen() pdfJsWrapper.container.parentElement
?.requestFullscreen()
.catch(debugConsole.error)
} }
}, [pdfJsWrapper]) }, [pdfJsWrapper])
@ -136,8 +139,8 @@ export default function usePresentationMode(
const handleExitFullscreen = useCallback(() => { const handleExitFullscreen = useCallback(() => {
if (pdfJsWrapper) { if (pdfJsWrapper) {
pdfJsWrapper.viewer.scrollMode = storedState.current.scrollMode pdfJsWrapper.viewer.scrollMode = storedState.current.scrollMode!
pdfJsWrapper.viewer.spreadMode = storedState.current.spreadMode pdfJsWrapper.viewer.spreadMode = storedState.current.spreadMode!
if (storedState.current.currentScaleValue !== undefined) { if (storedState.current.currentScaleValue !== undefined) {
setScale(storedState.current.currentScaleValue) setScale(storedState.current.currentScaleValue)

View file

@ -1,5 +1,7 @@
export function buildHighlightElement(highlight, wrapper) { import { PDFJS } from '@/features/pdf-preview/util/pdf-js'
const pageView = wrapper.viewer.getPageView(highlight.page - 1)
export function buildHighlightElement(highlight, viewer) {
const pageView = viewer.getPageView(highlight.page - 1)
const viewport = pageView.viewport const viewport = pageView.viewport
@ -12,7 +14,7 @@ export function buildHighlightElement(highlight, wrapper) {
height - highlight.v + 10, // yMax height - highlight.v + 10, // yMax
]) ])
const [left, top, right, bottom] = wrapper.PDFJS.Util.normalizeRect(rect) const [left, top, right, bottom] = PDFJS.Util.normalizeRect(rect)
const element = document.createElement('div') const element = document.createElement('div')
element.style.left = Math.floor(pageView.div.offsetLeft + left) + 'px' element.style.left = Math.floor(pageView.div.offsetLeft + left) + 'px'
@ -27,7 +29,7 @@ export function buildHighlightElement(highlight, wrapper) {
element.style.opacity = '0' element.style.opacity = '0'
element.style.transition = 'opacity 1s' element.style.transition = 'opacity 1s'
wrapper.viewer.viewer.append(element) viewer.viewer?.append(element)
return element return element
} }

View file

@ -1,6 +1,6 @@
import OError from '@overleaf/o-error' import OError from '@overleaf/o-error'
import { fallbackRequest, fetchRange } from './pdf-caching' import { fallbackRequest, fetchRange } from './pdf-caching'
import { captureException } from '../../../infrastructure/error-reporter' import { captureException } from '@/infrastructure/error-reporter'
import { getPdfCachingMetrics } from './metrics' import { getPdfCachingMetrics } from './metrics'
import { import {
cachedUrlLookupEnabled, cachedUrlLookupEnabled,
@ -9,16 +9,17 @@ import {
prefetchLargeEnabled, prefetchLargeEnabled,
trackPdfDownloadEnabled, trackPdfDownloadEnabled,
} from './pdf-caching-flags' } from './pdf-caching-flags'
import { isNetworkError } from '../../../utils/isNetworkError' import { isNetworkError } from '@/utils/isNetworkError'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { PDFJS } from './pdf-js'
// 30 seconds: The shutdown grace period of a clsi pre-emp instance. // 30 seconds: The shutdown grace period of a clsi pre-emp instance.
const STALE_OUTPUT_REQUEST_THRESHOLD_MS = 30 * 1000 const STALE_OUTPUT_REQUEST_THRESHOLD_MS = 30 * 1000
export function generatePdfCachingTransportFactory(PDFJS) { export function generatePdfCachingTransportFactory() {
// NOTE: The custom transport can be used for tracking download volume. // NOTE: The custom transport can be used for tracking download volume.
if (!enablePdfCaching && !trackPdfDownloadEnabled) { if (!enablePdfCaching && !trackPdfDownloadEnabled) {
return () => null return () => undefined
} }
const usageScore = new Map() const usageScore = new Map()
const cachedUrls = new Map() const cachedUrls = new Map()
@ -180,7 +181,7 @@ export function generatePdfCachingTransportFactory(PDFJS) {
if (metrics.failedOnce) { if (metrics.failedOnce) {
// Disable pdf caching once any fetch request failed. // Disable pdf caching once any fetch request failed.
// Be trigger-happy here until we reached a stable state of the feature. // Be trigger-happy here until we reached a stable state of the feature.
return null return undefined
} }
// Latency is collected per preview cycle. // Latency is collected per preview cycle.
metrics.latencyComputeMax = 0 metrics.latencyComputeMax = 0

View file

@ -1,38 +0,0 @@
// To add a new version, copy and adjust one of the `importPDFJS*` functions below,
// add the variant to the "switch" statement, and add to `pdfjsVersions` in webpack.config.js
import 'core-js/stable/global-this' // polyfill for globalThis (used by pdf.js)
import 'core-js/stable/promise/all-settled' // polyfill for Promise.allSettled (used by pdf.js)
import 'core-js/stable/structured-clone' // polyfill for global.StructuredClone (used by pdf.js)
import 'core-js/stable/array/at' // polyfill for Array.prototype.at (used by pdf.js)
import { createWorker } from '@/utils/worker'
async function importPDFJS() {
const cMapUrl = '/js/pdfjs-dist/cmaps/'
const standardFontDataUrl = '/fonts/pdfjs-dist/'
const imageResourcesPath = '/images/pdfjs-dist/'
// ensure that PDF.js is loaded before importing the viewer
const PDFJS = await import('pdfjs-dist/legacy/build/pdf')
const [PDFJSViewer] = await Promise.all([
import('pdfjs-dist/legacy/web/pdf_viewer'),
import('pdfjs-dist/legacy/web/pdf_viewer.css'),
])
createWorker(() => {
PDFJS.GlobalWorkerOptions.workerPort = new Worker(
new URL('pdfjs-dist/legacy/build/pdf.worker.mjs', import.meta.url) // NOTE: .mjs extension
)
})
return {
PDFJS,
PDFJSViewer,
cMapUrl,
imageResourcesPath,
standardFontDataUrl,
}
}
export default importPDFJS()

View file

@ -1,81 +1,70 @@
import { captureException } from '../../../infrastructure/error-reporter' import { captureException } from '@/infrastructure/error-reporter'
import { generatePdfCachingTransportFactory } from './pdf-caching-transport' import { generatePdfCachingTransportFactory } from './pdf-caching-transport'
import { debugConsole } from '@/utils/debugging' import { debugConsole } from '@/utils/debugging'
import { PDFJS, loadPdfDocumentFromUrl, imageResourcesPath } from './pdf-js'
const params = new URLSearchParams(window.location.search) import {
const disableFontFace = params.get('disable-font-face') === 'true' PDFViewer,
const disableStream = process.env.NODE_ENV !== 'test' EventBus,
PDFLinkService,
LinkTarget,
} from 'pdfjs-dist/legacy/web/pdf_viewer.mjs'
import 'pdfjs-dist/legacy/web/pdf_viewer.css'
const DEFAULT_RANGE_CHUNK_SIZE = 128 * 1024 // 128K chunks const DEFAULT_RANGE_CHUNK_SIZE = 128 * 1024 // 128K chunks
export default class PDFJSWrapper { export default class PDFJSWrapper {
constructor(container) { private loadDocumentTask: PDFJS.PDFDocumentLoadingTask | undefined
this.container = container
}
async init() { public readonly viewer: PDFViewer
const { public readonly eventBus: EventBus
PDFJS, private readonly linkService: PDFLinkService
PDFJSViewer,
cMapUrl,
imageResourcesPath,
standardFontDataUrl,
} = await import('./pdf-js-versions').then(m => {
return m.default
})
this.PDFJS = PDFJS
this.genPdfCachingTransport = generatePdfCachingTransportFactory(PDFJS)
this.PDFJSViewer = PDFJSViewer
this.cMapUrl = cMapUrl
this.standardFontDataUrl = standardFontDataUrl
this.imageResourcesPath = imageResourcesPath
// eslint-disable-next-line no-useless-constructor
constructor(public container: HTMLDivElement) {
// create the event bus // create the event bus
const eventBus = new PDFJSViewer.EventBus() this.eventBus = new EventBus()
// create the link service // create the link service
const linkService = new PDFJSViewer.PDFLinkService({ this.linkService = new PDFLinkService({
eventBus, eventBus: this.eventBus,
externalLinkTarget: 2, externalLinkTarget: LinkTarget.BLANK,
externalLinkRel: 'noopener', externalLinkRel: 'noopener',
}) })
// create the localization
// const l10n = new PDFJSViewer.GenericL10n('en-GB') // TODO: locale mapping?
// create the viewer // create the viewer
const viewer = new PDFJSViewer.PDFViewer({ this.viewer = new PDFViewer({
container: this.container, container: this.container,
eventBus, eventBus: this.eventBus,
imageResourcesPath, imageResourcesPath,
linkService, linkService: this.linkService,
// l10n, // commented out since it currently breaks `aria-label` rendering in pdf pages
enableScripting: false, // default is false, but set explicitly to be sure
enableXfa: false, // default is false (2021-10-12), but set explicitly to be sure
renderInteractiveForms: false,
maxCanvasPixels: 8192 * 8192, // default is 4096 * 4096, increased for better resolution at high zoom levels maxCanvasPixels: 8192 * 8192, // default is 4096 * 4096, increased for better resolution at high zoom levels
annotationMode: PDFJS.AnnotationMode?.ENABLE, // enable annotations but not forms annotationMode: PDFJS.AnnotationMode.ENABLE, // enable annotations but not forms
annotationEditorMode: PDFJS.AnnotationEditorType?.DISABLE, // disable annotation editing annotationEditorMode: PDFJS.AnnotationEditorType.DISABLE, // disable annotation editing
}) })
linkService.setViewer(viewer) this.linkService.setViewer(this.viewer)
this.eventBus = eventBus
this.linkService = linkService
this.viewer = viewer
} }
// load a document from a URL // load a document from a URL
loadDocument({ url, pdfFile, abortController, handleFetchError }) { loadDocument({
url,
pdfFile,
abortController,
handleFetchError,
}: {
url: string
pdfFile: Record<string, any>
abortController: AbortController
handleFetchError: (error: Error) => void
}) {
// cancel any previous loading task // cancel any previous loading task
if (this.loadDocumentTask) { if (this.loadDocumentTask) {
this.loadDocumentTask.destroy() this.loadDocumentTask.destroy().catch(debugConsole.error)
this.loadDocumentTask = undefined this.loadDocumentTask = undefined
} }
return new Promise((resolve, reject) => { return new Promise<PDFJS.PDFDocumentProxy>((resolve, reject) => {
const rangeTransport = this.genPdfCachingTransport({ const rangeTransport = generatePdfCachingTransportFactory()({
url, url,
pdfFile, pdfFile,
abortController, abortController,
@ -87,35 +76,25 @@ export default class PDFJSWrapper {
// custom range transport. Restore it by bumping the chunk size. // custom range transport. Restore it by bumping the chunk size.
rangeChunkSize = pdfFile.size rangeChunkSize = pdfFile.size
} }
this.loadDocumentTask = this.PDFJS.getDocument({ this.loadDocumentTask = loadPdfDocumentFromUrl(url, {
url,
cMapUrl: this.cMapUrl,
cMapPacked: true,
standardFontDataUrl: this.standardFontDataUrl,
disableFontFace,
rangeChunkSize, rangeChunkSize,
disableAutoFetch: true,
disableStream,
isEvalSupported: false,
textLayerMode: 2, // PDFJSViewer.TextLayerMode.ENABLE,
range: rangeTransport, range: rangeTransport,
}) })
this.loadDocumentTask.promise this.loadDocumentTask.promise
.then(doc => { .then(doc => {
if (!this.loadDocumentTask) { if (!this.loadDocumentTask || !this.viewer) {
return // ignoring the response since loading task has been aborted return // ignoring the response since loading task has been aborted
} }
const previousDoc = this.viewer.pdfDocument const previousDoc = this.viewer.pdfDocument
this.viewer.setDocument(doc) this.viewer.setDocument(doc)
this.linkService.setDocument(doc) this.linkService.setDocument(doc)
resolve(doc) resolve(doc)
if (previousDoc) { if (previousDoc) {
previousDoc.cleanup().catch(debugConsole.error) previousDoc.cleanup().catch(debugConsole.error)
previousDoc.destroy() previousDoc.destroy().catch(debugConsole.error)
} }
}) })
.catch(error => { .catch(error => {
@ -163,7 +142,7 @@ export default class PDFJSWrapper {
} }
// get the page and offset of a click event // get the page and offset of a click event
clickPosition(event, canvas, page) { clickPosition(event: MouseEvent, canvas: HTMLCanvasElement, page: number) {
if (!canvas) { if (!canvas) {
return return
} }
@ -205,7 +184,7 @@ export default class PDFJSWrapper {
} }
} }
scrollToPosition(position, scale = null) { scrollToPosition(position: Record<string, any>, scale = null) {
const destArray = [ const destArray = [
null, null,
{ {
@ -239,13 +218,9 @@ export default class PDFJSWrapper {
this.loadDocumentTask = undefined this.loadDocumentTask = undefined
} }
destroy() { async destroy() {
if (this.loadDocumentTask) { await this.loadDocumentTask?.destroy().catch(debugConsole.error)
this.loadDocumentTask.destroy()
this.loadDocumentTask = undefined this.loadDocumentTask = undefined
} await this.viewer.pdfDocument?.destroy().catch(debugConsole.error)
if (this.viewer) {
this.viewer.destroy()
}
} }
} }

View file

@ -0,0 +1,40 @@
import 'core-js/stable/global-this' // polyfill for globalThis (used by pdf.js)
import 'core-js/stable/promise/all-settled' // polyfill for Promise.allSettled (used by pdf.js)
import 'core-js/stable/structured-clone' // polyfill for global.StructuredClone (used by pdf.js)
import 'core-js/stable/array/at' // polyfill for Array.prototype.at (used by pdf.js)
import { createWorker } from '@/utils/worker'
import * as PDFJS from 'pdfjs-dist/legacy/build/pdf.mjs'
import type { DocumentInitParameters } from 'pdfjs-dist/types/src/display/api'
export { PDFJS }
createWorker(() => {
PDFJS.GlobalWorkerOptions.workerPort = new Worker(
new URL('pdfjs-dist/legacy/build/pdf.worker.mjs', import.meta.url) // NOTE: .mjs extension
)
})
export const imageResourcesPath = '/images/pdfjs-dist/'
const cMapUrl = '/js/pdfjs-dist/cmaps/'
const standardFontDataUrl = '/fonts/pdfjs-dist/'
const params = new URLSearchParams(window.location.search)
const disableFontFace = params.get('disable-font-face') === 'true'
const disableStream = process.env.NODE_ENV !== 'test'
export const loadPdfDocumentFromUrl = (
url: string,
options: Partial<DocumentInitParameters> = {}
) =>
PDFJS.getDocument({
url,
cMapUrl,
standardFontDataUrl,
disableFontFace,
disableAutoFetch: true, // only fetch the data needed for the displayed pages
disableStream,
isEvalSupported: false,
enableXfa: false, // default is false (2021-10-12), but set explicitly to be sure
...options,
})

View file

@ -140,14 +140,16 @@ export class GraphicsWidget extends WidgetType {
} }
async renderPDF(view: EditorView, canvas: HTMLCanvasElement, url: string) { async renderPDF(view: EditorView, canvas: HTMLCanvasElement, url: string) {
const { PDFJS } = await this.importPDFJS() const { loadPdfDocumentFromUrl } = await import(
'@/features/pdf-preview/util/pdf-js'
)
// bail out if loading PDF.js took too long // bail out if loading PDF.js took too long
if (this.destroyed) { if (this.destroyed) {
return return
} }
const pdf = await PDFJS.getDocument({ url, isEvalSupported: false }).promise const pdf = await loadPdfDocumentFromUrl(url).promise
const page = await pdf.getPage(1) const page = await pdf.getPage(1)
// bail out if loading the PDF took too long // bail out if loading the PDF took too long
@ -162,16 +164,10 @@ export class GraphicsWidget extends WidgetType {
canvas.style.width = width canvas.style.width = width
canvas.style.maxWidth = width canvas.style.maxWidth = width
page.render({ page.render({
canvasContext: canvas.getContext('2d'), canvasContext: canvas.getContext('2d')!,
viewport, viewport,
}) })
this.height = viewport.height this.height = viewport.height
view.requestMeasure() view.requestMeasure()
} }
async importPDFJS(): Promise<any> {
return import('../../../../pdf-preview/util/pdf-js-versions').then(
m => m.default
)
}
} }