diff --git a/src/document_core/commands/object_ops/shape.rs b/src/document_core/commands/object_ops/shape.rs index bbffb1219b..211790d5f9 100644 --- a/src/document_core/commands/object_ops/shape.rs +++ b/src/document_core/commands/object_ops/shape.rs @@ -1297,7 +1297,7 @@ impl DocumentCore { let has_textbox = shape_type == "textbox"; let has_fill = shape_type != "line" && !is_connector; - let drawing = DrawingObjAttr { + let mut drawing = DrawingObjAttr { shape_attr: ShapeComponentAttr { ctrl_id, is_two_ctrl_id: true, @@ -1356,6 +1356,7 @@ impl DocumentCore { inst_id: (instance_id & 0x3FFFFFFF) + 1, ..Default::default() }; + drawing.sync_text_box_storage_bit(); let shape_obj = match shape_type { "line" diff --git a/src/document_core/queries/hwpctrl_sets.rs b/src/document_core/queries/hwpctrl_sets.rs index ce65f5725e..cb7fa22a31 100644 --- a/src/document_core/queries/hwpctrl_sets.rs +++ b/src/document_core/queries/hwpctrl_sets.rs @@ -1666,6 +1666,7 @@ impl DocumentCore { drawing.text_box.take().is_some() }; if changed { + drawing.sync_text_box_storage_bit(); section.raw_stream = None; } Ok(format!("{{\"ok\":{}}}", changed)) diff --git a/src/model/shape.rs b/src/model/shape.rs index f7011456ce..6a6326774e 100644 --- a/src/model/shape.rs +++ b/src/model/shape.rs @@ -348,6 +348,24 @@ pub struct DrawingObjAttr { pub caption: Option, } +impl DrawingObjAttr { + /// SHAPE_COMPONENT 저장 워드(`flip`)의 글상자 비트. + pub const TEXT_BOX_STORAGE_BIT: u32 = 0x0100_0000; + + /// 글상자 비트를 `text_box` 유무에 맞춘다 (#8172). + /// + /// 한컴은 글상자가 있는 도형에 이 비트가 없으면 HWP를 손상 문서로 거부하고, + /// 글상자 없는 도형에 세워도 거부한다(`hwpx_to_hwp`의 같은 계약). 편집으로 + /// 글상자를 만들거나 붙이거나 떼는 경로는 이 함수로 비트를 맞춘다. + pub fn sync_text_box_storage_bit(&mut self) { + if self.text_box.is_some() { + self.shape_attr.flip |= Self::TEXT_BOX_STORAGE_BIT; + } else { + self.shape_attr.flip &= !Self::TEXT_BOX_STORAGE_BIT; + } + } +} + /// 글상자 (그리기 개체 내 텍스트) #[derive(Debug, Default, Clone, serde::Serialize)] pub struct TextBox { diff --git a/tests/cases/issue_8172_textbox_shape_storage_bit.rs b/tests/cases/issue_8172_textbox_shape_storage_bit.rs new file mode 100644 index 0000000000..f95449e4f1 --- /dev/null +++ b/tests/cases/issue_8172_textbox_shape_storage_bit.rs @@ -0,0 +1,116 @@ +//! #8172: 편집으로 만든 글상자는 SHAPE_COMPONENT 저장 워드에 글상자 비트(0x0100_0000)를 담는다. +//! 공개 편집 API로 만든 메모리 입력이다. 한컴 2024가 이 비트 없는 글상자 HWP를 거부하고 +//! 이 비트만 세우면 정상으로 여는 것은 이슈의 MCP 실측이 근거다. 이 검사는 그 저장 계약만 본다. +use rhwp::document_core::DocumentCore; +use rhwp::model::control::Control; +use rhwp::model::shape::DrawingObjAttr; + +const BIT: u32 = DrawingObjAttr::TEXT_BOX_STORAGE_BIT; + +fn core_with(kinds: &[&str]) -> DocumentCore { + let mut core = DocumentCore::new_empty(); + core.create_blank_document_native().unwrap(); + for (i, kind) in kinds.iter().enumerate() { + core.create_shape_control_native( + 0, + 0, + 0, + 17008, + 5669, + (i as u32) * 20000, + 2000, + false, + "TopAndBottom", + kind, + false, + false, + &[], + ) + .unwrap(); + } + core +} + +/// 첫 문단의 그리기 개체를 (컨트롤 번호, 글상자 유무, 저장 워드) 로 앞에서부터 돌려준다. +fn shapes(core: &DocumentCore) -> Vec<(usize, bool, u32)> { + core.document().sections[0].paragraphs[0] + .controls + .iter() + .enumerate() + .filter_map(|(i, c)| match c { + Control::Shape(s) => s + .drawing() + .map(|d| (i, d.text_box.is_some(), d.shape_attr.flip)), + _ => None, + }) + .collect() +} + +fn assert_bit_matches_text_box(core: &DocumentCore, expected_shapes: usize, stage: &str) { + let found = shapes(core); + assert_eq!(found.len(), expected_shapes, "{stage}: 그리기 개체 수"); + for (i, has_text_box, flip) in found { + assert_eq!( + flip & BIT != 0, + has_text_box, + "{stage}: 컨트롤 {i} 글상자={has_text_box} flip={flip:#010x}" + ); + // 한컴 기본 비트는 글상자 유무와 관계없이 유지한다. + assert_ne!(flip & 0x0008_0000, 0, "{stage}: 컨트롤 {i} 기본 비트"); + } +} + +#[test] +fn created_textbox_carries_storage_bit_and_rectangle_does_not() { + let core = core_with(&["rectangle", "textbox"]); + let found = shapes(&core); + assert_eq!( + found.iter().map(|s| s.1).collect::>(), + vec![false, true], + "사각형 하나와 글상자 하나" + ); + assert_bit_matches_text_box(&core, 2, "생성 직후"); +} + +#[test] +fn storage_bit_survives_hwp_save_and_reopen() { + let core = core_with(&["rectangle", "textbox"]); + for (name, bytes) in [ + ("export_hwp_native", core.export_hwp_native().unwrap()), + ( + "export_hwp_with_adapter_snapshot", + core.export_hwp_with_adapter_snapshot().unwrap(), + ), + ] { + let reopened = DocumentCore::from_bytes(&bytes).unwrap(); + assert_bit_matches_text_box(&reopened, 2, name); + assert_eq!( + shapes(&reopened).iter().filter(|s| s.1).count(), + 1, + "{name}: 글상자 하나 보존" + ); + } +} + +#[test] +fn attaching_and_detaching_text_box_keeps_storage_bit_in_sync() { + let mut core = core_with(&["rectangle"]); + let (rect, has_text_box, _) = shapes(&core)[0]; + assert!(!has_text_box); + + assert_eq!( + core.set_text_box_at(0, rect, true).unwrap(), + r#"{"ok":true}"# + ); + assert_bit_matches_text_box(&core, 1, "글상자 붙인 뒤"); + assert!(shapes(&core)[0].1); + let reopened = DocumentCore::from_bytes(&core.export_hwp_native().unwrap()).unwrap(); + assert_bit_matches_text_box(&reopened, 1, "붙인 뒤 저장·다시 열기"); + + assert_eq!( + core.set_text_box_at(0, rect, false).unwrap(), + r#"{"ok":true}"# + ); + assert_bit_matches_text_box(&core, 1, "글상자 뗀 뒤"); + assert!(!shapes(&core)[0].1); +}