Rename crate to escpos-vfd and add tokio support

Rename crate to escpos-vfd and add tokio support

Introduce a configurable crate-ready API with presets, typed errors,
optional Tokio async support, and expanded documentation.
This commit is contained in:
Кобелев Андрей Андреевич
2026-08-16 11:24:20 +05:00
parent efc53e6ef5
commit 244d907cc2
25 changed files with 3823 additions and 782 deletions
+343
View File
@@ -0,0 +1,343 @@
//! Формирование байтов 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());
}
}