Jaimodiji commited on
Commit
bf92db0
·
verified ·
1 Parent(s): 3207995

Upload folder using huggingface_hub

Browse files
Dockerfile CHANGED
@@ -24,5 +24,5 @@ ENV NODE_ENV=development
24
  # Expose port 7860
25
  EXPOSE 7860
26
 
27
- # Run disk check, init, restore, then start dev server and backup worker concurrently
28
- CMD ["sh", "-c", "df -h && node cmd/hf_init.mjs && node cmd/hf_restore.mjs && npx concurrently \"npm run dev\" \"node cmd/hf_backup.mjs\""]
 
24
  # Expose port 7860
25
  EXPOSE 7860
26
 
27
+ # Run disk check, init, restore, then start dev server, backup worker, and PDF convert server concurrently
28
+ CMD ["sh", "-c", "df -h && node cmd/hf_init.mjs && node cmd/hf_restore.mjs && npx concurrently \"npm run dev\" \"node cmd/hf_backup.mjs\" \"node cmd/pdf_convert_server.mjs\""]
cmd/pdf_convert_server.mjs ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * PDF to SVG Conversion Server
3
+ *
4
+ * Runs alongside the Vite dev server on HuggingFace Spaces.
5
+ * Handles PDF to SVG conversion using the pdf2svg binary.
6
+ *
7
+ * Endpoints:
8
+ * - POST /convert/pdf - Upload PDF and get SVG pages
9
+ * - GET /convert/status/:jobId - Check job status
10
+ * - GET /convert/page/:jobId/:pageNum - Download a converted SVG page
11
+ */
12
+
13
+ import http from 'http';
14
+ import { spawn, execSync } from 'child_process';
15
+ import fs from 'fs';
16
+ import path from 'path';
17
+ import os from 'os';
18
+
19
+ const PORT = process.env.PDF_CONVERT_PORT || 7861;
20
+ const TEMP_DIR = path.join(os.tmpdir(), 'pdf_convert');
21
+
22
+ // Ensure temp directory exists
23
+ if (!fs.existsSync(TEMP_DIR)) {
24
+ fs.mkdirSync(TEMP_DIR, { recursive: true });
25
+ }
26
+
27
+ // Job storage
28
+ const jobs = new Map();
29
+
30
+ /**
31
+ * Parse multipart form data (simple implementation for file upload)
32
+ */
33
+ function parseMultipart(buffer, boundary) {
34
+ const parts = [];
35
+ const boundaryBuffer = Buffer.from(`--${boundary}`);
36
+ let start = buffer.indexOf(boundaryBuffer);
37
+
38
+ while (start !== -1) {
39
+ const end = buffer.indexOf(boundaryBuffer, start + boundaryBuffer.length);
40
+ if (end === -1) break;
41
+
42
+ const part = buffer.slice(start + boundaryBuffer.length, end);
43
+ const headerEnd = part.indexOf('\r\n\r\n');
44
+ if (headerEnd !== -1) {
45
+ const headers = part.slice(0, headerEnd).toString();
46
+ const content = part.slice(headerEnd + 4, part.length - 2); // -2 for trailing \r\n
47
+
48
+ const nameMatch = headers.match(/name="([^"]+)"/);
49
+ const filenameMatch = headers.match(/filename="([^"]+)"/);
50
+
51
+ if (nameMatch) {
52
+ parts.push({
53
+ name: nameMatch[1],
54
+ filename: filenameMatch ? filenameMatch[1] : null,
55
+ content: content
56
+ });
57
+ }
58
+ }
59
+ start = end;
60
+ }
61
+ return parts;
62
+ }
63
+
64
+ /**
65
+ * Get PDF page count using pdfinfo or pdf2svg
66
+ */
67
+ function getPdfPageCount(pdfPath) {
68
+ try {
69
+ // Try pdfinfo first
70
+ const output = execSync(`pdfinfo "${pdfPath}" 2>/dev/null | grep -i "Pages:" | awk '{print $2}'`, { encoding: 'utf8' });
71
+ const count = parseInt(output.trim(), 10);
72
+ if (!isNaN(count)) return count;
73
+ } catch (e) {
74
+ // pdfinfo not available, try alternative method
75
+ }
76
+
77
+ try {
78
+ // Try using pdf2svg on page 1 to check if it works, then binary search for count
79
+ // This is a fallback if pdfinfo isn't available
80
+ let maxPage = 1;
81
+ let testPage = 1;
82
+
83
+ // Test increasing pages until we fail
84
+ while (testPage <= 1000) {
85
+ const testOutput = path.join(TEMP_DIR, `test_${Date.now()}.svg`);
86
+ try {
87
+ execSync(`pdf2svg "${pdfPath}" "${testOutput}" ${testPage} 2>/dev/null`, { encoding: 'utf8' });
88
+ fs.unlinkSync(testOutput);
89
+ maxPage = testPage;
90
+ testPage++;
91
+ } catch (e) {
92
+ break;
93
+ }
94
+ }
95
+ return maxPage;
96
+ } catch (e) {
97
+ console.error('Failed to get page count:', e.message);
98
+ return 1;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Convert a single PDF page to SVG
104
+ */
105
+ async function convertPage(pdfPath, pageNum, outputPath) {
106
+ return new Promise((resolve, reject) => {
107
+ const proc = spawn('pdf2svg', [pdfPath, outputPath, String(pageNum)]);
108
+
109
+ let stderr = '';
110
+ proc.stderr.on('data', (data) => {
111
+ stderr += data.toString();
112
+ });
113
+
114
+ proc.on('close', (code) => {
115
+ if (code === 0 && fs.existsSync(outputPath)) {
116
+ resolve(outputPath);
117
+ } else {
118
+ reject(new Error(`pdf2svg failed: ${stderr || 'Unknown error'}`));
119
+ }
120
+ });
121
+
122
+ proc.on('error', (err) => {
123
+ reject(err);
124
+ });
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Process a PDF conversion job
130
+ */
131
+ async function processJob(jobId) {
132
+ const job = jobs.get(jobId);
133
+ if (!job) return;
134
+
135
+ job.status = 'processing';
136
+ job.updatedAt = Date.now();
137
+
138
+ try {
139
+ // Get page count
140
+ const pageCount = getPdfPageCount(job.pdfPath);
141
+ job.pageCount = pageCount;
142
+
143
+ // Convert each page
144
+ for (let i = 1; i <= pageCount; i++) {
145
+ const outputPath = path.join(job.outputDir, `page_${i}.svg`);
146
+ await convertPage(job.pdfPath, i, outputPath);
147
+ job.processedPages = i;
148
+ job.updatedAt = Date.now();
149
+ console.log(`[PDF Convert] Job ${jobId}: Page ${i}/${pageCount} converted`);
150
+ }
151
+
152
+ job.status = 'completed';
153
+ job.updatedAt = Date.now();
154
+ console.log(`[PDF Convert] Job ${jobId}: Completed - ${pageCount} pages`);
155
+
156
+ } catch (e) {
157
+ job.status = 'failed';
158
+ job.error = e.message;
159
+ job.updatedAt = Date.now();
160
+ console.error(`[PDF Convert] Job ${jobId}: Failed -`, e.message);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Handle HTTP requests
166
+ */
167
+ async function handleRequest(req, res) {
168
+ const url = new URL(req.url, `http://localhost:${PORT}`);
169
+
170
+ // CORS headers
171
+ res.setHeader('Access-Control-Allow-Origin', '*');
172
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
173
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
174
+
175
+ if (req.method === 'OPTIONS') {
176
+ res.writeHead(200);
177
+ res.end();
178
+ return;
179
+ }
180
+
181
+ // Health check
182
+ if (url.pathname === '/convert/health') {
183
+ res.writeHead(200, { 'Content-Type': 'application/json' });
184
+ res.end(JSON.stringify({ status: 'ok', pdf2svg: true }));
185
+ return;
186
+ }
187
+
188
+ // Upload PDF
189
+ if (req.method === 'POST' && url.pathname === '/convert/pdf') {
190
+ const chunks = [];
191
+ req.on('data', chunk => chunks.push(chunk));
192
+ req.on('end', async () => {
193
+ try {
194
+ const buffer = Buffer.concat(chunks);
195
+ const contentType = req.headers['content-type'] || '';
196
+
197
+ let pdfBuffer;
198
+
199
+ if (contentType.includes('multipart/form-data')) {
200
+ const boundary = contentType.split('boundary=')[1];
201
+ const parts = parseMultipart(buffer, boundary);
202
+ const filePart = parts.find(p => p.filename && p.filename.endsWith('.pdf'));
203
+ if (!filePart) {
204
+ res.writeHead(400, { 'Content-Type': 'application/json' });
205
+ res.end(JSON.stringify({ error: 'No PDF file found' }));
206
+ return;
207
+ }
208
+ pdfBuffer = filePart.content;
209
+ } else if (contentType === 'application/pdf') {
210
+ pdfBuffer = buffer;
211
+ } else {
212
+ res.writeHead(400, { 'Content-Type': 'application/json' });
213
+ res.end(JSON.stringify({ error: 'Invalid content type' }));
214
+ return;
215
+ }
216
+
217
+ // Create job
218
+ const jobId = `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
219
+ const jobDir = path.join(TEMP_DIR, jobId);
220
+ fs.mkdirSync(jobDir, { recursive: true });
221
+
222
+ const pdfPath = path.join(jobDir, 'source.pdf');
223
+ fs.writeFileSync(pdfPath, pdfBuffer);
224
+
225
+ const job = {
226
+ id: jobId,
227
+ status: 'pending',
228
+ pageCount: 0,
229
+ processedPages: 0,
230
+ pdfPath: pdfPath,
231
+ outputDir: jobDir,
232
+ createdAt: Date.now(),
233
+ updatedAt: Date.now()
234
+ };
235
+
236
+ jobs.set(jobId, job);
237
+
238
+ // Start processing async
239
+ processJob(jobId);
240
+
241
+ res.writeHead(200, { 'Content-Type': 'application/json' });
242
+ res.end(JSON.stringify({
243
+ jobId,
244
+ status: 'pending',
245
+ statusUrl: `/convert/status/${jobId}`
246
+ }));
247
+
248
+ } catch (e) {
249
+ console.error('Upload error:', e);
250
+ res.writeHead(500, { 'Content-Type': 'application/json' });
251
+ res.end(JSON.stringify({ error: e.message }));
252
+ }
253
+ });
254
+ return;
255
+ }
256
+
257
+ // Check job status
258
+ const statusMatch = url.pathname.match(/^\/convert\/status\/(.+)$/);
259
+ if (req.method === 'GET' && statusMatch) {
260
+ const jobId = statusMatch[1];
261
+ const job = jobs.get(jobId);
262
+
263
+ if (!job) {
264
+ res.writeHead(404, { 'Content-Type': 'application/json' });
265
+ res.end(JSON.stringify({ error: 'Job not found' }));
266
+ return;
267
+ }
268
+
269
+ const pages = [];
270
+ if (job.status === 'completed' || job.processedPages > 0) {
271
+ for (let i = 1; i <= job.processedPages; i++) {
272
+ pages.push({
273
+ page: i,
274
+ url: `/convert/page/${jobId}/${i}`
275
+ });
276
+ }
277
+ }
278
+
279
+ res.writeHead(200, { 'Content-Type': 'application/json' });
280
+ res.end(JSON.stringify({
281
+ id: job.id,
282
+ status: job.status,
283
+ pageCount: job.pageCount,
284
+ processedPages: job.processedPages,
285
+ error: job.error,
286
+ pages: pages
287
+ }));
288
+ return;
289
+ }
290
+
291
+ // Download page
292
+ const pageMatch = url.pathname.match(/^\/convert\/page\/(.+)\/(\d+)$/);
293
+ if (req.method === 'GET' && pageMatch) {
294
+ const jobId = pageMatch[1];
295
+ const pageNum = parseInt(pageMatch[2], 10);
296
+ const job = jobs.get(jobId);
297
+
298
+ if (!job) {
299
+ res.writeHead(404, { 'Content-Type': 'application/json' });
300
+ res.end(JSON.stringify({ error: 'Job not found' }));
301
+ return;
302
+ }
303
+
304
+ const svgPath = path.join(job.outputDir, `page_${pageNum}.svg`);
305
+ if (!fs.existsSync(svgPath)) {
306
+ res.writeHead(404, { 'Content-Type': 'application/json' });
307
+ res.end(JSON.stringify({ error: 'Page not found' }));
308
+ return;
309
+ }
310
+
311
+ const svgContent = fs.readFileSync(svgPath, 'utf8');
312
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml' });
313
+ res.end(svgContent);
314
+ return;
315
+ }
316
+
317
+ // 404 for unknown routes
318
+ res.writeHead(404, { 'Content-Type': 'application/json' });
319
+ res.end(JSON.stringify({ error: 'Not found' }));
320
+ }
321
+
322
+ // Create server
323
+ const server = http.createServer(handleRequest);
324
+
325
+ server.listen(PORT, () => {
326
+ console.log(`[PDF Convert Server] Running on port ${PORT}`);
327
+ console.log(`[PDF Convert Server] Endpoints:`);
328
+ console.log(` POST /convert/pdf - Upload PDF file`);
329
+ console.log(` GET /convert/status/:jobId - Check job status`);
330
+ console.log(` GET /convert/page/:jobId/:pageNum - Download SVG page`);
331
+ });
332
+
333
+ // Cleanup old jobs periodically (every 30 minutes)
334
+ setInterval(() => {
335
+ const now = Date.now();
336
+ const maxAge = 2 * 60 * 60 * 1000; // 2 hours
337
+
338
+ for (const [jobId, job] of jobs.entries()) {
339
+ if (now - job.createdAt > maxAge) {
340
+ // Clean up files
341
+ try {
342
+ if (fs.existsSync(job.outputDir)) {
343
+ fs.rmSync(job.outputDir, { recursive: true, force: true });
344
+ }
345
+ } catch (e) {
346
+ console.error(`[PDF Convert] Failed to cleanup job ${jobId}:`, e.message);
347
+ }
348
+ jobs.delete(jobId);
349
+ console.log(`[PDF Convert] Cleaned up old job: ${jobId}`);
350
+ }
351
+ }
352
+ }, 30 * 60 * 1000);
public/scripts/modules/ColorRmSession.js CHANGED
@@ -342,7 +342,8 @@ export const ColorRmSession = {
342
 
343
  /**
344
  * Import PDF file - opens file picker and initiates server-side conversion
345
- * This is an experimental feature that converts PDF pages to SVG
 
346
  */
347
  async importPdf() {
348
  const input = document.createElement('input');
@@ -356,14 +357,18 @@ export const ColorRmSession = {
356
  this.ui.toggleLoader(true, 'Uploading PDF...');
357
 
358
  try {
359
- // Upload PDF to server
 
 
 
 
 
 
 
360
  const formData = new FormData();
361
  formData.append('file', file);
362
 
363
- const uploadUrl = window.Config?.apiUrl(`/api/color_rm/pdf/${this.state.sessionId}`)
364
- || `/api/color_rm/pdf/${this.state.sessionId}`;
365
-
366
- const response = await fetch(uploadUrl, {
367
  method: 'POST',
368
  body: formData
369
  });
@@ -374,8 +379,8 @@ export const ColorRmSession = {
374
 
375
  const result = await response.json();
376
 
377
- // Show conversion status dialog
378
- this._showPdfConversionDialog(result.jobId, file.name);
379
 
380
  } catch (e) {
381
  console.error('PDF upload error:', e);
@@ -392,8 +397,9 @@ export const ColorRmSession = {
392
  * Shows PDF conversion status dialog with polling
393
  * @param {string} jobId - The conversion job ID
394
  * @param {string} fileName - Original file name
 
395
  */
396
- _showPdfConversionDialog(jobId, fileName) {
397
  const modal = document.createElement('div');
398
  modal.className = 'overlay';
399
  modal.style.cssText = 'position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:300; display:flex; align-items:center; justify-content:center;';
@@ -401,14 +407,14 @@ export const ColorRmSession = {
401
  modal.innerHTML = `
402
  <div class="card" style="max-width:450px; width:90%; background:var(--bg-panel); border:1px solid var(--border); padding:20px;">
403
  <h3 style="margin:0 0 15px; color:white; font-size:1.1rem;">
404
- <i class="bi bi-file-pdf" style="color:#ff6b6b;"></i> PDF Import (Experimental)
405
  </h3>
406
  <p style="color:#aaa; margin:0 0 15px; font-size:0.9rem;">${fileName}</p>
407
 
408
  <div id="pdfStatus" style="padding:15px; background:var(--bg-dark); border-radius:8px; margin-bottom:15px;">
409
  <div style="display:flex; align-items:center; gap:10px; color:#888;">
410
  <div class="spinner" style="width:20px; height:20px; border:2px solid #333; border-top-color:#4dabf7; border-radius:50%; animation:spin 1s linear infinite;"></div>
411
- <span>Processing PDF...</span>
412
  </div>
413
  <div id="pdfProgress" style="margin-top:10px; font-size:0.85rem; color:#666;"></div>
414
  </div>
@@ -423,7 +429,7 @@ export const ColorRmSession = {
423
  </div>
424
 
425
  <p style="font-size:0.75rem; color:#666; margin:15px 0 0; text-align:center;">
426
- ⚠️ Experimental: PDF conversion quality may vary
427
  </p>
428
  </div>
429
  <style>
@@ -438,10 +444,7 @@ export const ColorRmSession = {
438
 
439
  const pollStatus = async () => {
440
  try {
441
- const statusUrl = window.Config?.apiUrl(`/api/color_rm/pdf/${this.state.sessionId}/job/${jobId}`)
442
- || `/api/color_rm/pdf/${this.state.sessionId}/job/${jobId}`;
443
-
444
- const response = await fetch(statusUrl);
445
  if (!response.ok) throw new Error('Status check failed');
446
 
447
  jobData = await response.json();
@@ -451,23 +454,23 @@ export const ColorRmSession = {
451
  const importAllBtn = modal.querySelector('#pdfImportAll');
452
 
453
  if (jobData.status === 'processing') {
454
- progressEl.textContent = `Processing page ${jobData.processedPages} of ${jobData.pageCount || '?'}...`;
455
  } else if (jobData.status === 'completed') {
456
  clearInterval(pollInterval);
457
 
458
  statusEl.innerHTML = `
459
  <div style="color:#51cf66; display:flex; align-items:center; gap:10px;">
460
  <i class="bi bi-check-circle-fill"></i>
461
- <span>Conversion complete!</span>
462
  </div>
463
  `;
464
 
465
- if (jobData.availablePages && jobData.availablePages.length > 0) {
466
  pagesEl.style.display = 'block';
467
- pagesEl.innerHTML = jobData.availablePages.map(pageNum => `
468
- <div class="pdf-page-item" data-page="${pageNum}" style="padding:10px; border:1px solid var(--border); margin-bottom:5px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center;">
469
- <span>Page ${pageNum}</span>
470
- <button class="btn btn-sm pdf-import-page" data-page="${pageNum}">Import</button>
471
  </div>
472
  `).join('');
473
 
@@ -477,7 +480,7 @@ export const ColorRmSession = {
477
  pagesEl.querySelectorAll('.pdf-import-page').forEach(btn => {
478
  btn.onclick = (e) => {
479
  e.stopPropagation();
480
- this._importPdfPage(jobId, parseInt(btn.dataset.page));
481
  };
482
  });
483
  }
@@ -490,7 +493,7 @@ export const ColorRmSession = {
490
  </div>
491
  `;
492
  } else if (jobData.status === 'pending') {
493
- progressEl.textContent = 'Waiting in queue...';
494
  }
495
 
496
  } catch (e) {
@@ -499,14 +502,16 @@ export const ColorRmSession = {
499
  };
500
 
501
  // Start polling
502
- pollInterval = setInterval(pollStatus, 2000);
503
  pollStatus(); // Initial check
504
 
505
  // Import all pages button
506
  modal.querySelector('#pdfImportAll').onclick = async () => {
507
- if (jobData?.availablePages) {
508
- for (const pageNum of jobData.availablePages) {
509
- await this._importPdfPage(jobId, pageNum);
 
 
510
  }
511
  document.body.removeChild(modal);
512
  }
@@ -528,18 +533,15 @@ export const ColorRmSession = {
528
  },
529
 
530
  /**
531
- * Import a single PDF page as SVG
532
- * @param {string} jobId - The conversion job ID
533
- * @param {number} pageNum - Page number to import
534
  */
535
- async _importPdfPage(jobId, pageNum) {
536
  this.ui.toggleLoader(true, `Importing page ${pageNum}...`);
537
 
538
  try {
539
- const pageUrl = window.Config?.apiUrl(`/api/color_rm/pdf/${this.state.sessionId}/job/${jobId}/page/${pageNum}`)
540
- || `/api/color_rm/pdf/${this.state.sessionId}/job/${jobId}/page/${pageNum}`;
541
-
542
- const response = await fetch(pageUrl);
543
  if (!response.ok) throw new Error('Failed to download page');
544
 
545
  const svgContent = await response.text();
@@ -562,15 +564,32 @@ export const ColorRmSession = {
562
  await this.dbPut('pages', pageObj);
563
  this.state.images.push(pageObj);
564
 
565
- // CRITICAL: Upload the page blob to R2 so other users can fetch it
566
- const uploadSuccess = await this._uploadPageBlob(pageObj.pageId, pageObj.blob);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
 
568
- // Sync page structure to R2 and Liveblocks
 
569
  await this._syncPageStructureToServer();
570
  this._syncPageStructureToLive();
571
 
572
- if (this.liveSync && !this.liveSync.isInitializing && toSync.length > 0) {
573
- this.liveSync.setHistory(newPageIndex, toSync);
 
574
  }
575
 
576
  // Navigate to the new page
@@ -579,7 +598,7 @@ export const ColorRmSession = {
579
 
580
  if (this.state.activeSideTab === 'pages') this.renderPageSidebar();
581
 
582
- const syncStatus = uploadSuccess ? '✓ Synced' : '⚠ Local only';
583
  this.ui.showToast(`Imported PDF page ${pageNum} ${syncStatus}`);
584
 
585
  } catch (e) {
 
342
 
343
  /**
344
  * Import PDF file - opens file picker and initiates server-side conversion
345
+ * This is an experimental feature that converts PDF pages to SVG using pdf2svg
346
+ * The PDF conversion server runs locally on HuggingFace Spaces (port 7861)
347
  */
348
  async importPdf() {
349
  const input = document.createElement('input');
 
357
  this.ui.toggleLoader(true, 'Uploading PDF...');
358
 
359
  try {
360
+ // Get the base URL for the PDF convert server
361
+ // On HF Spaces, it runs on port 7861 on the same host
362
+ const currentHost = window.location.hostname;
363
+ const pdfServerBase = currentHost.includes('hf.space') || currentHost.includes('huggingface')
364
+ ? `${window.location.protocol}//${currentHost}:7861`
365
+ : 'http://localhost:7861';
366
+
367
+ // Upload PDF to local PDF convert server
368
  const formData = new FormData();
369
  formData.append('file', file);
370
 
371
+ const response = await fetch(`${pdfServerBase}/convert/pdf`, {
 
 
 
372
  method: 'POST',
373
  body: formData
374
  });
 
379
 
380
  const result = await response.json();
381
 
382
+ // Show conversion status dialog (using local server)
383
+ this._showPdfConversionDialog(result.jobId, file.name, pdfServerBase);
384
 
385
  } catch (e) {
386
  console.error('PDF upload error:', e);
 
397
  * Shows PDF conversion status dialog with polling
398
  * @param {string} jobId - The conversion job ID
399
  * @param {string} fileName - Original file name
400
+ * @param {string} serverBase - Base URL for PDF convert server
401
  */
402
+ _showPdfConversionDialog(jobId, fileName, serverBase) {
403
  const modal = document.createElement('div');
404
  modal.className = 'overlay';
405
  modal.style.cssText = 'position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:300; display:flex; align-items:center; justify-content:center;';
 
407
  modal.innerHTML = `
408
  <div class="card" style="max-width:450px; width:90%; background:var(--bg-panel); border:1px solid var(--border); padding:20px;">
409
  <h3 style="margin:0 0 15px; color:white; font-size:1.1rem;">
410
+ <i class="bi bi-file-pdf" style="color:#ff6b6b;"></i> PDF Import (pdf2svg)
411
  </h3>
412
  <p style="color:#aaa; margin:0 0 15px; font-size:0.9rem;">${fileName}</p>
413
 
414
  <div id="pdfStatus" style="padding:15px; background:var(--bg-dark); border-radius:8px; margin-bottom:15px;">
415
  <div style="display:flex; align-items:center; gap:10px; color:#888;">
416
  <div class="spinner" style="width:20px; height:20px; border:2px solid #333; border-top-color:#4dabf7; border-radius:50%; animation:spin 1s linear infinite;"></div>
417
+ <span>Converting with pdf2svg...</span>
418
  </div>
419
  <div id="pdfProgress" style="margin-top:10px; font-size:0.85rem; color:#666;"></div>
420
  </div>
 
429
  </div>
430
 
431
  <p style="font-size:0.75rem; color:#666; margin:15px 0 0; text-align:center;">
432
+ Using pdf2svg for high-quality vector conversion
433
  </p>
434
  </div>
435
  <style>
 
444
 
445
  const pollStatus = async () => {
446
  try {
447
+ const response = await fetch(`${serverBase}/convert/status/${jobId}`);
 
 
 
448
  if (!response.ok) throw new Error('Status check failed');
449
 
450
  jobData = await response.json();
 
454
  const importAllBtn = modal.querySelector('#pdfImportAll');
455
 
456
  if (jobData.status === 'processing') {
457
+ progressEl.textContent = `Converting page ${jobData.processedPages} of ${jobData.pageCount || '?'}...`;
458
  } else if (jobData.status === 'completed') {
459
  clearInterval(pollInterval);
460
 
461
  statusEl.innerHTML = `
462
  <div style="color:#51cf66; display:flex; align-items:center; gap:10px;">
463
  <i class="bi bi-check-circle-fill"></i>
464
+ <span>Conversion complete! ${jobData.pageCount} pages ready.</span>
465
  </div>
466
  `;
467
 
468
+ if (jobData.pages && jobData.pages.length > 0) {
469
  pagesEl.style.display = 'block';
470
+ pagesEl.innerHTML = jobData.pages.map(p => `
471
+ <div class="pdf-page-item" data-page="${p.page}" style="padding:10px; border:1px solid var(--border); margin-bottom:5px; border-radius:4px; cursor:pointer; display:flex; justify-content:space-between; align-items:center;">
472
+ <span>Page ${p.page}</span>
473
+ <button class="btn btn-sm pdf-import-page" data-page="${p.page}" data-url="${serverBase}${p.url}">Import</button>
474
  </div>
475
  `).join('');
476
 
 
480
  pagesEl.querySelectorAll('.pdf-import-page').forEach(btn => {
481
  btn.onclick = (e) => {
482
  e.stopPropagation();
483
+ this._importPdfPageFromUrl(btn.dataset.url, parseInt(btn.dataset.page));
484
  };
485
  });
486
  }
 
493
  </div>
494
  `;
495
  } else if (jobData.status === 'pending') {
496
+ progressEl.textContent = 'Starting conversion...';
497
  }
498
 
499
  } catch (e) {
 
502
  };
503
 
504
  // Start polling
505
+ pollInterval = setInterval(pollStatus, 1000);
506
  pollStatus(); // Initial check
507
 
508
  // Import all pages button
509
  modal.querySelector('#pdfImportAll').onclick = async () => {
510
+ if (jobData?.pages) {
511
+ modal.querySelector('#pdfImportAll').disabled = true;
512
+ modal.querySelector('#pdfImportAll').textContent = 'Importing...';
513
+ for (const p of jobData.pages) {
514
+ await this._importPdfPageFromUrl(`${serverBase}${p.url}`, p.page);
515
  }
516
  document.body.removeChild(modal);
517
  }
 
533
  },
534
 
535
  /**
536
+ * Import a PDF page from a URL (from local pdf2svg server)
537
+ * @param {string} svgUrl - URL to fetch SVG content
538
+ * @param {number} pageNum - Page number for display
539
  */
540
+ async _importPdfPageFromUrl(svgUrl, pageNum) {
541
  this.ui.toggleLoader(true, `Importing page ${pageNum}...`);
542
 
543
  try {
544
+ const response = await fetch(svgUrl);
 
 
 
545
  if (!response.ok) throw new Error('Failed to download page');
546
 
547
  const svgContent = await response.text();
 
564
  await this.dbPut('pages', pageObj);
565
  this.state.images.push(pageObj);
566
 
567
+ // Upload base history to R2 for SVG items
568
+ if (toSync.length > 0 && this.state.sessionId && pageObj.pageId) {
569
+ try {
570
+ const historyUrl = window.Config?.apiUrl(`/api/color_rm/history/${this.state.sessionId}/${pageObj.pageId}`)
571
+ || `/api/color_rm/history/${this.state.sessionId}/${pageObj.pageId}`;
572
+ await fetch(historyUrl, {
573
+ method: 'POST',
574
+ headers: { 'Content-Type': 'application/json' },
575
+ body: JSON.stringify(toSync)
576
+ });
577
+ pageObj.hasBaseHistory = true;
578
+ pageObj._baseHistory = [...toSync];
579
+ await this.dbPut('pages', pageObj);
580
+ } catch (e) {
581
+ console.error('[PDF Import] Failed to upload history:', e);
582
+ }
583
+ }
584
 
585
+ // Upload page blob and sync structure
586
+ const uploadSuccess = await this._uploadPageBlob(pageObj.pageId, pageObj.blob);
587
  await this._syncPageStructureToServer();
588
  this._syncPageStructureToLive();
589
 
590
+ if (this.liveSync && !this.liveSync.isInitializing) {
591
+ this.liveSync.setHistory(newPageIndex, []);
592
+ this.liveSync.updatePageMetadata(newPageIndex, { hasBaseHistory: true, baseHistoryCount: toSync.length });
593
  }
594
 
595
  // Navigate to the new page
 
598
 
599
  if (this.state.activeSideTab === 'pages') this.renderPageSidebar();
600
 
601
+ const syncStatus = uploadSuccess ? '✓' : '⚠';
602
  this.ui.showToast(`Imported PDF page ${pageNum} ${syncStatus}`);
603
 
604
  } catch (e) {