Automatic serial number workflow for paperless-ngx

Posted on Mar. 27, 2025 by Ben Dickson.


Using the following hardware:

..and the following software:

Paperless

I first installed paperless-ngx. I have set things up more or less exactly per it's install docs, the only setting changed are the following env-vars:

PAPERLESS_CONSUMER_ENABLE_BARCODES: "true"
PAPERLESS_CONSUMER_ENABLE_ASN_BARCODE: "true"
PAPERLESS_CONSUMER_BARCODE_SCANNER: "ZXING"
PAPERLESS_CONSUMER_BARCODE_UPSCALE: "1.5"

These are needed to:

The scanner

This scanner I have is very basic USB scanner, with no fancy network connection etc, so I have it connected up to a computer.

The computer is running Proxmox. Getting the USB device to reliably pass to a CT container was a pain, so scanservjs is running in a small VM, with the USB device mapped through (which reliably reconnects via the USB vendor ID - whereas the CT devices seem to require mapping via the /dev/... path which changes on reboot/reconnection)

scanservjs

I initially tried scanservjs, which is a web front-end to the SANE scanner thing. It worked quite well, gave a preview of the document and could easily be configured to push the document straight to paperless.

For reference, the way I set this up:

There is a lot of different ways to get a document into paperless - I used the HTTP API to push the document from the scanning VM to the paperless container.

First I generated an authorization token for paperless-ngx API. This is done by clicking on "My Profile" (top-right of UI, clicking on your username, then generating the key if not already present).

Then in scanservjs config, I add an afterScan callback which sends the document using curl.

/* eslint-disable no-unused-vars */
const options = { paths: ['/usr/lib/scanservjs'] };
const Process = require(require.resolve('./server/classes/process', options));
const dayjs = require(require.resolve('dayjs', options));

module.exports = {
  /**
   * This method is called after every scan has completed with the resultant
   * FileInfo.
   * @param {FileInfo} fileInfo
   * @returns {Promise.<any>}
   */
  async afterScan(fileInfo) {
    // Copy the file to the home directory
    return await Process.spawn(`curl -H "Authorization: Token TOKENGOESHERE" -F document=@'${fileInfo.fullname}' http://paperless.lan:8000/api/documents/post_document/`);
  },
};

Noting TOKENGOESHERE must be changed to the auth token from paperless, and the URL for paperless probably needs to change

Downside to this is you need to put the document in the scanner, open up the browser interface, click scan. I don't have the scanner setup next to the computer, so this was a bit inconvinient.

Scan Button Daemon (scanbd)

scanbd is a small program which listens for the buttons presses from the USB scanner, and runs a command.

I tried this, but configuring this seemed far more complicated than I had the patience for.

Labels

Tried https://github.com/tmaier/asn-qr-code-label-generator - worked okay but hard to tweak the positioning.

Created simple template using Typst (and on Github)

You put the following code in a thing.typ file, then run typst thing.typ and it generates a PDF.

To tweak the positioning, here's what I did:

This way you only waste one sheet to get the alignment perfect

Make sure to put the label in printer in same orientation (for me in the Brother printer, this was with the labels facing down, and the writing upside-down (i.e bottom of text away from me)

#import "@preview/cades:0.3.0": qr-code
#import "@preview/oxifmt:0.2.1": strfmt

#let start_asn = 1
#let label_width = 25.1mm
#let label_height = 10mm
#let qr_size = 8mm
#let codes_per_row = 7

// Function to format number as ASN with leading zeros
#let format_asn(n) = {
  strfmt("ASN{:05}", n)
}

#set page(
  paper: "a4",
  margin: (x: 9mm, y: 15mm)
)

#set table(inset: 0pt, stroke: none)

#table(
  columns: codes_per_row,
  column-gutter: 2.75mm,
  row-gutter: 0mm,
  ..range(start_asn, start_asn+189).map(num => {
    box(
      width: label_width,
      height: label_height,
      stroke: 0.1pt,
      {
        let asn = format_asn(num)
        place(
          left,
          dx: 1mm,
          dy: 1mm,
          qr-code(asn, width: qr_size, height: qr_size)
        )

        place(
          center,
          dx: 3mm,
          dy: 4mm,
          text(8pt, asn)
        )
      }
    )
  })
)

Note the preview/cades package for typst is a bit slow - I think this is due to it using an embedded Javascript runtime (Jogs) to generate the QR code, which seems a little over complicated way of generating the QR code image. There's another package tiaoma which similarly compiles a C library to wasm and runs that. Not sure why both options go this route instead using a pure-Rust QR code generating crate (there may be some reason I'm overlooking?)