1pub mod djvu;
2pub mod epub;
3pub mod html;
4pub mod pdf;
5
6mod djvulibre_sys;
7mod mupdf_sys;
8
9use self::djvu::DjvuOpener;
10use self::epub::EpubDocument;
11use self::html::HtmlDocument;
12use self::pdf::PdfOpener;
13use crate::device::CURRENT_DEVICE;
14use crate::framebuffer::Pixmap;
15use crate::geom::{Boundary, CycleDir};
16use crate::metadata::{Annotation, TextAlign};
17use crate::settings::INTERNAL_CARD_ROOT;
18use anyhow::{format_err, Error};
19use fxhash::FxHashMap;
20use nix::sys::statvfs;
21#[cfg(target_os = "linux")]
22use nix::sys::sysinfo;
23use regex::Regex;
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeSet;
26use std::env;
27use std::ffi::OsStr;
28use std::fs::{self, File};
29use std::os::unix::fs::FileExt;
30use std::path::Path;
31use std::process::Command;
32use tracing::{error, warn};
33use unicode_normalization::char::is_combining_mark;
34use unicode_normalization::UnicodeNormalization;
35
36pub const BYTES_PER_PAGE: f64 = 2048.0;
37
38#[derive(Debug, Clone)]
39pub enum Location {
40 Exact(usize),
41 Previous(usize),
42 Next(usize),
43 LocalUri(usize, String),
44 Uri(String),
45}
46
47#[derive(Debug, Clone)]
48pub struct BoundedText {
49 pub text: String,
50 pub rect: Boundary,
51 pub location: TextLocation,
52}
53
54#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
55#[serde(untagged)]
56pub enum TextLocation {
57 Static(usize, usize),
58 Dynamic(usize),
59}
60
61impl TextLocation {
62 pub fn location(self) -> usize {
63 match self {
64 TextLocation::Static(page, _) => page,
65 TextLocation::Dynamic(offset) => offset,
66 }
67 }
68
69 #[inline]
70 pub fn min_max(self, other: Self) -> (Self, Self) {
71 if self < other {
72 (self, other)
73 } else {
74 (other, self)
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
80pub struct TocEntry {
81 pub title: String,
82 pub location: Location,
83 pub index: usize,
84 pub children: Vec<TocEntry>,
85}
86
87#[derive(Debug, Clone)]
88pub struct Neighbors {
89 pub previous_page: Option<usize>,
90 pub next_page: Option<usize>,
91}
92
93pub trait Document: Send + Sync {
94 fn dims(&self, index: usize) -> Option<(f32, f32)>;
95 fn pages_count(&self) -> usize;
96
97 fn toc(&mut self) -> Option<Vec<TocEntry>>;
98 fn chapter<'a>(&mut self, offset: usize, toc: &'a [TocEntry]) -> Option<(&'a TocEntry, f32)>;
99 fn chapter_relative<'a>(
100 &mut self,
101 offset: usize,
102 dir: CycleDir,
103 toc: &'a [TocEntry],
104 ) -> Option<&'a TocEntry>;
105 fn words(&mut self, loc: Location) -> Option<(Vec<BoundedText>, usize)>;
106 fn lines(&mut self, loc: Location) -> Option<(Vec<BoundedText>, usize)>;
107 fn links(&mut self, loc: Location) -> Option<(Vec<BoundedText>, usize)>;
108 fn images(&mut self, loc: Location) -> Option<(Vec<Boundary>, usize)>;
109
110 fn pixmap(&mut self, loc: Location, scale: f32, samples: usize) -> Option<(Pixmap, usize)>;
111 fn layout(&mut self, width: u32, height: u32, font_size: f32, dpi: u16);
112 fn set_font_family(&mut self, family_name: &str, search_path: &str);
113 fn set_margin_width(&mut self, width: i32);
114 fn set_text_align(&mut self, text_align: TextAlign);
115 fn set_line_height(&mut self, line_height: f32);
116 fn set_hyphen_penalty(&mut self, hyphen_penalty: i32);
117 fn set_stretch_tolerance(&mut self, stretch_tolerance: f32);
118 fn set_ignore_document_css(&mut self, ignore: bool);
119
120 fn title(&self) -> Option<String>;
121 fn author(&self) -> Option<String>;
122 fn metadata(&self, key: &str) -> Option<String>;
123
124 fn is_reflowable(&self) -> bool;
125
126 fn has_synthetic_page_numbers(&self) -> bool {
127 false
128 }
129
130 fn save(&self, _path: &str) -> Result<(), Error> {
131 Err(format_err!("this document can't be saved"))
132 }
133
134 fn preview_pixmap(&mut self, width: f32, height: f32, samples: usize) -> Option<Pixmap> {
135 self.dims(0)
136 .and_then(|dims| {
137 let scale = (width / dims.0).min(height / dims.1);
138 self.pixmap(Location::Exact(0), scale, samples)
139 })
140 .map(|(pixmap, _)| pixmap)
141 }
142
143 fn resolve_location(&mut self, loc: Location) -> Option<usize> {
144 if self.pages_count() == 0 {
145 return None;
146 }
147
148 match loc {
149 Location::Exact(index) => {
150 if index >= self.pages_count() {
151 None
152 } else {
153 Some(index)
154 }
155 }
156 Location::Previous(index) => {
157 if index > 0 {
158 Some(index - 1)
159 } else {
160 None
161 }
162 }
163 Location::Next(index) => {
164 if index < self.pages_count() - 1 {
165 Some(index + 1)
166 } else {
167 None
168 }
169 }
170 _ => None,
171 }
172 }
173}
174
175pub fn file_kind<P: AsRef<Path>>(path: P) -> Option<String> {
176 path.as_ref()
177 .extension()
178 .and_then(OsStr::to_str)
179 .map(str::to_lowercase)
180 .or_else(|| guess_kind(path.as_ref()).ok().map(String::from))
181}
182
183pub fn guess_kind<P: AsRef<Path>>(path: P) -> Result<&'static str, Error> {
184 let file = File::open(path.as_ref())?;
185 let mut magic = [0; 4];
186 file.read_exact_at(&mut magic, 0)?;
187
188 if &magic == b"PK\x03\x04" {
189 let mut mime_type = [0; 28];
190 file.read_exact_at(&mut mime_type, 30)?;
191 if &mime_type == b"mimetypeapplication/epub+zip" {
192 return Ok("epub");
193 }
194 } else if &magic == b"%PDF" {
195 return Ok("pdf");
196 } else if &magic == b"AT&T" {
197 return Ok("djvu");
198 }
199
200 Err(format_err!("Unknown file type"))
201}
202
203pub trait HumanSize {
204 fn human_size(&self) -> String;
205}
206
207const SIZE_BASE: f32 = 1024.0;
208
209impl HumanSize for u64 {
210 fn human_size(&self) -> String {
211 let value = *self as f32;
212 let level = (value.max(1.0).log(SIZE_BASE).floor() as usize).min(3);
213 let factor = value / (SIZE_BASE).powi(level as i32);
214 let precision = level.saturating_sub(1 + factor.log(10.0).floor() as usize);
215 format!(
216 "{0:.1$} {2}",
217 factor,
218 precision,
219 ['B', 'K', 'M', 'G'][level]
220 )
221 }
222}
223
224pub fn asciify(name: &str) -> String {
225 name.nfkd()
226 .filter(|&c| !is_combining_mark(c))
227 .collect::<String>()
228 .replace('œ', "oe")
229 .replace('Œ', "Oe")
230 .replace('æ', "ae")
231 .replace('Æ', "Ae")
232 .replace('—', "-")
233 .replace('–', "-")
234 .replace('’', "'")
235}
236
237#[cfg_attr(feature = "otel", tracing::instrument(skip(path), fields(path = %path.as_ref().display())))]
238pub fn open<P: AsRef<Path>>(path: P) -> Option<Box<dyn Document>> {
239 let kind = file_kind(path.as_ref());
240 if kind.is_none() {
241 warn!(path = %path.as_ref().display(), "Failed to determine file kind");
242 }
243 kind.and_then(|k| match k.as_ref() {
244 "epub" => EpubDocument::new(&path)
245 .map_err(|e| error!(path = %path.as_ref().display(), error = %e, "Failed to open epub document"))
246 .map(|d| Box::new(d) as Box<dyn Document>)
247 .ok(),
248 "html" | "htm" => HtmlDocument::new(&path)
249 .map_err(|e| error!(path = %path.as_ref().display(), error = %e, "Failed to open html document"))
250 .map(|d| Box::new(d) as Box<dyn Document>)
251 .ok(),
252 "djvu" | "djv" => {
253 let opener = DjvuOpener::new();
254 if opener.is_none() {
255 warn!(path = %path.as_ref().display(), "Failed to create DjvuOpener");
256 }
257 opener.and_then(|o| {
258 let doc = o.open(&path).map(|d| Box::new(d) as Box<dyn Document>);
259 if doc.is_none() {
260 warn!(path = %path.as_ref().display(), "DjvuOpener failed to open document");
261 }
262 doc
263 })
264 }
265 _ => {
266 let opener = PdfOpener::new();
267 if opener.is_none() {
268 warn!(path = %path.as_ref().display(), "Failed to create PdfOpener");
269 }
270 opener.and_then(|mut o| {
271 if matches!(k.as_ref(), "mobi" | "fb2" | "xps" | "txt") {
272 o.load_user_stylesheet();
273 }
274 o.open(&path)
275 .map_err(|e| warn!(
276 path = %path.as_ref().display(),
277 kind = %k,
278 error = %e,
279 "PdfOpener failed to open document"
280 ))
281 .ok()
282 .map(|d| Box::new(d) as Box<dyn Document>)
283 })
284 }
285 })
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289#[serde(untagged)]
290pub enum SimpleTocEntry {
291 Leaf(String, TocLocation),
292 Container(String, TocLocation, Vec<SimpleTocEntry>),
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
296#[serde(untagged)]
297pub enum TocLocation {
298 Exact(usize),
299 Uri(String),
300}
301
302impl From<TocLocation> for Location {
303 fn from(loc: TocLocation) -> Location {
304 match loc {
305 TocLocation::Exact(n) => Location::Exact(n),
306 TocLocation::Uri(uri) => Location::Uri(uri),
307 }
308 }
309}
310
311impl From<&TocEntry> for SimpleTocEntry {
312 fn from(entry: &TocEntry) -> SimpleTocEntry {
315 let location = match &entry.location {
316 Location::Exact(n) => TocLocation::Exact(*n),
317 Location::Uri(uri) => TocLocation::Uri(uri.clone()),
318 Location::LocalUri(n, _) => TocLocation::Exact(*n),
319 _ => TocLocation::Exact(0),
320 };
321
322 if entry.children.is_empty() {
323 SimpleTocEntry::Leaf(entry.title.clone(), location)
324 } else {
325 let children = entry.children.iter().map(SimpleTocEntry::from).collect();
326 SimpleTocEntry::Container(entry.title.clone(), location, children)
327 }
328 }
329}
330
331pub fn toc_as_html(toc: &[TocEntry], chap_index: usize) -> String {
332 let mut buf = "<html>\n\t<head>\n\t\t<title>Table of Contents</title>\n\t\t\
333 <link rel=\"stylesheet\" type=\"text/css\" href=\"css/toc.css\"/>\n\t\
334 </head>\n\t<body>\n"
335 .to_string();
336 toc_as_html_aux(toc, chap_index, 0, &mut buf);
337 buf.push_str("\t</body>\n</html>");
338 buf
339}
340
341pub fn toc_as_html_aux(toc: &[TocEntry], chap_index: usize, depth: usize, buf: &mut String) {
342 buf.push_str(&"\t".repeat(depth + 2));
343 buf.push_str("<ul>\n");
344 for entry in toc {
345 buf.push_str(&"\t".repeat(depth + 3));
346 match entry.location {
347 Location::Exact(n) => buf.push_str(&format!("<li><a href=\"@{}\">", n)),
348 Location::Uri(ref uri) => buf.push_str(&format!("<li><a href=\"@{}\">", uri)),
349 _ => buf.push_str("<li><a href=\"#\">"),
350 }
351 let title = entry.title.replace('<', "<").replace('>', ">");
352 if entry.index == chap_index {
353 buf.push_str(&format!("<strong>{}</strong>", title));
354 } else {
355 buf.push_str(&title);
356 }
357 buf.push_str("</a></li>\n");
358 if !entry.children.is_empty() {
359 toc_as_html_aux(&entry.children, chap_index, depth + 1, buf);
360 }
361 }
362 buf.push_str(&"\t".repeat(depth + 2));
363 buf.push_str("</ul>\n");
364}
365
366pub fn annotations_as_html(
367 annotations: &[Annotation],
368 active_range: Option<(TextLocation, TextLocation)>,
369) -> String {
370 let mut buf = "<html>\n\t<head>\n\t\t<title>Annotations</title>\n\t\t\
371 <link rel=\"stylesheet\" type=\"text/css\" href=\"css/annotations.css\"/>\n\t\
372 </head>\n\t<body>\n"
373 .to_string();
374 buf.push_str("\t\t<ul>\n");
375 for annot in annotations {
376 let mut note = annot.note.replace('<', "<").replace('>', ">");
377 let mut text = annot.text.replace('<', "<").replace('>', ">");
378 let start = annot.selection[0];
379 if active_range.map_or(false, |(first, last)| start >= first && start <= last) {
380 if !note.is_empty() {
381 note = format!("<b>{}</b>", note);
382 }
383 text = format!("<b>{}</b>", text);
384 }
385 if note.is_empty() {
386 buf.push_str(&format!(
387 "\t\t<li><a href=\"@{}\">{}</a></li>\n",
388 start.location(),
389 text
390 ));
391 } else {
392 buf.push_str(&format!(
393 "\t\t<li><a href=\"@{}\"><i>{}</i> — {}</a></li>\n",
394 start.location(),
395 note,
396 text
397 ));
398 }
399 }
400 buf.push_str("\t\t</ul>\n");
401 buf.push_str("\t</body>\n</html>");
402 buf
403}
404
405pub fn bookmarks_as_html(bookmarks: &BTreeSet<usize>, index: usize, synthetic: bool) -> String {
406 let mut buf = "<html>\n\t<head>\n\t\t<title>Bookmarks</title>\n\t\t\
407 <link rel=\"stylesheet\" type=\"text/css\" href=\"css/bookmarks.css\"/>\n\t\
408 </head>\n\t<body>\n"
409 .to_string();
410 buf.push_str("\t\t<ul>\n");
411 for bkm in bookmarks {
412 let mut text = if synthetic {
413 format!("{:.1}", *bkm as f64 / BYTES_PER_PAGE)
414 } else {
415 format!("{}", bkm + 1)
416 };
417 if *bkm == index {
418 text = format!("<b>{}</b>", text);
419 }
420 buf.push_str(&format!("\t\t<li><a href=\"@{}\">{}</a></li>\n", bkm, text));
421 }
422 buf.push_str("\t\t</ul>\n");
423 buf.push_str("\t</body>\n</html>");
424 buf
425}
426
427#[inline]
428fn chapter(index: usize, pages_count: usize, toc: &[TocEntry]) -> Option<(&TocEntry, f32)> {
429 let mut chap = None;
430 let mut chap_index = 0;
431 let mut end_index = pages_count;
432 chapter_aux(toc, index, &mut chap, &mut chap_index, &mut end_index);
433 chap.zip(Some(
434 (index - chap_index) as f32 / (end_index - chap_index) as f32,
435 ))
436}
437
438fn chapter_aux<'a>(
439 toc: &'a [TocEntry],
440 index: usize,
441 chap: &mut Option<&'a TocEntry>,
442 chap_index: &mut usize,
443 end_index: &mut usize,
444) {
445 for entry in toc {
446 if let Location::Exact(entry_index) = entry.location {
447 if entry_index <= index && (chap.is_none() || entry_index > *chap_index) {
448 *chap = Some(entry);
449 *chap_index = entry_index;
450 }
451 if entry_index > index && entry_index < *end_index {
452 *end_index = entry_index;
453 }
454 }
455 chapter_aux(&entry.children, index, chap, chap_index, end_index);
456 }
457}
458
459#[inline]
460fn chapter_relative(index: usize, dir: CycleDir, toc: &[TocEntry]) -> Option<&TocEntry> {
461 let chap = chapter(index, usize::MAX, toc).map(|(c, _)| c);
462
463 match dir {
464 CycleDir::Previous => previous_chapter(chap, index, toc),
465 CycleDir::Next => next_chapter(chap, index, toc),
466 }
467}
468
469fn previous_chapter<'a>(
470 chap: Option<&TocEntry>,
471 index: usize,
472 toc: &'a [TocEntry],
473) -> Option<&'a TocEntry> {
474 for entry in toc.iter().rev() {
475 let result = previous_chapter(chap, index, &entry.children);
476 if result.is_some() {
477 return result;
478 }
479
480 if let Some(chap) = chap {
481 if entry.index < chap.index {
482 if let Location::Exact(entry_index) = entry.location {
483 if entry_index != index {
484 return Some(entry);
485 }
486 }
487 }
488 } else {
489 if let Location::Exact(entry_index) = entry.location {
490 if entry_index < index {
491 return Some(entry);
492 }
493 }
494 }
495 }
496 None
497}
498
499fn next_chapter<'a>(
500 chap: Option<&TocEntry>,
501 index: usize,
502 toc: &'a [TocEntry],
503) -> Option<&'a TocEntry> {
504 for entry in toc {
505 if let Some(chap) = chap {
506 if entry.index > chap.index {
507 if let Location::Exact(entry_index) = entry.location {
508 if entry_index != index {
509 return Some(entry);
510 }
511 }
512 }
513 } else {
514 if let Location::Exact(entry_index) = entry.location {
515 if entry_index > index {
516 return Some(entry);
517 }
518 }
519 }
520
521 let result = next_chapter(chap, index, &entry.children);
522 if result.is_some() {
523 return result;
524 }
525 }
526 None
527}
528
529pub fn chapter_from_uri<'a>(target_uri: &str, toc: &'a [TocEntry]) -> Option<&'a TocEntry> {
530 for entry in toc {
531 if let Location::Uri(ref uri) = entry.location {
532 if uri.starts_with(target_uri) {
533 return Some(entry);
534 }
535 }
536 let result = chapter_from_uri(target_uri, &entry.children);
537 if result.is_some() {
538 return result;
539 }
540 }
541 None
542}
543
544const CPUINFO_KEYS: [&str; 3] = ["Processor", "Features", "Hardware"];
545const HWINFO_KEYS: [&str; 19] = [
546 "CPU",
547 "PCB",
548 "DisplayPanel",
549 "DisplayCtrl",
550 "DisplayBusWidth",
551 "DisplayResolution",
552 "FrontLight",
553 "FrontLight_LEDrv",
554 "FL_PWM",
555 "TouchCtrl",
556 "TouchType",
557 "Battery",
558 "IFlash",
559 "RamSize",
560 "RamType",
561 "LightSensor",
562 "HallSensor",
563 "RSensor",
564 "Wifi",
565];
566
567pub fn sys_info_as_html() -> String {
568 let mut buf = "<html>\n\t<head>\n\t\t<title>System Info</title>\n\t\t\
569 <link rel=\"stylesheet\" type=\"text/css\" \
570 href=\"css/sysinfo.css\"/>\n\t</head>\n\t<body>\n"
571 .to_string();
572
573 buf.push_str("\t\t<table>\n");
574
575 buf.push_str("\t\t\t<tr>\n");
576 buf.push_str("\t\t\t\t<td class=\"key\">Model name</td>\n");
577 buf.push_str(&format!(
578 "\t\t\t\t<td class=\"value\">{}</td>\n",
579 CURRENT_DEVICE.model
580 ));
581 buf.push_str("\t\t\t</tr>\n");
582
583 buf.push_str("\t\t\t<tr>\n");
584 buf.push_str("\t\t\t\t<td class=\"key\">Hardware</td>\n");
585 buf.push_str(&format!(
586 "\t\t\t\t<td class=\"value\">Mark {}</td>\n",
587 CURRENT_DEVICE.mark()
588 ));
589 buf.push_str("\t\t\t</tr>\n");
590 buf.push_str("\t\t\t<tr class=\"sep\"></tr>\n");
591
592 for (name, var) in [
593 ("Code name", "PRODUCT"),
594 ("Model number", "MODEL_NUMBER"),
595 ("Firmware version", "FIRMWARE_VERSION"),
596 ]
597 .iter()
598 {
599 if let Ok(value) = env::var(var) {
600 buf.push_str("\t\t\t<tr>\n");
601 buf.push_str(&format!("\t\t\t\t<td class=\"key\">{}</td>\n", name));
602 buf.push_str(&format!("\t\t\t\t<td class=\"value\">{}</td>\n", value));
603 buf.push_str("\t\t\t</tr>\n");
604 }
605 }
606
607 buf.push_str("\t\t\t<tr class=\"sep\"></tr>\n");
608
609 let output = Command::new("scripts/ip.sh")
610 .output()
611 .map_err(|e| error!("Can't execute command: {:#}.", e))
612 .ok();
613
614 if let Some(stdout) = output
615 .filter(|output| output.status.success())
616 .and_then(|output| String::from_utf8(output.stdout).ok())
617 .filter(|stdout| !stdout.is_empty())
618 {
619 buf.push_str("\t\t\t<tr>\n");
620 buf.push_str("\t\t\t\t<td>IP Address</td>\n");
621 buf.push_str(&format!("\t\t\t\t<td>{}</td>\n", stdout));
622 buf.push_str("\t\t\t</tr>\n");
623 }
624
625 if let Ok(info) = statvfs::statvfs(INTERNAL_CARD_ROOT) {
626 let fbs = info.fragment_size() as u64;
627 let free = info.blocks_free() as u64 * fbs;
628 let total = info.blocks() as u64 * fbs;
629 buf.push_str("\t\t\t<tr>\n");
630 buf.push_str("\t\t\t\t<td>Storage (Free / Total)</td>\n");
631 buf.push_str(&format!(
632 "\t\t\t\t<td>{} / {}</td>\n",
633 free.human_size(),
634 total.human_size()
635 ));
636 buf.push_str("\t\t\t</tr>\n");
637 }
638
639 #[cfg(target_os = "linux")]
640 if let Ok(info) = sysinfo::sysinfo() {
641 buf.push_str("\t\t\t<tr>\n");
642 buf.push_str("\t\t\t\t<td>Memory (Free / Total)</td>\n");
643 buf.push_str(&format!(
644 "\t\t\t\t<td>{} / {}</td>\n",
645 info.ram_unused().human_size(),
646 info.ram_total().human_size()
647 ));
648 buf.push_str("\t\t\t</tr>\n");
649 let load = info.load_average();
650 buf.push_str("\t\t\t<tr>\n");
651 buf.push_str("\t\t\t\t<td>Load Average</td>\n");
652 buf.push_str(&format!(
653 "\t\t\t\t<td>{:.1}% {:.1}% {:.1}%</td>\n",
654 load.0 * 100.0,
655 load.1 * 100.0,
656 load.2 * 100.0
657 ));
658 buf.push_str("\t\t\t</tr>\n");
659 }
660
661 buf.push_str("\t\t\t<tr class=\"sep\"></tr>\n");
662
663 if let Ok(info) = fs::read_to_string("/proc/cpuinfo") {
664 for line in info.lines() {
665 if let Some(index) = line.find(':') {
666 let key = line[0..index].trim();
667 let value = line[index + 1..].trim();
668 if CPUINFO_KEYS.contains(&key) {
669 buf.push_str("\t\t\t<tr>\n");
670 buf.push_str(&format!("\t\t\t\t<td class=\"key\">{}</td>\n", key));
671 buf.push_str(&format!("\t\t\t\t<td class=\"value\">{}</td>\n", value));
672 buf.push_str("\t\t\t</tr>\n");
673 }
674 }
675 }
676 }
677
678 buf.push_str("\t\t\t<tr class=\"sep\"></tr>\n");
679
680 let output = Command::new("/bin/ntx_hwconfig")
681 .args(&["-s", "/dev/mmcblk0"])
682 .output()
683 .map_err(|e| error!("Can't execute command: {:#}.", e))
684 .ok();
685
686 let mut map = FxHashMap::default();
687
688 if let Some(stdout) = output.and_then(|output| String::from_utf8(output.stdout).ok()) {
689 let re = Regex::new(r#"\[\d+\]\s+(?P<key>[^=]+)='(?P<value>[^']+)'"#).unwrap();
690 for caps in re.captures_iter(&stdout) {
691 map.insert(caps["key"].to_string(), caps["value"].to_string());
692 }
693 }
694
695 if !map.is_empty() {
696 for key in HWINFO_KEYS.iter() {
697 if let Some(value) = map.get(*key) {
698 buf.push_str("\t\t\t<tr>\n");
699 buf.push_str(&format!("\t\t\t\t<td>{}</td>\n", key));
700 buf.push_str(&format!("\t\t\t\t<td>{}</td>\n", value));
701 buf.push_str("\t\t\t</tr>\n");
702 }
703 }
704 }
705
706 buf.push_str("\t\t</table>\n\t</body>\n</html>");
707 buf
708}