fix(pdf): pdf quality and multiline #204

Merged
enes merged 2 commits from 176-178-pdf-quality-multiline into staging 2024-09-17 13:59:17 +00:00
13 changed files with 244 additions and 208 deletions

10
package-lock.json generated
View File

@ -21,6 +21,7 @@
"@mui/material": "5.15.11", "@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0", "@nostr-dev-kit/ndk": "2.5.0",
"@pdf-lib/fontkit": "^1.1.1",
"@reduxjs/toolkit": "2.2.1", "@reduxjs/toolkit": "2.2.1",
"axios": "^1.7.4", "axios": "^1.7.4",
"crypto-hash": "3.0.0", "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": { "node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",

View File

@ -31,6 +31,7 @@
"@mui/material": "5.15.11", "@mui/material": "5.15.11",
"@noble/hashes": "^1.4.0", "@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "2.5.0", "@nostr-dev-kit/ndk": "2.5.0",
"@pdf-lib/fontkit": "^1.1.1",
"@reduxjs/toolkit": "2.2.1", "@reduxjs/toolkit": "2.2.1",
"axios": "^1.7.4", "axios": "^1.7.4",
"crypto-hash": "3.0.0", "crypto-hash": "3.0.0",

View File

@ -135,12 +135,18 @@ li {
// Consistent styling for every file mark // Consistent styling for every file mark
// Reverts some of the design defaults for font // Reverts some of the design defaults for font
.file-mark { .file-mark {
font-family: Arial; font-family: 'Roboto';
font-size: 16px; font-style: normal;
font-weight: normal; font-weight: normal;
color: black;
letter-spacing: normal; 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; scroll-margin-top: $header-height + $body-vertical-padding;
} }

Binary file not shown.

View File

@ -14,9 +14,10 @@ import { MouseState, PdfPage, DrawnField, DrawTool } from '../../types/drawing'
import { truncate } from 'lodash' import { truncate } from 'lodash'
import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils' import { settleAllFullfilfedPromises, hexToNpub, npubToHex } from '../../utils'
import { getSigitFile, SigitFile } from '../../utils/file' import { getSigitFile, SigitFile } from '../../utils/file'
import { getToolboxLabelByMarkType } from '../../utils/mark'
import { FileDivider } from '../FileDivider' import { FileDivider } from '../FileDivider'
import { ExtensionFileBox } from '../ExtensionFileBox' import { ExtensionFileBox } from '../ExtensionFileBox'
import { inPx } from '../../utils/pdf' import { FONT_SIZE, FONT_TYPE, inPx } from '../../utils/pdf'
import { useScale } from '../../hooks/useScale' import { useScale } from '../../hooks/useScale'
import { AvatarIconButton } from '../UserAvatarIconButton' import { AvatarIconButton } from '../UserAvatarIconButton'
import { LoadingSpinner } from '../LoadingSpinner' import { LoadingSpinner } from '../LoadingSpinner'
@ -390,7 +391,7 @@ export const DrawPDFFields = (props: Props) => {
backgroundColor: drawnField.counterpart backgroundColor: drawnField.counterpart
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}4b` ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}4b`
: undefined, : undefined,
borderColor: drawnField.counterpart outlineColor: drawnField.counterpart
? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}` ? `#${npubToHex(drawnField.counterpart)?.substring(0, 6)}`
: undefined, : undefined,
left: inPx(from(page.width, drawnField.left)), left: inPx(from(page.width, drawnField.left)),
@ -406,6 +407,16 @@ export const DrawPDFFields = (props: Props) => {
: undefined : undefined
}} }}
> >
<div
className={`file-mark ${styles.placeholder}`}
style={{
fontFamily: FONT_TYPE,
fontSize: inPx(from(page.width, FONT_SIZE))
}}
>
{getToolboxLabelByMarkType(drawnField.type) ||
'placeholder'}
</div>
<span <span
onPointerDown={(event) => onPointerDown={(event) =>
handleResizePointerDown(event, drawnFieldIndex) handleResizePointerDown(event, drawnFieldIndex)

View File

@ -13,9 +13,15 @@
} }
} }
.placeholder {
position: absolute;
opacity: 0.5;
inset: 0;
}
.drawingRectangle { .drawingRectangle {
position: absolute; position: absolute;
border: 1px solid #01aaad; outline: 1px solid #01aaad;
z-index: 50; z-index: 50;
background-color: #01aaad4b; background-color: #01aaad4b;
cursor: pointer; cursor: pointer;
@ -29,7 +35,7 @@
} }
&.edited { &.edited {
border: 1px dotted #01aaad; outline: 1px dotted #01aaad;
} }
.resizeHandle { .resizeHandle {

View File

@ -36,7 +36,7 @@ const PdfMarkItem = forwardRef<HTMLDivElement, PdfMarkItemProps>(
backgroundColor: selectedMark?.mark.npub backgroundColor: selectedMark?.mark.npub
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b` ? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}4b`
: undefined, : undefined,
borderColor: selectedMark?.mark.npub outlineColor: selectedMark?.mark.npub
? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}` ? `#${npubToHex(selectedMark?.mark.npub)?.substring(0, 6)}`
: undefined, : undefined,
left: inPx(from(pageWidth, location.left)), left: inPx(from(pageWidth, location.left)),

View File

@ -38,41 +38,24 @@ import {
sendNotification, sendNotification,
signEventForMetaFile, signEventForMetaFile,
updateUsersAppData, updateUsersAppData,
uploadToFileStorage uploadToFileStorage,
DEFAULT_TOOLBOX
} from '../../utils' } from '../../utils'
import { Container } from '../../components/Container' import { Container } from '../../components/Container'
import fileListStyles from '../../components/FileList/style.module.scss' 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 { DrawPDFFields } from '../../components/DrawPDFFields'
import { Mark } from '../../types/mark.ts' import { Mark } from '../../types/mark.ts'
import { StickySideColumns } from '../../layouts/StickySideColumns.tsx' import { StickySideColumns } from '../../layouts/StickySideColumns.tsx'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { import {
fa1,
faBriefcase,
faCalendarDays,
faCheckDouble,
faCircleDot,
faClock,
faCreditCard,
faEllipsis, faEllipsis,
faEye, faEye,
faFile, faFile,
faFileCirclePlus, faFileCirclePlus,
faGripLines, faGripLines,
faHeading,
faIdCard,
faImage,
faPaperclip,
faPen, faPen,
faPhone,
faPlus, faPlus,
faSignature,
faSquareCaretDown,
faSquareCheck,
faStamp,
faT,
faTableCellsLarge,
faToolbox, faToolbox,
faTrash, faTrash,
faUpload faUpload
@ -125,116 +108,7 @@ export const CreatePage = () => {
const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([]) const [drawnFiles, setDrawnFiles] = useState<SigitFile[]>([])
const [selectedTool, setSelectedTool] = useState<DrawTool>() const [selectedTool, setSelectedTool] = useState<DrawTool>()
const [toolbox] = useState<DrawTool[]>([ const [toolbox] = useState<DrawTool[]>(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
}
])
/** /**
* Changes the drawing tool * Changes the drawing tool

View File

@ -22,7 +22,6 @@ import { useLocation } from 'react-router-dom'
import axios from 'axios' import axios from 'axios'
import { import {
addMarks, addMarks,
convertToPdfBlob,
FONT_SIZE, FONT_SIZE,
FONT_TYPE, FONT_TYPE,
groupMarksByFileNamePage, groupMarksByFileNamePage,
@ -399,8 +398,7 @@ export const VerifyPage = () => {
for (const [fileName, file] of Object.entries(files)) { for (const [fileName, file] of Object.entries(files)) {
if (file.isPdf) { if (file.isPdf) {
// Draw marks into PDF file and generate a brand new blob // Draw marks into PDF file and generate a brand new blob
const pages = await addMarks(file, marksByPage[fileName]) const blob = await addMarks(file, marksByPage[fileName])
const blob = await convertToPdfBlob(pages)
zip.file(`files/${fileName}`, blob) zip.file(`files/${fileName}`, blob)
} else { } else {
zip.file(`files/${fileName}`, file) zip.file(`files/${fileName}`, file)

View File

@ -61,6 +61,6 @@
[data-dev='true'] { [data-dev='true'] {
.mark { .mark {
border: 1px dotted black; outline: 1px dotted black;
} }
} }

View File

@ -4,7 +4,6 @@ import { MOST_COMMON_MEDIA_TYPES } from './const.ts'
import { extractMarksFromSignedMeta } from './mark.ts' import { extractMarksFromSignedMeta } from './mark.ts'
import { import {
addMarks, addMarks,
convertToPdfBlob,
groupMarksByFileNamePage, groupMarksByFileNamePage,
isPdf, isPdf,
pdfToImages pdfToImages
@ -22,8 +21,7 @@ export const getZipWithFiles = async (
for (const [fileName, file] of Object.entries(files)) { for (const [fileName, file] of Object.entries(files)) {
if (file.isPdf) { if (file.isPdf) {
// Handle PDF Files // Handle PDF Files
const pages = await addMarks(file, marksByFileNamePage[fileName]) const blob = await addMarks(file, marksByFileNamePage[fileName])
const blob = await convertToPdfBlob(pages)
zip.file(`files/${fileName}`, blob) zip.file(`files/${fileName}`, blob)
} else { } else {
// Handle other files // Handle other files

View File

@ -3,6 +3,27 @@ import { hexToNpub } from './nostr.ts'
import { Meta, SignedEventContent } from '../types' import { Meta, SignedEventContent } from '../types'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { EMPTY } from './const.ts' 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. * 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)) 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 { export {
getCurrentUserMarks, getCurrentUserMarks,
filterMarksByPubkey, filterMarksByPubkey,

View File

@ -1,7 +1,6 @@
import { PdfPage } from '../types/drawing.ts' 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 { Mark } from '../types/mark.ts'
import * as PDFJS from 'pdfjs-dist' import * as PDFJS from 'pdfjs-dist'
import PDFJSWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker' import PDFJSWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?worker'
if (!PDFJS.GlobalWorkerOptions.workerPort) { if (!PDFJS.GlobalWorkerOptions.workerPort) {
@ -10,18 +9,23 @@ if (!PDFJS.GlobalWorkerOptions.workerPort) {
PDFJS.GlobalWorkerOptions.workerPort = worker 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 * 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 * correlate font size used at the time of filling in / drawing on the PDF
* because it is dynamically rendered, and the final size. * 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 export const FONT_SIZE: number = 16
/** /**
* Current font type used when generating a PDF. * 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 * A utility that transforms a drawing coordinate number into a CSS-compatible pixel string
@ -115,56 +119,28 @@ export const pdfToImages = async (
/** /**
* Takes in individual pdf file and an object with Marks grouped by Page number * 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 * Returns a PDF blob with embedded, completed and signed marks from all users as text
* of a PDF page with completed and signed marks from all users
*/ */
export const addMarks = async ( export const addMarks = async (
file: File, file: File,
marksPerPage: { [key: string]: Mark[] } marksPerPage: { [key: string]: Mark[] }
) => { ) => {
const p = await readPdf(file) const p = await readPdf(file)
const pdf = await PDFJS.getDocument(p).promise const pdf = await PDFDocument.load(p)
const canvas = document.createElement('canvas') const robotoFont = await embedFont(pdf)
const pages = pdf.getPages()
const images: string[] = [] for (let i = 0; i < pages.length; i++) {
if (marksPerPage && Object.hasOwn(marksPerPage, i)) {
for (let i = 0; i < pdf.numPages; i++) { marksPerPage[i]?.forEach((mark) =>
const page = await pdf.getPage(i + 1) drawMarkText(mark, pages[i], robotoFont)
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())
} }
} }
canvas.remove() const blob = await pdf.save()
return images return blob
}
/**
* 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
}
}
} }
/** /**
@ -177,6 +153,7 @@ export const hasValue = (mark: Mark): boolean => !!mark.value
* Draws a Mark on a Canvas representation of a PDF Page * Draws a Mark on a Canvas representation of a PDF Page
* @param mark to be drawn * @param mark to be drawn
* @param ctx a Canvas representation of a specific PDF Page * @param ctx a Canvas representation of a specific PDF Page
* @deprecated use drawMarkText
*/ */
export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => { export const draw = (mark: Mark, ctx: CanvasRenderingContext2D) => {
const { location } = mark const { location } = mark
@ -191,27 +168,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 * Draws a Mark on a PDF Page
* @param markedPdfPages * @param mark to be drawn
* @param page PDF Page
* @param font embedded font
*/ */
export const convertToPdfBlob = async ( export const drawMarkText = (mark: Mark, page: PDFPage, font: PDFFont) => {
markedPdfPages: string[] const { location } = mark
): Promise<Blob> => { const { height } = page.getSize()
const pdfDoc = await PDFDocument.create()
for (const page of markedPdfPages) { // Convert the mark location origin (top, left) to PDF origin (bottom, left)
const pngImage = await pdfDoc.embedPng(page) const x = location.left
const p = pdfDoc.addPage([pngImage.width, pngImage.height]) const y = height - location.top
p.drawImage(pngImage, {
x: 0,
y: 0,
width: pngImage.width,
height: pngImage.height
})
}
const pdfBytes = await pdfDoc.save() // Adjust y-coordinate for the text, drawText's y is the baseline for the font
return new Blob([pdfBytes], { type: 'application/pdf' }) // 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 +236,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
}