Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions e2e/pdf-studio.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { test, expect } from '@playwright/test';
import { PDFDocument } from 'pdf-lib';
import { readFileSync } from 'node:fs';
import { waitForIslands } from './helpers';

async function makePdf(pages: number): Promise<Buffer> {
const doc = await PDFDocument.create();
for (let i = 0; i < pages; i++) doc.addPage([200, 300]);
return Buffer.from(await doc.save());
}

async function downloadedPageCount(path: string): Promise<number> {
return (await PDFDocument.load(readFileSync(path))).getPageCount();
}

test('合併兩份 PDF → 下載檔頁數相加', async ({ page }) => {
await page.goto('/tools/pdf-studio');
await waitForIslands(page);

await page.locator('input[type="file"]').setInputFiles([
{ name: 'a.pdf', mimeType: 'application/pdf', buffer: await makePdf(3) },
{ name: 'b.pdf', mimeType: 'application/pdf', buffer: await makePdf(2) },
]);
await expect(page.getByText('a.pdf')).toBeVisible();
await expect(page.getByText('3 頁')).toBeVisible();

const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '合併下載' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('a-merged.pdf');
expect(await downloadedPageCount((await download.path())!)).toBe(5);
});

test('拆分:抽出 2,4 兩頁;亂打頁碼給明確錯誤', async ({ page }) => {
await page.goto('/tools/pdf-studio');
await waitForIslands(page);
await page.getByRole('button', { name: '拆分' }).click();

await page.locator('input[type="file"]').setInputFiles({ name: 'doc.pdf', mimeType: 'application/pdf', buffer: await makePdf(5) });
await expect(page.getByText('5 頁')).toBeVisible();

const rangeInput = page.getByLabel('要抽出的頁碼');
await rangeInput.fill('2, 99');
await expect(page.getByText(/頁碼格式不對或超出範圍/)).toBeVisible();
await expect(page.getByRole('button', { name: '抽出頁面下載' })).toBeDisabled();

await rangeInput.fill('2, 4');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '抽出頁面下載' }).click();
const download = await downloadPromise;
expect(await downloadedPageCount((await download.path())!)).toBe(2);
});

test('旋轉:全部頁面轉 90° 後下載', async ({ page }) => {
await page.goto('/tools/pdf-studio');
await waitForIslands(page);
await page.getByRole('button', { name: '旋轉' }).click();

await page.locator('input[type="file"]').setInputFiles({ name: 'scan.pdf', mimeType: 'application/pdf', buffer: await makePdf(2) });
await expect(page.getByText('2 頁')).toBeVisible();

const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '旋轉下載' }).click();
const download = await downloadPromise;
const doc = await PDFDocument.load(readFileSync((await download.path())!));
expect(doc.getPage(0).getRotation().angle).toBe(90);
expect(doc.getPage(1).getRotation().angle).toBe(90);
});
55 changes: 55 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"fit-file-parser": "^3.0.2",
"jsqr": "^1.4.0",
"marked": "^18.0.5",
"pdf-lib": "^1.17.1",
"qrcode": "^1.5.4",
"react": "^19.2.7",
"react-dom": "^19.2.7",
Expand Down
Binary file added public/og/en/pdf-studio.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/og/zh/pdf-studio.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/data/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,5 @@ export const tools: Tool[] = [
{ id: 'invoice-lottery', title: '發票對獎', titleEn: 'Invoice Lottery', description: '財政部最新開獎號碼,輸入末 3 碼立刻對獎。', descriptionEn: 'Check Taiwan uniform-invoice numbers against the latest official draw.', category: '實用工具', path: '/tools/invoice-lottery', icon: '🧧', status: 'available', keywords: ['發票', '統一發票', '對獎', '中獎號碼', 'invoice', 'lottery', 'receipt', '雲端發票', '財政部'] },
{ id: 'qr-scanner', title: 'QR 掃描', titleEn: 'QR Scanner', description: '相機或圖片掃描 QR 碼,網址、WiFi、名片自動解析。', descriptionEn: 'Scan QR codes from camera or images; URLs, WiFi and vCards parsed.', category: '實用工具', path: '/tools/qr-scanner', icon: '📷', status: 'available', keywords: ['qr', 'scan', 'scanner', '掃描', '掃碼', 'barcode', 'wifi', 'vcard', '解碼'] },
{ id: 'fuel-price', title: '中油油價', titleEn: 'Fuel Prices (TW)', description: '中油今日牌價與本週調價,附加油金額試算。', descriptionEn: 'Today’s CPC Taiwan fuel prices with a fill-up cost calculator.', category: '實用工具', path: '/tools/fuel-price', icon: '⛽', status: 'available', keywords: ['油價', '中油', '汽油', '柴油', '95', '92', '98', 'fuel', 'gas', 'petrol', 'diesel', '加油'] },
{ id: 'pdf-studio', title: 'PDF 工具', titleEn: 'PDF Studio', description: '合併、拆分與旋轉 PDF,檔案不離開瀏覽器。', descriptionEn: 'Merge, split and rotate PDFs — files never leave your browser.', category: '實用工具', path: '/tools/pdf-studio', icon: '📄', status: 'available', keywords: ['pdf', '合併', '拆分', '旋轉', 'merge', 'split', 'rotate', '頁面', '抽頁'] },
];
57 changes: 57 additions & 0 deletions src/lib/__tests__/pdfOps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { PDFDocument, degrees } from 'pdf-lib';
import { mergePdfs, extractPages, rotatePdf } from '../pdfOps';

