docxide-pdf 0.18.4

Library and CLI for converting DOCX files to PDF, matching Microsoft Word's output as closely as possible
Documentation
use std::collections::HashMap;
use std::io::{Read, Seek};

use crate::model::{EmbeddedImage, SmartArtDiagram, SmartArtShape};

use super::color::{parse_line_stroke, parse_solid_fill, resolve_dml_color};
use super::images::{find_blip_embed, read_image_from_zip};
use super::styles::ThemeFonts;
use super::textbox::parse_shape_geometry;
use super::{DIAGRAM_NS, DML_NS, DSP_NS, dml, dsp, emu_attr, frac_attr, part_path, read_zip_text};

/// Load the OPC relationship file for a given part and resolve relative targets
/// to full zip paths (e.g. `../media/image1.jpg` relative to `word/diagrams/`
/// becomes `word/media/image1.jpg`).
fn load_part_rels<R: Read + Seek>(
    zip: &mut zip::ZipArchive<R>,
    part_path: &str,
) -> HashMap<String, String> {
    let (dir, file) = part_path.rsplit_once('/').unwrap_or(("", part_path));
    let rels_path = if dir.is_empty() {
        format!("_rels/{file}.rels")
    } else {
        format!("{dir}/_rels/{file}.rels")
    };
    let Some(xml) = read_zip_text(zip, &rels_path) else {
        return HashMap::new();
    };
    let Ok(doc) = roxmltree::Document::parse(&xml) else {
        return HashMap::new();
    };
    doc.root_element()
        .children()
        .filter(|n| n.is_element())
        .filter_map(|n| {
            let id = n.attribute("Id")?.to_string();
            let raw_target = n.attribute("Target")?;
            // Prefix with / so read_image_from_zip can strip it for zip lookup
            let target = if raw_target.starts_with('/') {
                raw_target.to_string()
            } else {
                format!("/{}", normalize_path(&format!("{dir}/{raw_target}")))
            };
            Some((id, target))
        })
        .collect()
}

/// Resolve `..` segments in a path (e.g. `word/diagrams/../media/img.jpg` → `word/media/img.jpg`).
fn normalize_path(path: &str) -> String {
    let mut parts: Vec<&str> = Vec::new();
    for seg in path.split('/') {
        if seg == ".." {
            parts.pop();
        } else if !seg.is_empty() && seg != "." {
            parts.push(seg);
        }
    }
    parts.join("/")
}

pub(super) fn has_diagram_ref(container: roxmltree::Node) -> bool {
    container
        .descendants()
        .any(|n| n.has_tag_name((DML_NS, "graphicData")) && n.attribute("uri") == Some(DIAGRAM_NS))
}

/// Resolve the drawing file for a specific SmartArt diagram by extracting
/// the r:dm (data) relationship from dgm:relIds and deriving the drawing path.
fn find_diagram_drawing(
    container: roxmltree::Node,
    rels: &HashMap<String, String>,
) -> Option<String> {
    let dgm_rel_ids = container
        .descendants()
        .find(|n| n.has_tag_name((DIAGRAM_NS, "relIds")))?;
    let dm_rid = dgm_rel_ids.attribute((super::REL_NS, "dm"))?;
    let data_target = rels.get(dm_rid)?;
    Some(data_target.replace("/data", "/drawing"))
}

pub(super) fn parse_smartart_drawing<R: Read + Seek>(
    container: roxmltree::Node,
    rels: &HashMap<String, String>,
    zip: &mut zip::ZipArchive<R>,
    theme: &ThemeFonts,
    display_h: f32,
) -> SmartArtDiagram {
    let mut shapes = Vec::new();

    let drawing_target = find_diagram_drawing(container, rels).or_else(|| {
        rels.values()
            .find(|t| t.contains("diagrams/drawing"))
            .cloned()
    });
    if let Some(target) = drawing_target {
        let zip_path = part_path(&target);

        let diagram_rels = load_part_rels(zip, &zip_path);

        if let Some(xml) = read_zip_text(zip, &zip_path)
            && let Ok(doc) = roxmltree::Document::parse(&xml)
        {
            let sp_tree = dsp(doc.root(), "drawing").and_then(|d| dsp(d, "spTree"));

            if let Some(tree) = sp_tree {
                shapes = tree
                    .children()
                    .filter(|n| n.has_tag_name((DSP_NS, "sp")))
                    .filter_map(|sp| parse_dsp_shape(sp, theme, &diagram_rels, zip))
                    .collect();
            }
        }
    }

    SmartArtDiagram {
        display_height: display_h,
        shapes,
        anchor: None,
    }
}

