feat: switch booklet viewer from pdfjs-dist to MuPDF backend
Replace browser-side pdfjs-dist canvas rendering with server-side MuPDF rendering for high-quality PDF booklet display. MuPDF renders pages to PNG at configurable DPI in Rust, producing much sharper results than the JavaScript canvas approach which had persistent JPEG 2000 WASM decoder issues. - Add mupdf crate with sys-lib features (avoid GTK symbol conflicts) - Create pdf_viewer.rs with v2_booklet_open/render_page/close commands - Download PDF to temp file, render pages on demand via MuPDF - Display rendered PNGs as img tags instead of canvas - DPI scales with zoom and devicePixelRatio for crisp output - Support rotation via MuPDF transformation matrix
This commit is contained in:
4 files changed
+369
-81
No files matched your search
@@ -96,6 +96,17 @@ notify-rust = "4"
|
||||
# Image processing (tray icon + thumbnails)
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
|
||||
# PDF rendering with MuPDF engine (high-quality rasterization for booklet viewer)
|
||||
# NOTE: Use sys-lib-* features to link common libs from system, avoiding symbol
|
||||
# conflicts with GTK/GdkPixbuf used by tauri-plugin-dialog on Linux.
|
||||
mupdf = { version = "0.5", default-features = false, features = [
|
||||
"system-fonts",
|
||||
"sys-lib-libjpeg",
|
||||
"sys-lib-zlib",
|
||||
"sys-lib-harfbuzz",
|
||||
"sys-lib-freetype",
|
||||
] }
|
||||
|
||||
# URL opening
|
||||
open = "5"
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ pub mod tray;
|
||||
pub mod updates;
|
||||
pub mod user_data;
|
||||
pub mod visualizer;
|
||||
pub mod pdf_viewer;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tauri::{Emitter, Manager};
|
||||
@@ -972,6 +973,7 @@ pub fn run() {
|
||||
.manage(developer_settings_state)
|
||||
.manage(graphics_settings_state)
|
||||
.manage(window_settings_state)
|
||||
.manage(pdf_viewer::BookletState::new())
|
||||
// V2 integration states (qbz-integrations crate)
|
||||
.manage(listenbrainz_v2_state)
|
||||
.manage(musicbrainz_v2_state)
|
||||
@@ -1426,6 +1428,10 @@ pub fn run() {
|
||||
commands_v2::v2_get_system_color_scheme,
|
||||
commands_v2::v2_extract_palette,
|
||||
commands_v2::v2_fetch_url_bytes,
|
||||
// PDF booklet viewer (MuPDF backend)
|
||||
pdf_viewer::v2_booklet_open,
|
||||
pdf_viewer::v2_booklet_render_page,
|
||||
pdf_viewer::v2_booklet_close,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! PDF Viewer module using MuPDF for high-quality rendering.
|
||||
//!
|
||||
//! Used by the booklet viewer to render Qobuz album booklet PDFs.
|
||||
//! MuPDF renders pages server-side to PNG images at configurable DPI,
|
||||
//! producing much sharper results than browser-based pdfjs-dist canvas rendering.
|
||||
|
||||
use base64::Engine;
|
||||
use mupdf::{Colorspace, Document, Matrix};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Page size in points (1/72 inch)
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PageSize {
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
/// Info returned when opening a booklet
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct BookletInfo {
|
||||
pub num_pages: u32,
|
||||
pub page_sizes: Vec<PageSize>,
|
||||
}
|
||||
|
||||
/// Rendered page result
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RenderedPage {
|
||||
/// Base64-encoded PNG image data
|
||||
pub data: String,
|
||||
/// Width of the rendered image in pixels
|
||||
pub width: u32,
|
||||
/// Height of the rendered image in pixels
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// State for the currently open booklet PDF
|
||||
pub struct BookletState {
|
||||
/// Path to the temp file holding the downloaded PDF
|
||||
current_path: Mutex<Option<PathBuf>>,
|
||||
}
|
||||
|
||||
impl BookletState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_path: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn booklet_temp_dir() -> PathBuf {
|
||||
std::env::temp_dir().join("qbz-booklets")
|
||||
}
|
||||
|
||||
/// Download a PDF from a URL, save to temp, and return page info.
|
||||
#[tauri::command]
|
||||
pub async fn v2_booklet_open(
|
||||
url: String,
|
||||
state: tauri::State<'_, BookletState>,
|
||||
) -> Result<BookletInfo, String> {
|
||||
// Clean up any previous booklet
|
||||
{
|
||||
let mut path_lock = state.current_path.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(old_path) = path_lock.take() {
|
||||
let _ = std::fs::remove_file(&old_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Download PDF bytes
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| format!("HTTP client error: {}", e))?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch PDF: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}: {}", response.status(), url));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||
|
||||
// Save to temp file
|
||||
let temp_dir = booklet_temp_dir();
|
||||
std::fs::create_dir_all(&temp_dir)
|
||||
.map_err(|e| format!("Failed to create temp dir: {}", e))?;
|
||||
|
||||
let file_name = format!("{}.pdf", uuid::Uuid::new_v4());
|
||||
let temp_path = temp_dir.join(&file_name);
|
||||
|
||||
std::fs::write(&temp_path, &bytes)
|
||||
.map_err(|e| format!("Failed to write temp file: {}", e))?;
|
||||
|
||||
// Open with MuPDF and get page info (blocking operation)
|
||||
let path_str = temp_path.to_string_lossy().to_string();
|
||||
let info = tokio::task::spawn_blocking(move || -> Result<BookletInfo, String> {
|
||||
let document = Document::open(&path_str)
|
||||
.map_err(|e| format!("Failed to open PDF: {:?}", e))?;
|
||||
|
||||
let num_pages = document
|
||||
.page_count()
|
||||
.map_err(|e| format!("Failed to get page count: {:?}", e))? as u32;
|
||||
|
||||
let mut page_sizes = Vec::with_capacity(num_pages as usize);
|
||||
for i in 0..num_pages {
|
||||
match document.load_page(i as i32) {
|
||||
Ok(page) => {
|
||||
let bounds = page
|
||||
.bounds()
|
||||
.map_err(|e| format!("Failed to get page bounds: {:?}", e))?;
|
||||
page_sizes.push(PageSize {
|
||||
width: bounds.width(),
|
||||
height: bounds.height(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load page {}: {:?}", i, e);
|
||||
page_sizes.push(PageSize {
|
||||
width: 612.0,
|
||||
height: 792.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BookletInfo {
|
||||
num_pages,
|
||||
page_sizes,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Task failed: {}", e))??;
|
||||
|
||||
// Store the temp path
|
||||
{
|
||||
let mut path_lock = state.current_path.lock().map_err(|e| e.to_string())?;
|
||||
*path_lock = Some(temp_path);
|
||||
}
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Render a single page at the specified DPI.
|
||||
#[tauri::command]
|
||||
pub async fn v2_booklet_render_page(
|
||||
page: u32,
|
||||
dpi: u32,
|
||||
rotation: Option<u32>,
|
||||
state: tauri::State<'_, BookletState>,
|
||||
) -> Result<RenderedPage, String> {
|
||||
let path = {
|
||||
let path_lock = state.current_path.lock().map_err(|e| e.to_string())?;
|
||||
path_lock
|
||||
.as_ref()
|
||||
.ok_or_else(|| "No booklet is open".to_string())?
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
let rotation = rotation.unwrap_or(0);
|
||||
let dpi = dpi.max(36).min(600); // Clamp DPI to sane range
|
||||
|
||||
tokio::task::spawn_blocking(move || -> Result<RenderedPage, String> {
|
||||
let document = Document::open(&path)
|
||||
.map_err(|e| format!("Failed to open PDF: {:?}", e))?;
|
||||
|
||||
let page_index = (page - 1) as i32;
|
||||
let pdf_page = document
|
||||
.load_page(page_index)
|
||||
.map_err(|e| format!("Failed to load page {}: {:?}", page, e))?;
|
||||
|
||||
// Calculate scale factor (PDF base is 72 DPI)
|
||||
let scale = dpi as f32 / 72.0;
|
||||
|
||||
// Build transformation matrix: scale, then rotate if needed
|
||||
let matrix = if rotation == 0 {
|
||||
Matrix::new_scale(scale, scale)
|
||||
} else {
|
||||
// Get page bounds to translate rotation center
|
||||
let bounds = pdf_page
|
||||
.bounds()
|
||||
.map_err(|e| format!("Failed to get bounds: {:?}", e))?;
|
||||
let w = bounds.width() * scale;
|
||||
let h = bounds.height() * scale;
|
||||
|
||||
// Compose: scale -> rotate -> translate (to keep in positive coords)
|
||||
let mut m = Matrix::new_scale(scale, scale);
|
||||
m.concat(Matrix::new_rotate(rotation as f32));
|
||||
match rotation % 360 {
|
||||
90 => { m.concat(Matrix::new_translate(h, 0.0)); }
|
||||
180 => { m.concat(Matrix::new_translate(w, h)); }
|
||||
270 => { m.concat(Matrix::new_translate(0.0, w)); }
|
||||
_ => {}
|
||||
};
|
||||
m
|
||||
};
|
||||
|
||||
// Render page to pixmap (RGB with alpha)
|
||||
let pixmap = pdf_page
|
||||
.to_pixmap(&matrix, &Colorspace::device_rgb(), true, true)
|
||||
.map_err(|e| format!("Failed to render page: {:?}", e))?;
|
||||
|
||||
let actual_width = pixmap.width() as u32;
|
||||
let actual_height = pixmap.height() as u32;
|
||||
|
||||
// Encode to PNG
|
||||
let mut png_data = Vec::new();
|
||||
let mut cursor = Cursor::new(&mut png_data);
|
||||
pixmap
|
||||
.write_to(&mut cursor, mupdf::ImageFormat::PNG)
|
||||
.map_err(|e| format!("Failed to encode PNG: {:?}", e))?;
|
||||
|
||||
// Base64 encode
|
||||
let base64_data = base64::engine::general_purpose::STANDARD.encode(&png_data);
|
||||
|
||||
Ok(RenderedPage {
|
||||
data: base64_data,
|
||||
width: actual_width,
|
||||
height: actual_height,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Render task failed: {}", e))?
|
||||
}
|
||||
|
||||
/// Clean up the current booklet temp file.
|
||||
#[tauri::command]
|
||||
pub async fn v2_booklet_close(
|
||||
state: tauri::State<'_, BookletState>,
|
||||
) -> Result<(), String> {
|
||||
let mut path_lock = state.current_path.lock().map_err(|e| e.to_string())?;
|
||||
if let Some(old_path) = path_lock.take() {
|
||||
let _ = std::fs::remove_file(&old_path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -3,11 +3,22 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { X, ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Maximize, RotateCw } from 'lucide-svelte';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
// Serve worker from static/ (real HTTP URL, not Vite blob) so it
|
||||
// can resolve sibling WASM files (openjpeg.wasm, jbig2.wasm, etc.)
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.mjs';
|
||||
interface PageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface BookletInfo {
|
||||
num_pages: number;
|
||||
page_sizes: PageSize[];
|
||||
}
|
||||
|
||||
interface RenderedPage {
|
||||
data: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -23,98 +34,105 @@
|
||||
title = ''
|
||||
}: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement | null = $state(null);
|
||||
let pdfDoc: any = null;
|
||||
let currentPage = $state(1);
|
||||
let totalPages = $state(0);
|
||||
let zoom = $state(1);
|
||||
let rotation = $state(0);
|
||||
let isLoading = $state(true);
|
||||
let isRendering = $state(false);
|
||||
let error = $state('');
|
||||
let currentRenderTask: any = null;
|
||||
let containerEl: HTMLDivElement | null = $state(null);
|
||||
let pageImageSrc = $state('');
|
||||
let displayWidth = $state(0);
|
||||
let displayHeight = $state(0);
|
||||
|
||||
let pageSizes: PageSize[] = [];
|
||||
let bookletLoaded = false;
|
||||
|
||||
// Zoom is relative: 1.0 = fit to container width
|
||||
const MIN_ZOOM = 0.5;
|
||||
const MAX_ZOOM = 5;
|
||||
const ZOOM_STEP = 0.25;
|
||||
const BASE_DPI = 150;
|
||||
|
||||
async function loadPdf() {
|
||||
async function loadBooklet() {
|
||||
if (!url) return;
|
||||
|
||||
isLoading = true;
|
||||
error = '';
|
||||
bookletLoaded = false;
|
||||
|
||||
try {
|
||||
// Fetch PDF bytes through Tauri backend to bypass CORS
|
||||
const bytes = await invoke<number[]>('v2_fetch_url_bytes', { url });
|
||||
const data = new Uint8Array(bytes);
|
||||
|
||||
const loadingTask = pdfjsLib.getDocument({
|
||||
data,
|
||||
disableRange: true,
|
||||
disableAutoFetch: true,
|
||||
wasmUrl: '/pdfjs/',
|
||||
} as any);
|
||||
|
||||
pdfDoc = await loadingTask.promise;
|
||||
totalPages = pdfDoc.numPages;
|
||||
const info = await invoke<BookletInfo>('v2_booklet_open', { url });
|
||||
totalPages = info.num_pages;
|
||||
pageSizes = info.page_sizes;
|
||||
currentPage = 1;
|
||||
// Set loading false FIRST so canvas mounts in the DOM
|
||||
isLoading = false;
|
||||
await tick(); // Wait for Svelte to update DOM
|
||||
zoom = 1;
|
||||
rotation = 0;
|
||||
bookletLoaded = true;
|
||||
|
||||
// Auto-fit: calculate zoom so page fills container width
|
||||
const firstPage = await pdfDoc.getPage(1);
|
||||
const baseVp = firstPage.getViewport({ scale: 1, rotation });
|
||||
const availableWidth = (containerEl?.clientWidth ?? window.innerWidth) - 48;
|
||||
zoom = Math.min(availableWidth / baseVp.width, MAX_ZOOM);
|
||||
isLoading = false;
|
||||
await tick();
|
||||
|
||||
await renderPage();
|
||||
} catch (err: any) {
|
||||
console.error('[BookletViewer] Failed to load PDF:', err);
|
||||
console.error('[BookletViewer] Failed to load booklet:', err);
|
||||
error = err?.message || String(err) || 'Failed to load booklet';
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
if (!pdfDoc || !canvas) return;
|
||||
function calculateDpi(): number {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
// Render at enough DPI for crisp display at current zoom
|
||||
// zoom=1 means fit-to-width, so DPI should produce an image
|
||||
// that fills container width at native screen resolution
|
||||
return Math.round(BASE_DPI * zoom * dpr);
|
||||
}
|
||||
|
||||
if (currentRenderTask) {
|
||||
currentRenderTask.cancel();
|
||||
currentRenderTask = null;
|
||||
function calculateDisplaySize(): { width: number; height: number } {
|
||||
if (pageSizes.length === 0) return { width: 0, height: 0 };
|
||||
|
||||
const pageSize = pageSizes[currentPage - 1] || pageSizes[0];
|
||||
const containerWidth = (containerEl?.clientWidth ?? window.innerWidth) - 48;
|
||||
|
||||
// At zoom=1, page width fills container
|
||||
const fitScale = containerWidth / pageSize.width;
|
||||
|
||||
if (rotation === 90 || rotation === 270) {
|
||||
return {
|
||||
width: pageSize.height * fitScale * zoom,
|
||||
height: pageSize.width * fitScale * zoom,
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: pageSize.width * fitScale * zoom,
|
||||
height: pageSize.height * fitScale * zoom,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
if (!bookletLoaded) return;
|
||||
|
||||
isRendering = true;
|
||||
|
||||
try {
|
||||
const page = await pdfDoc.getPage(currentPage);
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
// Render at 3x for near-print quality (216 DPI at 1x screens)
|
||||
const qualityScale = 3;
|
||||
const scale = zoom * dpr * qualityScale;
|
||||
const viewport = page.getViewport({ scale, rotation });
|
||||
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
// CSS size = canvas pixels / (dpr * qualityScale)
|
||||
canvas.style.width = `${viewport.width / (dpr * qualityScale)}px`;
|
||||
canvas.style.height = `${viewport.height / (dpr * qualityScale)}px`;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
context.fillStyle = 'white';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
currentRenderTask = page.render({
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
const dpi = calculateDpi();
|
||||
const result = await invoke<RenderedPage>('v2_booklet_render_page', {
|
||||
page: currentPage,
|
||||
dpi,
|
||||
rotation,
|
||||
});
|
||||
await currentRenderTask.promise;
|
||||
currentRenderTask = null;
|
||||
|
||||
pageImageSrc = `data:image/png;base64,${result.data}`;
|
||||
const size = calculateDisplaySize();
|
||||
displayWidth = size.width;
|
||||
displayHeight = size.height;
|
||||
} catch (err: any) {
|
||||
if (err?.name !== 'RenderingCancelledException') {
|
||||
console.error('[BookletViewer] Render error:', err);
|
||||
}
|
||||
console.error('[BookletViewer] Render error:', err);
|
||||
error = err?.message || String(err) || 'Failed to render page';
|
||||
} finally {
|
||||
isRendering = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,12 +164,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fitToWidth() {
|
||||
if (!pdfDoc) return;
|
||||
const page = await pdfDoc.getPage(currentPage);
|
||||
const baseVp = page.getViewport({ scale: 1, rotation });
|
||||
const availableWidth = (containerEl?.clientWidth ?? window.innerWidth) - 48;
|
||||
zoom = Math.min(availableWidth / baseVp.width, MAX_ZOOM);
|
||||
function fitToWidth() {
|
||||
zoom = 1;
|
||||
renderPage();
|
||||
}
|
||||
|
||||
@@ -196,10 +210,8 @@
|
||||
document.body.appendChild(node);
|
||||
return {
|
||||
destroy() {
|
||||
if (pdfDoc) {
|
||||
pdfDoc.destroy();
|
||||
pdfDoc = null;
|
||||
}
|
||||
// Clean up the temp file when the portal is destroyed
|
||||
invoke('v2_booklet_close').catch(() => {});
|
||||
node.remove();
|
||||
}
|
||||
};
|
||||
@@ -207,14 +219,15 @@
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen && url) {
|
||||
loadPdf();
|
||||
loadBooklet();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen && pdfDoc) {
|
||||
pdfDoc.destroy();
|
||||
pdfDoc = null;
|
||||
if (!isOpen && bookletLoaded) {
|
||||
invoke('v2_booklet_close').catch(() => {});
|
||||
bookletLoaded = false;
|
||||
pageImageSrc = '';
|
||||
currentPage = 1;
|
||||
totalPages = 0;
|
||||
zoom = 1;
|
||||
@@ -277,9 +290,17 @@
|
||||
<span>{$t('album.bookletError')}</span>
|
||||
<span class="error-detail">{error}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="canvas-wrapper">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
{:else if pageImageSrc}
|
||||
<div class="page-wrapper">
|
||||
<img
|
||||
src={pageImageSrc}
|
||||
alt="Page {currentPage}"
|
||||
class="page-image"
|
||||
class:rendering={isRendering}
|
||||
style:width="{displayWidth}px"
|
||||
style:height="{displayHeight}px"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -395,16 +416,21 @@
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
.page-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.canvas-wrapper canvas {
|
||||
.page-image {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.page-image.rendering {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.booklet-loading,
|
||||
|
||||
Reference in new issue
Block a user