Skip to content

Commit

Permalink
feat: Implement physical pixel size management for PNG
Browse files Browse the repository at this point in the history
This feature adds support for the 'pHYs' chunk in the PNG format specification, which governs the intended physical pixel size. The physical pixel size, often measured in DPI, affects the displayed size of the image in viewers that support this feature.

A practical application of this functionality is encoding HiDPI images (such as 2x or 4x) for display on Apple Retina screens.
  • Loading branch information
yegor-pelykh committed Sep 11, 2024
1 parent 18bd44f commit 07deebf
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@
"source.fixAll.eslint": "explicit"
},
"files.eol": "\n",
"pieces.cloudCapabilities": "Blended",
"pieces.telemetry": true,
}
15 changes: 15 additions & 0 deletions src/formats/png-decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { PngFilterType } from './png/png-filter-type.js';
import { Pixel } from '../image/pixel.js';
import { ImageFormat } from './image-format.js';
import { Rectangle } from '../common/rectangle.js';
import { PngPhysicalPixelDimensions } from './png/png-physical-pixel-dimensions.js';

/**
* Decode a PNG encoded image.
Expand Down Expand Up @@ -543,6 +544,20 @@ export class PngDecoder implements Decoder {
this._input.skip(4);
}
break;
case 'pHYs': {
const physData = InputBuffer.from(this._input.readRange(chunkSize));
const x = physData.readUint32();
const y = physData.readUint32();
const unit = physData.read();
this._info.pixelDimensions = new PngPhysicalPixelDimensions(
x,
y,
unit
);
// CRC
this._input.skip(4);
break;
}
case 'IHDR': {
const hdr = InputBuffer.from(this._input.readRange(chunkSize));
const hdrBytes: Uint8Array = hdr.toUint8Array();
Expand Down
29 changes: 29 additions & 0 deletions src/formats/png-encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { NeuralQuantizer } from '../image/neural-quantizer.js';
import { PngColorType } from './png/png-color-type.js';
import { Palette } from '../image/palette.js';
import { IccProfile } from '../image/icc-profile.js';
import { PngPhysicalPixelDimensions } from './png/png-physical-pixel-dimensions.js';

/**
* Options for initializing the PNG encoder.
Expand All @@ -27,6 +28,11 @@ export interface PngEncoderInitOptions {
* The compression level to use.
*/
level?: CompressionLevel;
/**
* The physical pixel dimensions of the image.
* This provides information about the intended display size of the image in physical units.
*/
pixelDimensions?: PngPhysicalPixelDimensions;
}

/**
Expand Down Expand Up @@ -84,6 +90,18 @@ export class PngEncoder implements Encoder {
return this._supportsAnimation;
}

/**
* Physical pixel dimensions of the PNG.
*/
private _pixelDimensions: PngPhysicalPixelDimensions | undefined;

/**
* Gets the physical pixel dimensions of the PNG.
*/
public get pixelDimensions(): PngPhysicalPixelDimensions | undefined {
return this._pixelDimensions;
}

/**
* Constructor for PngEncoder.
* @param {PngEncoderInitOptions} [opt] - Initialization options for the encoder.
Expand All @@ -93,6 +111,7 @@ export class PngEncoder implements Encoder {
constructor(opt?: PngEncoderInitOptions) {
this._filter = opt?.filter ?? PngFilterType.paeth;
this._level = opt?.level ?? 6;
this._pixelDimensions = opt?.pixelDimensions;
}

/**
Expand Down Expand Up @@ -612,6 +631,16 @@ export class PngEncoder implements Encoder {
}
}

if (this._pixelDimensions !== undefined) {
const phys = new OutputBuffer({
bigEndian: true,
});
phys.writeUint32(this._pixelDimensions.xPxPerUnit);
phys.writeUint32(this._pixelDimensions.yPxPerUnit);
phys.writeByte(this._pixelDimensions.unitSpecifier);
PngEncoder.writeChunk(this._output, 'pHYs', phys.getBytes());
}

if (this._isAnimated) {
this.writeFrameControlChunk(_image);
this._sequenceNumber++;
Expand Down
11 changes: 11 additions & 0 deletions src/formats/png/png-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Color } from '../../color/color.js';
import { DecodeInfo } from '../decode-info.js';
import { PngColorType } from './png-color-type.js';
import { PngFrame } from './png-frame.js';
import { PngPhysicalPixelDimensions } from './png-physical-pixel-dimensions.js';

/**
* Interface for initializing PNG information options.
Expand Down Expand Up @@ -216,6 +217,16 @@ export class PngInfo implements DecodeInfo {
return this._textData;
}

private _pixelDimensions: PngPhysicalPixelDimensions | undefined;
/** Gets the physical pixel dimensions of the PNG image. */
public get pixelDimensions(): PngPhysicalPixelDimensions | undefined {
return this._pixelDimensions;
}
/** Sets the physical pixel dimensions of the PNG image. */
public set pixelDimensions(v: PngPhysicalPixelDimensions | undefined) {
this._pixelDimensions = v;
}

private _repeat = 0;

/** Gets the repeat count of the PNG image. */
Expand Down
109 changes: 109 additions & 0 deletions src/formats/png/png-physical-pixel-dimensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Class representing the physical pixel dimensions of a PNG image.
*
* @format
*/

