devlog / dotnet / expedl / shopee / wpf

What I did this weekend: turning a label complaint into an app

· 15 min read

ExpedL's main window after the redesign, in light theme.
ExpedL's main window after the redesign, in light theme. On the left, a list of 51 orders with tracking code, order ID, routing, page count and status; filter chips count orders with alerts, duplicates, already shipped and with 3 or more pages. On the right, order #006, with 3 pages, is open with its items. The footer reads "38 orders to ship · 49 labels" next to the Generate PDF button.

I asked a friend, in person, if there was anything at work he wished software would take care of. He works at a company that sells on Shopee, the online marketplace, and part of his job is printing the shipping labels. He told me about the labels coming out misaligned.

Every Shopee order prints as two pages: the shipping label and a loading checklist with the items. The printer is set to "2 pages per sheet", so each sheet, a single 4×6 thermal label, carries one whole order: label and checklist together. That works as long as every order has exactly two pages.

When an order has a lot of items, the checklist spills onto a third page. The example he gave me was a 101-page print with one order on pages 89 to 91. From page 92 on, every shipping label came out stuck to the checklist of the order before it:

Before: one order has 3 pages

sheet 45 | p.89  label A          | p.90  checklist A   |
sheet 46 | p.91  checklist A (2)  | p.92  label B       |  <- B starts in the wrong half
sheet 47 | p.93  checklist B      | p.94  label C       |
...and every sheet after this one is off by a page

After: a blank page right after the odd order

sheet 45 | p.89  label A          | p.90  checklist A   |
sheet 46 | p.91  checklist A (2)  | p.92  (blank)       |
sheet 47 | p.93  label B          | p.94  checklist B   |

The fix fits in one sentence: if an order has an odd number of pages, add a blank page after it. Doing that by hand meant printing in parts, working out the breaks yourself and hoping you didn't print an order twice.

I didn't know what a ZPL file was

The hardest part of the whole project was understanding the problem. Looking back, it's simple. At the time, without knowing his job, his first explanation sounded like a lot of moving parts, and I asked questions until I could see what actually had to happen.

I had also never heard of ZPL. It's the language of Zebra label printers, and it's what Shopee gives you when you download labels. I had to go read about it before I could plan anything.

What I learned about the workflow: download the ZPL files from Shopee, around 300 a day, convert them to PDF on a free website with no preview and no editing, then print from the browser. Besides the misalignment, two things bothered me. The free site receives every buyer's name, address, ZIP code and products. And nothing stops the same order from being printed twice.

Friday: writing it down first

On Friday, September 25, I spent the day writing a spec: context, constraints, phases and acceptance criteria. The free site costs nothing, so price was never going to be the argument. The argument is data safety, time, fewer human errors and less paper, and I wrote that every product decision had to serve those four.

