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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-12 10:16:11 -04:00
1 parent f1cb7eff99
commit d7ded183ae
27 files changed
+1427 -24

No files matched your search

Generated
+3 -1
View File
@@ -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
View File
@@ -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"] }
+3 -3
View File
@@ -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
+3 -1
View File
@@ -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"] }
+6 -2
View File
@@ -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)
}
}
+309
View File
@@ -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;
+88
View File
@@ -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}"
);
}
+4 -2
View File
@@ -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(),
+2
View File
@@ -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;
+115
View File
@@ -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(())
}
+172
View File
@@ -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('&', "&amp;").replace('<', "&lt;");
format!(
"<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>{title}</title><style>{STYLE}</style></head>\n<body>\n{body}</body></html>\n"
)
}
/// markdown-rs's four escapes undone.
fn unescape(text: &str) -> String {
text.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&amp;", "&")
}
/// 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))
}
+181
View File
@@ -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('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
}
+62
View File
@@ -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(),
}
}
+292
View File
@@ -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 = "![a](dot.png) ![b](gone.png) ![c](https://example.com/c.png?x=1&y=2)\n";
let page = page(text, Some(dir.path()), &Pictures::Inline);
assert!(
page.html
.contains("src=\"data:image/png;base64,cG5nIGJ5dGVz\""),
"{}",
page.html
);
assert!(
page.html
.contains("src=\"https://example.com/c.png?x=1&amp;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 = "![a](one/my%20pic.png) ![b](two/my%20pic.png) ![c](<one/my pic.png>)\n";
let page = page(
text,
Some(dir.path()),
&Pictures::Folder("doc_files".into()),
);
let names: Vec<&str> = page.copies.iter().map(|(_, name)| name.as_str()).collect();
assert_eq!(names, ["my pic.png", "my pic-2.png"]);
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 = "![a](a.webp) ![b](b.png) ![c](https://example.com/c.png)\n";
let page = page(text, Some(dir.path()), &Pictures::Pdf("files".into()));
assert_eq!(page.missing, ["a.webp", "https://example.com/c.png"]);
assert_eq!(page.copies.len(), 1);
}
#[test]
fn a_blocked_address_is_missing_by_its_text() {
let page = page("![the chart](file:///tmp/c.png)\n", None, &Pictures::Inline);
assert_eq!(page.missing, ["the chart"]);
}
#[test]
fn an_untitled_document_has_no_folder_for_pictures() {
let page = page("![a](a.png)\n", None, &Pictures::Inline);
assert_eq!(page.missing, ["a.png"]);
}
#[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&lt;b &amp; c</title>") && html.contains("<p>x</p>"));
}
#[test]
fn plain_text_keeps_words_code_and_tex() {
let text = "---\ntitle: x\n---\n\n# Head *one*\n\n- a\n- b `c`\n\n| h | i |\n|---|---|\n| 1 | 2 |\n\n$$\n\\sqrt{x}\n$$\n\n> quoted $y$\n";
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![d](dot.png)\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,
"![d](dot.png)\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,
"![d](dot.png)\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:?}"
);
}
}
+2
View File
@@ -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)
+53
View File
@@ -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)
}
+7 -1
View File
@@ -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())?;
+2
View File
@@ -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;
}
+78
View File
@@ -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);
}
+10 -2
View File
@@ -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", "打字机滚动"),
+5 -3
View File
@@ -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" |
+6 -3
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 \
+1 -1
View File
@@ -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"