mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 07:46:08 +00:00
craft-gif: GIF block reader (#939)
One walk over a GIF's blocks, without decompressing pixels: the logical screen, each frame's position, delay, disposal and transparent colour, the loop count and the first frame's colour table. Replaces PhotoCraft's two block walkers and gives the other apps a header-only animation probe.
This commit is contained in:
Generated
+7
@@ -1128,6 +1128,13 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-gif"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-hdr"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -46,6 +46,7 @@ craft-font-manager = { path = "crates/font-manager" }
|
||||
craft-font-store = { path = "crates/font-store" }
|
||||
craft-gainmap = { path = "crates/gainmap" }
|
||||
craft-gcode = { path = "crates/gcode" }
|
||||
craft-gif = { path = "crates/gif" }
|
||||
craft-hdr = { path = "crates/hdr" }
|
||||
craft-mesh = { path = "crates/mesh" }
|
||||
craft-heif = { path = "crates/heif" }
|
||||
|
||||
@@ -114,6 +114,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
millimetres to `G0`/`G1` moves with laser power and feed, for cutting lasers and plotters, as a
|
||||
commented file or as bare lines for streaming. Its `grbl` module has the real-time command bytes
|
||||
and the status and acknowledgement parsers.
|
||||
- [`craft-gif`](crates/gif/src/lib.rs): GIF block reader, from PhotoCraft: the logical screen,
|
||||
every frame's position, delay, disposal and transparent colour, the loop count and the first
|
||||
frame's colour table, without decompressing pixels. Tells an animation from a still and whether
|
||||
a file can hold the picture it declares before a decoder runs.
|
||||
- [`craft-hdr`](crates/hdr/src/lib.rs): Radiance HDR (RGBE) picture reader, from FilmCraft: flat,
|
||||
old and new run-length scan lines and all eight axis orders, to linear float RGB. Scan lines are
|
||||
validated before the picture is allocated.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "craft-gif"
|
||||
description = "GIF block reader: the logical screen, each frame's position, delay and transparency, the loop count and colour tables, without decompressing pixels."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Writes the animated test files the scan is checked against; not a product dependency.
|
||||
image = { version = "0.25", default-features = false, features = ["gif"] }
|
||||
@@ -0,0 +1,209 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
//! The blocks of a GIF, read without decompressing any pixels (GIF89a specification: Logical
|
||||
//! Screen Descriptor, Global and Local Color Tables, Graphic Control Extension, Application
|
||||
//! Extension, Image Descriptor).
|
||||
//!
|
||||
//! [`scan`] walks the file once and reports what an importer needs before it decodes anything:
|
||||
//! the logical screen size, every frame's position, size, delay and transparent colour, whether
|
||||
//! the file reached its trailer, and the loop count. That answers "is this an animation, how
|
||||
//! long is it, how many frames" and "can a file this small hold a picture this large" from the
|
||||
//! headers alone. [`palette`] gives the colour table of the first frame, for opening a GIF as an
|
||||
//! indexed image.
|
||||
//!
|
||||
//! Decoding pixels is not part of this crate; the apps use a GIF decoder for that and check its
|
||||
//! work against what [`scan`] says first.
|
||||
//!
|
||||
//! Input is treated as hostile: every read is bounds-checked, and the number of frames recorded
|
||||
//! is capped by the caller.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// One frame (image descriptor) of a GIF.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
/// File offset of the image descriptor (its `0x2C` separator).
|
||||
pub offset: usize,
|
||||
/// Position of the frame on the logical screen.
|
||||
pub left: u16,
|
||||
pub top: u16,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
/// Delay before the next frame in hundredths of a second, as stored (0 when the frame has no
|
||||
/// Graphic Control Extension). Players show very small delays longer; that is the caller's rule.
|
||||
pub delay_cs: u16,
|
||||
/// The transparent colour index, when the frame's Graphic Control Extension names one.
|
||||
pub transparent: Option<u8>,
|
||||
/// Disposal method (0–7) from the Graphic Control Extension.
|
||||
pub disposal: u8,
|
||||
/// Whether the frame has its own colour table.
|
||||
pub local_table: bool,
|
||||
}
|
||||
|
||||
/// What the blocks of a GIF say.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Info {
|
||||
/// Logical screen size.
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
/// Whether the file has a Global Color Table.
|
||||
pub global_table: bool,
|
||||
/// The frames found, in file order, up to the caller's limit.
|
||||
pub frames: Vec<Frame>,
|
||||
/// Whether the walk reached the trailer. When it did not (the file is cut short or damaged, or
|
||||
/// the frame limit was reached), more frames may exist than [`Info::frames`] holds.
|
||||
pub complete: bool,
|
||||
/// The NETSCAPE2.0 loop count: `Some(0)` loops forever, `None` when the file has none (play once).
|
||||
pub loop_count: Option<u16>,
|
||||
}
|
||||
|
||||
impl Info {
|
||||
/// Whether the file is known to hold more than one frame, or `None` when the walk stopped
|
||||
/// before the trailer after at most one frame, so frames past the break could not be counted.
|
||||
pub fn has_more_frames(&self) -> Option<bool> {
|
||||
if self.frames.len() > 1 { Some(true) } else { self.complete.then_some(false) }
|
||||
}
|
||||
|
||||
/// The sum of the frame delays in hundredths of a second, as stored.
|
||||
pub fn total_delay_cs(&self) -> u64 {
|
||||
self.frames.iter().map(|f| u64::from(f.delay_cs)).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` starts with a GIF signature.
|
||||
pub fn sniff(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a")
|
||||
}
|
||||
|
||||
/// Bytes of the colour table a packed-fields byte announces (0 without one).
|
||||
fn table_len(packed: u8) -> usize {
|
||||
if packed & 0x80 != 0 { 3usize << ((packed & 7) + 1) } else { 0 }
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// Skips the data sub-blocks starting at `at` (each length-prefixed, ended by a zero length).
|
||||
fn skip_sub_blocks(b: &[u8], mut at: usize) -> Option<usize> {
|
||||
loop {
|
||||
let n = usize::from(*b.get(at)?);
|
||||
at = at.checked_add(1 + n)?;
|
||||
if n == 0 {
|
||||
return Some(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walks the blocks of a GIF without decompressing any, recording at most `max_frames` frames.
|
||||
/// `None` when `bytes` is not a GIF or ends inside its Logical Screen Descriptor. A file that
|
||||
/// breaks off later gives what was read, with [`Info::complete`] false.
|
||||
pub fn scan(bytes: &[u8], max_frames: usize) -> Option<Info> {
|
||||
let b = bytes;
|
||||
if !sniff(b) {
|
||||
return None;
|
||||
}
|
||||
// Logical Screen Descriptor: width, height, packed fields, background, aspect.
|
||||
let (width, height, packed) = (u16_at(b, 6)?, u16_at(b, 8)?, *b.get(10)?);
|
||||
b.get(12)?;
|
||||
let mut info = Info { width, height, global_table: packed & 0x80 != 0, frames: Vec::new(), complete: false, loop_count: None };
|
||||
let mut at = 13 + table_len(packed);
|
||||
// What the last Graphic Control Extension said, for the next image.
|
||||
let (mut delay_cs, mut transparent, mut disposal) = (0u16, None, 0u8);
|
||||
// Each step either finishes the walk or moves `at` forward, so this ends with the file.
|
||||
let mut step = || -> Option<bool> {
|
||||
match *b.get(at)? {
|
||||
// Extension: label, then data sub-blocks up to a zero-length one.
|
||||
0x21 => {
|
||||
let label = *b.get(at + 1)?;
|
||||
let body = at + 2;
|
||||
// Graphic Control Extension: block size 4, packed (bit 0: transparent colour
|
||||
// given; bits 2–4: disposal), delay (2 bytes), transparent colour index.
|
||||
if label == 0xF9 && *b.get(body)? == 4 {
|
||||
let flags = *b.get(body + 1)?;
|
||||
delay_cs = u16_at(b, body + 2)?;
|
||||
transparent = (flags & 0x01 != 0).then_some(*b.get(body + 4)?);
|
||||
disposal = (flags >> 2) & 7;
|
||||
}
|
||||
// Application Extension NETSCAPE2.0 (or ANIMEXTS1.0): sub-block 3, 1, loop count.
|
||||
if label == 0xFF
|
||||
&& matches!(b.get(body..body + 12), Some([11, id @ ..]) if id == b"NETSCAPE2.0" || id == b"ANIMEXTS1.0")
|
||||
&& b.get(body + 12..body + 14) == Some(&[3, 1])
|
||||
{
|
||||
info.loop_count = u16_at(b, body + 14);
|
||||
}
|
||||
at = skip_sub_blocks(b, body)?;
|
||||
Some(true)
|
||||
}
|
||||
// Image Descriptor: separator, left, top, width, height, packed fields, then the
|
||||
// Local Color Table if bit 7 is set, the LZW code size and the data sub-blocks.
|
||||
0x2C => {
|
||||
if info.frames.len() >= max_frames {
|
||||
return None;
|
||||
}
|
||||
let packed = *b.get(at + 9)?;
|
||||
info.frames.push(Frame {
|
||||
offset: at,
|
||||
left: u16_at(b, at + 1)?,
|
||||
top: u16_at(b, at + 3)?,
|
||||
width: u16_at(b, at + 5)?,
|
||||
height: u16_at(b, at + 7)?,
|
||||
delay_cs,
|
||||
transparent,
|
||||
disposal,
|
||||
local_table: packed & 0x80 != 0,
|
||||
});
|
||||
(delay_cs, transparent, disposal) = (0, None, 0);
|
||||
at = skip_sub_blocks(b, (at + 10).checked_add(table_len(packed))?.checked_add(1)?)?;
|
||||
Some(true)
|
||||
}
|
||||
// Trailer.
|
||||
0x3B => {
|
||||
info.complete = true;
|
||||
Some(false)
|
||||
}
|
||||
// Anything else: the blocks break off here.
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
while step() == Some(true) {}
|
||||
Some(info)
|
||||
}
|
||||
|
||||
/// The colours of the table the first frame uses (its Local Color Table when it has one, else
|
||||
/// the Global one) and its transparent entry. `None` for anything that is not a well-formed GIF
|
||||
/// up to its first image, or a first frame without a table. A transparent index past the table
|
||||
/// is no transparent entry.
|
||||
pub fn palette(bytes: &[u8]) -> Option<(Vec<[u8; 3]>, Option<u8>)> {
|
||||
let info = scan(bytes, 1)?;
|
||||
let first = info.frames.first()?;
|
||||
let (at, packed) = if first.local_table { (first.offset + 10, *bytes.get(first.offset + 9)?) } else { (13, *bytes.get(10)?) };
|
||||
let colors = bytes.get(at..at.checked_add(table_len(packed))?)?.as_chunks::<3>().0.to_vec();
|
||||
if colors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let transparent = first.transparent.filter(|i| usize::from(*i) < colors.len());
|
||||
Some((colors, transparent))
|
||||
}
|
||||
|
||||
/// Whether a file of `file_len` bytes can plausibly hold `declared_bytes` of decoded picture: at
|
||||
/// most `max_ratio` decoded bytes for each file byte, plus 1 MiB. A decoder that sets up the whole
|
||||
/// logical screen before it reads a frame should be given only files that pass this.
|
||||
pub fn plausible(declared_bytes: u64, file_len: usize, max_ratio: u64) -> bool {
|
||||
declared_bytes <= (file_len as u64).saturating_mul(max_ratio).saturating_add(1 << 20)
|
||||
}
|
||||
|
||||
impl Info {
|
||||
/// The decoded bytes the file must plausibly back at `bytes_per_pixel`: the first frame, and
|
||||
/// whatever of the logical screen exceeds `unbacked_canvas` bytes (the screen around the first
|
||||
/// frame needs no data, so that much of it is free). Use with [`plausible`].
|
||||
pub fn bytes_to_back(&self, bytes_per_pixel: u64, unbacked_canvas: u64) -> u64 {
|
||||
let screen = u64::from(self.width).saturating_mul(u64::from(self.height)).saturating_mul(bytes_per_pixel);
|
||||
match self.frames.first() {
|
||||
Some(f) => {
|
||||
u64::from(f.width).saturating_mul(u64::from(f.height)).saturating_mul(bytes_per_pixel).max(screen.saturating_sub(unbacked_canvas))
|
||||
}
|
||||
None => screen,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! The palette tests are PhotoCraft's; the scan is also checked against files written by the
|
||||
//! `image` crate's GIF encoder.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A GIF up to its first image: `global` table entries, optional GCE transparency, optional
|
||||
/// local table.
|
||||
fn gif(global: &[[u8; 3]], transparent: Option<u8>, local: Option<&[[u8; 3]]>) -> Vec<u8> {
|
||||
let size_bits = |n: usize| (n.max(2).next_power_of_two().trailing_zeros() - 1) as u8;
|
||||
let mut b = b"GIF89a".to_vec();
|
||||
b.extend([4, 0, 4, 0]);
|
||||
b.push(if global.is_empty() { 0 } else { 0x80 | size_bits(global.len()) });
|
||||
b.extend([0, 0]);
|
||||
let pad = |b: &mut Vec<u8>, t: &[[u8; 3]]| {
|
||||
let n = 2usize << size_bits(t.len());
|
||||
for k in 0..n {
|
||||
b.extend(t.get(k).copied().unwrap_or([0, 0, 0]));
|
||||
}
|
||||
};
|
||||
if !global.is_empty() {
|
||||
pad(&mut b, global);
|
||||
}
|
||||
// A comment extension first, to skip.
|
||||
b.extend([0x21, 0xFE, 3, b'h', b'i', b'!', 0]);
|
||||
if let Some(i) = transparent {
|
||||
b.extend([0x21, 0xF9, 4, 0x01, 0, 0, i, 0]);
|
||||
}
|
||||
b.extend([0x2C, 0, 0, 0, 0, 4, 0, 4, 0]);
|
||||
match local {
|
||||
Some(t) => {
|
||||
b.push(0x80 | size_bits(t.len()));
|
||||
pad(&mut b, t);
|
||||
}
|
||||
None => b.push(0),
|
||||
}
|
||||
b.extend([2, 2, 0x4C, 0x01, 0, 0x3B]);
|
||||
b
|
||||
}
|
||||
|
||||
/// An animated GIF of solid 2 × 2 frames with these delays (hundredths), written by `image`.
|
||||
fn animated(delays_cs: &[u32], repeat: Option<image::codecs::gif::Repeat>) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = image::codecs::gif::GifEncoder::new(&mut out);
|
||||
if let Some(r) = repeat {
|
||||
enc.set_repeat(r).unwrap();
|
||||
}
|
||||
for (i, &cs) in delays_cs.iter().enumerate() {
|
||||
let buf = image::RgbaImage::from_pixel(2, 2, image::Rgba([i as u8 * 40, 0, 0, 255]));
|
||||
enc.encode_frame(image::Frame::from_parts(buf, 0, 0, image::Delay::from_numer_denom_ms(cs * 10, 1))).unwrap();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_frames_table_and_transparent_entry() {
|
||||
let g = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 255]];
|
||||
assert_eq!(palette(&gif(&g, None, None)), Some((g.to_vec(), None)));
|
||||
assert_eq!(palette(&gif(&g, Some(2), None)), Some((g.to_vec(), Some(2))));
|
||||
// A local table wins over the global one.
|
||||
let l = [[1, 2, 3], [4, 5, 6]];
|
||||
assert_eq!(palette(&gif(&g, Some(1), Some(&l))), Some((l.to_vec(), Some(1))));
|
||||
// A transparent index past the table is no transparent entry.
|
||||
assert_eq!(palette(&gif(&l, Some(9), None)), Some((l.to_vec(), None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_table_or_not_a_gif_is_none() {
|
||||
assert_eq!(palette(&gif(&[], None, None)), None, "no table at all");
|
||||
assert_eq!(palette(b"\x89PNG\r\n\x1a\n"), None);
|
||||
assert_eq!(palette(b""), None);
|
||||
assert_eq!(scan(b"\x89PNG\r\n\x1a\n", 10), None);
|
||||
assert_eq!(scan(b"GIF89a\x01\x00", 10), None);
|
||||
assert!(sniff(b"GIF87a") && sniff(b"GIF89a...") && !sniff(b"GIF88a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scan_reports_the_screen_and_the_frame() {
|
||||
let g = [[9, 9, 9], [8, 8, 8]];
|
||||
let info = scan(&gif(&g, Some(1), None), 10).unwrap();
|
||||
assert_eq!((info.width, info.height, info.global_table, info.complete, info.loop_count), (4, 4, true, true, None));
|
||||
let [f] = info.frames.as_slice() else { panic!("{:?}", info.frames) };
|
||||
assert_eq!((f.left, f.top, f.width, f.height, f.delay_cs, f.transparent, f.disposal, f.local_table), (0, 0, 4, 4, 0, Some(1), 0, false));
|
||||
assert_eq!(info.has_more_frames(), Some(false));
|
||||
let info = scan(&gif(&g, None, Some(&g)), 10).unwrap();
|
||||
assert_eq!((info.frames[0].local_table, info.frames[0].transparent), (true, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_animation_reports_every_delay_and_the_loop_count() {
|
||||
use image::codecs::gif::Repeat;
|
||||
let bytes = animated(&[5, 10, 0, 7], Some(Repeat::Infinite));
|
||||
let info = scan(&bytes, 100).unwrap();
|
||||
assert_eq!((info.width, info.height, info.complete, info.loop_count), (2, 2, true, Some(0)));
|
||||
assert_eq!(info.frames.iter().map(|f| f.delay_cs).collect::<Vec<_>>(), [5, 10, 0, 7]);
|
||||
assert_eq!((info.total_delay_cs(), info.has_more_frames()), (22, Some(true)));
|
||||
assert!(info.frames.iter().all(|f| (f.width, f.height) == (2, 2)));
|
||||
assert_eq!(scan(&animated(&[5, 5], Some(Repeat::Finite(3))), 100).unwrap().loop_count, Some(3));
|
||||
assert_eq!(scan(&animated(&[5], None), 100).unwrap().has_more_frames(), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frame_limit_stops_the_walk() {
|
||||
let bytes = animated(&[1, 2, 3, 4, 5], None);
|
||||
let info = scan(&bytes, 3).unwrap();
|
||||
assert_eq!((info.frames.len(), info.complete, info.has_more_frames()), (3, false, Some(true)));
|
||||
let info = scan(&bytes, 5).unwrap();
|
||||
assert_eq!((info.frames.len(), info.complete), (5, true));
|
||||
// One frame recorded and the walk stopped: whether more exist is not known.
|
||||
let info = scan(&bytes, 1).unwrap();
|
||||
assert_eq!((info.frames.len(), info.has_more_frames()), (1, None));
|
||||
assert_eq!(scan(&bytes, 0).unwrap().frames.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_or_damaged_file_gives_what_was_read() {
|
||||
let bytes = animated(&[4, 4, 4], None);
|
||||
let whole = scan(&bytes, 100).unwrap();
|
||||
for cut in 0..bytes.len() {
|
||||
// Never a panic; never complete; never more frames than the whole file has.
|
||||
if let Some(info) = scan(&bytes[..cut], 100) {
|
||||
assert!(!info.complete && info.frames.len() <= whole.frames.len(), "cut {cut}");
|
||||
}
|
||||
let _ = palette(&bytes[..cut]);
|
||||
}
|
||||
for i in 0..bytes.len() {
|
||||
for v in [0u8, 0x21, 0x2C, 0x3B, 0xFF] {
|
||||
let mut b = bytes.clone();
|
||||
b[i] = v;
|
||||
if let Some(info) = scan(&b, 100) {
|
||||
assert!(info.frames.iter().all(|f| f.offset < b.len()));
|
||||
}
|
||||
let _ = palette(&b);
|
||||
}
|
||||
}
|
||||
// A cut inside the second frame keeps the first.
|
||||
let second = whole.frames[1].offset;
|
||||
let info = scan(&bytes[..second + 5], 100).unwrap();
|
||||
assert_eq!((info.frames.len(), info.complete, info.has_more_frames()), (1, false, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plausibility_of_a_screen_for_a_file_size() {
|
||||
// A 20-byte file declaring a 65535 × 65535 screen around a 1 × 1 frame.
|
||||
let mut b = gif(&[[0, 0, 0], [1, 1, 1]], None, None);
|
||||
b[6..10].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
|
||||
let n = b.len();
|
||||
b[n - 11..n - 7].copy_from_slice(&[1, 0, 1, 0]);
|
||||
let info = scan(&b, 1).unwrap();
|
||||
assert_eq!((info.width, info.frames[0].width, info.frames[0].height), (65535, 1, 1));
|
||||
let screen = 65535u64 * 65535 * 4;
|
||||
// 256 MiB of screen is free; the rest must be backed by file.
|
||||
assert_eq!(info.bytes_to_back(4, 1 << 28), screen - (1 << 28));
|
||||
assert!(!plausible(info.bytes_to_back(4, 1 << 28), b.len(), 1 << 14));
|
||||
// A small screen needs only its first frame backed.
|
||||
let small = scan(&gif(&[[0, 0, 0], [1, 1, 1]], None, None), 1).unwrap();
|
||||
assert_eq!(small.bytes_to_back(4, 1 << 28), 64);
|
||||
assert!(plausible(64, 40, 1 << 14));
|
||||
// The ratio and the 1 MiB allowance, at and past the boundary.
|
||||
assert!(plausible((1 << 20) + 10 * 4, 10, 4));
|
||||
assert!(!plausible((1 << 20) + 10 * 4 + 1, 10, 4));
|
||||
}
|
||||
Reference in New Issue
Block a user