k2f_docx 0.2.3

K2F lock → DOCX exporter
Documentation
use crate::coord::{pt_to_emu, pt_to_twips};
use crate::effect::{
    box_is_effect, consume_following_box, effect_box_crop, glass_crop, is_math_node, is_rule_id,
    keep_for_effect, math_crop, rasterize_slice,
};
use crate::geo::{find_geo, page_bg_hex};
use crate::ir::{DocIR, PageElement, PageIR, PictureBox};
use crate::picture::picture_from_draw;
use crate::shape::shapes_from_box;
use crate::table::{paint_node_id, table_ref_placeholder};
use crate::text::{font_ctx, textbox_from_draw_ctx};
use crate::xml::word_hex_color;
use crate::DocxError;
use k2f_core::{LockFile, Page, PaintOp};
use k2f_paint::OpenedDocument;
use std::collections::{BTreeMap, HashSet};

pub fn classify_opened(doc: &OpenedDocument) -> Result<DocIR, DocxError> {
    let lock = doc.lock().ok_or(DocxError::Unlocked)?;
    if lock.has_unknown_paint_ops() {
        return Err(DocxError::UnknownOp);
    }
    let pages = &lock.geometry.pages;
    if pages.is_empty() {
        return Err(DocxError::Write("lock has no pages".into()));
    }
    let fonts = font_ctx(doc.fonts());
    let root = doc.semantic_root();
    let running = doc.running_blocks();
    let skip_running = crate::header::running_field_ids(running);
    let assets = doc.assets();
    let mut media_n = 1u32;
    let mut raster_n = 1u32;
    let mut ir_pages = Vec::with_capacity(pages.len());
    for (page_idx, page) in pages.iter().enumerate() {
        let ops = lock
            .render_plan
            .pages
            .get(page_idx)
            .map(|p| p.ops.as_slice())
            .unwrap_or(&[]);
        let mut elements = Vec::new();
        let mut skip_ops = HashSet::new();
        let bg_hex = word_hex_color(&page_bg_hex(page, ops));
        for (i, op) in ops.iter().enumerate() {
            if skip_ops.contains(&i) {
                continue;
            }
            let rel = rel_height(i);
            if let Some(nid) = paint_node_id(op) {
                if skip_running.contains(nid) {
                    continue;
                }
            }
            match op {
                PaintOp::Unknown => return Err(DocxError::UnknownOp),
                PaintOp::BackdropBlur { node_id, rect, .. } => {
                    let follow = consume_following_box(ops, i, node_id);
                    if let Some(j) = follow {
                        skip_ops.insert(j);
                    }
                    let crop = glass_crop(rect, ops, follow)?;
                    elements.push(PageElement::Raster(slice(
                        lock,
                        page_idx,
                        page,
                        ops,
                        node_id,
                        crop,
                        false,
                        raster_n,
                        rel,
                        doc.fonts(),
                        assets,
                    )?));
                    raster_n = raster_n.saturating_add(1);
                }
                PaintOp::DrawText {
                    node_id,
                    rect,
                    runs,
                } => {
                    let Some(node) = k2f_core::find_in_trees(root, running, node_id) else {
                        continue;
                    };
                    if is_math_node(node) {
                        let crop = math_crop(ops, node_id, rect);
                        elements.push(PageElement::Raster(slice(
                            lock,
                            page_idx,
                            page,
                            ops,
                            node_id,
                            crop,
                            true,
                            raster_n,
                            rel,
                            doc.fonts(),
                            assets,
                        )?));
                        raster_n = raster_n.saturating_add(1);
                    } else {
                        let geo = find_geo(&page.root, node_id);
                        if let Some(tb) = textbox_from_draw_ctx(node, rect, runs, geo, &fonts, rel)
                        {
                            elements.push(PageElement::TextBox(tb));
                        }
                    }
                }
                PaintOp::DrawBox {
                    node_id,
                    rect,
                    decoration,
                } => {
                    if is_rule_id(node_id) {
                        continue;
                    }
                    if box_is_effect(node_id, decoration)? {
                        let crop = effect_box_crop(rect, decoration)?;
                        elements.push(PageElement::Raster(slice(
                            lock,
                            page_idx,
                            page,
                            ops,
                            node_id,
                            crop,
                            false,
                            raster_n,
                            rel,
                            doc.fonts(),
                            assets,
                        )?));
                        raster_n = raster_n.saturating_add(1);
                    } else {
                        for s in shapes_from_box(
                            node_id,
                            rect,
                            decoration,
                            page.width,
                            page.height,
                            rel,
                            &bg_hex,
                        )? {
                            elements.push(PageElement::Shape(s));
                        }
                    }
                }
                PaintOp::DrawImage { node_id, rect, src } => {
                    if crate::geo::image_occluded_by_later_opaque_box(rect, &ops[i + 1..]) {
                        continue;
                    }
                    let pic = picture_from_draw(node_id, rect, src, assets, media_n, rel)?;
                    media_n = media_n.saturating_add(1);
                    elements.push(PageElement::Picture(pic));
                }
                PaintOp::DrawTableReference { node_id, rect, .. } => {
                    elements.push(PageElement::Shape(table_ref_placeholder(
                        node_id, rect, rel,
                    )));
                }
            }
        }
        assign_textbox_underlays(&mut elements, &bg_hex);
        absorb_self_fill_shapes(&mut elements);
        ir_pages.push(PageIR { bg_hex, elements });
    }
    let page0 = &pages[0];
    let (mut header, mut footer) =
        crate::header::collect_running(doc, lock, &fonts, &skip_running, assets, &mut media_n)?;
    let paper = ir_pages
        .first()
        .map(|p| p.bg_hex.as_str())
        .unwrap_or("FFFFFE");
    assign_textbox_underlays(&mut header, paper);
    assign_textbox_underlays(&mut footer, paper);
    absorb_self_fill_shapes(&mut header);
    absorb_self_fill_shapes(&mut footer);
    Ok(DocIR {
        title: doc.title().to_string(),
        page_width_emu: pt_to_emu(page0.width),
        page_height_emu: pt_to_emu(page0.height),
        page_width_twips: pt_to_twips(page0.width),
        page_height_twips: pt_to_twips(page0.height),
        pages: ir_pages,
        header,
        footer,
    })
}

