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