diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs
index c6fefdc..91d6d83 100644
--- a/crates/formats/src/html.rs
+++ b/crates/formats/src/html.rs
@@ -143,7 +143,12 @@ pub fn unescape(s: &str) -> String {
while let Some(k) = rest.find('&') {
out.push_str(&rest[..k]);
let after = &rest[k + 1..];
- let end = after.find(';').filter(|e| *e <= 32);
+ // An entity name is short; looking further would rescan the whole text for every stray `&`.
+ let mut cut = after.len().min(33);
+ while !after.is_char_boundary(cut) {
+ cut -= 1;
+ }
+ let end = after.get(..cut).and_then(|w| w.find(';'));
match end.and_then(|e| decode_entity(&after[..e]).map(|t| (t, e))) {
Some((t, e)) => {
out.push_str(&t);
diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs
index 79e332b..7616391 100644
--- a/crates/formats/src/markdown.rs
+++ b/crates/formats/src/markdown.rs
@@ -542,12 +542,16 @@ fn push_text(nodes: &mut Vec, s: &str, f: &Fmt) {
nodes.push(Node::Text(s.to_string(), f.clone()));
}
-/// The closing `]` matching the `[` at byte `open` (escapes and code spans respected).
-fn matching_bracket(s: &str, open: usize) -> Option {
+/// The closing `]` matching the `[` at byte `open` (escapes and code spans respected). Gives up
+/// after looking `max` bytes ahead.
+fn matching_bracket(s: &str, open: usize, max: usize) -> Option {
let b = s.as_bytes();
let mut depth = 0usize;
let mut i = open;
while let Some(&c) = b.get(i) {
+ if i - open > max {
+ return None;
+ }
match c {
b'\\' => i += 1,
b'`' => {
@@ -687,6 +691,10 @@ fn tag_attr(tag: &str, name: &str) -> Option {
fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec {
let mut nodes: Vec = Vec::new();
+ // Bytes the link and image scans below may look ahead in total. Each unmatched `[` or `![(`
+ // would otherwise rescan the rest of the text, which is quadratic on hostile input; once the
+ // budget is gone, further brackets are plain text.
+ let mut scan_budget = s.len().saturating_mul(8).saturating_add(8192);
let b = s.as_bytes();
let mut i = 0usize;
let mut text_start = 0usize;
@@ -758,8 +766,12 @@ fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec {
b'!' | b'[' if depth < MAX_NEST => {
let img = c == b'!';
let open = if img { i + 1 } else { i };
- let parsed = if b.get(open) == Some(&b'[') {
- matching_bracket(s, open).and_then(|close| {
+ let parsed = if b.get(open) == Some(&b'[') && scan_budget > 0 {
+ let found = matching_bracket(s, open, scan_budget);
+ scan_budget = scan_budget.saturating_sub(found.map_or(scan_budget, |close| close - open));
+ found.and_then(|close| {
+ // A destination that does not parse may have been read to the end of the text.
+ scan_budget = scan_budget.saturating_sub(s.len().saturating_sub(close));
let (dest, end) = link_dest(s, close + 1)?;
Some((s.get(open + 1..close)?.to_string(), dest, end))
})
@@ -795,7 +807,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec {
}
b'<' => {
let rest = s.get(i + 1..).unwrap_or("");
- let end = rest.find('>');
+ let end = within(rest, MAX_TAG).find('>');
let inner = end.and_then(|e| rest.get(..e)).unwrap_or("");
let lower = inner.to_ascii_lowercase();
let is_auto = !inner.is_empty()
@@ -826,7 +838,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec {
}
b'&' => {
let rest = s.get(i..).unwrap_or("");
- match rest.find(';').filter(|k| *k <= 32).and_then(|k| decode_entity(rest.get(1..k)?).map(|ch| (ch, k))) {
+ match within(rest, 33).find(';').and_then(|k| decode_entity(rest.get(1..k)?).map(|ch| (ch, k))) {
Some((ch, k)) => {
flush!();
push_text(&mut nodes, &ch, base);
@@ -843,6 +855,19 @@ fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec {
nodes
}
+/// Longest `<...>` tag or autolink taken as inline HTML.
+const MAX_TAG: usize = 4096;
+
+/// The first `n` bytes of `s` (fewer if that would cut a character), so a search for a closing
+/// character stays short however long the text is.
+fn within(s: &str, n: usize) -> &str {
+ let mut end = n.min(s.len());
+ while !s.is_char_boundary(end) {
+ end -= 1;
+ }
+ s.get(..end).unwrap_or("")
+}
+
fn emph_index(ch: char) -> usize {
match ch {
'*' => 0,
diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs
index a377428..0ebd31b 100644
--- a/crates/formats/src/model.rs
+++ b/crates/formats/src/model.rs
@@ -5,7 +5,7 @@
use std::sync::Arc;
use wordcraft_doc::numbering::{AbstractNum, Level, ListKind, Num, levels_for};
-use wordcraft_doc::para::{InlineObject, OBJ, PAGE_BREAK};
+use wordcraft_doc::para::{InlineObject, OBJ, PAGE_BREAK, Run};
use wordcraft_doc::props::{
Border, BorderStyle, Borders, CellProps, CharProps, NumRef, ParaProps, RowProps, TextColor, Underline, VMerge, VertAlign,
};
@@ -321,6 +321,26 @@ fn rule_borders() -> Borders {
Borders { bottom: Some(Border { style: BorderStyle::Single, width: 0.75, color: Some(Rgb(0xA0, 0xA0, 0xA0)), space: 1.0 }), ..Default::default() }
}
+/// Append text (without object characters) or one inline object to the end of `p`.
+fn append(p: &mut Paragraph, text: &str, props: &CharProps, obj: Option) {
+ let text: std::borrow::Cow = if text.contains(OBJ) { text.chars().filter(|c| *c != OBJ).collect::().into() } else { text.into() };
+ let piece = match obj {
+ Some(o) => {
+ p.objects.push(o);
+ OBJ.to_string()
+ }
+ None => text.into_owned(),
+ };
+ if piece.is_empty() {
+ return;
+ }
+ p.text.push_str(&piece);
+ match p.runs.last_mut() {
+ Some(last) if last.props == *props => last.len += piece.len(),
+ _ => p.runs.push(Run { len: piece.len(), props: props.clone() }),
+ }
+}
+
struct Builder<'a> {
doc: &'a mut Document,
}
@@ -349,8 +369,9 @@ impl Builder<'_> {
if p.page_break {
out.props.page_break_before = Some(true);
}
+ // Inlines are appended in order, so build the runs directly: inserting each one through
+ // `Paragraph::insert_text` rescans every earlier run and is quadratic in their number.
for i in &p.inlines {
- let end = out.len();
match i {
Inline::Text(t, f) => {
let t = clean_text(t);
@@ -358,32 +379,30 @@ impl Builder<'_> {
if p.kind == Kind::Code && cp.font.as_deref() == Some(MONO_FONT) {
cp.font = None;
}
- let _ = out.insert_text(end, &t, &cp);
+ append(&mut out, &t, &cp, None);
}
Inline::Image(img) => {
let key = self.doc.add_media(img.data.to_vec(), &img.ext);
let (w, h) = (finite_or(img.w, 72.0).clamp(1.0, 1584.0), finite_or(img.h, 72.0).clamp(1.0, 1584.0));
let obj = InlineObject::Image { media: key, w, h, alt: img.alt.clone(), float: Default::default(), crop: [0.0; 4] };
- let _ = out.insert_object(end, obj, &CharProps::default());
+ append(&mut out, "", &CharProps::default(), Some(obj));
}
Inline::Anchor(name) => {
- let _ = out.insert_object(end, InlineObject::BookmarkStart { name: name.clone() }, &CharProps::default());
- let e2 = out.len();
- let _ = out.insert_object(e2, InlineObject::BookmarkEnd { name: name.clone() }, &CharProps::default());
+ append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkStart { name: name.clone() }));
+ append(&mut out, "", &CharProps::default(), Some(InlineObject::BookmarkEnd { name: name.clone() }));
}
Inline::Figure(_) => {}
Inline::Equation { linear, display } => {
let linear = clean_text(linear);
if !linear.is_empty() {
- let _ = out.insert_object(
- end,
- InlineObject::Equation { linear, display: *display, math: Default::default() },
- &CharProps::default(),
- );
+ let obj = InlineObject::Equation { linear, display: *display, math: Default::default() };
+ append(&mut out, "", &CharProps::default(), Some(obj));
}
}
}
}
+ out.normalize();
+ out.touch();
out
}
diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs
index 0443882..546d97b 100644
--- a/crates/formats/src/tests.rs
+++ b/crates/formats/src/tests.rs
@@ -554,3 +554,39 @@ fn chart_alt_text_is_an_image_alt_never_body_text() {
}
assert_eq!(para_texts(&d), ["See below"]);
}
+
+/// Hostile Markdown and HTML that rescanned the rest of the text from every `[`, `![`, `<` or `&`
+/// took quadratic time: a few hundred KB froze the importer for tens of seconds.
+#[test]
+fn hostile_importer_input_takes_linear_time() {
+ use std::time::{Duration, Instant};
+ let cases: [(&str, String); 5] = [
+ ("md", "*[".repeat(50_000)),
+ ("md", "),
+ ("md", "[a](".repeat(100_000)),
+ ("md", "bold .\n").unwrap().unwrap();
+ let text = md.plain_text(Default::default());
+ assert!(text.contains("the docs"), "{text}");
+ assert!(text.contains("& &"), "{text}");
+ let html = import("html", "<&Aé &nosuchentity; A
".as_bytes()).unwrap().unwrap();
+ assert_eq!(html.plain_text(Default::default()).trim(), "<&AĆ© &nosuchentity; A");
+ // An entity name longer than the scan window is left alone, as before.
+ let long = format!("&{};
", "a".repeat(40));
+ let t = import("html", long.as_bytes()).unwrap().unwrap().plain_text(Default::default());
+ assert!(t.contains(&"a".repeat(40)), "{t}");
+}