The constraints: 100% offline, no telemetry, compliance with the LGPD (Brazil's data protection law), no admin rights to install or run, only permissively licensed dependencies (MIT, Apache, BSD, OFL), Windows 10 and 11, and a Portuguese interface.

The alignment rule became an invariant. Every order takes an even number of pages, so every shipping label lands on an odd page. The acceptance test was my friend's case: the 101-page print becomes 102 pages, with the blank on page 92 and the next label on 93.

My first guess at a stack went into the spec as a guess: .NET 8, WPF or WinUI 3, a library called BinaryKits.Zpl.Viewer to render the ZPL and PDFsharp to write the PDF. Each choice was there to be confirmed or thrown out. Before any product work there was a step zero, a fidelity spike: if the barcodes and QR codes in my output couldn't be decoded, I'd stop there.

I also decided how I'd work: plan first, architecture and milestones before code, one milestone at a time with small commits, and nothing from a later phase until the current one was done. Every decision that changed the spec became a row in a table, with the reason next to it.

Saturday: Shopee doesn't send what I thought

The spike was the first commit, at 3:22 pm on Saturday. The spec assumed ZPL full of text. Each page turned out to be one black-and-white bitmap, 816×1218 dots (4×6 inches at 203 dpi), compressed inside the ZPL. There's no text at all. Name, address, order ID and products are all pixels.

This is what a page looks like, minus the image data:

~DGR:<name>.GRF,124236,102,:Z64:<base64 of zlib data>:<crc16>
^XA^MMT,Y^PON^MNY^FO0,0^XGR:<name>.GRF,1,1^FS^PQ1,0,0,N^XZ
^XA^IDR:<name>.GRF^FS^XZ

The first line loads the image into the printer's memory, the second prints it and the third deletes it. Everything on the label is that one picture:

Synthetic Shopee shipping label with made-up data: recipient, QR codes, routing boxes and barcodes, all in one bitmap

That changed most of the plan:

  • No ZPL rendering library. Shopee's files have nothing for it to render. A small interpreter of my own, with a closed list of ZPL commands, is exact by construction, and any command outside the list rejects the file instead of producing a wrong label in silence.
  • No PDFsharp. The version I tested wrote compressed streams without the zlib ending (qpdf --check complained), turned on image interpolation and put random IDs in the metadata, so the same input never gave the same PDF. The PDF I needed was only 1-bit images, so it became a small writer using the standard library.
  • .NET 10 LTS instead of .NET 8, because .NET 8 support ends in November 2026.
  • WPF instead of WinUI 3: mature, publishes as a single executable without MSIX and runs on Windows 10.
  • The tracking code, read from the barcode, became the key of each order (duplicates, history, search), because the order ID only exists as an image.

The spike also found a problem in the old flow. The free site places the same bitmap on a 100×150 mm page, which shrinks it to about 98%. That's enough to break the 44-digit barcode of the NF-e key (the electronic invoice number every Brazilian shipment carries):

Free site's PDF ExpedL's PDF
Page 100×150 mm, image at ~98% 4×6 in, image at 1:1
NF-e barcode readable 0 of 50 50 of 50
All codes read at 203, 300 and 600 dpi 200 of 250 250 of 250

Real data never went into the repository. The spec asked for a tool to anonymize the text in ZPL files, but there was no text to anonymize, so that tool became a generator of synthetic pages in the same format. Every test page is marked SINTETICO, and a test fails if the mark is missing.

Saturday night: aligning is easy, printing isn't

The alignment is a pure function that only sees page numbers. The "every label on an odd page" invariant is tested across 500 combinations, and the PDF writer refuses a page sequence that breaks it.

To align, the app has to know which page is a label. A page counts as a label if its tracking barcode is readable or if more than 15% of it is ink. In my tests, labels sat between 0.246 and 0.266 ink and checklists between 0.011 and 0.073. When the two checks disagree, the app inserts no blanks and shows a warning instead of guessing.

The first screen was up at 7:20 pm. In a synthetic 101-page batch, order #045 has three pages on 89 to 91, and the app puts the blank on page 92, like in my friend's example:

ExpedL's first screen, Saturday at 7:20 pm.
ExpedL's first screen, Saturday at 7:20 pm. On the left, a plain table of orders with tracking code, page count and status; order #045 is marked "3 pages". In the middle, the pages of #045: label on page 89, checklist on 90, continuation on 91, and a dashed "blank inserted" page on 92. On the right, the label enlarged. The status bar reads "1 file, 50 orders, 102 pages, 1 blank added → 51 labels".

The printing was the bigger surprise. The spec had A4 layouts in it. Asking more questions showed there was no A4 anywhere: only 4×6 thermal labels, printed from the browser with "2 pages per sheet" at 100%, the browser rotating both pages and shrinking them to 2/3. So the app took that job over. It builds each 4×6 label itself, with the whole order on it, and whoever prints just sends it at 100%, one per sheet, with nothing to configure. The same screen got a checkbox to preview that:

The same first screen with "See how it comes out on the label" checked.
The same first screen with "See how it comes out on the label" checked. The enlarged page on the right now shows one 4×6 label with the shipping label rotated on the top half and the checklist on the bottom half, both shrunk to fit

That created a new problem with the barcodes. Shrinking a barcode to 2/3 on a 203 dpi printer doesn't work: a bar 2 dots wide becomes 1.33 dots, and the printer can only print 1 or 2. On the bench, 0 of 30 Code 128 barcodes could be read after the reduction. The way out was to rebuild each Code 128 pattern from the barcode's structure and redraw it with whole-dot bars. The content doesn't change, only the bar widths. With that, the NF-e barcode stays readable on the assembled label, which never happened in the old browser flow either.

By 10:20 pm there was a Generate PDF window that previews every label before saving. Same batch, first with alignment off:

Saturday's Generate PDF window with "Align orders" unchecked.
Saturday's Generate PDF window with "Align orders" unchecked. Labels 46, 47 and 48 have orange outlines and captions like "#045 + #046 · mixed": each one holds the end of one order and the start of the next. A yellow warning says 5 labels mix different orders and offers an "Align orders" button.

And with it on:

The same window with "Align orders" checked.
The same window with "Align orders" checked. Label 46 now holds the last page of order #045 next to a dashed blank page, and label 47 starts cleanly with order #046. The summary counts 101 order pages, 1 blank page and 51 labels.

By then the first version could read files safely with size limits, group pages into orders, align, write the PDF and show it all in a WPF window. With 300 files (600 pages), the table was complete about 1.35 s after the window opened, and the window answered in under 30 ms while it worked. The 102-page PDF took 11 ms. Before the night was over I added a history of orders already printed (encrypted with DPAPI, kept for 90 days), search and reprint of a single order, and batch editing with drag, remove, manual blanks and undo, with the alignment redone after every edit.

Sunday morning: reading what's only pixels

Sorting by product, a picking list and search by SKU all need text, and there was none. So, before any feature that depended on it, another spike: OCR. I weighed Tesseract against the OCR engine that ships with Windows. Windows OCR found the same order ID on the checklist as on the label in 50 of 50 orders, at about 95 ms per page, offline and with no new dependency. Tesseract would have added native DLLs and around 20 MB. Windows won. It's called through a COM bridge written by hand, because the official package to reach it didn't have a permissive license.

The routing codes were a small puzzle. A label has two black boxes with codes, and Shopee doesn't document them. Measuring 50 labels showed the top box is the sorting center and the bottom one is the delivery hub (L, the state, a number, like PR1-2 · LPR-18). The app now reads each box by its position and fixes the usual OCR confusions (0 and O, 1 and I) using the format each box can have. That fixed all 10 misreads in my test set.

By Sunday afternoon the table had order ID and routing columns, the search box took a tracking code, order ID, SKU or product, and the side panel listed the items read from the checklist:

ExpedL's main window on Sunday afternoon.
ExpedL's main window on Sunday afternoon. The order table now has order ID and routing columns, like "MG2-1 · LMG-87", above a search box for tracking code, order ID, SKU, product or order number. On the right, below the enlarged label, a panel shows the tracking code, order ID, routing and one item read by OCR, with copy buttons and a "Fix reading..." button.

The picking list sums the items of the whole batch by SKU, so whoever separates the products can grab everything at once. It only adds up readings that are identical, and marks the near-identical ones with "check". A shirt in "BLUE S" and one in "BLUE M" are one letter apart and are different sizes. Seeing two lines for the same product is annoying; picking the wrong size is worse.

OCR also made the app worse for a while. Peak memory went from about 510 MB to 1.1 GB, and the window froze for up to 1.7 s. OCR started at the same moment the list was being drawn, allocated about 5 MB per page on 6 threads, and the garbage collector kept stopping the UI thread. After reusing buffers, capping OCR at 4 threads and waiting for the window to go idle before starting, the peak was about 685 MB and the longest pause about 0.7 s.

Sunday afternoon: learning how to sell software

I had never sold software before, so this part was research. I had to decide what a license would look like for an app that never goes online, and what the law asks of me before a stranger can install it.

The license I landed on is paid once, lifetime, with a year of updates, and tied to one computer. Importing and checking a batch are free, so anyone can see what it does. Generating the PDF needs a key. My first attempt had an automatic 15-day trial, but clearing two local markers gave you another 15 days. Issuing a key by hand costs me very little, so I replaced it with trial keys that I send myself, with an expiry date.

I left code signing out for now. A certificate costs money and there are no customers yet, so whoever downloads the app sees a Windows SmartScreen warning once per version.

Then I wrote my own list of 15 improvements, things I thought would make the app more useful day to day. One stopped a real risk: generating the PDF twice in the same session used to print everything twice without a warning, and now each order is marked as shipped as soon as its PDF is saved. Another restores the batch after a crash without writing any label to disk. Others were about finding things in a big batch: grouping the list by file or by status, and fixing an OCR reading by hand. Grouped by status, the orders with three or more pages and the duplicates get their own groups:

The main window with the list grouped by status.
The main window with the list grouped by status. "OK" holds 49 collapsed orders, "3 or more pages" holds three highlighted orders, and "Duplicates" holds two unchecked orders marked in red. The middle panel repeats the same groups as cards with each order's pages.

Sunday evening: redoing the interface

The last two screenshots are how the main window looked at the end of Sunday afternoon. It showed the same batch three times, as a table, as cards and as an enlarged page, each with its own scroll. The redesign turned it into a list plus an inspector for the selected order, and the batch counts became filters you can click: orders with alerts, duplicates, already shipped, three or more pages. There's a brand now (a white L that stands for the whole order, with a kraft-colored tab in the corner, on a petrol-blue background), the Sora font for titles, colors by role and a dark theme.

The words changed too. The UI is in Portuguese, and statuses like "already out" read oddly, so an order is now simply "expedido", shipped. The name comes from the same word: "Exped" from expedir, to dispatch, which also reads in English, plus the L from ZPL and from the logo.

ExpedL after the redesign: order list with filter chips, order #006 open on the right, Generate PDF at the bottom

Monday: shipping 1.0.0

Monday was about turning a project into something other people can download. The legal part went in: Terms of Use and a Privacy Policy, accepted on first launch. The synthetic generator learned to draw real text with a small font rasterizer, so the OCR had something to read in the tests. With Sora it confused 1, l and I. With Windows' Arial, the readings were on par with the real labels.

CI on GitHub Actions now runs on a clean Windows machine for every pull request: restore with locked versions, build, test, publish. The code repository is private, so each release is copied to a public repository that only holds downloads, with a changelog written for the people who use the app. CI rejects a version without one.

Version 1.0.0 went out at 5:07 pm on Monday, September 28: a single 62 MB executable with .NET inside, for Windows 10 and 11. Counting from the first commit on Saturday afternoon, that's about two days. By then there were 482 automated tests, 74 recorded decisions and around 25,000 lines of C# and XAML across the app, the tests and the tools.

Not tested yet

So far it hasn't been tested for real. No label from the app has gone through a thermal printer or a barcode scanner, and it hasn't been used on a real day of shipping. All the readability numbers above are digital, and a digital reader is more forgiving than a physical one. My friend has seen it working and confirmed it solves his problem.

There are smaller gaps too. The OCR needs Windows' Portuguese OCR language, which a clean install doesn't have. Without it, order ID, routing, items, sorting, the picking list and SKU search are unavailable, while importing, checking and generating the PDF still work. The executable isn't signed. And nobody has bought it yet.

What's next

These are plans, each with what it depends on:

  • Print on a real thermal printer and scan the labels. That unblocks bigger text for the items on the checklist (the one thing my friend asked for besides the alignment), a check on the quality of the assembled label, and layouts for other paper sizes.
  • Show the recipient's name and address in the order panel. The OCR doesn't read that block yet, and there's an LGPD decision to make before it does.
  • Other marketplaces. I've mapped out three levels (accept any label generically, use the first barcode as the order key, or a dedicated profile per marketplace), but nothing starts without real sample files. My assumption about Shopee's format was already wrong once.
  • Sign the executable once there are customers.

What I'm taking from it

I used Claude Code as an assistant along the way, and the project went smoothly. What I got better at was project management, software architecture and writing prompts.

If you sell on Shopee and print labels on a 4×6 thermal printer, you can download ExpedL for Windows (all versions are on the product page). To try it, email me at contact@gabreusi.dev for a trial key.