zhoucantd commited on
Commit
08e75d5
·
verified ·
1 Parent(s): 62d6aa4

Upload 9 files

Browse files
Files changed (9) hide show
  1. .env.local +1 -0
  2. .gitignore +21 -20
  3. App.tsx +646 -0
  4. index.html +38 -0
  5. index.tsx +16 -0
  6. metadata.json +5 -0
  7. package.json +15 -32
  8. tsconfig.json +29 -0
  9. vite.config.ts +23 -0
.env.local ADDED
@@ -0,0 +1 @@
 
 
1
+ GEMINI_API_KEY=PLACEHOLDER_API_KEY
.gitignore CHANGED
@@ -1,23 +1,24 @@
1
- # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
-
3
- # dependencies
4
- /node_modules
5
- /.pnp
6
- .pnp.js
7
-
8
- # testing
9
- /coverage
10
-
11
- # production
12
- /build
13
-
14
- # misc
15
- .DS_Store
16
- .env.local
17
- .env.development.local
18
- .env.test.local
19
- .env.production.local
20
-
21
  npm-debug.log*
22
  yarn-debug.log*
23
  yarn-error.log*
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  npm-debug.log*
5
  yarn-debug.log*
6
  yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
App.tsx ADDED
@@ -0,0 +1,646 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
3
+ import { PDFDocument } from 'pdf-lib';
4
+ import * as pdfjsLib from 'pdfjs-dist';
5
+ import type { PageViewport } from 'pdfjs-dist';
6
+
7
+ // Configure pdf.js worker to run in the background
8
+ pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.worker.min.mjs`;
9
+
10
+ // --- Translations ---
11
+ const translations = {
12
+ en: {
13
+ title: "PDF Booklet Arranger",
14
+ subtitle: "Reorder your PDF pages for easy booklet printing.",
15
+ errorTitle: "Error",
16
+ errorInvalidFile: "Please upload a valid PDF file.",
17
+ errorReadPdf: "Could not read PDF. It might be corrupted or protected.",
18
+ errorProcessPdf: "Failed to process the PDF. It might be corrupted or protected.",
19
+ uploadButton: "Click to upload",
20
+ uploadOrDrag: "or drag and drop",
21
+ uploadHint: "PDF files only",
22
+ fileLabel: "File:",
23
+ pagesLabel: "Pages:",
24
+ frontCoverTitle: "Front Cover",
25
+ backCoverTitle: "Back Cover",
26
+ coverFirstPage: "First Page (Default)",
27
+ coverLastPage: "Last Page (Default)",
28
+ coverBlankPage: "Blank Page",
29
+ coverCustomPage: "Custom Page",
30
+ generateButton: "Generate Booklet",
31
+ loadingReadingInfo: "Reading PDF info...",
32
+ loadingInitializing: "Initializing...",
33
+ loadingCalculating: "Calculating layout...",
34
+ loadingReadingFile: "Reading file...",
35
+ loadingCopying: (count: number) => `Copying ${count} pages...`,
36
+ loadingSaving: "Saving new PDF...",
37
+ loadingCreatingLink: "Creating download link...",
38
+ loadingComplete: "Complete!",
39
+ successTitle: "Success!",
40
+ successMessage: (padded: number, original: number) => `Your booklet is ready. It has <strong>${padded} pages</strong> (originally ${original}).`,
41
+ printInstructions: "Print double-sided (flip on short edge) with 2 pages per sheet. Fold and staple.",
42
+ downloadButton: "Download Booklet PDF",
43
+ startOverButton: "Start Over",
44
+ privacyFooter: "PDF processing is done locally in your browser. Your files are never uploaded.",
45
+ previewTitle: "PDF Preview",
46
+ previewLoading: "Loading preview...",
47
+ previewError: "Could not display PDF preview.",
48
+ previewPageLabel: (num: number) => `Page ${num}`,
49
+ },
50
+ zh: {
51
+ title: "PDF 手册排版工具",
52
+ subtitle: "重新排列您的 PDF 页面,以便轻松打印成小册子。",
53
+ errorTitle: "错误",
54
+ errorInvalidFile: "请上传有效的 PDF 文件。",
55
+ errorReadPdf: "无法读取 PDF。文件可能已损坏或受保护。",
56
+ errorProcessPdf: "处理 PDF 失败。文件可能已损坏或受保护。",
57
+ uploadButton: "点击上传",
58
+ uploadOrDrag: "或拖放文件",
59
+ uploadHint: "仅限 PDF 文件",
60
+ fileLabel: "文件名:",
61
+ pagesLabel: "页数:",
62
+ frontCoverTitle: "封面",
63
+ backCoverTitle: "封底",
64
+ coverFirstPage: "第一页 (默认)",
65
+ coverLastPage: "最后一页 (默认)",
66
+ coverBlankPage: "空白页",
67
+ coverCustomPage: "自定义页面",
68
+ generateButton: "生成手册",
69
+ loadingReadingInfo: "正在读取 PDF 信息...",
70
+ loadingInitializing: "正在初始化...",
71
+ loadingCalculating: "正在计算布局...",
72
+ loadingReadingFile: "正在读取文件...",
73
+ loadingCopying: (count: number) => `正在复制 ${count} 页...`,
74
+ loadingSaving: "正在保存新的 PDF...",
75
+ loadingCreatingLink: "正在创建下载链接...",
76
+ loadingComplete: "完成!",
77
+ successTitle: "成功!",
78
+ successMessage: (padded: number, original: number) => `您的手册已准备就绪。共 <strong>${padded} 页</strong> (原文件 ${original} 页)。`,
79
+ printInstructions: "请使用双面打印(短边翻转),每张纸打印2页。然后对折并装订。",
80
+ downloadButton: "下载手册 PDF",
81
+ startOverButton: "重新开始",
82
+ privacyFooter: "PDF 处理在您的浏览器本地完成,您的文件不会被上传。",
83
+ previewTitle: "PDF 预览",
84
+ previewLoading: "正在加载预览...",
85
+ previewError: "无法显示 PDF 预览。",
86
+ previewPageLabel: (num: number) => `第 ${num} 页`,
87
+ },
88
+ };
89
+
90
+ type TranslationKey = keyof typeof translations['en'];
91
+
92
+ // --- Helper & Icon Components ---
93
+
94
+ const BookIcon = ({ className }: { className?: string }) => (
95
+ <svg xmlns="http://www.w3.org/2000/svg" className={className} viewBox="0 0 24 24" fill="currentColor">
96
+ <path d="M2 3.5A1.5 1.5 0 0 1 3.5 2H12v1.25a.75.75 0 0 0 1.5 0V2h6.5A1.5 1.5 0 0 1 21.5 3.5v17a1.5 1.5 0 0 1-1.5 1.5H13.5v-1.25a.75.75 0 0 0-1.5 0V22H3.5A1.5 1.5 0 0 1 2 20.5v-17ZM12 4.27v15.46a.75.75 0 0 0 .75.75h6.25a.75.75 0 0 0 .75-.75V4.27a3.248 3.248 0 0 0-3.03-.22.75.75 0 0 0-.44 0A3.248 3.248 0 0 0 12 4.27Z" />
97
+ </svg>
98
+ );
99
+
100
+ const UploadIcon = ({ className }: { className?: string }) => (
101
+ <svg xmlns="http://www.w3.org/2000/svg" className={className} viewBox="0 0 24 24" fill="currentColor">
102
+ <path d="M10.707 2.293a1 1 0 0 1 1.414 0l6 6a1 1 0 0 1-1.414 1.414L13 6.414V17a1 1 0 1 1-2 0V6.414L7.707 9.707a1 1 0 0 1-1.414-1.414l4-4Z" />
103
+ <path d="M5 19a1 1 0 0 1-1-1v-4a1 1 0 1 1 2 0v4a1 1 0 0 1-1 1Zm14 0a1 1 0 0 1-1-1v-4a1 1 0 1 1 2 0v4a1 1 0 0 1-1 1Z" />
104
+ </svg>
105
+ );
106
+
107
+ const DownloadIcon = ({ className }: { className?: string }) => (
108
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className={className}>
109
+ <path fillRule="evenodd" d="M12 2.25a.75.75 0 0 1 .75.75v11.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 1 1 1.06-1.06l3.22 3.22V3a.75.75 0 0 1 .75-.75Zm-9 13.5a.75.75 0 0 1 .75.75v2.25a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5V16.5a.75.75 0 0 1 1.5 0v2.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V16.5a.75.75 0 0 1 .75-.75Z" clipRule="evenodd" />
110
+ </svg>
111
+ );
112
+
113
+ const ProgressBar = ({ progress, message }: { progress: number; message: string }) => (
114
+ <div className="w-full max-w-sm mx-auto" aria-live="polite">
115
+ <div className="flex justify-between mb-1">
116
+ <span className="text-sm font-medium text-slate-700 dark:text-slate-300">{message}</span>
117
+ <span className="text-sm font-medium text-slate-700 dark:text-slate-300">{progress}%</span>
118
+ </div>
119
+ <div className="w-full bg-slate-200 rounded-full h-2.5 dark:bg-slate-700">
120
+ <div
121
+ className="bg-primary-600 h-2.5 rounded-full transition-all duration-300 ease-linear"
122
+ style={{ width: `${progress}%` }}
123
+ role="progressbar"
124
+ aria-valuenow={progress}
125
+ aria-valuemin={0}
126
+ aria-valuemax={100}
127
+ ></div>
128
+ </div>
129
+ </div>
130
+ );
131
+
132
+ // --- PDF Preview Component ---
133
+ const PDFPreview = ({ file, t }: { file: File | null, t: (key: TranslationKey, ...args: any[]) => string }) => {
134
+ const containerRef = useRef<HTMLDivElement>(null);
135
+ const [isRendering, setIsRendering] = useState(true);
136
+
137
+ useEffect(() => {
138
+ if (!file) return;
139
+
140
+ const renderPdf = async () => {
141
+ setIsRendering(true);
142
+ const container = containerRef.current;
143
+ if (!container) return;
144
+
145
+ // Clear previous preview
146
+ container.innerHTML = '';
147
+
148
+ try {
149
+ const arrayBuffer = await file.arrayBuffer();
150
+ const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
151
+ const pdf = await loadingTask.promise;
152
+ const numPages = pdf.numPages;
153
+
154
+ for (let pageNum = 1; pageNum <= numPages; pageNum++) {
155
+ const page = await pdf.getPage(pageNum);
156
+ const scale = 0.4; // Adjust scale for thumbnail size
157
+ const viewport = page.getViewport({ scale });
158
+
159
+ const pageWrapper = document.createElement('div');
160
+ pageWrapper.className = 'flex flex-col items-center flex-shrink-0';
161
+
162
+ const canvas = document.createElement('canvas');
163
+ canvas.className = 'rounded-md shadow-lg border border-slate-300 dark:border-slate-600';
164
+ const context = canvas.getContext('2d');
165
+
166
+ canvas.height = viewport.height;
167
+ canvas.width = viewport.width;
168
+
169
+ const pageLabel = document.createElement('p');
170
+ pageLabel.textContent = t('previewPageLabel', pageNum);
171
+ pageLabel.className = 'text-xs mt-2 text-slate-600 dark:text-slate-400 font-medium';
172
+
173
+ if (context) {
174
+ // FIX: The type definitions for pdfjs-dist in this environment seem to incorrectly require
175
+ // a 'canvas' property on the RenderParameters object. Adding it to satisfy the type checker.
176
+ const renderContext = {
177
+ canvasContext: context,
178
+ viewport: viewport
179
+ };
180
+ await page.render(renderContext as any).promise;
181
+ }
182
+
183
+ pageWrapper.appendChild(canvas);
184
+ pageWrapper.appendChild(pageLabel);
185
+ container.appendChild(pageWrapper);
186
+ }
187
+ } catch (error) {
188
+ console.error('Failed to render PDF preview:', error);
189
+ container.innerHTML = `<p class="text-red-500">${t('previewError')}</p>`;
190
+ } finally {
191
+ setIsRendering(false);
192
+ }
193
+ };
194
+
195
+ renderPdf();
196
+ }, [file, t]);
197
+
198
+ if (!file) return null;
199
+
200
+ return (
201
+ <div className="mt-6">
202
+ <h3 className="font-semibold text-slate-800 dark:text-slate-200 mb-3 text-left">{t('previewTitle')}</h3>
203
+ {isRendering && <p className="text-slate-500 dark:text-slate-400 text-sm">{t('previewLoading')}</p>}
204
+ <div
205
+ ref={containerRef}
206
+ className="flex space-x-4 overflow-x-auto bg-slate-100 dark:bg-slate-900/50 p-4 rounded-lg border border-slate-200 dark:border-slate-700 min-h-[200px] items-center"
207
+ aria-label="PDF page preview"
208
+ >
209
+ </div>
210
+ </div>
211
+ );
212
+ };
213
+
214
+
215
+ type CoverOption = 'first' | 'last' | 'blank' | 'custom';
216
+
217
+ const App: React.FC = () => {
218
+ const [file, setFile] = useState<File | null>(null);
219
+ const [isLoading, setIsLoading] = useState<boolean>(false);
220
+ const [error, setError] = useState<string | null>(null);
221
+ const [processedUrl, setProcessedUrl] = useState<string | null>(null);
222
+ const [dragActive, setDragActive] = useState<boolean>(false);
223
+ const [pages, setPages] = useState<{ original: number; padded: number } | null>(null);
224
+ const [progress, setProgress] = useState<number>(0);
225
+ const [loadingMessage, setLoadingMessage] = useState<string>('');
226
+ const [originalPageCount, setOriginalPageCount] = useState<number | null>(null);
227
+
228
+ const [coverOption, setCoverOption] = useState<CoverOption>('first');
229
+ const [coverPage, setCoverPage] = useState<number>(1);
230
+ const [backCoverOption, setBackCoverOption] = useState<CoverOption>('last');
231
+ const [backCoverPage, setBackCoverPage] = useState<number>(1);
232
+
233
+ const [language, setLanguage] = useState<'en' | 'zh'>('en');
234
+
235
+ const inputRef = useRef<HTMLInputElement>(null);
236
+
237
+ const t = useCallback((key: TranslationKey, ...args: any[]): string => {
238
+ const translation = translations[language][key];
239
+ if (typeof translation === 'function') {
240
+ return (translation as (...args: any[]) => string)(...args);
241
+ }
242
+ return translation;
243
+ }, [language]);
244
+
245
+ useEffect(() => {
246
+ if (originalPageCount) {
247
+ setBackCoverPage(originalPageCount);
248
+ }
249
+ }, [originalPageCount]);
250
+
251
+
252
+ const resetState = () => {
253
+ setFile(null);
254
+ setIsLoading(false);
255
+ setError(null);
256
+ if (processedUrl) {
257
+ URL.revokeObjectURL(processedUrl);
258
+ }
259
+ setProcessedUrl(null);
260
+ setPages(null);
261
+ setProgress(0);
262
+ setLoadingMessage('');
263
+ setOriginalPageCount(null);
264
+ setCoverOption('first');
265
+ setBackCoverOption('last');
266
+ setCoverPage(1);
267
+ setBackCoverPage(1);
268
+ if(inputRef.current) inputRef.current.value = '';
269
+ };
270
+
271
+ const handleFileChange = async (selectedFile: File | null) => {
272
+ resetState();
273
+ if (selectedFile) {
274
+ if (selectedFile.type !== 'application/pdf') {
275
+ setError(t('errorInvalidFile'));
276
+ return;
277
+ }
278
+ setFile(selectedFile);
279
+ setIsLoading(true);
280
+ setLoadingMessage(t('loadingReadingInfo'));
281
+ setProgress(50);
282
+ try {
283
+ const arrayBuffer = await selectedFile.arrayBuffer();
284
+ const pdf = await PDFDocument.load(arrayBuffer);
285
+ const count = pdf.getPageCount();
286
+ setOriginalPageCount(count);
287
+ setBackCoverPage(count);
288
+ } catch (e) {
289
+ setError(t('errorReadPdf'));
290
+ setOriginalPageCount(null);
291
+ setFile(null);
292
+ } finally {
293
+ setIsLoading(false);
294
+ setProgress(0);
295
+ setLoadingMessage('');
296
+ }
297
+ }
298
+ };
299
+
300
+ const handleDrag = (e: React.DragEvent) => {
301
+ e.preventDefault();
302
+ e.stopPropagation();
303
+ if (e.type === 'dragenter' || e.type === 'dragover') {
304
+ setDragActive(true);
305
+ } else if (e.type === 'dragleave') {
306
+ setDragActive(false);
307
+ }
308
+ };
309
+
310
+ const handleDrop = (e: React.DragEvent) => {
311
+ e.preventDefault();
312
+ e.stopPropagation();
313
+ setDragActive(false);
314
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
315
+ handleFileChange(e.dataTransfer.files[0]);
316
+ }
317
+ };
318
+
319
+ const onButtonClick = () => {
320
+ inputRef.current?.click();
321
+ };
322
+
323
+ const arrangeForBooklet = useCallback(async () => {
324
+ if (!file || !originalPageCount) return;
325
+
326
+ setIsLoading(true);
327
+ setError(null);
328
+ setProcessedUrl(null);
329
+ setPages(null);
330
+ setProgress(0);
331
+ setLoadingMessage(t('loadingInitializing'));
332
+
333
+ try {
334
+ await new Promise(resolve => setTimeout(resolve, 100));
335
+
336
+ // 1. Determine Cover and Back Cover sources
337
+ let coverSource: number | 'BLANK' = 'BLANK';
338
+ if (coverOption === 'first') coverSource = 0;
339
+ else if (coverOption === 'custom') coverSource = coverPage - 1;
340
+
341
+ let backCoverSource: number | 'BLANK' = 'BLANK';
342
+ if (backCoverOption === 'last') backCoverSource = originalPageCount - 1;
343
+ else if (backCoverOption === 'custom') backCoverSource = backCoverPage - 1;
344
+
345
+ // 2. Get body pages by excluding cover/back pages
346
+ const sourcePageIndexes = [...Array(originalPageCount).keys()];
347
+ const usedIndexes = new Set<number>();
348
+ if (typeof coverSource === 'number') usedIndexes.add(coverSource);
349
+ if (typeof backCoverSource === 'number') usedIndexes.add(backCoverSource);
350
+
351
+ const bodyPages = sourcePageIndexes.filter(p => !usedIndexes.has(p));
352
+
353
+ // 3. Assemble the content sequence in reading order
354
+ const contentSequence: (number | 'BLANK')[] = [coverSource, ...bodyPages, backCoverSource];
355
+ const numContentPages = contentSequence.length;
356
+
357
+ setProgress(10);
358
+ setLoadingMessage(t('loadingCalculating'));
359
+
360
+ // 4. Pad the sequence to be a multiple of 4, with special handling for (4n-2) cases
361
+ const paddedPageCount = Math.ceil(numContentPages / 4) * 4;
362
+ setPages({ original: originalPageCount, padded: paddedPageCount });
363
+
364
+ let finalSequence: (number | 'BLANK')[] = [];
365
+
366
+ if (numContentPages > 0 && numContentPages % 4 === 2) {
367
+ // Special case: Add blanks on inside of cover/back-cover sheet
368
+ finalSequence = new Array(paddedPageCount).fill('BLANK');
369
+ const body = contentSequence.slice(1, -1);
370
+ finalSequence[0] = contentSequence[0]; // Cover page
371
+ finalSequence[finalSequence.length - 1] = contentSequence[contentSequence.length - 1]; // Back cover
372
+ finalSequence.splice(2, body.length, ...body);
373
+ } else {
374
+ // Normal case: Pad with blanks at the end of the body
375
+ const blanksToAdd = paddedPageCount - numContentPages;
376
+ const padding = new Array(blanksToAdd).fill('BLANK');
377
+ const body = contentSequence.slice(1, -1);
378
+ finalSequence = [
379
+ contentSequence[0],
380
+ ...body,
381
+ ...padding,
382
+ contentSequence[contentSequence.length - 1]
383
+ ];
384
+ if (numContentPages === 0) finalSequence = new Array(paddedPageCount).fill('BLANK');
385
+ if (numContentPages === 1) finalSequence = [contentSequence[0], ...new Array(paddedPageCount-1).fill('BLANK')]
386
+ }
387
+
388
+ setProgress(25);
389
+ setLoadingMessage(t('loadingReadingFile'));
390
+ const arrayBuffer = await file.arrayBuffer();
391
+ const originalPdf = await PDFDocument.load(arrayBuffer);
392
+
393
+ // 5. Create new PDF and re-order pages
394
+ const newPdf = await PDFDocument.create();
395
+ const { width, height } = originalPdf.getPage(0).getSize();
396
+
397
+ // Corrected imposition logic to create the print-ready page sequence.
398
+ const reorderedPages: (number | 'BLANK')[] = [];
399
+ let low = 0;
400
+ let high = paddedPageCount - 1;
401
+ while(low < high) {
402
+ reorderedPages.push(finalSequence[high--]); // e.g., Page 8
403
+ reorderedPages.push(finalSequence[low++]); // e.g., Page 1
404
+ }
405
+
406
+ setProgress(40);
407
+ setLoadingMessage(t('loadingCopying', paddedPageCount));
408
+
409
+ for (let i = 0; i < reorderedPages.length; i++) {
410
+ const pageSource = reorderedPages[i];
411
+ if (pageSource === 'BLANK' || pageSource === undefined) {
412
+ newPdf.addPage([width, height]);
413
+ } else {
414
+ const [copiedPage] = await newPdf.copyPages(originalPdf, [pageSource]);
415
+ newPdf.addPage(copiedPage);
416
+ }
417
+ const loopProgress = 40 + Math.round(((i + 1) / reorderedPages.length) * 50);
418
+ setProgress(loopProgress);
419
+ }
420
+
421
+ setProgress(95);
422
+ setLoadingMessage(t('loadingSaving'));
423
+ const pdfBytes = await newPdf.save();
424
+
425
+ setProgress(98);
426
+ setLoadingMessage(t('loadingCreatingLink'));
427
+ const blob = new Blob([pdfBytes], { type: 'application/pdf' });
428
+ const url = URL.createObjectURL(blob);
429
+ setProcessedUrl(url);
430
+ setProgress(100);
431
+ setLoadingMessage(t('loadingComplete'));
432
+
433
+ } catch (err) {
434
+ console.error(err);
435
+ setError(t('errorProcessPdf'));
436
+ setProgress(0);
437
+ setLoadingMessage('');
438
+ } finally {
439
+ setIsLoading(false);
440
+ }
441
+ }, [file, originalPageCount, coverOption, coverPage, backCoverOption, backCoverPage, t]);
442
+
443
+ const renderCoverOptions = () => {
444
+ if (!originalPageCount) return null;
445
+
446
+ return (
447
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-left border-t border-slate-200 dark:border-slate-700 pt-6">
448
+ {/* Front Cover Options */}
449
+ <div>
450
+ <h3 className="font-semibold text-slate-800 dark:text-slate-200 mb-2">{t('frontCoverTitle')}</h3>
451
+ <div className="space-y-2">
452
+ <label className="flex items-center space-x-2">
453
+ <input type="radio" name="cover" checked={coverOption === 'first'} onChange={() => setCoverOption('first')} className="form-radio text-primary-600" />
454
+ <span>{t('coverFirstPage')}</span>
455
+ </label>
456
+ <label className="flex items-center space-x-2">
457
+ <input type="radio" name="cover" checked={coverOption === 'blank'} onChange={() => setCoverOption('blank')} className="form-radio text-primary-600"/>
458
+ <span>{t('coverBlankPage')}</span>
459
+ </label>
460
+ <label className="flex items-center space-x-2">
461
+ <input type="radio" name="cover" checked={coverOption === 'custom'} onChange={() => setCoverOption('custom')} className="form-radio text-primary-600"/>
462
+ <span>{t('coverCustomPage')}</span>
463
+ </label>
464
+ {coverOption === 'custom' && (
465
+ <div className="pl-6">
466
+ <input
467
+ type="number"
468
+ min="1"
469
+ max={originalPageCount}
470
+ value={coverPage}
471
+ onChange={(e) => setCoverPage(Math.max(1, Math.min(originalPageCount, parseInt(e.target.value, 10) || 1)))}
472
+ className="mt-1 block w-24 rounded-md border-slate-300 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200"
473
+ aria-label="Custom front cover page number"
474
+ />
475
+ </div>
476
+ )}
477
+ </div>
478
+ </div>
479
+
480
+ {/* Back Cover Options */}
481
+ <div>
482
+ <h3 className="font-semibold text-slate-800 dark:text-slate-200 mb-2">{t('backCoverTitle')}</h3>
483
+ <div className="space-y-2">
484
+ <label className="flex items-center space-x-2">
485
+ <input type="radio" name="backCover" checked={backCoverOption === 'last'} onChange={() => setBackCoverOption('last')} className="form-radio text-primary-600" />
486
+ <span>{t('coverLastPage')}</span>
487
+ </label>
488
+ <label className="flex items-center space-x-2">
489
+ <input type="radio" name="backCover" checked={backCoverOption === 'blank'} onChange={() => setBackCoverOption('blank')} className="form-radio text-primary-600"/>
490
+ <span>{t('coverBlankPage')}</span>
491
+ </label>
492
+ <label className="flex items-center space-x-2">
493
+ <input type="radio" name="backCover" checked={backCoverOption === 'custom'} onChange={() => setBackCoverOption('custom')} className="form-radio text-primary-600"/>
494
+ <span>{t('coverCustomPage')}</span>
495
+ </label>
496
+ {backCoverOption === 'custom' && (
497
+ <div className="pl-6">
498
+ <input
499
+ type="number"
500
+ min="1"
501
+ max={originalPageCount}
502
+ value={backCoverPage}
503
+ onChange={(e) => setBackCoverPage(Math.max(1, Math.min(originalPageCount, parseInt(e.target.value, 10) || 1)))}
504
+ className="mt-1 block w-24 rounded-md border-slate-300 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50 dark:bg-slate-800 dark:border-slate-600 dark:text-slate-200"
505
+ aria-label="Custom back cover page number"
506
+ />
507
+ </div>
508
+ )}
509
+ </div>
510
+ </div>
511
+ </div>
512
+ );
513
+ };
514
+
515
+ return (
516
+ <div className="min-h-screen flex flex-col items-center justify-center p-4 text-center">
517
+ <div className="w-full max-w-2xl bg-white dark:bg-slate-800 rounded-xl shadow-lg p-8 space-y-6 relative">
518
+
519
+ {/* Language Switcher */}
520
+ <div className="absolute top-4 right-4 flex space-x-2">
521
+ <button
522
+ onClick={() => setLanguage('en')}
523
+ className={`px-3 py-1 text-sm rounded-md transition-colors ${language === 'en' ? 'bg-primary-600 text-white shadow-sm' : 'bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300'}`}
524
+ aria-pressed={language === 'en'}
525
+ >
526
+ English
527
+ </button>
528
+ <button
529
+ onClick={() => setLanguage('zh')}
530
+ className={`px-3 py-1 text-sm rounded-md transition-colors ${language === 'zh' ? 'bg-primary-600 text-white shadow-sm' : 'bg-slate-200 dark:bg-slate-700 hover:bg-slate-300 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300'}`}
531
+ aria-pressed={language === 'zh'}
532
+ >
533
+ 中文
534
+ </button>
535
+ </div>
536
+
537
+
538
+ {/* Header */}
539
+ <div className="flex flex-col items-center">
540
+ <BookIcon className="w-12 h-12 text-primary-600 dark:text-primary-400" />
541
+ <h1 className="text-3xl font-bold text-slate-800 dark:text-slate-100 mt-2">{t('title')}</h1>
542
+ <p className="text-slate-600 dark:text-slate-400 mt-2 max-w-md">
543
+ {t('subtitle')}
544
+ </p>
545
+ </div>
546
+
547
+ {/* Error Display */}
548
+ {error && (
549
+ <div className="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 rounded-md text-left" role="alert">
550
+ <p className="font-bold">{t('errorTitle')}</p>
551
+ <p>{error}</p>
552
+ </div>
553
+ )}
554
+
555
+ {/* Main Content: Upload or Download */}
556
+ {!processedUrl ? (
557
+ <div>
558
+ {/* File Upload Section */}
559
+ <form
560
+ className={`w-full p-6 border-2 border-dashed rounded-lg transition-colors duration-200 ${dragActive ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-slate-300 dark:border-slate-600'}`}
561
+ onDragEnter={handleDrag} onDragLeave={handleDrag} onDragOver={handleDrag} onDrop={handleDrop}
562
+ onSubmit={(e) => e.preventDefault()}
563
+ >
564
+ <input
565
+ ref={inputRef}
566
+ type="file"
567
+ accept="application/pdf"
568
+ className="hidden"
569
+ onChange={(e) => handleFileChange(e.target.files ? e.target.files[0] : null)}
570
+ />
571
+ <div className="flex flex-col items-center justify-center space-y-4">
572
+ <UploadIcon className="w-10 h-10 text-slate-400" />
573
+ <p className="text-slate-500 dark:text-slate-400">
574
+ <button
575
+ type="button"
576
+ onClick={onButtonClick}
577
+ className="font-semibold text-primary-600 hover:text-primary-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
578
+ >
579
+ {t('uploadButton')}
580
+ </button>
581
+ {' '}{t('uploadOrDrag')}
582
+ </p>
583
+ <p className="text-xs text-slate-400">{t('uploadHint')}</p>
584
+ </div>
585
+ </form>
586
+
587
+ {/* File Info and Options */}
588
+ {file && originalPageCount && !isLoading && (
589
+ <div className="mt-6 text-sm text-slate-700 dark:text-slate-300">
590
+ <p><strong>{t('fileLabel')}</strong> {file.name}</p>
591
+ <p><strong>{t('pagesLabel')}</strong> {originalPageCount}</p>
592
+ <div className="mt-6">
593
+ {renderCoverOptions()}
594
+ </div>
595
+
596
+ <PDFPreview file={file} t={t} />
597
+
598
+ <button
599
+ onClick={arrangeForBooklet}
600
+ disabled={isLoading}
601
+ className="mt-8 w-full inline-flex justify-center items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:bg-slate-400 disabled:cursor-not-allowed"
602
+ >
603
+ {t('generateButton')}
604
+ </button>
605
+ </div>
606
+ )}
607
+
608
+ {/* Loading State */}
609
+ {isLoading && <div className="mt-6"><ProgressBar progress={progress} message={loadingMessage} /></div>}
610
+
611
+ </div>
612
+ ) : (
613
+ /* Download Section */
614
+ <div className="space-y-4">
615
+ <h2 className="text-2xl font-bold text-green-600 dark:text-green-400">{t('successTitle')}</h2>
616
+ {pages && <p className="text-slate-600 dark:text-slate-400" dangerouslySetInnerHTML={{ __html: t('successMessage', pages.padded, pages.original) }} />}
617
+ <p className="text-sm text-slate-500 dark:text-slate-500">
618
+ {t('printInstructions')}
619
+ </p>
620
+ <a
621
+ href={processedUrl}
622
+ download={`booklet-${file?.name || 'document.pdf'}`}
623
+ className="w-full inline-flex justify-center items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
624
+ >
625
+ <DownloadIcon className="w-5 h-5 mr-2" />
626
+ {t('downloadButton')}
627
+ </a>
628
+ <button
629
+ onClick={resetState}
630
+ className="w-full mt-4 inline-flex justify-center items-center px-6 py-3 border border-slate-300 dark:border-slate-600 text-base font-medium rounded-md shadow-sm text-slate-700 dark:text-slate-200 bg-white dark:bg-slate-700 hover:bg-slate-50 dark:hover:bg-slate-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
631
+ >
632
+ {t('startOverButton')}
633
+ </button>
634
+ </div>
635
+ )}
636
+
637
+ {/* Footer */}
638
+ <div className="text-center text-xs text-slate-400 dark:text-slate-500 pt-6 border-t border-slate-200 dark:border-slate-700">
639
+ <p>{t('privacyFooter')}</p>
640
+ </div>
641
+ </div>
642
+ </div>
643
+ );
644
+ };
645
+
646
+ export default App;
index.html ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <title>PDF Booklet Arranger</title>
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ <script>
11
+ tailwind.config = {
12
+ theme: {
13
+ extend: {
14
+ colors: {
15
+ primary: {"50":"#eff6ff","100":"#dbeafe","200":"#bfdbfe","300":"#93c5fd","400":"#60a5fa","500":"#3b82f6","600":"#2563eb","700":"#1d4ed8","800":"#1e40af","900":"#1e3a8a","950":"#172554"}
16
+ }
17
+ }
18
+ }
19
+ }
20
+ </script>
21
+ <script type="importmap">
22
+ {
23
+ "imports": {
24
+ "react": "https://aistudiocdn.com/react@^19.2.0",
25
+ "react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
26
+ "react/": "https://aistudiocdn.com/react@^19.2.0/",
27
+ "pdf-lib": "https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.esm.js",
28
+ "pdfjs-dist": "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.mjs"
29
+ }
30
+ }
31
+ </script>
32
+ <link rel="stylesheet" href="/index.css">
33
+ </head>
34
+ <body class="bg-slate-50 dark:bg-slate-900">
35
+ <div id="root"></div>
36
+ <script type="module" src="/index.tsx"></script>
37
+ </body>
38
+ </html>
index.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import ReactDOM from 'react-dom/client';
4
+ import App from './App';
5
+
6
+ const rootElement = document.getElementById('root');
7
+ if (!rootElement) {
8
+ throw new Error("Could not find root element to mount to");
9
+ }
10
+
11
+ const root = ReactDOM.createRoot(rootElement);
12
+ root.render(
13
+ <React.StrictMode>
14
+ <App />
15
+ </React.StrictMode>
16
+ );
metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "PDF Booklet Arranger",
3
+ "description": "Upload a PDF, and this tool will reorder the pages so you can print it as a booklet. After printing double-sided with two pages per sheet, simply fold the stack in half and staple the center to create a magazine-style booklet.",
4
+ "requestFramePermissions": []
5
+ }
package.json CHANGED
@@ -1,39 +1,22 @@
1
  {
2
- "name": "react-template",
3
- "version": "0.1.0",
4
  "private": true,
5
- "dependencies": {
6
- "@testing-library/dom": "^10.4.0",
7
- "@testing-library/jest-dom": "^6.6.3",
8
- "@testing-library/react": "^16.3.0",
9
- "@testing-library/user-event": "^13.5.0",
10
- "react": "^19.1.0",
11
- "react-dom": "^19.1.0",
12
- "react-scripts": "5.0.1",
13
- "web-vitals": "^2.1.4"
14
- },
15
  "scripts": {
16
- "start": "react-scripts start",
17
- "build": "react-scripts build",
18
- "test": "react-scripts test",
19
- "eject": "react-scripts eject"
20
  },
21
- "eslintConfig": {
22
- "extends": [
23
- "react-app",
24
- "react-app/jest"
25
- ]
26
  },
27
- "browserslist": {
28
- "production": [
29
- ">0.2%",
30
- "not dead",
31
- "not op_mini all"
32
- ],
33
- "development": [
34
- "last 1 chrome version",
35
- "last 1 firefox version",
36
- "last 1 safari version"
37
- ]
38
  }
39
  }
 
