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        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;