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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
505 changes: 505 additions & 0 deletions Compression.Core/Dictionary/Lzw/NuLzwCodec.cs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Compression.Core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ Use the concrete version you intend to consume; this document does not predict a

<!-- API:BEGIN generated by Hawkynt/RepositoryTemplate/package-readme — edit the XML docs in source, not here -->

Every public and protected member of all 537 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Compression.Core/REFERENCE.md).
Every public and protected member of all 540 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Compression.Core/REFERENCE.md).

<!-- API:END -->

Expand Down
37 changes: 36 additions & 1 deletion Compression.Core/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2100,7 +2100,7 @@ Decompresses data produced by `LzvnCompressor`.

### Namespace `Compression.Core.Dictionary.Lzw`

[`LzwBuildingBlock`](#lzwbuildingblock) · [`LzwCompressionLevel`](#lzwcompressionlevel) · [`LzwDecoder`](#lzwdecoder) · [`LzwEncoder`](#lzwencoder)
[`LzwBuildingBlock`](#lzwbuildingblock) · [`LzwCompressionLevel`](#lzwcompressionlevel) · [`LzwDecoder`](#lzwdecoder) · [`LzwEncoder`](#lzwencoder) · [`NuLzwBuildingBlock`](#nulzwbuildingblock) · [`NuLzwCodec`](#nulzwcodec) · [`NuLzwVariant`](#nulzwvariant)

#### `LzwBuildingBlock`

Expand Down Expand Up @@ -2148,6 +2148,41 @@ Encodes data using the LZW (Lempel-Ziv-Welch) algorithm with variable-width code
| `StopCode` | `int StopCode { get; }` | Gets the stop code value, or -1 if stop codes are disabled. |
| `Encode` | `void Encode(ReadOnlySpan<byte> data)` | Encodes the input data and writes compressed LZW codes to the output stream. |

#### `NuLzwBuildingBlock`

Benchmarkable raw building block for GS/ShrinkIt LZW/2.

Implements `IBuildingBlock`.

| Member | Signature | Summary |
| --- | --- | --- |
| `NuLzwBuildingBlock` | `NuLzwBuildingBlock()` | |
| `Description` | `string Description { get; }` | |
| `DisplayName` | `string DisplayName { get; }` | |
| `Family` | `AlgorithmFamily Family { get; }` | |
| `Id` | `string Id { get; }` | |
| `Compress` | `byte[] Compress(ReadOnlySpan<byte> data)` | |
| `Decompress` | `byte[] Decompress(ReadOnlySpan<byte> data)` | |

#### `NuLzwCodec`

Apple II NuFX/ShrinkIt RLE + LZW codec.

| Member | Signature | Summary |
| --- | --- | --- |
| `Compress` | `static byte[] Compress(ReadOnlySpan<byte> data, NuLzwVariant variant, byte volumeNumber = 254, byte rleDelimiter = 219)` | Compresses a native ShrinkIt LZW/1 or LZW/2 stream. |
| `Crc16Xmodem` | `static ushort Crc16Xmodem(ReadOnlySpan<byte> data, ushort seed = 0)` | Computes CRC-16/XMODEM (poly 0x1021, refin=false, refout=false) from an arbitrary seed. |
| `Decompress` | `static byte[] Decompress(ReadOnlySpan<byte> data, NuLzwVariant variant, int expandedLength)` | Expands a native ShrinkIt stream to exactly `expandedLength` logical bytes. |

#### `NuLzwVariant`

NuFX/ShrinkIt LZW dialect.

| Value | Numeric | Summary |
| --- | --- | --- |
| `Lzw1` | `0` | Original ProDOS ShrinkIt LZW/1: dictionary resets for every 4096-byte chunk and the stream carries CRC-16/XMODEM. |
| `Lzw2` | `1` | GS/ShrinkIt LZW/2: dictionary may persist between 4096-byte chunks and integrity is supplied by the NuFX thread header. |

### Namespace `Compression.Core.Dictionary.Lzwl`

[`LzwlBuildingBlock`](#lzwlbuildingblock)
Expand Down
132 changes: 132 additions & 0 deletions Compression.Tests/BuildingBlocks/NuLzwTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System.Buffers.Binary;
using System.Text;
using Compression.Core.Dictionary.Lzw;

namespace Compression.Tests.BuildingBlocks;

[TestFixture]
public sealed class NuLzwTests {
[Test]
public void Crc16Xmodem_MatchesCheckValue() {
var crc = NuLzwCodec.Crc16Xmodem(Encoding.ASCII.GetBytes("123456789"));
Assert.That(crc, Is.EqualTo(0x31C3));
}

[TestCase(NuLzwVariant.Lzw1)]
[TestCase(NuLzwVariant.Lzw2)]
public void EmptyStream_HasCanonicalHeaderAndRoundTrips(NuLzwVariant variant) {
var packed = NuLzwCodec.Compress([], variant);
Assert.That(packed, Is.EqualTo(variant == NuLzwVariant.Lzw1
? new byte[] { 0x00, 0x00, 0xFE, 0xDB }
: new byte[] { 0xFE, 0xDB }));
Assert.That(NuLzwCodec.Decompress(packed, variant, 0), Is.Empty);
}

[TestCase(NuLzwVariant.Lzw1, 1)]
[TestCase(NuLzwVariant.Lzw1, 4095)]
[TestCase(NuLzwVariant.Lzw1, 4096)]
[TestCase(NuLzwVariant.Lzw1, 4097)]
[TestCase(NuLzwVariant.Lzw1, 16385)]
[TestCase(NuLzwVariant.Lzw2, 1)]
[TestCase(NuLzwVariant.Lzw2, 4095)]
[TestCase(NuLzwVariant.Lzw2, 4096)]
[TestCase(NuLzwVariant.Lzw2, 4097)]
[TestCase(NuLzwVariant.Lzw2, 16385)]
public void RoundTrip_RunHeavyAndPartialChunks(NuLzwVariant variant, int length) {
var data = Enumerable.Range(0, length)
.Select(i => (byte)((i / 37) % 11 == 0 ? 0xDB : (i / 113) % 7))
.ToArray();

var packed = NuLzwCodec.Compress(data, variant);
var unpacked = NuLzwCodec.Decompress(packed, variant, data.Length);

Assert.That(unpacked, Is.EqualTo(data));
}

[TestCase(NuLzwVariant.Lzw1)]
[TestCase(NuLzwVariant.Lzw2)]
public void RoundTrip_IncompressibleChunks(NuLzwVariant variant) {
var data = new byte[12289];
var state = 0x12345678u;
for (var i = 0; i < data.Length; i++) {
state = state * 1664525u + 1013904223u;
data[i] = (byte)(state >> 24);
}

var packed = NuLzwCodec.Compress(data, variant);
Assert.That(NuLzwCodec.Decompress(packed, variant, data.Length), Is.EqualTo(data));
}

[Test]
public void Lzw2_PersistentDictionaryCrossesManyChunksAndCodeWidths() {
var data = new byte[96 * 1024 + 321];
var state = 0xCAFEBABEu;
for (var i = 0; i < data.Length; i++) {
state = state * 1103515245u + 12345u;
data[i] = (byte)((state >> 24) & 0x1F);
}

var packed = NuLzwCodec.Compress(data, NuLzwVariant.Lzw2);
var unpacked = NuLzwCodec.Decompress(packed, NuLzwVariant.Lzw2, data.Length);

Assert.That(unpacked, Is.EqualTo(data));
Assert.That(packed.Length, Is.LessThan(data.Length));
}

[Test]
public void Lzw1_CrcCoversZeroPaddedFinalChunk() {
var data = Enumerable.Repeat((byte)0x41, 5000).ToArray();
var packed = NuLzwCodec.Compress(data, NuLzwVariant.Lzw1);
packed[0] ^= 0x01;

Assert.Throws<InvalidDataException>(() =>
NuLzwCodec.Decompress(packed, NuLzwVariant.Lzw1, data.Length));
}

[Test]
public void Lzw2_IgnoresTrailingShrinkItPadByte() {
var data = Enumerable.Range(0, 9000).Select(i => (byte)(i % 13)).ToArray();
var packed = NuLzwCodec.Compress(data, NuLzwVariant.Lzw2);
var padded = packed.Concat(new byte[] { 0x00 }).ToArray();

Assert.That(NuLzwCodec.Decompress(padded, NuLzwVariant.Lzw2, data.Length), Is.EqualTo(data));
}

[Test]
public void Lzw2_IgnoresBogusCompressedLengthHintFromBadMacArchives() {
var data = Enumerable.Range(0, 4096).Select(i => (byte)(i % 7)).ToArray();
var packed = NuLzwCodec.Compress(data, NuLzwVariant.Lzw2);

var postRle = BinaryPrimitives.ReadUInt16LittleEndian(packed.AsSpan(2, 2));
Assert.That(postRle & 0x8000, Is.Not.Zero, "Fixture must select the LZW/2 chunk form.");

// The LZW/2 word at +4 is only a recovery hint. Historical Macintosh-created
// archives exist with this value byte-swapped or otherwise wrong. ShrinkIt-compatible
// decoding stops when the declared expanded output has been produced, not at this hint.
BinaryPrimitives.WriteUInt16LittleEndian(packed.AsSpan(4, 2), 1);

Assert.That(NuLzwCodec.Decompress(packed, NuLzwVariant.Lzw2, data.Length), Is.EqualTo(data));
}

[Test]
public void BuildingBlock_EnvelopeCarriesExpandedLength() {
var block = new NuLzwBuildingBlock();
var data = Enumerable.Range(0, 10000).Select(i => (byte)((i * 7) & 0x3F)).ToArray();

var packed = block.Compress(data);
var unpacked = block.Decompress(packed);

Assert.That(block.Id, Is.EqualTo("BB_NuLzw"));
Assert.That(unpacked, Is.EqualTo(data));
}

[Test]
public void TruncatedBitstreamIsRejected() {
var data = Enumerable.Range(0, 8192).Select(i => (byte)(i % 17)).ToArray();
var packed = NuLzwCodec.Compress(data, NuLzwVariant.Lzw2);
Array.Resize(ref packed, packed.Length - 7);

Assert.Throws<InvalidDataException>(() =>
NuLzwCodec.Decompress(packed, NuLzwVariant.Lzw2, data.Length));
}
}
58 changes: 58 additions & 0 deletions Compression.Tests/NuFx/NuFxSqueezeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Buffers.Binary;
using Compression.Registry;
using FileFormat.NuFx;

namespace Compression.Tests.NuFx;

[TestFixture]
public sealed class NuFxSqueezeTests {
[Test]
public void SqueezeThread_IsHeaderlessAndRoundTripsRleEdgeCases() {
var data = new List<byte>();
data.AddRange(Enumerable.Repeat((byte)'A', 255));
data.AddRange(Enumerable.Repeat((byte)'A', 17));
data.AddRange(Enumerable.Repeat((byte)0x90, 8));
data.AddRange(Enumerable.Range(0, 256).Select(i => (byte)i));
var expected = data.ToArray();

var descriptor = new NuFxFormatDescriptor();
using var archive = new MemoryStream();
descriptor.Create(archive, [ArchiveInputInfo.InMemory("A", expected)],
new FormatCreateOptions { MethodName = "squeeze" });

var bytes = archive.ToArray();
const int recordStart = 48;
var attribCount = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(recordStart + 6, 2));
var threadCount = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(recordStart + 0x0A, 4));
Assert.That(threadCount, Is.EqualTo(2u));

var filenameThread = recordStart + attribCount;
var filenameFieldLength = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(filenameThread + 12, 4));
var compressedStart = checked(recordStart + attribCount + (int)threadCount * 16 + (int)filenameFieldLength);

// NuFX thread format 1 starts directly with the Squeeze node count. A standalone
// Squeeze file would start 76 FF and is not legal in this thread representation.
Assert.That(bytes.AsSpan(compressedStart, 2).SequenceEqual(new byte[] { 0x76, 0xFF }), Is.False);
var nodeCount = BinaryPrimitives.ReadUInt16LittleEndian(bytes.AsSpan(compressedStart, 2));
Assert.That(nodeCount, Is.InRange(1, 256));

archive.Position = 0;
Assert.That(descriptor.ExtractEntryToMemory(archive, "A", null), Is.EqualTo(expected));
}

[Test]
public void SqueezeThread_EmptyPayloadUsesZeroNodeTree() {
var descriptor = new NuFxFormatDescriptor();
using var archive = new MemoryStream();
descriptor.Create(archive, [ArchiveInputInfo.InMemory("EMPTY", Array.Empty<byte>())],
new FormatCreateOptions { MethodName = "squeeze" });

archive.Position = 0;
var entry = descriptor.List(archive, null).Single();
Assert.That(entry.Method, Is.EqualTo("Squeeze"));
Assert.That(entry.CompressedSize, Is.EqualTo(2));

archive.Position = 0;
Assert.That(descriptor.ExtractEntryToMemory(archive, "EMPTY", null), Is.Empty);
}
}
Loading
Loading