Skip to main content

hardless_render/
svg.rs

1//! SVG output (FR-11): the same layout as the HTML output, drawn in printer dots.
2//!
3//! One SVG unit is one printer dot (576 across an 80 mm roll, 384 across 58 mm). Each run of
4//! text is stretched to its exact column width with `textLength`, so columns line up whatever
5//! monospace font the viewer has; the PNG output uses the bundled Go Mono font.
6
7use std::fmt::Write as _;
8
9use hardless_escpos::{Align, Font, HriPosition, Node};
10
11use crate::codes;
12use crate::html::{escape_into, symbology_name};
13use crate::layout::{layout, Block, Line, Segment};
14use crate::{PaperWidth, RenderOptions};
15
16/// Line pitch of normal-height text in dots (the ESC/POS default line spacing).
17const LINE_DOTS: u32 = 30;
18/// Paper margin left and right of the printable area, in dots (1.5 Font A columns).
19const MARGIN_X: u32 = 18;
20/// Paper margin above and below the receipt, in dots.
21const MARGIN_Y: u32 = 12;
22/// Space above and below a code or image, in dots.
23const BLOCK_PAD: u32 = 6;
24/// Space below the baseline for descenders, in dots at normal height.
25const DESCENT: u32 = 7;
26/// Glyph advance of a monospace font as a fraction of its size (Go Mono: 0.6 em).
27const ADVANCE_EM: f64 = 0.6;
28/// Height of an HRI or error text line, in dots.
29const SMALL_LINE_DOTS: u32 = 22;
30/// Font for receipt text: the bundled Go Mono first (used for PNG), then common monospace fonts.
31pub(crate) const FONT_FAMILY: &str = "'Go Mono', ui-monospace, Menlo, Consolas, monospace";
32const INK: &str = "#111";
33
34/// Renders an AST to a standalone SVG document with the same layout as [`crate::render_html`].
35///
36/// The image is 1 unit per printer dot: 612 × height for 80 mm paper (576 printable dots plus
37/// margins) and 420 × height for 58 mm. All receipt text is XML-escaped.
38///
39/// ```
40/// use hardless_escpos::parse;
41/// use hardless_render::{render_svg, RenderOptions};
42/// let svg = render_svg(&parse(b"Total: 1,250\n\x1dV\x00"), &RenderOptions::default());
43/// assert!(svg.starts_with("<svg") && svg.contains("Total: 1,250"));
44/// assert!(svg.contains(r#"width="612""#));
45/// ```
46pub fn render_svg(nodes: &[Node], options: &RenderOptions) -> String {
47    let paper = options.paper;
48    let width = paper.dots() + 2 * MARGIN_X;
49    let mut body = String::new();
50    let mut y = MARGIN_Y;
51    for block in layout(nodes, paper, options.show_unknown) {
52        match block {
53            Block::Line(line) => y += line_svg(&mut body, &line, y, paper),
54            Block::Cut { partial } => {
55                let mid = y + LINE_DOTS / 2;
56                let dashes = if partial { "2 4" } else { "8 6" };
57                let label = if partial { "partial cut" } else { "cut" };
58                let _ = write!(
59                    body,
60                    r##"<line x1="0" y1="{mid}" x2="{width}" y2="{mid}" stroke="#999" stroke-width="2" stroke-dasharray="{dashes}" aria-label="{label}"/>"##
61                );
62                y += LINE_DOTS;
63            }
64            Block::Qr {
65                align,
66                data,
67                text,
68                module_dots,
69                level,
70            } => {
71                y += BLOCK_PAD;
72                y += match codes::qr(&data, module_dots, level) {
73                    Ok(svg) => place(&mut body, &svg, align, y, paper),
74                    Err(e) => error(&mut body, &format!("{e}: {text}"), y),
75                };
76                y += BLOCK_PAD;
77            }
78            Block::Barcode {
79                align,
80                symbology,
81                data,
82                height_dots,
83                module_dots,
84                hri,
85            } => {
86                y += BLOCK_PAD;
87                let label = codes::barcode_text(symbology, &data);
88                match codes::barcode(symbology, &data, module_dots, height_dots) {
89                    Ok(svg) => {
90                        let center = f64::from(left_of(svg.width, align, paper))
91                            + f64::from(svg.width) / 2.0;
92                        if matches!(hri, HriPosition::Above | HriPosition::Both) {
93                            y += hri_svg(&mut body, &label, center, y, paper);
94                        }
95                        y += place(&mut body, &svg, align, y, paper);
96                        if matches!(hri, HriPosition::Below | HriPosition::Both) {
97                            y += hri_svg(&mut body, &label, center, y, paper);
98                        }
99                    }
100                    Err(e) => {
101                        y += error(&mut body, &format!("{} {e}", symbology_name(symbology)), y);
102                    }
103                }
104                y += BLOCK_PAD;
105            }
106            Block::Image {
107                align,
108                width_bytes,
109                height,
110                double_width,
111                double_height,
112                data,
113            } => {
114                y += BLOCK_PAD;
115                let svg = codes::image(width_bytes, height, double_width, double_height, &data);
116                y += place(&mut body, &svg, align, y, paper);
117                y += BLOCK_PAD;
118            }
119        }
120    }
121    let height = y + MARGIN_Y;
122    format!(
123        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" xml:space="preserve" font-family="{FONT_FAMILY}" role="img" aria-label="Receipt"><rect width="{width}" height="{height}" fill="#fff"/><g fill="{INK}">{body}</g></svg>"##
124    )
125}
126
127/// Draws one printed line whose top is at `top`; returns its height in dots.
128fn line_svg(out: &mut String, line: &Line, top: u32, paper: PaperWidth) -> u32 {
129    let (cols_a, cols_b) = paper.columns();
130    let line_units = cols_a * cols_b;
131    let dots_per_unit = f64::from(paper.dots()) / f64::from(line_units);
132    let pitch = LINE_DOTS * u32::from(line.height().max(1));
133    let bottom = top + pitch;
134    let free = f64::from(line_units.saturating_sub(line.columns)) * dots_per_unit;
135    let mut x = f64::from(MARGIN_X)
136        + match line.align {
137            Align::Left => 0.0,
138            Align::Center => free / 2.0,
139            Align::Right => free,
140        };
141    for segment in &line.segments {
142        match segment {
143            Segment::Text { text, style } => {
144                let columns = match style.font {
145                    Font::A => cols_a,
146                    Font::B | Font::C => cols_b,
147                };
148                let char_dots = f64::from(line_units / columns) * dots_per_unit;
149                let (w, h) = (style.width.max(1), style.height.max(1));
150                let n = text.chars().count() as f64;
151                let run = char_dots * n;
152                let baseline = bottom - DESCENT * u32::from(h);
153                if style.underline > 0 {
154                    let _ = write!(
155                        out,
156                        r#"<rect x="{x:.2}" y="{}" width="{:.2}" height="{}"/>"#,
157                        baseline + 2,
158                        run * f64::from(w),
159                        style.underline
160                    );
161                }
162                if text.chars().any(|c| c != ' ') {
163                    let _ = write!(
164                        out,
165                        r#"<text transform="translate({x:.2} {bottom}) scale({w} {h})" y="-{DESCENT}" font-size="{:.2}" textLength="{run:.2}" lengthAdjust="spacingAndGlyphs""#,
166                        char_dots / ADVANCE_EM
167                    );
168                    if style.bold {
169                        out.push_str(r#" font-weight="bold""#);
170                    }
171                    out.push('>');
172                    escape_into(out, text);
173                    out.push_str("</text>");
174                }
175                x += run * f64::from(w);
176            }
177            Segment::Unknown { label, title } => {
178                let badge = label.chars().count() as f64 * 7.0 + 8.0;
179                out.push_str("<g><title>");
180                escape_into(out, title);
181                let _ = write!(
182                    out,
183                    r##"</title><rect x="{x:.2}" y="{}" width="{badge:.2}" height="18" rx="3" fill="#fde68a"/><text x="{:.2}" y="{}" font-size="11" fill="#78350f">"##,
184                    bottom - 22,
185                    x + 4.0,
186                    bottom - 9
187                );
188                escape_into(out, label);
189                out.push_str("</text></g>");
190                x += badge + 4.0;
191            }
192        }
193    }
194    pitch
195}
196
197/// The left edge, in dots, of something `width` dots wide aligned on the paper.
198fn left_of(width: u32, align: Align, paper: PaperWidth) -> u32 {
199    let free = paper.dots().saturating_sub(width);
200    MARGIN_X
201        + match align {
202            Align::Left => 0,
203            Align::Center => free / 2,
204            Align::Right => free,
205        }
206}
207
208/// Places a code or image drawing at `y`; returns its height in dots.
209fn place(out: &mut String, svg: &codes::Svg, align: Align, y: u32, paper: PaperWidth) -> u32 {
210    let x = left_of(svg.width, align, paper);
211    let position = format!(
212        r#"<svg x="{x}" y="{y}" width="{}" height="{}" "#,
213        svg.width, svg.height
214    );
215    out.push_str(&svg.body.replacen("<svg ", &position, 1));
216    svg.height
217}
218
219/// Writes a barcode's human-readable text centred on `center`; returns its height in dots.
220fn hri_svg(out: &mut String, label: &str, center: f64, y: u32, paper: PaperWidth) -> u32 {
221    // Font B size, like the HTML output's smaller HRI text.
222    let (_, cols_b) = paper.columns();
223    let size = f64::from(paper.dots()) / f64::from(cols_b) / ADVANCE_EM;
224    let _ = write!(
225        out,
226        r#"<text x="{center:.2}" y="{}" font-size="{size:.2}" text-anchor="middle">"#,
227        y + 16
228    );
229    escape_into(out, label);
230    out.push_str("</text>");
231    SMALL_LINE_DOTS
232}
233
234/// Writes an error message in place of a code that cannot be drawn; returns its height.
235fn error(out: &mut String, message: &str, y: u32) -> u32 {
236    let _ = write!(
237        out,
238        r##"<text x="{MARGIN_X}" y="{}" font-size="14" fill="#b91c1c">"##,
239        y + 16
240    );
241    escape_into(out, message);
242    out.push_str("</text>");
243    SMALL_LINE_DOTS
244}