First attempt at labels
This commit is contained in:
commit
e569afec17
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
result
|
||||||
|
.direnv
|
||||||
|
|
||||||
|
*.pdf
|
||||||
|
addresses.csv
|
||||||
|
addresses.*.csv
|
||||||
|
/content
|
||||||
|
cache/
|
||||||
|
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
|
|
||||||
|
options.json
|
||||||
|
next_serial.txt
|
||||||
|
mailer_id.txt
|
54
common.typ
Normal file
54
common.typ
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
#let address_content(width, height, card) = {
|
||||||
|
set par(leading: 0.5em)
|
||||||
|
let text_height = if card.imb == "" {
|
||||||
|
height
|
||||||
|
} else {
|
||||||
|
height - 1in/8
|
||||||
|
}
|
||||||
|
place(
|
||||||
|
top + left,
|
||||||
|
dx: 0.5in,
|
||||||
|
block(
|
||||||
|
width: width - 0.5in,
|
||||||
|
height: text_height,
|
||||||
|
fill: luma(230),
|
||||||
|
align(
|
||||||
|
start + horizon,
|
||||||
|
text(font: ("OCR-B", "Noto Emoji"), size: 8pt, card.address)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if card.imb != "" {
|
||||||
|
place(
|
||||||
|
top + left,
|
||||||
|
dy: height - 1in/8,
|
||||||
|
block(
|
||||||
|
width: 100%,
|
||||||
|
height: 1in/8,
|
||||||
|
fill: luma(220),
|
||||||
|
align(
|
||||||
|
top + center,
|
||||||
|
text(font: "USPSIMBCompact", size: 12pt, card.imb)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if card.avatar != "" {
|
||||||
|
place(
|
||||||
|
top + left,
|
||||||
|
dx: 0.1in,
|
||||||
|
dy: 0.1in,
|
||||||
|
image(card.avatar, width: 0.3in)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#let address_block(width, height, card) = {
|
||||||
|
block(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
breakable: false,
|
||||||
|
fill: luma(240),
|
||||||
|
address_content(width, height, card)
|
||||||
|
)
|
||||||
|
}
|
61
flake.lock
Normal file
61
flake.lock
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1695318763,
|
||||||
|
"narHash": "sha256-FHVPDRP2AfvsxAdc+AsgFJevMz5VBmnZglFUMlxBkcY=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "e12483116b3b51a185a33a272bf351e357ba9a99",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"utils": "utils"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1694529238,
|
||||||
|
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
53
flake.nix
Normal file
53
flake.nix
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
|
||||||
|
utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = {
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
utils,
|
||||||
|
}: let
|
||||||
|
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
||||||
|
in
|
||||||
|
utils.lib.eachSystem supportedSystems (system: let
|
||||||
|
pkgs = import nixpkgs {inherit system;};
|
||||||
|
in rec {
|
||||||
|
formatter = pkgs.alejandra;
|
||||||
|
|
||||||
|
packages.included-fonts = pkgs.runCommand "included-fonts" {} ''
|
||||||
|
mkdir -p $out/share/fonts/{truetype,opentype}
|
||||||
|
cp ${./fonts}/*.ttf $out/share/fonts/truetype
|
||||||
|
cp ${./fonts}/*.otf $out/share/fonts/opentype
|
||||||
|
'';
|
||||||
|
|
||||||
|
devShell = with pkgs;
|
||||||
|
mkShell {
|
||||||
|
packages = [
|
||||||
|
typst
|
||||||
|
python3
|
||||||
|
python3Packages.autopep8
|
||||||
|
python3Packages.requests
|
||||||
|
];
|
||||||
|
|
||||||
|
# We need CLDR main, not just the annotations
|
||||||
|
CLDR_ROOT = pkgs.cldr-annotations.overrideAttrs (final: prev: {
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
mkdir -p $out/share/unicode/cldr
|
||||||
|
mv common $out/share/unicode/cldr
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
});
|
||||||
|
|
||||||
|
TYPST_FONT_PATHS = with pkgs; symlinkJoin {
|
||||||
|
name = "typst-fonts";
|
||||||
|
paths = [
|
||||||
|
packages.included-fonts
|
||||||
|
noto-fonts-emoji
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
BIN
fonts/OCR-B-Regular.otf
Normal file
BIN
fonts/OCR-B-Regular.otf
Normal file
Binary file not shown.
BIN
fonts/USPSIMBCompact.ttf
Normal file
BIN
fonts/USPSIMBCompact.ttf
Normal file
Binary file not shown.
149
format.py
Executable file
149
format.py
Executable file
|
@ -0,0 +1,149 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import string
|
||||||
|
import typing
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
import requests
|
||||||
|
import imb
|
||||||
|
|
||||||
|
|
||||||
|
def iso_code(s: str) -> str:
|
||||||
|
if len(s) != 2:
|
||||||
|
raise ValueError("must be 2 characters long")
|
||||||
|
s = s.lower()
|
||||||
|
if not (s[0] in string.ascii_lowercase and s[1] in string.ascii_lowercase):
|
||||||
|
raise ValueError("must be ascii letters")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def get_avatar(url: str) -> str:
|
||||||
|
if not os.path.exists("cache"):
|
||||||
|
os.mkdir("cache")
|
||||||
|
name = url.split("?")[0].split("/")[-1]
|
||||||
|
if os.path.exists("cache/" + name):
|
||||||
|
return "cache/" + name
|
||||||
|
result = requests.get(url)
|
||||||
|
if result.ok:
|
||||||
|
with open("cache/" + name, "wb") as outfile:
|
||||||
|
outfile.write(result.content)
|
||||||
|
return "cache/" + name
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def get_country_name(
|
||||||
|
root: ET.ElementTree, destination: str, alt=None
|
||||||
|
) -> typing.Optional[str]:
|
||||||
|
elements = root.findall(
|
||||||
|
f"./localeDisplayNames/territories/territory[@type='{destination.upper()}']"
|
||||||
|
)
|
||||||
|
normal = None
|
||||||
|
for element in elements:
|
||||||
|
if element.attrib.get("alt") == alt:
|
||||||
|
return element.text
|
||||||
|
elif element.attrib.get("alt") is None:
|
||||||
|
normal = element.text
|
||||||
|
return normal
|
||||||
|
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="format", description="format postcards with latex"
|
||||||
|
)
|
||||||
|
parser.add_argument("template", help="template to use",
|
||||||
|
nargs="?", default="2card")
|
||||||
|
parser.add_argument(
|
||||||
|
"-o", "--origin", help="origin country code", default="us", type=iso_code
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-l",
|
||||||
|
"--language",
|
||||||
|
help="language to use for countries ",
|
||||||
|
default="en",
|
||||||
|
type=iso_code,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-c",
|
||||||
|
"--count",
|
||||||
|
default=1,
|
||||||
|
type=int,
|
||||||
|
help="Number of sets of labels to print, default 1 (labels only)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-s",
|
||||||
|
"--skip",
|
||||||
|
default=0,
|
||||||
|
type=int,
|
||||||
|
help="Number of labels to skip (label sheets only)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"-a",
|
||||||
|
"--address-file",
|
||||||
|
default="addresses.csv",
|
||||||
|
type=str,
|
||||||
|
help="CSV file containing addresses",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = ET.parse(
|
||||||
|
f"{os.getenv('CLDR_ROOT')}/share/unicode/cldr/common/main/{args.language}.xml"
|
||||||
|
)
|
||||||
|
|
||||||
|
csvfile = open(args.address_file)
|
||||||
|
rows = csv.DictReader(csvfile)
|
||||||
|
|
||||||
|
current_serial = imb.get_first_serial()
|
||||||
|
mid = int(open("mailer_id.txt").read().strip())
|
||||||
|
|
||||||
|
|
||||||
|
cards = []
|
||||||
|
for row in rows:
|
||||||
|
if row["Address"] == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
country = (
|
||||||
|
[]
|
||||||
|
if row["Country"].lower() == args.origin
|
||||||
|
else [get_country_name(root, row["Country"]).upper()]
|
||||||
|
)
|
||||||
|
|
||||||
|
address = row["Address"].split("\n") + country
|
||||||
|
|
||||||
|
if row.get("Avatar", "") != "":
|
||||||
|
avatar = get_avatar(row["Avatar"])
|
||||||
|
else:
|
||||||
|
avatar = None
|
||||||
|
|
||||||
|
cards += [
|
||||||
|
{
|
||||||
|
"address": "\n".join(address),
|
||||||
|
"avatar": avatar,
|
||||||
|
"row": row,
|
||||||
|
"imb": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
cards = cards * args.count
|
||||||
|
|
||||||
|
serial = imb.get_first_serial()
|
||||||
|
if args.origin == "us":
|
||||||
|
for card in cards:
|
||||||
|
dpc = card["row"].get("DPC", "")
|
||||||
|
if dpc != "":
|
||||||
|
card["imb"] = imb.generate(
|
||||||
|
0, 310, mid, serial, dpc.replace(" ", "").replace("-", "")
|
||||||
|
)
|
||||||
|
serial += 1
|
||||||
|
imb.write_current_serial(serial)
|
||||||
|
|
||||||
|
with open("options.json", "w") as options:
|
||||||
|
json.dump(
|
||||||
|
fp=options,
|
||||||
|
obj={
|
||||||
|
"args": args.__dict__,
|
||||||
|
"cards": cards,
|
||||||
|
},
|
||||||
|
)
|
138
imb.py
Normal file
138
imb.py
Normal file
|
@ -0,0 +1,138 @@
|
||||||
|
import datetime
|
||||||
|
import typing
|
||||||
|
import imb_table
|
||||||
|
|
||||||
|
|
||||||
|
def get_first_serial() -> int:
|
||||||
|
"""
|
||||||
|
Generate a 6-digit serial number for an intelligent mail barcode.
|
||||||
|
The first 2 digits are the last 2 digits of the julian date, and the next 4
|
||||||
|
are serial and the next number to use is stored in a temporary file.
|
||||||
|
This will break if over 10000 serials are requested in a day
|
||||||
|
"""
|
||||||
|
# Last 2 digits of the ordinal day
|
||||||
|
date = datetime.datetime.utcnow().timetuple().tm_yday % 100
|
||||||
|
try:
|
||||||
|
serial_file = open("next_serial.txt")
|
||||||
|
next_serial = int(serial_file.read().strip())
|
||||||
|
if next_serial // 10000 == date:
|
||||||
|
first_idx = next_serial % 10000
|
||||||
|
else:
|
||||||
|
first_idx = 0
|
||||||
|
except (ValueError, IndexError, FileNotFoundError):
|
||||||
|
first_idx = 0
|
||||||
|
|
||||||
|
return date * 10000 + first_idx
|
||||||
|
|
||||||
|
|
||||||
|
def write_current_serial(current_serial: int):
|
||||||
|
serial_file = open("next_serial.txt", "w")
|
||||||
|
serial_file.write(format(current_serial, "06d"))
|
||||||
|
|
||||||
|
|
||||||
|
def _format_routing(routing: str) -> int:
|
||||||
|
if len(routing) == 0:
|
||||||
|
return 0
|
||||||
|
elif len(routing) == 5:
|
||||||
|
return int(routing) + 1
|
||||||
|
elif len(routing) == 9:
|
||||||
|
return int(routing) + 100000 + 1
|
||||||
|
elif len(routing) == 11:
|
||||||
|
return int(routing) + 1000000000 + 100000 + 1
|
||||||
|
else:
|
||||||
|
raise ValueError("Routing code must be 0, 5, 9, or 11 characters")
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_crc(data_int: int) -> int:
|
||||||
|
"""
|
||||||
|
Do the weird USPS CRC11 which requires precisely 102 bits in
|
||||||
|
This is done by copying USPS code which is not very optimal
|
||||||
|
"""
|
||||||
|
data = data_int.to_bytes(13, "big")
|
||||||
|
poly = 0xF35
|
||||||
|
fcs = 0x7FF
|
||||||
|
|
||||||
|
current = data[0] << 5
|
||||||
|
for _ in range(6):
|
||||||
|
if (fcs ^ current) & 0x400:
|
||||||
|
fcs = (fcs << 1) ^ poly
|
||||||
|
else:
|
||||||
|
fcs = fcs << 1
|
||||||
|
fcs &= 0x7FF
|
||||||
|
current <<= 1
|
||||||
|
|
||||||
|
for current in data[1:]:
|
||||||
|
current <<= 3
|
||||||
|
for _ in range(8):
|
||||||
|
if (fcs ^ current) & 0x400:
|
||||||
|
fcs = (fcs << 1) ^ poly
|
||||||
|
else:
|
||||||
|
fcs = fcs << 1
|
||||||
|
fcs &= 0x7FF
|
||||||
|
current <<= 1
|
||||||
|
|
||||||
|
return fcs
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_codewords(data: int, crc: int) -> typing.List[int]:
|
||||||
|
codewords = []
|
||||||
|
codewords.append(data % 636)
|
||||||
|
data //= 636
|
||||||
|
for i in range(8):
|
||||||
|
codewords.append(data % 1365)
|
||||||
|
data //= 1365
|
||||||
|
codewords.append(data)
|
||||||
|
|
||||||
|
codewords.reverse()
|
||||||
|
codewords[0] += ((crc >> 10) & 1) * 659
|
||||||
|
codewords[9] *= 2
|
||||||
|
return codewords
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_characters(codewords: typing.List[int], crc: int) -> typing.List[int]:
|
||||||
|
characters = []
|
||||||
|
for idx, codeword in enumerate(codewords):
|
||||||
|
xor = ((crc >> idx) & 1) * 0x1FFF
|
||||||
|
characters.append(imb_table.CHARACTER_TABLE[codeword] ^ xor)
|
||||||
|
|
||||||
|
return characters
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bit(characters: typing.List[int], character: int, bit: int) -> int:
|
||||||
|
return (characters[character] >> bit) & 1
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_bars(characters: typing.List[int]) -> str:
|
||||||
|
s = ""
|
||||||
|
for bar in range(65):
|
||||||
|
descender = _get_bit(characters, *imb_table.BAR_TABLE[bar * 2])
|
||||||
|
ascender = _get_bit(characters, *imb_table.BAR_TABLE[bar * 2 + 1])
|
||||||
|
s = s + "TADF"[descender * 2 + ascender]
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def from_payload(data: int) -> str:
|
||||||
|
crc = _generate_crc(data)
|
||||||
|
codewords = _generate_codewords(data, crc)
|
||||||
|
characters = _generate_characters(codewords, crc)
|
||||||
|
return _generate_bars(characters)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_payload(bi: int, stid: int, mailer: int, serial: int, raw_routing: str):
|
||||||
|
if mailer >= 1000000:
|
||||||
|
decimal_tracking = int(f"{stid:03d}{mailer:09d}{serial:06d}")
|
||||||
|
else:
|
||||||
|
# Why are you using this program?
|
||||||
|
decimal_tracking = int(f"{stid:03d}{mailer:06d}{serial:09d}")
|
||||||
|
|
||||||
|
payload = _format_routing(raw_routing)
|
||||||
|
|
||||||
|
# ... what the fuck usps
|
||||||
|
payload = payload * 10 + (bi // 10)
|
||||||
|
payload = payload * 5 + (bi % 10)
|
||||||
|
payload = payload * 10**18 + decimal_tracking
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def generate(bi: int, stid: int, mailer: int, serial: int, raw_routing: str):
|
||||||
|
return from_payload(_generate_payload(bi, stid, mailer, serial, raw_routing))
|
1500
imb_table.py
Normal file
1500
imb_table.py
Normal file
File diff suppressed because it is too large
Load diff
34
labels.typ
Normal file
34
labels.typ
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
#{
|
||||||
|
set page("us-letter", margin: 0em)
|
||||||
|
|
||||||
|
import "common.typ"
|
||||||
|
|
||||||
|
let options = json("options.json")
|
||||||
|
let cards = options.cards
|
||||||
|
let args = options.args
|
||||||
|
|
||||||
|
let printer_offset = 1in/16
|
||||||
|
|
||||||
|
let label_position(idx) = {
|
||||||
|
let offset_idx = idx + args.skip
|
||||||
|
let col = calc.rem(offset_idx, 3)
|
||||||
|
let row = calc.rem(calc.floor(offset_idx / 3), 10)
|
||||||
|
let x_pos = 3in/16 + 2.75in * col
|
||||||
|
let y_pos = printer_offset + 1in/2 + 1in * row
|
||||||
|
(x_pos, y_pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
for (idx, card) in cards.enumerate() {
|
||||||
|
if idx != 0 and calc.rem(idx + args.skip, 30) == 0 {
|
||||||
|
pagebreak()
|
||||||
|
}
|
||||||
|
let (dx, dy) = label_position(idx)
|
||||||
|
place(
|
||||||
|
top + left,
|
||||||
|
dx: dx,
|
||||||
|
dy: dy,
|
||||||
|
common.address_block(2in + 5in/8, 7in/8, card)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue