mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 13:27:27 -06:00
Every limit this branch added that leaves data out now says so: - font names over 256 bytes and links over 8 KiB (HTML, ODT, RTF, LaTeX, Markdown), and RTF style names cut to 256 bytes; - runs' links and fonts past the 64 MiB run-string budget; - spreadsheet rows refused for being over the text budget (ODS and Grid::push), once per sheet. model::dropped logs each kind once per imported document; each importer's entry point starts a new document with begin_import. Tests capture the warnings (crate::test_log) and check each is logged once, again for the next document, and not at all when nothing is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o
1005 lines
49 KiB
Rust
1005 lines
49 KiB
Rust
//! Round trips through every format, malformed input and fuzzing.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use wordcraft_doc::para::InlineObject;
|
|
use wordcraft_doc::{Align, Block, CharProps, Document, Paragraph, Table, para_block};
|
|
|
|
use crate::model::{self, FBlock, Kind};
|
|
use crate::{export, import};
|
|
|
|
/// A small PNG made with the image crate.
|
|
fn png() -> Vec<u8> {
|
|
let img = image::RgbaImage::from_fn(8, 4, |x, _| image::Rgba([(x * 30) as u8, 80, 160, 255]));
|
|
let mut buf = Vec::new();
|
|
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
|
|
buf
|
|
}
|
|
|
|
/// Headings, formatted runs, a link, nested lists, a table, a quote, code and a picture.
|
|
fn sample() -> Document {
|
|
let mut d = Document::new();
|
|
d.core.title = "Sample Title".into();
|
|
d.core.creator = "Ada Writer".into();
|
|
let mut blocks = Vec::new();
|
|
blocks.push(para_block(Paragraph::with_text("Main Heading", CharProps::default()).styled("Heading1")));
|
|
let mut p = Paragraph::with_text("Plain ", CharProps::default());
|
|
let n = p.len();
|
|
p.insert_text(n, "bold", &CharProps { bold: Some(true), ..Default::default() }).unwrap();
|
|
let n = p.len();
|
|
p.insert_text(n, " and ", &CharProps::default()).unwrap();
|
|
let n = p.len();
|
|
p.insert_text(n, "italic", &CharProps { italic: Some(true), ..Default::default() }).unwrap();
|
|
let n = p.len();
|
|
p.insert_text(n, " then ", &CharProps::default()).unwrap();
|
|
let n = p.len();
|
|
p.insert_text(n, "a link", &CharProps { link: Some("https://example.com/x".into()), ..Default::default() }).unwrap();
|
|
let n = p.len();
|
|
p.insert_text(n, ".", &CharProps::default()).unwrap();
|
|
blocks.push(para_block(p));
|
|
blocks.push(para_block(Paragraph::with_text("Second Level", CharProps::default()).styled("Heading2")));
|
|
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
|
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
|
for (t, nm, lvl) in [("first bullet", bul, 0u8), ("second bullet", bul, 0), ("nested bullet", bul, 1)] {
|
|
let mut q = Paragraph::with_text(t, CharProps::default()).styled("ListParagraph");
|
|
q.props.numbering = Some(wordcraft_doc::props::NumRef { num: nm, level: lvl });
|
|
blocks.push(para_block(q));
|
|
}
|
|
blocks.push(para_block(Paragraph::with_text("Between lists", CharProps::default())));
|
|
for t in ["step one", "step two"] {
|
|
let mut q = Paragraph::with_text(t, CharProps::default()).styled("ListParagraph");
|
|
q.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 });
|
|
blocks.push(para_block(q));
|
|
}
|
|
let mut t = Table::new(2, 2, 400.0);
|
|
for (r, c, s) in [(0, 0, "Name"), (0, 1, "Value"), (1, 0, "alpha"), (1, 1, "42")] {
|
|
t.rows[r].cells[c].blocks = vec![para_block(Paragraph::with_text(s, CharProps::default()))];
|
|
}
|
|
blocks.push(Arc::new(Block::Table(t)));
|
|
blocks.push(para_block(Paragraph::with_text("A wise quote", CharProps::default()).styled("Quote")));
|
|
let mut c = Paragraph::with_text("Centered text", CharProps::default());
|
|
c.props.align = Some(Align::Center);
|
|
blocks.push(para_block(c));
|
|
let key = d.add_media(png(), "png");
|
|
let mut ip = Paragraph::with_text("Picture: ", CharProps::default());
|
|
let n = ip.len();
|
|
ip.insert_object(
|
|
n,
|
|
InlineObject::Image { media: key, w: 60.0, h: 30.0, alt: "tiny".into(), float: Default::default(), crop: [0.0; 4], ole: None },
|
|
&CharProps::default(),
|
|
)
|
|
.unwrap();
|
|
blocks.push(para_block(ip));
|
|
blocks.push(para_block(Paragraph::with_text("The end", CharProps::default())));
|
|
d.body = blocks;
|
|
d
|
|
}
|
|
|
|
fn flat(doc: &Document) -> Vec<FBlock> {
|
|
model::from_doc(doc).blocks
|
|
}
|
|
|
|
fn para_texts(doc: &Document) -> Vec<String> {
|
|
fn walk(b: &[FBlock], out: &mut Vec<String>) {
|
|
for x in b {
|
|
match x {
|
|
FBlock::Para(p) => {
|
|
let t = p.text();
|
|
if !t.trim().is_empty() {
|
|
out.push(t.trim().to_string());
|
|
}
|
|
}
|
|
FBlock::Table(t) => {
|
|
for r in &t.rows {
|
|
for c in r {
|
|
walk(&c.blocks, out);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut v = Vec::new();
|
|
walk(&flat(doc), &mut v);
|
|
v
|
|
}
|
|
|
|
fn find_para<'a>(b: &'a [FBlock], text: &str) -> Option<&'a model::Para> {
|
|
b.iter().find_map(|x| match x {
|
|
FBlock::Para(p) if p.text().trim() == text => Some(p),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
fn check_round_trip(ext: &str, rich: bool) {
|
|
let d = sample();
|
|
let bytes = export(ext, &d).expect("handled").expect("export");
|
|
let back = import(ext, &bytes).expect("handled").unwrap_or_else(|e| panic!("{ext}: {e}"));
|
|
let texts = para_texts(&back);
|
|
for want in [
|
|
"Main Heading",
|
|
"Second Level",
|
|
"first bullet",
|
|
"second bullet",
|
|
"nested bullet",
|
|
"step one",
|
|
"step two",
|
|
"Name",
|
|
"Value",
|
|
"alpha",
|
|
"42",
|
|
"A wise quote",
|
|
"Centered text",
|
|
"The end",
|
|
] {
|
|
assert!(texts.iter().any(|t| if rich { t.ends_with(want) } else { t.contains(want) }), "{ext}: missing {want:?} in {texts:?}");
|
|
}
|
|
let joined = texts.join("\n");
|
|
assert!(joined.contains("Plain bold and italic then a link."), "{ext}: {joined}");
|
|
if !rich {
|
|
return;
|
|
}
|
|
let b = flat(&back);
|
|
assert_eq!(find_para(&b, "Main Heading").map(|p| p.kind), Some(Kind::Heading(1)), "{ext}");
|
|
assert_eq!(find_para(&b, "Second Level").map(|p| p.kind), Some(Kind::Heading(2)), "{ext}");
|
|
assert_eq!(find_para(&b, "A wise quote").map(|p| p.kind), Some(Kind::Quote), "{ext}");
|
|
if ext != "md" {
|
|
assert_eq!(find_para(&b, "Centered text").and_then(|p| p.align), Some(Align::Center), "{ext}");
|
|
}
|
|
let fb = find_para(&b, "first bullet").and_then(|p| p.list).expect("list");
|
|
assert!(!fb.ordered && fb.level == 0, "{ext}");
|
|
let nb = find_para(&b, "nested bullet").and_then(|p| p.list).expect("nested");
|
|
assert_eq!(nb.level, 1, "{ext}");
|
|
let s1 = find_para(&b, "step one").and_then(|p| p.list).expect("numbered");
|
|
assert!(s1.ordered, "{ext}");
|
|
assert!(find_para(&b, "Between lists").is_some_and(|p| p.list.is_none()), "{ext}");
|
|
let body = find_para(&b, "Plain bold and italic then a link.").expect("para");
|
|
let has = |t: &str, f: &dyn Fn(&model::Fmt) -> bool| body.inlines.iter().any(|i| matches!(i, model::Inline::Text(x, fm) if x == t && f(fm)));
|
|
assert!(has("bold", &|f| f.bold && !f.italic), "{ext}: {:?}", body.inlines);
|
|
assert!(has("italic", &|f| f.italic && !f.bold), "{ext}");
|
|
assert!(has("a link", &|f| f.link.as_deref() == Some("https://example.com/x")), "{ext}: {:?}", body.inlines);
|
|
let table = b.iter().find_map(|x| if let FBlock::Table(t) = x { Some(t) } else { None }).expect("table");
|
|
assert_eq!(table.rows.len(), 2, "{ext}");
|
|
assert_eq!(table.rows[1].iter().map(|c| c.text()).collect::<Vec<_>>(), vec!["alpha", "42"], "{ext}");
|
|
}
|
|
|
|
/// Two bullets directly followed by two numbered items.
|
|
fn bullets_then_numbers() -> Document {
|
|
let mut d = Document::new();
|
|
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
|
let num = d.numbering.add_list(wordcraft_doc::ListKind::Numbered);
|
|
let mut blocks = Vec::new();
|
|
for (t, nm) in [("dot one", bul), ("dot two", bul), ("num one", num), ("num two", num)] {
|
|
let mut q = Paragraph::with_text(t, CharProps::default()).styled("ListParagraph");
|
|
q.props.numbering = Some(wordcraft_doc::props::NumRef { num: nm, level: 0 });
|
|
blocks.push(para_block(q));
|
|
}
|
|
d.body = blocks;
|
|
d
|
|
}
|
|
|
|
#[test]
|
|
fn numbered_list_after_bullets_keeps_its_kind() {
|
|
let d = bullets_then_numbers();
|
|
for ext in ["odt", "rtf"] {
|
|
let bytes = export(ext, &d).expect("handled").expect("export");
|
|
let back = import(ext, &bytes).expect("handled").expect("import");
|
|
let b = flat(&back);
|
|
let kind = |t: &str| find_para(&b, t).and_then(|p| p.list).map(|l| l.ordered);
|
|
assert_eq!(kind("dot two"), Some(false), "{ext}");
|
|
assert_eq!(kind("num one"), Some(true), "{ext}");
|
|
assert_eq!(kind("num two"), Some(true), "{ext}");
|
|
}
|
|
let md = String::from_utf8(export("md", &d).expect("handled").expect("export")).unwrap();
|
|
assert!(md.contains("1. num one") && md.contains("2. num two"), "{md}");
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_list_items_that_start_like_blocks_keep_their_text() {
|
|
for text in ["---", "--", "1. Intro", "# Agenda", "ordinary item"] {
|
|
let mut d = Document::new();
|
|
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
|
let mut p = Paragraph::with_text(text, CharProps::default()).styled("ListParagraph");
|
|
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: bul, level: 0 });
|
|
d.body = vec![para_block(p)];
|
|
let bytes = export("md", &d).unwrap().unwrap();
|
|
let blocks = flat(&import("md", &bytes).unwrap().unwrap());
|
|
let [FBlock::Para(p)] = blocks.as_slice() else { panic!("{text}: {}", String::from_utf8_lossy(&bytes)) };
|
|
assert_eq!((p.text().as_str(), p.kind, p.list), (text, Kind::Normal, Some(model::ListInfo { ordered: false, level: 0, start: 1 })));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_round_trip() {
|
|
check_round_trip("md", true);
|
|
}
|
|
|
|
/// A code line of backticks must not be mistaken for the closing fence when the file comes
|
|
/// back: the writer's fence has to be one backtick longer than any content line.
|
|
#[test]
|
|
fn markdown_code_fence_outgrows_its_content() {
|
|
let line = "`".repeat(100);
|
|
let doc = import("md", format!(" {line}\n").as_bytes()).unwrap().unwrap();
|
|
assert_eq!(doc.plain_text(Default::default()).trim(), line);
|
|
let out = export("md", &doc).unwrap().unwrap();
|
|
let text = String::from_utf8(out).unwrap();
|
|
assert!(text.contains(&"`".repeat(101)), "the fence is longer than the content: {text}");
|
|
let back = import("md", text.as_bytes()).unwrap().unwrap();
|
|
assert_eq!(back.plain_text(Default::default()).trim(), line, "the code line survives a round trip");
|
|
}
|
|
|
|
#[test]
|
|
fn html_round_trip() {
|
|
check_round_trip("html", true);
|
|
let html = String::from_utf8(export("html", &sample()).unwrap().unwrap()).unwrap();
|
|
assert!(html.contains("data:image/png;base64,"));
|
|
let back = import("html", html.as_bytes()).unwrap().unwrap();
|
|
assert_eq!(back.media.len(), 1, "picture survives");
|
|
assert_eq!(back.core.title, "Sample Title");
|
|
}
|
|
|
|
#[test]
|
|
fn html_ruby_round_trip() {
|
|
// #1681: a phonetic guide exports as `<ruby>` and opens back as one ruby object, its base
|
|
// text still in the paragraph.
|
|
let props = CharProps::default();
|
|
let mut p = Paragraph::with_text("I read ", props.clone());
|
|
let n = p.len();
|
|
p.insert_object(n, model::ruby_object("漢字".into(), "かんじ".into(), 20.0), &props).unwrap();
|
|
let n = p.len();
|
|
p.insert_text(n, " daily.", &props).unwrap();
|
|
let mut d = Document::new();
|
|
d.body = vec![para_block(p)];
|
|
|
|
let html = String::from_utf8(export("html", &d).unwrap().unwrap()).unwrap();
|
|
assert!(html.contains("<ruby>漢字<rt>かんじ</rt></ruby>"), "{html}");
|
|
let back = import("html", html.as_bytes()).unwrap().unwrap();
|
|
assert_eq!(back.plain_text(Default::default()), "I read 漢字 daily.");
|
|
let ruby = back.body.iter().find_map(|b| match &**b {
|
|
Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
|
InlineObject::Ruby { base, ruby, .. } => Some((base.as_str(), ruby.as_str())),
|
|
_ => None,
|
|
}),
|
|
_ => None,
|
|
});
|
|
assert_eq!(ruby, Some(("漢字", "かんじ")));
|
|
}
|
|
|
|
#[test]
|
|
fn html_ruby_at_the_paragraph_start_keeps_a_leading_space() {
|
|
// #1681: the ruby's base text goes through the text escaper with the paragraph-start state
|
|
// taken before its formatting tags, so a leading space becomes ` ` instead of a space
|
|
// HTML may collapse.
|
|
let props = CharProps { bold: Some(true), ..Default::default() };
|
|
let mut p = Paragraph::with_text("", CharProps::default());
|
|
let n = p.len();
|
|
p.insert_object(n, model::ruby_object(" 漢字".into(), "かんじ".into(), 20.0), &props).unwrap();
|
|
let mut d = Document::new();
|
|
d.body = vec![para_block(p)];
|
|
|
|
let html = String::from_utf8(export("html", &d).unwrap().unwrap()).unwrap();
|
|
assert!(html.contains("<strong><ruby> 漢字<rt>かんじ</rt></ruby></strong>"), "{html}");
|
|
}
|
|
|
|
#[test]
|
|
fn rtf_round_trip() {
|
|
check_round_trip("rtf", true);
|
|
let back = import("rtf", &export("rtf", &sample()).unwrap().unwrap()).unwrap().unwrap();
|
|
assert_eq!(back.media.len(), 1, "picture survives");
|
|
assert_eq!(back.core.title, "Sample Title");
|
|
assert_eq!(back.core.creator, "Ada Writer");
|
|
}
|
|
|
|
#[test]
|
|
fn odt_round_trip() {
|
|
check_round_trip("odt", true);
|
|
let bytes = export("odt", &sample()).unwrap().unwrap();
|
|
assert!(bytes.starts_with(b"PK"));
|
|
assert_eq!(bytes.get(30..38), Some(&b"mimetype"[..]), "mimetype is the first entry");
|
|
let back = import("odt", &bytes).unwrap().unwrap();
|
|
assert_eq!(back.media.len(), 1, "picture survives");
|
|
assert_eq!(back.core.title, "Sample Title");
|
|
assert_eq!(back.core.creator, "Ada Writer");
|
|
}
|
|
|
|
#[test]
|
|
fn latex_round_trip() {
|
|
check_round_trip("tex", true);
|
|
let tex = String::from_utf8(export("tex", &sample()).unwrap().unwrap()).unwrap();
|
|
assert!(tex.starts_with("% Written by WordCraft") && tex.contains("\\documentclass{article}"), "{tex}");
|
|
assert!(tex.contains("\\section*{Main Heading}") && tex.contains("\\subsection*{Second Level}"), "{tex}");
|
|
assert!(tex.contains("\\textbf{bold}") && tex.contains("\\textit{italic}"), "{tex}");
|
|
assert!(tex.contains("\\href{https://example.com/x}{a link}"), "{tex}");
|
|
assert!(tex.contains("\\begin{itemize}") && tex.contains("\\begin{enumerate}") && tex.contains("\\begin{quote}"), "{tex}");
|
|
assert!(tex.contains("\\begin{tabular}") && tex.contains("\\begin{center}"), "{tex}");
|
|
assert!(tex.contains("\\textit{[Picture: tiny]}"), "pictures leave their alternative text: {tex}");
|
|
assert!(tex.contains("pdftitle={Sample Title}") && tex.contains("pdfauthor={Ada Writer}"), "{tex}");
|
|
assert!(tex.trim_end().ends_with("\\end{document}"));
|
|
// Exported LaTeX parses back to the same metadata.
|
|
let back = import("tex", tex.as_bytes()).unwrap().unwrap();
|
|
assert_eq!(back.core.title, "", "pdftitle isn't \\title");
|
|
for ext in ["tex", "latex", "ltx", ".TEX"] {
|
|
assert!(import(ext, b"x").is_some() && export(ext, &Document::new()).is_some(), "{ext}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn page_break_char_splits_paragraph() {
|
|
let mut d = Document::new();
|
|
d.body = vec![para_block(Paragraph::with_text("num two\u{c}Page two", CharProps::default()))];
|
|
let b = flat(&d);
|
|
assert!(matches!(&b[..], [FBlock::Para(a), FBlock::Para(c)] if a.text() == "num two" && !a.page_break && c.text() == "Page two" && c.page_break));
|
|
let txt = String::from_utf8(export("txt", &d).unwrap().unwrap()).unwrap();
|
|
assert!(!txt.contains("twoPage"), "{txt:?}");
|
|
let odt = import("odt", &export("odt", &d).unwrap().unwrap()).unwrap().unwrap();
|
|
let b = flat(&odt);
|
|
assert!(b.iter().any(|x| matches!(x, FBlock::Para(p) if p.text().trim() == "Page two" && p.page_break)));
|
|
}
|
|
|
|
#[test]
|
|
fn txt_round_trip() {
|
|
check_round_trip("txt", false);
|
|
let d = import("txt", "a\r\nb\nc".as_bytes()).unwrap().unwrap();
|
|
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body), "a\nb\nc");
|
|
// UTF-16 LE with BOM, UTF-16 without BOM, Windows-1252.
|
|
let mut u16le = vec![0xFF, 0xFE];
|
|
for u in "héllo".encode_utf16() {
|
|
u16le.extend(u.to_le_bytes());
|
|
}
|
|
assert_eq!(import("txt", &u16le).unwrap().unwrap().plain_text(Default::default()), "héllo");
|
|
let raw: Vec<u8> = "hello world".encode_utf16().flat_map(|u| u.to_be_bytes()).collect();
|
|
assert_eq!(import("txt", &raw).unwrap().unwrap().plain_text(Default::default()), "hello world");
|
|
assert_eq!(import("txt", &[0x63, 0x61, 0x66, 0xE9, 0x80]).unwrap().unwrap().plain_text(Default::default()), "café€");
|
|
}
|
|
|
|
#[test]
|
|
fn txt_export_writes_symbol_font_text_as_unicode() {
|
|
let mut d = Document::from_text("Done J\u{F0FC}");
|
|
d.format_range(&wordcraft_doc::Pos::body(0, 5), &wordcraft_doc::Pos::body(0, 9), &|c| c.font = Some("Wingdings".into())).unwrap();
|
|
let out = export("txt", &d).unwrap().unwrap();
|
|
assert_eq!(String::from_utf8(out).unwrap(), "Done \u{263A}\u{2713}");
|
|
}
|
|
|
|
/// #1080: `txt` export (and so `wordcraft-cli text`) keeps list labels, so a list item reads
|
|
/// apart from an ordinary paragraph.
|
|
#[test]
|
|
fn txt_export_keeps_list_labels() {
|
|
let mut d = Document::new();
|
|
let bul = d.numbering.add_list(wordcraft_doc::ListKind::Bullet);
|
|
let mut p = Paragraph::with_text("alpha", CharProps::default());
|
|
p.props.numbering = Some(wordcraft_doc::props::NumRef { num: bul, level: 0 });
|
|
d.body = vec![para_block(p), para_block(Paragraph::with_text("plain", CharProps::default()))];
|
|
let out = String::from_utf8(export("txt", &d).unwrap().unwrap()).unwrap();
|
|
let mut lines = out.split("\r\n");
|
|
assert!(lines.next().is_some_and(|l| l.contains("alpha") && !l.starts_with("alpha")), "{out:?}");
|
|
assert_eq!(lines.next(), Some("plain"));
|
|
}
|
|
|
|
/// #569: a file with an extension we don't know opens as text when it is text (a playlist), and
|
|
/// a binary file is told apart.
|
|
#[test]
|
|
fn text_sniffing() {
|
|
use crate::txt::is_text;
|
|
assert!(is_text(b""));
|
|
assert!(is_text(b"#EXTM3U\r\n#EXTINF:123,Artist - Title\r\nC:\\Music\\song.mp3\r\n"));
|
|
assert!(is_text("#EXTM3U\n#EXTINF:-1,Мелодия — ünïcødé\nhttp://x/y.mp3\n".as_bytes()));
|
|
assert!(is_text(&[0x23, 0x45, 0x58, 0x54, 0xE9, 0x0D, 0x0A, 0x1A])); // Windows-1252, DOS ^Z
|
|
assert!(is_text(&[0xEF, 0xBB, 0xBF, b'a']));
|
|
let u16le: Vec<u8> = "#EXTM3U\r\n".encode_utf16().flat_map(|u| u.to_le_bytes()).collect();
|
|
assert!(is_text(&u16le));
|
|
assert!(!is_text(b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR"));
|
|
assert!(!is_text(b"PK\x03\x04\x14\0\x06\0"));
|
|
assert!(!is_text(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n"));
|
|
assert!(!is_text(&[0x7F, b'E', b'L', b'F', 2, 1, 1, 3, 4, 5, 6, 7, 8]));
|
|
// Odd-length bytes with zeros mixed in: binary, not UTF-16.
|
|
assert!(!is_text(&[1, 0, 2, 0, 3]));
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_unknown() {
|
|
assert!(import("xyz", b"").is_none());
|
|
assert!(export("docx", &Document::new()).is_none());
|
|
assert!(import(".MD", b"# x").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn rtf_merged_cells_and_lists() {
|
|
let mut d = Document::new();
|
|
let mut t = Table::new(3, 2, 300.0);
|
|
t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("tall", CharProps::default()))];
|
|
t.rows[0].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Restart;
|
|
t.rows[1].cells[0].props.vmerge = wordcraft_doc::props::VMerge::Continue;
|
|
d.body = vec![Arc::new(Block::Table(t))];
|
|
d.ensure_nonempty();
|
|
for ext in ["rtf", "html", "odt"] {
|
|
let back = import(ext, &export(ext, &d).unwrap().unwrap()).unwrap().unwrap();
|
|
let Some(Block::Table(bt)) = back.body.first().map(|b| &**b) else { panic!("{ext}: no table") };
|
|
assert_eq!(bt.rows.len(), 3, "{ext}");
|
|
assert_eq!(bt.rows[0].cells[0].props.vmerge, wordcraft_doc::props::VMerge::Restart, "{ext}");
|
|
assert_eq!(bt.rows[1].cells[0].props.vmerge, wordcraft_doc::props::VMerge::Continue, "{ext}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_text_shapes() {
|
|
let md = String::from_utf8(export("md", &sample()).unwrap().unwrap()).unwrap();
|
|
assert!(md.contains("# Main Heading"));
|
|
assert!(md.contains("**bold**") && md.contains("*italic*"));
|
|
assert!(md.contains("[a link](https://example.com/x)"));
|
|
assert!(md.contains("- first bullet") && md.contains(" - nested bullet") && md.contains("1. step one"));
|
|
assert!(md.contains("| Name | Value |"));
|
|
assert!(md.contains("> A wise quote"));
|
|
assert!(md.contains(");
|
|
}
|
|
|
|
#[test]
|
|
fn code_blocks_and_rules() {
|
|
let d = import("md", b"```\nfn main() {}\n indented\n```\n\n***\n\nafter").unwrap().unwrap();
|
|
let b = flat(&d);
|
|
assert!(matches!(&b[0], FBlock::Para(p) if p.kind == Kind::Code && p.text() == "fn main() {}"));
|
|
assert!(matches!(&b[1], FBlock::Para(p) if p.kind == Kind::Code && p.text() == " indented"));
|
|
assert!(matches!(&b[2], FBlock::Para(p) if p.kind == Kind::Rule));
|
|
for ext in ["md", "html", "rtf", "odt", "tex"] {
|
|
let back = import(ext, &export(ext, &d).unwrap().unwrap()).unwrap().unwrap();
|
|
let bb = flat(&back);
|
|
assert!(bb.iter().any(|x| matches!(x, FBlock::Para(p) if p.kind == Kind::Code && p.text() == " indented")), "{ext}: {bb:?}");
|
|
assert!(bb.iter().any(|x| matches!(x, FBlock::Para(p) if p.kind == Kind::Rule)), "{ext}: {bb:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deep_nesting_is_bounded() {
|
|
let html = "<div><table><tr><td>".repeat(5000) + "x";
|
|
let d = import("html", html.as_bytes()).unwrap().unwrap();
|
|
assert!(d.plain_text(Default::default()).contains('x'));
|
|
let rtf = format!("{{\\rtf1 {}x{}}}", "{\\b ".repeat(100_000), "}".repeat(100_000));
|
|
let d = import("rtf", rtf.as_bytes()).unwrap().unwrap();
|
|
assert!(d.plain_text(Default::default()).contains('x'));
|
|
let md = ">".repeat(10_000) + " deep";
|
|
assert!(import("md", md.as_bytes()).unwrap().is_ok());
|
|
let md = (0..200).map(|i| format!("{}- item", " ".repeat(i))).collect::<Vec<_>>().join("\n");
|
|
assert!(import("md", md.as_bytes()).unwrap().is_ok());
|
|
let md = "[".repeat(5000) + &"](x)".repeat(5000);
|
|
assert!(import("md", md.as_bytes()).unwrap().is_ok());
|
|
let md = "*a ".repeat(20_000);
|
|
assert!(import("md", md.as_bytes()).unwrap().is_ok());
|
|
let tex = "{\\textbf{".repeat(50_000) + "x";
|
|
assert!(import("tex", tex.as_bytes()).unwrap().unwrap().plain_text(Default::default()).contains('x'));
|
|
let tex = "\\begin{itemize}\\item ".repeat(10_000) + "deep";
|
|
assert!(import("tex", tex.as_bytes()).unwrap().unwrap().plain_text(Default::default()).contains("deep"));
|
|
let tex = "\\begin{tabular}{l}".repeat(2_000) + "cell";
|
|
assert!(import("tex", tex.as_bytes()).unwrap().is_ok());
|
|
let tex = format!("${}x{}$", "\\frac{".repeat(10_000), "}".repeat(10_000));
|
|
assert!(import("tex", tex.as_bytes()).unwrap().is_ok());
|
|
// `*{n}{…}` column specs: nested 100k deep (the repeat recursed without a limit and overflowed
|
|
// the stack), and repeats multiplying past what a table holds (64^30 columns).
|
|
let tex = format!("\\begin{{tabular}}{{{}l{}}}x\\end{{tabular}}", "*{1}{".repeat(100_000), "}".repeat(100_000));
|
|
assert!(import("tex", tex.as_bytes()).unwrap().unwrap().plain_text(Default::default()).contains('x'));
|
|
let tex = format!("\\begin{{tabular}}{{{}p{{1cm}}{}}}x\\end{{tabular}}", "*{64}{".repeat(30), "}".repeat(30));
|
|
assert!(import("tex", tex.as_bytes()).unwrap().unwrap().plain_text(Default::default()).contains('x'));
|
|
// An equation of unmatched `(`: finding each group rescanned to the end of the input, so
|
|
// exporting it was quadratic (50k took seconds; 200k would take minutes).
|
|
let started = std::time::Instant::now();
|
|
let latex = crate::latex::linear_to_latex(&"(".repeat(200_000));
|
|
assert_eq!(latex.len(), 200_000);
|
|
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
|
// Brackets that do match, nested 100k deep: each group was copied out before looking for
|
|
// the `/` of a fraction, which was quadratic as well.
|
|
let started = std::time::Instant::now();
|
|
let nested = format!("{}x{}", "(".repeat(100_000), ")".repeat(100_000));
|
|
assert_eq!(crate::latex::linear_to_latex(&nested).len(), 200_001);
|
|
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
|
assert_eq!(crate::latex::linear_to_latex("(a)/(b)"), "\\frac{a}{b}");
|
|
assert_eq!(crate::latex::linear_to_latex("((a)/(b))/(c)"), "\\frac{\\frac{a}{b}}{c}");
|
|
// `\begin{` never closed: the name was looked for through the whole rest of the file.
|
|
let started = std::time::Instant::now();
|
|
let tex = "\\begin{".repeat(300_000);
|
|
assert!(import("tex", tex.as_bytes()).unwrap().is_ok());
|
|
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
|
}
|
|
|
|
/// What a document says in an equation or a bookmark name is not written to the exported file as
|
|
/// TeX commands: compiling the export must not read files or run programs the document names.
|
|
#[test]
|
|
fn latex_export_carries_no_commands_from_the_document() {
|
|
let tex = crate::latex::linear_to_latex("\\input secret.txt \\immediate\\write18\\bgroup id\\egroup \\csname x\\endcsname");
|
|
for cmd in ["\\input", "\\immediate", "\\write", "\\bgroup", "\\egroup", "\\csname", "\\endcsname"] {
|
|
assert!(!tex.contains(cmd), "{cmd} in {tex}");
|
|
}
|
|
assert!(tex.contains("\\backslash input"), "{tex}");
|
|
// `^^5c` is how TeX spells a backslash: two carets never come out side by side.
|
|
for linear in ["a^^5cinput secret.txt ", "a^^^^5cinput", "x^(^^5cinput)", "^^5cinput"] {
|
|
assert!(!crate::latex::linear_to_latex(linear).contains("^^"), "{linear}");
|
|
}
|
|
let deep = format!("{}^^5cinput x{}", "x^(".repeat(40), ")".repeat(40));
|
|
assert!(!crate::latex::linear_to_latex(&deep).contains("^^"));
|
|
// Symbols, functions and font switches are still commands.
|
|
assert_eq!(crate::latex::linear_to_latex("\\alpha+\\sin x+\\mathbb R"), "\\alpha+\\sin x+\\mathbb R");
|
|
assert_eq!(crate::latex::linear_to_latex("x^2^3"), "x^2^3");
|
|
// The same through a whole file, and for a bookmark name.
|
|
let d = crate::latex::import("Text $\\input secret.txt $ and \\hypertarget{^^5cinput secret.txt ^^5c}{here}.".as_bytes());
|
|
let out = String::from_utf8(export("tex", &d).unwrap().unwrap()).unwrap();
|
|
assert!(!out.contains("\\input") && !out.contains("^^"), "{out}");
|
|
}
|
|
|
|
/// Many labels in a row, with or without spaces between, are read in linear time (looking back
|
|
/// past them for the last text made a megabyte of labels take tens of seconds), and labels
|
|
/// separated by text are all kept.
|
|
#[test]
|
|
fn latex_labels_in_a_row_are_read_in_linear_time() {
|
|
let started = std::time::Instant::now();
|
|
for src in ["\\label{a} ".repeat(100_000) + "x", "\\label{a}".repeat(100_000) + "x", "\\hypertarget{a}{} ".repeat(100_000)] {
|
|
let d = crate::latex::import(src.as_bytes());
|
|
assert!(d.body.len() <= 2);
|
|
}
|
|
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
|
let d = crate::latex::import("\\label{one}First \\label{two}second \\label{three}third.".as_bytes());
|
|
let p = d.para_at(&wordcraft_doc::Pos::body(0, 0)).unwrap();
|
|
let names: Vec<&str> = p
|
|
.objects
|
|
.iter()
|
|
.filter_map(|o| if let wordcraft_doc::InlineObject::BookmarkStart { name } = o { Some(name.as_str()) } else { None })
|
|
.collect();
|
|
assert_eq!(names, ["one", "two", "three"]);
|
|
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body).replace('\u{FFFC}', ""), "First second third.");
|
|
}
|
|
|
|
#[test]
|
|
fn garbage_never_panics() {
|
|
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
|
for len in [0usize, 1, 7, 64, 513, 4096] {
|
|
let bytes: Vec<u8> = (0..len)
|
|
.map(|_| {
|
|
seed ^= seed << 13;
|
|
seed ^= seed >> 7;
|
|
seed ^= seed << 17;
|
|
seed as u8
|
|
})
|
|
.collect();
|
|
for ext in ["txt", "md", "html", "rtf", "odt", "tex"] {
|
|
let _ = import(ext, &bytes);
|
|
}
|
|
let mut rtf = b"{\\rtf1".to_vec();
|
|
rtf.extend(&bytes);
|
|
let _ = import("rtf", &rtf);
|
|
let mut odt = b"PK\x03\x04".to_vec();
|
|
odt.extend(&bytes);
|
|
let _ = import("odt", &odt);
|
|
}
|
|
// A zip that is not an ODF text document.
|
|
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
|
z.start_file("mimetype", zip::write::SimpleFileOptions::default()).unwrap();
|
|
std::io::Write::write_all(&mut z, b"application/zip").unwrap();
|
|
let bytes = z.finish().unwrap().into_inner();
|
|
assert!(import("odt", &bytes).unwrap().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_rtf_bits() {
|
|
for s in [
|
|
&b"{\\rtf1 \\u-1? \\u99999999999? \\'zz \\bin999999999 }"[..],
|
|
b"{\\rtf1{\\pict\\pngblip 89504e47zz}}",
|
|
b"{\\rtf1\\trowd\\cellx-5 \\intbl a\\cell\\row\\row\\cell}",
|
|
b"{\\rtf1 }}}}}}{{{{",
|
|
b"{\\rtf1\\fs-99999 \\cf999 \\f-3 x\\par}",
|
|
] {
|
|
let _ = import("rtf", s).unwrap();
|
|
}
|
|
// #1653: a flood of multibyte code-page bytes (leads and trails, raw and escaped) decodes as a
|
|
// stream and never panics or grows a buffer with the input.
|
|
let mut rtf = b"{\\rtf1\\ansi\\ansicpg932 ".to_vec();
|
|
rtf.resize(rtf.len() + 200_000, 0x93);
|
|
rtf.extend_from_slice(b"\\'fa\\par\\'93");
|
|
assert!(import("rtf", &rtf).unwrap().is_ok());
|
|
}
|
|
|
|
proptest::proptest! {
|
|
#![proptest_config(proptest::prelude::ProptestConfig { cases: 128, ..Default::default() })]
|
|
|
|
#[test]
|
|
fn fuzz_markdown(s in "[ -~\\n\\t*_`#>|\\[\\]()!~-]{0,300}") {
|
|
let d = import("md", s.as_bytes()).unwrap().unwrap();
|
|
let _ = export("md", &d).unwrap().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn fuzz_html(s in "(<(/)?(p|b|i|ul|ol|li|table|tr|td|th|pre|blockquote|a href=x|img src=data:,|br|h2|span style='color:red')>|[a-z &;#<>\"'=]){0,120}") {
|
|
let d = import("html", s.as_bytes()).unwrap().unwrap();
|
|
let _ = export("html", &d).unwrap().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn fuzz_rtf(s in "(\\{|\\}|\\\\[a-z]{1,8}-?[0-9]{0,4} ?|\\\\'[0-9a-f]{2}|\\\\\\*|[a-z ;]){0,150}") {
|
|
let src = format!("{{\\rtf1 {s}");
|
|
let d = import("rtf", src.as_bytes()).unwrap().unwrap();
|
|
let _ = export("rtf", &d).unwrap().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn fuzz_latex(s in "(\\\\(begin|end)\\{(itemize|tabular|verbatim|quote|center|x)\\}|\\\\[a-zA-Z]{1,8}\\*?|\\\\[^a-zA-Z]|[{}\\[\\]$&%~^_#]|\n\n|[a-z ]){0,150}") {
|
|
let d = import("tex", s.as_bytes()).unwrap().unwrap();
|
|
let _ = export("tex", &d).unwrap().unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn latex_text_round_trips(words in proptest::collection::vec("[a-zA-Z0-9{}$&%#_~^\\\\<>|`'.!-]{1,8}", 1..8)) {
|
|
let text = words.join(" ");
|
|
let d = Document::from_text(&text);
|
|
let tex = export("tex", &d).unwrap().unwrap();
|
|
let back = import("tex", &tex).unwrap().unwrap();
|
|
let got = back.plain_text(Default::default());
|
|
proptest::prop_assert_eq!(got.trim(), text.trim(), "tex: {}", String::from_utf8_lossy(&tex));
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_text_round_trips(words in proptest::collection::vec("[a-zA-Z0-9*_`\\[\\]<>#.!-]{1,8}", 1..8)) {
|
|
let text = words.join(" ");
|
|
let d = Document::from_text(&text);
|
|
let md = export("md", &d).unwrap().unwrap();
|
|
let back = import("md", &md).unwrap().unwrap();
|
|
let got = back.plain_text(Default::default());
|
|
proptest::prop_assert_eq!(got.trim(), text.trim(), "md: {}", String::from_utf8_lossy(&md));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn html_img_sources_go_through_the_loader() {
|
|
// Issue #98: an `img` named by a relative path was dropped, leaving only its alt text.
|
|
let html = r#"<p><img src="logo.png" alt="Sample Logo" width="120" height="40"></p><p><img src="gone.png" alt="Missing"></p><p>Text after.</p>"#;
|
|
let pic = png();
|
|
let seen = std::cell::RefCell::new(Vec::new());
|
|
let d = crate::html::import_with(html, &|src| {
|
|
seen.borrow_mut().push(src.to_string());
|
|
(src == "logo.png").then(|| std::sync::Arc::new(pic.clone()))
|
|
});
|
|
assert_eq!(*seen.borrow(), ["logo.png", "gone.png"]);
|
|
assert_eq!(d.media.len(), 1);
|
|
let text = d.plain_text(wordcraft_doc::StoryRef::Body);
|
|
assert!(text.contains("Missing") && !text.contains("Sample Logo"), "{text:?}");
|
|
// Without a loader only data: URIs load.
|
|
assert!(import("html", html.as_bytes()).unwrap().unwrap().media.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn chart_alt_text_is_an_image_alt_never_body_text() {
|
|
let mut p = Paragraph::with_text("See below", CharProps::default());
|
|
let chart = InlineObject::Graphic { w: 100.0, h: 50.0, alt: "Sales".into(), float: Default::default(), graphic: Arc::new(Default::default()) };
|
|
p.insert_object(4, chart, &CharProps::default()).unwrap();
|
|
let mut d = Document::new();
|
|
d.body = vec![para_block(p)];
|
|
let text = |ext: &str| String::from_utf8(export(ext, &d).unwrap().unwrap()).unwrap();
|
|
assert!(text("html").contains(r#"<img alt="Sales">"#), "{}", text("html"));
|
|
assert!(text("md").contains("![Sales]()"), "{}", text("md"));
|
|
for ext in ["txt", "rtf", "tex"] {
|
|
assert!(!text(ext).contains("Sales"), "{ext}: {}", text(ext));
|
|
}
|
|
assert_eq!(para_texts(&d), ["See below"]);
|
|
}
|
|
|
|
/// A picture marked decorative exports with an empty alt text, though it keeps its description,
|
|
/// so screen readers skip it; the same picture not marked decorative keeps its alt text.
|
|
#[test]
|
|
fn decorative_picture_exports_with_empty_alt_text() {
|
|
use wordcraft_doc::para::{AltText, Float};
|
|
let doc = |decorative: bool| {
|
|
let mut d = Document::new();
|
|
let media = d.add_media(png(), "png");
|
|
let float = Float { alt: AltText { decorative, ..Default::default() }, ..Default::default() };
|
|
let pic = InlineObject::Image { media, w: 40.0, h: 20.0, alt: "Blue bar".into(), float, crop: [0.0; 4], ole: None };
|
|
let mut p = Paragraph::with_text("Logo: ", CharProps::default());
|
|
let n = p.len();
|
|
p.insert_object(n, pic, &CharProps::default()).unwrap();
|
|
d.body = vec![para_block(p)];
|
|
d
|
|
};
|
|
let text = |d: &Document, ext: &str| String::from_utf8(export(ext, d).unwrap().unwrap()).unwrap();
|
|
let (plain, decorative) = (doc(false), doc(true));
|
|
assert!(text(&plain, "html").contains(r#"alt="Blue bar""#), "{}", text(&plain, "html"));
|
|
assert!(text(&plain, "md").contains(", "{}", text(&plain, "md"));
|
|
let (html, md) = (text(&decorative, "html"), text(&decorative, "md"));
|
|
assert!(html.contains(r#"alt="""#) && !html.contains("Blue bar"), "{html}");
|
|
assert!(md.contains(" && !md.contains("Blue bar"), "{md}");
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|
|
|
|
/// Backtick runs that never close, each a different length, sent every code span and every link
|
|
/// bracket scan over them looking for a closing run through the rest of the text: superlinear
|
|
/// time on hostile Markdown.
|
|
#[test]
|
|
fn unmatched_backtick_runs_take_linear_time() {
|
|
use std::time::{Duration, Instant};
|
|
// Runs of 1, 2, … 2000 backticks (2 MB), none of them closed.
|
|
let runs = (1..=2000).map(|n| "`".repeat(n)).collect::<Vec<_>>().join(" ");
|
|
let cases = [runs.clone(), format!("{}{runs}{}", "[".repeat(8), "]".repeat(8)), format!("[{runs}")];
|
|
for text in cases {
|
|
let t = Instant::now();
|
|
let d = import("md", text.as_bytes()).unwrap().unwrap();
|
|
let plain = d.plain_text(Default::default());
|
|
let elapsed = t.elapsed();
|
|
eprintln!("{:?}... took {elapsed:?}", &text[..4]);
|
|
assert!(elapsed < Duration::from_secs(5), "{:?}... took {elapsed:?}", &text[..4]);
|
|
assert_eq!(plain.matches('`').count(), text.matches('`').count(), "every backtick stays text");
|
|
}
|
|
}
|
|
|
|
/// Code spans and brackets around backticks read as before the backtick runs were indexed.
|
|
#[test]
|
|
fn code_spans_and_links_around_backticks_read_as_before() {
|
|
// An escaped backtick before a span, a span holding a shorter run, a run nothing closes, a `]`
|
|
// inside a code span in link text, and link text with a backtick that nothing closes.
|
|
let flow = crate::markdown::parse("\\``e` `` a ` b `` then ``` and `c` [x `]` y](http://a.org) [p ` q](http://b.org) ``f");
|
|
let FBlock::Para(p) = &flow.blocks[0] else { panic!("{:?}", flow.blocks) };
|
|
let spans: Vec<(String, bool, Option<String>)> = p
|
|
.inlines
|
|
.iter()
|
|
.filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.clone(), f.code, f.link.as_deref().map(str::to_string))) } else { None })
|
|
.collect();
|
|
let code: Vec<&str> = spans.iter().filter(|s| s.1).map(|s| s.0.as_str()).collect();
|
|
assert_eq!(code, ["e", "a ` b", "c", "]"], "{spans:?}");
|
|
let linked = |url: &str| spans.iter().filter(|s| s.2.as_deref() == Some(url)).map(|s| s.0.as_str()).collect::<String>();
|
|
assert_eq!(linked("http://a.org"), "x ] y");
|
|
assert_eq!(linked("http://b.org"), "p ` q");
|
|
assert_eq!(p.text(), "`e a ` b then ``` and c x ] y p ` q ``f");
|
|
}
|
|
|
|
/// An unescaped `&` in a LaTeX link address is part of the address.
|
|
#[test]
|
|
fn latex_href_keeps_ampersand() {
|
|
let d = import("tex", br"\href{https://e.com/a?b=1&c=2}{link}").unwrap().unwrap();
|
|
let links: Vec<Option<String>> = flat(&d)
|
|
.iter()
|
|
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
|
|
.flat_map(|p| p.inlines.iter())
|
|
.filter_map(|i| if let model::Inline::Text(_, f) = i { Some(f.link.as_deref().map(str::to_string)) } else { None })
|
|
.collect();
|
|
assert_eq!(links, [Some("https://e.com/a?b=1&c=2".to_string())]);
|
|
|
|
let d = import("tex", br"\url{https://e.com/a?b=1&c=2}").unwrap().unwrap();
|
|
let texts: Vec<(String, Option<String>)> = flat(&d)
|
|
.iter()
|
|
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
|
|
.flat_map(|p| p.inlines.iter())
|
|
.filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.to_string(), f.link.as_deref().map(str::to_string))) } else { None })
|
|
.collect();
|
|
let url = "https://e.com/a?b=1&c=2".to_string();
|
|
assert_eq!(texts, [(url.clone(), Some(url))]);
|
|
}
|
|
|
|
/// A `[` nothing closes, bracketed text that is not a link and links themselves used up the bracket
|
|
/// scans' budget as if each had read the rest of the text: later links in the paragraph were plain
|
|
/// text.
|
|
#[test]
|
|
fn brackets_do_not_turn_later_links_into_text() {
|
|
let links = |md: &str| {
|
|
let flow = crate::markdown::parse(md);
|
|
let FBlock::Para(p) = &flow.blocks[0] else { panic!("{:?}", flow.blocks) };
|
|
let mut urls: Vec<String> =
|
|
p.inlines.iter().filter_map(|i| if let model::Inline::Text(_, f) = i { f.link.as_deref().map(str::to_string) } else { None }).collect();
|
|
urls.dedup();
|
|
urls
|
|
};
|
|
// An interval nothing closes, before a link.
|
|
assert_eq!(links("Intervals [0, 1) and [docs](http://x.org)"), ["http://x.org"]);
|
|
// References early in a long paragraph, a link at its end.
|
|
let refs = (1..=12).map(|n| format!("[{n}]")).collect::<Vec<_>>().join(" ");
|
|
assert_eq!(links(&format!("{refs} {}[docs](http://x.org)", "word ".repeat(4000))), ["http://x.org"]);
|
|
// Many links through one paragraph.
|
|
let urls: Vec<String> = (0..60).map(|n| format!("http://x.org/{n}")).collect();
|
|
assert_eq!(links(&urls.iter().map(|u| format!("[link]({u}) {}", "word ".repeat(20))).collect::<String>()), urls);
|
|
}
|
|
|
|
/// 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}");
|
|
}
|
|
|
|
/// A document's hyperlink can be `javascript:` (WordCraft refuses to follow it); the exported
|
|
/// HTML and Markdown must not hand it to a browser either. Web, mail, bookmark and relative links
|
|
/// still export as links.
|
|
#[test]
|
|
fn exported_links_keep_only_safe_targets() {
|
|
let links = [
|
|
("script", "javascript:alert(1)"),
|
|
("split", "java\nscript:alert(1)"),
|
|
("vb", "vbscript:MsgBox(1)"),
|
|
("data", "data:text/html,<b>x</b>"),
|
|
("web", "https://ok.example/x"),
|
|
("mail", "mailto:a@b.example"),
|
|
("bookmark", "#top"),
|
|
("relative", "other.html"),
|
|
];
|
|
let mut d = Document::new();
|
|
let mut p = Paragraph::with_text("", CharProps::default());
|
|
for (text, l) in links {
|
|
let n = p.len();
|
|
p.insert_text(n, text, &CharProps { link: Some(l.to_string()), ..Default::default() }).unwrap();
|
|
let n = p.len();
|
|
p.insert_text(n, " ", &CharProps::default()).unwrap();
|
|
}
|
|
d.body = vec![para_block(p)];
|
|
let html = String::from_utf8(export("html", &d).unwrap().unwrap()).unwrap();
|
|
let md = String::from_utf8(export("md", &d).unwrap().unwrap()).unwrap();
|
|
for bad in ["javascript", "vbscript", "data:"] {
|
|
assert!(!html.to_ascii_lowercase().contains(bad), "html: {html}");
|
|
assert!(!md.to_ascii_lowercase().replace('\n', "").contains(bad), "md: {md}");
|
|
}
|
|
for (text, _) in links {
|
|
assert!(html.contains(text), "html keeps the text {text:?}: {html}");
|
|
assert!(md.contains(text), "md keeps the text {text:?}: {md}");
|
|
}
|
|
for (text, l) in &links[4..] {
|
|
assert!(html.contains(&format!("<a href=\"{l}\">{text}</a>")), "html: {html}");
|
|
assert!(md.contains(&format!("[{text}]({l})")), "md: {md}");
|
|
}
|
|
for text in ["script", "split", "vb", "data"] {
|
|
assert!(!html.contains(&format!(">{text}</a>")), "html: {html}");
|
|
assert!(!md.contains(&format!("[{text}](")), "md: {md}");
|
|
}
|
|
}
|
|
|
|
/// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`,
|
|
/// so the reader, which ends a bare destination at the first whitespace, reads it back whole.
|
|
#[test]
|
|
fn markdown_link_targets_with_unicode_spaces_read_back() {
|
|
for l in ["https://e.com/a\u{a0}b", "https://e.com/a\u{3000}b", "https://e.com/a b"] {
|
|
let mut d = Document::new();
|
|
let mut p = Paragraph::with_text("", CharProps::default());
|
|
p.insert_text(0, "go", &CharProps { link: Some(l.to_string()), ..Default::default() }).unwrap();
|
|
d.body = vec![para_block(p)];
|
|
let md = String::from_utf8(export("md", &d).unwrap().unwrap()).unwrap();
|
|
let sp = spans(&crate::markdown::parse(&md));
|
|
assert_eq!(sp.iter().find(|(t, _)| t == "go").and_then(|(_, f)| f.link.as_deref()), Some(l), "md: {md}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hidden_drawings_are_left_out_of_exports() {
|
|
let mut d = wordcraft_doc::Document::from_text("x");
|
|
let key = d.add_media(vec![0x89, b'P', b'N', b'G'], "png");
|
|
for hidden in [false, true] {
|
|
let float = wordcraft_doc::para::Float { hidden, ..Default::default() };
|
|
let image = wordcraft_doc::InlineObject::Image { media: key.clone(), w: 10.0, h: 10.0, alt: "Logo".into(), float, crop: [0.0; 4], ole: None };
|
|
d.insert_object(&wordcraft_doc::Pos::body(0, 0), image, &Default::default()).unwrap();
|
|
}
|
|
// Two pictures, one hidden: one comes out.
|
|
assert_eq!(crate::html::export(&d).matches("<img").count(), 1);
|
|
}
|
|
|
|
/// Every text span of the flow, with its formatting.
|
|
fn spans(flow: &model::Flow) -> Vec<(String, model::Fmt)> {
|
|
flow.blocks
|
|
.iter()
|
|
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
|
|
.flat_map(|p| p.inlines.iter())
|
|
.filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.clone(), f.clone())) } else { None })
|
|
.collect()
|
|
}
|
|
|
|
/// A font name or link target too long for any real one is dropped, its text kept: every run
|
|
/// carried its own copy, so one long name times many runs was gigabytes (#3713).
|
|
#[test]
|
|
fn overlong_font_names_and_links_are_dropped() {
|
|
let long = "x".repeat(1 << 20);
|
|
let rtf = format!("{{\\rtf1{{\\fonttbl{{\\f0 Arial;}}{{\\f1 {long};}}}}\\f1 {}}}", "{\\b a}a".repeat(1000));
|
|
let flow = crate::rtf::parse(rtf.as_bytes()).unwrap();
|
|
let sp = spans(&flow);
|
|
assert!(sp.iter().map(|s| s.0.len()).sum::<usize>() >= 2000);
|
|
assert!(sp.iter().all(|(_, f)| f.font.is_none()), "an overlong RTF font name is no font");
|
|
// A real one still is.
|
|
let flow = crate::rtf::parse(b"{\\rtf1{\\fonttbl{\\f0 Arial;}{\\f1 Georgia;}}\\f1 a}").unwrap();
|
|
assert_eq!(spans(&flow)[0].1.font.as_deref(), Some("Georgia"));
|
|
|
|
let html =
|
|
format!("<p><span style=\"font-family:'{long}'\"><a href=\"https://e.com/{long}\">kept</a></span> <font face=\"{long}\">too</font></p>");
|
|
let sp = spans(&crate::html::parse(&html));
|
|
assert!(sp.iter().any(|(t, _)| t.contains("kept")) && sp.iter().any(|(t, _)| t.contains("too")));
|
|
assert!(sp.iter().all(|(_, f)| f.font.is_none() && f.link.is_none()), "{:?}", sp.iter().map(|s| &s.0).collect::<Vec<_>>());
|
|
|
|
let sp = spans(&crate::markdown::parse(&format!("[kept](https://e.com/{long})")));
|
|
assert_eq!(sp.len(), 1);
|
|
assert_eq!((sp[0].0.as_str(), sp[0].1.link.is_none()), ("kept", true));
|
|
}
|
|
|
|
/// Spans of one link share its target, and so do references to one definition: a long
|
|
/// definition and many references no longer copy the destination into each (#3713).
|
|
#[test]
|
|
fn spans_share_their_link_target() {
|
|
let dest = format!("https://e.com/{}", "x".repeat(4000));
|
|
let md = format!("{}\n\n[r]: {dest}\n", "[a][r] **[b][r]** ".repeat(10_000));
|
|
let sp = spans(&crate::markdown::parse(&md));
|
|
let links: Vec<_> = sp.iter().filter_map(|(_, f)| f.link.as_ref()).collect();
|
|
assert_eq!(links.len(), 20_000);
|
|
assert!(links.iter().all(|l| Arc::ptr_eq(l, links[0])), "one copy of the destination");
|
|
|
|
let html = format!("<a href=\"{dest}\">{}</a>", "<b>a</b>a".repeat(1000));
|
|
let sp = spans(&crate::html::parse(&html));
|
|
assert!(sp.len() >= 2000);
|
|
let first = sp[0].1.link.clone().unwrap();
|
|
assert!(sp.iter().all(|(_, f)| f.link.as_ref().is_some_and(|l| Arc::ptr_eq(l, &first))));
|
|
}
|
|
|
|
/// The document's runs own their link and font strings; past a budget further runs keep their
|
|
/// text but lose them, so a flow of shared names can't expand into gigabytes (#3713).
|
|
#[test]
|
|
fn run_link_and_font_copies_are_budgeted() {
|
|
let link: Arc<str> = format!("https://e.com/{}", "x".repeat(model::MAX_LINK - 20)).into();
|
|
let mut p = model::Para::default();
|
|
for i in 0..20_000 {
|
|
let f = model::Fmt { bold: i % 2 == 0, link: Some(link.clone()), ..Default::default() };
|
|
p.push_text("a", &f);
|
|
}
|
|
model::begin_import();
|
|
let (doc, warned) = crate::test_log::warnings(|| model::to_doc(&model::Flow { blocks: vec![FBlock::Para(p)], ..Default::default() }));
|
|
let paras: Vec<_> = doc.body.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p) } else { None }).collect();
|
|
assert_eq!(paras.iter().map(|p| p.text.len()).sum::<usize>(), 20_000, "all text kept");
|
|
let runs: Vec<_> = paras.iter().flat_map(|p| p.runs.iter()).collect();
|
|
let kept: usize = runs.iter().filter_map(|r| r.props.link.as_ref()).map(String::len).sum();
|
|
assert!(kept > 0 && kept <= 64 << 20, "{kept}");
|
|
// The runs that lose theirs are said to, once for the document (review of #3873).
|
|
assert_eq!(warned.iter().filter(|w| w.contains("later runs lose them")).count(), 1, "{warned:?}");
|
|
}
|
|
|
|
/// Every limit that leaves a font name, link or style name out says so, once per kind per
|
|
/// document rather than once per item (review of #3873; the tracker complains of silent loss).
|
|
#[test]
|
|
fn dropped_names_and_links_are_logged_once_per_document() {
|
|
let long = "x".repeat(model::MAX_LINK + 1);
|
|
let html = format!(
|
|
"<p><span style=\"font-family:'{long}'\"><a href=\"https://e.com/{long}\">a</a></span> <font face=\"{long}\">b</font> <a href=\"https://e.com/{long}\">c</a></p>"
|
|
);
|
|
let count = |w: &[String], what: &str| w.iter().filter(|l| l.contains(what)).count();
|
|
for _ in 0..2 {
|
|
// Each import is its own document: the second is reported too.
|
|
let (doc, warned) = crate::test_log::warnings(|| crate::html::import(&html));
|
|
assert!(doc.body.len() == 1, "the text is kept");
|
|
assert_eq!(count(&warned, "font names longer"), 1, "{warned:?}");
|
|
assert_eq!(count(&warned, "links longer"), 1, "{warned:?}");
|
|
}
|
|
let (_, warned) = crate::test_log::warnings(|| crate::html::import("<p><a href=\"https://e.com\" style=\"font-family:Georgia\">fine</a></p>"));
|
|
assert!(warned.is_empty(), "nothing left out, nothing said: {warned:?}");
|
|
|
|
let md = format!("[a](https://e.com/{long}) [b](https://e.com/{long})");
|
|
let (_, warned) = crate::test_log::warnings(|| crate::markdown::import(&md));
|
|
assert_eq!(count(&warned, "links longer"), 1, "{warned:?}");
|
|
|
|
let rtf =
|
|
format!("{{\\rtf1{{\\fonttbl{{\\f0 Arial;}}{{\\f1 {long};}}{{\\f2 {long};}}}}{{\\stylesheet{{\\s1 {long};}}{{\\s2 {long};}}}}\\f1 a\\f2 b}}");
|
|
let (_, warned) = crate::test_log::warnings(|| crate::rtf::import(rtf.as_bytes()).unwrap());
|
|
assert_eq!(count(&warned, "font names longer"), 1, "{warned:?}");
|
|
assert_eq!(count(&warned, "style names longer"), 1, "{warned:?}");
|
|
|
|
let tex = format!("\\href{{https://e.com/{long}}}{{a}} \\url{{https://e.com/{long}}}");
|
|
let (_, warned) = crate::test_log::warnings(|| crate::latex::import(tex.as_bytes()));
|
|
assert_eq!(count(&warned, "links longer"), 1, "{warned:?}");
|
|
}
|