Truck drivers in Vietnam photograph paper fuel receipts and send the images over Zalo. At the fleet office, an accountant opens each photo and types the figures by hand into a fuel reconciliation spreadsheet. I built a local Python script that reads receipt images into Excel to skip that data entry step. No transport company uses the script, and I have zero real receipts on hand.
Offline stack and spreadsheet checks
I run the pipeline on Windows with Python 3.11, paddlepaddle 2.6.2, and PaddleOCR 2.9.1 on CPU. For models, I paired en_PP-OCRv3 detection with latin_PP-OCRv3 recognition and ch_ppocr_mobile_v2.0 angle classification. During inference, I block network sockets in Python so receipt images stay on the machine and out of the cloud.
From each slip, I extract date, time, station name, and license plate, normalizing plates like 51C-123.45 into 51C12345. Alongside those fields, I parse the fuel item (Dầu DO 0,05S, Xăng RON95, or E5 RON92), liters with a comma decimal such as 120,50, unit price, and total amount with dot thousands separators such as 2.410.000.
The output Excel workbook has two sheets. Sheet one holds the parsed rows, and sheet two stores the raw OCR text for cross-checking. I shade a row yellow and mark it CẦN XEM ("needs review") if a field is missing, if OCR confidence is low, or if liters * unit price differs from the total amount by more than 1%.
Six synthetic receipts and a 0/6 first run
Because I lack real fuel slips, I used PIL to generate six synthetic receipt images. All six use clear printed text, with slight rotation and light noise on two images and no license plate on one image. On my first run, fuel item scored 0/6, unit price scored 0/6, and station name scored 0/6.
Before touching my regexes, I opened sheet two to inspect the raw OCR text. The Latin recognition model read Vietnamese horn vowels (ơ and ư) as a base letter plus an apostrophe: "Đơn giá" became "Do'n gia", "CỬA HÀNG" became "CU'A HANG", and "Đường" became "Du'ng". Other diacritics shifted to Latin umlauts or acute marks. In that raw dump, "Mặt hàng: Dầu DO" appeared as "Mät hang: Däu DO" (turning both ặ and ầ into ä) and "Số lít" appeared as "Só lit".
Umlauts and acute marks caused zero trouble during matching because Unicode NFD decomposition strips combining characters, turning "Mät hang" into "mat hang" and "Só lit" into "so lit". An ASCII apostrophe is punctuation, so "Do'n gia" survived NFD stripping as "do'n gia" and missed my "don gia" label regex. Worse, the apostrophe put a word boundary right after "Do" in "Do'n". My case-insensitive \bDO\b pattern caught "Do'n" as a second "Dầu DO" item and reported multiple fuel items.
Dropping letter-sandwiched apostrophes in fold()
I run a fold() helper to strip diacritics before regex matching and keep the original OCR string for spreadsheet display. One regex pass at the top drops apostrophes between two letters before NFD decomposition:
def fold(text: str) -> str: # The Latin model reads ơ/ư as o'/u' (Do'n, CU'A): drop apostrophes between letters first. text = re.sub(r"(?<=[^\W\d_])['’`´](?=[^\W\d_])", "", text) text = unicodedata.normalize("NFD", text.replace("Đ", "D").replace("đ", "d")) return "".join(c for c in text if unicodedata.category(c) != "Mn").lower() Because [^\W\d_] matches Unicode letters, "Do'n gia" folds to "don gia" and "CU'A HANG" folds to "cua hang" without touching digits or outer quotes. I added one regression test using that raw OCR string, and all 70 tests in the suite pass.
Before and after on the six PIL receipts
| Field | First run | After fix | Notes |
|---|---|---|---|
| Fuel item | 0/6 | 6/6 | Do'n stops triggering a second \bDO\b match |
| Unit price | 0/6 | 6/6 | Do'n gia folds to don gia |
| Station name | 0/6 | 0/6 | Still outputs CU'A HANG XANG DAU SO 01 without diacritics |
After the fix, date, time, liters, and total amount each scored 6/6 on the synthetic batch. License plate extraction hit 6/6: the parser read all five printed plates and marked the plateless sixth receipt with CẦN XEM. Station name stays at 0/6 against accented ground truth because latin_PP-OCRv3 drops Vietnamese diacritics and the sheet displays raw OCR text, though "CU'A HANG XANG DAU SO 01" lets a person recognize the station.
Image 05 triggered CẦN XEM on the arithmetic check. That synthetic slip prints 95,8 liters at 20.500 VND per liter (1.963.900 VND) next to a total of 2.000.000 VND from a round-amount fill-up. Because the gap exceeds 1%, I shade that row yellow for the accountant to verify.
What I'd check next
I saved myself a blind regex hunt by dumping raw OCR text onto sheet two, since the parsed cells gave no clue why \bDO\b matched twice. Keeping fold() for matching while writing raw OCR strings to Excel prevented my apostrophe fix from altering the visible text.
Scoring 6/6 on PIL-generated images does not measure real accuracy. Clean bitmap fonts with mild tilt and light noise hide the failure modes you hit on phone photos. My next step is testing this script on real smartphone photos of fuel receipts from Zalo, where slips arrive blurry, angled, or crumpled. Have you run OCR on Vietnamese receipts or documents, and which recognition model kept Vietnamese diacritics intact for you?
