diff options
Diffstat (limited to 'src/md')
-rw-r--r-- | src/md/panhelper.rs | 26 | ||||
-rw-r--r-- | src/md/typeset.rs | 229 | ||||
-rw-r--r-- | src/md/visitor/block_class.rs | 25 | ||||
-rw-r--r-- | src/md/visitor/embedded.rs | 35 | ||||
-rw-r--r-- | src/md/visitor/image.rs | 25 | ||||
-rw-r--r-- | src/md/visitor/linting.rs | 40 | ||||
-rw-r--r-- | src/md/visitor/mod.rs | 17 | ||||
-rw-r--r-- | src/md/visitor/structure.rs | 100 | ||||
-rw-r--r-- | src/md/visitor/typesetting.rs | 85 |
9 files changed, 582 insertions, 0 deletions
diff --git a/src/md/panhelper.rs b/src/md/panhelper.rs new file mode 100644 index 0000000..f7ab801 --- /dev/null +++ b/src/md/panhelper.rs @@ -0,0 +1,26 @@ +use pandoc_ast::Attr; + +/// Is a code block marked as being of a given type? +pub fn is_class(attr: &Attr, class: &str) -> bool { + let (_id, classes, _kvpairs) = attr; + classes.iter().any(|s| s == class) +} + +/// Utility function to find key/value pairs from an attribute +pub fn find_attr_kv<'a>(attr: &'a Attr, key: &'static str) -> impl Iterator<Item = &'a str> { + attr.2.iter().flat_map(move |(key_, value)| { + if key == key_ { + Some(value.as_ref()) + } else { + None + } + }) +} + +/// Get the filename for a fenced code block tagged .file. +/// +/// The filename is the first (and presumably only) identifier for the +/// block. +pub fn get_filename(attr: &Attr) -> String { + attr.0.to_string() +} diff --git a/src/md/typeset.rs b/src/md/typeset.rs new file mode 100644 index 0000000..f63206a --- /dev/null +++ b/src/md/typeset.rs @@ -0,0 +1,229 @@ +use crate::parser::parse_scenario_snippet; +use crate::Bindings; +use crate::PartialStep; +use crate::ScenarioStep; +use crate::StepKind; +use crate::SubplotError; +use crate::{DiagramMarkup, DotMarkup, PikchrMarkup, PlantumlMarkup, Svg}; +use crate::{Warning, Warnings}; + +use pandoc_ast::Attr; +use pandoc_ast::Block; +use pandoc_ast::Inline; +use pandoc_ast::Target; + +/// Typeset an error as a Pandoc AST Block element. +pub fn error(err: SubplotError) -> Block { + let msg = format!("ERROR: {}", err); + Block::Para(error_msg(&msg)) +} + +/// Typeset an error message a vector of inlines. +pub fn error_msg(msg: &str) -> Vec<Inline> { + vec![Inline::Strong(vec![inlinestr(msg)])] +} + +/// Typeset a string as an inline element. +pub fn inlinestr(s: &str) -> Inline { + Inline::Str(String::from(s)) +} + +/// Typeset a code block tagged as a file. +pub fn file_block(attr: &Attr, text: &str) -> Block { + let filename = inlinestr(&attr.0); + let filename = Inline::Strong(vec![filename]); + let intro = Block::Para(vec![inlinestr("File:"), space(), filename]); + let mut cbattrs = attr.clone(); + if cbattrs.1.iter().any(|s| s == "noNumberLines") { + // If the block says "noNumberLines" we remove that class + cbattrs.1.retain(|s| s != "noNumberLines"); + } else if cbattrs.1.iter().all(|s| s != "numberLines") { + // Otherwise if it doesn't say numberLines we add that in. + cbattrs.1.push("numberLines".to_string()); + } + // If this was an `example`, convert that class to `file` + if cbattrs.1.iter().any(|s| s == "example") { + cbattrs.1.retain(|s| s != "example"); + cbattrs.1.push("file".into()); + } + let codeblock = Block::CodeBlock(cbattrs, text.to_string()); + let noattr = ("".to_string(), vec![], vec![]); + Block::Div(noattr, vec![intro, codeblock]) +} + +/// Typeset a scenario snippet as a Pandoc AST Block. +/// +/// Typesetting here means producing the Pandoc abstract syntax tree +/// nodes that result in the desired output, when Pandoc processes +/// them. +/// +/// The snippet is given as a text string, which is parsed. It need +/// not be a complete scenario, but it should consist of complete steps. +pub fn scenario_snippet(bindings: &Bindings, snippet: &str, warnings: &mut Warnings) -> Block { + let lines = parse_scenario_snippet(snippet); + let mut steps = vec![]; + let mut prevkind: Option<StepKind> = None; + + for line in lines { + let (this, thiskind) = step(bindings, line, prevkind, warnings); + steps.push(this); + prevkind = thiskind; + } + Block::LineBlock(steps) +} + +// Typeset a single scenario step as a sequence of Pandoc AST Inlines. +fn step( + bindings: &Bindings, + text: &str, + prevkind: Option<StepKind>, + warnings: &mut Warnings, +) -> (Vec<Inline>, Option<StepKind>) { + let step = ScenarioStep::new_from_str(text, prevkind); + if step.is_err() { + return ( + error_msg(&format!("Could not parse step: {}", text)), + prevkind, + ); + } + let step = step.unwrap(); + + let m = match bindings.find("", &step) { + Ok(m) => m, + Err(e) => { + let w = Warning::UnknownBinding(format!("{}", e)); + warnings.push(w.clone()); + return (error_msg(&format!("{}", w)), prevkind); + } + }; + + let mut inlines = vec![keyword(&step, prevkind), space()]; + + for part in m.parts() { + match part { + PartialStep::UncapturedText(s) => inlines.push(uncaptured(s.text())), + PartialStep::CapturedText { text, .. } => inlines.push(captured(text)), + } + } + + (inlines, Some(step.kind())) +} + +// Typeset first word, which is assumed to be a keyword, of a scenario +// step. +fn keyword(step: &ScenarioStep, prevkind: Option<StepKind>) -> Inline { + let actual = inlinestr(&format!("{}", step.kind())); + let and = inlinestr("and"); + let keyword = if let Some(prevkind) = prevkind { + if prevkind == step.kind() { + and + } else { + actual + } + } else { + actual + }; + Inline::Emph(vec![keyword]) +} + +// Typeset a space between words. +fn space() -> Inline { + Inline::Space +} + +// Typeset an uncaptured part of a step. +fn uncaptured(s: &str) -> Inline { + inlinestr(s) +} + +// Typeset a captured part of a step. +fn captured(s: &str) -> Inline { + Inline::Strong(vec![inlinestr(s)]) +} + +/// Typeset a link as a note. +pub fn link_as_note(attr: Attr, text: Vec<Inline>, target: Target) -> Inline { + let (url, _) = target.clone(); + let url = Inline::Code(attr.clone(), url); + let link = Inline::Link(attr.clone(), vec![url], target); + let note = Inline::Note(vec![Block::Para(vec![link])]); + let mut text = text; + text.push(note); + Inline::Span(attr, text) +} + +/// Take a pikchr diagram, render it as SVG, and return an AST block element. +/// +/// The `Block` will contain the SVG data. This allows the diagram to +/// be rendered without referencing external entities. +/// +/// If the code block which contained the pikchr contains other classes, they +/// can be added to the SVG for use in later typesetting etc. +pub fn pikchr_to_block(pikchr: &str, class: Option<&str>, warnings: &mut Warnings) -> Block { + match PikchrMarkup::new(pikchr, class).as_svg() { + Ok(svg) => typeset_svg(svg), + Err(err) => { + warnings.push(Warning::Pikchr(format!("{}", err))); + error(err) + } + } +} + +// Take a dot diagram, render it as SVG, and return an AST Block +// element. The Block will contain the SVG data. This allows the +// diagram to be rendered without referending external entities. +pub fn dot_to_block(dot: &str, warnings: &mut Warnings) -> Block { + match DotMarkup::new(dot).as_svg() { + Ok(svg) => typeset_svg(svg), + Err(err) => { + warnings.push(Warning::Dot(format!("{}", err))); + error(err) + } + } +} + +// Take a PlantUML diagram, render it as SVG, and return an AST Block +// element. The Block will contain the SVG data. This allows the +// diagram to be rendered without referending external entities. +pub fn plantuml_to_block(markup: &str, warnings: &mut Warnings) -> Block { + match PlantumlMarkup::new(markup).as_svg() { + Ok(svg) => typeset_svg(svg), + Err(err) => { + warnings.push(Warning::Plantuml(format!("{}", err))); + error(err) + } + } +} + +/// Typeset a project roadmap expressed as textual YAML, and render it +/// as an SVG image. +pub fn roadmap_to_block(yaml: &str, warnings: &mut Warnings) -> Block { + match roadmap::from_yaml(yaml) { + Ok(ref mut roadmap) => { + roadmap.set_missing_statuses(); + let width = 50; + match roadmap.format_as_dot(width) { + Ok(dot) => dot_to_block(&dot, warnings), + Err(e) => Block::Para(vec![inlinestr(&e.to_string())]), + } + } + Err(e) => Block::Para(vec![inlinestr(&e.to_string())]), + } +} + +// Typeset an SVG, represented as a byte vector, as a Pandoc AST Block +// element. +fn typeset_svg(svg: Svg) -> Block { + let url = svg_as_data_url(svg); + let attr = ("".to_string(), vec![], vec![]); + let img = Inline::Image(attr, vec![], (url, "".to_string())); + Block::Para(vec![img]) +} + +// Convert an SVG, represented as a byte vector, into a data: URL, +// which can be inlined so the image can be rendered without +// referencing external files. +fn svg_as_data_url(svg: Svg) -> String { + let svg = base64::encode(svg.data()); + format!("data:image/svg+xml;base64,{}", svg) +} diff --git a/src/md/visitor/block_class.rs b/src/md/visitor/block_class.rs new file mode 100644 index 0000000..303616b --- /dev/null +++ b/src/md/visitor/block_class.rs @@ -0,0 +1,25 @@ +use std::collections::HashSet; + +use pandoc_ast::{Block, MutVisitor}; + +#[derive(Default)] +pub struct BlockClassVisitor { + pub classes: HashSet<String>, +} + +impl MutVisitor for BlockClassVisitor { + fn visit_vec_block(&mut self, vec_block: &mut Vec<Block>) { + for block in vec_block { + match block { + Block::CodeBlock(attr, _) => { + for class in &attr.1 { + self.classes.insert(class.to_string()); + } + } + _ => { + self.visit_block(block); + } + } + } + } +} diff --git a/src/md/visitor/embedded.rs b/src/md/visitor/embedded.rs new file mode 100644 index 0000000..840d9ed --- /dev/null +++ b/src/md/visitor/embedded.rs @@ -0,0 +1,35 @@ +use crate::md::panhelper; +use crate::EmbeddedFile; +use crate::EmbeddedFiles; + +use pandoc_ast::{Block, MutVisitor}; + +impl MutVisitor for EmbeddedFiles { + fn visit_vec_block(&mut self, vec_block: &mut Vec<Block>) { + use panhelper::is_class; + for block in vec_block { + match block { + Block::CodeBlock(attr, contents) => { + if is_class(attr, "file") { + let add_newline = match panhelper::find_attr_kv(attr, "add-newline").next() + { + None | Some("auto") => !contents.ends_with('\n'), + Some("yes") => true, + Some("no") => false, + _ => unreachable!(), + }; + let contents = if add_newline { + format!("{}\n", contents) + } else { + contents.clone() + }; + self.push(EmbeddedFile::new(panhelper::get_filename(attr), contents)); + } + } + _ => { + self.visit_block(block); + } + } + } + } +} diff --git a/src/md/visitor/image.rs b/src/md/visitor/image.rs new file mode 100644 index 0000000..be49d66 --- /dev/null +++ b/src/md/visitor/image.rs @@ -0,0 +1,25 @@ +use std::path::PathBuf; + +use pandoc_ast::{Inline, MutVisitor}; + +pub struct ImageVisitor { + images: Vec<PathBuf>, +} + +impl ImageVisitor { + pub fn new() -> Self { + ImageVisitor { images: vec![] } + } + + pub fn images(&self) -> Vec<PathBuf> { + self.images.clone() + } +} + +impl MutVisitor for ImageVisitor { + fn visit_inline(&mut self, inline: &mut Inline) { + if let Inline::Image(_attr, _inlines, target) = inline { + self.images.push(PathBuf::from(&target.0)); + } + } +} diff --git a/src/md/visitor/linting.rs b/src/md/visitor/linting.rs new file mode 100644 index 0000000..d64b03e --- /dev/null +++ b/src/md/visitor/linting.rs @@ -0,0 +1,40 @@ +use crate::md::panhelper; +use crate::SubplotError; + +use pandoc_ast::{Block, MutVisitor}; + +#[derive(Default)] +pub struct LintingVisitor { + pub issues: Vec<SubplotError>, +} + +impl MutVisitor for LintingVisitor { + fn visit_vec_block(&mut self, vec_block: &mut Vec<Block>) { + for block in vec_block { + match block { + Block::CodeBlock(attr, _) => { + if panhelper::is_class(attr, "file") || panhelper::is_class(attr, "example") { + let newlines: Vec<_> = + panhelper::find_attr_kv(attr, "add-newline").collect(); + match newlines.len() { + 0 => {} + 1 => match newlines[0].to_ascii_lowercase().as_ref() { + "auto" | "yes" | "no" => {} + _ => self.issues.push(SubplotError::UnrecognisedAddNewline( + panhelper::get_filename(attr), + newlines[0].to_owned(), + )), + }, + _ => self.issues.push(SubplotError::RepeatedAddNewlineAttribute( + panhelper::get_filename(attr), + )), + } + } + } + _ => { + self.visit_block(block); + } + } + } + } +} diff --git a/src/md/visitor/mod.rs b/src/md/visitor/mod.rs new file mode 100644 index 0000000..1c095ac --- /dev/null +++ b/src/md/visitor/mod.rs @@ -0,0 +1,17 @@ +mod block_class; +pub use block_class::BlockClassVisitor; + +mod embedded; + +mod image; +pub use image::ImageVisitor; + +mod linting; +pub use linting::LintingVisitor; + +mod structure; +pub use structure::Element; +pub use structure::StructureVisitor; + +mod typesetting; +pub use typesetting::TypesettingVisitor; diff --git a/src/md/visitor/structure.rs b/src/md/visitor/structure.rs new file mode 100644 index 0000000..d8faef6 --- /dev/null +++ b/src/md/visitor/structure.rs @@ -0,0 +1,100 @@ +use crate::md::panhelper; + +use pandoc_ast::{Block, Inline, MutVisitor}; + +// A structure element in the document: a heading or a scenario snippet. +#[derive(Debug)] +pub enum Element { + // Headings consist of the text and the level of the heading. + Heading(String, i64), + + // Scenario snippets consist just of the unparsed text. + Snippet(String), +} + +impl Element { + pub fn heading(text: &str, level: i64) -> Element { + Element::Heading(text.to_string(), level) + } + + pub fn snippet(text: &str) -> Element { + Element::Snippet(text.to_string()) + } +} + +// A MutVisitor for extracting document structure. +pub struct StructureVisitor { + pub elements: Vec<Element>, +} + +impl StructureVisitor { + pub fn new() -> Self { + Self { elements: vec![] } + } +} + +impl MutVisitor for StructureVisitor { + fn visit_vec_block(&mut self, vec_block: &mut Vec<Block>) { + use panhelper::is_class; + for block in vec_block { + match block { + Block::Header(level, _attr, inlines) => { + let text = join(inlines); + let heading = Element::heading(&text, *level); + self.elements.push(heading); + } + Block::CodeBlock(attr, s) => { + if is_class(attr, "scenario") { + let snippet = Element::snippet(s); + self.elements.push(snippet); + } + } + _ => { + self.visit_block(block); + } + } + } + } +} + +fn join(vec: &[Inline]) -> String { + let mut buf = String::new(); + join_into_buffer(vec, &mut buf); + buf +} + +fn join_into_buffer(vec: &[Inline], buf: &mut String) { + for item in vec { + match item { + Inline::Str(s) => buf.push_str(s), + Inline::Emph(v) => join_into_buffer(v, buf), + Inline::Strong(v) => join_into_buffer(v, buf), + Inline::Strikeout(v) => join_into_buffer(v, buf), + Inline::Superscript(v) => join_into_buffer(v, buf), + Inline::Subscript(v) => join_into_buffer(v, buf), + Inline::SmallCaps(v) => join_into_buffer(v, buf), + Inline::Quoted(qt, v) => { + let q = match qt { + pandoc_ast::QuoteType::SingleQuote => "'", + pandoc_ast::QuoteType::DoubleQuote => "\"", + }; + buf.push_str(q); + join_into_buffer(v, buf); + buf.push_str(q); + } + Inline::Cite(_, v) => join_into_buffer(v, buf), + Inline::Code(_attr, s) => buf.push_str(s), + Inline::Space => buf.push(' '), + Inline::SoftBreak => buf.push(' '), + Inline::LineBreak => buf.push(' '), + Inline::Math(_, s) => buf.push_str(s), + Inline::RawInline(_, s) => buf.push_str(s), + Inline::Link(_, v, _) => join_into_buffer(v, buf), + Inline::Image(_, v, _) => join_into_buffer(v, buf), + Inline::Note(_) => buf.push_str(""), + Inline::Span(_attr, v) => join_into_buffer(v, buf), + #[cfg(feature = "pandoc_ast_08")] + Inline::Underline(v) => join_into_buffer(v, buf), + } + } +} diff --git a/src/md/visitor/typesetting.rs b/src/md/visitor/typesetting.rs new file mode 100644 index 0000000..2405c03 --- /dev/null +++ b/src/md/visitor/typesetting.rs @@ -0,0 +1,85 @@ +use crate::md::panhelper; +use crate::md::typeset; +use crate::{Bindings, Style, Warnings}; + +use pandoc_ast::{Block, Inline, MutVisitor}; + +/// Visitor for the pandoc AST. +/// +/// This includes rendering stuff which we find as we go +pub struct TypesettingVisitor<'a> { + style: Style, + bindings: &'a Bindings, + warnings: Warnings, +} + +impl<'a> TypesettingVisitor<'a> { + pub fn new(style: Style, bindings: &'a Bindings) -> Self { + TypesettingVisitor { + style, + bindings, + warnings: Warnings::default(), + } + } + + pub fn warnings(self) -> Warnings { + self.warnings + } +} + +// Visit interesting parts of the Pandoc abstract syntax tree. The +// document top level is a vector of blocks and we visit that and +// replace any fenced code block with the scenario tag with a typeset +// paragraph. Also, replace fenced code blocks with known diagram +// markup with the rendered SVG image. +impl<'a> MutVisitor for TypesettingVisitor<'a> { + fn visit_vec_block(&mut self, vec_block: &mut Vec<Block>) { + use panhelper::is_class; + for block in vec_block { + match block { + Block::CodeBlock(attr, s) => { + if is_class(attr, "scenario") { + *block = typeset::scenario_snippet(self.bindings, s, &mut self.warnings) + } else if is_class(attr, "file") || is_class(attr, "example") { + *block = typeset::file_block(attr, s) + } else if is_class(attr, "dot") { + *block = typeset::dot_to_block(s, &mut self.warnings) + } else if is_class(attr, "plantuml") { + *block = typeset::plantuml_to_block(s, &mut self.warnings) + } else if is_class(attr, "roadmap") { + *block = typeset::roadmap_to_block(s, &mut self.warnings) + } else if is_class(attr, "pikchr") { + let other_classes: Vec<_> = attr + .1 + .iter() + .map(String::as_str) + .filter(|s| *s != "pikchr") + .collect(); + let class = if other_classes.is_empty() { + None + } else { + Some(other_classes.join(" ")) + }; + let class = class.as_deref(); + *block = typeset::pikchr_to_block(s, class, &mut self.warnings) + } + } + _ => { + self.visit_block(block); + } + } + } + } + fn visit_vec_inline(&mut self, vec_inline: &mut Vec<Inline>) { + for inline in vec_inline { + match inline { + Inline::Link(attr, vec, target) if self.style.links_as_notes() => { + *inline = typeset::link_as_note(attr.clone(), vec.to_vec(), target.clone()); + } + _ => { + self.visit_inline(inline); + } + } + } + } +} |