write_checked_invoice.rs

Le fichier Rust de l'exemple « Une facture qui demande avant de revendiquer ». La bibliothèque lit la facture telle qu'elle est et nomme, clause par clause, tout ce qui la sépare de Factur-X. Deux choses font obstacle, puis une, puis plus aucune — et c'est seulement alors qu'un fichier est écrit.

Rust 463 lignes

À quoi sert cet exemple

Tout autre document ici atteint une norme en la déclarant : le fichier dit qu'il est Factur-X, et il est écrit en Factur-X. Déclarer est facile ; avoir raison ne l'est pas. Un document peut nommer une facture qu'il n'a jamais jointe, revendiquer un niveau de détail que le XML à l'intérieur ne suit pas, et tenir dans un contenant que la norme n'autorise pas — et sortir en disant les trois, pour revenir refusé par le service comptable auquel il a été envoyé.

Ce que montre cet exemple

  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
//! Creates an electronic invoice and asks the document what stands in its way
//! before it claims anything.
//!
//! Every other example reaches a standard by declaring it. This one asks first.
//! [`Document::check`] reads the document as it stands, writes nothing, changes
//! nothing, and hands back everything known here to stand between it and the
//! standard named — all of it at once, each finding citing the clause it comes
//! from.
//!
//! The invoice is composed the way a caller first writes it, with three things
//! wrong that only a report can name: it claims no archival standard, it names
//! an invoice it never attached, and it claims a profile the XML does not
//! carry. The first two hide the third, because a file that is not there cannot
//! be read; so each round prints what came back, puts it right, and asks again.
//! Nothing is written while anything stands.
//!
//! An empty report is not a certificate. It says that nothing this library
//! knows to look for is in the way, which is not the same as conforming:
//! `veraPDF` says whether the file conforms, and `scripts/check_pdfa.sh` asks
//! it.
//!
//! The page is written in the language `HQF_PDF_LANG` names. The XML is not: it
//! is read by an accounting system rather than by a person, and the same file
//! goes to a buyer in any country. What the terminal says is not either — it is
//! the library's own report, in the words the library states its clauses in.
//!
//! Usage: `cargo run --example write_checked_invoice -- tmp/checked.pdf
//! [font.ttf]`
//!        `HQF_PDF_LANG=fr cargo run --example write_checked_invoice`

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

use hqf_pdf::conformance::{Report, Standard};
use hqf_pdf::content::Content;
use hqf_pdf::metadata::attachment::{Attachment, Relationship};
use hqf_pdf::metadata::xmp::{Invoice, InvoiceProfile, Metadata, PdfA};
use hqf_pdf::{Document, Font, FontHandle, Page};

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

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

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

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

use language::Language;

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

/// When the invoice was issued. The library never reads the clock — a document
/// that stamped itself with the time would be a different file on every build —
/// so the caller says when, and here the caller is an example.
const ISSUED: &str = "2026-07-14T09:30:00+02:00";

/// The part of the archival standard an electronic invoice belongs in.
const CONTAINER: PdfA = PdfA::A3B;

/// The profile the caller claims before reading the XML.
const CLAIMED_AT_FIRST: InvoiceProfile = InvoiceProfile::En16931;

/// The profile the XML states under
/// `GuidelineSpecifiedDocumentContextParameter`.
const CARRIED: InvoiceProfile = InvoiceProfile::Basic;

/// The version of the Factur-X specification the XML follows.
const SPECIFICATION: &str = "1.0";

/// The name the standard has reading software look the XML up by, and the name
/// [`Attachment::invoice`] carries it under.
const INVOICE_FILE_NAME: &str = "factur-x.xml";

