docxide-pdf 0.12.0

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::{SmartArtDiagram, SmartArtShape};

use super::styles::ThemeFonts;
use super::color::parse_solid_fill;
use super::textbox::parse_shape_geometry;
use super::{DML_NS, DSP_NS, dml, dsp, emu_attr, read_zip_text};

fn is_ns(node: roxmltree::Node, name: &str, ns: &str) -> bool {
    node.tag_name().name() == name && node.tag_name().namespace() == Some(ns)
}

const DIAGRAM_URI: &str = "http://schemas.openxmlformats.org/drawingml/2006/diagram";

fn has_dml(node: roxmltree::Node, name: &str) -> bool {
    node.children()
        .any(|n| n.tag_name().name() == name && n.tag_name().namespace() == Some(DML_NS))
}

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

/// 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.tag_name().name() == "relIds"
            && n.tag_name().namespace() == Some(DIAGRAM_URI)
    })?;
    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_w: f32,
    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 = target
            .strip_prefix('/')
            .map(String::from)
            .unwrap_or_else(|| format!("word/{}", target));

        if let Some(xml) = read_zip_text(zip, &zip_path) {
            if 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| is_ns(*n, "sp", DSP_NS))
                        .filter_map(|sp| parse_dsp_shape(sp, theme))
                        .collect();
                }
            }
        }
    }

    SmartArtDiagram {
        display_width: display_w,
        display_height: display_h,
        shapes,
    }
}

fn parse_dsp_shape(sp: roxmltree::Node, theme: &ThemeFonts) -> 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 has_dml(sp_pr, "noFill") {
        None
    } else {
        parse_solid_fill(sp_pr, theme)
    };

    let (stroke_color, stroke_width) = dml(sp_pr, "ln")
        .and_then(|ln| {
            if has_dml(ln, "noFill") {
                return None;
            }
            let color = parse_solid_fill(ln, theme)?;
            let width = ln
                .attribute("w")
                .and_then(|v| v.parse::<f32>().ok())
                .map(super::emu_to_pts)
                .unwrap_or(0.75);
            Some((color, width))
        })
        .map_or((None, 0.0), |(c, w)| (Some(c), w));

    let mut tp = parse_dsp_text(sp, theme);

    // Fall back to dsp:style/a:fontRef color if text has no explicit color
    if tp.text_color.is_none() {
        tp.text_color = dsp(sp, "style")
            .and_then(|style| dml(style, "fontRef"))
            .and_then(|fr| parse_solid_fill(fr, theme)
                .or_else(|| {
                    // fontRef may have schemeClr directly as a child
                    fr.children()
                        .find(|n| n.tag_name().name() == "schemeClr" && n.tag_name().namespace() == Some(DML_NS))
                        .and_then(|sc| {
                            let val = sc.attribute("val")?;
                            let key = super::resolve_theme_color_key(val);
                            theme.colors.get(key).copied()
                        })
                }));
    }

    // 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"),
        ))
    });

    if fill.is_none() && tp.text.is_empty() && stroke_color.is_none() {
        return None;
    }

    Some(SmartArtShape {
        x,
        y,
        width: w,
        height: h,
        rotation_deg,
        shape_type: parse_shape_geometry(sp_pr),
        fill,
        stroke_color,
        stroke_width,
        text: tp.text,
        font_size: tp.font_size,
        text_color: tp.text_color,
        text_rect,
        text_inset_left: tp.left_inset,
        text_anchor: tp.anchor,
        text_align: tp.align,
    })
}

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

struct DspTextProps {
    text: String,
    font_size: f32,
    text_color: Option<[u8; 3]>,
    left_inset: f32,
    anchor: SmartArtTextAnchor,
    align: SmartArtTextAlign,
}

fn parse_dsp_text(sp: roxmltree::Node, theme: &ThemeFonts) -> DspTextProps {
    let Some(body) = dsp(sp, "txBody") else {
        return DspTextProps {
            text: String::new(), font_size: 0.0, text_color: None,
            left_inset: 0.0, anchor: SmartArtTextAnchor::Top, align: SmartArtTextAlign::Left,
        };
    };
    let body_pr = dml(body, "bodyPr");
    let left_inset = body_pr
        .and_then(|bp| bp.attribute("lIns"))
        .and_then(|v| v.parse::<f32>().ok())
        .map(super::emu_to_pts)
        .unwrap_or(0.0);
    let anchor = match body_pr.and_then(|bp| bp.attribute("anchor")) {
        Some("ctr") => SmartArtTextAnchor::Center,
        Some("b") => SmartArtTextAnchor::Bottom,
        _ => SmartArtTextAnchor::Top,
    };
    // Get paragraph alignment from first paragraph's pPr
    let align = body.children()
        .find(|n| is_ns(*n, "p", DML_NS))
        .and_then(|p| dml(p, "pPr"))
        .and_then(|ppr| ppr.attribute("algn"))
        .map(|a| match a {
            "ctr" => SmartArtTextAlign::Center,
            "r" => SmartArtTextAlign::Right,
            _ => SmartArtTextAlign::Left,
        })
        .unwrap_or(SmartArtTextAlign::Left);

    let mut lines = Vec::new();
    let mut font_size = 0.0;
    let mut text_color = None;

    for p in body.children().filter(|n| is_ns(*n, "p", DML_NS)) {
        let mut line_text = String::new();
        for r in p.children().filter(|n| is_ns(*n, "r", DML_NS)) {
            if let Some(rpr) = dml(r, "rPr") {
                if font_size == 0.0 {
                    if let Some(sz) = rpr.attribute("sz").and_then(|v| v.parse::<f32>().ok()) {
                        font_size = sz / 100.0;
                    }
                }
                if text_color.is_none() {
                    text_color = parse_solid_fill(rpr, theme);
                }
            }
            if let Some(t) = dml(r, "t") {
                if let Some(text) = t.text() {
                    line_text.push_str(text);
                }
            }
        }
        if !line_text.is_empty() {
            lines.push(line_text);
        }
    }

    DspTextProps {
        text: lines.join("\n"),
        font_size,
        text_color,
        left_inset,
        anchor,
        align,
    }
}