From e481175479e623421f25aa57baef6beafec5d7d9 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Wed, 11 Nov 2026 00:44:38 +0900 Subject: [PATCH 1/8] test: expose text edits that shift opaque range tags --- crates/hwp-cli/tests/range_tag_edits.rs | 181 ++++++ crates/hwp-convert/src/edit.rs | 4 + .../hwp-convert/src/edit_range_tag_tests.rs | 601 ++++++++++++++++++ crates/hwp-convert/src/form/binding/tests.rs | 32 +- 4 files changed, 803 insertions(+), 15 deletions(-) create mode 100644 crates/hwp-cli/tests/range_tag_edits.rs create mode 100644 crates/hwp-convert/src/edit_range_tag_tests.rs diff --git a/crates/hwp-cli/tests/range_tag_edits.rs b/crates/hwp-cli/tests/range_tag_edits.rs new file mode 100644 index 00000000..208fb10b --- /dev/null +++ b/crates/hwp-cli/tests/range_tag_edits.rs @@ -0,0 +1,181 @@ +//! End-to-end controlled refusals for opaque HWP5 paragraph range tags (#470). +//! Sentinels intentionally encode no tag layout; no genuine tagged sample is available. + +use std::path::PathBuf; +use std::process::Command; + +use hwp_model::{Control, OpaqueRecord}; + +#[path = "common/temp_path.rs"] +mod temp_path; + +struct Workspace { + dir: temp_path::TempPath, + source: PathBuf, + output: PathBuf, + bytes: Vec, +} + +impl Workspace { + fn new(label: &str, cell: bool, tagged: bool) -> Self { + let dir = temp_path::TempPath::new("hwp470", label); + std::fs::create_dir_all(&*dir).unwrap(); + let mut document = hwp_convert::from_markdown(if cell { + "| name | value |\n| --- | --- |\n| field | anchor |\n" + } else { + "anchor first\n\nanchor marked\n" + }); + let paragraph = if cell { + let table = document.sections[0] + .paragraphs + .iter_mut() + .flat_map(|paragraph| &mut paragraph.controls) + .find_map(|control| match control { + Control::Table(table) => Some(table), + _ => None, + }) + .unwrap(); + &mut table + .cells + .iter_mut() + .find(|cell| cell.row == 1 && cell.col == 1) + .unwrap() + .paragraphs[0] + } else { + &mut document.sections[0].paragraphs[1] + }; + if tagged { + paragraph.extras.push(OpaqueRecord { + tag: 0x46, + data: b"opaque range sentinel".to_vec(), + children: Vec::new(), + }); + } + let source = dir.join("source.hwp"); + hwp5::write_document(&document, &source, &hwp5::WriteOptions::default()).unwrap(); + // The public reader must retain the marker before this test can establish refusal. + let read = hwp5::read_document(&source).unwrap().document; + fn count(paragraphs: &[hwp_model::Paragraph]) -> usize { + paragraphs + .iter() + .map(|paragraph| { + paragraph + .extras + .iter() + .filter(|extra| extra.tag == 0x46) + .count() + + paragraph + .controls + .iter() + .map(|control| match control { + Control::Table(table) => { + table.cells.iter().map(|cell| count(&cell.paragraphs)).sum() + } + Control::Generic(generic) => generic + .paragraph_lists + .iter() + .map(|list| count(&list.paragraphs)) + .sum(), + _ => 0, + }) + .sum::() + }) + .sum() + } + assert_eq!( + read.sections + .iter() + .map(|section| count(§ion.paragraphs)) + .sum::(), + usize::from(tagged) + ); + let bytes = std::fs::read(&source).unwrap(); + Self { + output: dir.join("out.hwp"), + dir, + source, + bytes, + } + } + + fn edit(&self, args: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_hwp")) + .arg("edit") + .arg(&self.source) + .arg("-o") + .arg(&self.output) + .args(args) + .output() + .unwrap() + } + + fn assert_refusal(&self, result: std::process::Output) { + assert!( + !result.status.success(), + "a shifting edit on an opaque range tag must refuse" + ); + let message = String::from_utf8_lossy(&result.stderr); + assert!( + message.contains("PARA_RANGE_TAG"), + "missing range-tag cause: {message}" + ); + assert!( + message.contains("한글") && message.contains("같"), + "missing safe-edit remedy: {message}" + ); + assert!(!self.output.exists(), "a refused edit must not publish"); + assert_eq!(std::fs::read(&self.source).unwrap(), self.bytes); + } +} + +#[test] +fn global_replace_refuses_before_publication_when_a_later_match_is_tagged() { + let workspace = Workspace::new("global", false, true); + workspace.assert_refusal(workspace.edit(&["--replace", "anchor=>expanded anchor"])); +} + +#[test] +fn addressed_replace_refuses_before_publication_on_a_tagged_paragraph() { + let workspace = Workspace::new("addressed", false, true); + let ops = workspace.dir.join("ops.json"); + std::fs::write(&ops, r#"[{"op":"replace","address":{"at":{"section":0,"paragraph":1}},"from":"anchor","to":"expanded anchor"}]"#).unwrap(); + workspace.assert_refusal(workspace.edit(&["--ops", ops.to_str().unwrap()])); +} + +#[test] +fn cell_rebuild_refuses_to_discard_a_range_tag() { + let workspace = Workspace::new("cell", true, true); + workspace.assert_refusal(workspace.edit(&["--set-cell", "0:1:1=expanded anchor"])); +} + +#[test] +fn replacement_without_tags_remains_available() { + let workspace = Workspace::new("ordinary", false, false); + let result = workspace.edit(&["--replace", "anchor=>expanded anchor"]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); +} + +#[test] +fn equal_wchar_width_replacement_preserves_opaque_tag_bytes() { + let workspace = Workspace::new("same-width", false, true); + let result = workspace.edit(&["--replace", "anchor=>ANCHOR"]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let document = hwp5::read_document(&workspace.output).unwrap().document; + assert_eq!( + document.sections[0].paragraphs[1] + .extras + .iter() + .find(|extra| extra.tag == 0x46) + .unwrap() + .data, + b"opaque range sentinel" + ); +} diff --git a/crates/hwp-convert/src/edit.rs b/crates/hwp-convert/src/edit.rs index 6fcb4ab7..83c386b1 100644 --- a/crates/hwp-convert/src/edit.rs +++ b/crates/hwp-convert/src/edit.rs @@ -4053,3 +4053,7 @@ mod tests { validate_table_invariants(first_table(&doc)).unwrap(); } } + +#[cfg(test)] +#[path = "edit_range_tag_tests.rs"] +mod edit_range_tag_tests; diff --git a/crates/hwp-convert/src/edit_range_tag_tests.rs b/crates/hwp-convert/src/edit_range_tag_tests.rs new file mode 100644 index 00000000..e875b42e --- /dev/null +++ b/crates/hwp-convert/src/edit_range_tag_tests.rs @@ -0,0 +1,601 @@ +//! Opaque range-tag edit guards. Sentinel bytes deliberately encode no record layout. + +use std::collections::BTreeMap; +use std::fmt::Display; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::*; +use hwp_model::opaque::OpaqueRecord; +use hwp_model::{Caption, CaptionDirection, CaptionSide, GenericControl, LineSeg, ParagraphList}; + +const RANGE_TAG: u16 = 0x46; +const OPAQUE_TAG: &[u8] = b"opaque owner-authored range-tag sentinel"; + +/// Invoke the same tests before and after formerly infallible mutators gain Result returns. +/// Legacy success values become Ok; this adapter never makes a refusal decision itself. +trait OperationResult { + fn operation_result(self) -> Result<(), String>; +} + +impl OperationResult for usize { + fn operation_result(self) -> Result<(), String> { + Ok(()) + } +} + +impl OperationResult for bool { + fn operation_result(self) -> Result<(), String> { + Ok(()) + } +} + +impl OperationResult for () { + fn operation_result(self) -> Result<(), String> { + Ok(()) + } +} + +impl OperationResult for Result { + fn operation_result(self) -> Result<(), String> { + self.map(|_| ()).map_err(|error| error.to_string()) + } +} + +fn paragraph(text: &str) -> Paragraph { + let mut chars: Vec<_> = text.chars().map(HwpChar::Text).collect(); + chars.push(HwpChar::CharCtrl(hwp_model::ctrl_char::PARA_BREAK)); + Paragraph { + chars, + char_shape_runs: vec![(0, CharShapeId(0))], + line_segs: vec![LineSeg { + text_start: 0, + v_pos: 123, + line_height: 1000, + text_height: 900, + baseline_gap: 700, + line_spacing: 500, + col_start: 0, + seg_width: 10000, + flags: 1, + }], + ..Paragraph::default() + } +} + +fn tag(para: &mut Paragraph) { + para.extras.push(OpaqueRecord { + tag: RANGE_TAG, + data: OPAQUE_TAG.to_vec(), + children: Vec::new(), + }); +} + +fn document(texts: &[&str]) -> Document { + let mut doc = crate::from_markdown("fixture header"); + doc.sections[0].paragraphs = texts.iter().map(|text| paragraph(text)).collect(); + doc.bin_streams.push(hwp_model::BinStream { + name: "untouched.bin".into(), + data: b"untouched binary payload".to_vec(), + }); + doc +} + +fn generic(inner: Paragraph, caption: Option) -> Control { + Control::Generic(GenericControl { + ctrl_id: *b"gso ", + data: Vec::new(), + paragraph_lists: vec![ParagraphList { + header_data: b"original list header".to_vec(), + paragraphs: vec![inner], + }], + extras: Vec::new(), + raw_children: Vec::new(), + gso_shapes: Vec::new(), + equation: None, + column_def: None, + caption: caption.map(|para| Caption { + side: CaptionSide::Bottom, + direction: CaptionDirection::Horizontal, + gap: 0, + width: None, + last_width: 0, + paragraphs: vec![para], + }), + hwpx_raw_xml: Some("".into()), + container_box: None, + }) +} + +fn remove_tags(para: &mut Paragraph) { + para.extras.retain(|record| record.tag != RANGE_TAG); + for control in &mut para.controls { + match control { + Control::Table(table) => { + if let Some(caption) = &mut table.caption { + for para in &mut caption.paragraphs { + remove_tags(para); + } + } + for cell in &mut table.cells { + for para in &mut cell.paragraphs { + remove_tags(para); + } + } + } + Control::Picture(picture) => { + if let Some(caption) = &mut picture.caption { + for para in &mut caption.paragraphs { + remove_tags(para); + } + } + } + Control::Generic(generic) => { + for list in &mut generic.paragraph_lists { + for para in &mut list.paragraphs { + remove_tags(para); + } + } + if let Some(caption) = &mut generic.caption { + for para in &mut caption.paragraphs { + remove_tags(para); + } + } + } + Control::SectionDef(_) => {} + } + } +} + +fn assert_refused(result: impl OperationResult) { + let error = result + .operation_result() + .expect_err("an edit that shifts or discards opaque range tags must refuse"); + assert!( + error.contains("구간") || error.contains("PARA_RANGE_TAG"), + "the refusal must identify the opaque range-tag cause: {error}" + ); + assert!( + error.contains("지우") || error.contains("제거") || error.contains("같은"), + "the refusal must offer annotation removal or a same-length remedy: {error}" + ); +} + +fn assert_document_refusal( + mut doc: Document, + mut operation: impl FnMut(&mut Document) -> R, +) { + let mut positive = doc.clone(); + for section in &mut positive.sections { + for para in &mut section.paragraphs { + remove_tags(para); + } + } + let before_positive = positive.clone(); + operation(&mut positive) + .operation_result() + .expect("the untagged positive fixture must support the same real operation"); + assert_ne!( + positive, before_positive, + "the fixture must contain a real selected target" + ); + let before = doc.clone(); + assert_refused(operation(&mut doc)); + assert_eq!( + doc, before, + "refusal must preserve body, opaque records, headers, binaries, raw mirrors and caches" + ); +} + +#[test] +fn range_tags_private_splice_text_refuses_before_mutation() { + let mut para = paragraph("ABCD"); + tag(&mut para); + let before = para.clone(); + assert_refused(splice_text(&mut para, 0..1, 0, 1, "LONG")); + assert_eq!(para, before); +} + +#[test] +fn range_tags_private_splice_edits_preflights_every_edit() { + let mut para = paragraph("ABCD"); + tag(&mut para); + let before = para.clone(); + // Back-to-front application would otherwise change D before refusing the earlier growth. + assert_refused(splice_edits( + &mut para, + vec![(0..1, "LONG".into()), (3..4, "X".into())], + )); + assert_eq!(para, before); +} + +#[test] +fn range_tags_private_splice_edits_refuses_net_zero_opposing_deltas() { + let mut para = paragraph("ABCDEF"); + tag(&mut para); + let before = para.clone(); + // Total width remains six, but offsets between the edits would move. + assert_refused(splice_edits( + &mut para, + vec![(0..1, "AA".into()), (4..6, "Z".into())], + )); + assert_eq!(para, before); +} + +#[test] +fn range_tags_global_replace_is_atomic_for_a_late_tagged_match() { + let mut doc = document(&["needle first", "needle second"]); + tag(&mut doc.sections[0].paragraphs[1]); + assert_document_refusal(doc, |doc| { + replace_text(doc, "needle", "expanded value", true) + }); +} + +#[test] +fn range_tags_addressed_replace_refuses_without_invalidating_ancestors() { + let mut inner = paragraph("needle"); + tag(&mut inner); + let mut doc = document(&["host"]); + doc.sections[0].paragraphs[0] + .controls + .push(generic(inner, None)); + let path = SegmentPath { + section: 0, + indices: vec![0, 0, 0], + }; + assert_document_refusal(doc, |doc| { + replace_text_at(doc, &path, "needle", "expanded value") + }); +} + +#[test] +fn range_tags_global_replace_preflights_nested_paragraphs() { + let mut inner = paragraph("needle"); + tag(&mut inner); + let mut doc = document(&["needle first", "host"]); + doc.sections[0].paragraphs[1] + .controls + .push(generic(inner, None)); + assert_document_refusal(doc, |doc| replace_text(doc, "needle", "longer value", true)); +} + +fn values(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(key, value)| ((*key).into(), (*value).into())) + .collect() +} + +#[test] +fn range_tags_slot_fill_refuses_net_zero_splice_deltas() { + let mut doc = document(&["{{a}} {{b}}"]); + tag(&mut doc.sections[0].paragraphs[0]); + assert_document_refusal(doc, |doc| { + crate::replace_slots(doc, &values(&[("a", "123456"), ("b", "1234")])) + }); +} + +#[test] +fn range_tags_slot_fill_is_atomic_for_a_late_target() { + let mut doc = document(&["{{name}}", "{{name}}"]); + tag(&mut doc.sections[0].paragraphs[1]); + assert_document_refusal(doc, |doc| { + crate::replace_slots(doc, &values(&[("name", "X")])) + }); +} + +#[test] +fn range_tags_form_fill_refuses_net_zero_splice_deltas() { + let mut doc = document(&["{{a}} {{b}}"]); + tag(&mut doc.sections[0].paragraphs[0]); + assert_document_refusal(doc, |doc| { + crate::fill_form_fields(doc, &values(&[("a", "123456"), ("b", "1234")])) + }); +} + +fn table_document() -> Document { + crate::from_markdown("| 항목 | 값 |\n| --- | --- |\n| 성명 | old |\n") +} + +#[test] +fn range_tags_form_fill_preflights_late_whole_cell_replacement() { + let mut doc = table_document(); + doc.sections[0].paragraphs.insert(0, paragraph("{{early}}")); + let targets = find_form_cells_by_label(&mut doc, "성명", None); + assert_eq!( + targets.len(), + 1, + "the labeled value-cell fixture must resolve" + ); + assert_eq!((targets[0].row, targets[0].col), (1, 1)); + with_nth_table(&mut doc, 0, |table| { + let cell = table + .cells + .iter_mut() + .find(|cell| cell.row == 1 && cell.col == 1) + .unwrap(); + tag(&mut cell.paragraphs[0]); + }) + .unwrap(); + assert_document_refusal(doc, |doc| { + crate::fill_form_fields(doc, &values(&[("early", "X"), ("성명", "LONG")])) + }); +} + +fn field_document(two: bool) -> Document { + let mut doc = document(if two { + &["first:", "second:"] + } else { + &["first:"] + }); + crate::create_field(&mut doc, "first:", "entry", "A") + .operation_result() + .unwrap(); + if two { + crate::create_field(&mut doc, "second:", "entry", "BBB") + .operation_result() + .unwrap(); + } + assert_eq!(crate::list_fields(&doc).len(), if two { 2 } else { 1 }); + doc +} + +#[test] +fn range_tags_named_field_fill_is_atomic_for_a_late_target() { + let mut doc = field_document(true); + tag(&mut doc.sections[0].paragraphs[1]); + assert_document_refusal(doc, |doc| crate::set_field(doc, "entry", "longer")); +} + +#[test] +fn range_tags_named_field_fill_refuses_opposing_deltas_in_one_paragraph() { + let mut doc = document(&["first: second:"]); + for (anchor, value) in [("first:", "A"), ("second:", "BBB")] { + crate::create_field(&mut doc, anchor, "entry", value) + .operation_result() + .unwrap(); + } + assert_eq!(crate::list_fields(&doc).len(), 2); + tag(&mut doc.sections[0].paragraphs[0]); + assert_document_refusal(doc, |doc| crate::set_field(doc, "entry", "ZZ")); +} + +#[test] +fn range_tags_set_cell_refuses_discarding_opaque_tags() { + let mut doc = table_document(); + with_nth_table(&mut doc, 0, |table| { + let cell = table + .cells + .iter_mut() + .find(|cell| cell.row == 1 && cell.col == 1) + .unwrap(); + tag(&mut cell.paragraphs[0]); + }) + .unwrap(); + assert_document_refusal(doc, |doc| set_cell(doc, 0, 1, 1, "replacement")); +} + +#[test] +fn range_tags_set_cell_checks_tags_in_discarded_nested_captions() { + let mut doc = table_document(); + with_nth_table(&mut doc, 0, |table| { + let cell = table + .cells + .iter_mut() + .find(|cell| cell.row == 1 && cell.col == 1) + .unwrap(); + let mut caption = paragraph("caption annotation"); + tag(&mut caption); + cell.paragraphs[0] + .controls + .push(generic(paragraph("inner text"), Some(caption))); + }) + .unwrap(); + assert_document_refusal(doc, |doc| set_cell(doc, 0, 1, 1, "replacement")); +} + +fn assert_anchor_insertion_refuses(kind: &str) { + let mut doc = document(&["anchor"]); + tag(&mut doc.sections[0].paragraphs[0]); + assert_document_refusal(doc, |doc| match kind { + "field" => crate::create_field(doc, "anchor", "created", "value").operation_result(), + "hyperlink" => { + crate::create_hyperlink(doc, "anchor", "https://example.com", "link").operation_result() + } + "bookmark" => crate::create_bookmark(doc, "anchor", "created").operation_result(), + _ => unreachable!(), + }); +} + +#[test] +fn range_tags_field_creation_refuses_before_mutation() { + assert_anchor_insertion_refuses("field"); +} + +#[test] +fn range_tags_hyperlink_creation_refuses_before_header_mutation() { + assert_anchor_insertion_refuses("hyperlink"); +} + +#[test] +fn range_tags_bookmark_creation_refuses_before_mutation() { + assert_anchor_insertion_refuses("bookmark"); +} + +struct ImageFixture(PathBuf); + +impl ImageFixture { + fn new() -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let sequence = NEXT.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "hwp470-image-{}-{sequence}.png", + std::process::id() + )); + // Header-only synthetic pixels match image.rs's existing image_pixel_size fixture. + // A positive real-operation control below verifies this insertion fixture is accepted. + let mut bytes = vec![137, 80, 78, 71, 13, 10, 26, 10]; + bytes.extend_from_slice(&[0, 0, 0, 13]); + bytes.extend_from_slice(b"IHDR"); + bytes.extend_from_slice(&2u32.to_be_bytes()); + bytes.extend_from_slice(&2u32.to_be_bytes()); + std::fs::write(&path, bytes).unwrap(); + Self(path) + } +} + +impl Drop for ImageFixture { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } +} + +#[test] +fn range_tags_image_insertion_refuses_before_binary_mutation() { + let image = ImageFixture::new(); + let mut doc = document(&["anchor"]); + tag(&mut doc.sections[0].paragraphs[0]); + assert_document_refusal(doc, |doc| { + crate::image::insert_image(doc, "anchor", &image.0, crate::image::ImageSize::Natural) + }); +} + +#[test] +fn range_tags_seal_insertion_refuses_before_binary_mutation() { + let image = ImageFixture::new(); + let mut doc = document(&["anchor"]); + tag(&mut doc.sections[0].paragraphs[0]); + assert_document_refusal(doc, |doc| { + crate::image::insert_seal(doc, "anchor", &image.0, Some(5.0), |_, _| None) + }); +} + +#[test] +fn range_tags_object_deletion_refuses_before_removing_field_anchors() { + let mut doc = field_document(false); + tag(&mut doc.sections[0].paragraphs[0]); + assert_document_refusal(doc, |doc| delete_object(doc, ObjectKind::Field, "entry")); +} + +#[test] +fn range_tags_object_deletion_is_atomic_for_a_late_tagged_match() { + let mut doc = field_document(true); + tag(&mut doc.sections[0].paragraphs[1]); + assert_document_refusal(doc, |doc| delete_object(doc, ObjectKind::Field, "entry")); +} + +#[test] +fn range_tags_named_field_fill_checks_non_bmp_wchar_widths() { + let mut doc = field_document(false); + tag(&mut doc.sections[0].paragraphs[0]); + assert_document_refusal(doc, |doc| crate::set_field(doc, "entry", "😀")); +} + +#[test] +fn range_tags_set_cell_refusal_preserves_ancestor_raw_mirror() { + let mut table_doc = table_document(); + with_nth_table(&mut table_doc, 0, |table| { + let cell = table + .cells + .iter_mut() + .find(|cell| cell.row == 1 && cell.col == 1) + .unwrap(); + tag(&mut cell.paragraphs[0]); + }) + .unwrap(); + let table = table_doc.sections[0] + .paragraphs + .iter() + .flat_map(|para| ¶.controls) + .find(|control| matches!(control, Control::Table(_))) + .unwrap() + .clone(); + let mut inner = paragraph("nested table host"); + inner.controls.push(table); + let mut doc = document(&["outer host"]); + doc.sections[0].paragraphs[0] + .controls + .push(generic(inner, None)); + assert_document_refusal(doc, |doc| set_cell(doc, 0, 1, 1, "replacement")); +} + +#[test] +fn range_tags_private_remove_controls_refuses_before_mutation() { + let mut doc = document(&["anchor"]); + crate::create_bookmark(&mut doc, "anchor", "bookmark") + .operation_result() + .unwrap(); + assert_eq!(doc.sections[0].paragraphs[0].controls.len(), 1); + let mut para = doc.sections[0].paragraphs.remove(0); + tag(&mut para); + let before = para.clone(); + assert_refused(remove_controls(&mut para, &[0])); + assert_eq!(para, before); +} + +#[test] +fn range_tags_equal_normalized_wchar_splices_preserve_opaque_bytes() { + for (text, range, old_width, replacement) in [ + ("AB", 0..2, 2, "😀"), + ("12345678", 0..8, 8, "\t"), + ("A", 0..1, 1, "B\r"), + ("AB", 0..2, 2, "A\r\n"), + ] { + let mut para = paragraph(text); + tag(&mut para); + let extras = para.extras.clone(); + let old_len = para.wchar_len(); + splice_text(&mut para, range, 0, old_width, replacement) + .operation_result() + .expect("equal actual WCHAR widths do not shift range offsets"); + assert_eq!(para.extras, extras); + assert_eq!(para.wchar_len(), old_len); + } +} + +#[test] +fn range_tags_normalized_width_growth_is_not_hidden_by_scalar_counts() { + for replacement in ["\t", "😀"] { + let mut para = paragraph("A"); + tag(&mut para); + let before = para.clone(); + assert_refused(splice_text(&mut para, 0..1, 0, 1, replacement)); + assert_eq!(para, before); + } +} + +#[test] +fn range_tags_unselected_caption_does_not_block_body_replace() { + let mut doc = document(&["needle"]); + let mut caption = paragraph("unselected caption"); + tag(&mut caption); + doc.sections[0].paragraphs[0].controls.push(generic( + paragraph("unselected inner text"), + Some(caption.clone()), + )); + replace_text(&mut doc, "needle", "expanded value", true) + .operation_result() + .unwrap(); + let Control::Generic(generic) = &doc.sections[0].paragraphs[0].controls[0] else { + unreachable!() + }; + assert_eq!(generic.caption.as_ref().unwrap().paragraphs[0], caption); +} + +#[test] +fn range_tags_no_match_and_same_text_requests_are_allowed() { + let mut doc = document(&["unchanged"]); + tag(&mut doc.sections[0].paragraphs[0]); + let before = doc.clone(); + replace_text(&mut doc, "absent", "long replacement", true) + .operation_result() + .unwrap(); + assert_eq!(doc, before); + replace_text(&mut doc, "unchanged", "unchanged", true) + .operation_result() + .expect("a same-text request must not be rejected for range tags"); + assert_eq!( + doc.sections[0].paragraphs[0].extras, + before.sections[0].paragraphs[0].extras + ); +} diff --git a/crates/hwp-convert/src/form/binding/tests.rs b/crates/hwp-convert/src/form/binding/tests.rs index eba18b4e..2f35b45e 100644 --- a/crates/hwp-convert/src/form/binding/tests.rs +++ b/crates/hwp-convert/src/form/binding/tests.rs @@ -74,21 +74,23 @@ fn prepare_apply_refuses_intervening_binary_and_preview_changes_omitted_by_json( #[test] fn opaque_paragraph_records_are_visible_but_never_relocated_by_a_binding() { - let mut doc = document(); - doc.sections[0].paragraphs[1] = paragraph("{{ private }}"); - doc.sections[0].paragraphs[1] - .extras - .push(hwp_model::OpaqueRecord { - tag: u16::MAX, - data: vec![1, 2, 3], - children: vec![], - }); - let item = occurrence(&doc, "placeholder", "private"); - assert_unsupported(&item, "rich_target"); - let (plan, values) = plan(&doc, &item, "replacement", true); - let before = doc.clone(); - assert!(prepare_binding_plan(&doc, &source_hash(), &plan, &values).is_err()); - assert_eq!(doc, before); + for tag in [u16::MAX, 0x46] { + let mut doc = document(); + doc.sections[0].paragraphs[1] = paragraph("{{ private }}"); + doc.sections[0].paragraphs[1] + .extras + .push(hwp_model::OpaqueRecord { + tag, + data: vec![1, 2, 3], + children: vec![], + }); + let item = occurrence(&doc, "placeholder", "private"); + assert_unsupported(&item, "rich_target"); + let (plan, values) = plan(&doc, &item, "replacement", true); + let before = doc.clone(); + assert!(prepare_binding_plan(&doc, &source_hash(), &plan, &values).is_err()); + assert_eq!(doc, before); + } } fn source_hash() -> String { From 3dc0bf3580327528f4d185d8ad049981a05cc163 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Wed, 11 Nov 2026 00:56:40 +0900 Subject: [PATCH 2/8] fix: refuse edits that shift opaque HWP5 range tags --- crates/hwp-cli/src/commands/corpus.rs | 42 +- crates/hwp-cli/src/commands/edit.rs | 57 ++- crates/hwp-convert/src/bookmark.rs | 80 ++-- crates/hwp-convert/src/edit.rs | 474 ++++++++++++++----- crates/hwp-convert/src/field.rs | 396 +++++++++------- crates/hwp-convert/src/form.rs | 58 ++- crates/hwp-convert/src/form/binding/apply.rs | 2 +- crates/hwp-convert/src/form/binding/tests.rs | 10 +- crates/hwp-convert/src/format.rs | 14 +- crates/hwp-convert/src/image.rs | 162 ++++--- crates/hwp5/src/write.rs | 4 +- crates/hwp5/tests/synth.rs | 256 +++++----- crates/hwpx/tests/write.rs | 22 +- 13 files changed, 974 insertions(+), 603 deletions(-) diff --git a/crates/hwp-cli/src/commands/corpus.rs b/crates/hwp-cli/src/commands/corpus.rs index 2fb1397a..a64507d9 100644 --- a/crates/hwp-cli/src/commands/corpus.rs +++ b/crates/hwp-cli/src/commands/corpus.rs @@ -2225,9 +2225,9 @@ fn normalized_relative(root: &Path, path: &Path) -> Result { Ok(components.join("/")) } -/// 실패한 case는 자기 디렉터리를 비운 채 남긴다. 그 디렉터리를 "예상 밖"으로 처리하면 -/// 실패 사유가 담긴 report 자체가 게시되지 못하고 트리 불일치 오류로 덮인다. 그래서 runner가 -/// 만드는 case 디렉터리는 파일이 없어도 허용한다. +/// A failed case leaves its directory empty. Rejecting that directory as unexpected would +/// prevent publishing the failure report and replace the cause with a tree mismatch error. +/// Allow directories created by the runner even when they contain no files. fn audit_tree(root: &Path, manifest: &ArtifactManifest, case_ids: &[&str]) -> Result<()> { let expected_paths: BTreeSet<_> = manifest .files @@ -2266,7 +2266,7 @@ fn audit_tree(root: &Path, manifest: &ArtifactManifest, case_ids: &[&str]) -> Re ) .collect::>(); if !observed_directories.is_subset(&expected_directories) { - // 경로는 닫힌 manifest에서 온 산출물 이름이라 진단에 실어도 된다. + // Artifact names from the closed manifest are safe to include in diagnostics. let unexpected = observed_directories .difference(&expected_directories) .cloned() @@ -2601,8 +2601,8 @@ mod tests { let policy: CertificationPolicy = serde_json::from_slice(&policy_bytes).unwrap(); verify_policy_contract(&policy, &manifest.fonts).unwrap(); for font in &manifest.fonts { - // 폰트 바이트는 커밋하지 않는다. scripts/fetch-corpus-fonts.sh 가 채우기 전에는 건너뛰고, - // 있으면 커밋된 입력과 똑같이 hash를 강제한다(코퍼스 게이트는 fetch 후 항상 검사). + // Font bytes are not committed. Skip until scripts/fetch-corpus-fonts.sh supplies them; + // when present, enforce their hashes like committed inputs (the corpus gate always fetches first). if !corpus.join(&font.path).exists() { eprintln!("skip: corpus fonts absent — run scripts/fetch-corpus-fonts.sh"); continue; @@ -2994,19 +2994,25 @@ mod tests { let original = compiled_official_letter(); let mut first_link = original.clone(); - assert!(hwp_convert::create_hyperlink( - &mut first_link, - "제주한라대학교", - "https://example.invalid/first", - "링크" - )); + assert!( + hwp_convert::create_hyperlink( + &mut first_link, + "제주한라대학교", + "https://example.invalid/first", + "링크" + ) + .unwrap() + ); let mut second_link = original.clone(); - assert!(hwp_convert::create_hyperlink( - &mut second_link, - "제주한라대학교", - "https://example.invalid/second", - "링크" - )); + assert!( + hwp_convert::create_hyperlink( + &mut second_link, + "제주한라대학교", + "https://example.invalid/second", + "링크" + ) + .unwrap() + ); assert_ne!( structural_semantic_sha256(&first_link).unwrap(), structural_semantic_sha256(&second_link).unwrap() diff --git a/crates/hwp-cli/src/commands/edit.rs b/crates/hwp-cli/src/commands/edit.rs index 9217eff5..b874358e 100644 --- a/crates/hwp-cli/src/commands/edit.rs +++ b/crates/hwp-cli/src/commands/edit.rs @@ -1211,7 +1211,8 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result anyhow::Result anyhow::Result").with_context(|| { format!("--create-bookmark 형식은 \"앵커=>이름\" 입니다: {spec:?}") })?; - if hwp_convert::create_bookmark(&mut doc, anchor, name) { + if hwp_convert::create_bookmark(&mut doc, anchor, name) + .map_err(anyhow::Error::msg)? + { eprintln!("책갈피 생성: {anchor:?} 뒤에 이름={name:?}"); edits += 1; } else { @@ -1321,7 +1326,9 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::ResultURL\" 또는 \"앵커=>표시=>URL\" 입니다: {spec:?}" ), }; - if hwp_convert::create_hyperlink(&mut doc, anchor, url, display) { + if hwp_convert::create_hyperlink(&mut doc, anchor, url, display) + .map_err(anyhow::Error::msg)? + { eprintln!("하이퍼링크 생성: {anchor:?} 뒤에 표시={display:?} URL={url:?}"); edits += 1; } else { @@ -1361,7 +1368,8 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result { for name in specs { let n = - hwp_convert::delete_object(&mut doc, hwp_convert::ObjectKind::Field, name); + hwp_convert::delete_object(&mut doc, hwp_convert::ObjectKind::Field, name) + .map_err(anyhow::Error::msg)?; if n == 0 { eprintln!("경고: 필드를 찾지 못했습니다 ({name:?})"); unapplied.push(format!("--delete-field {name:?}")); @@ -1736,7 +1748,8 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result { - if hwp_convert::create_field(doc, anchor, name, value) { + if hwp_convert::create_field(doc, anchor, name, value).map_err(anyhow::Error::msg)? { eprintln!("누름틀 생성: {anchor:?} 뒤에 이름={name:?} 값={value:?}"); *edits += 1; } else { @@ -3434,7 +3449,7 @@ fn apply_typed_operation( } } TypedEditOperation::CreateBookmark { anchor, name } => { - if hwp_convert::create_bookmark(doc, anchor, name) { + if hwp_convert::create_bookmark(doc, anchor, name).map_err(anyhow::Error::msg)? { eprintln!("책갈피 생성: {anchor:?} 뒤에 이름={name:?}"); *edits += 1; } else { @@ -3446,7 +3461,9 @@ fn apply_typed_operation( display, url, } => { - if hwp_convert::create_hyperlink(doc, anchor, url, display) { + if hwp_convert::create_hyperlink(doc, anchor, url, display) + .map_err(anyhow::Error::msg)? + { eprintln!("하이퍼링크 생성: {anchor:?} 뒤에 표시={display:?} URL={url:?}"); *edits += 1; } else { @@ -3479,7 +3496,7 @@ fn apply_typed_operation( } TypedEditOperation::SetField { name, value } => { let before = doc.clone(); - let count = hwp_convert::set_field(doc, name, value); + let count = hwp_convert::set_field(doc, name, value).map_err(anyhow::Error::msg)?; if count == 0 { unapplied.push(format!("set_field name={name:?}")); } else { @@ -3921,7 +3938,8 @@ fn apply_typed_operation( } } TypedEditOperation::DeleteImage { anchor } => { - let count = hwp_convert::delete_object(doc, hwp_convert::ObjectKind::Image, anchor); + let count = hwp_convert::delete_object(doc, hwp_convert::ObjectKind::Image, anchor) + .map_err(anyhow::Error::msg)?; if count == 0 { unapplied.push(format!("delete_image anchor={anchor:?}")); } else { @@ -3936,7 +3954,8 @@ fn apply_typed_operation( (None, Some(anchor)) => (hwp_convert::ObjectKind::Table, anchor.as_str()), _ => anyhow::bail!("delete_table은 index와 anchor 중 하나만 지정해야 합니다"), }; - let count = hwp_convert::delete_object(doc, kind, selector); + let count = + hwp_convert::delete_object(doc, kind, selector).map_err(anyhow::Error::msg)?; if count == 0 { unapplied.push(format!("delete_table index={index:?} anchor={anchor:?}")); } else { @@ -3945,7 +3964,8 @@ fn apply_typed_operation( } } TypedEditOperation::DeleteField { name } => { - let count = hwp_convert::delete_object(doc, hwp_convert::ObjectKind::Field, name); + let count = hwp_convert::delete_object(doc, hwp_convert::ObjectKind::Field, name) + .map_err(anyhow::Error::msg)?; if count == 0 { unapplied.push(format!("delete_field name={name:?}")); } else { @@ -3954,7 +3974,8 @@ fn apply_typed_operation( } } TypedEditOperation::DeleteBookmark { name } => { - let count = hwp_convert::delete_object(doc, hwp_convert::ObjectKind::Bookmark, name); + let count = hwp_convert::delete_object(doc, hwp_convert::ObjectKind::Bookmark, name) + .map_err(anyhow::Error::msg)?; if count == 0 { unapplied.push(format!("delete_bookmark name={name:?}")); } else { diff --git a/crates/hwp-convert/src/bookmark.rs b/crates/hwp-convert/src/bookmark.rs index 70c14de1..886dea7e 100644 --- a/crates/hwp-convert/src/bookmark.rs +++ b/crates/hwp-convert/src/bookmark.rs @@ -1,32 +1,35 @@ -//! 책갈피(bookmark) 읽기·생성 — hwp5 `bokm` 확장 컨트롤. +//! Bookmark reading/creation through the HWP5 bokm extended control. //! -//! 책갈피는 필드(%bmk)가 아니라 `bokm` 컨트롤로 저장된다(그래서 `list_fields`가 못 잡는다): -//! - 문자: `ExtCtrl { code: 22(BOOKMARK), ctrl_id: b"bokm" }` — START/END 쌍 없는 **단일 지점 표식**. -//! - 컨트롤: `Generic { ctrl_id: b"bokm", data: []빈, raw_children: [CTRL_DATA(이름)] }`. -//! - 이름: CTRL_DATA Parameter Set의 BSTR — %clk 필드와 레이아웃이 달라(별도 코덱) 정품 바이트를 -//! 그대로 복제해야 한글이 수용한다(가나다·다문단 정답지 방식). +//! Bookmarks use bokm controls rather than %bmk fields, so list_fields does not collect them: +//! - Character: ExtCtrl with code 22 (BOOKMARK) and ctrl_id=b"bokm", a single point marker without +//! a START/END pair. +//! - Control: Generic with ctrl_id=b"bokm", empty data, and raw_children holding CTRL_DATA for the +//! name. +//! - Name: a BSTR in a CTRL_DATA Parameter Set. Its layout differs from %clk fields and requires a +//! separate codec copying genuine bytes for Hangul acceptance, following the `가나다`/`다문단` +//! ground-truth method. //! -//! 리더/IR/writer는 `bokm`을 무손실 왕복하므로(identity 게이트, `fixtures/hwp5/bookmark.hwp`), -//! 이 모듈은 IR을 바꾸지 않는 온디맨드 조회와 편집 경로 삽입만 담당한다. +//! The reader/IR/writer round-trip bokm losslessly (identity gate, fixtures/hwp5/bookmark.hwp). +//! This module provides on-demand inspection without IR mutation, plus insertion on edit paths. use hwp_model::ctrl_char::BOOKMARK; use hwp_model::opaque::OpaqueRecord; use hwp_model::{Control, Document, GenericControl, HwpChar, Paragraph}; -use crate::edit::{adjust_runs, find_match, utf16_len}; +use crate::edit::{adjust_runs, ensure_anchor_shift_allowed, find_match, utf16_len}; use crate::field::{relink_ctrl_index, rev_payload}; pub(crate) const CTRL_DATA_TAG: u16 = 0x0010 + 71; // HWPTAG_CTRL_DATA (0x57) const BOKM: [u8; 4] = *b"bokm"; -/// 책갈피 하나의 정보. +/// Information about one bookmark. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BookmarkInfo { - /// 책갈피 이름. + /// Bookmark name. pub name: String, } -/// 문서의 모든 책갈피를 등장 순서로 나열한다(본문·표 셀·글상자 재귀). +/// Lists all bookmarks in encounter order, recursively through body, table cells and text boxes. pub fn list_bookmarks(doc: &Document) -> Vec { let mut out = Vec::new(); for section in &doc.sections { @@ -76,8 +79,8 @@ fn collect_bookmarks(para: &Paragraph, out: &mut Vec) { } } -/// bokm 컨트롤(Generic)에서 책갈피 이름을 읽는다. hwpx writer가 `` -/// 방출 시 재사용한다. +/// Reads a bookmark name from a Generic bokm control, reused by the HWPX writer when emitting +/// hp:bookmark name. pub fn bookmark_name(ctrl: &Control) -> Option { let Control::Generic(g) = ctrl else { return None; @@ -88,8 +91,8 @@ pub fn bookmark_name(ctrl: &Control) -> Option { .and_then(|r| decode_bokm_name(&r.data)) } -/// bokm CTRL_DATA Parameter Set에서 이름 BSTR을 디코드한다. -/// 레이아웃: setid(2) count(2) id(4) type(2) len(2=WCHAR 수) 이름(utf16-le). +/// Decodes the name BSTR from a bokm CTRL_DATA Parameter Set: setid(2), count(2), id(4), type(2), +/// length(2, in WCHAR), and UTF-16LE name. fn decode_bokm_name(cd: &[u8]) -> Option { if cd.len() < 12 { return None; @@ -110,9 +113,9 @@ fn decode_utf16le(b: &[u8]) -> String { String::from_utf16_lossy(&units) } -/// bokm CTRL_DATA Parameter Set 바이트(정품 한글과 동일 레이아웃). -/// setid=0x021b · count=1 · id=0x40000000 · type=1(BSTR) · len(2B) · 이름. -/// hwpx reader가 `` → bokm Generic 합성 시 재사용한다. +/// bokm CTRL_DATA Parameter Set bytes matching the genuine Hangul layout: setid=0x021b, count=1, +/// id=0x40000000, type=1 (BSTR), length(2 bytes), name. Reused by the HWPX reader when synthesizing +/// hp:bookmark name into Generic bokm. pub fn make_bokm_ctrl_data(name: &str) -> Vec { let mut cd = vec![0x1b, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x40, 0x01, 0x00]; let units: Vec = name.encode_utf16().collect(); @@ -123,7 +126,7 @@ pub fn make_bokm_ctrl_data(name: &str) -> Vec { cd } -/// bokm 컨트롤 레코드(CTRL_HEADER data 빈, CTRL_DATA에 이름). +/// bokm control record: empty CTRL_HEADER data, with the name in CTRL_DATA. fn make_bokm_control(name: &str) -> Control { Control::Generic(GenericControl { ctrl_id: BOKM, @@ -144,7 +147,7 @@ fn make_bokm_control(name: &str) -> Control { }) } -/// 책갈피 문자: 단일 code-22 ExtCtrl(bokm). payload 선두 4B = 역순 ctrl_id. +/// Bookmark character: one code-22 bokm ExtCtrl; the first four payload bytes are reversed ctrl_id. fn make_bokm_char() -> HwpChar { HwpChar::ExtCtrl { code: BOOKMARK, @@ -154,14 +157,14 @@ fn make_bokm_char() -> HwpChar { } } -/// 한 문단에서 앵커 텍스트 뒤에 책갈피를 삽입한다. 반환=삽입 여부. +/// Inserts a bookmark after anchor text in one paragraph, returning whether it was inserted. fn create_bookmark_in_para(para: &mut Paragraph, anchor: &str, name: &str) -> bool { let Some((cidx, wpos)) = find_match(¶.chars, anchor, 0) else { return false; }; let ins = (cidx + anchor.chars().count()).min(para.chars.len()); let iw = wpos + utf16_len(anchor); - // control 삽입 위치 = ins 이전 ExtCtrl 개수(등장순서가 chars와 정합해야 함). + // The insertion control index is the preceding ExtCtrl count, matching character/control order. let ci = para.chars[..ins] .iter() .filter(|c| matches!(c, HwpChar::ExtCtrl { .. })) @@ -173,12 +176,12 @@ fn create_bookmark_in_para(para: &mut Paragraph, anchor: &str, name: &str) -> bo para.chars.insert(ins, ch); adjust_runs(&mut para.char_shape_runs, iw, 0, inserted_w); relink_ctrl_index(para); - para.header.ctrl_mask = 0; // writer가 chars에서 재계산(BOOKMARK bit22 포함) + para.header.ctrl_mask = 0; // The writer recalculates ctrl_mask from chars, including BOOKMARK bit22. para.line_segs.clear(); true } -/// 본문/표 셀/글상자 문단을 재귀로 훑어 첫 매칭에 책갈피를 삽입한다. +/// Recursively inserts at the first anchor match in body, table-cell or text-box paragraphs. fn create_bookmark_rec(para: &mut Paragraph, anchor: &str, name: &str) -> bool { if create_bookmark_in_para(para, anchor, name) { return true; @@ -198,7 +201,8 @@ fn create_bookmark_rec(para: &mut Paragraph, anchor: &str, name: &str) -> bool { for l in &mut g.paragraph_lists { for p in &mut l.paragraphs { if create_bookmark_rec(p, anchor, name) { - // 내용이 바뀐 개체의 원문 XML은 낡았다 — stale 방출 금지. + // The object's content changed; clear captured XML to prevent stale + // output. g.hwpx_raw_xml = None; return true; } @@ -211,17 +215,19 @@ fn create_bookmark_rec(para: &mut Paragraph, anchor: &str, name: &str) -> bool { false } -/// `anchor` 텍스트를 가진 첫 문단의 그 뒤에 `name` 이름의 책갈피(지점 표식)를 삽입한다. -/// 반환=삽입 여부. 삽입 후 [`list_bookmarks`]로 확인할 수 있다. -pub fn create_bookmark(doc: &mut Document, anchor: &str, name: &str) -> bool { +/// Inserts a named point bookmark after anchor text in the first matching paragraph. The result +/// indicates insertion; inspect it with list_bookmarks afterward. +/// Refuses before mutation when insertion would shift opaque range tags in the target. +pub fn create_bookmark(doc: &mut Document, anchor: &str, name: &str) -> Result { + ensure_anchor_shift_allowed(doc, anchor, 8)?; for section in &mut doc.sections { for para in &mut section.paragraphs { if create_bookmark_rec(para, anchor, name) { - return true; + return Ok(true); } } } - false + Ok(false) } #[cfg(test)] @@ -229,7 +235,7 @@ mod tests { use super::*; use hwp_model::{CharShapeId, Section}; - /// 정품 bookmark.hwp에서 추출한 "책갈피테스트" CTRL_DATA 24바이트. + /// 24 CTRL_DATA bytes for "책갈피테스트", extracted from the genuine bookmark.hwp fixture. const FIXTURE_CTRL_DATA: [u8; 24] = [ 0x1b, 0x02, 0x01, 0x00, 0x00, 0x00, 0x00, 0x40, 0x01, 0x00, 0x06, 0x00, 0x45, 0xcc, 0x08, 0xac, 0x3c, 0xd5, 0x4c, 0xd1, 0xa4, 0xc2, 0xb8, 0xd2, @@ -237,7 +243,7 @@ mod tests { #[test] fn ctrl_data_정품_바이트_동일() { - // 생성 바이트가 정품 한글과 완전히 같아야 한글이 수용한다. + // Generated bytes must match genuine Hangul exactly for native acceptance. assert_eq!(make_bokm_ctrl_data("책갈피테스트"), FIXTURE_CTRL_DATA); } @@ -251,7 +257,7 @@ mod tests { decode_bokm_name(&make_bokm_ctrl_data("표1")).as_deref(), Some("표1") ); - // 잘린 데이터는 None(패닉 없음). + // Truncated data returns None without panicking. assert_eq!(decode_bokm_name(&FIXTURE_CTRL_DATA[..8]), None); } @@ -266,11 +272,11 @@ mod tests { }], extras: Vec::new(), }); - assert!(create_bookmark(&mut doc, "제목", "책갈피1")); + assert!(create_bookmark(&mut doc, "제목", "책갈피1").unwrap()); let bms = list_bookmarks(&doc); assert_eq!(bms.len(), 1); assert_eq!(bms[0].name, "책갈피1"); - // 없는 앵커는 삽입 실패. - assert!(!create_bookmark(&mut doc, "없는앵커", "x")); + // A missing anchor inserts nothing. + assert!(!create_bookmark(&mut doc, "없는앵커", "x").unwrap()); } } diff --git a/crates/hwp-convert/src/edit.rs b/crates/hwp-convert/src/edit.rs index 83c386b1..d019a98e 100644 --- a/crates/hwp-convert/src/edit.rs +++ b/crates/hwp-convert/src/edit.rs @@ -174,125 +174,287 @@ fn is_explicit_form_value(cell: &hwp_model::Cell, wanted: &str) -> bool { if token.range == (0..normalized.len()) && token.name == wanted) } -/// 문서 전체에서 `from`을 `to`로 치환한다(본문·표 셀·글상자 문단 재귀). -/// `all`이 거짓이면 첫 1건만 바꾼다. 반환값은 치환 횟수. -/// -/// 한 문단의 연속된 일반 문자(Text) 안에서만 매칭한다 — 컨트롤 문자(표 앵커· -/// 문단끝 등)가 끼면 그 경계에서 매칭이 끊긴다(서식·구조 보존). -pub fn replace_text(doc: &mut Document, from: &str, to: &str, all: bool) -> usize { +/// Opaque HWP5 range marks (format 5.0 section 4.3.5); no saved layout is available locally. +pub(crate) const PARA_RANGE_TAG: u16 = 0x10 + 54; +pub(crate) const RANGE_TAG_SHIFT_REFUSAL: &str = "미해석 PARA_RANGE_TAG 구간 표시가 있어 WCHAR 위치가 바뀌거나 표시를 잃는 문단 재작성을 지원하지 않습니다. 같은 WCHAR 길이의 텍스트 치환을 사용하거나 한글에서 구간 표시를 제거한 파일로 다시 실행하세요."; + +pub(crate) fn paragraph_has_range_tags(para: &Paragraph) -> bool { + para.extras + .iter() + .any(|record| record.tag == PARA_RANGE_TAG) +} + +pub(crate) fn paragraph_tree_has_range_tags(para: &Paragraph) -> bool { + fn raw_has_tags(records: &[hwp_model::OpaqueRecord]) -> bool { + records + .iter() + .any(|record| record.tag == PARA_RANGE_TAG || raw_has_tags(&record.children)) + } + if raw_has_tags(¶.extras) { + return true; + } + para.controls.iter().any(|control| { + let caption = match control { + Control::Table(table) => { + if table + .cells + .iter() + .any(|cell| cell.paragraphs.iter().any(paragraph_tree_has_range_tags)) + { + return true; + } + &table.caption + } + Control::Picture(picture) => &picture.caption, + Control::Generic(generic) => { + if raw_has_tags(&generic.raw_children) + || generic + .paragraph_lists + .iter() + .any(|list| list.paragraphs.iter().any(paragraph_tree_has_range_tags)) + { + return true; + } + &generic.caption + } + Control::SectionDef(_) => return false, + }; + caption + .as_ref() + .is_some_and(|caption| caption.paragraphs.iter().any(paragraph_tree_has_range_tags)) + }) +} + +pub(crate) fn document_has_range_tags(doc: &Document) -> bool { + doc.sections + .iter() + .any(|section| section.paragraphs.iter().any(paragraph_tree_has_range_tags)) +} + +pub(crate) fn ensure_range_tag_shift_allowed( + para: &Paragraph, + from_w: u32, + to_w: u32, +) -> Result<(), String> { + if from_w != to_w && paragraph_has_range_tags(para) { + return Err(RANGE_TAG_SHIFT_REFUSAL.to_string()); + } + Ok(()) +} + +/// Matches the existing first-anchor insertion walk: own text, cells and Generic lists. +/// Captions are not targets of these anchor-based mutators. +pub(crate) fn ensure_anchor_shift_allowed( + doc: &Document, + anchor: &str, + inserted_w: u32, +) -> Result<(), String> { + fn first<'a>(para: &'a Paragraph, anchor: &str) -> Option<&'a Paragraph> { + if find_match(¶.chars, anchor, 0).is_some() { + return Some(para); + } + for control in ¶.controls { + match control { + Control::Table(table) => { + for cell in &table.cells { + for paragraph in &cell.paragraphs { + if let Some(found) = first(paragraph, anchor) { + return Some(found); + } + } + } + } + Control::Generic(generic) => { + for list in &generic.paragraph_lists { + for paragraph in &list.paragraphs { + if let Some(found) = first(paragraph, anchor) { + return Some(found); + } + } + } + } + _ => {} + } + } + None + } + for section in &doc.sections { + for para in §ion.paragraphs { + if let Some(found) = first(para, anchor) { + return ensure_range_tag_shift_allowed(found, 0, inserted_w); + } + } + } + Ok(()) +} + +fn replacement_chars(to: &str) -> Vec { + to.replace("\r\n", "\n") + .chars() + .filter_map(|c| match c { + '\n' => Some(HwpChar::CharCtrl(hwp_model::ctrl_char::LINE_BREAK)), + '\t' => Some(HwpChar::InlineCtrl { + code: hwp_model::ctrl_char::TAB, + payload: vec![0; 12], + }), + c if (c as u32) < 0x20 => None, + c => Some(HwpChar::Text(c)), + }) + .collect() +} + +/// Validates every original range before any back-to-front splice, including net-zero batches. +pub(crate) fn check_splice_edits( + para: &Paragraph, + edits: &[(std::ops::Range, String)], +) -> Result<(), String> { + if paragraph_has_range_tags(para) { + for (range, replacement) in edits { + let from_w = para.chars[range.clone()] + .iter() + .map(HwpChar::wchar_width) + .sum(); + let to_w = replacement_chars(replacement) + .iter() + .map(HwpChar::wchar_width) + .sum(); + ensure_range_tag_shift_allowed(para, from_w, to_w)?; + } + } + Ok(()) +} + +/// Replaces matches in contiguous Text spans across body, cells and text boxes. +/// With `all=false`, only the first match is changed. A width-changing tagged target +/// refuses before any target is mutated; the successful result is the replacement count. +pub fn replace_text(doc: &mut Document, from: &str, to: &str, all: bool) -> Result { if from.is_empty() { - return 0; + return Ok(0); + } + if document_has_range_tags(doc) { + replace_text_pass(doc, from, to, all, false)?; } + replace_text_pass(doc, from, to, all, true) +} + +fn replace_text_pass( + doc: &mut Document, + from: &str, + to: &str, + all: bool, + apply: bool, +) -> Result { let mut budget = if all { usize::MAX } else { 1 }; let mut count = 0; for section in &mut doc.sections { for para in &mut section.paragraphs { - count += replace_in_para(para, from, to, &mut budget); + count += replace_in_para(para, from, to, &mut budget, apply)?; if budget == 0 { - return count; + return Ok(count); } } } - count + Ok(count) } -fn replace_in_para(para: &mut Paragraph, from: &str, to: &str, budget: &mut usize) -> usize { - let mut n = replace_in_chars(para, from, to, budget); +fn replace_in_para( + para: &mut Paragraph, + from: &str, + to: &str, + budget: &mut usize, + apply: bool, +) -> Result { + let mut n = replace_in_chars(para, from, to, budget, apply)?; for ctrl in &mut para.controls { if *budget == 0 { break; } match ctrl { - Control::Table(t) => { - for cell in &mut t.cells { - for p in &mut cell.paragraphs { + Control::Table(table) => { + for cell in &mut table.cells { + for paragraph in &mut cell.paragraphs { if *budget == 0 { break; } - n += replace_in_para(p, from, to, budget); + n += replace_in_para(paragraph, from, to, budget, apply)?; } } } - Control::Generic(g) => { + Control::Generic(generic) => { let before = n; - for list in &mut g.paragraph_lists { - for p in &mut list.paragraphs { + for list in &mut generic.paragraph_lists { + for paragraph in &mut list.paragraphs { if *budget == 0 { break; } - n += replace_in_para(p, from, to, budget); + n += replace_in_para(paragraph, from, to, budget, apply)?; } } - if n > before { - // 내용이 바뀐 개체의 원문 XML은 낡았다 — stale 방출 금지. - g.hwpx_raw_xml = None; + if apply && n > before { + generic.hwpx_raw_xml = None; } } _ => {} } } - n + Ok(n) } -/// 한 문단의 `chars` 안에서 치환을 반복한다(budget 한도). char_shape_run 위치를 -/// 보정한다. 줄 배치는 비워 두고(낡음) writer가 재합성하게 한다. -fn replace_in_chars(para: &mut Paragraph, from: &str, to: &str, budget: &mut usize) -> usize { +fn replace_in_chars( + para: &mut Paragraph, + from: &str, + to: &str, + budget: &mut usize, + apply: bool, +) -> Result { let from_w = utf16_len(from); let from_chars = from.chars().count(); + let to_w = replacement_chars(to).iter().map(HwpChar::wchar_width).sum(); let mut count = 0; - // 삽입한 `to` 다음부터 이어서 탐색한다 — `to`가 `from`을 포함하면(예: - // "한라대학교"→"제주한라대학교") 처음부터 재탐색 시 삽입한 텍스트 안에서 - // 다시 매칭돼 무한 루프에 빠진다. let mut start = 0usize; while *budget > 0 { let Some((char_idx, wpos)) = find_match(¶.chars, from, start) else { break; }; - let inserted = splice_text(para, char_idx..char_idx + from_chars, wpos, from_w, to); + let inserted = if apply { + splice_text(para, char_idx..char_idx + from_chars, wpos, from_w, to)? + } else { + ensure_range_tag_shift_allowed(para, from_w, to_w)?; + from_chars + }; count += 1; *budget -= 1; start = char_idx + inserted; } - count + Ok(count) } -/// Replace the text characters `range` of `para` (starting at WCHAR offset `wpos`, `from_w` -/// WCHARs wide) with `to`, and return how many characters went in. The new text takes the char -/// shape active at `wpos`, later run boundaries shift by the length change, and the now stale -/// line layout is dropped for the writer to rebuild. `to` is normalized the way markdown import -/// stores text: CRLF and LF become a line break, a tab the 8-WCHAR inline tab control, and -/// other C0 controls are dropped. +/// Replaces one text span after validating its normalized WCHAR width. Range records stay opaque. pub(crate) fn splice_text( para: &mut Paragraph, range: std::ops::Range, wpos: u32, from_w: u32, to: &str, -) -> usize { - let to_hwp: Vec = to - .replace("\r\n", "\n") - .chars() - .filter_map(|c| match c { - '\n' => Some(HwpChar::CharCtrl(hwp_model::ctrl_char::LINE_BREAK)), - '\t' => Some(HwpChar::InlineCtrl { - code: hwp_model::ctrl_char::TAB, - payload: vec![0; 12], - }), - c if (c as u32) < 0x20 => None, - c => Some(HwpChar::Text(c)), - }) - .collect(); +) -> Result { + let to_hwp = replacement_chars(to); let to_w = to_hwp.iter().map(HwpChar::wchar_width).sum(); + ensure_range_tag_shift_allowed(para, from_w, to_w)?; let inserted = to_hwp.len(); - para.chars.splice(range, to_hwp); - adjust_runs(&mut para.char_shape_runs, wpos, from_w, to_w); - para.line_segs.clear(); - inserted + if !paragraph_has_range_tags(para) || para.chars[range.clone()] != to_hwp { + para.chars.splice(range, to_hwp); + adjust_runs(&mut para.char_shape_runs, wpos, from_w, to_w); + para.line_segs.clear(); + } + Ok(inserted) } -/// Apply `edits` (character ranges of `para`, in order and not overlapping, with their -/// replacements) back to front, so each range still names the original text when it is spliced. -pub(crate) fn splice_edits(para: &mut Paragraph, edits: Vec<(std::ops::Range, String)>) { +/// Applies disjoint original spans back to front, after validating the whole paragraph batch. +pub(crate) fn splice_edits( + para: &mut Paragraph, + edits: Vec<(std::ops::Range, String)>, +) -> Result<(), String> { + check_splice_edits(para, &edits)?; for (range, replacement) in edits.into_iter().rev() { let wpos = para.chars[..range.start] .iter() @@ -302,29 +464,32 @@ pub(crate) fn splice_edits(para: &mut Paragraph, edits: Vec<(std::ops::Range usize { +/// Addressed replacement touches one writable paragraph and preserves ancestors on refusal. +pub fn replace_text_at( + doc: &mut Document, + path: &SegmentPath, + from: &str, + to: &str, +) -> Result { if from.is_empty() { - return 0; + return Ok(0); } let Some(para) = crate::address::paragraph_at_mut(doc, path) else { - return 0; + return Ok(0); }; let mut budget = usize::MAX; - let count = replace_in_chars(para, from, to, &mut budget); + replace_in_chars(para, from, to, &mut budget, false)?; + budget = usize::MAX; + let count = replace_in_chars(para, from, to, &mut budget, true)?; if count > 0 { crate::address::invalidate_ancestors(doc, path); } - count + Ok(count) } /// 연속된 Text 문자열에서 `start_idx` 이후 `from`의 첫 위치를 찾는다. @@ -397,6 +562,9 @@ pub fn set_cell( col: u16, text: &str, ) -> Result<(), String> { + if document_has_range_tags(doc) && check_set_cell_edit(doc, table_index, row, col, text)? { + return Ok(()); + } // The hwp5-sourced edit path runs the writer with synthesize=false, so neither // assign_instance_ids nor set_last_para_flag runs and paragraphs created here // must carry their own document-unique ids. Read the global maximum before the @@ -1500,6 +1668,55 @@ fn split_cell_blocks(text: &str) -> Vec { blocks } +/// Checks the whole cell subtree before the rebuilding walk can invalidate ancestors. +/// A tagged exact no-op preserves the existing paragraphs and their opaque records. +pub(crate) fn check_set_cell_edit( + doc: &mut Document, + table_index: usize, + row: u16, + col: u16, + text: &str, +) -> Result { + let blocks = split_cell_blocks(text); + with_nth_table_readonly(doc, table_index, |table| { + let cell = table + .cells + .iter() + .find(|cell| cell.row == row && cell.col == col) + .ok_or_else(|| format!("표에 셀 ({row}, {col})이 없습니다"))?; + if !cell.paragraphs.iter().any(paragraph_tree_has_range_tags) { + return Ok(false); + } + let identical = cell.paragraphs.len() == blocks.len() + && cell.paragraphs.iter().zip(&blocks).all(|(para, block)| { + if !para.controls.is_empty() { + return false; + } + let chars = para + .chars + .strip_suffix(&[HwpChar::CharCtrl(hwp_model::ctrl_char::PARA_BREAK)]) + .unwrap_or(¶.chars); + let expected: Vec<_> = block + .chars() + .map(|c| { + if c == '\n' { + HwpChar::CharCtrl(hwp_model::ctrl_char::LINE_BREAK) + } else { + HwpChar::Text(c) + } + }) + .collect(); + chars == expected + }); + if identical { + Ok(true) + } else { + Err(RANGE_TAG_SHIFT_REFUSAL.to_string()) + } + }) + .unwrap_or_else(|| Err(format!("표 #{table_index}를 찾을 수 없습니다"))) +} + /// `next_instance_id` must already be a reserved, overflow-checked range of /// `blocks.len() - 1` ids - see [`set_cell`], which refuses before mutating anything. fn set_cell_in_table( @@ -2382,15 +2599,31 @@ pub enum ObjectKind { /// Deletes objects — removes the control together with its anchor chars (through FIELD_END for /// fields) and adjusts WCHAR positions (GK-8). Recurses through body, table cells, and text /// boxes. Returns the number deleted. -pub fn delete_object(doc: &mut Document, kind: ObjectKind, selector: &str) -> usize { +pub fn delete_object( + doc: &mut Document, + kind: ObjectKind, + selector: &str, +) -> Result { + if document_has_range_tags(doc) { + delete_object_pass(doc, kind, selector, false)?; + } + delete_object_pass(doc, kind, selector, true) +} + +fn delete_object_pass( + doc: &mut Document, + kind: ObjectKind, + selector: &str, + apply: bool, +) -> Result { let mut count = 0; let mut table_seen = 0usize; for section in &mut doc.sections { for para in &mut section.paragraphs { - count += delete_object_in_para(para, kind, selector, &mut table_seen); + count += delete_object_in_para(para, kind, selector, &mut table_seen, apply)?; } } - count + Ok(count) } fn delete_object_in_para( @@ -2398,7 +2631,8 @@ fn delete_object_in_para( kind: ObjectKind, selector: &str, table_seen: &mut usize, -) -> usize { + apply: bool, +) -> Result { // Collects the indexes of the controls to delete. let targets: Vec = para .controls @@ -2438,7 +2672,7 @@ fn delete_object_in_para( Control::Table(t) => { for cell in &mut t.cells { for p in &mut cell.paragraphs { - n += delete_object_in_para(p, kind, selector, table_seen); + n += delete_object_in_para(p, kind, selector, table_seen, apply)?; } } } @@ -2446,10 +2680,10 @@ fn delete_object_in_para( let before = n; for list in &mut g.paragraph_lists { for p in &mut list.paragraphs { - n += delete_object_in_para(p, kind, selector, table_seen); + n += delete_object_in_para(p, kind, selector, table_seen, apply)?; } } - if n > before { + if apply && n > before { // 내용이 바뀐 개체의 원문 XML은 낡았다 — stale 방출 금지. g.hwpx_raw_xml = None; } @@ -2458,30 +2692,30 @@ fn delete_object_in_para( } } if targets.is_empty() { - return n; + return Ok(n); + } + if apply { + n += remove_controls(para, &targets)?; + } else { + check_control_removal(para, &targets)?; + n += targets.len(); } - n += remove_controls(para, &targets); - n + Ok(n) } /// Removes the controls at the `targets` indexes and their anchor chars (ExtCtrl + FIELD_END of /// a field start), and adjusts char_shape_runs positions. Returns the number removed. -fn remove_controls(para: &mut Paragraph, targets: &[usize]) -> usize { - // 1) Remove anchor chars — the ExtCtrl pointing at a target control + the field's FIELD_END. - let mut removed: Vec<(u32, u32)> = Vec::new(); - let mut orig_pos = 0u32; - let mut kept = Vec::with_capacity(para.chars.len()); - // Whether we are looking for the FIELD_END (code 4) matching a deleted FIELD_START (code 3). - // Text/CharCtrl in between pass through untouched. +fn check_control_removal(para: &Paragraph, targets: &[usize]) -> Result, String> { + let mut removed = Vec::new(); + let mut orig_pos = 0; let mut pending_field_end = false; - for ch in std::mem::take(&mut para.chars) { + for ch in ¶.chars { let width = ch.wchar_width(); - let drop = match &ch { + let drop = match ch { HwpChar::ExtCtrl { code, ctrl_index, .. } => { let hit = ctrl_index.is_some_and(|i| targets.contains(&(i as usize))); - // Only a field start (FIELD_START=3) opens FIELD_END tracking — pictures, tables, and bookmarks have none. pending_field_end = hit && *code == 3; hit } @@ -2496,11 +2730,24 @@ fn remove_controls(para: &mut Paragraph, targets: &[usize]) -> usize { }; if drop { removed.push((orig_pos, width)); - } else { - kept.push(ch); } orig_pos += width; } + ensure_range_tag_shift_allowed(para, removed.iter().map(|(_, width)| width).sum(), 0)?; + Ok(removed) +} + +fn remove_controls(para: &mut Paragraph, targets: &[usize]) -> Result { + let removed = check_control_removal(para, targets)?; + let mut orig_pos = 0; + let kept = std::mem::take(&mut para.chars) + .into_iter() + .filter(|ch| { + let keep = !removed.iter().any(|(pos, _)| *pos == orig_pos); + orig_pos += ch.wchar_width(); + keep + }) + .collect(); para.chars = kept; // 2) Remove the controls (in reverse order to preserve indexes). let mut sorted = targets.to_vec(); @@ -2521,7 +2768,7 @@ fn remove_controls(para: &mut Paragraph, targets: &[usize]) -> usize { para.char_shape_runs.dedup(); // 4) Reconnect ExtCtrl ↔ controls in order of appearance. crate::field::relink_ctrl_index(para); - n + Ok(n) } #[cfg(test)] @@ -2538,7 +2785,7 @@ mod tests { section: 0, indices: vec![1], }; - let n = replace_text_at(&mut doc, &path, "같은", "다른"); + let n = replace_text_at(&mut doc, &path, "같은", "다른").unwrap(); assert_eq!(n, 1); assert_eq!(doc.sections[0].paragraphs[0].plain_text(), "같은 문단"); assert_eq!(doc.sections[0].paragraphs[1].plain_text(), "다른 문단"); @@ -2553,7 +2800,7 @@ mod tests { section: 0, indices: vec![99], }; - let n = replace_text_at(&mut doc, &path, "문단", "다른"); + let n = replace_text_at(&mut doc, &path, "문단", "다른").unwrap(); assert_eq!(n, 0); assert_eq!(doc, before); } @@ -2894,7 +3141,10 @@ mod tests { std::fs::write(&tmp, &png).unwrap(); crate::image::insert_image(&mut doc, "본문", &tmp, crate::image::ImageSize::Natural) .unwrap(); - assert_eq!(delete_object(&mut doc, ObjectKind::Image, "본문"), 1); + assert_eq!( + delete_object(&mut doc, ObjectKind::Image, "본문").unwrap(), + 1 + ); let has_pic = doc.sections[0] .paragraphs .iter() @@ -2903,7 +3153,10 @@ mod tests { assert!(!has_pic, "그림 삭제됨"); let _ = std::fs::remove_file(&tmp); // Deleting the nth table — removing the 0-th (the GFM table) makes the table disappear. - assert_eq!(delete_object(&mut doc, ObjectKind::TableNth(0), ""), 1); + assert_eq!( + delete_object(&mut doc, ObjectKind::TableNth(0), "").unwrap(), + 1 + ); let has_table = doc.sections[0] .paragraphs .iter() @@ -2911,7 +3164,10 @@ mod tests { .any(|c| matches!(c, Control::Table(_))); assert!(!has_table, "표 삭제됨"); // A missing index deletes 0. - assert_eq!(delete_object(&mut doc, ObjectKind::TableNth(3), ""), 0); + assert_eq!( + delete_object(&mut doc, ObjectKind::TableNth(3), "").unwrap(), + 0 + ); } #[test] @@ -2923,8 +3179,14 @@ mod tests { "앵커", "책갈피1" )); - assert_eq!(delete_object(&mut doc, ObjectKind::Field, "이름"), 1); - assert_eq!(delete_object(&mut doc, ObjectKind::Bookmark, "책갈피1"), 1); + assert_eq!( + delete_object(&mut doc, ObjectKind::Field, "이름").unwrap(), + 1 + ); + assert_eq!( + delete_object(&mut doc, ObjectKind::Bookmark, "책갈피1").unwrap(), + 1 + ); // No leftover fields/bookmarks may remain. assert!(crate::field::list_fields(&doc).is_empty()); assert!(crate::bookmark::list_bookmarks(&doc).is_empty()); @@ -2959,7 +3221,7 @@ mod tests { for p in &mut doc.sections[0].paragraphs { p.line_segs.push(dummy_lineseg()); } - let n = replace_text(&mut doc, "바꿀문단", "변경됨", true); + let n = replace_text(&mut doc, "바꿀문단", "변경됨", true).unwrap(); assert_eq!(n, 1); let paras = &doc.sections[0].paragraphs; let edited = paras @@ -2977,7 +3239,7 @@ mod tests { #[test] fn 본문_치환_길이변화_run보정() { let mut doc = from_markdown("부서명을 적으세요\n"); - let n = replace_text(&mut doc, "부서명", "기획팀입니다", true); + let n = replace_text(&mut doc, "부서명", "기획팀입니다", true).unwrap(); assert_eq!(n, 1); let text = doc.plain_text(); assert!(text.contains("기획팀입니다을 적으세요"), "got: {text:?}"); @@ -2999,7 +3261,7 @@ mod tests { fn 치환문이_찾기문_포함_무한루프_없음() { // "한라대학교" → "제주한라대학교": to가 from을 포함 → 재탐색 무한루프 방지. let mut doc = from_markdown("한라대학교 보고서\n"); - let n = replace_text(&mut doc, "한라대학교", "제주한라대학교", true); + let n = replace_text(&mut doc, "한라대학교", "제주한라대학교", true).unwrap(); assert_eq!(n, 1); let text = doc.plain_text(); assert!(text.contains("제주한라대학교 보고서"), "got: {text:?}"); @@ -3009,9 +3271,9 @@ mod tests { #[test] fn 치환_전체_vs_단일() { let mut doc = from_markdown("가 가 가\n"); - let single = replace_text(&mut doc.clone(), "가", "나", false); + let single = replace_text(&mut doc.clone(), "가", "나", false).unwrap(); assert_eq!(single, 1); - let all = replace_text(&mut doc, "가", "나", true); + let all = replace_text(&mut doc, "가", "나", true).unwrap(); assert_eq!(all, 3); assert!(doc.plain_text().contains("나 나 나")); } diff --git a/crates/hwp-convert/src/field.rs b/crates/hwp-convert/src/field.rs index f42a1986..83873fc7 100644 --- a/crates/hwp-convert/src/field.rs +++ b/crates/hwp-convert/src/field.rs @@ -1,13 +1,13 @@ -//! 필드/누름틀 읽기·채우기 (한컴 GetFieldList/PutFieldText 동등). +//! Field and click-here-field discovery and filling (Hancom GetFieldList/PutFieldText equivalents). //! -//! HWP 필드 = FIELD_START(문자 코드 3, 확장 컨트롤) ... 표시 텍스트 ... FIELD_END(코드 4). -//! - 종류: 컨트롤 ID (%clk=누름틀, %fmu=계산식, %hlk=하이퍼링크 등). -//! - 이름: CTRL_DATA(HWPTAG, Parameter Set)의 첫 BSTR — 누름틀에만 보통 존재. -//! - 명령: 필드 레코드의 command 문자열(계산식 식, 누름틀 지시문 등). -//! - 값: FIELD_START~FIELD_END 사이 텍스트. +//! HWP fields contain FIELD_START (character code 3, extended control), display text, then FIELD_END (code 4). +//! - Kind: control ID (%clk click-here, %fmu formula, %hlk hyperlink, etc.). +//! - Name: first BSTR in CTRL_DATA (HWPTAG Parameter Set), usually present only for click-here fields. +//! - Command: the field record command string (formula, click-here instructions, etc.). +//! - Value: text between FIELD_START and FIELD_END. //! -//! IR을 바꾸지 않고 온디맨드로 파싱한다(리더/writer/왕복 무영향). 채우기는 값 영역만 -//! 교체하고 char_shape_run을 보정한다 — 쓰기는 편집 경로(write_hwp_edited)를 거친다. +//! Metadata is parsed on demand without changing the IR, readers, writers or identity round-trips. +//! Filling replaces the value and adjusts character-shape runs; writing uses write_hwp_edited. use std::collections::BTreeMap; @@ -20,32 +20,32 @@ const CTRL_DATA_TAG: u16 = 0x0010 + 71; // HWPTAG_CTRL_DATA const FIELD_START: u16 = 3; const FIELD_END: u16 = 4; -/// 필드 하나의 정보. +/// Information about one field. #[derive(Debug, Clone, PartialEq)] pub struct FieldInfo { - /// 종류 표시명(누름틀/계산식/...). + /// Display name of the field kind. pub kind: String, - /// 컨트롤 ID 원문(예: "%clk"). + /// Original control ID, such as "%clk". pub ctrl_id: String, - /// 필드 이름(누름틀; CTRL_DATA). 없으면 None. + /// Optional field name from CTRL_DATA. pub name: Option, - /// 명령/지시문(필드 레코드 command). 없으면 None. + /// Optional command or instruction from the field record. pub command: Option, - /// 화면 표시 값. + /// Displayed value. pub value: String, } -/// 필드 종류 컨트롤 ID인지(누름틀·계산식·하이퍼링크 등). 스펙 표 128의 34종 중 -/// **메모(`%%me`)를 제외한 33종**을 인식한다. 인식하지 못하면 hwpx write가 필드를 -/// **통째 드롭**(catch-all)하므로, 종류 라벨을 몰라도 최소한 fieldBegin/End·내용 -/// 텍스트는 보존하도록 목록에 담는다(OWPML type은 [`owpml_field_type`]의 UNKNOWN -/// 폴백 — GF-1과 동일 손실 등급). `%%me`는 메모 리스트(GB-7)와 짝을 이뤄야 하는데 -/// 그 본문은 아직 미방출이라, 근거 없이 dangling 메모 필드를 내보내면 한글에서 -/// 문서가 깨질 수 있어 **의도적으로 보류**한다(정답지 필요). +/// Recognize 33 of the 34 field kinds in specification table 128, excluding memo (`%%me`). +/// Unknown control IDs are otherwise dropped by the HWPX writer catch-all, so recognized +/// kinds preserve fieldBegin/End and their text even when their kind label is unknown. +/// The OWPML type then uses the UNKNOWN fallback in [`owpml_field_type`] (GF-1 loss level). +/// Memo fields must pair with memo lists (GB-7), whose bodies are not yet emitted. +/// A dangling memo field could corrupt the document in Hangul, so this kind remains +/// intentionally excluded until genuine-file evidence establishes safe emission. pub fn is_field_ctrl_id(ctrl_id: &[u8; 4]) -> bool { matches!( ctrl_id, - // 기존 12종(표 128). + // Original 12 kinds (table 128). b"%clk" | b"%fmu" | b"%hlk" @@ -58,7 +58,7 @@ pub fn is_field_ctrl_id(ctrl_id: &[u8; 4]) -> bool { | b"%smr" | b"%usr" | b"%unk" - // 변경추적 필드 19종(FIELD_REVISION_*, 표 128). + // 19 revision-tracking kinds (FIELD_REVISION_*, table 128). | b"%sig" | b"%spl" | b"%%mr" @@ -78,13 +78,13 @@ pub fn is_field_ctrl_id(ctrl_id: &[u8; 4]) -> bool { | b"%%*l" | b"%%*n" | b"%%*e" - // 개인정보 보안·차례(표 128). 메모(%%me)는 GB-7 연동 우려로 보류. + // Privacy and table of contents (table 128). Memo needs GB-7 support. | b"%cpr" | b"%toc" ) } -/// 컨트롤 ID → OWPML fieldBegin type 속성. +/// Map a control ID to the OWPML fieldBegin type attribute. pub fn owpml_field_type(ctrl_id: &[u8; 4]) -> &'static str { match ctrl_id { b"%clk" => "CLICK_HERE", @@ -98,15 +98,15 @@ pub fn owpml_field_type(ctrl_id: &[u8; 4]) -> &'static str { b"%pat" => "PATH", b"%smr" => "SUMMARY", b"%usr" => "USER_INFO", - // 차례 — 정답지 코퍼스 실측(과업지시서 hwpx `type="TABLEOFCONTENTS"`). + // Genuine corpus evidence: the task-description HWPX uses type="TABLEOFCONTENTS". b"%toc" => "TABLEOFCONTENTS", - // 변경추적 19종·개인정보(%cpr)는 OWPML type 이름 근거가 없어 UNKNOWN 폴백 - // (fieldBegin/End·내용은 보존, 종류 라벨만 상실 — GF-1 등급). + // The OWPML names for revision kinds and privacy (%cpr) lack evidence, so use UNKNOWN. + // Keep fieldBegin/End and content; only the kind label is lost (GF-1 level). _ => "UNKNOWN", } } -/// OWPML fieldBegin type → 컨트롤 ID(역매핑, 미지는 `%unk`). +/// Map an OWPML fieldBegin type back to a control ID, using `%unk` for unknown types. pub fn field_ctrl_id_from_owpml(t: &str) -> [u8; 4] { match t { "CLICK_HERE" => *b"%clk", @@ -141,11 +141,11 @@ fn kind_of(ctrl_id: &[u8; 4]) -> &'static str { b"%cpr" => "개인정보보호", b"%toc" => "차례", b"%unk" => "알수없음", - _ => "필드", // 변경추적(FIELD_REVISION_*) 등은 일반 라벨 + _ => "필드", // Revision-tracking kinds use the generic label. } } -/// 문서의 모든 필드를 등장 순서로 나열한다(본문·표 셀·글상자 재귀). +/// List every field in encounter order, recursing through body, table cells and text boxes. pub fn list_fields(doc: &Document) -> Vec { let mut out = Vec::new(); for section in &doc.sections { @@ -184,7 +184,7 @@ fn collect_fields(para: &Paragraph, out: &mut Vec) { } } -/// FIELD_START(start) 다음부터 FIELD_END 전까지의 표시 텍스트. +/// Display text after FIELD_START at `start` and before FIELD_END. fn field_value(chars: &[HwpChar], start: usize) -> String { let mut s = String::new(); for ch in &chars[start + 1..] { @@ -198,10 +198,10 @@ fn field_value(chars: &[HwpChar], start: usize) -> String { s } -/// %hlk 하이퍼링크 컨트롤이면 대상 URL을 복원해 돌려준다(md/html 링크 방출용). -/// 표시 텍스트는 FIELD_START~FIELD_END 사이 문자열이고, URL은 커맨드 -/// (`{URL};1;0;0;`)에 담긴다 — 첫 비이스케이프 `;` 전까지가 URL이며 -/// [`hlk_command`]의 백슬래시 이스케이프를 되돌린다. +/// Restore the target URL of a %hlk control for Markdown/HTML link emission. +/// Display text is between FIELD_START and FIELD_END. The command stores the URL as +/// `{URL};1;0;0;`: take text before the first unescaped semicolon, then undo +/// the backslash escaping performed by [`hlk_command`]. pub fn hyperlink_url(ctrl: &Control) -> Option { let Control::Generic(g) = ctrl else { return None; @@ -213,8 +213,8 @@ pub fn hyperlink_url(ctrl: &Control) -> Option { Some(unescape_hlk_url(&cmd)) } -/// [`hlk_command`]의 역: 첫 비이스케이프 `;`(필드 구분자) 전까지를 URL로 보고 -/// `\\`·`\;`·`\:` 등 백슬래시 이스케이프를 되돌린다. +/// Invert [`hlk_command`]: take the URL before the first unescaped semicolon delimiter +/// and remove its backslash escapes. fn unescape_hlk_url(cmd: &str) -> String { let mut out = String::with_capacity(cmd.len()); let mut it = cmd.chars(); @@ -232,7 +232,7 @@ fn unescape_hlk_url(cmd: &str) -> String { out } -/// 필드 컨트롤에서 (이름, 명령)을 읽는다. +/// Read the name and command of a field control. pub fn field_meta(ctrl: &Control) -> (Option, Option) { let Control::Generic(g) = ctrl else { return (None, None); @@ -245,7 +245,7 @@ pub fn field_meta(ctrl: &Control) -> (Option, Option) { (name, parse_command(&g.data)) } -/// 필드 레코드(ctrl_id 제거됨) command 문자열: 속성(4) 기타(1) len(2) WCHAR[len] id(4). +/// Field command data without ctrl_id: attributes(4), extra(1), length(2), WCHAR[length], id(4). fn parse_command(data: &[u8]) -> Option { if data.len() < 7 { return None; @@ -258,7 +258,7 @@ fn parse_command(data: &[u8]) -> Option { Some(decode_utf16le(&data[7..end])) } -/// CTRL_DATA Parameter Set의 첫 BSTR(필드 이름). 알 수 없는 항목 타입을 만나면 중단. +/// Read the first BSTR (field name) in CTRL_DATA; stop at an unknown item type. fn first_bstr(data: &[u8]) -> Option { if data.len() < 4 { return None; @@ -290,7 +290,7 @@ fn first_bstr(data: &[u8]) -> Option { 2 | 6 => o += 1, // I1/UI1 3 | 7 => o += 2, // I2/UI2 4 | 5 | 8 | 9 => o += 4, // I4/I/UI4/UI - _ => break, // SET/ARRAY/BINDATA/미지 — 안전 중단 + _ => break, // Stop safely at SET/ARRAY/BINDATA or unknown types. } } None @@ -304,19 +304,31 @@ fn decode_utf16le(b: &[u8]) -> String { String::from_utf16_lossy(&units) } -/// 이름이 일치하는 필드의 값을 `value`로 채운다. 반환=채운 개수. -pub fn set_field(doc: &mut Document, name: &str, value: &str) -> usize { +/// Fill matching named fields and return the count, refusing edits that shift opaque range tags. +pub fn set_field(doc: &mut Document, name: &str, value: &str) -> Result { + if crate::edit::document_has_range_tags(doc) { + for section in &mut doc.sections { + for para in &mut section.paragraphs { + set_field_para(para, name, value, false)?; + } + } + } let mut count = 0; for section in &mut doc.sections { for para in &mut section.paragraphs { - count += set_field_para(para, name, value); + count += set_field_para(para, name, value, true)?; } } - count + Ok(count) } -fn set_field_para(para: &mut Paragraph, name: &str, value: &str) -> usize { - // 불변 스캔: 이름 일치 필드의 (값 시작 char idx, 값 끝 char idx, 값 WCHAR 위치, 옛 값 WCHAR 길이). +fn set_field_para( + para: &mut Paragraph, + name: &str, + value: &str, + apply: bool, +) -> Result { + // Scan without mutation: value start/end character indices, WCHAR position and old WCHAR width. let mut regions: Vec<(usize, usize, u32, u32)> = Vec::new(); let mut wpos = 0u32; for (idx, ch) in para.chars.iter().enumerate() { @@ -346,13 +358,19 @@ fn set_field_para(para: &mut Paragraph, name: &str, value: &str) -> usize { } let count = regions.len(); - let new_wlen = utf16_len(value); - // 인덱스 무효화 방지: 뒤에서 앞으로 적용. + let new_wlen = build_value_chars(value) + .iter() + .map(HwpChar::wchar_width) + .sum(); + // Apply from the end so earlier character indices stay valid. for (s, e, vp, old_wlen) in regions.into_iter().rev() { - para.chars.splice(s..e, build_value_chars(value)); - adjust_runs(&mut para.char_shape_runs, vp, old_wlen, new_wlen); + crate::edit::ensure_range_tag_shift_allowed(para, old_wlen, new_wlen)?; + if apply { + para.chars.splice(s..e, build_value_chars(value)); + adjust_runs(&mut para.char_shape_runs, vp, old_wlen, new_wlen); + } } - if count > 0 { + if apply && count > 0 { para.line_segs.clear(); } @@ -362,7 +380,7 @@ fn set_field_para(para: &mut Paragraph, name: &str, value: &str) -> usize { Control::Table(t) => { for cell in &mut t.cells { for p in &mut cell.paragraphs { - nested += set_field_para(p, name, value); + nested += set_field_para(p, name, value, apply)?; } } } @@ -370,18 +388,18 @@ fn set_field_para(para: &mut Paragraph, name: &str, value: &str) -> usize { let before = nested; for l in &mut g.paragraph_lists { for p in &mut l.paragraphs { - nested += set_field_para(p, name, value); + nested += set_field_para(p, name, value, apply)?; } } - if nested > before { - // 내용이 바뀐 개체의 원문 XML은 낡았다 — stale 방출 금지. + if apply && nested > before { + // Changed content makes the captured object XML stale. g.hwpx_raw_xml = None; } } _ => {} } } - count + nested + Ok(count + nested) } fn build_value_chars(value: &str) -> Vec { @@ -397,12 +415,12 @@ fn build_value_chars(value: &str) -> Vec { .collect() } -/// `{{name}}` 텍스트 자리표시자 하나. +/// One `{{name}}` text placeholder. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PlaceholderInfo { - /// 자리표시자 이름(중괄호 안, 양끝 공백 제거). + /// Placeholder name inside braces, with surrounding whitespace trimmed. pub name: String, - /// 문서 전체 등장 횟수. + /// Number of occurrences throughout the document. pub occurrences: usize, } @@ -428,27 +446,37 @@ pub fn scan_placeholders(doc: &Document) -> Vec { /// token is filled once whatever its spelling. Keys are trimmed like names /// ([`hwp_model::slot_lookup`]); keys naming one slot with equal values each count its tokens. /// Returns the count per key as the caller spelled it, every key included. +/// Validate every selected edit before mutation when the document carries opaque range tags. pub fn replace_slots( doc: &mut Document, values: &BTreeMap, ) -> Result, String> { let lookup = hwp_model::slot_lookup(values)?; + if crate::edit::document_has_range_tags(doc) { + let mut ignored = BTreeMap::new(); + for section in &mut doc.sections { + for para in &mut section.paragraphs { + replace_slots_in(para, &lookup, &mut ignored, false)?; + } + } + } let mut counts: BTreeMap = values.keys().map(|k| (k.clone(), 0)).collect(); for section in &mut doc.sections { for para in &mut section.paragraphs { - replace_slots_in(para, &lookup, &mut counts); + replace_slots_in(para, &lookup, &mut counts, true)?; } } Ok(counts) } -/// [`replace_slots`] on `para` and its nested paragraphs. Returns whether anything changed, so -/// a text box on the way drops its now stale raw XML. +/// Validate or apply [`replace_slots`] on `para` and its nested paragraphs. +/// Only the apply pass invalidates captured raw XML and writes characters. fn replace_slots_in( para: &mut Paragraph, lookup: &BTreeMap<&str, hwp_model::SlotRequest<'_, String>>, counts: &mut BTreeMap, -) -> bool { + apply: bool, +) -> Result { let mut edits = Vec::new(); for (start, seg) in text_segments(para) { for token in hwp_model::slot_tokens(&seg) { @@ -463,13 +491,17 @@ fn replace_slots_in( } } let mut changed = !edits.is_empty(); - crate::edit::splice_edits(para, edits); + if apply { + crate::edit::splice_edits(para, edits)?; + } else { + crate::edit::check_splice_edits(para, &edits)?; + } for ctrl in &mut para.controls { match ctrl { Control::Table(table) => { for cell in &mut table.cells { for p in &mut cell.paragraphs { - changed |= replace_slots_in(p, lookup, counts); + changed |= replace_slots_in(p, lookup, counts, apply)?; } } } @@ -477,10 +509,10 @@ fn replace_slots_in( let mut inner = false; for list in &mut generic.paragraph_lists { for p in &mut list.paragraphs { - inner |= replace_slots_in(p, lookup, counts); + inner |= replace_slots_in(p, lookup, counts, apply)?; } } - if inner { + if apply && inner { // The object's content changed, so its raw XML is stale. generic.hwpx_raw_xml = None; } @@ -489,7 +521,7 @@ fn replace_slots_in( _ => {} } } - changed + Ok(changed) } /// Hand `f` each run of contiguous text characters of `para` and of its nested paragraphs. @@ -539,7 +571,7 @@ fn scan_segment(seg: &str, out: &mut Vec) { } } -/// 컨트롤의 중첩 문단(표 셀·글상자 리스트)에 f를 적용한다(읽기 전용). +/// Apply `f` to nested table-cell and text-box paragraphs without mutation. fn for_each_nested(ctrl: &Control, f: &mut impl FnMut(&Paragraph)) { match ctrl { Control::Table(t) => { @@ -560,7 +592,7 @@ fn for_each_nested(ctrl: &Control, f: &mut impl FnMut(&Paragraph)) { } } -/// ExtCtrl payload(12B): 선두 4B = 역순 ctrl_id(리더가 역순으로 파싱), 나머지 0. +/// ExtCtrl payload (12 bytes): reversed ctrl_id in the first four bytes, then zeros. pub fn rev_payload(ctrl_id: &[u8; 4]) -> Vec { let mut p = vec![0u8; 12]; p[0] = ctrl_id[3]; @@ -570,20 +602,20 @@ pub fn rev_payload(ctrl_id: &[u8; 4]) -> Vec { p } -/// FIELD_END(코드 4) payload(12B): 역순 ctrl_id 3바이트(`%` 제외) + 0. -/// 정품 실측(테스트.hwp 한글 제작): %hlk END = `6b 6c 68 00`(="klh\0"). END payload가 -/// 전부 0이면 한글이 FIELD_START↔END 짝을 못 지어 필드 미완성 → **클릭 이동 불가** -/// (4차 정답지 대조 확정). START는 `%`까지(4B) 담고 END는 `%`를 뺀 3B만 담는다. +/// FIELD_END (code 4) payload: three reversed ctrl_id bytes (without `%`), then zero. +/// Genuine Hangul-authored test-file evidence: %hlk END starts `6b 6c 68 00` ("klh" then zero). +/// With an all-zero END payload Hangul cannot pair FIELD_START and END, so clicking fails +/// (confirmed in the fourth oracle comparison). START includes `%`; END omits it. pub fn field_end_payload(ctrl_id: &[u8; 4]) -> Vec { let mut p = vec![0u8; 12]; p[0] = ctrl_id[3]; p[1] = ctrl_id[2]; p[2] = ctrl_id[1]; - // p[3] = 0 ('%' 마커는 END에 담지 않음) + // p[3] stays zero; END omits the '%' marker. p } -/// CTRL_DATA Parameter Set: 필드 이름 BSTR 1개(setid0·count1·id1·BSTR). +/// CTRL_DATA Parameter Set with one field-name BSTR (setid 0, count 1, item id 1). pub fn make_field_ctrl_data(name: &str) -> Vec { let mut cd = vec![0u8, 0, 1, 0]; // setid=0, count=1 cd.extend([1u8, 0, 1, 0]); // item id=1, type=BSTR(1) @@ -595,12 +627,12 @@ pub fn make_field_ctrl_data(name: &str) -> Vec { cd } -/// 필드 컨트롤 레코드. `name`이 있으면 CTRL_DATA(누름틀 이름), `command`가 있으면 -/// data에 명령(하이퍼링크 URL 등)을 싣는다. +/// Build a field control record, with optional CTRL_DATA name and optional command +/// data such as a hyperlink URL. fn make_field_control(ctrl_id: [u8; 4], name: Option<&str>, command: Option<&str>) -> Control { let data = match command { Some(cmd) => make_field_command_data(&ctrl_id, cmd), - None => vec![0u8; 11], // 속성4 기타1 len2=0 id4 + None => vec![0u8; 11], // attributes(4), extra(1), zero length(2), id(4). }; let raw_children = match name { Some(nm) => vec![OpaqueRecord { @@ -625,15 +657,15 @@ fn make_field_control(ctrl_id: [u8; 4], name: Option<&str>, command: Option<&str }) } -/// 필드 커맨드 레코드 data: 속성(4) 기타(1) len(2) WCHAR[len] **id(4≠0)** trailing(4). -/// 종류별 속성/기타(정품 실측): %hlk=(0x00008800,0) · %fmu=(0,0x08) · 기타=(0,0). -/// **id는 반드시 비영** — 한글은 id=0 필드를 하이퍼링크/필드로 인식하지 않는다(실기 확인: -/// id=0이면 %hlk가 평문 취급, 정품 work_report %hlk는 id=0xd707bf6d). command 해시로 -/// 결정론적 비영 id를 부여(같은 명령=같은 id, 서로 다른 URL=다른 id). +/// Field command data: attributes(4), extra(1), length(2), WCHAR[length], nonzero id(4), trailing(4). +/// Genuine-file observations: %hlk attributes 0x00008800, extra 0; %fmu attributes 0, extra 0x08. +/// The ID must be nonzero: native testing shows Hangul treats an id=0 %hlk as plain text. +/// A genuine work_report %hlk uses id=0xd707bf6d. Hash the command for a deterministic +/// nonzero ID, so identical commands share an ID and different URLs get different IDs. pub fn make_field_command_data(ctrl_id: &[u8; 4], command: &str) -> Vec { let (attr, etc): (u32, u8) = match ctrl_id { - // 정품 한글 %hlk 실측 = 0x0000a800(bit 0x2000 포함). 0x00008800(work_report 복제)만 - // 쓰면 파랑+밑줄은 되나 한글이 클릭 이동을 하지 않는다(2차 실기 확인). + // Genuine Hangul %hlk uses 0x0000a800, including bit 0x2000. With only 0x00008800, + // blue underline appears but clicking does not navigate (second native verification). b"%hlk" => (0x0000_a800, 0), b"%fmu" => (0x0000_0000, 0x08), _ => (0x0000_0000, 0), @@ -650,7 +682,7 @@ pub fn make_field_command_data(ctrl_id: &[u8; 4], command: &str) -> Vec { data } -/// 명령 문자열에서 결정론적 비영 필드 instance id(FNV-1a 32bit, 0이면 1로 보정). +/// Derive a deterministic nonzero field instance ID with 32-bit FNV-1a, replacing zero with one. fn field_instance_id(command: &str) -> u32 { let mut h: u32 = 0x811c_9dc5; for b in command.bytes() { @@ -660,10 +692,10 @@ fn field_instance_id(command: &str) -> u32 { if h == 0 { 1 } else { h } } -/// 인라인 하이퍼링크 조각(markdown 등 스트리밍 삽입용): (FIELD_START 문자, FIELD_END 문자, -/// %hlk 컨트롤). 호출자는 두 문자 사이에 표시 텍스트를 넣고 파랑+밑줄 글자모양을 입힌 뒤 -/// `relink_ctrl_index`로 ctrl_index를 잇는다. `create_hyperlink`(앵커 치환)와 달리 파싱 중 -/// 위치에 바로 삽입할 때 쓴다. +/// Inline hyperlink parts for streaming Markdown insertion: FIELD_START, FIELD_END and %hlk control. +/// The caller inserts display text between them, applies blue underline, then calls +/// `relink_ctrl_index`. Unlike anchored `create_hyperlink`, this inserts directly at +/// the current parsing position. pub(crate) fn hyperlink_field_parts(url: &str) -> (HwpChar, HwpChar, Control) { let ctrl_id = *b"%hlk"; ( @@ -681,8 +713,8 @@ pub(crate) fn hyperlink_field_parts(url: &str) -> (HwpChar, HwpChar, Control) { ) } -/// 하이퍼링크 필드 커맨드: `{URL};1;0;0;` — URL 특수문자를 정품 규칙으로 백슬래시 이스케이프. -/// v1은 `\ ; :`만 이스케이프(정품 `http\://…` 확인). 복잡한 URL은 근사. +/// Hyperlink command `{URL};1;0;0;`, with special URL characters escaped using genuine-file rules. +/// Version 1 escapes backslash, semicolon and colon only; complex URLs remain approximate. fn hlk_command(url: &str) -> String { let mut esc = String::with_capacity(url.len() + 8); for c in url.chars() { @@ -696,7 +728,7 @@ fn hlk_command(url: &str) -> String { format!("{esc};1;0;0;") } -/// 필드 문자열: FIELD_START(ExtCtrl, 코드 3, ctrl_id) + 표시값 + FIELD_END(InlineCtrl, 코드 4). +/// Field characters: FIELD_START (ExtCtrl code 3), display value, FIELD_END (InlineCtrl code 4). fn make_field_chars(ctrl_id: [u8; 4], value: &str) -> Vec { let mut chars = vec![HwpChar::ExtCtrl { code: FIELD_START, @@ -712,7 +744,7 @@ fn make_field_chars(ctrl_id: [u8; 4], value: &str) -> Vec { chars } -/// 문단의 ExtCtrl ↔ controls 등장순서 연결(ctrl_index)을 다시 매긴다. +/// Relink paragraph ExtCtrl characters to controls in encounter order. pub fn relink_ctrl_index(para: &mut Paragraph) { let mut next = 0u32; for ch in &mut para.chars { @@ -723,9 +755,9 @@ pub fn relink_ctrl_index(para: &mut Paragraph) { } } -/// 하이퍼링크 표시 텍스트용 글자모양(파랑 + 밑줄)을 header에 확보하고 id를 돌려준다. -/// 정품 하이퍼링크(work_report "설치하기")는 별도 charPr(파랑+밑줄)를 쓴다 — 이 글자 -/// 모양이 없으면 한글이 링크로 인식/표시하지 않는다(실기 확인). 이미 있으면 재사용. +/// Find or add the blue-underlined character shape used by hyperlink display text. +/// Genuine work_report hyperlinks use a separate blue-underlined charPr; native testing shows +/// this shape is required for Hangul to recognize/display links. Reuse an existing shape. fn hyperlink_char_shape(doc: &mut Document) -> CharShapeId { const BLUE: u32 = 0x00FF_0000; // COLORREF 0x00BBGGRR = RGB(0,0,255) if let Some(i) = doc @@ -739,16 +771,16 @@ fn hyperlink_char_shape(doc: &mut Document) -> CharShapeId { let mut cs = doc.header.char_shapes.first().cloned().unwrap_or_default(); cs.text_color = BLUE; cs.underline_color = BLUE; - cs.attr = (cs.attr & !(0x3 << 2)) | (1 << 2); // 밑줄 종류 1(글자 아래) + cs.attr = (cs.attr & !(0x3 << 2)) | (1 << 2); // Underline type 1, below the text. if cs.shade_color == 0 { - cs.shade_color = 0xFFFF_FFFF; // 검정 음영 방지 + cs.shade_color = 0xFFFF_FFFF; // Avoid black shading. } let id = doc.header.char_shapes.len() as u16; doc.header.char_shapes.push(cs); CharShapeId(id) } -/// char_shape_runs의 [start, end) WCHAR 구간을 `shape`로 지정하고 end에서 이전 모양을 복원. +/// Apply `shape` to the [start, end) WCHAR interval and restore the previous shape at its end. fn apply_run_style(runs: &mut Vec<(u32, CharShapeId)>, start: u32, end: u32, shape: CharShapeId) { if end <= start { return; @@ -765,7 +797,7 @@ fn apply_run_style(runs: &mut Vec<(u32, CharShapeId)>, start: u32, end: u32, sha let mut out: Vec<(u32, CharShapeId)> = Vec::with_capacity(runs.len()); for &(p, id) in runs.iter() { match out.last() { - Some(&(_, lid)) if lid == id => {} // 같은 모양 연속 — 잉여 경계 제거 + Some(&(_, lid)) if lid == id => {} // Drop redundant boundaries between identical shapes. _ => out.push((p, id)), } } @@ -775,8 +807,8 @@ fn apply_run_style(runs: &mut Vec<(u32, CharShapeId)>, start: u32, end: u32, sha *runs = out; } -/// 한 문단에서 앵커 텍스트 뒤에 필드(누름틀/하이퍼링크 등)를 삽입한다. 반환=삽입 여부. -/// `value_shape`가 있으면 표시 텍스트 구간에 그 글자모양(하이퍼링크 파랑+밑줄)을 적용한다. +/// Insert a field after an anchor in one paragraph, returning whether it was inserted. +/// When supplied, apply `value_shape` to the display text (blue underline for hyperlinks). fn create_field_in_para( para: &mut Paragraph, anchor: &str, @@ -791,7 +823,7 @@ fn create_field_in_para( }; let ins = (cidx + anchor.chars().count()).min(para.chars.len()); let iw = wpos + utf16_len(anchor); - // control 삽입 위치 = ins 이전 ExtCtrl 개수(등장순서가 chars와 정합해야 함). + // Insert the control after earlier ExtCtrl characters, keeping controls in character order. let ci = para.chars[..ins] .iter() .filter(|c| matches!(c, HwpChar::ExtCtrl { .. })) @@ -803,7 +835,7 @@ fn create_field_in_para( let inserted_w: u32 = field_chars.iter().map(HwpChar::wchar_width).sum(); para.chars.splice(ins..ins, field_chars); adjust_runs(&mut para.char_shape_runs, iw, 0, inserted_w); - // 표시 텍스트 구간 = [iw + FIELD_START(8), + display] 에 하이퍼링크 글자모양 적용. + // Apply the hyperlink shape to the display interval after FIELD_START (eight WCHARs). if let Some(shape) = value_shape { let dstart = iw + 8; apply_run_style( @@ -814,12 +846,12 @@ fn create_field_in_para( ); } relink_ctrl_index(para); - para.header.ctrl_mask = 0; // writer가 chars에서 재계산(FIELD_START bit3 포함) + para.header.ctrl_mask = 0; // The writer recomputes this from characters, including FIELD_START bit 3. para.line_segs.clear(); true } -/// 본문/표 셀/글상자 문단을 재귀로 훑어 첫 매칭에 필드를 삽입한다. +/// Insert at the first matching body, table-cell or text-box paragraph recursively. fn create_field_rec( para: &mut Paragraph, anchor: &str, @@ -847,7 +879,7 @@ fn create_field_rec( for l in &mut g.paragraph_lists { for p in &mut l.paragraphs { if create_field_rec(p, anchor, ctrl_id, name, command, value, value_shape) { - // 내용이 바뀐 개체의 원문 XML은 낡았다 — stale 방출 금지. + // Changed content makes the captured object XML stale. g.hwpx_raw_xml = None; return true; } @@ -860,7 +892,7 @@ fn create_field_rec( false } -/// 문서를 훑어 첫 앵커 매칭에 필드를 삽입한다(본문·표 셀·글상자 재귀). +/// Insert at the first anchor match in body, table cells or text boxes. fn create_field_generic( doc: &mut Document, anchor: &str, @@ -880,19 +912,47 @@ fn create_field_generic( false } -/// `anchor` 텍스트를 가진 첫 문단의 그 뒤에 `name` 이름의 %clk 누름틀을 삽입한다 -/// (표시값 `value`, 보통 빈 문자열). 반환=삽입 여부. 삽입 후 `set_field`로 채울 수 있다. -pub fn create_field(doc: &mut Document, anchor: &str, name: &str, value: &str) -> bool { - create_field_generic(doc, anchor, *b"%clk", Some(name), None, value, None) +/// Insert a named %clk field after the first `anchor`, with `value` as its display text. +/// Return whether it was inserted, or refuse range-tag shifts. Fill it later with `set_field`. +pub fn create_field( + doc: &mut Document, + anchor: &str, + name: &str, + value: &str, +) -> Result { + let inserted_w = make_field_chars(*b"%clk", value) + .iter() + .map(HwpChar::wchar_width) + .sum(); + crate::edit::ensure_anchor_shift_allowed(doc, anchor, inserted_w)?; + Ok(create_field_generic( + doc, + anchor, + *b"%clk", + Some(name), + None, + value, + None, + )) } -/// `anchor` 텍스트 뒤에 하이퍼링크(%hlk)를 삽입한다. `display`=클릭 표시 텍스트, `url`=대상. -/// 반환=삽입 여부. hwp5·hwpx 양쪽에 동일하게 방출된다. 표시 텍스트에 하이퍼링크 글자모양 -/// (파랑+밑줄)을 적용해 한글이 링크로 인식/표시하게 한다(실기 확인 — 미적용 시 평문 취급). -pub fn create_hyperlink(doc: &mut Document, anchor: &str, url: &str, display: &str) -> bool { +/// Insert a %hlk hyperlink after `anchor`, with `display` as text and `url` as its target. +/// Return whether it was inserted, or refuse range-tag shifts. Both writers emit the same field. +/// Blue underline makes Hangul recognize/display the link; native tests show plain text otherwise. +pub fn create_hyperlink( + doc: &mut Document, + anchor: &str, + url: &str, + display: &str, +) -> Result { + let inserted_w = make_field_chars(*b"%hlk", display) + .iter() + .map(HwpChar::wchar_width) + .sum(); + crate::edit::ensure_anchor_shift_allowed(doc, anchor, inserted_w)?; let cmd = hlk_command(url); let shape = hyperlink_char_shape(doc); - create_field_generic( + Ok(create_field_generic( doc, anchor, *b"%hlk", @@ -900,7 +960,7 @@ pub fn create_hyperlink(doc: &mut Document, anchor: &str, url: &str, display: &s Some(&cmd), display, Some(shape), - ) + )) } #[cfg(test)] @@ -915,7 +975,7 @@ mod tests { #[test] fn parse_command_계산식() { - // 속성(4) 기타(1) len(2) WCHAR[len] id(4) + // attributes(4), extra(1), length(2), WCHAR[length], id(4). let cmd = "SUM(A1:A3)"; let mut data = vec![0u8; 5]; data.extend((cmd.encode_utf16().count() as u16).to_le_bytes()); @@ -926,7 +986,7 @@ mod tests { #[test] fn first_bstr_파라미터셋() { - // SetID(2) count(2)=2; item1: id(2) type(2)=3(I2) + 2바이트; item2: id(2) type(2)=1(BSTR) + // SetID(2), count(2)=2; item 1 is I2 with two payload bytes; item 2 is BSTR. let name = "수신"; let mut data = vec![0, 0, 2, 0]; // setid=0, count=2 data.extend([1, 0, 3, 0, 0xAA, 0xBB]); // item id=1 type=I2(3) value 2B @@ -936,7 +996,7 @@ mod tests { assert_eq!(first_bstr(&data).as_deref(), Some("수신")); } - /// 이름 있는 누름틀 필드 하나를 가진 합성 문단. + /// Synthetic paragraph with one named click-here field. fn field_para(name: &str, value: &str) -> Paragraph { // CTRL_DATA: Parameter Set(setid=0, count=1) + item(id=1, type=BSTR) + len + WCHAR[] let mut cd = vec![0, 0, 1, 0]; @@ -945,7 +1005,7 @@ mod tests { cd.extend(wbytes(name)); let ctrl = Control::Generic(GenericControl { ctrl_id: *b"%clk", - data: vec![0u8; 11], // 속성4 기타1 len2=0 id4 + data: vec![0u8; 11], // attributes(4), extra(1), zero length(2), id(4). paragraph_lists: Vec::new(), extras: Vec::new(), raw_children: vec![OpaqueRecord { @@ -1035,42 +1095,42 @@ mod tests { fn set_field_값_교체() { let mut doc = Document::default(); doc.sections.push(hwp_model::Section { - paragraphs: vec![field_para("수신", "")], // 빈 누름틀 + paragraphs: vec![field_para("수신", "")], // Empty click-here field. extras: Vec::new(), }); - let n = set_field(&mut doc, "수신", "기획팀"); + let n = set_field(&mut doc, "수신", "기획팀").unwrap(); assert_eq!(n, 1); let fields = list_fields(&doc); assert_eq!(fields[0].value, "기획팀"); - // 다른 이름은 안 바뀜 - assert_eq!(set_field(&mut doc, "없는이름", "x"), 0); + // A different name is unchanged. + assert_eq!(set_field(&mut doc, "없는이름", "x").unwrap(), 0); } #[test] fn create_field_삽입_후_읽기_채우기() { let mut doc = crate::from_markdown::from_markdown("수신: 부서"); - // 앵커 "수신:" 뒤에 누름틀 삽입. - assert!(create_field(&mut doc, "수신:", "수신처", "")); + // Insert after the anchor. + assert!(create_field(&mut doc, "수신:", "수신처", "").unwrap()); let fields = list_fields(&doc); assert_eq!(fields.len(), 1); assert_eq!(fields[0].kind, "누름틀"); assert_eq!(fields[0].ctrl_id, "%clk"); assert_eq!(fields[0].name.as_deref(), Some("수신처")); assert_eq!(fields[0].value, ""); - // 생성한 누름틀을 이름으로 채우기. - assert_eq!(set_field(&mut doc, "수신처", "홍길동"), 1); + // Fill the created field by name. + assert_eq!(set_field(&mut doc, "수신처", "홍길동").unwrap(), 1); assert_eq!(list_fields(&doc)[0].value, "홍길동"); - // 없는 앵커는 삽입 안 됨. - assert!(!create_field(&mut doc, "없는앵커", "x", "")); + // A missing anchor inserts nothing. + assert!(!create_field(&mut doc, "없는앵커", "x", "").unwrap()); } #[test] fn create_field_ctrl_index_정합() { - // 삽입된 ExtCtrl이 controls[ctrl_index]와 등장순서로 맞물리는지. + // Inserted ExtCtrl and controls[ctrl_index] agree in encounter order. let mut doc = crate::from_markdown::from_markdown("가나다"); - assert!(create_field(&mut doc, "가", "필드", "값")); + assert!(create_field(&mut doc, "가", "필드", "값").unwrap()); let para = &doc.sections[0].paragraphs[0]; - // ExtCtrl의 ctrl_index가 %clk 컨트롤을 가리킨다. + // The ExtCtrl index points to the %clk control. let ext = para .chars .iter() @@ -1085,8 +1145,8 @@ mod tests { Control::Generic(g) => assert_eq!(&g.ctrl_id, b"%clk"), _ => panic!("필드 컨트롤이 %clk Generic이어야"), } - // 필드 ExtCtrl payload 선두 4B = 역순 ctrl_id("%clk"→"klc%"). - // (문단 선두 secd ExtCtrl과 구분하려 code로 필터.) + // The first four ExtCtrl payload bytes contain the reversed ctrl_id ("%clk" to "klc%"). + // Filter by code to exclude the leading secd ExtCtrl. let payload = para.chars.iter().find_map(|c| match c { HwpChar::ExtCtrl { code, payload, .. } if *code == FIELD_START => Some(payload.clone()), _ => None, @@ -1096,7 +1156,7 @@ mod tests { #[test] fn hlk_command_정품_포맷() { - // work_report.hwp 정품 %hlk와 동일: URL의 `:`→`\:`, 뒤에 `;1;0;0;`. + // Match genuine work_report.hwp: escape URL colons and append `;1;0;0;`. assert_eq!( hlk_command("http://hangeul.naver.com/font"), "http\\://hangeul.naver.com/font;1;0;0;" @@ -1105,16 +1165,16 @@ mod tests { #[test] fn make_field_command_data_바이트() { - // 정품 %hlk data 구조: attr=0x00008800·etc=0·len(2)·WCHAR·id(4≠0)·trailing(4). + // Genuine %hlk data has attributes, extra, length, WCHARs, nonzero id and trailing bytes. let cmd = "http\\://hangeul.naver.com/font;1;0;0;"; let data = make_field_command_data(b"%hlk", cmd); assert_eq!(&data[0..5], &[0x00, 0xa8, 0x00, 0x00, 0x00]); // attr=0x0000a800+etc let len = u16::from_le_bytes([data[5], data[6]]) as usize; assert_eq!(len, cmd.encode_utf16().count()); - assert_eq!(len, 37); // 정품 work_report %hlk와 동일 길이 - // parse_command이 커맨드를 그대로 복원한다. + assert_eq!(len, 37); // Same length as the genuine work_report %hlk. + // parse_command restores the command unchanged. assert_eq!(parse_command(&data).as_deref(), Some(cmd)); - // ★id(4)는 비영이어야 한글이 하이퍼링크로 인식한다(실기 확인). + // The ID must be nonzero for Hangul to recognize the hyperlink (native verification). let id = u32::from_le_bytes([ data[7 + len * 2], data[8 + len * 2], @@ -1128,7 +1188,7 @@ mod tests { #[test] fn make_field_command_data_종류별_속성() { - // %fmu는 정품 실측 attr=0·etc=0x08, %hlk는 attr=0x00008800. + // Genuine %fmu uses attributes 0 and extra 0x08; check the observed %hlk attributes too. let fmu = make_field_command_data(b"%fmu", "=SUM(A1:A2)"); assert_eq!(&fmu[0..5], &[0x00, 0x00, 0x00, 0x00, 0x08]); let hlk = make_field_command_data(b"%hlk", "x;1;0;0;"); @@ -1138,12 +1198,7 @@ mod tests { #[test] fn create_hyperlink_삽입_후_읽기() { let mut doc = crate::from_markdown::from_markdown("자세히: 여기"); - assert!(create_hyperlink( - &mut doc, - "자세히:", - "https://example.com/path", - "링크" - )); + assert!(create_hyperlink(&mut doc, "자세히:", "https://example.com/path", "링크").unwrap()); let fields = list_fields(&doc); assert_eq!(fields.len(), 1); assert_eq!(fields[0].kind, "하이퍼링크"); @@ -1158,26 +1213,21 @@ mod tests { #[test] fn hyperlink_url_커맨드에서_복원() { - // %hlk 컨트롤의 커맨드(`{URL};1;0;0;`)에서 URL을 이스케이프 해제해 복원한다. + // Restore and unescape the URL from the %hlk command. let ctrl = make_field_control(*b"%hlk", None, Some(&hlk_command("http://x.io/a"))); assert_eq!(hyperlink_url(&ctrl).as_deref(), Some("http://x.io/a")); - // %clk 등 비하이퍼링크는 None. + // Non-hyperlink controls, including %clk, return None. let clk = make_field_control(*b"%clk", Some("이름"), None); assert_eq!(hyperlink_url(&clk), None); } #[test] fn create_hyperlink_표시텍스트_파랑밑줄() { - // 정품 하이퍼링크처럼 표시 텍스트에 파랑+밑줄 글자모양이 입혀져야 한다 - // (없으면 한글이 평문 취급 — 실기 확인). + // Display text must have the blue-underlined character shape of a genuine hyperlink. + // Without it, Hangul treats the field as plain text (native verification). let mut doc = crate::from_markdown::from_markdown("여기: 자리"); - assert!(create_hyperlink( - &mut doc, - "여기:", - "https://x.io", - "링크텍스트" - )); - // 파랑+밑줄 글자모양이 header에 추가됐다. + assert!(create_hyperlink(&mut doc, "여기:", "https://x.io", "링크텍스트").unwrap()); + // The header now includes a blue-underlined character shape. let hlk = doc .header .char_shapes @@ -1185,15 +1235,15 @@ mod tests { .position(|c| c.text_color == 0x00FF_0000 && c.underline_kind() == 1) .expect("하이퍼링크 글자모양"); let para = &doc.sections[0].paragraphs[0]; - // 표시 텍스트 구간에 하이퍼링크 글자모양 run이 존재한다(위치는 선두 컨트롤에 - // 따라 달라지므로 값으로 확인). 표시 텍스트 5 WCHAR("링크텍스트") 폭. + // The display interval has a hyperlink-shape run; check values because leading controls + // change its position. This display text is five WCHARs wide. let hlk_run = para .char_shape_runs .iter() .find(|(_, id)| id.0 == hlk as u16) .map(|(p, _)| *p) .expect("하이퍼링크 글자모양 run"); - // 링크 구간 뒤(원래 모양)로 복원하는 경계가 있어야 한다. + // A boundary after the link must restore the original shape. assert!( para.char_shape_runs .iter() @@ -1205,15 +1255,15 @@ mod tests { #[test] fn field_end_payload_ctrl_id_담김() { - // 정품(테스트.hwp 한글 제작) FIELD_END = 역순 ctrl_id 3B(`%` 제외) + 0. - // %hlk END = 6b 6c 68 00…; 전부 0이면 한글이 필드 짝을 못 지어 클릭 불가. + // Genuine Hangul-authored test-file END has three reversed ctrl_id bytes without `%`, then zero. + // %hlk END starts 6b 6c 68 00; all-zero bytes prevent pairing and clicking in Hangul. assert_eq!( field_end_payload(b"%hlk"), vec![0x6b, 0x6c, 0x68, 0, 0, 0, 0, 0, 0, 0, 0, 0] ); - // 생성한 하이퍼링크의 FIELD_END(InlineCtrl 코드4) payload가 비영(ctrl_id 담김). + // The created hyperlink END payload (InlineCtrl code 4) contains the nonzero ctrl_id bytes. let mut doc = crate::from_markdown::from_markdown("여기: 자리"); - assert!(create_hyperlink(&mut doc, "여기:", "https://x.io", "링크")); + assert!(create_hyperlink(&mut doc, "여기:", "https://x.io", "링크").unwrap()); let end = doc.sections[0].paragraphs[0] .chars .iter() @@ -1229,10 +1279,10 @@ mod tests { #[test] fn create_field_누름틀은_글자모양_불변() { - // %clk 누름틀은 하이퍼링크 글자모양을 추가하지 않는다(파랑 미적용). + // A %clk field does not add the hyperlink shape. let mut doc = crate::from_markdown::from_markdown("수신: 부서"); let before = doc.header.char_shapes.len(); - assert!(create_field(&mut doc, "수신:", "수신처", "")); + assert!(create_field(&mut doc, "수신:", "수신처", "").unwrap()); assert_eq!(doc.header.char_shapes.len(), before, "글자모양 추가 없음"); } } diff --git a/crates/hwp-convert/src/form.rs b/crates/hwp-convert/src/form.rs index 4024cf99..0dee0de3 100644 --- a/crates/hwp-convert/src/form.rs +++ b/crates/hwp-convert/src/form.rs @@ -210,6 +210,7 @@ pub struct FormFill { /// A slot owns its text: a label whose value text or value cell holds a requested slot is left /// to the slot, so one place is filled once. When two keys reach one place, the first keeps it /// and a warning names the other. +/// When opaque range tags are present, validate all text and whole-cell edits before mutation. pub fn fill_form_fields( doc: &mut Document, values: &BTreeMap, @@ -302,10 +303,27 @@ pub fn fill_form_fields( } } + // Validate both text and whole-cell writes before either pass mutates the document. + if crate::edit::document_has_range_tags(doc) { + let mut preflight = FillRun { + values: &lookup, + counts: BTreeMap::new(), + warnings: Vec::new(), + }; + for section in &mut doc.sections { + for para in &mut section.paragraphs { + preflight.fill_text(para, false, false)?; + } + } + for (key, at) in &targets { + crate::edit::check_set_cell_edit(doc, at.table, at.row, at.col, &lookup[key])?; + } + } + // 2. Slots, inline labels and cell blanks, on the unfilled text. for section in &mut doc.sections { for para in &mut section.paragraphs { - run.fill_text(para, false); + run.fill_text(para, false, true)?; } } @@ -376,11 +394,16 @@ struct FillRun<'a> { } impl FillRun<'_> { - /// Step 2 of [`fill_form_fields`] on `para` and its nested paragraphs. Returns whether - /// anything changed, so a text box on the way drops its now stale raw XML. - fn fill_text(&mut self, para: &mut Paragraph, in_cell: bool) -> bool { + /// Validate or apply step 2 of [`fill_form_fields`] on `para` and nested paragraphs. + /// Return whether an edit was found; only the apply pass invalidates captured XML. + fn fill_text( + &mut self, + para: &mut Paragraph, + in_cell: bool, + apply: bool, + ) -> Result { let values = self.values; - let mut changed = splice_segments(para, |seg| { + let mut changed = splice_segments(para, apply, |seg| { let mut edits = Vec::new(); for candidate in segment_candidates(seg, in_cell, values, &mut self.warnings) { *self.counts.entry(candidate.key).or_default() += candidate.fills; @@ -389,13 +412,13 @@ impl FillRun<'_> { } edits.sort_by_key(|(range, _)| range.start); edits - }); + })?; for ctrl in &mut para.controls { match ctrl { Control::Table(table) => { for cell in &mut table.cells { for p in &mut cell.paragraphs { - changed |= self.fill_text(p, true); + changed |= self.fill_text(p, true, apply)?; } } } @@ -403,10 +426,10 @@ impl FillRun<'_> { let mut inner = false; for list in &mut generic.paragraph_lists { for p in &mut list.paragraphs { - inner |= self.fill_text(p, in_cell); + inner |= self.fill_text(p, in_cell, apply)?; } } - if inner { + if apply && inner { generic.hwpx_raw_xml = None; } changed |= inner; @@ -414,7 +437,7 @@ impl FillRun<'_> { _ => {} } } - changed + Ok(changed) } } @@ -856,11 +879,12 @@ fn is_label_letter(ch: char) -> bool { /// Splice, in each text segment of `para`, the edits `find` returns for it: byte ranges of /// the segment, in order and not overlapping, with their replacements. Returns whether it -/// spliced anything. +/// an edit was found. With `apply` false, validate the edits without changing the paragraph. fn splice_segments( para: &mut Paragraph, + apply: bool, mut find: impl FnMut(&str) -> Vec<(Range, String)>, -) -> bool { +) -> Result { let mut edits = Vec::new(); for (seg_start, seg) in text_segments(para) { for (range, replacement) in find(&seg) { @@ -870,8 +894,12 @@ fn splice_segments( } } let changed = !edits.is_empty(); - splice_edits(para, edits); - changed + if apply { + splice_edits(para, edits)?; + } else { + crate::edit::check_splice_edits(para, &edits)?; + } + Ok(changed) } /// A value cell a fill may write without overwriting content: empty, or one slot. @@ -1386,7 +1414,7 @@ mod tests { #[test] fn a_value_cell_with_controls_is_left_alone() { let mut doc = from_markdown("| 성명 | 누름 |\n|---|---|\n| 주소 | |\n"); - assert!(crate::create_field(&mut doc, "누름", "이름란", "")); + assert!(crate::create_field(&mut doc, "누름", "이름란", "").unwrap()); let before = doc.clone(); let fill = fill_form_fields(&mut doc, &values(&[("성명", "홍길동")])).unwrap(); assert_eq!(fill.unmatched, ["성명"]); diff --git a/crates/hwp-convert/src/form/binding/apply.rs b/crates/hwp-convert/src/form/binding/apply.rs index 52a0e78c..6bff32d6 100644 --- a/crates/hwp-convert/src/form/binding/apply.rs +++ b/crates/hwp-convert/src/form/binding/apply.rs @@ -399,7 +399,7 @@ pub fn prepare_binding_plan( let path = SegmentPath { section, indices }; let paragraph = crate::address::paragraph_at_mut(&mut candidate, &path) .ok_or_else(|| refuse("적용할 문단 주소가 없음"))?; - crate::edit::splice_edits(paragraph, edits); + crate::edit::splice_edits(paragraph, edits)?; crate::address::invalidate_ancestors(&mut candidate, &path); } let plan_bytes = serde_json::to_vec(plan).map_err(|_| refuse("계획 직렬화 실패"))?; diff --git a/crates/hwp-convert/src/form/binding/tests.rs b/crates/hwp-convert/src/form/binding/tests.rs index 2f35b45e..3f250e59 100644 --- a/crates/hwp-convert/src/form/binding/tests.rs +++ b/crates/hwp-convert/src/form/binding/tests.rs @@ -399,12 +399,10 @@ fn captured_xml_generic_list_is_inventory_only() { fn native_field_para() -> Paragraph { let mut doc = document(); doc.sections[0].paragraphs[1] = paragraph("FIELD-ANCHOR"); - assert!(crate::field::create_field( - &mut doc, - "FIELD-ANCHOR", - "owner-native-field", - "value" - )); + assert!( + crate::field::create_field(&mut doc, "FIELD-ANCHOR", "owner-native-field", "value") + .unwrap() + ); doc.sections[0].paragraphs[1].clone() } fn assert_native_inventory(doc: &Document, inherited_cause: &str) { diff --git a/crates/hwp-convert/src/format.rs b/crates/hwp-convert/src/format.rs index 74692417..bf7449ce 100644 --- a/crates/hwp-convert/src/format.rs +++ b/crates/hwp-convert/src/format.rs @@ -10,7 +10,7 @@ use hwp_model::{ ParaShapeId, Paragraph, }; -use crate::edit::{find_match, utf16_len}; +use crate::edit::{find_match, paragraph_has_range_tags, utf16_len}; use crate::segment_id::SegmentPath; /// Character-format changes; None retains the existing value. @@ -287,12 +287,6 @@ pub fn set_para_align_at(doc: &mut Document, path: &SegmentPath, align: u8) -> b applied } -/// HWP5 `HWPTAG_PARA_RANGE_TAG` (HWPTAG_BEGIN + 54; 한글 문서 파일 형식 5.0 §4.3.5, 표 63): -/// highlight and proofreading marks whose start and end are text offsets. They sit in -/// `Paragraph::extras` as opaque records and no splice path in this crate rebases them -/// (#470), so an edit that moves text under one must refuse instead. -const PARA_RANGE_TAG: u16 = 0x10 + 54; - /// Why `keep_together` is not implemented. The paragraph-shape bits it would write (attr1 bit 17 /// keep-with-next, bit 18 keep-lines, bit 19 break-before) are **unverified**: none of the /// genuine Hangul-saved files available (902 parsed, about 13,000 paragraph shapes) sets any of @@ -465,7 +459,7 @@ fn apply_blank_plan(para: &mut Paragraph, plan: &BlankPlan) { /// Idempotent: a second application finds no leading blanks and an identical shape, so it /// returns `Ok(false)`, appends no shape and leaves the paragraph, line layout included, /// untouched. Refuses a `level` outside 0..=7, and a paragraph that carries a -/// [`PARA_RANGE_TAG`] record when blanks would be removed (before anything is changed). +/// [`crate::edit::PARA_RANGE_TAG`] record when blanks would be removed (before anything is changed). pub fn set_indent_level_at( doc: &mut Document, path: &SegmentPath, @@ -487,7 +481,7 @@ pub fn set_indent_level_at( }; // Plan first, mutate later: every refusal happens before the document is touched. let plan = leading_blank_plan(para); - if plan.is_some() && para.extras.iter().any(|e| e.tag == PARA_RANGE_TAG) { + if plan.is_some() && paragraph_has_range_tags(para) { return Err("set_indent_level은 구간 표시(형광펜·교정 부호)가 있는 문단의 앞쪽 공백을 지울 수 없습니다: 표시의 위치가 글자 위치를 가리켜 공백을 지우면 엉뚱한 글자에 표시가 걸립니다. 표시를 먼저 지우거나, 공백을 지우지 않는 set_para로 left_mm·indent_mm만 지정하세요".to_string()); } let stripped = plan.is_some(); @@ -1336,7 +1330,7 @@ mod tests { fn range_tag() -> hwp_model::OpaqueRecord { hwp_model::OpaqueRecord { - tag: PARA_RANGE_TAG, + tag: crate::edit::PARA_RANGE_TAG, data: vec![0, 0, 0, 0, 5, 0, 0, 0, 1, 0, 0, 0], children: Vec::new(), } diff --git a/crates/hwp-convert/src/image.rs b/crates/hwp-convert/src/image.rs index 886fa5cc..2310b3b8 100644 --- a/crates/hwp-convert/src/image.rs +++ b/crates/hwp-convert/src/image.rs @@ -1,8 +1,9 @@ -//! 이미지 삽입 — 앵커 텍스트 뒤에 새 그림(Picture)을 꽂는다. +//! Image insertion: add a new Picture after anchor text. //! -//! writer가 빈-extras Picture에 hwp5 도형 레코드(SHAPE_COMPONENT + 그림)를 합성하므로 -//! (hwpx→hwp와 동일한 검증된 경로), 여기서는 최소 Picture + BinStream + 앵커 ExtCtrl만 -//! 만든다. bin_ref=ItemRef(name)로 hwpx 출력·hwp5 합성·렌더가 모두 바이트를 해석한다. +//! The writer synthesizes HWP5 shape records (SHAPE_COMPONENT plus picture) for Pictures with empty +//! extras, using the verified HWPX-to-HWP path. Build only a minimal Picture, BinStream and ExtCtrl +//! anchor here. bin_ref=ItemRef(name) lets HWPX output, HWP5 synthesis and rendering resolve the +//! same bytes. use std::path::{Path, PathBuf}; @@ -10,27 +11,27 @@ use hwp_model::{ BinRef, BinStream, CharShape, Control, Document, HwpChar, HwpUnit, Paragraph, Picture, }; -use crate::edit::{adjust_runs, find_match, utf16_len}; +use crate::edit::{adjust_runs, ensure_anchor_shift_allowed, find_match, utf16_len}; use crate::field::{relink_ctrl_index, rev_payload}; -/// gso 개체(그림/표 등) 확장 컨트롤 문자 코드. +/// Extended-control character code for gso objects such as pictures and tables. const GSO_CODE: u16 = 11; /// mm → HWPUNIT(1/7200 inch = 1/100 pt): 7200/25.4. const MM_TO_HWPUNIT: f32 = 283.464_57; -/// PageDef 없을 때 본문 폭 기본값(HWPUNIT, ≈400pt). +/// Default body width without a PageDef, in HWPUNIT (approximately 400pt). const DEFAULT_CONTENT_WIDTH: i32 = 40_000; -/// 삽입 이미지 표시 크기. +/// Display dimensions of an inserted image. pub enum ImageSize { - /// 원본 픽셀 크기(96 DPI 기준), 본문 폭 초과 시 비례 축소. + /// Original pixel dimensions at 96 DPI, proportionally reduced to fit body width. Natural, - /// 밀리미터 지정(너비, 높이). + /// Width and height in millimeters. Mm(f32, f32), } -/// PNG/GIF/BMP/JPEG 헤더에서 픽셀 (너비, 높이)를 읽는다(무의존 헤더 파싱). +/// Reads pixel width/height from PNG/GIF/BMP/JPEG headers without a decoding dependency. pub fn image_pixel_size(data: &[u8]) -> Option<(u32, u32)> { - // PNG: IHDR 폭/높이 at 16..24 (big-endian) + // PNG: big-endian IHDR width/height at 16..24. if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") { let w = u32::from_be_bytes(data[16..20].try_into().ok()?); let h = u32::from_be_bytes(data[20..24].try_into().ok()?); @@ -42,13 +43,13 @@ pub fn image_pixel_size(data: &[u8]) -> Option<(u32, u32)> { let h = u16::from_le_bytes([data[8], data[9]]) as u32; return Some((w, h)); } - // BMP: BITMAPINFOHEADER 폭/높이 at 18..26 (little-endian) + // BMP: little-endian BITMAPINFOHEADER width/height at 18..26. if data.len() >= 26 && data.starts_with(b"BM") { let w = i32::from_le_bytes(data[18..22].try_into().ok()?); let h = i32::from_le_bytes(data[22..26].try_into().ok()?); return Some((w.unsigned_abs(), h.unsigned_abs())); } - // JPEG: SOF 마커(0xFFC0~0xFFCF, C4/C8/CC 제외)에서 높이·너비 + // JPEG: height/width in SOF markers 0xFFC0..0xFFCF, excluding C4/C8/CC. if data.len() >= 4 && data[0] == 0xFF && data[1] == 0xD8 { let mut i = 2; while i + 9 < data.len() { @@ -73,8 +74,9 @@ pub fn image_pixel_size(data: &[u8]) -> Option<(u32, u32)> { None } -/// 매직 바이트로 이미지 (확장자, MIME)를 판별한다(md/html 이미지 복원용). -/// 알 수 없으면 `("bin", "application/octet-stream")` — 스펙상 미지 포맷은 `.bin`. +/// Identifies image extension/MIME from magic bytes, for Markdown/HTML image restoration. Unknown +/// formats use ("bin", "application/octet-stream"); the specification assigns .bin to unknown +/// formats. pub fn image_kind(data: &[u8]) -> (&'static str, &'static str) { match data { [0x89, b'P', b'N', b'G', ..] => ("png", "image/png"), @@ -85,7 +87,7 @@ pub fn image_kind(data: &[u8]) -> (&'static str, &'static str) { } } -/// 문서 첫 구역의 본문 폭(HWPUNIT). PageDef 없으면 A4 근사 기본값. +/// Body width of the first section, in HWPUNIT; use an A4-like default without a PageDef. fn content_width(doc: &Document) -> i32 { doc.sections .first() @@ -95,7 +97,7 @@ fn content_width(doc: &Document) -> i32 { .unwrap_or(DEFAULT_CONTENT_WIDTH) } -/// 확장자(소문자) 추출·검증. 지원: png/jpg/jpeg/bmp/gif. +/// Extracts and validates a lowercase extension: png/jpg/jpeg/bmp/gif. fn ext_of(path: &Path) -> Result { let ext = path .extension() @@ -110,7 +112,8 @@ fn ext_of(path: &Path) -> Result { } } -/// 표시 크기(HWPUNIT)를 계산한다. 자연 크기는 본문 폭(max_w) 초과 시 비례 축소. +/// Computes display dimensions in HWPUNIT, proportionally shrinking natural size beyond max_w body +/// width. pub(crate) fn display_size(data: &[u8], size: &ImageSize, max_w: i32) -> (i32, i32) { match size { ImageSize::Mm(w, h) => ( @@ -288,14 +291,14 @@ fn check_resolved_under_roots(resolved: &Path, roots: &[PathBuf]) -> Result<(), } } -/// 한 문단에서 앵커 텍스트 뒤에 그림 앵커를 삽입한다. 반환=삽입 여부. +/// Inserts a picture anchor after matching text in one paragraph, returning whether inserted. fn insert_image_in_para(para: &mut Paragraph, anchor: &str, pic: &Picture) -> bool { let Some((cidx, wpos)) = find_match(¶.chars, anchor, 0) else { return false; }; let ins = (cidx + anchor.chars().count()).min(para.chars.len()); let iw = wpos + utf16_len(anchor); - // control 삽입 위치 = ins 이전 ExtCtrl 개수(등장순서가 chars와 정합해야 함). + // The insertion control index is the preceding ExtCtrl count, matching character/control order. let ci = para.chars[..ins] .iter() .filter(|c| matches!(c, HwpChar::ExtCtrl { .. })) @@ -313,12 +316,12 @@ fn insert_image_in_para(para: &mut Paragraph, anchor: &str, pic: &Picture) -> bo ); adjust_runs(&mut para.char_shape_runs, iw, 0, 8); // ExtCtrl wchar_width=8 relink_ctrl_index(para); - para.header.ctrl_mask = 0; // writer가 chars에서 재계산(gso bit11 포함) + para.header.ctrl_mask = 0; // The writer recalculates ctrl_mask from chars, including gso bit11. para.line_segs.clear(); true } -/// 본문/표 셀/글상자 문단을 재귀로 훑어 첫 매칭에 그림을 삽입한다. +/// Recursively inserts at the first anchor match in body, table-cell or text-box paragraphs. fn insert_image_rec(para: &mut Paragraph, anchor: &str, pic: &Picture) -> bool { if insert_image_in_para(para, anchor, pic) { return true; @@ -338,7 +341,8 @@ fn insert_image_rec(para: &mut Paragraph, anchor: &str, pic: &Picture) -> bool { for l in &mut g.paragraph_lists { for p in &mut l.paragraphs { if insert_image_rec(p, anchor, pic) { - // 내용이 바뀐 개체의 원문 XML은 낡았다 — stale 방출 금지. + // The object's content changed; clear captured XML to prevent stale + // output. g.hwpx_raw_xml = None; return true; } @@ -351,8 +355,9 @@ fn insert_image_rec(para: &mut Paragraph, anchor: &str, pic: &Picture) -> bool { false } -/// `anchor` 텍스트를 가진 첫 문단의 그 뒤에 `path` 이미지를 인라인(글자처럼)으로 삽입한다. -/// writer(hwp5)가 빈-extras Picture에 도형 레코드를 합성한다. +/// Inserts the image at path inline after anchor text in the first matching paragraph. The HWP5 +/// writer synthesizes shape records for the resulting empty-extras Picture. +/// Refuses before document mutation when the anchor insertion would shift opaque range tags. pub fn insert_image( doc: &mut Document, anchor: &str, @@ -365,6 +370,7 @@ pub fn insert_image( if data.is_empty() { return Err(format!("빈 이미지 파일: {}", path.display())); } + ensure_anchor_shift_allowed(doc, anchor, 8)?; let (w, h) = display_size(&data, &size, content_width(doc)); let name = format!("inserted{}.{ext}", doc.bin_streams.len() + 1); let pic = Picture { @@ -399,7 +405,7 @@ pub fn insert_image( Ok(()) } -/// 도장(직인) 기본 크기 20mm — 공공 실무 관례. +/// Default seal size: 20mm, following public-administration practice. const DEFAULT_SEAL_MM: f32 = 20.0; /// Fallback advance classes for the seal anchor, in thousandths of the active char shape's /// base size (em). Used **only** when no measured metrics are available (no exact face on @@ -425,28 +431,29 @@ const SEAL_EM_PUNCT: i32 = 320; /// Base size used when a char shape is missing or has no size (10pt), as the measured /// path does for line height. const SEAL_DEFAULT_BASE: i32 = 1000; -/// 도장 z-순서 — 본문·일반 개체 위(앞)에 겹치도록 크게 잡는다. +/// Seal z-order, placed in front of body text and ordinary objects. const SEAL_Z_ORDER: u32 = 1000; -/// 도장 배치를 위해 호출자(`hwp-cli`)가 실측해 넘기는 앵커 메트릭(HWPUNIT 단위). -/// `hwp-convert`는 `hwp-render`에 의존하지 않으므로(project invariant 1) 이 실측은 -/// `hwp-cli`가 셰이핑 엔진을 거쳐 계산해 데이터로 넘긴다(D-06). +/// Measured seal-anchor metrics supplied by hwp-cli in HWPUNIT. hwp-convert cannot depend on +/// hwp-render (project invariant 1), so hwp-cli computes these through the shaping engine and +/// passes them as data (D-06). #[derive(Debug, Clone, Copy)] pub struct SealAnchorMetrics { - /// 앵커 문구가 시작되는, 문단 기준 실측 가로 오프셋(HWPUNIT). + /// Measured horizontal offset of the anchor text's start, relative to the paragraph, in + /// HWPUNIT. pub anchor_start: i32, - /// 앵커 문구 자체의 실측 너비(HWPUNIT). + /// Measured width of the anchor text itself, in HWPUNIT. pub anchor_width: i32, - /// 앵커가 놓인 줄의 실측 높이(HWPUNIT). 도장이 이보다 크면 세로 오프셋이 음수가 - /// 되어 위아래 줄과 겹친다 — 이는 의도된 동작이며 보정하지 않는다(D-07). + /// Measured height of the anchor's line, in HWPUNIT. A taller seal has a negative vertical + /// offset and overlaps surrounding lines; this is intentional and must not be clamped (D-07). pub line_height: i32, } /// Anchor measurement callback: (matched paragraph, anchor WCHAR range) -> metrics. type SealMeasure<'a> = dyn FnMut(&Paragraph, (u32, u32)) -> Option + 'a; -/// 한 문단에서 앵커 문구 위에 도장을 **부유 배치**한다. 앵커 텍스트는 유지하고, -/// gso 앵커 문자만 앵커 뒤에 삽입한다. 반환=삽입 여부. +/// Places a floating seal over the anchor in one paragraph. Keep anchor text and insert only a gso +/// anchor character after it; return whether inserted. /// /// Placement is paragraph-relative (vertRelTo/horzRelTo=PARA), centred on the anchor. /// `measure` supplies measured metrics for this paragraph (D-06); `None` falls back to @@ -467,16 +474,16 @@ fn insert_seal_in_para( // metrics can never come from a different (e.g. enclosing) paragraph. let m = measure(para, (wpos, wpos + utf16_len(anchor))) .unwrap_or_else(|| estimate_anchor_metrics(para, (cidx, wpos), anchor, char_shapes)); - // 앵커 문구 중앙에 도장 중심을 맞춘 문단 기준 오프셋. + // Center the seal horizontally on the anchor text, relative to the paragraph. let horz = m.anchor_start + m.anchor_width / 2 - seal_w / 2; - // 줄 높이보다 큰 도장은 위로 밀어 줄 중앙에 오게 한다(음수=위로). - // 위아래 줄과의 겹침은 보정하지 않는다(D-07) — 클램프 금지. + // A seal taller than the line moves upward to remain vertically centered. Overlap with adjacent + // lines is intentional; do not clamp (D-07). let vert = (m.line_height - seal_h) / 2; let pic = Picture { common_data: Vec::new(), width: HwpUnit(seal_w.max(1)), height: HwpUnit(seal_h.max(1)), - treat_as_char: false, // 부유(글 앞) 배치 — writer가 floating 공통속성 합성 + treat_as_char: false, // Floating in front of text; the writer synthesizes common floating attributes. z_order: SEAL_Z_ORDER, vert_offset: vert, horz_offset: horz.max(0), @@ -492,7 +499,7 @@ fn insert_seal_in_para( bin_ref: BinRef::ItemRef(name.to_string()), extras: Vec::new(), }; - // 앵커 뒤에 gso 앵커 문자 삽입 — 앵커 텍스트 자체는 유지된다. + // Insert the gso anchor after the matched text, retaining the text itself. let ins = (cidx + anchor.chars().count()).min(para.chars.len()); let iw = wpos + utf16_len(anchor); let ci = para.chars[..ins] @@ -512,7 +519,7 @@ fn insert_seal_in_para( ); adjust_runs(&mut para.char_shape_runs, iw, 0, 8); // ExtCtrl wchar_width=8 relink_ctrl_index(para); - para.header.ctrl_mask = 0; // writer가 chars에서 재계산(gso bit11 포함) + para.header.ctrl_mask = 0; // The writer recalculates ctrl_mask from chars, including gso bit11. para.line_segs.clear(); true } @@ -565,7 +572,7 @@ fn estimate_anchor_metrics( } } -/// 본문/표 셀/글상자 문단을 재귀로 훑어 첫 매칭에 도장을 부유 배치한다. +/// Recursively places a floating seal at the first anchor match in body, cells or text boxes. fn insert_seal_rec( para: &mut Paragraph, char_shapes: &[CharShape], @@ -593,7 +600,8 @@ fn insert_seal_rec( for l in &mut g.paragraph_lists { for p in &mut l.paragraphs { if insert_seal_rec(p, char_shapes, anchor, seal_w, seal_h, name, measure) { - // 내용이 바뀐 개체의 원문 XML은 낡았다 — stale 방출 금지. + // The object's content changed; clear captured XML to prevent stale + // output. g.hwpx_raw_xml = None; return true; } @@ -606,16 +614,19 @@ fn insert_seal_rec( false } -/// 앵커 문구가 있는 첫 문단의 그 위치에 도장 이미지를 **부유(글 앞) 배치**로 겹친다. -/// `insert_image`(앵커 뒤 인라인 삽입)와 달리 앵커 텍스트를 유지하고 그림을 떠 있는 -/// 개체로 만들어 앵커 문구 위에 겹치게 한다("(인)" 위 직인 날인 관례). +/// Places a seal floating in front of text over the first matching anchor paragraph. Unlike inline +/// insert_image, retain the anchor text and overlap it with a floating image, following the +/// official-seal convention over "(인)". /// -/// `size_mm`이 없으면 기본 20mm(도장 관례). 이미지 원본 비율을 유지한다(정사각 폴백). -/// hwp5 writer가 빈-extras Picture에 floating 공통속성을 합성한다(검증된 경로 재사용). +/// Absent size_mm defaults to the customary 20mm. Preserve the image aspect ratio, falling back to +/// square. +/// The HWP5 writer synthesizes floating common attributes for empty-extras Pictures, reusing the +/// verified path. /// /// `measure` is called once with the paragraph that actually receives the seal and the /// anchor's WCHAR range `[start, end)` in it; `hwp-cli` answers with shaped metrics (D-06). /// `None` falls back to a font-independent width-class estimate (same result on every host). +/// Refuses before mutation or measurement when insertion would shift opaque range tags. pub fn insert_seal( doc: &mut Document, anchor: &str, @@ -633,8 +644,9 @@ pub fn insert_seal( if !(mm.is_finite() && mm > 0.0) { return Err(format!("도장 크기(mm)는 양수여야 합니다: {mm}")); } + ensure_anchor_shift_allowed(doc, anchor, 8)?; let seal_w = ((mm * MM_TO_HWPUNIT).round() as i32).max(1); - // 원본 비율 유지(치수를 못 읽으면 정사각). + // Preserve the original aspect ratio; use square dimensions when unreadable. let seal_h = match image_pixel_size(&data) { Some((pw, ph)) if pw > 0 => ((i64::from(seal_w) * i64::from(ph)) / i64::from(pw)) as i32, _ => seal_w, @@ -658,10 +670,10 @@ pub fn insert_seal( mod tests { use super::*; - /// image_pixel_size가 최소 PNG/BMP 헤더에서 치수를 읽는다. + /// image_pixel_size reads dimensions from minimal PNG/BMP headers. #[test] fn 픽셀_치수_헤더_파싱() { - // PNG: 시그니처(8) + IHDR len(4) + "IHDR"(4) + w(4 BE) + h(4 BE) + // PNG: signature(8), IHDR length(4), "IHDR"(4), big-endian width(4), height(4). let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); png.extend([0, 0, 0, 13]); png.extend(b"IHDR"); @@ -669,7 +681,7 @@ mod tests { png.extend(100u32.to_be_bytes()); assert_eq!(image_pixel_size(&png), Some((200, 100))); - // BMP: "BM" + 16바이트 채운 뒤 폭/높이 at 18/22 (LE) + // BMP: "BM", 16 filler bytes, then little-endian width/height at 18..26. let mut bmp = b"BM".to_vec(); bmp.extend([0u8; 16]); bmp.extend(50i32.to_le_bytes()); @@ -677,7 +689,7 @@ mod tests { assert_eq!(image_pixel_size(&bmp), Some((50, 40))); } - /// insert_image가 Picture+BinStream을 만들고 앵커 링크가 맞는다. + /// insert_image creates a Picture and BinStream with a valid anchor link. #[test] fn 이미지_삽입_구조() { let mut doc = crate::from_markdown::from_markdown("사진: 여기"); @@ -689,12 +701,12 @@ mod tests { png.extend(b"IHDR"); png.extend(96u32.to_be_bytes()); png.extend(96u32.to_be_bytes()); - png.extend([0u8; 8]); // 나머지(파싱 안 함) + png.extend([0u8; 8]); // Remaining bytes are not parsed. std::fs::write(&png_path, &png).unwrap(); insert_image(&mut doc, "사진:", &png_path, ImageSize::Natural).unwrap(); - // BinStream 1개 + Picture 1개 + resolve_bin 성공. + // One BinStream, one Picture, and successful resolve_bin. assert_eq!(doc.bin_streams.len(), 1); let para = &doc.sections[0].paragraphs[0]; let pic = para.controls.iter().find_map(|c| match c { @@ -704,7 +716,7 @@ mod tests { let pic = pic.expect("Picture 존재"); assert!(pic.extras.is_empty(), "writer가 합성하도록 빈 extras"); assert!(doc.resolve_bin(&pic.bin_ref).is_some(), "bin_ref 해석"); - // 앵커 ExtCtrl가 Picture를 가리킨다. + // The anchor ExtCtrl points to the Picture. let ext = para.chars.iter().find_map(|c| match c { HwpChar::ExtCtrl { code, ctrl_index, .. @@ -718,12 +730,12 @@ mod tests { // 96px → 96*7200/96 = 7200 HWPUNIT. assert_eq!(pic.width.0, 7200); - // 없는 앵커는 오류. + // A missing anchor is an error. assert!(insert_image(&mut doc, "없는앵커", &png_path, ImageSize::Natural).is_err()); let _ = std::fs::remove_dir_all(&dir); } - /// insert_seal이 앵커 문단에 **부유** Picture를 만들고 앵커 텍스트를 유지한다. + /// insert_seal creates a floating Picture in the anchor paragraph and preserves anchor text. #[test] fn 도장_부유_삽입_구조() { let mut doc = crate::from_markdown::from_markdown("결재란 (인) 끝"); @@ -750,30 +762,31 @@ mod tests { _ => None, }) .expect("Picture 존재"); - // 부유 배치·writer 합성용 빈 extras·앞 z-order. - // hwpx writer(write_picture)는 treat_as_char=false일 때 - // textWrap=IN_FRONT_OF_TEXT + flowWithText=0 + allowOverlap=1 + PARA 기준으로 - // 이 오프셋/z-order를 방출하고, hwp5 writer는 attr=0x04aa4310(PARA·글앞·제한해제)로 합성한다. + // Floating placement, empty extras for writer synthesis, and front z-order. When + // treat_as_char=false, the HWPX writer emits textWrap=IN_FRONT_OF_TEXT, flowWithText=0, + // allowOverlap=1, PARA-relative offsets/z-order. The HWP5 writer synthesizes + // attr=0x04aa4310 (PARA, in front of text, unrestricted). assert!(!pic.treat_as_char, "도장은 부유(글 앞) 배치여야 한다"); assert!( pic.extras.is_empty(), "writer가 floating 속성 합성하도록 빈 extras" ); assert_eq!(pic.z_order, SEAL_Z_ORDER, "앞(위) 배치용 z-order 상수"); - // 도장이 줄 높이보다 크면 세로 오프셋이 음수(위로 올려 줄 중앙 정렬). + // A seal taller than its line has a negative vertical offset, moving upward to stay + // centered. assert!( pic.vert_offset < 0, "큰 도장은 줄 위로 올려 겹친다(음수 세로 오프셋)" ); assert!(doc.resolve_bin(&pic.bin_ref).is_some(), "bin_ref 해석"); - // 기본 20mm·정사각(96x96)이므로 너비=높이. + // Default 20mm and square 96x96 source: width equals height. assert_eq!(pic.width.0, (20.0 * MM_TO_HWPUNIT).round() as i32); assert_eq!(pic.height.0, pic.width.0, "원본 비율(정사각) 유지"); - // 앵커 문구를 지나며 오프셋이 양수(문단 안쪽으로 이동). + // The anchor text advances the horizontal offset into the paragraph. assert!(pic.horz_offset > 0, "앵커 뒤쪽 가로 오프셋"); - // 앵커 텍스트는 유지되어야 한다. + // Anchor text must remain unchanged. assert!(doc.plain_text().contains("(인)"), "앵커 텍스트 유지"); - // 앵커 ExtCtrl가 Picture를 가리킨다. + // The anchor ExtCtrl points to the Picture. let ext = para.chars.iter().find_map(|c| match c { HwpChar::ExtCtrl { code, ctrl_index, .. @@ -784,7 +797,7 @@ mod tests { matches!(para.controls[ext.unwrap() as usize], Control::Picture(_)), "앵커 ExtCtrl가 Picture를 가리켜야 한다" ); - // 없는 앵커·잘못된 크기는 오류. + // Missing anchors and invalid sizes are errors. assert!(insert_seal(&mut doc, "없음", &png_path, Some(15.0), |_, _| None).is_err()); assert!(insert_seal(&mut doc, "(인)", &png_path, Some(0.0), |_, _| None).is_err()); let _ = std::fs::remove_dir_all(&dir); @@ -800,8 +813,9 @@ mod tests { png } - /// 실측 메트릭이 있으면(D-06) 도장 중심이 상수 근사가 아니라 실측 앵커 - /// 위치·너비로 결정된다 — 줄보다 작은 도장(세로 오프셋 양수)인 경우. + /// With measured metrics (D-06), the seal center follows the measured anchor position/width + /// rather than constant estimates; here the seal is shorter than its line and has a positive + /// vertical offset. #[test] fn 도장_실측_메트릭_위치_정확() { let mut doc = crate::from_markdown::from_markdown("결재란 (인) 끝"); @@ -847,8 +861,8 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// 실측 메트릭에서 도장이 앵커 줄보다 크면 세로 오프셋이 음수가 되어 위아래 - /// 줄과 겹친다 — 이는 의도된 동작이며 클램프하지 않는다(D-07). + /// When measured metrics put a seal taller than its anchor line, the vertical offset is + /// negative and it overlaps adjacent lines. This is intentional and must not be clamped (D-07). #[test] fn 도장_실측_메트릭_줄보다_크면_세로오프셋_음수() { let mut doc = crate::from_markdown::from_markdown("결재란 (인) 끝"); diff --git a/crates/hwp5/src/write.rs b/crates/hwp5/src/write.rs index 89b9fb99..977f1331 100644 --- a/crates/hwp5/src/write.rs +++ b/crates/hwp5/src/write.rs @@ -5013,7 +5013,7 @@ mod tests { let original = crate::read_document(&source).unwrap().document; let mut edited = original.clone(); assert_eq!( - hwp_convert::replace_text(&mut edited, "replace", "updated", true), + hwp_convert::replace_text(&mut edited, "replace", "updated", true).unwrap(), 1 ); @@ -5068,7 +5068,7 @@ mod tests { let original = crate::read_document(&source).unwrap().document; let mut edited = original.clone(); assert_eq!( - hwp_convert::replace_text(&mut edited, "replace", "updated", true), + hwp_convert::replace_text(&mut edited, "replace", "updated", true).unwrap(), 1 ); diff --git a/crates/hwp5/tests/synth.rs b/crates/hwp5/tests/synth.rs index aa84c4e7..7df19b4f 100644 --- a/crates/hwp5/tests/synth.rs +++ b/crates/hwp5/tests/synth.rs @@ -1,8 +1,8 @@ -//! 합성 문서(md/hwpx 출신, tail 없음)가 한글 5.1.0.1 규격을 따르는지 검증. +//! Verify synthesized documents (from md/hwpx, without tails) follow Hangul 5.1.0.1. //! -//! 한글 실기 게이트에서 합성 문서만 "변조/보안경고"가 났던 5대 결함의 -//! 회귀 방지: 버전-레이아웃 정합(PARA_SHAPE 58B/PARA_HEADER 24B), -//! TAB_DEF/NUMBERING 존재(dangling reference 방지), secd 필수 자식. +//! Regressions for the five defects that caused tamper/security warnings only for synthesized +//! documents in native Hangul checks: version/layout agreement (PARA_SHAPE 58B/PARA_HEADER 24B), +//! nonempty TAB_DEF/NUMBERING (no dangling references), and required secd children. use std::path::PathBuf; @@ -13,8 +13,8 @@ fn tmp(name: &str) -> temp_path::TempPath { temp_path::TempPath::new("hwp5-synth-tests", name) } -/// 신규 HWP 합성은 경로와 실행 시각에 무관하게 바이트가 같고, 모든 CFB directory -/// entry의 creation/modification FILETIME이 canonical CFB epoch여야 한다. +/// New HWP synthesis must be byte-identical regardless of path or execution time; every CFB +/// directory entry creation/modification FILETIME must equal the canonical CFB epoch. #[test] fn 신규_패키지는_시간과_경로에_무관하게_결정적() { let doc = hwp_convert::from_markdown("결정적 HWP"); @@ -65,7 +65,7 @@ fn 신규_패키지는_시간과_경로에_무관하게_결정적() { std::fs::remove_file(second).unwrap(); } -/// markdown→hwp 합성 문서가 한글 무결성 검사 통과 조건을 모두 만족해야 한다. +/// Markdown-to-HWP synthesis must satisfy every condition checked by Hangul integrity validation. #[test] fn 합성_문서_한글_규격_충족() { let doc = hwp_convert::from_markdown( @@ -77,20 +77,20 @@ fn 합성_문서_한글_규격_충족() { let reread = hwp5::read_document(&out).unwrap(); let d = reread.document; - // 1. TAB_DEF/NUMBERING 비어 있지 않음 (PARA_SHAPE의 tab_def_id/numbering_id 참조처) + // 1. TAB_DEF/NUMBERING are nonempty (targets of PARA_SHAPE tab_def_id/numbering_id). assert!(!d.header.tab_defs.is_empty(), "TAB_DEF dangling reference"); assert!( !d.header.numberings.is_empty(), "NUMBERING dangling reference" ); - // 1a. char_shape 음영색(shade_color) != 0 — 0이면 한글이 글자 칸마다 불투명 - // 검정 음영을 그려 '검은 바'가 된다(14차 실기). 정품은 0xFFFFFFFF('없음'). + // 1a. char_shape shade_color != 0: zero draws opaque black shading in every character cell, + // producing black bars (native check 14). Genuine files use 0xFFFFFFFF (none). for cs in &d.header.char_shapes { assert_ne!(cs.shade_color, 0, "char_shape 음영색 0 = 검은 바"); } - // 1b. COMPATIBLE_DOCUMENT(0x1E) 존재 — 5.1.x 필수 (한글 정품 가나다·hello_world 보유) + // 1b. COMPATIBLE_DOCUMENT (0x1E) is required for 5.1.x (genuine 가나다/hello_world samples). let mut c0 = hwp5::Hwp5Container::open(&out).unwrap(); let di0 = c0.read_record_stream("/DocInfo").unwrap(); let scan = hwp5::record::scan_stream(&di0, hwp5::record::ScanMode::Tolerant).unwrap(); @@ -103,7 +103,7 @@ fn 합성_문서_한글_규격_충족() { assert!(child_tags.contains(&0x1F), "LAYOUT_COMPATIBILITY 자식"); assert!(child_tags.contains(&0x20), "TRACKCHANGE 자식"); - // 2. secd 필수 자식: 각주/미주 모양 + 쪽 테두리 3종 + // 2. Required secd children: footnote/endnote shapes and all three page border definitions. let secd = d.sections[0].section_def().expect("구역 정의"); let footnotes = secd.extras.iter().filter(|e| e.tag == 0x4A).count(); let page_borders = secd.extras.iter().filter(|e| e.tag == 0x4B).count(); @@ -111,11 +111,11 @@ fn 합성_문서_한글_규격_충족() { assert_eq!(page_borders, 3, "secd 쪽 테두리 3종"); assert!(secd.page.is_some(), "PAGE_DEF"); - // 3. EncryptVersion=4 (현대 한글 마커) + // 3. EncryptVersion=4 (modern Hangul marker). let mut c = hwp5::Hwp5Container::open(&out).unwrap(); assert!(c.file_header().is_compressed()); - // 4. 레코드 길이가 5.1.0.1 규격 (압축 해제 후 직접 측정) + // 4. Record lengths follow 5.1.0.1 (measured directly after decompression). let di = c.read_record_stream("/DocInfo").unwrap(); let bt = c.read_record_stream("/BodyText/Section0").unwrap(); assert!( @@ -132,10 +132,10 @@ fn 합성_문서_한글_규격_충족() { ); } -/// GI-1/GI-2 왕복 (c): md(각주·순서목록·중첩) → hwp5 저장 → 재읽기. -/// 합성 각주·번호/글머리 문단이 synth 규격(레코드 크기·dangling 방지) 위반 없이 -/// 저장·복원되는지 검증한다. (취소선 플래그는 hwp5가 바이너리로 쓰지 않아 왕복에서 -/// 소실된다 — DIFFSPEC 회피를 위한 설계상 결정. hwpx 왕복은 보존.) +/// GI-1/GI-2 round trip (c): md (footnotes, ordered/nested lists) -> HWP5 -> read back. +/// Synthesized footnotes and numbered/bulleted paragraphs must survive without violating +/// record-size and reference constraints. Strikethrough is lost because HWP5 does not write +/// the flag, intentionally avoiding DIFFSPEC. The HWPX round trip preserves it. #[test] fn 왕복_각주_목록_hwp5_규격() { let md = "\ @@ -151,7 +151,7 @@ fn 왕복_각주_목록_hwp5_규격() { let doc = hwp_convert::from_markdown(md); let out = tmp("synth_notes_list.hwp"); let warnings = hwp5::write_document(&doc, &out, &hwp5::WriteOptions::default()).unwrap(); - // 각주·목록이 DROP 없이 저장돼야 한다. + // Footnotes and lists must be saved without DROP losses. assert!( !warnings.iter().any(|w| w.contains("DROP")), "DROP 경고: {warnings:?}" @@ -160,14 +160,14 @@ fn 왕복_각주_목록_hwp5_규격() { let reread = hwp5::read_document(&out).unwrap().document; let d = &reread; - // 1) 각주 컨트롤(fn) + 본문 문단 리스트가 재읽기에서 살아 있다. + // 1) The footnote control (fn) and its body paragraphs survive rereading. let has_fn = d.sections[0].paragraphs.iter().any(|p| { p.controls.iter().any(|c| matches!(c, hwp_model::Control::Generic(g) if g.ctrl_id == *b"fn " && !g.paragraph_lists.is_empty())) }); assert!(has_fn, "각주 컨트롤 왕복"); - // 2) 번호/글머리 머리 문단모양이 살아 있고 참조 테이블이 dangling 아님(defect A10). + // 2) Numbering/bullet paragraph shapes survive with valid references (defect A10). let has_number = d.header.para_shapes.iter().any(|ps| ps.head_type() == 2); let has_bullet = d.header.para_shapes.iter().any(|ps| ps.head_type() == 3); assert!(has_number, "번호 머리 문단모양 보존"); @@ -190,7 +190,7 @@ fn 왕복_각주_목록_hwp5_규격() { } } - // 3) synth 규격: 레코드 크기(새 취소선 문자모양·목록 문단모양·각주 본문 포함). + // 3) Synthesized record sizes include new strike/list shapes and footnote bodies. let mut c = hwp5::Hwp5Container::open(&out).unwrap(); let di = c.read_record_stream("/DocInfo").unwrap(); let bt = c.read_record_stream("/BodyText/Section0").unwrap(); @@ -207,9 +207,9 @@ fn 왕복_각주_목록_hwp5_규격() { "PARA_HEADER 24B (각주 본문 문단 포함)" ); - // 4) BULLET 레코드가 정품 필드 패턴과 일치(사업계획서 전수 대조): 25B, [8..12]=글자모양 - // id 없음(0xFFFFFFFF), [12..14]=글머리표 문자(IR bullet_chars 순서대로 — 개조식 - // 사다리라 수준별로 다르다). 오프셋이 어긋나면 한글이 마커를 미표시한다(1차 H2 실기 결함). + // 4) BULLET records match genuine field patterns (all business-plan samples): 25B, [8..12] + // has no character-shape ID (0xFFFFFFFF), [12..14] stores the bullet character in IR + // bullet_chars order (different by level). Wrong offsets hide markers (native H2 defect 1). let bullets = all_records(&di, 0x18); assert_eq!( bullets.len(), @@ -230,10 +230,10 @@ fn 왕복_각주_목록_hwp5_규격() { ); } - // 5) 취소선: strike CharShape의 on-disk 속성 bit18(취소선 여부=1)이 세워졌는지(바이트). - // CHAR_SHAPE 속성은 오프셋 26(면 ID 14×2=14B + ratios/spacings/rel/offsets 각 7B ×4 = - // 28B... → base_size 앞). 여기선 레코드에서 (attr>>18)&1 을 가진 것이 하나라도 있으면 - // OK. 속성 오프셋: face_ids(14)+ratios(7)+spacings(7)+rel_sizes(7)+offsets(7)+base_size(4)=46. + // 5) Strikethrough: on-disk CharShape attribute bit18 must be set in at least one record. + // The attribute follows face_ids(14), ratios(7), spacings(7), relative sizes(7), + // offsets(7), and base_size(4). Check (attr >> 18) & 1 directly in the record bytes. + // The attribute offset is therefore 46. let strike_present = all_records(&di, 0x15).iter().any(|d| { d.len() >= 50 && (u32::from_le_bytes([d[46], d[47], d[48], d[49]]) >> 18) & 1 == 1 }); @@ -243,13 +243,13 @@ fn 왕복_각주_목록_hwp5_규격() { ); } -/// 번호/글머리 numbering_id의 포맷 경계 ±1 변환이 정확한 역이어야 한다. -/// IR 규약은 0-기반이고 hwp5 on-disk는 1-기반이므로, md(0-기반)→hwp5(write +1)→ -/// 재읽기(read -1)에서 numbering_id가 0으로 보존돼야 한다(왕복 무손실). +/// Numbering/bullet numbering_id format-boundary +/-1 conversions must be exact inverses. +/// The IR is zero-based and HWP5 on disk is one-based: md -> HWP5 (write +1) -> +/// read back (read -1) must preserve numbering_id=0 without loss. #[test] fn hwp5_numbering_id_0기반_경계왕복() { let doc = hwp_convert::from_markdown("1. 하나\n2. 둘\n\n- 가\n- 나\n"); - // md 출신 IR은 번호/글머리 참조가 0-기반이어야 한다. + // The md-derived IR uses zero-based numbering/bullet references. for ps in &doc.header.para_shapes { if matches!(ps.head_type(), 2 | 3) { assert_eq!(ps.numbering_id, 0, "md 출신 IR numbering_id는 0-기반"); @@ -258,8 +258,8 @@ fn hwp5_numbering_id_0기반_경계왕복() { let out = tmp("numbering_base.hwp"); hwp5::write_document(&doc, &out, &hwp5::WriteOptions::default()).unwrap(); - // on-disk PARA_SHAPE의 numbering_id는 1-기반이어야 한다(head 2/3 문단=1). 오프셋 30: - // attr1(4) + i32×6(24) + tab_def_id(2) = 30. write가 +1 복원했는지 바이트로 확인. + // On-disk PARA_SHAPE numbering_id must be one-based (head 2/3 paragraphs use 1). Offset 30: + // attr1(4) + i32*6(24) + tab_def_id(2). Check the writer restored +1 in the bytes. let mut c = hwp5::Hwp5Container::open(&out).unwrap(); let di = c.read_record_stream("/DocInfo").unwrap(); let raw_ids: Vec = all_records(&di, 0x19) @@ -274,7 +274,7 @@ fn hwp5_numbering_id_0기반_경계왕복() { "머리 문단모양 on-disk numbering_id가 1-기반(=1)이어야: {raw_ids:?}" ); - // 재읽기에서 다시 0-기반으로 정규화되는지(read -1 == write +1의 역). + // Reading normalizes back to zero-based IDs (read -1 inverts write +1). let reread = hwp5::read_document(&out).unwrap().document; for ps in &reread.header.para_shapes { if matches!(ps.head_type(), 2 | 3) { @@ -283,13 +283,13 @@ fn hwp5_numbering_id_0기반_경계왕복() { } } -/// GI-3/GI-4 왕복: md(이미지+인라인 코드) → hwp5 저장 → 재읽기에서 Picture·bin·코드 글자모양 생존. +/// GI-3/GI-4 round trip: md (image/inline code) -> HWP5 -> read preserves Picture/bin/code shapes. #[test] fn md_이미지_코드_hwp5_왕복() { use std::io::Write; let dir = std::env::temp_dir().join(format!("hwp5-md-imgcode-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - // 최소 PNG(16×16 치수 헤더). + // Minimal PNG (16x16 dimension header). let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); png.extend([0, 0, 0, 13]); png.extend(b"IHDR"); @@ -325,7 +325,7 @@ fn md_이미지_코드_hwp5_왕복() { ); let reread = hwp5::read_document(&out).unwrap().document; - // 이미지 Picture + bin 데이터 생존. + // The Picture and binary image data survive. let has_pic = reread.sections[0].paragraphs.iter().any(|p| { p.controls .iter() @@ -343,7 +343,7 @@ fn md_이미지_코드_hwp5_왕복() { }); assert_eq!(description, Some("제목😀\n\n대체 설명")); - // 인라인 코드: 함초롬돋움(face_id=1) 글자모양 + 그걸 참조하는 run. + // Inline code: HCR Dotum (face_id=1) character shape and a run referencing it. let code_ids: std::collections::HashSet = reread .header .char_shapes @@ -362,14 +362,14 @@ fn md_이미지_코드_hwp5_왕복() { let _ = std::fs::remove_dir_all(&dir); } -/// 본문 탭이 md→hwp5 경로에서 8 WCHAR 인라인 컨트롤(코드 9)로 저장·복원돼야 한다. -/// Text('\t')로 1 WCHAR만 나가면 한글이 코드 9를 인라인 컨트롤 선두로 오인해 뒤 -/// 7 WCHAR를 잘못 삼켜 파일이 깨진다(§3.2.3 표 6). +/// Body tabs must round-trip as eight-WCHAR inline controls (code 9) through md -> HWP5. +/// Writing Text('\t') as one WCHAR makes Hangul interpret code 9 as an inline-control start +/// and consume the following seven WCHARs, corrupting the file (section 3.2.3, table 6). #[test] fn 본문_탭_hwp5_인라인컨트롤_왕복() { use hwp_model::HwpChar; let doc = hwp_convert::from_markdown("앞\t뒤\n"); - // IR 불변식: 탭은 InlineCtrl(9), Text('\t') 부재. + // IR invariant: tabs use InlineCtrl(9), with no Text('\t'). let chars = &doc.sections[0].paragraphs[0].chars; assert!( chars @@ -385,7 +385,7 @@ fn 본문_탭_hwp5_인라인컨트롤_왕복() { let out = tmp("synth_tab.hwp"); hwp5::write_document(&doc, &out, &hwp5::WriteOptions::default()).unwrap(); - // PARA_TEXT에 탭 인라인 컨트롤 16바이트(09 00 + 12*0 + 09 00)가 있어야 한다. + // PARA_TEXT must contain the 16-byte inline tab control (09 00 + 12*0 + 09 00). let mut c = hwp5::Hwp5Container::open(&out).unwrap(); let bt = c.read_record_stream("/BodyText/Section0").unwrap(); let pt = first_record(&bt, 0x43).expect("PARA_TEXT"); @@ -395,7 +395,7 @@ fn 본문_탭_hwp5_인라인컨트롤_왕복() { "PARA_TEXT에 탭 인라인 컨트롤 16B 없음" ); - // 왕복: 다시 읽어도 InlineCtrl(9) + 텍스트 순서 보존. + // Round trip: InlineCtrl(9) and text order remain intact after reading. let reread = hwp5::read_document(&out).unwrap().document; let rc = &reread.sections[0].paragraphs[0].chars; assert!( @@ -406,9 +406,9 @@ fn 본문_탭_hwp5_인라인컨트롤_왕복() { assert!(reread.plain_text().contains("앞\t뒤"), "탭 텍스트 복원"); } -/// 합성 문단의 본문 구조가 정품 한글 문단(가나다.hwp 5.1.1.0)과 동형이어야 한다. -/// 정품 대조로 확정한 5대 본문 결함의 회귀 방지 — 이 결함들이 합쳐져 -/// "보안 낮춤에도 손상" 경고를 냈다. +/// Synthesized body structure must match genuine Hangul paragraphs (가나다.hwp 5.1.1.0). +/// Regressions for the five body defects established by genuine-file comparison; together +/// they caused corruption warnings even with security settings lowered. #[test] fn 합성_문단_본문_구조_정품_동형() { let doc = hwp_convert::from_markdown("가나다\n"); @@ -417,20 +417,20 @@ fn 합성_문단_본문_구조_정품_동형() { let mut c = hwp5::Hwp5Container::open(&out).unwrap(); let bt = c.read_record_stream("/BodyText/Section0").unwrap(); - // 1. PARA_TEXT는 문단끝 문자(0x0d=13)로 끝나야 한다 (정품 188문단 전수). + // 1. PARA_TEXT ends with paragraph terminator 0x0d=13 (all 188 genuine paragraphs). let pt = first_record(&bt, 0x43).expect("PARA_TEXT"); let last = u16::from_le_bytes([pt[pt.len() - 2], pt[pt.len() - 1]]); assert_eq!(last, 13, "PARA_TEXT는 문단끝 0x0d로 종료해야"); - // 2. PARA_HEADER nchars 최상위 비트(0x80000000) 세팅 (정품 단일 문단 전수). + // 2. PARA_HEADER nchars has its top bit set (0x80000000, all genuine single paragraphs). let ph = first_record(&bt, 0x42).expect("PARA_HEADER"); let nchars = u32::from_le_bytes([ph[0], ph[1], ph[2], ph[3]]); assert_ne!(nchars & 0x8000_0000, 0, "nchars bit31"); - // 3. 구역 첫 문단 break_type=0x03 (offset 11) — 정품 동형. + // 3. The first section paragraph uses break_type=0x03 (offset 11), matching genuine files. assert_eq!(ph[11], 0x03, "구역 첫 문단 break_type"); - // 4. PARA_CHAR_SHAPE run 수 = char_shape_cnt(offset 12, u16), 중복 병합으로 단일. + // 4. PARA_CHAR_SHAPE run count equals char_shape_cnt (offset 12, u16), deduplicated to one. let cs = first_record(&bt, 0x44).expect("PARA_CHAR_SHAPE"); let cnt = u16::from_le_bytes([ph[12], ph[13]]); assert_eq!( @@ -440,7 +440,7 @@ fn 합성_문단_본문_구조_정품_동형() { ); assert_eq!(cnt, 1, "단일 문단은 단일 char_shape run (중복 없음)"); - // 5. PAGE_BORDER_FILL attribute 첫 u32 = 1 (hello_world 표본 잔재 garbage 아님). + // 5. PAGE_BORDER_FILL begins with attribute u32=1 (not leftover hello_world sample garbage). let pbf = first_record(&bt, 0x4B).expect("PAGE_BORDER_FILL"); assert_eq!( u32::from_le_bytes([pbf[0], pbf[1], pbf[2], pbf[3]]), @@ -449,15 +449,15 @@ fn 합성_문단_본문_구조_정품_동형() { ); } -/// 빈 셀을 포함한 GFM 표 → 모든 표 셀 LIST_HEADER 의 nparas ≥ 1. +/// GFM tables, including empty cells: every cell LIST_HEADER has nparas >= 1. /// -/// 셀에 PARA_HEADER 가 하나도 안 붙으면(nparas=0) 한글이 문서를 '손상'으로 -/// 거부한다(M6-md생성.hwp 구 산출물의 실제 결함). from_markdown 은 셀 종료 시 -/// flush_paragraph_inner(force=true) 와 누락 칸 vec![Paragraph::default()] -/// 충전으로 nparas≥1 을 보장한다. 짧은 행·빈 셀·헤더-only 표 모두 검증. +/// Hangul rejects a document as corrupt when a cell lacks PARA_HEADER (nparas=0), +/// as observed in the old M6-md생성.hwp output. At cell close, from_markdown calls +/// flush_paragraph_inner(force=true) and fills missing cells with vec![Paragraph::default()] +/// to ensure nparas >= 1. Cover short rows, empty cells, and header-only tables. #[test] fn 표_빈셀_포함_모든_셀_nparas_1이상() { - // 빈 셀(`| |`)·짧은 행(2칸 < 3열 헤더)·헤더 only 행을 모두 포함. + // Include empty cells (`| |`), a short row (two cells vs. three header columns), and header-only rows. let doc = hwp_convert::from_markdown("| | | |\n| --- | --- | --- |\n| a | | |\n| b | c |\n"); let out = tmp("synth_empty_cell.hwp"); @@ -477,24 +477,24 @@ fn 표_빈셀_포함_모든_셀_nparas_1이상() { } } -/// 표 행 추가(add_rows)로 늘린 표가 한글 합성 규격을 만족해야 한다. +/// Tables extended with add_rows must satisfy the Hangul synthesis constraints. /// -/// 양식 채우기에서 행/칸을 추가(`hwp edit --add-row`, `hwp fill --data tables`)한 -/// 표는 새 셀이 빈 문단이라도 nparas≥1·문단끝·nchars bit31을 지켜야 하고, -/// row_cell_counts(행별 셀 수)가 행 수·셀 수와 정합해야 한다(hwp5 extract assert). -/// 어긋나면 한글이 '손상'으로 거부한다. +/// Tables extended by form filling (`hwp edit --add-row`, `hwp fill --data tables`) must +/// retain nparas >= 1, paragraph terminators, and nchars bit31 even in new empty cells; +/// row_cell_counts must agree with row/cell counts (the hwp5 extraction assertion). +/// Hangul rejects inconsistent output as corrupt. #[test] fn 행_추가_표_합성_규격_충족() { let mut doc = hwp_convert::from_markdown("| 품목 | 수량 |\n| --- | --- |\n| | |\n"); - // 빈 행 3개 추가 후 일부 채움(양식 변형 시나리오). + // Add three empty rows and fill some cells (form modification scenario). hwp_convert::add_rows(&mut doc, 0, None, 3).unwrap(); hwp_convert::set_cell(&mut doc, 0, 1, 0, "노트북").unwrap(); hwp_convert::set_cell(&mut doc, 0, 1, 1, "5").unwrap(); - // 행 4는 비워 둔 채(빈 셀 규격 검증). + // Leave row 4 empty to check empty-cell constraints. let out = tmp("synth_grown_table.hwp"); hwp5::write_document(&doc, &out, &hwp5::WriteOptions::default()).unwrap(); - // 1) 재읽기 IR: 표가 5행으로 늘고 row_cell_counts 정합. + // 1) Read-back IR: five rows and consistent row_cell_counts. let reread = hwp5::read_document(&out).unwrap().document; let table = reread.sections[0] .paragraphs @@ -521,7 +521,7 @@ fn 행_추가_표_합성_규격_충족() { "row_cell_counts 합 == 셀 수" ); - // 2) 모든 셀 LIST_HEADER nparas ≥ 1 (빈 새 셀 포함 — 한글 손상 방지). + // 2) Every cell LIST_HEADER has nparas >= 1, including new empty cells. let mut c = hwp5::Hwp5Container::open(&out).unwrap(); let bt = c.read_record_stream("/BodyText/Section0").unwrap(); let list_headers = all_records(&bt, 0x48); @@ -538,7 +538,7 @@ fn 행_추가_표_합성_규격_충족() { ); } - // 3) 채운 셀은 본문에 반영(노트북/5), 빈 새 행은 빈 셀로 남음. + // 3) Filled cells appear in the body (노트북/5); the new empty row remains empty. let text = reread.plain_text(); assert!( text.contains("노트북") && text.contains('5'), @@ -546,18 +546,18 @@ fn 행_추가_표_합성_규격_충족() { ); } -/// 빈 문단(빈 표 셀 포함)은 PARA_TEXT 레코드를 갖지 않아야 한다. +/// Empty paragraphs, including empty table cells, must have no PARA_TEXT record. /// -/// 정품(work_report·한라대 정품) 실측: 빈 문단은 nchars=1 + PARA_CHAR_SHAPE + -/// PARA_LINE_SEG 를 갖되 PARA_TEXT 레코드가 없다(문단끝은 암묵적). 합성 경로는 -/// 모든 문단에 0x0d 를 붙이는데, 빈 문단을 PARA_TEXT=[0x0d] 로 방출하면 한글이 -/// "파일이 손상되었습니다 + 본문 비어있음"으로 거부한다 — 빈 셀이 있는 표 -/// (제목 박스·목차·구역 헤더) 전부 손상시킨 근본 원인. emit_paragraph 는 -/// char_count>1 일 때만 PARA_TEXT 를 방출해야 한다. (pyhwp 는 빈 PARA_TEXT 를 -/// 관대하게 통과시켜 23라운드 동안 미검출 — 정품 바이트 대조로만 잡힌 결함.) +/// Measured genuine work_report/university samples: empty paragraphs have nchars=1, +/// PARA_CHAR_SHAPE and PARA_LINE_SEG, with no PARA_TEXT (the terminator is implicit). +/// Synthesis appends 0x0d to every paragraph; emitting PARA_TEXT=[0x0d] for empty ones +/// causes Hangul to reject the file as corrupt with an empty body. This affected tables +/// with empty cells (title boxes, contents, section headers). emit_paragraph must emit +/// PARA_TEXT only when char_count>1. pyhwp accepted empty PARA_TEXT for 23 rounds; +/// only comparison with genuine bytes revealed this defect. #[test] fn 빈_문단은_para_text_없음() { - // 빈 셀(`| |`)을 다수 포함한 표 + 채워진 셀·본문 문단. + // A table with several empty cells (`| |`), filled cells, and body paragraphs. let doc = hwp_convert::from_markdown( "본문 문단입니다.\n\n| | | |\n| --- | --- | --- |\n| 채움 | | |\n| | | |\n", ); @@ -572,10 +572,10 @@ fn 빈_문단은_para_text_없음() { let mut filled_paras = 0; for (i, (tag, _lvl, p)) in recs.iter().enumerate() { if *tag != 0x42 { - continue; // PARA_HEADER 만 + continue; // PARA_HEADER only. } let nchars = u32::from_le_bytes([p[0], p[1], p[2], p[3]]) & 0x7FFF_FFFF; - // 이 문단의 자식(다음 PARA_HEADER 전까지)에 PARA_TEXT(0x43)가 있는가. + // Does this paragraph have a PARA_TEXT (0x43) child before the next PARA_HEADER? let has_text = recs[i + 1..] .iter() .take_while(|(t, _, _)| *t != 0x42) @@ -598,7 +598,7 @@ fn 빈_문단은_para_text_없음() { assert!(filled_paras > 0, "채워진 문단도 있어야 한다(시험 전제)"); } -/// 스트림의 모든 레코드를 (tag, level, payload) 로 펼친다. +/// Flatten every stream record into (tag, level, payload). fn records_with_level(data: &[u8]) -> Vec<(u16, u16, Vec)> { let mut out = Vec::new(); let mut i = 0usize; @@ -618,7 +618,7 @@ fn records_with_level(data: &[u8]) -> Vec<(u16, u16, Vec)> { out } -/// 스트림에서 특정 태그 레코드들의 페이로드 목록. +/// Payloads of all stream records with the requested tag. fn all_records(data: &[u8], tag: u16) -> Vec> { let mut out = Vec::new(); let mut i = 0usize; @@ -639,7 +639,7 @@ fn all_records(data: &[u8], tag: u16) -> Vec> { out } -/// 스트림에서 특정 태그의 첫 레코드 페이로드. +/// The first stream record payload with the requested tag. fn first_record(data: &[u8], tag: u16) -> Option> { let mut i = 0usize; while i + 4 <= data.len() { @@ -659,7 +659,7 @@ fn first_record(data: &[u8], tag: u16) -> Option> { None } -/// 레코드 스트림에서 특정 태그 레코드들의 페이로드 크기 목록. +/// Payload sizes of stream records with the requested tag. fn record_sizes(data: &[u8], tag: u16) -> Vec { let mut out = Vec::new(); let mut i = 0usize; @@ -680,15 +680,18 @@ fn record_sizes(data: &[u8], tag: u16) -> Vec { out } -/// 신규 누름틀(%clk) 생성이 hwp5 이진 왕복을 통과한다 — payload 역순 ctrl_id + -/// CTRL_DATA 이름 BSTR이 실제 writer→reader를 거쳐 정확히 복원되는지(IR 단정만으론 -/// payload 바이트를 검증 못 함). +/// New form fields (%clk) survive an HWP5 binary round trip: reversed ctrl_id in the +/// payload and the CTRL_DATA name BSTR are restored through the actual writer/reader. +/// IR-only assertions cannot verify the payload bytes. #[test] fn 누름틀_생성_이진_왕복() { let mut doc = hwp_convert::from_markdown("수신: 부서명\n\n참조: 부서명"); - assert!(hwp_convert::create_field(&mut doc, "수신:", "수신처", "")); - // 같은 호출에서 채우기까지. - assert_eq!(hwp_convert::set_field(&mut doc, "수신처", "기획팀"), 1); + assert!(hwp_convert::create_field(&mut doc, "수신:", "수신처", "").unwrap()); + // Fill the field in the same operation sequence. + assert_eq!( + hwp_convert::set_field(&mut doc, "수신처", "기획팀").unwrap(), + 1 + ); let out = tmp("field.hwp"); hwp5::write_document(&doc, &out, &hwp5::WriteOptions::default()).unwrap(); @@ -702,16 +705,12 @@ fn 누름틀_생성_이진_왕복() { assert_eq!(clk[0].value, "기획팀"); } -/// 신규 책갈피(bokm) 생성이 hwp5 이진 왕복을 통과한다 — code-22 ExtCtrl payload(역순 -/// ctrl_id) + bokm CTRL_DATA 이름 BSTR이 실제 writer→reader를 거쳐 정확히 복원되는지. +/// New bookmarks (bokm) survive an HWP5 binary round trip: code-22 ExtCtrl payload +/// (reversed ctrl_id) and bokm CTRL_DATA name BSTR are restored by the writer/reader. #[test] fn 책갈피_생성_이진_왕복() { let mut doc = hwp_convert::from_markdown("제목 문단\n\n다음 문단"); - assert!(hwp_convert::create_bookmark( - &mut doc, - "제목", - "책갈피테스트" - )); + assert!(hwp_convert::create_bookmark(&mut doc, "제목", "책갈피테스트").unwrap()); let out = tmp("bookmark.hwp"); hwp5::write_document(&doc, &out, &hwp5::WriteOptions::default()).unwrap(); @@ -722,17 +721,14 @@ fn 책갈피_생성_이진_왕복() { assert_eq!(bms[0].name, "책갈피테스트"); } -/// 신규 하이퍼링크(%hlk) 생성이 hwp5 이진 왕복을 통과한다 — 필드 레코드 command(URL) -/// 바이트가 실제 writer→reader를 거쳐 정확히 복원되는지. +/// New hyperlinks (%hlk) survive an HWP5 binary round trip: field command (URL) +/// bytes are restored through the actual writer/reader. #[test] fn 하이퍼링크_생성_이진_왕복() { let mut doc = hwp_convert::from_markdown("문서: 참고\n\n본문"); - assert!(hwp_convert::create_hyperlink( - &mut doc, - "문서:", - "https://example.com/a", - "여기" - )); + assert!( + hwp_convert::create_hyperlink(&mut doc, "문서:", "https://example.com/a", "여기").unwrap() + ); let out = tmp("hyperlink.hwp"); hwp5::write_document(&doc, &out, &hwp5::WriteOptions::default()).unwrap(); @@ -749,9 +745,9 @@ fn 하이퍼링크_생성_이진_왕복() { ); } -/// hwpx-출신 글상자(구조화 도형 + 문단)는 안전 저하된다: SHAPE_COMPONENT 재합성이 -/// 한글 실기에서 손상 판정됐기에(㉕), 글상자 텍스트를 본문으로 hoist해 보존하고 도형 -/// 래퍼는 생략한다. 왕복 결과는 유효(텍스트 보존)하고, gso/도형 레코드는 남지 않는다. +/// HWPX text boxes (structured shapes plus paragraphs) degrade safely: native Hangul +/// rejected synthesized SHAPE_COMPONENT records (check 25), so text is hoisted into +/// the body and the shape wrapper omitted. Text survives; no gso/shape records remain. #[test] fn 글상자_hwpx출신_안전저하_텍스트보존() { use hwp_model::{CharShapeId, Control, GenericControl, HwpChar, Paragraph, ParagraphList}; @@ -781,7 +777,7 @@ fn 글상자_hwpx출신_안전저하_텍스트보존() { description: None, }; let gso = GenericControl { - ctrl_id: *b"rect", // hwpx reader가 만드는 형태 + ctrl_id: *b"rect", // The form produced by the HWPX reader. data: Vec::new(), paragraph_lists: vec![ParagraphList { header_data: Vec::new(), @@ -796,7 +792,7 @@ fn 글상자_hwpx출신_안전저하_텍스트보존() { hwpx_raw_xml: None, container_box: None, }; - // 둘째 문단에 앵커(ExtCtrl 코드 11) + 컨트롤 부착. + // Attach an ExtCtrl code-11 anchor and control to the second paragraph. let para = &mut doc.sections[0].paragraphs[1]; let idx = para.controls.len() as u32; para.chars.push(HwpChar::ExtCtrl { @@ -816,13 +812,13 @@ fn 글상자_hwpx출신_안전저하_텍스트보존() { ); let reread = hwp5::read_document(&out).unwrap().document; - // 글상자 텍스트가 본문으로 hoist돼 보존된다. + // Text-box text is hoisted into the body and preserved. assert!( reread.plain_text().contains("상자속글"), "글상자 텍스트 보존: {}", reread.plain_text() ); - // gso/도형 레코드는 남지 않는다(래퍼 생략). + // No gso/shape records remain (wrapper omitted). let has_gso = reread .sections .iter() @@ -830,13 +826,13 @@ fn 글상자_hwpx출신_안전저하_텍스트보존() { .flat_map(|p| &p.controls) .any(|c| matches!(c, Control::Generic(g) if g.ctrl_id == *b"gso ")); assert!(!has_gso, "gso 래퍼는 저하로 제거돼야"); - // 손상 원인이던 "페이로드가 없는 컨트롤 드롭"은 없다(텍스트 hoist로 대체). + // The corrupting payload-free control drop is replaced by text hoisting. assert!(!reread.plain_text().is_empty(), "유효 문서(빈 본문 아님)"); } -/// 스타일 사다리(from_markdown): 목록은 네이티브 BULLET/NUMBERING 정의(B7 25B 정답지, -/// 실기 확정)로, H1~H3 절 번호는 리터럴 접두로 방출된다 — hwp5 출력에서 BULLET -/// 레코드 수가 IR 글머리 정의 수와 일치하고 절 번호 텍스트가 왕복되는지 단언한다. +/// Style ladder (from_markdown): lists use native BULLET/NUMBERING definitions +/// (B7, 25B, verified in native Hangul); H1-H3 section numbers are literal prefixes. +/// Assert BULLET count equals IR definitions and section-number text round-trips. #[test] fn 스타일_사다리_hwp5_정의_왕복() { let doc = hwp_convert::from_markdown("# 제목\n\n- 항목\n - 하위\n\n1. 첫\n2. 둘\n"); @@ -852,7 +848,7 @@ fn 스타일_사다리_hwp5_정의_왕복() { "BULLET 레코드 수 == IR 글머리 정의 수" ); - // 절 번호는 리터럴 접두로, 목록 텍스트는 마커 없는 본문으로 왕복된다. + // Section numbers round-trip as literal prefixes; list body text has no markers. let reread = hwp5::read_document(&out).unwrap().document; let text = reread.plain_text(); assert!(text.contains("1. 제목"), "{text}"); @@ -1248,10 +1244,10 @@ fn official_hwp5_rejects_sixteenth_level_two_item_in_materializable_control_list ); } -/// 스트림 경로 구분자 회귀: cfb 열거가 플랫폼 구분자(Windows `\`)를 섞어도 -/// list_streams/body_sections는 항상 `/` 정규 경로를 돌려줘야 한다 — 아니면 -/// Windows에서 `/BodyText/Section` 필터가 전부 빗나가 본문이 빈 문서로 읽힌다 -/// (CI windows-latest의 "표 #0 없음"/빈 cat 3건의 근본 원인). +/// Stream-separator regression: even if cfb enumerates platform separators (Windows `\`), +/// list_streams/body_sections must return normalized `/` paths. Otherwise the +/// `/BodyText/Section` filter misses every section and Windows reads an empty body +/// (the root cause of three windows-latest missing-table/empty-cat failures). #[test] fn 스트림_경로_구분자_정규화() { let doc = hwp_convert::from_markdown("본문\n"); @@ -1266,24 +1262,24 @@ fn 스트림_경로_구분자_정규화() { } let sections = c.body_sections(); assert_eq!(sections, vec!["/BodyText/Section0".to_string()]); - // 열거 경로로 바로 읽기가 돼야 한다. + // Enumerated paths must be readable directly. let body = c.read_record_stream(§ions[0]).unwrap(); assert!(!body.is_empty(), "본문 스트림 읽기"); } -/// GE-β7/β8: XMLTemplate·DocHistory 스토리지가 read→write 되쓰기에서 원문 바이트 -/// 그대로 통과한다(내용 미해석 pass-through). 합성 .hwp에 두 스토리지를 주입한 뒤 -/// read_document→write_document 후 스트림 존재·바이트 동일을 확인한다. +/// GE-beta7/beta8: XMLTemplate and DocHistory streams pass through a read/write cycle +/// with identical bytes, without interpretation. Inject both storages into synthesized +/// HWP, then check their presence and bytes after read_document/write_document. #[test] fn xmltemplate_dochistory_스토리지_왕복() { use std::io::{Read as _, Write as _}; - // 1. 기본 문서를 저장하고 CFB에 두 스토리지를 주입한다(한글 저장본 모사). + // 1. Save a base document and inject both storages into CFB, mimicking Hangul output. let doc = hwp_convert::from_markdown("본문"); let a = tmp("storage_a.hwp"); hwp5::write_document(&doc, &a, &hwp5::WriteOptions::default()).unwrap(); - let tmpl_bytes: Vec = (0u8..=255).collect(); // 압축 안 된 임의 바이트 + let tmpl_bytes: Vec = (0u8..=255).collect(); // Arbitrary uncompressed bytes. let hist_bytes: Vec = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01, 0x02]; { let file = std::fs::OpenOptions::new() @@ -1305,7 +1301,7 @@ fn xmltemplate_dochistory_스토리지_왕복() { cfb.flush().unwrap(); } - // 2. read_document이 두 스토리지를 원문 그대로 IR에 올린다. + // 2. read_document preserves both storages verbatim in the IR. let reread = hwp5::read_document(&a).unwrap().document; assert_eq!( reread.hwp5_xml_template, @@ -1318,7 +1314,7 @@ fn xmltemplate_dochistory_스토리지_왕복() { "DocHistory 포착" ); - // 3. write_document이 두 스토리지를 바이트 동일하게 재방출한다. + // 3. write_document re-emits both storages byte-identically. let b = tmp("storage_b.hwp"); hwp5::write_document(&reread, &b, &hwp5::WriteOptions::default()).unwrap(); let file = std::fs::File::open(&b).unwrap(); diff --git a/crates/hwpx/tests/write.rs b/crates/hwpx/tests/write.rs index 2d37837d..36b7652f 100644 --- a/crates/hwpx/tests/write.rs +++ b/crates/hwpx/tests/write.rs @@ -1098,8 +1098,11 @@ fn regex_lite_strip_t(xml: &str) -> String { #[test] fn 필드_생성_hwpx_왕복() { let mut doc = hwp_convert::from_markdown("수신: 부서명"); - assert!(hwp_convert::create_field(&mut doc, "수신:", "수신처", "")); - assert_eq!(hwp_convert::set_field(&mut doc, "수신처", "홍길동"), 1); + assert!(hwp_convert::create_field(&mut doc, "수신:", "수신처", "").unwrap()); + assert_eq!( + hwp_convert::set_field(&mut doc, "수신처", "홍길동").unwrap(), + 1 + ); let out = tmp("field.hwpx"); let warnings = hwpx::write_document(&doc, &out).unwrap(); @@ -1248,11 +1251,7 @@ fn 확장필드_hwp5출신_hwpx_방출() { #[test] fn 책갈피_생성_hwpx_왕복() { let mut doc = hwp_convert::from_markdown("제목 문단\n\n본문"); - assert!(hwp_convert::create_bookmark( - &mut doc, - "제목", - "책갈피테스트" - )); + assert!(hwp_convert::create_bookmark(&mut doc, "제목", "책갈피테스트").unwrap()); let out = tmp("bookmark.hwpx"); let warnings = hwpx::write_document(&doc, &out).unwrap(); @@ -1285,12 +1284,9 @@ fn 책갈피_생성_hwpx_왕복() { #[test] fn 하이퍼링크_생성_hwpx_왕복() { let mut doc = hwp_convert::from_markdown("문서: 참고"); - assert!(hwp_convert::create_hyperlink( - &mut doc, - "문서:", - "https://example.com/a", - "여기" - )); + assert!( + hwp_convert::create_hyperlink(&mut doc, "문서:", "https://example.com/a", "여기").unwrap() + ); let out = tmp("hyperlink.hwpx"); let warnings = hwpx::write_document(&doc, &out).unwrap(); From eff8793e2eb80ed6771f3b456ed1e8407aa81f3a Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Wed, 11 Nov 2026 00:57:31 +0900 Subject: [PATCH 3/8] docs: translate edit comments and describe range-tag guards --- crates/hwp-convert/src/edit.rs | 406 ++++++++++++++++++--------------- 1 file changed, 216 insertions(+), 190 deletions(-) diff --git a/crates/hwp-convert/src/edit.rs b/crates/hwp-convert/src/edit.rs index d019a98e..c418a3d1 100644 --- a/crates/hwp-convert/src/edit.rs +++ b/crates/hwp-convert/src/edit.rs @@ -1,11 +1,11 @@ -//! 인메모리 IR 편집 프리미티브. +//! In-memory IR editing primitives. //! -//! 원본 문서를 읽어 메모리에서 텍스트/표 셀을 바꾼 뒤 다시 쓴다 — 이미지·opaque -//! 레코드 등 모든 비편집 데이터가 그대로 보존된다(JSON 파일 왕복과 달리 무손실). +//! Read an original document, edit text/table cells in memory, then rewrite it. Preserve all +//! unedited data, including images and opaque records, unlike a JSON round-trip. //! -//! 편집된 문단은 줄 배치(PARA_LINE_SEG)·nchars·문단끝 0x0d 캐시가 낡으므로, -//! 쓸 때 반드시 writer의 합성 경로(hwp5: `WriteOptions.edited=true`)를 거쳐야 -//! 한글이 수용한다. 이 모듈은 IR만 바꾸고, 불변식 재수립은 writer가 담당한다. +//! Edited paragraph caches (PARA_LINE_SEG, nchars and ending 0x0d) are stale. Write through the +//! synthesized writer path (HWP5 WriteOptions.edited=true) for Hangul acceptance. This module +//! changes only the IR; the writer restores invariants. use hwp_model::{CharShapeId, Control, Document, HwpChar, Paragraph}; @@ -430,6 +430,8 @@ fn replace_in_chars( } /// Replaces one text span after validating its normalized WCHAR width. Range records stay opaque. +/// CRLF/LF become line breaks, tabs become 8-WCHAR inline controls and other C0 chars are dropped. +/// Later shape-run boundaries shift by the width delta; stale line layout is cleared. pub(crate) fn splice_text( para: &mut Paragraph, range: std::ops::Range, @@ -492,8 +494,8 @@ pub fn replace_text_at( Ok(count) } -/// 연속된 Text 문자열에서 `start_idx` 이후 `from`의 첫 위치를 찾는다. -/// 반환: (chars 벡터 내 시작 인덱스, 문단 내 WCHAR 오프셋). +/// Finds the first from match after start_idx in contiguous Text. Returns (character-vector index, +/// paragraph-relative WCHAR offset). pub(crate) fn find_match(chars: &[HwpChar], from: &str, start_idx: usize) -> Option<(usize, u32)> { let mut wpos: u32 = chars[..start_idx.min(chars.len())] .iter() @@ -526,9 +528,9 @@ pub(crate) fn find_match(chars: &[HwpChar], from: &str, start_idx: usize) -> Opt None } -/// 치환 위치 `p`(WCHAR), 옛 길이 `lo`, 새 길이 `ln`에 맞춰 char_shape_run 경계를 -/// 옮긴다. 치환 구간 내부 경계는 제거하고(치환 텍스트는 p에서 활성인 모양을 상속), -/// 이후 경계는 길이 변화만큼 평행 이동한다. +/// Adjusts char_shape_run boundaries for replacement at WCHAR position p, old length lo and new +/// length ln. Remove internal boundaries so replacement text inherits the shape active at p; shift +/// later boundaries by the length delta. pub(crate) fn adjust_runs(runs: &mut Vec<(u32, CharShapeId)>, p: u32, lo: u32, ln: u32) { let delta = i64::from(ln) - i64::from(lo); let mut out: Vec<(u32, CharShapeId)> = Vec::with_capacity(runs.len()); @@ -538,11 +540,11 @@ pub(crate) fn adjust_runs(runs: &mut Vec<(u32, CharShapeId)>, p: u32, lo: u32, l } else if pos >= p + lo { (i64::from(pos) + delta).max(0) as u32 } else { - continue; // 치환 구간 내부 경계 제거 + continue; // Remove boundaries inside the replaced range. }; match out.last() { - Some(&(lp, _)) if lp == np => {} // 같은 위치 중복 — 첫 것 유지 - Some(&(_, lid)) if lid == id => {} // 같은 모양 연속 — 잉여 경계 제거 + Some(&(lp, _)) if lp == np => {} // Duplicate position: keep the first. + Some(&(_, lid)) if lid == id => {} // Consecutive identical shape: omit the redundant boundary. _ => out.push((np, id)), } } @@ -552,9 +554,9 @@ pub(crate) fn adjust_runs(runs: &mut Vec<(u32, CharShapeId)>, p: u32, lo: u32, l *runs = out; } -/// `table_index`번째 표(문서 등장 순서, 0-기반)의 (row, col) 셀 텍스트를 바꾼다. -/// 셀의 첫 문단 서식을 템플릿으로 보존하고 내용만 교체한다. 빈 줄(LF 두 개 이상)로 -/// 나뉜 값은 블록마다 문단 하나가 된다 — [`split_cell_blocks`] 참고. +/// Replaces cell (row, col) text in table_index, the zero-based document-order table index. Use the +/// cell's first paragraph as a formatting template. Values separated by blank lines (two or more +/// LF) become one paragraph per block; see split_cell_blocks. pub fn set_cell( doc: &mut Document, table_index: usize, @@ -597,11 +599,11 @@ pub fn set_cell( .unwrap_or_else(|| Err(format!("표 #{table_index}를 찾을 수 없습니다"))) } -/// `table_index`번째 표(0-기반)에 빈 행을 `count`개 추가한다. `template_row`(0-기반, -/// 생략 시 마지막의 병합 없는 행)를 복제해 셀 서식(폭·여백·테두리·문자/문단 모양)을 -/// 보존하고 내용은 비운다 — 추가된 행(인덱스 `기존행수`부터)은 이후 [`set_cell`]로 -/// 채운다. hwp5 출력은 반드시 edited 합성 경로(`WriteOptions.edited=true`)를 거쳐야 -/// 한글이 수용한다(줄 배치·문단끝·nchars 불변식 재합성). +/// Adds count empty rows to zero-based table_index, cloning template_row (zero-based, defaulting to +/// the last unmerged row) to preserve cell width/margins/borders/list/shape formatting while +/// clearing contents. Fill new rows, starting at the previous row count, through set_cell. HWP5 +/// output must use WriteOptions.edited=true to restore line-layout, paragraph-end and nchars +/// invariants for Hangul acceptance. pub fn add_rows( doc: &mut Document, table_index: usize, @@ -636,14 +638,15 @@ pub fn add_rows_at( .unwrap_or_else(|| Err(format!("표 #{table_index}를 찾을 수 없습니다"))) } -/// `table_index`번째 표(0-기반)의 (행 수, 열 수)를 반환한다. 데이터 구동 채우기가 -/// 추가할 행 수를 계산할 때 쓴다(현재 행 수 조회). 읽기 전용 — `hwpx_raw_xml`을 건드리지 않는다. +/// Returns (row count, column count) of zero-based table_index, used by data-driven fills to +/// determine added rows. Read-only; retain hwpx_raw_xml. pub fn table_dims(doc: &mut Document, table_index: usize) -> Option<(u16, u16)> { with_nth_table_readonly(doc, table_index, |t| (t.rows, t.cols)) } -/// `table_index`번째 표(0-기반)의 `row`행을 삭제한다(이후 행 재번호, row_cell_counts -/// 갱신). 병합 셀이 있거나 세로 병합에 덮인 행은 그리드가 깨지므로 거부한다. +/// Deletes zero-based row from table_index, renumbering subsequent rows and updating +/// row_cell_counts. Refuses merged cells or rows covered by vertical merges to avoid breaking the +/// grid. pub fn delete_table_row(doc: &mut Document, table_index: usize, row: u16) -> Result<(), String> { with_nth_table(doc, table_index, |t| delete_row_in_table(t, row)) .unwrap_or_else(|| Err(format!("표 #{table_index}를 찾을 수 없습니다"))) @@ -674,26 +677,30 @@ fn delete_row_in_table(table: &mut hwp_model::Table, row: u16) -> Result<(), Str Ok(()) } -// ── 표 셀 병합/분할 · 열 추가/삭제 (GK-1 · GK-2) ───────────────────────────── +// Table-cell merge/split and column insertion/deletion (GK-1/GK-2). // -// 정답지 실측(정품 한글 1,816개 병합 표, hwp5+hwpx 만장일치)으로 확정한 저장 규칙을 -// 유지한다: -// 1. 병합 영역은 **좌상단 앵커 셀 1개만** 저장하고 피병합(covered) 셀은 목록에서 -// 완전히 생략한다(hwp5 LIST_HEADER·hwpx `` 공통). -// 2. Σ(col_span×row_span) == rows×cols (앵커 셀들의 면적이 그리드를 정확히 타일링). -// 3. 셀 순서는 행 우선(앵커 (row,col) 사전식). 피병합 열은 그 행에서 건너뛴다. -// 4. row_cell_counts[r] = 앵커 row==r 셀 개수. row_span>1 셀은 앵커 행에만 계상. -// 5. 병합 셀 cellSz = 영역 전체 폭/높이(구성 열 폭 합·행 높이 합). -// 조작 후 [`validate_table_invariants`]로 재확인한다(깨지면 Err — 손상 표 미방출). -// 표 로케이터는 #9의 [`with_nth_table`](재귀·set-cell 인덱스 일치)을 공용한다. - -/// 완전 미상 표(정상 표엔 안 나옴)의 폭 근사 기준 — A4 본문 폭 대략치. +// Maintain the storage rules confirmed across 1,816 genuine Hangul merged tables, unanimously in +// HWP5 and HWPX: +// 1. Store only the upper-left anchor of a merged region; omit covered cells completely, in both +// HWP5 LIST_HEADER and HWPX hp:tc. +// 2. The sum of col_span * row_span equals rows * cols; anchor areas tile the grid exactly. +// 3. Store cells row-major, lexicographically by anchor (row,col), skipping covered columns. +// 4. row_cell_counts[r] counts anchors whose row==r; count row_span>1 cells only in their anchor +// row. +// 5. Merged-cell cellSz covers the whole region, summing constituent column widths/row heights. +// Recheck with validate_table_invariants after editing; return Err rather than emit a corrupt +// table. +// Share #9's recursive with_nth_table locator and its set-cell-compatible indices. + +/// Fallback width for a completely unknown table, absent from normal samples: approximate A4 body +/// width. const BODY_WIDTH_APPROX: i32 = 42520; -/// 빈 행 높이 근사 — 10pt 텍스트 + 셀 여백. +/// Fallback empty-row height: 10pt text plus cell margins. const ROW_HEIGHT_APPROX: i32 = 1700; -/// 논리 그리드(rows×cols): 각 위치를 소유한 셀 인덱스(피병합 위치도 앵커 인덱스로 채움). -/// 셀 겹침·빈칸·범위 초과가 있으면 Err(표 구조 파손 감지). +/// Logical rows-by-cols grid containing each position's owning cell index, including the anchor for +/// covered positions. Return Err for overlap, gaps or out-of-bounds cells to detect structural +/// corruption. fn build_grid(table: &hwp_model::Table) -> Result>, String> { let rows = table.rows as usize; let cols = table.cols as usize; @@ -726,8 +733,8 @@ fn build_grid(table: &hwp_model::Table) -> Result>, String> { Ok(grid) } -/// 그리드 열별 폭(HWPUNIT). 단일 열 셀에서 확정하고, 다중 열 셀만 걸친 열은 잔여를 -/// 균등 분배하며, 그래도 미상이면 평균으로 근사한다. +/// Column widths in HWPUNIT: derive from single-column cells, distribute residual width evenly +/// where only spanning cells exist, then approximate remaining unknowns by the average. fn column_widths(table: &hwp_model::Table) -> Vec { let cols = table.cols as usize; let mut w = vec![0i32; cols]; @@ -769,7 +776,7 @@ fn column_widths(table: &hwp_model::Table) -> Vec { w } -/// 그리드 행별 높이(HWPUNIT). [`column_widths`]의 세로 대응. +/// Row heights in HWPUNIT, the vertical counterpart of column_widths. fn row_heights(table: &hwp_model::Table) -> Vec { let rows = table.rows as usize; let mut h = vec![0i32; rows]; @@ -811,7 +818,8 @@ fn row_heights(table: &hwp_model::Table) -> Vec { h } -/// 표 내 최대 instance_id (새 빈 문단에 고유 비-0 id 부여용 — [`add_rows`] 규칙과 동일). +/// Maximum table-local instance_id, used for unique nonzero IDs in new empty paragraphs, as in +/// add_rows. fn max_instance_id(table: &hwp_model::Table) -> u32 { table .cells @@ -822,12 +830,12 @@ fn max_instance_id(table: &hwp_model::Table) -> u32 { .unwrap_or(0) } -/// 문단에 실제 텍스트(Text 문자)가 있는지 — 병합 시 빈 문단 정리에 쓴다. +/// Whether a paragraph contains actual Text, used to remove empty paragraphs during cell merges. fn has_text(p: &Paragraph) -> bool { p.chars.iter().any(|c| matches!(c, HwpChar::Text(_))) } -/// 리스트 마지막 문단만 nchars bit31(chars_flags 0x80)을 세운다(B4 규칙). +/// Sets nchars bit31 (chars_flags 0x80) only on the last paragraph in a list, following B4. pub(crate) fn fixup_last_para_flag(paras: &mut [Paragraph]) { let n = paras.len(); for (i, p) in paras.iter_mut().enumerate() { @@ -839,8 +847,9 @@ pub(crate) fn fixup_last_para_flag(paras: &mut [Paragraph]) { } } -/// 빈 1×1 셀 — 템플릿에서 여백·테두리·list_attr·모양을 상속하고, 문단 1개·문자모양 -/// run 1개·마지막 문단 비트([`blank_para_like`])·고유 instance_id를 채운다(A5~A7 게이트). +/// An empty 1x1 cell inherits margins, borders, list_attr and shapes from its template. Populate +/// one paragraph, one character-shape run, the last-paragraph bit (blank_para_like), and a unique +/// instance_id (A5..A7 gates). fn blank_cell( row: u16, col: u16, @@ -866,7 +875,7 @@ fn blank_cell( } } -/// row_cell_counts를 셀 목록에서 재계산(앵커 row별 셀 수). +/// Recalculates row_cell_counts from the cell list, counting anchors per row. fn recount_rows(table: &mut hwp_model::Table) { let mut counts = vec![0u16; table.rows as usize]; for c in &table.cells { @@ -877,14 +886,14 @@ fn recount_rows(table: &mut hwp_model::Table) { table.row_cell_counts = counts; } -/// 셀 목록을 행 우선(앵커 (row,col))으로 정렬(정품 저장 순서 불변식). +/// Sorts cells row-major by anchor (row,col), the genuine storage-order invariant. fn sort_cells_row_major(table: &mut hwp_model::Table) { table.cells.sort_by_key(|c| (c.row, c.col)); } -/// 표 불변식 재검증(조작 후 손상 방지 게이트). 위반이면 Err. +/// Revalidates table invariants after edits as a corruption gate; return Err on violations. pub(crate) fn validate_table_invariants(table: &hwp_model::Table) -> Result<(), String> { - build_grid(table)?; // 겹침·빈칸·범위 + build_grid(table)?; // Overlap, gaps and bounds. let area: usize = table .cells .iter() @@ -919,9 +928,9 @@ pub(crate) fn validate_table_invariants(table: &hwp_model::Table) -> Result<(), Ok(()) } -/// N번째 표에서 사각 영역 (r1,c1)-(r2,c2)를 병합한다(0-기반, 경계 포함). 좌상단 앵커가 -/// span을 획득하고 피병합 셀의 문단 내용을 이어받으며, 피병합 셀은 목록에서 제거된다. -/// 영역이 기존 병합과 부분 겹침이거나 범위 밖이면 Err. +/// Merges the inclusive zero-based rectangle (r1,c1)..(r2,c2) in the nth table. The upper-left +/// anchor acquires the span and absorbs paragraphs from covered cells, which leave the list. Return +/// Err for partial overlap with existing merges or an out-of-bounds region. pub fn merge_cells( doc: &mut Document, table_index: usize, @@ -1026,8 +1035,9 @@ fn merge_cells_in_table( validate_table_invariants(table) } -/// N번째 표의 (row,col) 앵커 셀(span>1)을 1×1 셀들로 분해한다. 앵커는 좌상단 위치와 -/// 내용을 유지하고, 나머지 커버 위치엔 빈 셀을 만든다(A5~A7). cellSz 균등 분배. +/// Splits the span>1 anchor at (row,col) in the nth table into 1x1 cells. Preserve the upper-left +/// anchor's position/content and fill the remaining covered positions with empty cells (A5..A7), +/// dividing cellSz evenly. pub fn split_cell( doc: &mut Document, table_index: usize, @@ -1110,7 +1120,7 @@ fn split_cell_in_table(table: &mut hwp_model::Table, row: u16, col: u16) -> Resu validate_table_invariants(table) } -/// `table_index`번째 표(0-기반) **끝에** 열을 하나 추가한다(mcp·기존 CLI 호환). +/// Appends one column to zero-based table_index, retaining MCP/legacy CLI compatibility. /// Total table width is preserved (the append special case of [`add_table_columns`]). /// Merged tables are supported. pub fn add_col(doc: &mut Document, table_index: usize) -> Result<(), String> { @@ -1175,10 +1185,10 @@ fn add_table_column_in_table( if u32::from(cols) + u32::from(count) > u32::from(u16::MAX) { return Err("열 수가 u16 범위를 넘습니다".to_string()); } - build_grid(table)?; // 사전 검증 - // 병합 없는 표를 **끝에** 추가하는 경우는 #9의 행별 정확 재분배를 그대로 쓴다(각 행의 - // 총폭을 독립 보존 — 정품 그리드가 아닌 비균일 행도 정확). 병합 표·위치 삽입은 열 - // 정렬이 필요하므로 아래 열 폭 기반 경로로 처리한다. + build_grid(table)?; // Prevalidate. + // For appending to an unmerged table, retain #9's exact per-row redistribution, independently + // preserving every row's total width, including nonuniform rows unlike genuine grids. Merged + // tables and interior insertion require aligned columns and use the column-width path below. let has_merge = table .cells .iter() @@ -1258,7 +1268,8 @@ fn add_table_column_in_table( } } table.cols += count; - // 모든 셀 width = Σ final_colw[셀이 차지하는 열들] (전체·행 총폭 정확 보존). + // Each cell width is the sum of final_colw for its spanned columns, preserving + // whole-table/per-row totals exactly. for c in &mut table.cells { let s: i64 = (c.col as usize..(c.col + c.col_span) as usize) .map(|j| final_colw.get(j).copied().unwrap_or(0)) @@ -1335,9 +1346,9 @@ fn add_col_append_uniform(table: &mut hwp_model::Table, count: u16) -> Result<() Ok(()) } -/// `table_index`번째 표의 col 열(0-기반)을 삭제한다. 그 열을 가로지르는 병합 셀은 -/// col_span−1로 줄고, 그 열에만 있던 1×1 셀은 제거된다. **전체 표 폭은 유지**(삭제 열 -/// 폭을 남은 열에 비율로 재분배)한다. 마지막 1열이면 Err. +/// Deletes zero-based col from table_index. Crossing merged cells shrink col_span by one; 1x1 cells +/// solely in that column are removed. Preserve total table width by redistributing the removed +/// width proportionally. Refuse deletion of the last remaining column. pub fn delete_table_column(doc: &mut Document, table_index: usize, col: u16) -> Result<(), String> { with_nth_table(doc, table_index, |t| delete_table_column_in_table(t, col)) .unwrap_or_else(|| Err(format!("표 #{table_index}를 찾을 수 없습니다"))) @@ -1350,11 +1361,11 @@ fn delete_table_column_in_table(table: &mut hwp_model::Table, col: u16) -> Resul if table.cols <= 1 { return Err("마지막 열은 삭제할 수 없습니다".to_string()); } - build_grid(table)?; // 사전 검증 + build_grid(table)?; // Prevalidate. let colw = column_widths(table); let total: i64 = colw.iter().map(|&w| i64::from(w)).sum(); let remain_total = total - i64::from(colw[col as usize]); - // 구조: 셀 이동/축소/제거. + // Move, shrink or remove cells. let mut to_remove: Vec = Vec::new(); for (i, c) in table.cells.iter_mut().enumerate() { let ac = c.col; @@ -1362,7 +1373,7 @@ fn delete_table_column_in_table(table: &mut hwp_model::Table, col: u16) -> Resul if ac > col { c.col -= 1; } else if ec <= col { - // 삭제 열 왼쪽 — 그대로. + // Left of the removed column: unchanged. } else if c.col_span > 1 { c.col_span -= 1; } else { @@ -1373,7 +1384,7 @@ fn delete_table_column_in_table(table: &mut hwp_model::Table, col: u16) -> Resul table.cells.remove(i); } table.cols -= 1; - // 남은 열(옛 인덱스 col 제외) 새 폭: 삭제 열 폭을 비율로 흡수(전체 폭 유지). + // Remaining columns absorb the removed column's width proportionally, retaining total width. let old_remaining: Vec = (0..colw.len()).filter(|&j| j != col as usize).collect(); let ncols = table.cols as usize; let mut final_colw = vec![0i64; ncols]; @@ -1399,8 +1410,8 @@ fn delete_table_column_in_table(table: &mut hwp_model::Table, col: u16) -> Resul validate_table_invariants(table) } -/// `"키=값"` 메타데이터 지정을 문서에 적용한다. 키: `title`|`author`|`subject`|`keywords`. -/// 값이 비면 해당 필드를 `None`으로 지운다. 알 수 없는 키/형식은 `Err`. +/// Applies key=value metadata: title, author, subject or keywords. Empty values clear a field to +/// None. Unknown keys or malformed specifications return Err. pub fn apply_meta(doc: &mut Document, spec: &str) -> Result<(), String> { let (key, value) = spec .split_once('=') @@ -1420,11 +1431,11 @@ pub fn apply_meta(doc: &mut Document, spec: &str) -> Result<(), String> { Ok(()) } -/// 문서 등장 순서 `index`번째 표를 찾아 `f`를 적용한다(0-기반). 본문·표 셀·글상자 -/// 문단을 재귀로 훑는다. 표를 찾으면 `Some(f의 결과)`, 못 찾으면 `None`. -/// `f`가 개체 원문 XML(`hwpx_raw_xml`)을 품은 Generic 안의 표를 바꾸면 그 원문은 -/// 낡으므로 지운다 — writer가 stale XML을 방출하는 것을 막는다(방출할 emitter가 -/// 없어 fail-closed 보존 오류로 이어진다). +/// Finds zero-based document-order table index and applies f, recursively through body, cells and +/// text boxes. Returns Some(f's result) when found, otherwise None. +/// If f changes a table inside a Generic control with captured hwpx_raw_xml, clear that stale XML. +/// This prevents stale output and instead produces a fail-closed preservation error if no semantic +/// emitter exists. pub(crate) fn with_nth_table R>( doc: &mut Document, index: usize, @@ -1433,7 +1444,7 @@ pub(crate) fn with_nth_table R>( walk_nth_table_root(doc, index, true, f) } -/// 읽기 전용 변형([`table_dims`]) — 내용을 바꾸지 않으므로 `hwpx_raw_xml`을 지우지 않는다. +/// Read-only variant used by table_dims; retain hwpx_raw_xml because content is unchanged. pub(crate) fn with_nth_table_readonly R>( doc: &mut Document, index: usize, @@ -1442,9 +1453,9 @@ pub(crate) fn with_nth_table_readonly R>( walk_nth_table_root(doc, index, false, f) } -/// #296: 표 배치 요약 — (전체, 인라인, 부유). `placement`를 reader가 채운 표(hwp5/hwpx -/// 출신)는 그 값을 따르고, 합성 표(placement=None)는 HWPX writer의 폴백과 같은 인라인으로 -/// 센다. 읽기 전용 walker라 문서를 바꾸지 않는다. +/// #296: table-placement counts (total, inline, floating). Use reader-provided HWP5/HWPX placement +/// where present; synthesized placement=None follows the HWPX writer's inline fallback. The +/// read-only walker never mutates the document. pub fn table_placement_stats(doc: &mut Document) -> (usize, usize, usize) { let (mut total, mut inline, mut floating) = (0usize, 0usize, 0usize); let mut i = 0usize; @@ -1462,7 +1473,7 @@ pub fn table_placement_stats(doc: &mut Document) -> (usize, usize, usize) { ]) & 1 == 1 } else { - true // 합성 표 — writer 기본값(inline)과 동일 + true // Synthesized table: matches the writer's inline default. } }) }) { @@ -1477,10 +1488,10 @@ pub fn table_placement_stats(doc: &mut Document) -> (usize, usize, usize) { (total, inline, floating) } -/// #296: 표 배치(글자처럼 취급) 전환. `index`는 문서 등장 순서 0-기반 재귀 인덱스 -/// (`--label-table`과 같은 주소 체계), None이면 문서의 모든 표. 대상 표가 하나도 -/// 없으면 None(미적용), 있으면 실제로 값이 바뀐 표 수 Some(n) — 이미 요청된 배치인 -/// 표는 건드리지 않으므로 재적용은 no-op이다(바이트 안정). +/// #296: switches treat-as-character table placement. index is the zero-based recursive +/// document-order index, matching label-table; None selects every table. Return None when there are +/// no targets, otherwise Some(number changed). Tables already matching the request remain +/// untouched, so reapplication is a byte-stable no-op. pub fn set_table_placement( doc: &mut Document, index: Option, @@ -1494,7 +1505,7 @@ pub fn set_table_placement( let needs = with_nth_table_readonly(doc, target, |t| !table_placement_matches(t, placement)); match needs { - None => break, // 그 인덱스의 표가 없다 — 탐색 종료 + None => break, // No table at this index; stop searching. Some(false) => found += 1, Some(true) => { let did = with_nth_table(doc, target, |t| apply_table_placement(t, placement)) @@ -1511,7 +1522,7 @@ pub fn set_table_placement( (found > 0).then_some(changed) } -/// 표가 요청된 배치와 이미 일치하는지 — placement와 hwp5 원본 common_data 비트 모두 검사. +/// Whether requested placement already matches both placement and original HWP5 common_data bits. fn table_placement_matches(t: &hwp_model::Table, tp: crate::from_markdown::TablePlacement) -> bool { let floating = tp == crate::from_markdown::TablePlacement::Floating; let placement_ok = t @@ -1522,9 +1533,9 @@ fn table_placement_matches(t: &hwp_model::Table, tp: crate::from_markdown::Table placement_ok && raw_ok } -/// hwp5 공통 속성(common_data)의 배치 비트가 `treat`와 일치하는지. attr은 [0..4] LE, -/// bit0=글자처럼 취급, bit13=본문과 어울림(GsoPlacement::synth_attr와 같은 레이아웃). -/// common_data가 없는 합성 표는 비트 검사를 통과한 것으로 본다(placement가 전부다). +/// Whether HWP5 common_data placement bits match treat. attr is little-endian [0..4], +/// bit0=treat-as-character, bit13=wrap with body text, matching GsoPlacement::synth_attr. +/// Synthesized tables without common_data pass this check; placement alone determines them. fn common_data_placement_matches(common_data: &[u8], treat: bool) -> bool { if common_data.len() < 4 { return true; @@ -1538,8 +1549,8 @@ fn common_data_placement_matches(common_data: &[u8], treat: bool) -> bool { (attr & 1 == u32::from(treat)) && ((attr >> 13) & 1 == u32::from(treat)) } -/// 실제 변환 — `table_placement_matches`가 false일 때만 호출한다. 값이 이미 맞는 필드는 -/// 건드리지 않는다(재적용 바이트 안정). +/// Applies the change only when table_placement_matches is false. Leave already-correct fields +/// untouched for byte-stable reapplication. fn apply_table_placement( t: &mut hwp_model::Table, tp: crate::from_markdown::TablePlacement, @@ -1556,14 +1567,15 @@ fn apply_table_placement( } } None => { - // 합성 표(md 출신): placement를 채우면 writer의 synthesized 폴백( pageBreak=CELL, - // repeatHeader=1 )이 꺼지므로 그 기본값을 attr(0b110)로 명시한다. + // For synthesized Markdown tables, setting placement disables the writer's synthesized + // fallback (pageBreak=CELL, repeatHeader=1). Set those defaults explicitly in attr + // (0b110). t.placement = Some(tp.gso_placement()); t.attr |= 0b110; changed = true; } } - // hwp5 출신은 writer가 raw common_data를 우선하므로 배치 비트도 함께 패치한다. + // The HWP5 writer prioritizes raw common_data, so patch its placement bits as well. if t.common_data.len() >= 4 { let attr = u32::from_le_bytes([ t.common_data[0], @@ -1637,7 +1649,8 @@ fn walk_nth_table R>( walk_nth_table(p, index, mutating, seen, f, out); if out.is_some() { if mutating { - // 내용이 바뀐 개체의 원문 XML은 낡았다 — stale 방출 금지. + // The object's content changed; clear captured XML to prevent stale + // output. g.hwpx_raw_xml = None; } return; @@ -1732,8 +1745,9 @@ fn set_cell_in_table( .find(|c| c.row == row && c.col == col) .ok_or_else(|| format!("표에 셀 ({row}, {col})이 없습니다"))?; - // 첫 문단을 서식 템플릿으로 — 문단/스타일/문자 모양/헤더 보존, 내용만 교체. - // 셀의 문단 목록은 아래에서 통째로 교체되므로 템플릿을 미리 복제해 둔다. + // Clone the first paragraph as a formatting template, preserving paragraph/style/character + // shapes and header. The complete paragraph list is replaced below, so capture the template + // first. let template = cell.paragraphs.first().cloned(); let mut paras: Vec = blocks .iter() @@ -1770,22 +1784,20 @@ fn set_cell_in_table( Ok(()) } -/// 표 행 추가/셀 설정용 빈 문단 — 템플릿 문단의 문단/스타일/첫 글자모양/헤더를 -/// 보존하고 내용은 비운다(줄 배치도 비워 writer가 재합성). 한글 합성 게이트는 -/// 셀당 문단 ≥1·문자모양 run ≥1만 요구하므로 빈 chars로 충분하다(writer가 -/// nchars=1·PARA_TEXT 생략을 처리). +/// Empty paragraph for row insertion/cell replacement: retain the template's paragraph/style/first +/// character shape/header, clearing contents and line layouts for writer synthesis. Hangul +/// synthesis requires at least one paragraph per cell and one character-shape run, so empty chars +/// suffice; the writer handles nchars=1 with omitted PARA_TEXT. /// -/// nchars bit31(리스트 마지막 문단 표식)을 켠 채로 돌려준다 — 대부분의 호출자는 -/// `cell.paragraphs = vec![이 문단]`이라 이 문단이 리스트의 마지막이기 때문이다. -/// 여러 문단을 만드는 호출자([`set_cell_in_table`])는 리스트를 다 만든 뒤 -/// [`fixup_last_para_flag`]로 마지막 문단에만 비트를 남겨야 한다. hwp5 출신 편집 -/// 경로는 writer가 set_last_para_flag를 돌리지 않으므로(synthesize=false) 이 비트를 -/// 여기서 관리하지 않으면 한글이 셀을 손상으로 판정한다(B4). +/// Return with nchars bit31 set for the last paragraph in a list: most callers put only this +/// paragraph in cell.paragraphs. Multi-paragraph callers (set_cell_in_table) must call +/// fixup_last_para_flag after assembling the list to leave the bit only on its final paragraph. +/// HWP5 edits use synthesize=false and do not run writer set_last_para_flag; failing to maintain it +/// here makes Hangul reject the cell as corrupt (B4). /// -/// 템플릿 헤더의 `ctrl_mask`는 지운다 — 마스크는 템플릿이 품고 있던 확장/인라인 -/// 컨트롤을 가리키는데 이 문단은 controls를 비우므로, 그대로 물려주면 writer가 -/// "있다고 표시된 컨트롤이 실제로 없는" 문단을 방출한다(한글 손상 판정). 0이면 -/// writer가 문자 목록에서 다시 계산한다. +/// Clear the template header's ctrl_mask. It names the template's extended/inline controls, but +/// this paragraph has no controls; inheriting it would declare nonexistent controls and cause +/// Hangul corruption. With zero, the writer recalculates from characters. fn blank_para_like(template: Option<&Paragraph>) -> Paragraph { let mut header = template.map(|p| p.header.clone()).unwrap_or_default(); header.chars_flags |= 0x80; @@ -1825,8 +1837,8 @@ fn add_rows_in_table( return Err(format!("행 삽입 위치 {a}가 범위를 벗어남 (0..={rows})")); } }; - // 행 수는 u16 범위 — 남은 용량을 넘으면 거부(넘으면 count as u16 절단으로 cells/ - // row_cell_counts가 어긋나 표 레코드가 깨진다). + // Row counts are u16: refuse beyond remaining capacity, or truncating count to u16 misaligns + // cells/row_cell_counts and corrupts table records. let remaining = usize::from(u16::MAX) - usize::from(table.rows); if count > remaining { return Err(format!( @@ -1897,9 +1909,10 @@ fn add_rows_in_table_inner( } let colw = column_widths(table); let rowh = row_heights(table); - // 복제 문단 instance_id 충돌 방지: hwp5 출신 편집 경로는 writer가 id를 재부여하지 - // 않으므로(synthesize=false), 표 내 최댓값 위로 고유 id를 부여한다(같은 템플릿 - // 문단을 N개 셀에 복제하면 비-0 id가 N+1개 중복돼 한글 개체 링크가 깨진다). + // Prevent cloned paragraph instance_id collisions. HWP5 edits use synthesize=false and the + // writer does not reassign IDs, so assign unique IDs above the table-local maximum. Cloning one + // template into N cells otherwise repeats one nonzero ID N+1 times and breaks Hangul object + // links. let mut next_inst = max_instance_id(table); let count16 = count as u16; // 1) Positioned insertion: shift cells at/below the boundary, extend vertical @@ -1975,8 +1988,9 @@ fn add_rows_in_table_inner( Ok(()) } -/// 행 r이 전 열을 1×1 셀로 채우는 '깨끗한' 행인지 — 병합 셀이 없고(row/col_span==1) -/// 세로 병합에 덮이지도 않음(row_cell_counts==cols). 행 복제·삭제·열 추가 가드 공용. +/// Whether row r is clean: all columns have 1x1 cells with no merges (row_span/col_span==1) and no +/// coverage from a vertical merge (row_cell_counts==cols). Shared by row cloning/deletion and +/// column-insertion guards. fn is_clean_row(table: &hwp_model::Table, r: u16) -> bool { table.row_cell_counts.get(r as usize).copied() == Some(table.cols) && table @@ -1986,8 +2000,9 @@ fn is_clean_row(table: &hwp_model::Table, r: u16) -> bool { .all(|c| c.col_span == 1 && c.row_span == 1) } -/// 복제 기본 템플릿: 마지막의 '깨끗한' 행 — 전 열을 채우고(row_cell_counts==cols) -/// 병합 셀이 없는 행. 세로 병합에 덮인 행은 셀 수가 cols보다 적어 자동 제외된다. +/// Default clone template: the last clean, unmerged row filling every column +/// (row_cell_counts==cols). Rows covered by vertical merges have fewer cells than columns and are +/// excluded automatically. fn clean_template_row(table: &hwp_model::Table) -> Option { (0..table.rows).rev().find(|&r| is_clean_row(table, r)) } @@ -2684,7 +2699,7 @@ fn delete_object_in_para( } } if apply && n > before { - // 내용이 바뀐 개체의 원문 XML은 낡았다 — stale 방출 금지. + // The object's content changed; clear captured XML to prevent stale output. g.hwpx_raw_xml = None; } } @@ -2704,7 +2719,7 @@ fn delete_object_in_para( } /// Removes the controls at the `targets` indexes and their anchor chars (ExtCtrl + FIELD_END of -/// a field start), and adjusts char_shape_runs positions. Returns the number removed. +/// a field start), validating the width change before the removal walk mutates anything. fn check_control_removal(para: &Paragraph, targets: &[usize]) -> Result, String> { let mut removed = Vec::new(); let mut orig_pos = 0; @@ -3215,8 +3230,8 @@ mod tests { #[test] fn 편집된_문단만_줄배치_무효화() { - // 외과적 편집: 편집한 문단의 줄 배치만 비우고, 미편집 문단은 보존해야 - // (한글이 표 행 높이 등을 그대로 유지하도록). + // Surgical editing clears only the edited paragraph's line layout, preserving unedited + // caches so Hangul retains table-row heights, etc. let mut doc = from_markdown("바꿀문단 있음\n\n그대로 둘 문단\n"); for p in &mut doc.sections[0].paragraphs { p.line_segs.push(dummy_lineseg()); @@ -3243,7 +3258,7 @@ mod tests { assert_eq!(n, 1); let text = doc.plain_text(); assert!(text.contains("기획팀입니다을 적으세요"), "got: {text:?}"); - // char_shape_run은 0에서 시작하고 단조 증가해야 한다. + // Character-shape runs start at zero and increase monotonically. for section in &doc.sections { for p in §ion.paragraphs { if let Some(first) = p.char_shape_runs.first() { @@ -3259,7 +3274,7 @@ mod tests { #[test] fn 치환문이_찾기문_포함_무한루프_없음() { - // "한라대학교" → "제주한라대학교": to가 from을 포함 → 재탐색 무한루프 방지. + // "한라대학교" -> "제주한라대학교": replacement contains its search text; prevent infinite rematching. let mut doc = from_markdown("한라대학교 보고서\n"); let n = replace_text(&mut doc, "한라대학교", "제주한라대학교", true).unwrap(); assert_eq!(n, 1); @@ -3284,7 +3299,7 @@ mod tests { set_cell(&mut doc, 0, 1, 0, "바뀐값").unwrap(); let text = doc.plain_text(); assert!(text.contains("바뀐값"), "got: {text:?}"); - // 셀이 1개 문단(내용+문단끝)만 갖는지. + // The cell contains exactly one paragraph with content plus paragraph end. assert!(set_cell(&mut doc, 0, 99, 99, "x").is_err()); assert!(set_cell(&mut doc, 5, 0, 0, "x").is_err()); } @@ -3298,21 +3313,23 @@ mod tests { #[test] fn set_cell_블록_분할_규칙() { - // CRLF 정규화 · LF 2개 이상이 경계 · 앞뒤 LF만 제거 · 빈 블록 폐기. + // Normalize CRLF; split at two or more LF; trim only leading/trailing LF; discard empty + // blocks. assert_eq!(split_cell_blocks("A\n\nB"), vec!["A", "B"]); assert_eq!(split_cell_blocks("A\r\n\r\nB"), vec!["A", "B"]); assert_eq!(split_cell_blocks("A\n\n\nB"), vec!["A", "B"]); assert_eq!(split_cell_blocks("A\n\n\n\n\nB"), vec!["A", "B"]); - // 블록 안의 단일 LF는 문단 내 줄바꿈으로 남는다. + // One LF within a block remains an in-paragraph line break. assert_eq!(split_cell_blocks("A\nB"), vec!["A\nB"]); - // 빈 값 · 빈 줄뿐인 값은 빈 문단 1개(기존 동작). + // An empty value or only blank lines yields one empty paragraph, retaining prior behavior. assert_eq!(split_cell_blocks(""), vec![""]); assert_eq!(split_cell_blocks("\n\n\n"), vec![""]); } #[test] fn set_cell_단일_블록은_문단_1개() { - // 분할 도입 전 동작 보존: 블록 1개면 문단 1개, 템플릿 instance_id 그대로. + // Preserve behavior before block splitting: one block yields one paragraph with the + // template instance_id. let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); let before = cell_at(first_table(&doc), 1, 0).paragraphs[0] .header @@ -3329,7 +3346,7 @@ mod tests { 0x80, "마지막 문단 비트" ); - // 문단 내 줄바꿈은 LINE_BREAK로 남는다. + // In-paragraph line breaks remain LINE_BREAK controls. assert!( cell.paragraphs[0] .chars @@ -3359,7 +3376,7 @@ mod tests { }) .collect(); assert_eq!(texts, vec!["첫째", "둘째", "셋째"], "블록 순서 보존"); - // B4: 마지막 문단만 bit31. + // B4: only the last paragraph carries bit31. for (i, p) in cell.paragraphs.iter().enumerate() { let last = i + 1 == cell.paragraphs.len(); assert_eq!( @@ -3368,8 +3385,8 @@ mod tests { "문단 {i} 마지막 비트" ); } - // A8: 문단마다 고유 instance_id. 문단 0은 템플릿 id를 물려받으므로(여기서는 - // from_markdown 문서라 0), 새로 만든 1..N만 비-0을 요구한다. + // A8: unique instance_id per paragraph. Paragraph 0 inherits the template ID, zero for this + // from_markdown fixture, so only new paragraphs 1..N require nonzero IDs. let ids: Vec = cell .paragraphs .iter() @@ -3379,8 +3396,8 @@ mod tests { ids[1..].iter().all(|id| *id != 0), "새 문단 id 비-0: {ids:?}" ); - // 그리고 셀 안이 아니라 **문서 전체**에서 유일해야 한다 — 템플릿 id를 물려받는 - // 문단 0만 예외이므로 새로 만든 id들만 검사한다. + // IDs must be unique document-wide, beyond the cell. Paragraph 0 inherits its template ID + // and is the sole exception, so check only newly allocated IDs. let mut all = Vec::new(); collect_instance_ids(&doc, &mut all); for id in &ids[1..] { @@ -3390,7 +3407,7 @@ mod tests { "새 id {id}가 문서에서 유일하지 않다: {all:?}" ); } - // 줄 배치는 비워 writer가 재합성한다(B2/B3). + // Leave line layouts empty for writer synthesis (B2/B3). assert!(cell.paragraphs.iter().all(|p| p.line_segs.is_empty())); } @@ -3431,38 +3448,38 @@ mod tests { } } - /// A8: instance id 배정이 u32를 넘기면 0으로 감기거나 살아 있는 id와 충돌한다. - /// 넘칠 상황이면 아무것도 바꾸지 않고 거절해야 한다(원자적 거부). + /// A8: allocating instance IDs beyond u32 would wrap to zero or collide with a live ID. Refuse + /// atomically without changing anything when overflow would occur. #[test] fn set_cell_는_instance_id_넘침을_거절한다() { let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); - // 문서 최댓값을 u32 끝에 붙여 둔다 — 새 문단 하나도 배정할 수 없다. + // Place the document maximum at the u32 limit; no additional paragraph can be allocated. doc.sections[0].paragraphs[0].header.instance_id = u32::MAX; let before = doc.clone(); let err = set_cell(&mut doc, 0, 1, 0, "첫째\n\n둘째").unwrap_err(); assert!(err.contains("instance_id"), "{err}"); assert_eq!(doc, before, "거절 시 문서 불변"); - // 한 칸만 남으면 새 문단 하나(블록 2개)까지는 배정된다. + // One remaining slot permits one new paragraph, i.e. two blocks. doc.sections[0].paragraphs[0].header.instance_id = u32::MAX - 1; set_cell(&mut doc, 0, 1, 0, "첫째\n\n둘째").unwrap(); let cell = cell_at(first_table(&doc), 1, 0); assert_eq!(cell.paragraphs[1].header.instance_id, u32::MAX); - // 두 칸이 필요하면(블록 3개) 다시 거절한다. + // Two required slots, i.e. three blocks, are refused again. let before = doc.clone(); let err = set_cell(&mut doc, 0, 1, 1, "첫째\n\n둘째\n\n셋째").unwrap_err(); assert!(err.contains("instance_id"), "{err}"); assert_eq!(doc, before, "거절 시 문서 불변"); - // 블록 하나짜리 값은 새 id가 필요 없으므로 넘침과 무관하게 통과한다. + // A one-block value needs no new ID and succeeds independently of overflow. set_cell(&mut doc, 0, 1, 1, "한 문단").unwrap(); } #[test] fn blank_para_like_는_ctrl_mask를_지운다() { - // 템플릿이 품은 컨트롤 마스크를 물려받으면 controls가 빈 문단이 "있다고 - // 표시된 컨트롤이 없는" 상태로 방출된다 — 한글 손상 판정. + // Inheriting the template control mask with an empty controls list declares nonexistent + // controls and causes Hangul corruption. let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); { let t = doc.sections[0] @@ -3491,7 +3508,7 @@ mod tests { #[test] fn set_cell_반복_적용은_멱등() { - // 같은 값을 두 번 넣으면 문단 텍스트·개수가 그대로다(멱등성 프로브). + // Setting the same value twice preserves paragraph text/count, an idempotency probe. let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); set_cell(&mut doc, 0, 1, 0, "A\n\nB").unwrap(); let once = cell_at(first_table(&doc), 1, 0).clone(); @@ -3525,7 +3542,7 @@ mod tests { #[test] fn 행_추가_구조_불변식() { - // 2행 2열 표 → 3행 추가 → rows=5, cells=10, row_cell_counts 길이=5·합=10. + // A 2x2 table plus three rows has rows=5, cells=10, row_cell_counts length 5 and sum 10. let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); let before = first_table(&doc); let (r0, cells0, cols) = (before.rows, before.cells.len(), before.cols); @@ -3543,12 +3560,13 @@ mod tests { t.cells.len(), "row_cell_counts 합 == 셀 수 (hwp5 extract assert)" ); - // 새 행은 기존 최대 행 다음부터, 행 우선 평탄 순서 유지(append만). + // New rows follow the old maximum row, retaining append-only row-major order. let rows_in_order: Vec = t.cells.iter().map(|c| c.row).collect(); let mut sorted = rows_in_order.clone(); sorted.sort_unstable(); assert_eq!(rows_in_order, sorted, "cells 행 우선(단조 비감소) 순서"); - // 새 셀은 빈 문단 1개·문자모양 run 1개(한글 합성 게이트)·span 1. + // Each new cell has one empty paragraph, one character-shape run (Hangul synthesis gate), + // and span 1. for c in t.cells.iter().filter(|c| c.row >= r0) { assert_eq!(c.paragraphs.len(), 1, "새 셀 문단 1개"); assert!(c.paragraphs[0].chars.is_empty(), "새 셀 비어 있음"); @@ -3561,9 +3579,9 @@ mod tests { #[test] fn 행_추가_후_채우기() { let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); - let r0 = first_table(&doc).rows; // 2 (헤더+데이터) + let r0 = first_table(&doc).rows; // Two rows: header plus data. add_rows(&mut doc, 0, None, 1).unwrap(); - // 새 행 인덱스 = r0, 거기에 값 채움. + // The new row index is r0; fill values there. set_cell(&mut doc, 0, r0, 0, "새값A").unwrap(); set_cell(&mut doc, 0, r0, 1, "새값B").unwrap(); let text = doc.plain_text(); @@ -3575,7 +3593,7 @@ mod tests { #[test] fn 행_추가_서식_보존() { - // 새 셀의 폭·여백·테두리·문단모양이 템플릿 행에서 복제되는지. + // Cell width/margins/borders/paragraph shapes are copied from the template row. let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); let r0 = first_table(&doc).rows; let tpl: Vec<_> = { @@ -3603,32 +3621,33 @@ mod tests { #[test] fn 행_추가_엣지케이스() { let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); - // count=0은 무변경. + // count=0 leaves everything unchanged. let before = first_table(&doc).rows; add_rows(&mut doc, 0, Some(0), 0).unwrap(); assert_eq!(first_table(&doc).rows, before); - // 없는 표. + // Missing table. assert!(add_rows(&mut doc, 9, None, 1).is_err()); - // 범위 밖 템플릿 행. + // Template row out of bounds. assert!(add_rows(&mut doc, 0, Some(99), 1).is_err()); } #[test] fn 행_추가_u16_초과_거부() { - // count가 남은 u16 용량을 넘으면 절단 손상 대신 깔끔히 거부(레코드 깨짐 방지). + // Refuse counts exceeding remaining u16 capacity instead of truncating and corrupting + // records. let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); let err = add_rows(&mut doc, 0, None, 70_000).unwrap_err(); assert!(err.contains("u16"), "u16 범위 안내: {err}"); - // 표는 변경되지 않아야(거부 전 무변경). + // Refusal leaves the table unchanged. assert_eq!(first_table(&doc).rows, 2); } #[test] fn 행_추가_새문단_고유_instance_id_와_마지막비트() { - // 복제 문단은 (1) 서로 다른 비-0 instance_id, (2) nchars bit31(마지막 문단)을 - // 가져야 한다 — hwp5 출신 편집 경로는 writer가 재부여/세팅하지 않으므로. + // Cloned paragraphs need distinct nonzero instance_id values and nchars bit31 for their + // final paragraph. The HWP5 edit path neither reassigns IDs nor sets this flag. let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); - // 기존 셀 문단에 비-0 instance_id 부여(hwp5 출신 모사). + // Assign nonzero instance_id values to existing cell paragraphs, mimicking HWP5 input. for (i, c) in first_table_mut(&mut doc).cells.iter_mut().enumerate() { for p in &mut c.paragraphs { p.header.instance_id = (i as u32 + 1) * 100; @@ -3663,7 +3682,8 @@ mod tests { let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); { let t = first_table_mut(&mut doc); - // (0,0)을 세로 2행 병합으로, (1,0) 셀 제거 → 행 1은 (1,1)만(셀 1개, cols=2). + // Vertically merge (0,0) across two rows and remove cell (1,0). Row 1 then has only + // cell (1,1), one anchor with cols=2. if let Some(c00) = t.cells.iter_mut().find(|c| c.row == 0 && c.col == 0) { c00.row_span = 2; } @@ -3915,7 +3935,7 @@ mod tests { assert_eq!(before.cells.len(), after.cells.len()); } - /// 셀 폭을 원하는 대로 갖는 표를 만든다(행별 width 지정, 단순 그리드). + /// Builds a simple-grid table with caller-specified cell widths per row. fn width_table(widths: &[&[i32]]) -> Document { let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); let t = first_table_mut(&mut doc); @@ -3939,7 +3959,8 @@ mod tests { #[test] fn 열_추가_구조_불변식() { - // 2x2 표 → 열 추가 → cols=3, 셀 6개, row_cell_counts [3,3], 행 우선 순서. + // A 2x2 table plus one column has cols=3, six cells, row_cell_counts [3,3], row-major + // order. let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); let cells0 = first_table(&doc).cells.len(); add_col(&mut doc, 0).unwrap(); @@ -3951,7 +3972,7 @@ mod tests { let mut sorted = rows_in_order.clone(); sorted.sort_unstable(); assert_eq!(rows_in_order, sorted, "행 우선 순서 유지"); - // 새 열(마지막 열) 셀은 빈 문단 1개. + // Each cell in the appended last column has one empty paragraph. for c in t.cells.iter().filter(|c| c.col == 2) { assert_eq!(c.paragraphs.len(), 1); assert!(c.paragraphs[0].chars.is_empty()); @@ -3960,7 +3981,8 @@ mod tests { #[test] fn 열_추가_폭_합_정확보존() { - // 행 총폭이 열 추가 전후로 정확히 일치해야 한다(균등 몫 + 잔차 마지막 셀). + // Every row's total width remains exact after insertion, with equal shares and residual in + // the last cell. let mut doc = width_table(&[&[100, 50, 51], &[200, 200, 202]]); let before: Vec = (0..2) .map(|r| { @@ -3983,7 +4005,7 @@ mod tests { .sum(); assert_eq!(&sum, expect, "행 {r} 총폭 보존"); } - // 새 열 폭 = 행총폭/(기존열수+1). + // New column width = row total width / (old column count+1). assert_eq!( t.cells .iter() @@ -3993,20 +4015,21 @@ mod tests { .0, 201 / 4 ); - // 모든 폭은 양수. + // Every width is positive. assert!(t.cells.iter().all(|c| c.width.0 > 0)); } #[test] fn 열_추가_병합표_지원() { - // GK-2 통합: 병합 표도 열 추가를 지원한다(과거 #9의 '병합 거부'를 대체). - // (0,0)-(0,1) 가로 병합 후 끝에 열 추가 → 병합 유지·구조 유효. + // GK-2: merged tables also support added columns, replacing #9's former merged-table + // refusal. After horizontally merging (0,0)..(0,1), append a column; retain the merge and + // structural validity. let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); - merge_cells(&mut doc, 0, 0, 0, 0, 1).unwrap(); // 헤더 2칸 병합 + merge_cells(&mut doc, 0, 0, 0, 0, 1).unwrap(); // Merge the two header cells. add_col(&mut doc, 0).unwrap(); let t = first_table(&doc); assert_eq!(t.cols, 3, "열 추가됨"); - // 병합 앵커(0,0)은 유지, 면적 합=rows×cols. + // Retain anchor (0,0); total cell area equals rows*cols. assert!( t.cells .iter() @@ -4029,7 +4052,8 @@ mod tests { let t = first_table_mut(&mut doc); t.rows = 3; t.row_cell_counts = vec![2, 2, 1]; - // (2,0)을 덮는 세로 병합: (1,0) rowspan=2, 행 2는 셀 1개(덮인 행). + // Vertical merge covering (2,0): row 1 anchor (1,0) has rowspan=2; row 2 has one cell + // because it is covered. if let Some(c10) = t.cells.iter_mut().find(|c| c.row == 1 && c.col == 0) { c10.row_span = 2; } @@ -4038,10 +4062,10 @@ mod tests { c2.col = 1; t.cells.push(c2); } - // 덮인 행(2) 삭제 거부. + // Refuse deletion of covered row 2. let err = delete_table_row(&mut doc, 0, 2).unwrap_err(); assert!(err.contains("병합"), "병합 행 거부 안내: {err}"); - // 깨끗한 행(0)은 삭제 가능… 단 (1,0) rowspan이 행1에서 시작 → 행1도 거부. + // Clean row 0 can be deleted, but row 1 is refused because a rowspan starts at (1,0). let err1 = delete_table_row(&mut doc, 0, 1).unwrap_err(); assert!(err1.contains("병합")); delete_table_row(&mut doc, 0, 0).unwrap(); @@ -4050,9 +4074,9 @@ mod tests { #[test] fn 표_연산_재귀_인덱싱() { - // 중첩 표가 있으면 set-cell과 같은 깊이 우선 인덱스로 행/열 연산이 걸린다. + // Nested tables use the same depth-first indices as set-cell for row/column operations. let mut doc = from_markdown("| 가 | 나 |\n|----|----|\n| 1 | 2 |\n"); - // 바깥 표의 (1,0) 셀 안에 1x1 중첩 표 삽입. + // Insert a nested 1x1 table into outer cell (1,0). let inner = { let t = first_table(&doc); let mut inner = t.clone(); @@ -4075,7 +4099,8 @@ mod tests { .unwrap(); cell.paragraphs[0].controls.push(Control::Table(inner)); } - // 인덱스 1 = 중첩 표(깊이 우선). set-cell과 같은 번호로 행 추가가 걸려야 한다. + // Index 1 identifies the nested table in depth-first order; row insertion must use the same + // index as set-cell. add_rows(&mut doc, 1, None, 1).unwrap(); let outer = first_table(&doc); let inner_t = outer @@ -4104,7 +4129,7 @@ mod tests { .expect("표 없음") } - // ── 병합/분할·열 조작 (GK-1·GK-2) ────────────────────────────────── + // Merge/split and column operations (GK-1/GK-2). fn table_3x3() -> Document { from_markdown("| a | b | c |\n|---|---|---|\n| d | e | f |\n| g | h | i |\n") @@ -4234,7 +4259,8 @@ mod tests { #[test] fn 열_추가_전체폭_유지() { - // 전체 표 폭(행별 총폭)이 열 추가 후에도 정확히 보존돼야(#9 tbl9 정책 계승). + // Preserve total table width, including per-row totals, exactly after adding a column, + // retaining #9's tbl9 policy. let mut doc = table_3x3(); let before = row_widths(first_table(&doc)); add_table_column(&mut doc, 0, 1).unwrap(); @@ -4249,7 +4275,7 @@ mod tests { fn 열_추가_끝에_append() { let mut doc = table_3x3(); let before = row_widths(first_table(&doc)); - add_col(&mut doc, 0).unwrap(); // 끝에 추가(mcp·기본 CLI 경로) + add_col(&mut doc, 0).unwrap(); // Append at the end, the MCP/default CLI path. let t = first_table(&doc); assert_eq!(t.cols, 4); assert_eq!(t.cells.iter().filter(|c| c.col == 3).count(), 3); @@ -4272,8 +4298,8 @@ mod tests { fn 열_삭제_기본_전체폭유지() { let mut doc = table_3x3(); let before = row_widths(first_table(&doc)); - add_table_column(&mut doc, 0, 3).unwrap(); // 4열 - delete_table_column(&mut doc, 0, 1).unwrap(); // 3열 복귀 + add_table_column(&mut doc, 0, 3).unwrap(); // Four columns. + delete_table_column(&mut doc, 0, 1).unwrap(); // Back to three columns. let t = first_table(&doc); assert_eq!(t.cols, 3); assert_eq!(t.cells.len(), 9); @@ -4283,7 +4309,7 @@ mod tests { #[test] fn 열_삭제_병합축소_단일열제거() { - // 병합 축소. + // Shrink a merge. let mut doc = table_3x3(); merge_cells(&mut doc, 0, 0, 0, 0, 2).unwrap(); delete_table_column(&mut doc, 0, 1).unwrap(); @@ -4291,13 +4317,13 @@ mod tests { assert_eq!(t.cols, 2); let a = t.cells.iter().find(|c| c.row == 0 && c.col == 0).unwrap(); assert_eq!(a.col_span, 2, "병합 셀 축소"); - // 단일 열 셀 제거. + // Remove a single-column cell. let mut doc2 = table_3x3(); delete_table_column(&mut doc2, 0, 1).unwrap(); let t2 = first_table(&doc2); assert_eq!(t2.cols, 2); assert_eq!(t2.cells.len(), 6); - // 마지막 열 거부. + // Refuse deleting the last column. let mut doc3 = from_markdown("| a |\n|---|\n| b |\n"); assert!( delete_table_column(&mut doc3, 0, 0).is_err(), From 065ec9ef49c37dc6cee2e55b21d1fe223be0f53e Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Wed, 11 Nov 2026 00:57:31 +0900 Subject: [PATCH 4/8] docs: record opaque range-tag edit refusals --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8851a79..efc41121 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,13 @@ The workspace `Cargo.toml` `[workspace.package] version` is the single source fo this is a serialized-ID uniqueness fix, with its effect on opening in Hancom unverified ([#465](https://github.com/STAIxBWLB/hwp-cli/issues/465)). +- Text edits refuse before mutation when they would shift opaque HWP5 `PARA_RANGE_TAG` + positions, including field/slot/form filling and control insertion/removal. Whole-cell + replacement also refuses when rebuilding would discard a tagged subtree. Equal-WCHAR-width + text splices and exact tagged-cell no-ops preserve the opaque records. The refusal explains + how to use equal-width text or remove the marks in Hangul; reader and identity paths are + unchanged ([#470](https://github.com/STAIxBWLB/hwp-cli/issues/470)). + ## [1.5.0] **Compatibility** From ddf21cf1d1ab67306319d5b9e0a3751a9faac692 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Wed, 11 Nov 2026 00:57:50 +0900 Subject: [PATCH 5/8] test: unwrap fallible field and bookmark creation results --- crates/hwp-convert/src/edit.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/hwp-convert/src/edit.rs b/crates/hwp-convert/src/edit.rs index c418a3d1..c6852e79 100644 --- a/crates/hwp-convert/src/edit.rs +++ b/crates/hwp-convert/src/edit.rs @@ -3188,12 +3188,8 @@ mod tests { #[test] fn delete_object_필드와_책갈피() { let mut doc = crate::from_markdown::from_markdown("여기 앵커입니다.\n"); - assert!(crate::field::create_field(&mut doc, "앵커", "이름", "값")); - assert!(crate::bookmark::create_bookmark( - &mut doc, - "앵커", - "책갈피1" - )); + assert!(crate::field::create_field(&mut doc, "앵커", "이름", "값").unwrap()); + assert!(crate::bookmark::create_bookmark(&mut doc, "앵커", "책갈피1").unwrap()); assert_eq!( delete_object(&mut doc, ObjectKind::Field, "이름").unwrap(), 1 From 016debd6fe09002359424993408a06fd3d09a98f Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Wed, 11 Nov 2026 00:58:49 +0900 Subject: [PATCH 6/8] docs: clarify form splice preflight return value --- crates/hwp-convert/src/form.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/hwp-convert/src/form.rs b/crates/hwp-convert/src/form.rs index 0dee0de3..01a2de93 100644 --- a/crates/hwp-convert/src/form.rs +++ b/crates/hwp-convert/src/form.rs @@ -878,7 +878,7 @@ fn is_label_letter(ch: char) -> bool { } /// Splice, in each text segment of `para`, the edits `find` returns for it: byte ranges of -/// the segment, in order and not overlapping, with their replacements. Returns whether it +/// the segment, in order and not overlapping, with their replacements. Returns whether /// an edit was found. With `apply` false, validate the edits without changing the paragraph. fn splice_segments( para: &mut Paragraph, From 6cdba2031a4690a4f28623c1ce80faf843af3771 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Wed, 11 Nov 2026 02:24:56 +0900 Subject: [PATCH 7/8] test: expose field tab width mismatch at serialization --- crates/hwp-cli/tests/range_tag_edits.rs | 42 ++++++++++++ .../hwp-convert/src/edit_range_tag_tests.rs | 34 ++++++++++ crates/hwp-convert/src/field.rs | 65 +++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/crates/hwp-cli/tests/range_tag_edits.rs b/crates/hwp-cli/tests/range_tag_edits.rs index 208fb10b..db5a6a5b 100644 --- a/crates/hwp-cli/tests/range_tag_edits.rs +++ b/crates/hwp-cli/tests/range_tag_edits.rs @@ -179,3 +179,45 @@ fn equal_wchar_width_replacement_preserves_opaque_tag_bytes() { b"opaque range sentinel" ); } + +#[test] +fn named_field_tab_refuses_to_shift_a_tagged_range_before_publication() { + let dir = temp_path::TempPath::new("hwp470", "field-tab-width"); + std::fs::create_dir_all(&*dir).unwrap(); + let mut document = hwp_convert::from_markdown("Field anchor."); + assert!( + hwp_convert::create_field(&mut document, "Field anchor.", "entry", "A").unwrap(), + "setup must create a named field before attaching the opaque tag" + ); + document.sections[0].paragraphs[0] + .extras + .push(OpaqueRecord { + tag: 0x46, + data: b"opaque range sentinel".to_vec(), + children: Vec::new(), + }); + let source = dir.join("source.hwp"); + hwp5::write_document(&document, &source, &hwp5::WriteOptions::default()).unwrap(); + let reread = hwp5::read_document(&source).unwrap().document; + let fields = hwp_convert::list_fields(&reread); + assert_eq!(fields.len(), 1, "setup must retain exactly one field"); + assert_eq!(fields[0].name.as_deref(), Some("entry")); + assert_eq!(fields[0].value, "A", "setup must retain a one-WCHAR value"); + assert_eq!( + reread.sections[0].paragraphs[0] + .extras + .iter() + .find(|record| record.tag == 0x46) + .expect("source read-back must retain the opaque range tag") + .data, + b"opaque range sentinel" + ); + let workspace = Workspace { + output: dir.join("out.hwp"), + bytes: std::fs::read(&source).unwrap(), + source, + dir, + }; + // A serialized HWP tab occupies eight WCHARs, so this changes the value width. + workspace.assert_refusal(workspace.edit(&["--set-field", "entry= "])); +} diff --git a/crates/hwp-convert/src/edit_range_tag_tests.rs b/crates/hwp-convert/src/edit_range_tag_tests.rs index e875b42e..08e40d2a 100644 --- a/crates/hwp-convert/src/edit_range_tag_tests.rs +++ b/crates/hwp-convert/src/edit_range_tag_tests.rs @@ -599,3 +599,37 @@ fn range_tags_no_match_and_same_text_requests_are_allowed() { before.sections[0].paragraphs[0].extras ); } + +#[test] +fn field_tab_growth_refuses_tagged_named_field_edit() { + let mut doc = field_document(false); + tag(&mut doc.sections[0].paragraphs[0]); + assert_document_refusal(doc, |doc| crate::set_field(doc, "entry", "\t")); +} + +#[test] +fn field_tab_guard_keeps_same_width_non_tab_controls_available() { + for value in ["B", "\n"] { + let mut doc = field_document(false); + tag(&mut doc.sections[0].paragraphs[0]); + let extras = doc.sections[0].paragraphs[0].extras.clone(); + assert_eq!(crate::set_field(&mut doc, "entry", value).unwrap(), 1); + assert_eq!(crate::list_fields(&doc)[0].value, value); + assert_eq!(doc.sections[0].paragraphs[0].extras, extras); + } +} + +#[test] +fn field_tab_guard_allows_eight_wchar_replacement() { + let mut doc = field_document(false); + crate::set_field(&mut doc, "entry", "12345678").unwrap(); + tag(&mut doc.sections[0].paragraphs[0]); + let extras = doc.sections[0].paragraphs[0].extras.clone(); + let result = crate::set_field(&mut doc, "entry", "\t"); + assert!( + result.is_ok(), + "equal serialized WCHAR widths must remain allowed: {result:?}" + ); + assert_eq!(crate::list_fields(&doc)[0].value, "\t"); + assert_eq!(doc.sections[0].paragraphs[0].extras, extras); +} diff --git a/crates/hwp-convert/src/field.rs b/crates/hwp-convert/src/field.rs index 83873fc7..583dd1bb 100644 --- a/crates/hwp-convert/src/field.rs +++ b/crates/hwp-convert/src/field.rs @@ -1106,6 +1106,71 @@ mod tests { assert_eq!(set_field(&mut doc, "없는이름", "x").unwrap(), 0); } + #[test] + fn field_tab_creation_tracks_serialized_width_and_value() { + let mut doc = crate::from_markdown("anchor tail"); + let before: u32 = doc.sections[0].paragraphs[0] + .chars + .iter() + .map(HwpChar::wchar_width) + .sum(); + assert!(create_field(&mut doc, "anchor", "entry", "\t").unwrap()); + assert_eq!(list_fields(&doc)[0].value, "\t"); + let after: u32 = doc.sections[0].paragraphs[0] + .chars + .iter() + .map(HwpChar::wchar_width) + .sum(); + assert_eq!( + after - before, + 24, + "two eight-WCHAR field controls surround an eight-WCHAR tab" + ); + } + + #[test] + fn field_tab_hyperlink_style_covers_the_complete_display_width() { + let mut doc = crate::from_markdown("anchor tail"); + assert!(create_hyperlink(&mut doc, "anchor", "https://example.com", "A\tB").unwrap()); + assert_eq!(list_fields(&doc)[0].value, "A\tB"); + let shape = CharShapeId( + doc.header + .char_shapes + .iter() + .position(|shape| shape.text_color == 0x00FF_0000 && shape.underline_kind() == 1) + .unwrap() as u16, + ); + let para = &doc.sections[0].paragraphs[0]; + let start = para + .chars + .iter() + .position(|ch| { + matches!(ch, + HwpChar::ExtCtrl { code, .. } if *code == FIELD_START) + }) + .unwrap(); + let display_start: u32 = para.chars[..=start].iter().map(HwpChar::wchar_width).sum(); + let display_end = display_start + 10; // A + eight-WCHAR tab + B. + let last_shape = para + .char_shape_runs + .iter() + .rev() + .find(|(pos, _)| *pos < display_end) + .map(|(_, id)| *id); + assert_eq!( + last_shape, + Some(shape), + "the final display character must retain hyperlink style" + ); + assert!( + para.char_shape_runs + .iter() + .any(|(pos, id)| *pos == display_end && *id != shape), + "restore the original shape at the serialized display end: {:?}", + para.char_shape_runs + ); + } + #[test] fn create_field_삽입_후_읽기_채우기() { let mut doc = crate::from_markdown::from_markdown("수신: 부서"); From 2f3dbfda0e205e3f49655233cfae0859509c3360 Mon Sep 17 00:00:00 2001 From: Young Joon Lee Date: Wed, 11 Nov 2026 02:27:56 +0900 Subject: [PATCH 8/8] fix: normalize field tabs before checking range-tag widths --- CHANGELOG.md | 4 +++- crates/hwp-convert/src/field.rs | 18 +++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efc41121..175a6494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,7 +73,9 @@ The workspace `Cargo.toml` `[workspace.package] version` is the single source fo - Text edits refuse before mutation when they would shift opaque HWP5 `PARA_RANGE_TAG` positions, including field/slot/form filling and control insertion/removal. Whole-cell replacement also refuses when rebuilding would discard a tagged subtree. Equal-WCHAR-width - text splices and exact tagged-cell no-ops preserve the opaque records. The refusal explains + text splices and exact tagged-cell no-ops preserve the opaque records. Field values and + hyperlink displays normalize tabs to their eight-WCHAR serialized width before preflight + and insertion; hyperlink style boundaries use that width too. The refusal explains how to use equal-width text or remove the marks in Hangul; reader and identity paths are unchanged ([#470](https://github.com/STAIxBWLB/hwp-cli/issues/470)). diff --git a/crates/hwp-convert/src/field.rs b/crates/hwp-convert/src/field.rs index 583dd1bb..a843a86b 100644 --- a/crates/hwp-convert/src/field.rs +++ b/crates/hwp-convert/src/field.rs @@ -191,6 +191,7 @@ fn field_value(chars: &[HwpChar], start: usize) -> String { match ch { HwpChar::Text(c) => s.push(*c), HwpChar::CharCtrl(10) => s.push('\n'), + HwpChar::InlineCtrl { code, .. } if *code == hwp_model::ctrl_char::TAB => s.push('\t'), HwpChar::InlineCtrl { code, .. } if *code == FIELD_END => break, _ => {} } @@ -402,15 +403,18 @@ fn set_field_para( Ok(count + nested) } +/// Field tabs use the same eight-WCHAR inline representation emitted by the HWP5 writer. +/// Both preflight and insertion consume this builder, including field/hyperlink creation. fn build_value_chars(value: &str) -> Vec { value .chars() - .map(|c| { - if c == '\n' { - HwpChar::CharCtrl(10) - } else { - HwpChar::Text(c) - } + .map(|c| match c { + '\t' => HwpChar::InlineCtrl { + code: hwp_model::ctrl_char::TAB, + payload: vec![0; 12], + }, + '\n' => HwpChar::CharCtrl(hwp_model::ctrl_char::LINE_BREAK), + c => HwpChar::Text(c), }) .collect() } @@ -841,7 +845,7 @@ fn create_field_in_para( apply_run_style( &mut para.char_shape_runs, dstart, - dstart + utf16_len(value), + iw + inserted_w - 8, // Restore before the eight-WCHAR FIELD_END control. shape, ); }