An invoice laid on a letterhead

An invoice laid on a letterhead somebody else made.

Rust write_overlay.rs 270 lines
  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
//! Lays an invoice on a letterhead somebody else made.
//!
//! The letterhead is a PDF: a page with a header block on it, and nothing else.
//! It is read, brought in as a form, drawn at the foot of the new page's stack,
//! and the invoice is set on top of it. Nothing about the letterhead is
//! rewritten — its fonts, its colours and its marks come across as they are.
//!
//! The invoice's labels, and the one label the stand-in letterhead carries, are
//! held in `Words`, once per language, and `HQF_PDF_LANG` picks which set is
//! drawn. The leader dots are counted rather than typed, so a longer label in
//! another language still ends its amount on the same character.
//!
//! Usage: `cargo run --example write_overlay -- tmp/overlay.pdf
//! [letterhead.pdf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_overlay --
//! tmp/surcharge.pdf`

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

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

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

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

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

use language::Language;

/// The font the example draws with.
fn default_font() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fonts")
        .join("DejaVuSans.ttf")
}

/// How many billed lines the invoice sets.
const ITEM_COUNT: usize = 3;

/// How many characters a billed line runs to: its label, a space, the leader
/// dots, a space, then its amount. The dots take whatever is left, so the
/// amounts end on the same character however long the label before them is.
const LINE_CHARS: usize = 56;

/// The invoice's number, and what each billed line comes to. Neither is
/// language.
const NUMBER: &str = "1964413";
const AMOUNTS: [&str; ITEM_COUNT] = ["4 250.00 EUR", "850.00 EUR", "5 100.00 EUR"];

/// The letterhead's own name, address and registration number. None of them is
/// language: they belong to whoever the letterhead is for.
const COMPANY: &str = "ACME Ltd";
const COMPANY_ADDRESS: &str = "12 Fleet Street — London EC4Y 1AA";
const COMPANY_NUMBER: &str = "123 456 789";

/// The size each of the five invoice lines is set at, in the order they are
/// laid down.
const SIZES: [f64; 5] = [16.0, 11.0, 11.0, 11.0, 12.0];

/// The words the invoice is written in, one set per language.
///
/// What is not language stays out of it: the invoice's number, the three
/// amounts, and the letterhead's name, address and number.
#[derive(Debug)]
struct Words {
    /// What stands on the letterhead before its registration number.
    company_number_label: &'static str,
    /// What stands before the invoice's number.
    number_label: &'static str,
    /// The line that gives the day the invoice was issued.
    issued: &'static str,
    /// What each billed line is for, in the order they are set.
    items: [&'static str; ITEM_COUNT],
}

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

    /// The five lines the invoice sets, in order. Each billed line is padded
    /// out with leader dots until it runs to `LINE_CHARS`.
    fn lines(&self) -> Vec<String> {
        let mut lines = vec![
            format!("{} {NUMBER}", self.number_label),
            self.issued.to_owned(),
        ];
        lines.extend(self.items.iter().zip(AMOUNTS).map(|(item, amount)| {
            let dots = LINE_CHARS.saturating_sub(item.chars().count() + amount.chars().count() + 2);
            format!("{item} {} {amount}", ".".repeat(dots))
        }));
        lines
    }

    /// The second line of the stand-in letterhead: an address, then the number
    /// the company registers under, under whatever this language calls it.
    fn letterhead_line(&self) -> String {
        format!(
            "{COMPANY_ADDRESS} — {} {COMPANY_NUMBER}",
            self.company_number_label
        )
    }
}

/// The invoice in English.
const ENGLISH: Words = Words {
    company_number_label: "Company No.",
    number_label: "Invoice No.",
    issued: "Issued 14 July 2026",
    items: ["Rendering engine development", "VAT at 20 %", "Total due"],
};

/// The invoice in French.
const FRENCH: Words = Words {
    company_number_label: "N° d'entreprise",
    number_label: "Facture n°",
    issued: "Émise le 14 juillet 2026",
    items: [
        "Développement du moteur de rendu",
        "TVA à 20 %",
        "Total à payer",
    ],
};

