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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# _next_

- Add an opt-in `ignoreWhitespace` parameter to `fromHex`, `fromBase32`,
`fromBase64`, and their `tryFrom` counterparts. When `true`, ASCII whitespace
(space, tab, line feed, vertical tab, form feed, carriage return) in the input
is skipped instead of rejected, so line-wrapped payloads such as PEM and MIME
decode without pre-processing. It is `false` by default, so existing output is
unchanged.

# 3.6.1

- Update `README.md` for the 3.6.0 API: the `to<Name>Bytes` byte encoders, the
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,33 @@ void main() {
}
```

### Whitespace-tolerant decoding

Line-wrapped payloads (PEM, MIME, HTTP headers) carry newlines and spaces that
strict decoders reject. Pass `ignoreWhitespace: true` to `fromHex`, `fromBase32`,
or `fromBase64` to skip ASCII whitespace (space, `\t`, `\n`, `\v`, `\f`, `\r`)
instead. It is off by default, so ordinary decoding is unchanged.

<!-- file: example/whitespace_example.dart -->

```dart
import 'package:convertlib/convertlib.dart';

void main() {
// A Base-64 body wrapped across lines the way PEM/MIME fold it.
const wrapped = 'SGVsbG8sIHdv\n'
'cmxkISBGcm9t\n'
'IGNvbnZlcnRs\n'
'aWIu\n';

print(fromUtf8(fromBase64(wrapped, ignoreWhitespace: true)));

// Strict decoding (the default) still rejects the whitespace.
print(tryFromBase64(wrapped)); // null
print(tryFromBase64(wrapped, ignoreWhitespace: true) != null); // true
}
```

### BigInt ↔ bytes

Read a byte sequence as an arbitrary-precision integer and back. Combine with
Expand Down
15 changes: 15 additions & 0 deletions example/whitespace_example.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import 'package:convertlib/convertlib.dart';

void main() {
// A Base-64 body wrapped across lines the way PEM/MIME fold it.
const wrapped = 'SGVsbG8sIHdv\n'
'cmxkISBGcm9t\n'
'IGNvbnZlcnRs\n'
'aWIu\n';

print(fromUtf8(fromBase64(wrapped, ignoreWhitespace: true)));

// Strict decoding (the default) still rejects the whitespace.
print(tryFromBase64(wrapped)); // null
print(tryFromBase64(wrapped, ignoreWhitespace: true) != null); // true
}
16 changes: 13 additions & 3 deletions lib/src/base16.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import 'dart:typed_data';

import 'codecs/base16.dart';
import 'core/whitespace.dart';

Base16Codec _codecFromParameters({
bool upper = false,
Expand Down Expand Up @@ -55,6 +56,9 @@ Uint8List toHexBytes(
///
/// Parameters:
/// - [input] should be a valid Base-16 (hexadecimal) string.
/// - If [ignoreWhitespace] is true, ASCII whitespace characters (space, tab,
/// line feed, vertical tab, form feed, and carriage return) in the [input]
/// are skipped instead of rejected. It is `false` by default.
/// - [codec] is the [Base16Codec] to use. It is derived from the other
/// parameters if not provided.
///
Expand All @@ -66,22 +70,28 @@ Uint8List toHexBytes(
Uint8List fromHex(
String input, {
Base16Codec? codec,
bool ignoreWhitespace = false,
}) {
codec ??= _codecFromParameters();
return codec.decoder.convert(input.codeUnits);
List<int> data = input.codeUnits;
if (ignoreWhitespace) {
data = stripWhitespace(data);
}
return codec.decoder.convert(data);
}

/// Converts a Base-16 string to an 8-bit integer sequence, returning `null`
/// instead of throwing when the [input] is not valid.
///
/// This is the non-throwing counterpart of [fromHex]. See [fromHex] for the
/// meaning of [codec].
/// meaning of [codec] and [ignoreWhitespace].
Uint8List? tryFromHex(
String input, {
Base16Codec? codec,
bool ignoreWhitespace = false,
}) {
try {
return fromHex(input, codec: codec);
return fromHex(input, codec: codec, ignoreWhitespace: ignoreWhitespace);
} on FormatException {
return null;
}
Expand Down
19 changes: 16 additions & 3 deletions lib/src/base32.dart
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ Uint8List toBase32Bytes(
/// - [input] should be a valid base-32 encoded string.
/// - If [padding] is true, the [input] may contain padding characters, which
/// are ignored during decoding.
/// - If [ignoreWhitespace] is true, ASCII whitespace characters (space, tab,
/// line feed, vertical tab, form feed, and carriage return) in the [input]
/// are skipped instead of rejected. It is `false` by default.
/// - [codec] is the [Base32Codec] to use. It is derived from the other
/// parameters if not provided.
///
Expand All @@ -104,23 +107,33 @@ Uint8List fromBase32(
String input, {
Base32Codec? codec,
bool padding = true,
bool ignoreWhitespace = false,
}) {
codec ??= _codecFromParameters(padding: padding);
return codec.decoder.convert(input.codeUnits);
return codec.decoder.convert(
input.codeUnits,
ignoreWhitespace: ignoreWhitespace,
);
}

/// Converts a Base-32 string to an 8-bit integer sequence, returning `null`
/// instead of throwing when the [input] is not valid.
///
/// This is the non-throwing counterpart of [fromBase32]. See [fromBase32] for
/// the meaning of [codec] and [padding].
/// the meaning of [codec], [padding], and [ignoreWhitespace].
Uint8List? tryFromBase32(
String input, {
Base32Codec? codec,
bool padding = true,
bool ignoreWhitespace = false,
}) {
try {
return fromBase32(input, codec: codec, padding: padding);
return fromBase32(
input,
codec: codec,
padding: padding,
ignoreWhitespace: ignoreWhitespace,
);
} on FormatException {
return null;
}
Expand Down
20 changes: 17 additions & 3 deletions lib/src/base64.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ Uint8List toBase64Bytes(
/// - [input] should be a valid base-64 encoded string.
/// - If [padding] is true, the [input] may contain padding characters, which
/// are ignored during decoding.
/// - If [ignoreWhitespace] is true, ASCII whitespace characters (space, tab,
/// line feed, vertical tab, form feed, and carriage return) in the [input]
/// are skipped instead of rejected. This is useful for line-wrapped payloads
/// such as PEM and MIME. It is `false` by default.
/// - [codec] is the [Base64Codec] to use. It is derived from the other
/// parameters if not provided.
///
Expand All @@ -101,23 +105,33 @@ Uint8List fromBase64(
String input, {
Base64Codec? codec,
bool padding = true,
bool ignoreWhitespace = false,
}) {
codec ??= _codecFromParameters(padding: padding);
return codec.decoder.convert(input.codeUnits);
return codec.decoder.convert(
input.codeUnits,
ignoreWhitespace: ignoreWhitespace,
);
}

/// Converts a Base-64 string to an 8-bit integer sequence, returning `null`
/// instead of throwing when the [input] is not valid.
///
/// This is the non-throwing counterpart of [fromBase64]. See [fromBase64] for
/// the meaning of [codec] and [padding].
/// the meaning of [codec], [padding], and [ignoreWhitespace].
Uint8List? tryFromBase64(
String input, {
Base64Codec? codec,
bool padding = true,
bool ignoreWhitespace = false,
}) {
try {
return fromBase64(input, codec: codec, padding: padding);
return fromBase64(
input,
codec: codec,
padding: padding,
ignoreWhitespace: ignoreWhitespace,
);
} on FormatException {
return null;
}
Expand Down
9 changes: 8 additions & 1 deletion lib/src/codecs/base32.dart
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,14 @@ class Base32Decoder extends AlphabetDecoder {
}) : super(bits: 5);

@override
Uint8List convert(List<int> encoded) {
Uint8List convert(List<int> encoded, {bool ignoreWhitespace = false}) {
// The fast path below decodes fixed 8-character groups, so it cannot skip
// interspersed whitespace. Defer that case to the generic bit-accumulator,
// which tolerates whitespace anywhere without an intermediate buffer.
if (ignoreWhitespace) {
return super.convert(encoded, ignoreWhitespace: true);
}

final table = alphabet;
final pad = padding;
final tlen = table.length;
Expand Down
9 changes: 8 additions & 1 deletion lib/src/codecs/base64.dart
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,14 @@ class Base64Decoder extends AlphabetDecoder {
}) : super(bits: 6);

@override
Uint8List convert(List<int> encoded) {
Uint8List convert(List<int> encoded, {bool ignoreWhitespace = false}) {
// The fast path below decodes fixed 4-character groups, so it cannot skip
// interspersed whitespace. Defer that case to the generic bit-accumulator,
// which tolerates whitespace anywhere without an intermediate buffer.
if (ignoreWhitespace) {
return super.convert(encoded, ignoreWhitespace: true);
}

final table = alphabet;
final pad = padding;
final tlen = table.length;
Expand Down
4 changes: 3 additions & 1 deletion lib/src/core/alphabet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ class AlphabetDecoder extends ByteDecoder {
int get source => bits;

@override
Uint8List convert(List<int> encoded) {
Uint8List convert(List<int> encoded, {bool ignoreWhitespace = false}) {
final table = alphabet;
final tlen = table.length;
final pad = padding;
Expand All @@ -131,6 +131,7 @@ class AlphabetDecoder extends ByteDecoder {
p = n = l = 0;
for (i = 0; i < len; ++i) {
y = encoded[i];
if (ignoreWhitespace && (y == 0x20 || (y >= 0x09 && y <= 0x0D))) continue;
if (y == pad) break;
if (y < 0 || y >= tlen || (x = table[y]) < 0) {
throw FormatException('Invalid character $y at $i');
Expand All @@ -146,6 +147,7 @@ class AlphabetDecoder extends ByteDecoder {

for (; i < len; ++i) {
y = encoded[i];
if (ignoreWhitespace && (y == 0x20 || (y >= 0x09 && y <= 0x0D))) continue;
if (y != pad) {
throw FormatException('Invalid character $y at $i');
}
Expand Down
43 changes: 43 additions & 0 deletions lib/src/core/whitespace.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) 2026, Sudipto Chandra
// All rights reserved. Check LICENSE file for details.

/// Returns [input] with every ASCII whitespace code unit removed.
///
/// The characters treated as whitespace are the space (`0x20`) and the C0
/// control range `0x09`..`0x0D`: horizontal tab (`\t`), line feed (`\n`),
/// vertical tab (`\v`), form feed (`\f`), and carriage return (`\r`). These are
/// the characters that line-wrapped encodings such as PEM and MIME insert into
/// otherwise valid Base-16/32/64 payloads.
///
/// When [input] contains no whitespace, the same instance is returned without
/// any allocation; otherwise a single exactly-sized `List<int>` is allocated
/// and filled in one pass.
///
/// Every other code unit is preserved exactly, including values above `0xFF`,
/// so that a subsequent decoder still rejects genuinely invalid characters. The
/// returned list is a plain `List<int>` (never a `Uint8List`) for the same
/// reason — a `Uint8List` would truncate wide code units and could mask an
/// invalid character as a valid one.
List<int> stripWhitespace(List<int> input) {
final n = input.length;
int i, c, ws, j;

// First pass: count whitespace. A clean input allocates nothing and is
// returned untouched, so the flag is free when the payload has no folding.
ws = 0;
for (i = 0; i < n; ++i) {
c = input[i];
if (c == 0x20 || (c >= 0x09 && c <= 0x0D)) ws++;
}
if (ws == 0) return input;

// Second pass: compact into a single buffer of the exact final length.
final out = List<int>.filled(n - ws, 0);
j = 0;
for (i = 0; i < n; ++i) {
c = input[i];
if (c == 0x20 || (c >= 0x09 && c <= 0x0D)) continue;
out[j++] = c;
}
return out;
}
Loading