Skip to main content

ui/
rich_html.rs

1// rich_html -- RichDoc as HTML and back
2//
3//   doc.to_html() -> String          a fragment: <div style="white-space:pre-wrap"> with <p>,
4//                                    <h1>..<h3>, <blockquote>, nested <ul> / <ol> (<li>), and
5//                                    per run <a href>, <span style="color / background-color /
6//                                    font-size / font-family">, <b>, <i>, <u>, <s>
7//   RichDoc::from_html(s) -> RichDoc a tolerant reader of what browsers and office programs
8//                                    put on the clipboard: blocks (p, div, h1..h6, li, ul / ol
9//                                    nesting, blockquote, pre, tr, br), inline formats (b /
10//                                    strong, i / em, u / ins, s / strike / del, a href, font,
11//                                    code, CSS in style attributes: color, background(-color),
12//                                    font-weight, font-style, text-decoration, font-size in
13//                                    pt / px / em / % / keywords, font-family, text-align),
14//                                    entities; script, style and head are skipped
15//   (crate) clip_html(paras, font, size) the clipboard's HTML: to_html plus the fragment as
16//                                    JSON in a comment (<!--jfrich ...-->), so a paste into a
17//                                    richtext keeps every attribute exactly
18//   (crate) html_read(s) -> HRead   the same reader for the Markdown / HTML viewer
19//                                    (md_doc.rs): the paragraphs and, by paragraph, what a
20//                                    viewer shows besides them -- rules, images and form fields
21//                                    (U+FFFC in the text), preformatted blocks and their
22//                                    language, the heading level, the quotation depth, table
23//                                    rows and their cells, the numbers of list items, task
24//                                    boxes; the forms and their fields (md_doc.rs "Forms").
25//                                    It reads the element tree of the HTML (md_dom.rs): the
26//                                    tags as the tree has them, elements with the hidden
27//                                    attribute or display:none left out
28//   (crate) dom_read(dom) / dom_para(dom, l)   the same for a tree of a document (MdDoc::html,
29//                                    its changes): the paragraphs, fields and forms know their
30//                                    elements; one block element's paragraph read again
31//
32// Style sheets (the viewer's reader only; RichDoc::from_html takes the style attributes as
33// before): the <style> elements of the tree (media screen / all, type text/css) and the
34// <link rel="stylesheet"> elements whose text the app gave (MdDoc::set_sheet: a link reads
35// nothing by itself) in document order give rules -- selectors of md_dom.rs (tag, *, #id,
36// .class, [attr], descendant and child) with declarations; comments, at-rules and rules whose selector is not in the subset
37// are left out.  An element's style is the cascade: the declarations of the rules that match
38// by specificity and source order, its style attribute's, then the !important ones the same
39// way (of each property the last).  The text's properties are inherited as the formats are
40// (colour, weight, style, decoration, size, family, alignment); a link takes the link colour
41// unless a rule for it gives one.  A block element (and ul, ol, nav, form ...) whose style
42// gives margins, padding, borders (width, a visible style -- drawn solid -- and colour, per
43// side) or a background gets a box (md_doc.rs MdBox; its background is the box's, not the
44// text's).  display:none hides.  Rules are indexed by the id, class or tag their subject needs;
45// matching has a budget proportional to the tree and the sheets (then no rule matches), at
46// most RULES_MAX rules.
47//
48// Whitespace outside <pre> / white-space:pre* collapses as in browsers; a paragraph is a
49// block or the text between two <br>.  Nesting is capped (NEST_MAX open blocks, formats and
50// lists: deeper tags count as text-level), so no input costs more than linear time.
51
52use alloc::collections::BTreeMap;
53use alloc::format;
54use alloc::string::String;
55use alloc::vec::Vec;
56
57use crate::md_doc::{MdBox, MdField, MdForm, MdOption, MF_BUTTON, MF_CHECKBOX, MF_HIDDEN, MF_PASSWORD, MF_RADIO, MF_RESET, MF_SELECT, MF_SUBMIT, MF_TEXT, MF_TEXTAREA};
58use crate::md_dom::{parse_sels, sel_match, void, Dom, MdNode, Sel, SelKey, K_ELEM, K_TEXT, NIL};
59use crate::rich_doc::*;
60
61fn esc(s: &str, out: &mut String) {
62    for c in s.chars() {
63        match c {
64            '&' => out.push_str("&amp;"),
65            '<' => out.push_str("&lt;"),
66            '>' => out.push_str("&gt;"),
67            '"' => out.push_str("&quot;"),
68            _ => out.push(c),
69        }
70    }
71}
72
73// the CSS of a font family: quoted unless generic
74fn css_family(f: &str) -> String {
75    if matches!(f, "serif" | "sans-serif" | "monospace" | "cursive" | "fantasy" | "system-ui") {
76        String::from(f)
77    } else {
78        format!("'{}'", f.replace('\'', ""))
79    }
80}
81
82fn run_html(t: &str, f: &Fmt, out: &mut String) {
83    let mut close: Vec<&str> = Vec::new();
84    if !f.link.is_empty() {
85        out.push_str("<a href=\"");
86        esc(&f.link, out);
87        out.push_str("\">");
88        close.push("</a>");
89    }
90    let mut css = String::new();
91    if f.color != 0 {
92        css.push_str(&format!("color:{};", color_hex(f.color)));
93    }
94    if f.bg != 0 {
95        css.push_str(&format!("background-color:{};", color_hex(f.bg)));
96    }
97    if f.size != 0 {
98        css.push_str(&format!("font-size:{}pt;", size_text(f.size)));
99    }
100    if !f.font.is_empty() {
101        css.push_str(&format!("font-family:{};", css_family(&f.font)));
102    }
103    if !css.is_empty() {
104        css.pop();
105        out.push_str("<span style=\"");
106        esc(&css, out);
107        out.push_str("\">");
108        close.push("</span>");
109    }
110    for (bit, open, end) in [(RF_BOLD, "<b>", "</b>"), (RF_ITALIC, "<i>", "</i>"), (RF_UNDER, "<u>", "</u>"), (RF_STRIKE, "<s>", "</s>")] {
111        if f.flags & bit != 0 {
112            out.push_str(open);
113            close.push(end);
114        }
115    }
116    esc(t, out);
117    for c in close.iter().rev() {
118        out.push_str(c);
119    }
120}
121
122fn para_content(p: &Para, out: &mut String) {
123    if p.is_empty() {
124        out.push_str("<br>");
125        return;
126    }
127    for (t, f) in p.runs() {
128        run_html(t, f, out);
129    }
130}
131
132fn align_css(a: u8) -> &'static str {
133    match a {
134        RA_CENTER => "text-align:center",
135        RA_RIGHT => "text-align:right",
136        RA_JUSTIFY => "text-align:justify",
137        _ => "",
138    }
139}
140
141/// the paragraphs as an HTML fragment (font: the document's family, size in tenths of pt)
142pub(crate) fn html_of(paras: &[Para], font: &str, size: u16) -> String {
143    let mut out = String::from("<div style=\"white-space:pre-wrap");
144    if !font.is_empty() {
145        out.push_str(";font-family:");
146        esc(&css_family(font), &mut out);
147    }
148    if size != 0 {
149        out.push_str(&format!(";font-size:{}pt", size_text(size)));
150    }
151    out.push_str("\">");
152    // open lists: (kind, an <li> open)
153    let mut lists: Vec<(u8, bool)> = Vec::new();
154    let close_to = |lists: &mut Vec<(u8, bool)>, n: usize, out: &mut String| {
155        while lists.len() > n {
156            let (k, li) = lists.pop().unwrap();
157            if li {
158                out.push_str("</li>");
159            }
160            out.push_str(if k == LS_NUMBER { "</ol>" } else { "</ul>" });
161        }
162    };
163    for p in paras {
164        if p.list == LS_NONE {
165            close_to(&mut lists, 0, &mut out);
166            let (tag, quote) = match p.style {
167                PS_H1 => ("h1", false),
168                PS_H2 => ("h2", false),
169                PS_H3 => ("h3", false),
170                PS_QUOTE => ("p", true),
171                _ => ("p", false),
172            };
173            if quote {
174                out.push_str("<blockquote>");
175            }
176            let mut css = String::from(align_css(p.align));
177            if p.level > 0 {
178                if !css.is_empty() {
179                    css.push(';');
180                }
181                css.push_str(&format!("margin-left:{}em", p.level as u32 * 2));
182            }
183            out.push('<');
184            out.push_str(tag);
185            if !css.is_empty() {
186                out.push_str(" style=\"");
187                out.push_str(&css);
188                out.push('"');
189            }
190            out.push('>');
191            para_content(p, &mut out);
192            out.push_str("</");
193            out.push_str(tag);
194            out.push('>');
195            if quote {
196                out.push_str("</blockquote>");
197            }
198            continue;
199        }
200        let depth = p.level as usize + 1;
201        close_to(&mut lists, depth, &mut out);
202        if lists.len() == depth && lists[depth - 1].0 != p.list {
203            close_to(&mut lists, depth - 1, &mut out);
204        }
205        if lists.len() == depth {
206            if lists[depth - 1].1 {
207                out.push_str("</li>");
208                lists[depth - 1].1 = false;
209            }
210        }
211        while lists.len() < depth {
212            let d = lists.len();
213            let kind = if d + 1 == depth { p.list } else { LS_BULLET };
214            out.push_str(if kind == LS_NUMBER {
215                match d % 3 {
216                    0 => "<ol>",
217                    1 => "<ol type=\"a\">",
218                    _ => "<ol type=\"i\">",
219                }
220            } else {
221                "<ul>"
222            });
223            lists.push((kind, false));
224            if d + 1 < depth {
225                // a deeper item without one above it: an empty item holds the list
226                out.push_str("<li>");
227                lists[d].1 = true;
228            }
229        }
230        out.push_str("<li");
231        let css = align_css(p.align);
232        if !css.is_empty() {
233            out.push_str(" style=\"");
234            out.push_str(css);
235            out.push('"');
236        }
237        out.push('>');
238        para_content(p, &mut out);
239        lists[depth - 1].1 = true;
240    }
241    close_to(&mut lists, 0, &mut out);
242    out.push_str("</div>");
243    out
244}
245
246/// the clipboard's HTML: html_of with the exact fragment as JSON in a leading comment
247pub(crate) fn clip_html(paras: &[Para], font: &str, size: u16) -> String {
248    let mut d = RichDoc::from_paras(paras.to_vec());
249    d.font = String::from(font);
250    d.size = size;
251    // a comment cannot hold "--": inside JSON strings "--" is the same text
252    let json = d.to_json().replace("--", "-\\u002d");
253    format!("<!--jfrich {}-->{}", json, html_of(paras, font, size))
254}
255
256impl RichDoc {
257    /// the document as an HTML fragment (see rich_html.rs)
258    pub fn to_html(&self) -> String {
259        html_of(self.paras(), &self.font, self.size)
260    }
261
262    /// a document from HTML (see rich_html.rs); a fragment of a richtext's clipboard is
263    /// taken exactly
264    pub fn from_html(s: &str) -> RichDoc {
265        if let Some(d) = embedded(s) {
266            return d;
267        }
268        let mut r = Reader::new();
269        r.run(s);
270        RichDoc::from_paras(r.finish())
271    }
272}
273
274// the JSON of clip_html's comment
275fn embedded(s: &str) -> Option<RichDoc> {
276    let i = s.find("<!--jfrich ")?;
277    let rest = &s[i + 11..];
278    let j = rest.find("-->")?;
279    RichDoc::from_json(&rest[..j])
280}
281
282/// (md_doc.rs) the paragraphs of HTML and what a viewer shows besides them (see the module
283/// header)
284pub(crate) fn html_read(s: &str) -> HRead {
285    let d = Dom::parse(s);
286    let sheet = Sheet::of(&d, &BTreeMap::new());
287    dom_read(&d, &sheet, false)
288}
289
290/// (md_doc.rs) html_read of a document's tree with its style sheets (handles: the paragraphs,
291/// fields and forms name their elements, HRead::ranges the paragraphs of the block elements)
292pub(crate) fn dom_read(d: &Dom, sheet: &Sheet, handles: bool) -> HRead {
293    let mut r = Reader::new();
294    r.ext = Some(Ext::default());
295    r.css = sheet.budget(d.nodes.len());
296    r.walk(d, 0, false, handles, sheet);
297    r.cap_end();
298    r.label_end();
299    let mut e = r.ext.take().unwrap_or_default();
300    // label for= and form= by id (the first element with it)
301    let mut ids: BTreeMap<String, usize> = BTreeMap::new();
302    for (i, f) in e.fields.iter().enumerate().rev() {
303        if !f.id.is_empty() {
304            ids.insert(f.id.clone(), i);
305        }
306    }
307    for (id, text) in core::mem::take(&mut e.labels) {
308        if let Some(f) = ids.get(&id).and_then(|&i| e.fields.get_mut(i)) {
309            if f.label.is_empty() && !matches!(f.kind, MF_SUBMIT | MF_RESET | MF_BUTTON) {
310                f.label = text;
311            }
312        }
313    }
314    ids.clear();
315    for (i, f) in e.forms.iter().enumerate().rev() {
316        if !f.id.is_empty() {
317            ids.insert(f.id.clone(), i);
318        }
319    }
320    for (k, id) in core::mem::take(&mut e.form_of) {
321        if let (Some(&i), Some(f)) = (ids.get(&id), e.fields.get_mut(k)) {
322            f.form = i as u32 + 1;
323        }
324    }
325    for (k, t) in core::mem::take(&mut e.titles) {
326        if let Some(f) = e.fields.get_mut(k).filter(|f| f.label.is_empty()) {
327            f.label = t;
328        }
329    }
330    let mut paras = r.finish();
331    if paras.len() == 1 && paras[0].is_empty() && e.items.is_empty() {
332        paras.clear();
333    }
334    HRead {
335        paras,
336        ext: e.items,
337        forms: e.forms,
338        fields: e.fields,
339        nodes: e.para_nodes,
340        ranges: e.ranges,
341        boxes: e.boxes,
342        para_boxes: e.para_boxes,
343        rule_boxes: e.rule_boxes,
344    }
345}
346
347/// (md_doc.rs) the paragraph block element l of a document's tree shows by itself, read again
348/// with the format its ancestors give it (None: it shows none or several)
349pub(crate) fn dom_para(d: &Dom, sheet: &Sheet, l: u32) -> Option<Para> {
350    let mut r = Reader::new();
351    r.ext = Some(Ext::default());
352    r.css = sheet.budget(64);
353    let mut chain = Vec::new();
354    let mut x = d.nodes[l as usize].parent;
355    while x != NIL {
356        chain.push(x);
357        x = d.nodes[x as usize].parent;
358    }
359    // (what the ancestors push on the reader's formats)
360    let mut f = Fmt::default();
361    for &a in chain.iter().rev() {
362        let n = &d.nodes[a as usize];
363        if n.kind == K_ELEM && pushes_fmt(&n.name) {
364            let eff = sheet.attrs_of(d, a, &mut r.css);
365            f = fmt_with(f, &n.name, eff.as_deref().unwrap_or(&n.attrs), true);
366        }
367    }
368    r.fmts.push((String::from("#"), f));
369    r.walk(d, l, true, true, sheet);
370    if r.out.len() != 1 {
371        return None;
372    }
373    r.out.pop()
374}
375
376// an element that puts a format on the reader's stack (in a document's tree, outside cells,
377// select and button, at a depth below NEST_MAX)
378fn pushes_fmt(tag: &str) -> bool {
379    !matches!(
380        tag,
381        "ul" | "ol" | "form" | "label" | "textarea" | "select" | "button" | "br" | "img" | "hr" | "meta" | "link" | "input" | "script" | "style"
382            | "head" | "title" | "template" | "noscript" | "svg"
383    )
384}
385
386// display:none in a style attribute (the last display wins)
387fn display_none(style: &str) -> bool {
388    let mut none = false;
389    for decl in style.split(';') {
390        if let Some((p, v)) = decl.split_once(':') {
391            if p.trim().eq_ignore_ascii_case("display") {
392                none = v.trim().trim_end_matches("!important").trim().eq_ignore_ascii_case("none");
393            }
394        }
395    }
396    none
397}
398
399/// what html_read returns: the paragraphs, the items by paragraph, the forms and fields
400/// (MdField::form: index + 1 into forms; HExt::Field::n: index into fields); dom_read with
401/// handles: the element of every paragraph (the innermost block; the last one may be missing:
402/// none) and the paragraphs of the block elements (element, first, end); the boxes of CSS, the
403/// boxes around every paragraph (indices of boxes, outermost first; the last one may be
404/// missing) and around the rules before paragraphs
405pub(crate) struct HRead {
406    pub paras: Vec<Para>,
407    pub ext: Vec<(usize, HExt)>,
408    pub forms: Vec<MdForm>,
409    pub fields: Vec<MdField>,
410    pub nodes: Vec<MdNode>,
411    pub ranges: Vec<(u32, u32, u32)>,
412    pub boxes: Vec<MdBox>,
413    pub para_boxes: Vec<Vec<u32>>,
414    pub rule_boxes: Vec<(usize, Vec<u32>)>,
415}
416
417/// what html_read reports about paragraph `at` (the index the item is filed under)
418#[derive(Clone, Debug, PartialEq)]
419pub(crate) enum HExt {
420    /// a form field: U+FFFC at `byte` of the paragraph (None: a hidden one, no character);
421    /// fields[n]
422    Field { byte: Option<usize>, n: usize },
423    /// the paragraph is a task item: a disabled check box at its start (checked)
424    Task(bool),
425    /// a horizontal rule before the paragraph
426    Rule,
427    /// an image: U+FFFC at `byte` of the paragraph (width / height: attributes, 0 none)
428    Image { byte: usize, src: String, alt: String, w: u32, h: u32 },
429    /// the paragraph is a line of preformatted block `block` (language from a class
430    /// "language-x" of its <code>)
431    Pre { block: u32, lang: String },
432    /// the paragraph is a heading of this level (1 ..= 6)
433    Heading(u8),
434    /// the paragraph is inside this many block quotes
435    Quote(u8),
436    /// the paragraph is a row of table `table` (header row); its cells start at these bytes
437    /// ('\t' between them) with their alignment (0 none, 1 left, 2 centre, 3 right)
438    Row { table: u32, head: bool, cells: Vec<(usize, u8)> },
439    /// the paragraph begins a list item (its number in an ordered list, else 0)
440    Item(u32),
441}
442
443// the reader's extra state for html_read
444#[derive(Default)]
445struct Ext {
446    items: Vec<(usize, HExt)>,
447    pre: u32,
448    lang: String,
449    table: u32,
450    cells: Vec<(usize, u8)>,
451    head: bool,
452    thead: bool,
453    item: Option<u32>,
454    // per open list: the next number (0: bullets)
455    nums: Vec<u32>,
456    // inside a table cell
457    cell: bool,
458    // forms and their fields; the open form (index)
459    forms: Vec<MdForm>,
460    fields: Vec<MdField>,
461    form: Option<u32>,
462    // the open <label>: for=, its text, the field inside it
463    label: Option<(String, String, Option<usize>)>,
464    // labels for= an id, form= ids and titles of fields (resolved at the end)
465    labels: Vec<(String, String)>,
466    form_of: Vec<(usize, String)>,
467    titles: Vec<(usize, String)>,
468    // the field whose content is read: a <select>'s options or a <button>'s caption (raw,
469    // collapsed at its end); the option being read has a value attribute
470    cap: Option<usize>,
471    opt_val: bool,
472    // the <textarea> whose text is read raw
473    raw: Option<usize>,
474    // dom_read with handles: the element of every paragraph, the paragraphs of the block
475    // elements (element, first, end)
476    para_nodes: Vec<MdNode>,
477    ranges: Vec<(u32, u32, u32)>,
478    // the boxes of CSS, the open ones (outermost first), the boxes around every paragraph and
479    // around the rules (by the paragraph they come before)
480    boxes: Vec<MdBox>,
481    open_boxes: Vec<u32>,
482    para_boxes: Vec<Vec<u32>>,
483    rule_boxes: Vec<(usize, Vec<u32>)>,
484}
485
486// open blocks, formats and lists at most (deeper ones are not opened)
487const NEST_MAX: usize = 64;
488// forms, fields, choices of a list at most (more count as nothing)
489const FORMS_MAX: usize = 4096;
490const FIELDS_MAX: usize = 0xFFFE;
491const OPTIONS_MAX: usize = 4096;
492
493// the bytes of a label, caption or choice kept at most
494const CAP_TEXT: usize = 8192;
495
496// s + t, at most CAP_TEXT bytes (cut at a character)
497fn push_cap(s: &mut String, t: &str) {
498    let mut n = CAP_TEXT.saturating_sub(s.len()).min(t.len());
499    while !t.is_char_boundary(n) {
500        n -= 1;
501    }
502    s.push_str(&t[..n]);
503}
504
505// text with its runs of white space as one blank, none at the ends (a no-break space stays,
506// as a blank)
507fn collapse(s: &str) -> String {
508    let mut out = String::with_capacity(s.len());
509    for w in s.split(|c: char| c.is_whitespace() && c != '\u{A0}').filter(|w| !w.is_empty()) {
510        if !out.is_empty() {
511            out.push(' ');
512        }
513        out.push_str(&w.replace('\u{A0}', " "));
514    }
515    out
516}
517
518// ------------------------------------------------------------------ the reader
519
520pub(crate) fn entity(name: &str) -> Option<char> {
521    if let Some(num) = name.strip_prefix('#') {
522        let v = if let Some(h) = num.strip_prefix(['x', 'X']) { u32::from_str_radix(h, 16).ok()? } else { num.parse().ok()? };
523        return char::from_u32(v).map(|c| if c == '\0' { '\u{FFFD}' } else { c });
524    }
525    Some(match name {
526        "amp" => '&',
527        "lt" => '<',
528        "gt" => '>',
529        "quot" => '"',
530        "apos" => '\'',
531        "nbsp" => '\u{A0}',
532        "ensp" => '\u{2002}',
533        "emsp" => '\u{2003}',
534        "thinsp" => '\u{2009}',
535        "ndash" => '–',
536        "mdash" => '—',
537        "hellip" => '…',
538        "lsquo" => '‘',
539        "rsquo" => '’',
540        "sbquo" => '‚',
541        "ldquo" => '“',
542        "rdquo" => '”',
543        "bdquo" => '„',
544        "laquo" => '«',
545        "raquo" => '»',
546        "bull" => '•',
547        "middot" => '·',
548        "copy" => '©',
549        "reg" => '®',
550        "trade" => '™',
551        "euro" => '€',
552        "deg" => '°',
553        "times" => '×',
554        "divide" => '÷',
555        "auml" => 'ä',
556        "ouml" => 'ö',
557        "uuml" => 'ü',
558        "Auml" => 'Ä',
559        "Ouml" => 'Ö',
560        "Uuml" => 'Ü',
561        "szlig" => 'ß',
562        "eacute" => 'é',
563        "egrave" => 'è',
564        "agrave" => 'à',
565        "ccedil" => 'ç',
566        "shy" => '\u{AD}',
567        _ => return None,
568    })
569}
570
571// text with its entities decoded
572pub(crate) fn decode(s: &str) -> String {
573    if !s.contains('&') {
574        return String::from(s);
575    }
576    let mut out = String::with_capacity(s.len());
577    let mut rest = s;
578    while let Some(i) = rest.find('&') {
579        out.push_str(&rest[..i]);
580        rest = &rest[i..];
581        let end = rest[1..].find(|c: char| c == ';' || c == '&' || c == '<' || c.is_whitespace()).map(|j| j + 1);
582        match end {
583            Some(j) if rest.as_bytes()[j] == b';' && j <= 12 => match entity(&rest[1..j]) {
584                Some(c) => {
585                    out.push(c);
586                    rest = &rest[j + 1..];
587                }
588                None => {
589                    out.push('&');
590                    rest = &rest[1..];
591                }
592            },
593            _ => {
594                out.push('&');
595                rest = &rest[1..];
596            }
597        }
598    }
599    out.push_str(rest);
600    out
601}
602
603// the attributes of a tag: (name lower-case, value decoded)
604pub(crate) fn attrs(s: &str) -> Vec<(String, String)> {
605    let b = s.as_bytes();
606    let mut v = Vec::new();
607    let mut i = 0;
608    while i < b.len() {
609        while i < b.len() && (b[i].is_ascii_whitespace() || b[i] == b'/') {
610            i += 1;
611        }
612        let n0 = i;
613        while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'=' && b[i] != b'/' {
614            i += 1;
615        }
616        if n0 == i {
617            break;
618        }
619        let name = s[n0..i].to_ascii_lowercase();
620        while i < b.len() && b[i].is_ascii_whitespace() {
621            i += 1;
622        }
623        let mut val = String::new();
624        if i < b.len() && b[i] == b'=' {
625            i += 1;
626            while i < b.len() && b[i].is_ascii_whitespace() {
627                i += 1;
628            }
629            if i < b.len() && (b[i] == b'"' || b[i] == b'\'') {
630                let q = b[i];
631                let v0 = i + 1;
632                i += 1;
633                while i < b.len() && b[i] != q {
634                    i += 1;
635                }
636                val = decode(&s[v0..i.min(b.len())]);
637                i += 1;
638            } else {
639                let v0 = i;
640                while i < b.len() && !b[i].is_ascii_whitespace() {
641                    i += 1;
642                }
643                val = decode(&s[v0..i]);
644            }
645        }
646        v.push((name, val));
647    }
648    v
649}
650
651// a CSS length as tenths of a point (em / %: of `base`)
652fn css_size(v: &str, base: u16) -> Option<u16> {
653    let v = v.trim().to_ascii_lowercase();
654    let num = |s: &str| -> Option<f32> { s.trim().parse::<f32>().ok() };
655    let base = if base == 0 { 110 } else { base } as f32;
656    let t = if let Some(n) = v.strip_suffix("pt") {
657        num(n)? * 10.0
658    } else if let Some(n) = v.strip_suffix("px") {
659        num(n)? * 7.5
660    } else if let Some(n) = v.strip_suffix("rem") {
661        num(n)? * 110.0
662    } else if let Some(n) = v.strip_suffix("em") {
663        num(n)? * base
664    } else if let Some(n) = v.strip_suffix('%') {
665        num(n)? * base / 100.0
666    } else {
667        match v.as_str() {
668            "xx-small" => 70.0,
669            "x-small" => 75.0,
670            "small" => 100.0,
671            "medium" => 120.0,
672            "large" => 135.0,
673            "x-large" => 180.0,
674            "xx-large" => 240.0,
675            "smaller" => base * 0.83,
676            "larger" => base * 1.2,
677            _ => num(&v)? * 7.5,
678        }
679    };
680    (t >= 10.0 && t <= 4000.0).then_some((t + 0.5) as u16)
681}
682
683// a CSS colour (#hex, rgb(), rgba(), a few names) -> 0xAARRGGBB
684fn css_color(v: &str) -> Option<u32> {
685    let v = v.trim().to_ascii_lowercase();
686    if v.starts_with('#') {
687        return parse_hex_color(&v);
688    }
689    if let Some(inner) = v.strip_prefix("rgba(").or_else(|| v.strip_prefix("rgb(")) {
690        let inner = inner.trim_end_matches(')');
691        let parts: Vec<&str> = inner.split([',', ' ', '/']).filter(|p| !p.is_empty()).collect();
692        if parts.len() < 3 {
693            return None;
694        }
695        let c = |s: &str| -> Option<u32> {
696            if let Some(p) = s.strip_suffix('%') {
697                Some((p.trim().parse::<f32>().ok()? * 2.55).clamp(0.0, 255.0) as u32)
698            } else {
699                Some(s.trim().parse::<f32>().ok()?.clamp(0.0, 255.0) as u32)
700            }
701        };
702        let a = match parts.get(3) {
703            Some(s) if s.ends_with('%') => (s.trim_end_matches('%').parse::<f32>().ok()? * 2.55) as u32,
704            Some(s) => (s.parse::<f32>().ok()? * 255.0) as u32,
705            None => 255,
706        };
707        if a == 0 {
708            return None; // transparent: no colour
709        }
710        return Some(a.min(255) << 24 | c(parts[0])? << 16 | c(parts[1])? << 8 | c(parts[2])?);
711    }
712    let rgb = match v.as_str() {
713        "black" => 0x000000,
714        "white" => 0xFFFFFF,
715        "red" => 0xFF0000,
716        "green" => 0x008000,
717        "blue" => 0x0000FF,
718        "yellow" => 0xFFFF00,
719        "orange" => 0xFFA500,
720        "purple" => 0x800080,
721        "gray" | "grey" => 0x808080,
722        "silver" => 0xC0C0C0,
723        "maroon" => 0x800000,
724        "navy" => 0x000080,
725        "teal" => 0x008080,
726        "olive" => 0x808000,
727        "lime" => 0x00FF00,
728        "aqua" | "cyan" => 0x00FFFF,
729        "fuchsia" | "magenta" => 0xFF00FF,
730        _ => return None,
731    };
732    Some(0xFF00_0000 | rgb)
733}
734
735// a block's attributes and text settings (node: its element in a document's tree)
736#[derive(Clone)]
737struct Block {
738    tag: String,
739    align: u8,
740    style: u8,
741    pre: bool,
742    node: MdNode,
743}
744
745struct Reader {
746    out: Vec<Para>,
747    cur: Para,
748    // the paragraph holds something (text or an explicit break)
749    started: bool,
750    // the last character added was a collapsed blank
751    space: bool,
752    fmts: Vec<(String, Fmt)>,
753    blocks: Vec<Block>,
754    // open lists: LS_*
755    lists: Vec<u8>,
756    // the paragraph is a list item
757    item: Option<(u8, u8)>,
758    skip: Option<String>,
759    // html_read: what a viewer shows besides the paragraphs
760    ext: Option<Ext>,
761    // dom_read: the element being opened, what matching the style sheets' rules may still cost
762    node: MdNode,
763    css: usize,
764}
765
766const BLOCKS: [&str; 22] = [
767    "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote", "pre", "tr", "dt", "dd", "section", "article", "header", "footer",
768    "table", "address", "figure", "center",
769];
770
771impl Reader {
772    fn new() -> Reader {
773        Reader {
774            out: Vec::new(),
775            cur: Para::new(),
776            started: false,
777            space: false,
778            fmts: Vec::new(),
779            blocks: Vec::new(),
780            lists: Vec::new(),
781            item: None,
782            skip: None,
783            ext: None,
784            node: MdNode::NONE,
785            css: 0,
786        }
787    }
788
789    // html_read: the elements of a tree in document order (below `top`, or `top` itself when
790    // `own`) as the stream of their tags, each with the style the sheet's cascade gives it;
791    // hidden ones (the hidden attribute, display:none) are left out, raw text is skipped but a
792    // text area's; a box of CSS (MdBox) around the paragraphs of a block element whose style
793    // gives one.  handles: paragraphs, fields and forms name their elements, the block
794    // elements' paragraphs are noted
795    fn walk(&mut self, d: &Dom, top: u32, own: bool, handles: bool, sheet: &Sheet) {
796        // (node, Some(paragraphs before its content, a box opened): its end)
797        let mut st: Vec<(u32, Option<(usize, bool)>)> = Vec::new();
798        let push_kids = |st: &mut Vec<(u32, Option<(usize, bool)>)>, x: u32| {
799            let mut k = d.nodes[x as usize].last;
800            while k != NIL {
801                st.push((k, None));
802                k = d.nodes[k as usize].prev;
803            }
804        };
805        if own {
806            st.push((top, None));
807        } else {
808            push_kids(&mut st, top);
809        }
810        while let Some((x, end)) = st.pop() {
811            let n = &d.nodes[x as usize];
812            if let Some((p0, boxed)) = end {
813                self.close(&n.name);
814                if let Some(e) = self.ext.as_mut() {
815                    if boxed {
816                        e.open_boxes.pop();
817                    }
818                    if handles && BLOCKS.contains(&n.name.as_str()) {
819                        e.ranges.push((x, p0 as u32, self.out.len() as u32));
820                    }
821                }
822                continue;
823            }
824            if n.kind == K_TEXT {
825                self.text_dec(&n.name);
826                continue;
827            }
828            if n.kind != K_ELEM || d.hidden_attr(x) {
829                continue;
830            }
831            let eff = if sheet.is_empty() { None } else { sheet.attrs_of(d, x, &mut self.css) };
832            let at: &[(String, String)] = eff.as_deref().unwrap_or(&n.attrs);
833            let style = at.iter().find(|a| a.0 == "style").map(|a| a.1.as_str());
834            if style.is_some_and(display_none) {
835                continue;
836            }
837            self.node = if handles { d.handle(x) } else { MdNode::NONE };
838            // (a box's place among the quotes and lists: those around its element)
839            let depth = self.blocks.iter().filter(|b| b.tag == "blockquote").count() + self.lists.len();
840            self.open(&n.name, at, false);
841            if void(&n.name) {
842                continue;
843            }
844            if self.skip.is_some() {
845                // raw text: a text area's is its value, the rest is skipped
846                self.raw_text_dec(&d.text_of(x));
847                self.skip = None;
848                self.close(&n.name);
849                continue;
850            }
851            let mut boxed = false;
852            if let (Some(e), Some(s)) = (self.ext.as_mut(), style) {
853                if box_tag(&n.name) {
854                    if let Some(mut b) = box_of(s) {
855                        b.depth = depth.min(255) as u8;
856                        b.node = self.node;
857                        e.open_boxes.push(e.boxes.len() as u32);
858                        e.boxes.push(b);
859                        boxed = true;
860                    }
861                }
862            }
863            st.push((x, Some((self.out.len(), boxed))));
864            push_kids(&mut st, x);
865        }
866    }
867
868    fn fmt(&self) -> Fmt {
869        self.fmts.last().map(|f| f.1.clone()).unwrap_or_default()
870    }
871
872    fn pre(&self) -> bool {
873        self.blocks.iter().any(|b| b.pre)
874    }
875
876    // finish the current paragraph (an empty one only when forced)
877    fn flush(&mut self, force: bool) {
878        if !self.started && !force {
879            return;
880        }
881        let mut p = core::mem::take(&mut self.cur);
882        // trailing collapsed blanks go
883        if self.space && !p.is_empty() {
884            let t: String = String::from(p.text().trim_end_matches(' '));
885            let mut q = Para::new();
886            for (s, e, f) in p.spans() {
887                let e = e.min(t.len());
888                if s < e {
889                    q.push(&t[s..e], f);
890                }
891            }
892            q.mark = p.mark.clone();
893            p = q;
894        }
895        let b = self.blocks.last();
896        p.align = self.blocks.iter().rev().map(|b| b.align).find(|&a| a != RA_LEFT).unwrap_or(RA_LEFT);
897        p.style = b.map_or(PS_NORMAL, |_| self.blocks.iter().rev().map(|b| b.style).find(|&s| s != PS_NORMAL).unwrap_or(PS_NORMAL));
898        if let Some((kind, level)) = self.item {
899            p.list = kind;
900            p.level = level;
901        }
902        p.mark = self.fmt();
903        if self.ext.is_some() {
904            self.ext_para();
905        }
906        self.out.push(p);
907        self.cur = Para::new();
908        self.started = false;
909        self.space = false;
910    }
911
912    // html_read: what the paragraph about to be filed is besides its text
913    fn ext_para(&mut self) {
914        let at = self.out.len();
915        let pre = self.pre();
916        let level = self.blocks.iter().rev().find_map(|b| {
917            let t = b.tag.as_bytes();
918            (t.len() == 2 && t[0] == b'h' && (b'1'..=b'6').contains(&t[1])).then(|| t[1] - b'0')
919        });
920        let quote = self.blocks.iter().filter(|b| b.tag == "blockquote").count().min(255) as u8;
921        let row = self.blocks.last().is_some_and(|b| b.tag == "tr");
922        let node = self.blocks.last().map_or(MdNode::NONE, |b| b.node);
923        let Some(e) = self.ext.as_mut() else { return };
924        e.para_nodes.push(node);
925        e.para_boxes.push(e.open_boxes.clone());
926        if pre {
927            e.items.push((at, HExt::Pre { block: e.pre, lang: e.lang.clone() }));
928        }
929        if let Some(l) = level {
930            e.items.push((at, HExt::Heading(l)));
931        }
932        if quote > 0 {
933            e.items.push((at, HExt::Quote(quote)));
934        }
935        if let Some(n) = e.item.take() {
936            e.items.push((at, HExt::Item(n)));
937        }
938        if row && !e.cells.is_empty() {
939            let cells = core::mem::take(&mut e.cells);
940            e.items.push((at, HExt::Row { table: e.table, head: e.head || e.thead, cells }));
941        }
942        e.cells.clear();
943        e.head = false;
944    }
945
946    // html_read: an element of a viewer (img, hr, cells, pre, code, lists, tables)
947    fn ext_open(&mut self, tag: &str, at: &[(String, String)]) {
948        let attr = |k: &str| at.iter().find(|a| a.0 == k).map(|a| a.1.as_str());
949        let num = |k: &str| attr(k).and_then(|v| v.trim().trim_end_matches("px").parse::<u32>().ok()).unwrap_or(0).min(100_000);
950        match tag {
951            "img" => {
952                let f = self.fmt();
953                let byte = self.cur.len();
954                self.cur.push("\u{FFFC}", &f);
955                self.started = true;
956                self.space = false;
957                let (src, alt) = (String::from(attr("src").unwrap_or("")), String::from(attr("alt").unwrap_or("")));
958                let (w, h) = (num("width"), num("height"));
959                let n = self.out.len();
960                if let Some(e) = self.ext.as_mut() {
961                    e.items.push((n, HExt::Image { byte, src, alt, w, h }));
962                }
963            }
964            "td" | "th" => {
965                let f = self.fmt();
966                let first = self.ext.as_ref().is_none_or(|e| e.cells.is_empty());
967                if !first {
968                    self.cur.push("\t", &f);
969                }
970                self.started = true;
971                self.space = false;
972                let byte = self.cur.len();
973                let a = attr("align").or_else(|| {
974                    attr("style").and_then(|v| v.split(';').filter_map(|d| d.split_once(':')).find(|(p, _)| p.trim() == "text-align").map(|(_, x)| x.trim()))
975                });
976                let align = match a.map(|s| s.to_ascii_lowercase()).as_deref() {
977                    Some("left") | Some("start") => 1,
978                    Some("center") => 2,
979                    Some("right") | Some("end") => 3,
980                    _ => 0,
981                };
982                if let Some(e) = self.ext.as_mut() {
983                    e.cells.push((byte, align));
984                    e.head |= tag == "th";
985                    e.cell = true;
986                }
987            }
988            _ => {
989                let Some(e) = self.ext.as_mut() else { return };
990                match tag {
991                    "table" => {
992                        e.table += 1;
993                        e.thead = false;
994                    }
995                    "thead" => e.thead = true,
996                    "tbody" | "tfoot" => e.thead = false,
997                    "pre" => {
998                        e.pre += 1;
999                        e.lang.clear();
1000                    }
1001                    "code" => {
1002                        if let Some(c) = attr("class") {
1003                            for w in c.split_whitespace() {
1004                                if let Some(l) = w.strip_prefix("language-").or_else(|| w.strip_prefix("lang-")) {
1005                                    e.lang = String::from(l);
1006                                }
1007                            }
1008                        }
1009                    }
1010                    "ol" => {
1011                        let start = attr("start").and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(1);
1012                        e.nums.push(start.min(999_999_999));
1013                    }
1014                    "ul" => e.nums.push(0),
1015                    "li" => {
1016                        let n = match e.nums.last_mut() {
1017                            Some(n) if *n > 0 => {
1018                                let v = *n;
1019                                *n = n.saturating_add(1);
1020                                v
1021                            }
1022                            _ => 0,
1023                        };
1024                        e.item = Some(n);
1025                    }
1026                    _ => {}
1027                }
1028            }
1029        }
1030    }
1031
1032    fn text(&mut self, raw: &str) {
1033        self.text_dec(&decode(raw));
1034    }
1035
1036    // text with its entities decoded
1037    fn text_dec(&mut self, t: &str) {
1038        // html_read: inside a <select> or <button> the text is the field's; the text of an open
1039        // <label> names its field
1040        if let Some(e) = self.ext.as_mut() {
1041            if let Some(fd) = e.cap.and_then(|f| e.fields.get_mut(f)) {
1042                if fd.kind != MF_SELECT {
1043                    push_cap(&mut fd.label, &t);
1044                } else if let Some(o) = fd.options.last_mut() {
1045                    push_cap(&mut o.label, &t);
1046                }
1047                return;
1048            }
1049            if let Some(l) = e.label.as_mut() {
1050                push_cap(&mut l.1, &t);
1051            }
1052        }
1053        if self.pre() {
1054            let f = self.fmt();
1055            let mut first = true;
1056            for line in lines_of(&t) {
1057                if !first {
1058                    self.flush(true);
1059                }
1060                first = false;
1061                self.cur.push(&line.replace('\u{A0}', " "), &f);
1062                self.started = true;
1063            }
1064            return;
1065        }
1066        let mut s = String::new();
1067        for c in t.chars() {
1068            if c.is_whitespace() && c != '\u{A0}' {
1069                let at_start = !self.started && s.is_empty();
1070                if !self.space && !at_start {
1071                    s.push(' ');
1072                    self.space = true;
1073                }
1074            } else {
1075                s.push(if c == '\u{A0}' { ' ' } else { c });
1076                self.space = false;
1077            }
1078        }
1079        if !s.is_empty() {
1080            let f = self.fmt();
1081            self.cur.push(&s, &f);
1082            self.started = true;
1083        }
1084    }
1085
1086    // the format an element's attributes give (on top of the current one)
1087    fn fmt_for(&self, tag: &str, at: &[(String, String)]) -> Fmt {
1088        fmt_with(self.fmt(), tag, at, self.ext.is_some())
1089    }
1090
1091    // html_read inside a table cell: blocks, lists and breaks are part of the cell's text
1092    fn in_cell(&self, tag: &str) -> bool {
1093        self.ext.as_ref().is_some_and(|e| e.cell) && !matches!(tag, "tr" | "td" | "th" | "table" | "thead" | "tbody" | "tfoot")
1094    }
1095
1096    // html_read: a new field -- its U+FFFC (none for a hidden one) in the paragraph, the open
1097    // form's, the open label's -> its index (None: FIELDS_MAX reached)
1098    fn field_new(&mut self, mut fd: MdField, at: &[(String, String)]) -> Option<usize> {
1099        let attr = |k: &str| at.iter().find(|a| a.0 == k).map(|a| a.1.as_str());
1100        let n = self.ext.as_ref()?.fields.len();
1101        if n >= FIELDS_MAX {
1102            return None;
1103        }
1104        fd.name = String::from(attr("name").unwrap_or(""));
1105        fd.id = String::from(attr("id").unwrap_or("").trim());
1106        fd.node = self.node;
1107        fd.disabled = attr("disabled").is_some();
1108        fd.readonly = attr("readonly").is_some();
1109        if !matches!(fd.kind, MF_SUBMIT | MF_RESET | MF_BUTTON) {
1110            fd.label = collapse(attr("aria-label").unwrap_or(""));
1111        }
1112        let byte = if fd.kind == MF_HIDDEN {
1113            None
1114        } else {
1115            let f = self.fmt();
1116            let b = self.cur.len();
1117            self.cur.push("\u{FFFC}", &f);
1118            self.started = true;
1119            self.space = false;
1120            Some(b)
1121        };
1122        let para = self.out.len();
1123        let e = self.ext.as_mut()?;
1124        fd.form = e.form.map_or(0, |i| i + 1);
1125        if let Some(id) = attr("form") {
1126            e.form_of.push((n, String::from(id.trim())));
1127        }
1128        if let Some(t) = attr("title") {
1129            e.titles.push((n, collapse(t)));
1130        }
1131        if let Some(l) = e.label.as_mut() {
1132            if l.2.is_none() {
1133                l.2 = Some(n);
1134            }
1135        }
1136        e.fields.push(fd);
1137        e.items.push((para, HExt::Field { byte, n }));
1138        Some(n)
1139    }
1140
1141    // html_read: the choice read last gets its text (collapsed) and, without a value
1142    // attribute, that text as its value
1143    fn option_end(fd: &mut MdField, had_value: bool) {
1144        if let Some(o) = fd.options.last_mut() {
1145            o.label = collapse(&o.label);
1146            if !had_value {
1147                o.value = o.label.clone();
1148            }
1149        }
1150    }
1151
1152    // html_read: the end of a <select>'s or <button>'s content
1153    fn cap_end(&mut self) {
1154        let Some(e) = self.ext.as_mut() else { return };
1155        let Some(f) = e.cap.take() else { return };
1156        let had_value = e.opt_val;
1157        let Some(fd) = e.fields.get_mut(f) else { return };
1158        if fd.kind == MF_SELECT {
1159            Self::option_end(fd, had_value);
1160            if fd.selected < 0 && !fd.options.is_empty() {
1161                fd.selected = 0;
1162            }
1163        } else {
1164            fd.label = collapse(&fd.label);
1165        }
1166    }
1167
1168    // html_read: the end of a <label>: its text names the field inside it, or the one with the
1169    // id of its for= (resolved at the end); an aria-label wins
1170    fn label_end(&mut self) {
1171        let Some(e) = self.ext.as_mut() else { return };
1172        let Some((to, text, field)) = e.label.take() else { return };
1173        let text = collapse(&text);
1174        if text.is_empty() {
1175            return;
1176        }
1177        if !to.is_empty() {
1178            e.labels.push((to, text));
1179        } else if let Some(fd) = field.and_then(|f| e.fields.get_mut(f)) {
1180            if fd.label.is_empty() && !matches!(fd.kind, MF_SUBMIT | MF_RESET | MF_BUTTON) {
1181                fd.label = text;
1182            }
1183        }
1184    }
1185
1186    // html_read: a <textarea>'s text (raw; entities decoded, line ends as '\n', the first one
1187    // dropped)
1188    fn raw_text(&mut self, s: &str) {
1189        if self.ext.as_ref().is_some_and(|e| e.raw.is_some()) {
1190            self.raw_text_dec(&decode(s));
1191        }
1192    }
1193
1194    // the same with the entities decoded
1195    fn raw_text_dec(&mut self, s: &str) {
1196        let Some(e) = self.ext.as_mut() else { return };
1197        let Some(fd) = e.raw.take().and_then(|f| e.fields.get_mut(f)) else { return };
1198        let t = s.replace("\r\n", "\n").replace('\r', "\n");
1199        fd.value = String::from(t.strip_prefix('\n').unwrap_or(&t));
1200    }
1201
1202    // html_read: forms and their fields (form, label, input, textarea, select / option,
1203    // button) -> the tag was taken
1204    fn form_open(&mut self, tag: &str, at: &[(String, String)]) -> bool {
1205        let attr = |k: &str| at.iter().find(|a| a.0 == k).map(|a| a.1.as_str());
1206        let num = |k: &str| attr(k).and_then(|v| v.trim().parse::<u32>().ok()).unwrap_or(0).min(100_000);
1207        let started = self.started;
1208        let Some(e) = self.ext.as_mut() else { return false };
1209        // inside a <select> or <button>: choices, a picture's alt text; other tags count as
1210        // nothing, a new field ends it
1211        if let Some(fd) = e.cap.and_then(|f| e.fields.get_mut(f)) {
1212            match tag {
1213                "input" | "select" | "textarea" | "button" => {}
1214                "option" if fd.kind == MF_SELECT => {
1215                    if fd.options.len() < OPTIONS_MAX {
1216                        Self::option_end(fd, e.opt_val);
1217                        if attr("selected").is_some() {
1218                            fd.selected = fd.options.len() as i32;
1219                        }
1220                        fd.options.push(MdOption { value: String::from(attr("value").unwrap_or("")), label: String::new() });
1221                        e.opt_val = attr("value").is_some();
1222                    }
1223                    return true;
1224                }
1225                "img" if fd.kind != MF_SELECT => {
1226                    push_cap(&mut fd.label, attr("alt").unwrap_or(""));
1227                    return true;
1228                }
1229                _ => return true,
1230            }
1231            self.cap_end();
1232        }
1233        let Some(e) = self.ext.as_mut() else { return false };
1234        let field = |kind: u8| MdField { kind, selected: -1, ..MdField::default() };
1235        match tag {
1236            "form" => {
1237                // (forms do not nest: an inner one counts as nothing)
1238                if e.form.is_none() && e.forms.len() < FORMS_MAX {
1239                    let method = if attr("method").is_some_and(|m| m.trim().eq_ignore_ascii_case("post")) { "post" } else { "get" };
1240                    e.forms.push(MdForm {
1241                        action: String::from(attr("action").unwrap_or("").trim()),
1242                        method: String::from(method),
1243                        id: String::from(attr("id").unwrap_or("").trim()),
1244                        name: String::from(attr("name").unwrap_or("")),
1245                        node: self.node,
1246                    });
1247                    e.form = Some(e.forms.len() as u32 - 1);
1248                    if !self.in_cell(tag) {
1249                        self.flush(false);
1250                    }
1251                }
1252            }
1253            "label" => {
1254                self.label_end();
1255                if let Some(e) = self.ext.as_mut() {
1256                    e.label = Some((String::from(attr("for").unwrap_or("").trim()), String::new(), None));
1257                }
1258            }
1259            "input" => {
1260                let ty = attr("type").unwrap_or("text").trim().to_ascii_lowercase();
1261                let kind = match ty.as_str() {
1262                    "password" => MF_PASSWORD,
1263                    "checkbox" => MF_CHECKBOX,
1264                    "radio" => MF_RADIO,
1265                    "hidden" => MF_HIDDEN,
1266                    "submit" | "image" => MF_SUBMIT,
1267                    "reset" => MF_RESET,
1268                    "button" => MF_BUTTON,
1269                    _ => MF_TEXT,
1270                };
1271                let checked = attr("checked").is_some();
1272                // a disabled check box at the start of a list item: the item's task box
1273                if kind == MF_CHECKBOX && attr("disabled").is_some() && e.item.is_some() && !started {
1274                    e.items.push((self.out.len(), HExt::Task(checked)));
1275                    return true;
1276                }
1277                let value = attr("value");
1278                let mut fd = field(kind);
1279                fd.checked = checked;
1280                fd.value = String::from(match kind {
1281                    MF_CHECKBOX | MF_RADIO => value.unwrap_or("on"),
1282                    _ => value.unwrap_or(""),
1283                });
1284                if kind == MF_TEXT || kind == MF_PASSWORD {
1285                    fd.value.retain(|c| c != '\r' && c != '\n');
1286                }
1287                if matches!(kind, MF_SUBMIT | MF_RESET | MF_BUTTON) {
1288                    fd.label = collapse(if ty == "image" { attr("alt").unwrap_or("") } else { value.unwrap_or("") });
1289                }
1290                fd.placeholder = collapse(attr("placeholder").unwrap_or(""));
1291                fd.size = num("size");
1292                fd.maxlength = num("maxlength");
1293                self.field_new(fd, at);
1294            }
1295            "textarea" => {
1296                let mut fd = field(MF_TEXTAREA);
1297                fd.placeholder = collapse(attr("placeholder").unwrap_or(""));
1298                fd.size = num("cols");
1299                fd.rows = num("rows");
1300                fd.maxlength = num("maxlength");
1301                let n = self.field_new(fd, at);
1302                if let Some(e) = self.ext.as_mut() {
1303                    e.raw = n;
1304                }
1305                // (the text up to </textarea> is the field's: run reads it raw)
1306                self.skip = Some(String::from("textarea"));
1307            }
1308            "select" | "button" => {
1309                let kind = match (tag, attr("type").map(|t| t.trim().to_ascii_lowercase()).as_deref()) {
1310                    ("select", _) => MF_SELECT,
1311                    (_, Some("reset")) => MF_RESET,
1312                    (_, Some("button")) => MF_BUTTON,
1313                    _ => MF_SUBMIT,
1314                };
1315                let mut fd = field(kind);
1316                if kind != MF_SELECT {
1317                    fd.value = String::from(attr("value").unwrap_or(""));
1318                }
1319                let n = self.field_new(fd, at);
1320                if let Some(e) = self.ext.as_mut() {
1321                    e.cap = n;
1322                    e.opt_val = false;
1323                }
1324            }
1325            _ => return false,
1326        }
1327        true
1328    }
1329
1330    // html_read: the end of a form, a label, a list or a button -> the tag was taken
1331    fn form_close(&mut self, tag: &str) -> bool {
1332        let Some(e) = self.ext.as_ref() else { return false };
1333        if let Some(f) = e.cap {
1334            let select = e.fields.get(f).is_some_and(|fd| fd.kind == MF_SELECT);
1335            match tag {
1336                "select" if select => {}
1337                "button" if !select => {}
1338                "form" => {}
1339                // (end tags inside a <select> or <button> count as nothing)
1340                _ => return true,
1341            }
1342            self.cap_end();
1343            if tag != "form" {
1344                return true;
1345            }
1346        }
1347        match tag {
1348            "label" => self.label_end(),
1349            "form" => {
1350                if self.ext.as_ref().is_some_and(|e| e.form.is_some()) {
1351                    if !self.in_cell(tag) {
1352                        self.flush(false);
1353                    }
1354                    if let Some(e) = self.ext.as_mut() {
1355                        e.form = None;
1356                    }
1357                }
1358            }
1359            _ => return false,
1360        }
1361        true
1362    }
1363
1364    fn open(&mut self, tag: &str, at: &[(String, String)], selfclose: bool) {
1365        if self.ext.is_some() && self.form_open(tag, at) {
1366            return;
1367        }
1368        if self.in_cell(tag) && (BLOCKS.contains(&tag) || matches!(tag, "br" | "hr" | "ul" | "ol")) {
1369            if !self.space && self.started {
1370                self.text(" ");
1371            }
1372            return;
1373        }
1374        match tag {
1375            "script" | "style" | "head" | "title" | "template" | "noscript" | "svg" => {
1376                if !selfclose {
1377                    self.skip = Some(String::from(tag));
1378                }
1379                return;
1380            }
1381            "br" => {
1382                self.flush(true);
1383                return;
1384            }
1385            "img" | "hr" | "meta" | "link" | "input" => {
1386                if tag == "hr" {
1387                    self.flush(false);
1388                    if let Some(e) = self.ext.as_mut() {
1389                        e.items.push((self.out.len(), HExt::Rule));
1390                        e.rule_boxes.push((self.out.len(), e.open_boxes.clone()));
1391                    }
1392                } else if tag == "img" && self.ext.is_some() {
1393                    self.ext_open(tag, at);
1394                }
1395                return;
1396            }
1397            "ul" | "ol" => {
1398                self.flush(false);
1399                if self.lists.len() < NEST_MAX {
1400                    self.lists.push(if tag == "ol" { LS_NUMBER } else { LS_BULLET });
1401                    if self.ext.is_some() {
1402                        self.ext_open(tag, at);
1403                    }
1404                }
1405                return;
1406            }
1407            _ => {}
1408        }
1409        // deeper than NEST_MAX: the tag counts as nothing (its text stays)
1410        if self.blocks.len() >= NEST_MAX || self.fmts.len() >= NEST_MAX {
1411            return;
1412        }
1413        let block = BLOCKS.contains(&tag);
1414        if self.ext.is_some() && matches!(tag, "table" | "thead" | "tbody" | "tfoot" | "code") {
1415            self.ext_open(tag, at);
1416        }
1417        if block {
1418            self.flush(false);
1419            let mut align = RA_LEFT;
1420            for (k, v) in at {
1421                let v = v.to_ascii_lowercase();
1422                let a = if k == "align" {
1423                    Some(v.as_str())
1424                } else if k == "style" {
1425                    v.split(';').filter_map(|d| d.split_once(':')).find(|(p, _)| p.trim() == "text-align").map(|(_, x)| x.trim())
1426                } else {
1427                    None
1428                };
1429                align = match a {
1430                    Some("center") => RA_CENTER,
1431                    Some("right") | Some("end") => RA_RIGHT,
1432                    Some("justify") => RA_JUSTIFY,
1433                    _ => align,
1434                };
1435            }
1436            if tag == "center" {
1437                align = RA_CENTER;
1438            }
1439            let style = match tag {
1440                "h1" => PS_H1,
1441                "h2" => PS_H2,
1442                "h3" | "h4" | "h5" | "h6" => PS_H3,
1443                "blockquote" => PS_QUOTE,
1444                _ => PS_NORMAL,
1445            };
1446            let pre = tag == "pre"
1447                || at.iter().any(|(k, v)| k == "style" && v.to_ascii_lowercase().replace(' ', "").contains("white-space:pre"));
1448            self.blocks.push(Block { tag: String::from(tag), align, style, pre, node: self.node });
1449            if tag == "li" {
1450                let kind = self.lists.last().copied().unwrap_or(LS_BULLET);
1451                self.item = Some((kind, (self.lists.len().max(1) - 1).min(LEVEL_MAX as usize) as u8));
1452            }
1453            if self.ext.is_some() && matches!(tag, "pre" | "li") {
1454                self.ext_open(tag, at);
1455            }
1456        }
1457        if self.ext.is_some() && matches!(tag, "td" | "th") {
1458            self.ext_open(tag, at);
1459        }
1460        if selfclose {
1461            if block {
1462                self.close(tag);
1463            }
1464            return;
1465        }
1466        let f = self.fmt_for(tag, at);
1467        self.fmts.push((String::from(tag), f));
1468    }
1469
1470    fn close(&mut self, tag: &str) {
1471        if self.ext.is_some() && self.form_close(tag) {
1472            return;
1473        }
1474        if let Some(i) = self.fmts.iter().rposition(|f| f.0 == tag) {
1475            self.fmts.truncate(i);
1476        }
1477        if self.in_cell(tag) && (BLOCKS.contains(&tag) || matches!(tag, "ul" | "ol")) {
1478            return;
1479        }
1480        if let Some(e) = self.ext.as_mut() {
1481            if matches!(tag, "td" | "th" | "tr" | "table") {
1482                e.cell = false;
1483            }
1484        }
1485        match tag {
1486            "ul" | "ol" => {
1487                self.flush(false);
1488                self.lists.pop();
1489                if self.lists.is_empty() {
1490                    self.item = None;
1491                }
1492                if let Some(e) = self.ext.as_mut() {
1493                    e.nums.pop();
1494                }
1495                return;
1496            }
1497            "thead" => {
1498                if let Some(e) = self.ext.as_mut() {
1499                    e.thead = false;
1500                }
1501            }
1502            _ => {}
1503        }
1504        if BLOCKS.contains(&tag) {
1505            self.flush(false);
1506            if let Some(i) = self.blocks.iter().rposition(|b| b.tag == tag) {
1507                self.blocks.truncate(i);
1508            }
1509            if tag == "li" {
1510                self.item = None;
1511            }
1512        }
1513    }
1514
1515    fn run(&mut self, s: &str) {
1516        let mut rest = s;
1517        while !rest.is_empty() {
1518            if let Some(tag) = &self.skip {
1519                let close = format!("</{}", tag);
1520                match find_ci(rest, &close) {
1521                    Some(i) => {
1522                        self.raw_text(&rest[..i]);
1523                        rest = &rest[i..];
1524                        self.skip = None;
1525                    }
1526                    None => {
1527                        self.raw_text(rest);
1528                        return;
1529                    }
1530                }
1531            }
1532            let Some(lt) = rest.find('<') else {
1533                self.text(rest);
1534                return;
1535            };
1536            if lt > 0 {
1537                self.text(&rest[..lt]);
1538            }
1539            rest = &rest[lt..];
1540            if rest.starts_with("<!--") {
1541                rest = rest.find("-->").map_or("", |j| &rest[j + 3..]);
1542                continue;
1543            }
1544            if rest.starts_with("<!") || rest.starts_with("<?") {
1545                rest = rest.find('>').map_or("", |j| &rest[j + 1..]);
1546                continue;
1547            }
1548            let Some(gt) = tag_end(rest) else {
1549                self.text(rest);
1550                return;
1551            };
1552            let inner = &rest[1..gt];
1553            rest = &rest[gt + 1..];
1554            if let Some(name) = inner.strip_prefix('/') {
1555                let name = name.trim().split_whitespace().next().unwrap_or("").to_ascii_lowercase();
1556                self.close(&name);
1557                continue;
1558            }
1559            let name_end = inner.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(inner.len());
1560            let name = inner[..name_end].to_ascii_lowercase();
1561            if name.is_empty() || !name.as_bytes()[0].is_ascii_alphabetic() {
1562                self.text("<");
1563                self.text(inner);
1564                self.text(">");
1565                continue;
1566            }
1567            let selfclose = inner.trim_end().ends_with('/');
1568            let at = attrs(&inner[name_end..]);
1569            self.open(&name, &at, selfclose);
1570        }
1571    }
1572
1573    fn finish(mut self) -> Vec<Para> {
1574        self.flush(false);
1575        // trailing empty paragraphs of a closing <br>
1576        while self.out.len() > 1 && self.out.last().is_some_and(|p| p.is_empty()) {
1577            self.out.pop();
1578        }
1579        self.out
1580    }
1581}
1582
1583// ------------------------------------------------------------------ style sheets
1584
1585// rules of a document's style sheets, declarations of a rule at most (more are ignored)
1586const RULES_MAX: usize = 4096;
1587const DECLS_MAX: usize = 256;
1588
1589// a declaration: the property (lower case), its value, !important
1590type Decl = (String, String, bool);
1591
1592// a rule: its selector, specificity and place, its declarations (an index of Sheet::decls)
1593#[derive(Debug)]
1594struct Rule {
1595    sel: Sel,
1596    spec: u32,
1597    order: u32,
1598    decls: u32,
1599}
1600
1601/// the style sheets of a document (its <style> elements and the <link rel="stylesheet"> elements
1602/// the app gave a text for, in document order; media other than screen / all and types other than
1603/// text/css are left out): the rules by the id, class or tag their subject needs
1604#[derive(Debug, Default)]
1605pub(crate) struct Sheet {
1606    rules: Vec<Rule>,
1607    decls: Vec<Vec<Decl>>,
1608    ids: BTreeMap<String, Vec<u32>>,
1609    classes: BTreeMap<String, Vec<u32>>,
1610    tags: BTreeMap<String, Vec<u32>>,
1611    any: Vec<u32>,
1612    // bytes of CSS read
1613    size: usize,
1614    // the addresses of its links, as written, in document order (each once)
1615    links: Vec<String>,
1616}
1617
1618// a <link> element that names a style sheet (a rel with the word, an address)
1619fn sheet_link<'a>(d: &'a Dom, x: u32) -> Option<&'a str> {
1620    let rel = d.attr(x, "rel")?;
1621    if !rel.split_ascii_whitespace().any(|w| w.eq_ignore_ascii_case("stylesheet")) {
1622        return None;
1623    }
1624    d.attr(x, "href").map(str::trim).filter(|h| !h.is_empty())
1625}
1626
1627impl Sheet {
1628    /// the style sheets of a document's tree; linked: the texts of its links by their addresses
1629    pub(crate) fn of(d: &Dom, linked: &BTreeMap<String, String>) -> Sheet {
1630        let mut s = Sheet::default();
1631        if !d.is_html() {
1632            return s;
1633        }
1634        for x in d.subtree(0) {
1635            let n = &d.nodes[x as usize];
1636            if n.kind != K_ELEM || (n.name != "style" && n.name != "link") {
1637                continue;
1638            }
1639            let media = d.attr(x, "media").unwrap_or("").to_ascii_lowercase();
1640            let ty = d.attr(x, "type").unwrap_or("text/css").trim().to_ascii_lowercase();
1641            if (!media.trim().is_empty() && !media.contains("screen") && !media.contains("all")) || (!ty.is_empty() && ty != "text/css") {
1642                continue;
1643            }
1644            if n.name == "style" {
1645                s.add(&d.text_of(x));
1646                continue;
1647            }
1648            let Some(href) = sheet_link(d, x) else { continue };
1649            if !s.links.iter().any(|l| l == href) {
1650                s.links.push(String::from(href));
1651            }
1652            if let Some(css) = linked.get(href) {
1653                s.add(css);
1654            }
1655        }
1656        s
1657    }
1658
1659    /// the addresses its <link> elements name (MdDoc::sheet_links)
1660    pub(crate) fn links(&self) -> &[String] {
1661        &self.links
1662    }
1663
1664    /// element i's style as the cascade gives it: "property:value;" of the rules that match and
1665    /// its style attribute (MdDoc::style_of)
1666    pub(crate) fn style_of(&self, d: &Dom, i: u32) -> String {
1667        let mut budget = self.budget(64);
1668        match self.attrs_of(d, i, &mut budget) {
1669            Some(at) => at.into_iter().find(|a| a.0 == "style").map_or(String::new(), |a| a.1),
1670            None => String::from(d.attr(i, "style").unwrap_or("")),
1671        }
1672    }
1673
1674    /// no rules
1675    pub(crate) fn is_empty(&self) -> bool {
1676        self.rules.is_empty()
1677    }
1678
1679    /// what matching may cost in a document of n nodes (compounds compared)
1680    pub(crate) fn budget(&self, n: usize) -> usize {
1681        n.saturating_add(self.size).saturating_mul(64).saturating_add(1 << 20)
1682    }
1683
1684    // the rules of a sheet's text (comments, at-rules and rules with selectors this subset does
1685    // not take are left out)
1686    fn add(&mut self, css: &str) {
1687        self.size = self.size.saturating_add(css.len());
1688        let s = strip_comments(css);
1689        let b = s.as_bytes();
1690        let mut i = 0;
1691        while i < b.len() && self.rules.len() < RULES_MAX {
1692            if b[i].is_ascii_whitespace() || b[i] == b';' || b[i] == b'}' {
1693                i += 1;
1694                continue;
1695            }
1696            if b[i] == b'@' {
1697                // an at-rule: up to its ';' or the end of its block
1698                i = skip_rule(b, i, true);
1699                continue;
1700            }
1701            let Some(open) = s[i..].find('{').map(|k| i + k) else { break };
1702            let end = skip_rule(b, open, false);
1703            let sel = s[i..open].trim();
1704            let body = &s[open + 1..end.saturating_sub(1).max(open + 1)];
1705            i = end;
1706            let Some(sels) = parse_sels(sel) else { continue };
1707            let decls = parse_decls(body);
1708            if decls.is_empty() {
1709                continue;
1710            }
1711            let di = self.decls.len() as u32;
1712            self.decls.push(decls);
1713            for sel in sels {
1714                if self.rules.len() >= RULES_MAX {
1715                    break;
1716                }
1717                let r = self.rules.len() as u32;
1718                match sel.key() {
1719                    SelKey::Id(k) => self.ids.entry(String::from(k)).or_default().push(r),
1720                    SelKey::Class(k) => self.classes.entry(String::from(k)).or_default().push(r),
1721                    SelKey::Tag(k) => self.tags.entry(String::from(k)).or_default().push(r),
1722                    SelKey::Any => self.any.push(r),
1723                }
1724                self.rules.push(Rule { spec: sel.spec(), sel, order: r, decls: di });
1725            }
1726        }
1727    }
1728
1729    /// element i's attributes with its style as the cascade gives it: the declarations of the
1730    /// rules that match by specificity and order, its style attribute's, then the !important
1731    /// ones in the same order -> None: no rule matches (its own attributes stand)
1732    pub(crate) fn attrs_of(&self, d: &Dom, i: u32, budget: &mut usize) -> Option<Vec<(String, String)>> {
1733        if self.rules.is_empty() || *budget == 0 {
1734            return None;
1735        }
1736        let mut hits: Vec<(u32, u32, u32)> = Vec::new();
1737        let test = |list: &[u32], hits: &mut Vec<(u32, u32, u32)>, budget: &mut usize| {
1738            for &r in list {
1739                if *budget == 0 {
1740                    break;
1741                }
1742                let rule = &self.rules[r as usize];
1743                if sel_match(d, i, &rule.sel, budget) {
1744                    hits.push((rule.spec, rule.order, rule.decls));
1745                }
1746            }
1747        };
1748        test(&self.any, &mut hits, budget);
1749        if let Some(v) = d.attr(i, "id").and_then(|id| self.ids.get(id)) {
1750            test(v, &mut hits, budget);
1751        }
1752        if let Some(c) = d.attr(i, "class") {
1753            for w in c.split_ascii_whitespace() {
1754                if let Some(v) = self.classes.get(w) {
1755                    test(v, &mut hits, budget);
1756                }
1757            }
1758        }
1759        if let Some(v) = self.tags.get(&d.nodes[i as usize].name) {
1760            test(v, &mut hits, budget);
1761        }
1762        if hits.is_empty() {
1763            return None;
1764        }
1765        hits.sort_unstable();
1766        hits.dedup();
1767        let own = d.attr(i, "style").map(parse_decls).unwrap_or_default();
1768        // the declarations in the cascade's order; of each property only its last one, where
1769        // it stands (a shorthand and its parts keep their order)
1770        let mut seq: Vec<(&str, &str)> = Vec::new();
1771        for imp in [false, true] {
1772            let rules = hits.iter().flat_map(|h| self.decls[h.2 as usize].iter());
1773            for (p, v, im) in rules.chain(own.iter()) {
1774                if *im == imp {
1775                    seq.push((p, v));
1776                }
1777            }
1778        }
1779        let mut last: BTreeMap<&str, usize> = BTreeMap::new();
1780        for (k, (p, _)) in seq.iter().enumerate() {
1781            last.insert(p, k);
1782        }
1783        let mut css = String::new();
1784        for (k, (p, v)) in seq.iter().enumerate() {
1785            if last.get(p) == Some(&k) {
1786                css.push_str(p);
1787                css.push(':');
1788                css.push_str(v);
1789                css.push(';');
1790            }
1791        }
1792        let mut at = d.nodes[i as usize].attrs.clone();
1793        match at.iter_mut().find(|a| a.0 == "style") {
1794            Some(a) => a.1 = css,
1795            None => at.push((String::from("style"), css)),
1796        }
1797        Some(at)
1798    }
1799}
1800
1801// CSS without its comments
1802fn strip_comments(s: &str) -> String {
1803    let mut out = String::with_capacity(s.len());
1804    let mut rest = s;
1805    while let Some(i) = rest.find("/*") {
1806        out.push_str(&rest[..i]);
1807        out.push(' ');
1808        rest = rest[i + 2..].find("*/").map_or("", |j| &rest[i + 2 + j + 2..]);
1809    }
1810    out.push_str(rest);
1811    out
1812}
1813
1814// the end of a rule or at-rule at b[i] (at '{' for a rule): after its block (blocks inside
1815// counted, quotes respected) or, for an at-rule without a block, after its ';'
1816fn skip_rule(b: &[u8], mut i: usize, at: bool) -> usize {
1817    let (mut depth, mut q) = (0usize, 0u8);
1818    while i < b.len() {
1819        let c = b[i];
1820        i += 1;
1821        if q != 0 {
1822            if c == q {
1823                q = 0;
1824            }
1825            continue;
1826        }
1827        match c {
1828            b'"' | b'\'' => q = c,
1829            b'{' => depth += 1,
1830            b'}' => {
1831                depth = depth.saturating_sub(1);
1832                if depth == 0 {
1833                    return i;
1834                }
1835            }
1836            b';' if at && depth == 0 => return i,
1837            _ => {}
1838        }
1839    }
1840    i
1841}
1842
1843// the declarations of a block or a style attribute (property: value [!important]; ...)
1844fn parse_decls(body: &str) -> Vec<Decl> {
1845    let mut out = Vec::new();
1846    let b = body.as_bytes();
1847    let (mut start, mut q, mut paren) = (0, 0u8, 0usize);
1848    let push = |d: &str, out: &mut Vec<Decl>| {
1849        let Some((p, v)) = d.split_once(':') else { return };
1850        let p = p.trim().to_ascii_lowercase();
1851        if p.is_empty() || !p.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-') || out.len() >= DECLS_MAX {
1852            return;
1853        }
1854        let mut v = v.trim();
1855        let mut imp = false;
1856        if let Some(k) = v.rfind('!') {
1857            if v[k + 1..].trim().eq_ignore_ascii_case("important") {
1858                imp = true;
1859                v = v[..k].trim();
1860            }
1861        }
1862        if !v.is_empty() {
1863            out.push((p, String::from(v), imp));
1864        }
1865    };
1866    for (i, &c) in b.iter().enumerate() {
1867        if q != 0 {
1868            if c == q {
1869                q = 0;
1870            }
1871            continue;
1872        }
1873        match c {
1874            b'"' | b'\'' => q = c,
1875            b'(' => paren += 1,
1876            b')' => paren = paren.saturating_sub(1),
1877            b';' if paren == 0 => {
1878                push(&body[start..i], &mut out);
1879                start = i + 1;
1880            }
1881            _ => {}
1882        }
1883    }
1884    push(&body[start..], &mut out);
1885    out
1886}
1887
1888/// an element whose CSS may give a box around its blocks (md_doc.rs MdBox)
1889pub(crate) fn box_tag(t: &str) -> bool {
1890    BLOCKS.contains(&t)
1891        || matches!(t, "ul" | "ol" | "dl" | "nav" | "main" | "aside" | "form" | "fieldset" | "details" | "summary" | "body" | "figcaption" | "hgroup" | "dialog" | "menu")
1892}
1893
1894// a CSS length in px at 100 % text (em, rem: 14 px, the body text; % and other units: none)
1895fn css_len(v: &str) -> Option<i32> {
1896    let v = v.trim().to_ascii_lowercase();
1897    let num = |s: &str| s.trim().parse::<f32>().ok();
1898    let px = if v == "0" || v == "auto" {
1899        0.0
1900    } else if let Some(n) = v.strip_suffix("px") {
1901        num(n)?
1902    } else if let Some(n) = v.strip_suffix("pt") {
1903        num(n)? * 4.0 / 3.0
1904    } else if let Some(n) = v.strip_suffix("rem").or_else(|| v.strip_suffix("em")) {
1905        num(n)? * 14.0
1906    } else {
1907        match v.as_str() {
1908            "thin" => 1.0,
1909            "medium" => 3.0,
1910            "thick" => 5.0,
1911            _ => return None,
1912        }
1913    };
1914    Some((px.clamp(-2000.0, 2000.0) + if px < 0.0 { -0.5 } else { 0.5 }) as i32)
1915}
1916
1917// one to four values for the four sides (top, right, bottom, left)
1918fn sides<T: Copy>(v: &[T]) -> Option<[T; 4]> {
1919    match *v {
1920        [a] => Some([a, a, a, a]),
1921        [a, b] => Some([a, b, a, b]),
1922        [a, b, c] => Some([a, b, c, b]),
1923        [a, b, c, d] => Some([a, b, c, d]),
1924        _ => None,
1925    }
1926}
1927
1928// a border style: visible (true: drawn solid), none / hidden (false)
1929fn border_style(v: &str) -> Option<bool> {
1930    match v.trim().to_ascii_lowercase().as_str() {
1931        "solid" | "dashed" | "dotted" | "double" | "groove" | "ridge" | "inset" | "outset" => Some(true),
1932        "none" | "hidden" => Some(false),
1933        _ => None,
1934    }
1935}
1936
1937// the words of a value (blanks outside parentheses)
1938fn words(v: &str) -> Vec<&str> {
1939    let mut out = Vec::new();
1940    let (mut start, mut paren) = (None, 0usize);
1941    for (i, c) in v.char_indices() {
1942        match c {
1943            '(' => paren += 1,
1944            ')' => paren = paren.saturating_sub(1),
1945            _ => {}
1946        }
1947        if c.is_whitespace() && paren == 0 {
1948            if let Some(s) = start.take() {
1949                out.push(&v[s..i]);
1950            }
1951        } else if start.is_none() {
1952            start = Some(i);
1953        }
1954    }
1955    if let Some(s) = start {
1956        out.push(&v[s..]);
1957    }
1958    out
1959}
1960
1961/// the box an element's style gives (margins, padding, borders drawn solid, the background)
1962/// -> None: none
1963pub(crate) fn box_of(style: &str) -> Option<MdBox> {
1964    let mut b = MdBox::default();
1965    // per side: the border's width given, its style (None: not given), its colour
1966    let mut bw: [Option<i32>; 4] = [None; 4];
1967    let mut bs: [Option<bool>; 4] = [None; 4];
1968    let mut bc = [0u32; 4];
1969    let pick = |p: &str| match p {
1970        "top" => Some(0),
1971        "right" => Some(1),
1972        "bottom" => Some(2),
1973        "left" => Some(3),
1974        _ => None,
1975    };
1976    for decl in style.split(';') {
1977        let Some((p, v)) = decl.split_once(':') else { continue };
1978        let p = p.trim().to_ascii_lowercase();
1979        let v = v.trim();
1980        let lens = || -> Option<[i32; 4]> { sides(&words(v).iter().map(|w| css_len(w)).collect::<Option<Vec<i32>>>()?) };
1981        match p.as_str() {
1982            "margin" => {
1983                if let Some(s) = lens() {
1984                    b.margin = s.map(|x| x.clamp(0, 1000) as i16);
1985                    b.mset = 15;
1986                }
1987            }
1988            "padding" => {
1989                if let Some(s) = lens() {
1990                    b.padding = s.map(|x| x.clamp(0, 1000) as i16);
1991                }
1992            }
1993            "border-width" => {
1994                if let Some(s) = lens() {
1995                    bw = s.map(Some);
1996                }
1997            }
1998            "border-style" => {
1999                if let Some(s) = sides(&words(v).iter().map(|w| border_style(w)).collect::<Option<Vec<bool>>>().unwrap_or_default()) {
2000                    bs = s.map(Some);
2001                }
2002            }
2003            "border-color" => {
2004                if let Some(s) = sides(&words(v).iter().map(|w| css_color(w)).collect::<Option<Vec<u32>>>().unwrap_or_default()) {
2005                    bc = s;
2006                }
2007            }
2008            "background" | "background-color" => {
2009                if let Some(c) = words(v).into_iter().find_map(css_color) {
2010                    b.bg = c;
2011                } else if v.eq_ignore_ascii_case("none") || v.eq_ignore_ascii_case("transparent") {
2012                    b.bg = 0;
2013                }
2014            }
2015            _ => {
2016                let rest = p.strip_prefix("margin-").or_else(|| p.strip_prefix("padding-"));
2017                if let (Some(side), Some(n)) = (rest.and_then(pick), css_len(v)) {
2018                    if p.starts_with('m') {
2019                        b.margin[side] = n.clamp(0, 1000) as i16;
2020                        b.mset |= 1 << side;
2021                    } else {
2022                        b.padding[side] = n.clamp(0, 1000) as i16;
2023                    }
2024                    continue;
2025                }
2026                // border, border-SIDE (width style colour in any order), border-SIDE-width / -style / -color
2027                let Some(rest) = p.strip_prefix("border") else { continue };
2028                let (which, part) = match rest.strip_prefix('-') {
2029                    None if rest.is_empty() => ([true; 4], ""),
2030                    Some(r) => match r.split_once('-') {
2031                        Some((s, part)) => match pick(s) {
2032                            Some(k) => (core::array::from_fn(|i| i == k), part),
2033                            None => continue,
2034                        },
2035                        None => match pick(r) {
2036                            Some(k) => (core::array::from_fn(|i| i == k), ""),
2037                            None => continue,
2038                        },
2039                    },
2040                    _ => continue,
2041                };
2042                for (k, on) in which.iter().enumerate() {
2043                    if !on {
2044                        continue;
2045                    }
2046                    match part {
2047                        "width" => bw[k] = css_len(v).or(bw[k]),
2048                        "style" => bs[k] = border_style(v).or(bs[k]),
2049                        "color" => bc[k] = css_color(v).unwrap_or(bc[k]),
2050                        "" => {
2051                            // the shorthand resets what it does not give
2052                            let (mut w, mut s, mut c) = (None, None, 0);
2053                            for t in words(v) {
2054                                if let Some(x) = border_style(t) {
2055                                    s = Some(x);
2056                                } else if let Some(x) = css_len(t) {
2057                                    w = Some(x);
2058                                } else if let Some(x) = css_color(t) {
2059                                    c = x;
2060                                }
2061                            }
2062                            bw[k] = w;
2063                            bs[k] = Some(s.unwrap_or(false));
2064                            bc[k] = c;
2065                        }
2066                        _ => {}
2067                    }
2068                }
2069            }
2070        }
2071    }
2072    for k in 0..4 {
2073        // (a border needs a style; without a width it is medium)
2074        b.border[k] = if bs[k] == Some(true) { bw[k].unwrap_or(3).clamp(0, 100) as u8 } else { 0 };
2075        b.border_color[k] = bc[k];
2076    }
2077    let any = b.margin.iter().any(|&x| x != 0) || b.padding.iter().any(|&x| x != 0) || b.border.iter().any(|&x| x != 0) || b.bg != 0 || b.mset != 0;
2078    any.then_some(b)
2079}
2080
2081// the format an element's attributes give on top of f (view: the viewer's reader -- a link
2082// takes the link colour unless it has one of its own, a box's background is the box's)
2083fn fmt_with(mut f: Fmt, tag: &str, at: &[(String, String)], view: bool) -> Fmt {
2084    if view && tag == "a" && at.iter().any(|a| a.0 == "href") {
2085        f.color = 0;
2086    }
2087    let boxed = view && box_tag(tag);
2088    match tag {
2089        "b" | "strong" => f.flags |= RF_BOLD,
2090        "i" | "em" | "cite" | "var" | "dfn" => f.flags |= RF_ITALIC,
2091        "u" | "ins" => f.flags |= RF_UNDER,
2092        "s" | "strike" | "del" => f.flags |= RF_STRIKE,
2093        "code" | "tt" | "kbd" | "samp" => f.font = String::from("monospace"),
2094        "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "th" => {}
2095        _ => {}
2096    }
2097    for (k, v) in at {
2098        match k.as_str() {
2099            "href" if tag == "a" => f.link = v.clone(),
2100            "color" if tag == "font" => f.color = css_color(v).unwrap_or(f.color),
2101            "face" if tag == "font" => f.font = family_of(v),
2102            "size" if tag == "font" => {
2103                f.size = match v.trim() {
2104                    "1" => 75,
2105                    "2" => 100,
2106                    "3" => 120,
2107                    "4" => 135,
2108                    "5" => 180,
2109                    "6" => 240,
2110                    "7" => 360,
2111                    _ => f.size,
2112                }
2113            }
2114            "style" => {
2115                for decl in v.split(';') {
2116                    let Some((p, val)) = decl.split_once(':') else { continue };
2117                    let val = val.trim().trim_end_matches("!important").trim();
2118                    match p.trim().to_ascii_lowercase().as_str() {
2119                        "color" => f.color = css_color(val).unwrap_or(f.color),
2120                        "background-color" | "background" if !boxed => {
2121                            if let Some(c) = val.split_whitespace().find_map(css_color) {
2122                                if c & 0xFF_FFFF != 0xFF_FFFF {
2123                                    f.bg = c;
2124                                }
2125                            }
2126                        }
2127                        "font-weight" => {
2128                            let bold = matches!(val, "bold" | "bolder") || val.parse::<u32>().is_ok_and(|w| w >= 600);
2129                            if bold {
2130                                f.flags |= RF_BOLD;
2131                            } else if matches!(val, "normal" | "lighter") || val.parse::<u32>().is_ok_and(|w| w < 600) {
2132                                f.flags &= !RF_BOLD;
2133                            }
2134                        }
2135                        "font-style" => {
2136                            if val.starts_with("italic") || val.starts_with("oblique") {
2137                                f.flags |= RF_ITALIC;
2138                            } else if val == "normal" {
2139                                f.flags &= !RF_ITALIC;
2140                            }
2141                        }
2142                        "text-decoration" | "text-decoration-line" => {
2143                            if val.contains("underline") {
2144                                f.flags |= RF_UNDER;
2145                            }
2146                            if val.contains("line-through") {
2147                                f.flags |= RF_STRIKE;
2148                            }
2149                            if val == "none" {
2150                                f.flags &= !(RF_UNDER | RF_STRIKE);
2151                            }
2152                        }
2153                        "font-size" => f.size = css_size(val, f.size).unwrap_or(f.size),
2154                        "font-family" => f.font = family_of(val),
2155                        _ => {}
2156                    }
2157                }
2158            }
2159            _ => {}
2160        }
2161    }
2162    f
2163}
2164
2165// the '>' that ends the tag at s[0] == '<' (quotes respected)
2166pub(crate) fn tag_end(s: &str) -> Option<usize> {
2167    let mut q = 0u8;
2168    for (i, b) in s.bytes().enumerate().skip(1) {
2169        if q != 0 {
2170            if b == q {
2171                q = 0;
2172            }
2173        } else if b == b'"' || b == b'\'' {
2174            q = b;
2175        } else if b == b'>' {
2176            return Some(i);
2177        }
2178    }
2179    None
2180}
2181
2182pub(crate) fn find_ci(hay: &str, needle: &str) -> Option<usize> {
2183    let h = hay.as_bytes();
2184    let n = needle.as_bytes();
2185    if n.len() > h.len() {
2186        return None;
2187    }
2188    (0..=h.len() - n.len()).find(|&i| h[i..i + n.len()].eq_ignore_ascii_case(n))
2189}
2190
2191// the first family of a CSS font-family list
2192fn family_of(v: &str) -> String {
2193    let f = v.split(',').next().unwrap_or("").trim().trim_matches(['\'', '"']).trim();
2194    String::from(f)
2195}