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:
+343
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user