fn parse_dsp_shape<R: Read + Seek>(
    sp: roxmltree::Node,
    theme: &ThemeFonts,
    diagram_rels: &HashMap<String, String>,
    zip: &mut zip::ZipArchive<R>,
) -> Option<SmartArtShape> {
    let sp_pr = dsp(sp, "spPr")?;
    let xfrm = dml(sp_pr, "xfrm")?;
    let off = dml(xfrm, "off")?;
    let ext = dml(xfrm, "ext")?;

    let x = emu_attr(off, "x");
    let y = emu_attr(off, "y");
    let w = emu_attr(ext, "cx");
    let h = emu_attr(ext, "cy");
    // rot is in 60,000ths of a degree
    let rotation_deg = xfrm
        .attribute("rot")
        .and_then(|v| v.parse::<f64>().ok())
        .map(|v| (v / 60_000.0) as f32)
        .unwrap_or(0.0);

    let fill = if dml(sp_pr, "noFill").is_some() {
        None
    } else {
        parse_solid_fill(sp_pr, theme)
    };

    let image_fill: Option<EmbeddedImage> = dml(sp_pr, "blipFill").and_then(|bf| {
        let embed_id = find_blip_embed(bf)?;
        read_image_from_zip(embed_id, diagram_rels, zip, w, h)
    });

    let (stroke_color, stroke_width) = dml(sp_pr, "ln")
        .and_then(|ln| parse_line_stroke(ln, theme))
        .map_or((None, 0.0), |(c, w)| (Some(c), w));

    let tp = parse_dsp_text(sp, theme);

    // Resolve default text color from dsp:style/a:fontRef. resolve_dml_color
    // applies any tint/shade/lumMod on the schemeClr (the old inline fallback
    // dropped them).
    let default_text_color = dsp(sp, "style")
        .and_then(|style| dml(style, "fontRef"))
        .and_then(|fr| parse_solid_fill(fr, theme).or_else(|| resolve_dml_color(fr, theme)));

    let paragraphs: Vec<SmartArtPara> = tp
        .paragraphs
        .into_iter()
        .map(|mut para| {
            for run in &mut para.runs {
                if run.color.is_none() {
                    run.color = default_text_color;
                }
            }
            para
        })
        .collect();

    // dsp:txXfrm provides a separate rectangle for text placement
    let text_rect = dsp(sp, "txXfrm").and_then(|tx| {
        let tx_off = dml(tx, "off")?;
        let tx_ext = dml(tx, "ext")?;
        Some((
            emu_attr(tx_off, "x"),
            emu_attr(tx_off, "y"),
            emu_attr(tx_ext, "cx"),
            emu_attr(tx_ext, "cy"),
        ))
    });

    let has_text = paragraphs
        .iter()
        .any(|p| p.runs.iter().any(|r| !r.text.is_empty()));
    if fill.is_none() && image_fill.is_none() && !has_text && stroke_color.is_none() {
        return None;
    }

    Some(SmartArtShape {
        x,
        y,
        width: w,
        height: h,
        rotation_deg,
        shape_type: parse_shape_geometry(sp_pr),
        fill,
        image_fill,
        stroke_color,
        stroke_width,
        paragraphs,
        text_rect,
        text_insets: tp.insets,
        text_anchor: tp.anchor,
    })
}

use crate::model::{SmartArtPara, SmartArtRun, SmartArtTextAlign, SmartArtTextAnchor};

struct DspTextProps {
    paragraphs: Vec<SmartArtPara>,
    /// (top, right, bottom, left) insets in pts
    insets: (f32, f32, f32, f32),
    anchor: SmartArtTextAnchor,
}

