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:
@@ -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; }
|
||||
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user