From 88b0f8f5fc2204aa2ab21d06961a143c07fff45d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 10:39:36 -0400 Subject: [PATCH] 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) --- Cargo.lock | 2 +- Cargo.toml | 2 +- agents.md | 4 +- apps/ely-mark/src/export/mod.rs | 17 +- apps/ely-mark/src/output/files.rs | 57 ++---- apps/ely-mark/src/output/html.rs | 278 +++++++++++++++++++++++++++ apps/ely-mark/src/output/mod.rs | 130 ++----------- apps/ely-mark/src/output/pictures.rs | 180 +++++++++-------- apps/ely-mark/src/output/plain.rs | 1 + apps/ely-mark/src/output/tests.rs | 265 +++++++++++++++++-------- scripts/fetch-references.sh | 2 +- tasks/reviews/C14.14-r1.md | 21 ++ 12 files changed, 647 insertions(+), 312 deletions(-) create mode 100644 apps/ely-mark/src/output/html.rs create mode 100644 tasks/reviews/C14.14-r1.md diff --git a/Cargo.lock b/Cargo.lock index d9874b2..c626e31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 4dc948b..10b3d34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/agents.md b/agents.md index a82d655..f54d7ff 100644 --- a/agents.md +++ b/agents.md @@ -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 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印删去,在 C14.9、C14.10、C14.12、C14.14 原生重做。C14.5 删去 CEF:`crates/ely-web-content`、`apps/ely-mark/web`、`scripts/fetch-cef.sh`、`scripts/build-mark-content.sh`、macOS 包里的辅助应用与其授权、Linux AppImage 的 `libcef` 安排、CEF 与网页依赖的许可、存储设置里的网页缓存、`webview.sh`、`hardened-macos.sh`、`linux-mark-idle.sh`、参考源码 cef、cef-rs、milkdown、codemirror-dev;`apps::open` 不再复原信号(只为 Chromium 屏蔽的 SIGTERM 而设)。旧 C14.1 至 C14.7、R2.13 与 ElyMark AppImage 的记录(CEF 实现)删去,在 git 历史;仍在的应用行为(外部变更与比较、会话、改名、Git 状态、最近与文件夹、`look.json`)在 C14.8 至 C14.16 逐条核对后重记。用户以前的 ElyMark 数据目录里可能留着 `web` 缓存文件夹,Ely 不再读写它。组件库 6f0401e:焦点进入空的可视文档即打开末尾的输入框,新文件可直接键入(原先键入无处落下);362a0fc:换回可视时同样打开(预览与可视共用焦点,无焦点事件),全文撤销后若文档成空,读完新文字再打开末尾的输入框;639521e:只有一份撤销历史,块的输入框自己的撤销与重做从不运行:在打开的块里撤销或重做,先把块写回为一步,再走全文的历史(块内逐键的撤销没有了,一次块编辑是一步),重做跨模式、跨多次撤销都在。ElyMark(C14.6):每个标签各记其模式,切换不改一个字节(编码写出与原文相同),撤销跨模式(全文一份撤销历史);视图在模式首次可键入时记一次 `mark: shown as "…"`(源码即刻,可视与预览在块读完后),PERF-07 计到这条。Windows 上实测(Ryzen 9 9950X3D)1 MB 文档 P50 341 毫秒、P95 560 毫秒(`docs/perf/2026-10-12-Ryzen9-9950X3D.md`);macOS、Linux 与树莓派未测。C14.7:语法由 markdown-rs 读(CommonMark 加 GFM 的表格、任务、删除线、自动链接,脚注,公式),front matter 不进可视,以原文块显示在最上(组件库 parse 测试核对各种块画成内容而非原文);链接照 M-05:预览单击、可视 Cmd(他处 Ctrl)按下交应用,可视普通按下编辑该块(组件库 bbb6071 的链接测试);应用只把 http、https、mailto 交系统打开,其余以提示拒绝(应用测试);链接地址与图片读不出的原因不进日志(地址与路径属文档内容)。文档旁图片按文件所在文件夹读(`MarkView` 在打开与改名时交给组件);组件库 4801555:表格单元格也是块,图片在格内照常画出(原只画替代文字),图片取自身宽度、以所在容器为上限(单元格可收缩,窄格里的图片按比例缩小,组件库 2e196a1)。gpui 的图片加载(`vendor/gpui-pre` 的 `img.rs`):GIF 与 WebP 的坏帧即结束加载并报错(`img.rs` 的加载器与 `platform.rs` 的 `Image::to_image_data`,后者给 `data:` 图片用)(原样跳过坏帧,解码器到文件尾仍不停地报错,加载永不结束、占满一个核),错误与日志不带图片路径(原样把路径写进错误,`AssetLogger` 照写进日志);应用测试以坏 GIF 核对两处加载都结束、错误里没有所在文件夹名。C14.8:左栏三页照旧(文件夹树、大纲、搜索);大纲在纵向滚动区里(标题再多也选得到,侧栏测试以 100 个标题滚到最后),取组件按 markdown-rs 读出的标题与行,选一项即 `reveal`(该行在视图里、插入点在其上;可视模式里键入仍须先按下块);多标签与最近列表经外壳与 `recent.json` 照旧。`desk::open_in` 收数据文件夹(`open` 照旧取本机的),应用的 gpui 测试以临时文件夹起整个窗口:打开文件即有标签、大纲行号、跳转落点与最近列表,键入的标题进大纲,两个文件两个标签各有大纲,打开文件夹只列 Markdown;文件监视在真线程上,测试以 `allow_parking` 允许。`ely_ui::install_no_launch_files`(`test-support`)给测试一个空的启动文件队列。随删去的网页页面一起的 `web.*` 文案(`i18n/web.rs`)删去。C14.9(组件库 ce4a1e9):回车照列表续写(无序、有序加一、任务为未勾),空项回车即结束列表,源码与打开的块都如此,只在 markdown-rs 读作列表项且不在代码或公式里时(围栏里的 `- a` 照常换行;长文读完前源码里回车只换行),新项照所在项的引用前缀、缩进(空格或 Tab)、标记后的空白与任务框写出,父项标记处换成空格(组件库 e162082);没写正文的任务框也算空项,回车即结束列表;Shift+Tab 把行首一个 Tab 当一级退回;在空白行(含只有空格的行)插入表格即以表格取代该行(上一行有字时先空一行),免成缩进代码(组件库 cd16b20);打开的块里 Tab、Shift+Tab 把所触的行缩进或退回四格(`MarkdownDocument > ElyInput` 键位,块外 Tab 照旧在块间移动),源码由编辑器自己缩进;`MarkdownDocument::edit_table` 按插入点所在表格增删行列,`insert_table` 在插入点所在行后放一个三列表格,都为一步撤销,表格按规范写法重写(引用与列表里的表格各行照留其前缀,前缀不一即拒绝;表头之外的多余单元格照留,在其上的列操作拒绝;拒绝的原因经 `TableRefusal` 交给应用提示),预览里一切修改(表格、替换)都拒绝;`show_match` 在源码选中、可视打开其所在块并选中、预览转到其行,`replace` 一步替换(自己的写入计入,已打开的块不被关掉);文字未读完时 `show_match` 先读,长文等读完在下一帧落定(记下所等的文字版本:其间文字一变即作废,新的请求取代等着的);围栏代码按其语言着色(`forms::syntax_fenced`,散文名不着色,无名照通用着色)。ElyMark:Cmd/Ctrl+F 与“编辑›查找和替换”打开查找框(组件库 `FindWidget`,`find_all` 的大小写、全词、正则;正则写错即框内显示原因),源码里标出全部匹配与当前一处;替换一处后转到替换文字之后的下一处(到尾即回到第一处),全部替换为一步撤销;“表格”菜单插入表格与增删行列,插入点不在表格里即提示。应用测试以整窗核对计数、替换与撤销、正则错误、跳到下一处、表格菜单。C14.10(组件库 d126a7e):拖到文档上的文件与剪贴板里只有图片(没有文字)时的粘贴,组件以 `Attachment` 事件交给属主(源码、打开的块与可视无块时都接 Cmd/Ctrl+V),`MarkdownDocument::insert` 在插入点写入文字(打开的块里照键入,预览拒绝)。ElyMark 的 `attach.rs`:只收图片(png、jpg、jpeg、gif、webp、svg、bmp,别的文件提示不收),在后台复制到文件旁的 `assets/`(拖入的保留原名、重名加序号,粘贴的名为 `pasted-毫秒.扩展名`;`ely_files::import_attachment` 与新的 `write_attachment` 同一套:以 `create_new` 占名,从不覆盖,写坏即删去),完成时文件仍在原文件夹才在插入点写 `![名](assets/名)`(名中的控制字符换成空格,方括号与反斜杠转义;地址除字母数字与 `-._~` 外逐字节百分号转义;文件名不是规范文字的图片在复制前即拒绝并说明,链接从不改写文件名),否则提示图片留在哪里;未保存过的文档与预览拒绝并说明。视图的文件夹只在外壳告知已存文件的位置时才有(`MarkView::moved`),图片的读取位置随之;未命名文档没有文件夹。`Opened` 去掉(只剩源),引擎直接载入 `Source`。C14.11(组件库 e89fb32):`MarkdownDocument::reset` 换文字时保留插入点与顶行(按新文字夹住,顶行等新文字读完后落定),干净的标签接过磁盘文字即停在原处,撤销不回到旧文字;外部变更、改名跟随、删除与读错照旧(`outside.rs`;`ely_files::Fingerprint` 加上文件身份,保留修改时间的原子替换也认作变更,各应用的冲突核对同受其益;每次读盘编号,只认最新一次,落定前再核对磁盘仍是所读的那份,否则重看),比较后的合并经 `view.adopt` 为一步撤销。应用测试以整窗核对:干净重载保位与撤销止于磁盘,删除后留着文字并标为已删,改名后标签跟到新名,路径变成文件夹即标为读不出,合并为一步撤销,保留时间的替换照样重载,过期的读取不落定。C14.12(组件库 508fee9):`MarkdownDocument::set_look(DocumentLook)` 是书写视图,只管怎么显示:`measure` 给正文栏的最大宽并居中(三种模式),`font` 给可视与预览的正文字体(代码照用等宽),`scale` 乘以正文与标题的字号(可视、预览经渲染的 `Draw`,打开的块同样,源码经 `CodeEditor::set_scale`),`focus` 让可视里读者所在块之外的块、源码里插入点所在行之外的行淡到 0.35(`CodeEditor::set_focus_line`),`typewriter` 让可视里读者所在块(打开的或插入点所在的)、源码里插入点所在行居中(块、文字、键入、块高或可视区一变即重新居中;两端各留半屏余量,首末块与首末行也能居中:可视放在第一块之上与末尾项之下,源码为列表首尾的空行(行数取正文区高度的一半,绘制、输入法与点按的坐标同一原点);模式切换的定位落定后重新居中;源码在渲染时以严格居中滚动,`CodeEditor::set_typewriter`;居中记录含视图与行高;组件库 f102ea0、cf9cc19、7ed4ea8)。ElyMark 的 `look.json` 照旧(专注、打字机、窄中宽 36/48/64rem、无衬线/衬线/等宽、字号 70% 至 200%),新标签首次报到即取用,菜单一改即作用于全部标签并存下(存不下即提示);衬线字体为 Georgia(Linux 为 DejaVu Serif,未在 Linux 上核对是否装有);专注模式另收起标签条、左栏与状态栏(照旧)。搜索(C14.13,MARK-10)照旧:本文件或所开文件夹,不分大小写逐行找,打开着的文件按当前文字;每条结果另记该行第一处命中的字符列(查询与原文都逐字去掉大小写,词尾 `ς` 同 `σ`,照 Unicode 的大小写折叠;逐字对应回原文,`İ` 等变长的字照样对得上),选中即打开或转到该文件,插入点落在该处。输出(C14.14,MARK-11):“文件›导出…”(⌘E)一个对话框选 HTML、HTML 与资源文件夹、PDF、纯文本,再选位置;HTML 由 markdown-rs 生成(GFM 加数学,front matter 不进输出,原始 HTML 照 markdown-rs 默认转义,页面里没有能运行的东西),公式换成 RaTeX 的轮廓 SVG(组件库 `formula_svg`,黑色),Mermaid 图换成浅色 SVG,经 usvg 把箭头标记与文字都变成路径(组件库 `diagram_svg`;有字找不到字体即报错,不让标签悄悄消失),画不出的公式与图照原代码保留并按行提示;图片:自包含的 HTML 内嵌为 data URL,资源文件夹版拷进新建的 `名称_files`(同名不同源的加序号,文件夹已在即拒绝,写坏即删去),网址照原样,读不到的照原地址并提示;纯文本取自 mdast(块间空行,表格以制表符分格,公式写 TeX)。PDF:同一页面与图片写进标签的暂存文件夹,`ely-pdf-worker` 的 `HTML_PDF` 以 MuPDF 的 story 排成 A4(边距 2 厘米),MuPDF 读不了的图(WebP、网址)事先列为缺图;story 或绘制有任何警告即整份失败、不写文件(同图片导出);暂存随即删去。打印(⌘P)先写同一份 PDF 再交系统面板(`ely_office::print_later`)。“编辑›复制为富文本”:所选的 Markdown(源码的选区或打开的块的选区,组件库 `MarkdownDocument::selected`;都没有即全文)以同一套生成 HTML(图片内嵌)放进剪贴板,纯文本为 Markdown 本身。导出不写打开着的文件(`ely_files::export` 与外壳的持有者检查)。ElyMark 因此依赖 `ely-pdf-engine`,各端的包随带 `ely-pdf-worker` 与 MuPDF、OpenSSL、Great Vibes 的许可;OCR 语言数据只随 ElyPDF。 +- 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 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印删去,在 C14.9、C14.10、C14.12、C14.14 原生重做。C14.5 删去 CEF:`crates/ely-web-content`、`apps/ely-mark/web`、`scripts/fetch-cef.sh`、`scripts/build-mark-content.sh`、macOS 包里的辅助应用与其授权、Linux AppImage 的 `libcef` 安排、CEF 与网页依赖的许可、存储设置里的网页缓存、`webview.sh`、`hardened-macos.sh`、`linux-mark-idle.sh`、参考源码 cef、cef-rs、milkdown、codemirror-dev;`apps::open` 不再复原信号(只为 Chromium 屏蔽的 SIGTERM 而设)。旧 C14.1 至 C14.7、R2.13 与 ElyMark AppImage 的记录(CEF 实现)删去,在 git 历史;仍在的应用行为(外部变更与比较、会话、改名、Git 状态、最近与文件夹、`look.json`)在 C14.8 至 C14.16 逐条核对后重记。用户以前的 ElyMark 数据目录里可能留着 `web` 缓存文件夹,Ely 不再读写它。组件库 6f0401e:焦点进入空的可视文档即打开末尾的输入框,新文件可直接键入(原先键入无处落下);362a0fc:换回可视时同样打开(预览与可视共用焦点,无焦点事件),全文撤销后若文档成空,读完新文字再打开末尾的输入框;639521e:只有一份撤销历史,块的输入框自己的撤销与重做从不运行:在打开的块里撤销或重做,先把块写回为一步,再走全文的历史(块内逐键的撤销没有了,一次块编辑是一步),重做跨模式、跨多次撤销都在。ElyMark(C14.6):每个标签各记其模式,切换不改一个字节(编码写出与原文相同),撤销跨模式(全文一份撤销历史);视图在模式首次可键入时记一次 `mark: shown as "…"`(源码即刻,可视与预览在块读完后),PERF-07 计到这条。Windows 上实测(Ryzen 9 9950X3D)1 MB 文档 P50 341 毫秒、P95 560 毫秒(`docs/perf/2026-10-12-Ryzen9-9950X3D.md`);macOS、Linux 与树莓派未测。C14.7:语法由 markdown-rs 读(CommonMark 加 GFM 的表格、任务、删除线、自动链接,脚注,公式),front matter 不进可视,以原文块显示在最上(组件库 parse 测试核对各种块画成内容而非原文);链接照 M-05:预览单击、可视 Cmd(他处 Ctrl)按下交应用,可视普通按下编辑该块(组件库 bbb6071 的链接测试);应用只把 http、https、mailto 交系统打开,其余以提示拒绝(应用测试);链接地址与图片读不出的原因不进日志(地址与路径属文档内容)。文档旁图片按文件所在文件夹读(`MarkView` 在打开与改名时交给组件);组件库 4801555:表格单元格也是块,图片在格内照常画出(原只画替代文字),图片取自身宽度、以所在容器为上限(单元格可收缩,窄格里的图片按比例缩小,组件库 2e196a1)。gpui 的图片加载(`vendor/gpui-pre` 的 `img.rs`):GIF 与 WebP 的坏帧即结束加载并报错(`img.rs` 的加载器与 `platform.rs` 的 `Image::to_image_data`,后者给 `data:` 图片用)(原样跳过坏帧,解码器到文件尾仍不停地报错,加载永不结束、占满一个核),错误与日志不带图片路径(原样把路径写进错误,`AssetLogger` 照写进日志);应用测试以坏 GIF 核对两处加载都结束、错误里没有所在文件夹名。C14.8:左栏三页照旧(文件夹树、大纲、搜索);大纲在纵向滚动区里(标题再多也选得到,侧栏测试以 100 个标题滚到最后),取组件按 markdown-rs 读出的标题与行,选一项即 `reveal`(该行在视图里、插入点在其上;可视模式里键入仍须先按下块);多标签与最近列表经外壳与 `recent.json` 照旧。`desk::open_in` 收数据文件夹(`open` 照旧取本机的),应用的 gpui 测试以临时文件夹起整个窗口:打开文件即有标签、大纲行号、跳转落点与最近列表,键入的标题进大纲,两个文件两个标签各有大纲,打开文件夹只列 Markdown;文件监视在真线程上,测试以 `allow_parking` 允许。`ely_ui::install_no_launch_files`(`test-support`)给测试一个空的启动文件队列。随删去的网页页面一起的 `web.*` 文案(`i18n/web.rs`)删去。C14.9(组件库 ce4a1e9):回车照列表续写(无序、有序加一、任务为未勾),空项回车即结束列表,源码与打开的块都如此,只在 markdown-rs 读作列表项且不在代码或公式里时(围栏里的 `- a` 照常换行;长文读完前源码里回车只换行),新项照所在项的引用前缀、缩进(空格或 Tab)、标记后的空白与任务框写出,父项标记处换成空格(组件库 e162082);没写正文的任务框也算空项,回车即结束列表;Shift+Tab 把行首一个 Tab 当一级退回;在空白行(含只有空格的行)插入表格即以表格取代该行(上一行有字时先空一行),免成缩进代码(组件库 cd16b20);打开的块里 Tab、Shift+Tab 把所触的行缩进或退回四格(`MarkdownDocument > ElyInput` 键位,块外 Tab 照旧在块间移动),源码由编辑器自己缩进;`MarkdownDocument::edit_table` 按插入点所在表格增删行列,`insert_table` 在插入点所在行后放一个三列表格,都为一步撤销,表格按规范写法重写(引用与列表里的表格各行照留其前缀,前缀不一即拒绝;表头之外的多余单元格照留,在其上的列操作拒绝;拒绝的原因经 `TableRefusal` 交给应用提示),预览里一切修改(表格、替换)都拒绝;`show_match` 在源码选中、可视打开其所在块并选中、预览转到其行,`replace` 一步替换(自己的写入计入,已打开的块不被关掉);文字未读完时 `show_match` 先读,长文等读完在下一帧落定(记下所等的文字版本:其间文字一变即作废,新的请求取代等着的);围栏代码按其语言着色(`forms::syntax_fenced`,散文名不着色,无名照通用着色)。ElyMark:Cmd/Ctrl+F 与“编辑›查找和替换”打开查找框(组件库 `FindWidget`,`find_all` 的大小写、全词、正则;正则写错即框内显示原因),源码里标出全部匹配与当前一处;替换一处后转到替换文字之后的下一处(到尾即回到第一处),全部替换为一步撤销;“表格”菜单插入表格与增删行列,插入点不在表格里即提示。应用测试以整窗核对计数、替换与撤销、正则错误、跳到下一处、表格菜单。C14.10(组件库 d126a7e):拖到文档上的文件与剪贴板里只有图片(没有文字)时的粘贴,组件以 `Attachment` 事件交给属主(源码、打开的块与可视无块时都接 Cmd/Ctrl+V),`MarkdownDocument::insert` 在插入点写入文字(打开的块里照键入,预览拒绝)。ElyMark 的 `attach.rs`:只收图片(png、jpg、jpeg、gif、webp、svg、bmp,别的文件提示不收),在后台复制到文件旁的 `assets/`(拖入的保留原名、重名加序号,粘贴的名为 `pasted-毫秒.扩展名`;`ely_files::import_attachment` 与新的 `write_attachment` 同一套:以 `create_new` 占名,从不覆盖,写坏即删去),完成时文件仍在原文件夹才在插入点写 `![名](assets/名)`(名中的控制字符换成空格,方括号与反斜杠转义;地址除字母数字与 `-._~` 外逐字节百分号转义;文件名不是规范文字的图片在复制前即拒绝并说明,链接从不改写文件名),否则提示图片留在哪里;未保存过的文档与预览拒绝并说明。视图的文件夹只在外壳告知已存文件的位置时才有(`MarkView::moved`),图片的读取位置随之;未命名文档没有文件夹。`Opened` 去掉(只剩源),引擎直接载入 `Source`。C14.11(组件库 e89fb32):`MarkdownDocument::reset` 换文字时保留插入点与顶行(按新文字夹住,顶行等新文字读完后落定),干净的标签接过磁盘文字即停在原处,撤销不回到旧文字;外部变更、改名跟随、删除与读错照旧(`outside.rs`;`ely_files::Fingerprint` 加上文件身份,保留修改时间的原子替换也认作变更,各应用的冲突核对同受其益;每次读盘编号,只认最新一次,落定前再核对磁盘仍是所读的那份,否则重看),比较后的合并经 `view.adopt` 为一步撤销。应用测试以整窗核对:干净重载保位与撤销止于磁盘,删除后留着文字并标为已删,改名后标签跟到新名,路径变成文件夹即标为读不出,合并为一步撤销,保留时间的替换照样重载,过期的读取不落定。C14.12(组件库 508fee9):`MarkdownDocument::set_look(DocumentLook)` 是书写视图,只管怎么显示:`measure` 给正文栏的最大宽并居中(三种模式),`font` 给可视与预览的正文字体(代码照用等宽),`scale` 乘以正文与标题的字号(可视、预览经渲染的 `Draw`,打开的块同样,源码经 `CodeEditor::set_scale`),`focus` 让可视里读者所在块之外的块、源码里插入点所在行之外的行淡到 0.35(`CodeEditor::set_focus_line`),`typewriter` 让可视里读者所在块(打开的或插入点所在的)、源码里插入点所在行居中(块、文字、键入、块高或可视区一变即重新居中;两端各留半屏余量,首末块与首末行也能居中:可视放在第一块之上与末尾项之下,源码为列表首尾的空行(行数取正文区高度的一半,绘制、输入法与点按的坐标同一原点);模式切换的定位落定后重新居中;源码在渲染时以严格居中滚动,`CodeEditor::set_typewriter`;居中记录含视图与行高;组件库 f102ea0、cf9cc19、7ed4ea8)。ElyMark 的 `look.json` 照旧(专注、打字机、窄中宽 36/48/64rem、无衬线/衬线/等宽、字号 70% 至 200%),新标签首次报到即取用,菜单一改即作用于全部标签并存下(存不下即提示);衬线字体为 Georgia(Linux 为 DejaVu Serif,未在 Linux 上核对是否装有);专注模式另收起标签条、左栏与状态栏(照旧)。搜索(C14.13,MARK-10)照旧:本文件或所开文件夹,不分大小写逐行找,打开着的文件按当前文字;每条结果另记该行第一处命中的字符列(查询与原文都逐字去掉大小写,词尾 `ς` 同 `σ`,照 Unicode 的大小写折叠;逐字对应回原文,`İ` 等变长的字照样对得上),选中即打开或转到该文件,插入点落在该处。输出(C14.14,MARK-11):“文件›导出…”(⌘E)一个对话框选 HTML、HTML 与资源文件夹、PDF、纯文本,再选位置;HTML 由 ElyMark 按 markdown-rs 的语法树逐个节点写出(`output/html.rs`:GFM 加数学,front matter 不进输出,文档里的 HTML 一律转义成文字,链接只放行无协议、网页与邮件地址,脚注照 GFM 放在页末),公式换成 RaTeX 的轮廓 SVG(组件库 `formula_svg`,黑色),Mermaid 图换成浅色 SVG,经 usvg 把箭头标记与文字都变成路径(组件库 `diagram_svg`;有标签或字找不到字体即报错,不让字悄悄消失),画不出的公式与图照原代码保留,按各自所在的行提示;图片:自包含的 HTML 内嵌为 data URL,资源文件夹版拷进新建的 `名称_files`(以 `create_new` 占名,由文件系统判同名,大小写不分的盘上也不互相覆盖;文件夹已在即拒绝,写坏只删自己建的),网址与 `data:image` 原样保留,读不到的在 HTML 里照原地址、在 PDF 里写成方括号里的替代文字,并都提示;纯文本取自 mdast(块间空行,表格以制表符分格,公式写 TeX)。PDF:同一页面与图片写进标签暂存目录下每次各自的文件夹,`ely-pdf-worker` 的 `HTML_PDF` 以 MuPDF 的 story 排成 A4(边距 2 厘米),MuPDF 读不了的图(WebP、网址)事先列为缺图;story 或绘制有任何警告即整份失败、不写文件(同图片导出);暂存随即删去。打印(⌘P)先写同一份 PDF 再交系统面板(`ely_office::print_later`)。“编辑›复制为富文本”:所选的 Markdown(源码的选区或打开的块的选区,组件库 `MarkdownDocument::selected`;都没有即全文)以同一套生成 HTML(图片内嵌)放进剪贴板,纯文本为 Markdown 本身;缺图、画不出的公式与图同导出一样提示。导出不写打开着的文件(`ely_files::export` 与外壳的持有者检查)。ElyMark 因此依赖 `ely-pdf-engine`,各端的包随带 `ely-pdf-worker` 与 MuPDF、OpenSSL、Great Vibes 的许可;OCR 语言数据只随 ElyPDF。 diff --git a/apps/ely-mark/src/export/mod.rs b/apps/ely-mark/src/export/mod.rs index 2b29bfd..86f01fe 100644 --- a/apps/ely-mark/src/export/mod.rs +++ b/apps/ely-mark/src/export/mod.rs @@ -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); + } } } } diff --git a/apps/ely-mark/src/output/files.rs b/apps/ely-mark/src/output/files.rs index 7c48164..d00c976 100644 --- a/apps/ely-mark/src/output/files.rs +++ b/apps/ely-mark/src/output/files.rs @@ -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 { - 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 { - 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 { - 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(()) -} diff --git a/apps/ely-mark/src/output/html.rs b/apps/ely-mark/src/output/html.rs new file mode 100644 index 0000000..cdc42b1 --- /dev/null +++ b/apps/ely-mark/src/output/html.rs @@ -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)>, + notes: HashMap, + /// Footnotes in the order first called. + called: Vec, +} + +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!("")); + 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!("
    ")), + None => self.push(&format!("<{tag}>")), + } + for item in &list.children { + let Node::ListItem(each) = item else { continue }; + self.push("
  1. "); + if let Some(checked) = each.checked { + let mark = if checked { " checked" } else { "" }; + self.push(&format!(" ")); + } + self.children(item, !list.spread && !each.spread)?; + self.push("
  2. "); + } + self.push(&format!("")); + } + 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!("
    {}
    ", 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!("{}", 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("
    "), + Node::ThematicBreak(_) => self.push("
    "), + 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!("{number}")); + } + // 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(""); + for (at, row) in node.children().into_iter().flatten().enumerate() { + let cell = if at == 0 { "th" } else { "td" }; + self.push(""); + 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!("")); + } + self.push(""); + } + self.push("
    "); + 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!("
    {}
    ", escape(value))); + } + + fn math(&mut self, node: &Node, value: &str, display: bool) { + match (formula_svg(value, display), display) { + (Ok(svg), true) => self.push(&format!("
    {}
    ", bare(&svg))), + (Ok(svg), false) => self.push(&format!("{}", 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!("{}", 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!("", attribute(url))), + false => self.push(""), + } + self.children(node, false)?; + self.push(""); + 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!( + "\"{}\"{title}", + attribute(&src), + attribute(alt) + )); + Ok(()) + } + + fn footnotes(&mut self) -> io::Result<()> { + if self.called.is_empty() { + return Ok(()); + } + self.push("

      "); + // 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!("
    1. ")); + match note { + Some(note) => self.children(¬e, false)?, + None => log::error!("mark: footnote {at} has no definition"), + } + self.push("
    2. "); + } + self.push("
    "); + 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(" 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) +} diff --git a/apps/ely-mark/src/output/mod.rs b/apps/ely-mark/src/output/mod.rs index ecd3cdf..22087c4 100644 --- a/apps/ely-mark/src/output/mod.rs +++ b/apps/ely-mark/src/output/mod.rs @@ -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, /// Lines of diagrams kept as code. @@ -32,11 +27,6 @@ pub struct Page { pub formulas: Vec<(usize, String)>, } -const DISPLAY: &str = "
    ";
    -const INLINE: &str = "";
    -const MERMAID: &str = "
    ";
    -const IMAGE: &str = "
    ", - INLINE => "", - _ => "\"", - }; - 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!("
    {}
    ", bare(&svg))), - Err(_) => { - page.diagrams.push(line(&tree, &source, true)); - None - } - }, - _ => match formula_svg(&source, mark == DISPLAY) { - Ok(svg) if mark == DISPLAY => { - Some(format!("
    {}
    ", bare(&svg))) - } - Ok(svg) => Some(format!("{}", 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!( - "\n{title}\n\n{body}\n" + "\n{}\n\n{body}\n\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(" 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 { - 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('"', """) } diff --git a/apps/ely-mark/src/output/pictures.rs b/apps/ely-mark/src/output/pictures.rs index 7c3832a..11f5438 100644 --- a/apps/ely-mark/src/output/pictures.rs +++ b/apps/ely-mark/src/output/pictures.rs @@ -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 { - 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> { + 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 { + fn carried(&mut self, path: &Path) -> io::Result> { 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 { + 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('<', "<") -} diff --git a/apps/ely-mark/src/output/plain.rs b/apps/ely-mark/src/output/plain.rs index d25d4e5..82ccd11 100644 --- a/apps/ely-mark/src/output/plain.rs +++ b/apps/ely-mark/src/output/plain.rs @@ -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(), } diff --git a/apps/ely-mark/src/output/tests.rs b/apps/ely-mark/src/output/tests.rs index 3ea92c5..d26a5a9 100644 --- a/apps/ely-mark/src/output/tests.rs +++ b/apps/ely-mark/src/output/tests.rs @@ -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(" = 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("
    \\frac{"),
             "{}",
             page.html
         );
    +    assert!(page.html.contains("\\sqrt{"), "{}", page.html);
         assert!(
             page.html
    -            .contains("language-math math-inline\">\\sqrt{"),
    -        "{}",
    -        page.html
    -    );
    -    assert!(
    -        page.html.contains("language-mermaid\">flowchart LR"),
    +            .contains("
    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 [
    +        "

    T

    ", + "
  3. done
  4. ", + "
      ", + "l", + "2", + "gone", + "a", + "r", + "bad", + "1", + "
    1. The note.

    2. ", + ] { + 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("