/// Word Dark Mode inverts text in `noFill` floating boxes. Paint an opaque
/// underlay matching the shape behind the box (or the page paper).
///
/// Skip the paper fallback when a raster/picture already covers the box — a
/// white underlay on a gradient or glass slice hides the lock chrome (and
/// lock-white text on that fill).
fn assign_textbox_underlays(elements: &mut [PageElement], paper_hex: &str) {
    let shapes: Vec<(u32, i64, i64, i64, i64, String)> = elements
        .iter()
        .filter_map(|el| match el {
            PageElement::Shape(s)
                if s.gradient.is_none() && s.fill_alpha >= 255 && s.fill_hex.is_some() =>
            {
                s.fill_hex.as_ref().map(|h| {
                    (
                        s.relative_height,
                        s.x_emu,
                        s.y_emu,
                        s.cx_emu,
                        s.cy_emu,
                        h.clone(),
                    )
                })
            }
            _ => None,
        })
        .collect();
    let covers: Vec<(u32, i64, i64, i64, i64)> = elements
        .iter()
        .filter_map(|el| match el {
            PageElement::Picture(p) | PageElement::Raster(p) => {
                Some((p.relative_height, p.x_emu, p.y_emu, p.cx_emu, p.cy_emu))
            }
            PageElement::Shape(s) if s.gradient.is_some() || s.fill_alpha < 255 => {
                Some((s.relative_height, s.x_emu, s.y_emu, s.cx_emu, s.cy_emu))
            }
            _ => None,
        })
        .collect();
    for el in elements.iter_mut() {
        let PageElement::TextBox(tb) = el else {
            continue;
        };
        let px = tb.x_emu.saturating_add(tb.cx_emu / 2);
        let py = tb.y_emu.saturating_add(tb.cy_emu / 2);
        let mut found = None;
        for (rel, x, y, w, h, hex) in &shapes {
            if *rel >= tb.relative_height {
                continue;
            }
            if px >= *x && py >= *y && px < x.saturating_add(*w) && py < y.saturating_add(*h) {
                found = Some(hex.clone());
            }
        }
        tb.fill_hex = if let Some(hex) = found {
            Some(word_hex_color(&hex))
        } else if covers.iter().any(|(rel, x, y, w, h)| {
            *rel < tb.relative_height
                && px >= *x
                && py >= *y
                && px < x.saturating_add(*w)
                && py < y.saturating_add(*h)
        }) {
            None
        } else {
            Some(word_hex_color(paper_hex))
        };
        tb.fill_alpha = 255;
    }
}

/// A DrawBox + DrawText for the same node (pills, chips) must be one Word
/// shape. LibreOffice paints the later roundRect on top of the text box and
/// hides white labels. Fold the corner radius and fill into the text box and
/// drop the duplicate fill shape.
fn absorb_self_fill_shapes(elements: &mut Vec<PageElement>) {
    let extras: BTreeMap<String, (i64, Option<String>, u8)> = elements
        .iter()
        .filter_map(|el| match el {
            PageElement::Shape(s) => Some((
                s.node_id.clone(),
                (s.corner_emu, s.fill_hex.clone(), s.fill_alpha),
            )),
            _ => None,
        })
        .collect();
    let mut absorbed = HashSet::new();
    for el in elements.iter_mut() {
        let PageElement::TextBox(tb) = el else {
            continue;
        };
        if let Some((corner, fill, alpha)) = extras.get(&tb.node_id) {
            tb.corner_emu = *corner;
            if fill.is_some() {
                tb.fill_hex = fill.clone();
                tb.fill_alpha = *alpha;
            }
            absorbed.insert(tb.node_id.clone());
        }
    }
    if absorbed.is_empty() {
        return;
    }
    elements.retain(|el| match el {
        PageElement::Shape(s) => !absorbed.contains(&s.node_id),
        _ => true,
    });
}

fn slice(
    lock: &LockFile,
    page_idx: usize,
    page: &Page,
    ops: &[PaintOp],
    node_id: &str,
    crop: k2f_core::Rect,
    math: bool,
    raster_n: u32,
    rel: u32,
    fonts: &BTreeMap<String, Vec<u8>>,
    assets: &BTreeMap<String, Vec<u8>>,
) -> Result<PictureBox, DocxError> {
    let keep = keep_for_effect(node_id, page, math);
    rasterize_slice(
        lock, page_idx, page, ops, &keep, crop, node_id, raster_n, rel, fonts, assets,
    )
}

fn rel_height(op_index: usize) -> u32 {
    u32::try_from(op_index.saturating_mul(10)).unwrap_or(u32::MAX)
}