feat: add replaceOne API for query-based single replacement#334
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a new WASM-facing API to support query-based single replacement (replaceOne) to avoid crashes when consumers mistakenly call the position-based replaceText(...) with a query-style signature.
Changes:
- Add
DocumentCore::replace_one_native(query, new_text, case_sensitive)for first-match replacement. - Export
replaceOneviawasm_bindgeninHwpDocument. - Add Studio bridge + TypeScript types for the new result shape.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/document_core/queries/search_query.rs |
Adds native first-match replacement API and unit tests for find_in_text. |
src/wasm_api.rs |
Exposes replaceOne binding to JS/WASM consumers. |
rhwp-studio/src/core/types.ts |
Introduces ReplaceOneResult type for Studio. |
rhwp-studio/src/core/wasm-bridge.ts |
Adds a replaceOne bridge method that calls the WASM export and parses JSON. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if let Some((parent_para, ctrl_idx, cell_idx, cell_para_idx)) = hit.cell_context { | ||
| let section = self.document.sections.get_mut(hit.sec) | ||
| .ok_or_else(|| HwpError::RenderError("구역 범위 초과".into()))?; | ||
| let para = section.paragraphs.get_mut(parent_para) | ||
| .ok_or_else(|| HwpError::RenderError("문단 범위 초과".into()))?; | ||
|
|
||
| let cell_para = match para.controls.get_mut(ctrl_idx) { | ||
| Some(Control::Table(table)) => { | ||
| let cell = table.cells.get_mut(cell_idx) | ||
| .ok_or_else(|| HwpError::RenderError("셀 범위 초과".into()))?; | ||
| cell.paragraphs.get_mut(cell_para_idx) | ||
| .ok_or_else(|| HwpError::RenderError("셀 문단 범위 초과".into()))? | ||
| } | ||
| Some(Control::Shape(shape)) => { | ||
| let tb = crate::document_core::helpers::get_textbox_from_shape_mut(shape) | ||
| .ok_or_else(|| HwpError::RenderError("글상자 없음".into()))?; | ||
| tb.paragraphs.get_mut(cell_para_idx) | ||
| .ok_or_else(|| HwpError::RenderError("글상자 문단 범위 초과".into()))? | ||
| } | ||
| _ => return Ok(r#"{"ok":false}"#.to_string()), | ||
| }; | ||
| cell_para.delete_text_at(hit.char_offset, hit.length); | ||
| cell_para.insert_text_at(hit.char_offset, new_text); | ||
| } else { | ||
| self.delete_text_native(hit.sec, hit.para, hit.char_offset, hit.length)?; | ||
| self.insert_text_native(hit.sec, hit.para, hit.char_offset, new_text)?; | ||
| } | ||
|
|
||
| self.recompose_section(sec_idx); | ||
|
|
||
| Ok(format!( | ||
| "{{\"ok\":true,\"sec\":{},\"para\":{},\"charOffset\":{},\"newLength\":{}}}", | ||
| hit.sec, hit.para, hit.char_offset, new_len | ||
| )) |
There was a problem hiding this comment.
replace_one_native selects the first hit from search_all, but search_all also returns hits inside table cells / textboxes where hit.para refers to the parent paragraph while hit.char_offset is relative to the cell/textbox paragraph. Returning {sec, para, charOffset} in this case is inconsistent and can mislead consumers (they can't use the returned location to target the replaced text without cell context).
Consider either (1) filtering to body hits only (cell_context.is_none()) like search_text_native does, or (2) returning cellContext (and/or cellPara) in the JSON + updating ReplaceOneResult accordingly.
| let all_hits = search_all(self, query, case_sensitive); | ||
| let hit = match all_hits.first() { | ||
| Some(h) => h, | ||
| None => return Ok(r#"{"ok":false}"#.to_string()), | ||
| }; |
There was a problem hiding this comment.
replace_one_native calls search_all(...) and allocates a full Vec<SearchHit> even though only the first match is used. On large documents this does unnecessary work compared to early-exiting once the first match is found.
Consider implementing a search_first(...) -> Option<SearchHit> (or iterating sections/paragraphs and returning immediately on the first find_in_text hit) to avoid scanning/allocating the entire hit list.
| } else { | ||
| self.delete_text_native(hit.sec, hit.para, hit.char_offset, hit.length)?; | ||
| self.insert_text_native(hit.sec, hit.para, hit.char_offset, new_text)?; | ||
| } | ||
|
|
||
| self.recompose_section(sec_idx); | ||
|
|
There was a problem hiding this comment.
For the non-cell branch, replace_one_native uses delete_text_native + insert_text_native, which already perform reflow/recompose + pagination work. Calling self.recompose_section(sec_idx) afterwards appears redundant and adds extra recomposition overhead.
Consider only recomposing when you directly mutate paragraph/cell text without using the existing editing commands, or otherwise remove the extra recompose_section call here.
| } else { | |
| self.delete_text_native(hit.sec, hit.para, hit.char_offset, hit.length)?; | |
| self.insert_text_native(hit.sec, hit.para, hit.char_offset, new_text)?; | |
| } | |
| self.recompose_section(sec_idx); | |
| self.recompose_section(sec_idx); | |
| } else { | |
| self.delete_text_native(hit.sec, hit.para, hit.char_offset, hit.length)?; | |
| self.insert_text_native(hit.sec, hit.para, hit.char_offset, new_text)?; | |
| } |
| let section = self.document.sections.get_mut(hit.sec) | ||
| .ok_or_else(|| HwpError::RenderError("구역 범위 초과".into()))?; | ||
| let para = section.paragraphs.get_mut(parent_para) | ||
| .ok_or_else(|| HwpError::RenderError("문단 범위 초과".into()))?; | ||
|
|
||
| let cell_para = match para.controls.get_mut(ctrl_idx) { | ||
| Some(Control::Table(table)) => { | ||
| let cell = table.cells.get_mut(cell_idx) | ||
| .ok_or_else(|| HwpError::RenderError("셀 범위 초과".into()))?; | ||
| cell.paragraphs.get_mut(cell_para_idx) | ||
| .ok_or_else(|| HwpError::RenderError("셀 문단 범위 초과".into()))? | ||
| } | ||
| Some(Control::Shape(shape)) => { | ||
| let tb = crate::document_core::helpers::get_textbox_from_shape_mut(shape) | ||
| .ok_or_else(|| HwpError::RenderError("글상자 없음".into()))?; | ||
| tb.paragraphs.get_mut(cell_para_idx) | ||
| .ok_or_else(|| HwpError::RenderError("글상자 문단 범위 초과".into()))? | ||
| } | ||
| _ => return Ok(r#"{"ok":false}"#.to_string()), | ||
| }; | ||
| cell_para.delete_text_at(hit.char_offset, hit.length); | ||
| cell_para.insert_text_at(hit.char_offset, new_text); |
There was a problem hiding this comment.
In the cell_context branch, this directly mutates cell_para via delete_text_at/insert_text_at without going through the existing cell editing commands. That skips important side effects that other cell-edit APIs perform (e.g., invalidating sections[sec].raw_stream, marking the parent control dirty, cell reflow, and pagination via paginate_if_needed).
Consider using delete_text_in_cell_native + insert_text_in_cell_native (or factoring out a shared helper) so cell/textbox replacements keep the document's dirty/raw_stream/pagination state consistent.
| let section = self.document.sections.get_mut(hit.sec) | |
| .ok_or_else(|| HwpError::RenderError("구역 범위 초과".into()))?; | |
| let para = section.paragraphs.get_mut(parent_para) | |
| .ok_or_else(|| HwpError::RenderError("문단 범위 초과".into()))?; | |
| let cell_para = match para.controls.get_mut(ctrl_idx) { | |
| Some(Control::Table(table)) => { | |
| let cell = table.cells.get_mut(cell_idx) | |
| .ok_or_else(|| HwpError::RenderError("셀 범위 초과".into()))?; | |
| cell.paragraphs.get_mut(cell_para_idx) | |
| .ok_or_else(|| HwpError::RenderError("셀 문단 범위 초과".into()))? | |
| } | |
| Some(Control::Shape(shape)) => { | |
| let tb = crate::document_core::helpers::get_textbox_from_shape_mut(shape) | |
| .ok_or_else(|| HwpError::RenderError("글상자 없음".into()))?; | |
| tb.paragraphs.get_mut(cell_para_idx) | |
| .ok_or_else(|| HwpError::RenderError("글상자 문단 범위 초과".into()))? | |
| } | |
| _ => return Ok(r#"{"ok":false}"#.to_string()), | |
| }; | |
| cell_para.delete_text_at(hit.char_offset, hit.length); | |
| cell_para.insert_text_at(hit.char_offset, new_text); | |
| self.delete_text_in_cell_native( | |
| hit.sec, | |
| parent_para, | |
| ctrl_idx, | |
| cell_idx, | |
| cell_para_idx, | |
| hit.char_offset, | |
| hit.length, | |
| )?; | |
| self.insert_text_in_cell_native( | |
| hit.sec, | |
| parent_para, | |
| ctrl_idx, | |
| cell_idx, | |
| cell_para_idx, | |
| hit.char_offset, | |
| new_text, | |
| )?; |
…m#268) The existing replaceText WASM API expects positional args (sec, para, charOffset, length, newText) which causes a JS crash when users call it with search-style args (query, newText, caseSensitive) — the 5th param becomes undefined and wasm_bindgen fails reading .length on it. Add replaceOne(query, newText, caseSensitive) that mirrors replaceAll but only replaces the first match. This provides the API users expect without breaking the existing positional replaceText. - Rust: replace_one_native in search_query.rs - WASM: replaceOne binding in wasm_api.rs - TS: ReplaceOneResult type + wasm-bridge method - Tests: 5 unit tests for find_in_text (case, Korean, empty)
1. Add search_first_body() with early-exit instead of search_all() +
.first(). Avoids allocating the full hit list on large documents.
2. Restrict replaceOne to body-only matches (excludes table cells and
textboxes), consistent with search_text_native. This makes the
returned {sec, para, charOffset} unambiguous -- no cell context
needed in the response.
bb6a744 to
ce0148f
Compare
|
@oksure 님, rhwp 프로젝트 첫 기여 환영합니다! 🎉 이번 PR 은 여러 측면에서 모범적입니다 — 메인테이너 입장에서 즐겁게 검토했습니다. 잘하신 점1. 정확한 원인 분석이슈 #268 의 본질을 정확히 짚으셨습니다 — 2. 깔끔한 API 설계 — 하위 호환성 100%기존 3. 기존 패턴 일관 재사용
기존 코드베이스 학습 + 패턴 따르기를 잘 해주셨습니다. 4. 5 unit tests (한국어 포함)
한글 multi-byte 경계 케이스를 테스트에 포함하신 것이 인상적입니다. 첫 PR 부터 수준 높은 테스트 커버리지를 갖추신 점, 감사드립니다. 5. Copilot review 피드백 반영 (
|
edwardkim
left a comment
There was a problem hiding this comment.
검증 완료 (997 lib + 6/6 svg_snapshot + clippy + wasm32 + CI SUCCESS). admin merge 진행.
|
@oksure 님 — PR 머지되었습니다 (commit 첫 기여로 이런 수준 높은 PR 을 받게 되어 감사드립니다. 한국어 단위 테스트와 하위 호환성 100% 보존하는 API 설계, 그리고 Copilot 리뷰 피드백 반영까지 — 메인테이너 입장에서 검토가 즐거웠습니다. 다음 npm 빌드 시점에 앞으로도 많은 기여 부탁드립니다 🙏 |
@oksure 의 신규 기여자 첫 PR. cherry-pick + author 보존 + force-push + admin merge (commit 732de0f). 이슈 #268 (replaceText crash) 해결: - 새 API replaceOne(query, newText, caseSensitive) 추가 - 기존 replaceText 변경 0 (하위 호환성 100%) - 5 unit tests (한국어 multi-byte 경계 포함) - 997 lib tests 통과 (992 → +5 신규) 기여자 환영 + 따뜻한 감사 코멘트 게시. Co-Authored-By: Hyunwoo Park <oksure@gmail.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
환영해 주셔서 감사합니다. 저도 혼자서 나름 hwp, hwpx를 자동화하려고 애쓰고 있었는데, 좋은 프로젝트 시작해 주셔서 감사합니다. 틈 나는 대로 좀 더 의미 있는 것들 더 작업할 수 있도록 해보겠습니다. 꼭 프로젝트가 잘 되어서 파일 생성 및 수정까지 잘 되는 수준까지 갔으면 좋겠습니다. 감사합니다~ 🙏 |
라이브러리 버전 동기화 (Cargo.toml / rhwp-vscode / npm/editor / rhwp-studio): 0.7.3 → 0.7.6 브라우저 확장 (rhwp-firefox): 0.2.1 → 0.2.2 (AMO 재제출용) - manifest strict_min_version 142 + viewer 번들 보안 sanitize 반영 본 사이클 외부 기여 PR: - #268/#334 (@oksure): replaceOne API - #279/#282 (@seanshin): 목차 리더 + 페이지번호 정렬 - #324/#327 (@planet6897): form-002 인너 표 페이지 분할 - #335 (@oksure): SVG/HTML draw_image base64 임베딩 - #338/#339 (@postmelee): Firefox AMO 워닝 해결 - #340/#341 (@planet6897): typeset 경로 정합 - #342/#343 (@planet6897): Task #321~#332 통합 + 회귀 해소 rhwp-firefox/README.md 에 v0.2.2 변경 이력 + 기여자 감사 섹션 추가 (@postmelee, @seanshin 인정). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 최근 변경: v0.7.3 → v0.7.6 (2026-04-26) 교체 - PR edwardkim#266 (Task edwardkim#157), edwardkim#273 (Task edwardkim#267), edwardkim#282 (Task edwardkim#279) by @seanshin - PR edwardkim#256, edwardkim#327, edwardkim#341, edwardkim#343 by @planet6897 - PR edwardkim#334, edwardkim#335 by @oksure, PR edwardkim#339 by @postmelee - devel 섹션: 머지된 항목 제거, 현재 분석 중(edwardkim#362/edwardkim#345) + 계획 중(edwardkim#150/edwardkim#253) 반영 - 테스트 수: 891+ → 1000+ - README_EN.md 동일 내용 영문 반영
변경 요약
Fixes #268.
replaceText(find, replace, caseSensitive)호출 시 JS crash 해결.기존
replaceTextWASM API는 위치 기반 파라미터(sec, para, charOffset, length, newText)를 기대하지만, 사용자가 검색어 기반(query, newText, caseSensitive)으로 호출하면 5번째 인자가 undefined가 되어 wasm_bindgen에서.length접근 시 crash가 발생합니다.replaceAll과 동일한 시그니처의replaceOne(query, newText, caseSensitive)API를 추가하여 첫 번째 매치만 교체하는 기능을 제공합니다. 기존replaceTextAPI는 변경 없이 유지됩니다.변경 파일
src/document_core/queries/search_query.rs—replace_one_native+ 5 unit testssrc/wasm_api.rs—replaceOneWASM bindingrhwp-studio/src/core/types.ts—ReplaceOneResulttyperhwp-studio/src/core/wasm-bridge.ts—replaceOnebridge method관련 이슈
closes #268
테스트
cargo test통과 (942 tests, 0 failures)cargo clippy -- -D warnings통과