Skip to content

Commit 136b21a

Browse files
chrisryugjclaude
andcommitted
fix(preview): 검색 결과 표 태그 노출 근본 수정 + v3.0.2
kordoc 이 colspan/rowspan 병합셀 표를 HTML <table> 로 반환해 인덱싱 텍스트에 td/tr/colspan 태그가 박히고, 검색 결과 카드 스니펫에 그대로 노출되던 것 수정. - 백엔드(근본): kordoc parse() 의 content/chunks 를 인덱싱 전 HTML 표→plain text (행=줄바꿈·셀=공백) 로 정규화. FTS5 인덱스·snippet·content_preview 정화. 미리보기 패널은 get_markdown 원본 경로라 GFM 표 렌더 그대로 유지. - 프론트(방어): stripHtmlTags 강화 — 토큰화로 < > = " 구분자가 날아간 td colspan4tdtr 잔재까지 정리. 공백 깨진 태그는 표 토큰으로만 좁게 겨냥해 본문 부등호/꺾쇠(a < b, template<T>) 오삭제 방지. - kordoc HTML표 변환 유닛 테스트 8종 추가 (헤더셀·인라인태그·복수표·중첩표·빈셀 등). - 버전 3.0.1 → 3.0.2, CHANGELOG/README 배지 갱신. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e7d4a87 commit 136b21a

8 files changed

Lines changed: 176 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## [3.0.2] - 2026-06-19
4+
5+
**검색 결과 미리보기 표 태그 노출 근본 수정 (v3.0.0 후속)**
6+
7+
### 🐛 수정
8+
9+
- **검색 결과 카드에 표 태그(`td`/`tr`/`colspan`) 노출** — kordoc은 colspan/rowspan 병합셀 표를 GFM으로 표현하지 못해 HTML `<table>`로 반환하는데, 이 태그가 인덱싱 텍스트에 그대로 들어가 검색 결과 스니펫에 노출되던 것을 근본 수정. (v3.0.0의 미리보기 패널 방어는 별도 경로였고, 이번엔 검색 결과 카드/인덱스 경로를 잡는다.)
10+
- **백엔드(근본)** — kordoc 파싱 결과를 인덱스/청크에 넣기 전 HTML 표를 plain text(행=줄바꿈·셀=공백)로 직렬화. FTS5 인덱스·`snippet()`·`content_preview`가 깨끗해진다. 미리보기 패널은 별도 경로(`get_markdown` 원본)를 써 GFM 표 렌더가 그대로 유지된다.
11+
- **프론트(방어)**`stripHtmlTags`를 강화해, FTS5 토큰화 과정에서 `< > = "` 구분자가 떨어져 나가 `td colspan4tdtr`처럼 깨진 잔재까지 정리. 공백이 끼어 깨진 태그는 표 전용 토큰(td/tr/th/colspan/rowspan)으로만 좁게 겨냥해 본문의 `a < b`·`template<T>` 같은 부등호/꺾쇠 오삭제를 방지한다.
12+
13+
> **업그레이드 노트**: 백엔드 정규화는 신규·재인덱싱 문서에 적용됩니다. 이미 인덱싱된 문서의 태그 잔재는 프론트 방어로 가려지며, 해당 폴더를 재인덱싱하면 인덱스 자체가 깨끗해집니다.
14+
15+
### 🧹 내부
16+
17+
- kordoc 파서에 HTML 표→plain text 변환 유닛 테스트 8종 추가(헤더셀·인라인태그·복수표·중첩표·빈셀·잔여태그·noop·기본). 백엔드 테스트 전체 통과, clippy 무경고.
18+
319
## [3.0.1] - 2026-06-19
420

