Skip to content

Commit fc2f9ee

Browse files
chrisryugjclaude
andcommitted
release: v3.8.1 — HWP/HWPX 검색 결과 실제 페이지 번호 (kordoc v4.7.3)
kordoc v4.7.3(실제 페이지 경계 복원, kordoc#66) 반영. 블록 IR 의 쪽 번호를 검색 청크에 사영해 kordoc 경로(HWP·HWPX·PDF) 결과에 페이지 배지·"N페이지로 열기"·위치 힌트가 동작한다. content 는 불변이라 인덱스 드리프트 없음 — 페이지 힌트는 재인덱싱분부터. - kordoc.rs: KordocResponse.blocks/metadata.pageMode 역직렬화, page_breakpoints(마크다운 안전 조각 커서 검색) + annotate_chunk_pages. pageMode="layout"일 때만, 매칭 실패 블록은 스킵(오배정 없음) - IRTable 계약: rows/cols 는 정수(개수), 셀은 cells — 실 JSON 대조로 확정, 계약 테스트 고정 (배열 가정 시 역직렬화 전체 사망 함정) - publish.yml kordoc SHA 핀 4b65e57(v4.7.1) → b7b2254(v4.7.3) 두 job - 검증: cargo 274 pass·clippy·fmt, 실 hwpx 9쪽 E2E(pageCount·청크 페이지 배정), v4.7.3 마크다운 출력은 v4.7.1 동일(NED=0) 확인 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016rS6qv2ZsPe2Yhce57wdye
1 parent abaf352 commit fc2f9ee

7 files changed

Lines changed: 286 additions & 8 deletions

File tree

.github/workflows/publish.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,13 @@ jobs:
4848
# 리포가 private이면 token: ${{ secrets.KORDOC_PAT }} 추가 필요
4949
#
5050
# SHA 고정으로 공급망 공격 방지. 새 kordoc 버전 릴리스 시 이 SHA를 갱신.
51-
# 현재: v4.7.1 (밑줄 <u> 보존 PDF·HWPX·HWP5, PDF 링크 어노테이션, 2단 지면 읽기순, BOM XML) — 2026-08-06
51+
# 현재: v4.7.3 (HWP/HWPX 실제 페이지 경계 복원 #66 — pageMode·실페이지 pageNumber) — 2026-08-13
5252
- name: Checkout kordoc
5353
uses: actions/checkout@v4
5454
with:
5555
repository: chrisryugj/kordoc
5656
path: kordoc-src
57-
ref: 4b65e571f3f3f9fe6b5d8a8b0958d1b5f0b532a3
57+
ref: b7b2254e27468f157ad02bb2ec6c8262f8a71378
5858

5959
- name: Build kordoc dist
6060
working-directory: kordoc-src
@@ -282,7 +282,7 @@ jobs:
282282
with:
283283
repository: chrisryugj/kordoc
284284
path: kordoc-src
285-
ref: 4b65e571f3f3f9fe6b5d8a8b0958d1b5f0b532a3
285+
ref: b7b2254e27468f157ad02bb2ec6c8262f8a71378
286286

287287
- name: Build kordoc dist
288288
working-directory: kordoc-src

CHANGELOG.md

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

3+
## [3.8.1] - 2026-08-13
4+
5+
HWP/HWPX 검색 결과에 **실제 페이지 번호**가 붙습니다 — kordoc v4.7.3(실제 페이지 경계 복원, kordoc#66) 반영.
6+
7+
### ✨ 추가
8+
9+
- **HWP·HWPX 검색 결과 페이지 표시/이동**: 지금까지 kordoc 경로로 인덱싱된 문서(HWP·HWPX·PDF·DOCX)는
10+
청크에 페이지 정보가 없어 결과 배지(`Np`)·"N페이지로 열기"·미리보기 페이지 점프가 동작하지
11+
않았습니다. kordoc v4.7.3 이 한컴 저장본의 조판 캐시로 실제 쪽 번호를 복원하면서
12+
(`metadata.pageMode: "layout"`), 블록 IR 의 쪽 번호를 검색 청크에 사영해 위치 힌트("페이지 N")를
13+
답니다. PDF 도 kordoc 경로에서 같은 방식으로 페이지가 붙습니다.
14+
- 검색 인덱스 본문(content)은 그대로라 기존 인덱스와 호환됩니다 — 페이지 힌트는
15+
**재인덱싱된 문서부터** 반영됩니다.
16+
- 조판 캐시가 없는 프로그램 생성 파일(`pageMode: "section"`)과 구버전 kordoc 응답은
17+
종전대로 페이지 없이 동작합니다 (오배정 없음 — 매칭 실패 블록은 건너뜀).
18+
19+
### 📦 의존성
20+
21+
- kordoc 사이드카 핀 **v4.7.1 → v4.7.3** (`b7b2254`, publish.yml 두 job 동시 갱신):
22+
HWP/HWPX 실제 페이지 경계 복원(#66 — vertpos 역행·명시 쪽나눔·분할 표 셀 흐름 리셋·이중
23+
카운트 억제 4신호, 실코퍼스 hwp↔hwpx↔pdf 대조 전수 일치). v4.7.2 폐쇄망 대응 포함.
24+
마크다운 출력은 v4.7.1 과 동일(kordoc 게이트 NED=0)이라 검색 인덱스 드리프트 없음.
25+
26+
### ✅ 검증
27+
28+
kordoc 실 JSON 계약 확인 — IRTable 의 `rows`/`cols` 는 개수(정수)이고 셀 데이터는 `cells`
29+
(배열 가정 시 역직렬화 전체가 죽는 함정을 실출력 대조로 차단, 계약 테스트로 고정).
30+
실 hwpx(9쪽 채용공고) E2E 로 pageCount=9·전 청크 페이지 부여·후반 청크 후반 페이지 배정 확인.
31+
332
## [3.8.0] - 2026-08-06
433

534
사내 보안솔루션(DRM) 환경용 MS Office 인덱싱 fallback + kordoc v4.7.1 반영.

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.8.0",
3+
"version": "3.8.1",
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.8.0"
3+
version = "3.8.1"
44
description = "로컬 문서 검색 앱 - HWPX, Office, PDF 지원"
55
authors = ["Chris"]
66
edition = "2021"

src-tauri/src/parsers/kordoc.rs

Lines changed: 250 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,33 @@ struct KordocResponse {
117117
/// PDF 전용 — 페이지별 품질 신호 (kordoc v2.9+, ocrReason 은 v4.2 갱신)
118118
#[serde(default)]
119119
page_quality: Vec<KordocPageQuality>,
120+
/// 블록 IR — 청크 페이지 매핑에 사용 (pageNumber 는 kordoc v4.7.3+ 에서
121+
/// 한컴 저장본 실제 쪽 번호, PDF 는 원래 실제 페이지). 구버전 응답엔 없어도 무방.
122+
#[serde(default)]
123+
blocks: Vec<KordocBlock>,
124+
}
125+
126+
/// kordoc IRBlock — 페이지 매핑에 필요한 필드만 역직렬화
127+
#[derive(Deserialize)]
128+
#[serde(rename_all = "camelCase")]
129+
struct KordocBlock {
130+
#[serde(rename = "type")]
131+
block_type: String,
132+
text: Option<String>,
133+
page_number: Option<usize>,
134+
table: Option<KordocTable>,
135+
}
136+
137+
/// kordoc IRTable — `rows`/`cols` 는 **개수(정수)** 이고 셀 데이터는 `cells` 다 (실 JSON 확인).
138+
#[derive(Deserialize)]
139+
struct KordocTable {
140+
#[serde(default)]
141+
cells: Vec<Vec<KordocCell>>,
142+
}
143+
144+
#[derive(Deserialize)]
145+
struct KordocCell {
146+
text: Option<String>,
120147
}
121148

122149
/// kordoc 페이지 품질 신호 (필요 필드만 역직렬화)
@@ -139,6 +166,9 @@ struct KordocMetadata {
139166
author: Option<String>,
140167
created_at: Option<String>,
141168
page_count: Option<usize>,
169+
/// "layout"(실제 페이지 경계) | "section"(섹션 근사) — kordoc v4.7.3+ (#66).
170+
/// 구버전 응답엔 없음 → 페이지 매핑 스킵.
171+
page_mode: Option<String>,
142172
}
143173

144174
#[derive(Deserialize)]
@@ -299,6 +329,7 @@ pub fn parse_with_options(path: &Path, opts: KordocOptions) -> Result<ParsedDocu
299329
author: None,
300330
created_at: None,
301331
page_count: None,
332+
page_mode: None,
302333
});
303334

