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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 10:39:36 -04:00
1 parent d7ded183ae
commit 88b0f8f5fc
12 files changed
+647 -312

No files matched your search

+11 -6
View File
@@ -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);
}
}
}
}
+21 -36
View File
@@ -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(())
}
+278
View File
@@ -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(&note, 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
View File
@@ -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('&', "&amp;").replace('<', "&lt;");
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("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&amp;", "&")
/// Text safe between tags.
fn escape(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
/// 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('"', "&quot;")
}
+105 -75
View File
@@ -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('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
}
+1
View File
@@ -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(),
}
+189 -76
View File
@@ -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 = "![a](dot.png) ![b](gone.png) ![c](https://example.com/c.png?x=1&y=2)\n";
let page = page(text, Some(dir.path()), &Pictures::Inline);
let text = format!(
"![a](dot.png) ![b](gone.png) ![c](https://example.com/a%2Fb.png?q=a%26b&x=1) ![d](data:image/png;base64,{DOT})\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&amp;y=2\""),
.contains("src=\"https://example.com/a%2Fb.png?q=a%26b&amp;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 = "![a](one/my%20pic.png) ![b](two/my%20pic.png) ![c](<one/my pic.png>)\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 = "![a](one/image.png) ![b](two/IMAGE.PNG) ![c](one/image.png)\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(
"![a](dot.png)\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 = "![first](a.webp) ![second](https://example.com/c.png) ![third](gone.png)\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("![a](a.png) ![b](javascript:x)\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 = "![a](a.webp) ![b](b.png) ![c](https://example.com/c.png)\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("![the chart](file:///tmp/c.png)\n", None, &Pictures::Inline);
assert_eq!(page.missing, ["the chart"]);
}
#[test]
fn an_untitled_document_has_no_folder_for_pictures() {
let page = page("![a](a.png)\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!("![a](<{url}>)\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&lt;b &amp; 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![dot](data:image/png;base64,{DOT}) ![lost](gone.png)\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}");
}
}
}