chore: initial commit

This commit is contained in:
Stevan Freeborn committed 2026-10-23 15:30:19 -05:00
commit c2c8aaa953
20 files changed
+4688

No files matched your search

+157
View File
@@ -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);
});
});
+20
View File
@@ -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);
}
+106
View File
@@ -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,
);
});
});
+109
View File
@@ -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);
});
});