Skip to main content

hardless_render/
html.rs

1//! HTML output (FR-10, FR-12).
2
3use std::fmt::Write as _;
4
5use hardless_escpos::{Align, Font, HriPosition, Node, Symbology};
6
7use crate::codes;
8use crate::layout::{layout, Block, Line, Segment, DOTS_PER_COLUMN};
9use crate::RenderOptions;
10
11/// Styles for [`render_html`] output. Everything is scoped under `.hl-receipt`; one Font A
12/// column is `1ch` of the receipt font, so text, codes and images keep the printer's
13/// proportions at any font size. The receipt is white paper in both light and dark themes.
14pub const STYLESHEET: &str = r#".hl-receipt{--hl-ink:#111;--hl-paper:#fff;box-sizing:content-box;width:calc(var(--hl-cols)*1ch);padding:1ch 1.5ch;background:var(--hl-paper);color:var(--hl-ink);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;line-height:1.25;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.25)}
15.hl-receipt *{box-sizing:border-box}
16.hl-line{white-space:pre;min-height:calc(var(--hl-lh,1)*1.25em);display:flex;align-items:flex-end}
17.hl-align-left{justify-content:flex-start}.hl-align-center{justify-content:center}.hl-align-right{justify-content:flex-end}
18.hl-b{font-weight:700}.hl-u1{text-decoration:underline;text-decoration-thickness:1px}.hl-u2{text-decoration:underline;text-decoration-thickness:2px}
19.hl-fb{font-size:.75em}
20.hl-s{display:inline-block;transform:scale(var(--w),var(--h));transform-origin:0 100%;margin-right:calc((var(--w) - 1)*var(--n)*1ch)}
21.hl-block{display:flex;padding:.25em 0}
22.hl-block svg{display:block;fill:var(--hl-ink)}
23.hl-code{display:flex;flex-direction:column;align-items:center}
24.hl-hri{font-size:.75em;white-space:pre}
25.hl-cut{margin:.6em -1.5ch;border-top:2px dashed #999;height:0}
26.hl-cut-partial{border-top-style:dotted}
27.hl-unknown{display:inline-block;margin:0 .15em;padding:0 .3em;border-radius:3px;background:#fde68a;color:#78350f;font:600 10px/1.4 ui-sans-serif,system-ui,sans-serif;white-space:nowrap;cursor:help}
28.hl-error{font:11px/1.3 ui-sans-serif,system-ui,sans-serif;color:#b91c1c}
29"#;
30
31/// Renders an AST to an HTML fragment (`<div class="hl-receipt">`). Include
32/// [`STYLESHEET`] once on the page, or use [`render_html_document`].
33///
34/// All receipt text is escaped, so the output is safe to insert into a page.
35///
36/// ```
37/// use hardless_escpos::parse;
38/// use hardless_render::{render_html, RenderOptions};
39/// let html = render_html(&parse(b"<b>Hi</b>\n"), &RenderOptions::default());
40/// assert!(html.contains("&lt;b&gt;Hi&lt;/b&gt;"));
41/// ```
42pub fn render_html(nodes: &[Node], options: &RenderOptions) -> String {
43    let (cols, _) = options.paper.columns();
44    let mut out = format!(
45        r#"<div class="hl-receipt hl-paper-{}" style="--hl-cols:{cols}">"#,
46        options.paper.millimetres()
47    );
48    for block in layout(nodes, options.paper, options.show_unknown) {
49        match block {
50            Block::Line(line) => line_html(&mut out, &line),
51            Block::Cut { partial } => {
52                let class = if partial {
53                    "hl-cut hl-cut-partial"
54                } else {
55                    "hl-cut"
56                };
57                let label = if partial { "partial cut" } else { "cut" };
58                let _ = write!(
59                    out,
60                    r#"<div class="{class}" role="separator" aria-label="{label}"></div>"#
61                );
62            }
63            Block::Qr {
64                align,
65                data,
66                text,
67                module_dots,
68                level,
69            } => {
70                open_block(&mut out, align);
71                match codes::qr(&data, module_dots, level) {
72                    Ok(svg) => sized(&mut out, &svg),
73                    Err(e) => error(&mut out, &format!("{e}: {text}")),
74                }
75                out.push_str("</div>");
76            }
77            Block::Barcode {
78                align,
79                symbology,
80                data,
81                height_dots,
82                module_dots,
83                hri,
84            } => {
85                open_block(&mut out, align);
86                out.push_str(r#"<div class="hl-code">"#);
87                let label = codes::barcode_text(symbology, &data);
88                if matches!(hri, HriPosition::Above | HriPosition::Both) {
89                    hri_html(&mut out, &label);
90                }
91                match codes::barcode(symbology, &data, module_dots, height_dots) {
92                    Ok(svg) => sized(&mut out, &svg),
93                    Err(e) => error(&mut out, &format!("{} {e}", symbology_name(symbology))),
94                }
95                if matches!(hri, HriPosition::Below | HriPosition::Both) {
96                    hri_html(&mut out, &label);
97                }
98                out.push_str("</div></div>");
99            }
100            Block::Image {
101                align,
102                width_bytes,
103                height,
104                double_width,
105                double_height,
106                data,
107            } => {
108                open_block(&mut out, align);
109                sized(
110                    &mut out,
111                    &codes::image(width_bytes, height, double_width, double_height, &data),
112                );
113                out.push_str("</div>");
114            }
115        }
116    }
117    out.push_str("</div>");
118    out
119}
120
121/// Renders an AST to a complete HTML document with [`STYLESHEET`], e.g. for
122/// `GET /devices/:id/jobs/:jobId/render?format=html`.
123pub fn render_html_document(nodes: &[Node], options: &RenderOptions) -> String {
124    format!(
125        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Receipt</title><style>body{{margin:0;padding:16px;background:#e5e7eb}}{STYLESHEET}</style></head><body>{}</body></html>",
126        render_html(nodes, options)
127    )
128}
129
130fn line_html(out: &mut String, line: &Line) {
131    let height = line.height();
132    let _ = write!(out, r#"<div class="hl-line {}""#, align_class(line.align));
133    if height > 1 {
134        let _ = write!(out, r#" style="--hl-lh:{height}""#);
135    }
136    out.push('>');
137    for segment in &line.segments {
138        match segment {
139            Segment::Text { text, style } => {
140                let mut classes = Vec::new();
141                if style.bold {
142                    classes.push("hl-b");
143                }
144                match style.underline {
145                    1 => classes.push("hl-u1"),
146                    2 => classes.push("hl-u2"),
147                    _ => {}
148                }
149                if style.font != Font::A {
150                    classes.push("hl-fb");
151                }
152                let scaled = style.width > 1 || style.height > 1;
153                if scaled {
154                    classes.push("hl-s");
155                }
156                let _ = write!(out, r#"<span class="{}""#, classes.join(" "));
157                if scaled {
158                    let _ = write!(
159                        out,
160                        r#" style="--w:{};--h:{};--n:{}""#,
161                        style.width,
162                        style.height,
163                        text.chars().count()
164                    );
165                }
166                out.push('>');
167                escape_into(out, text);
168                out.push_str("</span>");
169            }
170            Segment::Unknown { label, title } => {
171                out.push_str(r#"<span class="hl-unknown" title=""#);
172                escape_into(out, title);
173                out.push_str(r#"">"#);
174                escape_into(out, label);
175                out.push_str("</span>");
176            }
177        }
178    }
179    out.push_str("</div>");
180}
181
182fn open_block(out: &mut String, align: Align) {
183    let _ = write!(out, r#"<div class="hl-block {}">"#, align_class(align));
184}
185
186/// Writes an SVG sized in Font A columns (12 dots per column).
187fn sized(out: &mut String, svg: &codes::Svg) {
188    let width = f64::from(svg.width) / f64::from(DOTS_PER_COLUMN);
189    let height = f64::from(svg.height) / f64::from(DOTS_PER_COLUMN);
190    let style = format!(r#" style="width:{width:.3}ch;height:{height:.3}ch""#);
191    out.push_str(&svg.body.replacen("<svg ", &format!("<svg{style} "), 1));
192}
193
194fn hri_html(out: &mut String, label: &str) {
195    out.push_str(r#"<div class="hl-hri">"#);
196    escape_into(out, label);
197    out.push_str("</div>");
198}
199
200fn error(out: &mut String, message: &str) {
201    out.push_str(r#"<div class="hl-error">"#);
202    escape_into(out, message);
203    out.push_str("</div>");
204}
205
206fn align_class(align: Align) -> &'static str {
207    match align {
208        Align::Left => "hl-align-left",
209        Align::Center => "hl-align-center",
210        Align::Right => "hl-align-right",
211    }
212}
213
214pub(crate) fn symbology_name(symbology: Symbology) -> &'static str {
215    match symbology {
216        Symbology::Code128 => "CODE128",
217        Symbology::Ean13 => "EAN13",
218        Symbology::Code39 => "CODE39",
219    }
220}
221
222/// HTML-escapes text, and replaces control characters (which have no glyph) with U+FFFD.
223pub(crate) fn escape_into(out: &mut String, text: &str) {
224    for c in text.chars() {
225        match c {
226            '&' => out.push_str("&amp;"),
227            '<' => out.push_str("&lt;"),
228            '>' => out.push_str("&gt;"),
229            '"' => out.push_str("&quot;"),
230            '\'' => out.push_str("&#39;"),
231            c if c.is_control() => out.push('\u{fffd}'),
232            c => out.push(c),
233        }
234    }
235}