diff --git a/src/EncDotNet.Iso8211/Iso8211DataDescriptiveRecordWriter.cs b/src/EncDotNet.Iso8211/Iso8211DataDescriptiveRecordWriter.cs new file mode 100644 index 0000000..82cf287 --- /dev/null +++ b/src/EncDotNet.Iso8211/Iso8211DataDescriptiveRecordWriter.cs @@ -0,0 +1,156 @@ +using System.Text; + +namespace EncDotNet.Iso8211; + +/// +/// Encodes Data Descriptive Record (DDR) field definitions into ISO 8211 field data. +/// +/// +/// +/// This type is the symmetric inverse of . It +/// emits each field definition using the canonical two-unit-terminator layout that the reader +/// parses back into an equivalent : +/// +/// +/// Field controls — the data structure code and data type code as +/// single digits, followed by filler bytes up to the DDR's field control length. +/// Field name (NAME) — variable-length, terminated by a unit terminator. +/// Subfield labels (LBLS) — subfield names joined by !, with a +/// * prefix marking the repeating group, terminated by a unit terminator. +/// Format controls (FMTS) — the format controls string (e.g. (b11,b14)). +/// +/// +/// The resulting fields are assembled into a DDR record (leader identifier 'L') that can be +/// serialized with . +/// +/// +public static class Iso8211DataDescriptiveRecordWriter +{ + /// + /// The default field control length used for DDR records when none is specified. + /// + public const int DefaultFieldControlLength = 6; + + /// + /// Encodes a single field definition into a DDR . + /// + /// The field definition to encode. + /// The DDR field control length (number of field-control bytes). + /// Optional writer options. Defaults to . + /// The encoded DDR field. Its data excludes the trailing field terminator. + public static Iso8211Field EncodeFieldDefinition( + Iso8211FieldDefinition definition, + int fieldControlLength = DefaultFieldControlLength, + Iso8211WriterOptions? options = null) + { + ArgumentNullException.ThrowIfNull(definition); + options ??= Iso8211WriterOptions.Default; + + var unitTerminator = options.TerminatorWidth >= 2 ? new byte[] { 0x1F, 0x00 } : new byte[] { 0x1F }; + var data = new List(); + + AppendFieldControls(data, definition, fieldControlLength); + + // NAME section. + data.AddRange(Encoding.ASCII.GetBytes(definition.FieldName ?? string.Empty)); + data.AddRange(unitTerminator); + + // LBLS (subfield descriptors) section. + data.AddRange(Encoding.ASCII.GetBytes(BuildDescriptorString(definition))); + data.AddRange(unitTerminator); + + // FMTS (format controls) section. + data.AddRange(Encoding.ASCII.GetBytes(definition.FormatControls ?? string.Empty)); + + return new Iso8211Field + { + Tag = definition.Tag, + Data = data.ToArray() + }; + } + + /// + /// Builds a complete DDR from a set of field definitions. + /// + /// The field definitions to encode, in order. + /// The DDR field control length (number of field-control bytes). + /// Optional writer options. Defaults to . + /// A DDR record (leader identifier 'L'). + public static Iso8211Record BuildDdr( + IEnumerable fieldDefinitions, + int fieldControlLength = DefaultFieldControlLength, + Iso8211WriterOptions? options = null) + { + ArgumentNullException.ThrowIfNull(fieldDefinitions); + options ??= Iso8211WriterOptions.Default; + + var builder = new Iso8211RecordBuilder(options) + { + LeaderIdentifier = 'L', + FieldControlLength = fieldControlLength + }; + + foreach (var definition in fieldDefinitions) + { + builder.AddField(EncodeFieldDefinition(definition, fieldControlLength, options)); + } + + return builder.Build(); + } + + private static void AppendFieldControls(List data, Iso8211FieldDefinition definition, int fieldControlLength) + { + if (fieldControlLength >= 1) + { + data.Add((byte)('0' + (int)definition.DataStructureCode)); + } + if (fieldControlLength >= 2) + { + data.Add((byte)('0' + (int)definition.DataTypeCode)); + } + + var fillerLength = fieldControlLength - 2; + if (fillerLength > 0) + { + // Filler bytes are ignored by the reader; use the conventional S-57 trailer + // (auxiliary controls '0' padding followed by the printable graphic ';' and escape '&'). + var filler = new char[fillerLength]; + for (int i = 0; i < fillerLength; i++) + { + filler[i] = '0'; + } + if (fillerLength >= 2) + { + filler[fillerLength - 2] = ';'; + filler[fillerLength - 1] = '&'; + } + data.AddRange(Encoding.ASCII.GetBytes(filler)); + } + } + + private static string BuildDescriptorString(Iso8211FieldDefinition definition) + { + var subfields = definition.SubfieldDefinitions; + if (subfields is null || subfields.Count == 0) + { + // No subfield labels: fall back to any array descriptor, otherwise empty. + return definition.ArrayDescriptor ?? string.Empty; + } + + var repeatingStart = definition.RepeatingSubfieldStartIndex; + var sb = new StringBuilder(); + for (int i = 0; i < subfields.Count; i++) + { + if (i > 0) + { + sb.Append('!'); + } + if (i == repeatingStart) + { + sb.Append('*'); + } + sb.Append(subfields[i].Name); + } + return sb.ToString(); + } +} diff --git a/src/EncDotNet.Iso8211/Iso8211DocumentBuilder.cs b/src/EncDotNet.Iso8211/Iso8211DocumentBuilder.cs new file mode 100644 index 0000000..9f5cb98 --- /dev/null +++ b/src/EncDotNet.Iso8211/Iso8211DocumentBuilder.cs @@ -0,0 +1,43 @@ +namespace EncDotNet.Iso8211; + +/// +/// Builds an from a sequence of records. +/// +/// +/// This is a convenience aggregator over . The resulting +/// document can be serialized with . +/// +public sealed class Iso8211DocumentBuilder +{ + private readonly List _records = new(); + + /// + /// Adds a record to the document. + /// + /// The record to add. + /// This builder, for chaining. + public Iso8211DocumentBuilder AddRecord(Iso8211Record record) + { + ArgumentNullException.ThrowIfNull(record); + _records.Add(record); + return this; + } + + /// + /// Builds a record with and adds it to the document. + /// + /// The record builder to build and add. + /// This builder, for chaining. + public Iso8211DocumentBuilder AddRecord(Iso8211RecordBuilder recordBuilder) + { + ArgumentNullException.ThrowIfNull(recordBuilder); + _records.Add(recordBuilder.Build()); + return this; + } + + /// + /// Builds the . + /// + /// The built document. + public Iso8211Document Build() => new() { Records = new List(_records) }; +} diff --git a/src/EncDotNet.Iso8211/Iso8211DocumentWriter.cs b/src/EncDotNet.Iso8211/Iso8211DocumentWriter.cs new file mode 100644 index 0000000..3374531 --- /dev/null +++ b/src/EncDotNet.Iso8211/Iso8211DocumentWriter.cs @@ -0,0 +1,115 @@ +namespace EncDotNet.Iso8211; + +/// +/// Provides methods to write (serialize) an to ISO 8211 bytes. +/// +/// +/// +/// This writer is the symmetric inverse of . For a document +/// produced by the reader from a canonically-encoded source, Read → Write yields +/// byte-identical output, and Read → Write → Read yields an equivalent object model. +/// +/// +/// The leader flag characters and directory entry-map sizes are preserved from each record's +/// , while the record length and base address of the field +/// area are recomputed from the record's fields. +/// +/// +public static class Iso8211DocumentWriter +{ + /// + /// Serializes an ISO 8211 document to a byte array. + /// + /// The document to serialize. + /// Optional writer options. Defaults to . + /// The serialized ISO 8211 bytes. + public static byte[] Write(Iso8211Document document, Iso8211WriterOptions? options = null) + { + ArgumentNullException.ThrowIfNull(document); + options ??= Iso8211WriterOptions.Default; + + var output = new List(EstimateCapacity(document)); + foreach (var record in document.Records) + { + Iso8211RecordWriter.Write(record, options, output); + } + + return output.ToArray(); + } + + /// + /// Serializes an ISO 8211 document to a stream. + /// + /// The destination stream. + /// The document to serialize. + /// Optional writer options. Defaults to . + public static void Write(Stream stream, Iso8211Document document, Iso8211WriterOptions? options = null) + { + ArgumentNullException.ThrowIfNull(stream); + var bytes = Write(document, options); + stream.Write(bytes, 0, bytes.Length); + } + + /// + /// Asynchronously serializes an ISO 8211 document to a stream. + /// + /// The destination stream. + /// The document to serialize. + /// Optional writer options. Defaults to . + /// A token to cancel the operation. + /// A task that represents the asynchronous write operation. + public static async Task WriteAsync( + Stream stream, + Iso8211Document document, + Iso8211WriterOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stream); + var bytes = Write(document, options); + await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + } + + /// + /// Serializes an ISO 8211 document to a file. + /// + /// The destination file path. + /// The document to serialize. + /// Optional writer options. Defaults to . + public static void WriteToFile(string path, Iso8211Document document, Iso8211WriterOptions? options = null) + { + var bytes = Write(document, options); + File.WriteAllBytes(path, bytes); + } + + /// + /// Asynchronously serializes an ISO 8211 document to a file. + /// + /// The destination file path. + /// The document to serialize. + /// Optional writer options. Defaults to . + /// A token to cancel the operation. + /// A task that represents the asynchronous write operation. + public static async Task WriteToFileAsync( + string path, + Iso8211Document document, + Iso8211WriterOptions? options = null, + CancellationToken cancellationToken = default) + { + var bytes = Write(document, options); + await File.WriteAllBytesAsync(path, bytes, cancellationToken).ConfigureAwait(false); + } + + private static int EstimateCapacity(Iso8211Document document) + { + var total = 0; + foreach (var record in document.Records) + { + total += 32 + (record.Directory.Count * 12); + foreach (var field in record.Fields) + { + total += field.Data.Length + 1; + } + } + return total > 0 ? total : 64; + } +} diff --git a/src/EncDotNet.Iso8211/Iso8211FieldBuilder.cs b/src/EncDotNet.Iso8211/Iso8211FieldBuilder.cs new file mode 100644 index 0000000..845c31e --- /dev/null +++ b/src/EncDotNet.Iso8211/Iso8211FieldBuilder.cs @@ -0,0 +1,170 @@ +using System.Collections.Immutable; + +namespace EncDotNet.Iso8211; + +/// +/// Builds an by encoding subfield values according to a field definition. +/// +/// +/// +/// The builder encodes subfields in declaration order and, when the parent field defines a +/// repeating subfield group, wraps back to the start of the group — mirroring the parsing +/// behaviour of . Unit terminators (0x1F) are inserted +/// after variable-length subfields when another subfield follows; the trailing field terminator +/// (0x1E) is added by when the record is written. +/// +/// +public sealed class Iso8211FieldBuilder +{ + private readonly string _tag; + private readonly ImmutableArray _formats; + private readonly int _repeatingStartIndex; + private readonly Iso8211WriterOptions _options; + private readonly List _segments = new(); + private int _index; + + /// + /// Initializes a new instance of the class from a field definition. + /// + /// The field definition describing the field tag and subfield formats. + /// Optional writer options. Defaults to . + public Iso8211FieldBuilder(Iso8211FieldDefinition definition, Iso8211WriterOptions? options = null) + { + ArgumentNullException.ThrowIfNull(definition); + _tag = definition.Tag; + _formats = definition.SubfieldDefinitions.Select(s => s.Format).ToImmutableArray(); + _repeatingStartIndex = definition.RepeatingSubfieldStartIndex; + _options = options ?? Iso8211WriterOptions.Default; + } + + /// + /// Initializes a new instance of the class from explicit subfield formats. + /// + /// The field tag. + /// The subfield formats, in declaration order. + /// + /// The index at which the repeating subfield group begins, or -1 for no repeating group. + /// + /// Optional writer options. Defaults to . + public Iso8211FieldBuilder( + string tag, + IEnumerable formats, + int repeatingStartIndex = -1, + Iso8211WriterOptions? options = null) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(formats); + _tag = tag; + _formats = formats.ToImmutableArray(); + _repeatingStartIndex = repeatingStartIndex; + _options = options ?? Iso8211WriterOptions.Default; + } + + /// + /// Gets the field tag being built. + /// + public string Tag => _tag; + + /// + /// Encodes a single subfield value using the next subfield format in sequence. + /// + /// The value to encode. + /// This builder, for chaining. + /// + /// Thrown when there are no more subfield formats to consume and the field has no repeating group. + /// + public Iso8211FieldBuilder AddSubfield(object? value) + { + if (_formats.IsDefaultOrEmpty || _index >= _formats.Length) + { + throw new InvalidOperationException( + "No subfield format is available for the next value. The field has no (further) subfield definitions."); + } + + var format = _formats[_index]; + var bytes = Iso8211SubfieldEncoder.Encode(value, format, _options); + _segments.Add(new Segment(bytes, !format.IsFixedWidth)); + + _index++; + if (_index >= _formats.Length && _repeatingStartIndex >= 0) + { + _index = _repeatingStartIndex; + } + + return this; + } + + /// + /// Encodes multiple subfield values in sequence. + /// + /// The values to encode, in declaration order. + /// This builder, for chaining. + public Iso8211FieldBuilder AddSubfields(params object?[] values) + { + ArgumentNullException.ThrowIfNull(values); + foreach (var value in values) + { + AddSubfield(value); + } + return this; + } + + /// + /// Appends raw, pre-encoded bytes to the field data as a single variable-length segment. + /// + /// The raw bytes to append. + /// + /// Whether the appended segment should be followed by a unit terminator when another segment + /// follows. Defaults to true. + /// + /// This builder, for chaining. + public Iso8211FieldBuilder AddRaw(byte[] bytes, bool isVariableLength = true) + { + ArgumentNullException.ThrowIfNull(bytes); + _segments.Add(new Segment(bytes, isVariableLength)); + return this; + } + + /// + /// Builds the with the encoded subfield data. + /// + /// The built field. The data excludes the trailing field terminator. + public Iso8211Field Build() + { + var unitTerminator = UnitTerminatorBytes(_options); + var data = new List(); + + for (int i = 0; i < _segments.Count; i++) + { + data.AddRange(_segments[i].Bytes); + + var isLast = i == _segments.Count - 1; + if (!isLast && _segments[i].IsVariableLength) + { + data.AddRange(unitTerminator); + } + } + + return new Iso8211Field + { + Tag = _tag, + Data = data.ToArray() + }; + } + + private static byte[] UnitTerminatorBytes(Iso8211WriterOptions options) => + options.TerminatorWidth >= 2 ? new byte[] { 0x1F, 0x00 } : new byte[] { 0x1F }; + + private readonly struct Segment + { + public Segment(byte[] bytes, bool isVariableLength) + { + Bytes = bytes; + IsVariableLength = isVariableLength; + } + + public byte[] Bytes { get; } + + public bool IsVariableLength { get; } + } +} diff --git a/src/EncDotNet.Iso8211/Iso8211RecordBuilder.cs b/src/EncDotNet.Iso8211/Iso8211RecordBuilder.cs new file mode 100644 index 0000000..8e6cb25 --- /dev/null +++ b/src/EncDotNet.Iso8211/Iso8211RecordBuilder.cs @@ -0,0 +1,166 @@ +namespace EncDotNet.Iso8211; + +/// +/// Builds an from a set of fields, computing its directory and leader. +/// +/// +/// +/// The builder recomputes the record length, base address, directory entries, and (when +/// is enabled) the entry-map sizes +/// from the added fields, while preserving the leader flag characters configured on the builder. +/// +/// +public sealed class Iso8211RecordBuilder +{ + private readonly Iso8211WriterOptions _options; + private readonly List _fields = new(); + + /// + /// Initializes a new instance of the class. + /// + /// Optional writer options. Defaults to . + public Iso8211RecordBuilder(Iso8211WriterOptions? options = null) + { + _options = options ?? Iso8211WriterOptions.Default; + } + + /// + /// Gets or sets the interchange level character. Defaults to '3'. + /// + public char InterchangeLevel { get; set; } = '3'; + + /// + /// Gets or sets the leader identifier ('L' for a DDR, 'D' for a data record). + /// Defaults to 'D'. + /// + public char LeaderIdentifier { get; set; } = 'D'; + + /// + /// Gets or sets the inline code extension indicator. Defaults to 'E'. + /// + public char InlineCodeExtensionIndicator { get; set; } = 'E'; + + /// + /// Gets or sets the version number character. Defaults to '1'. + /// + public char VersionNumber { get; set; } = '1'; + + /// + /// Gets or sets the application indicator character. Defaults to a space. + /// + public char ApplicationIndicator { get; set; } = ' '; + + /// + /// Gets or sets the field control length recorded in the leader. Defaults to 0. + /// + public int FieldControlLength { get; set; } + + /// + /// Gets or sets the three-character extended character set indicator. Defaults to three spaces. + /// + public string ExtendedCharacterSetIndicator { get; set; } = " "; + + /// + /// Gets or sets the declared size of the field tag field. Defaults to 4. + /// + /// Ignored when is enabled. + public int SizeOfFieldTagField { get; set; } = 4; + + /// + /// Gets or sets the declared size of the field length field, or 0 to auto-size. Defaults to 0. + /// + public int SizeOfFieldLengthField { get; set; } + + /// + /// Gets or sets the declared size of the field position field, or 0 to auto-size. Defaults to 0. + /// + public int SizeOfFieldPositionField { get; set; } + + /// + /// Adds a field to the record. + /// + /// The field to add. + /// This builder, for chaining. + public Iso8211RecordBuilder AddField(Iso8211Field field) + { + ArgumentNullException.ThrowIfNull(field); + _fields.Add(field); + return this; + } + + /// + /// Builds a field with and adds it to the record. + /// + /// The field builder to build and add. + /// This builder, for chaining. + public Iso8211RecordBuilder AddField(Iso8211FieldBuilder fieldBuilder) + { + ArgumentNullException.ThrowIfNull(fieldBuilder); + _fields.Add(fieldBuilder.Build()); + return this; + } + + /// + /// Adds a field from a tag and pre-encoded field data. + /// + /// The field tag. + /// The field data, excluding the trailing field terminator. + /// This builder, for chaining. + public Iso8211RecordBuilder AddField(string tag, byte[] data) + { + ArgumentNullException.ThrowIfNull(tag); + ArgumentNullException.ThrowIfNull(data); + _fields.Add(new Iso8211Field { Tag = tag, Data = data }); + return this; + } + + /// + /// Builds the with a computed directory and leader. + /// + /// The built record. + public Iso8211Record Build() + { + var seedLeader = new Iso8211RecordLeader + { + InterchangeLevel = InterchangeLevel, + LeaderIdentifier = LeaderIdentifier, + InlineCodeExtensionIndicator = InlineCodeExtensionIndicator, + VersionNumber = VersionNumber, + ApplicationIndicator = ApplicationIndicator, + FieldControlLength = FieldControlLength, + ExtendedCharacterSetIndicator = ExtendedCharacterSetIndicator, + SizeOfFieldTagField = SizeOfFieldTagField, + SizeOfFieldLengthField = SizeOfFieldLengthField, + SizeOfFieldPositionField = SizeOfFieldPositionField + }; + + var layout = Iso8211RecordWriter.ComputeLayout(seedLeader, _fields, _options); + + var directory = new List(_fields.Count); + for (int i = 0; i < _fields.Count; i++) + { + directory.Add(new Iso8211DirectoryEntry + { + Tag = _fields[i].Tag, + Length = layout.Lengths[i], + Position = layout.Positions[i] + }); + } + + var leader = seedLeader with + { + RecordLength = layout.RecordLength, + BaseAddressOfFieldArea = layout.BaseAddress, + SizeOfFieldTagField = layout.TagSize, + SizeOfFieldLengthField = layout.LengthSize, + SizeOfFieldPositionField = layout.PositionSize + }; + + return new Iso8211Record + { + Leader = leader, + Directory = directory, + Fields = new List(_fields) + }; + } +} diff --git a/src/EncDotNet.Iso8211/Iso8211RecordWriter.cs b/src/EncDotNet.Iso8211/Iso8211RecordWriter.cs new file mode 100644 index 0000000..6889156 --- /dev/null +++ b/src/EncDotNet.Iso8211/Iso8211RecordWriter.cs @@ -0,0 +1,269 @@ +using System.Globalization; +using System.Text; + +namespace EncDotNet.Iso8211; + +/// +/// Encodes a single into its ISO 8211 byte representation. +/// +/// +/// +/// This type is the symmetric inverse of the record parsing performed by +/// . It emits the 24-byte record leader, the directory +/// (one entry per field, terminated by a field terminator), and the field area (each field's +/// data followed by a field terminator). +/// +/// +/// The record length and base address of the field area are always recomputed from the +/// record's fields, while the leader flag characters and — unless +/// is enabled — the directory +/// entry-map sizes are taken from . This yields byte-identical +/// output for canonically-encoded sources read by . +/// +/// +internal static class Iso8211RecordWriter +{ + private const int LeaderLength = 24; + private const byte FieldTerminator = 0x1E; + + /// + /// Encodes the specified record and appends the resulting bytes to . + /// + /// The record to encode. + /// The writer options. + /// The destination buffer to append the encoded record to. + public static void Write(Iso8211Record record, Iso8211WriterOptions options, List output) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(output); + + var fields = record.Fields; + var layout = ComputeLayout(record.Leader, fields, options); + + WriteLeader(output, record.Leader, layout.RecordLength, layout.BaseAddress, layout.LengthSize, layout.PositionSize, layout.TagSize); + + // Directory. + for (int i = 0; i < fields.Count; i++) + { + WriteTag(output, fields[i].Tag, layout.TagSize); + WriteNumeric(output, layout.Lengths[i], layout.LengthSize); + WriteNumeric(output, layout.Positions[i], layout.PositionSize); + } + output.Add(FieldTerminator); + + // Field area. + for (int i = 0; i < fields.Count; i++) + { + output.AddRange(fields[i].Data); + output.Add(FieldTerminator); + } + } + + /// + /// Computes the directory layout (entry-map sizes, per-field lengths and positions, base + /// address, and record length) for a record without emitting any bytes. + /// + /// The record leader supplying declared entry-map sizes. + /// The record's fields. + /// The writer options. + /// The computed record layout. + public static Iso8211RecordLayout ComputeLayout( + Iso8211RecordLeader leader, + IReadOnlyList fields, + Iso8211WriterOptions options) + { + var fieldCount = fields.Count; + var fieldLengths = new int[fieldCount]; + var fieldPositions = new int[fieldCount]; + var fieldAreaLength = 0; + + for (int i = 0; i < fieldCount; i++) + { + var length = fields[i].Data.Length + 1; // +1 for the field terminator + fieldLengths[i] = length; + fieldPositions[i] = fieldAreaLength; + fieldAreaLength += length; + } + + var tagSize = DetermineTagSize(leader, fields, options); + var lengthSize = DetermineNumericSize(leader.SizeOfFieldLengthField, fieldLengths, options); + var positionSize = DetermineNumericSize(leader.SizeOfFieldPositionField, fieldPositions, options); + + var entrySize = tagSize + lengthSize + positionSize; + var directoryBytes = entrySize * fieldCount; + var baseAddress = LeaderLength + directoryBytes + 1; // +1 for the directory field terminator + var recordLength = baseAddress + fieldAreaLength; + + return new Iso8211RecordLayout(fieldLengths, fieldPositions, tagSize, lengthSize, positionSize, baseAddress, recordLength); + } + + private static void WriteLeader( + List output, + Iso8211RecordLeader leader, + int recordLength, + int baseAddress, + int lengthSize, + int positionSize, + int tagSize) + { + var sb = new StringBuilder(LeaderLength); + sb.Append(recordLength.ToString("D5", CultureInfo.InvariantCulture)); + sb.Append(OrDefault(leader.InterchangeLevel, '3')); + sb.Append(OrDefault(leader.LeaderIdentifier, 'D')); + sb.Append(OrDefault(leader.InlineCodeExtensionIndicator, 'E')); + sb.Append(OrDefault(leader.VersionNumber, '1')); + sb.Append(OrDefault(leader.ApplicationIndicator, ' ')); + sb.Append(leader.FieldControlLength.ToString("D2", CultureInfo.InvariantCulture)); + sb.Append(baseAddress.ToString("D5", CultureInfo.InvariantCulture)); + sb.Append(FitExtendedCharset(leader.ExtendedCharacterSetIndicator)); + sb.Append((char)('0' + lengthSize)); + sb.Append((char)('0' + positionSize)); + sb.Append('0'); // Reserved. + sb.Append((char)('0' + tagSize)); + + var leaderText = sb.ToString(); + if (leaderText.Length != LeaderLength) + { + throw new InvalidOperationException($"Encoded leader length was {leaderText.Length}; expected {LeaderLength}."); + } + + output.AddRange(Encoding.ASCII.GetBytes(leaderText)); + } + + private static int DetermineTagSize(Iso8211RecordLeader leader, IReadOnlyList fields, Iso8211WriterOptions options) + { + var maxTag = 0; + foreach (var field in fields) + { + if (field.Tag.Length > maxTag) + { + maxTag = field.Tag.Length; + } + } + + if (!options.AutoSizeDirectoryEntries && leader.SizeOfFieldTagField > 0) + { + if (maxTag > leader.SizeOfFieldTagField) + { + throw new InvalidOperationException( + $"A field tag of length {maxTag} does not fit the leader-declared tag size of {leader.SizeOfFieldTagField}."); + } + return leader.SizeOfFieldTagField; + } + + return Math.Max(maxTag, 1); + } + + private static int DetermineNumericSize(int declaredSize, int[] values, Iso8211WriterOptions options) + { + var required = 1; + foreach (var value in values) + { + var digits = value == 0 ? 1 : (int)Math.Floor(Math.Log10(value)) + 1; + if (digits > required) + { + required = digits; + } + } + + if (!options.AutoSizeDirectoryEntries && declaredSize > 0) + { + if (required > declaredSize) + { + throw new InvalidOperationException( + $"A directory value requires {required} digits, which exceeds the leader-declared size of {declaredSize}."); + } + return declaredSize; + } + + return required; + } + + private static void WriteTag(List output, string tag, int tagSize) + { + tag ??= string.Empty; + for (int i = 0; i < tagSize; i++) + { + output.Add(i < tag.Length ? (byte)tag[i] : (byte)' '); + } + } + + private static void WriteNumeric(List output, int value, int size) + { + var text = value.ToString(CultureInfo.InvariantCulture); + if (text.Length > size) + { + throw new InvalidOperationException($"Value {value} does not fit in {size} digit(s)."); + } + for (int i = 0; i < size - text.Length; i++) + { + output.Add((byte)'0'); + } + for (int i = 0; i < text.Length; i++) + { + output.Add((byte)text[i]); + } + } + + private static string FitExtendedCharset(string? value) + { + value ??= " "; + if (value.Length == 3) + { + return value; + } + if (value.Length > 3) + { + return value.Substring(0, 3); + } + return value.PadRight(3, ' '); + } + + private static char OrDefault(char value, char fallback) => value == '\0' ? fallback : value; +} + +/// +/// Describes the computed directory layout of an ISO 8211 record. +/// +internal readonly struct Iso8211RecordLayout +{ + public Iso8211RecordLayout( + int[] lengths, + int[] positions, + int tagSize, + int lengthSize, + int positionSize, + int baseAddress, + int recordLength) + { + Lengths = lengths; + Positions = positions; + TagSize = tagSize; + LengthSize = lengthSize; + PositionSize = positionSize; + BaseAddress = baseAddress; + RecordLength = recordLength; + } + + /// Gets the per-field lengths, including each field's trailing terminator. + public int[] Lengths { get; } + + /// Gets the per-field positions within the field area. + public int[] Positions { get; } + + /// Gets the size of the field tag field in directory entries. + public int TagSize { get; } + + /// Gets the size of the field length field in directory entries. + public int LengthSize { get; } + + /// Gets the size of the field position field in directory entries. + public int PositionSize { get; } + + /// Gets the base address of the field area. + public int BaseAddress { get; } + + /// Gets the total record length in bytes. + public int RecordLength { get; } +} diff --git a/src/EncDotNet.Iso8211/Iso8211SubfieldEncoder.cs b/src/EncDotNet.Iso8211/Iso8211SubfieldEncoder.cs new file mode 100644 index 0000000..60a6ea5 --- /dev/null +++ b/src/EncDotNet.Iso8211/Iso8211SubfieldEncoder.cs @@ -0,0 +1,264 @@ +using System.Buffers.Binary; +using System.Globalization; +using System.Text; + +namespace EncDotNet.Iso8211; + +/// +/// Encodes individual ISO 8211 subfield values into their binary representation. +/// +/// +/// +/// This type is the symmetric inverse of the subfield conversion performed by +/// . Each method encodes a single subfield value according to +/// an ; unit and field terminators are not written here — +/// they are appended by the field/record writers. +/// +/// +internal static class Iso8211SubfieldEncoder +{ + /// + /// Encodes a single subfield value according to the supplied format. + /// + /// The value to encode. Accepted CLR types depend on the format type. + /// The subfield format describing how to encode the value. + /// The writer options controlling encoding and terminator width. + /// The encoded subfield bytes, excluding any terminator. + public static byte[] Encode(object? value, Iso8211SubfieldFormat format, Iso8211WriterOptions options) + { + return format.FormatType switch + { + Iso8211SubfieldFormatType.CharacterData => EncodeCharacter(value, format, options), + Iso8211SubfieldFormatType.Integer => EncodeAsciiInteger(value, format), + Iso8211SubfieldFormatType.Real => EncodeAsciiReal(value, format), + Iso8211SubfieldFormatType.UnsignedInteger => EncodeUnsignedBinary(value, format.Width), + Iso8211SubfieldFormatType.SignedInteger => EncodeSignedBinary(value, format.Width), + Iso8211SubfieldFormatType.FloatingPoint => EncodeFloatingBinary(value, format.Width), + Iso8211SubfieldFormatType.BitString => EncodeBitString(value, format.Width), + _ => throw new InvalidOperationException($"Unknown subfield format type: {format.FormatType}.") + }; + } + + private static byte[] EncodeCharacter(object? value, Iso8211SubfieldFormat format, Iso8211WriterOptions options) + { + var text = value switch + { + null => string.Empty, + string s => s, + byte[] raw => options.EffectiveEncoding.GetString(raw), + IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + + var encoding = options.EffectiveEncoding; + var bytes = encoding.GetBytes(text); + + if (!format.IsFixedWidth) + { + return bytes; + } + + // Fixed-width character data: pad with spaces or truncate to the exact width (in code units). + var charWidth = options.LexicalLevel >= 2 ? 2 : 1; + var targetBytes = format.Width * charWidth; + return FitToWidth(bytes, targetBytes, PadByte(encoding, charWidth)); + } + + private static byte[] EncodeAsciiInteger(object? value, Iso8211SubfieldFormat format) + { + var number = ToInt64(value); + string text; + + if (format.IsFixedWidth) + { + // Right-justify within the fixed width. The width includes any leading sign, so a + // negative value is encoded as sign + zero-padded magnitude (e.g. -5 in I(4) => "-005"). + if (number < 0) + { + var magnitude = (-number).ToString(CultureInfo.InvariantCulture); + text = "-" + magnitude.PadLeft(format.Width - 1, '0'); + } + else + { + text = number.ToString(CultureInfo.InvariantCulture).PadLeft(format.Width, '0'); + } + + if (text.Length > format.Width) + { + throw new InvalidOperationException( + $"Integer value {number} does not fit in an I({format.Width}) subfield."); + } + } + else + { + text = number.ToString(CultureInfo.InvariantCulture); + } + + return Encoding.ASCII.GetBytes(text); + } + + private static byte[] EncodeAsciiReal(object? value, Iso8211SubfieldFormat format) + { + var text = value switch + { + null => string.Empty, + string s => s, + IFormattable f => f.ToString("R", CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + + if (!format.IsFixedWidth) + { + return Encoding.ASCII.GetBytes(text); + } + + if (text.Length > format.Width) + { + throw new InvalidOperationException( + $"Real value '{text}' does not fit in an R({format.Width}) subfield."); + } + + // Right-justify within the fixed width, padding with spaces (the reader trims). + var bytes = Encoding.ASCII.GetBytes(text.PadLeft(format.Width, ' ')); + return bytes; + } + + private static byte[] EncodeUnsignedBinary(object? value, int width) + { + if (width <= 0) + { + throw new InvalidOperationException("Unsigned binary subfields require a fixed byte width."); + } + + var raw = unchecked((ulong)ToInt64(value)); + return WriteLittleEndian(raw, width); + } + + private static byte[] EncodeSignedBinary(object? value, int width) + { + if (width <= 0) + { + throw new InvalidOperationException("Signed binary subfields require a fixed byte width."); + } + + var raw = unchecked((ulong)ToInt64(value)); + return WriteLittleEndian(raw, width); + } + + private static byte[] EncodeFloatingBinary(object? value, int width) + { + var d = ToDouble(value); + switch (width) + { + case 4: + { + var bytes = new byte[4]; + BinaryPrimitives.WriteSingleLittleEndian(bytes, (float)d); + return bytes; + } + case 8: + { + var bytes = new byte[8]; + BinaryPrimitives.WriteDoubleLittleEndian(bytes, d); + return bytes; + } + default: + throw new InvalidOperationException($"Unsupported floating-point binary width: {width} byte(s)."); + } + } + + private static byte[] EncodeBitString(object? value, int width) + { + switch (value) + { + case byte[] raw: + if (width <= 0) + { + return (byte[])raw.Clone(); + } + return FitToWidth(raw, width, 0); + case string hex: + { + var raw = Convert.FromHexString(hex); + return width <= 0 ? raw : FitToWidth(raw, width, 0); + } + case null: + return width <= 0 ? Array.Empty() : new byte[width]; + default: + // Treat as an integer packed little-endian into the declared byte width. + if (width <= 0) + { + throw new InvalidOperationException("Bit string subfields require a fixed byte width for integer values."); + } + return WriteLittleEndian(unchecked((ulong)ToInt64(value)), width); + } + } + + private static byte[] WriteLittleEndian(ulong value, int width) + { + var bytes = new byte[width]; + for (int i = 0; i < width; i++) + { + bytes[i] = (byte)(value >> (i * 8)); + } + return bytes; + } + + private static byte[] FitToWidth(byte[] source, int width, byte pad) + { + if (source.Length == width) + { + return source; + } + + var result = new byte[width]; + if (source.Length > width) + { + Array.Copy(source, result, width); + } + else + { + Array.Copy(source, result, source.Length); + for (int i = source.Length; i < width; i++) + { + result[i] = pad; + } + } + return result; + } + + private static byte PadByte(Encoding encoding, int charWidth) + { + // The space padding byte; for single-byte encodings this is 0x20. + var spaceBytes = encoding.GetBytes(" "); + return spaceBytes.Length > 0 ? spaceBytes[0] : (byte)0x20; + } + + private static long ToInt64(object? value) => value switch + { + null => 0L, + long l => l, + int i => i, + uint ui => ui, + ulong ul => unchecked((long)ul), + short s => s, + ushort us => us, + byte b => b, + sbyte sb => sb, + string str => long.Parse(str.Trim(), CultureInfo.InvariantCulture), + bool boolean => boolean ? 1L : 0L, + IConvertible c => c.ToInt64(CultureInfo.InvariantCulture), + _ => throw new InvalidOperationException($"Cannot encode value of type {value.GetType().Name} as an integer.") + }; + + private static double ToDouble(object? value) => value switch + { + null => 0d, + double d => d, + float f => f, + decimal m => (double)m, + string str => double.Parse(str.Trim(), CultureInfo.InvariantCulture), + IConvertible c => c.ToDouble(CultureInfo.InvariantCulture), + _ => throw new InvalidOperationException($"Cannot encode value of type {value.GetType().Name} as a floating-point number.") + }; +} diff --git a/src/EncDotNet.Iso8211/Iso8211WriterOptions.cs b/src/EncDotNet.Iso8211/Iso8211WriterOptions.cs new file mode 100644 index 0000000..fc89549 --- /dev/null +++ b/src/EncDotNet.Iso8211/Iso8211WriterOptions.cs @@ -0,0 +1,73 @@ +using System.Text; + +namespace EncDotNet.Iso8211; + +/// +/// Options for configuring the ISO 8211 writer and builders. +/// +/// +/// +/// These options are the write-side counterpart to and +/// control how records, fields, and subfields are encoded to ISO 8211 bytes. +/// +/// +public sealed class Iso8211WriterOptions +{ + /// + /// Gets the default writer options (ASCII, lexical level 1, auto-sized directory entries). + /// + public static Iso8211WriterOptions Default { get; } = new(); + + /// + /// Gets or sets the lexical level, which determines terminator width and character encoding. + /// + /// + /// + /// Lexical level 0 and 1 use single-byte terminators (UT 0x1F, FT 0x1E) and + /// ASCII/UTF-8 character data. Lexical level 2 uses two-byte terminators (e.g. 0x1F 0x00) + /// and UCS-2 character data, consistent with . + /// + /// The default is 1. + /// + public int LexicalLevel { get; set; } = 1; + + /// + /// Gets or sets the character encoding used for character-based subfield data. + /// + /// + /// When null (the default), the encoding is derived from : + /// ASCII for levels 0/1 and Unicode (UCS-2) for level 2. + /// + public Encoding? Encoding { get; set; } + + /// + /// Gets or sets a value indicating whether directory entry field-length and field-position + /// widths are automatically sized to fit the record, overriding the sizes declared on the + /// record leader. + /// + /// + /// + /// When false (the default), the writer preserves the entry-map sizes declared on the + /// leader (, + /// , + /// ), which yields byte-identical output + /// for canonically-encoded sources. + /// + /// + /// When true, the writer computes the minimum widths required to encode the record's + /// largest field length and position. This is useful when constructing records from scratch. + /// + /// + public bool AutoSizeDirectoryEntries { get; set; } + + /// + /// Gets the terminator width in bytes implied by . + /// + internal int TerminatorWidth => LexicalLevel >= 2 ? 2 : 1; + + /// + /// Gets the effective character encoding, deriving it from when + /// is not explicitly set. + /// + internal Encoding EffectiveEncoding => Encoding ?? (LexicalLevel >= 2 ? System.Text.Encoding.Unicode : System.Text.Encoding.ASCII); +} diff --git a/src/EncDotNet.Iso8211/README.md b/src/EncDotNet.Iso8211/README.md index e3d037a..8dd5db2 100644 --- a/src/EncDotNet.Iso8211/README.md +++ b/src/EncDotNet.Iso8211/README.md @@ -9,6 +9,8 @@ A .NET 10 parser for the **ISO/IEC 8211** binary container format — the underl - Parse any ISO 8211 file (not limited to S-57 charts) - Read the Data Descriptive Record (DDR) to discover field tags, subfield names, and data types - Decode data records and iterate their fields and subfield values +- **Write ISO 8211 files** — serialize an `Iso8211Document` back to bytes (round-trippable, byte-identical for canonical sources) +- **Build records from scratch** with fluent builders and a DDR encoder - Low-allocation `ref struct` reader for streaming large files - Immutable record types for parsed data (thread-safe, LINQ-friendly) @@ -54,6 +56,48 @@ foreach (var record in document.DataRecords) } ``` +## Writing ISO 8211 + +Serialize an existing `Iso8211Document` back to bytes (the symmetric inverse of the +reader — round-trippable, and byte-identical for canonically-encoded sources): + +```csharp +using EncDotNet.Iso8211; + +var document = Iso8211DocumentReader.ReadFromFile("chart.000"); + +// Round-trip: write the document back out. +byte[] bytes = Iso8211DocumentWriter.Write(document); +Iso8211DocumentWriter.WriteToFile("chart-copy.000", document); +``` + +To construct records from scratch, use the fluent builders and the DDR encoder: + +```csharp +using System.Collections.Immutable; +using EncDotNet.Iso8211; + +// Describe the field's subfields: a 2-char code and a variable-length name. +var formats = ImmutableArray.Create( + new Iso8211SubfieldFormat { FormatType = Iso8211SubfieldFormatType.CharacterData, Width = 2 }, + new Iso8211SubfieldFormat { FormatType = Iso8211SubfieldFormatType.CharacterData, Width = 0 }); + +var field = new Iso8211FieldBuilder("EXMP", formats) + .AddSubfield("US") + .AddSubfield("EXAMPLE") + .Build(); + +var record = new Iso8211RecordBuilder() + .AddField(field) + .Build(); + +var document = new Iso8211DocumentBuilder() + .AddRecord(record) + .Build(); + +byte[] bytes = Iso8211DocumentWriter.Write(document); +``` + ## Key Types | Type | Description | @@ -63,6 +107,11 @@ foreach (var record in document.DataRecords) | `Iso8211DataDescriptiveRecord` | The parsed DDR — field definitions and subfield schemas | | `Iso8211DataDescriptiveRecordReader` | Parses the raw DDR record into a typed representation | | `Iso8211FieldReader` | Decodes subfield values from a field's binary data using the DDR schema | +| `Iso8211DocumentWriter` | Serializes an `Iso8211Document` back to ISO 8211 bytes (round-trip) | +| `Iso8211DocumentBuilder` | Fluently assembles records into a document for writing | +| `Iso8211RecordBuilder` | Builds a single record (leader flags, fields, recomputed directory) | +| `Iso8211FieldBuilder` | Builds a field's data by appending typed subfield values | +| `Iso8211DataDescriptiveRecordWriter` | Encodes field definitions into a DDR record | | `Iso8211Reader` | Low-level streaming `ref struct` reader for incremental parsing | | `Iso8211Record` | A single data record with its leader, directory entries, and fields | | `Iso8211FieldDefinition` | Defines a field's tag, structure code, data type, and subfield layout | diff --git a/tests/EndDotNet.UnitTests/Iso8211DocumentWriterTests.cs b/tests/EndDotNet.UnitTests/Iso8211DocumentWriterTests.cs new file mode 100644 index 0000000..675c37e --- /dev/null +++ b/tests/EndDotNet.UnitTests/Iso8211DocumentWriterTests.cs @@ -0,0 +1,317 @@ +using System.Text; +using EncDotNet.Iso8211; + +namespace EndDotNet.UnitTests; + +/// +/// Unit tests for , , +/// and , focusing on round-trip fidelity with +/// . +/// +public class Iso8211DocumentWriterTests +{ + private const byte UT = 0x1F; + private const byte FT = 0x1E; + + #region Byte-identical round-trip (Read -> Write) + + [Fact] + public void Write_SingleFieldRecord_IsByteIdentical() + { + var original = BuildRecordBytes('D', new[] { ("0001", "TEST"u8.ToArray()) }); + + var document = Iso8211DocumentReader.Read(original); + var written = Iso8211DocumentWriter.Write(document); + + Assert.Equal(original, written); + } + + [Fact] + public void Write_MultiFieldRecord_IsByteIdentical() + { + var original = BuildRecordBytes('D', new[] + { + ("0001", "HELLO"u8.ToArray()), + ("0002", "WORLD"u8.ToArray()) + }); + + var document = Iso8211DocumentReader.Read(original); + var written = Iso8211DocumentWriter.Write(document); + + Assert.Equal(original, written); + } + + [Fact] + public void Write_SubfieldRecordWithUnitTerminators_IsByteIdentical() + { + var fieldData = Concat("SUB1"u8.ToArray(), new[] { UT }, "SUB2"u8.ToArray(), new[] { UT }, "SUB3"u8.ToArray()); + var original = BuildRecordBytes('D', new[] { ("0001", fieldData) }); + + var document = Iso8211DocumentReader.Read(original); + var written = Iso8211DocumentWriter.Write(document); + + Assert.Equal(original, written); + } + + [Fact] + public void Write_BinaryFieldDataContainingTerminatorBytes_IsByteIdentical() + { + // Field data that includes 0x1E / 0x1F as ordinary binary payload bytes. + var fieldData = new byte[] { 0x01, 0x1E, 0x02, 0x1F, 0x03, 0xFF, 0x00 }; + var original = BuildRecordBytes('D', new[] { ("VRID", fieldData) }); + + var document = Iso8211DocumentReader.Read(original); + var written = Iso8211DocumentWriter.Write(document); + + Assert.Equal(original, written); + } + + [Fact] + public void Write_EmptyField_IsByteIdentical() + { + var original = BuildRecordBytes('D', new[] { ("0001", Array.Empty()) }); + + var document = Iso8211DocumentReader.Read(original); + var written = Iso8211DocumentWriter.Write(document); + + Assert.Equal(original, written); + } + + [Fact] + public void Write_RepeatedFieldTags_IsByteIdentical() + { + var original = BuildRecordBytes('D', new[] + { + ("ATTF", "A"u8.ToArray()), + ("ATTF", "BB"u8.ToArray()), + ("ATTF", "CCC"u8.ToArray()) + }); + + var document = Iso8211DocumentReader.Read(original); + var written = Iso8211DocumentWriter.Write(document); + + Assert.Equal(original, written); + } + + [Fact] + public void Write_MultipleRecords_IsByteIdentical() + { + var ddr = BuildRecordBytes('L', new[] { ("0001", "DDRDATA"u8.ToArray()) }); + var dr = BuildRecordBytes('D', new[] { ("FRID", "FEATURE"u8.ToArray()) }); + var original = Concat(ddr, dr); + + var document = Iso8211DocumentReader.Read(original); + var written = Iso8211DocumentWriter.Write(document); + + Assert.Equal(original, written); + Assert.Equal(2, document.Records.Count); + } + + #endregion + + #region Round-trip via builders (Write -> Read) + + [Fact] + public void Build_And_Write_DataRecord_RoundTripsThroughReader() + { + var record = new Iso8211RecordBuilder() + .AddField("0001", "HELLO"u8.ToArray()) + .AddField("0002", "WORLD"u8.ToArray()) + .Build(); + + var document = new Iso8211DocumentBuilder().AddRecord(record).Build(); + var bytes = Iso8211DocumentWriter.Write(document); + + var reparsed = Iso8211DocumentReader.Read(bytes); + + var reparsedRecord = Assert.Single(reparsed.Records); + Assert.Equal('D', reparsedRecord.Leader.LeaderIdentifier); + Assert.Equal(2, reparsedRecord.Fields.Count); + Assert.Equal("0001", reparsedRecord.Fields[0].Tag); + Assert.Equal("HELLO", reparsedRecord.Fields[0].GetDataString()); + Assert.Equal("0002", reparsedRecord.Fields[1].Tag); + Assert.Equal("WORLD", reparsedRecord.Fields[1].GetDataString()); + } + + [Fact] + public void RecordBuilder_ComputesLeaderAndDirectory() + { + var record = new Iso8211RecordBuilder() + .AddField("0001", "HELLO"u8.ToArray()) + .AddField("0002", "WORLD!"u8.ToArray()) + .Build(); + + Assert.Equal(2, record.Directory.Count); + Assert.Equal("0001", record.Directory[0].Tag); + Assert.Equal(6, record.Directory[0].Length); // "HELLO" + FT + Assert.Equal(0, record.Directory[0].Position); + Assert.Equal(7, record.Directory[1].Length); // "WORLD!" + FT + Assert.Equal(6, record.Directory[1].Position); + + // Length/position fields auto-size to a single digit here; tag size defaults to 4. + // Base address = 24 + directory(2 * (4+1+1)) + 1 FT. + Assert.Equal(24 + (2 * 6) + 1, record.Leader.BaseAddressOfFieldArea); + Assert.Equal(record.Leader.BaseAddressOfFieldArea + 6 + 7, record.Leader.RecordLength); + } + + [Fact] + public void RecordBuilder_AutoSizesDirectoryEntries_WhenRequested() + { + var options = new Iso8211WriterOptions { AutoSizeDirectoryEntries = true }; + var record = new Iso8211RecordBuilder(options) + .AddField("0001", "AB"u8.ToArray()) + .Build(); + + // "AB" + FT = length 3 (1 digit), position 0 (1 digit), tag "0001" (4). + Assert.Equal(4, record.Leader.SizeOfFieldTagField); + Assert.Equal(1, record.Leader.SizeOfFieldLengthField); + Assert.Equal(1, record.Leader.SizeOfFieldPositionField); + + // Ensure it still round-trips. + var document = new Iso8211DocumentBuilder().AddRecord(record).Build(); + var bytes = Iso8211DocumentWriter.Write(document, options); + var reparsed = Iso8211DocumentReader.Read(bytes); + Assert.Equal("AB", reparsed.Records[0].Fields[0].GetDataString()); + } + + #endregion + + #region Stream / file overloads + + [Fact] + public async Task WriteAsync_And_WriteToFile_ProduceSameBytes() + { + var original = BuildRecordBytes('D', new[] { ("0001", "TEST"u8.ToArray()) }); + var document = Iso8211DocumentReader.Read(original); + var expected = Iso8211DocumentWriter.Write(document); + + using var ms = new MemoryStream(); + await Iso8211DocumentWriter.WriteAsync(ms, document); + Assert.Equal(expected, ms.ToArray()); + + var path = Path.GetTempFileName(); + try + { + await Iso8211DocumentWriter.WriteToFileAsync(path, document); + Assert.Equal(expected, await File.ReadAllBytesAsync(path)); + + Iso8211DocumentWriter.WriteToFile(path, document); + Assert.Equal(expected, File.ReadAllBytes(path)); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Write_ThenReadFromFile_RoundTrips() + { + var original = BuildRecordBytes('D', new[] { ("0001", "ROUNDTRIP"u8.ToArray()) }); + var document = Iso8211DocumentReader.Read(original); + + var path = Path.GetTempFileName(); + try + { + Iso8211DocumentWriter.WriteToFile(path, document); + var reparsed = Iso8211DocumentReader.ReadFromFile(path); + Assert.Equal("ROUNDTRIP", reparsed.Records[0].Fields[0].GetDataString()); + } + finally + { + File.Delete(path); + } + } + + #endregion + + #region Optional real-file corpus (self-skipping) + + [Fact] + public void Corpus_ReadWriteRead_IsEquivalent() + { + // Populated only when a fixtures directory is provided via environment variable, + // so CI remains green without shipping large binary charts. + var dir = Environment.GetEnvironmentVariable("ISO8211_CORPUS_DIR"); + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + { + return; + } + + var files = Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories) + .Where(f => + { + var ext = Path.GetExtension(f); + return ext.Equals(".000", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".031", StringComparison.OrdinalIgnoreCase); + }); + + foreach (var path in files) + { + var original = File.ReadAllBytes(path); + var document = Iso8211DocumentReader.Read(original); + var written = Iso8211DocumentWriter.Write(document); + + // Read -> Write -> Read must yield an equivalent object model; canonical sources + // additionally round-trip byte-for-byte. + var reparsed = Iso8211DocumentReader.Read(written); + Assert.Equal(document.Records.Count, reparsed.Records.Count); + for (int i = 0; i < document.Records.Count; i++) + { + var a = document.Records[i]; + var b = reparsed.Records[i]; + Assert.Equal(a.Fields.Count, b.Fields.Count); + for (int j = 0; j < a.Fields.Count; j++) + { + Assert.Equal(a.Fields[j].Tag, b.Fields[j].Tag); + Assert.Equal(a.Fields[j].Data, b.Fields[j].Data); + } + } + } + } + + #endregion + + #region Helpers + + /// + /// Builds a canonical ISO 8211 record (24-byte leader, directory, field area) using the + /// same layout the reader expects: tag size 4, length size 3, position size 3. + /// + private static byte[] BuildRecordBytes(char leaderIdentifier, IReadOnlyList<(string Tag, byte[] Data)> fields) + { + var directory = new List(); + var fieldArea = new List(); + var position = 0; + + foreach (var (tag, data) in fields) + { + var length = data.Length + 1; // +1 for FT + directory.AddRange(Encoding.ASCII.GetBytes($"{tag}{length:D3}{position:D3}")); + fieldArea.AddRange(data); + fieldArea.Add(FT); + position += length; + } + directory.Add(FT); // directory terminator + + var baseAddress = 24 + directory.Count; + var recordLength = baseAddress + fieldArea.Count; + + var leader = Encoding.ASCII.GetBytes( + $"{recordLength:D5}3{leaderIdentifier}E1 00{baseAddress:D5} 3304"); + + return Concat(leader, directory.ToArray(), fieldArea.ToArray()); + } + + private static byte[] Concat(params byte[][] arrays) + { + var result = new List(); + foreach (var array in arrays) + { + result.AddRange(array); + } + return result.ToArray(); + } + + #endregion +} diff --git a/tests/EndDotNet.UnitTests/Iso8211SubfieldEncoderTests.cs b/tests/EndDotNet.UnitTests/Iso8211SubfieldEncoderTests.cs new file mode 100644 index 0000000..b56011e --- /dev/null +++ b/tests/EndDotNet.UnitTests/Iso8211SubfieldEncoderTests.cs @@ -0,0 +1,372 @@ +using System.Collections.Immutable; +using EncDotNet.Iso8211; + +namespace EndDotNet.UnitTests; + +/// +/// Unit tests for , , and +/// . Each subfield format type is verified by +/// encoding a value and decoding it back with . +/// +public class Iso8211SubfieldEncoderTests +{ + private static readonly Iso8211WriterOptions Options = Iso8211WriterOptions.Default; + + #region Direct binary encoding + + [Fact] + public void Encode_UnsignedBinary_IsLittleEndian() + { + var format = Format(Iso8211SubfieldFormatType.UnsignedInteger, 2); + var bytes = Iso8211SubfieldEncoder.Encode((ushort)0x1234, format, Options); + Assert.Equal(new byte[] { 0x34, 0x12 }, bytes); + } + + [Fact] + public void Encode_UnsignedBinary_SingleByte() + { + var format = Format(Iso8211SubfieldFormatType.UnsignedInteger, 1); + var bytes = Iso8211SubfieldEncoder.Encode((byte)0xAB, format, Options); + Assert.Equal(new byte[] { 0xAB }, bytes); + } + + [Fact] + public void Encode_SignedBinary_NegativeIsTwosComplementLittleEndian() + { + var format = Format(Iso8211SubfieldFormatType.SignedInteger, 4); + var bytes = Iso8211SubfieldEncoder.Encode(-1, format, Options); + Assert.Equal(new byte[] { 0xFF, 0xFF, 0xFF, 0xFF }, bytes); + } + + [Fact] + public void Encode_SignedBinary_KnownValue() + { + var format = Format(Iso8211SubfieldFormatType.SignedInteger, 4); + var bytes = Iso8211SubfieldEncoder.Encode(-2, format, Options); + Assert.Equal(new byte[] { 0xFE, 0xFF, 0xFF, 0xFF }, bytes); + } + + [Fact] + public void Encode_FloatingBinary_Double_RoundTrips() + { + var format = Format(Iso8211SubfieldFormatType.FloatingPoint, 8); + var bytes = Iso8211SubfieldEncoder.Encode(3.14159, format, Options); + Assert.Equal(8, bytes.Length); + Assert.Equal(3.14159, System.Buffers.Binary.BinaryPrimitives.ReadDoubleLittleEndian(bytes), 10); + } + + [Fact] + public void Encode_FloatingBinary_Single_RoundTrips() + { + var format = Format(Iso8211SubfieldFormatType.FloatingPoint, 4); + var bytes = Iso8211SubfieldEncoder.Encode(1.5f, format, Options); + Assert.Equal(4, bytes.Length); + Assert.Equal(1.5f, System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(bytes)); + } + + [Fact] + public void Encode_FixedCharacter_PadsWithSpaces() + { + var format = Format(Iso8211SubfieldFormatType.CharacterData, 6); + var bytes = Iso8211SubfieldEncoder.Encode("AB", format, Options); + Assert.Equal(System.Text.Encoding.ASCII.GetBytes("AB "), bytes); + } + + [Fact] + public void Encode_FixedInteger_ZeroPadsRightJustified() + { + var format = Format(Iso8211SubfieldFormatType.Integer, 5); + var bytes = Iso8211SubfieldEncoder.Encode(42, format, Options); + Assert.Equal(System.Text.Encoding.ASCII.GetBytes("00042"), bytes); + } + + [Fact] + public void Encode_FixedInteger_NegativeIncludesSign() + { + var format = Format(Iso8211SubfieldFormatType.Integer, 4); + var bytes = Iso8211SubfieldEncoder.Encode(-5, format, Options); + Assert.Equal(System.Text.Encoding.ASCII.GetBytes("-005"), bytes); + } + + [Fact] + public void Encode_BitString_RawBytes() + { + var format = Format(Iso8211SubfieldFormatType.BitString, 2); + var bytes = Iso8211SubfieldEncoder.Encode(new byte[] { 0xDE, 0xAD }, format, Options); + Assert.Equal(new byte[] { 0xDE, 0xAD }, bytes); + } + + #endregion + + #region Encode -> decode round-trips through Iso8211FieldReader + + [Fact] + public void RoundTrip_FixedBinarySubfields() + { + var definition = FieldDefinition( + tag: "FRID", + Iso8211DataStructureCode.Array, + Iso8211DataTypeCode.Binary, + "(b11,b14)", + repeatingStart: -1, + ("RCNM", Format(Iso8211SubfieldFormatType.UnsignedInteger, 1)), + ("RCID", Format(Iso8211SubfieldFormatType.UnsignedInteger, 4))); + + var field = new Iso8211FieldBuilder(definition) + .AddSubfield((byte)110) + .AddSubfield((uint)123456) + .Build(); + + var reader = new Iso8211FieldReader(definition, field.Data); + Assert.Equal(110, reader.GetSubfield("RCNM")); + Assert.Equal(123456u, reader.GetSubfield("RCID")); + } + + [Fact] + public void RoundTrip_SignedBinarySubfields() + { + var definition = FieldDefinition( + tag: "TEST", + Iso8211DataStructureCode.Array, + Iso8211DataTypeCode.Binary, + "(b24,b24)", + repeatingStart: -1, + ("YCOO", Format(Iso8211SubfieldFormatType.SignedInteger, 4)), + ("XCOO", Format(Iso8211SubfieldFormatType.SignedInteger, 4))); + + var field = new Iso8211FieldBuilder(definition) + .AddSubfield(-100) + .AddSubfield(200) + .Build(); + + var reader = new Iso8211FieldReader(definition, field.Data); + Assert.Equal(-100, reader.GetSubfield("YCOO")); + Assert.Equal(200, reader.GetSubfield("XCOO")); + } + + [Fact] + public void RoundTrip_VariableCharacterSubfields() + { + var definition = FieldDefinition( + tag: "DSID", + Iso8211DataStructureCode.Array, + Iso8211DataTypeCode.MixedDataTypes, + "(A,A)", + repeatingStart: -1, + ("DSNM", Format(Iso8211SubfieldFormatType.CharacterData, 0)), + ("EDTN", Format(Iso8211SubfieldFormatType.CharacterData, 0))); + + var field = new Iso8211FieldBuilder(definition) + .AddSubfield("US5WA22M.000") + .AddSubfield("2") + .Build(); + + var reader = new Iso8211FieldReader(definition, field.Data); + Assert.Equal("US5WA22M.000", reader.GetSubfield("DSNM")); + Assert.Equal("2", reader.GetSubfield("EDTN")); + } + + [Fact] + public void RoundTrip_MixedFixedAndVariableSubfields() + { + var definition = FieldDefinition( + tag: "MIXD", + Iso8211DataStructureCode.Array, + Iso8211DataTypeCode.MixedDataTypes, + "(b11,A,I(4))", + repeatingStart: -1, + ("CODE", Format(Iso8211SubfieldFormatType.UnsignedInteger, 1)), + ("NAME", Format(Iso8211SubfieldFormatType.CharacterData, 0)), + ("NUMB", Format(Iso8211SubfieldFormatType.Integer, 4))); + + var field = new Iso8211FieldBuilder(definition) + .AddSubfields((byte)7, "HELLO", 42) + .Build(); + + var reader = new Iso8211FieldReader(definition, field.Data); + Assert.Equal(7, reader.GetSubfield("CODE")); + Assert.Equal("HELLO", reader.GetSubfield("NAME")); + Assert.Equal(42, reader.GetSubfield("NUMB")); + } + + [Fact] + public void RoundTrip_RepeatingGroup() + { + var definition = FieldDefinition( + tag: "SG2D", + Iso8211DataStructureCode.Vector, + Iso8211DataTypeCode.Binary, + "(2b24)", + repeatingStart: 0, + ("YCOO", Format(Iso8211SubfieldFormatType.SignedInteger, 4)), + ("XCOO", Format(Iso8211SubfieldFormatType.SignedInteger, 4))); + + var field = new Iso8211FieldBuilder(definition) + .AddSubfields(10, 20) // group 0 + .AddSubfields(30, 40) // group 1 + .AddSubfields(50, 60) // group 2 + .Build(); + + var reader = new Iso8211FieldReader(definition, field.Data); + var ys = reader.GetSubfieldValues("YCOO"); + var xs = reader.GetSubfieldValues("XCOO"); + Assert.Equal(new[] { 10, 30, 50 }, ys); + Assert.Equal(new[] { 20, 40, 60 }, xs); + } + + #endregion + + #region DDR writer round-trips through the DDR reader + + [Fact] + public void Ddr_EncodeFieldDefinition_RoundTripsNonRepeating() + { + var definition = FieldDefinition( + tag: "FRID", + Iso8211DataStructureCode.Array, + Iso8211DataTypeCode.Binary, + "(b11,b14)", + repeatingStart: -1, + fieldName: "Feature record identifier", + ("RCNM", Format(Iso8211SubfieldFormatType.UnsignedInteger, 1)), + ("RCID", Format(Iso8211SubfieldFormatType.UnsignedInteger, 4))); + + var ddr = Iso8211DataDescriptiveRecordWriter.BuildDdr(new[] { definition }); + var document = new Iso8211DocumentBuilder().AddRecord(ddr).Build(); + var bytes = Iso8211DocumentWriter.Write(document); + + var reparsed = Iso8211DocumentReader.Read(bytes); + var parsedDdr = Iso8211DataDescriptiveRecordReader.Read(reparsed.Records[0]); + + var def = Assert.Single(parsedDdr.FieldDefinitions); + Assert.Equal("FRID", def.Tag); + Assert.Equal(Iso8211DataStructureCode.Array, def.DataStructureCode); + Assert.Equal(Iso8211DataTypeCode.Binary, def.DataTypeCode); + Assert.Equal("Feature record identifier", def.FieldName); + Assert.Equal(-1, def.RepeatingSubfieldStartIndex); + Assert.Equal(2, def.SubfieldDefinitions.Count); + Assert.Equal("RCNM", def.SubfieldDefinitions[0].Name); + Assert.Equal(Iso8211SubfieldFormatType.UnsignedInteger, def.SubfieldDefinitions[0].Format.FormatType); + Assert.Equal(1, def.SubfieldDefinitions[0].Format.Width); + Assert.Equal("RCID", def.SubfieldDefinitions[1].Name); + Assert.Equal(4, def.SubfieldDefinitions[1].Format.Width); + } + + [Fact] + public void Ddr_EncodeFieldDefinition_RoundTripsRepeatingGroup() + { + var definition = FieldDefinition( + tag: "SG2D", + Iso8211DataStructureCode.Vector, + Iso8211DataTypeCode.Binary, + "(2b24)", + repeatingStart: 0, + fieldName: "2D coordinate", + ("YCOO", Format(Iso8211SubfieldFormatType.SignedInteger, 4)), + ("XCOO", Format(Iso8211SubfieldFormatType.SignedInteger, 4))); + + var ddr = Iso8211DataDescriptiveRecordWriter.BuildDdr(new[] { definition }); + var document = new Iso8211DocumentBuilder().AddRecord(ddr).Build(); + var bytes = Iso8211DocumentWriter.Write(document); + + var reparsed = Iso8211DocumentReader.Read(bytes); + var parsedDdr = Iso8211DataDescriptiveRecordReader.Read(reparsed.Records[0]); + + var def = Assert.Single(parsedDdr.FieldDefinitions); + Assert.Equal("SG2D", def.Tag); + Assert.Equal(Iso8211DataStructureCode.Vector, def.DataStructureCode); + Assert.Equal(0, def.RepeatingSubfieldStartIndex); + Assert.Equal(2, def.SubfieldDefinitions.Count); + Assert.Equal("YCOO", def.SubfieldDefinitions[0].Name); + Assert.Equal("XCOO", def.SubfieldDefinitions[1].Name); + } + + [Fact] + public void Ddr_FullDocument_WithDataRecord_RoundTrips() + { + var definition = FieldDefinition( + tag: "FRID", + Iso8211DataStructureCode.Array, + Iso8211DataTypeCode.Binary, + "(b11,b14)", + repeatingStart: -1, + ("RCNM", Format(Iso8211SubfieldFormatType.UnsignedInteger, 1)), + ("RCID", Format(Iso8211SubfieldFormatType.UnsignedInteger, 4))); + + var ddr = Iso8211DataDescriptiveRecordWriter.BuildDdr(new[] { definition }); + + var dataRecord = new Iso8211RecordBuilder() + .AddField(new Iso8211FieldBuilder(definition).AddSubfields((byte)100, (uint)555).Build()) + .Build(); + + var document = new Iso8211DocumentBuilder() + .AddRecord(ddr) + .AddRecord(dataRecord) + .Build(); + + var bytes = Iso8211DocumentWriter.Write(document); + var reparsed = Iso8211DocumentReader.Read(bytes); + + Assert.Equal(2, reparsed.Records.Count); + Assert.True(reparsed.Records[0].IsDataDescriptiveRecord); + Assert.False(reparsed.Records[1].IsDataDescriptiveRecord); + + var parsedDdr = Iso8211DataDescriptiveRecordReader.Read(reparsed.Records[0]); + var def = parsedDdr.FieldDefinitions.Single(d => d.Tag == "FRID"); + + var fieldReader = new Iso8211FieldReader(def, reparsed.Records[1].GetFieldByTag("FRID")!.Data); + Assert.Equal(100, fieldReader.GetSubfield("RCNM")); + Assert.Equal(555u, fieldReader.GetSubfield("RCID")); + } + + #endregion + + #region Helpers + + private static Iso8211SubfieldFormat Format(Iso8211SubfieldFormatType type, int width) => + new() { FormatType = type, Width = width }; + + private static Iso8211FieldDefinition FieldDefinition( + string tag, + Iso8211DataStructureCode structureCode, + Iso8211DataTypeCode typeCode, + string formatControls, + int repeatingStart, + params (string Name, Iso8211SubfieldFormat Format)[] subfields) + => FieldDefinition(tag, structureCode, typeCode, formatControls, repeatingStart, fieldName: tag, subfields); + + private static Iso8211FieldDefinition FieldDefinition( + string tag, + Iso8211DataStructureCode structureCode, + Iso8211DataTypeCode typeCode, + string formatControls, + int repeatingStart, + string fieldName, + params (string Name, Iso8211SubfieldFormat Format)[] subfields) + { + var defs = ImmutableArray.CreateBuilder(); + for (int i = 0; i < subfields.Length; i++) + { + defs.Add(new Iso8211SubfieldDefinition + { + Name = subfields[i].Name, + Format = subfields[i].Format, + Index = i, + IsRepeating = repeatingStart >= 0 && i >= repeatingStart + }); + } + + return new Iso8211FieldDefinition + { + Tag = tag, + DataStructureCode = structureCode, + DataTypeCode = typeCode, + FieldName = fieldName, + FormatControls = formatControls, + SubfieldDefinitions = defs.ToImmutable(), + RepeatingSubfieldStartIndex = repeatingStart + }; + } + + #endregion +}