feat: reimplement typlite by html export (#1684)

* dev: init markdown file

* dev: typlite element derive

* feat: pass tests

refactor lib.rs to separated files (#1692)

feat(typlite): Docx export and export markdown in cmark-writer (#1698)

* feat: docx export support

* refactor: simplify DocxConverter structure and improve content handling

* tests: add binary insta for docx

* feat: add MathBlock style and improve frame rendering in DocxConverter

* fix: enhance paragraph creation(silly method)

* fix: enhance math equation rendering

* use md5 instead of docx binary

* feat: enhance list numbering and paragraph handling in DocxConverter

* feat: add all_elements test

* refactor

* reimpl md export in cmark-writer

* feat: add support for highlight tag in MarkdownConverter

* feat: refactor LaTeXConverter to improve element processing and add new methods

* fmt

* Refactor DOCX converter to improve list handling and document structure

- Introduced separate methods for creating ordered and unordered list numbering.
- Enhanced list management by tracking next numbering IDs.
- Consolidated paragraph and run management within the DocxConverter.
- Improved image processing with better error handling and placeholder support.
- Streamlined the handling of various HTML elements, including headings, lists, and images.
- Added functionality for processing captions and preformatted blocks.
- Updated methods for processing inline styles and links.

* feat: update cmark-writer to version 0.2.0

* feat: refactor code block handling in DOCX converter for improved readability

* refactor: refactor DOCX converter to enhance document structure

* refactor docx to separated files

* update instas

* fmt

* chore: update cmark-writer version to 0.3.0

* fix: ol custom value

* feat: table and grid processing

* use cmark-writer's ast node for consistency

* fix: update snapshot hashes for document generation tests

* fix: add preamble

* update snapshot hashes

* refactor DOCX conversion: Split writer functionality into separate module, enhance image processing, and clean up utility functions

* update comments in LaTeX and Markdown converters for clarity and consistency

* fmt

* delete utils

* feat: support figure node by custom node in cmark-writer

* fix

* fix: frame

* feat: enhance table conversion logic in MarkdownConverter

* refactor: simplify FigureNode implementation by removing CustomNode trait

* chore: update cmark-writer to version 0.5.0

* fix: update figure and raw inline snapshots for consistency

* fix: update snapshot hashes and correct caption reference in markdown.typ

* refactor proj structure

* feat: update CompileArgs to support multiple output files and remove debug option

* docs: update README to clarify usage of multiple output formats and comment out feature section

* remove DocxConverter module

* feat: impl assets-path feature and add ExternalFrameNode for handling external frames and update writers to support it

* feat: enhance HTML element conversion to include attributes and children handling

* feat: update cmark-writer to 0.6.1 and refactor related code

* fix: update snapshots for figure caption, list, outline, and docx generation

* feat: refactor HTML element conversion to use create_html_element method and enhance table processing

* fix

* feat: add HighlightNode for highlighted text and integrate with HTML to AST parser and LaTeX writer

* refactoor

* update tests

Co-Authored-By: Hong Jiarong <me@jrhim.com>

* feat: revert latex/docx conversions

* fix: warnings

* bad: convert docs

* build: remove other cargo deps

* build: update cargo.lock

* test: update snapshot

* chore: remove useless parser trait

* feat: annotate v1

* feat: annotate v2

* test: update snapshot

* question: is it a bug?

* test: update bad snapshot

---------

Co-authored-by: Hong Jiarong <me@jrhim.com>
This commit is contained in:
Myriad-Dreamin 2025-04-30 22:12:25 +08:00 committed by GitHub
parent 170dd7b948
commit f35d1056ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 2755 additions and 950 deletions

23
Cargo.lock generated
View file

@ -579,6 +579,26 @@ dependencies = [
"roff",
]
[[package]]
name = "cmark-writer"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79ac3be73d18979a75f69c6f73bae74d2ff9fa61e9c3539732040429ad2659b8"
dependencies = [
"cmark-writer-macros",
]
[[package]]
name = "cmark-writer-macros"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23ad8476b03e933c3ff38eefa7c96a9c155e4402621918852f27cf67cb637b99"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]]
name = "cobs"
version = "0.2.3"
@ -4743,15 +4763,18 @@ version = "0.13.12"
dependencies = [
"base64",
"clap",
"cmark-writer",
"comemo",
"ecow",
"insta",
"regex",
"tinymist-analysis",
"tinymist-derive",
"tinymist-project",
"tinymist-std",
"tinymist-tests",
"typst",
"typst-html",
"typst-svg",
"typst-syntax",
]

View file

@ -47,7 +47,11 @@ pub fn identify_pat_docs(converted: &str) -> StrResult<TidyPatDocs> {
loop {
if matching_return_ty {
matching_return_ty = false;
let Some(w) = line.trim_start().strip_prefix("->") else {
let line = line.trim_start();
let type_line = line
.strip_prefix("-\\>")
.or_else(|| line.strip_prefix("->"));
let Some(w) = type_line else {
// break_line = Some(i);
continue;
};
@ -204,7 +208,7 @@ mod tests {
- <!-- typlite:begin:list-item 0 -->`types` (optional): A list of accepted argument types.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->`default` (optional): Default value for this argument.<!-- typlite:end:list-item 0 -->
See @@show-module() for outputting the results of this function.
See show-module() for outputting the results of this function.
- <!-- typlite:begin:list-item 0 -->content (string): Content of `.typ` file to analyze for docstrings.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->name (string): The name for the module.<!-- typlite:end:list-item 0 -->
@ -223,7 +227,7 @@ See @@show-module() for outputting the results of this function.
- <!-- typlite:begin:list-item 0 -->`types` (optional): A list of accepted argument types.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->`default` (optional): Default value for this argument.<!-- typlite:end:list-item 0 -->
See @@show-module() for outputting the results of this function.
See show-module() for outputting the results of this function.
<< docs
>>return
string
@ -258,7 +262,7 @@ See @@show-module() for outputting the results of this function.
insta::assert_snapshot!(func(r###"These again are dictionaries with the keys
- <!-- typlite:begin:list-item 0 -->`description` (optional): The description for the argument.<!-- typlite:end:list-item 0 -->
See @@show-module() for outputting the results of this function.
See show-module() for outputting the results of this function.
- <!-- typlite:begin:list-item 0 -->name (string): The name for the module.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->label-prefix (auto, string): The label-prefix for internal function
@ -270,7 +274,7 @@ See @@show-module() for outputting the results of this function.
These again are dictionaries with the keys
- <!-- typlite:begin:list-item 0 -->`description` (optional): The description for the argument.<!-- typlite:end:list-item 0 -->
See @@show-module() for outputting the results of this function.
See show-module() for outputting the results of this function.
<< docs
>>return
string
@ -289,10 +293,10 @@ See @@show-module() for outputting the results of this function.
#[test]
fn test_identify_tidy_docs3() {
insta::assert_snapshot!(var(r###"See @@show-module() for outputting the results of this function.
insta::assert_snapshot!(var(r###"See show-module() for outputting the results of this function.
-> string"###), @r"
>> docs:
See @@show-module() for outputting the results of this function.
See show-module() for outputting the results of this function.
<< docs
>>return
string

View file

@ -96,3 +96,57 @@ pub fn gen_decl_enum(input: TokenStream) -> TokenStream {
TokenStream::from(expanded)
}
#[proc_macro_derive(TypliteAttr)]
pub fn gen_typlite_element(input: TokenStream) -> TokenStream {
// Parse the input tokens into a syntax tree
let input = parse_macro_input!(input as DeriveInput);
// extract the fields from the struct
let field_parsers = match &input.data {
syn::Data::Struct(data) => match &data.fields {
syn::Fields::Named(fields) => fields
.named
.iter()
.map(|f| {
let name = f.ident.as_ref().unwrap();
let ty = &f.ty;
quote! {
md_attr::#name => {
let value = <#ty>::parse_attr(content)?;
result.#name = value;
}
}
})
.collect::<Vec<_>>(),
syn::Fields::Unnamed(_) => panic!("unnamed fields are not supported"),
syn::Fields::Unit => panic!("unit structs are not supported"),
},
_ => panic!("only structs are supported"),
};
let input_name = &input.ident;
// generate parse trait
let expanded = quote! {
impl TypliteAttrsParser for #input_name {
fn parse(attrs: &HtmlAttrs) -> Result<Self> {
let mut result = Self::default();
for (name, content) in attrs.0.iter() {
match *name {
#(#field_parsers)*
_ => {
return Err(format!("unknown attribute: {name}").into());
}
}
}
Ok(result)
}
}
};
TokenStream::from(expanded)
}

View file

@ -2,7 +2,6 @@
source: crates/tinymist-query/src/analysis.rs
expression: "snap.join(\"\\n\")"
input_file: crates/tinymist-query/src/fixtures/docs/blocky2.typ
snapshot_kind: text
---
= docstings
Pattern(..)@41..42 in /s0.typ -> DocString { docs: Some("This is X\nNote: This is not Y"), var_bounds: {}, vars: {}, res_ty: None }
Pattern(..)@41..42 in /s0.typ -> DocString { docs: Some("This is X Note: This is not Y"), var_bounds: {}, vars: {}, res_ty: None }

View file

@ -2,7 +2,6 @@
source: crates/tinymist-query/src/analysis.rs
expression: "snap.join(\"\\n\")"
input_file: crates/tinymist-query/src/fixtures/docs/multiple_line.typ
snapshot_kind: text
---
= docstings
Pattern(..)@45..46 in /s0.typ -> DocString { docs: Some("This is X.\nNote: this is not Y."), var_bounds: {}, vars: {}, res_ty: None }
Pattern(..)@45..46 in /s0.typ -> DocString { docs: Some("This is X. Note: this is not Y."), var_bounds: {}, vars: {}, res_ty: None }

View file

@ -2,9 +2,8 @@
source: crates/tinymist-query/src/hover.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/hover/annotate_dict_param.typ
snapshot_kind: text
---
{
"contents": "```typc\nlet show-example(\n ..options: arguments,\n inherited-scope: dictionary = (:),\n) = none;\n```\n\n---\n\n- <!-- typlite:begin:list-item 0 -->inherited-scope (dictionary): Definitions that are made available to the entire parsed\n module. This parameter is only used internally.<!-- typlite:end:list-item 0 -->\n\n# Rest Parameters\n\n## options\n\n```typc\ntype: arguments\n```\n\n\n\n# Named Parameters\n\n## inherited-scope\n\n```typc\ntype: dictionary\n```\n\nDefinitions that are made available to the entire parsed\n module. This parameter is only used internally.",
"contents": "```typc\nlet show-example(\n ..options: arguments,\n inherited-scope: dictionary = (:),\n) = none;\n```\n\n---\n\n- <!-- typlite:begin:list-item 0 -->inherited-scope (dictionary): Definitions that are made available to the entire parsed module. This parameter is only used internally.<!-- typlite:end:list-item 0 -->\n\n# Rest Parameters\n\n## options\n\n```typc\ntype: arguments\n```\n\n\n\n# Named Parameters\n\n## inherited-scope\n\n```typc\ntype: dictionary\n```\n\nDefinitions that are made available to the entire parsed module. This parameter is only used internally.",
"range": "7:20:7:32"
}

View file

@ -2,9 +2,8 @@
source: crates/tinymist-query/src/hover.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/hover/annotate_dict_param2.typ
snapshot_kind: text
---
{
"contents": "```typc\nlet inherited-scope = dictionary;\n```\n\n---\n\nDefinitions that are made available to the entire parsed\n module. This parameter is only used internally.",
"contents": "```typc\nlet inherited-scope = dictionary;\n```\n\n---\n\nDefinitions that are made available to the entire parsed module. This parameter is only used internally.",
"range": "6:21:6:36"
}

View file

@ -4,6 +4,6 @@ expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/hover/annotate_docs_error.typ
---
{
"contents": "```typc\nlet speaker-note(\n note: any,\n mode: str = \"typ\",\n setting: (any) => any = Closure(..),\n) = none;\n```\n\n---\n\nSpeaker notes are a way to add additional information to your slides that is not visible to the audience. This can be useful for providing additional context or reminders to yourself.\n\n## Example\n\n```typ\n#speaker-note[This is a speaker note]\n\n```\n```\nRender Error\ncompiling node: error: unknown variable: speaker-note at \"/__render__.typ\":214..226\nHint: if you meant to use subtraction, try adding spaces around the minus sign: \\`speaker - note\\`\n\n```\n\n# Positional Parameters\n\n## note\n\n```typc\ntype: \n```\n\n\n\n# Named Parameters\n\n## mode\n\n```typc\ntype: \"typ\"\n```\n\n\n\n## setting (named)\n\n```typc\ntype: (any) => any\n```\n\n",
"contents": "```typc\nlet speaker-note(\n note: any,\n mode: str = \"typ\",\n setting: (any) => any = Closure(..),\n) = none;\n```\n\n---\n\n```\nfailed to parse docs: failed to convert to markdown: convert source for main file: [SourceDiagnostic { severity: Error, span: Span(..), message: \"unknown variable: example\", trace: [Import], hints: [] }]\n```\n\n````typ\nSpeaker notes are a way to add additional information to your slides that is not visible to the audience. This can be useful for providing additional context or reminders to yourself.\n\n== Example\n\n#example(```typ\n#speaker-note[This is a speaker note]\n```)\n````\n\n# Positional Parameters\n\n## note\n\n```typc\ntype: \n```\n\n\n\n# Named Parameters\n\n## mode\n\n```typc\ntype: \"typ\"\n```\n\n\n\n## setting (named)\n\n```typc\ntype: (any) => any\n```\n\n",
"range": "11:20:11:32"
}

View file

@ -2,9 +2,8 @@
source: crates/tinymist-query/src/hover.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/hover/module_alias.typ
snapshot_kind: text
---
{
"contents": "### Sampled Values\n```typc\n<module themod>\n```\n\n---\n\n# The Module (Alias)",
"contents": "### Sampled Values\n```typc\n<module themod>\n```\n\n---\n\n## The Module (Alias)\n",
"range": "2:24:2:31"
}

View file

@ -2,9 +2,8 @@
source: crates/tinymist-query/src/hover.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/hover/module_path.typ
snapshot_kind: text
---
{
"contents": "# Some Module",
"contents": "## Some Module\n",
"range": "0:29:0:46"
}

View file

@ -2,9 +2,8 @@
source: crates/tinymist-query/src/hover.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/hover/module_var.typ
snapshot_kind: text
---
{
"contents": "### Sampled Values\n```typc\n<module themod>\n```\n\n---\n\n# The Module",
"contents": "### Sampled Values\n```typc\n<module themod>\n```\n\n---\n\n## The Module\n",
"range": "2:24:2:30"
}

View file

@ -4,6 +4,6 @@ expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/hover/render_equation.typ
---
{
"contents": "```typc\nlet lam(\n A: type,\n B: type,\n) = dictionary;\n```\n\n---\n\nLambda constructor.\n\nTyping Rule:\n\n<p align=\"center\"><img alt=\"typst-block\" src=\"data:image-hash/svg+xml;base64,redacted\" /></p>\n\n# Positional Parameters\n\n## A\n\n```typc\ntype: type\n```\n\nThe type of the argument.\n - <!-- typlite:begin:list-item 1 -->It can be also regarded as the condition of the proposition.<!-- typlite:end:list-item 1 -->\n\n## B (positional)\n\n```typc\ntype: type\n```\n\nThe type of the body.\n - <!-- typlite:begin:list-item 1 -->It can be also regarded as the conclusion of the proposition.<!-- typlite:end:list-item 1 -->",
"contents": "```typc\nlet lam(\n A: any,\n B: any,\n) = dictionary;\n```\n\n---\n\nLambda constructor.\n\nTyping Rule:\n\n<img alt=\"typst-block\" src=\"data:image-hash/svg+xml;base64,redacted\" />\n\n- <!-- typlite:begin:list-item 0 -->A (type): The type of the argument.\n \n - <!-- typlite:begin:list-item 0 -->It can be also regarded as the condition of the proposition.<!-- typlite:end:list-item 0 -->\n \n <!-- typlite:end:list-item 0 -->\n- <!-- typlite:begin:list-item 0 -->B (type): The type of the body.\n \n - <!-- typlite:begin:list-item 0 -->It can be also regarded as the conclusion of the proposition.<!-- typlite:end:list-item 0 -->\n \n <!-- typlite:end:list-item 0 -->\n\n# Positional Parameters\n\n## A\n\n```typc\ntype: \n```\n\n\n\n## B (positional)\n\n```typc\ntype: \n```\n\n",
"range": "12:20:12:23"
}

View file

@ -4,6 +4,6 @@ expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/hover/render_equation_no_html.typ
---
{
"contents": "```typc\nlet lam(\n A: type,\n B: type,\n) = dictionary;\n```\n\n---\n\nLambda constructor.\n\nTyping Rule:\n\n```typc\n$ (Γ , x : A ⊢ M : B #h(2em) Γ ⊢ a:B)/(Γ ⊢ λ (x : A) → M : π (x : A) → B) $\n```\n\n# Positional Parameters\n\n## A\n\n```typc\ntype: type\n```\n\nThe type of the argument.\n - It can be also regarded as the condition of the proposition.\n\n## B (positional)\n\n```typc\ntype: type\n```\n\nThe type of the body.\n - It can be also regarded as the conclusion of the proposition.",
"contents": "```typc\nlet lam(\n A: any,\n B: any,\n) = dictionary;\n```\n\n---\n\nLambda constructor.\n\nTyping Rule:\n\n<img alt=\"typst-block\" src=\"data:image-hash/svg+xml;base64,redacted\" />\n\n- A (type): The type of the argument.\n \n - It can be also regarded as the condition of the proposition.\n \n \n- B (type): The type of the body.\n \n - It can be also regarded as the conclusion of the proposition.\n\n# Positional Parameters\n\n## A\n\n```typc\ntype: \n```\n\n\n\n## B (positional)\n\n```typc\ntype: \n```\n\n",
"range": "14:20:14:23"
}

View file

@ -317,7 +317,12 @@ impl fmt::Display for JsonRepr {
let mut ser = Serializer::with_formatter(w, PrettyFormatter::with_indent(b" "));
self.0.serialize(&mut ser).unwrap();
f.write_str(&String::from_utf8(ser.into_inner().into_inner().unwrap()).unwrap())
let res = String::from_utf8(ser.into_inner().into_inner().unwrap()).unwrap();
// replace Span(number) to Span(..)
static REG: LazyLock<regex::Regex> =
LazyLock::new(|| regex::Regex::new(r#"Span\((\d+)\)"#).unwrap());
let res = REG.replace_all(&res, "Span(..)");
f.write_str(&res)
}
}

View file

@ -27,10 +27,13 @@ comemo.workspace = true
ecow.workspace = true
tinymist-analysis.workspace = true
tinymist-std.workspace = true
tinymist-derive.workspace = true
tinymist-project = { workspace = true, features = ["lsp"] }
typst.workspace = true
typst-svg.workspace = true
typst-syntax.workspace = true
typst-html.workspace = true
cmark-writer = { version = "0.6.1", features = ["gfm"] }
[dev-dependencies]
insta.workspace = true

View file

@ -9,9 +9,13 @@ Converts a subset of typst to markdown.
typlite main.typ
# specify output
typlite main.typ output.md
# multiple output formats
typlite main.typ output.md output.tex output.docx
```
## Feature
<!-- ## Feature
not implemented yet
- **Contexual Content Rendering**: Contents begin with `context` keyword will be rendered as svg output. The svg output will be embedded inline in the output file as **base64** by default, if the `--assets-path` parameter is not specified. Otherwise, the svg output will be saved in the specified folder and the path will be embedded in the output file. By specify the `--assets-src-path` parameter, the source code of the context will also be saved in the specified folder.
@ -32,4 +36,4 @@ typlite main.typ output.md
└── main.typ # input file
```
- **Raw Output**: Raw codes with `typlite` language will be directly output into the Markdown result.
- **Raw Output**: Raw codes with `typlite` language will be directly output into the Markdown result. -->

View file

@ -0,0 +1,121 @@
//! Attributes for HTML elements and parsing
use ecow::EcoString;
use tinymist_derive::TypliteAttr;
use typst::html::HtmlAttrs;
use crate::Result;
/// Tag attributes defined for HTML elements.
pub mod md_attr {
use typst::html::HtmlAttr;
macro_rules! attrs {
($($attr:ident -> $name:ident)*) => {
$(#[allow(non_upper_case_globals)]
pub const $attr: HtmlAttr = HtmlAttr::constant(
stringify!($name)
);)*
}
}
attrs! {
src -> src
alt -> alt
level -> level
dest -> dest
lang -> lang
block -> block
text -> text
value -> value
caption -> caption
}
}
#[derive(TypliteAttr, Default)]
pub struct HeadingAttr {
pub level: usize,
}
#[derive(TypliteAttr, Default)]
pub struct ImageAttr {
pub src: EcoString,
pub alt: EcoString,
}
#[derive(TypliteAttr, Default)]
pub struct FigureAttr {
pub caption: EcoString,
}
#[derive(TypliteAttr, Default)]
pub struct LinkAttr {
pub dest: EcoString,
}
#[derive(TypliteAttr, Default)]
pub struct RawAttr {
pub lang: EcoString,
pub block: bool,
pub text: EcoString,
}
#[derive(TypliteAttr, Default)]
pub struct ListItemAttr {
pub value: Option<u32>,
}
pub trait TypliteAttrsParser {
fn parse(attrs: &HtmlAttrs) -> Result<Self>
where
Self: Sized;
}
pub trait TypliteAttrParser {
fn parse_attr(content: &EcoString) -> Result<Self>
where
Self: Sized;
}
impl TypliteAttrParser for usize {
fn parse_attr(content: &EcoString) -> Result<Self> {
Ok(content
.parse::<usize>()
.map_err(|_| format!("cannot parse {} as usize", content))?)
}
}
impl TypliteAttrParser for u32 {
fn parse_attr(content: &EcoString) -> Result<Self> {
Ok(content
.parse::<u32>()
.map_err(|_| format!("cannot parse {} as u32", content))?)
}
}
impl TypliteAttrParser for bool {
fn parse_attr(content: &EcoString) -> Result<Self> {
Ok(content
.parse::<bool>()
.map_err(|_| format!("cannot parse {} as bool", content))?)
}
}
impl TypliteAttrParser for EcoString {
fn parse_attr(content: &EcoString) -> Result<Self> {
Ok(content.clone())
}
}
impl<T> TypliteAttrParser for Option<T>
where
T: TypliteAttrParser,
{
fn parse_attr(content: &EcoString) -> Result<Self> {
if content.is_empty() {
Ok(None)
} else {
T::parse_attr(content).map(Some)
}
}
}

View file

@ -0,0 +1,110 @@
//! Common types and interfaces for the conversion system
use cmark_writer::ast::{CustomNodeWriter, Node};
use cmark_writer::custom_node;
use cmark_writer::WriteResult;
use ecow::EcoString;
use std::path::PathBuf;
use crate::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListState {
Ordered,
Unordered,
}
/// Valid formats for the conversion.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Md,
LaTeX,
Docx,
}
/// Figure node implementation for all formats
#[derive(Debug, PartialEq, Clone)]
#[custom_node]
pub struct FigureNode {
/// The main content of the figure, can be any block node
pub body: Box<Node>,
/// The caption text for the figure
pub caption: String,
}
impl FigureNode {
fn write_custom(&self, writer: &mut dyn CustomNodeWriter) -> WriteResult<()> {
let mut temp_writer = cmark_writer::writer::CommonMarkWriter::new();
temp_writer.write(&self.body)?;
let content = temp_writer.into_string();
writer.write_str(&content)?;
writer.write_str("\n")?;
writer.write_str(&self.caption)?;
Ok(())
}
fn is_block_custom(&self) -> bool {
true
}
}
/// External Frame node for handling frames stored as external files
#[derive(Debug, PartialEq, Clone)]
#[custom_node]
pub struct ExternalFrameNode {
/// The path to the external file containing the frame
pub file_path: PathBuf,
/// Alternative text for the frame
pub alt_text: String,
/// Original SVG data (needed for DOCX that still embeds images)
pub svg_data: String,
}
impl ExternalFrameNode {
fn write_custom(&self, writer: &mut dyn CustomNodeWriter) -> WriteResult<()> {
// The actual handling is implemented in format-specific writers
writer.write_str(&format!(
"![{}]({})",
self.alt_text,
self.file_path.display()
))?;
Ok(())
}
fn is_block_custom(&self) -> bool {
true
}
}
/// Highlight node for highlighted text
#[derive(Debug, PartialEq, Clone)]
#[custom_node]
pub struct HighlightNode {
/// The content to be highlighted
pub content: Vec<Node>,
}
impl HighlightNode {
fn write_custom(&self, writer: &mut dyn CustomNodeWriter) -> WriteResult<()> {
let mut temp_writer = cmark_writer::writer::CommonMarkWriter::new();
for node in &self.content {
temp_writer.write(node)?;
}
let content = temp_writer.into_string();
writer.write_str(&format!("=={}==", content))?;
Ok(())
}
fn is_block_custom(&self) -> bool {
false
}
}
/// Common writer interface for different formats
pub trait FormatWriter {
/// Write AST document to output format
fn write_eco(&mut self, document: &Node, output: &mut EcoString) -> Result<()>;
/// Write AST document to vector
fn write_vec(&mut self, document: &Node) -> Result<Vec<u8>>;
}

View file

@ -25,11 +25,32 @@ impl fmt::Debug for Error {
}
}
impl<T> From<T> for Error
where
T: Into<Cow<'static, str>>,
{
fn from(s: T) -> Self {
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error(Box::new(Repr::Msg(e.to_string().into())))
}
}
impl From<fmt::Error> for Error {
fn from(e: fmt::Error) -> Self {
Error(Box::new(Repr::Msg(e.to_string().into())))
}
}
impl From<&'static str> for Error {
fn from(s: &'static str) -> Self {
Error(Box::new(Repr::Msg(s.into())))
}
}
impl From<String> for Error {
fn from(s: String) -> Self {
Error(Box::new(Repr::Msg(s.into())))
}
}
impl From<Cow<'static, str>> for Error {
fn from(s: Cow<'static, str>) -> Self {
Error(Box::new(Repr::Msg(s)))
}
}

View file

@ -1,7 +1,7 @@
These again are dictionaries with the keys
- `description` (optional): The description for the argument.
See @@show-module() for outputting the results of this function.
See show-module() for outputting the results of this function.
- name (string): The name for the module.
- label-prefix (auto, string): The label-prefix for internal function

View file

@ -3,14 +3,28 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, true)"
input_file: crates/typlite/src/fixtures/docs/nest_list.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><p>These again are dictionaries with the keys</p><ul><li><span><m1raw lang="" block="false" text="description"></m1raw></span> (optional): The description for the argument.</li></ul><m1parbreak></m1parbreak><p>See show-module() for outputting the results of this function.</p><m1parbreak></m1parbreak><ul><li>name (string): The name for the module.</li><li><p>label-prefix (auto, string): The label-prefix for internal function references. If <span><m1raw lang="" block="false" text="auto"></m1raw></span>, the label-prefix name will be the module name.</p><ul><li>nested something</li><li>nested something 2</li></ul></li></ul><p>-> string</p></m1document></body>
</html>
=====
These again are dictionaries with the keys
- <!-- typlite:begin:list-item 0 -->`description` (optional): The description for the argument.<!-- typlite:end:list-item 0 -->
See @@show-module() for outputting the results of this function.
See show-module() for outputting the results of this function.
- <!-- typlite:begin:list-item 0 -->name (string): The name for the module.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->label-prefix (auto, string): The label-prefix for internal function
references. If `auto`, the label-prefix name will be the module name.
- <!-- typlite:begin:list-item 1 -->nested something<!-- typlite:end:list-item 1 -->
- <!-- typlite:begin:list-item 1 -->nested something 2<!-- typlite:end:list-item 1 --><!-- typlite:end:list-item 0 -->
-> string
- <!-- typlite:begin:list-item 0 -->label-prefix (auto, string): The label-prefix for internal function references. If `auto`, the label-prefix name will be the module name.
- <!-- typlite:begin:list-item 0 -->nested something<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->nested something 2<!-- typlite:end:list-item 0 -->
<!-- typlite:end:list-item 0 -->
-\> string

View file

@ -3,21 +3,29 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, true)"
input_file: crates/typlite/src/fixtures/docs/tidy.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><p>These again are dictionaries with the keys</p><ul><li><span><m1raw lang="" block="false" text="description"></m1raw></span> (optional): The description for the argument.</li><li><span><m1raw lang="" block="false" text="types"></m1raw></span> (optional): A list of accepted argument types.</li><li><span><m1raw lang="" block="false" text="default"></m1raw></span> (optional): Default value for this argument.</li></ul><m1parbreak></m1parbreak><p>See show-module() for outputting the results of this function.</p><m1parbreak></m1parbreak><ul><li>content (string): Content of <span><m1raw lang="" block="false" text=".typ"></m1raw></span> file to analyze for docstrings.</li><li>name (string): The name for the module.</li><li>label-prefix (auto, string): The label-prefix for internal function references. If <span><m1raw lang="" block="false" text="auto"></m1raw></span>, the label-prefix name will be the module name.</li><li>require-all-parameters (boolean): Require that all parameters of a functions are documented and fail if some are not.</li><li>scope (dictionary): A dictionary of definitions that are then available in all function and parameter descriptions.</li><li>preamble (string): Code to prepend to all code snippets shown with <span><m1raw lang="" block="false" text="#example()"></m1raw></span>. This can for instance be used to import something from the scope.</li></ul><p>-> string</p></m1document></body>
</html>
=====
These again are dictionaries with the keys
- <!-- typlite:begin:list-item 0 -->`description` (optional): The description for the argument.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->`types` (optional): A list of accepted argument types.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->`default` (optional): Default value for this argument.<!-- typlite:end:list-item 0 -->
See @@show-module() for outputting the results of this function.
See show-module() for outputting the results of this function.
- <!-- typlite:begin:list-item 0 -->content (string): Content of `.typ` file to analyze for docstrings.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->name (string): The name for the module.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->label-prefix (auto, string): The label-prefix for internal function
references. If `auto`, the label-prefix name will be the module name.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->require-all-parameters (boolean): Require that all parameters of a
functions are documented and fail if some are not.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->scope (dictionary): A dictionary of definitions that are then available
in all function and parameter descriptions.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->preamble (string): Code to prepend to all code snippets shown with `#example()`.
This can for instance be used to import something from the scope.<!-- typlite:end:list-item 0 -->
-> string
- <!-- typlite:begin:list-item 0 -->label-prefix (auto, string): The label-prefix for internal function references. If `auto`, the label-prefix name will be the module name.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->require-all-parameters (boolean): Require that all parameters of a functions are documented and fail if some are not.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->scope (dictionary): A dictionary of definitions that are then available in all function and parameter descriptions.<!-- typlite:end:list-item 0 -->
- <!-- typlite:begin:list-item 0 -->preamble (string): Code to prepend to all code snippets shown with `#example()`. This can for instance be used to import something from the scope.<!-- typlite:end:list-item 0 -->
-\> string

View file

@ -3,7 +3,7 @@ These again are dictionaries with the keys
- `types` (optional): A list of accepted argument types.
- `default` (optional): Default value for this argument.
See @@show-module() for outputting the results of this function.
See show-module() for outputting the results of this function.
- content (string): Content of `.typ` file to analyze for docstrings.
- name (string): The name for the module.

View file

@ -3,5 +3,16 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/base.typ
---
# Hello, World!
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1heading level="1"><span style="display: inline-block;">Hello, World!</span></m1heading><p>This is a typst document.</p></m1document></body>
</html>
=====
## Hello, World!
This is a typst document.

View file

@ -3,5 +3,15 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/enum.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><ol><li>A</li><li>B</li></ol></m1document></body>
</html>
=====
1. A
1. B
2. B

View file

@ -3,5 +3,15 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/enum2.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><ol><li value="2">A</li><li>B</li></ol></m1document></body>
</html>
=====
2. A
1. B
3. B

View file

@ -3,4 +3,16 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/figure_caption.typ
---
![Caption, Content](./fig.svg)
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1figure caption="Caption"><m1image src="./fig.svg" alt="Content"></m1image></m1figure></m1document></body>
</html>
=====
![Content](./fig.svg)
Caption

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/figure_image.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1figure caption=""><m1image src="./fig.svg" alt=""></m1image></m1figure></m1document></body>
</html>
=====
![](./fig.svg)

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/figure_image_alt.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1figure caption=""><m1image src="./fig.svg" alt="Content"></m1image></m1figure></m1document></body>
</html>
=====
![Content](./fig.svg)

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/image.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1image src="./fig.svg" alt=""></m1image></m1document></body>
</html>
=====
![](./fig.svg)

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/image_alt.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1image src="./fig.svg" alt="Content"></m1image></m1document></body>
</html>
=====
![Content](./fig.svg)

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/link.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><span><m1link dest="https://example.com">https://example.com</m1link></span></m1document></body>
</html>
=====
[https://example.com](https://example.com)

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/link2.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><span><m1link dest="https://example.com">Content</m1link></span></m1document></body>
</html>
=====
[Content](https://example.com)

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/link3.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><span><m1link dest="https://example.com">Reverse <span><m1strong>the World</m1strong></span></m1link></span></m1document></body>
</html>
=====
[Reverse **the World**](https://example.com)

View file

@ -3,5 +3,15 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/list.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><ul><li>Some <span><m1strong>item</m1strong></span></li><li>Another <span><m1emph>item</m1emph></span></li></ul></m1document></body>
</html>
=====
- Some **item**
- Another _item_

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/math_block.typ
---
<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="data:image-hash/svg+xml;base64,redacted"><img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" /></picture></p>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1eqblock>redacted-frame</m1eqblock></m1document></body>
</html>
=====
<img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" />

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/math_block2.typ
---
<p align="center"><picture><source media="(prefers-color-scheme: dark)" srcset="data:image-hash/svg+xml;base64,redacted"><img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" /></picture></p>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1eqblock>redacted-frame</m1eqblock></m1document></body>
</html>
=====
<img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" />

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/math_inline.typ
---
<picture><source media="(prefers-color-scheme: dark)" srcset="data:image-hash/svg+xml;base64,redacted"><img style="vertical-align: -0.35em" alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" /></picture>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1eqinline>redacted-frame</m1eqinline></m1document></body>
</html>
=====
<img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" />

View file

@ -3,4 +3,77 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/outline.typ
---
failed to convert to markdown: unknown variable: outline
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1outline><m1heading level="1"><span style="display: inline-block;">Contents</span></m1heading><m1outentry level="2"><m1heading level="2"><span style="display: inline-block;">Heading 1</span></m1heading></m1outentry><m1outentry level="3"><m1heading level="3"><span style="display: inline-block;">Heading 2</span></m1heading></m1outentry></m1outline><m1parbreak></m1parbreak><m1heading level="2"><span style="display: inline-block;">Heading 1</span></m1heading><m1parbreak></m1parbreak><m1heading level="3"><span style="display: inline-block;">Heading 2</span></m1heading><m1parbreak></m1parbreak><p><span><m1link dest="https://example.com">This is a link to example.com</m1link></span></p><m1parbreak></m1parbreak><p>Inline <span><m1raw lang="" block="false" text="code"></m1raw></span> has <span><m1raw lang="" block="false" text="back-ticks around"></m1raw></span> it.</p><m1parbreak></m1parbreak><m1raw lang="cs" block="true" text="using System.IO.Compression;
#pragma warning disable 414, 3021
namespace MyApplication
{
[Obsolete(&quot;...&quot;)]
class Program : IInterface
{
public static List<int> JustDoIt(int count)
{
Console.WriteLine($&quot;Hello {Name}!&quot;);
return new List<int>(new int[] { 1, 2, 3 })
}
}
}"></m1raw><m1parbreak></m1parbreak><p>Math inline:</p><m1eqinline>redacted-frame</m1eqinline><p>and block:</p><m1eqblock>redacted-frame</m1eqblock><m1parbreak></m1parbreak><ul><li>First item</li><li><p>Second item</p><ol><li>First sub-item</li><li><p>Second sub-item</p><ul><li>First sub-sub-item</li></ul></li></ol></li></ul><m1parbreak></m1parbreak><dl><dt>First term</dt><dd>First definition</dd></dl><m1parbreak></m1parbreak><m1table><table><tr><td>0</td><td>1</td><td>2</td><td>3</td><td>4</td><td>5</td><td>6</td><td>7</td><td>8</td><td>9</td><td>10</td><td>11</td><td>12</td><td>13</td><td>14</td><td>15</td><td>16</td><td>17</td><td>18</td><td>19</td></tr></table></m1table></m1document></body>
</html>
=====
## Contents
### Heading 1
#### Heading 2
### Heading 1
#### Heading 2
[This is a link to example.com](https://example.com)
Inline `code` has `back-ticks around` it.
```cs
using System.IO.Compression;
#pragma warning disable 414, 3021
namespace MyApplication
{
[Obsolete("...")]
class Program : IInterface
{
public static List<int> JustDoIt(int count)
{
Console.WriteLine($"Hello {Name}!");
return new List<int>(new int[] { 1, 2, 3 })
}
}
}
```
Math inline:<img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" />and block:
<img alt="typst-block" src="data:image-hash/svg+xml;base64,redacted" />
- First item
- Second item
1. First sub-item
2. Second sub-item
- First sub-sub-item
<dl><dt>First term</dt><dd>First definition</dd></dl>
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |

View file

@ -3,4 +3,14 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/raw_inline.typ
---
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document>Some inlined raw <span><m1raw lang="" block="false" text="a"></m1raw></span>, <span><m1raw lang="c" block="false" text="b"></m1raw></span></m1document></body>
</html>
=====
Some inlined raw `a`, `b`

View file

@ -3,4 +3,23 @@ source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
input_file: crates/typlite/src/fixtures/integration/table.typ
---
failed to convert to markdown: invalid columns argument of type Binary
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body><m1document><m1table><table><tr><td>0</td><td>1</td><td>2</td><td>3</td><td>4</td><td>5</td><td>6</td><td>7</td><td>8</td><td>9</td><td>10</td><td>11</td><td>12</td><td>13</td><td>14</td><td>15</td><td>16</td><td>17</td><td>18</td><td>19</td></tr></table></m1table><m1parbreak></m1parbreak><m1table><table><tr><td>0</td><td>1</td><td>2</td><td>3</td><td>4</td><td>5</td></tr><tr><td>6</td><td>7</td><td>8</td><td>9</td><td>10</td><td>11</td></tr><tr><td>12</td><td>13</td><td>14</td><td>15</td><td>16</td><td>17</td></tr><tr><td>18</td><td>19</td><td></td><td></td><td></td><td></td></tr></table></m1table><m1parbreak></m1parbreak><m1table><table><tr><td>0</td><td>1</td><td>2</td><td>3</td><td>4</td><td>5</td></tr><tr><td>6</td><td>7</td><td>8</td><td>9</td><td>10</td><td>11</td></tr><tr><td>12</td><td>13</td><td>14</td><td>15</td><td>16</td><td>17</td></tr><tr><td>18</td><td>19</td><td colspan="2">0</td><td colspan="2">1</td></tr><tr><td colspan="2">2</td><td colspan="2">3</td><td colspan="2">4</td></tr><tr><td colspan="2">5</td><td colspan="2">6</td><td colspan="2">7</td></tr><tr><td colspan="2">8</td><td colspan="2">9</td><td colspan="2">10</td></tr><tr><td colspan="2">11</td><td colspan="2">12</td><td colspan="2">13</td></tr><tr><td colspan="2">14</td><td colspan="2">15</td><td colspan="2">16</td></tr><tr><td colspan="2">17</td><td colspan="2">18</td><td colspan="2">19</td></tr></table></m1table></m1document></body>
</html>
=====
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 0 | 1 | 2 | 3 | 4 | 5 |
| --- | --- | --- | --- | --- | --- |
| 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 |
<table><tr><td>0</td><td>1</td><td>2</td><td>3</td><td>4</td><td>5</td></tr><tr><td>6</td><td>7</td><td>8</td><td>9</td><td>10</td><td>11</td></tr><tr><td>12</td><td>13</td><td>14</td><td>15</td><td>16</td><td>17</td></tr><tr><td>18</td><td>19</td><td colspan="2">0</td><td colspan="2">1</td></tr><tr><td colspan="2">2</td><td colspan="2">3</td><td colspan="2">4</td></tr><tr><td colspan="2">5</td><td colspan="2">6</td><td colspan="2">7</td></tr><tr><td colspan="2">8</td><td colspan="2">9</td><td colspan="2">10</td></tr><tr><td colspan="2">11</td><td colspan="2">12</td><td colspan="2">13</td></tr><tr><td colspan="2">14</td><td colspan="2">15</td><td colspan="2">16</td></tr><tr><td colspan="2">17</td><td colspan="2">18</td><td colspan="2">19</td></tr></table>

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,7 @@
//! # Typlite Library
use crate::{scopes::Scopes, tinymist_std::typst::diag::EcoString, worker::TypliteWorker};
use super::*;
use ecow::eco_format;
use typst_syntax::{ast, SyntaxKind, SyntaxNode};

View file

@ -1,6 +1,7 @@
#![doc = include_str!("../README.md")]
use std::{
io::Write,
path::{Path, PathBuf},
sync::Arc,
};
@ -8,7 +9,7 @@ use std::{
use clap::Parser;
use ecow::{eco_format, EcoString};
use tinymist_project::WorldProvider;
use typlite::{value::*, TypliteFeat};
use typlite::{common::Format, value::*, TypliteFeat};
use typlite::{CompileOnceArgs, Typlite};
/// Common arguments of compile, watch, and query.
@ -17,19 +18,13 @@ pub struct CompileArgs {
#[clap(flatten)]
pub compile: CompileOnceArgs,
/// Path to output file
#[clap(value_name = "OUTPUT")]
pub output: Option<String>,
/// Path to output file(s)
#[clap(value_name = "OUTPUT", action = clap::ArgAction::Append)]
pub outputs: Vec<String>,
/// Configures the path of assets directory
#[clap(long, default_value = None, value_name = "ASSETS_PATH")]
pub assets_path: Option<String>,
/// Configure the path to the assets' corresponding source code directory.
/// When the path is specified, typlite adds a href to jump to the source
/// code in the exported asset.
#[clap(long, default_value = None, value_name = "ASSETS_SRC_PATH")]
pub assets_src_path: Option<String>,
}
fn main() -> typlite::Result<()> {
@ -41,11 +36,16 @@ fn main() -> typlite::Result<()> {
.input
.as_ref()
.ok_or("Missing required argument: INPUT")?;
let output = match args.output {
Some(stdout_path) if stdout_path == "-" => None,
Some(output_path) => Some(PathBuf::from(output_path)),
None => Some(Path::new(input).with_extension("md")),
let outputs = if args.outputs.is_empty() {
vec![Path::new(input)
.with_extension("md")
.to_string_lossy()
.to_string()]
} else {
args.outputs.clone()
};
let assets_path = match args.assets_path {
Some(assets_path) => {
let path = PathBuf::from(assets_path);
@ -58,18 +58,6 @@ fn main() -> typlite::Result<()> {
}
None => None,
};
let assets_src_path = match args.assets_src_path {
Some(assets_src_path) => {
let path = PathBuf::from(assets_src_path);
if !path.exists() {
if let Err(e) = std::fs::create_dir_all(&path) {
return Err(format!("failed to create assets' src directory: {}", e).into());
}
}
Some(path)
}
None => None,
};
let universe = args.compile.resolve().map_err(|err| format!("{err:?}"))?;
let world = universe.snapshot();
@ -77,18 +65,60 @@ fn main() -> typlite::Result<()> {
let converter = Typlite::new(Arc::new(world))
.with_library(lib())
.with_feature(TypliteFeat {
assets_path,
assets_src_path,
assets_path: assets_path.clone(),
..Default::default()
});
let conv = converter.convert();
let doc = match converter.convert_doc() {
Ok(doc) => doc,
Err(err) => return Err(format!("failed to convert document: {err}").into()),
};
match (conv, output) {
(Ok(conv), None) => println!("{}", conv),
(Ok(conv), Some(output)) => std::fs::write(output, conv.as_str()).unwrap(),
(Err(err), ..) => {
eprintln!("{err}");
std::process::exit(1);
for output_path in &outputs {
let is_stdout = output_path == "-";
let output = if is_stdout {
None
} else {
Some(PathBuf::from(output_path))
};
let format = match &output {
Some(output) if output.extension() == Some(std::ffi::OsStr::new("tex")) => {
Format::LaTeX
}
Some(output) if output.extension() == Some(std::ffi::OsStr::new("docx")) => {
Format::Docx
}
_ => Format::Md,
};
match format {
Format::Docx => todo!(),
Format::LaTeX => todo!(),
Format::Md => {
let result = doc.to_md_string();
match (result, output) {
(Ok(content), None) => {
std::io::stdout()
.write_all(content.as_str().as_bytes())
.unwrap();
}
(Ok(content), Some(output)) => {
if let Err(err) = std::fs::write(&output, content.as_str()) {
eprintln!(
"failed to write Markdown file {}: {}",
output.display(),
err
);
continue;
}
println!("Generated Markdown file: {}", output.display());
}
(Err(err), _) => {
eprintln!("Error converting to Markdown for {}: {}", output_path, err);
continue;
}
}
}
}
}

View file

@ -0,0 +1,5 @@
[package]
name = "markdown"
version = "0.1.0"
entrypoint = "lib.typ"
description = "Markdown support for typst."

View file

@ -0,0 +1,151 @@
#let bool-str(x) = {
if x {
"true"
} else {
"false"
}
}
// typst doesn't allow things like `typParbreak`.
#let md-parbreak = html.elem("m1parbreak", "")
#let md-linebreak = html.elem("m1linebreak", "")
#let md-strong(body, delta: 0) = html.elem("span", html.elem("m1strong", body))
#let md-emph(body) = html.elem("span", html.elem("m1emph", body))
#let md-highlight(body) = html.elem("span", html.elem("m1highlight", body))
#let md-strike(body) = html.elem("span", html.elem("m1strike", body))
#let md-raw(lang: none, block: false, text) = {
let body = html.elem(
"m1raw",
attrs: (
lang: if lang == none {
""
} else {
lang
},
block: bool-str(block),
text: text,
),
"",
)
if block {
return body
} else {
html.elem("span", body)
}
}
#let md-link(dest: none, body) = html.elem(
"span",
html.elem(
"m1link",
attrs: (dest: dest),
body,
),
)
#let md-label(dest: none, body) = html.elem(
"m1label",
attrs: (dest: dest),
body,
)
#let md-ref(body) = html.elem(
"span",
html.elem(
"m1ref",
body,
),
)
#let md-heading(level: int, body) = html.elem(
"m1heading",
attrs: (level: str(level)),
box(body),
)
#let md-outline = html.elem.with("m1outline")
#let md-outline-entry(level: int, body) = html.elem(
"m1outentry",
attrs: (level: str(level)),
body,
)
#let md-quote(attribution: none, body) = html.elem(
"m1quote",
attrs: (attribution: attribution),
body,
)
#let md-table(it) = html.elem(
"m1table",
it,
)
#let md-grid(columns: auto, ..children) = html.elem(
"m1grid",
table(columns: columns, ..children.pos().map(it => table.cell(it))),
)
#let md-image(src: "", alt: none) = html.elem(
"m1image",
attrs: (
src: src,
alt: if alt == none {
""
} else {
alt
},
),
"",
)
#let md-figure(body, caption: none) = html.elem(
"m1figure",
attrs: (
caption: if caption == none {
""
} else {
if caption.body.func() == text {
caption.body.text
} else {
""
}
},
),
body,
)
#let if-not-paged(it, act) = {
if target() == "html" {
act
} else {
it
}
}
#let md-doc(body) = context {
// distinguish parbreak from <p> tag
show parbreak: it => if-not-paged(it, md-parbreak)
show strong: it => if-not-paged(it, md-strong(it.body, delta: it.delta))
show emph: it => if-not-paged(it, md-emph(it.body))
show highlight: it => if-not-paged(it, md-highlight(it))
show strike: it => if-not-paged(it, md-strike(it))
// todo: icc?
show image: it => if-not-paged(it, md-image(src: it.source, alt: it.alt))
show raw: it => if-not-paged(it, md-raw(lang: it.lang, block: it.block, it.text))
show link: it => if-not-paged(it, md-link(dest: it.dest, it.body))
show ref: it => if-not-paged(it, md-ref(it))
show heading: it => if-not-paged(it, md-heading(level: it.level, it.body))
show outline: it => if-not-paged(it, md-outline(it))
show outline.entry: it => if-not-paged(it, md-outline-entry(level: it.level, it.element))
show quote: it => if-not-paged(it, md-quote(attribution: it.attribution, it.body))
show table: it => if-not-paged(it, md-table(it))
show grid: it => if-not-paged(it, md-grid(columns: it.columns, ..it.children))
show math.equation.where(block: false): it => if-not-paged(
it,
html.elem("m1eqinline", html.frame(box(inset: 0.5em, it))),
)
show math.equation.where(block: true): it => if-not-paged(
it,
html.elem("m1eqblock", html.frame(block(inset: 0.5em, it))),
)
show linebreak: it => if-not-paged(it, md-linebreak)
show figure: it => if-not-paged(it, md-figure(it.body, caption: it.caption))
html.elem("m1document", body)
}

View file

@ -0,0 +1,293 @@
//! HTML parser core, containing main structures and general parsing logic
use cmark_writer::ast::{HtmlAttribute, HtmlElement as CmarkHtmlElement, Node};
use cmark_writer::CustomNode;
use typst::html::{tag, HtmlElement, HtmlNode};
use crate::attributes::{HeadingAttr, RawAttr, TypliteAttrsParser};
use crate::common::ListState;
use crate::tags::md_tag;
use crate::Result;
use crate::TypliteFeat;
use super::{inline::InlineParser, list::ListParser, media::MediaParser, table::TableParser};
/// HTML to AST parser implementation
pub struct HtmlToAstParser {
pub feat: TypliteFeat,
pub list_state: Option<ListState>,
pub list_level: usize,
pub blocks: Vec<Node>,
pub inline_buffer: Vec<Node>,
}
impl HtmlToAstParser {
pub fn new(feat: TypliteFeat) -> Self {
Self {
feat,
list_level: 0,
list_state: None,
blocks: Vec::new(),
inline_buffer: Vec::new(),
}
}
pub fn convert_element(&mut self, element: &HtmlElement) -> Result<()> {
match element.tag {
tag::head => Ok(()),
tag::html | tag::body | md_tag::doc => {
self.convert_children(element)?;
Ok(())
}
md_tag::parbreak => {
self.flush_inline_buffer();
Ok(())
}
md_tag::heading => {
self.flush_inline_buffer();
let attrs = HeadingAttr::parse(&element.attrs)?;
self.convert_children(element)?;
self.flush_inline_buffer_as_block(|content| {
Node::heading(attrs.level as u8 + 1, content)
});
Ok(())
}
tag::ol => {
self.flush_inline_buffer();
self.list_level += 1;
let items = ListParser::convert_list(self, element);
self.list_level -= 1;
self.blocks.push(Node::OrderedList {
start: 1,
items: items?,
});
Ok(())
}
tag::ul => {
self.flush_inline_buffer();
self.list_level += 1;
let items = ListParser::convert_list(self, element);
self.list_level -= 1;
self.blocks.push(Node::UnorderedList(items?));
Ok(())
}
md_tag::raw => {
let attrs = RawAttr::parse(&element.attrs)?;
if attrs.block {
self.flush_inline_buffer();
self.blocks
.push(Node::code_block(Some(attrs.lang.into()), attrs.text.into()));
} else {
self.inline_buffer.push(Node::InlineCode(attrs.text.into()));
}
Ok(())
}
md_tag::quote => {
self.flush_inline_buffer();
self.convert_children(element)?;
self.flush_inline_buffer_as_block(|content| {
Node::BlockQuote(vec![Node::Paragraph(content)])
});
Ok(())
}
md_tag::figure => InlineParser::convert_figure(self, element),
tag::p | tag::span => {
self.convert_children(element)?;
Ok(())
}
tag::strong | md_tag::strong => InlineParser::convert_strong(self, element),
tag::em | md_tag::emph => InlineParser::convert_emphasis(self, element),
md_tag::highlight => InlineParser::convert_highlight(self, element),
md_tag::strike => InlineParser::convert_strikethrough(self, element),
md_tag::link => InlineParser::convert_link(self, element),
md_tag::image => InlineParser::convert_image(self, element),
md_tag::linebreak => {
self.inline_buffer.push(Node::HardBreak);
Ok(())
}
md_tag::table | md_tag::grid => {
self.flush_inline_buffer();
if let Some(table) = TableParser::convert_table(self, element)? {
self.blocks.push(table);
}
Ok(())
}
md_tag::math_equation_inline | md_tag::math_equation_block => {
if element.tag == md_tag::math_equation_block {
self.flush_inline_buffer();
}
self.convert_children(element)?;
if element.tag == md_tag::math_equation_block {
self.flush_inline_buffer();
}
Ok(())
}
_ => {
let tag_name = element.tag.resolve().to_string();
if !tag_name.starts_with("m1") {
let html_element = self.create_html_element(element)?;
self.inline_buffer.push(html_element);
} else {
self.convert_children(element)?;
}
Ok(())
}
}
}
/// Create a CommonMark HTML element from the given HTML element
pub(crate) fn create_html_element(&mut self, element: &HtmlElement) -> Result<Node> {
let attributes = element
.attrs
.0
.iter()
.map(|(name, value)| HtmlAttribute {
name: name.to_string(),
value: value.to_string(),
})
.collect();
let mut children = Vec::new();
self.convert_children_into(&mut children, element)?;
Ok(Node::HtmlElement(CmarkHtmlElement {
tag: element.tag.resolve().to_string(),
attributes,
children,
self_closing: element.children.is_empty(),
}))
}
pub fn flush_inline_buffer(&mut self) {
if !self.inline_buffer.is_empty() {
self.blocks
.push(Node::Paragraph(std::mem::take(&mut self.inline_buffer)));
}
}
pub fn flush_inline_buffer_as_block(&mut self, make_block: impl FnOnce(Vec<Node>) -> Node) {
if !self.inline_buffer.is_empty() {
self.blocks
.push(make_block(std::mem::take(&mut self.inline_buffer)));
}
}
pub fn convert_children(&mut self, element: &HtmlElement) -> Result<()> {
for child in &element.children {
match child {
HtmlNode::Text(text, _) => {
self.inline_buffer
.push(Node::Text(text.as_str().to_string()));
}
HtmlNode::Element(element) => {
self.convert_element(element)?;
}
HtmlNode::Frame(frame) => {
self.inline_buffer
.push(MediaParser::convert_frame(self, frame));
}
_ => {}
}
}
Ok(())
}
pub fn convert_children_into(
&mut self,
target: &mut Vec<Node>,
element: &HtmlElement,
) -> Result<()> {
let prev_buffer = std::mem::take(&mut self.inline_buffer);
self.convert_children(element)?;
target.append(&mut self.inline_buffer);
self.inline_buffer = prev_buffer;
Ok(())
}
pub(crate) fn begin_list(&mut self) {
if self.feat.annotate_elem {
self.inline_buffer
.push(Node::Custom(Box::new(Comment(format!(
"typlite:begin:list-item {}",
self.list_level - 1
)))))
}
}
pub(crate) fn end_list(&mut self) {
if self.feat.annotate_elem {
self.inline_buffer
.push(Node::Custom(Box::new(Comment(format!(
"typlite:end:list-item {}",
self.list_level - 1
)))))
}
}
}
#[derive(Debug, Clone)]
struct Comment(String);
impl CustomNode for Comment {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn write(
&self,
writer: &mut dyn cmark_writer::CustomNodeWriter,
) -> cmark_writer::WriteResult<()> {
writer.write_str("<!-- ")?;
writer.write_str(&self.0)?;
writer.write_str(" -->")?;
Ok(())
}
fn clone_box(&self) -> Box<dyn CustomNode> {
Box::new(self.clone())
}
fn eq_box(&self, other: &dyn CustomNode) -> bool {
if let Some(other) = other.as_any().downcast_ref::<Comment>() {
self.0 == other.0
} else {
false
}
}
fn is_block(&self) -> bool {
false
}
}
impl HtmlToAstParser {
pub fn parse(mut self, root: &HtmlElement) -> Result<Node> {
self.blocks.clear();
self.inline_buffer.clear();
self.convert_element(root)?;
self.flush_inline_buffer();
Ok(Node::Document(self.blocks))
}
}

View file

@ -0,0 +1,98 @@
//! Inline element processing module, handles text and inline style elements
use cmark_writer::ast::Node;
use typst::html::HtmlElement;
use crate::attributes::{FigureAttr, ImageAttr, LinkAttr, TypliteAttrsParser};
use crate::common::{FigureNode, HighlightNode};
use crate::Result;
use super::core::HtmlToAstParser;
/// Inline style element parser
pub struct InlineParser;
impl InlineParser {
/// Convert strong emphasis element
pub fn convert_strong(parser: &mut HtmlToAstParser, element: &HtmlElement) -> Result<()> {
let mut content = Vec::new();
parser.convert_children_into(&mut content, element)?;
parser.inline_buffer.push(Node::Strong(content));
Ok(())
}
/// Convert emphasis element
pub fn convert_emphasis(parser: &mut HtmlToAstParser, element: &HtmlElement) -> Result<()> {
let mut content = Vec::new();
parser.convert_children_into(&mut content, element)?;
parser.inline_buffer.push(Node::Emphasis(content));
Ok(())
}
/// Convert highlight element
pub fn convert_highlight(parser: &mut HtmlToAstParser, element: &HtmlElement) -> Result<()> {
let mut content = Vec::new();
parser.convert_children_into(&mut content, element)?;
parser
.inline_buffer
.push(Node::Custom(Box::new(HighlightNode { content })));
Ok(())
}
/// Convert strikethrough element
pub fn convert_strikethrough(
parser: &mut HtmlToAstParser,
element: &HtmlElement,
) -> Result<()> {
let mut content = Vec::new();
parser.convert_children_into(&mut content, element)?;
parser.inline_buffer.push(Node::Strikethrough(content));
Ok(())
}
/// Convert link element
pub fn convert_link(parser: &mut HtmlToAstParser, element: &HtmlElement) -> Result<()> {
let attrs = LinkAttr::parse(&element.attrs)?;
let mut content = Vec::new();
parser.convert_children_into(&mut content, element)?;
parser.inline_buffer.push(Node::Link {
url: attrs.dest.into(),
title: None,
content,
});
Ok(())
}
/// Convert image element
pub fn convert_image(parser: &mut HtmlToAstParser, element: &HtmlElement) -> Result<()> {
let attrs = ImageAttr::parse(&element.attrs)?;
let src = attrs.src.as_str();
parser.inline_buffer.push(Node::Image {
url: src.to_string(),
title: None,
alt: vec![Node::Text(attrs.alt.into())],
});
Ok(())
}
/// Convert figure element
pub fn convert_figure(parser: &mut HtmlToAstParser, element: &HtmlElement) -> Result<()> {
parser.flush_inline_buffer();
// Parse figure attributes to extract caption
let attrs = FigureAttr::parse(&element.attrs)?;
let caption = attrs.caption.to_string();
// Find image and body content
let mut body_content = Vec::new();
parser.convert_children_into(&mut body_content, element)?;
let body = Box::new(Node::Paragraph(body_content));
// Create figure node using generic definition
parser
.blocks
.push(Node::Custom(Box::new(FigureNode { body, caption })));
Ok(())
}
}

View file

@ -0,0 +1,88 @@
//! HTML list parsing module, handling conversion of ordered and unordered lists
use cmark_writer::ast::{ListItem, Node};
use typst::html::{tag, HtmlElement, HtmlNode};
use crate::attributes::{ListItemAttr, TypliteAttrsParser};
use crate::Result;
use super::core::HtmlToAstParser;
/// List parser
pub struct ListParser;
impl ListParser {
/// Convert HTML list to ListItem vector
pub fn convert_list(
parser: &mut HtmlToAstParser,
element: &HtmlElement,
) -> Result<Vec<ListItem>> {
let mut all_items = Vec::new();
let prev_buffer = std::mem::take(&mut parser.inline_buffer);
let is_ordered = element.tag == tag::ol;
for child in &element.children {
if let HtmlNode::Element(li) = child {
if li.tag == tag::li {
let attrs = ListItemAttr::parse(&li.attrs)?;
let mut item_content = Vec::new();
parser.begin_list();
for li_child in &li.children {
match li_child {
HtmlNode::Text(text, _) => {
parser
.inline_buffer
.push(Node::Text(text.as_str().to_string()));
}
HtmlNode::Element(child_elem) => {
if child_elem.tag == tag::ul || child_elem.tag == tag::ol {
// Handle nested lists
if !parser.inline_buffer.is_empty() {
item_content.push(Node::Paragraph(std::mem::take(
&mut parser.inline_buffer,
)));
}
let items = Self::convert_list(parser, child_elem)?;
if child_elem.tag == tag::ul {
item_content.push(Node::UnorderedList(items));
} else {
item_content.push(Node::OrderedList { start: 1, items });
}
} else {
parser.convert_element(child_elem)?;
}
}
_ => {}
}
}
parser.end_list();
if !parser.inline_buffer.is_empty() {
item_content
.push(Node::Paragraph(std::mem::take(&mut parser.inline_buffer)));
}
if !item_content.is_empty() {
if is_ordered {
all_items.push(ListItem::Ordered {
number: attrs.value,
content: item_content,
});
} else {
all_items.push(ListItem::Unordered {
content: item_content,
});
}
}
}
}
}
parser.inline_buffer = prev_buffer;
Ok(all_items)
}
}

View file

@ -0,0 +1,74 @@
//! Media processing module, handles images, SVG and Frame media elements
use base64::Engine;
use cmark_writer::ast::{HtmlAttribute, HtmlElement as CmarkHtmlElement, Node};
use std::sync::atomic::{AtomicUsize, Ordering};
use typst::layout::Frame;
use crate::common::ExternalFrameNode;
use super::core::HtmlToAstParser;
/// Media content parser
pub struct MediaParser;
impl MediaParser {
/// Convert Typst frame to CommonMark node
pub fn convert_frame(parser: &HtmlToAstParser, frame: &Frame) -> Node {
let svg = typst_svg::svg_frame(frame);
let data = base64::engine::general_purpose::STANDARD.encode(svg.as_bytes());
if let Some(assets_path) = &parser.feat.assets_path {
// Use a unique static counter to generate filenames
static FRAME_COUNTER: AtomicUsize = AtomicUsize::new(0);
let file_id = FRAME_COUNTER.fetch_add(1, Ordering::Relaxed);
let file_name = format!("frame_{}.svg", file_id);
let file_path = assets_path.join(&file_name);
if let Err(e) = std::fs::write(&file_path, svg.as_bytes()) {
if parser.feat.soft_error {
return Self::create_embedded_frame(&data);
} else {
// Construct error node
return Node::HtmlElement(CmarkHtmlElement {
tag: "div".to_string(),
attributes: vec![HtmlAttribute {
name: "class".to_string(),
value: "error".to_string(),
}],
children: vec![Node::Text(format!("Error writing frame to file: {}", e))],
self_closing: false,
});
}
}
return Node::Custom(Box::new(ExternalFrameNode {
file_path,
alt_text: "typst-frame".to_string(),
svg_data: data,
}));
}
// Fall back to embedded mode if no external asset path is specified
Self::create_embedded_frame(&data)
}
/// Create embedded frame node
fn create_embedded_frame(data: &str) -> Node {
Node::HtmlElement(CmarkHtmlElement {
tag: "img".to_string(),
attributes: vec![
HtmlAttribute {
name: "alt".to_string(),
value: "typst-block".to_string(),
},
HtmlAttribute {
name: "src".to_string(),
value: format!("data:image/svg+xml;base64,{data}"),
},
],
children: vec![],
self_closing: true,
})
}
}

View file

@ -0,0 +1,9 @@
//! Parser implementation for Typst HTML to CommonMark AST
mod core;
mod inline;
mod list;
mod media;
mod table;
pub use core::HtmlToAstParser;

View file

@ -0,0 +1,190 @@
//! HTML table parsing module, processes the conversion of table elements
use cmark_writer::ast::Node;
use cmark_writer::gfm::TableAlignment;
use typst::html::{tag, HtmlElement, HtmlNode};
use typst::utils::PicoStr;
use crate::tags::md_tag;
use crate::Result;
use super::core::HtmlToAstParser;
/// Table parser
pub struct TableParser;
impl TableParser {
/// Convert HTML table to CommonMark AST
pub fn convert_table(
parser: &mut HtmlToAstParser,
element: &HtmlElement,
) -> Result<Option<Node>> {
// Find the real table element
let real_table_elem = Self::find_real_table_element(element);
// Process the table (if found)
if let Some(table) = real_table_elem {
// Check if the table contains rowspan or colspan attributes
// If it does, fall back to using HtmlElement
if Self::table_has_complex_cells(table) {
if let Ok(html_node) = parser.create_html_element(table) {
return Ok(Some(html_node));
}
return Ok(None);
}
let mut headers = Vec::new();
let mut rows = Vec::new();
let mut is_header = true;
Self::extract_table_content(parser, table, &mut headers, &mut rows, &mut is_header)?;
return Self::create_table_node(headers, rows);
}
Ok(None)
}
/// Find the real table element in the HTML structure
fn find_real_table_element(element: &HtmlElement) -> Option<&HtmlElement> {
if element.tag == md_tag::grid {
// For grid: grid -> table -> table
Self::find_table_in_grid(element)
} else {
// For m1table -> table
Self::find_table_direct(element)
}
}
fn find_table_in_grid(grid_element: &HtmlElement) -> Option<&HtmlElement> {
for child in &grid_element.children {
if let HtmlNode::Element(table_elem) = child {
if table_elem.tag == md_tag::table {
// Find table tag within m1table
for inner_child in &table_elem.children {
if let HtmlNode::Element(inner) = inner_child {
if inner.tag == tag::table {
return Some(inner);
}
}
}
}
}
}
None
}
fn find_table_direct(element: &HtmlElement) -> Option<&HtmlElement> {
for child in &element.children {
if let HtmlNode::Element(table_elem) = child {
if table_elem.tag == tag::table {
return Some(table_elem);
}
}
}
None
}
// Extract table content from the table element
fn extract_table_content(
parser: &mut HtmlToAstParser,
table: &HtmlElement,
headers: &mut Vec<Vec<Node>>,
rows: &mut Vec<Vec<Vec<Node>>>,
is_header: &mut bool,
) -> Result<()> {
// Process rows in the table
for row_node in &table.children {
if let HtmlNode::Element(row_elem) = row_node {
if row_elem.tag == tag::tr {
let current_row =
Self::process_table_row(parser, row_elem, *is_header, headers)?;
// After the first row, treat remaining rows as data rows
if *is_header {
*is_header = false;
} else if !current_row.is_empty() {
rows.push(current_row);
}
}
}
}
Ok(())
}
fn process_table_row(
parser: &mut HtmlToAstParser,
row_elem: &HtmlElement,
is_header: bool,
headers: &mut Vec<Vec<Node>>,
) -> Result<Vec<Vec<Node>>> {
let mut current_row = Vec::new();
// Process cells in this row
for cell_node in &row_elem.children {
if let HtmlNode::Element(cell) = cell_node {
if cell.tag == tag::td {
let mut cell_content = Vec::new();
parser.convert_children_into(&mut cell_content, cell)?;
// Add to appropriate section
if is_header {
headers.push(cell_content);
} else {
current_row.push(cell_content);
}
}
}
}
Ok(current_row)
}
/// Check if the table has complex cells (rowspan/colspan)
fn table_has_complex_cells(table: &HtmlElement) -> bool {
for row_node in &table.children {
if let HtmlNode::Element(row_elem) = row_node {
if row_elem.tag == tag::tr {
for cell_node in &row_elem.children {
if let HtmlNode::Element(cell) = cell_node {
if (cell.tag == tag::td || cell.tag == tag::th)
&& cell.attrs.0.iter().any(|(name, _)| {
let name = name.into_inner();
name == PicoStr::constant("colspan")
|| name == PicoStr::constant("rowspan")
})
{
return true;
}
}
}
}
}
}
false
}
fn create_table_node(
headers: Vec<Vec<Node>>,
rows: Vec<Vec<Vec<Node>>>,
) -> Result<Option<Node>> {
// Create alignment array (default to None for all columns)
let alignments = vec![TableAlignment::None; headers.len().max(1)];
// If there is content, add the table to blocks
if !headers.is_empty() || !rows.is_empty() {
let flattened_headers = headers.into_iter().flatten().collect();
let flattened_rows: Vec<_> = rows
.into_iter()
.map(|row| row.into_iter().flatten().collect())
.collect();
return Ok(Some(Node::Table {
headers: flattened_headers,
rows: flattened_rows,
alignments,
}));
}
Ok(None)
}
}

View file

@ -0,0 +1,43 @@
//! Custom HTML tags used by Typlite
/// Tag definitions specific to markdown conversion
pub mod md_tag {
use typst::html::HtmlTag;
macro_rules! tags {
($($tag:ident -> $name:ident)*) => {
$(#[allow(non_upper_case_globals)]
pub const $tag: HtmlTag = HtmlTag::constant(
stringify!($name)
);)*
}
}
tags! {
parbreak -> m1parbreak
linebreak -> m1linebreak
image -> m1image
strong -> m1strong
emph -> m1emph
highlight -> m1highlight
strike -> m1strike
raw -> m1raw
label -> m1label
reference -> m1ref
heading -> m1heading
outline -> m1outline
outline_entry -> m1outentry
quote -> m1quote
table -> m1table
// table_cell -> m1tablecell
grid -> m1grid
// grid_cell -> m1gridcell
figure -> m1figure
math_equation_inline -> m1eqinline
math_equation_block -> m1eqblock
doc -> m1document
link -> m1link
}
}

View file

@ -1,6 +1,8 @@
use std::sync::OnceLock;
use regex::Regex;
use typst::html::{HtmlNode, HtmlTag};
use typst_syntax::Span;
use super::*;
@ -24,22 +26,49 @@ fn convert_docs() {
});
}
fn conv(world: LspWorld, for_docs: bool) -> EcoString {
fn conv(world: LspWorld, for_docs: bool) -> String {
let converter = Typlite::new(Arc::new(world)).with_feature(TypliteFeat {
annotate_elem: for_docs,
..Default::default()
});
match converter.convert() {
Ok(conv) => {
let doc = match converter.convert_doc() {
Ok(doc) => doc,
Err(err) => return format!("failed to convert to markdown: {err}"),
};
let repr = typst_html::html(&redact(doc.base.clone())).unwrap();
let res = doc.to_md_string().unwrap();
static REG: OnceLock<Regex> = OnceLock::new();
let reg =
REG.get_or_init(|| Regex::new(r#"data:image/svg\+xml;base64,([^"]+)"#).unwrap());
let res = reg.replace_all(&conv, |_captures: &regex::Captures| {
let reg = REG.get_or_init(|| Regex::new(r#"data:image/svg\+xml;base64,([^"]+)"#).unwrap());
let res = reg.replace_all(&res, |_captures: &regex::Captures| {
"data:image-hash/svg+xml;base64,redacted"
});
res.into()
[repr.as_str(), res.as_ref()].join("\n=====\n")
}
Err(err) => format!("failed to convert to markdown: {err}").into(),
fn redact(doc: HtmlDocument) -> HtmlDocument {
let mut doc = doc;
for node in doc.root.children.iter_mut() {
redact_node(node);
}
doc
}
fn redact_node(node: &mut HtmlNode) {
match node {
HtmlNode::Element(elem) => {
if elem.tag == HtmlTag::constant("svg") {
elem.children = vec![];
} else {
for child in elem.children.iter_mut() {
redact_node(child);
}
}
}
HtmlNode::Frame(_) => {
*node = HtmlNode::Text("redacted-frame".into(), Span::detached());
}
_ => {}
}
}

View file

@ -1,6 +1,12 @@
//! # Typlite Values
use crate::tinymist_std::typst::diag::EcoString;
use crate::worker::TypliteWorker;
use core::fmt;
use typst_syntax::{
ast::{self, AstNode},
SyntaxNode,
};
use crate::*;

View file

@ -0,0 +1,824 @@
use std::fmt::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use base64::Engine;
use ecow::{eco_format, EcoString};
use tinymist_analysis;
use tinymist_project::base::ShadowApi;
use tinymist_project::{EntryReader, LspWorld};
use typst::foundations::{Bytes, Dict, IntoValue};
use typst::layout::Abs;
use typst::syntax::{FileId, Source, SyntaxKind, SyntaxNode};
use typst::utils::LazyHash;
use typst::World;
use typst::WorldExt;
use typst_syntax::ast::{self, AstNode};
use typst_syntax::ast::{Emph, Equation, Heading, Raw, Strong};
use crate::scopes::Scopes;
use crate::tinymist_std::path::unix_slash;
use crate::value::{Args, Value};
use crate::worker::SyntaxKind::Text;
use crate::Result;
use crate::TypliteFeat;
use crate::WorkspaceResolver;
/// Typlite worker for converting syntax nodes to markdown
#[derive(Clone)]
pub struct TypliteWorker {
pub current: FileId,
pub scopes: Arc<Scopes<Value>>,
pub world: Arc<LspWorld>,
pub list_depth: usize,
pub prepend_code: EcoString,
pub assets_numbering: usize,
/// Features for the conversion.
pub feat: TypliteFeat,
}
impl TypliteWorker {
/// Convert the content to a markdown string.
pub fn convert(&mut self, node: &SyntaxNode) -> Result<EcoString> {
Ok(Self::value(self.eval(node)?))
}
/// Eval the content
pub fn eval(&mut self, node: &SyntaxNode) -> Result<Value> {
use SyntaxKind::*;
let res = match node.kind() {
RawLang | RawDelim | RawTrimmed => Err("converting clause")?,
Math | MathIdent | MathAlignPoint | MathDelimited | MathAttach | MathPrimes
| MathFrac | MathRoot | MathShorthand | MathText => Err("converting math node")?,
// Error nodes
Error => Err(node.clone().into_text().to_string())?,
None | End => Ok(Value::None),
// Non-leaf nodes
Markup => self.reduce(node),
Code => self.reduce(node),
Equation => self.equation(node),
CodeBlock => {
let code_block: ast::CodeBlock = node.cast().unwrap();
self.eval(code_block.body().to_untyped())
}
ContentBlock => {
let content_block: ast::ContentBlock = node.cast().unwrap();
self.eval(content_block.body().to_untyped())
}
Parenthesized => {
let parenthesized: ast::Parenthesized = node.cast().unwrap();
self.eval(parenthesized.expr().to_untyped())
}
// Text nodes
Text | Space | Parbreak => Self::str(node),
Linebreak => Self::char('\n'),
// Semantic nodes
Escape => Self::escape(node),
Shorthand => Self::shorthand(node),
SmartQuote => Self::str(node),
Strong => self.strong(node),
Emph => self.emph(node),
Raw => Self::raw(node),
Link => self.link(node),
Label => Self::label(node),
Ref => Self::label_ref(node),
RefMarker => Self::ref_marker(node),
Heading => self.heading(node),
HeadingMarker => Self::str(node),
ListItem => self.list_item(node),
ListMarker => Self::str(node),
EnumItem => self.enum_item(node),
EnumMarker => Self::str(node),
TermItem => self.term_item(node),
TermMarker => Self::str(node),
// Punctuation
// Hash => Self::char('#'),
Hash => Ok(Value::None),
LeftBrace => Self::char('{'),
RightBrace => Self::char('}'),
LeftBracket => Self::char('['),
RightBracket => Self::char(']'),
LeftParen => Self::char('('),
RightParen => Self::char(')'),
Comma => Self::char(','),
Semicolon => Ok(Value::None),
Colon => Self::char(':'),
Star => Self::char('*'),
Underscore => Self::char('_'),
Dollar => Self::char('$'),
Plus => Self::char('+'),
Minus => Self::char('-'),
Slash => Self::char('/'),
Hat => Self::char('^'),
Prime => Self::char('\''),
Dot => Self::char('.'),
Eq => Self::char('='),
Lt => Self::char('<'),
Gt => Self::char('>'),
// Compound punctuation
EqEq => Self::str(node),
ExclEq => Self::str(node),
LtEq => Self::str(node),
GtEq => Self::str(node),
PlusEq => Self::str(node),
HyphEq => Self::str(node),
StarEq => Self::str(node),
SlashEq => Self::str(node),
Dots => Self::str(node),
Arrow => Self::str(node),
Root => Self::str(node),
// Keywords
Auto => Self::str(node),
Not => Self::str(node),
And => Self::str(node),
Or => Self::str(node),
Let => Self::str(node),
Set => Self::str(node),
Show => Self::str(node),
Context => Self::str(node),
If => Self::str(node),
Else => Self::str(node),
For => Self::str(node),
In => Self::str(node),
While => Self::str(node),
Break => Self::str(node),
Continue => Self::str(node),
Return => Self::str(node),
Import => Self::str(node),
Include => Self::str(node),
As => Self::str(node),
LetBinding => self.let_binding(node),
FieldAccess => self.field_access(node),
FuncCall => self.func_call(node),
Contextual => self.contextual(node),
// Clause nodes
Named => Ok(Value::None),
Keyed => Ok(Value::None),
Unary => Ok(Value::None),
Binary => Ok(Value::None),
Spread => Ok(Value::None),
ImportItems => Ok(Value::None),
ImportItemPath => Ok(Value::None),
RenamedImportItem => Ok(Value::None),
Closure => Ok(Value::None),
Args => Ok(Value::None),
Params => Ok(Value::None),
// Ignored code expressions
Ident => Ok(Value::None),
Bool => Ok(Value::None),
Int => Ok(Value::None),
Float => Ok(Value::None),
Numeric => Ok(Value::None),
Str => Ok(Value::Str({
let s: ast::Str = node.cast().unwrap();
s.get()
})),
Array => Ok(Value::None),
Dict => Ok(Value::None),
// Ignored code expressions
SetRule => Ok(Value::None),
ShowRule => Ok(Value::None),
Destructuring => Ok(Value::None),
DestructAssignment => Ok(Value::None),
Conditional => Ok(Value::None),
WhileLoop => Ok(Value::None),
ForLoop => Ok(Value::None),
LoopBreak => Ok(Value::None),
LoopContinue => Ok(Value::None),
FuncReturn => Ok(Value::None),
ModuleImport => Ok(Value::None),
ModuleInclude => self.include(node),
// Ignored comments
LineComment => Ok(Value::None),
BlockComment => Ok(Value::None),
Shebang => Ok(Value::None),
};
if res.clone()? == Value::None
&& !matches!(
node.kind(),
Ident | Bool | Int | Float | Numeric | Str | Array | Dict
)
{
self.prepend_code += node.clone().into_text();
if node.kind() != Hash {
self.prepend_code += "\n"
};
}
res
}
fn reduce(&mut self, node: &SyntaxNode) -> Result<Value> {
let mut s = EcoString::new();
for child in node.children() {
s.push_str(&Self::value(self.eval(child)?));
}
Ok(Value::Content(s))
}
pub fn to_raw_block(&mut self, node: &SyntaxNode, inline: bool) -> Result<Value> {
let content = node.clone().into_text();
let s = if inline {
let mut s = EcoString::with_capacity(content.len() + 2);
s.push_str("`");
s.push_str(&content);
s.push_str("`");
s
} else {
let mut s = EcoString::with_capacity(content.len() + 15);
s.push_str("```");
let lang = match node.cast::<ast::Expr>() {
Some(ast::Expr::Text(..) | ast::Expr::Space(..)) => "typ",
Some(..) => "typc",
None => "typ",
};
s.push_str(lang);
s.push('\n');
s.push_str(&content);
s.push('\n');
s.push_str("```");
s
};
Ok(Value::Content(s))
}
pub fn render(
&mut self,
prepend_node: &SyntaxNode,
node: &SyntaxNode,
inline: bool,
) -> Result<Value> {
self.assets_numbering += 1;
let prepend_code = prepend_node.clone().into_text();
let code = node.clone().into_text();
// if let Some(assets_src_path) = &self.feat.assets_src_path {
// let file_name = assets_src_path
// .join(self.assets_numbering.to_string())
// .with_extension("typ");
// if let Err(e) = std::fs::write(&file_name, format!("#{{\n// render_code\n{}\n}}", code))
// {
// return Err(format!("failed to write code to file: {}", e).into());
// }
// }
self.render_code(&prepend_code, &code, false, "center", "", inline)
}
pub fn render_code(
&mut self,
prepend_code: &str,
code: &str,
is_markup: bool,
align: &str,
extra_attrs: &str,
inline: bool,
) -> Result<Value> {
let theme = self.feat.color_theme;
// let code_file_name = if let Some(assets_src_path) = &self.feat.assets_src_path {
// Some(
// assets_src_path
// .join(self.assets_numbering.to_string())
// .with_extension("typ"),
// )
// } else {
// None
// };
let code_file_name = None;
let mut render = |theme| self.render_inner(prepend_code, code, is_markup, theme);
let mut content = EcoString::new();
let inline_attrs = if inline {
r#" style="vertical-align: -0.35em""#
} else {
""
};
let write_error = |content: &mut EcoString, err: &str| {
let err = err.replace("`", r#"\`"#);
let _ = write!(content, "```\nRender Error\n{err}\n```");
};
let write_image = |content: &mut EcoString,
file_name: &std::path::Path,
code_file_name: Option<&PathBuf>,
inline_attrs: &str,
extra_attrs: &str| {
if let Some(code_file_name) = code_file_name {
let _ = write!(
content,
r#"<a href="{}"><img{inline_attrs} alt="typst-block" src="{}" {extra_attrs}/></a>"#,
code_file_name.display(),
file_name.display()
);
} else {
let _ = write!(
content,
r#"<img{inline_attrs} alt="typst-block" src="{}" {extra_attrs}/>"#,
file_name.display()
);
}
};
let write_picture = |content: &mut EcoString,
dark_file_name: &std::path::Path,
light_file_name: &std::path::Path,
code_file_name: Option<&PathBuf>,
inline_attrs: &str,
extra_attrs: &str| {
if let Some(code_file_name) = code_file_name {
let _ = write!(
content,
r#"<a href="{}"><picture><source media="(prefers-color-scheme: dark)" srcset="{}"><img{inline_attrs} alt="typst-block" src="{}" {extra_attrs}/></picture></a>"#,
code_file_name.display(),
dark_file_name.display(),
light_file_name.display()
);
} else {
let _ = write!(
content,
r#"<picture><source media="(prefers-color-scheme: dark)" srcset="{}"><img{inline_attrs} alt="typst-block" src="{}" {extra_attrs}/></picture>"#,
dark_file_name.display(),
light_file_name.display()
);
}
};
match theme {
Some(theme) => {
let data = match render(theme) {
Ok(data) => data,
Err(err) if self.feat.soft_error => {
write_error(&mut content, &err.to_string());
return Ok(Value::Content(content));
}
Err(err) => return Err(err),
};
if !inline {
let _ = write!(content, r#"<p align="{align}">"#);
}
if let Some(assets_path) = &self.feat.assets_path {
let file_name =
assets_path.join(format!("{}_{:?}.svg", self.assets_numbering, theme));
std::fs::write(&file_name, &data)
.map_err(|e| format!("failed to write SVG to file: {}", e))?;
write_image(
&mut content,
&file_name,
code_file_name.as_ref(),
inline_attrs,
extra_attrs,
);
} else {
let _ = write!(
content,
r#"<img{inline_attrs} alt="typst-block" src="data:image/svg+xml;base64,{data}" {extra_attrs}/>"#
);
}
if !inline {
content.push_str("</p>");
}
}
None => {
let dark = match render(crate::ColorTheme::Dark) {
Ok(d) => d,
Err(err) if self.feat.soft_error => {
write_error(&mut content, &err.to_string());
return Ok(Value::Content(content));
}
Err(err) => return Err(err),
};
let light = match render(crate::ColorTheme::Light) {
Ok(l) => l,
Err(err) if self.feat.soft_error => {
write_error(&mut content, &err.to_string());
return Ok(Value::Content(content));
}
Err(err) => return Err(err),
};
if !inline {
let _ = write!(content, r#"<p align="{align}">"#);
}
if let Some(assets_path) = &self.feat.assets_path {
let dark_file_name = assets_path.join(format!(
"{}_{:?}.svg",
self.assets_numbering,
crate::ColorTheme::Dark
));
let light_file_name = assets_path.join(format!(
"{}_{:?}.svg",
self.assets_numbering,
crate::ColorTheme::Light
));
write_picture(
&mut content,
&dark_file_name,
&light_file_name,
code_file_name.as_ref(),
inline_attrs,
extra_attrs,
);
} else {
let _ = write!(
content,
r#"<picture><source media="(prefers-color-scheme: dark)" srcset="data:image/svg+xml;base64,{dark}"><img{inline_attrs} alt="typst-block" src="data:image/svg+xml;base64,{light}" {extra_attrs}/></picture>"#
);
}
if !inline {
content.push_str("</p>");
}
}
}
Ok(Value::Content(content))
}
fn render_inner(
&mut self,
prepend_code: &str,
code: &str,
is_markup: bool,
theme: crate::ColorTheme,
) -> Result<String> {
static DARK_THEME_INPUT: LazyLock<Arc<LazyHash<Dict>>> = LazyLock::new(|| {
Arc::new(LazyHash::new(Dict::from_iter(std::iter::once((
"x-color-theme".into(),
"dark".into_value(),
)))))
});
let code = WrapCode(code, is_markup);
let inputs = match theme {
crate::ColorTheme::Dark => Some(DARK_THEME_INPUT.clone()),
crate::ColorTheme::Light => None,
};
let code = eco_format!(
r##"{prepend_code}
#set page(width: auto, height: auto, margin: (y: 0.45em, rest: 0em), fill: none);
#set text(fill: rgb("#c0caf5")) if sys.inputs.at("x-color-theme", default: none) == "dark";
{code}"##
);
let main = Bytes::new(code.as_bytes().to_owned());
let path = Path::new("__render__.typ");
let entry = self.world.entry_state().select_in_workspace(path);
let mut world = self.world.task(tinymist_project::TaskInputs {
entry: Some(entry),
inputs,
});
world.take_db();
world.map_shadow_by_id(world.main(), main).unwrap();
let document = typst::compile(&world).output;
let document = document.map_err(|diagnostics| {
let mut err = String::new();
let _ = write!(err, "compiling node: ");
let write_span = |span: typst_syntax::Span, err: &mut String| {
let file = span.id().map(|id| match id.package() {
Some(package) if WorkspaceResolver::is_package_file(id) => {
format!("{package}:{}", unix_slash(id.vpath().as_rooted_path()))
}
Some(_) | None => unix_slash(id.vpath().as_rooted_path()),
});
let range = world.range(span);
match (file, range) {
(Some(file), Some(range)) => {
let _ = write!(err, "{file:?}:{range:?}");
}
(Some(file), None) => {
let _ = write!(err, "{file:?}");
}
(None, Some(range)) => {
let _ = write!(err, "{range:?}");
}
_ => {
let _ = write!(err, "unknown location");
}
}
};
for s in diagnostics.iter() {
match s.severity {
typst::diag::Severity::Error => {
let _ = write!(err, "error: ");
}
typst::diag::Severity::Warning => {
let _ = write!(err, "warning: ");
}
}
err.push_str(&s.message);
err.push_str(" at ");
write_span(s.span, &mut err);
for hint in s.hints.iter() {
err.push_str("\nHint: ");
err.push_str(hint);
}
for trace in &s.trace {
write!(err, "\nTrace: {} at ", trace.v).unwrap();
write_span(trace.span, &mut err);
}
err.push('\n');
}
err
})?;
let svg_payload = typst_svg::svg_merged(&document, Abs::zero());
if let Some(assets_path) = &self.feat.assets_path {
let file_name = assets_path.join(format!("{}_{:?}.svg", self.assets_numbering, theme));
if let Err(e) = std::fs::write(&file_name, &svg_payload) {
return Err(format!("failed to write SVG to file: {}", e).into());
}
Ok(file_name.to_string_lossy().to_string())
} else {
Ok(base64::engine::general_purpose::STANDARD.encode(svg_payload))
}
}
fn char(arg: char) -> Result<Value> {
Ok(Value::Content(arg.into()))
}
fn str(node: &SyntaxNode) -> Result<Value> {
Ok(Value::Content(node.clone().into_text()))
}
pub fn value(res: Value) -> EcoString {
match res {
Value::None => EcoString::new(),
Value::Content(content) => content,
Value::Str(s) => s,
Value::Image { path, alt } => eco_format!("![{alt}]({path})"),
_ => eco_format!("{res:?}"),
}
}
fn escape(node: &SyntaxNode) -> Result<Value> {
// todo: escape characters
Self::str(node)
}
fn shorthand(node: &SyntaxNode) -> Result<Value> {
// todo: shorthands
Self::str(node)
}
fn strong(&mut self, node: &SyntaxNode) -> Result<Value> {
let mut s = EcoString::new();
let strong = node.cast::<Strong>().unwrap();
s.push_str("**");
s.push_str(&Self::value(self.eval(strong.body().to_untyped())?));
s.push_str("**");
Ok(Value::Content(s))
}
fn emph(&mut self, node: &SyntaxNode) -> Result<Value> {
let mut s = EcoString::new();
let emph = node.cast::<Emph>().unwrap();
s.push('_');
s.push_str(&Self::value(self.eval(emph.body().to_untyped())?));
s.push('_');
Ok(Value::Content(s))
}
fn heading(&mut self, node: &SyntaxNode) -> Result<Value> {
let mut s = EcoString::new();
let heading = node.cast::<Heading>().unwrap();
let level = heading.depth();
for _ in 0..level.get() {
s.push('#');
}
s.push(' ');
s.push_str(&Self::value(self.eval(heading.body().to_untyped())?));
Ok(Value::Content(s))
}
fn raw(node: &SyntaxNode) -> Result<Value> {
let mut s = EcoString::new();
let raw = node.cast::<Raw>().unwrap();
// Raw codes with typlite language will not be treated as a code block but
// directly output into the Markdown result.
if let Some(lang) = raw.lang() {
if &EcoString::from("typlite") == lang.get() {
for line in raw.lines() {
s.push_str(&Self::value(Self::str(line.to_untyped())?));
s.push('\n');
}
return Ok(Value::Content(s));
}
}
if raw.block() {
s.push_str(&Self::value(Self::str(node)?));
return Ok(Value::Content(s));
}
s.push('`');
for line in raw.lines() {
s.push_str(&Self::value(Self::str(line.to_untyped())?));
}
s.push('`');
Ok(Value::Content(s))
}
fn link(&mut self, node: &SyntaxNode) -> Result<Value> {
// GFM supports autolinks
if self.feat.gfm {
return Self::str(node);
}
let mut s = EcoString::new();
s.push('[');
s.push_str(&Self::value(Self::str(node)?));
s.push(']');
s.push('(');
s.push_str(&Self::value(Self::str(node)?));
s.push(')');
Ok(Value::Content(s))
}
fn label(_node: &SyntaxNode) -> Result<Value> {
Result::Ok(Value::None)
}
fn label_ref(node: &SyntaxNode) -> Result<Value> {
Self::str(node)
}
fn ref_marker(node: &SyntaxNode) -> Result<Value> {
Self::str(node)
}
fn list_item(&mut self, node: &SyntaxNode) -> Result<Value> {
let mut s = EcoString::new();
let list_item = node.cast::<ast::ListItem>().unwrap();
for _ in 0..self.list_depth {
s.push_str(" ");
}
s.push_str("- ");
if self.feat.annotate_elem {
let _ = write!(s, "<!-- typlite:begin:list-item {} -->", self.list_depth);
self.list_depth += 1;
}
s.push_str(&Self::value(self.eval(list_item.body().to_untyped())?));
if self.feat.annotate_elem {
self.list_depth -= 1;
let _ = write!(s, "<!-- typlite:end:list-item {} -->", self.list_depth);
}
Ok(Value::Content(s))
}
fn enum_item(&mut self, node: &SyntaxNode) -> Result<Value> {
let enum_item = node.cast::<ast::EnumItem>().unwrap();
let mut s = EcoString::new();
for _ in 0..self.list_depth {
s.push_str(" ");
}
if self.feat.annotate_elem {
let _ = write!(s, "<!-- typlite:begin:enum-item {} -->", self.list_depth);
self.list_depth += 1;
}
if let Some(num) = enum_item.number() {
s.push_str(&format!("{}. ", num));
} else {
s.push_str("1. ");
}
s.push_str(&Self::value(self.eval(enum_item.body().to_untyped())?));
if self.feat.annotate_elem {
self.list_depth -= 1;
let _ = write!(s, "<!-- typlite:end:enum-item {} -->", self.list_depth);
}
Ok(Value::Content(s))
}
fn term_item(&mut self, node: &SyntaxNode) -> Result<Value> {
self.reduce(node)
}
fn equation(&mut self, node: &SyntaxNode) -> Result<Value> {
let equation: Equation = node.cast().unwrap();
if self.feat.remove_html {
return self.to_raw_block(node, !equation.block());
}
self.render(&SyntaxNode::leaf(Text, ""), node, !equation.block())
}
fn let_binding(&self, node: &SyntaxNode) -> Result<Value> {
let _ = node;
Ok(Value::None)
}
fn field_access(&self, node: &SyntaxNode) -> Result<Value> {
let _ = node;
Ok(Value::None)
}
fn func_call(&mut self, node: &SyntaxNode) -> Result<Value> {
let c: ast::FuncCall = node.cast().unwrap();
let callee = match c.callee() {
ast::Expr::Ident(callee) => self.scopes.get(callee.get()),
ast::Expr::FieldAccess(..) => return Ok(Value::None),
_ => return Ok(Value::None),
}?;
let Value::RawFunc(func) = callee else {
return Err("callee is not a function")?;
};
func(Args::new(self, c.args()))
}
fn contextual(&mut self, node: &SyntaxNode) -> Result<Value> {
if self.feat.remove_html {
return self.to_raw_block(node, false);
}
// Trim the last `#` in the prepend code. (#context)
self.prepend_code = self.prepend_code.trim_end_matches('#').into();
self.render(
&SyntaxNode::leaf(node.kind(), self.prepend_code.clone()),
node,
false,
)
}
fn include(&self, node: &SyntaxNode) -> Result<Value> {
let include: ast::ModuleInclude = node.cast().unwrap();
let path = include.source();
let src =
tinymist_analysis::syntax::find_source_by_expr(self.world.as_ref(), self.current, path)
.ok_or_else(|| format!("failed to find source on path {path:?}"))?;
self.clone().sub_file(src).map(Value::Content)
}
fn sub_file(mut self, src: Source) -> Result<EcoString> {
self.current = src.id();
self.convert(src.root())
}
}
struct WrapCode<'a>(&'a str, bool);
impl fmt::Display for WrapCode<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let is_markup = self.1;
if is_markup {
f.write_str("#[")?;
} else {
f.write_str("#{")?;
}
f.write_str(self.0)?;
if is_markup {
f.write_str("]")
} else {
f.write_str("}")
}
}
}

View file

@ -0,0 +1,31 @@
//! Markdown writer implementation
use cmark_writer::ast::Node;
use cmark_writer::writer::CommonMarkWriter;
use ecow::EcoString;
use crate::common::FormatWriter;
use crate::Result;
/// Markdown writer implementation
#[derive(Default)]
pub struct MarkdownWriter {}
impl MarkdownWriter {
pub fn new() -> Self {
Self {}
}
}
impl FormatWriter for MarkdownWriter {
fn write_eco(&mut self, document: &Node, output: &mut EcoString) -> Result<()> {
let mut writer = CommonMarkWriter::new();
writer.write(document).expect("Failed to write document");
output.push_str(&writer.into_string());
Ok(())
}
fn write_vec(&mut self, _document: &Node) -> Result<Vec<u8>> {
Err("Markdown writer does not support writing to Vec<u8>".into())
}
}

View file

@ -0,0 +1,25 @@
//! Writer implementations for different output formats
pub mod markdown;
pub use markdown::MarkdownWriter;
use crate::common::{Format, FormatWriter};
/// Create a writer instance based on the specified format
pub fn create_writer(format: Format) -> Box<dyn FormatWriter> {
match format {
Format::Md => Box::new(markdown::MarkdownWriter::new()),
Format::LaTeX | Format::Docx => {
panic!("LaTeX and Docx writers are not implemented yet")
}
}
}
pub struct WriterFactory;
impl WriterFactory {
pub fn create(format: Format) -> Box<dyn FormatWriter> {
create_writer(format)
}
}