C14.14 r1: HTML from the syntax tree; pictures claimed by create_new; one scratch per PDF; diagram glyphs checked; rich copy reports
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d7ded183ae
commit
88b0f8f5fc
12 files changed
+647
-312
No files matched your search
Generated
+1
-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=c7a86079fc39c3bfd234ce5b7eca77204f10b7fc#c7a86079fc39c3bfd234ce5b7eca77204f10b7fc"
|
||||
source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=ff9c0f88d3b7975a316f6869b5d250200178885a#ff9c0f88d3b7975a316f6869b5d250200178885a"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+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 = "c7a86079fc39c3bfd234ce5b7eca77204f10b7fc" }
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "ff9c0f88d3b7975a316f6869b5d250200178885a" }
|
||||
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"] }
|
||||
|
||||
@@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木
|
||||
|
||||
## 组件映射
|
||||
|
||||
依赖 `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` 核对下表每个名字都是组件库公开项。
|
||||
依赖 `ely-gpui-component`(pin ff9c0f8,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
|
||||
@@ -291,16 +291,21 @@ impl Desk {
|
||||
.active_tab()
|
||||
.and_then(|tab| tab.session.path())
|
||||
.and_then(|path| path.parent());
|
||||
let copied = output::body(&markdown, beside, &Pictures::Inline).and_then(|page| {
|
||||
let copied = output::body(&markdown, beside, &Pictures::Inline).and_then(|mut page| {
|
||||
let clip = Clip::Html {
|
||||
html: page.html,
|
||||
html: std::mem::take(&mut page.html),
|
||||
text: markdown,
|
||||
};
|
||||
clipboard::write(&clip).map_err(|error| std::io::Error::other(format!("{error:#}")))
|
||||
clipboard::write(&clip)
|
||||
.map(|()| page)
|
||||
.map_err(|error| std::io::Error::other(format!("{error:#}")))
|
||||
});
|
||||
if let Err(error) = copied {
|
||||
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
match copied {
|
||||
Ok(page) => self.left_out(&page, cx),
|
||||
Err(error) => {
|
||||
let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]);
|
||||
self.toast(said, Severity::Danger, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
use std::{
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
path::Path,
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use super::{Page, Pictures, body, document, plain};
|
||||
@@ -62,39 +63,30 @@ fn export(target: &Target, bytes: &[u8]) -> io::Result<()> {
|
||||
|
||||
/// The page beside a new folder of its pictures.
|
||||
fn linked(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::Result<Page> {
|
||||
let name = format!("{title}_files");
|
||||
let page = body(text, beside, &Pictures::Folder(name.clone()))?;
|
||||
if page.copies.is_empty() {
|
||||
export(target, document(title, &page.html).as_bytes())?;
|
||||
return Ok(page);
|
||||
let folder = target.path.with_file_name(format!("{title}_files"));
|
||||
let found = folder.exists();
|
||||
let written = body(text, beside, &Pictures::Folder(folder.clone()))
|
||||
.and_then(|page| export(target, document(title, &page.html).as_bytes()).map(|()| page));
|
||||
// A failure takes only the folder it made.
|
||||
if written.is_err()
|
||||
&& !found
|
||||
&& folder.exists()
|
||||
&& let Err(left) = std::fs::remove_dir_all(&folder)
|
||||
{
|
||||
log::error!("mark: the half-made picture folder stays: {left}");
|
||||
}
|
||||
let folder = target.path.with_file_name(&name);
|
||||
// A folder already there is someone's: refused.
|
||||
std::fs::create_dir(&folder).map_err(|error| match error.kind() {
|
||||
io::ErrorKind::AlreadyExists => io::Error::new(error.kind(), folder.display().to_string()),
|
||||
_ => error,
|
||||
})?;
|
||||
let written = copy(&page.copies, &folder)
|
||||
.and_then(|()| export(target, document(title, &page.html).as_bytes()));
|
||||
if let Err(error) = written {
|
||||
if let Err(left) = std::fs::remove_dir_all(&folder) {
|
||||
log::error!("mark: the half-made picture folder stays: {left}");
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(page)
|
||||
written
|
||||
}
|
||||
|
||||
/// The page set by MuPDF, from a scratch folder.
|
||||
/// The page set by MuPDF, in its own scratch.
|
||||
fn pdf(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::Result<Page> {
|
||||
let dir = target.work.join("pdf");
|
||||
if dir.exists() {
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
}
|
||||
std::fs::create_dir_all(dir.join("files"))?;
|
||||
static JOBS: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = target
|
||||
.work
|
||||
.join(format!("pdf-{}", JOBS.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let set = (|| -> io::Result<Page> {
|
||||
let page = body(text, beside, &Pictures::Pdf("files".into()))?;
|
||||
copy(&page.copies, &dir.join("files"))?;
|
||||
let page = body(text, beside, &Pictures::Pdf(dir.join("files")))?;
|
||||
std::fs::write(dir.join("page.html"), document(title, &page.html))?;
|
||||
let out = dir.join("out.pdf");
|
||||
ely_pdf_engine::Worker::spawn()?.html_pdf(&dir, "page.html", &out)?;
|
||||
@@ -106,10 +98,3 @@ fn pdf(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::R
|
||||
cleaned?;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
fn copy(copies: &[(PathBuf, String)], folder: &Path) -> io::Result<()> {
|
||||
for (from, name) in copies {
|
||||
std::fs::copy(from, folder.join(name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! A syntax tree written as HTML, node by node.
|
||||
|
||||
use std::{collections::HashMap, io};
|
||||
|
||||
use ely_gpui_component::{documents::diagram_svg, typography::formula_svg};
|
||||
use markdown::mdast::{AlignKind, Node};
|
||||
|
||||
use super::{Page, attribute, escape, pictures::Placed};
|
||||
|
||||
/// Writes `tree` into `page`; pictures go through `placed`.
|
||||
pub(super) fn write(tree: &Node, placed: &mut Placed, page: &mut Page) -> io::Result<()> {
|
||||
let mut writer = Writer {
|
||||
placed,
|
||||
page,
|
||||
links: HashMap::new(),
|
||||
notes: HashMap::new(),
|
||||
called: Vec::new(),
|
||||
};
|
||||
writer.gather(tree);
|
||||
writer.children(tree, false)?;
|
||||
writer.footnotes()
|
||||
}
|
||||
|
||||
struct Writer<'a, 'b, 'c> {
|
||||
placed: &'b mut Placed<'a>,
|
||||
page: &'c mut Page,
|
||||
/// Link definitions by identifier: address and title.
|
||||
links: HashMap<String, (String, Option<String>)>,
|
||||
notes: HashMap<String, Node>,
|
||||
/// Footnotes in the order first called.
|
||||
called: Vec<String>,
|
||||
}
|
||||
|
||||
impl Writer<'_, '_, '_> {
|
||||
fn gather(&mut self, node: &Node) {
|
||||
match node {
|
||||
Node::Definition(definition) => {
|
||||
let target = (definition.url.clone(), definition.title.clone());
|
||||
self.links
|
||||
.entry(definition.identifier.clone())
|
||||
.or_insert(target);
|
||||
}
|
||||
Node::FootnoteDefinition(note) => {
|
||||
self.notes
|
||||
.entry(note.identifier.clone())
|
||||
.or_insert_with(|| node.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.gather(child);
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, text: &str) {
|
||||
self.page.html.push_str(text);
|
||||
}
|
||||
|
||||
fn children(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
for child in node.children().into_iter().flatten() {
|
||||
self.node(child, tight)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wrapped(&mut self, tag: &str, node: &Node) -> io::Result<()> {
|
||||
self.push(&format!("<{tag}>"));
|
||||
self.children(node, false)?;
|
||||
self.push(&format!("</{tag}>"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node(&mut self, node: &Node, tight: bool) -> io::Result<()> {
|
||||
match node {
|
||||
Node::Paragraph(_) if tight => self.children(node, false)?,
|
||||
Node::Paragraph(_) => self.wrapped("p", node)?,
|
||||
Node::Heading(heading) => self.wrapped(&format!("h{}", heading.depth), node)?,
|
||||
Node::Blockquote(_) => self.wrapped("blockquote", node)?,
|
||||
Node::Emphasis(_) => self.wrapped("em", node)?,
|
||||
Node::Strong(_) => self.wrapped("strong", node)?,
|
||||
Node::Delete(_) => self.wrapped("del", node)?,
|
||||
Node::List(list) => {
|
||||
let tag = if list.ordered { "ol" } else { "ul" };
|
||||
match list.start.filter(|start| list.ordered && *start != 1) {
|
||||
Some(start) => self.push(&format!("<ol start=\"{start}\">")),
|
||||
None => self.push(&format!("<{tag}>")),
|
||||
}
|
||||
for item in &list.children {
|
||||
let Node::ListItem(each) = item else { continue };
|
||||
self.push("<li>");
|
||||
if let Some(checked) = each.checked {
|
||||
let mark = if checked { " checked" } else { "" };
|
||||
self.push(&format!("<input type=\"checkbox\" disabled{mark}> "));
|
||||
}
|
||||
self.children(item, !list.spread && !each.spread)?;
|
||||
self.push("</li>");
|
||||
}
|
||||
self.push(&format!("</{tag}>"));
|
||||
}
|
||||
Node::Table(table) => self.table(node, &table.align)?,
|
||||
Node::Code(code) if code.lang.as_deref() == Some("mermaid") => {
|
||||
let line = line_of(node);
|
||||
match diagram_svg(&code.value) {
|
||||
Ok(svg) => self.push(&format!("<div class=\"diagram\">{}</div>", bare(&svg))),
|
||||
Err(_) => {
|
||||
self.page.diagrams.push(line);
|
||||
self.code(Some("mermaid"), &code.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Node::Code(code) => self.code(code.lang.as_deref(), &code.value),
|
||||
Node::Math(math) => self.math(node, &math.value, true),
|
||||
Node::InlineMath(math) => self.math(node, &math.value, false),
|
||||
Node::InlineCode(code) => self.push(&format!("<code>{}</code>", escape(&code.value))),
|
||||
Node::Text(text) => self.push(&escape(&text.value)),
|
||||
// HTML in the document shows as text, never runs.
|
||||
Node::Html(html) => self.push(&escape(&html.value)),
|
||||
Node::Break(_) => self.push("<br>"),
|
||||
Node::ThematicBreak(_) => self.push("<hr>"),
|
||||
Node::Link(link) => self.link(node, &link.url, link.title.as_deref())?,
|
||||
Node::LinkReference(link) => match self.links.get(&link.identifier).cloned() {
|
||||
Some((url, title)) => self.link(node, &url, title.as_deref())?,
|
||||
None => self.children(node, false)?,
|
||||
},
|
||||
Node::Image(image) => self.picture(&image.url, &image.alt, image.title.as_deref())?,
|
||||
Node::ImageReference(image) => match self.links.get(&image.identifier).cloned() {
|
||||
Some((url, title)) => self.picture(&url, &image.alt, title.as_deref())?,
|
||||
None => self.push(&escape(&image.alt)),
|
||||
},
|
||||
Node::FootnoteReference(call) => {
|
||||
let number = match self.called.iter().position(|id| *id == call.identifier) {
|
||||
Some(at) => at + 1,
|
||||
None => {
|
||||
self.called.push(call.identifier.clone());
|
||||
self.called.len()
|
||||
}
|
||||
};
|
||||
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"));
|
||||
}
|
||||
// Written elsewhere, or never.
|
||||
Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) | Node::FootnoteDefinition(_) => {}
|
||||
_ => self.children(node, false)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn table(&mut self, node: &Node, align: &[AlignKind]) -> io::Result<()> {
|
||||
self.push("<table>");
|
||||
for (at, row) in node.children().into_iter().flatten().enumerate() {
|
||||
let cell = if at == 0 { "th" } else { "td" };
|
||||
self.push("<tr>");
|
||||
for (column, each) in row.children().into_iter().flatten().enumerate() {
|
||||
let side = match align.get(column) {
|
||||
Some(AlignKind::Left) => " style=\"text-align:left\"",
|
||||
Some(AlignKind::Right) => " style=\"text-align:right\"",
|
||||
Some(AlignKind::Center) => " style=\"text-align:center\"",
|
||||
_ => "",
|
||||
};
|
||||
self.push(&format!("<{cell}{side}>"));
|
||||
self.children(each, false)?;
|
||||
self.push(&format!("</{cell}>"));
|
||||
}
|
||||
self.push("</tr>");
|
||||
}
|
||||
self.push("</table>");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn code(&mut self, lang: Option<&str>, value: &str) {
|
||||
let class = lang.map_or_else(String::new, |lang| {
|
||||
format!(" class=\"language-{}\"", attribute(lang))
|
||||
});
|
||||
self.push(&format!("<pre><code{class}>{}</code></pre>", escape(value)));
|
||||
}
|
||||
|
||||
fn math(&mut self, node: &Node, value: &str, display: bool) {
|
||||
match (formula_svg(value, display), display) {
|
||||
(Ok(svg), true) => self.push(&format!("<div class=\"math\">{}</div>", bare(&svg))),
|
||||
(Ok(svg), false) => self.push(&format!("<span class=\"math\">{}</span>", bare(&svg))),
|
||||
(Err(error), _) => {
|
||||
self.page.formulas.push((line_of(node), error.to_string()));
|
||||
match display {
|
||||
true => self.code(Some("math"), value),
|
||||
false => self.push(&format!("<code>{}</code>", escape(value))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn link(&mut self, node: &Node, url: &str, title: Option<&str>) -> io::Result<()> {
|
||||
let title = title.map_or_else(String::new, |title| {
|
||||
format!(" title=\"{}\"", attribute(title))
|
||||
});
|
||||
// Only web, mail and in-page addresses follow.
|
||||
match safe(url) {
|
||||
true => self.push(&format!("<a href=\"{}\"{title}>", attribute(url))),
|
||||
false => self.push("<a>"),
|
||||
}
|
||||
self.children(node, false)?;
|
||||
self.push("</a>");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn picture(&mut self, url: &str, alt: &str, title: Option<&str>) -> io::Result<()> {
|
||||
let src = self.placed.place(url)?;
|
||||
if src.is_none() {
|
||||
self.page.missing.push(if url.is_empty() {
|
||||
alt.to_owned()
|
||||
} else {
|
||||
url.to_owned()
|
||||
});
|
||||
}
|
||||
// A PDF shows what it left out as words.
|
||||
let Some(src) = src.or_else(|| (!self.placed.pdf()).then(|| url.to_owned())) else {
|
||||
self.push(&format!("[{}]", escape(alt)));
|
||||
return Ok(());
|
||||
};
|
||||
let title = title.map_or_else(String::new, |title| {
|
||||
format!(" title=\"{}\"", attribute(title))
|
||||
});
|
||||
self.push(&format!(
|
||||
"<img src=\"{}\" alt=\"{}\"{title}>",
|
||||
attribute(&src),
|
||||
attribute(alt)
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn footnotes(&mut self) -> io::Result<()> {
|
||||
if self.called.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.push("<section class=\"footnotes\"><hr><ol>");
|
||||
// A note may call another note.
|
||||
let mut at = 0;
|
||||
while at < self.called.len() {
|
||||
let note = self.notes.get(&self.called[at]).cloned();
|
||||
at += 1;
|
||||
self.push(&format!("<li id=\"fn-{at}\">"));
|
||||
match note {
|
||||
Some(note) => self.children(¬e, false)?,
|
||||
None => log::error!("mark: footnote {at} has no definition"),
|
||||
}
|
||||
self.push("</li>");
|
||||
}
|
||||
self.push("</ol></section>");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The line a node starts on, from one.
|
||||
fn line_of(node: &Node) -> usize {
|
||||
node.position().map_or(0, |at| at.start.line)
|
||||
}
|
||||
|
||||
/// An SVG without its XML declaration.
|
||||
fn bare(svg: &str) -> &str {
|
||||
svg.find("<svg").map_or(svg, |at| &svg[at..])
|
||||
}
|
||||
|
||||
/// A plain, web or mail address.
|
||||
fn safe(url: &str) -> bool {
|
||||
match scheme(url) {
|
||||
None => true,
|
||||
Some(scheme) => ["http", "https", "mailto"].contains(&scheme.to_lowercase().as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An address's scheme, when it has one.
|
||||
pub(super) fn scheme(url: &str) -> Option<&str> {
|
||||
let end = url.find(':')?;
|
||||
let name = &url[..end];
|
||||
let plain = name
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch));
|
||||
// A drive letter is a path, not a scheme.
|
||||
(plain && name.len() > 1).then_some(name)
|
||||
}
|
||||
+16
-114
@@ -1,6 +1,7 @@
|
||||
//! Markdown as an HTML page or plain text (MARK-11).
|
||||
|
||||
mod files;
|
||||
mod html;
|
||||
mod pictures;
|
||||
mod plain;
|
||||
#[cfg(test)]
|
||||
@@ -8,11 +9,7 @@ mod tests;
|
||||
|
||||
use std::{io, path::Path};
|
||||
|
||||
use ely_gpui_component::{
|
||||
documents::{diagram_svg, markdown_options},
|
||||
typography::formula_svg,
|
||||
};
|
||||
use markdown::{CompileOptions, Options, mdast::Node};
|
||||
use ely_gpui_component::documents::markdown_options;
|
||||
|
||||
pub use files::{Format, Target, write};
|
||||
pub use pictures::Pictures;
|
||||
@@ -22,8 +19,6 @@ pub use plain::plain;
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Page {
|
||||
pub html: String,
|
||||
/// Pictures to copy beside the page: from, then name.
|
||||
pub copies: Vec<(std::path::PathBuf, String)>,
|
||||
/// Pictures left out, as the document wrote them.
|
||||
pub missing: Vec<String>,
|
||||
/// Lines of diagrams kept as code.
|
||||
@@ -32,11 +27,6 @@ pub struct Page {
|
||||
pub formulas: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
const DISPLAY: &str = "<pre><code class=\"language-math math-display\">";
|
||||
const INLINE: &str = "<code class=\"language-math math-inline\">";
|
||||
const MERMAID: &str = "<pre><code class=\"language-mermaid\">";
|
||||
const IMAGE: &str = "<img src=\"";
|
||||
|
||||
/// The page's look: light, as printed.
|
||||
const STYLE: &str = "body{font-family:sans-serif;font-size:11pt;line-height:1.5;color:#1f2328;max-width:48rem;margin:2rem auto;padding:0 1rem}\
|
||||
pre{background:#f6f8fa;padding:.75em;white-space:pre-wrap}\
|
||||
@@ -47,71 +37,15 @@ img,svg{max-width:100%}div.math{text-align:center}span.math svg{vertical-align:m
|
||||
|
||||
/// `text`'s body as HTML; pictures go as `pictures` says.
|
||||
pub fn body(text: &str, beside: Option<&Path>, pictures: &Pictures) -> io::Result<Page> {
|
||||
let options = Options {
|
||||
parse: markdown_options(),
|
||||
compile: CompileOptions::gfm(),
|
||||
};
|
||||
let html = markdown::to_html_with_options(text, &options)
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let tree = markdown::to_mdast(text, &options.parse)
|
||||
let tree = markdown::to_mdast(text, &markdown_options())
|
||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
let mut page = Page::default();
|
||||
let mut placed = pictures::Placed::new(beside, pictures);
|
||||
let mut rest = html.as_str();
|
||||
while let Some((at, mark)) = [DISPLAY, INLINE, MERMAID, IMAGE]
|
||||
.iter()
|
||||
.filter_map(|mark| rest.find(mark).map(|at| (at, *mark)))
|
||||
.min()
|
||||
{
|
||||
page.html.push_str(&rest[..at]);
|
||||
rest = &rest[at + mark.len()..];
|
||||
let close = match mark {
|
||||
DISPLAY | MERMAID => "</code></pre>",
|
||||
INLINE => "</code>",
|
||||
_ => "\"",
|
||||
};
|
||||
let end = rest
|
||||
.find(close)
|
||||
.ok_or_else(|| io::Error::other("markdown-rs left an element open"))?;
|
||||
let inside = &rest[..end];
|
||||
rest = &rest[end + close.len()..];
|
||||
let source = unescape(inside);
|
||||
if mark == IMAGE {
|
||||
page.html.push_str(IMAGE);
|
||||
page.html
|
||||
.push_str(&placed.place(&source, rest, &mut page.missing));
|
||||
page.html.push('"');
|
||||
continue;
|
||||
}
|
||||
let drawn = match mark {
|
||||
MERMAID => match diagram_svg(&source) {
|
||||
Ok(svg) => Some(format!("<div class=\"diagram\">{}</div>", bare(&svg))),
|
||||
Err(_) => {
|
||||
page.diagrams.push(line(&tree, &source, true));
|
||||
None
|
||||
}
|
||||
},
|
||||
_ => match formula_svg(&source, mark == DISPLAY) {
|
||||
Ok(svg) if mark == DISPLAY => {
|
||||
Some(format!("<div class=\"math\">{}</div>", bare(&svg)))
|
||||
}
|
||||
Ok(svg) => Some(format!("<span class=\"math\">{}</span>", bare(&svg))),
|
||||
Err(error) => {
|
||||
page.formulas
|
||||
.push((line(&tree, &source, false), error.to_string()));
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
// What did not draw stays as written.
|
||||
page.html += &drawn.unwrap_or_else(|| format!("{mark}{inside}{close}"));
|
||||
}
|
||||
page.html.push_str(rest);
|
||||
page.copies = placed.copies;
|
||||
html::write(&tree, &mut placed, &mut page)?;
|
||||
log::info!(
|
||||
"mark: a page of {} bytes, {} copies, {} missing, {} diagrams and {} formulas as code",
|
||||
"mark: a page of {} bytes, {} pictures copied, {} missing, {} diagrams and {} formulas as code",
|
||||
page.html.len(),
|
||||
page.copies.len(),
|
||||
placed.copied.len(),
|
||||
page.missing.len(),
|
||||
page.diagrams.len(),
|
||||
page.formulas.len()
|
||||
@@ -121,52 +55,20 @@ pub fn body(text: &str, beside: Option<&Path>, pictures: &Pictures) -> io::Resul
|
||||
|
||||
/// A whole HTML document around `body`.
|
||||
pub fn document(title: &str, body: &str) -> String {
|
||||
let title = title.replace('&', "&").replace('<', "<");
|
||||
format!(
|
||||
"<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>{title}</title><style>{STYLE}</style></head>\n<body>\n{body}</body></html>\n"
|
||||
"<!doctype html>\n<html><head><meta charset=\"utf-8\"><title>{}</title><style>{STYLE}</style></head>\n<body>\n{body}\n</body></html>\n",
|
||||
escape(title)
|
||||
)
|
||||
}
|
||||
|
||||
/// markdown-rs's four escapes undone.
|
||||
fn unescape(text: &str) -> String {
|
||||
text.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("&", "&")
|
||||
/// Text safe between tags.
|
||||
fn escape(text: &str) -> String {
|
||||
text.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
/// An SVG without its XML declaration.
|
||||
fn bare(svg: &str) -> &str {
|
||||
svg.find("<svg").map_or(svg, |at| &svg[at..])
|
||||
}
|
||||
|
||||
/// The line of the first diagram or formula with `source`.
|
||||
fn line(tree: &Node, source: &str, diagram: bool) -> usize {
|
||||
let found = find(tree, source, diagram);
|
||||
if found.is_none() {
|
||||
log::error!(
|
||||
"mark: an undrawn {} has no line",
|
||||
if diagram { "diagram" } else { "formula" }
|
||||
);
|
||||
}
|
||||
found.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn find(tree: &Node, source: &str, diagram: bool) -> Option<usize> {
|
||||
let found = match tree {
|
||||
Node::Code(code) if diagram && code.lang.as_deref() == Some("mermaid") => {
|
||||
(code.value.trim_end() == source.trim_end()).then_some(tree)
|
||||
}
|
||||
Node::Math(math) if !diagram => {
|
||||
(math.value.trim_end() == source.trim_end()).then_some(tree)
|
||||
}
|
||||
Node::InlineMath(math) if !diagram => (math.value == source).then_some(tree),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(at) = found.and_then(Node::position) {
|
||||
return Some(at.start.line);
|
||||
}
|
||||
tree.children()?
|
||||
.iter()
|
||||
.find_map(|child| find(child, source, diagram))
|
||||
/// Text safe inside a quoted attribute.
|
||||
fn attribute(text: &str) -> String {
|
||||
escape(text).replace('"', """)
|
||||
}
|
||||
@@ -1,27 +1,41 @@
|
||||
//! Where an exported page's pictures go.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{
|
||||
fs::File,
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use base64::Engine as _;
|
||||
|
||||
use super::html::scheme;
|
||||
|
||||
/// Where pictures go.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Pictures {
|
||||
/// Inside the page, as data.
|
||||
Inline,
|
||||
/// Copied into the named folder beside the page.
|
||||
Folder(String),
|
||||
/// As a folder, in formats MuPDF reads.
|
||||
Pdf(String),
|
||||
/// Copied into this new folder beside the page.
|
||||
Folder(PathBuf),
|
||||
/// Copied into this folder, in formats MuPDF reads.
|
||||
Pdf(PathBuf),
|
||||
}
|
||||
|
||||
/// Picture formats MuPDF reads from files.
|
||||
const READABLE: [&str; 8] = ["png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "svg"];
|
||||
|
||||
/// Inline pictures MuPDF reads.
|
||||
const READABLE_DATA: [&str; 3] = [
|
||||
"data:image/png;base64,",
|
||||
"data:image/jpeg;base64,",
|
||||
"data:image/gif;base64,",
|
||||
];
|
||||
|
||||
pub(super) struct Placed<'a> {
|
||||
beside: Option<&'a Path>,
|
||||
pictures: &'a Pictures,
|
||||
pub copies: Vec<(PathBuf, String)>,
|
||||
/// Pictures copied so far, and their names.
|
||||
pub copied: Vec<(PathBuf, String)>,
|
||||
}
|
||||
|
||||
impl<'a> Placed<'a> {
|
||||
@@ -29,87 +43,111 @@ impl<'a> Placed<'a> {
|
||||
Self {
|
||||
beside,
|
||||
pictures,
|
||||
copies: Vec::new(),
|
||||
copied: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `src` a picture takes; else it is missing.
|
||||
pub fn place(&mut self, src: &str, after: &str, missing: &mut Vec<String>) -> String {
|
||||
let address = decoded(src);
|
||||
// markdown-rs blanks addresses it will not allow.
|
||||
if address.is_empty() {
|
||||
missing.push(alt(after));
|
||||
return String::new();
|
||||
pub fn pdf(&self) -> bool {
|
||||
matches!(self.pictures, Pictures::Pdf(_))
|
||||
}
|
||||
|
||||
/// The `src` for `url`; none when it is not carried.
|
||||
pub fn place(&mut self, url: &str) -> io::Result<Option<String>> {
|
||||
let lower = url.to_lowercase();
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") {
|
||||
return Ok((!self.pdf()).then(|| url.to_owned()));
|
||||
}
|
||||
let web = address.starts_with("http://") || address.starts_with("https://");
|
||||
if web || address.starts_with("data:") {
|
||||
if web && matches!(self.pictures, Pictures::Pdf(_)) {
|
||||
missing.push(address);
|
||||
return String::new();
|
||||
}
|
||||
return attribute(&address);
|
||||
if lower.starts_with("data:") {
|
||||
let readable = match self.pdf() {
|
||||
true => READABLE_DATA.iter().any(|kind| lower.starts_with(kind)),
|
||||
false => lower.starts_with("data:image/"),
|
||||
};
|
||||
return Ok(readable.then(|| url.to_owned()));
|
||||
}
|
||||
let path = match (Path::new(&address).is_absolute(), self.beside) {
|
||||
(true, _) => PathBuf::from(&address),
|
||||
(false, Some(beside)) => beside.join(&address),
|
||||
let path = match (lower.starts_with("file://"), scheme(url)) {
|
||||
(true, _) => file_path(&url["file://".len()..]),
|
||||
// Other schemes are no picture of the document's.
|
||||
(false, Some(_)) => return Ok(None),
|
||||
(false, None) => {
|
||||
missing.push(address);
|
||||
return String::new();
|
||||
let path = PathBuf::from(decoded(url));
|
||||
match (path.is_absolute(), self.beside) {
|
||||
(true, _) => path,
|
||||
(false, Some(beside)) => beside.join(path),
|
||||
(false, None) => return Ok(None),
|
||||
}
|
||||
}
|
||||
};
|
||||
match self.carried(&path) {
|
||||
Some(src) => src,
|
||||
None => {
|
||||
missing.push(address);
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
self.carried(&path)
|
||||
}
|
||||
|
||||
fn carried(&mut self, path: &Path) -> Option<String> {
|
||||
fn carried(&mut self, path: &Path) -> io::Result<Option<String>> {
|
||||
let extension = path
|
||||
.extension()
|
||||
.map(|extension| extension.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
let folder = match self.pictures {
|
||||
Pictures::Inline => {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
return Some(format!("data:{};base64,{data}", mime(&extension)));
|
||||
return Ok(Some(format!("data:{};base64,{data}", mime(&extension))));
|
||||
}
|
||||
Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return None,
|
||||
Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return Ok(None),
|
||||
Pictures::Folder(folder) | Pictures::Pdf(folder) => folder.clone(),
|
||||
};
|
||||
if !path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let name = self.named(path);
|
||||
Some(format!("{folder}/{}", encoded(&name)))
|
||||
let named = match self.copied.iter().find(|(from, _)| from == path) {
|
||||
Some((_, name)) => name.clone(),
|
||||
None => {
|
||||
let Ok(mut source) = File::open(path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !source.metadata()?.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = self.copy(&mut source, path, &folder)?;
|
||||
self.copied.push((path.to_path_buf(), name.clone()));
|
||||
name
|
||||
}
|
||||
};
|
||||
let shelf = folder
|
||||
.file_name()
|
||||
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
|
||||
Ok(Some(format!("{}/{}", encoded(&shelf), encoded(&named))))
|
||||
}
|
||||
|
||||
/// A name in the folder, one per picture.
|
||||
fn named(&mut self, path: &Path) -> String {
|
||||
if let Some((_, name)) = self.copies.iter().find(|(from, _)| from == path) {
|
||||
return name.clone();
|
||||
/// Copies `source` under a name still free.
|
||||
fn copy(&self, source: &mut File, path: &Path, folder: &Path) -> io::Result<String> {
|
||||
if self.copied.is_empty() {
|
||||
// A folder already there is someone's: refused.
|
||||
std::fs::create_dir(folder).map_err(|error| match error.kind() {
|
||||
io::ErrorKind::AlreadyExists => {
|
||||
io::Error::new(error.kind(), folder.display().to_string())
|
||||
}
|
||||
_ => error,
|
||||
})?;
|
||||
}
|
||||
let file = path
|
||||
.file_name()
|
||||
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
|
||||
let (stem, extension) = file
|
||||
.rsplit_once('.')
|
||||
.map_or((file.as_str(), ""), |(stem, ext)| (stem, ext));
|
||||
let taken = |name: &str| self.copies.iter().any(|(_, kept)| kept == name);
|
||||
let mut name = file.clone();
|
||||
let mut count = 1;
|
||||
while taken(&name) {
|
||||
count += 1;
|
||||
name = match extension {
|
||||
"" => format!("{stem}-{count}"),
|
||||
let (stem, extension) = file.rsplit_once('.').unwrap_or((file.as_str(), ""));
|
||||
for count in 1.. {
|
||||
let name = match (count, extension) {
|
||||
(1, _) => file.clone(),
|
||||
(_, "") => format!("{stem}-{count}"),
|
||||
_ => format!("{stem}-{count}.{extension}"),
|
||||
};
|
||||
// The file system judges which names are the same.
|
||||
match File::create_new(folder.join(&name)) {
|
||||
Ok(mut kept) => {
|
||||
io::copy(source, &mut kept)?;
|
||||
return Ok(name);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
self.copies.push((path.to_path_buf(), name.clone()));
|
||||
name
|
||||
unreachable!("names never run out")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,16 +165,16 @@ fn mime(extension: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `alt` after a blanked `src`.
|
||||
fn alt(after: &str) -> String {
|
||||
let text = after.strip_prefix(" alt=\"").unwrap_or_default();
|
||||
super::unescape(&text[..text.find('"').unwrap_or(text.len())])
|
||||
/// A `file:` address's path; a drive keeps no leading slash.
|
||||
fn file_path(rest: &str) -> PathBuf {
|
||||
let path = decoded(rest.strip_prefix("localhost").unwrap_or(rest));
|
||||
let drive = path.len() > 2 && path.as_bytes()[2] == b':' && path.starts_with('/');
|
||||
PathBuf::from(if drive { &path[1..] } else { &path })
|
||||
}
|
||||
|
||||
/// `%XX` undone; bytes that are not UTF-8 stay as written.
|
||||
fn decoded(src: &str) -> String {
|
||||
let src = super::unescape(src);
|
||||
let bytes = src.as_bytes();
|
||||
fn decoded(url: &str) -> String {
|
||||
let bytes = url.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut at = 0;
|
||||
while at < bytes.len() {
|
||||
@@ -157,10 +195,10 @@ fn decoded(src: &str) -> String {
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).unwrap_or(src)
|
||||
String::from_utf8(out).unwrap_or_else(|_| url.to_owned())
|
||||
}
|
||||
|
||||
/// A name as a path segment of a URL.
|
||||
/// A name as one path segment of a URL.
|
||||
fn encoded(name: &str) -> String {
|
||||
name.bytes()
|
||||
.map(|byte| match byte {
|
||||
@@ -171,11 +209,3 @@ fn encoded(name: &str) -> String {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A value safe inside a quoted attribute.
|
||||
fn attribute(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
}
|
||||
@@ -56,6 +56,7 @@ fn words(node: &Node) -> String {
|
||||
Node::InlineMath(math) => math.value.clone(),
|
||||
Node::Break(_) => "\n".into(),
|
||||
Node::Image(image) => image.alt.clone(),
|
||||
Node::ImageReference(image) => image.alt.clone(),
|
||||
Node::Html(_) => String::new(),
|
||||
_ => node.children().into_iter().flatten().map(words).collect(),
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ fn page(text: &str, beside: Option<&Path>, pictures: &Pictures) -> super::Page {
|
||||
body(text, beside, pictures).expect("a page")
|
||||
}
|
||||
|
||||
/// A 1×1 PNG, as base64.
|
||||
const DOT: &str =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4z8AAAAMBAQAY3Y2wAAAAAElFTkSuQmCC";
|
||||
|
||||
#[test]
|
||||
fn formulas_and_diagrams_draw_as_svg() {
|
||||
let text =
|
||||
"Inline $x^2$ here.\n\n$$\n\\sqrt{y}\n$$\n\n```mermaid\nflowchart LR\n A --> B\n```\n";
|
||||
let page = page(text, None, &Pictures::Inline);
|
||||
assert!(!page.html.contains("language-math"), "{}", page.html);
|
||||
assert!(!page.html.contains("language-mermaid"), "{}", page.html);
|
||||
assert_eq!(page.html.matches("<svg").count(), 3, "{}", page.html);
|
||||
assert!(
|
||||
page.html.contains("<span class=\"math\"><svg")
|
||||
@@ -22,36 +24,47 @@ fn formulas_and_diagrams_draw_as_svg() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_does_not_draw_stays_as_code_with_its_line() {
|
||||
let text = "# Title\n\n$$\n\\frac{\n$$\n\nText $\\sqrt{$ more.\n\n```mermaid\nflowchart LR\n A -->\n```\n";
|
||||
fn what_does_not_draw_stays_as_code_at_its_own_line() {
|
||||
let text = "$$\n\\frac{\n$$\n\n$$\n\\frac{\n$$\n\nText $\\sqrt{$ more.\n\n```mermaid\nflowchart LR\n A -->\n```\n\n```mermaid\nflowchart LR\n A[\u{F0000}] --> B\n```\n";
|
||||
let page = page(text, None, &Pictures::Inline);
|
||||
let lines: Vec<usize> = page.formulas.iter().map(|(line, _)| *line).collect();
|
||||
assert_eq!(lines, [3, 7]);
|
||||
assert_eq!(lines, [1, 5, 9]);
|
||||
assert!(page.formulas.iter().all(|(_, why)| !why.is_empty()));
|
||||
assert_eq!(page.diagrams, [9]);
|
||||
assert_eq!(page.diagrams, [11, 16]);
|
||||
assert!(
|
||||
page.html.contains("language-math math-display\">\\frac{"),
|
||||
page.html
|
||||
.contains("<pre><code class=\"language-math\">\\frac{"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(page.html.contains("<code>\\sqrt{</code>"), "{}", page.html);
|
||||
assert!(
|
||||
page.html
|
||||
.contains("language-math math-inline\">\\sqrt{</code>"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(
|
||||
page.html.contains("language-mermaid\">flowchart LR"),
|
||||
.contains("<pre><code class=\"language-mermaid\">flowchart LR"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_undrawn_formula_after_a_drawn_one_keeps_its_code() {
|
||||
let page = page("$$\nx\n$$\n$$\n\\frac{\n$$\n", None, &Pictures::Inline);
|
||||
assert_eq!(page.formulas.len(), 1);
|
||||
assert!(page.html.contains("\\frac{"), "{}", page.html);
|
||||
fn markdown_writes_as_html() {
|
||||
let text = "# T\n\n- [x] done\n- b\n\n3. c\n\n| l | r |\n|:--|--:|\n| 1 | 2 |\n\n~~gone~~ [a](https://x.org \"t\") [r][ref] [bad](javascript:alert(1))\n\nNote[^1].\n\n[^1]: The note.\n\n[ref]: /docs\n";
|
||||
let html = page(text, None, &Pictures::Inline).html;
|
||||
for part in [
|
||||
"<h1>T</h1>",
|
||||
"<li><input type=\"checkbox\" disabled checked> done</li>",
|
||||
"<ol start=\"3\">",
|
||||
"<th style=\"text-align:left\">l</th>",
|
||||
"<td style=\"text-align:right\">2</td>",
|
||||
"<del>gone</del>",
|
||||
"<a href=\"https://x.org\" title=\"t\">a</a>",
|
||||
"<a href=\"/docs\">r</a>",
|
||||
"<a>bad</a>",
|
||||
"<sup><a href=\"#fn-1\">1</a></sup>",
|
||||
"<li id=\"fn-1\"><p>The note.</p></li>",
|
||||
] {
|
||||
assert!(html.contains(part), "{part} in {html}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -63,15 +76,17 @@ fn front_matter_and_raw_html_do_not_go_live() {
|
||||
);
|
||||
assert!(!page.html.contains("Secret"), "{}", page.html);
|
||||
assert!(!page.html.contains("<script>"), "{}", page.html);
|
||||
assert!(page.html.contains("Text."));
|
||||
assert!(page.html.contains("<p>Text.</p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_pictures_are_data_and_web_ones_stay() {
|
||||
fn inline_pictures_are_data_and_addresses_stay_as_written() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
std::fs::write(dir.path().join("dot.png"), b"png bytes").expect("writes");
|
||||
let text = "  \n";
|
||||
let page = page(text, Some(dir.path()), &Pictures::Inline);
|
||||
let text = format!(
|
||||
"   \n"
|
||||
);
|
||||
let page = page(&text, Some(dir.path()), &Pictures::Inline);
|
||||
assert!(
|
||||
page.html
|
||||
.contains("src=\"data:image/png;base64,cG5nIGJ5dGVz\""),
|
||||
@@ -80,78 +95,133 @@ fn inline_pictures_are_data_and_web_ones_stay() {
|
||||
);
|
||||
assert!(
|
||||
page.html
|
||||
.contains("src=\"https://example.com/c.png?x=1&y=2\""),
|
||||
.contains("src=\"https://example.com/a%2Fb.png?q=a%26b&x=1\""),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(
|
||||
page.html
|
||||
.contains(&format!("src=\"data:image/png;base64,{DOT}\"")),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert!(
|
||||
page.html.contains("<img src=\"gone.png\" alt=\"b\">"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
assert_eq!(page.missing, ["gone.png"]);
|
||||
assert!(page.copies.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_pictures_copy_once_under_names_of_their_own() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
for folder in ["one", "two"] {
|
||||
for (folder, name) in [("one", "image.png"), ("two", "IMAGE.PNG")] {
|
||||
std::fs::create_dir(dir.path().join(folder)).expect("makes");
|
||||
std::fs::write(dir.path().join(folder).join("my pic.png"), folder).expect("writes");
|
||||
std::fs::write(dir.path().join(folder).join(name), folder).expect("writes");
|
||||
}
|
||||
let text = "  \n";
|
||||
let page = page(
|
||||
text,
|
||||
Some(dir.path()),
|
||||
&Pictures::Folder("doc_files".into()),
|
||||
);
|
||||
let names: Vec<&str> = page.copies.iter().map(|(_, name)| name.as_str()).collect();
|
||||
assert_eq!(names, ["my pic.png", "my pic-2.png"]);
|
||||
let shelf = dir.path().join("note#1_files");
|
||||
let text = "  \n";
|
||||
let page = page(text, Some(dir.path()), &Pictures::Folder(shelf.clone()));
|
||||
let mut held: Vec<Vec<u8>> = std::fs::read_dir(&shelf)
|
||||
.expect("the folder")
|
||||
.map(|entry| std::fs::read(entry.expect("an entry").path()).expect("reads"))
|
||||
.collect();
|
||||
held.sort();
|
||||
assert_eq!(
|
||||
page.html.matches("src=\"doc_files/my%20pic.png\"").count(),
|
||||
held,
|
||||
[b"one".to_vec(), b"two".to_vec()],
|
||||
"no picture overwrote another"
|
||||
);
|
||||
assert_eq!(
|
||||
page.html
|
||||
.matches("src=\"note%231_files/image.png\"")
|
||||
.count(),
|
||||
2,
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_already_there_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
std::fs::write(dir.path().join("dot.png"), b"p").expect("writes");
|
||||
let shelf = dir.path().join("taken");
|
||||
std::fs::create_dir(&shelf).expect("makes");
|
||||
let error = body(
|
||||
"\n",
|
||||
Some(dir.path()),
|
||||
&Pictures::Folder(shelf),
|
||||
)
|
||||
.expect_err("refused");
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_leaves_out_what_mupdf_cannot_read_as_words() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
std::fs::write(dir.path().join("a.webp"), b"w").expect("writes");
|
||||
let text = "  \n";
|
||||
let page = page(
|
||||
text,
|
||||
Some(dir.path()),
|
||||
&Pictures::Pdf(dir.path().join("files")),
|
||||
);
|
||||
assert_eq!(
|
||||
page.missing,
|
||||
["a.webp", "https://example.com/c.png", "gone.png"]
|
||||
);
|
||||
assert!(!page.html.contains("<img"), "{}", page.html);
|
||||
assert!(
|
||||
page.html.contains("src=\"doc_files/my%20pic-2.png\""),
|
||||
page.html.contains("[first]") && page.html.contains("[third]"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_leaves_out_what_mupdf_cannot_read() {
|
||||
fn other_schemes_and_untitled_pictures_are_missing() {
|
||||
let page = page(" \n", None, &Pictures::Inline);
|
||||
assert_eq!(page.missing, ["a.png", "javascript:x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_address_reads_its_path() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
std::fs::write(dir.path().join("a.webp"), b"w").expect("writes");
|
||||
std::fs::write(dir.path().join("b.png"), b"p").expect("writes");
|
||||
let text = "  \n";
|
||||
let page = page(text, Some(dir.path()), &Pictures::Pdf("files".into()));
|
||||
assert_eq!(page.missing, ["a.webp", "https://example.com/c.png"]);
|
||||
assert_eq!(page.copies.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blocked_address_is_missing_by_its_text() {
|
||||
let page = page("\n", None, &Pictures::Inline);
|
||||
assert_eq!(page.missing, ["the chart"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untitled_document_has_no_folder_for_pictures() {
|
||||
let page = page("\n", None, &Pictures::Inline);
|
||||
assert_eq!(page.missing, ["a.png"]);
|
||||
let picture = dir.path().join("d o t.png");
|
||||
std::fs::write(&picture, b"png").expect("writes");
|
||||
let path = picture
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
.replace(' ', "%20");
|
||||
let url = if path.starts_with('/') {
|
||||
format!("file://{path}")
|
||||
} else {
|
||||
format!("file:///{path}")
|
||||
};
|
||||
let page = page(&format!("\n"), None, &Pictures::Inline);
|
||||
assert!(page.missing.is_empty(), "{:?}", page.missing);
|
||||
assert!(
|
||||
page.html.contains("data:image/png;base64,cG5n"),
|
||||
"{}",
|
||||
page.html
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_wraps_the_body_and_escapes_its_title() {
|
||||
let html = document("a<b & c", "<p>x</p>\n");
|
||||
let html = document("a<b & c", "<p>x</p>");
|
||||
assert!(html.starts_with("<!doctype html>"));
|
||||
assert!(html.contains("<title>a<b & c</title>") && html.contains("<p>x</p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_text_keeps_words_code_and_tex() {
|
||||
let text = "---\ntitle: x\n---\n\n# Head *one*\n\n- a\n- b `c`\n\n| h | i |\n|---|---|\n| 1 | 2 |\n\n$$\n\\sqrt{x}\n$$\n\n> quoted $y$\n";
|
||||
let text = "---\ntitle: x\n---\n\n# Head *one*\n\n- a\n- b `c`\n\n| h | i |\n|---|---|\n| 1 | 2 |\n\n$$\n\\sqrt{x}\n$$\n\n> quoted $y$ ![a cat][cat]\n\n[cat]: pic.png\n";
|
||||
assert_eq!(
|
||||
plain(text).expect("text"),
|
||||
"Head one\n\na\nb c\n\nh\ti\n1\t2\n\n\\sqrt{x}\n\nquoted y\n"
|
||||
"Head one\n\na\nb c\n\nh\ti\n1\t2\n\n\\sqrt{x}\n\nquoted y a cat\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,6 +229,7 @@ mod files {
|
||||
use std::path::Path;
|
||||
|
||||
use super::super::{Format, Target, write};
|
||||
use super::DOT;
|
||||
|
||||
fn target<'a>(path: &'a Path, work: &'a Path) -> Target<'a> {
|
||||
Target {
|
||||
@@ -169,14 +240,10 @@ mod files {
|
||||
}
|
||||
}
|
||||
|
||||
fn picture(dir: &Path) {
|
||||
std::fs::write(dir.join("dot.png"), b"png").expect("writes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_carries_its_pictures_inside() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
picture(dir.path());
|
||||
std::fs::write(dir.path().join("dot.png"), b"png").expect("writes");
|
||||
let out = dir.path().join("out.html");
|
||||
write(
|
||||
Format::Page,
|
||||
@@ -193,7 +260,7 @@ mod files {
|
||||
#[test]
|
||||
fn a_linked_page_copies_into_a_new_folder_only() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
picture(dir.path());
|
||||
std::fs::write(dir.path().join("dot.png"), b"png").expect("writes");
|
||||
let out = dir.path().join("out.html");
|
||||
write(
|
||||
Format::Linked,
|
||||
@@ -222,6 +289,10 @@ mod files {
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
|
||||
assert!(error.to_string().contains("out_files"), "{error}");
|
||||
assert!(!out.exists(), "nothing written");
|
||||
assert!(
|
||||
dir.path().join("out_files/dot.png").exists(),
|
||||
"the folder found stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -261,32 +332,74 @@ mod files {
|
||||
);
|
||||
}
|
||||
|
||||
/// The text of a PDF's first page.
|
||||
fn words(pdf: &Path) -> String {
|
||||
let mut worker = ely_pdf_engine::Worker::spawn().expect("the worker");
|
||||
let doc = worker.open(pdf).expect("opens").doc;
|
||||
worker
|
||||
.text(doc, 0)
|
||||
.expect("text")
|
||||
.iter()
|
||||
.map(|ch| ch.c)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_sets_the_page_and_leaves_no_scratch() {
|
||||
let dir = tempfile::tempdir().expect("a dir");
|
||||
let work = tempfile::tempdir().expect("work");
|
||||
let out = dir.path().join("out.pdf");
|
||||
let text = "# Heading 标题\n\nInline $x^2$.\n\n```mermaid\nflowchart LR\n A --> B\n```\n";
|
||||
let text = format!(
|
||||
"# Heading 标题\n\nInline $x^2$.\n\n \n\n```mermaid\nflowchart LR\n A --> B\n```\n"
|
||||
);
|
||||
let page = write(
|
||||
Format::Pdf,
|
||||
text,
|
||||
&text,
|
||||
Some(dir.path()),
|
||||
&target(&out, work.path()),
|
||||
)
|
||||
.expect("writes");
|
||||
assert!(page.missing.is_empty() && page.formulas.is_empty() && page.diagrams.is_empty());
|
||||
assert!(!work.path().join("pdf").exists(), "the scratch is gone");
|
||||
let mut worker = ely_pdf_engine::Worker::spawn().expect("the worker");
|
||||
let doc = worker.open(&out).expect("opens").doc;
|
||||
let words: String = worker
|
||||
.text(doc, 0)
|
||||
.expect("text")
|
||||
.iter()
|
||||
.map(|ch| ch.c)
|
||||
.collect();
|
||||
assert_eq!(page.missing, ["gone.png"]);
|
||||
assert!(page.formulas.is_empty() && page.diagrams.is_empty());
|
||||
assert_eq!(
|
||||
std::fs::read_dir(work.path()).expect("work").count(),
|
||||
0,
|
||||
"the scratch is gone"
|
||||
);
|
||||
let words = words(&out);
|
||||
assert!(
|
||||
words.contains("Heading") && words.contains("标题"),
|
||||
words.contains("Heading") && words.contains("标题") && words.contains("[lost]"),
|
||||
"{words:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdfs_at_once_keep_to_their_own_pages() {
|
||||
let (dir, work) = (
|
||||
tempfile::tempdir().expect("a dir"),
|
||||
tempfile::tempdir().expect("work"),
|
||||
);
|
||||
let jobs: Vec<_> = (0..4)
|
||||
.map(|n| {
|
||||
let (out, work) = (
|
||||
dir.path().join(format!("{n}.pdf")),
|
||||
work.path().to_path_buf(),
|
||||
);
|
||||
std::thread::spawn(move || {
|
||||
write(
|
||||
Format::Pdf,
|
||||
&format!("# Page{n}\n"),
|
||||
None,
|
||||
&target(&out, &work),
|
||||
)
|
||||
.expect("writes");
|
||||
out
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for (n, job) in jobs.into_iter().enumerate() {
|
||||
let out = job.join().expect("the job");
|
||||
assert!(words(&out).contains(&format!("Page{n}")), "job {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 c7a86079fc39c3bfd234ce5b7eca77204f10b7fc
|
||||
fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git ff9c0f88d3b7975a316f6869b5d250200178885a
|
||||
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 \
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
FAIL
|
||||
|
||||
- apps/ely-mark/src/output/pictures.rs:87 — The resource-folder name is inserted into a quoted HTML attribute without URL encoding or HTML escaping. Exporting to `note#1.html` copies the image successfully but emits `src="note#1_files/pic.png"`, so the browser treats the resource path as a fragment. On Linux, the valid filename `note" onerror="alert(1);x=.html` produces an actual `onerror` attribute; parsing the exported file and executing that handler with a mock `alert` confirms execution. Encode the folder as a URL path segment and escape the completed attribute. Evidence: `.work/C14.14-r1/windows-probe.log`, `linux-security-probe.log`.
|
||||
|
||||
- apps/ely-mark/src/output/files.rs:90 — Every PDF operation for a tab shares `work/pdf`, deleting and overwriting another operation's HTML, PDF and cleanup directory. The action handlers launch background jobs without serializing them. Two concurrent exports with different text reproduced a destination containing the other job's text, a cleanup error after a destination was written, and a PDF that failed to open with `no objects found`. Give each operation its own scratch files, or serialize the tab's export/print operations through completion and cleanup. Evidence: `.work/C14.14-r1/linux-pdf-probe.log`.
|
||||
|
||||
- apps/ely-mark/src/output/pictures.rs:103 — Resource-name collision detection compares strings case-sensitively, but copying overwrites existing files on case-insensitive filesystems. On native Windows, exporting `source-one/image.png` and `source-two/IMAGE.PNG` succeeds with two copy records and no warning, yet the output directory contains one file holding the second image's bytes; both references display that image. Allocate names without overwriting, accounting for the destination filesystem's filename equivalence. Evidence: `case_collision` in `.work/C14.14-r1/windows-probe.log`.
|
||||
|
||||
- apps/ely-mark/src/output/pictures.rs:64 — Missing or unsupported pictures become `<img src="">`. For HTML this discards the original address, contrary to the documented preservation rule. For PDF, even a known missing image causes MuPDF to warn `html: cannot load image src=''`, failing the entire export before `left_out` can show its missing-picture notice. A document containing ordinary text and `` cannot export or print; the explicitly excluded WebP and network pictures take the same path. Preserve missing addresses in HTML and represent known PDF omissions without asking Story to load an empty image, while retaining their diagnostics. Evidence: `.work/C14.14-r1/linux-probe.log`, `linux-pdf-probe.log`.
|
||||
|
||||
- apps/ely-mark/src/output/pictures.rs:38 — Percent decoding is applied to network URLs before they are returned as supposedly unchanged addresses. `https://example.com/a%2Fb.png?q=a%26b` exports as `https://example.com/a/b.png?q=a&b`, changing both the requested path and query parameters without a warning. Preserve network URL escaping; decode only the filesystem path used to read a local resource. Native Windows and WSL reproduce this in `.work/C14.14-r1/windows-probe.log` and `linux-probe.log`.
|
||||
|
||||
- apps/ely-mark/src/output/mod.rs:52 — The default compiler blanks `data:` image destinations before picture handling can inspect them. A valid embedded PNG supported by the document renderer becomes `src=""` and is reported missing by its alt text; HTML/rich copying loses the image, and PDF export fails. The `data:` branch in `Placed::place` is consequently unreachable for these images. Process supported image destinations from the syntax tree before compilation discards them, retaining the protection against executable links and raw HTML. Evidence: `data_picture` in `.work/C14.14-r1/windows-probe.log`, and `data` in `linux-pdf-probe.log`.
|
||||
|
||||
- reference/Ely-GPUI-Components/src/documents/diagram.rs:114 (c7a8607) — Counting retained text nodes does not establish that their characters found fonts. The WSL probe checked every loaded font face and found zero covering `𓀀`, yet `diagram_svg` returned success for both `A[𓀀]` and `A[ABC 𓀀]`. The exported SVG therefore substitutes missing glyphs without a diagram diagnostic, contradicting the stated missing-font error behavior. Validate glyph coverage/shaping failures and return an error so ElyMark preserves the diagram's source and reports its line. Evidence: `.work/C14.14-r1/linux-diagram-probe.log`.
|
||||
|
||||
- apps/ely-mark/src/output/plain.rs:58 — Plain-text extraction handles `Node::Image` but drops `Node::ImageReference`, which has alt text and no children. `A ![a cat][cat].` followed by `[cat]: pic.png` exports as `A .\n`, whereas the equivalent inline image retains `a cat`. Preserve alt text for reference-style images as well. Native Windows and WSL reproduce `plain_reference="A .\n"` in `.work/C14.14-r1/windows-probe.log` and `linux-probe.log`.
|
||||
|
||||
- apps/ely-mark/src/export/mod.rs:294 — Rich copying discards `Page.missing`, `Page.formulas` and `Page.diagrams` and only reports a fatal conversion/clipboard error. Copying a missing local image or an invalid formula therefore succeeds silently with missing content or fallback code; the user receives none of the available omission/error diagnostics. Retain the page metadata through clipboard writing and surface these warnings on successful copy, as the other output actions do. The missing-image and invalid-formula metadata returned by the same converter is demonstrated in `.work/C14.14-r1/windows-probe.log`.
|
||||
|
||||
- apps/ely-mark/src/output/mod.rs:145 — Error positions are recovered by repeatedly searching the entire tree for the first matching source string. Two identical invalid formulas on lines 1 and 3 produce diagnostics for lines `[1, 1]`; repeated invalid diagrams have the same problem. Track each rendered occurrence's syntax-tree position rather than looking up its text from the beginning, so each warning identifies its actual line. Evidence: `same_bad_formula` in `.work/C14.14-r1/windows-probe.log` and `linux-probe.log`.
|
||||
Reference in new issue
Block a user