k2f_docx 0.2.3

K2F lock → DOCX exporter
Documentation
use crate::coord::pt_to_emu;
use crate::ir::PictureBox;
use crate::xml::escape_xml;
use crate::DocxError;
use k2f_core::Rect;
use k2f_paint::{decode_raster, letterbox_rect, lookup_image};
use std::collections::BTreeMap;
use std::io::Cursor;

pub(crate) fn picture_from_draw(
    node_id: &str,
    rect: &Rect,
    src: &str,
    assets: &BTreeMap<String, Vec<u8>>,
    media_index: u32,
    relative_height: u32,
) -> Result<PictureBox, DocxError> {
    let bytes = lookup_image(assets, src)
        .ok_or_else(|| DocxError::Write(format!("missing image '{src}'")))?;
    let dest = dest_rect_for_image(rect, bytes);
    let (ext, payload) = encode_media(bytes)?;
    Ok(PictureBox {
        node_id: node_id.to_string(),
        x_emu: pt_to_emu(dest.x),
        y_emu: pt_to_emu(dest.y),
        cx_emu: pt_to_emu(dest.width),
        cy_emu: pt_to_emu(dest.height),
        media_name: format!("image{media_index}.{ext}"),
        bytes: payload,
        relative_height,
    })
}

fn dest_rect_for_image(rect: &Rect, bytes: &[u8]) -> Rect {
    decode_raster(bytes)
        .ok()
        .and_then(|img| letterbox_rect(img.width(), img.height(), rect))
        .unwrap_or_else(|| rect.clone())
}

pub(crate) fn encode_media(bytes: &[u8]) -> Result<(&'static str, Vec<u8>), DocxError> {
    if is_png(bytes) {
        return Ok(("png", bytes.to_vec()));
    }
    if is_jpeg(bytes) {
        return Ok(("jpg", bytes.to_vec()));
    }
    if looks_like_svg(bytes) {
        return Ok(("svg", bytes.to_vec()));
    }
    if is_webp(bytes) {
        return raster_to_png(bytes);
    }
    match raster_to_png(bytes) {
        Ok(png) => Ok(png),
        Err(_) => Err(DocxError::Write(
            "unsupported image bytes (need png/jpg/webp/svg)".into(),
        )),
    }
}

fn raster_to_png(bytes: &[u8]) -> Result<(&'static str, Vec<u8>), DocxError> {
    let img = decode_raster(bytes).map_err(|e| DocxError::Write(e.to_string()))?;
    let mut out = Cursor::new(Vec::new());
    img.write_to(&mut out, image::ImageFormat::Png)
        .map_err(|e| DocxError::Write(format!("png encode: {e}")))?;
    Ok(("png", out.into_inner()))
}

fn is_png(bytes: &[u8]) -> bool {
    bytes.starts_with(&[0x89, b'P', b'N', b'G'])
}

fn is_jpeg(bytes: &[u8]) -> bool {
    bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF
}

fn is_webp(bytes: &[u8]) -> bool {
    bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP"
}

fn looks_like_svg(bytes: &[u8]) -> bool {
    let s = String::from_utf8_lossy(bytes);
    let trimmed = s.trim_start().to_ascii_lowercase();
    trimmed.starts_with("<svg") || (trimmed.starts_with("<?xml") && trimmed.contains("<svg"))
}

pub(crate) fn pic_xml(pic: &PictureBox, embed_rid: &str, cnv_id: u32) -> String {
    pic_xml_named(pic, embed_rid, &pic.node_id, cnv_id)
}

/// Effect slices (gradient / glass / shadow) as a DrawingML shape with a blip
/// fill. LibreOffice Writer paints `pic:pic` above every `wps:wsp` regardless of
/// `behindDoc` / `relativeHeight`, so a full-page gradient raster would cover
/// later text. A `wps:wsp` joins the shape z-order stack.
pub(crate) fn raster_wsp_xml(pic: &PictureBox, embed_rid: &str) -> String {
    format!(
        r#"                <wps:wsp>
                  <wps:cNvSpPr txBox="1"/>
                  <wps:spPr>
                    <a:xfrm>
                      <a:off x="0" y="0"/>
                      <a:ext cx="{cx}" cy="{cy}"/>
                    </a:xfrm>
                    <a:prstGeom prst="rect">
                      <a:avLst/>
                    </a:prstGeom>
                    <a:blipFill>
                      <a:blip r:embed="{rid}"/>
                      <a:stretch>
                        <a:fillRect/>
                      </a:stretch>
                    </a:blipFill>
                    <a:ln>
                      <a:noFill/>
                    </a:ln>
                  </wps:spPr>
                  <wps:txbx>
                    <w:txbxContent>
                      <w:p/>
                    </w:txbxContent>
                  </wps:txbx>
                  <wps:bodyPr wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t">
                    <a:noAutofit/>
                  </wps:bodyPr>
                </wps:wsp>
"#,
        cx = pic.cx_emu,
        cy = pic.cy_emu,
        rid = embed_rid,
    )
}

