fix(typlite): table with table.header did not convert properly (#1812)

* chore: add TODO comment for table.header rendering in md-table function

* feat: enhance table parsing to support thead/tbody structure and complex cells

* fmt

* feat: add InlineNode for handling flattened inline content in table cells

* feat: update snapshot files and enhance DOCX/LaTeX writer to support InlineNode processing

* refactor: custom node handling in DOCX and LaTeX writers

* dev: md-grid should have similar signature to grid

---------

Co-authored-by: Myriad-Dreamin <camiyoru@gmail.com>
This commit is contained in:
Hong Jiarong 2025-06-16 05:27:16 +08:00 committed by Myriad-Dreamin
parent 9a44074629
commit 8fb118f3ff
12 changed files with 345 additions and 187 deletions

View file

@ -188,6 +188,30 @@ impl CenterNode {
}
}
/// Inline node for flattened inline content (useful for table cells)
#[derive(Debug, PartialEq, Clone)]
#[custom_node(block = false, html_impl = true)]
pub struct InlineNode {
/// The inline content nodes
pub content: Vec<Node>,
}
impl InlineNode {
fn write_custom(&self, writer: &mut CommonMarkWriter) -> WriteResult<()> {
for node in &self.content {
writer.write(node)?;
}
Ok(())
}
fn write_html_custom(&self, writer: &mut HtmlWriter) -> HtmlWriteResult<()> {
for node in &self.content {
writer.write_node(node)?;
}
Ok(())
}
}
/// Alert node for alert messages
#[derive(Debug, PartialEq, Clone)]
#[custom_node(block = true, html_impl = false)]

View file

@ -43,6 +43,17 @@ In Figure 1 you can see a common representation of the Sun, which is a star tha
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim aeque doleamus animo, cum corpore dolemus, fieri tamen permagna accessio potest, si aliquod aeternum et infinitum impendere malum nobis opinemur. Quod idem licet transferre in voluptatem, ut postea variari voluptas distinguique possit, augeri amplificarique non possit. At etiam Athenis, ut e patre audiebam facete et urbane Stoicos irridente, statua est in quo a nobis philosophia defensa et collaudata est, cum id, quod maxime placeat, facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet, ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum.
| Planet | Distance (million km) |
| --- | --- |
| Mercury | 57.9 |
| Venus | 108.2 |
| Earth | 149.6 |
| Mars | 227.9 |
| Jupiter | 778.6 |
| Saturn | 1,433.5 |
| Uranus | 2,872.5 |
| Neptune | 4,495.1 |
<p align="center"><figure class="figure"><p></p>
</figure></p>
@ -54,6 +65,6 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i
## Bibliography
| \[1\] | R. Astley | and | L. Morris | , | “At-scale impact of the Net Wok: A culinarically holistic investigation of distributed dumplings,” | | Armenian Journal of Proceedings | , vol. | 61 | , | pp. | | 192219 | , | 2020 | . |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| \[2\] | L. Morris | and | R. Astley | , | “Net Wok++: Taking distributed dumplings to the cloud,” | | Armenian Journal of Proceedings | , vol. | 65 | , | pp. | | 101118 | , | 2022 | . |
| \[1\] | R. Astley and L. Morris, “At-scale impact of the Net Wok: A culinarically holistic investigation of distributed dumplings,” Armenian Journal of Proceedings, vol. 61, pp. 192219, 2020. |
| --- | --- |
| \[2\] | L. Morris and R. Astley, “Net Wok++: Taking distributed dumplings to the cloud,” Armenian Journal of Proceedings, vol. 65, pp. 101118, 2022. |

View file

@ -1,7 +1,8 @@
---
source: crates/typlite/src/tests.rs
expression: "conv(world, false)"
expression: "conv(world, ConvKind::Md { for_docs: false })"
input_file: crates/typlite/src/fixtures/integration/table.typ
snapshot_kind: text
---
<!DOCTYPE html>
<html>
@ -20,6 +21,6 @@ input_file: crates/typlite/src/fixtures/integration/table.typ
| --- | --- | --- | --- | --- | --- |
| 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 |
| 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>

