Skip to content

feat: add replaceOne API for query-based single replacement#334

Merged
edwardkim merged 2 commits into
edwardkim:develfrom
oksure:contrib/fix-replace-text-crash
Apr 26, 2026
Merged

feat: add replaceOne API for query-based single replacement#334
edwardkim merged 2 commits into
edwardkim:develfrom
oksure:contrib/fix-replace-text-crash

Conversation

@oksure

@oksure oksure commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

변경 요약

Fixes #268. replaceText(find, replace, caseSensitive) 호출 시 JS crash 해결.

기존 replaceText WASM API는 위치 기반 파라미터 (sec, para, charOffset, length, newText)를 기대하지만, 사용자가 검색어 기반 (query, newText, caseSensitive)으로 호출하면 5번째 인자가 undefined가 되어 wasm_bindgen에서 .length 접근 시 crash가 발생합니다.

replaceAll과 동일한 시그니처의 replaceOne(query, newText, caseSensitive) API를 추가하여 첫 번째 매치만 교체하는 기능을 제공합니다. 기존 replaceText API는 변경 없이 유지됩니다.

변경 파일

  • src/document_core/queries/search_query.rsreplace_one_native + 5 unit tests
  • src/wasm_api.rsreplaceOne WASM binding
  • rhwp-studio/src/core/types.tsReplaceOneResult type
  • rhwp-studio/src/core/wasm-bridge.tsreplaceOne bridge method

관련 이슈

closes #268

테스트

  • cargo test 통과 (942 tests, 0 failures)
  • cargo clippy -- -D warnings 통과
  • 관련 샘플 파일로 SVG 내보내기 확인 (해당 없음 — API 전용 변경)
  • 웹(WASM) 렌더링 확인 (WASM 빌드는 Docker 필요, 로컬 cargo test로 검증)

Copilot AI review requested due to automatic review settings April 25, 2026 13:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 replaceOne via wasm_bindgen in HwpDocument.
  • 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.

Comment on lines +214 to +247
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
))

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +205 to +209
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()),
};

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +237 to +243
} 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);

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
} 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)?;
}

Copilot uses AI. Check for mistakes.
Comment on lines +215 to +236
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);

Copilot AI Apr 25, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
)?;

Copilot uses AI. Check for mistakes.
oksure added 2 commits April 26, 2026 10:23
…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.
@edwardkim edwardkim force-pushed the contrib/fix-replace-text-crash branch from bb6a744 to ce0148f Compare April 26, 2026 01:45
@edwardkim edwardkim changed the base branch from main to devel April 26, 2026 01:45
@edwardkim

Copy link
Copy Markdown
Owner

@oksure 님, rhwp 프로젝트 첫 기여 환영합니다! 🎉

이번 PR 은 여러 측면에서 모범적입니다 — 메인테이너 입장에서 즐겁게 검토했습니다.

잘하신 점

1. 정확한 원인 분석

이슈 #268 의 본질을 정확히 짚으셨습니다 — replaceText 가 위치 기반 시그니처 (sec, para, charOffset, length, newText) 인데 사용자가 검색어 기반으로 호출 시 undefined.length crash 가 발생하는 점, wasm-bindgen 경계에서의 시그니처 미스매치 함정을 명확히 설명하신 PR 본문이 일품이었습니다.

2. 깔끔한 API 설계 — 하위 호환성 100%

기존 replaceText 를 건드리지 않고 새 API replaceOne(query, newText, caseSensitive) 를 추가하신 결정이 정공법입니다. 기존 사용자 영향 0, 새 use-case 만 깔끔하게 추가. replaceAll 과 동일 시그니처 + 첫 매치만 교체 시맨틱 — JS 친화적이고 일관된 설계.

3. 기존 패턴 일관 재사용

  • search_first_body 가 기존 search_all 의 early-exit 변형
  • delete_text_native + insert_text_native 재사용
  • JSON 응답 형식 ({"ok":true,...}) 이 기존 {"count":N} 와 한 쌍의 의미

기존 코드베이스 학습 + 패턴 따르기를 잘 해주셨습니다.

4. 5 unit tests (한국어 포함)

  • find_in_text_case_sensitive / case_insensitive
  • find_in_text_korean ("안녕하세요 세계""세계", "가나가나""가나" multiple matches)
  • find_in_text_multiple_matches
  • find_in_text_empty_inputs

한글 multi-byte 경계 케이스를 테스트에 포함하신 것이 인상적입니다. 첫 PR 부터 수준 높은 테스트 커버리지를 갖추신 점, 감사드립니다.

