Files
vfd/src/codec.rs
T

403 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Формирование байтов 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))
));
}
}