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);
+}