feat(frontend): Phase 1C — markdown viewer 완성 (PDF 통합 + status badge + image placeholder)

Phase 1B marker_worker 결과(현재 success 29건, 전부 PDF)를 사용자 흐름에
연결하고 1D pilot 품질 평가 데이터를 확보하기 위한 viewer 마무리 작업.

빠진 부분 3가지를 닫는다:

1) PDF viewerType 기본 view = Markdown
   - md_status='success' AND md_content 비어있지 않음일 때 MarkdownDoc 기본 표시.
   - 사용자가 "PDF 원본" 토글 시 iframe.
   - pdfViewMode 초기화는 doc.id 변경 시에만 (lastDocId tracker) — reactive cycle
     이 사용자 토글을 덮어쓰지 않도록 보호.
   - markdown 사라지는 케이스(success → failed 재처리)는 자동으로 pdf 로 보호.

2) Image renderer → placeholder card (docMarkdown.ts)
   - md_content 의 69%(20/29)에 image syntax 포함. asset serving(1B.5) 미구현
     상태에서 raw <img> 를 emit 하면 깨진 아이콘 → 1D pilot 평가가 markdown
     품질이 아닌 viewer 미완성 문제로 오염됨.
   - href / alt / basename 모두 escape 후 figure.md-image-placeholder 로 렌더.
   - 원본 src 는 data-md-image-src 에 escape 보존 → 1B.5 ImgAuth selector 로
     실제 <img> 로 교체할 entry point 마련.
   - DOMPurify ADD_ATTR 에 data-md-image-src 추가.

3) MarkdownStatusBadge (신규) — 4-state badge
   - pending 숨김(legacy 9792건 시각 노이즈 회피).
   - processing/success/skipped/failed 표시.
   - success tooltip: md_extraction_quality 의 metrics raw 일부
     (markdown_heading_count / markdown_table_row_count / markdown_image_count /
     text_length_ratio / warnings) 만 노출. text_length_ratio / null /
     metrics nested / flat fallback 모두 방어.
   - skipped/failed tooltip: md_extraction_error 또는 정책 문구.
   - MarkdownDoc 내부 + PDF iframe fallback 양쪽에서 재사용 → failed 같이
     MarkdownDoc 가 안 렌더되는 경로에서도 사용자가 상태를 알 수 있음.

기존 markdown/hwp-markdown/article 분기에도 mdExtractionQuality prop 전달.

