Files
homeclaw/web-ui/pptx-wizard.html
T
kimandClaude Sonnet 4.6 557e328610 Integrate mistral vision analysis into PDF figure extraction flow
- Image extraction now immediately followed by per-figure mistral analysis
  no manual button click needed: extract → analyze runs as one atomic flow
- Progress shows "이미지 분석 1/5..." inline with prep step
- Captions + 제외 filtering available before outline generation
- "🔍 재분석" button retained for manual uploads / re-run on failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 15:04:47 +09:00

3761 lines
199 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>📊 슬라이드 제작</title>
<style>
:root{
--bg:#0f1117;--panel:#1a1d27;--panel2:#22263a;--line:#2e3250;
--text:#e2e8f0;--muted:#94a3b8;--brand:#6366f1;--brand2:#818cf8;
--green:#22c55e;--red:#ef4444;--yellow:#f59e0b;--r:10px;
}
body.theme-light{
--bg:#eef1f8;--panel:#ffffff;--panel2:#f0f3fb;--line:#cdd3e8;
--text:#1e2337;--muted:#5a6480;--brand:#4f52cc;--brand2:#6366f1;
--green:#15803d;--red:#dc2626;--yellow:#b45309;
}
body.theme-warm{
--bg:#f5f0e8;--panel:#fffdf7;--panel2:#ede8dc;--line:#d6ccbb;
--text:#2c2318;--muted:#7a6e5e;--brand:#7c5c2e;--brand2:#a07840;
--green:#15803d;--red:#dc2626;--yellow:#b45309;
}
.theme-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:4px 10px;font-size:12px;color:var(--muted);cursor:pointer;transition:.15s;}
.theme-btn:hover,.theme-btn.active{border-color:var(--brand);color:var(--brand2);background:rgba(99,102,241,.08);}
.src-chk{display:flex;align-items:flex-start;gap:6px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;background:var(--panel2);border:1.5px solid var(--line);border-radius:7px;padding:7px 12px;transition:.15s;user-select:none;max-width:220px;}
.src-chk:has(input:checked){border-color:var(--brand);background:rgba(99,102,241,.1);color:var(--brand2);}
.src-chk input{accent-color:var(--brand);width:13px;height:13px;cursor:pointer;margin-top:2px;flex-shrink:0;}
.src-chk-desc{display:block;font-size:10px;font-weight:400;color:var(--muted);margin-top:3px;line-height:1.5;}
*{margin:0;padding:0;box-sizing:border-box;}
body{background:var(--bg);color:var(--text);font-family:'Segoe UI',system-ui,sans-serif;height:100vh;display:flex;flex-direction:column;overflow:hidden;font-size:14px;}
.header{display:flex;align-items:center;gap:10px;padding:10px 18px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
.header h1{font-size:16px;font-weight:700;}
.steps{display:flex;gap:2px;margin-left:auto;align-items:center;}
.step-hint{font-size:10px;color:var(--muted);margin-right:6px;}
.sdot{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);padding:5px 12px;border-radius:6px;cursor:pointer;transition:.15s;user-select:none;}
.sdot.active{color:var(--brand2);background:rgba(99,102,241,.15);font-weight:700;}
.sdot.done{color:var(--green);}
.sdot .n{width:20px;height:20px;border-radius:50%;border:1.5px solid currentColor;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;}
.body{display:flex;flex:1;min-height:0;}
.sv{display:none;flex-direction:column;flex:1;min-height:0;}
.sv.active{display:flex;}
.ptitle{font-size:11px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;padding:11px 16px 8px;border-bottom:1px solid var(--line);flex-shrink:0;}
.scroll{flex:1;overflow-y:auto;padding:14px 16px;}
/* Buttons */
.btn{padding:8px 16px;border:none;border-radius:var(--r);font-size:13px;font-weight:600;cursor:pointer;transition:.15s;white-space:nowrap;}
.btn:disabled{opacity:.4;cursor:not-allowed;}
.bp{background:var(--brand);color:#fff;}.bp:not(:disabled):hover{background:var(--brand2);}
.bg{background:var(--panel2);color:var(--text);border:1px solid var(--line);}.bg:not(:disabled):hover{background:var(--line);}
.bgreen{background:#166534;color:#86efac;border:1px solid #166534;}.bgreen:not(:disabled):hover{background:#15803d;}
.bred{background:#7f1d1d;color:#fca5a5;border:1px solid #7f1d1d;}
/* Search */
.srow{display:flex;gap:8px;margin-bottom:12px;}
.srow input{flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);padding:8px 12px;font-size:13px;color:var(--text);outline:none;}
.srow input:focus{border-color:var(--brand);}
.srow .yin{width:80px;flex-shrink:0;}
/* Paper cards */
.pcard{background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);padding:11px 13px;margin-bottom:8px;display:flex;gap:10px;align-items:flex-start;transition:.15s;}
.pcard.sel{border-color:var(--brand);background:rgba(99,102,241,.07);}
.pcard input[type=checkbox]{margin-top:3px;flex-shrink:0;accent-color:var(--brand);width:15px;height:15px;cursor:pointer;}
.pmeta{flex:1;min-width:0;}
.ptitle2{font-size:13px;font-weight:600;line-height:1.45;margin-bottom:4px;}
.psub{font-size:11px;color:var(--muted);}
.badge{display:inline-block;font-size:10px;padding:2px 6px;border-radius:3px;margin-right:3px;font-weight:700;}
.bpmc{background:rgba(34,197,94,.15);color:var(--green);}
.byear{background:rgba(99,102,241,.15);color:var(--brand2);}
.btype{background:rgba(245,158,11,.1);color:var(--yellow);}
.bnopmc{background:rgba(239,68,68,.1);color:var(--red);}
.bupload{background:rgba(99,102,241,.15);color:var(--brand2);}
.dropzone{border:2px dashed var(--line);border-radius:10px;padding:18px 14px;text-align:center;cursor:pointer;transition:.15s;color:var(--muted);font-size:12px;margin-bottom:6px;}
.dropzone:hover,.dropzone.drag{border-color:var(--brand);color:var(--brand2);background:rgba(99,102,241,.05);}
.dropzone input[type=file]{display:none;}
.pbtns{display:flex;gap:5px;margin-top:6px;}
/* Prep */
.prog{padding:8px 10px;border-bottom:1px solid var(--line);font-size:13px;border-radius:6px;transition:background .2s;}
.prog.active{background:rgba(99,102,241,.08);border-left:3px solid var(--brand2);padding-left:8px;}
.psp{display:inline-block;padding:2px 9px;border-radius:10px;font-size:11px;font-weight:600;white-space:nowrap;}
.psp-wait{background:var(--line);color:var(--muted);}
.psp-run{background:rgba(99,102,241,.2);color:var(--brand2);}
.psp-done{background:rgba(34,197,94,.15);color:var(--green);}
.psp-fail{background:rgba(239,68,68,.15);color:var(--red);}
.psp-skip{background:var(--line);color:var(--muted);}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
.psp-run{animation:blink 1.2s ease-in-out infinite;}
.prog:last-child{border-bottom:none;}
.pstat{width:16px;flex-shrink:0;margin-top:1px;}
/* Img grid */
.igrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px;}
.icard{position:relative;border:1.5px solid var(--line);border-radius:7px;overflow:hidden;cursor:pointer;aspect-ratio:4/3;background:var(--panel2);transition:.15s;}
.icard:hover{border-color:var(--brand);}
.icard img{width:100%;height:100%;object-fit:cover;}
.ilabel{position:absolute;bottom:0;left:0;right:0;background:rgba(0,0,0,.65);color:#fff;font-size:9px;padding:2px 5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
/* Slide cards */
/* slide editor */
.slide-eg{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px;padding-bottom:8px;}
.scard{background:var(--panel2);border:1.5px solid var(--line);border-radius:10px;overflow:hidden;transition:.15s;}
.scard.open{border-color:var(--brand);}
.scard-hd{display:flex;align-items:center;gap:6px;padding:7px 10px;cursor:pointer;background:var(--panel);user-select:none;}
.scard-hd:hover{background:var(--line);}
.slide-mini{aspect-ratio:16/9;padding:8% 9%;position:relative;overflow:hidden;cursor:pointer;box-sizing:border-box;}
.slide-mini-title{font-weight:700;line-height:1.3;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;word-break:keep-all;overflow-wrap:break-word;}
.slide-mini-bullets{margin-top:5px;opacity:.75;}
.slide-mini-img{position:absolute;right:5%;top:15%;width:38%;height:70%;object-fit:cover;border-radius:3px;opacity:.88;}
.slide-edit-area{padding:12px;border-top:1px solid var(--line);}
.slide-edit-row{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;margin-top:8px;}
.sbul-ta{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 8px;font-size:12px;color:var(--text);resize:vertical;outline:none;line-height:1.5;min-height:60px;font-family:monospace;}
.sbul-ta:focus{border-color:var(--brand);}
.elabel{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;display:block;margin-bottom:3px;}
/* translation modal */
.txlmodal{width:min(680px,94vw);max-height:86vh;}
.txlmodal-hd{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-shrink:0;}
.txlmodal-body{flex:1;overflow-y:auto;padding:18px 20px;font-size:13px;line-height:1.85;white-space:pre-wrap;color:var(--text);}
.txlmodal-ft{padding:10px 14px;border-top:1px solid var(--line);display:flex;gap:8px;justify-content:flex-end;flex-shrink:0;}
.snum{width:22px;height:22px;border-radius:50%;background:var(--brand);color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
.stype-tag{font-size:10px;color:var(--muted);background:var(--panel);padding:2px 6px;border-radius:3px;}
.sin{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:13px;color:var(--text);width:100%;outline:none;}
.sin:focus{border-color:var(--brand);}
.sbul textarea{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:6px 9px;font-size:12px;color:var(--text);font-family:'IBM Plex Mono',monospace;resize:vertical;outline:none;margin-top:5px;line-height:1.5;}
.sbul textarea:focus{border-color:var(--brand);}
.simg-row{display:flex;align-items:center;gap:8px;margin-top:7px;}
.sthumb{width:52px;height:40px;border-radius:5px;object-fit:cover;border:1px solid var(--line);}
.sno-img{width:52px;height:40px;border-radius:5px;border:1px dashed var(--line);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:14px;flex-shrink:0;}
.pickbtn{font-size:11px;padding:3px 9px;border-radius:5px;background:var(--panel);border:1px solid var(--line);color:var(--muted);cursor:pointer;}
.pickbtn:hover{border-color:var(--brand);color:var(--brand2);}
/* Skin picker */
.skin-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:5px;margin-top:5px;}
.skin-sw{width:100%;aspect-ratio:1;border-radius:5px;cursor:pointer;border:2px solid transparent;transition:.15s;}
.skin-sw.active{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand);transform:scale(1.12);}
.skin-sw:hover{transform:scale(1.08);}
/* Preview grid */
.preview-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px;}
.preview-card{position:relative;background:var(--panel2);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;transition:.15s;}
.preview-card:hover{border-color:var(--brand);}
.preview-card img{width:100%;display:block;}
.prev-edit-btn{position:absolute;top:6px;right:6px;opacity:0;transition:.15s;z-index:2;background:rgba(30,30,40,.75);color:#fff;border:none;border-radius:5px;padding:3px 9px;font-size:11px;cursor:pointer;backdrop-filter:blur(4px);}
.preview-card:hover .prev-edit-btn{opacity:1;}
.prev-edit-btn:hover{background:var(--brand)!important;opacity:1!important;}
.preview-card[data-edited]::after{content:'● 수정됨';position:absolute;top:6px;left:6px;background:rgba(99,102,241,.85);color:#fff;font-size:10px;padding:2px 7px;border-radius:10px;pointer-events:none;z-index:3;}
.preview-label{font-size:11px;padding:6px 10px;color:var(--muted);text-align:center;}
/* Settings sidebar — LEFT */
.sidebar{width:230px;flex-shrink:0;border-right:1px solid var(--line);background:var(--panel);display:flex;flex-direction:column;overflow:hidden;order:-1;}
.sidebar .scroll{padding:12px;}
.field{margin-bottom:12px;}
.flabel{display:block;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:5px;}
.field select,.field input[type=text],.field input[type=number]{width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:6px 8px;font-size:12px;color:var(--text);outline:none;}
.field select:focus,.field input:focus{border-color:var(--brand);}
/* Bottom bar */
.bbar{padding:9px 14px;border-top:1px solid var(--line);background:var(--panel);flex-shrink:0;display:flex;gap:7px;align-items:center;}
.bbar .sp{flex:1;}
.smsg{font-size:12px;color:var(--muted);}
.smsg.running{animation:blink 1.2s ease-in-out infinite;}
/* Modals */
.mbg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:200;align-items:center;justify-content:center;}
.mbg.open{display:flex;}
.modal{background:var(--panel);border:1px solid var(--line);border-radius:14px;display:flex;flex-direction:column;gap:0;}
/* Abstract modal */
.amodal{width:min(740px,94vw);max-height:88vh;}
.amodal-hd{padding:16px 20px;border-bottom:1px solid var(--line);flex-shrink:0;}
.amodal-title{font-size:15px;font-weight:700;line-height:1.4;margin-bottom:5px;}
.amodal-meta{font-size:12px;color:var(--muted);}
.atabs{display:flex;gap:2px;padding:8px 16px;border-bottom:1px solid var(--line);flex-shrink:0;}
.atab{padding:5px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;color:var(--muted);background:none;border:none;}
.atab.active{color:var(--brand2);background:rgba(99,102,241,.15);}
.amodal-body{flex:1;overflow-y:auto;padding:18px 20px;font-size:14px;line-height:1.85;white-space:pre-wrap;color:var(--text);}
.amodal-ft{padding:12px 18px;border-top:1px solid var(--line);flex-shrink:0;display:flex;gap:8px;justify-content:flex-end;align-items:center;}
/* Image picker modal */
.imodal{width:min(680px,94vw);max-height:82vh;}
.imodal-hd{padding:14px 18px;border-bottom:1px solid var(--line);font-size:14px;font-weight:700;flex-shrink:0;}
.imodal-body{flex:1;overflow-y:auto;padding:14px;}
.imodal-ft{padding:10px 16px;border-top:1px solid var(--line);flex-shrink:0;display:flex;justify-content:flex-end;}
/* PDF capture modal */
.pdfmodal{width:min(900px,96vw);max-height:94vh;height:94vh;}
.pdfmodal-hd{padding:10px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px;flex-shrink:0;}
.pdfmodal-body{flex:1;overflow:hidden;display:flex;flex-direction:column;align-items:center;background:#111;position:relative;}
.pdfmodal-ft{padding:8px 14px;border-top:1px solid var(--line);display:flex;gap:8px;align-items:center;flex-shrink:0;}
#pdf-canvas-wrap{overflow:auto;flex:1;width:100%;display:flex;justify-content:flex-start;align-items:flex-start;background:#111;}
#pdf-main-canvas{display:block;cursor:crosshair;}
/* Slideshow overlay */
#slideshow{display:none;position:fixed;inset:0;background:#000;z-index:300;flex-direction:column;align-items:center;justify-content:center;}
#slideshow.open{display:flex;}
.ss-img-wrap{flex:1;min-height:0;display:flex;align-items:center;justify-content:center;width:100%;padding:16px 80px;}
#ss-img{max-width:100%;max-height:100%;object-fit:contain;border-radius:4px;}
.ss-bar{width:100%;padding:14px 20px;display:flex;align-items:center;gap:12px;background:rgba(0,0,0,.6);flex-shrink:0;}
.ss-btn{background:rgba(255,255,255,.12);border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:8px;padding:8px 18px;font-size:14px;cursor:pointer;transition:.15s;}
.ss-btn:hover{background:rgba(255,255,255,.22);}
.ss-counter{color:#fff;font-size:14px;font-weight:600;min-width:60px;text-align:center;}
.ss-title{color:rgba(255,255,255,.7);font-size:13px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.ss-nav{position:absolute;top:50%;transform:translateY(-50%);background:rgba(0,0,0,.4);border:none;color:#fff;font-size:28px;width:52px;height:52px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.2s;}
.ss-nav:hover{background:rgba(99,102,241,.6);}
#ss-prev{left:12px;}
#ss-nav-next{right:12px;}
/* ── Slide Editor Modal ─────────────────────────────────────────────────── */
.sedmodal{width:1200px;height:90vh;max-width:98vw;max-height:98vh;border-radius:10px;position:fixed;box-shadow:0 8px 40px rgba(0,0,0,.6);cursor:default;overflow:hidden;left:50%;top:50%;transform:translate(-50%,-50%);}
.sed-hd{cursor:move;user-select:none;}
.sed-hd{display:flex;align-items:center;gap:8px;padding:10px 18px;border-bottom:1px solid var(--line);flex-shrink:0;}
.sed-body{display:flex;flex:1;min-height:0;overflow:hidden;}
.sed-ctrl{width:340px;flex-shrink:0;border-right:1px solid var(--line);overflow-y:auto;padding:16px 18px;display:flex;flex-direction:column;gap:16px;}
.sed-stage{flex:1;background:#080b12;display:flex;align-items:center;justify-content:center;padding:28px;min-width:0;position:relative;}
.sed-preview{position:relative;border-radius:6px;box-shadow:0 8px 48px rgba(0,0,0,.7);overflow:hidden;transition:background .2s;}
.sed-section-lbl{font-size:12px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px;display:block;}
.sed-hint{display:flex;gap:10px;font-size:10px;color:var(--muted);margin-top:3px;margin-bottom:4px;opacity:.7}
.sed-hint span{background:var(--panel2);padding:1px 6px;border-radius:4px}
.layout-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;}
.layout-btn{background:var(--panel2);border:1.5px solid var(--line);border-radius:7px;padding:8px 4px 6px;cursor:pointer;text-align:center;transition:.15s;user-select:none;}
.layout-btn:hover{border-color:var(--brand);background:rgba(99,102,241,.08);}
.layout-btn.active{border-color:var(--brand);background:rgba(99,102,241,.15);color:var(--brand2);}
.layout-btn .li{font-size:13px;display:block;margin-bottom:3px;letter-spacing:.5px;font-family:monospace;}
.layout-btn .lt{font-size:11px;color:var(--muted);}
.layout-btn.active .lt{color:var(--brand2);font-weight:700;}
.sed-inp{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:9px 12px;font-size:16px;color:var(--text);outline:none;font-family:inherit;resize:none;line-height:1.5;min-height:46px;}
.sed-inp:focus{border-color:var(--brand);}
.sed-ta{width:100%;background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:9px 12px;font-size:15px;color:var(--text);font-family:'IBM Plex Mono',monospace;resize:vertical;outline:none;line-height:1.65;min-height:110px;}
.sed-ta:focus{border-color:var(--brand);}
.sed-row{display:flex;align-items:center;gap:8px;margin-top:6px;}
.sed-range{flex:1;accent-color:var(--brand);}
.sed-val{font-size:13px;font-weight:700;color:var(--brand2);min-width:40px;text-align:right;}
.sed-skin-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;margin-top:5px;}
.sed-skin-sw{aspect-ratio:1;border-radius:5px;cursor:pointer;border:2px solid transparent;transition:.15s;}
.sed-skin-sw.active{border-color:var(--brand);box-shadow:0 0 0 2px var(--brand);transform:scale(1.15);}
.sed-skin-sw:hover{transform:scale(1.1);}
.fx-btn{font-size:11px;padding:3px 8px;border-radius:5px;background:var(--panel);border:1px solid var(--line);color:var(--muted);cursor:pointer;transition:.12s;}
.fx-btn:hover{border-color:var(--brand);color:var(--brand2);}
.fx-btn.active{background:var(--brand);border-color:var(--brand);color:#fff;}
.clr-row{display:flex;align-items:center;gap:6px;margin-top:5px;}
.clr-row label{font-size:11px;color:var(--muted);min-width:36px;}
.clr-row input[type=color]{width:28px;height:22px;border:1px solid var(--line);border-radius:4px;padding:0 2px;background:var(--panel);cursor:pointer;}
.clr-row .clr-reset{font-size:10px;color:var(--muted);cursor:pointer;padding:2px 5px;border:1px solid var(--line);border-radius:4px;background:var(--panel);}
.clr-row .clr-reset:hover{color:var(--red);border-color:var(--red);}
.sed-img-thumb{width:100%;height:100px;border-radius:6px;background:var(--panel) center/cover no-repeat;border:1px solid var(--line);display:flex;align-items:center;justify-content:center;font-size:28px;color:var(--muted);cursor:pointer;transition:.15s;}
.sed-img-thumb:hover{border-color:var(--brand);}
.sed-nav-info{font-size:12px;color:var(--muted);text-align:center;flex:1;}
</style>
</head>
<body>
<div class="header">
<span style="font-size:18px">📊</span>
<h1>슬라이드 제작 마법사</h1>
<div style="display:flex;gap:4px;margin-left:14px">
<button class="theme-btn active" id="tbtn-dark" onclick="setTheme('dark')">🌙 다크</button>
<button class="theme-btn" id="tbtn-light" onclick="setTheme('light')">☀️ 라이트</button>
<button class="theme-btn" id="tbtn-warm" onclick="setTheme('warm')">🌿 웜</button>
</div>
<div class="steps">
<span class="step-hint">← 번호 클릭으로 이동</span>
<div class="sdot active" id="sd1" onclick="goStep(1)"><div class="n">1</div>논문 검색</div>
<div class="sdot" id="sd2" onclick="goStep(2)"><div class="n">2</div>준비</div>
<div class="sdot" id="sd3" onclick="goStep(3)"><div class="n">3</div>아웃라인</div>
<div class="sdot" id="sd4" onclick="goStep(4)"><div class="n">4</div>프리뷰</div>
</div>
</div>
<div class="body">
<!-- ── STEP 1 ── -->
<div class="sv active" id="step-1">
<div class="ptitle">PubMed 논문 검색</div>
<div class="scroll">
<div style="display:flex;gap:6px;margin-bottom:8px;align-items:flex-start;flex-wrap:wrap">
<span style="font-size:11px;font-weight:700;color:var(--muted);margin-top:7px">검색 소스:</span>
<label class="src-chk" id="src-pubmed">
<input type="checkbox" checked onchange="updSrcState()">
<span>🔬 PubMed<span class="src-chk-desc">미국 NLM 의학·생명과학 전문 DB. PMC 등재 논문은 전문 PDF 다운로드 가능.</span></span>
</label>
<label class="src-chk" id="src-openalex">
<input type="checkbox" onchange="updSrcState()">
<span>🌐 OpenAlex<span class="src-chk-desc">전 분야 2억+ 논문. API 키 불필요. 인용수·OA 여부 제공. OA 논문은 PDF 다운로드 가능.</span></span>
</label>
<label class="src-chk" id="src-semantic">
<input type="checkbox" onchange="updSrcState()">
<span>🧠 Semantic Scholar<span class="src-chk-desc">AI·CS·학제간 연구에 강함. 인용 영향력 지수 및 OA PDF 링크 제공.</span></span>
</label>
</div>
<div class="srow">
<input id="qi" placeholder="예: sinus lift augmentation" onkeydown="if(event.key==='Enter')doSearch()">
<input class="yin" id="qy" type="number" placeholder="연도" min="1990" max="2030" value="2017">
<span style="font-size:11px;color:var(--muted);white-space:nowrap">이후 (선택)</span>
<button class="btn bp" onclick="doSearch()" id="sbtn">🔍 검색</button>
</div>
<div id="search-legend" style="display:none;padding:7px 12px;background:rgba(99,102,241,.07);border-radius:7px;margin-bottom:6px;font-size:11px;color:var(--muted);line-height:1.7">
<span style="color:var(--green);font-weight:700">■ PMC등재</span> — PubMed Central에 전문(PDF) 있음. 체크박스로 선택해 PPTX에 활용 가능.<br>
<span style="color:var(--red);font-weight:700">■ PMC미등재</span> — PMC에 전문 없음. 초록 보기 가능, PDF 다운로드 불가.<br>
<span style="color:var(--brand2);font-weight:700">■ 연도</span> — 출판 연도 &nbsp;|&nbsp; <span style="color:#fcd34d;font-weight:700">■ 유형</span> — 논문 종류 (RCT, Review 등)
</div>
<div id="results"></div>
<div style="padding:8px 0 2px;display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px">
<div style="flex:1;height:1px;background:var(--line)"></div>또는 PDF 직접 업로드<div style="flex:1;height:1px;background:var(--line)"></div>
</div>
<div class="dropzone" id="pdf-dropzone"
onclick="document.getElementById('pdf-file-input').click()"
ondragover="event.preventDefault();this.classList.add('drag')"
ondragleave="this.classList.remove('drag')"
ondrop="this.classList.remove('drag');handlePdfDrop(event)">
<input type="file" id="pdf-file-input" accept=".pdf" multiple onchange="handlePdfFileInput(this)">
📄 PDF 파일을 드래그하거나 클릭해서 선택<br>
<span style="font-size:10px">여러 파일 동시 업로드 가능</span>
</div>
<div id="upload-list"></div>
</div>
<div class="bbar">
<span class="smsg" id="s1msg"></span><div class="sp"></div>
<button class="btn bp" id="s1next" onclick="goStep(2)" disabled>▶ PDF 준비 →</button>
</div>
</div>
<!-- ── STEP 2 ── -->
<div class="sv" id="step-2">
<div class="ptitle">PDF 다운로드</div>
<div class="scroll" style="display:flex;gap:14px">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em">진행 상황</span>
<span id="s2-overall" style="display:none;font-size:11px;color:var(--brand2);font-weight:700"></span>
</div>
<div id="proglist"></div>
<div id="img-search-section" style="margin-top:14px;border-top:1px solid var(--line);padding-top:10px">
<div style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em;margin-bottom:6px">🔍 이미지 검색</div>
<div style="display:flex;gap:6px">
<input id="img-search-input" type="text" placeholder="예: 구강 해부도, TMJ X-ray"
style="flex:1;font-size:12px;padding:4px 8px;background:var(--panel2);border:1px solid var(--line);border-radius:5px;color:var(--fg);outline:none"
onkeydown="if(event.key==='Enter')doImgSearch()">
<button class="btn bg" style="font-size:11px;padding:3px 10px;flex-shrink:0" onclick="doImgSearch()">검색</button>
</div>
<div id="img-search-results" style="margin-top:8px;max-height:260px;overflow-y:auto"></div>
</div>
</div>
<div style="flex:2;min-width:0">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="font-size:10px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:.04em">캡처 이미지 <span id="imgcnt"></span></span>
<button id="btn-analyze-imgs" onclick="analyzeImages()" style="font-size:11px;color:#10b981;background:rgba(16,185,129,.1);border:1px solid #10b981;border-radius:5px;padding:2px 8px;cursor:pointer;flex-shrink:0" title="미분석 이미지를 mistral vision으로 재분석 (준비 실행 시 자동 처리됨)">🔍 재분석</button>
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--brand2);background:rgba(99,102,241,.1);border:1px solid var(--brand);border-radius:5px;padding:2px 8px;cursor:pointer;flex-shrink:0" title="이미지 파일 업로드">
<input type="file" id="img-upload-input" accept="image/*" multiple style="display:none" onchange="handleImgUpload(this.files)">
+ 이미지 업로드
</label>
</div>
<div id="img-dropzone"
style="border:2px dashed var(--line);border-radius:7px;padding:6px 10px;text-align:center;font-size:11px;color:var(--muted);margin-bottom:7px;cursor:pointer;transition:.15s"
onclick="document.getElementById('img-upload-input').click()"
ondragover="event.preventDefault();this.style.borderColor='var(--brand)';this.style.color='var(--brand2)'"
ondragleave="this.style.borderColor='';this.style.color=''"
ondrop="event.preventDefault();this.style.borderColor='';this.style.color='';handleImgUpload(event.dataTransfer.files)">
이미지 파일을 여기에 드래그하거나 클릭
</div>
<div class="igrid" id="imgrid"></div>
</div>
</div>
<div class="bbar">
<span class="smsg" id="s2msg"></span><div class="sp"></div>
<button class="btn bp" id="s2start" onclick="startPrep()">▶ 시작</button>
</div>
</div>
<!-- ── STEP 3 ── -->
<div class="sv" id="step-3">
<div class="ptitle">슬라이드 아웃라인 편집</div>
<div class="scroll" id="outline-scroll">
<div id="outline-cards" style="padding-bottom:8px">
<div style="color:var(--muted);font-size:12px;text-align:center;padding:24px 0">준비 단계 완료 후 아웃라인이 자동 생성됩니다</div>
</div>
</div>
<div class="bbar">
<span class="smsg" id="s3msg"></span><div class="sp"></div>
<button class="btn bgreen" id="s3create" onclick="createPptx()" disabled>✅ PPTX 생성 →</button>
</div>
</div>
<!-- ── STEP 4 ── -->
<div class="sv" id="step-4">
<div class="ptitle">슬라이드 프리뷰</div>
<div class="scroll">
<div id="preview-hint" style="display:none;padding:7px 14px;background:rgba(99,102,241,.07);border-radius:7px;margin-bottom:8px;font-size:12px;color:var(--muted);line-height:1.6">
카드에 마우스를 올리면 <strong>✏ 편집</strong> 버튼이 나타납니다. 편집 후 <strong>🔄 PPTX 재생성</strong>으로 업데이트하세요. &nbsp;|&nbsp; 카드 이미지를 클릭하면 슬라이드 쇼를 시작합니다.
</div>
<div id="preview-grid" class="preview-grid">
<div style="color:var(--muted);font-size:12px;text-align:center;padding:24px 0;grid-column:1/-1">PPTX 생성 후 프리뷰가 표시됩니다</div>
</div>
<div style="margin-top:14px;padding:8px 12px;border-top:1px solid var(--line);font-size:11px;color:var(--muted);line-height:1.7">
↑ 상단 <strong>1 · 2 · 3</strong> 단계 버튼을 클릭하면 이전 단계로 돌아갈 수 있습니다.&ensp;
논문 추가·변경 → <strong>1단계</strong>, PDF 처리 재실행 → <strong>2단계</strong>, 아웃라인 수정 → <strong>3단계</strong>.
</div>
</div>
<div class="bbar">
<span class="smsg" id="s4msg"></span><div class="sp"></div>
<button class="btn bg" onclick="reloadPreview()">↺ 새로고침</button>
<button class="btn bg" id="ss-launch" onclick="ssStart()" disabled>▶ 슬라이드 쇼</button>
<button class="btn bgreen" id="s4-regen" onclick="s4Regen()" style="display:none">🔄 PPTX 재생성</button>
<a id="dl-btn" class="btn bgreen" style="text-decoration:none;display:none" download>⬇ 다운로드</a>
</div>
</div>
<!-- ── SETTINGS SIDEBAR ── -->
<div class="sidebar">
<div class="ptitle">설정</div>
<div class="scroll">
<div class="field">
<button class="btn bgreen" style="width:100%;font-size:12px;padding:7px 0" onclick="newProject()">+ 새 프로젝트</button>
</div>
<div class="field">
<label class="flabel">기존 프로젝트 열기</label>
<div style="display:flex;gap:5px">
<select id="cfg-open-proj" style="flex:1;background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:4px 6px;font-size:12px;color:var(--text);outline:none" onchange="onOpenProjChange(this.value)">
<option value="">— 선택 —</option>
</select>
<button class="btn bg" style="font-size:11px;padding:4px 8px;flex-shrink:0" onclick="loadProjList()" title="목록 새로고침">↺</button>
<button class="btn bred" style="font-size:11px;padding:4px 8px;flex-shrink:0" onclick="deleteSelectedProject()" title="선택한 프로젝트 폴더 삭제">🗑</button>
</div>
</div>
<div class="field">
<label class="flabel">모델 요청</label>
<textarea id="cfg-topic" placeholder="예: 상악동 거상술 최신 동향에 대해 치과의사 대상 학회 발표 자료를 만들어주세요. 임상 적용 위주로 구성하고 핵심 수치를 강조해주세요." style="width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:7px 9px;font-size:12px;color:var(--text);resize:vertical;outline:none;line-height:1.6;min-height:72px;font-family:inherit" onfocus="this.style.borderColor='var(--brand)'" onblur="this.style.borderColor='var(--line)'"></textarea>
</div>
<div class="field">
<label class="flabel">프로젝트 폴더명</label>
<input type="text" id="cfg-project" placeholder="자동 (아웃라인 생성 후 채워짐)" style="width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:7px;padding:5px 8px;font-size:12px;color:var(--text);outline:none;font-family:inherit" onfocus="this.style.borderColor='var(--brand)'" onblur="this.style.borderColor='var(--line)'">
</div>
<div class="field">
<label class="flabel">슬라이드 수</label>
<input type="number" id="cfg-slides" min="5" max="30" value="12">
</div>
<div class="field">
<label class="flabel">템플릿 <span id="tmpl-lbl" style="font-weight:400;text-transform:none;font-size:11px;margin-left:4px;color:var(--text)">creative</span></label>
<input type="hidden" id="cfg-tmpl" value="creative">
<div class="skin-grid" id="tmpl-grid" style="grid-template-columns:repeat(6,1fr)"></div>
</div>
<div class="field">
<label class="flabel">배경 스킨 <span id="skin-name-lbl" style="font-weight:400;text-transform:none;margin-left:4px"></span></label>
<div class="skin-grid" id="skin-grid"></div>
</div>
<div class="field">
<label class="flabel">정렬</label>
<select id="cfg-sort">
<option value="relevance">관련도순</option>
<option value="date">최신순</option>
</select>
</div>
<div class="field">
<label class="flabel">PDF 이미지 자동 추출</label>
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--text);cursor:pointer;line-height:1.5">
<input type="checkbox" id="cfg-auto-img" checked onchange="onAutoImgToggle()" style="accent-color:var(--brand);width:15px;height:15px;cursor:pointer;margin-top:1px;flex-shrink:0">
<span id="cfg-auto-img-lbl"></span>
</label>
</div>
<div class="field">
<label class="flabel">PDF 본문 텍스트 추출</label>
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--text);cursor:pointer;line-height:1.5">
<input type="checkbox" id="cfg-extract-txt" checked onchange="onExtractTxtToggle()" style="accent-color:var(--brand);width:15px;height:15px;cursor:pointer;margin-top:1px;flex-shrink:0">
<span id="cfg-extract-txt-lbl"></span>
</label>
</div>
<div class="field">
<label class="flabel">본문 자동 번역</label>
<label style="display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--text);cursor:pointer;line-height:1.5">
<input type="checkbox" id="cfg-auto-txl" checked onchange="onAutoTxlToggle()" style="accent-color:var(--brand);width:15px;height:15px;cursor:pointer;margin-top:1px;flex-shrink:0">
<span id="cfg-auto-txl-lbl"></span>
</label>
</div>
<div class="field" style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px">
<label class="flabel">선택된 논문</label>
<div id="sel-summary" style="font-size:10px;color:var(--muted)">없음</div>
</div>
</div>
</div>
</div>
<!-- Abstract modal -->
<div class="mbg" id="abg">
<div class="modal amodal">
<div class="amodal-hd">
<div class="amodal-title" id="am-title"></div>
<div class="amodal-meta" id="am-meta"></div>
</div>
<div class="atabs">
<button class="atab active" id="at-en" onclick="switchAbTab('en')">영문</button>
<button class="atab" id="at-ko" onclick="switchAbTab('ko')">한국어 번역</button>
</div>
<div class="amodal-body" id="am-body">로딩 중...</div>
<div class="amodal-ft">
<button class="btn bg" id="am-translate-btn" onclick="translateAbstract()">🌐 번역</button>
<button class="btn bg" onclick="closeAbModal()">닫기</button>
</div>
</div>
</div>
<!-- Slideshow overlay -->
<div id="slideshow">
<button class="ss-nav" id="ss-prev" onclick="ssPrev()">&#8249;</button>
<button class="ss-nav" id="ss-nav-next" onclick="ssNext()">&#8250;</button>
<div class="ss-img-wrap"><img id="ss-img" src="" alt=""></div>
<div class="ss-bar">
<button class="ss-btn" onclick="ssPrev()">← 이전</button>
<span class="ss-counter" id="ss-counter">1 / 1</span>
<button class="ss-btn" onclick="ssNext()">다음 →</button>
<span class="ss-title" id="ss-title"></span>
<button class="ss-btn" onclick="ssToggleAuto()" id="ss-auto-btn">⏵ 자동</button>
<button class="ss-btn" onclick="ssClose()" style="margin-left:auto">✕ 닫기</button>
</div>
</div>
<!-- Image picker modal -->
<div class="mbg" id="ibg" style="z-index:600">
<div class="modal imodal">
<div class="imodal-hd">이미지 선택</div>
<div class="imodal-body"><div class="igrid" id="imodal-grid"></div></div>
<div class="imodal-ft">
<button class="btn bg" onclick="closeIModal()">취소</button>
</div>
</div>
</div>
<!-- PDF Translation modal -->
<div class="mbg" id="txlbg" onclick="if(event.target===this)closeTxlModal()">
<div class="modal txlmodal" onclick="event.stopPropagation()">
<div class="txlmodal-hd">
<span style="font-size:14px">📄</span>
<span id="txl-title" style="font-size:13px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">전문</span>
<button class="btn bg" style="padding:3px 10px;font-size:11px" id="txl-tab-en" onclick="switchTxlTab('en')">원문</button>
<button class="btn bg" style="padding:3px 10px;font-size:11px" id="txl-tab-ko" onclick="switchTxlTab('ko')">번역</button>
<button class="btn bg" style="padding:3px 10px;font-size:11px" onclick="copyTxlText()">복사</button>
<button class="btn bg" style="padding:3px 10px;font-size:11px" onclick="closeTxlModal()">✕</button>
</div>
<div class="txlmodal-body" id="txl-body"></div>
<div class="txlmodal-ft">
<span id="txl-status" style="font-size:11px;color:var(--muted);flex:1"></span>
<button class="btn bg" onclick="closeTxlModal()">닫기</button>
</div>
</div>
</div>
<!-- Image lightbox -->
<div class="mbg" id="lbg" onclick="closeImgPreview()" style="z-index:400">
<div style="position:relative;max-width:90vw;max-height:90vh;display:flex;flex-direction:column;gap:8px" onclick="event.stopPropagation()">
<div style="display:flex;justify-content:space-between;align-items:center">
<span id="lb-label" style="color:rgba(255,255,255,.7);font-size:12px;max-width:80vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
<button onclick="closeImgPreview()" style="background:rgba(255,255,255,.15);border:none;color:#fff;border-radius:6px;padding:4px 10px;cursor:pointer;font-size:13px;margin-left:12px">✕</button>
</div>
<img id="lb-img" src="" style="max-width:88vw;max-height:82vh;border-radius:8px;display:block;box-shadow:0 8px 32px rgba(0,0,0,.6)">
</div>
</div>
<!-- Slide Editor Modal -->
<div class="mbg" id="sedbg" onclick="if(event.target===this)closeSed()" style="padding:0;align-items:center;justify-content:center">
<div class="modal sedmodal" onclick="event.stopPropagation()">
<div class="sed-hd">
<div class="snum" id="sed-num">1</div>
<span class="stype-tag" id="sed-type"></span>
<span id="sed-title-lbl" style="flex:1;font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0 10px;color:var(--text)"></span>
<span class="sed-nav-info"><span id="sed-cur">1</span> / <span id="sed-total">1</span></span>
<button class="btn bg" style="padding:5px 12px" onclick="sedNav(-1)">← 이전</button>
<button class="btn bg" style="padding:5px 12px" onclick="sedNav(1)">다음 →</button>
<button class="btn bg" style="padding:5px 12px" onclick="closeSed()">✕ 닫기</button>
</div>
<div class="sed-body">
<!-- Controls -->
<div class="sed-ctrl">
<!-- Layout -->
<div id="sed-layout-sec">
<span class="sed-section-lbl">레이아웃</span>
<div class="layout-grid">
<div class="layout-btn" data-layout="split-right" onclick="sedSetLayout('split-right')"><span class="li">T|I</span><span class="lt">텍스트+이미지</span></div>
<div class="layout-btn" data-layout="split-left" onclick="sedSetLayout('split-left')"><span class="li">I|T</span><span class="lt">이미지+텍스트</span></div>
<div class="layout-btn" data-layout="text" onclick="sedSetLayout('text')"><span class="li">T</span><span class="lt">텍스트만</span></div>
<div class="layout-btn" data-layout="fullscreen" onclick="sedSetLayout('fullscreen')"><span class="li">[I]</span><span class="lt">전체이미지</span></div>
<div class="layout-btn" data-layout="compare" onclick="sedSetLayout('compare')"><span class="li">||</span><span class="lt">2단비교</span></div>
</div>
</div>
<!-- Title -->
<div>
<span class="sed-section-lbl">제목</span>
<textarea class="sed-inp" id="sed-title-inp" oninput="sedChange()" onkeydown="setTimeout(sedRenderPreview,0)" placeholder="슬라이드 제목" rows="2"></textarea>
<div class="sed-row">
<span style="font-size:11px;color:var(--muted)">제목 크기</span>
<input type="range" class="sed-range" id="sed-title-size" min="14" max="52" step="1" oninput="sedChange()">
<span class="sed-val" id="sed-title-size-val">28pt</span>
</div>
</div>
<!-- Subtitle (title-type only) -->
<div id="sed-subtitle-sec">
<span class="sed-section-lbl">부제목</span>
<textarea class="sed-inp" id="sed-subtitle-inp" oninput="sedChange()" onkeydown="setTimeout(sedRenderPreview,0)" placeholder="부제목" rows="2"></textarea>
</div>
<!-- Bullets -->
<div id="sed-bullets-sec">
<span class="sed-section-lbl">내용</span>
<textarea class="sed-ta" id="sed-bullets-ta" oninput="sedChange()" onkeydown="setTimeout(sedRenderPreview,0)"></textarea>
<div class="sed-hint">
<span>Enter = 같은 항목 줄바꿈</span>
<span>빈 줄 = 새 항목 ●</span>
<span>- 시작 = 하위 항목 –</span>
</div>
<div class="sed-row">
<span style="font-size:11px;color:var(--muted)">본문 크기</span>
<input type="range" class="sed-range" id="sed-body-size" min="10" max="36" step="1" oninput="sedChange()">
<span class="sed-val" id="sed-body-size-val">18pt</span>
</div>
</div>
<!-- Image -->
<div id="sed-img-sec">
<span class="sed-section-lbl">이미지</span>
<div class="sed-img-thumb" id="sed-img-thumb" onclick="sedPickImg()">🖼</div>
<input type="text" class="sed-inp" id="sed-img-url-inp" placeholder="URL 직접 붙여넣기" style="margin-top:5px;font-size:11px" oninput="sedImgUrlInput()">
<div style="display:flex;gap:6px;margin-top:5px">
<button class="btn bg" style="flex:1;font-size:12px" onclick="sedPickImg()">🖼 선택</button>
<button class="btn bp" style="flex:1;font-size:12px" onclick="sedAutoExtract()">📷 자동추출</button>
<button class="btn bg" style="font-size:12px;color:var(--red)" onclick="sedClearImg()">✕</button>
</div>
<button class="btn bg" style="width:100%;font-size:11px;margin-top:4px;color:var(--muted)" onclick="sedOpenPdfPicker()">✂ 수동 캡처 (PDF 드래그)</button>
<div class="sed-row" style="margin-top:8px">
<span style="font-size:11px;color:var(--muted)">이미지 크기</span>
<input type="range" class="sed-range" id="sed-img-scale" min="20" max="80" step="5" oninput="sedChange()">
<span class="sed-val" id="sed-img-scale-val">50%</span>
</div>
</div>
<!-- Font -->
<div>
<span class="sed-section-lbl">폰트</span>
<div class="sed-row" style="margin-top:2px">
<span style="font-size:11px;color:var(--muted);min-width:36px">패밀리</span>
<select id="sed-font-family" style="flex:1;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:3px 6px;font-size:12px;color:var(--text)" onchange="sedChange()">
<option value="">기본 (Calibri/맑은고딕)</option>
<option value="Malgun Gothic">맑은 고딕</option>
<option value="NanumGothic">나눔고딕</option>
<option value="Arial">Arial</option>
<option value="Calibri">Calibri</option>
<option value="Georgia">Georgia</option>
<option value="Times New Roman">Times New Roman</option>
</select>
</div>
<div class="clr-row">
<label>제목색</label>
<input type="color" id="sed-title-color" value="#1a1a2e" oninput="sedChange()">
<button class="clr-reset" onclick="sedResetColor('title')">초기화</button>
</div>
<div class="clr-row">
<label>본문색</label>
<input type="color" id="sed-body-color" value="#2d3748" oninput="sedChange()">
<button class="clr-reset" onclick="sedResetColor('body')">초기화</button>
</div>
</div>
<!-- Image FX -->
<div id="sed-img-fx-sec">
<span class="sed-section-lbl">이미지 효과</span>
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:4px">
<button class="fx-btn active" data-fx="" onclick="sedSetFx('')">원본</button>
<button class="fx-btn" data-fx="blur" onclick="sedSetFx('blur')">흐리게</button>
<button class="fx-btn" data-fx="grayscale" onclick="sedSetFx('grayscale')">흑백</button>
<button class="fx-btn" data-fx="contrast" onclick="sedSetFx('contrast')">대비↑</button>
<button class="fx-btn" data-fx="bright" onclick="sedSetFx('bright')">밝기↑</button>
<button class="fx-btn" data-fx="round" onclick="sedSetFx('round')">모서리</button>
</div>
<div id="sed-fx-blur-row" class="sed-row" style="display:none;margin-top:6px">
<span style="font-size:11px;color:var(--muted)">강도</span>
<input type="range" class="sed-range" id="sed-fx-blur" min="1" max="20" value="5" oninput="sedFxSlider()">
<span class="sed-val" id="sed-fx-blur-val">5</span>
</div>
<div id="sed-fx-round-row" class="sed-row" style="display:none;margin-top:6px">
<span style="font-size:11px;color:var(--muted)">반경</span>
<input type="range" class="sed-range" id="sed-fx-radius" min="5" max="50" value="15" oninput="sedFxSlider()">
<span class="sed-val" id="sed-fx-radius-val">15%</span>
</div>
</div>
<!-- Background -->
<div>
<span class="sed-section-lbl">배경 <span id="sed-skin-lbl" style="font-weight:400;text-transform:none;font-size:11px;margin-left:4px;color:var(--text)"></span></span>
<div class="sed-skin-grid" id="sed-skin-grid"></div>
<div style="display:flex;gap:5px;align-items:center;margin-top:6px">
<div id="sed-bg-img-thumb" style="width:38px;height:26px;border:1px solid var(--line);border-radius:3px;background-size:cover;background-position:center;flex-shrink:0;font-size:10px;display:flex;align-items:center;justify-content:center;color:var(--muted)">🖼</div>
<button class="pickbtn" style="font-size:11px;padding:3px 8px" onclick="sedPickBgImg()">이미지 배경</button>
<button class="pickbtn" id="sed-bg-img-clear" style="font-size:11px;padding:3px 8px;color:var(--red);display:none" onclick="sedClearBgImg()">✕</button>
</div>
</div>
</div>
<!-- Preview Stage -->
<div class="sed-stage" id="sed-stage">
<div class="sed-preview" id="sed-preview"></div>
</div>
</div>
</div>
</div>
<!-- PDF Capture modal -->
<div class="mbg" id="pdfbg" onclick="if(event.target===this)closePdfModal()">
<div class="modal pdfmodal" onclick="event.stopPropagation()">
<div class="pdfmodal-hd">
<span style="font-size:13px;font-weight:700">📄 PDF 캡처</span>
<span id="pdf-modal-name" style="font-size:11px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
<button class="btn bg" style="padding:4px 10px;font-size:11px" onclick="pdfPrevPage()">◀</button>
<span id="pdf-page-lbl" style="font-size:12px;color:var(--muted);min-width:60px;text-align:center">1 / 1</span>
<button class="btn bg" style="padding:4px 10px;font-size:11px" onclick="pdfNextPage()">▶</button>
<button class="btn bg" style="padding:4px 10px;font-size:11px" onclick="pdfZoomStep(-1)">-</button>
<span id="pdf-zoom-lbl" style="font-size:12px;color:var(--muted);min-width:42px;text-align:center;cursor:pointer" title="클릭하여 초기화" onclick="pdfZoomReset()">100%</span>
<button class="btn bg" style="padding:4px 10px;font-size:11px" onclick="pdfZoomStep(1)">+</button>
<button class="btn bg" style="padding:4px 10px;font-size:11px" onclick="closePdfModal()">✕</button>
</div>
<div class="pdfmodal-body" id="pdfmodal-body">
<div id="pdf-canvas-wrap">
<canvas id="pdf-main-canvas"></canvas>
</div>
<div id="pdf-load-msg" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#aaa;font-size:14px">페이지 로딩 중...</div>
</div>
<div class="pdfmodal-ft">
<span style="font-size:11px;color:var(--muted)">드래그로 영역 선택 → 캡처</span>
<div style="flex:1"></div>
<span id="pdf-sel-info" style="font-size:11px;color:var(--brand2);margin-right:8px"></span>
<button class="btn bg" id="pdf-capture-btn" onclick="pdfCapture()" disabled>📷 캡처</button>
<button class="btn bg" onclick="pdfClearSel()">✕ 선택 해제</button>
</div>
</div>
</div>
<script>
'use strict';
// ── Theme ────────────────────────────────────────────────────────────────────
function setTheme(name) {
document.body.classList.remove('theme-light','theme-warm');
if(name==='light') document.body.classList.add('theme-light');
if(name==='warm') document.body.classList.add('theme-warm');
document.querySelectorAll('.theme-btn').forEach(b=>b.classList.toggle('active',b.id==='tbtn-'+name));
localStorage.setItem('pptx-wizard-theme',name);
}
(function(){ setTheme(localStorage.getItem('pptx-wizard-theme')||'dark'); })();
// 브라우저 기본 파일 드롭(새 탭 열기) 전역 차단
window.addEventListener('dragover', e => e.preventDefault(), false);
window.addEventListener('drop', e => e.preventDefault(), false);
// ── skin definitions ────────────────────────────────────────────────────────
const SKINS = [
{v:'navy', label:'Navy', c:'#1d4ed8'},
{v:'midnight', label:'Midnight', c:'#4c1d95'},
{v:'charcoal', label:'Charcoal', c:'#4b5563'},
{v:'slate', label:'Slate', c:'#2d6a8f'},
{v:'cream', label:'Cream', c:'#fffbeb'},
{v:'sand', label:'Sand', c:'#fef3c7'},
{v:'sky', label:'Sky', c:'#e0f2fe'},
{v:'peach', label:'Peach', c:'#fff1f0'},
{v:'coral', label:'Coral', c:'#ff6b6b'},
{v:'teal', label:'Teal', c:'#0d9488'},
{v:'emerald', label:'Emerald', c:'#059669'},
{v:'plum', label:'Plum', c:'#7c3aed'},
];
// ── state ───────────────────────────────────────────────────────────────────
let papers = [], selected = new Set(), paperMeta = {};
function updSrcState(){
// 최소 하나는 선택 유지
const checks=[...document.querySelectorAll('.src-chk input')];
if(checks.every(c=>!c.checked)) checks[0].checked=true;
}
let extractedImages = [], paperTexts = {}, imageCaptions = {};
let prepLog = {}, outline = null, outlineEdits = {}, currentProcessingKey = null;
let pptxRelPath = null, previewImages = [];
let currentStep = 1, abTabMode = 'en', modalSlide = -1;
let selectedSkin = 'cream';
let selectedSlide = -1;
let paperTranslations = {}, txlStatus = {};
function paperKey(p) {
if(p._isUpload) return 'UPL_'+(p._localPdf||'').split('/').pop();
if(p.pmcid) return 'PMC'+String(p.pmcid).replace(/^PMC/i,'');
if(p.pmid) return 'PMID'+String(p.pmid);
if(p.doi) return 'DOI_'+p.doi.toLowerCase().trim();
return null;
}
function paperLabel(key) {
if(key.startsWith('UPL_')) return key.slice(4);
const p=papers.find(x=>paperKey(x)===key);
if(p&&p.title) return p.title.slice(0,60)+(p.title.length>60?'…':'');
return key;
}
// ── Save outline to project folder ──────────────────────────────────────────
async function saveOutlineToProject() {
if (!outline) return;
const project = resolveProjectSlug();
if (!project) return;
try {
await fetch('/api/pptx/save-outline', {
...jh(), method: 'POST',
body: JSON.stringify({ project, outline })
});
} catch (e) { console.warn('[wizard] save-outline failed:', e.message); }
}
// ── Save papers to project (PDF paths, extracted texts, translations) ──────
async function savePapersToProject() {
const project = resolveProjectSlug();
if (!project) return;
const paperEntries = [];
const translations = {};
for (const key of selected) {
const p = papers.find(x => paperKey(x) === key);
if (!p) continue;
const isUpl = key.startsWith('UPL_');
const entry = {
key,
title: p.title || (isUpl ? key.slice(4) : ''),
authors: p.authors || '',
year: p.year || '',
journal: p.journal || '',
doi: p.doi || '',
pmcid: p.pmcid || null,
pmid: p.pmid || null,
_source: isUpl ? 'upload' : (p._source || 'pubmed'),
abstract: (paperMeta[key]?.abstract) || p.abstract || '',
pdfPath: prepLog[key]?.pdfPath || null,
txtPath: null,
koPath: null,
};
// Determine txt/ko paths from pdfPath or key
if (entry.pdfPath) {
const baseName = entry.pdfPath.replace(/\.pdf$/i, '');
if (prepLog[key]?.txt === 'done') entry.txtPath = baseName + '.txt';
if (paperTranslations[key] && entry.txtPath) {
entry.koPath = entry.txtPath.replace(/\.txt$/, '.ko.txt');
translations[key] = paperTranslations[key];
}
} else if (prepLog[key]?.txt === 'done') {
// No PDF but text exists (e.g. abstract-only fallback)
entry.txtPath = project + '/' + key.replace(/[^a-zA-Z0-9_\-]/g, '_') + '.txt';
if (paperTranslations[key]) {
entry.koPath = entry.txtPath.replace(/\.txt$/, '.ko.txt');
translations[key] = paperTranslations[key];
}
}
paperEntries.push(entry);
}
if (!paperEntries.length) return;
try {
await fetch('/api/pptx/save-papers', {
...jh(), method: 'POST',
body: JSON.stringify({ project, papers: paperEntries, translations })
});
loadProjList();
} catch (e) { console.warn('[wizard] save-papers failed:', e.message); }
}
// ── New / Open existing project ──────────────────────────────────────────────
function newProject(){
papers=[]; selected=new Set(); paperMeta={};
paperTexts={}; paperTranslations={}; txlStatus={};
prepLog={}; extractedImages={}; imageCaptions={}; abPaper=null;
outline=null; outlineEdits={}; pptxRelPath=null; previewImages=[];
document.querySelectorAll('.preview-card[data-edited]').forEach(c=>delete c.dataset.edited);
const sel=document.getElementById('cfg-open-proj'); if(sel) sel.value='';
const dlBtn=document.getElementById('dl-btn'); if(dlBtn) dlBtn.style.display='none';
const regenBtn=document.getElementById('s4-regen'); if(regenBtn) regenBtn.style.display='none';
document.getElementById('cfg-project').value='';
document.getElementById('cfg-topic').value='';
localStorage.removeItem('pptx-session');
renderPapers(); renderProgList(); renderOutline();
goStep(1);
}
async function deleteSelectedProject(){
const sel=document.getElementById('cfg-open-proj');
const relPath=sel?.value||'';
if(!relPath){ alert('삭제할 프로젝트를 먼저 선택하세요.'); return; }
const _dp=relPath.replace(/\\/g,'/').split('/');
const project=relPath.endsWith('.pptx')&&_dp.length>=3 ? _dp[1] : _dp[0];
if(!confirm(`프로젝트 폴더 "${project}" 를 완전히 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) return;
try{
const r=await fetch('/api/pptx/delete-project',{...jh(),method:'DELETE',body:JSON.stringify({project})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'삭제 실패');
await loadProjList();
sel.value='';
alert(`"${project}" 삭제 완료.`);
}catch(e){ alert('삭제 오류: '+e.message); }
}
async function loadProjList(){
const sel=document.getElementById('cfg-open-proj'); if(!sel)return;
sel.innerHTML='<option value="">로딩 중...</option>';
try{
const r=await fetch('/api/pptx/list',cr()); const d=await r.json();
const items=d.items||[];
sel.innerHTML='<option value="">— 선택 —</option>'
+items.map(it=>`<option value="${esc(it.relPath)}">${esc(it.project)}${it.hasPptx?' / '+esc(it.filename):' 📂'}</option>`).join('');
}catch(e){ sel.innerHTML='<option value="">오류: '+esc(e.message)+'</option>'; }
}
async function onOpenProjChange(relPath){
if(!relPath)return;
const parts=relPath.replace(/\\/g,'/').split('/');
const hasPptx=relPath.endsWith('.pptx');
// PPTX relPath = "pptx/{slug}/{file}.pptx" → parts[1], no-PPTX relPath = "{slug}" → parts[0]
const project=(hasPptx && parts.length>=3) ? parts[1] : parts[0];
const projInp=document.getElementById('cfg-project'); if(projInp) projInp.value=project;
const dlBtn=document.getElementById('dl-btn');
const regenBtn=document.getElementById('s4-regen');
// 이전 편집/논문 상태 초기화
outlineEdits={};
document.querySelectorAll('.preview-card[data-edited]').forEach(c=>delete c.dataset.edited);
papers=[]; selected=new Set(); paperMeta={};
paperTexts={}; paperTranslations={}; txlStatus={};
prepLog={}; extractedImages=[]; imageCaptions={}; abPaper=null;
// PPTX 없는 프로젝트 — step 1로 이동해서 논문 목록 확인
if(!hasPptx){
pptxRelPath=null;
dlBtn.style.display='none';
if(regenBtn) regenBtn.style.display='none';
await loadExistingProjectPdfs();
goStep(1);
return;
}
pptxRelPath=relPath;
const fileUrl='/api/files/'+relPath.split('/').map(s=>encodeURIComponent(s)).join('/');
dlBtn.href=fileUrl; dlBtn.style.display='inline-flex'; dlBtn.download=parts[parts.length-1];
if(regenBtn) regenBtn.style.display='';
// Reload saved papers for this project
let hasLoadedPapers = false;
try {
const pr = await fetch('/api/pptx/project-papers?project=' + encodeURIComponent(project), cr());
const pd = await pr.json();
if (pd.success && pd.papers && pd.papers.length) {
// Restore papers array (excluding uploads which are session-only)
papers = pd.papers.map(pe => ({
title: pe.title || '',
authors: pe.authors || '',
year: pe.year || '',
journal: pe.journal || '',
doi: pe.doi || '',
pmcid: pe.pmcid || null,
pmid: pe.pmid || null,
_source: pe._source || 'pubmed',
abstract: pe.abstract || '',
_fromSaved: true,
}));
// Restore selection
selected = new Set(pd.papers.map(pe => pe.key));
// Restore paperTexts and paperTranslations from saved files
for (const pe of pd.papers) {
if (pe.txtPath && pe.hasText) {
try {
const tr = await fetch('/api/files/' + pe.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (tr.ok) paperTexts[pe.key] = await tr.text();
} catch {}
}
if (pe.koPath && pe.hasTranslation) {
try {
const kr = await fetch('/api/files/' + pe.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (kr.ok) paperTranslations[pe.key] = await kr.text();
} catch {}
}
if (pe.abstract) {
paperMeta[pe.key] = { ...(paperMeta[pe.key] || {}), abstract: pe.abstract };
}
// Set prepLog so the prep UI shows completed state
prepLog[pe.key] = {
pdf: pe.pdfPath ? 'done' : 'skip',
img: 'skip',
txt: pe.txtPath && pe.hasText ? 'done' : (pe.abstract ? 'done' : 'skip'),
pdfPath: pe.pdfPath || null,
};
if (pe.koPath && pe.hasTranslation) txlStatus[pe.key] = 'done';
}
renderPapers();
updSelUI();
renderProgList();
hasLoadedPapers = true;
}
} catch (e) { console.warn('[wizard] project-papers load failed:', e.message); }
// 아웃라인 역추출 — PPTX → outline.json 순서로 시도
try{
const r=await fetch('/api/pptx/read-outline?path='+encodeURIComponent(relPath),cr());
const d=await r.json();
if(d.success&&d.outline){
outline=d.outline;
if(outline.slides) outline.slides=cleanSlideTexts(outline.slides);
const skin=d.outline.background||'cream';
selectedSkin=skin;
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===skin));
renderOutline();
document.getElementById('s3create').disabled=false;
}
}catch(e){ console.warn('[wizard] outline read failed:',e.message); }
// PPTX에서 못 읽었으면 outline.json 시도
if(!outline){
try{
const proj=resolveProjectSlug();
const ojPath='pptx/'+proj+'/outline.json';
const r=await fetch('/api/files/'+ojPath.split('/').map(encodeURIComponent).join('/'),cr());
if(r.ok){
const d=await r.json();
if(d && d.slides){
outline=d;
if(outline.slides) outline.slides=cleanSlideTexts(outline.slides);
const skin=outline.background||'cream';
selectedSkin=skin;
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===skin));
renderOutline();
document.getElementById('s3create').disabled=false;
}
}
}catch{}
}
// 디스크 PDF도 로드 시도 (papers.json 없는 프로젝트 포함)
await loadExistingProjectPdfs();
if (hasLoadedPapers || papers.length > 0) {
goStep(1);
loadPreview(relPath); // 백그라운드로 프리뷰도 미리 로드
} else {
goStep(4);
await loadPreview(relPath);
}
}
(function(){
loadProjList();
// 이전 세션 복원 (새로고침 후에도 outline/편집 내용 유지)
try{
const saved=JSON.parse(localStorage.getItem('pptx-session')||'null');
// 24시간 이내 세션만 복원
if(saved && saved.outline && saved.pptxRelPath && (Date.now()-saved.ts)<86400000){
outline=saved.outline;
pptxRelPath=saved.pptxRelPath;
if(saved.skin){ selectedSkin=saved.skin; }
const fileUrl='/api/files/'+saved.pptxRelPath.split('/').map(encodeURIComponent).join('/');
const dlBtn=document.getElementById('dl-btn');
if(dlBtn){ dlBtn.href=fileUrl; dlBtn.style.display='inline-flex'; dlBtn.download=saved.pptxRelPath.split('/').pop()||'slides.pptx'; }
const regenBtn=document.getElementById('s4-regen'); if(regenBtn) regenBtn.style.display='';
document.getElementById('s3create').disabled=false;
renderOutline();
goStep(4);
loadPreview(saved.pptxRelPath);
}
}catch(_){}
})();
// ── init ────────────────────────────────────────────────────────────────────
const TMPLS = [
{v:'dark', label:'Dark', bg:'#1F242D', accent:'#4C8DFF'},
{v:'business', label:'Business', bg:'#FFFFFF', accent:'#1668E3'},
{v:'minimal', label:'Minimal', bg:'#F8F8F8', accent:'#888888'},
{v:'creative', label:'Creative', bg:'#FFFFFF', accent:'#E53E3E'},
{v:'pastel', label:'Pastel', bg:'#FAF5FF', accent:'#9F7AEA'},
{v:'warm', label:'Warm', bg:'#FFFBF0', accent:'#D97706'},
];
let selectedTmpl = 'creative';
(function initTmplGrid() {
const g = document.getElementById('tmpl-grid'); if(!g) return;
TMPLS.forEach(t => {
const d = document.createElement('div');
d.className = 'skin-sw' + (t.v === selectedTmpl ? ' active' : '');
d.title = t.label;
d.dataset.v = t.v;
// 위: 배경색, 아래 1/4: 포인트색 바
d.style.cssText = `background:${t.bg};border:2px solid ${t.v===selectedTmpl?'var(--brand)':'transparent'};position:relative;overflow:hidden;`;
const bar = document.createElement('div');
bar.style.cssText = `position:absolute;bottom:0;left:0;right:0;height:30%;background:${t.accent};`;
d.appendChild(bar);
// 라벨
const lbl = document.createElement('div');
lbl.style.cssText = `position:absolute;top:2px;left:0;right:0;text-align:center;font-size:8px;font-weight:700;color:${t.bg==='#FFFFFF'||t.bg==='#F8F8F8'||t.bg==='#FAF5FF'||t.bg==='#FFFBF0'?'#333':'#eee'};`;
lbl.textContent = t.label;
d.appendChild(lbl);
d.onclick = () => selectTmpl(t.v);
g.appendChild(d);
});
})();
function selectTmpl(v) {
selectedTmpl = v;
document.getElementById('cfg-tmpl').value = v;
document.querySelectorAll('#tmpl-grid .skin-sw').forEach(el => {
const t = TMPLS.find(x => x.v === el.dataset.v);
el.classList.toggle('active', el.dataset.v === v);
el.style.borderColor = el.dataset.v === v ? 'var(--brand)' : 'transparent';
});
const t = TMPLS.find(x => x.v === v);
if(t) document.getElementById('tmpl-lbl').textContent = t.label;
}
(function initSkinGrid() {
const g = document.getElementById('skin-grid');
SKINS.forEach(s => {
const d = document.createElement('div');
d.className = 'skin-sw' + (s.v === selectedSkin ? ' active' : '');
d.style.background = s.c;
d.title = s.label;
d.dataset.v = s.v;
d.onclick = () => selectSkin(s.v);
g.appendChild(d);
});
const initSk = SKINS.find(s => s.v === selectedSkin);
if(initSk) document.getElementById('skin-name-lbl').textContent = initSk.label;
})();
function selectSkin(v) {
selectedSkin = v;
document.querySelectorAll('.skin-sw').forEach(el => el.classList.toggle('active', el.dataset.v === v));
const sk = SKINS.find(s => s.v === v);
document.getElementById('skin-name-lbl').textContent = sk ? sk.label : '';
}
// ── PDF 이미지 자동 추출 토글 ──────────────────────────────────────────────────
let autoExtractImages = localStorage.getItem('pptx-auto-img') !== 'off';
function updAutoImgLbl() {
const l = document.getElementById('cfg-auto-img-lbl'); if(!l) return;
l.textContent = autoExtractImages ? '켜짐 — 준비 단계에서 그림·도표 자동 추출' : '꺼짐 — 수동 캡처/추출만 사용';
}
function onAutoImgToggle() {
autoExtractImages = document.getElementById('cfg-auto-img').checked;
localStorage.setItem('pptx-auto-img', autoExtractImages ? 'on' : 'off');
updAutoImgLbl();
}
(function initAutoImg(){
const cb = document.getElementById('cfg-auto-img');
if(cb) cb.checked = autoExtractImages;
updAutoImgLbl();
})();
// ── PDF 텍스트 추출 토글 ───────────────────────────────────────────────────────
let extractText = localStorage.getItem('pptx-extract-txt') !== 'off';
function updExtractTxtLbl() {
const l = document.getElementById('cfg-extract-txt-lbl'); if(!l) return;
l.textContent = extractText ? '켜짐 — 본문을 추출해 아웃라인 근거로 사용' : '꺼짐 — 본문 추출 생략';
}
function onExtractTxtToggle() {
extractText = document.getElementById('cfg-extract-txt').checked;
localStorage.setItem('pptx-extract-txt', extractText ? 'on' : 'off');
updExtractTxtLbl();
}
(function initExtractTxt(){
const cb = document.getElementById('cfg-extract-txt');
if(cb) cb.checked = extractText;
updExtractTxtLbl();
})();
// ── 본문 자동 번역 토글 ────────────────────────────────────────────────────────
let autoTranslate = localStorage.getItem('pptx-auto-txl') !== 'off';
function updAutoTxlLbl() {
const l = document.getElementById('cfg-auto-txl-lbl'); if(!l) return;
l.textContent = autoTranslate ? '켜짐 — 추출한 본문을 한국어로 자동 번역' : '꺼짐 — 번역 안 함';
}
function onAutoTxlToggle() {
autoTranslate = document.getElementById('cfg-auto-txl').checked;
localStorage.setItem('pptx-auto-txl', autoTranslate ? 'on' : 'off');
updAutoTxlLbl();
}
(function initAutoTxl(){
const cb = document.getElementById('cfg-auto-txl');
if(cb) cb.checked = autoTranslate;
updAutoTxlLbl();
})();
// ── credentials ─────────────────────────────────────────────────────────────
function cr() { return {credentials:'include'}; }
function jh() { return {credentials:'include',headers:{'Content-Type':'application/json'}}; }
// ── utils ───────────────────────────────────────────────────────────────────
function esc(s){ return String(s||'').replace(/&/g,'&amp;').replace(/\\/g,'&#92;').replace(/`/g,'&#96;').replace(/\$/g,'&#36;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function cleanOoxmlEscapes(s){
if(typeof s!=='string') return s;
return s.replace(/_?x[0-9A-Fa-f]{4}_/g,' ').replace(/ +/g,' ').trim();
}
function cleanSlideTexts(slides){
const TEXT_FIELDS=['title','subtitle','body','content','notes'];
return (slides||[]).map(s=>{
const out=Object.assign({},s);
TEXT_FIELDS.forEach(f=>{ if(typeof out[f]==='string') out[f]=cleanOoxmlEscapes(out[f]); });
if(Array.isArray(out.bullets)) out.bullets=out.bullets.map(b=>typeof b==='string'?cleanOoxmlEscapes(b):b);
if(Array.isArray(out.bullet_points)) out.bullet_points=out.bullet_points.map(b=>typeof b==='string'?cleanOoxmlEscapes(b):b);
return out;
});
}
function errHtml(s){ return esc(s).replace(/(https?:\/\/[^\s&]+)/g,'<a href="$1" target="_blank" style="color:#f87171;word-break:break-all">$1</a>').replace(/\n/g,'<br>'); }
function setMsg(n, msg, c){ const e=document.getElementById('s'+n+'msg'); if(e){e.textContent=msg;e.style.color=c||'var(--muted)';} }
// 경과 시간 타이머: startTimer('s3msg', '생성 중', color) → stopFn
function startTimer(msgId, baseText, color) {
const t0=Date.now();
const el=document.getElementById(msgId); if(!el)return()=>{};
el.textContent=baseText+' (0s)'; el.style.color=color||'var(--brand2)';
el.classList.add('running');
const iv=setInterval(()=>{
if(!el.isConnected){clearInterval(iv);return;}
el.textContent=baseText+' ('+Math.floor((Date.now()-t0)/1000)+'s)';
},1000);
return ()=>{ clearInterval(iv); el.classList.remove('running'); };
}
function ic(state){ return state==='done'?'<span style="color:var(--green)">✓</span>':state==='running'?'<span style="color:var(--brand2)">⟳</span>':state==='fail'?'<span style="color:var(--red)">✗</span>':state==='skip'?'<span style="color:var(--muted)">—</span>':'<span style="color:var(--muted)">○</span>'; }
// ── steps ───────────────────────────────────────────────────────────────────
function goStep(n) {
for(let i=1;i<=4;i++){
document.getElementById('step-'+i).classList.toggle('active',i===n);
const d=document.getElementById('sd'+i);
d.classList.toggle('active',i===n); d.classList.toggle('done',i<n);
}
currentStep=n;
if(n===1){ renderPapers(); renderUploadList(); updSelUI(); loadExistingProjectPdfs(); }
if(n===2){ renderProgList(); loadExistingProjectPdfs(); }
}
// ── STEP 1: Search ──────────────────────────────────────────────────────────
async function doSearch() {
const q=document.getElementById('qi').value.trim();
const yr=document.getElementById('qy').value;
const sort=document.getElementById('cfg-sort').value;
if(!q)return;
const usePubmed=document.querySelector('#src-pubmed input')?.checked;
const useOpenalex=document.querySelector('#src-openalex input')?.checked;
const useSemantic=document.querySelector('#src-semantic input')?.checked;
const btn=document.getElementById('sbtn');
btn.disabled=true; btn.textContent='검색 중...'; setMsg(1,'검색 중...','var(--brand2)');
document.getElementById('results').innerHTML='<div style="color:var(--muted);padding:14px;text-align:center;font-size:12px">검색 중...</div>';
try {
const tasks=[];
if(usePubmed) tasks.push(
fetch('/api/pubmed/search?q='+encodeURIComponent(q)+'&max=15&sort='+sort+(yr?'&min_year='+yr:''),cr())
.then(r=>r.json()).then(d=>(d.data?.results||[]).map(p=>({...p,_source:'pubmed'})))
.catch(()=>[])
);
if(useOpenalex) tasks.push(
fetch('/api/openalex/search?q='+encodeURIComponent(q)+'&max=15&sort='+sort+(yr?'&min_year='+yr:''),cr())
.then(r=>r.json()).then(d=>(d.data?.results||[]).map(p=>({
_source:'openalex', title:p.title, authors:(p.authors||[]).join(', '),
year:p.year, journal:'', doi:p.doi, pmcid:null, pmid:null, pub_types:'',
citations:p.citations, is_oa:p.is_oa, oa_url:p.oa_url, abstract:p.abstract||'',
}))).catch(()=>[])
);
if(useSemantic) tasks.push(
fetch('/api/semantic/search?q='+encodeURIComponent(q)+'&max=15'+(yr?'&min_year='+yr:''),cr())
.then(r=>r.json()).then(d=>(d.data?.papers||[]).map(p=>({
_source:'semantic', title:p.title, authors:(p.authors||[]).join(', '),
year:p.year, journal:'', doi:p.doi, pmcid:null, pmid:p.pmid||null, pub_types:'',
citations:p.citations, inf_citations:p.inf_citations, oa_pdf:p.oa_pdf, abstract:p.abstract||'',
}))).catch(()=>[])
);
const results=await Promise.all(tasks);
// 병합 + DOI 기준 중복 제거 (pubmed 우선)
const merged=[], seenDoi=new Set(), seenTitle=new Set();
for(const list of results) for(const p of list){
const dkey=p.doi?p.doi.toLowerCase().trim():'';
const tkey=p.title?p.title.toLowerCase().slice(0,60):'';
if(dkey&&seenDoi.has(dkey)) continue;
if(!dkey&&tkey&&seenTitle.has(tkey)) continue;
if(dkey) seenDoi.add(dkey);
if(tkey) seenTitle.add(tkey);
merged.push(p);
}
papers=[...papers.filter(p=>p._isUpload), ...merged];
renderPapers();
setMsg(1,merged.length+'건 검색됨 ('+[usePubmed&&'PubMed',useOpenalex&&'OpenAlex',useSemantic&&'Semantic'].filter(Boolean).join('+')+')','var(--green)');
document.getElementById('search-legend').style.display='';
} catch(e){ setMsg(1,'검색 실패: '+e.message,'var(--red)'); document.getElementById('results').innerHTML='<div style="color:var(--red);padding:12px;font-size:12px">'+esc(e.message)+'</div>'; }
finally{ btn.disabled=false; btn.textContent='🔍 검색'; }
}
function renderPapers() {
const el=document.getElementById('results');
const searchPapers=papers.filter(p=>!p._isUpload);
if(!searchPapers.length){el.innerHTML='<div style="color:var(--muted);padding:12px;font-size:12px">결과 없음</div>';return;}
el.innerHTML=searchPapers.map((p)=>{
const idx=papers.indexOf(p);
const src=p._source||'pubmed';
const hp=!!p.pmcid;
const hasOaPdf=!!(p.oa_url||p.oa_pdf);
const hasPdf=src==='pubmed'?hp:hasOaPdf;
const pmcid=hp?'PMC'+String(p.pmcid||'').replace(/^PMC/i,''):'';
const key=paperKey(p); const isSel=key?selected.has(key):false;
const canSelect=!!key;
const srcBadge=src==='openalex'?`<span class="badge" style="background:rgba(34,197,94,.15);color:var(--green)">OpenAlex</span>`:
src==='semantic'?`<span class="badge" style="background:rgba(251,191,36,.15);color:var(--yellow)">Semantic Scholar</span>`:'';
const citeBadge=p.citations?`<span class="badge" style="background:rgba(148,163,184,.1);color:var(--muted)">인용 ${p.citations}</span>`:'';
const oaBadge=hasOaPdf?`<span class="badge" style="background:rgba(34,197,94,.15);color:var(--green)" title="Open Access PDF 있음">OA</span>`
:(!hasPdf?`<span class="badge" style="background:rgba(148,163,184,.1);color:var(--muted)" title="PDF 없음 — 초록으로만 사용">초록만</span>`:'');
return `<div class="pcard ${isSel?'sel':''}" id="pc${idx}">
<input type="checkbox" ${isSel?'checked':''} ${!canSelect?'disabled':''} onclick="event.stopPropagation();toggleP(${idx})">
<div class="pmeta">
<div class="ptitle2">${esc(p.title)}</div>
<div class="psub">${esc(p.authors||'')} · ${esc(p.journal||'')} ${esc(p.year||'')}</div>
<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:3px">
${srcBadge}
${src==='pubmed'?(hp?`<span class="badge bpmc" title="PMC 전문 PDF 가능">${pmcid}</span>`:'<span class="badge bnopmc">PMC미등재</span>'):''}
${p.year?`<span class="badge byear">${p.year}</span>`:''}
${p.pub_types?`<span class="badge btype">${p.pub_types.split(',')[0].trim()}</span>`:''}
${oaBadge}${citeBadge}
</div>
<div class="pbtns">
${src==='pubmed'?`<button class="btn bg" style="font-size:12px;padding:4px 12px" onclick="event.stopPropagation();showAbstract(${idx})">📋 초록 보기</button>`:''}
${p.pmid?`<a class="btn bg" style="font-size:12px;padding:4px 10px;text-decoration:none" href="https://pubmed.ncbi.nlm.nih.gov/${p.pmid}/" target="_blank" onclick="event.stopPropagation()">PubMed ↗</a>`:''}
${hp?`<a class="btn bg" style="font-size:12px;padding:4px 10px;text-decoration:none;color:var(--green)" href="https://www.ncbi.nlm.nih.gov/pmc/articles/${pmcid}/" target="_blank" onclick="event.stopPropagation()">전문(PMC) ↗</a>`:''}
${(p.oa_url||p.oa_pdf)?`<a class="btn bg" style="font-size:12px;padding:4px 10px;text-decoration:none;color:var(--green)" href="${p.oa_url||p.oa_pdf}" target="_blank" onclick="event.stopPropagation()">OA PDF ↗</a>`:''}
${p.doi?`<a class="btn bg" style="font-size:12px;padding:4px 10px;text-decoration:none" href="https://doi.org/${p.doi}" target="_blank" onclick="event.stopPropagation()">DOI ↗</a>`:''}
</div>
</div>
</div>`;
}).join('');
updSelUI();
}
function renderUploadList() {
const el=document.getElementById('upload-list'); if(!el)return;
const ups=papers.filter(p=>p._isUpload);
if(!ups.length){el.innerHTML='';return;}
el.innerHTML=ups.map((p,_)=>{
const idx=papers.indexOf(p);
const key=paperKey(p); const isSel=key?selected.has(key):false;
const fname=(p._localPdf||'').split('/').pop();
return `<div class="pcard ${isSel?'sel':''}" id="pc${idx}" style="cursor:pointer" onclick="toggleP(${idx})">
<input type="checkbox" ${isSel?'checked':''} onclick="event.stopPropagation();toggleP(${idx})">
<div class="pmeta">
<div class="ptitle2">${esc(p.title||fname)}</div>
<div class="psub" style="font-size:10px">${esc(p._localPdf||'')} ${p._size?'· '+Math.round(p._size/1024)+'KB':''}</div>
<div style="margin-top:3px">
<span class="badge bupload" title="직접 업로드한 PDF">📄 직접 업로드</span>
</div>
<div class="pbtns">
<button class="btn bred" style="font-size:11px;padding:3px 10px" onclick="event.stopPropagation();removeUpload(${idx})">✕ 제거</button>
</div>
</div>
</div>`;
}).join('');
}
function toggleP(idx) {
const p=papers[idx]; if(!p)return;
const key=paperKey(p); if(!key)return;
selected.has(key)?selected.delete(key):selected.add(key);
const card=document.getElementById('pc'+idx);
if(card){ card.classList.toggle('sel',selected.has(key)); const cb=card.querySelector('input');if(cb)cb.checked=selected.has(key); }
updSelUI();
}
function updSelUI() {
document.getElementById('s1next').disabled=selected.size===0;
const el=document.getElementById('sel-summary');
if(!selected.size){el.textContent='없음';return;}
el.innerHTML=Array.from(selected).map(id=>`<div>${esc(paperLabel(id))}</div>`).join('');
}
// ── Abstract modal ───────────────────────────────────────────────────────────
let abPaper=null, abEn='', abKo='';
async function showAbstract(idx) {
abPaper=papers[idx]; abEn=''; abKo=''; abTabMode='en';
document.getElementById('am-title').textContent=abPaper.title||'';
document.getElementById('am-meta').textContent=(abPaper.authors||'')+' · '+(abPaper.journal||'')+' ('+abPaper.year+')';
document.getElementById('am-body').textContent='초록 로딩 중...';
document.getElementById('at-en').classList.add('active'); document.getElementById('at-ko').classList.remove('active');
document.getElementById('am-translate-btn').disabled=false;
document.getElementById('am-translate-btn').textContent='🌐 번역';
document.getElementById('abg').classList.add('open');
const pmcid='PMC'+String(abPaper.pmcid||abPaper.pmid||'').replace(/^PMC/i,'');
const pmid=String(abPaper.pmid||'');
try {
const r=await fetch('/api/pubmed/fetch?pmids='+pmid,cr()); const d=await r.json();
if(!r.ok) throw new Error(d.error||'fetch failed');
// pubmedFetchTool returns data.articles[], fallback to stdout text
const art=d.data?.articles?.[0]||d.data?.results?.[0];
abEn=(art?.abstract) || (() => { const m=(d.stdout||'').match(/Abstract:\n([\s\S]*?)(?:\n\n|\n═|$)/); return m?m[1].trim():''; })() || d.stdout?.slice(0,2000) || '초록 없음';
// Store in state for outline generation
paperMeta[pmcid]={...( paperMeta[pmcid]||{}), abstract:abEn };
document.getElementById('am-body').textContent=abEn;
} catch(e){ document.getElementById('am-body').textContent='초록 로드 실패: '+e.message; }
}
function switchAbTab(t) {
abTabMode=t;
document.getElementById('at-en').classList.toggle('active',t==='en');
document.getElementById('at-ko').classList.toggle('active',t==='ko');
if(t==='en') document.getElementById('am-body').textContent=abEn||'';
else document.getElementById('am-body').textContent=abKo||'(번역 버튼을 눌러주세요)';
}
async function translateAbstract() {
if(!abEn){return;}
const btn=document.getElementById('am-translate-btn');
btn.disabled=true; btn.textContent='번역 중...';
if(abKo){ switchAbTab('ko'); btn.disabled=false; btn.textContent='🌐 번역'; return; }
// switch to ko tab immediately and show streaming progress
abTabMode='ko';
document.getElementById('at-en').classList.remove('active');
document.getElementById('at-ko').classList.add('active');
const bodyEl=document.getElementById('am-body');
bodyEl.textContent='번역 중...';
try {
const msg='다음 영문 의학 논문 초록을 자연스러운 한국어로 번역해주세요:\n\n'+abEn;
const r=await fetch('/api/chat',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json','Accept':'text/event-stream'},body:JSON.stringify({message:msg,sessionId:'translate-abstract',model:'mistral-large-3:675b-cloud'})});
if(!r.ok) throw new Error('API '+r.status);
const reader=r.body.getReader(),dec=new TextDecoder();
let buf='',reply='';
while(true){
const {done,value}=await reader.read(); if(done)break;
buf+=dec.decode(value,{stream:true});
const lines=buf.split('\n'); buf=lines.pop();
for(const line of lines){
if(!line.startsWith('data: '))continue;
try{ const ev=JSON.parse(line.slice(6));
if((ev.type==='token'||ev.type==='text_delta')&&ev.text){
reply+=ev.text;
if(abTabMode==='ko') bodyEl.textContent=reply;
} else if(ev.type==='final'||ev.type==='reply'||ev.type==='final_reply'){
const t=ev.text||ev.reply||ev.content; if(t) reply=t;
} else if(ev.type==='done'&&ev.reply){ reply=ev.reply; }
}catch{}
}
}
if(buf.trim()){ try{ const ev=JSON.parse(buf.trim().replace(/^data: /,'')); if(ev.type==='final'&&ev.text) reply=ev.text; }catch{} }
abKo=reply.trim()||'번역 실패';
const pmcid=abPaper?'PMC'+String(abPaper.pmcid||'').replace(/^PMC/i,''):'';
if(pmcid) paperMeta[pmcid]={...(paperMeta[pmcid]||{}),translatedAbstract:abKo};
if(abTabMode==='ko') bodyEl.textContent=abKo;
} catch(e){ abKo='번역 오류: '+e.message; if(abTabMode==='ko') bodyEl.textContent=abKo; }
finally{ btn.disabled=false; btn.textContent='🌐 번역'; }
}
function closeAbModal(){ document.getElementById('abg').classList.remove('open'); }
// ── PDF 텍스트 번역 ──────────────────────────────────────────────────────────
const txlStartTime = {};
const CHUNK_SIZE = 15000;
function splitChunks(text, size) {
const chunks = [];
let pos = 0;
while (pos < text.length) {
let end = pos + size;
if (end < text.length) {
const nl = text.lastIndexOf('\n\n', end);
if (nl > pos) end = nl + 2;
else { const n = text.lastIndexOf('\n', end); if (n > pos) end = n + 1; }
}
chunks.push(text.slice(pos, end));
pos = end;
}
return chunks;
}
async function chatStream(msg, sessionId) {
const r = await fetch('/api/chat', {method:'POST', credentials:'include',
headers:{'Content-Type':'application/json','Accept':'text/event-stream'},
body: JSON.stringify({message: msg, sessionId, direct: true, model: 'mistral-large-3:675b-cloud'})});
if (!r.ok) throw new Error('API ' + r.status);
const reader = r.body.getReader(), dec = new TextDecoder();
let buf = '', reply = '';
while (true) {
const {done, value} = await reader.read(); if (done) break;
buf += dec.decode(value, {stream: true});
const lines = buf.split('\n'); buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try { const ev = JSON.parse(line.slice(6));
if ((ev.type==='token'||ev.type==='text_delta') && ev.text) reply += ev.text;
else if (ev.type==='final' && ev.text) reply = ev.text;
else if (ev.type==='done' && ev.reply) reply = ev.reply;
} catch {}
}
}
return reply.trim();
}
async function translatePdfText(key, text) {
txlStatus[key]='running'; txlStartTime[key]=Date.now(); updProg(key);
const timer=setInterval(()=>{ if(txlStatus[key]==='running') updProg(key); else clearInterval(timer); },1000);
const sid = 'translate-pdf-' + key.replace(/[^a-z0-9]/gi,'_').slice(0,30);
const chunks = splitChunks(text, CHUNK_SIZE);
const parts = new Array(chunks.length).fill(null);
let failed = false;
try {
// 모든 청크를 병렬로 번역
await Promise.all(chunks.map(async (chunk, i) => {
const prompt = '다음 의학 논문 본문을 자연스러운 한국어로 번역해주세요. 원문의 단락 구조를 유지하세요:\n\n' + chunk;
parts[i] = await chatStream(prompt, sid + '_c' + i);
// 완료된 청크 수 표시
const done = parts.filter(Boolean).length;
paperTranslations[key] = parts.map((p,j)=>p||(j<done?'':'[번역 중...]')).filter(Boolean).join('\n\n');
updProg(key);
}));
paperTranslations[key] = parts.join('\n\n').trim() || '번역 실패';
txlStatus[key] = 'done';
} catch(e) {
paperTranslations[key] = parts.filter(Boolean).join('\n\n') + (parts.filter(Boolean).length ? '\n\n' : '') + '번역 오류: ' + e.message;
txlStatus[key] = 'fail';
}
clearInterval(timer);
updProg(key);
updOutlineBtn();
savePapersToProject();
}
function updOutlineBtn() {
// 준비 완료 기준: 각 논문의 텍스트 단계가 종료(done/fail/skip)되고,
// 번역이 진행 중이 아닐 때. (번역/추출 토글 OFF여도 게이트가 막히지 않음)
const allDone=Array.from(selected).every(k=>{
const s=prepLog[k]||{};
const txtTerminal=s.txt==='done'||s.txt==='fail'||s.txt==='skip';
if(!txtTerminal) return false;
if(txlStatus[k]==='running') return false;
return true;
});
let btn=document.getElementById('s2-outline-btn');
if(allDone){
if(!btn){
btn=document.createElement('button');
btn.id='s2-outline-btn'; btn.className='btn bp';
btn.textContent='✨ 아웃라인 생성 →';
btn.onclick=()=>{ goStep(3); generateOutline(); };
document.querySelector('#step-2 .bbar').appendChild(btn);
}
} else {
if(btn) btn.remove();
}
}
let txlViewKey=null, txlTabMode='en';
function showPdfTranslation(key){
txlViewKey=key; txlTabMode='en';
document.getElementById('txl-title').textContent=paperLabel(key)+' — 전문/번역';
const body=document.getElementById('txl-body');
body.textContent=paperTexts[key]||'전문 없음';
document.getElementById('txl-status').textContent=txlStatus[key]==='done'?'번역 완료':txlStatus[key]==='fail'?'번역 실패':'';
document.getElementById('txl-tab-en').style.background='var(--brand)';
document.getElementById('txl-tab-en').style.color='#fff';
document.getElementById('txl-tab-ko').style.background='';
document.getElementById('txl-tab-ko').style.color='';
document.getElementById('txlbg').classList.add('open');
}
function switchTxlTab(t){
txlTabMode=t;
const body=document.getElementById('txl-body');
if(t==='en'){
body.textContent=paperTexts[txlViewKey]||'전문 없음';
document.getElementById('txl-tab-en').style.background='var(--brand)';
document.getElementById('txl-tab-en').style.color='#fff';
document.getElementById('txl-tab-ko').style.background='';
document.getElementById('txl-tab-ko').style.color='';
} else {
body.textContent=paperTranslations[txlViewKey]||'번역 없음';
document.getElementById('txl-tab-ko').style.background='var(--brand)';
document.getElementById('txl-tab-ko').style.color='#fff';
document.getElementById('txl-tab-en').style.background='';
document.getElementById('txl-tab-en').style.color='';
}
}
function closeTxlModal(){ document.getElementById('txlbg').classList.remove('open'); }
function copyTxlText(){
const t=document.getElementById('txl-body').textContent;
navigator.clipboard.writeText(t).then(()=>{
const s=document.getElementById('txl-status'); s.textContent='클립보드에 복사됨'; setTimeout(()=>s.textContent='',2000);
});
}
// ── PDF 직접 업로드 ──────────────────────────────────────────────────────────
function handlePdfDrop(e) {
e.preventDefault();
const files=[...e.dataTransfer.files].filter(f=>f.name.toLowerCase().endsWith('.pdf'));
if(files.length) uploadPdfs(files);
}
function handlePdfFileInput(input) {
const files=[...input.files];
if(files.length) uploadPdfs(files);
input.value='';
}
async function uploadPdfs(files) {
const dz=document.getElementById('pdf-dropzone');
dz.textContent='업로드 중...';
for(const file of files){
try{
const fd=new FormData(); fd.append('file',file);
const r=await fetch('/api/upload/file',{method:'POST',credentials:'include',body:fd});
const d=await r.json();
if(!d.success) throw new Error(d.error||'업로드 실패');
const existing=papers.findIndex(p=>p._isUpload&&p._localPdf===d.path);
if(existing<0){
const fname=file.name.replace(/\.pdf$/i,'');
papers.push({_isUpload:true,_localPdf:d.path,_size:file.size,title:fname,pmcid:null,pmid:null,authors:'',journal:'',year:'',doi:'',pub_types:''});
const key=paperKey(papers[papers.length-1]);
selected.add(key);
}
setMsg(1,file.name+' 업로드 완료','var(--green)');
}catch(e){ setMsg(1,file.name+' 실패: '+e.message,'var(--red)'); }
}
dz.innerHTML='📄 PDF 파일을 드래그하거나 클릭해서 선택<br><span style="font-size:10px">여러 파일 동시 업로드 가능</span>';
renderUploadList(); updSelUI();
}
function removeUpload(idx){
const p=papers[idx]; if(!p||!p._isUpload)return;
const key=paperKey(p); selected.delete(key);
papers.splice(idx,1);
renderUploadList(); updSelUI();
}
// ── STEP 2: Prep ─────────────────────────────────────────────────────────────
// Load existing PDFs from the project folder and papers.json manifest.
// If the project already has PDFs on disk, add them as selectable papers
// so the user doesn't have to re-download or re-search.
async function loadExistingProjectPdfs() {
const project = resolveProjectSlug();
if (!project) return;
// 1) Try to load papers.json manifest first (has full metadata)
let manifestPapers = [];
try {
const pr = await fetch('/api/pptx/project-papers?project=' + encodeURIComponent(project), cr());
const pd = await pr.json();
if (pd.success && pd.papers && pd.papers.length) {
manifestPapers = pd.papers;
}
} catch {}
// 2) If we have manifest data and papers aren't already loaded, restore them
if (manifestPapers.length && !papers.filter(p => !p._isUpload).length) {
for (const pe of manifestPapers) {
const existingKey = pe.key;
if (selected.has(existingKey)) continue; // already in selection
const isUplEntry = existingKey.startsWith('UPL_') || pe._source === 'upload';
papers.push({
title: pe.title || '',
authors: pe.authors || '',
year: pe.year || '',
journal: pe.journal || '',
doi: pe.doi || '',
pmcid: pe.pmcid || null,
pmid: pe.pmid || null,
_source: pe._source || 'pubmed',
abstract: pe.abstract || '',
_fromSaved: true,
...(isUplEntry ? { _isUpload: true, _localPdf: pe.pdfPath || '' } : {}),
});
selected.add(existingKey);
// Restore prepLog for already-processed papers
if (!prepLog[existingKey]) {
prepLog[existingKey] = {
pdf: pe.pdfPath ? 'done' : 'skip',
img: 'skip',
txt: pe.txtPath && pe.hasText ? 'done' : 'skip',
pdfPath: pe.pdfPath || null,
};
}
// Restore paperTexts from .txt files
if (pe.txtPath && pe.hasText && !paperTexts[existingKey]) {
try {
const tr = await fetch('/api/files/' + pe.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (tr.ok) paperTexts[existingKey] = await tr.text();
} catch {}
}
// Restore translations
if (pe.koPath && pe.hasTranslation && !paperTranslations[existingKey]) {
try {
const kr = await fetch('/api/files/' + pe.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (kr.ok) { paperTranslations[existingKey] = await kr.text(); txlStatus[existingKey] = 'done'; }
} catch {}
}
if (pe.abstract && !paperMeta[existingKey]) {
paperMeta[existingKey] = { abstract: pe.abstract };
}
}
renderPapers();
updSelUI();
// 프로젝트 폴더 이미지 로드 (UPL_ 전용 프로젝트도 포함)
try {
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(project), cr());
const id = await ir.json();
if (id.images && id.images.length) {
for (const f of id.images) {
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
if (!extractedImages.find(img => img.url === url))
extractedImages.push({ url, label: f.name, pdf: null });
}
renderImgGrid();
document.getElementById('imgcnt').textContent = '(' + extractedImages.length + ')';
}
} catch {}
}
// 3) Also scan for PDFs on disk that aren't in the manifest
// (e.g. manually added, or from before papers.json was introduced)
try {
const r = await fetch('/api/pptx/project-pdfs?project=' + encodeURIComponent(project), cr());
const d = await r.json();
const pdfs = d.pdfs || [];
for (const pdf of pdfs) {
// pdf.path = "sinus/PMC8273047.pdf", pdf.name = "PMC8273047" (no extension)
const pathFile = (pdf.path || '').split('/').pop() || '';
const baseName = pdf.name || pathFile.replace(/\.pdf$/i, '') || '';
if (!baseName) continue;
const key = baseName.startsWith('PMC') ? baseName : 'UPL_' + baseName;
if (selected.has(key) || papers.some(p => paperKey(p) === key)) continue; // already known
// Add as a selectable paper from existing disk file
papers.push({
_isUpload: true,
_localPdf: pdf.path,
_fromSaved: true,
title: baseName,
pmcid: baseName.startsWith('PMC') ? baseName.replace(/^PMC/i, '') : null,
pmid: null, authors: '', journal: '', year: '', doi: '', pub_types: '',
});
selected.add(key);
// Set prepLog as done (PDF already on disk)
if (!prepLog[key]) {
prepLog[key] = { pdf: 'done', img: 'skip', txt: 'skip', pdfPath: pdf.path };
}
// Load .txt text from companion file (server tells us if it exists)
if (pdf.hasText && pdf.txtPath && !paperTexts[key]) {
try {
const tr = await fetch('/api/files/' + pdf.txtPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (tr.ok) {
paperTexts[key] = await tr.text();
prepLog[key].txt = 'done';
}
} catch {}
}
// Load .ko.txt translation from companion file
if (pdf.hasTranslation && pdf.koPath && !paperTranslations[key]) {
try {
const kr = await fetch('/api/files/' + pdf.koPath.split('/').map(s => encodeURIComponent(s)).join('/'), cr());
if (kr.ok) {
paperTranslations[key] = await kr.text();
txlStatus[key] = 'done';
}
} catch {}
}
// Try to load extracted images from {project}/{baseName}_images/ directory
// Images are at the project directory root, scanned via project-images endpoint
const imgDir = project + '/' + baseName + '_images';
if (!extractedImages.some(img => img.url.includes(baseName + '_images'))) {
try {
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(imgDir), cr());
const id = await ir.json();
if (id.images && id.images.length) {
for (const f of id.images) {
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
if (!extractedImages.find(img => img.url === url)) {
extractedImages.push({ url, label: baseName + ': ' + f.name, pdf: pdf.path });
}
}
prepLog[key].img = 'done';
prepLog[key].imgCount = id.images.length;
}
} catch {}
}
}
// Also load images from the project directory root (extracted figures at root level)
try {
const ir = await fetch('/api/pptx/project-images?project=' + encodeURIComponent(project), cr());
const id = await ir.json();
if (id.images && id.images.length) {
for (const f of id.images) {
const url = '/api/files/' + f.path.split('/').map(s => encodeURIComponent(s)).join('/');
if (!extractedImages.find(img => img.url === url)) {
extractedImages.push({ url, label: f.name, pdf: null });
}
}
}
} catch {}
if (pdfs.length) {
renderPapers();
renderUploadList();
updSelUI();
}
renderImgGrid();
document.getElementById('imgcnt').textContent = '(' + extractedImages.length + ')';
} catch {}
// 5) Update UI — if all selected papers are already prepped, show completed state
const allDone = Array.from(selected).every(k => {
const s = prepLog[k] || {};
return s.pdf === 'done' || s.pdf === 'skip';
});
renderProgList();
if (allDone && selected.size > 0) {
setMsg(2, '✓ 기존 PDF 복원됨 — 바로 아웃라인 생성 가능', 'var(--green)');
// Show the outline button
updOutlineBtn();
// Change start button to "다시 실행"
const btn = document.getElementById('s2start');
if (btn) { btn.disabled = false; btn.textContent = '▶ 다시 실행'; }
}
}
function pspHtml(label, state, extra='') {
const cls = state==='done'?'psp-done':state==='running'?'psp-run':state==='fail'?'psp-fail':state==='skip'?'psp-skip':'psp-wait';
const icon = state==='done'?'✓ ':state==='running'?'⟳ ':state==='fail'?'✗ ':state==='skip'?'— ':'○ ';
return `<span class="psp ${cls}">${icon}${label}${extra?'&thinsp;'+extra:''}</span>`;
}
function progRowHtml(key, s) {
const lbl=paperLabel(key);
const isUp=key.startsWith('UPL_');
const pdfPath=s.pdfPath||'';
const txSt=txlStatus[key];
const txElapsed=txlStartTime[key]?Math.round((Date.now()-txlStartTime[key])/1000)+'s':'';
// Step pills: PDF → (이미지) → 텍스트 → 번역
const pdfPill = isUp ? pspHtml('업로드','done') : pspHtml('PDF', s.pdf||'waiting');
// 이미지 추출 pill — 자동 추출이 꺼져있거나 시도하지 않은 경우(skip) 숨김
const imgPill = (s.img && s.img!=='skip')
? pspHtml('이미지', s.img, s.img==='done'&&s.imgCount?String(s.imgCount):'')
: '';
// 텍스트 pill — 추출이 꺼져있으면(skip) 숨김
const txtPill = (s.txt && s.txt!=='skip') ? pspHtml('텍스트', s.txt) : '';
// 번역 pill — 번역을 시도하지 않았으면(자동 번역 OFF 등) 숨김
const txPill = txSt==='done' ? pspHtml('번역','done') :
txSt==='running' ? pspHtml('번역','running',txElapsed) :
txSt==='fail' ? pspHtml('번역','fail') : '';
const sep='<span style="color:var(--muted);font-size:10px;margin:0 3px">→</span>';
const pills=[pdfPill,imgPill,txtPill,txPill].filter(Boolean).join(sep);
const subMsg=s.sub?`<div style="font-size:10px;color:var(--brand2);margin-top:3px;opacity:.85">⟳ ${esc(s.sub)}</div>`:'';
const capBtn=pdfPath?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px" onclick="openPdfCapture('${esc(pdfPath)}','${esc(lbl)}')">📷 이미지 캡처</button>`:'';
const txtViewBtn=paperTexts[key]?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px;color:var(--brand2)" onclick="showPdfTranslation('${esc(key)}')">📄 전문 보기</button>`:'';
const txViewBtn=txSt==='done'?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px;color:var(--green)" onclick="switchTxlTab('ko');showPdfTranslation('${esc(key)}');switchTxlTab('ko')">📝 번역 보기</button>`:'';
const txStartBtn=(paperTexts[key]&&txSt!=='done'&&txSt!=='running')?`<button class="btn bg" style="font-size:10px;padding:2px 8px;margin-top:5px" onclick="translatePdfText('${esc(key)}',paperTexts['${esc(key)}'])">🌐 번역</button>`:'';
// 이미 추출된 번역/텍스트 미리보기 (첫 120자)
const previewText = paperTranslations[key]
? paperTranslations[key].slice(0, 120).replace(/\s+/g,' ').trim() + (paperTranslations[key].length > 120 ? '…' : '')
: (paperTexts[key]
? paperTexts[key].slice(0, 120).replace(/\s+/g,' ').trim() + (paperTexts[key].length > 120 ? '…' : '')
: '');
const previewTab = paperTranslations[key] ? 'ko' : 'en';
const previewHtml = previewText
? `<div style="font-size:10px;color:var(--muted);margin-top:4px;line-height:1.5;font-style:italic;cursor:pointer" onclick="switchTxlTab('${previewTab}');showPdfTranslation('${esc(key)}')" title="클릭하면 전문 보기">${esc(previewText)} <span style="color:var(--brand2);font-style:normal">전문 ↗</span></div>`
: '';
return `<div style="font-size:11px;font-weight:600;margin-bottom:5px;word-break:break-all">${esc(lbl)}</div>
<div style="display:flex;align-items:center;flex-wrap:wrap;gap:2px">${pills}</div>
${previewHtml}
${subMsg}
${s.error?(s.error.includes('초록으로 대체')
?`<div style="font-size:10px;color:#f59e0b;margin-top:4px">⚠ PDF 다운로드 불가 — 초록으로 대체됨</div>`
:(s.error.includes('전문 텍스트로 대체')
?`<div style="font-size:10px;color:#f59e0b;margin-top:4px">⚠ PDF 없음 — 전문 텍스트로 대체됨</div>`
:`<div style="font-size:10px;color:var(--red);margin-top:4px">${errHtml(s.error)}</div>`))
:''}
${(capBtn||txtViewBtn||txViewBtn||txStartBtn)?`<div style="display:flex;gap:6px;flex-wrap:wrap">${capBtn}${txtViewBtn}${txViewBtn}${txStartBtn}</div>`:''}`;
}
function renderProgList() {
const el=document.getElementById('proglist'); if(!el)return;
const ids=Array.from(selected);
if(!ids.length){el.innerHTML='<div style="color:var(--muted);font-size:12px">선택된 논문이 없습니다</div>';return;}
el.innerHTML=ids.map(key=>{
const s=prepLog[key]||{};
const act=key===currentProcessingKey?' active':'';
return `<div class="prog${act}" id="pr-${CSS.escape(key)}">${progRowHtml(key,s)}</div>`;
}).join('');
}
function updProg(key) {
const row=document.getElementById('pr-'+CSS.escape(key)); if(!row)return;
const s=prepLog[key]||{};
row.innerHTML=progRowHtml(key,s);
if(key===currentProcessingKey) row.classList.add('active'); else row.classList.remove('active');
}
function updOverall() {
const keys=Array.from(selected);
const done=keys.filter(k=>{
const s=prepLog[k]||{};
return (s.pdf==='done'||s.pdf==='fail'||k.startsWith('UPL_'))&&(s.txt==='done'||s.txt==='fail'||s.txt==='skip');
}).length;
const el=document.getElementById('s2-overall');
if(!el) return;
if(keys.length>0){ el.style.display=''; el.textContent=`${done} / ${keys.length} 완료`; }
else { el.style.display='none'; }
}
// project slug 결정: cfg-project → cfg-topic → 첫 논문 키 → 타임스탬프
function resolveProjectSlug(){
const inp=(document.getElementById('cfg-project')?.value||'').trim();
if(inp) return inp.replace(/[^a-zA-Z0-9가-힣_\-]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'').toLowerCase().slice(0,60);
// 검색 주제어로 추론
const topic=(document.getElementById('cfg-topic')?.value||'').trim();
if(topic) return topic.replace(/[^a-zA-Z0-9가-힣]/g,'_').replace(/_+/g,'_').replace(/^_+|_+$/g,'').toLowerCase().slice(0,40);
// 첫 번째 선택 항목으로 추론
const firstKey=Array.from(selected)[0]||'';
if(firstKey.startsWith('PMC')) return firstKey.toLowerCase();
const firstPaper=papers.find(p=>paperKey(p)===firstKey);
if(firstPaper?.title) return firstPaper.title.replace(/[^a-zA-Z0-9가-힣]/g,'_').replace(/_+/g,'_').toLowerCase().slice(0,40);
// 최후 폴백: 날짜 기반
return 'project_'+new Date().toISOString().slice(0,10).replace(/-/g,'');
}
async function startPrep() {
const btn=document.getElementById('s2start');
if(btn.disabled) return; // 이미 실행 중
btn.disabled=true; btn.textContent='진행 중...';
setMsg(2,'준비 중...','var(--brand2)');
const keys=Array.from(selected); prepLog={}; renderProgList(); extractedImages=[];
document.getElementById('imgrid').innerHTML='<div style="color:var(--muted);font-size:11px">📷 이미지 캡처 버튼으로 추가하세요</div>';
// 프로젝트 slug 결정 — PDF/이미지를 프로젝트 폴더에 직접 저장 (항상 결정)
const prepProject=resolveProjectSlug();
const inp=document.getElementById('cfg-project');
if(inp&&!inp.value) inp.value=prepProject;
for(const key of keys){
const isUpload=key.startsWith('UPL_');
const paper=papers.find(p=>paperKey(p)===key);
currentProcessingKey=key;
prepLog[key]={pdf:isUpload?'done':'running',img:autoExtractImages?'waiting':'skip',txt:extractText?'waiting':'skip'};
updProg(key); updOverall();
try{
let pdfPath=null;
if(isUpload){
// 직접 업로드 — 다운로드 스킵, 업로드 경로 바로 사용
pdfPath=paper?._localPdf||null;
prepLog[key].pdfPath=pdfPath;
if(!pdfPath) throw new Error('업로드 경로 없음');
setMsg(2,paperLabel(key)+' 이미지 추출...','var(--brand2)');
} else if(key.startsWith('DOI_')||key.startsWith('PMID')){
// OpenAlex/Semantic — OA URL로 직접 다운로드
const oaUrl=paper?.oa_url||paper?.oa_pdf||null;
if(!oaUrl) throw new Error('OA PDF URL 없음 — 이 논문은 오픈액세스 PDF가 없습니다');
setMsg(2,paperLabel(key)+' PDF 다운로드...','var(--brand2)');
const dlr=await fetch('/api/pptx/download-pdf',{...jh(),method:'POST',body:JSON.stringify({url:oaUrl,doi:paper?.doi,project:prepProject})});
const dld=await dlr.json();
if(!dld.success&&!dld.stdout) throw new Error(dld.error||'다운로드 실패');
const pm=(dld.stdout||dld.path||'').match(/((?:pubmed|uploads|[a-z0-9_-]+)\/[^\s'"]+\.pdf)/i);
pdfPath=pm?pm[1]:dld.path||null;
prepLog[key].pdf='done'; prepLog[key].pdfPath=pdfPath; updProg(key);
if(!pdfPath){ prepLog[key].img='fail'; prepLog[key].txt='fail'; prepLog[key].error='PDF 경로 확인 불가'; updProg(key); continue; }
setMsg(2,paperLabel(key)+' 이미지 추출...','var(--brand2)');
} else {
// PMC 다운로드 (SSE 스트리밍)
setMsg(2,key+' PDF 다운로드...','var(--brand2)');
const dld = await new Promise(async (resolve)=>{
const r=await fetch('/api/pptx/download-pdf-stream',{...jh(),method:'POST',body:JSON.stringify({pmcid:key,project:prepProject})});
const reader=r.body.getReader(); const dec=new TextDecoder();
let buf='', result=null;
while(true){
const {done,value}=await reader.read(); if(done) break;
buf+=dec.decode(value,{stream:true});
const lines=buf.split('\n'); buf=lines.pop();
for(const line of lines){
if(!line.startsWith('data: ')) continue;
try{
const ev=JSON.parse(line.slice(6));
if(ev.type==='progress'){
prepLog[key].sub=ev.msg; updProg(key);
} else if(ev.type==='done'){
result=ev;
}
}catch{}
}
}
prepLog[key].sub=''; updProg(key);
resolve(result||{success:false,error:'응답 없음'});
});
if(!dld.success&&!dld.stdout) throw new Error(dld.error||'다운로드 실패');
// 텍스트 폴백: PDF 대신 efetch 전문 텍스트를 받은 경우
if(dld._textFallback && dld._txtPath){
prepLog[key].pdf='fail'; prepLog[key].pdfPath=null;
prepLog[key].img='skip'; prepLog[key].txt='done';
prepLog[key].error='PDF 없음 → 전문 텍스트로 대체';
const txtContent=dld.stdout||'';
if(txtContent) paperTexts[key]=txtContent.slice(0,200000);
updProg(key); updOverall();
if(autoTranslate && paperTexts[key]) translatePdfText(key, paperTexts[key]);
continue;
}
const pm=(dld.stdout||dld.path||'').match(/((?:pubmed|[a-z0-9_-]+)\/[^\s'"]+\.pdf)/i);
pdfPath=pm?pm[1]:null;
prepLog[key].pdf='done'; prepLog[key].pdfPath=pdfPath; updProg(key);
if(!pdfPath){ prepLog[key].img='fail'; prepLog[key].txt='fail'; prepLog[key].error='PDF 경로 확인 불가'; updProg(key); continue; }
setMsg(2,key+' 이미지 추출...','var(--brand2)');
}
// 이미지 자동 추출 + mistral vision 분석 (토글 ON일 때만)
if(autoExtractImages && pdfPath){
prepLog[key].img='running'; updProg(key);
setMsg(2,paperLabel(key)+' 이미지 추출...','var(--brand2)');
try{
const exr=await fetch('/api/pptx/extract-images',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdfPath,project:prepProject})});
const exd=await exr.json();
let added=0;
const newImgs=[];
if(exd.success&&exd.data?.files){
const pname=isUpload?(pdfPath.split('/').pop()||'').replace(/\.pdf$/i,''):key;
for(const f of exd.data.files){
const url='/api/files/'+f;
if(!extractedImages.find(img=>img.url===url)){
const entry={url,label:pname+': '+f.split('/').pop(),pdf:pdfPath};
extractedImages.push(entry);
newImgs.push(entry);
added++;
}
}
}
renderImgGrid();
// mistral vision으로 새로 추출된 이미지 즉시 분석
for(let ni=0;ni<newImgs.length;ni++){
const nim=newImgs[ni];
if(imageCaptions[nim.url]) continue;
setMsg(2,`${paperLabel(key)} 이미지 분석 ${ni+1}/${newImgs.length}...`,'var(--brand2)');
try{
const ar=await fetch('/api/pptx/analyze-image',{...jh(),method:'POST',body:JSON.stringify({url:nim.url,label:nim.label,project:prepProject})});
const ad=await ar.json();
if(ad.caption) imageCaptions[nim.url]=ad.caption;
}catch{}
renderImgGrid();
}
prepLog[key].img='done'; prepLog[key].imgCount=added;
}catch(e2){ prepLog[key].img='fail'; }
updProg(key);
} else {
prepLog[key].img='skip'; updProg(key);
}
// Text extract (토글 ON일 때만)
if(extractText && pdfPath){
prepLog[key].txt='running'; updProg(key);
setMsg(2,paperLabel(key)+' 텍스트 추출...','var(--brand2)');
try{
const txtSavePath=pdfPath.replace(/\.pdf$/i,'.txt');
const txr=await fetch('/api/pptx/read-pdf',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdfPath,save_path:txtSavePath,max_chars:200000})});
const txd=await txr.json();
if(txd.success||txd.stdout){ paperTexts[key]=(txd.stdout||txd.data?.text||'').slice(0,200000); prepLog[key].txt='done'; }
else{ prepLog[key].txt='fail'; }
}catch(e2){ prepLog[key].txt='fail'; }
} else {
prepLog[key].txt='skip';
}
updProg(key); updOverall();
// 텍스트 추출 성공 + 자동 번역 ON이면 백그라운드 번역 시작
if(autoTranslate && prepLog[key].txt==='done' && paperTexts[key]) translatePdfText(key,paperTexts[key]);
}catch(e){
['pdf','img','txt'].forEach(k=>{ if(prepLog[key][k]==='running'||prepLog[key][k]==='waiting') prepLog[key][k]='fail'; });
// PDF 실패시 초록으로 대체
const ab=paper?.abstract||'';
if(ab&&!paperTexts[key]){
paperTexts[key]='[초록만 사용 — PDF 다운로드 불가]\n\n'+ab;
prepLog[key].txt='done';
prepLog[key].error=(e.message||'다운로드 실패')+' (초록으로 대체)';
// 초록 대체분도 (자동 번역 ON이면) 번역
if(autoTranslate) translatePdfText(key, paperTexts[key]);
} else {
prepLog[key].error=e.message;
// 본문도 초록도 없으면 번역 단계 건너뜀 — 게이트가 막히지 않도록 표시
txlStatus[key]='fail';
}
updProg(key); updOverall();
}
}
currentProcessingKey=null;
// remove active highlight from all rows
document.querySelectorAll('#proglist .prog.active').forEach(r=>r.classList.remove('active'));
updOverall();
const allDone=keys.every(k=>['done','fail','skip'].includes(prepLog[k]?.txt));
setMsg(2, allDone ? '✓ 준비 완료' : '⚠ 일부 실패', allDone?'var(--green)':'var(--red)');
btn.disabled=false; btn.textContent='▶ 다시 실행';
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
// 모든 논문이 PDF 번역 없이 끝났거나 일부가 실패한 경우에도 아웃라인 버튼을 노출
updOutlineBtn();
// Save paper metadata, texts, and translations to project folder
savePapersToProject();
}
function renderImgGrid(){
const g=document.getElementById('imgrid'); if(!g)return;
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
if(!extractedImages.length){g.innerHTML='<div style="color:var(--muted);font-size:11px">없음</div>';return;}
g.innerHTML=extractedImages.map((img,i)=>{
const cap=imageCaptions[img.url]||'';
const excluded=cap==='제외';
const capHtml=cap?`<div style="font-size:8px;padding:1px 4px;line-height:1.3;color:${excluded?'#f87171':'#34d399'};overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(cap)}">${excluded?'⛔ 제외':cap}</div>`:'';
return `<div class="icard" title="${esc(img.label)}" style="position:relative;opacity:${excluded?.5:1}" onmouseenter="this.querySelector('.icard-del').style.opacity=1" onmouseleave="this.querySelector('.icard-del').style.opacity=0">
<img src="${esc(img.url)}" loading="lazy" onerror="this.style.display='none'" onclick="showImgPreview('${esc(img.url)}','${esc(img.label)}')" style="cursor:zoom-in;width:100%;height:100%;object-fit:cover">
<button class="icard-del" onclick="event.stopPropagation();deleteImg(${i})" style="position:absolute;top:4px;right:4px;opacity:0;transition:.15s;background:rgba(220,38,38,.85);border:none;color:#fff;border-radius:4px;width:22px;height:22px;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;line-height:1" title="삭제">✕</button>
<div class="ilabel" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px;padding:2px 5px">${esc(img.label)}</div>
${capHtml}
</div>`;
}).join('');
}
async function analyzeImages(){
if(!extractedImages.length){setMsg(2,'분석할 이미지가 없습니다.','var(--yellow)');return;}
const btn=document.getElementById('btn-analyze-imgs');
btn.disabled=true;
const project=resolveProjectSlug();
const total=extractedImages.length;
let done=0;
for(const img of extractedImages){
btn.textContent=`🔍 ${done}/${total}`;
if(!imageCaptions[img.url]){
try{
const r=await fetch('/api/pptx/analyze-image',{...jh(),method:'POST',body:JSON.stringify({url:img.url,label:img.label,project})});
const d=await r.json();
if(d.caption) imageCaptions[img.url]=d.caption;
}catch(e){console.warn('[analyzeImages]',e);}
}
done++;
renderImgGrid();
}
btn.disabled=false;
btn.textContent='🔍 이미지 분석';
const excluded=Object.values(imageCaptions).filter(c=>c==='제외').length;
setMsg(2,`이미지 분석 완료 (${total}장${excluded?', 제외 '+excluded+'장':''})`, 'var(--green)');
}
async function deleteImg(i){
const img = extractedImages[i];
if (img) {
// /api/files/<relpath> 형태에서 relpath 추출
const url = img.url || '';
const prefix = '/api/files/';
if (url.startsWith(prefix)) {
const relPath = url.slice(prefix.length).split('/').map(decodeURIComponent).join('/');
try {
await fetch('/api/pptx/delete-image', { ...jh(), method: 'DELETE', body: JSON.stringify({ path: relPath }) });
} catch (e) { console.warn('[deleteImg]', e); }
}
}
extractedImages.splice(i, 1);
renderImgGrid();
}
async function doImgSearch() {
const q = document.getElementById('img-search-input').value.trim();
const res = document.getElementById('img-search-results');
if (!q) return;
res.innerHTML = '<div style="font-size:11px;color:var(--muted)">검색 중…</div>';
try {
const r = await fetch('/api/pptx/search-images', { ...jh(), method:'POST', body:JSON.stringify({query:q,count:6}) });
const d = await r.json();
if (!d.images?.length) { res.innerHTML = '<div style="font-size:11px;color:var(--muted)">결과 없음</div>'; return; }
res.innerHTML = `<div class="igrid" style="grid-template-columns:repeat(auto-fill,minmax(85px,1fr))">${d.images.map(img=>`<div class="icard" style="aspect-ratio:4/3"><img src="${esc(img.url)}" loading="lazy" onerror="this.parentNode.style.display='none'" onclick="showImgPreview('${esc(img.url)}','${esc(img.label)}')" style="width:100%;height:100%;object-fit:cover;cursor:zoom-in"><div class="ilabel">${esc(img.label)}</div><button style="position:absolute;top:2px;right:2px;background:rgba(99,102,241,.85);border:none;color:#fff;border-radius:4px;width:20px;height:20px;font-size:13px;cursor:pointer;line-height:1" onclick="addSearchImg('${esc(img.url)}','${esc(img.label)}',this)" title="갤러리에 추가">+</button></div>`).join('')}</div>`;
} catch(e) { res.innerHTML = `<div style="font-size:11px;color:var(--danger)">${esc(e.message)}</div>`; }
}
async function addSearchImg(url, label, btn) {
const project = resolveProjectSlug();
if (!project) { alert('프로젝트를 먼저 선택하세요'); return; }
btn.textContent = '…'; btn.disabled = true;
try {
const r = await fetch('/api/pptx/save-image-url', { ...jh(), method:'POST', body:JSON.stringify({url,project}) });
const d = await r.json();
if (d.success) {
if (!extractedImages.find(img => img.url === d.url)) extractedImages.push({ url: d.url, label, pdf: null });
renderImgGrid();
btn.textContent = '✓'; btn.style.background = 'rgba(34,197,94,.85)';
} else { btn.textContent = '✗'; btn.disabled = false; }
} catch { btn.textContent = '✗'; btn.disabled = false; }
}
async function handleImgUpload(files) {
if (!files || !files.length) return;
const project = resolveProjectSlug();
if (!project) { alert('먼저 프로젝트 폴더명을 입력하거나 PDF 준비를 시작해주세요.'); return; }
const dz = document.getElementById('img-dropzone');
const origTxt = dz ? dz.textContent : '';
const total = files.length;
let done = 0;
for (const file of files) {
if (dz) dz.textContent = `업로드 중... ${done+1}/${total}`;
const fd = new FormData();
fd.append('image', file, file.name);
fd.append('project', project);
try {
const r = await fetch('/api/pptx/upload-image', { method: 'POST', credentials: 'include', body: fd });
const d = await r.json();
if (d.success) {
const label = file.name.replace(/\.[^.]+$/, '');
if (!extractedImages.find(img => img.url === d.url)) {
extractedImages.push({ url: d.url, label, pdf: null });
}
} else {
console.warn('[img upload]', d.error);
}
} catch (e) { console.warn('[img upload]', e); }
done++;
}
if (dz) dz.textContent = origTxt;
document.getElementById('img-upload-input').value = '';
renderImgGrid();
}
async function deleteImgRefreshPicker(i){
extractedImages.splice(i,1);
renderImgGrid();
const g=document.getElementById('imodal-grid');
if(!g)return;
await _loadPickerImages(g);
}
// ── STEP 3: Outline ──────────────────────────────────────────────────────────
async function generateOutline() {
const stopTimer=startTimer('s3msg','✨ 아웃라인 생성 중');
const ph=document.querySelector('#outline-cards div');
const genStart=Date.now();
const phTimer=setInterval(()=>{
const el=document.querySelector('#outline-cards > div');
if(el) el.textContent='아웃라인 생성 중 — '+Math.round((Date.now()-genStart)/1000)+'s';
},1000);
if(ph) ph.textContent='아웃라인 생성 중 — 0s';
const topic=(document.getElementById('cfg-topic').value||'').trim()||'발표 자료를 만들어주세요';
const tmpl=document.getElementById('cfg-tmpl').value;
const slides=parseInt(document.getElementById('cfg-slides').value)||12;
const skin=selectedSkin;
const pmcids=Array.from(selected).join(', ');
// Collect abstracts and PDF text for each paper
const contentBlocks=[];
for(const pmcid of selected){
const meta=paperMeta[pmcid]||{};
const txt=paperTexts[pmcid]||'';
const p=papers.find(x=>'PMC'+String(x.pmcid||'').replace(/^PMC/i,'')===pmcid);
const title=p?.title||pmcid;
let block=`[${pmcid}] ${title}`;
if(meta.abstract) block+=`\n초록: ${meta.abstract.slice(0,800)}`;
if(txt) block+=`\n본문 발췌: ${txt.slice(0,2000)}`;
contentBlocks.push(block);
}
const imgList=extractedImages
.filter(img=>(imageCaptions[img.url]||'')!=='제외')
.slice(0,30)
.map((img,i)=>{const cap=imageCaptions[img.url]?` — ${imageCaptions[img.url]}`:'';return `${i+1}. ${img.url} (${img.label})${cap}`;})
.join('\n');
const prompt=`[PPTX 워크플로 시작]\n요청: ${topic}\nPMCIDs: ${pmcids}\n템플릿: ${tmpl}${skin?'\n배경 스킨: '+skin:''}\n슬라이드 수: ${slides}장\n\n=== 논문 내용 ===\n${contentBlocks.join('\n\n---\n')}\n\n=== 추출된 이미지 ===\n${imgList||'없음'}\n\n위 논문 내용과 이미지를 활용해 슬라이드 아웃라인을 \`\`\`pptx-outline 블록으로 출력해주세요. create_presentation은 아직 호출하지 마세요.`;
try{
const r=await fetch('/api/chat',{method:'POST',credentials:'include',headers:{'Content-Type':'application/json','Accept':'text/event-stream'},body:JSON.stringify({message:prompt,history:[],useTools:true,model:'mistral-large-3:675b-cloud'})});
if(!r.ok) throw new Error('API '+r.status);
const reader=r.body.getReader(),dec=new TextDecoder();
let buf='',reply='';
while(true){
const {done,value}=await reader.read(); if(done)break;
buf+=dec.decode(value,{stream:true});
const lines=buf.split('\n'); buf=lines.pop();
for(const line of lines){
if(!line.startsWith('data: '))continue;
try{ const ev=JSON.parse(line.slice(6));
if(ev.type==='reply'||ev.type==='final_reply') reply=ev.reply||ev.content||reply;
if((ev.type==='token'||ev.type==='text_delta')&&ev.text) reply+=ev.text;
}catch{}
}
}
const m=reply.match(/```pptx-outline\s*\n([\s\S]*?)```/);
stopTimer();
if(m){ outline=JSON.parse(m[1]); if(outline.slides) outline.slides=cleanSlideTexts(outline.slides); renderOutline(); document.getElementById('s3create').disabled=false; setMsg(3,'아웃라인 준비 ('+(outline.slides||[]).length+'장)','var(--green)');
const projInp=document.getElementById('cfg-project'); if(projInp&&!projInp.value&&outline.project) projInp.value=outline.project;
saveOutlineToProject(); }
else setMsg(3,'pptx-outline 블록 없음. 다시 시도하세요.','var(--yellow)');
}catch(e){ stopTimer(); setMsg(3,'오류: '+e.message,'var(--red)'); }
finally{ clearInterval(phTimer); }
}
const TICON={title:'🏷',section:'📂',content:'📝',image:'🖼',table:'📊',chart:'📈',timeline:'⏳',compare:'⚖'};
// dark skin names (navy/charcoal계열 — pptx_gen.py DARK_SKIN_NAMES 와 동기화)
const DARK_SKIN_NAMES=new Set(['navy','charcoal','midnight','ocean','sunset','forest_green',
'mint','slate','burgundy','moss','plum','deep_red','teal','emerald',
'Skin_film','Skin_theater',
'Skin_slate','Skin_navy','Skin_burgundy','Skin_moss','Skin_plum','Skin_deep_red']);
function skinColors(v){
const sk=SKINS.find(s=>s.v===v);
const bg=sk?sk.c:'#334155';
const hex=bg.replace('#','');
let dark=DARK_SKIN_NAMES.has(v||'');
if(!dark && /^[0-9a-f]{6}$/i.test(hex)){
const r=parseInt(hex.slice(0,2),16),g=parseInt(hex.slice(2,4),16),b=parseInt(hex.slice(4,6),16);
dark=(0.299*r+0.587*g+0.114*b)<140;
}
// 스킨 이미지 URL — /api/ppt/skins/<name> 엔드포인트
const bgImg=v?`/api/ppt/skins/${encodeURIComponent(v)}`:'';
return {bg, bgImg, txt:dark?'#ffffff':'#1a1a2e', muted:dark?'rgba(255,255,255,.6)':'rgba(0,0,0,.45)', dark};
}
function renderOutline(){
const c=document.getElementById('outline-cards'); if(!c||!outline)return;
const slides=outline.slides||[];
c.innerHTML='<div class="slide-eg">'+slides.map((s,i)=>slideCardHtml(s,i)).join('')
+`<div style="padding:8px 0;text-align:center"><button class="btn bg" style="font-size:12px;padding:5px 18px" onclick="slideInsert(${slides.length})">+ 슬라이드 추가</button></div>`
+'</div>';
}
function slideCardHtml(s,i){
const ed=outlineEdits[i]||{};
const title=ed.title!==undefined?ed.title:(s.title||'');
const bullets=ed.bullets!==undefined?ed.bullets:(s.bullets||[]);
const imgUrl=ed.image_url!==undefined?ed.image_url:(s.image_url||'');
const bg=ed.background!==undefined?ed.background:(s.background||selectedSkin||'');
const fs=ed.font_size||s.font_size||18;
const icon=TICON[s.type]||'📄';
const open=selectedSlide===i;
const {bg:bgc,bgImg:bgImg2,txt,muted}=skinColors(bg);
const miniStyle=bgImg2?`background:${bgc};background-image:url(${bgImg2});background-size:cover;background-position:center`:`background:${bgc}`;
const sk=SKINS.find(x=>x.v===bg);
const skLabel=sk?sk.label:'기본';
const skinOpts=SKINS.map(sk=>`<option value="${esc(sk.v)}" ${bg===sk.v?'selected':''}>${sk.label}</option>`).join('');
const curType=ed.type||s.type||'content';
const curLayout=ed.layout||s.layout||'split-right';
const SLIDE_TYPES=[
{v:'title', l:'🏷 표지'},
{v:'content', l:'📝 내용'},
{v:'section', l:'📂 섹션'},
{v:'image', l:'🖼 이미지'},
{v:'table', l:'📊 표'},
{v:'chart', l:'📈 차트'},
{v:'timeline', l:'⏳ 타임라인'},
{v:'compare', l:'⚖ 비교'},
{v:'blank', l:'⬜ 빈 슬라이드'},
];
const LAYOUTS=[
{v:'split-right', l:'이미지 오른쪽'},
{v:'split-left', l:'이미지 왼쪽'},
{v:'text', l:'텍스트만'},
{v:'fullscreen', l:'이미지 전체'},
];
const typeOpts=SLIDE_TYPES.map(t=>`<option value="${t.v}" ${curType===t.v?'selected':''}>${t.l}</option>`).join('');
const layoutOpts=LAYOUTS.map(t=>`<option value="${t.v}" ${curLayout===t.v?'selected':''}>${t.l}</option>`).join('');
const hasBul=curType==='content'||curType==='compare'||curType==='image';
const bulPreview=bullets.slice(0,4).map(b=>{
const subM=String(b||'').match(/^[-–—]\s+(.*)/); const isSub=!!subM; const txt=isSub?esc(subM[1]):esc(String(b||''));
const dot=isSub?'–':'•'; const pad=isSub?'padding-left:8px;':'';
return `<div style="font-size:${Math.min(fs*.52,10)}px;padding:1px 0;${pad}">${dot} ${txt}</div>`;
}).join('');
const imgPreview=imgUrl?`<img class="slide-mini-img" src="${esc(imgUrl)}" onerror="this.style.display='none'">`:'';
return `<div class="scard${open?' open':''}" id="sc${i}">
<div class="scard-hd" onclick="selectSlide(${i})">
<div class="snum">${i+1}</div>
<span style="font-size:12px">${icon}</span>
<span class="stype-tag">${s.type}</span>
<span style="flex:1"></span>
<span style="font-size:10px;color:var(--muted)">${esc(skLabel)}</span>
<span style="font-size:10px;color:var(--muted);margin-left:4px">${fs}pt</span>
<button class="btn bg" style="font-size:10px;padding:2px 7px;margin-left:6px" title="위로" onclick="event.stopPropagation();slideMove(${i},-1)">↑</button>
<button class="btn bg" style="font-size:10px;padding:2px 7px" title="아래로" onclick="event.stopPropagation();slideMove(${i},1)">↓</button>
<button class="btn bg" style="font-size:10px;padding:2px 7px" title="앞에 삽입" onclick="event.stopPropagation();slideInsert(${i})">+</button>
<button class="btn bg" style="font-size:10px;padding:2px 7px;color:var(--red)" title="삭제" onclick="event.stopPropagation();slideDelete(${i})">✕</button>
<button class="btn bp" style="font-size:10px;padding:2px 9px;margin-left:4px" onclick="event.stopPropagation();openSed(${i})">✏ 편집</button>
</div>
<div class="slide-mini" id="sm${i}" style="${miniStyle}" onclick="selectSlide(${i})">
${imgPreview}
<div style="position:relative;z-index:1;max-width:${imgUrl?'58%':'100%'}">
<div class="slide-mini-title" id="spt${i}" style="font-size:${Math.min(fs*.72,15)}px;color:${txt}">${esc(title)||`<span style="opacity:.3">제목 없음</span>`}</div>
${hasBul&&bulPreview?`<div class="slide-mini-bullets" id="spb${i}" style="color:${muted}">${bulPreview}</div>`:''}
</div>
</div>
${open?`<div class="slide-edit-area" onclick="event.stopPropagation()">
<div class="slide-edit-row" style="margin-bottom:8px">
<div style="flex:1;min-width:100px">
<label class="elabel">슬라이드 유형</label>
<select class="sin" style="padding:4px 6px;font-size:12px" onchange="eType(${i},this.value)">${typeOpts}</select>
</div>
<div style="flex:1;min-width:100px">
<label class="elabel">레이아웃</label>
<select class="sin" style="padding:4px 6px;font-size:12px" onchange="eLayout(${i},this.value)">${layoutOpts}</select>
</div>
</div>
<div>
<label class="elabel">제목</label>
<input class="sin" id="sei${i}" value="${esc(title)}" oninput="eTitle(${i},this.value)" placeholder="슬라이드 제목">
</div>
${hasBul?`<div style="margin-top:8px">
<label class="elabel">내용</label>
<textarea class="sbul-ta" oninput="eBul(${i},this.value)">${esc(bullets.join('\n\n'))}</textarea>
<div style="font-size:10px;color:var(--muted);margin-top:2px;line-height:1.5">Enter=같은 항목 · 빈 줄=새 항목 ● · -시작=하위 –</div>
</div>`:''}
<div class="slide-edit-row">
<div style="flex:1;min-width:100px">
<label class="elabel">배경 스킨</label>
<select class="sin" style="padding:4px 6px;font-size:12px" onchange="eBg(${i},this.value)">${skinOpts}</select>
</div>
<div style="width:80px">
<label class="elabel">폰트 크기</label>
<div style="display:flex;align-items:center;gap:3px">
<input type="number" min="10" max="40" class="sin" style="padding:4px 6px;font-size:12px;text-align:center" value="${fs}" oninput="eFontSize(${i},this.value)">
<span style="font-size:10px;color:var(--muted)">pt</span>
</div>
</div>
</div>
<div style="display:flex;gap:6px;align-items:center;margin-top:10px">
${imgUrl?`<img src="${esc(imgUrl)}" style="width:52px;height:38px;object-fit:cover;border-radius:4px;border:1px solid var(--line);cursor:pointer" onclick="showImgPreview('${esc(imgUrl)}','')" onerror="this.style.display='none'">`:`<div style="width:52px;height:38px;border:1px dashed var(--line);border-radius:4px;display:flex;align-items:center;justify-content:center;color:var(--muted)">🖼</div>`}
<button class="pickbtn" onclick="openIPicker(${i})">이미지 선택</button>
${imgUrl?`<button class="pickbtn" style="color:var(--red)" onclick="clearImg(${i})">✕ 제거</button>`:''}
</div>
</div>`:''}
</div>`;
}
function slideApplyEdits() {
// outlineEdits를 outline.slides에 병합한 뒤 edits 초기화
outline.slides = outline.slides.map((s,i) => {
const ed = outlineEdits[i]||{};
return Object.assign({}, s,
ed.title!==undefined?{title:ed.title}:{},
ed.subtitle!==undefined?{subtitle:ed.subtitle}:{},
ed.bullets!==undefined?{bullets:ed.bullets}:{},
ed.image_url!==undefined?{image_url:ed.image_url}:{},
ed.image_search!==undefined?{image_search:ed.image_search}:{},
ed.background!==undefined&&ed.background?{background:ed.background}:{},
ed.bg_image!==undefined?{bg_image:ed.bg_image}:{},
ed.bg_img_url!==undefined?{bg_img_url:ed.bg_img_url}:{},
ed.font_family!==undefined?{font_family:ed.font_family}:{},
ed.title_color!==undefined?{title_color:ed.title_color}:{},
ed.body_color!==undefined?{body_color:ed.body_color}:{},
ed.img_filter!==undefined?{img_filter:ed.img_filter}:{},
ed.img_blur_radius!==undefined?{img_blur_radius:ed.img_blur_radius}:{},
ed.img_corner_radius!==undefined?{img_corner_radius:ed.img_corner_radius}:{},
ed.font_size!==undefined?{font_size:ed.font_size}:{},
ed.title_size!==undefined?{title_size:ed.title_size}:{},
ed.img_scale!==undefined?{img_scale:ed.img_scale}:{},
ed.img_x!=null?{img_x:ed.img_x}:{},
ed.img_y!=null?{img_y:ed.img_y}:{},
ed.img_w!=null?{img_w:ed.img_w}:{},
ed.img_h!=null?{img_h:ed.img_h}:{},
ed.type!==undefined?{type:ed.type}:{},
ed.layout!==undefined?{layout:ed.layout}:{},
ed.text_y!=null?{text_y:ed.text_y}:{}
);
});
outlineEdits = {};
}
function slideMove(i, dir) {
const slides = outline.slides||[]; const j = i+dir;
if(j<0||j>=slides.length) return;
slideApplyEdits();
[slides[i],slides[j]] = [slides[j],slides[i]];
selectedSlide = j;
renderOutline();
setTimeout(()=>{ const el=document.getElementById('sc'+j); if(el) el.scrollIntoView({behavior:'smooth',block:'nearest'}); },50);
}
function slideDelete(i) {
const slides = outline.slides||[]; if(slides.length<=1) return;
slideApplyEdits();
slides.splice(i,1);
selectedSlide = -1;
renderOutline();
setMsg(3, '슬라이드 '+slides.length+'장', 'var(--muted)');
}
function slideInsert(i) {
slideApplyEdits();
const slides = outline.slides||[];
const newSlide = {type:'content', title:'새 슬라이드', bullets:[], background: slides[i]?.background||''};
slides.splice(i, 0, newSlide);
selectedSlide = i;
renderOutline();
setTimeout(()=>{ const el=document.getElementById('sc'+i); if(el) el.scrollIntoView({behavior:'smooth',block:'nearest'}); },50);
}
function selectSlide(i){
selectedSlide=selectedSlide===i?-1:i;
renderOutline();
if(selectedSlide>=0){ const el=document.getElementById('sc'+i); if(el)setTimeout(()=>el.scrollIntoView({behavior:'smooth',block:'nearest'}),50); }
}
function eType(i,v){
if(!outlineEdits[i])outlineEdits[i]={};
outlineEdits[i].type=v;
if(outline.slides[i]) outline.slides[i].type=v;
renderOutline(); selectSlide(i);
}
function eLayout(i,v){
if(!outlineEdits[i])outlineEdits[i]={};
outlineEdits[i].layout=v;
if(outline.slides[i]) outline.slides[i].layout=v;
}
function eTitle(i,v){
if(!outlineEdits[i])outlineEdits[i]={}; outlineEdits[i].title=v;
const el=document.getElementById('spt'+i); if(el) el.textContent=v||'';
}
function eBul(i,v){
if(!outlineEdits[i])outlineEdits[i]={};
const _groups=v.split(/\n\n+/);
const arr=_groups.map(g=>g.replace(/^\n+|\n+$/g,'')).filter(g=>g.trim()!=='');
outlineEdits[i].bullets=arr;
const el=document.getElementById('spb'+i);
if(el){ const fs=outlineEdits[i]?.font_size||(outline?.slides?.[i]?.font_size)||18; el.innerHTML=arr.slice(0,4).map(b=>{
const subM=String(b).match(/^[-–—]\s+(.*)/); const isSub=!!subM; const txt=isSub?esc(subM[1]):esc(String(b));
const dot=isSub?'–':'•'; const pad=isSub?'padding-left:8px;':'';
return `<div style="font-size:${Math.min(fs*.52,10)}px;padding:1px 0;${pad}">${dot} ${txt}</div>`;
}).join(''); }
}
function eBg(i,v){
if(!outlineEdits[i])outlineEdits[i]={}; outlineEdits[i].background=v;
const sk=SKINS.find(s=>s.v===v)||SKINS[0];
const mini=document.getElementById('sm'+i); if(mini) mini.style.background=sk.c;
const {txt,muted}=skinColors(v);
const pt=document.getElementById('spt'+i); if(pt) pt.style.color=txt;
const pb=document.getElementById('spb'+i); if(pb) pb.style.color=muted;
}
function eFontSize(i,v){
const n=parseInt(v); if(!outlineEdits[i])outlineEdits[i]={};
outlineEdits[i].font_size=isNaN(n)?18:Math.min(40,Math.max(10,n));
const el=document.getElementById('spt'+i); if(el) el.style.fontSize=Math.min(n*.72,15)+'px';
}
function clearImg(i){ if(!outlineEdits[i])outlineEdits[i]={}; outlineEdits[i].image_url=''; outlineEdits[i].image_search=''; renderOutline(); }
// ── Image picker ─────────────────────────────────────────────────────────────
function _pickerGridHtml(imgs, allowDelete){
if(!imgs.length) return '<div style="color:var(--muted);font-size:12px;padding:12px;text-align:center">이미지가 없습니다</div>';
return imgs.map((img,i)=>`<div class="icard" title="${esc(img.label)}" style="cursor:pointer">
<img src="${esc(img.url)}" loading="lazy" onerror="this.style.display='none'" onclick="pickImg('${esc(img.url)}')" style="cursor:pointer">
<div class="ilabel" style="display:flex;gap:4px;align-items:center;padding:3px 5px">
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:9px">${esc(img.label)}</span>
<button style="font-size:9px;padding:1px 4px;background:rgba(255,255,255,.15);border:none;color:#fff;border-radius:3px;cursor:pointer;flex-shrink:0" onclick="event.stopPropagation();showImgPreview('${esc(img.url)}','${esc(img.label)}')">🔍</button>
${allowDelete?`<button style="font-size:9px;padding:1px 4px;background:rgba(239,68,68,.75);border:none;color:#fff;border-radius:3px;cursor:pointer;flex-shrink:0" onclick="event.stopPropagation();deleteImgRefreshPicker(${i})" title="삭제">✕</button>`:''}
</div>
</div>`).join('');
}
async function _loadPickerImages(g){
if(extractedImages.length){
g.innerHTML=_pickerGridHtml(extractedImages, true);
return;
}
// 추출 이미지 없으면 프로젝트 폴더 이미지 자동 로드
const proj=(document.getElementById('cfg-project')?.value||'').trim()||outline?.project||existingProject||'';
if(!proj){ g.innerHTML='<div style="color:var(--muted);font-size:12px;padding:12px;text-align:center">추출된 이미지가 없습니다.<br>📷 자동추출을 먼저 실행하세요.</div>'; return; }
g.innerHTML='<div style="color:var(--muted);font-size:12px;padding:12px;text-align:center">⏳ 폴더 이미지 로딩...</div>';
try{
const r=await fetch(`/api/pptx/project-images?project=${encodeURIComponent(proj)}`,{...jh()});
const d=await r.json();
const imgs=(d.images||[]).map(f=>({url:'/api/files/'+f.path,label:f.name}));
if(imgs.length){
g.innerHTML=_pickerGridHtml(imgs, false);
} else {
g.innerHTML='<div style="color:var(--muted);font-size:12px;padding:12px;text-align:center">이미지 파일 없음<br>📷 자동추출을 먼저 실행하세요.</div>';
}
}catch{
g.innerHTML='<div style="color:var(--muted);font-size:12px;padding:12px;text-align:center">이미지 로드 실패</div>';
}
}
async function openIPicker(idx){
modalSlide=idx;
pickerMode='image';
const g=document.getElementById('imodal-grid');
document.getElementById('ibg').classList.add('open');
await _loadPickerImages(g);
}
let pickerMode='image'; // 'image' | 'background'
function pickImg(url){
if(modalSlide>=0){
if(pickerMode==='background'){
sedDirty=true;
if(!outlineEdits[modalSlide])outlineEdits[modalSlide]={};
outlineEdits[modalSlide].bg_img_url=url;
// workspace-relative path for Python: strip /api/files/ prefix
const bgPath=url.startsWith('/api/files/')?url.slice('/api/files/'.length):url;
outlineEdits[modalSlide].bg_image=bgPath;
if(editorIdx===modalSlide) _sedUpdateBgImgThumb(url);
sedRenderPreview();
} else {
if(editorIdx===modalSlide) sedDirty=true;
if(!outlineEdits[modalSlide])outlineEdits[modalSlide]={};
outlineEdits[modalSlide].image_url=url;
renderOutline();
if(editorIdx===modalSlide){
const thumb=document.getElementById('sed-img-thumb');
if(thumb){thumb.style.backgroundImage=`url(${url})`;thumb.textContent='';}
const urlInp=document.getElementById('sed-img-url-inp');
if(urlInp) urlInp.value=url;
sedRenderPreview();
}
}
}
closeIModal();
}
function _sedUpdateBgImgThumb(url){
const th=document.getElementById('sed-bg-img-thumb');
const cl=document.getElementById('sed-bg-img-clear');
if(!th) return;
if(url){ th.style.backgroundImage=`url(${url})`; th.textContent=''; if(cl)cl.style.display=''; }
else { th.style.backgroundImage=''; th.textContent='🖼'; if(cl)cl.style.display='none'; }
}
async function sedPickBgImg(){
const i=editorIdx; if(i<0)return;
modalSlide=i;
pickerMode='background';
const g=document.getElementById('imodal-grid');
document.getElementById('ibg').classList.add('open');
await _loadPickerImages(g);
}
function sedClearBgImg(){
const i=editorIdx; if(i<0)return;
sedDirty=true;
if(!outlineEdits[i])outlineEdits[i]={};
outlineEdits[i].bg_img_url='';
outlineEdits[i].bg_image='';
_sedUpdateBgImgThumb('');
sedRenderPreview();
}
function closeIModal(){ document.getElementById('ibg').classList.remove('open'); }
function showImgPreview(url, label) {
document.getElementById('lb-img').src = url;
document.getElementById('lb-label').textContent = label || '';
document.getElementById('lbg').classList.add('open');
}
function closeImgPreview() { document.getElementById('lbg').classList.remove('open'); }
// ── PDF Capture viewer ───────────────────────────────────────────────────────
let pdfCapState = { path:'', name:'', page:0, total:1, sel:null, dragging:false, startX:0, startY:0, zoom:1.0, baseScale:1.0 };
function openPdfCapture(pdfPath, name) {
pdfCapState = { path:pdfPath, name:name||pdfPath, page:0, total:1, sel:null, dragging:false, startX:0, startY:0, zoom:1.0, baseScale:1.0 };
document.getElementById('pdf-modal-name').textContent = pdfCapState.name;
document.getElementById('pdf-zoom-lbl').textContent = '100%';
document.getElementById('pdfbg').classList.add('open');
pdfLoadPage(0);
const cv = document.getElementById('pdf-main-canvas');
cv.onmousedown = pdfSelStart;
cv.onmousemove = pdfSelMove;
cv.onmouseup = pdfSelEnd;
cv.onmouseleave= e => { if(pdfCapState.dragging) pdfSelEnd(e); };
cv.ontouchstart= e => { e.preventDefault(); const t=e.touches[0],r=cv.getBoundingClientRect(); pdfSelStart({clientX:t.clientX,clientY:t.clientY,_rect:r}); };
cv.ontouchmove = e => { e.preventDefault(); const t=e.touches[0],r=cv.getBoundingClientRect(); pdfSelMove({clientX:t.clientX,clientY:t.clientY,_rect:r}); };
cv.ontouchend = e => { e.preventDefault(); pdfSelEnd(null); };
const wrap = document.getElementById('pdf-canvas-wrap');
wrap.onwheel = e => {
e.preventDefault();
const delta = e.deltaY < 0 ? 0.12 : -0.12;
pdfCapState.zoom = Math.min(5, Math.max(0.2, pdfCapState.zoom + delta));
pdfApplyZoom();
};
}
// convert mouse event to canvas pixel coords
function pdfEvCoords(e) {
const cv=document.getElementById('pdf-main-canvas');
const rect=e._rect||cv.getBoundingClientRect();
const scaleX=cv.width/rect.width, scaleY=cv.height/rect.height;
return { x:(e.clientX-rect.left)*scaleX, y:(e.clientY-rect.top)*scaleY };
}
function pdfRedraw() {
const cv=document.getElementById('pdf-main-canvas');
const ctx=cv.getContext('2d');
ctx.clearRect(0,0,cv.width,cv.height);
if(pdfCapState.bmp) ctx.drawImage(pdfCapState.bmp,0,0);
const s=pdfCapState.sel;
if(s){
ctx.strokeStyle='#6366f1'; ctx.lineWidth=Math.max(2,cv.width/400); ctx.setLineDash([6,3]);
ctx.strokeRect(s.x,s.y,s.w,s.h);
ctx.fillStyle='rgba(99,102,241,0.14)'; ctx.fillRect(s.x,s.y,s.w,s.h);
}
}
async function pdfLoadPage(page) {
const msg=document.getElementById('pdf-load-msg');
const cv=document.getElementById('pdf-main-canvas');
msg.style.display=''; cv.style.display='none';
pdfClearSel(true);
const url=`/api/pptx/pdf-page?path=${encodeURIComponent(pdfCapState.path)}&page=${page}&dpi=150`;
try{
const r=await fetch(url,{credentials:'include'});
if(!r.ok) throw new Error('HTTP '+r.status);
const total=parseInt(r.headers.get('X-Page-Count')||'1');
pdfCapState.total=total; pdfCapState.page=page;
document.getElementById('pdf-page-lbl').textContent=`${page+1} / ${total}`;
const blob=await r.blob();
const bmp=await createImageBitmap(blob);
pdfCapState.bmp=bmp;
cv.width=bmp.width; cv.height=bmp.height;
// 캔버스를 창 크기에 맞게 CSS 스케일 (원본 해상도 유지, 표시만 축소)
const wrap=document.getElementById('pdf-canvas-wrap');
const maxW=wrap.clientWidth-4, maxH=wrap.clientHeight-4;
const baseScale=Math.min(1, maxW/bmp.width, maxH/bmp.height);
pdfCapState.baseScale=baseScale;
cv.style.width=Math.round(bmp.width*baseScale*pdfCapState.zoom)+'px';
cv.style.height=Math.round(bmp.height*baseScale*pdfCapState.zoom)+'px';
pdfRedraw();
msg.style.display='none'; cv.style.display='block';
}catch(e){ msg.textContent='로드 실패: '+e.message; }
}
function pdfApplyZoom() {
const cv=document.getElementById('pdf-main-canvas');
const bmp=pdfCapState.bmp;
if(!bmp) return;
const z=pdfCapState.zoom;
cv.style.width=Math.round(bmp.width*pdfCapState.baseScale*z)+'px';
cv.style.height=Math.round(bmp.height*pdfCapState.baseScale*z)+'px';
document.getElementById('pdf-zoom-lbl').textContent=Math.round(z*100)+'%';
}
function pdfZoomStep(dir) {
pdfCapState.zoom=Math.min(5,Math.max(0.2,pdfCapState.zoom+dir*0.2));
pdfApplyZoom();
}
function pdfZoomReset() { pdfCapState.zoom=1.0; pdfApplyZoom(); }
function pdfPrevPage(){ if(pdfCapState.page>0) pdfLoadPage(pdfCapState.page-1); }
function pdfNextPage(){ if(pdfCapState.page<pdfCapState.total-1) pdfLoadPage(pdfCapState.page+1); }
function pdfSelStart(e) {
const {x,y}=pdfEvCoords(e);
pdfCapState.dragging=true; pdfCapState.startX=x; pdfCapState.startY=y;
pdfCapState.sel=null;
document.getElementById('pdf-capture-btn').disabled=true;
document.getElementById('pdf-sel-info').textContent='';
pdfRedraw();
}
function pdfSelMove(e) {
if(!pdfCapState.dragging)return;
const {x,y}=pdfEvCoords(e);
const rx=Math.min(pdfCapState.startX,x), ry=Math.min(pdfCapState.startY,y);
const rw=Math.abs(x-pdfCapState.startX), rh=Math.abs(y-pdfCapState.startY);
pdfCapState.sel={x:rx,y:ry,w:rw,h:rh};
pdfRedraw();
}
function pdfSelEnd(e) {
if(!pdfCapState.dragging)return;
pdfCapState.dragging=false;
if(e){ const {x,y}=pdfEvCoords(e);
const rx=Math.min(pdfCapState.startX,x), ry=Math.min(pdfCapState.startY,y);
const rw=Math.abs(x-pdfCapState.startX), rh=Math.abs(y-pdfCapState.startY);
pdfCapState.sel={x:rx,y:ry,w:rw,h:rh};
}
const cv=document.getElementById('pdf-main-canvas');
const s=pdfCapState.sel;
if(s&&s.w>8&&s.h>8){
pdfCapState.sel=Object.assign(s,{fx:s.x/cv.width,fy:s.y/cv.height,fw:s.w/cv.width,fh:s.h/cv.height});
document.getElementById('pdf-capture-btn').disabled=false;
document.getElementById('pdf-sel-info').textContent=`${Math.round(s.w)}×${Math.round(s.h)}px 선택됨`;
} else { pdfCapState.sel=null; }
pdfRedraw();
}
function pdfClearSel(silent) {
pdfCapState.sel=null; pdfCapState.dragging=false;
document.getElementById('pdf-capture-btn').disabled=true;
document.getElementById('pdf-sel-info').textContent='';
if(!silent) pdfRedraw();
}
// PDF figures 자동 추출 → extractedImages에 추가 → 이미지 피커 열기
async function sedAutoExtract(){
let pdfs=Object.entries(prepLog)
.filter(([,s])=>s.pdfPath)
.map(([k,s])=>({key:k,path:s.pdfPath,name:k.startsWith('UPL_')?s.pdfPath.split('/').pop():k}));
// prepLog가 비었으면 프로젝트 폴더에서 PDF 검색
if(!pdfs.length){
const proj=(document.getElementById('cfg-project')?.value||'').trim() || outline?.project || existingProject || '';
if(proj){
try{
const r=await fetch(`/api/pptx/project-pdfs?project=${encodeURIComponent(proj)}`,{...jh()});
const d=await r.json();
if(d.pdfs?.length) pdfs=d.pdfs;
}catch{}
}
}
if(!pdfs.length){ alert('PDF를 찾을 수 없습니다.\n2단계에서 논문을 준비하거나 프로젝트 폴더에 PDF를 추가하세요.'); return; }
const thumb=document.getElementById('sed-img-thumb');
const savedBg=thumb.style.backgroundImage, savedTxt=thumb.textContent;
thumb.style.backgroundImage=''; thumb.textContent='⏳';
let added=0;
for(const pdf of pdfs){
try{
const r=await fetch('/api/pptx/extract-images',{...jh(),method:'POST',body:JSON.stringify({pdf_path:pdf.path})});
const d=await r.json();
if(d.success&&d.data?.files){
for(const f of d.data.files){
const url='/api/files/'+f;
if(!extractedImages.find(img=>img.url===url)){
extractedImages.push({url,label:pdf.name.replace(/\.pdf$/i,'')+': '+f.split('/').pop(),pdf:pdf.path});
added++;
}
}
}
}catch{}
}
renderImgGrid();
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
thumb.style.backgroundImage=savedBg; thumb.textContent=savedTxt;
if(!extractedImages.length){ alert('추출된 이미지가 없습니다. PDF에 그림/도표가 없거나 스캔본일 수 있습니다.'); return; }
sedPickImg();
}
// PDF 목록에서 캡처 열기 (sedmodal에서 호출)
async function sedOpenPdfPicker(){
let pdfs=Object.entries(prepLog)
.filter(([,s])=>s.pdfPath)
.map(([k,s])=>({key:k,path:s.pdfPath,name:k.startsWith('UPL_')?s.pdfPath.split('/').pop():k}));
// prepLog가 비었으면 프로젝트 폴더에서 PDF 검색
if(!pdfs.length){
const proj=(document.getElementById('cfg-project')?.value||'').trim() || outline?.project || existingProject || '';
if(proj){
try{
const r=await fetch(`/api/pptx/project-pdfs?project=${encodeURIComponent(proj)}`,{...jh()});
const d=await r.json();
if(d.pdfs?.length) pdfs=d.pdfs;
}catch{}
}
}
if(!pdfs.length){ alert('PDF를 찾을 수 없습니다.\n2단계에서 논문을 준비하거나 프로젝트 폴더에 PDF를 추가하세요.'); return; }
if(pdfs.length===1){ openPdfCapture(pdfs[0].path, pdfs[0].name); return; }
// 여러 PDF: 간단한 선택창
const names=pdfs.map((p,i)=>`${i+1}. ${p.name}`).join('\n');
const idx=prompt(`PDF를 선택하세요 (번호 입력):\n${names}`, '1');
if(!idx)return;
const chosen=pdfs[parseInt(idx)-1];
if(chosen) openPdfCapture(chosen.path, chosen.name);
}
async function pdfCapture() {
const s=pdfCapState; if(!s.sel)return;
const btn=document.getElementById('pdf-capture-btn');
btn.disabled=true; btn.textContent='캡처 중...';
try{
const r=await fetch('/api/pptx/pdf-crop',{...jh(),method:'POST',body:JSON.stringify({
pdf_path:s.path, page:s.page, fx:s.sel.fx, fy:s.sel.fy, fw:s.sel.fw, fh:s.sel.fh
})});
const d=await r.json();
if(!d.success) throw new Error(d.error||'캡처 실패');
const imgObj={url:d.url, label:`캡처 p${s.page+1} (${s.name})`, pdf:s.path};
extractedImages.push(imgObj);
renderImgGrid();
document.getElementById('imgcnt').textContent='('+extractedImages.length+')';
pdfClearSel();
setMsg(2,'캡처 완료 — 이미지 '+extractedImages.length+'개','var(--green)');
// sedmodal이 열려있으면 캡처 이미지를 현재 슬라이드에 바로 적용
if(editorIdx>=0){
sedDirty=true;
if(!outlineEdits[editorIdx])outlineEdits[editorIdx]={};
outlineEdits[editorIdx].image_url=imgObj.url;
outlineEdits[editorIdx].image_search='';
const thumb=document.getElementById('sed-img-thumb');
if(thumb){thumb.style.backgroundImage=`url(${imgObj.url})`;thumb.textContent='';}
const urlInp=document.getElementById('sed-img-url-inp');
if(urlInp) urlInp.value=imgObj.url;
sedRenderPreview();
closePdfModal();
}
}catch(e){ alert('캡처 실패: '+e.message); }
finally{ btn.disabled=false; btn.textContent='📷 캡처'; }
}
function closePdfModal(){ document.getElementById('pdfbg').classList.remove('open'); pdfClearSel(true); }
// ── PPTX creation ─────────────────────────────────────────────────────────────
async function createPptx(){
if(!outline)return;
const btn=document.getElementById('s3create'); btn.disabled=true; btn.textContent='생성 중...';
const stopTimer=startTimer('s3msg','⚙ PPTX 생성 중');
const sentEdits = JSON.parse(JSON.stringify(outlineEdits));
// 편집 내용을 슬라이드에 병합
const slides=(outline.slides||[]).map((s,i)=>{
const ed=outlineEdits[i]; if(!ed)return s;
return Object.assign({},s,
ed.title!==undefined?{title:ed.title}:{},
ed.subtitle!==undefined?{subtitle:ed.subtitle}:{},
ed.bullets!==undefined?{bullets:ed.bullets}:{},
ed.image_url!==undefined?{image_url:ed.image_url}:{},
ed.image_search!==undefined?{image_search:ed.image_search}:{},
ed.background!==undefined&&ed.background?{background:ed.background}:{},
ed.bg_image!==undefined?{bg_image:ed.bg_image}:{},
ed.bg_img_url!==undefined?{bg_img_url:ed.bg_img_url}:{},
ed.font_family!==undefined?{font_family:ed.font_family}:{},
ed.title_color!==undefined?{title_color:ed.title_color}:{},
ed.body_color!==undefined?{body_color:ed.body_color}:{},
ed.img_filter!==undefined?{img_filter:ed.img_filter}:{},
ed.img_blur_radius!==undefined?{img_blur_radius:ed.img_blur_radius}:{},
ed.img_corner_radius!==undefined?{img_corner_radius:ed.img_corner_radius}:{},
ed.font_size!==undefined?{font_size:ed.font_size}:{},
ed.title_size!==undefined?{title_size:ed.title_size}:{},
ed.img_scale!==undefined?{img_scale:ed.img_scale}:{},
ed.img_x!=null?{img_x:ed.img_x}:{},
ed.img_y!=null?{img_y:ed.img_y}:{},
ed.img_w!=null?{img_w:ed.img_w}:{},
ed.img_h!=null?{img_h:ed.img_h}:{},
ed.type!==undefined?{type:ed.type}:{},
ed.layout!==undefined?{layout:ed.layout}:{},
ed.text_y!=null?{text_y:ed.text_y}:{}
);
});
const cfgProject = (document.getElementById('cfg-project')?.value||'').trim();
const existingProject = pptxRelPath ? pptxRelPath.replace(/\\/g,'/').split('/')[0] : null;
const resolvedProject = cfgProject || existingProject || outline.project || null;
const spec=Object.assign({},outline,{slides},
resolvedProject ? {project: resolvedProject, title: resolvedProject} : {}
);
try{
const r=await fetch('/api/pptx/create',{method:'POST',...jh(),body:JSON.stringify({spec}),signal:AbortSignal.timeout(120000)});
const d=await r.json();
if(!r.ok||!d.success) throw new Error(d.error||'생성 실패');
stopTimer();
// 결과에서 PPTX 경로 추출
let fileUrl='';
if(d.data?.path){
const rel=d.data.path.replace(/\\/g,'/');
const wsIdx=rel.indexOf('/workspace/');
if(wsIdx>=0) fileUrl='/api/files/'+rel.slice(wsIdx+'/workspace/'.length).split('/').map(encodeURIComponent).join('/');
else fileUrl='/api/files/'+rel.split('/').map(encodeURIComponent).join('/');
}
if(!fileUrl&&d.stdout){
const m=d.stdout.match(/download_url:\s*(\/api\/files\/[^\s|]+)/);
if(m) fileUrl=m[1];
}
if(!fileUrl){
const fr=await fetch('/api/pptx/latest',cr()); const fd=await fr.json();
if(fd.found) fileUrl=fd.url;
}
if(!fileUrl) throw new Error('PPTX 경로를 찾을 수 없습니다');
// 편집 반영 완료 — outline.slides baking
Object.keys(sentEdits).forEach(k=>{
const idx=+k, ed=sentEdits[k]||{}, s=outline.slides?.[idx]; if(!s)return;
if(JSON.stringify(outlineEdits[idx]||{})!==JSON.stringify(ed)) return;
if(ed.title!==undefined) s.title=ed.title;
if(ed.subtitle!==undefined) s.subtitle=ed.subtitle;
if(ed.bullets!==undefined) s.bullets=ed.bullets;
if(ed.image_url!==undefined) s.image_url=ed.image_url;
if(ed.image_search!==undefined) s.image_search=ed.image_search;
if(ed.background) s.background=ed.background;
if(ed.bg_img_url!==undefined) s.bg_img_url=ed.bg_img_url;
if(ed.bg_image!==undefined) s.bg_image=ed.bg_image;
if(ed.font_family!==undefined) s.font_family=ed.font_family;
if(ed.title_color!==undefined) s.title_color=ed.title_color;
if(ed.body_color!==undefined) s.body_color=ed.body_color;
if(ed.img_filter!==undefined) s.img_filter=ed.img_filter;
if(ed.img_blur_radius!==undefined) s.img_blur_radius=ed.img_blur_radius;
if(ed.img_corner_radius!==undefined) s.img_corner_radius=ed.img_corner_radius;
if(ed.font_size!==undefined) s.font_size=ed.font_size;
if(ed.title_size!==undefined) s.title_size=ed.title_size;
if(ed.img_scale!==undefined) s.img_scale=ed.img_scale;
if(ed.img_x!==undefined) s.img_x=ed.img_x;
if(ed.img_y!==undefined) s.img_y=ed.img_y;
if(ed.img_w!==undefined) s.img_w=ed.img_w;
if(ed.img_h!==undefined) s.img_h=ed.img_h;
if(ed.type!==undefined) s.type=ed.type;
if(ed.layout!==undefined) s.layout=ed.layout;
if(ed.text_y!=null) s.text_y=ed.text_y;
delete outlineEdits[idx];
});
if(Object.keys(outlineEdits).length===0)
document.querySelectorAll('.preview-card[data-edited]').forEach(c=>delete c.dataset.edited);
const relPath=decodeURIComponent(fileUrl.replace(/^\/api\/files\//,''));
pptxRelPath=relPath;
const dlBtn=document.getElementById('dl-btn');
dlBtn.href=fileUrl; dlBtn.style.display='inline-flex'; dlBtn.download=relPath.split('/').pop()||'slides.pptx';
setMsg(3,'✅ 생성 완료!','var(--green)');
// 새로고침 후 복원을 위해 세션 저장
try{ localStorage.setItem('pptx-session',JSON.stringify({outline,pptxRelPath:relPath,skin:selectedSkin,ts:Date.now()})); }catch(_){}
goStep(4);
await loadPreview(relPath);
}catch(e){ stopTimer(); setMsg(3,'오류: '+e.message,'var(--red)'); }
finally{
btn.disabled=false; btn.textContent='✅ PPTX 생성 →';
if(regenPending){ regenPending=false; s4Regen(); }
}
}
// ── STEP 4: Preview ──────────────────────────────────────────────────────────
async function loadPreview(relPath){
const stopTimer=startTimer('s4msg','🖼 슬라이드 변환 중');
document.getElementById('preview-grid').innerHTML='<div style="color:var(--muted);font-size:12px;text-align:center;padding:32px;grid-column:1/-1">🖼 변환 중...</div>';
try{
const r=await fetch('/api/pptx/preview?path='+encodeURIComponent(relPath),{...cr(),signal:AbortSignal.timeout(90000)});
const d=await r.json();
if(!r.ok||!d.success) throw new Error(d.error||'프리뷰 생성 실패');
previewImages=d.images||[];
if(!previewImages.length) throw new Error('슬라이드 이미지 0개');
stopTimer();
renderPreview();
setMsg(4,previewImages.length+'장 미리보기','var(--green)');
document.getElementById('preview-hint').style.display='';
const regenBtn=document.getElementById('s4-regen');
if(regenBtn&&outline) regenBtn.style.display='';
}catch(e){
stopTimer();
const msg2=e.name==='TimeoutError'?'변환 시간 초과 (90s)':e.message;
setMsg(4,'프리뷰 오류: '+msg2,'var(--red)');
document.getElementById('preview-grid').innerHTML=
'<div style="color:var(--red);font-size:12px;text-align:center;padding:24px;grid-column:1/-1">'+esc(msg2)+'<br><br>'
+'<button class="btn bg" onclick="reloadPreview()" style="margin-top:8px">↺ 다시 시도</button></div>';
}
}
function renderPreview(){
const g=document.getElementById('preview-grid');
if(!previewImages.length){ g.innerHTML='<div style="color:var(--muted);font-size:13px;text-align:center;padding:24px;grid-column:1/-1">이미지 없음</div>'; return; }
const slides=outline?.slides||[];
const canEdit=!!(outline&&slides.length);
g.innerHTML=previewImages.map((url,i)=>{
const sl=slides[i];
const lbl=sl?`${i+1}. ${sl.title||sl.type||''}`:`슬라이드 ${i+1}`;
const editBtn=canEdit?`<button class="prev-edit-btn" onclick="event.stopPropagation();openSedFromPreview(${i})">✏ 편집</button>`:'';
return `<div class="preview-card" title="${esc(lbl)}">
${editBtn}
<img src="${esc(url)}" loading="lazy" alt="${esc(lbl)}" style="cursor:zoom-in"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"
onclick="ssStart(${i})">
<div style="display:none;align-items:center;justify-content:center;height:120px;font-size:12px;color:var(--muted)">이미지 로드 실패</div>
<div class="preview-label" id="prev-lbl-${i}">${esc(lbl)}</div>
</div>`;
}).join('');
const ssBtn=document.getElementById('ss-launch');
if(ssBtn) ssBtn.disabled=false;
}
async function reloadPreview(){
if(pptxRelPath){ await loadPreview(pptxRelPath); return; }
// Try to find newest PPTX
try{
const r=await fetch('/api/pptx/latest',cr()); const d=await r.json();
if(d.found){ pptxRelPath=d.relPath; const dlBtn=document.getElementById('dl-btn'); dlBtn.href=d.url; dlBtn.style.display='inline-flex'; await loadPreview(d.relPath); }
else setMsg(4,'PPTX 파일이 없습니다','var(--red)');
}catch(e){ setMsg(4,'오류: '+e.message,'var(--red)'); }
}
// ── Slideshow ─────────────────────────────────────────────────────────────────
let ssIdx=0, ssAutoTimer=null, ssAutoOn=false;
function ssStart(idx=0){
if(!previewImages.length)return;
ssIdx=Math.max(0,Math.min(idx,previewImages.length-1));
ssRender();
document.getElementById('slideshow').classList.add('open');
document.addEventListener('keydown',ssKey);
}
function ssClose(){
document.getElementById('slideshow').classList.remove('open');
document.removeEventListener('keydown',ssKey);
if(ssAutoTimer){clearInterval(ssAutoTimer);ssAutoTimer=null;ssAutoOn=false;}
const b=document.getElementById('ss-auto-btn');if(b)b.textContent='⏵ 자동';
}
function ssRender(){
const url=previewImages[ssIdx]||'';
document.getElementById('ss-img').src=url;
document.getElementById('ss-counter').textContent=(ssIdx+1)+' / '+previewImages.length;
const sl=(outline?.slides||[])[ssIdx];
document.getElementById('ss-title').textContent=sl?(sl.title||sl.type||''):'';
}
function ssPrev(){ ssIdx=(ssIdx-1+previewImages.length)%previewImages.length; ssRender(); }
function ssNext(){ ssIdx=(ssIdx+1)%previewImages.length; ssRender(); }
function ssToggleAuto(){
ssAutoOn=!ssAutoOn;
const b=document.getElementById('ss-auto-btn');
if(ssAutoOn){ ssAutoTimer=setInterval(ssNext,3500); b.textContent='⏸ 정지'; }
else{ clearInterval(ssAutoTimer);ssAutoTimer=null; b.textContent='⏵ 자동'; }
}
function ssKey(e){
if(e.key==='Escape') ssClose();
else if(e.key==='ArrowLeft'||e.key==='ArrowUp') ssPrev();
else if(e.key==='ArrowRight'||e.key==='ArrowDown'||e.key===' '){e.preventDefault();ssNext();}
}
// ── Slide Editor ─────────────────────────────────────────────────────────────
let editorIdx = -1;
(function initSedSkins(){
const g=document.getElementById('sed-skin-grid');
SKINS.forEach(s=>{
const d=document.createElement('div');
d.className='sed-skin-sw'; d.style.background=s.c; d.title=s.label; d.dataset.v=s.v;
d.onclick=()=>sedSetBg(s.v); g.appendChild(d);
});
})();
let sedFromPreview=false;
let sedDirty=false; // 실제 편집이 있었을 때만 true
function openSed(i){
if(!outline||(outline.slides||[]).length===0)return;
editorIdx=Math.max(0,Math.min(i,(outline.slides||[]).length-1));
sedDirty=false;
const bg=document.getElementById('sedbg');
bg.classList.add('open');
const modal=bg.querySelector('.sedmodal');
const saved=JSON.parse(localStorage.getItem('sed-pos')||'null');
if(saved&&saved.left!=null&&saved.top!=null){
// 저장된 절대 좌표 복원: CSS 중앙정렬용 transform을 반드시 해제해야
// translate(-50%,-50%)가 더해져 모달이 화면 밖(헤더 잘림)으로 밀리는 것을 막는다.
// 새 탭(window.open)으로 새로고침된 직후엔 인라인 transform이 없어 이 버그가 발생했다.
modal.style.transform='none';
// 창 크기가 달라졌을 때를 대비해 화면 안으로 클램프
const mw=modal.offsetWidth||1200, mh=modal.offsetHeight||Math.round(window.innerHeight*0.9);
const left=Math.max(0,Math.min(saved.left,window.innerWidth-mw));
const top=Math.max(0,Math.min(saved.top,window.innerHeight-mh));
modal.style.left=left+'px'; modal.style.top=top+'px';
} else {
// 저장된 위치 없음 → CSS 기본 중앙정렬 복귀(이전 드래그 잔여 인라인 스타일 제거)
modal.style.removeProperty('left');
modal.style.removeProperty('top');
modal.style.removeProperty('transform');
}
sedInitDrag(modal);
// sedLoad after modal is visible so clientWidth/clientHeight are correct
sedLoad();
}
function openSedFromPreview(i){
sedFromPreview=true;
openSed(i);
}
function closeSed(){
// sedmodal 편집 내용을 outline.slides에 즉시 병합 (저장) — 실제 변경이 있을 때만
const i=editorIdx;
if(sedDirty && i>=0 && outline?.slides?.[i] && outlineEdits[i]){
const ed=outlineEdits[i], s=outline.slides[i];
if(ed.title!==undefined) s.title=ed.title;
if(ed.subtitle!==undefined) s.subtitle=ed.subtitle;
if(ed.bullets!==undefined) s.bullets=ed.bullets;
if(ed.image_url!==undefined) s.image_url=ed.image_url;
if(ed.image_search!==undefined)s.image_search=ed.image_search;
if(ed.background) s.background=ed.background;
if(ed.bg_img_url!==undefined) s.bg_img_url=ed.bg_img_url;
if(ed.bg_image!==undefined) s.bg_image=ed.bg_image;
if(ed.font_family!==undefined) s.font_family=ed.font_family;
if(ed.title_color!==undefined) s.title_color=ed.title_color;
if(ed.body_color!==undefined) s.body_color=ed.body_color;
if(ed.img_filter!==undefined) s.img_filter=ed.img_filter;
if(ed.img_blur_radius!==undefined) s.img_blur_radius=ed.img_blur_radius;
if(ed.img_corner_radius!==undefined) s.img_corner_radius=ed.img_corner_radius;
if(ed.font_size!==undefined)s.font_size=ed.font_size;
if(ed.title_size!==undefined)s.title_size=ed.title_size;
if(ed.img_scale!==undefined)s.img_scale=ed.img_scale;
if(ed.img_x!==undefined) s.img_x=ed.img_x;
if(ed.img_y!==undefined) s.img_y=ed.img_y;
if(ed.img_w!==undefined) s.img_w=ed.img_w;
if(ed.img_h!==undefined) s.img_h=ed.img_h;
if(ed.type!==undefined) s.type=ed.type;
if(ed.layout!==undefined) s.layout=ed.layout;
if(ed.text_y!=null) s.text_y=ed.text_y;
delete outlineEdits[i]; // 병합 완료 → 완전히 제거 (빈 {}객체 잔류 방지)
} else if(!sedDirty) {
// 편집 없이 닫음 → 임시 편집분 완전 제거
delete outlineEdits[i];
}
document.getElementById('sedbg').classList.remove('open');
const wasDirty=sedDirty;
const wasPreview=sedFromPreview;
const savedIdx=i;
sedDirty=false;
sedFromPreview=false;
editorIdx=-1;
renderOutline();
if(wasDirty) saveOutlineToProject();
if(wasPreview && wasDirty) afterSedPreview(savedIdx); // 실제 변경이 있을 때만
}
function afterSedPreview(editedIdx){
// 편집된 슬라이드만 라벨 갱신 + 수정됨 표시
const sl=outline?.slides?.[editedIdx];
if(sl){
const lbl=document.getElementById('prev-lbl-'+editedIdx);
if(lbl) lbl.textContent=`${editedIdx+1}. ${sl.title||sl.type||''}`;
const card=lbl?.closest?.('.preview-card');
if(card) card.dataset.edited='1';
}
// 자동 재생성 — 생성 중이면 s4Regen이 끝난 뒤 1회 재생성으로 예약
s4Regen();
}
let regenPending=false;
function s4Regen(){
const genBtn=document.getElementById('s3create');
if(genBtn&&genBtn.disabled){ regenPending=true; return; } // 생성 중 → 끝난 뒤 1회 예약
const btn=document.getElementById('s4-regen');
if(btn){ btn.classList.remove('bp'); btn.classList.add('bgreen'); }
createPptx();
}
function sedInitDrag(modal){
const hd=modal.querySelector('.sed-hd');
if(hd._sedDragBound) return;
hd._sedDragBound=true;
let ox=0,oy=0,mx=0,my=0;
hd.addEventListener('mousedown',e=>{
if(e.target.tagName==='BUTTON'||e.target.closest('button')) return;
e.preventDefault();
const r=modal.getBoundingClientRect();
// 현재 fixed 위치 확정
modal.style.left=r.left+'px'; modal.style.top=r.top+'px';
modal.style.transform='none';
ox=e.clientX-r.left; oy=e.clientY-r.top;
const onMove=e=>{
let nx=e.clientX-ox, ny=e.clientY-oy;
nx=Math.max(0,Math.min(nx,window.innerWidth-modal.offsetWidth));
ny=Math.max(0,Math.min(ny,window.innerHeight-modal.offsetHeight));
modal.style.left=nx+'px'; modal.style.top=ny+'px';
};
const onUp=()=>{
document.removeEventListener('mousemove',onMove);
document.removeEventListener('mouseup',onUp);
const r2=modal.getBoundingClientRect();
localStorage.setItem('sed-pos',JSON.stringify({left:r2.left,top:r2.top}));
};
document.addEventListener('mousemove',onMove);
document.addEventListener('mouseup',onUp);
});
}
function sedNav(dir){
const total=(outline?.slides||[]).length; if(!total)return;
editorIdx=(editorIdx+dir+total)%total;
sedLoad();
}
function sedLoad(){
const i=editorIdx; if(!outline)return;
const s=outline.slides[i]||{};
const ed=outlineEdits[i]||{};
const type=s.type||'content';
const title=ed.title!==undefined?ed.title:(s.title||'');
const subtitle=ed.subtitle!==undefined?ed.subtitle:(s.subtitle||'');
// body/content(string) → split into bullets array for editing
// bullets 배열이 있으면 그대로 사용, body/content 문자열이면 \n으로 분리
// (textarea에서는 \n\n이 항목 구분이지만, AI 생성 데이터는 \n 구분이므로 원본 파싱은 \n 사용)
const rawBullets = ed.bullets!==undefined ? ed.bullets
: s.bullets || s.bullet_points
|| (typeof (s.body||s.content)==='string' ? (s.body||s.content).split('\n') : []);
const bullets = Array.isArray(rawBullets) ? rawBullets
: typeof rawBullets==='string' ? rawBullets.split('\n') : [];
const imgUrl=ed.image_url!==undefined?ed.image_url:(s.image_url||'');
const bg=ed.background||s.background||selectedSkin||'';
const titleSz=ed.title_size!==undefined?ed.title_size:(s.title_size||(type==='title'?34:26));
const bodySz=ed.font_size!==undefined?ed.font_size:(s.font_size||18);
const imgScale=ed.img_scale!==undefined?ed.img_scale:(s.img_scale!=null?s.img_scale:50);
const layout=ed.layout||s.layout||'split-right';
const total=(outline.slides||[]).length;
document.getElementById('sed-num').textContent=i+1;
document.getElementById('sed-cur').textContent=i+1;
document.getElementById('sed-total').textContent=total;
document.getElementById('sed-type').textContent=type;
document.getElementById('sed-title-lbl').textContent=title||`슬라이드 ${i+1}`;
document.getElementById('sed-title-inp').value=title;
document.getElementById('sed-subtitle-inp').value=subtitle;
document.getElementById('sed-bullets-ta').value=Array.isArray(bullets)?bullets.join('\n\n'):(bullets||'');
document.getElementById('sed-title-size').value=titleSz;
document.getElementById('sed-title-size-val').textContent=titleSz+'pt';
document.getElementById('sed-body-size').value=bodySz;
document.getElementById('sed-body-size-val').textContent=bodySz+'pt';
document.getElementById('sed-img-scale').value=imgScale;
document.getElementById('sed-img-scale-val').textContent=imgScale+'%';
// Show/hide sections
const isTitle=type==='title';
const hasBul=['content','image','compare'].includes(type);
const hasImg=['content','image','title'].includes(type);
const hasLayout=['content','image','compare'].includes(type);
document.getElementById('sed-subtitle-sec').style.display=isTitle?'':'none';
document.getElementById('sed-bullets-sec').style.display=hasBul?'':'none';
document.getElementById('sed-img-sec').style.display=hasImg?'':'none';
document.getElementById('sed-layout-sec').style.display=hasLayout?'':'none';
// Layout buttons
document.querySelectorAll('.layout-btn').forEach(btn=>btn.classList.toggle('active',btn.dataset.layout===layout));
// Skin
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===bg));
const sk=SKINS.find(s=>s.v===bg);
document.getElementById('sed-skin-lbl').textContent=sk?sk.label:'기본';
// Image thumb + URL input
const thumb=document.getElementById('sed-img-thumb');
const urlInp=document.getElementById('sed-img-url-inp');
if(imgUrl){
thumb.style.backgroundImage=`url(${imgUrl})`; thumb.textContent='';
if(urlInp) urlInp.value=imgUrl;
} else {
thumb.style.backgroundImage=''; thumb.textContent='🖼';
const srch=ed.image_search!==undefined?ed.image_search:(s.image_search||'');
if(urlInp) urlInp.value='';
if(srch) thumb.textContent='🔍 '+srch;
}
// Background image thumb
const bgImgUrl=ed.bg_img_url!==undefined?ed.bg_img_url:(s.bg_img_url||'');
_sedUpdateBgImgThumb(bgImgUrl);
// Font & color
document.getElementById('sed-font-family').value=ed.font_family!==undefined?ed.font_family:(s.font_family||'');
const tc=ed.title_color!==undefined?ed.title_color:(s.title_color||'');
const bc=ed.body_color!==undefined?ed.body_color:(s.body_color||'');
document.getElementById('sed-title-color').value=tc?('#'+tc.replace('#','')):'#1a1a2e';
document.getElementById('sed-body-color').value=bc?('#'+bc.replace('#','')):'#2d3748';
// Image FX
const fx=ed.img_filter!==undefined?ed.img_filter:(s.img_filter||'');
const blurR=ed.img_blur_radius!==undefined?ed.img_blur_radius:(s.img_blur_radius||5);
const roundR=ed.img_corner_radius!==undefined?ed.img_corner_radius:(s.img_corner_radius||15);
document.querySelectorAll('.fx-btn').forEach(b=>b.classList.toggle('active',b.dataset.fx===fx));
document.getElementById('sed-fx-blur').value=blurR;
document.getElementById('sed-fx-blur-val').textContent=blurR;
document.getElementById('sed-fx-radius').value=roundR;
document.getElementById('sed-fx-radius-val').textContent=roundR+'%';
document.getElementById('sed-fx-blur-row').style.display=fx==='blur'?'':'none';
document.getElementById('sed-fx-round-row').style.display=fx==='round'?'':'none';
requestAnimationFrame(()=>sedRenderPreview());
}
function sedChange(){
const i=editorIdx; if(i<0||!outline)return;
sedDirty=true;
if(!outlineEdits[i])outlineEdits[i]={};
const ed=outlineEdits[i];
ed.title=document.getElementById('sed-title-inp').value;
ed.subtitle=document.getElementById('sed-subtitle-inp').value;
const bval=document.getElementById('sed-bullets-ta').value;
// 빈 줄(\n\n)로 분리 → 각 그룹이 하나의 글머리 기호
// 그룹 내 단순 \n은 같은 점 안 줄바꿈
const _groups=bval.split(/\n\n+/);
ed.bullets=_groups.map(g=>g.replace(/^\n+|\n+$/g,'')).filter(g=>g.trim()!=='');
ed.title_size=parseInt(document.getElementById('sed-title-size').value)||26;
ed.font_size=parseInt(document.getElementById('sed-body-size').value)||18;
ed.img_scale=parseInt(document.getElementById('sed-img-scale').value)||50;
document.getElementById('sed-title-size-val').textContent=ed.title_size+'pt';
document.getElementById('sed-body-size-val').textContent=ed.font_size+'pt';
document.getElementById('sed-img-scale-val').textContent=ed.img_scale+'%';
// Font & color
ed.font_family=document.getElementById('sed-font-family').value;
const _tc=document.getElementById('sed-title-color').value.replace('#','');
const _bc=document.getElementById('sed-body-color').value.replace('#','');
ed.title_color=_tc; ed.body_color=_bc;
document.getElementById('sed-title-lbl').textContent=ed.title||`슬라이드 ${i+1}`;
sedRenderPreview();
}
function sedResetColor(which){
const i=editorIdx; if(i<0)return;
sedDirty=true;
if(!outlineEdits[i])outlineEdits[i]={};
const {dark}=skinColors(outlineEdits[i]?.background||outline?.slides?.[i]?.background||selectedSkin||'');
if(which==='title'){
outlineEdits[i].title_color=dark?'ffffff':'1a1a2e';
document.getElementById('sed-title-color').value=dark?'#ffffff':'#1a1a2e';
} else {
outlineEdits[i].body_color=dark?'e0e0e0':'2d3748';
document.getElementById('sed-body-color').value=dark?'#e0e0e0':'#2d3748';
}
sedRenderPreview();
}
function sedSetFx(fx){
const i=editorIdx; if(i<0)return;
sedDirty=true;
if(!outlineEdits[i])outlineEdits[i]={};
outlineEdits[i].img_filter=fx;
document.querySelectorAll('.fx-btn').forEach(b=>b.classList.toggle('active',b.dataset.fx===fx));
document.getElementById('sed-fx-blur-row').style.display=fx==='blur'?'':'none';
document.getElementById('sed-fx-round-row').style.display=fx==='round'?'':'none';
sedRenderPreview();
}
function sedFxSlider(){
const i=editorIdx; if(i<0)return;
sedDirty=true;
if(!outlineEdits[i])outlineEdits[i]={};
const blurR=parseInt(document.getElementById('sed-fx-blur').value)||5;
const roundR=parseInt(document.getElementById('sed-fx-radius').value)||15;
outlineEdits[i].img_blur_radius=blurR;
outlineEdits[i].img_corner_radius=roundR;
document.getElementById('sed-fx-blur-val').textContent=blurR;
document.getElementById('sed-fx-radius-val').textContent=roundR+'%';
sedRenderPreview();
}
function sedSetLayout(layout){
const i=editorIdx; if(i<0)return;
sedDirty=true;
if(!outlineEdits[i])outlineEdits[i]={};
outlineEdits[i].layout=layout;
if(outline?.slides?.[i]) outline.slides[i].layout=layout;
document.querySelectorAll('.layout-btn').forEach(btn=>btn.classList.toggle('active',btn.dataset.layout===layout));
sedRenderPreview();
}
function sedSetBg(v){
const i=editorIdx; if(i<0)return;
sedDirty=true;
if(!outlineEdits[i])outlineEdits[i]={};
outlineEdits[i].background=v;
document.querySelectorAll('.sed-skin-sw').forEach(sw=>sw.classList.toggle('active',sw.dataset.v===v));
const sk=SKINS.find(s=>s.v===v);
document.getElementById('sed-skin-lbl').textContent=sk?sk.label:'기본';
sedRenderPreview();
}
async function sedPickImg(){
modalSlide=editorIdx;
pickerMode='image';
const g=document.getElementById('imodal-grid');
document.getElementById('ibg').classList.add('open');
await _loadPickerImages(g);
}
function sedImgUrlInput(){
const i=editorIdx; if(i<0)return;
sedDirty=true;
const url=(document.getElementById('sed-img-url-inp')?.value||'').trim();
if(!outlineEdits[i])outlineEdits[i]={};
outlineEdits[i].image_url=url;
outlineEdits[i].image_search='';
const thumb=document.getElementById('sed-img-thumb');
if(url){ thumb.style.backgroundImage=`url(${url})`; thumb.textContent=''; }
else { thumb.style.backgroundImage=''; thumb.textContent='🖼'; }
sedRenderPreview();
}
function sedClearImg(){
const i=editorIdx; if(i<0)return;
sedDirty=true;
if(!outlineEdits[i])outlineEdits[i]={};
outlineEdits[i].image_url='';
outlineEdits[i].image_search='';
const thumb=document.getElementById('sed-img-thumb');
thumb.style.backgroundImage=''; thumb.textContent='🖼';
const urlInp=document.getElementById('sed-img-url-inp');
if(urlInp) urlInp.value='';
sedRenderPreview();
}
function sedRenderPreview(){
const i=editorIdx; if(i<0||!outline)return;
const prev=document.getElementById('sed-preview');
const stage=document.getElementById('sed-stage');
if(!prev||!stage)return;
// 편집창에서는 항상 HTML 미리보기 (드래그 등 인터랙션 지원)
const s=outline.slides[i]||{};
const ed=outlineEdits[i]||{};
const type=s.type||'content';
const titleRaw=(document.getElementById('sed-title-inp')?.value??ed.title??(s.title||''));
const subtitleRaw=(document.getElementById('sed-subtitle-inp')?.value??ed.subtitle??(s.subtitle||''));
// 줄바꿈을 <br>로 변환해서 미리보기에 반영
const titleHtml=esc(titleRaw).replace(/\n/g,'<br>');
const subtitleHtml=esc(subtitleRaw).replace(/\n/g,'<br>');
const title=titleRaw; // PPTX 생성 등 text 용도
const subtitle=subtitleRaw;
// 미리보기는 textarea 현재값 직접 사용 (빈 줄 = 단락 구분선 표시)
const ta=document.getElementById('sed-bullets-ta');
const _fbBullets=ed.bullets||s.bullets||s.bullet_points
||(typeof(s.body||s.content)==='string'?(s.body||s.content).split('\n'):[]);
const bullets=ta?ta.value.split(/\n\n+/).map(g=>g.replace(/^\n+|\n+$/g,'')).filter(g=>g.trim()!==''):(Array.isArray(_fbBullets)?_fbBullets:[]);
const imgUrlRaw=ed.image_url!==undefined?ed.image_url:(s.image_url||'');
const imgPath=ed.image_path!==undefined?ed.image_path:(s.image_path||'');
const imgProject=(outline?.project||'').replace(/[^a-zA-Z0-9가-힣_\-]/g,'_').replace(/_+/g,'_').toLowerCase();
const imgUrl=imgUrlRaw||(imgPath&&imgProject?`/api/files/${encodeURIComponent(imgProject)}/${encodeURIComponent(imgPath)}`:'');
const bg=ed.background||s.background||selectedSkin||'';
const titleSz=ed.title_size!==undefined?ed.title_size:(s.title_size||(type==='title'?34:26));
const bodySz=ed.font_size!==undefined?ed.font_size:(s.font_size||18);
const imgPct=ed.img_scale!==undefined?ed.img_scale:50;
const layout=ed.layout||s.layout||'split-right';
const {bg:bgc,bgImg,txt,muted:mutC,dark:isDark}=skinColors(bg);
const bgImgUrl=ed.bg_img_url!==undefined?ed.bg_img_url:(s.bg_img_url||'');
const activeBgImg=bgImgUrl||bgImg; // 이미지 배경 우선, 없으면 스킨
// Custom colors & font
const _tc=ed.title_color!==undefined?ed.title_color:(s.title_color||'');
const _bc=ed.body_color!==undefined?ed.body_color:(s.body_color||'');
const titleCol=_tc?('#'+_tc.replace('#','')):txt;
const bodyCol=_bc?('#'+_bc.replace('#','')):txt;
const fontFam=ed.font_family!==undefined?ed.font_family:(s.font_family||'');
const fontStyle=fontFam?`font-family:'${fontFam}',sans-serif;`:'';
// Image FX → CSS filter string
const imgFx=ed.img_filter!==undefined?ed.img_filter:(s.img_filter||'');
const blurR=ed.img_blur_radius!==undefined?ed.img_blur_radius:(s.img_blur_radius||5);
const roundR=ed.img_corner_radius!==undefined?ed.img_corner_radius:(s.img_corner_radius||15);
let imgCssFilter=''; let imgBorderRadius='';
if(imgFx==='blur') imgCssFilter=`filter:blur(${blurR*0.5}px)`;
else if(imgFx==='grayscale') imgCssFilter='filter:grayscale(100%)';
else if(imgFx==='contrast') imgCssFilter='filter:contrast(1.6) saturate(1.2)';
else if(imgFx==='bright') imgCssFilter='filter:brightness(1.4)';
else if(imgFx==='round') imgBorderRadius=`border-radius:${roundR}%;overflow:hidden`;
// Fit 16:9 into stage
// Force reflow so clientWidth/clientHeight are accurate after display:none→flex
void stage.offsetHeight;
const sw=stage.clientWidth-48, sh=stage.clientHeight-48;
let pw=sw, ph=pw/16*9;
if(ph>sh){ph=sh;pw=ph/9*16;}
pw=Math.floor(pw); ph=Math.floor(ph);
prev.style.width=pw+'px'; prev.style.height=ph+'px';
// 배경 이미지 (이미지 배경 > 스킨 배경)
if(activeBgImg && !imgUrl){
prev.style.background=bgc;
prev.style.backgroundImage=`url(${activeBgImg})`;
prev.style.backgroundSize='cover';
prev.style.backgroundPosition='center';
} else {
prev.style.background=bgc;
prev.style.backgroundImage='';
}
const sc=pw/960; // scale factor
const ts=Math.round(titleSz*sc);
const bs=Math.round(bodySz*sc);
function bulletHtml(list,color,size){
return (list||[]).map(b=>{
if(b.trim()==='') return `<div style="height:${Math.round(size*0.6)}px"></div>`;
const subM=b.match(/^[-–—]\s+(.*)/);
const isSub=!!subM;
const rawTxt=isSub?subM[1]:String(b);
// \n within a bullet → <br> soft break
const txt=esc(rawTxt).replace(/\n/g,'<br>');
const dot=isSub?'–':'●';
const dotOpacity=isSub?'.4':'.65';
const pad=isSub?`padding-left:${Math.round(14*sc)}px;`:'';
return `<div style="display:flex;gap:${Math.round(7*sc)}px;margin-bottom:${Math.round(5*sc)}px;align-items:flex-start;${pad}${fontStyle}">
<div style="width:${Math.round(5*sc)}px;height:${Math.round(5*sc)}px;min-width:${Math.round(5*sc)}px;border-radius:50%;background:${color};margin-top:${Math.round(size*.55)}px;opacity:${dotOpacity}"></div>
<div style="font-size:${size}px;color:${color};line-height:1.5;opacity:.92;word-break:break-word">${txt}</div>
</div>`;
}).join('');
}
// imgFxWrap: wrapper div that applies CSS filter / border-radius to images
const imgFxWrapStyle=imgBorderRadius?`style="${imgBorderRadius};overflow:hidden;width:100%;height:100%"`:'';
const imgFxFilterStyle=imgCssFilter?`style="${imgCssFilter};width:100%;height:100%"`:'';
function wrapImgFx(imgTag){
if(!imgBorderRadius && !imgCssFilter) return imgTag;
if(imgBorderRadius && imgCssFilter)
return `<div ${imgFxWrapStyle}><div ${imgFxFilterStyle}>${imgTag}</div></div>`;
if(imgBorderRadius) return `<div ${imgFxWrapStyle}>${imgTag}</div>`;
return `<div ${imgFxFilterStyle}>${imgTag}</div>`;
}
let html='';
const p=`${Math.round(18*sc)}px`;
const pt=`${Math.round(14*sc)}px`;
if(type==='title'){
const hasBg=!!imgUrl;
// pptx_gen.py: bg_image 있으면 y=2.8"/7.5"=37.3%, 없으면 y=2.5"/7.5"=33.3%
// 스킨 이미지(bgImg)가 있으면 bg_image가 설정되므로 37.3% 사용
const hasSkinImg=!!bgImg && !hasBg;
const titleTopPct=hasSkinImg||hasBg ? 0.373 : 0.333;
const titleTopPx=Math.round(ph*titleTopPct);
const titleLeftPx=Math.round(pw*0.06); // x=0.8/13.33"
const titleWidthPx=Math.round(pw*0.878); // w=11.7/13.33"
const textCol=hasBg?'#fff':(titleCol||txt);
const subCol=hasBg?'rgba(255,255,255,.8)':(bodyCol||mutC);
html=`${hasBg?`<div style="position:absolute;inset:0;background-image:url(${imgUrl});background-size:cover;background-position:center;${imgCssFilter}${imgBorderRadius?';'+imgBorderRadius:''}"></div><div style="position:absolute;inset:0;background:rgba(0,0,0,.48)"></div>`:''}
<div style="position:absolute;top:${titleTopPx}px;left:${titleLeftPx}px;width:${titleWidthPx}px;text-align:center;z-index:1">
<div style="font-size:${ts}px;font-weight:800;color:${textCol};line-height:1.35;margin-bottom:${Math.round(12*sc)}px">${titleHtml||'제목'}</div>
${subtitleRaw?`<div style="font-size:${bs}px;color:${subCol};line-height:1.4">${subtitleHtml}</div>`:''}
</div>`;
} else if(type==='section'){
html=`<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:${p}">
<div style="text-align:center">
<div style="font-size:${Math.round(ts*1.2)}px;font-weight:800;color:${txt};line-height:1.35">${titleHtml}</div>
<div style="height:${Math.round(3*sc)}px;background:${txt};opacity:.25;margin:${Math.round(14*sc)}px auto 0;width:${Math.round(100*sc)}px;border-radius:2px"></div>
</div>
</div>`;
} else if(type==='content'||type==='image'){
if(layout==='fullscreen'){
if(imgUrl){
html=`<div style="position:absolute;inset:0;background-image:url(${imgUrl});background-size:cover;background-position:center${imgCssFilter?';'+imgCssFilter:''}${imgBorderRadius?';'+imgBorderRadius:''}"></div>
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(0,0,0,.75) 50%,transparent 80%)"></div>
<div style="position:absolute;bottom:0;left:0;right:0;padding:${p};z-index:1">
<div style="font-size:${ts}px;font-weight:700;color:#fff;line-height:1.35;margin-bottom:${Math.round(8*sc)}px">${titleHtml}</div>
${bulletHtml(bullets,'rgba(255,255,255,.88)',bs)}
</div>`;
} else {
html=`<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:${mutC}22">
<div style="text-align:center;padding:${p}">
<div style="font-size:${Math.round(18*sc)}px;color:${mutC};margin-bottom:${Math.round(8*sc)}px">🖼</div>
<div style="font-size:${ts}px;font-weight:700;color:${txt};line-height:1.35;margin-bottom:${Math.round(8*sc)}px">${titleHtml}</div>
${bulletHtml(bullets,txt,bs)}
</div>
</div>`;
}
} else if(layout==='text'){
const defTY=titleRaw?0.2:0.067;
const handlePx=Math.round(10*sc);
const ty=Math.max(0,Math.round((ed.text_y!=null?ed.text_y:defTY)*ph)-handlePx);
html=`<div style="position:absolute;inset:0;overflow:hidden;${fontStyle}">
${titleRaw?`<div style="padding:${pt} ${p} ${Math.round(6*sc)}px;position:absolute;top:0;left:0;right:0">
<div style="font-size:${ts}px;font-weight:700;color:${titleCol};line-height:1.35">${titleHtml}</div>
<div style="height:${Math.round(2*sc)}px;background:${titleCol};opacity:.15;margin-top:${Math.round(7*sc)}px;border-radius:1px"></div>
</div>`:''}
<div id="sed-txt-wrapper" style="position:absolute;top:${ty}px;left:0;right:0;cursor:ns-resize;user-select:none">
<div style="height:${Math.round(10*sc)}px;display:flex;align-items:center;justify-content:center;gap:${Math.round(3*sc)}px;opacity:0.3;color:${bodyCol};font-size:${Math.round(9*sc)}px">⠿ 드래그</div>
<div style="padding:0 ${p};overflow:hidden">${bulletHtml(bullets,bodyCol,bs)}</div>
</div>
</div>`;
} else {
// split-right / split-left (이미지 없으면 placeholder)
const imgRight=layout!=='split-left';
const imgW=imgPct+'%';
const txtW=(100-imgPct)+'%';
const bpadx=Math.round(14*sc);
const defTY=titleRaw?0.2:0.067;
const handlePx=Math.round(10*sc);
const ty=Math.max(0,Math.round((ed.text_y!=null?ed.text_y:defTY)*ph)-handlePx);
const textSide=`<div style="width:${txtW};height:100%;position:relative;overflow:hidden;${fontStyle}">
${titleRaw?`<div style="padding:${pt} ${bpadx}px ${Math.round(6*sc)}px;position:absolute;top:0;left:0;right:0">
<div style="font-size:${ts}px;font-weight:700;color:${titleCol};line-height:1.35">${titleHtml}</div>
<div style="height:${Math.round(2*sc)}px;background:${titleCol};opacity:.15;margin-top:${Math.round(6*sc)}px;border-radius:1px"></div>
</div>`:''}
<div id="sed-txt-wrapper" style="position:absolute;top:${ty}px;left:0;right:0;cursor:ns-resize;user-select:none">
<div style="height:${Math.round(10*sc)}px;display:flex;align-items:center;justify-content:center;gap:${Math.round(3*sc)}px;opacity:0.3;color:${bodyCol};font-size:${Math.round(9*sc)}px">⠿ 드래그</div>
<div style="padding:0 ${bpadx}px;overflow:hidden">${bulletHtml(bullets,bodyCol,bs)}</div>
</div>
</div>`;
if(imgUrl){
const defX=imgRight?(1-(imgPct/100)):0;
const defW=imgPct/100;
const defY=0.2; const defH=0.75;
const ix=Math.round((ed.img_x!=null?ed.img_x:defX)*pw);
const iy=Math.round((ed.img_y!=null?ed.img_y:defY)*ph);
const iw=Math.round((ed.img_w!=null?ed.img_w:defW)*pw);
const ih=Math.round((ed.img_h!=null?ed.img_h:defH)*ph);
const H='8px';
const hStyle=`position:absolute;width:${H};height:${H};background:#6366f1;border:1.5px solid #fff;border-radius:2px;z-index:12;box-sizing:border-box`;
const _imgTag=`<img src="${imgUrl}" draggable="false" style="width:100%;height:100%;object-fit:contain;display:block;pointer-events:none" onerror="this.style.display='none'">`;
html=`<div style="position:absolute;inset:0">${textSide}</div>
<div id="sed-img-wrapper" style="position:absolute;left:${ix}px;top:${iy}px;width:${iw}px;height:${ih}px;z-index:10;cursor:move;box-sizing:border-box;border:1.5px dashed rgba(99,102,241,.55)">
${wrapImgFx(_imgTag)}
<div data-r="nw" style="${hStyle};left:-4px;top:-4px;cursor:nw-resize"></div>
<div data-r="ne" style="${hStyle};right:-4px;top:-4px;cursor:ne-resize"></div>
<div data-r="sw" style="${hStyle};left:-4px;bottom:-4px;cursor:sw-resize"></div>
<div data-r="se" style="${hStyle};right:-4px;bottom:-4px;cursor:se-resize"></div>
<div data-r="n" style="${hStyle};left:calc(50% - 4px);top:-4px;cursor:n-resize"></div>
<div data-r="s" style="${hStyle};left:calc(50% - 4px);bottom:-4px;cursor:s-resize"></div>
<div data-r="w" style="${hStyle};left:-4px;top:calc(50% - 4px);cursor:w-resize"></div>
<div data-r="e" style="${hStyle};right:-4px;top:calc(50% - 4px);cursor:e-resize"></div>
</div>`;
} else {
const imgPlaceholder=`<div style="width:${imgW};height:100%;display:flex;align-items:center;justify-content:center;color:${mutC};font-size:${Math.round(22*sc)}px;background:${mutC}18;border:${Math.round(1.5*sc)}px dashed ${mutC}55">🖼</div>`;
html=`<div style="position:absolute;inset:0;display:flex;align-items:stretch">
${imgRight?textSide+imgPlaceholder:imgPlaceholder+textSide}
</div>`;
}
}
} else if(type==='table'||type==='chart'||type==='timeline'){
const ico=type==='table'?'📊':type==='chart'?'📈':'⏳';
html=`<div style="position:absolute;inset:0;display:flex;flex-direction:column;padding:${p}">
<div style="font-size:${ts}px;font-weight:700;color:${txt};line-height:1.35;margin-bottom:${Math.round(12*sc)}px">${titleHtml}</div>
<div style="flex:1;border:${Math.round(2*sc)}px dashed ${mutC};border-radius:${Math.round(6*sc)}px;display:flex;align-items:center;justify-content:center;color:${mutC};font-size:${Math.round(24*sc)}px;gap:${Math.round(8*sc)}px">
${ico} <span style="font-size:${Math.round(13*sc)}px">${type}</span>
</div>
</div>`;
} else {
html=`<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center">
<div style="font-size:${ts}px;font-weight:700;color:${txt};text-align:center;line-height:1.35">${titleHtml||type}</div>
</div>`;
}
prev.innerHTML=html;
// 이미지 드래그 + 리사이즈
const wrapper=prev.querySelector('#sed-img-wrapper');
if(wrapper) attachImgDrag(wrapper, prev, i, pw, ph);
// 텍스트 블록 Y축 드래그
const txWrapper=prev.querySelector('#sed-txt-wrapper');
if(txWrapper) attachTextDrag(txWrapper, prev, i, ph);
}
function attachImgDrag(wrapper, prevEl, slideIdx, pw, ph){
wrapper.addEventListener('mousedown', function(e){
e.preventDefault(); e.stopPropagation();
const dir=e.target.dataset?.r||''; // 핸들이면 방향, 아니면 이동
const startMx=e.clientX, startMy=e.clientY;
let x0=wrapper.offsetLeft, y0=wrapper.offsetTop;
let w0=wrapper.offsetWidth, h0=wrapper.offsetHeight;
const MIN=40;
function onMove(ev){
const dx=ev.clientX-startMx, dy=ev.clientY-startMy;
let x=x0,y=y0,w=w0,h=h0;
if(!dir){ // 이동
x=Math.max(0,Math.min(pw-w0,x0+dx));
y=Math.max(0,Math.min(ph-h0,y0+dy));
} else { // 리사이즈
if(dir.includes('e')){ w=Math.max(MIN,w0+dx); }
if(dir.includes('s')){ h=Math.max(MIN,h0+dy); }
if(dir.includes('w')){ const nw=Math.max(MIN,w0-dx); x=x0+w0-nw; w=nw; }
if(dir.includes('n')){ const nh=Math.max(MIN,h0-dy); y=y0+h0-nh; h=nh; }
// 경계 클램핑
if(x<0){w+=x;x=0;} if(y<0){h+=y;y=0;}
if(x+w>pw) w=pw-x; if(y+h>ph) h=ph-y;
}
wrapper.style.left=x+'px'; wrapper.style.top=y+'px';
wrapper.style.width=w+'px'; wrapper.style.height=h+'px';
}
function onUp(){
document.removeEventListener('mousemove',onMove);
document.removeEventListener('mouseup',onUp);
const ed=outlineEdits[slideIdx]||(outlineEdits[slideIdx]={});
ed.img_x=parseFloat((wrapper.offsetLeft/pw).toFixed(4));
ed.img_y=parseFloat((wrapper.offsetTop/ph).toFixed(4));
ed.img_w=parseFloat((wrapper.offsetWidth/pw).toFixed(4));
ed.img_h=parseFloat((wrapper.offsetHeight/ph).toFixed(4));
sedDirty=true;
}
document.addEventListener('mousemove',onMove);
document.addEventListener('mouseup',onUp);
});
}
function attachTextDrag(txw, prevEl, slideIdx, ph){
txw.addEventListener('mousedown', function(e){
if(e.target.closest('#sed-img-wrapper')) return;
e.preventDefault(); e.stopPropagation();
const startMy=e.clientY, y0=txw.offsetTop;
function onMove(ev){
const y=Math.max(0, Math.min(ph-20, y0+(ev.clientY-startMy)));
txw.style.top=y+'px';
}
function onUp(){
document.removeEventListener('mousemove',onMove);
document.removeEventListener('mouseup',onUp);
const ed=outlineEdits[slideIdx]||(outlineEdits[slideIdx]={});
const _hh=txw.firstElementChild?txw.firstElementChild.offsetHeight:0;
ed.text_y=parseFloat(((txw.offsetTop+_hh)/ph).toFixed(4));
sedDirty=true;
}
document.addEventListener('mousemove',onMove);
document.addEventListener('mouseup',onUp);
});
}
// ── modal click-outside close ────────────────────────────────────────────────
document.getElementById('abg').addEventListener('click',e=>{if(e.target===e.currentTarget)closeAbModal();});
window.addEventListener('resize',()=>{if(editorIdx>=0)sedRenderPreview();});
document.getElementById('ibg').addEventListener('click',e=>{if(e.target===e.currentTarget)closeIModal();});
document.getElementById('slideshow').addEventListener('click',e=>{if(e.target===e.currentTarget)ssClose();});
</script>
</body>
</html>