#![doc = include_str!("../../README.md")]
use std::path::PathBuf;
use clap::Parser as _;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use sl_map_apis::map_tiles::{
Map, MapError, MapProgressEvent, MapTileCache, MapTileCacheError, TileOutcome,
};
use sl_map_apis::region::{
RegionNameToGridCoordinatesCache, USBNotecardToGridRectangleError,
usb_notecard_to_grid_rectangle,
};
use sl_types::map::{
GridCoordinates, GridRectangle, GridRectangleLike as _, USBNotecard, USBNotecardLoadError,
};
use tracing::instrument;
use tracing_subscriber::{
EnvFilter, Layer as _, Registry, filter::LevelFilter, layer::SubscriberExt as _,
util::SubscriberInitExt as _,
};
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("error when retrieving environment variable: {0}")]
EnvVarError(#[from] std::env::VarError),
#[error("error in CLI option parsing: {0}")]
ClapError(#[from] clap::Error),
#[error("error parsing log filter: {0}")]
LogFilterParseError(#[from] tracing_subscriber::filter::ParseError),
#[error("error in ratelimiter: {0}")]
RateLimiterError(#[from] ratelimit::Error),
#[error("error in map tile cache: {0}")]
MapTileCacheError(#[from] Box<MapTileCacheError>),
#[error("error in map generation: {0}")]
MapError(Box<MapError>),
#[error("error in image processing: {0}")]
ImageError(#[from] image::error::ImageError),
#[error("error loading USB notecard: {0}")]
USBNotecardLoadError(#[from] USBNotecardLoadError),
#[error("error in region name/grid coordinate cache: {0}")]
RegionNameCacheError(#[from] sl_map_apis::region::CacheError),
#[error("error converting a USB notecard to a grid rectangle: {0}")]
USBNotecardToGridRectangleError(#[from] USBNotecardToGridRectangleError),
#[error("error writing metadata output file: {0}")]
MetadataOutputFileError(#[source] std::io::Error),
#[error(
"GLW overlay requested but no font supplied; pass --font <path-to-ttf> \
(DejaVuSans.ttf is checked in at the workspace root)"
)]
FontRequired,
#[error(
"{0} requested but no font supplied; pass --font <path-to-ttf> (or a \
per-feature font path) — DejaVuSans.ttf is checked in at the workspace root"
)]
TextFontRequired(&'static str),
#[error("placement error: {0}")]
Placement(String),
#[error("invalid output dimensions: {0}")]
InvalidDimensions(String),
#[error("this subcommand requires --cache-dir <path>")]
CacheDirRequired,
#[error("file IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("error parsing font file: {0}")]
FontParseError(#[from] ab_glyph::InvalidFont),
#[error("error in GLW event cache: {0}")]
GlwEventCacheError(#[from] sl_glw::GlwEventCacheError),
#[error("GLW JSON error: {0}")]
GlwJsonError(#[from] serde_json::Error),
}
impl From<MapError> for Error {
fn from(value: MapError) -> Self {
Self::MapError(Box::new(value))
}
}
impl From<sl_map_apis::text::FontError> for Error {
fn from(value: sl_map_apis::text::FontError) -> Self {
match value {
sl_map_apis::text::FontError::Read { source, .. } => Self::IoError(source),
sl_map_apis::text::FontError::Parse(invalid) => Self::FontParseError(invalid),
}
}
}
#[derive(clap::Parser, Debug, Clone)]
pub struct FromGridRectangle {
#[clap(long)]
pub lower_left_x: u32,
#[clap(long)]
pub lower_left_y: u32,
#[clap(long)]
pub upper_right_x: u32,
#[clap(long)]
pub upper_right_y: u32,
#[clap(long, value_parser = parse_color)]
pub missing_map_tile_color: Option<image::Rgba<u8>>,
#[clap(long, value_parser = parse_color)]
pub missing_region_color: Option<image::Rgba<u8>>,
#[clap(long)]
pub max_width: u32,
#[clap(long)]
pub max_height: u32,
#[clap(long)]
pub output_file: PathBuf,
#[clap(long)]
pub metadata_output_file: Option<PathBuf>,
#[clap(flatten)]
pub placement: PlacementArgs,
#[clap(flatten)]
pub glw: GlwOverlayArgs,
}
impl From<&FromGridRectangle> for GridRectangle {
fn from(
&FromGridRectangle {
lower_left_x,
lower_left_y,
upper_right_x,
upper_right_y,
..
}: &FromGridRectangle,
) -> Self {
Self::new(
GridCoordinates::new(lower_left_x.to_owned(), lower_left_y.to_owned()),
GridCoordinates::new(upper_right_x.to_owned(), upper_right_y.to_owned()),
)
}
}
pub fn parse_color(s: &str) -> Result<image::Rgba<u8>, hex_color::ParseHexColorError> {
let hex_color = hex_color::HexColor::parse(s)?;
Ok(image::Rgba(hex_color.to_be_bytes()))
}
#[derive(clap::ValueEnum, Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum OutputFormat {
#[default]
Png,
#[clap(alias = "jpg")]
Jpeg,
}
impl OutputFormat {
const fn image_format(self) -> image::ImageFormat {
match self {
Self::Png => image::ImageFormat::Png,
Self::Jpeg => image::ImageFormat::Jpeg,
}
}
}
const fn default_logo_scale() -> u8 {
1
}
fn default_label_color() -> String {
"#ffffff".to_owned()
}
#[derive(serde::Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct LogoSpec {
file: PathBuf,
slot: String,
#[serde(default = "default_logo_scale")]
scale: u8,
#[serde(default)]
h_align: Option<String>,
#[serde(default)]
v_align: Option<String>,
}
#[derive(serde::Deserialize, Debug, Clone)]
#[serde(deny_unknown_fields)]
pub struct LabelSpec {
slot: String,
lines: Vec<String>,
#[serde(default)]
font: Option<PathBuf>,
font_px: f32,
#[serde(default = "default_label_color")]
color: String,
#[serde(default)]
h_align: Option<String>,
#[serde(default)]
v_align: Option<String>,
}
fn parse_logo_spec(s: &str) -> Result<LogoSpec, serde_json::Error> {
serde_json::from_str(s)
}
fn parse_label_spec(s: &str) -> Result<LabelSpec, serde_json::Error> {
serde_json::from_str(s)
}
fn parse_slot_group(slot: &str) -> Result<Vec<sl_map_apis::coverage::PlacementSlot>, crate::Error> {
use sl_map_apis::coverage::PlacementSlot;
let mut group: Vec<PlacementSlot> = Vec::new();
for name in slot.split('+') {
let name = name.trim();
let parsed: PlacementSlot =
name.parse()
.map_err(|err: sl_map_apis::coverage::ParsePlacementSlotError| {
crate::Error::Placement(err.to_string())
})?;
if !group.contains(&parsed) {
group.push(parsed);
}
}
if group.is_empty() {
return Err(crate::Error::Placement(
"no placement slot supplied".to_owned(),
));
}
if !PlacementSlot::slots_form_rectangle(&group) {
return Err(crate::Error::Placement(format!(
"the combined slot group `{slot}` does not form a solid rectangle"
)));
}
Ok(group)
}
fn parse_h_align(
value: Option<&str>,
) -> Result<Option<sl_map_apis::coverage::HAlign>, crate::Error> {
use sl_map_apis::coverage::HAlign;
match value.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some("left") => Ok(Some(HAlign::Left)),
Some("center") => Ok(Some(HAlign::Center)),
Some("right") => Ok(Some(HAlign::Right)),
Some(other) => Err(crate::Error::Placement(format!(
"invalid h_align `{other}`"
))),
}
}
fn parse_v_align(
value: Option<&str>,
) -> Result<Option<sl_map_apis::coverage::VAlign>, crate::Error> {
use sl_map_apis::coverage::VAlign;
match value.map(str::trim).filter(|s| !s.is_empty()) {
None => Ok(None),
Some("top") => Ok(Some(VAlign::Top)),
Some("center") => Ok(Some(VAlign::Center)),
Some("bottom") => Ok(Some(VAlign::Bottom)),
Some(other) => Err(crate::Error::Placement(format!(
"invalid v_align `{other}`"
))),
}
}
fn legend_position_from_slot(
slot: Option<&str>,
) -> Result<Option<sl_map_apis::coverage::PlacementSlot>, crate::Error> {
use sl_map_apis::coverage::PlacementSlot;
let name = match slot {
None => return Ok(Some(PlacementSlot::TopLeft)),
Some(s) => s.trim(),
};
match name {
"" => Ok(Some(PlacementSlot::TopLeft)),
"none" => Ok(None),
other => other
.parse::<PlacementSlot>()
.map(Some)
.map_err(|err| crate::Error::Placement(err.to_string())),
}
}
#[derive(clap::Args, Debug, Clone, Default)]
pub struct PlacementArgs {
#[clap(long)]
pub region_rectangles: bool,
#[clap(long)]
pub region_names: bool,
#[clap(long)]
pub region_coordinates: bool,
#[clap(long)]
pub region_font: Option<PathBuf>,
#[clap(long, value_enum, default_value_t = OutputFormat::Png)]
pub format: OutputFormat,
#[clap(long = "logo", value_parser = parse_logo_spec)]
pub logos: Vec<LogoSpec>,
#[clap(long = "label", value_parser = parse_label_spec)]
pub labels: Vec<LabelSpec>,
}
impl PlacementArgs {
const fn region_overlay(&self) -> RegionOverlayOptions {
RegionOverlayOptions {
rectangles: self.region_rectangles,
names: self.region_names,
coordinates: self.region_coordinates,
}
}
const fn has_placements(&self) -> bool {
!self.logos.is_empty() || !self.labels.is_empty()
}
}
#[derive(clap::Args, Debug, Clone, Default)]
pub struct GlwOverlayArgs {
#[clap(long, conflicts_with_all = ["glw_event_key", "glw_input_file"])]
pub glw_event_id: Option<u32>,
#[clap(long, conflicts_with_all = ["glw_event_id", "glw_input_file"])]
pub glw_event_key: Option<String>,
#[clap(long, conflicts_with_all = ["glw_event_id", "glw_event_key"])]
pub glw_input_file: Option<PathBuf>,
#[clap(long)]
pub glw_output_file: Option<PathBuf>,
#[clap(long)]
pub glw_base_url: Option<url::Url>,
#[clap(long)]
pub glw_margin_band: bool,
#[clap(long, value_parser = parse_color)]
pub glw_area_outline_color: Option<image::Rgba<u8>>,
#[clap(long, value_parser = parse_color)]
pub glw_circle_outline_color: Option<image::Rgba<u8>>,
#[clap(long, value_parser = parse_color)]
pub glw_margin_outline_color: Option<image::Rgba<u8>>,
#[clap(long, value_parser = parse_color)]
pub glw_wind_color: Option<image::Rgba<u8>>,
#[clap(long, value_parser = parse_color)]
pub glw_current_color: Option<image::Rgba<u8>>,
#[clap(long, value_parser = parse_color)]
pub glw_wave_color: Option<image::Rgba<u8>>,
#[clap(long, value_parser = parse_color)]
pub glw_area_fill_color: Option<image::Rgba<u8>>,
#[clap(long, value_parser = parse_color)]
pub glw_label_color: Option<image::Rgba<u8>>,
#[clap(long)]
pub glw_legend_slot: Option<String>,
}
enum GlwSource {
ById(sl_glw::EventId),
ByKey(sl_glw::GlwEventKey),
FromFile(PathBuf),
}
impl GlwOverlayArgs {
fn source(&self) -> Option<GlwSource> {
if let Some(id) = self.glw_event_id {
return Some(GlwSource::ById(sl_glw::EventId::new(id)));
}
if let Some(key) = self.glw_event_key.as_deref() {
return Some(GlwSource::ByKey(sl_glw::GlwEventKey::new(key)));
}
if let Some(path) = self.glw_input_file.as_ref() {
return Some(GlwSource::FromFile(path.clone()));
}
None
}
fn build_style(&self) -> Result<sl_glw::GlwStyle, crate::Error> {
let mut style = sl_glw::GlwStyle {
legend_position: legend_position_from_slot(self.glw_legend_slot.as_deref())?,
draw_margin_band: self.glw_margin_band,
..sl_glw::GlwStyle::default()
};
if let Some(c) = self.glw_area_outline_color {
style.palette.area_outline = c;
}
if let Some(c) = self.glw_circle_outline_color {
style.palette.circle_outline = c;
}
if let Some(c) = self.glw_margin_outline_color {
style.palette.margin_outline = c;
}
if let Some(c) = self.glw_wind_color {
style.palette.wind_arrow = c;
}
if let Some(c) = self.glw_current_color {
style.palette.current_arrow = c;
}
if let Some(c) = self.glw_wave_color {
style.palette.wave_glyph = c;
}
if let Some(c) = self.glw_area_fill_color {
style.palette.area_fill = Some(c);
}
if let Some(c) = self.glw_label_color {
style.palette.label_fg = c;
}
Ok(style)
}
fn legend_slot(&self) -> Option<sl_map_apis::coverage::PlacementSlot> {
self.source()?;
legend_position_from_slot(self.glw_legend_slot.as_deref())
.ok()
.flatten()
}
}
#[derive(clap::Parser, Debug, Clone)]
pub struct FromUSBNotecard {
#[clap(long)]
pub usb_notecard: PathBuf,
#[clap(long, value_parser = parse_color, default_value = "#f00")]
pub color: image::Rgba<u8>,
#[clap(
long,
conflicts_with_all = ["border_north", "border_south", "border_east", "border_west"],
)]
pub border_regions: Option<u16>,
#[clap(long)]
pub border_north: Option<u16>,
#[clap(long)]
pub border_south: Option<u16>,
#[clap(long)]
pub border_east: Option<u16>,
#[clap(long)]
pub border_west: Option<u16>,
#[clap(long, value_parser = parse_color)]
pub missing_map_tile_color: Option<image::Rgba<u8>>,
#[clap(long, value_parser = parse_color)]
pub missing_region_color: Option<image::Rgba<u8>>,
#[clap(long)]
pub max_width: u32,
#[clap(long)]
pub max_height: u32,
#[clap(long)]
pub output_file_without_route: Option<PathBuf>,
#[clap(long)]
pub output_file: PathBuf,
#[clap(long)]
pub metadata_output_file: Option<PathBuf>,
#[clap(flatten)]
pub placement: PlacementArgs,
#[clap(flatten)]
pub glw: GlwOverlayArgs,
}
#[derive(clap::Parser, Debug)]
pub enum Command {
FromGridRectangle(FromGridRectangle),
FromUSBNotecard(FromUSBNotecard),
PlacementSlots(PlacementSlots),
MeasureText(MeasureText),
}
#[derive(clap::Parser, Debug)]
pub struct PlacementSlots {
#[clap(long, requires_all = ["lower_left_y", "upper_right_x", "upper_right_y"],
conflicts_with = "usb_notecard")]
pub lower_left_x: Option<u32>,
#[clap(long)]
pub lower_left_y: Option<u32>,
#[clap(long)]
pub upper_right_x: Option<u32>,
#[clap(long)]
pub upper_right_y: Option<u32>,
#[clap(long)]
pub usb_notecard: Option<PathBuf>,
#[clap(long, value_parser = parse_color, default_value = "#f00")]
pub color: image::Rgba<u8>,
#[clap(long)]
pub max_width: u32,
#[clap(long)]
pub max_height: u32,
#[clap(long = "group")]
pub groups: Vec<String>,
#[clap(flatten)]
pub glw: GlwOverlayArgs,
}
#[derive(clap::Parser, Debug)]
pub struct MeasureText {
#[clap(long)]
pub font: Option<PathBuf>,
#[clap(long)]
pub font_px: f32,
#[clap(long = "line", required = true)]
pub lines: Vec<String>,
}
#[derive(clap::Parser, Debug)]
#[clap(name = clap::crate_name!(),
about = clap::crate_description!(),
author = clap::crate_authors!(),
version = clap::crate_version!(),
)]
struct Options {
#[clap(long)]
cache_dir: Option<PathBuf>,
#[clap(long)]
font: Option<PathBuf>,
#[clap(subcommand)]
command: Command,
}
fn output_metadata(
grid_rectangle: &GridRectangle,
metadata_output_file: Option<&PathBuf>,
) -> Result<(), crate::Error> {
let pps_config = format!("PPS HUD config: {}", grid_rectangle.pps_hud_config());
let aspect = format!(
"The aspect ratio of the image is {}:{} ({})",
grid_rectangle.size_x(),
grid_rectangle.size_y(),
f64::from(grid_rectangle.size_x()) / f64::from(grid_rectangle.size_y())
);
let note =
"You can use this to edit e.g. the PPS HUD to have the correct ratio of width and height";
println!("{pps_config}");
println!("{aspect}");
println!("{note}");
if let Some(path) = metadata_output_file {
fs_err::write(path, format!("{pps_config}\n{aspect}\n{note}\n"))
.map_err(crate::Error::MetadataOutputFileError)?;
}
Ok(())
}
async fn report_progress(mut rx: tokio::sync::mpsc::Receiver<MapProgressEvent>) {
let multi = MultiProgress::new();
let bar_style = ProgressStyle::with_template(
"{prefix:<14} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {pos}/{len} {msg}",
)
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("=>-");
let mut tiles: Option<ProgressBar> = None;
let mut regions: Option<ProgressBar> = None;
let mut waypoints: Option<ProgressBar> = None;
let mut region_names: Option<ProgressBar> = None;
let mut memory_hits: u64 = 0;
let mut disk_hits: u64 = 0;
let mut network_fetches: u64 = 0;
let mut missing_tiles: u64 = 0;
while let Some(event) = rx.recv().await {
match event {
MapProgressEvent::PlanComputed {
zoom_level,
total_tiles,
} => {
let pb = multi.add(ProgressBar::new(u64::from(total_tiles)));
pb.set_style(bar_style.to_owned());
pb.set_prefix(format!("tiles z={}", zoom_level.into_inner()));
tiles = Some(pb);
}
MapProgressEvent::TileStarted { .. } => {}
MapProgressEvent::TileFinished { outcome, .. } => {
match outcome {
TileOutcome::LoadedFromMemoryCache => {
memory_hits = memory_hits.saturating_add(1);
}
TileOutcome::LoadedFromDiskCache => {
disk_hits = disk_hits.saturating_add(1);
}
TileOutcome::FetchedFromNetwork => {
network_fetches = network_fetches.saturating_add(1);
}
TileOutcome::Missing => {
missing_tiles = missing_tiles.saturating_add(1);
}
}
if let Some(pb) = tiles.as_ref() {
pb.inc(1);
pb.set_message(format!(
"mem={memory_hits} disk={disk_hits} net={network_fetches} missing={missing_tiles}"
));
}
}
MapProgressEvent::RegionCheckPlanned { total_regions } => {
let pb = multi.add(ProgressBar::new(u64::from(total_regions)));
pb.set_style(bar_style.to_owned());
pb.set_prefix("regions");
regions = Some(pb);
}
MapProgressEvent::RegionChecked { .. } => {
if let Some(pb) = regions.as_ref() {
pb.inc(1);
}
}
MapProgressEvent::RoutePlanned { total_waypoints } => {
let total = u64::try_from(total_waypoints).unwrap_or(u64::MAX);
let pb = multi.add(ProgressBar::new(total));
pb.set_style(bar_style.to_owned());
pb.set_prefix("waypoints");
waypoints = Some(pb);
}
MapProgressEvent::RouteWaypointResolved { region, .. } => {
if let Some(pb) = waypoints.as_ref() {
pb.inc(1);
pb.set_message(region.into_inner());
}
}
MapProgressEvent::RegionNamesPlanned { total_regions } => {
let pb = multi.add(ProgressBar::new(u64::from(total_regions)));
pb.set_style(bar_style.to_owned());
pb.set_prefix("region names");
region_names = Some(pb);
}
MapProgressEvent::RegionNameResolved { .. } => {
if let Some(pb) = region_names.as_ref() {
pb.inc(1);
}
}
}
}
if let Some(pb) = tiles {
pb.finish();
}
if let Some(pb) = regions {
pb.finish();
}
if let Some(pb) = waypoints {
pb.finish();
}
}
async fn join_progress_task(handle: tokio::task::JoinHandle<()>) {
if let Err(err) = handle.await {
tracing::warn!("progress task did not finish cleanly: {err}");
}
}
async fn resolve_glw_event(
cache_dir: &std::path::Path,
args: &GlwOverlayArgs,
) -> Result<Option<sl_glw::GlwEvent>, crate::Error> {
let Some(source) = args.source() else {
return Ok(None);
};
let event = match source {
GlwSource::ById(id) => {
let mut cache =
sl_glw::GlwEventCache::new(cache_dir.to_owned(), args.glw_base_url.clone())?;
cache.get_event_by_id(id).await?
}
GlwSource::ByKey(key) => {
let mut cache =
sl_glw::GlwEventCache::new(cache_dir.to_owned(), args.glw_base_url.clone())?;
cache.get_event_by_key(&key).await?
}
GlwSource::FromFile(path) => {
let json = fs_err::read_to_string(&path)?;
Some(serde_json::from_str::<sl_glw::GlwEvent>(&json)?)
}
};
Ok(event)
}
async fn fetch_and_draw_glw(
cache_dir: &std::path::Path,
font_path: Option<&std::path::Path>,
args: &GlwOverlayArgs,
map: &mut Map,
) -> Result<bool, crate::Error> {
use sl_glw::MapLikeGlwExt as _;
if args.source().is_none() {
return Ok(false);
}
let Some(font_path) = font_path else {
return Err(crate::Error::FontRequired);
};
let font = sl_map_apis::text::load_font(font_path)?;
let Some(event) = resolve_glw_event(cache_dir, args).await? else {
tracing::warn!(
"GLW event not found (server returned no event); rendering map without overlay"
);
return Ok(false);
};
if let Some(output_path) = args.glw_output_file.as_ref() {
let json = serde_json::to_string_pretty(&event)?;
fs_err::write(output_path, json)?;
}
let style = args.build_style()?;
map.draw_glw_event_with_font(&event, &style, &font);
Ok(true)
}
const MIN_PIXELS_PER_REGION_FOR_REGION_LABELS: f32 = 64.0;
const MAX_REGIONS_FOR_REGION_LABELS: usize = 1024;
const REGION_LABEL_FONT_FACTOR: f32 = 0.12;
const REGION_LABEL_FONT_MIN_PX: f32 = 8.0;
const REGION_LABEL_FONT_MAX_PX: f32 = 22.0;
const REGION_LABEL_PADDING: i32 = 3;
const REGION_RECTANGLE_COLOR: image::Rgba<u8> = image::Rgba([255, 255, 255, 255]);
#[derive(Debug, Clone, Copy, Default)]
struct RegionOverlayOptions {
rectangles: bool,
names: bool,
coordinates: bool,
}
impl RegionOverlayOptions {
const fn any(self) -> bool {
self.rectangles || self.names || self.coordinates
}
const fn any_text(self) -> bool {
self.names || self.coordinates
}
}
const fn region_text_overlay_allowed(pixels_per_region: f32, region_count: usize) -> bool {
pixels_per_region >= MIN_PIXELS_PER_REGION_FOR_REGION_LABELS
&& region_count <= MAX_REGIONS_FOR_REGION_LABELS
}
fn region_pixel_rect(map: &Map, grid: &GridCoordinates) -> Option<(u32, u32, u32, u32)> {
use sl_map_apis::map_tiles::MapLike as _;
let (x0, y0) = map.pixel_coordinates_for_coordinates(
grid,
&sl_types::map::RegionCoordinates::new(0f32, 0f32, 0f32),
)?;
let (x1, y1) = map.pixel_coordinates_for_coordinates(
grid,
&sl_types::map::RegionCoordinates::new(256f32, 256f32, 0f32),
)?;
Some((x0.min(x1), y0.min(y1), x0.abs_diff(x1), y0.abs_diff(y1)))
}
fn draw_region_rectangles(map: &mut Map) {
use sl_map_apis::map_tiles::MapLike as _;
use sl_types::map::GridRectangleLike as _;
for x in map.x_range() {
for y in map.y_range() {
let grid = GridCoordinates::new(x, y);
if let Some((left, top, width, height)) = region_pixel_rect(map, &grid) {
map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
}
}
}
}
async fn apply_region_overlay(
opts: RegionOverlayOptions,
font_path: Option<&std::path::Path>,
region_cache: &mut RegionNameToGridCoordinatesCache,
map: &mut Map,
) -> Result<(), crate::Error> {
use sl_map_apis::map_tiles::MapLike as _;
use sl_types::map::GridRectangleLike as _;
if !opts.any() {
return Ok(());
}
let pixels_per_region = map.pixels_per_region();
let region_count =
usize::try_from(u64::from(map.size_x()).saturating_mul(u64::from(map.size_y())))
.unwrap_or(usize::MAX);
let run_loop = opts.any_text() && region_text_overlay_allowed(pixels_per_region, region_count);
if !run_loop {
if opts.any_text() {
tracing::info!(
pixels_per_region,
region_count,
"skipping region name/coordinate overlay (regions too small or too many)"
);
}
if opts.rectangles {
draw_region_rectangles(map);
}
return Ok(());
}
let font_path = font_path.ok_or(crate::Error::TextFontRequired(
"region name/coordinate overlay",
))?;
let font = sl_map_apis::text::load_font(font_path)?;
let scale = ab_glyph::PxScale::from(
(pixels_per_region * REGION_LABEL_FONT_FACTOR)
.clamp(REGION_LABEL_FONT_MIN_PX, REGION_LABEL_FONT_MAX_PX),
);
let style = sl_map_apis::text::LabelStyle {
scale,
fg: image::Rgba([255, 255, 255, 255]),
shadow: image::Rgba([0, 0, 0, 180]),
align: sl_map_apis::coverage::HAlign::Left,
};
for x in map.x_range() {
for y in map.y_range() {
let grid = GridCoordinates::new(x, y);
let Some((left, top, width, height)) = region_pixel_rect(map, &grid) else {
continue;
};
let mut region_name: Option<String> = None;
if opts.names {
match region_cache.get_region_name(&grid).await {
Ok(Some(name)) => region_name = Some(name.to_string()),
Ok(None) => {}
Err(err) => {
tracing::debug!("region name lookup failed for {grid:?}: {err}");
}
}
}
if opts.rectangles {
map.draw_hollow_rect(left, top, width, height, REGION_RECTANGLE_COLOR);
}
let mut lines: Vec<String> = Vec::new();
if opts.coordinates {
lines.push(format!("({x}, {y})"));
}
if let Some(name) = region_name {
lines.push(name);
}
if lines.is_empty() {
continue;
}
let (_text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
let bottom = top.saturating_add(height);
let origin_x = i32::try_from(left)
.unwrap_or(0)
.saturating_add(REGION_LABEL_PADDING);
let origin_y = i32::try_from(bottom)
.unwrap_or(0)
.saturating_sub(REGION_LABEL_PADDING)
.saturating_sub(i32::try_from(text_h).unwrap_or(0));
map.draw_text_label((origin_x, origin_y), &lines, &style, &font);
}
}
Ok(())
}
struct LabelDraw {
lines: Vec<String>,
font: ab_glyph::FontVec,
style: sl_map_apis::text::LabelStyle,
origin: (i32, i32),
}
struct LogoDraw {
img: image::RgbaImage,
x: i64,
y: i64,
}
fn resolve_placement(
group: &[sl_map_apis::coverage::PlacementSlot],
slots: &[sl_map_apis::coverage::PlacementSlotInfo],
grid: &sl_map_apis::coverage::OccupancyGrid,
) -> Result<
(
Vec<sl_map_apis::coverage::PlacementSlot>,
sl_map_apis::coverage::PixelRect,
),
crate::Error,
> {
let anchor = *group
.first()
.ok_or_else(|| crate::Error::Placement("empty slot group".to_owned()))?;
if group.len() > 1 {
let rect = grid.subset_rect(group).ok_or_else(|| {
crate::Error::Placement(format!(
"the combined slot at `{anchor}` is fully covered; no room for content"
))
})?;
Ok((group.to_vec(), rect))
} else {
let info = slots
.iter()
.find(|info| info.slot == anchor)
.ok_or_else(|| crate::Error::Placement(format!("slot `{anchor}` not found")))?;
let rect = info.free_rect.ok_or_else(|| {
crate::Error::Placement(format!(
"slot `{anchor}` is fully covered; no room for content"
))
})?;
Ok((vec![anchor], rect))
}
}
fn reserve(
reserved: &[sl_map_apis::coverage::PlacementSlot],
used: &mut Vec<sl_map_apis::coverage::PlacementSlot>,
others: &[sl_map_apis::coverage::PlacementSlot],
legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
what: &str,
) -> Result<(), crate::Error> {
for &slot in reserved {
if legend_slot == Some(slot) {
return Err(crate::Error::Placement(format!(
"a {what} uses slot `{slot}` which is occupied by the legend"
)));
}
if used.contains(&slot) || others.contains(&slot) {
return Err(crate::Error::Placement(format!(
"two placements target the same slot `{slot}`"
)));
}
used.push(slot);
}
Ok(())
}
fn plan_labels(
global_font: Option<&std::path::Path>,
labels: &[LabelSpec],
legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
occupancy: &Map,
) -> Result<(Vec<LabelDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), crate::Error> {
let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
let mut draws: Vec<LabelDraw> = Vec::new();
if labels.is_empty() {
return Ok((draws, used));
}
let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
occupancy,
sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
);
let slots = grid.evaluate_slots();
for label in labels {
let lines: Vec<String> = label.lines.clone();
if lines.iter().all(|line| line.trim().is_empty()) {
continue;
}
if !(label.font_px.is_finite() && label.font_px > 0f32) {
return Err(crate::Error::Placement(format!(
"label font size must be a positive number of pixels, got {}",
label.font_px
)));
}
let group = parse_slot_group(&label.slot)?;
let anchor = *group
.first()
.ok_or_else(|| crate::Error::Placement("empty slot group".to_owned()))?;
let (reserved, rect) = resolve_placement(&group, &slots, &grid)?;
reserve(
&reserved,
&mut used,
reserved_by_others,
legend_slot,
"label",
)?;
let font_path = label
.font
.as_deref()
.or(global_font)
.ok_or(crate::Error::TextFontRequired("a text label"))?;
let font = sl_map_apis::text::load_font(font_path)?;
let color = parse_color(label.color.trim()).map_err(|err| {
crate::Error::Placement(format!("invalid label color `{}`: {err}", label.color))
})?;
let scale = ab_glyph::PxScale::from(label.font_px);
let (text_w, text_h) = sl_map_apis::text::measure_text(scale, &font, &lines);
if text_w > rect.width || text_h > rect.height {
return Err(crate::Error::Placement(format!(
"label text renders at {text_w}x{text_h} px but the free area at slot `{anchor}` only has {}x{} px",
rect.width, rect.height
)));
}
let (default_h, default_v) = anchor.default_alignment();
let h = parse_h_align(label.h_align.as_deref())?.unwrap_or(default_h);
let v = parse_v_align(label.v_align.as_deref())?.unwrap_or(default_v);
let origin_x = rect.x.saturating_add(h.offset(text_w, rect.width));
let origin_y = rect.y.saturating_add(v.offset(text_h, rect.height));
draws.push(LabelDraw {
lines,
font,
style: sl_map_apis::text::LabelStyle {
scale,
fg: color,
shadow: image::Rgba([0, 0, 0, 180]),
align: h,
},
origin: (
i32::try_from(origin_x).unwrap_or(0),
i32::try_from(origin_y).unwrap_or(0),
),
});
}
Ok((draws, used))
}
fn execute_labels(draws: &[LabelDraw], map: &mut Map) {
use sl_map_apis::map_tiles::MapLike as _;
for d in draws {
map.draw_text_label(d.origin, &d.lines, &d.style, &d.font);
}
}
fn plan_logos(
logos: &[LogoSpec],
legend_slot: Option<sl_map_apis::coverage::PlacementSlot>,
reserved_by_others: &[sl_map_apis::coverage::PlacementSlot],
occupancy: &Map,
) -> Result<(Vec<LogoDraw>, Vec<sl_map_apis::coverage::PlacementSlot>), crate::Error> {
let mut used: Vec<sl_map_apis::coverage::PlacementSlot> = Vec::new();
let mut draws: Vec<LogoDraw> = Vec::new();
if logos.is_empty() {
return Ok((draws, used));
}
let grid = sl_map_apis::coverage::OccupancyGrid::from_map(
occupancy,
sl_map_apis::coverage::DEFAULT_COVERAGE_GRID,
);
let slots = grid.evaluate_slots();
for logo in logos {
if logo.scale != 1 && logo.scale != 2 && logo.scale != 4 {
return Err(crate::Error::Placement(format!(
"logo scale must be 1, 2 or 4, got {}",
logo.scale
)));
}
let group = parse_slot_group(&logo.slot)?;
let anchor = *group
.first()
.ok_or_else(|| crate::Error::Placement("empty slot group".to_owned()))?;
let (reserved, rect) = resolve_placement(&group, &slots, &grid)?;
reserve(
&reserved,
&mut used,
reserved_by_others,
legend_slot,
"logo",
)?;
let decoded = image::open(&logo.file)?;
let mut rgba = decoded.to_rgba8();
if logo.scale != 1 {
let factor = u32::from(logo.scale);
let (w, h) = (rgba.width(), rgba.height());
rgba = image::imageops::resize(
&rgba,
w.saturating_mul(factor),
h.saturating_mul(factor),
image::imageops::FilterType::Nearest,
);
}
let (w, h) = (rgba.width(), rgba.height());
if w > rect.width || h > rect.height {
return Err(crate::Error::Placement(format!(
"logo renders at {w}x{h} px but the free area at slot `{anchor}` only has {}x{} px",
rect.width, rect.height
)));
}
let (default_h, default_v) = anchor.default_alignment();
let hh = parse_h_align(logo.h_align.as_deref())?.unwrap_or(default_h);
let vv = parse_v_align(logo.v_align.as_deref())?.unwrap_or(default_v);
let origin_x = rect.x.saturating_add(hh.offset(w, rect.width));
let origin_y = rect.y.saturating_add(vv.offset(h, rect.height));
draws.push(LogoDraw {
img: rgba,
x: i64::from(origin_x),
y: i64::from(origin_y),
});
}
Ok((draws, used))
}
fn execute_logos(draws: &[LogoDraw], map: &mut Map) {
use sl_map_apis::map_tiles::MapLike as _;
for d in draws {
image::imageops::overlay(map.image_mut(), &d.img, d.x, d.y);
}
}
#[expect(
clippy::too_many_arguments,
reason = "this gathers the full render context (area, dimensions, overlay, route, placements) for a single pre-render pass"
)]
async fn plan_placements(
cache_dir: &std::path::Path,
global_font: Option<&std::path::Path>,
occ_rect: GridRectangle,
max_width: u32,
max_height: u32,
glw: &GlwOverlayArgs,
route: Option<(
&mut RegionNameToGridCoordinatesCache,
&USBNotecard,
image::Rgba<u8>,
)>,
labels: &[LabelSpec],
logos: &[LogoSpec],
) -> Result<(Vec<LabelDraw>, Vec<LogoDraw>), crate::Error> {
use sl_glw::MapLikeGlwExt as _;
let legend_slot = glw.legend_slot();
let mut occ = Map::blank_fit(occ_rect, max_width, max_height)?;
if glw.source().is_some() {
let font_path = global_font.ok_or(crate::Error::FontRequired)?;
let font = sl_map_apis::text::load_font(font_path)?;
if let Some(event) = resolve_glw_event(cache_dir, glw).await? {
let mut style = glw.build_style()?;
style.legend_position = None;
occ.draw_glw_event_with_font(&event, &style, &font);
}
}
if let Some((region_cache, notecard, color)) = route {
occ.draw_route_with_progress(region_cache, notecard, color, None)
.await?;
}
let (label_draws, label_slots) = plan_labels(global_font, labels, legend_slot, &[], &occ)?;
let (logo_draws, _) = plan_logos(logos, legend_slot, &label_slots, &occ)?;
Ok((label_draws, logo_draws))
}
const MAX_OUTPUT_DIMENSION: u32 = 0x8000;
const MAX_OUTPUT_AREA: u64 = 0x1000_0000;
fn validate_dimensions(max_width: u32, max_height: u32) -> Result<(), crate::Error> {
if max_width == 0 || max_height == 0 {
return Err(crate::Error::InvalidDimensions(
"max_width and max_height must be greater than zero".to_owned(),
));
}
if max_width > MAX_OUTPUT_DIMENSION || max_height > MAX_OUTPUT_DIMENSION {
return Err(crate::Error::InvalidDimensions(format!(
"max_width and max_height must each be <= {MAX_OUTPUT_DIMENSION}"
)));
}
if u64::from(max_width).saturating_mul(u64::from(max_height)) > MAX_OUTPUT_AREA {
return Err(crate::Error::InvalidDimensions(format!(
"max_width * max_height must be <= {MAX_OUTPUT_AREA} pixels"
)));
}
Ok(())
}
async fn run_placement_slots(
cache_dir: &std::path::Path,
font: Option<&std::path::Path>,
args: &PlacementSlots,
) -> Result<(), crate::Error> {
use image::GenericImageView as _;
use sl_glw::MapLikeGlwExt as _;
use sl_map_apis::coverage::{DEFAULT_COVERAGE_GRID, OccupancyGrid, PlacementSlot};
validate_dimensions(args.max_width, args.max_height)?;
let mut region_cache = RegionNameToGridCoordinatesCache::new(cache_dir.to_owned())?;
let (grid_rectangle, notecard) = if let Some(path) = &args.usb_notecard {
let notecard = USBNotecard::load_from_file(path)?;
let rect = usb_notecard_to_grid_rectangle(&mut region_cache, ¬ecard).await?;
(rect, Some(notecard))
} else if let (Some(llx), Some(lly), Some(urx), Some(ury)) = (
args.lower_left_x,
args.lower_left_y,
args.upper_right_x,
args.upper_right_y,
) {
(
GridRectangle::new(
GridCoordinates::new(llx, lly),
GridCoordinates::new(urx, ury),
),
None,
)
} else {
return Err(crate::Error::Placement(
"provide either --usb-notecard or all four grid-rectangle corner flags".to_owned(),
));
};
let mut occ = Map::blank_fit(grid_rectangle, args.max_width, args.max_height)?;
if args.glw.source().is_some() {
let font_path = font.ok_or(crate::Error::FontRequired)?;
let glw_font = sl_map_apis::text::load_font(font_path)?;
if let Some(event) = resolve_glw_event(cache_dir, &args.glw).await? {
let mut style = args.glw.build_style()?;
style.legend_position = None;
occ.draw_glw_event_with_font(&event, &style, &glw_font);
}
}
if let Some(notecard) = ¬ecard {
occ.draw_route_with_progress(&mut region_cache, notecard, args.color, None)
.await?;
}
let mut groups: Vec<Vec<PlacementSlot>> = Vec::with_capacity(args.groups.len());
for g in &args.groups {
groups.push(parse_slot_group(g)?);
}
let grid = OccupancyGrid::from_map(&occ, DEFAULT_COVERAGE_GRID);
let slots = grid.evaluate_slots();
let (image_width, image_height) = occ.dimensions();
println!("image: {image_width}x{image_height} px");
println!(
"{:<14} {:<9} {:<20} {:<9} connected_neighbours",
"slot", "available", "free_rect(x,y,w,h)", "occupied"
);
for info in &slots {
let rect = info.free_rect.map_or_else(
|| "-".to_owned(),
|r| format!("{},{},{},{}", r.x, r.y, r.width, r.height),
);
let neighbours = info
.connected_neighbours
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(",");
println!(
"{:<14} {:<9} {:<20} {:<8.0}% {neighbours}",
info.slot.as_str(),
info.available,
rect,
info.occupied_fraction * 100.0,
);
}
if !groups.is_empty() {
println!("groups:");
for (group, names) in groups.iter().zip(args.groups.iter()) {
let rect = grid.subset_rect(group).map_or_else(
|| "fully covered".to_owned(),
|r| format!("{},{},{},{}", r.x, r.y, r.width, r.height),
);
println!(" {names}: {rect}");
}
}
Ok(())
}
fn run_measure_text(
global_font: Option<&std::path::Path>,
args: &MeasureText,
) -> Result<(), crate::Error> {
if !(args.font_px.is_finite() && args.font_px > 0f32) {
return Err(crate::Error::Placement(format!(
"font size must be a positive number of pixels, got {}",
args.font_px
)));
}
let font_path = args
.font
.as_deref()
.or(global_font)
.ok_or(crate::Error::TextFontRequired("measure-text"))?;
let font = sl_map_apis::text::load_font(font_path)?;
let scale = ab_glyph::PxScale::from(args.font_px);
let (width, height) = sl_map_apis::text::measure_text(scale, &font, &args.lines);
println!("{width}x{height} px ({} line(s))", args.lines.len());
Ok(())
}
fn save_map(map: &Map, path: &std::path::Path, format: OutputFormat) -> Result<(), crate::Error> {
use sl_map_apis::map_tiles::MapLike as _;
match format {
OutputFormat::Png => map.save(path)?,
OutputFormat::Jpeg => {
let mut file = fs_err::File::create(path)?;
map.image().write_to(&mut file, format.image_format())?;
}
}
Ok(())
}
#[instrument]
async fn do_stuff() -> Result<(), crate::Error> {
let options = Options::parse();
tracing::debug!("{:#?}", options);
let Options {
cache_dir,
font,
command,
} = options;
match command {
Command::FromGridRectangle(from_grid_rectangle) => {
let cache_dir = cache_dir.ok_or(crate::Error::CacheDirRequired)?;
let grid_rectangle: GridRectangle = (&from_grid_rectangle).into();
let placement = &from_grid_rectangle.placement;
let (label_draws, logo_draws) = if placement.has_placements() {
plan_placements(
&cache_dir,
font.as_deref(),
grid_rectangle.to_owned(),
from_grid_rectangle.max_width,
from_grid_rectangle.max_height,
&from_grid_rectangle.glw,
None,
&placement.labels,
&placement.logos,
)
.await?
} else {
(Vec::new(), Vec::new())
};
let ratelimiter = ratelimit::Ratelimiter::builder(10).build()?;
let mut map_tile_cache = MapTileCache::new(cache_dir.clone(), Some(ratelimiter));
let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
let progress_task = tokio::spawn(report_progress(rx));
let mut map = Map::new_with_progress(
&mut map_tile_cache,
from_grid_rectangle.max_width,
from_grid_rectangle.max_height,
grid_rectangle.to_owned(),
from_grid_rectangle.missing_map_tile_color,
from_grid_rectangle.missing_region_color,
Some(&tx),
)
.await?;
drop(tx);
join_progress_task(progress_task).await;
let region_opts = placement.region_overlay();
if region_opts.any() {
let region_font = placement.region_font.as_deref().or(font.as_deref());
let mut region_cache = RegionNameToGridCoordinatesCache::new(cache_dir.clone())?;
apply_region_overlay(region_opts, region_font, &mut region_cache, &mut map).await?;
}
fetch_and_draw_glw(
&cache_dir,
font.as_deref(),
&from_grid_rectangle.glw,
&mut map,
)
.await?;
execute_labels(&label_draws, &mut map);
execute_logos(&logo_draws, &mut map);
save_map(&map, &from_grid_rectangle.output_file, placement.format)?;
output_metadata(
&grid_rectangle,
from_grid_rectangle.metadata_output_file.as_ref(),
)?;
}
Command::FromUSBNotecard(from_usb_notecard) => {
let cache_dir = cache_dir.ok_or(crate::Error::CacheDirRequired)?;
let usb_notecard = USBNotecard::load_from_file(&from_usb_notecard.usb_notecard)?;
let mut region_name_to_grid_coordinates_cache =
RegionNameToGridCoordinatesCache::new(cache_dir.clone())?;
let (border_north, border_south, border_east, border_west) =
if let Some(b) = from_usb_notecard.border_regions {
(b, b, b, b)
} else {
(
from_usb_notecard.border_north.unwrap_or(0),
from_usb_notecard.border_south.unwrap_or(0),
from_usb_notecard.border_east.unwrap_or(0),
from_usb_notecard.border_west.unwrap_or(0),
)
};
let grid_rectangle = usb_notecard_to_grid_rectangle(
&mut region_name_to_grid_coordinates_cache,
&usb_notecard,
)
.await?
.expanded_west(border_west)
.expanded_east(border_east)
.expanded_south(border_south)
.expanded_north(border_north);
let placement = &from_usb_notecard.placement;
let (label_draws, logo_draws) = if placement.has_placements() {
plan_placements(
&cache_dir,
font.as_deref(),
grid_rectangle.to_owned(),
from_usb_notecard.max_width,
from_usb_notecard.max_height,
&from_usb_notecard.glw,
Some((
&mut region_name_to_grid_coordinates_cache,
&usb_notecard,
from_usb_notecard.color,
)),
&placement.labels,
&placement.logos,
)
.await?
} else {
(Vec::new(), Vec::new())
};
let ratelimiter = ratelimit::Ratelimiter::builder(10).build()?;
let mut map_tile_cache = MapTileCache::new(cache_dir.clone(), Some(ratelimiter));
let (tx, rx) = tokio::sync::mpsc::channel::<MapProgressEvent>(256);
let progress_task = tokio::spawn(report_progress(rx));
let mut map = Map::new_with_progress(
&mut map_tile_cache,
from_usb_notecard.max_width,
from_usb_notecard.max_height,
grid_rectangle.to_owned(),
from_usb_notecard.missing_map_tile_color,
from_usb_notecard.missing_region_color,
Some(&tx),
)
.await?;
if let Some(output_file_without_route) = &from_usb_notecard.output_file_without_route {
save_map(&map, output_file_without_route, placement.format)?;
}
let region_opts = placement.region_overlay();
if region_opts.any() {
let region_font = placement.region_font.as_deref().or(font.as_deref());
apply_region_overlay(
region_opts,
region_font,
&mut region_name_to_grid_coordinates_cache,
&mut map,
)
.await?;
}
fetch_and_draw_glw(
&cache_dir,
font.as_deref(),
&from_usb_notecard.glw,
&mut map,
)
.await?;
map.draw_route_with_progress(
&mut region_name_to_grid_coordinates_cache,
&usb_notecard,
from_usb_notecard.color,
Some(&tx),
)
.await?;
drop(tx);
join_progress_task(progress_task).await;
execute_labels(&label_draws, &mut map);
execute_logos(&logo_draws, &mut map);
save_map(&map, &from_usb_notecard.output_file, placement.format)?;
output_metadata(
&grid_rectangle,
from_usb_notecard.metadata_output_file.as_ref(),
)?;
}
Command::PlacementSlots(placement_slots) => {
let cache_dir = cache_dir.ok_or(crate::Error::CacheDirRequired)?;
run_placement_slots(&cache_dir, font.as_deref(), &placement_slots).await?;
}
Command::MeasureText(measure_text) => {
run_measure_text(font.as_deref(), &measure_text)?;
}
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<(), Error> {
let terminal_env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::WARN.into())
.parse(std::env::var("RUST_LOG").unwrap_or_else(|_| "".to_string()))?;
let file_env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::TRACE.into())
.parse(std::env::var("SL_MAP_CLI_LOG").unwrap_or_else(|_| "".to_string()))?;
let registry = Registry::default();
let registry =
registry.with(tracing_subscriber::fmt::Layer::default().with_filter(terminal_env_filter));
let log_dir = std::env::var("SL_MAP_CLI_LOG_DIR");
let file_layer = if let Ok(log_dir) = log_dir {
let log_file = if let Ok(log_file) = std::env::var("SL_MAP_CLI_LOG_FILE") {
log_file
} else {
"sl_map_cli.log".to_string()
};
tracing::info!("Logging to {}/{}", log_dir, log_file);
let file_appender = tracing_appender::rolling::never(log_dir, log_file);
Some(
tracing_subscriber::fmt::Layer::default()
.with_writer(file_appender)
.with_filter(file_env_filter),
)
} else {
None
};
let registry = registry.with(file_layer);
registry.init();
log_panics::init();
match do_stuff().await {
Ok(()) => (),
Err(e) => {
tracing::error!("{}", e);
eprintln!("{e}");
std::process::exit(1);
}
}
tracing::debug!("Exiting");
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::{assert_eq, assert_matches};
const FONT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../DejaVuSans.ttf");
const LOGO: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../sl_sailing_logo.png");
fn blank_map() -> Result<Map, Box<dyn std::error::Error>> {
let rect = GridRectangle::new(
GridCoordinates::new(1000, 1000),
GridCoordinates::new(1003, 1003),
);
Ok(Map::blank_fit(rect, 1024, 1024)?)
}
#[test]
fn output_format_default_is_png() {
assert_eq!(OutputFormat::default(), OutputFormat::Png);
}
#[test]
fn parse_logo_spec_parses_and_defaults() -> Result<(), Box<dyn std::error::Error>> {
let logo = parse_logo_spec(r#"{"file":"logo.png","slot":"top_left"}"#)?;
assert_eq!(logo.file, PathBuf::from("logo.png"));
assert_eq!(logo.slot, "top_left");
assert_eq!(logo.scale, 1, "scale defaults to 1");
assert_eq!(logo.h_align, None);
assert_matches!(
parse_logo_spec(r#"{"file":"x.png","slot":"a","bogus":1}"#),
Err(_)
);
assert_matches!(parse_logo_spec(r#"{"slot":"top_left"}"#), Err(_));
Ok(())
}
#[test]
fn parse_label_spec_parses_and_defaults() -> Result<(), Box<dyn std::error::Error>> {
let label = parse_label_spec(r#"{"slot":"top_right","font_px":24,"lines":["a","b"]}"#)?;
assert_eq!(label.slot, "top_right");
assert_eq!(label.lines, vec!["a".to_owned(), "b".to_owned()]);
assert_eq!(label.color, "#ffffff", "color defaults to white");
assert_eq!(label.font, None);
assert_matches!(parse_label_spec(r#"{"slot":"a","lines":[]}"#), Err(_));
Ok(())
}
#[test]
fn parse_slot_group_single_combined_and_rectangle_rule()
-> Result<(), Box<dyn std::error::Error>> {
use sl_map_apis::coverage::PlacementSlot as P;
assert_eq!(parse_slot_group("bottom_right")?, vec![P::BottomRight]);
assert_eq!(
parse_slot_group("top_left+top_center+top_left")?,
vec![P::TopLeft, P::TopCenter]
);
assert_matches!(parse_slot_group("top_left+bottom_right"), Err(_));
assert_matches!(parse_slot_group("top_left+bottom_center"), Err(_));
assert_matches!(parse_slot_group("nowhere"), Err(_));
Ok(())
}
#[test]
fn legend_position_from_slot_cases() -> Result<(), Box<dyn std::error::Error>> {
use sl_map_apis::coverage::PlacementSlot as P;
assert_eq!(legend_position_from_slot(None)?, Some(P::TopLeft));
assert_eq!(legend_position_from_slot(Some(""))?, Some(P::TopLeft));
assert_eq!(legend_position_from_slot(Some("none"))?, None);
assert_eq!(legend_position_from_slot(Some("center"))?, Some(P::Center));
assert_matches!(legend_position_from_slot(Some("nonsense")), Err(_));
Ok(())
}
#[test]
fn reserve_rejects_legend_and_double_use() -> Result<(), Box<dyn std::error::Error>> {
use sl_map_apis::coverage::PlacementSlot as P;
let mut used = Vec::new();
assert_matches!(
reserve(&[P::TopLeft], &mut used, &[], Some(P::TopLeft), "label"),
Err(_)
);
let mut used = Vec::new();
assert_matches!(
reserve(&[P::TopLeft], &mut used, &[P::TopLeft], None, "logo"),
Err(_)
);
let mut used = Vec::new();
reserve(&[P::TopRight], &mut used, &[], None, "label")?;
assert_eq!(used, vec![P::TopRight]);
Ok(())
}
#[test]
fn plan_labels_places_small_and_rejects_oversize() -> Result<(), Box<dyn std::error::Error>> {
let map = blank_map()?;
let small = LabelSpec {
slot: "top_left".to_owned(),
lines: vec!["hi".to_owned()],
font: None,
font_px: 16.0,
color: "#ffffff".to_owned(),
h_align: None,
v_align: None,
};
let font = std::path::Path::new(FONT);
let (draws, used) = plan_labels(Some(font), &[small], None, &[], &map)?;
assert_eq!(draws.len(), 1);
assert_eq!(used, vec![sl_map_apis::coverage::PlacementSlot::TopLeft]);
let huge = LabelSpec {
slot: "top_left".to_owned(),
lines: vec!["WAY TOO BIG".to_owned()],
font: None,
font_px: 4000.0,
color: "#ffffff".to_owned(),
h_align: None,
v_align: None,
};
assert!(plan_labels(Some(font), &[huge], None, &[], &map).is_err());
let needs_font = LabelSpec {
slot: "top_left".to_owned(),
lines: vec!["hi".to_owned()],
font: None,
font_px: 16.0,
color: "#ffffff".to_owned(),
h_align: None,
v_align: None,
};
assert!(plan_labels(None, &[needs_font], None, &[], &map).is_err());
Ok(())
}
#[test]
fn plan_logos_places_small_and_rejects_oversize() -> Result<(), Box<dyn std::error::Error>> {
let map = blank_map()?;
let small = LogoSpec {
file: PathBuf::from(LOGO),
slot: "bottom_right".to_owned(),
scale: 1,
h_align: None,
v_align: None,
};
let (draws, used) = plan_logos(&[small], None, &[], &map)?;
assert_eq!(draws.len(), 1);
assert_eq!(
used,
vec![sl_map_apis::coverage::PlacementSlot::BottomRight]
);
let big = LogoSpec {
file: PathBuf::from(LOGO),
slot: "bottom_right".to_owned(),
scale: 4,
h_align: None,
v_align: None,
};
assert!(plan_logos(&[big], None, &[], &map).is_err());
let bad_scale = LogoSpec {
file: PathBuf::from(LOGO),
slot: "bottom_right".to_owned(),
scale: 3,
h_align: None,
v_align: None,
};
assert!(plan_logos(&[bad_scale], None, &[], &map).is_err());
Ok(())
}
#[test]
fn validate_dimensions_bounds() -> Result<(), Box<dyn std::error::Error>> {
assert_matches!(validate_dimensions(0, 100), Err(_));
assert_matches!(validate_dimensions(100, 0), Err(_));
assert_matches!(validate_dimensions(MAX_OUTPUT_DIMENSION + 1, 1), Err(_));
validate_dimensions(2048, 2048)?;
Ok(())
}
}