byte-codec 0.0.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +254 -0
- package/.github/workflows/dependabot-auto-merge.yml +64 -0
- package/.github/workflows/sibling-dependency-update.yml +174 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +2 -0
- package/.husky/pre-push +2 -0
- package/CHANGELOG.md +12 -0
- package/README.md +43 -0
- package/commitlint.config.ts +8 -0
- package/eslint.config.ts +19 -0
- package/package.json +63 -2
- package/release.config.ts +65 -0
- package/src/bytes/crc32.ts +24 -0
- package/src/bytes/flate.ts +69 -0
- package/src/bytes/reader.ts +73 -0
- package/src/bytes/writer.ts +45 -0
- package/src/image/jpeg-info.ts +90 -0
- package/src/image/png-decode.ts +263 -0
- package/src/image/png-encode.ts +74 -0
- package/src/image/png-filter.ts +142 -0
- package/src/index.test.ts +33 -0
- package/src/index.ts +9 -0
- package/tsconfig.json +18 -0
- package/tsdown.config.ts +10 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { crc32 } from '../bytes/crc32';
|
|
2
|
+
import { deflate } from '../bytes/flate';
|
|
3
|
+
import { ByteWriter, concatBytes } from '../bytes/writer';
|
|
4
|
+
import type { RawImage } from './png-decode';
|
|
5
|
+
import { filterScanlines } from './png-filter';
|
|
6
|
+
|
|
7
|
+
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
8
|
+
|
|
9
|
+
export interface PngEncodeOptions {
|
|
10
|
+
// 'adaptive' (the default) picks, per row, whichever of the five PNG filters minimises the sum of the filtered bytes' absolute values -- the PNG spec's own recommended heuristic. 'none' always emits filter type 0, useful for deterministic, human-auditable test output.
|
|
11
|
+
readonly filter?: 'none' | 'adaptive';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function u32be(value: number): Uint8Array<ArrayBuffer> {
|
|
15
|
+
const bytes = new Uint8Array(4);
|
|
16
|
+
new DataView(bytes.buffer).setUint32(0, value);
|
|
17
|
+
return bytes;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeChunk(writer: ByteWriter, type: string, data: Uint8Array<ArrayBuffer>): void {
|
|
21
|
+
const typeBytes = new TextEncoder().encode(type);
|
|
22
|
+
writer.writeBytes(u32be(data.length));
|
|
23
|
+
writer.writeBytes(typeBytes);
|
|
24
|
+
writer.writeBytes(data);
|
|
25
|
+
writer.writeBytes(u32be(crc32(concatBytes([typeBytes, data]))));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// IHDR colour type: 0 gray, 2 truecolor(RGB), 4 gray+alpha, 6 truecolor+alpha(RGBA). RawImage's channels/alpha combination maps onto these four (never 3, palette -- this encoder never emits an indexed-colour image, since RawImage carries no palette of its own).
|
|
29
|
+
function colorTypeFor(image: RawImage): number {
|
|
30
|
+
if (image.channels === 1) {
|
|
31
|
+
return image.alpha === undefined ? 0 : 4;
|
|
32
|
+
}
|
|
33
|
+
return image.alpha === undefined ? 2 : 6;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Encodes normalised raw pixel data (8 bits per channel, optionally with a separate alpha plane) into PNG file bytes -- the exact inverse of decodePng's RawImage shape.
|
|
37
|
+
export function encodePng(image: RawImage, options: PngEncodeOptions = {}): Uint8Array<ArrayBuffer> {
|
|
38
|
+
const { width, height, channels, data, alpha } = image;
|
|
39
|
+
const outChannels = alpha === undefined ? channels : channels + 1;
|
|
40
|
+
const bytesPerRow = width * outChannels;
|
|
41
|
+
const pixelCount = width * height;
|
|
42
|
+
|
|
43
|
+
const interleaved = new Uint8Array(pixelCount * outChannels);
|
|
44
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
45
|
+
const srcBase = i * channels;
|
|
46
|
+
const dstBase = i * outChannels;
|
|
47
|
+
for (let c = 0; c < channels; c++) {
|
|
48
|
+
interleaved[dstBase + c] = data[srcBase + c]!;
|
|
49
|
+
}
|
|
50
|
+
if (alpha !== undefined) {
|
|
51
|
+
interleaved[dstBase + channels] = alpha[i]!;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const filtered = filterScanlines(interleaved, height, bytesPerRow, outChannels, options.filter ?? 'adaptive');
|
|
56
|
+
const compressed = deflate(filtered);
|
|
57
|
+
|
|
58
|
+
const ihdr = new Uint8Array(13);
|
|
59
|
+
const ihdrView = new DataView(ihdr.buffer);
|
|
60
|
+
ihdrView.setUint32(0, width);
|
|
61
|
+
ihdrView.setUint32(4, height);
|
|
62
|
+
ihdr[8] = 8; // bit depth: always 8, since RawImage is always 8 bits per channel
|
|
63
|
+
ihdr[9] = colorTypeFor(image);
|
|
64
|
+
ihdr[10] = 0; // compression method: always 0 (deflate)
|
|
65
|
+
ihdr[11] = 0; // filter method: always 0 (the five-filter adaptive scheme)
|
|
66
|
+
ihdr[12] = 0; // interlace method: 0 (no interlacing)
|
|
67
|
+
|
|
68
|
+
const writer = new ByteWriter();
|
|
69
|
+
writer.writeBytes(PNG_SIGNATURE);
|
|
70
|
+
writeChunk(writer, 'IHDR', ihdr);
|
|
71
|
+
writeChunk(writer, 'IDAT', compressed);
|
|
72
|
+
writeChunk(writer, 'IEND', new Uint8Array(0));
|
|
73
|
+
return writer.toBytes();
|
|
74
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The five PNG scanline (un)filters (PNG spec section 9.2), shared by the PDF cross-reference stream predictor path (src/pdf/predictors.ts): xref streams are almost always /Predictor 12, which is exactly PNG's "Up" filter applied to fixed-width rows, so this module sits on the critical path for reading modern PDFs, not just for PNG images.
|
|
2
|
+
export type PngFilterType = 0 | 1 | 2 | 3 | 4; // None, Sub, Up, Average, Paeth
|
|
3
|
+
|
|
4
|
+
function paethPredictor(a: number, b: number, c: number): number {
|
|
5
|
+
const p = a + b - c;
|
|
6
|
+
const pa = Math.abs(p - a);
|
|
7
|
+
const pb = Math.abs(p - b);
|
|
8
|
+
const pc = Math.abs(p - c);
|
|
9
|
+
if (pa <= pb && pa <= pc) {
|
|
10
|
+
return a;
|
|
11
|
+
}
|
|
12
|
+
if (pb <= pc) {
|
|
13
|
+
return b;
|
|
14
|
+
}
|
|
15
|
+
return c;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The value a filter type predicts from the left (a), above (b), and above-left (c) samples -- added back in during unfiltering, or subtracted out during filtering. Returning a value from a pure function (rather than assigning inside a switch) sidesteps having to prove a switch over a literal union is exhaustive to a variable declared without an initialiser.
|
|
19
|
+
function isPngFilterType(value: number): value is PngFilterType {
|
|
20
|
+
return value === 0 || value === 1 || value === 2 || value === 3 || value === 4;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function predictorValue(filterType: PngFilterType, a: number, b: number, c: number): number {
|
|
24
|
+
if (filterType === 1) {
|
|
25
|
+
return a;
|
|
26
|
+
}
|
|
27
|
+
if (filterType === 2) {
|
|
28
|
+
return b;
|
|
29
|
+
}
|
|
30
|
+
if (filterType === 3) {
|
|
31
|
+
return Math.floor((a + b) / 2);
|
|
32
|
+
}
|
|
33
|
+
if (filterType === 4) {
|
|
34
|
+
return paethPredictor(a, b, c);
|
|
35
|
+
}
|
|
36
|
+
return 0; // None
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Reverses PNG's per-scanline filtering. `data` is the inflated IDAT payload: height rows, each prefixed by one filter-type byte followed by `bytesPerRow` filtered sample bytes. Returns the raw (unfiltered) pixel bytes, height * bytesPerRow long, with the filter-type bytes stripped.
|
|
40
|
+
export function unfilterScanlines(
|
|
41
|
+
data: Uint8Array<ArrayBuffer>,
|
|
42
|
+
height: number,
|
|
43
|
+
bytesPerRow: number,
|
|
44
|
+
bpp: number,
|
|
45
|
+
): Uint8Array<ArrayBuffer> {
|
|
46
|
+
const stride = bytesPerRow + 1;
|
|
47
|
+
if (data.length < height * stride) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`PNG scanline data too short: expected at least ${height * stride} bytes, got ${data.length}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const out = new Uint8Array(height * bytesPerRow);
|
|
53
|
+
for (let y = 0; y < height; y++) {
|
|
54
|
+
const filterByte = data[y * stride];
|
|
55
|
+
if (filterByte === undefined || !isPngFilterType(filterByte)) {
|
|
56
|
+
throw new Error(`unknown PNG filter type: ${String(filterByte)}`);
|
|
57
|
+
}
|
|
58
|
+
const rowStart = y * stride + 1;
|
|
59
|
+
const outRowStart = y * bytesPerRow;
|
|
60
|
+
const prevOutRowStart = y > 0 ? outRowStart - bytesPerRow : undefined;
|
|
61
|
+
for (let x = 0; x < bytesPerRow; x++) {
|
|
62
|
+
const raw = data[rowStart + x]!;
|
|
63
|
+
const a = x >= bpp ? out[outRowStart + x - bpp]! : 0;
|
|
64
|
+
const b = prevOutRowStart === undefined ? 0 : out[prevOutRowStart + x]!;
|
|
65
|
+
const c = x >= bpp && prevOutRowStart !== undefined ? out[prevOutRowStart + x - bpp]! : 0;
|
|
66
|
+
out[outRowStart + x] = (raw + predictorValue(filterByte, a, b, c)) & 0xff;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function sumOfAbsSigned(bytes: Uint8Array<ArrayBuffer>): number {
|
|
73
|
+
let sum = 0;
|
|
74
|
+
for (const byte of bytes) {
|
|
75
|
+
sum += byte < 128 ? byte : 256 - byte;
|
|
76
|
+
}
|
|
77
|
+
return sum;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function filterRowInto(
|
|
81
|
+
raw: Uint8Array<ArrayBuffer>,
|
|
82
|
+
rowStart: number,
|
|
83
|
+
prevRowStart: number | undefined,
|
|
84
|
+
bytesPerRow: number,
|
|
85
|
+
bpp: number,
|
|
86
|
+
filterType: PngFilterType,
|
|
87
|
+
out: Uint8Array<ArrayBuffer>,
|
|
88
|
+
outOffset: number,
|
|
89
|
+
): void {
|
|
90
|
+
for (let x = 0; x < bytesPerRow; x++) {
|
|
91
|
+
const rawByte = raw[rowStart + x]!;
|
|
92
|
+
const a = x >= bpp ? raw[rowStart + x - bpp]! : 0;
|
|
93
|
+
const b = prevRowStart === undefined ? 0 : raw[prevRowStart + x]!;
|
|
94
|
+
const c = x >= bpp && prevRowStart !== undefined ? raw[prevRowStart + x - bpp]! : 0;
|
|
95
|
+
out[outOffset + x] = (rawByte - predictorValue(filterType, a, b, c)) & 0xff;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ALL_FILTER_TYPES: readonly PngFilterType[] = [0, 1, 2, 3, 4];
|
|
100
|
+
|
|
101
|
+
// Filters raw (unfiltered) pixel bytes into PNG's per-scanline IDAT payload shape. `strategy: 'none'` always emits filter type 0 (useful for deterministic, human-auditable test output); `'adaptive'` (the default) picks, per row, whichever of the five filters minimises the sum of the filtered bytes' absolute values interpreted as signed -- the heuristic the PNG spec itself recommends.
|
|
102
|
+
export function filterScanlines(
|
|
103
|
+
raw: Uint8Array<ArrayBuffer>,
|
|
104
|
+
height: number,
|
|
105
|
+
bytesPerRow: number,
|
|
106
|
+
bpp: number,
|
|
107
|
+
strategy: 'none' | 'adaptive' = 'adaptive',
|
|
108
|
+
): Uint8Array<ArrayBuffer> {
|
|
109
|
+
const stride = bytesPerRow + 1;
|
|
110
|
+
const out = new Uint8Array(height * stride);
|
|
111
|
+
const candidate = new Uint8Array(bytesPerRow);
|
|
112
|
+
|
|
113
|
+
for (let y = 0; y < height; y++) {
|
|
114
|
+
const rowStart = y * bytesPerRow;
|
|
115
|
+
const prevRowStart = y > 0 ? rowStart - bytesPerRow : undefined;
|
|
116
|
+
const outRowStart = y * stride;
|
|
117
|
+
|
|
118
|
+
if (strategy === 'none') {
|
|
119
|
+
out[outRowStart] = 0;
|
|
120
|
+
filterRowInto(raw, rowStart, prevRowStart, bytesPerRow, bpp, 0, out, outRowStart + 1);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let bestType: PngFilterType = 0;
|
|
125
|
+
let bestSum = Number.POSITIVE_INFINITY;
|
|
126
|
+
let best: Uint8Array<ArrayBuffer> | undefined;
|
|
127
|
+
for (const filterType of ALL_FILTER_TYPES) {
|
|
128
|
+
filterRowInto(raw, rowStart, prevRowStart, bytesPerRow, bpp, filterType, candidate, 0);
|
|
129
|
+
const sum = sumOfAbsSigned(candidate);
|
|
130
|
+
if (sum < bestSum) {
|
|
131
|
+
bestSum = sum;
|
|
132
|
+
bestType = filterType;
|
|
133
|
+
best = candidate.slice();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
out[outRowStart] = bestType;
|
|
137
|
+
if (best !== undefined) {
|
|
138
|
+
out.set(best, outRowStart + 1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { crc32, encodePng, decodePng, readJpegInfo, ByteWriter, concatBytes } from './index';
|
|
3
|
+
|
|
4
|
+
describe('byte-codec smoke', () => {
|
|
5
|
+
it('crc32 produces a consistent hash for known input', () => {
|
|
6
|
+
expect(crc32(new Uint8Array([1, 2, 3, 4]))).toBe(crc32(new Uint8Array([1, 2, 3, 4])));
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('encodePng then decodePng round-trips a small image', () => {
|
|
10
|
+
const pixels = new Uint8Array([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0]);
|
|
11
|
+
const png = encodePng({ width: 2, height: 2, channels: 3, data: pixels });
|
|
12
|
+
const decoded = decodePng(png);
|
|
13
|
+
expect(decoded.width).toBe(2);
|
|
14
|
+
expect(decoded.height).toBe(2);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('ByteWriter accumulates and produces concatenated output', () => {
|
|
18
|
+
const w = new ByteWriter();
|
|
19
|
+
w.writeByte(1);
|
|
20
|
+
w.writeByte(2);
|
|
21
|
+
w.writeByte(3);
|
|
22
|
+
expect(Array.from(w.toBytes())).toEqual([1, 2, 3]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('concatBytes joins arrays', () => {
|
|
26
|
+
const result = concatBytes([new Uint8Array([1, 2]), new Uint8Array([3, 4])]);
|
|
27
|
+
expect(Array.from(result)).toEqual([1, 2, 3, 4]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('readJpegInfo throws for non-JPEG input', () => {
|
|
31
|
+
expect(() => readJpegInfo(new Uint8Array([0, 0, 0]))).toThrow(/JPEG/);
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The shared byte/image utility package for the documents.js family: generic byte-level primitives (ByteWriter, ByteReader, CRC-32, deflate/inflate) and PNG/JPEG image encoding/decoding with zero PDF knowledge. Extracted from pdf-codec (where they lived as a directory-isolated subgraph with no PDF imports) so both pdf-codec and documents.js consume them from a neutral home rather than one fetching byte utilities from a backend.
|
|
2
|
+
export * from './bytes/writer';
|
|
3
|
+
export * from './bytes/reader';
|
|
4
|
+
export * from './bytes/crc32';
|
|
5
|
+
export * from './bytes/flate';
|
|
6
|
+
export * from './image/png-encode';
|
|
7
|
+
export * from './image/png-decode';
|
|
8
|
+
export * from './image/png-filter';
|
|
9
|
+
export * from './image/jpeg-info';
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"strict": true,
|
|
4
|
+
"noUncheckedIndexedAccess": true,
|
|
5
|
+
"target": "ES2024",
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"lib": ["ES2024"],
|
|
9
|
+
"types": ["node"],
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"esModuleInterop": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src"]
|
|
18
|
+
}
|