From d6e0f5de04b149b86d255081d8771998d05c19f0 Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Sat, 2 May 2026 15:38:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=201C=20=E2=80=94=20mark?= =?UTF-8?q?down=20viewer=20=EC=99=84=EC=84=B1=20(PDF=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20+=20status=20badge=20+=20image=20placeholder)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 를 emit 하면 깨진 아이콘 → 1D pilot 평가가 markdown 품질이 아닌 viewer 미완성 문제로 오염됨. - href / alt / basename 모두 escape 후 figure.md-image-placeholder 로 렌더. - 원본 src 는 data-md-image-src 에 escape 보존 → 1B.5 ImgAuth selector 로 실제 로 교체할 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// 저장 + 서빙 - 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) --- frontend/src/app.css | 20 ++++ .../src/lib/components/MarkdownDoc.svelte | 23 ++-- .../lib/components/MarkdownStatusBadge.svelte | 105 ++++++++++++++++++ frontend/src/lib/utils/docMarkdown.ts | 37 +++--- .../src/routes/documents/[id]/+page.svelte | 70 +++++++++++- 5 files changed, 222 insertions(+), 33 deletions(-) create mode 100644 frontend/src/lib/components/MarkdownStatusBadge.svelte diff --git a/frontend/src/app.css b/frontend/src/app.css index 267f62d..0a8d43e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; } diff --git a/frontend/src/lib/components/MarkdownDoc.svelte b/frontend/src/lib/components/MarkdownDoc.svelte index 2740c74..4df2af3 100644 --- a/frontend/src/lib/components/MarkdownDoc.svelte +++ b/frontend/src/lib/components/MarkdownDoc.svelte @@ -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 | 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 @@ }); +
+ +
+ {#if frontmatterEntries.length}
{/if} -{#if showFailureBadge} -
- Markdown 변환 실패 — 원본 추출 텍스트 표시 -
-{/if} -
+ /** + * 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 | null; + }; + + let { + mdStatus = null, + mdExtractionError = null, + mdExtractionQuality = null, + }: Props = $props(); + + function qualitySummary(q: Record | 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 = + rawMetrics && typeof rawMetrics === 'object' ? (rawMetrics as Record) : 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(() => { + 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; + } + }); + + +{#if badge} + {badge.label} +{/if} diff --git a/frontend/src/lib/utils/docMarkdown.ts b/frontend/src/lib/utils/docMarkdown.ts index b4e4839..39b213c 100644 --- a/frontend/src/lib/utils/docMarkdown.ts +++ b/frontend/src/lib/utils/docMarkdown.ts @@ -5,8 +5,8 @@ * * 차이점: * - GFM heading id (anchor 용 id 자동 부여, prefix=doc-) - * - 이미지 token 을 figure + figcaption 으로 감싸기 (alt 있을 때) - * - 모든 에 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 를 emit 하면 asset 미서빙 상태(1B.5 전)에선 +// 깨진 아이콘이 보임 → 1D pilot 평가가 markdown 품질이 아닌 viewer 미완성 문제로 오염됨. +// 따라서 1C 에선 placeholder 로 렌더하되 원본 href 는 data-md-image-src 에 보존 +// (1B.5 에서 ImgAuth selector 로 잡아 실제 로 교체 예정). 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 = `${escAttr(text)}`; - if (text) { - return `
${img}
${escText(text)}
`; - } - 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 ( + `
` + + `
` + + `` + + `${safeLabel}` + + `
` + + `
` + ); }, }, }); 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'], diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index 985d852..3b950e3 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -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'} - +
+ + {#if canShowMarkdown} + + + {/if} +
+ {#if pdfViewMode === 'markdown' && canShowMarkdown} + + {:else} + + {/if} {:else if viewerType === 'image'}