吃得饱系列-用 Rust 实现一个图片转 base64 的 CLI
发现需求
公司的前端做微信小程序开发过程中,经常要做一桩非常重复的事,就是找后端开发把图片资源放到对象存储上,或者手动把图片转成 base64 字符串使用,通常情况下,他们会选择把图片上传到某个网站生成 base64 字符串,然后复制粘贴写到 JS 文件,再通过 export default 字符串
的方式导出来。于是我做了一个 CLI 来解决该问题。
生成 base64
base64 原理
首先就是了解一下 base64 的原理,这样方便后续写算法解决问题。众所周知,一个字节等于 8bit,但是 base64 是把一个字节分成 6bit 的“块”,然后再把每个“块”对应一个相应的字符。
数值 | 字符 | 数值 | 字符 | 数值 | 字符 | 数值 | 字符 |
---|---|---|---|---|---|---|---|
0 | A | 16 | Q | 32 | g | 48 | w |
1 | B | 17 | R | 33 | h | 49 | x |
2 | C | 18 | S | 34 | i | 50 | y |
3 | D | 19 | T | 35 | j | 51 | z |
4 | E | 20 | U | 36 | k | 52 | 0 |
5 | F | 21 | V | 37 | l | 53 | 1 |
6 | G | 22 | W | 38 | m | 54 | 2 |
7 | H | 23 | X | 39 | n | 55 | 3 |
8 | I | 24 | Y | 40 | o | 56 | 4 |
9 | J | 25 | Z | 41 | p | 57 | 5 |
10 | K | 26 | a | 42 | q | 58 | 6 |
11 | L | 27 | b | 43 | r | 59 | 7 |
12 | M | 28 | c | 44 | s | 60 | 8 |
13 | N | 29 | d | 45 | t | 61 | 9 |
14 | O | 30 | e | 46 | u | 62 | + |
15 | P | 31 | f | 47 | v | 63 | / |
然后再分解一下,譬如有个字符串叫 "foo",把它拆成二进制表示就是
f | o | o | ||
---|---|---|---|---|
ASCII | 102 | 157 | 157 | |
8bit | 01100110 | 01101111 | 01101111 | |
6bit | 011001 | 100110 | 111101 | 101111 |
6bit binary | 25 | 38 | 61 | 47 |
base64 word | Z | m | 9 | v |
现在一目了然,知道 base64 是怎么生成的啦,不过还有个小问题,上面的情况是 24 个 bit 刚好能被 6 整除,如果碰到不能被 6 整除的情况,就要余数补零再把 000000
替换成 =
,譬如
f | o | |||
---|---|---|---|---|
ASCII | 102 | 157 | ||
8bit | 01100110 | 01101111 | 00000000 | |
6bit | 011001 | 100110 | 111100 | 00(0000) |
6bit binary | 25 | 38 | 60 | |
base64 word | Z | m | 8 | = |
基于上面的理论,可以得出一个结论,经过 base64 编码后,通常会比原先的大 30% 以上。
用 Rust 实现 encoder
日常创建项目先,项目名就叫 img2base64
cargo new img2base64
然后用自己熟悉的开发工具打开项目,创建文件 base64.rs,我们先把码表写一下
const TABLE: &str = "\
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
到时候生成对应索引取字符就行,所以自然而然地,我们应该做个切片取字符的函数
pub trait CharAt {
fn char_at(&self, index: usize) -> char;
}
impl CharAt for str {
fn char_at(&self, index: usize) -> char {
self[index..].chars().next().unwrap()
}
}
接着来把编码器对外接口假设一下
pub fn encode(bytes: &[u8], size: usize) -> String {
todo!();
}
我们以上面的例子 foo
为输入,预分配一个动态数组,可以通过 Vec 的 resize
方法处理,把空白的都补 =
,生成结果的尺寸可以利用整型除法的向下取整特性计算出来,也就是
pub fn encode(bytes: &[u8], size: usize) -> String {
let mut buffer = Vec::<char>::new();
buffer.resize((size + 2) / 3 * 4, '=');
// ...
}
然后就是把每个字符循环处理一下,假设只有一个字符 f 的 foo 切片,我们知道 bytes 是一个 u8 的切片,那这里依次把它下标为 0/1/2 的取出来再转成 u32 就行
102 (ASCII code "f") | 111 (ASCII code "o") | 111 (ASCII code "o") |
---|---|---|
01100110 | 01101111 | 01101111 |
再分别左移 16/8/0 位通过 |
符号接起来就是
6713199 (不重要) |
---|
00000000 01100110 01101111 01101111 |
接着是右移 18/12/6/0 位,其实就是取出每个字符对应的索引,再查我们定义的常量表取出对应的 base64 字符放到临时 Vec 里,写成循环就是下面这种形式
pub fn encode(bytes: &[u8], size: usize) -> String {
let mut buffer = Vec::<char>::new();
buffer.resize((size + 2) / 3 * 4, '=');
let mut i = 0;
let mut j = 0;
let n = size / 3 * 3;
while i < n {
let x: u32 = ((bytes[i] as u32) << 16)
| ((bytes[i + 1] as u32) << 8)
| (bytes[i + 2] as u32);
buffer[j] = TABLE.char_at(((x >> 18) & 63) as usize);
buffer[j + 1] = TABLE.char_at(((x >> 12) & 63) as usize);
buffer[j + 2] = TABLE.char_at(((x >> 6) & 63) as usize);
buffer[j + 3] = TABLE.char_at((x & 63) as usize);
i += 3;
j += 4;
}
buffer.iter().collect()
}
然后来写个测试看看效果
#[test]
fn test_encode() {
assert_eq!(encode("".as_bytes(), 0), "");
assert_eq!(encode("f".as_bytes(), 1), "Zg==");
assert_eq!(encode("fo".as_bytes(), 2), "Zm8=");
assert_eq!(encode("foo".as_bytes(), 3), "Zm9v");
assert_eq!(encode("foob".as_bytes(), 4), "Zm9vYg==");
}
然后测试就出错啦,果然测试用例才是第一生产力,看了一下测试错误
---- base64::test_encode stdout ----
thread 'base64::test_encode' panicked at 'assertion failed: `(left == right)`
left: `"===="`,
right: `"Zg=="`', src/base64.rs
是没处理一个字符的情况,由于我们设置 buffer 时默认是填充成 =
,所以剩余的补 =
的环节可以省略
pub fn encode(bytes: &[u8], size: usize) -> String {
let mut buffer = Vec::<char>::new();
buffer.resize((size + 2) / 3 * 4, '=');
let mut i = 0;
let mut j = 0;
let n = size / 3 * 3;
while i < n {
let x: u32 = ((bytes[i] as u32) << 16)
| ((bytes[i + 1] as u32) << 8)
| (bytes[i + 2] as u32);
buffer[j] = TABLE.char_at(((x >> 18) & 63) as usize);
buffer[j + 1] = TABLE.char_at(((x >> 12) & 63) as usize);
buffer[j + 2] = TABLE.char_at(((x >> 6) & 63) as usize);
buffer[j + 3] = TABLE.char_at((x & 63) as usize);
i += 3;
j += 4;
}
if i + 1 == size {
let x = (bytes[i] as u32) << 16;
buffer[j] = TABLE.char_at(((x >> 18) & 63) as usize);
buffer[j + 1] = TABLE.char_at(((x >> 12) & 63) as usize);
}
buffer.iter().collect()
}
再次跑测试,发觉又错了
---- base64::test_encode stdout ----
thread 'base64::test_encode' panicked at 'assertion failed: `(left == right)`
left: `"===="`,
right: `"Zm8="`', src/base64.rs
应该是没处理两个字符的情况,所以
pub fn encode(bytes: &[u8], size: usize) -> String {
let mut buffer = Vec::<char>::new();
buffer.resize((size + 2) / 3 * 4, '=');
let mut i = 0;
let mut j = 0;
let n = size / 3 * 3;
while i < n {
let x: u32 = ((bytes[i] as u32) << 16)
| ((bytes[i + 1] as u32) << 8)
| (bytes[i + 2] as u32);
buffer[j] = TABLE.char_at(((x >> 18) & 63) as usize);
buffer[j + 1] = TABLE.char_at(((x >> 12) & 63) as usize);
buffer[j + 2] = TABLE.char_at(((x >> 6) & 63) as usize);
buffer[j + 3] = TABLE.char_at((x & 63) as usize);
i += 3;
j += 4;
}
if i + 1 == size {
let x = (bytes[i] as u32) << 16;
buffer[j] = TABLE.char_at(((x >> 18) & 63) as usize);
buffer[j + 1] = TABLE.char_at(((x >> 12) & 63) as usize);
} else if i + 2 == size {
let x = (bytes[i] as u32) << 16 | (bytes[i + 1] as u32) << 8;
buffer[j] = TABLE.char_at(((x >> 18) & 63) as usize);
buffer[j + 1] = TABLE.char_at(((x >> 12) & 63) as usize);
buffer[j + 2] = TABLE.char_at(((x >> 6) & 63) as usize);
}
buffer.iter().collect()
}
这下子把测试用例的情况都覆盖到了,接着就是读取文件喂给 encode
函数。
读取文件
我们的目标是把这个 CLI 当成一个独立的程序,使用时就是下面这种形式
img2base64 <file-path>
如果直接跑这个项目就是
cargo run <file-path>
也就是讲只是简单地读取文件二进制数据,所以就不引用第三方库,直接用 Rust 的 std 处理,先在 main.rs 入口读取路径
fn main() -> Result<(), io::Error> {
let input = std::env::args().nth(1).expect("please input file path");
// TODO: 读取二进制数据
Ok(())
}
再写一个函数读取二进制数据
fn parse(path: &str) -> io::Result<Box<dyn Read>> {
let file = File::open(path)?;
Ok(Box::new(file))
}
然后把 input 路径切片传给该函数,因为 input 是一个 String,加个 &
就能给函数使用,加个 ?
符就能取出 Result
的内容,也就是 File::open
后的文件指针,因为受 File::open 的类型是 File,它实现了 Read trait,所以可以使用 read_to_end
函数,这个函数文档上说它会从头读取到尾,同时把读取到的内容给传进来的 Vec<u8>
引用,再用 ?
取出读取后的 size
fn main() -> Result<(), io::Error> {
let input = std::env::args().nth(1).expect("please input file path");
let mut buf = Vec::new();
let size = parse(&input)?
.read_to_end(&mut buf)?;
// TODO: 生成 base64
Ok(())
}
现在我们 u8 的数组有啦,数组尺寸也有啦,那自然而然直接调用 encode
函数就行,完整代码就是
use std::{
fs::File,
io::{self, Read},
};
mod base64;
fn main() -> Result<(), io::Error> {
let input = std::env::args().nth(1).expect("please input file path");
let mut buf = Vec::new();
let size = parse(&input)?
.read_to_end(&mut buf)?;
print!(base64::encode(&buf, size));
Ok(())
}
fn parse(path: &str) -> io::Result<Box<dyn Read>> {
let file = File::open(path)?;
Ok(Box::new(file))
}
生成对应的文件前缀
已经结束咧,其实还能再优化下体验,因为前端使用的 base64 图片通常会加前缀,那我们就根据图片副档名给生成的字符串加前缀。其实逻辑很简单只是判断一下字符串,不过直接字符串太简单,那就过度封装一下吧
建个 format.rs 文件,定义 enum
,我们暂时只处理 PNG/JPG/SVG 的格式
#[derive(Debug)]
pub enum ImageFormat {
Png,
Jpeg,
Svg,
}
然后给 enum
挂函数,逻辑很简单,就不详细解释嘞,只是判断字符串给出对应枚举值
impl ImageFormat {
pub fn from_ext<S>(ext: S) -> Option<ImageFormat>
where
S: AsRef<OsStr>,
{
fn inner(ext: &OsStr) -> Option<ImageFormat> {
let ext = ext.to_str()?.to_ascii_lowercase();
Some(match ext.as_str() {
"jpg" | "jpeg" => ImageFormat::Jpeg,
"png" => ImageFormat::Png,
"svg" => ImageFormat::Svg,
_ => return None,
})
}
inner(ext.as_ref())
}
pub fn from_ext_str<P>(ext: P) -> Option<ImageFormat>
where
P: AsRef<Path>,
{
fn inner(ext: &Path) -> Option<ImageFormat> {
ext.extension().and_then(ImageFormat::from_ext)
}
inner(ext.as_ref())
}
}
然后在 main.rs 处使用,现在真正结束咧
use std::{
fs::File,
io::{self, Read},
};
use format::ImageFormat;
mod base64;
mod format;
fn main() -> Result<(), io::Error> {
let mut buf = Vec::new();
let input = std::env::args().nth(1).expect("please input file path");
let size = parse(&input)?.read_to_end(&mut buf)?;
let fmt = ImageFormat::from_ext_str(input).map(|fmt| match fmt {
ImageFormat::Svg => "data:image/svg+xml;base64,",
ImageFormat::Jpeg => "data:image/jpg;base64,",
ImageFormat::Png => "data:image/png;base64,",
});
match fmt {
Some(f) => print!("{}{}", f, base64::encode(&buf, size)),
None => print!("{}", base64::encode(&buf, size)),
}
Ok(())
}
fn parse(path: &str) -> io::Result<Box<dyn Read>> {
let file = File::open(path)?;
Ok(Box::new(file))
}
优化使用者体验
这里所谓的优化使用者体验,其实是预设一个前置条件,那就是我司的前端全都是用得 Mac 电脑,所以就可以做如下的事。
先是把该项目 build 一下,然后拷贝到 /usr/local/bin
目录下
cargo build --release
cp ./target/release/img2base64 /usr/local/bin
然后用 Mac 自带的 Automator 创建一个 Quick Action,选择 Run Shell Script
,并输入下面这段东西
for f in "$@"
do
/usr/local/bin/img2base64 "$f" | pbcopy
done
具体设置如下图,需要注意右边 Pass input: 要改成 as arguments
这样我们就给 Finder 加了个拓展,此时只要在待转换的图片文件上点右键选择 Quick Action,找到 img2base64 就能生成 base64 字符串并复制到剪贴板。
当然还有更进一步的做法,譬如把源程序改成生成对应的 JS/TS 代码字符串,再通过 Shell 把文件写入生成一个文件。
总之 macOS 真好使,Rust 真好玩。