# PDF Merge Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a PDF composition feature to the Options page that lets users select, reorder, and merge images/PDFs into a single PDF document.

**Architecture:** A new `utils/pdf-merge.ts` module handles all pdf-lib operations (image embedding + PDF page copying). The Options page (`entrypoints/options/main.ts`) gets a new `renderPdfPage()` function with drag-and-drop reordering, file selection modal, and merge trigger. Styles go in `entrypoints/options/style.css`.

**Tech Stack:** TypeScript, vanilla DOM, pdf-lib, IndexedDB (existing), WXT

---

## File Structure

| File | Responsibility |
|------|---------------|
| `utils/pdf-merge.ts` (create) | Core PDF synthesis: embed images as A4 pages, copy PDF pages, webp→png conversion via Canvas |
| `entrypoints/options/main.ts` (modify) | Add `renderPdfPage()` + drag-and-drop logic + file picker modal + merge trigger + download. Wire into `switchPage()` |
| `entrypoints/options/style.css` (modify) | PDF page styles: file list cards, drag handle, drag-over placeholder, merge button, picker modal |
| `package.json` (modify) | Add `pdf-lib` dependency |

---

## Task 1: Install pdf-lib

**Files:**
- Modify: `package.json`

- [ ] **Step 1: Add pdf-lib to dependencies**

```json
"dependencies": {
  "pinyin-pro": "^3.28.1",
  "pdf-lib": "^1.17.1"
}
```

- [ ] **Step 2: Install the package**

Run: `npm install`

- [ ] **Step 3: Commit**

```bash
git add package.json package-lock.json
npm install
git add package-lock.json
```

---

## Task 2: Create pdf-merge.ts core module

**Files:**
- Create: `utils/pdf-merge.ts`

- [ ] **Step 1: Write the module**

```typescript
import { PDFDocument } from 'pdf-lib';

export interface MergeFileItem {
  name: string;
  type: 'image' | 'pdf';
  data: ArrayBuffer;
}

const A4_WIDTH = 595.28;
const A4_HEIGHT = 841.89;

async function convertWebpToPng(webpBuffer: ArrayBuffer): Promise<ArrayBuffer> {
  const blob = new Blob([webpBuffer], { type: 'image/webp' });
  const img = new Image();
  const url = URL.createObjectURL(blob);

  return new Promise((resolve, reject) => {
    img.onload = () => {
      const canvas = document.createElement('canvas');
      canvas.width = img.width;
      canvas.height = img.height;
      const ctx = canvas.getContext('2d');
      if (!ctx) { URL.revokeObjectURL(url); reject(new Error('Canvas context failed')); return; }
      ctx.drawImage(img, 0, 0);
      canvas.toBlob((pngBlob) => {
        URL.revokeObjectURL(url);
        if (!pngBlob) { reject(new Error('WebP to PNG conversion failed')); return; }
        pngBlob.arrayBuffer().then(resolve).catch(reject);
      }, 'image/png');
    };
    img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('WebP load failed')); };
    img.src = url;
  });
}

export async function mergeFilesToPdf(files: MergeFileItem[]): Promise<Uint8Array> {
  const pdfDoc = await PDFDocument.create();

  for (const file of files) {
    if (file.type === 'pdf') {
      const donor = await PDFDocument.load(file.data);
      const pages = await pdfDoc.copyPages(donor, donor.getPageIndices());
      for (const page of pages) {
        pdfDoc.addPage(page);
      }
    } else {
      let imageBytes = file.data;
      const isWebp = file.name.toLowerCase().endsWith('.webp');

      let embeddedImage;
      if (isWebp) {
        imageBytes = await convertWebpToPng(imageBytes);
        embeddedImage = await pdfDoc.embedPng(imageBytes);
      } else if (file.name.toLowerCase().match(/\.(jpg|jpeg)$/)) {
        embeddedImage = await pdfDoc.embedJpg(imageBytes);
      } else {
        embeddedImage = await pdfDoc.embedPng(imageBytes);
      }

      const imgWidth = embeddedImage.width;
      const imgHeight = embeddedImage.height;
      const scale = Math.min((A4_WIDTH * 0.9) / imgWidth, (A4_HEIGHT * 0.9) / imgHeight);
      const scaledW = imgWidth * scale;
      const scaledH = imgHeight * scale;
      const x = (A4_WIDTH - scaledW) / 2;
      const y = (A4_HEIGHT - scaledH) / 2;

      const page = pdfDoc.addPage([A4_WIDTH, A4_HEIGHT]);
      page.drawImage(embeddedImage, { x, y, width: scaledW, height: scaledH });
    }
  }

  return pdfDoc.save();
}
```

