Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.5.0
1.5.1
37 changes: 33 additions & 4 deletions pi/backend/app/escpos_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import base64
import logging
import os
import unicodedata
from collections.abc import Callable
from contextvars import ContextVar
from io import BytesIO
Expand All @@ -18,12 +19,13 @@

log = logging.getLogger(__name__)

ReceiptCharset = Literal["pc858", "pc850", "cp1252"]
ReceiptCharset = Literal["pc858", "pc850", "cp1252", "ascii"]

RECEIPT_CHARSET_PRESETS: dict[str, tuple[str, int]] = {
"pc858": ("cp858", 19),
"pc850": ("cp850", 2),
"cp1252": ("cp1252", 16),
"ascii": ("ascii", 0),
}

_slip_charset: ContextVar[str | None] = ContextVar("slip_charset", default=None)
Expand Down Expand Up @@ -119,13 +121,40 @@ def build_cash_drawer_kick(command: str) -> bytes:
return escpos_init_preamble() + kick


def transliterate_receipt_text(text: str) -> str:
"""Fold umlauts and accents to ASCII for printers that ignore code pages."""
replacements = {
"ä": "ae",
"ö": "oe",
"ü": "ue",
"Ä": "Ae",
"Ö": "Oe",
"Ü": "Ue",
"ß": "ss",
}
out: list[str] = []
for ch in str(text):
if ch in replacements:
out.append(replacements[ch])
continue
decomposed = unicodedata.normalize("NFD", ch)
base = "".join(c for c in decomposed if unicodedata.category(c) != "Mn")
out.append(base)
return "".join(out)


def encode_escpos_text(text: str, *, charset: str | None = None) -> bytes:
encoding, _ = resolve_escpos_charset(charset)
return str(text).encode(encoding, errors="replace")
payload = transliterate_receipt_text(text) if encoding == "ascii" else str(text)
return payload.encode(encoding, errors="replace")


def write_escpos_text(printer: Dummy, text: str, *, newline: bool = True) -> None:
printer._raw(encode_escpos_text(text + ("\n" if newline else "")))
def write_escpos_text(printer: Dummy, text: str, *, newline: bool = True, charset: str | None = None) -> None:
_, codepage = resolve_escpos_charset(charset)
body = str(text) + ("\n" if newline else "")
printer._raw(
bytes([0x1B, 0x74, codepage & 0xFF]) + encode_escpos_text(body, charset=charset)
)


def finish_slip(printer: Dummy, *, feed_lines: int = 1, charset: str | None = None) -> bytes:
Expand Down
2 changes: 1 addition & 1 deletion pi/backend/app/schemas/edge.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
)

ReceiptPaperWidth = Literal["80mm", "58mm", "53mm"]
ReceiptCharset = Literal["pc858", "pc850", "cp1252"]
ReceiptCharset = Literal["pc858", "pc850", "cp1252", "ascii"]


# --- Request bodies ---
Expand Down
60 changes: 59 additions & 1 deletion pi/backend/tests/test_escpos_encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,19 @@
escpos_init_preamble,
render_slip,
resolve_escpos_charset,
transliterate_receipt_text,
write_line,
)
from app.print_worker import build_escpos_station_test_slip, build_payment_receipt_text


def _assert_esc_t_before(body: bytes, needle: bytes, *, codepage: int) -> None:
idx = body.find(needle)
assert idx >= 3, f"{needle!r} not found in slip body"
assert body[idx - 3 : idx - 1] == b"\x1b\x74"
assert body[idx - 1] == codepage


def test_escpos_text_uses_cp858_single_byte():
for ch in ("ä", "é", "è", "î", "ç"):
encoded = encode_escpos_text(ch)
Expand All @@ -29,14 +37,43 @@ def test_resolve_receipt_charset_presets():
assert resolve_escpos_charset("pc858") == ("cp858", 19)
assert resolve_escpos_charset("pc850") == ("cp850", 2)
assert resolve_escpos_charset("cp1252") == ("cp1252", 16)
assert set(RECEIPT_CHARSET_PRESETS) == {"pc858", "pc850", "cp1252"}
assert resolve_escpos_charset("ascii") == ("ascii", 0)
assert set(RECEIPT_CHARSET_PRESETS) == {"pc858", "pc850", "cp1252", "ascii"}