Out of scope (1B.5 또는 후속):
- ImgAuth blob URL 실제 wiring (data-md-image-src selector + Bearer raw)
- /data/assets/<doc_id>/ 저장 + 서빙
- Caddy /data/assets/* 라우팅
- localStorage 사용자 view preference 저장
- side-by-side viewer (1D pilot 결과 본 후)
- quality chip 별도 UI (1D 후)

Verify:
- npm run build 통과
- npm run lint:tokens 신규 파일 위반 0
- 관련 plan: ~/.claude/plans/iterative-nibbling-catmull.md
- pre-flight: md_extraction_quality 실제 shape 확인 ({score, metrics:{...}, warnings:[]})

Risks:
- feature/design-system worktree 가 [id]/+page.svelte 의 stale 버전 보유
  (main 보다 212 commits behind, MarkdownDoc 부재). 1C 머지 후 worktree
  머지 시 conflict 확정 — 그쪽 rebase 필요 (별건).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-05-02 15:38:45 +09:00
parent dfc5913c5e
commit d6e0f5de04
5 changed files with 222 additions and 33 deletions
+20
View File
@@ -180,5 +180,25 @@ body {
.markdown-doc .katex-display { overflow-x: auto; overflow-y: hidden; padding: 0.25em 0; }
.markdown-doc .katex-display > .katex { white-space: nowrap; }
/* Phase 1C: image placeholder — asset serving (1B.5) 도착 전까지 깨진 아이콘 회피.
원본 src 는 figure 의 data-md-image-src 에 보존되어 1B.5 selector 로 복원 가능. */
.markdown-doc .md-image-placeholder {
margin: 1em 0;
text-align: left;
}
.markdown-doc .md-image-placeholder-card {
display: inline-flex;
align-items: center;
gap: 0.5em;
padding: 0.5em 0.75em;
border: 1px dashed var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--text-dim);
font-size: 0.85em;
font-style: italic;
}
.markdown-doc .md-image-placeholder-icon { font-style: normal; opacity: 0.7; }
/* Phase 1C: frontmatter 박스 — 본문 위 메타 표시 */
.md-frontmatter dt { font-weight: 500; }
+9 -14
View File
@@ -10,13 +10,13 @@
* 기능:
* - md_frontmatter (JSONB) 가 비어있지 않으면 본문 위에 메타 박스
* - heading anchor (h1~h6 id 자동 부여 + # 링크)
* - figure caption (이미지 alt 있을 때)
* - 이미지는 placeholder card 로 렌더 (1B.5 ImgAuth wiring 전)
* - KaTeX ($...$ inline / $$...$$ block)
* - DOMPurify sanitize
*
* 이미지 ImgAuth wiring 은 Phase 1B.5 후 추가 — 현재는 marker 출력 그대로 (data-md-img="1" 마킹만).
* - md_status badge (processing/success/skipped/failed) — MarkdownStatusBadge 위임
*/
import { renderDocMarkdown } from '$lib/utils/docMarkdown';
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
type Props = {
mdContent?: string | null;
@@ -24,6 +24,7 @@
extractedText?: string | null;
mdStatus?: string | null;
mdExtractionError?: string | null;
mdExtractionQuality?: Record<string, unknown> | null;
placeholder?: string;
/** 추가 래퍼 클래스. tailwind prose-* / spacing 등을 호출 측에서 입혀야 할 때. */
class?: string;
@@ -35,6 +36,7 @@
extractedText = null,
mdStatus = null,
mdExtractionError = null,
mdExtractionQuality = null,
placeholder = '*텍스트 추출 대기 중*',
class: klass = '',
}: Props = $props();
@@ -56,8 +58,6 @@
);
});
let showFailureBadge = $derived(!usingMarkdown && mdStatus === 'failed');
function formatValue(v: unknown): string {
if (v === null || v === undefined) return '';
if (typeof v === 'object') {
@@ -95,6 +95,10 @@
});
</script>
<div class="mb-2">
<MarkdownStatusBadge {mdStatus} {mdExtractionError} {mdExtractionQuality} />
</div>
{#if frontmatterEntries.length}
<dl
class="md-frontmatter mb-4 grid grid-cols-[max-content,1fr] gap-x-3 gap-y-1 rounded border border-border/60 bg-bg/40 px-4 py-3 text-xs"
@@ -107,15 +111,6 @@
</dl>
{/if}
{#if showFailureBadge}
<div
class="mb-3 inline-flex items-center gap-2 rounded border border-warn/40 bg-warn/10 px-2 py-1 text-[10px] text-warn"
title={mdExtractionError ?? undefined}
>
Markdown 변환 실패 — 원본 추출 텍스트 표시
</div>
{/if}
<div
bind:this={containerRef}
class="markdown-body markdown-doc leading-relaxed {klass}"
@@ -0,0 +1,105 @@
<script lang="ts">
/**
* Phase 1C — md_status 표시 badge.
*
* MarkdownDoc 내부 + PDF iframe fallback 양쪽에서 재사용된다.
* `md_status='failed'` 같은 경우 MarkdownDoc 가 fallback 으로 빠지거나
* PDF 분기에서 canShowMarkdown=false 라 컴포넌트 자체가 안 렌더링되는데도,
* 사용자는 "왜 markdown 이 안 보이는지" 알 수 있어야 하므로 분리된 컴포넌트로 둠.
*
* 정책 (사용자 결정):
* - pending 은 표시 안 함 (legacy 9792 건에 모두 노출되는 시각적 노이즈 회피).
* - processing/success/skipped/failed 4 상태 표시.
* - success 도 작은 chip 으로 노출 — 1D pilot 에서 markdown 화면 식별용.
* - skipped/failed 는 tooltip 으로 reason/error 보조 표시.
*
* Quality summary tooltip:
* - md_extraction_quality 의 실제 shape: { score, metrics: {...}, warnings: [...] }
* - metrics 안의 numeric 키 일부만 추출. null/undefined 모두 방어.
* - 정식 quality chip 은 1D pilot 후 별도 결정.
*/
import Badge from '$lib/components/ui/Badge.svelte';
type Props = {
mdStatus?: string | null;
mdExtractionError?: string | null;
mdExtractionQuality?: Record<string, unknown> | null;
};
let {
mdStatus = null,
mdExtractionError = null,
mdExtractionQuality = null,
}: Props = $props();
function qualitySummary(q: Record<string, unknown> | null | undefined): string | null {
if (!q || typeof q !== 'object') return null;
// marker_worker 는 { score, metrics: {...}, warnings: [...] } 로 씀.
// metrics nested 가 정식 위치. flat 도 향후 호환 위해 fallback.
const rawMetrics = (q as { metrics?: unknown }).metrics;
const metrics: Record<string, unknown> =
rawMetrics && typeof rawMetrics === 'object' ? (rawMetrics as Record<string, unknown>) : q;
const parts: string[] = [];
const headings = metrics.markdown_heading_count;
if (typeof headings === 'number') parts.push(`headings: ${headings}`);
const tableRows = metrics.markdown_table_row_count;
if (typeof tableRows === 'number') parts.push(`table rows: ${tableRows}`);
const images = metrics.markdown_image_count;
if (typeof images === 'number') parts.push(`images: ${images}`);
const ratio = metrics.text_length_ratio ?? metrics.text_ratio;
if (typeof ratio === 'number' && Number.isFinite(ratio)) {
parts.push(`text ratio: ${ratio.toFixed(2)}`);
}
const warnings = (q as { warnings?: unknown }).warnings;
if (Array.isArray(warnings) && warnings.length) {
parts.push(`warnings: ${warnings.join(', ')}`);
}
return parts.length ? parts.join('\n') : null;
}
type BadgeSpec = {
tone: 'neutral' | 'success' | 'warning' | 'error' | 'accent';
label: string;
tooltip: string | null;
};
let badge = $derived.by<BadgeSpec | null>(() => {
if (!mdStatus || mdStatus === 'pending') return null;
switch (mdStatus) {
case 'processing':
return { tone: 'accent', label: 'Markdown 변환 중', tooltip: null };
case 'success':
return {
tone: 'success',
label: 'Markdown',
tooltip: qualitySummary(mdExtractionQuality),
};
case 'skipped':
return {
tone: 'neutral',
label: 'Markdown 제외',
tooltip: mdExtractionError ?? '표 중심 문서 등 변환 제외 정책 적용',
};
case 'failed':
return {
tone: 'error',
label: 'Markdown 변환 실패',
tooltip: mdExtractionError ?? null,
};
default:
return null;
}
});
</script>
{#if badge}
<Badge tone={badge.tone} title={badge.tooltip ?? undefined}>{badge.label}</Badge>
{/if}
+23 -14
View File
@@ -5,8 +5,8 @@
*
* 차이점:
* - GFM heading id (anchor 용 id 자동 부여, prefix=doc-)
* - 이미지 token 을 figure + figcaption 으로 감싸기 (alt 있을 때)
* - 모든 <img> 에 data-md-img="1" 마킹 — Phase 1B.5 에서 ImgAuth 후처리 selector 로 사용
* - 이미지는 placeholder card 로 렌더 (1B.5 ImgAuth wiring 전까지 깨진 아이콘 노출 방지).
* 원본 src 는 data-md-image-src 에 escape 되어 보존됨 — 1B.5 에서 selector 로 복원.
*
* KaTeX / DOMPurify 정책은 mathMarkdown.ts 의 정책과 동일.
*/
@@ -37,27 +37,36 @@ docMarked.use(
} as any),
);
// 이미지 → figure + figcaption (alt 가 있으면). 모든 img 에 data-md-img="1" 마킹.
// 이미지 → placeholder card. raw <img> 를 emit 하면 asset 미서빙 상태(1B.5 전)에선
// 깨진 아이콘이 보임 → 1D pilot 평가가 markdown 품질이 아닌 viewer 미완성 문제로 오염됨.
// 따라서 1C 에선 placeholder 로 렌더하되 원본 href 는 data-md-image-src 에 보존
// (1B.5 에서 ImgAuth selector 로 잡아 실제 <img> 로 교체 예정).
docMarked.use({
renderer: {
image(token: any): string {
const href = (token?.href ?? '') as string;
const text = (token?.text ?? '') as string;
const title = (token?.title ?? '') as string;
const titleAttr = title ? ` title="${escAttr(title)}"` : '';
const img = `<img src="${escAttr(href)}" alt="${escAttr(text)}"${titleAttr} loading="lazy" data-md-img="1" />`;
if (text) {
return `<figure class="md-figure">${img}<figcaption>${escText(text)}</figcaption></figure>`;
}
return img;
const rawHref = (token?.href ?? '') as string;
const rawAlt = (token?.text ?? '') as string;
const basename = rawHref.split('/').pop() ?? rawHref;
const labelSrc = rawAlt || basename || '이미지';
const safeHref = escAttr(rawHref);
const safeLabel = escText(`[이미지: ${labelSrc} — 아직 표시되지 않음]`);
return (
`<figure class="md-image-placeholder" data-md-img="1" data-md-image-src="${safeHref}">` +
`<div class="md-image-placeholder-card">` +
`<span class="md-image-placeholder-icon" aria-hidden="true">🖼️</span>` +
`<span class="md-image-placeholder-label">${safeLabel}</span>` +
`</div>` +
`</figure>`
);
},
},
});
const SANITIZE_OPTS = {
USE_PROFILES: { html: true },
// KaTeX (style + aria-hidden), heading anchor (id), 이미지 마킹 (data-md-img), figure caption (figure/figcaption)
ADD_ATTR: ['style', 'aria-hidden', 'id', 'data-md-img', 'loading'],
// KaTeX (style + aria-hidden), heading anchor (id), 이미지 마킹 (data-md-img,
// data-md-image-src — 1B.5 ImgAuth selector 용), figure caption (figure/figcaption)
ADD_ATTR: ['style', 'aria-hidden', 'id', 'data-md-img', 'data-md-image-src', 'loading'],
ADD_TAGS: ['figure', 'figcaption'],
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'link', 'meta'],
FORBID_ATTR: ['onerror', 'onclick', 'onload', 'onmouseover', 'onfocus'],
@@ -16,6 +16,7 @@
import Skeleton from '$lib/components/ui/Skeleton.svelte';
import HandwriteCanvas from '$lib/components/HandwriteCanvas.svelte';
import MarkdownDoc from '$lib/components/MarkdownDoc.svelte';
import MarkdownStatusBadge from '$lib/components/MarkdownStatusBadge.svelte';
import NoteEditor from '$lib/components/editors/NoteEditor.svelte';
import EditUrlEditor from '$lib/components/editors/EditUrlEditor.svelte';
import TagsEditor from '$lib/components/editors/TagsEditor.svelte';
@@ -122,6 +123,28 @@
doc ? (doc.source_channel === 'news' ? 'article' : getViewerType(doc.file_format)) : 'none'
);
// PDF 분기 전용: marker_worker 가 만든 canonical markdown 이 있으면 기본으로 그것을 보여줌.
// Phase 1B 산출물의 95% 가 PDF 라 1D pilot 평가가 실사용 화면 기반이 되도록 markdown-first.
// 사용자가 "PDF 원본" 토글하면 iframe. lastDocId 로 문서 전환만 감지해서 사용자 토글이
// reactive cycle 에 덮이지 않도록 보호.
let pdfViewMode = $state('markdown'); // 'markdown' | 'pdf'
let lastDocId = $state(null);
let canShowMarkdown = $derived(
!!(doc?.md_status === 'success' && doc?.md_content?.trim())
);
$effect(() => {
if (!doc) return;
if (doc.id !== lastDocId) {
lastDocId = doc.id;
pdfViewMode = canShowMarkdown ? 'markdown' : 'pdf';
}
// 같은 문서 안에서 markdown 이 사라지면 (success → failed 재처리 등) PDF 로 보호.
if (!canShowMarkdown && pdfViewMode === 'markdown') {
pdfViewMode = 'pdf';
}
});
function getViewerType(format) {
if (['md', 'txt', 'csv', 'html'].includes(format)) return 'markdown';
if (format === 'pdf') return 'pdf';
@@ -230,15 +253,51 @@
mdFrontmatter={doc.md_frontmatter}
mdStatus={doc.md_status}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
extractedText={doc.extracted_text || rawMarkdown}
class="prose prose-invert prose-base lg:prose-sm max-w-none"
/>
{:else if viewerType === 'pdf'}
<iframe
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
class="w-full h-[80vh] rounded"
title={doc.title}
></iframe>
<div class="mb-2 flex items-center gap-2">
<MarkdownStatusBadge
mdStatus={doc.md_status}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
/>
{#if canShowMarkdown}
<Button
size="sm"
variant={pdfViewMode === 'markdown' ? 'primary' : 'secondary'}
onclick={() => (pdfViewMode = 'markdown')}
>
Markdown
</Button>
<Button
size="sm"
variant={pdfViewMode === 'pdf' ? 'primary' : 'secondary'}
onclick={() => (pdfViewMode = 'pdf')}
>
PDF 원본
</Button>
{/if}
</div>
{#if pdfViewMode === 'markdown' && canShowMarkdown}
<MarkdownDoc
mdContent={doc.md_content}
mdFrontmatter={doc.md_frontmatter}
mdStatus={doc.md_status}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
extractedText={doc.extracted_text}
class="prose prose-invert prose-base lg:prose-sm max-w-none"
/>
{:else}
<iframe
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
class="w-full h-[80vh] rounded"
title={doc.title}
></iframe>
{/if}
{:else if viewerType === 'image'}
<img
src="/api/documents/{doc.id}/file?token={getAccessToken()}"
@@ -280,6 +339,7 @@
mdFrontmatter={doc.md_frontmatter}
mdStatus={doc.md_status}
mdExtractionError={doc.md_extraction_error}
mdExtractionQuality={doc.md_extraction_quality}
extractedText={doc.extracted_text}
class="mb-6"
/>