- [ ] **Step 2: Commit**

```bash
git add utils/pdf-merge.ts
git commit -m "feat: add pdf-merge core module"
```

---

## Task 3: Add PDF page rendering logic in main.ts

**Files:**
- Modify: `entrypoints/options/main.ts`

This is a large task. We add:
1. Import pdf-merge utilities
2. A state variable for the PDF page's selected files
3. `renderPdfPage()` function
4. Helper functions for drag-and-drop, file picker modal, merge action
5. Wire `switchPage()` to call `renderPdfPage()` for the `'pdf'` page

### Step 3.1: Add import

- [ ] **Step 1: Add import at the top**

In `entrypoints/options/main.ts`, after the existing db imports, add:

```typescript
import { mergeFilesToPdf, type MergeFileItem } from '../../utils/pdf-merge';
```

### Step 3.2: Add state variable

- [ ] **Step 2: Add state variable after other state declarations**

After line `let nextFieldId = 0;` add:

```typescript
let pdfMergeQueue: { fileRecordId: number; name: string; type: string }[] = [];
```

### Step 3.3: Wire switchPage

- [ ] **Step 3: Update switchPage()**

In `switchPage()` around line 72, change:

```typescript
  if (page === 'profile') {
    renderProfilePage();
  } else if (page === 'settings') {
    renderSettingsPage();
  } else if (page === 'certificates') {
    renderCertificatesPage();
  } else {
    renderPlaceholderPage(config.title);
  }
```

To:

```typescript
  if (page === 'profile') {
    renderProfilePage();
  } else if (page === 'settings') {
    renderSettingsPage();
  } else if (page === 'certificates') {
    renderCertificatesPage();
  } else if (page === 'pdf') {
    renderPdfPage();
  } else {
    renderPlaceholderPage(config.title);
  }
```

### Step 3.4: Add renderPdfPage and helpers

- [ ] **Step 4: Add the renderPdfPage function after renderPlaceholderPage**

Insert this block after the `renderPlaceholderPage` function (around line 84):

```typescript
const PDF_ACCEPT_TYPES = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'application/pdf']);

function renderPdfPage() {
  const itemsHtml = pdfMergeQueue.map((item, idx) => {
    const isImage = item.type !== 'application/pdf';
    const icon = isImage ? '🖼' : '📄';
    const file = fileRecords.find(f => f.id === item.fileRecordId);
    const thumbHtml = (isImage && file)
      ? `<img src="${URL.createObjectURL(file.fileBody)}" alt="" class="pdf-queue-thumb-img" />`
      : `<span class="pdf-queue-thumb-icon">${icon}</span>`;
    return `
      <div class="pdf-queue-item" data-idx="${idx}" draggable="true">
        <div class="pdf-queue-drag-handle">☰</div>
        <div class="pdf-queue-thumb">${thumbHtml}</div>
        <div class="pdf-queue-name">${escapeHtml(item.name)}</div>
        <button class="pdf-queue-remove" data-idx="${idx}" title="移除">×</button>
      </div>
    `;
  }).join('');

  const emptyHtml = pdfMergeQueue.length === 0
    ? '<div class="pdf-queue-empty">暂无文件，请从下方添加</div>'
    : '';

  pageContent.innerHTML = `
    <div class="pdf-page">
      <div class="pdf-filename-row">
        <label for="pdfFilename">文件名</label>
        <input type="text" id="pdfFilename" class="pdf-filename-input" placeholder="输入合成后的 PDF 文件名" value="合并文档" />
        <span class="pdf-filename-ext">.pdf</span>
      </div>
      <div class="pdf-queue" id="pdfQueue">
        ${itemsHtml}${emptyHtml}
      </div>
      <div class="pdf-actions">
        <button class="pdf-btn-secondary" id="pdfPickFromRecords">+ 从证明材料选取</button>
        <button class="pdf-btn-secondary" id="pdfUploadLocal">+ 本地上传</button>
        <button class="pdf-btn-primary${pdfMergeQueue.length === 0 ? ' disabled' : ''}" id="pdfMergeBtn" ${pdfMergeQueue.length === 0 ? 'disabled' : ''}>合成 PDF</button>
      </div>
    </div>
  `;

  bindPdfPageEvents();
}
```

- [ ] **Step 5: Add bindPdfPageEvents function**

Insert after `renderPdfPage`:

