@@ -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 토글 반영).
327465pub 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 {
11381276mod 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}
0 commit comments