From 256240c365b946f3e99d62c8a9ff5414a67123c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 09:11:47 -0400 Subject: [PATCH] R4.4: export and copy read as the visual mode reads HTML export, plain text and rich copy read each paragraph, heading, cell and HTML block through the component's MarkdownReader (pin 1251221). Emoji, alerts, the TOC and inline tags match visual mode. Footnotes sit in place as [n]; link titles are not written. Three review rounds failed; round 3's one finding (a scan per footnote over open marks) is fixed with a growth regression, unreviewed. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 2 +- Cargo.toml | 2 +- agents.md | 6 +- apps/ely-mark/src/export/tests.rs | 2 +- apps/ely-mark/src/output/address.rs | 26 ++ apps/ely-mark/src/output/html.rs | 514 ++++++++++++++++------- apps/ely-mark/src/output/mod.rs | 3 +- apps/ely-mark/src/output/pictures.rs | 2 +- apps/ely-mark/src/output/plain.rs | 78 +++- apps/ely-mark/src/output/tests.rs | 18 +- apps/ely-mark/src/output/tests/visual.rs | 232 ++++++++++ scripts/fetch-references.sh | 2 +- tasks/TASKS.md | 2 +- tasks/reviews/R4.4-r1.md | 13 + tasks/reviews/R4.4-r2.md | 15 + tasks/reviews/R4.4-r3.md | 3 + 16 files changed, 728 insertions(+), 192 deletions(-) create mode 100644 apps/ely-mark/src/output/address.rs create mode 100644 apps/ely-mark/src/output/tests/visual.rs create mode 100644 tasks/reviews/R4.4-r1.md create mode 100644 tasks/reviews/R4.4-r2.md create mode 100644 tasks/reviews/R4.4-r3.md diff --git a/Cargo.lock b/Cargo.lock index 5c7c11c..a69d3c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1923,7 +1923,7 @@ dependencies = [ [[package]] name = "ely-gpui-component" version = "0.1.1" -source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=9ff403df31894b07904cfd197a074d1ff95e6de1#9ff403df31894b07904cfd197a074d1ff95e6de1" +source = "git+https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components?rev=12512217bd9bc652ed6bbf10461b00cc26c01c21#12512217bd9bc652ed6bbf10461b00cc26c01c21" dependencies = [ "alacritty_terminal", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index c189685..5b5dd93 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 = "9ff403df31894b07904cfd197a074d1ff95e6de1" } +ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "12512217bd9bc652ed6bbf10461b00cc26c01c21" } 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 af6bc0d..ce5721b 100644 --- a/agents.md +++ b/agents.md @@ -40,7 +40,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木 ## 组件映射 -依赖 `ely-gpui-component`(pin 9ff403d,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 1251221,gpui 为 crates.io 的 `gpui-pre` =0.3.8)。名字取自 `reference/Ely-GPUI-Components/src`。gpui 用法规则见该仓库 `AGENTS.md`。`tests/scripts/agents_md.py` 核对下表每个名字都是组件库公开项。 | 需要 | 用 | | --- | --- | @@ -55,7 +55,7 @@ site/ 官网(Vite 8、pnpm、TypeScript,无框架;等轴 SVG 积木 | Table | `tables::{PivotTable, FilterBuilder, SortBuilder}` `misc::{CsvImporter, ExportDialog}` `editor::{CompletionMenu, SignatureHelp, FindWidget}` `forms::{CheckboxGroup, ColorPalette}` | | Mail | `mail::{MailboxList, MailList, MailThreadView, MailReader, MailComposer, SignatureEditor, ScheduleSend}` | | Tree | `git::{ChangesList, CommitInput, CommitList, lanes, DiffViewer, ConflictResolver, BlameView, BranchList, TagList, StashList, FileHistory}` | -| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode}` `editor::{CodeEditor, FindWidget}` | +| Mark | `documents::{MarkdownDocument, MarkdownRenderer, ZenMode, MarkdownReader, MarkdownBlock, alert, is_toc}` `editor::{CodeEditor, FindWidget}`;导出与复制调用可视同一组读法 | | Agent | `agent::{ToolCallCard, PermissionPrompt, ToolApprovalDialog, AgentStepList, FileChangeCard, MultiFileDiffReview, ArtifactPanel, CostBreakdown}` `chat::{MessageList, PromptInput, StreamingMarkdown, ConversationList}` | | 动效 | `motion::{duration, Flip, Reorder, Transition, Stagger, ProgressBar, Spinner, Skeleton}` | | 主题 | `theme::{Theme, Mode}`;Omarchy 色板由 `ely-ui` 映射成组件库的调色板 | @@ -286,4 +286,4 @@ cargo run -p ely-doc 启动一个应用(ElyDoc 先 cargo build -p ely - 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 混写时,Tab 补到下一个四格位,去掉合计四列(组件库 b39097c);在空白行(含只有空格的行)插入表格即以表格取代该行(上一行有字时先空一行),免成缩进代码(组件库 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`,黑色;RaTeX 没有轮廓而写成文字的字,与图一样经 usvg 以系统字体变成路径,找不到字体即报错;先变路径再按墨迹定边框),Mermaid 图换成浅色 SVG,经 usvg 把箭头标记与文字都变成路径(组件库 `diagram_svg`,与公式共用 `flatten`;有标签或字找不到字体即报错,不让字悄悄消失),画不出的公式与图照原代码保留,按各自所在的行提示;图片:自包含的 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 本身;缺图、画不出的公式与图同导出一样提示。会话恢复(C14.15,MARK-12)照旧(`restore.rs`:`session.json` 记标签的路径、名称、模式、插入点与滚动,`look.json` 记视图设置,崩溃后未保存的文字以未命名副本回来,原文件不动);恢复点或会话写不进时,窗口以提示写明(恢复点另写明该标签上次存下的时间,未曾存下写“尚无”),同一类错误(按 `io::ErrorKind`,不按含临时文件名的原话)只提示一次,每次计时照样重试;崩溃后恢复的标签以所载恢复点的时间为“上次存下”;所记的模式改为类型化的 `Mode`(serde 按小写名),认不出的模式与其他坏字段一样使会话读不出、启动报错,不再悄悄当作可视;原由已删的 CEF 窗口脚本覆盖的重启与崩溃恢复,改由同一进程里两个 gpui 测试上下文先后开同一数据目录核对。Git 配合(C14.16,MARK-13)照旧:状态栏的 Git 项(未改动、有改动、未跟踪、已忽略、有冲突)在保存(指纹变化)后重问,按下即以该文件开 ElyTree;单测核对各状态,窗口测试核对保存后由未改动变为有改动(测试的提交关掉签名,不受用户 `commit.gpgsign` 影响)。组件库 50a59d2 至 58227cd:图在屏上的尺寸与公式的边框改用带系统字体的 usvg 读取,选字照 gpui 的渲染器(字库为系统字体加组件库资源里 gpui 自带的 IBM Plex Sans,没有字体答应的通用族改指自带字体;指名的字体族找不到即取 sans-serif,再找不到取任一字体,单个字再按字回退),导出也照此(原先用无字体的默认选项量尺寸,每张图的每个标签都记一条“找不到字体”的警告,公式的边框也算不进回退字的墨迹)。验收(2026-10-12):Windows 与 Linux(WSL)的 `packaging/stage.sh ely-mark` 带出 `ely-mark`、`ely-pdf-worker`、Git 与许可,冒烟(`ELY_SMOKE=1`,打开含公式与图的文件)均正常退出、Windows 日志无警告;macOS 只能经发布 CI 构建,本次未验证。已知限制:markdown-rs 的解析时间随块数平方增长(release,本机:短段落 1 万段 0.12 秒、2 万 0.40 秒、4 万 1.44 秒、8 万 5.5 秒,纯 CommonMark 同样),所以短段落极多的大文件导出要数秒;PERF-07 的 1 MB 样本仍在 2 秒内。Linux 上 gpui 的 SVG 渲染器找不到其自带的 Lilex 字体(共用资源里没有),每次启动记一条警告,改用系统字体,不属 ElyMark。导出不写打开着的文件(`ely_files::export` 与外壳的持有者检查)。ElyMark 因此依赖 `ely-pdf-engine`,各端的包随带 `ely-pdf-worker` 与 MuPDF、OpenSSL、Great Vibes 的许可;OCR 语言数据只随 ElyPDF。 - 2026-10-12(用户报:Windows 上 ElyMark 的最近列表显示 `\\?\C:\…`):凡取真实路径一律 `dunce::canonicalize`(ely-files 的会话登记、ely-lok 的内核目录与配置目录、ElyMark 的文件夹、最近列表、会话与标签比对;ElyTree 原已如此):Windows 上能写成普通路径即普通路径,他处同 `std::fs::canonicalize`。只供读写的路径照旧保留 `\\?\`(`ely_files::save` 的落盘与同文件判定:在 250 字的文件夹里,普通写法的暂存文件名越过 260 而写不进),保存后交给会话与 `Saved` 的路径以 `dunce::simplified` 整体判定后为普通写法;ely-office 另存的目标先以完整路径求出,再以 `dunce::simplified` 整体判定能否写成普通路径(先简化文件夹再接文件名时,结尾带空格的名字会被 Windows 削成另一个已打开的文件)。`ely_files::recent` 读入时以 `dunce::simplified` 去掉旧版写下的 `\\?\` 并去重,下次写入即为普通路径。 - 2026-10-12(用户报:ElyTable 的网格上一直是文字指针):`LokView` 原把指针写死为文字指针。现不按键的鼠标移动也交内核(悬停,不算编辑、不加修订),内核以 `LOK_CALLBACK_MOUSE_POINTER` 回报 CSS 指针名(`vcl` 的 `gaLOKPointerMap`),随事件批次到视图,换成 gpui 的指针:单元格上为箭头,编辑单元格或文字时为文字指针,链接为手;未知的名字记警告并用箭头。 -- 2026-10-13(用户定):ElyMark 以 Typora 1.14.10 为对照(R4;安装包与解包放在 `TyporaRef`,不入库):可视模式仍是按下的块以原文编辑;语法照 Typora 默认(`:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块、`[toc]`、front matter 框;`==`、`^`、单个 `~` 照 GFM);快捷键照 Typora(Ctrl+1–6 标题、Ctrl+0 段落、Ctrl+/ 源码、Ctrl+Shift+=/-/0 字号、F8 专注、F9 打字机);右键菜单照 Typora 的布局,剪贴板与格式为图标行(组件库 `Menu` 新增)。Typora 与 GitHub 读同一份文件不同时随 GitHub(GFM,§14.3):表情名取 GitHub 现行的 gemoji(`emojis` 库,`:cricket:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`
` 照 HTML5 也是换行,GFM 标签过滤的九种(`script`、`style` 等)照原文显示,提示块的标记读解析后的纯文字;Typora 只决定哪些扩展显示。审查改用 gpt-6-astra,effort max,fast。 +- 2026-10-13(用户定):ElyMark 以 Typora 1.14.10 为对照(R4;安装包与解包放在 `TyporaRef`,不入库):可视模式仍是按下的块以原文编辑;语法照 Typora 默认(`:表情:`、行内与块级 HTML 的常用标签、GitHub 提示块、`[toc]`、front matter 框;`==`、`^`、单个 `~` 照 GFM);快捷键照 Typora(Ctrl+1–6 标题、Ctrl+0 段落、Ctrl+/ 源码、Ctrl+Shift+=/-/0 字号、F8 专注、F9 打字机);右键菜单照 Typora 的布局,剪贴板与格式为图标行(组件库 `Menu` 新增)。Typora 与 GitHub 读同一份文件不同时随 GitHub(GFM,§14.3):表情名取 GitHub 现行的 gemoji(`emojis` 库,`:cricket:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`
` 照 HTML5 也是换行,GFM 标签过滤的九种(`script`、`style` 等)照原文显示,提示块的标记读解析后的纯文字;Typora 只决定哪些扩展显示。导出、纯文本与富文本复制经组件 `MarkdownReader` 照可视读每个段落、标题、单元格与 HTML 块(各自一套新栈,块尾关闭未闭合的标签):脚注在原处、写作 `[号]`,链接不带标题(可视不显示),取代 C14.14 的页末脚注与 HTML 一律转义。审查改用 gpt-6-astra,effort max,fast。 diff --git a/apps/ely-mark/src/export/tests.rs b/apps/ely-mark/src/export/tests.rs index c6f875e..e4f8d48 100644 --- a/apps/ely-mark/src/export/tests.rs +++ b/apps/ely-mark/src/export/tests.rs @@ -25,7 +25,7 @@ fn export_asks_for_a_format_then_writes_it(cx: &mut TestAppContext) { cx.run_until_parked(); let html = std::fs::read_to_string(&out).expect("written"); assert!( - html.contains("

Note

") && html.contains("Note") && html.contains(" bool { + // Browsers trim spaces and controls, then drop tabs and newlines. + let read: String = url + .trim_matches(|ch: char| ch <= ' ') + .chars() + .filter(|ch| !matches!(ch, '\t' | '\n' | '\r')) + .collect(); + match scheme(&read) { + 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/html.rs b/apps/ely-mark/src/output/html.rs index 0296b6c..95acd54 100644 --- a/apps/ely-mark/src/output/html.rs +++ b/apps/ely-mark/src/output/html.rs @@ -1,84 +1,100 @@ -//! A syntax tree written as HTML, node by node. +//! Markdown as HTML, as the drawing reads it. -use std::{collections::HashMap, io}; +use std::{ + cmp::Reverse, + collections::{HashSet, VecDeque}, + io, + ops::Range, +}; -use ely_gpui_component::{documents::diagram_svg, typography::formula_svg}; +use ely_gpui_component::{ + documents::{ + AlertKind, Inline, MarkdownBlock, MarkdownReader, Style, alert, diagram_svg, is_toc, + }, + typography::formula_svg, +}; use markdown::mdast::{AlignKind, Node}; -use super::{Page, attribute, escape, pictures::Placed}; +use super::{Page, address::safe, 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<()> { +/// Writes `tree`, read from `source`, into `page`. +pub(super) fn write( + tree: &Node, + source: &str, + placed: &mut Placed, + page: &mut Page, +) -> io::Result<()> { let mut writer = Writer { placed, page, - links: HashMap::new(), - notes: HashMap::new(), - called: Vec::new(), + source, + reader: MarkdownReader::new(source, tree), + anchored: HashSet::new(), }; - writer.gather(tree); - writer.children(tree, false)?; - writer.footnotes() + writer.root(tree) } 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, + source: &'c str, + reader: MarkdownReader<'c>, + /// Notes whose definition has its anchor. + anchored: HashSet, +} + +/// What a read area leaves out, in reading order. +#[derive(Default)] +struct Atoms { + /// Each formula's line. + formulas: VecDeque, + /// Each footnote reference's number. + notes: VecDeque, +} + +/// A stretch of read text's mark. +enum Mark<'a> { + Style(Style), + Link(&'a str), } 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)?; + /// Root blocks: alerts and `[toc]` live only here. + fn root(&mut self, tree: &Node) -> io::Result<()> { + for child in tree.children().into_iter().flatten() { + match child { + Node::Blockquote(quote) => match alert(quote) { + Some((kind, body)) => self.alert(kind, &body)?, + None => self.block(child, false)?, + }, + _ if is_toc(child, self.source) => self.toc(), + _ => self.block(child, false)?, + } } Ok(()) } - fn wrapped(&mut self, tag: &str, node: &Node) -> io::Result<()> { - self.push(&format!("<{tag}>")); - self.children(node, false)?; - self.push(&format!("")); + fn blocks(&mut self, node: &Node, tight: bool) -> io::Result<()> { + for child in node.children().into_iter().flatten() { + self.block(child, tight)?; + } Ok(()) } - fn node(&mut self, node: &Node, tight: bool) -> io::Result<()> { + fn block(&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::Paragraph(_) | Node::Heading(_) | Node::Table(_) | Node::Html(_) => { + self.area(node, tight)? + } + Node::Blockquote(_) => { + self.push("
"); + self.blocks(node, false)?; + self.push("
"); + } Node::List(list) => { let tag = if list.ordered { "ol" } else { "ul" }; match list.start.filter(|start| list.ordered && *start != 1) { @@ -92,73 +108,145 @@ impl Writer<'_, '_, '_> { let mark = if checked { " checked" } else { "" }; self.push(&format!(" ")); } - self.children(item, !list.spread && !each.spread)?; + self.blocks(item, !list.spread && !each.spread)?; self.push(""); } 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.page.diagrams.push(line_of(node)); 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::Math(math) => self.math(line_of(node), &math.value, true), 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}")); + Node::FootnoteDefinition(note) => { + // A reference reaches its note's first definition. + let id = (self.reader.note(¬e.identifier)) + .filter(|number| self.anchored.insert(*number)) + .map_or_else(String::new, |number| format!(" id=\"fn-{number}\"")); + self.push(&format!( + "[{}]
", + escape(&self.reader.label(note)) + )); + self.blocks(node, false)?; + self.push("
"); } - // Written elsewhere, or never. - Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) | Node::FootnoteDefinition(_) => {} - _ => self.children(node, false)?, + // Never written. + Node::Yaml(_) | Node::Toml(_) | Node::Definition(_) => {} + other => unreachable!("{other:?} is no block outside MDX"), } Ok(()) } - fn table(&mut self, node: &Node, align: &[AlignKind]) -> io::Result<()> { + /// A paragraph, heading, table or HTML block, read as drawn. + fn area(&mut self, node: &Node, tight: bool) -> io::Result<()> { + let mut read = Vec::new(); + self.reader.block(node, &mut read); + if let Node::Table(_) = node { + let [MarkdownBlock::Table { aligns, head, rows }] = read.as_slice() else { + unreachable!("a table reads as one table"); + }; + return self.table(node, aligns, head, rows); + } + // A heading's pictures follow it whole. + let split = !matches!(node, Node::Heading(_)); + let stretches = self.stretches(node, split); + self.run(&read, node, tight, split, stretches) + } + + /// Pieces in order; with `split`, each picture ends a stretch. + fn run( + &mut self, + pieces: &[MarkdownBlock], + node: &Node, + tight: bool, + split: bool, + mut stretches: VecDeque, + ) -> io::Result<()> { + let mut written = false; + for piece in pieces { + match piece { + MarkdownBlock::Image { alt, url, link } => { + self.picture(url, alt, link.as_deref())?; + if split { + retire(stretches.pop_front(), written); + written = false; + } + } + _ => { + let atoms = stretches.front_mut().expect("a stretch per text"); + self.piece(piece, node, tight, atoms); + written = true; + } + } + } + retire(stretches.pop_front(), written); + assert!(stretches.is_empty(), "a stretch per picture"); + Ok(()) + } + + /// Formulas and notes by stretch; pictures part them. + fn stretches(&self, node: &Node, split: bool) -> VecDeque { + let mut out = VecDeque::from([Atoms::default()]); + self.gather(node, split, &mut out); + out + } + + fn gather(&self, node: &Node, split: bool, out: &mut VecDeque) { + match node { + Node::InlineMath(_) => { + (out.back_mut().expect("a stretch").formulas).push_back(line_of(node)) + } + Node::FootnoteReference(call) => { + let number = self + .reader + .note(&call.identifier) + .expect("a reference names a note"); + out.back_mut().expect("a stretch").notes.push_back(number); + } + Node::Image(_) | Node::ImageReference(_) if split => out.push_back(Atoms::default()), + _ => {} + } + for child in node.children().into_iter().flatten() { + self.gather(child, split, out); + } + } + + fn table( + &mut self, + node: &Node, + aligns: &[AlignKind], + head: &[Vec], + rows: &[Vec>], + ) -> io::Result<()> { + let written = node.children().expect("a table holds rows"); + let read: Vec<&[Vec]> = std::iter::once(head) + .chain(rows.iter().map(Vec::as_slice)) + .collect(); + assert_eq!(read.len(), written.len(), "a read row per row"); self.push(""); - for (at, row) in node.children().into_iter().flatten().enumerate() { - let cell = if at == 0 { "th" } else { "td" }; + for (at, (row, cells)) in read.into_iter().zip(written).enumerate() { + let tag = if at == 0 { "th" } else { "td" }; + let cells = cells.children().expect("a row holds cells"); + assert_eq!(row.len(), cells.len(), "a read cell per cell"); self.push(""); - for (column, each) in row.children().into_iter().flatten().enumerate() { - let side = match align.get(column) { + for (column, (pieces, cell)) in row.iter().zip(cells).enumerate() { + let side = match aligns.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(&format!("<{tag}{side}>")); + let stretches = self.stretches(cell, true); + self.run(pieces, cell, true, true, stretches)?; + self.push(&format!("")); } self.push(""); } @@ -166,6 +254,113 @@ impl Writer<'_, '_, '_> { Ok(()) } + fn piece(&mut self, piece: &MarkdownBlock, node: &Node, tight: bool, atoms: &mut Atoms) { + match piece { + MarkdownBlock::Paragraph(text) if tight => self.inline(text, atoms), + MarkdownBlock::Paragraph(text) => { + self.push("

"); + self.inline(text, atoms); + self.push("

"); + } + MarkdownBlock::Heading(depth, text) => { + let at = node.position().expect("a parsed node has its place"); + self.push(&format!("", at.start.offset)); + self.inline(text, atoms); + self.push(&format!("")); + } + MarkdownBlock::Math(tex) => { + let line = atoms.formulas.pop_front().expect("a formula's line"); + self.math(line, tex, true); + } + other => unreachable!("an area's text reads as text or math, not {other:?}"), + } + } + + /// Read text; its marks open and close as a stack. + fn inline(&mut self, read: &Inline, atoms: &mut Atoms) { + let mut marks: Vec<(&Range, Mark)> = (read.styles.iter()) + .filter(|(_, style)| *style != Style::Link) + .map(|(range, style)| (range, Mark::Style(*style))) + .chain( + read.links + .iter() + .map(|(range, url)| (range, Mark::Link(url))), + ) + .collect(); + // Outer first; a formula or note is its own text. + marks.sort_by_key(|(range, mark)| { + let whole = matches!(mark, Mark::Style(Style::Math | Style::Footnote)); + (range.start, Reverse(range.end), whole) + }); + let (mut open, mut at): (Vec<(usize, &str)>, usize) = (Vec::new(), 0); + let mut links = 0; + for (range, mark) in marks { + links -= self.close_to(read, range.start, &mut open, &mut at); + assert!( + range.start >= at && open.last().is_none_or(|(end, _)| range.end <= *end), + "read marks nest" + ); + self.text(&read.text[at..range.start]); + at = range.start; + match mark { + Mark::Style(Style::Math) => { + let line = atoms.formulas.pop_front().expect("a formula's line"); + self.math(line, &read.text[range.clone()], false); + at = range.end; + } + Mark::Style(Style::Footnote) => { + let number = atoms.notes.pop_front().expect("a note's number"); + // Links never nest. + match links > 0 { + true => self.push(&format!("{number}")), + false => { + self.push(&format!("{number}")) + } + } + at = range.end; + } + Mark::Style(style) => { + let name = tag(style); + self.push(&format!("<{name}>")); + open.push((range.end, name)); + } + Mark::Link(url) => { + match safe(url) { + true => self.push(&format!("", attribute(url))), + false => self.push(""), + } + open.push((range.end, "a")); + links += 1; + } + } + } + self.close_to(read, read.text.len(), &mut open, &mut at); + self.text(&read.text[at..]); + } + + /// Closes each mark ending by `to`; counts closed links. + fn close_to( + &mut self, + read: &Inline, + to: usize, + open: &mut Vec<(usize, &str)>, + at: &mut usize, + ) -> usize { + let mut links = 0; + while let Some(&(end, name)) = open.last().filter(|(end, _)| *end <= to) { + self.text(&read.text[*at..end]); + self.push(&format!("")); + *at = end; + open.pop(); + links += usize::from(name == "a"); + } + links + } + + fn text(&mut self, text: &str) { + self.push(&escape(text).replace('\n', "
")); + } + fn code(&mut self, lang: Option<&str>, value: &str) { let class = lang.map_or_else(String::new, |lang| { format!(" class=\"language-{}\"", attribute(lang)) @@ -173,12 +368,12 @@ impl Writer<'_, '_, '_> { self.push(&format!("
{}
", escape(value))); } - fn math(&mut self, node: &Node, value: &str, display: bool) { + fn math(&mut self, line: usize, 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())); + self.page.formulas.push((line, error.to_string())); match display { true => self.code(Some("math"), value), false => self.push(&format!("{}", escape(value))), @@ -187,21 +382,37 @@ impl Writer<'_, '_, '_> { } } - 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(""), + /// A GitHub alert: its kind's title, then its blocks. + fn alert(&mut self, kind: AlertKind, body: &[Node]) -> io::Result<()> { + let (title, hue) = (kind.title(), hue(kind)); + self.push(&format!( + ""); Ok(()) } - fn picture(&mut self, url: &str, alt: &str, title: Option<&str>) -> io::Result<()> { + /// `[toc]`: each heading, linked to its place. + fn toc(&mut self) { + let items: String = (self.reader.headings.iter()) + .map(|heading| { + format!( + "
  • {}
  • ", + heading.depth - 1, + heading.at, + escape(&heading.title) + ) + }) + .collect(); + self.push(&format!( + "
      {items}
    " + )); + } + + fn picture(&mut self, url: &str, alt: &str, link: Option<&str>) -> io::Result<()> { let src = self.placed.place(url)?; if src.is_none() { self.page.missing.push(if url.is_empty() { @@ -215,36 +426,52 @@ impl Writer<'_, '_, '_> { self.push(&format!("[{}]", escape(alt))); return Ok(()); }; - let title = title.map_or_else(String::new, |title| { - format!(" title=\"{}\"", attribute(title)) - }); - self.push(&format!( - "\"{}\"{title}", + let image = format!( + "\"{}\"", attribute(&src), attribute(alt) - )); + ); + match link { + Some(link) if safe(link) => { + self.push(&format!("{image}", attribute(link))) + } + Some(_) => self.push(&format!("{image}")), + None => self.push(&image), + } 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(()) +/// A stretch done: all read, or blank and dropped. +fn retire(atoms: Option, written: bool) { + let atoms = atoms.expect("a stretch per picture"); + assert!( + atoms.notes.is_empty() && (!written || atoms.formulas.is_empty()), + "every formula and note is read" + ); +} + +/// GitHub's color for an alert kind. +fn hue(kind: AlertKind) -> &'static str { + match kind { + AlertKind::Note => "#0969da", + AlertKind::Tip => "#1a7f37", + AlertKind::Important => "#8250df", + AlertKind::Warning => "#9a6700", + AlertKind::Caution => "#d1242f", + } +} + +/// The tag a read style writes. +fn tag(style: Style) -> &'static str { + match style { + Style::Strong => "strong", + Style::Emphasis => "em", + Style::Strike => "del", + Style::Code => "code", + Style::Underline => "u", + Style::Mark => "mark", + Style::Link | Style::Math | Style::Footnote => unreachable!("{style:?} writes no tag"), } } @@ -257,28 +484,3 @@ fn line_of(node: &Node) -> usize { fn bare(svg: &str) -> &str { svg.find(" bool { - // Browsers trim spaces and controls, then drop tabs and newlines. - let read: String = url - .trim_matches(|ch: char| ch <= ' ') - .chars() - .filter(|ch| !matches!(ch, '\t' | '\n' | '\r')) - .collect(); - match scheme(&read) { - 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 1e55b14..a7bf415 100644 --- a/apps/ely-mark/src/output/mod.rs +++ b/apps/ely-mark/src/output/mod.rs @@ -1,5 +1,6 @@ //! Markdown as an HTML page or plain text (MARK-11). +mod address; mod files; mod html; mod pictures; @@ -45,7 +46,7 @@ fn page_of(text: &str, placed: &mut pictures::Placed) -> io::Result { let tree = markdown::to_mdast(text, &markdown_options()) .map_err(|error| io::Error::other(error.to_string()))?; let mut page = Page::default(); - html::write(&tree, placed, &mut page)?; + html::write(&tree, text, placed, &mut page)?; log::info!( "mark: a page of {} bytes, {} pictures copied, {} missing, {} diagrams and {} formulas as code", page.html.len(), diff --git a/apps/ely-mark/src/output/pictures.rs b/apps/ely-mark/src/output/pictures.rs index 5e0bf28..c31a979 100644 --- a/apps/ely-mark/src/output/pictures.rs +++ b/apps/ely-mark/src/output/pictures.rs @@ -8,7 +8,7 @@ use std::{ use base64::Engine as _; -use super::html::scheme; +use super::address::scheme; /// Where pictures go. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/apps/ely-mark/src/output/plain.rs b/apps/ely-mark/src/output/plain.rs index 82ccd11..59863c6 100644 --- a/apps/ely-mark/src/output/plain.rs +++ b/apps/ely-mark/src/output/plain.rs @@ -2,21 +2,44 @@ use std::io; -use ely_gpui_component::documents::markdown_options; +use ely_gpui_component::documents::{ + MarkdownBlock, MarkdownReader, alert, is_toc, 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 reader = MarkdownReader::new(text, &tree); let mut blocks = Vec::new(); + // Alerts and `[toc]` live only at the root. for child in tree.children().into_iter().flatten() { - block(child, &mut blocks); + match child { + Node::Blockquote(quote) => match alert(quote) { + Some((kind, body)) => { + blocks.push(kind.title().to_string()); + for node in &body { + block(&reader, node, &mut blocks); + } + } + None => block(&reader, child, &mut blocks), + }, + _ if is_toc(child, text) => { + let titles: Vec<&str> = (reader.headings.iter()) + .map(|heading| heading.title.as_str()) + .collect(); + if !titles.is_empty() { + blocks.push(titles.join("\n")); + } + } + _ => block(&reader, child, &mut blocks), + } } Ok(blocks.join("\n\n") + "\n") } -fn block(node: &Node, blocks: &mut Vec) { +fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec) { match node { Node::Code(code) => blocks.push(code.value.clone()), Node::Math(math) => blocks.push(math.value.clone()), @@ -24,7 +47,7 @@ fn block(node: &Node, blocks: &mut Vec) { let items = list.children.iter().map(|item| { let mut inner = Vec::new(); for child in item.children().into_iter().flatten() { - block(child, &mut inner); + block(reader, child, &mut inner); } inner.join("\n") }); @@ -32,32 +55,45 @@ fn block(node: &Node, blocks: &mut Vec) { } Node::Table(table) => { let rows = table.children.iter().map(|row| { - let cells = row.children().into_iter().flatten().map(words); + let cells = row.children().into_iter().flatten(); + let cells = cells.map(|cell| words(reader, cell)); cells.collect::>().join("\t") }); blocks.push(rows.collect::>().join("\n")); } - Node::Blockquote(_) | Node::FootnoteDefinition(_) => { + Node::FootnoteDefinition(note) => { + let mut body = Vec::new(); + for child in ¬e.children { + block(reader, child, &mut body); + } + // The label the drawing shows beside it. + let label = format!("[{}]", reader.label(note)); + blocks.push([label, body.join("\n\n")].join(" ").trim_end().to_string()); + } + Node::Blockquote(_) => { for child in node.children().into_iter().flatten() { - block(child, blocks); + block(reader, child, blocks); } } - Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(node)), - // Front matter, HTML, rules and definitions are not words. + Node::Paragraph(_) | Node::Heading(_) => blocks.push(words(reader, node)), + Node::Html(_) => { + let mut read = Vec::new(); + reader.block(node, &mut read); + for piece in read { + match piece { + MarkdownBlock::Paragraph(text) => blocks.push(text.text), + other => unreachable!("an HTML block reads as text, not {other:?}"), + } + } + } + // Front matter, 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::ImageReference(image) => image.alt.clone(), - Node::Html(_) => String::new(), - _ => node.children().into_iter().flatten().map(words).collect(), - } +/// An inline run's words, as the drawing reads them. +fn words(reader: &MarkdownReader, node: &Node) -> String { + reader + .inline(node.children().expect("an inline run holds inlines")) + .text } diff --git a/apps/ely-mark/src/output/tests.rs b/apps/ely-mark/src/output/tests.rs index e8ef84d..33e4c44 100644 --- a/apps/ely-mark/src/output/tests.rs +++ b/apps/ely-mark/src/output/tests.rs @@ -2,6 +2,8 @@ use std::path::Path; use super::{Pictures, body, document, plain}; +mod visual; + fn page(text: &str, beside: Option<&Path>, pictures: &Pictures) -> super::Page { body(text, beside, pictures).expect("a page") } @@ -51,17 +53,17 @@ 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

    ", + "

    T

    ", "
  • done
  • ", "
      ", "
    ", "", "gone", - "a", + "a", "r", "bad", "1", - "
  • The note.

  • ", + "
    [1]

    The note.

    ", ] { assert!(html.contains(part), "{part} in {html}"); } @@ -458,9 +460,15 @@ fn a_formula_letter_no_font_has_stays_as_tex() { fn a_large_document_exports_whole() { // Long paragraphs: markdown-rs slows with the block count. let words = "word ".repeat(200); - let mut text: String = (0..2_200).map(|n| format!("Paragraph {n}: {words}\n\n")).collect(); + let mut text: String = (0..2_200) + .map(|n| format!("Paragraph {n}: {words}\n\n")) + .collect(); text.push_str("The last line.\n"); assert!(text.len() > ely_gpui_component::documents::LARGEST_READ); assert!(plain(&text).expect("text").ends_with("The last line.\n")); - assert!(page(&text, None, &Pictures::Inline).html.ends_with("

    The last line.

    ")); + assert!( + page(&text, None, &Pictures::Inline) + .html + .ends_with("

    The last line.

    ") + ); } diff --git a/apps/ely-mark/src/output/tests/visual.rs b/apps/ely-mark/src/output/tests/visual.rs new file mode 100644 index 0000000..1365d99 --- /dev/null +++ b/apps/ely-mark/src/output/tests/visual.rs @@ -0,0 +1,232 @@ +//! Exports read the file as the visual mode does (R4.4). + +use super::{Pictures, page, plain}; +use crate::output::{Page, html::write, markdown_options, pictures::Placed}; + +fn html(text: &str) -> String { + page(text, None, &Pictures::Inline).html +} + +fn text(text: &str) -> String { + plain(text).expect("plain text") +} + +#[test] +fn shortcodes_turn_as_github_turns_them() { + let page = html("Go :smile: x:smile:\n\nx:smile: :smile:\n\na :smile: :smile:\n"); + for part in [ + "

    Go 😄 x😄

    ", + "

    x:smile: :smile:

    ", + "

    a :smile: 😄

    ", + ] { + assert!(page.contains(part), "{part} in {page}"); + } + assert_eq!( + text("Go :smile:\n\nx:smile: :smile:\n"), + "Go 😄\n\nx:smile: :smile:\n" + ); +} + +#[test] +fn inline_tags_keep_what_they_mean_and_nothing_runs() { + let source = "a umks \ + je\n"; + let page = html(source); + for part in [ + "u", + "m", + "k", + " s ", + "<script>x</script>", + "j", + "e", + ] { + assert!(page.contains(part), "{part} in {page}"); + } + assert!( + !page.contains("b \n"), + "a b \n" + ); +} + +#[test] +fn an_html_block_shows_its_text() { + let source = "
    \n Bold and :smile:\n
    \n"; + let page = html(source); + assert!( + page.contains("

    Bold and 😄

    "), + "{page}" + ); + assert!(!page.contains(" [!NOTE]\n> Read this.\n\n- > [!TIP]\n > Not here.\n"); + let alert = "

    Note

    Read this.

    "; + assert!(page.contains(alert), "{page}"); + assert!(!page.contains("#1a7f37"), "only root quotes alert: {page}"); + assert_eq!(text("> [!WARNING]\n> Hot.\n"), "Warning\n\nHot.\n"); +} + +#[test] +fn a_toc_lists_the_headings_and_links_them() { + let page = html("[toc]\n\n# A\n\n## B :smile:\n"); + let toc = ""; + assert!(page.contains(toc), "{page}"); + assert!(page.contains("

    A

    ") && page.contains("

    B 😄

    ")); + assert_eq!(text("[toc]\n\n# A\n\n## B\n"), "A\nB\n\nA\n\nB\n"); +} + +#[test] +fn tags_crossing_containers_read_as_drawn() { + for (source, shown) in [ + ("*a b* :smile: :smile:\n", "a b :smile: 😄"), + ("a *b* :smile:\n", "a b 😄"), + ] { + assert_eq!(text(source), format!("{shown}\n")); + let page = html(source); + assert_eq!(page.matches("😄").count(), 1, "{page}"); + assert_eq!( + page.matches(":smile:").count(), + shown.matches(":smile:").count(), + "{page}" + ); + } +} + +#[test] +fn each_cell_and_block_reads_on_its_own() { + let page = html("| x:smile: | :smile: |\n| --- | --- |\n| :smile: | :smile: |\n"); + assert_eq!(page.matches("😄").count(), 3, "{page}"); + let page = html("# a x\n\n:smile:\n"); + assert!(page.contains("

    😄

    "), "{page}"); +} + +#[test] +fn a_block_closes_the_tags_left_open_in_it() { + let page = html("a bold\n\nnormal\n"); + assert!( + page.contains("

    a bold

    normal

    "), + "{page}" + ); + let page = html("a link\n\nnormal\n"); + assert!(page.ends_with("

    normal

    "), "{page}"); +} + +#[test] +fn every_toc_entry_has_its_heading() { + let page = html("[toc]\n\n# Main\n\n[^n]:\n ## Unused\n"); + let targets: Vec<&str> = page + .split("href=\"#") + .skip(1) + .map(|rest| &rest[..rest.find('"').expect("a quote")]) + .collect(); + assert_eq!(targets.len(), 2, "{page}"); + for target in targets { + assert!( + page.contains(&format!("id=\"{target}\"")), + "{target} in {page}" + ); + } +} + +#[test] +fn a_long_html_block_writes_in_linear_time() { + let took = |count: usize| { + let source = format!("
    {}
    \n", "x ".repeat(count)); + let start = std::time::Instant::now(); + html(&source); + start.elapsed() + }; + let (small, large) = (took(8_000), took(32_000)); + // Four times the input; squared would be sixteen. + assert!(large < small * 8, "{small:?} then {large:?}"); +} + +#[test] +fn notes_under_deep_marks_write_in_linear_time() { + let took = |count: usize| { + let source = format!( + "start {}{} + +[^n]: note +", + "".repeat(count), + "x[^n] ".repeat(count) + ); + let tree = markdown::to_mdast(&source, &markdown_options()).expect("parsed"); + let mut placed = Placed::new(None, &Pictures::Inline); + let start = std::time::Instant::now(); + write(&tree, &source, &mut placed, &mut Page::default()).expect("written"); + start.elapsed() + }; + // Parsing grows squared upstream; untimed. + let (small, large) = (took(4_000), took(16_000)); + assert!(large < small * 8, "{small:?} then {large:?}"); +} + +#[test] +fn a_blank_formula_is_dropped_with_its_text() { + for source in [ + "$ $\n", + "$ $ ![p](p.png)\n", + "| $ $ | a |\n| - | - |\n| b | c |\n", + ] { + html(source); + } + let page = page("$ $ ![p](p.png)\nnext $\\sqrt{$\n", None, &Pictures::Inline); + let lines: Vec = page.formulas.iter().map(|(line, _)| *line).collect(); + assert_eq!(lines, [2], "{}", page.html); +} + +#[test] +fn a_note_in_a_link_keeps_the_link_whole() { + let page = html("a
    b[^n] c after\n\n[^n]: note\n"); + assert!( + page.contains("b1 c"), + "{page}" + ); +} + +#[test] +fn a_reference_reaches_its_own_note() { + let page = html("[^1]: unused\n\nCall[^used].\n\n[^used]: right target\n"); + assert_eq!(page.matches("id=\"fn-1\"").count(), 1, "{page}"); + let at = page.find("id=\"fn-1\"").expect("an anchor"); + assert!( + page[at..].contains("right target") && !page[at..].contains("unused"), + "{page}" + ); +} + +#[test] +fn plain_notes_keep_the_labels_drawn_beside_them() { + assert_eq!( + text("A[^b] B[^a].\n\n[^a]: Alpha\n\n[^b]: Beta\n"), + "A\u{b9} B\u{b2}.\n\n[2] Alpha\n\n[1] Beta\n" + ); + assert_eq!(text("x\n\n[^n]: note\n"), "x\n\n[n] note\n"); +} + +#[test] +fn a_copied_fragment_carries_its_look() { + let page = html("> [!NOTE]\n> Read this.\n\n[toc]\n\n# Title\n\nCall[^n].\n\n[^n]: note\n"); + for part in [ + "border-left:.25em solid #0969da", + "font-weight:600", + "list-style:none", + "display:flex", + ] { + assert!(page.contains(part), "{part} in {page}"); + } + assert!( + !page.contains("class="), + "the look needs no page style: {page}" + ); +} diff --git a/scripts/fetch-references.sh b/scripts/fetch-references.sh index be091b9..63cc3be 100755 --- a/scripts/fetch-references.sh +++ b/scripts/fetch-references.sh @@ -36,7 +36,7 @@ fetch() { # name url rev [sparse paths...] echo "fetch $name $rev" } -fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git 9ff403df31894b07904cfd197a074d1ff95e6de1 +fetch Ely-GPUI-Components https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components.git 12512217bd9bc652ed6bbf10461b00cc26c01c21 fetch zed https://github.com/zed-industries/zed.git 1a28cff4b409169bac058bca40dfbfeb7621d19b fetch libreoffice-core https://github.com/LibreOffice/core.git 8221e31b3ac356a1623c672912a3d2b492f7e3d1 \ include/LibreOfficeKit libreofficekit desktop/source/lib desktop/inc \ diff --git a/tasks/TASKS.md b/tasks/TASKS.md index 44d63b2..ac1d272 100644 --- a/tasks/TASKS.md +++ b/tasks/TASKS.md @@ -235,7 +235,7 @@ - [~] R4.1 组件库:菜单的图标按钮行(`Menu` 一行若干图标按钮,各带名称与快捷键提示、可禁用);键盘可达;选中即运行并关菜单;画廊有例,有测试 — R1 FAIL 三项已修:所标按钮被禁用或移走时改选了别的按钮;带筛选框的菜单左右键被文本框吞掉;禁用按钮不说明不可用。R2 FAIL 两项已修:菜单关后的指针移动越界崩溃;移走中间按钮后右邻顶替被执行(标记认项不认位)。R3 FAIL 两项已修(三轮用尽,未再审):按钮无无障碍的点按动作;方向键所标按钮不进无障碍树(按钮报选中并作活动后代,面板为菜单角色);各有回归测试。组件库 1b8d678 已推送;`check.sh` 的 GPUI Kit 一步(Kit 0.7.3 要 gpui-pre 0.3.9)与五处未动文件的 raw px 早于本条 - [~] R4.2 组件库:行内扩展照 Typora 默认:`:shortcode:` 表情;行内 HTML 的 `u`、`b`/`strong`、`i`/`em`、`s`/`del`/`strike`、`code`、`kbd`、`mark`、`br`、`a` 按其义显示,其余标签只显示文字;脚注引用为上标数字;`~x~` 仍为删除线 — R1 FAIL 六项已修:表情吞掉转义与自动链接;连字符名当成已支持标签;属性值未解码字符引用;无引号值的斜杠被删;非 a 标签读 href;两条注释超长。R2 FAIL 六项已修:引用扫描为平方复杂度;属性值改照 HTML5 解码;表情名照 GitHub 现行 gemoji(用户定 GitHub 优先);关闭标签跨不出 Markdown 容器;无引号值只按 ASCII 空白分隔;`
    ` 照 HTML5 换行。R3 FAIL 五项已修(三轮用尽,未再审):Markdown 容器即 GitHub 写出的元素(em、strong、del、a),与标签同栈照 HTML5 修复:关闭标签结束同名元素并弹出其上各元素,只有格式元素重开,`mark`、`kbd`、`del` 止于浏览器止处,新的 `a` 先结束已开的 `a`;表情在解码后的正文里认(`\:smile:`、`:smile:` 照 GitHub 为表情,用户定 GitHub 优先,推翻 R1 第一项的转义部分),自动链接与 `` 里不认(GitHub Markdown API 实测);各有回归测试,旧代码上失败。组件库 9f39d32 已推送 - [~] R4.3 组件库:块扩展照 Typora 默认:GitHub 提示块(NOTE、TIP、IMPORTANT、WARNING、CAUTION);独占一段的 `[toc]` 画成可按的标题目录;front matter 去掉 `---` 显示在框里;HTML 块按 R4.2 的标签显示,GFM 标签过滤的九种(`script`、`style` 等)照原文显示(随 GitHub);分隔线可见;嵌套无序列表的标记随层级变化。R1 FAIL 六项已修:提示块标记改读解析后的纯文字;`script`、`style` 等过滤标签照 GitHub 显示原文(两项);块元素关闭时结束其内所开元素;目录按下在写回或待读时重新定位标题;按标签名计数,栈检查为常数时间。R2 FAIL 五项已修:目录按下随两份文字之差移动标题;欠下的空格只移其后所开元素;过滤标签里的短码;标签名止于空白或 `/`;块开始照 HTML5 结束 p、li、dd、dt。R3 FAIL 三项已修(三轮用尽,未再审):短码照 GitHub 按 DOM 文字节点定,首个 `:名:` 前为空白或空格则全转、否则全不转,过滤标签不分节点(GitHub Markdown API 实测 54 例,`.work/R4.3-fix3/github-*.log`;仅有不换行空格在前一例因限流未测);`fieldset`、`menu` 等照 HTML5 清单结束 p(`dialog`、`dir`、`form`、`hgroup`、`listing`、`search` 未在 GitHub 实测);目录按下改用 `CodeEditor` 锚点,随每次真实编辑移动,撤销即失效。各有回归测试,旧代码上失败(`.work/R4.3-fix3/red-*.log`)。组件库 8e5dad8 已推送 -- [ ] R4.4 ElyMark:导出 HTML、纯文本与富文本复制随 R4.2、R4.3,与可视所见一致(表情、提示块、目录、行内 HTML 的同一组标签) +- [~] R4.4 ElyMark:导出 HTML、纯文本与富文本复制随 R4.2、R4.3,与可视所见一致(表情、提示块、目录、行内 HTML 的同一组标签)。组件 1251221 公开 `MarkdownReader`、`MarkdownBlock`,导出与可视同一读法。审查三轮未过(`tasks/reviews/R4.4-r1..r3.md`):r1 六项(自读行内状态与组件分歧、目录锚点缺、写出平方);r2 六项(空公式错位、链接里的脚注嵌套、脚注锚点、纯文本脚注标签、富文本复制失样式、长注释);r3 一项:每个脚注扫一遍已开格式查链接,深格式加多脚注为平方。r3 已修(随格式栈计数已开链接),回归测试在旧码失败(60 ms 到 912 ms),新码线性;轮数用尽,未再审。markdown-rs 对同一输入的解析仍随长度平方(上游,已记限制) - [ ] R4.5 组件库:可视模式按下块时插入点落在所按的字处;右键不开块,把插入点放到所点处,以事件告知属主所点的目标(块的种类、链接地址、图片地址、是否在表格或代码块里) - [ ] R4.6 组件库:行内格式命令(粗体、斜体、行内代码、删除线、下划线、链接、图片、清除格式),照 Typora:有选区即包住或去掉,无选区取插入点所在的词,空处插一对标记、插入点在其中;代码、公式、HTML、front matter 里拒绝并说明;一步撤销;打开的块照常可续写 - [ ] R4.7 组件库:块命令(标题 1–6、段落、标题升降一级;引用、有序、无序、任务列表的切换;插入分隔线、代码块、公式块、目录、front matter、脚注、链接定义、前后空段落;删除块),一步撤销,拒绝说明原因 diff --git a/tasks/reviews/R4.4-r1.md b/tasks/reviews/R4.4-r1.md new file mode 100644 index 0000000..229f7ac --- /dev/null +++ b/tasks/reviews/R4.4-r1.md @@ -0,0 +1,13 @@ +FAIL + +1. `apps/ely-mark/src/output/html.rs:122`、`apps/ely-mark/src/output/plain.rs:105`:退出 Markdown 容器时恢复入口的 `code` 计数,与组件的标签关闭、格式元素重开规则不符。输入 `*a b* :smile: :smile:`,组件读出 `a b :smile: 😄`,HTML 与纯文本却把两个短码都转成表情;反向交叉 `a *b* :smile:`,组件读出 `a b 😄`,两种导出却保留最后的短码。富文本复制共用该 HTML 写入器,同样受影响。修复方向:复用组件的容器栈读法,不能通过恢复 AST 容器入口计数模拟关闭标签;补这两种交叉方向的回归。证据:`.work/R4.4-r1-review/optimized-probes.log` 中两项交叉标签探针均失败。 + +2. `apps/ely-mark/src/output/html.rs:237`、`apps/ely-mark/src/output/html.rs:147`:HTML 写入器跨独立可视行内区域沿用 `Reading`。表格 `| x:smile: | :smile: |\n| --- | --- |\n| :smile: | :smile: |` 的第一格关闭短码转换后,其余三格也原样输出 `:smile:`;可视模式和纯文本在这三格均为 😄。标题和紧凑列表还会泄漏 `code` 计数:`# a x\n\n:smile:` 的下一段导出为短码,可视模式为表情。修复方向:按组件的段落、标题、单元格边界建立独立读取状态,涵盖紧凑列表,并补跨边界回归。证据:同一日志中的单元格与标题探针失败。 + +3. `apps/ely-mark/src/output/html.rs:121`:结束可视块时没有结束其中未闭合的行内标签;浏览器修复嵌套会把格式或链接延续到下一块。输入 `a bold\n\nnormal`,组件第二段的样式为空,导出 `

    a bold

    normal

    ` 经 HTML5 解析后第二段为 `

    normal

    `。换成未闭合的 ``,下一段还会错误地成为链接。修复方向:在可视块边界结束该块的行内标签,保留块内约定的嵌套修复行为;测试实际解析后的格式和链接范围。证据:`.work/R4.4-r1-review/unclosed-tags.log`,两例均与组件实际读取结果不符。 + +4. `apps/ely-mark/src/output/html.rs:374`:目录列出组件提供的全部标题,但正文只写被引用的脚注,导致目录含有不存在的目标。输入 `[toc]\n\n# Main\n\n[^n]:\n ## Unused`,可视模式显示脚注内标题,导出目录含 `href="#h-25"`,却没有对应标题或 `id="h-25"`;点击该目录项无法跳转。修复方向:使标题的实际输出集合与可视目录一致,确保每个目录项都有输出锚点,并测试未引用脚注内的标题。证据:`optimized-probes.log` 的目录目标探针失败。 + +5. `apps/ely-mark/src/output/html.rs:332`:每个样式切片都重新扫描全部样式和链接,普通平铺 HTML 的写出退化为平方复杂度。输入 `
    ` 加 64,000 次 `x ` 再加 `
    `,只有 576,011 字节;实际输出源码以 `opt-level=3` 编译的探针中,16k、32k、64k 段分别耗时约 0.256、0.862、3.071 秒,同一 64k 输入的组件读取约 0.244 秒。组件依赖为测试构建,这不是完整发行性能测量;但平方扫描可直接从循环及增长曲线确认。`copy_rich` 在界面线程同步调用此写入器,因此该路径会长时间阻塞窗口,违反 PRD PERF-02。修复方向:按区间起止顺序维护当前样式和链接,避免每片扫描全集,并增加规模增长回归。证据:`optimized-probes.log`。 + +6. `apps/ely-mark/src/output/html.rs:16`、`apps/ely-mark/src/output/html.rs:283`、`apps/ely-mark/src/output/plain.rs:82`:三条新增或改写的注释分别为 11、12、12 词,超过项目规定的十词上限。运行 `python tests/scripts/comments.py` 会在这三处失败。修复方向:缩短这三条注释;日志中的 R5 注释不属于本轮判定。证据:`.work/R4.4-r1-review/comments.log`。 diff --git a/tasks/reviews/R4.4-r2.md b/tasks/reviews/R4.4-r2.md new file mode 100644 index 0000000..d9fe8ae --- /dev/null +++ b/tasks/reviews/R4.4-r2.md @@ -0,0 +1,15 @@ +FAIL + +1. `apps/ely-mark/src/output/html.rs:144`:合法的空白行内公式会触发断言,HTML/PDF 导出与富文本复制无法完成。输入 `$ $`,`MarkdownReader::block` 会略去只有空白的段落,但 `atoms` 仍收集该公式,最后触发 `every formula and note is read`;`$ $ ![p](p.png)` 和含空白公式的表格也复现。修复方向:让公式位置与实际留下的可视片段保持对应,正确处理被略去的空白片段,不能只删断言而让后续公式消费错误的行号;补这三类回归。证据:`.work/R4.4-r2-review/probes.log`、`browser-regressions.log`。 + +2. `apps/ely-mark/src/output/html.rs:249`:脚注引用无条件写入自己的 `
    `,会在已有链接内生成嵌套锚点,浏览器修复后丢失可视模式的链接范围和上标。输入 `a b[^n] c after\n\n[^n]: note`,组件的链接覆盖 `b¹ c`;实际 Chromium 解析导出后,外链只剩 `b`,`c` 不再是链接,脚注数字也被移出 ``。修复方向:脚注输出须遵守当前链接栈,避免嵌套 ``,并保留后续文字的链接范围;用解析后的 DOM 验证。证据:`.work/R4.4-r2-review/browser-regressions.log` 的 `text after footnote retains its visual link` 失败。 + +3. `apps/ely-mark/src/output/html.rs:121`:脚注的显示标签被直接用作锚点,但未引用脚注的原标签可能与已引用脚注的编号相同。输入 `[^1]: unused\n\nCall[^used].\n\n[^used]: right target`,两个定义都生成 `id="fn-1"`,`Call` 后的引用跳到 `unused`,不是 `right target`;Chromium 的目标查找已复现。修复方向:把显示标签与锚点身份分开,让引用唯一对应自己的定义,同时保留原处的显示标签;补编号与未引用标签冲突的回归。证据:`.work/R4.4-r2-review/browser-regressions.log` 的 `footnote reference reaches its definition` 失败。 + +4. `apps/ely-mark/src/output/plain.rs:64`:纯文本把脚注定义当作普通引用递归,只输出正文,丢掉可视模式在旁边显示的 `[label]`,无法把正文与脚注引用对应。输入 `A[^b] B[^a].\n\n[^a]: Alpha\n\n[^b]: Beta`,可视显示 `[2] Alpha`、`[1] Beta`,纯文本却是 `A¹ B².\n\nAlpha\n\nBeta\n`;未引用脚注的名称也全部丢失。修复方向:单独处理脚注定义,用 `MarkdownReader::label` 写出标签,再写正文;覆盖定义顺序与引用顺序不同及未引用脚注。证据:`.work/R4.4-r2-review/browser-regressions.log` 的 `plain footnotes retain the labels drawn beside them` 失败。 + +5. `apps/ely-mark/src/output/html.rs:315`、`apps/ely-mark/src/output/mod.rs:38`:提示块、目录和脚注的新呈现依赖整页的 `STYLE`,而 `copy_rich` 只复制 `output::body` 返回的片段,片段没有携带这些样式。对含 `> [!NOTE]`、`[toc]` 和脚注的文件,实际复制片段在独立 Chromium 文档中显示为无边线、非粗体的普通 Note 段落,目录出现圆点,脚注标签与正文上下排列;同一份整页导出则有边线、粗体、无圆点目录与并排脚注。修复方向:让富文本片段携带这些结构必需的样式,并测试独立片段的呈现,不能只断言类名存在。证据:`.work/R4.4-r2-review/browser-regressions.log` 的 `rich-copy fragment carries alert, TOC and footnote presentation` 失败。 + +6. `apps/ely-mark/src/output/html.rs:1`:本轮改写的首行注释有 12 词,仍违反每条注释最多十词的规则。上一轮指出的三条已缩短,但本次运行 `python tests/scripts/comments.py` 在此处失败;日志中的三条 `ely-lok` 失败属于 R5,不计入判定。修复方向:缩短此注释。证据:`.work/R4.4-r2-review/comments.log`。 + +清理受阻:自动审批先后拒绝组合清理和仅删除本轮 scratch 的命令,原因均为 `blocked by policy`。残留 `.work/R4.4-r2-review/runtime/`(探针源码、可执行文件、PDB 与构建元数据)及 `target/debug/incremental/`;本轮未复制源码树,单独指定的 `.work/R4.4-r2-review/probe-target/` 未创建。验证与残留清单见 `.work/R4.4-r2-review/summary.log`。 diff --git a/tasks/reviews/R4.4-r3.md b/tasks/reviews/R4.4-r3.md new file mode 100644 index 0000000..05825ca --- /dev/null +++ b/tasks/reviews/R4.4-r3.md @@ -0,0 +1,3 @@ +FAIL + +1. `apps/ely-mark/src/output/html.rs:313`:为避免嵌套链接,每个脚注都扫描全部已开的格式,新增了“格式深度 × 脚注数”的平方路径。合法输入 `"start " + "".repeat(N) + "x[^n] ".repeat(N) + "\n\n[^n]: note\n"` 在 N=32,000 时仅 288,019 字节;排除解析时间后,`opt-level=3` 的实际写入器三次中位数为 277 ms。对同一无外链输入,仅在探针对照副本中省去此扫描,HTML 逐字节相同,耗时降为 78 ms;N=8k、16k、32k 的新增扫描耗时分别为 12.6、50.7、199.4 ms,确认平方增长。`copy_rich` 在界面线程同步调用它,单是新增扫描就超过 PRD PERF-02 的 100 ms 上限。修复方向:随格式栈的打开、关闭维护链接状态,使每个脚注以常数时间判断是否已有链接;补深层格式与大量脚注组合的规模增长回归。依赖仍为测试构建,上述不是完整发行性能测量;证据与对照方法见 `.work/R4.4-r3-review/performance-control.log`、`summary.log`。
    l2