```typescript
function bindPdfPageEvents() {
  // Drag and drop reordering
  const queue = document.getElementById('pdfQueue')!;
  let dragSrcIdx: number | null = null;

  queue.querySelectorAll<HTMLElement>('.pdf-queue-item').forEach(item => {
    item.addEventListener('dragstart', (e) => {
      dragSrcIdx = Number(item.dataset.idx);
      item.classList.add('dragging');
      (e as DragEvent).dataTransfer?.setData('text/plain', String(dragSrcIdx));
      (e as DragEvent).dataTransfer!.effectAllowed = 'move';
    });
    item.addEventListener('dragend', () => {
      item.classList.remove('dragging');
      queue.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
      dragSrcIdx = null;
    });
    item.addEventListener('dragover', (e) => {
      e.preventDefault();
      (e as DragEvent).dataTransfer!.dropEffect = 'move';
      const target = (e.target as HTMLElement).closest('.pdf-queue-item') as HTMLElement | null;
      if (target && target !== item) {
        target.classList.add('drag-over');
      }
    });
    item.addEventListener('dragleave', (e) => {
      const target = (e.target as HTMLElement).closest('.pdf-queue-item') as HTMLElement | null;
      if (target) target.classList.remove('drag-over');
    });
    item.addEventListener('drop', (e) => {
      e.preventDefault();
      const target = (e.target as HTMLElement).closest('.pdf-queue-item') as HTMLElement | null;
      if (!target || dragSrcIdx === null) return;
      const dropIdx = Number(target.dataset.idx);
      if (dropIdx === dragSrcIdx) return;
      const [moved] = pdfMergeQueue.splice(dragSrcIdx, 1);
      pdfMergeQueue.splice(dropIdx, 0, moved);
      renderPdfPage();
    });
  });

  // Remove item
  queue.querySelectorAll<HTMLButtonElement>('.pdf-queue-remove').forEach(btn => {
    btn.addEventListener('click', () => {
      const idx = Number(btn.dataset.idx);
      pdfMergeQueue.splice(idx, 1);
      renderPdfPage();
    });
  });

  // Pick from records
  document.getElementById('pdfPickFromRecords')?.addEventListener('click', () => {
    showPdfFilePickerModal();
  });

  // Local upload
  document.getElementById('pdfUploadLocal')?.addEventListener('click', async () => {
    const input = document.createElement('input');
    input.type = 'file';
    input.multiple = true;
    input.accept = '.jpg,.jpeg,.png,.webp,.pdf';
    input.style.display = 'none';
    document.body.appendChild(input);

    input.addEventListener('change', async () => {
      const files = Array.from(input.files ?? []);
      const uncId = await getUncategorizedId();
      if (!uncId) { input.remove(); return; }

      for (const file of files) {
        if (!PDF_ACCEPT_TYPES.has(file.type)) continue;
        const id = await addFileRecord({
          filename: file.name,
          fileType: file.type,
          fileBody: file,
          fileSize: file.size,
          fileDescription: '',
          categoryId: uncId,
          createdAt: Date.now(),
        });
        fileRecords = await getAllFileRecords();
        pdfMergeQueue.push({ fileRecordId: id, name: file.name, type: file.type });
      }
      input.remove();
      renderPdfPage();
    });

    input.click();
  });

  // Merge
  document.getElementById('pdfMergeBtn')?.addEventListener('click', async () => {
    if (pdfMergeQueue.length === 0) return;
    const btn = document.getElementById('pdfMergeBtn') as HTMLButtonElement;
    btn.textContent = '合成中...';
    btn.disabled = true;

    try {
      const mergeItems: MergeFileItem[] = [];
      for (const q of pdfMergeQueue) {
        const file = fileRecords.find(f => f.id === q.fileRecordId);
        if (!file) continue;
        const buf = await file.fileBody.arrayBuffer();
        mergeItems.push({
          name: file.filename,
          type: file.fileType === 'application/pdf' ? 'pdf' : 'image',
          data: buf,
        });
      }

      const pdfBytes = await mergeFilesToPdf(mergeItems);
      const pdfBlob = new Blob([pdfBytes], { type: 'application/pdf' });

      // Save to fileRecords
      const filenameInput = document.getElementById('pdfFilename') as HTMLInputElement;
      const filename = (filenameInput.value.trim() || '合并文档') + '.pdf';
      const uncId = await getUncategorizedId();
      await addFileRecord({
        filename,
        fileType: 'application/pdf',
        fileBody: pdfBlob,
        fileSize: pdfBlob.size,
        fileDescription: '',
        categoryId: uncId ?? 0,
        createdAt: Date.now(),
      });
      fileRecords = await getAllFileRecords();

      // Trigger download
      const url = URL.createObjectURL(pdfBlob);
      const a = document.createElement('a');
      a.href = url;
      a.download = filename;
      document.body.appendChild(a);
      a.click();
      a.remove();
      setTimeout(() => URL.revokeObjectURL(url), 5000);

      // Clear queue
      pdfMergeQueue = [];
      renderPdfPage();
    } catch (err) {
      console.error('PDF merge failed:', err);
      alert('PDF 合成失败，请检查文件格式');
      btn.textContent = '合成 PDF';
      btn.disabled = false;
    }
  });
}

function showPdfFilePickerModal() {
  const eligibleFiles = fileRecords.filter(f => PDF_ACCEPT_TYPES.has(f.fileType));
  const itemsHtml = eligibleFiles.map(f => `
    <label class="pdf-picker-item">
      <input type="checkbox" class="pdf-picker-checkbox" data-file-id="${f.id}" />
      <span class="pdf-picker-name">${escapeHtml(f.filename)}</span>
      <span class="pdf-picker-type">${f.fileType === 'application/pdf' ? 'PDF' : '图片'}</span>
    </label>
  `).join('');

  const overlay = document.createElement('div');
  overlay.className = 'pdf-picker-overlay';
  overlay.innerHTML = `
    <div class="pdf-picker-modal">
      <div class="pdf-picker-header">
        <h3>选择文件</h3>
        <button class="pdf-picker-close" id="pdfPickerClose">×</button>
      </div>
      <div class="pdf-picker-list">${itemsHtml}</div>
      <div class="pdf-picker-footer">
        <button class="pdf-btn-secondary" id="pdfPickerCancel">取消</button>
        <button class="pdf-btn-primary" id="pdfPickerConfirm">确认添加</button>
      </div>
    </div>
  `;
  document.body.appendChild(overlay);

  const close = () => overlay.remove();
  overlay.querySelector('#pdfPickerClose')?.addEventListener('click', close);
  overlay.querySelector('#pdfPickerCancel')?.addEventListener('click', close);
  overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });

  overlay.querySelector('#pdfPickerConfirm')?.addEventListener('click', () => {
    const checked = overlay.querySelectorAll<HTMLInputElement>('.pdf-picker-checkbox:checked');
    checked.forEach(cb => {
      const fileId = Number(cb.dataset.fileId);
      const file = fileRecords.find(f => f.id === fileId);
      if (!file || pdfMergeQueue.some(q => q.fileRecordId === fileId)) return;
      pdfMergeQueue.push({ fileRecordId: fileId, name: file.filename, type: file.fileType });
    });
    close();
    renderPdfPage();
  });
}
```

