Skip to main content

hardless_http_dialects/
epos.rs

1//! The ePOS-Print-compatible dialect (PRD section 9.1): `POST
2//! /cgi-bin/epos/service.cgi?devid=<id>&timeout=<ms>` with a SOAP envelope holding an
3//! `epos-print` document.
4//!
5//! Implemented from the public "ePOS-Print XML User's Manual" (element and attribute names,
6//! response codes and status bits). No vendor SDK code is used.
7
8use roxmltree::Node;
9
10use crate::escpos::{unescape, Align, EscPos, Hri, QrLevel};
11use crate::xml::{self, attr, bad, child, elements, flag, number, text};
12use crate::{DialectError, Outcome, PrintRequest, PrinterStatus};
13
14/// The `epos-print` namespace.
15pub const NAMESPACE: &str = "http://www.epson-pos.com/schemas/2011/03/epos-print";
16/// The SOAP 1.1 envelope namespace.
17pub const SOAP_NAMESPACE: &str = "http://schemas.xmlsoap.org/soap/envelope/";
18
19/// `status` bits of the response, from the manual's status table.
20pub mod status {
21    /// No response from the printer.
22    pub const NO_RESPONSE: u32 = 0x0000_0001;
23    /// Printing completed.
24    pub const PRINT_SUCCESS: u32 = 0x0000_0002;
25    /// The drawer-kick connector's pin 3 reads high.
26    pub const DRAWER_KICK: u32 = 0x0000_0004;
27    /// The printer is offline.
28    pub const OFFLINE: u32 = 0x0000_0008;
29    /// The cover is open.
30    pub const COVER_OPEN: u32 = 0x0000_0020;
31    /// The roll is nearly empty.
32    pub const RECEIPT_NEAR_END: u32 = 0x0002_0000;
33    /// The roll is empty.
34    pub const RECEIPT_END: u32 = 0x0008_0000;
35}
36
37/// A parsed request.
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub struct EposRequest {
40    /// The document as ESC/POS.
41    pub print: PrintRequest,
42    /// `devid` from the SOAP header, if given there instead of the URL.
43    pub devid: Option<String>,
44    /// `timeout` in milliseconds from the SOAP header.
45    pub timeout_ms: Option<u64>,
46    /// `printjobid` from the SOAP header, echoed in the response.
47    pub print_job_id: Option<String>,
48}
49
50/// Parses a request: a SOAP envelope with an `epos-print` body, or a bare `epos-print`
51/// document. Elements and attribute values the manual does not define are an error, which
52/// the endpoint answers as `SchemaError`.
53///
54/// ```
55/// use hardless_http_dialects::epos;
56/// let req = epos::parse(r#"<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body>
57///   <epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">
58///     <text align="center">Hello&#10;</text><cut type="feed"/>
59///   </epos-print></s:Body></s:Envelope>"#).unwrap();
60/// assert_eq!(req.print.escpos, b"\x1b@\x1ba\x01Hello\n\x1dVB\x00");
61/// ```
62pub fn parse(document: &str) -> Result<EposRequest, DialectError> {
63    let doc = xml::parse(document)?;
64    let root = doc.root_element();
65    let mut request = EposRequest::default();
66    let body = match root.tag_name().name() {
67        "epos-print" => root,
68        "Envelope" => {
69            if let Some(parameter) = child(root, "Header").and_then(|h| child(h, "parameter")) {
70                request.devid = child(parameter, "devid").map(|n| text(n).trim().to_owned());
71                request.timeout_ms = child(parameter, "timeout")
72                    .map(|n| text(n).trim().parse())
73                    .transpose()
74                    .map_err(|_| DialectError::new("<timeout> must be a number"))?;
75                request.print_job_id =
76                    child(parameter, "printjobid").map(|n| text(n).trim().to_owned());
77            }
78            child(root, "Body")
79                .and_then(|b| child(b, "epos-print"))
80                .ok_or_else(|| DialectError::new("the SOAP body has no <epos-print> element"))?
81        }
82        other => {
83            return Err(DialectError::new(format!(
84                "expected a SOAP envelope or <epos-print>, not <{other}>"
85            )))
86        }
87    };
88    request.print = translate(body)?;
89    Ok(request)
90}
91
92/// Text settings that carry over between `text` elements in a document.
93#[derive(Clone, Copy)]
94struct TextState {
95    width: u8,
96    height: u8,
97}
98
99fn translate(epos_print: Node<'_, '_>) -> Result<PrintRequest, DialectError> {
100    let mut doc = EscPos::new();
101    let mut warnings = Vec::new();
102    let mut state = TextState {
103        width: 1,
104        height: 1,
105    };
106    for node in elements(epos_print) {
107        element(node, &mut doc, &mut state, &mut warnings)?;
108    }
109    Ok(PrintRequest {
110        escpos: doc.finish(),
111        warnings,
112    })
113}
114
115fn element(
116    node: Node<'_, '_>,
117    doc: &mut EscPos,
118    state: &mut TextState,
119    warnings: &mut Vec<String>,
120) -> Result<(), DialectError> {
121    match node.tag_name().name() {
122        "text" => text_element(node, doc, state, warnings),
123        "feed" => {
124            if let Some(lines) = number::<u8>(node, "line")? {
125                doc.feed_lines(lines);
126            } else if let Some(dots) = number::<u32>(node, "unit")? {
127                // ESC/POS line feeds are 30 dots at the default line spacing.
128                doc.feed_lines(u8::try_from(dots.div_ceil(30)).unwrap_or(u8::MAX));
129            } else if attr(node, "pos").is_none() {
130                doc.line_feed();
131            }
132            Ok(())
133        }
134        "image" => image(node, doc),
135        "logo" => {
136            let k1 = number::<u8>(node, "key1")?.unwrap_or(32);
137            let k2 = number::<u8>(node, "key2")?.unwrap_or(32);
138            warnings.push(format!("NV logo {k1}/{k2}: the emulator stores no logos"));
139            // GS ( L function 69 (print NV graphics); the printer reports it as unknown.
140            doc.raw(&[0x1D, 0x28, 0x4C, 0x06, 0x00, 0x30, 0x45, k1, k2, 0x01, 0x01]);
141            Ok(())
142        }
143        "barcode" => barcode(node, doc, warnings),
144        "symbol" => symbol(node, doc),
145        "cut" => {
146            match attr(node, "type").unwrap_or("feed") {
147                "no_feed" => doc.cut(true, false),
148                "feed" | "reserve" => doc.cut(true, true),
149                other => return Err(bad(node, "type", other)),
150            }
151            Ok(())
152        }
153        "pulse" => {
154            let pin5 = match attr(node, "drawer").unwrap_or("drawer_1") {
155                "drawer_1" => false,
156                "drawer_2" => true,
157                other => return Err(bad(node, "drawer", other)),
158            };
159            let ms = match attr(node, "time").unwrap_or("pulse_100") {
160                "pulse_100" => 100,
161                "pulse_200" => 200,
162                "pulse_300" => 300,
163                "pulse_400" => 400,
164                "pulse_500" => 500,
165                other => return Err(bad(node, "time", other)),
166            };
167            doc.pulse(pin5, ms, ms);
168            Ok(())
169        }
170        "command" => {
171            let hex: String = text(node).chars().filter(|c| !c.is_whitespace()).collect();
172            let bytes = decode_hex(&hex)
173                .ok_or_else(|| DialectError::new("<command> must be hexadecimal bytes"))?;
174            doc.raw(&bytes);
175            Ok(())
176        }
177        "sound" => {
178            warnings.push("<sound>: the emulated printer has no buzzer".into());
179            Ok(())
180        }
181        "page" => {
182            warnings.push("<page>: page mode is printed in standard mode".into());
183            for inner in elements(node) {
184                match inner.tag_name().name() {
185                    "area" | "direction" | "position" | "line" | "rectangle" => {}
186                    _ => element(inner, doc, state, warnings)?,
187                }
188            }
189            Ok(())
190        }
191        "layout" | "hline" | "vline" | "recovery" | "reset" => {
192            warnings.push(format!(
193                "<{}> is not reproduced by the emulator",
194                node.tag_name().name()
195            ));
196            Ok(())
197        }
198        other => Err(DialectError::new(format!(
199            "<{other}> is not an epos-print element"
200        ))),
201    }
202}
203
204fn text_element(
205    node: Node<'_, '_>,
206    doc: &mut EscPos,
207    state: &mut TextState,
208    warnings: &mut Vec<String>,
209) -> Result<(), DialectError> {
210    if let Some(align) = attr(node, "align") {
211        doc.align(align_value(node, align)?);
212    }
213    if let Some(font) = attr(node, "font") {
214        match font {
215            "font_a" => doc.font_b(false),
216            "font_b" | "font_c" | "font_d" | "font_e" => doc.font_b(true),
217            other => return Err(bad(node, "font", other)),
218        }
219    }
220    if let Some(em) = flag(node, "em")? {
221        doc.bold(em);
222    }
223    if let Some(ul) = flag(node, "ul")? {
224        doc.underline(u8::from(ul));
225    }
226    let mut size = *state;
227    if let Some(dw) = flag(node, "dw")? {
228        size.width = if dw { 2 } else { 1 };
229    }
230    if let Some(dh) = flag(node, "dh")? {
231        size.height = if dh { 2 } else { 1 };
232    }
233    if let Some(w) = number::<u8>(node, "width")? {
234        size.width = w.clamp(1, 8);
235    }
236    if let Some(h) = number::<u8>(node, "height")? {
237        size.height = h.clamp(1, 8);
238    }
239    if (size.width, size.height) != (state.width, state.height) {
240        doc.size(size.width, size.height);
241        *state = size;
242    }
243    for name in ["reverse", "rotate", "x", "linespc"] {
244        if attr(node, name).is_some() {
245            warnings.push(format!("<text {name}>: not reproduced by the emulator"));
246        }
247    }
248    if let Some(lang) = attr(node, "lang") {
249        if !matches!(lang, "en" | "de" | "fr" | "it" | "es" | "multi") {
250            warnings.push(format!(
251                "<text lang=\"{lang}\">: only Latin code pages are emulated"
252            ));
253        }
254    }
255    doc.text(&text(node));
256    Ok(())
257}
258
259fn align_value(node: Node<'_, '_>, value: &str) -> Result<Align, DialectError> {
260    match value {
261        "left" => Ok(Align::Left),
262        "center" => Ok(Align::Center),
263        "right" => Ok(Align::Right),
264        other => Err(bad(node, "align", other)),
265    }
266}
267
268fn image(node: Node<'_, '_>, doc: &mut EscPos) -> Result<(), DialectError> {
269    let width =
270        number::<u32>(node, "width")?.ok_or_else(|| DialectError::new("<image> needs a width"))?;
271    let height = number::<u32>(node, "height")?
272        .ok_or_else(|| DialectError::new("<image> needs a height"))?;
273    if let Some(align) = attr(node, "align") {
274        doc.align(align_value(node, align)?);
275    }
276    let encoded: String = text(node).chars().filter(|c| !c.is_whitespace()).collect();
277    let data = hardless_core_base64(&encoded)?;
278    let data = match attr(node, "mode").unwrap_or("mono") {
279        "mono" => data,
280        // 16-level grayscale: 4 bits a pixel. The emulated printer prints one bit, dark from
281        // level 8.
282        "gray16" => gray16_to_mono(&data, width, height),
283        other => return Err(bad(node, "mode", other)),
284    };
285    doc.raster(width, height, &data);
286    Ok(())
287}
288
289fn gray16_to_mono(data: &[u8], width: u32, height: u32) -> Vec<u8> {
290    let row_bytes = width.div_ceil(8) as usize;
291    let mut out = vec![0u8; row_bytes * height as usize];
292    let src_row = width.div_ceil(2) as usize;
293    for y in 0..height as usize {
294        for x in 0..width as usize {
295            let Some(&byte) = data.get(y * src_row + x / 2) else {
296                continue;
297            };
298            let level = if x % 2 == 0 { byte >> 4 } else { byte & 0x0F };
299            if level >= 8 {
300                out[y * row_bytes + x / 8] |= 0x80 >> (x % 8);
301            }
302        }
303    }
304    out
305}
306
307fn barcode(
308    node: Node<'_, '_>,
309    doc: &mut EscPos,
310    warnings: &mut Vec<String>,
311) -> Result<(), DialectError> {
312    let kind = attr(node, "type").ok_or_else(|| DialectError::new("<barcode> needs a type"))?;
313    // GS k function B symbol numbers.
314    let m = match kind {
315        "upc_a" => 65,
316        "upc_e" => 66,
317        "ean13" | "jan13" => 67,
318        "ean8" | "jan8" => 68,
319        "code39" => 69,
320        "itf" => 70,
321        "codabar" => 71,
322        "code93" => 72,
323        "code128" | "code128_auto" => 73,
324        "gs1_128" => 74,
325        "gs1_databar_omnidirectional" => 75,
326        "gs1_databar_truncated" => 76,
327        "gs1_databar_limited" => 77,
328        "gs1_databar_expanded" => 78,
329        other => return Err(bad(node, "type", other)),
330    };
331    let hri = match attr(node, "hri").unwrap_or("none") {
332        "none" => Hri::None,
333        "above" => Hri::Above,
334        "below" => Hri::Below,
335        "both" => Hri::Both,
336        other => return Err(bad(node, "hri", other)),
337    };
338    if let Some(align) = attr(node, "align") {
339        doc.align(align_value(node, align)?);
340    }
341    if attr(node, "rotate").is_some() {
342        warnings.push("<barcode rotate>: not reproduced by the emulator".into());
343    }
344    let module = number::<u8>(node, "width")?.unwrap_or(3);
345    let height = number::<u8>(node, "height")?.unwrap_or(162);
346    let mut data = unescape(&text(node));
347    if m == 73 && !data.starts_with(b"{") {
348        // ESC/POS CODE128 data names its code set; plain text is code set B.
349        data.splice(0..0, *b"{B");
350    }
351    doc.barcode(m, &data, hri, module, height);
352    Ok(())
353}
354
355fn symbol(node: Node<'_, '_>, doc: &mut EscPos) -> Result<(), DialectError> {
356    let kind = attr(node, "type").ok_or_else(|| DialectError::new("<symbol> needs a type"))?;
357    let data = unescape(&text(node));
358    let module = number::<u8>(node, "width")?.unwrap_or(3);
359    let qr = |model: u8, doc: &mut EscPos| -> Result<(), DialectError> {
360        let level = match attr(node, "level").unwrap_or("level_m") {
361            "level_l" => QrLevel::L,
362            "level_m" | "default" => QrLevel::M,
363            "level_q" => QrLevel::Q,
364            "level_h" => QrLevel::H,
365            other => return Err(bad(node, "level", other)),
366        };
367        doc.qr(model, module, level, &data);
368        Ok(())
369    };
370    match kind {
371        "qrcode_model_1" => qr(1, doc),
372        "qrcode_model_2" => qr(2, doc),
373        // Symbols the emulated printer does not draw; `GS ( k` with their own code.
374        k if k.starts_with("pdf417") => {
375            doc.other_symbol(48, &data);
376            Ok(())
377        }
378        k if k.starts_with("maxicode") => {
379            doc.other_symbol(50, &data);
380            Ok(())
381        }
382        k if k.starts_with("gs1_databar") => {
383            doc.other_symbol(51, &data);
384            Ok(())
385        }
386        k if k.starts_with("azteccode") => {
387            doc.other_symbol(53, &data);
388            Ok(())
389        }
390        k if k.starts_with("datamatrix") || k == "qrcode_micro" => {
391            doc.other_symbol(54, &data);
392            Ok(())
393        }
394        other => Err(bad(node, "type", other)),
395    }
396}
397
398fn decode_hex(hex: &str) -> Option<Vec<u8>> {
399    if !hex.len().is_multiple_of(2) {
400        return None;
401    }
402    (0..hex.len())
403        .step_by(2)
404        .map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
405        .collect()
406}
407
408fn hardless_core_base64(text: &str) -> Result<Vec<u8>, DialectError> {
409    hardless_core::base64::decode(text)
410        .map_err(|e| DialectError::new(format!("<image> data is not base64: {e}")))
411}
412
413/// The `status` bitmask for a print's outcome and the printer's state afterwards.
414///
415/// ```
416/// use hardless_http_dialects::{epos, Outcome, PrinterStatus};
417/// let ok = PrinterStatus { online: true, ..Default::default() };
418/// assert_eq!(epos::status_bits(&Outcome::Printed, &ok), epos::status::PRINT_SUCCESS);
419/// ```
420pub fn status_bits(outcome: &Outcome, printer: &PrinterStatus) -> u32 {
421    if matches!(outcome, Outcome::BadDocument(_) | Outcome::DeviceNotFound) {
422        return 0;
423    }
424    let mut bits = 0;
425    if outcome.printed() {
426        bits |= status::PRINT_SUCCESS;
427    }
428    if printer.drawer_pin3_high {
429        bits |= status::DRAWER_KICK;
430    }
431    if !printer.online {
432        bits |= status::OFFLINE;
433    }
434    if printer.cover_open {
435        bits |= status::COVER_OPEN;
436    }
437    if printer.paper_near_end {
438        bits |= status::RECEIPT_NEAR_END;
439    }
440    if printer.paper_end {
441        bits |= status::RECEIPT_END;
442    }
443    bits
444}
445
446/// The response `code` for an outcome: empty on success, else the manual's error code.
447pub fn error_code(outcome: &Outcome) -> &'static str {
448    match outcome {
449        Outcome::Printed => "",
450        Outcome::PaperEnd => "EPTR_REC_EMPTY",
451        Outcome::CoverOpen => "EPTR_COVER_OPEN",
452        Outcome::Timeout => "EX_TIMEOUT",
453        Outcome::BadDocument(_) => "SchemaError",
454        Outcome::DeviceNotFound => "DeviceNotFound",
455    }
456}
457
458/// The SOAP response document.
459///
460/// ```
461/// use hardless_http_dialects::{epos, Outcome, PrinterStatus};
462/// let paper_out = PrinterStatus { paper_end: true, paper_near_end: true, ..Default::default() };
463/// let xml = epos::response(&Outcome::PaperEnd, &paper_out, None);
464/// assert!(xml.contains(r#"success="false" code="EPTR_REC_EMPTY" status="655368""#));
465/// ```
466pub fn response(outcome: &Outcome, printer: &PrinterStatus, print_job_id: Option<&str>) -> String {
467    let header = print_job_id.map_or(String::new(), |id| {
468        format!(
469            r#"<s:Header><parameter xmlns="{NAMESPACE}"><printjobid>{}</printjobid></parameter></s:Header>"#,
470            xml::escape(id)
471        )
472    });
473    format!(
474        r#"<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="{SOAP_NAMESPACE}">{header}<s:Body><response success="{}" code="{}" status="{}" battery="0" xmlns="{NAMESPACE}"/></s:Body></s:Envelope>"#,
475        outcome.printed(),
476        error_code(outcome),
477        status_bits(outcome, printer)
478    )
479}