1use crate::{
17 Color, Font, FontWeight, ListStyle, ParagraphAlign, ParagraphRun, ParagraphStyle, RunStyle,
18 StyledText, TextRun, Underline, coalesce_runs, paragraph_bounds,
19};
20
21#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct DocStyle {
26 pub base: Font,
27 pub base_points: f64,
31}
32
33impl Default for DocStyle {
34 fn default() -> Self {
35 DocStyle {
36 base: Font::Body,
37 base_points: 16.0,
38 }
39 }
40}
41
42impl DocStyle {
43 pub fn new(base: Font, base_points: f64) -> Self {
44 DocStyle { base, base_points }
45 }
46 fn plain(&self) -> RunStyle {
47 RunStyle::plain(self.base)
48 }
49}
50
51fn heading_scale(level: u8) -> f64 {
54 match level {
55 1 => 2.0,
56 2 => 1.5,
57 3 => 1.17,
58 4 => 1.0,
59 5 => 0.83,
60 _ => 0.67,
61 }
62}
63
64fn heading_level_for(scale: f64) -> Option<u8> {
71 if (scale - 1.0).abs() < 1e-6 {
72 return None;
73 }
74 (1u8..=6).find(|level| (heading_scale(*level) - scale).abs() < 1e-6)
75}
76
77pub fn markdown_to_styled(md: &str, style: DocStyle) -> StyledText {
90 let mut doc = StyledText::default();
91 let mut paragraphs: Vec<ParagraphRun> = Vec::new();
92 let mut ordinal = 1u32;
93 let mut prev_ordered = false;
94
95 for raw in md.lines() {
96 let start = doc.text.len();
97 let (body, para, heading) = split_block_markers(raw);
98
99 let para = match para.list {
101 ListStyle::Ordered(_) => {
102 if !prev_ordered {
103 ordinal = 1;
104 }
105 prev_ordered = true;
106 let p = ParagraphStyle {
107 list: ListStyle::Ordered(ordinal),
108 ..para
109 };
110 ordinal += 1;
111 p
112 }
113 _ => {
114 prev_ordered = false;
115 para
116 }
117 };
118
119 let (text, mut runs) = crate::markdown::parse(body, style.base);
120 if let Some(level) = heading {
122 let scale = heading_scale(level);
123 for r in &mut runs {
124 r.font.scale = scale;
125 if r.font.weight.is_none() {
126 r.font.weight = Some(FontWeight::Bold);
127 }
128 }
129 let mut head = RunStyle::plain(style.base);
130 head.font.scale = scale;
131 head.set_bold(true);
132 merge_base_run(&mut runs, text.len(), head);
133 }
134 for r in &mut runs {
135 r.range = (r.range.start + start)..(r.range.end + start);
136 }
137 doc.runs.extend(runs);
138 doc.text.push_str(&text);
139 doc.text.push('\n');
140 let end = doc.text.len();
141 if para != ParagraphStyle::default() {
142 paragraphs.push(ParagraphRun::new(start..end, para));
143 }
144 }
145 if !md.ends_with('\n') && doc.text.ends_with('\n') {
147 doc.text.pop();
148 doc.clamp();
149 }
150 doc.paragraphs = paragraphs;
151 doc.runs = coalesce_runs(std::mem::take(&mut doc.runs), style.base);
152 doc.clamp();
153 doc
154}
155
156fn split_block_markers(line: &str) -> (&str, ParagraphStyle, Option<u8>) {
159 let mut s = line;
160 let mut para = ParagraphStyle::default();
161 while let Some(rest) = s.strip_prefix('>') {
163 para.indent += 24.0;
164 s = rest.strip_prefix(' ').unwrap_or(rest);
165 }
166 let indented = s.len() - s.trim_start_matches(' ').len();
169 let trimmed = s.trim_start_matches(' ');
170 if let Some(rest) = trimmed
171 .strip_prefix("- ")
172 .or_else(|| trimmed.strip_prefix("* "))
173 {
174 para.list = ListStyle::Bullet;
175 para.list_level = (indented / 2).min(u8::MAX as usize) as u8;
176 return (rest, para, None);
177 }
178 if let Some((num, rest)) = split_ordered_marker(trimmed) {
179 para.list = ListStyle::Ordered(num);
180 para.list_level = (indented / 2).min(u8::MAX as usize) as u8;
181 return (rest, para, None);
182 }
183 let hashes = s.bytes().take_while(|b| *b == b'#').count();
185 if (1..=6).contains(&hashes)
186 && let Some(rest) = s[hashes..].strip_prefix(' ')
187 {
188 return (rest, para, Some(hashes as u8));
189 }
190 (s, para, None)
191}
192
193fn split_ordered_marker(s: &str) -> Option<(u32, &str)> {
195 let digits = s.bytes().take_while(|b| b.is_ascii_digit()).count();
196 if digits == 0 || digits > 9 {
197 return None;
198 }
199 let rest = s[digits..].strip_prefix(". ")?;
200 s[..digits].parse().ok().map(|n| (n, rest))
201}
202
203fn merge_base_run(runs: &mut Vec<TextRun>, len: usize, style: RunStyle) {
205 let mut out = Vec::with_capacity(runs.len() * 2 + 1);
206 let mut at = 0usize;
207 for r in runs.iter() {
208 if r.range.start > at {
209 out.push(TextRun::styled(at..r.range.start, style.clone()));
210 }
211 out.push(r.clone());
212 at = r.range.end;
213 }
214 if at < len {
215 out.push(TextRun::styled(at..len, style));
216 }
217 *runs = out;
218}
219
220pub fn styled_to_markdown(doc: &StyledText, style: DocStyle) -> String {
225 let mut out = String::with_capacity(doc.text.len() + 32);
226 for (start, end) in paragraph_bounds(&doc.text) {
227 let line_end = end - usize::from(doc.text[start..end].ends_with('\n'));
228 let para = doc
229 .paragraphs
230 .iter()
231 .find(|p| p.range.start <= start && p.range.end >= line_end.max(start + 1))
232 .map(|p| p.style())
233 .unwrap_or_default();
234 let heading = doc
236 .style_of(start..line_end.max(start), style.base)
237 .font
238 .scale;
239 let heading = if line_end > start {
240 heading_level_for(heading)
241 } else {
242 None
243 };
244 for _ in 0..(para.indent / 24.0).round().max(0.0) as usize {
245 out.push_str("> ");
246 }
247 for _ in 0..para.list_level {
248 out.push_str(" ");
249 }
250 match para.list {
251 ListStyle::Bullet => out.push_str("- "),
252 ListStyle::Ordered(n) => out.push_str(&format!("{n}. ")),
253 ListStyle::None => {}
254 }
255 if let Some(level) = heading {
256 for _ in 0..level {
257 out.push('#');
258 }
259 out.push(' ');
260 }
261 write_markdown_inline(doc, start..line_end, style, heading.is_some(), &mut out);
262 if end < doc.text.len() || doc.text[start..end].ends_with('\n') {
263 out.push('\n');
264 }
265 }
266 out
267}
268
269fn write_markdown_inline(
270 doc: &StyledText,
271 range: std::ops::Range<usize>,
272 style: DocStyle,
273 in_heading: bool,
274 out: &mut String,
275) {
276 for (seg, s) in doc.segments(style.base) {
277 let lo = seg.start.max(range.start);
278 let hi = seg.end.min(range.end);
279 if lo >= hi {
280 continue;
281 }
282 let Some(text) = doc.text.get(lo..hi) else {
283 continue;
284 };
285 let bold = s.bold() && !in_heading;
287 let (open, close) = markdown_markers(&s, bold);
288 if let Some(url) = &s.link {
289 out.push('[');
290 out.push_str(&open);
291 out.push_str(text);
292 out.push_str(&close);
293 out.push_str("](");
294 out.push_str(url);
295 out.push(')');
296 continue;
297 }
298 out.push_str(&open);
299 out.push_str(&escape_markdown(text));
300 out.push_str(&close);
301 }
302}
303
304fn markdown_markers(s: &RunStyle, bold: bool) -> (String, String) {
305 let mut open = String::new();
306 let mut close = String::new();
307 if bold {
308 open.push_str("**");
309 close.insert_str(0, "**");
310 }
311 if s.font.italic {
312 open.push('*');
313 close.insert(0, '*');
314 }
315 if s.strikethrough {
316 open.push_str("~~");
317 close.insert_str(0, "~~");
318 }
319 if s.font.monospace {
320 open.push('`');
321 close.insert(0, '`');
322 }
323 (open, close)
324}
325
326fn escape_markdown(s: &str) -> String {
327 let mut out = String::with_capacity(s.len());
328 for c in s.chars() {
329 if matches!(c, '*' | '_' | '`' | '~' | '[' | ']' | '\\') {
330 out.push('\\');
331 }
332 out.push(c);
333 }
334 out
335}
336
337pub fn styled_to_html(doc: &StyledText, style: DocStyle) -> String {
344 let mut out = String::with_capacity(doc.text.len() * 2);
345 for (start, end) in paragraph_bounds(&doc.text) {
346 let line_end = end - usize::from(doc.text[start..end].ends_with('\n'));
347 let para = doc
348 .paragraphs
349 .iter()
350 .find(|p| p.range.start <= start && p.range.end >= line_end.max(start + 1))
351 .map(|p| p.style())
352 .unwrap_or_default();
353 let (open_tag, close_tag) = match para.list {
354 ListStyle::None => ("p", "p"),
355 _ => ("li", "li"),
356 };
357 out.push('<');
358 out.push_str(open_tag);
359 let mut css = String::new();
360 match para.align {
361 ParagraphAlign::Natural => {}
362 ParagraphAlign::Center => css.push_str("text-align:center;"),
363 ParagraphAlign::Trailing => css.push_str("text-align:end;"),
364 ParagraphAlign::Justified => css.push_str("text-align:justify;"),
365 }
366 let indent = para.indent + f64::from(para.list_level) * 24.0;
367 if indent > 0.0 {
368 css.push_str(&format!("margin-inline-start:{indent}px;"));
369 }
370 if para.space_before > 0.0 {
371 css.push_str(&format!("margin-block-start:{}px;", para.space_before));
372 }
373 if para.space_after > 0.0 {
374 css.push_str(&format!("margin-block-end:{}px;", para.space_after));
375 }
376 if !css.is_empty() {
377 out.push_str(&format!(" style=\"{css}\""));
378 }
379 out.push('>');
380 for (seg, s) in doc.segments(style.base) {
381 let lo = seg.start.max(start);
382 let hi = seg.end.min(line_end);
383 if lo >= hi {
384 continue;
385 }
386 let Some(text) = doc.text.get(lo..hi) else {
387 continue;
388 };
389 write_html_run(&s, text, &mut out);
390 }
391 out.push_str("</");
392 out.push_str(close_tag);
393 out.push_str(">\n");
394 }
395 out
396}
397
398fn write_html_run(s: &RunStyle, text: &str, out: &mut String) {
399 let mut css = String::new();
400 if let Some(c) = s.color {
401 css.push_str(&format!("color:{};", css_hex(c)));
402 }
403 if let Some(c) = s.background {
404 css.push_str(&format!("background-color:{};", css_hex(c)));
405 }
406 if s.font.scale != 1.0 {
407 css.push_str(&format!("font-size:{}em;", s.font.scale));
408 }
409 if let Font::System(pt) | Font::Custom(_, pt) = s.font.style {
410 css.push_str(&format!("font-size:{pt}px;"));
411 }
412 let mut tags: Vec<&str> = Vec::new();
413 if let Some(url) = &s.link {
414 out.push_str("<a href=\"");
415 escape_html(url, out);
416 out.push_str("\">");
417 }
418 if !css.is_empty() {
419 out.push_str(&format!("<span style=\"{css}\">"));
420 }
421 if s.bold() {
422 tags.push("b");
423 }
424 if s.font.italic {
425 tags.push("i");
426 }
427 if s.underline.is_on() {
428 tags.push("u");
429 }
430 if s.strikethrough {
431 tags.push("s");
432 }
433 if s.font.monospace {
434 tags.push("code");
435 }
436 for t in &tags {
437 out.push('<');
438 out.push_str(t);
439 out.push('>');
440 }
441 escape_html(text, out);
442 for t in tags.iter().rev() {
443 out.push_str("</");
444 out.push_str(t);
445 out.push('>');
446 }
447 if !css.is_empty() {
448 out.push_str("</span>");
449 }
450 if s.link.is_some() {
451 out.push_str("</a>");
452 }
453}
454
455fn css_hex(c: Color) -> String {
456 let q = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
457 format!("#{:02x}{:02x}{:02x}", q(c.r), q(c.g), q(c.b))
458}
459
460fn escape_html(s: &str, out: &mut String) {
461 for c in s.chars() {
462 match c {
463 '&' => out.push_str("&"),
464 '<' => out.push_str("<"),
465 '>' => out.push_str(">"),
466 '"' => out.push_str("""),
467 _ => out.push(c),
468 }
469 }
470}
471
472pub fn html_to_styled(html: &str, style: DocStyle) -> StyledText {
478 let mut p = HtmlParser {
479 src: html.as_bytes(),
480 at: 0,
481 doc: StyledText::default(),
482 stack: vec![style.plain()],
483 para_stack: vec![ParagraphStyle::default()],
484 style,
485 para_start: 0,
486 list_ordinal: Vec::new(),
487 };
488 p.run();
489 p.finish()
490}
491
492struct HtmlParser<'a> {
493 src: &'a [u8],
494 at: usize,
495 doc: StyledText,
496 stack: Vec<RunStyle>,
497 para_stack: Vec<ParagraphStyle>,
498 style: DocStyle,
499 para_start: usize,
500 list_ordinal: Vec<u32>,
501}
502
503impl HtmlParser<'_> {
504 fn run(&mut self) {
505 let mut text_start = 0usize;
506 while self.at < self.src.len() {
507 if self.src[self.at] == b'<' {
508 self.flush(text_start);
509 self.tag();
510 text_start = self.doc.text.len();
511 } else {
512 let c = self.next_char();
513 self.push_char(c);
514 }
515 }
516 self.flush(text_start);
517 }
518
519 fn next_char(&mut self) -> char {
520 if self.src[self.at] == b'&'
522 && let Some(end) = self.src[self.at..].iter().take(12).position(|b| *b == b';')
523 {
524 let ent = std::str::from_utf8(&self.src[self.at + 1..self.at + end]).unwrap_or("");
525 let decoded = match ent {
526 "amp" => Some('&'),
527 "lt" => Some('<'),
528 "gt" => Some('>'),
529 "quot" => Some('"'),
530 "apos" | "#39" => Some('\''),
531 "nbsp" => Some('\u{a0}'),
532 e => e
533 .strip_prefix('#')
534 .and_then(|n| {
535 n.strip_prefix('x')
536 .and_then(|h| u32::from_str_radix(h, 16).ok())
537 .or_else(|| n.parse().ok())
538 })
539 .and_then(char::from_u32),
540 };
541 if let Some(c) = decoded {
542 self.at += end + 1;
543 return c;
544 }
545 }
546 let rest = &self.src[self.at..];
547 let s = std::str::from_utf8(rest).unwrap_or("\u{fffd}");
548 let c = s.chars().next().unwrap_or('\u{fffd}');
549 self.at += c.len_utf8().min(rest.len());
550 c
551 }
552
553 fn push_char(&mut self, c: char) {
554 if c.is_whitespace() {
557 if self.doc.text.ends_with(' ') || self.doc.text.ends_with('\n') {
558 return;
559 }
560 self.doc.text.push(' ');
561 return;
562 }
563 self.doc.text.push(c);
564 }
565
566 fn flush(&mut self, from: usize) {
568 let end = self.doc.text.len();
569 let style = self
570 .stack
571 .last()
572 .cloned()
573 .unwrap_or_else(|| self.style.plain());
574 if end > from && style != self.style.plain() {
575 self.doc.runs.push(TextRun::styled(from..end, style));
576 }
577 }
578
579 fn tag(&mut self) {
580 let Some(close) = self.src[self.at..].iter().position(|b| *b == b'>') else {
581 self.at = self.src.len();
582 return;
583 };
584 let raw = std::str::from_utf8(&self.src[self.at + 1..self.at + close]).unwrap_or("");
585 self.at += close + 1;
586 let closing = raw.starts_with('/');
587 let body = raw.trim_start_matches('/').trim_end_matches('/');
588 let name: String = body
589 .split([' ', '\t', '\n'])
590 .next()
591 .unwrap_or("")
592 .to_ascii_lowercase();
593 if !closing && matches!(name.as_str(), "script" | "style") {
595 let needle = format!("</{name}");
596 if let Some(end) = find_ci(&self.src[self.at..], needle.as_bytes()) {
597 self.at += end;
598 } else {
599 self.at = self.src.len();
600 }
601 return;
602 }
603 if name == "br" {
604 self.end_paragraph();
605 return;
606 }
607 if closing {
608 self.close(&name);
609 } else {
610 self.open(&name, body);
611 }
612 }
613
614 fn open(&mut self, name: &str, body: &str) {
615 if is_block(name) {
616 self.end_paragraph();
617 let mut para = ParagraphStyle::default();
618 if let Some(level) = heading_tag_level(name) {
619 let mut s = self.style.plain();
620 s.font.scale = heading_scale(level);
621 s.set_bold(true);
622 self.stack.push(s);
623 self.para_stack.push(para);
624 return;
625 }
626 if name == "li" {
627 let ordered = self.list_ordinal.last().copied();
628 para.list = match ordered {
629 Some(n) => ListStyle::Ordered(n),
630 None => ListStyle::Bullet,
631 };
632 para.list_level = self.list_ordinal.len().saturating_sub(1).min(255) as u8;
633 if let Some(n) = self.list_ordinal.last_mut() {
634 *n += 1;
635 }
636 }
637 if name == "ul" {
638 self.list_ordinal.push(0);
639 if let Some(last) = self.list_ordinal.last_mut() {
641 *last = 0;
642 }
643 self.para_stack.push(para);
644 self.stack.push(self.current_run());
645 return;
646 }
647 if name == "ol" {
648 self.list_ordinal.push(1);
649 self.para_stack.push(para);
650 self.stack.push(self.current_run());
651 return;
652 }
653 if name == "blockquote" {
654 para.indent += 24.0;
655 }
656 apply_block_css(&mut para, body);
657 self.para_stack.push(para);
658 self.stack.push(self.current_run());
659 return;
660 }
661 let mut s = self.current_run();
662 match name {
663 "b" | "strong" => s.set_bold(true),
664 "i" | "em" => s.set_italic(true),
665 "u" | "ins" => s.underline = Underline::Single,
666 "s" | "del" | "strike" => s.strikethrough = true,
667 "code" | "kbd" | "samp" | "tt" => s.font.monospace = true,
668 "a" => s.link = attr(body, "href").map(str::to_string),
669 _ => {}
670 }
671 apply_inline_css(&mut s, body, self.style);
672 self.stack.push(s);
673 }
674
675 fn close(&mut self, name: &str) {
676 if is_block(name) {
677 self.end_paragraph();
678 if self.para_stack.len() > 1 {
679 self.para_stack.pop();
680 }
681 if matches!(name, "ul" | "ol") {
682 self.list_ordinal.pop();
683 }
684 }
685 if self.stack.len() > 1 {
686 self.stack.pop();
687 }
688 }
689
690 fn current_run(&self) -> RunStyle {
691 self.stack
692 .last()
693 .cloned()
694 .unwrap_or_else(|| self.style.plain())
695 }
696
697 fn end_paragraph(&mut self) {
699 while self.doc.text.ends_with(' ') {
701 self.doc.text.pop();
702 }
703 if self.doc.text.len() <= self.para_start {
704 return;
705 }
706 self.doc.text.push('\n');
707 let style = self.para_stack.last().copied().unwrap_or_default();
708 if style != ParagraphStyle::default() {
709 self.doc.paragraphs.push(ParagraphRun::new(
710 self.para_start..self.doc.text.len(),
711 style,
712 ));
713 }
714 self.para_start = self.doc.text.len();
715 }
716
717 fn finish(mut self) -> StyledText {
718 self.end_paragraph();
719 if self.doc.text.ends_with('\n') {
721 self.doc.text.pop();
722 }
723 let base = self.style.base;
724 self.doc.runs = coalesce_runs(std::mem::take(&mut self.doc.runs), base);
725 self.doc.clamp();
726 self.doc
727 }
728}
729
730fn is_block(name: &str) -> bool {
731 matches!(
732 name,
733 "p" | "div"
734 | "li"
735 | "ul"
736 | "ol"
737 | "blockquote"
738 | "pre"
739 | "h1"
740 | "h2"
741 | "h3"
742 | "h4"
743 | "h5"
744 | "h6"
745 )
746}
747
748fn heading_tag_level(name: &str) -> Option<u8> {
749 let n = name.strip_prefix('h')?;
750 let level: u8 = n.parse().ok()?;
751 (1..=6).contains(&level).then_some(level)
752}
753
754fn attr<'a>(body: &'a str, name: &str) -> Option<&'a str> {
756 let lower = body.to_ascii_lowercase();
757 let at = lower.find(&format!("{name}="))? + name.len() + 1;
758 let rest = &body[at..];
759 let quote = rest.chars().next()?;
760 if quote == '"' || quote == '\'' {
761 rest[1..].find(quote).map(|e| &rest[1..1 + e])
762 } else {
763 Some(rest.split([' ', '\t']).next().unwrap_or(rest))
764 }
765}
766
767fn apply_inline_css(s: &mut RunStyle, body: &str, doc: DocStyle) {
768 let Some(css) = attr(body, "style") else {
769 return;
770 };
771 for decl in css.split(';') {
772 let Some((k, v)) = decl.split_once(':') else {
773 continue;
774 };
775 let (k, v) = (k.trim().to_ascii_lowercase(), v.trim());
776 match k.as_str() {
777 "color" => s.color = parse_css_color(v),
778 "background-color" | "background" => s.background = parse_css_color(v),
779 "font-weight" => {
780 let bold = v == "bold" || v.parse::<u32>().is_ok_and(|n| n >= 600);
781 s.set_bold(bold);
782 }
783 "font-style" => s.set_italic(v == "italic" || v == "oblique"),
784 "text-decoration" | "text-decoration-line" => {
785 if v.contains("underline") {
786 s.underline = Underline::Single;
787 }
788 if v.contains("line-through") {
789 s.strikethrough = true;
790 }
791 }
792 "font-family" => s.font.monospace = v.contains("monospace") || v.contains("Courier"),
793 "font-size" => {
794 if let Some(em) = v
795 .strip_suffix("em")
796 .and_then(|n| n.trim().parse::<f64>().ok())
797 {
798 s.font.scale = em;
799 } else if let Some(px) = v
800 .strip_suffix("px")
801 .and_then(|n| n.trim().parse::<f64>().ok())
802 {
803 s.font.style = Font::System(px);
806 s.font.scale = 1.0;
807 } else if let Some(pct) = v
808 .strip_suffix('%')
809 .and_then(|n| n.trim().parse::<f64>().ok())
810 {
811 s.font.scale = pct / 100.0;
812 } else if let Some(pt) = v
813 .strip_suffix("pt")
814 .and_then(|n| n.trim().parse::<f64>().ok())
815 {
816 s.font.style = Font::System(pt * 96.0 / 72.0);
817 s.font.scale = 1.0;
818 }
819 let _ = doc;
820 }
821 _ => {}
822 }
823 }
824}
825
826fn apply_block_css(para: &mut ParagraphStyle, body: &str) {
827 let Some(css) = attr(body, "style") else {
828 return;
829 };
830 for decl in css.split(';') {
831 let Some((k, v)) = decl.split_once(':') else {
832 continue;
833 };
834 let (k, v) = (k.trim().to_ascii_lowercase(), v.trim());
835 let px = |v: &str| v.trim_end_matches("px").trim().parse::<f64>().ok();
836 match k.as_str() {
837 "text-align" => {
838 para.align = match v {
839 "center" => ParagraphAlign::Center,
840 "right" | "end" => ParagraphAlign::Trailing,
841 "justify" => ParagraphAlign::Justified,
842 _ => ParagraphAlign::Natural,
843 }
844 }
845 "margin-inline-start" | "margin-left" | "padding-inline-start" => {
846 para.indent = px(v).unwrap_or(para.indent)
847 }
848 "margin-block-start" | "margin-top" => {
849 para.space_before = px(v).unwrap_or(para.space_before)
850 }
851 "margin-block-end" | "margin-bottom" => {
852 para.space_after = px(v).unwrap_or(para.space_after)
853 }
854 _ => {}
855 }
856 }
857}
858
859fn parse_css_color(v: &str) -> Option<Color> {
861 let v = v.trim();
862 if let Some(rest) = v.strip_prefix("rgb(").and_then(|r| r.strip_suffix(')')) {
863 let mut it = rest.split(',').map(|n| n.trim().parse::<f64>().ok());
864 return Some(Color::rgb(
865 it.next()?? / 255.0,
866 it.next()?? / 255.0,
867 it.next()?? / 255.0,
868 ));
869 }
870 Color::parse(v)
871}
872
873fn find_ci(hay: &[u8], needle: &[u8]) -> Option<usize> {
875 hay.windows(needle.len())
876 .position(|w| w.eq_ignore_ascii_case(needle))
877}
878
879pub fn styled_to_rtf(doc: &StyledText, style: DocStyle) -> String {
886 let mut colors: Vec<Color> = Vec::new();
889 let color_index = |c: Color, colors: &mut Vec<Color>| -> usize {
890 match colors.iter().position(|x| *x == c) {
891 Some(i) => i + 1,
892 None => {
893 colors.push(c);
894 colors.len()
895 }
896 }
897 };
898 let mut body = String::new();
899 for (start, end) in paragraph_bounds(&doc.text) {
900 let line_end = end - usize::from(doc.text[start..end].ends_with('\n'));
901 let para = doc
902 .paragraphs
903 .iter()
904 .find(|p| p.range.start <= start && p.range.end >= line_end.max(start + 1))
905 .map(|p| p.style())
906 .unwrap_or_default();
907 body.push_str("\\pard");
908 match para.align {
909 ParagraphAlign::Natural => body.push_str("\\ql"),
910 ParagraphAlign::Center => body.push_str("\\qc"),
911 ParagraphAlign::Trailing => body.push_str("\\qr"),
912 ParagraphAlign::Justified => body.push_str("\\qj"),
913 }
914 let indent = para.indent + f64::from(para.list_level) * 24.0;
915 if indent > 0.0 {
916 body.push_str(&format!("\\li{}", (indent * 20.0).round() as i64));
918 }
919 if para.space_before > 0.0 {
920 body.push_str(&format!(
921 "\\sb{}",
922 (para.space_before * 20.0).round() as i64
923 ));
924 }
925 if para.space_after > 0.0 {
926 body.push_str(&format!("\\sa{}", (para.space_after * 20.0).round() as i64));
927 }
928 match para.list {
929 ListStyle::Bullet => body.push_str("\\bullet\\tab "),
930 ListStyle::Ordered(n) => body.push_str(&format!(" {n}.\\tab ")),
931 ListStyle::None => body.push(' '),
934 }
935 for (seg, s) in doc.segments(style.base) {
936 let lo = seg.start.max(start);
937 let hi = seg.end.min(line_end);
938 if lo >= hi {
939 continue;
940 }
941 let Some(text) = doc.text.get(lo..hi) else {
942 continue;
943 };
944 body.push('{');
945 let attrs_at = body.len();
949 if s.bold() {
950 body.push_str("\\b");
951 }
952 if s.font.italic {
953 body.push_str("\\i");
954 }
955 match s.underline {
956 Underline::None => {}
957 Underline::Double => body.push_str("\\uldb"),
958 Underline::Dotted => body.push_str("\\uld"),
959 Underline::Wavy => body.push_str("\\ulwave"),
960 Underline::Single => body.push_str("\\ul"),
961 }
962 if s.strikethrough {
963 body.push_str("\\strike");
964 }
965 let pts = s.font.resolved_points(style.base_points);
967 if (pts - style.base_points).abs() > 0.01 {
968 body.push_str(&format!("\\fs{}", (pts * 2.0).round() as i64));
969 }
970 if let Some(c) = s.color {
971 body.push_str(&format!("\\cf{}", color_index(c, &mut colors)));
972 }
973 if let Some(c) = s.background {
974 body.push_str(&format!("\\highlight{}", color_index(c, &mut colors)));
975 }
976 if s.font.monospace {
977 body.push_str("\\f1");
978 }
979 if body.len() > attrs_at {
980 body.push(' ');
981 }
982 escape_rtf(text, &mut body);
983 body.push('}');
984 }
985 body.push_str("\\par\n");
986 }
987
988 let mut out = String::with_capacity(body.len() + 256);
989 out.push_str("{\\rtf1\\ansi\\deff0");
990 out.push_str("{\\fonttbl{\\f0 ");
991 out.push_str(rtf_family(style.base));
992 out.push_str(";}{\\f1 Courier New;}}");
993 out.push_str("{\\colortbl;");
994 for c in &colors {
995 let q = |v: f64| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
996 out.push_str(&format!(
997 "\\red{}\\green{}\\blue{};",
998 q(c.r),
999 q(c.g),
1000 q(c.b)
1001 ));
1002 }
1003 out.push('}');
1004 out.push_str(&format!(
1005 "\\fs{}\n",
1006 (style.base_points * 2.0).round() as i64
1007 ));
1008 out.push_str(&body);
1009 out.push('}');
1010 out
1011}
1012
1013fn rtf_family(base: Font) -> &'static str {
1014 match base {
1015 Font::Custom(name, _) => {
1016 name
1020 }
1021 _ => "Helvetica",
1022 }
1023}
1024
1025fn escape_rtf(s: &str, out: &mut String) {
1026 for c in s.chars() {
1027 match c {
1028 '\\' | '{' | '}' => {
1029 out.push('\\');
1030 out.push(c);
1031 }
1032 '\n' => out.push_str("\\line "),
1033 c if (c as u32) < 128 => out.push(c),
1034 c => {
1038 let mut buf = [0u16; 2];
1039 for unit in c.encode_utf16(&mut buf) {
1040 out.push_str(&format!("\\u{}?", *unit as i16));
1041 }
1042 }
1043 }
1044 }
1045}
1046
1047pub fn rtf_to_styled(rtf: &str, style: DocStyle) -> StyledText {
1056 let mut p = RtfParser {
1057 src: rtf.as_bytes(),
1058 at: 0,
1059 doc: StyledText::default(),
1060 stack: vec![RtfState::new(style)],
1061 colors: Vec::new(),
1062 mono_fonts: Vec::new(),
1063 style,
1064 run_start: 0,
1065 para_start: 0,
1066 para: ParagraphStyle::default(),
1067 pending_utf16: Vec::new(),
1068 };
1069 p.run();
1070 p.finish()
1071}
1072
1073#[derive(Clone)]
1074struct RtfState {
1075 run: RunStyle,
1076 skip: bool,
1078}
1079
1080impl RtfState {
1081 fn new(style: DocStyle) -> Self {
1082 RtfState {
1083 run: style.plain(),
1084 skip: false,
1085 }
1086 }
1087}
1088
1089struct RtfParser<'a> {
1090 src: &'a [u8],
1091 at: usize,
1092 doc: StyledText,
1093 stack: Vec<RtfState>,
1094 colors: Vec<Color>,
1095 mono_fonts: Vec<u32>,
1096 style: DocStyle,
1097 run_start: usize,
1098 para_start: usize,
1099 para: ParagraphStyle,
1100 pending_utf16: Vec<u16>,
1101}
1102
1103impl RtfParser<'_> {
1104 fn run(&mut self) {
1105 while self.at < self.src.len() {
1106 match self.src[self.at] {
1107 b'{' => {
1108 self.at += 1;
1109 let top = self
1110 .stack
1111 .last()
1112 .cloned()
1113 .unwrap_or(RtfState::new(self.style));
1114 self.stack.push(top);
1115 }
1116 b'}' => {
1117 self.at += 1;
1118 self.flush_run();
1119 if self.stack.len() > 1 {
1120 self.stack.pop();
1121 }
1122 }
1123 b'\\' => self.control(),
1124 b'\r' | b'\n' => self.at += 1, _ => {
1126 let b = self.src[self.at];
1127 self.at += 1;
1128 self.push_byte(b);
1129 }
1130 }
1131 }
1132 }
1133
1134 fn state(&self) -> RtfState {
1135 self.stack
1136 .last()
1137 .cloned()
1138 .unwrap_or_else(|| RtfState::new(self.style))
1139 }
1140
1141 fn skipping(&self) -> bool {
1142 self.stack.last().is_some_and(|s| s.skip)
1143 }
1144
1145 fn push_byte(&mut self, b: u8) {
1146 self.flush_utf16();
1147 if self.skipping() {
1148 return;
1149 }
1150 self.doc.text.push(b as char);
1151 }
1152
1153 fn push_char(&mut self, c: char) {
1154 if self.skipping() {
1155 return;
1156 }
1157 self.doc.text.push(c);
1158 }
1159
1160 fn push_utf16(&mut self, unit: u16) {
1162 if self.skipping() {
1163 return;
1164 }
1165 self.pending_utf16.push(unit);
1166 let decoded: String = String::from_utf16_lossy(&self.pending_utf16);
1167 if !decoded.contains('\u{fffd}') || self.pending_utf16.len() >= 2 {
1170 self.doc.text.push_str(&decoded);
1171 self.pending_utf16.clear();
1172 }
1173 }
1174
1175 fn flush_utf16(&mut self) {
1176 if !self.pending_utf16.is_empty() {
1177 let s = String::from_utf16_lossy(&self.pending_utf16);
1178 self.pending_utf16.clear();
1179 if !self.skipping() {
1180 self.doc.text.push_str(&s);
1181 }
1182 }
1183 }
1184
1185 fn flush_run(&mut self) {
1187 self.flush_utf16();
1188 let end = self.doc.text.len();
1189 let run = self.state().run;
1190 if end > self.run_start && run != self.style.plain() {
1191 self.doc
1192 .runs
1193 .push(TextRun::styled(self.run_start..end, run));
1194 }
1195 self.run_start = end;
1196 }
1197
1198 fn skip_group(&mut self) {
1201 let mut depth = 1i32;
1202 while self.at < self.src.len() && depth > 0 {
1203 match self.src[self.at] {
1204 b'{' => depth += 1,
1205 b'}' => depth -= 1,
1206 b'\\' => self.at += 1,
1208 _ => {}
1209 }
1210 self.at += 1;
1211 }
1212 if self.stack.len() > 1 {
1213 self.stack.pop();
1214 }
1215 self.run_start = self.doc.text.len();
1216 }
1217
1218 fn control(&mut self) {
1219 self.at += 1;
1220 if self.at >= self.src.len() {
1221 return;
1222 }
1223 let b = self.src[self.at];
1224 if b == b'\'' {
1227 let hex = self
1228 .src
1229 .get(self.at + 1..self.at + 3)
1230 .and_then(|h| std::str::from_utf8(h).ok())
1231 .and_then(|h| u8::from_str_radix(h, 16).ok());
1232 self.at += 3.min(self.src.len() - self.at);
1233 if let Some(v) = hex {
1234 self.flush_utf16();
1235 self.push_char(v as char);
1236 }
1237 return;
1238 }
1239 if b == b'*' {
1244 self.at += 1;
1245 if let Some(st) = self.stack.last_mut() {
1246 st.skip = true;
1247 }
1248 return;
1249 }
1250 if !b.is_ascii_alphabetic() {
1251 self.at += 1;
1253 self.flush_utf16();
1254 self.push_char(b as char);
1255 return;
1256 }
1257 let word_start = self.at;
1258 while self.at < self.src.len() && self.src[self.at].is_ascii_alphabetic() {
1259 self.at += 1;
1260 }
1261 let word = std::str::from_utf8(&self.src[word_start..self.at])
1262 .unwrap_or("")
1263 .to_string();
1264 let num_start = self.at;
1266 if self.at < self.src.len() && (self.src[self.at] == b'-') {
1267 self.at += 1;
1268 }
1269 while self.at < self.src.len() && self.src[self.at].is_ascii_digit() {
1270 self.at += 1;
1271 }
1272 let param: Option<i64> = std::str::from_utf8(&self.src[num_start..self.at])
1273 .ok()
1274 .and_then(|s| s.parse().ok());
1275 if self.at < self.src.len() && self.src[self.at] == b' ' {
1276 self.at += 1;
1277 }
1278 self.word(&word, param);
1279 }
1280
1281 fn word(&mut self, word: &str, param: Option<i64>) {
1282 let styling = matches!(
1284 word,
1285 "b" | "i"
1286 | "ul"
1287 | "ulnone"
1288 | "uldb"
1289 | "uld"
1290 | "ulwave"
1291 | "strike"
1292 | "striked"
1293 | "fs"
1294 | "cf"
1295 | "highlight"
1296 | "f"
1297 | "plain"
1298 );
1299 if styling {
1300 self.flush_run();
1301 }
1302 let on = param != Some(0);
1303 let st = self.stack.last_mut();
1304 let Some(st) = st else { return };
1305 match word {
1306 "fonttbl" | "colortbl" | "stylesheet" | "info" | "generator" | "pict" | "object"
1308 | "listtable" | "listoverridetable" => st.skip = true,
1309 "b" => st.run.set_bold(on),
1310 "i" => st.run.set_italic(on),
1311 "ul" => {
1312 st.run.underline = if on {
1313 Underline::Single
1314 } else {
1315 Underline::None
1316 }
1317 }
1318 "ulnone" => st.run.underline = Underline::None,
1319 "uldb" => st.run.underline = Underline::Double,
1320 "uld" => st.run.underline = Underline::Dotted,
1321 "ulwave" => st.run.underline = Underline::Wavy,
1322 "strike" | "striked" => st.run.strikethrough = on,
1323 "plain" => st.run = self.style.plain(),
1324 "fs" => {
1325 if let Some(half) = param {
1326 let pts = half as f64 / 2.0;
1327 if (pts - self.style.base_points).abs() < 0.01 {
1328 st.run.font.style = self.style.base;
1329 st.run.font.scale = 1.0;
1330 } else {
1331 st.run.font.style = Font::System(pts);
1332 st.run.font.scale = 1.0;
1333 }
1334 }
1335 }
1336 "cf" => st.run.color = color_at(&self.colors, param),
1337 "highlight" => st.run.background = color_at(&self.colors, param),
1338 "f" => {
1339 st.run.font.monospace = param.is_some_and(|n| self.mono_fonts.contains(&(n as u32)))
1340 }
1341 "par" => {
1342 self.flush_run();
1343 self.push_char('\n');
1344 self.run_start = self.doc.text.len();
1345 self.close_paragraph();
1346 }
1347 "line" => {
1348 self.flush_run();
1349 self.push_char('\n');
1350 self.run_start = self.doc.text.len();
1351 }
1352 "tab" => {
1353 self.push_char('\t');
1354 }
1355 "bullet" => self.push_char('\u{2022}'),
1356 "emdash" => self.push_char('—'),
1357 "endash" => self.push_char('–'),
1358 "lquote" => self.push_char('\u{2018}'),
1359 "rquote" => self.push_char('\u{2019}'),
1360 "ldblquote" => self.push_char('\u{201c}'),
1361 "rdblquote" => self.push_char('\u{201d}'),
1362 "u" => {
1363 if let Some(n) = param {
1364 self.push_utf16(n as i16 as u16);
1365 if self.at < self.src.len() && self.src[self.at] == b'?' {
1367 self.at += 1;
1368 }
1369 }
1370 }
1371 "pard" => self.para = ParagraphStyle::default(),
1372 "ql" => self.para.align = ParagraphAlign::Natural,
1373 "qc" => self.para.align = ParagraphAlign::Center,
1374 "qr" => self.para.align = ParagraphAlign::Trailing,
1375 "qj" => self.para.align = ParagraphAlign::Justified,
1376 "li" => self.para.indent = param.unwrap_or(0) as f64 / 20.0,
1377 "sb" => self.para.space_before = param.unwrap_or(0) as f64 / 20.0,
1378 "sa" => self.para.space_after = param.unwrap_or(0) as f64 / 20.0,
1379 _ => {}
1380 }
1381 if word == "colortbl" {
1385 self.read_color_table();
1386 self.skip_group();
1387 }
1388 if word == "fonttbl" {
1389 self.read_font_table();
1390 self.skip_group();
1391 }
1392 }
1393
1394 fn read_color_table(&mut self) {
1397 let mut rgb = [0u8; 3];
1398 let mut seen = false;
1399 while self.at < self.src.len() && self.src[self.at] != b'}' {
1400 if self.src[self.at] == b'\\' {
1401 let start = self.at + 1;
1402 let mut e = start;
1403 while e < self.src.len() && self.src[e].is_ascii_alphabetic() {
1404 e += 1;
1405 }
1406 let word = std::str::from_utf8(&self.src[start..e]).unwrap_or("");
1407 let ns = e;
1408 while e < self.src.len() && self.src[e].is_ascii_digit() {
1409 e += 1;
1410 }
1411 let n: u8 = std::str::from_utf8(&self.src[ns..e])
1412 .ok()
1413 .and_then(|s| s.parse().ok())
1414 .unwrap_or(0);
1415 match word {
1416 "red" => {
1417 rgb[0] = n;
1418 seen = true;
1419 }
1420 "green" => {
1421 rgb[1] = n;
1422 seen = true;
1423 }
1424 "blue" => {
1425 rgb[2] = n;
1426 seen = true;
1427 }
1428 _ => {}
1429 }
1430 self.at = e;
1431 continue;
1432 }
1433 if self.src[self.at] == b';' {
1434 self.colors.push(if seen {
1435 Color::rgb(
1436 f64::from(rgb[0]) / 255.0,
1437 f64::from(rgb[1]) / 255.0,
1438 f64::from(rgb[2]) / 255.0,
1439 )
1440 } else {
1441 Color::BLACK
1442 });
1443 rgb = [0u8; 3];
1444 seen = false;
1445 }
1446 self.at += 1;
1447 }
1448 }
1449
1450 fn read_font_table(&mut self) {
1454 let mut depth = 1i32;
1455 let mut current: Option<u32> = None;
1456 let mut name = String::new();
1457 let from = self.at;
1458 let mut at = self.at;
1459 while at < self.src.len() && depth > 0 {
1460 match self.src[at] {
1461 b'{' => {
1462 depth += 1;
1463 at += 1;
1464 }
1465 b'}' | b';' => {
1466 if self.src[at] == b'}' {
1467 depth -= 1;
1468 }
1469 if let Some(i) = current.take()
1470 && is_mono_family(&name)
1471 {
1472 self.mono_fonts.push(i);
1473 }
1474 name.clear();
1475 at += 1;
1476 }
1477 b'\\' => {
1478 let s = at + 1;
1479 let mut e = s;
1480 while e < self.src.len() && self.src[e].is_ascii_alphabetic() {
1481 e += 1;
1482 }
1483 let word = std::str::from_utf8(&self.src[s..e]).unwrap_or("");
1484 let ns = e;
1485 while e < self.src.len() && self.src[e].is_ascii_digit() {
1486 e += 1;
1487 }
1488 let n: Option<u32> = std::str::from_utf8(&self.src[ns..e])
1489 .ok()
1490 .and_then(|s| s.parse().ok());
1491 match word {
1492 "f" => current = n,
1493 "fmodern" => {
1496 if let Some(i) = current {
1497 self.mono_fonts.push(i);
1498 }
1499 }
1500 _ => {}
1501 }
1502 at = e;
1503 if at < self.src.len() && self.src[at] == b' ' {
1504 at += 1;
1505 }
1506 }
1507 b => {
1508 name.push(b as char);
1509 at += 1;
1510 }
1511 }
1512 }
1513 self.at = from;
1515 }
1516
1517 fn close_paragraph(&mut self) {
1518 if self.doc.text.len() <= self.para_start {
1519 self.para_start = self.doc.text.len();
1520 return;
1521 }
1522 if self.para != ParagraphStyle::default() {
1523 self.doc.paragraphs.push(ParagraphRun::new(
1524 self.para_start..self.doc.text.len(),
1525 self.para,
1526 ));
1527 }
1528 self.para_start = self.doc.text.len();
1529 }
1530
1531 fn finish(mut self) -> StyledText {
1532 self.flush_run();
1533 self.close_paragraph();
1534 if self.doc.text.ends_with('\n') {
1536 self.doc.text.pop();
1537 }
1538 let base = self.style.base;
1539 self.doc.runs = coalesce_runs(std::mem::take(&mut self.doc.runs), base);
1540 self.doc.clamp();
1541 self.doc
1542 }
1543}
1544
1545fn color_at(colors: &[Color], param: Option<i64>) -> Option<Color> {
1546 let i = param?;
1550 if i <= 0 {
1551 return None;
1552 }
1553 colors.get(i as usize).copied()
1554}
1555
1556fn is_mono_family(name: &str) -> bool {
1557 let n = name.to_ascii_lowercase();
1558 ["courier", "consolas", "mono", "menlo", "monaco"]
1559 .iter()
1560 .any(|m| n.contains(m))
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565 use super::*;
1566
1567 fn style() -> DocStyle {
1568 DocStyle::new(Font::Body, 16.0)
1569 }
1570
1571 fn plain_doc(t: &str) -> StyledText {
1572 StyledText::plain(t)
1573 }
1574
1575 #[test]
1576 fn markdown_inline_round_trips() {
1577 let md = "Save **now** or *lose* it.";
1578 let doc = markdown_to_styled(md, style());
1579 assert_eq!(doc.text, "Save now or lose it.");
1580 assert!(doc.validate().is_ok());
1581 assert_eq!(styled_to_markdown(&doc, style()), md);
1582 }
1583
1584 #[test]
1585 fn markdown_headings_become_scaled_bold_runs() {
1586 let doc = markdown_to_styled("# Title\nbody", style());
1587 assert_eq!(doc.text, "Title\nbody");
1588 let s = doc.style_of(0..5, Font::Body);
1589 assert!(s.bold());
1590 assert_eq!(s.font.scale, 2.0);
1591 assert!(!doc.style_of(6..10, Font::Body).bold());
1592 assert_eq!(styled_to_markdown(&doc, style()), "# Title\nbody");
1593 }
1594
1595 #[test]
1596 fn markdown_lists_become_paragraph_runs() {
1597 let doc = markdown_to_styled("- one\n- two\n1. a\n2. b", style());
1598 assert_eq!(doc.text, "one\ntwo\na\nb");
1599 let kinds: Vec<_> = doc.paragraphs.iter().map(|p| p.list).collect();
1600 assert_eq!(
1601 kinds,
1602 vec![
1603 ListStyle::Bullet,
1604 ListStyle::Bullet,
1605 ListStyle::Ordered(1),
1606 ListStyle::Ordered(2)
1607 ]
1608 );
1609 assert_eq!(
1610 styled_to_markdown(&doc, style()),
1611 "- one\n- two\n1. a\n2. b"
1612 );
1613 }
1614
1615 #[test]
1616 fn markdown_quotes_indent() {
1617 let doc = markdown_to_styled("> quoted", style());
1618 assert_eq!(doc.text, "quoted");
1619 assert_eq!(doc.paragraphs[0].indent, 24.0);
1620 assert_eq!(styled_to_markdown(&doc, style()), "> quoted");
1621 }
1622
1623 #[test]
1624 fn markdown_escapes_what_would_reparse() {
1625 let doc = plain_doc("2 * 3 * 4 and _x_");
1626 let md = styled_to_markdown(&doc, style());
1627 assert_eq!(markdown_to_styled(&md, style()).text, doc.text);
1628 }
1629
1630 #[test]
1631 fn html_round_trips_the_attributes_it_can_write() {
1632 let mut doc = StyledText::plain("hello world");
1633 doc.apply(0..5, Font::Body, |s| {
1634 s.set_bold(true);
1635 s.underline = Underline::Single;
1636 s.color = Some(Color::hex(0xff0000));
1637 });
1638 doc.apply(6..11, Font::Body, |s| {
1639 s.strikethrough = true;
1640 s.background = Some(Color::hex(0x00ff00));
1641 });
1642 let html = styled_to_html(&doc, style());
1643 let back = html_to_styled(&html, style());
1644 assert_eq!(back.text, "hello world");
1645 let a = back.style_of(0..5, Font::Body);
1646 assert!(a.bold() && a.underline.is_on());
1647 assert_eq!(a.color, Some(Color::hex(0xff0000)));
1648 let b = back.style_of(6..11, Font::Body);
1649 assert!(b.strikethrough);
1650 assert_eq!(b.background, Some(Color::hex(0x00ff00)));
1651 }
1652
1653 #[test]
1654 fn html_paragraphs_and_headings() {
1655 let doc = html_to_styled(
1656 "<h2>Title</h2><p style=\"text-align:center\">mid</p>",
1657 style(),
1658 );
1659 assert_eq!(doc.text, "Title\nmid");
1660 assert_eq!(doc.style_of(0..5, Font::Body).font.scale, 1.5);
1661 assert_eq!(doc.paragraph_style_of(6..9).align, ParagraphAlign::Center);
1662 }
1663
1664 #[test]
1665 fn html_lists_number_themselves() {
1666 let doc = html_to_styled("<ol><li>a</li><li>b</li></ol>", style());
1667 assert_eq!(doc.text, "a\nb");
1668 assert_eq!(doc.paragraphs[0].list, ListStyle::Ordered(1));
1669 assert_eq!(doc.paragraphs[1].list, ListStyle::Ordered(2));
1670 }
1671
1672 #[test]
1673 fn html_ignores_scripts_and_unknown_tags() {
1674 let doc = html_to_styled(
1675 "<p>a<script>var x = '<b>';</script><weird>b</weird></p>",
1676 style(),
1677 );
1678 assert_eq!(doc.text, "ab", "script contents are not text");
1679 }
1680
1681 #[test]
1682 fn html_entities_and_multibyte_survive() {
1683 let doc = html_to_styled("<p>a & b é caf\u{e9} \u{1f600}</p>", style());
1684 assert_eq!(doc.text, "a & b é café 😀");
1685 assert!(doc.validate().is_ok());
1686 }
1687
1688 #[test]
1689 fn rtf_round_trips_every_attribute_day_writes() {
1690 let mut doc = StyledText::plain("bold italic under strike big red");
1691 doc.apply(0..4, Font::Body, |s| s.set_bold(true));
1692 doc.apply(5..11, Font::Body, |s| s.set_italic(true));
1693 doc.apply(12..17, Font::Body, |s| s.underline = Underline::Single);
1694 doc.apply(18..24, Font::Body, |s| s.strikethrough = true);
1695 doc.apply(25..28, Font::Body, |s| s.font.scale = 1.5);
1696 doc.apply(29..32, Font::Body, |s| s.color = Some(Color::hex(0xcc0000)));
1697
1698 let rtf = styled_to_rtf(&doc, style());
1699 let back = rtf_to_styled(&rtf, style());
1700 assert_eq!(back.text, doc.text);
1701 assert!(back.style_of(0..4, Font::Body).bold());
1702 assert!(back.style_of(5..11, Font::Body).font.italic);
1703 assert!(back.style_of(12..17, Font::Body).underline.is_on());
1704 assert!(back.style_of(18..24, Font::Body).strikethrough);
1705 assert_eq!(
1706 back.style_of(29..32, Font::Body).color,
1707 Some(Color::hex(0xcc0000))
1708 );
1709 let big = back.style_of(25..28, Font::Body);
1711 assert_eq!(big.font.resolved_points(16.0), 24.0);
1712 }
1713
1714 #[test]
1715 fn rtf_paragraph_attributes_round_trip() {
1716 let mut doc = StyledText::plain("one\ntwo");
1717 doc.apply_paragraph(0..1, |p| p.align = ParagraphAlign::Center);
1718 doc.apply_paragraph(5..5, |p| p.indent = 36.0);
1719 let back = rtf_to_styled(&styled_to_rtf(&doc, style()), style());
1720 assert_eq!(back.text, "one\ntwo");
1721 assert_eq!(back.paragraph_style_of(0..1).align, ParagraphAlign::Center);
1722 assert_eq!(back.paragraph_style_of(5..5).indent, 36.0);
1723 }
1724
1725 #[test]
1726 fn rtf_escapes_and_unicode_survive() {
1727 let doc = plain_doc("a{b}c\\d é 😀");
1728 let back = rtf_to_styled(&styled_to_rtf(&doc, style()), style());
1729 assert_eq!(back.text, "a{b}c\\d é 😀");
1730 }
1731
1732 #[test]
1733 fn rtf_skips_destinations_and_tables() {
1734 let rtf = "{\\rtf1\\ansi{\\fonttbl{\\f0\\fswiss Helvetica;}}{\\*\\expandedcolortbl;;}\
1737 {\\*\\generator Riched20;}\\fs32 hello\\par}";
1738 let doc = rtf_to_styled(rtf, style());
1739 assert_eq!(doc.text, "hello", "got {:?}", doc.text);
1740 }
1741
1742 #[test]
1743 fn every_codec_survives_hostile_input_without_panicking() {
1744 for bad in [
1745 "",
1746 "{",
1747 "}",
1748 "\\",
1749 "{\\rtf1\\ansi\\u",
1750 "{\\rtf1{{{{{{",
1751 "{\\rtf1\\'zz}",
1752 "{\\colortbl",
1753 "<",
1754 "<p",
1755 "<p style=",
1756 "<a href=",
1757 "&#",
1758 "&#xZZ;",
1759 "<script>",
1760 "**unclosed",
1761 "# ",
1762 "- ",
1763 "1. ",
1764 ] {
1765 let _ = rtf_to_styled(bad, style());
1766 let _ = html_to_styled(bad, style());
1767 let d = markdown_to_styled(bad, style());
1768 assert!(d.validate().is_ok(), "markdown {bad:?} produced bad ranges");
1769 }
1770 }
1771}