render footnotes

This commit is contained in:
Noah Hellman 2023-01-18 22:30:24 +01:00
parent cbead322ed
commit 8ccfb4c603
5 changed files with 406 additions and 14 deletions

View file

@ -618,6 +618,32 @@ mod test {
);
}
#[test]
fn parse_footnote_post() {
test_parse!(
concat!(
"[^a]\n",
"\n",
"[^a]: note\n",
"\n",
"para\n", //
),
(Enter(Leaf(Paragraph)), ""),
(Inline, "[^a]"),
(Exit(Leaf(Paragraph)), ""),
(Atom(Blankline), "\n"),
(Enter(Container(Footnote)), "a"),
(Enter(Leaf(Paragraph)), ""),
(Inline, "note"),
(Exit(Leaf(Paragraph)), ""),
(Atom(Blankline), "\n"),
(Exit(Container(Footnote)), "a"),
(Enter(Leaf(Paragraph)), ""),
(Inline, "para"),
(Exit(Leaf(Paragraph)), ""),
);
}
#[test]
fn parse_attr() {
test_parse!(
@ -754,4 +780,42 @@ mod test {
1,
);
}
#[test]
fn block_footnote_empty() {
test_block!("[^tag]:\n", Block::Container(Footnote), "tag", 1);
}
#[test]
fn block_footnote_single() {
test_block!("[^tag]: a\n", Block::Container(Footnote), "tag", 1);
}
#[test]
fn block_footnote_multiline() {
test_block!(
concat!(
"[^tag]: a\n",
" b\n", //
),
Block::Container(Footnote),
"tag",
2,
);
}
#[test]
fn block_footnote_multiline_post() {
test_block!(
concat!(
"[^tag]: a\n",
" b\n",
"\n",
"para\n", //
),
Block::Container(Footnote),
"tag",
3,
);
}
}

View file

