문서 스튜디오 아이콘

문서 스튜디오

텍스트, 오피스, PDF, HWP/HWPX, OLE 문서를 한 화면에서 열고 편집 가능한 표현으로 바꿔 다시 저장하는 작업실입니다.

브라우저 내부 처리 · 보기 + 편집 + 변환 · 가능한 형식으로 다시 저장

파일 열기

문서를 브라우저에서 바로 열고 가능한 표현으로 편집합니다.

여기로 파일을 끌어다 놓으셔도 됩니다.
대표 형식: .txt .md .csv .xls .xlsx .docx .pptx .pdf .hwp .hwpx .doc .ppt .msg .json .xml .html
직접 원본 편집이 어려운 형식은 추출된 표현으로 바꿔 가능한 형식으로 다시 저장합니다.

뷰어

원본, 바로 수정, 구조 JSON 중 필요한 보기로 전환합니다.

1 / 1
바로 수정 보기에서는 보이는 문서를 직접 눌러 편집하실 수 있습니다.
열린 문서가 없습니다.

현재 문서 상태

현재 파일의 감지 결과와 가능한 작업을 요약합니다.

파일명
없음
감지 형식
없음
현재 표현
없음
파일 크기
0 B
내용 통계
없음
현재 변환 가능
없음
원본 보기
없음
처리 메모
파일을 열면 채워집니다.
📘 기본 안내
  • 텍스트 / 코드 / 마크다운: 직접 편집하고 txt/md/html/docx/pdf 등 가능한 형식으로 저장합니다.
  • 스프레드시트: XLS, XLSX, CSV, TSV, ODS 계열을 셀 단위로 편집하고 xlsx/csv/tsv/html/json으로 변환합니다.
  • 오피스 ZIP 문서: DOCX, PPTX, ODT, ODP, EPUB, PAGES류는 원본 보기와 추출 편집 표현을 함께 제공합니다.
  • PDF: 원본 페이지 렌더와 추출 텍스트 편집을 함께 제공합니다.
  • 한글 문서: HWP는 라이브러리 기반 파싱, HWPX는 ZIP+XML 직접 파싱으로 HTML/Markdown/텍스트 표현을 만듭니다.
  • 구형 OLE 문서: DOC, PPT, MSG, PUB류는 구조와 추출 문자열을 보여주고 편집 가능한 표현으로 연결합니다.
한계와 의도
  • 복합 문서: 원본 그대로 수정은 형식 특성상 제한됩니다. PDF, HWP, DOCX 같은 형식은 추출된 표현을 수정한 뒤 가능한 형식으로 다시 저장하는 방식입니다.
  • 처리 계층: 형식별 처리 계층이 다르므로, 가능한 경우는 원본 렌더, 어려운 경우는 추출 편집, 마지막에는 구조/문자열 추출까지 내려갑니다.
  • 네트워크: 차단 환경에서는 CDN 기반 처리기가 로드되지 않을 수 있으며, 이때는 텍스트 또는 기본 미리보기 중심으로 자동 하향합니다.
사용한 GitHub 기반 라이브러리
  • PDF / 표: pdf.js와 SheetJS로 PDF 렌더와 텍스트 추출, XLS/XLSX/CSV/TSV 읽기와 저장을 처리합니다.
  • DOCX: Mammoth.js와 docx-preview로 DOCX 추출 편집 표현과 원본 레이아웃 보기를 처리합니다.
  • HWP / OLE: @ohah/hwpjs와 CFB로 HWP 파싱과 구형 OLE 문서 구조 읽기를 처리합니다.
  • 편집기: Monaco Editor로 텍스트 계열 편집기를 구성합니다.
  • 렌더 / 변환: markdown-it, DOMPurify, Turndown, html-docx-js, html2pdf.js로 렌더, 정화, Markdown 변환, DOCX/PDF 생성을 처리합니다.
