Skip to main content

mdbook_typstpdf/config/
chapter.rs

1use imagesize;
2use lazy_static::lazy_static;
3use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
4use regex;
5use reqwest::blocking::Client;
6use std::fs;
7use std::io::Write;
8use std::time::{SystemTime, UNIX_EPOCH};
9use std::{
10    cmp::PartialEq,
11    path::{Path, PathBuf},
12};
13
14use crate::config::IMAGE_DIR;
15
16use super::Config;
17
18// Make TableState implement PartialEq
19#[derive(Debug, Clone, Copy, PartialEq)]
20enum TableState {
21    None,
22    InTable,
23    InHeader,
24    InRow,
25}
26
27// Helper enum to track list types
28enum ListType {
29    Ordered(Option<u64>),
30    Unordered,
31}
32
33lazy_static! {
34    static ref RE_HTML_IMG: regex::Regex =
35        regex::Regex::new(r#"<img[^>]*src=["']([^"']+)["']"#).unwrap();
36}
37
38// 页面尺寸常量 (A4 paper in points)
39const PAGE_WIDTH: f64 = 595.0; // A4 width in points
40const PAGE_HEIGHT: f64 = 842.0; // A4 height in points
41                                // const MAX_WIDTH_PERCENT: f64 = 0.95; // 95% of page width
42                                // const MAX_HEIGHT_PERCENT: f64 = 0.45; // 45% of page height
43
44// 计算图片的合适尺寸
45fn calculate_image_size(
46    image_path: &str,
47    max_width_percent: &Option<f64>, // for example, 95% of the page width
48    max_height_percent: &Option<f64>, // for example, 45% of page height
49    ctx: &mdbook_renderer::RenderContext,
50) -> (String, String) {
51    // 默认值,如果无法获取图片尺寸
52    let default_width = "100%";
53    let default_height = "auto";
54
55    if max_width_percent.is_none() && max_height_percent.is_none() {
56        // return with default value if both are none
57        return (default_width.to_string(), default_height.to_string());
58    }
59
60    let mut max_width_percent = match max_width_percent {
61        Some(num) => *num,
62        None => 1.0,
63    };
64    let mut max_height_percent = match max_height_percent {
65        Some(num) => *num,
66        None => 1.0,
67    };
68    if max_width_percent > 1.0 || max_width_percent <= 0.0 {
69        log::error!(
70            "Image max width percent is out of range:{}, change it to 1.0 or 100%",
71            max_width_percent
72        );
73        max_width_percent = 1.0;
74    }
75    if max_height_percent > 1.0 || max_height_percent <= 0.0 {
76        log::error!(
77            "Image max height percent is out of range:{}, change it to 1.0 or 100%",
78            max_height_percent
79        );
80        max_height_percent = 1.0;
81    }
82
83    // 获取图片的完整路径
84    let src_dir = ctx.root.join(&ctx.config.book.src);
85    let full_path = src_dir.join(image_path);
86
87    // 尝试获取图片尺寸
88    match imagesize::size(full_path) {
89        Ok(size) => {
90            let img_width = size.width as f64;
91            let img_height = size.height as f64;
92
93            // 计算图片在100%宽度时的高度比例
94            let height_ratio = img_height / img_width;
95            let full_width_height = PAGE_WIDTH * height_ratio;
96
97            // 检查规则1:如果高度超过页面高度的45%
98            if full_width_height > PAGE_HEIGHT * max_height_percent {
99                // 需要缩小图片
100                let max_height = PAGE_HEIGHT * max_height_percent;
101                let new_width = max_height / height_ratio;
102                let width_percent = (new_width / PAGE_WIDTH) * 100.0;
103
104                return (
105                    format!("{}%", width_percent.round()),
106                    default_height.to_string(),
107                );
108            }
109
110            // 检查规则2:如果高度低于45%但宽度超过95%
111            if img_width > PAGE_WIDTH * max_width_percent
112                && full_width_height < PAGE_HEIGHT * max_height_percent
113            {
114                return (
115                    format!("{}%", (max_width_percent * 100.0).round()),
116                    default_height.to_string(),
117                );
118            }
119
120            // 默认使用100%宽度
121            (default_width.to_string(), default_height.to_string())
122        }
123        Err(_) => {
124            // 如果无法获取图片尺寸,使用默认值
125            (default_width.to_string(), default_height.to_string())
126        }
127    }
128}
129
130impl Config {
131    pub fn parse_chapter_content(
132        &self,
133        chapter: &mdbook_renderer::book::Chapter,
134        content: &str,
135        dst_file_path: &std::path::Path,
136        image_parent_dir: &std::path::Path,
137        ctx: &mdbook_renderer::RenderContext,
138    ) -> anyhow::Result<String> {
139        // Parse the chapter content from markdown to typst format
140        let mut options = Options::empty();
141        options.insert(Options::ENABLE_TABLES);
142        options.insert(Options::ENABLE_FOOTNOTES);
143        options.insert(Options::ENABLE_STRIKETHROUGH);
144        options.insert(Options::ENABLE_TASKLISTS);
145
146        // preprocess the <img> tag, example: <img src="docs/01-introduction/image-20250224001420194.png" alt="image-20250224001420194" style="zoom:50%;" />
147        // to: ![image-20250224001420194](docs/01-introduction/image-20250224001420194.png)
148        let content = preprocess_img_tag(content);
149
150        let parser = Parser::new_ext(&content, options);
151        let mut typst_output = String::new();
152
153        let image_folder_name = IMAGE_DIR;
154        let image_dir = image_parent_dir.join(image_folder_name);
155
156        // Create the image directory if it doesn't exist
157        if !image_dir.exists() {
158            std::fs::create_dir_all(&image_dir)?;
159            log::debug!("Created image directory at {:?}", image_dir);
160        }
161
162        // Calculate the correct relative path to templates
163        let template_rel_path = self.calculate_relative_path_to_templates(chapter, ctx);
164        log::debug!(
165            "Template relative path for {}: {}",
166            dst_file_path.display(),
167            template_rel_path
168        );
169        // Add quote block setting at the beginning of the document
170        if let Some(chapter_imports) = &self.chapter_imports {
171            typst_output.push_str(chapter_imports);
172        }
173        // typst_output.push_str("#import \"@preview/gentle-clues:0.6.0\": *\n\n");
174
175        let mut list_stack = Vec::new();
176        let mut table_state = TableState::None;
177
178        let mut table_columns: usize = 0;
179
180        // Track current image caption status
181        // let mut current_image_has_caption = false;
182        let mut in_image = false;
183
184        // Add a tracking variable at the beginning of your parse_chapter_content function
185        let mut in_strong_context = false;
186        let mut handled_bold_url = false;
187
188        let mut in_code_block = false;
189        let mut is_fenced_code_block = false;
190        let mut code_block_language = None;
191        let mut first_para_in_list_item = false; // there may be multiple paras inisde a list item.
192
193        for event in parser {
194            log::trace!("event:{:?}", event);
195            match event {
196                Event::Start(tag) => match tag {
197                    Tag::Paragraph => {
198                        if table_state != TableState::None {
199                            // Inside a table, don't add paragraph markers
200                        } else if !list_stack.is_empty() {
201                            // inside a list, need to handle ident differently.
202                            if first_para_in_list_item {
203                                first_para_in_list_item = false;
204                            } else {
205                                // ensure new line and ident
206                                let item_ident = "  ".repeat(list_stack.len());
207                                typst_output.push_str(&format!("\n{}", item_ident));
208                            }
209                        } else {
210                            typst_output.push('\n');
211                        }
212                    }
213                    Tag::Heading { level, .. } => {
214                        typst_output.push_str("\n\n");
215                        typst_output.push_str(&format!("{} ", "=".repeat(level as usize)));
216                    }
217                    Tag::BlockQuote(_) => {
218                        // Ensure a clean start for the blockquote
219                        if !typst_output.is_empty() && !typst_output.ends_with('\n') {
220                            typst_output.push('\n');
221                        }
222                        typst_output.push_str("#quote[");
223                    }
224                    Tag::CodeBlock(kind) => {
225                        log::debug!("chapter: {:?}, Code block kind: {:?}", chapter.name, kind);
226                        in_code_block = true;
227                        if let CodeBlockKind::Fenced(lang) = kind {
228                            is_fenced_code_block = true;
229                            if !lang.is_empty() {
230                                code_block_language = Some(lang.to_string());
231                            }
232                        }
233                    }
234                    Tag::List(start) => {
235                        // Ensure we start on a new line
236                        if !typst_output.ends_with('\n') {
237                            typst_output.push('\n');
238                        }
239
240                        if start.is_some() {
241                            list_stack.push(ListType::Ordered(start));
242                        } else {
243                            list_stack.push(ListType::Unordered);
244                        }
245                        // The actual list markers will be added by Tag::Item
246                    }
247                    Tag::Item => {
248                        first_para_in_list_item = true;
249                        // For all list items, we need to ensure proper formatting
250                        // Determine the list type for Typst syntax
251                        let list_type = match list_stack.last() {
252                            Some(ListType::Ordered(start)) => match start {
253                                None => "+ ",
254                                Some(1) => "+ ", // we can't differentiate 1 or default.
255                                Some(num) => &format!("{}. ", num),
256                            },
257                            _ => "- ",
258                        };
259
260                        // Add indent based on list nesting level
261                        let indent = "  ".repeat(list_stack.len() - 1);
262
263                        // Ensure the item starts on a new line
264                        if !typst_output.ends_with('\n') {
265                            typst_output.push('\n');
266                        }
267
268                        typst_output.push_str(&format!("{}{} ", indent, list_type));
269                    }
270                    Tag::Emphasis => {
271                        typst_output.push_str(" _");
272                    }
273                    Tag::Strong => {
274                        in_strong_context = true;
275                        handled_bold_url = false;
276                        typst_output.push_str(" *"); // Keep this for non-URL content
277                    }
278                    Tag::Strikethrough => {
279                        typst_output.push_str("#strike[");
280                    }
281                    Tag::Link {
282                        link_type: _,
283                        dest_url,
284                        ..
285                    } => {
286                        typst_output.push_str(&format!("#link(\"{}\")[", dest_url));
287                    }
288                    Tag::Image {
289                        link_type,
290                        dest_url,
291                        title,
292                        ..
293                    } => {
294                        // image inside a list should be indented too.
295                        let list_ident = "  ".repeat(list_stack.len());
296                        in_image = true;
297                        log::debug!(
298                            "Image link_type:{:?},dest_url:{:?},title:{:?}",
299                            link_type,
300                            dest_url,
301                            title
302                        );
303                        // Extract image path
304                        let image_path_name = dest_url.to_string();
305
306                        // Handle local images or remote images
307                        if !image_path_name.starts_with("http://")
308                            && !image_path_name.starts_with("https://")
309                        {
310                            let image_path = Path::new(&image_path_name);
311                            if image_path.is_absolute() {
312                                // Absolute path - use as is
313                                typst_output.push_str(&format!(
314                                    "{},#figure(\n  image(\"{}\"),\n  caption: none)",
315                                    list_ident, image_path_name
316                                ));
317                            } else {
318                                // Relative path - copy to image folder
319                                let src_dir = ctx.root.join(&ctx.config.book.src);
320                                let src_image_path = src_dir.join(&image_path_name);
321
322                                // Get the filename from the image path
323                                // let file_name = Path::new(&image_path_name).file_name().unwrap_or_default().to_str().unwrap_or_default();
324
325                                // Destination path in the image folder
326                                // let dst_image_path = image_dir.join(file_name);
327                                let dst_image_path = image_dir.join(&image_path_name);
328
329                                // get the path of the dst_image_path, create the directory if it doesn't exist
330                                if let Some(image_new_path) = dst_image_path.parent() {
331                                    if !image_new_path.exists() {
332                                        log::debug!("Creating image dir: {:?}", image_new_path);
333                                        std::fs::create_dir_all(image_new_path)?;
334                                    }
335                                }
336
337                                // Copy the image file
338                                if src_image_path.exists() {
339                                    if let Err(e) = std::fs::copy(&src_image_path, &dst_image_path)
340                                    {
341                                        log::error!(
342                                            "Failed to copy image from {:?} to {:?}: {}",
343                                            src_image_path,
344                                            dst_image_path,
345                                            e
346                                        );
347                                    } else {
348                                        log::debug!(
349                                            "Copied image from {:?} to {:?}",
350                                            src_image_path,
351                                            dst_image_path
352                                        );
353                                    }
354                                } else {
355                                    log::warn!("Source image not found: {:?}", src_image_path);
356                                }
357
358                                // Calculate image size
359                                let (width, _height) = calculate_image_size(
360                                    &image_path_name,
361                                    &self.max_width,
362                                    &self.max_height,
363                                    ctx,
364                                );
365                                let new_image_path = format!("{}/{}", IMAGE_DIR, image_path_name);
366
367                                typst_output.push_str(&format!(
368                                    "{}#figure(\n  image(\"{}\", width: {}),\n  caption: none)",
369                                    list_ident, new_image_path, width
370                                ));
371                            }
372                        } else if image_path_name.starts_with("http://")
373                            || image_path_name.starts_with("https://")
374                        {
375                            // Handle remote images - download to image folder
376                            match download_remote_image(&image_path_name, &image_dir) {
377                                Ok(local_path) => {
378                                    typst_output.push_str(&format!(
379                                        "{}#figure(\n  image(\"{}/{}\"),\n  caption: none)",
380                                        list_ident, image_folder_name, local_path
381                                    ));
382                                }
383                                Err(e) => {
384                                    log::error!(
385                                        "Failed to process remote image {}: {}",
386                                        image_path_name,
387                                        e
388                                    );
389                                    typst_output.push_str(&format!(
390                                        "{}#text(fill: red)[Image download failed]",
391                                        list_ident
392                                    ));
393                                }
394                            }
395                        }
396                    }
397                    Tag::Table(alignments) => {
398                        // if there is a list inside a table, or a table inside a list, or nested, the situation is not handled yet.
399                        log::debug!("Table alignments: {:?}", alignments);
400                        table_state = TableState::InTable;
401
402                        table_columns = alignments.len();
403                        typst_output.push_str("#table(\n");
404                        typst_output.push_str(&format!("  columns: {},\n", table_columns));
405                    }
406                    Tag::TableHead => {
407                        log::debug!("Table columns: {}", table_columns);
408                        table_state = TableState::InHeader;
409
410                        typst_output.push_str("  table.header(");
411                    }
412                    Tag::TableRow => {
413                        table_state = TableState::InRow;
414                    }
415                    Tag::TableCell => {
416                        typst_output.push('[');
417                    }
418                    Tag::FootnoteDefinition(_footnote_id) => {
419                        // Handle footnote definitions
420                        typst_output.push_str("#footnote[");
421                    }
422                    _ => {}
423                },
424                Event::End(end_tag) => match end_tag {
425                    TagEnd::Paragraph => {
426                        if table_state == TableState::None {
427                            typst_output.push('\n');
428                        }
429                    }
430                    TagEnd::Heading(_) => {
431                        typst_output.push('\n');
432                    }
433                    TagEnd::BlockQuote(_) => {
434                        // Simply add the closing bracket for blockquotes
435                        typst_output.push(']');
436                        if !typst_output.ends_with("]\n") {
437                            typst_output.push('\n');
438                        }
439                    }
440                    TagEnd::CodeBlock => {
441                        in_code_block = false;
442                        is_fenced_code_block = false;
443                        code_block_language = None;
444                    }
445                    TagEnd::List(_) => {
446                        list_stack.pop();
447                        typst_output.push('\n');
448                    }
449                    TagEnd::Item => {
450                        // 在列表项结束时添加换行
451                        typst_output.push('\n');
452                    }
453                    TagEnd::Emphasis => {
454                        typst_output.push_str("_ ");
455                    }
456                    TagEnd::Strong => {
457                        in_strong_context = false;
458                        if !handled_bold_url {
459                            typst_output.push_str("* "); // Only add closing asterisk if we didn't handle a URL
460                        } else {
461                            typst_output.push(' '); // Just add a space if we already closed the bold formatting
462                        }
463                    }
464                    TagEnd::Strikethrough => {
465                        typst_output.push(']');
466                    }
467                    TagEnd::Link => {
468                        typst_output.push_str("] ");
469                    }
470                    TagEnd::Image => {
471                        // do nothing
472                        in_image = false;
473                    }
474                    TagEnd::Table => {
475                        table_state = TableState::None;
476
477                        typst_output.push_str(")\n");
478                    }
479                    TagEnd::TableHead => {
480                        table_state = TableState::InTable;
481                        typst_output.push_str("),\n");
482
483                        // // Instead of removing the trailing comma, ensure it's there but without the space
484                        // if typst_output.ends_with(", ") {
485                        //     // Keep the comma but replace the space with a newline
486                        //     typst_output.truncate(typst_output.len() - 1);
487                        //     typst_output.push('\n');
488                        // } else if !typst_output.ends_with(",\n") {
489                        //     // If there's no comma at all, add one before the newline
490                        //     typst_output.push_str(",\n");
491                        // }
492                    }
493                    TagEnd::TableRow => {
494                        table_state = TableState::InTable;
495
496                        // Instead of removing the trailing comma, ensure it's there but without the space
497                        if typst_output.ends_with(", ") {
498                            // Replace the space with a newline
499                            typst_output.truncate(typst_output.len() - 1);
500                            typst_output.push('\n');
501                        } else if !typst_output.ends_with(",\n") {
502                            // If there's no comma at all, add one before the newline
503                            typst_output.push_str(",\n");
504                        }
505                    }
506                    TagEnd::TableCell => {
507                        typst_output.push(']');
508                        typst_output.push_str(", ");
509                    }
510                    TagEnd::FootnoteDefinition => {
511                        typst_output.push(']');
512                    }
513                    _ => {}
514                },
515                Event::Text(text) => {
516                    if in_image {
517                        // do nothing
518                    } else if in_code_block {
519                        log::debug!("chapter: {:?}, Text: {:?}", chapter.name, text);
520                        if is_fenced_code_block {
521                            // if it's inside a list item, ident is required.
522                            let item_ident = "  ".repeat(list_stack.len());
523                            typst_output.push_str(&item_ident);
524                            if text.contains("```") {
525                                typst_output.push_str("````");
526                            } else {
527                                typst_output.push_str("```");
528                            }
529                        } else {
530                            typst_output.push_str("` ");
531                        }
532                        if let Some(language) = code_block_language.take() {
533                            typst_output.push_str(&language);
534                        }
535                        if is_fenced_code_block {
536                            typst_output.push('\n');
537                        }
538                        // typst_output.push('\n');
539                        typst_output.push_str(&text);
540                        if is_fenced_code_block {
541                            if text.contains("```") {
542                                typst_output.push_str("\n````");
543                            } else {
544                                typst_output.push_str("\n```");
545                            }
546                        } else {
547                            typst_output.push_str(" `");
548                        }
549                    } else {
550                        let text_str = text.to_string();
551
552                        if is_url(&text_str) && in_strong_context {
553                            // For URLs in bold context, remove previous bold marker and format properly
554                            // Check if we need to trim the previous " *" that was added
555                            if typst_output.ends_with(" *") {
556                                typst_output.truncate(typst_output.len() - 2);
557                            }
558
559                            // Add formatted bold link
560                            typst_output
561                                .push_str(&format!(" *#link(\"{}\")[{}]*", text_str, text_str));
562                            handled_bold_url = true; // Mark that we've handled this bold URL
563                        } else if is_url(&text_str) {
564                            // Regular URL (not in bold)
565                            typst_output
566                                .push_str(&format!("#link(\"{}\")[{}]", text_str, text_str));
567                        } else {
568                            // Regular text
569                            let escaped_text = escape_typst_special_chars(&text_str);
570                            let processed_text = fix_typst_formatting(&escaped_text);
571                            typst_output.push_str(&processed_text);
572                        }
573                    }
574                }
575                Event::Code(code) => {
576                    // if it's defined as code block, no matter fenced or not, it will be handled in Event::Text following
577                    // the code block is opened and closed in Event::Text
578                    typst_output.push_str("` ");
579                    typst_output.push_str(&code);
580                    typst_output.push_str(" `");
581                }
582                Event::InlineMath(math) => {
583                    // Handle inline math with Typst's $ syntax
584                    typst_output.push_str(&format!("${{{}}};$", math));
585                }
586                Event::DisplayMath(math) => {
587                    // Handle display math with Typst's $$ syntax
588                    typst_output.push_str(&format!("$${{{}}};$$", math));
589                }
590                Event::InlineHtml(html) => {
591                    // Handle inline HTML similar to regular HTML
592                    let html_str = html.to_string();
593                    log::debug!("Inline HTML: {:?}", html_str);
594                    // Check if it's an image tag
595                    if html_str.contains("<img") {
596                        if let Some(cap) = RE_HTML_IMG.captures(&html_str) {
597                            if let Some(src) = cap.get(1) {
598                                let image_path = src.as_str();
599                                log::debug!("Inline image path: {:?}", image_path);
600
601                                if !image_path.starts_with("http://")
602                                    && !image_path.starts_with("https://")
603                                {
604                                    // Relative path
605                                    let file_name = Path::new(image_path)
606                                        .file_name()
607                                        .unwrap_or_default()
608                                        .to_str()
609                                        .unwrap_or_default();
610
611                                    // Calculate image size
612                                    let (width, _height) = calculate_image_size(
613                                        image_path,
614                                        &self.max_width,
615                                        &self.max_height,
616                                        ctx,
617                                    );
618
619                                    typst_output.push_str(&format!("#figure(\n  image(\"{}/{}\", width: {}),\n  caption: []\n)", 
620                                        image_folder_name, file_name, width
621                                    ));
622                                } else {
623                                    // Process remote image
624                                    match download_remote_image(image_path, &image_dir) {
625                                        Ok(local_path) => {
626                                            typst_output.push_str(&format!(
627                                                "#figure(\n  image(\"{}/{}\"),\n  caption: []\n)",
628                                                image_folder_name, local_path
629                                            ));
630                                        }
631                                        Err(e) => {
632                                            log::error!(
633                                                "Failed to process remote image {}: {}",
634                                                image_path,
635                                                e
636                                            );
637                                            typst_output.push_str(
638                                                "#text(fill: red)[Image download failed]",
639                                            );
640                                        }
641                                    }
642                                }
643                            }
644                        }
645                    }
646                    // Ignore other HTML tags
647                }
648                Event::Html(html) => {
649                    // Handle block HTML
650                    let html_str = html.to_string();
651
652                    // Check if it's an image tag
653                    if html_str.contains("<img") {
654                        if let Some(cap) = RE_HTML_IMG.captures(&html_str) {
655                            if let Some(src) = cap.get(1) {
656                                let image_path = src.as_str();
657
658                                if !image_path.starts_with("http://")
659                                    && !image_path.starts_with("https://")
660                                {
661                                    // Relative path
662                                    let file_name = Path::new(image_path)
663                                        .file_name()
664                                        .unwrap_or_default()
665                                        .to_str()
666                                        .unwrap_or_default();
667
668                                    // Calculate image size
669                                    let (width, _height) = calculate_image_size(
670                                        image_path,
671                                        &self.max_width,
672                                        &self.max_height,
673                                        ctx,
674                                    );
675
676                                    typst_output.push_str(&format!("#figure(\n  image(\"{}/{}\", width: {}),\n  caption: []\n)", 
677                                        image_folder_name, file_name, width
678                                    ));
679                                } else {
680                                    // Process remote image
681                                    match download_remote_image(image_path, &image_dir) {
682                                        Ok(local_path) => {
683                                            typst_output.push_str(&format!(
684                                                "#figure(\n  image(\"{}/{}\"),\n  caption: []\n)",
685                                                image_folder_name, local_path
686                                            ));
687                                        }
688                                        Err(e) => {
689                                            log::error!(
690                                                "Failed to process remote image {}: {}",
691                                                image_path,
692                                                e
693                                            );
694                                            typst_output.push_str(
695                                                "#text(fill: red)[Image download failed]",
696                                            );
697                                        }
698                                    }
699                                }
700                            }
701                        }
702                    }
703                    // Ignore other HTML tags
704                }
705                Event::FootnoteReference(reference) => {
706                    typst_output.push_str(&format!("#footnote[See note {}]", reference));
707                }
708                Event::SoftBreak => {
709                    typst_output.push(' ');
710                }
711                Event::HardBreak => {
712                    typst_output.push_str("\\\n");
713                }
714                Event::Rule => {
715                    typst_output.push_str("\n#line(length: 100%)\n");
716                }
717                Event::TaskListMarker(checked) => {
718                    let marker = if checked { "[x]" } else { "[ ]" };
719                    typst_output.push_str(&format!("{} ", marker));
720                }
721            }
722        }
723
724        // Apply post-processing for special typst formatting issues
725        // let typst_output = post_process_typst_output(&typst_output)?;
726
727        log::debug!("Converted content to typst format");
728        Ok(typst_output)
729    }
730    // Helper function to debug book structure
731    fn debug_book_structure(&self, book: &mdbook_renderer::book::Book) {
732        log::debug!("Book structure analysis:");
733        log::debug!("Total items: {}", book.items.len());
734
735        for (i, item) in book.items.iter().enumerate() {
736            match item {
737                mdbook_renderer::book::BookItem::Chapter(chapter) => {
738                    log::debug!("Section {}: Chapter name:'{}'", i + 1, chapter.name);
739                    log::debug!(
740                        "  - length: {},numbers:{:?};sub_items:{}",
741                        chapter.content.len(),
742                        chapter.number,
743                        chapter.sub_items.len()
744                    );
745                    log::debug!(
746                        "  - Path: {:?}; source_path: {:?}",
747                        chapter.path,
748                        chapter.source_path
749                    );
750                    log::debug!("  - parent_names: {:?}", chapter.parent_names);
751
752                    debug_sub_items(&chapter.sub_items, 1);
753                }
754                mdbook_renderer::book::BookItem::Separator => {
755                    log::debug!("Section {}: Separator", i + 1);
756                }
757                mdbook_renderer::book::BookItem::PartTitle(title) => {
758                    log::debug!("Section {}: Part Title '{}'", i + 1, title);
759                }
760            }
761        }
762    }
763
764    pub fn convert_chapters(
765        &self,
766        chapter_file_list: &mut Vec<PathBuf>, // full path of the generated typst file
767        ctx: &mdbook_renderer::RenderContext,
768    ) -> anyhow::Result<()> {
769        let book = &ctx.book;
770
771        // Debug book structure
772        self.debug_book_structure(book);
773
774        // // Create a map to track which chapter each image belongs to
775        // let mut chapter_images = std::collections::HashMap::new();
776
777        for (chapter_number, item) in book.items.iter().enumerate() {
778            match item {
779                mdbook_renderer::book::BookItem::Chapter(chapter) => {
780                    self.process_each_chapter(chapter, chapter_number + 1, chapter_file_list, ctx)?;
781                }
782                mdbook_renderer::book::BookItem::Separator => {
783                    log::debug!("Skipping separator in book structure");
784                }
785                mdbook_renderer::book::BookItem::PartTitle(title) => {
786                    log::info!("Skipping part title in book structure: {}", title);
787                }
788            }
789        }
790
791        // // Copy images from the source directory to chapter-specific directories
792        // self.copy_images(ctx, &chapter_dir, &chapter_images)?;
793
794        Ok(())
795    }
796    // Helper function to calculate the correct relative path to templates
797    fn calculate_relative_path_to_templates(
798        &self,
799        chapter: &mdbook_renderer::book::Chapter,
800        ctx: &mdbook_renderer::RenderContext,
801    ) -> String {
802        let rel_name = self.get_chapter_relative_chapter_file_name(chapter, ctx);
803        match rel_name {
804            None => "".to_string(),
805            Some(rel) => {
806                // this folder is started from chapters, so, simply count the number of "/" or "\\" in the path
807                let path_str = rel.to_string_lossy().to_string();
808                let subdirs = if path_str.contains('/') {
809                    path_str.split("/").count() - 1
810                } else {
811                    path_str.split("\\").count() - 1
812                };
813                "../".repeat(subdirs)
814            }
815        }
816    }
817
818    fn process_each_chapter(
819        &self,
820        chapter: &mdbook_renderer::book::Chapter,
821        _chapter_number: usize, // full index, including Seperator and PartTitle
822        chapter_file_list: &mut Vec<PathBuf>,
823        ctx: &mdbook_renderer::RenderContext,
824    ) -> anyhow::Result<()> {
825        let chapter_dir = self.get_chapters_dir(ctx);
826        if let Some(_source_path) = &chapter.source_path {
827            let chapter_file = self.get_chapter_full_file_name(chapter, ctx).unwrap(); // when source_path is Some, the chapter_file is the full path
828            chapter_file_list.push(chapter_file.clone());
829            // get the path of the `typ_name` file and create the folder if not exists
830            let typ_dir = chapter_file.parent().unwrap_or_else(|| &chapter_dir);
831            if !typ_dir.exists() {
832                std::fs::create_dir_all(typ_dir)?;
833            }
834
835            let typst_content =
836                self.parse_chapter_content(chapter, &chapter.content, &chapter_file, typ_dir, ctx)?;
837
838            // Write to file
839            std::fs::write(&chapter_file, typst_content)?;
840            log::debug!(
841                "Created typst file for {}: {}",
842                chapter.name,
843                chapter_file.display()
844            );
845        }
846
847        for (sub_chapter_number, sub_item) in chapter.sub_items.iter().enumerate() {
848            match sub_item {
849                mdbook_renderer::book::BookItem::Chapter(chapter) => {
850                    self.process_each_chapter(
851                        chapter,
852                        sub_chapter_number + 1,
853                        chapter_file_list,
854                        ctx,
855                    )?;
856                }
857                mdbook_renderer::book::BookItem::Separator => {
858                    log::debug!("Skipping separator in book structure");
859                }
860                mdbook_renderer::book::BookItem::PartTitle(title) => {
861                    log::info!("Skipping part title in book structure: {}", title);
862                }
863            }
864        }
865
866        Ok(())
867    }
868}
869
870fn preprocess_img_tag(content: &str) -> String {
871    // Regex to capture the src attribute from img tags
872    let re = regex::Regex::new(r#"<img[^>]*src=["']([^"']+)["'][^>]*>"#).unwrap();
873
874    // If no img tags found, return original content
875    if !re.is_match(content) {
876        return content.to_string();
877    }
878
879    // Regex to extract alt attribute if it exists
880    let alt_re = regex::Regex::new(r#"alt=["']([^"']+)["']"#).unwrap();
881
882    // Replace each img tag with markdown image syntax
883    let result = re.replace_all(content, |caps: &regex::Captures| {
884        let src = caps.get(1).unwrap().as_str();
885        let img_tag = caps.get(0).unwrap().as_str();
886
887        // Try to extract alt attribute
888        let alt = if let Some(alt_caps) = alt_re.captures(img_tag) {
889            alt_caps.get(1).unwrap().as_str().to_string()
890        } else {
891            // If alt is missing, extract filename from src path
892            let path = Path::new(src);
893            let filename = path
894                .file_stem()
895                .unwrap_or_default()
896                .to_str()
897                .unwrap_or_default();
898            filename.to_string()
899        };
900
901        log::debug!("Converting img tag - src: {:?}, alt: {:?}", src, alt);
902        format!("![{}]({})", alt, src)
903    });
904
905    result.into_owned()
906}
907// Helper function to escape special Typst characters
908fn escape_typst_special_chars(text: &str) -> String {
909    let mut result = String::with_capacity(text.len());
910
911    // Skip leading # characters that might be from Markdown headings
912    let text_without_leading_hash = text.trim_start_matches('#').trim_start();
913    let text_to_process = if text_without_leading_hash.len() < text.len() {
914        text_without_leading_hash
915    } else {
916        text
917    };
918
919    // Special case for "unquoted *" pattern in command line examples
920    if text_to_process.contains("unquoted *") {
921        return text_to_process.replace("unquoted *", "unquoted \\*");
922    }
923
924    // Special handling for URLs
925    if is_url(text_to_process) {
926        // For URLs, don't escape any characters as they'll be handled by #link
927        return text_to_process.to_string();
928    }
929
930    // Original character escaping logic
931    for c in text_to_process.chars() {
932        match c {
933            '#' | '*' | '_' | '`' | '$' | '{' | '}' | '[' | ']' | '<' | '>' => {
934                result.push('\\');
935                result.push(c);
936            }
937            '\\' => {
938                // Double backslashes in string literals
939                result.push('\\');
940                result.push('\\');
941            }
942            '"' => {
943                // Escape quotes in string literals
944                result.push('\\');
945                result.push('"');
946            }
947            _ => result.push(c),
948        }
949    }
950    result
951}
952
953// Helper function to fix common formatting issues in Typst output
954fn fix_typst_formatting(text: &str) -> String {
955    let mut result = text.to_string();
956
957    // Fix spacing after colons in bold text
958    result = result.replace("*:", "* :");
959
960    // // Fix spacing after "is" in bold text
961    // result = result.replace("*is", "* is");
962
963    // // Fix spacing after "pod" in bold text
964    // result = result.replace("*pod", "* pod");
965
966    result
967}
968
969// // Helper function to post-process the Typst output
970// pub fn post_process_typst_output(content: &str) -> Result<String, Error> {
971//     let mut processed_content = String::new();
972//     // Remove unused state variables
973//     let mut code_block_depth = 0;
974//     let mut raw_call_depth = 0;
975//     let mut quote_block_depth = 0;
976//     let mut xml_tag_depth = 0;
977
978//     // First pass: identify unclosed elements and standalone closing brackets
979//     let mut standalone_closing_brackets = Vec::new();
980//     let mut quote_stack = Vec::new();
981
982//     for (i, line) in content.lines().enumerate() {
983//         // Track state for each line
984//         if line.contains("#code_block[") {
985//             code_block_depth += 1;
986//         }
987//         if line.contains("#raw(") {
988//             raw_call_depth += 1;
989//         }
990//         if line.contains("#quote[") {
991//             quote_block_depth += 1;
992//             quote_stack.push(i + 1); // Store line number (1-indexed)
993//         }
994
995//         // Check for closing brackets
996//         let closing_brackets = line.matches("]").count();
997//         if closing_brackets > 0 {
998//             // Handle escaped closing brackets (like \])
999//             let escaped_closing_brackets = line.matches("\\]").count();
1000//             let actual_closing_brackets = closing_brackets - escaped_closing_brackets;
1001
1002//             // Adjust depths based on actual closing brackets
1003//             if code_block_depth > 0 && actual_closing_brackets > 0 {
1004//                 code_block_depth -= std::cmp::min(code_block_depth, actual_closing_brackets);
1005//             } else if raw_call_depth > 0 && actual_closing_brackets > 0 {
1006//                 raw_call_depth -= std::cmp::min(raw_call_depth, actual_closing_brackets);
1007//             } else if quote_block_depth > 0 && actual_closing_brackets > 0 {
1008//                 let brackets_to_close = std::cmp::min(quote_block_depth, actual_closing_brackets);
1009//                 quote_block_depth -= brackets_to_close;
1010//                 // Remove the corresponding opening brackets from the stack
1011//                 for _ in 0..brackets_to_close {
1012//                     if !quote_stack.is_empty() {
1013//                         quote_stack.pop();
1014//                     }
1015//                 }
1016//             } else if actual_closing_brackets > 0 {
1017//                 // This is a standalone closing bracket
1018//                 if !line.trim().starts_with("\\]") { // Ignore if it's an escaped bracket at the start of a line
1019//                     standalone_closing_brackets.push(i + 1); // Store line number (1-indexed)
1020//                 }
1021//             }
1022//         }
1023
1024//         // Check for XML tags
1025//         if line.contains("<") && line.contains(">") {
1026//             let opening_tags = line.matches("<").count();
1027//             let closing_tags = line.matches(">").count();
1028
1029//             match opening_tags.cmp(&closing_tags) {
1030//                 std::cmp::Ordering::Greater => {
1031//                     xml_tag_depth += opening_tags - closing_tags;
1032//                 },
1033//                 std::cmp::Ordering::Less => {
1034//                     let excess_closing = closing_tags - opening_tags;
1035//                     if xml_tag_depth >= excess_closing {
1036//                         xml_tag_depth -= excess_closing;
1037//                     } else {
1038//                         // More closing tags than we have open
1039//                         xml_tag_depth = 0;
1040//                     }
1041//                 },
1042//                 std::cmp::Ordering::Equal => {
1043//                     // Tags are balanced, no change to depth
1044//                 }
1045//             }
1046//         }
1047//     }
1048
1049//     // Store unclosed elements counts for later use
1050//     let unclosed_code_blocks = code_block_depth;
1051//     let unclosed_raw_calls = raw_call_depth;
1052//     let unclosed_quote_blocks = quote_block_depth;
1053//     let unclosed_xml_tags = xml_tag_depth;
1054
1055//     // Log warnings for standalone closing brackets
1056//     if !standalone_closing_brackets.is_empty() {
1057//         warn!("Standalone closing brackets at lines: {:?}", standalone_closing_brackets);
1058//         log::info!("{}",&content[0..50]);
1059//     }
1060
1061//     // Reset state for second pass
1062//     code_block_depth = 0;
1063//     raw_call_depth = 0;
1064//     quote_block_depth = 0;
1065
1066//     // Second pass: process content
1067//     for line in content.lines() {
1068//         let mut processed_line = line.to_string();
1069
1070//         // Handle escaped closing brackets by temporarily replacing them
1071//         processed_line = processed_line.replace("\\]", "ESCAPED_BRACKET_PLACEHOLDER");
1072
1073//         // Track state for each line
1074//         if processed_line.contains("#code_block[") {
1075//             code_block_depth += 1;
1076//         }
1077//         if processed_line.contains("#raw(") {
1078//             raw_call_depth += 1;
1079//         }
1080//         if processed_line.contains("#quote[") {
1081//             quote_block_depth += 1;
1082//         }
1083
1084//         // Check for closing brackets
1085//         let closing_brackets = processed_line.matches("]").count();
1086//         if closing_brackets > 0 {
1087//             // Adjust depths based on closing brackets
1088//             if code_block_depth > 0 && closing_brackets > 0 {
1089//                 let brackets_to_close = std::cmp::min(code_block_depth, closing_brackets);
1090//                 code_block_depth -= brackets_to_close;
1091//             } else if raw_call_depth > 0 && closing_brackets > 0 {
1092//                 let brackets_to_close = std::cmp::min(raw_call_depth, closing_brackets);
1093//                 raw_call_depth -= brackets_to_close;
1094//             } else if quote_block_depth > 0 && closing_brackets > 0 {
1095//                 let brackets_to_close = std::cmp::min(quote_block_depth, closing_brackets);
1096//                 quote_block_depth -= brackets_to_close;
1097//             }
1098//         }
1099
1100//         // Restore escaped closing brackets
1101//         processed_line = processed_line.replace("ESCAPED_BRACKET_PLACEHOLDER", "\\]");
1102
1103//         // Add the processed line to the output
1104//         processed_content.push_str(&processed_line);
1105//         processed_content.push('\n');
1106//     }
1107
1108//     // Close any unclosed elements at the end of the file
1109//     if unclosed_quote_blocks > 0 {
1110//         warn!("Adding {} closing brackets for unclosed quote blocks", unclosed_quote_blocks);
1111//         for _ in 0..unclosed_quote_blocks {
1112//             processed_content.push_str("]\n");
1113//         }
1114//     }
1115
1116//     if unclosed_code_blocks > 0 {
1117//         warn!("Adding {} closing brackets for unclosed code blocks", unclosed_code_blocks);
1118//         for _ in 0..unclosed_code_blocks {
1119//             processed_content.push_str("]\n");
1120//         }
1121//     }
1122
1123//     if unclosed_raw_calls > 0 {
1124//         warn!("Adding {} closing parentheses for unclosed raw calls", unclosed_raw_calls);
1125//         for _ in 0..unclosed_raw_calls {
1126//             processed_content.push_str(")\n");
1127//         }
1128//     }
1129
1130//     if unclosed_xml_tags > 0 {
1131//         warn!("Adding {} closing tags for unclosed XML tags", unclosed_xml_tags);
1132//         for _ in 0..unclosed_xml_tags {
1133//             processed_content.push_str(">\n");
1134//         }
1135//     }
1136
1137//     Ok(processed_content)
1138// }
1139
1140// // New function to convert MathJax notation to Typst math syntax
1141// fn convert_mathjax_to_typst(text: &str) -> String {
1142//     let mut result = text.to_string();
1143
1144//     // Convert display math: \\[ ... \\] to Typst's $ ... $ format
1145//     // Use (?s) flag for multiline matching and .*? for non-greedy matching
1146//     let display_math_re = regex::Regex::new(r"(?s)\\\\(\[)(.*?)\\\\(\])").unwrap();
1147//     result = display_math_re.replace_all(&result, |caps: &regex::Captures| {
1148//         let math_content = &caps[2];
1149//         format!("$ {} $", convert_latex_to_typst(math_content))
1150//     }).to_string();
1151
1152//     // Convert inline math: \\( ... \\) to Typst's $ ... $ format
1153//     // Use (?s) flag for multiline matching and .*? for non-greedy matching
1154//     let inline_math_re = regex::Regex::new(r"(?s)\\\\(\()(.*?)\\\\(\))").unwrap();
1155//     result = inline_math_re.replace_all(&result, |caps: &regex::Captures| {
1156//         let math_content = &caps[2];
1157//         format!("$ {} $", convert_latex_to_typst(math_content))
1158//     }).to_string();
1159
1160//     result
1161// }
1162
1163// // Helper function to convert LaTeX math syntax to Typst math syntax
1164// fn convert_latex_to_typst(latex: &str) -> String {
1165//     // This is a simplified converter that handles common LaTeX constructs
1166//     // A complete converter would be more extensive
1167//     let mut typst = latex.to_string();
1168
1169//     // Convert common LaTeX commands to Typst equivalents
1170//     // Basic replacements
1171//     typst = typst.replace("\\begin{aligned}", "");
1172//     typst = typst.replace("\\end{aligned}", "");
1173
1174//     // Matrices
1175//     typst = typst.replace("\\begin{pmatrix}", "mat(");
1176//     typst = typst.replace("\\end{pmatrix}", ")");
1177
1178//     // Better matrix handling with proper comma separation
1179//     // Replace matrix row separators \\ with ), (
1180//     let matrix_row_re = regex::Regex::new(r"mat\((.*?)\\\\").unwrap();
1181//     typst = matrix_row_re.replace_all(&typst, |caps: &regex::Captures| {
1182//         format!("mat({});", &caps[1])
1183//     }).to_string();
1184
1185//     // Convert the cells within a matrix row to be comma-separated
1186//     let matrix_cell_re = regex::Regex::new(r"mat\((.*?)\s+(\S+)\s*\)").unwrap();
1187//     typst = matrix_cell_re.replace_all(&typst, |caps: &regex::Captures| {
1188//         let cells = caps[1].split_whitespace().collect::<Vec<&str>>().join(", ");
1189//         let last_cell = &caps[2];
1190//         format!("mat({}, {})", cells, last_cell)
1191//     }).to_string();
1192
1193//     // Fractions
1194//     let frac_re = regex::Regex::new(r"\\frac\{([^}]*)\}\{([^}]*)\}").unwrap();
1195//     typst = frac_re.replace_all(&typst, |caps: &regex::Captures| {
1196//         format!("frac({}, {})", &caps[1], &caps[2])
1197//     }).to_string();
1198
1199//     // Subscripts and superscripts
1200//     let subscript_re = regex::Regex::new(r"_\{([^}]*)\}").unwrap();
1201//     typst = subscript_re.replace_all(&typst, |caps: &regex::Captures| {
1202//         format!("_({}) ", &caps[1])
1203//     }).to_string();
1204
1205//     let superscript_re = regex::Regex::new(r"\^\{([^}]*)\}").unwrap();
1206//     typst = superscript_re.replace_all(&typst, |caps: &regex::Captures| {
1207//         format!("^({}) ", &caps[1])
1208//     }).to_string();
1209
1210//     // Common mathematical symbols
1211//     typst = typst.replace("\\infty", "infinity");
1212//     typst = typst.replace("\\int", "integral");
1213//     typst = typst.replace("\\sum", "sum");
1214//     typst = typst.replace("\\prod", "product");
1215//     typst = typst.replace("\\to", "arrow");
1216//     typst = typst.replace("\\rightarrow", "arrow");
1217//     typst = typst.replace("\\Rightarrow", "=>");
1218//     typst = typst.replace("\\implies", "=>");
1219//     typst = typst.replace("\\alpha", "alpha");
1220//     typst = typst.replace("\\beta", "beta");
1221//     typst = typst.replace("\\gamma", "gamma");
1222//     typst = typst.replace("\\delta", "delta");
1223//     typst = typst.replace("\\epsilon", "epsilon");
1224//     typst = typst.replace("\\zeta", "zeta");
1225//     typst = typst.replace("\\eta", "eta");
1226//     typst = typst.replace("\\theta", "theta");
1227//     typst = typst.replace("\\iota", "iota");
1228//     typst = typst.replace("\\kappa", "kappa");
1229//     typst = typst.replace("\\lambda", "lambda");
1230//     typst = typst.replace("\\mu", "mu");
1231//     typst = typst.replace("\\nu", "nu");
1232//     typst = typst.replace("\\xi", "xi");
1233//     typst = typst.replace("\\pi", "pi");
1234//     typst = typst.replace("\\rho", "rho");
1235//     typst = typst.replace("\\sigma", "sigma");
1236//     typst = typst.replace("\\tau", "tau");
1237//     typst = typst.replace("\\upsilon", "upsilon");
1238//     typst = typst.replace("\\phi", "phi");
1239//     typst = typst.replace("\\chi", "chi");
1240//     typst = typst.replace("\\psi", "psi");
1241//     typst = typst.replace("\\omega", "omega");
1242
1243//     // Align markers
1244//     typst = typst.replace("&=", "=");
1245//     typst = typst.replace("&\\approx", "approx");
1246
1247//     // Line breaks in math
1248//     typst = typst.replace("\\\\", " \\\n");
1249
1250//     typst
1251// }
1252
1253// // 辅助函数,只处理非代码块中的$符号
1254// fn escape_dollar_signs(text: &str) -> String {
1255//     let re = regex::Regex::new(r"\$([A-Za-z][A-Za-z0-9_]*)").unwrap();
1256
1257//     let mut result = String::new();
1258//     let mut last_end = 0;
1259
1260//     for cap in re.captures_iter(text) {
1261//         let full_match = cap.get(0).unwrap();
1262//         let start = full_match.start();
1263//         let end = full_match.end();
1264
1265//         // 检查$是否已经被转义
1266//         let is_escaped = start > 0 && text[..start].ends_with('\\');
1267
1268//         // 添加匹配前的文本
1269//         result.push_str(&text[last_end..start]);
1270
1271//         // 添加匹配,根据需要转义$
1272//         if is_escaped {
1273//             // 已经转义,保持原样
1274//             result.push_str(&text[start..end]);
1275//         } else {
1276//             // 未转义,添加转义
1277//             result.push('\\');
1278//             result.push_str(&text[start..end]);
1279//         }
1280
1281//         last_end = end;
1282//     }
1283
1284//     // 添加剩余文本
1285//     result.push_str(&text[last_end..]);
1286
1287//     result
1288// }
1289
1290// Helper function to recursively debug sub-items
1291fn debug_sub_items(sub_items: &[mdbook_renderer::book::BookItem], level: usize) {
1292    let indent = "  ".repeat(level + 1);
1293
1294    for (i, item) in sub_items.iter().enumerate() {
1295        match item {
1296            mdbook_renderer::book::BookItem::Chapter(chapter) => {
1297                log::debug!("{}Sub-item {}: Chapter '{}'", indent, i + 1, chapter.name);
1298                log::debug!(
1299                    "  - length: {},numbers:{:?};sub_items:{}",
1300                    chapter.content.len(),
1301                    chapter.number,
1302                    chapter.sub_items.len()
1303                );
1304                log::debug!(
1305                    "  - Path: {:?}; source_path: {:?}",
1306                    chapter.path,
1307                    chapter.source_path
1308                );
1309                log::debug!("  - parent_names: {:?}", chapter.parent_names);
1310
1311                if !chapter.sub_items.is_empty() {
1312                    debug_sub_items(&chapter.sub_items, level + 1);
1313                }
1314            }
1315            mdbook_renderer::book::BookItem::Separator => {
1316                log::debug!("{}Sub-item {}: Separator", indent, i + 1);
1317            }
1318            mdbook_renderer::book::BookItem::PartTitle(title) => {
1319                log::debug!("{}Sub-item {}: Part Title '{}'", indent, i + 1, title);
1320            }
1321        }
1322    }
1323}
1324
1325// Function to download an image from URL and save it locally
1326fn download_remote_image(image_url: &str, image_dir: &Path) -> anyhow::Result<String> {
1327    // Create a client for making requests
1328    let client = Client::new();
1329
1330    // Generate a unique filename based on URL hash or timestamp
1331    let timestamp = SystemTime::now()
1332        .duration_since(UNIX_EPOCH)
1333        .unwrap_or_default()
1334        .as_secs();
1335
1336    // Extract extension from URL or default to png
1337    let extension = match image_url.split('.').next_back() {
1338        Some(ext)
1339            if ["jpg", "jpeg", "png", "gif", "webp", "svg"]
1340                .contains(&ext.to_lowercase().as_str()) =>
1341        {
1342            ext
1343        }
1344        _ => "png",
1345    };
1346
1347    let file_name = format!("remote_img_{}.{}", timestamp, extension);
1348    let file_path = image_dir.join(&file_name);
1349
1350    // Attempt to download the image
1351    match client.get(image_url).send() {
1352        Ok(response) => {
1353            if response.status().is_success() {
1354                match response.bytes() {
1355                    Ok(bytes) => {
1356                        // Write the image data to file
1357                        let mut file = fs::File::create(&file_path)?;
1358                        file.write_all(bytes.as_ref())?;
1359
1360                        // Return the filename to be used in Typst
1361                        log::debug!(
1362                            "Downloaded remote image: {} -> {}",
1363                            image_url,
1364                            file_path.display()
1365                        );
1366                        Ok(file_name)
1367                    }
1368                    Err(e) => {
1369                        log::error!("Failed to read image bytes from {}: {}", image_url, e);
1370                        create_placeholder_image(image_dir)
1371                    }
1372                }
1373            } else {
1374                log::error!(
1375                    "Failed to download image {}: HTTP status {}",
1376                    image_url,
1377                    response.status()
1378                );
1379                create_placeholder_image(image_dir)
1380            }
1381        }
1382        Err(e) => {
1383            log::error!("Failed to download image {}: {}", image_url, e);
1384            create_placeholder_image(image_dir)
1385        }
1386    }
1387}
1388
1389// Create a placeholder image when download fails
1390fn create_placeholder_image(image_dir: &Path) -> anyhow::Result<String> {
1391    let timestamp = SystemTime::now()
1392        .duration_since(UNIX_EPOCH)
1393        .unwrap_or_default()
1394        .as_secs();
1395
1396    let file_name = format!("placeholder_{}.txt", timestamp);
1397    let file_path = image_dir.join(&file_name);
1398
1399    // Create a simple text file that Typst can include
1400    let mut file = fs::File::create(&file_path)?;
1401    file.write_all(b"#text(fill: red)[Image could not be downloaded]")?;
1402
1403    log::warn!(
1404        "Created placeholder for failed image download: {}",
1405        file_path.display()
1406    );
1407    Ok(file_name)
1408}
1409
1410// Add this function to detect URLs
1411fn is_url(text: &str) -> bool {
1412    // Simple check for http/https URLs
1413    text.starts_with("http://") || text.starts_with("https://")
1414}
1415
1416// tests
1417#[cfg(test)]
1418mod tests {
1419    use super::*;
1420
1421    #[test]
1422    fn test_process_img_tag() {
1423        let input = r###"<img src="docs/01-introduction/image-20250224001420194.png" alt="image-20250224001420194" style="zoom:50%;" />"###;
1424        let expected =
1425            r###"![image-20250224001420194](docs/01-introduction/image-20250224001420194.png)"###;
1426        let output = preprocess_img_tag(input);
1427        assert_eq!(output, expected);
1428
1429        let input = r###"## Work Folder and Common OS Environment Variables
1430
1431<img src="docs/01-introduction/image-20250221105725169.png" alt="image-20250221105725169" style="zoom:50%;" />
1432
1433Please download the attached small zip file and unzip it to a **working folder** you created. Under the working folder, you should see several components:"###;
1434        let expected = r###"## Work Folder and Common OS Environment Variables
1435
1436![image-20250221105725169](docs/01-introduction/image-20250221105725169.png)
1437
1438Please download the attached small zip file and unzip it to a **working folder** you created. Under the working folder, you should see several components:"###;
1439        let output = preprocess_img_tag(input);
1440        assert_eq!(output, expected);
1441
1442        // <img src="_images/image-20250213001741756.png" style="zoom:50%;" />
1443        let input = r###"<img src="_images/image-20250213001741756.png" style="zoom:50%;" />"###;
1444        let expected = r###"![image-20250213001741756](_images/image-20250213001741756.png)"###;
1445        let output = preprocess_img_tag(input);
1446        assert_eq!(output, expected);
1447    }
1448}