From b6b1d239d43206de362001cd32b6835d757371f3 Mon Sep 17 00:00:00 2001 From: ubermensch1218 <15313188+ubermensch1218@users.noreply.github.com> Date: Wed, 13 May 2026 21:09:32 +0900 Subject: [PATCH 1/4] Protect the first HWP writer contract The writer path already has a native export entry point, but it lacked a small public-facing contract that proves a blank editable HWP can serialize, reload, and preserve mixed text after insertion. This adds that seam at the WASM/native wrapper level without expanding writer implementation scope. Constraint: Follow-on writer work should target .hwp output first and keep Swift/iOS work on top of a stable Rust writer contract. Rejected: Use HWPX generation as the first contract | the current milestone is HWP writer introduction, not HWPX export. Confidence: high Scope-risk: narrow Directive: Do not weaken the FileHeader, DocInfo, and BodyText/Section0 assertions without replacing them with an equivalent HWP validity check. Tested: cargo test --lib hwp_writer_contract -- --nocapture Tested: cargo test --lib test_export_hwp_empty -- --nocapture Tested: git diff --check (cherry picked from commit 807758c1411f2cc8dc4741ce6b5e5a5abd04a977) --- src/wasm_api/tests.rs | 68 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/wasm_api/tests.rs b/src/wasm_api/tests.rs index 14616695..972a7429 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()); From 9a41d69ab9ac5cfd7d4a0d355d5a00168f2dc5ef Mon Sep 17 00:00:00 2001 From: ubermensch1218 <15313188+ubermensch1218@users.noreply.github.com> Date: Sat, 16 May 2026 02:35:02 +0900 Subject: [PATCH 2/4] Preserve floating table geometry for generated HWP The CSAT HWP generator needs passage markers that are real table controls with square wrapping. setTableProperties previously updated attr bits and raw bytes inconsistently, so reopened files lost relation, offset, margin, and anchor state. Constraint: HWP table controls serialize object placement through CommonObjAttr. Rejected: Patch generated files after export | leaves rhwp unable to round-trip table placement. Confidence: high Scope-risk: narrow Directive: Keep table.common, table.attr, and raw_ctrl_data synchronized when adding table placement fields. Tested: cargo test test_set_table_properties_syncs_square_wrapping Not-tested: Full rhwp test suite (cherry picked from commit dfa2d4a95e693aaf4b26a8ea9790cbba3c035124) --- src/document_core/commands/table_ops.rs | 161 ++++++++++++++++-------- src/wasm_api/tests.rs | 62 +++++++++ 2 files changed, 173 insertions(+), 50 deletions(-) diff --git a/src/document_core/commands/table_ops.rs b/src/document_core/commands/table_ops.rs index 036ecb72..94f9e585 100644 --- a/src/document_core/commands/table_ops.rs +++ b/src/document_core/commands/table_ops.rs @@ -787,14 +787,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 +855,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 +886,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 +904,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 +1013,31 @@ 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.common.attr = table.attr; + 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/wasm_api/tests.rs b/src/wasm_api/tests.rs index 972a7429..553f7400 100644 --- a/src/wasm_api/tests.rs +++ b/src/wasm_api/tests.rs @@ -15770,6 +15770,68 @@ 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.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_extract_thumbnail_with_preview() { // PrvImage가 있는 HWP 파일 테스트 From dd270516d953b70e834289804cf25b4108b2f0a8 Mon Sep 17 00:00:00 2001 From: ubermensch1218 <15313188+ubermensch1218@users.noreply.github.com> Date: Sat, 16 May 2026 13:07:47 +0900 Subject: [PATCH 3/4] Stabilize generated table geometry Generated CSAT-style HWP output was relying on nested table geometry, but the binary table metadata could drift from the visible cell settings. This keeps table dimensions and row metadata aligned with the HWP CommonObjAttr layout before ggugeo consumes the WASM build. Constraint: Hancom reads serialized CommonObjAttr and table row-size records, not just high-level cell JSON Rejected: Keep patching only ggugeo marker constants | the width symptoms came from rhwp geometry serialization as well Confidence: medium Scope-risk: moderate Directive: Do not change CommonObjAttr offsets without checking parser/control/shape.rs layout Tested: cargo test test_update_ctrl_dimensions_writes_common_obj_width_offsets Tested: cargo test test_set_cell_properties_keeps_width_with_border_widths Tested: cargo test test_set_table_properties_syncs_square_wrapping (cherry picked from commit 25d0d86c6e9d509872f55667a6f98d78b7ff1efa) --- src/document_core/commands/table_ops.rs | 12 +++- src/document_core/html_table_import.rs | 39 ++++++------ src/model/table.rs | 14 +++-- src/parser/hwpx/section.rs | 10 +-- src/wasm_api/tests.rs | 82 +++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 33 deletions(-) diff --git a/src/document_core/commands/table_ops.rs b/src/document_core/commands/table_ops.rs index 94f9e585..4782b062 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,11 +388,17 @@ impl DocumentCore { ) -> Result { use super::super::helpers::{json_u32, json_i16, json_u8, json_bool}; + let parsed_json = serde_json::from_str::(json).ok(); 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; } + let width = if parsed_json.is_some() { + top_level_u32(parsed_json.as_ref(), "width") + } else { + json_u32(json, "width") + }; + 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; } diff --git a/src/document_core/html_table_import.rs b/src/document_core/html_table_import.rs index a43ba24d..61c82b70 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 f983b8c8..66d6aed1 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 d2f6da4e..5084f492 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/wasm_api/tests.rs b/src/wasm_api/tests.rs index 553f7400..01afb938 100644 --- a/src/wasm_api/tests.rs +++ b/src/wasm_api/tests.rs @@ -15832,6 +15832,88 @@ } } + #[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 파일 테스트 From d021efbae3e90aa229a4b9b07bbea3def957846f Mon Sep 17 00:00:00 2001 From: ubermensch1218 <15313188+ubermensch1218@users.noreply.github.com> Date: Sat, 16 May 2026 14:57:23 +0900 Subject: [PATCH 4/4] Make generated table geometry survive Hancom Hancom ignores the intended narrow floating-table width when the serialized CommonObjAttr keeps the HWPTAG_TABLE record attr. The writer now forces CommonObjAttr packing from the common object fields, refreshes cell paragraph geometry after dimension changes, and gives square-wrapped generated tables matching line segment offsets. Constraint: Current fix was validated against an editable HWP CSAT marker case where Hancom only honored width after CommonObjAttr serialization stopped preserving the table record attr. Rejected: Caller-side width tuning only | Hancom continued to open the marker table too wide while the raw object attr stayed polluted. Confidence: medium Scope-risk: moderate Directive: Do not copy HWPTAG_TABLE attr into CommonObjAttr before serialization; those bitfields are distinct. Tested: cargo test test_set_table_properties_syncs_square_wrapping Tested: cargo test test_update_ctrl_dimensions_writes_common_obj_width_offsets Tested: cargo test test_set_cell_properties_keeps_width_with_border_widths Tested: git diff --check -- src/document_core/commands/table_ops.rs src/document_core/commands/text_editing.rs src/renderer/composer/line_breaking.rs src/wasm_api/tests.rs Not-tested: full cargo test suite (cherry picked from commit 2c8ae0d00adf108c9e25fca7a4ae9b8962fc227f) --- src/document_core/commands/table_ops.rs | 91 ++++++++++++++-------- src/document_core/commands/text_editing.rs | 5 ++ src/renderer/composer/line_breaking.rs | 39 +++++++++- src/wasm_api/tests.rs | 2 + 4 files changed, 104 insertions(+), 33 deletions(-) diff --git a/src/document_core/commands/table_ops.rs b/src/document_core/commands/table_ops.rs index 4782b062..6b6036b4 100644 --- a/src/document_core/commands/table_ops.rs +++ b/src/document_core/commands/table_ops.rs @@ -389,44 +389,57 @@ impl DocumentCore { use super::super::helpers::{json_u32, json_i16, json_u8, json_bool}; let parsed_json = serde_json::from_str::(json).ok(); - 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)))?; - let width = if parsed_json.is_some() { top_level_u32(parsed_json.as_ref(), "width") } else { json_u32(json, "width") }; - 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_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 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\""); @@ -460,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(); @@ -1044,7 +1069,9 @@ impl DocumentCore { table.common.margin.bottom = v; } - table.common.attr = table.attr; + // `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; diff --git a/src/document_core/commands/text_editing.rs b/src/document_core/commands/text_editing.rs index 413cf950..864923f7 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/renderer/composer/line_breaking.rs b/src/renderer/composer/line_breaking.rs index 834baf20..7014c730 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 01afb938..38e3e21d 100644 --- a/src/wasm_api/tests.rs +++ b/src/wasm_api/tests.rs @@ -15822,6 +15822,8 @@ 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);