521
**`.eml` 메일 파일 검색 지원 (이슈 #29)**

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
</p>
1515

1616
<p align="center">
17-
<a href="https://github.com/chrisryugj/Docufinder/releases"><img src="https://img.shields.io/badge/version-3.0.0-blue.svg" alt="Version" /></a>
17+
<a href="https://github.com/chrisryugj/Docufinder/releases"><img src="https://img.shields.io/badge/version-3.0.2-blue.svg" alt="Version" /></a>
1818
<a href="https://tauri.app"><img src="https://img.shields.io/badge/Tauri-2.10-24C8D8.svg" alt="Tauri 2" /></a>
1919
<a href="LICENSE"><img src="https://img.shields.io/badge/License-BSL%201.1-blue.svg" alt="License" /></a>
2020
</p>

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "anything",
3-
"version": "3.0.1",
3+
"version": "3.0.2",
44
"description": "로컬 문서 검색 앱 - Everything 대항마",
55
"type": "module",
66
"scripts": {

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "docufinder"
3-
version = "3.0.1"
3+
version = "3.0.2"
44
description = "로컬 문서 검색 앱 - HWPX, Office, PDF 지원"
55
authors = ["Chris"]
66
edition = "2021"

src-tauri/src/parsers/kordoc.rs

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,15 @@ pub fn parse_with_options(path: &Path, opts: KordocOptions) -> Result<ParsedDocu
216216
page_count: meta.page_count,
217217
};
218218

219-
let chunks = chunk_text(&markdown, DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP);
219+
// kordoc 은 colspan/rowspan 병합셀 표를 GFM 으로 표현하지 못해 HTML <table> 로 반환한다.
220+
// 그대로 인덱싱하면 검색 결과 스니펫에 td/tr/colspan 태그가 노출되므로(FTS5 토큰화 과정에서
221+
// 깨진 잔재까지 섞여 표시 레이어 정규식만으로는 못 막음) 검색용 content/chunks 에선 표를
222+
// plain text 로 직렬화한다. 미리보기 패널은 get_markdown 원본을 써 GFM 렌더가 유지된다.
223+
let content = html_tables_to_text(&markdown);
224+
let chunks = chunk_text(&content, DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP);
220225

221226
Ok(ParsedDocument {
222-
content: markdown,
227+
content,
223228
metadata,
224229
chunks,
225230
})
@@ -554,3 +559,128 @@ fn parse_iso_timestamp(s: &str) -> Option<i64> {
554559
}
555560
None
556561
}
562+
563+
/// kordoc 마크다운의 HTML 표(`<table>…</table>`)를 검색 인덱스용 plain text 로 변환한다.
564+
///
565+
/// 행은 줄바꿈, 셀은 공백으로 직렬화하고 변환되지 못한(중첩 표 등) 잔여 표/줄바꿈 태그를
566+
/// 제거한다. 표가 없으면 입력을 그대로 돌려준다(정규식 매칭 0 → 사실상 무비용).
567+
/// 미리보기 패널은 `get_markdown` 원본(HTML 표 유지)을 쓰므로 GFM 렌더에는 영향이 없다.
568+
fn html_tables_to_text(md: &str) -> String {
569+
use std::sync::OnceLock;
570+
static TABLE_RE: OnceLock<regex::Regex> = OnceLock::new();
571+
static ROW_RE: OnceLock<regex::Regex> = OnceLock::new();
572+
static CELL_RE: OnceLock<regex::Regex> = OnceLock::new();
573+
static INNER_TAG_RE: OnceLock<regex::Regex> = OnceLock::new();
574+
static LEFTOVER_RE: OnceLock<regex::Regex> = OnceLock::new();
575+
576+
let table_re =
577+
TABLE_RE.get_or_init(|| regex::Regex::new(r"(?is)<table[^>]*>.*?</table>").unwrap());
578+
let row_re = ROW_RE.get_or_init(|| regex::Regex::new(r"(?is)<tr[^>]*>(.*?)</tr>").unwrap());
579+
let cell_re =
580+
CELL_RE.get_or_init(|| regex::Regex::new(r"(?is)<t[dh][^>]*>(.*?)</t[dh]>").unwrap());
581+
let inner_tag_re = INNER_TAG_RE.get_or_init(|| regex::Regex::new(r"(?is)<[^>]+>").unwrap());
582+
let leftover_re = LEFTOVER_RE.get_or_init(|| {
583+
regex::Regex::new(r"(?is)</?(?:table|thead|tbody|tfoot|tr|td|th|col|colgroup|br)[^>]*>")
584+
.unwrap()
585+
});
586+
587+
let replaced = table_re.replace_all(md, |caps: &regex::Captures| {
588+
let table = &caps[0];
589+
let mut rows: Vec<String> = Vec::new();
590+
for row in row_re.captures_iter(table) {
591+
let mut cells: Vec<String> = Vec::new();
592+
for cell in cell_re.captures_iter(&row[1]) {
593+
let stripped = inner_tag_re.replace_all(&cell[1], " ");
594+
let cell_text = stripped.split_whitespace().collect::<Vec<_>>().join(" ");
595+
if !cell_text.is_empty() {
596+
cells.push(cell_text);
597+
}
598+
}
599+
if !cells.is_empty() {
600+
rows.push(cells.join(" "));
601+
}
602+
}
603+
if rows.is_empty() {
604+
String::new()
605+
} else {
606+
format!("\n{}\n", rows.join("\n"))
607+
}
608+
});
609+
610+
// 중첩 표 등으로 위 변환을 빠져나간 잔여 표/줄바꿈 태그 제거 (정규식은 중첩을 셀 수 없음).
611+
leftover_re.replace_all(&replaced, " ").into_owned()
612+
}
613+
614+
#[cfg(test)]
615+
mod tests {
616+
use super::*;
617+
618+
#[test]
619+
fn html_table_serialized_to_plain_text() {
620+
let md = "앞\n<table><tr><td>A</td><td colspan=\"2\">B</td></tr>\
621+
<tr><td>C</td><td>D</td></tr></table>\n뒤";
622+
let out = html_tables_to_text(md);
623+
assert!(out.contains("A B"), "행1 셀이 공백 결합돼야: {out:?}");
624+
assert!(out.contains("C D"), "행2 셀이 공백 결합돼야: {out:?}");
625+
assert!(!out.contains("<td"), "td 태그 잔존: {out:?}");
626+
assert!(!out.contains("colspan"), "colspan 잔존: {out:?}");
627+
assert!(out.contains("앞") && out.contains("뒤"), "표 바깥 본문 보존: {out:?}");
628+
}
629+
630+
#[test]
631+
fn no_table_is_noop() {
632+
let md = "# 제목\n본문 텍스트 — GFM 표 아님 | 열1 | 열2";
633+
assert_eq!(html_tables_to_text(md), md);
634+
}
635+
636+
#[test]
637+
fn leftover_table_tags_removed() {
638+
// 닫는 </table> 가 없어 표 단위 변환에 실패해도 잔여 태그는 정리된다.
639+
let md = "<tr><td>x</td><td>y</td>";
640+
let out = html_tables_to_text(md);
641+
assert!(!out.contains("<td") && !out.contains("<tr"), "잔여 태그 정리 실패: {out:?}");
642+
assert!(out.contains('x') && out.contains('y'), "셀 텍스트 보존: {out:?}");
643+
}
644+
645+
#[test]
646+
fn th_header_and_inline_tags() {
647+
// <th> 헤더 셀 + 셀 내부 인라인 태그(<b>)도 텍스트만 남는다.
648+
let md = "<table><tr><th>구분</th><th>값</th></tr>\
649+
<tr><td><b>합계</b></td><td>100</td></tr></table>";
650+
let out = html_tables_to_text(md);
651+
assert!(out.contains("구분 값"), "헤더 행: {out:?}");
652+
assert!(out.contains("합계 100"), "본문 행 + 인라인 태그 제거: {out:?}");
653+
assert!(!out.contains('<'), "꺾쇠 잔존: {out:?}");
654+
}
655+
656+
#[test]
657+
fn multiple_tables_with_br_and_between_text() {
658+
let md = "<table><tr><td>a</td></tr></table>중간<table><tr><td>b<br>c</td></tr></table>";
659+
let out = html_tables_to_text(md);
660+
assert!(
661+
out.contains('a') && out.contains('b') && out.contains('c') && out.contains("중간"),
662+
"내용 보존: {out:?}"
663+
);
664+
assert!(!out.contains("<br") && !out.contains("<td"), "태그 잔존: {out:?}");
665+
}
666+
667+
#[test]
668+
fn nested_table_leaves_no_tags() {
669+
// 셀 안 중첩 표는 non-greedy 매칭이 안쪽 </table> 에서 멈춰 바깥 잔여가 생기지만,
670+
// leftover 정리로 태그가 본문에 노출되지 않는다 (스니펫 노출 방어가 목적).
671+
let md = "<table><tr><td><table><tr><td>안</td></tr></table></td></tr></table>";
672+
let out = html_tables_to_text(md);
673+
assert!(out.contains('안'), "내부 셀 텍스트 보존: {out:?}");
674+
assert!(
675+
!out.contains("<table") && !out.contains("<td") && !out.contains("</"),
676+
"태그 잔존: {out:?}"
677+
);
678+
}
679+
680+
#[test]
681+
fn empty_cells_skipped() {
682+
let md = "<table><tr><td></td><td>값</td><td> </td></tr></table>";
683+
let out = html_tables_to_text(md);
684+
assert_eq!(out.trim(), "값", "빈 셀은 스킵: {out:?}");
685+
}
686+
}

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "Anything",
4-
"version": "3.0.1",
4+
"version": "3.0.2",
55
"identifier": "com.anything.app",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