fn pic_xml_named(pic: &PictureBox, embed_rid: &str, name: &str, cnv_id: u32) -> String {
    let name = escape_xml(name);
    format!(
        r#"                <pic:pic>
                  <pic:nvPicPr>
                    <pic:cNvPr id="{cnv_id}" name="{name}"/>
                    <pic:cNvPicPr>
                      <a:picLocks noChangeAspect="0"/>
                    </pic:cNvPicPr>
                  </pic:nvPicPr>
                  <pic:blipFill>
                    <a:blip r:embed="{rid}"/>
                    <a:stretch>
                      <a:fillRect/>
                    </a:stretch>
                  </pic:blipFill>
                  <pic:spPr>
                    <a:xfrm>
                      <a:off x="0" y="0"/>
                      <a:ext cx="{cx}" cy="{cy}"/>
                    </a:xfrm>
                    <a:prstGeom prst="rect">
                      <a:avLst/>
                    </a:prstGeom>
                  </pic:spPr>
                </pic:pic>
"#,
        rid = embed_rid,
        cx = pic.cx_emu,
        cy = pic.cy_emu,
    )
}

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

    #[test]
    fn missing_image_returns_write_error() {
        let rect = Rect {
            x: Pt(0),
            y: Pt(0),
            width: Pt(10_000),
            height: Pt(10_000),
        };
        let err = picture_from_draw("pic", &rect, "nope.png", &BTreeMap::new(), 1, 0).unwrap_err();
        match err {
            DocxError::Write(msg) => assert!(msg.contains("missing image")),
            other => panic!("expected Write, got {other:?}"),
        }
    }

    #[test]
    fn svg_stays_svg() {
        let svg = b"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\"></svg>";
        let (ext, payload) = encode_media(svg).unwrap();
        assert_eq!(ext, "svg");
        assert_eq!(payload, svg);
    }

    #[test]
    fn png_keeps_original_bytes() {
        let png = [0x89, b'P', b'N', b'G', 0, 0, 0, 0];
        let (ext, payload) = encode_media(&png).unwrap();
        assert_eq!(ext, "png");
        assert_eq!(payload, png);
    }

    #[test]
    fn webp_encodes_as_png() {
        // 1×1 red VP8 WebP from Pillow.
        let webp: &[u8] = &[
            0x52, 0x49, 0x46, 0x46, 0x3c, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50,
            0x38, 0x20, 0x30, 0x00, 0x00, 0x00, 0xd0, 0x01, 0x00, 0x9d, 0x01, 0x2a, 0x01, 0x00,
            0x01, 0x00, 0x01, 0x40, 0x26, 0x25, 0xa0, 0x02, 0x74, 0xba, 0x01, 0xf8, 0x00, 0x03,
            0xb0, 0x00, 0xfe, 0xf2, 0xeb, 0x7f, 0xfc, 0xd8, 0x15, 0xcd, 0x73, 0xef, 0xf7, 0xff,
            0xd2, 0xe0, 0xfd, 0x2e, 0x0f, 0xd2, 0xe0, 0xff, 0xd2, 0x90, 0x00, 0x00,
        ];
        assert!(is_webp(webp));
        let (ext, payload) = encode_media(webp).expect("webp must decode");
        assert_eq!(ext, "png");
        assert!(is_png(&payload), "webp must become PNG bytes");
    }

    #[test]
    fn dest_rect_letterboxes_tall_png_into_wide_box() {
        use image::codecs::png::PngEncoder;
        use image::{ExtendedColorType, ImageEncoder};
        let pixels = [0u8; 6];
        let mut out = Cursor::new(Vec::new());
        PngEncoder::new(&mut out)
            .write_image(&pixels, 1, 2, ExtendedColorType::Rgb8)
            .unwrap();
        let bytes = out.into_inner();
        let rect = Rect {
            x: Pt(0),
            y: Pt(0),
            width: Pt(200_000),
            height: Pt(100_000),
        };
        let dest = dest_rect_for_image(&rect, &bytes);
        assert_eq!(dest.width, Pt(50_000));
        assert_eq!(dest.x, Pt(75_000));
    }
}