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: u16,
322    /// lower-left y grid coordinate.
323    pub lower_left_y: u16,
324    /// upper-right x grid coordinate.
325    pub upper_right_x: u16,
326    /// upper-right y grid coordinate.
327    pub upper_right_y: u16,
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: u16,
421    /// lower-left y grid coordinate.
422    pub lower_left_y: u16,
423    /// upper-right x grid coordinate.
424    pub upper_right_x: u16,
425    /// upper-right y grid coordinate.
426    pub upper_right_y: u16,
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: u16,
1046    /// lower-left y grid coordinate of the final-image rectangle.
1047    pub lower_left_y: u16,
1048    /// upper-right x grid coordinate of the final-image rectangle.
1049    pub upper_right_x: u16,
1050    /// upper-right y grid coordinate of the final-image rectangle.
1051    pub upper_right_y: u16,
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(u32::from(rect.size_x()));
1099    let height = pixels_per_region.saturating_mul(u32::from(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: u16,
1197    /// resolved y grid coordinate of the region.
1198    pub region_y: u16,
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: u16,
1210    /// lower-left y grid coordinate of the final-image rectangle.
1211    pub lower_left_y: u16,
1212    /// upper-right x grid coordinate of the final-image rectangle.
1213    pub upper_right_x: u16,
1214    /// upper-right y grid coordinate of the final-image rectangle.
1215    pub upper_right_y: u16,
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: u16,
1291    /// lower-left y grid coordinate of the final-image rectangle.
1292    pub lower_left_y: u16,
1293    /// upper-right x grid coordinate of the final-image rectangle.
1294    pub upper_right_x: u16,
1295    /// upper-right y grid coordinate of the final-image rectangle.
1296    pub upper_right_y: u16,
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 = usize::from(map.size_x()).saturating_mul(usize::from(map.size_y()));
2358    let run_loop = opts.any_text() && region_text_overlay_allowed(pixels_per_region, region_count);
2359    if !run_loop {
2360        if opts.any_text() {
2361            tracing::info!(
2362                pixels_per_region,
2363                region_count,
2364                "skipping region name/coordinate overlay (regions too small or too many)"
2365            );
2366        }
2367        // No per-region loop, so draw the outlines on their own.
2368        if opts.rectangles {
2369            draw_region_rectangles(map);
2370        }
2371        return Ok(());
2372    }
2373    let font_id = font_id.ok_or_else(|| {
2374        Error::BadRequest("select a font for the region name / coordinate overlay".to_owned())
2375    })?;
2376    let font_path = state
2377        .fonts
2378        .path_for(font_id)
2379        .ok_or_else(|| Error::BadRequest(format!("unknown region label font_id `{font_id}`")))?;
2380    let font = sl_map_apis::text::load_font(font_path)?;
2381    let scale = ab_glyph::PxScale::from(
2382        (pixels_per_region * REGION_LABEL_FONT_FACTOR)
2383            .clamp(REGION_LABEL_FONT_MIN_PX, REGION_LABEL_FONT_MAX_PX),
2384    );
2385    let style = sl_map_apis::text::LabelStyle {
2386        scale,
2387        fg: Rgba([255, 255, 255, 255]),
2388        shadow: Rgba([0, 0, 0, 180]),
2389        align: sl_map_apis::coverage::HAlign::Left,
2390    };
2391    // Resolving a name is a (cached but possibly cold) upstream lookup per
2392    // region, so report it as progress — but only when names are actually being
2393    // looked up (coordinates / rectangles are computed locally and need none).
2394    let total_regions = u32::try_from(region_count).unwrap_or(u32::MAX);
2395    if let Some(tx) = progress.filter(|_| opts.names) {
2396        drop(tx.try_send(MapProgressEvent::RegionNamesPlanned { total_regions }));
2397    }
2398    let mut resolved: u32 = 0;
2399    for x in map.x_range() {
2400        for y in map.y_range() {
2401            let grid = GridCoordinates::new(x, y);
2402            let Some((left, top, width, height)) = region_pixel_rect(map, &grid) else {
2403                continue;
2404            };
2405            // Resolve the name (when enabled). The result also tells us whether
2406            // the region exists: `Ok(None)` is a confirmed-missing region, which
2407            // we paint with the missing-region colour when that is enabled.
2408            // `Err` is an inconclusive lookup — leave it untouched.
2409            let mut region_name: Option<String> = None;
2410            let mut missing = false;
2411            if opts.names {
2412                let lookup = {
2413                    let mut region = state.region_cache.lock().await;
2414                    region.get_region_name(&grid).await
2415                };
2416                match lookup {
2417                    Ok(Some(name)) => region_name = Some(name.to_string()),
2418                    Ok(None) => missing = true,
2419                    Err(err) => {
2420                        tracing::debug!("region name lookup failed for {grid:?}: {err}");
2421                    }
2422                }
2423                if let Some(tx) = progress {
2424                    drop(tx.try_send(MapProgressEvent::RegionNameResolved {
2425                        index: resolved,
2426                        total: total_regions,
2427                    }));
2428                }
2429                resolved = resolved.saturating_add(1);
2430            }
2431            // Layer per region, bottom-up: missing-region fill, then the outline,
2432            // then the text — so each sits above the previous.
2433            if let Some(color) = missing_region_color.filter(|_| missing) {
2434                map.draw_filled_rect(left, top, width, height, color);
2435            }
2436            if opts.rectangles {
2437                map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
2438            }
2439            let mut lines: Vec<String> = Vec::new();
2440            if opts.coordinates {
2441                lines.push(format!("({x}, {y})"));
2442            }
2443            if let Some(name) = region_name {
2444                lines.push(name);
2445            }
2446            if lines.is_empty() {
2447                continue;
2448            }
2449            let (_text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
2450            // SL y points up while image y points down, so the region's bottom
2451            // edge (the text anchor) is `top + height`.
2452            let bottom = top.saturating_add(height);
2453            let origin_x = i32::try_from(left)
2454                .unwrap_or(0)
2455                .saturating_add(REGION_LABEL_PADDING);
2456            let origin_y = i32::try_from(bottom)
2457                .unwrap_or(0)
2458                .saturating_sub(REGION_LABEL_PADDING)
2459                .saturating_sub(i32::try_from(text_h).unwrap_or(0));
2460            map.draw_text_label((origin_x, origin_y), &lines, &style, &font);
2461        }
2462    }
2463    Ok(())
2464}
2465
2466/// The pixel rectangle `(left, top, width, height)` a region occupies in `map`,
2467/// or `None` if the region is outside it. The two opposite corners are mapped to
2468/// pixels (same pattern as `crop_imm_grid_rectangle`) so no floating-point
2469/// pixel-per-region rounding is needed.
2470fn region_pixel_rect(map: &Map, grid: &GridCoordinates) -> Option<(u32, u32, u32, u32)> {
2471    use sl_map_apis::map_tiles::MapLike as _;
2472    let (x0, y0) =
2473        map.pixel_coordinates_for_coordinates(grid, &RegionCoordinates::new(0f32, 0f32, 0f32))?;
2474    let (x1, y1) =
2475        map.pixel_coordinates_for_coordinates(grid, &RegionCoordinates::new(256f32, 256f32, 0f32))?;
2476    Some((x0.min(x1), y0.min(y1), x0.abs_diff(x1), y0.abs_diff(y1)))
2477}
2478
2479/// Draw a hairline outline around every region of `map`.
2480fn draw_region_rectangles(map: &mut Map) {
2481    use sl_map_apis::map_tiles::MapLike as _;
2482    use sl_types::map::GridRectangleLike as _;
2483    for x in map.x_range() {
2484        for y in map.y_range() {
2485            let grid = GridCoordinates::new(x, y);
2486            if let Some((left, top, width, height)) = region_pixel_rect(map, &grid) {
2487                map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
2488            }
2489        }
2490    }
2491}
2492
2493/// Run the grid-rectangle render to completion.
2494async fn run_grid_rectangle_job(
2495    state: AppState,
2496    job: Arc<JobState>,
2497    rect: GridRectangle,
2498    common: CommonParams,
2499    glw_ctx: Option<GlwJobCtx>,
2500    labels: Vec<TextLabel>,
2501    logos: Vec<LogoPlacement>,
2502) -> Result<JobOutcome, Error> {
2503    let metadata = build_metadata(&rect);
2504    // Kept for the overlay-only occupancy map below; `new_with_progress` moves
2505    // `rect` into the tiled map.
2506    let occ_rect = rect.clone();
2507    let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
2508    let forwarder = tokio::spawn(forward_events(Arc::clone(&job), rx));
2509    let mut map = {
2510        let mut cache = state.map_tile_cache.lock().await;
2511        Map::new_with_progress(
2512            &mut cache,
2513            common.max_width,
2514            common.max_height,
2515            rect,
2516            common.missing_map_tile_color,
2517            common.missing_region_color,
2518            Some(&tx),
2519        )
2520        .await?
2521    };
2522    // Layering: base map below, then the per-region annotation overlay, then
2523    // the GLW overlay. No route for the grid-rectangle path. Labels go last,
2524    // above everything. The overlay resolves region names (when enabled), so it
2525    // keeps reporting progress on `tx` — drop the sender and join the forwarder
2526    // only after it returns.
2527    apply_region_overlay(
2528        &state,
2529        common.region_overlay,
2530        common.region_label_font_id.as_deref(),
2531        // The final render fills missing regions properly during tile building
2532        // (`Map::new_with_progress`), so the overlay never paints them here.
2533        None,
2534        &mut map,
2535        Some(&tx),
2536    )
2537    .await?;
2538    drop(tx);
2539    // wait for the forwarder so the event history is complete before we
2540    // signal completion to subscribers
2541    let _join = forwarder.await;
2542    let glw_data_id = apply_glw_overlay_to_map(&state, glw_ctx.as_ref(), &mut map).await?;
2543    // Free space for labels/logos is measured on an overlay-only map (GLW shapes
2544    // only, no route on the grid path); the same planning rejected an over-full
2545    // slot at submit time, so this normally cannot fail here.
2546    let (label_draws, logo_draws) = plan_placements(
2547        &state,
2548        occ_rect,
2549        &common,
2550        glw_ctx.as_ref(),
2551        None,
2552        &labels,
2553        &logos,
2554    )
2555    .await?;
2556    execute_labels(&label_draws, &mut map);
2557    execute_logos(&logo_draws, &mut map);
2558    let image = encode_map(&map, common.format)?;
2559    Ok(JobOutcome::Ok {
2560        image,
2561        image_without_route: None,
2562        content_type: common.format.content_type(),
2563        metadata,
2564        glw_data_id,
2565    })
2566}
2567
2568/// Run the USB-notecard render to completion.
2569#[expect(
2570    clippy::too_many_arguments,
2571    reason = "this is a one-shot helper invoked from a single spawn site"
2572)]
2573async fn run_usb_notecard_job(
2574    state: AppState,
2575    job: Arc<JobState>,
2576    render_id: Uuid,
2577    notecard_id: Uuid,
2578    notecard: USBNotecard,
2579    borders: (u16, u16, u16, u16),
2580    route_color: Rgba<u8>,
2581    common: CommonParams,
2582    with_without_route: bool,
2583    glw_ctx: Option<GlwJobCtx>,
2584    labels: Vec<TextLabel>,
2585    logos: Vec<LogoPlacement>,
2586) -> Result<JobOutcome, Error> {
2587    let (bare_rect, rect) = resolve_notecard_rect(&state, &notecard, borders).await?;
2588    // Cache the bare rectangle (without border padding) on the notecard
2589    // row so the library UI can show its bounds without redoing region
2590    // resolution. The bare box keeps notecard bounds == route's bounding box.
2591    update_notecard_bounds(&state.db, notecard_id, &bare_rect).await?;
2592    // Backfill the bounds on the saved_renders row now that we know the
2593    // rectangle; the library UI shows them even for `in_progress` rows.
2594    update_render_bounds(&state.db, render_id, &rect).await?;
2595    let metadata = build_metadata(&rect);
2596    // Kept for the overlay-only occupancy map below; `new_with_progress` moves
2597    // `rect` into the tiled map.
2598    let occ_rect = rect.clone();
2599    let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
2600    let forwarder = tokio::spawn(forward_events(Arc::clone(&job), rx));
2601    let (image_without_route, map, glw_data_id) = {
2602        let mut map = {
2603            let mut cache = state.map_tile_cache.lock().await;
2604            Map::new_with_progress(
2605                &mut cache,
2606                common.max_width,
2607                common.max_height,
2608                rect,
2609                common.missing_map_tile_color,
2610                common.missing_region_color,
2611                Some(&tx),
2612            )
2613            .await?
2614        };
2615        // Layering: encode the without-route variant BEFORE any
2616        // overlays so the diagnostic save shows just the bare map.
2617        let without_route = if with_without_route {
2618            Some(encode_map(&map, common.format)?)
2619        } else {
2620            None
2621        };
2622        // Per-region annotation overlay sits just above the bare tiles (and so
2623        // is excluded from the without-route diagnostic above), below the GLW
2624        // overlay and route.
2625        apply_region_overlay(
2626            &state,
2627            common.region_overlay,
2628            common.region_label_font_id.as_deref(),
2629            // The final render fills missing regions properly during tile
2630            // building (`Map::new_with_progress`), so the overlay never paints
2631            // them here.
2632            None,
2633            &mut map,
2634            Some(&tx),
2635        )
2636        .await?;
2637        // GLW overlay sits between the base map and the route, so the
2638        // route line stays the most-readable element of the final
2639        // image.
2640        let glw_data_id = apply_glw_overlay_to_map(&state, glw_ctx.as_ref(), &mut map).await?;
2641        {
2642            let mut region = state.region_cache.lock().await;
2643            map.draw_route_with_progress(&mut region, &notecard, route_color, Some(&tx))
2644                .await?;
2645        }
2646        // Labels and logos go last, above the route, sharing one
2647        // mutually-exclusive pool of placement slots. Their free space is
2648        // measured on an overlay-only map (route + GLW shapes, legend excluded)
2649        // rather than the opaque tiled map; the same planning rejected an
2650        // over-full slot at submit time, so this normally cannot fail here.
2651        let (label_draws, logo_draws) = plan_placements(
2652            &state,
2653            occ_rect,
2654            &common,
2655            glw_ctx.as_ref(),
2656            Some((&notecard, route_color)),
2657            &labels,
2658            &logos,
2659        )
2660        .await?;
2661        execute_labels(&label_draws, &mut map);
2662        execute_logos(&logo_draws, &mut map);
2663        (without_route, map, glw_data_id)
2664    };
2665    drop(tx);
2666    let _join = forwarder.await;
2667    let image = encode_map(&map, common.format)?;
2668    Ok(JobOutcome::Ok {
2669        image,
2670        image_without_route,
2671        content_type: common.format.content_type(),
2672        metadata,
2673        glw_data_id,
2674    })
2675}
2676
2677/// Forward `MapProgressEvent`s coming from the renderer into the job's
2678/// recorded event history, converting them to `ProgressDto` on the way.
2679async fn forward_events(job: Arc<JobState>, mut rx: tokio::sync::mpsc::Receiver<MapProgressEvent>) {
2680    while let Some(event) = rx.recv().await {
2681        record_event(&job, ProgressDto::from(event)).await;
2682    }
2683}
2684
2685/// Compute the metadata block (aspect ratio + PPS HUD config) for a
2686/// rendered grid rectangle.
2687fn build_metadata(rect: &GridRectangle) -> Metadata {
2688    let aspect_x = rect.size_x();
2689    let aspect_y = rect.size_y();
2690    let aspect_ratio = f32::from(aspect_x) / f32::from(aspect_y);
2691    Metadata {
2692        aspect_x,
2693        aspect_y,
2694        aspect_ratio,
2695        pps_hud_config: rect.pps_hud_config(),
2696    }
2697}
2698
2699/// Encode the rendered map as image bytes in the requested format.
2700fn encode_map(map: &Map, format: OutputFormat) -> Result<Bytes, Error> {
2701    let mut buf: Vec<u8> = Vec::new();
2702    let mut cursor = Cursor::new(&mut buf);
2703    sl_map_apis::map_tiles::MapLike::image(map).write_to(&mut cursor, format.image_format())?;
2704    Ok(Bytes::from(buf))
2705}
2706
2707/// Finalise the job: record either Ok or Err and publish via the watch
2708/// channel so SSE handlers can emit the final `done` / `error` event.
2709/// Returns the [`JobOutcome`] for the persistence step.
2710async fn finish_job(job: &Arc<JobState>, result: Result<JobOutcome, Error>) -> JobOutcome {
2711    let outcome = match result {
2712        Ok(o) => o,
2713        Err(e) => {
2714            let message = format!("{e}");
2715            record_event(
2716                job,
2717                ProgressDto::Error {
2718                    message: message.clone(),
2719                },
2720            )
2721            .await;
2722            JobOutcome::Err(message)
2723        }
2724    };
2725    if matches!(outcome, JobOutcome::Ok { .. }) {
2726        record_event(job, ProgressDto::Done).await;
2727    }
2728    let arc = Arc::new(outcome.clone());
2729    drop(job.outcome.send_replace(Some(arc)));
2730    outcome
2731}
2732
2733/// Reject `max_width` / `max_height` outside the per-side caps. Both must
2734/// be > 0 and <= [`MAX_OUTPUT_DIMENSION`].
2735fn validate_dimensions(max_width: u32, max_height: u32) -> Result<(), Error> {
2736    if max_width == 0 || max_height == 0 {
2737        return Err(Error::BadRequest(
2738            "max_width and max_height must be greater than zero".to_owned(),
2739        ));
2740    }
2741    if max_width > MAX_OUTPUT_DIMENSION || max_height > MAX_OUTPUT_DIMENSION {
2742        return Err(Error::BadRequest(format!(
2743            "max_width and max_height must each be <= {MAX_OUTPUT_DIMENSION}"
2744        )));
2745    }
2746    // The product is the real allocation bound; the per-side cap alone would
2747    // still allow a ~4 GiB buffer. u64 so the multiply cannot overflow.
2748    if u64::from(max_width).saturating_mul(u64::from(max_height)) > MAX_OUTPUT_AREA {
2749        return Err(Error::BadRequest(format!(
2750            "max_width * max_height must be <= {MAX_OUTPUT_AREA} pixels"
2751        )));
2752    }
2753    Ok(())
2754}
2755
2756/// Reject the request if the user already has
2757/// [`MAX_CONCURRENT_RENDERS_PER_USER`] or more renders in progress. The
2758/// count is not strictly atomic with the subsequent insert — two requests
2759/// arriving in the same millisecond could both pass — but the race window
2760/// is small and the cap exists to limit accidental DoS rather than to be a
2761/// hard quota.
2762async fn assert_under_concurrent_limit(db: &sqlx::SqlitePool, user_id: Uuid) -> Result<(), Error> {
2763    let count: i64 = sqlx::query_scalar(
2764        "SELECT COUNT(*) FROM saved_renders \
2765         WHERE created_by = ?1 AND status = 'in_progress'",
2766    )
2767    .bind(user_id.as_bytes().to_vec())
2768    .fetch_one(db)
2769    .await
2770    .map_err(|err| {
2771        tracing::error!("count in-progress renders failed: {err}");
2772        Error::Database
2773    })?;
2774    if count >= MAX_CONCURRENT_RENDERS_PER_USER {
2775        return Err(Error::Forbidden(format!(
2776            "at most {MAX_CONCURRENT_RENDERS_PER_USER} renders may be in progress per user; \
2777             wait for one to finish"
2778        )));
2779    }
2780    Ok(())
2781}
2782
2783/// Parse a possibly-empty u16 from a form field.
2784fn parse_optional_u16(s: &str) -> Result<Option<u16>, Error> {
2785    let trimmed = s.trim();
2786    if trimmed.is_empty() {
2787        return Ok(None);
2788    }
2789    trimmed
2790        .parse::<u16>()
2791        .map(Some)
2792        .map_err(|e| Error::BadRequest(format!("invalid u16 `{trimmed}`: {e}")))
2793}
2794
2795/// Parse a required u32 from a form field.
2796fn parse_u32(s: &str) -> Result<u32, Error> {
2797    s.trim()
2798        .parse::<u32>()
2799        .map_err(|e| Error::BadRequest(format!("invalid u32 `{s}`: {e}")))
2800}
2801
2802/// Interpret a multipart-form checkbox value as a boolean. Treats the usual
2803/// truthy spellings as `true` and everything else (including absence, handled
2804/// by the field never appearing) as `false`.
2805fn parse_form_bool(raw: &str) -> bool {
2806    matches!(
2807        raw.trim().to_ascii_lowercase().as_str(),
2808        "1" | "on" | "true" | "yes"
2809    )
2810}
2811
2812// =====================================================================
2813// GLW overlay plumbing — shared between the two render workers.
2814// =====================================================================
2815
2816/// Inputs the render worker needs to resolve and apply a GLW overlay.
2817struct GlwJobCtx {
2818    /// the user's GLW request payload as it arrived from the form.
2819    options: GlwRenderOptions,
2820    /// destination the render lives in (and the auto-saved GLW row
2821    /// will inherit).
2822    destination: Destination,
2823    /// avatar id to record as the GLW row's `created_by`.
2824    created_by: Uuid,
2825}
2826
2827/// Resolved GLW event ready to draw onto the map, paired with the
2828/// `saved_glw_data` row id the render should reference.
2829struct ResolvedGlwEvent {
2830    /// the deserialised event.
2831    event: sl_glw::GlwEvent,
2832    /// the saved_glw_data row this event lives in.
2833    glw_data_id: Uuid,
2834}
2835
2836/// Resolve the GLW source to an event plus the saved_glw_data row id.
2837/// For fresh-fetch sources (event id / key / pasted JSON) this inserts
2838/// a new `saved_glw_data` row. For the `SavedId` source it just reads
2839/// the existing row.
2840///
2841/// The freshly-inserted row is a first-class, named library item in its own
2842/// right: the GLW list query (`fetch_glw_data_for`) returns it regardless of
2843/// whether any render links it, and the user can rename or delete it from the
2844/// library's GLW tab. So a render that *fails* after this insert does not
2845/// strand a hidden orphan — the row stays a usable, user-managed library entry
2846/// (and, being unlinked, is freely deletable; the `ON DELETE RESTRICT` FK only
2847/// bites once a *successful* render references it). That is deliberate: it
2848/// keeps `Regenerate` stable and lets the user re-render from the already
2849/// fetched data without hitting the upstream again. Consequently we do *not*
2850/// garbage-collect "unreferenced" GLW rows — doing so would delete intentional
2851/// library items, unlike the render/logo *file* sweeper, whose targets have no
2852/// independent meaning.
2853async fn resolve_glw_event(
2854    state: &AppState,
2855    ctx: &GlwJobCtx,
2856) -> Result<Option<ResolvedGlwEvent>, Error> {
2857    match &ctx.options.source {
2858        GlwSource::SavedId { glw_data_id } => {
2859            let row =
2860                crate::library::assert_can_read_glw_data(&state.db, ctx.created_by, *glw_data_id)
2861                    .await?;
2862            // Enforce the same-library invariant the comment on `validate_logos`
2863            // documents: a render's saved GLW data must live in the render's own
2864            // scope, so it stays visible to (and deletable by) the same audience
2865            // and a group render never silently depends on a personal row.
2866            let glw_dest = crate::library::destination_from_columns(
2867                row.owner_user_id.clone(),
2868                row.owner_group_id.clone(),
2869            )?;
2870            if glw_dest != ctx.destination {
2871                return Err(Error::BadRequest(format!(
2872                    "GLW data {glw_data_id} is not in the same library as this render; \
2873                     copy it into the render's scope first"
2874                )));
2875            }
2876            let event: sl_glw::GlwEvent = serde_json::from_str(&row.payload_json)?;
2877            Ok(Some(ResolvedGlwEvent {
2878                event,
2879                glw_data_id: *glw_data_id,
2880            }))
2881        }
2882        GlwSource::EventId { event_id } => {
2883            let id = sl_glw::EventId::new(*event_id);
2884            let event_opt = {
2885                let mut cache = state.glw_event_cache.lock().await;
2886                cache.get_event_by_id(id).await?
2887            };
2888            let Some(event) = event_opt else {
2889                return Ok(None);
2890            };
2891            let now = Utc::now();
2892            let name = default_or_user_name(
2893                ctx.options.save_as.as_deref(),
2894                &format!(
2895                    "Event {event_id} fetched {ts}",
2896                    ts = now.format("%Y-%m-%d %H:%M UTC")
2897                ),
2898            );
2899            let payload_json = serde_json::to_string(&event)?;
2900            let glw_data_id = crate::routes::glw::insert_glw_data_row(
2901                state,
2902                &crate::routes::glw::InsertGlwData {
2903                    destination: ctx.destination,
2904                    created_by: ctx.created_by,
2905                    name: &name,
2906                    source_kind: crate::library::GlwDataSourceKind::EventId,
2907                    source_event_id: Some(*event_id),
2908                    source_event_key: None,
2909                    payload_json: &payload_json,
2910                    event_id: Some(event.event_id.get()),
2911                    event_key: Some(event.event_key.as_str()),
2912                    event_name: Some(event.event_name.as_str()),
2913                    fetched_at: now,
2914                },
2915            )
2916            .await?;
2917            Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2918        }
2919        GlwSource::EventKey { event_key } => {
2920            let key = sl_glw::GlwEventKey::new(event_key);
2921            let event_opt = {
2922                let mut cache = state.glw_event_cache.lock().await;
2923                cache.get_event_by_key(&key).await?
2924            };
2925            let Some(event) = event_opt else {
2926                return Ok(None);
2927            };
2928            let now = Utc::now();
2929            let name = default_or_user_name(
2930                ctx.options.save_as.as_deref(),
2931                &format!(
2932                    "Key \"{event_key}\" fetched {ts}",
2933                    ts = now.format("%Y-%m-%d %H:%M UTC")
2934                ),
2935            );
2936            let payload_json = serde_json::to_string(&event)?;
2937            let glw_data_id = crate::routes::glw::insert_glw_data_row(
2938                state,
2939                &crate::routes::glw::InsertGlwData {
2940                    destination: ctx.destination,
2941                    created_by: ctx.created_by,
2942                    name: &name,
2943                    source_kind: crate::library::GlwDataSourceKind::EventKey,
2944                    source_event_id: None,
2945                    source_event_key: Some(event_key.as_str()),
2946                    payload_json: &payload_json,
2947                    event_id: Some(event.event_id.get()),
2948                    event_key: Some(event.event_key.as_str()),
2949                    event_name: Some(event.event_name.as_str()),
2950                    fetched_at: now,
2951                },
2952            )
2953            .await?;
2954            Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2955        }
2956        GlwSource::PastedJson { payload } => {
2957            let event: sl_glw::GlwEvent = serde_json::from_str(payload)?;
2958            let now = Utc::now();
2959            let name = default_or_user_name(
2960                ctx.options.save_as.as_deref(),
2961                &format!("Pasted JSON {}", now.format("%Y-%m-%d %H:%M UTC")),
2962            );
2963            let payload_json = serde_json::to_string(&event)?;
2964            let glw_data_id = crate::routes::glw::insert_glw_data_row(
2965                state,
2966                &crate::routes::glw::InsertGlwData {
2967                    destination: ctx.destination,
2968                    created_by: ctx.created_by,
2969                    name: &name,
2970                    source_kind: crate::library::GlwDataSourceKind::PastedJson,
2971                    source_event_id: None,
2972                    source_event_key: None,
2973                    payload_json: &payload_json,
2974                    event_id: Some(event.event_id.get()),
2975                    event_key: Some(event.event_key.as_str()),
2976                    event_name: Some(event.event_name.as_str()),
2977                    fetched_at: now,
2978                },
2979            )
2980            .await?;
2981            Ok(Some(ResolvedGlwEvent { event, glw_data_id }))
2982        }
2983    }
2984}
2985
2986/// If the user supplied a non-empty name, return it trimmed; otherwise
2987/// return the generated default.
2988fn default_or_user_name(supplied: Option<&str>, default: &str) -> String {
2989    let trimmed = supplied.map(str::trim).filter(|s| !s.is_empty());
2990    match trimmed {
2991        Some(s) => s.to_owned(),
2992        None => default.to_owned(),
2993    }
2994}
2995
2996/// Apply a GLW overlay onto `map` if `ctx` is `Some`. Returns the id
2997/// of the `saved_glw_data` row the worker should reference on
2998/// `saved_renders.glw_data_id`, or `None` if no overlay was drawn
2999/// (no GLW requested, or the server returned no event).
3000async fn apply_glw_overlay_to_map(
3001    state: &AppState,
3002    ctx: Option<&GlwJobCtx>,
3003    map: &mut Map,
3004) -> Result<Option<Uuid>, Error> {
3005    use sl_glw::MapLikeGlwExt as _;
3006    let Some(ctx) = ctx else {
3007        return Ok(None);
3008    };
3009    // Resolve the font first so a missing-font request fails fast,
3010    // before any HTTP fetch or DB writes.
3011    let font_path = state
3012        .fonts
3013        .path_for(&ctx.options.font_id)
3014        .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", ctx.options.font_id)))?;
3015    let font = sl_map_apis::text::load_font(font_path)?;
3016
3017    let resolved = resolve_glw_event(state, ctx).await?;
3018    let Some(resolved) = resolved else {
3019        tracing::warn!("GLW event not found (server returned no event); rendering without overlay");
3020        return Ok(None);
3021    };
3022    let style = build_glw_style(&ctx.options.style, ctx.options.legend_slot.as_deref())?;
3023    map.draw_glw_event_with_font(&resolved.event, &style, &font);
3024    Ok(Some(resolved.glw_data_id))
3025}
3026
3027/// Start from [`sl_glw::GlwStyle::default`] with the legend placed in the
3028/// requested slot (defaulting to `TopLeft`), then layer the user's
3029/// per-element colour and toggle overrides.
3030fn build_glw_style(
3031    overrides: &GlwStyleOverrides,
3032    legend_slot: Option<&str>,
3033) -> Result<sl_glw::GlwStyle, Error> {
3034    let mut style = sl_glw::GlwStyle {
3035        legend_position: legend_position_from_slot(legend_slot)?,
3036        draw_margin_band: overrides.margin_band,
3037        ..sl_glw::GlwStyle::default()
3038    };
3039    if let Some(c) = overrides.area_outline_color.as_deref() {
3040        style.palette.area_outline = parse_color(c.trim())?;
3041    }
3042    if let Some(c) = overrides.circle_outline_color.as_deref() {
3043        style.palette.circle_outline = parse_color(c.trim())?;
3044    }
3045    if let Some(c) = overrides.margin_outline_color.as_deref() {
3046        style.palette.margin_outline = parse_color(c.trim())?;
3047    }
3048    if let Some(c) = overrides.wind_color.as_deref() {
3049        style.palette.wind_arrow = parse_color(c.trim())?;
3050    }
3051    if let Some(c) = overrides.current_color.as_deref() {
3052        style.palette.current_arrow = parse_color(c.trim())?;
3053    }
3054    if let Some(c) = overrides.wave_color.as_deref() {
3055        style.palette.wave_glyph = parse_color(c.trim())?;
3056    }
3057    if let Some(c) = overrides.label_color.as_deref() {
3058        style.palette.label_fg = parse_color(c.trim())?;
3059    }
3060    Ok(style)
3061}
3062
3063/// Map a legend-slot name to a placement slot (or `None` to hide it). Absent /
3064/// empty → `TopLeft` (back-compat); `"none"` → hidden; any of the nine slot
3065/// names → that slot; anything else is a bad request.
3066fn legend_position_from_slot(
3067    slot: Option<&str>,
3068) -> Result<Option<sl_map_apis::coverage::PlacementSlot>, Error> {
3069    use sl_map_apis::coverage::PlacementSlot;
3070    let name = match slot {
3071        None => return Ok(Some(PlacementSlot::TopLeft)),
3072        Some(s) => s.trim(),
3073    };
3074    match name {
3075        "" => Ok(Some(PlacementSlot::TopLeft)),
3076        "none" => Ok(None),
3077        other => other
3078            .parse::<PlacementSlot>()
3079            .map(Some)
3080            .map_err(|err| Error::BadRequest(err.to_string())),
3081    }
3082}
3083
3084/// The slot anchor the base legend occupies for this job (if any): `None`
3085/// when there is no GLW overlay, the legend is hidden, or the slot is
3086/// invalid (the invalid case fails earlier in `apply_glw_overlay_to_map`).
3087fn legend_slot_of(ctx: Option<&GlwJobCtx>) -> Option<sl_map_apis::coverage::PlacementSlot> {
3088    let ctx = ctx?;
3089    legend_position_from_slot(ctx.options.legend_slot.as_deref())
3090        .ok()
3091        .flatten()
3092}
3093
3094/// Parse an optional horizontal-alignment name; absent/empty → `None` (use
3095/// the slot default).
3096fn parse_h_align(value: Option<&str>) -> Result<Option<sl_map_apis::coverage::HAlign>, Error> {
3097    use sl_map_apis::coverage::HAlign;
3098    match value.map(str::trim).filter(|s| !s.is_empty()) {
3099        None => Ok(None),
3100        Some("left") => Ok(Some(HAlign::Left)),
3101        Some("center") => Ok(Some(HAlign::Center)),
3102        Some("right") => Ok(Some(HAlign::Right)),
3103        Some(other) => Err(Error::BadRequest(format!("invalid h_align `{other}`"))),
3104    }
3105}
3106
3107/// Parse an optional vertical-alignment name; absent/empty → `None` (use the
3108/// slot default).
3109fn parse_v_align(value: Option<&str>) -> Result<Option<sl_map_apis::coverage::VAlign>, Error> {
3110    use sl_map_apis::coverage::VAlign;
3111    match value.map(str::trim).filter(|s| !s.is_empty()) {
3112        None => Ok(None),
3113        Some("top") => Ok(Some(VAlign::Top)),
3114        Some("center") => Ok(Some(VAlign::Center)),
3115        Some("bottom") => Ok(Some(VAlign::Bottom)),
3116        Some(other) => Err(Error::BadRequest(format!("invalid v_align `{other}`"))),
3117    }
3118}
3119
3120/// Parse a placement's combined slot group from the request: each name in
3121/// `names` parsed to a [`PlacementSlot`], always including `anchor`. An empty
3122/// `names` (the common single-slot case, and old saved settings) yields just
3123/// `[anchor]`.
3124fn parse_slot_group(
3125    anchor: sl_map_apis::coverage::PlacementSlot,
3126    names: &[String],
3127) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, Error> {
3128    if names.is_empty() {
3129        return Ok(vec![anchor]);
3130    }
3131    let mut group: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::with_capacity(names.len());
3132    for name in names {
3133        let slot = name
3134            .parse::<sl_map_apis::coverage::PlacementSlot>()
3135            .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3136                Error::BadRequest(err.to_string())
3137            })?;
3138        if !group.contains(&slot) {
3139            group.push(slot);
3140        }
3141    }
3142    if !group.contains(&anchor) {
3143        group.push(anchor);
3144    }
3145    if !sl_map_apis::coverage::PlacementSlot::slots_form_rectangle(&group) {
3146        let joined = group
3147            .iter()
3148            .map(|s| s.as_str())
3149            .collect::<Vec<_>>()
3150            .join("+");
3151        return Err(Error::BadRequest(format!(
3152            "the combined slot group `{joined}` does not form a solid rectangle"
3153        )));
3154    }
3155    Ok(group)
3156}
3157
3158/// Resolve a placement to the slots it reserves and the pixel rectangle to fit
3159/// content into. A single-slot placement uses that slot's own free rectangle;
3160/// a combined placement (more than one slot in `group`) uses the largest free
3161/// rectangle within exactly those slots' thirds ([`OccupancyGrid::subset_rect`])
3162/// and reserves the whole group. Shared by labels and logos.
3163fn resolve_placement(
3164    anchor: sl_map_apis::coverage::PlacementSlot,
3165    group: &[sl_map_apis::coverage::PlacementSlot],
3166    slots: &[sl_map_apis::coverage::PlacementSlotInfo],
3167    grid: &sl_map_apis::coverage::OccupancyGrid,
3168) -> Result<
3169    (
3170        Vec<sl_map_apis::coverage::PlacementSlot>,
3171        sl_map_apis::coverage::PixelRect,
3172    ),
3173    Error,
3174> {
3175    if group.len() > 1 {
3176        let rect = grid.subset_rect(group).ok_or_else(|| {
3177            Error::BadRequest(format!(
3178                "the combined slot at `{anchor}` is fully covered; no room for content"
3179            ))
3180        })?;
3181        Ok((group.to_vec(), rect))
3182    } else {
3183        let info = slots
3184            .iter()
3185            .find(|info| info.slot == anchor)
3186            .ok_or_else(|| Error::BadRequest(format!("slot `{anchor}` not found")))?;
3187        let rect = info.free_rect.ok_or_else(|| {
3188            Error::BadRequest(format!(
3189                "slot `{anchor}` is fully covered; no room for content"
3190            ))
3191        })?;
3192        Ok((vec![anchor], rect))
3193    }
3194}
3195
3196/// Reserve a placement's slots in the shared pool, rejecting any clash with
3197/// the legend, with already-reserved slots from the *other* placement kind
3198/// (`others`), or with slots reserved earlier in this pass (`used`). `what`
3199/// names the placement kind for the error message.
3200fn reserve(
3201    reserved: &[sl_map_apis::coverage::PlacementSlot],
3202    used: &mut Vec<sl_map_apis::coverage::PlacementSlot>,
3203    others: &[sl_map_apis::coverage::PlacementSlot],
3204    legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3205    what: &str,
3206) -> Result<(), Error> {
3207    for &slot in reserved {
3208        if legend_slot == Some(slot) {
3209            return Err(Error::BadRequest(format!(
3210                "a {what} uses slot `{slot}` which is occupied by the legend"
3211            )));
3212        }
3213        if used.contains(&slot) || others.contains(&slot) {
3214            return Err(Error::BadRequest(format!(
3215                "two placements target the same slot `{slot}`"
3216            )));
3217        }
3218        used.push(slot);
3219    }
3220    Ok(())
3221}
3222
3223/// One accepted label, ready to draw by [`execute_labels`].
3224struct LabelDraw {
3225    /// the text, one entry per line.
3226    lines: Vec<String>,
3227    /// the resolved font to render with.
3228    font: ab_glyph::FontVec,
3229    /// colour, scale and shadow for the text.
3230    style: sl_map_apis::text::LabelStyle,
3231    /// top-left pixel origin within the image.
3232    origin: (i32, i32),
3233}
3234
3235/// One accepted logo (already scaled), ready to composite by
3236/// [`execute_logos`].
3237struct LogoDraw {
3238    /// the decoded (and optionally doubled) logo bitmap.
3239    img: image::RgbaImage,
3240    /// x pixel coordinate of the logo's top-left corner.
3241    x: i64,
3242    /// y pixel coordinate of the logo's top-left corner.
3243    y: i64,
3244}
3245
3246/// Plan the free-floating text labels against the free space measured on
3247/// `occupancy` (an overlay-only map carrying just the route + GLW shapes, so
3248/// the opaque base tiles do not count as covered). Rejects (as a `BadRequest`)
3249/// any label that overflows its (single-slot or spanned) free space, or whose
3250/// reserved slots clash with the legend, another label, or a slot already
3251/// reserved by a logo (`reserved_by_others`). Each label is aligned within its
3252/// free rectangle using its own alignment, defaulting to the slot's outward
3253/// alignment. Returns the draw list (to hand to [`execute_labels`]) and the
3254/// set of slots the labels reserved so logos can avoid them.
3255fn plan_labels(
3256    fonts: &crate::fonts::FontDirectory,
3257    labels: &[TextLabel],
3258    legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3259    reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3260    occupancy: &Map,
3261) -> Result<(Vec<LabelDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), Error> {
3262    let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
3263    let mut draws: Vec<LabelDraw> = Vec::new();
3264    if labels.is_empty() {
3265        return Ok((draws, used));
3266    }
3267    // Authoritative free space, measured once on the overlay-only map.
3268    let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
3269        occupancy,
3270        sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
3271    );
3272    let slots = grid.evaluate_slots();
3273    // Validate + reserve + measure, collecting one draw item per visible label
3274    // so nothing is drawn until every label has been accepted.
3275    for label in labels {
3276        let lines: Vec<String> = label.lines.clone();
3277        if lines.iter().all(|line| line.trim().is_empty()) {
3278            // a blank label draws nothing and reserves nothing
3279            continue;
3280        }
3281        if !(label.font_px.is_finite() && label.font_px > 0f32) {
3282            return Err(Error::BadRequest(format!(
3283                "label font size must be a positive number of pixels, got {}",
3284                label.font_px
3285            )));
3286        }
3287        let anchor: sl_map_apis::coverage::PlacementSlot =
3288            label
3289                .slot
3290                .parse()
3291                .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3292                    Error::BadRequest(err.to_string())
3293                })?;
3294        let group = parse_slot_group(anchor, &label.slots)?;
3295        let (reserved, rect) = resolve_placement(anchor, &group, &slots, &grid)?;
3296        reserve(
3297            &reserved,
3298            &mut used,
3299            reserved_by_others,
3300            legend_slot,
3301            "label",
3302        )?;
3303        let font_path = fonts
3304            .path_for(&label.font_id)
3305            .ok_or_else(|| Error::BadRequest(format!("unknown font_id `{}`", label.font_id)))?;
3306        let font = sl_map_apis::text::load_font(font_path)?;
3307        let color = parse_color(label.color.trim())?;
3308        let scale = ab_glyph::PxScale::from(label.font_px);
3309        let (text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
3310        if text_w > rect.width || text_h > rect.height {
3311            return Err(Error::BadRequest(format!(
3312                "label text renders at {text_w}x{text_h} px but the free area at slot `{anchor}` only has {}x{} px",
3313                rect.width, rect.height
3314            )));
3315        }
3316        // Align within the free rectangle: the label's own value, else the
3317        // slot's outward default.
3318        let (default_h, default_v) = anchor.default_alignment();
3319        let h = parse_h_align(label.h_align.as_deref())?.unwrap_or(default_h);
3320        let v = parse_v_align(label.v_align.as_deref())?.unwrap_or(default_v);
3321        let origin_x = rect.x.saturating_add(h.offset(text_w, rect.width));
3322        let origin_y = rect.y.saturating_add(v.offset(text_h, rect.height));
3323        draws.push(LabelDraw {
3324            lines,
3325            font,
3326            style: sl_map_apis::text::LabelStyle {
3327                scale,
3328                fg: color,
3329                shadow: Rgba([0, 0, 0, 180]),
3330                // Each line is aligned within the block by the label's own
3331                // horizontal alignment (the block is then placed in the slot).
3332                align: h,
3333            },
3334            origin: (
3335                i32::try_from(origin_x).unwrap_or(0),
3336                i32::try_from(origin_y).unwrap_or(0),
3337            ),
3338        });
3339    }
3340    Ok((draws, used))
3341}
3342
3343/// Draw a planned label list onto `map` (above the route and GLW overlay).
3344fn execute_labels(draws: &[LabelDraw], map: &mut Map) {
3345    use sl_map_apis::map_tiles::MapLike as _;
3346    for d in draws {
3347        map.draw_text_label(d.origin, &d.lines, &d.style, &d.font);
3348    }
3349}
3350
3351/// Plan and draw labels onto `map` in one step, measuring free space on `map`
3352/// itself. Used by tests where the draw target is also a faithful occupancy
3353/// source (a transparent overlay map); the real render measures occupancy on a
3354/// separate overlay-only map via [`plan_labels`] + [`execute_labels`] because
3355/// its base tiles are opaque.
3356#[cfg(test)]
3357fn draw_labels_on_map(
3358    fonts: &crate::fonts::FontDirectory,
3359    labels: &[TextLabel],
3360    legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3361    reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3362    map: &mut Map,
3363) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, Error> {
3364    let (draws, used) = plan_labels(fonts, labels, legend_slot, reserved_by_others, map)?;
3365    execute_labels(&draws, map);
3366    Ok(used)
3367}
3368
3369/// Composite the logo images onto `map`, last (above the route, GLW overlay
3370/// and labels). Each logo is drawn at its native pixel size (optionally
3371/// integer-doubled with nearest-neighbour sampling), alpha-blended, and
3372/// aligned within its (single-slot or spanned) free rectangle. Rejects (as a
3373/// `BadRequest`) any logo that overflows its free space, has an invalid
3374/// scale, or whose reserved slots clash with the legend, another logo, or a
3375/// slot already reserved by a label (`reserved_by_others`). Returns the set
3376/// of slots the logos reserved.
3377/// Plan the logo placements against the free space measured on `occupancy`
3378/// (an overlay-only map). Each logo is loaded, decoded, optionally
3379/// integer-doubled (nearest-neighbour), and aligned within its (single-slot or
3380/// spanned) free rectangle. Rejects (as a `BadRequest`) any logo that overflows
3381/// its free space, has an invalid scale, or whose reserved slots clash with the
3382/// legend, another logo, or a slot already reserved by a label
3383/// (`reserved_by_others`). Returns the draw list (for [`execute_logos`]) and
3384/// the set of slots the logos reserved.
3385async fn plan_logos(
3386    state: &AppState,
3387    logos: &[LogoPlacement],
3388    legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
3389    reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
3390    occupancy: &Map,
3391) -> Result<(Vec<LogoDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), Error> {
3392    let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
3393    let mut draws: Vec<LogoDraw> = Vec::new();
3394    if logos.is_empty() {
3395        return Ok((draws, used));
3396    }
3397    let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
3398        occupancy,
3399        sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
3400    );
3401    let slots = grid.evaluate_slots();
3402    // Validate + reserve + load + decode + scale + fit, so nothing is drawn
3403    // until every logo has been accepted.
3404    for logo in logos {
3405        if logo.scale != 1 && logo.scale != 2 && logo.scale != 4 {
3406            return Err(Error::BadRequest(format!(
3407                "logo scale must be 1, 2 or 4, got {}",
3408                logo.scale
3409            )));
3410        }
3411        let anchor: sl_map_apis::coverage::PlacementSlot =
3412            logo.slot
3413                .parse()
3414                .map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
3415                    Error::BadRequest(err.to_string())
3416                })?;
3417        let group = parse_slot_group(anchor, &logo.slots)?;
3418        let (reserved, rect) = resolve_placement(anchor, &group, &slots, &grid)?;
3419        reserve(
3420            &reserved,
3421            &mut used,
3422            reserved_by_others,
3423            legend_slot,
3424            "logo",
3425        )?;
3426        // Look up the stored file (read permission was checked at submit
3427        // time; the saved_render_logos link prevents deletion meanwhile).
3428        let row: Option<(String,)> =
3429            sqlx::query_as("SELECT image_filename FROM saved_logos WHERE logo_id = ?1")
3430                .bind(logo.logo_id.as_bytes().to_vec())
3431                .fetch_optional(&state.db)
3432                .await
3433                .map_err(|err| {
3434                    tracing::error!("logo file lookup failed: {err}");
3435                    Error::Database
3436                })?;
3437        let (image_filename,) = row
3438            .ok_or_else(|| Error::BadRequest(format!("logo {} no longer exists", logo.logo_id)))?;
3439        let bytes = storage::read_logo_file(&state.config.storage_dir, &image_filename).await?;
3440        let decoded = image::load_from_memory(&bytes).map_err(|e| {
3441            Error::BadRequest(format!("could not decode logo {}: {e}", logo.logo_id))
3442        })?;
3443        let mut rgba = decoded.to_rgba8();
3444        if logo.scale != 1 {
3445            let factor = u32::from(logo.scale);
3446            let (w, h) = (rgba.width(), rgba.height());
3447            rgba = image::imageops::resize(
3448                &rgba,
3449                w.saturating_mul(factor),
3450                h.saturating_mul(factor),
3451                image::imageops::FilterType::Nearest,
3452            );
3453        }
3454        let (w, h) = (rgba.width(), rgba.height());
3455        if w > rect.width || h > rect.height {
3456            return Err(Error::BadRequest(format!(
3457                "logo renders at {w}x{h} px but the free area at slot `{anchor}` only has {}x{} px",
3458                rect.width, rect.height
3459            )));
3460        }
3461        let (default_h, default_v) = anchor.default_alignment();
3462        let hh = parse_h_align(logo.h_align.as_deref())?.unwrap_or(default_h);
3463        let vv = parse_v_align(logo.v_align.as_deref())?.unwrap_or(default_v);
3464        let origin_x = rect.x.saturating_add(hh.offset(w, rect.width));
3465        let origin_y = rect.y.saturating_add(vv.offset(h, rect.height));
3466        draws.push(LogoDraw {
3467            img: rgba,
3468            x: i64::from(origin_x),
3469            y: i64::from(origin_y),
3470        });
3471    }
3472    Ok((draws, used))
3473}
3474
3475/// Composite a planned logo list onto `map` with alpha blending (above the
3476/// route, GLW overlay and labels).
3477fn execute_logos(draws: &[LogoDraw], map: &mut Map) {
3478    use sl_map_apis::map_tiles::MapLike as _;
3479    for d in draws {
3480        image::imageops::overlay(map.image_mut(), &d.img, d.x, d.y);
3481    }
3482}
3483
3484/// Resolve a notecard to its route's grid rectangle: the bare bounding box of
3485/// the waypoints plus the configured border padding. Shared by the submit-time
3486/// placement check and the render job so both measure the same rectangle.
3487async fn resolve_notecard_rect(
3488    state: &AppState,
3489    notecard: &USBNotecard,
3490    borders: (u16, u16, u16, u16),
3491) -> Result<(GridRectangle, GridRectangle), Error> {
3492    let bare_rect = {
3493        let mut region = state.region_cache.lock().await;
3494        usb_notecard_to_grid_rectangle(&mut region, notecard).await?
3495    };
3496    let (border_north, border_south, border_east, border_west) = borders;
3497    let rect = bare_rect
3498        .expanded_west(border_west)
3499        .expanded_east(border_east)
3500        .expanded_south(border_south)
3501        .expanded_north(border_north);
3502    Ok((bare_rect, rect))
3503}
3504
3505/// Build the overlay-only occupancy map (a blank base sized to the final image,
3506/// plus the GLW shapes and — for notecard renders — the route) and plan the
3507/// labels and logos against it, returning the draws to paint.
3508///
3509/// This is the single source of truth for placement fit. The submit handlers
3510/// call it before persisting a render so an over-full slot is rejected up front
3511/// (nothing is saved), and the render jobs call it to produce the draws they
3512/// composite onto the real map. Sharing one routine guarantees the pre-save
3513/// check and the job can never disagree about whether a placement fits. The
3514/// occupancy is measured on a blank base (the opaque map tiles would otherwise
3515/// count as fully covered), so this needs no map-tile fetch.
3516async fn plan_placements(
3517    state: &AppState,
3518    occ_rect: GridRectangle,
3519    common: &CommonParams,
3520    glw_ctx: Option<&GlwJobCtx>,
3521    route: Option<(&USBNotecard, Rgba<u8>)>,
3522    labels: &[TextLabel],
3523    logos: &[LogoPlacement],
3524) -> Result<(Vec<LabelDraw>, Vec<LogoDraw>), Error> {
3525    let legend_slot = legend_slot_of(glw_ctx);
3526    let occ = {
3527        let mut occ = Map::blank_fit(occ_rect, common.max_width, common.max_height)?;
3528        if let Some(ctx) = glw_ctx {
3529            apply_glw_overlay_readonly(state, ctx.created_by, &ctx.options, &mut occ).await?;
3530        }
3531        if let Some((notecard, color)) = route {
3532            let mut region = state.region_cache.lock().await;
3533            occ.draw_route_with_progress(&mut region, notecard, color, None)
3534                .await?;
3535        }
3536        occ
3537    };
3538    let (label_draws, label_slots) = plan_labels(&state.fonts, labels, legend_slot, &[], &occ)?;
3539    let (logo_draws, _) = plan_logos(state, logos, legend_slot, &label_slots, &occ).await?;
3540    Ok((label_draws, logo_draws))
3541}
3542
3543/// Validate that every logo placement references a logo the user may read
3544/// and that lives in the same library scope as the render (matching the
3545/// notecard/GLW same-scope invariant). Returns the de-duplicated list of
3546/// logo ids to link onto the render row.
3547async fn validate_logos(
3548    state: &AppState,
3549    current_user: Uuid,
3550    destination: Destination,
3551    logos: &[LogoPlacement],
3552) -> Result<Vec<Uuid>, Error> {
3553    let mut ids: Vec<Uuid> = Vec::new();
3554    for logo in logos {
3555        let row = library::assert_can_read_logo(&state.db, current_user, logo.logo_id).await?;
3556        let logo_dest = library::destination_from_columns(
3557            row.owner_user_id.clone(),
3558            row.owner_group_id.clone(),
3559        )?;
3560        if logo_dest != destination {
3561            return Err(Error::BadRequest(format!(
3562                "logo {} is not in the same library as this render; copy it into the render's \
3563                 scope first",
3564                logo.logo_id
3565            )));
3566        }
3567        if !ids.contains(&logo.logo_id) {
3568            ids.push(logo.logo_id);
3569        }
3570    }
3571    Ok(ids)
3572}
3573
3574/// Authorize *read* access to every logo referenced by a placement set,
3575/// without the same-library requirement [`validate_logos`] additionally
3576/// enforces. Used by the read-only placement-preview endpoints: they carry no
3577/// render destination (so the same-scope check does not apply), but must still
3578/// refuse to composite — and thereby disclose the pixels of — a logo the
3579/// current user cannot see. [`plan_logos`] itself loads the file by id with no
3580/// authorization, trusting its callers to have gated read access first.
3581async fn assert_can_read_logos(
3582    state: &AppState,
3583    current_user: Uuid,
3584    logos: &[LogoPlacement],
3585) -> Result<(), Error> {
3586    for logo in logos {
3587        library::assert_can_read_logo(&state.db, current_user, logo.logo_id).await?;
3588    }
3589    Ok(())
3590}
3591
3592/// Link the render's logos, marking the freshly-inserted render row `failed`
3593/// if the link step errors. Without this a [`link_render_logos`] failure
3594/// between [`insert_render_row`] and spawning the job would strand the
3595/// `in_progress` row forever — no job is ever spawned to fail it, so it would
3596/// show as a perpetual spinner and keep counting against the user's
3597/// concurrent-render cap.
3598async fn link_render_logos_or_fail(
3599    state: &AppState,
3600    render_id: Uuid,
3601    logo_ids: &[Uuid],
3602) -> Result<(), Error> {
3603    if let Err(err) = link_render_logos(state, render_id, logo_ids).await {
3604        update_failed(
3605            state,
3606            render_id,
3607            &format!("linking render logos failed: {err}"),
3608            Utc::now(),
3609        )
3610        .await;
3611        return Err(err);
3612    }
3613    Ok(())
3614}
3615
3616/// Insert the `saved_render_logos` link rows for a render. Idempotent per
3617/// (render, logo) pair.
3618async fn link_render_logos(
3619    state: &AppState,
3620    render_id: Uuid,
3621    logo_ids: &[Uuid],
3622) -> Result<(), Error> {
3623    for id in logo_ids {
3624        sqlx::query(
3625            "INSERT OR IGNORE INTO saved_render_logos (render_id, logo_id) VALUES (?1, ?2)",
3626        )
3627        .bind(render_id.as_bytes().to_vec())
3628        .bind(id.as_bytes().to_vec())
3629        .execute(&state.db)
3630        .await
3631        .map_err(|err| {
3632            tracing::error!("link render logo failed: {err}");
3633            Error::Database
3634        })?;
3635    }
3636    Ok(())
3637}
3638
3639/// Rewrite a `saved_renders.settings_json` so the carried GLW source
3640/// becomes a stable `SavedId` pointing at the resolved row. Returns
3641/// `Ok(None)` if the existing settings either has no GLW field or
3642/// already carries a `SavedId` (then the original JSON is left alone).
3643async fn rewrite_settings_json_with_saved_glw(
3644    state: &AppState,
3645    render_id: Uuid,
3646    glw_data_id: Uuid,
3647) -> Result<Option<String>, Error> {
3648    let row: Option<(String,)> =
3649        sqlx::query_as("SELECT settings_json FROM saved_renders WHERE render_id = ?1")
3650            .bind(render_id.as_bytes().to_vec())
3651            .fetch_optional(&state.db)
3652            .await
3653            .map_err(|err| {
3654                tracing::error!("settings_json fetch failed: {err}");
3655                Error::Database
3656            })?;
3657    let Some((settings_json,)) = row else {
3658        return Ok(None);
3659    };
3660    let mut parsed: SavedRenderSettings = serde_json::from_str(&settings_json)?;
3661    let glw_slot: &mut Option<GlwRenderOptions> = match &mut parsed {
3662        SavedRenderSettings::GridRectangle(s) => &mut s.glw,
3663        SavedRenderSettings::UsbNotecard(s) => &mut s.glw,
3664    };
3665    let Some(opts) = glw_slot.as_mut() else {
3666        return Ok(None);
3667    };
3668    // No-op if it's already SavedId (e.g. the user submitted a SavedId
3669    // and the worker simply re-read the row).
3670    if matches!(opts.source, GlwSource::SavedId { .. }) {
3671        return Ok(None);
3672    }
3673    opts.source = GlwSource::SavedId { glw_data_id };
3674    let json = serde_json::to_string(&parsed)?;
3675    Ok(Some(json))
3676}
3677
3678#[cfg(test)]
3679mod placement_slots_tests {
3680    use super::*;
3681    use pretty_assertions::assert_eq;
3682
3683    fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
3684        // a 4x4 region rectangle at zoom 4 -> 128x128 pixels
3685        let rect = GridRectangle::new(
3686            GridCoordinates::new(1000, 1000),
3687            GridCoordinates::new(1003, 1003),
3688        );
3689        Ok(Map::blank(rect, ZoomLevel::try_new(4)?))
3690    }
3691
3692    #[test]
3693    fn empty_map_reports_nine_free_slots() -> Result<(), Box<dyn std::error::Error>> {
3694        let map = blank_map()?;
3695        let resp = compute_placement_slots(&map, &[]);
3696        assert_eq!(resp.slots.len(), 9);
3697        assert_eq!((resp.image_width, resp.image_height), (128, 128));
3698        // with no overlay drawn (and the legend excluded by design) every
3699        // anchor is free, including the legend's default top-left corner
3700        for s in &resp.slots {
3701            assert!(s.available, "{} should be free", s.slot);
3702        }
3703        let top_left = resp
3704            .slots
3705            .iter()
3706            .find(|s| s.slot == "top_left")
3707            .ok_or("missing top_left slot")?;
3708        assert!(top_left.free_rect.is_some());
3709        Ok(())
3710    }
3711
3712    #[test]
3713    fn route_blocks_centre_slot() -> Result<(), Box<dyn std::error::Error>> {
3714        let mut map = blank_map()?;
3715        map.draw_pixel_waypoint_route(
3716            &[(10f32, 10f32), (64f32, 64f32), (118f32, 118f32)],
3717            Rgba([255, 0, 0, 255]),
3718        )?;
3719        let resp = compute_placement_slots(&map, &[]);
3720        let center = resp
3721            .slots
3722            .iter()
3723            .find(|s| s.slot == "center")
3724            .ok_or("missing center slot")?;
3725        assert!(!center.available);
3726        let top_right = resp
3727            .slots
3728            .iter()
3729            .find(|s| s.slot == "top_right")
3730            .ok_or("missing top_right slot")?;
3731        assert!(top_right.available);
3732        Ok(())
3733    }
3734
3735    #[test]
3736    fn requested_group_reports_combined_rect() -> Result<(), Box<dyn std::error::Error>> {
3737        use sl_map_apis::coverage::PlacementSlot as P;
3738        let map = blank_map()?;
3739        let resp = compute_placement_slots(&map, &[vec![P::TopLeft, P::TopCenter]]);
3740        assert_eq!(resp.groups.len(), 1);
3741        let g = resp.groups.first().ok_or("missing group")?;
3742        assert_eq!(g.slots, vec!["top_left", "top_center"]);
3743        assert!(g.available && g.free_rect.is_some());
3744        // the combined rect is wider than either single top slot
3745        let tl = resp
3746            .slots
3747            .iter()
3748            .find(|s| s.slot == "top_left")
3749            .ok_or("missing top_left")?;
3750        assert!(g.free_width > tl.free_width);
3751        Ok(())
3752    }
3753}
3754
3755#[cfg(test)]
3756mod glw_preview_tests {
3757    //! Tests for the drawing the `glw_preview` handler performs once the event
3758    //! is resolved: rendering the GLW overlay onto a transparent blank map at
3759    //! the preview zoom, with the legend excluded (it is placed separately by
3760    //! the placement-slot logic). The resolution / HTTP plumbing needs a full
3761    //! `AppState`, so these cover the pure drawing core instead.
3762    use super::*;
3763    use pretty_assertions::assert_eq;
3764    use sl_glw::{GlwEvent, MapLikeGlwExt as _};
3765
3766    /// Sample event with one area and one circle (same shape as the
3767    /// `sl-glw` render smoke test's fixture).
3768    const SAMPLE_JSON: &str = r#"{
3769        "eventId": 6910,
3770        "eventName": "test cruise",
3771        "eventKey": "key cruise",
3772        "directorName": "LaliaCasau Resident",
3773        "directorKey": "b609826a-b167-41e0-8e67-9fc0e78b97a1",
3774        "base": {
3775            "wind": { "dir": 175, "speed": 17, "gusts": 8, "shifts": 5, "period": 90 },
3776            "waves": {
3777                "height": 1.5, "speed": 3, "length": 35,
3778                "heightVar": 5, "lengthVar": 5,
3779                "effects": { "speed": 1, "steer": 1 }
3780            },
3781            "currents": { "speed": 0, "dir": 180, "waterDepth": 0 }
3782        },
3783        "areas": {
3784            "area1": {
3785                "coordSW": { "x": 1133, "y": 1048 },
3786                "coordNE": { "x": 1135, "y": 1049 },
3787                "margin": 25, "overlap": 0,
3788                "currents": { "speed": 1, "dir": 225, "waterDepth": 8 }
3789            }
3790        },
3791        "circles": {
3792            "circle1": {
3793                "centerSim": { "x": 1136, "y": 1051 },
3794                "centerPoint": { "x": 90, "y": 175 },
3795                "radius": 127, "margin": 25, "overlap": 0,
3796                "wind": { "speed": 15 },
3797                "currents": { "speed": 0.1, "dir": 225, "waterDepth": 6 }
3798            }
3799        }
3800    }"#;
3801
3802    /// A `FontDirectory` scanning the workspace root for the bundled
3803    /// `DejaVuSans.ttf`.
3804    fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
3805        let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
3806        let fonts = crate::fonts::FontDirectory::scan(root)?;
3807        let path = fonts
3808            .path_for("DejaVuSans.ttf")
3809            .ok_or("bundled DejaVuSans.ttf font is missing")?;
3810        Ok(sl_map_apis::text::load_font(path)?)
3811    }
3812
3813    /// The blank map the preview overlay is drawn onto: a transparent RGBA
3814    /// image whose dimensions are `pixels_per_region(zoom) * size`, matching
3815    /// the `boundsW`/`boundsH` the client computes for the bounds rectangle.
3816    /// This is the alignment contract between the overlay PNG and the tiles.
3817    #[test]
3818    fn blank_overlay_dimensions_match_client_bounds() -> Result<(), Box<dyn std::error::Error>> {
3819        let rect = GridRectangle::new(
3820            GridCoordinates::new(1130, 1045),
3821            GridCoordinates::new(1140, 1055),
3822        );
3823        let zoom = ZoomLevel::try_new(4)?;
3824        let map = Map::blank(rect, zoom);
3825        let (w, h) = image::GenericImageView::dimensions(&map);
3826        // 11 sims each way × 32 px/region at zoom 4.
3827        assert_eq!((w, h), (11 * 32, 11 * 32));
3828        // a fresh blank overlay is fully transparent, so it composites over the
3829        // tiles without altering them until shapes are drawn
3830        for y in 0..h {
3831            for x in 0..w {
3832                let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
3833                assert_eq!(a, 0, "blank overlay must start fully transparent");
3834            }
3835        }
3836        Ok(())
3837    }
3838
3839    /// Whether any non-transparent pixel falls inside the top-left `n×n`
3840    /// corner, where the default-slot legend would be drawn and where the
3841    /// sample event has no shapes (all shapes sit at sim x ≥ 1133, i.e. ≥ 96 px
3842    /// from the left).
3843    fn top_left_has_pixels(map: &Map, n: u32) -> bool {
3844        for y in 0..n {
3845            for x in 0..n {
3846                let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
3847                if a != 0 {
3848                    return true;
3849                }
3850            }
3851        }
3852        false
3853    }
3854
3855    /// The preview draws the shapes but omits the legend: drawing the event
3856    /// with the legend enabled fills the top-left corner, while the preview's
3857    /// legend-disabled style leaves that corner untouched.
3858    #[test]
3859    fn preview_overlay_omits_the_legend() -> Result<(), Box<dyn std::error::Error>> {
3860        let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
3861        let font = test_font()?;
3862        let rect = GridRectangle::new(
3863            GridCoordinates::new(1130, 1045),
3864            GridCoordinates::new(1140, 1055),
3865        );
3866        let zoom = ZoomLevel::try_new(4)?;
3867
3868        // Baseline: the legend in its default top-left slot writes pixels into
3869        // the top-left corner.
3870        let mut with_legend = Map::blank(rect.clone(), zoom);
3871        let style_with = build_glw_style(&GlwStyleOverrides::default(), Some("top_left"))?;
3872        with_legend.draw_glw_event_with_font(&event, &style_with, &font);
3873        assert!(
3874            top_left_has_pixels(&with_legend, 40),
3875            "the legend should fill the top-left corner when enabled"
3876        );
3877
3878        // Preview style: legend disabled exactly as `glw_preview` does. The
3879        // shape-free top-left corner stays fully transparent.
3880        let mut without_legend = Map::blank(rect, zoom);
3881        let mut style_without = build_glw_style(&GlwStyleOverrides::default(), None)?;
3882        style_without.legend_position = None;
3883        without_legend.draw_glw_event_with_font(&event, &style_without, &font);
3884        assert!(
3885            !top_left_has_pixels(&without_legend, 40),
3886            "the preview overlay must not draw the legend"
3887        );
3888
3889        // The shapes themselves are still drawn (the overlay isn't empty).
3890        let (w, h) = image::GenericImageView::dimensions(&without_legend);
3891        let mut any = false;
3892        'outer: for y in 0..h {
3893            for x in 0..w {
3894                let image::Rgba([_, _, _, a]) =
3895                    image::GenericImageView::get_pixel(&without_legend, x, y);
3896                if a != 0 {
3897                    any = true;
3898                    break 'outer;
3899                }
3900            }
3901        }
3902        assert!(any, "the GLW shapes should still be drawn");
3903        Ok(())
3904    }
3905}
3906
3907#[cfg(test)]
3908mod route_preview_tests {
3909    //! Tests for the drawing the `route_preview` handler performs: converting
3910    //! already-resolved waypoints to pixel coordinates and drawing the route
3911    //! (spline + arrows, in the route colour) onto a blank final-image-sized
3912    //! map — the very same `pixel_coordinates_for_coordinates` +
3913    //! `draw_pixel_waypoint_route` calls the handler makes. The auth / HTTP
3914    //! plumbing needs a full `AppState`, so these cover the pure drawing core.
3915    use super::*;
3916    use pretty_assertions::assert_eq;
3917    use sl_map_apis::map_tiles::MapLike as _;
3918
3919    /// Distinctive opaque colour the route is drawn in. The spline rectangles
3920    /// and arrow polygons write it verbatim (no anti-aliasing), so it can be
3921    /// matched exactly.
3922    const ROUTE_COLOR: Rgba<u8> = Rgba([255, 0, 0, 255]);
3923
3924    /// Pixel coordinates are bounded by the (capped) output dimensions, so they
3925    /// never approach `f32`'s 2^23 exact-integer ceiling — the same widening
3926    /// `draw_route_with_progress` and the handler perform.
3927    #[expect(
3928        clippy::as_conversions,
3929        clippy::cast_precision_loss,
3930        reason = "pixel coordinates are bounded by the output dimensions and never approach 2^23"
3931    )]
3932    fn widen(v: u32) -> f32 {
3933        v as f32
3934    }
3935
3936    /// Convert one resolved waypoint `(region_x, region_y, x, y)` to pixel
3937    /// coordinates exactly as the `route_preview` handler does.
3938    fn pixel_for(map: &Map, rx: u16, ry: u16, x: f32, y: f32) -> Option<(f32, f32)> {
3939        let (px, py) = map.pixel_coordinates_for_coordinates(
3940            &GridCoordinates::new(rx, ry),
3941            &RegionCoordinates::new(x, y, 0f32),
3942        )?;
3943        Some((widen(px), widen(py)))
3944    }
3945
3946    /// Shortest distance from point `p` to the line segment `a`–`b`.
3947    fn distance_to_segment(p: (f32, f32), a: (f32, f32), b: (f32, f32)) -> f32 {
3948        let (px, py) = p;
3949        let (ax, ay) = a;
3950        let (bx, by) = b;
3951        let (dx, dy) = (bx - ax, by - ay);
3952        let len_sq = (dx * dx) + (dy * dy);
3953        let t = if len_sq <= f32::EPSILON {
3954            0f32
3955        } else {
3956            ((((px - ax) * dx) + ((py - ay) * dy)) / len_sq).clamp(0f32, 1f32)
3957        };
3958        let (cx, cy) = (ax + (t * dx), ay + (t * dy));
3959        ((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
3960    }
3961
3962    /// The route is drawn in the requested colour over an otherwise fully
3963    /// transparent background: this is the alignment + colour contract the
3964    /// client relies on when compositing the PNG over the tiles.
3965    #[test]
3966    fn route_drawn_in_colour_on_transparent_background() -> Result<(), Box<dyn std::error::Error>> {
3967        let rect = GridRectangle::new(
3968            GridCoordinates::new(1000, 1000),
3969            GridCoordinates::new(1010, 1010),
3970        );
3971        let mut map = Map::blank_fit(rect, 512, 512)?;
3972        let pixel_waypoints = vec![
3973            pixel_for(&map, 1001, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?,
3974            pixel_for(&map, 1009, 1009, 128f32, 128f32).ok_or("waypoint outside rect")?,
3975        ];
3976        map.draw_pixel_waypoint_route(&pixel_waypoints, ROUTE_COLOR)?;
3977
3978        let (w, h) = image::GenericImageView::dimensions(&map);
3979        let mut coloured = 0u32;
3980        let mut foreign = 0u32;
3981        for y in 0..h {
3982            for x in 0..w {
3983                let image::Rgba([r, g, b, a]) = image::GenericImageView::get_pixel(&map, x, y);
3984                if image::Rgba([r, g, b, a]) == ROUTE_COLOR {
3985                    coloured += 1;
3986                } else if a != 0 {
3987                    // any non-transparent pixel that isn't the route colour
3988                    foreign += 1;
3989                }
3990            }
3991        }
3992        assert!(
3993            coloured > 0,
3994            "the route must be drawn in the requested colour"
3995        );
3996        assert_eq!(
3997            foreign, 0,
3998            "only the route colour and full transparency may appear"
3999        );
4000        Ok(())
4001    }
4002
4003    /// Three waypoints forming a peak are joined by a Catmull-Rom spline, not
4004    /// straight segments: at least one route pixel lies clearly off both
4005    /// straight chords between consecutive waypoints. This is what makes the
4006    /// preview match the curved output instead of the old straight polyline.
4007    #[test]
4008    fn route_curves_between_waypoints() -> Result<(), Box<dyn std::error::Error>> {
4009        let rect = GridRectangle::new(
4010            GridCoordinates::new(1000, 1000),
4011            GridCoordinates::new(1012, 1012),
4012        );
4013        let mut map = Map::blank_fit(rect, 512, 512)?;
4014        // A ∧-shaped arc: ends low, middle high. The tangent at the middle is
4015        // horizontal, so the spline bows away from the straight chords.
4016        let p0 = pixel_for(&map, 1001, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?;
4017        let p1 = pixel_for(&map, 1006, 1006, 128f32, 128f32).ok_or("waypoint outside rect")?;
4018        let p2 = pixel_for(&map, 1011, 1001, 128f32, 128f32).ok_or("waypoint outside rect")?;
4019        map.draw_pixel_waypoint_route(&[p0, p1, p2], ROUTE_COLOR)?;
4020
4021        let (w, h) = image::GenericImageView::dimensions(&map);
4022        let mut max_off = 0f32;
4023        for y in 0..h {
4024            for x in 0..w {
4025                if image::GenericImageView::get_pixel(&map, x, y) == ROUTE_COLOR {
4026                    let p = (widen(x), widen(y));
4027                    let off = distance_to_segment(p, p0, p1).min(distance_to_segment(p, p1, p2));
4028                    max_off = max_off.max(off);
4029                }
4030            }
4031        }
4032        assert!(
4033            max_off > 4f32,
4034            "the route should curve away from the straight chords (max off-chord \
4035             distance was {max_off:.1} px); a straight polyline would stay on them"
4036        );
4037        Ok(())
4038    }
4039}
4040
4041#[cfg(test)]
4042mod glw_legend_preview_tests {
4043    //! Tests for the drawing `glw_legend_preview` performs: rendering ONLY the
4044    //! base legend onto a blank final-image-sized map at its chosen slot. The
4045    //! resolution / HTTP plumbing needs a full `AppState`, so these cover the
4046    //! pure drawing core (the same `draw_glw_base_legend` call the handler
4047    //! makes) instead.
4048    use super::*;
4049    use pretty_assertions::assert_eq;
4050    use sl_glw::{GlwEvent, MapLikeGlwExt as _};
4051
4052    /// Minimal event with a non-zero base block (the legend only reads
4053    /// `base`); the empty `areas`/`circles` keep the legend the only content.
4054    const SAMPLE_JSON: &str = r#"{
4055        "eventId": 6910,
4056        "eventName": "test cruise",
4057        "eventKey": "key cruise",
4058        "directorName": "LaliaCasau Resident",
4059        "directorKey": "b609826a-b167-41e0-8e67-9fc0e78b97a1",
4060        "base": {
4061            "wind": { "dir": 175, "speed": 17, "gusts": 8, "shifts": 5, "period": 90 },
4062            "waves": {
4063                "height": 1.5, "speed": 3, "length": 35,
4064                "heightVar": 5, "lengthVar": 5,
4065                "effects": { "speed": 1, "steer": 1 }
4066            },
4067            "currents": { "speed": 0, "dir": 180, "waterDepth": 0 }
4068        },
4069        "areas": {},
4070        "circles": {}
4071    }"#;
4072
4073    /// A font loaded from the workspace's bundled `DejaVuSans.ttf`.
4074    fn test_font() -> Result<ab_glyph::FontVec, Box<dyn std::error::Error>> {
4075        let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
4076        let fonts = crate::fonts::FontDirectory::scan(root)?;
4077        let path = fonts
4078            .path_for("DejaVuSans.ttf")
4079            .ok_or("bundled DejaVuSans.ttf font is missing")?;
4080        Ok(sl_map_apis::text::load_font(path)?)
4081    }
4082
4083    /// True if any non-transparent pixel falls inside the `w`×`h` corner at
4084    /// `(ox, oy)`.
4085    fn corner_has_pixels(map: &Map, ox: u32, oy: u32, w: u32, h: u32) -> bool {
4086        for y in oy..oy.saturating_add(h) {
4087            for x in ox..ox.saturating_add(w) {
4088                let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
4089                if a != 0 {
4090                    return true;
4091                }
4092            }
4093        }
4094        false
4095    }
4096
4097    /// The legend is drawn only in its chosen slot and nowhere else: a
4098    /// `bottom_right` legend fills the bottom-right corner while leaving the
4099    /// top-left corner transparent, and no shapes are drawn (legend-only).
4100    #[test]
4101    fn legend_drawn_only_in_chosen_slot() -> Result<(), Box<dyn std::error::Error>> {
4102        let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
4103        let font = test_font()?;
4104        let rect = GridRectangle::new(
4105            GridCoordinates::new(1130, 1045),
4106            GridCoordinates::new(1140, 1055),
4107        );
4108        let mut map = Map::blank_fit(rect, 512, 512)?;
4109        let style = build_glw_style(&GlwStyleOverrides::default(), Some("bottom_right"))?;
4110        // Exactly what the handler does: draw the legend alone (no shapes).
4111        map.draw_glw_base_legend(&event.base, &style, &font);
4112        let (w, h) = image::GenericImageView::dimensions(&map);
4113        assert!(
4114            corner_has_pixels(&map, w.saturating_sub(80), h.saturating_sub(80), 80, 80),
4115            "the bottom_right legend should fill the bottom-right corner"
4116        );
4117        assert!(
4118            !corner_has_pixels(&map, 0, 0, 80, 80),
4119            "no legend should appear in the unselected top-left corner"
4120        );
4121        Ok(())
4122    }
4123
4124    /// A `none` legend slot yields `legend_position == None`, so the handler
4125    /// skips drawing and the map stays fully transparent.
4126    #[test]
4127    fn legend_slot_none_draws_nothing() -> Result<(), Box<dyn std::error::Error>> {
4128        let event: GlwEvent = serde_json::from_str(SAMPLE_JSON)?;
4129        let font = test_font()?;
4130        let rect = GridRectangle::new(
4131            GridCoordinates::new(1130, 1045),
4132            GridCoordinates::new(1140, 1055),
4133        );
4134        let mut map = Map::blank_fit(rect, 512, 512)?;
4135        let style = build_glw_style(&GlwStyleOverrides::default(), Some("none"))?;
4136        assert!(
4137            style.legend_position.is_none(),
4138            "the `none` slot must disable the legend"
4139        );
4140        // The handler only calls draw when legend_position.is_some(); mirror
4141        // that guard, then assert nothing was drawn.
4142        if style.legend_position.is_some() {
4143            map.draw_glw_base_legend(&event.base, &style, &font);
4144        }
4145        let (w, h) = image::GenericImageView::dimensions(&map);
4146        for y in 0..h {
4147            for x in 0..w {
4148                let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(&map, x, y);
4149                assert_eq!(a, 0, "a `none` legend slot must leave the map transparent");
4150            }
4151        }
4152        Ok(())
4153    }
4154}
4155
4156#[cfg(test)]
4157mod label_tests {
4158    use super::*;
4159    use pretty_assertions::assert_eq;
4160    use pretty_assertions::assert_matches;
4161    use sl_map_apis::coverage::{HAlign, PlacementSlot, VAlign};
4162
4163    /// A `FontDirectory` scanning the workspace root, which contains the
4164    /// bundled `DejaVuSans.ttf` (font id `DejaVuSans.ttf`).
4165    fn test_fonts() -> Result<crate::fonts::FontDirectory, Box<dyn std::error::Error>> {
4166        let root = std::path::PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/.."));
4167        Ok(crate::fonts::FontDirectory::scan(root)?)
4168    }
4169
4170    /// A blank 352x352 RGBA map (11 sims at zoom 4) — every slot fully free.
4171    fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
4172        let rect = GridRectangle::new(
4173            GridCoordinates::new(1130, 1130),
4174            GridCoordinates::new(1140, 1140),
4175        );
4176        Ok(Map::blank(rect, ZoomLevel::try_new(4)?))
4177    }
4178
4179    fn label(slot: &str, font_px: f32, lines: &[&str]) -> TextLabel {
4180        TextLabel {
4181            slot: slot.to_owned(),
4182            lines: lines.iter().map(|s| (*s).to_owned()).collect(),
4183            font_id: "DejaVuSans.ttf".to_owned(),
4184            font_px,
4185            color: "#ffffff".to_owned(),
4186            h_align: None,
4187            v_align: None,
4188            slots: Vec::new(),
4189        }
4190    }
4191
4192    fn any_pixel_drawn(map: &Map) -> bool {
4193        let (w, h) = image::GenericImageView::dimensions(map);
4194        for y in 0..h {
4195            for x in 0..w {
4196                let image::Rgba([_, _, _, a]) = image::GenericImageView::get_pixel(map, x, y);
4197                if a != 0 {
4198                    return true;
4199                }
4200            }
4201        }
4202        false
4203    }
4204
4205    /// A copy of `blank_map` filled fully opaque, modelling the real render's
4206    /// base tiles (`new_with_progress` builds an opaque RGB image).
4207    fn opaque_map() -> Result<Map, Box<dyn std::error::Error>> {
4208        use image::GenericImage as _;
4209        use sl_map_apis::map_tiles::MapLike as _;
4210        let mut map = blank_map()?;
4211        let (w, h) = image::GenericImageView::dimensions(&map);
4212        for y in 0..h {
4213            for x in 0..w {
4214                map.image_mut().put_pixel(x, y, image::Rgba([5, 5, 5, 255]));
4215            }
4216        }
4217        Ok(map)
4218    }
4219
4220    /// The split between planning (occupancy) and drawing (target) is what lets
4221    /// the real render place labels: planning against an overlay-only map finds
4222    /// free space and the result draws onto an opaque target, whereas planning
4223    /// against the opaque map itself finds no room (the bug the split fixes).
4224    #[test]
4225    fn labels_plan_on_overlay_then_draw_onto_opaque_target()
4226    -> Result<(), Box<dyn std::error::Error>> {
4227        let fonts = test_fonts()?;
4228        let lbls = [label("top_left", 18f32, &["Hi"])];
4229
4230        // Overlay-only occupancy (a transparent map) → the slot is free, the
4231        // label is planned, and it draws onto an opaque target.
4232        let occ = blank_map()?;
4233        let (draws, used) = plan_labels(&fonts, &lbls, None, &[], &occ)?;
4234        assert!(!draws.is_empty(), "the label should be planned");
4235        assert!(used.contains(&PlacementSlot::TopLeft));
4236        let mut target = opaque_map()?;
4237        execute_labels(&draws, &mut target);
4238        let (w, h) = image::GenericImageView::dimensions(&target);
4239        let mut changed = false;
4240        'outer: for y in 0..h {
4241            for x in 0..w {
4242                let image::Rgba([r, g, b, _]) = image::GenericImageView::get_pixel(&target, x, y);
4243                if (r, g, b) != (5, 5, 5) {
4244                    changed = true;
4245                    break 'outer;
4246                }
4247            }
4248        }
4249        assert!(changed, "the label must draw onto the opaque target");
4250
4251        // Planning against the opaque map directly finds no free space.
4252        let opaque_occ = opaque_map()?;
4253        assert!(
4254            plan_labels(&fonts, &lbls, None, &[], &opaque_occ).is_err(),
4255            "an opaque occupancy map leaves no room — the regression the split fixes"
4256        );
4257        Ok(())
4258    }
4259
4260    #[test]
4261    fn legend_position_from_slot_maps_names() -> Result<(), Box<dyn std::error::Error>> {
4262        use sl_map_apis::coverage::PlacementSlot as P;
4263        assert_eq!(legend_position_from_slot(None)?, Some(P::TopLeft));
4264        assert_eq!(legend_position_from_slot(Some(""))?, Some(P::TopLeft));
4265        assert_eq!(legend_position_from_slot(Some("none"))?, None);
4266        assert_eq!(legend_position_from_slot(Some("center"))?, Some(P::Center));
4267        assert_eq!(
4268            legend_position_from_slot(Some("bottom_right"))?,
4269            Some(P::BottomRight)
4270        );
4271        assert_matches!(legend_position_from_slot(Some("nonsense")), Err(_));
4272        Ok(())
4273    }
4274
4275    #[test]
4276    fn slot_parsers_and_alignment() -> Result<(), Box<dyn std::error::Error>> {
4277        assert_eq!(
4278            "middle_left".parse::<PlacementSlot>()?,
4279            PlacementSlot::MiddleLeft
4280        );
4281        assert_matches!("nope".parse::<PlacementSlot>(), Err(_));
4282        assert_eq!(parse_h_align(None)?, None);
4283        assert_eq!(parse_h_align(Some("right"))?, Some(HAlign::Right));
4284        assert_eq!(parse_v_align(Some("bottom"))?, Some(VAlign::Bottom));
4285        assert_matches!(parse_h_align(Some("sideways")), Err(_));
4286        Ok(())
4287    }
4288
4289    #[test]
4290    fn small_label_draws_and_oversized_is_rejected() -> Result<(), Box<dyn std::error::Error>> {
4291        let fonts = test_fonts()?;
4292
4293        let mut map = blank_map()?;
4294        draw_labels_on_map(
4295            &fonts,
4296            &[label("top_left", 18f32, &["Hi"])],
4297            None,
4298            &[],
4299            &mut map,
4300        )?;
4301        assert!(any_pixel_drawn(&map), "a fitting label should draw pixels");
4302
4303        // a font far too large to fit the 352px map is rejected
4304        let mut map2 = blank_map()?;
4305        let err = draw_labels_on_map(
4306            &fonts,
4307            &[label("center", 4000f32, &["BIG"])],
4308            None,
4309            &[],
4310            &mut map2,
4311        );
4312        assert_matches!(
4313            err,
4314            Err(Error::BadRequest(_)),
4315            "oversized label must be rejected"
4316        );
4317        Ok(())
4318    }
4319
4320    #[test]
4321    fn slot_collisions_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
4322        let fonts = test_fonts()?;
4323
4324        // two labels in the same slot
4325        let mut map = blank_map()?;
4326        let dup = draw_labels_on_map(
4327            &fonts,
4328            &[
4329                label("top_right", 16f32, &["a"]),
4330                label("top_right", 16f32, &["b"]),
4331            ],
4332            None,
4333            &[],
4334            &mut map,
4335        );
4336        assert_matches!(
4337            dup,
4338            Err(Error::BadRequest(_)),
4339            "duplicate slot must be rejected"
4340        );
4341
4342        // a label sharing the legend's slot
4343        let mut map2 = blank_map()?;
4344        let clash = draw_labels_on_map(
4345            &fonts,
4346            &[label("top_left", 16f32, &["x"])],
4347            Some(PlacementSlot::TopLeft),
4348            &[],
4349            &mut map2,
4350        );
4351        assert_matches!(
4352            clash,
4353            Err(Error::BadRequest(_)),
4354            "legend collision must be rejected"
4355        );
4356        Ok(())
4357    }
4358
4359    #[test]
4360    fn blank_label_is_skipped() -> Result<(), Box<dyn std::error::Error>> {
4361        let fonts = test_fonts()?;
4362        let mut map = blank_map()?;
4363        draw_labels_on_map(
4364            &fonts,
4365            &[label("center", 16f32, &["", "   "])],
4366            None,
4367            &[],
4368            &mut map,
4369        )?;
4370        assert!(!any_pixel_drawn(&map), "an all-blank label draws nothing");
4371        Ok(())
4372    }
4373
4374    #[test]
4375    fn reserve_rejects_legend_duplicate_and_other_pool() -> Result<(), Box<dyn std::error::Error>> {
4376        // a slot occupied by the legend is rejected
4377        let mut used = Vec::new();
4378        assert_matches!(
4379            reserve(
4380                &[PlacementSlot::TopLeft],
4381                &mut used,
4382                &[],
4383                Some(PlacementSlot::TopLeft),
4384                "label",
4385            ),
4386            Err(Error::BadRequest(_))
4387        );
4388
4389        // a slot reserved earlier in the same pool is rejected
4390        let mut used = Vec::new();
4391        reserve(&[PlacementSlot::TopRight], &mut used, &[], None, "label")?;
4392        assert_matches!(
4393            reserve(&[PlacementSlot::TopRight], &mut used, &[], None, "logo"),
4394            Err(Error::BadRequest(_))
4395        );
4396
4397        // a slot reserved by the other pool (e.g. a label) is rejected for a logo
4398        let mut used = Vec::new();
4399        assert_matches!(
4400            reserve(
4401                &[PlacementSlot::Center],
4402                &mut used,
4403                &[PlacementSlot::Center],
4404                None,
4405                "logo",
4406            ),
4407            Err(Error::BadRequest(_))
4408        );
4409        Ok(())
4410    }
4411
4412    #[test]
4413    fn resolve_placement_single_vs_combined() -> Result<(), Box<dyn std::error::Error>> {
4414        let map = blank_map()?;
4415        let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
4416            &map,
4417            sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
4418        );
4419        let slots = grid.evaluate_slots();
4420
4421        // a single slot reserves just the anchor and yields a positive rect
4422        let (reserved, rect) = resolve_placement(
4423            PlacementSlot::TopLeft,
4424            &[PlacementSlot::TopLeft],
4425            &slots,
4426            &grid,
4427        )?;
4428        assert_eq!(reserved, vec![PlacementSlot::TopLeft]);
4429        assert!(rect.width > 0 && rect.height > 0);
4430
4431        // a combined group reserves exactly the chosen slots and its rectangle
4432        // spans them (wider than the single top-left slot)
4433        let group = vec![PlacementSlot::TopLeft, PlacementSlot::TopCenter];
4434        let (all, combined) = resolve_placement(PlacementSlot::TopLeft, &group, &slots, &grid)?;
4435        assert_eq!(all, group);
4436        assert!(combined.width > rect.width);
4437        Ok(())
4438    }
4439
4440    #[test]
4441    fn parse_slot_group_defaults_and_includes_anchor() -> Result<(), Box<dyn std::error::Error>> {
4442        use sl_map_apis::coverage::PlacementSlot as P;
4443        // empty -> just the anchor
4444        assert_eq!(parse_slot_group(P::TopLeft, &[])?, vec![P::TopLeft]);
4445        // explicit names, anchor appended if missing, de-duplicated
4446        let g = parse_slot_group(
4447            P::TopLeft,
4448            &["top_center".to_owned(), "top_center".to_owned()],
4449        )?;
4450        assert_eq!(g, vec![P::TopCenter, P::TopLeft]);
4451        assert_matches!(parse_slot_group(P::TopLeft, &["nope".to_owned()]), Err(_));
4452        Ok(())
4453    }
4454
4455    #[test]
4456    fn parse_slot_group_rejects_non_rectangular_groups() {
4457        use sl_map_apis::coverage::PlacementSlot as P;
4458        // an adjacent pair forming a 1x2 rectangle is accepted
4459        assert_matches!(
4460            parse_slot_group(P::TopLeft, &["top_center".to_owned()]),
4461            Ok(_)
4462        );
4463        // a diagonal pair does not form a solid rectangle
4464        assert_matches!(
4465            parse_slot_group(P::TopLeft, &["bottom_right".to_owned()]),
4466            Err(_)
4467        );
4468        // an L-shape (anchor appended) is rejected too
4469        assert_matches!(
4470            parse_slot_group(
4471                P::TopLeft,
4472                &["top_center".to_owned(), "middle_left".to_owned()],
4473            ),
4474            Err(_)
4475        );
4476    }
4477
4478    #[test]
4479    fn parse_groups_rejects_non_rectangular_groups() {
4480        // a contiguous row is accepted
4481        assert_matches!(
4482            parse_groups(&[vec!["top_left".to_owned(), "top_center".to_owned()]]),
4483            Ok(_)
4484        );
4485        // a gapped column (top + bottom, missing middle) is rejected
4486        assert_matches!(
4487            parse_groups(&[vec!["top_left".to_owned(), "bottom_left".to_owned()]]),
4488            Err(_)
4489        );
4490    }
4491}
4492
4493#[cfg(test)]
4494mod region_overlay_tests {
4495    use super::*;
4496    use pretty_assertions::assert_eq;
4497
4498    #[test]
4499    fn text_overlay_gate_requires_both_size_and_count() {
4500        // both within bounds -> allowed
4501        assert!(region_text_overlay_allowed(
4502            MIN_PIXELS_PER_REGION_FOR_REGION_LABELS,
4503            MAX_REGIONS_FOR_REGION_LABELS
4504        ));
4505        // regions too small -> skipped even with a single region
4506        assert!(!region_text_overlay_allowed(
4507            MIN_PIXELS_PER_REGION_FOR_REGION_LABELS - 1.0,
4508            1
4509        ));
4510        // too many regions -> skipped even when each is large
4511        assert!(!region_text_overlay_allowed(
4512            256.0,
4513            MAX_REGIONS_FOR_REGION_LABELS + 1
4514        ));
4515        // a comfortably-sized, small render is allowed
4516        assert!(region_text_overlay_allowed(256.0, 4));
4517    }
4518
4519    #[test]
4520    fn rectangles_write_visible_pixels() -> Result<(), Box<dyn std::error::Error>> {
4521        // a 4x4 region map at zoom 4 -> 128x128 transparent pixels
4522        let rect = GridRectangle::new(
4523            GridCoordinates::new(1000, 1000),
4524            GridCoordinates::new(1003, 1003),
4525        );
4526        let mut map = Map::blank(rect, ZoomLevel::try_new(4)?);
4527        // before drawing, the map is fully transparent
4528        let before = (0..map.height())
4529            .flat_map(|y| (0..map.width()).map(move |x| (x, y)))
4530            .any(|(x, y)| {
4531                let image::Rgba([_, _, _, a]) = map.get_pixel(x, y);
4532                a != 0
4533            });
4534        assert!(!before, "blank map should start fully transparent");
4535
4536        draw_region_rectangles(&mut map);
4537
4538        // the region grid lines must now have written opaque white pixels
4539        let mut drawn = false;
4540        for y in 0..map.height() {
4541            for x in 0..map.width() {
4542                if map.get_pixel(x, y) == image::Rgba([255, 255, 255, 255]) {
4543                    drawn = true;
4544                }
4545            }
4546        }
4547        assert!(
4548            drawn,
4549            "draw_region_rectangles should write opaque white pixels"
4550        );
4551        Ok(())
4552    }
4553
4554    #[test]
4555    fn missing_region_fill_paints_the_region_rect() -> Result<(), Box<dyn std::error::Error>> {
4556        use sl_map_apis::map_tiles::MapLike as _;
4557        // a 2x2 region map at zoom 4 -> 64x64 px, 32 px per region.
4558        let rect = GridRectangle::new(
4559            GridCoordinates::new(1000, 1000),
4560            GridCoordinates::new(1001, 1001),
4561        );
4562        let mut map = Map::blank(rect, ZoomLevel::try_new(4)?);
4563        // Fill the lower-left region (grid 1000,1000) the way apply_region_overlay
4564        // does for an `Ok(None)` (missing) region.
4565        let grid = GridCoordinates::new(1000, 1000);
4566        let (left, top, width, height) =
4567            region_pixel_rect(&map, &grid).ok_or("region not in map")?;
4568        let fill = Rgba([0x19, 0x48, 0x5a, 0xff]);
4569        map.draw_filled_rect(left, top, width, height, fill);
4570
4571        // a pixel inside the filled region carries the colour …
4572        assert_eq!(
4573            map.get_pixel(left + 1, top + 1),
4574            fill,
4575            "fill colour painted"
4576        );
4577        // … while a pixel in the diagonally-opposite region stays transparent.
4578        let other = region_pixel_rect(&map, &GridCoordinates::new(1001, 1001))
4579            .ok_or("other region not in map")?;
4580        let image::Rgba([_, _, _, a]) = map.get_pixel(other.0 + 1, other.1 + 1);
4581        assert_eq!(a, 0, "unfilled region stays transparent");
4582        Ok(())
4583    }
4584}