1
  {
2
+ "name": "pdf-booklet-arranger",
 
3
  "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
 
 
 
 
 
 
 
 
6
  "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
 
10
  },
11
+ "dependencies": {
12
+ "react": "^19.2.0",
13
+ "react-dom": "^19.2.0",
14
+ "pdf-lib": "1.17.1"
 
15
  },
16
+ "devDependencies": {
17
+ "@types/node": "^22.14.0",
18
+ "@vitejs/plugin-react": "^5.0.0",
19
+ "typescript": "~5.8.2",
20
+ "vite": "^6.2.0"
 
 
 
 
 
 
21
  }
22
  }
tsconfig.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "experimentalDecorators": true,
5
+ "useDefineForClassFields": false,
6
+ "module": "ESNext",
7
+ "lib": [
8
+ "ES2022",
9
+ "DOM",
10
+ "DOM.Iterable"
11
+ ],
12
+ "skipLibCheck": true,
13
+ "types": [
14
+ "node"
15
+ ],
16
+ "moduleResolution": "bundler",
17
+ "isolatedModules": true,
18
+ "moduleDetection": "force",
19
+ "allowJs": true,
20
+ "jsx": "react-jsx",
21
+ "paths": {
22
+ "@/*": [
23
+ "./*"
24
+ ]
25
+ },
26
+ "allowImportingTsExtensions": true,
27
+ "noEmit": true
28
+ }
29
+ }
vite.config.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'path';
2
+ import { defineConfig, loadEnv } from 'vite';
3
+ import react from '@vitejs/plugin-react';
4
+
5
+ export default defineConfig(({ mode }) => {
6
+ const env = loadEnv(mode, '.', '');
7
+ return {
8
+ server: {
9
+ port: 3000,
10
+ host: '0.0.0.0',
11
+ },
12
+ plugins: [react()],
13
+ define: {
14
+ 'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
15
+ 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
16
+ },
17
+ resolve: {
18
+ alias: {
19
+ '@': path.resolve(__dirname, '.'),
20
+ }
21
+ }
22
+ };
23
+ });