use std::sync::Arc;
use wasm_bindgen::prelude::*;
use crate::{
djvu_document::DjVuDocument,
djvu_render::{render_coarse, render_progressive},
djvu_tile,
};
#[wasm_bindgen]
pub struct WasmPixmap {
data: Vec<u8>,
width: u32,
height: u32,
}
#[wasm_bindgen]
impl WasmPixmap {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmPixmap {
WasmPixmap {
data: Vec::new(),
width: 0,
height: 0,
}
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn byte_length(&self) -> u32 {
self.data.len() as u32
}
#[allow(unsafe_code)]
pub fn view(&self) -> js_sys::Uint8ClampedArray {
unsafe { js_sys::Uint8ClampedArray::view(&self.data) }
}
pub fn to_bytes(&self) -> js_sys::Uint8ClampedArray {
let arr = js_sys::Uint8ClampedArray::new_with_length(self.data.len() as u32);
arr.copy_from(&self.data);
arr
}
}
impl Default for WasmPixmap {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "wasm-threads")]
pub use wasm_bindgen_rayon::init_thread_pool;
#[wasm_bindgen]
pub struct WasmDocument {
inner: Arc<DjVuDocument>,
}
#[wasm_bindgen]
impl WasmDocument {
pub fn from_bytes(data: Vec<u8>) -> Result<WasmDocument, JsError> {
let backing: crate::djvu_document::Backing = Arc::new(data);
let doc = DjVuDocument::parse_backed_with_options(
backing,
&crate::resource_limits::ParseOptions::default(),
)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(WasmDocument {
inner: Arc::new(doc),
})
}
pub fn page_count(&self) -> u32 {
self.inner.page_count() as u32
}
pub fn render_pages_batch(
&self,
target_dpi: u32,
start: u32,
count: u32,
) -> Result<Vec<WasmPixmap>, JsError> {
let total = self.inner.page_count();
let end = (start as usize).saturating_add(count as usize).min(total);
let idxs: Vec<usize> = ((start as usize).min(total)..end).collect();
let render_one = |&i: &usize| -> Result<WasmPixmap, String> {
let pm = crate::foreign::render_at_dpi(&self.inner, i, target_dpi as f32)
.map_err(|e| e.to_string())?;
Ok(WasmPixmap {
width: pm.width,
height: pm.height,
data: pm.data,
})
};
#[cfg(feature = "parallel")]
let out: Result<Vec<WasmPixmap>, String> = {
use rayon::prelude::*;
idxs.par_iter().map(render_one).collect()
};
#[cfg(not(feature = "parallel"))]
let out: Result<Vec<WasmPixmap>, String> = idxs.iter().map(render_one).collect();
out.map_err(|e| JsError::new(&e))
}
pub fn page(&self, index: u32) -> Result<WasmPage, JsError> {
let count = self.inner.page_count();
if index as usize >= count {
return Err(JsError::new(&format!(
"page index {index} out of range (document has {count} pages)"
)));
}
Ok(WasmPage {
doc: Arc::clone(&self.inner),
index: index as usize,
})
}
}
#[wasm_bindgen]
pub struct WasmPage {
doc: Arc<DjVuDocument>,
index: usize,
}
#[wasm_bindgen]
impl WasmPage {
pub fn dpi(&self) -> u32 {
crate::foreign::page_dpi(&self.doc, self.index).unwrap_or(300)
}
pub fn width_at(&self, target_dpi: u32) -> u32 {
self.doc
.page(self.index)
.map(|p| crate::export_common::size_at_dpi(p, target_dpi as f32).0)
.unwrap_or(1)
}
pub fn height_at(&self, target_dpi: u32) -> u32 {
self.doc
.page(self.index)
.map(|p| crate::export_common::size_at_dpi(p, target_dpi as f32).1)
.unwrap_or(1)
}
pub fn text(&self) -> Result<Option<String>, JsError> {
crate::foreign::text(&self.doc, self.index).map_err(|e| JsError::new(&e.to_string()))
}
pub fn text_zones_json(&self, target_dpi: u32) -> Result<Option<String>, JsError> {
let page = crate::foreign::page(&self.doc, self.index)
.map_err(|e| JsError::new(&e.to_string()))?;
let (render_w, render_h) = crate::export_common::size_at_dpi(page, target_dpi as f32);
let Some(layer) = page
.text_layer_at_size(render_w, render_h)
.map_err(|e| JsError::new(&e.to_string()))?
else {
return Ok(None);
};
let mut buf = String::from("[");
let mut first = true;
for zone in &layer.zones {
collect_leaf_zones(zone, &mut buf, &mut first);
}
buf.push(']');
Ok(Some(buf))
}
pub fn bg44_chunk_count(&self) -> u32 {
self.doc
.page(self.index)
.map(|p| p.bg44_chunks().len() as u32)
.unwrap_or(0)
}
pub fn render_coarse(
&self,
target_dpi: u32,
) -> Result<Option<js_sys::Uint8ClampedArray>, JsError> {
let page = crate::foreign::page(&self.doc, self.index)
.map_err(|e| JsError::new(&e.to_string()))?;
let opts = crate::foreign::render_opts_for_dpi(page, target_dpi as f32);
let pm = render_coarse(page, &opts).map_err(|e| JsError::new(&e.to_string()))?;
Ok(pm.map(|p| {
let arr = js_sys::Uint8ClampedArray::new_with_length(p.data.len() as u32);
arr.copy_from(&p.data);
arr
}))
}
pub fn render_progressive(
&self,
target_dpi: u32,
chunk_n: u32,
) -> Result<js_sys::Uint8ClampedArray, JsError> {
let page = crate::foreign::page(&self.doc, self.index)
.map_err(|e| JsError::new(&e.to_string()))?;
let opts = crate::foreign::render_opts_for_dpi(page, target_dpi as f32);
let pm = render_progressive(page, &opts, chunk_n as usize)
.map_err(|e| JsError::new(&e.to_string()))?;
let arr = js_sys::Uint8ClampedArray::new_with_length(pm.data.len() as u32);
arr.copy_from(&pm.data);
Ok(arr)
}
pub fn render(&self, target_dpi: u32) -> Result<js_sys::Uint8ClampedArray, JsError> {
let pm = crate::foreign::render_at_dpi(&self.doc, self.index, target_dpi as f32)
.map_err(|e| JsError::new(&e.to_string()))?;
let arr = js_sys::Uint8ClampedArray::new_with_length(pm.data.len() as u32);
arr.copy_from(&pm.data);
Ok(arr)
}
pub fn render_into_pixmap(&self, target_dpi: u32, out: &mut WasmPixmap) -> Result<(), JsError> {
let page = crate::foreign::page(&self.doc, self.index)
.map_err(|e| JsError::new(&e.to_string()))?;
let opts = crate::foreign::render_opts_for_dpi(page, target_dpi as f32);
let need = opts.width as usize * opts.height as usize * 4;
out.data.resize(need, 0);
crate::djvu_render::render_into(page, &opts, &mut out.data)
.map_err(|e| JsError::new(&e.to_string()))?;
out.width = opts.width;
out.height = opts.height;
Ok(())
}
pub fn render_progressive_into_pixmap(
&self,
target_dpi: u32,
chunk_n: u32,
out: &mut WasmPixmap,
) -> Result<(), JsError> {
let page = crate::foreign::page(&self.doc, self.index)
.map_err(|e| JsError::new(&e.to_string()))?;
let opts = crate::foreign::render_opts_for_dpi(page, target_dpi as f32);
let pm = render_progressive(page, &opts, chunk_n as usize)
.map_err(|e| JsError::new(&e.to_string()))?;
out.width = pm.width;
out.height = pm.height;
out.data.clear();
out.data.extend_from_slice(&pm.data);
Ok(())
}
pub fn tile_cols(&self, target_dpi: u32, tile_size: u32) -> Result<u32, JsError> {
self.tile_layout(target_dpi, tile_size)
.map(|layout| layout.cols())
}
pub fn tile_rows(&self, target_dpi: u32, tile_size: u32) -> Result<u32, JsError> {
self.tile_layout(target_dpi, tile_size)
.map(|layout| layout.rows())
}
pub fn render_tile(
&self,
target_dpi: u32,
tile_size: u32,
col: u32,
row: u32,
) -> Result<WasmPixmap, JsError> {
let page = crate::foreign::page(&self.doc, self.index)
.map_err(|e| JsError::new(&e.to_string()))?;
let opts = crate::foreign::render_opts_for_dpi(page, target_dpi as f32);
let pm = djvu_tile::render_tile_cached(page, &opts, tile_size, col, row)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(WasmPixmap {
data: pm.data,
width: pm.width,
height: pm.height,
})
}
pub fn render_tile_progressive(
&self,
target_dpi: u32,
tile_size: u32,
col: u32,
row: u32,
chunk_n: u32,
) -> Result<WasmPixmap, JsError> {
let page = crate::foreign::page(&self.doc, self.index)
.map_err(|e| JsError::new(&e.to_string()))?;
let opts = crate::foreign::render_opts_for_dpi(page, target_dpi as f32);
let controls = djvu_tile::TileRenderControls {
quality_step: Some(chunk_n as usize),
..Default::default()
};
let pm = djvu_tile::render_tile_with(page, &opts, tile_size, col, row, &controls)
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(WasmPixmap {
data: pm.data,
width: pm.width,
height: pm.height,
})
}
pub fn render_tile_into_pixmap(
&self,
target_dpi: u32,
tile_size: u32,
col: u32,
row: u32,
out: &mut WasmPixmap,
) -> Result<(), JsError> {
let page = crate::foreign::page(&self.doc, self.index)
.map_err(|e| JsError::new(&e.to_string()))?;
let opts = crate::foreign::render_opts_for_dpi(page, target_dpi as f32);
let pm = djvu_tile::render_tile_cached(page, &opts, tile_size, col, row)
.map_err(|e| JsError::new(&e.to_string()))?;
out.width = pm.width;
out.height = pm.height;
out.data.clear();
out.data.extend_from_slice(&pm.data);
Ok(())
}
}
impl WasmPage {
fn tile_layout(
&self,
target_dpi: u32,
tile_size: u32,
) -> Result<djvu_tile::TileLayout, JsError> {
let page = crate::foreign::page(&self.doc, self.index)
.map_err(|e| JsError::new(&e.to_string()))?;
let opts = crate::foreign::render_opts_for_dpi(page, target_dpi as f32);
djvu_tile::TileLayout::new(page, &opts, tile_size).map_err(|e| JsError::new(&e.to_string()))
}
}
fn collect_leaf_zones(zone: &crate::text::TextZone, buf: &mut String, first: &mut bool) {
if zone.children.is_empty() {
let t = zone.text.trim();
if t.is_empty() {
return;
}
if !*first {
buf.push(',');
}
*first = false;
buf.push_str("{\"t\":\"");
json_escape_into(t, buf);
buf.push_str("\",\"x\":");
buf.push_str(&zone.rect.x.to_string());
buf.push_str(",\"y\":");
buf.push_str(&zone.rect.y.to_string());
buf.push_str(",\"w\":");
buf.push_str(&zone.rect.width.to_string());
buf.push_str(",\"h\":");
buf.push_str(&zone.rect.height.to_string());
buf.push('}');
} else {
for child in &zone.children {
collect_leaf_zones(child, buf, first);
}
}
}
fn json_escape_into(s: &str, buf: &mut String) {
for ch in s.chars() {
match ch {
'"' => buf.push_str("\\\""),
'\\' => buf.push_str("\\\\"),
'\n' => buf.push_str("\\n"),
'\r' => buf.push_str("\\r"),
'\t' => buf.push_str("\\t"),
c if (c as u32) < 0x20 => {
buf.push_str(&format!("\\u{:04x}", c as u32));
}
c => buf.push(c),
}
}
}
#[cfg(all(feature = "async", target_arch = "wasm32"))]
mod lazy {
use std::{
collections::{BTreeMap, VecDeque},
future::Future,
io::SeekFrom,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use tokio::io::{AsyncRead, AsyncSeek, ReadBuf};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use super::WasmPixmap;
use crate::djvu_async::{LazyDocument, from_async_reader_lazy_local};
const LAZY_BLOCK: u64 = 64 * 1024;
const LAZY_CACHE_BLOCKS: usize = 64;
fn js_io_err(e: JsValue) -> std::io::Error {
std::io::Error::other(
e.as_string()
.unwrap_or_else(|| "JS range fetch failed".to_string()),
)
}
struct JsRangeReader {
fetch: js_sys::Function,
len: u64,
pos: u64,
cache: BTreeMap<u64, Vec<u8>>,
order: VecDeque<u64>,
pending: Option<(u64, JsFuture)>,
}
impl JsRangeReader {
fn new(fetch: js_sys::Function, len: u64) -> Self {
Self {
fetch,
len,
pos: 0,
cache: BTreeMap::new(),
order: VecDeque::new(),
pending: None,
}
}
fn poll_block(&mut self, block: u64, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
if self.cache.contains_key(&block) {
return Poll::Ready(Ok(()));
}
if self.pending.as_ref().map(|(b, _)| *b) != Some(block) {
let start = block * LAZY_BLOCK;
let len = LAZY_BLOCK.min(self.len.saturating_sub(start));
let promise = self
.fetch
.call2(
&JsValue::NULL,
&JsValue::from_f64(start as f64),
&JsValue::from_f64(len as f64),
)
.map_err(js_io_err)?;
self.pending = Some((block, JsFuture::from(js_sys::Promise::from(promise))));
}
let (_, fut) = self.pending.as_mut().expect("pending fetch just set");
match Pin::new(fut).poll(cx) {
Poll::Pending => Poll::Pending,
Poll::Ready(Err(e)) => {
self.pending = None;
Poll::Ready(Err(js_io_err(e)))
}
Poll::Ready(Ok(value)) => {
self.pending = None;
let bytes = js_sys::Uint8Array::new(&value).to_vec();
self.cache.insert(block, bytes);
self.order.push_back(block);
if self.order.len() > LAZY_CACHE_BLOCKS
&& let Some(old) = self.order.pop_front()
{
self.cache.remove(&old);
}
Poll::Ready(Ok(()))
}
}
}
}
impl AsyncRead for JsRangeReader {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<std::io::Result<()>> {
if self.pos >= self.len {
return Poll::Ready(Ok(())); }
let block = self.pos / LAZY_BLOCK;
match self.poll_block(block, cx) {
Poll::Pending => return Poll::Pending,
Poll::Ready(Err(e)) => return Poll::Ready(Err(e)),
Poll::Ready(Ok(())) => {}
}
let data = &self.cache[&block];
let off = (self.pos - block * LAZY_BLOCK) as usize;
let n = buf.remaining().min(data.len().saturating_sub(off));
if n == 0 {
return Poll::Ready(Err(std::io::Error::other(
"range fetch returned fewer bytes than requested",
)));
}
buf.put_slice(&data[off..off + n]);
self.pos += n as u64;
Poll::Ready(Ok(()))
}
}
impl AsyncSeek for JsRangeReader {
fn start_seek(mut self: Pin<&mut Self>, position: SeekFrom) -> std::io::Result<()> {
self.pos = match position {
SeekFrom::Start(o) => o,
SeekFrom::End(o) => (self.len as i64).saturating_add(o).max(0) as u64,
SeekFrom::Current(o) => (self.pos as i64).saturating_add(o).max(0) as u64,
};
Ok(())
}
fn poll_complete(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
) -> Poll<std::io::Result<u64>> {
Poll::Ready(Ok(self.pos))
}
}
#[wasm_bindgen]
pub struct WasmLazyDocument {
inner: LazyDocument<JsRangeReader>,
}
#[wasm_bindgen]
impl WasmLazyDocument {
pub async fn open(
total_len: f64,
fetch: js_sys::Function,
) -> Result<WasmLazyDocument, JsError> {
let reader = JsRangeReader::new(fetch, total_len as u64);
let inner = from_async_reader_lazy_local(reader)
.await
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(WasmLazyDocument { inner })
}
pub fn page_count(&self) -> u32 {
self.inner.page_count() as u32
}
pub async fn page_info(&self, index: u32) -> Result<js_sys::Uint32Array, JsError> {
let page = self
.inner
.page_async(index as usize)
.await
.map_err(|e| JsError::new(&e.to_string()))?;
let out = [page.width() as u32, page.height() as u32, page.dpi() as u32];
Ok(js_sys::Uint32Array::from(&out[..]))
}
pub async fn render_page(
&self,
index: u32,
target_dpi: u32,
) -> Result<WasmPixmap, JsError> {
let page = self
.inner
.page_async(index as usize)
.await
.map_err(|e| JsError::new(&e.to_string()))?;
Self::render(&page, target_dpi, u32::MAX)
}
pub async fn render_page_progressive(
&self,
index: u32,
target_dpi: u32,
chunk_n: u32,
) -> Result<WasmPixmap, JsError> {
let page = self
.inner
.page_async(index as usize)
.await
.map_err(|e| JsError::new(&e.to_string()))?;
Self::render(&page, target_dpi, chunk_n)
}
fn render(
page: &Arc<crate::djvu_document::DjVuPage>,
target_dpi: u32,
chunk_n: u32,
) -> Result<WasmPixmap, JsError> {
let opts = crate::foreign::render_opts_for_dpi(page, target_dpi as f32);
let pm = if chunk_n == u32::MAX {
crate::djvu_render::render_pixmap(page, &opts)
} else {
crate::djvu_render::render_progressive(page, &opts, chunk_n as usize)
}
.map_err(|e| JsError::new(&e.to_string()))?;
Ok(WasmPixmap {
width: pm.width,
height: pm.height,
data: pm.data,
})
}
}
}
#[cfg(all(feature = "async", target_arch = "wasm32"))]
pub use lazy::WasmLazyDocument;
#[cfg(not(target_arch = "wasm32"))]
#[cfg(test)]
mod native_tests {
use super::*;
use crate::djvu_render::{RenderOptions, render_pixmap};
fn boy_bytes() -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/boy.djvu");
std::fs::read(&path).expect("boy.djvu not found in tests/fixtures/")
}
#[test]
fn wasm_document_from_bytes_page_count() {
let bytes = boy_bytes();
let doc = DjVuDocument::parse(&bytes).expect("parse failed");
assert_eq!(doc.page_count(), 1);
}
#[test]
fn wasm_document_from_bytes_invalid_returns_error() {
assert!(DjVuDocument::parse(b"not a djvu file").is_err());
}
#[test]
fn wasm_page_dpi() {
let bytes = boy_bytes();
let doc = DjVuDocument::parse(&bytes).unwrap();
assert_eq!(doc.page(0).unwrap().dpi(), 100);
}
#[test]
fn wasm_page_dimensions_at_dpi() {
let bytes = boy_bytes();
let doc = DjVuDocument::parse(&bytes).unwrap();
let page = doc.page(0).unwrap();
let scale = 50_f32 / page.dpi() as f32; let w = ((page.width() as f32 * scale).round() as u32).max(1);
let h = ((page.height() as f32 * scale).round() as u32).max(1);
assert_eq!(w, 96);
assert_eq!(h, 128);
}
#[test]
fn wasm_page_render_pixel_count() {
let bytes = boy_bytes();
let doc = DjVuDocument::parse(&bytes).unwrap();
let page = doc.page(0).unwrap();
let scale = 150_f32 / page.dpi() as f32;
let w = ((page.width() as f32 * scale).round() as u32).max(1);
let h = ((page.height() as f32 * scale).round() as u32).max(1);
let opts = RenderOptions {
width: w,
height: h,
..Default::default()
};
let pm = render_pixmap(page, &opts).expect("render failed");
assert_eq!(pm.data.len(), (w * h * 4) as usize);
}
#[test]
fn wasm_render_coarse_color_page() {
let bytes = boy_bytes();
let doc = DjVuDocument::parse(&bytes).unwrap();
let page = doc.page(0).unwrap();
let scale = 150_f32 / page.dpi() as f32;
let w = ((page.width() as f32 * scale).round() as u32).max(1);
let h = ((page.height() as f32 * scale).round() as u32).max(1);
let opts = crate::foreign::render_opts_for_dpi(page, 150.0);
let result = render_coarse(page, &opts).expect("render_coarse failed");
let pm = result.expect("expected Some for color page");
assert_eq!(pm.data.len(), (w * h * 4) as usize);
}
#[test]
fn wasm_bg44_chunk_count_color_page() {
let bytes = boy_bytes();
let doc = DjVuDocument::parse(&bytes).unwrap();
assert!(!doc.page(0).unwrap().bg44_chunks().is_empty());
}
#[test]
fn wasm_render_progressive_chunk0() {
let bytes = boy_bytes();
let doc = DjVuDocument::parse(&bytes).unwrap();
let page = doc.page(0).unwrap();
let scale = 150_f32 / page.dpi() as f32;
let w = ((page.width() as f32 * scale).round() as u32).max(1);
let h = ((page.height() as f32 * scale).round() as u32).max(1);
let opts = crate::foreign::render_opts_for_dpi(page, 150.0);
let pm = render_progressive(page, &opts, 0).expect("render_progressive failed");
assert_eq!(pm.data.len(), (w * h * 4) as usize);
}
}