documents: the drawing's reading rules are public, for exports to share
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 10m31s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

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) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 00:59:03 -04:00
1 parent 8e5dad8177
commit 97fa047d18
5 files changed
+159 -102

No files matched your search

+4
View File
@@ -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};
+90 -76
View File
@@ -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 @ ..] = &quote.children[..] else {
return None;
};
let (kind, after) = marker(&paragraph.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<Node>) {
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("<!--") {
rest = comment.split_once("-->").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("<!--") {
rest = comment.split_once("-->").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<mdast::Node>)> {
let [mdast::Node::Paragraph(paragraph), rest @ ..] = &quote.children[..] else {
return None;
};
let (kind, after) = marker(&paragraph.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<DocumentHeading> {
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<mdast::Node>)> {
let mut line = String::new();
+3 -3
View File
@@ -49,7 +49,7 @@ fn candidate(text: &str) -> Option<usize> {
/// 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<bool>,
/// 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())];
+46
View File
@@ -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<Style>,
href: Option<String>,
},
Close(String),
Break,
/// A filtered tag, shown as its text.
Text(String),
/// Gone; the text around it stays.
Gone,
}
/// What the inline HTML `written` does.
pub fn inline_tag(written: &str) -> InlineTag {
let Some(tag) = tag(written) else {
return InlineTag::Gone;
};
if filtered(&tag.name) {
return InlineTag::Text(text(written));
}
if tag.name == "br" {
return InlineTag::Break;
}
let Some(style) = inline_style(&tag.name) else {
return InlineTag::Gone;
};
if tag.closing {
return InlineTag::Close(tag.name);
}
let href = match style {
Style::Link => tag.attribute("href").map(str::to_string),
_ => None,
};
let style = (style != Style::Link || href.is_some()).then_some(style);
InlineTag::Open {
name: tag.name,
style,
href,
}
}
/// Start tags that end an open `p`, as HTML5 lists them.
pub(crate) fn closes_p(name: &str) -> bool {
matches!(
+16 -23
View File
@@ -8,7 +8,9 @@ mod blocks;
mod emoji;
mod html;
pub use blocks::{AlertKind, DocumentHeading};
pub use blocks::{AlertKind, DocumentHeading, alert, headings, html_block_text, is_toc};
pub use emoji::Shortcodes;
pub use html::{InlineTag, inline_tag};
/// How a stretch of inline text prints.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -156,28 +158,19 @@ impl Run {
/// Opens or closes an element for an inline HTML tag.
fn html(&mut self, written: &str) {
let Some(tag) = html::tag(written) else {
return self.shortcodes.split();
};
if html::filtered(&tag.name) {
return self.push_text(&html::text(written));
match html::inline_tag(written) {
InlineTag::Text(shown) => self.push_text(&shown),
InlineTag::Open { name, style, href } => self.start(&name, style, href),
InlineTag::Close(name) => {
self.shortcodes.split();
self.end(&name);
}
InlineTag::Break => {
self.shortcodes.split();
self.text.text.push('\n');
}
InlineTag::Gone => self.shortcodes.split(),
}
self.shortcodes.split();
if tag.name == "br" {
return self.text.text.push('\n');
}
let Some(style) = html::inline_style(&tag.name) else {
return;
};
if tag.closing {
return self.end(&tag.name);
}
let url = match style {
Style::Link => tag.attribute("href").map(str::to_string),
_ => None,
};
let style = (style != Style::Link || url.is_some()).then_some(style);
self.start(&tag.name, style, url);
}
/// Opens `name`; a new `a` ends the open one.
@@ -366,7 +359,7 @@ impl<'a> Reader<'a> {
},
body: self.nested(&note.children),
}),
mdast::Node::Html(html) => self.html_block(&html.value, out),
mdast::Node::Html(html) => text_block(html_block_text(&html.value), out),
mdast::Node::Yaml(yaml) => out.push(Node::FrontMatter(yaml.value.clone())),
mdast::Node::Toml(toml) => out.push(Node::FrontMatter(toml.value.clone())),
mdast::Node::Definition(_) => {}