DU0 TU0
D26 T51
`, 'text/html'); return (doc.body.textContent || '').replace(/\u00A0/g, ' ').replace(/\n{3,}/g, '\n\n').trim(); } // ----------[🧩 sanitizeHtml 처리]---------- function sanitizeHtml(html) { return window.DOMPurify.sanitize(html, { ADD_ATTR: ['style', 'class', 'colspan', 'rowspan'], ADD_TAGS: ['style'] }); } // ----------[🧩 formatBytes 처리]---------- function formatBytes(bytes) { const value = Number(bytes) || 0; if (value < 1024) { return `${value} B`; } const units = ['KB', 'MB', 'GB', 'TB']; let size = value / 1024; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex += 1; } return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)} ${units[unitIndex]}`; } // ----------[🧩 normalizeBaseName 처리]---------- function normalizeBaseName(fileName) { const trimmed = String(fileName || '').trim(); const withoutExt = trimmed.replace(/\.[^.]+$/, ''); const cleaned = withoutExt.replace(/[\\/:*?"<>|]+/g, ' ').trim().replace(/\s+/g, ' '); return cleaned !== '' ? cleaned : 'document'; } // ----------[🧩 getExtension 처리]---------- function getExtension(fileName) { const trimmed = String(fileName || '').trim(); const lastDot = trimmed.lastIndexOf('.'); if (lastDot < 0) { return ''; } return trimmed.slice(lastDot + 1).toLowerCase(); } // ----------[🧩 detectCategory 처리]---------- function detectCategory(ext, file) { if (spreadsheetExtensions.has(ext)) { return 'spreadsheet'; } if (ext === 'docx') { return 'docx'; } if (archiveExtensions.has(ext)) { return 'archive'; } if (oleExtensions.has(ext)) { return 'ole'; } if (ext === 'pdf') { return 'pdf'; } if (ext === 'hwp') { return 'hwp'; } if (ext === 'hwpx') { return 'hwpx'; } if (textExtensions.has(ext)) { if (ext === 'md' || ext === 'markdown') { return 'markdown'; } if (ext === 'html' || ext === 'htm' || ext === 'mht' || ext === 'mhtml') { return 'html'; } return 'text'; } if (file && typeof file.type === 'string' && file.type === 'application/epub+zip') { return 'archive'; } if (file && typeof file.type === 'string' && file.type.startsWith('text/')) { return 'text'; } return 'binary'; } // ----------[🧩 getMonacoLanguage 처리]---------- function getMonacoLanguage(kind, ext) { if (kind === 'html') { return 'html'; } if (kind === 'markdown') { return 'markdown'; } if (kind === 'json') { return 'json'; } return monacoLanguageByExtension[ext] || 'plaintext'; } // ----------[🧩 ensureMonaco 처리]---------- async function ensureMonaco() { if (state.editor) { return state.editor; } if (!monacoReadyPromise) { monacoReadyPromise = new Promise((resolve, reject) => { if (!window.require) { reject(new Error('Monaco 로더를 찾을 수 없습니다.')); return; } window.require.config({ paths: { vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs' } }); window.require(['vs/editor/editor.main'], () => { resolve(window.monaco); }, reject); }); } state.monaco = await monacoReadyPromise; state.editor = state.monaco.editor.create(refs.monacoMount, { value: '', language: 'plaintext', theme: 'vs-dark', automaticLayout: true, wordWrap: 'on', minimap: { enabled: false }, fontSize: 14, lineHeight: 22, scrollBeyondLastLine: false, tabSize: 4, insertSpaces: true }); state.editor.onDidChangeModelContent(() => { if (state.category === 'spreadsheet' || state.isProgrammaticEditorUpdate) { return; } syncEditorToState(); }); return state.editor; } // ----------[🧩 loadPdfjs 처리]---------- async function loadPdfjs() { if (!pdfjsLibPromise) { pdfjsLibPromise = import('https://cdn.jsdelivr.net/npm/pdfjs-dist@5.6.205/build/pdf.min.mjs'); } const pdfjsLib = await pdfjsLibPromise; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@5.6.205/build/pdf.worker.min.mjs'; return pdfjsLib; } // ----------[🧩 loadDocxPreview 처리]---------- async function loadDocxPreview() { if (!docxPreviewPromise) { docxPreviewPromise = import('https://cdn.jsdelivr.net/npm/docx-preview@0.3.7/dist/docx-preview.min.mjs'); } return docxPreviewPromise; } // ----------[🧩 loadHwpjs 처리]---------- async function loadHwpjs() { if (!hwpjsPromise) { hwpjsPromise = import('https://cdn.jsdelivr.net/npm/@ohah/hwpjs@0.1.0-rc.10/+esm'); } return hwpjsPromise; } // ----------[🧩 buildTextHtml 처리]---------- function buildTextHtml(text) { return `
${escapeHtml(text)}
`; } // ----------[🧩 wrapHtmlDocument 처리]---------- function wrapHtmlDocument(title, bodyHtml) { return ` ${escapeHtml(title)} ${bodyHtml} `; } // ----------[🧩 markdownToHtml 처리]---------- function markdownToHtml(markdown) { const rendered = markdownRenderer.render(markdown || ''); return sanitizeHtml(rendered); } // ----------[🧩 htmlToMarkdown 처리]---------- function htmlToMarkdown(html) { try { return turndownService.turndown(html || '').trim(); } catch (error) { console.warn(error); return stripHtmlToText(html || ''); } } // ----------[🧩 getJsonRepresentationText 처리]---------- function getJsonRepresentationText() { return typeof state.representations.json === 'string' ? state.representations.json : JSON.stringify(state.representations.json ?? {}, null, 2); } // ----------[🧩 updateTextLikeRepresentationsFromKind 처리]---------- function updateTextLikeRepresentationsFromKind(kind, value) { const nextValue = String(value ?? ''); if (kind === 'text') { state.representations.text = nextValue; state.representations.markdown = nextValue; state.representations.html = buildTextHtml(nextValue); return; } if (kind === 'markdown') { const html = markdownToHtml(nextValue); state.representations.markdown = nextValue; state.representations.html = html; state.representations.text = stripHtmlToText(html); return; } if (kind === 'html') { const html = sanitizeHtml(nextValue); state.representations.html = html; state.representations.markdown = htmlToMarkdown(html); state.representations.text = stripHtmlToText(html); return; } if (kind === 'json') { try { state.representations.json = JSON.parse(nextValue); } catch (_) { state.representations.json = nextValue; } const jsonText = getJsonRepresentationText(); state.representations.text = jsonText; state.representations.markdown = '```json\n' + jsonText + '\n```'; state.representations.html = buildTextHtml(jsonText); } } // ----------[🧩 syncHiddenEditorFromState 처리]---------- function syncHiddenEditorFromState() { if (state.category === 'spreadsheet' || !state.editor) { return; } setEditorValue( getRepresentationValueForEditor(state.editorKind), getMonacoLanguage(state.editorKind, state.ext) ); } // ----------[🧩 getEditablePlainText 처리]---------- function getEditablePlainText(element) { return String(element?.textContent || '').replace(/\u00A0/g, ' '); } // ----------[🧩 syncViewerSurfaceToState 처리]---------- function syncViewerSurfaceToState() { if (state.category === 'hwp' && !refs.viewerFrame.hidden) { syncHwpFrameToState(); return; } if (state.category === 'spreadsheet' && state.previewMode !== 'working') { return; } if (!refs.jsonView.hidden && refs.jsonViewText.getAttribute('contenteditable')) { updateTextLikeRepresentationsFromKind('json', getEditablePlainText(refs.jsonViewText)); syncHiddenEditorFromState(); updateSummary(); scheduleDraftSave(); return; } if (!refs.fallbackView.hidden && refs.fallbackViewText.getAttribute('contenteditable')) { updateTextLikeRepresentationsFromKind('text', getEditablePlainText(refs.fallbackViewText)); syncHiddenEditorFromState(); updateSummary(); scheduleDraftSave(); return; } const htmlViewContentElement = getHtmlViewContentElement(); if (!refs.htmlView.hidden && htmlViewContentElement.getAttribute('contenteditable')) { if (state.category === 'docx' && state.editorKind === 'html') { state.docxWorkingHtml = getHtmlViewDocumentHtml(); } updateTextLikeRepresentationsFromKind('html', getHtmlViewDocumentHtml()); if (state.editorKind === 'markdown') { state.representations.markdown = htmlToMarkdown(state.representations.html); } syncHiddenEditorFromState(); updateSummary(); scheduleDraftSave(); } } // ----------[🧩 syncHwpFrameToState 처리]---------- function syncHwpFrameToState() { const frameDocument = refs.viewerFrame.contentDocument; if (!frameDocument || !frameDocument.documentElement) { return; } state.hwpRawHtml = frameDocument.documentElement.outerHTML; const bodyHtml = frameDocument.body ? frameDocument.body.innerHTML : ''; const bodyText = frameDocument.body ? (frameDocument.body.innerText || '').trim() : ''; state.representations.html = bodyHtml || state.representations.html; state.representations.text = bodyText || state.representations.text; if (state.representations.markdown.trim() === '') { state.representations.markdown = bodyText || state.representations.text; } updateSummary(); scheduleDraftSave(); } // ----------[🧩 updateViewerViewportHeight 처리]---------- function updateViewerViewportHeight() { if (!refs.viewerStage || !refs.viewerViewport) { return; } const rect = refs.viewerStage.getBoundingClientRect(); const available = Math.max(420, Math.floor(window.innerHeight - rect.top - 18)); refs.viewerViewport.style.height = `${available}px`; } // ----------[🧩 clampViewerZoom 처리]---------- function clampViewerZoom(value) { const numeric = Number(value) || 1; return Math.min(4, Math.max(0.35, numeric)); } // ----------[🧩 resetViewerZoomState 처리]---------- function resetViewerZoomState() { state.viewerZoom = 1; state.viewerPageIndex = 0; state.viewerPageCount = 1; updateViewerPageUi(); } // ----------[🧩 updateViewerPageUi 처리]---------- function updateViewerPageUi() { const pageCount = Math.max(1, state.viewerPageCount || 1); const pageIndex = Math.min(pageCount - 1, Math.max(0, state.viewerPageIndex || 0)); refs.viewerPageIndicator.textContent = `${pageIndex + 1} / ${pageCount}`; refs.viewerPagePrevButton.disabled = pageCount <= 1 || pageIndex <= 0; refs.viewerPageNextButton.disabled = pageCount <= 1 || pageIndex >= pageCount - 1; refs.viewerZoomResetButton.textContent = `${Math.round(clampViewerZoom(state.viewerZoom) * 100)}%`; } // ----------[🧩 getHtmlViewContentElement 처리]---------- function getHtmlViewContentElement() { return refs.htmlView.querySelector('.viewerSinglePage') || refs.htmlView; } // ----------[🧩 setHtmlViewContent 처리]---------- function setHtmlViewContent(html, editable = false) { refs.htmlView.removeAttribute('contenteditable'); const page = document.createElement('div'); page.className = 'viewerSinglePage'; if (editable) { page.setAttribute('contenteditable', 'true'); } page.innerHTML = html; refs.htmlView.innerHTML = ''; refs.htmlView.appendChild(page); } // ----------[🧩 getHtmlViewDocumentHtml 처리]---------- function getHtmlViewDocumentHtml() { return getHtmlViewContentElement().innerHTML; } // ----------[🧩 getDocxPageElements 처리]---------- function getDocxPageElements(root) { const result = []; const seen = new Set(); const selectors = ['.docx-wrapper > section', '.docx > section', 'section.docx']; for (const selector of selectors) { for (const element of root.querySelectorAll(selector)) { if (!seen.has(element)) { seen.add(element); result.push(element); } } } return result; } // ----------[🧩 getActiveViewerPagingContext 처리]---------- function getActiveViewerPagingContext() { if (!refs.viewerFrame.hidden) { const frameDocument = refs.viewerFrame.contentDocument; const frameBody = frameDocument?.body || null; if (!frameBody) { return null; } const pages = Array.from(frameDocument.querySelectorAll('.hpa')); return { kind: 'frame', viewport: refs.viewerViewport, container: frameBody, pages: pages.length > 0 ? pages : [frameBody], document: frameDocument }; } if (!refs.pdfView.hidden) { return { kind: 'pdf', viewport: refs.viewerViewport, container: refs.pdfPages, pages: Array.from(refs.pdfPages.children), document }; } if (!refs.docxOriginalView.hidden) { const pages = getDocxPageElements(refs.docxOriginalView); return { kind: 'docx-original', viewport: refs.viewerViewport, container: refs.docxOriginalView, pages: pages.length > 0 ? pages : [refs.docxOriginalView.firstElementChild || refs.docxOriginalView], document }; } if (!refs.htmlView.hidden) { const docxPages = getDocxPageElements(refs.htmlView); return { kind: 'html', viewport: refs.viewerViewport, container: refs.htmlView, pages: docxPages.length > 0 ? docxPages : [getHtmlViewContentElement()], document }; } if (!refs.jsonView.hidden) { return { kind: 'json', viewport: refs.viewerViewport, container: refs.jsonView, pages: [refs.jsonViewText], document }; } if (!refs.fallbackView.hidden) { return { kind: 'fallback', viewport: refs.viewerViewport, container: refs.fallbackView, pages: [refs.fallbackViewText], document }; } return null; } // ----------[🧩 setViewerPageVisibility 처리]---------- function setViewerPageVisibility(page, visible) { if (!page || page.tagName === 'BODY') { return; } if (!Object.prototype.hasOwnProperty.call(page.dataset, 'viewerDisplay')) { page.dataset.viewerDisplay = page.style.display || ''; } page.style.display = visible ? page.dataset.viewerDisplay : 'none'; } // ----------[🧩 measureViewerPage 처리]---------- function measureViewerPage(page) { if (!page) { return { width: 1, height: 1 }; } const previousZoom = page.style.zoom || ''; page.style.zoom = '1'; const rect = page.getBoundingClientRect(); const width = Math.max(1, Math.ceil(page.scrollWidth || rect.width || 1)); const height = Math.max(1, Math.ceil(page.scrollHeight || rect.height || 1)); page.style.zoom = previousZoom; return { width, height }; } // ----------[🧩 styleViewerContainer 처리]---------- function styleViewerContainer(context) { if (!context || !context.container) { return; } if (context.kind === 'frame') { const frameDocument = context.document; if (!frameDocument) { return; } let helperStyle = frameDocument.getElementById('documentStudioFrameStyle'); if (!helperStyle) { helperStyle = frameDocument.createElement('style'); helperStyle.id = 'documentStudioFrameStyle'; frameDocument.head.appendChild(helperStyle); } helperStyle.textContent = ` html, body { width: 100%; height: 100%; margin: 0 !important; overflow: hidden !important; background: #ffffff; } body { display: grid; place-items: center; } .hpa { margin: 0 !important; box-shadow: none !important; } `; return; } context.container.style.display = 'grid'; context.container.style.placeItems = 'center'; context.container.style.width = '100%'; context.container.style.height = '100%'; context.container.style.overflow = 'hidden'; } // ----------[🧩 applyViewerPageLayout 처리]---------- function applyViewerPageLayout() { updateViewerViewportHeight(); const context = getActiveViewerPagingContext(); if (!context || !context.viewport) { state.viewerPageCount = 1; state.viewerPageIndex = 0; updateViewerPageUi(); return; } styleViewerContainer(context); const pages = context.pages.filter(Boolean); state.viewerPageCount = Math.max(1, pages.length); if (state.viewerPageIndex >= state.viewerPageCount) { state.viewerPageIndex = state.viewerPageCount - 1; } if (state.viewerPageIndex < 0) { state.viewerPageIndex = 0; } pages.forEach((page, index) => { setViewerPageVisibility(page, index === state.viewerPageIndex); if (page && page.style) { page.style.zoom = '1'; page.style.margin = '0'; } }); const activePage = pages[state.viewerPageIndex]; if (!activePage) { updateViewerPageUi(); return; } const { width, height } = measureViewerPage(activePage); const availableWidth = Math.max(120, context.viewport.clientWidth - 28); const availableHeight = Math.max(120, context.viewport.clientHeight - 28); const fitScale = Math.min(availableWidth / width, availableHeight / height); const appliedZoom = Math.max(0.05, fitScale * clampViewerZoom(state.viewerZoom)); activePage.style.zoom = String(appliedZoom); updateViewerPageUi(); } // ----------[🧩 setViewerZoom 처리]---------- function setViewerZoom(value) { state.viewerZoom = clampViewerZoom(value); applyViewerPageLayout(); } // ----------[🧩 changeViewerZoom 처리]---------- function changeViewerZoom(step) { setViewerZoom(state.viewerZoom + step); } // ----------[🧩 resetViewerZoom 처리]---------- function resetViewerZoom() { setViewerZoom(1); } // ----------[🧩 changeViewerPage 처리]---------- function changeViewerPage(step) { state.viewerPageIndex = Math.max(0, Math.min(Math.max(0, state.viewerPageCount - 1), state.viewerPageIndex + step)); applyViewerPageLayout(); } // ----------[🧩 handleViewerWheelZoom 처리]---------- function handleViewerWheelZoom(event) { if (!event.ctrlKey) { return; } event.preventDefault(); if (state.category === 'empty') { return; } changeViewerZoom(event.deltaY < 0 ? 0.1 : -0.1); } // ----------[🧩 handleViewerMiddleClickReset 처리]---------- function handleViewerMiddleClickReset(event) { if (!event.ctrlKey || event.button !== 1) { return; } event.preventDefault(); if (state.category === 'empty') { return; } resetViewerZoom(); } // ----------[🧩 bindViewerFrameShortcuts 처리]---------- function bindViewerFrameShortcuts(frameDocument) { if (!frameDocument) { return; } if (frameDocument.body) { frameDocument.body.onwheel = handleViewerWheelZoom; frameDocument.body.onmousedown = handleViewerMiddleClickReset; return; } frameDocument.onwheel = handleViewerWheelZoom; frameDocument.onmousedown = handleViewerMiddleClickReset; } // ----------[🧩 getCurrentEditorText 처리]---------- function getCurrentEditorText() { if (state.category === 'spreadsheet') { return ''; } return state.editor ? state.editor.getValue() : getRepresentationValueForEditor(state.editorKind); } // ----------[🧩 syncEditorToState 처리]---------- function syncEditorToState() { updateTextLikeRepresentationsFromKind(state.editorKind, getCurrentEditorText()); updateSummary(); scheduleDraftSave(); } // ----------[🧩 setEditorValue 처리]---------- function setEditorValue(value, language) { if (!state.editor || !state.monaco) { return; } const model = state.editor.getModel(); if (model) { state.monaco.editor.setModelLanguage(model, language); } state.isProgrammaticEditorUpdate = true; try { state.editor.setValue(value); } finally { state.isProgrammaticEditorUpdate = false; } } // ----------[🧩 setEditorKind 처리]---------- function setEditorKind(kind, syncSelect = true) { state.editorKind = kind; if (syncSelect) { refs.editorKindSelect.value = kind; } if (kind === 'spreadsheet') { renderSheetTabs(); renderSheetTable(); } else { const editorValue = getRepresentationValueForEditor(kind); setEditorValue(editorValue, getMonacoLanguage(kind, state.ext)); } updateSummary(); scheduleDraftSave(120); renderPreview(state.previewMode); } // ----------[🧩 getRepresentationValueForEditor 처리]---------- function getRepresentationValueForEditor(kind) { if (kind === 'text') { return state.representations.text || ''; } if (kind === 'markdown') { return state.representations.markdown || ''; } if (kind === 'html') { return state.representations.html || ''; } if (kind === 'json') { return getJsonRepresentationText(); } return ''; } // ----------[🧩 getCurrentPlainText 처리]---------- function getCurrentPlainText() { if (state.category === 'spreadsheet') { const grid = getActiveSheetGrid(); return grid.map((row) => row.join('\t')).join('\n').trim(); } if (state.editorKind === 'text') { return state.representations.text || ''; } if (state.editorKind === 'markdown') { return state.representations.text || stripHtmlToText(markdownToHtml(state.representations.markdown || '')); } if (state.editorKind === 'html') { return state.representations.text || stripHtmlToText(state.representations.html || ''); } if (state.editorKind === 'json') { return getJsonRepresentationText(); } return ''; } // ----------[🧩 getCurrentMarkdown 처리]---------- function getCurrentMarkdown() { if (state.category === 'spreadsheet') { return gridToMarkdown(getActiveSheetGrid()); } if (state.editorKind === 'markdown') { return state.representations.markdown || ''; } if (state.editorKind === 'html') { return htmlToMarkdown(state.representations.html || ''); } if (state.editorKind === 'text') { return state.representations.text || ''; } if (state.editorKind === 'json') { return '```json\n' + getJsonRepresentationText() + '\n```'; } return ''; } // ----------[🧩 getCurrentHtmlFragment 처리]---------- function getCurrentHtmlFragment() { if (state.category === 'spreadsheet') { return gridToHtml(getActiveSheetGrid(), getActiveSheetName()); } if (state.category === 'hwp' && state.hwpRawHtml) { return extractBodyInnerHtml(state.hwpRawHtml); } if (state.category === 'docx' && state.editorKind === 'html' && state.docxWorkingHtml) { return state.docxWorkingHtml; } if (state.editorKind === 'html') { return sanitizeHtml(state.representations.html || ''); } if (state.editorKind === 'markdown') { return state.representations.html || markdownToHtml(state.representations.markdown || ''); } if (state.editorKind === 'json') { return buildTextHtml(getJsonRepresentationText()); } return buildTextHtml(state.representations.text || ''); } // ----------[🧩 getCurrentJsonText 처리]---------- function getCurrentJsonText() { if (state.category === 'spreadsheet') { const payload = { sheets: state.workbookSheets.map((sheet) => ({ name: sheet.name, rows: sheet.grid.length, columns: getGridWidth(sheet.grid), data: sheet.grid })) }; return JSON.stringify(payload, null, 2); } const currentJson = state.representations.json; if (typeof currentJson === 'string') { return currentJson; } if (currentJson && typeof currentJson === 'object') { return JSON.stringify(currentJson, null, 2); } return JSON.stringify({ fileName: state.fileName, category: state.category, editorKind: state.editorKind, notes: state.notes }, null, 2); } // ----------[🧩 getGridWidth 처리]---------- function getGridWidth(grid) { return grid.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0); } // ----------[🧩 ensureGridShape 처리]---------- function ensureGridShape(grid, minRows = 12, minCols = 8) { const cloned = Array.isArray(grid) ? grid.map((row) => Array.isArray(row) ? [...row] : []) : []; const rows = Math.max(cloned.length, minRows); const cols = Math.max(getGridWidth(cloned), minCols); for (let rowIndex = 0; rowIndex < rows; rowIndex += 1) { if (!Array.isArray(cloned[rowIndex])) { cloned[rowIndex] = []; } for (let colIndex = 0; colIndex < cols; colIndex += 1) { if (typeof cloned[rowIndex][colIndex] === 'undefined') { cloned[rowIndex][colIndex] = ''; } } } return cloned; } // ----------[🧩 sheetToGrid 처리]---------- function sheetToGrid(sheet) { const rows = window.XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '' }); return ensureGridShape(rows, Math.max(rows.length, 12), Math.max(getGridWidth(rows), 8)); } // ----------[🧩 gridToSheet 처리]---------- function gridToSheet(grid) { const normalized = ensureGridShape(grid, 1, 1); const worksheet = window.XLSX.utils.aoa_to_sheet(normalized); for (let rowIndex = 0; rowIndex < normalized.length; rowIndex += 1) { for (let colIndex = 0; colIndex < normalized[rowIndex].length; colIndex += 1) { const cellValue = normalized[rowIndex][colIndex]; if (typeof cellValue !== 'string') { continue; } if (!cellValue.startsWith('=')) { continue; } const address = window.XLSX.utils.encode_cell({ r: rowIndex, c: colIndex }); worksheet[address] = { t: 'n', f: cellValue.slice(1) }; } } return worksheet; } // ----------[🧩 getActiveSheet 처리]---------- function getActiveSheet() { return state.workbookSheets[state.activeSheetIndex] || null; } // ----------[🧩 getActiveSheetGrid 처리]---------- function getActiveSheetGrid() { const activeSheet = getActiveSheet(); return activeSheet ? activeSheet.grid : ensureGridShape([]); } // ----------[🧩 getActiveSheetName 처리]---------- function getActiveSheetName() { const activeSheet = getActiveSheet(); return activeSheet ? activeSheet.name : 'Sheet1'; } // ----------[🧩 renderSheetTabs 처리]---------- function renderSheetTabs() { refs.sheetTabs.innerHTML = ''; state.workbookSheets.forEach((sheet, index) => { const button = document.createElement('button'); button.type = 'button'; button.className = 'tabButton'; button.textContent = sheet.name || `Sheet${index + 1}`; button.setAttribute('aria-pressed', index === state.activeSheetIndex ? 'true' : 'false'); button.addEventListener('click', () => { state.activeSheetIndex = index; state.originalHtml = gridToHtml(getActiveSheetGrid(), getActiveSheetName()); renderSheetTabs(); renderSheetTable(); updateSummary(); scheduleDraftSave(120); renderPreview(state.previewMode); }); refs.sheetTabs.appendChild(button); }); } // ----------[🧩 columnLabel 처리]---------- function columnLabel(index) { let value = index + 1; let label = ''; while (value > 0) { const remainder = (value - 1) % 26; label = String.fromCharCode(65 + remainder) + label; value = Math.floor((value - 1) / 26); } return label; } // ----------[🧩 renderSheetTable 처리]---------- function renderSheetTable() { const activeSheet = getActiveSheet(); if (!activeSheet) { refs.sheetTable.innerHTML = ''; return; } const grid = ensureGridShape(activeSheet.grid); activeSheet.grid = grid; const cols = getGridWidth(grid); let html = ''; for (let colIndex = 0; colIndex < cols; colIndex += 1) { html += `${escapeHtml(columnLabel(colIndex))}`; } html += ''; for (let rowIndex = 0; rowIndex < grid.length; rowIndex += 1) { html += `${rowIndex + 1}`; for (let colIndex = 0; colIndex < cols; colIndex += 1) { const cellValue = grid[rowIndex][colIndex] ?? ''; html += `
${escapeHtml(cellValue)}
`; } html += ''; } html += ''; refs.sheetTable.innerHTML = html; } // ----------[🧩 gridToHtml 처리]---------- function gridToHtml(grid, title = 'Sheet') { const normalized = ensureGridShape(grid, 1, 1); const cols = getGridWidth(normalized); let html = `

${escapeHtml(title)}

`; for (let colIndex = 0; colIndex < cols; colIndex += 1) { html += ``; } html += ''; for (const row of normalized) { html += ''; for (let colIndex = 0; colIndex < cols; colIndex += 1) { html += ``; } html += ''; } html += '
${escapeHtml(columnLabel(colIndex))}
${escapeHtml(String(row[colIndex] ?? ''))}
'; return html; } // ----------[🧩 gridToMarkdown 처리]---------- function gridToMarkdown(grid) { const normalized = ensureGridShape(grid, 1, 1); const cols = getGridWidth(normalized); const header = []; const separator = []; for (let colIndex = 0; colIndex < cols; colIndex += 1) { header.push(String(normalized[0]?.[colIndex] ?? columnLabel(colIndex)).replaceAll('|', '\\|') || columnLabel(colIndex)); separator.push('---'); } const rows = normalized.slice(1).map((row) => ( '| ' + Array.from({ length: cols }, (_, colIndex) => String(row[colIndex] ?? '').replaceAll('|', '\\|')).join(' | ') + ' |' )); return ['| ' + header.join(' | ') + ' |', '| ' + separator.join(' | ') + ' |', ...rows].join('\n').trim(); } // ----------[🧩 resetPreviewContainers 처리]---------- function resetPreviewContainers() { refs.sheetWorkspace.hidden = true; refs.docxOriginalView.hidden = true; refs.pdfView.hidden = true; refs.viewerFrame.hidden = true; refs.htmlView.hidden = true; refs.jsonView.hidden = true; refs.fallbackView.hidden = true; refs.htmlView.classList.remove('viewerHtml-paper', 'viewerEditable', 'viewerDocxEditable'); refs.htmlView.removeAttribute('contenteditable'); getHtmlViewContentElement().removeAttribute('contenteditable'); refs.jsonViewText.removeAttribute('contenteditable'); refs.jsonViewText.classList.remove('viewerPlainEditable'); refs.fallbackViewText.removeAttribute('contenteditable'); refs.fallbackViewText.classList.remove('viewerPlainEditable'); } // ----------[🧩 setPreviewButtons 처리]---------- function setPreviewButtons(mode) { refs.previewOriginalButton.setAttribute('aria-pressed', mode === 'original' ? 'true' : 'false'); refs.previewWorkingButton.setAttribute('aria-pressed', mode === 'working' ? 'true' : 'false'); refs.previewJsonButton.setAttribute('aria-pressed', mode === 'json' ? 'true' : 'false'); } // ----------[🧩 renderPreview 처리]---------- async function renderPreview(mode) { state.previewMode = mode; setPreviewButtons(mode); resetPreviewContainers(); if (state.category === 'empty') { refs.fallbackView.hidden = false; refs.fallbackViewText.textContent = '열린 문서가 없습니다.'; refs.viewerHint.textContent = '문서를 열면 이 영역에서 바로 확인하고 수정하실 수 있습니다.'; applyViewerPageLayout(); return; } if (mode === 'json') { refs.jsonView.hidden = false; refs.jsonViewText.textContent = getCurrentJsonText(); if (state.category !== 'spreadsheet' && state.editorKind === 'json') { refs.jsonViewText.setAttribute('contenteditable', 'plaintext-only'); refs.jsonViewText.classList.add('viewerPlainEditable'); refs.viewerHint.textContent = '구조 JSON을 직접 수정하실 수 있습니다.'; } else if (state.category === 'spreadsheet') { refs.viewerHint.textContent = '스프레드시트 구조 요약 보기입니다.'; } else { refs.viewerHint.textContent = '구조 JSON 보기입니다.'; } applyViewerPageLayout(); return; } if (mode === 'working') { if (state.category === 'spreadsheet') { refs.sheetWorkspace.hidden = false; renderSheetTabs(); renderSheetTable(); refs.viewerHint.textContent = '셀을 바로 눌러 수정하고, 필요하면 행과 열을 추가하실 수 있습니다.'; state.viewerPageCount = 1; state.viewerPageIndex = 0; updateViewerViewportHeight(); updateViewerPageUi(); return; } if (state.category === 'hwp' && state.hwpRawHtml) { refs.viewerFrame.hidden = false; await renderHwpFrame(true); refs.viewerHint.textContent = 'HWP는 레이아웃 보존을 위해 격리 프레임으로 표시합니다. 프레임 안에서 직접 수정하실 수 있습니다.'; applyViewerPageLayout(); return; } if (state.category === 'docx' && state.editorKind === 'html' && state.originalDocxBuffer) { refs.htmlView.hidden = false; refs.htmlView.classList.add('viewerEditable', 'viewerDocxEditable'); if (state.docxWorkingHtml) { setHtmlViewContent(state.docxWorkingHtml, true); } else { await renderEditableDocx(); } refs.viewerHint.textContent = 'DOCX 작업 보기는 표와 문단 구조를 다시 조립해 셀 안에서 바로 수정할 수 있게 표시합니다. 원본은 원본 탭에서 확인하실 수 있습니다.'; applyViewerPageLayout(); return; } if (state.editorKind === 'json') { refs.jsonView.hidden = false; refs.jsonViewText.textContent = getJsonRepresentationText(); refs.jsonViewText.setAttribute('contenteditable', 'plaintext-only'); refs.jsonViewText.classList.add('viewerPlainEditable'); refs.viewerHint.textContent = 'JSON 내용을 직접 수정하시면 바로 반영됩니다.'; applyViewerPageLayout(); return; } if (state.editorKind === 'text') { refs.fallbackView.hidden = false; refs.fallbackViewText.textContent = state.representations.text || ''; refs.fallbackViewText.setAttribute('contenteditable', 'plaintext-only'); refs.fallbackViewText.classList.add('viewerPlainEditable'); refs.viewerHint.textContent = '보이는 텍스트를 바로 눌러 수정하실 수 있습니다.'; applyViewerPageLayout(); return; } refs.htmlView.hidden = false; refs.htmlView.classList.add('viewerHtml-paper', 'viewerEditable'); setHtmlViewContent(getCurrentHtmlFragment(), true); refs.viewerHint.textContent = '보이는 문서를 직접 눌러 수정하실 수 있습니다.'; applyViewerPageLayout(); return; } if (state.category === 'docx' && state.originalDocxBuffer) { refs.docxOriginalView.hidden = false; refs.viewerHint.textContent = '원본 보기입니다. 직접 수정은 바로 수정 보기에서 진행하실 수 있습니다.'; await renderOriginalDocx(); applyViewerPageLayout(); return; } if (state.category === 'pdf' && state.bytes) { refs.pdfView.hidden = false; refs.viewerHint.textContent = '원본 PDF 렌더 보기입니다. 직접 수정은 바로 수정 보기에서 진행하실 수 있습니다.'; await renderOriginalPdf(); applyViewerPageLayout(); return; } if (state.category === 'hwp' && state.hwpRawHtml) { refs.viewerFrame.hidden = false; await renderHwpFrame(false); refs.viewerHint.textContent = '원본 HWP 렌더 보기입니다.'; applyViewerPageLayout(); return; } if (state.originalHtml) { refs.htmlView.hidden = false; if (state.originalHtmlIsPaper) { refs.htmlView.classList.add('viewerHtml-paper'); } setHtmlViewContent(state.originalHtml, false); refs.viewerHint.textContent = '원본 보기입니다. 직접 수정은 바로 수정 보기에서 진행하실 수 있습니다.'; applyViewerPageLayout(); return; } refs.fallbackView.hidden = false; refs.fallbackViewText.textContent = getCurrentPlainText() || '원본 보기 데이터가 없습니다.'; refs.viewerHint.textContent = '원본 보기 데이터가 없습니다.'; applyViewerPageLayout(); } // ----------[🧩 renderOriginalDocx 처리]---------- async function renderOriginalDocx() { if (!state.originalDocxBuffer) { refs.docxOriginalView.textContent = '원본 DOCX 데이터가 없습니다.'; return; } if (refs.docxOriginalView.dataset.rendered === 'true') { return; } refs.docxOriginalView.innerHTML = ''; try { await renderDocxPreviewInto(refs.docxOriginalView); refs.docxOriginalView.dataset.rendered = 'true'; } catch (error) { console.error(error); refs.docxOriginalView.innerHTML = `
${escapeHtml(`DOCX 원본 렌더에 실패했습니다.\n${error instanceof Error ? error.message : String(error)}`)}
`; } } // ----------[🧩 renderEditableDocx 처리]---------- async function renderEditableDocx() { if (!state.originalDocxBuffer) { setHtmlViewContent(buildTextHtml('DOCX 편집용 데이터가 없습니다.'), false); return; } refs.htmlView.innerHTML = ''; try { await renderDocxPreviewInto(refs.htmlView); const renderedHtml = refs.htmlView.innerHTML; setHtmlViewContent(renderedHtml, true); state.docxWorkingHtml = renderedHtml; updateTextLikeRepresentationsFromKind('html', renderedHtml); syncHiddenEditorFromState(); updateSummary(); } catch (error) { console.error(error); refs.htmlView.classList.remove('viewerDocxEditable'); refs.htmlView.classList.add('viewerHtml-paper'); setHtmlViewContent( state.representations.html || buildTextHtml(`DOCX 작업 보기 렌더에 실패했습니다.\n${error instanceof Error ? error.message : String(error)}`), false ); } } // ----------[🧩 renderHwpFrame 처리]---------- async function renderHwpFrame(editable) { if (!state.hwpRawHtml) { refs.viewerFrame.srcdoc = '

HWP 렌더 데이터가 없습니다.

'; return; } await new Promise((resolve) => { refs.viewerFrame.onload = () => resolve(); refs.viewerFrame.srcdoc = state.hwpRawHtml; }); const frameDocument = refs.viewerFrame.contentDocument; if (!frameDocument) { return; } bindViewerFrameShortcuts(frameDocument); try { frameDocument.designMode = editable ? 'on' : 'off'; } catch (_) { // noop } const body = frameDocument.body; if (body) { body.contentEditable = editable ? 'true' : 'false'; } if (editable) { frameDocument.oninput = () => { syncHwpFrameToState(); }; } else { frameDocument.oninput = null; } refs.viewerFrame.style.width = '100%'; refs.viewerFrame.style.height = '100%'; } // ----------[🧩 renderDocxPreviewInto 처리]---------- async function renderDocxPreviewInto(container) { const docxPreview = await loadDocxPreview(); const renderAsync = docxPreview.renderAsync || docxPreview.default?.renderAsync || docxPreview.default; if (typeof renderAsync !== 'function') { throw new Error('DOCX 렌더러 진입점을 찾지 못했습니다.'); } await renderAsync( state.originalDocxBuffer, container, container, { inWrapper: true, ignoreWidth: false, ignoreHeight: false, useBase64URL: true } ); } // ----------[🧩 renderOriginalPdf 처리]---------- async function renderOriginalPdf() { if (refs.pdfPages.dataset.rendered === 'true') { return; } refs.pdfPages.innerHTML = ''; try { const pdfjsLib = await loadPdfjs(); const loadingTask = pdfjsLib.getDocument({ data: state.bytes, isEvalSupported: false }); const pdfDocument = await loadingTask.promise; state.originalPdfDocument = pdfDocument; for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) { const page = await pdfDocument.getPage(pageNumber); const viewport = page.getViewport({ scale: 1.35 }); const canvas = document.createElement('canvas'); canvas.className = 'pdfPageCanvas'; canvas.width = Math.ceil(viewport.width); canvas.height = Math.ceil(viewport.height); const context = canvas.getContext('2d'); if (!context) { continue; } await page.render({ canvasContext: context, viewport }).promise; const wrapper = document.createElement('div'); wrapper.className = 'pdfPageCard'; wrapper.innerHTML = `
${pageNumber} / ${pdfDocument.numPages} 페이지
`; wrapper.appendChild(canvas); refs.pdfPages.appendChild(wrapper); } refs.pdfPages.dataset.rendered = 'true'; } catch (error) { console.error(error); refs.pdfPages.innerHTML = `
${escapeHtml(`PDF 렌더에 실패했습니다.\n${error instanceof Error ? error.message : String(error)}`)}
`; } } // ----------[🧩 formatDraftSavedAt 처리]---------- function formatDraftSavedAt(savedAt) { if (typeof savedAt !== 'string' || savedAt.trim() === '') { return ''; } const date = new Date(savedAt); if (Number.isNaN(date.getTime())) { return ''; } return date.toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } // ----------[🧩 readDraftSnapshot 처리]---------- function readDraftSnapshot() { try { const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY); if (!raw) { return null; } const snapshot = JSON.parse(raw); return snapshot && typeof snapshot === 'object' ? snapshot : null; } catch (_) { return null; } } // ----------[🧩 updateRestoreDraftButton 처리]---------- function updateRestoreDraftButton() { const snapshot = readDraftSnapshot(); refs.restoreDraftButton.textContent = '임시 복원'; if (!snapshot) { refs.restoreDraftButton.disabled = true; refs.restoreDraftButton.title = ''; return; } refs.restoreDraftButton.disabled = false; const savedAtLabel = formatDraftSavedAt(snapshot.savedAt); refs.restoreDraftButton.title = savedAtLabel !== '' ? `마지막 임시 저장: ${savedAtLabel}` : '브라우저에 저장된 작업을 다시 엽니다.'; } // ----------[🧩 clearDraftSnapshot 처리]---------- function clearDraftSnapshot() { try { window.localStorage.removeItem(DRAFT_STORAGE_KEY); } catch (_) { // noop } state.lastDraftSavedAt = ''; updateRestoreDraftButton(); } // ----------[🧩 cloneDraftWorkbookSheets 처리]---------- function cloneDraftWorkbookSheets(sheets, minRows = 1, minCols = 1) { if (!Array.isArray(sheets)) { return []; } return sheets.map((sheet, index) => ({ name: typeof sheet?.name === 'string' && sheet.name.trim() !== '' ? sheet.name.trim() : `시트 ${index + 1}`, grid: ensureGridShape(sheet?.grid, minRows, minCols) })); } // ----------[🧩 buildDraftSnapshot 처리]---------- function buildDraftSnapshot() { if (state.category === 'empty') { return null; } return { version: 1, savedAt: new Date().toISOString(), fileName: state.fileName || '', baseName: state.baseName || normalizeBaseName(state.fileName || 'document'), ext: state.ext || '', category: state.category, size: Math.max(0, Number(state.size) || 0), editorKind: state.editorKind, availableEditorKinds: [...state.availableEditorKinds], availableExports: [...state.availableExports], originalViewLabel: state.originalViewLabel || '', notes: [...state.notes], previewMode: state.previewMode, representations: { text: state.representations.text || '', markdown: state.representations.markdown || '', html: state.representations.html || '', json: state.representations.json ?? null }, workbookSheets: state.category === 'spreadsheet' ? cloneDraftWorkbookSheets(state.workbookSheets, 1, 1) : [], activeSheetIndex: Number.isInteger(state.activeSheetIndex) ? state.activeSheetIndex : 0, originalHtml: state.originalHtml || '', originalHtmlIsPaper: state.originalHtmlIsPaper !== false, originalJson: state.originalJson ?? null, docxWorkingHtml: state.docxWorkingHtml || '', hwpRawHtml: state.hwpRawHtml || '' }; } // ----------[🧩 persistDraftSnapshotNow 처리]---------- function persistDraftSnapshotNow() { if (state.draftSaveTimer) { window.clearTimeout(state.draftSaveTimer); state.draftSaveTimer = 0; } const snapshot = buildDraftSnapshot(); if (!snapshot) { updateRestoreDraftButton(); return false; } try { window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(snapshot)); state.lastDraftSavedAt = snapshot.savedAt; updateRestoreDraftButton(); return true; } catch (error) { console.error(error); return false; } } // ----------[🧩 scheduleDraftSave 처리]---------- function scheduleDraftSave(delay = 420) { if (state.category === 'empty') { updateRestoreDraftButton(); return; } if (state.draftSaveTimer) { window.clearTimeout(state.draftSaveTimer); } state.draftSaveTimer = window.setTimeout(() => { persistDraftSnapshotNow(); }, Math.max(0, Number(delay) || 0)); } // ----------[🧩 getContentStatsText 처리]---------- function getContentStatsText() { if (state.category === 'empty') { return '없음'; } if (state.category === 'spreadsheet') { const activeGrid = getActiveSheetGrid(); return `시트 ${Math.max(1, state.workbookSheets.length)}개 · ${activeGrid.length}행 · ${getGridWidth(activeGrid)}열`; } const text = String(getCurrentPlainText() || '').replace(/\r\n/g, '\n'); const trimmed = text.trim(); const parts = [`문자 ${text.length}`]; if (text !== '') { parts.push(`줄 ${text.split('\n').length}`); } if (trimmed !== '') { parts.push(`단어 ${trimmed.split(/\s+/).filter(Boolean).length}`); } if (state.category === 'pdf' && state.originalPdfDocument?.numPages) { parts.push(`페이지 ${state.originalPdfDocument.numPages}`); } return parts.join(' · '); } // ----------[🧩 updateSummary 처리]---------- function updateSummary() { refs.summaryFileName.textContent = state.fileName || '없음'; refs.summaryFormat.textContent = state.category === 'empty' ? '없음' : `${state.category} (${state.ext || '확장자 없음'})`; refs.summaryEditorKind.textContent = state.category === 'empty' ? '없음' : editorKindLabel(state.editorKind); refs.summaryFileSize.textContent = formatBytes(state.size); refs.summaryContentStats.textContent = getContentStatsText(); refs.summaryConversions.textContent = state.availableExports.length ? state.availableExports.join(', ') : '없음'; refs.summaryOriginalView.textContent = state.originalViewLabel || '없음'; refs.summaryNotes.textContent = state.notes.length ? state.notes.join(' / ') : '파일을 열면 채워집니다.'; } // ----------[🧩 editorKindLabel 처리]---------- function editorKindLabel(kind) { if (kind === 'text') { return '텍스트'; } if (kind === 'markdown') { return 'Markdown'; } if (kind === 'html') { return 'HTML'; } if (kind === 'json') { return 'JSON'; } if (kind === 'spreadsheet') { return '스프레드시트 셀'; } return kind; } // ----------[🧩 resetState 처리]---------- function resetState(options = {}) { const shouldClearDraft = options && options.clearDraft === true; if (state.draftSaveTimer) { window.clearTimeout(state.draftSaveTimer); state.draftSaveTimer = 0; } state.file = null; state.fileName = ''; state.baseName = 'document'; state.ext = ''; state.category = 'empty'; state.size = 0; state.bytes = null; state.editorKind = 'text'; state.availableEditorKinds = []; state.availableExports = []; state.originalViewLabel = '없음'; state.notes = []; state.previewMode = 'working'; state.representations = { text: '', markdown: '', html: '', json: null }; state.workbookSheets = []; state.activeSheetIndex = 0; state.originalDocxBuffer = null; state.docxWorkingHtml = ''; state.hwpRawHtml = ''; state.originalPdfDocument = null; state.originalHtml = ''; state.originalHtmlIsPaper = true; state.originalJson = null; state.lastDraftSavedAt = ''; resetViewerZoomState(); refs.docxOriginalView.dataset.rendered = 'false'; refs.pdfPages.dataset.rendered = 'false'; refs.pdfPages.innerHTML = ''; refs.docxOriginalView.innerHTML = ''; refs.viewerFrame.onload = null; refs.viewerFrame.srcdoc = ''; refs.viewerFrame.style.removeProperty('width'); refs.viewerFrame.style.removeProperty('height'); refs.editorKindSelect.innerHTML = ''; refs.exportSelect.innerHTML = ''; refs.fallbackViewText.textContent = '열린 문서가 없습니다.'; refs.viewerHint.textContent = '바로 수정 보기에서는 보이는 문서를 직접 눌러 편집하실 수 있습니다.'; refs.editorHint.textContent = '선택한 표현 데이터를 내부에서 준비합니다.'; refs.sheetTabs.innerHTML = ''; refs.sheetTable.innerHTML = ''; if (state.editor) { setEditorValue('', 'plaintext'); } refs.monacoMount.hidden = false; refs.sheetWorkspace.hidden = true; renderPreview('working'); updateSummary(); if (shouldClearDraft) { clearDraftSnapshot(); } else { updateRestoreDraftButton(); } } // ----------[🧩 getDefaultKindsAndExportsForCategory 처리]---------- function getDefaultKindsAndExportsForCategory(category) { if (category === 'spreadsheet') { return { kinds: ['spreadsheet'], exportsList: ['xlsx', 'csv', 'tsv', 'html', 'json'] }; } return { kinds: ['text', 'markdown', 'html', 'json'], exportsList: ['txt', 'md', 'html', 'docx', 'pdf', 'json'] }; } // ----------[🧩 createBlankGrid 처리]---------- function createBlankGrid(rows = 18, cols = 8) { return Array.from({ length: rows }, () => Array.from({ length: cols }, () => '')); } // ----------[🧩 startBlankDocument 처리]---------- async function startBlankDocument() { resetState(); const fileName = '새 문서.txt'; const defaults = getDefaultKindsAndExportsForCategory('text'); fillRepresentations({ text: '', markdown: '', html: '', json: { kind: 'blank-document', createdAt: new Date().toISOString() } }); state.fileName = fileName; state.baseName = normalizeBaseName(fileName); state.ext = 'txt'; state.category = 'text'; state.size = 0; state.originalHtml = buildTextHtml(''); state.originalHtmlIsPaper = false; state.originalViewLabel = '새 문서 초안'; state.originalJson = state.representations.json; state.notes = ['브라우저에서 바로 새 문서를 작성 중']; setAvailableKindsAndExports(defaults.kinds, defaults.exportsList); await ensureMonaco(); setEditorKind('text'); refs.viewerHint.textContent = '새 문서를 바로 작성하실 수 있습니다.'; refs.editorHint.textContent = '텍스트, Markdown, HTML, JSON 표현으로 전환하면서 작성할 수 있습니다.'; scheduleDraftSave(80); showStatus('새 문서를 시작했습니다.', 'info'); } // ----------[🧩 startBlankSpreadsheet 처리]---------- async function startBlankSpreadsheet() { resetState(); const fileName = '새 표.xlsx'; const defaults = getDefaultKindsAndExportsForCategory('spreadsheet'); const firstGrid = createBlankGrid(18, 8); state.fileName = fileName; state.baseName = normalizeBaseName(fileName); state.ext = 'xlsx'; state.category = 'spreadsheet'; state.size = 0; state.workbookSheets = [{ name: '시트 1', grid: firstGrid }]; state.activeSheetIndex = 0; fillRepresentations({ text: firstGrid.map((row) => row.join('\t')).join('\n'), markdown: gridToMarkdown(firstGrid), html: gridToHtml(firstGrid, '시트 1'), json: { sheets: [{ name: '시트 1', rows: firstGrid.length, columns: getGridWidth(firstGrid), data: firstGrid }] } }); state.originalHtml = state.representations.html; state.originalHtmlIsPaper = true; state.originalViewLabel = '새 표 초안'; state.originalJson = state.representations.json; state.notes = ['브라우저에서 바로 새 표를 작성 중']; setAvailableKindsAndExports(defaults.kinds, defaults.exportsList); setEditorKind('spreadsheet'); refs.viewerHint.textContent = '새 표를 바로 작성하실 수 있습니다.'; refs.editorHint.textContent = '셀을 수정하고 행과 열을 추가한 뒤 다양한 형식으로 저장할 수 있습니다.'; scheduleDraftSave(80); showStatus('새 표를 시작했습니다.', 'info'); } // ----------[🧩 restoreDraftSnapshot 처리]---------- async function restoreDraftSnapshot() { const snapshot = readDraftSnapshot(); if (!snapshot) { updateRestoreDraftButton(); showStatus('복원할 임시 작업이 없습니다.', 'warn'); return; } resetState(); const defaults = getDefaultKindsAndExportsForCategory(snapshot.category); const storedKinds = Array.isArray(snapshot.availableEditorKinds) ? snapshot.availableEditorKinds.filter((kind) => VALID_EDITOR_KINDS.has(kind)) : []; const storedExports = Array.isArray(snapshot.availableExports) ? snapshot.availableExports.filter((extension) => typeof extension === 'string' && extension.trim() !== '') : []; state.fileName = typeof snapshot.fileName === 'string' && snapshot.fileName !== '' ? snapshot.fileName : '복원 문서.txt'; state.baseName = typeof snapshot.baseName === 'string' && snapshot.baseName !== '' ? snapshot.baseName : normalizeBaseName(state.fileName); state.ext = typeof snapshot.ext === 'string' ? snapshot.ext : getExtension(state.fileName); state.category = typeof snapshot.category === 'string' && snapshot.category !== '' ? snapshot.category : 'text'; state.size = Math.max(0, Number(snapshot.size) || 0); state.originalViewLabel = typeof snapshot.originalViewLabel === 'string' && snapshot.originalViewLabel !== '' ? snapshot.originalViewLabel : '임시 복원 보기'; state.notes = Array.isArray(snapshot.notes) ? snapshot.notes.filter((note) => typeof note === 'string' && note.trim() !== '') : []; if (!state.notes.includes('브라우저 임시 저장본에서 복원')) { state.notes.push('브라우저 임시 저장본에서 복원'); } state.previewMode = VALID_PREVIEW_MODES.has(snapshot.previewMode) ? snapshot.previewMode : 'working'; state.representations = { text: typeof snapshot.representations?.text === 'string' ? snapshot.representations.text : '', markdown: typeof snapshot.representations?.markdown === 'string' ? snapshot.representations.markdown : '', html: typeof snapshot.representations?.html === 'string' ? snapshot.representations.html : '', json: snapshot.representations && Object.prototype.hasOwnProperty.call(snapshot.representations, 'json') ? snapshot.representations.json : null }; state.originalHtml = typeof snapshot.originalHtml === 'string' ? snapshot.originalHtml : (state.representations.html || buildTextHtml(state.representations.text || '')); state.originalHtmlIsPaper = snapshot.originalHtmlIsPaper !== false; state.originalJson = Object.prototype.hasOwnProperty.call(snapshot, 'originalJson') ? snapshot.originalJson : state.representations.json; state.docxWorkingHtml = typeof snapshot.docxWorkingHtml === 'string' ? snapshot.docxWorkingHtml : ''; state.hwpRawHtml = typeof snapshot.hwpRawHtml === 'string' ? snapshot.hwpRawHtml : ''; setAvailableKindsAndExports( storedKinds.length ? storedKinds : defaults.kinds, storedExports.length ? storedExports : defaults.exportsList ); refs.viewerHint.textContent = '브라우저에 남아 있던 작업을 복원했습니다.'; if (state.category === 'spreadsheet') { state.workbookSheets = cloneDraftWorkbookSheets(snapshot.workbookSheets, 18, 8); if (!state.workbookSheets.length) { state.workbookSheets = [{ name: '시트 1', grid: createBlankGrid(18, 8) }]; } state.activeSheetIndex = Math.min( state.workbookSheets.length - 1, Math.max(0, Number(snapshot.activeSheetIndex) || 0) ); state.originalHtml = state.originalHtml || gridToHtml(getActiveSheetGrid(), getActiveSheetName()); state.originalJson = state.originalJson ?? { sheets: state.workbookSheets.map((sheet) => ({ name: sheet.name, rows: sheet.grid.length, columns: getGridWidth(sheet.grid), data: sheet.grid })) }; refs.editorHint.textContent = '임시 저장된 표 작업을 다시 불러왔습니다.'; setEditorKind('spreadsheet'); } else { const nextKind = state.availableEditorKinds.includes(snapshot.editorKind) ? snapshot.editorKind : (state.availableEditorKinds[0] || 'text'); refs.editorHint.textContent = '임시 저장된 문서 작업을 다시 불러왔습니다.'; await ensureMonaco(); setEditorKind(nextKind); } state.lastDraftSavedAt = typeof snapshot.savedAt === 'string' ? snapshot.savedAt : ''; updateSummary(); updateRestoreDraftButton(); scheduleDraftSave(80); showStatus('브라우저에 남아 있던 작업을 복원했습니다.', 'info'); } // ----------[🧩 updateControls 처리]---------- function updateControls() { refs.exportSelect.innerHTML = ''; for (const extension of state.availableExports) { const option = document.createElement('option'); option.value = extension; option.textContent = '.' + extension; refs.exportSelect.appendChild(option); } refs.editorKindSelect.innerHTML = ''; for (const kind of state.availableEditorKinds) { const option = document.createElement('option'); option.value = kind; option.textContent = editorKindLabel(kind); refs.editorKindSelect.appendChild(option); } if (state.availableEditorKinds.includes(state.editorKind)) { refs.editorKindSelect.value = state.editorKind; } } // ----------[🧩 fillRepresentations 처리]---------- function fillRepresentations(data) { state.representations.text = data.text || ''; state.representations.markdown = data.markdown || ''; state.representations.html = data.html || ''; state.representations.json = data.json ?? null; } // ----------[🧩 setAvailableKindsAndExports 처리]---------- function setAvailableKindsAndExports(kinds, exportsList) { state.availableEditorKinds = [...kinds]; state.availableExports = [...exportsList]; updateControls(); } // ----------[🧩 decodeTextBytes 처리]---------- function decodeTextBytes(uint8) { if (!(uint8 instanceof Uint8Array)) { return ''; } if (uint8.length >= 2 && uint8[0] === 0xFF && uint8[1] === 0xFE) { return new TextDecoder('utf-16le').decode(uint8); } if (uint8.length >= 2 && uint8[0] === 0xFE && uint8[1] === 0xFF) { const swapped = new Uint8Array(uint8.length - (uint8.length % 2)); for (let index = 0; index < swapped.length; index += 2) { swapped[index] = uint8[index + 1]; swapped[index + 1] = uint8[index]; } return new TextDecoder('utf-16le').decode(swapped); } const utf8Text = new TextDecoder('utf-8').decode(uint8); const replacementCount = (utf8Text.match(/\uFFFD/g) || []).length; if (replacementCount > 0 && replacementCount >= Math.max(1, Math.floor(utf8Text.length * 0.02))) { try { return new TextDecoder('euc-kr').decode(uint8); } catch (_) { return utf8Text; } } return utf8Text; } // ----------[🧩 looksZipBytes 처리]---------- function looksZipBytes(uint8) { return uint8 instanceof Uint8Array && uint8.length >= 4 && uint8[0] === 0x50 && uint8[1] === 0x4B; } // ----------[🧩 looksOleBytes 처리]---------- function looksOleBytes(uint8) { const signature = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; if (!(uint8 instanceof Uint8Array) || uint8.length < signature.length) { return false; } return signature.every((value, index) => uint8[index] === value); } // ----------[🧩 parseXmlDocument 처리]---------- function parseXmlDocument(xmlText) { const xmlDoc = new DOMParser().parseFromString(xmlText, 'application/xml'); if (xmlDoc.getElementsByTagName('parsererror').length > 0) { return null; } return xmlDoc; } // ----------[🧩 getXmlAttribute 처리]---------- function getXmlAttribute(node, names) { if (!node || !node.attributes) { return ''; } const nameSet = new Set(Array.isArray(names) ? names : [names]); for (const attribute of Array.from(node.attributes)) { if (nameSet.has(attribute.name) || nameSet.has(attribute.localName)) { return attribute.value || ''; } } return ''; } // ----------[🧩 findXmlChild 처리]---------- function findXmlChild(node, localName) { return Array.from(node?.childNodes || []).find((child) => ( child.nodeType === Node.ELEMENT_NODE && (child.localName || child.nodeName) === localName )) || null; } // ----------[🧩 buildDocxCellAttributes 처리]---------- function buildDocxCellAttributes(node) { const tcPr = findXmlChild(node, 'tcPr'); if (!tcPr) { return ''; } const attributes = []; const styles = []; const gridSpan = findXmlChild(tcPr, 'gridSpan'); const colspan = Number(getXmlAttribute(gridSpan, ['w:val', 'val'])); if (Number.isInteger(colspan) && colspan > 1) { attributes.push(`colspan="${colspan}"`); } const shd = findXmlChild(tcPr, 'shd'); const fill = getXmlAttribute(shd, ['w:fill', 'fill']).replace('#', '').trim(); if (/^[0-9a-fA-F]{6}$/.test(fill) && fill.toLowerCase() !== 'auto') { styles.push(`background-color:#${fill}`); } const verticalAlign = findXmlChild(tcPr, 'vAlign'); const verticalAlignValue = getXmlAttribute(verticalAlign, ['w:val', 'val']).toLowerCase(); if (verticalAlignValue === 'center') { styles.push('vertical-align:middle'); } else if (verticalAlignValue === 'bottom') { styles.push('vertical-align:bottom'); } const width = findXmlChild(tcPr, 'tcW'); const widthType = getXmlAttribute(width, ['w:type', 'type']).toLowerCase(); const widthValue = Number(getXmlAttribute(width, ['w:w', 'w'])); if (widthType === 'dxa' && Number.isFinite(widthValue) && widthValue > 0) { styles.push(`width:${Math.max(1, widthValue / 20)}pt`); } if (styles.length > 0) { attributes.push(`style="${styles.join(';')}"`); } return attributes.length > 0 ? ' ' + attributes.join(' ') : ''; } // ----------[🧩 parseDocxNode 처리]---------- function parseDocxNode(node) { if (!node) { return ''; } if (node.nodeType === Node.TEXT_NODE) { return escapeHtml(node.textContent || ''); } if (node.nodeType !== Node.ELEMENT_NODE) { return ''; } const localName = node.localName || node.nodeName; const children = Array.from(node.childNodes).map(parseDocxNode).join(''); if (localName === 'document' || localName === 'body' || localName === 'sdt' || localName === 'sdtContent' || localName === 'smartTag') { return children; } if (localName === 'tbl') { return `${children}
`; } if (localName === 'tr') { return `${children}`; } if (localName === 'tc') { const content = children.trim() === '' ? ' ' : children; return `${content}`; } if (localName === 'p') { const content = children.trim() === '' ? ' ' : children; return `

${content}

`; } if (localName === 'r' || localName === 'hyperlink' || localName === 'ins' || localName === 'drawing' || localName === 'pict' || localName === 'textbox' || localName === 'txbxContent' || localName === 'object') { return children; } if (localName === 't' || localName === 'delText') { return escapeHtml(node.textContent || ''); } if (localName === 'tab') { return ' '; } if (localName === 'br' || localName === 'cr') { return '
'; } return children; } // ----------[🧩 extractDocxEditableHtml 처리]---------- async function extractDocxEditableHtml(arrayBuffer) { const zip = await window.JSZip.loadAsync(arrayBuffer); const documentXmlEntry = zip.file('word/document.xml'); if (!documentXmlEntry) { return ''; } const xmlText = await documentXmlEntry.async('string'); const xmlDoc = parseXmlDocument(xmlText); if (!xmlDoc) { return ''; } const body = Array.from(xmlDoc.getElementsByTagName('*')).find((element) => ( (element.localName || element.nodeName) === 'body' )); const html = parseDocxNode(body || xmlDoc.documentElement).trim(); return sanitizeHtml(html); } // ----------[🧩 extractBodyInnerHtml 처리]---------- function extractBodyInnerHtml(htmlText) { const doc = new DOMParser().parseFromString(htmlText, 'text/html'); return doc.body ? doc.body.innerHTML : htmlText; } // ----------[🧩 parseOdfNode 처리]---------- function parseOdfNode(node) { if (!node) { return ''; } if (node.nodeType === Node.TEXT_NODE) { return escapeHtml(node.textContent || ''); } if (node.nodeType !== Node.ELEMENT_NODE) { return ''; } const localName = node.localName || node.nodeName; const children = Array.from(node.childNodes).map(parseOdfNode).join(''); if (localName === 'p') { return `

${children.trim() === '' ? ' ' : children}

`; } if (localName === 'h') { return `

${children}

`; } if (localName === 'list') { return ``; } if (localName === 'list-item') { return `
  • ${children}
  • `; } if (localName === 'table') { return `${children}
    `; } if (localName === 'table-row') { return `${children}`; } if (localName === 'table-cell') { return `${children.trim() === '' ? ' ' : children}`; } if (localName === 'span' || localName === 'a') { return children; } if (localName === 'line-break') { return '
    '; } if (localName === 'tab') { return ' '; } if (localName === 's') { return ' '; } return children; } // ----------[🧩 extractPrintableStrings 처리]---------- function extractPrintableStrings(uint8) { if (!(uint8 instanceof Uint8Array) || uint8.length === 0) { return ''; } const utf16Text = new TextDecoder('utf-16le').decode(uint8); const utf16Matches = utf16Text.match(/[\p{L}\p{N}\p{P}\p{S}\s]{4,}/gu) || []; let asciiText = ''; for (const byte of uint8) { asciiText += (byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte <= 126)) ? String.fromCharCode(byte) : '\n'; } const asciiMatches = asciiText.match(/[^\n]{4,}/g) || []; const unique = []; const seen = new Set(); for (const source of [...utf16Matches, ...asciiMatches]) { const cleaned = source.replace(/\s+/g, ' ').trim(); if (cleaned.length < 4 || seen.has(cleaned)) { continue; } seen.add(cleaned); unique.push(cleaned); if (unique.length >= 500) { break; } } return unique.join('\n'); } // ----------[🧩 loadArchiveDocument 처리]---------- async function loadArchiveDocument(file, providedBuffer = null) { const arrayBuffer = providedBuffer ? providedBuffer.buffer.slice(providedBuffer.byteOffset, providedBuffer.byteOffset + providedBuffer.byteLength) : await file.arrayBuffer(); state.bytes = providedBuffer instanceof Uint8Array ? providedBuffer : new Uint8Array(arrayBuffer); const zip = await window.JSZip.loadAsync(arrayBuffer); const entryNames = Object.keys(zip.files); let html = ''; let text = ''; let json = { files: entryNames }; let viewLabel = 'ZIP 기반 문서 추출 렌더'; const ext = state.ext; if (['pptx', 'ppsx', 'potx'].includes(ext)) { const slideNames = entryNames .filter((name) => /^ppt\/slides\/slide\d+\.xml$/i.test(name)) .sort((left, right) => { const leftNumber = Number((left.match(/slide(\d+)/i) || [])[1] || 0); const rightNumber = Number((right.match(/slide(\d+)/i) || [])[1] || 0); return leftNumber - rightNumber; }); const slides = []; for (let index = 0; index < slideNames.length; index += 1) { const xmlText = await zip.file(slideNames[index]).async('string'); const xmlDoc = parseXmlDocument(xmlText); if (!xmlDoc) { continue; } const lines = Array.from(xmlDoc.getElementsByTagName('*')) .filter((node) => node.localName === 't') .map((node) => (node.textContent || '').trim()) .filter(Boolean); slides.push(lines); } html = sanitizeHtml(slides.map((slideLines, index) => { const slideText = slideLines.map((line) => `

    ${escapeHtml(line)}

    `).join(''); return `

    슬라이드 ${index + 1}

    ${slideText || '

     

    '}
    `; }).join('')); text = slides.map((slideLines, index) => `슬라이드 ${index + 1}\n${slideLines.join('\n')}`.trim()).join('\n\n').trim(); json = { slides: slideNames, files: entryNames }; viewLabel = 'PPTX 슬라이드 추출'; state.notes.push('PPTX/슬라이드 XML 추출 모드'); } else if (['odt', 'ott', 'odp', 'otp'].includes(ext) && zip.file('content.xml')) { const contentXml = await zip.file('content.xml').async('string'); const xmlDoc = parseXmlDocument(contentXml); if (!xmlDoc) { throw new Error('ODF content.xml 파싱에 실패했습니다.'); } html = sanitizeHtml(parseOdfNode(xmlDoc.documentElement)); text = stripHtmlToText(html); json = { files: entryNames, root: xmlDoc.documentElement.nodeName }; viewLabel = 'ODF 콘텐츠 렌더'; state.notes.push('ODF content.xml 직접 파싱'); } else if (ext === 'epub') { const htmlEntryNames = entryNames.filter((name) => /\.(xhtml|html|htm)$/i.test(name)); const sections = []; for (const entryName of htmlEntryNames.slice(0, 80)) { const raw = await zip.file(entryName).async('string'); const fragment = sanitizeHtml(extractBodyInnerHtml(raw)); if (stripHtmlToText(fragment) === '') { continue; } sections.push(`

    ${escapeHtml(entryName)}

    ${fragment}
    `); } html = sections.join(''); text = stripHtmlToText(html); json = { files: entryNames, htmlEntries: htmlEntryNames }; viewLabel = 'EPUB 본문 추출'; state.notes.push('EPUB XHTML 본문 추출'); } else { const contentXmlEntry = zip.file('content.xml'); if (contentXmlEntry) { const xmlDoc = parseXmlDocument(await contentXmlEntry.async('string')); if (xmlDoc) { html = sanitizeHtml(parseOdfNode(xmlDoc.documentElement)); text = stripHtmlToText(html); json = { files: entryNames, root: xmlDoc.documentElement.nodeName }; viewLabel = 'ZIP/XML 문서 추출'; state.notes.push('ZIP 내부 content.xml 사용'); } } if (!html) { const textLikeEntries = entryNames.filter((name) => /\.(xml|rels|html|xhtml|txt|md|json|ya?ml|csv)$/i.test(name)); const chunks = []; for (const entryName of textLikeEntries.slice(0, 30)) { const raw = await zip.file(entryName).async('string'); if (/\.(html|xhtml?)$/i.test(entryName)) { chunks.push(`

    ${escapeHtml(entryName)}

    ${sanitizeHtml(extractBodyInnerHtml(raw))}
    `); } else { chunks.push(`

    ${escapeHtml(entryName)}

    ${buildTextHtml(raw)}
    `); } } html = chunks.join('') || buildTextHtml('압축 내부에 바로 표시할 본문을 찾지 못했습니다.'); text = stripHtmlToText(html); json = { files: entryNames, sampledEntries: textLikeEntries.slice(0, 30) }; viewLabel = 'ZIP 내부 텍스트 추출'; state.notes.push('일반 ZIP/OOXML 텍스트 샘플링'); } } const markdown = htmlToMarkdown(html); fillRepresentations({ text: text || '추출된 텍스트가 없습니다.', markdown: markdown || text, html: html || buildTextHtml(text || '추출 결과가 없습니다.'), json }); state.originalHtml = state.representations.html; state.originalHtmlIsPaper = true; state.originalViewLabel = viewLabel; state.editorKind = 'html'; setAvailableKindsAndExports(['html', 'markdown', 'text', 'json'], ['html', 'md', 'txt', 'docx', 'pdf', 'json']); await ensureMonaco(); setEditorKind('html'); } // ----------[🧩 loadOleDocument 처리]---------- async function loadOleDocument(file, providedBuffer = null) { const buffer = providedBuffer ?? new Uint8Array(await file.arrayBuffer()); state.bytes = buffer; let entries = []; let extractedText = ''; try { if (window.CFB && typeof window.CFB.read === 'function') { const cfb = window.CFB.read(buffer, { type: 'array' }); entries = Array.isArray(cfb?.FullPaths) ? cfb.FullPaths.filter(Boolean) : []; const parts = []; if (Array.isArray(cfb?.FileIndex)) { for (const item of cfb.FileIndex) { if (!item || !item.content || !(item.content instanceof Uint8Array || Array.isArray(item.content))) { continue; } const content = item.content instanceof Uint8Array ? item.content : new Uint8Array(item.content); const snippet = extractPrintableStrings(content); if (snippet !== '') { parts.push(`## ${item.name || item.FullPath || 'stream'}\n${snippet}`); } if (parts.length >= 16) { break; } } } extractedText = parts.join('\n\n').trim(); } } catch (error) { console.warn(error); } if (!extractedText) { extractedText = extractPrintableStrings(buffer); } const fallbackText = extractedText !== '' ? extractedText : `OLE 문서 구조는 감지했지만 읽을 수 있는 문자열이 많지 않습니다.\n\n파일명: ${file.name}\n확장자: ${state.ext || '(없음)'}\n크기: ${formatBytes(file.size)}`; fillRepresentations({ text: fallbackText, markdown: fallbackText, html: buildTextHtml(fallbackText), json: { entries, extension: state.ext, size: file.size } }); state.originalHtml = state.representations.html; state.originalHtmlIsPaper = false; state.originalViewLabel = 'OLE 구조/문자열 추출'; state.notes.push('구형 DOC/PPT/MSG/PUB 계열 추출 모드'); state.editorKind = 'text'; setAvailableKindsAndExports(['text', 'markdown', 'json'], ['txt', 'md', 'html', 'docx', 'pdf', 'json']); await ensureMonaco(); setEditorKind('text'); } // ----------[🧩 loadTextDocument 처리]---------- async function loadTextDocument(file) { const buffer = new Uint8Array(await file.arrayBuffer()); state.bytes = buffer; const text = decodeTextBytes(buffer); if (state.category === 'markdown') { fillRepresentations({ text, markdown: text, html: markdownToHtml(text), json: null }); state.editorKind = 'markdown'; state.originalHtml = markdownToHtml(text); state.originalHtmlIsPaper = true; state.originalViewLabel = 'Markdown 렌더'; state.notes.push('Markdown 실편집 모드'); setAvailableKindsAndExports(['markdown', 'html', 'text'], ['md', 'html', 'txt', 'docx', 'pdf']); } else if (state.category === 'html') { fillRepresentations({ text: stripHtmlToText(text), markdown: htmlToMarkdown(text), html: text, json: null }); state.editorKind = 'html'; state.originalHtml = sanitizeHtml(text); state.originalHtmlIsPaper = true; state.originalViewLabel = 'HTML 렌더'; state.notes.push('HTML 소스 직접 편집'); setAvailableKindsAndExports(['html', 'markdown', 'text'], ['html', 'md', 'txt', 'docx', 'pdf']); } else { let parsedJson = null; if (state.ext === 'json') { try { parsedJson = JSON.parse(text); fillRepresentations({ text: JSON.stringify(parsedJson, null, 2), markdown: '```json\n' + JSON.stringify(parsedJson, null, 2) + '\n```', html: buildTextHtml(JSON.stringify(parsedJson, null, 2)), json: parsedJson }); state.editorKind = 'json'; } catch (_) { fillRepresentations({ text, markdown: text, html: buildTextHtml(text), json: text }); state.editorKind = 'text'; } state.originalViewLabel = 'JSON 텍스트'; state.originalHtml = buildTextHtml(typeof parsedJson === 'object' ? JSON.stringify(parsedJson, null, 2) : text); state.originalHtmlIsPaper = false; state.notes.push(parsedJson ? 'JSON 파싱 성공' : 'JSON 텍스트로 유지'); setAvailableKindsAndExports(['json', 'text', 'markdown', 'html'], ['json', 'txt', 'md', 'html', 'docx', 'pdf']); } else { fillRepresentations({ text, markdown: text, html: buildTextHtml(text), json: null }); state.editorKind = 'text'; state.originalViewLabel = '텍스트 미리보기'; state.originalHtml = buildTextHtml(text); state.originalHtmlIsPaper = false; state.notes.push('텍스트 직접 편집'); setAvailableKindsAndExports(['text', 'markdown', 'html'], ['txt', 'md', 'html', 'docx', 'pdf']); } } await ensureMonaco(); setEditorKind(state.editorKind); } // ----------[🧩 loadSpreadsheetDocument 처리]---------- async function loadSpreadsheetDocument(file) { const buffer = await file.arrayBuffer(); state.bytes = new Uint8Array(buffer); const workbook = window.XLSX.read(buffer, { type: 'array', cellDates: true }); state.workbookSheets = workbook.SheetNames.map((sheetName) => ({ name: sheetName, grid: sheetToGrid(workbook.Sheets[sheetName]) })); state.activeSheetIndex = 0; state.editorKind = 'spreadsheet'; state.originalViewLabel = '시트 테이블 렌더'; state.notes.push(`${state.workbookSheets.length}개 시트 로드`); state.originalHtml = gridToHtml(getActiveSheetGrid(), getActiveSheetName()); state.originalHtmlIsPaper = true; state.representations.json = { sheets: state.workbookSheets.map((sheet) => ({ name: sheet.name, rows: sheet.grid.length, columns: getGridWidth(sheet.grid) })) }; setAvailableKindsAndExports(['spreadsheet'], ['xlsx', 'csv', 'tsv', 'html', 'json']); setEditorKind('spreadsheet'); } // ----------[🧩 loadDocxDocument 처리]---------- async function loadDocxDocument(file) { const buffer = await file.arrayBuffer(); state.bytes = new Uint8Array(buffer); state.originalDocxBuffer = buffer; state.originalViewLabel = 'DOCX 원본 렌더'; state.notes.push('원본 레이아웃은 docx-preview'); let workingHtml = ''; let workingSource = 'docx-xml'; let mammothMessages = []; try { workingHtml = await extractDocxEditableHtml(buffer); } catch (error) { console.warn(error); } if (stripHtmlToText(workingHtml) === '') { const mammothResult = await window.mammoth.convertToHtml( { arrayBuffer: buffer }, { includeDefaultStyleMap: true } ); workingHtml = sanitizeHtml(mammothResult.value || ''); workingSource = 'mammoth'; mammothMessages = Array.isArray(mammothResult.messages) ? mammothResult.messages : []; state.notes.push('DOCX XML 재구성이 비어 Mammoth 추출로 대체'); } else { state.notes.push('작업 보기는 DOCX XML 표/문단 구조 재구성'); } const markdown = htmlToMarkdown(workingHtml); const text = stripHtmlToText(workingHtml); fillRepresentations({ text, markdown, html: workingHtml, json: { source: workingSource, messages: mammothMessages, textLength: text.length } }); state.docxWorkingHtml = workingHtml; state.originalHtml = workingHtml; state.originalHtmlIsPaper = true; state.editorKind = 'html'; setAvailableKindsAndExports(['html', 'markdown', 'text'], ['docx', 'html', 'md', 'txt', 'pdf', 'json']); await ensureMonaco(); setEditorKind('html'); } // ----------[🧩 extractPdfText 처리]---------- async function extractPdfText(pdfDocument) { const pages = []; for (let pageNumber = 1; pageNumber <= pdfDocument.numPages; pageNumber += 1) { const page = await pdfDocument.getPage(pageNumber); const content = await page.getTextContent(); let pageText = ''; for (const item of content.items) { if (!item || typeof item.str !== 'string') { continue; } pageText += item.str; if (item.hasEOL) { pageText += '\n'; } else { pageText += ' '; } } pages.push(pageText.trim()); } return pages.join('\n\n').trim(); } // ----------[🧩 loadPdfDocument 처리]---------- async function loadPdfDocument(file) { const buffer = await file.arrayBuffer(); state.bytes = new Uint8Array(buffer); state.originalViewLabel = 'PDF 페이지 렌더'; state.notes.push('원본 PDF는 페이지 캔버스로 렌더'); const pdfjsLib = await loadPdfjs(); const pdfDocument = await pdfjsLib.getDocument({ data: state.bytes, isEvalSupported: false }).promise; state.originalPdfDocument = pdfDocument; const text = await extractPdfText(pdfDocument); fillRepresentations({ text, markdown: text, html: buildTextHtml(text), json: { pages: pdfDocument.numPages, extractedTextLength: text.length } }); state.editorKind = 'text'; state.originalHtml = buildTextHtml(text); state.originalHtmlIsPaper = false; setAvailableKindsAndExports(['text', 'markdown', 'html'], ['txt', 'md', 'html', 'docx', 'pdf', 'json']); await ensureMonaco(); setEditorKind('text'); } // ----------[🧩 parseHwpxNode 처리]---------- function parseHwpxNode(node) { if (!node) { return ''; } if (node.nodeType === Node.TEXT_NODE) { return escapeHtml(node.textContent || ''); } if (node.nodeType !== Node.ELEMENT_NODE) { return ''; } const children = Array.from(node.childNodes).map(parseHwpxNode).join(''); const localName = node.localName || node.nodeName; if (localName === 'section' || localName === 'body') { return children; } if (localName === 'p') { const content = children.trim() === '' ? ' ' : children; return `

    ${content}

    `; } if (localName === 'run' || localName === 'hp' || localName === 'linesegarray') { return children; } if (localName === 't') { return escapeHtml(node.textContent || ''); } if (localName === 'tbl') { return `${children}
    `; } if (localName === 'tr') { return `${children}`; } if (localName === 'tc') { return `${children}`; } if (localName === 'lineBreak' || localName === 'br') { return '
    '; } if (localName === 'tab') { return ' '; } return children; } // ----------[🧩 loadHwpxDocument 처리]---------- async function loadHwpxDocument(file) { const buffer = await file.arrayBuffer(); state.bytes = new Uint8Array(buffer); const zip = await window.JSZip.loadAsync(buffer); const sectionNames = Object.keys(zip.files) .filter((name) => /^Contents\/section\d+\.xml$/i.test(name)) .sort((left, right) => { const leftNumber = Number((left.match(/section(\d+)/i) || [])[1] || 0); const rightNumber = Number((right.match(/section(\d+)/i) || [])[1] || 0); return leftNumber - rightNumber; }); if (sectionNames.length === 0) { throw new Error('HWPX 섹션 XML을 찾지 못했습니다.'); } let html = ''; for (const sectionName of sectionNames) { const xmlText = await zip.file(sectionName).async('string'); const xmlDoc = new DOMParser().parseFromString(xmlText, 'application/xml'); if (xmlDoc.getElementsByTagName('parsererror').length > 0) { continue; } html += parseHwpxNode(xmlDoc.documentElement); } html = sanitizeHtml(html); const markdown = htmlToMarkdown(html); const text = stripHtmlToText(html); fillRepresentations({ text, markdown, html, json: { files: Object.keys(zip.files), sections: sectionNames } }); state.originalHtml = html; state.originalHtmlIsPaper = true; state.originalViewLabel = 'HWPX XML 렌더'; state.notes.push('HWPX는 브라우저에서 직접 ZIP/XML 파싱'); state.editorKind = 'html'; setAvailableKindsAndExports(['html', 'markdown', 'text', 'json'], ['html', 'md', 'txt', 'docx', 'pdf', 'json']); await ensureMonaco(); setEditorKind('html'); } // ----------[🧩 loadHwpDocument 처리]---------- async function loadHwpDocument(file) { const buffer = new Uint8Array(await file.arrayBuffer()); state.bytes = buffer; state.originalViewLabel = 'HWP HTML 렌더'; state.notes.push('HWP는 @ohah/hwpjs 로 파싱'); try { const hwpjs = await loadHwpjs(); const rawHtml = hwpjs.toHtml(buffer) || ''; const html = rawHtml; const markdownResult = hwpjs.toMarkdown(buffer, { image: 'base64', use_html: true }); const markdown = typeof markdownResult === 'string' ? markdownResult : (markdownResult?.markdown || ''); const jsonText = hwpjs.toJson(buffer); let jsonData = null; try { jsonData = JSON.parse(jsonText); } catch (_) { jsonData = jsonText; } const text = stripHtmlToText(html || markdownToHtml(markdown)); fillRepresentations({ text, markdown: markdown || text, html: html || markdownToHtml(markdown || text), json: jsonData }); state.hwpRawHtml = rawHtml; state.originalHtml = ''; state.originalHtmlIsPaper = false; state.editorKind = 'html'; setAvailableKindsAndExports(['html', 'markdown', 'text', 'json'], ['html', 'md', 'txt', 'docx', 'pdf', 'json']); await ensureMonaco(); setEditorKind('html'); } catch (error) { console.error(error); fillRepresentations({ text: 'HWP 파서 로드 또는 파싱에 실패했습니다.', markdown: '', html: buildTextHtml(`HWP 파서 로드 또는 파싱에 실패했습니다.\n${error instanceof Error ? error.message : String(error)}`), json: null }); state.originalHtml = state.representations.html; state.originalHtmlIsPaper = false; state.editorKind = 'text'; state.notes.push('HWP 파서 실패 시 텍스트 안내로 하향'); setAvailableKindsAndExports(['text'], ['txt', 'html', 'pdf']); await ensureMonaco(); setEditorKind('text'); } } // ----------[🧩 loadBinaryFallback 처리]---------- async function loadBinaryFallback(file) { const buffer = new Uint8Array(await file.arrayBuffer()); state.bytes = buffer; if (looksZipBytes(buffer)) { state.category = 'archive'; await loadArchiveDocument(file, buffer); return; } if (looksOleBytes(buffer)) { state.category = 'ole'; await loadOleDocument(file, buffer); return; } const decodedText = decodeTextBytes(buffer); const printableRatio = decodedText.length > 0 ? ((decodedText.match(/[^\u0000-\u0008\u000B\u000C\u000E-\u001F]/g) || []).length / decodedText.length) : 0; if (decodedText.trim() !== '' && printableRatio >= 0.88) { fillRepresentations({ text: decodedText, markdown: decodedText, html: buildTextHtml(decodedText), json: { fileName: file.name, extension: state.ext, size: file.size, inferredAs: 'text' } }); state.originalHtml = state.representations.html; state.originalHtmlIsPaper = false; state.originalViewLabel = '확장자 미등록 텍스트 추론'; state.notes.push('확장자 미등록이지만 텍스트로 판별'); state.editorKind = 'text'; setAvailableKindsAndExports(['text', 'markdown', 'html'], ['txt', 'md', 'html', 'docx', 'pdf', 'json']); await ensureMonaco(); setEditorKind('text'); return; } const heuristicText = extractPrintableStrings(buffer); const text = `지원 우선 포맷이 아닌 파일입니다.\n\n파일명: ${file.name}\n확장자: ${state.ext || '(없음)'}\n크기: ${formatBytes(file.size)}\nMIME: ${file.type || '(없음)'}\n\n${heuristicText !== '' ? `추출 가능한 문자열 샘플:\n${heuristicText}` : '이 형식은 현재 바이너리 원본 편집 대신 메타 확인만 제공합니다.'}`; fillRepresentations({ text, markdown: text, html: buildTextHtml(text), json: { fileName: file.name, extension: state.ext, size: file.size, mime: file.type || '' } }); state.originalHtml = buildTextHtml(text); state.originalHtmlIsPaper = false; state.originalViewLabel = '바이너리 안내'; state.notes.push('지원 우선 포맷 아님'); state.editorKind = 'text'; setAvailableKindsAndExports(['text', 'json'], ['txt', 'html', 'json']); await ensureMonaco(); setEditorKind('text'); } // ----------[🧩 loadFile 처리]---------- async function loadFile(file) { resetState(); state.file = file; state.fileName = file.name || 'untitled'; state.baseName = normalizeBaseName(state.fileName); state.ext = getExtension(state.fileName); state.category = detectCategory(state.ext, file); state.size = file.size || 0; refs.viewerHint.textContent = '문서를 해석하고 있습니다.'; refs.editorHint.textContent = '문서를 해석하고 있습니다.'; updateSummary(); showStatus(`${state.fileName} 파일을 여는 중입니다.`, 'info', 2600); try { if (state.category === 'text' || state.category === 'markdown' || state.category === 'html') { await loadTextDocument(file); } else if (state.category === 'spreadsheet') { await loadSpreadsheetDocument(file); } else if (state.category === 'docx') { await loadDocxDocument(file); } else if (state.category === 'archive') { await loadArchiveDocument(file); } else if (state.category === 'ole') { await loadOleDocument(file); } else if (state.category === 'pdf') { await loadPdfDocument(file); } else if (state.category === 'hwp') { await loadHwpDocument(file); } else if (state.category === 'hwpx') { await loadHwpxDocument(file); } else { await loadBinaryFallback(file); } updateSummary(); renderPreview('working'); refs.viewerHint.textContent = '바로 수정 보기에서 문서를 직접 눌러 편집하시고, 필요하면 원본/구조 보기로 전환하실 수 있습니다.'; refs.editorHint.textContent = `${editorKindLabel(state.editorKind)} 표현 데이터를 내부에서 준비했습니다.`; scheduleDraftSave(80); showStatus(`${state.fileName} 파일을 열었습니다.`, 'info'); } catch (error) { console.error(error); resetState(); const message = error instanceof Error ? error.message : String(error); refs.fallbackViewText.textContent = `문서를 여는 중 오류가 발생했습니다.\n${message}`; refs.fallbackView.hidden = false; showStatus(`문서 열기에 실패했습니다: ${message}`, 'error', 6800); } } // ----------[🧩 saveBlob 처리]---------- function saveBlob(blob, fileName) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; document.body.appendChild(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 4000); } // ----------[🧩 registerDownloadSuccess 처리]---------- async function registerDownloadSuccess() { try { const formData = new FormData(); formData.set('action', 'countDownload'); const response = await fetch(APP_PATH, { method: 'POST', body: formData, keepalive: true }); const data = await response.json(); if (data && data.ok && data.counts) { refs.buttonCounter.textContent = `DU${Math.max(0, Number(data.counts.db) || 0)} TU${Math.max(0, Number(data.counts.tb) || 0)}`; } } catch (_) { // noop } } // ----------[🧩 exportCurrentPdf 처리]---------- async function exportCurrentPdf(fileName) { if (typeof window.html2pdf !== 'function') { throw new Error('PDF 변환 라이브러리를 찾지 못했습니다.'); } refs.exportStage.innerHTML = wrapHtmlDocument(state.baseName, getCurrentHtmlFragment()); const body = refs.exportStage.querySelector('body'); if (!body) { throw new Error('PDF 내보내기용 HTML 생성에 실패했습니다.'); } await window.html2pdf() .set({ margin: [10, 10, 10, 10], filename: fileName, pagebreak: { mode: ['css', 'legacy'] }, image: { type: 'jpeg', quality: 0.98 }, html2canvas: { scale: 2, useCORS: true, backgroundColor: '#ffffff' }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' } }) .from(body) .save(); } // ----------[🧩 exportSpreadsheet 처리]---------- async function exportSpreadsheet(format) { const workbook = window.XLSX.utils.book_new(); for (const sheet of state.workbookSheets) { window.XLSX.utils.book_append_sheet( workbook, gridToSheet(sheet.grid), (sheet.name || 'Sheet').slice(0, 31) ); } if (format === 'xlsx') { const arrayBuffer = window.XLSX.write(workbook, { type: 'array', bookType: 'xlsx' }); saveBlob(new Blob([arrayBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `${state.baseName}.xlsx`); return; } if (format === 'csv' || format === 'tsv') { const separator = format === 'tsv' ? '\t' : ','; const text = window.XLSX.utils.sheet_to_csv(gridToSheet(getActiveSheetGrid()), { FS: separator }); saveBlob(new Blob([text], { type: 'text/plain;charset=utf-8' }), `${state.baseName}.${format}`); return; } if (format === 'html') { const html = wrapHtmlDocument(state.baseName, gridToHtml(getActiveSheetGrid(), getActiveSheetName())); saveBlob(new Blob([html], { type: 'text/html;charset=utf-8' }), `${state.baseName}.html`); return; } if (format === 'json') { const payload = { sheets: state.workbookSheets.map((sheet) => ({ name: sheet.name, data: sheet.grid })) }; saveBlob(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }), `${state.baseName}.json`); return; } throw new Error(`지원하지 않는 스프레드시트 저장 형식입니다: ${format}`); } // ----------[🧩 downloadCurrent 처리]---------- async function downloadCurrent() { if (state.category === 'empty') { showStatus('먼저 문서를 여셔야 합니다.', 'warn'); return; } syncViewerSurfaceToState(); const format = refs.exportSelect.value; if (!format) { showStatus('저장 형식을 먼저 고르셔야 합니다.', 'warn'); return; } try { if (state.category === 'spreadsheet') { await exportSpreadsheet(format); await registerDownloadSuccess(); showStatus(`.${format} 형식으로 저장했습니다.`, 'info'); return; } if (format === 'txt') { saveBlob(new Blob([getCurrentPlainText()], { type: 'text/plain;charset=utf-8' }), `${state.baseName}.txt`); } else if (format === 'md') { saveBlob(new Blob([getCurrentMarkdown()], { type: 'text/markdown;charset=utf-8' }), `${state.baseName}.md`); } else if (format === 'html') { const htmlDocument = state.category === 'hwp' && state.hwpRawHtml ? state.hwpRawHtml : wrapHtmlDocument(state.baseName, getCurrentHtmlFragment()); saveBlob(new Blob([htmlDocument], { type: 'text/html;charset=utf-8' }), `${state.baseName}.html`); } else if (format === 'docx') { const html = wrapHtmlDocument(state.baseName, getCurrentHtmlFragment()); const blob = window.htmlDocx.asBlob(html); saveBlob(blob, `${state.baseName}.docx`); } else if (format === 'pdf') { await exportCurrentPdf(`${state.baseName}.pdf`); } else if (format === 'json') { saveBlob(new Blob([getCurrentJsonText()], { type: 'application/json;charset=utf-8' }), `${state.baseName}.json`); } else { throw new Error(`지원하지 않는 저장 형식입니다: ${format}`); } await registerDownloadSuccess(); showStatus(`.${format} 형식으로 저장했습니다.`, 'info'); } catch (error) { console.error(error); showStatus(`저장에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`, 'error', 6800); } } // ----------[🧩 handleSheetInput 처리]---------- function handleSheetInput(target) { if (!(target instanceof HTMLElement) || !target.classList.contains('sheetCell')) { return; } const row = Number(target.dataset.row); const col = Number(target.dataset.col); if (!Number.isInteger(row) || !Number.isInteger(col)) { return; } const activeSheet = getActiveSheet(); if (!activeSheet) { return; } activeSheet.grid[row][col] = target.textContent || ''; state.originalHtml = gridToHtml(getActiveSheetGrid(), getActiveSheetName()); updateSummary(); scheduleDraftSave(); if (state.previewMode === 'original') { renderPreview('original'); } else if (state.previewMode === 'json') { refs.jsonViewText.textContent = getCurrentJsonText(); } } refs.sheetTable.addEventListener('input', (event) => { handleSheetInput(event.target); }); refs.htmlView.addEventListener('input', () => { if (state.previewMode !== 'working' || refs.htmlView.hidden || !getHtmlViewContentElement().getAttribute('contenteditable')) { return; } syncViewerSurfaceToState(); }); refs.jsonViewText.addEventListener('input', () => { if (refs.jsonView.hidden || !refs.jsonViewText.getAttribute('contenteditable')) { return; } syncViewerSurfaceToState(); }); refs.fallbackViewText.addEventListener('input', () => { if (refs.fallbackView.hidden || !refs.fallbackViewText.getAttribute('contenteditable')) { return; } syncViewerSurfaceToState(); }); refs.pickFileButton.addEventListener('click', () => { refs.fileInput.click(); }); refs.newDocumentButton.addEventListener('click', async () => { await startBlankDocument(); }); refs.newSheetButton.addEventListener('click', async () => { await startBlankSpreadsheet(); }); refs.restoreDraftButton.addEventListener('click', async () => { await restoreDraftSnapshot(); }); refs.fileInput.addEventListener('change', async (event) => { const file = event.target.files && event.target.files[0]; if (file) { await loadFile(file); } refs.fileInput.value = ''; }); refs.dropZone.addEventListener('dragover', (event) => { event.preventDefault(); refs.dropZone.classList.add('is-dragover'); }); refs.dropZone.addEventListener('dragleave', () => { refs.dropZone.classList.remove('is-dragover'); }); refs.dropZone.addEventListener('drop', async (event) => { event.preventDefault(); refs.dropZone.classList.remove('is-dragover'); const file = event.dataTransfer?.files?.[0]; if (file) { await loadFile(file); } }); refs.clearButton.addEventListener('click', () => { resetState({ clearDraft: true }); showStatus('문서 작업 상태를 초기화했습니다.', 'info'); }); refs.downloadButton.addEventListener('click', async () => { syncViewerSurfaceToState(); await downloadCurrent(); }); refs.copyTextButton.addEventListener('click', async () => { syncViewerSurfaceToState(); const text = getCurrentPlainText(); if (!text) { showStatus('복사할 텍스트가 없습니다.', 'warn'); return; } try { await navigator.clipboard.writeText(text); showStatus('현재 텍스트를 클립보드에 복사했습니다.', 'info'); } catch (error) { showStatus(`클립보드 복사에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`, 'error'); } }); refs.refreshPreviewButton.addEventListener('click', () => { syncViewerSurfaceToState(); syncEditorToState(); renderPreview(state.previewMode); showStatus('현재 보기 영역을 다시 그렸습니다.', 'info'); }); refs.editorKindSelect.addEventListener('change', () => { const nextKind = refs.editorKindSelect.value; if (!nextKind || !state.availableEditorKinds.includes(nextKind)) { return; } syncViewerSurfaceToState(); syncEditorToState(); setEditorKind(nextKind, false); showStatus(`${editorKindLabel(nextKind)} 표현으로 전환했습니다.`, 'info'); }); refs.previewOriginalButton.addEventListener('click', () => { syncViewerSurfaceToState(); renderPreview('original'); scheduleDraftSave(120); }); refs.previewWorkingButton.addEventListener('click', () => { syncViewerSurfaceToState(); syncEditorToState(); renderPreview('working'); scheduleDraftSave(120); }); refs.previewJsonButton.addEventListener('click', () => { syncViewerSurfaceToState(); syncEditorToState(); renderPreview('json'); scheduleDraftSave(120); }); refs.viewerPagePrevButton.addEventListener('click', () => { changeViewerPage(-1); }); refs.viewerPageNextButton.addEventListener('click', () => { changeViewerPage(1); }); refs.viewerZoomOutButton.addEventListener('click', () => { changeViewerZoom(-0.1); }); refs.viewerZoomResetButton.addEventListener('click', () => { resetViewerZoom(); }); refs.viewerZoomInButton.addEventListener('click', () => { changeViewerZoom(0.1); }); refs.viewerViewport.addEventListener('wheel', handleViewerWheelZoom, { passive: false }); refs.viewerViewport.addEventListener('mousedown', handleViewerMiddleClickReset); refs.addRowButton.addEventListener('click', () => { const activeSheet = getActiveSheet(); if (!activeSheet) { return; } activeSheet.grid.push(Array.from({ length: getGridWidth(activeSheet.grid) || 8 }, () => '')); state.originalHtml = gridToHtml(getActiveSheetGrid(), getActiveSheetName()); renderSheetTable(); updateSummary(); scheduleDraftSave(); renderPreview(state.previewMode); }); refs.removeRowButton.addEventListener('click', () => { const activeSheet = getActiveSheet(); if (!activeSheet || activeSheet.grid.length <= 1) { return; } activeSheet.grid.pop(); state.originalHtml = gridToHtml(getActiveSheetGrid(), getActiveSheetName()); renderSheetTable(); updateSummary(); scheduleDraftSave(); renderPreview(state.previewMode); }); refs.addColumnButton.addEventListener('click', () => { const activeSheet = getActiveSheet(); if (!activeSheet) { return; } const width = getGridWidth(activeSheet.grid); for (const row of activeSheet.grid) { row[width] = ''; } state.originalHtml = gridToHtml(getActiveSheetGrid(), getActiveSheetName()); renderSheetTable(); updateSummary(); scheduleDraftSave(); renderPreview(state.previewMode); }); refs.removeColumnButton.addEventListener('click', () => { const activeSheet = getActiveSheet(); if (!activeSheet) { return; } const width = getGridWidth(activeSheet.grid); if (width <= 1) { return; } for (const row of activeSheet.grid) { row.pop(); } state.originalHtml = gridToHtml(getActiveSheetGrid(), getActiveSheetName()); renderSheetTable(); updateSummary(); scheduleDraftSave(); renderPreview(state.previewMode); }); window.addEventListener('resize', () => { if (state.editor) { state.editor.layout(); } applyViewerPageLayout(); }); window.addEventListener('pagehide', () => { persistDraftSnapshotNow(); }); resetState(); updateRestoreDraftButton();