C14.14: export HTML, HTML and folder, PDF (MuPDF story in the PDF worker) and plain text; print; rich copy
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f1cb7eff99
commit
d7ded183ae
27 files changed
+1427
-24
No files matched your search
@@ -12,14 +12,17 @@ embed-resource.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
base64.workspace = true
|
||||
ely-files.workspace = true
|
||||
ely-gpui-component.workspace = true
|
||||
ely-office.workspace = true
|
||||
ely-pdf-engine.workspace = true
|
||||
ely-platform.workspace = true
|
||||
ely-ui.workspace = true
|
||||
gpui.workspace = true
|
||||
log.workspace = true
|
||||
futures.workspace = true
|
||||
markdown = "=1.0.0"
|
||||
jiff.workspace = true
|
||||
notify.workspace = true
|
||||
serde.workspace = true
|
||||
@@ -29,7 +32,6 @@ serde_json.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
markdown = "=1.0.0"
|
||||
ely-gpui-component = { workspace = true, features = ["test-support"] }
|
||||
ely-ui = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
|
||||
@@ -30,6 +30,8 @@ pub struct Desk {
|
||||
pub core: Core<Desk>,
|
||||
/// The tab being renamed and its name field.
|
||||
pub naming: Option<(u64, Entity<TextInput>)>,
|
||||
/// The export dialog's tab and format.
|
||||
pub exporting: Option<(u64, crate::output::Format)>,
|
||||
pub folder: Option<crate::folder::Folder>,
|
||||
/// Folder listings so far; searches redo on a new one.
|
||||
pub listings: u64,
|
||||
@@ -186,6 +188,7 @@ pub(crate) fn open_in(
|
||||
Desk {
|
||||
core: Core::new(Arc::new(Mutex::new(())), work, cx),
|
||||
naming: None,
|
||||
exporting: None,
|
||||
folder: None,
|
||||
listings: 0,
|
||||
recent,
|
||||
@@ -440,8 +443,9 @@ impl Render for Desk {
|
||||
.children(self.core_dialog(cx))
|
||||
.children(self.naming_element(cx))
|
||||
.children(self.compare_dialog(cx))
|
||||
.children(self.find_box(cx));
|
||||
Self::table_actions(Self::look_actions(root, cx), cx)
|
||||
.children(self.find_box(cx))
|
||||
.children(self.export_dialog(cx));
|
||||
Self::output_actions(Self::table_actions(Self::look_actions(root, cx), cx), cx)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Export, print and rich copy (MARK-11).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use ely_gpui_component::{
|
||||
buttons::{Button, ButtonVariant, SegmentedControl},
|
||||
overlays::Dialog,
|
||||
primitives::Severity,
|
||||
theme::ControlSize,
|
||||
};
|
||||
use ely_office::prelude::*;
|
||||
use ely_platform::clipboard::{self, Clip};
|
||||
use ely_ui::{
|
||||
commands::{Export, Print},
|
||||
i18n::text,
|
||||
};
|
||||
use gpui::{
|
||||
AnyElement, Context, Div, InteractiveElement, IntoElement, ParentElement as _, TaskExt as _,
|
||||
actions,
|
||||
};
|
||||
|
||||
use crate::desk::Desk;
|
||||
use crate::output::{self, Format, Page, Pictures, Target};
|
||||
|
||||
actions!(mark, [CopyRich]);
|
||||
|
||||
const FORMATS: [(Format, &str, &str); 4] = [
|
||||
(Format::Page, "page", "html"),
|
||||
(Format::Linked, "linked", "html"),
|
||||
(Format::Pdf, "pdf", "pdf"),
|
||||
(Format::Text, "text", "txt"),
|
||||
];
|
||||
|
||||
fn key(format: Format) -> &'static str {
|
||||
FORMATS
|
||||
.iter()
|
||||
.find(|(each, ..)| *each == format)
|
||||
.expect("listed")
|
||||
.1
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
pub fn output_actions(root: Div, cx: &mut Context<Self>) -> Div {
|
||||
root.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx)))
|
||||
.on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx)))
|
||||
.on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy_rich(cx)))
|
||||
}
|
||||
|
||||
fn no_document(&self, cx: &mut Context<Self>) {
|
||||
let said = text(cx, "command.blocked.no_document", &[]);
|
||||
self.toast(said, Severity::Warning, cx);
|
||||
}
|
||||
|
||||
fn ask_export(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.core.active else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
self.exporting = Some((tab, Format::Page));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn export_dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let (_, format) = self.exporting?;
|
||||
let (picking, going, closing) = (cx.entity(), cx.entity(), cx.entity());
|
||||
let formats = FORMATS
|
||||
.iter()
|
||||
.fold(
|
||||
SegmentedControl::new("mark-export-format", key(format)),
|
||||
|control, (_, value, _)| {
|
||||
control.segment(*value, text(cx, &format!("mark.export.{value}"), &[]), None)
|
||||
},
|
||||
)
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(move |value, _, cx| {
|
||||
let Some((chosen, ..)) =
|
||||
FORMATS.iter().find(|(_, each, _)| *each == value.as_ref())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let chosen = *chosen;
|
||||
picking.update(cx, |desk, cx| {
|
||||
if let Some((_, format)) = &mut desk.exporting {
|
||||
*format = chosen;
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
});
|
||||
let go = text(cx, "mark.export.go", &[]);
|
||||
let card = Dialog::new("mark-export", text(cx, "mark.export", &[]), move |_, cx| {
|
||||
closing.update(cx, |desk, cx| {
|
||||
desk.exporting = None;
|
||||
cx.notify();
|
||||
})
|
||||
})
|
||||
.child(formats)
|
||||
.action(ely_office::cancel(cx))
|
||||
.action(move |_| {
|
||||
Button::new("mark-export-go", go)
|
||||
.variant(ButtonVariant::Primary)
|
||||
.on_click(move |_, _, cx| going.update(cx, |desk, cx| desk.export_asked(cx)))
|
||||
});
|
||||
Some(card.into_any_element())
|
||||
}
|
||||
|
||||
/// Asks where, then writes the chosen format.
|
||||
fn export_asked(&mut self, cx: &mut Context<Self>) {
|
||||
let Some((tab, format)) = self.exporting.take() else {
|
||||
return;
|
||||
};
|
||||
cx.notify();
|
||||
let Some(source) = self.tab(tab) else {
|
||||
return;
|
||||
};
|
||||
let Some(dir) = ely_office::folder(source) else {
|
||||
return self.export_failed("no documents folder", cx);
|
||||
};
|
||||
let stem = source
|
||||
.name
|
||||
.rsplit_once('.')
|
||||
.map_or(source.name.as_ref(), |(stem, _)| stem);
|
||||
let extension = FORMATS
|
||||
.iter()
|
||||
.find(|(each, ..)| *each == format)
|
||||
.expect("listed")
|
||||
.2;
|
||||
let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{extension}")));
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let chosen = match chosen.await {
|
||||
Ok(Ok(path)) => Ok(path),
|
||||
Ok(Err(error)) => Err(error.to_string()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
};
|
||||
desk.update(cx, |desk, cx| match chosen {
|
||||
Ok(Some(path)) => desk.export_to(tab, path, format, cx),
|
||||
Ok(None) => log::info!("mark: the export panel was cancelled"),
|
||||
Err(error) => desk.export_failed(&error, cx),
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
pub fn export_to(&mut self, tab: u64, path: PathBuf, format: Format, cx: &mut Context<Self>) {
|
||||
if !self.settled(tab, cx) {
|
||||
return self.when_settled(tab, move |desk, _, cx| {
|
||||
desk.export_to(tab, path, format, cx)
|
||||
});
|
||||
}
|
||||
let (Some(source), Some(markdown)) = (self.tab(tab), self.text_of(tab)) else {
|
||||
return;
|
||||
};
|
||||
match self.holder(&path) {
|
||||
Ok(None) => {}
|
||||
Ok(Some(_)) => return self.export_failed("an open document is there", cx),
|
||||
Err(error) => return self.export_failed(&error.to_string(), cx),
|
||||
}
|
||||
let session = source.session.path().map(PathBuf::from);
|
||||
let beside = session
|
||||
.as_deref()
|
||||
.and_then(|path| path.parent())
|
||||
.map(PathBuf::from);
|
||||
let (revision, work) = (source.session.revision(), source.work.clone());
|
||||
let opened = path.clone();
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let written = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let target = Target {
|
||||
path: &path,
|
||||
session: session.as_deref(),
|
||||
revision,
|
||||
work: &work,
|
||||
};
|
||||
output::write(format, &markdown, beside.as_deref(), &target)
|
||||
})
|
||||
.await;
|
||||
desk.update(cx, |desk, cx| match written {
|
||||
Ok(page) => {
|
||||
let name = ely_office::files::file_name(&opened);
|
||||
desk.toast_output(text(cx, "mark.export.done", &[("name", &name)]), opened, cx);
|
||||
desk.left_out(&page, cx);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
let said = text(
|
||||
cx,
|
||||
"mark.export.folder_taken",
|
||||
&[("name", &error.to_string())],
|
||||
);
|
||||
desk.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
Err(error) => desk.export_failed(&error.to_string(), cx),
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
/// Says what an export could not carry.
|
||||
fn left_out(&self, page: &Page, cx: &mut Context<Self>) {
|
||||
// Names and errors quote the document: not logged.
|
||||
if !page.missing.is_empty() {
|
||||
let said = text(
|
||||
cx,
|
||||
"mark.export.missing",
|
||||
&[("list", &page.missing.join(", "))],
|
||||
);
|
||||
self.toast_unlogged(said, Severity::Warning, cx);
|
||||
}
|
||||
if !page.diagrams.is_empty() {
|
||||
let lines: Vec<String> = page.diagrams.iter().map(usize::to_string).collect();
|
||||
let said = text(cx, "mark.export.diagrams", &[("list", &lines.join(", "))]);
|
||||
self.toast(said, Severity::Warning, cx);
|
||||
}
|
||||
if !page.formulas.is_empty() {
|
||||
let each = page.formulas.iter().map(|(line, error)| {
|
||||
text(
|
||||
cx,
|
||||
"mark.export.formula",
|
||||
&[("line", &line.to_string()), ("error", error)],
|
||||
)
|
||||
.to_string()
|
||||
});
|
||||
let said = text(
|
||||
cx,
|
||||
"mark.export.formulas",
|
||||
&[("list", &each.collect::<Vec<_>>().join("; "))],
|
||||
);
|
||||
self.toast_unlogged(said, Severity::Warning, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn export_failed(&self, error: &str, cx: &mut Context<Self>) {
|
||||
let said = text(cx, "mark.export.failed", &[("error", error)]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
|
||||
/// Prints the PDF export through the system panel.
|
||||
fn print(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(tab) = self.core.active else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
if !self.settled(tab, cx) {
|
||||
return self.when_settled(tab, |desk, _, cx| desk.print(cx));
|
||||
}
|
||||
let (Some(source), Some(markdown)) = (self.tab(tab), self.text_of(tab)) else {
|
||||
return;
|
||||
};
|
||||
let beside = source
|
||||
.session
|
||||
.path()
|
||||
.and_then(|path| path.parent())
|
||||
.map(PathBuf::from);
|
||||
let (revision, work) = (source.session.revision(), source.work.clone());
|
||||
let pdf = work.join("print.pdf");
|
||||
log::info!("mark: prints tab {tab}");
|
||||
cx.spawn(async move |desk, cx| {
|
||||
let written = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let target = Target {
|
||||
path: &pdf,
|
||||
session: None,
|
||||
revision,
|
||||
work: &work,
|
||||
};
|
||||
output::write(Format::Pdf, &markdown, beside.as_deref(), &target)
|
||||
.map(|page| (page, pdf))
|
||||
})
|
||||
.await;
|
||||
desk.update(cx, |desk, cx| match written {
|
||||
Ok((page, pdf)) => {
|
||||
desk.left_out(&page, cx);
|
||||
ely_office::print_later(pdf, cx);
|
||||
}
|
||||
Err(error) => desk.print_failed(&error.to_string(), cx),
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
/// The selection, or the whole text, as HTML.
|
||||
fn copy_rich(&mut self, cx: &mut Context<Self>) {
|
||||
let Some(view) = self.active_view() else {
|
||||
return self.no_document(cx);
|
||||
};
|
||||
let document = view.read(cx).document().clone();
|
||||
let selected = document.read(cx).selected(cx);
|
||||
let markdown = match selected.is_empty() {
|
||||
true => document.read(cx).text(cx),
|
||||
false => selected,
|
||||
};
|
||||
let beside = self
|
||||
.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 clip = Clip::Html {
|
||||
html: page.html,
|
||||
text: markdown,
|
||||
};
|
||||
clipboard::write(&clip).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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,88 @@
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use crate::desk::tests::{open, start};
|
||||
use crate::output::Format;
|
||||
|
||||
#[gpui::test]
|
||||
fn export_asks_for_a_format_then_writes_it(cx: &mut TestAppContext) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("note.md");
|
||||
std::fs::write(&path, "# Note\n\nText $x$.\n").expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
cx.dispatch_action(ely_ui::commands::Export);
|
||||
cx.run_until_parked();
|
||||
let (tab, format) = desk.read_with(cx, |desk, _| desk.exporting.expect("the dialog"));
|
||||
assert_eq!(format, Format::Page);
|
||||
let out = files.path().join("note.html");
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.exporting = None;
|
||||
desk.export_to(tab, out.clone(), Format::Page, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let html = std::fs::read_to_string(&out).expect("written");
|
||||
assert!(
|
||||
html.contains("<h1>Note</h1>") && html.contains("<svg"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn export_never_writes_over_the_open_file(cx: &mut TestAppContext) {
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("note.md");
|
||||
std::fs::write(&path, "# Note\n").expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
let tab = desk.read_with(cx, |desk, _| desk.core.active.expect("a tab"));
|
||||
desk.update(cx, |desk, cx| {
|
||||
desk.export_to(tab, path.clone(), Format::Text, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(std::fs::read_to_string(&path).expect("reads"), "# Note\n");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn rich_copy_takes_the_selection_as_html(cx: &mut TestAppContext) {
|
||||
if std::env::var_os("ELY_CLIPBOARD_TEST").is_none() {
|
||||
eprintln!("SKIP: set ELY_CLIPBOARD_TEST=1; this replaces the clipboard");
|
||||
return;
|
||||
}
|
||||
let (data, files) = (
|
||||
tempfile::tempdir().expect("data"),
|
||||
tempfile::tempdir().expect("files"),
|
||||
);
|
||||
let path = files.path().join("note.md");
|
||||
std::fs::write(&path, "# Note\n\n**Bold** words.\n").expect("a file");
|
||||
let (desk, cx) = start(cx, data.path());
|
||||
open(&desk, &path, cx);
|
||||
let view = desk.read_with(cx, |desk, _| desk.active_view().expect("a tab"));
|
||||
view.update_in(cx, |view, window, cx| {
|
||||
view.switch(crate::view::Mode::Source, window, cx)
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let document = view.read_with(cx, |view, _| view.document().clone());
|
||||
document.update(cx, |document, cx| {
|
||||
document
|
||||
.editor()
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(8..22), cx))
|
||||
});
|
||||
cx.dispatch_action(crate::export::CopyRich);
|
||||
let clip = ely_platform::clipboard::read()
|
||||
.expect("reads")
|
||||
.expect("a clip");
|
||||
let ely_platform::clipboard::Clip::Html { html, text } = clip else {
|
||||
panic!("not HTML: {clip:?}");
|
||||
};
|
||||
assert_eq!(text, "**Bold** words");
|
||||
assert!(
|
||||
html.contains("<strong>Bold</strong>") && !html.contains("Note"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
@@ -40,8 +40,10 @@ pub fn menus(look: &crate::look::Look, cx: &App) -> Vec<Menu> {
|
||||
ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow,
|
||||
};
|
||||
vec![
|
||||
Menu::new(text(cx, "menu.edit", &[]))
|
||||
.items([item("office.find.open", Box::new(ely_office::FindReplace))]),
|
||||
Menu::new(text(cx, "menu.edit", &[])).items([
|
||||
item("office.find.open", Box::new(ely_office::FindReplace)),
|
||||
item("mark.copy.rich", Box::new(crate::export::CopyRich)),
|
||||
]),
|
||||
Menu::new(text(cx, "mark.menu.table", &[])).items([
|
||||
item("mark.table.insert", Box::new(InsertTable)),
|
||||
MenuItem::separator(),
|
||||
|
||||
@@ -6,10 +6,12 @@ mod autosave;
|
||||
mod compare;
|
||||
mod desk;
|
||||
mod engine;
|
||||
mod export;
|
||||
mod finding;
|
||||
mod folder;
|
||||
mod keys;
|
||||
mod look;
|
||||
mod output;
|
||||
mod outside;
|
||||
mod places;
|
||||
mod rename;
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! An export written to disk.
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use super::{Page, Pictures, body, document, plain};
|
||||
|
||||
/// What an export writes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
/// One HTML file, pictures inside.
|
||||
Page,
|
||||
/// HTML and a folder of its pictures.
|
||||
Linked,
|
||||
Pdf,
|
||||
Text,
|
||||
}
|
||||
|
||||
/// Where one export goes, and what it leaves alone.
|
||||
pub struct Target<'a> {
|
||||
pub path: &'a Path,
|
||||
/// The tab's own file, never written over.
|
||||
pub session: Option<&'a Path>,
|
||||
pub revision: u64,
|
||||
/// A scratch folder of the tab's.
|
||||
pub work: &'a Path,
|
||||
}
|
||||
|
||||
/// `text` written as `format`; what it left out.
|
||||
pub fn write(
|
||||
format: Format,
|
||||
text: &str,
|
||||
beside: Option<&Path>,
|
||||
target: &Target,
|
||||
) -> io::Result<Page> {
|
||||
let title = target
|
||||
.path
|
||||
.file_stem()
|
||||
.map_or_else(String::new, |stem| stem.to_string_lossy().into_owned());
|
||||
let page = match format {
|
||||
Format::Page => {
|
||||
let page = body(text, beside, &Pictures::Inline)?;
|
||||
export(target, document(&title, &page.html).as_bytes())?;
|
||||
page
|
||||
}
|
||||
Format::Linked => linked(text, beside, target, &title)?,
|
||||
Format::Pdf => pdf(text, beside, target, &title)?,
|
||||
Format::Text => {
|
||||
export(target, plain(text)?.as_bytes())?;
|
||||
Page::default()
|
||||
}
|
||||
};
|
||||
log::info!("mark: exported {format:?}, revision {}", target.revision);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
fn export(target: &Target, bytes: &[u8]) -> io::Result<()> {
|
||||
ely_files::export(target.path, target.session, bytes, target.revision).map(|_| ())
|
||||
}
|
||||
|
||||
/// 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(&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)
|
||||
}
|
||||
|
||||
/// The page set by MuPDF, from a scratch folder.
|
||||
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"))?;
|
||||
let set = (|| -> io::Result<Page> {
|
||||
let page = body(text, beside, &Pictures::Pdf("files".into()))?;
|
||||
copy(&page.copies, &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)?;
|
||||
export(target, &std::fs::read(&out)?)?;
|
||||
Ok(page)
|
||||
})();
|
||||
let cleaned = std::fs::remove_dir_all(&dir);
|
||||
let page = set?;
|
||||
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,172 @@
|
||||
//! Markdown as an HTML page or plain text (MARK-11).
|
||||
|
||||
mod files;
|
||||
mod pictures;
|
||||
mod plain;
|
||||
#[cfg(test)]
|
||||
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};
|
||||
|
||||
pub use files::{Format, Target, write};
|
||||
pub use pictures::Pictures;
|
||||
pub use plain::plain;
|
||||
|
||||
/// A page and what it could not carry.
|
||||
#[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.
|
||||
pub diagrams: Vec<usize>,
|
||||
/// Lines of formulas kept as code, and why.
|
||||
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}\
|
||||
code{font-family:monospace}\
|
||||
table{border-collapse:collapse}th,td{border:1px solid #d0d7de;padding:.3em .6em}\
|
||||
blockquote{margin-left:0;padding-left:1em;border-left:3px solid #d0d7de;color:#59636e}\
|
||||
img,svg{max-width:100%}div.math{text-align:center}span.math svg{vertical-align:middle}";
|
||||
|
||||
/// `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)
|
||||
.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;
|
||||
log::info!(
|
||||
"mark: a page of {} bytes, {} copies, {} missing, {} diagrams and {} formulas as code",
|
||||
page.html.len(),
|
||||
page.copies.len(),
|
||||
page.missing.len(),
|
||||
page.diagrams.len(),
|
||||
page.formulas.len()
|
||||
);
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
/// 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"
|
||||
)
|
||||
}
|
||||
|
||||
/// markdown-rs's four escapes undone.
|
||||
fn unescape(text: &str) -> String {
|
||||
text.replace("<", "<")
|
||||
.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))
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Where an exported page's pictures go.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// Picture formats MuPDF reads from files.
|
||||
const READABLE: [&str; 8] = ["png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "svg"];
|
||||
|
||||
pub(super) struct Placed<'a> {
|
||||
beside: Option<&'a Path>,
|
||||
pictures: &'a Pictures,
|
||||
pub copies: Vec<(PathBuf, String)>,
|
||||
}
|
||||
|
||||
impl<'a> Placed<'a> {
|
||||
pub fn new(beside: Option<&'a Path>, pictures: &'a Pictures) -> Self {
|
||||
Self {
|
||||
beside,
|
||||
pictures,
|
||||
copies: 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();
|
||||
}
|
||||
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);
|
||||
}
|
||||
let path = match (Path::new(&address).is_absolute(), self.beside) {
|
||||
(true, _) => PathBuf::from(&address),
|
||||
(false, Some(beside)) => beside.join(&address),
|
||||
(false, None) => {
|
||||
missing.push(address);
|
||||
return String::new();
|
||||
}
|
||||
};
|
||||
match self.carried(&path) {
|
||||
Some(src) => src,
|
||||
None => {
|
||||
missing.push(address);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn carried(&mut self, path: &Path) -> 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 data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
return Some(format!("data:{};base64,{data}", mime(&extension)));
|
||||
}
|
||||
Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return 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)))
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
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}"),
|
||||
_ => format!("{stem}-{count}.{extension}"),
|
||||
};
|
||||
}
|
||||
self.copies.push((path.to_path_buf(), name.clone()));
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
fn mime(extension: &str) -> &'static str {
|
||||
match extension {
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"svg" => "image/svg+xml",
|
||||
"webp" => "image/webp",
|
||||
"bmp" => "image/bmp",
|
||||
"tif" | "tiff" => "image/tiff",
|
||||
"avif" => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())])
|
||||
}
|
||||
|
||||
/// `%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();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut at = 0;
|
||||
while at < bytes.len() {
|
||||
let hex = bytes
|
||||
.get(at + 1..at + 3)
|
||||
.and_then(|pair| std::str::from_utf8(pair).ok());
|
||||
match (
|
||||
bytes[at],
|
||||
hex.and_then(|pair| u8::from_str_radix(pair, 16).ok()),
|
||||
) {
|
||||
(b'%', Some(byte)) => {
|
||||
out.push(byte);
|
||||
at += 3;
|
||||
}
|
||||
(byte, _) => {
|
||||
out.push(byte);
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).unwrap_or(src)
|
||||
}
|
||||
|
||||
/// A name as a path segment of a URL.
|
||||
fn encoded(name: &str) -> String {
|
||||
name.bytes()
|
||||
.map(|byte| match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||
(byte as char).to_string()
|
||||
}
|
||||
_ => format!("%{byte:02X}"),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A value safe inside a quoted attribute.
|
||||
fn attribute(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Markdown as plain text: words, code and TeX, no marks.
|
||||
|
||||
use std::io;
|
||||
|
||||
use ely_gpui_component::documents::markdown_options;
|
||||
use markdown::mdast::Node;
|
||||
|
||||
/// `text` as plain text, a blank line between blocks.
|
||||
pub fn plain(text: &str) -> io::Result<String> {
|
||||
let tree = markdown::to_mdast(text, &markdown_options())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let mut blocks = Vec::new();
|
||||
for child in tree.children().into_iter().flatten() {
|
||||
block(child, &mut blocks);
|
||||
}
|
||||
Ok(blocks.join("\n\n") + "\n")
|
||||
}
|
||||
|
||||
fn block(node: &Node, blocks: &mut Vec<String>) {
|
||||
match node {
|
||||
Node::Code(code) => blocks.push(code.value.clone()),
|
||||
Node::Math(math) => blocks.push(math.value.clone()),
|
||||
Node::List(list) => {
|
||||
let items = list.children.iter().map(|item| {
|
||||
let mut inner = Vec::new();
|
||||
for child in item.children().into_iter().flatten() {
|
||||
block(child, &mut inner);
|
||||
}
|
||||
inner.join("\n")
|
||||
});
|
||||
blocks.push(items.collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
Node::Table(table) => {
|
||||
let rows = table.children.iter().map(|row| {
|
||||
let cells = row.children().into_iter().flatten().map(words);
|
||||
cells.collect::<Vec<_>>().join("\t")
|
||||
});
|
||||
blocks.push(rows.collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
Node::Blockquote(_) | Node::FootnoteDefinition(_) => {
|
||||
for child in node.children().into_iter().flatten() {
|
||||
block(child, blocks);
|
||||
}
|
||||
}
|
||||
Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(node)),
|
||||
// Front matter, HTML, rules and definitions are not words.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The words of an inline run.
|
||||
fn words(node: &Node) -> String {
|
||||
match node {
|
||||
Node::Text(text) => text.value.clone(),
|
||||
Node::InlineCode(code) => code.value.clone(),
|
||||
Node::InlineMath(math) => math.value.clone(),
|
||||
Node::Break(_) => "\n".into(),
|
||||
Node::Image(image) => image.alt.clone(),
|
||||
Node::Html(_) => String::new(),
|
||||
_ => node.children().into_iter().flatten().map(words).collect(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
use std::path::Path;
|
||||
|
||||
use super::{Pictures, body, document, plain};
|
||||
|
||||
fn page(text: &str, beside: Option<&Path>, pictures: &Pictures) -> super::Page {
|
||||
body(text, beside, pictures).expect("a page")
|
||||
}
|
||||
|
||||
#[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")
|
||||
&& page.html.contains("<div class=\"diagram\"><svg")
|
||||
);
|
||||
assert!(page.formulas.is_empty() && page.diagrams.is_empty());
|
||||
}
|
||||
|
||||
#[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";
|
||||
let page = page(text, None, &Pictures::Inline);
|
||||
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
|
||||
assert_eq!(lines, [3, 7]);
|
||||
assert!(page.formulas.iter().all(|(_, why)| !why.is_empty()));
|
||||
assert_eq!(page.diagrams, [9]);
|
||||
assert!(
|
||||
page.html.contains("language-math math-display\">\\frac{"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(
|
||||
page.html
|
||||
.contains("language-math math-inline\">\\sqrt{</code>"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(
|
||||
page.html.contains("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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_matter_and_raw_html_do_not_go_live() {
|
||||
let page = page(
|
||||
"---\ntitle: Secret\n---\n\n<script>alert(1)</script>\n\nText.\n",
|
||||
None,
|
||||
&Pictures::Inline,
|
||||
);
|
||||
assert!(!page.html.contains("Secret"), "{}", page.html);
|
||||
assert!(!page.html.contains("<script>"), "{}", page.html);
|
||||
assert!(page.html.contains("Text."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_pictures_are_data_and_web_ones_stay() {
|
||||
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);
|
||||
assert!(
|
||||
page.html
|
||||
.contains("src=\"data:image/png;base64,cG5nIGJ5dGVz\""),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(
|
||||
page.html
|
||||
.contains("src=\"https://example.com/c.png?x=1&y=2\""),
|
||||
"{}",
|
||||
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"] {
|
||||
std::fs::create_dir(dir.path().join(folder)).expect("makes");
|
||||
std::fs::write(dir.path().join(folder).join("my pic.png"), 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"]);
|
||||
assert_eq!(
|
||||
page.html.matches("src=\"doc_files/my%20pic.png\"").count(),
|
||||
2,
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(
|
||||
page.html.contains("src=\"doc_files/my%20pic-2.png\""),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_leaves_out_what_mupdf_cannot_read() {
|
||||
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"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_wraps_the_body_and_escapes_its_title() {
|
||||
let html = document("a<b & c", "<p>x</p>\n");
|
||||
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";
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
mod files {
|
||||
use std::path::Path;
|
||||
|
||||
use super::super::{Format, Target, write};
|
||||
|
||||
fn target<'a>(path: &'a Path, work: &'a Path) -> Target<'a> {
|
||||
Target {
|
||||
path,
|
||||
session: None,
|
||||
revision: 1,
|
||||
work,
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
let out = dir.path().join("out.html");
|
||||
write(
|
||||
Format::Page,
|
||||
"# Hi\n\n\n",
|
||||
Some(dir.path()),
|
||||
&target(&out, dir.path()),
|
||||
)
|
||||
.expect("writes");
|
||||
let html = std::fs::read_to_string(&out).expect("reads");
|
||||
assert!(html.starts_with("<!doctype html>") && html.contains("<title>out</title>"));
|
||||
assert!(html.contains("data:image/png;base64,"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_page_copies_into_a_new_folder_only() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
picture(dir.path());
|
||||
let out = dir.path().join("out.html");
|
||||
write(
|
||||
Format::Linked,
|
||||
"\n",
|
||||
Some(dir.path()),
|
||||
&target(&out, dir.path()),
|
||||
)
|
||||
.expect("writes");
|
||||
assert_eq!(
|
||||
std::fs::read(dir.path().join("out_files/dot.png")).expect("copied"),
|
||||
b"png"
|
||||
);
|
||||
assert!(
|
||||
std::fs::read_to_string(&out)
|
||||
.expect("reads")
|
||||
.contains("src=\"out_files/dot.png\"")
|
||||
);
|
||||
std::fs::remove_file(&out).expect("removes");
|
||||
let error = write(
|
||||
Format::Linked,
|
||||
"\n",
|
||||
Some(dir.path()),
|
||||
&target(&out, dir.path()),
|
||||
)
|
||||
.expect_err("the folder is there");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
|
||||
assert!(error.to_string().contains("out_files"), "{error}");
|
||||
assert!(!out.exists(), "nothing written");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_page_without_pictures_makes_no_folder() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let out = dir.path().join("out.html");
|
||||
write(
|
||||
Format::Linked,
|
||||
"Text.\n",
|
||||
Some(dir.path()),
|
||||
&target(&out, dir.path()),
|
||||
)
|
||||
.expect("writes");
|
||||
assert!(!dir.path().join("out_files").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_plain_and_the_open_file_is_never_written() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let out = dir.path().join("doc.md");
|
||||
std::fs::write(&out, "# Kept\n").expect("writes");
|
||||
let mut aimed = target(&out, dir.path());
|
||||
aimed.session = Some(&out);
|
||||
write(Format::Text, "# Other\n", None, &aimed).expect_err("refused");
|
||||
assert_eq!(std::fs::read_to_string(&out).expect("reads"), "# Kept\n");
|
||||
let txt = dir.path().join("doc.txt");
|
||||
write(
|
||||
Format::Text,
|
||||
"# Title *here*\n",
|
||||
None,
|
||||
&target(&txt, dir.path()),
|
||||
)
|
||||
.expect("writes");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&txt).expect("reads"),
|
||||
"Title here\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 page = write(
|
||||
Format::Pdf,
|
||||
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!(
|
||||
words.contains("Heading") && words.contains("标题"),
|
||||
"{words:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user