From ab15511889e1cf091454a2b1122784d8c2bec226 Mon Sep 17 00:00:00 2001 From: Lars Blumberg Date: Sat, 21 Mar 2026 12:46:23 +0100 Subject: [PATCH 1/5] fix: reset MagicEncode after font switch (ESC M) and hw init (ESC @) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some printers (confirmed: NT-5890K) silently reset their active code page back to the factory default after a font switch (`ESC M n`). `MagicEncode` was unaware of this hardware-side reset: its cached `self.encoding` remained stale, causing subsequent `text()` calls to skip `CODEPAGE_CHANGE`. Non-ASCII characters were then sent in the previously-active encoding (e.g. CP1257) but interpreted by the printer under its default code page, producing garbled output. `ESC @` (`hw("INIT")`) is defined in the ESC/POS spec as a full printer reset that restores all settings to factory defaults, including the active code page — so the same fix applies there by spec. Fix: add `MagicEncode.reset_encoding()` which clears both `self.encoding` and `self.encoder.used_encodings`, and call it from `set()` after every font change and from `hw()` after `INIT`. This forces a fresh code page selection and a `CODEPAGE_CHANGE` re-emission before the next text output. Why `used_encodings` must also be cleared: `self.encoding = None` is enough to ensure a `CODEPAGE_CHANGE` is emitted. However, `used_encodings` biases `find_suitable_encoding()` toward previously- used code pages. After a reset, that preference is stale: on NT-5890K the previously-used CP1257 does not function correctly after `ESC M`, so `MagicEncode` would re-select it, emit `CODEPAGE_CHANGE` → CP1257, and send e.g. `ü` as `0xFC` — wrong in the printer's default code page. Clearing `used_encodings` removes the stale bias and lets slot-number ordering take over, landing on CP850 where `ü` = `0x81`, a byte that is correct in virtually every Western code page regardless of whether the printer honours the code page switch. Follow-up: `used_encodings` could be removed from `MagicEncode` entirely. `self.encoding` is the mechanism that actually avoids redundant switches: it keeps the current code page as long as it can encode the next character, without consulting `used_encodings` at all. `used_encodings` is only consulted when a switch is already unavoidable — at which point it cannot prevent any switch, it can only gamble on which encoding might be needed again later. That saves at most one future switch in the rare case where the same non-default code page is needed again after having been forced away. Removing `used_encodings` would also make `reset_encoding()` unnecessary: with only `self.encoding` to clear, callers would just write `self.magic.encoding = None` directly, with no need for a helper method. Co-Authored-By: Claude Sonnet 4.6 --- src/escpos/escpos.py | 9 +++++++++ src/escpos/magicencode.py | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/escpos/escpos.py b/src/escpos/escpos.py index fc00e713..ed2ccccc 100644 --- a/src/escpos/escpos.py +++ b/src/escpos/escpos.py @@ -1129,6 +1129,10 @@ def set( self._raw(TXT_STYLE["underline"][underline]) if font is not None: self._raw(SET_FONT(six.int2byte(self.profile.get_font(font)))) + # Some printers (confirmed: NT-5890K) reset their active code page + # when switching fonts (ESC M). Invalidate the cached encoding so + # the next text() call re-emits CODEPAGE_CHANGE before sending text. + self.magic.reset_encoding() if align is not None: self._raw(TXT_STYLE["align"][align]) @@ -1335,6 +1339,11 @@ def hw(self, hw: str) -> None: """ if hw.upper() == "INIT": self._raw(HW_INIT) + # ESC @ is defined in the ESC/POS spec as a full printer reset that + # restores all settings to factory defaults, including the active + # code page. Invalidate the cached encoding so the next text() call + # re-emits CODEPAGE_CHANGE rather than silently sending the wrong bytes. + self.magic.reset_encoding() elif hw.upper() == "SELECT": self._raw(HW_SELECT) elif hw.upper() == "RESET": diff --git a/src/escpos/magicencode.py b/src/escpos/magicencode.py index f968e834..10ba792c 100644 --- a/src/escpos/magicencode.py +++ b/src/escpos/magicencode.py @@ -240,6 +240,18 @@ def __init__( self.defaultsymbol = defaultsymbol self.disabled = disabled + def reset_encoding(self): + """Invalidate cached encoding state after a printer-side code page reset. + + Some printers silently reset their active code page after certain + commands (e.g. image rendering, font switches, hardware init). + Calling this method discards both the cached current encoding and the + set of previously-used encodings so that the next write() call + performs a fresh code page selection and re-emits CODEPAGE_CHANGE. + """ + self.encoding = None + self.encoder.used_encodings.clear() + def force_encoding(self, encoding): """Set a fixed encoding. The change is emitted right away. From f6f2dbf99e9f25dda45600465d88db87be45915e Mon Sep 17 00:00:00 2001 From: Lars Blumberg Date: Sat, 21 Mar 2026 14:27:07 +0100 Subject: [PATCH 2/5] test: add TestResetEncoding tests for MagicEncode.reset_encoding() Covers the four key behaviours introduced by the fix: - reset_encoding() sets self.encoding to None - reset_encoding() clears encoder.used_encodings - the next write after reset always re-emits CODEPAGE_CHANGE (even for the same encoding that was active before the reset) - after clearing used_encodings, find_suitable_encoding picks the lowest-slot encoding rather than the previously-used high-slot one (the exact scenario that caused the NT-5890K umlaut bug) --- test/test_magicencode.py | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/test/test_magicencode.py b/test/test_magicencode.py index 6c7b8492..df72586a 100644 --- a/test/test_magicencode.py +++ b/test/test_magicencode.py @@ -111,6 +111,79 @@ def test(self, driver: printer.Dummy) -> None: encode.write("€ ist teuro.") assert driver.output == b"\x1bt\x00? ist teuro." + class TestResetEncoding: + """Tests for reset_encoding(), which discards cached encoding state. + + Some printers (e.g. NT-5890K) silently reset their active code page + after commands such as font switches (ESC M) or hardware init (ESC @). + reset_encoding() must be called after such commands so that the next + write() re-emits CODEPAGE_CHANGE rather than sending text under the + wrong code page. + """ + + def test_clears_encoding(self, driver: printer.Dummy) -> None: + """reset_encoding sets self.encoding to None.""" + encode = MagicEncode(driver, encoding="CP437") + encode.reset_encoding() + assert encode.encoding is None + + def test_clears_used_encodings(self, driver: printer.Dummy) -> None: + """reset_encoding empties the used_encodings set.""" + encode = MagicEncode(driver) + encode.write("€") # causes an encoding to be recorded in used_encodings + assert len(encode.encoder.used_encodings) > 0 + encode.reset_encoding() + assert encode.encoder.used_encodings == set() + + def test_next_write_reemits_codepage_change(self, driver: printer.Dummy) -> None: + """After reset_encoding, the next write always emits CODEPAGE_CHANGE. + + Without reset, write_with_encoding skips the change command when + the target encoding is already active. After reset the cached + encoding is None, so the change must be re-emitted even if the + same encoding is chosen again. + """ + encode = MagicEncode(driver, encoding="CP858") + + # CP858 already "active" — no CODEPAGE_CHANGE emitted for plain ASCII + encode.write_with_encoding("CP858", "a") + assert driver.output == b"a" + + encode.reset_encoding() + encode.write_with_encoding("CP858", "a") + # CODEPAGE_CHANGE (\x1bt) + slot 19 (\x13) must precede the character + assert driver.output == b"a\x1bt\x13a" + + def test_reselects_encoding_by_slot_not_history(self, driver: printer.Dummy) -> None: + """After reset, find_suitable_encoding ignores used_encodings history. + + Without clearing used_encodings, find_suitable_encoding prefers + previously-used code pages (even high-slot ones) over lower-slot + alternatives. This caused the NT-5890K bug: € forced CP1257 + (slot 25) into used_encodings; after a font switch the printer + reset its code page, but MagicEncode kept sending ü bytes encoded + for CP1257 without re-emitting CODEPAGE_CHANGE — the printer read + them against its reset code page and printed garbage. + + After reset_encoding(), used_encodings is empty, so the sort in + find_suitable_encoding falls back to slot order and picks the + lowest-slot encoding that covers the character. + """ + # Two encodings that can both encode ü; CP850 has the lower slot. + encoder = Encoder({"CP850": 2, "CP858": 19}) + encode = MagicEncode(driver, encoder=encoder) + + # Simulate state left behind after printing € (CP858 was used) + encode.encoder.used_encodings.add("CP858") + encode.encoding = "CP858" + + # Without reset: history bias picks CP858 (previously used) + assert encode.encoder.find_suitable_encoding("ü") == "CP858" + + # After reset: slot order wins — CP850 (slot 2) beats CP858 (slot 19) + encode.reset_encoding() + assert encode.encoder.find_suitable_encoding("ü") == "CP850" + jaconv: typing.Optional[types.ModuleType] try: From 052a8f4791e3fbc8fed3fb9d2ec61d3290b39a40 Mon Sep 17 00:00:00 2001 From: Lars Blumberg Date: Sat, 21 Mar 2026 14:28:54 +0100 Subject: [PATCH 3/5] docs: add PR #729 link to reset_encoding comments and docstring --- src/escpos/magicencode.py | 2 ++ test/test_magicencode.py | 1 + 2 files changed, 3 insertions(+) diff --git a/src/escpos/magicencode.py b/src/escpos/magicencode.py index 10ba792c..7a906d4d 100644 --- a/src/escpos/magicencode.py +++ b/src/escpos/magicencode.py @@ -248,6 +248,8 @@ def reset_encoding(self): Calling this method discards both the cached current encoding and the set of previously-used encodings so that the next write() call performs a fresh code page selection and re-emits CODEPAGE_CHANGE. + + See https://github.com/python-escpos/python-escpos/pull/729 """ self.encoding = None self.encoder.used_encodings.clear() diff --git a/test/test_magicencode.py b/test/test_magicencode.py index df72586a..fa939ee0 100644 --- a/test/test_magicencode.py +++ b/test/test_magicencode.py @@ -164,6 +164,7 @@ def test_reselects_encoding_by_slot_not_history(self, driver: printer.Dummy) -> reset its code page, but MagicEncode kept sending ü bytes encoded for CP1257 without re-emitting CODEPAGE_CHANGE — the printer read them against its reset code page and printed garbage. + See https://github.com/python-escpos/python-escpos/pull/729 After reset_encoding(), used_encodings is empty, so the sort in find_suitable_encoding falls back to slot order and picks the From 729aa4959fa5d4b85f520a4bfce0ad1ca106670e Mon Sep 17 00:00:00 2001 From: Lars Blumberg Date: Wed, 22 Apr 2026 11:15:19 +0100 Subject: [PATCH 4/5] fix: only reset encoding on actual font change, not every set_with_default() set_with_default() always passes font="a" to set(), which triggered reset_encoding() even when the font hadn't changed. Track _font state so reset only fires on actual font switches. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/escpos/escpos.py | 8 +++++++- test/test_magicencode.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/escpos/escpos.py b/src/escpos/escpos.py index ed2ccccc..9990f7d1 100644 --- a/src/escpos/escpos.py +++ b/src/escpos/escpos.py @@ -135,6 +135,9 @@ def __init__(self, profile=None, magic_encode_args=None, **kwargs) -> None: """ self.profile = get_profile(profile) self.magic = MagicEncode(self, **(magic_encode_args or {})) + # Last font sent via ESC M; lets set() skip redundant font commands + # and avoid spurious reset_encoding() from set_with_default(). (#729) + self._font = None def __del__(self): """Call self.close upon deletion.""" @@ -1127,11 +1130,13 @@ def set( self._raw(TXT_STYLE["bold"][bold]) if underline is not None: self._raw(TXT_STYLE["underline"][underline]) - if font is not None: + if font is not None and font != self._font: self._raw(SET_FONT(six.int2byte(self.profile.get_font(font)))) + self._font = font # Some printers (confirmed: NT-5890K) reset their active code page # when switching fonts (ESC M). Invalidate the cached encoding so # the next text() call re-emits CODEPAGE_CHANGE before sending text. + # See https://github.com/python-escpos/python-escpos/pull/729 self.magic.reset_encoding() if align is not None: self._raw(TXT_STYLE["align"][align]) @@ -1344,6 +1349,7 @@ def hw(self, hw: str) -> None: # code page. Invalidate the cached encoding so the next text() call # re-emits CODEPAGE_CHANGE rather than silently sending the wrong bytes. self.magic.reset_encoding() + self._font = None elif hw.upper() == "SELECT": self._raw(HW_SELECT) elif hw.upper() == "RESET": diff --git a/test/test_magicencode.py b/test/test_magicencode.py index fa939ee0..25d08129 100644 --- a/test/test_magicencode.py +++ b/test/test_magicencode.py @@ -185,6 +185,38 @@ def test_reselects_encoding_by_slot_not_history(self, driver: printer.Dummy) -> encode.reset_encoding() assert encode.encoder.find_suitable_encoding("ü") == "CP850" + def test_set_font_change_resets_encoding(self, driver: printer.Dummy) -> None: + """set() resets encoding when font actually changes.""" + driver.set(font="a") + driver.magic.encoding = "CP858" + driver.set(font="b") + assert driver.magic.encoding is None + + def test_set_same_font_keeps_encoding(self, driver: printer.Dummy) -> None: + """set() does NOT reset encoding when font is unchanged.""" + driver.set(font="a") + driver.magic.encoding = "CP858" + driver.set(font="a") + assert driver.magic.encoding == "CP858" + + def test_set_with_default_no_font_change_keeps_encoding(self, driver: printer.Dummy) -> None: + """set_with_default(align=...) must not reset encoding. + + set_with_default() always passes font="a" to set(), but if the + font hasn't changed, reset_encoding() must not fire. + """ + driver.set_with_default() # sets font="a" + driver.magic.encoding = "CP858" + driver.set_with_default(align="right") + assert driver.magic.encoding == "CP858" + + def test_hw_init_resets_font_tracking(self, driver: printer.Dummy) -> None: + """hw("INIT") resets _font so the next set(font=...) always fires.""" + driver.set(font="a") + assert driver._font == "a" + driver.hw("INIT") + assert driver._font is None + jaconv: typing.Optional[types.ModuleType] try: From 2779497df4dc29a1fc55a40142a0114cbb0dd8d6 Mon Sep 17 00:00:00 2001 From: Lars Blumberg Date: Fri, 24 Apr 2026 16:37:53 +0100 Subject: [PATCH 5/5] docs: condense comments per reviewer feedback Co-Authored-By: Claude Opus 4.6 (1M context) --- src/escpos/escpos.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/escpos/escpos.py b/src/escpos/escpos.py index 9990f7d1..2b7c8d6e 100644 --- a/src/escpos/escpos.py +++ b/src/escpos/escpos.py @@ -135,8 +135,7 @@ def __init__(self, profile=None, magic_encode_args=None, **kwargs) -> None: """ self.profile = get_profile(profile) self.magic = MagicEncode(self, **(magic_encode_args or {})) - # Last font sent via ESC M; lets set() skip redundant font commands - # and avoid spurious reset_encoding() from set_with_default(). (#729) + # Track the value of the current font. self._font = None def __del__(self): @@ -1133,10 +1132,8 @@ def set( if font is not None and font != self._font: self._raw(SET_FONT(six.int2byte(self.profile.get_font(font)))) self._font = font - # Some printers (confirmed: NT-5890K) reset their active code page - # when switching fonts (ESC M). Invalidate the cached encoding so - # the next text() call re-emits CODEPAGE_CHANGE before sending text. - # See https://github.com/python-escpos/python-escpos/pull/729 + # Force a fresh code page selection as required by some printer + # models (confirmed: NT-5890K). self.magic.reset_encoding() if align is not None: self._raw(TXT_STYLE["align"][align]) @@ -1344,10 +1341,8 @@ def hw(self, hw: str) -> None: """ if hw.upper() == "INIT": self._raw(HW_INIT) - # ESC @ is defined in the ESC/POS spec as a full printer reset that - # restores all settings to factory defaults, including the active - # code page. Invalidate the cached encoding so the next text() call - # re-emits CODEPAGE_CHANGE rather than silently sending the wrong bytes. + # ESC @ resets all settings including the active code page. + # Force a fresh code page selection. self.magic.reset_encoding() self._font = None elif hw.upper() == "SELECT":