diff --git a/e2e/pdf-studio.spec.ts b/e2e/pdf-studio.spec.ts new file mode 100644 index 0000000..f48d65f --- /dev/null +++ b/e2e/pdf-studio.spec.ts @@ -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 { + 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 { + 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); +}); diff --git a/package-lock.json b/package-lock.json index 90be3da..3e7e5cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,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", @@ -3342,6 +3343,36 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/standard-fonts/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@pdf-lib/upng/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/@playwright/test": { "version": "1.61.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", @@ -11494,6 +11525,30 @@ "dev": true, "license": "MIT" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", diff --git a/package.json b/package.json index e486290..f82c338 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/og/en/pdf-studio.png b/public/og/en/pdf-studio.png new file mode 100644 index 0000000..39fa458 Binary files /dev/null and b/public/og/en/pdf-studio.png differ diff --git a/public/og/zh/pdf-studio.png b/public/og/zh/pdf-studio.png new file mode 100644 index 0000000..3651c0c Binary files /dev/null and b/public/og/zh/pdf-studio.png differ diff --git a/src/data/tools.ts b/src/data/tools.ts index dcff015..bfc1c7b 100644 --- a/src/data/tools.ts +++ b/src/data/tools.ts @@ -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', '頁面', '抽頁'] }, ]; diff --git a/src/lib/__tests__/pdfOps.test.ts b/src/lib/__tests__/pdfOps.test.ts new file mode 100644 index 0000000..e59ddb9 --- /dev/null +++ b/src/lib/__tests__/pdfOps.test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/src/lib/__tests__/pdfPages.test.ts b/src/lib/__tests__/pdfPages.test.ts new file mode 100644 index 0000000..b2c985c --- /dev/null +++ b/src/lib/__tests__/pdfPages.test.ts @@ -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(); + }); +}); diff --git a/src/lib/pdfOps.ts b/src/lib/pdfOps.ts new file mode 100644 index 0000000..a11c348 --- /dev/null +++ b/src/lib/pdfOps.ts @@ -0,0 +1,37 @@ +// PDF 操作核心:合併/抽頁/旋轉。全部在瀏覽器(或測試的 Node)本機執行, +// 檔案不離開使用者的機器。頁碼一律 1-indexed(與 UI、parsePageRanges 一致)。 + +import { PDFDocument, degrees } from 'pdf-lib'; + +export async function mergePdfs(files: Uint8Array[]): Promise { + 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 { + 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 { + 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 { + return (await PDFDocument.load(bytes)).getPageCount(); +} diff --git a/src/lib/pdfPages.ts b/src/lib/pdfPages.ts new file mode 100644 index 0000000..de0371c --- /dev/null +++ b/src/lib/pdfPages.ts @@ -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(); + 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; +} diff --git a/src/pages/en/tools/pdf-studio.astro b/src/pages/en/tools/pdf-studio.astro new file mode 100644 index 0000000..25c3136 --- /dev/null +++ b/src/pages/en/tools/pdf-studio.astro @@ -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')!; +--- + + ← All tools +

{tool.titleEn}

+ +
diff --git a/src/pages/tools/pdf-studio.astro b/src/pages/tools/pdf-studio.astro new file mode 100644 index 0000000..33e5d60 --- /dev/null +++ b/src/pages/tools/pdf-studio.astro @@ -0,0 +1,9 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +import PdfStudio from '../../tools/PdfStudio.tsx'; +--- + + ← 回到所有工具 +

PDF 工具

+ +
diff --git a/src/tools/PdfStudio.tsx b/src/tools/PdfStudio.tsx new file mode 100644 index 0000000..c3ec1fc --- /dev/null +++ b/src/tools/PdfStudio.tsx @@ -0,0 +1,280 @@ +import { useState } from 'react'; +import Tabs from '../components/Tabs'; +import { parsePageRanges } from '../lib/pdfPages'; +import { mergePdfs, extractPages, rotatePdf, pdfPageCount } from '../lib/pdfOps'; +import type { Locale } from '../lib/i18n'; + +const L = { + zh: { + tabMerge: '合併', + tabSplit: '拆分', + tabRotate: '旋轉', + addFiles: '選擇 PDF 檔案(可多選)', + addFile: '選擇 PDF 檔案', + pages: (n: number) => `${n} 頁`, + up: '上移', + down: '下移', + remove: '移除', + fileAria: (name: string) => `檔案 ${name}`, + mergeBtn: '合併下載', + mergeHint: '依清單順序合併,可用上移/下移調整。', + rangeLabel: '要抽出的頁碼', + rangePlaceholder: '例如 1-3, 7, 10-12', + rangeInvalid: '頁碼格式不對或超出範圍,請用「1-3, 7」這樣的寫法。', + splitBtn: '抽出頁面下載', + rotateAngle: '旋轉角度', + rotateRange: '頁碼(留空=全部頁面)', + rotateBtn: '旋轉下載', + working: '處理中…', + loadError: '這個檔案無法讀取,可能不是有效的 PDF 或有密碼保護。', + privacy: '所有處理都在你的瀏覽器本機完成,PDF 不會上傳到任何伺服器。', + deg: (d: number) => `${d}°`, + }, + en: { + tabMerge: 'Merge', + tabSplit: 'Split', + tabRotate: 'Rotate', + addFiles: 'Choose PDF files (multiple allowed)', + addFile: 'Choose a PDF file', + pages: (n: number) => `${n} page${n === 1 ? '' : 's'}`, + up: 'Up', + down: 'Down', + remove: 'Remove', + fileAria: (name: string) => `File ${name}`, + mergeBtn: 'Merge & download', + mergeHint: 'Merged in list order — use Up / Down to rearrange.', + rangeLabel: 'Pages to extract', + rangePlaceholder: 'e.g. 1-3, 7, 10-12', + rangeInvalid: 'Invalid page range — use the “1-3, 7” format within the page count.', + splitBtn: 'Extract & download', + rotateAngle: 'Rotation', + rotateRange: 'Pages (empty = all pages)', + rotateBtn: 'Rotate & download', + working: 'Working…', + loadError: 'Could not read this file — it may not be a valid PDF, or it is password-protected.', + privacy: 'Everything happens locally in your browser — your PDFs are never uploaded.', + deg: (d: number) => `${d}°`, + }, +} as const; +type Dict = (typeof L)[Locale]; + +interface Loaded { + name: string; + bytes: Uint8Array; + pages: number; +} + +const btn = 'rounded-lg border border-edge px-4 py-2 text-sm text-ink transition-colors hover:border-accent hover:text-accent disabled:opacity-40 disabled:hover:border-edge disabled:hover:text-ink'; +const primaryBtn = 'rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-40'; +const smallBtn = 'rounded border border-edge px-2 py-0.5 text-xs text-muted transition-colors hover:border-accent hover:text-accent disabled:opacity-30 disabled:hover:border-edge disabled:hover:text-muted'; +const inputClass = 'w-full rounded-lg border border-edge bg-surface px-3 py-2.5 text-ink outline-none transition-colors focus:border-accent'; + +function download(bytes: Uint8Array, filename: string) { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([bytes.buffer as ArrayBuffer], { type: 'application/pdf' })); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); +} + +async function loadFile(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + return { name: file.name, bytes, pages: await pdfPageCount(bytes) }; +} + +function baseName(name: string): string { + return name.replace(/\.pdf$/i, ''); +} + +function FilePicker({ label, multiple, onFiles, t }: { label: string; multiple: boolean; onFiles: (files: Loaded[]) => void; t: Dict }) { + const [error, setError] = useState(''); + return ( +
+ + {error &&

{error}

} +
+ ); +} + +function MergePanel({ t }: { t: Dict }) { + const [files, setFiles] = useState([]); + const [busy, setBusy] = useState(false); + + function move(i: number, dir: -1 | 1) { + setFiles((fs) => { + const next = [...fs]; + [next[i], next[i + dir]] = [next[i + dir], next[i]]; + return next; + }); + } + + return ( +
+ setFiles((fs) => [...fs, ...f])} t={t} /> + {files.length > 0 && ( +
    + {files.map((f, i) => ( +
  • + {f.name} + {t.pages(f.pages)} + + + +
  • + ))} +
+ )} +

{t.mergeHint}

+ +
+ ); +} + +function SplitPanel({ t }: { t: Dict }) { + const [file, setFile] = useState(null); + const [range, setRange] = useState(''); + const [busy, setBusy] = useState(false); + + const pages = file && range.trim() !== '' ? parsePageRanges(range, file.pages) : null; + const invalid = file !== null && range.trim() !== '' && pages === null; + + return ( +
+ setFile(f[0])} t={t} /> + {file && ( + <> +

+ {file.name} ・ {t.pages(file.pages)} +

+ + {invalid &&

{t.rangeInvalid}

} + + + )} +
+ ); +} + +function RotatePanel({ t }: { t: Dict }) { + const [file, setFile] = useState(null); + const [angle, setAngle] = useState(90); + const [range, setRange] = useState(''); + const [busy, setBusy] = useState(false); + + const pages = file && range.trim() !== '' ? parsePageRanges(range, file.pages) : undefined; + const invalid = file !== null && range.trim() !== '' && pages === null; + + return ( +
+ setFile(f[0])} t={t} /> + {file && ( + <> +

+ {file.name} ・ {t.pages(file.pages)} +

+
+ + +
+ {invalid &&

{t.rangeInvalid}

} + + + )} +
+ ); +} + +export default function PdfStudio({ locale = 'zh' }: { locale?: Locale }) { + const t = L[locale]; + const [tab, setTab] = useState('merge'); + return ( +
+
+ +
+ {tab === 'merge' && } + {tab === 'split' && } + {tab === 'rotate' && } +

{t.privacy}

+
+ ); +}