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.
@@ -0,0 +1,65 @@
1
+ import type { Options } from 'semantic-release';
2
+
3
+ type ReleaseLevel = 'major' | 'minor' | 'patch' | false;
4
+
5
+ interface CommitType {
6
+ readonly type: string;
7
+ readonly release: ReleaseLevel;
8
+ }
9
+
10
+ /**
11
+ * Single source of truth for the conventional-commit types this project uses. commitlint's allowed type-enum (commitlint.config.ts imports this) and commit-analyzer's releaseRules below both derive from it, so a type can't trigger a release without also being accepted by commit-msg validation, or the reverse.
12
+ *
13
+ * Defined here rather than in a shared commit-types.ts: semantic-release loads this file via cosmiconfig, which transpiles only this one file to ESM, so a sibling .ts module would not resolve. commitlint's jiti loader has no such limit, so it imports commitTypes from here.
14
+ */
15
+ export const commitTypes: readonly CommitType[] = [
16
+ { type: 'feat', release: 'minor' },
17
+ { type: 'fix', release: 'patch' },
18
+ { type: 'perf', release: 'patch' },
19
+ { type: 'revert', release: 'patch' },
20
+ { type: 'refactor', release: 'patch' },
21
+ { type: 'docs', release: 'patch' },
22
+ { type: 'style', release: 'patch' },
23
+ { type: 'test', release: 'patch' },
24
+ { type: 'build', release: 'patch' },
25
+ { type: 'ci', release: 'patch' },
26
+ { type: 'chore', release: 'patch' },
27
+ ];
28
+
29
+ /**
30
+ * Runs on `main`. Analyses commits since the last tag, bumps the version, publishes to npmjs.org (trusted OIDC publishing, no stored token -- see .github/workflows/ci.yml), creates a versioned tag and GitHub Release with generated notes, and commits CHANGELOG.md + package.json back to main. The release commit's [skip ci] message avoids a redundant CI run.
31
+ */
32
+ const config: Options = {
33
+ branches: ['main'],
34
+ plugins: [
35
+ [
36
+ '@semantic-release/commit-analyzer',
37
+ {
38
+ preset: 'conventionalcommits',
39
+ releaseRules: [
40
+ { breaking: true, release: 'major' },
41
+ ...commitTypes.map((t) => ({ type: t.type, release: t.release })),
42
+ ],
43
+ },
44
+ ],
45
+ [
46
+ '@semantic-release/release-notes-generator',
47
+ {
48
+ // Deliberately angular, not conventionalcommits, despite commit-analyzer above using conventionalcommits without issue. conventional-changelog-conventionalcommits@10.2.1 exports its changelog body under the key `template`, but the conventional-changelog-writer version release-notes-generator@14.1.1 bundles only reads `options.mainTemplate` -- so the body silently falls back to the writer's own generic default, whose commit partial doesn't match conventionalcommits' function-based partial signature either. The result is a changelog with a version header and nothing under it, for every commit, with zero custom configuration: confirmed with `preset: 'conventionalcommits'` and no presetConfig at all. angular is release-notes-generator's own tested default and renders correctly. commitTypes above still drives commit-analyzer's releaseRules and commitlint's type-enum; it just can't also drive per-type changelog sections until this is fixed upstream, so there is no per-type `section` field here to go unused.
49
+ preset: 'angular',
50
+ },
51
+ ],
52
+ '@semantic-release/changelog',
53
+ ['@semantic-release/npm', { npmPublish: true }],
54
+ '@semantic-release/github',
55
+ [
56
+ '@semantic-release/git',
57
+ {
58
+ assets: ['CHANGELOG.md', 'package.json'],
59
+ message: 'chore(release): ${nextRelease.version} [skip ci]',
60
+ },
61
+ ],
62
+ ],
63
+ };
64
+
65
+ export default config;
@@ -0,0 +1,24 @@
1
+ // Table-driven CRC32 (polynomial 0xEDB88320, the standard IEEE 802.3 / ZIP / PNG polynomial). fflate exports no CRC32 of its own (only an internal one used by its ZIP writer), and PNG's chunk format requires one per chunk, so this is genuinely new code, not a duplicate of anything already in the tree.
2
+ const CRC32_POLYNOMIAL = 0xedb88320;
3
+ const BYTE_VALUES = 256;
4
+ const BITS_PER_BYTE = 8;
5
+
6
+ const CRC32_TABLE: Uint32Array = (() => {
7
+ const table = new Uint32Array(BYTE_VALUES);
8
+ for (let n = 0; n < BYTE_VALUES; n++) {
9
+ let c = n;
10
+ for (let k = 0; k < BITS_PER_BYTE; k++) {
11
+ c = (c & 1) === 1 ? CRC32_POLYNOMIAL ^ (c >>> 1) : c >>> 1;
12
+ }
13
+ table[n] = c >>> 0;
14
+ }
15
+ return table;
16
+ })();
17
+
18
+ export function crc32(bytes: Uint8Array<ArrayBuffer>): number {
19
+ let crc = 0xffffffff;
20
+ for (const byte of bytes) {
21
+ crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
22
+ }
23
+ return (crc ^ 0xffffffff) >>> 0;
24
+ }
@@ -0,0 +1,69 @@
1
+ import { Unzlib, inflateSync, unzlibSync, zlibSync } from 'fflate';
2
+ import { isAsciiWhitespace } from './reader';
3
+ import { concatBytes } from './writer';
4
+
5
+ // The only file in the package that imports fflate -- the direct analogue of ooxml.js's own src/zip.ts ("a thin wrapper over fflate's zipSync/unzipSync, isomorphic and dependency-free"). PDF's FlateDecode filter and PNG's IDAT payload both use zlib-framed DEFLATE (RFC 1950 -- a 2-byte header plus a trailing Adler-32 checksum) -- that is zlibSync/unzlibSync, NOT fflate's deflateSync/inflateSync, which are raw DEFLATE (RFC 1951) with no wrapper. Emitting or expecting the wrong framing produces a stream every conformant PDF/PNG reader rejects.
6
+
7
+ // Guards every call in this module against a maliciously or accidentally huge decompressed output -- both PDF and PNG streams here come from arbitrary, potentially adversarial input.
8
+ export const MAX_INFLATE_OUTPUT_BYTES = 512 * 1024 * 1024;
9
+
10
+ export type DeflateLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
11
+
12
+ export function deflate(data: Uint8Array<ArrayBuffer>, level?: DeflateLevel): Uint8Array<ArrayBuffer> {
13
+ return zlibSync(data, level === undefined ? undefined : { level });
14
+ }
15
+
16
+ export function inflate(data: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer> {
17
+ const out = unzlibSync(data);
18
+ if (out.length > MAX_INFLATE_OUTPUT_BYTES) {
19
+ throw new Error(`inflated output exceeds the ${MAX_INFLATE_OUTPUT_BYTES}-byte limit`);
20
+ }
21
+ return out;
22
+ }
23
+
24
+ export interface InflateResult {
25
+ readonly bytes: Uint8Array<ArrayBuffer>;
26
+ readonly recovered: boolean;
27
+ }
28
+
29
+ // A tolerant inflate for the read path: real-world PDF/PNG streams occasionally carry a leading whitespace byte, are mistakenly raw-DEFLATE under a zlib-labelled filter, or are truncated. Tries, in order: a plain inflate(); skipping leading whitespace and retrying; raw inflateSync (some producers mislabel raw DEFLATE as FlateDecode); and finally the streaming Unzlib class, which emits chunks as they're produced, so a mid-stream truncation still yields whatever decoded successfully before the failure (`recovered: true`) rather than nothing at all.
30
+ export function inflateTolerant(data: Uint8Array<ArrayBuffer>): InflateResult {
31
+ try {
32
+ return { bytes: inflate(data), recovered: false };
33
+ } catch {
34
+ // fall through to the recovery ladder below
35
+ }
36
+
37
+ let offset = 0;
38
+ while (offset < data.length && isAsciiWhitespace(data[offset])) {
39
+ offset++;
40
+ }
41
+ if (offset > 0) {
42
+ try {
43
+ return { bytes: inflate(data.subarray(offset)), recovered: true };
44
+ } catch {
45
+ // fall through
46
+ }
47
+ }
48
+
49
+ try {
50
+ return { bytes: inflateSync(data), recovered: true };
51
+ } catch {
52
+ // fall through
53
+ }
54
+
55
+ const chunks: Uint8Array<ArrayBuffer>[] = [];
56
+ const unzlib = new Unzlib((chunk) => {
57
+ chunks.push(chunk);
58
+ });
59
+ try {
60
+ // Pushed as NOT final: fflate only flushes decoded output incrementally as it's produced when a push is not marked as the stream's end -- marking truncated data `final: true` instead makes it run the end-of-stream/checksum finalisation path, which throws atomically before emitting anything at all (verified empirically against fflate 0.8.3). Since this is already the last-resort recovery tier, skipping checksum verification here is an acceptable trade.
61
+ unzlib.push(data, false);
62
+ } catch {
63
+ // whatever chunks were emitted before the throw are still valid partial output
64
+ }
65
+ if (chunks.length === 0) {
66
+ throw new Error('unable to inflate stream: no data could be recovered');
67
+ }
68
+ return { bytes: concatBytes(chunks), recovered: true };
69
+ }
@@ -0,0 +1,73 @@
1
+ const ASCII_WHITESPACE_BYTES = new Set([0x00, 0x09, 0x0a, 0x0c, 0x0d, 0x20]);
2
+
3
+ export function isAsciiWhitespace(byte: number | undefined): boolean {
4
+ return byte !== undefined && ASCII_WHITESPACE_BYTES.has(byte);
5
+ }
6
+
7
+ // A forward-only cursor over a byte buffer, with explicit mark()/reset() for the backtracking a tokenizer needs -- e.g. the PDF lexer's `N G R` (a reference) vs `N G obj` (an indirect object header) ambiguity, resolved only by trying to read two integers and a keyword, then rewinding if it doesn't match.
8
+ export class ByteReader {
9
+ private readonly bytes: Uint8Array<ArrayBuffer>;
10
+ private position = 0;
11
+
12
+ constructor(bytes: Uint8Array<ArrayBuffer>) {
13
+ this.bytes = bytes;
14
+ }
15
+
16
+ get offset(): number {
17
+ return this.position;
18
+ }
19
+
20
+ get length(): number {
21
+ return this.bytes.length;
22
+ }
23
+
24
+ atEnd(): boolean {
25
+ return this.position >= this.bytes.length;
26
+ }
27
+
28
+ peek(aheadBy = 0): number | undefined {
29
+ return this.bytes[this.position + aheadBy];
30
+ }
31
+
32
+ next(): number | undefined {
33
+ const byte = this.bytes[this.position];
34
+ if (byte !== undefined) {
35
+ this.position++;
36
+ }
37
+ return byte;
38
+ }
39
+
40
+ // Returns a resumption point for reset(); does not itself change position.
41
+ mark(): number {
42
+ return this.position;
43
+ }
44
+
45
+ reset(mark: number): void {
46
+ this.position = mark;
47
+ }
48
+
49
+ seek(offset: number): void {
50
+ this.position = offset;
51
+ }
52
+
53
+ slice(start: number, end: number): Uint8Array<ArrayBuffer> {
54
+ return this.bytes.subarray(start, end);
55
+ }
56
+
57
+ skipWhitespace(): void {
58
+ while (isAsciiWhitespace(this.peek())) {
59
+ this.position++;
60
+ }
61
+ }
62
+
63
+ // Consumes `keyword` as a literal ASCII sequence at the current position, advancing past it, and returns true; otherwise leaves the position unchanged and returns false.
64
+ matchKeyword(keyword: string): boolean {
65
+ for (let i = 0; i < keyword.length; i++) {
66
+ if (this.peek(i) !== keyword.charCodeAt(i)) {
67
+ return false;
68
+ }
69
+ }
70
+ this.position += keyword.length;
71
+ return true;
72
+ }
73
+ }
@@ -0,0 +1,45 @@
1
+ // A chunked, growable byte-output builder: writes accumulate into a list of chunks rather than repeatedly reallocating and copying one growing buffer, which is O(n^2) for many small writes -- exactly the access pattern the PDF writer (one write per operator) and the PNG encoder (one write per scanline) both have.
2
+ export class ByteWriter {
3
+ private readonly chunks: Uint8Array<ArrayBuffer>[] = [];
4
+ private byteLength = 0;
5
+
6
+ get length(): number {
7
+ return this.byteLength;
8
+ }
9
+
10
+ writeBytes(bytes: Uint8Array<ArrayBuffer>): void {
11
+ if (bytes.length === 0) {
12
+ return;
13
+ }
14
+ this.chunks.push(bytes);
15
+ this.byteLength += bytes.length;
16
+ }
17
+
18
+ writeByte(byte: number): void {
19
+ this.writeBytes(new Uint8Array([byte]));
20
+ }
21
+
22
+ // Encodes `text` as UTF-8 (ASCII in practice, for the PDF/PNG syntax this writer produces) and appends it.
23
+ writeAscii(text: string): void {
24
+ this.writeBytes(new TextEncoder().encode(text));
25
+ }
26
+
27
+ toBytes(): Uint8Array<ArrayBuffer> {
28
+ const out = new Uint8Array(this.byteLength);
29
+ let offset = 0;
30
+ for (const chunk of this.chunks) {
31
+ out.set(chunk, offset);
32
+ offset += chunk.length;
33
+ }
34
+ return out;
35
+ }
36
+ }
37
+
38
+ // Concatenates a list of byte chunks into one contiguous array without the O(n^2) cost of repeated single-chunk concatenation.
39
+ export function concatBytes(chunks: readonly Uint8Array<ArrayBuffer>[]): Uint8Array<ArrayBuffer> {
40
+ const writer = new ByteWriter();
41
+ for (const chunk of chunks) {
42
+ writer.writeBytes(chunk);
43
+ }
44
+ return writer.toBytes();
45
+ }
@@ -0,0 +1,90 @@
1
+ // A JPEG's compressed byte stream passes through this whole package unchanged in both directions (embedded via a PDF Image XObject's /DCTDecode filter on write; extracted as-is on read) -- the single biggest scope reduction in the hand-written PDF codec, since no JPEG decoder or encoder is needed at all. The one piece of information still needed from a JPEG that isn't available without looking inside it is its pixel dimensions and component count, which the PDF Image XObject dictionary requires (/Width, /Height, /ColorSpace) -- this module recovers exactly that, by scanning marker segments, without decoding a single sample.
2
+ export interface JpegInfo {
3
+ readonly width: number;
4
+ readonly height: number;
5
+ readonly components: number;
6
+ readonly precision: number;
7
+ readonly progressive: boolean;
8
+ // The Adobe APP14 marker's transform byte, if present: 0 = unknown/CMYK-as-is, 1 = YCbCr, 2 = YCCK. A 4-component (CMYK) JPEG with transform 2, or an untagged 4-component JPEG, almost always needs colour inversion (/Decode [1 0 1 0 1 0 1 0]) to render correctly -- a well-known, near-universal convention rather than something this scanner can verify from the bytes alone.
9
+ readonly adobeTransform: number | undefined;
10
+ }
11
+
12
+ const SOI = 0xd8;
13
+ const EOI = 0xd9;
14
+ const APP14 = 0xee;
15
+ // SOF0 (baseline), SOF1 (extended sequential Huffman), SOF2 (progressive Huffman), SOF9 (extended sequential arithmetic), SOF10 (progressive arithmetic) -- the marker codes actually used to carry frame dimensions. SOF3/SOF5-7/SOF11/SOF13-15 (lossless / differential / hierarchical variants) are not handled, since they are not produced by mainstream PDF-embedding producers.
16
+ const SOF_MARKERS = new Set([0xc0, 0xc1, 0xc2, 0xc9, 0xca]);
17
+ const PROGRESSIVE_SOF_MARKERS = new Set([0xc2, 0xca]);
18
+ // Markers with no following length/payload: TEM, SOI, EOI, and the eight restart markers.
19
+ const NO_PAYLOAD_MARKERS = new Set([0x01, 0xd8, 0xd9, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7]);
20
+
21
+ function requireByte(bytes: Uint8Array<ArrayBuffer>, index: number): number {
22
+ const value = bytes[index];
23
+ if (value === undefined) {
24
+ throw new Error('unexpected end of JPEG data');
25
+ }
26
+ return value;
27
+ }
28
+
29
+ function readUint16BE(bytes: Uint8Array<ArrayBuffer>, offset: number): number {
30
+ return (requireByte(bytes, offset) << 8) | requireByte(bytes, offset + 1);
31
+ }
32
+
33
+ // Scans a JPEG file's marker segments for its SOF (start-of-frame) segment, recovering dimensions, component count and progressive-ness without decoding any entropy-coded scan data. Throws if the bytes don't start with SOI or no SOF marker is found before EOI/truncation.
34
+ export function readJpegInfo(bytes: Uint8Array<ArrayBuffer>): JpegInfo {
35
+ if (requireByte(bytes, 0) !== 0xff || requireByte(bytes, 1) !== SOI) {
36
+ throw new Error('not a valid JPEG file: missing SOI marker');
37
+ }
38
+
39
+ let offset = 2;
40
+ let adobeTransform: number | undefined;
41
+
42
+ while (offset < bytes.length) {
43
+ if (bytes[offset] !== 0xff) {
44
+ offset++;
45
+ continue;
46
+ }
47
+ let markerOffset = offset + 1;
48
+ while (bytes[markerOffset] === 0xff) {
49
+ markerOffset++;
50
+ }
51
+ const marker = bytes[markerOffset];
52
+ if (marker === undefined) {
53
+ break;
54
+ }
55
+ offset = markerOffset + 1;
56
+
57
+ if (marker === EOI) {
58
+ break;
59
+ }
60
+ if (NO_PAYLOAD_MARKERS.has(marker)) {
61
+ continue;
62
+ }
63
+
64
+ const segmentLength = readUint16BE(bytes, offset); // includes the 2 length bytes themselves
65
+
66
+ if (marker === APP14 && segmentLength >= 14) {
67
+ adobeTransform = requireByte(bytes, offset + 2 + 11);
68
+ }
69
+
70
+ if (SOF_MARKERS.has(marker)) {
71
+ const p = offset + 2;
72
+ const precision = requireByte(bytes, p);
73
+ const height = readUint16BE(bytes, p + 1);
74
+ const width = readUint16BE(bytes, p + 3);
75
+ const components = requireByte(bytes, p + 5);
76
+ return {
77
+ width,
78
+ height,
79
+ components,
80
+ precision,
81
+ progressive: PROGRESSIVE_SOF_MARKERS.has(marker),
82
+ adobeTransform,
83
+ };
84
+ }
85
+
86
+ offset += segmentLength;
87
+ }
88
+
89
+ throw new Error('no SOF marker found in JPEG file');
90
+ }
@@ -0,0 +1,263 @@
1
+ import { crc32 } from '../bytes/crc32';
2
+ import { inflateTolerant } from '../bytes/flate';
3
+ import { concatBytes } from '../bytes/writer';
4
+ import { unfilterScanlines } from './png-filter';
5
+
6
+ // Normalising every PNG colour type down to 8-bit gray-or-RGB plus a separate alpha plane is deliberate: it is exactly the shape a PDF Image XObject wants (/DeviceGray or /DeviceRGB, /BitsPerComponent 8, alpha as a separate /SMask /DeviceGray XObject), so the PDF writer does zero rearranging of whatever this decoder produces.
7
+ export interface RawImage {
8
+ readonly width: number;
9
+ readonly height: number;
10
+ readonly channels: 1 | 3;
11
+ readonly data: Uint8Array<ArrayBuffer>;
12
+ readonly alpha?: Uint8Array<ArrayBuffer>;
13
+ }
14
+
15
+ export interface PngDecodeOptions {
16
+ readonly onWarning?: (message: string) => void;
17
+ }
18
+
19
+ const PNG_SIGNATURE: readonly number[] = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
20
+
21
+ interface PngChunk {
22
+ readonly type: string;
23
+ readonly data: Uint8Array<ArrayBuffer>;
24
+ }
25
+
26
+ function requireDataView(bytes: Uint8Array<ArrayBuffer>): DataView {
27
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
28
+ }
29
+
30
+ function readChunks(bytes: Uint8Array<ArrayBuffer>, onWarning: ((m: string) => void) | undefined): PngChunk[] {
31
+ const chunks: PngChunk[] = [];
32
+ const view = requireDataView(bytes);
33
+ let offset = PNG_SIGNATURE.length;
34
+ while (offset + 8 <= bytes.length) {
35
+ const length = view.getUint32(offset);
36
+ const typeBytes = bytes.subarray(offset + 4, offset + 8);
37
+ const type = new TextDecoder('latin1').decode(typeBytes);
38
+ const dataStart = offset + 8;
39
+ const dataEnd = dataStart + length;
40
+ if (dataEnd + 4 > bytes.length) {
41
+ throw new Error(`PNG chunk '${type}' declares a length that runs past the end of the file`);
42
+ }
43
+ const data = bytes.subarray(dataStart, dataEnd);
44
+ if (onWarning !== undefined) {
45
+ const storedCrc = view.getUint32(dataEnd);
46
+ const computedCrc = crc32(concatBytes([typeBytes, data]));
47
+ if (storedCrc !== computedCrc) {
48
+ onWarning(`PNG chunk '${type}' failed its CRC32 check`);
49
+ }
50
+ }
51
+ chunks.push({ type, data });
52
+ offset = dataEnd + 4;
53
+ if (type === 'IEND') {
54
+ break;
55
+ }
56
+ }
57
+ return chunks;
58
+ }
59
+
60
+ interface Ihdr {
61
+ readonly width: number;
62
+ readonly height: number;
63
+ readonly bitDepth: number;
64
+ readonly colorType: number;
65
+ readonly interlace: number;
66
+ }
67
+
68
+ function parseIhdr(data: Uint8Array<ArrayBuffer>): Ihdr {
69
+ const view = requireDataView(data);
70
+ return {
71
+ width: view.getUint32(0),
72
+ height: view.getUint32(4),
73
+ bitDepth: data[8]!,
74
+ colorType: data[9]!,
75
+ interlace: data[12]!,
76
+ };
77
+ }
78
+
79
+ function channelsForColorType(colorType: number): number {
80
+ if (colorType === 0) {
81
+ return 1; // grayscale
82
+ }
83
+ if (colorType === 2) {
84
+ return 3; // truecolor
85
+ }
86
+ if (colorType === 3) {
87
+ return 1; // palette index
88
+ }
89
+ if (colorType === 4) {
90
+ return 2; // grayscale + alpha
91
+ }
92
+ if (colorType === 6) {
93
+ return 4; // truecolor + alpha
94
+ }
95
+ throw new Error(`unsupported PNG colour type: ${colorType}`);
96
+ }
97
+
98
+ // PNG's own "bpp" for filtering purposes: bytes per complete pixel, rounded up, minimum 1.
99
+ function filterBpp(bitDepth: number, channels: number): number {
100
+ return Math.max(1, Math.ceil((bitDepth * channels) / 8));
101
+ }
102
+
103
+ // Unpacks one already-unfiltered scanline into one number per sample (raw, unscaled -- 0..2^bitDepth-1 for bit depths under 16, or the 16-bit value's high byte for bitDepth 16, per this decoder's documented 16-bit handling: reduce every depth down to an 8-bit-equivalent raw sample here, and scale to a full 0..255 display range later only for grayscale, where sub-8-bit depths need it).
104
+ function unpackRow(rowBytes: Uint8Array<ArrayBuffer>, width: number, channels: number, bitDepth: number): number[] {
105
+ const sampleCount = width * channels;
106
+ const samples: number[] = new Array<number>(sampleCount);
107
+ if (bitDepth === 8) {
108
+ for (let i = 0; i < sampleCount; i++) {
109
+ samples[i] = rowBytes[i]!;
110
+ }
111
+ } else if (bitDepth === 16) {
112
+ for (let i = 0; i < sampleCount; i++) {
113
+ samples[i] = rowBytes[i * 2]!; // high byte only
114
+ }
115
+ } else {
116
+ const mask = (1 << bitDepth) - 1;
117
+ for (let i = 0; i < sampleCount; i++) {
118
+ const bitOffset = i * bitDepth;
119
+ const byteIndex = bitOffset >> 3;
120
+ const shift = 8 - bitDepth - (bitOffset & 7);
121
+ samples[i] = (rowBytes[byteIndex]! >> shift) & mask;
122
+ }
123
+ }
124
+ return samples;
125
+ }
126
+
127
+ function readTrnsGrayValue(trns: Uint8Array<ArrayBuffer>): number {
128
+ return requireDataView(trns).getUint16(0);
129
+ }
130
+
131
+ function readTrnsRgbKey(trns: Uint8Array<ArrayBuffer>): readonly [number, number, number] {
132
+ const view = requireDataView(trns);
133
+ return [view.getUint16(0), view.getUint16(2), view.getUint16(4)];
134
+ }
135
+
136
+ function scaleToByte(sample: number, bitDepth: number): number {
137
+ if (bitDepth === 16) {
138
+ return sample; // already the high byte, i.e. already 0..255
139
+ }
140
+ const maxSample = (1 << bitDepth) - 1;
141
+ return Math.round((sample * 255) / maxSample);
142
+ }
143
+
144
+ // Decodes PNG file bytes into raw, normalised pixel data. Supports colour types 0/2/3/4/6 (gray, truecolor, indexed+PLTE, gray+alpha, truecolor+alpha) at bit depths 1/2/4/8/16 as applicable, plus tRNS transparency for all three non-alpha colour types. Adam7-interlaced sources are rejected explicitly (diagnostic-worthy but essentially never produced by Office/mainstream tooling) rather than silently decoded wrong.
145
+ export function decodePng(bytes: Uint8Array<ArrayBuffer>, options: PngDecodeOptions = {}): RawImage {
146
+ for (let i = 0; i < PNG_SIGNATURE.length; i++) {
147
+ if (bytes[i] !== PNG_SIGNATURE[i]) {
148
+ throw new Error('not a valid PNG file: bad signature');
149
+ }
150
+ }
151
+
152
+ const chunks = readChunks(bytes, options.onWarning);
153
+ const ihdrChunk = chunks[0];
154
+ if (ihdrChunk?.type !== 'IHDR') {
155
+ throw new Error('PNG file does not begin with an IHDR chunk');
156
+ }
157
+ const ihdr = parseIhdr(ihdrChunk.data);
158
+ if (ihdr.interlace !== 0) {
159
+ throw new Error('Adam7-interlaced PNG images are not supported');
160
+ }
161
+
162
+ const channels = channelsForColorType(ihdr.colorType);
163
+ const bpp = filterBpp(ihdr.bitDepth, channels);
164
+ const bytesPerRow = Math.ceil((ihdr.width * channels * ihdr.bitDepth) / 8);
165
+
166
+ const idatChunks = chunks.filter((c) => c.type === 'IDAT').map((c) => c.data);
167
+ if (idatChunks.length === 0) {
168
+ throw new Error('PNG file has no IDAT chunks');
169
+ }
170
+ // Every IDAT chunk must be concatenated before inflating -- multi-IDAT files are routine (Office emits them), and inflating only the first chunk is the single most common PNG-decoder bug.
171
+ const compressed = concatBytes(idatChunks);
172
+ const { bytes: inflated, recovered } = inflateTolerant(compressed);
173
+ if (recovered && options.onWarning !== undefined) {
174
+ options.onWarning('PNG IDAT stream required tolerant recovery (truncated or malformed)');
175
+ }
176
+ const unfiltered = unfilterScanlines(inflated, ihdr.height, bytesPerRow, bpp);
177
+
178
+ const palette = ihdr.colorType === 3 ? chunks.find((c) => c.type === 'PLTE')?.data : undefined;
179
+ if (ihdr.colorType === 3 && palette === undefined) {
180
+ throw new Error('indexed-colour PNG has no PLTE chunk');
181
+ }
182
+ const trns = chunks.find((c) => c.type === 'tRNS')?.data;
183
+
184
+ return buildRawImage(ihdr, channels, bytesPerRow, unfiltered, palette, trns);
185
+ }
186
+
187
+ function buildRawImage(
188
+ ihdr: Ihdr,
189
+ channels: number,
190
+ bytesPerRow: number,
191
+ unfiltered: Uint8Array<ArrayBuffer>,
192
+ palette: Uint8Array<ArrayBuffer> | undefined,
193
+ trns: Uint8Array<ArrayBuffer> | undefined,
194
+ ): RawImage {
195
+ const { width, height, bitDepth, colorType } = ihdr;
196
+ const outChannels: 1 | 3 = colorType === 0 || colorType === 4 ? 1 : 3;
197
+ const data = new Uint8Array(width * height * outChannels);
198
+ const hasAlpha = colorType === 4 || colorType === 6 || trns !== undefined;
199
+ const alpha = hasAlpha ? new Uint8Array(width * height).fill(255) : undefined;
200
+
201
+ const trnsGray = colorType === 0 && trns !== undefined ? readTrnsGrayValue(trns) : undefined;
202
+ const trnsRgb = colorType === 2 && trns !== undefined ? readTrnsRgbKey(trns) : undefined;
203
+
204
+ for (let y = 0; y < height; y++) {
205
+ const rowStart = y * bytesPerRow;
206
+ const rowBytes = unfiltered.subarray(rowStart, rowStart + bytesPerRow);
207
+ const samples = unpackRow(rowBytes, width, channels, bitDepth);
208
+ for (let x = 0; x < width; x++) {
209
+ const pixelBase = x * channels;
210
+ const outBase = (y * width + x) * outChannels;
211
+ const alphaIndex = y * width + x;
212
+
213
+ if (colorType === 0) {
214
+ const g = samples[pixelBase]!;
215
+ data[outBase] = scaleToByte(g, bitDepth);
216
+ if (alpha !== undefined && trnsGray !== undefined) {
217
+ alpha[alphaIndex] = g === trnsGray ? 0 : 255;
218
+ }
219
+ } else if (colorType === 2) {
220
+ const r = samples[pixelBase]!;
221
+ const g = samples[pixelBase + 1]!;
222
+ const b = samples[pixelBase + 2]!;
223
+ data[outBase] = r;
224
+ data[outBase + 1] = g;
225
+ data[outBase + 2] = b;
226
+ if (alpha !== undefined && trnsRgb !== undefined) {
227
+ const [kr, kg, kb] = trnsRgb;
228
+ alpha[alphaIndex] = r === kr && g === kg && b === kb ? 0 : 255;
229
+ }
230
+ } else if (colorType === 3) {
231
+ const index = samples[pixelBase]!;
232
+ if (palette === undefined) {
233
+ throw new Error('indexed-colour PNG has no PLTE chunk');
234
+ }
235
+ data[outBase] = palette[index * 3]!;
236
+ data[outBase + 1] = palette[index * 3 + 1]!;
237
+ data[outBase + 2] = palette[index * 3 + 2]!;
238
+ if (alpha !== undefined && trns !== undefined) {
239
+ alpha[alphaIndex] = index < trns.length ? trns[index]! : 255;
240
+ }
241
+ } else if (colorType === 4) {
242
+ const g = samples[pixelBase]!;
243
+ const a = samples[pixelBase + 1]!;
244
+ data[outBase] = scaleToByte(g, bitDepth);
245
+ if (alpha !== undefined) {
246
+ alpha[alphaIndex] = scaleToByte(a, bitDepth);
247
+ }
248
+ } else {
249
+ // colorType === 6: truecolor + alpha
250
+ data[outBase] = samples[pixelBase]!;
251
+ data[outBase + 1] = samples[pixelBase + 1]!;
252
+ data[outBase + 2] = samples[pixelBase + 2]!;
253
+ if (alpha !== undefined) {
254
+ alpha[alphaIndex] = samples[pixelBase + 3]!;
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ return alpha === undefined
261
+ ? { width, height, channels: outChannels, data }
262
+ : { width, height, channels: outChannels, data, alpha };
263
+ }