diff --git a/app/lib/pdf/barcodeDecoder.ts b/app/lib/pdf/barcodeDecoder.ts index 9eeae79..a8c17c3 100644 --- a/app/lib/pdf/barcodeDecoder.ts +++ b/app/lib/pdf/barcodeDecoder.ts @@ -1,5 +1,5 @@ import { PDFPageProxy } from 'pdfjs-dist'; -import { BrowserPDF417Reader, BrowserMultiFormatReader } from '@zxing/browser'; +import { BrowserPDF417Reader } from '@zxing/browser'; import * as pdfJSx from 'pdfjs-dist'; import { BarcodeFormat, DecodeHintType, Result } from '@zxing/library'; @@ -21,6 +21,12 @@ export type BillInfo = { description: string, }; +/** Breaks current microtask execution and gives the UI thread a chance to do a re-paint */ +const yieldToBrowser = (label:string) => new Promise((resolve) => { + setTimeout(() => { + resolve(true); + }, 0); +}); /** * Decodes a PDF417 barcode @@ -46,7 +52,7 @@ export type BillInfo = { * description:Akontacijska rata za 01.2024. * */ -const parseHubText = (text: string) => { +const parseHubText = (text: string):BillInfo => { const [ header, currency, @@ -129,7 +135,7 @@ const image2canvas = async function (imageFile:File): Promise * @param {File} pdfFile - a file containing a PDF document * @return {Promise} the canvas with the first page of the PDF */ -const pdf2canvas = async function (pdfFile:File): Promise { +const pdf2canvas = async function (pdfFile:File): Promise> { const reader = new FileReader(); const data = await new Promise((resolve, reject) => { @@ -144,38 +150,127 @@ const pdf2canvas = async function (pdfFile:File): Promise { pdfJS.GlobalWorkerOptions.workerSrc = window.location.origin + '/pdf.worker.min.mjs'; const pdf = await pdfJS.getDocument(data).promise; - - const page: PDFPageProxy = await pdf.getPage(1); - const scale = 4; - const viewport = page.getViewport({ scale }); + const canvases: Array = []; - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - canvas.height = viewport.height; - canvas.width = viewport.width; + for(let i = 0; i< pdf.numPages; i++) { + const page: PDFPageProxy = await pdf.getPage(i+1); - await page.render({ canvasContext: context as CanvasRenderingContext2D, viewport }).promise; + const scale = 4; + const viewport = page.getViewport({ scale }); - return(canvas); + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; + + await page.render({ canvasContext: context as CanvasRenderingContext2D, viewport }).promise; + + canvases.push(canvas); + } + + return(canvases); } +export type DecodeResult = { + billInfo: BillInfo, + barcodeImage: string, +}; + /** - * Decodes PDF417 from image contained within the given canvas + * Searches the given canvas for all PDF417 codes and decodes them. + * @param {HTMLCanvasElement} canvas - the canvas to search for PDF417 codes + * @return {Promise | null>} - an array of decoded results * */ -const decodeFromCanvas = async (canvas:HTMLCanvasElement) => { +const decodeFromCanvas = async (canvas:HTMLCanvasElement): Promise | null> => { try { const hints = new Map(); hints.set(DecodeHintType.POSSIBLE_FORMATS, [ BarcodeFormat.PDF_417 ]); hints.set(DecodeHintType.PURE_BARCODE, false); const codeReader = new BrowserPDF417Reader(hints); - const result = await codeReader.decodeFromCanvas(canvas); - - return({ - billInfo: parseHubText(result.getText()), - barcodeImage: copyBarcodeImage(canvas, result) - }) + + const width = canvas.width; + const height = canvas.height; + + // Canvas can contain multiple PDF417 codes, so we need to try to find them all + // The issue is that `BrowserPDF417Reader` can only decode one code at a time + // and it will throw an error if it finds more than one code. + // To solve this, we will try splitting the canvas into different number of subsections + // and decode each subsection separately. The best result will be the one with the most codes found. + const splits = [5,4,3,2,1,0]; + + let bestResult: Array|null = null; + + for(let splitIx = 0; splitIx < splits.length; splitIx++) { + const split = splits[splitIx]; + + const overlap = split === 0 ? 0 : Math.round(height / 50); // 50% overlap ensuring that we don't miss any codes that might be split between sections + const sectionHeight = split === 0 ? height : (Math.floor( Math.floor(height / split) + overlap)); + + + const canvasSections = Array.from({ length: split+1 }, (_, i) => { + + const sectionCanvas = document.createElement('canvas'); + sectionCanvas.width = width; + sectionCanvas.height = sectionHeight; + const sectionContext = sectionCanvas.getContext('2d'); + + if (!sectionContext) { + throw new Error('Failed to get canvas context'); + } + + // Calculate the starting Y position for each section + const startY = i===0 ? 0 : i * (sectionHeight) - overlap; + + // Draw the section of the original canvas onto the new section canvas + sectionContext.drawImage(canvas, 0, startY, width, sectionHeight, 0, 0, width, sectionHeight); + return sectionCanvas; + }); + + + const codesFoundInSection: Array = []; + + // Try to decode each section + for (const sectionCanvas of canvasSections) { + try { + // give browser a chance to re-paint + // this is needed to avoid UI freezing when decoding large images + await yieldToBrowser('decodeFromCanvas'); + + + const result = await codeReader.decodeFromCanvas(sectionCanvas); + + if (result) { + codesFoundInSection.push({ + billInfo: parseHubText(result.getText()), + barcodeImage: copyBarcodeImage(sectionCanvas, result) + }); + } + + } catch (error) { + // If no code was found in the current section an error will be thrown + // -> we can ignore it + + } finally { + } + } + + await yieldToBrowser('after decodeFromCanvas'); + + // IF in this iteration we found less codes than in the previous best result, + // we can stop searching for more codes + // This is because the number of codes found in each section will only decrease + // as we increase the number of sections (split) + if(bestResult && codesFoundInSection.length <= bestResult.length) { + return(bestResult); + } + + bestResult = codesFoundInSection; + }; + + return(bestResult); + } catch(ex:any) { console.log(ex); return(null); @@ -186,7 +281,7 @@ const decodeFromCanvas = async (canvas:HTMLCanvasElement) => { * Copies bar code from the given canvas * */ -const copyBarcodeImage = (canvas:HTMLCanvasElement, decoderResult:Result) => { +const copyBarcodeImage = (canvas:HTMLCanvasElement, decoderResult:Result):string => { // get coordinates of bar code const points = decoderResult.getResultPoints(); @@ -241,7 +336,20 @@ const decodeFromFile = async (file:File) => { case 'image/jpeg': return(await decodeFromCanvas( await image2canvas(file) )); case 'application/pdf': - return(await decodeFromCanvas( await pdf2canvas(file) )); + const pageCanvas = await pdf2canvas(file); + // go through each page of the PDF and decode the PDF417 codes + // if there are multiple pages, we will decode each page separately + // and return the results from all pages + const results = (await Promise.all(pageCanvas.map(async (canvas) => { + await yieldToBrowser('decodeFromCanvas'); + return await decodeFromCanvas(canvas); + }))) + // remove null results (pages with no PDF417 codes) + .filter((result) => result !== null) + // flatten the array of arrays into a single array + .flat(); + + return(results); default: console.error(file.name, 'is not a .pdf file.'); return null; @@ -253,13 +361,13 @@ const decodeFromFile = async (file:File) => { * @param {Event} event - The change event from an HTMLInputElement. * @return {Promise} The canvas with the first page of the PDF, or null if the document is not a PDF. */ -export async function findDecodePdf417(event: React.ChangeEvent): Promise<{ billInfo: BillInfo, barcodeImage:string } | null> { +export async function findDecodePdf417(event: React.ChangeEvent): Promise|null> { const file = (event.target as HTMLInputElement).files?.[0]; if(!file) { console.error('No file was selected.'); return null; } - + return(await decodeFromFile(file)); } \ No newline at end of file diff --git a/app/ui/BillEditForm.tsx b/app/ui/BillEditForm.tsx index 03f1d10..9d07238 100644 --- a/app/ui/BillEditForm.tsx +++ b/app/ui/BillEditForm.tsx @@ -7,7 +7,7 @@ import { useFormState } from "react-dom"; import { updateOrAddBill } from "../lib/actions/billActions"; import Link from "next/link"; import { formatYearMonth } from "../lib/format"; -import { findDecodePdf417 } from "../lib/pdf/barcodeDecoder"; +import { DecodeResult, findDecodePdf417 } from "../lib/pdf/barcodeDecoder"; import { useLocale, useTranslations } from "next-intl"; // Next.js does not encode an utf-8 file name correctly when sending a form with a file attachment @@ -37,10 +37,12 @@ export const BillEditForm:FC = ({ location, bill }) => { const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear, billMonth); + const [ isScanningPDF, setIsScanningPDF ] = React.useState(false); const [ state, dispatch ] = useFormState(handleAction, initialState); const [ isPaid, setIsPaid ] = React.useState(paid); const [ payedAmount, setPayedAmount ] = React.useState(initialPayedAmount ? `${initialPayedAmount/100}` : "" ); const [ barcodeImage, setBarcodeImage ] = React.useState(initialBarcodeImage); + const [ barcodeResults, setBarcodeResults ] = React.useState | null>(null); const billPaid_handleChange = (event: React.ChangeEvent) => { setIsPaid(event.target.checked); @@ -50,19 +52,39 @@ export const BillEditForm:FC = ({ location, bill }) => { setPayedAmount(event.target.value); } - const billAttachment_handleChange = (event: React.ChangeEvent) => { - findDecodePdf417(event) - .then(result => { - if(result) { - const { - barcodeImage, - billInfo - } = result; + const billAttachment_handleChange = async (event: React.ChangeEvent) => { + setIsScanningPDF(true); - setPayedAmount(`${billInfo.amount/100}`); - setBarcodeImage(barcodeImage); - } - }); + setPayedAmount(""); + setBarcodeImage(undefined); + setBarcodeResults(null); + + const results = await findDecodePdf417(event); + if(results && results.length > 0) { + + if(results.length === 1) { + const { + barcodeImage, + billInfo + } = results[0]; + + setPayedAmount(`${billInfo.amount/100}`); + setBarcodeImage(barcodeImage); + } else { + setPayedAmount(""); + setBarcodeImage(undefined); + setBarcodeResults(results); + } + + } + + setIsScanningPDF(false); + } + + const handleBarcodeSelectClick = (result: DecodeResult) => { + setPayedAmount(`${result.billInfo.amount/100}`); + setBarcodeImage(result.barcodeImage); + setBarcodeResults(null); } return( @@ -98,8 +120,36 @@ export const BillEditForm:FC = ({ location, bill }) => { : null }
- +
+ { + isScanningPDF && +
+
+ {t("scanning-pdf")} +
+ } + { + // if multiple results are found, show them as a list + // and notify the user to select the correct one + barcodeResults && barcodeResults.length > 0 && + <> +
+ +
+
+ +
+ + }
{state.errors?.billAttachment && state.errors.billAttachment.map((error: string) => ( @@ -141,7 +191,6 @@ export const BillEditForm:FC = ({ location, bill }) => {

{t.rich('barcode-disclaimer', { br: () =>
})}

: null } -
{state.errors?.billNotes && diff --git a/messages/en.json b/messages/en.json index 5a5980d..98edd7c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -69,6 +69,8 @@ "bill-edit-form": { "bill-name-placeholder": "Bill name", "paid-checkbox": "Paid", + "scanning-pdf": "🕵️‍♂️ Scanning document for 2D codes ...", + "multiple-barcode-results-notification": "✅ Multiple 2D barcodes found. Pick the one which you want to use:", "payed-amount": "Amount", "barcode-disclaimer": "After scanning the code make sure the information is correct.

We are not liable in case of an incorrect payment.", "notes-placeholder": "Notes", diff --git a/messages/hr.json b/messages/hr.json index e99d658..cdc916b 100644 --- a/messages/hr.json +++ b/messages/hr.json @@ -69,6 +69,8 @@ "bill-edit-form": { "bill-name-placeholder": "Ime računa", "paid-checkbox": "Plaćeno", + "scanning-pdf": "🕵️‍♂️ Tražim 2D barkodove unutar dokumenta...", + "multiple-barcode-results-notification": "✅ Pronađeno je više 2D barkodova. Molimo odaberi onaj koji se odnosi na zadani mjesec:", "payed-amount": "Iznos", "barcode-disclaimer": "Nakon skeniranja bar koda obavezni provjeri jesu li svi podaci ispravni.

Ne snosimo odgovornost za slučaj pogrešno provedene uplate.", "notes-placeholder": "Bilješke",