Import: hostile Markdown and HTML no longer take quadratic time (#355)
A few hundred KB of `*[`, `, `<` (for `>`) and `&` (for `;`). Bracket and destination scans now share a per-paragraph look-ahead budget, tags are searched within 4 KB and entity names within 33 bytes. - HTML `unescape` searched the whole remaining text for `;` after every stray `&`; it now looks only at the first 33 bytes. - The Markdown/HTML-to-document step appended every inline through `insert_text`, which rescans all earlier runs, so a paragraph with thousands of runs took quadratic time on its own. It now appends runs directly and normalises once. Ordinary links, images, tags and entities read as before. Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e2d8551b7f
commit
9ddaff2dae
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -542,12 +542,16 @@ fn push_text(nodes: &mut Vec<Node>, 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<usize> {
|
||||
/// 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<usize> {
|
||||
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<String> {
|
|||
|
||||
fn tokenize(s: &str, base: &Fmt, depth: usize) -> Vec<Node> {
|
||||
let mut nodes: Vec<Node> = 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<Node> {
|
|||
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<Node> {
|
|||
}
|
||||
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<Node> {
|
|||
}
|
||||
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<Node> {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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<InlineObject>) {
|
||||
let text: std::borrow::Cow<str> = if text.contains(OBJ) { text.chars().filter(|c| *c != OBJ).collect::<String>().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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "<a".repeat(200_000)),
|
||||
("html", "&#".repeat(500_000)),
|
||||
];
|
||||
for (ext, text) in cases {
|
||||
let t = Instant::now();
|
||||
let d = import(ext, text.as_bytes()).unwrap().unwrap();
|
||||
let _ = d.plain_text(Default::default());
|
||||
assert!(t.elapsed() < Duration::from_secs(5), "{ext} {:?}... took {:?}", &text[..8], t.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
/// The bounded scans must not change how ordinary links, images, tags and entities read.
|
||||
#[test]
|
||||
fn bounded_scans_keep_ordinary_markdown_and_entities() {
|
||||
let md =
|
||||
import("md", b"See [the *docs*](https://example.com/a(b) and  & & <b>bold</b> <https://example.com>.\n").unwrap().unwrap();
|
||||
let text = md.plain_text(Default::default());
|
||||
assert!(text.contains("the docs"), "{text}");
|
||||
assert!(text.contains("& &"), "{text}");
|
||||
let html = import("html", "<p><&Aé &nosuchentity; A</p>".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!("<p>&{};</p>", "a".repeat(40));
|
||||
let t = import("html", long.as_bytes()).unwrap().unwrap().plain_text(Default::default());
|
||||
assert!(t.contains(&"a".repeat(40)), "{t}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue