likes
comments
collection
share

吃得饱系列-用 Rust 实现一个图片转 base64 的 CLI

作者站长头像
站长
· 阅读数 23

发现需求

公司的前端做微信小程序开发过程中,经常要做一桩非常重复的事,就是找后端开发把图片资源放到对象存储上,或者手动把图片转成 base64 字符串使用,通常情况下,他们会选择把图片上传到某个网站生成 base64 字符串,然后复制粘贴写到 JS 文件,再通过 export default 字符串 的方式导出来。于是我做了一个 CLI 来解决该问题。

生成 base64

base64 原理

首先就是了解一下 base64 的原理,这样方便后续写算法解决问题。众所周知,一个字节等于 8bit,但是 base64 是把一个字节分成 6bit 的“块”,然后再把每个“块”对应一个相应的字符。

数值字符数值字符数值字符数值字符
0A16Q32g48w
1B17R33h49x
2C18S34i50y
3D19T35j51z
4E20U36k520
5F21V37l531
6G22W38m542
7H23X39n553
8I24Y40o564
9J25Z41p575
10K26a42q586
11L27b43r597
12M28c44s608
13N29d45t619
14O30e46u62+
15P31f47v63/

然后再分解一下,譬如有个字符串叫 "foo",把它拆成二进制表示就是

foo
ASCII102157157
8bit011001100110111101101111
6bit011001100110111101101111
6bit binary25386147
base64 wordZm9v

现在一目了然,知道 base64 是怎么生成的啦,不过还有个小问题,上面的情况是 24 个 bit 刚好能被 6 整除,如果碰到不能被 6 整除的情况,就要余数补零再把 000000 替换成 =,譬如

fo
ASCII102157
8bit011001100110111100000000
6bit01100110011011110000(0000)
6bit binary253860
base64 wordZm8=

基于上面的理论,可以得出一个结论,经过 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 为输入,预分配一个动态数组,可以通过 Vecresize 方法处理,把空白的都补 =,生成结果的尺寸可以利用整型除法的向下取整特性计算出来,也就是

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")
011001100110111101101111

再分别左移 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

吃得饱系列-用 Rust 实现一个图片转 base64 的 CLI

这样我们就给 Finder 加了个拓展,此时只要在待转换的图片文件上点右键选择 Quick Action,找到 img2base64 就能生成 base64 字符串并复制到剪贴板。


当然还有更进一步的做法,譬如把源程序改成生成对应的 JS/TS 代码字符串,再通过 Shell 把文件写入生成一个文件。

总之 macOS 真好使,Rust 真好玩。