def test_encode_escpos_text_honors_charset_preset():
assert encode_escpos_text("ä", charset="pc850") == "ä".encode("cp850")
assert encode_escpos_text("ä", charset="cp1252") == "ä".encode("cp1252")


def test_write_escpos_text_prepends_esc_t_codepage():
raw = render_slip(lambda p: write_line(p, "Hello"))
pre = escpos_init_preamble()
body = raw[len(pre) :]
_assert_esc_t_before(body, b"Hello", codepage=19)


def test_render_slip_esc_t_before_each_text_line():
def render(p):
write_line(p, "Line one")
write_line(p, "Line two")

raw = render_slip(render, charset="pc850")
pre = escpos_init_preamble(charset="pc850")
body = raw[len(pre) :]
_assert_esc_t_before(body, b"Line one", codepage=2)
_assert_esc_t_before(body, b"Line two", codepage=2)


def test_render_slip_text_only_has_esc_t_before_text():
"""Regression: text-only slips must still select code page before each line."""
raw = render_slip(lambda p: (write_line(p, "Testdruck"), write_line(p, "Danke")))
pre = escpos_init_preamble()
body = raw[len(pre) :]
_assert_esc_t_before(body, b"Testdruck", codepage=19)
_assert_esc_t_before(body, b"Danke", codepage=19)


def test_render_slip_body_avoids_esc_t_pc437_reset():
raw = render_slip(lambda p: write_line(p, "Größe ä"))
pre = escpos_init_preamble()
Expand Down Expand Up @@ -116,6 +153,27 @@ def test_payment_receipt_test_charset_banner():
assert "Ää Öö Üü ß Éé Èè Îî Çç" in text


def test_transliterate_receipt_text_german():
assert transliterate_receipt_text("Größe") == "Groesse"
assert transliterate_receipt_text("Zürich") == "Zuerich"
assert transliterate_receipt_text("Äpfel") == "Aepfel"
assert transliterate_receipt_text("Straße") == "Strasse"
assert transliterate_receipt_text("Crème brûlée") == "Creme brulee"


def test_encode_escpos_text_ascii_transliterates():
assert encode_escpos_text("Größe", charset="ascii") == b"Groesse"
assert encode_escpos_text("Zürich", charset="ascii") == b"Zuerich"


def test_render_slip_ascii_charset_transliterates():
raw = render_slip(lambda p: write_line(p, "Zürich"), charset="ascii")
pre = escpos_init_preamble(charset="ascii")
body = raw[len(pre) :]
assert b"Zuerich" in body
_assert_esc_t_before(body, b"Zuerich", codepage=0)


