k2f_docx 0.2.3

K2F lock → DOCX exporter
Documentation
use crate::coord::pt_to_twips;
use crate::ir::{BorderStroke, CellBorders, TableCell, TextAlign};
use crate::text::{infer_text_align, line_spacing_twips, runs_from_paint, vert_center, FontCtx};
use crate::DocxError;
use k2f_core::{
    node_text, Border, BorderEdge, BorderStyle, BoxDecoration, Fill, GeometryNode, PaintOp, Rect,
    RunningBlockNode, SemanticNode, TextGlyphRun,
};
use k2f_paint::{parse_hex_rgba, resolve_fill};
use std::collections::HashMap;

pub(crate) struct CellPaint {
    fill_hex: Option<String>,
    border: Option<Border>,
    runs: Vec<TextGlyphRun>,
}

pub(crate) fn cell_paints(ops: &[PaintOp]) -> Result<HashMap<String, CellPaint>, DocxError> {
    let mut map: HashMap<String, CellPaint> = HashMap::new();
    for op in ops {
        match op {
            PaintOp::DrawBox {
                node_id,
                decoration,
                ..
            } => {
                let fill = opaque_solid_hex(decoration)?;
                let e = map.entry(node_id.clone()).or_insert_with(CellPaint::empty);
                e.fill_hex = fill;
                e.border = decoration.border.clone();
            }
            PaintOp::DrawText { node_id, runs, .. } => {
                map.entry(node_id.clone())
                    .or_insert_with(CellPaint::empty)
                    .runs = runs.clone();
            }
            _ => {}
        }
    }
    Ok(map)
}

impl CellPaint {
    fn empty() -> Self {
        Self {
            fill_hex: None,
            border: None,
            runs: Vec::new(),
        }
    }
}

pub(crate) fn build_cell(
    geo: &GeometryNode,
    width_twips: i64,
    paint: Option<&CellPaint>,
    node: Option<&SemanticNode>,
    header: bool,
    fonts: &FontCtx,
) -> Result<TableCell, DocxError> {
    let text = node.and_then(node_text).unwrap_or("");
    let align = if text.is_empty() {
        TextAlign::Left
    } else {
        infer_text_align(geo, text)
    };
    let modifiers = node.map(|n| n.modifiers.as_slice()).unwrap_or(&[]);
    let paint_runs = paint.map(|p| p.runs.as_slice()).unwrap_or(&[]);
    let mut runs = if text.is_empty() {
        Vec::new()
    } else {
        runs_from_paint(text, paint_runs, modifiers, Some(geo), fonts)
    };
    if header && paint_runs.is_empty() {
        for r in &mut runs {
            r.bold = true;
        }
    }
    let preserve = node
        .map(|n| n.preserve_whitespace == Some(true) || n.role == "code_block")
        .unwrap_or(false);
    let font_size = paint
        .and_then(|p| p.runs.first())
        .map(|r| r.style.font_size)
        .or_else(|| geo.text_runs.first().map(|r| r.style.font_size))
        .unwrap_or(k2f_core::Pt(12_000));
    let cell_rect = Rect {
        x: geo.x,
        y: geo.y,
        width: geo.width,
        height: geo.height,
    };
    let centered = vert_center(Some(geo), &cell_rect, font_size);
    let mut line_twips = line_spacing_twips(Some(geo));
    if line_twips.is_none() && !runs.is_empty() {
        line_twips = Some(pt_to_twips(font_size).max(20));
    }
    Ok(TableCell {
        node_id: geo.id.clone(),
        width_twips,
        runs,
        align,
        fill_hex: paint.and_then(|p| p.fill_hex.clone()),
        preserve_whitespace: preserve,
        borders: cell_borders(paint.and_then(|p| p.border.as_ref()))?,
        vert_center: centered,
        line_twips,
    })
}

pub(crate) fn cell_borders(border: Option<&Border>) -> Result<CellBorders, DocxError> {
    let Some(b) = border else {
        return Ok(CellBorders::default());
    };
    if b.width_pt <= 0 || b.edges.is_empty() {
        return Ok(CellBorders::default());
    }
    let stroke = BorderStroke {
        color_hex: srgb_hex(&b.color)?,
        sz: border_sz(b.width_pt),
        val: match b.style {
            BorderStyle::Solid => "single",
            BorderStyle::Dashed => "dashed",
            BorderStyle::Dotted => "dotted",
        },
    };
    Ok(CellBorders {
        top: edge(b, BorderEdge::Top, &stroke),
        left: edge(b, BorderEdge::Left, &stroke),
        bottom: edge(b, BorderEdge::Bottom, &stroke),
        right: edge(b, BorderEdge::Right, &stroke),
    })
}

fn edge(b: &Border, e: BorderEdge, stroke: &BorderStroke) -> Option<BorderStroke> {
    b.draws_edge(e).then(|| stroke.clone())
}

fn border_sz(width_pt_millipt: i64) -> i64 {
    (width_pt_millipt / 125).clamp(2, 96)
}

fn opaque_solid_hex(decoration: &BoxDecoration) -> Result<Option<String>, DocxError> {
    match resolve_fill(decoration) {
        Ok(Some(Fill::Solid { color })) => {
            let [r, g, b, a] = parse_hex_rgba(&color)
                .ok_or_else(|| DocxError::Write(format!("unparseable fill color '{color}'")))?;
            if a < 255 {
                return Ok(None);
            }
            Ok(Some(format!("{r:02X}{g:02X}{b:02X}")))
        }
        Ok(_) => Ok(None),
        Err(k2f_paint::PaintError::UnresolvedRef(name)) => {
            Err(DocxError::Write(format!("unresolved fill ref '{name}'")))
        }
        Err(e) => Err(e.into()),
    }
}

fn srgb_hex(color: &str) -> Result<String, DocxError> {
    let [r, g, b, _] = parse_hex_rgba(color)
        .ok_or_else(|| DocxError::Write(format!("unparseable color '{color}'")))?;
    Ok(format!("{r:02X}{g:02X}{b:02X}"))
}

pub(crate) fn find_node<'a>(
    root: &'a SemanticNode,
    running: &'a [RunningBlockNode],
    id: &str,
) -> Option<&'a SemanticNode> {
    k2f_core::find_in_trees(root, running, id)
}