mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-07 08:31:01 +01:00
392 lines
9.6 KiB
TypeScript
392 lines
9.6 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
computeTileCenters,
|
|
addImageWatermark,
|
|
} from '../js/utils/pdf-operations';
|
|
import {
|
|
PDFArray,
|
|
PDFDocument,
|
|
PDFName,
|
|
PDFObject,
|
|
PDFRawStream,
|
|
PDFRef,
|
|
} from 'pdf-lib';
|
|
import { inflateSync } from 'node:zlib';
|
|
|
|
const PNG_1X1_BASE64 =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
|
|
|
|
function pngBytes(): Uint8Array {
|
|
return new Uint8Array(Buffer.from(PNG_1X1_BASE64, 'base64'));
|
|
}
|
|
|
|
async function createTestPdf(pageCount: number): Promise<Uint8Array> {
|
|
const doc = await PDFDocument.create();
|
|
for (let i = 0; i < pageCount; i++) {
|
|
doc.addPage([612, 792]);
|
|
}
|
|
return new Uint8Array(await doc.save());
|
|
}
|
|
|
|
function resolve(doc: PDFDocument, ref?: PDFObject): PDFObject | undefined {
|
|
if (ref instanceof PDFRef) {
|
|
return doc.context.lookup(ref) as PDFObject | undefined;
|
|
}
|
|
return ref;
|
|
}
|
|
|
|
function decodeStream(doc: PDFDocument, ref?: PDFObject): string {
|
|
const stream = resolve(doc, ref);
|
|
if (!(stream instanceof PDFRawStream)) return '';
|
|
const raw = Buffer.from(stream.contents);
|
|
const filter = stream.dict.get(PDFName.of('Filter'));
|
|
return filter?.toString().includes('FlateDecode')
|
|
? inflateSync(raw).toString('latin1')
|
|
: raw.toString('latin1');
|
|
}
|
|
|
|
async function countDrawOpsPerPage(bytes: Uint8Array): Promise<number[]> {
|
|
const doc = await PDFDocument.load(bytes);
|
|
return doc.getPages().map((page) => {
|
|
const contents = page.node.get(PDFName.of('Contents'));
|
|
const resolved = doc.context.lookup(contents);
|
|
const parts =
|
|
resolved instanceof PDFArray
|
|
? resolved.asArray().map((entry) => decodeStream(doc, entry))
|
|
: [decodeStream(doc, contents)];
|
|
return parts.join('\n').match(/\/[^\s/[\]<>()]+\s+Do\b/g)?.length ?? 0;
|
|
});
|
|
}
|
|
|
|
async function countDrawOps(bytes: Uint8Array): Promise<number> {
|
|
const perPage = await countDrawOpsPerPage(bytes);
|
|
return perPage.reduce((sum, n) => sum + n, 0);
|
|
}
|
|
|
|
describe('computeTileCenters', () => {
|
|
const page = { pageWidth: 612, pageHeight: 792 };
|
|
|
|
it('produces a single-row-and-column-aligned grid at angle 0', () => {
|
|
const centers = computeTileCenters({
|
|
...page,
|
|
itemWidth: 100,
|
|
itemHeight: 100,
|
|
angle: 0,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
});
|
|
|
|
expect(centers.length).toBeGreaterThan(1);
|
|
|
|
const xs = [...new Set(centers.map((c) => Math.round(c.x)))].sort(
|
|
(a, b) => a - b
|
|
);
|
|
const ys = [...new Set(centers.map((c) => Math.round(c.y)))].sort(
|
|
(a, b) => a - b
|
|
);
|
|
|
|
for (let i = 1; i < xs.length; i++) expect(xs[i] - xs[i - 1]).toBe(100);
|
|
for (let i = 1; i < ys.length; i++) expect(ys[i] - ys[i - 1]).toBe(100);
|
|
expect(centers.length).toBe(xs.length * ys.length);
|
|
});
|
|
|
|
it('covers the whole page including all four corners', () => {
|
|
const centers = computeTileCenters({
|
|
...page,
|
|
itemWidth: 200,
|
|
itemHeight: 60,
|
|
angle: 45,
|
|
gapX: 0.2,
|
|
gapY: 0.5,
|
|
});
|
|
|
|
const radius = Math.hypot(200, 60) / 2;
|
|
const corners = [
|
|
{ x: 0, y: 0 },
|
|
{ x: 612, y: 0 },
|
|
{ x: 0, y: 792 },
|
|
{ x: 612, y: 792 },
|
|
];
|
|
|
|
for (const corner of corners) {
|
|
const nearest = Math.min(
|
|
...centers.map((c) => Math.hypot(c.x - corner.x, c.y - corner.y))
|
|
);
|
|
expect(nearest).toBeLessThanOrEqual(radius);
|
|
}
|
|
});
|
|
|
|
it('keeps every tile within reach of the page', () => {
|
|
const centers = computeTileCenters({
|
|
...page,
|
|
itemWidth: 150,
|
|
itemHeight: 50,
|
|
angle: -45,
|
|
gapX: 0.25,
|
|
gapY: 0.75,
|
|
});
|
|
|
|
const cos = Math.abs(Math.cos((-45 * Math.PI) / 180));
|
|
const sin = Math.abs(Math.sin((-45 * Math.PI) / 180));
|
|
const halfExtentX = (cos * 150 + sin * 50) / 2;
|
|
const halfExtentY = (sin * 150 + cos * 50) / 2;
|
|
|
|
for (const c of centers) {
|
|
expect(c.x).toBeGreaterThanOrEqual(-halfExtentX);
|
|
expect(c.x).toBeLessThanOrEqual(612 + halfExtentX);
|
|
expect(c.y).toBeGreaterThanOrEqual(-halfExtentY);
|
|
expect(c.y).toBeLessThanOrEqual(792 + halfExtentY);
|
|
}
|
|
});
|
|
|
|
it('is symmetric about the page center', () => {
|
|
const centers = computeTileCenters({
|
|
...page,
|
|
itemWidth: 120,
|
|
itemHeight: 40,
|
|
angle: 30,
|
|
gapX: 0.5,
|
|
gapY: 0.5,
|
|
});
|
|
|
|
const keys = new Set(
|
|
centers.map((c) => `${c.x.toFixed(3)}:${c.y.toFixed(3)}`)
|
|
);
|
|
for (const c of centers) {
|
|
const mirrorX = (612 - c.x).toFixed(3);
|
|
const mirrorY = (792 - c.y).toFixed(3);
|
|
expect(keys.has(`${mirrorX}:${mirrorY}`)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('spaces tiles further apart as the gap grows', () => {
|
|
const tight = computeTileCenters({
|
|
...page,
|
|
itemWidth: 100,
|
|
itemHeight: 40,
|
|
angle: -45,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
});
|
|
const loose = computeTileCenters({
|
|
...page,
|
|
itemWidth: 100,
|
|
itemHeight: 40,
|
|
angle: -45,
|
|
gapX: 1,
|
|
gapY: 2,
|
|
});
|
|
|
|
expect(loose.length).toBeLessThan(tight.length);
|
|
expect(loose.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('treats negative gaps as zero', () => {
|
|
const negative = computeTileCenters({
|
|
...page,
|
|
itemWidth: 100,
|
|
itemHeight: 40,
|
|
angle: 0,
|
|
gapX: -5,
|
|
gapY: -5,
|
|
});
|
|
const zero = computeTileCenters({
|
|
...page,
|
|
itemWidth: 100,
|
|
itemHeight: 40,
|
|
angle: 0,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
});
|
|
|
|
expect(negative).toEqual(zero);
|
|
});
|
|
|
|
it('caps the tile count for tiny watermarks', () => {
|
|
const centers = computeTileCenters({
|
|
...page,
|
|
itemWidth: 1,
|
|
itemHeight: 1,
|
|
angle: 0,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
});
|
|
|
|
expect(centers.length).toBeGreaterThan(0);
|
|
expect(centers.length).toBeLessThanOrEqual(3000);
|
|
});
|
|
|
|
it('stays bounded for extreme aspect ratios', () => {
|
|
const wide = computeTileCenters({
|
|
...page,
|
|
itemWidth: 5000,
|
|
itemHeight: 2,
|
|
angle: 17,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
});
|
|
const tall = computeTileCenters({
|
|
...page,
|
|
itemWidth: 1,
|
|
itemHeight: 5000,
|
|
angle: 0,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
});
|
|
|
|
for (const centers of [wide, tall]) {
|
|
expect(centers.length).toBeGreaterThan(0);
|
|
expect(centers.length).toBeLessThanOrEqual(3000);
|
|
expect(
|
|
centers.every((c) => Number.isFinite(c.x) && Number.isFinite(c.y))
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('ignores a non-finite angle', () => {
|
|
expect(
|
|
computeTileCenters({
|
|
...page,
|
|
itemWidth: 100,
|
|
itemHeight: 40,
|
|
angle: Number.NaN,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('returns nothing for degenerate input', () => {
|
|
expect(
|
|
computeTileCenters({
|
|
pageWidth: 0,
|
|
pageHeight: 792,
|
|
itemWidth: 10,
|
|
itemHeight: 10,
|
|
angle: 0,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
})
|
|
).toEqual([]);
|
|
|
|
expect(
|
|
computeTileCenters({
|
|
...page,
|
|
itemWidth: 0,
|
|
itemHeight: 10,
|
|
angle: 0,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
})
|
|
).toEqual([]);
|
|
|
|
expect(
|
|
computeTileCenters({
|
|
...page,
|
|
itemWidth: Number.NaN,
|
|
itemHeight: 10,
|
|
angle: 0,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
})
|
|
).toEqual([]);
|
|
|
|
expect(
|
|
computeTileCenters({
|
|
...page,
|
|
itemWidth: -10,
|
|
itemHeight: 10,
|
|
angle: 0,
|
|
gapX: 0,
|
|
gapY: 0,
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('addImageWatermark tiling', () => {
|
|
it('draws the watermark once per page in single mode', async () => {
|
|
const result = await addImageWatermark(await createTestPdf(2), {
|
|
imageBytes: pngBytes(),
|
|
imageType: 'png',
|
|
opacity: 0.3,
|
|
angle: -45,
|
|
scale: 100,
|
|
});
|
|
|
|
expect(await countDrawOps(result)).toBe(2);
|
|
});
|
|
|
|
it('repeats the watermark across each page in tile mode', async () => {
|
|
const single = await addImageWatermark(await createTestPdf(1), {
|
|
imageBytes: pngBytes(),
|
|
imageType: 'png',
|
|
opacity: 0.3,
|
|
angle: -45,
|
|
scale: 100,
|
|
});
|
|
const tiled = await addImageWatermark(await createTestPdf(1), {
|
|
imageBytes: pngBytes(),
|
|
imageType: 'png',
|
|
opacity: 0.3,
|
|
angle: -45,
|
|
scale: 100,
|
|
tile: true,
|
|
tileGapX: 0.25,
|
|
tileGapY: 0.75,
|
|
});
|
|
|
|
expect(await countDrawOps(single)).toBe(1);
|
|
expect(await countDrawOps(tiled)).toBeGreaterThan(10);
|
|
});
|
|
|
|
it('only tiles the selected pages', async () => {
|
|
const tiled = await addImageWatermark(await createTestPdf(3), {
|
|
imageBytes: pngBytes(),
|
|
imageType: 'png',
|
|
opacity: 0.3,
|
|
angle: 0,
|
|
scale: 100,
|
|
pageIndices: [1],
|
|
tile: true,
|
|
tileGapX: 0.25,
|
|
tileGapY: 0.75,
|
|
});
|
|
|
|
const doc = await PDFDocument.load(tiled);
|
|
expect(doc.getPageCount()).toBe(3);
|
|
const perPage = await countDrawOpsPerPage(tiled);
|
|
expect(perPage[0]).toBe(0);
|
|
expect(perPage[1]).toBeGreaterThan(10);
|
|
expect(perPage[2]).toBe(0);
|
|
|
|
const untouched = await addImageWatermark(await createTestPdf(3), {
|
|
imageBytes: pngBytes(),
|
|
imageType: 'png',
|
|
opacity: 0.3,
|
|
angle: 0,
|
|
scale: 100,
|
|
pageIndices: [],
|
|
tile: true,
|
|
});
|
|
expect(await countDrawOps(untouched)).toBe(0);
|
|
});
|
|
|
|
it('produces a loadable PDF with a bounded size when tiling', async () => {
|
|
const tiled = await addImageWatermark(await createTestPdf(5), {
|
|
imageBytes: pngBytes(),
|
|
imageType: 'png',
|
|
opacity: 0.2,
|
|
angle: -45,
|
|
scale: 20,
|
|
tile: true,
|
|
tileGapX: 0,
|
|
tileGapY: 0,
|
|
});
|
|
|
|
const doc = await PDFDocument.load(tiled);
|
|
expect(doc.getPageCount()).toBe(5);
|
|
expect(tiled.byteLength).toBeLessThan(5 * 1024 * 1024);
|
|
});
|
|
});
|