/// 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)];

/// A letterhead, for when the caller has none to hand.
fn a_letterhead(
    font_path: &Path,
    words: &'static Words,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
    let mut doc = Document::new();
    doc.set_license(licence::licensed());
    let font = doc.add_font(Font::parse(fs::read(font_path)?)?);

    let mut content = Content::new();
    content.save_state();
    content.set_fill_rgb(0.91, 0.94, 0.98)?;
    content.rect(0.0, 742.0, 595.276, 100.0)?;
    content.fill();
    content.set_fill_rgb(0.2, 0.35, 0.6)?;
    content.rect(0.0, 738.0, 595.276, 4.0)?;
    content.fill();
    content.restore_state();

    content.begin_text();
    content.set_font(font.name(), 22.0)?;
    content.text_origin(56.0, 786.0)?;
    content.show_glyphs(&font.glyphs(COMPANY));
    content.end_text();

    content.begin_text();
    content.set_font(font.name(), 9.0)?;
    content.text_origin(56.0, 764.0)?;
    content.show_glyphs(&font.glyphs(&words.letterhead_line()));
    content.end_text();

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

    Ok(doc.to_bytes()?)
}

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

    let mut args = env::args().skip(1);
    // A named file is written as named; the default one carries the language,
    // so the two languages do not overwrite each other in `tmp/`.
    let path = args
        .next()
        .unwrap_or_else(|| language.file_name(&out::default_path("overlay")));
    let font_path = default_font();

    // The letterhead: the caller's, or one made here to stand in for it.
    let letterhead = match args.next() {
        Some(given) => fs::read(given)?,
        None => a_letterhead(&font_path, words)?,
    };

    let reader = Reader::new(letterhead)?;
    println!("the letterhead has {} page(s)", reader.pages()?.len());

    let mut doc = Document::new();
    doc.set_license(licence::licensed());

    // The page comes in as a form, with everything it drew and everything it
    // named. It is the same page, and it is now a thing this document can draw.
    let template = doc.import_page(&reader, 0)?;
    println!(
        "imported it as {}: {:.0} × {:.0} points",
        String::from_utf8_lossy(template.name().as_bytes()),
        template.width(),
        template.height()
    );

    let font = doc.add_font(Font::parse(fs::read(&font_path)?)?);

    let mut content = Content::new();
    content.draw_form(template.name(), 0.0, 0.0, 1.0)?;

    let mut top = 680.0;
    for (line, size) in words.lines().iter().zip(SIZES) {
        content.begin_text();
        content.set_font(font.name(), size)?;
        content.text_origin(56.0, top)?;
        content.show_glyphs(&font.glyphs(line));
        content.end_text();
        top = size.mul_add(-2.0, top);
    }

    let mut page = Page::new(template.width(), template.height());
    page.content = content.into_bytes();
    template.add_to(&mut page);
    doc.add_page(page)?;

    let bytes = doc.to_bytes()?;
    if let Some(parent) = Path::new(&path).parent() {
        fs::create_dir_all(parent)?;
    }
    fs::write(&path, &bytes)?;

    println!("wrote {path}: {} bytes", bytes.len());
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::{ENGLISH, FRENCH, LINE_CHARS, WORDS, language};

    /// The lines two languages are allowed to write the same way. There are
    /// none: the letterhead's name and address, the invoice's number and the
    /// three amounts are all held outside the words.
    const SPARED: [&str; 0] = [];

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

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

    /// The leader dots are counted, not typed, so every billed line ends its
    /// amount on the same character in every language.
    #[test]
    fn every_billed_line_runs_to_the_same_width() {
        for words in [&ENGLISH, &FRENCH] {
            for line in words.lines().iter().skip(2) {
                assert_eq!(line.chars().count(), LINE_CHARS, "{line}");
            }
        }
    }
}