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
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.
290 lines
9.7 KiB
Rust
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");
|
|
}
|
|
}
|