add: drawings in questions
This commit is contained in:
parent
6be729de50
commit
16843a6ab8
28 changed files with 1181 additions and 32 deletions
|
@ -234,6 +234,9 @@ pub struct UserSettings {
|
|||
/// If timelines are paged instead of infinitely scrolled.
|
||||
#[serde(default)]
|
||||
pub paged_timelines: bool,
|
||||
/// If drawings are enabled for questions sent to the user.
|
||||
#[serde(default)]
|
||||
pub enable_drawings: bool,
|
||||
}
|
||||
|
||||
fn mime_avif() -> String {
|
||||
|
|
285
crates/core/src/model/carp.rs
Normal file
285
crates/core/src/model/carp.rs
Normal file
|
@ -0,0 +1,285 @@
|
|||
use serde::{Serialize, Deserialize};
|
||||
|
||||
/// Starting at the beginning of the file, the header details specific information
|
||||
/// about the file.
|
||||
///
|
||||
/// 1. `CG` tag (2 bytes)
|
||||
/// 2. version number (2 bytes)
|
||||
/// 3. width of graph (4 bytes)
|
||||
/// 4. height of graph (4 bytes)
|
||||
/// 5. `END_OF_HEADER`
|
||||
///
|
||||
/// The header has a total of 13 bytes. (12 of info, 1 of `END_OF_HEADER)
|
||||
///
|
||||
/// Everything after `END_OF_HEADER` should be another command and its parameters.
|
||||
pub const END_OF_HEADER: u8 = 0x1a;
|
||||
/// The color command marks the beginning of a hex-encoded color **string**.
|
||||
///
|
||||
/// The hastag character should **not** be included.
|
||||
pub const COLOR: u8 = 0x1b;
|
||||
/// The size command marks the beginning of a integer brush size.
|
||||
pub const SIZE: u8 = 0x2b;
|
||||
/// Marks the beginning of a new line.
|
||||
pub const LINE: u8 = 0x3b;
|
||||
/// A point marks the coordinates (relative to the previous `DELTA_ORIGIN`, or `(0, 0)`)
|
||||
/// in which a point should be drawn.
|
||||
///
|
||||
/// The size and color are that of the previous `COLOR` and `SIZE` commands.
|
||||
///
|
||||
/// Points are two `u32`s (or 8 bytes in length).
|
||||
pub const POINT: u8 = 0x4b;
|
||||
/// An end-of-file marker.
|
||||
pub const EOF: u8 = 0x1f;
|
||||
|
||||
/// A type of [`Command`].
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum CommandType {
|
||||
/// [`END_OF_HEADER`]
|
||||
EndOfHeader = END_OF_HEADER,
|
||||
/// [`COLOR`]
|
||||
Color = COLOR,
|
||||
/// [`SIZE`]
|
||||
Size = SIZE,
|
||||
/// [`LINE`]
|
||||
Line = LINE,
|
||||
/// [`POINT`]
|
||||
Point = POINT,
|
||||
/// [`EOF`]
|
||||
Eof = EOF,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Command {
|
||||
/// The type of the command.
|
||||
pub r#type: CommandType,
|
||||
/// Raw data as bytes.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl From<Command> for Vec<u8> {
|
||||
fn from(val: Command) -> Self {
|
||||
let mut d = val.data;
|
||||
d.insert(0, val.r#type as u8);
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// A graph is CarpGraph's representation of an image. It's essentially just a
|
||||
/// reproducable series of commands which a renderer can traverse to reconstruct
|
||||
/// an image.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct CarpGraph {
|
||||
pub header: Vec<u8>,
|
||||
pub dimensions: (u32, u32),
|
||||
pub commands: Vec<Command>,
|
||||
}
|
||||
|
||||
macro_rules! select_bytes {
|
||||
($count:literal, $from:ident) => {{
|
||||
let mut data: Vec<u8> = Vec::new();
|
||||
let mut seen_bytes = 0;
|
||||
|
||||
while let Some((_, byte)) = $from.next() {
|
||||
seen_bytes += 1;
|
||||
data.push(byte.to_owned());
|
||||
|
||||
if seen_bytes == $count {
|
||||
// we only need <count> bytes, stop just before we eat the next byte
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
data
|
||||
}};
|
||||
}
|
||||
|
||||
macro_rules! spread {
|
||||
($into:ident, $from:expr) => {
|
||||
for byte in &$from {
|
||||
$into.push(byte.to_owned())
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl CarpGraph {
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
|
||||
// reconstruct header
|
||||
spread!(out, self.header);
|
||||
spread!(out, self.dimensions.0.to_be_bytes()); // width
|
||||
spread!(out, self.dimensions.1.to_be_bytes()); // height
|
||||
out.push(END_OF_HEADER);
|
||||
|
||||
// reconstruct commands
|
||||
for command in &self.commands {
|
||||
out.push(command.r#type as u8);
|
||||
spread!(out, command.data);
|
||||
}
|
||||
|
||||
// ...
|
||||
out.push(EOF);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn from_bytes(bytes: Vec<u8>) -> Self {
|
||||
let mut header: Vec<u8> = Vec::new();
|
||||
let mut dimensions: (u32, u32) = (0, 0);
|
||||
let mut commands: Vec<Command> = Vec::new();
|
||||
|
||||
let mut in_header: bool = true;
|
||||
let mut byte_buffer: Vec<u8> = Vec::new(); // storage for bytes which need to construct a bigger type (like `u32`)
|
||||
|
||||
let mut bytes_iter = bytes.iter().enumerate();
|
||||
while let Some((i, byte)) = bytes_iter.next() {
|
||||
let byte = byte.to_owned();
|
||||
match byte {
|
||||
END_OF_HEADER => in_header = false,
|
||||
COLOR => {
|
||||
let data = select_bytes!(6, bytes_iter);
|
||||
commands.push(Command {
|
||||
r#type: CommandType::Color,
|
||||
data,
|
||||
});
|
||||
}
|
||||
SIZE => {
|
||||
let data = select_bytes!(2, bytes_iter);
|
||||
commands.push(Command {
|
||||
r#type: CommandType::Size,
|
||||
data,
|
||||
});
|
||||
}
|
||||
POINT => {
|
||||
let data = select_bytes!(8, bytes_iter);
|
||||
commands.push(Command {
|
||||
r#type: CommandType::Point,
|
||||
data,
|
||||
});
|
||||
}
|
||||
LINE => commands.push(Command {
|
||||
r#type: CommandType::Line,
|
||||
data: Vec::new(),
|
||||
}),
|
||||
EOF => break,
|
||||
_ => {
|
||||
if in_header {
|
||||
if (0..2).contains(&i) {
|
||||
// tag
|
||||
header.push(byte);
|
||||
} else if (2..4).contains(&i) {
|
||||
// version
|
||||
header.push(byte);
|
||||
} else if (4..8).contains(&i) {
|
||||
// width
|
||||
byte_buffer.push(byte);
|
||||
|
||||
if i == 7 {
|
||||
// end, construct from byte buffer
|
||||
let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
|
||||
dimensions.0 = u32::from_be_bytes(bytes.try_into().unwrap());
|
||||
byte_buffer = Vec::new();
|
||||
}
|
||||
} else if (8..12).contains(&i) {
|
||||
// height
|
||||
byte_buffer.push(byte);
|
||||
|
||||
if i == 11 {
|
||||
// end, construct from byte buffer
|
||||
let (bytes, _) = byte_buffer.split_at(size_of::<u32>());
|
||||
dimensions.1 = u32::from_be_bytes(bytes.try_into().unwrap());
|
||||
byte_buffer = Vec::new();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// misc byte
|
||||
println!("extraneous byte at {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
header,
|
||||
dimensions,
|
||||
commands,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_svg(&self) -> String {
|
||||
let mut out: String = String::new();
|
||||
out.push_str(&format!(
|
||||
"<svg viewBox=\"0 0 {} {}\" xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" style=\"background: white; width: {}px; height: {}px\" class=\"carpgraph\">",
|
||||
self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1
|
||||
));
|
||||
|
||||
// add lines
|
||||
let mut stroke_size: u16 = 2;
|
||||
let mut stroke_color: String = "000000".to_string();
|
||||
|
||||
let mut previous_x_y: Option<(u32, u32)> = None;
|
||||
let mut line_path = String::new();
|
||||
|
||||
for command in &self.commands {
|
||||
match command.r#type {
|
||||
CommandType::Size => {
|
||||
let (bytes, _) = command.data.split_at(size_of::<u16>());
|
||||
stroke_size = u16::from_be_bytes(bytes.try_into().unwrap_or([0, 0]));
|
||||
}
|
||||
CommandType::Color => {
|
||||
stroke_color =
|
||||
String::from_utf8(command.data.to_owned()).unwrap_or("#000000".to_string())
|
||||
}
|
||||
CommandType::Line => {
|
||||
if !line_path.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
|
||||
));
|
||||
}
|
||||
|
||||
previous_x_y = None;
|
||||
line_path = String::new();
|
||||
}
|
||||
CommandType::Point => {
|
||||
let (x, y) = command.data.split_at(size_of::<u32>());
|
||||
let point = ({ u32::from_be_bytes(x.try_into().unwrap()) }, {
|
||||
u32::from_be_bytes(y.try_into().unwrap())
|
||||
});
|
||||
|
||||
// add to path string
|
||||
line_path.push_str(&format!(
|
||||
" M{} {}{}",
|
||||
point.0,
|
||||
point.1,
|
||||
if let Some(pxy) = previous_x_y {
|
||||
// line to there
|
||||
format!(" L{} {}", pxy.0, pxy.1)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
));
|
||||
|
||||
previous_x_y = Some((point.0, point.1));
|
||||
|
||||
// add circular point
|
||||
out.push_str(&format!(
|
||||
"<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"#{stroke_color}\" />",
|
||||
point.0,
|
||||
point.1,
|
||||
stroke_size / 2 // the size is technically the diameter of the circle
|
||||
));
|
||||
}
|
||||
_ => unreachable!("never pushed to commands"),
|
||||
}
|
||||
}
|
||||
|
||||
if !line_path.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"<path d=\"{line_path}\" stroke=\"#{stroke_color}\" stroke-width=\"{stroke_size}\" />"
|
||||
));
|
||||
}
|
||||
|
||||
// return
|
||||
format!("{out}</svg>")
|
||||
}
|
||||
}
|
|
@ -345,6 +345,9 @@ pub struct Question {
|
|||
/// The IP of the question creator for IP blocking and identifying anonymous users.
|
||||
#[serde(default)]
|
||||
pub ip: String,
|
||||
/// The IDs of all uploads which hold this question's drawings.
|
||||
#[serde(default)]
|
||||
pub drawings: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Question {
|
||||
|
@ -369,6 +372,7 @@ impl Question {
|
|||
dislikes: 0,
|
||||
context: QuestionContext::default(),
|
||||
ip,
|
||||
drawings: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
pub mod addr;
|
||||
pub mod apps;
|
||||
pub mod auth;
|
||||
pub mod carp;
|
||||
pub mod channels;
|
||||
pub mod communities;
|
||||
pub mod communities_permissions;
|
||||
|
@ -46,6 +47,7 @@ pub enum Error {
|
|||
TitleInUse,
|
||||
QuestionsDisabled,
|
||||
RequiresSupporter,
|
||||
DrawingsDisabled,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
|
@ -68,6 +70,7 @@ impl Display for Error {
|
|||
Self::TitleInUse => "Title in use".to_string(),
|
||||
Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(),
|
||||
Self::RequiresSupporter => "Only site supporters can do this".to_string(),
|
||||
Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(),
|
||||
_ => format!("An unknown error as occurred: ({:?})", self),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ use crate::config::Config;
|
|||
use std::fs::{write, exists, remove_file};
|
||||
use super::{Error, Result};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[derive(Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum MediaType {
|
||||
#[serde(alias = "image/webp")]
|
||||
Webp,
|
||||
|
@ -17,6 +17,8 @@ pub enum MediaType {
|
|||
Jpg,
|
||||
#[serde(alias = "image/gif")]
|
||||
Gif,
|
||||
#[serde(alias = "image/carpgraph")]
|
||||
Carpgraph,
|
||||
}
|
||||
|
||||
impl MediaType {
|
||||
|
@ -27,6 +29,7 @@ impl MediaType {
|
|||
Self::Png => "png",
|
||||
Self::Jpg => "jpg",
|
||||
Self::Gif => "gif",
|
||||
Self::Carpgraph => "carpgraph",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue