Compare commits
	
		
			17 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e1639ff8ba | |||
| 2b328d805e | |||
| d085335fc5 | |||
| a38dd669a8 | |||
| ace82a0f4d | |||
| 819115960b | |||
| 5bd06d892d | |||
| b14c855cc9 | |||
| f1a17cb4e7 | |||
| 3b7905b46d | |||
| c6d2974fea | |||
| 6c80b80aca | |||
| 990d37df99 | |||
| e80e88cb1c | |||
| 534d7ad615 | |||
| ce03d7b5b0 | |||
| 049c87869a | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -1 +1,5 @@ | ||||
| *~ | ||||
| partdb-labeler.bat | ||||
| partdb_labeler.egg-info | ||||
| build | ||||
| */__pycache__/* | ||||
							
								
								
									
										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. | ||||
							
								
								
									
										17
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								README.md
									
									
									
									
									
								
							| @@ -25,4 +25,19 @@ printer via USB. | ||||
| 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
									
								
							
							
								
								
									
										184
									
								
								partdb_labeler/partdb_labeler.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										184
									
								
								partdb_labeler/partdb_labeler.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,184 @@ | ||||
| #!/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 | ||||
|  | ||||
| # 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", 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.2') | ||||
|     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 | ||||
|  | ||||
|     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(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) | ||||
|     loc=textline(z, res, part["ipn"], loc, 5) | ||||
|  | ||||
|     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) | ||||
|  | ||||
|     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() | ||||
| @@ -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.2.4" | ||||
| 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