async function makePdf(pages: number, rotate = 0): Promise<Uint8Array> {
const doc = await PDFDocument.create();
for (let i = 0; i < pages; i++) {
const page = doc.addPage([200, 300]);
if (rotate) page.setRotation(degrees(rotate));
}
return doc.save();
}

async function pageCount(bytes: Uint8Array): Promise<number> {
return (await PDFDocument.load(bytes)).getPageCount();
}

describe('mergePdfs', () => {
it('兩份 PDF 依序合併,頁數相加', async () => {
const a = await makePdf(3);
const b = await makePdf(2);
const merged = await mergePdfs([a, b]);
expect(await pageCount(merged)).toBe(5);
});

it('單一檔案也能過(等於重存)', async () => {
const merged = await mergePdfs([await makePdf(4)]);
expect(await pageCount(merged)).toBe(4);
});
});

describe('extractPages', () => {
it('照指定順序抽頁成新檔', async () => {
const src = await makePdf(5);
const out = await extractPages(src, [4, 2]);
expect(await pageCount(out)).toBe(2);
});
});

describe('rotatePdf', () => {
it('全部頁面加轉 90°,原有旋轉角度要累加', async () => {
const src = await makePdf(2, 90);
const out = await rotatePdf(src, 90);
const doc = await PDFDocument.load(out);
expect(doc.getPage(0).getRotation().angle).toBe(180);
expect(doc.getPage(1).getRotation().angle).toBe(180);
});

it('只轉指定頁,其他頁不動;累加後 mod 360', async () => {
const src = await makePdf(3, 270);
const out = await rotatePdf(src, 180, [2]);
const doc = await PDFDocument.load(out);
expect(doc.getPage(0).getRotation().angle).toBe(270);
expect(doc.getPage(1).getRotation().angle).toBe(90); // 270+180 mod 360
expect(doc.getPage(2).getRotation().angle).toBe(270);
});
});
31 changes: 31 additions & 0 deletions src/lib/__tests__/pdfPages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { parsePageRanges } from '../pdfPages';

describe('parsePageRanges', () => {
it('單頁、範圍、混用(1-indexed,保留輸入順序)', () => {
expect(parsePageRanges('1-3, 7, 10-12', 20)).toEqual([1, 2, 3, 7, 10, 11, 12]);
expect(parsePageRanges('5', 10)).toEqual([5]);
expect(parsePageRanges('2-2', 10)).toEqual([2]);
});

it('全形逗號、頓號、多餘空白都容忍(中文使用者會這樣打)', () => {
expect(parsePageRanges('1,3、5 - 6', 10)).toEqual([1, 3, 5, 6]);
});

it('重複頁只取一次,順序以先出現為準', () => {
expect(parsePageRanges('3, 1-4', 10)).toEqual([3, 1, 2, 4]);
});

it('超出總頁數、反向範圍、0 或負數 → null(整串拒絕,不猜使用者意圖)', () => {
expect(parsePageRanges('1-99', 10)).toBeNull();
expect(parsePageRanges('5-3', 10)).toBeNull();
expect(parsePageRanges('0', 10)).toBeNull();
expect(parsePageRanges('-2', 10)).toBeNull();
});

it('空字串或垃圾輸入 → null', () => {
expect(parsePageRanges('', 10)).toBeNull();
expect(parsePageRanges('abc', 10)).toBeNull();
expect(parsePageRanges('1-', 10)).toBeNull();
});
});
37 changes: 37 additions & 0 deletions src/lib/pdfOps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// PDF 操作核心:合併/抽頁/旋轉。全部在瀏覽器(或測試的 Node)本機執行,
// 檔案不離開使用者的機器。頁碼一律 1-indexed(與 UI、parsePageRanges 一致)。