View file

@ -2,6 +2,7 @@
source: crates/typlite/src/tests.rs
expression: "conv(world, ConvKind::LaTeX)"
input_file: crates/typlite/src/fixtures/integration/ieee.typ
snapshot_kind: text
---
<!DOCTYPE html>
<html>
@ -50,6 +51,24 @@ In Figure 1 you can see a common representation of the Sun, which is a star tha
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim aeque doleamus animo, cum corpore dolemus, fieri tamen permagna accessio potest, si aliquod aeternum et infinitum impendere malum nobis opinemur. Quod idem licet transferre in voluptatem, ut postea variari voluptas distinguique possit, augeri amplificarique non possit. At etiam Athenis, ut e patre audiebam facete et urbane Stoicos irridente, statua est in quo a nobis philosophia defensa et collaudata est, cum id, quod maxime placeat, facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet, ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum.
\begin{table}[htbp]
\centering
\begin{tabular}{cc}
\hline
Planet & Distance (million km) \\
\hline
Mercury & 57.9 \\
Venus & 108.2 \\
Earth & 149.6 \\
Mars & 227.9 \\
Jupiter & 778.6 \\
Saturn & 1,433.5 \\
Uranus & 2,872.5 \\
Neptune & 4,495.1 \\
\hline
\end{tabular}
\end{table}
\begin{center}
\begin{figure}[htbp]
\centering
@ -68,11 +87,11 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i
\begin{table}[htbp]
\centering
\begin{tabular}{ccccccccccccccccc}
\begin{tabular}{cc}
\hline
[1] & R. Astley & and & L. Morris & , & “At-scale impact of the Net Wok: A culinarically holistic investigation of distributed dumplings,” & & Armenian Journal of Proceedings & , vol. & 61 & , & pp. & & 192219 & , & 2020 & . \\
[1] & R. Astley and L. Morris, “At-scale impact of the Net Wok: A culinarically holistic investigation of distributed dumplings,” Armenian Journal of Proceedings, vol. 61, pp. 192219, 2020. \\
\hline
[2] & L. Morris & and & R. Astley & , & “Net Wok++: Taking distributed dumplings to the cloud,” & & Armenian Journal of Proceedings & , vol. & 65 & , & pp. & & 101118 & , & 2022 & . \\
[2] & L. Morris and R. Astley, “Net Wok++: Taking distributed dumplings to the cloud,” Armenian Journal of Proceedings, vol. 65, pp. 101118, 2022. \\
\hline
\end{tabular}
\end{table}

View file

@ -2,6 +2,7 @@
source: crates/typlite/src/tests.rs
expression: "conv(world, ConvKind::LaTeX)"
input_file: crates/typlite/src/fixtures/integration/table.typ
snapshot_kind: text
---
<!DOCTYPE html>
<html>
@ -33,7 +34,7 @@ input_file: crates/typlite/src/fixtures/integration/table.typ
\hline
6 & 7 & 8 & 9 & 10 & 11 \\
12 & 13 & 14 & 15 & 16 & 17 \\
18 & 19 \\
18 & 19 & & & & \\
\hline
\end{tabular}
\end{table}

View file

@ -2,5 +2,6 @@
source: crates/typlite/src/tests.rs
expression: hash
input_file: crates/typlite/src/fixtures/integration/ieee.typ
snapshot_kind: text
---
siphash128_13:75c6857b5ce54f3db89646afcd1f4f78
siphash128_13:914f20b049c227a73ad350294a7d79b3

View file

@ -2,5 +2,6 @@
source: crates/typlite/src/tests.rs
expression: hash
input_file: crates/typlite/src/fixtures/integration/image.typ
snapshot_kind: text
---
siphash128_13:89ee713812f00bde9ac174f72c81760

View file