/// The invoice, as a machine reads it: a Factur-X BASIC profile document, which
/// carries the billed lines as well as the header. A real invoice carries more,
/// and carries it under the same rules.
fn invoice_xml() -> Vec<u8> {
    let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
<rsm:CrossIndustryInvoice
    xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
    xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
    xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
  <rsm:ExchangedDocumentContext>
    <ram:GuidelineSpecifiedDocumentContextParameter>
      <ram:ID>urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic</ram:ID>
    </ram:GuidelineSpecifiedDocumentContextParameter>
  </rsm:ExchangedDocumentContext>
  <rsm:ExchangedDocument>
    <ram:ID>2026-0207</ram:ID>
    <ram:TypeCode>380</ram:TypeCode>
    <ram:IssueDateTime>
      <udt:DateTimeString format="102">20260714</udt:DateTimeString>
    </ram:IssueDateTime>
  </rsm:ExchangedDocument>
  <rsm:SupplyChainTradeTransaction>
    <ram:IncludedSupplyChainTradeLineItem>
      <ram:AssociatedDocumentLineDocument>
        <ram:LineID>1</ram:LineID>
      </ram:AssociatedDocumentLineDocument>
      <ram:SpecifiedTradeProduct>
        <ram:Name>Rendering engine licence</ram:Name>
      </ram:SpecifiedTradeProduct>
      <ram:SpecifiedLineTradeAgreement>
        <ram:NetPriceProductTradePrice>
          <ram:ChargeAmount>12400.00</ram:ChargeAmount>
        </ram:NetPriceProductTradePrice>
      </ram:SpecifiedLineTradeAgreement>
      <ram:SpecifiedLineTradeDelivery>
        <ram:BilledQuantity unitCode="C62">1</ram:BilledQuantity>
      </ram:SpecifiedLineTradeDelivery>
      <ram:SpecifiedLineTradeSettlement>
        <ram:ApplicableTradeTax>
          <ram:TypeCode>VAT</ram:TypeCode>
          <ram:CategoryCode>S</ram:CategoryCode>
          <ram:RateApplicablePercent>20.00</ram:RateApplicablePercent>
        </ram:ApplicableTradeTax>
        <ram:SpecifiedTradeSettlementLineMonetarySummation>
          <ram:LineTotalAmount>12400.00</ram:LineTotalAmount>
        </ram:SpecifiedTradeSettlementLineMonetarySummation>
      </ram:SpecifiedLineTradeSettlement>
    </ram:IncludedSupplyChainTradeLineItem>
    <ram:ApplicableHeaderTradeAgreement>
      <ram:SellerTradeParty>
        <ram:Name>HQF Development</ram:Name>
        <ram:SpecifiedLegalOrganization>
          <ram:ID schemeID="0002">752492777</ram:ID>
        </ram:SpecifiedLegalOrganization>
        <ram:PostalTradeAddress>
          <ram:CountryID>FR</ram:CountryID>
        </ram:PostalTradeAddress>
      </ram:SellerTradeParty>
      <ram:BuyerTradeParty>
        <ram:Name>Northwind Trading SARL</ram:Name>
      </ram:BuyerTradeParty>
    </ram:ApplicableHeaderTradeAgreement>
    <ram:ApplicableHeaderTradeDelivery/>
    <ram:ApplicableHeaderTradeSettlement>
      <ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
      <ram:SpecifiedTradeSettlementHeaderMonetarySummation>
        <ram:TaxBasisTotalAmount>12400.00</ram:TaxBasisTotalAmount>
        <ram:TaxTotalAmount currencyID="EUR">2480.00</ram:TaxTotalAmount>
        <ram:GrandTotalAmount>14880.00</ram:GrandTotalAmount>
        <ram:DuePayableAmount>14880.00</ram:DuePayableAmount>
      </ram:SpecifiedTradeSettlementHeaderMonetarySummation>
    </ram:ApplicableHeaderTradeSettlement>
  </rsm:SupplyChainTradeTransaction>
</rsm:CrossIndustryInvoice>
"#;
    xml.as_bytes().to_vec()
}

/// The invoice's number, which the page shows and the XML carries under
/// `ram:ID`. One number, written once on each side.
const NUMBER: &str = "2026-0207";

/// Who issued the invoice, on the page and in the XML both.
const SELLER: &str = "HQF Development";

