diff --git a/package-lock.json b/package-lock.json index 2bcd952..71e0923 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", "@nostr-dev-kit/ndk": "2.5.0", + "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", "axios": "^1.7.4", "crypto-hash": "3.0.0", @@ -1749,6 +1750,15 @@ } } }, + "node_modules/@pdf-lib/fontkit": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz", + "integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, "node_modules/@pdf-lib/standard-fonts": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", diff --git a/package.json b/package.json index aaeba6e..a833103 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@mui/material": "5.15.11", "@noble/hashes": "^1.4.0", "@nostr-dev-kit/ndk": "2.5.0", + "@pdf-lib/fontkit": "^1.1.1", "@reduxjs/toolkit": "2.2.1", "axios": "^1.7.4", "crypto-hash": "3.0.0", diff --git a/src/App.scss b/src/App.scss index 4d95be6..c4fa323 100644 --- a/src/App.scss +++ b/src/App.scss @@ -135,12 +135,18 @@ li { // Consistent styling for every file mark // Reverts some of the design defaults for font .file-mark { - font-family: Arial; - font-size: 16px; + font-family: 'Roboto'; + font-style: normal; font-weight: normal; - color: black; letter-spacing: normal; - border: 1px solid transparent; + line-height: 1; + + font-size: 16px; + color: black; + outline: 1px solid transparent; + + justify-content: start; + align-items: start; scroll-margin-top: $header-height + $body-vertical-padding; } diff --git a/src/assets/fonts/roboto-regular.ttf b/src/assets/fonts/roboto-regular.ttf new file mode 100644 index 0000000..2d116d9 Binary files /dev/null and b/src/assets/fonts/roboto-regular.ttf differ diff --git a/src/components/DrawPDFFields/index.tsx b/src/components/DrawPDFFields/index.tsx index 7efae8b..7c1227d 100644 --- a/src/components/DrawPDFFields/index.tsx +++ b/src/components/DrawPDFFields/index.tsx @@ -14,9 +14,10 @@ import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing' import { truncate } from 'lodash' import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils' import { getSigitFile, SigitFile } from '../../utils/file' +import { getToolboxLabelByMarkType } from '../../utils/mark' import { FileDivider } from '../FileDivider' import { ExtensionFileBox } from '../ExtensionFileBox' -import { inPx } from '../../utils/pdf' +import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf' import { useScale } from '../../hooks/useScale' import { AvatarIconButton } from '../UserAvatarIconButton' import { LoadingSpinner } from '../LoadingSpinner' @@ -390,7 +391,7 @@ export const DrawPDFFields = (props: Props) => { backgroundColor: drawnField.counterpart ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}4b` : undefined, - borderColor: drawnField.counterpart + outlineColor: drawnField.counterpart ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}` : undefined, left: inPx(from(page.width, drawnField.left)), @@ -406,6 +407,16 @@ export const DrawPDFFields = (props: Props) => { : undefined }} > +
+ {getToolboxLabelByMarkType(drawnField.type) || + 'placeholder'} +
handleResizePointerDown(event, drawnFieldIndex) diff --git a/src/components/DrawPDFFields/style.module.scss b/src/components/DrawPDFFields/style.module.scss index 62fa688..13afb9f 100644 --- a/src/components/DrawPDFFields/style.module.scss +++ b/src/components/DrawPDFFields/style.module.scss @@ -13,9 +13,15 @@ } } +.placeholder { + position: absolute; + opacity: 0.5; + inset: 0; +} + .drawingRectangle { position: absolute; - border: 1px solid #01aaad; + outline: 1px solid #01aaad; z-index: 50; background-color: #01aaad4b; cursor: pointer; @@ -29,7 +35,7 @@ } &.edited { - border: 1px dotted #01aaad; + outline: 1px dotted #01aaad; } .resizeHandle { diff --git a/src/components/PDFView/PdfMarkItem.tsx b/src/components/PDFView/PdfMarkItem.tsx index db57800..cb06455 100644 --- a/src/components/PDFView/PdfMarkItem.tsx +++ b/src/components/PDFView/PdfMarkItem.tsx @@ -36,7 +36,7 @@ const PdfMarkItem = forwardRef( backgroundColor: selectedMark?.mark.npub ? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b` : undefined, - borderColor: selectedMark?.mark.npub + outlineColor: selectedMark?.mark.npub ? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}` : undefined, left: inPx(from(pageWidth, location.left)), diff --git a/src/pages/create/index.tsx b/src/pages/create/index.tsx index f4dc550..dd17450 100644 --- a/src/pages/create/index.tsx +++ b/src/pages/create/index.tsx @@ -38,41 +38,24 @@ import { sendNotification, signEventForMetaFile, updateUsersAppData, - uploadToFileStorage + uploadToFileStorage, + DEFAULT_TOOLBOX } from '../../utils' import { Container } from '../../components/Container' import fileListStyles from '../../components/FileList/style.module.scss' -import { DrawTool, MarkType } from '../../types/drawing' +import { DrawTool } from '../../types/drawing' import { DrawPDFFields } from '../../components/DrawPDFFields' import { Mark } from '../../types/mark.ts' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { - fa1, - faBriefcase, - faCalendarDays, - faCheckDouble, - faCircleDot, - faClock, - faCreditCard, faEllipsis, faEye, faFile, faFileCirclePlus, faGripLines, - faHeading, - faIdCard, - faImage, - faPaperclip, faPen, - faPhone, faPlus, - faSignature, - faSquareCaretDown, - faSquareCheck, - faStamp, - faT, - faTableCellsLarge, faToolbox, faTrash, faUpload @@ -125,116 +108,7 @@ export const CreatePage = () => { const [drawnFiles, setDrawnFiles] = useState([]) const [selectedTool, setSelectedTool] = useState() - const [toolbox] = useState([ - { - identifier: MarkType.TEXT, - icon: faT, - label: 'Text', - active: true - }, - { - identifier: MarkType.SIGNATURE, - icon: faSignature, - label: 'Signature', - active: false - }, - { - identifier: MarkType.JOBTITLE, - icon: faBriefcase, - label: 'Job Title', - active: false - }, - { - identifier: MarkType.FULLNAME, - icon: faIdCard, - label: 'Full Name', - active: false - }, - { - identifier: MarkType.INITIALS, - icon: faHeading, - label: 'Initials', - active: false - }, - { - identifier: MarkType.DATETIME, - icon: faClock, - label: 'Date Time', - active: false - }, - { - identifier: MarkType.DATE, - icon: faCalendarDays, - label: 'Date', - active: false - }, - { - identifier: MarkType.NUMBER, - icon: fa1, - label: 'Number', - active: false - }, - { - identifier: MarkType.IMAGES, - icon: faImage, - label: 'Images', - active: false - }, - { - identifier: MarkType.CHECKBOX, - icon: faSquareCheck, - label: 'Checkbox', - active: false - }, - { - identifier: MarkType.MULTIPLE, - icon: faCheckDouble, - label: 'Multiple', - active: false - }, - { - identifier: MarkType.FILE, - icon: faPaperclip, - label: 'File', - active: false - }, - { - identifier: MarkType.RADIO, - icon: faCircleDot, - label: 'Radio', - active: false - }, - { - identifier: MarkType.SELECT, - icon: faSquareCaretDown, - label: 'Select', - active: false - }, - { - identifier: MarkType.CELLS, - icon: faTableCellsLarge, - label: 'Cells', - active: false - }, - { - identifier: MarkType.STAMP, - icon: faStamp, - label: 'Stamp', - active: false - }, - { - identifier: MarkType.PAYMENT, - icon: faCreditCard, - label: 'Payment', - active: false - }, - { - identifier: MarkType.PHONE, - icon: faPhone, - label: 'Phone', - active: false - } - ]) + const [toolbox] = useState(DEFAULT_TOOLBOX) /** * Changes the drawing tool diff --git a/src/pages/verify/index.tsx b/src/pages/verify/index.tsx index af0b4c7..de66991 100644 --- a/src/pages/verify/index.tsx +++ b/src/pages/verify/index.tsx @@ -22,7 +22,6 @@ import { useLocation } from 'react-router-dom' import axios from 'axios' import { addMarks, - convertToPdfBlob, FONT_SIZE, FONT_TYPE, groupMarksByFileNamePage, @@ -399,8 +398,7 @@ export const VerifyPage = () => { for (const [fileName, file] of Object.entries(files)) { if (file.isPdf) { // Draw marks into PDF file and generate a brand new blob - const pages = await addMarks(file, marksByPage[fileName]) - const blob = await convertToPdfBlob(pages) + const blob = await addMarks(file, marksByPage[fileName]) zip.file(`files/${fileName}`, blob) } else { zip.file(`files/${fileName}`, file) diff --git a/src/pages/verify/style.module.scss b/src/pages/verify/style.module.scss index af93107..b63ba60 100644 --- a/src/pages/verify/style.module.scss +++ b/src/pages/verify/style.module.scss @@ -61,6 +61,6 @@ [data-dev='true'] { .mark { - border: 1px dotted black; + outline: 1px dotted black; } } diff --git a/src/utils/file.ts b/src/utils/file.ts index 63d40e5..6156858 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -4,7 +4,6 @@ import { MOST_COMMON_MEDIA_TYPES } from './const.ts' import { extractMarksFromSignedMeta } from './mark.ts' import { addMarks, - convertToPdfBlob, groupMarksByFileNamePage, isPdf, pdfToImages @@ -22,8 +21,7 @@ export const getZipWithFiles = async ( for (const [fileName, file] of Object.entries(files)) { if (file.isPdf) { // Handle PDF Files - const pages = await addMarks(file, marksByFileNamePage[fileName]) - const blob = await convertToPdfBlob(pages) + const blob = await addMarks(file, marksByFileNamePage[fileName]) zip.file(`files/${fileName}`, blob) } else { // Handle other files diff --git a/src/utils/mark.ts b/src/utils/mark.ts index 44540c4..ac80623 100644 --- a/src/utils/mark.ts +++ b/src/utils/mark.ts @@ -3,6 +3,27 @@ import { hexToNpub } from './nostr.ts' import { Meta, SignedEventContent } from '../types' import { Event } from 'nostr-tools' import { EMPTY } from './const.ts' +import { MarkType } from '../types/drawing.ts' +import { + faT, + faSignature, + faBriefcase, + faIdCard, + faHeading, + faClock, + faCalendarDays, + fa1, + faImage, + faSquareCheck, + faCheckDouble, + faPaperclip, + faCircleDot, + faSquareCaretDown, + faTableCellsLarge, + faStamp, + faCreditCard, + faPhone +} from '@fortawesome/free-solid-svg-icons' /** * Takes in an array of Marks already filtered by User. @@ -131,6 +152,121 @@ const findOtherUserMarks = (marks: Mark[], pubkey: string): Mark[] => { return marks.filter((mark) => mark.npub !== hexToNpub(pubkey)) } +export const DEFAULT_TOOLBOX = [ + { + identifier: MarkType.TEXT, + icon: faT, + label: 'Text', + active: true + }, + { + identifier: MarkType.SIGNATURE, + icon: faSignature, + label: 'Signature', + active: false + }, + { + identifier: MarkType.JOBTITLE, + icon: faBriefcase, + label: 'Job Title', + active: false + }, + { + identifier: MarkType.FULLNAME, + icon: faIdCard, + label: 'Full Name', + active: false + }, + { + identifier: MarkType.INITIALS, + icon: faHeading, + label: 'Initials', + active: false + }, + { + identifier: MarkType.DATETIME, + icon: faClock, + label: 'Date Time', + active: false + }, + { + identifier: MarkType.DATE, + icon: faCalendarDays, + label: 'Date', + active: false + }, + { + identifier: MarkType.NUMBER, + icon: fa1, + label: 'Number', + active: false + }, + { + identifier: MarkType.IMAGES, + icon: faImage, + label: 'Images', + active: false + }, + { + identifier: MarkType.CHECKBOX, + icon: faSquareCheck, + label: 'Checkbox', + active: false + }, + { + identifier: MarkType.MULTIPLE, + icon: faCheckDouble, + label: 'Multiple', + active: false + }, + { + identifier: MarkType.FILE, + icon: faPaperclip, + label: 'File', + active: false + }, + { + identifier: MarkType.RADIO, + icon: faCircleDot, + label: 'Radio', + active: false + }, + { + identifier: MarkType.SELECT, + icon: faSquareCaretDown, + label: 'Select', + active: false + }, + { + identifier: MarkType.CELLS, + icon: faTableCellsLarge, + label: 'Cells', + active: false + }, + { + identifier: MarkType.STAMP, + icon: faStamp, + label: 'Stamp', + active: false + }, + { + identifier: MarkType.PAYMENT, + icon: faCreditCard, + label: 'Payment', + active: false + }, + { + identifier: MarkType.PHONE, + icon: faPhone, + label: 'Phone', + active: false + } +] + +export const getToolboxLabelByMarkType = (markType: MarkType) => { + return DEFAULT_TOOLBOX.find((t) => t.identifier === markType)?.label +} + export { getCurrentUserMarks, filterMarksByPubkey, diff --git a/src/utils/pdf.ts b/src/utils/pdf.ts index 763d582..f4d4700 100644 --- a/src/utils/pdf.ts +++ b/src/utils/pdf.ts @@ -1,7 +1,6 @@ import { PdfPage } from '../types/drawing.ts' -import { PDFDocument } from 'pdf-lib' +import { PDFDocument, PDFFont, PDFPage, rgb } from 'pdf-lib' import { Mark } from '../types/mark.ts' - import * as PDFJS from 'pdfjs-dist' import PDFJSWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker' if (!PDFJS.GlobalWorkerOptions.workerPort) { @@ -10,18 +9,23 @@ if (!PDFJS.GlobalWorkerOptions.workerPort) { PDFJS.GlobalWorkerOptions.workerPort = worker } +import fontkit from '@pdf-lib/fontkit' +import defaultFont from '../assets/fonts/roboto-regular.ttf' + /** * Defined font size used when generating a PDF. Currently it is difficult to fully * correlate font size used at the time of filling in / drawing on the PDF * because it is dynamically rendered, and the final size. - * This should be fixed going forward. - * Switching to PDF-Lib will most likely make this problem redundant. */ export const FONT_SIZE: number = 16 /** * Current font type used when generating a PDF. */ -export const FONT_TYPE: string = 'Arial' +export const FONT_TYPE: string = 'Roboto' +/** + * Current line height used when generating a PDF. + */ +export const FONT_LINE_HEIGHT: number = 1 /** * A utility that transforms a drawing coordinate number into a CSS-compatible pixel string @@ -90,14 +94,11 @@ export const pdfToImages = async ( const pages: PdfPage[] = [] const pdf = await PDFJS.getDocument(data).promise const canvas = document.createElement('canvas') - const width = getInnerContentWidth() for (let i = 0; i < pdf.numPages; i++) { const page = await pdf.getPage(i + 1) - const originalViewport = page.getViewport({ scale: 1 }) - const scale = width / originalViewport.width - const viewport = page.getViewport({ scale: scale }) + const viewport = page.getViewport({ scale: 1 }) const context = canvas.getContext('2d') canvas.height = viewport.height canvas.width = viewport.width @@ -105,7 +106,7 @@ export const pdfToImages = async ( await page.render({ canvasContext: context!, viewport: viewport }).promise pages.push({ image: canvas.toDataURL(), - width: originalViewport.width, + width: viewport.width, drawnFields: [] }) } @@ -115,56 +116,28 @@ export const pdfToImages = async ( /** * 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 + * Returns a PDF blob with embedded, completed and signed marks from all users as text */ export 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 pdf = await PDFDocument.load(p) + const robotoFont = await embedFont(pdf) + const pages = pdf.getPages() - const images: string[] = [] - - for (let i = 0; i < pdf.numPages; i++) { - const page = await pdf.getPage(i + 1) - const viewport = page.getViewport({ scale: 1 }) - const context = canvas.getContext('2d') - canvas.height = viewport.height - canvas.width = viewport.width - if (context) { - await page.render({ canvasContext: context, viewport: viewport }).promise - - if (marksPerPage && Object.hasOwn(marksPerPage, i)) { - marksPerPage[i]?.forEach((mark) => draw(mark, context)) - } - - images.push(canvas.toDataURL()) + for (let i = 0; i < pages.length; i++) { + if (marksPerPage && Object.hasOwn(marksPerPage, i)) { + marksPerPage[i]?.forEach((mark) => + drawMarkText(mark, pages[i], robotoFont) + ) } } - canvas.remove() + const blob = await pdf.save() - return images -} - -/** - * Utility to scale mark in line with the PDF-to-PNG scale - */ -export const scaleMark = (mark: Mark, scale: number): Mark => { - const { location } = mark - return { - ...mark, - location: { - ...location, - width: location.width * scale, - height: location.height * scale, - left: location.left * scale, - top: location.top * scale - } - } + return blob } /** @@ -177,6 +150,7 @@ export 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 + * @deprecated use drawMarkText */ export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => { const { location } = mark @@ -191,27 +165,37 @@ export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => { } /** - * Takes an array of encoded PDF pages and returns a blob that is a complete PDF file - * @param markedPdfPages + * Draws a Mark on a PDF Page + * @param mark to be drawn + * @param page PDF Page + * @param font embedded font */ -export const convertToPdfBlob = async ( - markedPdfPages: string[] -): Promise => { - const pdfDoc = await PDFDocument.create() +export const drawMarkText = (mark: Mark, page: PDFPage, font: PDFFont) => { + const { location } = mark + const { height } = page.getSize() - 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 - }) - } + // Convert the mark location origin (top, left) to PDF origin (bottom, left) + const x = location.left + const y = height - location.top - const pdfBytes = await pdfDoc.save() - return new Blob([pdfBytes], { type: 'application/pdf' }) + // Adjust y-coordinate for the text, drawText's y is the baseline for the font + // We start from the y (top location border) and we need to bump it down + // We move font baseline by the difference between rendered height and actual height (gap) + // And finally move down by the height without descender to get the new baseline + const adjustedY = + y - + (font.heightAtSize(FONT_SIZE) - FONT_SIZE) - + font.heightAtSize(FONT_SIZE, { descender: false }) + + page.drawText(`${mark.value}`, { + x, + y: adjustedY, + size: FONT_SIZE, + font: font, + color: rgb(0, 0, 0), + maxWidth: location.width, + lineHeight: FONT_SIZE * FONT_LINE_HEIGHT + }) } /** @@ -249,3 +233,12 @@ export const byPage = ( } } } + +async function embedFont(pdf: PDFDocument) { + const fontBytes = await fetch(defaultFont).then((res) => res.arrayBuffer()) + + pdf.registerFontkit(fontkit) + + const embeddedFont = await pdf.embedFont(fontBytes) + return embeddedFont +}