export class PngPhysicalPixelDimensions {
/**
* Conversion factor from meters to inches.
*/
private static readonly _inchesPerM: number = 39.3701;

/**
* Unit specifier for unknown units.
*/
public static readonly unitUnknown: number = 0;

/**
* Unit specifier for meters.
*/
public static readonly unitMeter: number = 1;

/**
* Pixels per unit on the X axis.
*/
private _xPxPerUnit: number;

/**
* Gets the pixels per unit on the X axis.
* @returns {number} Pixels per unit on the X axis.
*/
public get xPxPerUnit(): number {
return this._xPxPerUnit;
}

/**
* Pixels per unit on the Y axis.
*/
private _yPxPerUnit: number;

/**
* Gets the pixels per unit on the Y axis.
*/
public get yPxPerUnit(): number {
return this._yPxPerUnit;
}

/**
* Unit specifier, either `unitUnknown` or `unitMeter`.
*/
private _unitSpecifier: number;

/**
* Gets the unit specifier.
*/
public get unitSpecifier(): number {
return this._unitSpecifier;
}

/**
* Constructs a dimension descriptor with the given values.
* @param {number} xPxPerUnit - Pixels per unit on the X axis.
* @param {number} yPxPerUnit - Pixels per unit on the Y axis.
* @param {number} unitSpecifier - Unit specifier, either `unitUnknown` or `unitMeter`.
*/
constructor(xPxPerUnit: number, yPxPerUnit: number, unitSpecifier: number) {
this._xPxPerUnit = xPxPerUnit;
this._yPxPerUnit = yPxPerUnit;
this._unitSpecifier = unitSpecifier;
}

/**
* Constructs a dimension descriptor specifying x and y resolution in dots per inch (DPI).
* If `dpiY` is unspecified, `dpiX` is used for both x and y axes.
* @param {number} dpiX - Dots per inch on the X axis.
* @param {number} [dpiY] - Dots per inch on the Y axis.
* @returns {PngPhysicalPixelDimensions} A new instance of `PngPhysicalPixelDimensions`.
*/
public static fromDPI(
dpiX: number,
dpiY?: number
): PngPhysicalPixelDimensions {
const xPxPerUnit = Math.round(
dpiX * PngPhysicalPixelDimensions._inchesPerM
);
const yPxPerUnit = Math.round(
(dpiY ?? dpiX) * PngPhysicalPixelDimensions._inchesPerM
);
const unitSpecifier = PngPhysicalPixelDimensions.unitMeter;
return new PngPhysicalPixelDimensions(
xPxPerUnit,
yPxPerUnit,
unitSpecifier
);
}

/**
* Checks if this instance is equal to another `PngPhysicalPixelDimensions` instance.
* @param {PngPhysicalPixelDimensions} other - The other instance to compare with.
* @returns {boolean} `true` if the instances are equal, `false` otherwise.
*/
public equals(other: PngPhysicalPixelDimensions): boolean {
return (
this._xPxPerUnit === other._xPxPerUnit &&
this._yPxPerUnit === other._yPxPerUnit &&
this._unitSpecifier === other._unitSpecifier
);
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ export { PngDisposeMode } from './formats/png/png-dispose-mode.js';
export { PngFilterType } from './formats/png/png-filter-type.js';
export { PngFrame, PngFrameInitOptions } from './formats/png/png-frame.js';
export { PngInfo, PngInfoInitOptions } from './formats/png/png-info.js';
export { PngPhysicalPixelDimensions } from './formats/png/png-physical-pixel-dimensions.js';

export { PnmFormat } from './formats/pnm/pnm-format.js';
export { PnmInfo } from './formats/pnm/pnm-info.js';
Expand Down
51 changes: 51 additions & 0 deletions test/format/format.png.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
LibError,
MemoryImage,
PaletteUint8,
PngDecoder,
PngEncoder,
PngFilterType,
PngPhysicalPixelDimensions,
Point,
Transform,
} from '../../src';
Expand Down Expand Up @@ -942,6 +944,55 @@ describe('Format: PNG', () => {
expect(img2.textData?.get('foo')).toBe('bar');
});

/**
* Test case for verifying the pHYs (physical pixel dimensions) chunk
* in PNG encoding and decoding.
*/
test('pHYs', () => {
const img = new MemoryImage({
width: 16,
height: 16,
});
const phys1 = new PngPhysicalPixelDimensions(
1000,
1000,
PngPhysicalPixelDimensions.unitMeter
);
const png1 = new PngEncoder({
pixelDimensions: phys1,
}).encode({
image: img,
});
const dec1 = new PngDecoder();
dec1.decode({
bytes: png1,
});
expect(dec1).toBeDefined();
if (dec1 === undefined) {
return;
}

const equals = dec1.info.pixelDimensions?.equals(phys1) ?? false;
expect(equals).toBeTruthy();

const phys2 = PngPhysicalPixelDimensions.fromDPI(144, 288);
const png2 = new PngEncoder({
pixelDimensions: phys2,
}).encode({
image: img,
});
const dec2 = new PngDecoder();
dec2.decode({
bytes: png2,
});

const equals2 = dec2.info.pixelDimensions?.equals(phys1) ?? false;
expect(equals2).toBeFalsy();

const equals3 = dec2.info.pixelDimensions?.equals(phys2) ?? false;
expect(equals3).toBeTruthy();
});

/**
* Test encoding and decoding a PNG image with an ICC profile.
*/
Expand Down

0 comments on commit 07deebf

Please sign in to comment.