chore: initial commit
This commit is contained in:
commit
c2c8aaa953
20 files changed
+4688
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
*.tgz
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
Generated
+3279
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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();
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["node_modules", "dist", "test"]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in new issue
Block a user