import { PDFDocument, degrees } from 'pdf-lib';

export async function mergePdfs(files: Uint8Array[]): Promise<Uint8Array> {
const out = await PDFDocument.create();
for (const bytes of files) {
const src = await PDFDocument.load(bytes);
const pages = await out.copyPages(src, src.getPageIndices());
for (const p of pages) out.addPage(p);
}
return out.save();
}

export async function extractPages(bytes: Uint8Array, pages: number[]): Promise<Uint8Array> {
const src = await PDFDocument.load(bytes);
const out = await PDFDocument.create();
const copied = await out.copyPages(src, pages.map((p) => p - 1));
for (const p of copied) out.addPage(p);
return out.save();
}

/** 指定頁(省略 = 全部)在原有旋轉上加轉 degreesToAdd(90/180/270) */
export async function rotatePdf(bytes: Uint8Array, degreesToAdd: number, pages?: number[]): Promise<Uint8Array> {
const doc = await PDFDocument.load(bytes);
const targets = pages ?? doc.getPageIndices().map((i) => i + 1);
for (const p of targets) {
const page = doc.getPage(p - 1);
page.setRotation(degrees((page.getRotation().angle + degreesToAdd) % 360));
}
return doc.save();
}

export async function pdfPageCount(bytes: Uint8Array): Promise<number> {
return (await PDFDocument.load(bytes)).getPageCount();
}
29 changes: 29 additions & 0 deletions src/lib/pdfPages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// 頁碼範圍字串解析:「1-3, 7, 10-12」→ [1,2,3,7,10,11,12](1-indexed)。
// 任一段不合法就整串回 null —— 寧可要求改正,不猜使用者意圖。

export function parsePageRanges(input: string, totalPages: number): number[] | null {
// 全形逗號與頓號一併當分隔符(中文輸入法很常見)
const parts = input
.replace(/[,、]/g, ',')
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
if (parts.length === 0) return null;

const seen = new Set<number>();
const pages: number[] = [];
for (const part of parts) {
const m = part.match(/^(\d+)(?:\s*-\s*(\d+))?$/);
if (!m) return null;
const start = Number(m[1]);
const end = m[2] !== undefined ? Number(m[2]) : start;
if (start < 1 || end < start || end > totalPages) return null;
for (let p = start; p <= end; p++) {
if (!seen.has(p)) {
seen.add(p);
pages.push(p);
}
}
}
return pages;
}
12 changes: 12 additions & 0 deletions src/pages/en/tools/pdf-studio.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PdfStudio from '../../../tools/PdfStudio.tsx';
import { tools } from '../../../data/tools';

const tool = tools.find((t) => t.id === 'pdf-studio')!;
---
<BaseLayout title={`${tool.titleEn} — Tiglet`} description={tool.descriptionEn} toolId={tool.id}>
<a href="/en/" class="text-sm text-muted hover:text-accent">← All tools</a>
<h1 class="mt-4 mb-8 font-serif text-3xl text-ink">{tool.titleEn}</h1>
<PdfStudio client:visible locale="en" />
</BaseLayout>
9 changes: 9 additions & 0 deletions src/pages/tools/pdf-studio.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PdfStudio from '../../tools/PdfStudio.tsx';
---
<BaseLayout title="PDF 工具 — Tiglet" description="PDF 合併、拆分(抽出頁碼範圍)與旋轉,全部在瀏覽器本機完成,檔案不會上傳到任何伺服器。" toolId="pdf-studio">
<a href="/" class="text-sm text-muted hover:text-accent">← 回到所有工具</a>
<h1 class="mt-4 mb-8 font-serif text-3xl text-ink">PDF 工具</h1>
<PdfStudio client:visible />
</BaseLayout>
Loading
Loading