k2f_docx 0.2.3

K2F lock → DOCX exporter
Documentation
mod common;

use k2f_core::{
    for_each_node, node_text, Border, BorderEdge, BorderStyle, GridTrack, NodeContent,
    SemanticNode, TableDataSource, TableSpec,
};
use k2f_docx::{can_emit_native_table, export_opened, table_cell_wml, TextAlign};

fn invoice_sample_cell(doc: &k2f_paint::OpenedDocument) -> (String, String) {
    let mut sample = None;
    for_each_node(doc.semantic_root(), &mut |n| {
        if let NodeContent::Table(spec) = &n.content {
            if let TableDataSource::Inline { rows } = &spec.data {
                if let Some(cell) = rows.get(1).and_then(|r| r.first()) {
                    if let Some(text) = node_text(cell) {
                        sample = Some((cell.id.clone(), text.to_string()));
                    }
                }
            } else {
                panic!("invoice table still Asset; OpenedDocument should expand");
            }
        }
    });
    sample.expect("invoice table has a text cell in row 1")
}

fn invoice_cell_ids(doc: &k2f_paint::OpenedDocument) -> Vec<String> {
    let mut ids = Vec::new();
    for_each_node(doc.semantic_root(), &mut |n| {
        if let NodeContent::Table(spec) = &n.content {
            if let TableDataSource::Inline { rows } = &spec.data {
                ids.extend(rows.iter().flatten().map(|c| c.id.clone()));
            }
        }
    });
    ids
}

fn text_cell(id: &str) -> SemanticNode {
    SemanticNode {
        id: id.into(),
        role: "body".into(),
        content: NodeContent::Text("x".into()),
        ..Default::default()
    }
}

#[test]
fn invoice_table_is_boxes_not_w_tbl() {
    let doc = common::invoice();
    let (cell_id, text) = invoice_sample_cell(&doc);
    assert!(!text.is_empty(), "sample cell text");
    let docx = export_opened(&doc).unwrap();
    let xml = common::xml_in(&docx, "word/document.xml");
    assert!(
        !xml.contains("<w:tbl>"),
        "Word Dark Mode inverts w:tbl / w:shd; table cells must be lock boxes + text"
    );
    assert!(
        !xml.contains("<wps:style>"),
        "wps:style fillRef follows the Office theme; lock RGB lives in spPr / w:rPr, got style"
    );
    assert!(
        xml.contains(&text),
        "cell {text:?} ({cell_id}) missing from Word text boxes"
    );
}

#[allow(non_snake_case)]
#[test]
fn gridCol_writer_still_matches_spec_shape() {
    let spec = TableSpec {
        column_widths: vec![GridTrack::Fr { fr: 1 }, GridTrack::Fr { fr: 1 }],
        header_rows: 0,
        gap: 0,
        row_gap: None,
        column_gap: None,
        data: TableDataSource::Inline {
            rows: vec![vec![text_cell("a"), text_cell("b")]],
        },
    };
    assert!(can_emit_native_table(&spec));
    assert_eq!(spec.column_widths.len(), 2);
}

