escpos-vfd 0.2.0

ESC/POS-compatible VFD customer display driver with sync and optional Tokio APIs
Documentation
//! Формирование байтов Epson/ESC/POS-команд без привязки к конкретному serial-порту.
//!
//! Этот модуль полезен для тестов, нестандартных транспортов и проверки того, какие
//! байты будут отправлены устройству при выбранных [`DisplaySettings`]. Обычным
//! приложениям чаще достаточно [`crate::Vfd`] или [`crate::VfdWorker`], но codec
//! остаётся публичным для диагностики и интеграции с собственным транспортом.

use crate::config::{DisplaySettings, TextEncoding};
use crate::error::{Result, VfdError};
use encoding_rs::{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`.
    ///
    /// Метод не валидирует настройки. Если codec создаётся не через [`crate::Vfd`],
    /// вызовите [`DisplaySettings::validate`] самостоятельно.
    pub fn new(display: DisplaySettings) -> Self {
        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<u8> {
        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` символы вне ASCII заменяются на `?`. Для CP866 и Windows-1251
    /// используется `encoding_rs`, поэтому неподдерживаемые символы проходят стандартную
    /// замену этой библиотеки.
    pub fn encode_text(&self, text: &str) -> Vec<u8> {
        match self.display.encoding {
            TextEncoding::Cp866 => IBM866.encode(text).0.into_owned(),
            TextEncoding::Windows1251 => WINDOWS_1251.encode(text).0.into_owned(),
            TextEncoding::Ascii => text
                .chars()
                .map(|ch| if ch.is_ascii() { ch as u8 } else { b'?' })
                .collect(),
            TextEncoding::Utf8 => text.as_bytes().to_vec(),
        }
    }

    /// Нормализует строку до фиксированной ширины.
    ///
    /// Метод сначала применяет [`sanitize_text`], затем обрезает по числу символов и
    /// дополняет пробелами до `display.columns`.
    pub fn fit_line(&self, text: &str) -> String {
        fit_to_width(&sanitize_text(text), self.display.columns)
    }

    /// Обрезает текст по правому краю от координаты `x`.
    ///
    /// Проверяется только колонка `x`; строка для этой операции не нужна, поэтому для
    /// проверки используется первая строка.
    ///
    /// # Ошибки
    ///
    /// Возвращает [`VfdError::InvalidCoordinate`], если `x` вне дисплея.
    pub fn clip_from(&self, x: u8, text: &str) -> Result<String> {
        self.validate_xy(x, 1)?;
        let remaining = self.display.columns - usize::from(x) + 1;
        Ok(truncate_chars(&sanitize_text(text), remaining).to_string())
    }

    /// Проверяет координаты относительно геометрии.
    ///
    /// Координаты задаются от единицы. Значение `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(|c| match c {
            '' => '.',
            '' | '' => '-',
            '' => '#',
            '\t' => ' ',
            '' | '' => '"',
            '' | '' => '\'',
            _ => c,
        })
        .collect()
}

/// Совместимый алиас для старого 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<char> = 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);

        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);

        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));

        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());
    }
}