feat: 문서 탐색기 PDF 내보내기 (브라우저 인쇄 → PDF로 저장)

마크다운/텍스트 뷰어 툴바에 🖨 PDF 버튼 추가. 렌더된 결과(KaTeX·mermaid
SVG·코드강조·GFM 표)를 깨끗한 새 창에 담아 print() → 인쇄창에서 "PDF로 저장".

- 폐쇄망 OK: 외부 리소스 없이 /lib 로컬 CSS만 사용 (KaTeX 폰트도 로컬)
- 한글: 인쇄 본문 폰트 맑은 고딕 지정 → 윈도우에서 깨짐 없음
- 실제 텍스트(검색·복사 가능), 페이지 잘림 방지(break-inside) 적용

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-24 07:08:15 +09:00
parent 3556739a3e
commit 24478b0ccf

View File

@@ -264,6 +264,73 @@ function docsViewUnsupported(path, ext) {
</div>`;
}
/* ── PDF 내보내기 (브라우저 인쇄 → "PDF로 저장") ──────────────
이미 렌더된 결과(md=marked/KaTeX/mermaid SVG/코드강조, txt=pre)를
깨끗한 새 창에 담아 print(). 폐쇄망 OK — 외부 리소스 없이 /lib 로컬 CSS만 사용. */
const DOCS_PRINT_CSS = `
*{box-sizing:border-box}
body{font:15px/1.7 'Malgun Gothic','Noto Sans KR',sans-serif;color:#24292f;max-width:900px;margin:0 auto;padding:24px}
h1,h2,h3,h4,h5,h6{font-weight:700;line-height:1.3;margin:24px 0 12px;color:#1f2328}
h1{font-size:1.9em;border-bottom:1px solid #d8dee4;padding-bottom:.3em}
h2{font-size:1.5em;border-bottom:1px solid #d8dee4;padding-bottom:.3em}
h3{font-size:1.25em}h4{font-size:1.05em}
p{margin:0 0 12px}a{color:#0969da;text-decoration:none}
ul,ol{margin:0 0 12px;padding-left:2em}li{margin:3px 0}
blockquote{margin:0 0 12px;padding:2px 16px;color:#57606a;border-left:4px solid #d0d7de}
hr{border:0;border-top:1px solid #d8dee4;margin:20px 0}
img{max-width:100%}
code{font-family:'D2Coding','Consolas',monospace;font-size:85%;background:rgba(175,184,193,.2);padding:.2em .4em;border-radius:6px}
pre{background:#f6f8fa;border:1px solid #e4e8ec;border-radius:8px;padding:14px;overflow:auto;line-height:1.5}
pre code{background:none;padding:0;font-size:13px}
table{border-collapse:collapse;margin:0 0 14px;max-width:100%}
th,td{border:1px solid #d0d7de;padding:6px 13px}th{background:#f6f8fa}
tr:nth-child(2n) td{background:#f6f8fa}
.mermaid{text-align:center;margin:0 0 14px}.mermaid svg{max-width:100%;height:auto}
pre.txt{white-space:pre-wrap;word-break:break-word;font-family:'D2Coding','Consolas',monospace}
@page{margin:15mm}
@media print{body{max-width:none;padding:0}pre,table,img,.mermaid,blockquote{break-inside:avoid}h1,h2,h3,h4{break-after:avoid}}
`;
function docsExportPdf() {
const f = docsState.curFile;
if (!f) return;
const isMd = DOCS_MD_EXT.includes(f.ext);
const isTxt = DOCS_TEXT_EXT.includes(f.ext);
if (!isMd && !isTxt) return;
// 파일명(확장자 제거) → PDF 기본 파일명으로 사용됨
const base = (f.path.split('/').pop() || f.path).replace(/\.[^.]+$/, '');
let contentHtml = '';
let cssLinks = '';
if (isMd) {
const body = document.querySelector('#docs-viewer .md-body');
if (!body) { alert('먼저 문서를 연 뒤 시도하세요.'); return; }
contentHtml = `<div class="md-body">${body.innerHTML}</div>`;
// 코드강조/수식 스타일 (로컬 /lib). KaTeX 폰트는 이 CSS의 상대경로로 로컬 로드됨
cssLinks =
'<link rel="stylesheet" href="/lib/highlight-github.min.css">' +
'<link rel="stylesheet" href="/lib/katex/katex.min.css">';
} else {
const pre = document.querySelector('#docs-viewer .docs-text-pre');
contentHtml = `<pre class="txt">${esc(pre ? pre.textContent : '')}</pre>`;
}
const w = window.open('', '_blank');
if (!w) { alert('팝업이 차단되었습니다. 팝업을 허용한 뒤 다시 시도하세요.'); return; }
w.document.write(
`<!doctype html><html lang="ko"><head><meta charset="utf-8">` +
`<title>${esc(base)}</title>${cssLinks}<style>${DOCS_PRINT_CSS}</style></head>` +
`<body>${contentHtml}` +
`<script>window.addEventListener('load',function(){` +
`(document.fonts&&document.fonts.ready?document.fonts.ready:Promise.resolve())` +
`.then(function(){setTimeout(function(){window.focus();window.print();},100);});});` +
`window.onafterprint=function(){window.close();};<\/script>` +
`</body></html>`
);
w.document.close();
}
/* ── 마크다운 렌더 파이프라인 ──────────────────────────────── */
function docsRenderMarkdownInto(el, text) {
const rawHtml = marked.parse(text, { gfm: true, breaks: false });
@@ -385,6 +452,7 @@ function docsRenderViewerActions() {
h += `<button class="btn-b btn-sm" onclick="docsEditCancel()">취소</button>`;
} else {
if (isText && docsState.canManage) h += `<button class="btn-b btn-sm" onclick="docsEditStart()">✎ 편집</button>`;
if (isText) h += `<button class="btn-b btn-sm" onclick="docsExportPdf()" title="인쇄 창에서 'PDF로 저장' 선택">🖨 PDF</button>`;
h += `<a class="btn-b btn-sm" href="/api/docs/raw?path=${encodeURIComponent(f.path)}&download=true">⬇ 다운로드</a>`;
}
host.innerHTML = h;