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
+}