mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* Add craft-wordmodel: WordCraft's document model without its editing code
WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.
Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.
* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs
The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.
* craft-mime: detect, for bytes that name no character set
is_text, decode (BOM, unmarked UTF-16, UTF-8, else the legacy encoding
the bytes look like), legacy and legacy_label, behind a detect feature
that brings in chardetng. Moved from WordCraft's crates/formats/src/txt.rs
at 06c2ae6f with its text-sniffing tests. Also charset::is_known, so a
caller can tell a label decode would read as UTF-8 from one it knows.
* craft-wordmodel: keep the document operations (edit.rs) with the model
The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.
base64 0.23, as WordCraft and craft-offcrypto use.
* craft-codepage: one table per legacy code page, for craft-rtf, craft-dxf and craft-mime
craft-rtf and craft-dxf each carried tables for DOS 437, 737, 850, 852 and
857 (and craft-rtf one for 866). They are now one set in craft-codepage,
which takes craft-rtf's codepage module (Windows and Mac tables, OEM glyphs,
font charsets) and craft-dxf's DOS tables. craft_rtf::codepage re-exports it
unchanged; craft-dxf keeps its $DWGCODEPAGE names and Decoder and looks the
DOS tables up there.
Every byte of every code page either crate decodes is unchanged: the
public decoders of both crates were dumped over all 256 bytes of each page
(and the East Asian pairs) before and after, and the dumps are identical.
craft-mime now reads the DOS pages by their IANA names (IBM437, cp850,
ibm-852, 857...), which it read as UTF-8 before.
* Add craft-math: the equation tree with OMML, MathML, LaTeX and UnicodeMath
The math tree, LaTeX, linear-format, symbol and tree-walk modules come
from craft-wordmodel (storytold/craft-libs#963, WordCraft's crates/doc at
07f97737) unchanged, with the run colour (Rgb) and the symbol-font tables
they use. MathML is WordCraft's crates/formats/src/mathml.rs and OMML is
WordCraft's crates/docx/src/read/math.rs and write/math.rs (06c2ae6f /
07f97737), moved onto craft-xml. The HTML-specific MathML tests stay in
WordCraft; the rest moved with the code.
mathml::parse and omml::parse/read_str/write_str are new: string entry
points for callers that have no tree of their own.
* craft-math: read MathLive's and other spellings of LaTeX math commands
\abs{...} (an absolute value), \lvert \rvert \lVert \rVert, \nparallel,
\backprime, \infinity, \cross, \differentialD, \exponentialE and
\imaginaryI (MathLive's d, upright e and i), \emph, \textsf, \hbox,
\mathnormal and \underleftarrow / \underrightarrow /
\underleftrightarrow were unknown and read as literal text. They are read
as equaSHion's typed-input parser reads them (storytold/craft-libs#29).
* craft-math: equaSHion's equation corpus as test data
corpus/equations.tsv, corpus/latex.tsv and the OMML samples of
storytold/craft-libs#29 (7a6ef6b9), unmodified.
* craft-math: corpus round trips, GridCraft's OMML cases, entry points and limits tested; README
The corpus equations go through OMML, MathML and LaTeX and back; the OMML
samples are written back as kept and from the tree. GridCraft's OMML to
LaTeX test cases (crates/xlsx/src/omml.rs) give LaTeX that reads back the
same through the tree. omml::parse and read_str are tested against other
prefixes, the strict namespace, kept OMML, junk, the element cap and deep
nesting.
* Add craft-flow: the flow model the text formats map to, on craft-wordmodel
WordCraft's crates/formats/src/model (07f97737) moved as it is: Flow,
FBlock, Para, Inline, Fmt, tables and cells, pictures, to_doc and
from_doc, picture sniffing, colours, base64 and the Dropped report. Only
the paths changed (wordcraft_doc is craft_wordmodel); the report helpers
are public so the format crates can call them; the image re-encoding
helper stays with WordCraft's RTF writer.
* Add craft-markdown: Markdown read and written, on craft-flow
WordCraft's crates/formats/src/markdown (07f97737) with its 35 tests,
moved onto craft-flow and craft-wordmodel. Character references come from
craft-html. The table cell budget lives in craft-flow, which names it in
its warning. Two test lines that wrote plain text through WordCraft's txt
module stay in WordCraft.
* WIP: craft-latex sources from WordCraft (unmodified)
* Add craft-latex: LaTeX documents read and written, on craft-flow
WordCraft's crates/formats/src/latex (07f97737) with its 21 tests, moved
onto craft-flow and craft-wordmodel; bytes are decoded by craft-mime's
detect. The LaTeX parts of WordCraft's cross-format tests (nesting,
column specs, labels, no TeX commands from the document, the fuzz and
round-trip properties) come along as tests/hostile.rs. The ligature test
builds its three-font paragraph as a flow instead of reading HTML.
* craft-math: latex_text, LaTeX math and the linear format converted as text
WordCraft's crates/formats/src/latex/math.rs (07f97737): the converter its
LaTeX document reader and writer use, which keeps the source's spacing.
It moves here so craft-math is the one place that converts LaTeX math;
craft-latex calls it, and its output does not change. Its tests (from
WordCraft's latex tests and cross-format tests) come along.
* craft-math: latex_text's group finders are public, for craft-latex's length parser
* craft-latex: math through craft_math::latex_text; label timing test by ratio
The document math converter moved to craft-math (#1040) unchanged; this
crate calls it there (and its length parser uses its group finders), so
export output is the same. The label test compared one read with a
ten-second wall clock and failed on a loaded machine; it now compares the
fastest of three reads of 25,000 and 100,000 labels, which a linear read
keeps near 4x and a quadratic one near 16x.
* Cargo.lock after the merge
* WIP: WordCraft's HTML document reader and writer, unmodified (crates/formats/src/html at 07f97737)
* craft-html: HTML documents on craft-flow, behind a flow feature
WordCraft's crates/formats/src/html (07f97737) as craft_html::doc: the
tag-soup builder, CSS subset, ruby and MathML reading, page decoding and
the writer, with its 44 tests. It reads tokens from this crate's
tokenizer through a small adapter (decoded text and attributes, names in
lower case) instead of its own lexer, and its character references are
this crate's. Lexer::set_pos is new: the ruby reader hands a token back.
The HTML parts of WordCraft's MathML and cross-format tests come along as
tests/doc.rs, the linear-time check as a ratio.
* craft-math: operator names take their LaTeX scripts directly again (restores #1019)
#1019 (22161d2d) wrote \sin^2\theta as \sin ^{2}{\theta } so it reads back as
the same tree. The squash merge of #1038, whose branch predated it, put
math_latex.rs and the corpus test back as they were before. This applies
#1019's change again, unchanged, with its test.
* Cargo.lock: craft-html's flow dependencies
---------
Co-authored-by: shuff57 <62350898+shuff57@users.noreply.github.com>
360 lines
13 KiB
Rust
360 lines
13 KiB
Rust
//! 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
|
|
}
|
|
|
|
/// Goes back (or forward) to `pos`, a byte offset an earlier [`Lexer::pos`] gave, to read
|
|
/// from there again: for a reader that looked at a token and hands it back. An offset past the
|
|
/// end, or inside a character, moves to the next character boundary.
|
|
pub fn set_pos(&mut self, pos: usize) {
|
|
let mut pos = pos.min(self.src.len());
|
|
while !self.src.is_char_boundary(pos) {
|
|
pos += 1;
|
|
}
|
|
self.pos = pos;
|
|
self.raw = None;
|
|
}
|
|
|
|
/// 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;
|