Compare commits

...

5 Commits

Author SHA1 Message Date
hyungi 5c065e6bec feat(documents): 개요 점프 결선 — anchor splice + id↔id 점프 + scroll-spy ([id])
불만② 개요→본문 점프를 deterministic 하게 결선(경로 A). 상세페이지([id], 개요 rail 보유).

- MarkdownDoc: anchorMap prop 추가 → 렌더 전 md_content 의 각 offset(내림차순)에
  <span id="sec-{chunkId}" class="md-anchor"> splice(점프 타깃). DOMPurify span+id+class 통과.
- SectionOutline: onJump(chunkId)/activeKey prop. 클릭=아코디언 toggle + onJump(점프).
  activeKey 일치 항목 좌측 accent border 강조(scroll-spy).
- [id]: anchorMap=buildAnchorMap(md_content, sections)(canShowMarkdown 시) → MarkdownDoc 전달.
  jumpToSection=#sec-id scrollIntoView. scroll-spy(window scroll, 120px 상단 통과 마지막 anchor).
  SectionOutline 양쪽(xl rail·details)에 onJump/activeKey 배선.

id↔id 직매칭이라 중복제목(표-1·Part UW 814건)·비-ATX(제N조) 정확. anchor 없는 절=점프
비활성(아코디언 폴백). node test 10/10, vite build + lint:tokens(신규0) PASS.
다음 = 3-pane(DocumentViewer) 개요 rail(commit 3, 레이아웃).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:17:07 +09:00
hyungi e1a047c2c2 feat(documents): 개요 점프 anchorMap 유틸 (forward-cursor 3중 방어)
불만② 개요→본문 점프의 deterministic anchor 좌표 산출(경로 A, FE-only).
게이트 측정상 textContent 매칭은 중복 63%·비-ATX 로 5% + silent 오점프 → md_content
에서 각 절 heading 라인 offset 을 찾아 <a id="sec-{chunk_id}"> 주입 좌표를 만든다.

★ false-early-match 방어 3중 (적대 리뷰 반영):
- 라인-시작(전체-라인) 매칭 → 본문 중간 상호참조("see Part UW")는 라인 전체가 제목과
  같지 않아 제외(forward-cursor 가 못 막던 핵심 구멍).
- 전체 매칭 + truncation(builder [:200]) 처리 → '제1조'가 '제1조의2' 오매칭 차단.
- 단조 커서 + 코드펜스 회피 → 역행/펜스 매칭 거부 = anchor 없음(점프 비활성, 오점프 금지).

