|
|
|
@@ -39,13 +39,19 @@ except ImportError: |
|
|
|
win32gui = None # type: ignore[assignment] |
|
|
|
|
|
|
|
try: |
|
|
|
from PIL import Image, ImageDraw, ImageFont |
|
|
|
from PIL import Image, ImageDraw, ImageFont, ImageOps |
|
|
|
try: |
|
|
|
from PIL import ImageWin # type: ignore |
|
|
|
except Exception: |
|
|
|
ImageWin = None # type: ignore[assignment] |
|
|
|
import qrcode |
|
|
|
_HAS_PIL_QR = True |
|
|
|
except ImportError: |
|
|
|
Image = None # type: ignore[assignment] |
|
|
|
ImageDraw = None # type: ignore[assignment] |
|
|
|
ImageFont = None # type: ignore[assignment] |
|
|
|
ImageOps = None # type: ignore[assignment] |
|
|
|
ImageWin = None # type: ignore[assignment] |
|
|
|
qrcode = None # type: ignore[assignment] |
|
|
|
_HAS_PIL_QR = False |
|
|
|
|
|
|
|
@@ -205,21 +211,22 @@ def generate_zpl_dataflex( |
|
|
|
label_esc = _zpl_escape(label_line) |
|
|
|
# QR payload: prefer JSON {"itemId":..., "stockInLineId":...} when both present; else fall back to lot/batch text |
|
|
|
if item_id is not None and stock_in_line_id is not None: |
|
|
|
qr_value = _zpl_escape(json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id})) |
|
|
|
qr_payload = json.dumps({"itemId": item_id, "stockInLineId": stock_in_line_id}) |
|
|
|
else: |
|
|
|
qr_value = _zpl_escape(label_line if label_line else batch_no.strip()) |
|
|
|
qr_payload = label_line if label_line else batch_no.strip() |
|
|
|
qr_value = _zpl_escape(qr_payload) |
|
|
|
return f"""^XA |
|
|
|
^CI28 |
|
|
|
^PW700 |
|
|
|
^LL500 |
|
|
|
^PO N |
|
|
|
^FO10,20 |
|
|
|
^BQR,4,7^FD{qr_value}^FS |
|
|
|
^BQN,2,4^FDQA,{qr_value}^FS |
|
|
|
^FO170,20 |
|
|
|
^A@R,72,72,{font_regular}^FD{desc}^FS |
|
|
|
^FO0,290 |
|
|
|
^FO0,200 |
|
|
|
^A@R,72,72,{font_regular}^FD{label_esc}^FS |
|
|
|
^FO75,290 |
|
|
|
^FO55,200 |
|
|
|
^A@R,88,88,{font_bold}^FD{code}^FS |
|
|
|
^XZ""" |
|
|
|
|
|
|
|
@@ -262,22 +269,52 @@ def generate_zpl_label_small( |
|
|
|
^XZ""" |
|
|
|
|
|
|
|
|
|
|
|
# Label image size (pixels) for 標簽機 image printing; words drawn bigger for readability |
|
|
|
LABEL_IMAGE_W = 520 |
|
|
|
LABEL_IMAGE_H = 520 |
|
|
|
LABEL_PADDING = 20 |
|
|
|
LABEL_FONT_NAME_SIZE = 38 # item name (bigger) |
|
|
|
LABEL_FONT_CODE_SIZE = 44 # item code (bigger) |
|
|
|
LABEL_FONT_BATCH_SIZE = 30 # batch/lot line |
|
|
|
LABEL_QR_SIZE = 140 # QR module size in pixels |
|
|
|
# Label image size (pixels) for 標簽機 image printing. |
|
|
|
# Enlarged for readability (approx +90% scale). |
|
|
|
LABEL_IMAGE_W = 720 |
|
|
|
LABEL_IMAGE_H = 530 |
|
|
|
LABEL_PADDING = 23 |
|
|
|
LABEL_FONT_NAME_SIZE = 42 |
|
|
|
LABEL_FONT_CODE_SIZE = 49 |
|
|
|
LABEL_FONT_BATCH_SIZE = 34 |
|
|
|
LABEL_QR_SIZE = 210 |
|
|
|
|
|
|
|
|
|
|
|
def _get_chinese_font(size: int) -> Optional["ImageFont.FreeTypeFont"]: |
|
|
|
"""Return a Chinese-capable font for PIL, or None to use default.""" |
|
|
|
if ImageFont is None: |
|
|
|
return None |
|
|
|
# Prefer Traditional Chinese fonts on Windows |
|
|
|
for name in ("Microsoft JhengHei UI", "Microsoft JhengHei", "MingLiU", "SimHei", "Microsoft YaHei", "SimSun"): |
|
|
|
# Prefer real font files on Windows (font *names* may fail and silently fallback). |
|
|
|
if os.name == "nt": |
|
|
|
fonts_dir = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts") |
|
|
|
for rel in ( |
|
|
|
"msjh.ttc", # Microsoft JhengHei |
|
|
|
"msjhl.ttc", # Microsoft JhengHei Light |
|
|
|
"msjhbd.ttc", # Microsoft JhengHei Bold |
|
|
|
"mingliu.ttc", |
|
|
|
"mingliub.ttc", |
|
|
|
"kaiu.ttf", |
|
|
|
"msyh.ttc", # Microsoft YaHei |
|
|
|
"msyhbd.ttc", |
|
|
|
"simhei.ttf", |
|
|
|
"simsun.ttc", |
|
|
|
): |
|
|
|
p = os.path.join(fonts_dir, rel) |
|
|
|
try: |
|
|
|
if os.path.exists(p): |
|
|
|
return ImageFont.truetype(p, size) |
|
|
|
except (OSError, IOError): |
|
|
|
continue |
|
|
|
# Fallback: try common font names (may still work depending on Pillow build) |
|
|
|
for name in ( |
|
|
|
"Microsoft JhengHei UI", |
|
|
|
"Microsoft JhengHei", |
|
|
|
"MingLiU", |
|
|
|
"MingLiU_HKSCS", |
|
|
|
"Microsoft YaHei", |
|
|
|
"SimHei", |
|
|
|
"SimSun", |
|
|
|
): |
|
|
|
try: |
|
|
|
return ImageFont.truetype(name, size) |
|
|
|
except (OSError, IOError): |
|
|
|
@@ -328,23 +365,60 @@ def render_label_to_image( |
|
|
|
name_str = (item_name or "—").strip() |
|
|
|
max_name_w = LABEL_IMAGE_W - x_right - LABEL_PADDING |
|
|
|
if font_name: |
|
|
|
# Wrap by text width (Pillow 8+ textbbox) or by char count |
|
|
|
# Wrap rule: after 7 "words" (excl. parentheses). ()() not counted; +=*/. and A–Z/a–z count as 0.5. |
|
|
|
def _wrap_text(text: str, font, max_width: int) -> list: |
|
|
|
ignore = set("()()") |
|
|
|
half = set("+=*/.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") |
|
|
|
max_count = 6.5 |
|
|
|
lines: list[str] = [] |
|
|
|
current: list[str] = [] |
|
|
|
count = 0.0 |
|
|
|
|
|
|
|
for ch in text: |
|
|
|
if ch == "\n": |
|
|
|
lines.append("".join(current).strip()) |
|
|
|
current = [] |
|
|
|
count = 0.0 |
|
|
|
continue |
|
|
|
|
|
|
|
ch_count = 0.0 if ch in ignore else (0.5 if ch in half else 1.0) |
|
|
|
if count + ch_count > max_count and current: |
|
|
|
lines.append("".join(current).strip()) |
|
|
|
current = [] |
|
|
|
count = 0.0 |
|
|
|
|
|
|
|
current.append(ch) |
|
|
|
count += ch_count |
|
|
|
|
|
|
|
if current: |
|
|
|
lines.append("".join(current).strip()) |
|
|
|
|
|
|
|
# Max 2 rows for item name. If still long, keep everything in row 2. |
|
|
|
if len(lines) > 2: |
|
|
|
lines = [lines[0], "".join(lines[1:]).strip()] |
|
|
|
|
|
|
|
# Safety: if any line still exceeds pixel width, wrap by width as well. |
|
|
|
if hasattr(draw, "textbbox"): |
|
|
|
words = list(text) |
|
|
|
lines, line = [], [] |
|
|
|
for c in words: |
|
|
|
line.append(c) |
|
|
|
bbox = draw.textbbox((0, 0), "".join(line), font=font) |
|
|
|
if bbox[2] - bbox[0] > max_width and len(line) > 1: |
|
|
|
lines.append("".join(line[:-1])) |
|
|
|
line = [line[-1]] |
|
|
|
if line: |
|
|
|
lines.append("".join(line)) |
|
|
|
return lines |
|
|
|
# Fallback: ~12 chars per line for Chinese |
|
|
|
chunk = 12 |
|
|
|
return [text[i : i + chunk] for i in range(0, len(text), chunk)] |
|
|
|
out: list[str] = [] |
|
|
|
for ln in lines: |
|
|
|
buf: list[str] = [] |
|
|
|
for ch in ln: |
|
|
|
buf.append(ch) |
|
|
|
bbox = draw.textbbox((0, 0), "".join(buf), font=font) |
|
|
|
if bbox[2] - bbox[0] > max_width and len(buf) > 1: |
|
|
|
out.append("".join(buf[:-1]).strip()) |
|
|
|
buf = [buf[-1]] |
|
|
|
if buf: |
|
|
|
out.append("".join(buf).strip()) |
|
|
|
out = [x for x in out if x] |
|
|
|
if len(out) > 2: |
|
|
|
out = [out[0], "".join(out[1:]).strip()] |
|
|
|
return out |
|
|
|
|
|
|
|
lines = [x for x in lines if x] |
|
|
|
if len(lines) > 2: |
|
|
|
lines = [lines[0], "".join(lines[1:]).strip()] |
|
|
|
return lines |
|
|
|
lines = _wrap_text(name_str, font_name, max_name_w) |
|
|
|
for i, ln in enumerate(lines): |
|
|
|
draw.text((x_right, y_line + i * (LABEL_FONT_NAME_SIZE + 4)), ln, font=font_name, fill="black") |
|
|
|
@@ -368,6 +442,32 @@ def render_label_to_image( |
|
|
|
return img |
|
|
|
|
|
|
|
|
|
|
|
def _image_to_zpl_gfa(pil_image: "Image.Image") -> str: |
|
|
|
""" |
|
|
|
Convert a PIL image into ZPL ^GFA (ASCII hex) so we can print Chinese reliably |
|
|
|
on ZPL printers (USB/Windows printer or COM) without relying on GDI drivers. |
|
|
|
""" |
|
|
|
if Image is None or ImageOps is None: |
|
|
|
raise RuntimeError("Pillow is required for image-to-ZPL conversion.") |
|
|
|
# Convert to 1-bit monochrome bitmap. Invert so '1' bits represent black in ZPL. |
|
|
|
img_bw = ImageOps.invert(pil_image.convert("L")).convert("1") |
|
|
|
w, h = img_bw.size |
|
|
|
bytes_per_row = (w + 7) // 8 |
|
|
|
raw = img_bw.tobytes() |
|
|
|
total = bytes_per_row * h |
|
|
|
# Ensure length matches expected (Pillow should already pack per row). |
|
|
|
if len(raw) != total: |
|
|
|
raw = raw[:total].ljust(total, b"\x00") |
|
|
|
hex_data = raw.hex().upper() |
|
|
|
return f"""^XA |
|
|
|
^PW{w} |
|
|
|
^LL{h} |
|
|
|
^FO0,0 |
|
|
|
^GFA,{total},{total},{bytes_per_row},{hex_data} |
|
|
|
^FS |
|
|
|
^XZ""" |
|
|
|
|
|
|
|
|
|
|
|
def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> None: |
|
|
|
""" |
|
|
|
Send a PIL Image to 標簽機 via Windows GDI (so Chinese and graphics print correctly). |
|
|
|
@@ -380,38 +480,58 @@ def send_image_to_label_printer(printer_name: str, pil_image: "Image.Image") -> |
|
|
|
raise RuntimeError("Image printing is only supported for a Windows printer name (e.g. TSC TTP-246M Pro).") |
|
|
|
if win32print is None or win32ui is None or win32con is None or win32gui is None: |
|
|
|
raise RuntimeError("pywin32 is required. Run: pip install pywin32") |
|
|
|
# Draw image to printer DC via temp BMP (GDI uses BMP) |
|
|
|
with tempfile.NamedTemporaryFile(suffix=".bmp", delete=False) as f: |
|
|
|
tmp_bmp = f.name |
|
|
|
dc = win32ui.CreateDC() |
|
|
|
dc.CreatePrinterDC(dest) |
|
|
|
dc.StartDoc("FPSMS Label") |
|
|
|
dc.StartPage() |
|
|
|
try: |
|
|
|
pil_image.save(tmp_bmp, "BMP") |
|
|
|
hbm = win32gui.LoadImage( |
|
|
|
0, tmp_bmp, win32con.IMAGE_BITMAP, 0, 0, |
|
|
|
win32con.LR_LOADFROMFILE | win32con.LR_CREATEDIBSECTION, |
|
|
|
) |
|
|
|
if hbm == 0: |
|
|
|
raise RuntimeError("Failed to load label image as bitmap.") |
|
|
|
dc = win32ui.CreateDC() |
|
|
|
dc.CreatePrinterDC(dest) |
|
|
|
dc.StartDoc("FPSMS Label") |
|
|
|
dc.StartPage() |
|
|
|
bmp_w = pil_image.width |
|
|
|
bmp_h = pil_image.height |
|
|
|
# Scale-to-fit printable area (important for smaller physical labels). |
|
|
|
try: |
|
|
|
mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc())) |
|
|
|
# PyCBitmap.FromHandle works across pywin32 versions |
|
|
|
bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm) |
|
|
|
mem_dc.SelectObject(bmp) |
|
|
|
bmp_w = pil_image.width |
|
|
|
bmp_h = pil_image.height |
|
|
|
dc.StretchBlt((0, 0), (bmp_w, bmp_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY) |
|
|
|
finally: |
|
|
|
win32gui.DeleteObject(hbm) |
|
|
|
page_w = int(dc.GetDeviceCaps(win32con.HORZRES)) |
|
|
|
page_h = int(dc.GetDeviceCaps(win32con.VERTRES)) |
|
|
|
except Exception: |
|
|
|
page_w, page_h = bmp_w, bmp_h |
|
|
|
if page_w <= 0 or page_h <= 0: |
|
|
|
page_w, page_h = bmp_w, bmp_h |
|
|
|
scale = min(page_w / max(1, bmp_w), page_h / max(1, bmp_h)) |
|
|
|
out_w = max(1, int(bmp_w * scale)) |
|
|
|
out_h = max(1, int(bmp_h * scale)) |
|
|
|
x0 = max(0, (page_w - out_w) // 2) |
|
|
|
y0 = max(0, (page_h - out_h) // 2) |
|
|
|
|
|
|
|
# Most reliable: render via Pillow ImageWin directly to printer DC. |
|
|
|
if ImageWin is not None: |
|
|
|
dib = ImageWin.Dib(pil_image.convert("RGB")) |
|
|
|
dib.draw(dc.GetHandleOutput(), (x0, y0, x0 + out_w, y0 + out_h)) |
|
|
|
else: |
|
|
|
# Fallback: Draw image to printer DC via temp BMP (GDI uses BMP) |
|
|
|
with tempfile.NamedTemporaryFile(suffix=".bmp", delete=False) as f: |
|
|
|
tmp_bmp = f.name |
|
|
|
try: |
|
|
|
pil_image.save(tmp_bmp, "BMP") |
|
|
|
hbm = win32gui.LoadImage( |
|
|
|
0, tmp_bmp, win32con.IMAGE_BITMAP, 0, 0, |
|
|
|
win32con.LR_LOADFROMFILE | win32con.LR_CREATEDIBSECTION, |
|
|
|
) |
|
|
|
if hbm == 0: |
|
|
|
raise RuntimeError("Failed to load label image as bitmap.") |
|
|
|
try: |
|
|
|
mem_dc = win32ui.CreateDCFromHandle(win32gui.CreateCompatibleDC(dc.GetSafeHdc())) |
|
|
|
bmp = getattr(win32ui, "CreateBitmapFromHandle", lambda h: win32ui.PyCBitmap.FromHandle(h))(hbm) |
|
|
|
mem_dc.SelectObject(bmp) |
|
|
|
dc.StretchBlt((x0, y0), (out_w, out_h), mem_dc, (0, 0), (bmp_w, bmp_h), win32con.SRCCOPY) |
|
|
|
finally: |
|
|
|
win32gui.DeleteObject(hbm) |
|
|
|
finally: |
|
|
|
try: |
|
|
|
os.unlink(tmp_bmp) |
|
|
|
except OSError: |
|
|
|
pass |
|
|
|
finally: |
|
|
|
dc.EndPage() |
|
|
|
dc.EndDoc() |
|
|
|
finally: |
|
|
|
try: |
|
|
|
os.unlink(tmp_bmp) |
|
|
|
except OSError: |
|
|
|
pass |
|
|
|
|
|
|
|
|
|
|
|
def run_dataflex_continuous_print( |
|
|
|
@@ -1333,27 +1453,20 @@ def main() -> None: |
|
|
|
lot_no = j.get("lotNo") |
|
|
|
n = 100 if count == -1 else count |
|
|
|
try: |
|
|
|
# Prefer image printing so Chinese displays correctly; words are bigger |
|
|
|
if _HAS_PIL_QR and os.name == "nt" and not com.upper().startswith("COM"): |
|
|
|
label_img = render_label_to_image( |
|
|
|
b, item_code, item_name, |
|
|
|
item_id=item_id, stock_in_line_id=stock_in_line_id, |
|
|
|
lot_no=lot_no, |
|
|
|
) |
|
|
|
for i in range(n): |
|
|
|
send_image_to_label_printer(com, label_img) |
|
|
|
if i < n - 1: |
|
|
|
time.sleep(0.5) |
|
|
|
else: |
|
|
|
zpl = generate_zpl_label_small( |
|
|
|
b, item_code, item_name, |
|
|
|
item_id=item_id, stock_in_line_id=stock_in_line_id, |
|
|
|
lot_no=lot_no, |
|
|
|
) |
|
|
|
for i in range(n): |
|
|
|
send_zpl_to_label_printer(com, zpl) |
|
|
|
if i < n - 1: |
|
|
|
time.sleep(0.5) |
|
|
|
# Always render to image (Chinese OK), then send as ZPL graphic (^GFA). |
|
|
|
# This is more reliable than Windows GDI and works for both Windows printer name and COM. |
|
|
|
if not _HAS_PIL_QR: |
|
|
|
raise RuntimeError("請先安裝 Pillow + qrcode(pip install Pillow qrcode[pil])。") |
|
|
|
label_img = render_label_to_image( |
|
|
|
b, item_code, item_name, |
|
|
|
item_id=item_id, stock_in_line_id=stock_in_line_id, |
|
|
|
lot_no=lot_no, |
|
|
|
) |
|
|
|
zpl_img = _image_to_zpl_gfa(label_img) |
|
|
|
for i in range(n): |
|
|
|
send_zpl_to_label_printer(com, zpl_img) |
|
|
|
if i < n - 1: |
|
|
|
time.sleep(0.5) |
|
|
|
msg = f"已送出列印:{n} 張標簽" if count != -1 else f"已送出列印:{n} 張標簽 (連續)" |
|
|
|
messagebox.showinfo("標簽機", msg) |
|
|
|
except Exception as err: |
|
|
|
|