/// What each of the three billed lines comes to, in the order they are set.
/// These are the figures the XML totals, so they read the same wherever the
/// page is read.
const AMOUNTS: [&str; 3] = ["12 400.00 EUR", "2 480.00 EUR", "14 880.00 EUR"];

/// 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 = 58;

/// The size each line of the page is set at, in points, in the order they are
/// drawn.
const SIZES: [f64; 6] = [16.0, 10.0, 11.0, 11.0, 11.0, 12.0];

/// How far in from the left edge of the sheet every line is set, in points.
const LEFT: f64 = 72.0;

/// The words the page is written in, one set per language.
///
/// What is not language stays out of it: the invoice's number, the seller's
/// name and the three amounts read the same wherever the page is read, and so
/// does every word of the XML.
#[derive(Debug)]
struct Words {
    /// What stands before the invoice's number, at the head of the page and in
    /// the document's own title.
    number_label: &'static str,
    /// What the file says it holds.
    subject: &'static str,
    /// What stands before the seller's name.
    issued_by: &'static str,
    /// The line that gives the day it was issued and the term it falls due in.
    dates: &'static str,
    /// What each billed line is called, in the order `AMOUNTS` prices them.
    items: [&'static str; AMOUNTS.len()],
}

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

    /// What the page is headed by, which is also what the file says of itself.
    fn title(&self) -> String {
        format!("{} {NUMBER}", self.number_label)
    }

