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) <noreply@anthropic.com>
This commit is contained in:
1 parent
9d80ba6f39
commit
256240c365
16 files changed
+728
-192
No files matched your search
Generated
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ jiff = "0.2"
|
||||
anyhow = "1"
|
||||
directories = "6"
|
||||
dunce = "1"
|
||||
ely-gpui-component = { git = "https://git.zacharyzhang.com/ZacharyZhang-NY/Ely-GPUI-Components", rev = "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"] }
|
||||
|
||||
@@ -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 即只用源码并转去源码(随当前文字,不只看打开时);网页实现的查找替换、表格、附件、书写视图的应用、导出与打印Line truncated
|
||||
- 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:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `<code>` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`</br>` 照 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:` 为蟋蟀),转义与字符引用之后照样是表情,自动链接与 `<code>` 里不是,同一 DOM 文字节点里第一个 `:名:` 前为空白或空格才全部转、否则全不转(`x:smile: :smile:` 原样,过滤标签不分节点),HTML 标签与强调交叉时照浏览器修复 CommonMark 输出,`</br>` 照 HTML5 也是换行,GFM 标签过滤的九种(`script`、`style` 等)照原文显示,提示块的标记读解析后的纯文字;Typora 只决定哪些扩展显示。导出、纯文本与富文本复制经组件 `MarkdownReader` 照可视读每个段落、标题、单元格与 HTML 块(各自一套新栈,块尾关闭未闭合的标签):脚注在原处、写作 `[号]`,链接不带标题(可视不显示),取代 C14.14 的页末脚注与 HTML 一律转义。审查改用 gpt-6-astra,effort max,fast。
|
||||
@@ -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("<h1>Note</h1>") && html.contains("<svg"),
|
||||
html.contains("<h1 id=\"h-0\">Note</h1>") && html.contains("<svg"),
|
||||
"{html}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Addresses, read as browsers read them.
|
||||
|
||||
/// A plain, web or mail address, read as browsers do.
|
||||
pub(super) fn safe(url: &str) -> 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)
|
||||
}
|
||||
+358
-156
@@ -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<String, (String, Option<String>)>,
|
||||
notes: HashMap<String, Node>,
|
||||
/// Footnotes in the order first called.
|
||||
called: Vec<String>,
|
||||
source: &'c str,
|
||||
reader: MarkdownReader<'c>,
|
||||
/// Notes whose definition has its anchor.
|
||||
anchored: HashSet<usize>,
|
||||
}
|
||||
|
||||
/// What a read area leaves out, in reading order.
|
||||
#[derive(Default)]
|
||||
struct Atoms {
|
||||
/// Each formula's line.
|
||||
formulas: VecDeque<usize>,
|
||||
/// Each footnote reference's number.
|
||||
notes: VecDeque<usize>,
|
||||
}
|
||||
|
||||
/// 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!("</{tag}>"));
|
||||
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("<blockquote>");
|
||||
self.blocks(node, false)?;
|
||||
self.push("</blockquote>");
|
||||
}
|
||||
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!("<input type=\"checkbox\" disabled{mark}> "));
|
||||
}
|
||||
self.children(item, !list.spread && !each.spread)?;
|
||||
self.blocks(item, !list.spread && !each.spread)?;
|
||||
self.push("</li>");
|
||||
}
|
||||
self.push(&format!("</{tag}>"));
|
||||
}
|
||||
Node::Table(table) => self.table(node, &table.align)?,
|
||||
Node::Code(code) if code.lang.as_deref() == Some("mermaid") => {
|
||||
let line = line_of(node);
|
||||
match diagram_svg(&code.value) {
|
||||
Ok(svg) => self.push(&format!("<div class=\"diagram\">{}</div>", bare(&svg))),
|
||||
Err(_) => {
|
||||
self.page.diagrams.push(line);
|
||||
self.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!("<code>{}</code>", escape(&code.value))),
|
||||
Node::Text(text) => self.push(&escape(&text.value)),
|
||||
// HTML in the document shows as text, never runs.
|
||||
Node::Html(html) => self.push(&escape(&html.value)),
|
||||
Node::Break(_) => self.push("<br>"),
|
||||
Node::Math(math) => self.math(line_of(node), &math.value, true),
|
||||
Node::ThematicBreak(_) => self.push("<hr>"),
|
||||
Node::Link(link) => self.link(node, &link.url, link.title.as_deref())?,
|
||||
Node::LinkReference(link) => match self.links.get(&link.identifier).cloned() {
|
||||
Some((url, title)) => self.link(node, &url, title.as_deref())?,
|
||||
None => self.children(node, false)?,
|
||||
},
|
||||
Node::Image(image) => self.picture(&image.url, &image.alt, image.title.as_deref())?,
|
||||
Node::ImageReference(image) => match self.links.get(&image.identifier).cloned() {
|
||||
Some((url, title)) => self.picture(&url, &image.alt, title.as_deref())?,
|
||||
None => self.push(&escape(&image.alt)),
|
||||
},
|
||||
Node::FootnoteReference(call) => {
|
||||
let number = match self.called.iter().position(|id| *id == call.identifier) {
|
||||
Some(at) => at + 1,
|
||||
None => {
|
||||
self.called.push(call.identifier.clone());
|
||||
self.called.len()
|
||||
}
|
||||
};
|
||||
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"));
|
||||
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!(
|
||||
"<div{id} style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[{}]</span><div>",
|
||||
escape(&self.reader.label(note))
|
||||
));
|
||||
self.blocks(node, false)?;
|
||||
self.push("</div></div>");
|
||||
}
|
||||
// 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<Atoms>,
|
||||
) -> 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<Atoms> {
|
||||
let mut out = VecDeque::from([Atoms::default()]);
|
||||
self.gather(node, split, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn gather(&self, node: &Node, split: bool, out: &mut VecDeque<Atoms>) {
|
||||
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<MarkdownBlock>],
|
||||
rows: &[Vec<Vec<MarkdownBlock>>],
|
||||
) -> io::Result<()> {
|
||||
let written = node.children().expect("a table holds rows");
|
||||
let read: Vec<&[Vec<MarkdownBlock>]> = 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("<table>");
|
||||
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("<tr>");
|
||||
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!("</{cell}>"));
|
||||
self.push(&format!("<{tag}{side}>"));
|
||||
let stretches = self.stretches(cell, true);
|
||||
self.run(pieces, cell, true, true, stretches)?;
|
||||
self.push(&format!("</{tag}>"));
|
||||
}
|
||||
self.push("</tr>");
|
||||
}
|
||||
@@ -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("<p>");
|
||||
self.inline(text, atoms);
|
||||
self.push("</p>");
|
||||
}
|
||||
MarkdownBlock::Heading(depth, text) => {
|
||||
let at = node.position().expect("a parsed node has its place");
|
||||
self.push(&format!("<h{depth} id=\"h-{}\">", at.start.offset));
|
||||
self.inline(text, atoms);
|
||||
self.push(&format!("</h{depth}>"));
|
||||
}
|
||||
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<usize>, 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!("<sup>{number}</sup>")),
|
||||
false => {
|
||||
self.push(&format!("<sup><a href=\"#fn-{number}\">{number}</a></sup>"))
|
||||
}
|
||||
}
|
||||
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!("<a href=\"{}\">", attribute(url))),
|
||||
false => self.push("<a>"),
|
||||
}
|
||||
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!("</{name}>"));
|
||||
*at = end;
|
||||
open.pop();
|
||||
links += usize::from(name == "a");
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
fn text(&mut self, text: &str) {
|
||||
self.push(&escape(text).replace('\n', "<br>"));
|
||||
}
|
||||
|
||||
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!("<pre><code{class}>{}</code></pre>", 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!("<div class=\"math\">{}</div>", bare(&svg))),
|
||||
(Ok(svg), false) => self.push(&format!("<span class=\"math\">{}</span>", bare(&svg))),
|
||||
(Err(error), _) => {
|
||||
self.page.formulas.push((line_of(node), error.to_string()));
|
||||
self.page.formulas.push((line, error.to_string()));
|
||||
match display {
|
||||
true => self.code(Some("math"), value),
|
||||
false => self.push(&format!("<code>{}</code>", 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!("<a href=\"{}\"{title}>", attribute(url))),
|
||||
false => self.push("<a>"),
|
||||
/// 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!(
|
||||
"<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid {hue}\"><p style=\"font-weight:600;color:{hue}\">{title}</p>"
|
||||
));
|
||||
for node in body {
|
||||
self.block(node, false)?;
|
||||
}
|
||||
self.children(node, false)?;
|
||||
self.push("</a>");
|
||||
self.push("</div>");
|
||||
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!(
|
||||
"<li style=\"margin-left:{}em\"><a href=\"#h-{}\">{}</a></li>",
|
||||
heading.depth - 1,
|
||||
heading.at,
|
||||
escape(&heading.title)
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
self.push(&format!(
|
||||
"<ul style=\"list-style:none;padding-left:0\">{items}</ul>"
|
||||
));
|
||||
}
|
||||
|
||||
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!(
|
||||
"<img src=\"{}\" alt=\"{}\"{title}>",
|
||||
let image = format!(
|
||||
"<img src=\"{}\" alt=\"{}\">",
|
||||
attribute(&src),
|
||||
attribute(alt)
|
||||
));
|
||||
);
|
||||
match link {
|
||||
Some(link) if safe(link) => {
|
||||
self.push(&format!("<a href=\"{}\">{image}</a>", attribute(link)))
|
||||
}
|
||||
Some(_) => self.push(&format!("<a>{image}</a>")),
|
||||
None => self.push(&image),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn footnotes(&mut self) -> io::Result<()> {
|
||||
if self.called.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.push("<section class=\"footnotes\"><hr><ol>");
|
||||
// A note may call another note.
|
||||
let mut at = 0;
|
||||
while at < self.called.len() {
|
||||
let note = self.notes.get(&self.called[at]).cloned();
|
||||
at += 1;
|
||||
self.push(&format!("<li id=\"fn-{at}\">"));
|
||||
match note {
|
||||
Some(note) => self.children(¬e, false)?,
|
||||
None => log::error!("mark: footnote {at} has no definition"),
|
||||
}
|
||||
self.push("</li>");
|
||||
}
|
||||
self.push("</ol></section>");
|
||||
Ok(())
|
||||
/// A stretch done: all read, or blank and dropped.
|
||||
fn retire(atoms: Option<Atoms>, 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("<svg").map_or(svg, |at| &svg[at..])
|
||||
}
|
||||
|
||||
/// A plain, web or mail address, read as browsers do.
|
||||
fn safe(url: &str) -> 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)
|
||||
}
|
||||
@@ -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<Page> {
|
||||
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(),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<String> {
|
||||
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<String>) {
|
||||
fn block(reader: &MarkdownReader, node: &Node, blocks: &mut Vec<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
}
|
||||
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::<Vec<_>>().join("\t")
|
||||
});
|
||||
blocks.push(rows.collect::<Vec<_>>().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
|
||||
}
|
||||
@@ -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 [
|
||||
"<h1>T</h1>",
|
||||
"<h1 id=\"h-0\">T</h1>",
|
||||
"<li><input type=\"checkbox\" disabled checked> done</li>",
|
||||
"<ol start=\"3\">",
|
||||
"<th style=\"text-align:left\">l</th>",
|
||||
"<td style=\"text-align:right\">2</td>",
|
||||
"<del>gone</del>",
|
||||
"<a href=\"https://x.org\" title=\"t\">a</a>",
|
||||
"<a href=\"https://x.org\">a</a>",
|
||||
"<a href=\"/docs\">r</a>",
|
||||
"<a>bad</a>",
|
||||
"<sup><a href=\"#fn-1\">1</a></sup>",
|
||||
"<li id=\"fn-1\"><p>The note.</p></li>",
|
||||
"<div id=\"fn-1\" style=\"display:flex;gap:.5em;font-size:.9em;color:#59636e\"><span>[1]</span><div><p>The note.</p></div></div>",
|
||||
] {
|
||||
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("<p>The last line.</p>"));
|
||||
assert!(
|
||||
page(&text, None, &Pictures::Inline)
|
||||
.html
|
||||
.ends_with("<p>The last line.</p>")
|
||||
);
|
||||
}
|
||||
@@ -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 <code>:smile:</code> :smile:\n");
|
||||
for part in [
|
||||
"<p>Go 😄 x😄</p>",
|
||||
"<p>x:smile: :smile:</p>",
|
||||
"<p>a <code>:smile:</code> 😄</p>",
|
||||
] {
|
||||
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 <u>u</u> <mark>m</mark> <kbd>k</kbd> <span>s</span> <script>x</script> \
|
||||
<a href=\"javascript:x\">j</a> <a href=\"https://e.test\">e</a>\n";
|
||||
let page = html(source);
|
||||
for part in [
|
||||
"<u>u</u>",
|
||||
"<mark>m</mark>",
|
||||
"<code>k</code>",
|
||||
" s ",
|
||||
"<script>x</script>",
|
||||
"<a>j</a>",
|
||||
"<a href=\"https://e.test\">e</a>",
|
||||
] {
|
||||
assert!(page.contains(part), "{part} in {page}");
|
||||
}
|
||||
assert!(
|
||||
!page.contains("<span") && !page.contains("<script"),
|
||||
"{page}"
|
||||
);
|
||||
assert_eq!(
|
||||
text("a <b>b</b> <script>x</script>\n"),
|
||||
"a b <script>x</script>\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_html_block_shows_its_text() {
|
||||
let source = "<div align=\"center\">\n <b>Bold</b> and :smile:\n</div>\n";
|
||||
let page = html(source);
|
||||
assert!(
|
||||
page.contains("<p><strong>Bold</strong> and 😄</p>"),
|
||||
"{page}"
|
||||
);
|
||||
assert!(!page.contains("<div"), "{page}");
|
||||
assert_eq!(text(source), "Bold and 😄\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_writes_its_kind_and_title() {
|
||||
let page = html("> [!NOTE]\n> Read this.\n\n- > [!TIP]\n > Not here.\n");
|
||||
let alert = "<div style=\"padding:.25em 1em;margin:1em 0;border-left:.25em solid #0969da\"><p style=\"font-weight:600;color:#0969da\">Note</p><p>Read this.</p></div>";
|
||||
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 = "<ul style=\"list-style:none;padding-left:0\"><li style=\"margin-left:0em\"><a href=\"#h-7\">A</a></li><li style=\"margin-left:1em\"><a href=\"#h-12\">B 😄</a></li></ul>";
|
||||
assert!(page.contains(toc), "{page}");
|
||||
assert!(page.contains("<h1 id=\"h-7\">A</h1>") && page.contains("<h2 id=\"h-12\">B 😄</h2>"));
|
||||
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 <code>b* :smile:</code> :smile:\n", "a b :smile: 😄"),
|
||||
("a <code>*b</code>* :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 <code>x\n\n:smile:\n");
|
||||
assert!(page.contains("<p>😄</p>"), "{page}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_closes_the_tags_left_open_in_it() {
|
||||
let page = html("a <b>bold\n\nnormal\n");
|
||||
assert!(
|
||||
page.contains("<p>a <strong>bold</strong></p><p>normal</p>"),
|
||||
"{page}"
|
||||
);
|
||||
let page = html("a <a href=\"https://example.test\">link\n\nnormal\n");
|
||||
assert!(page.ends_with("<p>normal</p>"), "{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!("<div>{}</div>\n", "<b>x</b> ".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
|
||||
",
|
||||
"<b>".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",
|
||||
"$ $ \n",
|
||||
"| $ $ | a |\n| - | - |\n| b | c |\n",
|
||||
] {
|
||||
html(source);
|
||||
}
|
||||
let page = page("$ $ \nnext $\\sqrt{$\n", None, &Pictures::Inline);
|
||||
let lines: Vec<usize> = 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 <a href=\"https://example.test\">b[^n] c</a> after\n\n[^n]: note\n");
|
||||
assert!(
|
||||
page.contains("<a href=\"https://example.test\">b<sup>1</sup> c</a>"),
|
||||
"{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}"
|
||||
);
|
||||
}
|
||||
@@ -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 \
|
||||
|
||||
+1
-1
@@ -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 空白分隔;`</br>` 照 HTML5 换行。R3 FAIL 五项已修(三轮用尽,未再审):Markdown 容器即 GitHub 写出的元素(em、strong、del、a),与标签同栈照 HTML5 修复:关闭标签结束同名元素并弹出其上各元素,只有格式元素重开,`mark`、`kbd`、`del` 止于浏览器止处,新的 `a` 先结束已开的 `a`;表情在解码后的正文里认(`\:smile:`、`:smile:` 照 GitHub 为表情,用户定 GitHub 优先,推翻 R1 第一项的转义部分),自动链接与 `<code>` 里不认(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、脚注、链接定义、前后空段落;删除块),一步撤销,拒绝说明原因
|
||||
|
||||
@@ -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 <code>b* :smile:</code> :smile:`,组件读出 `a b :smile: 😄`,HTML 与纯文本却把两个短码都转成表情;反向交叉 `a <code>*b</code>* :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 <code>x\n\n:smile:` 的下一段导出为短码,可视模式为表情。修复方向:按组件的段落、标题、单元格边界建立独立读取状态,涵盖紧凑列表,并补跨边界回归。证据:同一日志中的单元格与标题探针失败。
|
||||
|
||||
3. `apps/ely-mark/src/output/html.rs:121`:结束可视块时没有结束其中未闭合的行内标签;浏览器修复嵌套会把格式或链接延续到下一块。输入 `a <b>bold\n\nnormal`,组件第二段的样式为空,导出 `<p>a <b>bold</p><p>normal</p>` 经 HTML5 解析后第二段为 `<p><b>normal</b></p>`。换成未闭合的 `<a href="https://example.test">`,下一段还会错误地成为链接。修复方向:在可视块边界结束该块的行内标签,保留块内约定的嵌套修复行为;测试实际解析后的格式和链接范围。证据:`.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 的写出退化为平方复杂度。输入 `<div>` 加 64,000 次 `<b>x</b> ` 再加 `</div>`,只有 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`。
|
||||
@@ -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`;`$ $ ` 和含空白公式的表格也复现。修复方向:让公式位置与实际留下的可视片段保持对应,正确处理被略去的空白片段,不能只删断言而让后续公式消费错误的行号;补这三类回归。证据:`.work/R4.4-r2-review/probes.log`、`browser-regressions.log`。
|
||||
|
||||
2. `apps/ely-mark/src/output/html.rs:249`:脚注引用无条件写入自己的 `<a>`,会在已有链接内生成嵌套锚点,浏览器修复后丢失可视模式的链接范围和上标。输入 `a <a href="https://example.test">b[^n] c</a> after\n\n[^n]: note`,组件的链接覆盖 `b¹ c`;实际 Chromium 解析导出后,外链只剩 `b`,`c` 不再是链接,脚注数字也被移出 `<sup>`。修复方向:脚注输出须遵守当前链接栈,避免嵌套 `<a>`,并保留后续文字的链接范围;用解析后的 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`。
|
||||
@@ -0,0 +1,3 @@
|
||||
FAIL
|
||||
|
||||
1. `apps/ely-mark/src/output/html.rs:313`:为避免嵌套链接,每个脚注都扫描全部已开的格式,新增了“格式深度 × 脚注数”的平方路径。合法输入 `"start " + "<b>".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`。
|
||||
Reference in new issue
Block a user