- [ ] **Step 6: Commit**

```bash
git add entrypoints/options/main.ts
git commit -m "feat: add PDF merge page UI and logic"
```

---

## Task 4: Add styles for PDF page

**Files:**
- Modify: `entrypoints/options/style.css`

- [ ] **Step 1: Append styles at the end of the file**

```css
/* ===== PDF Merge Page ===== */

.pdf-page {
  max-width: 720px;
}

.pdf-filename-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 20px;
}

.pdf-filename-row label {
  font-size: 14px;
  font-weight: 500;
  color: #374151;
  white-space: nowrap;
}

.pdf-filename-input {
  flex: 1;
  padding: 8px 12px;
  border: 1px solid #E5E7EB;
  border-radius: 8px;
  font-size: 14px;
  background: #fff;
  color: #1F2937;
}

.pdf-filename-input:focus {
  outline: none;
  border-color: #257FFD;
}

.pdf-filename-ext {
  font-size: 14px;
  color: #6B7280;
}

.pdf-queue {
  background: #fff;
  border: 1px solid #E5E7EB;
  border-radius: 10px;
  min-height: 120px;
  margin-bottom: 20px;
  overflow: hidden;
}

.pdf-queue-empty {
  padding: 40px;
  text-align: center;
  color: #9CA3AF;
  font-size: 14px;
}

.pdf-queue-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid #F3F4F6;
  cursor: grab;
  transition: background 0.15s;
}

.pdf-queue-item:last-child {
  border-bottom: none;
}

.pdf-queue-item:hover {
  background: #F9FAFB;
}

.pdf-queue-item.dragging {
  opacity: 0.5;
  cursor: grabbing;
}

.pdf-queue-item.drag-over {
  border-top: 2px solid #257FFD;
}

.pdf-queue-drag-handle {
  color: #D1D5DB;
  font-size: 16px;
  cursor: grab;
  user-select: none;
}

.pdf-queue-thumb {
  width: 40px;
  height: 40px;
  border-radius: 6px;
  background: #F3F4F6;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  flex-shrink: 0;
}

.pdf-queue-thumb-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.pdf-queue-thumb-icon {
  font-size: 18px;
}

.pdf-queue-name {
  flex: 1;
  font-size: 14px;
  color: #1F2937;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pdf-queue-remove {
  width: 28px;
  height: 28px;
  border: none;
  background: transparent;
  color: #9CA3AF;
  font-size: 18px;
  cursor: pointer;
  border-radius: 6px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.pdf-queue-remove:hover {
  background: #FEE2E2;
  color: #EF4444;
}

.pdf-actions {
  display: flex;
  gap: 12px;
  flex-wrap: wrap;
}

.pdf-btn-primary,
.pdf-btn-secondary {
  padding: 10px 20px;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  border: none;
  transition: opacity 0.15s;
}

.pdf-btn-primary {
  background: #257FFD;
  color: #fff;
}

.pdf-btn-primary:hover:not(:disabled) {
  opacity: 0.9;
}

.pdf-btn-primary:disabled,
.pdf-btn-primary.disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.pdf-btn-secondary {
  background: #F3F4F6;
  color: #374151;
  border: 1px solid #E5E7EB;
}

.pdf-btn-secondary:hover {
  background: #E5E7EB;
}

/* ===== PDF File Picker Modal ===== */

.pdf-picker-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.4);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 200;
}

.pdf-picker-modal {
  background: #fff;
  border-radius: 12px;
  width: 480px;
  max-width: 90vw;
  max-height: 80vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.pdf-picker-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 20px;
  border-bottom: 1px solid #F3F4F6;
}

.pdf-picker-header h3 {
  font-size: 16px;
  font-weight: 600;
  color: #1F2937;
}

.pdf-picker-close {
  background: none;
  border: none;
  font-size: 22px;
  color: #9CA3AF;
  cursor: pointer;
}

.pdf-picker-list {
  flex: 1;
  overflow-y: auto;
  padding: 8px 0;
}

.pdf-picker-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 20px;
  cursor: pointer;
  transition: background 0.15s;
}

.pdf-picker-item:hover {
  background: #F9FAFB;
}

.pdf-picker-checkbox {
  width: 16px;
  height: 16px;
  accent-color: #257FFD;
}

.pdf-picker-name {
  flex: 1;
  font-size: 14px;
  color: #1F2937;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pdf-picker-type {
  font-size: 12px;
  color: #9CA3AF;
  background: #F3F4F6;
  padding: 2px 8px;
  border-radius: 4px;
}

.pdf-picker-footer {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  padding: 12px 20px;
  border-top: 1px solid #F3F4F6;
}
```

