//! Формирование байтов Epson/ESC/POS-команд без привязки к конкретному serial-порту. //! //! Этот модуль полезен для тестов, нестандартных транспортов и проверки того, какие //! байты будут отправлены устройству при выбранных [`DisplaySettings`]. Обычным //! приложениям чаще достаточно [`crate::Vfd`] или [`crate::VfdWorker`], но codec //! остаётся публичным для диагностики и интеграции с собственным транспортом. use crate::config::{DisplaySettings, TextEncoding}; use crate::error::{ConfigError, Result, VfdError}; use encoding_rs::{Encoding, IBM866, WINDOWS_1251}; /// Кодировщик Epson/ESC/POS-команд для выбранной геометрии и таблицы символов. /// /// `EpsonCodec` не открывает порт и не выполняет I/O. Он только валидирует координаты, /// собирает управляющие последовательности и кодирует текст в выбранный [`TextEncoding`]. #[derive(Debug, Clone)] pub struct EpsonCodec { display: DisplaySettings, } impl EpsonCodec { /// Создаёт codec для заданных настроек дисплея. /// /// Значение `display.code_table` влияет только на байты инициализации `ESC t n`; /// кодировка текста берётся из `display.encoding`. /// /// Метод валидирует геометрию и диапазоны до сохранения настроек. /// /// # Ошибки /// /// Возвращает [`ConfigError`], если настройки дисплея некорректны. pub fn new(display: DisplaySettings) -> std::result::Result { display.validate()?; Ok(Self { display }) } /// Настройки дисплея, для которых работает codec. pub fn display(&self) -> &DisplaySettings { &self.display } /// Команды инициализации при открытии. /// /// Возвращает `ESC @`, если включён `reset_on_open`, и `ESC t n`, если задана /// аппаратная таблица символов. При полностью ручной конфигурации `code_table = None` /// команда выбора таблицы не добавляется. pub fn init(&self) -> Vec { let mut out = Vec::with_capacity(5); if self.display.reset_on_open { out.extend_from_slice(&[0x1B, 0x40]); } if let Some(table) = self.display.code_table { out.extend_from_slice(&[0x1B, 0x74, table]); } out } /// Команда очистки дисплея. /// /// Возвращает один байт `0x0C`. Запись в транспорт выполняет вызывающий код. pub fn clear(&self) -> [u8; 1] { [0x0C] } /// Команда позиционирования курсора в координатах от единицы. /// /// # Ошибки /// /// Возвращает [`VfdError::InvalidCoordinate`], если `x` или `y` равны нулю либо /// выходят за `columns`/`rows`. pub fn goto_xy(&self, x: u8, y: u8) -> Result<[u8; 4]> { self.validate_xy(x, y)?; Ok([0x1F, 0x24, x, y]) } /// Команда установки яркости. /// /// # Ошибки /// /// Возвращает [`VfdError::UnsupportedBrightness`], если яркость выключена в /// конфигурации (`brightness = None`) или уровень вне настроенного диапазона. pub fn brightness(&self, level: u8) -> Result<[u8; 3]> { if let Some(range) = &self.display.brightness { if range.contains(&level) { return Ok([0x1F, 0x58, level]); } return Err(VfdError::UnsupportedBrightness { level, min: *range.start(), max: *range.end(), }); } Err(VfdError::UnsupportedBrightness { level, min: 0, max: 0, }) } /// Кодирует текст в настроенной кодировке. /// /// Для `Ascii`, CP866 и Windows-1251 неподдерживаемый символ заменяется ровно одним /// байтом `?`. Управляющие символы заменяются пробелами во всех кодировках, поэтому /// произвольные команды следует отправлять только через `write_raw` API. pub fn encode_text(&self, text: &str) -> Vec { match self.display.encoding { TextEncoding::Cp866 => encode_single_byte(IBM866, text), TextEncoding::Windows1251 => encode_single_byte(WINDOWS_1251, text), TextEncoding::Ascii => text .chars() .map(sanitize_char) .map(|ch| if ch.is_ascii() { ch as u8 } else { b'?' }) .collect(), TextEncoding::Utf8 => sanitize_text(text).into_bytes(), } } /// Нормализует строку до фиксированной ширины. /// /// Метод сначала применяет [`sanitize_text`], затем обрезает по числу символов и /// дополняет пробелами до `display.columns`. pub fn fit_line(&self, text: &str) -> String { let text = sanitize_to_width(text, self.display.columns); fit_to_width(&text, self.display.columns) } /// Обрезает текст по правому краю от координаты `x`. /// /// Проверяется только колонка `x`; строка для этой операции не нужна, поэтому для /// проверки используется первая строка. /// /// # Ошибки /// /// Возвращает [`VfdError::InvalidCoordinate`], если `x` вне дисплея. pub fn clip_from(&self, x: u8, text: &str) -> Result { self.validate_xy(x, 1)?; let remaining = self.display.columns - usize::from(x) + 1; Ok(sanitize_to_width(text, remaining)) } /// Проверяет координаты относительно геометрии. /// /// Координаты задаются от единицы. Значение `0` всегда ошибка. /// /// # Ошибки /// /// Возвращает [`VfdError::InvalidCoordinate`] с запрошенными координатами и текущей /// геометрией дисплея. pub fn validate_xy(&self, x: u8, y: u8) -> Result<()> { if x == 0 || y == 0 || usize::from(x) > self.display.columns || usize::from(y) > self.display.rows { return Err(VfdError::InvalidCoordinate { x, y, columns: self.display.columns, rows: self.display.rows, }); } Ok(()) } /// Проверяет строку относительно геометрии. /// /// # Ошибки /// /// Возвращает [`VfdError::InvalidLine`], если `line = 0` или строка больше /// настроенного `rows`. pub fn validate_line(&self, line: u8) -> Result<()> { if line == 0 || usize::from(line) > self.display.rows { return Err(VfdError::InvalidLine { line, rows: self.display.rows, }); } Ok(()) } } /// Возвращает срез не длиннее заданного числа символов, не разрывая UTF-8. /// /// # Примеры /// /// ``` /// assert_eq!(escpos_vfd::codec::truncate_chars("ёжик", 2), "ёж"); /// ``` pub fn truncate_chars(s: &str, max_chars: usize) -> &str { s.char_indices() .nth(max_chars) .map_or(s, |(byte_index, _)| &s[..byte_index]) } /// Заменяет типографские символы на безопасные аналоги для однобайтовых таблиц. /// /// Функция намеренно не выбирает кодировку. Она заменяет управляющие символы пробелами и /// нормализует символы вроде длинного тире и `№`, плохо представленные на VFD-дисплеях. pub fn sanitize_text(s: &str) -> String { s.chars().map(sanitize_char).collect() } pub(crate) fn sanitize_to_width(s: &str, width: usize) -> String { s.chars().take(width).map(sanitize_char).collect() } fn sanitize_char(c: char) -> char { if c.is_control() { return ' '; } match c { '…' => '.', '—' | '–' => '-', '№' => '#', '“' | '”' => '"', '‘' | '’' => '\'', _ => c, } } fn encode_single_byte(encoding: &'static Encoding, text: &str) -> Vec { let mut out = Vec::with_capacity(text.chars().count()); let mut utf8 = [0; 4]; for ch in text.chars().map(sanitize_char) { let encoded_char = ch.encode_utf8(&mut utf8); let (encoded, _, had_errors) = encoding.encode(encoded_char); if had_errors || encoded.len() != 1 { out.push(b'?'); } else { out.push(encoded[0]); } } out } /// Совместимый алиас для старого helper. /// /// Новому коду лучше использовать [`sanitize_text`]: санация теперь общая для разных /// однобайтовых кодировок, а не только для CP866. pub fn sanitize_for_cp866(s: &str) -> String { sanitize_text(s) } /// Обрезает строку по числу символов и дополняет пробелами до заданной ширины. /// /// Функция считает Unicode scalar values, а не байты. Это важно для кириллицы: /// строка не будет обрезана посередине UTF-8 последовательности. /// /// # Примеры /// /// ``` /// use escpos_vfd::fit_to_width; /// /// assert_eq!(fit_to_width("Привет", 4), "Прив"); /// assert_eq!(fit_to_width("да", 4), "да "); /// ``` pub fn fit_to_width(s: &str, width: usize) -> String { let mut out = String::with_capacity(width); let mut len = 0; for ch in s.chars().take(width) { out.push(ch); len += 1; } out.extend(std::iter::repeat_n(' ', width.saturating_sub(len))); out } pub(crate) fn changed_runs(current: &str, next: &str) -> Vec<(u8, String)> { let mut runs = Vec::new(); let mut run_start = None; let mut run_text = String::new(); for (index, (old, new)) in current.chars().zip(next.chars()).enumerate() { if old != new { run_start.get_or_insert((index + 1) as u8); run_text.push(new); } else if let Some(start) = run_start.take() { runs.push((start, std::mem::take(&mut run_text))); } } if let Some(start) = run_start { runs.push((start, run_text)); } runs } pub(crate) fn replace_cached_range(line: &mut String, x: u8, text: &str, width: usize) { if x == 0 || usize::from(x) > width || text.is_empty() { return; } let mut chars: Vec = line.chars().take(width).collect(); chars.resize(width, ' '); let start = usize::from(x) - 1; for (slot, ch) in chars[start..].iter_mut().zip(text.chars()) { *slot = ch; } line.clear(); line.extend(chars); } #[cfg(test)] mod tests { use super::*; use crate::config::{Preset, VfdConfig}; #[test] fn preset_init_matches_legacy_reset_and_cp866_table() { let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap(); let codec = EpsonCodec::new(cfg.display).unwrap(); assert_eq!(codec.init(), vec![0x1B, 0x40, 0x1B, 0x74, 6]); } #[test] fn optional_code_table_can_be_omitted() { let display = DisplaySettings::new(20, 2, TextEncoding::Cp866); let codec = EpsonCodec::new(display).unwrap(); assert_eq!(codec.init(), vec![0x1B, 0x40]); } #[test] fn validates_geometry_instead_of_ignoring_invalid_coordinates() { let codec = EpsonCodec::new(DisplaySettings::new(20, 4, TextEncoding::Cp866)).unwrap(); assert!(codec.goto_xy(1, 4).is_ok()); assert!(matches!( codec.goto_xy(21, 1), Err(VfdError::InvalidCoordinate { .. }) )); assert!(matches!( codec.validate_line(5), Err(VfdError::InvalidLine { .. }) )); } #[test] fn sanitizes_and_fits_unicode_by_characters() { assert_eq!(sanitize_for_cp866("№1\t“тест”—‘да’…"), "#1 \"тест\"-'да'."); assert_eq!(fit_to_width("Привет", 4), "Прив"); assert_eq!(fit_to_width("да", 4), "да "); assert_eq!(fit_to_width("text", 0), ""); assert_eq!(truncate_chars("ёжик", 3), "ёжи"); } #[test] fn cache_update_replaces_the_complete_fragment() { let mut line = "Temp: 00.00C ".to_string(); replace_cached_range(&mut line, 7, "21.50", 20); assert_eq!(line, "Temp: 21.50C "); } #[test] fn cache_update_handles_unicode_and_clips_at_the_right_edge() { let mut line = String::new(); replace_cached_range(&mut line, 3, "ёжик", 5); assert_eq!(line, " ёжи"); } #[test] fn diff_groups_adjacent_changes_into_minimal_runs() { assert_eq!( changed_runs("abcd efgh", "abXY eZZh"), vec![(3, "XY".to_string()), (7, "ZZ".to_string())] ); assert!(changed_runs("без перемен", "без перемен").is_empty()); } #[test] fn text_sanitization_neutralizes_protocol_controls() { assert_eq!(sanitize_text("A\0\u{1b}\u{1f}\r\nB"), "A B"); for encoding in [ TextEncoding::Cp866, TextEncoding::Windows1251, TextEncoding::Ascii, TextEncoding::Utf8, ] { let codec = EpsonCodec::new(DisplaySettings::new(8, 1, encoding)).unwrap(); assert_eq!(codec.encode_text("\0\u{1b}\u{1f}\r\n"), b" "); } } #[test] fn legacy_encodings_use_one_byte_for_unmappable_characters() { for encoding in [TextEncoding::Cp866, TextEncoding::Windows1251] { let codec = EpsonCodec::new(DisplaySettings::new(1, 1, encoding)).unwrap(); assert_eq!(codec.encode_text("😀"), vec![b'?']); } } #[test] fn codec_constructor_rejects_invalid_geometry() { assert!(matches!( EpsonCodec::new(DisplaySettings::new(usize::MAX, 1, TextEncoding::Ascii)), Err(ConfigError::InvalidColumns(usize::MAX)) )); } }