From 84d95b1022cb1d3f4838e099bf5c3ba969218384 Mon Sep 17 00:00:00 2001 From: funkpower Date: Sat, 19 Sep 2026 21:01:24 -0400 Subject: [PATCH] mdscrub: a page anchor the engine cannot backtrack on --- crates/mdscrub/src/heading.rs | 42 +++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/crates/mdscrub/src/heading.rs b/crates/mdscrub/src/heading.rs index 895c1a8..2d86409 100644 --- a/crates/mdscrub/src/heading.rs +++ b/crates/mdscrub/src/heading.rs @@ -8,9 +8,8 @@ use std::sync::LazyLock; -use fancy_regex::Regex as Boundaried; use regex::Regex; -use textkit::pattern::{SPACE, WORD_END, WORD_START}; +use textkit::pattern::SPACE; fn pattern(source: &str) -> Regex { Regex::new(source).expect("a valid pattern") @@ -37,11 +36,21 @@ static HEADING: LazyLock = LazyLock::new(|| pattern(&format!(r"^(#{{1,6}} /// A page anchor: ``, ``, and the rest of /// the shapes an EPUB uses to say where a printed page began. -pub static PAGE_ANCHOR: LazyLock = LazyLock::new(|| { - Boundaried::new(&format!( - r#"(?i)<(?:span|a|div){WORD_END}[^>]*{WORD_START}id{SPACE}*={SPACE}*["'][^"']*?page[-_]?(\d+)[^"']*["'][^>]*>(?:)?"# - )) - .expect("a valid pattern") +/// +/// On the backtracking engine, as the Python is. A tag carrying an `id` that +/// is not a page — `` is what a Kindle conversion +/// leaves behind, and one real book has forty-four of them — makes the two +/// `[^"']` runs and the two `[^>]` runs try every split between them before +/// giving up. On a megabyte of markdown that does not finish. The engine here +/// cannot backtrack, so no input can make it. +/// +/// The word boundaries are this engine's `\b` rather than Python's, which +/// differ only around combining marks and connector punctuation — neither of +/// which occurs between ` = LazyLock::new(|| { + pattern( + r#"(?i)<(?:span|a|div)\b[^>]*\bid[\s]*=[\s]*["'][^"']*?page[-_]?(\d+)[^"']*["'][^>]*>(?:)?"#, + ) }); /// Clean one heading's text. @@ -73,7 +82,6 @@ pub fn clean_all(markdown: &str) -> String { let raw = &found[2]; let pages: Vec = PAGE_ANCHOR .captures_iter(raw) - .filter_map(Result::ok) .map(|anchor| textkit::decimal(&anchor[1])) .collect(); let cleaned = clean(&PAGE_ANCHOR.replace_all(raw, "")); @@ -96,7 +104,7 @@ pub fn clean_all(markdown: &str) -> String { /// rather than a piece of its text. pub fn anchors_to_markers(markdown: &str) -> String { PAGE_ANCHOR - .replace_all(markdown, |found: &fancy_regex::Captures| { + .replace_all(markdown, |found: ®ex::Captures| { format!("\n\n\n\n", &found[1]) }) .into_owned() @@ -106,6 +114,22 @@ pub fn anchors_to_markers(markdown: &str) -> String { mod tests { use super::*; + #[test] + fn a_tag_whose_id_is_not_a_page_finishes() { + // What a Kindle conversion leaves behind, forty-four times in one + // real book. On a backtracking engine the two `[^"']` runs and the + // two `[^>]` runs try every split between them before giving up, and + // on a megabyte of it that never returns. + let junk = "text\n\n".repeat(400); + let markdown = format!("# Heading\n\n{junk}\n"); + let out = anchors_to_markers(&markdown); + assert!( + out.contains(""), + "the real anchor is still found" + ); + assert!(out.contains("filepos1024631"), "the others are left alone"); + } + #[test] fn a_link_wrapper_comes_off() { assert_eq!(clean("[I. The Setting](#ch1)"), "I. The Setting");