304335
let metadata = DocumentMetadata {
@@ -313,7 +344,14 @@ pub fn parse_with_options(path: &Path, opts: KordocOptions) -> Result<ParsedDocu
313344
// 깨진 잔재까지 섞여 표시 레이어 정규식만으로는 못 막음) 검색용 content/chunks 에선 표를
314345
// plain text 로 직렬화한다. 미리보기 패널은 get_markdown 원본을 써 GFM 렌더가 유지된다.
315346
let content = html_tables_to_text(&markdown);
316-
let chunks = chunk_text(&content, DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP);
347+
let mut chunks = chunk_text(&content, DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP);
348+
349+
// 실제 페이지 매핑 (#66, kordoc v4.7.3+) — pageMode="layout"(한컴 저장본·PDF·COM)일 때만
350+
// 블록 IR 의 쪽 번호를 content 오프셋에 사영해 청크에 "페이지 N" 힌트를 단다.
351+
// 섹션 근사(section)·구버전 응답은 종전대로 페이지 없음 (content/인덱스는 불변).
352+
if meta.page_mode.as_deref() == Some("layout") && !resp.blocks.is_empty() {
353+
annotate_chunk_pages(&mut chunks, &content, &resp.blocks);
354+
}
317355

318356
Ok(ParsedDocument {
319357
content,
@@ -323,6 +361,106 @@ pub fn parse_with_options(path: &Path, opts: KordocOptions) -> Result<ParsedDocu
323361
})
324362
}
325363

