diff --git a/tools/est-test/PLAN.md b/tools/est-test/PLAN.md
index d8e7085c5..57d79da07 100644
--- a/tools/est-test/PLAN.md
+++ b/tools/est-test/PLAN.md
@@ -487,6 +487,8 @@ ative-standard.js에 표준시장단가 원천(SilJukDat)과 내역 행(EstMainD
- 남음: 구성비·요율 전환(CalcBase), 약어 표시(SName), 제비율 기준 문서 보기(원본 한글 파일).
- [x] 수량산출서 검색·전체보기(원본 TFRMMULLANG btnSerch·btnAllDisplay — 빈칸·|·괄호 무시 포함 검색) — 고르기 줄을 `native-quantity-sheets.js` 로 옮겨 700줄 제한 유지.
- 검증완료: `test_est_quantity_sheets.py` 검색 시험 포함 수량 시험 22개 통과. ORCA 브라우저에서 「집수」 검색 → 항목에 집수정이 있는 「배수」만·선택 이동, 전체보기 → 3개 복귀, 샘플 복구.
+ - [x] 셀복사·셀붙여넣기(원본 ActionCellCopy·ActionCellPaste) — 마지막으로 누른 칸의 열을 고른 행들(없으면 그 행)만큼 복사(시스템 클립보드에도 글자로), 다른 칸을 누르고 붙이면 그 행부터 아래로 차례로 넣음(열 달라도 됨, 수치 칸은 수치만, 코드·결과비목 칸 거부).
+ - 검증완료: 바인딩 시험 통과. ORCA 브라우저에서 자재 품명 2칸 복사 → 6번째 행 규격부터 붙여넣기(경유·휘발유), 코드 칸 거부, 샘플 복구.
- [x] 산출 줄 잘라내기·복사·붙여넣기(원본 TFRMMULWORD popCut·popCopy·popPaste) — 산출 줄마다 복사·잘라내기·아래 붙여넣기. 붙일 때 수량 지정 표지를 떼고 글만(같은 항목이 두 번 잡히지 않게), 수량 지정된 줄은 잘라내기 거부.
- 검증완료: 산출서·수량 흐름·바인딩 시험 22개 통과. ORCA 브라우저에서 표지 있는 줄 복사 → 아래 붙여넣기(표지 없음), 표지 줄 잘라내기 거부, 샘플 복구.
- [x] 수량산출서 엑셀형식 전환(원본 TFRMMULLANG popSetMode — DataKind 로 편집기 교체) — 산출서 화면 「엑셀형식으로」: 엑셀 시트가 없으면 빈 시트를 만들어 엑셀형으로, 「일반형식으로」는 엑셀 내용을 보관한 채 끄고 셀 연결 수량을 쓰지 않음. 바꾼 산출서는 ESTX 저장 거부(JSON).
diff --git a/tools/est-test/index.html b/tools/est-test/index.html
index 3cb7a0323..c80e18952 100644
--- a/tools/est-test/index.html
+++ b/tools/est-test/index.html
@@ -59,6 +59,7 @@
+
@@ -135,7 +136,9 @@
>
+ >
diff --git a/tools/est-test/native-business-actions.js b/tools/est-test/native-business-actions.js
index 2edcd466c..3b3a696b1 100644
--- a/tools/est-test/native-business-actions.js
+++ b/tools/est-test/native-business-actions.js
@@ -630,7 +630,8 @@ const EstBusinessActions = (() => {
(typeof EstDocEditor !== "undefined" && EstDocEditor.handle(name, state, node)) ||
(typeof EstDocForms !== "undefined" && EstDocForms.handle(name, state, node)) ||
(typeof EstBlockFile !== "undefined" && EstBlockFile.handle(name, state)) ||
- (typeof EstDisplay !== "undefined" && EstDisplay.handle(name, state))
+ (typeof EstDisplay !== "undefined" && EstDisplay.handle(name, state)) ||
+ (typeof EstCellClip !== "undefined" && EstCellClip.handle(name, state))
) {
EstApp.render();
} else if (name === "insert-annual") {
diff --git a/tools/est-test/native-cell-clip.js b/tools/est-test/native-cell-clip.js
new file mode 100644
index 000000000..cfa17ce0d
--- /dev/null
+++ b/tools/est-test/native-cell-clip.js
@@ -0,0 +1,49 @@
+// 원본 셀복사·셀붙여넣기(TfrmEstMain ActionCellCopy·ActionCellPaste) — 마지막으로 누른 칸의 열을, 고른 행들(없으면 그 행)만큼 복사하고
+// 다른 칸을 누른 뒤 그 행부터 아래로 차례로 붙여넣는다(열이 달라도 됨). 코드·결과비목 칸은 연결 처리가 따로 있어 붙여넣기 거부.
+const EstCellClip = (() => {
+ let last = null;
+ function copy(state) {
+ if (!last) throw Error("복사할 칸을 먼저 누르세요.");
+ const rows = state.project.tables[last.table] || [],
+ picked = [...state.selection].filter((i) => rows[i]).sort((a, b) => a - b),
+ list = picked.includes(last.row) ? picked : [last.row];
+ state.cellClip = { field: last.field, values: list.map((i) => rows[i][last.field] ?? null) };
+ const text = state.cellClip.values
+ .map((v) => (last.field === "LineData" ? EstText.display(v ?? "") : (v ?? "")))
+ .join("\n");
+ navigator.clipboard?.writeText(text).catch(() => {});
+ state.notice = `${list.length}칸을 복사했습니다.`;
+ }
+ function paste(state) {
+ if (!last) throw Error("붙여넣을 칸을 먼저 누르세요.");
+ if (!state.cellClip?.values.length) throw Error("복사한 칸이 없습니다.");
+ if (["Code", "SCheck"].includes(last.field))
+ throw Error("코드·결과비목 칸은 셀붙여넣기를 쓸 수 없습니다. 칸에서 직접 고치세요.");
+ const rows = state.project.tables[last.table] || [],
+ numeric = EstModel.numeric.has(last.field);
+ const values = state.cellClip.values.map((v) => {
+ if (!numeric) return v == null ? "" : String(v);
+ const n = v === "" || v == null ? null : Number(v);
+ if (n !== null && !Number.isFinite(n))
+ throw Error("수치 칸에는 수치만 붙여넣을 수 있습니다.");
+ return n;
+ });
+ if (last.row + values.length > rows.length) throw Error("붙여넣을 행이 모자랍니다.");
+ EstApp.change((project) => {
+ const table = project.tables[last.table];
+ values.forEach((v, i) => (table[last.row + i][last.field] = v));
+ });
+ }
+ if (typeof document !== "undefined" && document.addEventListener)
+ document.addEventListener("focusin", (event) => {
+ const t = event.target.closest?.("[data-table][data-field][data-row]");
+ if (t) last = { table: t.dataset.table, row: Number(t.dataset.row), field: t.dataset.field };
+ });
+ function handle(name, state) {
+ if (name === "cell-copy") copy(state);
+ else if (name === "cell-paste") paste(state);
+ else return false;
+ return true;
+ }
+ return { handle };
+})();
diff --git a/tools/est-test/server.cjs b/tools/est-test/server.cjs
index ab5fb1db0..62924800f 100644
--- a/tools/est-test/server.cjs
+++ b/tools/est-test/server.cjs
@@ -80,6 +80,7 @@ const allowed = new Set([
"native-doc-forms.js",
"native-block-file.js",
"native-display.js",
+ "native-cell-clip.js",
"native-wonga-tools.js",
"native-estimate-print.js",
"native-xml-check.js",