import { PdfFile, PdfPage } from '../types/drawing.ts' import * as PDFJS from 'pdfjs-dist' import { PDFDocument } from 'pdf-lib' import { Mark } from '../types/mark.ts' PDFJS.GlobalWorkerOptions.workerSrc = 'node_modules/pdfjs-dist/build/pdf.worker.mjs'; /** * Scale between the PDF page's natural size and rendered size * @constant {number} */ const SCALE: number = 3; /** * Converts a PDF ArrayBuffer to a generic PDF File * @param arrayBuffer of a PDF * @param fileName identifier of the pdf file */ const toFile = (arrayBuffer: ArrayBuffer, fileName: string) : File => { const blob = new Blob([arrayBuffer], { type: "application/pdf" }); return new File([blob], fileName, { type: "application/pdf" }); } /** * Converts a generic PDF File to Sigit's internal Pdf File type * @param {File} file * @return {PdfFile} Sigit's internal PDF File type */ const toPdfFile = async (file: File): Promise => { const data = await readPdf(file) const pages = await pdfToImages(data) return { file, pages, expanded: false } } /** * Transforms an array of generic PDF Files into an array of Sigit's * internal representation of Pdf Files * @param selectedFiles - an array of generic PDF Files * @return PdfFile[] - an array of Sigit's internal Pdf File type */ const toPdfFiles = async (selectedFiles: File[]): Promise => { return Promise.all(selectedFiles .filter(isPdf) .map(toPdfFile)); } /** * A utility that transforms a drawing coordinate number into a CSS-compatible string * @param coordinate */ const inPx = (coordinate: number): string => `${coordinate}px`; /** * A utility that checks if a given file is of the pdf type * @param file */ const isPdf = (file: File) => file.type.toLowerCase().includes('pdf'); /** * Reads the pdf file binaries */ const readPdf = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e: any) => { const data = e.target.result resolve(data) }; reader.onerror = (err) => { console.error('err', err) reject(err) }; reader.readAsDataURL(file); }) } /** * Converts pdf to the images * @param data pdf file bytes */ const pdfToImages = async (data: any): Promise => { const images: string[] = []; const pdf = await PDFJS.getDocument(data).promise; const canvas = document.createElement("canvas"); for (let i = 0; i < pdf.numPages; i++) { const page = await pdf.getPage(i + 1); const viewport = page.getViewport({ scale: SCALE }); const context = canvas.getContext("2d"); canvas.height = viewport.height; canvas.width = viewport.width; await page.render({ canvasContext: context!, viewport: viewport }).promise; images.push(canvas.toDataURL()); } return Promise.resolve(images.map((image) => { return { image, drawnFields: [] } })) } /** * Takes in individual pdf file and an object with Marks grouped by Page number * Returns an array of encoded images where each image is a representation * of a PDF page with completed and signed marks from all users */ const addMarks = async (file: File, marksPerPage: {[key: string]: Mark[]}) => { const p = await readPdf(file); const pdf = await PDFJS.getDocument(p).promise; const canvas = document.createElement("canvas"); const images: string[] = []; for (let i = 0; i< pdf.numPages; i++) { const page = await pdf.getPage(i+1) const viewport = page.getViewport({ scale: SCALE }); const context = canvas.getContext("2d"); canvas.height = viewport.height; canvas.width = viewport.width; await page.render({ canvasContext: context!, viewport: viewport }).promise; marksPerPage[i].forEach(mark => draw(mark, context!)) images.push(canvas.toDataURL()); } return Promise.resolve(images); } /** * Utility to scale mark in line with the PDF-to-PNG scale */ const scaleMark = (mark: Mark): Mark => { const { location } = mark; return { ...mark, location: { ...location, width: location.width * SCALE, height: location.height * SCALE, left: location.left * SCALE, top: location.top * SCALE } } } /** * Utility to check if a Mark has value * @param mark */ const hasValue = (mark: Mark): boolean => !!mark.value; /** * Draws a Mark on a Canvas representation of a PDF Page * @param mark to be drawn * @param ctx a Canvas representation of a specific PDF Page */ const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => { const { location } = mark; ctx!.font = '20px Arial'; ctx!.fillStyle = 'black'; const textMetrics = ctx!.measureText(mark.value!); const textX = location.left + (location.width - textMetrics.width) / 2; const textY = location.top + (location.height + parseInt(ctx!.font)) / 2; ctx!.fillText(mark.value!, textX, textY); } /** * Takes an array of encoded PDF pages and returns a blob that is a complete PDF file * @param markedPdfPages */ const convertToPdfBlob = async (markedPdfPages: string[]): Promise => { const pdfDoc = await PDFDocument.create(); for (const page of markedPdfPages) { const pngImage = await pdfDoc.embedPng(page) const p = pdfDoc.addPage([pngImage.width, pngImage.height]) p.drawImage(pngImage, { x: 0, y: 0, width: pngImage.width, height: pngImage.height }) } const pdfBytes = await pdfDoc.save() return new Blob([pdfBytes], { type: 'application/pdf' }) } /** * Takes an ArrayBuffer of a PDF file and converts to Sigit's Internal Pdf File type * @param arrayBuffer * @param fileName */ const convertToPdfFile = async (arrayBuffer: ArrayBuffer, fileName: string): Promise => { const file = toFile(arrayBuffer, fileName); return toPdfFile(file); } /** * @param marks - an array of Marks * @function hasValue removes any Mark without a property * @function scaleMark scales remaining marks in line with SCALE * @function byPage groups remaining Marks by their page marks.location.page */ const groupMarksByPage = (marks: Mark[]) => { return marks .filter(hasValue) .map(scaleMark) .reduce<{[key: number]: Mark[]}>(byPage, {}) } /** * A reducer callback that transforms an array of marks into an object grouped by the page number * Can be replaced by Object.groupBy https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/groupBy * when it is implemented in TypeScript * Implementation is standard from the Array.prototype.reduce documentation * @param obj - accumulator in the reducer callback * @param mark - current value, i.e. Mark being examined */ const byPage = (obj: { [key: number]: Mark[]}, mark: Mark) => { const key = mark.location.page; const curGroup = obj[key] ?? []; return { ...obj, [key]: [...curGroup, mark] } } export { toFile, toPdfFile, toPdfFiles, inPx, convertToPdfFile, addMarks, convertToPdfBlob, groupMarksByPage, }