Skip to main content

pcf_debug/render/
html.rs

1//! A self-contained HTML report: inline CSS, no JS framework, collapsible
2//! sections via native `<details>`. Consumes the same [`Report`] as the text
3//! renderer so the two never disagree.
4
5use pcf::TYPE_RAW;
6
7use super::{label_or, uid_hex, Report};
8use crate::model::algo_name;
9use crate::model::diag::Severity;
10use crate::model::{LayoutMap, RegionKind};
11use crate::plugin::{Decoded, FieldNode, FieldValue};
12
13/// Escape the five characters that matter in HTML text/attribute context.
14fn esc(s: &str) -> String {
15    let mut out = String::with_capacity(s.len());
16    for c in s.chars() {
17        match c {
18            '&' => out.push_str("&amp;"),
19            '<' => out.push_str("&lt;"),
20            '>' => out.push_str("&gt;"),
21            '"' => out.push_str("&quot;"),
22            '\'' => out.push_str("&#39;"),
23            _ => out.push(c),
24        }
25    }
26    out
27}
28
29const STYLE: &str = r#"
30:root { color-scheme: light dark; }
31body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
32       margin: 1.5rem; line-height: 1.4; }
33h1 { font-size: 1.3rem; } h2 { font-size: 1.05rem; margin-top: 1.5rem; }
34.bytemap { display: flex; width: 100%; height: 34px; border: 1px solid #8888;
35           border-radius: 4px; overflow: hidden; margin: .5rem 0; }
36.bytemap > div { min-width: 1px; }
37.k-header { background: #4e79a7; } .k-tableheader { background: #59a14f; }
38.k-entries { background: #76b7b2; } .k-data { background: #e15759; }
39.k-slack { background: #bab0ac; } .k-gap {
40    background: repeating-linear-gradient(45deg,#bbb,#bbb 4px,#ddd 4px,#ddd 8px); }
41.legend span { display: inline-block; margin-right: .8rem; }
42.legend i { display: inline-block; width: .8rem; height: .8rem; vertical-align: middle;
43            margin-right: .25rem; border: 1px solid #8884; }
44table { border-collapse: collapse; width: 100%; margin: .5rem 0; }
45th, td { border: 1px solid #8884; padding: .2rem .5rem; text-align: left;
46         font-size: .85rem; }
47th { background: #8881; }
48.ok { color: #2a8; } .fail { color: #c33; font-weight: bold; } .muted { color: #999; }
49details { margin: .3rem 0; } summary { cursor: pointer; }
50ul.fields { list-style: none; padding-left: 1.1rem; border-left: 1px dotted #8886; }
51.fname { font-weight: bold; } .frange { color: #999; font-size: .8rem; }
52.fnote { color: #999; font-style: italic; }
53.warn { color: #b80; } .diag-info { color: #888; } .diag-warn { color: #b80; }
54.diag-error { color: #c33; }
55"#;
56
57fn kind_class(kind: &RegionKind) -> &'static str {
58    match kind {
59        RegionKind::FileHeader => "k-header",
60        RegionKind::TableBlockHeader { .. } => "k-tableheader",
61        RegionKind::EntryArray { .. } => "k-entries",
62        RegionKind::PartitionData { .. } => "k-data",
63        RegionKind::Slack { .. } => "k-slack",
64        RegionKind::Gap => "k-gap",
65    }
66}
67
68fn byte_map(l: &LayoutMap) -> String {
69    let mut out = String::from("<div class=\"bytemap\">");
70    if l.file_len > 0 {
71        for r in &l.regions {
72            if r.len == 0 {
73                continue;
74            }
75            let pct = r.len as f64 / l.file_len as f64 * 100.0;
76            out.push_str(&format!(
77                "<div class=\"{}\" style=\"flex-grow:{:.4}\" title=\"{}\"></div>",
78                kind_class(&r.kind),
79                pct,
80                esc(&format!(
81                    "{:#x}..{:#x} ({} B) {}",
82                    r.start,
83                    r.end(),
84                    r.len,
85                    r.label
86                ))
87            ));
88        }
89    }
90    out.push_str("</div>");
91    out.push_str(
92        "<div class=\"legend\">\
93         <span><i class=\"k-header\"></i>header</span>\
94         <span><i class=\"k-tableheader\"></i>table header</span>\
95         <span><i class=\"k-entries\"></i>entries</span>\
96         <span><i class=\"k-data\"></i>data</span>\
97         <span><i class=\"k-slack\"></i>slack</span>\
98         <span><i class=\"k-gap\"></i>gap</span>\
99         </div>",
100    );
101    out
102}
103
104fn type_str(t: u32) -> String {
105    match t {
106        TYPE_RAW => format!("{t:#010x} (RAW)"),
107        0 => format!("{t:#010x} (RESERVED)"),
108        _ => format!("{t:#010x}"),
109    }
110}
111
112fn partition_table(l: &LayoutMap) -> String {
113    let mut out = String::from(
114        "<table><tr><th>type</th><th>uid</th><th>label</th><th>start</th>\
115         <th>used</th><th>max</th><th>free</th><th>algo</th><th>data</th></tr>",
116    );
117    for b in &l.blocks {
118        for ev in &b.entries {
119            let e = &ev.entry;
120            let verify = match ev.data_hash_ok {
121                Some(true) => "<span class=\"ok\">OK</span>",
122                Some(false) => "<span class=\"fail\">FAIL</span>",
123                None => "<span class=\"muted\">—</span>",
124            };
125            out.push_str(&format!(
126                "<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td>\
127                 <td>{}</td><td>{}</td><td>{}</td><td>{verify}</td></tr>",
128                esc(&type_str(e.partition_type)),
129                esc(&uid_hex(&e.uid)),
130                esc(&label_or(e)),
131                e.start_offset,
132                e.used_bytes,
133                e.max_length,
134                e.free_bytes(),
135                algo_name(e.data_hash_algo),
136            ));
137        }
138    }
139    out.push_str("</table>");
140    out
141}
142
143fn value_html(v: &FieldValue) -> String {
144    match v {
145        FieldValue::None => String::new(),
146        FieldValue::U64(n) => n.to_string(),
147        FieldValue::Bytes(b) => {
148            if b.is_empty() {
149                "(empty)".into()
150            } else {
151                b.iter().map(|x| format!("{x:02x}")).collect::<String>()
152            }
153        }
154        FieldValue::Text(s) => format!("&quot;{}&quot;", esc(s)),
155        FieldValue::Uid(u) => uid_hex(u),
156        FieldValue::Enum { raw, name } => format!("{raw} ({})", esc(name)),
157        FieldValue::Flags { raw, set } => {
158            if set.is_empty() {
159                format!("{raw:#x} (none)")
160            } else {
161                format!("{raw:#x} ({})", esc(&set.join("|")))
162            }
163        }
164    }
165}
166
167fn field_node(node: &FieldNode, out: &mut String) {
168    out.push_str("<li>");
169    out.push_str(&format!("<span class=\"fname\">{}</span>", esc(&node.name)));
170    let v = value_html(&node.value);
171    if !v.is_empty() {
172        out.push_str(&format!(" = {v}"));
173    }
174    if let Some((a, b)) = node.range {
175        out.push_str(&format!(" <span class=\"frange\">[{a}..{b}]</span>"));
176    }
177    if let Some(n) = &node.note {
178        out.push_str(&format!(" <span class=\"fnote\">// {}</span>", esc(n)));
179    }
180    if !node.children.is_empty() {
181        out.push_str("<ul class=\"fields\">");
182        for c in &node.children {
183            field_node(c, out);
184        }
185        out.push_str("</ul>");
186    }
187    out.push_str("</li>");
188}
189
190fn decoded_section(uid: &[u8; 16], dec: &Decoded) -> String {
191    let mut out = format!(
192        "<details><summary>uid {} — {}</summary>",
193        esc(&uid_hex(uid)),
194        esc(&dec.format_name)
195    );
196    out.push_str("<ul class=\"fields\">");
197    for f in &dec.fields {
198        field_node(f, &mut out);
199    }
200    out.push_str("</ul>");
201    for w in &dec.warnings {
202        out.push_str(&format!("<div class=\"warn\">⚠ {}</div>", esc(w)));
203    }
204    out.push_str("</details>");
205    out
206}
207
208fn diagnostics_section(l: &LayoutMap) -> String {
209    if l.diagnostics.is_empty() {
210        return "<p class=\"ok\">no anomalies</p>".into();
211    }
212    let mut out = String::from("<ul>");
213    for d in &l.diagnostics {
214        let cls = match d.severity {
215            Severity::Info => "diag-info",
216            Severity::Warning => "diag-warn",
217            Severity::Error => "diag-error",
218        };
219        out.push_str(&format!(
220            "<li class=\"{cls}\">[{}] {}</li>",
221            d.severity.tag(),
222            esc(&d.message)
223        ));
224    }
225    out.push_str("</ul>");
226    out
227}
228
229/// Render the whole report as a single self-contained HTML document.
230pub fn render(report: &Report, title: &str) -> String {
231    let l = &report.layout;
232    let mut body = String::new();
233    body.push_str(&format!("<h1>PCF report: {}</h1>", esc(title)));
234    if let Some(h) = l.header {
235        body.push_str(&format!(
236            "<p>version {}.{} · first block @ {:#x} · file length {} B</p>",
237            h.version_major, h.version_minor, h.partition_table_offset, l.file_len
238        ));
239    }
240
241    body.push_str("<h2>byte map</h2>");
242    body.push_str(&byte_map(l));
243
244    body.push_str("<h2>partitions</h2>");
245    body.push_str(&partition_table(l));
246
247    body.push_str("<h2>decoded partitions</h2>");
248    if report.decoded.is_empty() {
249        body.push_str("<p class=\"muted\">(nothing to decode)</p>");
250    } else {
251        for (uid, dec) in &report.decoded {
252            body.push_str(&decoded_section(uid, dec));
253        }
254    }
255
256    body.push_str("<h2>diagnostics</h2>");
257    body.push_str(&diagnostics_section(l));
258
259    format!(
260        "<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\
261         <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\
262         <title>{}</title><style>{STYLE}</style></head><body>{body}</body></html>\n",
263        esc(title)
264    )
265}