Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f83bde6ea | |||
| 53cae2e3cc | |||
| 84149f75cf | |||
| 0aa17bfd40 | |||
| e1639ff8ba | |||
| 2b328d805e | |||
| d085335fc5 | |||
| a38dd669a8 | |||
| ace82a0f4d | |||
| 819115960b | |||
| 5bd06d892d | |||
| b14c855cc9 | |||
| f1a17cb4e7 | |||
| 3b7905b46d | |||
| c6d2974fea | |||
| 6c80b80aca | |||
| 990d37df99 | |||
| e80e88cb1c | |||
| 534d7ad615 | |||
| ce03d7b5b0 | |||
| 049c87869a |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1 +1,6 @@
|
||||
*~
|
||||
partdb-labeler.bat
|
||||
partdb_labeler.egg-info
|
||||
build
|
||||
*/__pycache__/*
|
||||
dist
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Scott Alfter
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
22
README.md
22
README.md
@@ -22,7 +22,27 @@ So far, it's been tested with two Zebra printers: an LP2844 and a GK420t. The L
|
||||
a CUPS print queue feeding it through a network print server. The GK420t was driven by a Windows 11 system, connected to the
|
||||
printer via USB.
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
This package is also available through [PyPI](https://pypi.org/project/partdb-labeler/) and [AUR](https://aur.archlinux.org/packages/python-partdb-labeler).
|
||||
|
||||
Usage
|
||||
-----
|
||||
|
||||
```python -m partdb-labeler -h``` will show you the available options.
|
||||
```partdb_labeler -h``` will show you the available options.
|
||||
|
||||
For convenience, you might also consider adding a short shell script somewhere in your PATH that will call the Python module
|
||||
with your server configuration. I use this (the API key is a read-only key I've publicized elsewhere):
|
||||
|
||||
```
|
||||
#!/usr/bin/env bash
|
||||
partdb_labeler -p https://partdb.alfter.us -k tcp_673fc81f0b7837ca4c029fbd6536b27742eb8b742eba27bf547c8136dc6a84f8 $*
|
||||
```
|
||||
|
||||
or the same, as a batch file on Windows:
|
||||
|
||||
```
|
||||
@echo off
|
||||
partdb_labeler -p https://partdb.alfter.us -k tcp_673fc81f0b7837ca4c029fbd6536b27742eb8b742eba27bf547c8136dc6a84f8 %*
|
||||
```
|
||||
|
||||
BIN
dist/partdb_labeler-0.1.1-py2.py3-none-any.whl
vendored
BIN
dist/partdb_labeler-0.1.1-py2.py3-none-any.whl
vendored
Binary file not shown.
BIN
dist/partdb_labeler-0.1.1.tar.gz
vendored
BIN
dist/partdb_labeler-0.1.1.tar.gz
vendored
Binary file not shown.
@@ -1,169 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import requests
|
||||
from zebra import Zebra
|
||||
import qrcode
|
||||
from math import ceil, floor
|
||||
from PIL import Image
|
||||
import textwrap
|
||||
import argparse
|
||||
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument("id", help="part ID", type=int)
|
||||
parser.add_argument("-x", help="label width, in inches (default: 2\")", type=float)
|
||||
parser.add_argument("-y", help="label height, in inches (default: 1\")", type=float)
|
||||
parser.add_argument("-g", help="label gap, in inches (default: 0.118\")", type=float)
|
||||
parser.add_argument("-q", help="send to selected print queue instead of stdout")
|
||||
parser.add_argument("-p", help="PartDB base URL")
|
||||
parser.add_argument("-k", help="PartDB API key")
|
||||
parser.add_argument("-r", help="printer resolution (default: 203 dpi)", type=int)
|
||||
parser.add_argument('-v', action='version', version='%(prog)s 0.1.1')
|
||||
args=parser.parse_args()
|
||||
id=args.id
|
||||
if args.x==None:
|
||||
label_width=2
|
||||
else:
|
||||
label_width=args.x
|
||||
if args.y==None:
|
||||
label_height=1
|
||||
else:
|
||||
label_height=args.y
|
||||
if args.g==None:
|
||||
label_gap=0.118
|
||||
else:
|
||||
label_gap=args.g
|
||||
if args.q==None:
|
||||
queue="zebra_python_unittest"
|
||||
else:
|
||||
queue=args.q
|
||||
base_url=args.p
|
||||
api_key=args.k
|
||||
if args.r==None:
|
||||
res=203
|
||||
else:
|
||||
res=args.r
|
||||
if res!=203 and res!=300:
|
||||
raise ValueError("valid resolution options are 203 and 300")
|
||||
|
||||
# width and height for built-in monospace fonts (includes whitespace)
|
||||
|
||||
font_metrics={}
|
||||
font_metrics[1]=(10,14)
|
||||
font_metrics[2]=(12,18)
|
||||
font_metrics[3]=(14,22)
|
||||
font_metrics[4]=(16,26)
|
||||
font_metrics[5]=(34,50)
|
||||
|
||||
# make substitutions for characters not in CP437
|
||||
|
||||
def subst(s):
|
||||
repl={}
|
||||
repl["®"]="(R)"
|
||||
repl["©"]="(C)"
|
||||
repl["Ω"]="Ω" # U+2126 -> U+03A9, which is in CP437
|
||||
repl["±"]="+/-"
|
||||
out=""
|
||||
for i in s:
|
||||
try:
|
||||
out=out+repl[i]
|
||||
except:
|
||||
out=out+i
|
||||
return out
|
||||
|
||||
# filter out characters not in selected codepage
|
||||
# (printer uses CP437)
|
||||
|
||||
def filter(s, cp):
|
||||
out=""
|
||||
for i in s:
|
||||
try:
|
||||
i.encode(cp)
|
||||
out=out+i
|
||||
except:
|
||||
pass
|
||||
return out
|
||||
|
||||
# handle escape characters in strings to be printed
|
||||
|
||||
def esc(s):
|
||||
out=""
|
||||
for i in s:
|
||||
if i=="\"":
|
||||
out=out+"\\\""
|
||||
elif i=="\\":
|
||||
out=out+"\\\\"
|
||||
else:
|
||||
out=out+i
|
||||
return out
|
||||
|
||||
# render a line of text at coordinates
|
||||
# return coordinates of next line
|
||||
|
||||
def textline(s, loc, fontnum):
|
||||
z.output(f"A{loc[0]},{loc[1]},0,{fontnum},1,1,N,\"{esc(filter(subst(s), "cp437"))}\"\n")
|
||||
return (loc[0], loc[1]+font_metrics[fontnum][1])
|
||||
|
||||
# wrap text in a bounding box at coordinates
|
||||
# return coordinates of next line and any unused text
|
||||
|
||||
def textbox(s, loc, bbox, fontnum):
|
||||
wrapped=textwrap.wrap(filter(subst(s), "cp437"), width=floor(bbox[0]/font_metrics[fontnum][0]))
|
||||
line=0
|
||||
while line*font_metrics[fontnum][1]<bbox[1] and line<len(wrapped):
|
||||
loc=textline(wrapped[line], loc, fontnum)
|
||||
line=line+1
|
||||
return loc, " ".join(wrapped[line:])
|
||||
|
||||
# render a QR code at coordinates
|
||||
# return size (single value, since QR codes are square)
|
||||
|
||||
def qr(s, loc, mul, brdr):
|
||||
qr = qrcode.QRCode(
|
||||
box_size=mul,
|
||||
border=brdr,
|
||||
)
|
||||
qr.add_data(s)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image().copy()
|
||||
padded=Image.new(mode="1", size=(8*ceil(img.width/8),img.height), color="white")
|
||||
padded.paste(im=img, box=(0,0,img.width,img.height))
|
||||
z.output(f"GW10,10,{ceil(padded.width/8)},{padded.height},{padded.tobytes().decode("cp437")}\n")
|
||||
return img.height
|
||||
|
||||
# look up the part
|
||||
|
||||
url=f"{base_url}/api/parts/{id}"
|
||||
headers={}
|
||||
headers["Accept"]="application/json"
|
||||
if api_key!=None:
|
||||
headers["Authorization"]=f"Bearer {api_key}"
|
||||
part=requests.get(url, headers=headers).json()
|
||||
|
||||
# render a label for it
|
||||
|
||||
label_width=floor(label_width*res)
|
||||
label_height=floor(label_height*res)
|
||||
label_gap=floor(label_gap*res)
|
||||
|
||||
z=Zebra(queue)
|
||||
z.output(f"q{label_width}\n")
|
||||
if (args.y!=None and args.g!=None):
|
||||
z.output(f"Q{label_height},{label_gap}\n")
|
||||
z.output("N\n")
|
||||
|
||||
if res==300:
|
||||
qr_size=qr(f"{base_url}/en/part/{id}", (10, 10), 6, 3)
|
||||
else:
|
||||
qr_size=qr(f"{base_url}/en/part/{id}", (10, 10), 4, 2)
|
||||
|
||||
loc=(15+qr_size, 20)
|
||||
loc=textline(part["ipn"], loc, 5)
|
||||
|
||||
loc, excess=textbox(f"{part["manufacturer"]["name"]} {part["manufacturer_product_number"]}", loc, (label_width-loc[0]-10, label_height-loc[1]-10), 2)
|
||||
|
||||
avail_y=floor((20+qr_size-loc[1])/font_metrics[2][1])*font_metrics[2][1]
|
||||
loc, excess=textbox(part["description"], loc, (label_width-loc[0]-10, avail_y), 2)
|
||||
if excess!="":
|
||||
loc=(10, loc[1])
|
||||
loc, excess=textbox(excess, loc, (label_width-20, label_height-loc[1]), 2)
|
||||
|
||||
z.output("P1\n")
|
||||
0
partdb_labeler/__init__.py
Normal file
0
partdb_labeler/__init__.py
Normal file
205
partdb_labeler/partdb_labeler.py
Normal file
205
partdb_labeler/partdb_labeler.py
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python
|
||||
import requests
|
||||
from zebra import Zebra
|
||||
import qrcode
|
||||
from math import ceil, floor
|
||||
from PIL import Image
|
||||
import textwrap
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
# make substitutions for characters not in CP437
|
||||
|
||||
def subst(s):
|
||||
repl={}
|
||||
repl["®"]="(R)"
|
||||
repl["©"]="(C)"
|
||||
repl["Ω"]="Ω" # U+2126 -> U+03A9, which is in CP437
|
||||
repl["±"]="+/-"
|
||||
out=""
|
||||
for i in s:
|
||||
try:
|
||||
out=out+repl[i]
|
||||
except:
|
||||
out=out+i
|
||||
return out
|
||||
|
||||
# filter out characters not in selected codepage
|
||||
# (printer uses CP437)
|
||||
|
||||
def filter(s, cp):
|
||||
out=""
|
||||
for i in s:
|
||||
try:
|
||||
i.encode(cp)
|
||||
out=out+i
|
||||
except:
|
||||
pass
|
||||
return out
|
||||
|
||||
# handle escape characters in strings to be printed
|
||||
|
||||
def esc(s):
|
||||
out=""
|
||||
for i in s:
|
||||
if i=="\"":
|
||||
out=out+"\\\""
|
||||
elif i=="\\":
|
||||
out=out+"\\\\"
|
||||
else:
|
||||
out=out+i
|
||||
return out
|
||||
|
||||
# render a line of text at coordinates
|
||||
# return coordinates of next line
|
||||
|
||||
def textline(z, res, s, loc, fontnum):
|
||||
z.output(f"A{loc[0]},{loc[1]},0,{fontnum},1,1,N,\"{esc(filter(subst(s), "cp437"))}\"\n")
|
||||
return (loc[0], loc[1]+font_metrics(res)[fontnum][1])
|
||||
|
||||
# wrap text in a bounding box at coordinates
|
||||
# return coordinates of next line and any unused text
|
||||
|
||||
def textbox(z, res, s, loc, bbox, fontnum):
|
||||
wrapped=textwrap.wrap(filter(subst(s), "cp437"), width=floor(bbox[0]/font_metrics(res)[fontnum][0]))
|
||||
line=0
|
||||
while line*font_metrics(res)[fontnum][1]<bbox[1] and line<len(wrapped):
|
||||
loc=textline(z, res, wrapped[line], loc, fontnum)
|
||||
line=line+1
|
||||
return loc, " ".join(wrapped[line:])
|
||||
|
||||
# render a QR code at coordinates
|
||||
# return size (single value, since QR codes are square)
|
||||
|
||||
def qr(z, s, loc, mul, brdr):
|
||||
qr = qrcode.QRCode(
|
||||
box_size=mul,
|
||||
border=brdr,
|
||||
)
|
||||
qr.add_data(s)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image().copy()
|
||||
padded=Image.new(mode="1", size=(8*ceil(img.width/8),img.height), color="white")
|
||||
padded.paste(im=img, box=(0,0,img.width,img.height))
|
||||
z.output(f"GW10,10,{ceil(padded.width/8)},{padded.height},{padded.tobytes().decode("cp437")}\n")
|
||||
return img.height
|
||||
|
||||
# width and height for built-in monospace fonts (includes whitespace)
|
||||
|
||||
def font_metrics(res):
|
||||
m={}
|
||||
if res==203:
|
||||
m[1]=(10,14)
|
||||
m[2]=(12,18)
|
||||
m[3]=(14,22)
|
||||
m[4]=(16,26)
|
||||
m[5]=(34,50)
|
||||
if res==300:
|
||||
m[1]=(14,22)
|
||||
m[2]=(18,30)
|
||||
m[3]=(22,38)
|
||||
m[4]=(26,46)
|
||||
m[5]=(50,82)
|
||||
return m
|
||||
|
||||
# entrypoint
|
||||
|
||||
def cli():
|
||||
parser=argparse.ArgumentParser()
|
||||
parser.add_argument("id", help="part ID (or IPN)")
|
||||
parser.add_argument("-i", action="store_true", help="search by IPN instead of part ID")
|
||||
parser.add_argument("-x", help="label width, in inches (default: 2\")", type=float)
|
||||
parser.add_argument("-y", help="label height, in inches (default: 1\")", type=float)
|
||||
parser.add_argument("-g", help="label gap, in inches (default: 0.118\")", type=float)
|
||||
parser.add_argument("-q", help="send to selected print queue instead of stdout")
|
||||
parser.add_argument("-p", help="PartDB base URL")
|
||||
parser.add_argument("-k", help="PartDB API key")
|
||||
parser.add_argument("-r", help="printer resolution (default: 203 dpi)", type=int)
|
||||
parser.add_argument('-v', action='version', version='%(prog)s 0.3.1')
|
||||
args=parser.parse_args()
|
||||
id=args.id
|
||||
if args.x==None:
|
||||
label_width=2
|
||||
else:
|
||||
label_width=args.x
|
||||
if args.y==None:
|
||||
label_height=1
|
||||
else:
|
||||
label_height=args.y
|
||||
if args.g==None:
|
||||
label_gap=0.118
|
||||
else:
|
||||
label_gap=args.g
|
||||
if args.q==None:
|
||||
queue="zebra_python_unittest"
|
||||
else:
|
||||
queue=args.q
|
||||
base_url=args.p
|
||||
api_key=args.k
|
||||
if args.r==None:
|
||||
res=203
|
||||
else:
|
||||
res=args.r
|
||||
if res!=203 and res!=300:
|
||||
raise ValueError("valid resolution options are 203 and 300")
|
||||
|
||||
# look up the part
|
||||
|
||||
if args.i==True:
|
||||
url=f"{base_url}/api/parts/?ipn={id}"
|
||||
else:
|
||||
url=f"{base_url}/api/parts/{id}"
|
||||
headers={}
|
||||
headers["Accept"]="application/json"
|
||||
if api_key!=None:
|
||||
headers["Authorization"]=f"Bearer {api_key}"
|
||||
part=requests.get(url, headers=headers).json()
|
||||
if args.i==True: # search by IPN
|
||||
try:
|
||||
part=part[0]
|
||||
except IndexError:
|
||||
sys.exit("part not found")
|
||||
try:
|
||||
if part["status"]==404: # catch a search that returns nothing
|
||||
sys.exit("part not found")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# render a label for it
|
||||
|
||||
label_width=floor(label_width*res)
|
||||
label_height=floor(label_height*res)
|
||||
label_gap=floor(label_gap*res)
|
||||
|
||||
z=Zebra(queue)
|
||||
z.output(f"q{label_width}\n")
|
||||
if (args.y!=None and args.g!=None):
|
||||
z.output(f"Q{label_height},{label_gap}\n")
|
||||
z.output("N\n")
|
||||
|
||||
if res==300:
|
||||
qr_size=qr(z, f"{base_url}/en/part/{id}", (10, 10), 6, 3)
|
||||
else:
|
||||
qr_size=qr(z, f"{base_url}/en/part/{id}", (10, 10), 4, 2)
|
||||
|
||||
loc=(15+qr_size, 20)
|
||||
try:
|
||||
loc=textline(z, res, part["ipn"], loc, 5)
|
||||
except KeyError:
|
||||
loc=textline(z, res, "", loc, 5)
|
||||
|
||||
try:
|
||||
loc, excess=textbox(z, res, f"{part["manufacturer"]["name"]} {part["manufacturer_product_number"]}", loc, (label_width-loc[0]-10, label_height-loc[1]-10), 2)
|
||||
except KeyError:
|
||||
loc, excess=textbox(z, res, "", loc, (label_width-loc[0]-10, label_height-loc[1]-10), 2)
|
||||
|
||||
avail_y=floor((20+qr_size-loc[1])/font_metrics(res)[2][1])*font_metrics(res)[2][1]
|
||||
loc, excess=textbox(z, res, part["description"], loc, (label_width-loc[0]-10, avail_y), 2)
|
||||
if excess!="":
|
||||
loc=(10, loc[1])
|
||||
loc, excess=textbox(z, res, excess, loc, (label_width-20, label_height-loc[1]), 2)
|
||||
|
||||
z.output("P1\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
6
publish.sh
Normal file
6
publish.sh
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# publish to PyPI
|
||||
rm -r dist || true
|
||||
python setup.py sdist bdist_wheel
|
||||
twine upload dist/*
|
||||
@@ -1,20 +1,22 @@
|
||||
[build-system]
|
||||
requires = ["flit_core >=3.2,<4"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
requires = ["setuptools"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "partdb-labeler"
|
||||
name = "partdb_labeler"
|
||||
version = "0.3.1"
|
||||
authors = [
|
||||
{name="Scott Alfter", email="scott@alfter.us"}
|
||||
{name = "Scott Alfter", email = "scott@alfter.us"},
|
||||
]
|
||||
description = "PartDB Labeler"
|
||||
version = "0.1.1"
|
||||
readme = "README.md"
|
||||
dependencies = ["requests", "zebra", "qrcode", "pillow"]
|
||||
|
||||
[project.urls]
|
||||
Home="https://gitlab.alfter.us/salfter/partdb-labeler"
|
||||
|
||||
[tool.flit.module]
|
||||
name="partdb-labeler"
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"requests",
|
||||
"zebra",
|
||||
"qrcode",
|
||||
"pillow"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
partdb_labeler = "partdb_labeler.partdb_labeler:cli"
|
||||
|
||||
5
setup.cfg
Normal file
5
setup.cfg
Normal file
@@ -0,0 +1,5 @@
|
||||
[metadata]
|
||||
description_file = README.md
|
||||
url = https://gitlab.alfter.us/salfter/partdb-labeler
|
||||
author = Scott Alfter
|
||||
author_email = scott@alfter.us
|
||||
Reference in New Issue
Block a user