Skip to main content

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**: `![alt](src) *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;