nigig-org/crates/apps/pdf/pdf-graphics/examples/generate_sample.rs
andodeki d4e3e9a443
Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Successful in 21s
doc-engine / coverage (push) Successful in 31s
doc-engine / consumer (push) Failing after 16m57s
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
feat(pdf): encryption on save — AES-128 and AES-256 (Phase 6, part one)
ADR 0024. This reverses ADR 0005's "never write encryption", and the
reason it is safe to reverse is that the facts changed underneath it.

A crate that only reads cannot produce weak ciphertext, so refusing to
write any was free. Now that Phase 4 creates documents and Phase 5 edits
them, the refusal does something worse than protect nobody: open a
password-protected file, change one annotation, save, and the output is
plaintext. No error, no warning — the protection is silently dropped. That
is this project's recurring failure mode in the one place where the
consequence is a breach.

The principle survives in a narrower form: no hand-rolled crypto, and no
weak cipher offered as an option. RC4 stays readable because files use it
and is not writable — EncryptionAlgorithm has no RC4 variant, so the
refusal is a type, not a runtime check someone can route around.

The encryptor is the literal inverse of the decryptor and imports its
primitives rather than restating them; two implementations of one algorithm
drift, and here they drift towards "decrypts to garbage". Every unit test
round-trips through the existing Decryptor.

Encryption sits at one choke point: PdfWriter holds the Encryptor and
write_object_at encrypts everything passing through. Not per call site —
there are twenty-two of those in PdfDocBuilder, and one stream written in
the clear inside an encrypted document is not a partial failure, it is a
leak that no reader will report because the file is otherwise valid. The
/Encrypt dictionary is the single deliberate exemption: it holds the salts
a reader needs before it has a key, so encrypting it bricks the file.

Verified against implementations we share no code with, now gated in CI:

  ok    qpdf opens it with the password
  ok    it really is AES-256
  ok    the wrong password is refused
  ok    poppler decrypts the content
  ok    no plaintext in the encrypted file

Four mutations, all killed — two only after the tests were strengthened,
and both misses are the interesting part:

  A fixed IV survived two_saves_of_one_document_are_not_byte_identical,
  because the AES-256 file key is fresh per save and that alone makes the
  output differ. The property actually needed is narrower: one encryptor,
  identical plaintext, different bytes. In CBC a repeated IV under one key
  leaks that two plaintexts are equal.

  A wrong /Length survived because our own reader recovers by scanning for
  endstream — a robustness fix from ADR 0023. An independent reader that
  trusts /Length reads a truncated stream and decrypts garbage. A lenient
  reader hides a broken writer, which is why the external gate exists.

The /Length test itself had a bug first: it searched a from_utf8_lossy view
and reported a stream declaring 80 bytes holding 156. Ciphertext is not
UTF-8; the replacement characters shifted every offset.

Unencrypted output stays byte-reproducible; encrypted output cannot be, and
a test asserts that loss rather than leaving it implicit.

pdf: 1220 passed (was 1187). pdf-ui: green. Coverage 88.21%,
encrypt_write.rs at 96.5%.

Signing is NOT started. It needs the trust-anchor decision ADR 0010
deferred: VerificationStatus::Valid is unreachable by construction, and
making sign -> verify pass is a policy change, not an implementation
detail. The plan's Phase 6 status now says so.
2026-08-18 07:27:45 +00:00

290 lines
9.7 KiB
Rust

