403 lines
15 KiB
Rust
403 lines
15 KiB
Rust
//! Формирование байтов 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<Self, ConfigError> {
|
||
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<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`, CP866 и Windows-1251 неподдерживаемый символ заменяется ровно одним
|
||
/// байтом `?`. Управляющие символы заменяются пробелами во всех кодировках, поэтому
|
||
/// произвольные команды следует отправлять только через `write_raw` API.
|
||
pub fn encode_text(&self, text: &str) -> Vec<u8> {
|
||
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<String> {
|
||
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<u8> {
|
||
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<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).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))
|
||
));
|
||
}
|
||
}
|