@ -48,25 +48,31 @@ enum Raw {
Other,
}
struct Writer<I, W> {
events: I,
struct Writer<I: Iterator, W> {
events: std::iter::Peekable<I>,
out: W,
raw: Raw,
text_only: bool,
encountered_footnote: bool,
footnote_number: Option<std::num::NonZeroUsize>,
footnote_backlink_written: bool,
}
impl<'s, I: Iterator<Item = Event<'s>>, W: std::fmt::Write> Writer<I, W> {
fn new(events: I, out: W) -> Self {
Self {
events,
events: events.peekable(),
out,
raw: Raw::None,
text_only: false,
encountered_footnote: false,
footnote_number: None,
footnote_backlink_written: false,
}
}
fn write(&mut self) -> std::fmt::Result {
for e in &mut self.events {
while let Some(e) = self.events.next() {
match e {
Event::Start(c, attrs) => {
if c.is_block() {
@ -81,7 +87,18 @@ impl<'s, I: Iterator<Item = Event<'s>>, W: std::fmt::Write> Writer<I, W> {
Container::ListItem => self.out.write_str("<li")?,
Container::DescriptionList => self.out.write_str("<dl")?,
Container::DescriptionDetails => self.out.write_str("<dd")?,
Container::Footnote { .. } => todo!(),
Container::Footnote { number, .. } => {
assert!(self.footnote_number.is_none());
self.footnote_number = Some((*number).try_into().unwrap());
if !self.encountered_footnote {
self.encountered_footnote = true;
self.out
.write_str("<section role=\"doc-endnotes\">\n<hr>\n<ol>\n")?;
}
write!(self.out, "<li id=\"fn{}\">", number)?;
self.footnote_backlink_written = false;
continue;
}
Container::Table => self.out.write_str("<table")?,
Container::TableRow => self.out.write_str("<tr")?,
Container::Div { .. } => self.out.write_str("<div")?,
@ -194,11 +211,36 @@ impl<'s, I: Iterator<Item = Event<'s>>, W: std::fmt::Write> Writer<I, W> {
Container::ListItem => self.out.write_str("</li>")?,
Container::DescriptionList => self.out.write_str("</dl>")?,
Container::DescriptionDetails => self.out.write_str("</dd>")?,
Container::Footnote { .. } => todo!(),
Container::Footnote { number, .. } => {
if !self.footnote_backlink_written {
write!(
self.out,
"\n<p><a href=\"#fnref{}\" role=\"doc-backlink\">↩︎︎</a></p>",
number,
)?;
}
self.out.write_str("\n</li>")?;
self.footnote_number = None;
}
Container::Table => self.out.write_str("</table>")?,
Container::TableRow => self.out.write_str("</tr>")?,
Container::Div { .. } => self.out.write_str("</div>")?,
Container::Paragraph => self.out.write_str("</p>")?,
Container::Paragraph => {
if let Some(num) = self.footnote_number {
if matches!(
self.events.peek(),
Some(Event::End(Container::Footnote { .. }))
) {
write!(
self.out,
r##"<a href="#fnref{}" role="doc-backlink">↩︎︎</a>"##,
num
)?;
self.footnote_backlink_written = true;
}
}
self.out.write_str("</p>")?;
}
Container::Heading { level } => write!(self.out, "</h{}>", level)?,
Container::TableCell => self.out.write_str("</td>")?,
Container::DescriptionTerm => self.out.write_str("</dt>")?,
@ -268,6 +310,13 @@ impl<'s, I: Iterator<Item = Event<'s>>, W: std::fmt::Write> Writer<I, W> {
}
Event::Atom(a) => match a {
Atom::FootnoteReference(_tag, number) => {
write!(
self.out,
r##"<a id="fnref{}" href="#fn{}" role="doc-noteref"><sup>{}</sup></a>"##,
number, number, number
)?;
}
Atom::Ellipsis => self.out.write_str("&hellip;")?,
Atom::EnDash => self.out.write_str("&ndash;")?,
Atom::EmDash => self.out.write_str("&mdash;")?,
@ -279,6 +328,10 @@ impl<'s, I: Iterator<Item = Event<'s>>, W: std::fmt::Write> Writer<I, W> {
},
}
}
if self.encountered_footnote {
self.out.write_str("\n</ol>\n</section>")?;
}
self.out.write_char('\n')?;
Ok(())
}
}

View file

@ -10,6 +10,7 @@ use Container::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Atom {
FootnoteReference,
Softbreak,
Hardbreak,
Escape,
@ -111,6 +112,7 @@ impl<I: Iterator<Item = char> + Clone> Parser<I> {
self.parse_verbatim(&first)
.or_else(|| self.parse_attributes(&first))
.or_else(|| self.parse_autolink(&first))
.or_else(|| self.parse_footnote_reference(&first))
.or_else(|| self.parse_container(&first))
.or_else(|| self.parse_atom(&first))
.unwrap_or(Event {
@ -341,6 +343,52 @@ impl<I: Iterator<Item = char> + Clone> Parser<I> {
}
}
fn parse_footnote_reference(&mut self, first: &lex::Token) -> Option<Event> {
if first.kind == lex::Kind::Open(Delimiter::Bracket)
&& matches!(
self.peek(),
Some(lex::Token {
kind: lex::Kind::Sym(Symbol::Caret),
..
})
)
{
let tok = self.eat();
debug_assert_eq!(
tok,
Some(lex::Token {
kind: lex::Kind::Sym(Symbol::Caret),
len: 1,
})
);
let mut ahead = self.lexer.chars();
let mut end = false;
let len = (&mut ahead)
.take_while(|c| {
if *c == '[' {
return false;
}
if *c == ']' {
end = true;
};
!end && *c != '\n'
})
.count();
end.then(|| {
self.lexer = lex::Lexer::new(ahead);
self.span = Span::by_len(self.span.end(), len);
let ev = Event {
kind: EventKind::Atom(FootnoteReference),
span: self.span,
};
self.span = Span::by_len(self.span.end(), 1);
ev
})
} else {
None
}
}
fn parse_container(&mut self, first: &lex::Token) -> Option<Event> {
Delim::from_token(first.kind).map(|(delim, dir)| {
self.openers
@ -633,6 +681,7 @@ impl<I: Iterator<Item = char> + Clone> Iterator for Parser<I> {
#[cfg(test)]
mod test {
use super::Atom::*;
use super::Container::*;
use super::EventKind::*;
use super::Verbatim;
@ -928,6 +977,16 @@ mod test {
test_parse!("<not-a-url>", (Str, "<not-a-url>"));
}
#[test]
fn footnote_reference() {
test_parse!(
"text[^footnote]. more text",
(Str, "text"),
(Atom(FootnoteReference), "footnote"),
(Str, ". more text"),
);
}
#[test]
fn container_basic() {
test_parse!(

View file

@ -25,7 +25,7 @@ pub enum Event<'s> {
/// A string object, text only.
Str(CowStr<'s>),
/// An atomic element.
Atom(Atom),
Atom(Atom<'s>),
}
#[derive(Debug, PartialEq, Eq)]
@ -41,7 +41,7 @@ pub enum Container<'s> {
/// Details describing a term within a description list.
DescriptionDetails,
/// A footnote definition.
Footnote { tag: &'s str },
Footnote { tag: &'s str, number: usize },
/// A table element.
Table,
/// A row element of a table.
@ -212,7 +212,9 @@ pub enum OrderedListStyle {
}
#[derive(Debug, PartialEq, Eq)]
pub enum Atom {
pub enum Atom<'s> {
/// A footnote reference.
FootnoteReference(&'s str, usize),
/// A horizontal ellipsis, i.e. a set of three periods.
Ellipsis,
/// An en dash.
@ -257,7 +259,7 @@ impl<'s> Container<'s> {
match c {
block::Container::Blockquote => Self::Blockquote,
block::Container::Div => panic!(),
block::Container::Footnote => Self::Footnote { tag: content },
block::Container::Footnote => panic!(),
block::Container::ListItem => todo!(),
}
}
@ -272,6 +274,14 @@ pub struct Parser<'s> {
tree: block::Branch,
inlines: span::InlineSpans<'s>,
inline_parser: Option<inline::Parser<span::InlineCharsIter<'s>>>,
/// Footnote references in the order they were encountered, without duplicates.
footnote_references: Vec<&'s str>,
/// Cache of footnotes to emit at the end.
footnotes: std::collections::HashMap<&'s str, block::Branch>,
/// Next or current footnote being parsed and emitted.
footnote_index: usize,
/// Currently within a footnote.
footnote_active: bool,
}
impl<'s> Parser<'s> {
@ -305,6 +315,10 @@ impl<'s> Parser<'s> {
_tree_data: tree,
link_definitions,
tree: branch,
footnote_references: Vec::new(),
footnotes: std::collections::HashMap::new(),
footnote_index: 0,
footnote_active: false,
inlines: span::InlineSpans::new(src),
inline_parser: None,
}
@ -389,6 +403,30 @@ impl<'s> Parser<'s> {
}
}
inline::EventKind::Atom(a) => Event::Atom(match a {
inline::Atom::FootnoteReference => {
let tag = match self.inlines.src(inline.span) {
CowStr::Borrowed(s) => s,
CowStr::Owned(..) => panic!(),
};
let number = self
.footnote_references
.iter()
.position(|t| *t == tag)
.map_or_else(
|| {
self.footnote_references.push(tag);
self.footnote_references.len()
},
|i| i + 1,
);
Atom::FootnoteReference(
match self.inlines.src(inline.span) {
CowStr::Borrowed(s) => s,
CowStr::Owned(..) => panic!(),
},
number,
)
}
inline::Atom::Ellipsis => Atom::Ellipsis,
inline::Atom::EnDash => Atom::EnDash,
inline::Atom::EmDash => Atom::EmDash,
@ -439,6 +477,10 @@ impl<'s> Parser<'s> {
block::Container::Div { .. } => Container::Div {
class: (!ev.span.is_empty()).then(|| content),
},
block::Container::Footnote => {
self.footnotes.insert(content, self.tree.take_branch());
continue;
}
_ => Container::from_container_block(content, c),
};
Event::Start(container, attributes)
@ -456,13 +498,43 @@ impl<'s> Parser<'s> {
}
None
}
fn footnote(&mut self) -> Option<Event<'s>> {
if self.footnote_active {
let tag = self.footnote_references.get(self.footnote_index).unwrap();
self.footnote_index += 1;
self.footnote_active = false;
Some(Event::End(Container::Footnote {
tag,
number: self.footnote_index,
}))
} else if let Some(tag) = self.footnote_references.get(self.footnote_index) {
self.tree = self
.footnotes
.remove(tag)
.unwrap_or_else(block::Branch::empty);
self.footnote_active = true;
Some(Event::Start(
Container::Footnote {
tag,
number: self.footnote_index + 1,
},
Attributes::new(),
))
} else {
None
}
}
}
impl<'s> Iterator for Parser<'s> {
type Item = Event<'s>;
fn next(&mut self) -> Option<Self::Item> {
self.inline().or_else(|| self.block())
self.inline()
.or_else(|| self.block())
.or_else(|| self.footnote())
}
}
@ -730,6 +802,144 @@ mod test {
);
}
#[test]
fn footnote_references() {
test_parse!(
"[^a][^b][^c]",
Start(Paragraph, Attributes::new()),
Atom(FootnoteReference("a", 1)),
Atom(FootnoteReference("b", 2)),
Atom(FootnoteReference("c", 3)),
End(Paragraph),
Start(
Footnote {
tag: "a",
number: 1
},
Attributes::new()
),
End(Footnote {
tag: "a",
number: 1
}),
Start(
Footnote {
tag: "b",
number: 2
},
Attributes::new()
),
End(Footnote {
tag: "b",
number: 2
}),
Start(
Footnote {
tag: "c",
number: 3
},
Attributes::new()
),
End(Footnote {
tag: "c",
number: 3
}),
);
}
#[test]
fn footnote() {
test_parse!(
"[^a]\n\n[^a]: a\n",
Start(Paragraph, Attributes::new()),
Atom(FootnoteReference("a", 1)),
End(Paragraph),
Atom(Blankline),
Start(
Footnote {
tag: "a",
number: 1
},
Attributes::new()
),
Start(Paragraph, Attributes::new()),
Str("a".into()),
End(Paragraph),
End(Footnote {
tag: "a",
number: 1
}),
);
}
#[test]
fn footnote_multiblock() {
test_parse!(
concat!(
"[^a]\n",
"\n",
"[^a]: abc\n",
"\n",
" def", //
),
Start(Paragraph, Attributes::new()),
Atom(FootnoteReference("a", 1)),
End(Paragraph),
Atom(Blankline),
Start(
Footnote {
tag: "a",
number: 1
},
Attributes::new()
),
Start(Paragraph, Attributes::new()),
Str("abc".into()),
End(Paragraph),
Atom(Blankline),
Start(Paragraph, Attributes::new()),
Str("def".into()),
End(Paragraph),
End(Footnote {
tag: "a",
number: 1
}),
);
}
#[test]
fn footnote_post() {
test_parse!(
concat!(
"[^a]\n",
"\n",
"[^a]: note\n",
"para\n", //
),
Start(Paragraph, Attributes::new()),
Atom(FootnoteReference("a", 1)),
End(Paragraph),
Atom(Blankline),
Start(Paragraph, Attributes::new()),
Str("para".into()),
End(Paragraph),
Start(
Footnote {
tag: "a",
number: 1
},
Attributes::new()
),
Start(Paragraph, Attributes::new()),
Str("note".into()),
End(Paragraph),
End(Footnote {
tag: "a",
number: 1
}),
);
}
#[test]
fn attr_block() {
test_parse!(

View file

@ -50,6 +50,14 @@ pub struct Branch<C: 'static, A: 'static> {
}
impl<C, A> Branch<C, A> {
pub fn empty() -> Self {
Self {
nodes: &[],
branch: Vec::new(),
head: None,
}
}
/// Count number of direct children nodes.
pub fn count_children(&self) -> usize {
let mut head = self.head;
@ -62,8 +70,6 @@ impl<C, A> Branch<C, A> {
count
}
/// Split off the remaining part of the current branch. The returned [`Branch`] will continue on
/// the branch, this [`Branch`] will skip over the current branch.
pub fn take_branch(&mut self) -> Self {
let head = self.head.take();
self.head = self.branch.pop();