feat(pdf-marking): implements png to pdf conversion and ability to download full sigits after signing

This commit is contained in:
eugene 2024-07-25 17:51:31 +03:00
parent 4a932ffe03
commit cb9a443fb1
7 changed files with 326 additions and 62 deletions

View File

@ -32,6 +32,7 @@
"lodash": "4.17.21",
"mui-file-input": "4.0.4",
"nostr-tools": "2.7.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.4.168",
"react": "^18.2.0",
"react-dnd": "16.0.1",

View File

@ -1,8 +1,8 @@
import { Box, Button, FormControl, InputLabel, TextField, Typography } from '@mui/material'
import { Box, Button, Typography } from '@mui/material'
import axios from 'axios'
import saveAs from 'file-saver'
import JSZip from 'jszip'
import _, { set } from 'lodash'
import _ from 'lodash'
import { MuiFileInput } from 'mui-file-input'
import { Event, verifyEvent } from 'nostr-tools'
import { useEffect, useState } from 'react'
@ -13,7 +13,7 @@ import { LoadingSpinner } from '../../components/LoadingSpinner'
import { NostrController } from '../../controllers'
import { appPublicRoutes } from '../../routes'
import { State } from '../../store/rootReducer'
import { CreateSignatureEventContent, Meta, SignedEvent, UserRole } from '../../types'
import { CreateSignatureEventContent, Meta, SignedEvent } from '../../types'
import {
decryptArrayBuffer,
encryptArrayBuffer,
@ -34,10 +34,11 @@ import {
import { DisplayMeta } from './internal/displayMeta'
import styles from './style.module.scss'
import { PdfFile } from '../../types/drawing.ts'
import { toFile, toPdfFile } from '../../utils/pdf.ts'
import { convertToPdfFile } from '../../utils/pdf.ts'
import PdfView from '../../components/PDFView'
import { CurrentUserMark, Mark, MarkConfig, MarkConfigDetails, User } from '../../types/mark.ts'
import { CurrentUserMark, Mark, MarkConfig } from '../../types/mark.ts'
import MarkFormField from './MarkFormField.tsx'
import { getLastSignersSig } from '../../utils/sign.ts'
enum SignedStatus {
Fully_Signed,
User_Is_Next_Signer,
@ -98,7 +99,6 @@ export const SignPage = () => {
const [currentUserMarks, setCurrentUserMarks] = useState<CurrentUserMark[]>([]);
const [currentMarkValue, setCurrentMarkValue] = useState<string>('');
const [isMarksCompleted, setIsMarksCompleted] = useState(false);
const [isLastUserMark, setIsLastUserMark] = useState(false);
useEffect(() => {
if (signers.length > 0) {
@ -330,11 +330,6 @@ export const SignPage = () => {
setCurrentFileHashes(fileHashes)
}
const convertToPdfFile = async (arrayBuffer: ArrayBuffer, fileName: string) => {
const file = toFile(arrayBuffer, fileName);
return toPdfFile(file);
}
const parseKeysJson = async (zip: JSZip) => {
const keysFileContent = await readContentOfZipEntry(
zip,
@ -491,8 +486,6 @@ export const SignPage = () => {
}
)
console.log('parsed meta: ', parsedMetaJson)
setMeta(parsedMetaJson)
}
@ -616,7 +609,7 @@ export const SignPage = () => {
encryptionKey: string
): Promise<File | null> => {
// Get the current timestamp in seconds
const unixNow = Math.floor(Date.now() / 1000)
const unixNow = now()
const blob = new Blob([encryptedArrayBuffer])
// Create a File object with the Blob data
const file = new File([blob], `compressed.sigit`, {
@ -763,7 +756,9 @@ export const SignPage = () => {
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
const prevSig = getLastSignersSig()
if (!meta) return;
const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return
const signedEvent = await signEventForMetaFile(
@ -813,7 +808,7 @@ export const SignPage = () => {
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
const unixNow = Math.floor(Date.now() / 1000)
const unixNow = now()
saveAs(blob, `exported-${unixNow}.sigit.zip`)
setIsLoading(false)
@ -878,7 +873,7 @@ export const SignPage = () => {
const finalZipFile = await createFinalZipFile(encryptedArrayBuffer, key)
if (!finalZipFile) return
const unixNow = Math.floor(Date.now() / 1000)
const unixNow = now()
saveAs(finalZipFile, `exported-${unixNow}.sigit.zip`)
}
@ -915,37 +910,6 @@ export const SignPage = () => {
}
}
/**
* This function returns the signature of last signer
* It will be used in the content of export signature's signedEvent
*/
const getLastSignersSig = () => {
if (!meta) return null
// if there're no signers then use creator's signature
if (signers.length === 0) {
try {
const createSignatureEvent: Event = JSON.parse(meta.createSignature)
return createSignatureEvent.sig
} catch (error) {
return null
}
}
// get last signer
const lastSigner = signers[signers.length - 1]
// get the signature of last signer
try {
const lastSignatureEvent: Event = JSON.parse(
meta.docSignatures[lastSigner]
)
return lastSignatureEvent.sig
} catch (error) {
return null
}
}
if (authUrl) {
return (
<iframe

View File

@ -15,7 +15,7 @@ import { useEffect, useState } from 'react'
import { toast } from 'react-toastify'
import { LoadingSpinner } from '../../components/LoadingSpinner'
import { UserComponent } from '../../components/username'
import { MetadataController } from '../../controllers'
import { MetadataController, NostrController } from '../../controllers'
import {
CreateSignatureEventContent,
Meta,
@ -23,19 +23,30 @@ import {
SignedEventContent
} from '../../types'
import {
decryptArrayBuffer,
decryptArrayBuffer, extractMarksFromSignedMeta,
extractZipUrlAndEncryptionKey,
getHash,
hexToNpub,
hexToNpub, now,
npubToHex,
parseJson,
readContentOfZipEntry,
shorten
shorten, signEventForMetaFile
} from '../../utils'
import styles from './style.module.scss'
import { Cancel, CheckCircle } from '@mui/icons-material'
import { useLocation } from 'react-router-dom'
import axios from 'axios'
import { PdfFile } from '../../types/drawing.ts'
import {
addMarks,
convertToPdfBlob,
convertToPdfFile,
groupMarksByPage,
} from '../../utils/pdf.ts'
import { State } from '../../store/rootReducer.ts'
import { useSelector } from 'react-redux'
import { getLastSignersSig } from '../../utils/sign.ts'
import { saveAs } from 'file-saver'
export const VerifyPage = () => {
const theme = useTheme()
@ -66,10 +77,13 @@ export const VerifyPage = () => {
const [currentFileHashes, setCurrentFileHashes] = useState<{
[key: string]: string | null
}>({})
const [files, setFiles] = useState<{ [filename: string]: PdfFile}>({})
const [metadata, setMetadata] = useState<{ [key: string]: ProfileMetadata }>(
{}
)
const usersPubkey = useSelector((state: State) => state.auth.usersPubkey)
const nostrController = NostrController.getInstance()
useEffect(() => {
if (uploadedZip) {
@ -124,6 +138,7 @@ export const VerifyPage = () => {
if (!zip) return
const files: { [filename: string]: PdfFile } = {}
const fileHashes: { [key: string]: string | null } = {}
const fileNames = Object.values(zip.files).map(
(entry) => entry.name
@ -139,6 +154,7 @@ export const VerifyPage = () => {
)
if (arrayBuffer) {
files[fileName] = await convertToPdfFile(arrayBuffer, fileName!)
const hash = await getHash(arrayBuffer)
if (hash) {
@ -150,6 +166,8 @@ export const VerifyPage = () => {
}
setCurrentFileHashes(fileHashes)
setFiles(files)
setSigners(createSignatureContent.signers)
setViewers(createSignatureContent.viewers)
@ -158,6 +176,8 @@ export const VerifyPage = () => {
setMeta(metaInNavState)
setIsLoading(false)
}
})
.catch((err) => {
@ -359,6 +379,77 @@ export const VerifyPage = () => {
}
}
const handleExport = async () => {
// const proverbialUrls = await addMarksV2(Object.values(files)[0].file, meta!)
// await convertToFinalPdf(proverbialUrls)
if (Object.entries(files).length === 0 ||!meta ||!usersPubkey) return;
const usersNpub = hexToNpub(usersPubkey)
if (
!signers.includes(usersNpub) &&
!viewers.includes(usersNpub) &&
submittedBy !== usersNpub
) {
return
}
setIsLoading(true)
setLoadingSpinnerDesc('Signing nostr event')
if (!meta) return;
const prevSig = getLastSignersSig(meta, signers)
if (!prevSig) return;
const signedEvent = await signEventForMetaFile(
JSON.stringify({ prevSig }),
nostrController,
setIsLoading
)
if (!signedEvent) return;
const exportSignature = JSON.stringify(signedEvent, null, 2)
const updatedMeta = {...meta, exportSignature }
const stringifiedMeta = JSON.stringify(updatedMeta, null, 2)
const zip = new JSZip()
zip.file('meta.json', stringifiedMeta)
const marks = extractMarksFromSignedMeta(updatedMeta)
const marksByPage = groupMarksByPage(marks)
for (const [fileName, pdf] of Object.entries(files)) {
const pages = await addMarks(pdf.file, marksByPage)
const blob = await convertToPdfBlob(pages)
zip.file(`/files/${fileName}`, blob)
}
const arrayBuffer = await zip
.generateAsync({
type: 'arraybuffer',
compression: 'DEFLATE',
compressionOptions: {
level: 6
}
})
.catch((err) => {
console.log('err in zip:>> ', err)
setIsLoading(false)
toast.error(err.message || 'Error occurred in generating zip file')
return null
})
if (!arrayBuffer) return
const blob = new Blob([arrayBuffer])
saveAs(blob, `exported-${now()}.sigit.zip`)
setIsLoading(false)
}
const displayUser = (pubkey: string, verifySignature = false) => {
const profile = metadata[pubkey]
@ -521,6 +612,11 @@ export const VerifyPage = () => {
Exported By
</Typography>
{displayExportedBy()}
<Box sx={{ mt: 1, display: 'flex', justifyContent: 'center' }}>
<Button onClick={handleExport} variant="contained">
Export Sigit
</Button>
</Box>
</ListItem>
{signers.length > 0 && (

View File

@ -30,7 +30,7 @@ export interface CreateSignatureEventContent {
export interface SignedEventContent {
prevSig: string
markConfig: Mark[]
marks: Mark[]
}
export interface Sigit {

View File

@ -12,10 +12,11 @@ import { toast } from 'react-toastify'
import { NostrController } from '../controllers'
import { AuthState } from '../store/auth/types'
import store from '../store/store'
import { CreateSignatureEventContent, Meta } from '../types'
import { CreateSignatureEventContent, Meta, SignedEventContent } from '../types'
import { hexToNpub, now } from './nostr'
import { parseJson } from './string'
import { hexToBytes } from '@noble/hashes/utils'
import { Mark } from '../types/mark.ts'
/**
* Uploads a file to a file storage service.
@ -264,3 +265,10 @@ export const extractZipUrlAndEncryptionKey = async (meta: Meta) => {
return null
}
export const extractMarksFromSignedMeta = (meta: Meta): Mark[] => {
return Object.values(meta.docSignatures)
.map((val: string) => JSON.parse(val as string))
.map((val: Event) => JSON.parse(val.content))
.flatMap((val: SignedEventContent) => val.marks);
}

View File

@ -1,33 +1,64 @@
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<PdfFile> => {
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<PdfFile[]> => {
return Promise.all(
selectedFiles
.filter(isPdf)
.map(toPdfFile));
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) => {
const readPdf = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
@ -57,7 +88,7 @@ const pdfToImages = async (data: any): Promise<PdfPage[]> => {
for (let i = 0; i < pdf.numPages; i++) {
const page = await pdf.getPage(i + 1);
const viewport = page.getViewport({ scale: 3 });
const viewport = page.getViewport({ scale: SCALE });
const context = canvas.getContext("2d");
canvas.height = viewport.height;
canvas.width = viewport.width;
@ -73,9 +104,140 @@ const pdfToImages = async (data: any): Promise<PdfPage[]> => {
}))
}
/**
* 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<Blob> => {
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<PdfFile> => {
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
inPx,
convertToPdfFile,
addMarks,
convertToPdfBlob,
groupMarksByPage,
}

33
src/utils/sign.ts Normal file
View File

@ -0,0 +1,33 @@
import { Event } from 'nostr-tools'
import { Meta } from '../types'
/**
* This function returns the signature of last signer
* It will be used in the content of export signature's signedEvent
*/
const getLastSignersSig = (meta: Meta, signers: `npub1${string}`[]): string | null => {
// if there're no signers then use creator's signature
if (signers.length === 0) {
try {
const createSignatureEvent: Event = JSON.parse(meta.createSignature)
return createSignatureEvent.sig
} catch (error) {
return null
}
}
// get last signer
const lastSigner = signers[signers.length - 1]
// get the signature of last signer
try {
const lastSignatureEvent: Event = JSON.parse(
meta.docSignatures[lastSigner]
)
return lastSignatureEvent.sig
} catch (error) {
return null
}
}
export { getLastSignersSig }