Skip to main content

sioul_app/
attach.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What a computer attaches to a text, made fit for a multimedia message
5//! (docs/texts.md, "Multimedia messages sent from a computer"; decisions 6
6//! to 10), before anything travels: so the sharing, the server and the phone
7//! only ever carry what will be sent.
8//!
9//! - **Pictures** (JPEG, PNG, WebP, TIFF, BMP, a still GIF; HEIC, AVIF and
10//!   JPEG XL where Qt reads them), told by their first bytes (`sniff`), are
11//!   always written again as a baseline JPEG (cpp/pictures.cpp), even one
12//!   that fits: their orientation applied, in sRGB, laid on white, with no
13//!   metadata at all (no GPS position, camera, time, comment or colour
14//!   profile); the long side at most 1,600 pixels (and the carrier's advice),
15//!   quality 85, then lowered to 50, then a quarter smaller each time, never
16//!   under 480 pixels (`fit`). Only a picture's own reader decodes it: never
17//!   a document Qt's plugins could also render (PDF, SVG, PostScript, a
18//!   camera's raw file).
19//! - **An animated GIF** goes with its frames as they are, written again
20//!   without its comments and other programs' blocks (XMP, a profile), when
21//!   the message holds it (`gif_pictures_alone`), else is refused in words.
22//! - **A video, a sound, a contact card, a calendar entry**, told by their
23//!   first bytes too (`sniff_medium`), go as they are, with what their files
24//!   hold, when the message holds them, else are refused in words.
25//! - **Other files** are refused, whatever their names say: a phone may
26//!   never open them. Send them by mail.
27//! - **Up to five media** a message (`texts::MEDIA_MAX`), the pictures
28//!   sharing equally what the others leave (`share`), 16 KB each at least;
29//!   what a picture going at its best leaves goes to those made smaller.
30//!
31//! The rules are pure and tested here; Qt only reads and writes the pictures,
32//! in a thread of their own, never the window's (tests/attach.rs: no
33//! metadata left, each format, the whole making).
34
35use sioul_core::mms;
36use sioul_core::texts as rules;
37use std::io::Read;
38use std::path::{Path, PathBuf};
39
40/// A picture's long side at most (OMA's guaranteed 1600 × 1200, decision 6).
41pub(crate) const FIRST_SIDE: u32 = 1600;
42/// …and at least: smaller than this, a picture is refused rather than sent unreadable.
43pub(crate) const LAST_SIDE: u32 = 480;
44const FIRST_QUALITY: u8 = 85;
45const LAST_QUALITY: u8 = 50;
46/// A picture's share at least (AOSP Messaging's `MIN_IMAGE_BYTE_SIZE`).
47pub(crate) const MIN_SHARE: u64 = 16 * 1024;
48/// What the words may take, kept out of the media's room as they are typed:
49/// 1,600 characters of four bytes each.
50pub(crate) const WORDS_RESERVE: usize = rules::WRITE_MAX * 4;
51
52/// A picture's long side and quality that fit its share (decision 6):
53/// first its long side at `first` (1,600 pixels, less as its carrier
54/// advises, never more than its own) at quality 85; over its share, the
55/// quality searched down to 50, the interval halved four times at most;
56/// still over at 50, a quarter smaller and searched again; never under 480
57/// pixels. `write` writes the picture at a long side and a quality and says
58/// its size (none: not written). None: it cannot be made small enough.
59pub(crate) fn fit(first: u32, share: u64, write: &mut dyn FnMut(u32, u8) -> Option<u64>) -> Option<(u32, u8)> {
60    let fits = |size: Option<u64>| size.is_some_and(|size| size <= share);
61    let mut side = first;
62    loop {
63        if fits(write(side, FIRST_QUALITY)) {
64            return Some((side, FIRST_QUALITY));
65        }
66        if fits(write(side, LAST_QUALITY)) {
67            let (mut good, mut over) = (LAST_QUALITY, FIRST_QUALITY);
68            for _ in 0..4 {
69                if over - good <= 1 {
70                    break;
71                }
72                let middle = good + (over - good) / 2;
73                if fits(write(side, middle)) {
74                    good = middle;
75                } else {
76                    over = middle;
77                }
78            }
79            return Some((side, good));
80        }
81        let smaller = (f64::from(side) * 0.75).round() as u32;
82        if smaller < LAST_SIDE {
83            return None;
84        }
85        side = smaller;
86    }
87}
88
89/// Each picture's share of the room a message leaves for its media's bytes
90/// (decision 10): the room less what goes as it is, shared equally, 16 KB
91/// each at least; none when it cannot be so.
92pub(crate) fn share(room: u64, fixed: u64, pictures: u64) -> Option<u64> {
93    let left = room.checked_sub(fixed)?;
94    if pictures == 0 {
95        return Some(left);
96    }
97    let each = left / pictures;
98    (each >= MIN_SHARE).then_some(each)
99}
100
101/// A picture's format by its first bytes, among those Sioul sends as a
102/// JPEG: Qt's name for it ("jpeg", "png", "gif", "webp", "tiff", "bmp",
103/// "heif", "avif", "jxl"); none for anything else, whatever its name says.
104pub(crate) fn sniff(head: &[u8]) -> Option<&'static str> {
105    let at = |from: usize, bytes: &[u8]| head.get(from..from + bytes.len()) == Some(bytes);
106    if at(0, &[0xFF, 0xD8, 0xFF]) {
107        return Some("jpeg");
108    }
109    if at(0, b"\x89PNG\r\n\x1a\n") {
110        return Some("png");
111    }
112    if at(0, b"GIF87a") || at(0, b"GIF89a") {
113        return Some("gif");
114    }
115    if at(0, b"RIFF") && at(8, b"WEBP") {
116        return Some("webp");
117    }
118    if at(0, b"II*\0") || at(0, b"MM\0*") {
119        return Some("tiff");
120    }
121    if at(0, b"BM") && head.len() >= 26 {
122        return Some("bmp");
123    }
124    if at(0, &[0xFF, 0x0A]) || at(0, b"\0\0\0\x0cJXL \r\n\x87\n") {
125        return Some("jxl");
126    }
127    // ISO's media files: a box `ftyp`, its major brand, its version, its other brands.
128    if at(4, b"ftyp") {
129        let length = head.get(0..4).map_or(0, |b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize).min(head.len());
130        let brands: Vec<&[u8]> = std::iter::once(head.get(8..12).unwrap_or_default()).chain(head.get(16..length).unwrap_or_default().chunks_exact(4)).collect();
131        if brands.iter().any(|b| matches!(*b, b"avif" | b"avis")) {
132            return Some("avif");
133        }
134        if brands.iter().any(|b| matches!(*b, b"heic" | b"heix" | b"heim" | b"heis" | b"hevc" | b"hevx" | b"mif1" | b"msf1")) {
135            return Some("heif");
136        }
137    }
138    None
139}
140
141/// A picture's format in words, for "this computer cannot read … pictures".
142pub(crate) fn format_words(format: &str) -> &'static str {
143    match format {
144        "jpeg" => "JPEG",
145        "png" => "PNG",
146        "gif" => "GIF",
147        "webp" => "WebP",
148        "tiff" => "TIFF",
149        "bmp" => "BMP",
150        "heif" => "HEIC",
151        "avif" => "AVIF",
152        "jxl" => "JPEG XL",
153        _ => "",
154    }
155}
156
157/// What a file attached is (decisions 7 to 9).
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub(crate) enum Kind {
160    /// A still picture: written again as a JPEG, made small.
161    Picture,
162    /// An animated GIF: its frames as they are, when it fits.
163    Animated,
164    /// A video, a sound, a contact card or a calendar entry, as it is, when it fits: its content type.
165    AsIs(&'static str),
166    /// Refused, said why: "kind" (a picture this computer's Qt cannot read:
167    /// HEIC on most), "file" (another file, whatever its name says).
168    Refused(&'static str),
169}
170
171/// A video's, a sound's, a contact card's or a calendar entry's content
172/// type by the file's first bytes (`head`), never by its name: MP4, MOV and
173/// 3GP (ISO's boxes, their brands), WebM, AMR, AAC, MP3, Ogg, WAV, vCard,
174/// vCalendar and iCalendar. The name only tells an MP4 of sound from one of
175/// video, which share their brands. None for anything else (a PDF named
176/// `.mp4` included).
177pub(crate) fn sniff_medium(head: &[u8], name: &str) -> Option<&'static str> {
178    let at = |from: usize, bytes: &[u8]| head.get(from..from + bytes.len()) == Some(bytes);
179    if at(4, b"ftyp") {
180        let brand = head.get(8..12)?;
181        let sound = name.rsplit_once('.').is_some_and(|(_, e)| e.eq_ignore_ascii_case("m4a"));
182        return match brand {
183            b"qt  " => Some("video/quicktime"),
184            b"M4A " | b"M4B " | b"M4P " => Some("audio/mp4"),
185            b if [b"3gp".as_slice(), b"3ge", b"3gg", b"3gs", b"3gr", b"3g2"].iter().any(|start| b.starts_with(start)) => Some("video/3gpp"),
186            b"isom" | b"iso2" | b"iso3" | b"iso4" | b"iso5" | b"iso6" | b"mp41" | b"mp42" | b"avc1" | b"M4V " | b"M4VH" | b"M4VP" | b"MSNV" | b"dash" | b"mmp4" => Some(if sound { "audio/mp4" } else { "video/mp4" }),
187            _ => None,
188        };
189    }
190    if at(0, &[0x1A, 0x45, 0xDF, 0xA3]) {
191        return webm(head).then_some("video/webm");
192    }
193    if at(0, b"#!AMR") {
194        return Some("audio/amr");
195    }
196    if at(0, b"OggS") {
197        return Some("audio/ogg");
198    }
199    if at(0, b"RIFF") && at(8, b"WAVE") {
200        return Some("audio/wav");
201    }
202    // An ID3 tag: its version (2 to 4) and its size in four bytes of seven bits each.
203    if at(0, b"ID3") && head.get(3).is_some_and(|v| (2..=4).contains(v)) && head.get(6..10).is_some_and(|size| size.iter().all(|b| *b < 0x80)) {
204        return Some("audio/mpeg");
205    }
206    // A frame, whole and valid, then the next frame's at the length its header gives: AAC's (ADTS),
207    // MPEG audio's; never two bytes alone, which a text in UTF-16 begins with (FF FE: the review's L-d).
208    let followed = |length: Option<usize>, next: &dyn Fn(&[u8]) -> Option<usize>| length.is_some_and(|length| head.get(length..).is_some_and(|rest| next(rest).is_some()));
209    if followed(adts_frame(head), &adts_frame) {
210        return Some("audio/aac");
211    }
212    if followed(mpeg_frame(head), &|rest: &[u8]| mpeg_frame(rest).filter(|_| rest.get(1).map(|b| b & 0xFE) == head.get(1).map(|b| b & 0xFE))) {
213        return Some("audio/mpeg");
214    }
215    // Cards and calendar entries: text, perhaps after a byte-order mark and blank lines.
216    let text = String::from_utf8_lossy(head.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(head)).trim_start().to_ascii_uppercase();
217    if text.starts_with("BEGIN:VCARD") {
218        return Some("text/x-vCard");
219    }
220    if text.starts_with("BEGIN:VCALENDAR") {
221        return Some(if text.contains("VERSION:1.0") { "text/x-vCalendar" } else { "text/calendar" });
222    }
223    None
224}
225
226/// An MPEG audio frame's length by its header (four bytes): its version,
227/// layer, bitrate, sampling rate and padding; none for what is no valid
228/// header (a reserved version or layer, a free or bad bitrate, a reserved rate).
229fn mpeg_frame(header: &[u8]) -> Option<usize> {
230    let [first, second, third, _] = *header.get(..4)? else { return None };
231    if first != 0xFF || second & 0xE0 != 0xE0 {
232        return None;
233    }
234    // Version: 0 MPEG-2.5, 1 reserved, 2 MPEG-2, 3 MPEG-1. Layer: 0 reserved, 1 III, 2 II, 3 I.
235    let (version, layer) = ((second >> 3) & 3, (second >> 1) & 3);
236    let (bitrate, rate) = (usize::from(third >> 4), usize::from((third >> 2) & 3));
237    if version == 1 || layer == 0 || bitrate == 0 || bitrate == 15 || rate == 3 {
238        return None;
239    }
240    const MPEG1: [[u32; 15]; 3] = [
241        [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448],
242        [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384],
243        [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320],
244    ];
245    const MPEG2: [[u32; 15]; 2] = [[0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256], [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160]];
246    let kbps = match (version, layer) {
247        (3, 3) => MPEG1[0][bitrate],
248        (3, 2) => MPEG1[1][bitrate],
249        (3, _) => MPEG1[2][bitrate],
250        (_, 3) => MPEG2[0][bitrate],
251        _ => MPEG2[1][bitrate],
252    };
253    let hertz = [[11_025u32, 12_000, 8_000], [0, 0, 0], [22_050, 24_000, 16_000], [44_100, 48_000, 32_000]][usize::from(version)][rate];
254    let (bits, padding) = (kbps * 1000, u32::from((third >> 1) & 1));
255    let length = match layer {
256        3 => (12 * bits / hertz + padding) * 4,
257        1 if version != 3 => 72 * bits / hertz + padding,
258        _ => 144 * bits / hertz + padding,
259    };
260    Some(length as usize)
261}
262
263/// An AAC frame's length by its ADTS header (seven bytes): its sync, layer
264/// 0, a sampling rate that exists; none else.
265fn adts_frame(header: &[u8]) -> Option<usize> {
266    let header = header.get(..7)?;
267    if header[0] != 0xFF || header[1] & 0xF6 != 0xF0 || (header[2] >> 2) & 0x0F >= 13 {
268        return None;
269    }
270    let length = (usize::from(header[3] & 0x03) << 11) | (usize::from(header[4]) << 3) | usize::from(header[5] >> 5);
271    (length >= 7).then_some(length)
272}
273
274/// Whether a Matroska file is a WebM one, by its EBML header's DocType
275/// element ("webm"), never by "webm" anywhere in its first bytes.
276fn webm(head: &[u8]) -> bool {
277    // A size: its length told by its first byte's leading zeros, that byte's marker left out.
278    let size = |at: usize| -> Option<(usize, usize)> {
279        let first = *head.get(at)?;
280        let length = first.leading_zeros() as usize + 1;
281        if length > 8 {
282            return None;
283        }
284        let mut value = u64::from(first) & (0xFF >> length);
285        for i in 1..length {
286            value = (value << 8) | u64::from(*head.get(at + i)?);
287        }
288        Some((usize::try_from(value).ok()?, length))
289    };
290    let Some((header, n)) = size(4) else { return false };
291    let end = (4 + n).saturating_add(header).min(head.len());
292    let mut at = 4 + n;
293    while at < end {
294        // An element: its ID (one to four bytes, by its first byte's leading zeros), its size, its body.
295        let id_length = head[at].leading_zeros() as usize + 1;
296        let (Some(id), Some((length, n))) = (head.get(at..at + id_length).filter(|_| id_length <= 4), size(at + id_length)) else { return false };
297        let body = at + id_length + n;
298        if id == [0x42, 0x82] {
299            return head.get(body..body.saturating_add(length)) == Some(b"webm");
300        }
301        at = body.saturating_add(length);
302    }
303    false
304}
305
306/// What a file is (decisions 7 to 9), by its first bytes alone: a picture
307/// (`picture`, as `sniff` tells it), an animated GIF when it holds several
308/// frames, refused where Qt here reads no such picture (`reads`); else a
309/// video, a sound, a contact card or a calendar entry (`medium`, as
310/// `sniff_medium` tells it); else another file, refused.
311pub(crate) fn kind_of(picture: Option<&str>, reads: bool, frames: u32, medium: Option<&'static str>) -> Kind {
312    match (picture, medium) {
313        (Some(_), _) if !reads => Kind::Refused("kind"),
314        (Some("gif"), _) if frames > 1 => Kind::Animated,
315        (Some(_), _) => Kind::Picture,
316        (None, Some(ct)) => Kind::AsIs(ct),
317        (None, None) => Kind::Refused("file"),
318    }
319}
320
321/// An animated GIF written again with its pictures alone (the review of 18
322/// October 2026, L-1, and of its re-check, L-c): its header, its screen and
323/// colour tables and each frame's image kept byte for byte; each frame's
324/// timing (its graphic control) written as its own four bytes alone, its
325/// loop (NETSCAPE2.0's or ANIMEXTS1.0's block) as its first sub-block alone
326/// (`01` and the count), whatever else a crafted GIF puts in them; its
327/// comments, plain-text blocks, other programs' blocks (XMP, a colour
328/// profile, an editor's notes) and whatever follows its end left out; its
329/// end written when it lacks one (Qt reads such a GIF). No frame is decoded:
330/// each block says its own length. None for a GIF cut inside a block.
331pub(crate) fn gif_pictures_alone(bytes: &[u8]) -> Option<Vec<u8>> {
332    // Where a run of sub-blocks (a length, its bytes, until a length of 0) ends.
333    let blocks_end = |mut at: usize| -> Option<usize> {
334        loop {
335            let length = usize::from(*bytes.get(at)?);
336            at += 1;
337            if length == 0 {
338                return Some(at);
339            }
340            at += length;
341            if at > bytes.len() {
342                return None;
343            }
344        }
345    };
346    let table = |packed: u8| if packed & 0x80 != 0 { 3 * (1usize << ((packed & 0x07) + 1)) } else { 0 };
347    let screen = bytes.get(..13)?;
348    if !(screen.starts_with(b"GIF87a") || screen.starts_with(b"GIF89a")) {
349        return None;
350    }
351    let mut at = 13 + table(screen[10]);
352    let mut out = bytes.get(..at)?.to_vec();
353    loop {
354        // Its end, or none after its last whole block.
355        let Some(&block) = bytes.get(at) else {
356            out.push(0x3B);
357            return Some(out);
358        };
359        match block {
360            0x3B => {
361                out.push(0x3B);
362                return Some(out);
363            }
364            0x2C => {
365                let descriptor = bytes.get(at..at + 10)?;
366                // Its colour table, the LZW code size, then its data's sub-blocks.
367                let end = blocks_end(at + 10 + table(descriptor[9]) + 1)?;
368                out.extend_from_slice(&bytes[at..end]);
369                at = end;
370            }
371            0x21 => {
372                let label = *bytes.get(at + 1)?;
373                let end = blocks_end(at + 2)?;
374                // A frame's timing: its four bytes (disposal and transparency, delay, transparent colour) alone.
375                if label == 0xF9 && bytes.get(at + 2) == Some(&4) {
376                    out.extend_from_slice(&[0x21, 0xF9, 4]);
377                    out.extend_from_slice(bytes.get(at + 3..at + 7)?);
378                    out.push(0);
379                }
380                // The loop: its name and the count of its first sub-block (`03 01 lo hi`) alone.
381                let looping = label == 0xFF && bytes.get(at + 2) == Some(&11) && matches!(bytes.get(at + 3..at + 14), Some(b"NETSCAPE2.0" | b"ANIMEXTS1.0"));
382                if looping && bytes.get(at + 14..at + 16) == Some(&[3u8, 1][..]) {
383                    out.extend_from_slice(bytes.get(at..at + 18)?);
384                    out.push(0);
385                }
386                at = end;
387            }
388            _ => return None,
389        }
390    }
391}
392
393/// A medium's file's extension in Sioul's cache, by its content type (never the file chosen's).
394fn extension_of(ct: &str) -> &'static str {
395    match ct {
396        "image/jpeg" => "jpg",
397        "image/gif" => "gif",
398        "video/mp4" => "mp4",
399        "video/quicktime" => "mov",
400        "video/3gpp" => "3gp",
401        "video/webm" => "webm",
402        "audio/mp4" => "m4a",
403        "audio/amr" => "amr",
404        "audio/aac" => "aac",
405        "audio/mpeg" => "mp3",
406        "audio/ogg" => "ogg",
407        "audio/wav" => "wav",
408        "text/x-vCard" => "vcf",
409        "text/x-vCalendar" => "vcs",
410        "text/calendar" => "ics",
411        _ => "dat",
412    }
413}
414
415/// A picture decoded by Qt (cpp/pictures.cpp), let go when dropped. Used on
416/// the thread that opened it.
417#[cfg(not(target_os = "android"))]
418pub(crate) struct Picture {
419    picture: *mut std::ffi::c_void,
420    pub(crate) width: u32,
421    pub(crate) height: u32,
422    pub(crate) frames: u32,
423}
424
425#[cfg(not(target_os = "android"))]
426unsafe extern "C" {
427    // SAFETY: as cpp/pictures.cpp defines them: C strings in UTF-8.
428    fn sioul_picture_reads(format: *const std::ffi::c_char) -> std::ffi::c_int;
429    fn sioul_picture_open(path: *const std::ffi::c_char, format: *const std::ffi::c_char, largest: std::ffi::c_int, width: *mut std::ffi::c_int, height: *mut std::ffi::c_int, frames: *mut std::ffi::c_int) -> *mut std::ffi::c_void;
430    fn sioul_picture_jpeg(picture: *const std::ffi::c_void, long_side: std::ffi::c_int, quality: std::ffi::c_int, out: *const std::ffi::c_char) -> std::ffi::c_longlong;
431    fn sioul_picture_close(picture: *mut std::ffi::c_void);
432}
433
434#[cfg(not(target_os = "android"))]
435impl Picture {
436    /// Whether Qt reads pictures of `format` here (`sniff`'s names).
437    pub(crate) fn reads(format: &str) -> bool {
438        let Ok(format) = std::ffi::CString::new(format) else { return false };
439        // SAFETY: a C string.
440        unsafe { sioul_picture_reads(format.as_ptr()) != 0 }
441    }
442
443    /// The picture at `path`, decoded by the reader of `format` alone, its
444    /// long side at `largest` or more when it is longer; none when it does not read.
445    pub(crate) fn open(path: &Path, format: &str, largest: u32) -> Option<Picture> {
446        let path = std::ffi::CString::new(path.to_str()?).ok()?;
447        let format = std::ffi::CString::new(format).ok()?;
448        let (mut width, mut height, mut frames) = (0, 0, 0);
449        let largest = std::ffi::c_int::try_from(largest).unwrap_or(std::ffi::c_int::MAX);
450        // SAFETY: two C strings, three places to write to.
451        let picture = unsafe { sioul_picture_open(path.as_ptr(), format.as_ptr(), largest, &mut width, &mut height, &mut frames) };
452        if picture.is_null() {
453            return None;
454        }
455        Some(Picture { picture, width: u32::try_from(width).unwrap_or(0), height: u32::try_from(height).unwrap_or(0), frames: u32::try_from(frames).unwrap_or(0) })
456    }
457
458    /// Its long side.
459    pub(crate) fn longest(&self) -> u32 {
460        self.width.max(self.height)
461    }
462
463    /// Written to `out` as a baseline JPEG, no metadata, its long side made
464    /// `long_side` when longer, at `quality`: its size, none when not written.
465    pub(crate) fn jpeg(&self, long_side: u32, quality: u8, out: &Path) -> Option<u64> {
466        let out = std::ffi::CString::new(out.to_str()?).ok()?;
467        let long_side = std::ffi::c_int::try_from(long_side).unwrap_or(std::ffi::c_int::MAX);
468        // SAFETY: the picture is this one's, alive; a C string.
469        let size = unsafe { sioul_picture_jpeg(self.picture, long_side, std::ffi::c_int::from(quality), out.as_ptr()) };
470        u64::try_from(size).ok()
471    }
472}
473
474#[cfg(not(target_os = "android"))]
475impl Drop for Picture {
476    fn drop(&mut self) {
477        // SAFETY: made by sioul_picture_open, let go once.
478        unsafe { sioul_picture_close(self.picture) };
479    }
480}
481
482/// A file's first bytes, enough for `sniff` and `sniff_medium`; none when it cannot be read.
483pub(crate) fn head_of(path: &Path) -> Option<Vec<u8>> {
484    let mut head = Vec::with_capacity(HEAD);
485    std::fs::File::open(path).ok()?.take(HEAD as u64).read_to_end(&mut head).ok()?;
486    Some(head)
487}
488
489/// How much of a file is read to tell what it is: an audio frame's next
490/// frame with it (an AAC frame may be 8 KB).
491const HEAD: usize = 16 * 1024;
492
493/// A picture made fit for its share: written to `out` as `fit` says, from
494/// `source`; its long side, its quality and its size. None: it cannot be
495/// made small enough, or is no picture Qt reads here.
496#[cfg(not(target_os = "android"))]
497pub(crate) fn reduce(source: &Path, share: u64, advised: u32, out: &Path) -> Option<(u32, u8, u64)> {
498    let format = sniff(&head_of(source)?)?;
499    reduce_picture(&Picture::open(source, format, FIRST_SIDE)?, share, advised, out)
500}
501
502/// A picture's long side when it goes at its best: 1,600 pixels, less as
503/// its carrier advises (`advised`, 0 none), never more than its own.
504#[cfg(not(target_os = "android"))]
505fn first_side(picture: &Picture, advised: u32) -> u32 {
506    FIRST_SIDE.min(if advised > 0 { advised } else { FIRST_SIDE }).min(picture.longest()).max(1)
507}
508
509/// A picture decoded already, made fit for its share (`reduce`).
510#[cfg(not(target_os = "android"))]
511fn reduce_picture(picture: &Picture, share: u64, advised: u32, out: &Path) -> Option<(u32, u8, u64)> {
512    let first = first_side(picture, advised);
513    let trying = out.with_extension("trying.jpg");
514    let found = fit(first, share, &mut |side, quality| picture.jpeg(side, quality, &trying));
515    let _ = std::fs::remove_file(&trying);
516    let (side, quality) = found?;
517    let size = picture.jpeg(side, quality, out)?;
518    (size <= share).then_some((side, quality, size))
519}
520
521/// One medium attached on the page, made fit: its file (in Sioul's cache,
522/// yours alone, gone once sent or let go; named by when and its rank, never
523/// by the file chosen), what it is, its size.
524#[derive(Debug, Clone, PartialEq, Eq)]
525pub(crate) struct Attached {
526    /// The file chosen, as the page gave it: never sent.
527    pub(crate) source: String,
528    /// "picture", "video", "sound", "card".
529    pub(crate) kind: String,
530    pub(crate) ct: String,
531    pub(crate) path: String,
532    pub(crate) size: u64,
533}
534
535/// A file chosen that does not go, for the page to say why: its words' key
536/// ("too-many", "kind", "unreadable", "file", "too-large", "gif-too-large",
537/// "too-many-pictures", "picture-too-large"), its size, a picture's format
538/// in words for "kind".
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub(crate) struct Left {
541    pub(crate) source: String,
542    pub(crate) why: &'static str,
543    pub(crate) size: u64,
544    pub(crate) format: &'static str,
545}
546
547/// The kind of an as-is medium, for the page's words.
548fn kind_word(ct: &str) -> &'static str {
549    match ct.split('/').next().unwrap_or("") {
550        "video" => "video",
551        "audio" => "sound",
552        _ => "card",
553    }
554}
555
556/// A medium's content type, by its kind, once made.
557fn ct_of(kind: &Kind) -> &'static str {
558    match kind {
559        Kind::Picture => "image/jpeg",
560        Kind::Animated => "image/gif",
561        Kind::AsIs(ct) => ct,
562        Kind::Refused(_) => "",
563    }
564}
565
566/// The bytes a message leaves for its media's own, its media of `cts`
567/// (their content types) under `limit`: measured as the phone will write it
568/// (`mms::room`), to the longest number, its words the most they may weigh
569/// (`WORDS_RESERVE`), so that neither typing nor the number pushes it over.
570fn room_for(cts: &[&str], limit: u64) -> u64 {
571    let media: Vec<mms::Medium> = cts.iter().map(|ct| mms::Medium { ct, bytes: &[] }).collect();
572    let reserve = "x".repeat(WORDS_RESERVE);
573    let key = "0".repeat(32);
574    let message = mms::Message { key: &key, to: &[mms::LONGEST_NUMBER], date: jiff::Timestamp::now().as_second(), words: &reserve, media: &media };
575    mms::room(&message, usize::try_from(limit).unwrap_or(usize::MAX)).map_or(0, |room| room as u64)
576}
577
578/// The media chosen (`sources`, in their order) made fit for one message
579/// under `limit` bytes (decisions 6 to 10), written into `folder`: those
580/// that go, and those that do not and why. `advised`: the carrier's largest
581/// picture's long side (0 none). The room is the message's own measure with
582/// its media empty, to the longest number, with the words' reserve
583/// (`room_for`). Only regular files are read; each picture is decoded once,
584/// by its own format's reader.
585#[cfg(not(target_os = "android"))]
586pub(crate) fn make(sources: &[PathBuf], limit: u64, advised: u32, folder: &Path) -> (Vec<Attached>, Vec<Left>) {
587    let mut left = Vec::new();
588    // What may go: the file, what it is, its size, and a picture decoded at the size it may go at most.
589    let mut chosen: Vec<(PathBuf, Kind, u64, Option<Picture>)> = Vec::new();
590    let refused = |source: &Path, why: &'static str, size: u64, format: &'static str| Left { source: source.display().to_string(), why, size, format };
591    for (i, source) in sources.iter().enumerate() {
592        if i >= rules::MEDIA_MAX {
593            left.push(refused(source, "too-many", 0, ""));
594            continue;
595        }
596        let Some(size) = std::fs::metadata(source).ok().filter(std::fs::Metadata::is_file).map(|m| m.len()).filter(|&size| size > 0) else {
597            left.push(refused(source, "unreadable", 0, ""));
598            continue;
599        };
600        let Some(head) = head_of(source) else {
601            left.push(refused(source, "unreadable", size, ""));
602            continue;
603        };
604        let name = source.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
605        let sniffed = sniff(&head);
606        let reads = sniffed.is_some_and(Picture::reads);
607        let picture = sniffed.filter(|_| reads).and_then(|format| Picture::open(source, format, FIRST_SIDE));
608        if reads && picture.is_none() {
609            left.push(refused(source, "unreadable", size, ""));
610            continue;
611        }
612        let medium = if sniffed.is_none() { sniff_medium(&head, &name) } else { None };
613        match kind_of(sniffed, reads, picture.as_ref().map_or(0, |p| p.frames), medium) {
614            Kind::Refused(why) => left.push(refused(source, why, size, sniffed.map_or("", format_words))),
615            Kind::Picture => chosen.push((source.clone(), Kind::Picture, size, picture)),
616            kind => chosen.push((source.clone(), kind, size, None)),
617        }
618    }
619    let mut keep: Vec<bool> = vec![true; chosen.len()];
620    let room = |keep: &[bool]| room_for(&chosen.iter().zip(keep).filter(|(_, k)| **k).map(|((_, kind, _, _), _)| ct_of(kind)).collect::<Vec<_>>(), limit);
621    // What goes as it is, in its order, while the room holds it: each one over it said so.
622    let mut fixed = 0u64;
623    for i in 0..chosen.len() {
624        let (source, kind, size, _) = &chosen[i];
625        if matches!(kind, Kind::AsIs(_) | Kind::Animated) {
626            if fixed + size > room(&keep) {
627                keep[i] = false;
628                left.push(refused(source, if *kind == Kind::Animated { "gif-too-large" } else { "too-large" }, *size, ""));
629            } else {
630                fixed += size;
631            }
632        }
633    }
634    // The pictures share what is left; too many for their share, the last ones said so.
635    let pictures = |keep: &[bool]| chosen.iter().zip(keep).filter(|((_, kind, _, _), k)| **k && *kind == Kind::Picture).count() as u64;
636    while pictures(&keep) > 0 && share(room(&keep), fixed, pictures(&keep)).is_none() {
637        let Some(last) = chosen.iter().zip(&keep).rposition(|((_, kind, _, _), k)| *k && *kind == Kind::Picture) else { break };
638        keep[last] = false;
639        left.push(refused(&chosen[last].0, "too-many-pictures", chosen[last].2, ""));
640    }
641    let each = share(room(&keep), fixed, pictures(&keep)).unwrap_or(0);
642    // Yours alone, as Sioul's cache around it.
643    let _ = std::fs::create_dir_all(folder);
644    #[cfg(unix)]
645    let _ = std::fs::set_permissions(folder, std::os::unix::fs::PermissionsExt::from_mode(0o700));
646    let stamp = jiff::Timestamp::now().as_millisecond();
647    let out_of = |i: usize, extension: &str| folder.join(format!("{stamp}-{i}.{extension}"));
648    // The pictures made for their equal shares (decision 10); then what those
649    // that went at their best left unused, with the shares of those refused,
650    // shared among those made smaller than their best, once.
651    let mut reduced: Vec<Option<(u32, u8, u64)>> = vec![None; chosen.len()];
652    let kept_pictures: Vec<usize> = (0..chosen.len()).filter(|&i| keep[i] && chosen[i].1 == Kind::Picture).collect();
653    for &i in &kept_pictures {
654        reduced[i] = chosen[i].3.as_ref().and_then(|picture| reduce_picture(picture, each, advised, &out_of(i, "jpg")));
655    }
656    let at_best = |i: usize| match (&chosen[i].3, reduced[i]) {
657        (Some(picture), Some((side, quality, _))) => side == first_side(picture, advised) && quality == FIRST_QUALITY,
658        _ => false,
659    };
660    let smaller: Vec<usize> = kept_pictures.iter().copied().filter(|&i| reduced[i].is_some() && !at_best(i)).collect();
661    let unused: u64 = kept_pictures.iter().filter(|&&i| !smaller.contains(&i)).map(|&i| reduced[i].map_or(each, |(_, _, size)| each.saturating_sub(size))).sum();
662    if !smaller.is_empty() && unused > 0 {
663        let more = each + unused / smaller.len() as u64;
664        for &i in &smaller {
665            let again = out_of(i, "again.jpg");
666            if let Some(better) = chosen[i].3.as_ref().and_then(|picture| reduce_picture(picture, more, advised, &again))
667                && std::fs::rename(&again, out_of(i, "jpg")).is_ok()
668            {
669                reduced[i] = Some(better);
670            }
671            let _ = std::fs::remove_file(&again);
672        }
673    }
674    let mut made = Vec::new();
675    for (i, ((source, kind, size, _), k)) in chosen.iter().zip(&keep).enumerate() {
676        if !k {
677            continue;
678        }
679        let shown = source.display().to_string();
680        match kind {
681            Kind::Picture => match reduced[i] {
682                Some((_, _, written)) => made.push(Attached { source: shown, kind: "picture".into(), ct: "image/jpeg".into(), path: out_of(i, "jpg").display().to_string(), size: written }),
683                None => {
684                    let _ = std::fs::remove_file(out_of(i, "jpg"));
685                    left.push(refused(source, "picture-too-large", *size, ""));
686                }
687            },
688            Kind::Animated | Kind::AsIs(_) => {
689                let ct = ct_of(kind);
690                let out = out_of(i, extension_of(ct));
691                // As much as was weighed, no more: a file grown since is refused, not cut.
692                let mut bytes = Vec::new();
693                let read = std::fs::File::open(source).and_then(|from| from.take(size + 1).read_to_end(&mut bytes));
694                // An animated GIF's frames alone: its comments and other programs' blocks left out.
695                let bytes = if *kind == Kind::Animated { gif_pictures_alone(&bytes) } else { Some(bytes) };
696                match (read, bytes) {
697                    (Ok(length), _) if length as u64 != *size => left.push(refused(source, "too-large", *size, "")),
698                    (Ok(_), Some(bytes)) if std::fs::write(&out, &bytes).is_ok() => made.push(Attached { source: shown, kind: if *kind == Kind::Animated { "picture".into() } else { kind_word(ct).into() }, ct: ct.into(), path: out.display().to_string(), size: bytes.len() as u64 }),
699                    _ => {
700                        let _ = std::fs::remove_file(&out);
701                        left.push(refused(source, "unreadable", *size, ""));
702                    }
703                }
704            }
705            Kind::Refused(_) => {}
706        }
707    }
708    (made, left)
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    /// A stand-in of a picture's JPEG size: its pixels and its quality.
716    fn sized(side: u32, quality: u8) -> u64 {
717        let pixels = u64::from(side) * u64::from(side) * 3 / 4;
718        pixels * u64::from(quality) / 200
719    }
720
721    /// The steps as decision 6 says them: 85 when it fits; else the quality
722    /// searched down to 50; else a quarter smaller; never under 480.
723    #[test]
724    fn a_picture_is_made_small_in_its_steps() {
725        let mut tried = Vec::new();
726        let mut write = |side: u32, quality: u8| {
727            tried.push((side, quality));
728            Some(sized(side, quality))
729        };
730        assert_eq!(fit(1600, sized(1600, 85), &mut write), Some((1600, 85)), "fits at once");
731        assert_eq!(tried, [(1600, 85)]);
732        tried.clear();
733        let mut write = |side: u32, quality: u8| {
734            tried.push((side, quality));
735            Some(sized(side, quality))
736        };
737        let (side, quality) = fit(1600, sized(1600, 70), &mut write).unwrap();
738        assert_eq!(side, 1600);
739        assert!((66..=70).contains(&quality), "the best quality found within four halvings: {quality}");
740        assert!(tried.len() <= 6 && tried[..2] == [(1600, 85), (1600, 50)], "{tried:?}");
741        tried.clear();
742        let mut write = |side: u32, quality: u8| {
743            tried.push((side, quality));
744            Some(sized(side, quality))
745        };
746        let (side, _) = fit(1600, sized(900, 60), &mut write).unwrap();
747        assert_eq!(side, 900, "a quarter smaller, twice: {tried:?}");
748        assert!(tried.iter().all(|(side, _)| [1600, 1200, 900].contains(side)));
749        let mut write = |side: u32, quality: u8| Some(sized(side, quality));
750        assert_eq!(fit(1600, sized(480, 50) - 1, &mut write), None, "never under 480 pixels");
751        assert!(fit(1600, sized(506, 50), &mut |side, quality| Some(sized(side, quality))).is_some_and(|(side, _)| side == 506), "1600, 1200, 900, 675, 506");
752        assert_eq!(fit(1600, u64::MAX, &mut |_, _| None), None, "not written");
753    }
754
755    /// The pictures share what the others leave, 16 KB each at least.
756    #[test]
757    fn pictures_share_what_the_others_leave() {
758        assert_eq!(share(600_000, 0, 1), Some(600_000));
759        assert_eq!(share(600_000, 100_000, 5), Some(100_000));
760        assert_eq!(share(600_000, 700_000, 1), None, "the others over the room");
761        assert_eq!(share(80_000, 0, 5), None, "under 16 KB each");
762        assert_eq!(share(80_000, 0, 4), Some(20_000));
763        assert_eq!(share(5_000, 1_000, 0), Some(4_000), "no picture: what is left");
764    }
765
766    /// A picture is told by its first bytes, never by its name.
767    #[test]
768    fn a_picture_is_told_by_its_first_bytes() {
769        assert_eq!(sniff(&[0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10]), Some("jpeg"));
770        assert_eq!(sniff(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"), Some("png"));
771        assert_eq!(sniff(b"GIF89a\x01\0\x01\0"), Some("gif"));
772        assert_eq!(sniff(b"RIFF\x24\0\0\0WEBPVP8L"), Some("webp"));
773        assert_eq!(sniff(b"II*\0\x08\0\0\0"), Some("tiff"));
774        assert_eq!(sniff(b"MM\0*\0\0\0\x08"), Some("tiff"));
775        assert_eq!(sniff(b"BM\x46\0\0\0\0\0\0\0\x36\0\0\0\x28\0\0\0\x02\0\0\0\x02\0\0\0"), Some("bmp"));
776        assert_eq!(sniff(b"BM"), None, "two letters are no bitmap");
777        assert_eq!(sniff(&[0xFF, 0x0A, 0xFA]), Some("jxl"));
778        assert_eq!(sniff(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic"), Some("heif"));
779        assert_eq!(sniff(b"\0\0\0\x1cftypmif1\0\0\0\0mif1avifmiaf"), Some("avif"), "an AVIF whose major brand is mif1");
780        assert_eq!(sniff(b"\0\0\0\x18ftypavif\0\0\0\0avifmif1"), Some("avif"));
781        assert_eq!(sniff(b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2"), None, "a video");
782        assert_eq!(sniff(b"%PDF-1.7\n"), None);
783        assert_eq!(sniff(b"<svg xmlns="), None);
784        assert_eq!(sniff(b"%!PS-Adobe-3.0 EPSF"), None);
785        assert_eq!(sniff(b""), None);
786    }
787
788    /// What a file is: by its bytes alone, never its name (decisions 7 to 9;
789    /// the review of 18 October 2026, I-3).
790    #[test]
791    fn a_file_is_known_by_what_it_is() {
792        assert_eq!(kind_of(Some("jpeg"), true, 1, None), Kind::Picture);
793        assert_eq!(kind_of(Some("gif"), true, 1, None), Kind::Picture, "a still GIF, a picture");
794        assert_eq!(kind_of(Some("gif"), true, 12, None), Kind::Animated);
795        assert_eq!(kind_of(Some("heif"), false, 0, None), Kind::Refused("kind"), "a HEIC Qt cannot read here");
796        assert_eq!(kind_of(Some("heif"), true, 1, None), Kind::Picture, "one Qt reads (KDE's plugins, Apple's)");
797        assert_eq!(kind_of(None, false, 0, Some("video/mp4")), Kind::AsIs("video/mp4"));
798        assert_eq!(kind_of(None, false, 0, None), Kind::Refused("file"));
799        let medium = |head: &[u8], name: &str| sniff_medium(head, name);
800        assert_eq!(medium(b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2", "Clip.MP4"), Some("video/mp4"));
801        assert_eq!(medium(b"\0\0\0\x18ftypisom\0\0\x02\0isomiso2", "voice.m4a"), Some("audio/mp4"), "a sound in an MP4");
802        assert_eq!(medium(b"\0\0\0\x18ftypM4A \0\0\x02\0M4A mp42", "voice"), Some("audio/mp4"));
803        assert_eq!(medium(b"\0\0\0\x14ftypqt  \0\0\x02\0qt  ", "IMG_0001.MOV"), Some("video/quicktime"));
804        assert_eq!(medium(b"\0\0\0\x14ftyp3gp4\0\0\x02\03gp4", "v.3gp"), Some("video/3gpp"));
805        assert_eq!(medium(b"\0\0\0\x18ftypheic\0\0\0\0mif1heic", "x.mp4"), None, "a picture's box is no video");
806        assert_eq!(medium(b"\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\x82\x84webm", "clip.webm"), Some("video/webm"));
807        assert_eq!(medium(b"\x1a\x45\xdf\xa3\x9f\x42\x86\x81\x01\x42\x82\x88matroska", "film.mkv"), None, "Matroska, not WebM");
808        assert_eq!(medium(b"\x1a\x45\xdf\xa3\x8b\x42\x82\x88matroska\x15\x49\xa9\x66\x84webm", "film.webm"), None, "webm in a later element: its DocType says Matroska");
809        assert_eq!(medium(b"#!AMR\n\x3c", "voice.amr"), Some("audio/amr"));
810        assert_eq!(medium(b"OggS\0\x02", "song.opus"), Some("audio/ogg"));
811        assert_eq!(medium(b"RIFF\x24\0\0\0WAVEfmt ", "a.wav"), Some("audio/wav"));
812        assert_eq!(medium(b"ID3\x04\0\0\0\0\0\0", "a.mp3"), Some("audio/mpeg"));
813        // A sound by whole frames, the next one's header where the first says (the review's re-check, L-d).
814        let mp3 = [&[0xFF, 0xFB, 0x90, 0x64][..], &[0; 413], &[0xFF, 0xFB, 0x90, 0x64], &[0; 64]].concat();
815        assert_eq!(mpeg_frame(&mp3), Some(417), "MPEG-1 Layer III, 128 kb/s, 44.1 kHz");
816        assert_eq!(medium(&mp3, "a.mp3"), Some("audio/mpeg"));
817        assert_eq!(medium(&mp3[..417], "a.mp3"), None, "one frame, its next not there");
818        assert_eq!(medium(&[0xFF, 0xFB, 0x90, 0x64], "a.mp3"), None, "two bytes of sync are no sound");
819        let adts = [&[0xFF, 0xF1, 0x50, 0x80, 0x0C, 0x9F, 0xFC][..], &[0; 93], &[0xFF, 0xF1, 0x50, 0x80, 0x0C, 0x9F, 0xFC], &[0; 93]].concat();
820        assert_eq!(adts_frame(&adts), Some(100));
821        assert_eq!(medium(&adts, "a.aac"), Some("audio/aac"));
822        assert_eq!(medium(b"ID3\x04\0\0\x80\0\0\0", "a.mp3"), None, "an ID3 tag's size is seven bits a byte");
823        // Text in UTF-16, as Windows writes it: its byte-order mark FF FE is no sound, nor a card.
824        let utf16 = |text: &str| [&[0xFF, 0xFE][..], &text.encode_utf16().flat_map(u16::to_le_bytes).collect::<Vec<u8>>()].concat();
825        assert_eq!(medium(&utf16("Notes for Tuesday: the boiler, the rent, the dentist.\r\n".repeat(40).as_str()), "notes.txt"), None);
826        assert_eq!(medium(&utf16(&format!("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Alice Example\r\nTEL:01 99 00 12 34\r\nEND:VCARD\r\n{}", " ".repeat(800))), "alice.vcf"), None);
827        assert_eq!(medium(&utf16("Bonjour, à demain.".repeat(60).as_str()), "mot.txt"), None, "its first letter a valid frame's, no next frame");
828        assert_eq!(medium(b"\xEF\xBB\xBF\r\nbegin:vcard\r\nVERSION:2.1\r\n", "alice.vcf"), Some("text/x-vCard"));
829        assert_eq!(medium(b"BEGIN:VCALENDAR\r\nVERSION:1.0\r\n", "dinner.vcs"), Some("text/x-vCalendar"));
830        assert_eq!(medium(b"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n", "dinner.ics"), Some("text/calendar"));
831        assert_eq!(medium(b"%PDF-1.7\n%\xe2\xe3", "lease.mp4"), None, "a PDF named .mp4: another file");
832        assert_eq!(medium(b"<svg xmlns=", "drawing.mp3"), None);
833        assert_eq!(medium(b"", "empty.mp4"), None);
834        for ct in ["image/jpeg", "image/gif", "video/mp4", "video/quicktime", "video/3gpp", "video/webm", "audio/mp4", "audio/amr", "audio/aac", "audio/mpeg", "audio/ogg", "audio/wav", "text/x-vCard", "text/x-vCalendar", "text/calendar"] {
835            assert!(rules::sendable(ct), "{ct}: the phone sends it");
836            assert_ne!(extension_of(ct), "dat", "{ct}");
837        }
838        assert_eq!(format_words("heif"), "HEIC");
839    }
840
841    /// An animated GIF keeps its frames, their timing and its loop, and
842    /// loses its comments, its plain text, other programs' blocks (XMP) and
843    /// what follows its end (the review of 18 October 2026, L-1).
844    #[test]
845    fn an_animated_gif_goes_with_its_frames_alone() {
846        let frame = |colour: u8| [&[0x21, 0xF9, 4, 0, 10, 0, 0, 0][..], &[0x2C, 0, 0, 0, 0, 2, 0, 1, 0, 0], &[2, 2, 0x4C | colour, 0x01, 0]].concat();
847        let netscape = b"\x21\xFF\x0BNETSCAPE2.0\x03\x01\0\0\0".to_vec();
848        let comment = [&[0x21, 0xFE, 16][..], b"SIOUL-SECRET-GIF", &[0]].concat();
849        let mut xmp = b"\x21\xFF\x0BXMP DataXMP".to_vec();
850        xmp.extend_from_slice(b"<x:xmpmeta>SIOUL-SECRET-XMP</x:xmpmeta>");
851        // XMP's magic trailer: 0x01, then 0xFF down to 0x00, then the end of the blocks.
852        xmp.push(0x01);
853        xmp.extend((0..=255u8).rev());
854        xmp.push(0);
855        let text = [&[0x21, 0x01, 12][..], &[0; 12], &[5][..], b"SECRE", &[0]].concat();
856        let screen = [b"GIF89a".as_slice(), &[2, 0, 1, 0, 0x80, 0, 0], &[255, 0, 0, 0, 0, 255]].concat();
857        let gif = [screen.clone(), netscape.clone(), comment, xmp, frame(0), text, frame(1), vec![0x3B], b"SIOUL-SECRET-AFTER".to_vec()].concat();
858        let alone = gif_pictures_alone(&gif).unwrap();
859        assert_eq!(alone, [screen, netscape, frame(0), frame(1), vec![0x3B]].concat());
860        assert!(!alone.windows(5).any(|w| w == b"SIOUL" || w == b"SECRE"), "no comment, XMP, text or tail");
861        assert_eq!(gif_pictures_alone(&alone).unwrap(), alone, "written again, the same");
862        assert_eq!(gif_pictures_alone(&gif[..gif.len() - 30]), None, "cut short: not a whole GIF");
863        // No end after its last whole block, which Qt reads: its end written (the re-check, I-h).
864        let screen_again = [b"GIF89a".as_slice(), &[2, 0, 1, 0, 0x80, 0, 0], &[255, 0, 0, 0, 0, 255]].concat();
865        let open = [screen_again.clone(), b"\x21\xFF\x0BNETSCAPE2.0\x03\x01\0\0\0".to_vec(), frame(0), frame(1)].concat();
866        assert_eq!(gif_pictures_alone(&open).unwrap(), [open.clone(), vec![0x3B]].concat());
867        // A crafted GIF hides words inside a frame's timing and the loop block: their values alone go (L-c).
868        let timing_with_more = [&[0x21, 0xF9, 4, 0, 10, 0, 0, 5][..], b"SECRE", &[0]].concat();
869        let timing_too_long = [&[0x21, 0xF9, 20][..], b"SECRET-OF-TWENTY-B..", &[0]].concat();
870        let loop_with_more = [b"\x21\xFF\x0BNETSCAPE2.0\x03\x01\0\0".as_slice(), &[5], b"SECRE", &[0]].concat();
871        let animexts_with_more = [b"\x21\xFF\x0BANIMEXTS1.0\x03\x01\x02\0".as_slice(), &[5], b"SECRE", &[0]].concat();
872        let frame_of = |timing: &[u8], colour: u8| [timing, &[0x2C, 0, 0, 0, 0, 2, 0, 1, 0, 0], &[2, 2, 0x4C | colour, 0x01, 0]].concat();
873        let crafted = [screen_again.clone(), loop_with_more, animexts_with_more, frame_of(&timing_with_more, 0), frame_of(&timing_too_long, 1), vec![0x3B]].concat();
874        let alone = gif_pictures_alone(&crafted).unwrap();
875        assert!(!alone.windows(5).any(|w| w == b"SECRE"), "{:?}", String::from_utf8_lossy(&alone));
876        let image = |colour: u8| [&[0x2C, 0, 0, 0, 0, 2, 0, 1, 0, 0][..], &[2, 2, 0x4C | colour, 0x01, 0]].concat();
877        let expected = [screen_again, b"\x21\xFF\x0BNETSCAPE2.0\x03\x01\0\0\0".to_vec(), b"\x21\xFF\x0BANIMEXTS1.0\x03\x01\x02\0\0".to_vec(), vec![0x21, 0xF9, 4, 0, 10, 0, 0, 0], image(0), image(1), vec![0x3B]].concat();
878        assert_eq!(alone, expected, "the timing's four bytes and the loop's count alone; a timing block of 20 bytes dropped");
879        assert_eq!(gif_pictures_alone(b"GIF89a"), None);
880        assert_eq!(gif_pictures_alone(b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR"), None);
881    }
882
883    /// The room is measured to the longest number with the words' most:
884    /// a message whose media fill it fits, with any number and any words.
885    #[test]
886    fn the_room_holds_any_number_and_any_words() {
887        let limit = 614_400;
888        let cts = ["image/jpeg", "image/jpeg", "video/mp4"];
889        let room = room_for(&cts, limit) as usize;
890        let bytes: Vec<Vec<u8>> = vec![vec![1; room / 3], vec![2; room / 3], vec![3; room - 2 * (room / 3)]];
891        let media: Vec<mms::Medium> = cts.iter().zip(&bytes).map(|(ct, bytes)| mms::Medium { ct, bytes }).collect();
892        let key = "1".repeat(32);
893        let longest_words = "😀".repeat(rules::WRITE_MAX);
894        for words in ["", "À demain.", longest_words.as_str()] {
895            for to in ["0199001234", "+33199001234", mms::LONGEST_NUMBER] {
896                let message = mms::Message { key: &key, to: &[to], date: 1_791_446_400, words, media: &media };
897                let size = mms::size(&message).unwrap();
898                assert!(size as u64 <= limit, "{to}, {} characters: {size}", words.chars().count());
899            }
900        }
901        assert_eq!(room_for(&cts, 1_000), 0, "a limit the words' reserve fills leaves none");
902    }
903}