Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_text/src/doc/html/write.rs

21.0 KiB, 75 runs

created by r1870400018:14311, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Rendering a document tree to HTML.
2//!
3//! The counterpart to a front-end: where [`markdown`](crate::doc::markdown) reads a syntax into the tree,
4//! this walks the tree out to the form a browser reads. It is the first of the outputs the tree's own
5//! documentation names, and it makes the tree's neutrality testable rather than merely asserted --
6//! `Rule`, an image by its path and a code span within a line all reach HTML intact, and each is
7//! something the signed-document consumer gives up.
8//!
9//! # A fragment, not a page
10//!
11//! What comes back is the document's blocks and nothing around them: no `<html>`, no `<head>`, no
12//! stylesheet. A caller owns the page and drops this into it, because the furniture around a document
13//! belongs to the site rather than to the prose.
14//!
15//! # Escaping
16//!
17//! Every run of text and every attribute value is escaped on the way out, so a document that says
18//! `<script>` says it rather than does it. That is the whole of the safety here: **a link's
19//! destination is written out as given**, and a tree carrying a `javascript:` destination will render
20//! one. That is right for prose whose author is trusted, which is what this renders today. Untrusted
21//! prose -- a comment, say -- wants a sanitiser between the front-end and here, which is a separate
22//! thing and does not exist yet.
23//!
24//! # Depth
25//!
26//! The walk is recursive, on the same terms as [`text_of`](crate::doc::text_of): a tree's depth is
27//! bounded by whatever built it, and each reader bounds its own. A tree assembled by hand deeper than
28//! a stack can walk will overflow this, exactly as it would overflow `text_of` or `Drop`.
29
30use crate::doc::{
31 Align,
32 Attrs,
33 Block,
34 Cell,
35 Doc,
36 Inline,
37 Row,
38};
39
40
41/// Renders a document to an HTML fragment.
42pub fn render(doc: &Doc) -> String {
43 render_with(doc, &Opts::default())
44}
45
46/// What a caller may vary about the rendering.
47#[derive(Clone, Debug, Default)]
48pub struct Opts {
49 /// The `rel` every link carries, where a caller wants one.
50 ///
51 /// Prose by a trusted author wants none: a link an author chose is a link the site vouches for.
52 /// Prose by a stranger wants `nofollow ugc noopener` -- a published comment otherwise lends the
53 /// site's standing to whatever it points at, which is the entire economic motive for comment spam.
54 pub link_rel: Option<String>,
55}
56
57/// Renders a document, varying what [`Opts`] allows.
58pub fn render_with(doc: &Doc, opts: &Opts) -> String {
59 let mut out = String::new();
60 blocks_with(&mut out, &doc.blocks, opts);
61 out
62}
63
64/// Writes an opening tag.
65///
66/// Tags are pushed rather than formatted because this crate's own [`fmt`](crate::fmt) module shadows
67/// the `fmt!` macro, and because a tag is a handful of bytes that needs no allocation to say.
68fn open(out: &mut String, tag: &str) {
69 out.push('<');
70 out.push_str(tag);
71 out.push('>');
72}
73
74/// Writes a closing tag.
75fn close(out: &mut String, tag: &str) {
76 out.push_str("</");
77 out.push_str(tag);
78 out.push('>');
79}
80
81/// Renders a run of blocks, each on its own line.
82fn blocks_with(out: &mut String, items: &[Block], opts: &Opts) {
83 for item in items {
84 block(out, item, opts);
85 out.push('\n');
86 }
87}
88
89/// Renders one block.
90fn block(out: &mut String, item: &Block, opts: &Opts) {
91 match item {
92 Block::Heading { level, content } => {
93 // The tree says 1 to 6 and the Markdown reader gives no other, but the type admits one, and
94 // a heading is not worth a panic. Anything outside the range renders at the nearest end.
95 let tag = match (*level).clamp(1, 6) {
96 1 => "h1",
97 2 => "h2",
98 3 => "h3",
99 4 => "h4",
100 5 => "h5",
101 _ => "h6",
102 };
103 open(out, tag);
104 inlines(out, content, opts);
105 close(out, tag);
106 }
107 Block::Para(content) => {
108 open(out, "p");
109 inlines(out, content, opts);
110 close(out, "p");
111 }
112 Block::List { ordered, items } => {
113 let tag = if *ordered { "ol" } else { "ul" };
114 open(out, tag);
115 out.push('\n');
116 for item in items {
117 out.push_str("<li>");
118 // The tree carries no tight/loose distinction -- that is presentation, and the tree holds
119 // meaning -- so it is inferred here: an item that is one paragraph and nothing else is
120 // what a reader wrote as a plain item, and wrapping it in a `<p>` would space a shopping
121 // list out like an essay. An item holding anything more is rendered as the blocks it is.
122 match item.as_slice() {
123 [Block::Para(content)] => inlines(out, content, opts),
124 _ => {
125 out.push('\n');
126 blocks_with(out, item, opts);
127 }
128 }
129 out.push_str("</li>\n");
130 }
131 close(out, tag);
132 }
133 Block::Code { lang, text } => {
134 match lang {
135 Some(lang) => {
136 // `language-x` is the convention every highlighter reads, and costs nothing to emit.
137 out.push_str("<pre><code class=\"language-");
138 escape_attr(out, lang);
139 out.push_str("\">");
140 }
141 None => out.push_str("<pre><code>"),
142 }
143 escape_text(out, text);
144 out.push_str("</code></pre>");
145 }
146 Block::Quote(inner) => {
147 out.push_str("<blockquote>\n");
148 blocks_with(out, inner, opts);
149 out.push_str("</blockquote>");
150 }
151 Block::Table { head, rows, cols } => {
152 out.push_str("<table>\n");
153 if let Some(head) = head {
154 out.push_str("<thead>\n");
155 row(out, head, cols, "th", opts);
156 out.push_str("</thead>\n");
157 }
158 out.push_str("<tbody>\n");
159 for r in rows {
160 row(out, r, cols, "td", opts);
161 }
162 out.push_str("</tbody>\n</table>");
163 }
164 Block::Rule => out.push_str("<hr>"),
165 Block::Div { attrs, content } => {
166 out.push_str("<div");
167 write_attrs(out, attrs);
168 out.push_str(">\n");
169 blocks_with(out, content, opts);
170 out.push_str("</div>");
171 }
172 }
173}
174
175/// Writes an element's attributes: an id, a class list, and each key-value pair.
176///
177/// The tree carries names and nothing else, so this writes names and nothing else: what `warning`
178/// looks like is the stylesheet's business, and a class attribute is exactly the handle a stylesheet
179/// reaches it by. A pair `k=v` becomes the attribute `k="v"`; the tree does not police which keys a
180/// document may set, on the same principle that it does not police what a class means.
181fn write_attrs(out: &mut String, attrs: &Attrs) {
182 if let Some(id) = &attrs.id {
183 out.push_str(" id=\"");
184 escape_attr(out, id);
185 out.push('"');
186 }
187 if !attrs.classes.is_empty() {
188 out.push_str(" class=\"");
189 for (i, c) in attrs.classes.iter().enumerate() {
190 if i > 0 {
191 out.push(' ');
192 }
193 escape_attr(out, c);
194 }
195 out.push('"');
196 }
197 for (k, v) in &attrs.pairs {
198 out.push(' ');
199 escape_attr(out, k);
200 out.push_str("=\"");
201 escape_attr(out, v);
202 out.push('"');
203 }
204}
205
206/// Renders one row of a table, its cells tagged `th` or `td` and aligned by column.
207fn row(out: &mut String, r: &Row, cols: &[Align], tag: &str, opts: &Opts) {
208 out.push_str("<tr>");
209 for (i, cell) in r.0.iter().enumerate() {
210 out.push('<');
211 out.push_str(tag);
212 // A row may run wider than the columns the table declared; a cell past the end is aligned by
213 // nothing, which is the same as a column that named no alignment.
214 align(out, cols.get(i).copied().unwrap_or_default());
215 out.push('>');
216 cells(out, cell, opts);
217 close(out, tag);
218 }
219 out.push_str("</tr>\n");
220}
221
222/// Writes a cell's alignment, where it has one.
223///
224/// `start` and `end` are CSS's *logical* values, and are used here rather than `left` and `right` for
225/// the same reason [`Align`] names its sides that way: the side text begins on depends on which way the
226/// text runs, and only the thing laying it out knows. CSS resolves them against the element's own
227/// direction, so a right-to-left table aligns to the right where a left-to-right one aligns to the
228/// left, from one rendering. Anyone tempted to "fix" these to `left`/`right` should read [`Align`]
229/// first.
230///
231/// Note the spelling: the variant is `Centre` and the CSS keyword is `center`. The keyword is not ours
232/// to spell.
233fn align(out: &mut String, a: Align) {
234 let val = match a {
235 Align::None => return,
236 Align::Start => "start",
237 Align::Centre => "center",
238 Align::End => "end",
239 };
240 out.push_str(" style=\"text-align: ");
241 out.push_str(val);
242 out.push('"');
243}
244
245/// Renders a cell's inline content.
246fn cells(out: &mut String, cell: &Cell, opts: &Opts) {
247 inlines(out, &cell.0, opts);
248}
249
250/// Renders a run of inlines.
251fn inlines(out: &mut String, content: &[Inline], opts: &Opts) {
252 for item in content {
253 inline(out, item, opts);
254 }
255}
256
257/// Renders one inline.
258fn inline(out: &mut String, item: &Inline, opts: &Opts) {
259 match item {
260 Inline::Text(t) => escape_text(out, t),
261 Inline::Emph { strong, content } => {
262 let tag = if *strong { "strong" } else { "em" };
263 open(out, tag);
264 inlines(out, content, opts);
265 close(out, tag);
266 }
267 Inline::Link { to, content } => {
268 out.push_str("<a href=\"");
269 escape_attr(out, to);
270 if let Some(rel) = &opts.link_rel {
271 out.push_str("\" rel=\"");
272 escape_attr(out, rel);
273 }
274 out.push_str("\">");
275 inlines(out, content, opts);
276 out.push_str("</a>");
277 }
278 Inline::Image { src, alt } => {
279 out.push_str("<img src=\"");
280 escape_attr(out, src);
281 out.push_str("\" alt=\"");
282 escape_attr(out, alt);
283 out.push_str("\">");
284 }
285 Inline::Span { attrs, content } => {
286 out.push_str("<span");
287 write_attrs(out, attrs);
288 out.push('>');
289 inlines(out, content, opts);
290 out.push_str("</span>");
291 }
292 Inline::Code(c) => {
293 out.push_str("<code>");
294 escape_text(out, c);
295 out.push_str("</code>");
296 }
297 // Only ever a hard break, so it is honoured unconditionally and no whitespace rule is needed
298 // here. A wrap in the author's editor never reaches the tree as one of these.
299 Inline::Break => out.push_str("<br>"),
300 }
301}
302
303/// Escapes a run of text for an element's content.
304///
305/// Public because a caller that wraps a rendered document in a page of its own -- a title, a meta
306/// description, a feed entry -- has the same text to make safe, and the rule should be stated once.
307pub fn escape_text(out: &mut String, s: &str) {
308 for c in s.chars() {
309 match c {
310 '&' => out.push_str("&amp;"),
311 '<' => out.push_str("&lt;"),
312 '>' => out.push_str("&gt;"),
313 _ => out.push(c),
314 }
315 }
316}
317
318/// Escapes a string for a quoted attribute value.
319///
320/// Both quote marks are escaped, not only the double the renderer happens to use, so a value stays
321/// inert if it is ever moved into single quotes.
322///
323/// Public for the same reason as [`escape_text`]: a page built around a document puts its title into
324/// an attribute, and that title came from the prose.
325pub fn escape_attr(out: &mut String, s: &str) {
326 for c in s.chars() {
327 match c {
328 '&' => out.push_str("&amp;"),
329 '<' => out.push_str("&lt;"),
330 '>' => out.push_str("&gt;"),
331 '"' => out.push_str("&quot;"),
332 '\'' => out.push_str("&#39;"),
333 _ => out.push(c),
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 use crate::doc::{
343 djot,
344 markdown,
345 };
346
347 use oxedyne_fe2o3_core::prelude::*;
348
349 fn text(s: &str) -> Vec<Inline> {
350 vec![Inline::Text(s.to_string())]
351 }
352
353 /// Djot reaches HTML through the same tree, and the constructs that justify it -- a named box, a
354 /// styled span -- arrive intact. This is the end-to-end check the reader's own tree-level tests
355 /// cannot make: it is the whole path a published post takes.
356 fn djot_html(src: &str) -> Outcome<String> {
357 Ok(render(&res!(djot::parse(src))))
358 }
359
360 #[test]
361 fn test_djot_reaches_html_intact_20() -> Outcome<()> {
362 // Djot swaps the markers against Markdown: `_` is emphasis, `*` is strong. If these ever come
363 // out reversed, this is where it shows.
364 let em = res!(djot_html("_soft_ and *loud*\n"));
365 assert!(em.contains("<em>soft</em>"), "underscore is emphasis: {}", em);
366 assert!(em.contains("<strong>loud</strong>"), "asterisk is strong: {}", em);
367
368 // The headline features: a div names a box, a span names a style.
369 let div = res!(djot_html("::: warning\nMind the gap.\n:::\n"));
370 assert!(div.contains("<div class=\"warning\">"), "the div lost its class: {}", div);
371 let span = res!(djot_html("A [bright]{.hl} word.\n"));
372 assert!(span.contains("<span class=\"hl\">bright</span>"), "the span lost its class: {}", span);
373
374 // Full attributes: an id, classes, a pair.
375 let attrs = res!(djot_html("[x]{#a .b .c k=v}\n"));
376 assert!(attrs.contains("id=\"a\""), "no id: {}", attrs);
377 assert!(attrs.contains("class=\"b c\""), "no classes: {}", attrs);
378 assert!(attrs.contains("k=\"v\""), "no pair: {}", attrs);
379
380 // A soft break is a space, never a break -- the same lesson the Markdown reader learned.
381 let soft = res!(djot_html("one\ntwo\n"));
382 assert!(soft.contains("one two"), "a soft break was not a space: {}", soft);
383 assert!(!soft.contains("<br>"), "a soft break became a hard one: {}", soft);
384
385 // Deferred syntax is literal text, not a crash and not a half-parse.
386 let deferred = res!(djot_html("cost $5 and :smile: and H~2~O\n"));
387 assert!(deferred.contains("$5"), "math ate a dollar sign: {}", deferred);
388 assert!(deferred.contains(":smile:"), "a symbol was consumed: {}", deferred);
389 Ok(())
390 }
391
392 /// A named division renders as a div whose attributes are the names the prose gave it.
393 #[test]
394 fn test_a_div_renders_its_attributes_11() -> Outcome<()> {
395 let doc = Doc {
396 blocks: vec![Block::Div {
397 attrs: Attrs {
398 id: Some("intro".to_string()),
399 classes: vec!["warning".to_string(), "boxed".to_string()],
400 pairs: vec![("data-k".to_string(), "v".to_string())],
401 },
402 content: vec![Block::Para(text("Careful."))],
403 }],
404 };
405 assert_eq!(
406 render(&doc),
407 "<div id=\"intro\" class=\"warning boxed\" data-k=\"v\">\n<p>Careful.</p>\n</div>\n",
408 );
409 Ok(())
410 }
411
412 /// An attributed span renders as a span carrying the same names, inline.
413 #[test]
414 fn test_a_span_renders_its_attributes_12() -> Outcome<()> {
415 let doc = Doc {
416 blocks: vec![Block::Para(vec![
417 Inline::Text("a ".to_string()),
418 Inline::Span {
419 attrs: Attrs { classes: vec!["hl".to_string()], ..Default::default() },
420 content: text("bright"),
421 },
422 Inline::Text(" word".to_string()),
423 ])],
424 };
425 assert_eq!(
426 render(&doc),
427 "<p>a <span class=\"hl\">bright</span> word</p>\n",
428 );
429 Ok(())
430 }
431
432 /// A name in an attribute value cannot break out of the attribute it sits in.
433 #[test]
434 fn test_an_attribute_value_is_escaped_13() -> Outcome<()> {
435 let doc = Doc {
436 blocks: vec![Block::Div {
437 attrs: Attrs {
438 classes: vec!["a\" onclick=\"x".to_string()],
439 ..Default::default()
440 },
441 content: vec![Block::Para(text("hi"))],
442 }],
443 };
444 let out = render(&doc);
445 assert!(!out.contains("onclick=\"x\""), "an attribute value broke out: {}", out);
446 assert!(out.contains("&quot;"), "the quote was not escaped: {}", out);
447 Ok(())
448 }
449
450 /// Text that would otherwise close a tag or open one is written out as what it says.
451 #[test]
452 fn test_text_is_escaped_00() -> Outcome<()> {
453 let doc = Doc { blocks: vec![Block::Para(text("Tom & Jerry <3 </p><script>"))] };
454 assert_eq!(
455 render(&doc),
456 "<p>Tom &amp; Jerry &lt;3 &lt;/p&gt;&lt;script&gt;</p>\n",
457 );
458 Ok(())
459 }
460
461 /// A quote mark in a destination cannot break out of the attribute it sits in.
462 #[test]
463 fn test_attributes_are_escaped_01() -> Outcome<()> {
464 let doc = Doc {
465 blocks: vec![Block::Para(vec![Inline::Link {
466 to: "a\" onclick=\"steal()".to_string(),
467 content: text("here"),
468 }])],
469 };
470 assert_eq!(
471 render(&doc),
472 "<p><a href=\"a&quot; onclick=&quot;steal()\">here</a></p>\n",
473 );
474 Ok(())
475 }
476
477 /// An alignment reaches HTML as a logical value, so a table aligns correctly whichever way its
478 /// prose runs. `Centre` is spelled `center`, because the keyword is CSS's and not ours.
479 #[test]
480 fn test_a_table_aligns_by_logical_side_02() -> Outcome<()> {
481 let doc = Doc {
482 blocks: vec![Block::Table {
483 head: Some(Row(vec![Cell(text("Name")), Cell(text("Age"))])),
484 rows: vec![Row(vec![Cell(text("Alice")), Cell(text("30"))])],
485 cols: vec![Align::Start, Align::End],
486 }],
487 };
488 let out = render(&doc);
489 assert!(out.contains("<th style=\"text-align: start\">Name</th>"), "got: {}", out);
490 assert!(out.contains("<th style=\"text-align: end\">Age</th>"), "got: {}", out);
491 assert!(out.contains("<td style=\"text-align: start\">Alice</td>"), "got: {}", out);
492 // Never left or right: the tree does not know which side the text begins on.
493 assert!(!out.contains("left"), "got: {}", out);
494 assert!(!out.contains("right"), "got: {}", out);
495 Ok(())
496 }
497
498 /// A column that named no alignment, and a cell past the end of the columns, are aligned by nothing.
499 #[test]
500 fn test_an_unaligned_cell_carries_no_style_03() -> Outcome<()> {
501 let doc = Doc {
502 blocks: vec![Block::Table {
503 head: None,
504 rows: vec![Row(vec![Cell(text("a")), Cell(text("b"))])],
505 cols: vec![Align::None],
506 }],
507 };
508 let out = render(&doc);
509 assert!(out.contains("<td>a</td><td>b</td>"), "got: {}", out);
510 assert!(!out.contains("<thead>"), "a table with no head grew one: {}", out);
511 Ok(())
512 }
513
514 /// An item that is one paragraph is what a reader wrote as a plain item, and is not spaced out.
515 #[test]
516 fn test_a_single_paragraph_item_renders_tight_04() -> Outcome<()> {
517 let doc = Doc {
518 blocks: vec![Block::List {
519 ordered: false,
520 items: vec![vec![Block::Para(text("one"))], vec![Block::Para(text("two"))]],
521 }],
522 };
523 assert_eq!(render(&doc), "<ul>\n<li>one</li>\n<li>two</li>\n</ul>\n");
524 Ok(())
525 }
526
527 /// An item holding more than a paragraph is rendered as the blocks it is.
528 #[test]
529 fn test_a_richer_item_renders_its_blocks_05() -> Outcome<()> {
530 let doc = Doc {
531 blocks: vec![Block::List {
532 ordered: true,
533 items: vec![vec![Block::Para(text("one")), Block::Para(text("still one"))]],
534 }],
535 };
536 let out = render(&doc);
537 assert!(out.starts_with("<ol>\n<li>\n<p>one</p>\n<p>still one</p>\n</li>"), "got: {}", out);
538 Ok(())
539 }
540
541 /// A heading outside the levels HTML has renders at the nearest one it does, rather than panicking.
542 #[test]
543 fn test_a_heading_level_is_clamped_06() -> Outcome<()> {
544 let doc = Doc {
545 blocks: vec![
546 Block::Heading { level: 0, content: text("low") },
547 Block::Heading { level: 9, content: text("high") },
548 ],
549 };
550 let out = render(&doc);
551 assert!(out.contains("<h1>low</h1>"), "got: {}", out);
552 assert!(out.contains("<h6>high</h6>"), "got: {}", out);
553 Ok(())
554 }
555
556 /// A fence's language reaches the class every highlighter reads; a fence without one says nothing.
557 #[test]
558 fn test_a_code_block_names_its_language_07() -> Outcome<()> {
559 let doc = Doc {
560 blocks: vec![
561 Block::Code { lang: Some("rust".to_string()), text: "let x = 1 < 2;\n".to_string() },
562 Block::Code { lang: None, text: "plain\n".to_string() },
563 ],
564 };
565 let out = render(&doc);
566 assert!(out.contains("<pre><code class=\"language-rust\">let x = 1 &lt; 2;\n</code></pre>"),
567 "got: {}", out);
568 assert!(out.contains("<pre><code>plain\n</code></pre>"), "got: {}", out);
569 Ok(())
570 }
571
572 /// A hard break is honoured unconditionally, needing no rule of its own about whitespace.
573 #[test]
574 fn test_a_hard_break_is_a_br_08() -> Outcome<()> {
575 let doc = Doc {
576 blocks: vec![Block::Para(vec![
577 Inline::Text("one".to_string()),
578 Inline::Break,
579 Inline::Text("two".to_string()),
580 ])],
581 };
582 assert_eq!(render(&doc), "<p>one<br>two</p>\n");
583 Ok(())
584 }
585
586 /// The three things a signed document gives up all reach HTML intact. This is what makes the
587 /// tree's neutrality a tested claim rather than an asserted one.
588 #[test]
589 fn test_what_sbj_drops_survives_to_html_09() -> Outcome<()> {
590 let doc = Doc {
591 blocks: vec![
592 Block::Rule,
593 Block::Para(vec![
594 Inline::Image { src: "fig.png".to_string(), alt: "a figure".to_string() },
595 Inline::Code("inline()".to_string()),
596 ]),
597 ],
598 };
599 let out = render(&doc);
600 assert!(out.contains("<hr>"), "a rule was lost: {}", out);
601 assert!(out.contains("<img src=\"fig.png\" alt=\"a figure\">"), "an image by path was lost: {}", out);
602 assert!(out.contains("<code>inline()</code>"), "an inline code span was lost: {}", out);
603 Ok(())
604 }
605
606 /// Emphasis and strong emphasis are distinct, and nest.
607 #[test]
608 fn test_emphasis_nests_10() -> Outcome<()> {
609 let doc = Doc {
610 blocks: vec![Block::Para(vec![Inline::Emph {
611 strong: true,
612 content: vec![Inline::Emph { strong: false, content: text("both") }],
613 }])],
614 };
615 assert_eq!(render(&doc), "<p><strong><em>both</em></strong></p>\n");
616 Ok(())
617 }
618
619 /// End to end, through the reader that exists: prose in, HTML out.
620 #[test]
621 fn test_markdown_reaches_html_11() -> Outcome<()> {
622 let src = "# A heading\n\nA paragraph with *emphasis* and a [link](https://example.com).\n";
623 let doc = res!(markdown::parse(src));
624 let out = render(&doc);
625 assert!(out.contains("<h1>A heading</h1>"), "got: {}", out);
626 assert!(out.contains("<em>emphasis</em>") || out.contains("<strong>emphasis</strong>"),
627 "got: {}", out);
628 assert!(out.contains("<a href=\"https://example.com\">link</a>"), "got: {}", out);
629 Ok(())
630 }
631
632 /// A hard-wrapped paragraph reaches HTML as one paragraph of flowing prose. The wrap the author's
633 /// editor made is not a break the author asked for, and must not become one.
634 #[test]
635 fn test_a_hard_wrapped_paragraph_flows_12() -> Outcome<()> {
636 let doc = res!(markdown::parse("One line\nand its continuation.\n"));
637 let out = render(&doc);
638 assert!(!out.contains("<br>"), "a soft wrap became a hard break: {}", out);
639 assert_eq!(out, "<p>One line and its continuation.</p>\n");
640 Ok(())
641 }
642}