use axum::body::Body; use axum::extract::{Path, Query, State}; use axum::http::{StatusCode, header}; use axum::response::Response; use crate::handler::html::error::html_err; use crate::handler::html::{HtmlRepoPath, resolve_view}; use crate::service::GitService; use crate::service::pdf; use crate::share::access::BasicAuth; #[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)] pub struct BlobPagesQuery { #[serde(default)] pub revision: Option, #[serde(default)] pub path: Option, /// Rasterisation scale: 1.0 is 72 dpi. Clamped to 0.5..=3.0. #[serde(default)] pub scale: Option, } /// Rasterised pages of a PDF, streamed one page at a time. /// /// Framing: repeating `u32 header_len | header | u32 payload_len | payload`, /// all big-endian. The header is JSON — `{page,total,width,height}` per page, /// `{page,total,error}` for a page that failed, `{total,done}` to close — and /// the payload of a page frame is its PNG bytes. Streaming (rather than one /// request per page) is what keeps the first page instant while the rest of a /// long document is still being rasterised. #[utoipa::path( get, path = "/repos/{space_name}/{repo_name}/html/blob-pages", tag = "html", description = "按页栅格化 PDF 并以分帧流式返回 PNG,供文件页阅读器使用。", operation_id = "render_blob_pages", params(HtmlRepoPath, BlobPagesQuery), responses( (status = 200, description = "framed png page stream"), (status = 404, description = "repo, ref or blob not found"), (status = 400, description = "blob is not a readable pdf") ) )] pub async fn render_blob_pages( State(service): State, Path(path): Path, Query(q): Query, auth: BasicAuth, ) -> Response { let view = match resolve_view(service.clone(), path, auth, q.revision, q.path, true).await { Ok(v) => v, Err(resp) => return resp, }; let ctx = view.ctx; let scale = pdf::clamp_scale(q.scale); let receiver = match pdf::render_stream( &service, &ctx.space_name, &ctx.repo_name, &view.revision, &view.clean_path, scale, ) .await { Ok(rx) => rx, Err(crate::share::errors::GitError::NotFound) => return html_err(404, "blob not found"), Err(e) => { tracing::warn!(error = %e, "pdf page stream rejected"); return html_err(400, "blob is not a readable pdf"); } }; let stream = futures::stream::unfold(receiver, |mut rx| async move { rx.recv() .await .map(|chunk| (Ok::, std::io::Error>(chunk), rx)) }); let mut resp = Response::new(Body::from_stream(stream)); *resp.status_mut() = StatusCode::OK; let headers = resp.headers_mut(); headers.insert( header::CONTENT_TYPE, "application/octet-stream".parse().unwrap(), ); headers.insert(header::CACHE_CONTROL, "private, no-store".parse().unwrap()); headers.insert(header::VARY, "Cookie".parse().unwrap()); // Reverse proxies must hand each frame through as it is produced, otherwise // the streaming reader degrades into a spinner until the document ends. headers.insert("x-accel-buffering", "no".parse().unwrap()); resp }