chore: add jimp dependency, icon padding script, and update configuration

This commit is contained in:
2026-05-31 14:04:37 +02:00
parent 30f81045ac
commit 7cd17307ba
1126 changed files with 166400 additions and 2 deletions
+4
View File
@@ -0,0 +1,4 @@
{
"esversion": 6,
"node": true
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright © 2017 Joseph T. Lapp
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.
+668
View File
@@ -0,0 +1,668 @@
# gifwrap
A Jimp-compatible library for working with GIFs
## Overview
`gifwrap` is a minimalist library for working with GIFs in Javascript, supporting both single- and multi-frame GIFs. It reads GIFs into an internal representation that's easy to work with and allows for making GIFs from scratch. The frame class is structured to make it easy to move images between [`Jimp`](https://github.com/oliver-moran/jimp) and `gifwrap` for more sophisticated image manipulation in `Jimp`, but the module has no dependency on `Jimp`.
The library uses Dean McNamee's [`omggif`](https://github.com/deanm/omggif) GIF encoder/decoder by default, but it employs an abstraction that allows using other encoders and decoders as well, once suitably wrapped.
At present, the module only works in Node.js. Includes Typescript typings.
## Installation
```
npm install gifwrap --save
```
or
```
yarn add gifwrap
```
## Usage
You can work with either GIF files or GIF encodings, and you can create GIFs from scratch.
The GifFrame class represents a single image frame, and the library largely represents a GIF as an array of GifFrame instances. For example, here is how you create a GIF from scratch:
```js
const { GifFrame, GifUtil, GifCodec } = require('gifwrap');
const width = 200, height = 100;
const frames = [];
let frame = new GifFrame(width, height, { delayCentisecs: 10 });
// modify the pixels at frame.bitmap.data
frames.push(frame);
frame = new GifFrame(width, height, { delayCentisecs: 15 });
// modify the pixels at frame.bitmap.data
frames.push(frame);
// add more frames as desired...
// to write to a file...
GifUtil.write("my-creation.gif", frames, { loops: 3 }).then(gif => {
console.log("written");
});
// to get the byte encoding without writing to a file...
const codec = new GifCodec();
codec.encodeGif(frames, { loops: 3 }).then(gif => {
// byte encoding is now in gif.buffer
});
```
Images are represented within a GifFrame exactly as they are in a `Jimp` image. In particular, each GifFrame instance has a `bitmap` property having the following structure:
* `frame.bitmap.width` - Width of image in pixels
* `frame.bitmap.height` - Height of image in pixels
* `frame.bitmap.data` - A Node.js Buffer that can be accessed like an array of bytes. Every 4 adjacent bytes represents the RGBA values of a single pixel. These 4 bytes correspond to red, green, blue, and alpha, in that order. Each pixel begins at an index that is a multiple of 4.
GIFs do not support partial transparency, so within `frame.bitmap.data`, pixels having alpha value 0x00 are treated as transparent and pixels of non-zero alpha value are treated as opaque. The encoder ignores the RGB values of transparent pixels.
`gifwrap` also provides utilities for reading GIF files and for parsing raw encodings:
```js
const { GifUtil } = require('gifwrap');
GifUtil.read("fancy.gif").then(inputGif => {
inputGif.frames.forEach(frame => {
const buf = frame.bitmap.data;
frame.scanAllCoords((x, y, bi) => {
// Halve all grays on right half of image.
if (x > inputGif.width / 2) {
const r = buf[bi];
const g = buf[bi + 1];
const b = buf[bi + 2];
const a = buf[bi + 3];
if (r === g && r === b && a === 0xFF) {
buf[bi] /= 2;
buf[bi + 1] /= 2;
buf[bi + 2] /= 2;
}
}
});
});
// Pass inputGif to write() to preserve the original GIF's specs.
return GifUtil.write("modified.gif", inputGif.frames, inputGif).then(outputGif => {
console.log("modified");
});
});
```
```js
const { GifUtil, GifCodec } = require('gifwrap');
const codec = new GifCodec();
const byteEncodingBuffer = getByteEncodingForSomeGif();
codec.decodeGif(byteEncodingBuffer).then(sourceGif => {
const edgeLength = Math.min(sourceGif.width, sourceGif.height);
sourceGif.frames.forEach(frame => {
// Make each frame a centered square of size edgeLength x edgeLength.
// Note that frames may vary in size and that reframe() works even if
// the frame's image is smaller than the square. Should this happen,
// the space surrounding the original image will be transparent.
const xOffset = (frame.bitmap.width - edgeLength)/2;
const yOffset = (frame.bitmap.height - edgeLength)/2;
frame.reframe(xOffset, yOffset, edgeLength, edgeLength);
});
// The encoder determines GIF size from the frames, not the provided spec (sourceGif).
return GifUtil.write("modified.gif", sourceGif.frames, sourceGif).then(outputGif => {
console.log("modified");
});
});
```
Notice that both encoding and decoding yields a GIF object. This is an instance of class Gif, and it provides information about the GIF, such as its size and how many times it loops. Notice also that you never call the Gif constructor to create a GIF. Instead, GIFs are created by providing a GifFrame array and a specification of GIF options. That specification is a subset of the properties of a Gif, so you can pass a previously-loaded Gif as a specification when writing or encoding. The encoder only uses the properties that can't be inferred from the frames -- namely, how many times the GIF loops and how to attempt to package the color tables within the encoding.
## Leveraging Jimp
This module was originally written as a wrapper around Jimp images -- hence its name -- and then with frames as subclasses of Jimp images. Neither approach worked out well. The final approach requires just a tad of legwork to use `gifwrap` images within Jimp.
Both Jimp images and GifFrame instances share the `bitmap` property. By transferring this property back and forth between Jimp images and GifFrame instances, an image can be moved back and forth between the two libraries.
You can construct a GifFrame from a Jimp image as follows:
```js
const { BitmapImage, GifFrame } = require('gifwrap');
const Jimp = require('jimp');
const j = new Jimp(200, 100, 0xFFFFFFFF);
// create a frame clone of a Jip bitmap
const fCopied = new GifFrame(new BitmapImage(j.bitmap));
// create a frame that shares a bitmap with Jimp (one way)
const fShared1 = new GifFrame(j.bitmap);
// create a frame that shares a bitmap with Jimp (another way)
const fShared2 = new GifFrame(1, 1, 0); // any GifFrame
fShared2.bitmap = j.bitmap;
```
And you can construct a Jimp instance from a GifFrame image as follows:
```js
const { BitmapImage, GifFrame } = require('gifwrap');
const Jimp = require('jimp');
const frame = new GifFrame(200, 100, 0xFFFFFFFF);
// create a Jimp containing a clone of the frame bitmap
jimpCopied = GifUtil.copyAsJimp(Jimp, frame);
// create a Jimp that shares a bitmap with the frame
jimpShared = GifUtil.shareAsJimp(Jimp, frame);
```
## Encoders and Decoders
`gifwrap` provides a default GIF encoder/decoder, but it is architected to be able to work with other encoders and decoders. The encoder and decoder may even be separate implementations. Encoders and decoders have varying capabilities, performance measures, and levels of reliability.
GifCodec is the default implementation, and it's both an encoder and a decoder. It's an adapter that wraps the [`omggif`](https://github.com/deanm/omggif) module. `omggif` appears to support a broad variety of GIFs, although it cannot produce an interlaced encoding (which there is little need for anyway). Although `omggif` doesn't include a test suite at present, `gifwrap`'s test suite happens to test it reasonably well by virtue of using `omggif` underneath.
An encoder need only implement GifCodec's [`encodeGif()`](#GifCodec+encodeGif) method, and a decoder need only implement its [`decodeGif()`](#GifCodec+decodeGif) method. See the descriptions of those methods for the requirement details. Although GifCodec is stateless, so that instances an be reused across multiple encodings and decodings, third party encoders and decoders need not be. However, applications that use the library with stateful encoders will need to be aware of the need to create new instances.
To use a third-party encoder or decoder with the GifUtil `write()` and `read()` functions, just pass an instance of the encoder or decoder as the last parameter to `write()` or `read()`, respectively. For example:
```js
const { GifUtil } = require('gifwrap');
const SnazzyDecoder = require('gifwrap-snazzy-decoder');
const AwesomeEncoder = require('gifwrap-awesome-encoder');
GifUtil.read("fancy.gif", new SnazzyDecoder()).then(gif =>
/*...*/
return GifUtil.write("modified.gif", gif.frames, gif, new AwesomeEncoder()).then(newGif => {
console.log("modified");
});
});
```
## API Reference
The [Typescript typings](https://github.com/jtlapp/gifwrap/blob/master/index.d.ts) provide an exact specification of the API and also serve as a cheat sheet. The classes and namespaces follow:
* **gifwrap**
* [.**Gif**](#new_Gif_new)
* [.**BitmapImage**](#BitmapImage)
* [.**GifFrame**](#GifFrame)
* [.**GifUtil**](#GifUtil)
* [.**GifCodec**](#GifCodec)
* [.**GifError**](#new_GifError_new)
* [BitmapImage](#BitmapImage)
* [new BitmapImage()](#new_BitmapImage_new)
* [.blit(toImage, toX, toY, fromX, fromY)](#BitmapImage+blit)
* [.fillRGBA(rgba)](#BitmapImage+fillRGBA)
* [.getRGBA(x, y)](#BitmapImage+getRGBA)
* [.getRGBASet()](#BitmapImage+getRGBASet)
* [.greyscale()](#BitmapImage+greyscale)
* [.reframe(xOffset, yOffset, width, height, fillRGBA)](#BitmapImage+reframe)
* [.scale(factor)](#BitmapImage+scale)
* [.scanAllCoords(scanHandler)](#BitmapImage+scanAllCoords)
* [.scanAllIndexes(scanHandler)](#BitmapImage+scanAllIndexes)
* [GifFrame](#GifFrame)
* [new GifFrame()](#new_GifFrame_new)
* [.getPalette()](#GifFrame+getPalette)
* [GifUtil](#GifUtil)
* [.cloneFrames(frames)](#GifUtil.cloneFrames)
* [.getColorInfo(frames, maxGlobalIndex)](#GifUtil.getColorInfo)
* [.copyAsJimp(Reference, Instance)](#GifUtil.copyAsJimp)
* [.getMaxDimensions(frames)](#GifUtil.getMaxDimensions)
* [.quantizeDekker(imageOrImages, maxColorIndexes, dither)](#GifUtil.quantizeDekker)
* [.quantizeSorokin(imageOrImages, maxColorIndexes, histogram, dither)](#GifUtil.quantizeSorokin)
* [.quantizeWu(imageOrImages, maxColorIndexes, significantBits, dither)](#GifUtil.quantizeWu)
* [.read(source, decoder)](#GifUtil.read)
* [.shareAsJimp(Reference, Instance)](#GifUtil.shareAsJimp)
* [.write(path, frames, spec, encoder)](#GifUtil.write)
* [GifCodec](#GifCodec)
* [new GifCodec(options)](#new_GifCodec_new)
* [.decodeGif(buffer)](#GifCodec+decodeGif)
* [.encodeGif(frames, spec)](#GifCodec+encodeGif)
<a name="new_Gif_new"></a>
### new Gif(buffer, frames, spec)
| Param | Type | Description |
| --- | --- | --- |
| buffer | <code>Buffer</code> | A Buffer containing the encoded bytes |
| frames | [<code>Array.&lt;GifFrame&gt;</code>](#GifFrame) | Array of frames found in the encoding |
| spec | <code>object</code> | Properties of the encoding as listed above |
Gif is a class representing an encoded GIF. It is intended to be a read-only representation of a byte-encoded GIF. Only encoders and decoders should be creating instances of this class.
Property | Description
--- | ---
width | width of the GIF at its widest
height | height of the GIF at its highest
loops | the number of times the GIF should loop before stopping; 0 => loop indefinitely
usesTransparency | boolean indicating whether at least one frame contains at least one transparent pixel
colorScope | the scope of the color tables as encoded within the GIF; either Gif.GlobalColorsOnly (== 1) or Gif.LocalColorsOnly (== 2).
frames | a array of GifFrame instances, one for each frame of the GIF
buffer | a Buffer holding the encoding's byte data
Its constructor should only ever be called by the GIF encoder or decoder.
<a name="new_BitmapImage_new"></a>
### new BitmapImage()
BitmapImage is a class that hold an RGBA (red, green, blue, alpha) representation of an image. It's shape is borrowed from the Jimp package to make it easy to transfer GIF image frames into Jimp and Jimp images into GIF image frames. Each instance has a `bitmap` property having the following properties:
Property | Description
--- | ---
bitmap.width | width of image in pixels
bitmap.height | height of image in pixels
bitmap.data | a Buffer whose every four bytes represents a pixel, each sequential byte of a pixel corresponding to the red, green, blue, and alpha values of the pixel
Its constructor supports the following signatures:
* new BitmapImage(bitmap: { width: number, height: number, data: Buffer })
* new BitmapImage(bitmapImage: BitmapImage)
* new BitmapImage(width: number, height: number, buffer: Buffer)
* new BitmapImage(width: number, height: number, backgroundRGBA?: number)
When a `BitmapImage` is provided, the constructed `BitmapImage` is a deep clone of the provided one, so that each image's pixel data can subsequently be modified without affecting each other.
`backgroundRGBA` is an optional parameter representing a pixel as a single number. In hex, the number is as follows: 0xRRGGBBAA, where RR is the red byte, GG the green byte, BB, the blue byte, and AA the alpha value. An AA of 0x00 is considered transparent, and all non-zero AA values are treated as opaque.
<a name="BitmapImage+blit"></a>
### *bitmapImage*.blit(toImage, toX, toY, fromX, fromY)
| Param | Type | Description |
| --- | --- | --- |
| toImage | [<code>BitmapImage</code>](#BitmapImage) | Image into which to copy the square |
| toX | <code>number</code> | x-coord in toImage of upper-left corner of receiving square |
| toY | <code>number</code> | y-coord in toImage of upper-left corner of receiving square |
| fromX | <code>number</code> | x-coord in this image of upper-left corner of source square |
| fromY | <code>number</code> | y-coord in this image of upper-left corner of source square |
Copy a square portion of this image into another image.
**Returns**: [<code>BitmapImage</code>](#BitmapImage) - The present image to allow for chaining.
<a name="BitmapImage+fillRGBA"></a>
### *bitmapImage*.fillRGBA(rgba)
| Param | Type | Description |
| --- | --- | --- |
| rgba | <code>number</code> | Color with which to fill image, expressed as a singlenumber in the form 0xRRGGBBAA, where AA is 0x00 for transparent and any other value for opaque. |
Fills the image with a single color.
**Returns**: [<code>BitmapImage</code>](#BitmapImage) - The present image to allow for chaining.
<a name="BitmapImage+getRGBA"></a>
### *bitmapImage*.getRGBA(x, y)
| Param | Type | Description |
| --- | --- | --- |
| x | <code>number</code> | x-coord of pixel |
| y | <code>number</code> | y-coord of pixel |
Gets the RGBA number of the pixel at the given coordinate in the form 0xRRGGBBAA, where AA is the alpha value, with alpha 0x00 encoding to transparency in GIFs.
**Returns**: <code>number</code> - RGBA of pixel in 0xRRGGBBAA form
<a name="BitmapImage+getRGBASet"></a>
### *bitmapImage*.getRGBASet()
Gets a set of all RGBA colors found within the image.
**Returns**: <code>Set</code> - Set of all RGBA colors that the image contains.
<a name="BitmapImage+greyscale"></a>
### *bitmapImage*.greyscale()
Converts the image to greyscale using inferred Adobe metrics.
**Returns**: [<code>BitmapImage</code>](#BitmapImage) - The present image to allow for chaining.
<a name="BitmapImage+reframe"></a>
### *bitmapImage*.reframe(xOffset, yOffset, width, height, fillRGBA)
| Param | Type | Description |
| --- | --- | --- |
| xOffset | <code>number</code> | The x-coord offset of the upper-left pixel of the desired image relative to the present image. |
| yOffset | <code>number</code> | The y-coord offset of the upper-left pixel of the desired image relative to the present image. |
| width | <code>number</code> | The width of the new image after reframing |
| height | <code>number</code> | The height of the new image after reframing |
| fillRGBA | <code>number</code> | The color with which to fill space added to the image as a result of the reframing, in 0xRRGGBBAA format, where AA is 0x00 to indicate transparent and a non-zero value to indicate opaque. This parameter is only required when the reframing exceeds the original boundaries (i.e. does not simply perform a crop). |
Reframes the image as if placing a frame around the original image and replacing the original image with the newly framed image. When the new frame is strictly within the boundaries of the original image, this method crops the image. When any of the new boundaries exceed those of the original image, the `fillRGBA` must be provided to indicate the color with which to fill the extra space added to the image.
**Returns**: [<code>BitmapImage</code>](#BitmapImage) - The present image to allow for chaining.
<a name="BitmapImage+scale"></a>
### *bitmapImage*.scale(factor)
| Param | Type | Description |
| --- | --- | --- |
| factor | <code>number</code> | The factor by which to scale up the image. Must be an integer >= 1. |
Scales the image size up by an integer factor. Each pixel of the original image becomes a square of the same color in the new image having a size of `factor` x `factor` pixels.
**Returns**: [<code>BitmapImage</code>](#BitmapImage) - The present image to allow for chaining.
<a name="BitmapImage+scanAllCoords"></a>
### *bitmapImage*.scanAllCoords(scanHandler)
**See**: scanAllIndexes
| Param | Type | Description |
| --- | --- | --- |
| scanHandler | <code>function</code> | A function(x: number, y: number, bi: number) to be called for each pixel of the image with that pixel's x-coord, y-coord, and index into the `data` buffer. The function accesses the pixel at this coordinate by accessing the `this.data` at index `bi`. |
Scans all coordinates of the image, handing each in turn to the provided handler function.
<a name="BitmapImage+scanAllIndexes"></a>
### *bitmapImage*.scanAllIndexes(scanHandler)
**See**: scanAllCoords
| Param | Type | Description |
| --- | --- | --- |
| scanHandler | <code>function</code> | A function(bi: number) to be called for each pixel of the image with that pixel's index into the `data` buffer. The pixels is found at index 'bi' within `this.data`. |
Scans all pixels of the image, handing the index of each in turn to the provided handler function. Runs a bit faster than `scanAllCoords()`, should the handler not need pixel coordinates.
<a name="new_GifFrame_new"></a>
### new GifFrame()
GifFrame is a class representing an image frame of a GIF. GIFs contain one or more instances of GifFrame.
Property | Description
--- | ---
xOffset | x-coord of position within GIF at which to render the image (defaults to 0)
yOffset | y-coord of position within GIF at which to render the image (defaults to 0)
disposalMethod | GIF disposal method; only relevant when the frames aren't all the same size (defaults to 2, disposing to background color)
delayCentisecs | duration of the frame in hundreths of a second
interlaced | boolean indicating whether the frame renders interlaced
Its constructor supports the following signatures:
* new GifFrame(bitmap: {width: number, height: number, data: Buffer}, options?)
* new GifFrame(bitmapImage: BitmapImage, options?)
* new GifFrame(width: number, height: number, buffer: Buffer, options?)
* new GifFrame(width: number, height: number, backgroundRGBA?: number, options?)
* new GifFrame(frame: GifFrame)
See the base class BitmapImage for a discussion of all parameters but `options` and `frame`. `options` is an optional argument providing initial values for the above-listed GifFrame properties. Each property within option is itself optional.
Provide a `frame` to the constructor to create a clone of the provided frame. The new frame includes a copy of the provided frame's pixel data so that each can subsequently be modified without affecting each other.
<a name="GifFrame+getPalette"></a>
### *gifFrame*.getPalette()
Get a summary of the colors found within the frame. The return value is an object of the following form:
Property | Description
--- | ---
colors | An array of all the opaque colors found within the frame. Each color is given as an RGB number of the form 0xRRGGBB. The array is sorted by increasing number. Will be an empty array when the image is completely transparent.
usesTransparency | boolean indicating whether there are any transparent pixels within the frame. A pixel is considered transparent if its alpha value is 0x00.
indexCount | The number of color indexes required to represent this palette of colors. It is equal to the number of opaque colors plus one if the image includes transparency.
**Returns**: <code>object</code> - An object representing a color palette as described above.
<a name="GifUtil.cloneFrames"></a>
### *GifUtil*.cloneFrames(frames)
| Param | Type | Description |
| --- | --- | --- |
| frames | [<code>Array.&lt;GifFrame&gt;</code>](#GifFrame) | An array of GifFrame instances to clone |
cloneFrames() clones provided frames. It's a utility method for cloning an entire array of frames at once.
**Returns**: [<code>Array.&lt;GifFrame&gt;</code>](#GifFrame) - An array of GifFrame clones of the provided frames.
<a name="GifUtil.getColorInfo"></a>
### *GifUtil*.getColorInfo(frames, maxGlobalIndex)
**Throws**:
- [<code>GifError</code>](#GifError) When any frame requires more than 256 color indexes.
| Param | Type | Description |
| --- | --- | --- |
| frames | [<code>Array.&lt;GifFrame&gt;</code>](#GifFrame) | Frames to examine for color and transparency. |
| maxGlobalIndex | <code>number</code> | Maximum number of color indexes (including one for transparency) allowed among the returned compilation of colors. `colors` and `indexCount` are not returned if the number of color indexes required to accommodate all frames exceeds this number. Returns `colors` and `indexCount` by default. |
getColorInfo() gets information about the colors used in the provided frames. The method is able to return an array of all colors found across all frames.
`maxGlobalIndex` controls whether the computation short-circuits to avoid doing work that the caller doesn't need. The method only returns `colors` and `indexCount` for the colors across all frames when the number of indexes required to store the colors and transparency in a GIF (which is the value of `indexCount`) is less than or equal to `maxGlobalIndex`. Such short-circuiting is useful when the caller just needs to determine whether any frame includes transparency.
**Returns**: <code>object</code> - Object containing at least `palettes` and `usesTransparency`. `palettes` is an array of all the palettes returned by GifFrame#getPalette(). `usesTransparency` indicates whether at least one frame uses transparency. If `maxGlobalIndex` is not exceeded, the object also contains `colors`, an array of all colors (RGB) found across all palettes, sorted by increasing value, and `indexCount` indicating the number of indexes required to store the colors and the transparency in a GIF.
<a name="GifUtil.copyAsJimp"></a>
### *GifUtil*.copyAsJimp(Reference, Instance)
| Param | Type | Description |
| --- | --- | --- |
| Reference | <code>object</code> | to the Jimp package, keeping this library from being dependent on Jimp. |
| Instance | <code>bitmapImageToCopy</code> | of BitmapImage (may be a GifUtil) with which to source the Jimp. |
copyAsJimp() returns a Jimp that contains a copy of the provided bitmap image (which may be either a BitmapImage or a GifFrame). Modifying the Jimp does not affect the provided bitmap image. This method serves as a macro for simplifying working with Jimp.
**Returns**: <code>object</code> - An new instance of Jimp containing a copy of the image in bitmapImageToCopy.
<a name="GifUtil.getMaxDimensions"></a>
### *GifUtil*.getMaxDimensions(frames)
| Param | Type | Description |
| --- | --- | --- |
| frames | [<code>Array.&lt;GifFrame&gt;</code>](#GifFrame) | Frames to measure for their aggregate maximum dimensions. |
getMaxDimensions() returns the pixel width and height required to accommodate all of the provided frames, according to the offsets and dimensions of each frame.
**Returns**: <code>object</code> - An object of the form {maxWidth, maxHeight} indicating the maximum width and height required to accommodate all frames.
<a name="GifUtil.quantizeDekker"></a>
### *GifUtil*.quantizeDekker(imageOrImages, maxColorIndexes, dither)
| Param | Type | Description |
| --- | --- | --- |
| imageOrImages | [<code>BitmapImage</code>](#BitmapImage) \| [<code>Array.&lt;BitmapImage&gt;</code>](#BitmapImage) | Image or array of images (such as GifFrame instances) to be color-quantized. Quantizing across multiple images ensures color consistency from frame to frame. |
| maxColorIndexes | <code>number</code> | The maximum number of color indexes that will exist in the palette after completing quantization. Defaults to 256. |
| dither | <code>object</code> | (optional) An object configuring the dithering to apply. The properties are as followings, imported from the [`image-q` package](https://github.com/ibezkrovnyi/image-quantization) without explanation: { `ditherAlgorithm`: One of 'FloydSteinberg', 'FalseFloydSteinberg', 'Stucki', 'Atkinson', 'Jarvis', 'Burkes', 'Sierra', 'TwoSierra', 'SierraLite'; `minimumColorDistanceToDither`: (optional) A number defaulting to 0; `serpentine`: (optional) A boolean defaulting to true; `calculateErrorLikeGIMP`: (optional) A boolean defaulting to false. } |
Quantizes colors so that there are at most a given number of color indexes (including transparency) across all provided images. Uses an algorithm by Anthony Dekker.
The method treats different RGBA combinations as different colors, so if the frame has multiple alpha values or multiple RGB values for an alpha value, the caller may first want to normalize them by converting all transparent pixels to the same RGBA values.
The method may increase the number of colors if there are fewer than the provided maximum.
<a name="GifUtil.quantizeSorokin"></a>
### *GifUtil*.quantizeSorokin(imageOrImages, maxColorIndexes, histogram, dither)
| Param | Type | Description |
| --- | --- | --- |
| imageOrImages | [<code>BitmapImage</code>](#BitmapImage) \| [<code>Array.&lt;BitmapImage&gt;</code>](#BitmapImage) | Image or array of images (such as GifFrame instances) to be color-quantized. Quantizing across multiple images ensures color consistency from frame to frame. |
| maxColorIndexes | <code>number</code> | The maximum number of color indexes that will exist in the palette after completing quantization. Defaults to 256. |
| histogram | <code>string</code> | (optional) Histogram method: 'top-pop' for global top-population, 'min-pop' for minimum-population threshhold within subregions. Defaults to 'min-pop'. |
| dither | <code>object</code> | (optional) An object configuring the dithering to apply, as explained for `quantizeDekker()`. |
Quantizes colors so that there are at most a given number of color indexes (including transparency) across all provided images. Uses an algorithm by Leon Sorokin. This quantization method differs from the other two by likely never increasing the number of colors, should there be fewer than the provided maximum.
The method treats different RGBA combinations as different colors, so if the frame has multiple alpha values or multiple RGB values for an alpha value, the caller may first want to normalize them by converting all transparent pixels to the same RGBA values.
<a name="GifUtil.quantizeWu"></a>
### *GifUtil*.quantizeWu(imageOrImages, maxColorIndexes, significantBits, dither)
| Param | Type | Description |
| --- | --- | --- |
| imageOrImages | [<code>BitmapImage</code>](#BitmapImage) \| [<code>Array.&lt;BitmapImage&gt;</code>](#BitmapImage) | Image or array of images (such as GifFrame instances) to be color-quantized. Quantizing across multiple images ensures color consistency from frame to frame. |
| maxColorIndexes | <code>number</code> | The maximum number of color indexes that will exist in the palette after completing quantization. Defaults to 256. |
| significantBits | <code>number</code> | (optional) This is the number of significant high bits in each RGB color channel. Takes integer values from 1 through 8. Higher values correspond to higher quality. Defaults to 5. |
| dither | <code>object</code> | (optional) An object configuring the dithering to apply, as explained for `quantizeDekker()`. |
Quantizes colors so that there are at most a given number of color indexes (including transparency) across all provided images. Uses an algorithm by Xiaolin Wu.
The method treats different RGBA combinations as different colors, so if the frame has multiple alpha values or multiple RGB values for an alpha value, the caller may first want to normalize them by converting all transparent pixels to the same RGBA values.
The method may increase the number of colors if there are fewer than the provided maximum.
<a name="GifUtil.read"></a>
### *GifUtil*.read(source, decoder)
| Param | Type | Description |
| --- | --- | --- |
| source | <code>string</code> \| <code>Buffer</code> | Source to decode. When a string, it's the GIF filename to load and parse. When a Buffer, it's an encoded GIF to parse. |
| decoder | <code>object</code> | An optional GIF decoder object implementing the `decode` method of class GifCodec. When provided, the method decodes the GIF using this decoder. When not provided, the method uses GifCodec. |
read() decodes an encoded GIF, whether provided as a filename or as a byte buffer.
**Returns**: <code>Promise</code> - A Promise that resolves to an instance of the Gif class, representing the decoded GIF.
<a name="GifUtil.shareAsJimp"></a>
### *GifUtil*.shareAsJimp(Reference, Instance)
| Param | Type | Description |
| --- | --- | --- |
| Reference | <code>object</code> | to the Jimp package, keeping this library from being dependent on Jimp. |
| Instance | <code>bitmapImageToShare</code> | of BitmapImage (may be a GifUtil) with which to source the Jimp. |
shareAsJimp() returns a Jimp that shares a bitmap with the provided bitmap image (which may be either a BitmapImage or a GifFrame). Modifying the image in either the Jimp or the BitmapImage affects the other objects. This method serves as a macro for simplifying working with Jimp.
**Returns**: <code>object</code> - An new instance of Jimp that shares the image in bitmapImageToShare.
<a name="GifUtil.write"></a>
### *GifUtil*.write(path, frames, spec, encoder)
| Param | Type | Description |
| --- | --- | --- |
| path | <code>string</code> | Filename to write GIF out as. Will overwrite an existing file. |
| frames | [<code>Array.&lt;GifFrame&gt;</code>](#GifFrame) | Array of frames to be written into GIF. |
| spec | <code>object</code> | An optional object that may provide values for `loops` and `colorScope`, as defined for the Gif class. However, `colorSpace` may also take the value Gif.GlobalColorsPreferred (== 0) to indicate that the encoder should attempt to create only a global color table. `loop` defaults to 0, looping indefinitely, and `colorScope` defaults to Gif.GlobalColorsPreferred. |
| encoder | <code>object</code> | An optional GIF encoder object implementing the `encode` method of class GifCodec. When provided, the method encodes the GIF using this encoder. When not provided, the method uses GifCodec. |
write() encodes a GIF and saves it as a file.
**Returns**: <code>Promise</code> - A Promise that resolves to an instance of the Gif class, representing the encoded GIF.
<a name="new_GifCodec_new"></a>
### new GifCodec(options)
| Param | Type | Description |
| --- | --- | --- |
| options | <code>object</code> | Optionally takes an objection whose only possible property is `transparentRGB`. Images are internally represented in RGBA format, where A is the alpha value of a pixel. When `transparentRGB` is provided, this RGB value (excluding alpha) is assigned to transparent pixels, which are also given alpha value 0x00. (All opaque pixels are given alpha value 0xFF). The RGB color of transparent pixels shouldn't matter for most applications. Defaults to 0x000000. |
GifCodec is a class that both encodes and decodes GIFs. It implements both the `encode()` method expected of an encoder and the `decode()` method expected of a decoder, and it wraps the `omggif` GIF encoder/decoder package. GifCodec serves as this library's default encoder and decoder, but it's possible to wrap other GIF encoders and decoders for use by `gifwrap` as well. GifCodec will not encode GIFs with interlacing.
Instances of this class are stateless and can be shared across multiple encodings and decodings.
Its constructor takes one option argument:
<a name="GifCodec+decodeGif"></a>
### *gifCodec*.decodeGif(buffer)
**Throws**:
- [<code>GifError</code>](#GifError) Error upon encountered an encoding-related problem with a GIF, so that the caller can distinguish between software errors and problems with GIFs.
| Param | Type | Description |
| --- | --- | --- |
| buffer | <code>Buffer</code> | Bytes of an encoded GIF to decode. |
Decodes a GIF from a Buffer to yield an instance of Gif. Transparent pixels of the GIF are given alpha values of 0x00, and opaque pixels are given alpha values of 0xFF. The RGB values of transparent pixels default to 0x000000 but can be overridden by the constructor's `transparentRGB` option.
**Returns**: <code>Promise</code> - A Promise that resolves to an instance of the Gif class, representing the encoded GIF.
<a name="GifCodec+encodeGif"></a>
### *gifCodec*.encodeGif(frames, spec)
**Throws**:
- [<code>GifError</code>](#GifError) Error upon encountered an encoding-related problem with a GIF, so that the caller can distinguish between software errors and problems with GIFs.
| Param | Type | Description |
| --- | --- | --- |
| frames | [<code>Array.&lt;GifFrame&gt;</code>](#GifFrame) | Array of frames to encode |
| spec | <code>object</code> | An optional object that may provide values for `loops` and `colorScope`, as defined for the Gif class. However, `colorSpace` may also take the value Gif.GlobalColorsPreferred (== 0) to indicate that the encoder should attempt to create only a global color table. `loop` defaults to 0, looping indefinitely. Set `loop` to null to disable looping, playing only once. `colorScope` defaults to Gif.GlobalColorsPreferred. |
Encodes a GIF from provided frames. Each pixel having an alpha value of 0x00 renders as transparent within the encoding, while all pixels of non-zero alpha value render as opaque.
**Returns**: <code>Promise</code> - A Promise that resolves to an instance of the Gif class, representing the encoded GIF.
<a name="new_GifError_new"></a>
### new GifError(messageOrError)
| Param | Type |
| --- | --- |
| messageOrError | <code>string</code> \| <code>Error</code> |
GifError is a class representing a GIF-related error
## LICENSE
MIT License
Copyright © 2017 Joseph T. Lapp
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.
+153
View File
@@ -0,0 +1,153 @@
export interface GifSpec {
loops?: number;
colorScope?: 0|1|2;
}
export interface GifEncoder {
encodeGif(frames: GifFrame[], spec: GifSpec): Promise<Gif>;
}
export interface GifDecoder {
decodeGif(buffer: Buffer): Promise<Gif>;
}
export class Gif implements GifSpec {
static readonly GlobalColorsPreferred: 0;
static readonly GlobalColorsOnly: 1;
static readonly LocalColorsOnly: 2;
width: number;
height: number;
loops: number;
usesTransparency: boolean;
colorScope: 0|1|2;
frames: GifFrame[];
buffer: Buffer;
constructor(frames: GifFrame[], buffer: Buffer, spec?: GifSpec);
}
export interface GifFrameOptions {
xOffset?: number;
yOffset?: number;
disposalMethod?: 0|1|2|3;
delayCentisecs?: number;
isInterlaced?: boolean;
}
export interface JimpBitmap {
width: number;
height: number;
data: Buffer;
}
export interface GifPalette {
colors: number[];
indexCount: number;
usesTransparency: boolean;
}
export class BitmapImage {
bitmap: JimpBitmap;
constructor(bitmap: JimpBitmap);
constructor(bitmapImage: BitmapImage);
constructor(width: number, height: number, buffer: Buffer);
constructor(width: number, height: number, backgroundRGBA?: number);
blit(toImage: BitmapImage, toX: number, toY: number, fromX: number, fromY: number,
fromWidth: number, fromHeight: number): this;
fillRGBA(color: number): this;
getRGBA(x: number, y: number): number;
getRGBASet(): Set<number>;
greyscale(): this;
reframe(xOffset: number, yOffset: number, width: number, height: number, fillRGBA?: number)
: this;
scale(factor: number): this;
scanAllCoords(handler: (x: number, y: number, bufferIndex: number) => void): void;
scanAllIndexes(handler: (bufferIndex: number) => void): void;
}
export class GifFrame extends BitmapImage implements GifFrameOptions {
static readonly DisposeToAnything: 0;
static readonly DisposeNothing: 1;
static readonly DisposeToBackgroundColor: 2;
static readonly DisposeToPrevious: 3;
xOffset: number;
yOffset: number;
disposalMethod: 0|1|2|3;
delayCentisecs: number;
interlaced: boolean;
constructor(bitmap: JimpBitmap, options?: GifFrameOptions);
constructor(bitmapImage: BitmapImage, options?: GifFrameOptions);
constructor(width: number, height: number, buffer: Buffer, options?: GifFrameOptions);
constructor(width: number, height: number, backgroundRGBA?: number, options?: GifFrameOptions);
constructor(frame: GifFrame);
getPalette(): GifPalette;
}
export interface GifCodecOptions {
transparentRGB?: number;
}
export class GifCodec implements GifEncoder, GifDecoder {
constructor(options?: GifCodecOptions);
encodeGif(frames: GifFrame[], spec: GifSpec): Promise<Gif>;
decodeGif(buffer: Buffer): Promise<Gif>;
}
export class GifError extends Error {
constructor(message: string);
}
export namespace GifUtil {
function cloneFrames(frames: GifFrame[]): GifFrame[];
function copyAsJimp(jimp: any, bitmapImageToCopy: BitmapImage): any;
function getColorInfo(frames: GifFrame[], maxGlobalIndex?: number): {
colors?: number[],
indexCount?: number,
usesTransparency: boolean,
palettes: GifPalette[]
}
function getMaxDimensions(frames: GifFrame[]): { maxWidth: number, maxHeight: number };
function quantizeDekker(imageOrImages: BitmapImage|BitmapImage[], maxColorIndexes: number,
dither?: Dither): void;
function quantizeSorokin(imageOrImages: BitmapImage|BitmapImage[], maxColorIndexes: number,
histogram?: string, dither?: Dither): void;
function quantizeWu(imageOrImages: BitmapImage|BitmapImage[], maxColorIndexes: number,
significantBits?: number, dither?: Dither): void;
function read(source: string|Buffer, decoder?: GifDecoder): Promise<Gif>;
function shareAsJimp(jimp: any, bitmapImageToCopy: BitmapImage): any;
function write(path: string, frames: GifFrame[], spec?: GifSpec, encoder?: GifEncoder):
Promise<Gif>;
}
export type DitherAlgorithm =
'FloydSteinberg' |
'FalseFloydSteinberg' |
'Stucki' |
'Atkinson' |
'Jarvis' |
'Burkes' |
'Sierra' |
'TwoSierra' |
'SierraLite';
export type Dither = {
ditherAlgorithm: DitherAlgorithm,
minimumColorDistanceToDither?: number, // default = 0
serpentine?: boolean, // default = true
calculateErrorLikeGIMP?: boolean // default = false
};
+39
View File
@@ -0,0 +1,39 @@
{
"name": "gifwrap",
"version": "0.10.1",
"description": "A Jimp-compatible library for working with GIFs",
"main": "src/index.js",
"scripts": {
"test": "mocha --timeout=6000 ./test/*.js",
"docs": "jsdoc2md --plugin dmd-clear --template templates/README.hbs src/index.js src/gif.js src/bitmapimage.js src/gifframe.js src/gifutil.js src/gifcodec.js > README.md"
},
"repository": {
"type": "git",
"url": "git+https://github.com/jtlapp/gifwrap.git"
},
"keywords": [
"image",
"image",
"processing",
"image",
"manipulation",
"gif",
"javascript"
],
"author": "Joseph T. Lapp",
"license": "MIT",
"bugs": {
"url": "https://github.com/jtlapp/gifwrap/issues"
},
"homepage": "https://github.com/jtlapp/gifwrap#readme",
"dependencies": {
"image-q": "^4.0.0",
"omggif": "^1.0.10"
},
"devDependencies": {
"chai": "^4.2.0",
"dmd-clear": "^0.1.2",
"jimp": "^0.2.28",
"mocha": "^9.2.2"
}
}
+310
View File
@@ -0,0 +1,310 @@
'use strict';
/** @class BitmapImage */
class BitmapImage {
/**
* BitmapImage is a class that hold an RGBA (red, green, blue, alpha) representation of an image. It's shape is borrowed from the Jimp package to make it easy to transfer GIF image frames into Jimp and Jimp images into GIF image frames. Each instance has a `bitmap` property having the following properties:
*
* Property | Description
* --- | ---
* bitmap.width | width of image in pixels
* bitmap.height | height of image in pixels
* bitmap.data | a Buffer whose every four bytes represents a pixel, each sequential byte of a pixel corresponding to the red, green, blue, and alpha values of the pixel
*
* Its constructor supports the following signatures:
*
* * new BitmapImage(bitmap: { width: number, height: number, data: Buffer })
* * new BitmapImage(bitmapImage: BitmapImage)
* * new BitmapImage(width: number, height: number, buffer: Buffer)
* * new BitmapImage(width: number, height: number, backgroundRGBA?: number)
*
* When a `BitmapImage` is provided, the constructed `BitmapImage` is a deep clone of the provided one, so that each image's pixel data can subsequently be modified without affecting each other.
*
* `backgroundRGBA` is an optional parameter representing a pixel as a single number. In hex, the number is as follows: 0xRRGGBBAA, where RR is the red byte, GG the green byte, BB, the blue byte, and AA the alpha value. An AA of 0x00 is considered transparent, and all non-zero AA values are treated as opaque.
*/
constructor(...args) {
// don't confirm the number of args, because a subclass may have
// additional args and pass them all to the superclass
if (args.length === 0) {
throw new Error("constructor requires parameters");
}
const firstArg = args[0];
if (firstArg !== null && typeof firstArg === 'object') {
if (firstArg instanceof BitmapImage) {
// copy a provided BitmapImage
const sourceBitmap = firstArg.bitmap;
this.bitmap = {
width: sourceBitmap.width,
height: sourceBitmap.height,
data: new Buffer(sourceBitmap.width * sourceBitmap.height * 4)
};
sourceBitmap.data.copy(this.bitmap.data);
}
else if (firstArg.width && firstArg.height && firstArg.data) {
// share a provided bitmap
this.bitmap = firstArg;
}
else {
throw new Error("unrecognized constructor parameters");
}
}
else if (typeof firstArg === 'number' && typeof args[1] === 'number')
{
const width = firstArg;
const height = args[1];
const thirdArg = args[2];
this.bitmap = { width, height };
if (Buffer.isBuffer(thirdArg)) {
this.bitmap.data = thirdArg;
}
else {
this.bitmap.data = new Buffer(width * height * 4);
if (typeof thirdArg === 'number') {
this.fillRGBA(thirdArg);
}
}
}
else {
throw new Error("unrecognized constructor parameters");
}
}
/**
* Copy a square portion of this image into another image.
*
* @param {BitmapImage} toImage Image into which to copy the square
* @param {number} toX x-coord in toImage of upper-left corner of receiving square
* @param {number} toY y-coord in toImage of upper-left corner of receiving square
* @param {number} fromX x-coord in this image of upper-left corner of source square
* @param {number} fromY y-coord in this image of upper-left corner of source square
* @return {BitmapImage} The present image to allow for chaining.
*/
blit(toImage, toX, toY, fromX, fromY, fromWidth, fromHeight) {
if (fromX + fromWidth > this.bitmap.width) {
throw new Error("copy exceeds width of source bitmap");
}
if (toX + fromWidth > toImage.bitmap.width) {
throw new Error("copy exceeds width of target bitmap");
}
if (fromY + fromHeight > this.bitmap.height) {
throw new Error("copy exceeds height of source bitmap");
}
if (toY + fromHeight > toImage.bitmap.height) {
throw new Erro("copy exceeds height of target bitmap");
}
const sourceBuf = this.bitmap.data;
const targetBuf = toImage.bitmap.data;
const sourceByteWidth = this.bitmap.width * 4;
const targetByteWidth = toImage.bitmap.width * 4;
const copyByteWidth = fromWidth * 4;
let si = fromY * sourceByteWidth + fromX * 4;
let ti = toY * targetByteWidth + toX * 4;
while (--fromHeight >= 0) {
sourceBuf.copy(targetBuf, ti, si, si + copyByteWidth);
si += sourceByteWidth;
ti += targetByteWidth;
}
return this;
}
/**
* Fills the image with a single color.
*
* @param {number} rgba Color with which to fill image, expressed as a singlenumber in the form 0xRRGGBBAA, where AA is 0x00 for transparent and any other value for opaque.
* @return {BitmapImage} The present image to allow for chaining.
*/
fillRGBA(rgba) {
const buf = this.bitmap.data;
const bufByteWidth = this.bitmap.height * 4;
let bi = 0;
while (bi < bufByteWidth) {
buf.writeUInt32BE(rgba, bi);
bi += 4;
}
while (bi < buf.length) {
buf.copy(buf, bi, 0, bufByteWidth);
bi += bufByteWidth;
}
return this;
}
/**
* Gets the RGBA number of the pixel at the given coordinate in the form 0xRRGGBBAA, where AA is the alpha value, with alpha 0x00 encoding to transparency in GIFs.
*
* @param {number} x x-coord of pixel
* @param {number} y y-coord of pixel
* @return {number} RGBA of pixel in 0xRRGGBBAA form
*/
getRGBA(x, y) {
const bi = (y * this.bitmap.width + x) * 4;
return this.bitmap.data.readUInt32BE(bi);
}
/**
* Gets a set of all RGBA colors found within the image.
*
* @return {Set} Set of all RGBA colors that the image contains.
*/
getRGBASet() {
const rgbaSet = new Set();
const buf = this.bitmap.data;
for (let bi = 0; bi < buf.length; bi += 4) {
rgbaSet.add(buf.readUInt32BE(bi, true));
}
return rgbaSet;
}
/**
* Converts the image to greyscale using inferred Adobe metrics.
*
* @return {BitmapImage} The present image to allow for chaining.
*/
greyscale() {
const buf = this.bitmap.data;
this.scan(0, 0, this.bitmap.width, this.bitmap.height, (x, y, idx) => {
const grey = Math.round(
0.299 * buf[idx] +
0.587 * buf[idx + 1] +
0.114 * buf[idx + 2]
);
buf[idx] = grey;
buf[idx + 1] = grey;
buf[idx + 2] = grey;
});
return this;
}
/**
* Reframes the image as if placing a frame around the original image and replacing the original image with the newly framed image. When the new frame is strictly within the boundaries of the original image, this method crops the image. When any of the new boundaries exceed those of the original image, the `fillRGBA` must be provided to indicate the color with which to fill the extra space added to the image.
*
* @param {number} xOffset The x-coord offset of the upper-left pixel of the desired image relative to the present image.
* @param {number} yOffset The y-coord offset of the upper-left pixel of the desired image relative to the present image.
* @param {number} width The width of the new image after reframing
* @param {number} height The height of the new image after reframing
* @param {number} fillRGBA The color with which to fill space added to the image as a result of the reframing, in 0xRRGGBBAA format, where AA is 0x00 to indicate transparent and a non-zero value to indicate opaque. This parameter is only required when the reframing exceeds the original boundaries (i.e. does not simply perform a crop).
* @return {BitmapImage} The present image to allow for chaining.
*/
reframe(xOffset, yOffset, width, height, fillRGBA) {
const cropX = (xOffset < 0 ? 0 : xOffset);
const cropY = (yOffset < 0 ? 0 : yOffset);
const cropWidth = (width + cropX > this.bitmap.width ?
this.bitmap.width - cropX : width);
const cropHeight = (height + cropY > this.bitmap.height ?
this.bitmap.height - cropY : height);
const newX = (xOffset < 0 ? -xOffset : 0);
const newY = (yOffset < 0 ? -yOffset : 0);
let image;
if (fillRGBA === undefined) {
if (cropX !== xOffset || cropY != yOffset ||
cropWidth !== width || cropHeight !== height)
{
throw new GifError(`fillRGBA required for this reframing`);
}
image = new BitmapImage(width, height);
}
else {
image = new BitmapImage(width, height, fillRGBA);
}
this.blit(image, newX, newY, cropX, cropY, cropWidth, cropHeight);
this.bitmap = image.bitmap;
return this;
}
/**
* Scales the image size up by an integer factor. Each pixel of the original image becomes a square of the same color in the new image having a size of `factor` x `factor` pixels.
*
* @param {number} factor The factor by which to scale up the image. Must be an integer >= 1.
* @return {BitmapImage} The present image to allow for chaining.
*/
scale(factor) {
if (factor === 1) {
return;
}
if (!Number.isInteger(factor) || factor < 1) {
throw new Error("the scale must be an integer >= 1");
}
const sourceWidth = this.bitmap.width;
const sourceHeight = this.bitmap.height;
const destByteWidth = sourceWidth * factor * 4;
const sourceBuf = this.bitmap.data;
const destBuf = new Buffer(sourceHeight * destByteWidth * factor);
let sourceIndex = 0;
let priorDestRowIndex;
let destIndex = 0;
for (let y = 0; y < sourceHeight; ++y) {
priorDestRowIndex = destIndex;
for (let x = 0; x < sourceWidth; ++x) {
const color = sourceBuf.readUInt32BE(sourceIndex, true);
for (let cx = 0; cx < factor; ++cx) {
destBuf.writeUInt32BE(color, destIndex);
destIndex += 4;
}
sourceIndex += 4;
}
for (let cy = 1; cy < factor; ++cy) {
destBuf.copy(destBuf, destIndex, priorDestRowIndex, destIndex);
destIndex += destByteWidth;
priorDestRowIndex += destByteWidth;
}
}
this.bitmap = {
width: sourceWidth * factor,
height: sourceHeight * factor,
data: destBuf
};
return this;
}
/**
* Scans all coordinates of the image, handing each in turn to the provided handler function.
*
* @param {function} scanHandler A function(x: number, y: number, bi: number) to be called for each pixel of the image with that pixel's x-coord, y-coord, and index into the `data` buffer. The function accesses the pixel at this coordinate by accessing the `this.data` at index `bi`.
* @see scanAllIndexes
*/
scanAllCoords(scanHandler) {
const width = this.bitmap.width;
const bufferLength = this.bitmap.data.length;
let x = 0;
let y = 0;
for (let bi = 0; bi < bufferLength; bi += 4) {
scanHandler(x, y, bi);
if (++x === width) {
x = 0;
++y;
}
}
}
/**
* Scans all pixels of the image, handing the index of each in turn to the provided handler function. Runs a bit faster than `scanAllCoords()`, should the handler not need pixel coordinates.
*
* @param {function} scanHandler A function(bi: number) to be called for each pixel of the image with that pixel's index into the `data` buffer. The pixels is found at index 'bi' within `this.data`.
* @see scanAllCoords
*/
scanAllIndexes(scanHandler) {
const bufferLength = this.bitmap.data.length;
for (let bi = 0; bi < bufferLength; bi += 4) {
scanHandler(bi);
}
}
}
module.exports = BitmapImage;
+69
View File
@@ -0,0 +1,69 @@
'use strict';
/** @class Gif */
class Gif {
// width - width of GIF in pixels
// height - height of GIF in pixels
// loops - 0 = unending; (n > 0) = iterate n times
// usesTransparency - whether any frames have transparent pixels
// colorScope - scope of color tables in GIF
// frames - array of frames
// buffer - GIF-formatted data
/**
* Gif is a class representing an encoded GIF. It is intended to be a read-only representation of a byte-encoded GIF. Only encoders and decoders should be creating instances of this class.
*
* Property | Description
* --- | ---
* width | width of the GIF at its widest
* height | height of the GIF at its highest
* loops | the number of times the GIF should loop before stopping; 0 => loop indefinitely
* usesTransparency | boolean indicating whether at least one frame contains at least one transparent pixel
* colorScope | the scope of the color tables as encoded within the GIF; either Gif.GlobalColorsOnly (== 1) or Gif.LocalColorsOnly (== 2).
* frames | a array of GifFrame instances, one for each frame of the GIF
* buffer | a Buffer holding the encoding's byte data
*
* Its constructor should only ever be called by the GIF encoder or decoder.
*
* @param {Buffer} buffer A Buffer containing the encoded bytes
* @param {GifFrame[]} frames Array of frames found in the encoding
* @param {object} spec Properties of the encoding as listed above
*/
constructor(buffer, frames, spec) {
this.width = spec.width;
this.height = spec.height;
this.loops = spec.loops;
this.usesTransparency = spec.usesTransparency;
this.colorScope = spec.colorScope;
this.frames = frames;
this.buffer = buffer;
}
}
Gif.GlobalColorsPreferred = 0;
Gif.GlobalColorsOnly = 1;
Gif.LocalColorsOnly = 2;
/** @class GifError */
class GifError extends Error {
/**
* GifError is a class representing a GIF-related error
*
* @param {string|Error} messageOrError
*/
constructor(messageOrError) {
super(messageOrError);
if (messageOrError instanceof Error) {
this.stack = 'Gif' + messageOrError.stack;
}
}
}
exports.Gif = Gif;
exports.GifError = GifError;
+404
View File
@@ -0,0 +1,404 @@
'use strict';
const Omggif = require('omggif');
const { Gif, GifError } = require('./gif');
// allow circular dependency with GifUtil
function GifUtil() {
const data = require('./gifutil');
GifUtil = function () {
return data;
};
return data;
}
const { GifFrame } = require('./gifframe');
const PER_GIF_OVERHEAD = 200; // these are guesses at upper limits
const PER_FRAME_OVERHEAD = 100;
// Note: I experimented with accepting a global color table when encoding and returning the global color table when decoding. Doing this properly greatly increased the complexity of the code and the amount of clock cycles required. The main issue is that each frame can specify any color of the global color table to be transparent within the frame, while this GIF library strives to hide GIF formatting details from its clients. E.g. it's possible to have 256 colors in the global color table and different transparencies in each frame, requiring clients to either provide per-frame transparency indexes, or for arcane reasons that won't be apparent to client developers, encode some GIFs with local color tables that previously decoded with global tables.
/** @class GifCodec */
class GifCodec
{
// _transparentRGBA - RGB given to transparent pixels (alpha=0) on decode; defaults to null indicating 0x000000, which is fastest
/**
* GifCodec is a class that both encodes and decodes GIFs. It implements both the `encode()` method expected of an encoder and the `decode()` method expected of a decoder, and it wraps the `omggif` GIF encoder/decoder package. GifCodec serves as this library's default encoder and decoder, but it's possible to wrap other GIF encoders and decoders for use by `gifwrap` as well. GifCodec will not encode GIFs with interlacing.
*
* Instances of this class are stateless and can be shared across multiple encodings and decodings.
*
* Its constructor takes one option argument:
*
* @param {object} options Optionally takes an objection whose only possible property is `transparentRGB`. Images are internally represented in RGBA format, where A is the alpha value of a pixel. When `transparentRGB` is provided, this RGB value (excluding alpha) is assigned to transparent pixels, which are also given alpha value 0x00. (All opaque pixels are given alpha value 0xFF). The RGB color of transparent pixels shouldn't matter for most applications. Defaults to 0x000000.
*/
constructor(options = {}) {
this._transparentRGB = null; // 0x000000
if (typeof options.transparentRGB === 'number' &&
options.transparentRGB !== 0)
{
this._transparentRGBA = options.transparentRGB * 256;
}
this._testInitialBufferSize = 0; // assume no buffer scaling test
}
/**
* Decodes a GIF from a Buffer to yield an instance of Gif. Transparent pixels of the GIF are given alpha values of 0x00, and opaque pixels are given alpha values of 0xFF. The RGB values of transparent pixels default to 0x000000 but can be overridden by the constructor's `transparentRGB` option.
*
* @param {Buffer} buffer Bytes of an encoded GIF to decode.
* @return {Promise} A Promise that resolves to an instance of the Gif class, representing the encoded GIF.
* @throws {GifError} Error upon encountered an encoding-related problem with a GIF, so that the caller can distinguish between software errors and problems with GIFs.
*/
decodeGif(buffer) {
try {
let reader;
try {
reader = new Omggif.GifReader(buffer);
}
catch (err) {
throw new GifError(err);
}
const frameCount = reader.numFrames();
const frames = [];
const spec = {
width: reader.width,
height: reader.height,
loops: reader.loopCount()
};
spec.usesTransparency = false;
for (let i = 0; i < frameCount; ++i) {
const frameInfo =
this._decodeFrame(reader, i, spec.usesTransparency);
frames.push(frameInfo.frame);
if (frameInfo.usesTransparency) {
spec.usesTransparency = true;
}
}
return Promise.resolve(new Gif(buffer, frames, spec));
}
catch (err) {
return Promise.reject(err);
}
}
/**
* Encodes a GIF from provided frames. Each pixel having an alpha value of 0x00 renders as transparent within the encoding, while all pixels of non-zero alpha value render as opaque.
*
* @param {GifFrame[]} frames Array of frames to encode
* @param {object} spec An optional object that may provide values for `loops` and `colorScope`, as defined for the Gif class. However, `colorSpace` may also take the value Gif.GlobalColorsPreferred (== 0) to indicate that the encoder should attempt to create only a global color table. `loop` defaults to 0, looping indefinitely. Set `loop` to null to disable looping, playing only once. `colorScope` defaults to Gif.GlobalColorsPreferred.
* @return {Promise} A Promise that resolves to an instance of the Gif class, representing the encoded GIF.
* @throws {GifError} Error upon encountered an encoding-related problem with a GIF, so that the caller can distinguish between software errors and problems with GIFs.
*/
encodeGif(frames, spec = {}) {
try {
if (frames === null || frames.length === 0) {
throw new GifError("there are no frames");
}
const dims = GifUtil().getMaxDimensions(frames);
spec = Object.assign({}, spec); // don't munge caller's spec
spec.width = dims.maxWidth;
spec.height = dims.maxHeight;
if (spec.loops === undefined) {
spec.loops = 0;
}
spec.colorScope = spec.colorScope || Gif.GlobalColorsPreferred;
return Promise.resolve(this._encodeGif(frames, spec));
}
catch (err) {
return Promise.reject(err);
}
}
_decodeFrame(reader, frameIndex, alreadyUsedTransparency) {
let info, buffer;
try {
info = reader.frameInfo(frameIndex);
buffer = new Buffer(reader.width * reader.height * 4);
reader.decodeAndBlitFrameRGBA(frameIndex, buffer);
if (info.width !== reader.width || info.height !== reader.height) {
if (info.y) {
// skip unused rows
buffer = buffer.slice(info.y * reader.width * 4);
}
if (reader.width > info.width) {
// skip scanstride
for (let ii = 0; ii < info.height; ++ii) {
buffer.copy(buffer, ii * info.width * 4,
(info.x + ii * reader.width) * 4,
(info.x + ii * reader.width) * 4 + info.width * 4);
}
}
// trim buffer to size
buffer = buffer.slice(0, info.width * info.height * 4);
}
}
catch (err) {
throw new GifError(err);
}
let usesTransparency = false;
if (this._transparentRGBA === null) {
if (!alreadyUsedTransparency) {
for (let i = 3; i < buffer.length; i += 4) {
if (buffer[i] === 0) {
usesTransparency = true;
i = buffer.length;
}
}
}
}
else {
for (let i = 3; i < buffer.length; i += 4) {
if (buffer[i] === 0) {
buffer.writeUInt32BE(this._transparentRGBA, i - 3);
usesTransparency = true; // GIF might encode unused index
}
}
}
const frame = new GifFrame(info.width, info.height, buffer, {
xOffset: info.x,
yOffset: info.y,
disposalMethod: info.disposal,
interlaced: info.interlaced,
delayCentisecs: info.delay
});
return { frame, usesTransparency };
}
_encodeGif(frames, spec) {
let colorInfo;
if (spec.colorScope === Gif.LocalColorsOnly) {
colorInfo = GifUtil().getColorInfo(frames, 0);
}
else {
colorInfo = GifUtil().getColorInfo(frames, 256);
if (!colorInfo.colors) { // if global palette impossible
if (spec.colorScope === Gif.GlobalColorsOnly) {
throw new GifError(
"Too many color indexes for global color table");
}
spec.colorScope = Gif.LocalColorsOnly
}
}
spec.usesTransparency = colorInfo.usesTransparency;
const localPalettes = colorInfo.palettes;
if (spec.colorScope === Gif.LocalColorsOnly) {
const localSizeEst = 2000; //this._getSizeEstimateLocal(localPalettes, frames);
return _encodeLocal(frames, spec, localSizeEst, localPalettes);
}
const globalSizeEst = 2000; //this._getSizeEstimateGlobal(colorInfo, frames);
return _encodeGlobal(frames, spec, globalSizeEst, colorInfo);
}
_getSizeEstimateGlobal(globalPalette, frames) {
if (this._testInitialBufferSize > 0) {
return this._testInitialBufferSize;
}
let sizeEst = PER_GIF_OVERHEAD + 3*256 /* max palette size*/;
const pixelBitWidth = _getPixelBitWidth(globalPalette);
frames.forEach(frame => {
sizeEst += _getFrameSizeEst(frame, pixelBitWidth);
});
return sizeEst; // should be the upper limit
}
_getSizeEstimateLocal(palettes, frames) {
if (this._testInitialBufferSize > 0) {
return this._testInitialBufferSize;
}
let sizeEst = PER_GIF_OVERHEAD;
for (let i = 0; i < frames.length; ++i ) {
const palette = palettes[i];
const pixelBitWidth = _getPixelBitWidth(palette);
sizeEst += _getFrameSizeEst(frames[i], pixelBitWidth);
}
return sizeEst; // should be the upper limit
}
}
exports.GifCodec = GifCodec;
function _colorLookupLinear(colors, color) {
const index = colors.indexOf(color);
return (index === -1 ? null : index);
}
function _colorLookupBinary(colors, color) {
// adapted from https://stackoverflow.com/a/10264318/650894
var lo = 0, hi = colors.length - 1, mid;
while (lo <= hi) {
mid = Math.floor((lo + hi)/2);
if (colors[mid] > color)
hi = mid - 1;
else if (colors[mid] < color)
lo = mid + 1;
else
return mid;
}
return null;
}
function _encodeGlobal(frames, spec, bufferSizeEst, globalPalette) {
// would be inefficient for frames to lookup colors in extended palette
const extendedGlobalPalette = {
colors: globalPalette.colors.slice(),
usesTransparency: globalPalette.usesTransparency
};
_extendPaletteToPowerOf2(extendedGlobalPalette);
const options = {
palette: extendedGlobalPalette.colors,
loop: spec.loops
};
let buffer = new Buffer(bufferSizeEst);
let gifWriter;
try {
gifWriter = new Omggif.GifWriter(buffer, spec.width, spec.height,
options);
}
catch (err) {
throw new GifError(err);
}
for (let i = 0; i < frames.length; ++i) {
buffer = _writeFrame(gifWriter, i, frames[i], globalPalette, false);
}
return new Gif(buffer.slice(0, gifWriter.end()), frames, spec);
}
function _encodeLocal(frames, spec, bufferSizeEst, localPalettes) {
const options = {
loop: spec.loops
};
let buffer = new Buffer(bufferSizeEst);
let gifWriter;
try {
gifWriter = new Omggif.GifWriter(buffer, spec.width, spec.height,
options);
}
catch (err) {
throw new GifError(err);
}
for (let i = 0; i < frames.length; ++i) {
buffer = _writeFrame(gifWriter, i, frames[i], localPalettes[i], true);
}
return new Gif(buffer.slice(0, gifWriter.end()), frames, spec);
}
function _extendPaletteToPowerOf2(palette) {
const colors = palette.colors;
if (palette.usesTransparency) {
colors.push(0);
}
const colorCount = colors.length;
let powerOf2 = 2;
while (colorCount > powerOf2) {
powerOf2 <<= 1;
}
colors.length = powerOf2;
colors.fill(0, colorCount);
}
function _getFrameSizeEst(frame, pixelBitWidth) {
let byteLength = frame.bitmap.width * frame.bitmap.height;
byteLength = Math.ceil(byteLength * pixelBitWidth / 8);
byteLength += Math.ceil(byteLength / 255); // add block size bytes
// assume maximum palete size because it might get extended for power of 2
return (PER_FRAME_OVERHEAD + byteLength + 3 * 256 /* largest palette */);
}
function _getIndexedImage(frameIndex, frame, palette) {
const colors = palette.colors;
const colorToIndexFunc = (colors.length <= 8 ? // guess at the break-even
_colorLookupLinear : _colorLookupBinary);
const colorBuffer = frame.bitmap.data;
const indexBuffer = new Buffer(colorBuffer.length/4);
let transparentIndex = colors.length;
let i = 0, j = 0;
while (i < colorBuffer.length) {
if (colorBuffer[i + 3] !== 0) {
const color = (colorBuffer.readUInt32BE(i, true) >> 8) & 0xFFFFFF;
// caller guarantees that the color will be in the palette
indexBuffer[j] = colorToIndexFunc(colors, color);
}
else {
indexBuffer[j] = transparentIndex;
}
i += 4; // skip alpha
++j;
}
if (palette.usesTransparency) {
if (transparentIndex === 256) {
throw new GifError(`Frame ${frameIndex} already has 256 colors` +
`and so can't use transparency`);
}
}
else {
transparentIndex = null;
}
return { buffer: indexBuffer, transparentIndex };
}
function _getPixelBitWidth(palette) {
let indexCount = palette.indexCount;
let pixelBitWidth = 0;
--indexCount; // start at maximum index
while (indexCount) {
++pixelBitWidth;
indexCount >>= 1;
}
return (pixelBitWidth > 0 ? pixelBitWidth : 1);
}
function _writeFrame(gifWriter, frameIndex, frame, palette, isLocalPalette) {
if (frame.interlaced) {
throw new GifError("writing interlaced GIFs is not supported");
}
const frameInfo = _getIndexedImage(frameIndex, frame, palette);
const options = {
delay: frame.delayCentisecs,
disposal: frame.disposalMethod,
transparent: frameInfo.transparentIndex
};
if (isLocalPalette) {
_extendPaletteToPowerOf2(palette); // ok 'cause palette never used again
options.palette = palette.colors;
}
try {
let buffer = gifWriter.getOutputBuffer();
let startOfFrame = gifWriter.getOutputBufferPosition();
let endOfFrame;
let tryAgain = true;
while (tryAgain) {
endOfFrame = gifWriter.addFrame(frame.xOffset, frame.yOffset,
frame.bitmap.width, frame.bitmap.height, frameInfo.buffer, options);
tryAgain = false;
if (endOfFrame >= buffer.length - 1) {
const biggerBuffer = new Buffer(buffer.length * 1.5);
buffer.copy(biggerBuffer);
gifWriter.setOutputBuffer(biggerBuffer);
gifWriter.setOutputBufferPosition(startOfFrame);
buffer = biggerBuffer;
tryAgain = true;
}
}
return buffer;
}
catch (err) {
throw new GifError(err);
}
}
+114
View File
@@ -0,0 +1,114 @@
'use strict';
const BitmapImage = require('./bitmapimage');
const { GifError } = require('./gif');
/** @class GifFrame */
class GifFrame extends BitmapImage {
// xOffset - x offset of bitmap on GIF (defaults to 0)
// yOffset - y offset of bitmap on GIF (defaults to 0)
// disposalMethod - pixel disposal method when handling partial images
// delayCentisecs - duration of frame in hundredths of a second
// interlaced - whether the image is interlaced (defaults to false)
/**
* GifFrame is a class representing an image frame of a GIF. GIFs contain one or more instances of GifFrame.
*
* Property | Description
* --- | ---
* xOffset | x-coord of position within GIF at which to render the image (defaults to 0)
* yOffset | y-coord of position within GIF at which to render the image (defaults to 0)
* disposalMethod | GIF disposal method; only relevant when the frames aren't all the same size (defaults to 2, disposing to background color)
* delayCentisecs | duration of the frame in hundreths of a second
* interlaced | boolean indicating whether the frame renders interlaced
*
* Its constructor supports the following signatures:
*
* * new GifFrame(bitmap: {width: number, height: number, data: Buffer}, options?)
* * new GifFrame(bitmapImage: BitmapImage, options?)
* * new GifFrame(width: number, height: number, buffer: Buffer, options?)
* * new GifFrame(width: number, height: number, backgroundRGBA?: number, options?)
* * new GifFrame(frame: GifFrame)
*
* See the base class BitmapImage for a discussion of all parameters but `options` and `frame`. `options` is an optional argument providing initial values for the above-listed GifFrame properties. Each property within option is itself optional.
*
* Provide a `frame` to the constructor to create a clone of the provided frame. The new frame includes a copy of the provided frame's pixel data so that each can subsequently be modified without affecting each other.
*/
constructor(...args) {
super(...args);
if (args[0] instanceof GifFrame) {
// copy a provided GifFrame
const source = args[0];
this.xOffset = source.xOffset;
this.yOffset = source.yOffset;
this.disposalMethod = source.disposalMethod;
this.delayCentisecs = source.delayCentisecs;
this.interlaced = source.interlaced;
}
else {
const lastArg = args[args.length - 1];
let options = {};
if (typeof lastArg === 'object' && !(lastArg instanceof BitmapImage)) {
options = lastArg;
}
this.xOffset = options.xOffset || 0;
this.yOffset = options.yOffset || 0;
this.disposalMethod = (options.disposalMethod !== undefined ?
options.disposalMethod : GifFrame.DisposeToBackgroundColor);
this.delayCentisecs = options.delayCentisecs || 8;
this.interlaced = options.interlaced || false;
}
}
/**
* Get a summary of the colors found within the frame. The return value is an object of the following form:
*
* Property | Description
* --- | ---
* colors | An array of all the opaque colors found within the frame. Each color is given as an RGB number of the form 0xRRGGBB. The array is sorted by increasing number. Will be an empty array when the image is completely transparent.
* usesTransparency | boolean indicating whether there are any transparent pixels within the frame. A pixel is considered transparent if its alpha value is 0x00.
* indexCount | The number of color indexes required to represent this palette of colors. It is equal to the number of opaque colors plus one if the image includes transparency.
*
* @return {object} An object representing a color palette as described above.
*/
getPalette() {
// returns with colors sorted low to high
const colorSet = new Set();
const buf = this.bitmap.data;
let i = 0;
let usesTransparency = false;
while (i < buf.length) {
if (buf[i + 3] === 0) {
usesTransparency = true;
}
else {
// can eliminate the bitshift by starting one byte prior
const color = (buf.readUInt32BE(i, true) >> 8) & 0xFFFFFF;
colorSet.add(color);
}
i += 4; // skip alpha
}
const colors = new Array(colorSet.size);
const iter = colorSet.values();
for (i = 0; i < colors.length; ++i) {
colors[i] = iter.next().value;
}
colors.sort((a, b) => (a - b));
let indexCount = colors.length;
if (usesTransparency) {
++indexCount;
}
return { colors, usesTransparency, indexCount };
}
}
GifFrame.DisposeToAnything = 0;
GifFrame.DisposeNothing = 1;
GifFrame.DisposeToBackgroundColor = 2;
GifFrame.DisposeToPrevious = 3;
exports.GifFrame = GifFrame;
+373
View File
@@ -0,0 +1,373 @@
'use strict';
/** @namespace GifUtil */
const fs = require('fs');
const ImageQ = require('image-q');
const BitmapImage = require('./bitmapimage');
const { GifFrame } = require('./gifframe');
const { GifError } = require('./gif');
const { GifCodec } = require('./gifcodec');
const INVALID_SUFFIXES = ['.jpg', '.jpeg', '.png', '.bmp'];
const defaultCodec = new GifCodec();
/**
* cloneFrames() clones provided frames. It's a utility method for cloning an entire array of frames at once.
*
* @function cloneFrames
* @memberof GifUtil
* @param {GifFrame[]} frames An array of GifFrame instances to clone
* @return {GifFrame[]} An array of GifFrame clones of the provided frames.
*/
exports.cloneFrames = function (frames) {
let clones = [];
frames.forEach(frame => {
clones.push(new GifFrame(frame));
});
return clones;
}
/**
* getColorInfo() gets information about the colors used in the provided frames. The method is able to return an array of all colors found across all frames.
*
* `maxGlobalIndex` controls whether the computation short-circuits to avoid doing work that the caller doesn't need. The method only returns `colors` and `indexCount` for the colors across all frames when the number of indexes required to store the colors and transparency in a GIF (which is the value of `indexCount`) is less than or equal to `maxGlobalIndex`. Such short-circuiting is useful when the caller just needs to determine whether any frame includes transparency.
*
* @function getColorInfo
* @memberof GifUtil
* @param {GifFrame[]} frames Frames to examine for color and transparency.
* @param {number} maxGlobalIndex Maximum number of color indexes (including one for transparency) allowed among the returned compilation of colors. `colors` and `indexCount` are not returned if the number of color indexes required to accommodate all frames exceeds this number. Returns `colors` and `indexCount` by default.
* @returns {object} Object containing at least `palettes` and `usesTransparency`. `palettes` is an array of all the palettes returned by GifFrame#getPalette(). `usesTransparency` indicates whether at least one frame uses transparency. If `maxGlobalIndex` is not exceeded, the object also contains `colors`, an array of all colors (RGB) found across all palettes, sorted by increasing value, and `indexCount` indicating the number of indexes required to store the colors and the transparency in a GIF.
* @throws {GifError} When any frame requires more than 256 color indexes.
*/
exports.getColorInfo = function (frames, maxGlobalIndex) {
let usesTransparency = false;
const palettes = [];
for (let i = 0; i < frames.length; ++i) {
let palette = frames[i].getPalette();
if (palette.usesTransparency) {
usesTransparency = true;
}
if (palette.indexCount > 256) {
throw new GifError(`Frame ${i} uses more than 256 color indexes`);
}
palettes.push(palette);
}
if (maxGlobalIndex === 0) {
return { usesTransparency, palettes };
}
const globalColorSet = new Set();
palettes.forEach(palette => {
palette.colors.forEach(color => {
globalColorSet.add(color);
});
});
let indexCount = globalColorSet.size;
if (usesTransparency) {
// odd that GIF requires a color table entry at transparent index
++indexCount;
}
if (maxGlobalIndex && indexCount > maxGlobalIndex) {
return { usesTransparency, palettes };
}
const colors = new Array(globalColorSet.size);
const iter = globalColorSet.values();
for (let i = 0; i < colors.length; ++i) {
colors[i] = iter.next().value;
}
colors.sort((a, b) => (a - b));
return { colors, indexCount, usesTransparency, palettes };
};
/**
* copyAsJimp() returns a Jimp that contains a copy of the provided bitmap image (which may be either a BitmapImage or a GifFrame). Modifying the Jimp does not affect the provided bitmap image. This method serves as a macro for simplifying working with Jimp.
*
* @function copyAsJimp
* @memberof GifUtil
* @param {object} Reference to the Jimp package, keeping this library from being dependent on Jimp.
* @param {bitmapImageToCopy} Instance of BitmapImage (may be a GifUtil) with which to source the Jimp.
* @return {object} An new instance of Jimp containing a copy of the image in bitmapImageToCopy.
*/
exports.copyAsJimp = function (jimp, bitmapImageToCopy) {
return exports.shareAsJimp(jimp, new BitmapImage(bitmapImageToCopy));
};
/**
* getMaxDimensions() returns the pixel width and height required to accommodate all of the provided frames, according to the offsets and dimensions of each frame.
*
* @function getMaxDimensions
* @memberof GifUtil
* @param {GifFrame[]} frames Frames to measure for their aggregate maximum dimensions.
* @return {object} An object of the form {maxWidth, maxHeight} indicating the maximum width and height required to accommodate all frames.
*/
exports.getMaxDimensions = function (frames) {
let maxWidth = 0, maxHeight = 0;
frames.forEach(frame => {
const width = frame.xOffset + frame.bitmap.width;
if (width > maxWidth) {
maxWidth = width;
}
const height = frame.yOffset + frame.bitmap.height;
if (height > maxHeight) {
maxHeight = height;
}
});
return { maxWidth, maxHeight };
};
/**
* Quantizes colors so that there are at most a given number of color indexes (including transparency) across all provided images. Uses an algorithm by Anthony Dekker.
*
* The method treats different RGBA combinations as different colors, so if the frame has multiple alpha values or multiple RGB values for an alpha value, the caller may first want to normalize them by converting all transparent pixels to the same RGBA values.
*
* The method may increase the number of colors if there are fewer than the provided maximum.
*
* @function quantizeDekker
* @memberof GifUtil
* @param {BitmapImage|BitmapImage[]} imageOrImages Image or array of images (such as GifFrame instances) to be color-quantized. Quantizing across multiple images ensures color consistency from frame to frame.
* @param {number} maxColorIndexes The maximum number of color indexes that will exist in the palette after completing quantization. Defaults to 256.
* @param {object} dither (optional) An object configuring the dithering to apply. The properties are as followings, imported from the [`image-q` package](https://github.com/ibezkrovnyi/image-quantization) without explanation: { `ditherAlgorithm`: One of 'FloydSteinberg', 'FalseFloydSteinberg', 'Stucki', 'Atkinson', 'Jarvis', 'Burkes', 'Sierra', 'TwoSierra', 'SierraLite'; `minimumColorDistanceToDither`: (optional) A number defaulting to 0; `serpentine`: (optional) A boolean defaulting to true; `calculateErrorLikeGIMP`: (optional) A boolean defaulting to false. }
*/
exports.quantizeDekker = function (imageOrImages, maxColorIndexes, dither) {
maxColorIndexes = maxColorIndexes || 256;
_quantize(imageOrImages, 'NeuQuantFloat', maxColorIndexes, 0, dither);
}
/**
* Quantizes colors so that there are at most a given number of color indexes (including transparency) across all provided images. Uses an algorithm by Leon Sorokin. This quantization method differs from the other two by likely never increasing the number of colors, should there be fewer than the provided maximum.
*
* The method treats different RGBA combinations as different colors, so if the frame has multiple alpha values or multiple RGB values for an alpha value, the caller may first want to normalize them by converting all transparent pixels to the same RGBA values.
*
* @function quantizeSorokin
* @memberof GifUtil
* @param {BitmapImage|BitmapImage[]} imageOrImages Image or array of images (such as GifFrame instances) to be color-quantized. Quantizing across multiple images ensures color consistency from frame to frame.
* @param {number} maxColorIndexes The maximum number of color indexes that will exist in the palette after completing quantization. Defaults to 256.
* @param {string} histogram (optional) Histogram method: 'top-pop' for global top-population, 'min-pop' for minimum-population threshhold within subregions. Defaults to 'min-pop'.
* @param {object} dither (optional) An object configuring the dithering to apply, as explained for `quantizeDekker()`.
*/
exports.quantizeSorokin = function (imageOrImages, maxColorIndexes, histogram, dither) {
maxColorIndexes = maxColorIndexes || 256;
histogram = histogram || 'min-pop';
let histogramID;
switch (histogram) {
case 'min-pop':
histogramID = 2;
break;
case 'top-pop':
histogramID = 1;
break
default:
throw new Error(`Invalid quantizeSorokin histogram '${histogram}'`);
}
_quantize(imageOrImages, 'RGBQuant', maxColorIndexes, histogramID, dither);
}
/**
* Quantizes colors so that there are at most a given number of color indexes (including transparency) across all provided images. Uses an algorithm by Xiaolin Wu.
*
* The method treats different RGBA combinations as different colors, so if the frame has multiple alpha values or multiple RGB values for an alpha value, the caller may first want to normalize them by converting all transparent pixels to the same RGBA values.
*
* The method may increase the number of colors if there are fewer than the provided maximum.
*
* @function quantizeWu
* @memberof GifUtil
* @param {BitmapImage|BitmapImage[]} imageOrImages Image or array of images (such as GifFrame instances) to be color-quantized. Quantizing across multiple images ensures color consistency from frame to frame.
* @param {number} maxColorIndexes The maximum number of color indexes that will exist in the palette after completing quantization. Defaults to 256.
* @param {number} significantBits (optional) This is the number of significant high bits in each RGB color channel. Takes integer values from 1 through 8. Higher values correspond to higher quality. Defaults to 5.
* @param {object} dither (optional) An object configuring the dithering to apply, as explained for `quantizeDekker()`.
*/
exports.quantizeWu = function (imageOrImages, maxColorIndexes, significantBits, dither) {
maxColorIndexes = maxColorIndexes || 256;
significantBits = significantBits || 5;
if (significantBits < 1 || significantBits > 8) {
throw new Error("Invalid quantization quality");
}
_quantize(imageOrImages, 'WuQuant', maxColorIndexes, significantBits, dither);
}
/**
* read() decodes an encoded GIF, whether provided as a filename or as a byte buffer.
*
* @function read
* @memberof GifUtil
* @param {string|Buffer} source Source to decode. When a string, it's the GIF filename to load and parse. When a Buffer, it's an encoded GIF to parse.
* @param {object} decoder An optional GIF decoder object implementing the `decode` method of class GifCodec. When provided, the method decodes the GIF using this decoder. When not provided, the method uses GifCodec.
* @return {Promise} A Promise that resolves to an instance of the Gif class, representing the decoded GIF.
*/
exports.read = function (source, decoder) {
decoder = decoder || defaultCodec;
if (Buffer.isBuffer(source)) {
return decoder.decodeGif(source);
}
return _readBinary(source)
.then(buffer => {
return decoder.decodeGif(buffer);
});
};
/**
* shareAsJimp() returns a Jimp that shares a bitmap with the provided bitmap image (which may be either a BitmapImage or a GifFrame). Modifying the image in either the Jimp or the BitmapImage affects the other objects. This method serves as a macro for simplifying working with Jimp.
*
* @function shareAsJimp
* @memberof GifUtil
* @param {object} Reference to the Jimp package, keeping this library from being dependent on Jimp.
* @param {bitmapImageToShare} Instance of BitmapImage (may be a GifUtil) with which to source the Jimp.
* @return {object} An new instance of Jimp that shares the image in bitmapImageToShare.
*/
exports.shareAsJimp = function (jimp, bitmapImageToShare) {
const jimpImage = new jimp(bitmapImageToShare.bitmap.width,
bitmapImageToShare.bitmap.height, 0);
jimpImage.bitmap.data = bitmapImageToShare.bitmap.data;
return jimpImage;
};
/**
* write() encodes a GIF and saves it as a file.
*
* @function write
* @memberof GifUtil
* @param {string} path Filename to write GIF out as. Will overwrite an existing file.
* @param {GifFrame[]} frames Array of frames to be written into GIF.
* @param {object} spec An optional object that may provide values for `loops` and `colorScope`, as defined for the Gif class. However, `colorSpace` may also take the value Gif.GlobalColorsPreferred (== 0) to indicate that the encoder should attempt to create only a global color table. `loop` defaults to 0, looping indefinitely, and `colorScope` defaults to Gif.GlobalColorsPreferred.
* @param {object} encoder An optional GIF encoder object implementing the `encode` method of class GifCodec. When provided, the method encodes the GIF using this encoder. When not provided, the method uses GifCodec.
* @return {Promise} A Promise that resolves to an instance of the Gif class, representing the encoded GIF.
*/
exports.write = function (path, frames, spec, encoder) {
encoder = encoder || defaultCodec;
const matches = path.match(/\.[a-zA-Z]+$/); // prevent accidents
if (matches !== null &&
INVALID_SUFFIXES.includes(matches[0].toLowerCase()))
{
throw new Error(`GIF '${path}' has an unexpected suffix`);
}
return encoder.encodeGif(frames, spec)
.then(gif => {
return _writeBinary(path, gif.buffer)
.then(() => {
return gif;
});
});
};
function _quantize(imageOrImages, method, maxColorIndexes, modifier, dither) {
const images = Array.isArray(imageOrImages) ? imageOrImages : [imageOrImages];
const ditherAlgs = [
'FloydSteinberg',
'FalseFloydSteinberg',
'Stucki',
'Atkinson',
'Jarvis',
'Burkes',
'Sierra',
'TwoSierra',
'SierraLite'
];
if (dither) {
if (ditherAlgs.indexOf(dither.ditherAlgorithm) < 0) {
throw new Error(`Invalid ditherAlgorithm '${dither.ditherAlgorithm}'`);
}
if (dither.serpentine === undefined) {
dither.serpentine = true;
}
if (dither.minimumColorDistanceToDither === undefined) {
dither.minimumColorDistanceToDither = 0;
}
if (dither.calculateErrorLikeGIMP === undefined) {
dither.calculateErrorLikeGIMP = false;
}
}
const distCalculator = new ImageQ.distance.Euclidean();
const quantizer = new ImageQ.palette[method](distCalculator, maxColorIndexes, modifier);
let imageMaker;
if (dither) {
imageMaker = new ImageQ.image.ErrorDiffusionArray(
distCalculator,
ImageQ.image.ErrorDiffusionArrayKernel[dither.ditherAlgorithm],
dither.serpentine,
dither.minimumColorDistanceToDither,
dither.calculateErrorLikeGIMP
);
}
else {
imageMaker = new ImageQ.image.NearestColor(distCalculator);
}
const inputContainers = [];
images.forEach(image => {
const imageBuf = image.bitmap.data;
const inputBuf = new ArrayBuffer(imageBuf.length);
const inputArray = new Uint32Array(inputBuf);
for (let bi = 0, ai = 0; bi < imageBuf.length; bi += 4, ++ai) {
inputArray[ai] = imageBuf.readUInt32LE(bi, true);
}
const inputContainer = ImageQ.utils.PointContainer.fromUint32Array(
inputArray, image.bitmap.width, image.bitmap.height);
quantizer.sample(inputContainer);
inputContainers.push(inputContainer);
});
const limitedPalette = quantizer.quantizeSync();
for (let i = 0; i < images.length; ++i) {
const imageBuf = images[i].bitmap.data;
const outputContainer = imageMaker.quantizeSync(inputContainers[i], limitedPalette);
const outputArray = outputContainer.toUint32Array();
for (let bi = 0, ai = 0; bi < imageBuf.length; bi += 4, ++ai) {
imageBuf.writeUInt32LE(outputArray[ai], bi);
}
}
}
function _readBinary(path) {
// TBD: add support for URLs
return new Promise((resolve, reject) => {
fs.readFile(path, (err, buffer) => {
if (err) {
return reject(err);
}
return resolve(buffer);
});
});
}
function _writeBinary(path, buffer) {
// TBD: add support for URLs
return new Promise((resolve, reject) => {
fs.writeFile(path, buffer, err => {
if (err) {
return reject(err);
}
return resolve();
});
});
}
+16
View File
@@ -0,0 +1,16 @@
'use strict';
const BitmapImage = require('./bitmapimage');
const { Gif, GifError } = require('./gif');
const { GifCodec } = require('./gifcodec');
const { GifFrame } = require('./gifframe');
const GifUtil = require('./gifutil');
module.exports = {
BitmapImage,
Gif,
GifCodec,
GifFrame,
GifUtil,
GifError
};
+56
View File
@@ -0,0 +1,56 @@
export type ByteArray = Uint8Array | Buffer;
export interface OmggifModule {
GifWriter: GifWriter;
GifReader: GifReader;
}
export interface GlobalOptions {
loop?: number; // 0 = unending loop; n > 0 = (n+1) iterations; null = once
palette?: number[]; // global palette RGB by color index
background?: number; // background index; most browsers may ignore this
}
export interface FrameOptions {
palette?: number[]; // RGB by color index
delay?: number; // duation in 100s of a second
disposal?: number; // what to do with background color (0-3)
transparent?: number; // transparency index
}
export interface GifWriter {
new (buffer: ByteArray, width: number, height: number, gopts?: GlobalOptions): GifWriter;
addFrame(x: number, y: number, width: number, height: number, indexedPixels: number[], opts?: FrameOptions): number; // returns size of buffer at end of frame
getOutputBuffer(): ByteArray;
setOutputBuffer(buffer: ByteArray): void;
getOutputBufferPosition(): number;
setOutputBufferPosition(position: number): void;
end(): number; // ends GIF and returns size of buffer
}
export interface GifReader {
width: number;
height: number;
new (buffer: ByteArray): GifReader;
numFrames(): number;
loopCount(): number;
frameInfo(frameNumber: number): FrameInfo;
decodeAndBlitFrameBGRA(frameNumber: number, pixels: number[]): void;
decodeAndBlitFrameRGBA(frameNumber: number, pixels: number[]): void;
}
export interface FrameInfo {
x: number;
y: number;
width: number;
height: number;
has_local_palette: boolean;
palette_offset: number;
data_offset: number;
data_length: number;
transparent_index: number;
interlaced: boolean;
delay: number; // 100ths of a second
disposal: number;
}
+267
View File
@@ -0,0 +1,267 @@
# gifwrap
A Jimp-compatible library for working with GIFs
## Overview
`gifwrap` is a minimalist library for working with GIFs in Javascript, supporting both single- and multi-frame GIFs. It reads GIFs into an internal representation that's easy to work with and allows for making GIFs from scratch. The frame class is structured to make it easy to move images between [`Jimp`](https://github.com/oliver-moran/jimp) and `gifwrap` for more sophisticated image manipulation in `Jimp`, but the module has no dependency on `Jimp`.
The library uses Dean McNamee's [`omggif`](https://github.com/deanm/omggif) GIF encoder/decoder by default, but it employs an abstraction that allows using other encoders and decoders as well, once suitably wrapped.
At present, the module only works in Node.js. Includes Typescript typings.
## Installation
```
npm install gifwrap --save
```
or
```
yarn add gifwrap
```
## Usage
You can work with either GIF files or GIF encodings, and you can create GIFs from scratch.
The GifFrame class represents a single image frame, and the library largely represents a GIF as an array of GifFrame instances. For example, here is how you create a GIF from scratch:
```js
const { GifFrame, GifUtil, GifCodec } = require('gifwrap');
const width = 200, height = 100;
const frames = [];
let frame = new GifFrame(width, height, { delayCentisecs: 10 });
// modify the pixels at frame.bitmap.data
frames.push(frame);
frame = new GifFrame(width, height, { delayCentisecs: 15 });
// modify the pixels at frame.bitmap.data
frames.push(frame);
// add more frames as desired...
// to write to a file...
GifUtil.write("my-creation.gif", frames, { loops: 3 }).then(gif => {
console.log("written");
});
// to get the byte encoding without writing to a file...
const codec = new GifCodec();
codec.encodeGif(frames, { loops: 3 }).then(gif => {
// byte encoding is now in gif.buffer
});
```
Images are represented within a GifFrame exactly as they are in a `Jimp` image. In particular, each GifFrame instance has a `bitmap` property having the following structure:
* `frame.bitmap.width` - Width of image in pixels
* `frame.bitmap.height` - Height of image in pixels
* `frame.bitmap.data` - A Node.js Buffer that can be accessed like an array of bytes. Every 4 adjacent bytes represents the RGBA values of a single pixel. These 4 bytes correspond to red, green, blue, and alpha, in that order. Each pixel begins at an index that is a multiple of 4.
GIFs do not support partial transparency, so within `frame.bitmap.data`, pixels having alpha value 0x00 are treated as transparent and pixels of non-zero alpha value are treated as opaque. The encoder ignores the RGB values of transparent pixels.
`gifwrap` also provides utilities for reading GIF files and for parsing raw encodings:
```js
const { GifUtil } = require('gifwrap');
GifUtil.read("fancy.gif").then(inputGif => {
inputGif.frames.forEach(frame => {
const buf = frame.bitmap.data;
frame.scanAllCoords((x, y, bi) => {
// Halve all grays on right half of image.
if (x > inputGif.width / 2) {
const r = buf[bi];
const g = buf[bi + 1];
const b = buf[bi + 2];
const a = buf[bi + 3];
if (r === g && r === b && a === 0xFF) {
buf[bi] /= 2;
buf[bi + 1] /= 2;
buf[bi + 2] /= 2;
}
}
});
});
// Pass inputGif to write() to preserve the original GIF's specs.
return GifUtil.write("modified.gif", inputGif.frames, inputGif).then(outputGif => {
console.log("modified");
});
});
```
```js
const { GifUtil, GifCodec } = require('gifwrap');
const codec = new GifCodec();
const byteEncodingBuffer = getByteEncodingForSomeGif();
codec.decodeGif(byteEncodingBuffer).then(sourceGif => {
const edgeLength = Math.min(sourceGif.width, sourceGif.height);
sourceGif.frames.forEach(frame => {
// Make each frame a centered square of size edgeLength x edgeLength.
// Note that frames may vary in size and that reframe() works even if
// the frame's image is smaller than the square. Should this happen,
// the space surrounding the original image will be transparent.
const xOffset = (frame.bitmap.width - edgeLength)/2;
const yOffset = (frame.bitmap.height - edgeLength)/2;
frame.reframe(xOffset, yOffset, edgeLength, edgeLength);
});
// The encoder determines GIF size from the frames, not the provided spec (sourceGif).
return GifUtil.write("modified.gif", sourceGif.frames, sourceGif).then(outputGif => {
console.log("modified");
});
});
```
Notice that both encoding and decoding yields a GIF object. This is an instance of class Gif, and it provides information about the GIF, such as its size and how many times it loops. Notice also that you never call the Gif constructor to create a GIF. Instead, GIFs are created by providing a GifFrame array and a specification of GIF options. That specification is a subset of the properties of a Gif, so you can pass a previously-loaded Gif as a specification when writing or encoding. The encoder only uses the properties that can't be inferred from the frames -- namely, how many times the GIF loops and how to attempt to package the color tables within the encoding.
## Leveraging Jimp
This module was originally written as a wrapper around Jimp images -- hence its name -- and then with frames as subclasses of Jimp images. Neither approach worked out well. The final approach requires just a tad of legwork to use `gifwrap` images within Jimp.
Both Jimp images and GifFrame instances share the `bitmap` property. By transferring this property back and forth between Jimp images and GifFrame instances, an image can be moved back and forth between the two libraries.
You can construct a GifFrame from a Jimp image as follows:
```js
const { BitmapImage, GifFrame } = require('gifwrap');
const Jimp = require('jimp');
const j = new Jimp(200, 100, 0xFFFFFFFF);
// create a frame clone of a Jip bitmap
const fCopied = new GifFrame(new BitmapImage(j.bitmap));
// create a frame that shares a bitmap with Jimp (one way)
const fShared1 = new GifFrame(j.bitmap);
// create a frame that shares a bitmap with Jimp (another way)
const fShared2 = new GifFrame(1, 1, 0); // any GifFrame
fShared2.bitmap = j.bitmap;
```
And you can construct a Jimp instance from a GifFrame image as follows:
```js
const { BitmapImage, GifFrame } = require('gifwrap');
const Jimp = require('jimp');
const frame = new GifFrame(200, 100, 0xFFFFFFFF);
// create a Jimp containing a clone of the frame bitmap
jimpCopied = GifUtil.copyAsJimp(Jimp, frame);
// create a Jimp that shares a bitmap with the frame
jimpShared = GifUtil.shareAsJimp(Jimp, frame);
```
## Encoders and Decoders
`gifwrap` provides a default GIF encoder/decoder, but it is architected to be able to work with other encoders and decoders. The encoder and decoder may even be separate implementations. Encoders and decoders have varying capabilities, performance measures, and levels of reliability.
GifCodec is the default implementation, and it's both an encoder and a decoder. It's an adapter that wraps the [`omggif`](https://github.com/deanm/omggif) module. `omggif` appears to support a broad variety of GIFs, although it cannot produce an interlaced encoding (which there is little need for anyway). Although `omggif` doesn't include a test suite at present, `gifwrap`'s test suite happens to test it reasonably well by virtue of using `omggif` underneath.
An encoder need only implement GifCodec's [`encodeGif()`](#GifCodec+encodeGif) method, and a decoder need only implement its [`decodeGif()`](#GifCodec+decodeGif) method. See the descriptions of those methods for the requirement details. Although GifCodec is stateless, so that instances an be reused across multiple encodings and decodings, third party encoders and decoders need not be. However, applications that use the library with stateful encoders will need to be aware of the need to create new instances.
To use a third-party encoder or decoder with the GifUtil `write()` and `read()` functions, just pass an instance of the encoder or decoder as the last parameter to `write()` or `read()`, respectively. For example:
```js
const { GifUtil } = require('gifwrap');
const SnazzyDecoder = require('gifwrap-snazzy-decoder');
const AwesomeEncoder = require('gifwrap-awesome-encoder');
GifUtil.read("fancy.gif", new SnazzyDecoder()).then(gif =>
/*...*/
return GifUtil.write("modified.gif", gif.frames, gif, new AwesomeEncoder()).then(newGif => {
console.log("modified");
});
});
```
## API Reference
The [Typescript typings](https://github.com/jtlapp/gifwrap/blob/master/index.d.ts) provide an exact specification of the API and also serve as a cheat sheet. The classes and namespaces follow:
* **gifwrap**
* [.**Gif**](#new_Gif_new)
* [.**BitmapImage**](#BitmapImage)
* [.**GifFrame**](#GifFrame)
* [.**GifUtil**](#GifUtil)
* [.**GifCodec**](#GifCodec)
* [.**GifError**](#new_GifError_new)
{{#class name="Gif"}}
{{>body~}}
{{>member-index~}}
{{/class}}
{{#class name="BitmapImage"}}
{{>body~}}
{{>member-index~}}
{{/class}}
{{#class name="GifFrame"}}
{{>body~}}
{{>member-index~}}
{{/class}}
{{#namespace name="GifUtil"}}
{{>body~}}
{{>member-index~}}
{{/namespace}}
{{#class name="GifCodec"}}
{{>body~}}
{{>member-index~}}
{{/class}}
{{#class name="Gif"}}
{{>members~}}
{{/class}}
{{#class name="BitmapImage"}}
{{>members~}}
{{/class}}
{{#class name="GifFrame"}}
{{>members~}}
{{/class}}
{{#namespace name="GifUtil"}}
{{>members~}}
{{/namespace}}
{{#class name="GifCodec"}}
{{>members~}}
{{/class}}
{{#class name="GifError"}}
{{>body~}}
{{>members~}}
{{/class}}
## LICENSE
MIT License
Copyright © 2017 Joseph T. Lapp
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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 791 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+91
View File
@@ -0,0 +1,91 @@
exports.COLORS = {
'R': 0xFF0000ff, // red
'r': 0xFF00007f, // red half-alpha
'G': 0x00FF00ff, // green
'g': 0x00FF007f, // green half-alpha
'B': 0x0000FFff, // blue
'b': 0x0000FF7f, // blue half-alpha
'*': 0x000000ff, // black
' ': 0x00000000, // fully transparent
'W': 0xFFFFFFff, // white
'_': 0xFFFFFF01, // white transparent
'4': 0x404040ff, // dark grey
'9': 0x909090ff // light grey
};
exports.PREMADE = {
sampleSprite: [
' *',
'* '
],
singleFrameMonoOpaque: [
'RRR',
'RRR',
'RRR'
],
singleFrameMonoOpaqueSpriteAt1x1: [
'RRR',
'RR*',
'R*R'
],
singleFrameNoColorTrans: [
' ',
' ',
' '
],
singleFrameMonoTrans: [
' G ',
'GGG',
' G '
],
singleFrameBWOpaque: [
'*WW*',
'W**W',
'*WW*'
],
singleFrameMultiOpaque: [
'RGBW',
'WRGB'
],
singleFrameMultiTrans: [
'RGB ',
' RGB',
' *'
],
singleFrameMultiPartialTrans: [
'_G ',
'__G ',
'rgb*'
],
twoFrameMultiOpaque: [
['**RR',
'GG**',
'**BB'],
['RR**',
'**GG',
'BB**']
],
threeFrameMonoTrans: [
['* ',
' ',
' '],
[' ',
' * ',
' '],
[' ',
' ',
' *']
]
};
+202
View File
@@ -0,0 +1,202 @@
'use strict';
const assert = require('chai').assert;
const Path = require('path');
const Jimp = require('jimp');
const Bitmaps = require('./bitmaps');
const { BitmapImage } = require('../../src/index');
const { GifFrame } = require('../../src/gifframe');
exports.checkBitmap = function (bitmap, width, height, rgbaOrBuf) {
assert.strictEqual(bitmap.width, width, "width");
assert.strictEqual(bitmap.height, height, "height");
if (Buffer.isBuffer(rgbaOrBuf)) {
assert.strictEqual(bitmap.data.length, rgbaOrBuf.length, "length");
for (let i = 0; i < rgbaOrBuf.length; ++i ) {
assert.strictEqual(rgbaOrBuf[i], bitmap.data[i], "at buffer index "+ i);
}
}
else if (typeof rgbaOrBuf === 'number') {
const rgba = rgbaOrBuf
const buf = bitmap.data;
for (let bi = 0; bi < buf.length; bi += 4) {
const found = buf.readUInt32BE(bi);
if (found !== rgba) {
assert.fail(found, rgba, `buffer fill color ${found} != ${rgba}`);
}
}
}
else {
throw new Error("last checkBitmap() param must be a color or a Buffer");
}
}
exports.checkFrameDefaults = function (actualInfo, options, frameIndex = 0) {
options = Object.assign({}, options); // don't munge caller
options.xOffset = options.xOffset || 0;
options.yOffset = options.yOffset || 0;
options.delayCentisecs = options.delayCentisecs || 8;
options.interlaced = (options.interlaced === true);
options.disposalMethod =
(options.disposalMethod || GifFrame.DisposeToBackgroundColor);
exports.verifyFrameInfo(actualInfo, options, frameIndex);
};
exports.compareToFrameDump = function (actualFrames, expectedDump) {
assert(Array.isArray(actualFrames));
assert.strictEqual(actualFrames.length, expectedDump.length);
for (let i = 0; i < actualFrames.length; ++i) {
const actualFrame = actualFrames[i];
const dump = expectedDump[i];
exports.verifyFrameInfo(actualFrame, {
xOffset: dump[0],
yOffset: dump[1],
bitmap: {
width: dump[2],
height: dump[3]
},
delayCentisecs: dump[4],
interlaced: dump[5],
disposalMethod: dump[6]
}, i);
}
};
exports.dumpFramesAsCode = function (frames) {
let first = true;
process.stdout.write("[\n");
frames.forEach(f => {
if (!first) {
process.stdout.write(",\n");
}
process.stdout.write(` [${f.xOffset}, ${f.yOffset}, `+
`${f.bitmap.width}, ${f.bitmap.height}, `+
`${f.delayCentisecs}, ${f.interlaced}, ${f.disposalMethod}]`);
first = false;
});
process.stdout.write("\n]\n");
};
exports.getBitmap = function (bitmapName, transparentRGB) {
const stringPic = Bitmaps.PREMADE[bitmapName];
if (stringPic === undefined) {
throw new Error(`Bitmap '${bitmapName}' not found`);
}
if (Array.isArray(stringPic[0])) {
throw new Error(`'${bitmapName}' is a bitmap series`);
}
return _stringsToBitmap(stringPic, transparentRGB);
};
exports.getFixturePath = function (filename) {
return Path.join(__dirname, "../fixtures", filename);
};
exports.getGifPath = function (filenameMinusExtension) {
return exports.getFixturePath(filenameMinusExtension + '.gif');
};
exports.getImagePath = function (filename) {
return exports.getFixturePath(filename);
};
exports.getSeries = function (seriesName, transparentRGB) {
const series = Bitmaps.PREMADE[seriesName];
if (series === undefined) {
throw new Error(`Bitmap series '${seriesName}' not found`);
}
if (!Array.isArray(series[0])) {
throw new Error(`'${seriesName}' is not a bitmap series`);
}
return series.map(stringPic =>
(_stringsToBitmap(stringPic, transparentRGB)));
};
exports.loadBitmapImage = function (imagePath) {
return new Promise((resolve, reject) => {
new Jimp(imagePath, (err, jimp) => {
if (err) return reject(err);
resolve(new BitmapImage(jimp.bitmap));
});
});
};
exports.saveBitmapImage = function (bitmapImage, path) {
let jimp = new Jimp(1, 1, 0);
jimp.bitmap = bitmapImage.bitmap;
return new Promise((resolve, reject) => {
jimp.write(path, (err) => {
if (err) return reject(err);
resolve();
});
});
};
exports.verifyFrameInfo = function (actual, expected, frameIndex=0, note='') {
expected = Object.assign({}, expected); // don't munge caller
if (expected.xOffset !== undefined) {
assert.strictEqual(actual.xOffset, expected.xOffset,
`frame ${frameIndex} same x offset${note}`);
}
if (expected.yOffset !== undefined) {
assert.strictEqual(actual.yOffset, expected.yOffset,
`frame ${frameIndex} same y offset${note}`);
}
if (expected.bitmap !== undefined) {
assert.strictEqual(actual.bitmap.width, expected.bitmap.width,
`frame ${frameIndex} same width${note}`);
assert.strictEqual(actual.bitmap.height, expected.bitmap.height,
`frame ${frameIndex} same height${note}`);
}
if (expected.delayCentisecs !== undefined) {
assert.strictEqual(actual.delayCentisecs, expected.delayCentisecs,
`frame ${frameIndex} same delay${note}`);
}
if (expected.disposalMethod !== undefined) {
assert.strictEqual(actual.disposalMethod, expected.disposalMethod,
`frame ${frameIndex} same disposal method${note}`);
}
assert.strictEqual(actual.interlaced, (expected.interlaced === true),
`frame ${frameIndex} same interlacing${note}`);
};
function _stringsToBitmap(stringPic, transparentRGB) {
const trans = transparentRGB; // shortens code, leaves parameter clear
const width = stringPic[0].length;
const height = stringPic.length;
const data = new Buffer(width * height * 4);
let offset = 0;
for (let y = 0; y < height; ++y) {
const row = stringPic[y];
if (row.length !== width) {
throw new Error("Inconsistent pixel string length");
}
for (let x = 0; x < width; ++x) {
if (Bitmaps.COLORS[row[x]] !== undefined) {
const color = Bitmaps.COLORS[row[x]];
const alpha = color & 0xff;
if (alpha !== 0 || trans === undefined) {
data[offset] = (color >> 24) & 0xff;
data[offset + 1] = (color >> 16) & 0xff;
data[offset + 2] = (color >> 8) & 0xff;
data[offset + 3] = color & 0xff;
}
else {
// not concerned about speed
data[offset] = (trans >> 16) & 0xff;
data[offset + 1] = (trans >> 8) & 0xff;
data[offset + 2] = trans & 0xff;
data[offset + 3] = 0;
}
offset += 4;
}
else {
const validChars = Object.keys(Bitmaps.COLORS).join('');
throw new Error(`Invalid pixel char '${row[x]}'. `+
`Valid chars are "${validChars}".`);
}
}
}
return { width, height, data };
}
+157
View File
@@ -0,0 +1,157 @@
'use strict';
const assert = require('chai').assert;
const Jimp = require('jimp');
const Tools = require('./lib/tools');
const { BitmapImage, GifUtil } = require('../src/index');
const SAMPLE_PNG_PATH = Tools.getFixturePath('lenna.png');
const SAMPLE_JPG_PATH = Tools.getFixturePath('pelagrina.jpg');
describe("BitmapImage construction behavior", () => {
it("constructs an empty uncolored image", (done) => {
const i = new BitmapImage(10, 5);
Tools.checkBitmap(i.bitmap, 10, 5, 0);
done();
});
it("constructs an empty colored image", (done) => {
const color = 0x01020304;
const i = new BitmapImage(10, 5, color);
Tools.checkBitmap(i.bitmap, 10, 5, color);
done();
});
it("constructs a buffer-sourced image", (done) => {
const buf = new Buffer(10 * 5);
const i = new BitmapImage(10, 5, buf);
Tools.checkBitmap(i.bitmap, 10, 5, buf);
done();
});
it("sourced bitmaps are shared", (done) => {
const b = { width: 5, height: 6, data: new Buffer(5 * 6) };
const j = new BitmapImage(b);
assert.strictEqual(b, j.bitmap);
done();
});
it("sourced images are copied", (done) => {
const color = 0x01020304;
const j1 = new BitmapImage(10, 5, color);
const j2 = new BitmapImage(j1);
assert.notStrictEqual(j1.bitmap, j2.bitmap);
assert.deepStrictEqual(j1.bitmap, j2.bitmap);
done();
});
});
describe("GifFrame bad construction behavior", () => {
it("won't accept garbage", (done) => {
assert.throws(() => {
new BitmapImage();
}, /requires parameters/);
assert.throws(() => {
new BitmapImage(null);
}, /unrecognized/);
assert.throws(() => {
new BitmapImage("string");
}, /unrecognized/);
assert.throws(() => {
new BitmapImage(() => {});
}, /unrecognized/);
assert.throws(() => {
new BitmapImage({});
}, /unrecognized/);
assert.throws(() => {
new BitmapImage(new Buffer(25));
}, /unrecognized/);
done();
});
it("width requires height", (done) => {
assert.throws(() => {
new BitmapImage(5);
}, /unrecognized/);
assert.throws(() => {
new BitmapImage(5, new Buffer(5));
}, /unrecognized/);
assert.throws(() => {
new BitmapImage(5, {});
}, /unrecognized/);
done();
});
});
// TBD: test BitmapImage transformation methods
describe("Jimp compatibility", () => {
it("works when sourced from Jimp", (done) => {
new Jimp(SAMPLE_PNG_PATH, (err, j1) => {
if (err) return done(err);
assert.strictEqual(err, null);
const initialColor = j1.getPixelColor(5, 5);
const i = new BitmapImage(j1.bitmap);
assert.strictEqual(i.getRGBA(5, 5), initialColor);
const newColor = initialColor + 0x01010101;
i.fillRGBA(newColor);
assert.strictEqual(i.getRGBA(5, 5), newColor);
const j2 = GifUtil.shareAsJimp(Jimp, i);
assert.strictEqual(j2.getPixelColor(5, 5), newColor);
done();
});
});
it("works when sourcing Jimp via sharing", (done) => {
const initialColor = 0x12344321;
const i1 = new BitmapImage(10, 5, initialColor);
const j = GifUtil.shareAsJimp(Jimp, i1);
assert.strictEqual(j.getPixelColor(3, 3), initialColor);
const newColor = initialColor + 0x01010101;
j.setPixelColor(newColor, 3, 3);
assert.strictEqual(j.getPixelColor(3, 3), newColor);
const i2 = new BitmapImage(j.bitmap);
assert.strictEqual(i2.getRGBA(3,3), newColor);
done();
});
it("works when sourcing Jimp via copying", (done) => {
const initialColor = 0x12344321;
const i1 = new BitmapImage(10, 5, initialColor);
const j = GifUtil.copyAsJimp(Jimp, i1);
assert.strictEqual(j.getPixelColor(3, 3), initialColor);
const newColor = initialColor + 0x01010101;
j.setPixelColor(newColor, 3, 3);
assert.strictEqual(j.getPixelColor(3, 3), newColor);
const i2 = new BitmapImage(j.bitmap);
assert.strictEqual(i2.getRGBA(3,3), newColor);
done();
});
it("composing with a sprite having transparency", (done) => {
const i1 = new BitmapImage(Tools.getBitmap('singleFrameMonoOpaque'));
const j1 = GifUtil.shareAsJimp(Jimp, i1);
const i2 = new BitmapImage(Tools.getBitmap('sampleSprite'));
const j2 = GifUtil.shareAsJimp(Jimp, i2);
j1.composite(j2, 1, 1);
const result = new BitmapImage(j1.bitmap);
const expected = new BitmapImage(Tools.getBitmap('singleFrameMonoOpaqueSpriteAt1x1'));
assert.deepStrictEqual(result.bitmap, expected.bitmap);
done();
});
});
+252
View File
@@ -0,0 +1,252 @@
'use strict';
const assert = require('chai').assert;
const Tools = require('./lib/tools');
const { Gif, GifFrame, GifCodec, GifUtil, GifError } =
require('../src/index');
describe("single frame decoding", () => {
it("reads an opaque monocolor file", () => {
const name = 'singleFrameMonoOpaque';
const bitmap = Tools.getBitmap(name);
return GifUtil.read(Tools.getGifPath(name))
.then(gif => {
_compareGifToSeries(gif, [bitmap], {
disposalMethod: GifFrame.DisposeToBackgroundColor,
usesTransparency: false
});
});
});
it("reads an opaque multi-color file", () => {
const name = 'singleFrameMultiOpaque';
const bitmap = Tools.getBitmap(name);
return GifUtil.read(Tools.getGifPath(name))
.then(gif => {
_compareGifToSeries(gif, [bitmap], {
disposalMethod: GifFrame.DisposeToBackgroundColor,
usesTransparency: false
});
});
});
it("reads a purely transparent file", () => {
const name = 'singleFrameNoColorTrans';
const bitmap = Tools.getBitmap(name, 0);
return GifUtil.read(Tools.getGifPath(name))
.then(gif => {
_compareGifToSeries(gif, [bitmap], {
disposalMethod: GifFrame.DisposeToBackgroundColor,
usesTransparency: true
});
});
});
it("reads a monochrome file with transparency", () => {
const name = 'singleFrameMonoTrans';
const bitmap = Tools.getBitmap(name, 0);
return GifUtil.read(Tools.getGifPath(name))
.then(gif => {
_compareGifToSeries(gif, [bitmap], {
disposalMethod: GifFrame.DisposeToBackgroundColor,
usesTransparency: true
});
});
});
it("reads a multicolor file with transparency", () => {
const name = 'singleFrameMultiTrans';
const bitmap = Tools.getBitmap(name, 0);
return GifUtil.read(Tools.getGifPath(name))
.then(gif => {
_compareGifToSeries(gif, [bitmap], {
disposalMethod: GifFrame.DisposeToBackgroundColor,
usesTransparency: true
});
});
});
it("reads a multicolor file with custom transparency color", () => {
const transRGB = 0x123456;
const name = 'singleFrameMultiTrans';
const bitmap = Tools.getBitmap(name, transRGB);
const decoder = new GifCodec({ transparentRGB: transRGB });
return GifUtil.read(Tools.getGifPath(name), decoder)
.then(gif => {
_compareGifToSeries(gif, [bitmap], {
disposalMethod: GifFrame.DisposeToBackgroundColor,
usesTransparency: true
});
});
});
});
describe("multiframe decoding", () => {
it("reads a 2-frame multicolor file without transparency", () => {
const name = 'twoFrameMultiOpaque';
const series = Tools.getSeries(name);
return GifUtil.read(Tools.getGifPath(name))
.then(gif => {
_compareGifToSeries(gif, series, {
disposalMethod: GifFrame.DisposeToBackgroundColor,
usesTransparency: false,
delayCentisecs: 50
});
});
});
it("reads a 3-frame monocolor file with transparency", () => {
const name = 'threeFrameMonoTrans';
const series = Tools.getSeries(name);
return GifUtil.read(Tools.getGifPath(name))
.then(gif => {
_compareGifToSeries(gif, series, {
disposalMethod: GifFrame.DisposeToBackgroundColor,
usesTransparency: true,
delayCentisecs: 25
});
});
});
it("reads a large multiframe file w/out transparency", () => {
return GifUtil.read(Tools.getGifPath('nburling-public'))
.then(gif => {
assert.strictEqual(gif.width, 238);
assert.strictEqual(gif.height, 372);
assert.strictEqual(gif.loops, 0);
assert.strictEqual(gif.usesTransparency, false);
assert(Array.isArray(gif.frames));
assert.strictEqual(gif.frames.length, 24);
for (let i = 0; i < gif.frames.length; ++i) {
const frame = gif.frames[i];
assert.strictEqual(frame.bitmap.width, gif.width);
assert.strictEqual(frame.bitmap.height, gif.height);
Tools.checkFrameDefaults(frame, {
xOffset: 0,
yOffset: 0,
disposalMethod: GifFrame.DisposeNothing,
delayCentisecs: 20
}, i);
}
assert(Buffer.isBuffer(gif.buffer));
});
});
it("reads a large multiframe file w/ offsets, w/out transparency", () => {
return GifUtil.read(Tools.getGifPath('rnaples-offsets-public'))
.then(gif => {
assert.strictEqual(gif.width, 480);
assert.strictEqual(gif.height, 693);
assert.strictEqual(gif.loops, 0);
assert.strictEqual(gif.usesTransparency, true);
const frameDump = [ // generated via Tools.dumpFramesAsCode()
[0, 0, 480, 693, 10, false, 1],
[208, 405, 130, 111, 10, false, 1],
[85, 0, 395, 516, 10, false, 1],
[85, 0, 395, 309, 10, false, 1],
[85, 0, 395, 309, 10, false, 1],
[85, 0, 395, 309, 10, false, 1],
[208, 0, 272, 516, 10, false, 1],
[85, 0, 375, 516, 10, false, 1],
[85, 2, 365, 514, 10, false, 1],
[85, 36, 346, 480, 10, false, 1],
[167, 79, 271, 213, 10, false, 1],
[85, 103, 353, 413, 10, false, 1],
[191, 142, 279, 374, 20, false, 1],
[0, 0, 1, 1, 20, false, 1],
[191, 142, 279, 374, 20, false, 1],
[191, 142, 279, 374, 30, false, 1],
[191, 142, 279, 374, 10, false, 1],
[85, 183, 395, 211, 10, false, 1],
[85, 183, 395, 258, 10, false, 1],
[85, 183, 395, 333, 10, false, 1],
[85, 183, 395, 363, 10, false, 1],
[85, 183, 395, 405, 10, false, 1],
[85, 183, 395, 442, 10, false, 1],
[208, 405, 272, 284, 10, false, 1],
[324, 499, 156, 194, 10, false, 1],
[394, 546, 86, 147, 10, false, 1],
[85, 183, 395, 510, 10, false, 1],
[0, 0, 1, 1, 10, false, 1],
[85, 183, 338, 333, 10, false, 1],
[208, 405, 130, 111, 10, false, 1],
[208, 247, 215, 269, 10, false, 1]
];
Tools.compareToFrameDump(gif.frames, frameDump);
assert(Buffer.isBuffer(gif.buffer));
});
});
});
describe("partial frame decoding", () => {
it("renders partial frames properly onto full frames", () => {
let actualBitmapImage;
return GifUtil.read(Tools.getGifPath('rnaples-offsets-public'))
.then(actualGif => {
actualBitmapImage = actualGif.frames[10];
return Tools.loadBitmapImage(Tools.getImagePath('rnaples-frame-10.png'));
})
.then(expectedBitmapImage => {
const expectedBitmap = expectedBitmapImage.bitmap;
Tools.checkBitmap(
actualBitmapImage.bitmap,
expectedBitmap.width,
expectedBitmap.height,
expectedBitmap.data
);
});
});
});
function _compareGifToSeries(actualGif, expectedSeries, options) {
assert.strictEqual(actualGif.width, expectedSeries[0].width);
assert.strictEqual(actualGif.height, expectedSeries[0].height);
if (options.loops === undefined) {
assert.strictEqual(actualGif.loops, 0);
}
else {
assert.strictEqual(actualGif.loops, options.loops);
}
if (options.usesTransparency !== undefined) {
assert.strictEqual(actualGif.usesTransparency,
options.usesTransparency);
}
if (options.optionization !== undefined) {
assert.strictEqual(actualGif.optionization, options.optionization);
}
assert(Array.isArray(actualGif.frames));
assert.strictEqual(actualGif.frames.length, expectedSeries.length);
for (let i = 0; i < actualGif.frames.length; ++i) {
const f = actualGif.frames[i];
Tools.checkFrameDefaults(f, options, i);
assert.deepStrictEqual(f.bitmap, expectedSeries[i],
`frame ${i} same bitmap`);
}
assert(Buffer.isBuffer(actualGif.buffer));
}
+307
View File
@@ -0,0 +1,307 @@
'use strict';
const assert = require('chai').assert;
const Tools = require('./lib/tools');
const { Gif, GifFrame, GifCodec, GifUtil, GifError } = require('../src/index');
// compare decoded encodings with decodings intead of comparing buffers, because there are many ways to encode the same data
const defaultCodec = new GifCodec();
describe("single-frame encoding", () => {
it("encodes an opaque monochrome GIF", () => {
const name = 'singleFrameMonoOpaque';
return _encodeDecodeFile(name, Gif.LocalColorsOnly) // simple code 1st
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly);
});
});
it("encodes a transparent GIF", () => {
const name = 'singleFrameNoColorTrans';
return _encodeDecodeFile(name, Gif.LocalColorsOnly) // simple code 1st
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly);
});
});
it("encodes a monochrome GIF with transparency", () => {
const name = 'singleFrameMonoTrans';
return _encodeDecodeFile(name, Gif.LocalColorsOnly) // simple code 1st
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly);
});
});
it("encodes a opaque two-color GIF", () => {
const name = 'singleFrameBWOpaque';
return _encodeDecodeFile(name, Gif.LocalColorsOnly) // simple code 1st
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly);
});
});
it("encodes a opaque multi-color GIF", () => {
const name = 'singleFrameMultiOpaque';
return _encodeDecodeFile(name, Gif.LocalColorsOnly) // simple code 1st
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly);
});
});
it("encodes a 4-color GIF w/ transparency", () => {
const name = 'singleFrameMultiTrans';
return _encodeDecodeFile(name, Gif.LocalColorsOnly); // simple code 1st
// .then(() => {
// return _encodeDecodeFile(name, Gif.GlobalColorsOnly);
// });
});
});
describe("multi-frame encoding", () => {
it("encodes a 2-frame multi-color opaque GIF", () => {
const name = 'twoFrameMultiOpaque';
return _encodeDecodeFile(name, Gif.LocalColorsOnly) // simple code 1st
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly);
});
});
it("encodes a 3-frame monocolor GIF with transparency", () => {
const name = 'threeFrameMonoTrans';
return _encodeDecodeFile(name, Gif.LocalColorsOnly) // simple code 1st
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly);
});
});
it("encodes a large multiframe file w/out transparency", () => {
const name = 'nburling-public';
return _encodeDecodeFile(name, Gif.LocalColorsOnly) // simple code 1st
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly);
});
});
it("encodes a large multiframe file w/ offsets, w/out transparency", () => {
const name = 'rnaples-offsets-public';
return _encodeDecodeFile(name, Gif.LocalColorsOnly) // simple code 1st
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly);
});
});
it("encodes large multiframe files w/ forced buffer-size scaling (1)", () => {
const scalingCodec = new GifCodec();
scalingCodec._testInitialBufferSize = 2048; // big enough for header
const name = 'nburling-public';
return _encodeDecodeFile(name, Gif.LocalColorsOnly, scalingCodec)
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly, scalingCodec);
});
});
it("encodes large multiframe files w/ forced buffer-size scaling (2)", () => {
const scalingCodec = new GifCodec();
scalingCodec._testInitialBufferSize = 2048; // big enough for header
const name = 'rnaples-offsets-public';
return _encodeDecodeFile(name, Gif.LocalColorsOnly, scalingCodec)
.then(() => {
return _encodeDecodeFile(name, Gif.GlobalColorsOnly, scalingCodec);
});
});
});
describe("encoding GlobalColorsPreferred", () => {
it("uses a global color table when 256 colors in 1 frame", () => {
const frames = [];
const options = { colorScope: Gif.GlobalColorsPreferred };
frames.push(_get256ColorFrame());
return defaultCodec.encodeGif(frames, options)
.then(encodedGif => {
options.colorScope = Gif.GlobalColorsOnly;
return defaultCodec.encodeGif(frames, options);
})
.then(encodedGif => {
assert(true);
})
});
it("uses a global color table when 256 colors in each of 2 frames", () => {
const frames = [];
const options = { colorScope: Gif.GlobalColorsPreferred };
frames.push(_get256ColorFrame());
frames.push(_get256ColorFrame());
return defaultCodec.encodeGif(frames, options)
.then(encodedGif => {
options.colorScope = Gif.GlobalColorsOnly;
return defaultCodec.encodeGif(frames, options);
})
.then(encodedGif => {
assert(true);
})
});
it("uses a global color table when 255 colors + transparency in each of 2 frames", () => {
const frames = [];
const options = { colorScope: Gif.GlobalColorsPreferred };
frames.push(_get256ColorFrame());
frames[0].bitmap.data[3] = 0;
frames.push(_get256ColorFrame());
frames[1].bitmap.data[3] = 0;
return defaultCodec.encodeGif(frames, options)
.then(encodedGif => {
options.colorScope = Gif.GlobalColorsOnly;
return defaultCodec.encodeGif(frames, options);
})
.then(encodedGif => {
assert(true);
})
});
it("uses a local color table when there are 257 opaque colors", () => {
const frames = [];
const options = { colorScope: Gif.GlobalColorsPreferred };
frames.push(_get256ColorFrame());
// put a 257th opaque color in the second frame
let buf = new Buffer(256 * 4); // defaults to zeroes
buf[0] = 0; buf[1] = 2; buf[2] = 3; buf[3] = 255;
frames.push(new GifFrame(16, 16, buf));
return defaultCodec.encodeGif(frames, options)
.then(encodedGif => {
options.colorScope = Gif.GlobalColorsOnly;
return defaultCodec.encodeGif(frames, options);
})
.then(encodedGif => {
assert.fail("should not encode");
})
.catch(err => {
if (!(err instanceof GifError)) {
throw err;
}
assert.strictEqual(err.message,
"Too many color indexes for global color table");
});
});
it("uses a local color table when there are 256 opaque colors + transparency", () => {
const frames = [];
const options = { colorScope: Gif.GlobalColorsPreferred };
frames.push(_get256ColorFrame());
frames.push(_get256ColorFrame());
frames[1].bitmap.data[3] = 0;
return defaultCodec.encodeGif(frames, options)
.then(encodedGif => {
options.colorScope = Gif.GlobalColorsOnly;
return defaultCodec.encodeGif(frames, options);
})
.then(encodedGif => {
assert.fail("should not encode");
})
.catch(err => {
if (!(err instanceof GifError)) {
throw err;
}
assert.strictEqual(err.message,
"Too many color indexes for global color table");
});
});
});
function _compareGifs(actual, expected, filename, note) {
note = `file '${filename}' (${note})`;
assert.strictEqual(actual.width, expected.width, note);
assert.strictEqual(actual.height, expected.height, note);
assert.strictEqual(actual.loops, expected.loops, note);
assert.strictEqual(actual.usesTransparency, expected.usesTransparency,
note);
assert(Buffer.isBuffer(actual.buffer), note);
assert(Array.isArray(actual.frames));
assert.strictEqual(actual.frames.length, expected.frames.length);
note = ` in ${note}`;
for (let i = 0; i < actual.frames.length; ++i) {
const actualFrame = actual.frames[i];
const expectedFrame = expected.frames[i];
Tools.verifyFrameInfo(actualFrame, expectedFrame, i, note);
}
}
function _encodeDecodeFile(filename, colorScope, codec) {
let expectedGif;
codec = codec || defaultCodec;
return GifUtil.read(Tools.getGifPath(filename), codec)
.then(readGif => {
expectedGif = readGif;
return codec.encodeGif(readGif.frames,
{ loops: readGif.loops, colorScope: colorScope });
})
.then(encodedGif => {
_compareGifs(encodedGif, expectedGif, filename,
`encoded == read (colorScope ${colorScope})`);
return codec.decodeGif(encodedGif.buffer);
})
.then(decodedGif => {
_compareGifs(decodedGif, expectedGif, filename,
`decoded == read (colorScope ${colorScope})`);
})
}
function _get256ColorFrame() {
let buf = new Buffer(256 * 4);
for (let i = 0; i < 256; ++i) {
const offset = i * 4;
buf[offset + 2] = buf[offset + 1] = buf[offset] = i;
buf[offset + 3] = 255;
}
return new GifFrame(16, 16, buf);
}
+276
View File
@@ -0,0 +1,276 @@
'use strict';
const assert = require('chai').assert;
const Jimp = require('jimp');
const Tools = require('./lib/tools');
const { BitmapImage, Gif, GifFrame, GifError } = require('../src/index');
const SAMPLE_PNG_PATH = Tools.getFixturePath('lenna.png');
const SAMPLE_JPG_PATH = Tools.getFixturePath('pelagrina.jpg');
describe("GifFrame good construction behavior", () => {
it("constructs an uncolored bitmap", (done) => {
const f = new GifFrame(10, 5);
Tools.checkBitmap(f.bitmap, 10, 5, 0);
Tools.checkFrameDefaults(f);
done();
});
it("initializes options in an uncolored bitmap", (done) => {
const f = new GifFrame(10, 5, { delayCentisecs: 100 });
Tools.checkBitmap(f.bitmap, 10, 5, 0);
Tools.checkFrameDefaults(f, {
delayCentisecs: 100
});
done();
});
it("constructs an empty colored bitmap", (done) => {
const color = 0x01020300;
const f = new GifFrame(10, 5, color);
Tools.checkBitmap(f.bitmap, 10, 5, color);
Tools.checkFrameDefaults(f);
done();
});
it("initializes options in a colored bitmap", (done) => {
const color = 0x010203ff;
const f = new GifFrame(10, 5, color, { delayCentisecs: 100 });
Tools.checkBitmap(f.bitmap, 10, 5, color);
Tools.checkFrameDefaults(f, {
delayCentisecs: 100
});
done();
});
it("sources from an existing bitmap, sharing", (done) => {
const color = 0x010203ff;
const j = new BitmapImage(10, 5, color);
const f = new GifFrame(j.bitmap);
assert.strictEqual(f.bitmap, j.bitmap);
Tools.checkFrameDefaults(f);
done();
});
it("sources from an existing bitmap, sharing, with options", (done) => {
const color = 0x010203ff;
const j = new BitmapImage(10, 5, color);
const f = new GifFrame(j.bitmap, { delayCentisecs: 100 });
assert.strictEqual(f.bitmap, j.bitmap);
Tools.checkFrameDefaults(f, {
delayCentisecs: 100
});
done();
});
it("sources from an existing BitmapImage, copying", (done) => {
const color = 0x010203ff;
const j = new BitmapImage(10, 5, color);
const f = new GifFrame(j);
assert.notStrictEqual(f.bitmap, j.bitmap);
assert.deepStrictEqual(f.bitmap, j.bitmap);
Tools.checkFrameDefaults(f);
done();
});
it("sources from an existing BitmapImage, copying, with options", (done) => {
const color = 0x010203ff;
const j = new BitmapImage(10, 5, color);
const f = new GifFrame(j, { delayCentisecs: 100 });
assert.notStrictEqual(f.bitmap, j.bitmap);
assert.deepStrictEqual(f.bitmap, j.bitmap);
Tools.checkFrameDefaults(f, {
delayCentisecs: 100
});
done();
});
it("initializes data params without options", (done) => {
const bitmap = Tools.getBitmap('singleFrameBWOpaque');
const f = new GifFrame(bitmap.width, bitmap.height, bitmap.data);
assert.strictEqual(f.bitmap.width, bitmap.width);
assert.strictEqual(f.bitmap.height, bitmap.height);
assert.strictEqual(f.bitmap.data, bitmap.data);
Tools.checkFrameDefaults(f);
done();
});
it("initializes data params with options", (done) => {
const bitmap = Tools.getBitmap('singleFrameBWOpaque');
const f = new GifFrame(bitmap.width, bitmap.height, bitmap.data,
{ delayCentisecs: 200, interlaced: true });
assert.strictEqual(f.bitmap.width, bitmap.width);
assert.strictEqual(f.bitmap.height, bitmap.height);
assert.strictEqual(f.bitmap.data, bitmap.data);
Tools.checkFrameDefaults(f, {
delayCentisecs: 200,
interlaced: true
});
done();
});
it("initializes bitmap without options", (done) => {
const bitmap = Tools.getBitmap('singleFrameBWOpaque');
const f = new GifFrame(bitmap);
assert.strictEqual(f.bitmap, bitmap);
Tools.checkFrameDefaults(f);
done();
});
it("initializes bitmap with options", (done) => {
const bitmap = Tools.getBitmap('singleFrameBWOpaque');
const f = new GifFrame(bitmap,
{ delayCentisecs: 200, interlaced: true });
assert.strictEqual(f.bitmap, bitmap);
Tools.checkFrameDefaults(f, {
delayCentisecs: 200,
interlaced: true
});
done();
});
it("clones an existing frame", (done) => {
const f1 = new GifFrame(5, 5, {
delayCentisecs: 100,
isInterlace: true
});
f1.bitmap = Tools.getBitmap('singleFrameBWOpaque');
const f2 = new GifFrame(f1);
Tools.verifyFrameInfo(f2, f1);
assert.notStrictEqual(f2.bitmap, f1.bitmap);
assert.deepStrictEqual(f2.bitmap, f1.bitmap);
done();
});
});
describe("GifFrame bad construction behavior", () => {
it("won't accept garbage", (done) => {
assert.throws(() => {
new GifFrame();
}, /requires parameters/);
assert.throws(() => {
new GifFrame(null);
}, /unrecognized/);
assert.throws(() => {
new GifFrame(null, { interlaced: true });
}, /unrecognized/);
assert.throws(() => {
new GifFrame("string");
}, /unrecognized/);
assert.throws(() => {
new GifFrame("string", { interlaced: true });
}, /unrecognized/);
assert.throws(() => {
new GifFrame(() => {});
}, /unrecognized/);
assert.throws(() => {
new GifFrame({});
}, /unrecognized/);
assert.throws(() => {
new GifFrame({ interlaced: true });
}, /unrecognized/);
assert.throws(() => {
new GifFrame(new Buffer(25));
}, /unrecognized/);
done();
});
it("width requires height", (done) => {
assert.throws(() => {
new GifFrame(5);
}, /unrecognized/);
assert.throws(() => {
new GifFrame(5, { interlaced: true });
}, /unrecognized/);
assert.throws(() => {
new GifFrame(5, new Buffer(5));
}, /unrecognized/);
done();
});
});
describe("GifFrame palette", () => {
it("is monocolor without transparency", (done) => {
const bitmap = Tools.getBitmap('singleFrameMonoOpaque');
const f = new GifFrame(bitmap);
const p = f.getPalette();
assert.deepStrictEqual(p.colors, [0xFF0000]);
assert.strictEqual(p.usesTransparency, false);
done();
});
it("includes two colors without transparency", (done) => {
const bitmap = Tools.getBitmap('singleFrameBWOpaque');
const f = new GifFrame(bitmap);
const p = f.getPalette();
assert.deepStrictEqual(p.colors, [0x000000, 0xffffff]);
assert.strictEqual(p.usesTransparency, false);
done();
});
it("includes multiple colors without transparency", (done) => {
const bitmap = Tools.getBitmap('singleFrameMultiOpaque');
const f = new GifFrame(bitmap);
const p = f.getPalette();
assert.deepStrictEqual(p.colors,
[0x0000ff, 0x00ff00, 0xff0000, 0xffffff]);
assert.strictEqual(p.usesTransparency, false);
done();
});
it("has only transparency", (done) => {
const bitmap = Tools.getBitmap('singleFrameNoColorTrans');
const f = new GifFrame(bitmap);
const p = f.getPalette();
assert.deepStrictEqual(p.colors, []);
assert.strictEqual(p.usesTransparency, true);
done();
});
it("is monocolor with transparency", (done) => {
const bitmap = Tools.getBitmap('singleFrameMonoTrans');
const f = new GifFrame(bitmap);
const p = f.getPalette();
assert.deepStrictEqual(p.colors, [0x00ff00]);
assert.strictEqual(p.usesTransparency, true);
done();
});
it("includes multiple colors with transparency", (done) => {
const bitmap = Tools.getBitmap('singleFrameMultiPartialTrans');
const f = new GifFrame(bitmap);
const p = f.getPalette();
assert.deepStrictEqual(p.colors,
[0x000000, 0x0000ff, 0x00ff00, 0xff0000, 0xffffff]);
assert.strictEqual(p.usesTransparency, true);
done();
});
});
function _assertDefaultFrameOptions(frame) {
assert.strictEqual(frame.xOffset, 0);
assert.strictEqual(frame.yOffset, 0);
assert.strictEqual(frame.disposalMethod, GifFrame.DisposeToBackgroundColor);
assert.strictEqual(typeof frame.delayCentisecs, 'number');
assert.strictEqual(frame.interlaced, false);
}
+277
View File
@@ -0,0 +1,277 @@
'use strict';
const assert = require('chai').assert;
const Tools = require('./lib/tools');
const { Gif, GifFrame, GifCodec, GifError, GifUtil } = require('../src/index');
const defaultCodec = new GifCodec();
describe("Gif width/height", () => {
it("sets width/height = size of the frames", () => {
const w = 24, h = 10;
const frames = [
new GifFrame(w, h),
new GifFrame(w, h, 0xffffffff)
];
return defaultCodec.encodeGif(frames)
.then(gif => {
assert.strictEqual(gif.width, w);
assert.strictEqual(gif.height, h);
});
});
it("sets width/height = size of largest frame", () => {
const w = 24, h = 10;
const frames = [
new GifFrame(w - 1, h - 1),
new GifFrame(w, h, 0xffffffff)
];
return defaultCodec.encodeGif(frames)
.then(gif => {
assert.strictEqual(gif.width, w);
assert.strictEqual(gif.height, h);
});
});
it("sets width/height = largest frame boundary", () => {
const w = 24, h = 10;
const frames = [
new GifFrame(w - 1, h - 1, {
xOffset: 1,
yOffset: 1
}),
new GifFrame(w, h, 0xffffffff)
];
return defaultCodec.encodeGif(frames)
.then(gif => {
assert.strictEqual(gif.width, w);
assert.strictEqual(gif.height, h);
});
});
});
describe("Gif loop count", () => {
it("decodes an infinite loop", () => {
return GifUtil.read(Tools.getGifPath('countConstantDelay0'))
.then(gif => {
assert.strictEqual(gif.loops, 0);
});
});
it("decodes a single-pass loop", () => {
return GifUtil.read(Tools.getGifPath('countConstantDelay1'))
.then(gif => {
assert.strictEqual(gif.loops, 1);
});
});
it("decodes a three-pass loop", () => {
return GifUtil.read(Tools.getGifPath('countConstantDelay3'))
.then(gif => {
assert.strictEqual(gif.loops, 3);
});
});
it("encodes an infinite loop", () => {
return _verifyEncodesLoopCount('countConstantDelay3', 0);
});
it("encodes a single-pass loop", () => {
return _verifyEncodesLoopCount('countConstantDelay0', 1);
});
it("encodes a three-pass loop", () => {
return _verifyEncodesLoopCount('countConstantDelay1', 3);
});
});
describe("Gif transparency", () => {
it("indicates no transparency", () => {
return GifUtil.read(Tools.getGifPath('twoFrameMultiOpaque'))
.then(readGif => {
assert.strictEqual(readGif.usesTransparency, false);
return defaultCodec.encodeGif(readGif.frames);
})
.then(encodedGif => {
assert.strictEqual(encodedGif.usesTransparency, false);
})
});
it("indicates transparency", () => {
return GifUtil.read(Tools.getGifPath('threeFrameMonoTrans'))
.then(readGif => {
assert.strictEqual(readGif.usesTransparency, true);
return defaultCodec.encodeGif(readGif.frames);
})
.then(encodedGif => {
assert.strictEqual(encodedGif.usesTransparency, true);
})
});
});
describe("GifFrame x/y-offsets", () => {
it("accommodates/encodes offsets", () => {
return GifUtil.read(Tools.getGifPath('count5x7'))
.then(readGif => {
const frames = readGif.frames;
assert(frames.length >= 4, "precondition for test");
assert.strictEqual(readGif.width, 5);
assert.strictEqual(readGif.height, 7);
frames.forEach(frame => {
_verifyFrameOffsets(frame, 0, 0);
});
frames[1].xOffset = 1;
frames[1].yOffset = 2;
frames[2].xOffset = 2;
frames[2].yOffset = 1;
return defaultCodec.encodeGif(frames);
})
.then(encodedGif => {
assert.strictEqual(encodedGif.width, 7);
assert.strictEqual(encodedGif.height, 9);
return defaultCodec.decodeGif(encodedGif.buffer);
})
.then(decodedGif => {
assert.strictEqual(decodedGif.width, 7);
assert.strictEqual(decodedGif.height, 9);
const frames = decodedGif.frames;
_verifyFrameOffsets(frames[0], 0, 0);
_verifyFrameOffsets(frames[1], 1, 2);
_verifyFrameOffsets(frames[2], 2, 1);
_verifyFrameOffsets(frames[3], 0, 0);
});
});
});
describe("GifFrame delay", () => {
it("confirms a constant frame-delay GIF", () => {
return GifUtil.read(Tools.getGifPath('countConstantDelay0'))
.then(readGif => {
readGif.frames.forEach(frame => {
assert.strictEqual(frame.delayCentisecs, 33);
});
});
});
it("confirms a variable frame-delay GIF", () => {
return GifUtil.read(Tools.getGifPath('countIncreasingDelay'))
.then(readGif => {
const frames = readGif.frames;
for (let i = 0; i < frames.length; ++i) {
assert.strictEqual(frames[i].delayCentisecs, (i + 1)*25);
}
});
});
it("encodes varying frame delays", () => {
return GifUtil.read(Tools.getGifPath('countConstantDelay0'))
.then(readGif => {
const frames = readGif.frames;
for (let i = 0; i < frames.length; ++i) {
frames[i].delayCentisecs = (i + 1)*25;
}
return defaultCodec.encodeGif(frames, { loops: readGif.loops });
})
.then(encodedGif => {
return defaultCodec.decodeGif(encodedGif.buffer);
})
.then(decodedGif => {
const frames = decodedGif.frames;
for (let i = 0; i < frames.length; ++i) {
assert.strictEqual(frames[i].delayCentisecs, (i + 1)*25);
}
});
});
});
describe("GifFrame disposal method", () => {
it("encodes/decodes disposal", () => {
return GifUtil.read(Tools.getGifPath('countConstantDelay0'))
.then(readGif => {
const frames = readGif.frames;
assert(frames.length >= 4, "precondition for test");
frames.forEach(frame => {
assert.strictEqual(frame.disposalMethod,
GifFrame.DisposeToBackgroundColor);
});
for (let i = 0; i < 4; ++i) {
frames[i].disposalMethod = i;
}
return defaultCodec.encodeGif(frames, { loops: readGif.loops });
})
.then(encodedGif => {
return defaultCodec.decodeGif(encodedGif.buffer);
})
.then(decodedGif => {
const frames = decodedGif.frames;
for (let i = 0; i < 4; ++i) {
assert.strictEqual(frames[i].disposalMethod, i);
}
});
});
});
function _verifyEncodesLoopCount(sourceFilename, loopCount) {
return GifUtil.read(Tools.getGifPath(sourceFilename))
.then(readGif => {
return defaultCodec.encodeGif(readGif.frames, { loops: loopCount });
})
.then(encodedGif => {
assert.strictEqual(encodedGif.loops, loopCount);
return defaultCodec.decodeGif(encodedGif.buffer);
})
.then(decodedGif => {
assert.strictEqual(decodedGif.loops, loopCount);
});
}
function _verifyFrameOffsets(frame, xOffset, yOffset) {
assert.strictEqual(frame.xOffset, xOffset);
assert.strictEqual(frame.yOffset, yOffset);
}
+292
View File
@@ -0,0 +1,292 @@
'use strict';
const assert = require('chai').assert;
const Jimp = require('jimp');
const Tools = require('./lib/tools');
const { BitmapImage, GifUtil } = require('../src/index');
describe("graphics color index reduction", () => {
it("Dekker-quantizes down to 32 colors", () => {
return _graphicsTest('quantizeDekker', 32);
});
it("Dekker-quantizes down to 256 colors", () => {
return _graphicsTest('quantizeDekker', 256);
});
it("Sorokin-quantizes down to 32 colors", () => {
return _graphicsTest('quantizeSorokin', 32);
});
it("Sorokin-quantizes down to 256 colors", () => {
return _graphicsTest('quantizeSorokin', 256);
});
it("Wu-quantizes down to 32 colors", () => {
return _graphicsTest('quantizeWu', 32);
});
it("Wu-quantizes down to 256 colors", () => {
return _graphicsTest('quantizeWu', 256);
});
});
describe("photo color index reduction", () => {
it("Dekker-quantizes down to 32 colors", () => {
return _photoTest('quantizeDekker', 32);
});
it("Dekker-quantizes down to 256 colors", () => {
return _photoTest('quantizeDekker', 256);
});
it("Sorokin-quantizes down to 32 colors", () => {
return _photoTest('quantizeSorokin', 32);
});
it("Sorokin-quantizes down to 256 colors", () => {
return _photoTest('quantizeSorokin', 256);
});
it("Wu-quantizes down to 32 colors", () => {
return _photoTest('quantizeWu', 32);
});
it("Wu-quantizes down to 256 colors", () => {
return _photoTest('quantizeWu', 256);
});
});
describe("dithering", () => {
it("FloydSteinberg-dither of Dekker-quantized 256 colors", () => {
return _ditherTest('quantizeDekker', 256, null, 'FloydSteinberg');
});
it("FloydSteinberg-dither of Sorokin-quantized 256 colors", () => {
return _ditherTest('quantizeSorokin', 256, 'min-pop', 'FloydSteinberg');
});
it("FloydSteinberg-dither of Wu-quantized 256 colors", () => {
return _ditherTest('quantizeWu', 256, 5, 'FloydSteinberg');
});
});
describe("reduce colors across a series of images", () => {
const specialOpaque = 0xffffffff;
const specialTransparent = 0;
it("Dekker-quantizes an opaque image series down to 256 colors", (done) => {
_seriesTest('quantizeDekker', specialOpaque);
done();
});
it("Dekker-quantizes an image series with transparency down to 256 colors", (done) => {
_seriesTest('quantizeDekker', specialTransparent);
done();
});
it("Sorokin-quantizes an opaque image series down to 256 colors", (done) => {
_seriesTest('quantizeSorokin', specialOpaque);
done();
});
it("Sorokin-quantizes an image series with transparency down to 256 colors", (done) => {
_seriesTest('quantizeSorokin', specialTransparent);
done();
});
it("Wu-quantizes an opaque image series down to 256 colors", (done) => {
_seriesTest('quantizeWu', specialOpaque);
done();
});
it("Wu-quantizes an image series with transparency down to 256 colors", (done) => {
_seriesTest('quantizeWu', specialTransparent);
done();
});
});
function _ditherTest(method, maxColors, modifier, ditherAlg) {
return _reductionTest("sculptmap.png", false, method, maxColors, modifier, {
ditherAlgorithm: ditherAlg
});
}
function _graphicsTest(method, maxColors) {
return _reductionTest("sculptmap.png", false, method, maxColors)
.then(() => {
return _reductionTest("rosewithtrans.png", true, method, maxColors);
})
}
function _hasTransparency(colorSet) {
for (let rgba of colorSet.values()) {
if ((rgba & 0xff) === 0x00) {
return true;
}
}
return false;
}
function _photoTest(method, maxColors) {
return _reductionTest("hairstreak.jpg", false, method, maxColors);
}
function _reductionTest(sourceFile, usesTransparency, method, maxColors, modifier, dither) {
const baseFile = sourceFile.substr(0, sourceFile.length - 4);
const suffix = method.substr(8);
let expectedFile = `quantized/${baseFile}${maxColors}_${suffix}`;
if (dither) {
expectedFile += '_' + dither.ditherAlgorithm;
}
expectedFile += '.png';
const label = `${method}(${maxColors}) - ${baseFile}`;
const writeFile = null; //expectedFile;
let work;
return new Promise((resolve, reject) => {
new Jimp(Tools.getFixturePath(sourceFile), (err, manyJimp) => {
if (err) return reject(err);
new Jimp(Tools.getFixturePath(expectedFile), (err, limitedJimp) => {
if (err) return reject(err);
work = new BitmapImage(manyJimp.bitmap);
const inputColorSet = work.getRGBASet();
assert.strictEqual(_hasTransparency(inputColorSet), usesTransparency, label);
assert.isAtLeast(inputColorSet.size, maxColors + 1, label);
if (method === 'quantizeDekker') {
GifUtil[method](work, maxColors, dither);
}
else {
GifUtil[method](work, maxColors, modifier, dither);
}
const workBuf = work.bitmap.data;
const limitedBuf = limitedJimp.bitmap.data;
assert.strictEqual(workBuf.length, manyJimp.bitmap.data.length, label);
assert.strictEqual(workBuf.compare(limitedBuf), 0, label);
const outputColorSet = work.getRGBASet();
assert.strictEqual(_hasTransparency(outputColorSet), usesTransparency, label);
assert.isAtMost(outputColorSet.size, maxColors, label);
resolve();
});
});
})
.then(() => {
if (writeFile) {
return _writeJimp(writeFile, work.bitmap);
}
});
}
function _seriesTest(method, specialColor) {
const images = [];
const width = 32, height = 32;
const maxColors = 256;
let image = new BitmapImage(width, height);
let buf = image.bitmap.data;
let bi = 0;
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
buf[bi] = x * 8;
buf[bi + 1] = y * 8;
buf[bi + 3] = 255;
bi += 4;
}
}
images.push(image); // 32 * 32 = 1024 colors
image = new BitmapImage(width, height);
buf = image.bitmap.data;
bi = 0;
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
buf[bi] = x * 8 + 4;
buf[bi + 1] = y * 8 + 4;
buf[bi + 3] = 255;
bi += 4;
}
}
images.push(image); // 32 * 32 = 1024 more colors
image = new BitmapImage(width, height);
buf = image.bitmap.data;
bi = 0;
for (let y = 0; y < height; ++y) {
for (let x = 0; x < width; ++x) {
if (x < width / 2) {
buf.writeUInt32BE(255, bi);
}
else {
buf.writeUInt32BE(specialColor, bi);
}
bi += 4;
}
}
images.push(image); // 1 more color (white), which should be there
GifUtil[method](images, maxColors);
const colorSet = new Set();
images.forEach(image => {
buf = image.bitmap.data;
for (let bi = 0; bi < buf.length; bi += 4) {
colorSet.add(buf.readUInt32BE(bi, true));
}
});
assert.isAtMost(colorSet.size, maxColors);
assert(colorSet.has(specialColor), "has special color");
}
function _writeJimp(filename, bitmap) {
const jimp = new Jimp(1, 1);
jimp.bitmap = bitmap;
return new Promise((resolve, reject) => {
jimp.write(filename, (err) => {
if (err) return reject(err);
resolve();
});
}).then(() => {
console.log(`WROTE ${filename}`);
})
.catch((err) => {
console.log(`WRITE FAILED ${err.stack}`);
});
}