moss_core/render/image.rs
1//! Image HTML synthesizer — the single entry point for emitting `<img>` /
2//! `<picture>` markup in moss output.
3//!
4//! See [`docs/reference/structural-html-emission.md`](../../../../docs/reference/structural-html-emission.md)
5//! for the architectural principle: structural HTML decisions are made at the
6//! typed-data layer (pulldown-cmark events, shortcode AST, typed component
7//! props), with all three call sites converging on the function in this
8//! module. Regex post-passes are reserved for non-markdown-origin attribute
9//! injection only.
10//!
11//! # Migration state (post-Step-7, 2026-05-16)
12//!
13//! Steps 1-7 of the structural-html-emission migration are complete:
14//! - Step 1: extracted `synthesize_image_html`
15//! - Step 2: routed markdown `Tag::Image` events
16//! - Step 3: routed `:::hero` shortcode image
17//! - Step 4: routed link-preview favicon
18//! - Step 6: routed every cover image path (folder cards, child summary
19//! cards, folder index hero, photo/video gallery thumbnails) through
20//! `render_cover_html`
21//! - Step 7: retired `wrap_img_in_picture` (the structural part of the
22//! legacy regex post-pass). The synthesizer now owns every `<picture>`
23//! wrap in moss output. `add_image_placeholder_attributes` survives as
24//! the attribute-injection seam for the documented carve-outs (see
25//! below).
26//!
27//! The byte-shape contract is captured by snapshot tests at the bottom of
28//! this file. They are the line of defense against accidental output
29//! drift; any future change to attribute order, quoting, or whitespace
30//! must update them deliberately.
31//!
32//! Later steps will:
33//! - Step 8: switch `MarkdownStandalone` to a `<figure class="moss-image">`
34//! wrapper (breaking change for user themes — staged separately)
35//! - Add AVIF `<source>` lines once the image pipeline produces AVIF
36//! - Drop the inline LQIP `style=` in favor of a wrapper CSS custom prop
37//!
38//! # Step-8 contract: synthesizer owns the outer `<figure>` (planned)
39//!
40//! `transform_events` currently wraps the synthesizer's `MarkdownStandalone`
41//! output in its own `<figure>` for the three caption-pattern branches
42//! (image+emphasis, separate-emphasis, implicit-figure). After Step 8 the
43//! synthesizer emits `<figure class="moss-image">` itself; if `transform_events`
44//! still wraps, the output will be `<figure><figure class="moss-image">...</figure>
45//! <figcaption>...</figcaption></figure>` — invalid double-wrap.
46//!
47//! The Step-8 contract: caption flows into the synthesizer via
48//! `MarkdownStandalone { caption: Option<&str> }` (or a richer
49//! `CaptionMarkdown` for emphasis-in-caption support), and the three
50//! caption-pattern branches collapse into a single `Event::Html(
51//! synthesize_image_html(..., MarkdownStandalone { caption }))` emission.
52//! The `<figcaption>` becomes the synthesizer's responsibility, NOT
53//! `transform_events`. Captures the spec at
54//! `docs/reference/structural-html-emission.md#output-shape`.
55//!
56//! # Carve-outs: bare `<img>` emitters not routed through the synthesizer
57//!
58//! Four emission paths land bare `<img>` HTML in the output stream that
59//! does NOT flow through `synthesize_image_html`. They rely on the regex
60//! post-pass (`build/media/placeholder.rs::add_image_placeholder_attributes`)
61//! for attribute injection (dims/loading/decoding/LQIP). None of them
62//! are bugs — each has a documented architectural reason to stay outside
63//! the synthesizer:
64//!
65//! - **Site logo** (`build/components/nav.rs::render_logo`) — themed UI
66//! affordance, not content. The logo has its own CSS sizing
67//! (`.site-logo { height: 1.8em }`) and does not participate in
68//! LQIP/dims/WebP-variant rendering.
69//! - **RSS read-tracking pixel** (`build/feeds/rss.rs`) — 1×1 `<img>` not
70//! rendered visibly; the synthesizer's dims fallback (800×600) and LQIP
71//! would be wrong for this case.
72//! - **Email body images** (`infra/newsletter.rs`) — email clients (Gmail,
73//! Outlook, Apple Mail) do not consistently support `<picture>` or
74//! `data-placeholder-src`-driven hydration. Keep flat for cross-client
75//! degradation.
76//! - **Raw HTML `<img>` in markdown source** — author-written
77//! `<img src="...">` literally embedded in `.md` files. pulldown-cmark
78//! emits these as `Event::Html` opaque pass-through, so they never reach
79//! `Tag::Image` and are not a moss-controlled emitter. Treated as user
80//! input, the markdown HTML is opaque to the synthesizer and gets only
81//! the additive attribute injection pass.
82//!
83//! Photography/video gallery thumbnails — previously a carve-out — were
84//! folded into the synthesizer in Step 7's commit. The **review colophon
85//! cover** (`build/features/review.rs::render_colophon`) — also previously
86//! a carve-out — was folded in 2026-05-16. Both use
87//! `ImageContext::FolderCardCover` (container-bounded thumbnail semantics).
88//!
89//! These four remaining carve-outs are flagged here so future maintenance
90//! does not drop their attribute injection. Step 7 retired the structural
91//! part of the regex (`wrap_img_in_picture`); the surviving
92//! `add_image_placeholder_attributes` provides additive attrs only for
93//! these bare-img paths.
94
95use crate::asset_paths::{
96 deployed_width, is_ladder_source_ext, is_webp_source_ext, ladder_rungs, to_webp, to_webp_rung,
97};
98use crate::asset_snapshot::{AssetSnapshot, FALLBACK_HEIGHT, FALLBACK_WIDTH};
99use crate::contract::sizes as ctx_sizes;
100// Same XML-safe escaping used everywhere else in moss for attribute values.
101use crate::media::html_escape;
102use std::collections::BTreeMap;
103use std::path::PathBuf;
104
105/// Where the image lives in the document, which determines the wrapper
106/// element and attribute set.
107///
108/// Step-1 implementation supports only `MarkdownInline` (the default emission
109/// shape from pulldown-cmark's serializer plus the legacy regex pair's added
110/// attributes). Other variants are scaffolded so the call sites in Steps 3-6
111/// can pass them without breaking the byte-shape contract; the synthesizer
112/// produces the same output for all variants until the wrapper-change step.
113///
114/// Step 8 (2026-05-17) made these contexts diverge structurally:
115/// - `MarkdownStandalone { caption }` → `<figure class="moss-image">…[<figcaption>]…</figure>` wrapper
116/// - `MarkdownInline` → bare `<img>` (or `<picture><img></picture>`)
117/// - `Hero` → bare `<img>` (the hero shortcode wraps with `<header>`)
118/// - `FolderCardCover` → bare `<img>` (`.moss-card-cover > ` wraps)
119/// - `LinkPreview` → bare `<img>` (link-preview anchor wraps)
120/// - `Favicon` → bare 16×16 `<img>` with no `<picture>`, no LQIP
121///
122/// Not `Copy` (the embedded `&str` caption would force a lifetime on
123/// every consumer); cloning is cheap (borrow) and the call sites pass by
124/// value through the synthesizer.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum ImageContext<'a> {
127 /// Image-only paragraph in markdown — emits `<figure class="moss-image">`
128 /// around the synthesized `<picture>`/`<img>` output. Used by
129 /// `transform_events`'s three caption-pattern branches:
130 ///
131 /// - **image+emphasis**: ` *caption*` — `caption = Some(emphasis_text)`
132 /// - **separate-emphasis**: image-only paragraph followed by emphasis-
133 /// only paragraph — `caption = Some(emphasis_text)`
134 /// - **implicit figure**: image-only paragraph with non-empty alt and
135 /// `[site].implicit_figure = true` — `caption = Some(alt_text)`
136 ///
137 /// `caption = None` means "figure wrap but no `<figcaption>`" (reserved
138 /// for future callers that want the wrapper structurally without prose).
139 ///
140 /// `width = Some("body|wide|page|screen")` emits `data-width="..."` on
141 /// the outer `<figure>` element per spec § P9. `None` omits the
142 /// attribute entirely so themes can target the absence via
143 /// `:not([data-width])`. `full` aliases to `screen` upstream — values
144 /// reaching this struct are already in canonical value-space.
145 MarkdownStandalone {
146 caption: Option<&'a str>,
147 width: Option<&'a str>,
148 /// Editorial runaround alignment, surfaced as a CSS class on the
149 /// outer `<figure>`. Phase 1 C1 (2026-05-25): Stage 2 dispatcher
150 /// passes `Some("moss-align-left")` / `Some("moss-align-right")`
151 /// when the markdown image's title carries `moss:align=left|right`.
152 /// `None` omits the class.
153 align: Option<&'a str>,
154 /// Arbitrary CSS class names from `moss:classes="foo bar"` title
155 /// params (Phase 1 C1). Each entry is appended to the figure's
156 /// `class="moss-image …"` attribute, space-separated. Empty slice
157 /// leaves the class list at its default (`moss-image`).
158 class_names: &'a [String],
159 /// Arbitrary `key="value"` HTML attributes from leftover
160 /// `moss:` title params (Phase 1 C1). The dispatcher passes
161 /// every param that isn't a known field (kind/width/align/classes)
162 /// through here so future params propagate without code changes.
163 /// Keys are emitted in BTreeMap order for stable byte shape.
164 /// Empty map emits no extra attributes.
165 extra_attrs: &'a BTreeMap<String, String>,
166 },
167 /// Image inside prose, a list, a table cell, or a callout body.
168 /// Always emits bare `<img>` or `<picture><img></picture>`.
169 MarkdownInline,
170 /// `:::hero` shortcode body image. The hero wrapper handles layout.
171 Hero,
172 /// Folder-card cover or child-summary cover image.
173 FolderCardCover,
174 /// External-link preview thumbnail image.
175 LinkPreview,
176 /// Favicon for a link-preview card. Bare 16×16 `<img>`, no `<picture>`,
177 /// no LQIP, no responsive variants.
178 Favicon,
179
180 /// Phase 2 scaffold (filled by Phase 2B agent): site-header / nav logo.
181 /// Bare `<img>` with `class="site-logo"`, no LQIP, no `<picture>`, no
182 /// `loading="lazy"` (logo is above-the-fold). CSS handles sizing
183 /// (`.site-logo { height: 1.8em }`).
184 SiteLogo,
185 /// Phase 2 scaffold (filled by Phase 2C agent): RSS read-tracking pixel.
186 /// 1×1 invisible `<img>`. No `loading="lazy"` (the pixel must fire on
187 /// read for tracking). No LQIP, no `<picture>`.
188 TrackingPixel,
189 /// Phase 2 scaffold (filled by Phase 2D agent): newsletter email body
190 /// image. Email-client-safe HTML subset: no `<picture>`, no `data-*`,
191 /// inline `style="display:block;max-width:100%;height:auto"` for
192 /// responsive email layouts. Explicit dims when known (callers pass
193 /// `Option<u32>` for width/height; missing → omit per email-client
194 /// tolerance).
195 EmailBody {
196 width: Option<u32>,
197 height: Option<u32>,
198 },
199
200 /// Phase 2E v5 PR3 (2026-05-26): `:::gallery` body image. Below-the-
201 /// fold thumbnail (`loading="lazy"`); same `<picture>`/dims/LQIP byte
202 /// shape as `MarkdownInline`. The outer `.moss-gallery-item` wrapper
203 /// is owned by the gallery shortcode's `DefaultHooks` impl in
204 /// `crates/moss-core/src/ast/hooks.rs`; this variant emits just the
205 /// inner `<picture><img></picture>` / `<img>` shape so the wrapper
206 /// can sit around it.
207 ///
208 /// Distinct from `MarkdownInline` so the gallery's per-item style
209 /// passthrough (object-position from `MediaAttrs`) has a typed home
210 /// to evolve into; today both contexts produce the same inner byte
211 /// shape via `synthesize_inner`.
212 GalleryThumb,
213
214 /// Phase 2E PR2 (2026-05-26): bare `<img>` emission with no `<picture>`
215 /// wrap, no `<source>`, no LQIP, no width/height, no `loading` attr.
216 /// Used by the hero typed-renderer fallback path
217 /// (`typed_renderers.rs::render_hero_html_typed`) when no manifest
218 /// (`MediaDimensionLookup`) is in scope — i.e. test / fragment-render
219 /// paths where `AssetRegistry::set_pending` has NOT been called for
220 /// the source's `.webp` companion.
221 ///
222 /// The asset-publish invariant (see `.claude/CLAUDE.md` § "Asset
223 /// publish invariant") requires us to NEVER emit a
224 /// `<source srcset="*.webp">` for an unregistered variant — the
225 /// preview server cannot return placeholder bytes for an URL that
226 /// AssetRegistry doesn't know about, and `<picture>` does not recover
227 /// from a chosen-source 404. `HeroBare` is the explicit opt-out for
228 /// code paths that run before set_pending.
229 ///
230 /// The byte shape mirrors the pre-Phase-2E fallback exactly:
231 /// `<img src="X" alt="Y"[ STYLE] />` where `STYLE` is the inline
232 /// style fragment (already escaped) threaded through
233 /// [`ImageRenderOptions::extra_attrs`] by the caller. The `class` and
234 /// `eager` options are ignored — the hero `<header>` wraps and CSS
235 /// handles loading priority.
236 HeroBare,
237}
238
239/// Optional rendering attributes a caller may pass.
240///
241/// The defaults preserve the current regex-pass byte shape:
242/// - `loading="lazy"` unless `eager=true` (which switches to `loading="eager"
243/// fetchpriority="high"`)
244/// - No extra inline style
245/// - No extra CSS classes on the inner `<img>`
246#[derive(Debug, Default, Clone)]
247pub struct ImageRenderOptions<'a> {
248 /// Above-the-fold loading hint. When true, emits
249 /// `loading="eager" fetchpriority="high"` instead of `loading="lazy"`.
250 pub eager: bool,
251 /// CSS classes to add to the inner `<img>`. Used by link-preview
252 /// favicons (`link-preview-favicon`) and similar UI affordances.
253 pub class: Option<&'a str>,
254 /// Raw extra HTML attribute fragment appended after the standard
255 /// attribute block (e.g., `style="object-fit:cover;object-position:50% 50%"`
256 /// for `:::hero` covers carrying `MediaAttrs`). The caller is responsible
257 /// for HTML-escaping values inside this fragment.
258 pub extra_attrs: Option<&'a str>,
259 /// Explicit `sizes=` override for the srcset ladder. When `Some`, wins
260 /// over the [`ImageContext`]-derived default — used by callers that know
261 /// the rendered slot better than the context does: a figure carrying a
262 /// `data-width` token ([`crate::contract::sizes::sizes_for_data_width`])
263 /// or an image inside a `.moss-grid` cell
264 /// ([`crate::contract::sizes::sizes_for_grid_cell`]).
265 pub sizes: Option<&'a str>,
266}
267
268/// Synthesize the HTML for an image reference.
269///
270/// `src` is the resolved URL as it should appear in `<img src=>` (already
271/// passed through the link resolver and dir_overrides for CJK sites).
272///
273/// `alt` is the accessible name. Empty string is permitted for decorative
274/// images; callers in WCAG-sensitive contexts should pass meaningful text.
275///
276/// `assets` is the [`AssetSnapshot`] holding pre-fetched per-path dimensions,
277/// LQIP data URIs, dominant colors, and registered variant kinds (WebP/AVIF).
278/// Phase 1 of the unified-image-emission migration (2026-05-25) replaced the
279/// prior `Option<&MediaDimensionLookup>` parameter with this typed contract —
280/// `MediaDimensionLookup` still populates the snapshot in `pipeline.rs`'s
281/// `build_asset_snapshot` boundary, but the synthesizer no longer probes it
282/// directly. Callers that don't have a populated snapshot (test/fragment-
283/// render paths) pass `&AssetSnapshot::new()`; the synthesizer then emits
284/// fallback dims (800×600) and no LQIP/color style.
285///
286/// `context` and `options` describe the call site. `Favicon` short-circuits
287/// to a 16×16 bare `<img>` (no manifest, no LQIP, no `<picture>`).
288///
289/// Byte-shape contract (preserved through Phase 1's data-source switch):
290///
291/// - With no `<picture>` wrap (non-raster):
292/// `<img src="X" width="W" height="H" loading="lazy" style="…" alt="Y" />`
293/// - Raster originals (png/jpg/jpeg):
294/// `<picture><source srcset="X.webp" type="image/webp"><img src="X" width="W" height="H" loading="lazy" style="…" alt="Y" /></picture>`
295/// - Inline style is `background-image:url(LQIP);background-size:cover` when
296/// the snapshot has LQIP; `background-color:#RRGGBB` when only dominant
297/// color is available; absent when neither.
298/// - For `eager: true`: `loading="eager" fetchpriority="high"` replaces
299/// `loading="lazy"`.
300pub fn synthesize_image_html(
301 src: &str,
302 alt: &str,
303 assets: &AssetSnapshot,
304 context: ImageContext<'_>,
305 options: &ImageRenderOptions<'_>,
306) -> String {
307 // Favicon short-circuit: hardcoded 16×16, no snapshot lookup, no <picture>.
308 // Matches the current emission shape in
309 // `build/markdown/typed_renderers.rs::render_link_preview`. `assets` is
310 // intentionally unused — favicons are UI affordances that never
311 // participate in the variant manifest.
312 if matches!(context, ImageContext::Favicon) {
313 let class_attr = options
314 .class
315 .map(|c| format!(r#" class="{}""#, html_escape(c)))
316 .unwrap_or_default();
317 return format!(
318 r#"<img{} src="{}" width="16" height="16" alt="{}">"#,
319 class_attr,
320 html_escape(src),
321 html_escape(alt),
322 );
323 }
324
325 // Phase 2 scaffold (filled by Phase 2 carve-out agents): the three former
326 // bare-<img> carve-outs become first-class synthesizer contexts. Each
327 // short-circuits before synthesize_inner (which assumes the standard
328 // <picture>/LQIP/dims pipeline that's wrong for these elements).
329 // Site-logo short-circuit (Phase 2B carve-out): bare `<img>` with
330 // `class="site-logo"`, no `<picture>`, no LQIP, no `loading="lazy"` —
331 // the logo is above-the-fold; CSS handles sizing
332 // (`.site-logo { height: 1.8em }`). Attribute order
333 // (`class`, `src`, `alt`, `aria-hidden`) preserves the pre-Phase-2
334 // byte shape emitted by `nav.rs::generate_navigation`.
335 if matches!(context, ImageContext::SiteLogo) {
336 // `extra_attrs` rides directly after `class` (the editor preview's
337 // `data-source-fm="logo"` annotation); `None` keeps the byte shape.
338 let extra = options
339 .extra_attrs
340 .map(|a| format!(" {a}"))
341 .unwrap_or_default();
342 return format!(
343 r#"<img class="site-logo"{} src="{}" alt="{}" aria-hidden="true">"#,
344 extra,
345 html_escape(src),
346 html_escape(alt),
347 );
348 }
349 if matches!(context, ImageContext::TrackingPixel) {
350 // Phase 2C: RSS read-tracking pixel. 1×1 invisible <img>.
351 // NO loading="lazy" — the pixel must fire on read for tracking.
352 // NO LQIP, NO <picture>, NO alt text (empty alt for invisible
353 // decoration). Self-closing form because this commonly lands in
354 // RSS feed XML (CDATA-wrapped <description>).
355 return format!(
356 r#"<img src="{}" alt="" width="1" height="1" />"#,
357 html_escape(src),
358 );
359 }
360 if matches!(context, ImageContext::HeroBare) {
361 // Phase 2E PR2 (2026-05-26): no-snapshot hero fallback. Byte
362 // shape matches the pre-PR2 emission at
363 // `typed_renderers.rs::render_hero_html_typed` lines 554-557:
364 // `<img src="X" alt="Y"[ STYLE] />`. `assets` is intentionally
365 // unused (the caller passes an empty snapshot via the no-
366 // manifest branch); the snapshot is part of the signature only
367 // for symmetry with the other contexts. `options.class` and
368 // `options.eager` are ignored — hero chrome (CSS / `<header>`)
369 // handles styling and loading priority.
370 //
371 // The inline `style=` fragment that the legacy fallback baked
372 // directly into `format!` is now threaded through
373 // `options.extra_attrs` (the caller pre-escapes the value and
374 // omits the leading space, matching the existing extra_attrs
375 // contract — the synthesizer prepends a single space).
376 let extra = options
377 .extra_attrs
378 .map(|s| format!(" {}", s))
379 .unwrap_or_default();
380 return format!(
381 r#"<img src="{}" alt="{}"{} />"#,
382 html_escape(src),
383 html_escape(alt),
384 extra,
385 );
386 }
387 if let ImageContext::EmailBody { width, height } = context {
388 // Phase 2D (2026-05-25): email-client-safe <img> for newsletter body
389 // images. Email clients do NOT support <picture>, do NOT support
390 // data-* attributes, and frequently strip <style> blocks — inline
391 // `style=` on <img> plus explicit width/height attrs is the
392 // cross-client minimum for responsive layouts. width/height are
393 // Option<u32>: omitted when unknown (e.g. remote URLs that aren't in
394 // AssetSnapshot). Email clients tolerate missing dims at the cost of
395 // a small layout shift.
396 let mut dims = String::new();
397 if let Some(w) = width {
398 dims.push_str(&format!(r#" width="{}""#, w));
399 }
400 if let Some(h) = height {
401 dims.push_str(&format!(r#" height="{}""#, h));
402 }
403 return format!(
404 r#"<img src="{}" alt="{}"{} style="display:block;max-width:100%;height:auto;" />"#,
405 html_escape(src),
406 html_escape(alt),
407 dims,
408 );
409 }
410
411 // Step 8: the inner `<img>` / `<picture>` is shape-equivalent across
412 // every non-favicon context. The wrapping `<figure class="moss-image">`
413 // and optional `<figcaption>` are the only context-dependent
414 // structure. Compute the inner first, then wrap if requested.
415 //
416 // The context decides the `sizes=` value for the srcset ladder
417 // (responsive-image-variants Task 3): full-bleed surfaces (hero,
418 // `data-width="screen|full"` figures) span the viewport; wide/page
419 // figures span their escape band (ADR-021 Corollary 2, the data-width
420 // CSS in site.css); cards/gallery thumbs occupy grid cells; everything
421 // else renders in the content column. `options.sizes` overrides all of
422 // it — the caller (figure renderer with a data-width token, grid cell)
423 // knows the slot better than the context does. Only emitted when the
424 // ladder is non-empty — see synthesize_inner.
425 let sizes_value: &str = match options.sizes {
426 Some(s) => s,
427 None => match &context {
428 ImageContext::Hero => ctx_sizes::SIZES_FULL_BLEED,
429 ImageContext::MarkdownStandalone { width: Some(w), .. } => {
430 ctx_sizes::sizes_for_data_width(w).unwrap_or(ctx_sizes::SIZES_BODY)
431 }
432 ImageContext::FolderCardCover | ImageContext::LinkPreview => ctx_sizes::SIZES_CARD,
433 ImageContext::GalleryThumb => ctx_sizes::SIZES_GALLERY,
434 _ => ctx_sizes::SIZES_BODY,
435 },
436 };
437 let inner = synthesize_inner(src, alt, assets, options, sizes_value);
438
439 match context {
440 ImageContext::MarkdownStandalone {
441 caption,
442 width,
443 align,
444 class_names,
445 extra_attrs,
446 } => wrap_in_figure_full(&inner, caption, width, align, class_names, extra_attrs),
447 // All other variants are "no outer wrapper" — caller-owned chrome
448 // (hero `<header>`, folder card container, link preview anchor)
449 // surrounds the bare img/picture output.
450 _ => inner,
451 }
452}
453
454/// Wrap an already-shaped image fragment in the standalone-image
455/// figure container.
456///
457/// `inner_html` is either:
458/// - The output of `synthesize_inner` (markdown `Tag::Image` flowing
459/// through `synthesize_image_html`), or
460/// - A resolve-phase `<img …>` / `<video …>` HTML string emitted by
461/// moss-core's wikilink-lowering (`![[file|display-params]]`).
462///
463/// Output shape:
464///
465/// - `caption = Some(text)`, `width = None`:
466/// ```html
467/// <figure class="moss-image"><picture>…<img …></picture>
468/// <figcaption>text</figcaption></figure>
469/// ```
470/// - `caption = None`, `width = Some("screen")`:
471/// ```html
472/// <figure class="moss-image" data-width="screen"><picture>…<img …></picture></figure>
473/// ```
474/// - `width = None` omits the attribute entirely so themes can target
475/// the absence via `:not([data-width])`. Per spec § P9, `data-width`
476/// sits on the wrapper element (here, `<figure>`) rather than the
477/// inner `<img>`.
478///
479/// Caption text is HTML-escaped at the boundary. Future work: allow
480/// markdown formatting inside the caption via an explicit
481/// `CaptionMarkdown` variant on `ImageContext`.
482///
483/// `pub(super)` so `build/markdown/pipeline.rs` can call this directly
484/// for the raw-HTML media branch of `emit_standalone_figure_image`
485/// without duplicating the wrapper byte shape. The synthesizer is the
486/// single source of truth for `<figure class="moss-image">` — when the
487/// wrapper class evolves (e.g. `moss-image moss-image--auto` per the
488/// Step-8 spec), only this function changes.
489pub fn wrap_in_figure(
490 inner_html: &str,
491 caption: Option<&str>,
492 width: Option<&str>,
493) -> String {
494 // 3-arg shorthand kept for the raw-HTML media branch in
495 // pipeline.rs::emit_standalone_figure_image (wikilink display-keyword
496 // images that don't carry moss: title params — no align / extra
497 // classes / extra attrs). Delegates to the canonical wrapper so the
498 // byte shape stays defined in exactly one place.
499 let empty_classes: &[String] = &[];
500 let empty_attrs: BTreeMap<String, String> = BTreeMap::new();
501 wrap_in_figure_full(inner_html, caption, width, None, empty_classes, &empty_attrs)
502}
503
504/// Canonical `<figure>`-wrapping function consumed by both `synthesize_image_html`
505/// for `MarkdownStandalone` and the 3-arg compatibility shim `wrap_in_figure`.
506///
507/// Class list assembly: `class="moss-image{ align_class?}{ class_names…}"`.
508/// Extra attrs render as `key="escaped_value"` in BTreeMap order, after
509/// `data-width=` and before the inner content.
510pub(super) fn wrap_in_figure_full(
511 inner_html: &str,
512 caption: Option<&str>,
513 width: Option<&str>,
514 align: Option<&str>,
515 class_names: &[String],
516 extra_attrs: &BTreeMap<String, String>,
517) -> String {
518 // `width` here is a closed-set &'static str from `match_width_token`
519 // ("body" | "wide" | "page" | "screen"). The `html_escape` call is
520 // defensive belt-and-braces — it never actually substitutes — and is
521 // kept for symmetry with the embed-renderer side's `html_escape_attr`.
522 let width_attr = width
523 .map(|w| format!(r#" data-width="{}""#, html_escape(w)))
524 .unwrap_or_default();
525
526 // Compose the class attribute: `moss-image` first (the structural
527 // hook), then the optional align class, then any author-supplied
528 // class names. Single space separator keeps the byte shape stable
529 // across the empty / align-only / class-only / both permutations.
530 let mut class_value = String::from("moss-image");
531 if let Some(a) = align {
532 class_value.push(' ');
533 class_value.push_str(a);
534 }
535 for cn in class_names {
536 if cn.is_empty() {
537 continue;
538 }
539 class_value.push(' ');
540 class_value.push_str(cn);
541 }
542 let class_attr = format!(r#" class="{}""#, html_escape(&class_value));
543
544 // Extra attrs are emitted in BTreeMap order (deterministic byte shape).
545 let mut extra = String::new();
546 for (k, v) in extra_attrs {
547 extra.push(' ');
548 extra.push_str(k);
549 extra.push_str(r#"=""#);
550 extra.push_str(&html_escape(v));
551 extra.push('"');
552 }
553
554 match caption {
555 Some(text) => format!(
556 r#"<figure{class}{w}{extra}>{inner}<figcaption>{cap}</figcaption></figure>"#,
557 class = class_attr,
558 w = width_attr,
559 extra = extra,
560 inner = inner_html,
561 cap = html_escape(text),
562 ),
563 None => format!(
564 r#"<figure{class}{w}{extra}>{inner}</figure>"#,
565 class = class_attr,
566 w = width_attr,
567 extra = extra,
568 inner = inner_html,
569 ),
570 }
571}
572
573/// Synthesize the inner `<img>` (or `<picture><img></picture>`) without
574/// the standalone-figure wrapper. Shared by every non-favicon context.
575///
576/// `sizes_value` is the context-resolved `sizes=` attribute value
577/// (`contract::sizes`); it is only emitted when the source is wide enough
578/// to have ladder rungs — narrow/unknown-dims sources keep the legacy
579/// single-URL `<source>` byte shape.
580fn synthesize_inner(
581 src: &str,
582 alt: &str,
583 assets: &AssetSnapshot,
584 options: &ImageRenderOptions<'_>,
585 sizes_value: &str,
586) -> String {
587 // Phase B (Task 12): a webp SOURCE is already webp — to_webp(src) == src, so
588 // a `<picture><source srcset=to_webp(src)>` would emit a `<source>` byte-
589 // identical to the inner `<img>` (pointless). Instead, emit the responsive
590 // ladder DIRECTLY on the `<img>` via `srcset`+`sizes`. This branch MUST run
591 // BEFORE `is_raster_original` — which now also matches webp (webp joined
592 // `is_ladder_source_ext` in Phase B) — so webp never falls into the
593 // `<picture>` conversion path below.
594 //
595 // Animated webp gets NO ladder: `assets.is_animated(src)` (scan-derived,
596 // Task 9/10) → empty ladder → the base `<img>` is byte-identical to today's
597 // bare webp emission. Small / unknown-dims webp is likewise byte-identical.
598 // This is the ONLY census site that passes a non-`false` animated flag; the
599 // pipeline sites keep `false` (canonical rationale + the EXIF-orientation
600 // agreement live on `asset_paths::ladder_rungs`). base_url == src: the served
601 // base webp IS the source (`to_webp(src) == src`).
602 if is_webp_source(src) {
603 return match resolve_ladder(assets, src, lookup_animated(assets, src)) {
604 None => render_img_tag(src, alt, assets, options, None),
605 Some((rungs, base_w)) => {
606 let srcset = build_srcset(src, src, rungs, base_w);
607 render_img_tag(src, alt, assets, options, Some((&srcset, sizes_value)))
608 }
609 };
610 }
611
612 let img_tag = render_img_tag(src, alt, assets, options, None);
613
614 // For raster originals, always emit <picture><source srcset=X.webp>.
615 // This markup is MODE-INDEPENDENT — the on-disk HTML is identical in
616 // preview and publish. The webp is encoded in the BACKGROUND for ALL modes
617 // (blocking.rs registers the variant Pending; a BackgroundHandle worker
618 // runs the encode). Two mechanisms keep the webp URL live without a 404:
619 // • Preview: the server serves the FULL ORIGINAL source bytes (source
620 // passthrough, preview/server/router.rs) for the not-yet-encoded
621 // variant URL, so the first paint is sharp; a Failed encode instead
622 // surfaces a warning SVG (preview/server/placeholder.rs).
623 // • Publish: the seal/persist task AWAITS the background drain barrier
624 // before sealing, so the sealed/deployed generation always contains the
625 // encoded .webp on disk (ADR-013 by construction).
626 // So the URL is always live in both modes.
627 //
628 // We must never emit a <source> that might 404 because a chosen <source>
629 // 404 is non-recoverable inside <picture> per HTML spec §
630 // "update-the-source-set" + § "update-the-image-data": browser commits to
631 // the chosen URL, fetch fails, image state goes to broken, error fires —
632 // browser does NOT walk back to the inner <img>.
633 //
634 // For non-raster sources (svg, favicons via Favicon context), no variant
635 // exists; emit the bare <img>.
636 //
637 // Pattern: explicit promise model. See
638 // docs/archive/2026-05-20-image-variant-honest-mirror.md (Layer 3).
639 if is_raster_original(src) {
640 // to_webp(src) inherits the dir_overrides + relative-prefix already
641 // applied to `src` by the upstream renderer. Swapping the extension
642 // on `src` keeps the emitted URL aligned with the AssetRegistry's
643 // registered key (blocking.rs's set_pending uses the same to_webp
644 // derivation). It is the `<picture>` base descriptor URL here.
645 let srcset_path = to_webp(src);
646 // `false`: png/jpg/jpeg are never animated through this path (animated
647 // gif/webp never reach `is_raster_original`; APNG is flattened by the
648 // base+rung encodes alike). Canonical agreement rationale + the
649 // EXIF-orientation agreement: `asset_paths::ladder_rungs` census doc.
650 // `resolve_ladder` is `Some` only when rungs exist; unknown dims →
651 // `None` → the legacy single-URL `<source>` shape.
652 match resolve_ladder(assets, src, false) {
653 None => format!(
654 r#"<picture><source srcset="{}" type="image/webp">{}</picture>"#,
655 html_escape(&encode_srcset_url(&srcset_path)),
656 img_tag,
657 ),
658 Some((rungs, base_w)) => {
659 let srcset = build_srcset(src, &srcset_path, rungs, base_w);
660 format!(
661 r#"<picture><source srcset="{}" type="image/webp" sizes="{}">{}</picture>"#,
662 html_escape(&srcset),
663 html_escape(sizes_value),
664 img_tag,
665 )
666 }
667 }
668 } else {
669 img_tag
670 }
671}
672
673/// Returns true when `src` is a raster original that always gets a webp
674/// variant from moss's image pipeline. EMISSION side of the shared ladder
675/// gate: extracts `src`'s extension and delegates membership to
676/// [`is_ladder_source_ext`] — the ONE predicate the pipeline census sites
677/// (registration/encode/sweep/heal) also consume.
678///
679/// After Phase B (Task 12) this also matches webp, so `synthesize_inner`
680/// checks [`is_webp_source`] FIRST and routes webp to the `<img srcset>`
681/// branch — a webp reaching THIS predicate's `<picture>` branch would emit a
682/// useless `<source>` identical to the inner `<img>`. Reaching here therefore
683/// means png/jpg/jpeg in practice (the webp branch already returned).
684///
685/// Note: this check is extension-only. `collect_images_for_conversion` applies
686/// additional content-based filters (e.g. `SkipReason::NotAnImage` for files
687/// whose magic bytes don't match the declared format). A file that passes
688/// `is_raster_original` here but is filtered by `NotAnImage` will NOT have
689/// `set_pending` called for it — the synthesizer still emits a `<picture>`, but
690/// its `<source>` webp URL is unregistered, so the preview server falls through
691/// to ServeDir and the variant 404s. It does NOT serve the LQIP placeholder —
692/// that path is reserved for `set_pending` (Pending) entries. Benign in
693/// practice: this only occurs for genuinely corrupt files (e.g. an HTML 404
694/// page saved as .png) that are not referenced from content.
695fn is_raster_original(src: &str) -> bool {
696 src.rsplit_once('.')
697 .is_some_and(|(_, ext)| is_ladder_source_ext(ext))
698}
699
700/// Returns true when `src` is a webp SOURCE (extension `.webp`, case-
701/// insensitive). EMISSION side of the webp-vs-conversion split: a webp source
702/// carries the responsive ladder directly on `<img srcset>` (no `<picture>`),
703/// so `synthesize_inner` tests this before [`is_raster_original`]. Delegates
704/// to [`is_webp_source_ext`] — the single extension gate in `asset_paths`.
705fn is_webp_source(src: &str) -> bool {
706 src.rsplit_once('.')
707 .is_some_and(|(_, ext)| is_webp_source_ext(ext))
708}
709
710/// Try several path normalizations against `AssetSnapshot.dimensions` so the
711/// synthesizer matches the same set of input forms the prior
712/// `MediaDimensionLookup::get` handled. `src` may arrive as the resolved URL
713/// with a leading `/` (review colophon covers, cover.rs absolute paths) or
714/// with a `./` / `../` relative prefix (CJK dir_overrides); scan stores keys
715/// in plain relative form. The probe order mirrors the lookup's:
716///
717/// 1. exact match
718/// 2. leading-`/` stripped (absolute-to-relative)
719/// 3. leading `./` / `../` stripped (relative normalization)
720///
721/// Returns `None` when none of the variants is in the snapshot. The caller
722/// supplies the fallback (800×600 for dims, no style for LQIP / color).
723fn lookup_dims(assets: &AssetSnapshot, src: &str) -> Option<(u32, u32)> {
724 probe_paths(src, |p| assets.dims(&p))
725}
726
727/// Whether `src` is a scan-flagged animated source. Probes the SAME path
728/// normalizations as [`lookup_dims`] against `AssetSnapshot.animated` (keyed
729/// identically to `dimensions`, both populated per-source in
730/// `build_asset_snapshot`), so a webp found for dims is found for animation
731/// too. Missing everywhere → `false` (test/fragment-render paths with an empty
732/// snapshot treat sources as non-animated). Only the webp ladder branch
733/// consults this — png/jpg/jpeg are never animated through the `<picture>`
734/// path (see `asset_paths::ladder_rungs` census doc).
735fn lookup_animated(assets: &AssetSnapshot, src: &str) -> bool {
736 probe_paths(src, |p| assets.animated.get(&p).copied()).unwrap_or(false)
737}
738
739/// Resolve the responsive ladder for `src`: the rung widths (strictly below the
740/// deployed base) and the deployed base WIDTH — or `None` when dims are unknown
741/// (snapshot miss) or the ladder is empty (small/animated source). Shared by
742/// BOTH emission paths (webp `<img srcset>` and png/jpg `<picture><source>`) so
743/// the `lookup_dims → ladder_rungs → is_empty → deployed_width` derivation
744/// CANNOT drift between them — and it must agree with registration/encode,
745/// which call the identical `ladder_rungs`/`deployed_width` (see the
746/// deterministic-agreement contract on [`crate::asset_paths::ladder_rungs`],
747/// including its EXIF-orientation agreement).
748fn resolve_ladder(
749 assets: &AssetSnapshot,
750 src: &str,
751 is_animated: bool,
752) -> Option<(&'static [u32], u32)> {
753 lookup_dims(assets, src).and_then(|(w, h)| {
754 let rungs = ladder_rungs(w, h, is_animated);
755 if rungs.is_empty() {
756 None
757 } else {
758 Some((rungs, deployed_width(w, h)))
759 }
760 })
761}
762
763/// Assemble a `srcset` value: one `to_webp_rung(src, w) {w}w` candidate per
764/// rung, then the base descriptor `{base_url} {base_w}w`. Shared by BOTH
765/// emission paths so the rung-URL derivation and descriptor shape cannot drift
766/// between them (and must agree with what registration/encode name). The ONLY
767/// difference is `base_url`: a webp SOURCE passes `src` itself (the served base
768/// IS the source — `to_webp(src) == src`); a png/jpg/jpeg source passes
769/// `to_webp(src)` (the converted `<picture>` base). Rung URLs derive from `src`
770/// in both cases. Caller HTML-escapes the returned value.
771fn build_srcset(src: &str, base_url: &str, rungs: &[u32], base_w: u32) -> String {
772 let mut parts: Vec<String> = rungs
773 .iter()
774 .map(|w| format!("{} {}w", encode_srcset_url(&to_webp_rung(src, *w)), w))
775 .collect();
776 parts.push(format!("{} {}w", encode_srcset_url(base_url), base_w));
777 parts.join(", ")
778}
779
780/// Percent-encode literal commas (`,` → `%2C`) in one `srcset` candidate URL.
781///
782/// `srcset` is a comma-delimited candidate list, so a literal comma inside a
783/// URL — which a comma-named source (`a,b.jpg`) produces, since
784/// `percent_encode_path_segments` keeps `,` literal by design — would mis-split
785/// the list in browsers AND in `html_post`'s `rewrite_srcset_candidates`.
786/// Comma is the ONLY char touched (`src` was already encoded upstream and never
787/// carries a `%2C`, so no double-encode); applied ONLY to `srcset` candidates —
788/// the base `<img src>` keeps its single, unambiguous literal comma. The
789/// deployed file has a literal comma on disk; a static server decodes `%2C` → `,`
790/// when resolving (verified for the preview server in `router.rs`'s `%2C` test).
791fn encode_srcset_url(url: &str) -> String {
792 url.replace(',', "%2C")
793}
794
795fn lookup_lqip<'a>(assets: &'a AssetSnapshot, src: &str) -> Option<&'a str> {
796 probe_paths(src, |p| assets.lqip(&p))
797}
798
799fn lookup_color<'a>(assets: &'a AssetSnapshot, src: &str) -> Option<&'a String> {
800 probe_paths(src, |p| assets.dominant_color.get(&p))
801}
802
803fn probe_paths<T>(src: &str, mut probe: impl FnMut(PathBuf) -> Option<T>) -> Option<T> {
804 if let Some(v) = probe_normalized(src, &mut probe) {
805 return Some(v);
806 }
807 // BUG 6.2 (belt-and-suspenders): body/wikilink images arrive percent-encoded
808 // (`Europe%20-%20A%20Prophecy`), but snapshot keys are the RAW source path.
809 // Decode `%XX` and re-probe so the encoded URL reverses to the source key.
810 // Pure + zero-I/O; on invalid/lone `%` `percent_decode` returns the input
811 // unchanged, so we only re-probe when decoding actually changed something.
812 let decoded = percent_decode(src);
813 if decoded != src {
814 if let Some(v) = probe_normalized(&decoded, &mut probe) {
815 return Some(v);
816 }
817 }
818 None
819}
820
821/// Probe `src` plus its leading-`/` and leading-`./`/`../`-stripped forms.
822fn probe_normalized<T>(src: &str, probe: &mut impl FnMut(PathBuf) -> Option<T>) -> Option<T> {
823 if let Some(v) = probe(PathBuf::from(src)) {
824 return Some(v);
825 }
826 let stripped = src.strip_prefix('/').unwrap_or(src);
827 if stripped != src {
828 if let Some(v) = probe(PathBuf::from(stripped)) {
829 return Some(v);
830 }
831 }
832 let mut s: &str = src;
833 while let Some(rest) = s.strip_prefix("./").or_else(|| s.strip_prefix("../")) {
834 s = rest;
835 }
836 if s != src {
837 if let Some(v) = probe(PathBuf::from(s)) {
838 return Some(v);
839 }
840 }
841 None
842}
843
844/// Percent-decode `%XX` byte sequences in a URL path (pure, zero-I/O).
845/// The single implementation lives next to the encoder it inverts; see
846/// [`crate::resolve::fuzzy_path::percent_decode_path`].
847fn percent_decode(path: &str) -> String {
848 crate::resolve::fuzzy_path::percent_decode_path(path)
849}
850
851/// Emit just the `<img>` tag with all attributes. Internal helper for
852/// `synthesize_image_html` — exposed as `pub(crate)` only for snapshot tests
853/// that want to assert against the bare img output without the optional
854/// `<picture>` wrapper.
855///
856/// `srcset_sizes` is `Some((srcset, sizes))` ONLY for the Phase-B webp ladder
857/// (Task 12), which carries the responsive candidates on the `<img>` itself
858/// rather than a `<source>`. When `Some`, ` srcset="…" sizes="…"` is emitted
859/// immediately after `src=` (both values HTML-escaped, matching the
860/// `<picture>` path's escaping). When `None` — every other caller and every
861/// non-laddered webp — the output is BYTE-IDENTICAL to the pre-Task-12 shape.
862pub(crate) fn render_img_tag(
863 src: &str,
864 alt: &str,
865 assets: &AssetSnapshot,
866 options: &ImageRenderOptions<'_>,
867 srcset_sizes: Option<(&str, &str)>,
868) -> String {
869 // AssetSnapshot's `dims` is keyed by PathBuf; the src arrives as the
870 // resolved URL the upstream renderer baked (potentially absolute, e.g.
871 // `/image/cover.jpg`). Scan stores relative keys (`image/cover.jpg`),
872 // so the synthesizer probes both forms via `lookup_dims`. Snapshot
873 // lookups absent → fall back to the legacy 800×600 (matches the prior
874 // `MediaDimensionLookup::get` semantics before the Phase 1 B1 migration).
875 // Stem fallback (extension-mismatch — e.g. `.mov` vs `.mp4`) was
876 // previously handled in MediaDimensionLookup::get; since AssetSnapshot
877 // exposes only exact-path access, that case will need follow-up if
878 // production sites rely on it (likely only for video posters, which
879 // are out of this Tag::Image path).
880 let (width, height) = lookup_dims(assets, src).unwrap_or((FALLBACK_WIDTH, FALLBACK_HEIGHT));
881
882 let class_attr = options
883 .class
884 .map(|c| format!(r#" class="{}""#, html_escape(c)))
885 .unwrap_or_default();
886
887 let (loading_attr, fetchpriority_attr) = if options.eager {
888 (r#" loading="eager""#, r#" fetchpriority="high""#)
889 } else {
890 (r#" loading="lazy""#, "")
891 };
892
893 // Suppress LQIP / dominant-color style when extra_attrs already carries
894 // a style= attribute (e.g., `:::hero {attrs="cover-fit=contain"}` passes
895 // `style="object-fit:contain"` through extra_attrs). The browser would
896 // honor the LAST style= it sees and drop the LQIP, so emitting both
897 // produces malformed HTML and loses the placeholder. The legacy regex
898 // pass (`placeholder.rs:413-422`) had the same has_style guard; this
899 // preserves parity. Future work: merge the two declarations into a
900 // single style= via a typed `ImageRenderOptions::media_attrs` field so
901 // the synthesizer owns escaping end-to-end (impl-review item 9).
902 let extra_has_style = options
903 .extra_attrs
904 .map(|s| s.contains("style="))
905 .unwrap_or(false);
906
907 let style_attr = if extra_has_style {
908 String::new()
909 } else if let Some(lqip) = lookup_lqip(assets, src) {
910 format!(
911 r#" style="background-image:url({});background-size:cover""#,
912 lqip
913 )
914 } else if let Some(color) = lookup_color(assets, src) {
915 format!(r#" style="background-color:{}""#, color)
916 } else {
917 String::new()
918 };
919
920 let extra = options
921 .extra_attrs
922 .map(|s| format!(" {}", s))
923 .unwrap_or_default();
924
925 // Phase B webp ladder (Task 12): responsive candidates ride the `<img>`
926 // itself. Emitted right after `src=` and before `width=`. Empty for every
927 // other caller, keeping the byte shape identical to the pre-Task-12 tag.
928 let srcset_attr = match srcset_sizes {
929 Some((srcset, sizes)) => format!(
930 r#" srcset="{}" sizes="{}""#,
931 html_escape(srcset),
932 html_escape(sizes),
933 ),
934 None => String::new(),
935 };
936
937 // `data-placeholder-src` removed 2026-05-20: the iframe-bridge handler
938 // now matches by URL substring against `src` / `srcset` (see
939 // frontend/bridge/iframe-bridge.ts, moss-asset-ready branch). The
940 // AssetRegistry's promise model + the preview server's URL-keyed lookup
941 // make the attribute redundant. See
942 // docs/archive/2026-05-20-image-variant-honest-mirror.md (Layer 3).
943 //
944 // Inline LQIP via `background-image: url(data:image/jpeg;base64,…)` is
945 // kept — legitimate production technique (cf. Vercel `blurDataURL`,
946 // nextjs.org/docs/app/api-reference/components/image). Shows a blurred
947 // preview instantly while the actual bytes are being decoded.
948 format!(
949 r#"<img{class_attr} src="{src_esc}"{srcset} width="{w}" height="{h}"{loading}{fetch}{style} alt="{alt}"{extra} />"#,
950 class_attr = class_attr,
951 src_esc = html_escape(src),
952 srcset = srcset_attr,
953 w = width,
954 h = height,
955 loading = loading_attr,
956 fetch = fetchpriority_attr,
957 style = style_attr,
958 alt = html_escape(alt),
959 extra = extra,
960 )
961}
962
963#[cfg(test)]
964#[path = "image_tests.rs"]
965mod tests;