C14.14 r1: HTML from the syntax tree; pictures claimed by create_new; one scratch per PDF; diagram glyphs checked; rich copy reports
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d7ded183ae
commit
88b0f8f5fc
12 files changed
+647
-312
No files matched your search
@@ -291,16 +291,21 @@ impl Desk {
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.session.path())
|
||||
.and_then(|path| path.parent());
|
||||
let copied = output::body(&markdown, beside, &Pictures::Inline).and_then(|page| {
|
||||
let copied = output::body(&markdown, beside, &Pictures::Inline).and_then(|mut page| {
|
||||
let clip = Clip::Html {
|
||||
html: page.html,
|
||||
html: std::mem::take(&mut page.html),
|
||||
text: markdown,
|
||||
};
|
||||
clipboard::write(&clip).map_err(|error| std::io::Error::other(format!("{error:#}")))
|
||||
clipboard::write(&clip)
|
||||
.map(|()| page)
|
||||
.map_err(|error| std::io::Error::other(format!("{error:#}")))
|
||||
});
|
||||
if let Err(error) = copied {
|
||||
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
match copied {
|
||||
Ok(page) => self.left_out(&page, cx),
|
||||
Err(error) => {
|
||||
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
path::Path,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use super::{Page, Pictures, body, document, plain};
|
||||
@@ -62,39 +63,30 @@ fn export(target: &Target, bytes: &[u8]) -> io::Result<()> {
|
||||
|
||||
/// The page beside a new folder of its pictures.
|
||||
fn linked(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::Result<Page> {
|
||||
let name = format!("{title}_files");
|
||||
let page = body(text, beside, &Pictures::Folder(name.clone()))?;
|
||||
if page.copies.is_empty() {
|
||||
export(target, document(title, &page.html).as_bytes())?;
|
||||
return Ok(page);
|
||||
let folder = target.path.with_file_name(format!("{title}_files"));
|
||||
let found = folder.exists();
|
||||
let written = body(text, beside, &Pictures::Folder(folder.clone()))
|
||||
.and_then(|page| export(target, document(title, &page.html).as_bytes()).map(|()| page));
|
||||
// A failure takes only the folder it made.
|
||||
if written.is_err()
|
||||
&& !found
|
||||
&& folder.exists()
|
||||
&& let Err(left) = std::fs::remove_dir_all(&folder)
|
||||
{
|
||||
log::error!("mark: the half-made picture folder stays: {left}");
|
||||
}
|
||||
let folder = target.path.with_file_name(&name);
|
||||
// A folder already there is someone's: refused.
|
||||
std::fs::create_dir(&folder).map_err(|error| match error.kind() {
|
||||
io::ErrorKind::AlreadyExists => io::Error::new(error.kind(), folder.display().to_string()),
|
||||
_ => error,
|
||||
})?;
|
||||
let written = copy(&page.copies, &folder)
|
||||
.and_then(|()| export(target, document(title, &page.html).as_bytes()));
|
||||
if let Err(error) = written {
|
||||
if let Err(left) = std::fs::remove_dir_all(&folder) {
|
||||
log::error!("mark: the half-made picture folder stays: {left}");
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(page)
|
||||
written
|
||||
}
|
||||
|
||||
/// The page set by MuPDF, from a scratch folder.
|
||||
/// The page set by MuPDF, in its own scratch.
|
||||
fn pdf(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::Result<Page> {
|
||||
let dir = target.work.join("pdf");
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
std::fs::create_dir_all(dir.join("files"))?;
|
||||
static JOBS: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = target
|
||||
.work
|
||||
.join(format!("pdf-{}", JOBS.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let set = (|| -> io::Result<Page> {
|
||||
let page = body(text, beside, &Pictures::Pdf("files".into()))?;
|
||||
copy(&page.copies, &dir.join("files"))?;
|
||||
let page = body(text, beside, &Pictures::Pdf(dir.join("files")))?;
|
||||
std::fs::write(dir.join("page.html"), document(title, &page.html))?;
|
||||
let out = dir.join("out.pdf");
|
||||
ely_pdf_engine::Worker::spawn()?.html_pdf(&dir, "page.html", &out)?;
|
||||
@@ -106,10 +98,3 @@ fn pdf(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::R
|
||||
cleaned?;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
fn copy(copies: &[(PathBuf, String)], folder: &Path) -> io::Result<()> {
|
||||
for (from, name) in copies {
|
||||
std::fs::copy(from, folder.join(name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! A syntax tree written as HTML, node by node.
|
||||
|
||||
use std::{collections::HashMap, io};
|
||||
|
||||
use ely_gpui_component::{documents::diagram_svg, typography::formula_svg};
|
||||
use markdown::mdast::{AlignKind, Node};
|
||||
|
||||
use super::{Page, attribute, escape, pictures::Placed};
|
||||
|
||||
/// Writes `tree` into `page`; pictures go through `placed`.
|
||||
pub(super) fn write(tree: &Node, placed: &mut Placed, page: &mut Page) -> io::Result<()> {
|
||||
let mut writer = Writer {
|
||||
placed,
|
||||
page,
|
||||
links: HashMap::new(),
|
||||
notes: HashMap::new(),
|
||||
called: Vec::new(),
|
||||
};
|
||||
writer.gather(tree);
|
||||
writer.children(tree, false)?;
|
||||
writer.footnotes()
|
||||
}
|
||||
|
||||
struct Writer<'a, 'b, 'c> {
|
||||
placed: &'b mut Placed<'a>,
|
||||
page: &'c mut Page,
|
||||
/// Link definitions by identifier: address and title.
|
||||
links: HashMap<String, (String, Option<String>)>,
|
||||
notes: HashMap<String, Node>,
|
||||
/// Footnotes in the order first called.
|
||||
called: Vec<String>,
|
||||
}
|
||||
|
||||
impl Writer<'_, '_, '_> {
|
||||
fn gather(&mut self, node: &Node) {
|
||||
match node {
|
||||
Node::Definition(definition) => {
|
||||
let target = (definition.url.clone(), definition.title.clone());
|
||||
self.links
|
||||
.entry(definition.identifier.clone())
|
||||
.or_insert(target);
|
||||
}
|
||||
Node::FootnoteDefinition(note) => {
|
||||
self.notes
|
||||
.entry(note.identifier.clone())
|
||||
.or_insert_with(|| node.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.gather(child);
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, text: &str) {
|
||||
self.page.html.push_str(text);
|
||||
}
|
||||
|
||||
fn children(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.node(child, tight)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wrapped(&mut self, tag: &str, node: &Node) -> io::Result<()> {
|
||||
self.push(&format!("<{tag}>"));
|
||||
self.children(node, false)?;
|
||||
self.push(&format!("</{tag}>"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
match node {
|
||||
Node::Paragraph(_) if tight => self.children(node, false)?,
|
||||
Node::Paragraph(_) => self.wrapped("p", node)?,
|
||||
Node::Heading(heading) => self.wrapped(&format!("h{}", heading.depth), node)?,
|
||||
Node::Blockquote(_) => self.wrapped("blockquote", node)?,
|
||||
Node::Emphasis(_) => self.wrapped("em", node)?,
|
||||
Node::Strong(_) => self.wrapped("strong", node)?,
|
||||
Node::Delete(_) => self.wrapped("del", node)?,
|
||||
Node::List(list) => {
|
||||
let tag = if list.ordered { "ol" } else { "ul" };
|
||||
match list.start.filter(|start| list.ordered && *start != 1) {
|
||||
Some(start) => self.push(&format!("<ol start=\"{start}\">")),
|
||||
None => self.push(&format!("<{tag}>")),
|
||||
}
|
||||
for item in &list.children {
|
||||
let Node::ListItem(each) = item else { continue };
|
||||
self.push("<li>");
|
||||
if let Some(checked) = each.checked {
|
||||
let mark = if checked { " checked" } else { "" };
|
||||
self.push(&format!("<input type=\"checkbox\" disabled{mark}> "));
|
||||
}
|
||||
self.children(item, !list.spread && !each.spread)?;
|
||||
self.push("</li>");
|
||||
}
|
||||
self.push(&format!("</{tag}>"));
|
||||
}
|
||||
Node::Table(table) => self.table(node, &table.align)?,
|
||||
Node::Code(code) if code.lang.as_deref() == Some("mermaid") => {
|
||||
let line = line_of(node);
|
||||
match diagram_svg(&code.value) {
|
||||
Ok(svg) => self.push(&format!("<div class=\"diagram\">{}</div>", bare(&svg))),
|
||||
Err(_) => {
|
||||
self.page.diagrams.push(line);
|
||||
self.code(Some("mermaid"), &code.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Code(code) => self.code(code.lang.as_deref(), &code.value),
|
||||
Node::Math(math) => self.math(node, &math.value, true),
|
||||
Node::InlineMath(math) => self.math(node, &math.value, false),
|
||||
Node::InlineCode(code) => self.push(&format!("<code>{}</code>", escape(&code.value))),
|
||||
Node::Text(text) => self.push(&escape(&text.value)),
|
||||
// HTML in the document shows as text, never runs.
|
||||
Node::Html(html) => self.push(&escape(&html.value)),
|
||||
Node::Break(_) => self.push("<br>"),
|
||||
Node::ThematicBreak(_) => self.push("<hr>"),
|
||||
Node::Link(link) => self.link(node, &link.url, link.title.as_deref())?,
|
||||
Node::LinkReference(link) => match self.links.get(&link.identifier).cloned() {
|
||||
Some((url, title)) => self.link(node, &url, title.as_deref())?,
|
||||
None => self.children(node, false)?,
|
||||
},
|
||||
Node::Image(image) => self.picture(&image.url, &image.alt, image.title.as_deref())?,
|
||||
Node::ImageReference(image) => match self.links.get(&image.identifier).cloned() {
|
||||
Some((url, title)) => self.picture(&url, &image.alt, title.as_deref())?,
|
||||
None => self.push(&escape(&image.alt)),
|
||||
},
|
||||
Node::FootnoteReference(call) => {
|
||||
let number = match self.called.iter().position(|id| *id == call.identifier) {
|
||||
Some(at) => at + 1,
|
||||
None => {
|
||||
self.called.push(call.identifier.clone());
|
||||
self.called.len()
|
||||
}
|
||||
};
|
||||
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"));
|
||||
}
|
||||
// Written elsewhere, or never.
|
||||
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) | Node::FootnoteDefinition(_) => {}
|
||||
_ => self.children(node, false)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn table(&mut self, node: &Node, align: &[AlignKind]) -> io::Result<()> {
|
||||
self.push("<table>");
|
||||
for (at, row) in node.children().into_iter().flatten().enumerate() {
|
||||
let cell = if at == 0 { "th" } else { "td" };
|
||||
self.push("<tr>");
|
||||
for (column, each) in row.children().into_iter().flatten().enumerate() {
|
||||
let side = match align.get(column) {
|
||||
Some(AlignKind::Left) => " style=\"text-align:left\"",
|
||||
Some(AlignKind::Right) => " style=\"text-align:right\"",
|
||||
Some(AlignKind::Center) => " style=\"text-align:center\"",
|
||||
_ => "",
|
||||
};
|
||||
self.push(&format!("<{cell}{side}>"));
|
||||
self.children(each, false)?;
|
||||
self.push(&format!("</{cell}>"));
|
||||
}
|
||||
self.push("</tr>");
|
||||
}
|
||||
self.push("</table>");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn code(&mut self, lang: Option<&str>, value: &str) {
|
||||
let class = lang.map_or_else(String::new, |lang| {
|
||||
format!(" class=\"language-{}\"", attribute(lang))
|
||||
});
|
||||
self.push(&format!("<pre><code{class}>{}</code></pre>", escape(value)));
|
||||
}
|
||||
|
||||
fn math(&mut self, node: &Node, value: &str, display: bool) {
|
||||
match (formula_svg(value, display), display) {
|
||||
(Ok(svg), true) => self.push(&format!("<div class=\"math\">{}</div>", bare(&svg))),
|
||||
(Ok(svg), false) => self.push(&format!("<span class=\"math\">{}</span>", bare(&svg))),
|
||||
(Err(error), _) => {
|
||||
self.page.formulas.push((line_of(node), error.to_string()));
|
||||
match display {
|
||||
true => self.code(Some("math"), value),
|
||||
false => self.push(&format!("<code>{}</code>", escape(value))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn link(&mut self, node: &Node, url: &str, title: Option<&str>) -> io::Result<()> {
|
||||
let title = title.map_or_else(String::new, |title| {
|
||||
format!(" title=\"{}\"", attribute(title))
|
||||
});
|
||||
// Only web, mail and in-page addresses follow.
|
||||
match safe(url) {
|
||||
true => self.push(&format!("<a href=\"{}\"{title}>", attribute(url))),
|
||||
false => self.push("<a>"),
|
||||
}
|
||||
self.children(node, false)?;
|
||||
self.push("</a>");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn picture(&mut self, url: &str, alt: &str, title: Option<&str>) -> io::Result<()> {
|
||||
let src = self.placed.place(url)?;
|
||||
if src.is_none() {
|
||||
self.page.missing.push(if url.is_empty() {
|
||||
alt.to_owned()
|
||||
} else {
|
||||
url.to_owned()
|
||||
});
|
||||
}
|
||||
// A PDF shows what it left out as words.
|
||||
let Some(src) = src.or_else(|| (!self.placed.pdf()).then(|| url.to_owned())) else {
|
||||
self.push(&format!("[{}]", escape(alt)));
|
||||
return Ok(());
|
||||
};
|
||||
let title = title.map_or_else(String::new, |title| {
|
||||
format!(" title=\"{}\"", attribute(title))
|
||||
});
|
||||
self.push(&format!(
|
||||
"<img src=\"{}\" alt=\"{}\"{title}>",
|
||||
attribute(&src),
|
||||
attribute(alt)
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn footnotes(&mut self) -> io::Result<()> {
|
||||
if self.called.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.push("<section class=\"footnotes\"><hr><ol>");
|
||||
// A note may call another note.
|
||||
let mut at = 0;
|
||||
while at < self.called.len() {
|
||||
let note = self.notes.get(&self.called[at]).cloned();
|
||||
at += 1;
|
||||
self.push(&format!("<li id=\"fn-{at}\">"));
|
||||
match note {
|
||||
Some(note) => self.children(¬e, false)?,
|
||||
None => log::error!("mark: footnote {at} has no definition"),
|
||||
}
|
||||
self.push("</li>");
|
||||
}
|
||||
self.push("</ol></section>");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The line a node starts on, from one.
|
||||
fn line_of(node: &Node) -> usize {
|
||||
node.position().map_or(0, |at| at.start.line)
|
||||
}
|
||||
|
||||
/// An SVG without its XML declaration.
|
||||
fn bare(svg: &str) -> &str {
|
||||
svg.find("<svg").map_or(svg, |at| &svg[at..])
|
||||
}
|
||||
|
||||
/// A plain, web or mail address.
|
||||
fn safe(url: &str) -> bool {
|
||||
match scheme(url) {
|
||||
None => true,
|
||||
Some(scheme) => ["http", "https", "mailto"].contains(&scheme.to_lowercase().as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An address's scheme, when it has one.
|
||||
pub(super) fn scheme(url: &str) -> Option<&str> {
|
||||
let end = url.find(':')?;
|
||||
let name = &url[..end];
|
||||
let plain = name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch));
|
||||
// A drive letter is a path, not a scheme.
|
||||
(plain && name.len() > 1).then_some(name)
|
||||
}
|
||||
+16
-114
@@ -1,6 +1,7 @@
|
||||
//! Markdown as an HTML page or plain text (MARK-11).
|
||||
|
||||
mod files;
|
||||
mod html;
|
||||
mod pictures;
|
||||
mod plain;
|
||||
#[cfg(test)]
|
||||
@@ -8,11 +9,7 @@ mod tests;
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_gpui_component::{
|
||||
documents::{diagram_svg, markdown_options},
|
||||
typography::formula_svg,
|
||||
};
|
||||
use markdown::{CompileOptions, Options, mdast::Node};
|
||||
use ely_gpui_component::documents::markdown_options;
|
||||
|
||||
pub use files::{Format, Target, write};
|
||||
pub use pictures::Pictures;
|
||||
@@ -22,8 +19,6 @@ pub use plain::plain;
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Page {
|
||||
pub html: String,
|
||||
/// Pictures to copy beside the page: from, then name.
|
||||
pub copies: Vec<(std::path::PathBuf, String)>,
|
||||
/// Pictures left out, as the document wrote them.
|
||||
pub missing: Vec<String>,
|
||||
/// Lines of diagrams kept as code.
|
||||
@@ -32,11 +27,6 @@ pub struct Page {
|
||||
pub formulas: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
const DISPLAY: &str = "<pre><code class=\"language-math math-display\">";
|
||||
const INLINE: &str = "<code class=\"language-math math-inline\">";
|
||||
const MERMAID: &str = "<pre><code class=\"language-mermaid\">";
|
||||
const IMAGE: &str = "<img src=\"";
|
||||
|
||||
/// The page's look: light, as printed.
|
||||
const STYLE: &str = "body{font-family:sans-serif;font-size:11pt;line-height:1.5;color:#1f2328;max-width:48rem;margin:2rem auto;padding:0 1rem}\
|
||||
pre{background:#f6f8fa;padding:.75em;white-space:pre-wrap}\
|
||||
@@ -47,71 +37,15 @@ img,svg{max-width:100%}div.math{text-align:center}span.math svg{vertical-align:m
|
||||
|
||||
/// `text`'s body as HTML; pictures go as `pictures` says.
|
||||
pub fn body(text: &str, beside: Option<&Path>, pictures: &Pictures) -> io::Result<Page> {
|
||||
let options = Options {
|
||||
parse: markdown_options(),
|
||||
compile: CompileOptions::gfm(),
|
||||
};
|
||||
let html = markdown::to_html_with_options(text, &options)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let tree = markdown::to_mdast(text, &options.parse)
|
||||
let tree = markdown::to_mdast(text, &markdown_options())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let mut page = Page::default();
|
||||
let mut placed = pictures::Placed::new(beside, pictures);
|
||||
let mut rest = html.as_str();
|
||||
while let Some((at, mark)) = [DISPLAY, INLINE, MERMAID, IMAGE]
|
||||
.iter()
|
||||
.filter_map(|mark| rest.find(mark).map(|at| (at, *mark)))
|
||||
.min()
|
||||
{
|
||||
page.html.push_str(&rest[..at]);
|
||||
rest = &rest[at + mark.len()..];
|
||||
let close = match mark {
|
||||
DISPLAY | MERMAID => "</code></pre>",
|
||||
INLINE => "</code>",
|
||||
_ => "\"",
|
||||
};
|
||||
let end = rest
|
||||
.find(close)
|
||||
.ok_or_else(|| io::Error::other("markdown-rs left an element open"))?;
|
||||
let inside = &rest[..end];
|
||||
rest = &rest[end + close.len()..];
|
||||
let source = unescape(inside);
|
||||
if mark == IMAGE {
|
||||
page.html.push_str(IMAGE);
|
||||
page.html
|
||||
.push_str(&placed.place(&source, rest, &mut page.missing));
|
||||
page.html.push('"');
|
||||
continue;
|
||||
}
|
||||
let drawn = match mark {
|
||||
MERMAID => match diagram_svg(&source) {
|
||||
Ok(svg) => Some(format!("<div class=\"diagram\">{}</div>", bare(&svg))),
|
||||
Err(_) => {
|
||||
page.diagrams.push(line(&tree, &source, true));
|
||||
None
|
||||
}
|
||||
},
|
||||
_ => match formula_svg(&source, mark == DISPLAY) {
|
||||
Ok(svg) if mark == DISPLAY => {
|
||||
Some(format!("<div class=\"math\">{}</div>", bare(&svg)))
|
||||
}
|
||||
Ok(svg) => Some(format!("<span class=\"math\">{}</span>", bare(&svg))),
|
||||
Err(error) => {
|
||||
page.formulas
|
||||
.push((line(&tree, &source, false), error.to_string()));
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
// What did not draw stays as written.
|
||||
page.html += &drawn.unwrap_or_else(|| format!("{mark}{inside}{close}"));
|
||||
}
|
||||
page.html.push_str(rest);
|
||||
page.copies = placed.copies;
|
||||
html::write(&tree, &mut placed, &mut page)?;
|
||||
log::info!(
|
||||
"mark: a page of {} bytes, {} copies, {} missing, {} diagrams and {} formulas as code",
|
||||
"mark: a page of {} bytes, {} pictures copied, {} missing, {} diagrams and {} formulas as code",
|
||||
page.html.len(),
|
||||
page.copies.len(),
|
||||
placed.copied.len(),
|
||||
page.missing.len(),
|
||||
page.diagrams.len(),
|
||||
page.formulas.len()
|
||||
@@ -121,52 +55,20 @@ pub fn body(text: &str, beside: Option<&Path>, pictures: &Pictures) -> io::Resul
|
||||
|
||||
/// A whole HTML document around `body`.
|
||||
pub fn document(title: &str, body: &str) -> String {
|
||||
let title = title.replace('&', "&").replace('<', "<");
|
||||
format!(
|
||||
"<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>{title}</title><style>{STYLE}</style></head>\n<body>\n{body}</body></html>\n"
|
||||
"<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>{}</title><style>{STYLE}</style></head>\n<body>\n{body}\n</body></html>\n",
|
||||
escape(title)
|
||||
)
|
||||
}
|
||||
|
||||
/// markdown-rs's four escapes undone.
|
||||
fn unescape(text: &str) -> String {
|
||||
text.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("&", "&")
|
||||
/// Text safe between tags.
|
||||
fn escape(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// An SVG without its XML declaration.
|
||||
fn bare(svg: &str) -> &str {
|
||||
svg.find("<svg").map_or(svg, |at| &svg[at..])
|
||||
}
|
||||
|
||||
/// The line of the first diagram or formula with `source`.
|
||||
fn line(tree: &Node, source: &str, diagram: bool) -> usize {
|
||||
let found = find(tree, source, diagram);
|
||||
if found.is_none() {
|
||||
log::error!(
|
||||
"mark: an undrawn {} has no line",
|
||||
if diagram { "diagram" } else { "formula" }
|
||||
);
|
||||
}
|
||||
found.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn find(tree: &Node, source: &str, diagram: bool) -> Option<usize> {
|
||||
let found = match tree {
|
||||
Node::Code(code) if diagram && code.lang.as_deref() == Some("mermaid") => {
|
||||
(code.value.trim_end() == source.trim_end()).then_some(tree)
|
||||
}
|
||||
Node::Math(math) if !diagram => {
|
||||
(math.value.trim_end() == source.trim_end()).then_some(tree)
|
||||
}
|
||||
Node::InlineMath(math) if !diagram => (math.value == source).then_some(tree),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(at) = found.and_then(Node::position) {
|
||||
return Some(at.start.line);
|
||||
}
|
||||
tree.children()?
|
||||
.iter()
|
||||
.find_map(|child| find(child, source, diagram))
|
||||
/// Text safe inside a quoted attribute.
|
||||
fn attribute(text: &str) -> String {
|
||||
escape(text).replace('"', """)
|
||||
}
|
||||
@@ -1,27 +1,41 @@
|
||||
//! Where an exported page's pictures go.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
fs::File,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
use super::html::scheme;
|
||||
|
||||
/// Where pictures go.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Pictures {
|
||||
/// Inside the page, as data.
|
||||
Inline,
|
||||
/// Copied into the named folder beside the page.
|
||||
Folder(String),
|
||||
/// As a folder, in formats MuPDF reads.
|
||||
Pdf(String),
|
||||
/// Copied into this new folder beside the page.
|
||||
Folder(PathBuf),
|
||||
/// Copied into this folder, in formats MuPDF reads.
|
||||
Pdf(PathBuf),
|
||||
}
|
||||
|
||||
/// Picture formats MuPDF reads from files.
|
||||
const READABLE: [&str; 8] = ["png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "svg"];
|
||||
|
||||
/// Inline pictures MuPDF reads.
|
||||
const READABLE_DATA: [&str; 3] = [
|
||||
"data:image/png;base64,",
|
||||
"data:image/jpeg;base64,",
|
||||
"data:image/gif;base64,",
|
||||
];
|
||||
|
||||
pub(super) struct Placed<'a> {
|
||||
beside: Option<&'a Path>,
|
||||
pictures: &'a Pictures,
|
||||
pub copies: Vec<(PathBuf, String)>,
|
||||
/// Pictures copied so far, and their names.
|
||||
pub copied: Vec<(PathBuf, String)>,
|
||||
}
|
||||
|
||||
impl<'a> Placed<'a> {
|
||||
@@ -29,87 +43,111 @@ impl<'a> Placed<'a> {
|
||||
Self {
|
||||
beside,
|
||||
pictures,
|
||||
copies: Vec::new(),
|
||||
copied: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `src` a picture takes; else it is missing.
|
||||
pub fn place(&mut self, src: &str, after: &str, missing: &mut Vec<String>) -> String {
|
||||
let address = decoded(src);
|
||||
// markdown-rs blanks addresses it will not allow.
|
||||
if address.is_empty() {
|
||||
missing.push(alt(after));
|
||||
return String::new();
|
||||
pub fn pdf(&self) -> bool {
|
||||
matches!(self.pictures, Pictures::Pdf(_))
|
||||
}
|
||||
|
||||
/// The `src` for `url`; none when it is not carried.
|
||||
pub fn place(&mut self, url: &str) -> io::Result<Option<String>> {
|
||||
let lower = url.to_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
return Ok((!self.pdf()).then(|| url.to_owned()));
|
||||
}
|
||||
let web = address.starts_with("http://") || address.starts_with("https://");
|
||||
if web || address.starts_with("data:") {
|
||||
if web && matches!(self.pictures, Pictures::Pdf(_)) {
|
||||
missing.push(address);
|
||||
return String::new();
|
||||
}
|
||||
return attribute(&address);
|
||||
if lower.starts_with("data:") {
|
||||
let readable = match self.pdf() {
|
||||
true => READABLE_DATA.iter().any(|kind| lower.starts_with(kind)),
|
||||
false => lower.starts_with("data:image/"),
|
||||
};
|
||||
return Ok(readable.then(|| url.to_owned()));
|
||||
}
|
||||
let path = match (Path::new(&address).is_absolute(), self.beside) {
|
||||
(true, _) => PathBuf::from(&address),
|
||||
(false, Some(beside)) => beside.join(&address),
|
||||
let path = match (lower.starts_with("file://"), scheme(url)) {
|
||||
(true, _) => file_path(&url["file://".len()..]),
|
||||
// Other schemes are no picture of the document's.
|
||||
(false, Some(_)) => return Ok(None),
|
||||
(false, None) => {
|
||||
missing.push(address);
|
||||
return String::new();
|
||||
let path = PathBuf::from(decoded(url));
|
||||
match (path.is_absolute(), self.beside) {
|
||||
(true, _) => path,
|
||||
(false, Some(beside)) => beside.join(path),
|
||||
(false, None) => return Ok(None),
|
||||
}
|
||||
}
|
||||
};
|
||||
match self.carried(&path) {
|
||||
Some(src) => src,
|
||||
None => {
|
||||
missing.push(address);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
self.carried(&path)
|
||||
}
|
||||
|
||||
fn carried(&mut self, path: &Path) -> Option<String> {
|
||||
fn carried(&mut self, path: &Path) -> io::Result<Option<String>> {
|
||||
let extension = path
|
||||
.extension()
|
||||
.map(|extension| extension.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
let folder = match self.pictures {
|
||||
Pictures::Inline => {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
return Some(format!("data:{};base64,{data}", mime(&extension)));
|
||||
return Ok(Some(format!("data:{};base64,{data}", mime(&extension))));
|
||||
}
|
||||
Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return None,
|
||||
Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return Ok(None),
|
||||
Pictures::Folder(folder) | Pictures::Pdf(folder) => folder.clone(),
|
||||
};
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let name = self.named(path);
|
||||
Some(format!("{folder}/{}", encoded(&name)))
|
||||
let named = match self.copied.iter().find(|(from, _)| from == path) {
|
||||
Some((_, name)) => name.clone(),
|
||||
None => {
|
||||
let Ok(mut source) = File::open(path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !source.metadata()?.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = self.copy(&mut source, path, &folder)?;
|
||||
self.copied.push((path.to_path_buf(), name.clone()));
|
||||
name
|
||||
}
|
||||
};
|
||||
let shelf = folder
|
||||
.file_name()
|
||||
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
|
||||
Ok(Some(format!("{}/{}", encoded(&shelf), encoded(&named))))
|
||||
}
|
||||
|
||||
/// A name in the folder, one per picture.
|
||||
fn named(&mut self, path: &Path) -> String {
|
||||
if let Some((_, name)) = self.copies.iter().find(|(from, _)| from == path) {
|
||||
return name.clone();
|
||||
/// Copies `source` under a name still free.
|
||||
fn copy(&self, source: &mut File, path: &Path, folder: &Path) -> io::Result<String> {
|
||||
if self.copied.is_empty() {
|
||||
// A folder already there is someone's: refused.
|
||||
std::fs::create_dir(folder).map_err(|error| match error.kind() {
|
||||
io::ErrorKind::AlreadyExists => {
|
||||
io::Error::new(error.kind(), folder.display().to_string())
|
||||
}
|
||||
_ => error,
|
||||
})?;
|
||||
}
|
||||
let file = path
|
||||
.file_name()
|
||||
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
|
||||
let (stem, extension) = file
|
||||
.rsplit_once('.')
|
||||
.map_or((file.as_str(), ""), |(stem, ext)| (stem, ext));
|
||||
let taken = |name: &str| self.copies.iter().any(|(_, kept)| kept == name);
|
||||
let mut name = file.clone();
|
||||
let mut count = 1;
|
||||
while taken(&name) {
|
||||
count += 1;
|
||||
name = match extension {
|
||||
"" => format!("{stem}-{count}"),
|
||||
let (stem, extension) = file.rsplit_once('.').unwrap_or((file.as_str(), ""));
|
||||
for count in 1.. {
|
||||
let name = match (count, extension) {
|
||||
(1, _) => file.clone(),
|
||||
(_, "") => format!("{stem}-{count}"),
|
||||
_ => format!("{stem}-{count}.{extension}"),
|
||||
};
|
||||
// The file system judges which names are the same.
|
||||
match File::create_new(folder.join(&name)) {
|
||||
Ok(mut kept) => {
|
||||
io::copy(source, &mut kept)?;
|
||||
return Ok(name);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
self.copies.push((path.to_path_buf(), name.clone()));
|
||||
name
|
||||
unreachable!("names never run out")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,16 +165,16 @@ fn mime(extension: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `alt` after a blanked `src`.
|
||||
fn alt(after: &str) -> String {
|
||||
let text = after.strip_prefix(" alt=\"").unwrap_or_default();
|
||||
super::unescape(&text[..text.find('"').unwrap_or(text.len())])
|
||||
/// A `file:` address's path; a drive keeps no leading slash.
|
||||
fn file_path(rest: &str) -> PathBuf {
|
||||
let path = decoded(rest.strip_prefix("localhost").unwrap_or(rest));
|
||||
let drive = path.len() > 2 && path.as_bytes()[2] == b':' && path.starts_with('/');
|
||||
PathBuf::from(if drive { &path[1..] } else { &path })
|
||||
}
|
||||
|
||||
/// `%XX` undone; bytes that are not UTF-8 stay as written.
|
||||
fn decoded(src: &str) -> String {
|
||||
let src = super::unescape(src);
|
||||
let bytes = src.as_bytes();
|
||||
fn decoded(url: &str) -> String {
|
||||
let bytes = url.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut at = 0;
|
||||
while at < bytes.len() {
|
||||
@@ -157,10 +195,10 @@ fn decoded(src: &str) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).unwrap_or(src)
|
||||
String::from_utf8(out).unwrap_or_else(|_| url.to_owned())
|
||||
}
|
||||
|
||||
/// A name as a path segment of a URL.
|
||||
/// A name as one path segment of a URL.
|
||||
fn encoded(name: &str) -> String {
|
||||
name.bytes()
|
||||
.map(|byte| match byte {
|
||||
@@ -171,11 +209,3 @@ fn encoded(name: &str) -> String {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A value safe inside a quoted attribute.
|
||||
fn attribute(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
}
|
||||
@@ -56,6 +56,7 @@ fn words(node: &Node) -> String {
|
||||
Node::InlineMath(math) => math.value.clone(),
|
||||
Node::Break(_) => "\n".into(),
|
||||
Node::Image(image) => image.alt.clone(),
|
||||
Node::ImageReference(image) => image.alt.clone(),
|
||||
Node::Html(_) => String::new(),
|
||||
_ => node.children().into_iter().flatten().map(words).collect(),
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ fn page(text: &str, beside: Option<&Path>, pictures: &Pictures) -> super::Page {
|
||||
body(text, beside, pictures).expect("a page")
|
||||
}
|
||||
|
||||
/// A 1×1 PNG, as base64.
|
||||
const DOT: &str =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2wAAAAAElFTkSuQmCC";
|
||||
|
||||
#[test]
|
||||
fn formulas_and_diagrams_draw_as_svg() {
|
||||
let text =
|
||||
"Inline $x^2$ here.\n\n$$\n\\sqrt{y}\n$$\n\n```mermaid\nflowchart LR\n A --> B\n```\n";
|
||||
let page = page(text, None, &Pictures::Inline);
|
||||
assert!(!page.html.contains("language-math"), "{}", page.html);
|
||||
assert!(!page.html.contains("language-mermaid"), "{}", page.html);
|
||||
assert_eq!(page.html.matches("<svg").count(), 3, "{}", page.html);
|
||||
assert!(
|
||||
page.html.contains("<span class=\"math\"><svg")
|
||||
@@ -22,36 +24,47 @@ fn formulas_and_diagrams_draw_as_svg() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_does_not_draw_stays_as_code_with_its_line() {
|
||||
let text = "# Title\n\n$$\n\\frac{\n$$\n\nText $\\sqrt{$ more.\n\n```mermaid\nflowchart LR\n A -->\n```\n";
|
||||
fn what_does_not_draw_stays_as_code_at_its_own_line() {
|
||||
let text = "$$\n\\frac{\n$$\n\n$$\n\\frac{\n$$\n\nText $\\sqrt{$ more.\n\n```mermaid\nflowchart LR\n A -->\n```\n\n```mermaid\nflowchart LR\n A[\u{F0000}] --> B\n```\n";
|
||||
let page = page(text, None, &Pictures::Inline);
|
||||
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
|
||||
assert_eq!(lines, [3, 7]);
|
||||
assert_eq!(lines, [1, 5, 9]);
|
||||
assert!(page.formulas.iter().all(|(_, why)| !why.is_empty()));
|
||||
assert_eq!(page.diagrams, [9]);
|
||||
assert_eq!(page.diagrams, [11, 16]);
|
||||
assert!(
|
||||
page.html.contains("language-math math-display\">\\frac{"),
|
||||
page.html
|
||||
.contains("<pre><code class=\"language-math\">\\frac{"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(page.html.contains("<code>\\sqrt{</code>"), "{}", page.html);
|
||||
assert!(
|
||||
page.html
|
||||
.contains("language-math math-inline\">\\sqrt{</code>"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(
|
||||
page.html.contains("language-mermaid\">flowchart LR"),
|
||||
.contains("<pre><code class=\"language-mermaid\">flowchart LR"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undrawn_formula_after_a_drawn_one_keeps_its_code() {
|
||||
let page = page("$$\nx\n$$\n$$\n\\frac{\n$$\n", None, &Pictures::Inline);
|
||||
assert_eq!(page.formulas.len(), 1);
|
||||
assert!(page.html.contains("\\frac{"), "{}", page.html);
|
||||
fn markdown_writes_as_html() {
|
||||
let text = "# T\n\n- [x] done\n- b\n\n3. c\n\n| l | r |\n|:--|--:|\n| 1 | 2 |\n\n~~gone~~ [a](https://x.org \"t\") [r][ref] [bad](javascript:alert(1))\n\nNote[^1].\n\n[^1]: The note.\n\n[ref]: /docs\n";
|
||||
let html = page(text, None, &Pictures::Inline).html;
|
||||
for part in [
|
||||
"<h1>T</h1>",
|
||||
"<li><input type=\"checkbox\" disabled checked> done</li>",
|
||||
"<ol start=\"3\">",
|
||||
"<th style=\"text-align:left\">l</th>",
|
||||
"<td style=\"text-align:right\">2</td>",
|
||||
"<del>gone</del>",
|
||||
"<a href=\"https://x.org\" title=\"t\">a</a>",
|
||||
"<a href=\"/docs\">r</a>",
|
||||
"<a>bad</a>",
|
||||
"<sup><a href=\"#fn-1\">1</a></sup>",
|
||||
"<li id=\"fn-1\"><p>The note.</p></li>",
|
||||
] {
|
||||
assert!(html.contains(part), "{part} in {html}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -63,15 +76,17 @@ fn front_matter_and_raw_html_do_not_go_live() {
|
||||
);
|
||||
assert!(!page.html.contains("Secret"), "{}", page.html);
|
||||
assert!(!page.html.contains("<script>"), "{}", page.html);
|
||||
assert!(page.html.contains("Text."));
|
||||
assert!(page.html.contains("<p>Text.</p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_pictures_are_data_and_web_ones_stay() {
|
||||
fn inline_pictures_are_data_and_addresses_stay_as_written() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
std::fs::write(dir.path().join("dot.png"), b"png bytes").expect("writes");
|
||||
let text = "  \n";
|
||||
let page = page(text, Some(dir.path()), &Pictures::Inline);
|
||||
let text = format!(
|
||||
"   \n"
|
||||
);
|
||||
let page = page(&text, Some(dir.path()), &Pictures::Inline);
|
||||
assert!(
|
||||
page.html
|
||||
.contains("src=\"data:image/png;base64,cG5nIGJ5dGVz\""),
|
||||
@@ -80,78 +95,133 @@ fn inline_pictures_are_data_and_web_ones_stay() {
|
||||
);
|
||||
assert!(
|
||||
page.html
|
||||
.contains("src=\"https://example.com/c.png?x=1&y=2\""),
|
||||
.contains("src=\"https://example.com/a%2Fb.png?q=a%26b&x=1\""),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(
|
||||
page.html
|
||||
.contains(&format!("src=\"data:image/png;base64,{DOT}\"")),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(
|
||||
page.html.contains("<img src=\"gone.png\" alt=\"b\">"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert_eq!(page.missing, ["gone.png"]);
|
||||
assert!(page.copies.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_pictures_copy_once_under_names_of_their_own() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
for folder in ["one", "two"] {
|
||||
for (folder, name) in [("one", "image.png"), ("two", "IMAGE.PNG")] {
|
||||
std::fs::create_dir(dir.path().join(folder)).expect("makes");
|
||||
std::fs::write(dir.path().join(folder).join("my pic.png"), folder).expect("writes");
|
||||
std::fs::write(dir.path().join(folder).join(name), folder).expect("writes");
|
||||
}
|
||||
let text = "  \n";
|
||||
let page = page(
|
||||
text,
|
||||
Some(dir.path()),
|
||||
&Pictures::Folder("doc_files".into()),
|
||||
);
|
||||
let names: Vec<&str> = page.copies.iter().map(|(_, name)| name.as_str()).collect();
|
||||
assert_eq!(names, ["my pic.png", "my pic-2.png"]);
|
||||
let shelf = dir.path().join("note#1_files");
|
||||
let text = "  \n";
|
||||
let page = page(text, Some(dir.path()), &Pictures::Folder(shelf.clone()));
|
||||
let mut held: Vec<Vec<u8>> = std::fs::read_dir(&shelf)
|
||||
.expect("the folder")
|
||||
.map(|entry| std::fs::read(entry.expect("an entry").path()).expect("reads"))
|
||||
.collect();
|
||||
held.sort();
|
||||
assert_eq!(
|
||||
page.html.matches("src=\"doc_files/my%20pic.png\"").count(),
|
||||
held,
|
||||
[b"one".to_vec(), b"two".to_vec()],
|
||||
"no picture overwrote another"
|
||||
);
|
||||
assert_eq!(
|
||||
page.html
|
||||
.matches("src=\"note%231_files/image.png\"")
|
||||
.count(),
|
||||
2,
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_already_there_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
std::fs::write(dir.path().join("dot.png"), b"p").expect("writes");
|
||||
let shelf = dir.path().join("taken");
|
||||
std::fs::create_dir(&shelf).expect("makes");
|
||||
let error = body(
|
||||
"\n",
|
||||
Some(dir.path()),
|
||||
&Pictures::Folder(shelf),
|
||||
)
|
||||
.expect_err("refused");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_leaves_out_what_mupdf_cannot_read_as_words() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
std::fs::write(dir.path().join("a.webp"), b"w").expect("writes");
|
||||
let text = "  \n";
|
||||
let page = page(
|
||||
text,
|
||||
Some(dir.path()),
|
||||
&Pictures::Pdf(dir.path().join("files")),
|
||||
);
|
||||
assert_eq!(
|
||||
page.missing,
|
||||
["a.webp", "https://example.com/c.png", "gone.png"]
|
||||
);
|
||||
assert!(!page.html.contains("<img"), "{}", page.html);
|
||||
assert!(
|
||||
page.html.contains("src=\"doc_files/my%20pic-2.png\""),
|
||||
page.html.contains("[first]") && page.html.contains("[third]"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_leaves_out_what_mupdf_cannot_read() {
|
||||
fn other_schemes_and_untitled_pictures_are_missing() {
|
||||
let page = page(" \n", None, &Pictures::Inline);
|
||||
assert_eq!(page.missing, ["a.png", "javascript:x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_address_reads_its_path() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
std::fs::write(dir.path().join("a.webp"), b"w").expect("writes");
|
||||
std::fs::write(dir.path().join("b.png"), b"p").expect("writes");
|
||||
let text = "  \n";
|
||||
let page = page(text, Some(dir.path()), &Pictures::Pdf("files".into()));
|
||||
assert_eq!(page.missing, ["a.webp", "https://example.com/c.png"]);
|
||||
assert_eq!(page.copies.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blocked_address_is_missing_by_its_text() {
|
||||
let page = page("\n", None, &Pictures::Inline);
|
||||
assert_eq!(page.missing, ["the chart"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untitled_document_has_no_folder_for_pictures() {
|
||||
let page = page("\n", None, &Pictures::Inline);
|
||||
assert_eq!(page.missing, ["a.png"]);
|
||||
let picture = dir.path().join("d o t.png");
|
||||
std::fs::write(&picture, b"png").expect("writes");
|
||||
let path = picture
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.replace(' ', "%20");
|
||||
let url = if path.starts_with('/') {
|
||||
format!("file://{path}")
|
||||
} else {
|
||||
format!("file:///{path}")
|
||||
};
|
||||
let page = page(&format!("\n"), None, &Pictures::Inline);
|
||||
assert!(page.missing.is_empty(), "{:?}", page.missing);
|
||||
assert!(
|
||||
page.html.contains("data:image/png;base64,cG5n"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_wraps_the_body_and_escapes_its_title() {
|
||||
let html = document("a<b & c", "<p>x</p>\n");
|
||||
let html = document("a<b & c", "<p>x</p>");
|
||||
assert!(html.starts_with("<!doctype html>"));
|
||||
assert!(html.contains("<title>a<b & c</title>") && html.contains("<p>x</p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_keeps_words_code_and_tex() {
|
||||
let text = "---\ntitle: x\n---\n\n# Head *one*\n\n- a\n- b `c`\n\n| h | i |\n|---|---|\n| 1 | 2 |\n\n$$\n\\sqrt{x}\n$$\n\n> quoted $y$\n";
|
||||
let text = "---\ntitle: x\n---\n\n# Head *one*\n\n- a\n- b `c`\n\n| h | i |\n|---|---|\n| 1 | 2 |\n\n$$\n\\sqrt{x}\n$$\n\n> quoted $y$ ![a cat][cat]\n\n[cat]: pic.png\n";
|
||||
assert_eq!(
|
||||
plain(text).expect("text"),
|
||||
"Head one\n\na\nb c\n\nh\ti\n1\t2\n\n\\sqrt{x}\n\nquoted y\n"
|
||||
"Head one\n\na\nb c\n\nh\ti\n1\t2\n\n\\sqrt{x}\n\nquoted y a cat\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +229,7 @@ mod files {
|
||||
use std::path::Path;
|
||||
|
||||
use super::super::{Format, Target, write};
|
||||
use super::DOT;
|
||||
|
||||
fn target<'a>(path: &'a Path, work: &'a Path) -> Target<'a> {
|
||||
Target {
|
||||
@@ -169,14 +240,10 @@ mod files {
|
||||
}
|
||||
}
|
||||
|
||||
fn picture(dir: &Path) {
|
||||
std::fs::write(dir.join("dot.png"), b"png").expect("writes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_carries_its_pictures_inside() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
picture(dir.path());
|
||||
std::fs::write(dir.path().join("dot.png"), b"png").expect("writes");
|
||||
let out = dir.path().join("out.html");
|
||||
write(
|
||||
Format::Page,
|
||||
@@ -193,7 +260,7 @@ mod files {
|
||||
#[test]
|
||||
fn a_linked_page_copies_into_a_new_folder_only() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
picture(dir.path());
|
||||
std::fs::write(dir.path().join("dot.png"), b"png").expect("writes");
|
||||
let out = dir.path().join("out.html");
|
||||
write(
|
||||
Format::Linked,
|
||||
@@ -222,6 +289,10 @@ mod files {
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
|
||||
assert!(error.to_string().contains("out_files"), "{error}");
|
||||
assert!(!out.exists(), "nothing written");
|
||||
assert!(
|
||||
dir.path().join("out_files/dot.png").exists(),
|
||||
"the folder found stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -261,32 +332,74 @@ mod files {
|
||||
);
|
||||
}
|
||||
|
||||
/// The text of a PDF's first page.
|
||||
fn words(pdf: &Path) -> String {
|
||||
let mut worker = ely_pdf_engine::Worker::spawn().expect("the worker");
|
||||
let doc = worker.open(pdf).expect("opens").doc;
|
||||
worker
|
||||
.text(doc, 0)
|
||||
.expect("text")
|
||||
.iter()
|
||||
.map(|ch| ch.c)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_sets_the_page_and_leaves_no_scratch() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let work = tempfile::tempdir().expect("work");
|
||||
let out = dir.path().join("out.pdf");
|
||||
let text = "# Heading 标题\n\nInline $x^2$.\n\n```mermaid\nflowchart LR\n A --> B\n```\n";
|
||||
let text = format!(
|
||||
"# Heading 标题\n\nInline $x^2$.\n\n \n\n```mermaid\nflowchart LR\n A --> B\n```\n"
|
||||
);
|
||||
let page = write(
|
||||
Format::Pdf,
|
||||
text,
|
||||
&text,
|
||||
Some(dir.path()),
|
||||
&target(&out, work.path()),
|
||||
)
|
||||
.expect("writes");
|
||||
assert!(page.missing.is_empty() && page.formulas.is_empty() && page.diagrams.is_empty());
|
||||
assert!(!work.path().join("pdf").exists(), "the scratch is gone");
|
||||
let mut worker = ely_pdf_engine::Worker::spawn().expect("the worker");
|
||||
let doc = worker.open(&out).expect("opens").doc;
|
||||
let words: String = worker
|
||||
.text(doc, 0)
|
||||
.expect("text")
|
||||
.iter()
|
||||
.map(|ch| ch.c)
|
||||
.collect();
|
||||
assert_eq!(page.missing, ["gone.png"]);
|
||||
assert!(page.formulas.is_empty() && page.diagrams.is_empty());
|
||||
assert_eq!(
|
||||
std::fs::read_dir(work.path()).expect("work").count(),
|
||||
0,
|
||||
"the scratch is gone"
|
||||
);
|
||||
let words = words(&out);
|
||||
assert!(
|
||||
words.contains("Heading") && words.contains("标题"),
|
||||
words.contains("Heading") && words.contains("标题") && words.contains("[lost]"),
|
||||
"{words:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfs_at_once_keep_to_their_own_pages() {
|
||||
let (dir, work) = (
|
||||
tempfile::tempdir().expect("a dir"),
|
||||
tempfile::tempdir().expect("work"),
|
||||
);
|
||||
let jobs: Vec<_> = (0..4)
|
||||
.map(|n| {
|
||||
let (out, work) = (
|
||||
dir.path().join(format!("{n}.pdf")),
|
||||
work.path().to_path_buf(),
|
||||
);
|
||||
std::thread::spawn(move || {
|
||||
write(
|
||||
Format::Pdf,
|
||||
&format!("# Page{n}\n"),
|
||||
None,
|
||||
&target(&out, &work),
|
||||
)
|
||||
.expect("writes");
|
||||
out
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for (n, job) in jobs.into_iter().enumerate() {
|
||||
let out = job.join().expect("the job");
|
||||
assert!(words(&out).contains(&format!("Page{n}")), "job {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user