chore: initial commit
This commit is contained in:
commit
c2c8aaa953
20 files changed
+4688
No files matched your search
@@ -0,0 +1,157 @@
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { convertPngsToPdf, convertPngsToPdfs } from "../src/lib/convert.js";
|
||||
import {
|
||||
createTempDirectory,
|
||||
removeTempDirectory,
|
||||
writeTinyPng,
|
||||
} from "./helpers.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirectories
|
||||
.splice(0)
|
||||
.map((directory) => removeTempDirectory(directory)),
|
||||
);
|
||||
});
|
||||
|
||||
async function makeTempDirectory(): Promise<string> {
|
||||
const directory = await createTempDirectory();
|
||||
tempDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
describe("convertPngsToPdfs", () => {
|
||||
it("converts each PNG to its own PDF beside the source", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const first = path.join(directory, "first.png");
|
||||
const second = path.join(directory, "second.PNG");
|
||||
await writeTinyPng(first);
|
||||
await writeTinyPng(second);
|
||||
|
||||
const result = await convertPngsToPdfs([first, second]);
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.converted).toHaveLength(2);
|
||||
|
||||
for (const conversion of result.converted) {
|
||||
expect(conversion.outputPath).toBe(
|
||||
path.join(
|
||||
directory,
|
||||
`${path.basename(conversion.inputPath, path.extname(conversion.inputPath))}.pdf`,
|
||||
),
|
||||
);
|
||||
|
||||
const pdf = await PDFDocument.load(await readFile(conversion.outputPath));
|
||||
expect(pdf.getPageCount()).toBe(1);
|
||||
expect(pdf.getPage(0).getWidth()).toBeCloseTo(0.75);
|
||||
expect(pdf.getPage(0).getHeight()).toBeCloseTo(0.75);
|
||||
}
|
||||
});
|
||||
|
||||
it("writes batch output to the selected directory", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const inputDirectory = path.join(directory, "input");
|
||||
const outputDirectory = path.join(directory, "output");
|
||||
await mkdir(inputDirectory);
|
||||
const input = path.join(inputDirectory, "image.png");
|
||||
await writeTinyPng(input);
|
||||
|
||||
const result = await convertPngsToPdfs([input], {
|
||||
outDir: outputDirectory,
|
||||
});
|
||||
|
||||
expect(result.converted[0]?.outputPath).toBe(
|
||||
path.join(outputDirectory, "image.pdf"),
|
||||
);
|
||||
expect(
|
||||
(await readFile(path.join(outputDirectory, "image.pdf"))).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("refuses existing outputs unless force is enabled", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const input = path.join(directory, "image.png");
|
||||
await writeTinyPng(input);
|
||||
|
||||
const initial = await convertPngsToPdfs([input]);
|
||||
expect(initial.failed).toEqual([]);
|
||||
|
||||
const refused = await convertPngsToPdfs([input]);
|
||||
expect(refused.converted).toEqual([]);
|
||||
expect(refused.failed).toHaveLength(1);
|
||||
expect(refused.failed[0]?.error.message).toMatch(/already exists/i);
|
||||
|
||||
const forced = await convertPngsToPdfs([input], { force: true });
|
||||
expect(forced.failed).toEqual([]);
|
||||
expect(forced.converted).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("detects output basename collisions before writing", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const firstDirectory = path.join(directory, "one");
|
||||
const secondDirectory = path.join(directory, "two");
|
||||
const outputDirectory = path.join(directory, "output");
|
||||
await mkdir(firstDirectory);
|
||||
await mkdir(secondDirectory);
|
||||
const first = path.join(firstDirectory, "image.png");
|
||||
const second = path.join(secondDirectory, "image.png");
|
||||
await writeTinyPng(first);
|
||||
await writeTinyPng(second);
|
||||
|
||||
await expect(
|
||||
convertPngsToPdfs([first, second], { outDir: outputDirectory }),
|
||||
).rejects.toThrow(/Output collision/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertPngsToPdf", () => {
|
||||
it("merges multiple PNGs into one multi-page PDF", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const first = path.join(directory, "first.png");
|
||||
const second = path.join(directory, "second.png");
|
||||
await writeTinyPng(first);
|
||||
await writeTinyPng(second);
|
||||
const output = path.join(directory, "album.pdf");
|
||||
|
||||
const actualOutput = await convertPngsToPdf([first, second], output);
|
||||
|
||||
expect(actualOutput).toBe(output);
|
||||
const pdf = await PDFDocument.load(await readFile(output));
|
||||
expect(pdf.getPageCount()).toBe(2);
|
||||
});
|
||||
|
||||
it("appends a PDF extension when merged output has none", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const input = path.join(directory, "image.png");
|
||||
await writeTinyPng(input);
|
||||
|
||||
const output = await convertPngsToPdf(
|
||||
[input],
|
||||
path.join(directory, "album"),
|
||||
);
|
||||
|
||||
expect(output).toBe(path.join(directory, "album.pdf"));
|
||||
expect((await readFile(output)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("refuses to overwrite merged outputs without force", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const input = path.join(directory, "image.png");
|
||||
const output = path.join(directory, "album.pdf");
|
||||
await writeTinyPng(input);
|
||||
await convertPngsToPdf([input], output);
|
||||
|
||||
await expect(convertPngsToPdf([input], output)).rejects.toThrow(
|
||||
/already exists/i,
|
||||
);
|
||||
|
||||
await expect(
|
||||
convertPngsToPdf([input], output, { force: true }),
|
||||
).resolves.toBe(output);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const TINY_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
export async function createTempDirectory(): Promise<string> {
|
||||
return mkdtemp(path.join(tmpdir(), "png2pdf-"));
|
||||
}
|
||||
|
||||
export async function removeTempDirectory(directory: string): Promise<void> {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export async function writeTinyPng(filePath: string): Promise<void> {
|
||||
await writeFile(filePath, TINY_PNG);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { expandInputs } from "../src/lib/inputs.js";
|
||||
import {
|
||||
createTempDirectory,
|
||||
removeTempDirectory,
|
||||
writeTinyPng,
|
||||
} from "./helpers.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirectories
|
||||
.splice(0)
|
||||
.map((directory) => removeTempDirectory(directory)),
|
||||
);
|
||||
});
|
||||
|
||||
async function makeTempDirectory(): Promise<string> {
|
||||
const directory = await createTempDirectory();
|
||||
tempDirectories.push(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
describe("expandInputs", () => {
|
||||
it("accepts direct PNG files regardless of extension case", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const upper = path.join(directory, "UPPER.PNG");
|
||||
const lower = path.join(directory, "lower.png");
|
||||
await writeTinyPng(upper);
|
||||
await writeTinyPng(lower);
|
||||
|
||||
const result = await expandInputs([upper, lower]);
|
||||
|
||||
expect(result).toEqual([lower, upper]);
|
||||
});
|
||||
|
||||
it("scans directories non-recursively by default", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const nested = path.join(directory, "nested");
|
||||
await mkdir(nested);
|
||||
const rootPng = path.join(directory, "root.png");
|
||||
await writeTinyPng(rootPng);
|
||||
await writeTinyPng(path.join(nested, "nested.png"));
|
||||
|
||||
const result = await expandInputs([directory]);
|
||||
|
||||
expect(result).toEqual([rootPng]);
|
||||
});
|
||||
|
||||
it("scans directories recursively when requested", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const nested = path.join(directory, "nested");
|
||||
await mkdir(nested);
|
||||
const rootPng = path.join(directory, "root.png");
|
||||
const nestedPng = path.join(nested, "nested.PNG");
|
||||
await writeTinyPng(rootPng);
|
||||
await writeTinyPng(nestedPng);
|
||||
|
||||
const result = await expandInputs([directory], { recursive: true });
|
||||
|
||||
expect(result).toEqual([nestedPng, rootPng]);
|
||||
});
|
||||
|
||||
it("supports glob patterns and filenames containing spaces", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const spaced = path.join(directory, "a spaced name.png");
|
||||
await writeTinyPng(spaced);
|
||||
await writeFile(path.join(directory, "not-an-image.txt"), "ignore me");
|
||||
|
||||
const pattern = path.join(directory, "*.png").replaceAll("\\", "/");
|
||||
const result = await expandInputs([pattern]);
|
||||
|
||||
expect(result).toEqual([spaced]);
|
||||
});
|
||||
|
||||
it("deduplicates overlapping inputs", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const file = path.join(directory, "one.png");
|
||||
await writeTinyPng(file);
|
||||
const pattern = path.join(directory, "*.png").replaceAll("\\", "/");
|
||||
|
||||
const result = await expandInputs([file, directory, pattern, file]);
|
||||
|
||||
expect(result).toEqual([file]);
|
||||
});
|
||||
|
||||
it("rejects explicit non-PNG files", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const textFile = path.join(directory, "notes.txt");
|
||||
await writeFile(textFile, "not a png");
|
||||
|
||||
await expect(expandInputs([textFile])).rejects.toThrow(/not a PNG file/i);
|
||||
});
|
||||
|
||||
it("rejects inputs without matching PNGs", async () => {
|
||||
const directory = await makeTempDirectory();
|
||||
const pattern = path.join(directory, "*.png").replaceAll("\\", "/");
|
||||
|
||||
await expect(expandInputs([pattern])).rejects.toThrow(
|
||||
/No PNG files found/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
PAGE_SIZES,
|
||||
calculatePageLayout,
|
||||
isPageSize,
|
||||
} from "../src/lib/layout.js";
|
||||
|
||||
describe("calculatePageLayout", () => {
|
||||
it("creates a fit page from pixel dimensions at 96 DPI", () => {
|
||||
const layout = calculatePageLayout({
|
||||
pixelWidth: 960,
|
||||
pixelHeight: 720,
|
||||
});
|
||||
|
||||
expect(layout.pageWidth).toBeCloseTo(720);
|
||||
expect(layout.pageHeight).toBeCloseTo(540);
|
||||
expect(layout.imageX).toBe(0);
|
||||
expect(layout.imageY).toBe(0);
|
||||
expect(layout.imageWidth).toBeCloseTo(720);
|
||||
expect(layout.imageHeight).toBeCloseTo(540);
|
||||
});
|
||||
|
||||
it("converts DPI and applies margins to fit pages", () => {
|
||||
const layout = calculatePageLayout({
|
||||
pixelWidth: 960,
|
||||
pixelHeight: 720,
|
||||
dpi: 192,
|
||||
margin: 18,
|
||||
});
|
||||
|
||||
expect(layout.pageWidth).toBeCloseTo(396);
|
||||
expect(layout.pageHeight).toBeCloseTo(306);
|
||||
expect(layout.imageX).toBe(18);
|
||||
expect(layout.imageY).toBe(18);
|
||||
expect(layout.imageWidth).toBeCloseTo(360);
|
||||
expect(layout.imageHeight).toBeCloseTo(270);
|
||||
});
|
||||
|
||||
it("fits landscape images onto landscape A4 paper and centers them", () => {
|
||||
const layout = calculatePageLayout({
|
||||
pixelWidth: 1920,
|
||||
pixelHeight: 1080,
|
||||
pageSize: "a4",
|
||||
margin: 20,
|
||||
});
|
||||
|
||||
expect(layout.pageWidth).toBeCloseTo(841.89);
|
||||
expect(layout.pageHeight).toBeCloseTo(595.28);
|
||||
expect(layout.imageWidth / layout.imageHeight).toBeCloseTo(16 / 9);
|
||||
expect(layout.imageX).toBeCloseTo(
|
||||
(layout.pageWidth - layout.imageWidth) / 2,
|
||||
);
|
||||
expect(layout.imageY).toBeCloseTo(
|
||||
(layout.pageHeight - layout.imageHeight) / 2,
|
||||
);
|
||||
expect(layout.imageX).toBeGreaterThanOrEqual(20);
|
||||
expect(layout.imageY).toBeGreaterThanOrEqual(20);
|
||||
});
|
||||
|
||||
it("keeps square images on portrait Letter paper", () => {
|
||||
const layout = calculatePageLayout({
|
||||
pixelWidth: 500,
|
||||
pixelHeight: 500,
|
||||
pageSize: "letter",
|
||||
});
|
||||
|
||||
expect(layout.pageWidth).toBeCloseTo(612);
|
||||
expect(layout.pageHeight).toBeCloseTo(792);
|
||||
expect(layout.imageWidth).toBeCloseTo(612);
|
||||
expect(layout.imageHeight).toBeCloseTo(612);
|
||||
expect(layout.imageX).toBeCloseTo(0);
|
||||
expect(layout.imageY).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it("rejects margins that leave no room on fixed paper", () => {
|
||||
expect(() =>
|
||||
calculatePageLayout({
|
||||
pixelWidth: 100,
|
||||
pixelHeight: 100,
|
||||
pageSize: "a4",
|
||||
margin: 300,
|
||||
}),
|
||||
).toThrow(/too large/i);
|
||||
});
|
||||
|
||||
it("rejects invalid dimensions, DPI, and margins", () => {
|
||||
expect(() =>
|
||||
calculatePageLayout({ pixelWidth: 0, pixelHeight: 100 }),
|
||||
).toThrow(/width/i);
|
||||
|
||||
expect(() =>
|
||||
calculatePageLayout({ pixelWidth: 100, pixelHeight: 100, dpi: 0 }),
|
||||
).toThrow(/dpi/i);
|
||||
|
||||
expect(() =>
|
||||
calculatePageLayout({ pixelWidth: 100, pixelHeight: 100, margin: -1 }),
|
||||
).toThrow(/margin/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("page size helpers", () => {
|
||||
it("exposes the supported page sizes", () => {
|
||||
expect(PAGE_SIZES).toEqual(["fit", "a4", "letter"]);
|
||||
expect(isPageSize("fit")).toBe(true);
|
||||
expect(isPageSize("a4")).toBe(true);
|
||||
expect(isPageSize("letter")).toBe(true);
|
||||
expect(isPageSize("legal")).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user