Réduire la taille du fichier

Le même document écrit deux fois, en clair puis rangé, avec ce que pèse chacun et ce que le rangement a fait gagner.

Rust write_object_streams.rs 537 lignes
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
//! Writes one document twice — spelled out, then packed into object streams —
//! and draws a page stating what each of them weighs.
//!
//! A PDF is mostly dictionaries: the catalogue, the page tree, every page,
//! every font, every annotation. None of them is a stream, so none of them is
//! compressed, and each is written out in full. Object streams gather them into
//! streams that compress together, and the table saying where everything is
//! becomes a stream as well.
//!
//! The document being weighed is built here and written both ways. The page
//! that reports the two weights is itself written packed, so the file this
//! leaves behind is one of the two things it is talking about.
//!
//! The words are held in `Words`, once per language, and `HQF_PDF_LANG` picks
//! which set is drawn. The numbers are not language: they are counted off the
//! two files.
//!
//! A second path, if one is given, is where the document that was weighed is
//! written spelled out, so that the two spellings of one document can be handed
//! to a reader side by side.
//!
//! Usage: `cargo run --example write_object_streams -- tmp/object_streams.pdf`
//!        `HQF_PDF_LANG=fr cargo run --example write_object_streams --
//! tmp/flux_objets.pdf`

use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use hqf_pdf::content::Content;
use hqf_pdf::{Document, Font, Page, TextFlow};

#[path = "shared/out.rs"]
mod out;

#[path = "shared/licence.rs"]
mod licence;

#[path = "shared/language.rs"]
mod language;

use language::Language;

/// Where the committed fonts sit.
fn font_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fonts")
}

/// How many pages the document being weighed holds.
///
/// Every page is a dictionary, and so is everything hanging off it, which is
/// exactly what packing gathers up: a document of one page would show almost
/// nothing.
const WEIGHED_PAGES: usize = 24;

/// The words the page is written in, one set per language.
///
/// What is not language stays out of it: the three weights are counted off the
/// files themselves.
#[derive(Debug)]
struct Words {
    /// The line across the top of the page.
    title: &'static str,
    /// What packing does, in two sentences.
    why: &'static str,
    /// The heading over the three weights.
    weights: &'static str,
    /// What the document being weighed is, cut where the page count goes in.
    subject_before: &'static str,
    subject_after: &'static str,
    /// The label on the weight of the document spelled out.
    flat: &'static str,
    /// The label on the weight of the same document packed.
    packed: &'static str,
    /// The label on what was saved.
    saved: &'static str,
    /// What a weight is counted in.
    unit: &'static str,
    /// The closing note, cut where the share saved goes into it.
    note_before: &'static str,
    note_after: &'static str,
    /// The line saying what this very file is.
    itself: &'static str,
}

impl Words {
    /// The words the page is written in, in `language`.
    fn of(language: Language) -> &'static Self {
        language::pick(&WORDS, language)
    }

    /// What the document being weighed is.
    fn subject(&self) -> String {
        format!(
            "{}{WEIGHED_PAGES}{}",
            self.subject_before, self.subject_after
        )
    }

    /// The closing note, with the share saved in it.
    fn note(&self, share: usize) -> String {
        format!("{}{share}{}", self.note_before, self.note_after)
    }
}

/// The page in English.
const ENGLISH: Words = Words {
    title: "What packing a document saves",
    why: "A PDF is mostly dictionaries — the catalogue, the page tree, every \
          page, every font, every annotation — and a dictionary is not a stream, \
          so each one is written out in full and none of them is compressed.\n\
          Object streams gather them into streams that are compressed together, \
          and the table stating where every object lives becomes a stream too. \
          The document a reader sees is the same one either way.",
    weights: "The same document, weighed twice",
    subject_before: "The document weighed here holds ",
    subject_after: " pages of text, each with a heading of its own.",
    flat: "Spelled out",
    packed: "Packed",
    saved: "Saved",
    unit: "bytes",
    note_before: "Packing this document saved ",
    note_after: " per cent of it. What it saves goes up with the number of \
                 pages, because a page costs a handful of dictionaries and \
                 dictionaries are what packing compresses.",
    itself: "This very file is packed, so a reader that opened it has read one \
             of the two documents being weighed.",
};

