Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion MODIFICATIONS.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
# Modifications

## [Unreleased]
## [1.17.1-mod.2026.1]

### Build System Changes

- Native ESM everywhere: all src/tests/apps use `.js`-suffixed local imports; Jest replaced with vitest; CJS build removed.
- Build outputs relocated to `dist/`: `dist/es` (native ESM + typings) and `dist/umd` (bundled).
- Package now uses conditional exports; consumers must import via the published entry points (`import`/`exports` map) rather than deep-linking files.

### PNG Handling Changes

- Switched PNG decoding from `@pdf-lib/upng` to `fast-png`, preventing freezes on truncated PNG inputs.
- Animated PNGs are now accepted and decoded as a single image.

## [1.17.1-mod.2025.8]

### Feature Removals
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@denkiyagi/pdf-lib",
"version": "1.17.1-mod.2025.8",
"version": "1.17.1-mod.2026.1",
"description": "A modified version of pdf-lib. Create and modify PDF files with JavaScript",
"author": "DenkiYagi Inc. (modified version author), Andrew Dillon <andrew.dillon.j@gmail.com> (orignal version author)",
"contributors": [
Expand Down Expand Up @@ -89,8 +89,8 @@
"dependencies": {
"@denkiyagi/fontkit": "2.0.4-mod.2025.5",
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"crypto-js": "^4.2.0",
"fast-png": "^8.0.0",
"pako": "^2.1.0",
"tslib": "^2.8.1"
},
Expand Down Expand Up @@ -135,4 +135,4 @@
"javascript",
"library"
]
}
}
9 changes: 0 additions & 9 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,6 @@ export class InvalidPngError extends PDFLibUtilsError {
}
}

export class AnimatedPngNotSupportedError extends PDFLibUtilsError {
constructor() {
super(
PDFLibErrorTypes.UNSUPPORTED_EXTERNAL_BINARY_DATA,
'Animated PNGs are not supported',
);
}
}

