Skip to content

Commit cdd6cab

Browse files
authored
feat(bench): add page splitting/copying benchmarks and markdown report (#27)
Add benchmarks for page splitting, copying, and merging (#26). Synthetic 100-page and 2000-page PDFs are generated from sample.pdf and cached to disk for reuse. New benchmark suites: - splitting.bench.ts: single-page extraction, full split, batch extract - copying.bench.ts: cross-doc copy, duplication, merging - comparison.bench.ts: head-to-head vs pdf-lib for all of the above Report generation: - scripts/bench-report.ts transforms vitest JSON output to markdown - reports/benchmarks.md committed to repo, updated by CI - .github/workflows/bench.yml runs weekly + on push to main
1 parent e5f0671 commit cdd6cab

File tree

9 files changed

+1010
-3
lines changed

9 files changed

+1010
-3
lines changed

.github/workflows/bench.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Benchmarks
2+
3+
on:
4+
# Run on pushes to main (to keep report up to date)
5+
push:
6+
branches: [main]
7+
# Run weekly on Mondays at 06:00 UTC
8+
schedule:
9+
- cron: "0 6 * * 1"
10+
# Allow manual trigger
11+
workflow_dispatch:
12+
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: true
16+
17+
permissions:
18+
contents: write
19+
20+
jobs:
21+
bench:
22+
name: Run Benchmarks
23+
runs-on: ubuntu-latest
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@v4
27+
28+
- name: Setup Bun
29+
uses: oven-sh/setup-bun@v2
30+
31+
- name: Install dependencies
32+
run: bun install --frozen-lockfile
33+
34+
- name: Run benchmarks and generate report
35+
run: bun run bench:report
36+
37+
- name: Upload JSON results
38+
uses: actions/upload-artifact@v4
39+
with:
40+
name: bench-results
41+
path: reports/bench-results.json
42+
retention-days: 90
43+
44+
- name: Commit updated report
45+
run: |
46+
git config user.name "github-actions[bot]"
47+
git config user.email "github-actions[bot]@users.noreply.github.com"
48+
49+
git add reports/benchmarks.md
50+
51+
if git diff --staged --quiet; then
52+
echo "No changes to benchmark report"
53+
else
54+
git commit -m "docs: update benchmark report"
55+
git push
56+
fi

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,8 @@ debug/
5252
fixtures/benchmarks/
5353
fixtures/private/
5454

55+
# Benchmark JSON results (machine-specific)
56+
reports/bench-results.json
57+
5558
# Temporary files
5659
tmp/

benchmarks/comparison.bench.ts

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import { PDFDocument } from "pdf-lib";
99
import { bench, describe } from "vitest";
1010

1111
import { PDF } from "../src";
12-
import { loadFixture, getHeavyPdf } from "./fixtures";
12+
import { getHeavyPdf, getSynthetic100, getSynthetic2000, loadFixture } from "./fixtures";
1313

14-
// Pre-load fixture
14+
// Pre-load fixtures
1515
const pdfBytes = await getHeavyPdf();
16+
const synthetic100 = await getSynthetic100();
17+
const synthetic2000 = await getSynthetic2000();
1618

1719
describe("Load PDF", () => {
1820
bench("libpdf", async () => {
@@ -119,3 +121,145 @@ describe("Load, modify, and save PDF", () => {
119121
await pdf.save();
120122
});
121123
});
124+
125+
// ─────────────────────────────────────────────────────────────────────────────
126+
// Page splitting comparison (issue #26)
127+
// ─────────────────────────────────────────────────────────────────────────────
128+
129+
describe("Extract single page from 100-page PDF", () => {
130+
bench("libpdf", async () => {
131+
const pdf = await PDF.load(synthetic100);
132+
const extracted = await pdf.extractPages([0]);
133+
await extracted.save();
134+
});
135+
136+
bench("pdf-lib", async () => {
137+
const pdf = await PDFDocument.load(synthetic100);
138+
const newDoc = await PDFDocument.create();
139+
const [page] = await newDoc.copyPages(pdf, [0]);
140+
newDoc.addPage(page);
141+
await newDoc.save();
142+
});
143+
});
144+
145+
describe("Split 100-page PDF into single-page PDFs", () => {
146+
bench(
147+
"libpdf",
148+
async () => {
149+
const pdf = await PDF.load(synthetic100);
150+
const pageCount = pdf.getPageCount();
151+
152+
for (let i = 0; i < pageCount; i++) {
153+
const single = await pdf.extractPages([i]);
154+
await single.save();
155+
}
156+
},
157+
{ warmupIterations: 1, iterations: 3 },
158+
);
159+
160+
bench(
161+
"pdf-lib",
162+
async () => {
163+
const pdf = await PDFDocument.load(synthetic100);
164+
const pageCount = pdf.getPageCount();
165+
166+
for (let i = 0; i < pageCount; i++) {
167+
const newDoc = await PDFDocument.create();
168+
const [page] = await newDoc.copyPages(pdf, [i]);
169+
newDoc.addPage(page);
170+
await newDoc.save();
171+
}
172+
},
173+
{ warmupIterations: 1, iterations: 3 },
174+
);
175+
});
176+
177+
describe(`Split 2000-page PDF into single-page PDFs (${(synthetic2000.length / 1024 / 1024).toFixed(1)}MB)`, () => {
178+
bench(
179+
"libpdf",
180+
async () => {
181+
const pdf = await PDF.load(synthetic2000);
182+
const pageCount = pdf.getPageCount();
183+
184+
for (let i = 0; i < pageCount; i++) {
185+
const single = await pdf.extractPages([i]);
186+
await single.save();
187+
}
188+
},
189+
{ warmupIterations: 0, iterations: 1, time: 0 },
190+
);
191+
192+
bench(
193+
"pdf-lib",
194+
async () => {
195+
const pdf = await PDFDocument.load(synthetic2000);
196+
const pageCount = pdf.getPageCount();
197+
198+
for (let i = 0; i < pageCount; i++) {
199+
const newDoc = await PDFDocument.create();
200+
const [page] = await newDoc.copyPages(pdf, [i]);
201+
newDoc.addPage(page);
202+
await newDoc.save();
203+
}
204+
},
205+
{ warmupIterations: 0, iterations: 1, time: 0 },
206+
);
207+
});
208+
209+
describe("Copy 10 pages between documents", () => {
210+
bench("libpdf", async () => {
211+
const source = await PDF.load(synthetic100);
212+
const dest = PDF.create();
213+
const indices = Array.from({ length: 10 }, (_, i) => i);
214+
await dest.copyPagesFrom(source, indices);
215+
await dest.save();
216+
});
217+
218+
bench("pdf-lib", async () => {
219+
const source = await PDFDocument.load(synthetic100);
220+
const dest = await PDFDocument.create();
221+
const indices = Array.from({ length: 10 }, (_, i) => i);
222+
const pages = await dest.copyPages(source, indices);
223+
224+
for (const page of pages) {
225+
dest.addPage(page);
226+
}
227+
228+
await dest.save();
229+
});
230+
});
231+
232+
describe("Merge 2 x 100-page PDFs", () => {
233+
bench(
234+
"libpdf",
235+
async () => {
236+
const merged = await PDF.merge([synthetic100, synthetic100]);
237+
await merged.save();
238+
},
239+
{ warmupIterations: 1, iterations: 3 },
240+
);
241+
242+
bench(
243+
"pdf-lib",
244+
async () => {
245+
const doc1 = await PDFDocument.load(synthetic100);
246+
const doc2 = await PDFDocument.load(synthetic100);
247+
const merged = await PDFDocument.create();
248+
249+
const pages1 = await merged.copyPages(doc1, doc1.getPageIndices());
250+
251+
for (const page of pages1) {
252+
merged.addPage(page);
253+
}
254+
255+
const pages2 = await merged.copyPages(doc2, doc2.getPageIndices());
256+
257+
for (const page of pages2) {
258+
merged.addPage(page);
259+
}
260+
261+
await merged.save();
262+
},
263+
{ warmupIterations: 1, iterations: 3 },
264+
);
265+
});

benchmarks/copying.bench.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* PDF page-copying and merging benchmarks.
3+
*
4+
* Tests the performance of copying pages between documents and merging
5+
* multiple PDFs. These operations are closely related to splitting
6+
* (issue #26) and represent the other side of the workflow.
7+
*/
8+
9+
import { bench, describe } from "vitest";
10+
11+
import { PDF } from "../src";
12+
import { getSynthetic100, loadFixture, mediumPdfPath } from "./fixtures";
13+
14+
// Pre-load fixtures
15+
const mediumPdf = await loadFixture(mediumPdfPath);
16+
const synthetic100 = await getSynthetic100();
17+
18+
// ─────────────────────────────────────────────────────────────────────────────
19+
// Page copying
20+
// ─────────────────────────────────────────────────────────────────────────────
21+
22+
describe("Copy pages between documents", () => {
23+
bench("copy 1 page", async () => {
24+
const source = await PDF.load(mediumPdf);
25+
const dest = PDF.create();
26+
await dest.copyPagesFrom(source, [0]);
27+
await dest.save();
28+
});
29+
30+
bench("copy 10 pages from 100-page PDF", async () => {
31+
const source = await PDF.load(synthetic100);
32+
const dest = PDF.create();
33+
const indices = Array.from({ length: 10 }, (_, i) => i);
34+
await dest.copyPagesFrom(source, indices);
35+
await dest.save();
36+
});
37+
38+
bench(
39+
"copy all 100 pages",
40+
async () => {
41+
const source = await PDF.load(synthetic100);
42+
const dest = PDF.create();
43+
const indices = Array.from({ length: 100 }, (_, i) => i);
44+
await dest.copyPagesFrom(source, indices);
45+
await dest.save();
46+
},
47+
{ warmupIterations: 1, iterations: 3 },
48+
);
49+
});
50+
51+
// ─────────────────────────────────────────────────────────────────────────────
52+
// Self-copy (page duplication)
53+
// ─────────────────────────────────────────────────────────────────────────────
54+
55+
describe("Duplicate pages within same document", () => {
56+
bench("duplicate page 0", async () => {
57+
const pdf = await PDF.load(mediumPdf);
58+
await pdf.copyPagesFrom(pdf, [0]);
59+
await pdf.save();
60+
});
61+
62+
bench("duplicate all pages (double the document)", async () => {
63+
const pdf = await PDF.load(mediumPdf);
64+
const indices = Array.from({ length: pdf.getPageCount() }, (_, i) => i);
65+
await pdf.copyPagesFrom(pdf, indices);
66+
await pdf.save();
67+
});
68+
});
69+
70+
// ─────────────────────────────────────────────────────────────────────────────
71+
// Merging
72+
// ─────────────────────────────────────────────────────────────────────────────
73+
74+
describe("Merge PDFs", () => {
75+
bench("merge 2 small PDFs", async () => {
76+
const merged = await PDF.merge([mediumPdf, mediumPdf]);
77+
await merged.save();
78+
});
79+
80+
bench("merge 10 small PDFs", async () => {
81+
const sources = Array.from({ length: 10 }, () => mediumPdf);
82+
const merged = await PDF.merge(sources);
83+
await merged.save();
84+
});
85+
86+
bench(
87+
"merge 2 x 100-page PDFs",
88+
async () => {
89+
const merged = await PDF.merge([synthetic100, synthetic100]);
90+
await merged.save();
91+
},
92+
{ warmupIterations: 1, iterations: 3 },
93+
);
94+
});

0 commit comments

Comments
 (0)