diff --git a/src/user_preferences/v0.rs b/src/user_preferences/v0.rs index dadf8de..c91d210 100644 --- a/src/user_preferences/v0.rs +++ b/src/user_preferences/v0.rs @@ -166,7 +166,6 @@ impl Preference for UserPreferencesV0 { } else { (i - last_default) as u8 - 2 }; - eprintln!("{} {} {} {}", toggle_enabled, distance, i, last_default); commands.push(Command::Move { toggle_enabled, distance @@ -194,7 +193,6 @@ impl Preference for UserPreferencesV0 { } else { (i - last_enabled) as u8 - 2 }; - eprintln!("{} {} {} {}", toggle_enabled, distance, i, last_enabled); commands.push(Command::Move { toggle_enabled, distance diff --git a/src/util.rs b/src/util.rs index 2e93d75..8b30918 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,21 @@ +use time::{Date, Month, OffsetDateTime}; + +/// The start of the COVID-19 lockdowns +/// +/// This is used as an epoch in order to convert from a given date to an integer seed. This is +/// specified as part of the algorithm for randomly selecting from a weighted list. +pub const COVID_EPOCH: Date = match Date::from_calendar_date(2020, Month::January, 26) { + Ok(d) => d, + Err(_) => Date::MIN, // This never runs, but we can't unwrap, so this is what we're stuck with +}; + +/// A state to use as an initial state before seeding +/// +/// Think of this as a ::new() method for a pcg64 generator. Subsiquent calls should be +/// made to the [`pcg64_seed()`] methods or the [`seed_with_date()`]/[`seed_with_today()`] +/// methods +pub const INITIAL_STATE: u128 = 1312_1312_1312; + pub fn pcg64(state: &mut u128) -> u64 { let mut x = *state; pcg64_iterstate(state); @@ -18,3 +36,27 @@ pub fn pcg64_seed(state: &mut u128, bytes: &[u8]) { } pcg64_iterstate(state); } + +/// Seed the generator with a given [`Date`] object +pub fn seed_with_date(state: &mut u128, date: Date) { + pcg64_seed( + state, + &( + (date - COVID_EPOCH) + .whole_days() + as u32 + ).to_le_bytes() + ) +} + +/// Seed the generator with the [`Date`] object representing today's date +/// +/// Uses the system's local time, or falls back to UTC +pub fn seed_with_today(state: &mut u128) { + seed_with_date( + state, + OffsetDateTime::now_local() + .unwrap_or_else(|_| OffsetDateTime::now_utc()) + .date() + ) +} diff --git a/src/weighted_table.rs b/src/weighted_table.rs index dd03535..55a730a 100644 --- a/src/weighted_table.rs +++ b/src/weighted_table.rs @@ -1,22 +1,10 @@ -use crate::{ - user_preferences::ParseError, - util::{pcg64, pcg64_seed}, -}; +use crate::{user_preferences::ParseError, util::{self, pcg64, pcg64_seed, seed_with_date, seed_with_today}}; use std::{collections::BTreeMap, hash::Hash}; use std::fmt::Debug; use std::cmp::Eq; -use time::{Date, Month, OffsetDateTime}; - -/// The start of the COVID-19 lockdowns -/// -/// This is used as an epoch in order to convert from a given date to an integer seed. This is -/// specified as part of the algorithm for randomly selecting from a weighted list. -pub const COVID_EPOCH: Date = match Date::from_calendar_date(2020, Month::January, 26) { - Ok(d) => d, - Err(_) => Date::MIN, // This never runs, but we can't unwrap, so this is what we're stuck with -}; +use time::Date; /// A list of pronouns and their associated weights, used for random selection /// @@ -82,41 +70,37 @@ impl WeightedTable { /// /// The date is generated for the system's time and timezone pub fn select_today(&self, seed: &[u8]) -> Loot { - self.select_on_date( - seed, - OffsetDateTime::now_local() - .unwrap_or_else(|_| OffsetDateTime::now_utc()) - .date() - ) + let mut generator = util::INITIAL_STATE; + + seed_with_today(&mut generator); + pcg64_seed(&mut generator, seed); + + self.select(&mut generator) } /// Randomly select a pronoun set for a given date and name. /// /// Is a wrapper for calling [`WeightedTable::select`] with the given date mixed into the seed. pub fn select_on_date(&self, seed: &[u8], date: Date) -> Loot { - let mut new_seed: Vec = Vec::with_capacity(seed.len() + 4); - new_seed.extend( - ( - (date - COVID_EPOCH) - .whole_days() - as u32 - ).to_le_bytes() - ); - new_seed.extend(seed); - self.select(&new_seed) + let mut generator = util::INITIAL_STATE; + + seed_with_date(&mut generator, date); + pcg64_seed(&mut generator, seed); + + self.select(&mut generator) } - /// Randomly select a pronoun set for a given seed + /// Randomly select a pronoun set from a given pre-seeded genenator + /// + /// You're probably looking for [`select_today()`] or [`select_on_date()`] /// /// This function is *pure*, and any randomness is produced internally using PRNG seeded with /// the given date and seed. That is to say, for any given seed, this table must always /// produce the same pronoun set. - pub fn select(&self, seed: &[u8]) -> Loot { + pub fn select(&self, generator: &mut u128) -> Loot { let (rollable_table, sum_weights) = self.rollable_table(); - let mut generator: u128 = 131213121312; - pcg64_seed(&mut generator, seed); - let random = pcg64(&mut generator) % sum_weights; + let random = pcg64(generator) % sum_weights; rollable_table.iter() .filter(|(weight, _)| random < *weight) diff --git a/web/.gitattributes b/web/.gitattributes new file mode 100644 index 0000000..9210e03 --- /dev/null +++ b/web/.gitattributes @@ -0,0 +1,2 @@ +*.ttf filter=lfs diff=lfs merge=lfs -text +*.otf filter=lfs diff=lfs merge=lfs -text diff --git a/web/Cargo.toml b/web/Cargo.toml index 4fef6ee..3e24c40 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "pronouns-today-web" version = "0.1.0" -edition = "2018" +edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -11,3 +11,14 @@ actix-web = "3" log = "0.4" env_logger = "0.9" askama = "0.10" +rusttype = { version = "0.9.2", optional = true } +image = { version = "0.23.14", optional = true } +lazy_static = { version = "1.4.0", optional = true } + +[features] +# Enables support for generating images for Open Graph Protocol cards. This means that +# when someone shares a link to their pronoun page on Twitter, Discord, or another app +# that supports OGP, the card will include a visual of their pronouns. +ogp_images = ["rusttype", "image", "lazy_static"] + +default = ["ogp_images"] diff --git a/web/assets/LeagueGothic.otf b/web/assets/LeagueGothic.otf new file mode 100644 index 0000000..071d571 --- /dev/null +++ b/web/assets/LeagueGothic.otf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:877d83072137a11a78b75b90eaa7349d30a485007599568db09f8390be077d69 +size 20392 diff --git a/web/src/contrast.rs b/web/src/contrast.rs new file mode 100644 index 0000000..fd5bfb6 --- /dev/null +++ b/web/src/contrast.rs @@ -0,0 +1,72 @@ +#![cfg(feature = "ogp_images")] + +use image::{Pixel, Rgb, Rgba}; +use pronouns_today::util::pcg64; + +pub fn generate_color_pair(state: &mut u128) -> ([u8; 3], [u8; 3]) { + let mut background = random_color(state); + let mut foreground; + loop { + foreground = random_color(state); + if meets_criteria(&foreground, &background) { + break; + } + background = random_color(state); + if meets_criteria(&foreground, &background) { + break; + } + } + (foreground, background) +} + +pub fn generate_color_pair_rgba(state: &mut u128) -> (Rgba, Rgba) { + let (color1, color2) = generate_color_pair(state); + + let (color1, color2): (Rgb, Rgb) = ( + color1.into(), + color2.into(), + ); + + (color1.to_rgba(), color2.to_rgba()) +} + +pub fn relative_lum(color: &[u8; 3]) -> f32 { + let color_mapped: Vec = color.iter() + .map(|val| { + let val = *val as f32 / 255.0; + if val < 0.03928 { + val / 12.92 + } else { + ((val+0.055)/1.055).powf(2.4) + } + }) + .collect(); + return 0.2126 * color_mapped[0] + 0.7152 * color_mapped[1] + 0.0722 * color_mapped[2] +} + +pub fn check_contrast(color1: &[u8; 3], color2: &[u8; 3]) -> f32 { + let a = 0.05 + relative_lum(color1); + let b = 0.05 + relative_lum(color2); + (a / b).max(b / a) +} + +pub fn get_saturation(color: &[u8; 3]) -> f32 { + let chroma_max = *(color.iter().max().unwrap()); + let chroma_min = *(color.iter().min().unwrap()); + return ((chroma_max - chroma_min) as f32) / chroma_max as f32; +} + +pub fn random_color(state: &mut u128) -> [u8; 3] { + let random = pcg64(state).to_le_bytes(); + return [ + random[0], + random[1], + random[2], + ] +} + +pub fn meets_criteria(foreground: &[u8; 3], background: &[u8; 3]) -> bool { + return check_contrast(foreground, background) >= 5.0 && + get_saturation(background) < 0.65 && + get_saturation(foreground) < 0.65 +} diff --git a/web/src/main.rs b/web/src/main.rs index 29e81bf..166a25c 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -1,3 +1,6 @@ +pub mod ogp_images; +pub mod contrast; + use std::collections::HashMap; use std::fmt::{self, Display}; @@ -5,24 +8,35 @@ use actix_web::dev::HttpResponseBuilder; use actix_web::http::{header, StatusCode}; use actix_web::middleware::normalize::TrailingSlash; use actix_web::middleware::{self, Logger}; -use actix_web::{get, post, web, App, HttpResponse, HttpServer, Responder, ResponseError, Result}; +use actix_web::web::resource; +use actix_web::{App, HttpRequest, HttpResponse, HttpServer, Responder, ResponseError, Result, post, web}; use askama::Template; use pronouns_today::user_preferences::Preference; use pronouns_today::{InstanceSettings, Pronoun}; +#[cfg(feature = "ogp_images")] +use image::{DynamicImage, ImageOutputFormat}; +#[cfg(feature = "ogp_images")] +use ogp_images::render_today; + +// TODO: Make this configurable +const HOSTNAME: &str = "pronouns.today"; + #[derive(Template)] #[template(path = "index.html")] struct IndexTemplate<'a> { name: Option, pronoun: &'a Pronoun, pronouns: Vec<(usize, &'a Pronoun)>, + url: String, } -fn render_page(pronoun: &Pronoun, settings: &InstanceSettings, name: Option) -> String { +fn render_page(pronoun: &Pronoun, settings: &InstanceSettings, name: Option, url: String) -> String { IndexTemplate { name, pronoun, pronouns: settings.pronoun_list.iter().enumerate().collect(), + url, } .render() .unwrap() @@ -53,40 +67,67 @@ async fn create_link( .finish()) } -#[get("/")] -async fn default(settings: web::Data) -> Result { - let pronoun = settings - .select_pronouns(None, None) - .map_err(|_| Error::InvlaidPrefString)?; - Ok(HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(render_page(&pronoun, &settings, None))) +fn form_full_url(host: &str, name: Option<&str>, prefstr: Option<&str>) -> String { + ["https:/", host].into_iter() + .chain(name) + .chain(prefstr) + .collect::>() + .join("/") } -#[get("/{prefs}")] -async fn only_prefs( - web::Path(prefs): web::Path, - settings: web::Data, -) -> Result { +/// Determine some basic information about a request +/// +/// Determines the name and prefstring properties, if available, and also computes the pronoun that +/// should be responded using. This method is designed to facilitate creating the basic response +/// pages. +/// +/// Both arguments should be passed directly from the caller, and the return values are, in order, +/// the prefstring, if available, the user's name, if available, and the computed pronouns. +fn get_request_info<'s, 'r>( + settings: &'s web::Data, + req: &'r HttpRequest, +) -> (Option<&'r str>, Option<&'r str>, Result<&'s Pronoun, Error>) { + let prefs = req.match_info().get("prefs"); + let name = req.match_info().get("name"); let pronoun = settings - .select_pronouns(None, Some(&prefs)) - .map_err(|_| Error::InvlaidPrefString)?; - Ok(HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .body(render_page(&pronoun, &settings, None))) + .select_pronouns(name, prefs) + .map_err(|_| Error::InvlaidPrefString); + (prefs, name, pronoun) } -#[get("/{name}/{prefs}")] -async fn prefs_and_name( - web::Path((name, prefs)): web::Path<(String, String)>, +async fn handle_basic_request( settings: web::Data, + req: HttpRequest, ) -> Result { - let pronoun = settings - .select_pronouns(Some(&name), Some(&prefs)) - .map_err(|_| Error::InvlaidPrefString)?; + + let (prefstr, name, pronoun) = get_request_info(&settings, &req); + Ok(HttpResponse::Ok() .content_type("text/html; charset=utf-8") - .body(render_page(&pronoun, &settings, Some(name)))) + .body(render_page( + pronoun?, + &settings, + name.map(str::to_owned), + form_full_url(HOSTNAME, name, prefstr) + ))) +} + +#[cfg(feature = "ogp_images")] +async fn handle_thumbnail_request( + settings: web::Data, + req: HttpRequest, +) -> Result { + + let (_, name, pronoun) = get_request_info(&settings, &req); + + let mut data: Vec = Vec::with_capacity(15_000); + let image = DynamicImage::ImageRgb8(render_today(pronoun?, name.unwrap_or(""))); + image.write_to(&mut data, ImageOutputFormat::Png) + .expect("Error encoding thumbnail to PNG"); + + Ok(HttpResponse::Ok() + .content_type("image/png") + .body(data)) } async fn not_found() -> impl Responder { @@ -101,13 +142,21 @@ async fn main() -> std::io::Result<()> { println!("Starting pronouns-today-web on 127.0.0.1:8080"); HttpServer::new(|| { let logger = Logger::default(); - App::new() + let app = App::new() .data(InstanceSettings::default()) .wrap(logger) - .wrap(middleware::NormalizePath::new(TrailingSlash::Trim)) - .service(prefs_and_name) - .service(only_prefs) - .service(default) + .wrap(middleware::NormalizePath::new(TrailingSlash::Trim)); + + #[cfg(feature = "ogp_images")] + let app = app + .service(resource("/thumb.png") .to(handle_thumbnail_request)) + .service(resource("/{prefs}/thumb.png") .to(handle_thumbnail_request)) + .service(resource("/{name}/{prefs}/thumb.png").to(handle_thumbnail_request)); + + app + .service(resource("/") .to(handle_basic_request)) + .service(resource("/{prefs}") .to(handle_basic_request)) + .service(resource("/{name}/{prefs}").to(handle_basic_request)) .service(create_link) .default_service(web::to(not_found)) }) diff --git a/web/src/ogp_images.rs b/web/src/ogp_images.rs new file mode 100644 index 0000000..b372efa --- /dev/null +++ b/web/src/ogp_images.rs @@ -0,0 +1,85 @@ +#![cfg(feature = "ogp_images")] + +use crate::contrast::generate_color_pair_rgba; + +use image::{DynamicImage, ImageBuffer, Pixel, Rgb, Rgba}; +use lazy_static::lazy_static; +use pronouns_today::{Pronoun, util::{self, pcg64_seed, seed_with_today}}; +use rusttype::{Font, Scale, point}; + +const SCALE: u32 = 400; +const FONT_SCALE: f32 = 250.0; +const FONT_DATA: &[u8] = include_bytes!("../assets/LeagueGothic.otf"); +lazy_static! { + static ref FONT: Font<'static> = Font::try_from_bytes(FONT_DATA) + .expect("Invalid font data in build. This build and binary is invalid, and cannot be used."); +} + +pub fn render_today(pronouns: &Pronoun, name: &str) -> ImageBuffer, Vec> { + let mut state = util::INITIAL_STATE; + + seed_with_today(&mut state); + pcg64_seed(&mut state, name.as_bytes()); + + render(pronouns, &mut state) +} +pub fn render(pronouns: &Pronoun, pcg64: &mut u128) -> ImageBuffer, Vec> { + let (color1, color2) = generate_color_pair_rgba(pcg64); + + render_with_colors(pronouns, color1, color2) +} + +pub fn render_with_colors(pronouns: &Pronoun, color1: Rgba, color2: Rgba) -> ImageBuffer, Vec> { + + let scale = Scale::uniform(FONT_SCALE); + + let mut output_image = ImageBuffer::from_fn(SCALE * 2, SCALE, |x, _| { + if x < SCALE { + color1 + } else { + color2 + } + }); + + for (color, text, offset) in [(color2, &pronouns.subject_pronoun, 0), (color1, &pronouns.object_pronoun, 400)] { + let v_metrics = FONT.v_metrics(scale); + let glyphs: Vec<_> = FONT + .layout(&text.to_uppercase(), scale, point(0.0, v_metrics.ascent)) + .collect(); + let min_x = glyphs + .first() + .map(|g| g.pixel_bounding_box().unwrap().min.x) + .unwrap_or(0); + let max_x = glyphs + .last() + .map(|g| g.pixel_bounding_box().unwrap().max.x) + .unwrap_or(0); + let width = max_x - min_x; + let height = v_metrics.ascent - v_metrics.descent; + + // Recompute offset to center text on page + let x_offset = (SCALE as u32 - width as u32) / 2 + offset; + let y_offset = ((SCALE as f32 - height) / 2.0) as u32; + + // Draw glyphs onto the buffer + for glyph in glyphs { + if let Some(bounding_box) = glyph.pixel_bounding_box() { + glyph.draw(|x, y, v| { + if v > 0.0 { + + let mut overlay_color = color; + let alpha = &mut overlay_color.channels_mut()[3]; + *alpha = (*alpha as f32 * v) as u8; + + output_image.get_pixel_mut( + x + x_offset + bounding_box.min.x as u32, + y + y_offset + bounding_box.min.y as u32 + ).blend(&overlay_color); + } + }); + } + } + } + + DynamicImage::ImageRgba8(output_image).into_rgb8() +} diff --git a/web/templates/index.html b/web/templates/index.html index 4ec74d1..888c0b1 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2,6 +2,24 @@ {% if name.is_some() %} {{ name.as_ref().unwrap() }}'s {% else %} My{% endif %} Pronouns Today + + + {% if name.is_some() %} + + {% else %} + + {% endif %} + + + + + + + + + + +