|
# Bluetenshops Label Print Helper - Jestetten (Angel Green) |
|
# |
|
# Pre-configured for: |
|
# Shop: https://jestetten.angelgreen.de |
|
# PDF: DYMO LabelWriter Wireless (product labels via /print-pdf) |
|
# ZPL: Zebra ZD410 (mini labels via /print) |
|
# Windows: 10/11 |
|
# |
|
# Tiny HTTP listener on localhost that supports: |
|
# POST /print - body = ZPL -> raw to the Zebra ZD410 (mini labels) |
|
# POST /print-pdf - body = PDF -> silent print to the Dymo (product labels) |
|
# GET /status - health check |
|
# |
|
# Run: |
|
# powershell -ExecutionPolicy Bypass -File label-print-helper.ps1 |
|
# |
|
# Auto-start on login is handled by install-helper.ps1 (Startup shortcut). |
|
|
|
$ErrorActionPreference = 'Stop' |
|
|
|
$Port = 9100 |
|
$ZplPrinterName = 'Zebra ZD410' # ZPL printer for /print (verify with: Get-Printer) |
|
$PdfPrinterName = 'DYMO LabelWriter Wireless' # PDF printer for /print-pdf (verify with: Get-Printer) |
|
$AllowOrigin = 'https://jestetten.angelgreen.de' |
|
|
|
# Locate a silent-print PDF engine. SumatraPDF (free, https://www.sumatrapdfreader.org/) |
|
# is preferred - lightweight, native silent print. Adobe Acrobat / Reader is a fallback. |
|
# This is called at startup AND on each /print-pdf request, so installing Sumatra |
|
# while the helper is already running just works on the next print attempt. |
|
function Find-PdfEngine { |
|
$sumatraCandidates = @( |
|
(Join-Path $env:LOCALAPPDATA 'SumatraPDF\SumatraPDF.exe'), |
|
(Join-Path $env:LOCALAPPDATA 'Programs\SumatraPDF\SumatraPDF.exe'), |
|
(Join-Path $env:LOCALAPPDATA 'Programs\SumatraPDF\SumatraPDF-3.exe'), |
|
(Join-Path $env:ProgramFiles 'SumatraPDF\SumatraPDF.exe'), |
|
(Join-Path ${env:ProgramFiles(x86)} 'SumatraPDF\SumatraPDF.exe') |
|
) |
|
foreach ($p in $sumatraCandidates) { |
|
if ($p -and (Test-Path $p)) { return @{ Path = $p; Type = 'sumatra' } } |
|
} |
|
# PATH fallback - covers any non-standard install location |
|
$cmd = Get-Command 'SumatraPDF.exe' -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 |
|
if ($cmd) { return @{ Path = $cmd.Source; Type = 'sumatra' } } |
|
|
|
$acrobatCandidates = @( |
|
'C:\Program Files\Adobe\Acrobat DC\Acrobat\Acrobat.exe', |
|
'C:\Program Files (x86)\Adobe\Acrobat Reader DC\Reader\AcroRd32.exe', |
|
'C:\Program Files\Adobe\Acrobat Reader DC\Reader\AcroRd32.exe' |
|
) |
|
foreach ($p in $acrobatCandidates) { |
|
if (Test-Path $p) { return @{ Path = $p; Type = 'acrobat' } } |
|
} |
|
return $null |
|
} |
|
|
|
$PdfEngine = Find-PdfEngine |
|
$AcrobatExe = if ($PdfEngine) { $PdfEngine.Path } else { $null } # legacy alias |
|
|
|
# --- Raw printer P/Invoke (winspool.drv) --------------------------------- |
|
Add-Type -TypeDefinition @" |
|
using System; |
|
using System.Runtime.InteropServices; |
|
|
|
public class RawPrinter { |
|
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] |
|
public class DOCINFO { |
|
[MarshalAs(UnmanagedType.LPWStr)] public string pDocName; |
|
[MarshalAs(UnmanagedType.LPWStr)] public string pOutputFile; |
|
[MarshalAs(UnmanagedType.LPWStr)] public string pDataType; |
|
} |
|
[DllImport("winspool.Drv", EntryPoint="OpenPrinterW", SetLastError=true, CharSet=CharSet.Unicode, ExactSpelling=true, CallingConvention=CallingConvention.StdCall)] public static extern bool OpenPrinter(string p, out IntPtr h, IntPtr pd); |
|
[DllImport("winspool.Drv", EntryPoint="ClosePrinter", SetLastError=true, ExactSpelling=true, CallingConvention=CallingConvention.StdCall)] public static extern bool ClosePrinter(IntPtr h); |
|
[DllImport("winspool.Drv", EntryPoint="StartDocPrinterW", SetLastError=true, CharSet=CharSet.Unicode, ExactSpelling=true, CallingConvention=CallingConvention.StdCall)] public static extern bool StartDocPrinter(IntPtr h, Int32 lvl, [In, MarshalAs(UnmanagedType.LPStruct)] DOCINFO di); |
|
[DllImport("winspool.Drv", EntryPoint="EndDocPrinter", SetLastError=true, ExactSpelling=true, CallingConvention=CallingConvention.StdCall)] public static extern bool EndDocPrinter(IntPtr h); |
|
[DllImport("winspool.Drv", EntryPoint="StartPagePrinter", SetLastError=true, ExactSpelling=true, CallingConvention=CallingConvention.StdCall)] public static extern bool StartPagePrinter(IntPtr h); |
|
[DllImport("winspool.Drv", EntryPoint="EndPagePrinter", SetLastError=true, ExactSpelling=true, CallingConvention=CallingConvention.StdCall)] public static extern bool EndPagePrinter(IntPtr h); |
|
[DllImport("winspool.Drv", EntryPoint="WritePrinter", SetLastError=true, ExactSpelling=true, CallingConvention=CallingConvention.StdCall)] public static extern bool WritePrinter(IntPtr h, IntPtr buf, Int32 cnt, out Int32 written); |
|
|
|
public static bool Send(string printer, byte[] bytes) { |
|
IntPtr h; |
|
var di = new DOCINFO { pDocName = "Mini Label", pDataType = "RAW" }; |
|
if (!OpenPrinter(printer, out h, IntPtr.Zero)) return false; |
|
try { |
|
if (!StartDocPrinter(h, 1, di)) return false; |
|
try { |
|
if (!StartPagePrinter(h)) return false; |
|
IntPtr buf = Marshal.AllocCoTaskMem(bytes.Length); |
|
try { |
|
Marshal.Copy(bytes, 0, buf, bytes.Length); |
|
Int32 written; |
|
if (!WritePrinter(h, buf, bytes.Length, out written)) return false; |
|
} finally { Marshal.FreeCoTaskMem(buf); } |
|
EndPagePrinter(h); |
|
} finally { EndDocPrinter(h); } |
|
} finally { ClosePrinter(h); } |
|
return true; |
|
} |
|
} |
|
"@ |
|
|
|
# --- HTTP listener ------------------------------------------------------- |
|
$listener = New-Object System.Net.HttpListener |
|
$listener.Prefixes.Add("http://localhost:$Port/") |
|
$listener.Prefixes.Add("http://127.0.0.1:$Port/") |
|
$listener.Start() |
|
Write-Host "Label helper listening on http://localhost:$Port" |
|
Write-Host " ZPL printer: $ZplPrinterName" |
|
Write-Host " PDF printer: $PdfPrinterName (via $(if ($PdfEngine) { "$($PdfEngine.Type): $(Split-Path -Leaf $PdfEngine.Path)" } else { 'NO PDF ENGINE FOUND - install SumatraPDF or Acrobat Reader' }))" |
|
|
|
function Set-Cors($resp) { |
|
$resp.Headers.Add('Access-Control-Allow-Origin', $AllowOrigin) |
|
$resp.Headers.Add('Access-Control-Allow-Methods', 'POST, GET, OPTIONS') |
|
$resp.Headers.Add('Access-Control-Allow-Headers', 'Content-Type') |
|
$resp.Headers.Add('Vary', 'Origin') |
|
} |
|
|
|
function Send-Text($resp, $code, $text) { |
|
$resp.StatusCode = $code |
|
$resp.ContentType = 'text/plain; charset=utf-8' |
|
$bytes = [Text.Encoding]::UTF8.GetBytes($text) |
|
$resp.ContentLength64 = $bytes.Length |
|
$resp.OutputStream.Write($bytes, 0, $bytes.Length) |
|
$resp.OutputStream.Close() |
|
} |
|
|
|
while ($listener.IsListening) { |
|
try { |
|
$ctx = $listener.GetContext() |
|
$req = $ctx.Request |
|
$resp = $ctx.Response |
|
Set-Cors $resp |
|
|
|
# CORS preflight |
|
if ($req.HttpMethod -eq 'OPTIONS') { |
|
$resp.StatusCode = 204 |
|
$resp.Close() |
|
continue |
|
} |
|
|
|
# Health check - used by the dialog to detect the helper |
|
if ($req.Url.AbsolutePath -eq '/status' -and $req.HttpMethod -eq 'GET') { |
|
if (-not $PdfEngine) { $PdfEngine = Find-PdfEngine } # late-installed Sumatra/Acrobat picked up here |
|
$pdfReady = if ($PdfEngine) { 'yes' } else { 'no' } |
|
# Local must NOT case-collide with the script-scope $PdfEngine hashtable; |
|
# PowerShell 5.1 treats $pdfEngine and $PdfEngine as the SAME variable, and |
|
# the earlier `$pdfEngine = ...Type` would clobber $PdfEngine with a string, |
|
# making the very next /print-pdf call hit Start-Process -FilePath $null. |
|
# See bluetenshops/scripts/README.md "PS 5.1 case-insensitivity trap". |
|
$pdfEngineLabel = if ($PdfEngine) { $PdfEngine.Type } else { 'none' } |
|
Send-Text $resp 200 "ok zpl=$ZplPrinterName pdf=$PdfPrinterName pdfReady=$pdfReady pdfEngine=$pdfEngineLabel" |
|
continue |
|
} |
|
|
|
# ZPL print endpoint (Zebra ZD410, mini labels) |
|
if ($req.Url.AbsolutePath -eq '/print' -and $req.HttpMethod -eq 'POST') { |
|
$reader = New-Object IO.StreamReader $req.InputStream, ([Text.Encoding]::UTF8) |
|
$zpl = $reader.ReadToEnd() |
|
$reader.Close() |
|
if (-not $zpl) { Send-Text $resp 400 'empty body'; continue } |
|
# Override printer with ?printer=... if provided (e.g. ZD421 alongside ZD410) |
|
$targetZplPrinter = $req.QueryString['printer'] |
|
if (-not $targetZplPrinter) { $targetZplPrinter = $ZplPrinterName } |
|
if (-not $targetZplPrinter) { Send-Text $resp 500 'no zpl printer configured'; continue } |
|
$bytes = [Text.Encoding]::ASCII.GetBytes($zpl) |
|
$ok = [RawPrinter]::Send($targetZplPrinter, $bytes) |
|
if ($ok) { |
|
Write-Host ("[{0}] ZPL printed {1} bytes -> {2}" -f (Get-Date -Format HH:mm:ss), $bytes.Length, $targetZplPrinter) |
|
Send-Text $resp 200 'printed' |
|
} else { |
|
Write-Host ("[{0}] ZPL FAILED - printer offline?" -f (Get-Date -Format HH:mm:ss)) |
|
Send-Text $resp 500 'print failed' |
|
} |
|
continue |
|
} |
|
|
|
# PDF print endpoint (Dymo) |
|
if ($req.Url.AbsolutePath -eq '/print-pdf' -and $req.HttpMethod -eq 'POST') { |
|
if (-not $PdfEngine) { $PdfEngine = Find-PdfEngine } # rescan in case Sumatra/Acrobat got installed after the helper started |
|
if (-not $PdfEngine) { Send-Text $resp 500 'no pdf engine installed (install SumatraPDF or Acrobat Reader)'; continue } |
|
# Override printer with ?printer=... if provided |
|
$targetPrinter = $req.QueryString['printer'] |
|
if (-not $targetPrinter) { $targetPrinter = $PdfPrinterName } |
|
|
|
# Read PDF bytes from request body to a temp file |
|
$tmpPdf = Join-Path $env:TEMP ("bs-label-" + [Guid]::NewGuid().ToString() + ".pdf") |
|
$fs = [IO.File]::Create($tmpPdf) |
|
$req.InputStream.CopyTo($fs) |
|
$fs.Close() |
|
$size = (Get-Item $tmpPdf).Length |
|
if ($size -lt 100) { |
|
Remove-Item $tmpPdf -Force -ErrorAction SilentlyContinue |
|
Send-Text $resp 400 'pdf too small' |
|
continue |
|
} |
|
|
|
try { |
|
if ($PdfEngine.Type -eq 'sumatra') { |
|
# SumatraPDF: -print-to "Printer" -silent "file.pdf" |
|
$argString = "-print-to `"$targetPrinter`" -silent `"$tmpPdf`"" |
|
} else { |
|
# Acrobat: /n = new instance, /t = silent print to named printer, then close |
|
$argString = "/n /t `"$tmpPdf`" `"$targetPrinter`"" |
|
} |
|
$proc = Start-Process -FilePath $PdfEngine.Path ` |
|
-ArgumentList $argString ` |
|
-PassThru -WindowStyle Hidden |
|
# Engine should exit on its own; give it up to 30 s |
|
if (-not $proc.WaitForExit(30000)) { |
|
$proc.Kill() |
|
Write-Host ("[{0}] PDF TIMEOUT killed {1}" -f (Get-Date -Format HH:mm:ss), $PdfEngine.Type) |
|
Send-Text $resp 500 "$($PdfEngine.Type) timeout" |
|
continue |
|
} |
|
Write-Host ("[{0}] PDF printed {1} bytes -> {2} (via {3})" -f (Get-Date -Format HH:mm:ss), $size, $targetPrinter, $PdfEngine.Type) |
|
Send-Text $resp 200 'printed' |
|
} finally { |
|
Remove-Item $tmpPdf -Force -ErrorAction SilentlyContinue |
|
} |
|
continue |
|
} |
|
|
|
Send-Text $resp 404 'not found' |
|
} catch { |
|
Write-Host "Error: $_" |
|
} |
|
} |