Merge branch 'ogp'

This commit is contained in:
Emi Simpson 2021-10-25 21:52:41 -04:00
commit b16fbc9d78
Signed by: Emi
GPG Key ID: A12F2C2FFDC3D847
10 changed files with 334 additions and 70 deletions

View File

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

View File

@ -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()
)
}

View File

@ -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<Loot: Eq + Ord + Hash + Copy + Debug> WeightedTable<Loot> {
///
/// 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<u8> = 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)

2
web/.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
*.ttf filter=lfs diff=lfs merge=lfs -text
*.otf filter=lfs diff=lfs merge=lfs -text

View File

@ -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"]

BIN
web/assets/LeagueGothic.otf (Stored with Git LFS) Normal file

Binary file not shown.

72
web/src/contrast.rs Normal file
View File

@ -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<u8>, Rgba<u8>) {
let (color1, color2) = generate_color_pair(state);
let (color1, color2): (Rgb<u8>, Rgb<u8>) = (
color1.into(),
color2.into(),
);
(color1.to_rgba(), color2.to_rgba())
}
pub fn relative_lum(color: &[u8; 3]) -> f32 {
let color_mapped: Vec<f32> = 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
}

View File

@ -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<String>,
pronoun: &'a Pronoun,
pronouns: Vec<(usize, &'a Pronoun)>,
url: String,
}
fn render_page(pronoun: &Pronoun, settings: &InstanceSettings, name: Option<String>) -> String {
fn render_page(pronoun: &Pronoun, settings: &InstanceSettings, name: Option<String>, 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<InstanceSettings>) -> Result<impl Responder> {
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::<Vec<&str>>()
.join("/")
}
#[get("/{prefs}")]
async fn only_prefs(
web::Path(prefs): web::Path<String>,
settings: web::Data<InstanceSettings>,
) -> Result<impl Responder> {
/// 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<InstanceSettings>,
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<InstanceSettings>,
req: HttpRequest,
) -> Result<impl Responder> {
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<InstanceSettings>,
req: HttpRequest,
) -> Result<impl Responder> {
let (_, name, pronoun) = get_request_info(&settings, &req);
let mut data: Vec<u8> = 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))
})

85
web/src/ogp_images.rs Normal file
View File

@ -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<Rgb<u8>, Vec<u8>> {
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<Rgb<u8>, Vec<u8>> {
let (color1, color2) = generate_color_pair_rgba(pcg64);
render_with_colors(pronouns, color1, color2)
}
pub fn render_with_colors(pronouns: &Pronoun, color1: Rgba<u8>, color2: Rgba<u8>) -> ImageBuffer<Rgb<u8>, Vec<u8>> {
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()
}

View File

@ -2,6 +2,24 @@
<head>
<title>{% if name.is_some() %} {{ name.as_ref().unwrap() }}'s {% else %}
My{% endif %} Pronouns Today</title>
<!-- OGP Tags -->
{% if name.is_some() %}
<meta property="og:title" content="{{ name.as_ref().unwrap() }}'s Pronouns Today" />
{% else %}
<meta property="og:title" content="Random Daily Pronouns" />
{% endif %}
<meta property="twitter:card" content="summary_large_image" />
<meta property="og:site_name" content="Pronouns.Today" />
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ url }}" />
<meta property="og:image:url" content="{{ url }}/thumb.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="800" />
<meta property="og:image:height" content="400" />
<meta property="og:image:alt" content="The pronouns {{pronoun.subject_pronoun | capitalize}}/{{pronoun.object_pronoun | capitalize}}" />
<meta property="og:description" content="{{ name.as_ref().map(String::as_str).unwrap_or("Whoever posted this") }} uses any pronouns, but for today, {{pronoun.subject_pronoun}} would appreciate it if you used {{pronoun.render_threeform()}} pronouns for {{pronoun.object_pronoun}}." />
<style>
#form {
display: none;