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