Skip to content
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ 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. 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)).

## [1.5.0]

**Compatibility**
Expand Down
42 changes: 24 additions & 18 deletions crates/hwp-cli/src/commands/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2225,9 +2225,9 @@ fn normalized_relative(root: &Path, path: &Path) -> Result<String> {
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
Expand Down Expand Up @@ -2266,7 +2266,7 @@ fn audit_tree(root: &Path, manifest: &ArtifactManifest, case_ids: &[&str]) -> Re
)
.collect::<BTreeSet<_>>();
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()
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
57 changes: 39 additions & 18 deletions crates/hwp-cli/src/commands/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1211,7 +1211,8 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result<E
unapplied.push(format!("--replace {spec:?}"));
continue;
}
let n = hwp_convert::replace_text(&mut doc, from, to, true);
let n = hwp_convert::replace_text(&mut doc, from, to, true)
.map_err(anyhow::Error::msg)?;
eprintln!("치환: {from:?} → {to:?} ({n}건)");
record_effect(
&before,
Expand Down Expand Up @@ -1287,7 +1288,9 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result<E
)
})?;
let (name, value) = rest.split_once('=').unwrap_or((rest, ""));
if hwp_convert::create_field(&mut doc, anchor, name, value) {
if hwp_convert::create_field(&mut doc, anchor, name, value)
.map_err(anyhow::Error::msg)?
{
eprintln!("누름틀 생성: {anchor:?} 뒤에 이름={name:?} 값={value:?}");
edits += 1;
} else {
Expand All @@ -1301,7 +1304,9 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result<E
let (anchor, name) = spec.split_once("=>").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 {
Expand All @@ -1321,7 +1326,9 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result<E
"--create-hyperlink 형식은 \"앵커=>URL\" 또는 \"앵커=>표시=>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 {
Expand Down Expand Up @@ -1361,7 +1368,8 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result<E
let (name, value) = spec.split_once('=').with_context(|| {
format!("--set-field 형식은 \"이름=값\" 입니다: {spec:?}")
})?;
let n = hwp_convert::set_field(&mut doc, name, value);
let n = hwp_convert::set_field(&mut doc, name, value)
.map_err(anyhow::Error::msg)?;
if n == 0 {
eprintln!("경고: 필드 {name:?}를 찾지 못했습니다 (hwp fields로 이름 확인)");
unapplied.push(format!("--set-field {spec:?}"));
Expand Down Expand Up @@ -1687,7 +1695,8 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result<E
&mut doc,
hwp_convert::ObjectKind::Image,
anchor,
);
)
.map_err(anyhow::Error::msg)?;
if n == 0 {
eprintln!("경고: 그림을 찾지 못했습니다 (앵커 {anchor:?})");
unapplied.push(format!("--delete-image {anchor:?}"));
Expand All @@ -1705,8 +1714,10 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result<E
hwp_convert::ObjectKind::TableNth(nth),
"",
)
.map_err(anyhow::Error::msg)?
} else {
hwp_convert::delete_object(&mut doc, hwp_convert::ObjectKind::Table, spec)
.map_err(anyhow::Error::msg)?
};
if n == 0 {
eprintln!("경고: 표를 찾지 못했습니다 ({spec:?})");
Expand All @@ -1720,7 +1731,8 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result<E
EditOperation::DeleteField(specs) => {
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:?}"));
Expand All @@ -1736,7 +1748,8 @@ pub fn execute(input: &Path, output: &Path, plan: &EditPlan) -> anyhow::Result<E
&mut doc,
hwp_convert::ObjectKind::Bookmark,
name,
);
)
.map_err(anyhow::Error::msg)?;
if n == 0 {
eprintln!("경고: 책갈피를 찾지 못했습니다 ({name:?})");
unapplied.push(format!("--delete-bookmark {name:?}"));
Expand Down Expand Up @@ -3365,7 +3378,8 @@ fn apply_typed_operation(
// Structural drift (planner decision 3): an earlier insert/delete/move in this
// batch may have shifted this paragraph's position since preflight resolved it.
let current_path = offsets.current_path(&target.path)?;
let count = hwp_convert::replace_text_at(doc, &current_path, from, to);
let count = hwp_convert::replace_text_at(doc, &current_path, from, to)
.map_err(anyhow::Error::msg)?;
if count == 0 {
unapplied.push(format!(
"replace address={current_path} from={from:?} to={to:?}"
Expand All @@ -3376,7 +3390,8 @@ fn apply_typed_operation(
}
} else {
let before = doc.clone();
let count = hwp_convert::replace_text(doc, from, to, true);
let count =
hwp_convert::replace_text(doc, from, to, true).map_err(anyhow::Error::msg)?;
eprintln!("치환: {from:?} → {to:?} ({count}건)");
record_effect(&before, doc, replace_request(from, to), edits, unapplied);
}
Expand Down Expand Up @@ -3426,15 +3441,15 @@ fn apply_typed_operation(
name,
value,
} => {
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 {
unapplied.push(format!("create_field anchor={anchor:?} name={name:?}"));
}
}
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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
Loading
Loading