docxcraft 0.1.0

Generate editable DOCX documents with rich text, tables, charts and SVG drawings
Documentation
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 ALGFORCE INC.

use anyhow::Result;
use std::fs::File;
use std::io::Read;
use std::path::Path;
use usvg::roxmltree::Document;
use uuid::Uuid;

/// 图片类型
#[derive(Debug, Clone, PartialEq)]
pub enum ImageType {
    Png,
    Jpeg,
    Svg,
}

/// 表示要插入的图片
#[derive(Debug, Clone)]
pub struct Image {
    pub path: String,
    pub alt_text: String,
    pub width: u32,
    pub height: u32,
    pub image_type: ImageType,
    pub data: Vec<u8>,
}

impl Image {
    /// 从文件路径创建图片
    pub fn new(path: String, alt_text: String, width: u32, height: u32) -> Result<Self> {
        let path_obj = Path::new(&path);
        let extension = path_obj
            .extension()
            .and_then(|ext| ext.to_str())
            .ok_or_else(|| anyhow::anyhow!("Unable to determine file extension"))?
            .to_lowercase();

        // 根据扩展名确定图片类型
        let image_type = match extension.as_str() {
            "png" => ImageType::Png,
            "jpg" | "jpeg" => ImageType::Jpeg,
            "svg" => ImageType::Svg,
            _ => {
                return Err(anyhow::anyhow!(
                    "Unsupported image format: {}. Only PNG, JPEG and SVG are supported.",
                    extension
                ))
            }
        };

        // 读取图片数据
        let mut file = File::open(&path)?;
        let mut data = Vec::new();
        file.read_to_end(&mut data)?;

        Ok(Self {
            path,
            alt_text,
            width,
            height,
            image_type,
            data,
        })
    }

    /// 从 SVG 字符串代码创建图片
    ///
    /// # 参数
    /// * `svg_code` - SVG 代码字符串
    /// * `alt_text` - 图片替代文本
    ///
    /// # 示例
    /// ```
    /// use docxcraft::image::Image;
    ///
    /// let svg_code = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
    ///     <circle cx="50" cy="50" r="40" fill="red"/>
    /// </svg>"#;
    ///
    /// let image = Image::from_svg_string(
    ///     svg_code.to_string(),
    ///     "Red Circle".to_string(),
    /// ).unwrap();
    /// ```
    pub fn from_svg_string(svg_code: String, alt_text: String) -> Result<Self> {
        // 将 SVG 字符串转换为字节数据
        let (width, height) = get_svg_size(&svg_code);
        let data = svg_code.into_bytes();

        // 生成一个虚拟路径,使用 UUID 来确保唯一性
        let path = format!("svg_{}.svg", Uuid::new_v4());

        Ok(Self {
            path,
            alt_text,
            width: width as u32,
            height: height as u32,
            image_type: ImageType::Svg,
            data,
        })
    }

    /// 从 PNG 字节创建图片
    pub fn from_png_bytes(
        data: Vec<u8>,
        alt_text: String,
        width: u32,
        height: u32,
    ) -> Result<Self> {
        let path = format!("png_{}.png", Uuid::new_v4());

        Ok(Self {
            path,
            alt_text,
            width,
            height,
            image_type: ImageType::Png,
            data,
        })
    }

    /// 获取图片的MIME类型
    pub fn get_mime_type(&self) -> &'static str {
        match self.image_type {
            ImageType::Png => "image/png",
            ImageType::Jpeg => "image/jpeg",
            ImageType::Svg => "image/svg+xml",
        }
    }

    /// 获取图片的文件扩展名
    pub fn get_extension(&self) -> &'static str {
        match self.image_type {
            ImageType::Png => "png",
            ImageType::Jpeg => "jpeg",
            ImageType::Svg => "svg",
        }
    }
}

