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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
- `DataEncoding::Shortest`, which picks percent-encoding or base64, whichever results in a shorter data URL
- `Escaping::Minimal`, which only escapes what has to be when percent-encoding, for data URLs that go inside
double-quoted HTML attributes or CSS strings; this keeps text formats like SVG short and readable
- `DataUrl::write_to()`, which writes a data URL to any `std::io::Write` without building it as a string first
- `DataUrl::encoded_len()`, the exact length of the serialized data URL, e.g. for preallocating buffers

### Changed

Expand All @@ -18,6 +20,12 @@
which use the new `DataEncoding` enum (`Percent`, `Base64` or `Shortest`)
- Percent-encoding now also escapes `&`, so that HTML entities can't alter the data
when a data URL is put into an HTML attribute by hand
- `set_data()` takes `impl Into<Vec<u8>>`: passing a `Vec<u8>` or `String` moves it in without copying,
while slices, arrays and string literals still work (`&Vec<u8>` needs `.as_slice()` or to be passed by value)
- Serializing writes in chunks instead of building the whole data URL in memory first, and parsing base64
no longer makes intermediate copies of the data: for a 10 MB payload, peak memory use while parsing went
from about 40 MB to 10 MB (just the result), and parsing got about a third faster
- The CLI writes its output as it encodes, instead of building it in memory first

