release: escpos-vfd 0.3.0
This commit is contained in:
+89
-30
@@ -6,8 +6,8 @@
|
||||
//! остаётся публичным для диагностики и интеграции с собственным транспортом.
|
||||
|
||||
use crate::config::{DisplaySettings, TextEncoding};
|
||||
use crate::error::{Result, VfdError};
|
||||
use encoding_rs::{IBM866, WINDOWS_1251};
|
||||
use crate::error::{ConfigError, Result, VfdError};
|
||||
use encoding_rs::{Encoding, IBM866, WINDOWS_1251};
|
||||
|
||||
/// Кодировщик Epson/ESC/POS-команд для выбранной геометрии и таблицы символов.
|
||||
///
|
||||
@@ -24,10 +24,14 @@ impl EpsonCodec {
|
||||
/// Значение `display.code_table` влияет только на байты инициализации `ESC t n`;
|
||||
/// кодировка текста берётся из `display.encoding`.
|
||||
///
|
||||
/// Метод не валидирует настройки. Если codec создаётся не через [`crate::Vfd`],
|
||||
/// вызовите [`DisplaySettings::validate`] самостоятельно.
|
||||
pub fn new(display: DisplaySettings) -> Self {
|
||||
Self { display }
|
||||
/// Метод валидирует геометрию и диапазоны до сохранения настроек.
|
||||
///
|
||||
/// # Ошибки
|
||||
///
|
||||
/// Возвращает [`ConfigError`], если настройки дисплея некорректны.
|
||||
pub fn new(display: DisplaySettings) -> std::result::Result<Self, ConfigError> {
|
||||
display.validate()?;
|
||||
Ok(Self { display })
|
||||
}
|
||||
|
||||
/// Настройки дисплея, для которых работает codec.
|
||||
@@ -95,18 +99,19 @@ impl EpsonCodec {
|
||||
|
||||
/// Кодирует текст в настроенной кодировке.
|
||||
///
|
||||
/// Для `Ascii` символы вне ASCII заменяются на `?`. Для CP866 и Windows-1251
|
||||
/// используется `encoding_rs`, поэтому неподдерживаемые символы проходят стандартную
|
||||
/// замену этой библиотеки.
|
||||
/// Для `Ascii`, CP866 и Windows-1251 неподдерживаемый символ заменяется ровно одним
|
||||
/// байтом `?`. Управляющие символы заменяются пробелами во всех кодировках, поэтому
|
||||
/// произвольные команды следует отправлять только через `write_raw` API.
|
||||
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::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 => text.as_bytes().to_vec(),
|
||||
TextEncoding::Utf8 => sanitize_text(text).into_bytes(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +120,8 @@ impl EpsonCodec {
|
||||
/// Метод сначала применяет [`sanitize_text`], затем обрезает по числу символов и
|
||||
/// дополняет пробелами до `display.columns`.
|
||||
pub fn fit_line(&self, text: &str) -> String {
|
||||
fit_to_width(&sanitize_text(text), self.display.columns)
|
||||
let text = sanitize_to_width(text, self.display.columns);
|
||||
fit_to_width(&text, self.display.columns)
|
||||
}
|
||||
|
||||
/// Обрезает текст по правому краю от координаты `x`.
|
||||
@@ -129,7 +135,7 @@ impl EpsonCodec {
|
||||
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())
|
||||
Ok(sanitize_to_width(text, remaining))
|
||||
}
|
||||
|
||||
/// Проверяет координаты относительно геометрии.
|
||||
@@ -188,20 +194,43 @@ pub fn truncate_chars(s: &str, max_chars: usize) -> &str {
|
||||
|
||||
/// Заменяет типографские символы на безопасные аналоги для однобайтовых таблиц.
|
||||
///
|
||||
/// Функция намеренно не выбирает кодировку. Она только убирает символы вроде длинного
|
||||
/// тире, табуляции и `№`, которые часто плохо представлены на VFD-дисплеях.
|
||||
/// Функция намеренно не выбирает кодировку. Она заменяет управляющие символы пробелами и
|
||||
/// нормализует символы вроде длинного тире и `№`, плохо представленные на VFD-дисплеях.
|
||||
pub fn sanitize_text(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
'…' => '.',
|
||||
'—' | '–' => '-',
|
||||
'№' => '#',
|
||||
'\t' => ' ',
|
||||
'“' | '”' => '"',
|
||||
'‘' | '’' => '\'',
|
||||
_ => c,
|
||||
})
|
||||
.collect()
|
||||
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.
|
||||
@@ -281,7 +310,7 @@ mod tests {
|
||||
#[test]
|
||||
fn preset_init_matches_legacy_reset_and_cp866_table() {
|
||||
let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap();
|
||||
let codec = EpsonCodec::new(cfg.display);
|
||||
let codec = EpsonCodec::new(cfg.display).unwrap();
|
||||
|
||||
assert_eq!(codec.init(), vec![0x1B, 0x40, 0x1B, 0x74, 6]);
|
||||
}
|
||||
@@ -289,14 +318,14 @@ mod tests {
|
||||
#[test]
|
||||
fn optional_code_table_can_be_omitted() {
|
||||
let display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
|
||||
let codec = EpsonCodec::new(display);
|
||||
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));
|
||||
let codec = EpsonCodec::new(DisplaySettings::new(20, 4, TextEncoding::Cp866)).unwrap();
|
||||
|
||||
assert!(codec.goto_xy(1, 4).is_ok());
|
||||
assert!(matches!(
|
||||
@@ -340,4 +369,34 @@ mod tests {
|
||||
);
|
||||
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))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user