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 return format!(
337 r#"<img class="site-logo" src="{}" alt="{}" aria-hidden="true">"#,
338 html_escape(src),
339 html_escape(alt),
340 );
341 }
342 if matches!(context, ImageContext::TrackingPixel) {
343 // Phase 2C: RSS read-tracking pixel. 1×1 invisible <img>.
344 // NO loading="lazy" — the pixel must fire on read for tracking.
345 // NO LQIP, NO <picture>, NO alt text (empty alt for invisible
346 // decoration). Self-closing form because this commonly lands in
347 // RSS feed XML (CDATA-wrapped <description>).
348 return format!(
349 r#"<img src="{}" alt="" width="1" height="1" />"#,
350 html_escape(src),
351 );
352 }
353 if matches!(context, ImageContext::HeroBare) {
354 // Phase 2E PR2 (2026-05-26): no-snapshot hero fallback. Byte
355 // shape matches the pre-PR2 emission at
356 // `typed_renderers.rs::render_hero_html_typed` lines 554-557:
357 // `<img src="X" alt="Y"[ STYLE] />`. `assets` is intentionally
358 // unused (the caller passes an empty snapshot via the no-
359 // manifest branch); the snapshot is part of the signature only
360 // for symmetry with the other contexts. `options.class` and
361 // `options.eager` are ignored — hero chrome (CSS / `<header>`)
362 // handles styling and loading priority.
363 //
364 // The inline `style=` fragment that the legacy fallback baked
365 // directly into `format!` is now threaded through
366 // `options.extra_attrs` (the caller pre-escapes the value and
367 // omits the leading space, matching the existing extra_attrs
368 // contract — the synthesizer prepends a single space).
369 let extra = options
370 .extra_attrs
371 .map(|s| format!(" {}", s))
372 .unwrap_or_default();
373 return format!(
374 r#"<img src="{}" alt="{}"{} />"#,
375 html_escape(src),
376 html_escape(alt),
377 extra,
378 );
379 }
380 if let ImageContext::EmailBody { width, height } = context {
381 // Phase 2D (2026-05-25): email-client-safe <img> for newsletter body
382 // images. Email clients do NOT support <picture>, do NOT support
383 // data-* attributes, and frequently strip <style> blocks — inline
384 // `style=` on <img> plus explicit width/height attrs is the
385 // cross-client minimum for responsive layouts. width/height are
386 // Option<u32>: omitted when unknown (e.g. remote URLs that aren't in
387 // AssetSnapshot). Email clients tolerate missing dims at the cost of
388 // a small layout shift.
389 let mut dims = String::new();
390 if let Some(w) = width {
391 dims.push_str(&format!(r#" width="{}""#, w));
392 }
393 if let Some(h) = height {
394 dims.push_str(&format!(r#" height="{}""#, h));
395 }
396 return format!(
397 r#"<img src="{}" alt="{}"{} style="display:block;max-width:100%;height:auto;" />"#,
398 html_escape(src),
399 html_escape(alt),
400 dims,
401 );
402 }
403
404 // Step 8: the inner `<img>` / `<picture>` is shape-equivalent across
405 // every non-favicon context. The wrapping `<figure class="moss-image">`
406 // and optional `<figcaption>` are the only context-dependent
407 // structure. Compute the inner first, then wrap if requested.
408 //
409 // The context decides the `sizes=` value for the srcset ladder
410 // (responsive-image-variants Task 3): full-bleed surfaces (hero,
411 // `data-width="screen|full"` figures) span the viewport; wide/page
412 // figures span their escape band (ADR-021 Corollary 2, the data-width
413 // CSS in site.css); cards/gallery thumbs occupy grid cells; everything
414 // else renders in the content column. `options.sizes` overrides all of
415 // it — the caller (figure renderer with a data-width token, grid cell)
416 // knows the slot better than the context does. Only emitted when the
417 // ladder is non-empty — see synthesize_inner.
418 let sizes_value: &str = match options.sizes {
419 Some(s) => s,
420 None => match &context {
421 ImageContext::Hero => ctx_sizes::SIZES_FULL_BLEED,
422 ImageContext::MarkdownStandalone { width: Some(w), .. } => {
423 ctx_sizes::sizes_for_data_width(w).unwrap_or(ctx_sizes::SIZES_BODY)
424 }
425 ImageContext::FolderCardCover | ImageContext::LinkPreview => ctx_sizes::SIZES_CARD,
426 ImageContext::GalleryThumb => ctx_sizes::SIZES_GALLERY,
427 _ => ctx_sizes::SIZES_BODY,
428 },
429 };
430 let inner = synthesize_inner(src, alt, assets, options, sizes_value);
431
432 match context {
433 ImageContext::MarkdownStandalone {
434 caption,
435 width,
436 align,
437 class_names,
438 extra_attrs,
439 } => wrap_in_figure_full(&inner, caption, width, align, class_names, extra_attrs),
440 // All other variants are "no outer wrapper" — caller-owned chrome
441 // (hero `<header>`, folder card container, link preview anchor)
442 // surrounds the bare img/picture output.
443 _ => inner,
444 }
445}
446
447/// Wrap an already-shaped image fragment in the standalone-image
448/// figure container.
449///
450/// `inner_html` is either:
451/// - The output of `synthesize_inner` (markdown `Tag::Image` flowing
452/// through `synthesize_image_html`), or
453/// - A resolve-phase `<img …>` / `<video …>` HTML string emitted by
454/// moss-core's wikilink-lowering (`![[file|display-params]]`).
455///
456/// Output shape:
457///
458/// - `caption = Some(text)`, `width = None`:
459/// ```html
460/// <figure class="moss-image"><picture>…<img …></picture>
461/// <figcaption>text</figcaption></figure>
462/// ```
463/// - `caption = None`, `width = Some("screen")`:
464/// ```html
465/// <figure class="moss-image" data-width="screen"><picture>…<img …></picture></figure>
466/// ```
467/// - `width = None` omits the attribute entirely so themes can target
468/// the absence via `:not([data-width])`. Per spec § P9, `data-width`
469/// sits on the wrapper element (here, `<figure>`) rather than the
470/// inner `<img>`.
471///
472/// Caption text is HTML-escaped at the boundary. Future work: allow
473/// markdown formatting inside the caption via an explicit
474/// `CaptionMarkdown` variant on `ImageContext`.
475///
476/// `pub(super)` so `build/markdown/pipeline.rs` can call this directly
477/// for the raw-HTML media branch of `emit_standalone_figure_image`
478/// without duplicating the wrapper byte shape. The synthesizer is the
479/// single source of truth for `<figure class="moss-image">` — when the
480/// wrapper class evolves (e.g. `moss-image moss-image--auto` per the
481/// Step-8 spec), only this function changes.
482pub fn wrap_in_figure(
483 inner_html: &str,
484 caption: Option<&str>,
485 width: Option<&str>,
486) -> String {
487 // 3-arg shorthand kept for the raw-HTML media branch in
488 // pipeline.rs::emit_standalone_figure_image (wikilink display-keyword
489 // images that don't carry moss: title params — no align / extra
490 // classes / extra attrs). Delegates to the canonical wrapper so the
491 // byte shape stays defined in exactly one place.
492 let empty_classes: &[String] = &[];
493 let empty_attrs: BTreeMap<String, String> = BTreeMap::new();
494 wrap_in_figure_full(inner_html, caption, width, None, empty_classes, &empty_attrs)
495}
496
497/// Canonical `<figure>`-wrapping function consumed by both `synthesize_image_html`
498/// for `MarkdownStandalone` and the 3-arg compatibility shim `wrap_in_figure`.
499///
500/// Class list assembly: `class="moss-image{ align_class?}{ class_names…}"`.
501/// Extra attrs render as `key="escaped_value"` in BTreeMap order, after
502/// `data-width=` and before the inner content.
503pub(super) fn wrap_in_figure_full(
504 inner_html: &str,
505 caption: Option<&str>,
506 width: Option<&str>,
507 align: Option<&str>,
508 class_names: &[String],
509 extra_attrs: &BTreeMap<String, String>,
510) -> String {
511 // `width` here is a closed-set &'static str from `match_width_token`
512 // ("body" | "wide" | "page" | "screen"). The `html_escape` call is
513 // defensive belt-and-braces — it never actually substitutes — and is
514 // kept for symmetry with the embed-renderer side's `html_escape_attr`.
515 let width_attr = width
516 .map(|w| format!(r#" data-width="{}""#, html_escape(w)))
517 .unwrap_or_default();
518
519 // Compose the class attribute: `moss-image` first (the structural
520 // hook), then the optional align class, then any author-supplied
521 // class names. Single space separator keeps the byte shape stable
522 // across the empty / align-only / class-only / both permutations.
523 let mut class_value = String::from("moss-image");
524 if let Some(a) = align {
525 class_value.push(' ');
526 class_value.push_str(a);
527 }
528 for cn in class_names {
529 if cn.is_empty() {
530 continue;
531 }
532 class_value.push(' ');
533 class_value.push_str(cn);
534 }
535 let class_attr = format!(r#" class="{}""#, html_escape(&class_value));
536
537 // Extra attrs are emitted in BTreeMap order (deterministic byte shape).
538 let mut extra = String::new();
539 for (k, v) in extra_attrs {
540 extra.push(' ');
541 extra.push_str(k);
542 extra.push_str(r#"=""#);
543 extra.push_str(&html_escape(v));
544 extra.push('"');
545 }
546
547 match caption {
548 Some(text) => format!(
549 r#"<figure{class}{w}{extra}>{inner}<figcaption>{cap}</figcaption></figure>"#,
550 class = class_attr,
551 w = width_attr,
552 extra = extra,
553 inner = inner_html,
554 cap = html_escape(text),
555 ),
556 None => format!(
557 r#"<figure{class}{w}{extra}>{inner}</figure>"#,
558 class = class_attr,
559 w = width_attr,
560 extra = extra,
561 inner = inner_html,
562 ),
563 }
564}
565
566/// Synthesize the inner `<img>` (or `<picture><img></picture>`) without
567/// the standalone-figure wrapper. Shared by every non-favicon context.
568///
569/// `sizes_value` is the context-resolved `sizes=` attribute value
570/// (`contract::sizes`); it is only emitted when the source is wide enough
571/// to have ladder rungs — narrow/unknown-dims sources keep the legacy
572/// single-URL `<source>` byte shape.
573fn synthesize_inner(
574 src: &str,
575 alt: &str,
576 assets: &AssetSnapshot,
577 options: &ImageRenderOptions<'_>,
578 sizes_value: &str,
579) -> String {
580 // Phase B (Task 12): a webp SOURCE is already webp — to_webp(src) == src, so
581 // a `<picture><source srcset=to_webp(src)>` would emit a `<source>` byte-
582 // identical to the inner `<img>` (pointless). Instead, emit the responsive
583 // ladder DIRECTLY on the `<img>` via `srcset`+`sizes`. This branch MUST run
584 // BEFORE `is_raster_original` — which now also matches webp (webp joined
585 // `is_ladder_source_ext` in Phase B) — so webp never falls into the
586 // `<picture>` conversion path below.
587 //
588 // Animated webp gets NO ladder: `assets.is_animated(src)` (scan-derived,
589 // Task 9/10) → empty ladder → the base `<img>` is byte-identical to today's
590 // bare webp emission. Small / unknown-dims webp is likewise byte-identical.
591 // This is the ONLY census site that passes a non-`false` animated flag; the
592 // pipeline sites keep `false` (canonical rationale + the EXIF-orientation
593 // agreement live on `asset_paths::ladder_rungs`). base_url == src: the served
594 // base webp IS the source (`to_webp(src) == src`).
595 if is_webp_source(src) {
596 return match resolve_ladder(assets, src, lookup_animated(assets, src)) {
597 None => render_img_tag(src, alt, assets, options, None),
598 Some((rungs, base_w)) => {
599 let srcset = build_srcset(src, src, rungs, base_w);
600 render_img_tag(src, alt, assets, options, Some((&srcset, sizes_value)))
601 }
602 };
603 }
604
605 let img_tag = render_img_tag(src, alt, assets, options, None);
606
607 // For raster originals, always emit <picture><source srcset=X.webp>.
608 // This markup is MODE-INDEPENDENT — the on-disk HTML is identical in
609 // preview and publish. The webp is encoded in the BACKGROUND for ALL modes
610 // (blocking.rs registers the variant Pending; a BackgroundHandle worker
611 // runs the encode). Two mechanisms keep the webp URL live without a 404:
612 // • Preview: the server serves the FULL ORIGINAL source bytes (source
613 // passthrough, preview/server/router.rs) for the not-yet-encoded
614 // variant URL, so the first paint is sharp; a Failed encode instead
615 // surfaces a warning SVG (preview/server/placeholder.rs).
616 // • Publish: the seal/persist task AWAITS the background drain barrier
617 // before sealing, so the sealed/deployed generation always contains the
618 // encoded .webp on disk (ADR-013 by construction).
619 // So the URL is always live in both modes.
620 //
621 // We must never emit a <source> that might 404 because a chosen <source>
622 // 404 is non-recoverable inside <picture> per HTML spec §
623 // "update-the-source-set" + § "update-the-image-data": browser commits to
624 // the chosen URL, fetch fails, image state goes to broken, error fires —
625 // browser does NOT walk back to the inner <img>.
626 //
627 // For non-raster sources (svg, favicons via Favicon context), no variant
628 // exists; emit the bare <img>.
629 //
630 // Pattern: explicit promise model. See
631 // docs/archive/2026-05-20-image-variant-honest-mirror.md (Layer 3).
632 if is_raster_original(src) {
633 // to_webp(src) inherits the dir_overrides + relative-prefix already
634 // applied to `src` by the upstream renderer. Swapping the extension
635 // on `src` keeps the emitted URL aligned with the AssetRegistry's
636 // registered key (blocking.rs's set_pending uses the same to_webp
637 // derivation). It is the `<picture>` base descriptor URL here.
638 let srcset_path = to_webp(src);
639 // `false`: png/jpg/jpeg are never animated through this path (animated
640 // gif/webp never reach `is_raster_original`; APNG is flattened by the
641 // base+rung encodes alike). Canonical agreement rationale + the
642 // EXIF-orientation agreement: `asset_paths::ladder_rungs` census doc.
643 // `resolve_ladder` is `Some` only when rungs exist; unknown dims →
644 // `None` → the legacy single-URL `<source>` shape.
645 match resolve_ladder(assets, src, false) {
646 None => format!(
647 r#"<picture><source srcset="{}" type="image/webp">{}</picture>"#,
648 html_escape(&encode_srcset_url(&srcset_path)),
649 img_tag,
650 ),
651 Some((rungs, base_w)) => {
652 let srcset = build_srcset(src, &srcset_path, rungs, base_w);
653 format!(
654 r#"<picture><source srcset="{}" type="image/webp" sizes="{}">{}</picture>"#,
655 html_escape(&srcset),
656 html_escape(sizes_value),
657 img_tag,
658 )
659 }
660 }
661 } else {
662 img_tag
663 }
664}
665
666/// Returns true when `src` is a raster original that always gets a webp
667/// variant from moss's image pipeline. EMISSION side of the shared ladder
668/// gate: extracts `src`'s extension and delegates membership to
669/// [`is_ladder_source_ext`] — the ONE predicate the pipeline census sites
670/// (registration/encode/sweep/heal) also consume.
671///
672/// After Phase B (Task 12) this also matches webp, so `synthesize_inner`
673/// checks [`is_webp_source`] FIRST and routes webp to the `<img srcset>`
674/// branch — a webp reaching THIS predicate's `<picture>` branch would emit a
675/// useless `<source>` identical to the inner `<img>`. Reaching here therefore
676/// means png/jpg/jpeg in practice (the webp branch already returned).
677///
678/// Note: this check is extension-only. `collect_images_for_conversion` applies
679/// additional content-based filters (e.g. `SkipReason::NotAnImage` for files
680/// whose magic bytes don't match the declared format). A file that passes
681/// `is_raster_original` here but is filtered by `NotAnImage` will NOT have
682/// `set_pending` called for it — the synthesizer still emits a `<picture>`, but
683/// its `<source>` webp URL is unregistered, so the preview server falls through
684/// to ServeDir and the variant 404s. It does NOT serve the LQIP placeholder —
685/// that path is reserved for `set_pending` (Pending) entries. Benign in
686/// practice: this only occurs for genuinely corrupt files (e.g. an HTML 404
687/// page saved as .png) that are not referenced from content.
688fn is_raster_original(src: &str) -> bool {
689 src.rsplit_once('.')
690 .is_some_and(|(_, ext)| is_ladder_source_ext(ext))
691}
692
693/// Returns true when `src` is a webp SOURCE (extension `.webp`, case-
694/// insensitive). EMISSION side of the webp-vs-conversion split: a webp source
695/// carries the responsive ladder directly on `<img srcset>` (no `<picture>`),
696/// so `synthesize_inner` tests this before [`is_raster_original`]. Delegates
697/// to [`is_webp_source_ext`] — the single extension gate in `asset_paths`.
698fn is_webp_source(src: &str) -> bool {
699 src.rsplit_once('.')
700 .is_some_and(|(_, ext)| is_webp_source_ext(ext))
701}
702
703/// Try several path normalizations against `AssetSnapshot.dimensions` so the
704/// synthesizer matches the same set of input forms the prior
705/// `MediaDimensionLookup::get` handled. `src` may arrive as the resolved URL
706/// with a leading `/` (review colophon covers, cover.rs absolute paths) or
707/// with a `./` / `../` relative prefix (CJK dir_overrides); scan stores keys
708/// in plain relative form. The probe order mirrors the lookup's:
709///
710/// 1. exact match
711/// 2. leading-`/` stripped (absolute-to-relative)
712/// 3. leading `./` / `../` stripped (relative normalization)
713///
714/// Returns `None` when none of the variants is in the snapshot. The caller
715/// supplies the fallback (800×600 for dims, no style for LQIP / color).
716fn lookup_dims(assets: &AssetSnapshot, src: &str) -> Option<(u32, u32)> {
717 probe_paths(src, |p| assets.dims(&p))
718}
719
720/// Whether `src` is a scan-flagged animated source. Probes the SAME path
721/// normalizations as [`lookup_dims`] against `AssetSnapshot.animated` (keyed
722/// identically to `dimensions`, both populated per-source in
723/// `build_asset_snapshot`), so a webp found for dims is found for animation
724/// too. Missing everywhere → `false` (test/fragment-render paths with an empty
725/// snapshot treat sources as non-animated). Only the webp ladder branch
726/// consults this — png/jpg/jpeg are never animated through the `<picture>`
727/// path (see `asset_paths::ladder_rungs` census doc).
728fn lookup_animated(assets: &AssetSnapshot, src: &str) -> bool {
729 probe_paths(src, |p| assets.animated.get(&p).copied()).unwrap_or(false)
730}
731
732/// Resolve the responsive ladder for `src`: the rung widths (strictly below the
733/// deployed base) and the deployed base WIDTH — or `None` when dims are unknown
734/// (snapshot miss) or the ladder is empty (small/animated source). Shared by
735/// BOTH emission paths (webp `<img srcset>` and png/jpg `<picture><source>`) so
736/// the `lookup_dims → ladder_rungs → is_empty → deployed_width` derivation
737/// CANNOT drift between them — and it must agree with registration/encode,
738/// which call the identical `ladder_rungs`/`deployed_width` (see the
739/// deterministic-agreement contract on [`crate::asset_paths::ladder_rungs`],
740/// including its EXIF-orientation agreement).
741fn resolve_ladder(
742 assets: &AssetSnapshot,
743 src: &str,
744 is_animated: bool,
745) -> Option<(&'static [u32], u32)> {
746 lookup_dims(assets, src).and_then(|(w, h)| {
747 let rungs = ladder_rungs(w, h, is_animated);
748 if rungs.is_empty() {
749 None
750 } else {
751 Some((rungs, deployed_width(w, h)))
752 }
753 })
754}
755
756/// Assemble a `srcset` value: one `to_webp_rung(src, w) {w}w` candidate per
757/// rung, then the base descriptor `{base_url} {base_w}w`. Shared by BOTH
758/// emission paths so the rung-URL derivation and descriptor shape cannot drift
759/// between them (and must agree with what registration/encode name). The ONLY
760/// difference is `base_url`: a webp SOURCE passes `src` itself (the served base
761/// IS the source — `to_webp(src) == src`); a png/jpg/jpeg source passes
762/// `to_webp(src)` (the converted `<picture>` base). Rung URLs derive from `src`
763/// in both cases. Caller HTML-escapes the returned value.
764fn build_srcset(src: &str, base_url: &str, rungs: &[u32], base_w: u32) -> String {
765 let mut parts: Vec<String> = rungs
766 .iter()
767 .map(|w| format!("{} {}w", encode_srcset_url(&to_webp_rung(src, *w)), w))
768 .collect();
769 parts.push(format!("{} {}w", encode_srcset_url(base_url), base_w));
770 parts.join(", ")
771}
772
773/// Percent-encode literal commas (`,` → `%2C`) in one `srcset` candidate URL.
774///
775/// `srcset` is a comma-delimited candidate list, so a literal comma inside a
776/// URL — which a comma-named source (`a,b.jpg`) produces, since
777/// `percent_encode_path_segments` keeps `,` literal by design — would mis-split
778/// the list in browsers AND in `html_post`'s `rewrite_srcset_candidates`.
779/// Comma is the ONLY char touched (`src` was already encoded upstream and never
780/// carries a `%2C`, so no double-encode); applied ONLY to `srcset` candidates —
781/// the base `<img src>` keeps its single, unambiguous literal comma. The
782/// deployed file has a literal comma on disk; a static server decodes `%2C` → `,`
783/// when resolving (verified for the preview server in `router.rs`'s `%2C` test).
784fn encode_srcset_url(url: &str) -> String {
785 url.replace(',', "%2C")
786}
787
788fn lookup_lqip<'a>(assets: &'a AssetSnapshot, src: &str) -> Option<&'a str> {
789 probe_paths(src, |p| assets.lqip(&p))
790}
791
792fn lookup_color<'a>(assets: &'a AssetSnapshot, src: &str) -> Option<&'a String> {
793 probe_paths(src, |p| assets.dominant_color.get(&p))
794}
795
796fn probe_paths<T>(src: &str, mut probe: impl FnMut(PathBuf) -> Option<T>) -> Option<T> {
797 if let Some(v) = probe_normalized(src, &mut probe) {
798 return Some(v);
799 }
800 // BUG 6.2 (belt-and-suspenders): body/wikilink images arrive percent-encoded
801 // (`Europe%20-%20A%20Prophecy`), but snapshot keys are the RAW source path.
802 // Decode `%XX` and re-probe so the encoded URL reverses to the source key.
803 // Pure + zero-I/O; on invalid/lone `%` `percent_decode` returns the input
804 // unchanged, so we only re-probe when decoding actually changed something.
805 let decoded = percent_decode(src);
806 if decoded != src {
807 if let Some(v) = probe_normalized(&decoded, &mut probe) {
808 return Some(v);
809 }
810 }
811 None
812}
813
814/// Probe `src` plus its leading-`/` and leading-`./`/`../`-stripped forms.
815fn probe_normalized<T>(src: &str, probe: &mut impl FnMut(PathBuf) -> Option<T>) -> Option<T> {
816 if let Some(v) = probe(PathBuf::from(src)) {
817 return Some(v);
818 }
819 let stripped = src.strip_prefix('/').unwrap_or(src);
820 if stripped != src {
821 if let Some(v) = probe(PathBuf::from(stripped)) {
822 return Some(v);
823 }
824 }
825 let mut s: &str = src;
826 while let Some(rest) = s.strip_prefix("./").or_else(|| s.strip_prefix("../")) {
827 s = rest;
828 }
829 if s != src {
830 if let Some(v) = probe(PathBuf::from(s)) {
831 return Some(v);
832 }
833 }
834 None
835}
836
837/// Percent-decode `%XX` byte sequences in a URL path (pure, zero-I/O).
838/// The single implementation lives next to the encoder it inverts; see
839/// [`crate::resolve::fuzzy_path::percent_decode_path`].
840fn percent_decode(path: &str) -> String {
841 crate::resolve::fuzzy_path::percent_decode_path(path)
842}
843
844/// Emit just the `<img>` tag with all attributes. Internal helper for
845/// `synthesize_image_html` — exposed as `pub(crate)` only for snapshot tests
846/// that want to assert against the bare img output without the optional
847/// `<picture>` wrapper.
848///
849/// `srcset_sizes` is `Some((srcset, sizes))` ONLY for the Phase-B webp ladder
850/// (Task 12), which carries the responsive candidates on the `<img>` itself
851/// rather than a `<source>`. When `Some`, ` srcset="…" sizes="…"` is emitted
852/// immediately after `src=` (both values HTML-escaped, matching the
853/// `<picture>` path's escaping). When `None` — every other caller and every
854/// non-laddered webp — the output is BYTE-IDENTICAL to the pre-Task-12 shape.
855pub(crate) fn render_img_tag(
856 src: &str,
857 alt: &str,
858 assets: &AssetSnapshot,
859 options: &ImageRenderOptions<'_>,
860 srcset_sizes: Option<(&str, &str)>,
861) -> String {
862 // AssetSnapshot's `dims` is keyed by PathBuf; the src arrives as the
863 // resolved URL the upstream renderer baked (potentially absolute, e.g.
864 // `/image/cover.jpg`). Scan stores relative keys (`image/cover.jpg`),
865 // so the synthesizer probes both forms via `lookup_dims`. Snapshot
866 // lookups absent → fall back to the legacy 800×600 (matches the prior
867 // `MediaDimensionLookup::get` semantics before the Phase 1 B1 migration).
868 // Stem fallback (extension-mismatch — e.g. `.mov` vs `.mp4`) was
869 // previously handled in MediaDimensionLookup::get; since AssetSnapshot
870 // exposes only exact-path access, that case will need follow-up if
871 // production sites rely on it (likely only for video posters, which
872 // are out of this Tag::Image path).
873 let (width, height) = lookup_dims(assets, src).unwrap_or((FALLBACK_WIDTH, FALLBACK_HEIGHT));
874
875 let class_attr = options
876 .class
877 .map(|c| format!(r#" class="{}""#, html_escape(c)))
878 .unwrap_or_default();
879
880 let (loading_attr, fetchpriority_attr) = if options.eager {
881 (r#" loading="eager""#, r#" fetchpriority="high""#)
882 } else {
883 (r#" loading="lazy""#, "")
884 };
885
886 // Suppress LQIP / dominant-color style when extra_attrs already carries
887 // a style= attribute (e.g., `:::hero {attrs="cover-fit=contain"}` passes
888 // `style="object-fit:contain"` through extra_attrs). The browser would
889 // honor the LAST style= it sees and drop the LQIP, so emitting both
890 // produces malformed HTML and loses the placeholder. The legacy regex
891 // pass (`placeholder.rs:413-422`) had the same has_style guard; this
892 // preserves parity. Future work: merge the two declarations into a
893 // single style= via a typed `ImageRenderOptions::media_attrs` field so
894 // the synthesizer owns escaping end-to-end (impl-review item 9).
895 let extra_has_style = options
896 .extra_attrs
897 .map(|s| s.contains("style="))
898 .unwrap_or(false);
899
900 let style_attr = if extra_has_style {
901 String::new()
902 } else if let Some(lqip) = lookup_lqip(assets, src) {
903 format!(
904 r#" style="background-image:url({});background-size:cover""#,
905 lqip
906 )
907 } else if let Some(color) = lookup_color(assets, src) {
908 format!(r#" style="background-color:{}""#, color)
909 } else {
910 String::new()
911 };
912
913 let extra = options
914 .extra_attrs
915 .map(|s| format!(" {}", s))
916 .unwrap_or_default();
917
918 // Phase B webp ladder (Task 12): responsive candidates ride the `<img>`
919 // itself. Emitted right after `src=` and before `width=`. Empty for every
920 // other caller, keeping the byte shape identical to the pre-Task-12 tag.
921 let srcset_attr = match srcset_sizes {
922 Some((srcset, sizes)) => format!(
923 r#" srcset="{}" sizes="{}""#,
924 html_escape(srcset),
925 html_escape(sizes),
926 ),
927 None => String::new(),
928 };
929
930 // `data-placeholder-src` removed 2026-05-20: the iframe-bridge handler
931 // now matches by URL substring against `src` / `srcset` (see
932 // frontend/bridge/iframe-bridge.ts, moss-asset-ready branch). The
933 // AssetRegistry's promise model + the preview server's URL-keyed lookup
934 // make the attribute redundant. See
935 // docs/archive/2026-05-20-image-variant-honest-mirror.md (Layer 3).
936 //
937 // Inline LQIP via `background-image: url(data:image/jpeg;base64,…)` is
938 // kept — legitimate production technique (cf. Vercel `blurDataURL`,
939 // nextjs.org/docs/app/api-reference/components/image). Shows a blurred
940 // preview instantly while the actual bytes are being decoded.
941 format!(
942 r#"<img{class_attr} src="{src_esc}"{srcset} width="{w}" height="{h}"{loading}{fetch}{style} alt="{alt}"{extra} />"#,
943 class_attr = class_attr,
944 src_esc = html_escape(src),
945 srcset = srcset_attr,
946 w = width,
947 h = height,
948 loading = loading_attr,
949 fetch = fetchpriority_attr,
950 style = style_attr,
951 alt = html_escape(alt),
952 extra = extra,
953 )
954}
955
956#[cfg(test)]
957#[path = "image_tests.rs"]
958mod tests;