Skip to main content

html_auto_p/
lib.rs

1/*!
2# HTML auto_p
3
4This library provides a function like `wpautop` in Wordpress. It uses a group of regex replaces used to identify text formatted with newlines and replace double line-breaks with HTML paragraph tags.
5
6Someone who familiars with HTML would prefer directly writing plain HTML instead of using an editor like TinyMCE or Gutenberg. However, it takes time to manually add newlines and paragraphs in HTML. Wordpress provides a handy function called `wpautop` which can replace double line-breaks with paragraph elements (`<p>`) and convert remaining line-breaks to `<br>` elements.
7
8The `auto_p` function in this library can be used like `wpautop`.
9
10```rust
11use html_auto_p::*;
12
13assert_eq!("<p>Hello world!</p>", auto_p("Hello world!", Options::new()));
14assert_eq!("<p>Line 1<br>\nLine 2</p>", auto_p("Line 1\nLine 2", Options::new().br(true)));
15assert_eq!("<p>Line 1<br>\nLine 2</p>", auto_p("Line 1<br>\nLine 2", Options::new().br(true)));
16assert_eq!("<p>Paragraph 1</p>\n<p>Paragraph 2</p>", auto_p("Paragraph 1\n\nParagraph 2", Options::new()));
17assert_eq!("<pre>Line 1<br>\nLine 2</pre>", auto_p("<pre>Line 1<br>\nLine 2</pre>", Options::new().br(true)));
18assert_eq!("<pre>Line 1&lt;br&gt;\nLine 2</pre>", auto_p("<pre>Line 1<br>\nLine 2</pre>", Options::new().br(true).esc_pre(true)));
19assert_eq!("<pre>Line 1\nLine 2</pre>", auto_p("<pre>\nLine 1\nLine 2\n</pre>", Options::new().remove_useless_newlines_in_pre(true)));
20```
21
22## Onig Support (alternative, unstable)
23
24To use the [`onig`](https://crates.io/crates/onig) crate, enable the `onig` feature.
25
26```toml
27[dependencies.html-auto-p]
28version = "*"
29features = ["onig"]
30```
31*/
32
33#[cfg(feature = "onig")]
34extern crate onig as regex;
35
36mod options;
37
38#[cfg(not(feature = "onig"))]
39use std::borrow::Cow;
40use std::{fmt::Write, str::from_utf8_unchecked, sync::LazyLock};
41
42pub use options::*;
43use regex::Regex;
44use trim_in_place::TrimInPlace;
45
46macro_rules! all_blocks_tag_names_except_p {
47    () => {
48        "table|thead|tfoot|caption|col|colgroup|tbody|tr|td|th|div|dl|dd|dt|ul|ol|li|pre|form|map|area|blockquote|address|math|h[1-6]|hr|fieldset|legend|section|article|aside|hgroup|header|footer|nav|figure|figcaption|details|menu|summary"
49    }
50}
51
52macro_rules! all_blocks_tag_names {
53    () => {
54        concat!(all_blocks_tag_names_except_p!(), "|p")
55    };
56}
57
58macro_rules! all_preserved_tag_names {
59    () => {
60        "textarea|script|style|svg"
61    };
62}
63
64macro_rules! all_block_and_preserved_tag_names {
65    () => {
66        concat!(all_blocks_tag_names!(), "|", all_preserved_tag_names!())
67    };
68}
69
70macro_rules! pattern_all_blocks_except_p {
71    () => {
72        concat!("(?i:", all_blocks_tag_names_except_p!(), ")")
73    };
74}
75
76macro_rules! pattern_all_blocks {
77    () => {
78        concat!("(?i:", all_blocks_tag_names!(), ")")
79    };
80}
81
82macro_rules! pattern_all_block_and_preserved_tag_names {
83    () => {
84        concat!("(?i:", all_block_and_preserved_tag_names!(), ")")
85    };
86}
87
88macro_rules! pattern_attributes {
89    () => {
90        "(?:\\s+[^<>\\s=]+(?:=(?:|(?:[^'\"])|(?:[^'\"][^\\s<>]*[^'\"])|(?:\"[^\"]*\")|(?:'[^']*'\
91         )))?)*\\s*"
92    };
93}
94
95static RE_PRE_ELEMENT: LazyLock<Regex> = LazyLock::new(|| {
96    Regex::new(concat!("(?i)", "(<pre", pattern_attributes!(), r">)([\s\S]*?)(</pre\s*>)")).unwrap()
97});
98static RE_TEXTAREA_ELEMENT: LazyLock<Regex> = LazyLock::new(|| {
99    Regex::new(concat!(
100        "(?i)",
101        "(<textarea",
102        pattern_attributes!(),
103        r">)([\s\S]*?)(</textarea\s*>)"
104    ))
105    .unwrap()
106});
107static RE_SCRIPT_ELEMENT: LazyLock<Regex> = LazyLock::new(|| {
108    Regex::new(concat!("(?i)", "(<script", pattern_attributes!(), r">)([\s\S]*?)(</script\s*>)"))
109        .unwrap()
110});
111static RE_STYLE_ELEMENT: LazyLock<Regex> = LazyLock::new(|| {
112    Regex::new(concat!("(?i)", "(<style", pattern_attributes!(), r">)([\s\S]*?)(</style\s*>)"))
113        .unwrap()
114});
115static RE_SVG_ELEMENT: LazyLock<Regex> = LazyLock::new(|| {
116    Regex::new(concat!("(?i)", "(<svg", pattern_attributes!(), r">)([\s\S]*?)(</svg\s*>)")).unwrap()
117});
118static RE_BR_ELEMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<br\s*/?>").unwrap());
119
120static RE_TAG: LazyLock<Regex> =
121    LazyLock::new(|| Regex::new(concat!(r"</?[^\s<]+(", pattern_attributes!(), r")/?>")).unwrap());
122
123static RE_OTHER_NEWLINE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?:\r\n|\r)").unwrap());
124#[allow(clippy::trivial_regex)]
125static RE_EMPTY_PARAGRAPH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<p></p>").unwrap());
126
127static RE_P_END_TAG_MISSING_START: LazyLock<Regex> = LazyLock::new(|| {
128    Regex::new(concat!(
129        "(?i)",
130        r"(<",
131        pattern_all_blocks_except_p!(),
132        pattern_attributes!(),
133        r">)(\s*)([^<]+)</p>"
134    ))
135    .unwrap()
136});
137static RE_P_START_TAG_MISSING_END: LazyLock<Regex> = LazyLock::new(|| {
138    Regex::new(concat!("(?i)", r"<p>([^<]+)(\s*)(</", pattern_all_blocks_except_p!(), r"\s*>)"))
139        .unwrap()
140});
141
142static RE_LI_IN_PARAGRAPH: LazyLock<Regex> = LazyLock::new(|| {
143    Regex::new(concat!("(?i)", r"<p>(<li", pattern_attributes!(), r">[\s\S]*)</p>")).unwrap()
144});
145
146static RE_BLOCK_AND_PRESERVED_TAG_AFTER_P_START_TAG: LazyLock<Regex> = LazyLock::new(|| {
147    Regex::new(concat!(
148        "(?i)",
149        r"<p>(</?",
150        pattern_all_block_and_preserved_tag_names!(),
151        pattern_attributes!(),
152        r">)"
153    ))
154    .unwrap()
155});
156static RE_BLOCK_AND_PRESERVED_TAG_BEFORE_P_END_TAG: LazyLock<Regex> = LazyLock::new(|| {
157    Regex::new(concat!(
158        "(?i)",
159        r"(</?",
160        pattern_all_block_and_preserved_tag_names!(),
161        pattern_attributes!(),
162        r">)</p>"
163    ))
164    .unwrap()
165});
166
167static RE_BR_ELEMENT_AFTER_BLOCK_TAG: LazyLock<Regex> = LazyLock::new(|| {
168    Regex::new(concat!("(?i)", r"(</?", pattern_all_blocks!(), pattern_attributes!(), r">)<br>\n"))
169        .unwrap()
170});
171static RE_BR_ELEMENT_BEFORE_BLOCK_TAG: LazyLock<Regex> = LazyLock::new(|| {
172    Regex::new(concat!("(?i)", r"<br>\n(</?", pattern_all_blocks!(), pattern_attributes!(), r">)"))
173        .unwrap()
174});
175
176/// A group of regex replaces used to identify text formatted with newlines and replace double line-breaks with HTML paragraph tags.
177///
178/// The original algorithm can be found in [wp-includes/formatting.php](https://github.com/WordPress/WordPress/blob/101d00601e8d00041218e31194c6f5e0dc4940aa/wp-includes/formatting.php#L442)
179///
180/// This function does not 100% work like `wpautop` does.
181pub fn auto_p<S: Into<String>>(pee: S, options: Options) -> String {
182    let mut pee = pee.into();
183
184    pee.trim_in_place();
185
186    if pee.is_empty() {
187        return pee;
188    }
189
190    let mut pre_inner_html_buffer: Vec<(String, usize, usize)> = Vec::new();
191    let mut script_inner_html_buffer: Vec<(String, usize, usize)> = Vec::new();
192    let mut style_inner_html_buffer: Vec<(String, usize, usize)> = Vec::new();
193    let mut textarea_inner_html_buffer: Vec<(String, usize, usize)> = Vec::new();
194    let mut svg_inner_html_buffer: Vec<(String, usize, usize)> = Vec::new();
195
196    // The inner HTML in `<pre>`, `<textarea>`, `<script>`, `<style>` and `<svg>` elements should not get `auto_p`ed, so temporarily copy it out, and fill the inner HTML with `'0'`
197    {
198        fn reserve(pee: &mut String, regex: &Regex, buffer: &mut Vec<(String, usize, usize)>) {
199            for captures in regex.captures_iter(pee) {
200                let (s, start, end) = get(&captures, 2);
201
202                buffer.push((String::from(s), start, end));
203            }
204
205            let bytes = unsafe { pee.as_mut_vec() };
206
207            for (_, start, end) in buffer.iter() {
208                for e in bytes[*start..*end].iter_mut() {
209                    *e = b'0';
210                }
211            }
212        }
213
214        reserve(&mut pee, &RE_PRE_ELEMENT, &mut pre_inner_html_buffer);
215        reserve(&mut pee, &RE_TEXTAREA_ELEMENT, &mut textarea_inner_html_buffer);
216        reserve(&mut pee, &RE_SCRIPT_ELEMENT, &mut script_inner_html_buffer);
217        reserve(&mut pee, &RE_STYLE_ELEMENT, &mut style_inner_html_buffer);
218        reserve(&mut pee, &RE_SVG_ELEMENT, &mut svg_inner_html_buffer);
219    }
220
221    // Standardize newline characters to `"\n"`.
222    let mut pee = replace_all(&RE_OTHER_NEWLINE, pee, "\n");
223
224    // Find newlines in all tags and replace them to `'\r'`s.
225    {
226        let mut newlines_in_tags: Vec<usize> = Vec::new();
227
228        for captures in RE_TAG.captures_iter(&pee) {
229            let (s, start, _) = get(&captures, 1);
230
231            for (i, e) in s.bytes().enumerate() {
232                if e == b'\n' {
233                    newlines_in_tags.push(i + start);
234                }
235            }
236        }
237
238        let bytes = unsafe { pee.as_mut_vec() };
239
240        for newline_index in newlines_in_tags {
241            bytes[newline_index] = b'\r';
242        }
243    }
244
245    // Split up the contents into an array of strings, separated by at-least-two line breaks.
246    let pees = pee.split("\n\n");
247
248    // Reset `pee` prior to rebuilding.
249    let separator_count = pee.matches("\n\n").count();
250    let chunk_count = separator_count + 1;
251    let mut pee = String::with_capacity(pee.len() - separator_count * 2 + chunk_count * 8);
252
253    // Rebuild the content as a string, wrapping every bit with a `<p>`.
254    for tinkle in pees {
255        pee.write_fmt(format_args!("<p>{}</p>\n", tinkle.trim())).unwrap();
256    }
257
258    // Remove empty paragraphs.
259    let mut pee = replace_all(&RE_EMPTY_PARAGRAPH, pee, "");
260
261    pee.trim_matches_in_place('\n');
262
263    // Add a starting `<p>` inside a block element if missing.
264    let pee = replace_all(&RE_P_END_TAG_MISSING_START, pee, "$1$2<p>$3</p>");
265
266    // Add a closing `<p>` inside a block element if missing.
267    let pee = replace_all(&RE_P_START_TAG_MISSING_END, pee, "<p>$1</p>$2$3");
268
269    // In some cases `<li>` may get wrapped in `<p>`, fix them.
270    let pee = replace_all(&RE_LI_IN_PARAGRAPH, pee, "$1");
271
272    // If an opening or closing block element tag is preceded by an opening `<p>` tag, remove the `<p>` tag.
273    let pee = replace_all(&RE_BLOCK_AND_PRESERVED_TAG_AFTER_P_START_TAG, pee, "$1");
274
275    // If an opening or closing block element tag is followed by a closing `</p>` tag, remove the `</p>` tag.
276    let pee = replace_all(&RE_BLOCK_AND_PRESERVED_TAG_BEFORE_P_END_TAG, pee, "$1");
277
278    // Optionally insert line breaks.
279    #[allow(clippy::let_and_return)]
280    let mut pee = if options.br {
281        // Normalize `<br>`
282        let mut pee = replace_all(&RE_BR_ELEMENT, pee, "<br>");
283
284        // Replace any new line characters that aren't preceded by a `<br>` with a `<br>`.
285        let mut v = Vec::new();
286
287        {
288            let bytes = pee.as_bytes();
289
290            let mut p = bytes.len();
291
292            loop {
293                if p == 0 {
294                    break;
295                }
296
297                p -= 1;
298
299                let e = bytes[p];
300
301                if e == b'\n' {
302                    let mut pp = p;
303
304                    loop {
305                        if pp == 0 {
306                            break;
307                        }
308
309                        pp -= 1;
310
311                        let e = bytes[pp];
312
313                        if !e.is_ascii_whitespace() {
314                            break;
315                        }
316                    }
317
318                    if pp < 3 || &bytes[(pp - 3)..=pp] != b"<br>" {
319                        v.push((pp + 1)..p);
320                    }
321
322                    p = pp;
323                }
324            }
325        }
326
327        for range in v.into_iter() {
328            pee.replace_range(range, "<br>");
329        }
330
331        // If a `<br>` tag is after an opening or closing block tag, remove it.
332        let pee = replace_all(&RE_BR_ELEMENT_AFTER_BLOCK_TAG, pee, "$1\n");
333
334        // If a `<br>` tag is before an opening or closing block tags, remove it.
335        let pee = replace_all(&RE_BR_ELEMENT_BEFORE_BLOCK_TAG, pee, "\n$1");
336
337        pee
338    } else {
339        pee
340    };
341
342    // Recover the inner HTML that have been filled with `'0'` before.
343    {
344        fn recover(pee: &mut String, regex: &Regex, buffer: &[(String, usize, usize)]) {
345            let mut v = Vec::with_capacity(buffer.len());
346
347            for (captures, inner_html) in regex.captures_iter(pee).zip(buffer.iter()) {
348                let (_, start, end) = get(&captures, 2);
349
350                v.push((start..end, inner_html.0.as_str()));
351            }
352
353            for (range, inner_html) in v.into_iter().rev() {
354                pee.replace_range(range, inner_html);
355            }
356        }
357
358        recover(&mut pee, &RE_SVG_ELEMENT, &svg_inner_html_buffer);
359        recover(&mut pee, &RE_STYLE_ELEMENT, &style_inner_html_buffer);
360        recover(&mut pee, &RE_SCRIPT_ELEMENT, &script_inner_html_buffer);
361        recover(&mut pee, &RE_TEXTAREA_ELEMENT, &textarea_inner_html_buffer);
362
363        if options.esc_pre || options.remove_useless_newlines_in_pre {
364            let mut v = Vec::with_capacity(pre_inner_html_buffer.len());
365
366            for (captures, inner_html) in
367                RE_PRE_ELEMENT.captures_iter(pee.as_str()).zip(pre_inner_html_buffer.iter())
368            {
369                let (_, start, end) = get(&captures, 2);
370
371                v.push((start..end, inner_html.0.as_str()));
372            }
373
374            if options.esc_pre {
375                if options.remove_useless_newlines_in_pre {
376                    for (range, inner_html) in v.into_iter().rev() {
377                        pee.replace_range(
378                            range,
379                            html_escape::encode_safe(trim_newline_exactly_one(inner_html)).as_ref(),
380                        );
381                    }
382                } else {
383                    for (range, inner_html) in v.into_iter().rev() {
384                        pee.replace_range(range, html_escape::encode_safe(inner_html).as_ref());
385                    }
386                }
387            } else if options.remove_useless_newlines_in_pre {
388                for (range, inner_html) in v.into_iter().rev() {
389                    pee.replace_range(range, trim_newline_exactly_one(inner_html));
390                }
391            } else {
392                for (range, inner_html) in v.into_iter().rev() {
393                    pee.replace_range(range, inner_html);
394                }
395            }
396        } else {
397            recover(&mut pee, &RE_PRE_ELEMENT, &pre_inner_html_buffer);
398        }
399    }
400
401    // Recover the newlines in tags that have been replaced with `'\r'` before.
402    {
403        let bytes = unsafe { pee.as_mut_vec() };
404
405        for e in bytes {
406            if *e == b'\r' {
407                *e = b'\n';
408            }
409        }
410    }
411
412    pee
413}
414
415fn trim_newline_exactly_one<S: ?Sized + AsRef<str>>(s: &S) -> &str {
416    let s = s.as_ref();
417    let bytes = s.as_bytes();
418    let length = bytes.len();
419
420    if length == 0 {
421        return "";
422    }
423
424    // from the start
425    let bytes = match bytes[0] {
426        b'\n' => {
427            if length == 1 {
428                return "";
429            } else if bytes[1] != b'\n' && bytes[1] != b'\r' {
430                &bytes[1..]
431            } else {
432                bytes
433            }
434        },
435        b'\r' => {
436            if length == 1 {
437                return "";
438            } else if bytes[1] == b'\n' {
439                if length == 2 {
440                    return "";
441                } else if bytes[2] != b'\n' && bytes[2] != b'\r' {
442                    &bytes[2..]
443                } else {
444                    bytes
445                }
446            } else if bytes[1] != b'\r' {
447                &bytes[1..]
448            } else {
449                bytes
450            }
451        },
452        _ => bytes,
453    };
454
455    let length = bytes.len();
456
457    // from the end
458    let bytes = match bytes[length - 1] {
459        b'\n' => {
460            if length == 1 {
461                return "";
462            } else if bytes[length - 2] != b'\n' && bytes[length - 2] != b'\r' {
463                &bytes[..(length - 1)]
464            } else {
465                bytes
466            }
467        },
468        b'\r' => {
469            if length == 1 {
470                return "";
471            } else if bytes[length - 2] == b'\n' {
472                if length == 2 {
473                    return "";
474                } else if bytes[length - 3] != b'\n' && bytes[length - 3] != b'\r' {
475                    &bytes[..(length - 2)]
476                } else {
477                    bytes
478                }
479            } else if bytes[length - 2] != b'\r' {
480                &bytes[..(length - 1)]
481            } else {
482                bytes
483            }
484        },
485        _ => bytes,
486    };
487
488    unsafe { from_utf8_unchecked(bytes) }
489}
490
491#[cfg(feature = "onig")]
492#[inline]
493fn replace_all(regex: &Regex, pee: String, rep: &str) -> String {
494    regex.replace_all(pee.as_str(), |caps: &regex::Captures| {
495        let mut s = String::with_capacity(rep.len());
496
497        let mut chars = rep.chars();
498
499        while let Some(c) = chars.next() {
500            if c == '$' {
501                let index = (chars.next().unwrap() as u8 - b'0') as usize;
502
503                s.push_str(caps.at(index).unwrap());
504            } else {
505                s.push(c);
506            }
507        }
508
509        s
510    })
511}
512
513#[cfg(not(feature = "onig"))]
514#[inline]
515fn replace_all(regex: &Regex, pee: String, rep: &str) -> String {
516    match regex.replace_all(pee.as_str(), rep) {
517        Cow::Owned(pee) => pee,
518        Cow::Borrowed(_) => pee,
519    }
520}
521
522#[cfg(feature = "onig")]
523#[inline]
524fn get<'a>(captures: &regex::Captures<'a>, index: usize) -> (&'a str, usize, usize) {
525    let (start, end) = captures.pos(index).unwrap();
526
527    (captures.at(index).unwrap(), start, end)
528}
529
530#[cfg(not(feature = "onig"))]
531#[inline]
532fn get<'a>(captures: &regex::Captures<'a>, index: usize) -> (&'a str, usize, usize) {
533    let captures = captures.get(index).unwrap();
534
535    (captures.as_str(), captures.start(), captures.end())
536}