1use 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
11pub 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
31pub 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
121pub 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
186fn 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
222pub(crate) fn escape_into(out: &mut String, text: &str) {
224 for c in text.chars() {
225 match c {
226 '&' => out.push_str("&"),
227 '<' => out.push_str("<"),
228 '>' => out.push_str(">"),
229 '"' => out.push_str("""),
230 '\'' => out.push_str("'"),
231 c if c.is_control() => out.push('\u{fffd}'),
232 c => out.push(c),
233 }
234 }
235}