From 97fa047d183c483b100ce9ef6a87faf9d6a4dea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 00:59:03 -0400 Subject: [PATCH] documents: the drawing's reading rules are public, for exports to share Shortcodes, inline_tag, html_block_text, alert, is_toc and headings are what the drawing itself calls, so an export that calls them shows the same. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/documents/mod.rs | 4 + src/documents/tree/blocks.rs | 166 +++++++++++++++++++---------------- src/documents/tree/emoji.rs | 6 +- src/documents/tree/html.rs | 46 ++++++++++ src/documents/tree/mod.rs | 39 ++++---- 5 files changed, 159 insertions(+), 102 deletions(-) diff --git a/src/documents/mod.rs b/src/documents/mod.rs index 254ada7..88ddc32 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -57,6 +57,10 @@ pub use templates::{Template, TemplatePicker}; pub use thumbs::PageThumbnailList; pub use todo::{Checklist, TodoItem}; pub use toolbar::FixedFormatToolbar; +pub use tree::{ + AlertKind, Inline, InlineTag, Shortcodes, Style, alert, headings, html_block_text, inline_tag, + is_toc, +}; pub(crate) use viewer::step as zoom_step; pub use viewer::{DocPage, DocumentViewer, PageHit, PageNote}; diff --git a/src/documents/tree/blocks.rs b/src/documents/tree/blocks.rs index 797bbbf..3d283fb 100644 --- a/src/documents/tree/blocks.rs +++ b/src/documents/tree/blocks.rs @@ -1,7 +1,7 @@ use markdown::mdast; -use super::{Node, Reader, Run, html, text_block}; -use crate::documents::markdown::span; +use super::{Inline, Node, Reader, Run, html}; +use crate::documents::markdown::{file_tree, span}; /// A heading anywhere in the file. #[derive(Clone, Debug, PartialEq)] @@ -58,9 +58,7 @@ impl Reader<'_> { if let Some(alert) = self.alert(node) { return out.push(alert); } - let toc = matches!(node, mdast::Node::Paragraph(_)) - && self.source[span(node)].trim().eq_ignore_ascii_case("[toc]"); - match toc { + match is_toc(node, self.source) { true => out.push(Node::Toc(self.headings.clone())), false => self.block(node, out), } @@ -71,87 +69,74 @@ impl Reader<'_> { let mdast::Node::Blockquote(quote) = node else { return None; }; - let [mdast::Node::Paragraph(paragraph), rest @ ..] = "e.children[..] else { - return None; - }; - let (kind, after) = marker(¶graph.children)?; - if after.is_empty() && rest.is_empty() { - return None; - } + let (kind, parts) = alert(quote)?; let mut body = Vec::new(); - if !after.is_empty() { - let kept = mdast::Paragraph { - children: after, - position: paragraph.position.clone(), - }; - self.block(&mdast::Node::Paragraph(kept), &mut body); - } - self.blocks(rest, &mut body); + self.blocks(&parts, &mut body); Some(Node::Alert { kind, body }) } +} - /// An HTML block's text, as a browser shows it. - pub(super) fn html_block(&self, written: &str, out: &mut Vec) { - let mut run = Run::default(); - let mut rest = written; - while let Some(at) = rest.find('<') { - run.words(&rest[..at]); - rest = &rest[at..]; - if let Some(comment) = rest.strip_prefix("").map_or("", |(_, after)| after); - run.shortcodes.split(); - continue; - } - let opens = - rest[1..].starts_with(|ch: char| ch.is_ascii_alphabetic() || "/!?".contains(ch)); - if !opens { - run.words("<"); - rest = &rest[1..]; - continue; - } - // An unclosed tag drops the rest, as HTML5 does. - let Some(end) = html::tag_end(rest) else { - rest = ""; - break; - }; - let (written, after) = rest.split_at(end); - rest = after; - match html::tag(written) { - Some(tag) if html::filtered(&tag.name) => run.words(written), - // A block closes what opened inside it. - Some(tag) if html::breaks(&tag.name) => { - run.line(); - match tag.closing { - true => run.end(&tag.name), - false => { - run.implied(&tag.name); - match tag.name == "hr" { - true => run.shortcodes.split(), - false => run.start(&tag.name, None, None), - } +/// An HTML block's text, as a browser shows it. +pub fn html_block_text(written: &str) -> Inline { + let mut run = Run::default(); + let mut rest = written; + while let Some(at) = rest.find('<') { + run.words(&rest[..at]); + rest = &rest[at..]; + if let Some(comment) = rest.strip_prefix("").map_or("", |(_, after)| after); + run.shortcodes.split(); + continue; + } + let opens = + rest[1..].starts_with(|ch: char| ch.is_ascii_alphabetic() || "/!?".contains(ch)); + if !opens { + run.words("<"); + rest = &rest[1..]; + continue; + } + // An unclosed tag drops the rest, as HTML5 does. + let Some(end) = html::tag_end(rest) else { + rest = ""; + break; + }; + let (written, after) = rest.split_at(end); + rest = after; + match html::tag(written) { + Some(tag) if html::filtered(&tag.name) => run.words(written), + // A block closes what opened inside it. + Some(tag) if html::breaks(&tag.name) => { + run.line(); + match tag.closing { + true => run.end(&tag.name), + false => { + run.implied(&tag.name); + match tag.name == "hr" { + true => run.shortcodes.split(), + false => run.start(&tag.name, None, None), } } } - // It ends a `p` but lays no block. - Some(tag) if !tag.closing && html::closes_p(&tag.name) => { - run.implied(&tag.name); - run.html(written); - } - // A line's end drops its space. - Some(tag) if tag.closing || tag.name == "br" => { - run.owed = run.owed.filter(|_| tag.name != "br"); - run.html(written); - } - Some(_) => { - run.pay(); - run.html(written); - } - None => run.shortcodes.split(), } + // It ends a `p` but lays no block. + Some(tag) if !tag.closing && html::closes_p(&tag.name) => { + run.implied(&tag.name); + run.html(written); + } + // A line's end drops its space. + Some(tag) if tag.closing || tag.name == "br" => { + run.owed = run.owed.filter(|_| tag.name != "br"); + run.html(written); + } + Some(_) => { + run.pay(); + run.html(written); + } + None => run.shortcodes.split(), } - run.words(rest); - text_block(run.cut(), out); } + run.words(rest); + run.cut() } impl Run { @@ -223,6 +208,35 @@ impl Run { } } +/// A quote's alert kind and its blocks after the marker. +pub fn alert(quote: &mdast::Blockquote) -> Option<(AlertKind, Vec)> { + let [mdast::Node::Paragraph(paragraph), rest @ ..] = "e.children[..] else { + return None; + }; + let (kind, after) = marker(¶graph.children)?; + if after.is_empty() && rest.is_empty() { + return None; + } + let head = (!after.is_empty()).then(|| { + mdast::Node::Paragraph(mdast::Paragraph { + children: after, + position: paragraph.position.clone(), + }) + }); + Some((kind, head.into_iter().chain(rest.iter().cloned()).collect())) +} + +/// A paragraph that is `[toc]` alone, as Typora reads it. +pub fn is_toc(node: &mdast::Node, source: &str) -> bool { + matches!(node, mdast::Node::Paragraph(_)) + && source[span(node)].trim().eq_ignore_ascii_case("[toc]") +} + +/// Every heading in `text`, as `[toc]` lists them. +pub fn headings(text: &str) -> Vec { + Reader::new(text, &file_tree(text)).headings +} + /// A plain first line naming a kind, and what follows. fn marker(children: &[mdast::Node]) -> Option<(AlertKind, Vec)> { let mut line = String::new(); diff --git a/src/documents/tree/emoji.rs b/src/documents/tree/emoji.rs index aa0ee3f..f737380 100644 --- a/src/documents/tree/emoji.rs +++ b/src/documents/tree/emoji.rs @@ -49,7 +49,7 @@ fn candidate(text: &str) -> Option { /// One DOM text node's shortcodes, as GitHub reads them. #[derive(Default)] -pub(crate) struct Shortcodes { +pub struct Shortcodes { /// Settled by the node's first `:name:`. reads: Option, /// Text before it held more than white space. @@ -59,12 +59,12 @@ pub(crate) struct Shortcodes { impl Shortcodes { /// A new DOM text node starts. - pub(crate) fn split(&mut self) { + pub fn split(&mut self) { *self = Self::default(); } /// Blank or spaced text before the first reads them all. - pub(crate) fn read(&mut self, text: &str) -> String { + pub fn read(&mut self, text: &str) -> String { if self.reads.is_none() { let at = candidate(text); let lead = &text[..at.unwrap_or(text.len())]; diff --git a/src/documents/tree/html.rs b/src/documents/tree/html.rs index 0f0d96f..64acec5 100644 --- a/src/documents/tree/html.rs +++ b/src/documents/tree/html.rs @@ -96,6 +96,52 @@ pub(crate) fn breaks(name: &str) -> bool { ) } +/// What one inline HTML tag does, as GitHub shows it. +#[derive(Clone, Debug, PartialEq)] +pub enum InlineTag { + /// Opens `name`; an anchor styles only with an address. + Open { + name: String, + style: Option