src/utils/searchTextUtils.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,31 @@ const DEFAULT_CONTEXT_AFTER = 200;
1212

1313
// ── HTML 태그 처리 유틸 ──────────────────────────────
1414

15-
/** HTML 태그 제거 (텍스트 콘텐츠만 유지, [[HL]] 마커 보존) */
15+
/**
16+
* HTML 태그 제거 (텍스트 콘텐츠만 유지, [[HL]] 마커 보존).
17+
*
18+
* kordoc 이 병합셀 표를 HTML `<table>` 로 내보내 인덱스에 태그가 박힌 기존 문서 방어용.
19+
* 신규/재인덱싱 문서는 백엔드(kordoc parse)에서 이미 plain text 로 정규화되지만,
20+
* FTS5 토큰화 과정에서 `< > = "` 구분자가 떨어져 나가 `td colspan4tdtr` 처럼 깨진 잔재는
21+
* 표시 시점에 한 번 더 정리한다. 표 전용 토큰(td/tr/th/colspan/rowspan)만 좁게 겨냥해
22+
* 일반 본문 오삭제를 피한다.
23+
*/
1624
export function stripHtmlTags(text: string): string {
1725
return text
18-
.replace(/<\/?[a-zA-Z][^>]*>/g, " ") // Full tags: <td>, </tr>, <th colspan="4">
19-
.replace(/\w+="[^"]*"(?:\s+\w+="[^"]*")*\s*>/g, "") // Partial attrs: colspan="4">
20-
.replace(/\b(?:t[dhr]|table|thead|tbody|tfoot|\/t[dhr]|\/table|\/thead|\/tbody)>/gi, "") // Bare tag names: td>, /tr>
21-
.replace(/\b(?:rowspan|colspan)="[^"]*"/gi, "") // Stray attributes: rowspan="3"
26+
// 표준 HTML 태그 (꺾쇠 바로 뒤 영문): <td>, </tr>, <th colspan="4">
27+
.replace(/<\/?[a-zA-Z][^<>]*>/g, " ")
28+
// 공백이 끼어 깨진 태그는 '표' 태그명으로 한정 — 본문의 < 영문 > (a < b, template<T>) 오삭제 방지
29+
.replace(/<\s*\/?\s*(?:t[dhr]|table|thead|tbody|tfoot|col(?:group|span)?|rowspan)\b[^<>]*>/gi, " ")
30+
// colspan/rowspan 속성 잔재 (구분자 날아가 숫자가 붙은 colspan4 형태까지 — 끝 \b 없음)
31+
.replace(/\b(?:col|row)span\s*=?\s*"?\d*"?/gi, " ")
32+
// 토큰화로 구분자가 사라져 붙어버린 표 셀/행 태그 시퀀스: tdtr, trtd, tdtd (2개 이상만)
33+
.replace(/(?:t[dhr]){2,}/gi, " ")
34+
// 잔여 닫는/여는 표 태그명: td>, /tr>, table>
35+
.replace(/\b(?:t[dhr]|table|thead|tbody|tfoot|colgroup)\b\s*>/gi, " ")
36+
// 정규식이 못 잡은 깨진 닫는 꺾쇠(</ 뒤가 한글 등 비영문)
37+
.replace(/<\s*\/\s*(?![a-zA-Z])/g, " ")
38+
// 따옴표 동반 깨진 꺾쇠 잔재 ("> =" 등) — 단독 부등호는 보존
39+
.replace(/["']\s*[<>=/]+|[<>=/]+\s*["']/g, " ")
2240
.replace(/\s{2,}/g, " ");
2341
}
2442

0 commit comments

Comments
 (0)