Skip to main content

cadmus_core/document/
mod.rs

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