/// 生成图片的 drawing XML
pub fn generate_image_drawing_xml(
    relationship_id: usize,
    width: u32,
    height: u32,
    alt_text: &str,
    image_type: &ImageType,
) -> String {
    // 转换尺寸 - Word中的尺寸单位是EMU (English Metric Unit)
    // 1英寸 = 914400 EMU, 1厘米 = 360000 EMU
    // 这里把像素近似转换为EMU (使用96 DPI的近似值)
    let page_width_emu = (21.0 - 2.0 * 2.0) * 360000.0; // 2cm 左右边距
    let orig_width_emu = width * 9525;
    let orig_height_emu = height * 9525;

    // 计算缩放比例
    let ratio = page_width_emu / orig_width_emu as f64;
    let emu_width = page_width_emu as u32;
    let emu_height = (orig_height_emu as f64 * ratio) as u32;

    // 使用图形控件ID,这与relationship_id不同
    let drawing_id = 1000 + relationship_id;

    // 根据图片类型生成不同的blip扩展信息
    let blip_ext = match image_type {
        ImageType::Svg => {
            // SVG需要特殊的扩展声明,注意URI是43D3而不是43C3
            // SVG只需要svgBlip扩展,不需要useLocalDpi
            format!(
                r#"
            <a:extLst>
              <a:ext uri="{{96DAC541-7B7A-43D3-8B79-37D633B846F1}}">
                <asvg:svgBlip xmlns:asvg="http://schemas.microsoft.com/office/drawing/2016/SVG/main" r:embed="rId{}"/>
              </a:ext>
            </a:extLst>"#,
                relationship_id
            )
        }
        ImageType::Png | ImageType::Jpeg => {
            // PNG和JPEG使用标准扩展
            r#"
            <a:extLst>
              <a:ext uri="{28A0092B-C50C-407E-A947-70E740481C1C}">
                <a14:useLocalDpi xmlns:a14="http://schemas.microsoft.com/office/drawing/2010/main" val="0"/>
              </a:ext>
            </a:extLst>"#.to_string()
        }
    };

    // 确保所有XML命名空间正确声明
    format!(
        r#"
<w:p>
  <w:r>
    <w:drawing>
      <wp:inline distT="0" distB="0" distL="0" distR="0">
        <wp:extent cx="{emu_width}" cy="{emu_height}"/>
        <wp:effectExtent l="0" t="0" r="0" b="0"/>
        <wp:docPr id="{drawing_id}" name="图形 {drawing_id}"/>
        <wp:cNvGraphicFramePr>
          <a:graphicFrameLocks xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" noChangeAspect="1"/>
        </wp:cNvGraphicFramePr>
        <a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
          <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
            <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
              <pic:nvPicPr>
                <pic:cNvPr id="{drawing_id}" name="图形 {drawing_id}"/>
                <pic:cNvPicPr><a:picLocks noChangeAspect="1" /></pic:cNvPicPr>
              </pic:nvPicPr>
              <pic:blipFill>
                <a:blip r:embed="rId{relationship_id}">{blip_ext}
                </a:blip>
                <a:stretch>
                  <a:fillRect/>
                </a:stretch>
              </pic:blipFill>
              <pic:spPr>
                <a:xfrm>
                  <a:off x="0" y="0"/>
                  <a:ext cx="{emu_width}" cy="{emu_height}"/>
                </a:xfrm>
                <a:prstGeom prst="rect">
                  <a:avLst/>
                </a:prstGeom>
              </pic:spPr>
            </pic:pic>
          </a:graphicData>
        </a:graphic>
      </wp:inline>
    </w:drawing>
  </w:r>
</w:p>"#,
        emu_width = emu_width,
        emu_height = emu_height,
        drawing_id = drawing_id,
        relationship_id = relationship_id,
        blip_ext = blip_ext
    )
}

fn get_svg_size(svg_code: &str) -> (f64, f64) {
    let doc = Document::parse(svg_code).unwrap();
    let root = doc.root_element();

    // 优先读取 width/height
    if let (Some(w), Some(h)) = (root.attribute("width"), root.attribute("height")) {
        if let (Some(width), Some(height)) = (parse_svg_length(w), parse_svg_length(h)) {
            return (width, height);
        }
    }

    // 没有 width/height 用 viewBox
    if let Some(viewbox) = root.attribute("viewBox") {
        let parts: Vec<&str> = viewbox
            .split(|ch: char| ch.is_whitespace() || ch == ',')
            .filter(|part| !part.is_empty())
            .collect();
        if parts.len() == 4 {
            if let (Ok(width), Ok(height)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>()) {
                return (width, height);
            }
        }
    }

    // 默认 100x100
    (100.0, 100.0)
}

fn parse_svg_length(value: &str) -> Option<f64> {
    let value = value.trim();
    if value.is_empty() || value.ends_with('%') {
        return None;
    }

    let units = ["px", "pt", "pc", "cm", "mm", "in"];
    let (number_part, scale) = if let Some(unit) = units.iter().find(|unit| value.ends_with(**unit))
    {
        let number_part = value.strip_suffix(unit)?;
        let scale = match *unit {
            "px" => 1.0,
            "pt" => 96.0 / 72.0,
            "pc" => 16.0,
            "cm" => 96.0 / 2.54,
            "mm" => 96.0 / 25.4,
            "in" => 96.0,
            _ => return None,
        };
        (number_part, scale)
    } else {
        (value, 1.0)
    };

    number_part.trim().parse::<f64>().ok().map(|v| v * scale)
}

#[cfg(test)]
mod tests {
    use super::{get_svg_size, parse_svg_length};

    #[test]
    fn parses_svg_lengths_with_supported_units() {
        assert_eq!(parse_svg_length("100"), Some(100.0));
        assert_eq!(parse_svg_length("100px"), Some(100.0));
        assert_eq!(parse_svg_length("2.54cm"), Some(96.0));
        assert_eq!(parse_svg_length("100%"), None);
    }

    #[test]
    fn falls_back_to_viewbox_when_svg_uses_percent_dimensions() {
        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 640 360"></svg>"#;
        assert_eq!(get_svg_size(svg), (640.0, 360.0));
    }
}