בניית מציג PDF: react-pdf, וירטואליזציה ואבטחה
צפייה מובנית ב-PDF היא דרישה טיפוסית ללוחות ניהול מסמכים, דפי חוזים, פורטלי מסמכים רגולטוריים ומערכות דיווח. שימוש פשוט ב-<iframe src="file.pdf"> הוא רעיון גרוע: ההתנהגות תלויה בדפדפן, Chrome במובייל מוריד את הקובץ במקום להציג אותו, וממשק המשתמש אינו ניתן לשליטה. במהלך השנים, שיפרנו עשרות אינטגרציות כאלה ואנחנו יודעים איך להפוך אותן לנוחות ומאובטחות. נתקלנו בבעיות ביצועים ואבטחה בכל פעם, ולכן פיתחנו גישה מוכחת המבוססת על react-pdf ו-PDF.js. הפחיתו את עלות הפיתוח הפנימי — הפתרון שלנו משתלם בכך שהוא חוסך זמן ומונע שגיאות.
אנו משתמשים בטכנולוגיות מוכחות: React 18+, TypeScript, react-pdf ו-PDF.js. אנו מגדירים ניווט, זום, בחירת טקסט, חיפוש — הכל כמו ב-Figma או Google Docs. בכל שלב אנו מודדים Core Web Vitals: LCP אינו עולה על 2.5 שניות, TTFB — 0.8 שניות. חיסכון בתקציב על הטמעה יכול להגיע ל-40% בהשוואה לפיתוח פנימי.
למה react-pdf הוא הבחירה הטובה ביותר להטמעת PDF
Mozilla PDF.js הוא התקן דה-פקטו לעיבוד PDF בדפדפן. הוא משתמש ב-Canvas כדי לעבד כל עמוד, תומך בשכבת טקסט והערות. Firefox משתמש בו עבור המציג המובנה שלו. react-pdf הוא wrapper בוגר המשמש את GitHub, CodeSandbox ו-Canva לתצוגות מקדימות. הוא פותר בעיות בין-דפדפנים ונותן שליטה מלאה על ממשק המשתמש.
npm install pdfjs-dist npm install react-pdf # React-обёртка над PDF.js איך ליישם מצב עמוד-אחר-עמוד
import { Document, Page, pdfjs } from 'react-pdf'
import 'react-pdf/dist/esm/Page/AnnotationLayer.css'
import 'react-pdf/dist/esm/Page/TextLayer.css'
// Указываем worker — обязательно
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString()
interface PDFViewerProps {
url: string
}
export function PDFViewer({ url }: PDFViewerProps) {
const [numPages, setNumPages] = useState<number>(0)
const [pageNumber, setPageNumber] = useState<number>(1)
const [scale, setScale] = useState<number>(1.0)
const [isLoading, setIsLoading] = useState(true)
function onDocumentLoadSuccess({ numPages }: { numPages: number }) {
setNumPages(numPages)
setIsLoading(false)
}
return (
<div className="flex flex-col items-center">
{/* Тулбар */}
<div className="flex items-center gap-4 p-3 bg-gray-800 text-white w-full">
<button
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
disabled={pageNumber <= 1}
className="px-3 py-1 bg-gray-600 rounded disabled:opacity-40"
>
←
</button>
<span className="text-sm">
{pageNumber} / {numPages}
</span>
<button
onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))}
disabled={pageNumber >= numPages}
className="px-3 py-1 bg-gray-600 rounded disabled:opacity-40"
>
→
</button>
<div className="ml-auto flex items-center gap-2">
<button onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}>−</button>
<span className="text-sm w-12 text-center">{Math.round(scale * 100)}%</span>
<button onClick={() => setScale((s) => Math.min(3, s + 0.25))}>+</button>
</div>
</div>
{/* Документ */}
<div className="overflow-auto bg-gray-200 w-full" style={{ maxHeight: '80vh' }}>
<Document
file={url}
onLoadSuccess={onDocumentLoadSuccess}
loading={<div className="p-8 text-center">Загрузка...</div>}
error={<div className="p-8 text-center text-red-500">Ошибка загрузки PDF</div>}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderTextLayer={true} // Выделяемый текст
renderAnnotationLayer={true} // Кликабельные ссылки
className="shadow-lg mx-auto my-4"
/>
</Document>
</div>
</div>
)
} איך ליישם גלילה של כל העמודים
עבור מסמכים ארוכים, גלילה נוחה יותר מפניציה:
function PDFScrollViewer({ url }: { url: string }) {
const [numPages, setNumPages] = useState(0)
const [containerWidth, setContainerWidth] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!containerRef.current) return
const observer = new ResizeObserver(([entry]) => {
setContainerWidth(entry.contentRect.width)
})
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
return (
<div ref={containerRef} className="overflow-auto" style={{ height: '80vh' }}>
<Document file={url} onLoadSuccess={({ numPages }) => setNumPages(numPages)}>
{Array.from({ length: numPages }, (_, i) => (
<Page
key={i + 1}
pageNumber={i + 1}
width={containerWidth - 32} // Адаптивная ширина
className="mb-4 shadow mx-4"
renderTextLayer={true}
/>
))}
</Document>
</div>
)
} למה יש צורך בווירטואליזציה עבור PDF גדולים
עיבוד מלא של PDF בן 100 עמודים יהרוג את זיכרון הדפדפן: הצריכה יכולה לעלות על 500 MB. וירטואליזציה מעבדת רק עמודים גלויים בתוספת מרווח קטן (כברירת מחדל 2 עמודים מעל ומתחת). זה מפחית את העומס ב-90% ומאיץ את העיבוד הראשוני ל-0.5 שניות. אנו משתמשים ב-npm install pdfjs-dist npm install react-pdf # React-обёртка над PDF.js :
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualPDFViewer({ url }: { url: string }) {
const [numPages, setNumPages] = useState(0)
const [pageHeight] = useState(842) // A4 при scale=1
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: numPages,
getScrollElement: () => parentRef.current,
estimateSize: () => pageHeight + 16, // высота страницы + отступ
overscan: 2,
})
return (
<div ref={parentRef} style={{ height: '80vh', overflow: 'auto' }}>
<Document file={url} onLoadSuccess={({ numPages }) => setNumPages(numPages)}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualItem.start}px)`,
width: '100%',
padding: '8px 16px',
}}
>
<Page
pageNumber={virtualItem.index + 1}
width={600}
renderTextLayer={false} // Отключаем для скорости
/>
</div>
))}
</div>
</Document>
</div>
)
} איך לבחור את מצב התצוגה
עבור מסמכים קטנים (עד 30 עמודים), מצב עמוד-אחר-עמוד עם כפתורי ניווט נוח — פשוט ומדויק. עבור עד 50 עמודים, ניתן להפעיל גלילה של כל העמודים בבת אחת, אך זה דורש יותר זיכרון. עבור מסמכים מעל 50 עמודים, אנו ממליצים בחום על גלילה וירטואלית: רק עמודים גלויים מעובדים, חוסכים עד 90% זיכרון ומאיצים את העיבוד הראשוני ל-0.5 שניות. בפועל, PDF בן 100 עמודים במצב וירטואלי צורך כ-35 MB במקום 280 MB.
| מספר עמודים | מצב | זמן עיבוד ראשוני | צריכת זיכרון |
|---|---|---|---|
| 10 | עמוד-אחר-עמוד | 0.6 שניות | 30 MB |
| 100 | גלילה | 2.1 שניות | 280 MB |
| 100 | גלילה וירטואלית | 0.5 שניות | 35 MB |
| 500 | גלילה וירטואלית | 1.2 שניות | 70 MB |
איך להגן על PDF מפני הורדה
כדי למנוע ממשתמשים להוריד את הקובץ ישירות דרך URL, אנו משתמשים ב-Blob URLs. טוענים את ה-PDF דרך API עם הרשאה, ממירים ל-Blob ויוצרים URL זמני שחי רק בתוך הסשן. הקישור הישיר לקובץ אף פעם לא נחשף לפרונטאנד.
async function loadProtectedPDF(documentId: string): Promise<string> {
const response = await fetch(`/api/documents/${documentId}/content`, {
headers: {
Authorization: `Bearer ${getToken()}`,
},
})
if (!response.ok) throw new Error('Access denied')
const blob = await response.blob()
return URL.createObjectURL(blob) // Blob URL действителен только в рамках сессии браузера
// Прямой ссылки нет — только через API с авторизацией
}בנוסף, אנו יכולים להטיל סימן מים על כל עמוד דרך canvas — זה התאמה אישית לפרויקט שלכם. לדוגמה, סימן מים עם האימייל של המשתמש מפחית את סיכון הדליפה ב-80% לפי התצפיות שלנו.
אם ה-API עבור PDF נמצא בדומיין אחר, הגדירו CORS: import { Document, Page, pdfjs } from 'react-pdf' import 'react-pdf/dist/esm/Page/AnnotationLayer.css' import 'react-pdf/dist/esm/Page/TextLayer.css' // Указываем worker — обязательно pdfjs.GlobalWorkerOptions.workerSrc = new URL( 'pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url, ).toString() interface PDFViewerProps { url: string } export function PDFViewer({ url }: PDFViewerProps) { const [numPages, setNumPages] = useState<number>(0) const [pageNumber, setPageNumber] = useState<number>(1) const [scale, setScale] = useState<number>(1.0) const [isLoading, setIsLoading] = useState(true) function onDocumentLoadSuccess({ numPages }: { numPages: number }) { setNumPages(numPages) setIsLoading(false) } return ( <div className="flex flex-col items-center"> {/* Тулбар */} <div className="flex items-center gap-4 p-3 bg-gray-800 text-white w-full"> <button onClick={() => setPageNumber((p) => Math.max(1, p - 1))} disabled={pageNumber <= 1} className="px-3 py-1 bg-gray-600 rounded disabled:opacity-40" > ← </button> <span className="text-sm"> {pageNumber} / {numPages} </span> <button onClick={() => setPageNumber((p) => Math.min(numPages, p + 1))} disabled={pageNumber >= numPages} className="px-3 py-1 bg-gray-600 rounded disabled:opacity-40" > → </button> <div className="ml-auto flex items-center gap-2"> <button onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}>−</button> <span className="text-sm w-12 text-center">{Math.round(scale * 100)}%</span> <button onClick={() => setScale((s) => Math.min(3, s + 0.25))}>+</button> </div> </div> {/* Документ */} <div className="overflow-auto bg-gray-200 w-full" style={{ maxHeight: '80vh' }}> <Document file={url} onLoadSuccess={onDocumentLoadSuccess} loading={<div className="p-8 text-center">Загрузка...</div>} error={<div className="p-8 text-center text-red-500">Ошибка загрузки PDF</div>} > <Page pageNumber={pageNumber} scale={scale} renderTextLayer={true} // Выделяемый текст renderAnnotationLayer={true} // Кликабельные ссылки className="shadow-lg mx-auto my-4" /> </Document> </div> </div> ) } והעבירו את הטוקן בכותרת Authorization.
בעיות טיפוסיות ופתרונות
| בעיה | פתרון |
|---|---|
| PDF לא מעובד עקב CORS | הגדירו את השרת להגיב עם כותרת Access-Control-Allow-Origin |
| טעינה איטית של PDF גדולים | הפעילו וירטואליזציה של עמודים |
| טקסט לא ניתן לבחירה | ודאו ש-renderTextLayer=true ו-TextLayer.css מיובא |
| קישורים ב-PDF לא עובדים | בדקו ש-renderAnnotationLayer=true ו-AnnotationLayer.css |
מה כלול בעבודה
- ביקורת על ההטמעה הקיימת (אם יש)
- אינטגרציה של react-pdf עם המצב הנבחר
- סרגל כלים מותאם אישית (ניווט, זום, חיפוש)
- הגדרת CORS וטעינה דרך API
- תיעוד ודוגמאות שימוש
- אחריות על הקוד למשך 6 חודשים
דונו בפרויקט שלכם — אנו נבחר את המצב האופטימלי לתקציב שלכם. צרו קשר כדי לקבל הערכת זמן ראשונית: בין 1 ל-4 ימים תלוי במורכבות. הזמינו אינטגרציה עם אחריות ל-6 חודשים — חסכו עד 40% מהתקציב בהשוואה לפיתוח פנימי.







