diff --git a/Cargo.lock b/Cargo.lock index 990a28d..d9874b2 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=7ed4ea832334905a85f8fb6ad4d10df4f04c17c7#7ed4ea832334905a85f8fb6ad4d10df4f04c17c7" +source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=c7a86079fc39c3bfd234ce5b7eca77204f10b7fc#c7a86079fc39c3bfd234ce5b7eca77204f10b7fc" dependencies = [ "alacritty_terminal", "anyhow", @@ -2006,9 +2006,11 @@ name = "ely-mark" version = "0.1.3" dependencies = [ "anyhow", + "base64 0.23.1", "ely-files", "ely-gpui-component", "ely-office", + "ely-pdf-engine", "ely-platform", "ely-ui", "embed-resource", diff --git a/Cargo.toml b/Cargo.toml index 9897d99..4dc948b 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 = "7ed4ea832334905a85f8fb6ad4d10df4f04c17c7" } +ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "c7a86079fc39c3bfd234ce5b7eca77204f10b7fc" } env_logger = "0.11" gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false, features = ["stacker"] } gpui_platform = { package = "gpui-pre-platform", version = "=0.3.8", features = ["font-kit", "wayland", "x11"] } diff --git a/agents.md b/agents.md index 34bea3e..a82d655 100644 --- a/agents.md +++ b/agents.md @@ -16,7 +16,7 @@ | PDF | MuPDF(C 接口),Tesseract OCR,独立工作进程 | | Mail | MailCore 2 + SQLite;正文渲染随其任务另定(不用 CEF 与 Electron) | | Tree | Git CLI,参数数组启动 | -| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析,RaTeX 公式,mermaid-rs-renderer 图 | +| Mark | 组件库 `documents::MarkdownDocument`(GPUI,原文即文本,无序列化);markdown-rs 解析与导出 HTML,RaTeX 公式,mermaid-rs-renderer 图;PDF 经 `ely-pdf-worker` 由 MuPDF 排版 | | Agent | Kigi CLI 的 Rust 代码直接嵌入(不用 ACP),沿用其提供商登录与模型配置 | ## 目录 @@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木 ## 组件映射 -依赖 `ely-gpui-component`(pin 7ed4ea8,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 +依赖 `ely-gpui-component`(pin c7a8607,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 | 需要 | 用 | | --- | --- | @@ -283,4 +283,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 2026-10-11:Linux 包的实测(L1.6)。本机(Omarchy,Hyprland 0.56)装上六个 Arch 包与六个 AppImage,`linux-installed.sh` 两种都过;deb、rpm、Arch 包在 Ubuntu 26.04、Fedora 44、Arch 容器里装上、全部 ELF 找得到库;deb 与 rpm 的六个应用在各自容器的 Xvfb 下画出窗口(`linux-distros.sh`)。截图按窗口(`grim -T` 加 Hyprland 的 `stableId`):按区域截时用户切换工作区或别的窗口盖住即截错。ElyMark 的文件监视略去读取后不会自激:重读前先比指纹(长度与修改时间),不打开文件。 - 2026-10-11(用户定:替换 v0.1.3 的 Linux 包):v0.1.3 发布里原有的六个 AppImage(无头,打不开窗口)已删去,换成本机容器里建出的六个应用各四种包(共 24 个),出自 L1.7 的提交(晚于该标签);上传后逐个核对大小,ElyTree 的 AppImage 下载回来散列相同。`package.sh` 把每个 RUNPATH 按所在 ELF 解析(`$ORIGIN` 或 `${ORIGIN}` 开头,规范化后须在 AppDir 内),否则拒绝(如未修正的 cairo):CI 用的是别处建好的引擎,旧构建会在这里报错而不是带进包里。替换的 Linux 包对应的源码随之上传到同一发布(`source.sh`,文件名带构建提交号),发布说明写明 Linux 包出自哪个提交。 - 2026-10-11:`ci.sh` 用 `ELY_SHARE` 时,CI 克隆里的 `engines`、`reference` 须是指向共享克隆的链接(Windows 为目录联接,不查);是本克隆自建的目录或指向别处即报错,不混用。`.gitignore` 的 `/engines`、`/reference` 不带斜杠,链接也被忽略(`source.sh` 要求工作区干净)。 -- 2026-10-11(用户定:ElyMark 不用 CEF,不用 Electron):ElyMark 改为原生。界面用组件库的 Markdown 组件(`documents::MarkdownEditor` 一族,缺的在组件库补并推到 Gitea),解析用 markdown-rs(`markdown` 1.0.0),公式用 RaTeX,Mermaid 用 mermaid-rs-renderer,PDF 用 MuPDF Story;CEF 随之删去,ElyMail 的正文也不用 CEF 与 Electron。进度在 `tasks/TASKS.md` 的 C14。组件库 fcad0ff 与 508cc55:`MarkdownRenderer`、`MarkdownEditor`、Markdown 着色、块范围与链接查找改读 markdown-rs 的语法树(GitHub 风格加公式),引用式链接与图片按定义解析,图片把所在段落分开,单行 `$$…$$` 为块公式,软换行(LF、CRLF、CR)读作空格,嵌套在引用与列表里的定义同样解析,强调与链接里的图片仍是图片(链接里的图片可按);`BlockEditor` 的导入导出不属 ElyMark,仍用 pulldown-cmark。markdown-rs 读嵌套图片时只留外层的说明。组件库 d5ecc5a:块公式经 `typography::Latex` 由 RaTeX 排出(字形为轮廓,按窗口缩放画),```` ```mermaid ```` 块经 `documents::Diagram` 由 mermaid-rs-renderer 画出(Mermaid 自己的深浅主题,底色与字色取 Ely 色板),都作为 gpui 资源在后台排版,RaTeX 或 Mermaid 对已解析的输入 panic 时显示为错误;`MarkdownRenderer::base` 给出文档所在文件夹,相对与 file 地址的图片从中读取并按自身大小、不超过栏宽显示,`data:` 地址解码,网络图片不载入(写明)。组件库 ab9a14c:每个公式、图与图片元素经 `primitives::held` 持有其资源,按资源计数持有者(8b9820b;画出的图存在共享的记录里,9036c92 起由最后离开的持有者释放,哪怕它自己还没收到图),最后一个持有者换了内容或离开窗口才从 gpui 资源缓存移除、在本帧之后从图集释放,共用同一内容的元素(含 gpui 缓存的视图)不受影响;公式按字形实际范围取画框,字形不被裁;公式失败显示原因、RaTeX 给的位置与原文,日志不含公式;`file:` 地址去掉查询与片段,`localhost` 为本机,Windows 上别的主机为共享路径,他处拒绝;Mermaid 的节点与饼图文字取 Ely 的前景色。组件库 2b6f551:`documents::MarkdownDocument` 是文件级的 Markdown 文档:全文与撤销在一个 `CodeEditor` 里(`CodeEditor::highlighter` 以 `markdown_highlights` 着色,无行号);可视以 gpui `list` 逐块画出文件的顶层块(链接定义、HTML、front matter 照原文显示,每个字节都在块里或块间),按下的块开成源码(`TextInput`),其键入即刻算作文档的文字,写回为一步撤销,上下键越过块边进入相邻块,Esc 写回;Cmd(他处 Ctrl)按下交给链接;预览只读;源码为编辑器本身;128 KiB 以内当场解析,更长的在后台解析,解析中按下块不打开(日志);新解析保留两端相同的块,滚动不跳。`CodeEditor` 的撤销历史最多留 64 MiB 文字,超出先丢最旧的步;`CodeEditor::reset` 换文字且撤销重新开始;`MarkdownMode` 加 `Preview`。组件库 fc181b4:一个插入点通用于各模式(编辑器的选区,写回的块移动它);`set_caret` 与 `set_top_line` 分别设插入点与视图,换模式保持顶行;按下的块在前一块写回后仍是所按的块(按偏移换算,结构变了即不开并记日志);块是 Tab 停靠点,Enter 或空格打开;预览不收按下、撤销与重做;撤销与重做每次记录、输入法组字、撤销、重做后都限在 64 MiB 内(先丢最旧的撤销,再丢最远的重做)。组件库 433c38f:只有对当下文字的解析才能打开块(解析的文字与编辑器相同),否则拒绝;要求的顶行在其文字解析后才落定:先到所在块,下一帧按块高的比例到块内位置,再有新的要求即接着放下一个;源码与列表之间切换保持顶行,可视与预览共用一个列表,切换不动滚动。组件库 abac8b4:顶行的要求带编号,迟到的帧回调只完成自己的那个;文字一换即按新文字重新放,放时按当下文字的行数夹住;`reveal` 是同样的要求(插入点设在该行,该行放到顶上),新文字解析后才落定。组件库 f59c9f7:文字超过读取上限(2 MiB)即丢掉旧的读取,大纲与块为空。ElyMark(C14.4):视图即一个 `MarkdownDocument`,文本即文件原文(BOM 与 CRLF 照 `text.rs` 存回),新建、打开、保存、另存、重命名经外壳照旧;视图在建成后的下一轮更新向外壳报一次载入(文档在 `new` 里当场解析,外壳此时还未订阅);文字超过 2 MiB 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印删去,在 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 的大小写折叠;逐字对应回原文,`İ` 等变长的字照样对得上),选中即打开或转到该文件,插入点落在该处。 +- 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。 diff --git a/apps/ely-mark/Cargo.toml b/apps/ely-mark/Cargo.toml index fd1fa1b..94a62b6 100644 --- a/apps/ely-mark/Cargo.toml +++ b/apps/ely-mark/Cargo.toml @@ -12,14 +12,17 @@ embed-resource.workspace = true [dependencies] anyhow.workspace = true +base64.workspace = true ely-files.workspace = true ely-gpui-component.workspace = true ely-office.workspace = true +ely-pdf-engine.workspace = true ely-platform.workspace = true ely-ui.workspace = true gpui.workspace = true log.workspace = true futures.workspace = true +markdown = "=1.0.0" jiff.workspace = true notify.workspace = true serde.workspace = true @@ -29,7 +32,6 @@ serde_json.workspace = true workspace = true [dev-dependencies] -markdown = "=1.0.0" ely-gpui-component = { workspace = true, features = ["test-support"] } ely-ui = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } diff --git a/apps/ely-mark/src/desk.rs b/apps/ely-mark/src/desk.rs index 4b23776..13d0e53 100644 --- a/apps/ely-mark/src/desk.rs +++ b/apps/ely-mark/src/desk.rs @@ -30,6 +30,8 @@ pub struct Desk { pub core: Core, /// The tab being renamed and its name field. pub naming: Option<(u64, Entity)>, + /// The export dialog's tab and format. + pub exporting: Option<(u64, crate::output::Format)>, pub folder: Option, /// Folder listings so far; searches redo on a new one. pub listings: u64, @@ -186,6 +188,7 @@ pub(crate) fn open_in( Desk { core: Core::new(Arc::new(Mutex::new(())), work, cx), naming: None, + exporting: None, folder: None, listings: 0, recent, @@ -440,8 +443,9 @@ impl Render for Desk { .children(self.core_dialog(cx)) .children(self.naming_element(cx)) .children(self.compare_dialog(cx)) - .children(self.find_box(cx)); - Self::table_actions(Self::look_actions(root, cx), cx) + .children(self.find_box(cx)) + .children(self.export_dialog(cx)); + Self::output_actions(Self::table_actions(Self::look_actions(root, cx), cx), cx) } } diff --git a/apps/ely-mark/src/export/mod.rs b/apps/ely-mark/src/export/mod.rs new file mode 100644 index 0000000..2b29bfd --- /dev/null +++ b/apps/ely-mark/src/export/mod.rs @@ -0,0 +1,309 @@ +//! Export, print and rich copy (MARK-11). + +use std::path::PathBuf; + +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, SegmentedControl}, + overlays::Dialog, + primitives::Severity, + theme::ControlSize, +}; +use ely_office::prelude::*; +use ely_platform::clipboard::{self, Clip}; +use ely_ui::{ + commands::{Export, Print}, + i18n::text, +}; +use gpui::{ + AnyElement, Context, Div, InteractiveElement, IntoElement, ParentElement as _, TaskExt as _, + actions, +}; + +use crate::desk::Desk; +use crate::output::{self, Format, Page, Pictures, Target}; + +actions!(mark, [CopyRich]); + +const FORMATS: [(Format, &str, &str); 4] = [ + (Format::Page, "page", "html"), + (Format::Linked, "linked", "html"), + (Format::Pdf, "pdf", "pdf"), + (Format::Text, "text", "txt"), +]; + +fn key(format: Format) -> &'static str { + FORMATS + .iter() + .find(|(each, ..)| *each == format) + .expect("listed") + .1 +} + +impl Desk { + pub fn output_actions(root: Div, cx: &mut Context) -> Div { + root.on_action(cx.listener(|desk, _: &Export, _, cx| desk.ask_export(cx))) + .on_action(cx.listener(|desk, _: &Print, _, cx| desk.print(cx))) + .on_action(cx.listener(|desk, _: &CopyRich, _, cx| desk.copy_rich(cx))) + } + + fn no_document(&self, cx: &mut Context) { + let said = text(cx, "command.blocked.no_document", &[]); + self.toast(said, Severity::Warning, cx); + } + + fn ask_export(&mut self, cx: &mut Context) { + let Some(tab) = self.core.active else { + return self.no_document(cx); + }; + self.exporting = Some((tab, Format::Page)); + cx.notify(); + } + + pub fn export_dialog(&self, cx: &mut Context) -> Option { + let (_, format) = self.exporting?; + let (picking, going, closing) = (cx.entity(), cx.entity(), cx.entity()); + let formats = FORMATS + .iter() + .fold( + SegmentedControl::new("mark-export-format", key(format)), + |control, (_, value, _)| { + control.segment(*value, text(cx, &format!("mark.export.{value}"), &[]), None) + }, + ) + .size(ControlSize::Sm) + .on_change(move |value, _, cx| { + let Some((chosen, ..)) = + FORMATS.iter().find(|(_, each, _)| *each == value.as_ref()) + else { + return; + }; + let chosen = *chosen; + picking.update(cx, |desk, cx| { + if let Some((_, format)) = &mut desk.exporting { + *format = chosen; + } + cx.notify(); + }) + }); + let go = text(cx, "mark.export.go", &[]); + let card = Dialog::new("mark-export", text(cx, "mark.export", &[]), move |_, cx| { + closing.update(cx, |desk, cx| { + desk.exporting = None; + cx.notify(); + }) + }) + .child(formats) + .action(ely_office::cancel(cx)) + .action(move |_| { + Button::new("mark-export-go", go) + .variant(ButtonVariant::Primary) + .on_click(move |_, _, cx| going.update(cx, |desk, cx| desk.export_asked(cx))) + }); + Some(card.into_any_element()) + } + + /// Asks where, then writes the chosen format. + fn export_asked(&mut self, cx: &mut Context) { + let Some((tab, format)) = self.exporting.take() else { + return; + }; + cx.notify(); + let Some(source) = self.tab(tab) else { + return; + }; + let Some(dir) = ely_office::folder(source) else { + return self.export_failed("no documents folder", cx); + }; + let stem = source + .name + .rsplit_once('.') + .map_or(source.name.as_ref(), |(stem, _)| stem); + let extension = FORMATS + .iter() + .find(|(each, ..)| *each == format) + .expect("listed") + .2; + let chosen = cx.prompt_for_new_path(&dir, Some(&format!("{stem}.{extension}"))); + cx.spawn(async move |desk, cx| { + let chosen = match chosen.await { + Ok(Ok(path)) => Ok(path), + Ok(Err(error)) => Err(error.to_string()), + Err(error) => Err(error.to_string()), + }; + desk.update(cx, |desk, cx| match chosen { + Ok(Some(path)) => desk.export_to(tab, path, format, cx), + Ok(None) => log::info!("mark: the export panel was cancelled"), + Err(error) => desk.export_failed(&error, cx), + }) + }) + .detach_and_log_err(cx); + } + + pub fn export_to(&mut self, tab: u64, path: PathBuf, format: Format, cx: &mut Context) { + if !self.settled(tab, cx) { + return self.when_settled(tab, move |desk, _, cx| { + desk.export_to(tab, path, format, cx) + }); + } + let (Some(source), Some(markdown)) = (self.tab(tab), self.text_of(tab)) else { + return; + }; + match self.holder(&path) { + Ok(None) => {} + Ok(Some(_)) => return self.export_failed("an open document is there", cx), + Err(error) => return self.export_failed(&error.to_string(), cx), + } + let session = source.session.path().map(PathBuf::from); + let beside = session + .as_deref() + .and_then(|path| path.parent()) + .map(PathBuf::from); + let (revision, work) = (source.session.revision(), source.work.clone()); + let opened = path.clone(); + cx.spawn(async move |desk, cx| { + let written = cx + .background_executor() + .spawn(async move { + let target = Target { + path: &path, + session: session.as_deref(), + revision, + work: &work, + }; + output::write(format, &markdown, beside.as_deref(), &target) + }) + .await; + desk.update(cx, |desk, cx| match written { + Ok(page) => { + let name = ely_office::files::file_name(&opened); + desk.toast_output(text(cx, "mark.export.done", &[("name", &name)]), opened, cx); + desk.left_out(&page, cx); + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let said = text( + cx, + "mark.export.folder_taken", + &[("name", &error.to_string())], + ); + desk.toast(said, Severity::Danger, cx); + } + Err(error) => desk.export_failed(&error.to_string(), cx), + }) + }) + .detach_and_log_err(cx); + } + + /// Says what an export could not carry. + fn left_out(&self, page: &Page, cx: &mut Context) { + // Names and errors quote the document: not logged. + if !page.missing.is_empty() { + let said = text( + cx, + "mark.export.missing", + &[("list", &page.missing.join(", "))], + ); + self.toast_unlogged(said, Severity::Warning, cx); + } + if !page.diagrams.is_empty() { + let lines: Vec = page.diagrams.iter().map(usize::to_string).collect(); + let said = text(cx, "mark.export.diagrams", &[("list", &lines.join(", "))]); + self.toast(said, Severity::Warning, cx); + } + if !page.formulas.is_empty() { + let each = page.formulas.iter().map(|(line, error)| { + text( + cx, + "mark.export.formula", + &[("line", &line.to_string()), ("error", error)], + ) + .to_string() + }); + let said = text( + cx, + "mark.export.formulas", + &[("list", &each.collect::>().join("; "))], + ); + self.toast_unlogged(said, Severity::Warning, cx); + } + } + + fn export_failed(&self, error: &str, cx: &mut Context) { + let said = text(cx, "mark.export.failed", &[("error", error)]); + self.toast(said, Severity::Danger, cx); + } + + /// Prints the PDF export through the system panel. + fn print(&mut self, cx: &mut Context) { + let Some(tab) = self.core.active else { + return self.no_document(cx); + }; + if !self.settled(tab, cx) { + return self.when_settled(tab, |desk, _, cx| desk.print(cx)); + } + let (Some(source), Some(markdown)) = (self.tab(tab), self.text_of(tab)) else { + return; + }; + let beside = source + .session + .path() + .and_then(|path| path.parent()) + .map(PathBuf::from); + let (revision, work) = (source.session.revision(), source.work.clone()); + let pdf = work.join("print.pdf"); + log::info!("mark: prints tab {tab}"); + cx.spawn(async move |desk, cx| { + let written = cx + .background_executor() + .spawn(async move { + let target = Target { + path: &pdf, + session: None, + revision, + work: &work, + }; + output::write(Format::Pdf, &markdown, beside.as_deref(), &target) + .map(|page| (page, pdf)) + }) + .await; + desk.update(cx, |desk, cx| match written { + Ok((page, pdf)) => { + desk.left_out(&page, cx); + ely_office::print_later(pdf, cx); + } + Err(error) => desk.print_failed(&error.to_string(), cx), + }) + }) + .detach_and_log_err(cx); + } + + /// The selection, or the whole text, as HTML. + fn copy_rich(&mut self, cx: &mut Context) { + let Some(view) = self.active_view() else { + return self.no_document(cx); + }; + let document = view.read(cx).document().clone(); + let selected = document.read(cx).selected(cx); + let markdown = match selected.is_empty() { + true => document.read(cx).text(cx), + false => selected, + }; + let beside = self + .active_tab() + .and_then(|tab| tab.session.path()) + .and_then(|path| path.parent()); + let copied = output::body(&markdown, beside, &Pictures::Inline).and_then(|page| { + let clip = Clip::Html { + html: page.html, + text: markdown, + }; + clipboard::write(&clip).map_err(|error| std::io::Error::other(format!("{error:#}"))) + }); + if let Err(error) = copied { + let said = text(cx, "mark.copy.failed", &[("error", &error.to_string())]); + self.toast(said, Severity::Danger, cx); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/ely-mark/src/export/tests.rs b/apps/ely-mark/src/export/tests.rs new file mode 100644 index 0000000..f6ad0bb --- /dev/null +++ b/apps/ely-mark/src/export/tests.rs @@ -0,0 +1,88 @@ +use gpui::TestAppContext; + +use crate::desk::tests::{open, start}; +use crate::output::Format; + +#[gpui::test] +fn export_asks_for_a_format_then_writes_it(cx: &mut TestAppContext) { + let (data, files) = ( + tempfile::tempdir().expect("data"), + tempfile::tempdir().expect("files"), + ); + let path = files.path().join("note.md"); + std::fs::write(&path, "# Note\n\nText $x$.\n").expect("a file"); + let (desk, cx) = start(cx, data.path()); + open(&desk, &path, cx); + cx.dispatch_action(ely_ui::commands::Export); + cx.run_until_parked(); + let (tab, format) = desk.read_with(cx, |desk, _| desk.exporting.expect("the dialog")); + assert_eq!(format, Format::Page); + let out = files.path().join("note.html"); + desk.update(cx, |desk, cx| { + desk.exporting = None; + desk.export_to(tab, out.clone(), Format::Page, cx) + }); + cx.run_until_parked(); + let html = std::fs::read_to_string(&out).expect("written"); + assert!( + html.contains("