export class InvalidOptionPassedError extends PDFLibUtilsError {
constructor(valueName: string, allowedValues: Primitive[], actual: any) {
const allowed = allowedValues.map(formatValue).join(' or ');
Expand Down
190 changes: 190 additions & 0 deletions src/utils/fast-png-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { convertIndexedToRgb, type DecodedPng } from 'fast-png';
import { InvalidPngError } from 'src/utils/errors.js';

const toByte = (value: number, depth: number) => {
if (depth === 8) return value;
if (depth === 16) return Math.round(value / 257);
const maxValue = (1 << depth) - 1;
return Math.round((value * 255) / maxValue);
};

const unpackSamples = (image: DecodedPng): Uint8Array | Uint16Array => {
const { data, depth, width, height, channels } = image;
if (depth === 16) {
if (data instanceof Uint16Array) return data;
return new Uint16Array(data.buffer, data.byteOffset, data.byteLength / 2);
}

if (depth === 8) {
if (data instanceof Uint8Array) return data;
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}

const raw =
data instanceof Uint8Array
? data
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const pixelCount = width * height;
const samplesPerPixel = channels;
const samplesPerRow = width * samplesPerPixel;
const samples = new Uint8Array(pixelCount * samplesPerPixel);
const mask = (1 << depth) - 1;

let dataIndex = 0;
let sampleIndex = 0;

for (let row = 0; row < height; row++) {
let bitsRemaining = 0;
let currentByte = 0;

for (let sample = 0; sample < samplesPerRow; sample++) {
if (bitsRemaining < depth) {
currentByte = raw[dataIndex++];
bitsRemaining = 8;
}

bitsRemaining -= depth;
samples[sampleIndex++] = (currentByte >> bitsRemaining) & mask;
}

bitsRemaining = 0;
}

return samples;
};

const expandIndexedToRgba = (image: DecodedPng) => {
if (!image.palette || image.palette.length === 0) {
throw new InvalidPngError('Indexed PNG is missing a color palette');
}

const paletteChannels = image.palette[0].length;
if (paletteChannels !== 3 && paletteChannels !== 4) {
throw new InvalidPngError(
`Unsupported palette channel count: ${paletteChannels}`,
);
}

const paletteData = convertIndexedToRgb(image);
const pixelCount = image.width * image.height;
const rgba = new Uint8Array(pixelCount * 4);

for (let i = 0; i < pixelCount; i++) {
const paletteOffset = i * paletteChannels;
const rgbaOffset = i * 4;

rgba[rgbaOffset] = paletteData[paletteOffset];
rgba[rgbaOffset + 1] = paletteData[paletteOffset + 1];
rgba[rgbaOffset + 2] = paletteData[paletteOffset + 2];
rgba[rgbaOffset + 3] =
paletteChannels === 4 ? paletteData[paletteOffset + 3] : 255;
}

return rgba;
};

export const toRgba8 = (image: DecodedPng): Uint8Array<ArrayBuffer> => {
if (image.palette) return expandIndexedToRgba(image);

const samples = unpackSamples(image);
const { channels, depth, width, height, transparency } = image;
const pixelCount = width * height;

const expectedSamples = pixelCount * channels;
if (samples.length < expectedSamples) {
throw new InvalidPngError(
`PNG data is truncated (expected at least ${expectedSamples} samples, got ${samples.length})`,
);
}

const rgba = new Uint8Array(pixelCount * 4);

switch (channels) {
case 4: {
for (let i = 0; i < pixelCount; i++) {
const sampleOffset = i * 4;
const rgbaOffset = i * 4;

rgba[rgbaOffset] = toByte(samples[sampleOffset], depth);
rgba[rgbaOffset + 1] = toByte(samples[sampleOffset + 1], depth);
rgba[rgbaOffset + 2] = toByte(samples[sampleOffset + 2], depth);
rgba[rgbaOffset + 3] = toByte(samples[sampleOffset + 3], depth);
}
break;
}

case 3: {
const transparentColor =
transparency && transparency.length >= 3
? [
toByte(transparency[0], depth),
toByte(transparency[1], depth),
toByte(transparency[2], depth),
]
: undefined;

for (let i = 0; i < pixelCount; i++) {
const sampleOffset = i * 3;
const rgbaOffset = i * 4;

const r = toByte(samples[sampleOffset], depth);
const g = toByte(samples[sampleOffset + 1], depth);
const b = toByte(samples[sampleOffset + 2], depth);

rgba[rgbaOffset] = r;
rgba[rgbaOffset + 1] = g;
rgba[rgbaOffset + 2] = b;
rgba[rgbaOffset + 3] =
transparentColor &&
r === transparentColor[0] &&
g === transparentColor[1] &&
b === transparentColor[2]
? 0
: 255;
}
break;
}

case 2: {
for (let i = 0; i < pixelCount; i++) {
const sampleOffset = i * 2;
const rgbaOffset = i * 4;

const gray = toByte(samples[sampleOffset], depth);
const alpha = toByte(samples[sampleOffset + 1], depth);

rgba[rgbaOffset] = gray;
rgba[rgbaOffset + 1] = gray;
rgba[rgbaOffset + 2] = gray;
rgba[rgbaOffset + 3] = alpha;
}
break;
}

case 1: {
const transparentSample =
transparency && transparency.length > 0
? toByte(transparency[0], depth)
: undefined;

for (let i = 0; i < pixelCount; i++) {
const rgbaOffset = i * 4;
const gray = toByte(samples[i], depth);

rgba[rgbaOffset] = gray;
rgba[rgbaOffset + 1] = gray;
rgba[rgbaOffset + 2] = gray;
rgba[rgbaOffset + 3] =
transparentSample !== undefined && gray === transparentSample
? 0
: 255;
}
break;
}

default:
throw new InvalidPngError(`Unknown channel count: ${channels}`);
}

return rgba;
};
67 changes: 27 additions & 40 deletions src/utils/png.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
import UPNGModule from '@pdf-lib/upng';
import {
AnimatedPngNotSupportedError,
InvalidPngError,
} from 'src/utils/errors.js';

/**
* UPNGModule has different shapes depending on the bundler / module system.
* The following attempts to cover the most common cases.
*/
const UPNG: typeof UPNGModule =
(UPNGModule as any)?.default?.default ??
(UPNGModule as any)?.default ??
(UPNGModule as any);

const mapUpngError = (error: unknown, msgPrefix: string): InvalidPngError => {
import { decode } from 'fast-png';
import { toRgba8 } from 'src/utils/fast-png-helper.js';
import { InvalidPngError } from 'src/utils/errors.js';

const mapPngError = (error: unknown, msgPrefix: string): InvalidPngError => {
const message =
error instanceof Error ? `${error.name}: ${error.message}` : String(error);
error instanceof InvalidPngError
? error.message
: error instanceof Error
? `${error.name}: ${error.message}`
: String(error);
return new InvalidPngError(`${msgPrefix} ${message}`);
};

const getImageType = (ctype: number) => {
if (ctype === 0) return PngType.Greyscale;
if (ctype === 2) return PngType.Truecolour;
if (ctype === 3) return PngType.IndexedColour;
if (ctype === 4) return PngType.GreyscaleWithAlpha;
if (ctype === 6) return PngType.TruecolourWithAlpha;
throw new InvalidPngError(`Unknown color type: ${ctype}`);
const getImageType = (channels: number, palette?: unknown[][]): PngType => {
if (palette) return PngType.IndexedColour;
if (channels === 1) return PngType.Greyscale;
if (channels === 2) return PngType.GreyscaleWithAlpha;
if (channels === 3) return PngType.Truecolour;
if (channels === 4) return PngType.TruecolourWithAlpha;
throw new InvalidPngError(`Unknown channel count: ${channels}`);
};

const splitAlphaChannel = (rgbaChannel: Uint8Array) => {
Expand Down Expand Up @@ -67,37 +60,31 @@ export class PNG {
readonly bitsPerComponent: number;

private constructor(pngData: Uint8Array) {
let upng: ReturnType<typeof UPNG.decode>;
let decoded: ReturnType<typeof decode>;
try {
// @ts-ignore : It internally does new Uint8Array()
upng = UPNG.decode(pngData);
decoded = decode(pngData);
} catch (error) {
throw mapUpngError(error, 'Failed to decode PNG:');
throw mapPngError(error, 'Failed to decode PNG:');
}

let frames: ArrayBuffer[];
let rgbaBuffer: Uint8Array;
try {
frames = UPNG.toRGBA8(upng);
rgbaBuffer = toRgba8(decoded);
} catch (error) {
throw mapUpngError(error, 'Failed to convert PNG to RGBA8:');
}

if (frames.length > 1) {
throw new AnimatedPngNotSupportedError();
throw mapPngError(error, 'Failed to convert PNG to RGBA8:');
}

const frame = new Uint8Array(frames[0]);
const { rgbChannel, alphaChannel } = splitAlphaChannel(frame);
const { rgbChannel, alphaChannel } = splitAlphaChannel(rgbaBuffer);

this.rgbChannel = rgbChannel;

const hasAlphaValues = alphaChannel.some((a) => a < 255);
if (hasAlphaValues) this.alphaChannel = alphaChannel;

this.type = getImageType(upng.ctype);
this.type = getImageType(decoded.channels, decoded.palette);

this.width = upng.width;
this.height = upng.height;
this.width = decoded.width;
this.height = decoded.height;
this.bitsPerComponent = 8;
}
}
12 changes: 12 additions & 0 deletions tests/api/PDFDocument.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,18 @@ describe(`PDFDocument`, () => {
InvalidPngError,
);
});

it(`throws an error when the PNG data is truncated`, async () => {
const pdfDoc = await PDFDocument.create();
const truncatedPng = examplePngImage.slice(
0,
Math.floor(examplePngImage.length / 2),
);

await expect(pdfDoc.embedPng(truncatedPng)).rejects.toThrow(
InvalidPngError,
);
});
});

describe(`save() method`, () => {
Expand Down
Loading