ginipick commited on
Commit
e9d99da
·
verified ·
1 Parent(s): dfb5abe

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +447 -0
app.py ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.responses import HTMLResponse
3
+ from fastapi.staticfiles import StaticFiles
4
+ import pathlib, os, uvicorn, base64
5
+
6
+ BASE = pathlib.Path(__file__).parent
7
+ app = FastAPI()
8
+ app.mount("/static", StaticFiles(directory=BASE), name="static")
9
+
10
+ # PDF 디렉토리 설정
11
+ PDF_DIR = BASE / "pdf"
12
+ if not PDF_DIR.exists():
13
+ PDF_DIR.mkdir(parents=True)
14
+
15
+ # PDF 파일 목록 가져오기
16
+ def get_pdf_files():
17
+ pdf_files = []
18
+ if PDF_DIR.exists():
19
+ pdf_files = [f for f in PDF_DIR.glob("*.pdf")]
20
+ return pdf_files
21
+
22
+ # PDF 썸네일 생성 및 프로젝트 데이터 준비
23
+ def generate_pdf_projects():
24
+ projects_data = []
25
+ pdf_files = get_pdf_files()
26
+
27
+ for pdf_file in pdf_files:
28
+ projects_data.append({
29
+ "path": str(pdf_file),
30
+ "name": pdf_file.stem
31
+ })
32
+
33
+ return projects_data
34
+
35
+ HTML = """
36
+ <!doctype html><html lang="ko"><head>
37
+ <meta charset="utf-8"><title>FlipBook Space</title>
38
+ <link rel="stylesheet" href="/static/flipbook.css">
39
+ <script src="/static/three.js"></script>
40
+ <script src="/static/iscroll.js"></script>
41
+ <script src="/static/mark.js"></script>
42
+ <script src="/static/mod3d.js"></script>
43
+ <script src="/static/pdf.js"></script>
44
+ <script src="/static/flipbook.js"></script>
45
+ <script src="/static/flipbook.book3.js"></script>
46
+ <script src="/static/flipbook.scroll.js"></script>
47
+ <script src="/static/flipbook.swipe.js"></script>
48
+ <script src="/static/flipbook.webgl.js"></script>
49
+ <style>
50
+ body{margin:0;background:#f0f0f0;font-family:sans-serif}
51
+ header{max-width:960px;margin:0 auto;padding:18px 20px;display:flex;align-items:center}
52
+ #homeBtn{display:none;width:38px;height:38px;border:none;border-radius:50%;cursor:pointer;
53
+ background:#0077c2;color:#fff;font-size:20px;margin-right:12px}
54
+ #homeBtn:hover{background:#005999}
55
+ h2{margin:0;font-size:1.5rem;font-weight:600}
56
+ #home,#viewerPage{max-width:960px;margin:0 auto;padding:0 20px 40px}
57
+ .grid{display:grid;grid-template-columns:repeat(auto-fill,180px);gap:16px;margin-top:24px}
58
+ .card{
59
+ background:#fff url('/static/book2.jpg') no-repeat center center;
60
+ background-size: 169%; /* 배경 이미지를 현재보다 30% 더 키움 (130% * 1.3 = 169%) */
61
+ border:1px solid #ccc;
62
+ border-radius:6px;
63
+ cursor:pointer;
64
+ box-shadow:0 2px 4px rgba(0,0,0,.12);
65
+ width: 180px;
66
+ height: 240px;
67
+ position: relative;
68
+ display: flex;
69
+ flex-direction: column;
70
+ align-items: center;
71
+ justify-content: center;
72
+ }
73
+ .card img{
74
+ width:55%; /* 썸네일 크기 줄임 (배경이 커져서 비율 유지) */
75
+ height:auto;
76
+ object-fit:contain;
77
+ position:absolute; /* 절대 위치로 변경 */
78
+ top:50%; /* 상단에서 50% */
79
+ left:50%; /* 좌측에서 50% */
80
+ transform: translate(-50%, -70%); /* 정중앙에서 20% 위로 이동 */
81
+ border: 1px solid #ddd;
82
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
83
+ }
84
+ .card p{
85
+ text-align:center;
86
+ margin:6px 0;
87
+ position: absolute;
88
+ bottom: 10px;
89
+ left: 50%;
90
+ transform: translateX(-50%);
91
+ background: rgba(255, 255, 255, 0.7);
92
+ padding: 4px 8px;
93
+ border-radius: 4px;
94
+ width: 85%;
95
+ white-space: nowrap;
96
+ overflow: hidden;
97
+ text-overflow: ellipsis;
98
+ max-width: 150px;
99
+ font-size: 11px; /* 기본 16px에서 약 30% 감소 */
100
+ }
101
+ button.upload{all:unset;cursor:pointer;border:1px solid #bbb;padding:8px 14px;border-radius:6px;background:#fff;margin:0 8px}
102
+ #viewer{
103
+ width:80%;
104
+ height:80vh; /* 전체 화면 높이의 80%로 축소 */
105
+ max-width:80%; /* 최대 너비 80%로 제한 */
106
+ margin:0;
107
+ background:#fff;
108
+ border:1px solid #ddd;
109
+ border-radius:8px;
110
+ position:fixed; /* 고정 위치 */
111
+ top:50%;
112
+ left:50%;
113
+ transform:translate(-50%, -50%); /* 중앙 정렬 */
114
+ z-index:1000; /* 최상위 표시 */
115
+ box-shadow:0 4px 20px rgba(0,0,0,0.15);
116
+ }
117
+ </style></head><body>
118
+
119
+ <header>
120
+ <button id="homeBtn" title="홈으로">⌂</button>
121
+ <h2>My FlipBook Projects</h2>
122
+ </header>
123
+
124
+ <section id="home">
125
+ <div>
126
+ <label class="upload">📷 이미지 <input id="imgInput" type="file" accept="image/*" multiple hidden></label>
127
+ <label class="upload">📄 PDF <input id="pdfInput" type="file" accept="application/pdf" hidden></label>
128
+ </div>
129
+ <div class="grid" id="grid"></div>
130
+ </section>
131
+
132
+ <section id="viewerPage" style="display:none">
133
+ <div id="viewer"></div>
134
+ </section>
135
+
136
+ <script>
137
+ let projects=[], fb=null;
138
+ const grid=$id('grid'), viewer=$id('viewer');
139
+ pdfjsLib.GlobalWorkerOptions.workerSrc='/static/pdf.worker.js';
140
+
141
+ // 서버에서 미리 로드된 PDF 프로젝트
142
+ let serverProjects = [];
143
+
144
+ /* 🔊 오디오 unlock – 내장 Audio 와 같은 MP3 경로 사용 */
145
+ ['click','touchstart'].forEach(evt=>{
146
+ document.addEventListener(evt,function u(){new Audio('static/turnPage2.mp3')
147
+ .play().then(a=>a.pause()).catch(()=>{});document.removeEventListener(evt,u,{capture:true});},
148
+ {once:true,capture:true});
149
+ });
150
+
151
+ /* ── 유틸 ── */
152
+ function $id(id){return document.getElementById(id)}
153
+ function addCard(i,thumb,title){
154
+ const d=document.createElement('div');
155
+ d.className='card';
156
+ d.onclick=()=>open(i);
157
+
158
+ // 제목 10글자 제한 및 말줄임표 처리
159
+ const displayTitle = title ?
160
+ (title.length > 10 ? title.substring(0, 10) + '...' : title) :
161
+ '프로젝트 ' + (i+1);
162
+
163
+ d.innerHTML=`<img src="${thumb}"><p title="${title || '프로젝트 ' + (i+1)}">${displayTitle}</p>`;
164
+ grid.appendChild(d);
165
+ }
166
+
167
+ /* ── 이미지 업로드 ── */
168
+ $id('imgInput').onchange=e=>{
169
+ const files=[...e.target.files]; if(!files.length) return;
170
+ const pages=[],tot=files.length;let done=0;
171
+ files.forEach((f,i)=>{const r=new FileReader();r.onload=x=>{pages[i]={src:x.target.result,thumb:x.target.result};
172
+ if(++done===tot) save(pages);};r.readAsDataURL(f);});
173
+ };
174
+
175
+ /* ── PDF 업로드 ── */
176
+ $id('pdfInput').onchange=e=>{
177
+ const file=e.target.files[0]; if(!file) return;
178
+ const fr=new FileReader();
179
+ fr.onload=v=>{
180
+ pdfjsLib.getDocument({data:v.target.result}).promise.then(async pdf=>{
181
+ const pages=[];
182
+ for(let p=1;p<=pdf.numPages;p++){
183
+ const pg=await pdf.getPage(p), vp=pg.getViewport({scale:1});
184
+ const c=document.createElement('canvas');c.width=vp.width;c.height=vp.height;
185
+ await pg.render({canvasContext:c.getContext('2d'),viewport:vp}).promise;
186
+ pages.push({src:c.toDataURL(),thumb:c.toDataURL()});
187
+ }
188
+ save(pages, file.name.replace('.pdf', ''));
189
+ });
190
+ };fr.readAsArrayBuffer(file);
191
+ };
192
+
193
+ /* ── 프로젝트 저장 ── */
194
+ function save(pages, title){
195
+ const id=projects.push(pages)-1;
196
+ addCard(id,pages[0].thumb, title);
197
+ }
198
+
199
+ /* ── 서버 PDF 로드 ── */
200
+ async function loadServerPDFs() {
201
+ try {
202
+ const response = await fetch('/api/pdf-projects');
203
+ serverProjects = await response.json();
204
+
205
+ // 서버 PDF 로드 및 썸네일 생성
206
+ for(let i = 0; i < serverProjects.length; i++) {
207
+ const project = serverProjects[i];
208
+ const response = await fetch(`/api/pdf-thumbnail?path=${encodeURIComponent(project.path)}`);
209
+ const data = await response.json();
210
+
211
+ if(data.thumbnail) {
212
+ const pages = [{
213
+ src: data.thumbnail,
214
+ thumb: data.thumbnail,
215
+ path: project.path
216
+ }];
217
+
218
+ save(pages, project.name);
219
+ }
220
+ }
221
+ } catch(error) {
222
+ console.error('서버 PDF 로드 실패:', error);
223
+ }
224
+ }
225
+
226
+ /* ── 카드 → FlipBook ── */
227
+ function open(i){
228
+ toggle(false);
229
+ const pages = projects[i];
230
+
231
+ // 로컬 프로젝트 또는 서버 PDF 로드
232
+ if(fb){fb.destroy();viewer.innerHTML='';}
233
+
234
+ if(pages[0].path) {
235
+ // 로딩 표시
236
+ viewer.innerHTML = '<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;"><div style="border:4px solid #f3f3f3;border-top:4px solid #3498db;border-radius:50%;width:50px;height:50px;margin:0 auto;animation:spin 2s linear infinite;"></div><p style="margin-top:20px;font-size:16px;">PDF 로딩 중...</p></div>';
237
+
238
+ // 스타일 추가
239
+ if (!document.getElementById('loadingStyle')) {
240
+ const style = document.createElement('style');
241
+ style.id = 'loadingStyle';
242
+ style.textContent = '@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}';
243
+ document.head.appendChild(style);
244
+ }
245
+
246
+ // 서버 PDF 파일 로드
247
+ fetch(`/api/pdf-content?path=${encodeURIComponent(pages[0].path)}`)
248
+ .then(response => {
249
+ if (!response.ok) {
250
+ throw new Error('PDF 로드 실패: ' + response.statusText);
251
+ }
252
+ return response.arrayBuffer();
253
+ })
254
+ .then(pdfData => {
255
+ // PDF 데이터 로드 확인 로깅
256
+ console.log('PDF 데이터 로드 완료:', pdfData.byteLength + ' 바이트');
257
+
258
+ return pdfjsLib.getDocument({data: pdfData}).promise;
259
+ })
260
+ .then(async pdf => {
261
+ console.log('PDF 문서 로드 완료. 페이지 수:', pdf.numPages);
262
+
263
+ const pdfPages = [];
264
+ const progressElement = viewer.querySelector('p');
265
+
266
+ for(let p = 1; p <= pdf.numPages; p++) {
267
+ if (progressElement) {
268
+ progressElement.textContent = `PDF 페이지 로딩 중... (${p}/${pdf.numPages})`;
269
+ }
270
+
271
+ try {
272
+ const pg = await pdf.getPage(p);
273
+ const vp = pg.getViewport({scale: 1});
274
+ const c = document.createElement('canvas');
275
+ c.width = vp.width;
276
+ c.height = vp.height;
277
+
278
+ await pg.render({canvasContext: c.getContext('2d'), viewport: vp}).promise;
279
+ pdfPages.push({src: c.toDataURL(), thumb: c.toDataURL()});
280
+ } catch (pageError) {
281
+ console.error(`페이지 ${p} 렌더링 오류:`, pageError);
282
+ }
283
+ }
284
+
285
+ console.log('모든 페이지 렌더링 완료:', pdfPages.length);
286
+
287
+ if (pdfPages.length > 0) {
288
+ createFlipBook(pdfPages);
289
+ } else {
290
+ throw new Error('PDF에서 페이지를 추출할 수 없습니다.');
291
+ }
292
+ })
293
+ .catch(error => {
294
+ console.error('PDF 처리 중 오류 발생:', error);
295
+ viewer.innerHTML = `<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);text-align:center;"><p style="color:red;font-size:16px;">PDF를 로드하는 중 오류가 발생했습니다:<br>${error.message}</p><button id="backBtn" style="margin-top:20px;padding:10px 20px;background:#0077c2;color:white;border:none;border-radius:4px;cursor:pointer;">홈으로 돌아가기</button></div>`;
296
+
297
+ document.getElementById('backBtn').addEventListener('click', function() {
298
+ toggle(true);
299
+ });
300
+ });
301
+ } else {
302
+ // 업로드된 프로젝트 보기
303
+ console.log('로컬 업로드된 프로젝트 렌더링:', pages.length + '페이지');
304
+ createFlipBook(pages);
305
+ }
306
+ }
307
+
308
+ function createFlipBook(pages) {
309
+ console.log('FlipBook 생성 시작. 페이지 수:', pages.length);
310
+
311
+ try {
312
+ fb = new FlipBook(viewer, {
313
+ pages: pages,
314
+ viewMode: 'webgl',
315
+ autoSize: true,
316
+ flipDuration: 800,
317
+ backgroundColor: '#fff',
318
+ /* 🔊 내장 사운드 */
319
+ sound: true,
320
+ assets: {flipMp3: 'static/turnPage2.mp3', hardFlipMp3: 'static/turnPage2.mp3'},
321
+ controlsProps: {enableFullscreen: true, thumbnails: true}
322
+ });
323
+
324
+ console.log('FlipBook 생성 완료');
325
+ } catch (error) {
326
+ console.error('FlipBook 생성 중 오류 발생:', error);
327
+ alert('FlipBook을 생성하는 중 오류가 발생했습니다: ' + error.message);
328
+ }
329
+ }
330
+
331
+ /* ── 네비게이션 ── */
332
+ $id('homeBtn').onclick=()=>{
333
+ if(fb) {
334
+ fb.destroy();
335
+ viewer.innerHTML = '';
336
+ fb = null;
337
+ }
338
+ toggle(true);
339
+ };
340
+
341
+ function toggle(showHome){
342
+ $id('home').style.display=showHome?'block':'none';
343
+ $id('viewerPage').style.display=showHome?'none':'block';
344
+ $id('homeBtn').style.display=showHome?'none':'inline-block';
345
+
346
+ // 추가: 전체 화면 모드에서 homeBtn 위치 조정
347
+ if(!showHome) {
348
+ $id('homeBtn').style.position = 'fixed';
349
+ $id('homeBtn').style.top = '20px';
350
+ $id('homeBtn').style.left = '20px';
351
+ $id('homeBtn').style.zIndex = '1001'; // 뷰어보다 높은 z-index
352
+ $id('homeBtn').style.fontSize = '24px'; // 크기 증가
353
+ $id('homeBtn').style.width = '48px';
354
+ $id('homeBtn').style.height = '48px';
355
+
356
+ // 배경 오버레이 추가
357
+ document.body.style.backgroundColor = '#3a3a3a';
358
+ } else {
359
+ $id('homeBtn').style.position = '';
360
+ $id('homeBtn').style.top = '';
361
+ $id('homeBtn').style.left = '';
362
+ $id('homeBtn').style.zIndex = '';
363
+ $id('homeBtn').style.fontSize = '';
364
+ $id('homeBtn').style.width = '';
365
+ $id('homeBtn').style.height = '';
366
+
367
+ // 배경 원래대로
368
+ document.body.style.backgroundColor = '#f0f0f0';
369
+ }
370
+ }
371
+
372
+ // 페이지 로드 시 서버 PDF 로드
373
+ window.addEventListener('DOMContentLoaded', loadServerPDFs);
374
+ </script>
375
+ </body></html>
376
+ """
377
+
378
+ # API 엔드포인트: PDF 프로젝트 목록
379
+ @app.get("/api/pdf-projects")
380
+ async def get_pdf_projects():
381
+ return generate_pdf_projects()
382
+
383
+ # API 엔드포인트: PDF 썸네일 생성
384
+ @app.get("/api/pdf-thumbnail")
385
+ async def get_pdf_thumbnail(path: str):
386
+ try:
387
+ import fitz # PyMuPDF
388
+
389
+ # PDF 파일 열기
390
+ doc = fitz.open(path)
391
+
392
+ # 첫 페이지 가져오기
393
+ if doc.page_count > 0:
394
+ page = doc[0]
395
+ # 썸네일용 이미지 렌더링 (해상도 조정)
396
+ pix = page.get_pixmap(matrix=fitz.Matrix(0.5, 0.5))
397
+ img_data = pix.tobytes("png")
398
+
399
+ # Base64 인코딩
400
+ b64_img = base64.b64encode(img_data).decode('utf-8')
401
+ return {"thumbnail": f"data:image/png;base64,{b64_img}"}
402
+
403
+ return {"thumbnail": None}
404
+ except Exception as e:
405
+ return {"error": str(e), "thumbnail": None}
406
+
407
+ @app.get("/api/pdf-content")
408
+ async def get_pdf_content(path: str):
409
+ try:
410
+ # 파일 존재 여부 확인
411
+ pdf_path = pathlib.Path(path)
412
+ if not pdf_path.exists():
413
+ return {"error": f"파일을 찾을 수 없습니다: {path}"}, 404
414
+
415
+ # 파일 읽기
416
+ with open(path, "rb") as pdf_file:
417
+ content = pdf_file.read()
418
+
419
+ # 파일명 처리 - URL 인코딩으로 한글 등 특수 문자 처리
420
+ import urllib.parse
421
+ filename = pdf_path.name
422
+ encoded_filename = urllib.parse.quote(filename)
423
+
424
+ # 응답 헤더 설정 - RFC 6266 표준 사용
425
+ headers = {
426
+ "Content-Type": "application/pdf",
427
+ "Content-Disposition": f"inline; filename=\"{encoded_filename}\"; filename*=UTF-8''{encoded_filename}"
428
+ }
429
+
430
+ # 파일 콘텐츠 직접 반환 (dict가 아닌 Response 객체)
431
+ from fastapi.responses import Response
432
+ return Response(content=content, media_type="application/pdf")
433
+ except Exception as e:
434
+ import traceback
435
+ error_details = traceback.format_exc()
436
+ print(f"PDF 콘텐츠 로드 오류: {str(e)}\n{error_details}")
437
+
438
+ # 오류 응답 반환 (JSON 형식)
439
+ from fastapi.responses import JSONResponse
440
+ return JSONResponse(content={"error": str(e)}, status_code=500)
441
+
442
+ @app.get("/", response_class=HTMLResponse)
443
+ async def root():
444
+ return HTML
445
+
446
+ if __name__ == "__main__":
447
+ uvicorn.run("app:app", host="0.0.0.0", port=int(os.getenv("PORT", 7860)))