feat: 实现 Chromium PDF 导出与精确预览
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
RenderingCancelledException,
|
||||
TextLayer,
|
||||
type PDFDocumentProxy,
|
||||
type PDFPageProxy,
|
||||
type RenderTask
|
||||
} from "pdfjs-dist";
|
||||
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
import {
|
||||
getPdfCanvasRenderScale,
|
||||
getPdfPageCssDimensions
|
||||
} from "./pdf-page-geometry";
|
||||
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||
|
||||
interface PdfPageDescriptor {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PrecisePdfPreviewProps {
|
||||
blob: Blob;
|
||||
onPageCount?: (pageCount: number) => void;
|
||||
}
|
||||
|
||||
interface PrecisePdfPageProps {
|
||||
document: PDFDocumentProxy;
|
||||
descriptor: PdfPageDescriptor;
|
||||
renderQueue: PdfPageRenderQueue;
|
||||
}
|
||||
|
||||
function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
||||
const [nearViewport, setNearViewport] = useState(false);
|
||||
const releaseTimerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
if (releaseTimerRef.current !== undefined) {
|
||||
window.clearTimeout(releaseTimerRef.current);
|
||||
releaseTimerRef.current = undefined;
|
||||
}
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
releaseTimerRef.current = window.setTimeout(() => {
|
||||
releaseTimerRef.current = undefined;
|
||||
setNearViewport(false);
|
||||
}, 1_500);
|
||||
},
|
||||
{
|
||||
rootMargin: "1200px 0px"
|
||||
}
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (releaseTimerRef.current !== undefined) {
|
||||
window.clearTimeout(releaseTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [elementRef]);
|
||||
|
||||
return nearViewport;
|
||||
}
|
||||
|
||||
function PrecisePdfPage({
|
||||
document,
|
||||
descriptor,
|
||||
renderQueue
|
||||
}: PrecisePdfPageProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const textLayerRef = useRef<HTMLDivElement>(null);
|
||||
const [displayWidth, setDisplayWidth] = useState(0);
|
||||
const [renderError, setRenderError] = useState("");
|
||||
const [renderState, setRenderState] = useState<
|
||||
"idle" | "queued" | "rendering" | "rendered"
|
||||
>("idle");
|
||||
const nearViewport = useNearViewport(containerRef);
|
||||
const dimensions = getPdfPageCssDimensions(
|
||||
descriptor.width,
|
||||
descriptor.height
|
||||
);
|
||||
const pageStyle = {
|
||||
width: `${dimensions.width}px`,
|
||||
aspectRatio: `${descriptor.width} / ${descriptor.height}`
|
||||
} satisfies CSSProperties;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateWidth = () => setDisplayWidth(container.clientWidth);
|
||||
updateWidth();
|
||||
const observer = new ResizeObserver(updateWidth);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const updateVisibility = useCallback(
|
||||
(visible: boolean) => {
|
||||
renderQueue.setVisible(descriptor.pageNumber, visible);
|
||||
},
|
||||
[descriptor.pageNumber, renderQueue]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
updateVisibility(true);
|
||||
return () => updateVisibility(false);
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
updateVisibility(Boolean(entry?.isIntersecting));
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
updateVisibility(false);
|
||||
};
|
||||
}, [updateVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const textContainer = textLayerRef.current;
|
||||
if (
|
||||
!nearViewport ||
|
||||
displayWidth <= 0 ||
|
||||
!canvas ||
|
||||
!textContainer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let page: PDFPageProxy | undefined;
|
||||
let renderTask: RenderTask | undefined;
|
||||
let textLayer: TextLayer | undefined;
|
||||
|
||||
setRenderError("");
|
||||
setRenderState("queued");
|
||||
const cancelQueuedRender = renderQueue.schedule(
|
||||
descriptor.pageNumber,
|
||||
async () => {
|
||||
try {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setRenderState("rendering");
|
||||
const loadedPage = await document.getPage(
|
||||
descriptor.pageNumber
|
||||
);
|
||||
page = loadedPage;
|
||||
const outputScale = Math.min(
|
||||
window.devicePixelRatio || 1,
|
||||
2
|
||||
);
|
||||
const renderScale = getPdfCanvasRenderScale(
|
||||
descriptor.width,
|
||||
displayWidth,
|
||||
outputScale
|
||||
);
|
||||
const displayScale = displayWidth / descriptor.width;
|
||||
const renderViewport = page.getViewport({
|
||||
scale: renderScale
|
||||
});
|
||||
const displayViewport = page.getViewport({
|
||||
scale: displayScale
|
||||
});
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error("浏览器无法创建 PDF Canvas");
|
||||
}
|
||||
|
||||
canvas.width = Math.ceil(renderViewport.width);
|
||||
canvas.height = Math.ceil(renderViewport.height);
|
||||
canvas.style.width = `${displayViewport.width}px`;
|
||||
canvas.style.height = `${displayViewport.height}px`;
|
||||
textContainer.replaceChildren();
|
||||
|
||||
renderTask = page.render({
|
||||
canvas,
|
||||
canvasContext: context,
|
||||
viewport: renderViewport
|
||||
});
|
||||
await renderTask.promise;
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setRenderState("rendered");
|
||||
|
||||
textLayer = new TextLayer({
|
||||
container: textContainer,
|
||||
textContentSource: page.streamTextContent(),
|
||||
viewport: displayViewport
|
||||
});
|
||||
await textLayer.render();
|
||||
} catch (reason: unknown) {
|
||||
if (
|
||||
disposed ||
|
||||
reason instanceof RenderingCancelledException
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setRenderError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "PDF 页面渲染失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelQueuedRender();
|
||||
renderTask?.cancel();
|
||||
textLayer?.cancel();
|
||||
page?.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
textContainer.replaceChildren();
|
||||
setRenderState("idle");
|
||||
};
|
||||
}, [
|
||||
descriptor.height,
|
||||
descriptor.pageNumber,
|
||||
descriptor.width,
|
||||
displayWidth,
|
||||
document,
|
||||
nearViewport,
|
||||
renderQueue
|
||||
]);
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={containerRef}
|
||||
className="precise-pdf-page"
|
||||
style={pageStyle}
|
||||
aria-label={`PDF 第 ${descriptor.pageNumber} 页`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={renderState === "rendered" ? "is-rendered" : ""}
|
||||
width={0}
|
||||
height={0}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div ref={textLayerRef} className="textLayer" />
|
||||
{renderState !== "rendered" ? (
|
||||
<span className="precise-pdf-page-placeholder">
|
||||
{renderState === "rendering"
|
||||
? `正在渲染第 ${descriptor.pageNumber} 页…`
|
||||
: `第 ${descriptor.pageNumber} 页`}
|
||||
</span>
|
||||
) : null}
|
||||
{renderError ? (
|
||||
<span className="precise-pdf-page-error">{renderError}</span>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrecisePdfPreview({
|
||||
blob,
|
||||
onPageCount
|
||||
}: PrecisePdfPreviewProps) {
|
||||
const [document, setDocument] = useState<PDFDocumentProxy>();
|
||||
const [pages, setPages] = useState<PdfPageDescriptor[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const renderQueue = useMemo(
|
||||
() => new PdfPageRenderQueue(1),
|
||||
[document]
|
||||
);
|
||||
|
||||
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let loadingTask:
|
||||
| ReturnType<typeof getDocument>
|
||||
| undefined;
|
||||
|
||||
setDocument(undefined);
|
||||
setPages([]);
|
||||
setError("");
|
||||
|
||||
void blob
|
||||
.arrayBuffer()
|
||||
.then((buffer) => {
|
||||
if (disposed) {
|
||||
return undefined;
|
||||
}
|
||||
loadingTask = getDocument({
|
||||
data: new Uint8Array(buffer)
|
||||
});
|
||||
return loadingTask.promise;
|
||||
})
|
||||
.then(async (loadedDocument) => {
|
||||
if (!loadedDocument || disposed) {
|
||||
return;
|
||||
}
|
||||
const descriptors = await Promise.all(
|
||||
Array.from(
|
||||
{ length: loadedDocument.numPages },
|
||||
async (_, index) => {
|
||||
const page = await loadedDocument.getPage(index + 1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
page.cleanup();
|
||||
return {
|
||||
pageNumber: index + 1,
|
||||
width: viewport.width,
|
||||
height: viewport.height
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setDocument(loadedDocument);
|
||||
setPages(descriptors);
|
||||
onPageCount?.(loadedDocument.numPages);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!disposed) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "无法读取 PDF"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
void loadingTask?.destroy();
|
||||
};
|
||||
}, [blob, onPageCount]);
|
||||
|
||||
if (error) {
|
||||
return <div className="precise-pdf-status is-error">{error}</div>;
|
||||
}
|
||||
if (!document || pages.length === 0) {
|
||||
return <div className="precise-pdf-status">正在读取精确预览…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="precise-pdf-document">
|
||||
{pages.map((page) => (
|
||||
<PrecisePdfPage
|
||||
key={page.pageNumber}
|
||||
document={document}
|
||||
descriptor={page}
|
||||
renderQueue={renderQueue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user