k2f_docx 0.2.2

K2F lock → DOCX exporter
Documentation
use crate::coord::pt_to_emu;
use crate::ir::PictureBox;
use crate::DocxError;
use image::codecs::png::PngEncoder;
use image::{ExtendedColorType, ImageEncoder};
use k2f_core::{BoxDecoration, Pt, Rect, ShadowRef};
use k2f_paint::OFFICIAL_PNG_SCALE;
use std::io::Cursor;

pub fn millipt_to_px(millipt: i64, scale: f32) -> i32 {
    let pt = millipt as f64 / 1000.0;
    (pt * f64::from(scale)).round() as i32
}

fn i64_pt(pt: Pt) -> i64 {
    i64::try_from(pt.0).unwrap_or(i64::MAX)
}

pub fn union_rect(a: Rect, b: Rect) -> Rect {
    let x1 = a.x.0.min(b.x.0);
    let y1 = a.y.0.min(b.y.0);
    let x2 = (a.x.0 + a.width.0).max(b.x.0 + b.width.0);
    let y2 = (a.y.0 + a.height.0).max(b.y.0 + b.height.0);
    Rect {
        x: Pt(x1),
        y: Pt(y1),
        width: Pt((x2 - x1).max(1)),
        height: Pt((y2 - y1).max(1)),
    }
}

pub fn clamp_rect_to_page(rect: Rect, page_w: Pt, page_h: Pt) -> Rect {
    let x = rect.x.0.max(0);
    let y = rect.y.0.max(0);
    let x2 = (rect.x.0 + rect.width.0).min(page_w.0).max(x + 1);
    let y2 = (rect.y.0 + rect.height.0).min(page_h.0).max(y + 1);
    Rect {
        x: Pt(x),
        y: Pt(y),
        width: Pt(x2 - x),
        height: Pt(y2 - y),
    }
}

pub fn expand_rect_for_shadow(rect: Rect, decoration: &BoxDecoration) -> Result<Rect, DocxError> {
    let Some(shadow) = decoration.shadow.as_ref() else {
        return Ok(rect);
    };
    let layers = match shadow {
        ShadowRef::Inline(s) => &s.layers,
        ShadowRef::Ref(name) => {
            return Err(DocxError::Write(format!("unresolved shadow ref '{name}'")));
        }
    };
    if layers.is_empty() {
        return Ok(rect);
    }
    let mut pad_l = 0i64;
    let mut pad_t = 0i64;
    let mut pad_r = 0i64;
    let mut pad_b = 0i64;
    for layer in layers {
        let extra = layer.blur_radius_pt.max(0) + layer.spread_radius_pt.max(0);
        pad_l = pad_l.max(extra + (-layer.offset_x_pt).max(0));
        pad_r = pad_r.max(extra + layer.offset_x_pt.max(0));
        pad_t = pad_t.max(extra + (-layer.offset_y_pt).max(0));
        pad_b = pad_b.max(extra + layer.offset_y_pt.max(0));
    }
    let min_pad = ((1000.0 / f64::from(OFFICIAL_PNG_SCALE)).ceil() as i64).max(1);
    if pad_l + pad_r + pad_t + pad_b == 0 {
        pad_l = min_pad;
        pad_r = min_pad;
        pad_t = min_pad;
        pad_b = min_pad;
    }
    Ok(Rect {
        x: Pt(rect.x.0 - i128::from(pad_l)),
        y: Pt(rect.y.0 - i128::from(pad_t)),
        width: Pt(rect.width.0 + i128::from(pad_l + pad_r)),
        height: Pt(rect.height.0 + i128::from(pad_t + pad_b)),
    })
}

pub fn crop_rgb_to_png(
    rgb: &[u8],
    img_w: u32,
    img_h: u32,
    rect: &Rect,
    scale: f32,
) -> Result<Vec<u8>, DocxError> {
    let mut left = millipt_to_px(i64_pt(rect.x), scale);
    let mut top = millipt_to_px(i64_pt(rect.y), scale);
    let mut right = millipt_to_px(i64_pt(rect.x) + i64_pt(rect.width), scale);
    let mut bottom = millipt_to_px(i64_pt(rect.y) + i64_pt(rect.height), scale);
    let w = img_w as i32;
    let h = img_h as i32;
    if w <= 0 || h <= 0 {
        return Err(DocxError::Write("empty page bitmap".into()));
    }
    left = left.clamp(0, w.saturating_sub(1));
    top = top.clamp(0, h.saturating_sub(1));
    right = right.clamp(left + 1, w);
    bottom = bottom.clamp(top + 1, h);
    let cw = (right - left) as u32;
    let ch = (bottom - top) as u32;
    let mut cropped = Vec::with_capacity((cw * ch * 3) as usize);
    for y in top..bottom {
        let row = ((y as u32) * img_w + left as u32) as usize * 3;
        cropped.extend_from_slice(&rgb[row..row + (cw as usize) * 3]);
    }
    encode_png(cw, ch, &cropped)
}

fn encode_png(w: u32, h: u32, rgb: &[u8]) -> Result<Vec<u8>, DocxError> {
    let mut out = Cursor::new(Vec::new());
    PngEncoder::new(&mut out)
        .write_image(rgb, w, h, ExtendedColorType::Rgb8)
        .map_err(|e| DocxError::Write(format!("png encode: {e}")))?;
    Ok(out.into_inner())
}

pub fn picture_from_crop(
    node_id: &str,
    crop: &Rect,
    png: Vec<u8>,
    raster_index: u32,
    relative_height: u32,
) -> PictureBox {
    PictureBox {
        node_id: node_id.to_string(),
        x_emu: pt_to_emu(crop.x),
        y_emu: pt_to_emu(crop.y),
        cx_emu: pt_to_emu(crop.width),
        cy_emu: pt_to_emu(crop.height),
        media_name: format!("raster{raster_index}.png"),
        bytes: png,
        relative_height,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn millipt_to_px_matches_official_scale() {
        assert_eq!(millipt_to_px(960_000, 2.0), 1920);
        assert_eq!(millipt_to_px(0, 2.0), 0);
    }
}