364+
// ─── 청크 페이지 매핑 (#66) ──────────────────────────
365+
366+
/// 블록 원문에서 마크다운 변환(escapeGfm `\*`·헤딩 `###`·HTML 표 직렬화)의 영향을
367+
/// 받지 않는 선두 조각을 뽑는다 — content 커서 검색용 needle.
368+
fn markdown_safe_prefix(text: &str) -> String {
369+
text.trim_start()
370+
.chars()
371+
.take_while(|c| {
372+
!matches!(
373+
c,
374+
'\\' | '*' | '_' | '~' | '`' | '[' | ']' | '<' | '>' | '|' | '#' | '\n'
375+
)
376+
})
377+
.take(24)
378+
.collect()
379+
}
380+
381+
/// chars[from..] 에서 needle(char 슬라이스)의 첫 등장 위치 (char 인덱스)
382+
fn find_chars_from(chars: &[char], from: usize, needle: &[char]) -> Option<usize> {
383+
if needle.is_empty() || from >= chars.len() {
384+
return None;
385+
}
386+
chars[from..]
387+
.windows(needle.len())
388+
.position(|w| w == needle)
389+
.map(|p| from + p)
390+
}
391+
392+
/// 블록 IR 의 쪽 번호를 content 문자 오프셋 브레이크포인트로 사영.
393+
/// 매칭 실패 블록은 건너뛴다 — 페이지 경계는 그 페이지의 후속 블록들로 재시도되므로
394+
/// 근사 정확도가 유지되고, 못 찾으면 이전 페이지가 이어질 뿐 오배정은 없다.
395+
fn page_breakpoints(chars: &[char], blocks: &[KordocBlock]) -> Vec<(usize, usize)> {
396+
let mut points: Vec<(usize, usize)> = Vec::new();
397+
let mut cursor = 0usize;
398+
let mut last_page = 0usize;
399+
for b in blocks {
400+
let Some(page) = b.page_number else { continue };
401+
// needle 후보: 문단/헤딩은 본문, 표는 첫 비어있지 않은 셀
402+
let text = if b.block_type == "table" {
403+
b.table.as_ref().and_then(|t| {
404+
t.cells
405+
.iter()
406+
.flatten()
407+
.find_map(|c| c.text.as_deref().filter(|s| !s.trim().is_empty()))
408+
})
409+
} else {
410+
b.text.as_deref()
411+
};
412+
let Some(text) = text else { continue };
413+
let needle: Vec<char> = markdown_safe_prefix(text).chars().collect();
414+
if needle.len() < 4 {
415+
continue; // 너무 짧으면 오매칭 위험
416+
}
417+
if let Some(found) = find_chars_from(chars, cursor, &needle) {
418+
if page != last_page {
419+
points.push((found, page));
420+
last_page = page;
421+
}
422+
cursor = found + 1; // 동일 텍스트 반복 블록도 전진하도록 최소 전진
423+
}
424+
}
425+
// HWP5 머리말 블록이 본문 앞(오프셋 0 부근)에 본문보다 큰 쪽 번호로 놓이는 변칙 방어
426+
if points.len() >= 2 && points[0].1 > points[1].1 {
427+
points.remove(0);
428+
}
429+
points
430+
}
431+
432+
/// 청크(char 오프셋)에 페이지 번호·위치 힌트를 부여한다. content 는 불변 —
433+
/// 브레이크포인트를 못 만들면 아무것도 하지 않는다(종전 동작).
434+
fn annotate_chunk_pages(
435+
chunks: &mut [super::DocumentChunk],
436+
content: &str,
437+
blocks: &[KordocBlock],
438+
) {
439+
let chars: Vec<char> = content.chars().collect();
440+
let points = page_breakpoints(&chars, blocks);
441+
if points.is_empty() {
442+
return;
443+
}
444+
let page_at = |off: usize| -> usize {
445+
match points.binary_search_by(|p| p.0.cmp(&off)) {
446+
Ok(i) => points[i].1,
447+
Err(0) => 1, // 첫 브레이크포인트 이전 = 문서 시작부
448+
Err(i) => points[i - 1].1,
449+
}
450+
};
451+
for ch in chunks.iter_mut() {
452+
let start = page_at(ch.start_offset);
453+
let end = page_at(ch.end_offset.saturating_sub(1)).max(start);
454+
ch.page_number = Some(start);
455+
ch.page_end = Some(end);
456+
ch.location_hint = Some(if start == end {
457+
format!("페이지 {}", start)
458+
} else {
459+
format!("페이지 {}-{}", start, end)
460+
});
461+
}
462+
}
463+
326464
/// kordoc으로 파일의 full markdown만 추출 (미리보기용, 전역 formula OCR 토글 반영).
327465
pub fn get_markdown(path: &Path) -> Result<String, ParseError> {
328466
get_markdown_with_options(path, options_for_path(path))
@@ -1138,6 +1276,92 @@ fn html_tables_to_text(md: &str) -> String {
11381276
mod tests {
11391277
use super::*;
11401278

1279+
fn block(block_type: &str, text: Option<&str>, page: usize) -> KordocBlock {
1280+
KordocBlock {
1281+
block_type: block_type.to_string(),
1282+
text: text.map(String::from),
1283+
page_number: Some(page),
1284+
table: None,
1285+
}
1286+
}
1287+
1288+
#[test]
1289+
fn chunk_pages_annotated_from_blocks() {
1290+
// 3페이지 문서 — 마크다운 헤딩 접두("## ")·escapeGfm(\*) 가 섞여도
1291+
// 블록 원문 선두 조각으로 매칭돼야 한다.
1292+
let content = "## 첫 페이지 제목\n\n첫 페이지 본문입니다.\n\n둘째 페이지 시작 문단.\n\n셋째 페이지 마지막 문단.";
1293+
let blocks = vec![
1294+
block("heading", Some("첫 페이지 제목"), 1),
1295+
block("paragraph", Some("첫 페이지 본문입니다."), 1),
1296+
block("paragraph", Some("둘째 페이지 시작 문단."), 2),
1297+
block("paragraph", Some("셋째 페이지 마지막 문단."), 3),
1298+
];
1299+
let mut chunks = chunk_text(content, 20, 4);
1300+
annotate_chunk_pages(&mut chunks, content, &blocks);
1301+
assert!(
1302+
chunks.iter().all(|c| c.page_number.is_some()),
1303+
"전 청크 페이지 부여"
1304+
);
1305+
assert_eq!(chunks.first().unwrap().page_number, Some(1));
1306+
assert_eq!(chunks.last().unwrap().page_end, Some(3));
1307+
let hint = chunks.first().unwrap().location_hint.as_deref().unwrap();
1308+
assert!(hint.starts_with("페이지 "), "위치 힌트 형식: {hint}");
1309+
}
1310+
1311+
#[test]
1312+
fn chunk_pages_table_block_uses_first_cell() {
1313+
let content = "본문 문단\n\n항목 값 비고 첫셀텍스트 둘째셀\n\n표 다음 문단";
1314+
let blocks = vec![
1315+
block("paragraph", Some("본문 문단"), 1),
1316+
KordocBlock {
1317+
block_type: "table".to_string(),
1318+
text: None,
1319+
page_number: Some(2),
1320+
table: Some(KordocTable {
1321+
cells: vec![vec![
1322+
KordocCell {
1323+
text: Some("항목 값 비고".to_string()),
1324+
},
1325+
KordocCell {
1326+
text: Some("둘째셀".to_string()),
1327+
},
1328+
]],
1329+
}),
1330+
},
1331+
block("paragraph", Some("표 다음 문단"), 2),
1332+
];
1333+
let mut chunks = chunk_text(content, 200, 20);
1334+
annotate_chunk_pages(&mut chunks, content, &blocks);
1335+
// 단일 청크: 1페이지에서 시작해 2페이지에서 끝난다
1336+
assert_eq!(chunks[0].page_number, Some(1));
1337+
assert_eq!(chunks[0].page_end, Some(2));
1338+
assert_eq!(chunks[0].location_hint.as_deref(), Some("페이지 1-2"));
1339+
}
1340+
1341+
#[test]
1342+
fn chunk_pages_unmatched_blocks_are_skipped() {
1343+
// 어떤 블록도 content 와 매칭되지 않으면 아무것도 하지 않는다 (종전 동작)
1344+
let content = "완전히 다른 내용";
1345+
let blocks = vec![block("paragraph", Some("매칭될 수 없는 텍스트"), 2)];
1346+
let mut chunks = chunk_text(content, 100, 10);
1347+
annotate_chunk_pages(&mut chunks, content, &blocks);
1348+
assert_eq!(chunks[0].page_number, None);
1349+
assert_eq!(chunks[0].location_hint, None);
1350+
}
1351+
1352+
#[test]
1353+
fn markdown_safe_prefix_cuts_specials() {
1354+
assert_eq!(markdown_safe_prefix("가나다*라마"), "가나다");
1355+
assert_eq!(markdown_safe_prefix(" 선두공백 제거"), "선두공백 제거");
1356+
assert_eq!(markdown_safe_prefix("한줄\n두줄"), "한줄");
1357+
assert_eq!(
1358+
markdown_safe_prefix("긴텍스트".repeat(20).as_str())
1359+
.chars()
1360+
.count(),
1361+
24
1362+
);
1363+
}
1364+
11411365
#[test]
11421366
fn html_table_serialized_to_plain_text() {
11431367
let md = "앞\n<table><tr><td>A</td><td colspan=\"2\">B</td></tr>\
@@ -1307,5 +1531,30 @@ mod tests {
13071531
assert!(resp.warnings[0].code.is_none());
13081532
assert!(resp.page_quality.is_empty());
13091533
assert_eq!(resp.is_image_based, None);
1534+
assert!(resp.blocks.is_empty());
1535+
}
1536+
1537+
/// kordoc v4.7.3 실 JSON 형태 계약 — IRTable 의 rows/cols 는 **정수(개수)** 고
1538+
/// 셀 데이터는 cells 다. 배열로 가정하면 역직렬화가 통째로 죽는다 (실출력 대조로 확정).
1539+
#[test]
1540+
fn kordoc_response_v473_blocks_shape_parses() {
1541+
let json = r#"{"success": true, "fileType": "hwpx", "markdown": "본문",
1542+
"blocks": [
1543+
{"type": "table", "pageNumber": 1, "table": {"rows": 2, "cols": 2, "hasHeader": true,
1544+
"cells": [[{"text": "셀A", "colSpan": 1, "rowSpan": 1}, {"text": "셀B", "colSpan": 1, "rowSpan": 1}]]}},
1545+
{"type": "paragraph", "text": "둘째 쪽 문단", "pageNumber": 2}
1546+
],
1547+
"metadata": {"pageCount": 2, "pageMode": "layout"}, "pageCount": 2}"#;
1548+
let resp: KordocResponse = serde_json::from_str(json).expect("v4.7.3 형태 역직렬화");
1549+
assert_eq!(resp.blocks.len(), 2);
1550+
assert_eq!(resp.blocks[0].block_type, "table");
1551+
assert_eq!(
1552+
resp.blocks[0].table.as_ref().unwrap().cells[0][0]
1553+
.text
1554+
.as_deref(),
1555+
Some("셀A")
1556+
);
1557+
assert_eq!(resp.blocks[1].page_number, Some(2));
1558+
assert_eq!(resp.metadata.unwrap().page_mode.as_deref(), Some("layout"));
13101559
}
13111560
}

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.8.0",
4+
"version": "3.8.1",
55
"identifier": "com.anything.app",
66
"build": {
77
"beforeDevCommand": "pnpm dev",

0 commit comments

Comments
 (0)