crates/gunnar-ui-server/src/api/render.rs
//! **`GET …/render` and `GET …/pdf` — any markdown file in a tree, typeset.**
//!
//! ```text
//! ┌─ /render?as=svg ─▶ the typeset page (anonymous: yes)
//! any *.md in a tree ──┼─ /render?as=html ─▶ the plain page (anonymous: yes)
//! └─ /pdf ─▶ application/pdf (anonymous: NO)
//! ```
//!
//! # What this is, and what [`crate::api::readme`] is
//!
//! They are the same pipeline pointed at different files. `readme` answers a
//! question about a *repository* — which of `CANDIDATES` is at the root — and
//! this one answers a question about a *path*: the caller already knows which
//! file it wants. Everything after "here is some markdown" is shared, in
//! [`page`], so a document rendered through either route is the same document:
//! one cache, one queue, one sanitiser, one link rewriter.
//!
//! It exists because of what gunnar.rs did with
//! `/repo/gunnar/gunnar/files?path=.nornir/gunnar-quickstart.md`: it showed a
//! quickstart guide as 400 lines of markdown source, on a console that was
//! already typesetting the README two clicks away.
//!
//! # The two doors are not the same door, and that is deliberate
//!
//! The **rendered page** is on `is_anonymous_read`, beside `readme`, and by the
//! same argument: it is a public repository's own documentation, it reaches
//! gunnar through exactly the calls `browse` already makes for the same visitor,
//! and a private repository is refused by gunnar against the guest principal's
//! absent grant. A stranger reading a project's quickstart is the whole point of
//! publishing one.
//!
//! The **PDF** is not. It is the most expensive thing this console can be asked
//! to do — a full paged typst compile, cover, outline and all — and it is a
//! convenience for somebody who is going to keep the file, not a way to read a
//! page. So it needs a session, twice over: the route is off the allow-list, so
//! the middleware refuses a visitor with no session before a handler exists, and
//! [`crate::reading::Audience`] refuses again in the handler. The console does
//! not draw a button for it either — the same [`Offer`](crate::reading::Offer)
//! decides both, which is why the button and the route cannot disagree.
use axum::extract::{Path, Query, State};
use axum::Json;
use nornir_docbook::ImageSet;
use serde::Deserialize;
use crate::api::browse::{LfsPointer, RawKind};
use crate::api::{ApiError, ApiResult, Reader};
use crate::app::AppState;
use crate::git::{self, Located, Repository, MAX_BLOB_BYTES, MAX_RAW_BYTES};
use crate::grpc::Fail;
use crate::reading::Audience;
use crate::readme::{self, Cache, Pictures, Readme, Shape};
/// The largest image the console will pull out of a tree to place in a
/// document.
///
/// Below `MAX_BLOB_BYTES` on purpose: a 2 MB screenshot becomes a 2 MB base64
/// data URL inside the page, and the page is inlined into the console's DOM. A
/// document carrying one is a document with a missing picture and a perfectly
/// good alt text.
pub const MAX_IMAGE_BYTES: u64 = 512 * 1024;
/// `?rev=&path=&as=`
#[derive(Debug, Clone, Deserialize)]
pub struct At {
/// A reference name or an object id. Empty means the default branch.
///
/// `rev` here rather than `readme`'s `ref`, because this route's neighbours
/// on the file view — `browse`, `raw`, `diff` — all spell it `rev` and the
/// console builds all four URLs from the same query.
#[serde(default)]
pub rev: String,
/// The file. Never a path segment, for the reason at the top of
/// [`crate::api::browse`].
#[serde(default)]
pub path: String,
/// `svg` (the default) or `html`, exactly as on the README route.
///
/// `as` on the wire, `as_shape` in Rust — `as` is a keyword.
#[serde(default, rename = "as")]
pub as_shape: Option<String>,
}
impl At {
fn shape(&self) -> Shape {
match self.as_shape.as_deref() {
Some("html") => Shape::Html,
_ => Shape::Svg,
}
}
}
/// Which document, where, for the link rewriter and the cache key.
#[derive(Debug, Clone, Copy)]
pub struct Document<'a> {
pub namespace: &'a str,
pub name: &'a str,
/// The revision as the caller asked for it — part of where a link goes,
/// never part of what the page looks like.
pub reference: &'a str,
/// The file's path in the tree.
pub path: &'a str,
/// The blob's object id, which is the cache key.
pub oid: &'a str,
}
/// `GET /api/repos/{namespace}/{name}/render?rev=&path=&as=`
pub async fn render(
reader: Reader,
State(state): State<AppState>,
Path((namespace, name)): Path<(String, String)>,
Query(at): Query<At>,
) -> ApiResult<Readme> {
let repo = Repository::open(&reader.gunnar, &namespace, &name, state.objects.clone()).await?;
let revision = repo.resolve(&at.rev).await?;
let found = markdown_at(&repo, &revision.tree_oid, &at.path).await?;
Ok(Json(
page(
&state,
&repo,
&revision.tree_oid,
Document {
namespace: &namespace,
name: &name,
reference: &at.rev,
path: &found.path,
oid: &found.oid,
},
&found.source,
at.shape(),
)
.await?,
))
}
/// **`GET /api/repos/{namespace}/{name}/pdf?rev=&path=` — the same document,
/// typeset as a document.**
///
/// Served exactly the way [`crate::api::browse::raw`] serves a PDF that is in
/// the tree, off the same [`RawKind`]: `application/pdf`, `inline`, the
/// filename in a `Content-Disposition`, and a policy that forbids framing and
/// nothing else — a PDF viewer runs in its own process and Chrome refuses to
/// draw one at all under `sandbox`. Reusing `RawKind` rather than restating
/// those three strings is the point: a console with two opinions about how a
/// PDF is served has one of them wrong.
pub async fn pdf(
audience: Audience,
reader: Reader,
State(state): State<AppState>,
Path((namespace, name)): Path<(String, String)>,
Query(at): Query<At>,
) -> Result<axum::response::Response, ApiError> {
use axum::response::IntoResponse;
use http::header;
// **The second refusal.** The first is `auth::middleware`: this path is not
// on `is_anonymous_read`, so a visitor with no session never arrives. This
// one holds even if somebody adds it there by mistake, and it is the same
// answer that decides whether the button is drawn.
if !audience.offer(crate::reading::store(&state)).pdf {
return Err(ApiError(Fail {
code: "permissiondenied".to_owned(),
message: "this console typesets PDFs for people who are logged in".to_owned(),
}));
}
let repo = Repository::open(&reader.gunnar, &namespace, &name, state.objects.clone()).await?;
let revision = repo.resolve(&at.rev).await?;
let found = markdown_at(&repo, &revision.tree_oid, &at.path).await?;
let key = Cache::pdf_key(&namespace, &name, &found.oid, &found.path);
let label = format!("{namespace}/{name} {}", found.path);
let source = found.source.clone();
// The same pictures the typeset page draws, fetched the same way: against
// the tree at this revision, beside the document. See `page`.
let directory = readme::directory_of(&found.path);
let repo_ref = &repo;
let tree_oid = revision.tree_oid.clone();
let body: Vec<u8> = state
.readmes
.get_or_render(&key, || async move {
let pictures = fetch_images(repo_ref, &tree_oid, &source, directory, For::Pdf).await;
readme::queue::shared()
.typeset(move || readme::render_pdf(&source, &label, &pictures.set))
.await
.map_err(readme::Trouble::Busy)?
.map_err(readme::Trouble::Document)
})
.await
.map_err(|trouble| match trouble {
readme::Trouble::Busy(refused) => ApiError(refused.fail()),
// **A PDF has no lesser grade to fall back to.** The typeset page
// is what a reader who only wants to read it should have been
// looking at; this route was asked for a file, and half a file is
// not one. The refusal carries the typesetter's own words.
readme::Trouble::Document(why) => ApiError(Fail {
code: "failedprecondition".to_owned(),
message: format!("{} did not typeset to a PDF: {why}", found.path),
}),
})?;
let file = found.path.rsplit('/').next().unwrap_or(&found.path);
let file = format!(
"{}.pdf",
file.rsplit_once('.').map_or(file, |(stem, _)| stem)
);
let disposition = format!(
"{}; filename*=UTF-8''{}",
RawKind::Pdf.disposition(),
percent_encoding::utf8_percent_encode(&file, percent_encoding::NON_ALPHANUMERIC)
);
Ok((
[
(header::CONTENT_TYPE, RawKind::Pdf.content_type().to_owned()),
(header::CONTENT_DISPOSITION, disposition),
(
header::CONTENT_SECURITY_POLICY,
RawKind::Pdf.policy().to_owned(),
),
],
body,
)
.into_response())
}
/// One markdown blob, read out of a tree.
pub struct Found {
/// The path as the tree spells it, which is not always as the caller did.
pub path: String,
pub oid: String,
pub source: String,
}
/// Walk to `path`, and refuse everything that is not a markdown file this
/// console can typeset.
///
/// Every refusal names the path and says which kind of thing it was, because
/// the alternative — one "cannot render that" for a directory, a submodule, a
/// PNG and a 4 MB file — is a console that makes an operator guess.
async fn markdown_at(repo: &Repository, tree_oid: &str, path: &str) -> Result<Found, ApiError> {
let (entry, path) = match repo.walk(tree_oid, path).await? {
Located::Entry { entry, path } if entry.kind != "submodule" => (entry, path),
Located::Entry { path, .. } => {
return Err(refused(
"failedprecondition",
format!("{path} is a submodule: its contents live in another repository"),
))
}
Located::Tree { path, .. } => {
return Err(refused(
"invalidargument",
format!("{path:?} is a directory, not a document"),
))
}
};
if !readme::is_markdown(&path) {
return Err(refused(
"invalidargument",
format!(
"{path} is not markdown, so there is nothing to typeset; the file view shows it \
as it was written"
),
));
}
let object = repo.read_object(&entry.oid, MAX_BLOB_BYTES).await?;
if object.size > MAX_BLOB_BYTES {
return Err(refused(
"resourceexhausted",
format!(
"object {path} is {} bytes, over the {MAX_BLOB_BYTES}-byte limit the console \
fetches",
object.size
),
));
}
if git::looks_binary(&object.data, object.truncated) {
return Err(refused(
"failedprecondition",
format!("{path} is named like markdown and is not text"),
));
}
Ok(Found {
path,
oid: object.oid.clone(),
source: String::from_utf8_lossy(&object.data).into_owned(),
})
}
fn refused(code: &str, message: String) -> ApiError {
ApiError(Fail {
code: code.to_owned(),
message,
})
}
/// **Typeset one markdown blob into the answer the console draws** — the whole
/// of the shared pipeline, used by this module and by
/// [`crate::api::readme`].
///
/// The order is load-bearing and is the same as it has always been: the cache
/// is consulted **before the images are fetched**, so a warm hit costs one read
/// transaction and not a tree walk per badge. The links are rewritten **after**
/// the cache, because `?rev=` is part of where a link goes and not of what the
/// page looks like.
///
/// # Errors
///
/// Only when the console is too busy to typeset at all (503/504). A document
/// the typesetter refuses is not an error: the reader gets the plain grade and
/// the reason, exactly as a README does.
pub async fn page(
state: &AppState,
repo: &Repository,
tree_oid: &str,
doc: Document<'_>,
source: &str,
shape: Shape,
) -> Result<Readme, ApiError> {
let directory = readme::directory_of(doc.path);
let key = Cache::key(doc.namespace, doc.name, doc.oid, doc.path, shape);
let label = format!("{}/{} {}", doc.namespace, doc.name, doc.path);
let rendered = state
.readmes
.get_or_render(&key, || async move {
let images = fetch_images(repo, tree_oid, source, directory, For::Page).await;
let source = source.to_owned();
// typst is synchronous and CPU-bound. On a runtime worker a 50 ms
// typeset is 50 ms every other request on that worker waits, so it
// goes to the blocking pool — through the bounded door in front of
// it, because the pool itself grows to meet whatever it is asked.
readme::queue::shared()
.typeset(move || readme::render(&source, &label, &images, shape))
.await
.map_err(readme::Trouble::Busy)?
.map_err(readme::Trouble::Document)
})
.await;
let links = readme::links::Base {
namespace: doc.namespace,
name: doc.name,
reference: doc.reference,
directory,
};
match rendered {
Ok(body) => Ok(Readme {
kind: shape.name(),
body: readme::links::rewrite(&body, &links),
path: doc.path.to_owned(),
oid: doc.oid.to_owned(),
warning: None,
}),
// The console is busy, which is not this document's fault and has no
// cheaper grade to fall back to — see `readme::Trouble`.
Err(readme::Trouble::Busy(refused)) => Err(ApiError(refused.fail())),
// **Never a 500.** The typesetter refused, or the page was too large;
// either way the reader gets the document and the reason, through
// markdown straight to sanitised HTML with no typst in the way.
Err(readme::Trouble::Document(why)) => {
tracing::info!(
repo = %format!("{}/{}", doc.namespace, doc.name),
path = %doc.path,
shape = shape.name(),
reason = %why,
"a document did not typeset; serving the plain HTML instead"
);
Ok(Readme {
kind: "html",
body: readme::links::rewrite(&readme::plain_html(source), &links),
path: doc.path.to_owned(),
oid: doc.oid.to_owned(),
warning: Some(why),
})
}
}
}
/// What a picture is wanted for: a page in a browser, or a PDF.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum For {
/// The typeset or plain page: a picture too large to carry is named by
/// URL instead (`readme::pictures`).
Page,
/// A PDF, which has no browser to fetch a URL: a picture is carried or not
/// drawn.
Pdf,
}
/// Resolve every relative image in the markdown against the tree — **from the
/// document's own directory** — and fetch what it can, up to [`ImageSet::MAX`].
///
/// Failure is never an error here: an image that is not there, is not an image,
/// or is too large simply does not get a slot, and docbook draws its alt text. A
/// document with a broken badge is not a document the console refuses.
///
/// A Git LFS pointer is followed (`Refs.ReadLfsObject`, the grant the walk
/// already spent), and a picture over [`MAX_IMAGE_BYTES`] is, on a page, named
/// rather than carried — see `readme::pictures` for gunnar.rs's nordisk/knut,
/// where both were true of the README's one picture and the page drew nothing.
pub async fn fetch_images(
repo: &Repository,
tree_oid: &str,
markdown: &str,
directory: &str,
purpose: For,
) -> Pictures {
let mut pictures = Pictures::new();
for Destination { dest, alt } in destinations(markdown) {
if pictures.len() >= ImageSet::MAX {
break;
}
let Some(fetched) = fetch_one(repo, tree_oid, &dest, directory).await else {
continue;
};
// `embed`/`refer` sniff the bytes and refuse anything that is not an
// image typst can place, so a `.png` that is really something else
// gets no slot rather than a compile error. They are keyed by the
// destination AS WRITTEN, because that is what docbook will look up —
// the resolution decides which blob, not which name.
match placement(fetched.whole, fetched.size, fetched.streamable, purpose) {
Placement::Carry => {
pictures.embed(&dest, &fetched.path, fetched.bytes);
}
Placement::Name => {
pictures.refer(&dest, &fetched.path, &fetched.bytes);
}
// A markdown `` with no slot is drawn as its alt text by
// docbook. A raw `<img>` with no slot is drawn as NOTHING, so it
// gets a box with its alt text in it — and, in the HTML grade, an
// `<img>` that names the file and carries the alt (`pictures`).
Placement::TooLarge => {
if let Some(alt) = alt {
pictures.unavailable(&dest, &fetched.path, &alt);
}
}
Placement::Skip => {}
}
}
pictures
}
/// How one fetched picture goes on the page.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Placement {
/// Its bytes go into the page.
Carry,
/// A stand-in its size goes into the page, and the page names the file.
Name,
/// Larger than the raw route will hand a browser
/// ([`git::MAX_RAW_BYTES`]): naming it would draw an empty box, so the
/// alt text is drawn instead.
TooLarge,
/// Nothing is drawn.
Skip,
}
/// `whole` is false when only the start of the file arrived; `size` is the
/// file's own size (an LFS object's, from its pointer); `streamable` is true
/// when the raw route can hand the browser a file past one gunnar answer
/// (gunnar read its head as a range, so it reads ranges).
fn placement(whole: bool, size: u64, streamable: bool, purpose: For) -> Placement {
match purpose {
_ if whole && size <= MAX_IMAGE_BYTES => Placement::Carry,
For::Page if size <= MAX_RAW_BYTES || streamable => Placement::Name,
For::Page => Placement::TooLarge,
For::Pdf => Placement::Skip,
}
}
/// How much of a picture past one gunnar answer is read to learn its size:
/// a PNG says it in 24 bytes, a JPEG after its EXIF block, which a camera can
/// make long.
const HEAD_BYTES: u64 = 256 * 1024;
/// One picture's bytes, and the tree path they are at.
struct Fetched {
path: String,
/// The whole file, or its first [`MAX_IMAGE_BYTES`] when `whole` is false —
/// enough for the header a stand-in is sized from. Empty for an LFS object
/// past [`MAX_RAW_BYTES`], which is not fetched at all.
bytes: Vec<u8>,
whole: bool,
/// The file's size.
size: u64,
/// The raw route can stream it although it is past one gunnar answer.
streamable: bool,
}
async fn fetch_one(
repo: &Repository,
tree_oid: &str,
dest: &str,
directory: &str,
) -> Option<Fetched> {
// `#anchor` and `?query` are URL syntax, not path syntax, and a tree has
// neither. Trimming them here is what makes `logo.png?raw=true` work.
let path = dest.split(['?', '#']).next().unwrap_or(dest);
// An absolute URL is somebody else's host: not fetched, not resolved, not
// looked for in the tree. docbook draws the alt text.
if path.is_empty() || is_absolute(dest) {
return None;
}
// **Beside the document, not beside the README.** The same resolution the
// links get (`readme::links::normalise`), so a picture is fetched from the
// blob a click on it would open: a leading `/` is the tree root, anything
// else is relative to the directory the document is in, and a `..` past the
// root stays at the root rather than leaving the tree.
let path = if path.starts_with('/') || directory.is_empty() {
readme::links::normalise(path)
} else {
readme::links::normalise(&format!("{directory}/{path}"))
};
if path.is_empty() {
return None;
}
let Located::Entry { entry, path } = repo.walk(tree_oid, &path).await.ok()? else {
return None;
};
if entry.is_dir() || entry.kind == "submodule" {
return None;
}
let object = repo.read_object(&entry.oid, MAX_IMAGE_BYTES).await.ok()?;
let whole = !object.truncated && object.size <= MAX_IMAGE_BYTES;
// **A Git LFS file is a pointer in the tree** — 131 bytes of text that no
// sniff calls a picture. The picture is in gunnar's LFS store, served whole
// or refused, up to gunnar's own ceiling for one answer.
// Past that ceiling only the HEAD is read, as a range — enough to size the
// picture, which the page then names and the raw route streams. A gunnar
// older than ranged reads refuses the range, and the page draws the alt.
if whole {
if let Some(pointer) = LfsPointer::parse(&object.data) {
if pointer.size > MAX_RAW_BYTES {
let head = repo
.read_lfs_range(pointer.oid, 0, HEAD_BYTES)
.await
.ok()
.filter(|range| range.size == pointer.size);
return Some(Fetched {
path,
streamable: head.is_some(),
bytes: head.map(|range| range.data).unwrap_or_default(),
whole: false,
size: pointer.size,
});
}
let bytes = repo
.read_lfs_object(pointer.oid, MAX_RAW_BYTES)
.await
.ok()?;
let whole = bytes.len() as u64 == pointer.size;
return Some(Fetched {
path,
bytes,
whole,
size: pointer.size,
streamable: false,
});
}
}
Some(Fetched {
path,
bytes: object.data.clone(),
whole,
size: object.size,
streamable: false,
})
}
/// True for a destination this console will not resolve against the tree.
///
/// Scheme-relative `//host/x` is included: it is an absolute URL wearing a
/// relative one's clothes, and treating it as a path would send the tree walk
/// looking for a directory called `host`.
fn is_absolute(dest: &str) -> bool {
dest.starts_with("//")
|| dest
.split_once("://")
.is_some_and(|(scheme, _)| !scheme.is_empty() && !scheme.contains('/'))
|| dest.starts_with("data:")
|| dest.starts_with("mailto:")
}
/// Every image destination in the markdown, in the order they appear —
/// markdown's `` and a raw `<img src="…">` alike.
///
/// Parsed with the same parser docbook translates with rather than with a
/// regular expression, so the two cannot disagree about what is an image: a
/// `` inside a code fence is not one, and this does not think it is.
/// The `<img>` half matters because that is how a README centres its badge on a
/// git host, and docbook recovers those too — a scan that missed them would
/// leave exactly those images unfetched.
fn destinations(markdown: &str) -> Vec<Destination> {
let mut options = pulldown_cmark::Options::empty();
options.insert(pulldown_cmark::Options::ENABLE_TABLES);
options.insert(pulldown_cmark::Options::ENABLE_STRIKETHROUGH);
options.insert(pulldown_cmark::Options::ENABLE_TASKLISTS);
let mut out = Vec::new();
for event in pulldown_cmark::Parser::new_ext(markdown, options) {
match event {
pulldown_cmark::Event::Start(pulldown_cmark::Tag::Image { dest_url, .. }) => {
out.push(Destination {
dest: dest_url.to_string(),
alt: None,
});
}
pulldown_cmark::Event::Html(html) | pulldown_cmark::Event::InlineHtml(html) => {
if let Some(src) = img_src(&html) {
// The words to draw when the picture cannot be: the
// author's `alt`, else the file's name.
let alt = img_attr(&html, "alt")
.filter(|alt| !alt.trim().is_empty())
.unwrap_or_else(|| {
let file = src.split(['?', '#']).next().unwrap_or(&src);
file.rsplit('/').next().unwrap_or(file).to_owned()
});
out.push(Destination {
dest: src,
alt: Some(alt),
});
}
}
_ => {}
}
}
out.dedup();
out
}
/// One image destination, as written.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Destination {
dest: String,
/// `Some` for a raw `<img>`, which docbook draws as nothing at all when it
/// has no picture; a markdown image's alt text is docbook's to draw.
alt: Option<String>,
}
/// One attribute of an `<img>` chunk, `name="…"` or `name='…'`, entities as
/// written.
fn img_attr(html: &str, name: &str) -> Option<String> {
let lower = html.to_ascii_lowercase();
let at = lower.find("<img")?;
let mut from = at;
while let Some(found) = lower[from..].find(&format!("{name}=")) {
let start = from + found;
from = start + name.len() + 1;
if !lower[..start].ends_with(char::is_whitespace) {
continue;
}
let rest = &html[from..];
let quote = rest.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let end = rest[1..].find(quote)?;
return Some(rest[1..1 + end].to_owned());
}
None
}
/// The first `src="…"` of an `<img>` chunk, if it is one.
///
/// Deliberately the same shape as docbook's own `extract_img_src`: this decides
/// what to FETCH and that decides what to DRAW, and the two disagreeing means an
/// image that was fetched and then not placed, or placed and then missing.
fn img_src(html: &str) -> Option<String> {
let lower = html.to_ascii_lowercase();
if !lower.contains("<img") {
return None;
}
let at = lower.find("src=")? + 4;
let rest = &html[at..];
let quote = rest.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let end = rest[1..].find(quote)?;
Some(rest[1..1 + end].to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn image_destinations_come_out_in_order_and_ignore_code_fences() {
let source = "\n\n```\n\n```\n\n\n";
let dests: Vec<String> = destinations(source).into_iter().map(|d| d.dest).collect();
assert_eq!(dests, vec!["one.png", "three.png"]);
}
/// **A README's badge is usually a raw `<img>`**, because that is the only
/// way to centre one on a git host. docbook places those; this has to fetch
/// them, or they are the images that silently do not appear.
#[test]
fn a_raw_html_img_block_is_a_destination_too() {
let source = "<p align=\"center\"><img src=\"docs/badge.png\" width=\"90\"></p>\n\n\
text with an <img src='inline.svg'> in it\n";
assert_eq!(
destinations(source),
vec![
Destination {
dest: "docs/badge.png".to_owned(),
alt: Some("badge.png".to_owned()),
},
Destination {
dest: "inline.svg".to_owned(),
alt: Some("inline.svg".to_owned()),
},
]
);
}
#[test]
fn img_src_handles_both_quote_styles_and_no_image() {
assert_eq!(img_src("<img src=\"a/b.png\">").as_deref(), Some("a/b.png"));
assert_eq!(
img_src("<img class=x src='c.svg' />").as_deref(),
Some("c.svg")
);
assert_eq!(img_src("<p>no image</p>"), None);
}
/// ★ **nordisk/knut on gunnar.rs (2026-10-15)**: the README's picture is a
/// 3.9 MB LFS object. A page names it rather than drawing nothing; a PDF,
/// with no browser to fetch it, still skips it; a small one is carried.
/// ★ And the owner's 5.7 MB one (5 968 091 bytes): past what the raw route
/// hands a browser, so the page draws its alt text rather than an empty box
/// over a URL that refuses.
#[test]
fn a_picture_too_large_to_carry_is_named_on_a_page() {
assert_eq!(
placement(true, 3_976_594, false, For::Page),
Placement::Name
);
assert_eq!(
placement(false, MAX_IMAGE_BYTES + 1, false, For::Page),
Placement::Name
);
assert_eq!(placement(true, 3_976_594, false, For::Pdf), Placement::Skip);
assert_eq!(placement(true, 70, false, For::Page), Placement::Carry);
assert_eq!(placement(true, 70, false, For::Pdf), Placement::Carry);
// Past one gunnar answer: named when gunnar read its head as a range
// (the raw route streams it), the alt text against an older gunnar.
assert_eq!(
placement(false, 5_968_091, true, For::Page),
Placement::Name
);
assert_eq!(
placement(false, 5_968_091, false, For::Page),
Placement::TooLarge
);
assert_eq!(placement(false, 5_968_091, true, For::Pdf), Placement::Skip);
}
#[test]
fn a_raw_img_s_alt_is_read_and_a_lookalike_is_not() {
let html = "<p align=\"center\"><img data-alt=\"no\" src=\"a.png\" alt=\"knut — a knytpunkt\" width=\"560\"></p>";
assert_eq!(img_attr(html, "alt").as_deref(), Some("knut — a knytpunkt"));
assert_eq!(img_attr("<img src='a.png'>", "alt"), None);
assert_eq!(
destinations("<img src=\"docs/x.png\" alt=\"\">")[0]
.alt
.as_deref(),
Some("x.png"),
"an empty alt is the file's name"
);
}
/// The LFS ceiling the console asks for is gunnar's own, and knut's
/// picture fits under it (it did not under the 2 MiB blob limit).
#[test]
fn the_lfs_ceiling_holds_the_picture_that_was_refused() {
const {
assert!(3_976_594 <= MAX_RAW_BYTES);
assert!(MAX_RAW_BYTES > MAX_BLOB_BYTES);
assert!(MAX_RAW_BYTES as usize <= crate::grpc::MAX_DECODE);
}
}
#[test]
fn a_scheme_relative_url_counts_as_absolute() {
assert!(is_absolute("//cdn.example.test/a.png"));
assert!(is_absolute("https://example.test/a.png"));
assert!(is_absolute("data:image/png;base64,AAA"));
assert!(!is_absolute("docs/a.png"));
assert!(!is_absolute("./docs/a.png"));
}
/// The query parameter names a rendering, so an unknown one is the default
/// rather than a 400 at somebody who typed a URL — and the default is the
/// typeset page.
#[test]
fn the_shape_defaults_to_svg_and_only_html_changes_it() {
let at = |value: Option<&str>| At {
rev: String::new(),
path: "a.md".to_owned(),
as_shape: value.map(str::to_owned),
};
assert_eq!(at(None).shape(), Shape::Svg);
assert_eq!(at(Some("html")).shape(), Shape::Html);
assert_eq!(
at(Some("pdf")).shape(),
Shape::Svg,
"the PDF is its own route"
);
}
}