def test_payment_receipt_charset_pc850():
slip = build_payment_receipt_text(
{
Expand Down
6 changes: 5 additions & 1 deletion pi/backend/tests/test_escpos_paper_width.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ def _decode_receipt(raw: bytes) -> str:


def _strip_esc(text: str) -> str:
cleaned = re.sub(r"\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]", "", text)
cleaned = re.sub(
r"\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]|\x1bt.",
"",
text,
)
return "".join(c for c in cleaned if c.isprintable() or c in " \t\n\r")


Expand Down
6 changes: 6 additions & 0 deletions pi/backend/tests/test_escpos_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,9 @@ def render(p):
raw = render_slip(render)
assert b"After logo" in raw
assert len(raw) > 40
pre = escpos_init_preamble()
body = raw[len(pre) :]
idx = body.find(b"After logo")
assert idx >= 3
assert body[idx - 3 : idx - 1] == b"\x1b\x74"
assert body[idx - 1] == 19
6 changes: 5 additions & 1 deletion pi/backend/tests/test_print_worker_receipt_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,11 @@ def test_payment_receipt_currency_only_on_total():

def test_payment_receipt_payment_line_right_aligned():
def strip_esc(s: str) -> str:
cleaned = re.sub(r"\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]", "", s)
cleaned = re.sub(
r"\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]|\x1bt.",
"",
s,
)
return "".join(c for c in cleaned if c.isprintable() or c in " \t\n\r")

raw = _payment_receipt_sample()
Expand Down
12 changes: 8 additions & 4 deletions pi/frontend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4634,7 +4634,8 @@
"enum": [
"pc858",
"pc850",
"cp1252"
"cp1252",
"ascii"
]
},
{
Expand Down Expand Up @@ -5115,7 +5116,8 @@
"enum": [
"pc858",
"pc850",
"cp1252"
"cp1252",
"ascii"
]
},
{
Expand Down Expand Up @@ -5346,7 +5348,8 @@
"enum": [
"pc858",
"pc850",
"cp1252"
"cp1252",
"ascii"
]
},
{
Expand Down Expand Up @@ -5567,7 +5570,8 @@
"enum": [
"pc858",
"pc850",
"cp1252"
"cp1252",
"ascii"
]
},
{
Expand Down
8 changes: 4 additions & 4 deletions pi/frontend/src/types/api.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1664,7 +1664,7 @@ export interface components {
/** Paper Width */
paper_width?: ("80mm" | "58mm" | "53mm") | null;
/** Charset */
charset?: ("pc858" | "pc850" | "cp1252") | null;
charset?: ("pc858" | "pc850" | "cp1252" | "ascii") | null;
};
/** PaymentReceiptEscposResponse */
PaymentReceiptEscposResponse: {
Expand Down Expand Up @@ -1818,7 +1818,7 @@ export interface components {
/** Paper Width */
paper_width?: ("80mm" | "58mm" | "53mm") | null;
/** Charset */
charset?: ("pc858" | "pc850" | "cp1252") | null;
charset?: ("pc858" | "pc850" | "cp1252" | "ascii") | null;
};
/** PrinterTestStationPrintsBody */
PrinterTestStationPrintsBody: {
Expand Down Expand Up @@ -1896,7 +1896,7 @@ export interface components {
/** Paper Width */
paper_width?: ("80mm" | "58mm" | "53mm") | null;
/** Charset */
charset?: ("pc858" | "pc850" | "cp1252") | null;
charset?: ("pc858" | "pc850" | "cp1252" | "ascii") | null;
};
/** ShiftSessionEscposResponse */
ShiftSessionEscposResponse: {
Expand Down Expand Up @@ -1965,7 +1965,7 @@ export interface components {
/** Paper Width */
paper_width?: ("80mm" | "58mm" | "53mm") | null;
/** Charset */
charset?: ("pc858" | "pc850" | "cp1252") | null;
charset?: ("pc858" | "pc850" | "cp1252" | "ascii") | null;
};
/** StationTestPrintResult */
StationTestPrintResult: {
Expand Down
3 changes: 3 additions & 0 deletions pi/frontend/src/utils/receiptCharset.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
CHARSET_ASCII,
CHARSET_PC850,
CHARSET_PC858,
getReceiptCharset,
Expand Down Expand Up @@ -32,6 +33,8 @@ describe('receiptCharset', () => {
it('persists valid charset', () => {
setReceiptCharset(CHARSET_PC850)
expect(getReceiptCharset()).toBe(CHARSET_PC850)
setReceiptCharset(CHARSET_ASCII)
expect(getReceiptCharset()).toBe(CHARSET_ASCII)
})

it('falls back to pc858 for invalid values', () => {
Expand Down
2 changes: 2 additions & 0 deletions pi/frontend/src/utils/receiptCharset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ export const RECEIPT_CHARSET_STORAGE_KEY = 'pi_receipt_charset'
export const CHARSET_PC858 = 'pc858'
export const CHARSET_PC850 = 'pc850'
export const CHARSET_CP1252 = 'cp1252'
export const CHARSET_ASCII = 'ascii'

export const RECEIPT_CHARSET_OPTIONS = [
{ value: CHARSET_PC858, label: 'Standard (PC858)' },
{ value: CHARSET_PC850, label: 'PC850 (viele Bluetooth-Drucker)' },
{ value: CHARSET_CP1252, label: 'Windows-1252 (PC1252)' },
{ value: CHARSET_ASCII, label: 'ASCII (ae/oe/ue)' },
] as const

export type ReceiptCharset = (typeof RECEIPT_CHARSET_OPTIONS)[number]['value']
Expand Down
Loading