## 0.2.1

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ assert_eq!(data_url.to_string(), "data:,Hello,%20World!");
let mut data_url = DataUrl::new();
data_url.set_media_type(Some("image/png"))?;
data_url.set_data_encoding(DataEncoding::Base64);
data_url.set_data(&[0x89, 0x50, 0x4E, 0x47]);
data_url.set_data([0x89, 0x50, 0x4E, 0x47]);
assert_eq!(data_url.to_string(), "data:image/png;base64,iVBORw==");
```

Expand Down
60 changes: 46 additions & 14 deletions src/base64.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/// Writes base64 in chunks, so that large data doesn't need one big intermediate string.
pub(crate) fn write<W: std::fmt::Write>(writer: &mut W, data: &[u8]) -> std::fmt::Result {
// A multiple of 3, so that only the very last chunk can need padding
const CHUNK_SIZE: usize = 6 * 1024;
let mut buffer = String::with_capacity(CHUNK_SIZE / 3 * 4);

for chunk in data.chunks(CHUNK_SIZE) {
buffer.clear();
encode_into(&mut buffer, chunk);
writer.write_str(&buffer)?;
}

Ok(())
}

/// Length of the base64 encoding of `length` bytes, padding included.
pub(crate) fn encoded_length(length: usize) -> usize {
length.div_ceil(3) * 4
}

pub(crate) fn encode_into(output: &mut String, data: &[u8]) {
output.reserve(data.len().div_ceil(3) * 4);

Expand Down Expand Up @@ -40,29 +60,30 @@ fn decode_char(c: u8) -> Option<u32> {
/// (<https://infra.spec.whatwg.org/#forgiving-base64-decode>): ASCII whitespace is ignored,
/// padding is optional, and anything else outside the alphabet makes decoding fail.
pub(crate) fn decode_forgiving(input: &[u8]) -> Option<Vec<u8>> {
let mut data: Vec<u8> = input
.iter()
.copied()
.filter(|c| !matches!(c, b'\t' | b'\n' | 0x0C | b'\r' | b' '))
.collect();

if data.len() % 4 == 0 {
if data.ends_with(b"==") {
data.truncate(data.len() - 2);
} else if data.ends_with(b"=") {
data.truncate(data.len() - 1);
let is_not_whitespace = |c: &&u8| !matches!(c, b'\t' | b'\n' | 0x0C | b'\r' | b' ');

// Work out the length without whitespace and padding first, instead of making a stripped copy
let mut length = input.iter().filter(is_not_whitespace).count();
if length % 4 == 0 {
let mut from_the_end = input.iter().rev().filter(is_not_whitespace);
if from_the_end.next() == Some(&b'=') {
length -= 1;
if from_the_end.next() == Some(&b'=') {
length -= 1;
}
}
}

if data.len() % 4 == 1 {
if length % 4 == 1 {
return None;
}

let mut output: Vec<u8> = Vec::with_capacity(data.len() / 4 * 3 + 2);
let mut output: Vec<u8> = Vec::with_capacity(length / 4 * 3 + 2);
let mut buffer: u32 = 0;
let mut bits: u8 = 0;

for c in data {
// The padding that was accounted for comes last, so stopping after `length` characters skips it
for &c in input.iter().filter(is_not_whitespace).take(length) {
buffer = buffer << 6 | decode_char(c)?;
bits += 6;
if bits == 24 {
Expand Down Expand Up @@ -116,6 +137,17 @@ mod tests {
assert_eq!(decode_forgiving(s.as_bytes()).unwrap(), data);
}

#[test]
fn chunked_writer_matches_one_go_encoding() {
let data: Vec<u8> = (0..=255).cycle().take(6 * 1024 * 3 + 7).collect();
let mut chunked = String::new();
write(&mut chunked, &data).unwrap();
let mut whole = String::new();
encode_into(&mut whole, &data);
assert_eq!(chunked, whole);
assert_eq!(chunked.len(), encoded_length(data.len()));
}

#[test]
fn rejects_url_safe_alphabet() {
assert_eq!(decode_forgiving(b"-_8="), None);
Expand Down
156 changes: 121 additions & 35 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
//! let mut data_url = DataUrl::new();
//! data_url.set_media_type(Some("image/png"))?;
//! data_url.set_data_encoding(DataEncoding::Base64);
//! data_url.set_data(&[0x89, 0x50, 0x4E, 0x47]);
//! data_url.set_data([0x89, 0x50, 0x4E, 0x47]);
//! assert_eq!(data_url.to_string(), "data:image/png;base64,iVBORw==");
//!
//! // For text, percent-encoding with minimal escaping is often much shorter than base64
Expand Down Expand Up @@ -47,6 +47,7 @@ pub use mime::MimeType;
use encoding_rs::{Encoding, REPLACEMENT, UTF_16BE, UTF_16LE, WINDOWS_1252};
use std::borrow::Cow;
use std::fmt;
use std::io;
use std::str::FromStr;

const DEFAULT_MEDIA_TYPE: &str = "text/plain";
Expand Down Expand Up @@ -212,15 +213,21 @@ impl DataUrl {
);
let mut mime_type: &str = mime_type.trim_matches(|c: char| c.is_ascii_whitespace());

let mut data: Vec<u8> = percent::decode(encoded_data.as_bytes());
// Base64 rarely contains percent-encoded characters, in which case decoding it can be done in one go
let percent_decoded: Cow<[u8]> = if encoded_data.contains('%') {
Cow::Owned(percent::decode(encoded_data.as_bytes()))
} else {
Cow::Borrowed(encoded_data.as_bytes())
};

let data_encoding = match strip_base64_marker(mime_type) {
let (data, data_encoding) = match strip_base64_marker(mime_type) {
Some(stripped) => {
data = base64::decode_forgiving(&data).ok_or(Error::InvalidBase64)?;
mime_type = stripped;
DataEncoding::Base64
let data =
base64::decode_forgiving(&percent_decoded).ok_or(Error::InvalidBase64)?;
(data, DataEncoding::Base64)
}
None => DataEncoding::Percent,
None => (percent_decoded.into_owned(), DataEncoding::Percent),
};

let (media_type, parameters) = if mime_type.starts_with(';') {
Expand Down Expand Up @@ -440,8 +447,11 @@ impl DataUrl {
}

/// Sets the raw data, which will be serialized byte-for-byte.
pub fn set_data(&mut self, data: &[u8]) {
self.data = data.to_vec();
///
/// Passing a `Vec<u8>` (or a `String`) moves it into the data URL without copying;
/// slices, arrays and string literals get copied.
pub fn set_data(&mut self, data: impl Into<Vec<u8>>) {
self.data = data.into();
}

/// The data decoded as text using [`encoding`](DataUrl::encoding).
Expand Down Expand Up @@ -474,54 +484,130 @@ impl DataUrl {
Ok(())
}

/// The fragment (the part after `#`), `None` if absent.
pub fn fragment(&self) -> Option<&str> {
self.fragment.as_deref()
/// The exact length of the serialized data URL, without serializing it,
/// e.g. for preallocating a buffer. Serialized data URLs are always ASCII,
/// so this is both the number of bytes and of characters.
pub fn encoded_len(&self) -> usize {
let should_encode = self.percent_encoding_set();
let data_length = if self.uses_base64(should_encode) {
";base64,".len() + base64::encoded_length(self.data.len())
} else {
",".len() + percent::encoded_length(&self.data, should_encode)
};

self.metadata().len() + data_length + self.encoded_fragment().map_or(0, |f| 1 + f.len())
}

/// Sets the fragment, or removes it with `None`.
pub fn set_fragment(&mut self, fragment: Option<&str>) {
self.fragment = fragment.map(str::to_string);
/// Writes the data URL to `writer` without building it as one string first,
/// which saves memory for large data (`to_string()` and `format!` work too).
///
/// ```
/// let mut data_url = dataurl::DataUrl::new();
/// data_url.set_data("Hello!");
///
/// let mut output: Vec<u8> = Vec::new();
/// data_url.write_to(&mut output)?;
/// assert_eq!(output, b"data:,Hello!");
/// # Ok::<(), std::io::Error>(())
/// ```
pub fn write_to<W: io::Write>(&self, writer: W) -> io::Result<()> {
// Adapts io::Write to fmt::Write, keeping the actual I/O error
struct Adapter<W> {
inner: W,
error: Option<io::Error>,
}

impl<W: io::Write> fmt::Write for Adapter<W> {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.inner.write_all(s.as_bytes()).map_err(|e| {
self.error = Some(e);
fmt::Error
})
}
}

let mut adapter = Adapter {
inner: writer,
error: None,
};
self.write_into(&mut adapter).map_err(|_| {
adapter
.error
.take()
.unwrap_or_else(|| io::Error::other("unable to write data URL"))
})
}
}