#[test]
fn cell_jc_matches_infer() {
    let center = table_cell_wml(TextAlign::Center, None);
    assert!(
        center.contains(r#"w:jc w:val="center""#),
        "table writer must emit infer Center, got {center}"
    );
    let right = table_cell_wml(TextAlign::Right, None);
    assert!(
        right.contains(r#"w:jc w:val="right""#),
        "table writer must emit infer Right, got {right}"
    );
    assert!(
        !center.contains(r#"w:jc w:val="left""#) || center.contains(r#"w:jc w:val="center""#),
        "must not hardcode left over Center"
    );
}

#[test]
fn cell_borders_follow_lock_edges() {
    let border = Border {
        width_pt: 1_000,
        color: "#1A73E8".into(),
        edges: vec![BorderEdge::Bottom],
        style: BorderStyle::Solid,
    };
    let xml = table_cell_wml(TextAlign::Left, Some(&border));
    assert!(
        xml.contains("D0D0D0") == false,
        "must not fake four-side #D0D0D0, got {xml}"
    );
    let bottom = xml.find("<w:bottom").expect("w:bottom");
    let after = &xml[bottom..];
    let tag_end = after
        .find("/>")
        .or_else(|| after.find('>'))
        .expect("bottom tag");
    let bottom_tag = &after[..tag_end];
    assert!(
        bottom_tag.contains("1A73E8") || bottom_tag.contains("1a73e8"),
        "bottom edge must keep lock color, got {bottom_tag}"
    );
    assert!(
        !bottom_tag.contains(r#"w:val="nil""#),
        "bottom must be drawn"
    );
    for edge in ["top", "left", "right"] {
        assert!(
            xml.contains(&format!(r#"<w:{edge} w:val="nil""#)),
            "missing edge {edge} must be nil, got {xml}"
        );
    }
}

#[test]
fn table_cells_export_as_named_textboxes() {
    let doc = common::invoice();
    let cell_ids = invoice_cell_ids(&doc);
    assert!(cell_ids.iter().any(|id| id == "invoice.row_1.item"));
    let docx = export_opened(&doc).unwrap();
    let xml = common::xml_in(&docx, "word/document.xml");
    let parsed = roxmltree::Document::parse(&xml).unwrap();
    let names: Vec<&str> = parsed
        .descendants()
        .filter(|n| n.has_tag_name("docPr"))
        .filter_map(|pr| common::local_attr(&pr, "name"))
        .collect();
    assert!(
        names.iter().any(|n| *n == "invoice.row_1.item"),
        "table cell must be a named text box, got {names:?}"
    );
}

#[test]
fn empty_cell_emits_w_p() {
    let xml = table_cell_wml(TextAlign::Left, None);
    assert!(
        xml.contains("<w:p>") || xml.contains("<w:p "),
        "empty cell must contain w:p, got {xml}"
    );
    assert!(
        !xml.contains("<w:t>"),
        "empty cell should not require w:t, got {xml}"
    );
}

#[test]
fn nested_cell_returns_none() {
    let image = TableSpec {
        column_widths: vec![GridTrack::Fr { fr: 1 }],
        header_rows: 0,
        gap: 0,
        row_gap: None,
        column_gap: None,
        data: TableDataSource::Inline {
            rows: vec![vec![SemanticNode {
                id: "img".into(),
                role: "body".into(),
                content: NodeContent::Image {
                    src: "assets/x.png".into(),
                    width: k2f_core::Pt(10_000),
                    height: k2f_core::Pt(10_000),
                },
                ..Default::default()
            }]],
        },
    };
    assert!(!can_emit_native_table(&image));
    let nested = TableSpec {
        column_widths: vec![GridTrack::Fr { fr: 1 }],
        header_rows: 0,
        gap: 0,
        row_gap: None,
        column_gap: None,
        data: TableDataSource::Inline {
            rows: vec![vec![SemanticNode {
                id: "wrap".into(),
                role: "body".into(),
                content: NodeContent::Container {
                    children: vec![text_cell("inner")],
                },
                ..Default::default()
            }]],
        },
    };
    assert!(!can_emit_native_table(&nested));
    let plain = TableSpec {
        column_widths: vec![GridTrack::Fr { fr: 1 }],
        header_rows: 0,
        gap: 0,
        row_gap: None,
        column_gap: None,
        data: TableDataSource::Inline {
            rows: vec![vec![text_cell("c")]],
        },
    };
    assert!(can_emit_native_table(&plain));
    let asset = TableSpec {
        column_widths: vec![GridTrack::Fr { fr: 1 }],
        header_rows: 0,
        gap: 0,
        row_gap: None,
        column_gap: None,
        data: TableDataSource::Asset {
            source: "assets/data/t.json".into(),
        },
    };
    assert!(!can_emit_native_table(&asset));
}