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
Generated
+3
-1
@@ -1922,7 +1922,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ely-gpui-component"
|
||||
version = "0.1.1"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=7ed4ea832334905a85f8fb6ad4d10df4f04c17c7#7ed4ea832334905a85f8fb6ad4d10df4f04c17c7"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=c7a86079fc39c3bfd234ce5b7eca77204f10b7fc#c7a86079fc39c3bfd234ce5b7eca77204f10b7fc"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
@@ -2006,9 +2006,11 @@ name = "ely-mark"
|
||||
version = "0.1.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.23.1",
|
||||
"ely-files",
|
||||
"ely-gpui-component",
|
||||
"ely-office",
|
||||
"ely-pdf-engine",
|
||||
"ely-platform",
|
||||
"ely-ui",
|
||||
"embed-resource",
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ jiff = "0.2"
|
||||
anyhow = "1"
|
||||
directories = "6"
|
||||
dunce = "1"
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "7ed4ea832334905a85f8fb6ad4d10df4f04c17c7" }
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "c7a86079fc39c3bfd234ce5b7eca77204f10b7fc" }
|
||||
env_logger = "0.11"
|
||||
gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] }
|
||||
gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] }
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
| PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 |
|
||||
| Mail | MailCore 2 + SQLite;正文渲染随其任务另定(不用 CEF 与 Electron) |
|
||||
| Tree | Git CLI,参数数组启动 |
|
||||
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析,RaTeX 公式,mermaid-rs-renderer 图 |
|
||||
| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析与导出 HTML,RaTeX 公式,mermaid-rs-renderer 图;PDF 经 `ely-pdf-worker` 由 MuPDF 排版 |
|
||||
| Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 |
|
||||
|
||||
## 目录
|
||||
@@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
|
||||
## 组件映射
|
||||
|
||||
依赖 `ely-gpui-component`(pin 7ed4ea8,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
|
||||
依赖 `ely-gpui-component`(pin c7a8607,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。
|
||||
|
||||
| 需要 | 用 |
|
||||
| --- | --- |
|
||||
@@ -283,4 +283,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely
|
||||
- 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。
|
||||
- 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。
|
||||
- 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。
|
||||
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
|
||||
- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ fn main() {
|
||||
"shim/ely_secure.c",
|
||||
"shim/ely_sign.c",
|
||||
"shim/ely_output.c",
|
||||
"shim/ely_story.c",
|
||||
"shim/ely_shim.h",
|
||||
"src/proto.rs",
|
||||
"src/bin/ely-pdf-worker/main.rs",
|
||||
@@ -86,6 +87,7 @@ fn main() {
|
||||
.file("shim/ely_secure.c")
|
||||
.file("shim/ely_sign.c")
|
||||
.file("shim/ely_output.c")
|
||||
.file("shim/ely_story.c")
|
||||
.include(root.join("reference/mupdf/include"))
|
||||
.include(openssl(&root, "includedir"))
|
||||
.warnings(true)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* A folder's HTML page as a PDF (MARK-11). */
|
||||
|
||||
#include "ely_shim.h"
|
||||
|
||||
/* A4, two centimetres in from each edge. */
|
||||
static const fz_rect sheet = {0, 0, 595.28f, 841.89f};
|
||||
static const fz_rect area = {56.69f, 56.69f, 538.59f, 785.2f};
|
||||
|
||||
int ely_html_pdf(fz_context *ctx, const char *dir, const char *page, const char *out)
|
||||
{
|
||||
fz_archive *folder = NULL;
|
||||
fz_buffer *html = NULL;
|
||||
fz_story *story = NULL;
|
||||
fz_document_writer *writer = NULL;
|
||||
fz_var(folder);
|
||||
fz_var(html);
|
||||
fz_var(story);
|
||||
fz_var(writer);
|
||||
fz_try(ctx)
|
||||
{
|
||||
ely_watch_begin(ctx);
|
||||
folder = fz_open_directory(ctx, dir);
|
||||
html = fz_read_archive_entry(ctx, folder, page);
|
||||
story = fz_new_story(ctx, html, NULL, 11, folder);
|
||||
writer = fz_new_pdf_writer(ctx, out, "");
|
||||
int more;
|
||||
do
|
||||
{
|
||||
fz_device *dev = fz_begin_page(ctx, writer, sheet);
|
||||
more = fz_place_story(ctx, story, area, NULL);
|
||||
fz_draw_story(ctx, story, dev, fz_identity);
|
||||
fz_end_page(ctx, writer);
|
||||
} while (more);
|
||||
fz_close_document_writer(ctx, writer);
|
||||
/* The story keeps its own warnings. */
|
||||
const char *said = fz_story_warnings(ctx, story);
|
||||
if (said && said[0])
|
||||
fz_throw(ctx, FZ_ERROR_FORMAT, "the page did not set whole: %s", said);
|
||||
if (ely_watch_end(ctx))
|
||||
fz_throw(ctx, FZ_ERROR_FORMAT, "the page did not set whole: %s", ely_open_warnings());
|
||||
}
|
||||
fz_always(ctx)
|
||||
{
|
||||
ely_watch_end(ctx);
|
||||
fz_drop_document_writer(ctx, writer);
|
||||
fz_drop_story(ctx, story);
|
||||
fz_drop_buffer(ctx, html);
|
||||
fz_drop_archive(ctx, folder);
|
||||
}
|
||||
fz_catch(ctx)
|
||||
return ely_fail(ctx);
|
||||
return 0;
|
||||
}
|
||||
@@ -331,7 +331,7 @@ impl Worker {
|
||||
// A document request names the state it was made for.
|
||||
let stamped;
|
||||
let payload = match kind {
|
||||
tag::OPEN | tag::HELLO => payload,
|
||||
tag::OPEN | tag::HELLO | tag::HTML_PDF => payload,
|
||||
_ => {
|
||||
stamped = self.current(payload)?;
|
||||
&stamped
|
||||
@@ -415,6 +415,10 @@ impl Worker {
|
||||
}
|
||||
tag::OUTLINE => self.mupdf.outline(self.doc(ask.u64()?)?),
|
||||
tag::HELLO => Ok(Pack::new().str(ely_pdf_engine::proto::BUILD).bytes()),
|
||||
tag::HTML_PDF => {
|
||||
let (dir, page, out) = (ask.str()?, ask.str()?, ask.str()?);
|
||||
self.mupdf.html_pdf(&dir, &page, &out).map(|()| Vec::new())
|
||||
}
|
||||
tag::CLOSE => {
|
||||
self.forget(ask.u64()?)?;
|
||||
Ok(Vec::new())
|
||||
|
||||
@@ -34,6 +34,7 @@ unsafe extern "C" {
|
||||
fn ely_watch_end(ctx: *mut Context) -> c_int;
|
||||
fn ely_check_pictures(ctx: *mut Context, doc: *mut Document) -> c_int;
|
||||
fn ely_print_copy(ctx: *mut Context, doc: *mut Document, raw: *const c_char, out: *const c_char, password: *const c_char) -> c_int;
|
||||
fn ely_html_pdf(ctx: *mut Context, dir: *const c_char, page: *const c_char, out: *const c_char) -> c_int;
|
||||
}
|
||||
|
||||
/// What MuPDF warned while opening the last file.
|
||||
@@ -56,6 +57,16 @@ impl Mupdf {
|
||||
done.map(|_| ())
|
||||
}
|
||||
|
||||
/// `page` in `dir` set as a PDF at `out`.
|
||||
pub fn html_pdf(&self, dir: &str, page: &str, out: &str) -> io::Result<()> {
|
||||
let (dir_c, page_c, out_c) = (text(dir)?, text(page)?, text(out)?);
|
||||
let done = checked(unsafe { ely_html_pdf(self.ctx, dir_c.as_ptr(), page_c.as_ptr(), out_c.as_ptr()) });
|
||||
if done.is_err() {
|
||||
clean(out.as_ref());
|
||||
}
|
||||
done.map(|_| log::info!("pdf-worker: set an HTML page as a PDF"))
|
||||
}
|
||||
|
||||
pub fn repaired(&self, doc: &Open) -> io::Result<bool> {
|
||||
checked(unsafe { ely_repaired(self.ctx, doc.0) }).map(|repaired| repaired != 0)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_ipc::Unpack;
|
||||
use ely_ipc::{Pack, Unpack};
|
||||
|
||||
use crate::client::{Doc, Worker};
|
||||
use crate::proto::tag;
|
||||
@@ -42,6 +42,12 @@ impl Worker {
|
||||
self.request(tag::PRINT_COPY, &self.whole(&doc)?.str(utf8(out)?).bytes()).map(|_| ())
|
||||
}
|
||||
|
||||
/// HTML `page` in `dir`, with its pictures, as a PDF.
|
||||
pub fn html_pdf(&mut self, dir: &Path, page: &str, out: &Path) -> io::Result<()> {
|
||||
let ask = Pack::new().str(utf8(dir)?).str(page).str(utf8(out)?);
|
||||
self.request(tag::HTML_PDF, &ask.bytes()).map(|_| ())
|
||||
}
|
||||
|
||||
/// Decodes every picture; true if any failed.
|
||||
pub fn check_pictures(&mut self, doc: Doc) -> io::Result<bool> {
|
||||
let reply = self.request(tag::CHECK_PICTURES, &self.whole(&doc)?.bytes())?;
|
||||
|
||||
@@ -81,6 +81,8 @@ pub mod tag {
|
||||
pub const REPAIR_REPORT: u8 = 58;
|
||||
/// Decodes every picture; answers whether any failed.
|
||||
pub const CHECK_PICTURES: u8 = 59;
|
||||
/// A folder's HTML page as a new PDF; no document.
|
||||
pub const HTML_PDF: u8 = 60;
|
||||
pub const OK: u8 = 100;
|
||||
pub const ERR: u8 = 101;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
//! An HTML folder set as a PDF (MARK-11).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ely_pdf_engine::Worker;
|
||||
|
||||
fn worker() -> Worker {
|
||||
Worker::spawn_from(Path::new(env!("CARGO_BIN_EXE_ely-pdf-worker"))).expect("the worker")
|
||||
}
|
||||
|
||||
/// A 1×1 red PNG.
|
||||
const DOT: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00,
|
||||
0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08,
|
||||
0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
|
||||
0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
fn page(dir: &Path, body: &str) {
|
||||
let html = format!("<!doctype html><html><head><meta charset=\"utf-8\"></head><body>{body}</body></html>");
|
||||
std::fs::write(dir.join("page.html"), html).expect("writes");
|
||||
}
|
||||
|
||||
/// The PDF's pages and its first page's text.
|
||||
fn read(worker: &mut Worker, pdf: &Path) -> (usize, String) {
|
||||
let doc = worker.open(pdf).expect("opens").doc;
|
||||
let pages = worker.pages(doc).expect("pages");
|
||||
let text: String = worker.text(doc, 0).expect("text").iter().map(|ch| ch.c).collect();
|
||||
worker.close(doc).expect("closes");
|
||||
(pages, text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_with_pictures_and_cjk_sets_as_a_pdf() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
std::fs::create_dir(dir.path().join("files")).expect("makes");
|
||||
std::fs::write(dir.path().join("files/dot.png"), DOT).expect("writes");
|
||||
let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20\" height=\"10\"><path d=\"M0 0H20V10Z\"/></svg>";
|
||||
page(dir.path(), &format!("<h1>Title</h1><p>中文段落 {svg}</p><p><img src=\"files/dot.png\" alt=\"dot\"></p>"));
|
||||
let pdf = dir.path().join("out.pdf");
|
||||
let mut worker = worker();
|
||||
worker.html_pdf(dir.path(), "page.html", &pdf).expect("sets");
|
||||
let (pages, text) = read(&mut worker, &pdf);
|
||||
assert_eq!(pages, 1);
|
||||
assert!(text.contains("Title") && text.contains("中文段落"), "{text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_page_runs_on() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let body: String = (0..300).map(|n| format!("<p>Paragraph {n}.</p>")).collect();
|
||||
page(dir.path(), &body);
|
||||
let pdf = dir.path().join("out.pdf");
|
||||
let mut worker = worker();
|
||||
worker.html_pdf(dir.path(), "page.html", &pdf).expect("sets");
|
||||
assert!(read(&mut worker, &pdf).0 > 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_picture_fails_and_writes_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
page(dir.path(), "<p><img src=\"files/gone.png\" alt=\"gone\"></p>");
|
||||
let pdf = dir.path().join("out.pdf");
|
||||
let error = worker().html_pdf(dir.path(), "page.html", &pdf).expect_err("refused");
|
||||
assert!(error.to_string().contains("did not set whole"), "{error}");
|
||||
assert!(!pdf.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_taller_than_a_page_still_sets() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"3000\"><path d=\"M0 0H100V3000Z\"/></svg>";
|
||||
page(dir.path(), &format!("<p>Before</p>{svg}<p>After</p>"));
|
||||
let pdf = dir.path().join("out.pdf");
|
||||
let mut worker = worker();
|
||||
worker.html_pdf(dir.path(), "page.html", &pdf).expect("sets");
|
||||
assert!(read(&mut worker, &pdf).0 >= 1);
|
||||
}
|
||||
@@ -56,7 +56,11 @@ pub(super) const EN: &[(&str, &str)] = &[
|
||||
("mark.export.done", "Exported {name}"),
|
||||
("mark.export.missing", "Exported without these images: {list}."),
|
||||
("mark.export.diagrams", "Diagrams that did not draw stay as code, at lines {list}."),
|
||||
("mark.export.formula", "The formula at line {line} did not render: {error}."),
|
||||
("mark.export.formulas", "Formulas that did not render stay as code: {list}."),
|
||||
("mark.export.formula", "line {line}, {error}"),
|
||||
("mark.export.folder_taken", "{name} is already there; the pictures need that folder."),
|
||||
("mark.copy.rich", "Copy as Rich Text"),
|
||||
("mark.copy.failed", "Copying failed: {error}"),
|
||||
("mark.export.failed", "The export failed: {error}"),
|
||||
("mark.look.focus", "Focus Mode"),
|
||||
("mark.look.typewriter", "Typewriter Scrolling"),
|
||||
@@ -149,7 +153,11 @@ pub(super) const ZH_CN: &[(&str, &str)] = &[
|
||||
("mark.export.done", "已导出 {name}"),
|
||||
("mark.export.missing", "已导出,缺这些图片:{list}。"),
|
||||
("mark.export.diagrams", "没能画出的图表保留为代码,在第 {list} 行。"),
|
||||
("mark.export.formula", "第 {line} 行的公式没能排出:{error}。"),
|
||||
("mark.export.formulas", "没能排出的公式保留为代码:{list}。"),
|
||||
("mark.export.formula", "第 {line} 行,{error}"),
|
||||
("mark.export.folder_taken", "{name} 已存在;图片要放进这个文件夹。"),
|
||||
("mark.copy.rich", "复制为富文本"),
|
||||
("mark.copy.failed", "复制失败:{error}"),
|
||||
("mark.export.failed", "导出失败:{error}"),
|
||||
("mark.look.focus", "专注模式"),
|
||||
("mark.look.typewriter", "打字机滚动"),
|
||||
|
||||
@@ -20,7 +20,7 @@ put() { # into, files...
|
||||
put . "$root/LICENSE"
|
||||
case $slug in
|
||||
ely-doc | ely-table | ely-show) crates=(ely-lok) ;;
|
||||
ely-pdf) crates=(ely-pdf-engine) ;;
|
||||
ely-pdf | ely-mark) crates=(ely-pdf-engine) ;;
|
||||
*) crates=() ;;
|
||||
esac
|
||||
python3 "$root/packaging/crates.py" "$slug" ${crates[@]+"${crates[@]}"} >"$out/rust-crates.txt"
|
||||
@@ -35,7 +35,7 @@ case $slug in
|
||||
ely-doc | ely-table | ely-show)
|
||||
put LibreOffice "$lo_licence" "$lo/LICENSE.html" "$lo/NOTICE" "$lo/CREDITS.fodt"
|
||||
;;
|
||||
ely-pdf)
|
||||
ely-pdf | ely-mark)
|
||||
put MuPDF "$root/engines/mupdf-src/COPYING"
|
||||
# Every third party the MuPDF build compiled in.
|
||||
case $(uname) in
|
||||
@@ -76,9 +76,11 @@ case $slug in
|
||||
Darwin) put OpenSSL "$(pkg-config --variable=prefix libcrypto)/LICENSE.txt" ;;
|
||||
*) put OpenSSL /usr/share/doc/libssl-dev/copyright /usr/share/common-licenses/Apache-2.0 ;;
|
||||
esac
|
||||
put tessdata "${ELY_TESSDATA:-$root/engines/tessdata}/LICENSE"
|
||||
put GreatVibes "$root/crates/ely-pdf-engine/fonts/OFL.txt"
|
||||
;;
|
||||
esac
|
||||
case $slug in
|
||||
ely-pdf) put tessdata "${ELY_TESSDATA:-$root/engines/tessdata}/LICENSE" ;;
|
||||
ely-mark)
|
||||
# RaTeX embeds KaTeX's fonts.
|
||||
katex=$(cargo metadata --format-version 1 --locked --manifest-path "$root/Cargo.toml" |
|
||||
|
||||
@@ -69,13 +69,16 @@ if uses ely-lok; then
|
||||
done < <(find "$app/Contents/Frameworks" -type f)
|
||||
fi
|
||||
if uses ely-pdf-engine; then
|
||||
tessdata=${ELY_TESSDATA:-$root/engines/tessdata}
|
||||
need "$tessdata/eng.traineddata" scripts/fetch-tessdata.sh
|
||||
build -p ely-pdf-engine --bin ely-pdf-worker
|
||||
cp "$root/target/$profile/ely-pdf-worker" "$app/Contents/MacOS/"
|
||||
inner+=("$app/Contents/MacOS/ely-pdf-worker")
|
||||
fi
|
||||
# OCR's languages: ElyPDF alone reads text from pictures.
|
||||
if [ "$slug" = ely-pdf ]; then
|
||||
tessdata=${ELY_TESSDATA:-$root/engines/tessdata}
|
||||
need "$tessdata/eng.traineddata" scripts/fetch-tessdata.sh
|
||||
mkdir "$app/Contents/Resources/tessdata"
|
||||
cp "$tessdata"/{eng,chi_sim,chi_tra}.traineddata "$app/Contents/Resources/tessdata/"
|
||||
inner+=("$app/Contents/MacOS/ely-pdf-worker")
|
||||
fi
|
||||
if [ "$slug" = ely-tree ]; then
|
||||
need "$root/engines/git/bin/git" "scripts/build-git.sh and scripts/fetch-git-lfs.sh"
|
||||
|
||||
+5
-2
@@ -54,10 +54,13 @@ if uses ely-lok; then
|
||||
fi
|
||||
fi
|
||||
if uses ely-pdf-engine; then
|
||||
tessdata=${ELY_TESSDATA:-$root/engines/tessdata}
|
||||
need "$tessdata/eng.traineddata" scripts/fetch-tessdata.sh
|
||||
build -p ely-pdf-engine --bin ely-pdf-worker
|
||||
cp "$release/ely-pdf-worker$exe" "$out/"
|
||||
fi
|
||||
# OCR's languages: ElyPDF alone reads text from pictures.
|
||||
if [ "$slug" = ely-pdf ]; then
|
||||
tessdata=${ELY_TESSDATA:-$root/engines/tessdata}
|
||||
need "$tessdata/eng.traineddata" scripts/fetch-tessdata.sh
|
||||
mkdir "$out/tessdata"
|
||||
cp "$tessdata"/{eng,chi_sim,chi_tra}.traineddata "$out/tessdata/"
|
||||
fi
|
||||
|
||||
@@ -36,7 +36,7 @@ fetch() { # name url rev [sparse paths...]
|
||||
echo "fetch $name $rev"
|
||||
}
|
||||
|
||||
fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git 7ed4ea832334905a85f8fb6ad4d10df4f04c17c7
|
||||
fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git c7a86079fc39c3bfd234ce5b7eca77204f10b7fc
|
||||
fetch zed https://github.com/zed-industries/zed.git 1a28cff4b409169bac058bca40dfbfeb7621d19b
|
||||
fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \
|
||||
include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \
|
||||
|
||||
@@ -72,7 +72,7 @@ while IFS=$'\t' read -r slug name; do
|
||||
ely-doc | ely-table | ely-show) engines=(LibreOffice) ;;
|
||||
ely-pdf) engines=(MuPDF MuPDF/fonts/urw MuPDF/fonts/sil MuPDF/fonts/noto MuPDF/fonts/droid MuPDF/fonts/han OpenSSL tessdata GreatVibes) ;;
|
||||
ely-tree) engines=(Git git-lfs) ;;
|
||||
ely-mark) engines=(KaTeX) ;;
|
||||
ely-mark) engines=(KaTeX MuPDF MuPDF/fonts/noto OpenSSL GreatVibes) ;;
|
||||
esac
|
||||
for part in LICENSE rust-crates.txt "${engines[@]}"; do
|
||||
[ -n "$(find "$licenses/$part" -type f -size +0 2>/dev/null)" ] || fail "$name lacks the licences of $part"
|
||||
|
||||
Reference in new issue
Block a user