Skip to main content

sl_map_web/routes/
render.rs

1//! Render endpoints: start a render job, persist it to the library, and
2//! return its id. The same UUID is used for the in-memory `JobStore` and the
3//! `saved_renders` row, so the existing `/api/render/{id}/*` endpoints
4//! (live SSE / in-memory image) and the new `/api/renders/{id}/*` endpoints
5//! (persisted) address the same render.
6
7use std::io::Cursor;
8use std::sync::Arc;
9use std::sync::atomic::Ordering;
10
11use axum::Json;
12use axum::extract::{Multipart, State};
13use bytes::Bytes;
14use chrono::Utc;
15use image::GenericImageView as _;
16use image::{ImageFormat, Rgba};
17use serde::{Deserialize, Serialize};
18use sl_map_apis::map_tiles::{Map, MapProgressEvent};
19use sl_map_apis::region::usb_notecard_to_grid_rectangle;
20use sl_types::map::{
21    GridCoordinates, GridRectangle, GridRectangleLike as _, RegionCoordinates, USBNotecard,
22    ZoomLevel,
23};
24use tokio_stream::wrappers::ReceiverStream;
25use uuid::Uuid;
26
27use crate::auth::CurrentUser;
28use crate::error::Error;
29use crate::jobs::{JobId, JobOutcome, JobState, Metadata, ProgressDto, record_event};
30use crate::library::{self, Destination};
31use crate::routes::notecards as notecard_routes;
32use crate::state::AppState;
33use crate::storage;
34
35/// Maximum width or height of a rendered image in pixels. The renderer
36/// allocates roughly `4 * max_width * max_height` bytes for the output
37/// buffer. This per-side cap is paired with [`MAX_OUTPUT_AREA`] below, which
38/// bounds the actual allocation: alone, 32 768 per side would permit a
39/// ~4 GiB buffer. Beyond a sanity check it prevents an attacker-supplied
40/// `max_width` / `max_height` from driving the server out of memory.
41const MAX_OUTPUT_DIMENSION: u32 = 0x8000;
42
43/// Maximum area (`max_width * max_height`) of a rendered image in pixels.
44/// 16 384² ≈ 268 M pixels ≈ 1 GiB for the RGBA output buffer — far above any
45/// realistic map (the form default is 2048²) yet well below the ~4 GiB a
46/// 32 768² request would otherwise allocate. This is the real memory bound and
47/// applies on every render *and* read-only preview path (which, unlike the
48/// submit path, have no per-user concurrency cap), so a single request — or a
49/// burst of preview requests — cannot exhaust memory.
50const MAX_OUTPUT_AREA: u64 = 0x4000 * 0x4000;
51
52/// Maximum number of in-progress renders per user. The renderer is
53/// serialised on a single map-tile cache, so one user submitting many
54/// concurrent jobs would block all other users. Three is a small ceiling
55/// that lets a user kick off a couple of variants in parallel without
56/// monopolising the worker.
57const MAX_CONCURRENT_RENDERS_PER_USER: i64 = 3;
58
59/// Minimum rendered size of a region, in pixels, for the per-region name and
60/// grid-coordinate text overlays to be drawn. Below this the text simply does
61/// not fit a region and would smear across its neighbours, so both are skipped
62/// (the cheap rectangle outline is still drawn). 64 px corresponds to zoom
63/// level 3 or lower.
64const MIN_PIXELS_PER_REGION_FOR_REGION_LABELS: f32 = 64.0;
65
66/// Maximum number of regions in a render for which the per-region name and
67/// grid-coordinate overlays are drawn. Each region name is an individual
68/// upstream lookup (cached, but cold on first use), so a huge rectangle would
69/// fan out into thousands of requests. With the default 2048 px output and the
70/// 64 px-per-region floor above this works out to roughly a 32×32 region area.
71const MAX_REGIONS_FOR_REGION_LABELS: usize = 1024;
72
73/// Fraction of a region's rendered pixel size used as the region-label font
74/// size, before clamping to [`REGION_LABEL_FONT_MIN_PX`] /
75/// [`REGION_LABEL_FONT_MAX_PX`]. Keeps the text proportional to the zoom.
76const REGION_LABEL_FONT_FACTOR: f32 = 0.12;
77
78/// Lower clamp for the region-label font size in pixels.
79const REGION_LABEL_FONT_MIN_PX: f32 = 8.0;
80
81/// Upper clamp for the region-label font size in pixels.
82const REGION_LABEL_FONT_MAX_PX: f32 = 22.0;
83
84/// Padding in pixels between a region's lower-left corner and the text block
85/// drawn inside it.
86const REGION_LABEL_PADDING: i32 = 3;
87
88/// Colour of the per-region rectangle outline (opaque white; the maps it sits
89/// over are land/water so a light hairline reads on both).
90const REGION_RECTANGLE_COLOR: Rgba<u8> = Rgba([255, 255, 255, 255]);
91
92/// Which of the optional per-region annotation overlays to draw. All three are
93/// independent checkboxes in the UI and may be combined.
94#[derive(Debug, Clone, Copy, Default)]
95struct RegionOverlayOptions {
96    /// draw a hairline rectangle around each region.
97    rectangles: bool,
98    /// draw each region's name in its lower-left corner.
99    names: bool,
100    /// draw each region's `(x, y)` grid coordinates (above the name when both
101    /// are enabled).
102    coordinates: bool,
103}
104
105impl RegionOverlayOptions {
106    /// whether any overlay at all is requested.
107    const fn any(self) -> bool {
108        self.rectangles || self.names || self.coordinates
109    }
110
111    /// whether any text overlay (name or coordinates) is requested. Text is
112    /// the part gated by region size and count.
113    const fn any_text(self) -> bool {
114        self.names || self.coordinates
115    }
116}
117
118/// Output format for the rendered image.
119#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum OutputFormat {
122    /// PNG output (default).
123    #[default]
124    Png,
125    /// JPEG output.
126    #[serde(alias = "jpg")]
127    Jpeg,
128}
129
130impl OutputFormat {
131    /// Map to the matching `image::ImageFormat`.
132    const fn image_format(self) -> ImageFormat {
133        match self {
134            Self::Png => ImageFormat::Png,
135            Self::Jpeg => ImageFormat::Jpeg,
136        }
137    }
138
139    /// MIME type for the format.
140    const fn content_type(self) -> &'static str {
141        match self {
142            Self::Png => "image/png",
143            Self::Jpeg => "image/jpeg",
144        }
145    }
146}
147
148/// Shared rendering parameters present in both endpoints.
149#[derive(Debug, Clone)]
150struct CommonParams {
151    /// max width of the output image in pixels.
152    max_width: u32,
153    /// max height of the output image in pixels.
154    max_height: u32,
155    /// fill colour for missing map tiles (default: leave black).
156    missing_map_tile_color: Option<Rgba<u8>>,
157    /// fill colour for missing regions (default: water-like).
158    missing_region_color: Option<Rgba<u8>>,
159    /// output image format.
160    format: OutputFormat,
161    /// which optional per-region annotation overlays to draw.
162    region_overlay: RegionOverlayOptions,
163    /// id of the font to draw region names / coordinates with (when enabled).
164    region_label_font_id: Option<String>,
165}
166
167/// Where a render's GLW overlay should come from.
168#[derive(Debug, Clone, Serialize, Deserialize)]
169#[serde(tag = "type", rename_all = "snake_case")]
170pub enum GlwSource {
171    /// Reuse a previously-saved `saved_glw_data` row by id.
172    SavedId {
173        /// the saved row's id.
174        glw_data_id: Uuid,
175    },
176    /// Fetch the event from the GLW server by numeric event id (and
177    /// auto-save the resolved JSON to a fresh `saved_glw_data` row).
178    EventId {
179        /// the numeric event id.
180        event_id: u32,
181    },
182    /// Fetch the event from the GLW server by string event key (and
183    /// auto-save).
184    EventKey {
185        /// the string event key.
186        event_key: String,
187    },
188    /// Parse a JSON document the user pasted into the form (and
189    /// auto-save). Advanced / dev path.
190    PastedJson {
191        /// the raw JSON document, expected to deserialise as a
192        /// [`sl_glw::GlwEvent`].
193        payload: String,
194    },
195}
196
197/// Optional per-element style overrides for the GLW overlay. All
198/// hex-colour strings are validated server-side via `parse_color`.
199/// Absent fields fall back to [`sl_glw::GlwStyle::default`].
200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct GlwStyleOverrides {
202    /// when `true`, draw the dashed margin band around each override.
203    #[serde(default)]
204    pub margin_band: bool,
205    /// optional hex colour for area rectangle outlines.
206    #[serde(default)]
207    pub area_outline_color: Option<String>,
208    /// optional hex colour for circle outlines.
209    #[serde(default)]
210    pub circle_outline_color: Option<String>,
211    /// optional hex colour for the dashed margin band.
212    #[serde(default)]
213    pub margin_outline_color: Option<String>,
214    /// optional hex colour for filled wind arrows.
215    #[serde(default)]
216    pub wind_color: Option<String>,
217    /// optional hex colour for filled current arrows.
218    #[serde(default)]
219    pub current_color: Option<String>,
220    /// optional hex colour for wave glyph strokes.
221    #[serde(default)]
222    pub wave_color: Option<String>,
223    /// optional hex colour for the per-shape label text.
224    #[serde(default)]
225    pub label_color: Option<String>,
226}
227
228/// A free-floating text label to draw in one of the nine placement slots.
229/// Independent of the GLW overlay (labels can be added to any render).
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct TextLabel {
232    /// placement-slot anchor name (`top_left` … `center` … `bottom_right`).
233    pub slot: String,
234    /// the text, one entry per line. Empty / all-blank labels draw nothing.
235    pub lines: Vec<String>,
236    /// id of the font to draw this label with (per-snippet). Must match one
237    /// returned by `GET /api/fonts`.
238    pub font_id: String,
239    /// font size in pixels.
240    pub font_px: f32,
241    /// hex colour string (`#rrggbb`) for the text.
242    pub color: String,
243    /// horizontal alignment within the slot's free rectangle
244    /// (`left` | `center` | `right`); absent → the slot's outward default.
245    #[serde(default)]
246    pub h_align: Option<String>,
247    /// vertical alignment within the slot's free rectangle
248    /// (`top` | `center` | `bottom`); absent → the slot's outward default.
249    #[serde(default)]
250    pub v_align: Option<String>,
251    /// the slots this label spans, as combined by the user (each a slot-anchor
252    /// name, including [`Self::slot`]). Empty → just [`Self::slot`]. When more
253    /// than one, the label grows to the largest free rectangle within exactly
254    /// those slots' thirds and reserves every slot listed.
255    #[serde(default)]
256    pub slots: Vec<String>,
257}
258
259/// Default integer scale factor for a [`LogoPlacement`].
260const fn default_logo_scale() -> u8 {
261    1
262}
263
264/// A logo image to composite into one of the nine placement slots, drawn at
265/// its native pixel size (optionally integer-doubled) and aligned within the
266/// slot's free rectangle like a text label.
267#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct LogoPlacement {
269    /// placement-slot anchor name (`top_left` … `center` … `bottom_right`).
270    pub slot: String,
271    /// id of the saved logo (`saved_logos.logo_id`) to draw.
272    pub logo_id: Uuid,
273    /// integer scale factor applied with nearest-neighbour sampling (no
274    /// blur). Must be `1`, `2` or `4` (enforced in `plan_logos`).
275    #[serde(default = "default_logo_scale")]
276    pub scale: u8,
277    /// the slots this logo spans, as combined by the user (each a slot-anchor
278    /// name, including [`Self::slot`]). Empty → just [`Self::slot`]. When more
279    /// than one, the logo's free rectangle is the largest fitting exactly those
280    /// slots' thirds and reserves every slot listed.
281    #[serde(default)]
282    pub slots: Vec<String>,
283    /// horizontal alignment within the (single-slot or spanned) free
284    /// rectangle; absent → the slot's outward default.
285    #[serde(default)]
286    pub h_align: Option<String>,
287    /// vertical alignment within the free rectangle; absent → the slot's
288    /// outward default.
289    #[serde(default)]
290    pub v_align: Option<String>,
291}
292
293/// Per-render GLW configuration accepted by both render endpoints.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct GlwRenderOptions {
296    /// where the GLW event comes from.
297    pub source: GlwSource,
298    /// id of the font to draw labels and the legend with. Must match
299    /// one returned by `GET /api/fonts`.
300    pub font_id: String,
301    /// user-supplied display name for the auto-saved row. When
302    /// `None`, the server builds a default name from the source and
303    /// fetch timestamp (e.g. `Event 6910 fetched 2026-06-10 15:30 UTC`).
304    /// Ignored for the `SavedId` source.
305    #[serde(default)]
306    pub save_as: Option<String>,
307    /// optional style overrides; absent fields use library defaults.
308    #[serde(default)]
309    pub style: GlwStyleOverrides,
310    /// which placement slot the base legend goes in (`top_left` …
311    /// `bottom_right`, or `none` to hide it). Absent → `top_left`
312    /// (back-compat with renders saved before this field existed).
313    #[serde(default)]
314    pub legend_slot: Option<String>,
315}
316
317/// Body of `POST /api/render/grid-rectangle` (JSON).
318#[derive(Debug, Clone, Deserialize)]
319pub struct GridRectangleRequest {
320    /// lower-left x grid coordinate.
321    pub lower_left_x: u32,
322    /// lower-left y grid coordinate.
323    pub lower_left_y: u32,
324    /// upper-right x grid coordinate.
325    pub upper_right_x: u32,
326    /// upper-right y grid coordinate.
327    pub upper_right_y: u32,
328    /// max output width in pixels.
329    pub max_width: u32,
330    /// max output height in pixels.
331    pub max_height: u32,
332    /// optional hex colour string for missing map tiles.
333    #[serde(default)]
334    pub missing_map_tile_color: Option<String>,
335    /// optional hex colour string for missing regions.
336    #[serde(default)]
337    pub missing_region_color: Option<String>,
338    /// output format.
339    #[serde(default)]
340    pub format: OutputFormat,
341    /// draw a hairline rectangle around each region.
342    #[serde(default)]
343    pub draw_region_rectangles: bool,
344    /// draw each region's name in its lower-left corner.
345    #[serde(default)]
346    pub draw_region_names: bool,
347    /// draw each region's `(x, y)` grid coordinates above its name.
348    #[serde(default)]
349    pub draw_region_coordinates: bool,
350    /// id of the font to draw region names / coordinates with. Required only
351    /// when one of those overlays is enabled; must match a `GET /api/fonts` id.
352    #[serde(default)]
353    pub region_label_font_id: Option<String>,
354    /// destination for the saved render. Defaults to the user's personal
355    /// library. Format: `"personal"` or `"group:<uuid>"`.
356    #[serde(default)]
357    pub save_to: Option<String>,
358    /// optional GLW (GlobalWind) wind / current / wave overlay. The
359    /// auto-saved GLW row inherits this render's `save_to` destination.
360    #[serde(default)]
361    pub glw: Option<GlwRenderOptions>,
362    /// zero or more free-floating text labels to draw in placement slots
363    /// (independent of the GLW overlay).
364    #[serde(default)]
365    pub labels: Vec<TextLabel>,
366    /// zero or more logo images to composite in placement slots.
367    #[serde(default)]
368    pub logos: Vec<LogoPlacement>,
369    /// user-defined combined slot groups (each a list of slot-anchor names),
370    /// used only by the placement-slots endpoint to report each group's
371    /// combined free rectangle. Ignored by the render itself (which takes the
372    /// group from each label/logo's `slots`).
373    #[serde(default)]
374    pub groups: Vec<Vec<String>>,
375}
376
377/// Response shape for both render endpoints.
378#[derive(Debug, Clone, Serialize)]
379pub struct StartedResponse {
380    /// the id of the newly created job (also the `saved_renders.render_id`).
381    pub job_id: Uuid,
382    /// summary of the notecard the render is linked to, if any. For
383    /// `usb-notecard` renders this is always populated; the id may differ
384    /// from the one the caller submitted if the notecard had to be copied
385    /// into the render's scope to satisfy the DB scope-match invariant.
386    /// For `grid-rectangle` renders this is omitted.
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub notecard: Option<NotecardSummary>,
389}
390
391/// Minimal notecard descriptor included in [`StartedResponse`] so the UI
392/// can update its notecard dropdown in place when the backend copied the
393/// source notecard into a new scope.
394#[derive(Debug, Clone, Serialize)]
395pub struct NotecardSummary {
396    /// the id of the notecard the render is linked to. May differ from a
397    /// caller-supplied `notecard_id` when an auto-copy happened.
398    pub notecard_id: Uuid,
399    /// display name, suitable for a dropdown option label.
400    pub name: String,
401    /// owning scope, in the form `"personal"` or `"group:<uuid>"`.
402    pub scope: String,
403}
404
405/// Settings JSON stored in `saved_renders.settings_json`. Designed to be
406/// fed back into the form for "Regenerate".
407#[derive(Debug, Clone, Serialize, Deserialize)]
408#[serde(tag = "kind", rename_all = "snake_case")]
409pub enum SavedRenderSettings {
410    /// settings used for a grid-rectangle render.
411    GridRectangle(SavedGridRectangleSettings),
412    /// settings used for a USB-notecard render.
413    UsbNotecard(SavedUsbNotecardSettings),
414}
415
416/// Persisted form fields for a grid-rectangle render.
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct SavedGridRectangleSettings {
419    /// lower-left x grid coordinate.
420    pub lower_left_x: u32,
421    /// lower-left y grid coordinate.
422    pub lower_left_y: u32,
423    /// upper-right x grid coordinate.
424    pub upper_right_x: u32,
425    /// upper-right y grid coordinate.
426    pub upper_right_y: u32,
427    /// max output width in pixels.
428    pub max_width: u32,
429    /// max output height in pixels.
430    pub max_height: u32,
431    /// optional hex colour string for missing map tiles.
432    pub missing_map_tile_color: Option<String>,
433    /// optional hex colour string for missing regions.
434    pub missing_region_color: Option<String>,
435    /// output format (`png` / `jpeg`).
436    pub format: String,
437    /// draw a hairline rectangle around each region.
438    #[serde(default)]
439    pub draw_region_rectangles: bool,
440    /// draw each region's name in its lower-left corner.
441    #[serde(default)]
442    pub draw_region_names: bool,
443    /// draw each region's `(x, y)` grid coordinates above its name.
444    #[serde(default)]
445    pub draw_region_coordinates: bool,
446    /// id of the font used for region names / coordinates.
447    #[serde(default)]
448    pub region_label_font_id: Option<String>,
449    /// GLW overlay used for the render. When set, the carrier is always
450    /// `GlwSource::SavedId` so `Regenerate` reliably points at a row in
451    /// `saved_glw_data` instead of refetching from the GLW server.
452    #[serde(default)]
453    pub glw: Option<GlwRenderOptions>,
454    /// free-floating text labels drawn on the render.
455    #[serde(default)]
456    pub labels: Vec<TextLabel>,
457    /// logo images composited on the render.
458    #[serde(default)]
459    pub logos: Vec<LogoPlacement>,
460}
461
462/// Persisted form fields for a USB-notecard render.
463#[derive(Debug, Clone, Serialize, Deserialize)]
464#[expect(
465    clippy::struct_excessive_bools,
466    reason = "this is a flat persisted form-settings record; each bool maps directly to one independent checkbox in the render form"
467)]
468pub struct SavedUsbNotecardSettings {
469    /// the saved notecard the render was launched from.
470    pub notecard_id: Uuid,
471    /// north-side border padding in whole regions added around the
472    /// route's bounding rectangle.
473    pub border_north: u16,
474    /// south-side border padding in whole regions added around the
475    /// route's bounding rectangle.
476    pub border_south: u16,
477    /// east-side border padding in whole regions added around the
478    /// route's bounding rectangle.
479    pub border_east: u16,
480    /// west-side border padding in whole regions added around the
481    /// route's bounding rectangle.
482    pub border_west: u16,
483    /// canonical `#rrggbb` colour the route polyline was rendered in.
484    pub color: String,
485    /// max output width in pixels.
486    pub max_width: u32,
487    /// max output height in pixels.
488    pub max_height: u32,
489    /// optional hex colour string for missing map tiles.
490    pub missing_map_tile_color: Option<String>,
491    /// optional hex colour string for missing regions.
492    pub missing_region_color: Option<String>,
493    /// output format (`png` / `jpeg`).
494    pub format: String,
495    /// draw a hairline rectangle around each region.
496    #[serde(default)]
497    pub draw_region_rectangles: bool,
498    /// draw each region's name in its lower-left corner.
499    #[serde(default)]
500    pub draw_region_names: bool,
501    /// draw each region's `(x, y)` grid coordinates above its name.
502    #[serde(default)]
503    pub draw_region_coordinates: bool,
504    /// id of the font used for region names / coordinates.
505    #[serde(default)]
506    pub region_label_font_id: Option<String>,
507    /// whether a without-route variant was also produced.
508    pub save_without_route: bool,
509    /// GLW overlay used for the render. See [`SavedGridRectangleSettings::glw`].
510    #[serde(default)]
511    pub glw: Option<GlwRenderOptions>,
512    /// free-floating text labels drawn on the render.
513    #[serde(default)]
514    pub labels: Vec<TextLabel>,
515    /// logo images composited on the render.
516    #[serde(default)]
517    pub logos: Vec<LogoPlacement>,
518}
519
520/// `POST /api/render/grid-rectangle` — start a render from explicit corners.
521///
522/// # Errors
523///
524/// Returns an error if any of the optional hex colour fields fails to parse,
525/// the destination is invalid, or the user is not allowed to save to it.
526pub async fn grid_rectangle(
527    user: CurrentUser,
528    State(state): State<AppState>,
529    Json(req): Json<GridRectangleRequest>,
530) -> Result<Json<StartedResponse>, Error> {
531    validate_dimensions(req.max_width, req.max_height)?;
532    let common = CommonParams {
533        max_width: req.max_width,
534        max_height: req.max_height,
535        missing_map_tile_color: req
536            .missing_map_tile_color
537            .as_deref()
538            .map(parse_color)
539            .transpose()?,
540        missing_region_color: req
541            .missing_region_color
542            .as_deref()
543            .map(parse_color)
544            .transpose()?,
545        format: req.format,
546        region_overlay: RegionOverlayOptions {
547            rectangles: req.draw_region_rectangles,
548            names: req.draw_region_names,
549            coordinates: req.draw_region_coordinates,
550        },
551        region_label_font_id: req.region_label_font_id.clone(),
552    };
553    assert_region_overlay_font(
554        &state,
555        common.region_overlay,
556        common.region_label_font_id.as_deref(),
557    )?;
558    let destination = Destination::parse(req.save_to.as_deref().unwrap_or("personal"))?;
559    library::assert_can_write(&state.db, user.user_id, destination).await?;
560    assert_under_concurrent_limit(&state.db, user.user_id).await?;
561    let logo_ids = validate_logos(&state, user.user_id, destination, &req.logos).await?;
562    let rect = GridRectangle::new(
563        GridCoordinates::new(req.lower_left_x, req.lower_left_y),
564        GridCoordinates::new(req.upper_right_x, req.upper_right_y),
565    );
566    let glw_ctx = req.glw.clone().map(|opts| GlwJobCtx {
567        options: opts,
568        destination,
569        created_by: user.user_id,
570    });
571    // Reject an over-full placement before persisting anything: the fit check
572    // runs on a blank+GLW map, so it needs no map-tile fetch.
573    plan_placements(
574        &state,
575        rect.clone(),
576        &common,
577        glw_ctx.as_ref(),
578        None,
579        &req.labels,
580        &req.logos,
581    )
582    .await?;
583    let settings = SavedRenderSettings::GridRectangle(SavedGridRectangleSettings {
584        lower_left_x: req.lower_left_x,
585        lower_left_y: req.lower_left_y,
586        upper_right_x: req.upper_right_x,
587        upper_right_y: req.upper_right_y,
588        max_width: req.max_width,
589        max_height: req.max_height,
590        missing_map_tile_color: common.missing_map_tile_color.map(hex_from_rgba),
591        missing_region_color: common.missing_region_color.map(hex_from_rgba),
592        format: format_name(req.format).to_owned(),
593        draw_region_rectangles: req.draw_region_rectangles,
594        draw_region_names: req.draw_region_names,
595        draw_region_coordinates: req.draw_region_coordinates,
596        region_label_font_id: req.region_label_font_id.clone(),
597        // The worker may rewrite this in place after a successful
598        // fresh-fetch so the carrier always lands as `SavedId` at rest
599        // (stable Regenerate).
600        glw: req.glw.clone(),
601        labels: req.labels.clone(),
602        logos: req.logos.clone(),
603    });
604    let render_id = Uuid::new_v4();
605    insert_render_row(
606        &state,
607        render_id,
608        destination,
609        user.user_id,
610        "grid_rectangle",
611        None,
612        &settings,
613        Some(&rect),
614    )
615    .await?;
616    link_render_logos_or_fail(&state, render_id, &logo_ids).await?;
617    let (job_id, job) = state.jobs.create_with_id(render_id).await;
618    spawn_grid_rectangle_job(
619        state, job_id, job, rect, common, glw_ctx, req.labels, req.logos,
620    );
621    Ok(Json(StartedResponse {
622        job_id,
623        notecard: None,
624    }))
625}
626
627/// `POST /api/render/usb-notecard` — start a render from a notecard.
628///
629/// # Errors
630///
631/// Returns an error if the multipart form is malformed, the notecard fails
632/// to parse, required fields are missing, the destination is invalid, or
633/// the user is not allowed to save there.
634pub async fn usb_notecard(
635    user: CurrentUser,
636    State(state): State<AppState>,
637    multipart: Multipart,
638) -> Result<Json<StartedResponse>, Error> {
639    let parsed = parse_render_form(multipart).await?;
640    library::assert_can_write(&state.db, user.user_id, parsed.destination).await?;
641    assert_region_overlay_font(
642        &state,
643        parsed.common.region_overlay,
644        parsed.common.region_label_font_id.as_deref(),
645    )?;
646    assert_under_concurrent_limit(&state.db, user.user_id).await?;
647    let logo_ids = validate_logos(&state, user.user_id, parsed.destination, &parsed.logos).await?;
648
649    // Resolve the notecard: reuse an existing one (auto-copied into the
650    // render's scope if needed) or persist a freshly uploaded one. The
651    // returned summary is what the response surfaces back to the UI so it
652    // can update its dropdown if the effective id is new.
653    let (notecard, notecard_summary) = resolve_notecard(&state, &user, &parsed).await?;
654
655    let glw_ctx = parsed.glw.clone().map(|opts| GlwJobCtx {
656        options: opts,
657        destination: parsed.destination,
658        created_by: user.user_id,
659    });
660    // Reject an over-full placement before persisting the render. The route's
661    // rectangle is resolved here for the fit check; the job re-resolves it
662    // (region lookups are cached) so it can also backfill the notecard bounds.
663    let (_bare_rect, rect) = resolve_notecard_rect(&state, &notecard, parsed.borders).await?;
664    plan_placements(
665        &state,
666        rect,
667        &parsed.common,
668        glw_ctx.as_ref(),
669        Some((&notecard, parsed.color)),
670        &parsed.labels,
671        &parsed.logos,
672    )
673    .await?;
674
675    let settings = SavedRenderSettings::UsbNotecard(SavedUsbNotecardSettings {
676        notecard_id: notecard_summary.notecard_id,
677        border_north: parsed.borders.0,
678        border_south: parsed.borders.1,
679        border_east: parsed.borders.2,
680        border_west: parsed.borders.3,
681        color: hex_from_rgba(parsed.color),
682        max_width: parsed.common.max_width,
683        max_height: parsed.common.max_height,
684        missing_map_tile_color: parsed.common.missing_map_tile_color.map(hex_from_rgba),
685        missing_region_color: parsed.common.missing_region_color.map(hex_from_rgba),
686        format: format_name(parsed.common.format).to_owned(),
687        draw_region_rectangles: parsed.common.region_overlay.rectangles,
688        draw_region_names: parsed.common.region_overlay.names,
689        draw_region_coordinates: parsed.common.region_overlay.coordinates,
690        region_label_font_id: parsed.common.region_label_font_id.clone(),
691        save_without_route: parsed.with_without_route,
692        // The worker may rewrite this in place after a successful
693        // fresh-fetch so the carrier always lands as `SavedId` at rest.
694        glw: parsed.glw.clone(),
695        labels: parsed.labels.clone(),
696        logos: parsed.logos.clone(),
697    });
698
699    let render_id = Uuid::new_v4();
700    insert_render_row(
701        &state,
702        render_id,
703        parsed.destination,
704        user.user_id,
705        "usb_notecard",
706        Some(notecard_summary.notecard_id),
707        &settings,
708        None,
709    )
710    .await?;
711    link_render_logos_or_fail(&state, render_id, &logo_ids).await?;
712
713    let (job_id, job) = state.jobs.create_with_id(render_id).await;
714    spawn_usb_notecard_job(
715        state,
716        job_id,
717        job,
718        notecard_summary.notecard_id,
719        notecard,
720        parsed.borders,
721        parsed.color,
722        parsed.common,
723        parsed.with_without_route,
724        glw_ctx,
725        parsed.labels,
726        parsed.logos,
727    );
728    Ok(Json(StartedResponse {
729        job_id,
730        notecard: Some(notecard_summary),
731    }))
732}
733
734// =====================================================================
735// Free-placement-slot detection — read-only preview, no DB writes.
736//
737// Reports which of nine fixed anchor positions (the four corners, the four
738// side midpoints and the centre) are free of overlay content (route + GLW
739// shapes/labels) so the UI can offer them as placement targets for a legend,
740// logo or label *before* the final render. Computed by drawing the overlays
741// onto a blank (no base tiles) map and measuring the empty space; see
742// [`sl_map_apis::coverage`].
743// =====================================================================
744
745/// An axis-aligned rectangle in image pixel coordinates (origin top-left).
746#[derive(Debug, Clone, Copy, Serialize)]
747pub struct PixelRectDto {
748    /// x coordinate of the left edge in pixels.
749    pub x: u32,
750    /// y coordinate of the top edge in pixels.
751    pub y: u32,
752    /// width in pixels.
753    pub width: u32,
754    /// height in pixels.
755    pub height: u32,
756}
757
758/// One of the nine candidate placement slots and how much free space it has.
759#[derive(Debug, Clone, Serialize)]
760pub struct SlotDto {
761    /// the slot name (`top_left`, `top_center`, …, `center`, …,
762    /// `bottom_right`).
763    pub slot: &'static str,
764    /// whether the slot itself is free of overlay content.
765    pub available: bool,
766    /// the largest empty rectangle that can be placed anchored here, confined to
767    /// this slot's own third of the map so the nine slots never overlap, or
768    /// `null` when the slot's third has no free space at the anchor.
769    #[serde(skip_serializing_if = "Option::is_none")]
770    pub free_rect: Option<PixelRectDto>,
771    /// width of [`Self::free_rect`] in pixels (`0` when covered).
772    pub free_width: u32,
773    /// height of [`Self::free_rect`] in pixels (`0` when covered).
774    pub free_height: u32,
775    /// fraction (`0.0..=1.0`) of the local third-of-the-map region around the
776    /// anchor that is covered, as a density hint.
777    pub occupied_fraction: f32,
778    /// orthogonally adjacent anchors that share one contiguous free area with
779    /// this anchor, so they can be combined for a larger element.
780    pub connected_neighbours: Vec<&'static str>,
781}
782
783/// The combined free rectangle for one user-defined slot group (two or more
784/// slots joined together), so the client can draw and fit-check it.
785#[derive(Debug, Clone, Serialize)]
786pub struct GroupDto {
787    /// the slot-anchor names making up this group (the first is the primary
788    /// anchor used for alignment).
789    pub slots: Vec<&'static str>,
790    /// whether the group has any free rectangle.
791    pub available: bool,
792    /// the largest free rectangle within exactly these slots' thirds, or `null`
793    /// when the group is fully covered.
794    #[serde(skip_serializing_if = "Option::is_none")]
795    pub free_rect: Option<PixelRectDto>,
796    /// width of [`Self::free_rect`] in pixels (`0` when covered).
797    pub free_width: u32,
798    /// height of [`Self::free_rect`] in pixels (`0` when covered).
799    pub free_height: u32,
800}
801
802/// Response shape for the `placement-slots` endpoints.
803#[derive(Debug, Clone, Serialize)]
804pub struct PlacementSlotsResponse {
805    /// width in pixels of the image the final render will produce.
806    pub image_width: u32,
807    /// height in pixels of the image the final render will produce.
808    pub image_height: u32,
809    /// the nine candidate anchors, in reading order.
810    pub slots: Vec<SlotDto>,
811    /// combined rectangles for the requested multi-slot groups, in request
812    /// order (empty when none were requested).
813    pub groups: Vec<GroupDto>,
814}
815
816/// Reduce a drawn-on blank map to the nine-slot placement report, plus a
817/// combined rectangle for each requested multi-slot `group`.
818fn compute_placement_slots(
819    map: &Map,
820    groups: &[Vec<sl_map_apis::coverage::PlacementSlot>],
821) -> PlacementSlotsResponse {
822    use sl_map_apis::coverage::PlacementSlot;
823    let image = sl_map_apis::map_tiles::MapLike::image(map);
824    let (image_width, image_height) = image.dimensions();
825    let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
826        map,
827        sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
828    );
829    let slots = grid
830        .evaluate_slots()
831        .into_iter()
832        .map(|info| SlotDto {
833            slot: info.slot.as_str(),
834            available: info.available,
835            free_rect: info.free_rect.map(|r| PixelRectDto {
836                x: r.x,
837                y: r.y,
838                width: r.width,
839                height: r.height,
840            }),
841            free_width: info.free_size.0,
842            free_height: info.free_size.1,
843            occupied_fraction: info.occupied_fraction,
844            connected_neighbours: info
845                .connected_neighbours
846                .into_iter()
847                .map(PlacementSlot::as_str)
848                .collect(),
849        })
850        .collect();
851    let group_dtos = groups
852        .iter()
853        .filter(|g| g.len() > 1)
854        .map(|g| {
855            let rect = grid.subset_rect(g);
856            GroupDto {
857                slots: g.iter().map(|s| s.as_str()).collect(),
858                available: rect.is_some(),
859                free_rect: rect.map(|r| PixelRectDto {
860                    x: r.x,
861                    y: r.y,
862                    width: r.width,
863                    height: r.height,
864                }),
865                free_width: rect.map_or(0, |r| r.width),
866                free_height: rect.map_or(0, |r| r.height),
867            }
868        })
869        .collect();
870    PlacementSlotsResponse {
871        image_width,
872        image_height,
873        slots,
874        groups: group_dtos,
875    }
876}
877
878/// Parse client-supplied slot groups (lists of slot-anchor names) into
879/// [`PlacementSlot`] vectors, rejecting unknown names and empty groups.
880fn parse_groups(
881    raw: &[Vec<String>],
882) -> Result<Vec<Vec<sl_map_apis::coverage::PlacementSlot>>, Error> {
883    raw.iter()
884        .map(|g| {
885            if g.is_empty() {
886                return Err(Error::BadRequest(
887                    "a placement group must not be empty".to_owned(),
888                ));
889            }
890            let group = g
891                .iter()
892                .map(|name| {
893                    name.parse::<sl_map_apis::coverage::PlacementSlot>()
894                        .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
895                            Error::BadRequest(err.to_string())
896                        })
897                })
898                .collect::<Result<Vec<_>, _>>()?;
899            if !sl_map_apis::coverage::PlacementSlot::slots_form_rectangle(&group) {
900                return Err(Error::BadRequest(format!(
901                    "the combined slot group `{}` does not form a solid rectangle",
902                    g.join("+")
903                )));
904            }
905            Ok(group)
906        })
907        .collect()
908}
909
910/// Resolve a GLW source to an event WITHOUT any persistence (unlike
911/// [`resolve_glw_event`], which inserts a `saved_glw_data` row for fresh-fetch
912/// sources). Used by the read-only placement-slots preview. `SavedId` still
913/// goes through the same read-permission gate.
914async fn resolve_glw_event_readonly(
915    state: &AppState,
916    user_id: Uuid,
917    source: &GlwSource,
918) -> Result<Option<sl_glw::GlwEvent>, Error> {
919    match source {
920        GlwSource::SavedId { glw_data_id } => {
921            let row =
922                crate::library::assert_can_read_glw_data(&state.db, user_id, *glw_data_id).await?;
923            Ok(Some(serde_json::from_str(&row.payload_json)?))
924        }
925        GlwSource::EventId { event_id } => {
926            let id = sl_glw::EventId::new(*event_id);
927            let mut cache = state.glw_event_cache.lock().await;
928            Ok(cache.get_event_by_id(id).await?)
929        }
930        GlwSource::EventKey { event_key } => {
931            let key = sl_glw::GlwEventKey::new(event_key);
932            let mut cache = state.glw_event_cache.lock().await;
933            Ok(cache.get_event_by_key(&key).await?)
934        }
935        GlwSource::PastedJson { payload } => Ok(Some(serde_json::from_str(payload)?)),
936    }
937}
938
939/// Draw the GLW overlay onto `map` for occupancy purposes only: the legend is
940/// deliberately excluded (it is a *candidate* element to be placed, not a
941/// constraint), so it does not block its own slot. Per-shape labels are kept
942/// because they mark real overlay positions.
943async fn apply_glw_overlay_readonly(
944    state: &AppState,
945    user_id: Uuid,
946    options: &GlwRenderOptions,
947    map: &mut Map,
948) -> Result<(), Error> {
949    use sl_glw::MapLikeGlwExt as _;
950    let font_path = state
951        .fonts
952        .path_for(&options.font_id)
953        .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", options.font_id)))?;
954    let font = sl_map_apis::text::load_font(font_path)?;
955    let Some(event) = resolve_glw_event_readonly(state, user_id, &options.source).await? else {
956        tracing::warn!("GLW event not found; placement slots computed without overlay");
957        return Ok(());
958    };
959    // The legend is excluded from occupancy (it is a candidate element, not a
960    // constraint), so its slot does not matter here.
961    let mut style = build_glw_style(&options.style, None)?;
962    style.legend_position = None;
963    map.draw_glw_event_with_font(&event, &style, &font);
964    Ok(())
965}
966
967/// `POST /api/render/placement-slots/grid-rectangle` — report the free
968/// placement slots for an explicit-corner render (optional GLW overlay), with
969/// no route. Read-only: nothing is persisted.
970///
971/// # Errors
972///
973/// Returns an error if the dimensions are invalid, the GLW colours fail to
974/// parse, or a referenced GLW row cannot be read.
975pub async fn free_placement_slots_grid_rectangle(
976    user: CurrentUser,
977    State(state): State<AppState>,
978    Json(req): Json<GridRectangleRequest>,
979) -> Result<Json<PlacementSlotsResponse>, Error> {
980    validate_dimensions(req.max_width, req.max_height)?;
981    let rect = GridRectangle::new(
982        GridCoordinates::new(req.lower_left_x, req.lower_left_y),
983        GridCoordinates::new(req.upper_right_x, req.upper_right_y),
984    );
985    let groups = parse_groups(&req.groups)?;
986    let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
987    if let Some(glw) = req.glw.as_ref() {
988        apply_glw_overlay_readonly(&state, user.user_id, glw, &mut map).await?;
989    }
990    Ok(Json(compute_placement_slots(&map, &groups)))
991}
992
993/// `POST /api/render/placement-slots/usb-notecard` — report the free
994/// placement slots for a notecard render (route + optional GLW overlay).
995/// Read-only: the notecard is parsed/loaded but never copied or persisted,
996/// and no GLW row is inserted.
997///
998/// # Errors
999///
1000/// Returns an error if the form is malformed, the notecard cannot be parsed
1001/// or read, a region cannot be resolved, or the GLW overlay fails to resolve.
1002pub async fn free_placement_slots_usb_notecard(
1003    user: CurrentUser,
1004    State(state): State<AppState>,
1005    multipart: Multipart,
1006) -> Result<Json<PlacementSlotsResponse>, Error> {
1007    let parsed = parse_render_form(multipart).await?;
1008    // Read-only notecard resolution (no auto-copy, no persistence).
1009    let notecard: USBNotecard = match &parsed.notecard_source {
1010        NotecardSource::Fresh { text, .. } => text.parse()?,
1011        NotecardSource::Existing { notecard_id } => {
1012            let row =
1013                crate::library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id)
1014                    .await?;
1015            row.body.parse()?
1016        }
1017    };
1018    let (border_north, border_south, border_east, border_west) = parsed.borders;
1019    let rect = {
1020        let mut region = state.region_cache.lock().await;
1021        usb_notecard_to_grid_rectangle(&mut region, &notecard).await?
1022    }
1023    .expanded_west(border_west)
1024    .expanded_east(border_east)
1025    .expanded_south(border_south)
1026    .expanded_north(border_north);
1027    let mut map = Map::blank_fit(rect, parsed.common.max_width, parsed.common.max_height)?;
1028    // Same layering as the real render: GLW under the route.
1029    if let Some(glw) = parsed.glw.as_ref() {
1030        apply_glw_overlay_readonly(&state, user.user_id, glw, &mut map).await?;
1031    }
1032    {
1033        let mut region = state.region_cache.lock().await;
1034        map.draw_route_with_progress(&mut region, &notecard, parsed.color, None)
1035            .await?;
1036    }
1037    let groups = parse_groups(&parsed.groups)?;
1038    Ok(Json(compute_placement_slots(&map, &groups)))
1039}
1040
1041/// Body of `POST /api/render/glw-preview` (JSON).
1042#[derive(Debug, Clone, Deserialize)]
1043pub struct GlwPreviewRequest {
1044    /// lower-left x grid coordinate of the final-image rectangle.
1045    pub lower_left_x: u32,
1046    /// lower-left y grid coordinate of the final-image rectangle.
1047    pub lower_left_y: u32,
1048    /// upper-right x grid coordinate of the final-image rectangle.
1049    pub upper_right_x: u32,
1050    /// upper-right y grid coordinate of the final-image rectangle.
1051    pub upper_right_y: u32,
1052    /// zoom level (1–8) the client is compositing the preview tiles at. The
1053    /// overlay is rasterised at this same zoom so its shapes line up 1:1 with
1054    /// the displayed tiles once the PNG is dropped into the final-image
1055    /// bounds, and so the returned image stays small.
1056    pub zoom: u8,
1057    /// the GLW overlay to draw.
1058    pub glw: GlwRenderOptions,
1059}
1060
1061/// `POST /api/render/glw-preview` — rasterise just the GLW overlay onto a
1062/// transparent image matching the final-image bounds, so the client-side
1063/// preview can composite it over the map tiles. Read-only: nothing is
1064/// persisted (no `saved_glw_data` row is inserted).
1065///
1066/// The overlay draws the geographic GLW content — shapes and their per-shape
1067/// labels — at the preview's zoom level rather than the final render's, so the
1068/// returned PNG is small and aligns with the preview tiles. The base legend is
1069/// deliberately omitted: like `apply_glw_overlay_readonly` it is a candidate
1070/// placement element handled separately by the placement-slot logic, not part
1071/// of the geographic overlay. When the GLW event cannot be resolved the
1072/// response is a fully transparent PNG, leaving the tiles unchanged.
1073///
1074/// # Errors
1075///
1076/// Returns an error if the zoom level is out of range, the font is unknown,
1077/// the GLW colours fail to parse, or a referenced GLW row cannot be read.
1078pub async fn glw_preview(
1079    user: CurrentUser,
1080    State(state): State<AppState>,
1081    Json(req): Json<GlwPreviewRequest>,
1082) -> Result<axum::response::Response, Error> {
1083    use axum::response::IntoResponse as _;
1084    use sl_glw::MapLikeGlwExt as _;
1085    let zoom = ZoomLevel::try_new(req.zoom)
1086        .map_err(|err| Error::BadRequest(format!("invalid zoom: {err}")))?;
1087    let rect = GridRectangle::new(
1088        GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1089        GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1090    );
1091    // The rectangle and zoom are client-controlled, so bound the resulting
1092    // image the same way the other render/preview endpoints do via
1093    // `validate_dimensions`. Unlike those, this preview sizes the map from a
1094    // fixed zoom rather than fitting into max_width/max_height, so a tiny
1095    // request (e.g. the full grid at zoom 1) would otherwise drive an enormous
1096    // `RgbaImage` allocation. Reproduce `Map::blank`'s sizing to check it.
1097    let pixels_per_region = u32::from(zoom.pixels_per_region());
1098    let width = pixels_per_region.saturating_mul(rect.size_x());
1099    let height = pixels_per_region.saturating_mul(rect.size_y());
1100    validate_dimensions(width, height)?;
1101    let mut map = Map::blank(rect, zoom);
1102    // Resolve the font first so a missing-font request fails fast.
1103    let font_path = state
1104        .fonts
1105        .path_for(&req.glw.font_id)
1106        .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", req.glw.font_id)))?;
1107    let font = sl_map_apis::text::load_font(font_path)?;
1108    // Read-only resolution: unlike the real render this never persists a row.
1109    if let Some(event) = resolve_glw_event_readonly(&state, user.user_id, &req.glw.source).await? {
1110        // The legend is excluded from the overlay: it is a *candidate*
1111        // placement element handled separately by the placement-slot logic
1112        // (see `apply_glw_overlay_readonly`), not part of the geographic
1113        // overlay the preview composites over the tiles. Only the shapes and
1114        // their per-shape labels are drawn.
1115        let mut style = build_glw_style(&req.glw.style, None)?;
1116        style.legend_position = None;
1117        map.draw_glw_event_with_font(&event, &style, &font);
1118    } else {
1119        tracing::warn!("GLW event not found; preview overlay is fully transparent");
1120    }
1121    // PNG keeps the transparent background so only the overlay composites over
1122    // the client's tiles.
1123    let image = encode_map(&map, OutputFormat::Png)?;
1124    Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
1125}
1126
1127/// `POST /api/render/glw-legend-preview` — rasterise just the GLW base legend
1128/// onto a transparent image the size of the final render, so the client-side
1129/// preview can drop it into the final-image bounds rectangle and show the
1130/// legend exactly where — and at the size — the final render will place it.
1131///
1132/// Unlike [`glw_preview`] this draws ONLY the legend (no geographic shapes or
1133/// per-shape labels), and it renders at the final-image resolution
1134/// (`Map::blank_fit` with the request's `max_width`/`max_height`) rather than
1135/// the preview zoom, so the legend's anchored position and its fixed font size
1136/// match the real render once the client scales the PNG into the bounds
1137/// rectangle. The request reuses [`GridRectangleRequest`]; only the corners,
1138/// `max_width`/`max_height` and `glw` (whose `legend_slot` chooses the slot)
1139/// are consulted. When no GLW overlay is requested, the legend slot is `none`,
1140/// or the GLW event cannot be resolved, the response is a fully transparent
1141/// PNG, leaving the tiles unchanged.
1142///
1143/// # Errors
1144///
1145/// Returns an error if the dimensions are invalid, the font is unknown, the
1146/// GLW colours fail to parse, or a referenced GLW row cannot be read.
1147pub async fn glw_legend_preview(
1148    user: CurrentUser,
1149    State(state): State<AppState>,
1150    Json(req): Json<GridRectangleRequest>,
1151) -> Result<axum::response::Response, Error> {
1152    use axum::response::IntoResponse as _;
1153    use sl_glw::MapLikeGlwExt as _;
1154    validate_dimensions(req.max_width, req.max_height)?;
1155    let rect = GridRectangle::new(
1156        GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1157        GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1158    );
1159    // Final-image resolution so the legend matches the real render exactly.
1160    let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
1161    if let Some(glw) = req.glw.as_ref() {
1162        // Resolve the font first so a missing-font request fails fast.
1163        let font_path = state
1164            .fonts
1165            .path_for(&glw.font_id)
1166            .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", glw.font_id)))?;
1167        let font = sl_map_apis::text::load_font(font_path)?;
1168        let style = build_glw_style(&glw.style, glw.legend_slot.as_deref())?;
1169        // Draw only the legend, at its chosen slot. `legend_position` is `None`
1170        // when the slot is `none`, so skip the lookup/draw entirely then.
1171        if style.legend_position.is_some() {
1172            if let Some(event) =
1173                resolve_glw_event_readonly(&state, user.user_id, &glw.source).await?
1174            {
1175                map.draw_glw_base_legend(&event.base, &style, &font);
1176            } else {
1177                tracing::warn!("GLW event not found; legend preview is fully transparent");
1178            }
1179        }
1180    }
1181    // PNG keeps the transparent background so only the legend composites over
1182    // the client's tiles.
1183    let image = encode_map(&map, OutputFormat::Png)?;
1184    Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
1185}
1186
1187/// One already-resolved route waypoint in a [`RoutePreviewRequest`]. The
1188/// region has already been resolved to grid coordinates by the client's
1189/// `/api/notecard/derive-rectangle` call, so the preview endpoint needs no
1190/// region lookup (no DB / network). Mirrors the fields of
1191/// [`crate::routes::notecards`]'s `ResolvedWaypoint`; `z` is irrelevant to the
1192/// pixel mapping and is omitted.
1193#[derive(Debug, Clone, Deserialize)]
1194pub struct RoutePreviewWaypoint {
1195    /// resolved x grid coordinate of the region.
1196    pub region_x: u32,
1197    /// resolved y grid coordinate of the region.
1198    pub region_y: u32,
1199    /// in-region x coordinate of the waypoint (metres).
1200    pub x: f32,
1201    /// in-region y coordinate of the waypoint (metres).
1202    pub y: f32,
1203}
1204
1205/// Body of `POST /api/render/route-preview` (JSON).
1206#[derive(Debug, Clone, Deserialize)]
1207pub struct RoutePreviewRequest {
1208    /// lower-left x grid coordinate of the final-image rectangle.
1209    pub lower_left_x: u32,
1210    /// lower-left y grid coordinate of the final-image rectangle.
1211    pub lower_left_y: u32,
1212    /// upper-right x grid coordinate of the final-image rectangle.
1213    pub upper_right_x: u32,
1214    /// upper-right y grid coordinate of the final-image rectangle.
1215    pub upper_right_y: u32,
1216    /// final-image fit width in pixels (so the route is rasterised at the same
1217    /// resolution the real render uses).
1218    pub max_width: u32,
1219    /// final-image fit height in pixels.
1220    pub max_height: u32,
1221    /// route colour as `#rrggbb`.
1222    pub color: String,
1223    /// the already-resolved waypoints, in order.
1224    pub waypoints: Vec<RoutePreviewWaypoint>,
1225}
1226
1227/// `POST /api/render/route-preview` — rasterise just the route onto a
1228/// transparent image at the final-image resolution, so the client-side preview
1229/// can composite it over the map tiles exactly as the real render draws it.
1230/// Read-only: nothing is persisted.
1231///
1232/// The route is drawn with the *same* code the final image uses
1233/// ([`Map::draw_pixel_waypoint_route`] — Catmull-Rom spline + per-waypoint
1234/// arrows + the route colour), onto a [`Map::blank_fit`] map sized for the
1235/// request's `max_width` / `max_height`. The client drops the returned PNG into
1236/// the preview's bounds rectangle (scaling it down), so the preview route is a
1237/// pixel-faithful, merely-downscaled copy of what the output will contain —
1238/// including correct arrow/line proportions and the same edge clipping. Unlike
1239/// [`Map::draw_route_with_progress`] the waypoints arrive already resolved to
1240/// grid coordinates, so this performs no region lookup.
1241///
1242/// # Errors
1243///
1244/// Returns [`Error::BadRequest`] if the dimensions are invalid, the colour
1245/// fails to parse, or a waypoint falls outside the rectangle; propagates any
1246/// error from the spline rasterisation or PNG encoding.
1247pub async fn route_preview(
1248    _user: CurrentUser,
1249    Json(req): Json<RoutePreviewRequest>,
1250) -> Result<axum::response::Response, Error> {
1251    use axum::response::IntoResponse as _;
1252    use sl_map_apis::map_tiles::MapLike as _;
1253    validate_dimensions(req.max_width, req.max_height)?;
1254    let color = parse_color(&req.color)?;
1255    let rect = GridRectangle::new(
1256        GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1257        GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1258    );
1259    // Final-image resolution so the route matches the real render exactly once
1260    // the client scales the PNG into the bounds rectangle.
1261    let mut map = Map::blank_fit(rect, req.max_width, req.max_height)?;
1262    let mut pixel_waypoints = Vec::with_capacity(req.waypoints.len());
1263    for waypoint in &req.waypoints {
1264        let grid = GridCoordinates::new(waypoint.region_x, waypoint.region_y);
1265        let region_coordinates = RegionCoordinates::new(waypoint.x, waypoint.y, 0f32);
1266        let (x, y) = map
1267            .pixel_coordinates_for_coordinates(&grid, &region_coordinates)
1268            .ok_or_else(|| {
1269                Error::BadRequest("a route waypoint falls outside the rectangle".to_owned())
1270            })?;
1271        #[expect(
1272            clippy::as_conversions,
1273            clippy::cast_precision_loss,
1274            reason = "matches draw_route_with_progress: pixel coordinates never approach 2^23"
1275        )]
1276        pixel_waypoints.push((x as f32, y as f32));
1277    }
1278    map.draw_pixel_waypoint_route(&pixel_waypoints, color)
1279        .map_err(|err| Error::BadRequest(format!("route rasterisation failed: {err}")))?;
1280    // PNG keeps the transparent background so only the route composites over the
1281    // client's tiles.
1282    let image = encode_map(&map, OutputFormat::Png)?;
1283    Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], image).into_response())
1284}
1285
1286/// Body of `POST /api/render/region-overlay-preview` (JSON).
1287#[derive(Debug, Clone, Deserialize)]
1288pub struct RegionOverlayPreviewRequest {
1289    /// lower-left x grid coordinate of the final-image rectangle.
1290    pub lower_left_x: u32,
1291    /// lower-left y grid coordinate of the final-image rectangle.
1292    pub lower_left_y: u32,
1293    /// upper-right x grid coordinate of the final-image rectangle.
1294    pub upper_right_x: u32,
1295    /// upper-right y grid coordinate of the final-image rectangle.
1296    pub upper_right_y: u32,
1297    /// final-image fit width in pixels (so the overlay is rasterised at the
1298    /// same resolution — and thus the same per-region pixel size — the real
1299    /// render uses, keeping the size gate consistent between preview and final).
1300    pub max_width: u32,
1301    /// final-image fit height in pixels.
1302    pub max_height: u32,
1303    /// draw a hairline rectangle around each region.
1304    #[serde(default)]
1305    pub draw_region_rectangles: bool,
1306    /// draw each region's name in its lower-left corner.
1307    #[serde(default)]
1308    pub draw_region_names: bool,
1309    /// draw each region's `(x, y)` grid coordinates above its name.
1310    #[serde(default)]
1311    pub draw_region_coordinates: bool,
1312    /// id of the font to draw region names / coordinates with.
1313    #[serde(default)]
1314    pub region_label_font_id: Option<String>,
1315    /// optional hex colour for the missing-region fill. When set (the user has
1316    /// "Fill missing regions" enabled) and region names are being looked up,
1317    /// regions the lookup reports as non-existent are painted with this colour,
1318    /// reusing the lookup result the names need anyway. Absent → no fill.
1319    #[serde(default)]
1320    pub missing_region_color: Option<String>,
1321}
1322
1323/// `POST /api/render/region-overlay-preview` — rasterise the optional
1324/// per-region annotation overlay (rectangles, names, grid coordinates) onto a
1325/// transparent image at the final-image resolution, so the client-side preview
1326/// can composite it into the bounds rectangle exactly where — and gated exactly
1327/// as — the final render draws it. Read-only: nothing is persisted (region
1328/// names are resolved through the shared cache only).
1329///
1330/// Resolving region names is the slow part (one cached-but-cold lookup per
1331/// region), so the response is a streaming `application/x-ndjson` body rather
1332/// than a bare PNG: one JSON object per line, the region-name progress events
1333/// (`region_names_planned` / `region_name_resolved`, same shape as the render
1334/// SSE) as they happen, then a terminal line — `{"type":"image","data":"<base64
1335/// png>"}` on success or `{"type":"error","message":…}` on failure. This lets
1336/// the preview show a live `region names: N / M` counter for the work it (not
1337/// the later render, which finds the names already cached) actually performs.
1338///
1339/// # Errors
1340///
1341/// Returns an error (before streaming starts) if the dimensions are invalid or a
1342/// text overlay is requested without a (known) font. Failures during rendering
1343/// are reported as the terminal `error` line within the stream.
1344pub async fn region_overlay_preview(
1345    _user: CurrentUser,
1346    State(state): State<AppState>,
1347    Json(req): Json<RegionOverlayPreviewRequest>,
1348) -> Result<axum::response::Response, Error> {
1349    use axum::response::IntoResponse as _;
1350    use futures::StreamExt as _;
1351    validate_dimensions(req.max_width, req.max_height)?;
1352    let rect = GridRectangle::new(
1353        GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1354        GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1355    );
1356    let opts = RegionOverlayOptions {
1357        rectangles: req.draw_region_rectangles,
1358        names: req.draw_region_names,
1359        coordinates: req.draw_region_coordinates,
1360    };
1361    // Fail fast with a normal HTTP error (before any streaming) when a text
1362    // overlay is requested without a usable font.
1363    assert_region_overlay_font(&state, opts, req.region_label_font_id.as_deref())?;
1364    let font_id = req.region_label_font_id.clone();
1365    let missing_region_color = req
1366        .missing_region_color
1367        .as_deref()
1368        .map(parse_color)
1369        .transpose()?;
1370    let (max_width, max_height) = (req.max_width, req.max_height);
1371
1372    // The worker streams NDJSON lines into this channel; the response body drains
1373    // it. A small buffer is fine — `forward_region_overlay_progress` applies
1374    // backpressure with `.send().await`.
1375    let (line_tx, line_rx) = tokio::sync::mpsc::channel::<Bytes>(64);
1376    drop(tokio::spawn(run_region_overlay_preview_stream(
1377        state,
1378        rect,
1379        opts,
1380        font_id,
1381        missing_region_color,
1382        max_width,
1383        max_height,
1384        line_tx,
1385    )));
1386    let stream = ReceiverStream::new(line_rx).map(Ok::<Bytes, std::convert::Infallible>);
1387    let body = axum::body::Body::from_stream(stream);
1388    Ok((
1389        [(axum::http::header::CONTENT_TYPE, "application/x-ndjson")],
1390        body,
1391    )
1392        .into_response())
1393}
1394
1395/// Worker for [`region_overlay_preview`]: build the overlay (streaming
1396/// region-name progress as NDJSON lines into `line_tx`) and emit a terminal
1397/// `image` (base64 PNG) or `error` line. Best-effort throughout — if the client
1398/// disconnects, the line sends fail and the worker simply stops.
1399#[expect(
1400    clippy::too_many_arguments,
1401    reason = "a one-shot helper carrying the preview request fields into the streaming task"
1402)]
1403async fn run_region_overlay_preview_stream(
1404    state: AppState,
1405    rect: GridRectangle,
1406    opts: RegionOverlayOptions,
1407    font_id: Option<String>,
1408    missing_region_color: Option<Rgba<u8>>,
1409    max_width: u32,
1410    max_height: u32,
1411    line_tx: tokio::sync::mpsc::Sender<Bytes>,
1412) {
1413    let (ev_tx, ev_rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
1414    let forwarder = tokio::spawn(forward_region_overlay_progress(ev_rx, line_tx.clone()));
1415    let result = async {
1416        let mut map = Map::blank_fit(rect, max_width, max_height)?;
1417        apply_region_overlay(
1418            &state,
1419            opts,
1420            font_id.as_deref(),
1421            missing_region_color,
1422            &mut map,
1423            Some(&ev_tx),
1424        )
1425        .await?;
1426        encode_map(&map, OutputFormat::Png)
1427    }
1428    .await;
1429    drop(ev_tx);
1430    let _join = forwarder.await;
1431    let final_line = match result {
1432        Ok(png) => {
1433            use base64::Engine as _;
1434            let data = base64::engine::general_purpose::STANDARD.encode(&png);
1435            serde_json::json!({ "type": "image", "data": data }).to_string()
1436        }
1437        Err(err) => serde_json::to_string(&ProgressDto::Error {
1438            message: err.to_string(),
1439        })
1440        .unwrap_or_else(|_| {
1441            r#"{"type":"error","message":"region overlay preview failed"}"#.to_owned()
1442        }),
1443    };
1444    drop(line_tx.send(Bytes::from(format!("{final_line}\n"))).await);
1445}
1446
1447/// Convert region-name `MapProgressEvent`s into NDJSON lines (one per line) on
1448/// the response channel, reusing [`ProgressDto`] so the wire shape matches the
1449/// render SSE. Stops when the event channel closes or the client disconnects.
1450async fn forward_region_overlay_progress(
1451    mut ev_rx: tokio::sync::mpsc::Receiver<MapProgressEvent>,
1452    line_tx: tokio::sync::mpsc::Sender<Bytes>,
1453) {
1454    while let Some(event) = ev_rx.recv().await {
1455        let Ok(json) = serde_json::to_string(&ProgressDto::from(event)) else {
1456            continue;
1457        };
1458        if line_tx
1459            .send(Bytes::from(format!("{json}\n")))
1460            .await
1461            .is_err()
1462        {
1463            break;
1464        }
1465    }
1466}
1467
1468/// Plan the labels + logos against `occupancy` (an overlay-only map carrying
1469/// the route + GLW shapes) and draw them onto a fresh transparent map of the
1470/// same final-image dimensions, returning the PNG. Used by the placement
1471/// preview endpoints: it runs the very same planning the real render does, so
1472/// the preview shows each label/logo exactly where the final image places it.
1473#[expect(
1474    clippy::too_many_arguments,
1475    reason = "a one-shot helper shared by the two placement-preview endpoints"
1476)]
1477async fn render_placement_elements_png(
1478    state: &AppState,
1479    occupancy: &Map,
1480    target_rect: GridRectangle,
1481    max_width: u32,
1482    max_height: u32,
1483    legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
1484    labels: &[TextLabel],
1485    logos: &[LogoPlacement],
1486) -> Result<Bytes, Error> {
1487    let (label_draws, label_slots) =
1488        plan_labels(&state.fonts, labels, legend_slot, &[], occupancy)?;
1489    let (logo_draws, _) = plan_logos(state, logos, legend_slot, &label_slots, occupancy).await?;
1490    let mut target = Map::blank_fit(target_rect, max_width, max_height)?;
1491    execute_labels(&label_draws, &mut target);
1492    execute_logos(&logo_draws, &mut target);
1493    encode_map(&target, OutputFormat::Png)
1494}
1495
1496/// `POST /api/render/placement-preview/grid-rectangle` — rasterise the text
1497/// labels and logos of an explicit-corner render onto a transparent PNG at the
1498/// final-image resolution, so the client preview can composite them into the
1499/// bounds rectangle exactly where the final render will place them. Free space
1500/// is measured on an overlay-only map (GLW shapes; no route on this path), as
1501/// the real render does. Read-only: nothing is persisted.
1502///
1503/// # Errors
1504///
1505/// Returns an error if the dimensions are invalid, a placement overflows or
1506/// clashes, a font/logo is unknown, or the GLW overlay fails to resolve.
1507pub async fn placement_preview_grid_rectangle(
1508    user: CurrentUser,
1509    State(state): State<AppState>,
1510    Json(req): Json<GridRectangleRequest>,
1511) -> Result<axum::response::Response, Error> {
1512    use axum::response::IntoResponse as _;
1513    validate_dimensions(req.max_width, req.max_height)?;
1514    // Gate read access before compositing — `plan_logos` loads the file by id
1515    // with no authorization (the submit path relies on `validate_logos`, which
1516    // the preview path does not run).
1517    assert_can_read_logos(&state, user.user_id, &req.logos).await?;
1518    let rect = GridRectangle::new(
1519        GridCoordinates::new(req.lower_left_x, req.lower_left_y),
1520        GridCoordinates::new(req.upper_right_x, req.upper_right_y),
1521    );
1522    let mut occ = Map::blank_fit(rect.clone(), req.max_width, req.max_height)?;
1523    let legend_slot = if let Some(glw) = req.glw.as_ref() {
1524        apply_glw_overlay_readonly(&state, user.user_id, glw, &mut occ).await?;
1525        legend_position_from_slot(glw.legend_slot.as_deref())?
1526    } else {
1527        None
1528    };
1529    let png = render_placement_elements_png(
1530        &state,
1531        &occ,
1532        rect,
1533        req.max_width,
1534        req.max_height,
1535        legend_slot,
1536        &req.labels,
1537        &req.logos,
1538    )
1539    .await?;
1540    Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], png).into_response())
1541}
1542
1543/// `POST /api/render/placement-preview/usb-notecard` — same as
1544/// [`placement_preview_grid_rectangle`] but for a notecard render: free space
1545/// is measured on an overlay-only map carrying the route plus the GLW shapes.
1546/// Read-only: the notecard is parsed/loaded but never copied or persisted.
1547///
1548/// # Errors
1549///
1550/// Returns an error if the form is malformed, the notecard cannot be parsed or
1551/// read, a region cannot be resolved, a placement overflows or clashes, or the
1552/// GLW overlay fails to resolve.
1553pub async fn placement_preview_usb_notecard(
1554    user: CurrentUser,
1555    State(state): State<AppState>,
1556    multipart: Multipart,
1557) -> Result<axum::response::Response, Error> {
1558    use axum::response::IntoResponse as _;
1559    let parsed = parse_render_form(multipart).await?;
1560    // Gate read access before compositing — `plan_logos` loads the file by id
1561    // with no authorization (the submit path relies on `validate_logos`, which
1562    // the preview path does not run).
1563    assert_can_read_logos(&state, user.user_id, &parsed.logos).await?;
1564    let notecard: USBNotecard = match &parsed.notecard_source {
1565        NotecardSource::Fresh { text, .. } => text.parse()?,
1566        NotecardSource::Existing { notecard_id } => {
1567            let row =
1568                crate::library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id)
1569                    .await?;
1570            row.body.parse()?
1571        }
1572    };
1573    let (border_north, border_south, border_east, border_west) = parsed.borders;
1574    let rect = {
1575        let mut region = state.region_cache.lock().await;
1576        usb_notecard_to_grid_rectangle(&mut region, &notecard).await?
1577    }
1578    .expanded_west(border_west)
1579    .expanded_east(border_east)
1580    .expanded_south(border_south)
1581    .expanded_north(border_north);
1582    // Same overlay-only occupancy as the real notecard render: GLW shapes
1583    // (legend excluded) then the route.
1584    let mut occ = Map::blank_fit(
1585        rect.clone(),
1586        parsed.common.max_width,
1587        parsed.common.max_height,
1588    )?;
1589    let legend_slot = if let Some(glw) = parsed.glw.as_ref() {
1590        apply_glw_overlay_readonly(&state, user.user_id, glw, &mut occ).await?;
1591        legend_position_from_slot(glw.legend_slot.as_deref())?
1592    } else {
1593        None
1594    };
1595    {
1596        let mut region = state.region_cache.lock().await;
1597        occ.draw_route_with_progress(&mut region, &notecard, parsed.color, None)
1598            .await?;
1599    }
1600    let png = render_placement_elements_png(
1601        &state,
1602        &occ,
1603        rect,
1604        parsed.common.max_width,
1605        parsed.common.max_height,
1606        legend_slot,
1607        &parsed.labels,
1608        &parsed.logos,
1609    )
1610    .await?;
1611    Ok(([(axum::http::header::CONTENT_TYPE, "image/png")], png).into_response())
1612}
1613
1614/// Map a [`OutputFormat`] to its lowercase name used in the persisted
1615/// settings JSON.
1616const fn format_name(format: OutputFormat) -> &'static str {
1617    match format {
1618        OutputFormat::Png => "png",
1619        OutputFormat::Jpeg => "jpeg",
1620    }
1621}
1622
1623/// Format an `Rgba<u8>` as `#rrggbb` (the alpha is dropped because the form
1624/// colour pickers don't expose alpha).
1625fn hex_from_rgba(rgba: Rgba<u8>) -> String {
1626    let Rgba(parts) = rgba;
1627    let r = parts.first().copied().unwrap_or(0);
1628    let g = parts.get(1).copied().unwrap_or(0);
1629    let b = parts.get(2).copied().unwrap_or(0);
1630    format!("#{r:02x}{g:02x}{b:02x}")
1631}
1632
1633/// Parse a hex colour string (e.g. `#ff0000`) into an `image::Rgba<u8>`.
1634fn parse_color(s: &str) -> Result<Rgba<u8>, Error> {
1635    let parsed = hex_color::HexColor::parse(s)
1636        .map_err(|e| Error::BadRequest(format!("invalid colour `{s}`: {e}")))?;
1637    Ok(Rgba(parsed.to_be_bytes()))
1638}
1639
1640/// Parsed multipart form for the USB-notecard render endpoint.
1641struct ParsedRenderForm {
1642    /// the notecard source: an existing saved notecard id, or a freshly
1643    /// uploaded one (text + optional name + destination).
1644    notecard_source: NotecardSource,
1645    /// the destination for the *render*.
1646    destination: Destination,
1647    /// per-side borders in regions: north, south, east, west.
1648    borders: (u16, u16, u16, u16),
1649    /// route colour.
1650    color: Rgba<u8>,
1651    /// shared output parameters.
1652    common: CommonParams,
1653    /// whether to also save the without-route variant.
1654    with_without_route: bool,
1655    /// optional GLW overlay. Carried in the multipart form as the
1656    /// `glw_json` field — a JSON-stringified [`GlwRenderOptions`] — so
1657    /// the browser doesn't have to send nested objects through
1658    /// `FormData`.
1659    glw: Option<GlwRenderOptions>,
1660    /// free-floating text labels. Carried in the multipart form as the
1661    /// `labels_json` field — a JSON-stringified `Vec<TextLabel>`.
1662    labels: Vec<TextLabel>,
1663    /// logo images. Carried in the multipart form as the `logos_json`
1664    /// field — a JSON-stringified `Vec<LogoPlacement>`.
1665    logos: Vec<LogoPlacement>,
1666    /// user-defined combined slot groups (each a list of slot-anchor names),
1667    /// carried as the `groups_json` field. Only the placement-slots endpoint
1668    /// uses them (to report each group's combined free rectangle).
1669    groups: Vec<Vec<String>>,
1670}
1671
1672/// Where the notecard for a render comes from.
1673enum NotecardSource {
1674    /// Reuse a saved notecard by id.
1675    Existing {
1676        /// the saved notecard's id.
1677        notecard_id: Uuid,
1678    },
1679    /// Use a freshly uploaded notecard; the resolver saves it to the
1680    /// render's destination before linking, so the
1681    /// "render and notecard share the same scope" invariant always holds.
1682    Fresh {
1683        /// the raw notecard text.
1684        text: String,
1685        /// the display name for the saved-notecards row.
1686        name: String,
1687    },
1688}
1689
1690/// Parse the multipart form for the USB-notecard render endpoint.
1691async fn parse_render_form(multipart: Multipart) -> Result<ParsedRenderForm, Error> {
1692    let mut form = crate::usb_notecard::NotecardForm::default();
1693    let mut color: Rgba<u8> = Rgba([0xff, 0x00, 0x00, 0xff]);
1694    let mut max_width: Option<u32> = None;
1695    let mut max_height: Option<u32> = None;
1696    let mut missing_map_tile_color: Option<Rgba<u8>> = None;
1697    let mut missing_region_color: Option<Rgba<u8>> = None;
1698    let mut format = OutputFormat::default();
1699    let mut with_without_route = false;
1700    let mut draw_region_rectangles = false;
1701    let mut draw_region_names = false;
1702    let mut draw_region_coordinates = false;
1703    let mut region_label_font_id: Option<String> = None;
1704    let mut notecard_text: Option<String> = None;
1705    let mut notecard_file: Option<String> = None;
1706    let mut notecard_id: Option<Uuid> = None;
1707    let mut destination_raw: Option<String> = None;
1708    let mut notecard_name: Option<String> = None;
1709    let mut glw: Option<GlwRenderOptions> = None;
1710    let mut labels: Vec<TextLabel> = Vec::new();
1711    let mut logos: Vec<LogoPlacement> = Vec::new();
1712    let mut groups: Vec<Vec<String>> = Vec::new();
1713    let mut multipart = multipart;
1714    while let Some(field) = multipart.next_field().await? {
1715        let Some(name) = field.name().map(str::to_owned) else {
1716            continue;
1717        };
1718        match name.as_str() {
1719            "notecard" => {
1720                let bytes = field.bytes().await?;
1721                if !bytes.is_empty() {
1722                    let text = String::from_utf8(bytes.to_vec())
1723                        .map_err(|e| Error::BadRequest(format!("notecard is not UTF-8: {e}")))?;
1724                    notecard_file = Some(text);
1725                }
1726            }
1727            "notecard_text" => {
1728                let text = field.text().await?;
1729                if !text.trim().is_empty() {
1730                    notecard_text = Some(text);
1731                }
1732            }
1733            "notecard_id" => {
1734                let raw = field.text().await?;
1735                let trimmed = raw.trim();
1736                if !trimmed.is_empty() {
1737                    notecard_id = Some(
1738                        Uuid::parse_str(trimmed)
1739                            .map_err(|e| Error::BadRequest(format!("invalid notecard_id: {e}")))?,
1740                    );
1741                }
1742            }
1743            "border_regions" => form.border_regions = parse_optional_u16(&field.text().await?)?,
1744            "border_north" => form.border_north = parse_optional_u16(&field.text().await?)?,
1745            "border_south" => form.border_south = parse_optional_u16(&field.text().await?)?,
1746            "border_east" => form.border_east = parse_optional_u16(&field.text().await?)?,
1747            "border_west" => form.border_west = parse_optional_u16(&field.text().await?)?,
1748            "color" => {
1749                let raw = field.text().await?;
1750                color = parse_color(raw.trim())?;
1751            }
1752            "max_width" => max_width = Some(parse_u32(&field.text().await?)?),
1753            "max_height" => max_height = Some(parse_u32(&field.text().await?)?),
1754            "missing_map_tile_color" => {
1755                let raw = field.text().await?;
1756                let trimmed = raw.trim();
1757                if !trimmed.is_empty() {
1758                    missing_map_tile_color = Some(parse_color(trimmed)?);
1759                }
1760            }
1761            "missing_region_color" => {
1762                let raw = field.text().await?;
1763                let trimmed = raw.trim();
1764                if !trimmed.is_empty() {
1765                    missing_region_color = Some(parse_color(trimmed)?);
1766                }
1767            }
1768            "format" => {
1769                let raw = field.text().await?;
1770                format = match raw.trim().to_ascii_lowercase().as_str() {
1771                    "png" => OutputFormat::Png,
1772                    "jpeg" | "jpg" => OutputFormat::Jpeg,
1773                    other => {
1774                        return Err(Error::BadRequest(format!("unknown format `{other}`")));
1775                    }
1776                };
1777            }
1778            "save_without_route" => {
1779                let raw = field.text().await?;
1780                with_without_route = parse_form_bool(&raw);
1781            }
1782            "draw_region_rectangles" => {
1783                let raw = field.text().await?;
1784                draw_region_rectangles = parse_form_bool(&raw);
1785            }
1786            "draw_region_names" => {
1787                let raw = field.text().await?;
1788                draw_region_names = parse_form_bool(&raw);
1789            }
1790            "draw_region_coordinates" => {
1791                let raw = field.text().await?;
1792                draw_region_coordinates = parse_form_bool(&raw);
1793            }
1794            "region_label_font_id" => {
1795                let raw = field.text().await?;
1796                let trimmed = raw.trim();
1797                if !trimmed.is_empty() {
1798                    region_label_font_id = Some(trimmed.to_owned());
1799                }
1800            }
1801            "save_to" => destination_raw = Some(field.text().await?),
1802            "notecard_name" => {
1803                let t = field.text().await?;
1804                if !t.trim().is_empty() {
1805                    notecard_name = Some(t);
1806                }
1807            }
1808            "glw_json" => {
1809                let raw = field.text().await?;
1810                if !raw.trim().is_empty() {
1811                    glw = Some(
1812                        serde_json::from_str::<GlwRenderOptions>(&raw)
1813                            .map_err(|e| Error::BadRequest(format!("invalid glw_json: {e}")))?,
1814                    );
1815                }
1816            }
1817            "labels_json" => {
1818                let raw = field.text().await?;
1819                if !raw.trim().is_empty() {
1820                    labels = serde_json::from_str::<Vec<TextLabel>>(&raw)
1821                        .map_err(|e| Error::BadRequest(format!("invalid labels_json: {e}")))?;
1822                }
1823            }
1824            "logos_json" => {
1825                let raw = field.text().await?;
1826                if !raw.trim().is_empty() {
1827                    logos = serde_json::from_str::<Vec<LogoPlacement>>(&raw)
1828                        .map_err(|e| Error::BadRequest(format!("invalid logos_json: {e}")))?;
1829                }
1830            }
1831            "groups_json" => {
1832                let raw = field.text().await?;
1833                if !raw.trim().is_empty() {
1834                    groups = serde_json::from_str::<Vec<Vec<String>>>(&raw)
1835                        .map_err(|e| Error::BadRequest(format!("invalid groups_json: {e}")))?;
1836                }
1837            }
1838            _ => {}
1839        }
1840    }
1841    let destination = Destination::parse(destination_raw.as_deref().unwrap_or("personal"))?;
1842    let max_width = max_width.ok_or_else(|| Error::BadRequest("missing max_width".to_owned()))?;
1843    let max_height =
1844        max_height.ok_or_else(|| Error::BadRequest("missing max_height".to_owned()))?;
1845    validate_dimensions(max_width, max_height)?;
1846    let borders = form.borders();
1847    let common = CommonParams {
1848        max_width,
1849        max_height,
1850        missing_map_tile_color,
1851        missing_region_color,
1852        format,
1853        region_overlay: RegionOverlayOptions {
1854            rectangles: draw_region_rectangles,
1855            names: draw_region_names,
1856            coordinates: draw_region_coordinates,
1857        },
1858        region_label_font_id,
1859    };
1860    let notecard_source = if let Some(id) = notecard_id {
1861        if notecard_file.is_some() || notecard_text.is_some() {
1862            return Err(Error::BadRequest(
1863                "supply either `notecard_id` or notecard text/file, not both".to_owned(),
1864            ));
1865        }
1866        NotecardSource::Existing { notecard_id: id }
1867    } else {
1868        let raw = notecard_file.or(notecard_text).ok_or_else(|| {
1869            Error::BadRequest(
1870                "supply `notecard_id`, `notecard` file upload, or `notecard_text`".to_owned(),
1871            )
1872        })?;
1873        let name = notecard_name.map_or_else(default_notecard_name, |n| n.trim().to_owned());
1874        NotecardSource::Fresh { text: raw, name }
1875    };
1876    Ok(ParsedRenderForm {
1877        notecard_source,
1878        destination,
1879        borders,
1880        color,
1881        common,
1882        with_without_route,
1883        glw,
1884        labels,
1885        logos,
1886        groups,
1887    })
1888}
1889
1890/// Compose a fallback display name for an unnamed notecard.
1891fn default_notecard_name() -> String {
1892    let now = Utc::now();
1893    format!("Uploaded {}", now.format("%Y-%m-%d %H:%M:%S UTC"))
1894}
1895
1896/// Resolve the notecard source for a USB-notecard render. Returns the parsed
1897/// `USBNotecard` (for the renderer) and a [`NotecardSummary`] describing
1898/// the saved row the render will link to. The render's destination is
1899/// canonical: a reused notecard in a different scope is copied into the
1900/// render's scope, and a freshly uploaded notecard is always saved into
1901/// the render's scope. Both behaviours keep the DB-level "render and
1902/// notecard share the same scope" trigger satisfied without ever
1903/// surfacing a constraint error to the caller.
1904async fn resolve_notecard(
1905    state: &AppState,
1906    user: &CurrentUser,
1907    parsed: &ParsedRenderForm,
1908) -> Result<(USBNotecard, NotecardSummary), Error> {
1909    match &parsed.notecard_source {
1910        NotecardSource::Existing { notecard_id } => {
1911            let row =
1912                library::assert_can_read_notecard(&state.db, user.user_id, *notecard_id).await?;
1913            let parsed_notecard: USBNotecard = row.body.parse()?;
1914            let notecard_scope = library::destination_from_columns(
1915                row.owner_user_id.clone(),
1916                row.owner_group_id.clone(),
1917            )?;
1918            let effective_id = if notecard_scope == parsed.destination {
1919                *notecard_id
1920            } else {
1921                notecard_routes::insert_notecard_row(
1922                    state,
1923                    parsed.destination,
1924                    user.user_id,
1925                    &row.name,
1926                    &row.body,
1927                )
1928                .await?
1929            };
1930            Ok((
1931                parsed_notecard,
1932                NotecardSummary {
1933                    notecard_id: effective_id,
1934                    name: row.name,
1935                    scope: parsed.destination.render_string(),
1936                },
1937            ))
1938        }
1939        NotecardSource::Fresh { text, name, .. } => {
1940            let parsed_notecard: USBNotecard = text.parse()?;
1941            let notecard_id = notecard_routes::insert_notecard_row(
1942                state,
1943                parsed.destination,
1944                user.user_id,
1945                name,
1946                text,
1947            )
1948            .await?;
1949            Ok((
1950                parsed_notecard,
1951                NotecardSummary {
1952                    notecard_id,
1953                    name: name.clone(),
1954                    scope: parsed.destination.render_string(),
1955                },
1956            ))
1957        }
1958    }
1959}
1960
1961/// Insert a `saved_renders` row with `status='in_progress'` and the supplied
1962/// settings JSON. `bounds` is `Some` for grid-rectangle renders (the corner
1963/// coordinates are known at submit time) and `None` for usb-notecard
1964/// renders, which compute the rectangle in the background and call
1965/// [`update_render_bounds`] once it is known.
1966#[expect(
1967    clippy::too_many_arguments,
1968    reason = "every argument is a distinct row column; bundling them into a struct would just be noise at the single call sites"
1969)]
1970async fn insert_render_row(
1971    state: &AppState,
1972    render_id: Uuid,
1973    destination: Destination,
1974    created_by: Uuid,
1975    kind: &str,
1976    notecard_id: Option<Uuid>,
1977    settings: &SavedRenderSettings,
1978    bounds: Option<&GridRectangle>,
1979) -> Result<(), Error> {
1980    let (owner_user, owner_group) = match destination {
1981        Destination::Personal => (Some(created_by.as_bytes().to_vec()), None),
1982        Destination::Group { group_id } => (None, Some(group_id.as_bytes().to_vec())),
1983    };
1984    let now = Utc::now();
1985    let settings_json = serde_json::to_string(settings)?;
1986    let (ll_x, ll_y, ur_x, ur_y) = match bounds {
1987        Some(rect) => (
1988            Some(i64::from(rect.lower_left_corner().x())),
1989            Some(i64::from(rect.lower_left_corner().y())),
1990            Some(i64::from(rect.upper_right_corner().x())),
1991            Some(i64::from(rect.upper_right_corner().y())),
1992        ),
1993        None => (None, None, None, None),
1994    };
1995    sqlx::query(
1996        "INSERT INTO saved_renders \
1997            (render_id, owner_user_id, owner_group_id, created_by, notecard_id, kind, \
1998             status, settings_json, created_at, \
1999             lower_left_x, lower_left_y, upper_right_x, upper_right_y) \
2000         VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'in_progress', ?7, ?8, ?9, ?10, ?11, ?12)",
2001    )
2002    .bind(render_id.as_bytes().to_vec())
2003    .bind(owner_user)
2004    .bind(owner_group)
2005    .bind(created_by.as_bytes().to_vec())
2006    .bind(notecard_id.map(|id| id.as_bytes().to_vec()))
2007    .bind(kind)
2008    .bind(settings_json)
2009    .bind(now)
2010    .bind(ll_x)
2011    .bind(ll_y)
2012    .bind(ur_x)
2013    .bind(ur_y)
2014    .execute(&state.db)
2015    .await
2016    .map_err(|err| {
2017        tracing::error!("insert saved_renders failed: {err}");
2018        Error::Database
2019    })?;
2020    Ok(())
2021}
2022
2023/// Backfill the bare-route rectangle bounds on a `saved_notecards` row.
2024/// The bounds are the bounding box of the notecard's waypoints, *without*
2025/// the border padding a particular render may have applied — so a single
2026/// notecard rendered with different borders still has stable cached
2027/// bounds. The columns are overwritten on every render rather than
2028/// checked-then-written: the bounding rectangle of a given notecard body
2029/// is fixed, so the only way the value differs across runs is if region-
2030/// name resolution returned a different answer (in which case the most
2031/// recent one is the most useful).
2032async fn update_notecard_bounds(
2033    db: &sqlx::SqlitePool,
2034    notecard_id: Uuid,
2035    rect: &GridRectangle,
2036) -> Result<(), Error> {
2037    sqlx::query(
2038        "UPDATE saved_notecards \
2039         SET lower_left_x = ?1, lower_left_y = ?2, \
2040             upper_right_x = ?3, upper_right_y = ?4 \
2041         WHERE notecard_id = ?5",
2042    )
2043    .bind(i64::from(rect.lower_left_corner().x()))
2044    .bind(i64::from(rect.lower_left_corner().y()))
2045    .bind(i64::from(rect.upper_right_corner().x()))
2046    .bind(i64::from(rect.upper_right_corner().y()))
2047    .bind(notecard_id.as_bytes().to_vec())
2048    .execute(db)
2049    .await
2050    .map_err(|err| {
2051        tracing::error!("update saved_notecards bounds failed: {err}");
2052        Error::Database
2053    })?;
2054    Ok(())
2055}
2056
2057/// Backfill the rectangle bounds on a `saved_renders` row. Used by the
2058/// usb-notecard path: the rect is computed inside the background job once
2059/// the notecard has been parsed and any region names resolved, at which
2060/// point we update the row so the library UI can show the bounds even
2061/// while the render is still `in_progress`.
2062async fn update_render_bounds(
2063    db: &sqlx::SqlitePool,
2064    render_id: Uuid,
2065    rect: &GridRectangle,
2066) -> Result<(), Error> {
2067    sqlx::query(
2068        "UPDATE saved_renders \
2069         SET lower_left_x = ?1, lower_left_y = ?2, \
2070             upper_right_x = ?3, upper_right_y = ?4 \
2071         WHERE render_id = ?5",
2072    )
2073    .bind(i64::from(rect.lower_left_corner().x()))
2074    .bind(i64::from(rect.lower_left_corner().y()))
2075    .bind(i64::from(rect.upper_right_corner().x()))
2076    .bind(i64::from(rect.upper_right_corner().y()))
2077    .bind(render_id.as_bytes().to_vec())
2078    .execute(db)
2079    .await
2080    .map_err(|err| {
2081        tracing::error!("update saved_renders bounds failed: {err}");
2082        Error::Database
2083    })?;
2084    Ok(())
2085}
2086
2087/// Update a `saved_renders` row to its terminal state. On `Ok`, writes the
2088/// image files to disk and updates metadata + filenames. On `Err`, records
2089/// the error message.
2090async fn finalize_render_row(state: &AppState, render_id: Uuid, outcome: &JobOutcome) {
2091    let now = Utc::now();
2092    match outcome {
2093        JobOutcome::Ok {
2094            image,
2095            image_without_route,
2096            content_type,
2097            metadata,
2098            glw_data_id,
2099        } => {
2100            let ext = storage::ext_for_content_type(content_type);
2101            let image_filename = match storage::write_render_file(
2102                &state.config.storage_dir,
2103                render_id,
2104                storage::IMAGE_SUFFIX,
2105                ext,
2106                image.clone(),
2107            )
2108            .await
2109            {
2110                Ok(f) => Some(f),
2111                Err(err) => {
2112                    tracing::error!("write primary render image failed: {err}");
2113                    update_failed(state, render_id, &format!("write image failed: {err}"), now)
2114                        .await;
2115                    return;
2116                }
2117            };
2118            let without_filename = if let Some(bytes) = image_without_route {
2119                match storage::write_render_file(
2120                    &state.config.storage_dir,
2121                    render_id,
2122                    storage::IMAGE_WITHOUT_ROUTE_SUFFIX,
2123                    ext,
2124                    bytes.clone(),
2125                )
2126                .await
2127                {
2128                    Ok(f) => Some(f),
2129                    Err(err) => {
2130                        tracing::warn!("write without-route render image failed: {err}");
2131                        None
2132                    }
2133                }
2134            } else {
2135                None
2136            };
2137            let metadata_json = match serde_json::to_string(metadata) {
2138                Ok(s) => s,
2139                Err(err) => {
2140                    tracing::error!("serialize metadata failed: {err}");
2141                    update_failed(
2142                        state,
2143                        render_id,
2144                        &format!("serialize metadata failed: {err}"),
2145                        now,
2146                    )
2147                    .await;
2148                    return;
2149                }
2150            };
2151            // If a GLW overlay was drawn, the worker has both an id to
2152            // link onto saved_renders and a freshly-stable `SavedId`
2153            // settings_json to record. Compute the rewritten settings
2154            // here (close to the DB write) so the worker stays focused
2155            // on rendering.
2156            let rewritten_settings_json = match glw_data_id {
2157                Some(id) => match rewrite_settings_json_with_saved_glw(state, render_id, *id).await
2158                {
2159                    Ok(updated) => updated,
2160                    Err(err) => {
2161                        tracing::error!("could not rewrite settings_json with glw_data_id: {err}");
2162                        None
2163                    }
2164                },
2165                None => None,
2166            };
2167            let result = sqlx::query(
2168                "UPDATE saved_renders SET status = 'done', finished_at = ?1, \
2169                    metadata_json = ?2, content_type = ?3, \
2170                    image_filename = ?4, image_without_route_filename = ?5, \
2171                    glw_data_id = COALESCE(?7, glw_data_id), \
2172                    settings_json = COALESCE(?8, settings_json) \
2173                 WHERE render_id = ?6",
2174            )
2175            .bind(now)
2176            .bind(metadata_json)
2177            .bind(*content_type)
2178            .bind(image_filename)
2179            .bind(without_filename)
2180            .bind(render_id.as_bytes().to_vec())
2181            .bind(glw_data_id.map(|id| id.as_bytes().to_vec()))
2182            .bind(rewritten_settings_json)
2183            .execute(&state.db)
2184            .await;
2185            if let Err(err) = result {
2186                tracing::error!("update saved_renders to done failed: {err}");
2187                state.library_cleanup_dirty.store(true, Ordering::Release);
2188            }
2189        }
2190        JobOutcome::Err(msg) => {
2191            update_failed(state, render_id, msg, now).await;
2192        }
2193    }
2194}
2195
2196/// Update a render row to `failed` with the supplied error message.
2197async fn update_failed(
2198    state: &AppState,
2199    render_id: Uuid,
2200    message: &str,
2201    now: chrono::DateTime<Utc>,
2202) {
2203    if let Err(err) = sqlx::query(
2204        "UPDATE saved_renders SET status = 'failed', finished_at = ?1, error_message = ?2 \
2205         WHERE render_id = ?3",
2206    )
2207    .bind(now)
2208    .bind(message)
2209    .bind(render_id.as_bytes().to_vec())
2210    .execute(&state.db)
2211    .await
2212    {
2213        tracing::error!("update saved_renders to failed failed: {err}");
2214    }
2215}
2216
2217/// Spawn the background task that renders a grid rectangle.
2218#[expect(
2219    clippy::too_many_arguments,
2220    reason = "this is a one-shot helper that wires together every form field"
2221)]
2222fn spawn_grid_rectangle_job(
2223    state: AppState,
2224    job_id: JobId,
2225    job: Arc<JobState>,
2226    rect: GridRectangle,
2227    common: CommonParams,
2228    glw_ctx: Option<GlwJobCtx>,
2229    labels: Vec<TextLabel>,
2230    logos: Vec<LogoPlacement>,
2231) {
2232    drop(tokio::spawn(async move {
2233        let result = run_grid_rectangle_job(
2234            state.clone(),
2235            Arc::clone(&job),
2236            rect,
2237            common,
2238            glw_ctx,
2239            labels,
2240            logos,
2241        )
2242        .await;
2243        let outcome = finish_job(&job, result).await;
2244        finalize_render_row(&state, job_id, &outcome).await;
2245        tracing::info!("render job {job_id} finished");
2246    }));
2247}
2248
2249/// Spawn the background task that renders from a USB notecard.
2250#[expect(
2251    clippy::too_many_arguments,
2252    reason = "this is a one-shot helper that wires together every form field"
2253)]
2254fn spawn_usb_notecard_job(
2255    state: AppState,
2256    job_id: JobId,
2257    job: Arc<JobState>,
2258    notecard_id: Uuid,
2259    notecard: USBNotecard,
2260    borders: (u16, u16, u16, u16),
2261    route_color: Rgba<u8>,
2262    common: CommonParams,
2263    with_without_route: bool,
2264    glw_ctx: Option<GlwJobCtx>,
2265    labels: Vec<TextLabel>,
2266    logos: Vec<LogoPlacement>,
2267) {
2268    drop(tokio::spawn(async move {
2269        let result = run_usb_notecard_job(
2270            state.clone(),
2271            Arc::clone(&job),
2272            job_id,
2273            notecard_id,
2274            notecard,
2275            borders,
2276            route_color,
2277            common,
2278            with_without_route,
2279            glw_ctx,
2280            labels,
2281            logos,
2282        )
2283        .await;
2284        let outcome = finish_job(&job, result).await;
2285        finalize_render_row(&state, job_id, &outcome).await;
2286        tracing::info!("render job {job_id} finished");
2287    }));
2288}
2289
2290// =====================================================================
2291// Per-region annotation overlay — rectangles, names, grid coordinates.
2292// Shared between the two render workers and the preview endpoint.
2293// =====================================================================
2294
2295/// Reject a render up front when a per-region text overlay (names or
2296/// coordinates) is requested but no usable font is available, so the submit
2297/// endpoints fail with a clear `400` instead of the background job dying. A
2298/// no-text request (rectangles only, or nothing) needs no font and always
2299/// passes.
2300fn assert_region_overlay_font(
2301    state: &AppState,
2302    opts: RegionOverlayOptions,
2303    font_id: Option<&str>,
2304) -> Result<(), Error> {
2305    if !opts.any_text() {
2306        return Ok(());
2307    }
2308    let font_id = font_id.ok_or_else(|| {
2309        Error::BadRequest("select a font for the region name / coordinate overlay".to_owned())
2310    })?;
2311    if state.fonts.path_for(font_id).is_none() {
2312        return Err(Error::BadRequest(format!(
2313            "unknown region label font_id `{font_id}`"
2314        )));
2315    }
2316    Ok(())
2317}
2318
2319/// Whether the per-region name / coordinate text overlay should be drawn for a
2320/// render whose regions render at `pixels_per_region` pixels each and which
2321/// covers `region_count` regions. Both gates must pass: the text only fits
2322/// (and only reads) above a minimum per-region size, and resolving a name per
2323/// region only stays affordable below a maximum region count.
2324const fn region_text_overlay_allowed(pixels_per_region: f32, region_count: usize) -> bool {
2325    pixels_per_region >= MIN_PIXELS_PER_REGION_FOR_REGION_LABELS
2326        && region_count <= MAX_REGIONS_FOR_REGION_LABELS
2327}
2328
2329/// Draw the requested per-region annotations onto `map`.
2330///
2331/// The rectangle outline is cheap and always drawn when requested. The name
2332/// and coordinate text is gated twice — both must hold or the text is skipped
2333/// (rectangles still draw): each region must render at least
2334/// [`MIN_PIXELS_PER_REGION_FOR_REGION_LABELS`] pixels (otherwise the text does
2335/// not fit), and the render must cover at most [`MAX_REGIONS_FOR_REGION_LABELS`]
2336/// regions (otherwise resolving every region name fans out into too many
2337/// upstream lookups). When both name and coordinate overlays are on, the
2338/// coordinates are stacked above the name.
2339async fn apply_region_overlay(
2340    state: &AppState,
2341    opts: RegionOverlayOptions,
2342    font_id: Option<&str>,
2343    missing_region_color: Option<Rgba<u8>>,
2344    map: &mut Map,
2345    progress: Option<&tokio::sync::mpsc::Sender<MapProgressEvent>>,
2346) -> Result<(), Error> {
2347    use sl_map_apis::map_tiles::MapLike as _;
2348    use sl_types::map::GridRectangleLike as _;
2349    if !opts.any() {
2350        return Ok(());
2351    }
2352    // The text overlays (names / coordinates) are gated twice — too-small regions
2353    // can't hold the text, and too-many regions would fan name resolution out
2354    // into thousands of lookups. When the gate fails the per-region loop is
2355    // skipped entirely; rectangles (which need no loop) are still drawn below.
2356    let pixels_per_region = map.pixels_per_region();
2357    let region_count =
2358        usize::try_from(u64::from(map.size_x()).saturating_mul(u64::from(map.size_y())))
2359            .unwrap_or(usize::MAX);
2360    let run_loop = opts.any_text() && region_text_overlay_allowed(pixels_per_region, region_count);
2361    if !run_loop {
2362        if opts.any_text() {
2363            tracing::info!(
2364                pixels_per_region,
2365                region_count,
2366                "skipping region name/coordinate overlay (regions too small or too many)"
2367            );
2368        }
2369        // No per-region loop, so draw the outlines on their own.
2370        if opts.rectangles {
2371            draw_region_rectangles(map);
2372        }
2373        return Ok(());
2374    }
2375    let font_id = font_id.ok_or_else(|| {
2376        Error::BadRequest("select a font for the region name / coordinate overlay".to_owned())
2377    })?;
2378    let font_path = state
2379        .fonts
2380        .path_for(font_id)
2381        .ok_or_else(|| Error::BadRequest(format!("unknown region label font_id `{font_id}`")))?;
2382    let font = sl_map_apis::text::load_font(font_path)?;
2383    let scale = ab_glyph::PxScale::from(
2384        (pixels_per_region * REGION_LABEL_FONT_FACTOR)
2385            .clamp(REGION_LABEL_FONT_MIN_PX, REGION_LABEL_FONT_MAX_PX),
2386    );
2387    let style = sl_map_apis::text::LabelStyle {
2388        scale,
2389        fg: Rgba([255, 255, 255, 255]),
2390        shadow: Rgba([0, 0, 0, 180]),
2391        align: sl_map_apis::coverage::HAlign::Left,
2392    };
2393    // Resolving a name is a (cached but possibly cold) upstream lookup per
2394    // region, so report it as progress — but only when names are actually being
2395    // looked up (coordinates / rectangles are computed locally and need none).
2396    let total_regions = u32::try_from(region_count).unwrap_or(u32::MAX);
2397    if let Some(tx) = progress.filter(|_| opts.names) {
2398        drop(tx.try_send(MapProgressEvent::RegionNamesPlanned { total_regions }));
2399    }
2400    let mut resolved: u32 = 0;
2401    for x in map.x_range() {
2402        for y in map.y_range() {
2403            let grid = GridCoordinates::new(x, y);
2404            let Some((left, top, width, height)) = region_pixel_rect(map, &grid) else {
2405                continue;
2406            };
2407            // Resolve the name (when enabled). The result also tells us whether
2408            // the region exists: `Ok(None)` is a confirmed-missing region, which
2409            // we paint with the missing-region colour when that is enabled.
2410            // `Err` is an inconclusive lookup — leave it untouched.
2411            let mut region_name: Option<String> = None;
2412            let mut missing = false;
2413            if opts.names {
2414                let lookup = {
2415                    let mut region = state.region_cache.lock().await;
2416                    region.get_region_name(&grid).await
2417                };
2418                match lookup {
2419                    Ok(Some(name)) => region_name = Some(name.to_string()),
2420                    Ok(None) => missing = true,
2421                    Err(err) => {
2422                        tracing::debug!("region name lookup failed for {grid:?}: {err}");
2423                    }
2424                }
2425                if let Some(tx) = progress {
2426                    drop(tx.try_send(MapProgressEvent::RegionNameResolved {
2427                        index: resolved,
2428                        total: total_regions,
2429                    }));
2430                }
2431                resolved = resolved.saturating_add(1);
2432            }
2433            // Layer per region, bottom-up: missing-region fill, then the outline,
2434            // then the text — so each sits above the previous.
2435            if let Some(color) = missing_region_color.filter(|_| missing) {
2436                map.draw_filled_rect(left, top, width, height, color);
2437            }
2438            if opts.rectangles {
2439                map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
2440            }
2441            let mut lines: Vec<String> = Vec::new();
2442            if opts.coordinates {
2443                lines.push(format!("({x}, {y})"));
2444            }
2445            if let Some(name) = region_name {
2446                lines.push(name);
2447            }
2448            if lines.is_empty() {
2449                continue;
2450            }
2451            let (_text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
2452            // SL y points up while image y points down, so the region's bottom
2453            // edge (the text anchor) is `top + height`.
2454            let bottom = top.saturating_add(height);
2455            let origin_x = i32::try_from(left)
2456                .unwrap_or(0)
2457                .saturating_add(REGION_LABEL_PADDING);
2458            let origin_y = i32::try_from(bottom)
2459                .unwrap_or(0)
2460                .saturating_sub(REGION_LABEL_PADDING)
2461                .saturating_sub(i32::try_from(text_h).unwrap_or(0));
2462            map.draw_text_label((origin_x, origin_y), &lines, &style, &font);
2463        }
2464    }
2465    Ok(())
2466}
2467
2468/// The pixel rectangle `(left, top, width, height)` a region occupies in `map`,
2469/// or `None` if the region is outside it. The two opposite corners are mapped to
2470/// pixels (same pattern as `crop_imm_grid_rectangle`) so no floating-point
2471/// pixel-per-region rounding is needed.
2472fn region_pixel_rect(map: &Map, grid: &GridCoordinates) -> Option<(u32, u32, u32, u32)> {
2473    use sl_map_apis::map_tiles::MapLike as _;
2474    let (x0, y0) =
2475        map.pixel_coordinates_for_coordinates(grid, &RegionCoordinates::new(0f32, 0f32, 0f32))?;
2476    let (x1, y1) =
2477        map.pixel_coordinates_for_coordinates(grid, &RegionCoordinates::new(256f32, 256f32, 0f32))?;
2478    Some((x0.min(x1), y0.min(y1), x0.abs_diff(x1), y0.abs_diff(y1)))
2479}
2480
2481/// Draw a hairline outline around every region of `map`.
2482fn draw_region_rectangles(map: &mut Map) {
2483    use sl_map_apis::map_tiles::MapLike as _;
2484    use sl_types::map::GridRectangleLike as _;
2485    for x in map.x_range() {
2486        for y in map.y_range() {
2487            let grid = GridCoordinates::new(x, y);
2488            if let Some((left, top, width, height)) = region_pixel_rect(map, &grid) {
2489                map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
2490            }
2491        }
2492    }
2493}
2494
2495/// Run the grid-rectangle render to completion.
2496async fn run_grid_rectangle_job(
2497    state: AppState,
2498    job: Arc<JobState>,
2499    rect: GridRectangle,
2500    common: CommonParams,
2501    glw_ctx: Option<GlwJobCtx>,
2502    labels: Vec<TextLabel>,
2503    logos: Vec<LogoPlacement>,
2504) -> Result<JobOutcome, Error> {
2505    let metadata = build_metadata(&rect);
2506    // Kept for the overlay-only occupancy map below; `new_with_progress` moves
2507    // `rect` into the tiled map.
2508    let occ_rect = rect.clone();
2509    let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
2510    let forwarder = tokio::spawn(forward_events(Arc::clone(&job), rx));
2511    let mut map = {
2512        let mut cache = state.map_tile_cache.lock().await;
2513        Map::new_with_progress(
2514            &mut cache,
2515            common.max_width,
2516            common.max_height,
2517            rect,
2518            common.missing_map_tile_color,
2519            common.missing_region_color,
2520            Some(&tx),
2521        )
2522        .await?
2523    };
2524    // Layering: base map below, then the per-region annotation overlay, then
2525    // the GLW overlay. No route for the grid-rectangle path. Labels go last,
2526    // above everything. The overlay resolves region names (when enabled), so it
2527    // keeps reporting progress on `tx` — drop the sender and join the forwarder
2528    // only after it returns.
2529    apply_region_overlay(
2530        &state,
2531        common.region_overlay,
2532        common.region_label_font_id.as_deref(),
2533        // The final render fills missing regions properly during tile building
2534        // (`Map::new_with_progress`), so the overlay never paints them here.
2535        None,
2536        &mut map,
2537        Some(&tx),
2538    )
2539    .await?;
2540    drop(tx);
2541    // wait for the forwarder so the event history is complete before we
2542    // signal completion to subscribers
2543    let _join = forwarder.await;
2544    let glw_data_id = apply_glw_overlay_to_map(&state, glw_ctx.as_ref(), &mut map).await?;
2545    // Free space for labels/logos is measured on an overlay-only map (GLW shapes
2546    // only, no route on the grid path); the same planning rejected an over-full
2547    // slot at submit time, so this normally cannot fail here.
2548    let (label_draws, logo_draws) = plan_placements(
2549        &state,
2550        occ_rect,
2551        &common,
2552        glw_ctx.as_ref(),
2553        None,
2554        &labels,
2555        &logos,
2556    )
2557    .await?;
2558    execute_labels(&label_draws, &mut map);
2559    execute_logos(&logo_draws, &mut map);
2560    let image = encode_map(&map, common.format)?;
2561    Ok(JobOutcome::Ok {
2562        image,
2563        image_without_route: None,
2564        content_type: common.format.content_type(),
2565        metadata,
2566        glw_data_id,
2567    })
2568}
2569
2570/// Run the USB-notecard render to completion.
2571#[expect(
2572    clippy::too_many_arguments,
2573    reason = "this is a one-shot helper invoked from a single spawn site"
2574)]
2575async fn run_usb_notecard_job(
2576    state: AppState,
2577    job: Arc<JobState>,
2578    render_id: Uuid,
2579    notecard_id: Uuid,
2580    notecard: USBNotecard,
2581    borders: (u16, u16, u16, u16),
2582    route_color: Rgba<u8>,
2583    common: CommonParams,
2584    with_without_route: bool,
2585    glw_ctx: Option<GlwJobCtx>,
2586    labels: Vec<TextLabel>,
2587    logos: Vec<LogoPlacement>,
2588) -> Result<JobOutcome, Error> {
2589    let (bare_rect, rect) = resolve_notecard_rect(&state, &notecard, borders).await?;
2590    // Cache the bare rectangle (without border padding) on the notecard
2591    // row so the library UI can show its bounds without redoing region
2592    // resolution. The bare box keeps notecard bounds == route's bounding box.
2593    update_notecard_bounds(&state.db, notecard_id, &bare_rect).await?;
2594    // Backfill the bounds on the saved_renders row now that we know the
2595    // rectangle; the library UI shows them even for `in_progress` rows.
2596    update_render_bounds(&state.db, render_id, &rect).await?;
2597    let metadata = build_metadata(&rect);
2598    // Kept for the overlay-only occupancy map below; `new_with_progress` moves
2599    // `rect` into the tiled map.
2600    let occ_rect = rect.clone();
2601    let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
2602    let forwarder = tokio::spawn(forward_events(Arc::clone(&job), rx));
2603    let (image_without_route, map, glw_data_id) = {
2604        let mut map = {
2605            let mut cache = state.map_tile_cache.lock().await;
2606            Map::new_with_progress(
2607                &mut cache,
2608                common.max_width,
2609                common.max_height,
2610                rect,
2611                common.missing_map_tile_color,
2612                common.missing_region_color,
2613                Some(&tx),
2614            )
2615            .await?
2616        };
2617        // Layering: encode the without-route variant BEFORE any
2618        // overlays so the diagnostic save shows just the bare map.
2619        let without_route = if with_without_route {
2620            Some(encode_map(&map, common.format)?)
2621        } else {
2622            None
2623        };
2624        // Per-region annotation overlay sits just above the bare tiles (and so
2625        // is excluded from the without-route diagnostic above), below the GLW
2626        // overlay and route.
2627        apply_region_overlay(
2628            &state,
2629            common.region_overlay,
2630            common.region_label_font_id.as_deref(),
2631            // The final render fills missing regions properly during tile
2632            // building (`Map::new_with_progress`), so the overlay never paints
2633            // them here.
2634            None,
2635            &mut map,
2636            Some(&tx),
2637        )
2638        .await?;
2639        // GLW overlay sits between the base map and the route, so the
2640        // route line stays the most-readable element of the final
2641        // image.
2642        let glw_data_id = apply_glw_overlay_to_map(&state, glw_ctx.as_ref(), &mut map).await?;
2643        {
2644            let mut region = state.region_cache.lock().await;
2645            map.draw_route_with_progress(&mut region, &notecard, route_color, Some(&tx))
2646                .await?;
2647        }
2648        // Labels and logos go last, above the route, sharing one
2649        // mutually-exclusive pool of placement slots. Their free space is
2650        // measured on an overlay-only map (route + GLW shapes, legend excluded)
2651        // rather than the opaque tiled map; the same planning rejected an
2652        // over-full slot at submit time, so this normally cannot fail here.
2653        let (label_draws, logo_draws) = plan_placements(
2654            &state,
2655            occ_rect,
2656            &common,
2657            glw_ctx.as_ref(),
2658            Some((&notecard, route_color)),
2659            &labels,
2660            &logos,
2661        )
2662        .await?;
2663        execute_labels(&label_draws, &mut map);
2664        execute_logos(&logo_draws, &mut map);
2665        (without_route, map, glw_data_id)
2666    };
2667    drop(tx);
2668    let _join = forwarder.await;
2669    let image = encode_map(&map, common.format)?;
2670    Ok(JobOutcome::Ok {
2671        image,
2672        image_without_route,
2673        content_type: common.format.content_type(),
2674        metadata,
2675        glw_data_id,
2676    })
2677}
2678
2679/// Forward `MapProgressEvent`s coming from the renderer into the job's
2680/// recorded event history, converting them to `ProgressDto` on the way.
2681async fn forward_events(job: Arc<JobState>, mut rx: tokio::sync::mpsc::Receiver<MapProgressEvent>) {
2682    while let Some(event) = rx.recv().await {
2683        record_event(&job, ProgressDto::from(event)).await;
2684    }
2685}
2686
2687/// Compute the metadata block (aspect ratio + PPS HUD config) for a
2688/// rendered grid rectangle.
2689fn build_metadata(rect: &GridRectangle) -> Metadata {
2690    let aspect_x = rect.size_x();
2691    let aspect_y = rect.size_y();
2692    let aspect_ratio = f64::from(aspect_x) / f64::from(aspect_y);
2693    Metadata {
2694        aspect_x,
2695        aspect_y,
2696        aspect_ratio,
2697        pps_hud_config: rect.pps_hud_config(),
2698    }
2699}
2700
2701/// Encode the rendered map as image bytes in the requested format.
2702fn encode_map(map: &Map, format: OutputFormat) -> Result<Bytes, Error> {
2703    let mut buf: Vec<u8> = Vec::new();
2704    let mut cursor = Cursor::new(&mut buf);
2705    sl_map_apis::map_tiles::MapLike::image(map).write_to(&mut cursor, format.image_format())?;
2706    Ok(Bytes::from(buf))
2707}
2708
2709/// Finalise the job: record either Ok or Err and publish via the watch
2710/// channel so SSE handlers can emit the final `done` / `error` event.
2711/// Returns the [`JobOutcome`] for the persistence step.
2712async fn finish_job(job: &Arc<JobState>, result: Result<JobOutcome, Error>) -> JobOutcome {
2713    let outcome = match result {
2714        Ok(o) => o,
2715        Err(e) => {
2716            let message = format!("{e}");
2717            record_event(
2718                job,
2719                ProgressDto::Error {
2720                    message: message.clone(),
2721                },
2722            )
2723            .await;
2724            JobOutcome::Err(message)
2725        }
2726    };
2727    if matches!(outcome, JobOutcome::Ok { .. }) {
2728        record_event(job, ProgressDto::Done).await;
2729    }
2730    let arc = Arc::new(outcome.clone());
2731    drop(job.outcome.send_replace(Some(arc)));
2732    outcome
2733}
2734
2735/// Reject `max_width` / `max_height` outside the per-side caps. Both must
2736/// be > 0 and <= [`MAX_OUTPUT_DIMENSION`].
2737fn validate_dimensions(max_width: u32, max_height: u32) -> Result<(), Error> {
2738    if max_width == 0 || max_height == 0 {
2739        return Err(Error::BadRequest(
2740            "max_width and max_height must be greater than zero".to_owned(),
2741        ));
2742    }
2743    if max_width > MAX_OUTPUT_DIMENSION || max_height > MAX_OUTPUT_DIMENSION {
2744        return Err(Error::BadRequest(format!(
2745            "max_width and max_height must each be <= {MAX_OUTPUT_DIMENSION}"
2746        )));
2747    }
2748    // The product is the real allocation bound; the per-side cap alone would
2749    // still allow a ~4 GiB buffer. u64 so the multiply cannot overflow.
2750    if u64::from(max_width).saturating_mul(u64::from(max_height)) > MAX_OUTPUT_AREA {
2751        return Err(Error::BadRequest(format!(
2752            "max_width * max_height must be <= {MAX_OUTPUT_AREA} pixels"
2753        )));
2754    }
2755    Ok(())
2756}
2757
2758/// Reject the request if the user already has
2759/// [`MAX_CONCURRENT_RENDERS_PER_USER`] or more renders in progress. The
2760/// count is not strictly atomic with the subsequent insert — two requests
2761/// arriving in the same millisecond could both pass — but the race window
2762/// is small and the cap exists to limit accidental DoS rather than to be a
2763/// hard quota.
2764async fn assert_under_concurrent_limit(db: &sqlx::SqlitePool, user_id: Uuid) -> Result<(), Error> {
2765    let count: i64 = sqlx::query_scalar(
2766        "SELECT COUNT(*) FROM saved_renders \
2767         WHERE created_by = ?1 AND status = 'in_progress'",
2768    )
2769    .bind(user_id.as_bytes().to_vec())
2770    .fetch_one(db)
2771    .await
2772    .map_err(|err| {
2773        tracing::error!("count in-progress renders failed: {err}");
2774        Error::Database
2775    })?;
2776    if count >= MAX_CONCURRENT_RENDERS_PER_USER {
2777        return Err(Error::Forbidden(format!(
2778            "at most {MAX_CONCURRENT_RENDERS_PER_USER} renders may be in progress per user; \
2779             wait for one to finish"
2780        )));
2781    }
2782    Ok(())
2783}
2784
2785/// Parse a possibly-empty u16 from a form field.
2786fn parse_optional_u16(s: &str) -> Result<Option<u16>, Error> {
2787    let trimmed = s.trim();
2788    if trimmed.is_empty() {
2789        return Ok(None);
2790    }
2791    trimmed
2792        .parse::<u16>()
2793        .map(Some)
2794        .map_err(|e| Error::BadRequest(format!("invalid u16 `{trimmed}`: {e}")))
2795}
2796
2797/// Parse a required u32 from a form field.
2798fn parse_u32(s: &str) -> Result<u32, Error> {
2799    s.trim()
2800        .parse::<u32>()
2801        .map_err(|e| Error::BadRequest(format!("invalid u32 `{s}`: {e}")))
2802}
2803
2804/// Interpret a multipart-form checkbox value as a boolean. Treats the usual
2805/// truthy spellings as `true` and everything else (including absence, handled
2806/// by the field never appearing) as `false`.
2807fn parse_form_bool(raw: &str) -> bool {
2808    matches!(
2809        raw.trim().to_ascii_lowercase().as_str(),
2810        "1" | "on" | "true" | "yes"
2811    )
2812}
2813
2814// =====================================================================
2815// GLW overlay plumbing — shared between the two render workers.
2816// =====================================================================
2817
2818/// Inputs the render worker needs to resolve and apply a GLW overlay.
2819struct GlwJobCtx {
2820    /// the user's GLW request payload as it arrived from the form.
2821    options: GlwRenderOptions,
2822    /// destination the render lives in (and the auto-saved GLW row
2823    /// will inherit).
2824    destination: Destination,
2825    /// avatar id to record as the GLW row's `created_by`.
2826    created_by: Uuid,
2827}
2828
2829/// Resolved GLW event ready to draw onto the map, paired with the
2830/// `saved_glw_data` row id the render should reference.
2831struct ResolvedGlwEvent {
2832    /// the deserialised event.
2833    event: sl_glw::GlwEvent,
2834    /// the saved_glw_data row this event lives in.
2835    glw_data_id: Uuid,
2836}
2837
2838/// Resolve the GLW source to an event plus the saved_glw_data row id.
2839/// For fresh-fetch sources (event id / key / pasted JSON) this inserts
2840/// a new `saved_glw_data` row. For the `SavedId` source it just reads
2841/// the existing row.
2842///
2843/// The freshly-inserted row is a first-class, named library item in its own
2844/// right: the GLW list query (`fetch_glw_data_for`) returns it regardless of
2845/// whether any render links it, and the user can rename or delete it from the
2846/// library's GLW tab. So a render that *fails* after this insert does not
2847/// strand a hidden orphan — the row stays a usable, user-managed library entry
2848/// (and, being unlinked, is freely deletable; the `ON DELETE RESTRICT` FK only
2849/// bites once a *successful* render references it). That is deliberate: it
2850/// keeps `Regenerate` stable and lets the user re-render from the already
2851/// fetched data without hitting the upstream again. Consequently we do *not*
2852/// garbage-collect "unreferenced" GLW rows — doing so would delete intentional
2853/// library items, unlike the render/logo *file* sweeper, whose targets have no
2854/// independent meaning.
2855async fn resolve_glw_event(
2856    state: &AppState,
2857    ctx: &GlwJobCtx,
2858) -> Result<Option<ResolvedGlwEvent>, Error> {
2859    match &ctx.options.source {
2860        GlwSource::SavedId { glw_data_id } => {
2861            let row =
2862                crate::library::assert_can_read_glw_data(&state.db, ctx.created_by, *glw_data_id)
2863                    .await?;
2864            // Enforce the same-library invariant the comment on `validate_logos`
2865            // documents: a render's saved GLW data must live in the render's own
2866            // scope, so it stays visible to (and deletable by) the same audience
2867            // and a group render never silently depends on a personal row.
2868            let glw_dest = crate::library::destination_from_columns(
2869                row.owner_user_id.clone(),
2870                row.owner_group_id.clone(),
2871            )?;
2872            if glw_dest != ctx.destination {
2873                return Err(Error::BadRequest(format!(
2874                    "GLW data {glw_data_id} is not in the same library as this render; \
2875                     copy it into the render's scope first"
2876                )));
2877            }
2878            let event: sl_glw::GlwEvent = serde_json::from_str(&row.payload_json)?;
2879            Ok(Some(ResolvedGlwEvent {
2880                event,
2881                glw_data_id: *glw_data_id,
2882            }))
2883        }
2884        GlwSource::EventId { event_id } => {
2885            let id = sl_glw::EventId::new(*event_id);
2886            let event_opt = {
2887                let mut cache = state.glw_event_cache.lock().await;
2888                cache.get_event_by_id(id).await?
2889            };
2890            let Some(event) = event_opt else {
2891                return Ok(None);
2892            };
2893            let now = Utc::now();
2894            let name = default_or_user_name(
2895                ctx.options.save_as.as_deref(),
2896                &format!(
2897                    "Event {event_id} fetched {ts}",
2898                    ts = now.format("%Y-%m-%d %H:%M UTC")
2899                ),
2900            );
2901            let payload_json = serde_json::to_string(&event)?;
2902            let glw_data_id = crate::routes::glw::insert_glw_data_row(
2903                state,
2904                &crate::routes::glw::InsertGlwData {
2905                    destination: ctx.destination,
2906                    created_by: ctx.created_by,
2907                    name: &name,
2908                    source_kind: crate::library::GlwDataSourceKind::EventId,
2909                    source_event_id: Some(*event_id),
2910                    source_event_key: None,
2911                    payload_json: &payload_json,
2912                    event_id: Some(event.event_id.get()),
2913                    event_key: Some(event.event_key.as_str()),
2914                    event_name: Some(event.event_name.as_str()),
2915                    fetched_at: now,
2916                },
2917            )
2918            .await?;
2919            Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2920        }
2921        GlwSource::EventKey { event_key } => {
2922            let key = sl_glw::GlwEventKey::new(event_key);
2923            let event_opt = {
2924                let mut cache = state.glw_event_cache.lock().await;
2925                cache.get_event_by_key(&key).await?
2926            };
2927            let Some(event) = event_opt else {
2928                return Ok(None);
2929            };
2930            let now = Utc::now();
2931            let name = default_or_user_name(
2932                ctx.options.save_as.as_deref(),
2933                &format!(
2934                    "Key \"{event_key}\" fetched {ts}",
2935                    ts = now.format("%Y-%m-%d %H:%M UTC")
2936                ),
2937            );
2938            let payload_json = serde_json::to_string(&event)?;
2939            let glw_data_id = crate::routes::glw::insert_glw_data_row(
2940                state,
2941                &crate::routes::glw::InsertGlwData {
2942                    destination: ctx.destination,
2943                    created_by: ctx.created_by,
2944                    name: &name,
2945                    source_kind: crate::library::GlwDataSourceKind::EventKey,
2946                    source_event_id: None,
2947                    source_event_key: Some(event_key.as_str()),
2948                    payload_json: &payload_json,
2949                    event_id: Some(event.event_id.get()),
2950                    event_key: Some(event.event_key.as_str()),
2951                    event_name: Some(event.event_name.as_str()),
2952                    fetched_at: now,
2953                },
2954            )
2955            .await?;
2956            Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2957        }
2958        GlwSource::PastedJson { payload } => {
2959            let event: sl_glw::GlwEvent = serde_json::from_str(payload)?;
2960            let now = Utc::now();
2961            let name = default_or_user_name(
2962                ctx.options.save_as.as_deref(),
2963                &format!("Pasted JSON {}", now.format("%Y-%m-%d %H:%M UTC")),
2964            );
2965            let payload_json = serde_json::to_string(&event)?;
2966            let glw_data_id = crate::routes::glw::insert_glw_data_row(
2967                state,
2968                &crate::routes::glw::InsertGlwData {
2969                    destination: ctx.destination,
2970                    created_by: ctx.created_by,
2971                    name: &name,
2972                    source_kind: crate::library::GlwDataSourceKind::PastedJson,
2973                    source_event_id: None,
2974                    source_event_key: None,
2975                    payload_json: &payload_json,
2976                    event_id: Some(event.event_id.get()),
2977                    event_key: Some(event.event_key.as_str()),
2978                    event_name: Some(event.event_name.as_str()),
2979                    fetched_at: now,
2980                },
2981            )
2982            .await?;
2983            Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2984        }
2985    }
2986}
2987
2988/// If the user supplied a non-empty name, return it trimmed; otherwise
2989/// return the generated default.
2990fn default_or_user_name(supplied: Option<&str>, default: &str) -> String {
2991    let trimmed = supplied.map(str::trim).filter(|s| !s.is_empty());
2992    match trimmed {
2993        Some(s) => s.to_owned(),
2994        None => default.to_owned(),
2995    }
2996}
2997
2998/// Apply a GLW overlay onto `map` if `ctx` is `Some`. Returns the id
2999/// of the `saved_glw_data` row the worker should reference on
3000/// `saved_renders.glw_data_id`, or `None` if no overlay was drawn
3001/// (no GLW requested, or the server returned no event).
3002async fn apply_glw_overlay_to_map(
3003    state: &AppState,
3004    ctx: Option<&GlwJobCtx>,
3005    map: &mut Map,
3006) -> Result<Option<Uuid>, Error> {
3007    use sl_glw::MapLikeGlwExt as _;
3008    let Some(ctx) = ctx else {
3009        return Ok(None);
3010    };
3011    // Resolve the font first so a missing-font request fails fast,
3012    // before any HTTP fetch or DB writes.
3013    let font_path = state
3014        .fonts
3015        .path_for(&ctx.options.font_id)
3016        .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", ctx.options.font_id)))?;
3017    let font = sl_map_apis::text::load_font(font_path)?;
3018
3019    let resolved = resolve_glw_event(state, ctx).await?;
3020    let Some(resolved) = resolved else {
3021        tracing::warn!("GLW event not found (server returned no event); rendering without overlay");
3022        return Ok(None);
3023    };
3024    let style = build_glw_style(&ctx.options.style, ctx.options.legend_slot.as_deref())?;
3025    map.draw_glw_event_with_font(&resolved.event, &style, &font);
3026    Ok(Some(resolved.glw_data_id))
3027}
3028
3029/// Start from [`sl_glw::GlwStyle::default`] with the legend placed in the
3030/// requested slot (defaulting to `TopLeft`), then layer the user's
3031/// per-element colour and toggle overrides.
3032fn build_glw_style(
3033    overrides: &GlwStyleOverrides,
3034    legend_slot: Option<&str>,
3035) -> Result<sl_glw::GlwStyle, Error> {
3036    let mut style = sl_glw::GlwStyle {
3037        legend_position: legend_position_from_slot(legend_slot)?,
3038        draw_margin_band: overrides.margin_band,
3039        ..sl_glw::GlwStyle::default()
3040    };
3041    if let Some(c) = overrides.area_outline_color.as_deref() {
3042        style.palette.area_outline = parse_color(c.trim())?;
3043    }
3044    if let Some(c) = overrides.circle_outline_color.as_deref() {
3045        style.palette.circle_outline = parse_color(c.trim())?;
3046    }
3047    if let Some(c) = overrides.margin_outline_color.as_deref() {
3048        style.palette.margin_outline = parse_color(c.trim())?;
3049    }
3050    if let Some(c) = overrides.wind_color.as_deref() {
3051        style.palette.wind_arrow = parse_color(c.trim())?;
3052    }
3053    if let Some(c) = overrides.current_color.as_deref() {
3054        style.palette.current_arrow = parse_color(c.trim())?;
3055    }
3056    if let Some(c) = overrides.wave_color.as_deref() {
3057        style.palette.wave_glyph = parse_color(c.trim())?;
3058    }
3059    if let Some(c) = overrides.label_color.as_deref() {
3060        style.palette.label_fg = parse_color(c.trim())?;
3061    }
3062    Ok(style)
3063}
3064
3065/// Map a legend-slot name to a placement slot (or `None` to hide it). Absent /
3066/// empty → `TopLeft` (back-compat); `"none"` → hidden; any of the nine slot
3067/// names → that slot; anything else is a bad request.
3068fn legend_position_from_slot(
3069    slot: Option<&str>,
3070) -> Result<Option<sl_map_apis::coverage::PlacementSlot>, Error> {
3071    use sl_map_apis::coverage::PlacementSlot;
3072    let name = match slot {
3073        None => return Ok(Some(PlacementSlot::TopLeft)),
3074        Some(s) => s.trim(),
3075    };
3076    match name {
3077        "" => Ok(Some(PlacementSlot::TopLeft)),
3078        "none" => Ok(None),
3079        other => other
3080            .parse::<PlacementSlot>()
3081            .map(Some)
3082            .map_err(|err| Error::BadRequest(err.to_string())),
3083    }
3084}
3085
3086/// The slot anchor the base legend occupies for this job (if any): `None`
3087/// when there is no GLW overlay, the legend is hidden, or the slot is
3088/// invalid (the invalid case fails earlier in `apply_glw_overlay_to_map`).
3089fn legend_slot_of(ctx: Option<&GlwJobCtx>) -> Option<sl_map_apis::coverage::PlacementSlot> {
3090    let ctx = ctx?;
3091    legend_position_from_slot(ctx.options.legend_slot.as_deref())
3092        .ok()
3093        .flatten()
3094}
3095
3096/// Parse an optional horizontal-alignment name; absent/empty → `None` (use
3097/// the slot default).
3098fn parse_h_align(value: Option<&str>) -> Result<Option<sl_map_apis::coverage::HAlign>, Error> {
3099    use sl_map_apis::coverage::HAlign;
3100    match value.map(str::trim).filter(|s| !s.is_empty()) {
3101        None => Ok(None),
3102        Some("left") => Ok(Some(HAlign::Left)),
3103        Some("center") => Ok(Some(HAlign::Center)),
3104        Some("right") => Ok(Some(HAlign::Right)),
3105        Some(other) => Err(Error::BadRequest(format!("invalid h_align `{other}`"))),
3106    }
3107}
3108
3109/// Parse an optional vertical-alignment name; absent/empty → `None` (use the
3110/// slot default).
3111fn parse_v_align(value: Option<&str>) -> Result<Option<sl_map_apis::coverage::VAlign>, Error> {
3112    use sl_map_apis::coverage::VAlign;
3113    match value.map(str::trim).filter(|s| !s.is_empty()) {
3114        None => Ok(None),
3115        Some("top") => Ok(Some(VAlign::Top)),
3116        Some("center") => Ok(Some(VAlign::Center)),
3117        Some("bottom") => Ok(Some(VAlign::Bottom)),
3118        Some(other) => Err(Error::BadRequest(format!("invalid v_align `{other}`"))),
3119    }
3120}
3121
3122/// Parse a placement's combined slot group from the request: each name in
3123/// `names` parsed to a [`PlacementSlot`], always including `anchor`. An empty
3124/// `names` (the common single-slot case, and old saved settings) yields just
3125/// `[anchor]`.
3126fn parse_slot_group(
3127    anchor: sl_map_apis::coverage::PlacementSlot,
3128    names: &[String],
3129) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, Error> {
3130    if names.is_empty() {
3131        return Ok(vec![anchor]);
3132    }
3133    let mut group: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::with_capacity(names.len());
3134    for name in names {
3135        let slot = name
3136            .parse::<sl_map_apis::coverage::PlacementSlot>()
3137            .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3138                Error::BadRequest(err.to_string())
3139            })?;
3140        if !group.contains(&slot) {
3141            group.push(slot);
3142        }
3143    }
3144    if !group.contains(&anchor) {
3145        group.push(anchor);
3146    }
3147    if !sl_map_apis::coverage::PlacementSlot::slots_form_rectangle(&group) {
3148        let joined = group
3149            .iter()
3150            .map(|s| s.as_str())
3151            .collect::<Vec<_>>()
3152            .join("+");
3153        return Err(Error::BadRequest(format!(
3154            "the combined slot group `{joined}` does not form a solid rectangle"
3155        )));
3156    }
3157    Ok(group)
3158}
3159
3160/// Resolve a placement to the slots it reserves and the pixel rectangle to fit
3161/// content into. A single-slot placement uses that slot's own free rectangle;
3162/// a combined placement (more than one slot in `group`) uses the largest free
3163/// rectangle within exactly those slots' thirds ([`OccupancyGrid::subset_rect`])
3164/// and reserves the whole group. Shared by labels and logos.
3165fn resolve_placement(
3166    anchor: sl_map_apis::coverage::PlacementSlot,
3167    group: &[sl_map_apis::coverage::PlacementSlot],
3168    slots: &[sl_map_apis::coverage::PlacementSlotInfo],
3169    grid: &sl_map_apis::coverage::OccupancyGrid,
3170) -> Result<
3171    (
3172        Vec<sl_map_apis::coverage::PlacementSlot>,
3173        sl_map_apis::coverage::PixelRect,
3174    ),
3175    Error,
3176> {
3177    if group.len() > 1 {
3178        let rect = grid.subset_rect(group).ok_or_else(|| {
3179            Error::BadRequest(format!(
3180                "the combined slot at `{anchor}` is fully covered; no room for content"
3181            ))
3182        })?;
3183        Ok((group.to_vec(), rect))
3184    } else {
3185        let info = slots
3186            .iter()
3187            .find(|info| info.slot == anchor)
3188            .ok_or_else(|| Error::BadRequest(format!("slot `{anchor}` not found")))?;
3189        let rect = info.free_rect.ok_or_else(|| {
3190            Error::BadRequest(format!(
3191                "slot `{anchor}` is fully covered; no room for content"
3192            ))
3193        })?;
3194        Ok((vec![anchor], rect))
3195    }
3196}
3197
3198/// Reserve a placement's slots in the shared pool, rejecting any clash with
3199/// the legend, with already-reserved slots from the *other* placement kind
3200/// (`others`), or with slots reserved earlier in this pass (`used`). `what`
3201/// names the placement kind for the error message.
3202fn reserve(
3203    reserved: &[sl_map_apis::coverage::PlacementSlot],
3204    used: &mut Vec<sl_map_apis::coverage::PlacementSlot>,
3205    others: &[sl_map_apis::coverage::PlacementSlot],
3206    legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3207    what: &str,
3208) -> Result<(), Error> {
3209    for &slot in reserved {
3210        if legend_slot == Some(slot) {
3211            return Err(Error::BadRequest(format!(
3212                "a {what} uses slot `{slot}` which is occupied by the legend"
3213            )));
3214        }
3215        if used.contains(&slot) || others.contains(&slot) {
3216            return Err(Error::BadRequest(format!(
3217                "two placements target the same slot `{slot}`"
3218            )));
3219        }
3220        used.push(slot);
3221    }
3222    Ok(())
3223}
3224
3225/// One accepted label, ready to draw by [`execute_labels`].
3226struct LabelDraw {
3227    /// the text, one entry per line.
3228    lines: Vec<String>,
3229    /// the resolved font to render with.
3230    font: ab_glyph::FontVec,
3231    /// colour, scale and shadow for the text.
3232    style: sl_map_apis::text::LabelStyle,
3233    /// top-left pixel origin within the image.
3234    origin: (i32, i32),
3235}
3236
3237/// One accepted logo (already scaled), ready to composite by
3238/// [`execute_logos`].
3239struct LogoDraw {
3240    /// the decoded (and optionally doubled) logo bitmap.
3241    img: image::RgbaImage,
3242    /// x pixel coordinate of the logo's top-left corner.
3243    x: i64,
3244    /// y pixel coordinate of the logo's top-left corner.
3245    y: i64,
3246}
3247
3248/// Plan the free-floating text labels against the free space measured on
3249/// `occupancy` (an overlay-only map carrying just the route + GLW shapes, so
3250/// the opaque base tiles do not count as covered). Rejects (as a `BadRequest`)
3251/// any label that overflows its (single-slot or spanned) free space, or whose
3252/// reserved slots clash with the legend, another label, or a slot already
3253/// reserved by a logo (`reserved_by_others`). Each label is aligned within its
3254/// free rectangle using its own alignment, defaulting to the slot's outward
3255/// alignment. Returns the draw list (to hand to [`execute_labels`]) and the
3256/// set of slots the labels reserved so logos can avoid them.
3257fn plan_labels(
3258    fonts: &crate::fonts::FontDirectory,
3259    labels: &[TextLabel],
3260    legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3261    reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3262    occupancy: &Map,
3263) -> Result<(Vec<LabelDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), Error> {
3264    let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
3265    let mut draws: Vec<LabelDraw> = Vec::new();
3266    if labels.is_empty() {
3267        return Ok((draws, used));
3268    }
3269    // Authoritative free space, measured once on the overlay-only map.
3270    let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
3271        occupancy,
3272        sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
3273    );
3274    let slots = grid.evaluate_slots();
3275    // Validate + reserve + measure, collecting one draw item per visible label
3276    // so nothing is drawn until every label has been accepted.
3277    for label in labels {
3278        let lines: Vec<String> = label.lines.clone();
3279        if lines.iter().all(|line| line.trim().is_empty()) {
3280            // a blank label draws nothing and reserves nothing
3281            continue;
3282        }
3283        if !(label.font_px.is_finite() && label.font_px > 0f32) {
3284            return Err(Error::BadRequest(format!(
3285                "label font size must be a positive number of pixels, got {}",
3286                label.font_px
3287            )));
3288        }
3289        let anchor: sl_map_apis::coverage::PlacementSlot =
3290            label
3291                .slot
3292                .parse()
3293                .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3294                    Error::BadRequest(err.to_string())
3295                })?;
3296        let group = parse_slot_group(anchor, &label.slots)?;
3297        let (reserved, rect) = resolve_placement(anchor, &group, &slots, &grid)?;
3298        reserve(
3299            &reserved,
3300            &mut used,
3301            reserved_by_others,
3302            legend_slot,
3303            "label",
3304        )?;
3305        let font_path = fonts
3306            .path_for(&label.font_id)
3307            .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", label.font_id)))?;
3308        let font = sl_map_apis::text::load_font(font_path)?;
3309        let color = parse_color(label.color.trim())?;
3310        let scale = ab_glyph::PxScale::from(label.font_px);
3311        let (text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
3312        if text_w > rect.width || text_h > rect.height {
3313            return Err(Error::BadRequest(format!(
3314                "label text renders at {text_w}x{text_h} px but the free area at slot `{anchor}` only has {}x{} px",
3315                rect.width, rect.height
3316            )));
3317        }
3318        // Align within the free rectangle: the label's own value, else the
3319        // slot's outward default.
3320        let (default_h, default_v) = anchor.default_alignment();
3321        let h = parse_h_align(label.h_align.as_deref())?.unwrap_or(default_h);
3322        let v = parse_v_align(label.v_align.as_deref())?.unwrap_or(default_v);
3323        let origin_x = rect.x.saturating_add(h.offset(text_w, rect.width));
3324        let origin_y = rect.y.saturating_add(v.offset(text_h, rect.height));
3325        draws.push(LabelDraw {
3326            lines,
3327            font,
3328            style: sl_map_apis::text::LabelStyle {
3329                scale,
3330                fg: color,
3331                shadow: Rgba([0, 0, 0, 180]),
3332                // Each line is aligned within the block by the label's own
3333                // horizontal alignment (the block is then placed in the slot).
3334                align: h,
3335            },
3336            origin: (
3337                i32::try_from(origin_x).unwrap_or(0),
3338                i32::try_from(origin_y).unwrap_or(0),
3339            ),
3340        });
3341    }
3342    Ok((draws, used))
3343}
3344
3345/// Draw a planned label list onto `map` (above the route and GLW overlay).
3346fn execute_labels(draws: &[LabelDraw], map: &mut Map) {
3347    use sl_map_apis::map_tiles::MapLike as _;
3348    for d in draws {
3349        map.draw_text_label(d.origin, &d.lines, &d.style, &d.font);
3350    }
3351}
3352
3353/// Plan and draw labels onto `map` in one step, measuring free space on `map`
3354/// itself. Used by tests where the draw target is also a faithful occupancy
3355/// source (a transparent overlay map); the real render measures occupancy on a
3356/// separate overlay-only map via [`plan_labels`] + [`execute_labels`] because
3357/// its base tiles are opaque.
3358#[cfg(test)]
3359fn draw_labels_on_map(
3360    fonts: &crate::fonts::FontDirectory,
3361    labels: &[TextLabel],
3362    legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3363    reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3364    map: &mut Map,
3365) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, Error> {
3366    let (draws, used) = plan_labels(fonts, labels, legend_slot, reserved_by_others, map)?;
3367    execute_labels(&draws, map);
3368    Ok(used)
3369}
3370
3371/// Composite the logo images onto `map`, last (above the route, GLW overlay
3372/// and labels). Each logo is drawn at its native pixel size (optionally
3373/// integer-doubled with nearest-neighbour sampling), alpha-blended, and
3374/// aligned within its (single-slot or spanned) free rectangle. Rejects (as a
3375/// `BadRequest`) any logo that overflows its free space, has an invalid
3376/// scale, or whose reserved slots clash with the legend, another logo, or a
3377/// slot already reserved by a label (`reserved_by_others`). Returns the set
3378/// of slots the logos reserved.
3379/// Plan the logo placements against the free space measured on `occupancy`
3380/// (an overlay-only map). Each logo is loaded, decoded, optionally
3381/// integer-doubled (nearest-neighbour), and aligned within its (single-slot or
3382/// spanned) free rectangle. Rejects (as a `BadRequest`) any logo that overflows
3383/// its free space, has an invalid scale, or whose reserved slots clash with the
3384/// legend, another logo, or a slot already reserved by a label
3385/// (`reserved_by_others`). Returns the draw list (for [`execute_logos`]) and
3386/// the set of slots the logos reserved.
3387async fn plan_logos(
3388    state: &AppState,
3389    logos: &[LogoPlacement],
3390    legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3391    reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3392    occupancy: &Map,
3393) -> Result<(Vec<LogoDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), Error> {
3394    let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
3395    let mut draws: Vec<LogoDraw> = Vec::new();
3396    if logos.is_empty() {
3397        return Ok((draws, used));
3398    }
3399    let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
3400        occupancy,
3401        sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
3402    );
3403    let slots = grid.evaluate_slots();
3404    // Validate + reserve + load + decode + scale + fit, so nothing is drawn
3405    // until every logo has been accepted.
3406    for logo in logos {
3407        if logo.scale != 1 && logo.scale != 2 && logo.scale != 4 {
3408            return Err(Error::BadRequest(format!(
3409                "logo scale must be 1, 2 or 4, got {}",
3410                logo.scale
3411            )));
3412        }
3413        let anchor: sl_map_apis::coverage::PlacementSlot =
3414            logo.slot
3415                .parse()
3416                .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3417                    Error::BadRequest(err.to_string())
3418                })?;
3419        let group = parse_slot_group(anchor, &logo.slots)?;
3420        let (reserved, rect) = resolve_placement(anchor, &group, &slots, &grid)?;
3421        reserve(
3422            &reserved,
3423            &mut used,
3424            reserved_by_others,
3425            legend_slot,
3426            "logo",
3427        )?;
3428        // Look up the stored file (read permission was checked at submit
3429        // time; the saved_render_logos link prevents deletion meanwhile).
3430        let row: Option<(String,)> =
3431            sqlx::query_as("SELECT image_filename FROM saved_logos WHERE logo_id = ?1")
3432                .bind(logo.logo_id.as_bytes().to_vec())
3433                .fetch_optional(&state.db)
3434                .await
3435                .map_err(|err| {
3436                    tracing::error!("logo file lookup failed: {err}");
3437                    Error::Database
3438                })?;
3439        let (image_filename,) = row
3440            .ok_or_else(|| Error::BadRequest(format!("logo {} no longer exists", logo.logo_id)))?;
3441        let bytes = storage::read_logo_file(&state.config.storage_dir, &image_filename).await?;
3442        let decoded = image::load_from_memory(&bytes).map_err(|e| {
3443            Error::BadRequest(format!("could not decode logo {}: {e}", logo.logo_id))
3444        })?;
3445        let mut rgba = decoded.to_rgba8();
3446        if logo.scale != 1 {
3447            let factor = u32::from(logo.scale);
3448            let (w, h) = (rgba.width(), rgba.height());
3449            rgba = image::imageops::resize(
3450                &rgba,
3451                w.saturating_mul(factor),
3452                h.saturating_mul(factor),
3453                image::imageops::FilterType::Nearest,
3454            );
3455        }
3456        let (w, h) = (rgba.width(), rgba.height());
3457        if w > rect.width || h > rect.height {
3458            return Err(Error::BadRequest(format!(
3459                "logo renders at {w}x{h} px but the free area at slot `{anchor}` only has {}x{} px",
3460                rect.width, rect.height
3461            )));
3462        }
3463        let (default_h, default_v) = anchor.default_alignment();
3464        let hh = parse_h_align(logo.h_align.as_deref())?.unwrap_or(default_h);
3465        let vv = parse_v_align(logo.v_align.as_deref())?.unwrap_or(default_v);
3466        let origin_x = rect.x.saturating_add(hh.offset(w, rect.width));
3467        let origin_y = rect.y.saturating_add(vv.offset(h, rect.height));
3468        draws.push(LogoDraw {
3469            img: rgba,
3470            x: i64::from(origin_x),
3471            y: i64::from(origin_y),
3472        });
3473    }
3474    Ok((draws, used))
3475}
3476
3477/// Composite a planned logo list onto `map` with alpha blending (above the
3478/// route, GLW overlay and labels).
3479fn execute_logos(draws: &[LogoDraw], map: &mut Map) {
3480    use sl_map_apis::map_tiles::MapLike as _;
3481    for d in draws {
3482        image::imageops::overlay(map.image_mut(), &d.img, d.x, d.y);
3483    }
3484}
3485
3486/// Resolve a notecard to its route's grid rectangle: the bare bounding box of
3487/// the waypoints plus the configured border padding. Shared by the submit-time
3488/// placement check and the render job so both measure the same rectangle.
3489async fn resolve_notecard_rect(
3490    state: &AppState,
3491    notecard: &USBNotecard,
3492    borders: (u16, u16, u16, u16),
3493) -> Result<(GridRectangle, GridRectangle), Error> {
3494    let bare_rect = {
3495        let mut region = state.region_cache.lock().await;
3496        usb_notecard_to_grid_rectangle(&mut region, notecard).await?
3497    };
3498    let (border_north, border_south, border_east, border_west) = borders;
3499    let rect = bare_rect
3500        .expanded_west(border_west)
3501        .expanded_east(border_east)
3502        .expanded_south(border_south)
3503        .expanded_north(border_north);
3504    Ok((bare_rect, rect))
3505}
3506
3507/// Build the overlay-only occupancy map (a blank base sized to the final image,
3508/// plus the GLW shapes and — for notecard renders — the route) and plan the
3509/// labels and logos against it, returning the draws to paint.
3510///
3511/// This is the single source of truth for placement fit. The submit handlers
3512/// call it before persisting a render so an over-full slot is rejected up front
3513/// (nothing is saved), and the render jobs call it to produce the draws they
3514/// composite onto the real map. Sharing one routine guarantees the pre-save
3515/// check and the job can never disagree about whether a placement fits. The
3516/// occupancy is measured on a blank base (the opaque map tiles would otherwise
3517/// count as fully covered), so this needs no map-tile fetch.
3518async fn plan_placements(
3519    state: &AppState,
3520    occ_rect: GridRectangle,
3521    common: &CommonParams,
3522    glw_ctx: Option<&GlwJobCtx>,
3523    route: Option<(&USBNotecard, Rgba<u8>)>,
3524    labels: &[TextLabel],
3525    logos: &[LogoPlacement],
3526) -> Result<(Vec<LabelDraw>, Vec<LogoDraw>), Error> {
3527    let legend_slot = legend_slot_of(glw_ctx);
3528    let occ = {
3529        let mut occ = Map::blank_fit(occ_rect, common.max_width, common.max_height)?;
3530        if let Some(ctx) = glw_ctx {
3531            apply_glw_overlay_readonly(state, ctx.created_by, &ctx.options, &mut occ).await?;
3532        }
3533        if let Some((notecard, color)) = route {
3534            let mut region = state.region_cache.lock().await;
3535            occ.draw_route_with_progress(&mut region, notecard, color, None)
3536                .await?;
3537        }
3538        occ
3539    };
3540    let (label_draws, label_slots) = plan_labels(&state.fonts, labels, legend_slot, &[], &occ)?;
3541    let (logo_draws, _) = plan_logos(state, logos, legend_slot, &label_slots, &occ).await?;
3542    Ok((label_draws, logo_draws))
3543}
3544
3545/// Validate that every logo placement references a logo the user may read
3546/// and that lives in the same library scope as the render (matching the
3547/// notecard/GLW same-scope invariant). Returns the de-duplicated list of
3548/// logo ids to link onto the render row.
3549async fn validate_logos(
3550    state: &AppState,
3551    current_user: Uuid,
3552    destination: Destination,
3553    logos: &[LogoPlacement],
3554) -> Result<Vec<Uuid>, Error> {
3555    let mut ids: Vec<Uuid> = Vec::new();
3556    for logo in logos {
3557        let row = library::assert_can_read_logo(&state.db, current_user, logo.logo_id).await?;
3558        let logo_dest = library::destination_from_columns(
3559            row.owner_user_id.clone(),
3560            row.owner_group_id.clone(),
3561        )?;
3562        if logo_dest != destination {
3563            return Err(Error::BadRequest(format!(
3564                "logo {} is not in the same library as this render; copy it into the render's \
3565                 scope first",
3566                logo.logo_id
3567            )));
3568        }
3569        if !ids.contains(&logo.logo_id) {
3570            ids.push(logo.logo_id);
3571        }
3572    }
3573    Ok(ids)
3574}
3575
3576/// Authorize *read* access to every logo referenced by a placement set,
3577/// without the same-library requirement [`validate_logos`] additionally
3578/// enforces. Used by the read-only placement-preview endpoints: they carry no
3579/// render destination (so the same-scope check does not apply), but must still
3580/// refuse to composite — and thereby disclose the pixels of — a logo the
3581/// current user cannot see. [`plan_logos`] itself loads the file by id with no
3582/// authorization, trusting its callers to have gated read access first.
3583async fn assert_can_read_logos(
3584    state: &AppState,
3585    current_user: Uuid,
3586    logos: &[LogoPlacement],
3587) -> Result<(), Error> {
3588    for logo in logos {
3589        library::assert_can_read_logo(&state.db, current_user, logo.logo_id).await?;
3590    }
3591    Ok(())
3592}
3593
3594/// Link the render's logos, marking the freshly-inserted render row `failed`
3595/// if the link step errors. Without this a [`link_render_logos`] failure
3596/// between [`insert_render_row`] and spawning the job would strand the
3597/// `in_progress` row forever — no job is ever spawned to fail it, so it would
3598/// show as a perpetual spinner and keep counting against the user's
3599/// concurrent-render cap.
3600async fn link_render_logos_or_fail(
3601    state: &AppState,
3602    render_id: Uuid,
3603    logo_ids: &[Uuid],
3604) -> Result<(), Error> {
3605    if let Err(err) = link_render_logos(state, render_id, logo_ids).await {
3606        update_failed(
3607            state,
3608            render_id,
3609            &format!("linking render logos failed: {err}"),
3610            Utc::now(),
3611        )
3612        .await;
3613        return Err(err);
3614    }
3615    Ok(())
3616}
3617
3618/// Insert the `saved_render_logos` link rows for a render. Idempotent per
3619/// (render, logo) pair.
3620async fn link_render_logos(
3621    state: &AppState,
3622    render_id: Uuid,
3623    logo_ids: &[Uuid],
3624) -> Result<(), Error> {
3625    for id in logo_ids {
3626        sqlx::query(
3627            "INSERT OR IGNORE INTO saved_render_logos (render_id, logo_id) VALUES (?1, ?2)",
3628        )
3629        .bind(render_id.as_bytes().to_vec())
3630        .bind(id.as_bytes().to_vec())
3631        .execute(&state.db)
3632        .await
3633        .map_err(|err| {
3634            tracing::error!("link render logo failed: {err}");
3635            Error::Database
3636        })?;
3637    }
3638    Ok(())
3639}
3640
3641/// Rewrite a `saved_renders.settings_json` so the carried GLW source
3642/// becomes a stable `SavedId` pointing at the resolved row. Returns
3643/// `Ok(None)` if the existing settings either has no GLW field or
3644/// already carries a `SavedId` (then the original JSON is left alone).
3645async fn rewrite_settings_json_with_saved_glw(
3646    state: &AppState,
3647    render_id: Uuid,
3648    glw_data_id: Uuid,
3649) -> Result<Option<String>, Error> {
3650    let row: Option<(String,)> =
3651        sqlx::query_as("SELECT settings_json FROM saved_renders WHERE render_id = ?1")
3652            .bind(render_id.as_bytes().to_vec())
3653            .fetch_optional(&state.db)
3654            .await
3655            .map_err(|err| {
3656                tracing::error!("settings_json fetch failed: {err}");
3657                Error::Database
3658            })?;
3659    let Some((settings_json,)) = row else {
3660        return Ok(None);
3661    };
3662    let mut parsed: SavedRenderSettings = serde_json::from_str(&settings_json)?;
3663    let glw_slot: &mut Option<GlwRenderOptions> = match &mut parsed {
3664        SavedRenderSettings::GridRectangle(s) => &mut s.glw,
3665        SavedRenderSettings::UsbNotecard(s) => &mut s.glw,
3666    };
3667    let Some(opts) = glw_slot.as_mut() else {
3668        return Ok(None);
3669    };
3670    // No-op if it's already SavedId (e.g. the user submitted a SavedId
3671    // and the worker simply re-read the row).
3672    if matches!(opts.source, GlwSource::SavedId { .. }) {
3673        return Ok(None);
3674    }
3675    opts.source = GlwSource::SavedId { glw_data_id };
3676    let json = serde_json::to_string(&parsed)?;
3677    Ok(Some(json))
3678}
3679
3680#[cfg(test)]
3681mod placement_slots_tests {
3682    use super::*;
3683    use pretty_assertions::assert_eq;
3684
3685    fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
3686        // a 4x4 region rectangle at zoom 4 -> 128x128 pixels
3687        let rect = GridRectangle::new(
3688            GridCoordinates::new(1000, 1000),
3689            GridCoordinates::new(1003, 1003),
3690        );
3691        Ok(Map::blank(rect, ZoomLevel::try_new(4)?))
3692    }
3693
3694    #[test]
3695    fn empty_map_reports_nine_free_slots() -> Result<(), Box<dyn std::error::Error>> {
3696        let map = blank_map()?;
3697        let resp = compute_placement_slots(&map, &[]);
3698        assert_eq!(resp.slots.len(), 9);
3699        assert_eq!((resp.image_width, resp.image_height), (128, 128));
3700        // with no overlay drawn (and the legend excluded by design) every
3701        // anchor is free, including the legend's default top-left corner
3702        for s in &resp.slots {
3703            assert!(s.available, "{} should be free", s.slot);
3704        }
3705        let top_left = resp
3706            .slots
3707            .iter()
3708            .find(|s| s.slot == "top_left")
3709            .ok_or("missing top_left slot")?;
3710        assert!(top_left.free_rect.is_some());
3711        Ok(())
3712    }
3713
3714    #[test]
3715    fn route_blocks_centre_slot() -> Result<(), Box<dyn std::error::Error>> {
3716        let mut map = blank_map()?;
3717        map.draw_pixel_waypoint_route(
3718            &[(10f32, 10f32), (64f32, 64f32), (118f32, 118f32)],
3719            Rgba([255, 0, 0, 255]),
3720        )?;
3721        let resp = compute_placement_slots(&map, &[]);
3722        let center = resp
3723            .slots
3724            .iter()
3725            .find(|s| s.slot == "center")
3726            .ok_or("missing center slot")?;
3727        assert!(!center.available);
3728        let top_right = resp
3729            .slots
3730            .iter()
3731            .find(|s| s.slot == "top_right")
3732            .ok_or("missing top_right slot")?;
3733        assert!(top_right.available);
3734        Ok(())
3735    }
3736
3737    #[test]
3738    fn requested_group_reports_combined_rect() -> Result<(), Box<dyn std::error::Error>> {
3739        use sl_map_apis::coverage::PlacementSlot as P;
3740        let map = blank_map()?;
3741        let resp = compute_placement_slots(&map, &[vec![P::TopLeft, P::TopCenter]]);
3742        assert_eq!(resp.groups.len(), 1);
3743        let g = resp.groups.first().ok_or("missing group")?;
3744        assert_eq!(g.slots, vec!["top_left", "top_center"]);
3745        assert!(g.available && g.free_rect.is_some());
3746        // the combined rect is wider than either single top slot
3747        let tl = resp
3748            .slots
3749            .iter()
3750            .find(|s| s.slot == "top_left")
3751            .ok_or("missing top_left")?;
3752        assert!(g.free_width > tl.free_width);
3753        Ok(())
3754    }
3755}
3756
3757#[cfg(test)]
3758mod glw_preview_tests {
3759    //! Tests for the drawing the `glw_preview` handler performs once the event
3760    //! is resolved: rendering the GLW overlay onto a transparent blank map at
3761    //! the preview zoom, with the legend excluded (it is placed separately by
3762    //! the placement-slot logic). The resolution / HTTP plumbing needs a full
3763    //! `AppState`, so these cover the pure drawing core instead.
3764    use super::*;
3765    use pretty_assertions::assert_eq;
3766    use sl_glw::{GlwEvent, MapLikeGlwExt as _};
3767
3768    /// Sample event with one area and one circle (same shape as the
3769    /// `sl-glw` render smoke test's fixture).
3770    const SAMPLE_JSON: &str = r#"{
3771        "eventId": 6910,
3772        "eventName": "test cruise",
3773        "eventKey": "key cruise",
3774        "directorName": "LaliaCasau Resident",
3775        "directorKey": "b609826a-b167-41e0-8e67-9fc0e78b97a1",
3776        "base": {
3777            "wind": { "dir": 175, "speed": 17, "gusts": 8, "shifts": 5, "period": 90 },
3778            "waves": {
3779                "height": 1.5, "speed": 3, "length": 35,
3780                "heightVar": 5, "lengthVar": 5,
3781                "effects": { "speed": 1, "steer": 1 }
3782            },
3783            "currents": { "speed": 0, "dir": 180, "waterDepth": 0 }
3784        },
3785        "areas": {
3786            "area1": {
3787                "coordSW": { "x": 1133, "y": 1048 },
3788                "coordNE": { "x": 1135, "y": 1049 },
3789                "margin": 25, "overlap": 0,
3790                "currents": { "speed": 1, "dir": 225, "waterDepth": 8 }
3791            }
3792        },
3793        "circles": {
3794            "circle1": {
3795                "centerSim": { "x": 1136, "y": 1051 },
3796                "centerPoint": { "x": 90, "y": 175 },
3797                "radius": 127, "margin": 25, "overlap": 0,
3798                "wind": { "speed": 15 },
3799                "currents": { "speed": 0.1, "dir": 225, "waterDepth": 6 }
3800            }
3801        }
3802    }"#;
3803
3804    /// A `FontDirectory` scanning the workspace root for the bundled
3805    /// `DejaVuSans.ttf`.
3806    fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
3807        let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
3808        let fonts = crate::fonts::FontDirectory::scan(root)?;
3809        let path = fonts
3810            .path_for("DejaVuSans.ttf")
3811            .ok_or("bundled DejaVuSans.ttf font is missing")?;
3812        Ok(sl_map_apis::text::load_font(path)?)
3813    }
3814
3815    /// The blank map the preview overlay is drawn onto: a transparent RGBA
3816    /// image whose dimensions are `pixels_per_region(zoom) * size`, matching
3817    /// the `boundsW`/`boundsH` the client computes for the bounds rectangle.
3818    /// This is the alignment contract between the overlay PNG and the tiles.
3819    #[test]
3820    fn blank_overlay_dimensions_match_client_bounds() -> Result<(), Box<dyn std::error::Error>> {
3821        let rect = GridRectangle::new(
3822            GridCoordinates::new(1130, 1045),
3823            GridCoordinates::new(1140, 1055),
3824        );
3825        let zoom = ZoomLevel::try_new(4)?;
3826        let map = Map::blank(rect, zoom);
3827        let (w, h) = image::GenericImageView::dimensions(&map);
3828        // 11 sims each way × 32 px/region at zoom 4.
3829        assert_eq!((w, h), (11 * 32, 11 * 32));
3830        // a fresh blank overlay is fully transparent, so it composites over the
3831        // tiles without altering them until shapes are drawn
3832        for y in 0..h {
3833            for x in 0..w {
3834                let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
3835                assert_eq!(a, 0, "blank overlay must start fully transparent");
3836            }
3837        }
3838        Ok(())
3839    }
3840
3841    /// Whether any non-transparent pixel falls inside the top-left `n×n`
3842    /// corner, where the default-slot legend would be drawn and where the
3843    /// sample event has no shapes (all shapes sit at sim x ≥ 1133, i.e. ≥ 96 px
3844    /// from the left).
3845    fn top_left_has_pixels(map: &Map, n: u32) -> bool {
3846        for y in 0..n {
3847            for x in 0..n {
3848                let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
3849                if a != 0 {
3850                    return true;
3851                }
3852            }
3853        }
3854        false
3855    }
3856
3857    /// The preview draws the shapes but omits the legend: drawing the event
3858    /// with the legend enabled fills the top-left corner, while the preview's
3859    /// legend-disabled style leaves that corner untouched.
3860    #[test]
3861    fn preview_overlay_omits_the_legend() -> Result<(), Box<dyn std::error::Error>> {
3862        let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
3863        let font = test_font()?;
3864        let rect = GridRectangle::new(
3865            GridCoordinates::new(1130, 1045),
3866            GridCoordinates::new(1140, 1055),
3867        );
3868        let zoom = ZoomLevel::try_new(4)?;
3869
3870        // Baseline: the legend in its default top-left slot writes pixels into
3871        // the top-left corner.
3872        let mut with_legend = Map::blank(rect.clone(), zoom);
3873        let style_with = build_glw_style(&GlwStyleOverrides::default(), Some("top_left"))?;
3874        with_legend.draw_glw_event_with_font(&event, &style_with, &font);
3875        assert!(
3876            top_left_has_pixels(&with_legend, 40),
3877            "the legend should fill the top-left corner when enabled"
3878        );
3879
3880        // Preview style: legend disabled exactly as `glw_preview` does. The
3881        // shape-free top-left corner stays fully transparent.
3882        let mut without_legend = Map::blank(rect, zoom);
3883        let mut style_without = build_glw_style(&GlwStyleOverrides::default(), None)?;
3884        style_without.legend_position = None;
3885        without_legend.draw_glw_event_with_font(&event, &style_without, &font);
3886        assert!(
3887            !top_left_has_pixels(&without_legend, 40),
3888            "the preview overlay must not draw the legend"
3889        );
3890
3891        // The shapes themselves are still drawn (the overlay isn't empty).
3892        let (w, h) = image::GenericImageView::dimensions(&without_legend);
3893        let mut any = false;
3894        'outer: for y in 0..h {
3895            for x in 0..w {
3896                let image::Rgba([_, _, _, a]) =
3897                    image::GenericImageView::get_pixel(&without_legend, x, y);
3898                if a != 0 {
3899                    any = true;
3900                    break 'outer;
3901                }
3902            }
3903        }
3904        assert!(any, "the GLW shapes should still be drawn");
3905        Ok(())
3906    }
3907}
3908
3909#[cfg(test)]
3910mod route_preview_tests {
3911    //! Tests for the drawing the `route_preview` handler performs: converting
3912    //! already-resolved waypoints to pixel coordinates and drawing the route
3913    //! (spline + arrows, in the route colour) onto a blank final-image-sized
3914    //! map — the very same `pixel_coordinates_for_coordinates` +
3915    //! `draw_pixel_waypoint_route` calls the handler makes. The auth / HTTP
3916    //! plumbing needs a full `AppState`, so these cover the pure drawing core.
3917    use super::*;
3918    use pretty_assertions::assert_eq;
3919    use sl_map_apis::map_tiles::MapLike as _;
3920
3921    /// Distinctive opaque colour the route is drawn in. The spline rectangles
3922    /// and arrow polygons write it verbatim (no anti-aliasing), so it can be
3923    /// matched exactly.
3924    const ROUTE_COLOR: Rgba<u8> = Rgba([255, 0, 0, 255]);
3925
3926    /// Pixel coordinates are bounded by the (capped) output dimensions, so they
3927    /// never approach `f32`'s 2^23 exact-integer ceiling — the same widening
3928    /// `draw_route_with_progress` and the handler perform.
3929    #[expect(
3930        clippy::as_conversions,
3931        clippy::cast_precision_loss,
3932        reason = "pixel coordinates are bounded by the output dimensions and never approach 2^23"
3933    )]
3934    fn widen(v: u32) -> f32 {
3935        v as f32
3936    }
3937
3938    /// Convert one resolved waypoint `(region_x, region_y, x, y)` to pixel
3939    /// coordinates exactly as the `route_preview` handler does.
3940    fn pixel_for(map: &Map, rx: u32, ry: u32, x: f32, y: f32) -> Option<(f32, f32)> {
3941        let (px, py) = map.pixel_coordinates_for_coordinates(
3942            &GridCoordinates::new(rx, ry),
3943            &RegionCoordinates::new(x, y, 0f32),
3944        )?;
3945        Some((widen(px), widen(py)))
3946    }
3947
3948    /// Shortest distance from point `p` to the line segment `a`–`b`.
3949    fn distance_to_segment(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
3950        let (px, py) = p;
3951        let (ax, ay) = a;
3952        let (bx, by) = b;
3953        let (dx, dy) = (bx - ax, by - ay);
3954        let len_sq = (dx * dx) + (dy * dy);
3955        let t = if len_sq <= f32::EPSILON {
3956            0f32
3957        } else {
3958            ((((px - ax) * dx) + ((py - ay) * dy)) / len_sq).clamp(0f32, 1f32)
3959        };
3960        let (cx, cy) = (ax + (t * dx), ay + (t * dy));
3961        ((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
3962    }
3963
3964    /// The route is drawn in the requested colour over an otherwise fully
3965    /// transparent background: this is the alignment + colour contract the
3966    /// client relies on when compositing the PNG over the tiles.
3967    #[test]
3968    fn route_drawn_in_colour_on_transparent_background() -> Result<(), Box<dyn std::error::Error>> {
3969        let rect = GridRectangle::new(
3970            GridCoordinates::new(1000, 1000),
3971            GridCoordinates::new(1010, 1010),
3972        );
3973        let mut map = Map::blank_fit(rect, 512, 512)?;
3974        let pixel_waypoints = vec![
3975            pixel_for(&map, 1001, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?,
3976            pixel_for(&map, 1009, 1009, 128f32, 128f32).ok_or("waypoint outside rect")?,
3977        ];
3978        map.draw_pixel_waypoint_route(&pixel_waypoints, ROUTE_COLOR)?;
3979
3980        let (w, h) = image::GenericImageView::dimensions(&map);
3981        let mut coloured = 0u32;
3982        let mut foreign = 0u32;
3983        for y in 0..h {
3984            for x in 0..w {
3985                let image::Rgba([r, g, b, a]) = image::GenericImageView::get_pixel(&map, x, y);
3986                if image::Rgba([r, g, b, a]) == ROUTE_COLOR {
3987                    coloured += 1;
3988                } else if a != 0 {
3989                    // any non-transparent pixel that isn't the route colour
3990                    foreign += 1;
3991                }
3992            }
3993        }
3994        assert!(
3995            coloured > 0,
3996            "the route must be drawn in the requested colour"
3997        );
3998        assert_eq!(
3999            foreign, 0,
4000            "only the route colour and full transparency may appear"
4001        );
4002        Ok(())
4003    }
4004
4005    /// Three waypoints forming a peak are joined by a Catmull-Rom spline, not
4006    /// straight segments: at least one route pixel lies clearly off both
4007    /// straight chords between consecutive waypoints. This is what makes the
4008    /// preview match the curved output instead of the old straight polyline.
4009    #[test]
4010    fn route_curves_between_waypoints() -> Result<(), Box<dyn std::error::Error>> {
4011        let rect = GridRectangle::new(
4012            GridCoordinates::new(1000, 1000),
4013            GridCoordinates::new(1012, 1012),
4014        );
4015        let mut map = Map::blank_fit(rect, 512, 512)?;
4016        // A ∧-shaped arc: ends low, middle high. The tangent at the middle is
4017        // horizontal, so the spline bows away from the straight chords.
4018        let p0 = pixel_for(&map, 1001, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?;
4019        let p1 = pixel_for(&map, 1006, 1006, 128f32, 128f32).ok_or("waypoint outside rect")?;
4020        let p2 = pixel_for(&map, 1011, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?;
4021        map.draw_pixel_waypoint_route(&[p0, p1, p2], ROUTE_COLOR)?;
4022
4023        let (w, h) = image::GenericImageView::dimensions(&map);
4024        let mut max_off = 0f32;
4025        for y in 0..h {
4026            for x in 0..w {
4027                if image::GenericImageView::get_pixel(&map, x, y) == ROUTE_COLOR {
4028                    let p = (widen(x), widen(y));
4029                    let off = distance_to_segment(p, p0, p1).min(distance_to_segment(p, p1, p2));
4030                    max_off = max_off.max(off);
4031                }
4032            }
4033        }
4034        assert!(
4035            max_off > 4f32,
4036            "the route should curve away from the straight chords (max off-chord \
4037             distance was {max_off:.1} px); a straight polyline would stay on them"
4038        );
4039        Ok(())
4040    }
4041}
4042
4043#[cfg(test)]
4044mod glw_legend_preview_tests {
4045    //! Tests for the drawing `glw_legend_preview` performs: rendering ONLY the
4046    //! base legend onto a blank final-image-sized map at its chosen slot. The
4047    //! resolution / HTTP plumbing needs a full `AppState`, so these cover the
4048    //! pure drawing core (the same `draw_glw_base_legend` call the handler
4049    //! makes) instead.
4050    use super::*;
4051    use pretty_assertions::assert_eq;
4052    use sl_glw::{GlwEvent, MapLikeGlwExt as _};
4053
4054    /// Minimal event with a non-zero base block (the legend only reads
4055    /// `base`); the empty `areas`/`circles` keep the legend the only content.
4056    const SAMPLE_JSON: &str = r#"{
4057        "eventId": 6910,
4058        "eventName": "test cruise",
4059        "eventKey": "key cruise",
4060        "directorName": "LaliaCasau Resident",
4061        "directorKey": "b609826a-b167-41e0-8e67-9fc0e78b97a1",
4062        "base": {
4063            "wind": { "dir": 175, "speed": 17, "gusts": 8, "shifts": 5, "period": 90 },
4064            "waves": {
4065                "height": 1.5, "speed": 3, "length": 35,
4066                "heightVar": 5, "lengthVar": 5,
4067                "effects": { "speed": 1, "steer": 1 }
4068            },
4069            "currents": { "speed": 0, "dir": 180, "waterDepth": 0 }
4070        },
4071        "areas": {},
4072        "circles": {}
4073    }"#;
4074
4075    /// A font loaded from the workspace's bundled `DejaVuSans.ttf`.
4076    fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
4077        let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
4078        let fonts = crate::fonts::FontDirectory::scan(root)?;
4079        let path = fonts
4080            .path_for("DejaVuSans.ttf")
4081            .ok_or("bundled DejaVuSans.ttf font is missing")?;
4082        Ok(sl_map_apis::text::load_font(path)?)
4083    }
4084
4085    /// True if any non-transparent pixel falls inside the `w`×`h` corner at
4086    /// `(ox, oy)`.
4087    fn corner_has_pixels(map: &Map, ox: u32, oy: u32, w: u32, h: u32) -> bool {
4088        for y in oy..oy.saturating_add(h) {
4089            for x in ox..ox.saturating_add(w) {
4090                let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
4091                if a != 0 {
4092                    return true;
4093                }
4094            }
4095        }
4096        false
4097    }
4098
4099    /// The legend is drawn only in its chosen slot and nowhere else: a
4100    /// `bottom_right` legend fills the bottom-right corner while leaving the
4101    /// top-left corner transparent, and no shapes are drawn (legend-only).
4102    #[test]
4103    fn legend_drawn_only_in_chosen_slot() -> Result<(), Box<dyn std::error::Error>> {
4104        let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
4105        let font = test_font()?;
4106        let rect = GridRectangle::new(
4107            GridCoordinates::new(1130, 1045),
4108            GridCoordinates::new(1140, 1055),
4109        );
4110        let mut map = Map::blank_fit(rect, 512, 512)?;
4111        let style = build_glw_style(&GlwStyleOverrides::default(), Some("bottom_right"))?;
4112        // Exactly what the handler does: draw the legend alone (no shapes).
4113        map.draw_glw_base_legend(&event.base, &style, &font);
4114        let (w, h) = image::GenericImageView::dimensions(&map);
4115        assert!(
4116            corner_has_pixels(&map, w.saturating_sub(80), h.saturating_sub(80), 80, 80),
4117            "the bottom_right legend should fill the bottom-right corner"
4118        );
4119        assert!(
4120            !corner_has_pixels(&map, 0, 0, 80, 80),
4121            "no legend should appear in the unselected top-left corner"
4122        );
4123        Ok(())
4124    }
4125
4126    /// A `none` legend slot yields `legend_position == None`, so the handler
4127    /// skips drawing and the map stays fully transparent.
4128    #[test]
4129    fn legend_slot_none_draws_nothing() -> Result<(), Box<dyn std::error::Error>> {
4130        let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
4131        let font = test_font()?;
4132        let rect = GridRectangle::new(
4133            GridCoordinates::new(1130, 1045),
4134            GridCoordinates::new(1140, 1055),
4135        );
4136        let mut map = Map::blank_fit(rect, 512, 512)?;
4137        let style = build_glw_style(&GlwStyleOverrides::default(), Some("none"))?;
4138        assert!(
4139            style.legend_position.is_none(),
4140            "the `none` slot must disable the legend"
4141        );
4142        // The handler only calls draw when legend_position.is_some(); mirror
4143        // that guard, then assert nothing was drawn.
4144        if style.legend_position.is_some() {
4145            map.draw_glw_base_legend(&event.base, &style, &font);
4146        }
4147        let (w, h) = image::GenericImageView::dimensions(&map);
4148        for y in 0..h {
4149            for x in 0..w {
4150                let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
4151                assert_eq!(a, 0, "a `none` legend slot must leave the map transparent");
4152            }
4153        }
4154        Ok(())
4155    }
4156}
4157
4158#[cfg(test)]
4159mod label_tests {
4160    use super::*;
4161    use pretty_assertions::assert_eq;
4162    use pretty_assertions::assert_matches;
4163    use sl_map_apis::coverage::{HAlign, PlacementSlot, VAlign};
4164
4165    /// A `FontDirectory` scanning the workspace root, which contains the
4166    /// bundled `DejaVuSans.ttf` (font id `DejaVuSans.ttf`).
4167    fn test_fonts() -> Result<crate::fonts::FontDirectory, Box<dyn std::error::Error>> {
4168        let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
4169        Ok(crate::fonts::FontDirectory::scan(root)?)
4170    }
4171
4172    /// A blank 352x352 RGBA map (11 sims at zoom 4) — every slot fully free.
4173    fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
4174        let rect = GridRectangle::new(
4175            GridCoordinates::new(1130, 1130),
4176            GridCoordinates::new(1140, 1140),
4177        );
4178        Ok(Map::blank(rect, ZoomLevel::try_new(4)?))
4179    }
4180
4181    fn label(slot: &str, font_px: f32, lines: &[&str]) -> TextLabel {
4182        TextLabel {
4183            slot: slot.to_owned(),
4184            lines: lines.iter().map(|s| (*s).to_owned()).collect(),
4185            font_id: "DejaVuSans.ttf".to_owned(),
4186            font_px,
4187            color: "#ffffff".to_owned(),
4188            h_align: None,
4189            v_align: None,
4190            slots: Vec::new(),
4191        }
4192    }
4193
4194    fn any_pixel_drawn(map: &Map) -> bool {
4195        let (w, h) = image::GenericImageView::dimensions(map);
4196        for y in 0..h {
4197            for x in 0..w {
4198                let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
4199                if a != 0 {
4200                    return true;
4201                }
4202            }
4203        }
4204        false
4205    }
4206
4207    /// A copy of `blank_map` filled fully opaque, modelling the real render's
4208    /// base tiles (`new_with_progress` builds an opaque RGB image).
4209    fn opaque_map() -> Result<Map, Box<dyn std::error::Error>> {
4210        use image::GenericImage as _;
4211        use sl_map_apis::map_tiles::MapLike as _;
4212        let mut map = blank_map()?;
4213        let (w, h) = image::GenericImageView::dimensions(&map);
4214        for y in 0..h {
4215            for x in 0..w {
4216                map.image_mut().put_pixel(x, y, image::Rgba([5, 5, 5, 255]));
4217            }
4218        }
4219        Ok(map)
4220    }
4221
4222    /// The split between planning (occupancy) and drawing (target) is what lets
4223    /// the real render place labels: planning against an overlay-only map finds
4224    /// free space and the result draws onto an opaque target, whereas planning
4225    /// against the opaque map itself finds no room (the bug the split fixes).
4226    #[test]
4227    fn labels_plan_on_overlay_then_draw_onto_opaque_target()
4228    -> Result<(), Box<dyn std::error::Error>> {
4229        let fonts = test_fonts()?;
4230        let lbls = [label("top_left", 18f32, &["Hi"])];
4231
4232        // Overlay-only occupancy (a transparent map) → the slot is free, the
4233        // label is planned, and it draws onto an opaque target.
4234        let occ = blank_map()?;
4235        let (draws, used) = plan_labels(&fonts, &lbls, None, &[], &occ)?;
4236        assert!(!draws.is_empty(), "the label should be planned");
4237        assert!(used.contains(&PlacementSlot::TopLeft));
4238        let mut target = opaque_map()?;
4239        execute_labels(&draws, &mut target);
4240        let (w, h) = image::GenericImageView::dimensions(&target);
4241        let mut changed = false;
4242        'outer: for y in 0..h {
4243            for x in 0..w {
4244                let image::Rgba([r, g, b, _]) = image::GenericImageView::get_pixel(&target, x, y);
4245                if (r, g, b) != (5, 5, 5) {
4246                    changed = true;
4247                    break 'outer;
4248                }
4249            }
4250        }
4251        assert!(changed, "the label must draw onto the opaque target");
4252
4253        // Planning against the opaque map directly finds no free space.
4254        let opaque_occ = opaque_map()?;
4255        assert!(
4256            plan_labels(&fonts, &lbls, None, &[], &opaque_occ).is_err(),
4257            "an opaque occupancy map leaves no room — the regression the split fixes"
4258        );
4259        Ok(())
4260    }
4261
4262    #[test]
4263    fn legend_position_from_slot_maps_names() -> Result<(), Box<dyn std::error::Error>> {
4264        use sl_map_apis::coverage::PlacementSlot as P;
4265        assert_eq!(legend_position_from_slot(None)?, Some(P::TopLeft));
4266        assert_eq!(legend_position_from_slot(Some(""))?, Some(P::TopLeft));
4267        assert_eq!(legend_position_from_slot(Some("none"))?, None);
4268        assert_eq!(legend_position_from_slot(Some("center"))?, Some(P::Center));
4269        assert_eq!(
4270            legend_position_from_slot(Some("bottom_right"))?,
4271            Some(P::BottomRight)
4272        );
4273        assert_matches!(legend_position_from_slot(Some("nonsense")), Err(_));
4274        Ok(())
4275    }
4276
4277    #[test]
4278    fn slot_parsers_and_alignment() -> Result<(), Box<dyn std::error::Error>> {
4279        assert_eq!(
4280            "middle_left".parse::<PlacementSlot>()?,
4281            PlacementSlot::MiddleLeft
4282        );
4283        assert_matches!("nope".parse::<PlacementSlot>(), Err(_));
4284        assert_eq!(parse_h_align(None)?, None);
4285        assert_eq!(parse_h_align(Some("right"))?, Some(HAlign::Right));
4286        assert_eq!(parse_v_align(Some("bottom"))?, Some(VAlign::Bottom));
4287        assert_matches!(parse_h_align(Some("sideways")), Err(_));
4288        Ok(())
4289    }
4290
4291    #[test]
4292    fn small_label_draws_and_oversized_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
4293        let fonts = test_fonts()?;
4294
4295        let mut map = blank_map()?;
4296        draw_labels_on_map(
4297            &fonts,
4298            &[label("top_left", 18f32, &["Hi"])],
4299            None,
4300            &[],
4301            &mut map,
4302        )?;
4303        assert!(any_pixel_drawn(&map), "a fitting label should draw pixels");
4304
4305        // a font far too large to fit the 352px map is rejected
4306        let mut map2 = blank_map()?;
4307        let err = draw_labels_on_map(
4308            &fonts,
4309            &[label("center", 4000f32, &["BIG"])],
4310            None,
4311            &[],
4312            &mut map2,
4313        );
4314        assert_matches!(
4315            err,
4316            Err(Error::BadRequest(_)),
4317            "oversized label must be rejected"
4318        );
4319        Ok(())
4320    }
4321
4322    #[test]
4323    fn slot_collisions_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
4324        let fonts = test_fonts()?;
4325
4326        // two labels in the same slot
4327        let mut map = blank_map()?;
4328        let dup = draw_labels_on_map(
4329            &fonts,
4330            &[
4331                label("top_right", 16f32, &["a"]),
4332                label("top_right", 16f32, &["b"]),
4333            ],
4334            None,
4335            &[],
4336            &mut map,
4337        );
4338        assert_matches!(
4339            dup,
4340            Err(Error::BadRequest(_)),
4341            "duplicate slot must be rejected"
4342        );
4343
4344        // a label sharing the legend's slot
4345        let mut map2 = blank_map()?;
4346        let clash = draw_labels_on_map(
4347            &fonts,
4348            &[label("top_left", 16f32, &["x"])],
4349            Some(PlacementSlot::TopLeft),
4350            &[],
4351            &mut map2,
4352        );
4353        assert_matches!(
4354            clash,
4355            Err(Error::BadRequest(_)),
4356            "legend collision must be rejected"
4357        );
4358        Ok(())
4359    }
4360
4361    #[test]
4362    fn blank_label_is_skipped() -> Result<(), Box<dyn std::error::Error>> {
4363        let fonts = test_fonts()?;
4364        let mut map = blank_map()?;
4365        draw_labels_on_map(
4366            &fonts,
4367            &[label("center", 16f32, &["", "   "])],
4368            None,
4369            &[],
4370            &mut map,
4371        )?;
4372        assert!(!any_pixel_drawn(&map), "an all-blank label draws nothing");
4373        Ok(())
4374    }
4375
4376    #[test]
4377    fn reserve_rejects_legend_duplicate_and_other_pool() -> Result<(), Box<dyn std::error::Error>> {
4378        // a slot occupied by the legend is rejected
4379        let mut used = Vec::new();
4380        assert_matches!(
4381            reserve(
4382                &[PlacementSlot::TopLeft],
4383                &mut used,
4384                &[],
4385                Some(PlacementSlot::TopLeft),
4386                "label",
4387            ),
4388            Err(Error::BadRequest(_))
4389        );
4390
4391        // a slot reserved earlier in the same pool is rejected
4392        let mut used = Vec::new();
4393        reserve(&[PlacementSlot::TopRight], &mut used, &[], None, "label")?;
4394        assert_matches!(
4395            reserve(&[PlacementSlot::TopRight], &mut used, &[], None, "logo"),
4396            Err(Error::BadRequest(_))
4397        );
4398
4399        // a slot reserved by the other pool (e.g. a label) is rejected for a logo
4400        let mut used = Vec::new();
4401        assert_matches!(
4402            reserve(
4403                &[PlacementSlot::Center],
4404                &mut used,
4405                &[PlacementSlot::Center],
4406                None,
4407                "logo",
4408            ),
4409            Err(Error::BadRequest(_))
4410        );
4411        Ok(())
4412    }
4413
4414    #[test]
4415    fn resolve_placement_single_vs_combined() -> Result<(), Box<dyn std::error::Error>> {
4416        let map = blank_map()?;
4417        let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
4418            &map,
4419            sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
4420        );
4421        let slots = grid.evaluate_slots();
4422
4423        // a single slot reserves just the anchor and yields a positive rect
4424        let (reserved, rect) = resolve_placement(
4425            PlacementSlot::TopLeft,
4426            &[PlacementSlot::TopLeft],
4427            &slots,
4428            &grid,
4429        )?;
4430        assert_eq!(reserved, vec![PlacementSlot::TopLeft]);
4431        assert!(rect.width > 0 && rect.height > 0);
4432
4433        // a combined group reserves exactly the chosen slots and its rectangle
4434        // spans them (wider than the single top-left slot)
4435        let group = vec![PlacementSlot::TopLeft, PlacementSlot::TopCenter];
4436        let (all, combined) = resolve_placement(PlacementSlot::TopLeft, &group, &slots, &grid)?;
4437        assert_eq!(all, group);
4438        assert!(combined.width > rect.width);
4439        Ok(())
4440    }
4441
4442    #[test]
4443    fn parse_slot_group_defaults_and_includes_anchor() -> Result<(), Box<dyn std::error::Error>> {
4444        use sl_map_apis::coverage::PlacementSlot as P;
4445        // empty -> just the anchor
4446        assert_eq!(parse_slot_group(P::TopLeft, &[])?, vec![P::TopLeft]);
4447        // explicit names, anchor appended if missing, de-duplicated
4448        let g = parse_slot_group(
4449            P::TopLeft,
4450            &["top_center".to_owned(), "top_center".to_owned()],
4451        )?;
4452        assert_eq!(g, vec![P::TopCenter, P::TopLeft]);
4453        assert_matches!(parse_slot_group(P::TopLeft, &["nope".to_owned()]), Err(_));
4454        Ok(())
4455    }
4456
4457    #[test]
4458    fn parse_slot_group_rejects_non_rectangular_groups() {
4459        use sl_map_apis::coverage::PlacementSlot as P;
4460        // an adjacent pair forming a 1x2 rectangle is accepted
4461        assert_matches!(
4462            parse_slot_group(P::TopLeft, &["top_center".to_owned()]),
4463            Ok(_)
4464        );
4465        // a diagonal pair does not form a solid rectangle
4466        assert_matches!(
4467            parse_slot_group(P::TopLeft, &["bottom_right".to_owned()]),
4468            Err(_)
4469        );
4470        // an L-shape (anchor appended) is rejected too
4471        assert_matches!(
4472            parse_slot_group(
4473                P::TopLeft,
4474                &["top_center".to_owned(), "middle_left".to_owned()],
4475            ),
4476            Err(_)
4477        );
4478    }
4479
4480    #[test]
4481    fn parse_groups_rejects_non_rectangular_groups() {
4482        // a contiguous row is accepted
4483        assert_matches!(
4484            parse_groups(&[vec!["top_left".to_owned(), "top_center".to_owned()]]),
4485            Ok(_)
4486        );
4487        // a gapped column (top + bottom, missing middle) is rejected
4488        assert_matches!(
4489            parse_groups(&[vec!["top_left".to_owned(), "bottom_left".to_owned()]]),
4490            Err(_)
4491        );
4492    }
4493}
4494
4495#[cfg(test)]
4496mod region_overlay_tests {
4497    use super::*;
4498    use pretty_assertions::assert_eq;
4499
4500    #[test]
4501    fn text_overlay_gate_requires_both_size_and_count() {
4502        // both within bounds -> allowed
4503        assert!(region_text_overlay_allowed(
4504            MIN_PIXELS_PER_REGION_FOR_REGION_LABELS,
4505            MAX_REGIONS_FOR_REGION_LABELS
4506        ));
4507        // regions too small -> skipped even with a single region
4508        assert!(!region_text_overlay_allowed(
4509            MIN_PIXELS_PER_REGION_FOR_REGION_LABELS - 1.0,
4510            1
4511        ));
4512        // too many regions -> skipped even when each is large
4513        assert!(!region_text_overlay_allowed(
4514            256.0,
4515            MAX_REGIONS_FOR_REGION_LABELS + 1
4516        ));
4517        // a comfortably-sized, small render is allowed
4518        assert!(region_text_overlay_allowed(256.0, 4));
4519    }
4520
4521    #[test]
4522    fn rectangles_write_visible_pixels() -> Result<(), Box<dyn std::error::Error>> {
4523        // a 4x4 region map at zoom 4 -> 128x128 transparent pixels
4524        let rect = GridRectangle::new(
4525            GridCoordinates::new(1000, 1000),
4526            GridCoordinates::new(1003, 1003),
4527        );
4528        let mut map = Map::blank(rect, ZoomLevel::try_new(4)?);
4529        // before drawing, the map is fully transparent
4530        let before = (0..map.height())
4531            .flat_map(|y| (0..map.width()).map(move |x| (x, y)))
4532            .any(|(x, y)| {
4533                let image::Rgba([_, _, _, a]) = map.get_pixel(x, y);
4534                a != 0
4535            });
4536        assert!(!before, "blank map should start fully transparent");
4537
4538        draw_region_rectangles(&mut map);
4539
4540        // the region grid lines must now have written opaque white pixels
4541        let mut drawn = false;
4542        for y in 0..map.height() {
4543            for x in 0..map.width() {
4544                if map.get_pixel(x, y) == image::Rgba([255, 255, 255, 255]) {
4545                    drawn = true;
4546                }
4547            }
4548        }
4549        assert!(
4550            drawn,
4551            "draw_region_rectangles should write opaque white pixels"
4552        );
4553        Ok(())
4554    }
4555
4556    #[test]
4557    fn missing_region_fill_paints_the_region_rect() -> Result<(), Box<dyn std::error::Error>> {
4558        use sl_map_apis::map_tiles::MapLike as _;
4559        // a 2x2 region map at zoom 4 -> 64x64 px, 32 px per region.
4560        let rect = GridRectangle::new(
4561            GridCoordinates::new(1000, 1000),
4562            GridCoordinates::new(1001, 1001),
4563        );
4564        let mut map = Map::blank(rect, ZoomLevel::try_new(4)?);
4565        // Fill the lower-left region (grid 1000,1000) the way apply_region_overlay
4566        // does for an `Ok(None)` (missing) region.
4567        let grid = GridCoordinates::new(1000, 1000);
4568        let (left, top, width, height) =
4569            region_pixel_rect(&map, &grid).ok_or("region not in map")?;
4570        let fill = Rgba([0x19, 0x48, 0x5a, 0xff]);
4571        map.draw_filled_rect(left, top, width, height, fill);
4572
4573        // a pixel inside the filled region carries the colour …
4574        assert_eq!(
4575            map.get_pixel(left + 1, top + 1),
4576            fill,
4577            "fill colour painted"
4578        );
4579        // … while a pixel in the diagonally-opposite region stays transparent.
4580        let other = region_pixel_rect(&map, &GridCoordinates::new(1001, 1001))
4581            .ok_or("other region not in map")?;
4582        let image::Rgba([_, _, _, a]) = map.get_pixel(other.0 + 1, other.1 + 1);
4583        assert_eq!(a, 0, "unfilled region stays transparent");
4584        Ok(())
4585    }
4586}