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: 
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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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: ®ex::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###""###;
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
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###""###;
1445 let output = preprocess_img_tag(input);
1446 assert_eq!(output, expected);
1447 }
1448}