@ -2,5 +2,6 @@
source: crates/typlite/src/tests.rs
expression: hash
input_file: crates/typlite/src/fixtures/integration/table.typ
snapshot_kind: text
---
siphash128_13:ce1b6f668016a12edf304ab7f38aea42
siphash128_13:593e825fdc657e578e557142b4e22211

View file

@ -76,7 +76,23 @@
)
#let md-grid(columns: auto, ..children) = html.elem(
"m1grid",
table(columns: columns, ..children.pos().map(it => table.cell(it))),
{
let children = children.pos()
let header = if children.first().func() == grid.header {
(table.header(..children.first().children.map(cell => table.cell(cell.body))),)
children = children.slice(1)
} else {
()
}
let footer = if children.last().func() == grid.footer {
(table.footer(..children.last().children.map(cell => table.cell(cell.body))),)
children = children.slice(0, -1)
} else {
()
}
table(columns: columns, ..header, ..children.map(it => table.cell(it)), ..footer)
},
)
#let md-image(src: "", alt: none) = html.elem(
"m1image",

View file

@ -5,6 +5,7 @@ use cmark_writer::gfm::TableAlignment;
use typst::html::{tag, HtmlElement, HtmlNode};
use typst::utils::PicoStr;
use crate::common::InlineNode;
use crate::tags::md_tag;
use crate::Result;
@ -88,21 +89,56 @@ impl TableParser {
fn extract_table_content(
parser: &mut HtmlToAstParser,
table: &HtmlElement,
headers: &mut Vec<Vec<Node>>,
rows: &mut Vec<Vec<Vec<Node>>>,
headers: &mut Vec<Node>,
rows: &mut Vec<Vec<Node>>,
is_header: &mut bool,
) -> Result<()> {
// Process rows in the table
for row_node in &table.children {
// Process table structure (direct rows or thead/tbody)
for child_node in &table.children {
if let HtmlNode::Element(element) = child_node {
match element.tag {
tag::thead => {
// Process header rows
Self::process_table_section(parser, element, headers, rows, true)?;
*is_header = false;
}
tag::tbody => {
// Process body rows
Self::process_table_section(parser, element, headers, rows, false)?;
}
tag::tr => {
// Direct row (no thead/tbody structure)
let current_row =
Self::process_table_row(parser, element, *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_section(
parser: &mut HtmlToAstParser,
section: &HtmlElement,
headers: &mut Vec<Node>,
rows: &mut Vec<Vec<Node>>,
is_header_section: bool,
) -> Result<()> {
for row_node in &section.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)?;
Self::process_table_row(parser, row_elem, is_header_section, headers)?;
// After the first row, treat remaining rows as data rows
if *is_header {
*is_header = false;
} else if !current_row.is_empty() {
if !is_header_section && !current_row.is_empty() {
rows.push(current_row);
}
}
@ -115,22 +151,25 @@ impl TableParser {
parser: &mut HtmlToAstParser,
row_elem: &HtmlElement,
is_header: bool,
headers: &mut Vec<Vec<Node>>,
) -> Result<Vec<Vec<Node>>> {
headers: &mut Vec<Node>,
) -> Result<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 {
if cell.tag == tag::td || cell.tag == tag::th {
let mut cell_content = Vec::new();
parser.convert_children_into(&mut cell_content, cell)?;
// Merge cell content into a single node
let merged_cell = Self::merge_cell_content(cell_content);
// Add to appropriate section
if is_header {
headers.push(cell_content);
if is_header || cell.tag == tag::th {
headers.push(merged_cell);
} else {
current_row.push(cell_content);
current_row.push(merged_cell);
}
}
}
@ -139,48 +178,75 @@ impl TableParser {
Ok(current_row)
}
/// Merge cell content nodes into a single node
fn merge_cell_content(content: Vec<Node>) -> Node {
match content.len() {
0 => Node::Text("".to_string()),
1 => content.into_iter().next().unwrap(),
_ => Node::Custom(Box::new(InlineNode { content })),
}
}
/// 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;
}
for child_node in &table.children {
if let HtmlNode::Element(element) = child_node {
match element.tag {
tag::thead | tag::tbody => {
// Check rows within thead/tbody
if Self::check_section_for_complex_cells(element) {
return true;
}
}
tag::tr => {
// Direct row
if Self::check_row_for_complex_cells(element) {
return true;
}
}
_ => {}
}
}
}
false
}
fn create_table_node(
headers: Vec<Vec<Node>>,
rows: Vec<Vec<Vec<Node>>>,
) -> Result<Option<Node>> {
fn check_section_for_complex_cells(section: &HtmlElement) -> bool {
for row_node in &section.children {
if let HtmlNode::Element(row_elem) = row_node {
if row_elem.tag == tag::tr && Self::check_row_for_complex_cells(row_elem) {
return true;
}
}
}
false
}
fn check_row_for_complex_cells(row_elem: &HtmlElement) -> bool {
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<Node>, rows: 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,
headers,
rows,
alignments,
}));
}

View file

@ -7,7 +7,7 @@ use ecow::EcoString;
use std::fs;
use std::io::Cursor;
use crate::common::{CenterNode, FigureNode, FormatWriter, HighlightNode};
use crate::common::{CenterNode, FigureNode, FormatWriter, HighlightNode, InlineNode};
use crate::Result;
use super::image_processor::DocxImageProcessor;
@ -235,15 +235,17 @@ impl DocxWriter {
Node::SoftBreak => {
run = run.add_text(" ");
}
Node::Custom(custom_node) => {
if let Some(highlight_node) = custom_node.as_any().downcast_ref::<HighlightNode>() {
run = run.highlight("yellow");
for child in &highlight_node.content {
run = self.process_inline_to_run(run, child)?;
}
} else {
// Handle other custom inline nodes if needed
println!("Unhandled custom inline node: {:?}", custom_node);
node if node.is_custom_type::<HighlightNode>() => {
let highlight_node = node.as_custom_type::<HighlightNode>().unwrap();
run = run.highlight("yellow");
for child in &highlight_node.content {
run = self.process_inline_to_run(run, child)?;
}
}
node if node.is_custom_type::<InlineNode>() => {
let inline_node = node.as_custom_type::<InlineNode>().unwrap();
for child in &inline_node.content {
run = self.process_inline_to_run(run, child)?;
}
}
// Other inline element types
@ -423,65 +425,76 @@ impl DocxWriter {
Node::Image { url, title: _, alt } => {
docx = self.process_image(docx, url, alt)?;
}
Node::Custom(custom_node) => {
if let Some(figure_node) = custom_node.as_any().downcast_ref::<FigureNode>() {
docx = self.process_figure(docx, figure_node)?;
} else if let Some(center_node) = custom_node.as_any().downcast_ref::<CenterNode>()
{
// Handle regular node but with center alignment
match &center_node.node {
Node::Paragraph(content) => {
docx = self.process_paragraph(docx, content, None)?;
// Get the last paragraph and center it
if let Some(DocumentChild::Paragraph(para)) =
docx.document.children.last_mut()
{
para.property = para.property.clone().align(AlignmentType::Center);
}
}
other => {
docx = self.process_node(docx, other)?;
// Get the last element and center it if it's a paragraph
if let Some(DocumentChild::Paragraph(para)) =
docx.document.children.last_mut()
{
para.property = para.property.clone().align(AlignmentType::Center);
}
node if node.is_custom_type::<FigureNode>() => {
let figure_node = node.as_custom_type::<FigureNode>().unwrap();
docx = self.process_figure(docx, figure_node)?;
}
node if node.is_custom_type::<CenterNode>() => {
let center_node = node.as_custom_type::<CenterNode>().unwrap();
// Handle regular node but with center alignment
match &center_node.node {
Node::Paragraph(content) => {
docx = self.process_paragraph(docx, content, None)?;
// Get the last paragraph and center it
if let Some(DocumentChild::Paragraph(para)) =
docx.document.children.last_mut()
{
para.property = para.property.clone().align(AlignmentType::Center);
}
}
} else if let Some(external_frame) = custom_node
.as_any()
.downcast_ref::<crate::common::ExternalFrameNode>(
) {
let data = base64::engine::general_purpose::STANDARD
.decode(&external_frame.svg)
.map_err(|e| format!("Failed to decode SVG data: {}", e))?;
docx = self.image_processor.process_image_data(
docx,
&data,
Some(&external_frame.alt_text),
None,
);
} else if let Some(highlight_node) =
custom_node.as_any().downcast_ref::<HighlightNode>()
{
// Handle HighlightNode at block level (convert to paragraph)
let mut para = Paragraph::new();
let mut run = Run::new().highlight("yellow");
for child in &highlight_node.content {
run = self.process_inline_to_run(run, child)?;
other => {
docx = self.process_node(docx, other)?;
// Get the last element and center it if it's a paragraph
if let Some(DocumentChild::Paragraph(para)) =
docx.document.children.last_mut()
{
para.property = para.property.clone().align(AlignmentType::Center);
}
}
}
}
node if node.is_custom_type::<crate::common::ExternalFrameNode>() => {
let external_frame = node
.as_custom_type::<crate::common::ExternalFrameNode>()
.unwrap();
let data = base64::engine::general_purpose::STANDARD
.decode(&external_frame.svg)
.map_err(|e| format!("Failed to decode SVG data: {}", e))?;
if !run.children.is_empty() {
para = para.add_run(run);
docx = docx.add_paragraph(para);
}
} else {
// Fallback for unknown custom nodes - ignore or add placeholder
let placeholder = "[Unknown custom content]";
let para = Paragraph::new().add_run(Run::new().add_text(placeholder));
docx = self.image_processor.process_image_data(
docx,
&data,
Some(&external_frame.alt_text),
None,
);
}
node if node.is_custom_type::<HighlightNode>() => {
let highlight_node = node.as_custom_type::<HighlightNode>().unwrap();
// Handle HighlightNode at block level (convert to paragraph)
let mut para = Paragraph::new();
let mut run = Run::new().highlight("yellow");
for child in &highlight_node.content {
run = self.process_inline_to_run(run, child)?;
}
if !run.children.is_empty() {
para = para.add_run(run);
docx = docx.add_paragraph(para);
}
}
node if node.is_custom_type::<InlineNode>() => {
let inline_node = node.as_custom_type::<InlineNode>().unwrap();
// Handle InlineNode at block level (convert to paragraph)
let mut para = Paragraph::new();
let mut run = Run::new();
for child in &inline_node.content {
run = self.process_inline_to_run(run, child)?;
}
if !run.children.is_empty() {
para = para.add_run(run);
docx = docx.add_paragraph(para);
}
}

View file

@ -7,7 +7,7 @@ use ecow::EcoString;
use tinymist_std::path::unix_slash;
use crate::common::{
CenterNode, ExternalFrameNode, FigureNode, FormatWriter, HighlightNode, ListState,
CenterNode, ExternalFrameNode, FigureNode, FormatWriter, HighlightNode, InlineNode, ListState,
};
use crate::Result;
@ -231,83 +231,87 @@ impl LaTeXWriter {
output.push_str("\\end{tabular}\n");
output.push_str("\\end{table}\n\n");
}
Node::Custom(custom_node) => {
if let Some(figure_node) = custom_node.as_any().downcast_ref::<FigureNode>() {
// Start figure environment
output.push_str("\\begin{figure}[htbp]\n\\centering\n");
node if node.is_custom_type::<FigureNode>() => {
let figure_node = node.as_custom_type::<FigureNode>().unwrap();
// Start figure environment
output.push_str("\\begin{figure}[htbp]\n\\centering\n");
// Handle the body content (typically an image)
match &*figure_node.body {
Node::Paragraph(content) => {
for node in content {
// Special handling for image nodes in figures
if let Node::Image {
url,
title: _,
alt: _,
} = node
{
// Path to the image file
let path = unix_slash(Path::new(url));
// Handle the body content (typically an image)
match &*figure_node.body {
Node::Paragraph(content) => {
for node in content {
// Special handling for image nodes in figures
if let Node::Image {
url,
title: _,
alt: _,
} = node
{
// Path to the image file
let path = unix_slash(Path::new(url));
// Write includegraphics command
output.push_str("\\includegraphics[width=0.8\\textwidth]{");
output.push_str(&path);
output.push_str("}\n");
} else {
// For non-image content, just render it normally
self.write_node(node, output)?;
}
// Write includegraphics command
output.push_str("\\includegraphics[width=0.8\\textwidth]{");
output.push_str(&path);
output.push_str("}\n");
} else {
// For non-image content, just render it normally
self.write_node(node, output)?;
}
}
// Directly handle the node if it's not in a paragraph
node => self.write_node(node, output)?,
}
// Directly handle the node if it's not in a paragraph
node => self.write_node(node, output)?,
}
// Add caption if present
if !figure_node.caption.is_empty() {
output.push_str("\\caption{");
output.push_str(&escape_latex(&figure_node.caption));
output.push_str("}\n");
}
// Close figure environment
output.push_str("\\end{figure}\n\n");
} else if let Some(external_frame) =
custom_node.as_any().downcast_ref::<ExternalFrameNode>()
{
// Handle externally stored frames
let path = unix_slash(&external_frame.file_path);
output.push_str("\\begin{figure}[htbp]\n");
output.push_str("\\centering\n");
output.push_str("\\includegraphics[width=0.8\\textwidth]{");
output.push_str(&path);
// Add caption if present
if !figure_node.caption.is_empty() {
output.push_str("\\caption{");
output.push_str(&escape_latex(&figure_node.caption));
output.push_str("}\n");
}
if !external_frame.alt_text.is_empty() {
output.push_str("\\caption{");
output.push_str(&escape_latex(&external_frame.alt_text));
output.push_str("}\n");
}
// Close figure environment
output.push_str("\\end{figure}\n\n");
}
node if node.is_custom_type::<ExternalFrameNode>() => {
let external_frame = node.as_custom_type::<ExternalFrameNode>().unwrap();
// Handle externally stored frames
let path = unix_slash(&external_frame.file_path);
output.push_str("\\end{figure}\n\n");
} else if let Some(center_node) = custom_node.as_any().downcast_ref::<CenterNode>()
{
output.push_str("\\begin{center}\n");
self.write_node(&center_node.node, output)?;
output.push_str("\\end{center}\n\n");
} else if let Some(highlight_node) =
custom_node.as_any().downcast_ref::<HighlightNode>()
{
output.push_str("\\colorbox{yellow}{");
for child in &highlight_node.content {
self.write_node(child, output)?;
}
output.push_str("}");
} else {
// Fallback for unknown custom nodes
output.push_str("[Unknown custom node]");
output.push_str("\\begin{figure}[htbp]\n");
output.push_str("\\centering\n");
output.push_str("\\includegraphics[width=0.8\\textwidth]{");
output.push_str(&path);
output.push_str("}\n");
if !external_frame.alt_text.is_empty() {
output.push_str("\\caption{");
output.push_str(&escape_latex(&external_frame.alt_text));
output.push_str("}\n");
}
output.push_str("\\end{figure}\n\n");
}
node if node.is_custom_type::<CenterNode>() => {
let center_node = node.as_custom_type::<CenterNode>().unwrap();
output.push_str("\\begin{center}\n");
self.write_node(&center_node.node, output)?;
output.push_str("\\end{center}\n\n");
}
node if node.is_custom_type::<HighlightNode>() => {
let highlight_node = node.as_custom_type::<HighlightNode>().unwrap();
output.push_str("\\colorbox{yellow}{");
for child in &highlight_node.content {
self.write_node(child, output)?;
}
output.push_str("}");
}
node if node.is_custom_type::<InlineNode>() => {
let inline_node = node.as_custom_type::<InlineNode>().unwrap();
// Process all child nodes inline
for child in &inline_node.content {
self.write_node(child, output)?;
}
}
Node::Text(text) => {