5. Copilot review 피드백 반영 (bb6a744)

첫 push 후 Copilot 리뷰 의견을 받아 코드 품질을 한 번 더 다듬으신 점도 좋았습니다.

메인테이너 처리

본 PR 의 base 가 main 으로 잡혀 있던 점은 사실 우리 측 안내 부족 입니다. README 에 base=devel 안내가 없었고, GitHub default branch 가 main 이라 새 기여자가 자연스럽게 main 으로 PR 한 흐름입니다. 직전 PR 처리 (#330 close 후) 에서 README 에 base=devel 안내를 추가했으나 본 PR 작성 시점에는 아직 반영 전이었습니다.

처리 절차 (PR #282/#327/#339 와 동일 패턴):

  1. local/task268 브랜치 (origin/devel 기준) 에 본인 핵심 2 커밋 cherry-pick (author=Hyunwoo Park 보존)
  2. ✅ 빌드/테스트 검증:
    • cargo build --release: 25.83s
    • cargo test --lib: 997 passed (기존 992 → +5 신규 테스트)
    • cargo test --lib search_query: 5 passed (본인 테스트 모두 통과)
    • cargo test --test svg_snapshot: 6/6 passed
    • cargo test --test issue_301: z-table 가드 통과
    • cargo clippy --lib -D warnings: clean
    • cargo check --target wasm32: clean
  3. ✅ 작성자 fork (oksure/rhwp) 의 contrib/fix-replace-text-crash 에 force-push (maintainerCanModify=true)
  4. ✅ PR base 를 maindevel 로 변경
  5. CI 통과 후 admin merge 진행

다음에 도움이 될 안내 (정중하게)

다음 PR 부터 조금 편하게 작업하실 수 있도록:

  • base 는 devel (main 아님). default branch 는 main 이지만 모든 기여 PR 은 devel 로 받습니다. 우리 READMECONTRIBUTING.md 에 명시되어 있습니다.
  • 이슈 → PR: 이슈 close 는 PR 머지 시 메인테이너가 합니다. PR description 의 closes #N 은 자동 close 트리거 — 이미 정확하게 사용해 주셨습니다 ✅

마무리

첫 PR 부터 깔끔한 분석 + 깔끔한 설계 + 한국어 단위 테스트까지, 감사하다는 말씀을 직접 드리고 싶습니다. CI 통과 즉시 admin merge 진행하겠습니다. 앞으로도 많은 기여 부탁드립니다 🙏

— rhwp 메인테이너

@edwardkim edwardkim left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검증 완료 (997 lib + 6/6 svg_snapshot + clippy + wasm32 + CI SUCCESS). admin merge 진행.

@edwardkim edwardkim merged commit 732de0f into edwardkim:devel Apr 26, 2026
@edwardkim

Copy link
Copy Markdown
Owner

@oksure 님 — PR 머지되었습니다 (commit 732de0f). 🎉

첫 기여로 이런 수준 높은 PR 을 받게 되어 감사드립니다. 한국어 단위 테스트와 하위 호환성 100% 보존하는 API 설계, 그리고 Copilot 리뷰 피드백 반영까지 — 메인테이너 입장에서 검토가 즐거웠습니다.

다음 npm 빌드 시점에 replaceOne API 가 @rhwp/core 에 포함됩니다.

앞으로도 많은 기여 부탁드립니다 🙏

edwardkim added a commit that referenced this pull request Apr 26, 2026
@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>
@oksure

oksure commented Apr 26, 2026

Copy link
Copy Markdown
Contributor Author

환영해 주셔서 감사합니다. 저도 혼자서 나름 hwp, hwpx를 자동화하려고 애쓰고 있었는데, 좋은 프로젝트 시작해 주셔서 감사합니다. 틈 나는 대로 좀 더 의미 있는 것들 더 작업할 수 있도록 해보겠습니다. 꼭 프로젝트가 잘 되어서 파일 생성 및 수정까지 잘 되는 수준까지 갔으면 좋겠습니다. 감사합니다~ 🙏

edwardkim added a commit that referenced this pull request Apr 26, 2026
라이브러리 버전 동기화 (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>
seanshin pushed a commit to seanshin/rhwp that referenced this pull request Apr 26, 2026
- 최근 변경: 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 동일 내용 영문 반영
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] doc.replaceText(find, replace, caseSensitive) crashes with 'Cannot read properties of undefined (reading length)' while replaceAll works

3 participants