//! Generate a sample PDF exercising every Phase 4 feature.
//!
//! Run with:
//!
//! ```text
//! cargo run -p nigig-pdf-graphics --example generate_sample -- out.pdf
//! ```
//!
//! The output is meant to be opened in a real viewer. Phase 4's exit
//! criterion is "generated PDFs open cleanly in external viewers", which no
//! test in this repository can assert on its own.
use std::collections::BTreeSet;
use nigig_pdf_cos::encrypt_write::EncryptionSettings;
use nigig_pdf_cos::writer::{
Attachment, DocumentMetadata, OutlineItem, PageLabelRange, PageLabelStyle, PageMode,
ViewerPreferences,
};
use nigig_pdf_graphics::content_writer::ContentWriter;
use nigig_pdf_graphics::create::{DocumentCreator, NewField, NewFieldKind};
fn main() {
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| "nigig-phase4-sample.pdf".to_string());
let font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf";
let mut creator = DocumentCreator::new();
creator.add_standard_font("Helv", "Helvetica");
let heading = "Nigig PDF — Phase 4";
let body = "Generated by nigig-pdf with an embedded DejaVu Sans subset.";
let embedded = std::fs::read(font_path).ok().and_then(|data| {
let chars: BTreeSet<char> = heading.chars().chain(body.chars()).collect();
creator.embed_truetype("F1", &data, &chars).ok()
});
// Page 1: text, colour and vector graphics.
let mut cw = ContentWriter::new();
cw.rgb_fill(0.10, 0.20, 0.45);
cw.rectangle(0.0, 742.0, 595.0, 50.0);
cw.fill();
if let Some(font) = &embedded {
cw.begin_text();
cw.rgb_fill(1.0, 1.0, 1.0);
cw.set_font("F1", 22.0);
cw.set_text_matrix(1.0, 0.0, 0.0, 1.0, 40.0, 758.0);
cw.show_glyph_hex(&font.encode(heading));
cw.end_text();
cw.begin_text();
cw.rgb_fill(0.0, 0.0, 0.0);
cw.set_font("F1", 12.0);
cw.set_text_matrix(1.0, 0.0, 0.0, 1.0, 40.0, 700.0);
cw.show_glyph_hex(&font.encode(body));
cw.end_text();
}
cw.begin_text();
cw.rgb_fill(0.0, 0.0, 0.0);
cw.set_font("Helv", 11.0);
cw.text_at(
40.0,
676.0,
"This line uses base-14 Helvetica, not embedded.",
);
cw.end_text();
cw.rgb_stroke(0.8, 0.2, 0.2);
cw.set_stroke_width(2.0);
cw.rounded_rect(40.0, 560.0, 200.0, 80.0, 8.0);
cw.stroke();
cw.rgb_fill(0.2, 0.6, 0.3);
cw.ellipse(400.0, 600.0, 60.0, 40.0);
cw.fill();
creator.builder().add_page(595.0, 792.0, &cw.build());
// Page 3 content is built here so the CFF font is registered before
// the pages that use it; page order is unaffected.
let cff_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../tests/corpus/fonts/cff_sample.otf"
);
let cff_text = "Hello CFF 123";
let cff_font = std::fs::read(cff_path).ok().and_then(|data| {
let chars: BTreeSet<char> = cff_text.chars().collect();
creator.embed_opentype_whole("C1", &data, &chars).ok()
});
// Page 2: the interactive form.
let mut p2 = ContentWriter::new();
p2.begin_text();
p2.set_font("Helv", 16.0);
p2.text_at(40.0, 720.0, "Interactive form");
p2.end_text();
for (y, label) in [
(652.0, "Full name:"),
(600.0, "Notes:"),
(505.0, "Agree:"),
(465.0, "Colour:"),
(417.0, "Country:"),
(377.0, "Locked:"),
] {
p2.begin_text();
p2.set_font("Helv", 10.0);
p2.text_at(40.0, y, label);
p2.end_text();
}
// Labels beside the radio buttons, placed to the right of each.
p2.begin_text();
p2.set_font("Helv", 10.0);
p2.text_at(134.0, 464.0, "red");
p2.end_text();
p2.begin_text();
p2.set_font("Helv", 10.0);
p2.text_at(224.0, 464.0, "blue");
p2.end_text();
creator.builder().add_page(595.0, 792.0, &p2.build());
creator.add_field(NewField {
name: "fullname".to_string(),
kind: NewFieldKind::Text {
value: "Ada Lovelace".to_string(),
multiline: false,
max_len: Some(60),
},
rect: [110.0, 645.0, 380.0, 668.0],
page: 1,
read_only: false,
required: true,
default_appearance: None,
});
creator.add_field(NewField {
name: "notes".to_string(),
kind: NewFieldKind::Text {
value: "A multi-line note.".to_string(),
multiline: true,
max_len: None,
},
rect: [110.0, 540.0, 380.0, 615.0],
page: 1,
read_only: false,
required: false,
default_appearance: None,
});
creator.add_field(NewField {
name: "agree".to_string(),
kind: NewFieldKind::Checkbox { checked: true },
rect: [110.0, 498.0, 128.0, 516.0],
page: 1,
read_only: false,
required: false,
default_appearance: None,
});
creator.add_field(NewField {
name: "colour".to_string(),
kind: NewFieldKind::Radio {
options: vec![
("red".to_string(), [110.0, 458.0, 128.0, 476.0]),
("blue".to_string(), [200.0, 458.0, 218.0, 476.0]),
],
selected: Some("blue".to_string()),
},
rect: [0.0; 4],
page: 1,
read_only: false,
required: false,
default_appearance: None,
});
creator.add_field(NewField {
name: "country".to_string(),
kind: NewFieldKind::Choice {
options: vec![
"Kenya".to_string(),
"Uganda".to_string(),
"Tanzania".to_string(),
],
selected: Some("Kenya".to_string()),
combo: true,
},
rect: [110.0, 410.0, 310.0, 433.0],
page: 1,
read_only: false,
required: false,
default_appearance: None,
});
creator.add_field(NewField {
name: "locked".to_string(),
kind: NewFieldKind::Text {
value: "read-only".to_string(),
multiline: false,
max_len: None,
},
rect: [110.0, 370.0, 310.0, 393.0],
page: 1,
read_only: true,
required: false,
default_appearance: None,
});
let builder = creator.builder();
builder.set_metadata(DocumentMetadata {
title: Some("Nigig PDF Phase 4 sample".to_string()),
author: Some("nigig".to_string()),
subject: Some("Document creation".to_string()),
keywords: Some("pdf generation phase4".to_string()),
creator: Some("nigig-pdf".to_string()),
producer: Some("nigig-pdf".to_string()),
creation_date: Some("D:20260816120000Z".to_string()),
mod_date: None,
});
builder.set_outline(vec![
OutlineItem::new("Cover page", 0)
.opened()
.with_children(vec![OutlineItem::new("Heading", 0).with_top(770.0)]),
OutlineItem::new("The form", 1).with_top(740.0),
]);
builder.add_named_destination("form", 1, Some(740.0));
builder.add_named_destination("cover", 0, None);
builder.add_attachment(Attachment {
name: "readme.txt".to_string(),
data: b"Generated by nigig-pdf, Phase 4.\n".to_vec(),
description: Some("About this file".to_string()),
mime_type: Some("text/plain".to_string()),
});
builder.set_page_labels(vec![
PageLabelRange {
start_page: 0,
style: PageLabelStyle::LowerRoman,
prefix: None,
first: None,
},
PageLabelRange {
start_page: 1,
style: PageLabelStyle::Decimal,
prefix: None,
first: Some(1),
},
]);
builder.set_page_mode(PageMode::UseOutlines);
builder.set_viewer_preferences(ViewerPreferences {
display_doc_title: Some(true),
..Default::default()
});
// Page 3: CFF-flavoured OpenType, embedded whole. Present only when
// the fixture is there, so the sample still generates without it.
if let Some(font) = &cff_font {
let mut p3 = ContentWriter::new();
p3.begin_text();
p3.set_font("Helv", 12.0);
p3.text_at(56.0, 740.0, "CFF (Type 1 outlines), embedded whole:");
p3.end_text();
p3.begin_text();
p3.set_font(&font.resource_name, 24.0);
p3.set_text_matrix(1.0, 0.0, 0.0, 1.0, 56.0, 690.0);
p3.show_glyph_hex(&font.encode(cff_text));
p3.end_text();
creator.builder().add_page(595.0, 792.0, &p3.build());
}
let pdf = creator.finish();
std::fs::write(&out, &pdf).expect("write");
// A second, encrypted copy when asked for, so the external-reader
// check can verify that a password-protected file we produce is one
// other implementations can actually open (Phase 6, ADR 0024).
if let Some(encrypted_out) = std::env::args().nth(2) {
let mut enc = DocumentCreator::new();
enc.add_standard_font("Helv", "Helvetica");
let mut page = ContentWriter::new();
page.begin_text();
page.set_font("Helv", 14.0);
page.text_at(40.0, 700.0, "ENCRYPTEDSAMPLE — opened with the password");
page.end_text();
enc.builder().add_page(595.0, 792.0, &page.build());
enc.builder().set_metadata(DocumentMetadata {
title: Some("Nigig PDF encrypted sample".to_string()),
..Default::default()
});
enc.builder()
.set_encryption(EncryptionSettings::with_password("hunter2"));
std::fs::write(&encrypted_out, enc.finish()).expect("write encrypted");
println!("wrote {encrypted_out} (AES-256, password hunter2)");
}
println!("wrote {} ({} bytes)", out, pdf.len());
if embedded.is_none() {
println!("note: {font_path} was not found, so no font was embedded");
}
}