How to select a file as bytes or text in Rust WASM?

883 views Asked by At

I am trying to get the Vec<u8> or String (or more ideally a Blob ObjectURL) of a file uploaded as triggered by a button click.

I am guessing this will require an invisible <input> somewhere in the DOM but I can't figure out how to leverage web_sys and/or gloo to either get the contents nor a Blob ObjectURL.

2

There are 2 answers

2
Caesar On BEST ANSWER

A js-triggered input probably won't do the trick, as many browsers won't let you trigger a file input from JS, for good reasons. You can use labels to hid the input if you think it is ugly. Other than that, you need to wiggle yourself through the files api of HtmlInputElement. Pretty painful, that:

use js_sys::{Object, Reflect, Uint8Array};
use wasm_bindgen::{prelude::*, JsCast};
use wasm_bindgen_futures::JsFuture;
use web_sys::*;

#[wasm_bindgen(start)]
pub fn init() {
    // Just some setup for the example
    std::panic::set_hook(Box::new(console_error_panic_hook::hook));
    let window = window().unwrap();
    let document = window.document().unwrap();
    let body = document.body().unwrap();
    while let Some(child) = body.first_child() {
        body.remove_child(&child).unwrap();
    }
    // Create the actual input element
    let input = document
        .create_element("input")
        .expect_throw("Create input")
        .dyn_into::<HtmlInputElement>()
        .unwrap();
    input
        .set_attribute("type", "file")
        .expect_throw("Set input type file");

    let recv_file = {
        let input = input.clone();
        Closure::<dyn FnMut()>::wrap(Box::new(move || {
            let input = input.clone();
            wasm_bindgen_futures::spawn_local(async move {
                file_callback(input.files()).await;
            })
        }))
    };
    input
        .add_event_listener_with_callback("change", recv_file.as_ref().dyn_ref().unwrap())
        .expect_throw("Listen for file upload");
    recv_file.forget(); // TODO: this leaks. I forgot how to get around that.
    body.append_child(&input).unwrap();
}

async fn file_callback(files: Option<FileList>) {
    let files = match files {
        Some(files) => files,
        None => return,
    };
    for i in 0..files.length() {
        let file = match files.item(i) {
            Some(file) => file,
            None => continue,
        };
        console::log_2(&"File:".into(), &file.name().into());
        let reader = file
            .stream()
            .get_reader()
            .dyn_into::<ReadableStreamDefaultReader>()
            .expect_throw("Reader is reader");
        let mut data = Vec::new();
        loop {
            let chunk = JsFuture::from(reader.read())
                .await
                .expect_throw("Read")
                .dyn_into::<Object>()
                .unwrap();
            // ReadableStreamReadResult is somehow wrong. So go by hand. Might be a web-sys bug.
            let done = Reflect::get(&chunk, &"done".into()).expect_throw("Get done");
            if done.is_truthy() {
                break;
            }
            let chunk = Reflect::get(&chunk, &"value".into())
                .expect_throw("Get chunk")
                .dyn_into::<Uint8Array>()
                .expect_throw("bytes are bytes");
            let data_len = data.len();
            data.resize(data_len + chunk.length() as usize, 255);
            chunk.copy_to(&mut data[data_len..]);
        }
        console::log_2(
            &"Got data".into(),
            &String::from_utf8_lossy(&data).into_owned().into(),
        );
    }
}

(If you've got questions about the code, ask. But it's too much to explain it in detail.)

And extra, the features you need on web-sys for this to work:

[dependencies.web-sys]
version = "0.3.60"
features = ["Window", "Navigator", "console", "Document", "HtmlInputElement", "Event", "EventTarget", "FileList", "File", "Blob", "ReadableStream", "ReadableStreamDefaultReader", "ReadableStreamReadResult"]

If you're using gloo with the futures feature enabled, the second function can be implemented much more neatly:

async fn file_callback(files: Option<FileList>) {
    let files = gloo::file::FileList::from(files.expect_throw("empty files"));
    for file in files.iter() {
        console_dbg!("File:", file.name());
        let data = gloo::file::futures::read_as_bytes(file)
            .await
            .expect_throw("read file");
        console_dbg!("Got data", String::from_utf8_lossy(&data));
    }
}
0
Durfsurn On

Thanks to Caesar I ended up with this code for use with dominator as the Dom crate.

pub fn upload_file_input(mimes: &str, mutable: Mutable<Vec<u8>>) -> Dom {
    input(|i| {
        i.class("file-input")
            .prop("type", "file")
            .prop("accept", mimes)
            .apply(|el| {
                let element: HtmlInputElement = el.__internal_element();

                let recv_file = {
                    let input = element.clone();
                    Closure::<dyn FnMut()>::wrap(Box::new(move || {
                        let input = input.clone();
                        let mutable = mutable.clone();
                        wasm_bindgen_futures::spawn_local(async move {
                            file_callback(input.files(), mutable.clone()).await;
                        })
                    }))
                };

                element
                    .add_event_listener_with_callback(
                        "change",
                        recv_file.as_ref().dyn_ref().unwrap(),
                    )
                    .expect("Listen for file upload");
                recv_file.forget();
                el
            })
    })
}

async fn file_callback(files: Option<FileList>, mutable: Mutable<Vec<u8>>) {
    let files = match files {
        Some(files) => files,
        None => return,
    };
    for i in 0..files.length() {
        let file = match files.item(i) {
            Some(file) => file,
            None => continue,
        };
        // gloo::console::console_dbg!("File:", &file.name());
        let reader = file
            .stream()
            .get_reader()
            .dyn_into::<ReadableStreamDefaultReader>()
            .expect("Reader is reader");
        let mut data = Vec::new();
        loop {
            let chunk = JsFuture::from(reader.read())
                .await
                .expect("Read")
                .dyn_into::<Object>()
                .unwrap();
            // ReadableStreamReadResult is somehow wrong. So go by hand. Might be a web-sys bug.
            let done = Reflect::get(&chunk, &"done".into()).expect("Get done");
            if done.is_truthy() {
                break;
            }
            let chunk = Reflect::get(&chunk, &"value".into())
                .expect("Get chunk")
                .dyn_into::<Uint8Array>()
                .expect("bytes are bytes");
            let data_len = data.len();
            data.resize(data_len + chunk.length() as usize, 255);
            chunk.copy_to(&mut data[data_len..]);
        }
        mutable.set(data);
        // gloo::console::console_dbg!(
        //     "Got data",
        //     &String::from_utf8_lossy(&data).into_owned(),
        // );
    }
}