mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
craft-html: a tolerant tokenizer, character references, escaping and the declared charset (#944)
The bottom layer every HTML reader in the Craft apps carried its own copy of. The tokenizer is MailCraft's (crates/html/src/tokenizer.rs), with what it dropped and capped turned into Options and Limits, a self-closing flag, and raw_content for callers that read a style sheet's text. The character references are WordCraft's (crates/formats/src/html/entity.rs at 06c2ae6f): its name table and the 94 legacy names that decode without a semicolon, with MailCraft's Latin-1 and Greek tables and its handling of numbers too long to be numbers. No tree, no CSS, no decoding of bytes.
This commit is contained in:
1 parent
e8f3149224
commit
e5299a3be0
10 files changed
+1213
No files matched your search
Generated
+7
@@ -1150,6 +1150,13 @@ dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-html"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -49,6 +49,7 @@ craft-gcode = { path = "crates/gcode" }
|
||||
craft-gif = { path = "crates/gif" }
|
||||
craft-hdr = { path = "crates/hdr" }
|
||||
craft-mesh = { path = "crates/mesh" }
|
||||
craft-html = { path = "crates/html" }
|
||||
craft-heif = { path = "crates/heif" }
|
||||
craft-fig = { path = "crates/fig" }
|
||||
craft-fonts-build = { path = "crates/fonts-build" }
|
||||
|
||||
@@ -125,6 +125,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
heic-rs reads the container and HEVC pictures, and this crate converts to 16-bit RGB to match
|
||||
libheif (VUI range and matrix, chroma upsampling, `imir`, ICC on grid tiles). Also orientation,
|
||||
alpha, EXIF/XMP and thumbnails. It never panics, and apps put it behind a non-default feature.
|
||||
- [`craft-html`](crates/html/README.md): the bottom layer of reading and writing HTML: a tolerant tokenizer (text, start tags, end tags; elements to drop or to read as raw text are options), character references with the legacy names browsers accept without `;`, escaping, and the character set a page declares. No tree, no CSS, no rendering.
|
||||
- [`craft-i18n`](crates/i18n/README.md): translations over the embedded craft-i18n catalogs:
|
||||
lookup with fallback (`pt-br` → `pt` → English), plurals, language names, the system locale and
|
||||
a pseudo locale. The catalogs stay a fully optional build input; apps keep `tl!` and their
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "craft-html"
|
||||
description = "The bottom layer of reading and writing HTML: a tolerant tokenizer, character references, escaping and the charset a page declares. No tree, no CSS, no rendering."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
@@ -0,0 +1,68 @@
|
||||
# craft-html
|
||||
|
||||
The bottom layer of reading and writing HTML: a tolerant tokenizer, character references,
|
||||
escaping and the character set a page declares. It replaces the tokenizer and entity table each
|
||||
Craft app carried. There is no tree builder, no CSS and no rendering: what the tags mean stays
|
||||
with the caller (a document importer, a mail reader, a clipboard reader).
|
||||
|
||||
```rust
|
||||
use craft_html::{Lexer, Options, Token, escape, unescape};
|
||||
|
||||
let opts = Options { drop: &["script", "title"], raw_text: &["textarea"], ..Options::default() };
|
||||
let mut lexer = Lexer::with("<title>t</title><style>p{}</style>café <b class='x'><3</b>", opts);
|
||||
let mut text = String::new();
|
||||
while let Some(token) = lexer.next() {
|
||||
match token {
|
||||
Token::Text(t) => text.push_str(&unescape(t)),
|
||||
// A style sheet's text, for a caller that reads it.
|
||||
Token::Start(tag) if tag.name == "style" => assert_eq!(lexer.raw_content("style"), "p{}"),
|
||||
Token::Start(tag) => assert_eq!(tag.attr("class").as_deref(), Some("x")),
|
||||
Token::End(name) => assert_eq!(name, "b"),
|
||||
}
|
||||
}
|
||||
assert_eq!(text, "café <3");
|
||||
assert_eq!(escape(&text), "café <3");
|
||||
```
|
||||
|
||||
## The tokenizer
|
||||
|
||||
`Lexer` is an iterator of `Token::Text`, `Token::Start(Tag)` and `Token::End(name)`, borrowed
|
||||
from the input. Text and attribute values come as written; `unescape` and `Tag::attr` decode
|
||||
them.
|
||||
|
||||
- A `<` that starts no tag is text. A tag, comment or raw element that never ends runs to the
|
||||
end of the input.
|
||||
- Comments, doctypes, processing instructions and conditional comments never come out.
|
||||
- `Options::drop`: elements skipped whole, content and end tag included.
|
||||
- `Options::raw_text`: elements whose content is one text token, not markup.
|
||||
- `Lexer::raw_content(name)`: after a start tag, take the element's raw content yourself.
|
||||
- `Limits`: attributes kept per tag (64) and the longest tag name (64 bytes; a longer one has an
|
||||
empty name).
|
||||
|
||||
## Character references
|
||||
|
||||
`unescape` decodes named, decimal and hex references. The named set is HTML 4's (Latin-1, Greek,
|
||||
symbols, arrows, mathematics) and a few more. The 94 legacy names decode without their `;`
|
||||
(`© 2024`), longest match first, as in browsers; `unescape_attr` leaves such a name alone
|
||||
when a letter, a digit or `=` follows, so a query string keeps its `©=1`. A numeric reference
|
||||
to 0x80-0x9F means the Windows-1252 character; 0, surrogates, and numbers past U+10FFFF or too
|
||||
long to be numbers become U+FFFD. Names are looked at for 32 bytes at most, so the work is
|
||||
linear.
|
||||
|
||||
`escape` writes `& < > " '` as references; `escape_dq` leaves `'`, for content and attribute
|
||||
values in double quotes.
|
||||
|
||||
## Not here
|
||||
|
||||
- The full HTML5 named reference table (about 2,200 names): only the names above.
|
||||
- Decoding bytes to text. `declared_charset` gives the label a page names; a decoder for it is
|
||||
the caller's (`craft-mime::charset` for the WHATWG labels).
|
||||
- Tree construction, implied end tags, CSS.
|
||||
|
||||
## Provenance
|
||||
|
||||
The tokenizer is MailCraft's (`crates/html/src/tokenizer.rs`), with its policy (which elements to
|
||||
drop, the caps) turned into options. The character references are WordCraft's
|
||||
(`crates/formats/src/html/entity.rs`: the name table and the legacy names without `;`), with
|
||||
MailCraft's Latin-1 and Greek tables and its handling of numbers too long to be numbers.
|
||||
Written from the HTML Living Standard.
|
||||
@@ -0,0 +1,388 @@
|
||||
//! Character references (`&`, `é`, `é`) and escaping.
|
||||
|
||||
/// The Latin-1 names in code point order, from U+00A0 (`nbsp`) to U+00FF (`yuml`).
|
||||
const LATIN1: [&str; 96] = [
|
||||
"nbsp", "iexcl", "cent", "pound", "curren", "yen", "brvbar", "sect", "uml", "copy", "ordf", "laquo", "not", "shy", "reg", "macr", "deg",
|
||||
"plusmn", "sup2", "sup3", "acute", "micro", "para", "middot", "cedil", "sup1", "ordm", "raquo", "frac14", "frac12", "frac34", "iquest", "Agrave",
|
||||
"Aacute", "Acirc", "Atilde", "Auml", "Aring", "AElig", "Ccedil", "Egrave", "Eacute", "Ecirc", "Euml", "Igrave", "Iacute", "Icirc", "Iuml", "ETH",
|
||||
"Ntilde", "Ograve", "Oacute", "Ocirc", "Otilde", "Ouml", "times", "Oslash", "Ugrave", "Uacute", "Ucirc", "Uuml", "Yacute", "THORN", "szlig",
|
||||
"agrave", "aacute", "acirc", "atilde", "auml", "aring", "aelig", "ccedil", "egrave", "eacute", "ecirc", "euml", "igrave", "iacute", "icirc",
|
||||
"iuml", "eth", "ntilde", "ograve", "oacute", "ocirc", "otilde", "ouml", "divide", "oslash", "ugrave", "uacute", "ucirc", "uuml", "yacute",
|
||||
"thorn", "yuml",
|
||||
];
|
||||
|
||||
/// Greek capitals from U+0391; U+03A2 is unassigned.
|
||||
const GREEK_UPPER: [&str; 25] = [
|
||||
"Alpha", "Beta", "Gamma", "Delta", "Epsilon", "Zeta", "Eta", "Theta", "Iota", "Kappa", "Lambda", "Mu", "Nu", "Xi", "Omicron", "Pi", "Rho", "",
|
||||
"Sigma", "Tau", "Upsilon", "Phi", "Chi", "Psi", "Omega",
|
||||
];
|
||||
|
||||
/// Greek small letters from U+03B1.
|
||||
const GREEK_LOWER: [&str; 25] = [
|
||||
"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", "iota", "kappa", "lambda", "mu", "nu", "xi", "omicron", "pi", "rho",
|
||||
"sigmaf", "sigma", "tau", "upsilon", "phi", "chi", "psi", "omega",
|
||||
];
|
||||
|
||||
/// What a numeric reference to 0x80–0x9F means (HTML Living Standard §13.2.5.80: the
|
||||
/// Windows-1252 characters, by long tradition).
|
||||
const C1: [u32; 32] = [
|
||||
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019,
|
||||
0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
|
||||
];
|
||||
|
||||
/// Longest entity name looked at.
|
||||
const MAX_NAME: usize = 32;
|
||||
|
||||
/// The legacy names HTML also decodes without a `;`, sorted by bytes.
|
||||
const BARE_NAMES: &[&str] = &[
|
||||
"AElig", "AMP", "Aacute", "Acirc", "Agrave", "Aring", "Atilde", "Auml", "COPY", "Ccedil", "Eacute", "Ecirc", "Egrave", "Euml", "GT", "Iacute",
|
||||
"Icirc", "Igrave", "Iuml", "LT", "Ntilde", "Oacute", "Ocirc", "Ograve", "Oslash", "Otilde", "Ouml", "QUOT", "REG", "Uacute", "Ucirc", "Ugrave",
|
||||
"Uuml", "Yacute", "aacute", "acirc", "aelig", "agrave", "amp", "aring", "atilde", "auml", "ccedil", "cent", "copy", "curren", "deg", "divide",
|
||||
"eacute", "ecirc", "egrave", "euml", "frac12", "frac14", "frac34", "gt", "iacute", "icirc", "iexcl", "igrave", "iquest", "iuml", "laquo", "lt",
|
||||
"micro", "middot", "nbsp", "ntilde", "oacute", "ocirc", "ograve", "oslash", "otilde", "ouml", "para", "plusmn", "pound", "quot", "raquo", "reg",
|
||||
"sect", "shy", "sup1", "sup2", "sup3", "szlig", "times", "uacute", "ucirc", "ugrave", "uuml", "yacute", "yen", "yuml",
|
||||
];
|
||||
|
||||
/// Text as HTML, for content and for attribute values in either kind of quote: `&`, `<`, `>`,
|
||||
/// `"` and `'` become character references.
|
||||
pub fn escape(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len().saturating_add(text.len() / 8));
|
||||
push_escaped(&mut out, text, true);
|
||||
out
|
||||
}
|
||||
|
||||
/// [`escape`] that leaves `'` as it is: for content and for attribute values in double quotes.
|
||||
pub fn escape_dq(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len().saturating_add(text.len() / 8));
|
||||
push_escaped(&mut out, text, false);
|
||||
out
|
||||
}
|
||||
|
||||
fn push_escaped(out: &mut String, text: &str, apos: bool) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' if apos => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The character an entity body names (`amp`, `#38`, `#x26`: what stands between `&` and `;`).
|
||||
pub fn decode_entity(body: &str) -> Option<char> {
|
||||
match body.strip_prefix('#') {
|
||||
Some(_) => reference(body, false).filter(|(_, used)| *used == body.len()).map(|(c, _)| c),
|
||||
None => named(body),
|
||||
}
|
||||
}
|
||||
|
||||
/// Character references in text decoded: named (the HTML 4 set and more), decimal and hex.
|
||||
/// Anything that is no reference stays as it is. A legacy name decodes without its `;`
|
||||
/// (`© 2024`), as in browsers.
|
||||
pub fn unescape(text: &str) -> String {
|
||||
unescape_in(text, false)
|
||||
}
|
||||
|
||||
/// [`unescape`] for an attribute value: a legacy name without its `;` stays text when a letter,
|
||||
/// a digit or `=` follows, so `?a=1©=2` is a query string and not a copyright sign.
|
||||
pub fn unescape_attr(text: &str) -> String {
|
||||
unescape_in(text, true)
|
||||
}
|
||||
|
||||
fn unescape_in(text: &str, attr: bool) -> String {
|
||||
if !text.contains('&') {
|
||||
return text.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(i) = rest.find('&') {
|
||||
out.push_str(rest.get(..i).unwrap_or(""));
|
||||
let after = rest.get(i + 1..).unwrap_or("");
|
||||
match reference(after, attr) {
|
||||
Some((c, used)) => {
|
||||
out.push(c);
|
||||
rest = after.get(used..).unwrap_or("");
|
||||
}
|
||||
None => {
|
||||
out.push('&');
|
||||
rest = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The character a reference names and the bytes it takes, given the text after its `&`
|
||||
/// (`attr`: inside an attribute value, see [`unescape_attr`]). At most [`MAX_NAME`] bytes are
|
||||
/// looked at for a name, so a text of stray `&`s is read in linear time.
|
||||
pub fn reference(s: &str, attr: bool) -> Option<(char, usize)> {
|
||||
if let Some(num) = s.strip_prefix('#') {
|
||||
let (digits, radix, skip) = match num.strip_prefix(['x', 'X']) {
|
||||
Some(hex) => (hex, 16, 2),
|
||||
None => (num, 10, 1),
|
||||
};
|
||||
let len = digits.bytes().take_while(|b| char::from(*b).is_digit(radix)).count();
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
// Too many digits is not a character; neither is 0, a surrogate, or anything past U+10FFFF.
|
||||
let mut code: u32 = 0;
|
||||
for d in digits.bytes().take(len) {
|
||||
let d = char::from(d).to_digit(radix).unwrap_or(0);
|
||||
code = code.checked_mul(radix).and_then(|c| c.checked_add(d)).unwrap_or(u32::MAX);
|
||||
}
|
||||
let semicolon = usize::from(digits.as_bytes().get(len) == Some(&b';'));
|
||||
return Some((numeric(code), skip + len + semicolon));
|
||||
}
|
||||
let len = s.bytes().take(MAX_NAME).take_while(u8::is_ascii_alphanumeric).count();
|
||||
if s.as_bytes().get(len) == Some(&b';')
|
||||
&& let Some(c) = s.get(..len).and_then(named)
|
||||
{
|
||||
return Some((c, len + 1));
|
||||
}
|
||||
// Without its `;`: the longest legacy name (2 to 6 letters) the text starts with.
|
||||
let len = (2..=len.min(6)).rev().find(|&l| s.get(..l).is_some_and(|p| BARE_NAMES.binary_search(&p).is_ok()))?;
|
||||
if attr && s.as_bytes().get(len).is_some_and(|b| b.is_ascii_alphanumeric() || *b == b'=') {
|
||||
return None;
|
||||
}
|
||||
s.get(..len).and_then(named).map(|c| (c, len))
|
||||
}
|
||||
|
||||
fn numeric(code: u32) -> char {
|
||||
let code = match code {
|
||||
0x80..=0x9F => C1.get((code - 0x80) as usize).copied().unwrap_or(code),
|
||||
c => c,
|
||||
};
|
||||
match char::from_u32(code) {
|
||||
Some('\0') | None => char::REPLACEMENT_CHARACTER,
|
||||
Some(c) => c,
|
||||
}
|
||||
}
|
||||
|
||||
fn named(name: &str) -> Option<char> {
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let c = match name {
|
||||
"amp" | "AMP" => '&',
|
||||
"lt" | "LT" => '<',
|
||||
"gt" | "GT" => '>',
|
||||
"quot" | "QUOT" => '"',
|
||||
"apos" | "APOS" => '\'',
|
||||
"nbsp" | "NBSP" => '\u{00A0}',
|
||||
"ensp" => '\u{2002}',
|
||||
"emsp" => '\u{2003}',
|
||||
"thinsp" => '\u{2009}',
|
||||
"shy" => '\u{00AD}',
|
||||
"copy" | "COPY" => '©',
|
||||
"reg" | "REG" => '®',
|
||||
"trade" | "TRADE" => '™',
|
||||
"hellip" => '…',
|
||||
"mdash" => '—',
|
||||
"ndash" => '–',
|
||||
"minus" => '−',
|
||||
"lsquo" => '‘',
|
||||
"rsquo" => '’',
|
||||
"sbquo" => '‚',
|
||||
"ldquo" => '“',
|
||||
"rdquo" => '”',
|
||||
"bdquo" => '„',
|
||||
"laquo" => '«',
|
||||
"raquo" => '»',
|
||||
"bull" => '•',
|
||||
"middot" => '·',
|
||||
"deg" => '°',
|
||||
"plusmn" => '±',
|
||||
"times" => '×',
|
||||
"divide" => '÷',
|
||||
"frac12" => '½',
|
||||
"frac14" => '¼',
|
||||
"frac34" => '¾',
|
||||
"frac13" => '⅓',
|
||||
"frac23" => '⅔',
|
||||
"frac18" => '⅛',
|
||||
"frac38" => '⅜',
|
||||
"frac58" => '⅝',
|
||||
"frac78" => '⅞',
|
||||
"sup1" => '¹',
|
||||
"sup2" => '²',
|
||||
"sup3" => '³',
|
||||
"micro" => 'µ',
|
||||
"permil" => '‰',
|
||||
"prime" => '′',
|
||||
"Prime" => '″',
|
||||
"oline" => '‾',
|
||||
"frasl" => '⁄',
|
||||
"sect" => '§',
|
||||
"para" => '¶',
|
||||
"euro" | "EURO" => '€',
|
||||
"pound" | "POUND" => '£',
|
||||
"yen" | "YEN" => '¥',
|
||||
"cent" | "CENT" => '¢',
|
||||
"curren" => '¤',
|
||||
"dagger" => '†',
|
||||
"Dagger" => '‡',
|
||||
"larr" => '←',
|
||||
"rarr" => '→',
|
||||
"uarr" => '↑',
|
||||
"darr" => '↓',
|
||||
"harr" => '↔',
|
||||
"lArr" => '⇐',
|
||||
"rArr" => '⇒',
|
||||
"uArr" => '⇑',
|
||||
"dArr" => '⇓',
|
||||
"hArr" => '⇔',
|
||||
"crarr" => '↵',
|
||||
"forall" => '∀',
|
||||
"part" => '∂',
|
||||
"exist" => '∃',
|
||||
"empty" => '∅',
|
||||
"nabla" => '∇',
|
||||
"isin" => '∈',
|
||||
"notin" => '∉',
|
||||
"ni" => '∋',
|
||||
"prod" => '∏',
|
||||
"sum" => '∑',
|
||||
"radic" => '√',
|
||||
"prop" => '∝',
|
||||
"ang" => '∠',
|
||||
"and" => '∧',
|
||||
"or" => '∨',
|
||||
"cap" => '∩',
|
||||
"cup" => '∪',
|
||||
"int" => '∫',
|
||||
"there4" => '∴',
|
||||
"sim" => '∼',
|
||||
"cong" => '≅',
|
||||
"asymp" | "approx" => '≈',
|
||||
"ne" => '≠',
|
||||
"equiv" => '≡',
|
||||
"le" => '≤',
|
||||
"ge" => '≥',
|
||||
"sub" => '⊂',
|
||||
"sup" => '⊃',
|
||||
"nsub" => '⊄',
|
||||
"sube" => '⊆',
|
||||
"supe" => '⊇',
|
||||
"oplus" => '⊕',
|
||||
"otimes" => '⊗',
|
||||
"perp" => '⊥',
|
||||
"sdot" => '⋅',
|
||||
"infin" => '∞',
|
||||
"loz" => '◊',
|
||||
"spades" => '♠',
|
||||
"clubs" => '♣',
|
||||
"hearts" => '♥',
|
||||
"diams" => '♦',
|
||||
"check" => '✓',
|
||||
"iexcl" => '¡',
|
||||
"iquest" => '¿',
|
||||
"szlig" => 'ß',
|
||||
"auml" => 'ä',
|
||||
"ouml" => 'ö',
|
||||
"uuml" => 'ü',
|
||||
"Auml" => 'Ä',
|
||||
"Ouml" => 'Ö',
|
||||
"Uuml" => 'Ü',
|
||||
"eacute" => 'é',
|
||||
"egrave" => 'è',
|
||||
"ecirc" => 'ê',
|
||||
"euml" => 'ë',
|
||||
"Eacute" => 'É',
|
||||
"Egrave" => 'È',
|
||||
"Ecirc" => 'Ê',
|
||||
"Euml" => 'Ë',
|
||||
"aacute" => 'á',
|
||||
"agrave" => 'à',
|
||||
"acirc" => 'â',
|
||||
"atilde" => 'ã',
|
||||
"Agrave" => 'À',
|
||||
"Aacute" => 'Á',
|
||||
"Acirc" => 'Â',
|
||||
"Atilde" => 'Ã',
|
||||
"iacute" => 'í',
|
||||
"igrave" => 'ì',
|
||||
"icirc" => 'î',
|
||||
"iuml" => 'ï',
|
||||
"Iacute" => 'Í',
|
||||
"Igrave" => 'Ì',
|
||||
"Icirc" => 'Î',
|
||||
"Iuml" => 'Ï',
|
||||
"oacute" => 'ó',
|
||||
"ograve" => 'ò',
|
||||
"ocirc" => 'ô',
|
||||
"otilde" => 'õ',
|
||||
"Oacute" => 'Ó',
|
||||
"Ograve" => 'Ò',
|
||||
"Ocirc" => 'Ô',
|
||||
"Otilde" => 'Õ',
|
||||
"uacute" => 'ú',
|
||||
"ugrave" => 'ù',
|
||||
"ucirc" => 'û',
|
||||
"Uacute" => 'Ú',
|
||||
"Ugrave" => 'Ù',
|
||||
"Ucirc" => 'Û',
|
||||
"yacute" => 'ý',
|
||||
"yuml" => 'ÿ',
|
||||
"Yacute" => 'Ý',
|
||||
"ntilde" => 'ñ',
|
||||
"Ntilde" => 'Ñ',
|
||||
"ccedil" => 'ç',
|
||||
"Ccedil" => 'Ç',
|
||||
"aring" => 'å',
|
||||
"Aring" => 'Å',
|
||||
"oslash" => 'ø',
|
||||
"Oslash" => 'Ø',
|
||||
"aelig" => 'æ',
|
||||
"AElig" => 'Æ',
|
||||
"alpha" => 'α',
|
||||
"beta" => 'β',
|
||||
"gamma" => 'γ',
|
||||
"delta" => 'δ',
|
||||
"epsilon" => 'ε',
|
||||
"zeta" => 'ζ',
|
||||
"eta" => 'η',
|
||||
"theta" => 'θ',
|
||||
"iota" => 'ι',
|
||||
"kappa" => 'κ',
|
||||
"lambda" => 'λ',
|
||||
"mu" => 'μ',
|
||||
"nu" => 'ν',
|
||||
"xi" => 'ξ',
|
||||
"pi" => 'π',
|
||||
"rho" => 'ρ',
|
||||
"sigma" => 'σ',
|
||||
"tau" => 'τ',
|
||||
"upsilon" => 'υ',
|
||||
"phi" => 'φ',
|
||||
"chi" => 'χ',
|
||||
"psi" => 'ψ',
|
||||
"omega" => 'ω',
|
||||
"Gamma" => 'Γ',
|
||||
"Delta" => 'Δ',
|
||||
"Theta" => 'Θ',
|
||||
"Lambda" => 'Λ',
|
||||
"Xi" => 'Ξ',
|
||||
"Pi" => 'Π',
|
||||
"Sigma" => 'Σ',
|
||||
"Upsilon" => 'Υ',
|
||||
"Phi" => 'Φ',
|
||||
"Psi" => 'Ψ',
|
||||
"Omega" => 'Ω',
|
||||
"zwj" => '\u{200D}',
|
||||
"zwnj" => '\u{200C}',
|
||||
"cross" => '\u{2717}',
|
||||
_ => {
|
||||
let at = |table: &[&str], base: u32| table.iter().position(|n| *n == name).and_then(|i| char::from_u32(base + i as u32));
|
||||
return at(&LATIN1, 0xA0).or_else(|| at(&GREEK_UPPER, 0x391)).or_else(|| at(&GREEK_LOWER, 0x3B1));
|
||||
}
|
||||
};
|
||||
Some(c)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,132 @@
|
||||
use super::{BARE_NAMES, GREEK_LOWER, GREEK_UPPER, LATIN1, decode_entity, escape, escape_dq, named, reference, unescape, unescape_attr};
|
||||
|
||||
#[test]
|
||||
fn escapes_the_five() {
|
||||
assert_eq!(escape(r#"<a href="x">Tom & 'Jerry'</a>"#), "<a href="x">Tom & 'Jerry'</a>");
|
||||
assert_eq!(escape_dq(r#"<a href="x">Tom & 'Jerry'</a>"#), "<a href="x">Tom & 'Jerry'</a>");
|
||||
assert_eq!(escape("plain ünïcode"), "plain ünïcode");
|
||||
assert_eq!(escape(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_names_are_sorted_and_known() {
|
||||
assert_eq!(BARE_NAMES.len(), 94);
|
||||
assert!(BARE_NAMES.windows(2).all(|w| w[0] < w[1]));
|
||||
assert!(BARE_NAMES.iter().all(|n| (2..=6).contains(&n.len()) && named(n).is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_latin_1_and_greek_name_is_its_code_point() {
|
||||
for (table, base) in [(&LATIN1[..], 0xA0), (&GREEK_UPPER[..], 0x391), (&GREEK_LOWER[..], 0x3B1)] {
|
||||
for (i, name) in table.iter().enumerate().filter(|(_, n)| !n.is_empty()) {
|
||||
assert_eq!(named(name), char::from_u32(base + i as u32), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_references() {
|
||||
assert_eq!(unescape("<b> & "x" 'y'"), "<b> & \"x\" 'y'");
|
||||
assert_eq!(unescape("a b"), "a\u{a0}b");
|
||||
assert_eq!(unescape("© ® ™ … — –"), "© ® ™ … — –");
|
||||
assert_eq!(unescape("‘a’ “b” €5"), "‘a’ “b” €5");
|
||||
assert_eq!(unescape("café naïve Über ß ÿ"), "café naïve Über ß ÿ");
|
||||
assert_eq!(unescape("αβΩς → ≤ ≠"), "αβΩς → ≤ ≠");
|
||||
assert_eq!(unescape("‌¦ÐþΑο✗✓♦"), "\u{200c}¦Ðþ\u{391}ο✗✓♦");
|
||||
assert_eq!(unescape("⅓∴⇔◊"), "⅓∴⇔◊");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_case_sensitive() {
|
||||
assert_eq!(unescape("Éé"), "Éé");
|
||||
assert_eq!(unescape("&EACUTE;"), "&EACUTE;");
|
||||
assert_eq!(unescape("&<"), "&<");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_references() {
|
||||
assert_eq!(unescape("ééé"), "ééé");
|
||||
assert_eq!(unescape("😀"), "😀");
|
||||
assert_eq!(unescape("😀"), "😀");
|
||||
// Without the semicolon.
|
||||
assert_eq!(unescape("A B."), "A B.");
|
||||
// Windows-1252 for the C1 range.
|
||||
assert_eq!(unescape("“hi” € ™"), "“hi” € ™");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_numbers_become_the_replacement_character() {
|
||||
assert_eq!(unescape("�"), "\u{fffd}");
|
||||
assert_eq!(unescape("�"), "\u{fffd}");
|
||||
assert_eq!(unescape("�"), "\u{fffd}");
|
||||
assert_eq!(unescape("�"), "\u{fffd}");
|
||||
assert_eq!(unescape("�"), "\u{fffd}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_references_are_left_alone() {
|
||||
for s in ["&", "&&", "& ", "&;", "&#;", "&#x;", "&#xZZ;", "&bogus;", "AT&T", "a & b", "x&y=z", "&#", "&#x", "&eacut", "ünï&", "&Alpha", "&hellip"]
|
||||
{
|
||||
assert_eq!(unescape(s), s, "{s}");
|
||||
assert_eq!(unescape_attr(s), s, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_names_work_without_a_semicolon() {
|
||||
assert_eq!(unescape("a < b > c"), "a < b > c");
|
||||
assert_eq!(unescape("x "), "x\u{a0}");
|
||||
assert_eq!(unescape("&"), "&");
|
||||
assert_eq!(unescape("© 2024 café!"), "© 2024 café!");
|
||||
// The longest name wins: `∉` is one reference; `¥x;` is `¥` and `x;`.
|
||||
assert_eq!(unescape("∉ ¥x;"), "∉ ¥x;");
|
||||
// In text even inside a word, as browsers read it.
|
||||
assert_eq!(unescape("&lify ?a=1&=2"), "&lify ?a=1&=2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_an_attribute_a_bare_name_before_a_letter_digit_or_equals_is_text() {
|
||||
assert_eq!(unescape_attr("?a=1&=2<=3©=4®1"), "?a=1&=2<=3©=4®1");
|
||||
assert_eq!(unescape_attr("&lify"), "&lify");
|
||||
assert_eq!(unescape_attr("?a=1&b=2"), "?a=1&b=2");
|
||||
assert_eq!(unescape_attr("a < b©"), "a < b©");
|
||||
// Numeric references need no `;` anywhere.
|
||||
assert_eq!(unescape_attr("A=1"), "A=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entity_body_decodes_whole_or_not_at_all() {
|
||||
assert_eq!(decode_entity("amp"), Some('&'));
|
||||
assert_eq!(decode_entity("#38"), Some('&'));
|
||||
assert_eq!(decode_entity("#x26"), Some('&'));
|
||||
assert_eq!(decode_entity("#150"), Some('–'));
|
||||
for none in ["", "#", "#x", "#38x", "#3 8", "bogus", "amp;"] {
|
||||
assert_eq!(decode_entity(none), None, "{none}");
|
||||
}
|
||||
assert_eq!(reference("amp;rest", false), Some(('&', 4)));
|
||||
assert_eq!(reference("#x41;", true), Some(('A', 5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_run_of_ampersands_is_quick() {
|
||||
let s = "&".repeat(200_000);
|
||||
assert_eq!(unescape(&s), s);
|
||||
let s = "&#".repeat(100_000);
|
||||
assert_eq!(unescape(&s), s);
|
||||
let s = "&aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".repeat(20_000);
|
||||
assert_eq!(unescape(&s), s);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn unescape_undoes_escape(s in proptest::prelude::any::<String>()) {
|
||||
proptest::prop_assert_eq!(unescape(&escape(&s)), s.clone());
|
||||
proptest::prop_assert_eq!(unescape_attr(&escape_dq(&s)), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescape_never_panics(s in "[&#xX0-9a-zA-Z;= é]{0,60}") {
|
||||
let _ = unescape(&s);
|
||||
let _ = unescape_attr(&s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! A tolerant HTML tokenizer: text, start tags and end tags.
|
||||
//!
|
||||
//! It follows the general syntax of the HTML Living Standard closely enough for documents, mail
|
||||
//! and clipboard fragments, and reads anything: a `<` that starts no tag is text, a tag or
|
||||
//! comment that never ends runs to the end of the input. Comments, doctypes and processing
|
||||
//! instructions never leave it.
|
||||
|
||||
use crate::entity::unescape_attr;
|
||||
|
||||
/// What a [`Lexer`] caps.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Most attributes kept per tag; the rest are read and dropped. Default 64.
|
||||
pub max_attrs: usize,
|
||||
/// Longest tag name kept, in bytes; a longer one is no real element and its name is empty.
|
||||
/// Default 64.
|
||||
pub max_tag_name: usize,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub const DEFAULT: Limits = Limits { max_attrs: 64, max_tag_name: 64 };
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Limits {
|
||||
Limits::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
/// How a [`Lexer`] reads. Element names are lower case.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Options<'o> {
|
||||
pub limits: Limits,
|
||||
/// Elements that never leave the lexer: the start tag, the content (raw text, not markup)
|
||||
/// and the end tag are skipped. For `script`, `style`, `title` and the like, when the caller
|
||||
/// has no use for them. A caller that wants a `style`'s text leaves it out of this list and
|
||||
/// calls [`Lexer::raw_content`] on its start tag.
|
||||
pub drop: &'o [&'o str],
|
||||
/// Elements whose content is text, not markup, and is given: after the start tag comes one
|
||||
/// [`Token::Text`] with everything up to the end tag. For `textarea`, `xmp`, `plaintext`.
|
||||
pub raw_text: &'o [&'o str],
|
||||
}
|
||||
|
||||
/// A start tag.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Tag<'a> {
|
||||
/// Lower case; empty for a name longer than [`Limits::max_tag_name`].
|
||||
pub name: String,
|
||||
/// Names and values as written, in the order written.
|
||||
pub attrs: Vec<(&'a str, &'a str)>,
|
||||
/// The tag ends in `/>`.
|
||||
pub self_close: bool,
|
||||
}
|
||||
|
||||
impl Tag<'_> {
|
||||
/// An attribute's value with character references decoded ([`unescape_attr`]); the first
|
||||
/// of a name wins, as in browsers. Names match in any letter case.
|
||||
pub fn attr(&self, name: &str) -> Option<String> {
|
||||
self.raw_attr(name).map(unescape_attr)
|
||||
}
|
||||
|
||||
/// An attribute's value as written, character references and all.
|
||||
pub fn raw_attr(&self, name: &str) -> Option<&str> {
|
||||
self.attrs.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, v)| *v)
|
||||
}
|
||||
|
||||
/// Whether the tag has the attribute, with or without a value.
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
self.attrs.iter().any(|(n, _)| n.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// Every attribute in the order written: the name as written, the value decoded.
|
||||
pub fn attributes(&self) -> impl Iterator<Item = (&str, String)> + '_ {
|
||||
self.attrs.iter().map(|(n, v)| (*n, unescape_attr(v)))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Token<'a> {
|
||||
/// Character data as written: references are not decoded ([`crate::unescape`]).
|
||||
Text(&'a str),
|
||||
Start(Tag<'a>),
|
||||
/// An end tag's name, lower case (empty when longer than [`Limits::max_tag_name`]).
|
||||
End(String),
|
||||
}
|
||||
|
||||
/// The tokenizer: an iterator of [`Token`]s over a string.
|
||||
pub struct Lexer<'a, 'o> {
|
||||
src: &'a str,
|
||||
pos: usize,
|
||||
opts: Options<'o>,
|
||||
/// Set after the start tag of a raw-text element: its content comes next.
|
||||
raw: Option<String>,
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a, 'static> {
|
||||
/// A lexer with [`Options::default`]: nothing dropped, no raw-text elements.
|
||||
pub fn new(src: &'a str) -> Lexer<'a, 'static> {
|
||||
Lexer::with(src, Options::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, 'o> Lexer<'a, 'o> {
|
||||
pub fn with(src: &'a str, opts: Options<'o>) -> Lexer<'a, 'o> {
|
||||
Lexer { src, pos: 0, opts, raw: None }
|
||||
}
|
||||
|
||||
/// How far into the input the tokens given so far go, in bytes.
|
||||
pub fn pos(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
|
||||
/// The raw content of element `name`, whose start tag was the last token: everything up to
|
||||
/// its end tag (any letter case), or to the end of the input when it has none. The end tag
|
||||
/// is consumed. For `style` and `script`, whose content is not markup.
|
||||
pub fn raw_content(&mut self, name: &str) -> &'a str {
|
||||
self.raw = None;
|
||||
let b = self.bytes();
|
||||
let end = find_end_tag(b, self.pos, name).unwrap_or(b.len());
|
||||
let text = self.slice(self.pos, end);
|
||||
self.pos = find_byte(b, end, b'>').map_or(b.len(), |i| i + 1);
|
||||
text
|
||||
}
|
||||
|
||||
fn bytes(&self) -> &'a [u8] {
|
||||
self.src.as_bytes()
|
||||
}
|
||||
|
||||
fn slice(&self, start: usize, end: usize) -> &'a str {
|
||||
self.src.get(start..end).unwrap_or("")
|
||||
}
|
||||
|
||||
/// A tag name in lower case; empty when it is too long to be a real element.
|
||||
fn tag_name(&self, raw: &str) -> String {
|
||||
if raw.len() > self.opts.limits.max_tag_name { String::new() } else { raw.to_ascii_lowercase() }
|
||||
}
|
||||
|
||||
/// Whether the `<` at `i` starts a tag, a comment or a declaration (rather than being text).
|
||||
fn markup_at(&self, i: usize) -> bool {
|
||||
let b = self.bytes();
|
||||
match b.get(i + 1) {
|
||||
Some(c) if c.is_ascii_alphabetic() => true,
|
||||
Some(b'!' | b'?') => true,
|
||||
Some(b'/') => matches!(b.get(i + 2), Some(c) if c.is_ascii_alphabetic() || *c == b'>'),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of the text run that starts at `self.pos`: the next `<` that starts markup.
|
||||
fn text_end(&self) -> usize {
|
||||
let b = self.bytes();
|
||||
let mut i = self.pos + 1;
|
||||
loop {
|
||||
match find_byte(b, i, b'<') {
|
||||
Some(j) if self.markup_at(j) => return j,
|
||||
Some(j) => i = j + 1,
|
||||
None => return b.len(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skip a comment, doctype or processing instruction at `self.pos`.
|
||||
fn skip_declaration(&mut self) {
|
||||
let b = self.bytes();
|
||||
let end = if b.get(self.pos..self.pos + 4) == Some(b"<!--") {
|
||||
let body = self.pos + 4;
|
||||
if b.get(body) == Some(&b'>') {
|
||||
body + 1
|
||||
} else if b.get(body..body + 2) == Some(b"->") {
|
||||
body + 2
|
||||
} else {
|
||||
find_sub(b, body, b"-->").map_or(b.len(), |i| i + 3)
|
||||
}
|
||||
} else {
|
||||
find_byte(b, self.pos, b'>').map_or(b.len(), |i| i + 1)
|
||||
};
|
||||
self.pos = end.min(b.len());
|
||||
}
|
||||
|
||||
/// Read the end tag at `self.pos` (`</name ...>`).
|
||||
fn close_tag(&mut self) -> Option<Token<'a>> {
|
||||
let b = self.bytes();
|
||||
let start = self.pos + 2;
|
||||
let mut i = start;
|
||||
while let Some(c) = b.get(i) {
|
||||
if c.is_ascii_whitespace() || *c == b'/' || *c == b'>' {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let name = self.slice(start, i);
|
||||
self.pos = find_byte(b, i, b'>').map_or(b.len(), |j| j + 1);
|
||||
// `</>` is nothing at all.
|
||||
(!name.is_empty()).then(|| Token::End(self.tag_name(name)))
|
||||
}
|
||||
|
||||
/// Read the start tag at `self.pos` (`<name attr=value ...>`).
|
||||
fn open_tag(&mut self) -> Tag<'a> {
|
||||
let b = self.bytes();
|
||||
let start = self.pos + 1;
|
||||
let mut i = start;
|
||||
while let Some(c) = b.get(i) {
|
||||
if c.is_ascii_whitespace() || *c == b'/' || *c == b'>' {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let name = self.tag_name(self.slice(start, i));
|
||||
let mut attrs = Vec::new();
|
||||
let mut self_close = false;
|
||||
loop {
|
||||
while matches!(b.get(i), Some(c) if c.is_ascii_whitespace() || *c == b'/') {
|
||||
self_close = b.get(i) == Some(&b'/') && b.get(i + 1) == Some(&b'>');
|
||||
i += 1;
|
||||
}
|
||||
match b.get(i) {
|
||||
None => break,
|
||||
Some(b'>') => {
|
||||
i += 1;
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
// An attribute name runs to white space, `/`, `>` or `=`; a leading `=` is part of it.
|
||||
let name_start = i;
|
||||
i += 1;
|
||||
while let Some(c) = b.get(i) {
|
||||
if c.is_ascii_whitespace() || matches!(*c, b'/' | b'>' | b'=') {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let attr = self.slice(name_start, i);
|
||||
let (value, next) = self.attr_value(i);
|
||||
i = next;
|
||||
if attrs.len() < self.opts.limits.max_attrs {
|
||||
attrs.push((attr, value));
|
||||
}
|
||||
}
|
||||
self.pos = i.min(b.len());
|
||||
Tag { name, attrs, self_close }
|
||||
}
|
||||
|
||||
/// The value after an attribute name that ends at `i` (empty when it has none), and where
|
||||
/// reading goes on.
|
||||
fn attr_value(&self, i: usize) -> (&'a str, usize) {
|
||||
let b = self.bytes();
|
||||
let mut j = i;
|
||||
while matches!(b.get(j), Some(c) if c.is_ascii_whitespace()) {
|
||||
j += 1;
|
||||
}
|
||||
if b.get(j) != Some(&b'=') {
|
||||
return ("", j);
|
||||
}
|
||||
j += 1;
|
||||
while matches!(b.get(j), Some(c) if c.is_ascii_whitespace()) {
|
||||
j += 1;
|
||||
}
|
||||
match b.get(j) {
|
||||
Some(&q) if q == b'"' || q == b'\'' => {
|
||||
let end = find_byte(b, j + 1, q).unwrap_or(b.len());
|
||||
(self.slice(j + 1, end), (end + 1).min(b.len()))
|
||||
}
|
||||
_ => {
|
||||
let mut end = j;
|
||||
while let Some(c) = b.get(end) {
|
||||
if c.is_ascii_whitespace() || *c == b'>' {
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
(self.slice(j, end), end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Lexer<'a, '_> {
|
||||
type Item = Token<'a>;
|
||||
|
||||
fn next(&mut self) -> Option<Token<'a>> {
|
||||
loop {
|
||||
let b = self.bytes();
|
||||
if self.pos >= b.len() {
|
||||
return None;
|
||||
}
|
||||
if let Some(name) = self.raw.take() {
|
||||
let end = find_end_tag(b, self.pos, &name).unwrap_or(b.len());
|
||||
let text = self.slice(self.pos, end);
|
||||
self.pos = end;
|
||||
if !text.is_empty() {
|
||||
return Some(Token::Text(text));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if b.get(self.pos) != Some(&b'<') || !self.markup_at(self.pos) {
|
||||
let start = self.pos;
|
||||
self.pos = self.text_end();
|
||||
return Some(Token::Text(self.slice(start, self.pos)));
|
||||
}
|
||||
match b.get(self.pos + 1) {
|
||||
Some(b'!' | b'?') => self.skip_declaration(),
|
||||
Some(b'/') => {
|
||||
if let Some(token) = self.close_tag() {
|
||||
return Some(token);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let tag = self.open_tag();
|
||||
if self.opts.drop.contains(&tag.name.as_str()) {
|
||||
self.raw_content(&tag.name);
|
||||
continue;
|
||||
}
|
||||
if self.opts.raw_text.contains(&tag.name.as_str()) {
|
||||
self.raw = Some(tag.name.clone());
|
||||
}
|
||||
return Some(Token::Start(tag));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_byte(b: &[u8], from: usize, byte: u8) -> Option<usize> {
|
||||
b.get(from..)?.iter().position(|c| *c == byte).map(|i| i + from)
|
||||
}
|
||||
|
||||
fn find_sub(b: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
|
||||
b.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|i| i + from)
|
||||
}
|
||||
|
||||
/// Where `</name` starts (any letter case, followed by white space, `/`, `>` or the end), at or after `from`.
|
||||
fn find_end_tag(b: &[u8], from: usize, name: &str) -> Option<usize> {
|
||||
let mut i = from;
|
||||
while let Some(lt) = find_byte(b, i, b'<') {
|
||||
let after = lt + 2 + name.len();
|
||||
let named = b.get(lt + 1) == Some(&b'/') && b.get(lt + 2..after).is_some_and(|n| n.eq_ignore_ascii_case(name.as_bytes()));
|
||||
if named && b.get(after).is_none_or(|c| c.is_ascii_whitespace() || matches!(*c, b'/' | b'>')) {
|
||||
return Some(lt);
|
||||
}
|
||||
i = lt + 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,177 @@
|
||||
use super::{Lexer, Limits, Options, Token};
|
||||
|
||||
/// As a mail reader sets it up.
|
||||
const MAIL: Options<'static> =
|
||||
Options { limits: Limits { max_attrs: 32, max_tag_name: 24 }, drop: &["script", "style", "title", "iframe"], raw_text: &["textarea", "xmp"] };
|
||||
|
||||
fn show<'a>(lexer: impl Iterator<Item = Token<'a>>) -> Vec<String> {
|
||||
lexer
|
||||
.map(|t| match t {
|
||||
Token::Text(t) => format!("T:{t}"),
|
||||
Token::Start(tag) => {
|
||||
let attrs: Vec<String> = tag.attrs.iter().map(|(n, v)| format!("{n}={v}")).collect();
|
||||
if attrs.is_empty() { format!("<{}>", tag.name) } else { format!("<{} {}>", tag.name, attrs.join(" ")) }
|
||||
}
|
||||
Token::End(n) => format!("</{n}>"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tokens(html: &str) -> Vec<String> {
|
||||
show(Lexer::with(html, MAIL))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_and_tags() {
|
||||
assert_eq!(tokens("Hi <B>there</b>!"), ["T:Hi ", "<b>", "T:there", "</b>", "T:!"]);
|
||||
assert_eq!(tokens(""), Vec::<String>::new());
|
||||
assert_eq!(tokens("plain"), ["T:plain"]);
|
||||
assert_eq!(tokens("<br/><hr />"), ["<br>", "<hr>"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attributes_in_every_quoting_style() {
|
||||
assert_eq!(tokens(r#"<a href="x y" title='it"s' id=z data-k>"#), [r#"<a href=x y title=it"s id=z data-k=>"#]);
|
||||
assert_eq!(tokens("<a href = \"u\"\n\tclass = c >t"), ["<a href=u class=c>", "T:t"]);
|
||||
assert_eq!(tokens("<img src=a.png/>"), ["<img src=a.png/>"]);
|
||||
assert_eq!(tokens("<p =x=y>"), ["<p =x=y>"]);
|
||||
// A `>` inside a quoted value does not end the tag.
|
||||
assert_eq!(tokens(r#"<a title="a>b">t</a>"#), ["<a title=a>b>", "T:t", "</a>"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_values_are_decoded_on_demand() {
|
||||
let mut t = Lexer::new(r#"<a HREF="?a=1&b=2©=3" href="second" Checked>"#);
|
||||
let Some(Token::Start(tag)) = t.next() else { panic!("no tag") };
|
||||
assert_eq!(tag.attr("href").as_deref(), Some("?a=1&b=2©=3"));
|
||||
assert_eq!(tag.raw_attr("href"), Some("?a=1&b=2©=3"));
|
||||
assert_eq!(tag.attr("missing"), None);
|
||||
assert!(tag.has("checked") && !tag.has("check"));
|
||||
assert_eq!(tag.attributes().map(|(n, _)| n).collect::<Vec<_>>(), ["HREF", "href", "Checked"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_that_closes_itself_says_so() {
|
||||
let closes = |html: &str| match Lexer::new(html).next() {
|
||||
Some(Token::Start(tag)) => tag.self_close,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
for yes in ["<br/>", "<br />", "<img src=\"a\"/>", "<img src=a />", "<p\n/>"] {
|
||||
assert!(closes(yes), "{yes}");
|
||||
}
|
||||
// A `/` that ends an unquoted value, or stands anywhere but before the `>`, is not it.
|
||||
for no in ["<br>", "<img src=a.png/>", "<a / b>", "<a/ >", "<br/"] {
|
||||
assert!(!closes(no), "{no}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_angle_bracket_is_text() {
|
||||
assert_eq!(tokens("a < b and c > d"), ["T:a < b and c > d"]);
|
||||
assert_eq!(tokens("1<2<3"), ["T:1<2<3"]);
|
||||
assert_eq!(tokens("<"), ["T:<"]);
|
||||
assert_eq!(tokens("x<"), ["T:x<"]);
|
||||
assert_eq!(tokens("<3 <- </ x"), ["T:<3 <- </ x"]);
|
||||
assert_eq!(tokens("a</>b"), ["T:a", "T:b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_and_declarations_vanish() {
|
||||
assert_eq!(tokens("a<!-- x -- > y -->b"), ["T:a", "T:b"]);
|
||||
assert_eq!(tokens("<!DOCTYPE html><p>x"), ["<p>", "T:x"]);
|
||||
assert_eq!(tokens("<?xml version=\"1.0\"?>x"), ["T:x"]);
|
||||
assert_eq!(tokens("a<!-->b<!--->c"), ["T:a", "T:b", "T:c"]);
|
||||
assert_eq!(tokens("<!--[if mso]><p>only word</p><![endif]-->shown"), ["T:shown"]);
|
||||
assert_eq!(tokens("<![if !mso]>shown<![endif]>"), ["T:shown"]);
|
||||
// An unfinished comment swallows the rest.
|
||||
assert_eq!(tokens("a<!-- b <p>c"), ["T:a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_elements_never_leave_the_lexer() {
|
||||
assert_eq!(tokens("a<script>if (a<b) alert('</p>')</script>b"), ["T:a", "T:b"]);
|
||||
assert_eq!(tokens("a<STYLE type=text/css>p > b { color: red }</Style >b"), ["T:a", "T:b"]);
|
||||
assert_eq!(tokens("<title>Hello</title>x"), ["T:x"]);
|
||||
assert_eq!(tokens("a<script>never closed <p>x"), ["T:a"]);
|
||||
// `</scripty>` is not the end tag.
|
||||
assert_eq!(tokens("<script>x</scripty>y</script>z"), ["T:z"]);
|
||||
// Dropping is an option: by default the tags come out and the content is markup like any other.
|
||||
assert_eq!(show(Lexer::new("a<script>x<b>y</script>b")), ["T:a", "<script>", "T:x", "<b>", "T:y", "</script>", "T:b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_content_is_the_callers_to_take() {
|
||||
let src = "<style media=print>p > b { color: red } </p></STYLE >after<script>x";
|
||||
let mut l = Lexer::new(src);
|
||||
let Some(Token::Start(tag)) = l.next() else { panic!("no tag") };
|
||||
assert_eq!((tag.name.as_str(), tag.raw_attr("media")), ("style", Some("print")));
|
||||
assert_eq!(l.raw_content("style"), "p > b { color: red } </p>");
|
||||
assert_eq!(l.next(), Some(Token::Text("after")));
|
||||
assert!(matches!(l.next(), Some(Token::Start(t)) if t.name == "script"));
|
||||
// No end tag: to the end of the input.
|
||||
assert_eq!(l.raw_content("script"), "x");
|
||||
assert_eq!((l.next(), l.pos()), (None, src.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_text_content_is_text() {
|
||||
assert_eq!(tokens("<textarea><b>x</b></textarea>y"), ["<textarea>", "T:<b>x</b>", "</textarea>", "T:y"]);
|
||||
assert_eq!(tokens("<textarea></textarea>"), ["<textarea>", "</textarea>"]);
|
||||
assert_eq!(tokens("<xmp>a<p>b"), ["<xmp>", "T:a<p>b"]);
|
||||
assert_eq!(show(Lexer::new("<xmp>a<p>b")), ["<xmp>", "T:a", "<p>", "T:b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfinished_tags_run_to_the_end() {
|
||||
assert_eq!(tokens("a<b"), ["T:a", "<b>"]);
|
||||
assert_eq!(tokens("a<a href=\"x"), ["T:a", "<a href=x>"]);
|
||||
assert_eq!(tokens("a</b"), ["T:a", "</b>"]);
|
||||
assert_eq!(tokens("a<b c"), ["T:a", "<b c=>"]);
|
||||
assert_eq!(tokens("a<b c="), ["T:a", "<b c=>"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_everywhere() {
|
||||
assert_eq!(tokens("é<b é=ü ñ>ß</b>ø"), ["T:é", "<b é=ü ñ=>", "T:ß", "</b>", "T:ø"]);
|
||||
assert_eq!(tokens("<é>"), ["T:<é>"]);
|
||||
assert_eq!(tokens("<bé>x"), ["<bé>", "T:x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_names_and_many_attributes_are_capped() {
|
||||
let name = |n: usize, opts: Options<'static>| show(Lexer::with(&format!("<{0}>x</{0}>", "a".repeat(n)), opts));
|
||||
assert_eq!(name(25, MAIL), ["<>", "T:x", "</>"]);
|
||||
assert_eq!(name(24, MAIL).first().map(String::len), Some(26));
|
||||
assert_eq!(name(65, Options::default()), ["<>", "T:x", "</>"]);
|
||||
assert_eq!(name(64, Options::default()).first().map(String::len), Some(66));
|
||||
let many = format!("<p {}>", (0..100).map(|i| format!("a{i}=1")).collect::<Vec<_>>().join(" "));
|
||||
for (opts, kept) in [(MAIL, 32), (Options::default(), 64)] {
|
||||
let Some(Token::Start(tag)) = Lexer::with(&many, opts).next() else { panic!("no tag") };
|
||||
assert_eq!(tag.attrs.len(), kept);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_runs_are_linear() {
|
||||
for s in [
|
||||
"<".repeat(300_000),
|
||||
"<a ".repeat(100_000),
|
||||
"<!--".repeat(100_000),
|
||||
"</".repeat(150_000),
|
||||
"<script>".repeat(50_000),
|
||||
"<b x=\"".repeat(50_000),
|
||||
] {
|
||||
assert!(Lexer::with(&s, MAIL).count() <= s.len());
|
||||
assert!(Lexer::new(&s).count() <= s.len());
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
#[test]
|
||||
fn any_input_tokenizes_and_keeps_the_text(s in "[<>/!a-c =\"'&;é-]{0,80}") {
|
||||
for opts in [MAIL, Options::default()] {
|
||||
let total: usize = Lexer::with(&s, opts).map(|t| match t { Token::Text(t) => t.len(), _ => 0 }).sum();
|
||||
proptest::prop_assert!(total <= s.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
//! The bottom layer of reading and writing HTML, shared by everything that reads a page, a
|
||||
//! mail body or a clipboard fragment.
|
||||
//!
|
||||
//! - [`Lexer`] is a tolerant tokenizer: text, start tags and end tags, borrowed from the input.
|
||||
//! It reads anything: a `<` that starts no tag is text, a tag or comment that never ends runs
|
||||
//! to the end of the input. [`Options`] names the elements to drop whole (`script`), the
|
||||
//! elements whose content is text (`textarea`), and the caps ([`Limits`]).
|
||||
//! - [`unescape`], [`unescape_attr`] and [`decode_entity`] decode character references;
|
||||
//! [`escape`] and [`escape_dq`] write them.
|
||||
//! - [`declared_charset`] finds the character set a page names in its first bytes.
|
||||
//!
|
||||
//! There is no tree builder, no CSS and no rendering here: what the tags mean is the caller's.
|
||||
//!
|
||||
//! Input is hostile. Nothing here panics on any string, and the work is linear in the input.
|
||||
//!
|
||||
//! ```
|
||||
//! use craft_html::{Lexer, Options, Token, unescape};
|
||||
//!
|
||||
//! let opts = Options { drop: &["script"], ..Options::default() };
|
||||
//! let mut text = String::new();
|
||||
//! let mut links = Vec::new();
|
||||
//! for token in Lexer::with(r#"Tom & <a href="?a=1&b=2">Jerry</a><script>x<y</script>"#, opts) {
|
||||
//! match token {
|
||||
//! Token::Text(t) => text.push_str(&unescape(t)),
|
||||
//! Token::Start(tag) if tag.name == "a" => links.extend(tag.attr("href")),
|
||||
//! _ => {}
|
||||
//! }
|
||||
//! }
|
||||
//! assert_eq!((text.as_str(), links.as_slice()), ("Tom & Jerry", &["?a=1&b=2".to_string()][..]));
|
||||
//! ```
|
||||
|
||||
#[cfg(doctest)]
|
||||
#[doc = include_str!("../README.md")]
|
||||
struct ReadmeExamples;
|
||||
|
||||
mod entity;
|
||||
mod lex;
|
||||
|
||||
pub use entity::{decode_entity, escape, escape_dq, reference, unescape, unescape_attr};
|
||||
pub use lex::{Lexer, Limits, Options, Tag, Token};
|
||||
|
||||
/// How many bytes of a page [`declared_charset`] looks at.
|
||||
pub const CHARSET_SNIFF_BYTES: usize = 2048;
|
||||
|
||||
/// The character set a page names in its first [`CHARSET_SNIFF_BYTES`] bytes: a
|
||||
/// `<meta charset=…>`, a `content="text/html; charset=…"`, or an XML declaration's `encoding=…`.
|
||||
/// Lower case, as written otherwise: the caller maps the label to a decoder. `None` when the
|
||||
/// page names none. A byte order mark is the caller's to check first.
|
||||
pub fn declared_charset(bytes: &[u8]) -> Option<String> {
|
||||
let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(CHARSET_SNIFF_BYTES)).unwrap_or(bytes)).to_ascii_lowercase();
|
||||
let (i, n) = head.find("charset").map(|i| (i, 7)).or_else(|| head.find("encoding").map(|i| (i, 8)))?;
|
||||
let after = head.get(i + n..)?.trim_start().strip_prefix('=')?;
|
||||
let trimmed = after.trim_start().trim_start_matches(['"', '\'']);
|
||||
let end = trimmed.find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ';' | '>' | '/')).unwrap_or(trimmed.len());
|
||||
let label = trimmed.get(..end)?.trim();
|
||||
(!label.is_empty()).then(|| label.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::declared_charset;
|
||||
|
||||
#[test]
|
||||
fn the_declared_charset_is_found_in_each_form() {
|
||||
assert_eq!(declared_charset(b"<meta charset=\"UTF-8\">").as_deref(), Some("utf-8"));
|
||||
assert_eq!(declared_charset(b"<META http-equiv=Content-Type content='text/html; charset=windows-1251'>").as_deref(), Some("windows-1251"));
|
||||
assert_eq!(declared_charset(b"<?xml version=\"1.0\" encoding = 'Shift_JIS'?>").as_deref(), Some("shift_jis"));
|
||||
assert_eq!(declared_charset(b"<meta charset=koi8-r/>").as_deref(), Some("koi8-r"));
|
||||
for none in [&b"<p>no charset here</p>"[..], b"charset", b"charset=", b"charset=''", b"", b"\xff\xfe\x00"] {
|
||||
assert_eq!(declared_charset(none), None);
|
||||
}
|
||||
// Only the head is looked at.
|
||||
let mut late = vec![b' '; super::CHARSET_SNIFF_BYTES];
|
||||
late.extend_from_slice(b"<meta charset=utf-8>");
|
||||
assert_eq!(declared_charset(&late), None);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user