jw-hwp-core 0.1.2

Read-only parser for Hancom HWP 5.0 (binary CFB) and HWPX (OWPML) documents
Documentation
//! Fluent builder for constructing an HwpDocument for writing.

use crate::doc_info::DocumentProperties;
use crate::model::{HwpDocument, ParagraphDetail, Section};
use crate::shape::{Align, CharShape, ParaShape, ShapeTables};
use crate::summary::Metadata;
use crate::table::{Cell, Table};

impl HwpDocument {
    pub fn new_for_writing(title: Option<String>) -> Self {
        let mut shapes = ShapeTables::default();
        // Default char shape: 10pt, normal
        shapes.char_shapes.insert(
            0,
            CharShape {
                face_ids: [0; 7],
                face_names: Default::default(),
                size_pt: 10.0,
                italic: false,
                bold: false,
                underline: false,
                strikethrough: false,
                superscript: false,
                subscript: false,
                color: 0,
            },
        );
        // Default para shape: both-align
        shapes.para_shapes.insert(
            0,
            ParaShape {
                align: Align::Both,
                left_margin: 0,
                right_margin: 0,
                indent: 0,
                space_before: 0,
                space_after: 0,
                line_spacing: 160,
            },
        );

        let metadata = Metadata {
            title,
            ..Metadata::default()
        };

        HwpDocument {
            version: "hwpx".into(),
            metadata,
            properties: DocumentProperties::default(),
            shapes,
            assets: Default::default(),
            sections: vec![Section {
                index: 0,
                paragraphs: vec![],
                paragraph_details: vec![],
                structure: vec![],
                tables: vec![],
            }],
            warnings: vec![],
        }
    }

    /// Find or create a CharShape matching the given attributes. Returns shape ID.
    pub fn ensure_char_shape(
        &mut self,
        bold: bool,
        italic: bool,
        underline: bool,
        size_pt: f32,
        color: u32,
    ) -> u32 {
        for (&id, cs) in &self.shapes.char_shapes {
            if cs.bold == bold
                && cs.italic == italic
                && cs.underline == underline
                && (cs.size_pt - size_pt).abs() < 0.01
                && cs.color == color
            {
                return id;
            }
        }
        let id = self.shapes.char_shapes.len() as u32;
        self.shapes.char_shapes.insert(
            id,
            CharShape {
                face_ids: [0; 7],
                face_names: Default::default(),
                size_pt,
                italic,
                bold,
                underline,
                strikethrough: false,
                superscript: false,
                subscript: false,
                color,
            },
        );
        id
    }

    /// Find or create a ParaShape matching the given alignment. Returns shape ID.
    pub fn ensure_para_shape(&mut self, align: Align) -> u32 {
        for (&id, ps) in &self.shapes.para_shapes {
            if ps.align == align {
                return id;
            }
        }
        let id = self.shapes.para_shapes.len() as u32;
        self.shapes.para_shapes.insert(
            id,
            ParaShape {
                align,
                left_margin: 0,
                right_margin: 0,
                indent: 0,
                space_before: 0,
                space_after: 0,
                line_spacing: 160,
            },
        );
        id
    }

    /// Add a paragraph to section 0. Returns paragraph_id "0:N".
    pub fn add_paragraph(
        &mut self,
        text: &str,
        char_shape_id: u32,
        para_shape_id: u32,
        insert_after: Option<&str>,
    ) -> String {
        let sec = &mut self.sections[0];
        let idx = if let Some(after_id) = insert_after {
            if let Some(pos) = after_id
                .split(':')
                .nth(1)
                .and_then(|s| s.parse::<usize>().ok())
            {
                let insert_at = (pos + 1).min(sec.paragraphs.len());
                sec.paragraphs.insert(insert_at, text.to_string());
                sec.paragraph_details.insert(
                    insert_at,
                    ParagraphDetail {
                        text: text.to_string(),
                        para_shape_id,
                        runs: vec![(0, char_shape_id)],
                        footnotes: vec![],
                        equation: None,
                        image_refs: vec![],
                    },
                );
                insert_at
            } else {
                sec.paragraphs.push(text.to_string());
                sec.paragraph_details.push(ParagraphDetail {
                    text: text.to_string(),
                    para_shape_id,
                    runs: vec![(0, char_shape_id)],
                    footnotes: vec![],
                    equation: None,
                    image_refs: vec![],
                });
                sec.paragraphs.len() - 1
            }
        } else {
            sec.paragraphs.push(text.to_string());
            sec.paragraph_details.push(ParagraphDetail {
                text: text.to_string(),
                para_shape_id,
                runs: vec![(0, char_shape_id)],
                footnotes: vec![],
                equation: None,
                image_refs: vec![],
            });
            sec.paragraphs.len() - 1
        };
        format!("0:{}", idx)
    }

