// files.js — file preview + attachment handling extracted from helpers.js. // Sheet preview (PDF embed, XLSX viewer, Luckysheet modal) + file upload/attachment // staging (handleImageUpload, handleFileUpload, stageAttachment, renderStagedAttachments, // pdfPageToText, ...). stagedAttachments stays global (sendChat reads it). function togglePdfEmbed(url, containerId, btn) { var container = document.getElementById(containerId); if (!container) return; var iframe = container.querySelector('iframe'); if (container.style.display === 'none') { container.style.display = 'block'; if (iframe) iframe.src = url; // src=""이면 현재페이지로 해석 → 항상 명시 설정 btn.innerHTML = '▲ 닫기'; } else { container.style.display = 'none'; btn.innerHTML = '◀ 미리보기'; } } var _luckyCurrentSaveUrl = null; function openXlsxNewWindow(url, title) { var q = '/html/xlsx-viewer.html?file=' + encodeURIComponent(url) + '&save=' + encodeURIComponent(url) + '&title=' + encodeURIComponent(title || '스프레드시트'); window.open(q, '_blank', 'width=1200,height=800,menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes'); } function toggleXlsxEmbedBtn(btn) { var url = encodeURI(btn.getAttribute('data-xlsx-url')); var card = btn.closest('.xlsx-card'); var nameEl = card ? card.querySelector('span[style*="font-weight"]') : null; var title = nameEl ? nameEl.textContent : url.split('/').pop(); var saveUrl = card ? card.querySelector('[data-xlsx-save-url]')?.getAttribute('data-xlsx-save-url') : null; openLuckyModal(url, title, saveUrl); } function openLuckyModal(url, title, saveUrl) { var modal = document.getElementById('lucky-modal'); var titleEl = document.getElementById('lucky-modal-title'); var container = document.getElementById('lucky-container'); if (!modal || !container) return; // Reset box to centered state on each open var box = document.getElementById('lucky-modal-box'); if (box) { box.style.position = 'relative'; box.style.left = ''; box.style.top = ''; box.style.width = '96vw'; box.style.height = '92vh'; box._isAbsolute = false; box.dataset.minimized = '0'; } modal.style.display = 'flex'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; titleEl.textContent = title || '스프레드시트'; _luckyCurrentSaveUrl = saveUrl || null; container.innerHTML = '
Luckysheet 로딩 중… (최초 1회 ~3MB)
'; initLuckyModalInteraction(); fetch(url, { headers: authHeaders() }) .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer(); }) .then(function(buf) { loadLuckysheet(function() { if (typeof LuckyExcel === 'undefined' || typeof luckysheet === 'undefined') { container.innerHTML = '
Luckysheet 로드 실패 — 네트워크 연결을 확인하세요.
'; return; } var file = new File([buf], 'sheet.xlsx', { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); LuckyExcel.transformExcelToLucky(file, function(exportInfo) { container.innerHTML = ''; try { luckysheet.destroy(); } catch(e) {} luckysheet.create({ container: 'lucky-container', data: exportInfo.sheets, title: '', lang: 'en', showinfobar: false, showstatisticBar: false, sheetBottomConfig: true, allowUpdate: true, enableAddRow: true, enableAddCol: true, showToolbar: true, plugins: [], defaultFontSize: 11, defaultFontName: 'Nanum Gothic', }); }, function(err) { container.innerHTML = '
Excel 변환 오류: ' + escHtml(String(err?.message || err)) + '
'; }); }); }) .catch(function(e) { container.innerHTML = '
불러오기 실패: ' + escHtml(e.message) + '
'; }); } function minimizeLuckyModal() { var box = document.getElementById('lucky-modal-box'); var modal = document.getElementById('lucky-modal'); var btn = event.target; if (!box || !modal) return; var minimized = box.dataset.minimized === '1'; if (minimized) { box.style.height = box._savedHeight || '92vh'; box.dataset.minimized = '0'; btn.textContent = '–'; btn.title = '최소화'; if (!box._isAbsolute) { modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; } } else { box._savedHeight = box.style.height || '92vh'; box.dataset.minimized = '1'; btn.innerHTML = '◻'; btn.title = '복원'; if (box._isAbsolute) { box.style.top = (window.innerHeight - 52) + 'px'; box.style.height = 'auto'; } else { box.style.height = 'auto'; modal.style.alignItems = 'flex-end'; modal.style.justifyContent = 'center'; modal.style.paddingBottom = '0'; } } } function closeLuckyModal() { var modal = document.getElementById('lucky-modal'); var box = document.getElementById('lucky-modal-box'); if (modal) { modal.style.display = 'none'; modal.style.alignItems = 'center'; modal.style.justifyContent = 'center'; } if (box) { box.style.position = 'relative'; box.style.left = ''; box.style.top = ''; box.style.width = '96vw'; box.style.height = '92vh'; box._isAbsolute = false; box._savedHeight = null; box.dataset.minimized = '0'; } try { luckysheet.destroy(); } catch(e) {} _luckyCurrentSaveUrl = null; } // Close on backdrop click document.getElementById('lucky-modal')?.addEventListener('click', function(e) { if (e.target === this) closeLuckyModal(); }); function initLuckyModalInteraction() { var box = document.getElementById('lucky-modal-box'); var modal = document.getElementById('lucky-modal'); if (!box || box._interactInit) return; box._interactInit = true; // Inject 8 resize handles [ { dir:'n', css:'top:0;left:10px;right:10px;height:6px;cursor:n-resize' }, { dir:'s', css:'bottom:0;left:10px;right:10px;height:6px;cursor:s-resize' }, { dir:'e', css:'right:0;top:10px;bottom:10px;width:6px;cursor:e-resize' }, { dir:'w', css:'left:0;top:10px;bottom:10px;width:6px;cursor:w-resize' }, { dir:'ne', css:'top:0;right:0;width:12px;height:12px;cursor:ne-resize' }, { dir:'nw', css:'top:0;left:0;width:12px;height:12px;cursor:nw-resize' }, { dir:'se', css:'bottom:0;right:0;width:12px;height:12px;cursor:se-resize' }, { dir:'sw', css:'bottom:0;left:0;width:12px;height:12px;cursor:sw-resize' }, ].forEach(function(d) { var h = document.createElement('div'); h.className = 'lucky-rh'; h.dataset.dir = d.dir; h.style.cssText = 'position:absolute;z-index:200;' + d.css; box.appendChild(h); }); function toAbsolute() { if (box._isAbsolute) return; var rect = box.getBoundingClientRect(); modal.style.display = 'block'; box.style.position = 'absolute'; box.style.left = rect.left + 'px'; box.style.top = rect.top + 'px'; box.style.width = rect.width + 'px'; box.style.height = rect.height + 'px'; box._isAbsolute = true; } // Title bar drag var header = box.firstElementChild; if (header) { header.style.cursor = 'grab'; header.addEventListener('mousedown', function(e) { if (e.target.tagName === 'BUTTON') return; toAbsolute(); var rect = box.getBoundingClientRect(); var ox = e.clientX - rect.left, oy = e.clientY - rect.top; header.style.cursor = 'grabbing'; function onMove(e) { box.style.left = (e.clientX - ox) + 'px'; box.style.top = Math.max(0, e.clientY - oy) + 'px'; } function onUp() { header.style.cursor = 'grab'; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault(); }); } // Resize handle drag box.addEventListener('mousedown', function(e) { var handle = e.target.closest ? e.target.closest('.lucky-rh') : null; if (!handle) return; toAbsolute(); var dir = handle.dataset.dir; var rect = box.getBoundingClientRect(); var sx = e.clientX, sy = e.clientY; var sl = rect.left, st = rect.top, sw = rect.width, sh = rect.height; document.body.style.userSelect = 'none'; document.body.style.cursor = handle.style.cursor; function onMove(e) { var dx = e.clientX - sx, dy = e.clientY - sy; var nl = sl, nt = st, nw = sw, nh = sh; if (dir.includes('e')) nw = Math.max(380, sw + dx); if (dir.includes('s')) nh = Math.max(180, sh + dy); if (dir.includes('w')) { nw = Math.max(380, sw - dx); nl = sl + sw - nw; } if (dir.includes('n')) { nh = Math.max(180, sh - dy); nt = st + sh - nh; } box.style.left = nl + 'px'; box.style.top = nt + 'px'; box.style.width = nw + 'px'; box.style.height = nh + 'px'; } function onUp() { document.body.style.userSelect = ''; document.body.style.cursor = ''; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); } document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); e.preventDefault(); e.stopPropagation(); }); } function saveLuckyModal() { var url = _luckyCurrentSaveUrl; if (!url) { alert('저장할 파일 경로가 없습니다.'); return; } var btn = document.getElementById('lucky-modal-save'); var relPath = url.replace(/^\/api\/files\//, ''); loadSheetJS(function() { try { // Luckysheet → SheetJS aoa conversion var sheets = luckysheet.getAllSheets(); var wb = XLSX.utils.book_new(); sheets.forEach(function(sheet) { var celldata = sheet.celldata || []; if (!celldata.length) { XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([[]]), sheet.name || 'Sheet'); return; } var maxR = 0, maxC = 0; celldata.forEach(function(c) { maxR = Math.max(maxR, c.r); maxC = Math.max(maxC, c.c); }); var aoa = []; for (var r = 0; r <= maxR; r++) aoa.push(new Array(maxC + 1).fill('')); celldata.forEach(function(c) { if (c.v == null) return; var val = (typeof c.v === 'object') ? (c.v.m !== undefined ? c.v.m : (c.v.v !== undefined ? c.v.v : '')) : c.v; aoa[c.r][c.c] = val; }); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), sheet.name || 'Sheet'); }); var xlsxBuf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); btn.disabled = true; btn.textContent = '저장 중…'; fetch('/api/files/' + relPath, { method: 'PUT', headers: authHeaders(), body: new Blob([xlsxBuf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), }) .then(function(r) { return r.json(); }) .then(function(res) { btn.disabled = false; btn.innerHTML = '💾 저장'; if (res.success) { btn.textContent = '✓ 저장됨'; setTimeout(function() { btn.innerHTML = '💾 저장'; }, 2000); } else alert('저장 실패: ' + (res.error || '알 수 없는 오류')); }) .catch(function(e) { btn.disabled = false; btn.innerHTML = '💾 저장'; alert('저장 오류: ' + e.message); }); } catch(e) { alert('Excel 변환 오류: ' + e.message); } }); } function toggleStagedExcelPreview(id) { var el = document.getElementById('excel-staged-' + id); if (!el) return; el.style.display = el.style.display === 'none' ? 'block' : 'none'; } function toggleTxtEmbedBtn(btn) { var card = btn.closest('.txt-card'); if (!card) return; var container = card.querySelector('.txt-preview-body'); if (!container) return; var url = encodeURI(btn.getAttribute('data-txt-url')); if (container.style.display === 'none') { container.style.display = 'block'; btn.innerHTML = '▲ 접기'; if (!container.dataset.loaded) { container.textContent = '불러오는 중…'; fetch(url, { headers: authHeaders() }) .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); }) .then(function(t) { container.textContent = t; container.dataset.loaded = '1'; }) .catch(function(e) { container.textContent = '텍스트를 불러올 수 없습니다. (' + e.message + ')'; }); } } else { container.style.display = 'none'; btn.innerHTML = '◀ 미리보기'; } } function togglePptxPreview(fileUrl, containerId, btn) { var container = document.getElementById(containerId); if (!container) return; var isVisible = container.style.display !== 'none'; if (isVisible) { container.style.display = 'none'; btn.innerHTML = '◀ Preview'; return; } // Show loading state container.style.display = 'flex'; container.innerHTML = 'Generating preview...'; btn.innerHTML = '▲ Preview'; btn.classList.add('loading'); // Extract relative path from various URL formats: // /api/files/Folder/file.pptx → Folder/file.pptx // https://host/api/files/Folder/file.pptx → Folder/file.pptx // Folder/file.pptx → Folder/file.pptx var filePath = fileUrl; // Handle sandboxed environments where origin may be sandbox:/ if (fileUrl.charAt(0) === '/') { filePath = fileUrl; // Already an absolute path, use as-is } else { try { filePath = new URL(fileUrl, window.location.origin).pathname; } catch(e) {} } filePath = filePath.replace(/^\/api\/files\/?/, ''); // If the link itself is a preview URL, extract the path query parameter var previewMatch = filePath.match(/^\/api\/pptx\/preview\?path=(.+)$/); if (previewMatch) { filePath = previewMatch[1]; } // Safely decode - handle already-decoded or invalid encodings try { filePath = decodeURIComponent(filePath); } catch(e) {} fetch('/api/pptx/preview?path=' + encodeURIComponent(filePath), { headers: authHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { btn.classList.remove('loading'); if (!data.success || !data.images || !data.images.length) { container.innerHTML = 'Preview failed: ' + escHtml(data.error || 'No slides found') + ''; return; } // img 태그는 Bearer 헤더를 보낼 수 없어서 auth 없이 요청 → user context 미설정 → 파일 못 찾음. // fetch()로 auth 포함해 가져온 뒤 Blob URL로 교체. Promise.all(data.images.map(function(imgUrl) { return fetch(imgUrl, { headers: authHeaders() }) .then(function(r) { return r.ok ? r.blob() : Promise.reject(new Error('HTTP ' + r.status)); }) .then(function(blob) { return URL.createObjectURL(blob); }) .catch(function() { return imgUrl; }); })).then(function(srcs) { container.innerHTML = srcs.map(function(src, i) { return 'Slide ' + (i + 1) + ''; }).join(''); }); }) .catch(function(err) { btn.classList.remove('loading'); container.innerHTML = 'Preview error: ' + escHtml(err.message) + ''; }); } function handleImageUpload(input) { var file = input.files && input.files[0]; if (!file) return; input.value = ''; var formData = new FormData(); formData.append('image', file); fetch('/api/upload/image', { method: 'POST', headers: authHeaders(), body: formData }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success && data.path) { var chatInput = document.getElementById('chat-input'); var imgRef = '![image](/api/files/' + data.path + ')'; chatInput.value = chatInput.value ? chatInput.value + '\n' + imgRef : imgRef; chatInput.focus(); } else { alert(data.error || 'Upload failed'); } }) .catch(function(e) { alert('Upload error: ' + e.message); }); } // ── 파일 첨부 (이미지·PDF·Excel·txt·csv 등) ───────────────────────────────── function handleFileUpload(input) { var file = input.files && input.files[0]; if (!file) return; input.value = ''; var name = file.name.toLowerCase(); // MIDI → 악보 변환 if (/\.(mid|midi)$/.test(name) || file.type === 'audio/midi' || file.type === 'audio/x-midi') { uploadMidiAndRender(file); return; } // 이미지 if (file.type.startsWith('image/')) { var imgForm = new FormData(); imgForm.append('image', file); fetch('/api/upload/image', { method: 'POST', headers: authHeaders(), body: imgForm }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success && data.path) { insertIntoChatInput('![' + file.name + '](/api/files/' + data.path + ')'); } else { alert(data.error || '이미지 업로드 실패'); } }) .catch(function(e) { alert('업로드 오류: ' + e.message); }); // txt / md } else if (/\.(txt|md)$/.test(name)) { var r1 = new FileReader(); r1.onload = function(e) { var ext = name.split('.').pop(); stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```' + ext + '\n' + e.target.result + '\n```', 'text'); }; r1.readAsText(file, 'UTF-8'); // CSV } else if (/\.csv$/.test(name)) { var r2 = new FileReader(); r2.onload = function(e) { stageAttachment(file.name, null, '**[파일: ' + file.name + ']**\n```csv\n' + e.target.result + '\n```', 'csv'); }; r2.readAsText(file, 'UTF-8'); // Excel } else if (/\.(xlsx|xls)$/.test(name)) { var xlsxFile = file; var xlsxForm = new FormData(); xlsxForm.append('file', xlsxFile); var xlsxUploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: xlsxForm }) .then(function(r) { return r.json(); }).catch(function() { return {}; }); loadSheetJS(function() { var r3 = new FileReader(); r3.onload = function(e) { try { var wb = XLSX.read(e.target.result, { type: 'array' }); var markdownContent = '**[파일: ' + xlsxFile.name + ']**\n\n'; var previewHtml = ''; wb.SheetNames.forEach(function(sn) { var rows = XLSX.utils.sheet_to_json(wb.Sheets[sn], { header: 1 }); markdownContent += '### ' + sn + '\n'; rows.forEach(function(row) { markdownContent += '| ' + row.map(function(c) { return String(c ?? ''); }).join(' | ') + ' |\n'; }); markdownContent += '\n'; previewHtml += '
' + escHtml(sn) + '
'; previewHtml += '
'; rows.slice(0, 30).forEach(function(row, ri) { previewHtml += ''; row.forEach(function(cell) { var tag = ri === 0 ? 'th' : 'td'; previewHtml += '<' + tag + ' style="border:1px solid var(--line);padding:3px 7px;text-align:left;white-space:nowrap">' + escHtml(String(cell ?? '')) + ''; }); previewHtml += ''; }); if (rows.length > 30) previewHtml += ''; previewHtml += '
… ' + (rows.length - 30) + '행 더
'; }); xlsxUploadPromise.then(function(d) { var fileUrl = (d.success && d.path) ? '/api/files/' + d.path : null; stageAttachment(xlsxFile.name, fileUrl, markdownContent, 'excel', { excelPreviewHtml: previewHtml }); }); } catch(err) { alert('Excel 파싱 오류: ' + err.message); } }; r3.readAsArrayBuffer(xlsxFile); }); // PDF — 업로드 + PDF.js 텍스트 추출 + txt 자동 저장 } else if (/\.pdf$/.test(name)) { var pdfFile = file; var pdfForm = new FormData(); pdfForm.append('file', pdfFile); var uploadPromise = fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: pdfForm }) .then(function(r) { return r.json(); }); var extractPromise = new Promise(function(resolve) { loadPdfJs(function() { var fr = new FileReader(); fr.onload = function(e) { pdfjsLib.getDocument({ data: new Uint8Array(e.target.result) }).promise .then(function(pdf) { var pages = []; for (var i = 1; i <= pdf.numPages; i++) pages.push(pdf.getPage(i)); Promise.all(pages).then(function(ps) { return Promise.all(ps.map(function(p, pi) { return p.getTextContent().then(function(tc) { return pdfPageToText(tc.items, pi + 1, pdf.numPages); }); })); }).then(function(texts) { resolve({ text: texts.join('\n\n'), pages: pdf.numPages }); }).catch(function() { resolve({ text: '', pages: 0 }); }); }).catch(function() { resolve({ text: '', pages: 0 }); }); }; fr.readAsArrayBuffer(pdfFile); }); }); Promise.all([uploadPromise, extractPromise]).then(function(res) { var upData = res[0], extracted = res[1]; var fileUrl = (upData.success && upData.path) ? '/api/files/' + upData.path : null; var fullText = (extracted.text || '').trim(); if (fullText) { var txtName = pdfFile.name.replace(/\.pdf$/i, '.txt'); var txtBlob = new Blob([extracted.text], { type: 'text/plain; charset=utf-8' }); var txtForm = new FormData(); txtForm.append('file', new File([txtBlob], txtName, { type: 'text/plain' })); fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: txtForm }) .then(function(r) { return r.json(); }) .then(function(d) { var txtUrl = (d.success && d.path) ? '/api/files/' + d.path : null; if (txtUrl) addProcessEntry('info', '📝 텍스트 저장: ' + txtName); var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')'; if (txtUrl) content += '\n[' + txtName + '](' + txtUrl + ')'; content += '\n\n이 파일로 무엇을 도와드릴까요? 사용자가 원하는 작업을 직접 알려주기 전까지 어떤 변환이나 생성 작업도 시작하지 마세요.'; stageAttachment(pdfFile.name, fileUrl, content, 'pdf', { txtPreview: fullText.slice(0, 500), txtUrl: txtUrl, txtName: txtName, pages: extracted.pages }); }).catch(function() { var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n\n이 파일로 무엇을 도와드릴까요?'; stageAttachment(pdfFile.name, fileUrl, content, 'pdf', { txtPreview: fullText.slice(0, 500), pages: extracted.pages }); }); } else { var content = '[' + pdfFile.name + '](' + (fileUrl || '') + ')\n*(텍스트 추출 불가 — 스캔 이미지이거나 보안 잠금 PDF)*'; stageAttachment(pdfFile.name, fileUrl, content, 'pdf'); } }).catch(function(e) { alert('PDF 처리 오류: ' + e.message); }); // 기타 (docx/pptx 등) } else { var otherForm = new FormData(); otherForm.append('file', file); fetch('/api/upload/file', { method: 'POST', headers: authHeaders(), body: otherForm }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success && data.path) { insertIntoChatInput('[' + file.name + '](/api/files/' + data.path + ')'); } else { alert(data.error || '파일 업로드 실패'); } }) .catch(function(e) { alert('업로드 오류: ' + e.message); }); } } function insertIntoChatInput(text) { var el = document.getElementById('chat-input'); el.value = el.value ? el.value + '\n' + text : text; el.focus(); el.dispatchEvent(new Event('input')); } // ── 첨부 파일 스테이징 ────────────────────────────────────────────────────── var stagedAttachments = []; function stageAttachment(name, url, content, type, extra) { var id = 'att-' + Date.now() + '-' + Math.random().toString(36).substr(2, 5); var att = { id: id, name: name, url: url, content: content, type: type }; if (extra) Object.assign(att, extra); stagedAttachments.push(att); renderStagedAttachments(); } function removeStagedAttachment(id) { stagedAttachments = stagedAttachments.filter(function(a) { return a.id !== id; }); renderStagedAttachments(); } function clearStagedAttachments() { stagedAttachments = []; renderStagedAttachments(); } function renderStagedAttachments() { var container = document.getElementById('staged-attachments'); if (!container) return; if (!stagedAttachments.length) { container.style.display = 'none'; container.innerHTML = ''; return; } container.style.display = 'flex'; container.innerHTML = stagedAttachments.map(function(a) { var icon = a.type === 'pdf' ? '📄' : a.type === 'excel' ? '📊' : a.type === 'csv' ? '📋' : '📝'; var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px'; // PDF 카드 if (a.type === 'pdf') { var txtPreview = a.txtPreview ? escHtml(a.txtPreview) + (a.txtPreview.length >= 500 ? '...' : '') : ''; var pagesInfo = a.pages ? ' (' + a.pages + '페이지)' : ''; return '
' + '
' + '📄' + '' + escHtml(a.name) + '' + (txtPreview ? '' : '') + (a.url ? '' : '') + (a.txtUrl ? '📝⬇' : '') + '' + '
' + (pagesInfo ? '
텍스트 추출 완료' + pagesInfo + '
' : '') + (txtPreview ? '' : '') + '' + '
'; } // TXT 자동저장 카드 (미리보기 + 다운로드) if (a.type === 'txt-auto') { var preview = escHtml((a.content || '').slice(0, 300)) + ((a.content || '').length > 300 ? '...' : ''); return '
' + '
' + '📝' + '' + escHtml(a.name) + '' + '' + (a.url ? '⬇ 저장' : '') + '' + '
' + '' + '
'; } // excel 카드 if (a.type === 'excel') { return '
' + '
' + '📊' + '' + escHtml(a.name) + '' + (a.excelPreviewHtml ? '' : '') + (a.url ? '⬇ 저장' : '') + '' + '
' + (a.excelPreviewHtml ? '' : '') + '
'; } // 기타 카드 (text, csv) return '
' + '
' + '' + icon + '' + '' + escHtml(a.name) + '' + '' + '
' + '
'; }).join(''); } function toggleStagedPdfPreview(id) { var el = document.getElementById('pdf-preview-' + id); if (!el) return; var att = stagedAttachments.find(function(a) { return a.id === id; }); if (el.style.display === 'none') { el.style.display = 'block'; var iframe = el.querySelector('iframe'); if (iframe && att && att.url) iframe.src = att.url; // 항상 명시 설정 } else { el.style.display = 'none'; } } function toggleStagedTxtPreview(id) { var el = document.getElementById('txt-preview-' + id); if (!el) return; el.style.display = el.style.display === 'none' ? 'block' : 'none'; } function toggleStagedTxtPdfPreview(id) { var el = document.getElementById('txt-pdf-preview-' + id); if (!el) return; el.style.display = el.style.display === 'none' ? 'block' : 'none'; } // PDF 카드에 txt 저장 버튼 동적 삽입 (txt 업로드 완료 후 호출) function addTxtButtonToPdfCard(pdfName, txtName, txtUrl) { // stagedAttachments에서 해당 PDF 찾기 var att = stagedAttachments.find(function(a) { return a.name === pdfName && a.type === 'pdf'; }); if (!att) return; // txt 정보를 attachment에 저장 (전송 시 displayMessage에 포함용) att.txtName = txtName; att.txtUrl = txtUrl; // 카드 DOM에 txt 버튼 삽입 var card = document.getElementById('card-' + att.id); if (!card) return; var btnRow = card.querySelector('div'); if (!btnRow) return; var btnStyle = 'background:none;border:1px solid var(--brand);cursor:pointer;font-size:11px;color:var(--brand);padding:2px 6px;border-radius:4px;text-decoration:none;display:inline-block'; var txtBtn = document.createElement('a'); txtBtn.href = txtUrl; txtBtn.download = txtName; txtBtn.title = txtName + ' 다운로드'; txtBtn.style.cssText = btnStyle; txtBtn.innerHTML = '📝 txt ⬇'; // ✕ 버튼 바로 앞에 삽입 var closeBtn = btnRow.lastElementChild; btnRow.insertBefore(txtBtn, closeBtn); } // ── PDF 페이지 텍스트 구조화 ──────────────────────────────────────────────── function pdfPageToText(items, pageNum, totalPages) { // filter out empty items var valid = items.filter(function(it) { return it.str && it.str.trim() !== ''; }); if (!valid.length) return ''; // sort top→bottom (y desc), left→right (x asc) valid.sort(function(a, b) { var ay = a.transform[5], by = b.transform[5]; if (Math.abs(ay - by) > 2) return by - ay; return a.transform[4] - b.transform[4]; }); // group items into lines by y-coordinate proximity var LINE_Y_THRESH = 3; var lines = []; var cur = null; valid.forEach(function(item) { var y = item.transform[5]; var h = item.height || 10; if (!cur || Math.abs(y - cur.y) > LINE_Y_THRESH) { cur = { y: y, h: h, items: [item] }; lines.push(cur); } else { cur.items.push(item); if (h > cur.h) cur.h = h; } }); // build output: join items per line; insert blank line on large y-gaps (paragraph break) var out = []; if (totalPages > 1) out.push('── ' + pageNum + '페이지 ──'); for (var i = 0; i < lines.length; i++) { var lineText = lines[i].items.map(function(it) { return it.str; }).join('').replace(/\s+/g, ' ').trim(); if (!lineText) continue; // paragraph break: gap > 1.4× current line height if (i > 0) { var gap = lines[i - 1].y - lines[i].y; if (gap > lines[i - 1].h * 1.4) out.push(''); } // fix hyphenation: merge "단어-\n다음줄" → "단어다음줄" if (out.length && out[out.length - 1].endsWith('-')) { out[out.length - 1] = out[out.length - 1].slice(0, -1) + lineText; } else { out.push(lineText); } } return out.join('\n'); }