use actix_web::http::header; use export::http::{Path, Query, Response, State}; use crate::handler::html::{HtmlRepoPath, error, resolve_view}; use crate::service::{GitService, pdf}; use ::session::Session; #[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, session: Session, ) -> Response { let view = match resolve_view(service.clone(), path, session, 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 error::html_err(404, "blob not found"); } Err(e) => { tracing::warn!(error = %e, "pdf page stream rejected"); return error::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::(actix_web::web::Bytes::from(chunk)), rx, ) }) }); let mut resp = actix_web::HttpResponse::Ok().streaming(stream); 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( header::HeaderName::from_static("x-accel-buffering"), "no".parse().unwrap(), ); resp }