diff --git a/src/escpos/escpos.py b/src/escpos/escpos.py index fc00e713..2b7c8d6e 100644 --- a/src/escpos/escpos.py +++ b/src/escpos/escpos.py @@ -135,6 +135,8 @@ def __init__(self, profile=None, magic_encode_args=None, **kwargs) -> None: """ self.profile = get_profile(profile) self.magic = MagicEncode(self, **(magic_encode_args or {})) + # Track the value of the current font. + self._font = None def __del__(self): """Call self.close upon deletion.""" @@ -1127,8 +1129,12 @@ 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 + # 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]) @@ -1335,6 +1341,10 @@ def hw(self, hw: str) -> None: """ if hw.upper() == "INIT": self._raw(HW_INIT) + # 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": self._raw(HW_SELECT) elif hw.upper() == "RESET": diff --git a/src/escpos/magicencode.py b/src/escpos/magicencode.py index f968e834..7a906d4d 100644 --- a/src/escpos/magicencode.py +++ b/src/escpos/magicencode.py @@ -240,6 +240,20 @@ 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. + + See https://github.com/python-escpos/python-escpos/pull/729 + """ + self.encoding = None + self.encoder.used_encodings.clear() + def force_encoding(self, encoding): """Set a fixed encoding. The change is emitted right away. diff --git a/test/test_magicencode.py b/test/test_magicencode.py index 6c7b8492..25d08129 100644 --- a/test/test_magicencode.py +++ b/test/test_magicencode.py @@ -111,6 +111,112 @@ 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. + 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 + 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" + + 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: