Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/document_core/commands/object_ops/shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/document_core/queries/hwpctrl_sets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
18 changes: 18 additions & 0 deletions src/model/shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,24 @@ pub struct DrawingObjAttr {
pub caption: Option<Caption>,
}

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 {
Expand Down
116 changes: 116 additions & 0 deletions tests/cases/issue_8172_textbox_shape_storage_bit.rs
Original file line number Diff line number Diff line change
@@ -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<_>>(),
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);
}
Loading