use imagesize;
use lazy_static::lazy_static;
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
use regex;
use reqwest::blocking::Client;
use std::fs;
use std::io::Write;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{
cmp::PartialEq,
path::{Path, PathBuf},
};
use crate::config::IMAGE_DIR;
use super::Config;
#[derive(Debug, Clone, Copy, PartialEq)]
enum TableState {
None,
InTable,
InHeader,
InRow,
}
enum ListType {
Ordered(Option<u64>),
Unordered,
}
lazy_static! {
static ref RE_HTML_IMG: regex::Regex =
regex::Regex::new(r#"<img[^>]*src=["']([^"']+)["']"#).unwrap();
}
const PAGE_WIDTH: f64 = 595.0; const PAGE_HEIGHT: f64 = 842.0;
fn calculate_image_size(
image_path: &str,
max_width_percent: &Option<f64>, max_height_percent: &Option<f64>, ctx: &mdbook_renderer::RenderContext,
) -> (String, String) {
let default_width = "100%";
let default_height = "auto";
if max_width_percent.is_none() && max_height_percent.is_none() {
return (default_width.to_string(), default_height.to_string());
}
let mut max_width_percent = match max_width_percent {
Some(num) => *num,
None => 1.0,
};
let mut max_height_percent = match max_height_percent {
Some(num) => *num,
None => 1.0,
};
if max_width_percent > 1.0 || max_width_percent <= 0.0 {
log::error!(
"Image max width percent is out of range:{}, change it to 1.0 or 100%",
max_width_percent
);
max_width_percent = 1.0;
}
if max_height_percent > 1.0 || max_height_percent <= 0.0 {
log::error!(
"Image max height percent is out of range:{}, change it to 1.0 or 100%",
max_height_percent
);
max_height_percent = 1.0;
}
let src_dir = ctx.root.join(&ctx.config.book.src);
let full_path = src_dir.join(image_path);
match imagesize::size(full_path) {
Ok(size) => {
let img_width = size.width as f64;
let img_height = size.height as f64;
let height_ratio = img_height / img_width;
let full_width_height = PAGE_WIDTH * height_ratio;
if full_width_height > PAGE_HEIGHT * max_height_percent {
let max_height = PAGE_HEIGHT * max_height_percent;
let new_width = max_height / height_ratio;
let width_percent = (new_width / PAGE_WIDTH) * 100.0;
return (
format!("{}%", width_percent.round()),
default_height.to_string(),
);
}
if img_width > PAGE_WIDTH * max_width_percent
&& full_width_height < PAGE_HEIGHT * max_height_percent
{
return (
format!("{}%", (max_width_percent * 100.0).round()),
default_height.to_string(),
);
}
(default_width.to_string(), default_height.to_string())
}
Err(_) => {
(default_width.to_string(), default_height.to_string())
}
}
}
impl Config {
pub fn parse_chapter_content(
&self,
chapter: &mdbook_renderer::book::Chapter,
content: &str,
dst_file_path: &std::path::Path,
image_parent_dir: &std::path::Path,
ctx: &mdbook_renderer::RenderContext,
) -> anyhow::Result<String> {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
let content = preprocess_img_tag(content);
let parser = Parser::new_ext(&content, options);
let mut typst_output = String::new();
let image_folder_name = IMAGE_DIR;
let image_dir = image_parent_dir.join(image_folder_name);
if !image_dir.exists() {
std::fs::create_dir_all(&image_dir)?;
log::debug!("Created image directory at {:?}", image_dir);
}
let template_rel_path = self.calculate_relative_path_to_templates(chapter, ctx);
log::debug!(
"Template relative path for {}: {}",
dst_file_path.display(),
template_rel_path
);
if let Some(chapter_imports) = &self.chapter_imports {
typst_output.push_str(chapter_imports);
}
let mut list_stack = Vec::new();
let mut table_state = TableState::None;
let mut table_columns: usize = 0;
let mut in_image = false;
let mut in_strong_context = false;
let mut handled_bold_url = false;
let mut in_code_block = false;
let mut is_fenced_code_block = false;
let mut code_block_language = None;
let mut first_para_in_list_item = false;
for event in parser {
log::trace!("event:{:?}", event);
match event {
Event::Start(tag) => match tag {
Tag::Paragraph => {
if table_state != TableState::None {
} else if !list_stack.is_empty() {
if first_para_in_list_item {
first_para_in_list_item = false;
} else {
let item_ident = " ".repeat(list_stack.len());
typst_output.push_str(&format!("\n{}", item_ident));
}
} else {
typst_output.push('\n');
}
}
Tag::Heading { level, .. } => {
typst_output.push_str("\n\n");
typst_output.push_str(&format!("{} ", "=".repeat(level as usize)));
}
Tag::BlockQuote(_) => {
if !typst_output.is_empty() && !typst_output.ends_with('\n') {
typst_output.push('\n');
}
typst_output.push_str("#quote[");
}
Tag::CodeBlock(kind) => {
log::debug!("chapter: {:?}, Code block kind: {:?}", chapter.name, kind);
in_code_block = true;
if let CodeBlockKind::Fenced(lang) = kind {
is_fenced_code_block = true;
if !lang.is_empty() {
code_block_language = Some(lang.to_string());
}
}
}
Tag::List(start) => {
if !typst_output.ends_with('\n') {
typst_output.push('\n');
}
if start.is_some() {
list_stack.push(ListType::Ordered(start));
} else {
list_stack.push(ListType::Unordered);
}
}
Tag::Item => {
first_para_in_list_item = true;
let list_type = match list_stack.last() {
Some(ListType::Ordered(start)) => match start {
None => "+ ",
Some(1) => "+ ", Some(num) => &format!("{}. ", num),
},
_ => "- ",
};
let indent = " ".repeat(list_stack.len() - 1);
if !typst_output.ends_with('\n') {
typst_output.push('\n');
}
typst_output.push_str(&format!("{}{} ", indent, list_type));
}
Tag::Emphasis => {
typst_output.push_str(" _");
}
Tag::Strong => {
in_strong_context = true;
handled_bold_url = false;
typst_output.push_str(" *"); }
Tag::Strikethrough => {
typst_output.push_str("#strike[");
}
Tag::Link {
link_type: _,
dest_url,
..
} => {
typst_output.push_str(&format!("#link(\"{}\")[", dest_url));
}
Tag::Image {
link_type,
dest_url,
title,
..
} => {
let list_ident = " ".repeat(list_stack.len());
in_image = true;
log::debug!(
"Image link_type:{:?},dest_url:{:?},title:{:?}",
link_type,
dest_url,
title
);
let image_path_name = dest_url.to_string();
if !image_path_name.starts_with("http://")
&& !image_path_name.starts_with("https://")
{
let image_path = Path::new(&image_path_name);
if image_path.is_absolute() {
typst_output.push_str(&format!(
"{},#figure(\n image(\"{}\"),\n caption: none)",
list_ident, image_path_name
));
} else {
let src_dir = ctx.root.join(&ctx.config.book.src);
let src_image_path = src_dir.join(&image_path_name);
let dst_image_path = image_dir.join(&image_path_name);
if let Some(image_new_path) = dst_image_path.parent() {
if !image_new_path.exists() {
log::debug!("Creating image dir: {:?}", image_new_path);
std::fs::create_dir_all(image_new_path)?;
}
}
if src_image_path.exists() {
if let Err(e) = std::fs::copy(&src_image_path, &dst_image_path)
{
log::error!(
"Failed to copy image from {:?} to {:?}: {}",
src_image_path,
dst_image_path,
e
);
} else {
log::debug!(
"Copied image from {:?} to {:?}",
src_image_path,
dst_image_path
);
}
} else {
log::warn!("Source image not found: {:?}", src_image_path);
}
let (width, _height) = calculate_image_size(
&image_path_name,
&self.max_width,
&self.max_height,
ctx,
);
let new_image_path = format!("{}/{}", IMAGE_DIR, image_path_name);
typst_output.push_str(&format!(
"{}#figure(\n image(\"{}\", width: {}),\n caption: none)",
list_ident, new_image_path, width
));
}
} else if image_path_name.starts_with("http://")
|| image_path_name.starts_with("https://")
{
match download_remote_image(&image_path_name, &image_dir) {
Ok(local_path) => {
typst_output.push_str(&format!(
"{}#figure(\n image(\"{}/{}\"),\n caption: none)",
list_ident, image_folder_name, local_path
));
}
Err(e) => {
log::error!(
"Failed to process remote image {}: {}",
image_path_name,
e
);
typst_output.push_str(&format!(
"{}#text(fill: red)[Image download failed]",
list_ident
));
}
}
}
}
Tag::Table(alignments) => {
log::debug!("Table alignments: {:?}", alignments);
table_state = TableState::InTable;
table_columns = alignments.len();
typst_output.push_str("#table(\n");
typst_output.push_str(&format!(" columns: {},\n", table_columns));
}
Tag::TableHead => {
log::debug!("Table columns: {}", table_columns);
table_state = TableState::InHeader;
typst_output.push_str(" table.header(");
}
Tag::TableRow => {
table_state = TableState::InRow;
}
Tag::TableCell => {
typst_output.push('[');
}
Tag::FootnoteDefinition(_footnote_id) => {
typst_output.push_str("#footnote[");
}
_ => {}
},
Event::End(end_tag) => match end_tag {
TagEnd::Paragraph => {
if table_state == TableState::None {
typst_output.push('\n');
}
}
TagEnd::Heading(_) => {
typst_output.push('\n');
}
TagEnd::BlockQuote(_) => {
typst_output.push(']');
if !typst_output.ends_with("]\n") {
typst_output.push('\n');
}
}
TagEnd::CodeBlock => {
in_code_block = false;
is_fenced_code_block = false;
code_block_language = None;
}
TagEnd::List(_) => {
list_stack.pop();
typst_output.push('\n');
}
TagEnd::Item => {
typst_output.push('\n');
}
TagEnd::Emphasis => {
typst_output.push_str("_ ");
}
TagEnd::Strong => {
in_strong_context = false;
if !handled_bold_url {
typst_output.push_str("* "); } else {
typst_output.push(' '); }
}
TagEnd::Strikethrough => {
typst_output.push(']');
}
TagEnd::Link => {
typst_output.push_str("] ");
}
TagEnd::Image => {
in_image = false;
}
TagEnd::Table => {
table_state = TableState::None;
typst_output.push_str(")\n");
}
TagEnd::TableHead => {
table_state = TableState::InTable;
typst_output.push_str("),\n");
}
TagEnd::TableRow => {
table_state = TableState::InTable;
if typst_output.ends_with(", ") {
typst_output.truncate(typst_output.len() - 1);
typst_output.push('\n');
} else if !typst_output.ends_with(",\n") {
typst_output.push_str(",\n");
}
}
TagEnd::TableCell => {
typst_output.push(']');
typst_output.push_str(", ");
}
TagEnd::FootnoteDefinition => {
typst_output.push(']');
}
_ => {}
},
Event::Text(text) => {
if in_image {
} else if in_code_block {
log::debug!("chapter: {:?}, Text: {:?}", chapter.name, text);
if is_fenced_code_block {
let item_ident = " ".repeat(list_stack.len());
typst_output.push_str(&item_ident);
if text.contains("```") {
typst_output.push_str("````");
} else {
typst_output.push_str("```");
}
} else {
typst_output.push_str("` ");
}
if let Some(language) = code_block_language.take() {
typst_output.push_str(&language);
}
if is_fenced_code_block {
typst_output.push('\n');
}
typst_output.push_str(&text);
if is_fenced_code_block {
if text.contains("```") {
typst_output.push_str("\n````");
} else {
typst_output.push_str("\n```");
}
} else {
typst_output.push_str(" `");
}
} else {
let text_str = text.to_string();
if is_url(&text_str) && in_strong_context {
if typst_output.ends_with(" *") {
typst_output.truncate(typst_output.len() - 2);
}
typst_output
.push_str(&format!(" *#link(\"{}\")[{}]*", text_str, text_str));
handled_bold_url = true; } else if is_url(&text_str) {
typst_output
.push_str(&format!("#link(\"{}\")[{}]", text_str, text_str));
} else {
let escaped_text = escape_typst_special_chars(&text_str);
let processed_text = fix_typst_formatting(&escaped_text);
typst_output.push_str(&processed_text);
}
}
}
Event::Code(code) => {
typst_output.push_str("` ");
typst_output.push_str(&code);
typst_output.push_str(" `");
}
Event::InlineMath(math) => {
typst_output.push_str(&format!("${{{}}};$", math));
}
Event::DisplayMath(math) => {
typst_output.push_str(&format!("$${{{}}};$$", math));
}
Event::InlineHtml(html) => {
let html_str = html.to_string();
log::debug!("Inline HTML: {:?}", html_str);
if html_str.contains("<img") {
if let Some(cap) = RE_HTML_IMG.captures(&html_str) {
if let Some(src) = cap.get(1) {
let image_path = src.as_str();
log::debug!("Inline image path: {:?}", image_path);
if !image_path.starts_with("http://")
&& !image_path.starts_with("https://")
{
let file_name = Path::new(image_path)
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
let (width, _height) = calculate_image_size(
image_path,
&self.max_width,
&self.max_height,
ctx,
);
typst_output.push_str(&format!("#figure(\n image(\"{}/{}\", width: {}),\n caption: []\n)",
image_folder_name, file_name, width
));
} else {
match download_remote_image(image_path, &image_dir) {
Ok(local_path) => {
typst_output.push_str(&format!(
"#figure(\n image(\"{}/{}\"),\n caption: []\n)",
image_folder_name, local_path
));
}
Err(e) => {
log::error!(
"Failed to process remote image {}: {}",
image_path,
e
);
typst_output.push_str(
"#text(fill: red)[Image download failed]",
);
}
}
}
}
}
}
}
Event::Html(html) => {
let html_str = html.to_string();
if html_str.contains("<img") {
if let Some(cap) = RE_HTML_IMG.captures(&html_str) {
if let Some(src) = cap.get(1) {
let image_path = src.as_str();
if !image_path.starts_with("http://")
&& !image_path.starts_with("https://")
{
let file_name = Path::new(image_path)
.file_name()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
let (width, _height) = calculate_image_size(
image_path,
&self.max_width,
&self.max_height,
ctx,
);
typst_output.push_str(&format!("#figure(\n image(\"{}/{}\", width: {}),\n caption: []\n)",
image_folder_name, file_name, width
));
} else {
match download_remote_image(image_path, &image_dir) {
Ok(local_path) => {
typst_output.push_str(&format!(
"#figure(\n image(\"{}/{}\"),\n caption: []\n)",
image_folder_name, local_path
));
}
Err(e) => {
log::error!(
"Failed to process remote image {}: {}",
image_path,
e
);
typst_output.push_str(
"#text(fill: red)[Image download failed]",
);
}
}
}
}
}
}
}
Event::FootnoteReference(reference) => {
typst_output.push_str(&format!("#footnote[See note {}]", reference));
}
Event::SoftBreak => {
typst_output.push(' ');
}
Event::HardBreak => {
typst_output.push_str("\\\n");
}
Event::Rule => {
typst_output.push_str("\n#line(length: 100%)\n");
}
Event::TaskListMarker(checked) => {
let marker = if checked { "[x]" } else { "[ ]" };
typst_output.push_str(&format!("{} ", marker));
}
}
}
log::debug!("Converted content to typst format");
Ok(typst_output)
}
fn debug_book_structure(&self, book: &mdbook_renderer::book::Book) {
log::debug!("Book structure analysis:");
log::debug!("Total items: {}", book.items.len());
for (i, item) in book.items.iter().enumerate() {
match item {
mdbook_renderer::book::BookItem::Chapter(chapter) => {
log::debug!("Section {}: Chapter name:'{}'", i + 1, chapter.name);
log::debug!(
" - length: {},numbers:{:?};sub_items:{}",
chapter.content.len(),
chapter.number,
chapter.sub_items.len()
);
log::debug!(
" - Path: {:?}; source_path: {:?}",
chapter.path,
chapter.source_path
);
log::debug!(" - parent_names: {:?}", chapter.parent_names);
debug_sub_items(&chapter.sub_items, 1);
}
mdbook_renderer::book::BookItem::Separator => {
log::debug!("Section {}: Separator", i + 1);
}
mdbook_renderer::book::BookItem::PartTitle(title) => {
log::debug!("Section {}: Part Title '{}'", i + 1, title);
}
}
}
}
pub fn convert_chapters(
&self,
chapter_file_list: &mut Vec<PathBuf>, ctx: &mdbook_renderer::RenderContext,
) -> anyhow::Result<()> {
let book = &ctx.book;
self.debug_book_structure(book);
for (chapter_number, item) in book.items.iter().enumerate() {
match item {
mdbook_renderer::book::BookItem::Chapter(chapter) => {
self.process_each_chapter(chapter, chapter_number + 1, chapter_file_list, ctx)?;
}
mdbook_renderer::book::BookItem::Separator => {
log::debug!("Skipping separator in book structure");
}
mdbook_renderer::book::BookItem::PartTitle(title) => {
log::info!("Skipping part title in book structure: {}", title);
}
}
}
Ok(())
}
fn calculate_relative_path_to_templates(
&self,
chapter: &mdbook_renderer::book::Chapter,
ctx: &mdbook_renderer::RenderContext,
) -> String {
let rel_name = self.get_chapter_relative_chapter_file_name(chapter, ctx);
match rel_name {
None => "".to_string(),
Some(rel) => {
let path_str = rel.to_string_lossy().to_string();
let subdirs = if path_str.contains('/') {
path_str.split("/").count() - 1
} else {
path_str.split("\\").count() - 1
};
"../".repeat(subdirs)
}
}
}
fn process_each_chapter(
&self,
chapter: &mdbook_renderer::book::Chapter,
_chapter_number: usize, chapter_file_list: &mut Vec<PathBuf>,
ctx: &mdbook_renderer::RenderContext,
) -> anyhow::Result<()> {
let chapter_dir = self.get_chapters_dir(ctx);
if let Some(_source_path) = &chapter.source_path {
let chapter_file = self.get_chapter_full_file_name(chapter, ctx).unwrap(); chapter_file_list.push(chapter_file.clone());
let typ_dir = chapter_file.parent().unwrap_or_else(|| &chapter_dir);
if !typ_dir.exists() {
std::fs::create_dir_all(typ_dir)?;
}
let typst_content =
self.parse_chapter_content(chapter, &chapter.content, &chapter_file, typ_dir, ctx)?;
std::fs::write(&chapter_file, typst_content)?;
log::debug!(
"Created typst file for {}: {}",
chapter.name,
chapter_file.display()
);
}
for (sub_chapter_number, sub_item) in chapter.sub_items.iter().enumerate() {
match sub_item {
mdbook_renderer::book::BookItem::Chapter(chapter) => {
self.process_each_chapter(
chapter,
sub_chapter_number + 1,
chapter_file_list,
ctx,
)?;
}
mdbook_renderer::book::BookItem::Separator => {
log::debug!("Skipping separator in book structure");
}
mdbook_renderer::book::BookItem::PartTitle(title) => {
log::info!("Skipping part title in book structure: {}", title);
}
}
}
Ok(())
}
}
fn preprocess_img_tag(content: &str) -> String {
let re = regex::Regex::new(r#"<img[^>]*src=["']([^"']+)["'][^>]*>"#).unwrap();
if !re.is_match(content) {
return content.to_string();
}
let alt_re = regex::Regex::new(r#"alt=["']([^"']+)["']"#).unwrap();
let result = re.replace_all(content, |caps: ®ex::Captures| {
let src = caps.get(1).unwrap().as_str();
let img_tag = caps.get(0).unwrap().as_str();
let alt = if let Some(alt_caps) = alt_re.captures(img_tag) {
alt_caps.get(1).unwrap().as_str().to_string()
} else {
let path = Path::new(src);
let filename = path
.file_stem()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
filename.to_string()
};
log::debug!("Converting img tag - src: {:?}, alt: {:?}", src, alt);
format!("", alt, src)
});
result.into_owned()
}
fn escape_typst_special_chars(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let text_without_leading_hash = text.trim_start_matches('#').trim_start();
let text_to_process = if text_without_leading_hash.len() < text.len() {
text_without_leading_hash
} else {
text
};
if text_to_process.contains("unquoted *") {
return text_to_process.replace("unquoted *", "unquoted \\*");
}
if is_url(text_to_process) {
return text_to_process.to_string();
}
for c in text_to_process.chars() {
match c {
'#' | '*' | '_' | '`' | '$' | '{' | '}' | '[' | ']' | '<' | '>' => {
result.push('\\');
result.push(c);
}
'\\' => {
result.push('\\');
result.push('\\');
}
'"' => {
result.push('\\');
result.push('"');
}
_ => result.push(c),
}
}
result
}
fn fix_typst_formatting(text: &str) -> String {
let mut result = text.to_string();
result = result.replace("*:", "* :");
result
}
fn debug_sub_items(sub_items: &[mdbook_renderer::book::BookItem], level: usize) {
let indent = " ".repeat(level + 1);
for (i, item) in sub_items.iter().enumerate() {
match item {
mdbook_renderer::book::BookItem::Chapter(chapter) => {
log::debug!("{}Sub-item {}: Chapter '{}'", indent, i + 1, chapter.name);
log::debug!(
" - length: {},numbers:{:?};sub_items:{}",
chapter.content.len(),
chapter.number,
chapter.sub_items.len()
);
log::debug!(
" - Path: {:?}; source_path: {:?}",
chapter.path,
chapter.source_path
);
log::debug!(" - parent_names: {:?}", chapter.parent_names);
if !chapter.sub_items.is_empty() {
debug_sub_items(&chapter.sub_items, level + 1);
}
}
mdbook_renderer::book::BookItem::Separator => {
log::debug!("{}Sub-item {}: Separator", indent, i + 1);
}
mdbook_renderer::book::BookItem::PartTitle(title) => {
log::debug!("{}Sub-item {}: Part Title '{}'", indent, i + 1, title);
}
}
}
}
fn download_remote_image(image_url: &str, image_dir: &Path) -> anyhow::Result<String> {
let client = Client::new();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let extension = match image_url.split('.').next_back() {
Some(ext)
if ["jpg", "jpeg", "png", "gif", "webp", "svg"]
.contains(&ext.to_lowercase().as_str()) =>
{
ext
}
_ => "png",
};
let file_name = format!("remote_img_{}.{}", timestamp, extension);
let file_path = image_dir.join(&file_name);
match client.get(image_url).send() {
Ok(response) => {
if response.status().is_success() {
match response.bytes() {
Ok(bytes) => {
let mut file = fs::File::create(&file_path)?;
file.write_all(bytes.as_ref())?;
log::debug!(
"Downloaded remote image: {} -> {}",
image_url,
file_path.display()
);
Ok(file_name)
}
Err(e) => {
log::error!("Failed to read image bytes from {}: {}", image_url, e);
create_placeholder_image(image_dir)
}
}
} else {
log::error!(
"Failed to download image {}: HTTP status {}",
image_url,
response.status()
);
create_placeholder_image(image_dir)
}
}
Err(e) => {
log::error!("Failed to download image {}: {}", image_url, e);
create_placeholder_image(image_dir)
}
}
}
fn create_placeholder_image(image_dir: &Path) -> anyhow::Result<String> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let file_name = format!("placeholder_{}.txt", timestamp);
let file_path = image_dir.join(&file_name);
let mut file = fs::File::create(&file_path)?;
file.write_all(b"#text(fill: red)[Image could not be downloaded]")?;
log::warn!(
"Created placeholder for failed image download: {}",
file_path.display()
);
Ok(file_name)
}
fn is_url(text: &str) -> bool {
text.starts_with("http://") || text.starts_with("https://")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_process_img_tag() {
let input = r###"<img src="docs/01-introduction/image-20250224001420194.png" alt="image-20250224001420194" style="zoom:50%;" />"###;
let expected =
r###""###;
let output = preprocess_img_tag(input);
assert_eq!(output, expected);
let input = r###"## Work Folder and Common OS Environment Variables
<img src="docs/01-introduction/image-20250221105725169.png" alt="image-20250221105725169" style="zoom:50%;" />
Please download the attached small zip file and unzip it to a **working folder** you created. Under the working folder, you should see several components:"###;
let expected = r###"## Work Folder and Common OS Environment Variables

Please download the attached small zip file and unzip it to a **working folder** you created. Under the working folder, you should see several components:"###;
let output = preprocess_img_tag(input);
assert_eq!(output, expected);
let input = r###"<img src="_images/image-20250213001741756.png" style="zoom:50%;" />"###;
let expected = r###""###;
let output = preprocess_img_tag(input);
assert_eq!(output, expected);
}
}