implemented scanning PDF docs for multiple 2D codes
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { PDFPageProxy } from 'pdfjs-dist';
|
import { PDFPageProxy } from 'pdfjs-dist';
|
||||||
import { BrowserPDF417Reader, BrowserMultiFormatReader } from '@zxing/browser';
|
import { BrowserPDF417Reader } from '@zxing/browser';
|
||||||
|
|
||||||
import * as pdfJSx from 'pdfjs-dist';
|
import * as pdfJSx from 'pdfjs-dist';
|
||||||
import { BarcodeFormat, DecodeHintType, Result } from '@zxing/library';
|
import { BarcodeFormat, DecodeHintType, Result } from '@zxing/library';
|
||||||
@@ -21,6 +21,12 @@ export type BillInfo = {
|
|||||||
description: string,
|
description: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Breaks current microtask execution and gives the UI thread a chance to do a re-paint */
|
||||||
|
const yieldToBrowser = (label:string) => new Promise<boolean>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(true);
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes a PDF417 barcode
|
* Decodes a PDF417 barcode
|
||||||
@@ -46,7 +52,7 @@ export type BillInfo = {
|
|||||||
* description:Akontacijska rata za 01.2024.
|
* description:Akontacijska rata za 01.2024.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const parseHubText = (text: string) => {
|
const parseHubText = (text: string):BillInfo => {
|
||||||
const [
|
const [
|
||||||
header,
|
header,
|
||||||
currency,
|
currency,
|
||||||
@@ -129,7 +135,7 @@ const image2canvas = async function (imageFile:File): Promise<HTMLCanvasElement>
|
|||||||
* @param {File} pdfFile - a file containing a PDF document
|
* @param {File} pdfFile - a file containing a PDF document
|
||||||
* @return {Promise<HTMLCanvasElement>} the canvas with the first page of the PDF
|
* @return {Promise<HTMLCanvasElement>} the canvas with the first page of the PDF
|
||||||
*/
|
*/
|
||||||
const pdf2canvas = async function (pdfFile:File): Promise<HTMLCanvasElement> {
|
const pdf2canvas = async function (pdfFile:File): Promise<Array<HTMLCanvasElement>> {
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
const data = await new Promise<Uint8Array>((resolve, reject) => {
|
const data = await new Promise<Uint8Array>((resolve, reject) => {
|
||||||
@@ -145,7 +151,10 @@ const pdf2canvas = async function (pdfFile:File): Promise<HTMLCanvasElement> {
|
|||||||
|
|
||||||
const pdf = await pdfJS.getDocument(data).promise;
|
const pdf = await pdfJS.getDocument(data).promise;
|
||||||
|
|
||||||
const page: PDFPageProxy = await pdf.getPage(1);
|
const canvases: Array<HTMLCanvasElement> = [];
|
||||||
|
|
||||||
|
for(let i = 0; i< pdf.numPages; i++) {
|
||||||
|
const page: PDFPageProxy = await pdf.getPage(i+1);
|
||||||
|
|
||||||
const scale = 4;
|
const scale = 4;
|
||||||
const viewport = page.getViewport({ scale });
|
const viewport = page.getViewport({ scale });
|
||||||
@@ -157,25 +166,111 @@ const pdf2canvas = async function (pdfFile:File): Promise<HTMLCanvasElement> {
|
|||||||
|
|
||||||
await page.render({ canvasContext: context as CanvasRenderingContext2D, viewport }).promise;
|
await page.render({ canvasContext: context as CanvasRenderingContext2D, viewport }).promise;
|
||||||
|
|
||||||
return(canvas);
|
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<Array<DecodeResult> | null>} - an array of decoded results
|
||||||
* */
|
* */
|
||||||
const decodeFromCanvas = async (canvas:HTMLCanvasElement) => {
|
const decodeFromCanvas = async (canvas:HTMLCanvasElement): Promise<Array<DecodeResult> | null> => {
|
||||||
try {
|
try {
|
||||||
const hints = new Map();
|
const hints = new Map();
|
||||||
hints.set(DecodeHintType.POSSIBLE_FORMATS, [ BarcodeFormat.PDF_417 ]);
|
hints.set(DecodeHintType.POSSIBLE_FORMATS, [ BarcodeFormat.PDF_417 ]);
|
||||||
hints.set(DecodeHintType.PURE_BARCODE, false);
|
hints.set(DecodeHintType.PURE_BARCODE, false);
|
||||||
|
|
||||||
const codeReader = new BrowserPDF417Reader(hints);
|
const codeReader = new BrowserPDF417Reader(hints);
|
||||||
const result = await codeReader.decodeFromCanvas(canvas);
|
|
||||||
|
|
||||||
return({
|
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<DecodeResult>|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<DecodeResult> = [];
|
||||||
|
|
||||||
|
// 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()),
|
billInfo: parseHubText(result.getText()),
|
||||||
barcodeImage: copyBarcodeImage(canvas, result)
|
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) {
|
} catch(ex:any) {
|
||||||
console.log(ex);
|
console.log(ex);
|
||||||
return(null);
|
return(null);
|
||||||
@@ -186,7 +281,7 @@ const decodeFromCanvas = async (canvas:HTMLCanvasElement) => {
|
|||||||
* Copies bar code from the given canvas
|
* 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
|
// get coordinates of bar code
|
||||||
const points = decoderResult.getResultPoints();
|
const points = decoderResult.getResultPoints();
|
||||||
@@ -241,7 +336,20 @@ const decodeFromFile = async (file:File) => {
|
|||||||
case 'image/jpeg':
|
case 'image/jpeg':
|
||||||
return(await decodeFromCanvas( await image2canvas(file) ));
|
return(await decodeFromCanvas( await image2canvas(file) ));
|
||||||
case 'application/pdf':
|
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:
|
default:
|
||||||
console.error(file.name, 'is not a .pdf file.');
|
console.error(file.name, 'is not a .pdf file.');
|
||||||
return null;
|
return null;
|
||||||
@@ -253,7 +361,7 @@ const decodeFromFile = async (file:File) => {
|
|||||||
* @param {Event} event - The change event from an HTMLInputElement.
|
* @param {Event} event - The change event from an HTMLInputElement.
|
||||||
* @return {Promise<HTMLCanvasElement | null>} The canvas with the first page of the PDF, or null if the document is not a PDF.
|
* @return {Promise<HTMLCanvasElement | null>} 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<HTMLInputElement>): Promise<{ billInfo: BillInfo, barcodeImage:string } | null> {
|
export async function findDecodePdf417(event: React.ChangeEvent<HTMLInputElement>): Promise<Array<DecodeResult>|null> {
|
||||||
const file = (event.target as HTMLInputElement).files?.[0];
|
const file = (event.target as HTMLInputElement).files?.[0];
|
||||||
|
|
||||||
if(!file) {
|
if(!file) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useFormState } from "react-dom";
|
|||||||
import { updateOrAddBill } from "../lib/actions/billActions";
|
import { updateOrAddBill } from "../lib/actions/billActions";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatYearMonth } from "../lib/format";
|
import { formatYearMonth } from "../lib/format";
|
||||||
import { findDecodePdf417 } from "../lib/pdf/barcodeDecoder";
|
import { DecodeResult, findDecodePdf417 } from "../lib/pdf/barcodeDecoder";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
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
|
// 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<BillEditFormProps> = ({ location, bill }) => {
|
|||||||
|
|
||||||
const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear, billMonth);
|
const handleAction = updateOrAddBillMiddleware.bind(null, locationID, billID, billYear, billMonth);
|
||||||
|
|
||||||
|
const [ isScanningPDF, setIsScanningPDF ] = React.useState<boolean>(false);
|
||||||
const [ state, dispatch ] = useFormState(handleAction, initialState);
|
const [ state, dispatch ] = useFormState(handleAction, initialState);
|
||||||
const [ isPaid, setIsPaid ] = React.useState<boolean>(paid);
|
const [ isPaid, setIsPaid ] = React.useState<boolean>(paid);
|
||||||
const [ payedAmount, setPayedAmount ] = React.useState<string>(initialPayedAmount ? `${initialPayedAmount/100}` : "" );
|
const [ payedAmount, setPayedAmount ] = React.useState<string>(initialPayedAmount ? `${initialPayedAmount/100}` : "" );
|
||||||
const [ barcodeImage, setBarcodeImage ] = React.useState<string | undefined>(initialBarcodeImage);
|
const [ barcodeImage, setBarcodeImage ] = React.useState<string | undefined>(initialBarcodeImage);
|
||||||
|
const [ barcodeResults, setBarcodeResults ] = React.useState<Array<DecodeResult> | null>(null);
|
||||||
|
|
||||||
const billPaid_handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const billPaid_handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setIsPaid(event.target.checked);
|
setIsPaid(event.target.checked);
|
||||||
@@ -50,19 +52,39 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
|
|||||||
setPayedAmount(event.target.value);
|
setPayedAmount(event.target.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const billAttachment_handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const billAttachment_handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
findDecodePdf417(event)
|
setIsScanningPDF(true);
|
||||||
.then(result => {
|
|
||||||
if(result) {
|
setPayedAmount("");
|
||||||
|
setBarcodeImage(undefined);
|
||||||
|
setBarcodeResults(null);
|
||||||
|
|
||||||
|
const results = await findDecodePdf417(event);
|
||||||
|
if(results && results.length > 0) {
|
||||||
|
|
||||||
|
if(results.length === 1) {
|
||||||
const {
|
const {
|
||||||
barcodeImage,
|
barcodeImage,
|
||||||
billInfo
|
billInfo
|
||||||
} = result;
|
} = results[0];
|
||||||
|
|
||||||
setPayedAmount(`${billInfo.amount/100}`);
|
setPayedAmount(`${billInfo.amount/100}`);
|
||||||
setBarcodeImage(barcodeImage);
|
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(
|
return(
|
||||||
@@ -98,8 +120,36 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
|
|||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<input id="billAttachment" name="billAttachment" type="file" className="file-input file-input-bordered grow file-input-xs my-2 block" onChange={billAttachment_handleChange} />
|
<input id="billAttachment" name="billAttachment" type="file" className="file-input file-input-bordered grow file-input-s my-2 block max-w-[17em] md:max-w-[80em] break-words" onChange={billAttachment_handleChange} />
|
||||||
</div>
|
</div>
|
||||||
|
{
|
||||||
|
isScanningPDF &&
|
||||||
|
<div className="flex flex-row items-center w-full mt-2">
|
||||||
|
<div className="loading loading-spinner loading-m mr-2"></div>
|
||||||
|
<span>{t("scanning-pdf")}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// if multiple results are found, show them as a list
|
||||||
|
// and notify the user to select the correct one
|
||||||
|
barcodeResults && barcodeResults.length > 0 &&
|
||||||
|
<>
|
||||||
|
<div className="flex mt-2 ml-2">
|
||||||
|
<label className="label-text max-w-xs break-words">{t("multiple-barcode-results-notification")}</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<label className="label grow pt-0 ml-3">
|
||||||
|
<ul className="list-none">
|
||||||
|
{barcodeResults.map((result, index) => (
|
||||||
|
<li key={index} className="cursor-pointer mt-3" onClick={() => handleBarcodeSelectClick(result)}>
|
||||||
|
👉 { result.billInfo.description }
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
<div id="status-error" aria-live="polite" aria-atomic="true">
|
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||||
{state.errors?.billAttachment &&
|
{state.errors?.billAttachment &&
|
||||||
state.errors.billAttachment.map((error: string) => (
|
state.errors.billAttachment.map((error: string) => (
|
||||||
@@ -141,7 +191,6 @@ export const BillEditForm:FC<BillEditFormProps> = ({ location, bill }) => {
|
|||||||
<p className="text-xs my-1">{t.rich('barcode-disclaimer', { br: () => <br /> })}</p>
|
<p className="text-xs my-1">{t.rich('barcode-disclaimer', { br: () => <br /> })}</p>
|
||||||
</div> : null
|
</div> : null
|
||||||
}
|
}
|
||||||
|
|
||||||
<textarea id="billNotes" name="billNotes" className="textarea textarea-bordered my-2 max-w-lg w-full block" placeholder={t("notes-placeholder")} defaultValue={notes ?? ''}></textarea>
|
<textarea id="billNotes" name="billNotes" className="textarea textarea-bordered my-2 max-w-lg w-full block" placeholder={t("notes-placeholder")} defaultValue={notes ?? ''}></textarea>
|
||||||
<div id="status-error" aria-live="polite" aria-atomic="true">
|
<div id="status-error" aria-live="polite" aria-atomic="true">
|
||||||
{state.errors?.billNotes &&
|
{state.errors?.billNotes &&
|
||||||
|
|||||||
@@ -69,6 +69,8 @@
|
|||||||
"bill-edit-form": {
|
"bill-edit-form": {
|
||||||
"bill-name-placeholder": "Bill name",
|
"bill-name-placeholder": "Bill name",
|
||||||
"paid-checkbox": "Paid",
|
"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",
|
"payed-amount": "Amount",
|
||||||
"barcode-disclaimer": "After scanning the code make sure the information is correct.<br></br>We are not liable in case of an incorrect payment.",
|
"barcode-disclaimer": "After scanning the code make sure the information is correct.<br></br>We are not liable in case of an incorrect payment.",
|
||||||
"notes-placeholder": "Notes",
|
"notes-placeholder": "Notes",
|
||||||
|
|||||||
@@ -69,6 +69,8 @@
|
|||||||
"bill-edit-form": {
|
"bill-edit-form": {
|
||||||
"bill-name-placeholder": "Ime računa",
|
"bill-name-placeholder": "Ime računa",
|
||||||
"paid-checkbox": "Plaćeno",
|
"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",
|
"payed-amount": "Iznos",
|
||||||
"barcode-disclaimer": "Nakon skeniranja bar koda obavezni provjeri jesu li svi podaci ispravni.<br></br>Ne snosimo odgovornost za slučaj pogrešno provedene uplate.",
|
"barcode-disclaimer": "Nakon skeniranja bar koda obavezni provjeri jesu li svi podaci ispravni.<br></br>Ne snosimo odgovornost za slučaj pogrešno provedene uplate.",
|
||||||
"notes-placeholder": "Bilješke",
|
"notes-placeholder": "Bilješke",
|
||||||
|
|||||||
Reference in New Issue
Block a user