    /// The lines the page shows, which say what the XML says.
    fn lines(&self) -> Vec<String> {
        let mut lines = vec![
            self.title(),
            format!("{} {SELLER}", self.issued_by),
            self.dates.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 page in English.
const ENGLISH: Words = Words {
    number_label: "Invoice No.",
    subject: "An electronic invoice, measured before it was claimed",
    issued_by: "Issued by",
    dates: "Issued 14 July 2026 — due within 30 days",
    items: ["Rendering engine licence", "VAT at 20 %", "Total due"],
};

/// The page in French.
const FRENCH: Words = Words {
    number_label: "Facture n°",
    subject: "Une facture électronique, mesurée avant d'être revendiquée",
    issued_by: "Émise par",
    dates: "Émise le 14 juillet 2026 — payable sous 30 jours",
    items: ["Licence 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)];

/// The facts the document states about itself, naming the invoice it carries
/// and claiming `profile` for it.
fn facts(words: &Words, profile: InvoiceProfile) -> Metadata {
    let mut facts = Metadata::default();
    facts.title = Some(words.title());
    facts.author = Some(SELLER.to_owned());
    facts.subject = Some(words.subject.to_owned());
    facts.producer = Some("hqf-pdf".to_owned());
    facts.created = Some(ISSUED.to_owned());
    facts.invoice = Some(Invoice::new(INVOICE_FILE_NAME, profile, SPECIFICATION));
    facts
}

/// The page a person reads, saying what the attached XML says.
fn invoice_page(font: &FontHandle, words: &Words) -> Result<Page, hqf_pdf::Error> {
    let mut content = Content::new();

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

    let mut page = Page::a4();
    page.content = content.into_bytes();
    Ok(page)
}

/// Prints what `report` holds: the clause each finding cites, what it says, and
/// the one thing it is about where it names one.
fn print_report(asked: &str, report: &Report) {
    if report.is_empty() {
        println!("{asked}: nothing known here stands in the way");
        return;
    }
    println!("{asked}: {} in the way", report.len());
    for finding in report.findings() {
        match &finding.subject {
            Some(subject) => println!("  {}: {} — {subject}", finding.clause, finding.what),
            None => println!("  {}: {}", finding.clause, finding.what),
        }
    }
}

fn main() -> std::process::ExitCode {
    failure::reported(run())
}

fn run() -> 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("checked_invoice")));
    let font_path = args.next().map_or_else(default_font, PathBuf::from);

    // The profile the document claims, which is what it is measured against: a
    // report answers the claim that was made, not the one that should have
    // been.
    let mut claimed = CLAIMED_AT_FIRST;

    let mut doc = Document::new();
    doc.set_license(licence::licensed());
    doc.set_metadata(facts(words, claimed));

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

    let mut report = doc.check(Standard::Invoice(claimed));
    print_report("as first composed", &report);

    // An invoice belongs in a document claiming the third part of the archival
    // standard, and the file it names is one it carries. What the attachment
    // *is* to the document has no sensible default: `Data` is right for the
    // profiles that carry part of the invoice, and `Alternative` for those that
    // carry all of it. This one is BASIC, which carries part.
    doc.set_conformance(CONTAINER);
    doc.attach(Attachment::invoice(
        invoice_xml(),
        Relationship::Data,
        ISSUED,
    ));

    report = doc.check(Standard::Invoice(claimed));
    print_report("once the file is carried and the standard claimed", &report);

    // The XML states the profile it keeps to, and the document says what the
    // XML states.
    claimed = CARRIED;
    doc.set_metadata(facts(words, claimed));

    report = doc.check(Standard::Invoice(claimed));
    print_report("once the profile is the one the XML carries", &report);

    // The archival standard is a second claim, and a claim nobody asked about
    // is a claim nobody measured.
    let archival = doc.check(Standard::Archival(CONTAINER));
    print_report("against the archival standard now claimed", &archival);

    if !report.is_empty() || !archival.is_empty() {
        return Err("something still stands in the way, and nothing is written".into());
    }

    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 hqf_pdf::conformance::Standard;
    use hqf_pdf::metadata::xmp::InvoiceProfile;

    use super::{CARRIED, CLAIMED_AT_FIRST, LINE_CHARS, WORDS, invoice_xml, language};

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

    #[test]
    fn every_language_writes_the_invoice_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 (named, words) in WORDS {
            for line in words.lines().iter().skip(3) {
                assert_eq!(
                    line.chars().count(),
                    LINE_CHARS,
                    "the {} page bills {line:?}",
                    named.code()
                );
            }
        }
    }

    /// The two profiles the example turns on are the ones its XML disagrees
    /// with and agrees with: a first claim the XML does not carry, and the one
    /// it does.
    #[test]
    fn the_xml_carries_one_of_the_two_profiles_and_not_the_other() {
        let xml = invoice_xml();
        let carried = |profile: InvoiceProfile| {
            profile.guidelines().iter().any(|guideline| {
                xml.windows(guideline.len())
                    .any(|window| window == guideline.as_bytes())
            })
        };

        assert!(carried(CARRIED), "the XML states the profile it keeps to");
        assert_ne!(
            CLAIMED_AT_FIRST, CARRIED,
            "the first claim is one the report has something to say about"
        );
    }

    /// The example is written to leave nothing behind: what it hands the writer
    /// is a document both of its reports are empty on.
    #[test]
    fn what_the_example_writes_has_nothing_left_standing() {
        let words = super::Words::of(language::Language::English);
        let mut doc = hqf_pdf::Document::new();
        doc.set_metadata(super::facts(words, CARRIED));
        doc.set_conformance(super::CONTAINER);
        doc.attach(hqf_pdf::metadata::attachment::Attachment::invoice(
            invoice_xml(),
            hqf_pdf::metadata::attachment::Relationship::Data,
            super::ISSUED,
        ));
        let font = doc.add_font(
            hqf_pdf::Font::parse(
                std::fs::read(super::default_font()).expect("the committed font is there"),
            )
            .expect("the committed font parses"),
        );
        doc.add_page(super::invoice_page(&font, words).expect("the page is composed"))
            .expect("the page is added");

        let invoice = doc.check(Standard::Invoice(CARRIED));
        let archival = doc.check(Standard::Archival(super::CONTAINER));

        assert!(invoice.is_empty(), "the invoice report: {invoice:?}");
        assert!(archival.is_empty(), "the archival report: {archival:?}");
    }
}