Note

") && html.contains("Bold") && !html.contains("Note"), + "{html}" + ); +} diff --git a/apps/ely-mark/src/keys.rs b/apps/ely-mark/src/keys.rs index 43329f6..a4ed164 100644 --- a/apps/ely-mark/src/keys.rs +++ b/apps/ely-mark/src/keys.rs @@ -40,8 +40,10 @@ pub fn menus(look: &crate::look::Look, cx: &App) -> Vec { ColumnLeft, ColumnRight, DeleteColumn, DeleteRow, InsertTable, RowAbove, RowBelow, }; vec![ - Menu::new(text(cx, "menu.edit", &[])) - .items([item("office.find.open", Box::new(ely_office::FindReplace))]), + Menu::new(text(cx, "menu.edit", &[])).items([ + item("office.find.open", Box::new(ely_office::FindReplace)), + item("mark.copy.rich", Box::new(crate::export::CopyRich)), + ]), Menu::new(text(cx, "mark.menu.table", &[])).items([ item("mark.table.insert", Box::new(InsertTable)), MenuItem::separator(), diff --git a/apps/ely-mark/src/main.rs b/apps/ely-mark/src/main.rs index 98cb866..f998534 100644 --- a/apps/ely-mark/src/main.rs +++ b/apps/ely-mark/src/main.rs @@ -6,10 +6,12 @@ mod autosave; mod compare; mod desk; mod engine; +mod export; mod finding; mod folder; mod keys; mod look; +mod output; mod outside; mod places; mod rename; diff --git a/apps/ely-mark/src/output/files.rs b/apps/ely-mark/src/output/files.rs new file mode 100644 index 0000000..7c48164 --- /dev/null +++ b/apps/ely-mark/src/output/files.rs @@ -0,0 +1,115 @@ +//! An export written to disk. + +use std::{ + io, + path::{Path, PathBuf}, +}; + +use super::{Page, Pictures, body, document, plain}; + +/// What an export writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + /// One HTML file, pictures inside. + Page, + /// HTML and a folder of its pictures. + Linked, + Pdf, + Text, +} + +/// Where one export goes, and what it leaves alone. +pub struct Target<'a> { + pub path: &'a Path, + /// The tab's own file, never written over. + pub session: Option<&'a Path>, + pub revision: u64, + /// A scratch folder of the tab's. + pub work: &'a Path, +} + +/// `text` written as `format`; what it left out. +pub fn write( + format: Format, + text: &str, + beside: Option<&Path>, + target: &Target, +) -> io::Result { + let title = target + .path + .file_stem() + .map_or_else(String::new, |stem| stem.to_string_lossy().into_owned()); + let page = match format { + Format::Page => { + let page = body(text, beside, &Pictures::Inline)?; + export(target, document(&title, &page.html).as_bytes())?; + page + } + Format::Linked => linked(text, beside, target, &title)?, + Format::Pdf => pdf(text, beside, target, &title)?, + Format::Text => { + export(target, plain(text)?.as_bytes())?; + Page::default() + } + }; + log::info!("mark: exported {format:?}, revision {}", target.revision); + Ok(page) +} + +fn export(target: &Target, bytes: &[u8]) -> io::Result<()> { + ely_files::export(target.path, target.session, bytes, target.revision).map(|_| ()) +} + +/// The page beside a new folder of its pictures. +fn linked(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::Result { + let name = format!("{title}_files"); + let page = body(text, beside, &Pictures::Folder(name.clone()))?; + if page.copies.is_empty() { + export(target, document(title, &page.html).as_bytes())?; + return Ok(page); + } + let folder = target.path.with_file_name(&name); + // A folder already there is someone's: refused. + std::fs::create_dir(&folder).map_err(|error| match error.kind() { + io::ErrorKind::AlreadyExists => io::Error::new(error.kind(), folder.display().to_string()), + _ => error, + })?; + let written = copy(&page.copies, &folder) + .and_then(|()| export(target, document(title, &page.html).as_bytes())); + if let Err(error) = written { + if let Err(left) = std::fs::remove_dir_all(&folder) { + log::error!("mark: the half-made picture folder stays: {left}"); + } + return Err(error); + } + Ok(page) +} + +/// The page set by MuPDF, from a scratch folder. +fn pdf(text: &str, beside: Option<&Path>, target: &Target, title: &str) -> io::Result { + let dir = target.work.join("pdf"); + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + } + std::fs::create_dir_all(dir.join("files"))?; + let set = (|| -> io::Result { + let page = body(text, beside, &Pictures::Pdf("files".into()))?; + copy(&page.copies, &dir.join("files"))?; + std::fs::write(dir.join("page.html"), document(title, &page.html))?; + let out = dir.join("out.pdf"); + ely_pdf_engine::Worker::spawn()?.html_pdf(&dir, "page.html", &out)?; + export(target, &std::fs::read(&out)?)?; + Ok(page) + })(); + let cleaned = std::fs::remove_dir_all(&dir); + let page = set?; + cleaned?; + Ok(page) +} + +fn copy(copies: &[(PathBuf, String)], folder: &Path) -> io::Result<()> { + for (from, name) in copies { + std::fs::copy(from, folder.join(name))?; + } + Ok(()) +} diff --git a/apps/ely-mark/src/output/mod.rs b/apps/ely-mark/src/output/mod.rs new file mode 100644 index 0000000..ecd3cdf --- /dev/null +++ b/apps/ely-mark/src/output/mod.rs @@ -0,0 +1,172 @@ +//! Markdown as an HTML page or plain text (MARK-11). + +mod files; +mod pictures; +mod plain; +#[cfg(test)] +mod tests; + +use std::{io, path::Path}; + +use ely_gpui_component::{ + documents::{diagram_svg, markdown_options}, + typography::formula_svg, +}; +use markdown::{CompileOptions, Options, mdast::Node}; + +pub use files::{Format, Target, write}; +pub use pictures::Pictures; +pub use plain::plain; + +/// A page and what it could not carry. +#[derive(Debug, Default)] +pub struct Page { + pub html: String, + /// Pictures to copy beside the page: from, then name. + pub copies: Vec<(std::path::PathBuf, String)>, + /// Pictures left out, as the document wrote them. + pub missing: Vec, + /// Lines of diagrams kept as code. + pub diagrams: Vec, + /// Lines of formulas kept as code, and why. + pub formulas: Vec<(usize, String)>, +} + +const DISPLAY: &str = "
";
+const INLINE: &str = "";
+const MERMAID: &str = "
";
+const IMAGE: &str = ", pictures: &Pictures) -> io::Result {
+    let options = Options {
+        parse: markdown_options(),
+        compile: CompileOptions::gfm(),
+    };
+    let html = markdown::to_html_with_options(text, &options)
+        .map_err(|error| io::Error::other(error.to_string()))?;
+    let tree = markdown::to_mdast(text, &options.parse)
+        .map_err(|error| io::Error::other(error.to_string()))?;
+    let mut page = Page::default();
+    let mut placed = pictures::Placed::new(beside, pictures);
+    let mut rest = html.as_str();
+    while let Some((at, mark)) = [DISPLAY, INLINE, MERMAID, IMAGE]
+        .iter()
+        .filter_map(|mark| rest.find(mark).map(|at| (at, *mark)))
+        .min()
+    {
+        page.html.push_str(&rest[..at]);
+        rest = &rest[at + mark.len()..];
+        let close = match mark {
+            DISPLAY | MERMAID => "
", + 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; + log::info!( + "mark: a page of {} bytes, {} copies, {} missing, {} diagrams and {} formulas as code", + page.html.len(), + page.copies.len(), + page.missing.len(), + page.diagrams.len(), + page.formulas.len() + ); + Ok(page) +} + +/// A whole HTML document around `body`. +pub fn document(title: &str, body: &str) -> String { + let title = title.replace('&', "&").replace('<', "<"); + format!( + "\n{title}\n\n{body}\n" + ) +} + +/// markdown-rs's four escapes undone. +fn unescape(text: &str) -> String { + text.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("&", "&") +} + +/// An SVG without its XML declaration. +fn bare(svg: &str) -> &str { + svg.find(" 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)) +} diff --git a/apps/ely-mark/src/output/pictures.rs b/apps/ely-mark/src/output/pictures.rs new file mode 100644 index 0000000..7c3832a --- /dev/null +++ b/apps/ely-mark/src/output/pictures.rs @@ -0,0 +1,181 @@ +//! Where an exported page's pictures go. + +use std::path::{Path, PathBuf}; + +use base64::Engine as _; + +/// Where pictures go. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Pictures { + /// Inside the page, as data. + Inline, + /// Copied into the named folder beside the page. + Folder(String), + /// As a folder, in formats MuPDF reads. + Pdf(String), +} + +/// Picture formats MuPDF reads from files. +const READABLE: [&str; 8] = ["png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff", "svg"]; + +pub(super) struct Placed<'a> { + beside: Option<&'a Path>, + pictures: &'a Pictures, + pub copies: Vec<(PathBuf, String)>, +} + +impl<'a> Placed<'a> { + pub fn new(beside: Option<&'a Path>, pictures: &'a Pictures) -> Self { + Self { + beside, + pictures, + copies: Vec::new(), + } + } + + /// The `src` a picture takes; else it is missing. + pub fn place(&mut self, src: &str, after: &str, missing: &mut Vec) -> String { + let address = decoded(src); + // markdown-rs blanks addresses it will not allow. + if address.is_empty() { + missing.push(alt(after)); + return String::new(); + } + let web = address.starts_with("http://") || address.starts_with("https://"); + if web || address.starts_with("data:") { + if web && matches!(self.pictures, Pictures::Pdf(_)) { + missing.push(address); + return String::new(); + } + return attribute(&address); + } + let path = match (Path::new(&address).is_absolute(), self.beside) { + (true, _) => PathBuf::from(&address), + (false, Some(beside)) => beside.join(&address), + (false, None) => { + missing.push(address); + return String::new(); + } + }; + match self.carried(&path) { + Some(src) => src, + None => { + missing.push(address); + String::new() + } + } + } + + fn carried(&mut self, path: &Path) -> Option { + let extension = path + .extension() + .map(|extension| extension.to_string_lossy().to_lowercase()) + .unwrap_or_default(); + let folder = match self.pictures { + Pictures::Inline => { + let bytes = std::fs::read(path).ok()?; + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + return Some(format!("data:{};base64,{data}", mime(&extension))); + } + Pictures::Pdf(_) if !READABLE.contains(&extension.as_str()) => return None, + Pictures::Folder(folder) | Pictures::Pdf(folder) => folder.clone(), + }; + if !path.is_file() { + return None; + } + let name = self.named(path); + Some(format!("{folder}/{}", encoded(&name))) + } + + /// A name in the folder, one per picture. + fn named(&mut self, path: &Path) -> String { + if let Some((_, name)) = self.copies.iter().find(|(from, _)| from == path) { + return name.clone(); + } + let file = path + .file_name() + .map_or_else(String::new, |name| name.to_string_lossy().into_owned()); + let (stem, extension) = file + .rsplit_once('.') + .map_or((file.as_str(), ""), |(stem, ext)| (stem, ext)); + let taken = |name: &str| self.copies.iter().any(|(_, kept)| kept == name); + let mut name = file.clone(); + let mut count = 1; + while taken(&name) { + count += 1; + name = match extension { + "" => format!("{stem}-{count}"), + _ => format!("{stem}-{count}.{extension}"), + }; + } + self.copies.push((path.to_path_buf(), name.clone())); + name + } +} + +fn mime(extension: &str) -> &'static str { + match extension { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "svg" => "image/svg+xml", + "webp" => "image/webp", + "bmp" => "image/bmp", + "tif" | "tiff" => "image/tiff", + "avif" => "image/avif", + _ => "application/octet-stream", + } +} + +/// The `alt` after a blanked `src`. +fn alt(after: &str) -> String { + let text = after.strip_prefix(" alt=\"").unwrap_or_default(); + super::unescape(&text[..text.find('"').unwrap_or(text.len())]) +} + +/// `%XX` undone; bytes that are not UTF-8 stay as written. +fn decoded(src: &str) -> String { + let src = super::unescape(src); + let bytes = src.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut at = 0; + while at < bytes.len() { + let hex = bytes + .get(at + 1..at + 3) + .and_then(|pair| std::str::from_utf8(pair).ok()); + match ( + bytes[at], + hex.and_then(|pair| u8::from_str_radix(pair, 16).ok()), + ) { + (b'%', Some(byte)) => { + out.push(byte); + at += 3; + } + (byte, _) => { + out.push(byte); + at += 1; + } + } + } + String::from_utf8(out).unwrap_or(src) +} + +/// A name as a path segment of a URL. +fn encoded(name: &str) -> String { + name.bytes() + .map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + (byte as char).to_string() + } + _ => format!("%{byte:02X}"), + }) + .collect() +} + +/// A value safe inside a quoted attribute. +fn attribute(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") +} diff --git a/apps/ely-mark/src/output/plain.rs b/apps/ely-mark/src/output/plain.rs new file mode 100644 index 0000000..d25d4e5 --- /dev/null +++ b/apps/ely-mark/src/output/plain.rs @@ -0,0 +1,62 @@ +//! Markdown as plain text: words, code and TeX, no marks. + +use std::io; + +use ely_gpui_component::documents::markdown_options; +use markdown::mdast::Node; + +/// `text` as plain text, a blank line between blocks. +pub fn plain(text: &str) -> io::Result { + let tree = markdown::to_mdast(text, &markdown_options()) + .map_err(|error| io::Error::other(error.to_string()))?; + let mut blocks = Vec::new(); + for child in tree.children().into_iter().flatten() { + block(child, &mut blocks); + } + Ok(blocks.join("\n\n") + "\n") +} + +fn block(node: &Node, blocks: &mut Vec) { + match node { + Node::Code(code) => blocks.push(code.value.clone()), + Node::Math(math) => blocks.push(math.value.clone()), + Node::List(list) => { + let items = list.children.iter().map(|item| { + let mut inner = Vec::new(); + for child in item.children().into_iter().flatten() { + block(child, &mut inner); + } + inner.join("\n") + }); + blocks.push(items.collect::>().join("\n")); + } + Node::Table(table) => { + let rows = table.children.iter().map(|row| { + let cells = row.children().into_iter().flatten().map(words); + cells.collect::>().join("\t") + }); + blocks.push(rows.collect::>().join("\n")); + } + Node::Blockquote(_) | Node::FootnoteDefinition(_) => { + for child in node.children().into_iter().flatten() { + block(child, blocks); + } + } + Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(node)), + // Front matter, HTML, rules and definitions are not words. + _ => {} + } +} + +/// The words of an inline run. +fn words(node: &Node) -> String { + match node { + Node::Text(text) => text.value.clone(), + Node::InlineCode(code) => code.value.clone(), + Node::InlineMath(math) => math.value.clone(), + Node::Break(_) => "\n".into(), + Node::Image(image) => image.alt.clone(), + Node::Html(_) => String::new(), + _ => node.children().into_iter().flatten().map(words).collect(), + } +} diff --git a/apps/ely-mark/src/output/tests.rs b/apps/ely-mark/src/output/tests.rs new file mode 100644 index 0000000..3ea92c5 --- /dev/null +++ b/apps/ely-mark/src/output/tests.rs @@ -0,0 +1,292 @@ +use std::path::Path; + +use super::{Pictures, body, document, plain}; + +fn page(text: &str, beside: Option<&Path>, pictures: &Pictures) -> super::Page { + body(text, beside, pictures).expect("a page") +} + +#[test] +fn formulas_and_diagrams_draw_as_svg() { + let text = + "Inline $x^2$ here.\n\n$$\n\\sqrt{y}\n$$\n\n```mermaid\nflowchart LR\n A --> B\n```\n"; + let page = page(text, None, &Pictures::Inline); + assert!(!page.html.contains("language-math"), "{}", page.html); + assert!(!page.html.contains("language-mermaid"), "{}", page.html); + assert_eq!(page.html.matches(" = page.formulas.iter().map(|(line, _)| *line).collect(); + assert_eq!(lines, [3, 7]); + assert!(page.formulas.iter().all(|(_, why)| !why.is_empty())); + assert_eq!(page.diagrams, [9]); + assert!( + page.html.contains("language-math math-display\">\\frac{"), + "{}", + page.html + ); + assert!( + page.html + .contains("language-math math-inline\">\\sqrt{
"), + "{}", + page.html + ); + assert!( + page.html.contains("language-mermaid\">flowchart LR"), + "{}", + page.html + ); +} + +#[test] +fn an_undrawn_formula_after_a_drawn_one_keeps_its_code() { + let page = page("$$\nx\n$$\n$$\n\\frac{\n$$\n", None, &Pictures::Inline); + assert_eq!(page.formulas.len(), 1); + assert!(page.html.contains("\\frac{"), "{}", page.html); +} + +#[test] +fn front_matter_and_raw_html_do_not_go_live() { + let page = page( + "---\ntitle: Secret\n---\n\n\n\nText.\n", + None, + &Pictures::Inline, + ); + assert!(!page.html.contains("Secret"), "{}", page.html); + assert!(!page.html.contains("