diff --git a/src/document_core/commands/table_ops.rs b/src/document_core/commands/table_ops.rs index 036ecb723..6b6036b45 100644 --- a/src/document_core/commands/table_ops.rs +++ b/src/document_core/commands/table_ops.rs @@ -7,6 +7,10 @@ use crate::error::HwpError; use crate::model::event::DocumentEvent; use super::super::helpers::{navigate_path_to_table, border_line_type_to_u8_val, color_ref_to_css}; +fn top_level_u32(value: Option<&serde_json::Value>, key: &str) -> Option { + value?.get(key)?.as_u64().and_then(|v| u32::try_from(v).ok()) +} + impl DocumentCore { pub(crate) fn get_table_mut( &mut self, @@ -384,39 +388,58 @@ impl DocumentCore { ) -> Result { use super::super::helpers::{json_u32, json_i16, json_u8, json_bool}; - let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; - let cell = table.cells.get_mut(cell_idx) - .ok_or_else(|| HwpError::RenderError(format!("셀 인덱스 {} 범위 초과", cell_idx)))?; - - if let Some(v) = json_u32(json, "width") { cell.width = v; } - if let Some(v) = json_u32(json, "height") { cell.height = v; } - if let Some(v) = json_i16(json, "paddingLeft") { cell.padding.left = v; } - if let Some(v) = json_i16(json, "paddingRight") { cell.padding.right = v; } - if let Some(v) = json_i16(json, "paddingTop") { cell.padding.top = v; } - if let Some(v) = json_i16(json, "paddingBottom") { cell.padding.bottom = v; } - if let Some(v) = json_u8(json, "verticalAlign") { - cell.vertical_align = match v { - 1 => crate::model::table::VerticalAlign::Center, - 2 => crate::model::table::VerticalAlign::Bottom, - _ => crate::model::table::VerticalAlign::Top, - }; - } - if let Some(v) = json_u8(json, "textDirection") { cell.text_direction = v; } - if let Some(v) = json_bool(json, "isHeader") { - cell.is_header = v; - if v { - cell.list_header_width_ref |= 0x04; - } else { - cell.list_header_width_ref &= !0x04; + let parsed_json = serde_json::from_str::(json).ok(); + let width = if parsed_json.is_some() { + top_level_u32(parsed_json.as_ref(), "width") + } else { + json_u32(json, "width") + }; + let should_reflow_cell = width.is_some() + || json_u32(json, "height").is_some() + || json_i16(json, "paddingLeft").is_some() + || json_i16(json, "paddingRight").is_some() + || json_i16(json, "paddingTop").is_some() + || json_i16(json, "paddingBottom").is_some(); + let cell_para_count = { + let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; + let cell = table.cells.get_mut(cell_idx) + .ok_or_else(|| HwpError::RenderError(format!("셀 인덱스 {} 범위 초과", cell_idx)))?; + + if let Some(v) = width { cell.width = v; } + if let Some(v) = json_u32(json, "height") { cell.height = v; } + if let Some(v) = json_i16(json, "paddingLeft") { cell.padding.left = v; } + if let Some(v) = json_i16(json, "paddingRight") { cell.padding.right = v; } + if let Some(v) = json_i16(json, "paddingTop") { cell.padding.top = v; } + if let Some(v) = json_i16(json, "paddingBottom") { cell.padding.bottom = v; } + if let Some(v) = json_u8(json, "verticalAlign") { + cell.vertical_align = match v { + 1 => crate::model::table::VerticalAlign::Center, + 2 => crate::model::table::VerticalAlign::Bottom, + _ => crate::model::table::VerticalAlign::Top, + }; } - } - if let Some(v) = json_bool(json, "cellProtect") { - if v { - cell.list_header_width_ref |= 0x02; - } else { - cell.list_header_width_ref &= !0x02; + if let Some(v) = json_u8(json, "textDirection") { cell.text_direction = v; } + if let Some(v) = json_bool(json, "isHeader") { + cell.is_header = v; + if v { + cell.list_header_width_ref |= 0x04; + } else { + cell.list_header_width_ref &= !0x04; + } + } + if let Some(v) = json_bool(json, "cellProtect") { + if v { + cell.list_header_width_ref |= 0x02; + } else { + cell.list_header_width_ref &= !0x02; + } } - } + + let para_count = cell.paragraphs.len(); + table.update_ctrl_dimensions(); + table.dirty = true; + para_count + }; // BorderFill 변경: borderLeft 등이 포함된 경우 create_border_fill_from_json으로 처리 let has_border = json.contains("\"borderLeft\""); @@ -450,6 +473,18 @@ impl DocumentCore { } + if should_reflow_cell { + for cell_para_idx in 0..cell_para_count { + self.reflow_cell_paragraph( + section_idx, + parent_para_idx, + control_idx, + cell_idx, + cell_para_idx, + ); + } + } + self.document.sections[section_idx].raw_stream = None; self.recompose_section(section_idx); self.paginate_if_needed(); @@ -787,14 +822,19 @@ impl DocumentCore { let bf_json = self.build_border_fill_json_by_id(table.border_fill_id); - // raw_ctrl_data에서 표 크기 & 바깥 여백 추출 + // raw_ctrl_data is serialized CommonObjAttr including attr. Prefer the + // parsed common/table fields so generated and edited tables agree. let rd = &table.raw_ctrl_data; - let table_width = if rd.len() >= 12 { u32::from_le_bytes([rd[8], rd[9], rd[10], rd[11]]) } else { 0 }; - let table_height = if rd.len() >= 16 { u32::from_le_bytes([rd[12], rd[13], rd[14], rd[15]]) } else { 0 }; - let outer_left = if rd.len() >= 22 { i16::from_le_bytes([rd[20], rd[21]]) } else { 0 }; - let outer_right = if rd.len() >= 24 { i16::from_le_bytes([rd[22], rd[23]]) } else { 0 }; - let outer_top = if rd.len() >= 26 { i16::from_le_bytes([rd[24], rd[25]]) } else { 0 }; - let outer_bottom = if rd.len() >= 28 { i16::from_le_bytes([rd[26], rd[27]]) } else { 0 }; + let table_width = if table.common.width != 0 { + table.common.width + } else if rd.len() >= 16 { u32::from_le_bytes([rd[12], rd[13], rd[14], rd[15]]) } else { 0 }; + let table_height = if table.common.height != 0 { + table.common.height + } else if rd.len() >= 20 { u32::from_le_bytes([rd[16], rd[17], rd[18], rd[19]]) } else { 0 }; + let outer_left = table.outer_margin_left; + let outer_right = table.outer_margin_right; + let outer_top = table.outer_margin_top; + let outer_bottom = table.outer_margin_bottom; // 캡션 정보 let caption_json = if let Some(ref cap) = table.caption { @@ -850,14 +890,11 @@ impl DocumentCore { crate::model::shape::HorzAlign::Inside => "Inside", crate::model::shape::HorzAlign::Outside => "Outside", }; - let vert_offset = if rd.len() >= 4 { i32::from_le_bytes([rd[0], rd[1], rd[2], rd[3]]) } else { 0 }; - let horz_offset = if rd.len() >= 8 { i32::from_le_bytes([rd[4], rd[5], rd[6], rd[7]]) } else { 0 }; + let vert_offset = table.common.vertical_offset as i32; + let horz_offset = table.common.horizontal_offset as i32; let restrict_in_page = (table.attr >> 13) & 0x01 != 0; let allow_overlap = (table.attr >> 14) & 0x01 != 0; - // raw_ctrl_data[32..36] = prevent_page_break (개체와 조판부호를 항상 같은 쪽에 놓기) - let keep_with_anchor = if rd.len() >= 36 { - i32::from_le_bytes([rd[32], rd[33], rd[34], rd[35]]) != 0 - } else { false }; + let keep_with_anchor = table.common.prevent_page_break != 0; Ok(format!( "{{\"cellSpacing\":{},\"paddingLeft\":{},\"paddingRight\":{},\"paddingTop\":{},\"paddingBottom\":{},\"pageBreak\":{},\"repeatHeader\":{},{},\"tableWidth\":{},\"tableHeight\":{},\"outerLeft\":{},\"outerRight\":{},\"outerTop\":{},\"outerBottom\":{}{},\"treatAsChar\":{},\"textWrap\":\"{}\",\"vertRelTo\":\"{}\",\"vertAlign\":\"{}\",\"horzRelTo\":\"{}\",\"horzAlign\":\"{}\",\"vertOffset\":{},\"horzOffset\":{},\"restrictInPage\":{},\"allowOverlap\":{},\"keepWithAnchor\":{}}}", @@ -884,6 +921,7 @@ impl DocumentCore { json: &str, ) -> Result { use super::super::helpers::{json_i16, json_i32, json_u8, json_u32, json_bool, json_str}; + use crate::model::shape::{HorzAlign, HorzRelTo, TextWrap, VertAlign, VertRelTo}; let table = self.get_table_mut(section_idx, parent_para_idx, control_idx)?; @@ -901,49 +939,106 @@ impl DocumentCore { } if let Some(v) = json_bool(json, "repeatHeader") { table.repeat_header = v; } if let Some(v) = json_bool(json, "treatAsChar") { + table.common.treat_as_char = v; if v { table.attr |= 0x01; } else { table.attr &= !0x01; } } - // 위치 속성: attr 비트 필드 + // 위치 속성: public state(common), legacy attr, serialized raw data를 함께 동기화한다. if let Some(v) = json_str(json, "textWrap") { - let bits: u32 = match v.as_str() { - "Square" => 0, "TopAndBottom" => 1, "BehindText" => 2, "InFrontOfText" => 3, _ => 0 + table.common.text_wrap = match v.as_str() { + "Square" => TextWrap::Square, + "Tight" => TextWrap::Tight, + "Through" => TextWrap::Through, + "TopAndBottom" => TextWrap::TopAndBottom, + "BehindText" => TextWrap::BehindText, + "InFrontOfText" => TextWrap::InFrontOfText, + _ => table.common.text_wrap, + }; + let bits: u32 = match table.common.text_wrap { + TextWrap::Square | TextWrap::Tight | TextWrap::Through => 0, + TextWrap::TopAndBottom => 1, + TextWrap::BehindText => 2, + TextWrap::InFrontOfText => 3, }; table.attr = (table.attr & !(0x07 << 21)) | (bits << 21); } if let Some(v) = json_str(json, "vertRelTo") { - let bits: u32 = match v.as_str() { - "Paper" => 0, "Page" => 1, "Para" => 2, _ => 0 + table.common.vert_rel_to = match v.as_str() { + "Paper" => VertRelTo::Paper, + "Page" => VertRelTo::Page, + "Para" => VertRelTo::Para, + _ => table.common.vert_rel_to, + }; + let bits: u32 = match table.common.vert_rel_to { + VertRelTo::Paper => 0, + VertRelTo::Page => 1, + VertRelTo::Para => 2, }; table.attr = (table.attr & !(0x03 << 3)) | (bits << 3); } if let Some(v) = json_str(json, "vertAlign") { - let bits: u32 = match v.as_str() { - "Top" => 0, "Center" => 1, "Bottom" => 2, "Inside" => 3, "Outside" => 4, _ => 0 + table.common.vert_align = match v.as_str() { + "Top" => VertAlign::Top, + "Center" => VertAlign::Center, + "Bottom" => VertAlign::Bottom, + "Inside" => VertAlign::Inside, + "Outside" => VertAlign::Outside, + _ => table.common.vert_align, + }; + let bits: u32 = match table.common.vert_align { + VertAlign::Top => 0, + VertAlign::Center => 1, + VertAlign::Bottom => 2, + VertAlign::Inside => 3, + VertAlign::Outside => 4, }; table.attr = (table.attr & !(0x07 << 5)) | (bits << 5); } if let Some(v) = json_str(json, "horzRelTo") { - let bits: u32 = match v.as_str() { - "Paper" => 0, "Page" => 1, "Column" => 2, "Para" => 3, _ => 0 + table.common.horz_rel_to = match v.as_str() { + "Paper" => HorzRelTo::Paper, + "Page" => HorzRelTo::Page, + "Column" => HorzRelTo::Column, + "Para" => HorzRelTo::Para, + _ => table.common.horz_rel_to, + }; + let bits: u32 = match table.common.horz_rel_to { + HorzRelTo::Paper => 0, + HorzRelTo::Page => 1, + HorzRelTo::Column => 2, + HorzRelTo::Para => 3, }; table.attr = (table.attr & !(0x03 << 8)) | (bits << 8); } if let Some(v) = json_str(json, "horzAlign") { - let bits: u32 = match v.as_str() { - "Left" => 0, "Center" => 1, "Right" => 2, "Inside" => 3, "Outside" => 4, _ => 0 + table.common.horz_align = match v.as_str() { + "Left" => HorzAlign::Left, + "Center" => HorzAlign::Center, + "Right" => HorzAlign::Right, + "Inside" => HorzAlign::Inside, + "Outside" => HorzAlign::Outside, + _ => table.common.horz_align, + }; + let bits: u32 = match table.common.horz_align { + HorzAlign::Left => 0, + HorzAlign::Center => 1, + HorzAlign::Right => 2, + HorzAlign::Inside => 3, + HorzAlign::Outside => 4, }; table.attr = (table.attr & !(0x07 << 10)) | (bits << 10); } - // 위치 오프셋: raw_ctrl_data - while table.raw_ctrl_data.len() < 8 { - table.raw_ctrl_data.push(0); - } if let Some(v) = json_i32(json, "vertOffset") { - table.raw_ctrl_data[0..4].copy_from_slice(&v.to_le_bytes()); + table.common.vertical_offset = v.max(0) as u32; } if let Some(v) = json_i32(json, "horzOffset") { - table.raw_ctrl_data[4..8].copy_from_slice(&v.to_le_bytes()); + table.common.horizontal_offset = v.max(0) as u32; + } + if let Some(v) = json_u32(json, "tableWidth") { + table.common.width = v; + } + if let Some(v) = json_u32(json, "tableHeight") { + table.common.height = v; } // restrictInPage → attr bit 13 if let Some(v) = json_bool(json, "restrictInPage") { @@ -953,30 +1048,33 @@ impl DocumentCore { if let Some(v) = json_bool(json, "allowOverlap") { if v { table.attr |= 1 << 14; } else { table.attr &= !(1 << 14); } } - // keepWithAnchor → raw_ctrl_data[32..36] (prevent_page_break) if let Some(v) = json_bool(json, "keepWithAnchor") { - while table.raw_ctrl_data.len() < 36 { - table.raw_ctrl_data.push(0); - } - let val: i32 = if v { 1 } else { 0 }; - table.raw_ctrl_data[32..36].copy_from_slice(&val.to_le_bytes()); + table.common.prevent_page_break = if v { 1 } else { 0 }; } - // 바깥 여백 (raw_ctrl_data[20..28]) - if table.raw_ctrl_data.len() >= 28 { - if let Some(v) = json_i16(json, "outerLeft") { - table.raw_ctrl_data[20..22].copy_from_slice(&v.to_le_bytes()); - } - if let Some(v) = json_i16(json, "outerRight") { - table.raw_ctrl_data[22..24].copy_from_slice(&v.to_le_bytes()); - } - if let Some(v) = json_i16(json, "outerTop") { - table.raw_ctrl_data[24..26].copy_from_slice(&v.to_le_bytes()); - } - if let Some(v) = json_i16(json, "outerBottom") { - table.raw_ctrl_data[26..28].copy_from_slice(&v.to_le_bytes()); - } + if let Some(v) = json_i16(json, "outerLeft") { + table.outer_margin_left = v; + table.common.margin.left = v; } + if let Some(v) = json_i16(json, "outerRight") { + table.outer_margin_right = v; + table.common.margin.right = v; + } + if let Some(v) = json_i16(json, "outerTop") { + table.outer_margin_top = v; + table.common.margin.top = v; + } + if let Some(v) = json_i16(json, "outerBottom") { + table.outer_margin_bottom = v; + table.common.margin.bottom = v; + } + + // `table.attr` is the HWPTAG_TABLE record attr, not CommonObjAttr. + // Force CommonObjAttr serialization to pack from the common enum fields. + table.common.attr = 0; + table.raw_ctrl_data = + crate::document_core::converters::common_obj_attr_writer::serialize_common_obj_attr(&table.common); + table.dirty = true; // 캡션 생성/수정 let mut caption_created = false; diff --git a/src/document_core/commands/text_editing.rs b/src/document_core/commands/text_editing.rs index 413cf9502..864923f74 100644 --- a/src/document_core/commands/text_editing.rs +++ b/src/document_core/commands/text_editing.rs @@ -461,6 +461,7 @@ impl DocumentCore { let margin_left = para_style.map(|s| s.margin_left).unwrap_or(0.0); let margin_right = para_style.map(|s| s.margin_right).unwrap_or(0.0); let final_width = (available_width - margin_left - margin_right).max(0.0); + let final_width_hwp = crate::renderer::px_to_hwpunit(final_width, self.dpi).max(1); // 가변 참조로 리플로우 실행 match self.document.sections[section_idx] @@ -470,6 +471,10 @@ impl DocumentCore { if let Some(cell) = table.cells.get_mut(cell_idx) { if let Some(cell_para) = cell.paragraphs.get_mut(cell_para_idx) { reflow_line_segs(cell_para, final_width, &self.styles, self.dpi); + for line_seg in &mut cell_para.line_segs { + line_seg.column_start = 0; + line_seg.segment_width = final_width_hwp; + } } } } diff --git a/src/document_core/html_table_import.rs b/src/document_core/html_table_import.rs index a43ba24d0..61c82b709 100644 --- a/src/document_core/html_table_import.rs +++ b/src/document_core/html_table_import.rs @@ -436,22 +436,29 @@ impl DocumentCore { let total_width: u32 = col_widths.iter().sum(); let total_height: u32 = row_heights.iter().sum(); - // raw_ctrl_data: CommonObjAttr (table.attr 이후 데이터) - // [0..4] vertical_offset, [4..8] horizontal_offset, - // [8..12] width, [12..16] height, [16..20] z_order, - // [20..22] margin.left, [22..24] margin.right, - // [24..26] margin.top, [26..28] margin.bottom, - // [28..32] instance_id, [32..34] desc_len(=0) + // table.attr: 기존 문서의 표와 동일한 패턴 사용 + // 0x082A2311 = treat_as_char | vert_rel_to=Para | horz_rel_to=Column | + // allow_overlap | width_criterion | various layout flags + // 정상 HWP 파일의 모든 표에서 사용되는 표준값 + let table_attr: u32 = 0x082A2311; + + // raw_ctrl_data: CommonObjAttr + // [0..4] attr, [4..8] vertical_offset, [8..12] horizontal_offset, + // [12..16] width, [16..20] height, [20..24] z_order, + // [24..26] margin.left, [26..28] margin.right, + // [28..30] margin.top, [30..32] margin.bottom, + // [32..36] instance_id, [36..38] desc_len(=0) let outer_margin: i16 = 283; // 바깥 여백 ~1mm let mut raw_ctrl_data = vec![0u8; 38]; // 32(base) + 2(desc_len) + 4(extra) - raw_ctrl_data[8..12].copy_from_slice(&total_width.to_le_bytes()); - raw_ctrl_data[12..16].copy_from_slice(&total_height.to_le_bytes()); + raw_ctrl_data[0..4].copy_from_slice(&table_attr.to_le_bytes()); + raw_ctrl_data[12..16].copy_from_slice(&total_width.to_le_bytes()); + raw_ctrl_data[16..20].copy_from_slice(&total_height.to_le_bytes()); // 바깥 여백 (left, right, top, bottom) - raw_ctrl_data[20..22].copy_from_slice(&outer_margin.to_le_bytes()); - raw_ctrl_data[22..24].copy_from_slice(&outer_margin.to_le_bytes()); raw_ctrl_data[24..26].copy_from_slice(&outer_margin.to_le_bytes()); raw_ctrl_data[26..28].copy_from_slice(&outer_margin.to_le_bytes()); - // [28..32] instance_id (DIFF-7 수정: 해시 기반 유니크 값 생성) + raw_ctrl_data[28..30].copy_from_slice(&outer_margin.to_le_bytes()); + raw_ctrl_data[30..32].copy_from_slice(&outer_margin.to_le_bytes()); + // [32..36] instance_id (DIFF-7 수정: 해시 기반 유니크 값 생성) // 정상 HWP 파일에서는 instance_id가 고유한 비-0 값을 가짐 let instance_id: u32 = { // 행/열 수, 셀 수, 총 폭/높이를 조합한 간단한 해시 @@ -464,8 +471,8 @@ impl DocumentCore { if h == 0 { h = 0x7c154b69; } // 절대 0이 되지 않도록 h }; - raw_ctrl_data[28..32].copy_from_slice(&instance_id.to_le_bytes()); - // [32..34] desc_len = 0, [34..38] reserved = 0 + raw_ctrl_data[32..36].copy_from_slice(&instance_id.to_le_bytes()); + // [36..38] desc_len = 0 // row_sizes: 각 행의 셀 수 let row_sizes: Vec = (0..row_count) @@ -495,12 +502,6 @@ impl DocumentCore { bottom: if table_padding_pt[3] > 0.01 { (table_padding_pt[3] * 100.0).round() as i16 } else { 141 }, }; - // table.attr: 기존 문서의 표와 동일한 패턴 사용 - // 0x082A2311 = treat_as_char | vert_rel_to=Para | horz_rel_to=Column | - // allow_overlap | width_criterion | various layout flags - // 정상 HWP 파일의 모든 표에서 사용되는 표준값 - let table_attr: u32 = 0x082A2311; - // raw_table_record_attr: 정상 파일 패턴 기반 (DIFF-5 수정) // bit 1: 셀 분리 금지 (항상 설정), bit 2: repeat_header // bit 26: 추가 레이아웃 속성 diff --git a/src/model/table.rs b/src/model/table.rs index f983b8c88..66d6aed18 100644 --- a/src/model/table.rs +++ b/src/model/table.rs @@ -239,17 +239,19 @@ impl Table { /// raw_ctrl_data 내 CommonObjAttr의 width/height를 재계산하여 갱신한다. /// - /// raw_ctrl_data 레이아웃 (attr 4바이트 이후): - /// [0..4] vertical_offset, [4..8] horizontal_offset, - /// [8..12] width, [12..16] height, ... + /// raw_ctrl_data 레이아웃: + /// [0..4] attr, [4..8] vertical_offset, [8..12] horizontal_offset, + /// [12..16] width, [16..20] height, ... pub fn update_ctrl_dimensions(&mut self) { - if self.raw_ctrl_data.len() < 16 { + if self.raw_ctrl_data.len() < 20 { return; } let total_width: HwpUnit = self.get_column_widths().iter().sum(); let total_height: HwpUnit = self.get_row_heights().iter().sum(); - self.raw_ctrl_data[8..12].copy_from_slice(&total_width.to_le_bytes()); - self.raw_ctrl_data[12..16].copy_from_slice(&total_height.to_le_bytes()); + self.common.width = total_width; + self.common.height = total_height; + self.raw_ctrl_data[12..16].copy_from_slice(&total_width.to_le_bytes()); + self.raw_ctrl_data[16..20].copy_from_slice(&total_height.to_le_bytes()); } /// 열별 폭을 추출한다 (col_span==1인 셀 기준). diff --git a/src/parser/hwpx/section.rs b/src/parser/hwpx/section.rs index d2f6da4ef..5084f4928 100644 --- a/src/parser/hwpx/section.rs +++ b/src/parser/hwpx/section.rs @@ -745,14 +745,10 @@ fn parse_table( buf.clear(); } - // row_sizes 설정 (행별 셀 높이의 최대값) + // row_sizes는 HWP TABLE 레코드의 행별 셀 수이다. for r in 0..table.row_count { - let max_h = table.cells.iter() - .filter(|c| c.row == r && c.row_span == 1) - .map(|c| c.height as i16) - .max() - .unwrap_or(0); - row_sizes.push(max_h); + let cell_count = table.cells.iter().filter(|c| c.row == r).count() as i16; + row_sizes.push(cell_count); } table.row_sizes = row_sizes; diff --git a/src/renderer/composer/line_breaking.rs b/src/renderer/composer/line_breaking.rs index 834baf20e..7014c7304 100644 --- a/src/renderer/composer/line_breaking.rs +++ b/src/renderer/composer/line_breaking.rs @@ -626,6 +626,12 @@ pub(crate) fn reflow_line_segs( ) { // 기존 LineSeg에서 dimension 값 보존 (원본 HWP 호환성 유지) let seg_width_hwp = px_to_hwpunit(available_width_px, dpi); + let wrap_column_start_hwp = square_wrap_column_start_hwp(para); + let wrap_segment_width_hwp = if wrap_column_start_hwp > 0 { + (seg_width_hwp - wrap_column_start_hwp).max(1) + } else { + seg_width_hwp + }; let orig = para.line_segs.first().cloned(); let has_valid_orig = orig.as_ref().map(|ls| ls.line_height > 0).unwrap_or(false); @@ -649,7 +655,8 @@ pub(crate) fn reflow_line_segs( text_height: text_height_hwp, baseline_distance: baseline_distance_hwp, line_spacing: line_spacing_hwp, - segment_width: seg_width_hwp, + column_start: wrap_column_start_hwp, + segment_width: wrap_segment_width_hwp, tag: if orig_tag != 0 { orig_tag } else { 0x00060000 }, ..Default::default() } @@ -737,6 +744,36 @@ pub(crate) fn reflow_line_segs( para.line_segs = new_line_segs; } +fn square_wrap_column_start_hwp(para: &Paragraph) -> i32 { + para.controls + .iter() + .filter_map(|control| match control { + crate::model::control::Control::Table(table) + if !table.common.treat_as_char + && matches!( + table.common.text_wrap, + crate::model::shape::TextWrap::Square + | crate::model::shape::TextWrap::Tight + | crate::model::shape::TextWrap::Through + ) => + { + let width = if table.common.width > 0 { + table.common.width + } else { + table.get_column_widths().iter().sum() + }; + Some( + width as i32 + + table.common.margin.left as i32 + + table.common.margin.right as i32, + ) + } + _ => None, + }) + .max() + .unwrap_or(0) +} + /// 구역 내 문단들의 vertical_pos를 순차적으로 재계산한다. /// /// `start_para`부터 구역 끝까지 각 문단의 vpos를 이전 문단의 vpos_end 기준으로 재계산. diff --git a/src/wasm_api/tests.rs b/src/wasm_api/tests.rs index 146166950..38e3e21d4 100644 --- a/src/wasm_api/tests.rs +++ b/src/wasm_api/tests.rs @@ -224,6 +224,74 @@ assert_eq!(&bytes[0..4], &[0xD0, 0xCF, 0x11, 0xE0]); } + fn assert_minimal_hwp_writer_streams(bytes: &[u8]) { + let mut cfb = crate::parser::cfb_reader::CfbReader::open(bytes) + .expect("writer output must be readable as HWP CFB"); + + assert!(cfb.has_stream("/FileHeader")); + assert!(cfb.has_stream("/DocInfo")); + assert!(cfb.has_stream("/BodyText/Section0")); + + let header = cfb + .read_file_header() + .expect("writer output must include a readable FileHeader stream"); + assert_eq!(header.len(), 256); + + let parsed = crate::parser::parse_hwp(bytes) + .expect("writer output must be reloadable through the HWP parser"); + assert_eq!(parsed.doc_properties.section_count, 1); + assert_eq!(parsed.sections.len(), 1); + assert_eq!(parsed.header.version.major, 5); + } + + fn create_blank_hwp_writer_document() -> HwpDocument { + let mut doc = HwpDocument::create_empty(); + doc.create_blank_document_native() + .expect("blank HWP template should initialize an editable writer document"); + doc + } + + #[test] + fn test_hwp_writer_contract_blank_document_roundtrip() { + let doc = create_blank_hwp_writer_document(); + + let bytes = doc + .export_hwp_native() + .expect("blank document must export as HWP bytes"); + + assert!(bytes.len() > 512); + assert_eq!(&bytes[0..4], &[0xD0, 0xCF, 0x11, 0xE0]); + assert_minimal_hwp_writer_streams(&bytes); + + let reloaded = HwpDocument::from_bytes(&bytes) + .expect("exported blank HWP must reload"); + assert_eq!(reloaded.page_count(), 1); + } + + #[test] + fn test_hwp_writer_contract_inserted_text_roundtrip() { + let mut doc = create_blank_hwp_writer_document(); + let text = "한글 English 123"; + + doc.insert_text_native(0, 0, 0, text) + .expect("text insertion should prepare writer input"); + let bytes = doc + .export_hwp_native() + .expect("edited document must export as HWP bytes"); + + assert_minimal_hwp_writer_streams(&bytes); + + let reloaded = HwpDocument::from_bytes(&bytes) + .expect("exported edited HWP must reload"); + let paragraph_text = &reloaded.document.sections[0].paragraphs[0].text; + assert!( + paragraph_text.contains("한글") + && paragraph_text.contains("English") + && paragraph_text.contains("123"), + "reloaded paragraph should preserve mixed text, got: {paragraph_text:?}" + ); + } + #[test] fn test_hwp_error_display() { let err = HwpError::InvalidFile("테스트".to_string()); @@ -15702,6 +15770,152 @@ eprintln!(" 인라인 TAC 표 생성 테스트 통과"); } + #[test] + fn test_set_table_properties_syncs_square_wrapping() { + let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + + doc.insert_text_native(0, 0, 0, "anchor").unwrap(); + doc.create_table_ex_native(0, 0, 0, 3, 2, false, Some(&[400, 400])).unwrap(); + + let (para_idx, ctrl_idx) = doc.document.sections[0].paragraphs.iter() + .enumerate() + .find_map(|(pi, para)| { + para.controls.iter().enumerate().find_map(|(ci, ctrl)| { + if matches!(ctrl, crate::model::control::Control::Table(_)) { + Some((pi, ci)) + } else { + None + } + }) + }) + .expect("생성된 표 컨트롤"); + + doc.set_table_properties_native( + 0, + para_idx, + ctrl_idx, + r#"{"treatAsChar":false,"textWrap":"Square","vertRelTo":"Para","vertAlign":"Top","horzRelTo":"Column","horzAlign":"Left","vertOffset":123,"horzOffset":456,"tableWidth":800,"tableHeight":1200,"outerLeft":35,"outerRight":35,"outerTop":70,"outerBottom":70,"restrictInPage":true,"allowOverlap":false,"keepWithAnchor":true}"#, + ).unwrap(); + + let props = doc.get_table_properties_native(0, para_idx, ctrl_idx).unwrap(); + assert!(props.contains("\"textWrap\":\"Square\""), "props={}", props); + assert!(props.contains("\"horzRelTo\":\"Column\""), "props={}", props); + assert!(props.contains("\"vertOffset\":123"), "props={}", props); + assert!(props.contains("\"horzOffset\":456"), "props={}", props); + assert!(props.contains("\"outerLeft\":35"), "props={}", props); + assert!(props.contains("\"keepWithAnchor\":true"), "props={}", props); + + if let crate::model::control::Control::Table(t) = + &doc.document.sections[0].paragraphs[para_idx].controls[ctrl_idx] + { + assert!(!t.common.treat_as_char); + assert_eq!(t.common.text_wrap, crate::model::shape::TextWrap::Square); + assert_eq!(t.common.horz_rel_to, crate::model::shape::HorzRelTo::Column); + assert_eq!(t.common.vert_rel_to, crate::model::shape::VertRelTo::Para); + assert_eq!(t.common.vertical_offset, 123); + assert_eq!(t.common.horizontal_offset, 456); + assert_eq!(t.common.prevent_page_break, 1); + let reparsed = crate::parser::control::parse_common_obj_attr(&t.raw_ctrl_data); + assert!(!reparsed.treat_as_char); + assert_eq!(reparsed.text_wrap, crate::model::shape::TextWrap::Square); + assert_eq!(reparsed.horz_rel_to, crate::model::shape::HorzRelTo::Column); + assert_eq!(reparsed.vert_rel_to, crate::model::shape::VertRelTo::Para); + assert_eq!(reparsed.width_criterion, crate::model::shape::SizeCriterion::Absolute); + assert_eq!(reparsed.height_criterion, crate::model::shape::SizeCriterion::Absolute); + assert_eq!(reparsed.vertical_offset, 123); + assert_eq!(reparsed.horizontal_offset, 456); + assert_eq!(reparsed.margin.left, 35); + assert_eq!(reparsed.margin.top, 70); + assert_eq!(reparsed.prevent_page_break, 1); + } else { + panic!("생성된 컨트롤이 표가 아님"); + } + } + + #[test] + fn test_set_cell_properties_keeps_width_with_border_widths() { + let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + + doc.create_table_ex_native(0, 0, 0, 1, 1, true, Some(&[400])).unwrap(); + let (para_idx, ctrl_idx) = doc.document.sections[0].paragraphs.iter() + .enumerate() + .find_map(|(pi, para)| { + para.controls.iter().enumerate().find_map(|(ci, ctrl)| { + if matches!(ctrl, crate::model::control::Control::Table(_)) { + Some((pi, ci)) + } else { + None + } + }) + }) + .expect("생성된 표 컨트롤"); + + doc.set_cell_properties_native( + 0, + para_idx, + ctrl_idx, + 0, + r##"{"width":400,"height":760,"borderLeft":{"type":1,"width":1,"color":"#000000"},"borderRight":{"type":0,"width":0,"color":"#000000"},"borderTop":{"type":1,"width":1,"color":"#000000"},"borderBottom":{"type":1,"width":1,"color":"#000000"}}"##, + ) + .unwrap(); + + if let crate::model::control::Control::Table(t) = + &doc.document.sections[0].paragraphs[para_idx].controls[ctrl_idx] + { + assert_eq!(t.cells[0].width, 400); + assert_eq!(t.cells[0].height, 760); + } else { + panic!("생성된 컨트롤이 표가 아님"); + } + } + + #[test] + fn test_update_ctrl_dimensions_writes_common_obj_width_offsets() { + let bytes = std::fs::read("saved/blank2010.hwp").expect("blank2010.hwp 읽기 실패"); + let mut doc = HwpDocument::from_bytes(&bytes).unwrap(); + doc.convert_to_editable_native().unwrap(); + + doc.create_table_ex_native(0, 0, 0, 3, 2, true, Some(&[1, 24])).unwrap(); + let (para_idx, ctrl_idx) = doc.document.sections[0].paragraphs.iter() + .enumerate() + .find_map(|(pi, para)| { + para.controls.iter().enumerate().find_map(|(ci, ctrl)| { + if matches!(ctrl, crate::model::control::Control::Table(_)) { + Some((pi, ci)) + } else { + None + } + }) + }) + .expect("생성된 표 컨트롤"); + + if let crate::model::control::Control::Table(t) = + &mut doc.document.sections[0].paragraphs[para_idx].controls[ctrl_idx] + { + for cell in &mut t.cells { + cell.width = if cell.col == 0 { 1 } else { 24 }; + cell.height = match cell.row { + 1 => 260, + _ => 4200, + }; + } + t.update_ctrl_dimensions(); + + let reparsed = crate::parser::control::parse_common_obj_attr(&t.raw_ctrl_data); + assert_eq!(reparsed.horizontal_offset, 0); + assert_eq!(reparsed.width, 25); + assert_eq!(reparsed.height, 8660); + assert_eq!(t.common.width, 25); + assert_eq!(t.common.height, 8660); + } else { + panic!("생성된 컨트롤이 표가 아님"); + } + } + #[test] fn test_extract_thumbnail_with_preview() { // PrvImage가 있는 HWP 파일 테스트