    /// Set text of an existing paragraph.
    pub fn set_text(&mut self, paragraph_id: &str, text: &str) -> Result<(), String> {
        let (sec, para) = parse_element_id(paragraph_id)?;
        let section = self.sections.get_mut(sec).ok_or("section not found")?;
        *section
            .paragraphs
            .get_mut(para)
            .ok_or("paragraph not found")? = text.to_string();
        if let Some(d) = section.paragraph_details.get_mut(para) {
            d.text = text.to_string();
        }
        Ok(())
    }

    /// Add a table to section 0. Returns table_id "0:N".
    pub fn add_table(
        &mut self,
        rows: u16,
        cols: u16,
        cells_text: Option<Vec<Vec<String>>>,
        _insert_after: Option<&str>,
    ) -> String {
        let sec = &mut self.sections[0];
        let cells: Vec<Vec<Option<Cell>>> = (0..rows)
            .map(|r| {
                (0..cols)
                    .map(|c| {
                        let text = cells_text
                            .as_ref()
                            .and_then(|ct| ct.get(r as usize))
                            .and_then(|row| row.get(c as usize))
                            .cloned()
                            .unwrap_or_default();
                        Some(Cell {
                            col: c,
                            row: r,
                            col_span: 1,
                            row_span: 1,
                            text: text.clone(),
                            paragraphs: vec![text],
                        })
                    })
                    .collect()
            })
            .collect();
        let para_idx = sec.paragraphs.len();
        // Insert a placeholder paragraph for the table
        sec.paragraphs.push(String::new());
        sec.paragraph_details.push(ParagraphDetail {
            text: String::new(),
            para_shape_id: 0,
            runs: vec![],
            footnotes: vec![],
            equation: None,
            image_refs: vec![],
        });
        let table = Table {
            id: format!("0:{}", para_idx),
            rows,
            cols,
            caption: None,
            cells,
        };
        sec.tables.push(table);
        format!("0:{}", para_idx)
    }

    /// Delete a paragraph or table by element_id.
    pub fn delete_element(&mut self, element_id: &str) -> Result<(), String> {
        let (sec, idx) = parse_element_id(element_id)?;
        let section = self.sections.get_mut(sec).ok_or("section not found")?;
        // Check if it's a table
        if let Some(tbl_pos) = section.tables.iter().position(|t| t.id == element_id) {
            section.tables.remove(tbl_pos);
        }
        if idx < section.paragraphs.len() {
            section.paragraphs.remove(idx);
            if idx < section.paragraph_details.len() {
                section.paragraph_details.remove(idx);
            }
        }
        Ok(())
    }
}

fn parse_element_id(id: &str) -> Result<(usize, usize), String> {
    let parts: Vec<&str> = id.split(':').collect();
    if parts.len() != 2 {
        return Err("element_id must be 'section:index'".into());
    }
    let sec: usize = parts[0].parse().map_err(|_| "invalid section")?;
    let idx: usize = parts[1].parse().map_err(|_| "invalid index")?;
    Ok((sec, idx))
}

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

    #[test]
    fn create_and_add_paragraphs() {
        let mut doc = HwpDocument::new_for_writing(Some("Test".into()));
        let p1 = doc.add_paragraph("Hello", 0, 0, None);
        let p2 = doc.add_paragraph("World", 0, 0, None);
        assert_eq!(p1, "0:0");
        assert_eq!(p2, "0:1");
        assert_eq!(doc.sections[0].paragraphs, vec!["Hello", "World"]);
    }

    #[test]
    fn set_text_updates_paragraph() {
        let mut doc = HwpDocument::new_for_writing(None);
        doc.add_paragraph("old", 0, 0, None);
        doc.set_text("0:0", "new").unwrap();
        assert_eq!(doc.sections[0].paragraphs[0], "new");
    }

    #[test]
    fn add_table_populates_cells() {
        let mut doc = HwpDocument::new_for_writing(None);
        let _tid = doc.add_table(
            2,
            2,
            Some(vec![
                vec!["A".into(), "B".into()],
                vec!["C".into(), "D".into()],
            ]),
            None,
        );
        assert_eq!(doc.sections[0].tables.len(), 1);
        let tbl = &doc.sections[0].tables[0];
        assert_eq!(tbl.cells[0][0].as_ref().unwrap().text, "A");
        assert_eq!(tbl.cells[1][1].as_ref().unwrap().text, "D");
    }

    #[test]
    fn delete_element_removes_paragraph() {
        let mut doc = HwpDocument::new_for_writing(None);
        doc.add_paragraph("a", 0, 0, None);
        doc.add_paragraph("b", 0, 0, None);
        doc.delete_element("0:0").unwrap();
        assert_eq!(doc.sections[0].paragraphs, vec!["b"]);
    }

    #[test]
    fn ensure_char_shape_deduplicates() {
        let mut doc = HwpDocument::new_for_writing(None);
        let id1 = doc.ensure_char_shape(true, false, false, 12.0, 0);
        let id2 = doc.ensure_char_shape(true, false, false, 12.0, 0);
        let id3 = doc.ensure_char_shape(false, true, false, 12.0, 0);
        assert_eq!(id1, id2);
        assert_ne!(id1, id3);
    }
}