impl fmt::Display for DataUrl {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let mut output = String::from("data:");
fn write_into<W: fmt::Write>(&self, writer: &mut W) -> fmt::Result {
writer.write_str(&self.metadata())?;

let should_encode = self.percent_encoding_set();
if self.uses_base64(should_encode) {
writer.write_str(";base64,")?;
base64::write(writer, &self.data)?;
} else {
writer.write_char(',')?;
percent::write_data(writer, &self.data, should_encode)?;
}

if let Some(fragment) = self.encoded_fragment() {
writer.write_char('#')?;
writer.write_str(&fragment)?;
}

Ok(())
}

/// "data:" followed by the media type and its parameters
fn metadata(&self) -> String {
let mut output = String::from("data:");
if let Some(media_type) = &self.media_type {
output.push_str(media_type);
}
mime::serialize_parameters(&mut output, &self.parameters);
output
}

let should_encode: fn(u8) -> bool = match self.escaping {
fn percent_encoding_set(&self) -> fn(u8) -> bool {
match self.escaping {
Escaping::Full => percent::in_data_set,
Escaping::Minimal => percent::in_minimal_data_set,
};
let use_base64 = match self.data_encoding {
}
}

fn uses_base64(&self, should_encode: fn(u8) -> bool) -> bool {
match self.data_encoding {
DataEncoding::Percent => false,
DataEncoding::Base64 => true,
DataEncoding::Shortest => {
// ";base64" plus 4 characters for every 3 bytes, versus 1 or 3 characters per byte
let base64_length = 7 + self.data.len().div_ceil(3) * 4;
let base64_length = ";base64".len() + base64::encoded_length(self.data.len());
base64_length < percent::encoded_length(&self.data, should_encode)
}
};

if use_base64 {
output.push_str(";base64,");
base64::encode_into(&mut output, &self.data);
} else {
output.push(',');
percent::encode_data_into(&mut output, &self.data, should_encode);
}
}

if let Some(fragment) = &self.fragment {
output.push('#');
fn encoded_fragment(&self) -> Option<String> {
self.fragment.as_ref().map(|fragment| {
let mut output = String::with_capacity(fragment.len());
percent::encode_into(&mut output, fragment.as_bytes(), percent::in_fragment_set);
}
output
})
}

/// The fragment (the part after `#`), `None` if absent.
pub fn fragment(&self) -> Option<&str> {
self.fragment.as_deref()
}

/// Sets the fragment, or removes it with `None`.
pub fn set_fragment(&mut self, fragment: Option<&str>) {
self.fragment = fragment.map(str::to_string);
}
}

f.write_str(&output)
impl fmt::Display for DataUrl {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
self.write_into(f)
}
}

Expand Down
Loading
Loading