window/section_split 조각·빈 제목은 skip. node test 10/10 PASS(상호참조 선행·중복 단조·
prefix·평문 제N조·펜스·window·miss·heading_path fallback). 순수 함수, vite build PASS.
다음 commit = MarkdownDoc splice + SectionOutline 점프 + DocumentViewer rail/scroll-spy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:11:00 +09:00
hyungi 2c77b3b0e7 Merge pull request 'feat(documents): 3-pane 중앙 리더 markdown-first 일원화 (DocumentViewer)' (#30) from feat/documents-viewer-unify into main
Reviewed-on: #30
2026-06-08 15:55:18 +09:00
hyungi 360871e9cf feat(documents): 3-pane 중앙 리더 markdown-first 일원화 (DocumentViewer)
메인 /documents 3-pane 의 중앙 리더(DocumentViewer)가 md_content 를 안 쓰고
PDF=raw iframe·md/txt=plain marked(extracted_text)만 렌더하던 이원화 제거.
"전부 MD화" 한 canonical markdown 이 전체보기 없이 메인에서 바로 보이게 함(불만①).

- viewerType.ts 신설: 분류 단일 source(상세페이지와 공유 예정, drift 차단).
  csv/json/xml/html→text(<pre>, 콤마 뭉침 회피), office→preview-pdf, hwp→hwp-markdown.
- DocumentViewer: 자체 getViewerType/renderMd(본문) 제거 → viewerType.ts + MarkdownDoc.
  - pdf: canShowMarkdown(isMdSuccess+md_content) 시 MarkdownDoc 기본 + [Markdown|PDF원본]
    토글 + MarkdownStatusBadge, 아니면 PDF iframe. lastDocId 가드는 fullDoc.id(prop) 키잉.
  - markdown(md/txt): MarkdownDoc(extracted_text=표시·편집 단일 필드), 편집 유지.
  - hwp-markdown/article: MarkdownDoc(앵커/KaTeX/이미지). 편집 미리보기만 plain marked 유지.
  - article/preview-pdf/image/text/cad/synology/unsupported 분기 보존(회귀 금지) + synology 신설.

API md_status='completed'(S1 validator live) 대응 = isMdSuccess. FE only, BE/스키마 무변.
vite build + lint:tokens(신규 위반 0) PASS. 후속: 개요 rail·안전점프(commit 2), [id] 정합(commit 3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:44:46 +09:00
hyungi 0f37fe6492 Merge pull request 'fix(ui): md_status 'success'/'completed' 어휘 양립 (S1 API remap 대비)' (#29) from fix/md-status-completed-compat into main
Reviewed-on: #29
2026-06-08 15:27:45 +09:00
7 changed files with 475 additions and 121 deletions
+126 -114
View File
@@ -3,10 +3,14 @@
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { ExternalLink, Save, RefreshCw } from 'lucide-svelte';
import { ExternalLink, Save } from 'lucide-svelte';
import Tabs from '$lib/components/ui/Tabs.svelte';
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
import { getViewerType } from '$lib/utils/viewerType';
import { isMdSuccess } from '$lib/utils/mdStatus';
// marked + sanitize
// 편집 미리보기 전용 plain marked (본문 렌더는 MarkdownDoc 가 담당).
marked.use({ mangle: false, headerIds: false });
function renderMd(text) {
return DOMPurify.sanitize(marked(text), {
@@ -22,33 +26,19 @@
let loading = $state(true);
let viewerType = $state('none');
// Markdown 편집
// Markdown 편집 (md/txt — extracted_text 가 표시·편집 단일 필드)
let editMode = $state(false);
let editContent = $state('');
let editTab = $state('edit');
let saving = $state(false);
let rawMarkdown = $state('');
function getViewerType(format) {
if (['md', 'txt'].includes(format)) return 'markdown';
if (format === 'pdf') return 'pdf';
if (['hwp', 'hwpx'].includes(format)) return 'preview-pdf';
if (['odoc', 'osheet', 'docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp'].includes(format)) return 'preview-pdf';
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff'].includes(format)) return 'image';
if (['csv', 'json', 'xml', 'html'].includes(format)) return 'text';
if (['dwg', 'dxf'].includes(format)) return 'cad';
return 'unsupported';
}
const ODF_FORMATS = ['ods', 'odt', 'odp', 'odoc', 'osheet'];
function getEditInfo(doc) {
// DB에 저장된 편집 URL 우선
if (doc.edit_url) return { url: doc.edit_url, label: '편집' };
// ODF 포맷 → Synology Drive
if (ODF_FORMATS.includes(doc.file_format)) return { url: 'https://link.hyungi.net', label: 'Synology Drive에서 열기' };
// CAD
if (['dwg', 'dxf'].includes(doc.file_format)) return { url: 'https://web.autocad.com', label: 'AutoCAD Web' };
function getEditInfo(d) {
if (d.edit_url) return { url: d.edit_url, label: '편집' };
if (ODF_FORMATS.includes(d.file_format)) return { url: 'https://link.hyungi.net', label: 'Synology Drive에서 열기' };
if (['dwg', 'dxf'].includes(d.file_format)) return { url: 'https://web.autocad.com', label: 'AutoCAD Web' };
return null;
}
@@ -61,18 +51,17 @@
async function loadFullDoc(id) {
loading = true;
rawMarkdown = '';
try {
fullDoc = await api(`/documents/${id}`);
viewerType = fullDoc.source_channel === 'news' ? 'article' : getViewerType(fullDoc.file_format);
viewerType = getViewerType(fullDoc.file_format, fullDoc.source_channel);
// Markdown: extracted_text 없으면 원본 파일 직접 가져오기
// 본문 markdown(md/txt) 인데 extracted_text 가 비면 원본 파일 직접 로드.
if (viewerType === 'markdown' && !fullDoc.extracted_text) {
try {
const resp = await fetch(`/api/documents/${id}/file?token=${getAccessToken()}`);
if (resp.ok) rawMarkdown = await resp.text();
} catch (e) { rawMarkdown = ''; }
} else {
rawMarkdown = '';
}
} catch (err) {
fullDoc = null;
@@ -82,6 +71,23 @@
}
}
// PDF markdown-first: marker 가 만든 canonical md_content 가 있으면 기본으로 그것을 보여주고
// "PDF 원본" 토글 제공. lastDocId 는 prop(fullDoc.id) 로 키잉 — 3-pane 은 라우트 리마운트가
// 없어 page.params 가드는 no-op 이 된다.
let pdfViewMode = $state('markdown');
let lastDocId = $state(null);
let canShowMarkdown = $derived(
!!(isMdSuccess(fullDoc?.md_status) && fullDoc?.md_content?.trim())
);
$effect(() => {
if (!fullDoc) return;
if (fullDoc.id !== lastDocId) {
lastDocId = fullDoc.id;
pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf';
}
if (!canShowMarkdown && pdfViewMode === 'markdown') pdfViewMode = 'pdf';
});
function startEdit() {
editContent = fullDoc?.extracted_text || rawMarkdown || '';
editMode = true;
@@ -113,6 +119,7 @@
}
let editInfo = $derived(fullDoc ? getEditInfo(fullDoc) : null);
const PROSE = 'prose prose-invert prose-base max-w-none';
</script>
<svelte:window on:keydown={handleKeydown} />
@@ -125,38 +132,22 @@
<div class="flex items-center gap-2">
{#if viewerType === 'markdown'}
{#if editMode}
<button
onclick={saveContent}
disabled={saving}
class="flex items-center gap-1 px-2 py-1 text-xs bg-accent text-white rounded hover:bg-accent-hover disabled:opacity-50"
>
<button onclick={saveContent} disabled={saving}
class="flex items-center gap-1 px-2 py-1 text-xs bg-accent text-white rounded hover:bg-accent-hover disabled:opacity-50">
<Save size={12} /> {saving ? '저장 중...' : '저장'}
</button>
<button
onclick={() => editMode = false}
class="px-2 py-1 text-xs text-dim hover:text-text"
>취소</button>
<button onclick={() => editMode = false} class="px-2 py-1 text-xs text-dim hover:text-text">취소</button>
{:else}
<button
onclick={startEdit}
class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded"
>편집</button>
<button onclick={startEdit} class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">편집</button>
{/if}
{/if}
{#if editInfo}
<a
href={editInfo.url}
target="_blank"
rel="noopener"
class="flex items-center gap-1 px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded"
>
<a href={editInfo.url} target="_blank" rel="noopener"
class="flex items-center gap-1 px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">
<ExternalLink size={12} /> {editInfo.label}
</a>
{/if}
<a
href="/documents/{fullDoc.id}"
class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded"
>전체 보기</a>
<a href="/documents/{fullDoc.id}" class="px-2 py-1 text-xs text-dim hover:text-accent border border-default rounded">전체 보기</a>
</div>
</div>
{/if}
@@ -164,109 +155,130 @@
<!-- 뷰어 본문 -->
<div class="flex-1 overflow-auto min-h-0">
{#if loading}
<div class="flex items-center justify-center h-full">
<p class="text-sm text-dim">로딩 중...</p>
</div>
<div class="flex items-center justify-center h-full"><p class="text-sm text-dim">로딩 중...</p></div>
{:else if fullDoc}
{#if viewerType === 'markdown'}
{#if editMode}
<!-- Markdown 편집 (Tabs 프리미티브 — E.4) -->
<div class="flex flex-col h-full">
<Tabs
tabs={[
{ id: 'edit', label: '편집' },
{ id: 'preview', label: '미리보기' },
]}
bind:value={editTab}
class="flex flex-col h-full"
>
<Tabs tabs={[{ id: 'edit', label: '편집' }, { id: 'preview', label: '미리보기' }]} bind:value={editTab} class="flex flex-col h-full">
{#snippet children(activeId)}
{#if activeId === 'edit'}
<textarea
bind:value={editContent}
<textarea bind:value={editContent}
class="flex-1 w-full p-4 bg-bg text-text text-sm font-mono resize-none outline-none min-h-[300px]"
spellcheck="false"
aria-label="마크다운 편집"
></textarea>
spellcheck="false" aria-label="마크다운 편집"></textarea>
{:else}
<div class="flex-1 overflow-auto p-4 markdown-body">
{@html renderMd(editContent)}
</div>
<div class="flex-1 overflow-auto p-4 markdown-body">{@html renderMd(editContent)}</div>
{/if}
{/snippet}
</Tabs>
</div>
{:else}
<div class="p-4 markdown-body">
{@html renderMd(fullDoc.extracted_text || rawMarkdown || '*텍스트 추출 대기 중*')}
<!-- md/txt = extracted_text 단일 필드(표시=편집), MarkdownDoc 로 앵커/KaTeX/이미지 렌더 -->
<div class="p-4">
<MarkdownDoc
documentId={fullDoc.id}
mdContent={null}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
extractedText={fullDoc.extracted_text || rawMarkdown}
class={PROSE}
/>
</div>
{/if}
{:else if viewerType === 'pdf'}
<iframe
src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}"
class="w-full h-full border-0"
title={fullDoc.title}
></iframe>
{:else if viewerType === 'preview-pdf'}
<iframe
src="/api/documents/{fullDoc.id}/preview?token={getAccessToken()}"
class="w-full h-full border-0"
title={fullDoc.title}
onerror={() => {}}
></iframe>
{:else if viewerType === 'image'}
<div class="flex items-center justify-center h-full p-4">
<img
src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}"
alt={fullDoc.title}
class="max-w-full max-h-full object-contain rounded"
<div class="p-4 flex flex-col h-full">
<div class="mb-2 flex items-center gap-2 shrink-0">
<MarkdownStatusBadge mdStatus={fullDoc.md_status} mdExtractionError={fullDoc.md_extraction_error} mdExtractionQuality={fullDoc.md_extraction_quality} />
{#if canShowMarkdown}
<button onclick={() => (pdfViewMode = 'markdown')}
class="px-2 py-1 text-xs rounded border {pdfViewMode === 'markdown' ? 'bg-accent text-white border-accent' : 'text-dim border-default hover:text-accent'}">Markdown</button>
<button onclick={() => (pdfViewMode = 'pdf')}
class="px-2 py-1 text-xs rounded border {pdfViewMode === 'pdf' ? 'bg-accent text-white border-accent' : 'text-dim border-default hover:text-accent'}">PDF 원본</button>
{/if}
</div>
{#if pdfViewMode === 'markdown' && canShowMarkdown}
<div class="flex-1 overflow-auto">
<MarkdownDoc
documentId={fullDoc.id}
mdContent={fullDoc.md_content}
mdFrontmatter={fullDoc.md_frontmatter}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
extractedText={fullDoc.extracted_text}
class={PROSE}
/>
</div>
{:else}
<iframe src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}" class="flex-1 w-full border-0 rounded" title={fullDoc.title}></iframe>
{/if}
</div>
{:else if viewerType === 'hwp-markdown'}
<div class="p-4">
<MarkdownDoc
documentId={fullDoc.id}
mdContent={fullDoc.md_content}
mdFrontmatter={fullDoc.md_frontmatter}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
extractedText={fullDoc.extracted_text}
class={PROSE}
/>
</div>
{:else if viewerType === 'preview-pdf'}
<iframe src="/api/documents/{fullDoc.id}/preview?token={getAccessToken()}" class="w-full h-full border-0" title={fullDoc.title} onerror={() => {}}></iframe>
{:else if viewerType === 'image'}
<div class="flex items-center justify-center h-full p-4">
<img src="/api/documents/{fullDoc.id}/file?token={getAccessToken()}" alt={fullDoc.title} class="max-w-full max-h-full object-contain rounded" />
</div>
{:else if viewerType === 'text'}
<div class="p-4">
<pre class="text-sm text-text whitespace-pre-wrap font-mono">{fullDoc.extracted_text || '텍스트 없음'}</pre>
<div class="p-4"><pre class="text-sm text-text whitespace-pre-wrap font-mono">{fullDoc.extracted_text || '텍스트 없음'}</pre></div>
{:else if viewerType === 'synology'}
<div class="flex flex-col items-center justify-center h-full gap-3">
<p class="text-sm text-dim">Synology Office 문서 — 외부 편집기에서 열어야 합니다.</p>
<a href={fullDoc.edit_url || 'https://link.hyungi.net'} target="_blank" rel="noopener"
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover">
<ExternalLink size={14} /> 새 창에서 열기
</a>
</div>
{:else if viewerType === 'cad'}
<div class="flex flex-col items-center justify-center h-full gap-3">
<p class="text-sm text-dim">CAD 미리보기 (향후 지원 예정)</p>
<a
href="https://web.autocad.com"
target="_blank"
class="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent-hover"
>AutoCAD Web에서 열기</a>
<a href="https://web.autocad.com" target="_blank" rel="noopener" class="px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent-hover">AutoCAD Web에서 열기</a>
</div>
{:else if viewerType === 'article'}
<!-- 뉴스 전용 뷰어 -->
<div class="p-5 max-w-3xl mx-auto">
<h1 class="text-lg font-bold mb-2">{fullDoc.title}</h1>
<h1 class="text-lg font-bold mb-2 text-text">{fullDoc.title}</h1>
<div class="flex items-center gap-2 mb-4 text-xs text-dim">
{#if fullDoc.ai_tags?.length}
{#each fullDoc.ai_tags.filter(t => t.startsWith('News/')) as tag}
<span class="px-1.5 py-0.5 rounded bg-blue-900/30 text-blue-400">{tag.replace('News/', '')}</span>
<span class="px-1.5 py-0.5 rounded bg-accent/15 text-accent-hover">{tag.replace('News/', '')}</span>
{/each}
{/if}
<span>{new Date(fullDoc.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
</div>
<div class="markdown-body mb-6">
{@html renderMd(fullDoc.extracted_text || '')}
</div>
<div class="flex items-center gap-3 pt-4 border-t border-default">
{#if fullDoc.edit_url}
<a
href={fullDoc.edit_url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover"
>
<MarkdownDoc
documentId={fullDoc.id}
mdContent={fullDoc.md_content}
mdStatus={fullDoc.md_status}
mdExtractionError={fullDoc.md_extraction_error}
mdExtractionQuality={fullDoc.md_extraction_quality}
extractedText={fullDoc.extracted_text}
class="{PROSE} mb-6"
/>
{#if fullDoc.edit_url}
<div class="flex items-center gap-3 pt-4 border-t border-default">
<a href={fullDoc.edit_url} target="_blank" rel="noopener noreferrer"
class="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover">
<ExternalLink size={14} /> 원문 보기
</a>
{/if}
</div>
</div>
{/if}
</div>
{:else}
<div class="flex items-center justify-center h-full">
<p class="text-sm text-dim">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p>
</div>
<div class="flex items-center justify-center h-full"><p class="text-sm text-dim">미리보기를 지원하지 않는 형식입니다 ({fullDoc.file_format})</p></div>
{/if}
{/if}
</div>
+21 -1
View File
@@ -28,6 +28,9 @@
mdStatus?: string | null;
mdExtractionError?: string | null;
mdExtractionQuality?: Record<string, unknown> | null;
/** 개요 점프용 anchor: {chunk_id: md_content 내 char offset}. 렌더 전 해당 위치에
* <span id="sec-{chunk_id}"> 주입(점프 타깃). buildAnchorMap(outlineAnchors) 산출물. */
anchorMap?: Record<number, number> | null;
placeholder?: string;
/** 추가 래퍼 클래스. tailwind prose-* / spacing 등을 호출 측에서 입혀야 할 때. */
class?: string;
@@ -41,10 +44,27 @@
mdStatus = null,
mdExtractionError = null,
mdExtractionQuality = null,
anchorMap = null,
placeholder = '*텍스트 추출 대기 중*',
class: klass = '',
}: Props = $props();
// 개요 anchor 주입: body 의 각 offset(내림차순)에 빈 <span id="sec-N"> 삽입(점프 타깃).
// offset 은 buildAnchorMap 이 body 와 동일 문자열 기준으로 산출했어야 함(호출측 책임).
function spliceAnchors(text: string, map: Record<number, number> | null): string {
if (!map) return text;
const ents = Object.entries(map)
.map(([id, off]) => [id, Number(off)] as [string, number])
.filter(([, o]) => Number.isFinite(o) && o >= 0 && o <= text.length)
.sort((a, b) => b[1] - a[1]);
if (!ents.length) return text;
let out = text;
for (const [id, off] of ents) {
out = out.slice(0, off) + `<span id="sec-${id}" class="md-anchor"></span>\n` + out.slice(off);
}
return out;
}
let usingMarkdown = $derived(!!(mdContent && mdContent.trim()));
let body = $derived(
usingMarkdown
@@ -53,7 +73,7 @@
? extractedText
: placeholder,
);
let renderedHtml = $derived(renderDocMarkdown(body));
let renderedHtml = $derived(renderDocMarkdown(spliceAnchors(body, anchorMap)));
let frontmatterEntries = $derived.by(() => {
if (!usingMarkdown || !mdFrontmatter) return [] as [string, unknown][];
@@ -15,8 +15,12 @@
interface Props {
sections: DocumentSection[];
/** 항목 클릭 시 본문 점프 콜백(부모가 #sec-{chunkId} scrollIntoView). 없으면 아코디언만. */
onJump?: (chunkId: number) => void;
/** scroll-spy 현재 절(chunk_id) — 강조용. */
activeKey?: number | null;
}
let { sections }: Props = $props();
let { sections, onJump, activeKey = null }: Props = $props();
let layout = $derived(groupOrFlat(sections));
let total = $derived(sections.length);
@@ -37,15 +41,17 @@
{#snippet itemRow(item: OutlineItem)}
{@const s = item.section}
{@const open = selectedId === s.chunk_id}
{@const active = activeKey != null && activeKey === s.chunk_id}
{@const typeLabel = sectionTypeLabel(s.section_type)}
<li>
<button
type="button"
onclick={() => toggle(item)}
onclick={() => { toggle(item); onJump?.(s.chunk_id); }}
aria-expanded={open}
aria-current={active ? 'true' : undefined}
class={[
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors',
open ? 'bg-surface-active text-text' : 'text-dim hover:bg-surface hover:text-text',
'w-full text-left px-2 py-1.5 rounded-md text-xs flex items-start gap-1.5 transition-colors border-l-2',
open ? 'bg-surface-active text-text border-accent' : active ? 'bg-surface text-accent-hover border-accent' : 'text-dim hover:bg-surface hover:text-text border-transparent',
].join(' ')}
>
<span class="flex-1 min-w-0 leading-snug break-words">{title(s)}</span>
@@ -0,0 +1,128 @@
// 순수함수 회귀 테스트. 실행(로컬, 의존성 0): node --test src/lib/utils/outlineAnchors.test.ts
// (Node ≥23 또는 22.6+ --experimental-strip-types — TS 타입 네이티브 strip.)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildAnchorMap } from './outlineAnchors.ts';
import { type DocumentSection } from './headingPath.ts';
let _id = 0;
function sec(p: Partial<DocumentSection>): DocumentSection {
return {
chunk_id: ++_id,
section_title: null,
heading_path: null,
level: null,
node_type: null,
is_leaf: true,
section_type: null,
summary: null,
confidence: null,
...p,
};
}
const md = (lines: string[]) => lines.join('\n');
const lineOff = (lines: string[], idx: number) => {
let o = 0;
for (let i = 0; i < idx; i++) o += lines[i].length + 1;
return o;
};
test('ATX heading 정확 매칭 + offset', () => {
const lines = ['# 개요', '본문 a', '## 설계 기준', '본문 b'];
const s = [
sec({ chunk_id: 101, section_title: '개요' }),
sec({ chunk_id: 102, section_title: '설계 기준' }),
];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[101], lineOff(lines, 0));
assert.equal(r.anchors[102], lineOff(lines, 2));
assert.equal(r.matched, 2);
});
test('★ false early match 방어 — 상호참조가 heading 보다 먼저', () => {
const lines = ['# 개요', '본 절은 Part UW 를 참조한다.', '내용', '# Part UW', '강판'];
const s = [
sec({ chunk_id: 1, section_title: '개요' }),
sec({ chunk_id: 2, section_title: 'Part UW' }),
];
const r = buildAnchorMap(md(lines), s);
// 상호참조(line 1)가 아니라 실제 heading(line 3)으로
assert.equal(r.anchors[2], lineOff(lines, 3));
assert.notEqual(r.anchors[2], lineOff(lines, 1));
});
test('중복 제목 — 단조 커서로 N번째 출현 매칭', () => {
const lines = ['## General', 'a', '## Scope', 'b', '## General', 'c'];
const s = [
sec({ chunk_id: 1, section_title: 'General' }),
sec({ chunk_id: 2, section_title: 'Scope' }),
sec({ chunk_id: 3, section_title: 'General' }),
];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 0)); // 첫 General
assert.equal(r.anchors[2], lineOff(lines, 2)); // Scope
assert.equal(r.anchors[3], lineOff(lines, 4)); // 둘째 General (오점프 아님)
});
test('prefix 가드 — 제1조 가 제1조의2 를 오매칭 안 함', () => {
const lines = ['# 제1조의2', 'x', '# 제1조', 'y'];
const s = [sec({ chunk_id: 1, section_title: '제1조' })];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 2)); // 제1조의2(line0) 아님
});
test('비-ATX 평문 제N조 (전체-라인 매칭)', () => {
const lines = ['제1조(목적) 이 법은 OO 을 정한다.', '본문', '제2조(정의) 용어는...'];
const s = [
sec({ chunk_id: 1, section_title: '제1조(목적) 이 법은 OO 을 정한다.', node_type: 'clause' }),
sec({ chunk_id: 2, section_title: '제2조(정의) 용어는...', node_type: 'clause' }),
];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 0));
assert.equal(r.anchors[2], lineOff(lines, 2));
});
test('window 조각 skip (anchor 없음)', () => {
const lines = ['## 절', 'aaa', 'bbb'];
const s = [
sec({ chunk_id: 1, section_title: '절' }),
sec({ chunk_id: 2, section_title: '절', node_type: 'window' }), // 부모 제목 상속 조각
];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 0));
assert.equal(r.anchors[2], undefined); // window = 점프 비활성
assert.equal(r.total, 1);
});
test('코드펜스 내부 heading 제외', () => {
const lines = ['```', '# General', '```', '# General', 'x'];
const s = [sec({ chunk_id: 1, section_title: 'General' })];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 3)); // 펜스 밖
});
test('miss = anchor 없음 (점프 비활성, 오점프 아님)', () => {
const lines = ['# 개요', '본문'];
const s = [
sec({ chunk_id: 1, section_title: '개요' }),
sec({ chunk_id: 2, section_title: '존재하지 않는 절' }),
];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 0));
assert.equal(r.anchors[2], undefined);
assert.equal(r.total, 2);
assert.equal(r.matched, 1);
});
test('heading_path 마지막 세그먼트 fallback', () => {
const lines = ['# 도입', 'x'];
const s = [sec({ chunk_id: 1, section_title: null, heading_path: 'A > 도입' })];
const r = buildAnchorMap(md(lines), s);
assert.equal(r.anchors[1], lineOff(lines, 0));
});
test('빈 입력 안전', () => {
assert.deepEqual(buildAnchorMap('', [sec({ section_title: 'x' })]).anchors, {});
assert.deepEqual(buildAnchorMap('# x', []).anchors, {});
assert.deepEqual(buildAnchorMap(null, null).anchors, {});
});
+101
View File
@@ -0,0 +1,101 @@
// 개요(절 목차) → 본문 deterministic 점프용 anchor offset 산출 (경로 A: FE-only).
//
// hier 절(section_title)은 md_content 의 heading 라인에서 나왔으나(builder.py build_hier_tree,
// md_content 순수함수), 비-ATX(제N조/Chapter)는 본문에 markdown heading 요소·id 가 안 생기고
// 중복 제목(표-1·Part UW…)이 흔해 슬러그·textContent 매칭이 깨진다. 그래서 md_content 에서
// 각 절의 heading 위치(char offset)를 직접 찾아 <a id="sec-{chunk_id}"> 를 주입할 좌표를 만든다.
//
// ★ false early match 방어 3중 (리뷰 반영):
// 1. 라인-시작(전체-라인) 매칭 — 본문 중간 상호참조("see Part UW for…")는 라인 전체가 제목과
// 같지 않으므로 제외. heading 라인(선두 #/리스트마커 제거 후 전체)만 매칭.
// 2. 전체 매칭 + truncation 처리 — 'first-N-chars' prefix 금지('제1조'가 '제1조의2' 오매칭 차단).
// builder 가 KO/ENG 제목을 [:200] truncate 하므로 truncated(매우 긴 제목)일 때만 startsWith.
// 3. 단조 커서 + 코드펜스 회피 — 매칭은 직전 매칭 다음 라인부터(역행 불가) + ``` ~~~ 펜스 내부 제외.
// 미스/역행은 anchor 없음 = 점프 비활성(아코디언 폴백). 오점프보다 무점프.
//
// ⚠ 잔여 한계: 본문 앞 '목차(TOC)'가 절 제목을 단독 라인으로 순서대로 나열하면 커서가 TOC 를
// 먼저 잡을 수 있다(연쇄 시프트). 4-1 의 '정확도' 측정으로 검출 — 빈번하면 경로 B(builder offset).
import { cleanHeading, type DocumentSection } from './headingPath.ts';
const TRUNCATE_HINT = 180; // builder.py 가 KO/ENG 제목을 [:200] 으로 자름 → 거의 그 길이면 truncated 로 간주
function norm(s: string | null | undefined): string {
return cleanHeading(s).toLowerCase();
}
/** 한 라인을 heading 후보 텍스트로: 선두 ATX #(1~6) / 리스트마커(-*+) / blockquote(>) 제거 후 정규화. */
function normLine(raw: string): string {
const stripped = raw.replace(/^\s{0,3}(?:#{1,6}\s+|[-*+]\s+|>\s+)?/, '');
return cleanHeading(stripped).toLowerCase();
}
export interface AnchorMapResult {
/** chunk_id → md_content 내 heading 라인 시작 char offset. (없으면 점프 비활성) */
anchors: Record<number, number>;
/** 후보(비-window·제목有) 절 수 — 4-1 커버리지 분모. */
total: number;
/** 신뢰 anchor 수 — 4-1 커버리지 분자. (정확도는 별도 수작업 검증) */
matched: number;
}
/**
* sections chunk_index ( ) (GET /documents/{id}/sections ORDER BY).
*/
export function buildAnchorMap(
mdContent: string | null | undefined,
sections: DocumentSection[] | null | undefined,
): AnchorMapResult {
const anchors: Record<number, number> = {};
if (!mdContent || !sections || sections.length === 0) {
return { anchors, total: 0, matched: 0 };
}
// 라인별 (offset, 정규화 텍스트, 펜스 여부) 사전계산.
const rawLines = mdContent.split('\n');
const lines: { off: number; norm: string }[] = [];
let off = 0;
let inFence = false;
for (const raw of rawLines) {
const fenceToggle = /^\s{0,3}(```|~~~)/.test(raw);
const fencedHere = inFence || fenceToggle; // 펜스 경계 라인도 매칭 제외
lines.push({ off, norm: fencedHere ? '' : normLine(raw) });
if (fenceToggle) inFence = !inFence;
off += raw.length + 1; // '\n'
}
let cursor = 0; // 단조 전진 라인 인덱스
let total = 0;
let matched = 0;
for (const s of sections) {
// window/section_split 조각은 자체 heading 없음(부모 제목 상속) → 건너뜀.
if (s.node_type === 'window' || s.node_type === 'section_split') continue;
let nt = norm(s.section_title);
if (!nt && s.heading_path) {
const last = s.heading_path.split('>').pop();
nt = norm(last);
}
if (!nt) continue;
total++;
const truncated = nt.length >= TRUNCATE_HINT;
let foundIdx = -1;
for (let i = cursor; i < lines.length; i++) {
const ln = lines[i].norm;
if (!ln) continue; // 빈 라인 / 펜스 내부
if (ln === nt || (truncated && ln.startsWith(nt))) {
foundIdx = i;
break;
}
}
if (foundIdx >= 0) {
anchors[s.chunk_id] = lines[foundIdx].off;
cursor = foundIdx + 1; // 단조: 다음 절은 이 라인 이후만
matched++;
}
// 미스 → anchor 없음(점프 비활성, 폴백)
}
return { anchors, total, matched };
}
+46
View File
@@ -0,0 +1,46 @@
// 뷰어 타입 분류 단일 source — 상세페이지(/documents/[id])와 3-pane 중앙 리더
// (DocumentViewer)가 공유한다. 두 곳이 각자 getViewerType 을 두면 csv/hwp/office 분기가
// drift 하므로(이원화 재발) 여기 하나로 수렴한다.
//
// ⚠ 소비 컴포넌트는 이 함수가 낼 수 있는 모든 ViewerType 에 render 분기가 있어야 한다.
// (분류 통합 ≠ render 통합 — 양쪽 컴포넌트의 {#if viewerType===...} 에 누락 없는지 확인.)
export type ViewerType =
| 'article'
| 'markdown'
| 'hwp-markdown'
| 'pdf'
| 'preview-pdf'
| 'image'
| 'text'
| 'synology'
| 'cad'
| 'unsupported';
const MARKDOWN = new Set(['md', 'txt']);
// csv/json/xml/html 은 markdown 으로 렌더하면 콤마/행이 한 문단으로 뭉친다 → <pre> 로 원형 보존.
const TEXT = new Set(['csv', 'json', 'xml', 'html']);
const HWP = new Set(['hwp', 'hwpx']);
// LibreOffice headless → PDF preview (/preview) 로 인앱 표시.
const OFFICE_PREVIEW = new Set(['docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp']);
// Synology Office 네이티브 — 인앱 변환 부적합, 외부 편집기로.
const SYNOLOGY = new Set(['odoc', 'osheet']);
const IMAGE = new Set(['jpg', 'jpeg', 'png', 'gif', 'bmp', 'tiff']);
const CAD = new Set(['dwg', 'dxf']);
export function getViewerType(
format: string | null | undefined,
sourceChannel?: string | null,
): ViewerType {
if (sourceChannel === 'news') return 'article';
const f = (format ?? '').toLowerCase();
if (MARKDOWN.has(f)) return 'markdown';
if (f === 'pdf') return 'pdf';
if (HWP.has(f)) return 'hwp-markdown';
if (OFFICE_PREVIEW.has(f)) return 'preview-pdf';
if (SYNOLOGY.has(f)) return 'synology';
if (IMAGE.has(f)) return 'image';
if (TEXT.has(f)) return 'text';
if (CAD.has(f)) return 'cad';
return 'unsupported';
}
@@ -7,6 +7,7 @@
import { goto } from '$app/navigation';
import { api, getAccessToken } from '$lib/api';
import { isMdSuccess } from '$lib/utils/mdStatus';
import { buildAnchorMap } from '$lib/utils/outlineAnchors';
import { addToast } from '$lib/stores/toast';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@@ -163,6 +164,45 @@
}
});
// ── 개요 점프 (outlineAnchors, 경로 A) ──
// anchorMap = md_content 의 각 절 heading offset. MarkdownDoc 가 <span id="sec-N"> 주입.
let anchorMap = $derived(
hasSections && canShowMarkdown && doc?.md_content
? buildAnchorMap(doc.md_content, sections).anchors
: {}
);
let activeKey = $state(null);
function jumpToSection(chunkId) {
const el = document.getElementById(`sec-${chunkId}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// scroll-spy: 화면 상단(120px)을 지난 마지막 .md-anchor = 현재 절. [id] 는 window 스크롤.
$effect(() => {
void anchorMap; // 문서/섹션 변화 시 재바인딩
if (typeof window === 'undefined') return;
let raf = 0;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
let cur = null;
document.querySelectorAll('.md-anchor').forEach((a) => {
if (a.getBoundingClientRect().top <= 120) cur = a;
});
if (cur) {
const m = cur.id.match(/^sec-(\d+)$/);
if (m) activeKey = Number(m[1]);
}
});
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
return () => {
window.removeEventListener('scroll', onScroll);
if (raf) cancelAnimationFrame(raf);
};
});
function getViewerType(format) {
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
if (format === 'pdf') return 'pdf';
@@ -229,7 +269,7 @@
<!-- 좌측 절 목차 — xl+ sticky rail (그 아래 viewport 는 본문 상단 collapsible) -->
<aside class="hidden xl:block xl:sticky xl:top-6 xl:self-start xl:max-h-[calc(100vh-3rem)] xl:overflow-y-auto">
<Card>
<SectionOutline {sections} />
<SectionOutline {sections} onJump={jumpToSection} {activeKey} />
</Card>
</aside>
{/if}
@@ -240,7 +280,7 @@
<!-- xl 미만: 절 목차 접이식 -->
<details class="xl:hidden">
<summary class="cursor-pointer text-sm text-dim px-1 py-2 select-none">절 목차 ({sections.length})</summary>
<Card class="mt-2"><SectionOutline {sections} /></Card>
<Card class="mt-2"><SectionOutline {sections} onJump={jumpToSection} {activeKey} /></Card>
</details>
{/if}
<!-- Affordance row -->
@@ -289,6 +329,7 @@
mdStatus={doc.md_status}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
anchorMap={anchorMap}
extractedText={doc.extracted_text || rawMarkdown}
class="prose prose-invert prose-base lg:prose-sm max-w-none"
/>