/// The page in French.
const FRENCH: Words = Words {
    title: "Ce que le rangement d'un document fait gagner",
    why: "Un PDF est surtout fait de dictionnaires — le catalogue, l'arbre des \
          pages, chaque page, chaque police, chaque annotation — et un \
          dictionnaire n'est pas un flux : chacun est donc écrit en toutes \
          lettres, et aucun n'est compressé.\n\
          Les flux d'objets les rassemblent dans des flux compressés ensemble, \
          et la table qui dit où vit chaque objet devient elle aussi un flux. Le \
          document que le lecteur voit est le même dans les deux cas.",
    weights: "Le même document, pesé deux fois",
    subject_before: "Le document pesé ici tient en ",
    subject_after: " pages de texte, chacune avec son propre titre.",
    flat: "En toutes lettres",
    packed: "Rangé",
    saved: "Gagné",
    unit: "octets",
    note_before: "Ranger ce document en a fait gagner ",
    note_after: " pour cent. Le gain monte avec le nombre de pages, parce \
                 qu'une page coûte une poignée de dictionnaires et que ce sont \
                 les dictionnaires que le rangement compresse.",
    itself: "Ce fichier-ci est rangé : un lecteur qui l'a ouvert a donc lu l'un \
             des deux documents pesés.",
};

/// Every language the example is written in. A language is added by writing its
/// own set of words and naming it here.
static WORDS: [(Language, &Words); 2] =
    [(Language::English, &ENGLISH), (Language::French, &FRENCH)];

/// The left edge of everything on the page.
const X: f64 = 60.0;

/// The width every block is broken to.
const ROOM: f64 = 475.0;

/// The baseline the title sits on.
const TITLE_TOP: f64 = 780.0;

/// The top of the block that says what packing does.
const WHY_TOP: f64 = 748.0;

/// The top of the line naming what was weighed.
const SUBJECT_TOP: f64 = 636.0;

/// The top of the heading over the three weights.
const WEIGHTS_TOP: f64 = 600.0;

/// The top of the first of the three weights.
const FIRST_WEIGHT_TOP: f64 = 574.0;

/// How far apart two weights sit.
const WEIGHT_DROP: f64 = 20.0;

/// Where the number of a weight begins, its label sitting at [`X`].
const NUMBER_X: f64 = X + 160.0;

/// The top of the closing note.
const NOTE_TOP: f64 = 486.0;

/// The top of the line saying what this file is.
const ITSELF_TOP: f64 = 424.0;

/// The document whose two weights the page reports: pages of text, each under a
/// heading of its own.
fn weighed(font: &Font) -> Result<Document, Box<dyn std::error::Error>> {
    let mut doc = Document::new();
    doc.set_license(licence::licensed());
    let handle = doc.add_font(font.clone());

    for number in 1..=WEIGHED_PAGES {
        let mut c = Content::new();

        let heading = TextFlow::new(&handle, 16.0);
        let title = format!("Chapter {number}");
        c.begin_text();
        heading.draw(&mut c, &heading.break_lines(&title, ROOM), X, 760.0, ROOM)?;
        c.end_text();

        let body = TextFlow::new(&handle, 11.0).leading(15.0);
        let prose = format!(
            "This is page {number} of a document written twice over, so that \
             what packing saves can be weighed rather than claimed."
        );
        c.begin_text();
        body.draw(&mut c, &body.break_lines(&prose, ROOM), X, 720.0, ROOM)?;
        c.end_text();

        let mut page = Page::a4();
        page.content = c.into_bytes();
        doc.add_page(page)?;
    }
    Ok(doc)
}

/// A count of bytes, its thousands held apart by a space.
fn counted(value: usize) -> String {
    let digits = value.to_string();
    let mut grouped = String::new();
    for (index, digit) in digits.chars().enumerate() {
        if index > 0 && (digits.len() - index) % 3 == 0 {
            grouped.push(' ');
        }
        grouped.push(digit);
    }
    grouped
}

/// The share of a document packing saved, in whole per cent.
///
/// Whole numbers throughout: a share written as a fraction would be spelled by
/// whatever language drew it, and the two twins must write the same bytes.
const fn share_saved(flat: usize, packed: usize) -> usize {
    if flat == 0 {
        return 0;
    }
    (flat - packed) * 100 / flat
}

/// Converts a row number to a float. A page holds far too few rows for a
/// `usize` to lose precision as an `f64`.
#[expect(
    clippy::cast_precision_loss,
    reason = "a row number on a page is a very small whole number"
)]
const fn count_f64(n: usize) -> f64 {
    n as f64
}