fn parse_dsp_text(sp: roxmltree::Node, theme: &ThemeFonts) -> DspTextProps {
    let Some(body) = dsp(sp, "txBody") else {
        return DspTextProps {
            paragraphs: Vec::new(),
            insets: (0.0, 0.0, 0.0, 0.0),
            anchor: SmartArtTextAnchor::Top,
        };
    };
    let body_pr = dml(body, "bodyPr");
    let insets = body_pr
        .map(|bp| {
            (
                emu_attr(bp, "tIns"),
                emu_attr(bp, "rIns"),
                emu_attr(bp, "bIns"),
                emu_attr(bp, "lIns"),
            )
        })
        .unwrap_or((0.0, 0.0, 0.0, 0.0));
    let anchor = match body_pr.and_then(|bp| bp.attribute("anchor")) {
        Some("ctr") => SmartArtTextAnchor::Center,
        Some("b") => SmartArtTextAnchor::Bottom,
        _ => SmartArtTextAnchor::Top,
    };

    // Resolve theme font fallback from dsp:style/a:fontRef
    let theme_font = dsp(sp, "style")
        .and_then(|s| dml(s, "fontRef"))
        .and_then(|fr| fr.attribute("idx"))
        .and_then(|idx| match idx {
            "minor" => Some(theme.minor.clone()),
            "major" => Some(theme.major.clone()),
            _ => None,
        })
        .filter(|n| !n.is_empty());

    let mut default_font_size = 0.0_f32;
    let mut paragraphs = Vec::new();

    for p in body.children().filter(|n| n.has_tag_name((DML_NS, "p"))) {
        let ppr = dml(p, "pPr");
        let bullet = ppr
            .and_then(|pp| dml(pp, "buChar"))
            .and_then(|bc| bc.attribute("char"))
            .map(String::from);
        let align = ppr
            .and_then(|pp| pp.attribute("algn"))
            .map(|a| match a {
                "ctr" => SmartArtTextAlign::Center,
                "r" => SmartArtTextAlign::Right,
                _ => SmartArtTextAlign::Left,
            })
            .unwrap_or(SmartArtTextAlign::Left);

        let mut runs = Vec::new();
        for r in p.children().filter(|n| n.has_tag_name((DML_NS, "r"))) {
            let text = dml(r, "t").and_then(|t| t.text()).unwrap_or("").to_string();
            if text.is_empty() {
                continue;
            }

            let rpr = dml(r, "rPr");
            let font_size = rpr
                .and_then(|rp| rp.attribute("sz"))
                .and_then(|v| v.parse::<f32>().ok())
                .map(|sz| sz / 100.0)
                .unwrap_or(default_font_size);
            if default_font_size == 0.0 && font_size > 0.0 {
                default_font_size = font_size;
            }

            let font_name = rpr
                .and_then(|rp| dml(rp, "latin"))
                .and_then(|n| n.attribute("typeface"))
                .filter(|tf| !tf.is_empty())
                .map(String::from)
                .or_else(|| theme_font.clone());

            let bold = rpr.and_then(|rp| rp.attribute("b")) == Some("1");
            let italic = rpr.and_then(|rp| rp.attribute("i")) == Some("1");
            let underline = rpr
                .and_then(|rp| rp.attribute("u"))
                .is_some_and(|v| v != "none");
            let strikethrough = rpr
                .and_then(|rp| rp.attribute("strike"))
                .is_some_and(|v| v != "noStrike");
            let baseline = rpr
                .and_then(|rp| rp.attribute("baseline"))
                .and_then(|v| v.parse::<i32>().ok())
                .unwrap_or(0);

            let color = rpr.and_then(|rp| parse_solid_fill(rp, theme));
            let highlight = rpr.and_then(|rp| dml(rp, "highlight")).and_then(|hl| {
                let clr = dml(hl, "srgbClr")?;
                super::parse_hex_color(clr.attribute("val")?)
            });

            runs.push(SmartArtRun {
                text,
                font_name,
                font_size,
                bold,
                italic,
                color,
                underline,
                strikethrough,
                baseline,
                highlight,
            });
        }

        let line_spacing_pct = ppr
            .and_then(|pp| dml(pp, "lnSpc"))
            .and_then(|ls| dml(ls, "spcPct"))
            .and_then(|sp| frac_attr(sp, "val"))
            .unwrap_or(0.0);

        if !runs.is_empty() {
            paragraphs.push(SmartArtPara {
                runs,
                bullet,
                align,
                line_spacing_pct,
            });
        }
    }

    DspTextProps {
        paragraphs,
        insets,
        anchor,
    }
}