- [ ] **Step 2: Commit**

```bash
git add entrypoints/options/style.css
git commit -m "feat: add PDF merge page styles"
```

---

## Task 5: Verify compilation

**Files:**
- Modify: none (verification only)

- [ ] **Step 1: Run type check**

Run: `npm run compile`

Expected: No errors from new code.

- [ ] **Step 2: Run dev build**

Run: `npm run dev`

Expected: Build succeeds, extension loads. Navigate to Options → PDF 汇总 page.

- [ ] **Step 3: Test manually**

1. Open Options page, click PDF 汇总
2. Click「从证明材料选取」，勾选几个图片/PDF，确认添加
3. 拖拽排序
4. 输入文件名，点击「合成 PDF」
5. 确认下载成功，且证明材料里出现新文件

- [ ] **Step 4: Final commit**

```bash
git add .
git commit -m "feat: complete PDF merge feature"
```

---

## Spec Coverage Check

| Spec Requirement | Task |
|-----------------|------|
| 在 PDF 汇总页面实现 | Task 3: `switchPage()` wire + `renderPdfPage()` |
| 从证明材料选取 | Task 3: `showPdfFilePickerModal()` |
| 本地上传并存入数据库 | Task 3: `pdfUploadLocal` handler |
| 拖拽排序 | Task 3: HTML5 drag-and-drop in `bindPdfPageEvents()` |
| 图片转 A4 居中留白 | Task 2: `mergeFilesToPdf()` image embedding logic |
| PDF 合并拷贝页面 | Task 2: `mergeFilesToPdf()` PDF copy logic |
| webp 转 png | Task 2: `convertWebpToPng()` |
| 手动命名 + 下载 | Task 3: merge button handler |
| 保存到证明材料 | Task 3: `addFileRecord()` after merge |

## Placeholder Scan

No TBD/TODO placeholders. All functions, types, and file paths are exact.

## Type Consistency

- `MergeFileItem` from `utils/pdf-merge.ts` used in `main.ts`
- `PDF_ACCEPT_TYPES` uses same MIME types as existing `IMAGE_TYPES`
- `fileRecordId` in `pdfMergeQueue` matches `FileRecord.id` type (`number`)
