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

+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
*.tgz
*.log
.DS_Store
+4
View File
@@ -0,0 +1,4 @@
dist
node_modules
coverage
package-lock.json
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 80
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stevan Freeborn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+122
View File
@@ -0,0 +1,122 @@
# @stevanfreeborn/png2pdf
Convert PNG images to PDF files from the command line.
## Requirements
- Node.js 20 or newer
- npm
## Install
Run without installing:
```powershell
npx @stevanfreeborn/png2pdf .\image.png
```
Install globally:
```powershell
npm install --global @stevanfreeborn/png2pdf
png2pdf --help
```
## Local development
```powershell
cd C:\Users\sfree\Repositories\png2pdf
npm install
npm run build
```
Run the local development build directly:
```powershell
npm run dev -- .\image.png
```
Or link the built CLI so `png2pdf` is available on your PATH:
```powershell
npm link
png2pdf --help
```
## Usage
```text
png2pdf <input...> [options]
```
Inputs can be PNG files, directories, or glob patterns. PNG extensions are
matched case-insensitively. Directory inputs are non-recursive unless
`--recursive` is supplied.
### Examples
Create a PDF beside each PNG:
```powershell
png2pdf .\screenshots\login.png
png2pdf .\screenshots
```
Write batch output to a directory:
```powershell
png2pdf ".\screenshots\**\*.png" --recursive --out-dir .\pdfs
```
Combine images into one multi-page PDF:
```powershell
png2pdf ".\screenshots\*.png" --merge --output .\album.pdf
```
Fit images onto A4 pages with a margin:
```powershell
png2pdf .\diagram.png --page-size a4 --margin 18
```
## Options
| Option | Description |
| --------------------- | ------------------------------------------------ |
| `-d, --out-dir <dir>` | Write batch PDFs to this directory. |
| `-m, --merge` | Combine all PNGs into one multi-page PDF. |
| `-o, --output <file>` | Output path for merged mode. Requires `--merge`. |
| `-r, --recursive` | Recurse into directory inputs. |
| `--page-size <size>` | `fit`, `a4`, or `letter`. Defaults to `fit`. |
| `--margin <points>` | Page margin in PDF points. Defaults to `0`. |
| `--dpi <number>` | DPI used to size `fit` pages. Defaults to `96`. |
| `-f, --force` | Overwrite existing output files. |
| `-q, --quiet` | Suppress success and progress output. |
| `-V, --version` | Print the version. |
| `-h, --help` | Print help. |
## Behavior
- By default, every PNG becomes its own PDF beside the source file.
- With `--merge`, all PNGs become pages in one PDF, in sorted path order.
- `fit` pages use the image's physical dimensions at the selected DPI plus
margins.
- `a4` and `letter` pages fit and center the image while preserving its aspect
ratio. Page orientation follows the image orientation.
- Transparent PNGs keep their alpha channel.
- Existing files are not overwritten unless `--force` is used.
- In batch mode, failures are reported without stopping other conversions. The
command exits with code `1` if any conversion fails.
- Batch output collisions, such as two inputs with the same basename and one
output directory, are rejected before conversion starts.
## Development
```powershell
npm run typecheck
npm run lint
npm test
npm run build
npm run format:check
```
+24
View File
@@ -0,0 +1,24 @@
import eslintJs from "@eslint/js";
import prettier from "eslint-config-prettier";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/**", "node_modules/**", "coverage/**"] },
eslintJs.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.ts"],
languageOptions: {
globals: globals.node,
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
},
},
prettier,
);
+3279
View File
File diff suppressed because it is too large. Load diff
+66
View File
@@ -0,0 +1,66 @@
{
"name": "@stevanfreeborn/png2pdf",
"version": "0.0.0",
"description": "Convert PNG images to PDF files from the command line.",
"type": "module",
"license": "MIT",
"author": "Stevan Freeborn",
"keywords": [
"png",
"pdf",
"convert",
"cli",
"image"
],
"bin": {
"png2pdf": "dist/cli.js"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"allowScripts": {
"esbuild@0.28.2": true
},
"scripts": {
"dev": "tsx src/cli.ts",
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepack": "npm run build",
"prepublishOnly": "npm run typecheck && npm run lint && npm test"
},
"dependencies": {
"commander": "^15.0.0",
"fast-glob": "^3.3.3",
"pdf-lib": "^1.17.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.6.5",
"eslint": "^10.13.0",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.13.0",
"prettier": "^3.9.10",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
"typescript-eslint": "^8.72.0",
"vitest": "^5.0.5"
}
}
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import path from "node:path";
import { Command, InvalidArgumentError } from "commander";
import { convertPngsToPdf, convertPngsToPdfs } from "./lib/convert.js";
import { Png2PdfError, getErrorMessage } from "./lib/errors.js";
import { expandInputs } from "./lib/inputs.js";
import { isPageSize, type PageSize } from "./lib/layout.js";
type CliOptions = {
outDir?: string;
merge?: boolean;
output?: string;
recursive?: boolean;
pageSize: PageSize;
margin: number;
dpi: number;
force?: boolean;
quiet?: boolean;
};
const require = createRequire(import.meta.url);
const packageJson = require("../package.json") as { version: string };
export function buildProgram(): Command {
const program = new Command();
program
.name("png2pdf")
.description("Convert PNG images to PDF files.")
.version(packageJson.version)
.argument("<input...>", "PNG files, directories, or glob patterns")
.option("-d, --out-dir <dir>", "write batch PDFs to this directory")
.option("-m, --merge", "combine all PNGs into one multi-page PDF")
.option("-o, --output <file>", "output path for merged mode")
.option("-r, --recursive", "recurse into directory inputs")
.option(
"--page-size <size>",
"page size: fit, a4, or letter",
parsePageSize,
"fit",
)
.option(
"--margin <points>",
"page margin in PDF points",
parseNonNegativeNumber,
0,
)
.option(
"--dpi <number>",
"DPI used to size fit pages",
parsePositiveNumber,
96,
)
.option("-f, --force", "overwrite existing output files")
.option("-q, --quiet", "suppress success output")
.showHelpAfterError()
.action(async (inputs: string[], options: CliOptions) => {
await runCommand(inputs, options);
});
return program;
}
async function runCommand(
inputs: readonly string[],
options: CliOptions,
): Promise<void> {
validateModeOptions(options);
const pngFiles = await expandInputs(inputs, {
recursive: options.recursive ?? false,
});
if (options.merge) {
const output = await convertPngsToPdf(pngFiles, options.output as string, {
pageSize: options.pageSize,
margin: options.margin,
dpi: options.dpi,
force: options.force ?? false,
});
if (!options.quiet) {
console.log(
`Created ${displayPath(output)} from ${countLabel(pngFiles.length)}.`,
);
}
return;
}
const result = await convertPngsToPdfs(pngFiles, {
outDir: options.outDir,
pageSize: options.pageSize,
margin: options.margin,
dpi: options.dpi,
force: options.force ?? false,
});
if (!options.quiet) {
for (const conversion of result.converted) {
console.log(
`${displayPath(conversion.inputPath)} -> ${displayPath(
conversion.outputPath,
)}`,
);
}
}
for (const failure of result.failed) {
console.error(
`Failed ${displayPath(failure.inputPath)}: ${getErrorMessage(
failure.error,
)}`,
);
}
if (!options.quiet) {
console.log(
`Converted ${result.converted.length} of ${countLabel(pngFiles.length)}.`,
);
}
if (result.failed.length > 0) {
process.exitCode = 1;
}
}
function validateModeOptions(options: CliOptions): void {
if (options.merge && !options.output) {
throw new Png2PdfError("--merge requires --output <file>.");
}
if (options.merge && options.outDir) {
throw new Png2PdfError("--out-dir cannot be used with --merge.");
}
if (!options.merge && options.output) {
throw new Png2PdfError("--output can only be used with --merge.");
}
}
function parsePageSize(value: string): PageSize {
if (isPageSize(value)) {
return value;
}
throw new InvalidArgumentError(
`Invalid page size "${value}". Expected fit, a4, or letter.`,
);
}
function parseNonNegativeNumber(value: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new InvalidArgumentError(`Expected a number zero or greater.`);
}
return parsed;
}
function parsePositiveNumber(value: string): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new InvalidArgumentError(`Expected a number greater than zero.`);
}
return parsed;
}
function displayPath(filePath: string): string {
const relative = path.relative(process.cwd(), filePath);
if (
relative === "" ||
relative.startsWith("..") ||
path.isAbsolute(relative)
) {
return filePath;
}
return relative;
}
function countLabel(count: number): string {
return `${count} ${count === 1 ? "PNG" : "PNGs"}`;
}
async function main(): Promise<void> {
try {
await buildProgram().parseAsync(process.argv);
} catch (error) {
console.error(`Error: ${getErrorMessage(error)}`);
process.exitCode = 1;
}
}
void main();
+20
View File
@@ -0,0 +1,20 @@
export {
convertPngsToPdf,
convertPngsToPdfs,
createPdfFromPngs,
type BatchConversionOptions,
type BatchConversionResult,
type ConversionFailure,
type ConversionOptions,
type ConversionSuccess,
} from "./lib/convert.js";
export { Png2PdfError, getErrorMessage } from "./lib/errors.js";
export { expandInputs, type ExpandInputsOptions } from "./lib/inputs.js";
export {
PAGE_SIZES,
calculatePageLayout,
isPageSize,
type PageLayout,
type PageLayoutOptions,
type PageSize,
} from "./lib/layout.js";
+252
View File
@@ -0,0 +1,252 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { PDFDocument } from "pdf-lib";
import { Png2PdfError, getErrorMessage } from "./errors.js";
import { calculatePageLayout, type PageSize } from "./layout.js";
export type ConversionOptions = {
pageSize?: PageSize;
margin?: number;
dpi?: number;
};
export type BatchConversionOptions = ConversionOptions & {
outDir?: string;
force?: boolean;
};
export type ConversionSuccess = {
inputPath: string;
outputPath: string;
};
export type ConversionFailure = {
inputPath: string;
error: Error;
};
export type BatchConversionResult = {
converted: ConversionSuccess[];
failed: ConversionFailure[];
};
type PlannedConversion = {
inputPath: string;
outputPath: string;
};
export async function createPdfFromPngs(
inputPaths: readonly string[],
options: ConversionOptions = {},
title?: string,
): Promise<Uint8Array> {
if (inputPaths.length === 0) {
throw new Png2PdfError("At least one PNG is required to create a PDF.");
}
const document = await PDFDocument.create();
for (const inputPath of inputPaths) {
const resolvedInput = path.resolve(inputPath);
let pngBytes: Uint8Array;
try {
pngBytes = await readFile(resolvedInput);
} catch (error) {
throw new Png2PdfError(
`Could not read PNG "${resolvedInput}": ${getErrorMessage(error)}`,
{ cause: error },
);
}
let image;
try {
image = await document.embedPng(pngBytes);
} catch (error) {
throw new Png2PdfError(
`Could not embed PNG "${resolvedInput}": ${getErrorMessage(error)}`,
{ cause: error },
);
}
const layout = calculatePageLayout({
pixelWidth: image.width,
pixelHeight: image.height,
dpi: options.dpi,
pageSize: options.pageSize,
margin: options.margin,
});
const page = document.addPage([layout.pageWidth, layout.pageHeight]);
page.drawImage(image, {
x: layout.imageX,
y: layout.imageY,
width: layout.imageWidth,
height: layout.imageHeight,
});
}
const defaultTitle =
inputPaths.length === 1 && inputPaths[0]
? path.basename(inputPaths[0], path.extname(inputPaths[0]))
: undefined;
document.setTitle(title ?? defaultTitle ?? "png2pdf document");
document.setCreator("png2pdf");
document.setProducer("png2pdf");
return document.save();
}
export async function convertPngsToPdfs(
inputPaths: readonly string[],
options: BatchConversionOptions = {},
): Promise<BatchConversionResult> {
if (inputPaths.length === 0) {
throw new Png2PdfError("At least one PNG is required.");
}
const planned = planBatchConversions(inputPaths, options.outDir);
const directories = new Set(
planned.map((conversion) => path.dirname(conversion.outputPath)),
);
await Promise.all(
[...directories].map((directory) => mkdir(directory, { recursive: true })),
);
const result: BatchConversionResult = { converted: [], failed: [] };
for (const conversion of planned) {
try {
if (!options.force && (await pathExists(conversion.outputPath))) {
throw new Png2PdfError(
`Output already exists: ${conversion.outputPath}. Use --force to overwrite.`,
);
}
const pdfBytes = await createPdfFromPngs(
[conversion.inputPath],
options,
path.basename(conversion.inputPath, path.extname(conversion.inputPath)),
);
await writePdf(conversion.outputPath, pdfBytes, options.force ?? false);
result.converted.push(conversion);
} catch (error) {
result.failed.push({
inputPath: conversion.inputPath,
error: toError(error),
});
}
}
return result;
}
export async function convertPngsToPdf(
inputPaths: readonly string[],
outputPath: string,
options: ConversionOptions & { force?: boolean } = {},
): Promise<string> {
if (inputPaths.length === 0) {
throw new Png2PdfError("At least one PNG is required.");
}
const resolvedOutput = withPdfExtension(path.resolve(outputPath));
await mkdir(path.dirname(resolvedOutput), { recursive: true });
if (!options.force && (await pathExists(resolvedOutput))) {
throw new Png2PdfError(
`Output already exists: ${resolvedOutput}. Use --force to overwrite.`,
);
}
const pdfBytes = await createPdfFromPngs(
inputPaths,
options,
path.basename(resolvedOutput, path.extname(resolvedOutput)),
);
await writePdf(resolvedOutput, pdfBytes, options.force ?? false);
return resolvedOutput;
}
function planBatchConversions(
inputPaths: readonly string[],
outDir?: string,
): PlannedConversion[] {
const resolvedOutDir = outDir ? path.resolve(outDir) : undefined;
const planned: PlannedConversion[] = [];
const outputs = new Map<string, string>();
for (const inputPath of inputPaths) {
const resolvedInput = path.resolve(inputPath);
const directory = resolvedOutDir ?? path.dirname(resolvedInput);
const filename = `${path.basename(
resolvedInput,
path.extname(resolvedInput),
)}.pdf`;
const outputPath = path.join(directory, filename);
const key =
process.platform === "win32" ? outputPath.toLowerCase() : outputPath;
const existingInput = outputs.get(key);
if (existingInput) {
throw new Png2PdfError(
`Output collision: "${existingInput}" and "${resolvedInput}" both map to "${outputPath}".`,
);
}
outputs.set(key, resolvedInput);
planned.push({ inputPath: resolvedInput, outputPath });
}
return planned;
}
async function writePdf(
outputPath: string,
pdfBytes: Uint8Array,
force: boolean,
): Promise<void> {
try {
await writeFile(outputPath, pdfBytes, { flag: force ? "w" : "wx" });
} catch (error) {
if (isErrnoException(error) && error.code === "EEXIST") {
throw new Png2PdfError(
`Output already exists: ${outputPath}. Use --force to overwrite.`,
{ cause: error },
);
}
throw new Png2PdfError(
`Could not write PDF "${outputPath}": ${getErrorMessage(error)}`,
{ cause: error },
);
}
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
function withPdfExtension(outputPath: string): string {
return path.extname(outputPath) === "" ? `${outputPath}.pdf` : outputPath;
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(String(error));
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
+10
View File
@@ -0,0 +1,10 @@
export class Png2PdfError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "Png2PdfError";
}
}
export function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+140
View File
@@ -0,0 +1,140 @@
import { stat } from "node:fs/promises";
import path from "node:path";
import fg from "fast-glob";
import { Png2PdfError, getErrorMessage } from "./errors.js";
export type ExpandInputsOptions = {
recursive?: boolean;
};
const GLOB_OPTIONS = {
absolute: true,
onlyFiles: true,
unique: true,
caseSensitiveMatch: false,
dot: false,
followSymbolicLinks: false,
} as const;
export async function expandInputs(
inputs: readonly string[],
options: ExpandInputsOptions = {},
): Promise<string[]> {
if (inputs.length === 0) {
throw new Png2PdfError(
"Provide at least one PNG file, directory, or glob.",
);
}
const matches: string[] = [];
const patterns: string[] = [];
const invalidFiles: string[] = [];
for (const input of inputs) {
if (input.trim() === "") {
throw new Png2PdfError("Input paths cannot be empty.");
}
const resolvedInput = path.resolve(input);
const stats = await getStats(resolvedInput);
if (!stats) {
patterns.push(toGlobPattern(input));
continue;
}
if (stats.isDirectory()) {
const directoryMatches = await fg(
options.recursive ? "**/*.png" : "*.png",
{
...GLOB_OPTIONS,
cwd: resolvedInput,
},
);
matches.push(...directoryMatches);
continue;
}
if (!stats.isFile() || !hasPngExtension(resolvedInput)) {
invalidFiles.push(input);
continue;
}
matches.push(resolvedInput);
}
if (invalidFiles.length > 0) {
throw new Png2PdfError(
`Input is not a PNG file: ${invalidFiles.join(", ")}`,
);
}
if (patterns.length > 0) {
const patternMatches = await fg(patterns, GLOB_OPTIONS);
matches.push(...patternMatches);
}
const pngMatches = matches.filter((filePath) => hasPngExtension(filePath));
const deduped = deduplicatePaths(pngMatches);
deduped.sort((left, right) =>
left.localeCompare(right, undefined, { sensitivity: "base" }),
);
if (deduped.length === 0) {
throw new Png2PdfError("No PNG files found for the provided input.");
}
return deduped;
}
function hasPngExtension(filePath: string): boolean {
return path.extname(filePath).toLowerCase() === ".png";
}
function toGlobPattern(input: string): string {
return input.replaceAll("\\", "/");
}
async function getStats(
filePath: string,
): Promise<Awaited<ReturnType<typeof stat>> | undefined> {
try {
return await stat(filePath);
} catch (error) {
if (isMissingPathError(error)) {
return undefined;
}
throw new Png2PdfError(
`Could not inspect input "${filePath}": ${getErrorMessage(error)}`,
{ cause: error },
);
}
}
function deduplicatePaths(filePaths: readonly string[]): string[] {
const seen = new Set<string>();
const deduped: string[] = [];
for (const filePath of filePaths) {
const resolved = path.resolve(filePath);
const key =
process.platform === "win32" ? resolved.toLowerCase() : resolved;
if (!seen.has(key)) {
seen.add(key);
deduped.push(resolved);
}
}
return deduped;
}
function isMissingPathError(error: unknown): boolean {
return (
error instanceof Error &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "ENOENT"
);
}
+109
View File
@@ -0,0 +1,109 @@
import { Png2PdfError } from "./errors.js";
export const PAGE_SIZES = ["fit", "a4", "letter"] as const;
export type PageSize = (typeof PAGE_SIZES)[number];
export type PageLayoutOptions = {
pixelWidth: number;
pixelHeight: number;
dpi?: number;
pageSize?: PageSize;
margin?: number;
};
export type PageLayout = {
pageWidth: number;
pageHeight: number;
imageX: number;
imageY: number;
imageWidth: number;
imageHeight: number;
};
const POINTS_PER_INCH = 72;
const DEFAULT_DPI = 96;
const PAPER_DIMENSIONS = {
a4: { width: 595.28, height: 841.89 },
letter: { width: 612, height: 792 },
} as const satisfies Record<
Exclude<PageSize, "fit">,
{ width: number; height: number }
>;
export function isPageSize(value: string): value is PageSize {
return (PAGE_SIZES as readonly string[]).includes(value);
}
export function calculatePageLayout(options: PageLayoutOptions): PageLayout {
const {
pixelWidth,
pixelHeight,
dpi = DEFAULT_DPI,
pageSize = "fit",
margin = 0,
} = options;
assertPositiveFinite(pixelWidth, "Image width");
assertPositiveFinite(pixelHeight, "Image height");
assertPositiveFinite(dpi, "DPI");
assertNonNegativeFinite(margin, "Margin");
const imageWidth = pixelsToPoints(pixelWidth, dpi);
const imageHeight = pixelsToPoints(pixelHeight, dpi);
if (pageSize === "fit") {
return {
pageWidth: imageWidth + margin * 2,
pageHeight: imageHeight + margin * 2,
imageX: margin,
imageY: margin,
imageWidth,
imageHeight,
};
}
const paper = PAPER_DIMENSIONS[pageSize];
const landscape = imageWidth > imageHeight;
const pageWidth = landscape ? paper.height : paper.width;
const pageHeight = landscape ? paper.width : paper.height;
const availableWidth = pageWidth - margin * 2;
const availableHeight = pageHeight - margin * 2;
if (availableWidth <= 0 || availableHeight <= 0) {
throw new Png2PdfError(
`Margin ${margin} pt is too large for ${pageSize.toUpperCase()} paper.`,
);
}
const scale = Math.min(
availableWidth / imageWidth,
availableHeight / imageHeight,
);
const drawnWidth = imageWidth * scale;
const drawnHeight = imageHeight * scale;
return {
pageWidth,
pageHeight,
imageX: (pageWidth - drawnWidth) / 2,
imageY: (pageHeight - drawnHeight) / 2,
imageWidth: drawnWidth,
imageHeight: drawnHeight,
};
}
function pixelsToPoints(pixels: number, dpi: number): number {
return (pixels / dpi) * POINTS_PER_INCH;
}
function assertPositiveFinite(value: number, label: string): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Png2PdfError(`${label} must be a positive number.`);
}
}
function assertNonNegativeFinite(value: number, label: string): void {
if (!Number.isFinite(value) || value < 0) {
throw new Png2PdfError(`${label} must be zero or greater.`);
}
}
+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);
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "test"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"types": ["node"],
"target": "ES2022",
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["node_modules", "dist"]
}