/// Writes `bytes` to `path`, making the directory it goes in if it is not
/// there, and says what was written.
fn written(path: &str, bytes: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
    if let Some(parent) = Path::new(path).parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(path, bytes)?;
    println!("wrote {path}: {} bytes", bytes.len());
    Ok(())
}

/// Draws the page that reports the two weights.
fn report(
    handle: &hqf_pdf::FontHandle,
    words: &Words,
    flat: usize,
    packed: usize,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let mut c = Content::new();

    let title = TextFlow::new(handle, 18.0);
    c.begin_text();
    title.draw(
        &mut c,
        &title.break_lines(words.title, ROOM),
        X,
        TITLE_TOP,
        ROOM,
    )?;
    c.end_text();

    let why = TextFlow::new(handle, 10.0).leading(14.0);
    c.begin_text();
    why.draw(&mut c, &why.break_lines(words.why, ROOM), X, WHY_TOP, ROOM)?;
    c.end_text();

    let subject = TextFlow::new(handle, 10.0).leading(14.0);
    c.begin_text();
    subject.draw(
        &mut c,
        &subject.break_lines(&words.subject(), ROOM),
        X,
        SUBJECT_TOP,
        ROOM,
    )?;
    c.end_text();

    let heading = TextFlow::new(handle, 11.0);
    c.begin_text();
    heading.draw(
        &mut c,
        &heading.break_lines(words.weights, ROOM),
        X,
        WEIGHTS_TOP,
        ROOM,
    )?;
    c.end_text();

    let rows = [
        (words.flat, flat),
        (words.packed, packed),
        (words.saved, flat - packed),
    ];
    for (index, (label, weight)) in rows.iter().enumerate() {
        let top = WEIGHT_DROP.mul_add(-count_f64(index), FIRST_WEIGHT_TOP);
        let line = TextFlow::new(handle, 10.0);
        c.begin_text();
        line.draw(&mut c, &line.break_lines(label, ROOM), X, top, ROOM)?;
        c.end_text();

        let stated = format!("{} {}", counted(*weight), words.unit);
        let number = TextFlow::new(handle, 10.0);
        c.begin_text();
        number.draw(
            &mut c,
            &number.break_lines(&stated, ROOM),
            NUMBER_X,
            top,
            ROOM,
        )?;
        c.end_text();
    }

    let note = words.note(share_saved(flat, packed));
    let closing = TextFlow::new(handle, 10.0).leading(14.0);
    c.begin_text();
    closing.draw(&mut c, &closing.break_lines(&note, ROOM), X, NOTE_TOP, ROOM)?;
    c.end_text();

    let itself = TextFlow::new(handle, 10.0).leading(14.0);
    c.begin_text();
    itself.draw(
        &mut c,
        &itself.break_lines(words.itself, ROOM),
        X,
        ITSELF_TOP,
        ROOM,
    )?;
    c.end_text();

    Ok(c.into_bytes())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let language = Language::from_environment()?;
    let words = Words::of(language);

    let out = env::args()
        .nth(1)
        .unwrap_or_else(|| language.file_name(&out::default_path("object_streams")));

    let font = Font::parse(fs::read(font_dir().join("DejaVuSans.ttf"))?)?;

    let spelled_out = weighed(&font)?.to_bytes()?;
    let flat = spelled_out.len();
    let mut packed_document = weighed(&font)?;
    packed_document.set_object_streams(true);
    let packed = packed_document.to_bytes()?.len();

    if let Some(beside) = env::args().nth(2) {
        written(&beside, &spelled_out)?;
    }

    let mut doc = Document::new();
    doc.set_license(licence::licensed());
    doc.set_object_streams(true);
    let handle = doc.add_font(font);

    let mut page = Page::a4();
    page.content = report(&handle, words, flat, packed)?;
    doc.add_page(page)?;

    let bytes = doc.to_bytes()?;
    written(&out, &bytes)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{
        FIRST_WEIGHT_TOP, ITSELF_TOP, NOTE_TOP, ROOM, WEIGHED_PAGES, WEIGHT_DROP, WORDS, counted,
        font_dir, language, share_saved, weighed,
    };
    use hqf_pdf::{Document, Font, TextFlow};

    /// The lines two languages are allowed to write the same way. There are
    /// none.
    const SPARED: [&str; 0] = [];

    /// The committed font, added to a document of its own.
    fn a_font() -> (Document, hqf_pdf::FontHandle) {
        let mut doc = Document::new();
        let handle = doc.add_font(
            Font::parse(std::fs::read(font_dir().join("DejaVuSans.ttf")).expect("the font"))
                .expect("the font parses"),
        );
        (doc, handle)
    }

    #[test]
    fn every_language_draws_the_page_in_its_own_words() {
        let untranslated = language::untranslated_lines(&WORDS, &SPARED);

        assert!(
            untranslated.is_empty(),
            "the page says these in more than one language: {untranslated:?}"
        );
    }

    /// The page has something to report: packing the document really does make
    /// it smaller, and by enough to show as whole per cent.
    #[test]
    fn packing_the_weighed_document_saves_something_worth_stating() {
        let font = Font::parse(std::fs::read(font_dir().join("DejaVuSans.ttf")).expect("the font"))
            .expect("the font parses");

        let flat = weighed(&font)
            .expect("the document builds")
            .to_bytes()
            .expect("the document is written")
            .len();
        let mut doc = weighed(&font).expect("the document builds");
        doc.set_object_streams(true);
        let packed = doc.to_bytes().expect("the document is written").len();

        assert!(packed < flat, "packed {packed} bytes against flat {flat}");
        assert!(
            share_saved(flat, packed) >= 1,
            "packing saved less than a whole per cent of {flat} bytes"
        );
    }

    /// The document weighed holds the pages the page says it holds.
    #[test]
    fn the_document_weighed_holds_the_pages_the_page_names() {
        let font = Font::parse(std::fs::read(font_dir().join("DejaVuSans.ttf")).expect("the font"))
            .expect("the font parses");
        let bytes = weighed(&font)
            .expect("the document builds")
            .to_bytes()
            .expect("the document is written");

        let reader = hqf_pdf::read::Reader::new(bytes).expect("the document reads");
        assert_eq!(
            reader.pages().expect("the document has pages").len(),
            WEIGHED_PAGES
        );
    }

    /// A count of bytes holds its thousands apart, in every language.
    #[test]
    fn a_count_of_bytes_holds_its_thousands_apart() {
        for (value, written) in [
            (0_usize, "0"),
            (999, "999"),
            (1_000, "1 000"),
            (50_177, "50 177"),
            (1_234_567, "1 234 567"),
        ] {
            assert_eq!(counted(value), written);
        }
    }

    /// Nothing the page draws runs off the paper.
    #[test]
    fn every_language_writes_blocks_no_wider_than_the_room_they_have() {
        let (_doc, handle) = a_font();

        for (language, words) in WORDS {
            for (text, size) in [
                (words.title, 18.0),
                (words.weights, 11.0),
                (words.flat, 10.0),
                (words.packed, 10.0),
                (words.saved, 10.0),
            ] {
                let width = handle.measure(text, size);
                assert!(
                    width <= ROOM,
                    "{language:?} writes {width} points in {ROOM}: {text}"
                );
            }

            for text in [words.why, words.itself, &words.subject(), &words.note(37)] {
                let flow = TextFlow::new(&handle, 10.0).leading(14.0);
                for line in &flow.break_lines(text, ROOM) {
                    assert!(
                        line.natural_width() <= ROOM,
                        "{language:?} sets a line wider than {ROOM}"
                    );
                }
            }
        }
    }

    /// Nothing the page draws lands on what comes after it.
    #[test]
    fn nothing_the_page_draws_lands_on_what_comes_after_it() {
        let (_doc, handle) = a_font();

        let lowest_weight = WEIGHT_DROP.mul_add(-2.0, FIRST_WEIGHT_TOP);
        assert!(
            lowest_weight > NOTE_TOP,
            "the last weight sits at {lowest_weight}, over a note at {NOTE_TOP}"
        );

        for (language, words) in WORDS {
            let flow = TextFlow::new(&handle, 10.0).leading(14.0);
            let note = words.note(37);
            let bottom = NOTE_TOP - flow.height(&flow.break_lines(&note, ROOM));
            assert!(
                bottom > ITSELF_TOP,
                "{language:?} sets its note down to {bottom}, over a line at {ITSELF_TOP}"
            );
        }
    }
}