one-shot first ver

This commit is contained in:
Brandon Thomas
2026-09-30 05:17:08 -04:00
commit f0b5f8ff55
208 changed files with 15369 additions and 0 deletions
+591
View File
@@ -0,0 +1,591 @@
//! Annotations with hand-built appearance streams.
use lopdf::{Dictionary, Document, Object, ObjectId, Stream, dictionary};
use super::pdf::{Content, Font, Fonts, Rgb, Stamp, colors, floats, form_xobject, name, rect, text};
/// An axis-aligned rectangle in PDF user space.
#[derive(Clone, Copy, Debug)]
pub struct Rect {
pub x0: f32,
pub y0: f32,
pub x1: f32,
pub y1: f32,
}
impl Rect {
pub fn new(x0: f32, y0: f32, x1: f32, y1: f32) -> Self {
Rect { x0: x0.min(x1), y0: y0.min(y1), x1: x0.max(x1), y1: y0.max(y1) }
}
pub fn xywh(x: f32, y: f32, w: f32, h: f32) -> Self {
Rect::new(x, y, x + w, y + h)
}
pub fn w(&self) -> f32 {
self.x1 - self.x0
}
pub fn h(&self) -> f32 {
self.y1 - self.y0
}
pub fn obj(&self) -> Object {
rect(self.x0, self.y0, self.x1, self.y1)
}
/// Parse a PDF `/Rect` array.
pub fn from_obj(obj: &Object) -> Option<Self> {
let a = obj.as_array().ok()?;
let v: Vec<f32> = a.iter().filter_map(|o| o.as_float().ok()).collect();
(v.len() == 4).then(|| Rect::new(v[0], v[1], v[2], v[3]))
}
}
/// Every annotation kind the showcase exercises.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Highlight,
Underline,
StrikeOut,
Squiggly,
Caret,
Note,
FreeText,
Line,
Square,
Circle,
Polygon,
PolyLine,
Ink,
Stamp,
Attachment,
}
impl Kind {
/// Parse the `<kind>` segment of a marker URI.
pub fn from_marker(s: &str) -> Option<Self> {
Some(match s {
"highlight" => Kind::Highlight,
"underline" => Kind::Underline,
"strikeout" => Kind::StrikeOut,
"squiggly" => Kind::Squiggly,
"caret" => Kind::Caret,
"note" => Kind::Note,
"freetext" => Kind::FreeText,
"line" => Kind::Line,
"square" => Kind::Square,
"circle" => Kind::Circle,
"polygon" => Kind::Polygon,
"polyline" => Kind::PolyLine,
"ink" => Kind::Ink,
"stamp" => Kind::Stamp,
"attach" => Kind::Attachment,
_ => return None,
})
}
}
const AUTHOR: &str = "PrintCraft Reviewer";
const AUTHOR_2: &str = "Layout Editor";
/// Builds annotation dictionaries (and their appearance XObjects) for one document.
pub struct Annotator {
fonts: Fonts,
now: Stamp,
seq: u32,
}
/// A built annotation: the main dictionary plus any extra annotation objects
/// (popups, replies) that must also be listed in the page's `/Annots`.
pub struct Built {
pub dict: Dictionary,
pub extra: Vec<ObjectId>,
}
impl Annotator {
pub fn new(fonts: Fonts, now: Stamp) -> Self {
Annotator { fonts, now, seq: 0 }
}
/// Common markup-annotation entries.
fn base(&mut self, subtype: &str, r: Rect, color: Rgb, author: &str, contents: &str, page: ObjectId) -> Dictionary {
self.seq += 1;
let when = self.now.plus_minutes(i64::from(self.seq));
dictionary! {
"Type" => "Annot",
"Subtype" => name(subtype),
"Rect" => r.obj(),
"P" => page,
"F" => 4,
"C" => color.array(),
"T" => text(author),
"Contents" => text(contents),
"Subj" => text(subtype),
"M" => Object::string_literal(when.pdf.clone()),
"CreationDate" => Object::string_literal(when.pdf),
"NM" => Object::string_literal(format!("printcraft-showcase-{:03}-{}", self.seq, subtype.to_lowercase())),
}
}
fn set_ap(&self, doc: &mut Document, dict: &mut Dictionary, r: Rect, content: Content, resources: Dictionary) {
let id = form_xobject(doc, r.w(), r.h(), content, resources);
dict.set("AP", dictionary! { "N" => id });
}
fn multiply_resources(&self) -> Dictionary {
dictionary! {
"ExtGState" => dictionary! { "Mult" => dictionary! { "Type" => "ExtGState", "BM" => "Multiply" } },
}
}
/// Build the annotation of `kind` whose geometry is derived from `r`.
/// `self_id` is the object id the returned dictionary will be stored under.
pub fn build(&mut self, doc: &mut Document, page: ObjectId, self_id: ObjectId, kind: Kind, r: Rect) -> Built {
let mut extra = Vec::new();
let dict = match kind {
Kind::Highlight | Kind::Underline | Kind::StrikeOut | Kind::Squiggly => self.text_markup(doc, page, kind, r),
Kind::Caret => self.caret(doc, page, r),
Kind::Note => {
let (dict, more) = self.sticky_note(doc, page, self_id, r);
extra = more;
dict
}
Kind::FreeText => self.free_text(doc, page, r),
Kind::Line => self.line(doc, page, r),
Kind::Square | Kind::Circle => self.shape(doc, page, kind, r),
Kind::Polygon | Kind::PolyLine => self.poly(doc, page, kind, r),
Kind::Ink => self.ink(doc, page, r),
Kind::Stamp => self.stamp(doc, page, r),
Kind::Attachment => self.attachment(doc, page, r),
};
Built { dict, extra }
}
fn text_markup(&mut self, doc: &mut Document, page: ObjectId, kind: Kind, r: Rect) -> Dictionary {
let (subtype, color, contents) = match kind {
Kind::Highlight => ("Highlight", colors::HIGHLIGHT, "Key phrase - keep this wording."),
Kind::Underline => ("Underline", colors::BLUE, "Please cite a source for this claim."),
Kind::StrikeOut => ("StrikeOut", colors::ACCENT, "Redundant; suggest deleting."),
_ => ("Squiggly", colors::TEAL, "Check the spelling here."),
};
let mut dict = self.base(subtype, r, color, AUTHOR, contents, page);
// QuadPoints order used by every mainstream viewer: TL, TR, BL, BR.
dict.set("QuadPoints", floats(&[r.x0, r.y1, r.x1, r.y1, r.x0, r.y0, r.x1, r.y0]));
dict.set("CA", 1.0);
let (w, h) = (r.w(), r.h());
let mut c = Content::new();
let mut res = Dictionary::new();
match kind {
Kind::Highlight => {
res = self.multiply_resources();
c.gs("Mult").fill_color(color).rect(0.0, 0.0, w, h).fill();
}
Kind::Underline => {
let t = (h / 16.0).max(0.8);
c.stroke_color(color).line_width(t).move_to(0.0, h * 0.17).line_to(w, h * 0.17).stroke();
}
Kind::StrikeOut => {
let t = (h / 16.0).max(0.8);
c.stroke_color(color).line_width(t).move_to(0.0, h * 0.44).line_to(w, h * 0.44).stroke();
}
_ => {
let (base, amp, period) = (h * 0.12, 1.4, 4.0);
c.stroke_color(color).line_width(0.9).round_caps().move_to(0.0, base);
let mut x = 0.0;
let mut up = true;
while x < w {
x = (x + period / 2.0).min(w);
c.line_to(x, if up { base + amp } else { base - amp });
up = !up;
}
c.stroke();
}
}
self.set_ap(doc, &mut dict, r, c, res);
dict
}
fn caret(&mut self, doc: &mut Document, page: ObjectId, anchor: Rect) -> Dictionary {
let cx = (anchor.x0 + anchor.x1) / 2.0;
let r = Rect::new(cx - 6.0, anchor.y0 + 1.0, cx + 6.0, anchor.y0 + anchor.h() * 0.62);
let mut dict = self.base("Caret", r, colors::BLUE, AUTHOR, "Insert: \"automatically\"", page);
dict.set("Sy", "None");
dict.set("RD", floats(&[0.0, 0.0, 0.0, 0.0]));
let (w, h) = (r.w(), r.h());
let mut c = Content::new();
c.fill_color(colors::BLUE)
.move_to(0.0, 0.0)
.curve_to(w * 0.35, h * 0.2, w * 0.45, h * 0.6, w * 0.5, h)
.curve_to(w * 0.55, h * 0.6, w * 0.65, h * 0.2, w, 0.0)
.close()
.fill();
self.set_ap(doc, &mut dict, r, c, Dictionary::new());
dict
}
fn note_icon(&self, doc: &mut Document, r: Rect, fill: Rgb) -> ObjectId {
let (w, h) = (r.w(), r.h());
let mut c = Content::new();
c.fill_color(fill)
.stroke_color(colors::INK)
.line_width(0.8)
.rounded_rect(0.5, 0.5, w - 1.0, h - 1.0, 3.0)
.fill_stroke();
c.line_width(1.0).round_caps();
for i in 1..=3 {
let y = h - 0.5 - (h - 1.0) * i as f32 / 4.2;
let x1 = if i == 3 { w * 0.55 } else { w * 0.78 };
c.move_to(w * 0.22, y).line_to(x1, y);
}
c.stroke();
form_xobject(doc, w, h, c, Dictionary::new())
}
fn sticky_note(&mut self, doc: &mut Document, page: ObjectId, self_id: ObjectId, r: Rect) -> (Dictionary, Vec<ObjectId>) {
let r = Rect::xywh(r.x0, r.y1 - 20.0, 20.0, 20.0);
let mut dict = self.base(
"Text",
r,
colors::GOLD,
AUTHOR,
"Sticky notes hold threaded discussions. This one has two replies.",
page,
);
dict.set("F", 28); // Print | NoZoom | NoRotate
dict.set("Name", "Comment");
dict.set("Open", false);
let icon = self.note_icon(doc, r, Rgb::hex(0xffd866));
dict.set("AP", dictionary! { "N" => icon });
let popup = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Popup",
"Parent" => self_id,
"P" => page,
"Rect" => Rect::new((r.x1 + 8.0).min(412.0), r.y1 - 110.0, (r.x1 + 8.0).min(412.0) + 190.0, r.y1).obj(),
"Open" => false,
"F" => 28,
});
dict.set("Popup", popup);
let mut reply = self.base(
"Text",
r,
colors::TEAL,
AUTHOR_2,
"Agreed - and the appearance stream draws the icon, so every viewer shows the same thing.",
page,
);
reply.set("F", 28);
reply.set("Name", "Comment");
reply.set("IRT", self_id);
reply.set("RT", "R");
reply.set("AP", dictionary! { "N" => icon });
let reply_id = doc.add_object(reply);
let mut state = self.base("Text", r, colors::TEAL, AUTHOR, "Accepted set by PrintCraft Reviewer", page);
state.set("F", 30); // hidden: review-state annotations are shown only in the comments list
state.set("Name", "Comment");
state.set("IRT", self_id);
state.set("State", Object::string_literal("Accepted"));
state.set("StateModel", Object::string_literal("Review"));
state.set("AP", dictionary! { "N" => icon });
let state_id = doc.add_object(state);
(dict, vec![popup, reply_id, state_id])
}
/// A standalone sticky note (no replies) at the given top-left corner.
pub fn simple_note(&mut self, doc: &mut Document, page: ObjectId, x: f32, y_top: f32, contents: &str) -> ObjectId {
let r = Rect::xywh(x, y_top - 20.0, 20.0, 20.0);
let mut dict = self.base("Text", r, colors::GOLD, AUTHOR, contents, page);
dict.set("F", 28);
dict.set("Name", "Note");
dict.set("Open", false);
let icon = self.note_icon(doc, r, Rgb::hex(0xffd866));
dict.set("AP", dictionary! { "N" => icon });
let id = doc.new_object_id();
let popup = doc.add_object(dictionary! {
"Type" => "Annot", "Subtype" => "Popup", "Parent" => id, "P" => page,
"Rect" => Rect::new(x - 200.0, y_top - 100.0, x - 10.0, y_top).obj(), "Open" => false, "F" => 28,
});
dict.set("Popup", popup);
doc.objects.insert(id, Object::Dictionary(dict));
id
}
fn free_text(&mut self, doc: &mut Document, page: ObjectId, r: Rect) -> Dictionary {
let lines = ["FreeText: typed straight onto", "the page, with a /DA and rich text."];
let contents = lines.join(" ");
let mut dict = self.base("FreeText", r, colors::FIELD, AUTHOR, &contents, page);
dict.set("DA", Object::string_literal("/Helv 9 Tf 0.086 0.129 0.231 rg"));
dict.set("DS", Object::string_literal("font: Helvetica,sans-serif 9.0pt; text-align:left; color:#16213B"));
dict.set(
"RC",
text(&format!(
"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" \
xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" xfa:APIVersion=\"Acroform:2.7.0.0\" \
xfa:spec=\"2.0.2\"><p><b>FreeText:</b> typed straight onto the page, with a /DA and \
<span style=\"color:#E4572E\">rich text</span>.</p></body>"
)),
);
dict.set("Q", 0);
dict.set("BS", dictionary! { "W" => 1, "S" => "S" });
let (w, h) = (r.w(), r.h());
let mut c = Content::new();
c.fill_color(colors::FIELD)
.stroke_color(colors::BLUE)
.line_width(1.0)
.rect(0.5, 0.5, w - 1.0, h - 1.0)
.fill_stroke();
c.save().rect(2.0, 2.0, w - 4.0, h - 4.0).clip();
c.fill_color(colors::INK);
c.text(Font::HelvBold, 9.0, 6.0, h - 15.0, "FreeText:");
let lead = Font::HelvBold.width("FreeText: ", 9.0);
c.text(Font::Helv, 9.0, 6.0 + lead, h - 15.0, "typed straight onto");
c.text(Font::Helv, 9.0, 6.0, h - 27.0, "the page, with a /DA and");
c.fill_color(colors::ACCENT).text(Font::Helv, 9.0, 6.0 + Font::Helv.width("the page, with a /DA and ", 9.0), h - 27.0, "rich text.");
c.restore();
self.set_ap(doc, &mut dict, r, c, self.fonts.resources());
dict
}
fn line(&mut self, doc: &mut Document, page: ObjectId, r: Rect) -> Dictionary {
let (x0, y0, x1, y1) = (r.x0 + 6.0, r.y0 + 8.0, r.x1 - 6.0, r.y1 - 8.0);
let mut dict = self.base("Line", r, colors::INK, AUTHOR, "Line annotation with a circle start and closed arrow end.", page);
dict.set("L", floats(&[x0, y0, x1, y1]));
dict.set("LE", vec![name("Circle"), name("ClosedArrow")]);
dict.set("IC", colors::ACCENT.array());
dict.set("BS", dictionary! { "W" => 1.5, "S" => "S" });
let (lx0, ly0, lx1, ly1) = (x0 - r.x0, y0 - r.y0, x1 - r.x0, y1 - r.y0);
let (dx, dy) = (lx1 - lx0, ly1 - ly0);
let len = (dx * dx + dy * dy).sqrt();
let (ux, uy) = (dx / len, dy / len);
let (al, aw) = (11.0, 4.5);
let (bx, by) = (lx1 - ux * al, ly1 - uy * al);
let mut c = Content::new();
c.stroke_color(colors::INK).line_width(1.5).round_caps();
c.move_to(lx0, ly0).line_to(bx, by).stroke();
c.fill_color(colors::ACCENT)
.move_to(lx1, ly1)
.line_to(bx - uy * aw, by + ux * aw)
.line_to(bx + uy * aw, by - ux * aw)
.close()
.fill_stroke();
c.ellipse(lx0, ly0, 3.0, 3.0).fill_stroke();
self.set_ap(doc, &mut dict, r, c, Dictionary::new());
dict
}
fn shape(&mut self, doc: &mut Document, page: ObjectId, kind: Kind, r: Rect) -> Dictionary {
let (subtype, stroke, fill, contents) = if kind == Kind::Square {
("Square", colors::TEAL, Rgb::hex(0xd8efe9), "Square annotation, dashed border, tinted interior.")
} else {
("Circle", colors::ACCENT, Rgb::hex(0xfbeccc), "Circle annotation with interior colour.")
};
let bw = 2.0;
let mut dict = self.base(subtype, r, stroke, AUTHOR, contents, page);
dict.set("IC", fill.array());
dict.set("CA", 0.85);
let (w, h) = (r.w(), r.h());
let mut c = Content::new();
c.gs("A").stroke_color(stroke).fill_color(fill).line_width(bw);
if kind == Kind::Square {
dict.set("BS", dictionary! { "W" => bw, "S" => "D", "D" => vec![Object::from(4), Object::from(2)] });
c.dash(4.0, 2.0).rect(bw / 2.0, bw / 2.0, w - bw, h - bw).fill_stroke();
} else {
dict.set("BS", dictionary! { "W" => bw, "S" => "S" });
c.ellipse(w / 2.0, h / 2.0, w / 2.0 - bw / 2.0, h / 2.0 - bw / 2.0).fill_stroke();
}
let res = dictionary! {
"ExtGState" => dictionary! { "A" => dictionary! { "Type" => "ExtGState", "CA" => 0.85, "ca" => 0.85 } },
};
self.set_ap(doc, &mut dict, r, c, res);
dict
}
fn poly(&mut self, doc: &mut Document, page: ObjectId, kind: Kind, r: Rect) -> Dictionary {
let (w, h) = (r.w(), r.h());
let local: Vec<(f32, f32)> = if kind == Kind::Polygon {
// An irregular hexagon.
vec![(0.5, 0.95), (0.93, 0.7), (0.85, 0.18), (0.45, 0.05), (0.08, 0.28), (0.1, 0.72)]
} else {
vec![(0.06, 0.2), (0.3, 0.8), (0.5, 0.35), (0.72, 0.9), (0.94, 0.45)]
}
.into_iter()
.map(|(fx, fy)| (4.0 + fx * (w - 8.0), 4.0 + fy * (h - 8.0)))
.collect();
let vertices: Vec<f32> = local.iter().flat_map(|&(x, y)| [r.x0 + x, r.y0 + y]).collect();
let (subtype, stroke, contents) = if kind == Kind::Polygon {
("Polygon", colors::BLUE, "Polygon annotation (six vertices).")
} else {
("PolyLine", colors::INK, "PolyLine annotation ending in an open arrow.")
};
let mut dict = self.base(subtype, r, stroke, AUTHOR, contents, page);
dict.set("Vertices", floats(&vertices));
dict.set("BS", dictionary! { "W" => 2, "S" => "S" });
let mut c = Content::new();
c.stroke_color(stroke).line_width(2.0).raw("1 j");
c.move_to(local[0].0, local[0].1);
for &(x, y) in &local[1..] {
c.line_to(x, y);
}
if kind == Kind::Polygon {
let fill = Rgb::hex(0xdbe6fa);
dict.set("IC", fill.array());
c.close().fill_color(fill).fill_stroke();
} else {
dict.set("LE", vec![name("None"), name("OpenArrow")]);
c.stroke();
let (a, b) = (local[local.len() - 2], local[local.len() - 1]);
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len = (dx * dx + dy * dy).sqrt();
let (ux, uy) = (dx / len, dy / len);
let (bx, by) = (b.0 - ux * 9.0, b.1 - uy * 9.0);
c.round_caps()
.move_to(bx - uy * 5.0, by + ux * 5.0)
.line_to(b.0, b.1)
.line_to(bx + uy * 5.0, by - ux * 5.0)
.stroke();
}
self.set_ap(doc, &mut dict, r, c, Dictionary::new());
dict
}
fn ink(&mut self, doc: &mut Document, page: ObjectId, r: Rect) -> Dictionary {
let (w, h) = (r.w(), r.h());
// A looping "signature-like" scribble and a short underline swoosh.
let scribble: Vec<(f32, f32)> = (0..=120)
.map(|i| {
let t = i as f32 / 120.0;
let x = 6.0 + t * (w * 0.78) + 7.0 * (t * 38.0).sin();
let y = h * 0.6 + (h * 0.26) * (t * 19.0).sin() * (1.0 - 0.5 * t);
(x, y)
})
.collect();
let swoosh: Vec<(f32, f32)> = (0..=30)
.map(|i| {
let t = i as f32 / 30.0;
(w * 0.1 + t * w * 0.85, h * 0.14 + 5.0 * (t * std::f32::consts::PI).sin())
})
.collect();
let mut dict = self.base("Ink", r, colors::ACCENT, AUTHOR, "Freehand ink: two strokes.", page);
let ink_list: Vec<Object> = [&scribble, &swoosh]
.iter()
.map(|pts| floats(&pts.iter().flat_map(|&(x, y)| [r.x0 + x, r.y0 + y]).collect::<Vec<_>>()))
.collect();
dict.set("InkList", ink_list);
dict.set("BS", dictionary! { "W" => 1.8, "S" => "S" });
let mut c = Content::new();
c.stroke_color(colors::ACCENT).line_width(1.8).round_caps();
for pts in [&scribble, &swoosh] {
c.move_to(pts[0].0, pts[0].1);
for &(x, y) in &pts[1..] {
c.line_to(x, y);
}
c.stroke();
}
self.set_ap(doc, &mut dict, r, c, Dictionary::new());
dict
}
fn stamp(&mut self, doc: &mut Document, page: ObjectId, r: Rect) -> Dictionary {
let green = Rgb::hex(0x1d7f4e);
let mut dict = self.base("Stamp", r, green, AUTHOR, "Approved for the showcase.", page);
dict.set("Name", "PrintCraftApproved");
dict.set("CA", 0.92);
let (w, h) = (r.w(), r.h());
let mut c = Content::new();
// Rotate slightly about the centre, scaled so the corners stay inside the box.
let (sin, cos) = (-4.0f32).to_radians().sin_cos();
let s = 0.9;
let (cx, cy) = (w / 2.0, h / 2.0);
c.cm(cos * s, sin * s, -sin * s, cos * s, cx - s * (cos * cx - sin * cy), cy - s * (sin * cx + cos * cy));
c.stroke_color(green).line_width(2.5).rounded_rect(2.0, 2.0, w - 4.0, h - 4.0, 8.0).stroke();
c.line_width(0.8).rounded_rect(6.0, 6.0, w - 12.0, h - 12.0, 5.0).stroke();
c.fill_color(green);
let title = "APPROVED";
let size = 22.0;
let tw = Font::HelvBold.width(title, size) + 3.0 * (title.len() as f32 - 1.0);
c.tracked_text(Font::HelvBold, size, (w - tw) / 2.0, h * 0.42, 3.0, title);
let date = format!("PRINTCRAFT QA \u{2022} {}", &self.now.iso[..10]);
let dw = Font::Helv.width(&date, 7.0) + 1.0 * (date.chars().count() as f32 - 1.0);
c.tracked_text(Font::Helv, 7.0, (w - dw) / 2.0, h * 0.2, 1.0, &date);
let res = self.fonts.resources();
self.set_ap(doc, &mut dict, r, c, res);
dict
}
fn attachment(&mut self, doc: &mut Document, page: ObjectId, r: Rect) -> Dictionary {
let note = "Review notes for the PrintCraft showcase.\n\n\
- Page 9 exercises every common annotation type.\n\
- Each annotation carries its own appearance stream.\n";
let fs = embedded_file(doc, "review-notes.txt", "text/plain", note.as_bytes(), "Notes attached to a comment", &self.now);
let mut dict = self.base("FileAttachment", r, colors::BLUE, AUTHOR, "Attached: review-notes.txt", page);
dict.set("FS", fs);
dict.set("Name", "Paperclip");
dict.set("F", 28);
let (w, h) = (r.w(), r.h());
let mut c = Content::new();
// A paperclip: nested U-shaped loops.
c.stroke_color(colors::BLUE).line_width(1.6).round_caps();
let (l, rr) = (w * 0.25, w * 0.75);
c.move_to(w * 0.62, h * 0.3)
.line_to(w * 0.62, h * 0.75)
.curve_to(w * 0.62, h * 0.92, w * 0.38, h * 0.92, w * 0.38, h * 0.75)
.line_to(w * 0.38, h * 0.2)
.curve_to(w * 0.38, h * 0.08, l, h * 0.02, l, h * 0.2)
.line_to(l, h * 0.82)
.curve_to(l, h * 1.02, rr, h * 1.02, rr, h * 0.82)
.line_to(rr, h * 0.25)
.stroke();
self.set_ap(doc, &mut dict, r, c, Dictionary::new());
dict
}
/// A link annotation (no border) to a page or a URI.
pub fn link(&mut self, doc: &mut Document, page: ObjectId, r: Rect, target: LinkTarget) -> ObjectId {
let mut dict = dictionary! {
"Type" => "Annot",
"Subtype" => "Link",
"Rect" => r.obj(),
"P" => page,
"F" => 4,
"Border" => vec![Object::from(0), Object::from(0), Object::from(0)],
"H" => "I",
};
match target {
LinkTarget::Page(p) => dict.set("Dest", vec![Object::Reference(p), name("XYZ"), Object::Null, Object::Null, Object::Null]),
LinkTarget::Uri(uri) => dict.set("A", dictionary! { "S" => "URI", "URI" => Object::string_literal(uri) }),
}
doc.add_object(dict)
}
}
pub enum LinkTarget<'a> {
Page(ObjectId),
Uri(&'a str),
}
/// Create an embedded file stream and its file specification; returns the filespec id.
pub fn embedded_file(doc: &mut Document, file_name: &str, mime: &str, data: &[u8], desc: &str, now: &Stamp) -> ObjectId {
let stream = Stream::new(
dictionary! {
"Type" => "EmbeddedFile",
"Subtype" => name(mime),
"Params" => dictionary! {
"Size" => data.len() as i64,
"ModDate" => Object::string_literal(now.pdf.clone()),
"CreationDate" => Object::string_literal(now.pdf.clone()),
},
},
data.to_vec(),
);
let ef = doc.add_object(stream);
doc.add_object(dictionary! {
"Type" => "Filespec",
"F" => Object::string_literal(file_name),
"UF" => text(file_name),
"Desc" => text(desc),
"AFRelationship" => "Supplement",
"EF" => dictionary! { "F" => ef, "UF" => ef },
})
}
+113
View File
@@ -0,0 +1,113 @@
//! Locating a Chromium-based browser and printing HTML to PDF with it.
use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
/// Find a Chrome/Chromium binary. `explicit` (from `--chrome`) wins, then the
/// `PRINTCRAFT_CHROME` / `CHROME` environment variables, then well-known
/// install locations, then `PATH`.
pub fn find(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(path) = explicit {
if path.is_file() {
return Ok(path.to_path_buf());
}
bail!("--chrome {} does not exist", path.display());
}
for var in ["PRINTCRAFT_CHROME", "CHROME"] {
if let Some(path) = env::var_os(var).map(PathBuf::from)
&& path.is_file()
{
return Ok(path);
}
}
let mut candidates: Vec<PathBuf> = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
]
.iter()
.map(PathBuf::from)
.collect();
if let Some(home) = env::var_os("HOME") {
candidates.push(
Path::new(&home).join("Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
);
}
for var in ["ProgramFiles", "ProgramFiles(x86)", "LocalAppData"] {
if let Some(base) = env::var_os(var) {
let base = PathBuf::from(base);
candidates.push(base.join(r"Google\Chrome\Application\chrome.exe"));
candidates.push(base.join(r"Microsoft\Edge\Application\msedge.exe"));
}
}
if let Some(found) = candidates.into_iter().find(|p| p.is_file()) {
return Ok(found);
}
let names = [
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"chrome",
"chrome.exe",
"msedge",
];
let path = env::var_os("PATH").unwrap_or_else(OsString::new);
for dir in env::split_paths(&path) {
for name in names {
let candidate = dir.join(name);
if candidate.is_file() {
return Ok(candidate);
}
}
}
bail!(
"could not find Google Chrome or Chromium; install one or pass --chrome <path> \
(or set PRINTCRAFT_CHROME)"
)
}
/// Print `html` to `pdf` with headless Chrome. Chrome emits a tagged PDF and,
/// with `--generate-pdf-document-outline`, bookmarks built from the headings.
pub fn print_to_pdf(chrome: &Path, html: &Path, pdf: &Path, profile_dir: &Path) -> Result<()> {
if pdf.exists() {
fs::remove_file(pdf).with_context(|| format!("removing stale {}", pdf.display()))?;
}
let mut print_arg = OsString::from("--print-to-pdf=");
print_arg.push(pdf);
let mut profile_arg = OsString::from("--user-data-dir=");
profile_arg.push(profile_dir);
let output = Command::new(chrome)
.arg("--headless=new")
.arg("--disable-gpu")
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--allow-file-access-from-files")
.arg("--no-pdf-header-footer")
.arg("--generate-pdf-document-outline")
.arg(profile_arg)
.arg(print_arg)
.arg(super::file_url(html))
.output()
.with_context(|| format!("running {}", chrome.display()))?;
let ok = output.status.success() && fs::metadata(pdf).map(|m| m.len() > 0).unwrap_or(false);
if !ok {
bail!(
"Chrome did not produce {} (status {}):\n{}",
pdf.display(),
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
Ok(())
}
+501
View File
@@ -0,0 +1,501 @@
//! The appended "Interactive Form" page: layout drawn with standard fonts plus a
//! full AcroForm whose every widget carries its own appearance streams.
use lopdf::{Dictionary, Document, Object, ObjectId, Stream, dictionary};
use super::annots::{Annotator, LinkTarget, Rect};
use super::pdf::{Content, Font, Fonts, Rgb, colors, form_xobject, name, text};
const PAGE_W: f32 = 612.0;
const PAGE_H: f32 = 792.0;
const LEFT: f32 = 61.2;
const RIGHT: f32 = PAGE_W - 61.2;
const COL2: f32 = 316.0;
const DA_TEXT: &str = "/Helv 10 Tf 0.086 0.129 0.231 rg";
const DA_ZAPF: &str = "/ZaDb 0 Tf 0.086 0.129 0.231 rg";
/// Result of building the form page.
pub struct FormPage {
pub page: ObjectId,
/// Top-level fields for `/AcroForm /Fields`.
pub fields: Vec<ObjectId>,
}
/// Inputs the form page needs from the rest of the document.
pub struct FormInputs<'a> {
pub fonts: &'a Fonts,
pub pages_root: ObjectId,
pub contents_page: ObjectId,
pub print_only_ocg: ObjectId,
pub folio: &'a str,
}
struct Builder<'a> {
doc: &'a mut Document,
fonts: &'a Fonts,
page: ObjectId,
widgets: Vec<ObjectId>,
fields: Vec<ObjectId>,
}
pub fn build(doc: &mut Document, annotator: &mut Annotator, inputs: FormInputs<'_>) -> FormPage {
let page = doc.new_object_id();
let mut b = Builder { doc, fonts: inputs.fonts, page, widgets: Vec::new(), fields: Vec::new() };
let mut c = Content::new();
draw_page_chrome(&mut c, inputs.folio);
// --- Contact -------------------------------------------------------------
section(&mut c, 612.0, "Contact");
let row1 = 575.0;
label(&mut c, LEFT, row1 + 24.0, "Full name");
b.text_field("name", "Full name", Rect::new(LEFT, row1, 296.0, row1 + 20.0), "Ada Typesetter", None, 0, |_| {});
label(&mut c, COL2, row1 + 24.0, "Email");
b.text_field("email", "Email address", Rect::new(COL2, row1, RIGHT, row1 + 20.0), "ada@example.com", None, 0, |_| {});
let row2 = 531.0;
label(&mut c, LEFT, row2 + 24.0, "Date (AFDate_FormatEx yyyy-mm-dd)");
b.text_field("date", "Date", Rect::new(LEFT, row2, 296.0, row2 + 20.0), "2026-09-30", None, 0, |d| {
d.set("AA", js_actions("AFDate_FormatEx(\"yyyy-mm-dd\");", "AFDate_KeystrokeEx(\"yyyy-mm-dd\");"));
});
label(&mut c, COL2, row2 + 24.0, "Amount (AFNumber_Format, right-aligned)");
b.text_field("amount", "Amount in US dollars", Rect::new(COL2, row2, RIGHT, row2 + 20.0), "1234.5", Some("$1,234.50"), 2, |d| {
d.set(
"AA",
js_actions(
"AFNumber_Format(2, 0, 0, 0, \"$\", true);",
"AFNumber_Keystroke(2, 0, 0, 0, \"$\", true);",
),
);
});
label(&mut c, LEFT, 510.0, "Comments (multiline)");
let comments = "PrintCraft renders this field from its own appearance stream, so it looks \
the same in every viewer. Edit it to see your viewer regenerate the text.";
b.text_field("comments", "Comments", Rect::new(LEFT, 446.0, RIGHT, 506.0), comments, None, 0, |d| {
d.set("Ff", 4096);
});
// --- Preferences -----------------------------------------------------------
section(&mut c, 418.0, "Preferences");
let checks = [
("subscribe", "Subscribe to release notes", true),
("license", "I have read the licence", true),
("stickers", "Send me stickers", false),
];
for (i, (field, caption, on)) in checks.into_iter().enumerate() {
let y = 382.0 - i as f32 * 20.0;
b.checkbox(field, caption, Rect::xywh(LEFT, y, 12.0, 12.0), on);
c.fill_color(colors::INK).text(Font::Helv, 10.0, LEFT + 20.0, y + 2.5, caption);
}
label(&mut c, LEFT, 398.0, "Checkboxes");
label(&mut c, COL2, 398.0, "Preferred output (radio group)");
let radios = [("PDFA", "PDF/A - archival"), ("PDFUA", "PDF/UA - accessible"), ("PDFX", "PDF/X - print exchange")];
b.radio_group("output", "Preferred output profile", &radios, "PDFUA", &mut c);
label(&mut c, LEFT, 320.0, "Paper size (combo box)");
b.combo("paper", "Paper size", Rect::new(LEFT, 296.0, 296.0, 316.0), &["Letter", "Legal", "Tabloid", "A4", "A3"], "Letter");
label(&mut c, COL2, 320.0, "Features of interest (multi-select list)");
b.list_box(
"interests",
"Features of interest",
Rect::new(COL2, 258.0, RIGHT, 316.0),
&["Rendering", "Forms", "Annotations", "Signatures", "Accessibility"],
&[1, 2],
);
// --- Actions & signature ----------------------------------------------------
section(&mut c, 232.0, "Actions & signature");
label(&mut c, LEFT, 212.0, "Push button (JavaScript action)");
b.push_button(
"hello",
"Run JavaScript: app.alert",
Rect::new(LEFT, 184.0, 250.0, 206.0),
"app.alert(\"Hello from the PrintCraft showcase! This alert is a JavaScript action on a push button.\", 3);",
);
c.fill_color(colors::INK_3).text(
Font::Helv,
7.0,
LEFT,
172.0,
"JavaScript: shows app.alert(...) in viewers that run document scripts.",
);
label(&mut c, COL2, 212.0, "Signature (unsigned /Sig field)");
b.signature("signature", "Approver signature", Rect::new(COL2, 150.0, RIGHT, 206.0));
// --- Print-only note on its own optional-content layer ------------------------
c.raw("/OC /OCNotes BDC");
c.fill_color(Rgb::hex(0xfbeccc)).rounded_rect(LEFT, 98.0, 250.0 - LEFT, 56.0, 4.0).fill();
c.fill_color(Rgb::hex(0x8a5a00));
c.tracked_text(Font::HelvBold, 6.5, LEFT + 8.0, 142.0, 1.2, "PRINT-ONLY NOTES LAYER");
c.text(Font::Helv, 8.0, LEFT + 8.0, 129.0, "Hidden on screen by default. Turn on the");
c.text(Font::Helv, 8.0, LEFT + 8.0, 118.0, "\u{201c}Print-only notes\u{201d} layer, or print, to see it.");
c.text(Font::Helv, 8.0, LEFT + 8.0, 107.0, "Office use: batch 2026-A, reviewed.");
c.raw("EMC");
// --- Links -----------------------------------------------------------------
let link_y = 118.0;
c.fill_color(colors::INK_2).text(Font::Helv, 9.0, COL2, link_y + 12.0, "Links");
c.fill_color(colors::BLUE).text(Font::Helv, 10.0, COL2, link_y - 2.0, "Back to the contents");
let back_w = Font::Helv.width("Back to the contents", 10.0);
let visit = "https://example.com/";
c.fill_color(colors::BLUE).text(Font::Helv, 10.0, COL2, link_y - 18.0, visit);
let visit_w = Font::Helv.width(visit, 10.0);
// --- Page object -------------------------------------------------------------
let mut annots = b.widgets.clone();
let fields = b.fields.clone();
annots.push(annotator.link(b.doc, page, Rect::new(COL2 - 1.0, link_y - 5.0, COL2 + back_w + 1.0, link_y + 9.0), LinkTarget::Page(inputs.contents_page)));
annots.push(annotator.link(b.doc, page, Rect::new(COL2 - 1.0, link_y - 21.0, COL2 + visit_w + 1.0, link_y - 7.0), LinkTarget::Uri(visit)));
annots.push(annotator.simple_note(
b.doc,
page,
RIGHT + 14.0,
700.0,
"Every widget on this page has /AP normal appearances, so NeedAppearances is false.",
));
let content_id = b.doc.add_object(Stream::new(Dictionary::new(), c.into_bytes()));
let resources = dictionary! {
"Font" => inputs.fonts.dict(),
"Properties" => dictionary! { "OCNotes" => inputs.print_only_ocg },
};
let page_dict = dictionary! {
"Type" => "Page",
"Parent" => inputs.pages_root,
"MediaBox" => vec![0.into(), 0.into(), PAGE_W.into(), PAGE_H.into()],
"Contents" => content_id,
"Resources" => resources,
"Annots" => annots.into_iter().map(Object::Reference).collect::<Vec<_>>(),
"Tabs" => "R",
};
b.doc.objects.insert(page, Object::Dictionary(page_dict));
FormPage { page, fields }
}
/// Background, running head, title block and folio, matching the Chrome-printed pages.
fn draw_page_chrome(c: &mut Content, folio: &str) {
c.fill_color(colors::PAPER).rect(0.0, 0.0, PAGE_W, PAGE_H).fill();
c.fill_color(colors::INK_3);
c.tracked_text(Font::Helv, 6.5, LEFT, 757.0, 1.2, "PRINTCRAFT SHOWCASE");
let right = "10 \u{00b7} INTERACTIVE FORM";
let w = Font::Helv.width(right, 6.5) + 1.2 * (right.chars().count() as f32 - 1.0);
c.tracked_text(Font::Helv, 6.5, RIGHT - w, 757.0, 1.2, right);
c.stroke_color(colors::RULE).line_width(0.5).move_to(LEFT, 751.0).line_to(RIGHT, 751.0).stroke();
c.fill_color(colors::ACCENT).tracked_text(Font::HelvBold, 7.5, LEFT, 722.0, 1.8, "CHAPTER TEN");
c.fill_color(colors::INK).text(Font::Times, 34.0, LEFT, 686.0, "Interactive");
let w = Font::Times.width("Interactive ", 34.0);
c.fill_color(colors::ACCENT).text(Font::TimesItalic, 34.0, LEFT + w, 686.0, "Form");
c.fill_color(colors::INK_2);
c.text(Font::TimesItalic, 13.0, LEFT, 660.0, "This page was not printed by Chrome: the xtask writes its content stream and");
c.text(Font::TimesItalic, 13.0, LEFT, 644.0, "an AcroForm directly. Every widget ships its own appearance streams.");
c.fill_color(colors::INK_3).text(Font::Helv, 8.0, LEFT, 32.0, "Interactive Form");
let w = Font::HelvBold.width(folio, 9.0);
c.fill_color(colors::INK).text(Font::HelvBold, 9.0, RIGHT - w, 32.0, folio);
}
fn section(c: &mut Content, y: f32, title: &str) {
c.fill_color(colors::INK).tracked_text(Font::HelvBold, 8.0, LEFT, y, 1.3, &title.to_uppercase());
c.stroke_color(colors::RULE).line_width(0.5).move_to(LEFT, y - 6.0).line_to(RIGHT, y - 6.0).stroke();
}
fn label(c: &mut Content, x: f32, y: f32, s: &str) {
c.fill_color(colors::INK_3).tracked_text(Font::Helv, 6.5, x, y, 0.6, &s.to_uppercase());
}
fn js(code: &str) -> Dictionary {
dictionary! { "S" => "JavaScript", "JS" => Object::string_literal(code) }
}
/// Format (/F) and keystroke (/K) additional actions.
fn js_actions(format: &str, keystroke: &str) -> Dictionary {
dictionary! { "F" => js(format), "K" => js(keystroke) }
}
/// Field background and border shared by every widget.
fn field_box(c: &mut Content, w: f32, h: f32) {
c.fill_color(colors::FIELD).stroke_color(colors::INK_3).line_width(0.75).rect(0.375, 0.375, w - 0.75, h - 0.75).fill_stroke();
}
fn mk(extra: Dictionary) -> Dictionary {
let mut d = dictionary! { "BC" => colors::INK_3.array(), "BG" => colors::FIELD.array() };
d.extend(&extra);
d
}
impl Builder<'_> {
fn widget_base(&self, r: Rect) -> Dictionary {
dictionary! {
"Type" => "Annot",
"Subtype" => "Widget",
"Rect" => r.obj(),
"P" => self.page,
"F" => 4,
"BS" => dictionary! { "W" => 1, "S" => "S" },
}
}
fn xobject(&mut self, w: f32, h: f32, c: Content) -> ObjectId {
form_xobject(self.doc, w, h, c, self.fonts.resources())
}
fn add_field(&mut self, dict: Dictionary) -> ObjectId {
let id = self.doc.add_object(dict);
self.widgets.push(id);
self.fields.push(id);
id
}
/// A text field; `display` overrides the appearance text (for formatted values),
/// `quadding` is 0 left, 1 centre, 2 right.
#[allow(clippy::too_many_arguments)]
fn text_field(
&mut self,
field: &str,
tooltip: &str,
r: Rect,
value: &str,
display: Option<&str>,
quadding: i64,
customise: impl FnOnce(&mut Dictionary),
) {
let (w, h) = (r.w(), r.h());
let mut d = self.widget_base(r);
d.set("FT", "Tx");
d.set("T", text(field));
d.set("TU", text(tooltip));
d.set("V", text(value));
d.set("DA", Object::string_literal(DA_TEXT));
d.set("Q", quadding);
d.set("MK", mk(Dictionary::new()));
customise(&mut d);
let multiline = d.get(b"Ff").and_then(Object::as_i64).is_ok_and(|ff| ff & 4096 != 0);
let shown = display.unwrap_or(value);
let mut c = Content::new();
field_box(&mut c, w, h);
c.raw("/Tx BMC").save().rect(1.0, 1.0, w - 2.0, h - 2.0).clip();
c.fill_color(colors::INK);
if multiline {
for (i, line) in wrap(shown, Font::Helv, 10.0, w - 8.0).iter().enumerate() {
c.text(Font::Helv, 10.0, 4.0, h - 13.0 - 12.0 * i as f32, line);
}
} else {
let tw = Font::Helv.width(shown, 10.0);
let x = match quadding {
1 => (w - tw) / 2.0,
2 => w - 4.0 - tw,
_ => 4.0,
};
c.text(Font::Helv, 10.0, x, (h - 7.0) / 2.0, shown);
}
c.restore().raw("EMC");
let ap = self.xobject(w, h, c);
d.set("AP", dictionary! { "N" => ap });
self.add_field(d);
}
fn check_appearances(&mut self, w: f32, h: f32, on_state: &str, round: bool) -> Dictionary {
let mut on = Content::new();
let mut off = Content::new();
for c in [&mut on, &mut off] {
c.fill_color(colors::FIELD).stroke_color(colors::INK_3).line_width(0.75);
if round {
c.ellipse(w / 2.0, h / 2.0, w / 2.0 - 0.5, h / 2.0 - 0.5).fill_stroke();
} else {
c.rect(0.375, 0.375, w - 0.75, h - 0.75).fill_stroke();
}
}
if round {
on.fill_color(colors::ACCENT).ellipse(w / 2.0, h / 2.0, w * 0.25, h * 0.25).fill();
} else {
// ZapfDingbats "4" is a check mark (glyph a20, width 760).
let size = h * 0.8;
on.fill_color(colors::ACCENT).text(Font::ZapfDingbats, size, (w - 0.76 * size) / 2.0, h * 0.2, "4");
}
let on_id = self.xobject(w, h, on);
let off_id = self.xobject(w, h, off);
let mut states = Dictionary::new();
states.set(on_state, on_id);
states.set("Off", off_id);
states
}
fn checkbox(&mut self, field: &str, tooltip: &str, r: Rect, checked: bool) {
let state = if checked { "Yes" } else { "Off" };
let mut d = self.widget_base(r);
d.set("FT", "Btn");
d.set("T", text(field));
d.set("TU", text(tooltip));
d.set("V", name(state));
d.set("AS", name(state));
d.set("DA", Object::string_literal(DA_ZAPF));
d.set("MK", mk(dictionary! { "CA" => Object::string_literal("4") }));
let n = self.check_appearances(r.w(), r.h(), "Yes", false);
d.set("AP", dictionary! { "N" => n });
self.add_field(d);
}
fn radio_group(&mut self, field: &str, tooltip: &str, options: &[(&str, &str)], selected: &str, c: &mut Content) {
let parent = self.doc.new_object_id();
let mut kids = Vec::new();
for (i, (export, caption)) in options.iter().enumerate() {
let y = 382.0 - i as f32 * 20.0;
let r = Rect::xywh(COL2, y, 12.0, 12.0);
let state = if *export == selected { *export } else { "Off" };
let mut d = self.widget_base(r);
d.set("Parent", parent);
d.set("AS", name(state));
d.set("MK", mk(dictionary! { "CA" => Object::string_literal("l") }));
d.set("DA", Object::string_literal(DA_ZAPF));
let n = self.check_appearances(12.0, 12.0, export, true);
d.set("AP", dictionary! { "N" => n });
let id = self.doc.add_object(d);
self.widgets.push(id);
kids.push(Object::Reference(id));
c.fill_color(colors::INK).text(Font::Helv, 10.0, COL2 + 20.0, y + 2.5, caption);
}
self.doc.objects.insert(
parent,
Object::Dictionary(dictionary! {
"FT" => "Btn",
"Ff" => 49152, // Radio | NoToggleToOff
"T" => text(field),
"TU" => text(tooltip),
"V" => name(selected),
"Kids" => kids,
}),
);
self.fields.push(parent);
}
fn combo(&mut self, field: &str, tooltip: &str, r: Rect, options: &[&str], value: &str) {
let (w, h) = (r.w(), r.h());
let mut d = self.widget_base(r);
d.set("FT", "Ch");
d.set("Ff", 131_072); // Combo
d.set("T", text(field));
d.set("TU", text(tooltip));
d.set("Opt", options.iter().map(|o| text(o)).collect::<Vec<_>>());
d.set("V", text(value));
d.set("DA", Object::string_literal(DA_TEXT));
d.set("MK", mk(Dictionary::new()));
let mut c = Content::new();
field_box(&mut c, w, h);
let bw = h;
c.fill_color(colors::RULE).rect(w - bw, 0.75, bw - 0.75, h - 1.5).fill();
c.fill_color(colors::INK)
.move_to(w - bw / 2.0 - 4.0, h / 2.0 + 2.0)
.line_to(w - bw / 2.0 + 4.0, h / 2.0 + 2.0)
.line_to(w - bw / 2.0, h / 2.0 - 3.0)
.close()
.fill();
c.raw("/Tx BMC").save().rect(1.0, 1.0, w - bw - 2.0, h - 2.0).clip();
c.fill_color(colors::INK).text(Font::Helv, 10.0, 4.0, (h - 7.0) / 2.0, value);
c.restore().raw("EMC");
let ap = self.xobject(w, h, c);
d.set("AP", dictionary! { "N" => ap });
self.add_field(d);
}
fn list_box(&mut self, field: &str, tooltip: &str, r: Rect, options: &[&str], selected: &[usize]) {
let (w, h) = (r.w(), r.h());
let mut d = self.widget_base(r);
d.set("FT", "Ch");
d.set("Ff", 2_097_152); // MultiSelect
d.set("T", text(field));
d.set("TU", text(tooltip));
d.set("Opt", options.iter().map(|o| text(o)).collect::<Vec<_>>());
d.set("V", selected.iter().map(|&i| text(options[i])).collect::<Vec<_>>());
d.set("I", selected.iter().map(|&i| Object::from(i as i64)).collect::<Vec<_>>());
d.set("TI", 0);
d.set("DA", Object::string_literal(DA_TEXT));
d.set("MK", mk(Dictionary::new()));
let row = 13.5;
let mut c = Content::new();
field_box(&mut c, w, h);
c.raw("/Tx BMC").save().rect(1.0, 1.0, w - 2.0, h - 2.0).clip();
for (i, option) in options.iter().enumerate() {
let top = h - 1.5 - row * i as f32;
if selected.contains(&i) {
c.fill_color(Rgb::hex(0xd6e2f8)).rect(1.0, top - row, w - 2.0, row).fill();
}
c.fill_color(colors::INK).text(Font::Helv, 10.0, 5.0, top - row + 3.8, option);
}
c.restore().raw("EMC");
let ap = self.xobject(w, h, c);
d.set("AP", dictionary! { "N" => ap });
self.add_field(d);
}
fn push_button(&mut self, field: &str, caption: &str, r: Rect, script: &str) {
let (w, h) = (r.w(), r.h());
let mut d = self.widget_base(r);
d.set("FT", "Btn");
d.set("Ff", 65_536); // Pushbutton
d.set("T", text(field));
d.set("TU", text("Runs a JavaScript app.alert"));
d.set("DA", Object::string_literal("/HeBo 9 Tf 1 g"));
d.set("H", "P");
d.set("MK", dictionary! { "BG" => colors::INK.array(), "CA" => text(caption) });
d.set("A", js(script));
let mut appearances = Dictionary::new();
for (key, fill) in [("N", colors::INK), ("D", colors::ACCENT)] {
let mut c = Content::new();
c.fill_color(fill).rounded_rect(0.0, 0.0, w, h, 4.0).fill();
let tw = Font::HelvBold.width(caption, 9.0);
c.fill_color(colors::WHITE).text(Font::HelvBold, 9.0, (w - tw) / 2.0, (h - 6.5) / 2.0, caption);
let id = self.xobject(w, h, c);
appearances.set(key, id);
}
d.set("AP", appearances);
self.add_field(d);
}
fn signature(&mut self, field: &str, tooltip: &str, r: Rect) {
let (w, h) = (r.w(), r.h());
let mut d = self.widget_base(r);
d.set("FT", "Sig");
d.set("T", text(field));
d.set("TU", text(tooltip));
d.set("DA", Object::string_literal(DA_TEXT));
d.set("MK", mk(Dictionary::new()));
let mut c = Content::new();
c.fill_color(colors::FIELD).stroke_color(colors::BLUE).line_width(1.0).dash(3.0, 2.0).rect(0.5, 0.5, w - 1.0, h - 1.0).fill_stroke();
c.raw("[] 0 d").stroke_color(colors::INK_3).line_width(0.5).move_to(24.0, 16.0).line_to(w - 12.0, 16.0).stroke();
c.fill_color(colors::BLUE).text(Font::HelvBold, 12.0, 10.0, 19.0, "\u{00d7}");
c.fill_color(colors::INK_3).text(Font::Helv, 7.0, 24.0, 6.0, "Sign here - unsigned signature field");
let ap = self.xobject(w, h, c);
d.set("AP", dictionary! { "N" => ap });
self.add_field(d);
}
}
/// Greedy word wrap using the standard-font metrics.
fn wrap(s: &str, font: Font, size: f32, width: f32) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in s.split_whitespace() {
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if font.width(&candidate, size) > width && !line.is_empty() {
lines.push(std::mem::replace(&mut line, word.to_string()));
} else {
line = candidate;
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
/// The `/AcroForm` dictionary for `fields`.
pub fn acroform(fonts: &Fonts, fields: &[ObjectId]) -> Dictionary {
dictionary! {
"Fields" => fields.iter().map(|&id| Object::Reference(id)).collect::<Vec<_>>(),
"DR" => dictionary! { "Font" => fonts.dict() },
"DA" => Object::string_literal("/Helv 0 Tf 0 g"),
"NeedAppearances" => false,
}
}
+99
View File
@@ -0,0 +1,99 @@
//! `cargo xtask demo-pdf`: build the PrintCraft showcase PDF.
//!
//! 1. Render a procedural PNG and fill the placeholders in `assets/demo/showcase.html`.
//! 2. Print the HTML with headless Chrome (tagged PDF, outline from headings).
//! 3. Post-process with `lopdf`: turn marker links into annotations, append an
//! AcroForm page, add layers, page labels, attachments, metadata and outline.
mod annots;
mod chrome;
mod form;
mod pdf;
mod postprocess;
mod raster;
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
const USAGE: &str = "Usage: cargo xtask demo-pdf [--chrome <path>] [--out <file.pdf>]";
pub fn run(args: &[String]) -> Result<()> {
let mut chrome_override = None;
let mut out_override = None;
let mut it = args.iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--chrome" => chrome_override = Some(PathBuf::from(it.next().context(USAGE)?)),
"--out" => out_override = Some(PathBuf::from(it.next().context(USAGE)?)),
"-h" | "--help" => {
println!("{USAGE}");
return Ok(());
}
other => bail!("unexpected argument `{other}`\n{USAGE}"),
}
}
let root = repo_root();
let out = out_override.unwrap_or_else(|| root.join("dist/demo/printcraft-showcase.pdf"));
let build = root.join("dist/demo/build");
fs::create_dir_all(&build).with_context(|| format!("creating {}", build.display()))?;
if let Some(parent) = out.parent() {
fs::create_dir_all(parent)?;
}
println!("demo-pdf: rendering raster");
let png = raster::mandelbrot_png(640)?;
fs::write(build.join("mandelbrot.png"), &png)?;
let source = root.join("assets/demo/showcase.html");
let html = fs::read_to_string(&source).with_context(|| format!("reading {}", source.display()))?;
let html = html
.replace("{{ASSETS}}", &file_url(&root.join("assets")))
.replace("{{MANDELBROT_PNG}}", &format!("data:image/png;base64,{}", raster::base64(&png)));
let html_path = build.join("showcase.html");
fs::write(&html_path, html)?;
let chrome = chrome::find(chrome_override.as_deref())?;
println!("demo-pdf: printing with {}", chrome.display());
let raw = build.join("chrome.pdf");
chrome::print_to_pdf(&chrome, &html_path, &raw, &build.join("chrome-profile"))?;
println!("demo-pdf: post-processing");
let mut doc = lopdf::Document::load(&raw).with_context(|| format!("loading {}", raw.display()))?;
let summary = postprocess::enhance(&mut doc, pdf::Stamp::now())?;
doc.compress();
doc.save(&out).with_context(|| format!("writing {}", out.display()))?;
println!(
"demo-pdf: wrote {} ({} pages, {} annotations, {} form fields, outline: {})",
out.display(),
summary.pages,
summary.annotations,
summary.fields,
summary.outline
);
Ok(())
}
/// The workspace root (the parent of this crate's manifest directory).
fn repo_root() -> PathBuf {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR"));
let manifest = manifest.canonicalize().unwrap_or_else(|_| manifest.to_path_buf());
manifest.parent().map(Path::to_path_buf).unwrap_or(manifest)
}
/// A `file://` URL for an absolute path, percent-encoding unsafe bytes.
pub(crate) fn file_url(path: &Path) -> String {
let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let s = abs.to_string_lossy().replace('\\', "/");
let mut url = String::from(if s.starts_with('/') { "file://" } else { "file:///" });
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' | b':' => url.push(byte as char),
_ => url.push_str(&format!("%{byte:02X}")),
}
}
url
}
+453
View File
@@ -0,0 +1,453 @@
//! Small helpers for writing PDF objects and content streams with `lopdf`.
use std::fmt::Write as _;
use lopdf::{Dictionary, Document, Object, ObjectId, Stream, StringFormat, dictionary};
/// An sRGB colour with components in `0.0..=1.0`.
#[derive(Clone, Copy, Debug)]
pub struct Rgb(pub f32, pub f32, pub f32);
impl Rgb {
pub const fn hex(v: u32) -> Self {
Rgb(
((v >> 16) & 0xff) as f32 / 255.0,
((v >> 8) & 0xff) as f32 / 255.0,
(v & 0xff) as f32 / 255.0,
)
}
pub fn array(self) -> Object {
Object::Array(vec![self.0.into(), self.1.into(), self.2.into()])
}
}
/// The showcase palette (mirrors the CSS custom properties in showcase.html).
pub mod colors {
use super::Rgb;
pub const INK: Rgb = Rgb::hex(0x16213b);
pub const INK_2: Rgb = Rgb::hex(0x4a5268);
pub const INK_3: Rgb = Rgb::hex(0x7b8196);
pub const PAPER: Rgb = Rgb::hex(0xfbf8f2);
pub const RULE: Rgb = Rgb::hex(0xe2dccf);
pub const FIELD: Rgb = Rgb::hex(0xfffdf8);
pub const ACCENT: Rgb = Rgb::hex(0xe4572e);
pub const BLUE: Rgb = Rgb::hex(0x2a6fdb);
pub const TEAL: Rgb = Rgb::hex(0x1a9e8f);
pub const GOLD: Rgb = Rgb::hex(0xe9a100);
pub const HIGHLIGHT: Rgb = Rgb::hex(0xffe066);
pub const WHITE: Rgb = Rgb(1.0, 1.0, 1.0);
}
/// A PDF text string: plain literal for ASCII, UTF-16BE with BOM otherwise.
pub fn text(s: &str) -> Object {
if s.is_ascii() {
Object::string_literal(s)
} else {
let mut bytes = vec![0xfe, 0xff];
for unit in s.encode_utf16() {
bytes.extend(unit.to_be_bytes());
}
Object::String(bytes, StringFormat::Hexadecimal)
}
}
pub fn name(s: &str) -> Object {
Object::Name(s.as_bytes().to_vec())
}
pub fn rect(x0: f32, y0: f32, x1: f32, y1: f32) -> Object {
Object::Array(vec![x0.into(), y0.into(), x1.into(), y1.into()])
}
pub fn floats(values: &[f32]) -> Object {
Object::Array(values.iter().map(|&v| v.into()).collect())
}
/// A current time stamp in both PDF (`D:...Z`) and ISO 8601 forms.
#[derive(Clone, Debug)]
pub struct Stamp {
pub unix: i64,
pub pdf: String,
pub iso: String,
}
impl Stamp {
pub fn now() -> Self {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Self::from_unix(secs)
}
pub fn from_unix(secs: i64) -> Self {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (h, mi, s) = (rem / 3600, rem % 3600 / 60, rem % 60);
// Civil-from-days (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
Stamp {
unix: secs,
pdf: format!("D:{y:04}{m:02}{d:02}{h:02}{mi:02}{s:02}Z"),
iso: format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z"),
}
}
/// The same moment shifted by `minutes` (used to order threaded replies).
pub fn plus_minutes(&self, minutes: i64) -> Self {
Self::from_unix(self.unix + minutes * 60)
}
}
/// The standard 14 fonts used by the xtask-written parts of the document.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Font {
Helv,
HelvBold,
Times,
TimesItalic,
Courier,
ZapfDingbats,
}
impl Font {
pub const ALL: [Font; 6] = [
Font::Helv,
Font::HelvBold,
Font::Times,
Font::TimesItalic,
Font::Courier,
Font::ZapfDingbats,
];
/// Resource name, following the conventional AcroForm names.
pub fn res(self) -> &'static str {
match self {
Font::Helv => "Helv",
Font::HelvBold => "HeBo",
Font::Times => "TiRo",
Font::TimesItalic => "TiIt",
Font::Courier => "Cour",
Font::ZapfDingbats => "ZaDb",
}
}
fn base_font(self) -> &'static str {
match self {
Font::Helv => "Helvetica",
Font::HelvBold => "Helvetica-Bold",
Font::Times => "Times-Roman",
Font::TimesItalic => "Times-Italic",
Font::Courier => "Courier",
Font::ZapfDingbats => "ZapfDingbats",
}
}
/// Advance width of `s` at `size` points (WinAnsi text; Helvetica metrics are
/// exact, the others are close enough for layout of short labels).
pub fn width(self, s: &str, size: f32) -> f32 {
let units: u32 = s
.chars()
.map(|c| match self {
Font::Helv => helvetica_width(c, false),
Font::HelvBold => helvetica_width(c, true),
Font::Courier => 600,
Font::Times | Font::TimesItalic => helvetica_width(c, false) * 9 / 10,
Font::ZapfDingbats => 800,
})
.sum();
units as f32 * size / 1000.0
}
}
/// Indirect font objects shared by every page, appearance stream and the AcroForm /DR.
#[derive(Clone, Debug)]
pub struct Fonts {
ids: Vec<(Font, ObjectId)>,
}
impl Fonts {
pub fn add_to(doc: &mut Document) -> Self {
let ids = Font::ALL
.iter()
.map(|&font| {
let mut dict = dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => font.base_font(),
};
if font != Font::ZapfDingbats {
dict.set("Encoding", "WinAnsiEncoding");
}
(font, doc.add_object(dict))
})
.collect();
Fonts { ids }
}
pub fn id(&self, font: Font) -> ObjectId {
self.ids.iter().find(|(f, _)| *f == font).map(|(_, id)| *id).expect("font registered")
}
/// A `/Font` resource dictionary naming every standard font.
pub fn dict(&self) -> Dictionary {
self.ids.iter().map(|(f, id)| (f.res(), Object::Reference(*id))).collect()
}
/// Full `/Resources` dictionary with these fonts (plus optional ExtGStates).
pub fn resources(&self) -> Dictionary {
dictionary! { "Font" => self.dict() }
}
}
/// Add a Form XObject with bounding box `[0 0 w h]`.
pub fn form_xobject(doc: &mut Document, w: f32, h: f32, content: Content, resources: Dictionary) -> ObjectId {
let dict = dictionary! {
"Type" => "XObject",
"Subtype" => "Form",
"FormType" => 1,
"BBox" => rect(0.0, 0.0, w, h),
"Resources" => resources,
};
doc.add_object(Stream::new(dict, content.into_bytes()))
}
/// A tiny content-stream builder. Coordinates are in PDF user space.
#[derive(Default, Debug)]
pub struct Content(String);
fn num(v: f32) -> String {
let s = format!("{:.3}", v);
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
impl Content {
pub fn new() -> Self {
Self::default()
}
pub fn into_bytes(self) -> Vec<u8> {
self.0.into_bytes()
}
/// Append raw operators.
pub fn raw(&mut self, ops: &str) -> &mut Self {
self.0.push_str(ops);
self.0.push('\n');
self
}
fn ops(&mut self, operands: &[f32], op: &str) -> &mut Self {
for v in operands {
self.0.push_str(&num(*v));
self.0.push(' ');
}
self.raw(op)
}
pub fn save(&mut self) -> &mut Self {
self.raw("q")
}
pub fn restore(&mut self) -> &mut Self {
self.raw("Q")
}
pub fn fill_color(&mut self, c: Rgb) -> &mut Self {
self.ops(&[c.0, c.1, c.2], "rg")
}
pub fn stroke_color(&mut self, c: Rgb) -> &mut Self {
self.ops(&[c.0, c.1, c.2], "RG")
}
pub fn line_width(&mut self, w: f32) -> &mut Self {
self.ops(&[w], "w")
}
pub fn dash(&mut self, on: f32, off: f32) -> &mut Self {
let _ = writeln!(self.0, "[{} {}] 0 d", num(on), num(off));
self
}
pub fn round_caps(&mut self) -> &mut Self {
self.raw("1 J 1 j")
}
pub fn cm(&mut self, a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) -> &mut Self {
self.ops(&[a, b, c, d, e, f], "cm")
}
pub fn rect(&mut self, x: f32, y: f32, w: f32, h: f32) -> &mut Self {
self.ops(&[x, y, w, h], "re")
}
pub fn move_to(&mut self, x: f32, y: f32) -> &mut Self {
self.ops(&[x, y], "m")
}
pub fn line_to(&mut self, x: f32, y: f32) -> &mut Self {
self.ops(&[x, y], "l")
}
pub fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) -> &mut Self {
self.ops(&[x1, y1, x2, y2, x3, y3], "c")
}
pub fn close(&mut self) -> &mut Self {
self.raw("h")
}
pub fn fill(&mut self) -> &mut Self {
self.raw("f")
}
pub fn stroke(&mut self) -> &mut Self {
self.raw("S")
}
pub fn fill_stroke(&mut self) -> &mut Self {
self.raw("B")
}
pub fn clip(&mut self) -> &mut Self {
self.raw("W n")
}
pub fn gs(&mut self, name: &str) -> &mut Self {
self.raw(&format!("/{name} gs"))
}
pub fn rounded_rect(&mut self, x: f32, y: f32, w: f32, h: f32, r: f32) -> &mut Self {
let r = r.min(w / 2.0).min(h / 2.0);
let k = 0.552_284_8 * r;
self.move_to(x + r, y)
.line_to(x + w - r, y)
.curve_to(x + w - r + k, y, x + w, y + r - k, x + w, y + r)
.line_to(x + w, y + h - r)
.curve_to(x + w, y + h - r + k, x + w - r + k, y + h, x + w - r, y + h)
.line_to(x + r, y + h)
.curve_to(x + r - k, y + h, x, y + h - r + k, x, y + h - r)
.line_to(x, y + r)
.curve_to(x, y + r - k, x + r - k, y, x + r, y)
.close()
}
pub fn ellipse(&mut self, cx: f32, cy: f32, rx: f32, ry: f32) -> &mut Self {
let (kx, ky) = (0.552_284_8 * rx, 0.552_284_8 * ry);
self.move_to(cx + rx, cy)
.curve_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry)
.curve_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy)
.curve_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry)
.curve_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy)
.close()
}
/// One line of text with its baseline starting at (x, y).
pub fn text(&mut self, font: Font, size: f32, x: f32, y: f32, s: &str) -> &mut Self {
let _ = writeln!(
self.0,
"BT /{} {} Tf {} {} Td {} Tj ET",
font.res(),
num(size),
num(x),
num(y),
pdf_string(s)
);
self
}
/// Text with letter-spacing (`Tc`), for tracked capitals.
pub fn tracked_text(&mut self, font: Font, size: f32, x: f32, y: f32, tracking: f32, s: &str) -> &mut Self {
let _ = writeln!(
self.0,
"BT /{} {} Tf {} Tc {} {} Td {} Tj ET",
font.res(),
num(size),
num(tracking),
num(x),
num(y),
pdf_string(s)
);
self
}
}
/// Encode `s` as a WinAnsi literal string `( ... )` for a content stream.
pub fn pdf_string(s: &str) -> String {
let mut out = String::from("(");
for byte in winansi(s) {
match byte {
b'(' | b')' | b'\\' => {
out.push('\\');
out.push(byte as char);
}
0x20..=0x7e => out.push(byte as char),
_ => {
let _ = write!(out, "\\{byte:03o}");
}
}
}
out.push(')');
out
}
/// Map text to WinAnsiEncoding bytes (unmappable characters become `?`).
pub fn winansi(s: &str) -> Vec<u8> {
s.chars()
.map(|c| match c {
'\u{20}'..='\u{7e}' => c as u8,
'\u{a0}'..='\u{ff}' => c as u32 as u8,
'\u{2022}' => 0x95,
'\u{2013}' => 0x96,
'\u{2014}' => 0x97,
'\u{2018}' => 0x91,
'\u{2019}' => 0x92,
'\u{201c}' => 0x93,
'\u{201d}' => 0x94,
'\u{2026}' => 0x85,
'\u{20ac}' => 0x80,
_ => b'?',
})
.collect()
}
/// Helvetica / Helvetica-Bold advance widths (1/1000 em) from the public AFM metrics.
fn helvetica_width(c: char, bold: bool) -> u32 {
const REGULAR: [u16; 95] = [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, // ' '..'/'
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, // '0'..'?'
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, // '@'..'O'
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, // 'P'..'_'
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, // '`'..'o'
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, // 'p'..'~'
];
const BOLD: [u16; 95] = [
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, //
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, //
975, 722, 722, 722, 722, 667, 611, 778, 722, 278, 556, 722, 611, 833, 722, 778, //
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 333, 278, 333, 584, 556, //
333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556, 278, 889, 611, 611, //
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584, //
];
let table = if bold { &BOLD } else { &REGULAR };
match c {
' '..='~' => u32::from(table[c as usize - 0x20]),
'\u{2014}' => 1000,
'\u{2013}' => 556,
'\u{2022}' => 350,
_ => 556,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stamps_round_trip() {
let s = Stamp::from_unix(1_790_000_000);
assert_eq!(s.iso, "2026-09-21T14:13:20Z");
assert_eq!(s.plus_minutes(0).pdf, s.pdf);
assert_eq!(s.plus_minutes(60 * 24).iso, "2026-09-22T14:13:20Z");
}
#[test]
fn strings_are_escaped() {
assert_eq!(pdf_string("a(b)\\"), "(a\\(b\\)\\\\)");
assert_eq!(pdf_string("—"), "(\\227)");
}
}
+494
View File
@@ -0,0 +1,494 @@
//! Post-processing of Chrome's PDF: everything a browser cannot emit.
use anyhow::{Context, Result, bail};
use lopdf::{Dictionary, Document, Object, ObjectId, Stream, dictionary};
use super::annots::{Annotator, Kind, Rect, embedded_file};
use super::form::{self, FormInputs};
use super::pdf::{Content, Font, Fonts, Stamp, colors, name, text};
/// Links to this prefix in showcase.html mark where annotations go.
const MARKER_PREFIX: &str = "https://mark.printcraft.invalid/";
const TITLE: &str = "PrintCraft Showcase";
const AUTHOR: &str = "PrintCraft";
const SUBJECT: &str = "A specimen document exercising typography, world scripts, vector graphics, \
MathML, forms, annotations, layers and attachments.";
const KEYWORDS: &[&str] = &["PDF", "typography", "OpenType", "AcroForm", "annotations", "optional content", "PrintCraft"];
const CREATOR: &str = "printcraft xtask demo-pdf";
/// Chrome-printed page indices (0-based) that the post-processor refers to.
const PAGE_CONTENTS: usize = 1;
const PAGE_SETTING_TEXT: usize = 3;
const PAGE_REVIEW: usize = 11;
const CHROME_PAGES: usize = 12;
pub struct Summary {
pub pages: usize,
pub annotations: usize,
pub fields: usize,
pub outline: String,
}
pub fn enhance(doc: &mut Document, now: Stamp) -> Result<Summary> {
doc.version = "1.7".into();
let pages: Vec<ObjectId> = doc.get_pages().into_values().collect();
if pages.len() != CHROME_PAGES {
bail!(
"Chrome produced {} pages but the showcase layout expects {CHROME_PAGES}; \
a page probably overflowed - check dist/demo/build/chrome.pdf",
pages.len()
);
}
let fonts = Fonts::add_to(doc);
let mut annotator = Annotator::new(fonts.clone(), now.clone());
let mut annotation_count = 0;
// 1. Marker links -> real annotations.
for (index, &page) in pages.iter().enumerate() {
let mut annots = page_annots(doc, page)?;
let mut seen: Vec<String> = Vec::new();
let mut keep = Vec::with_capacity(annots.len());
let mut extra = Vec::new();
for id in annots.drain(..) {
let Some((uri, r)) = marker_link(doc, id) else {
keep.push(id);
continue;
};
if seen.contains(&uri) {
continue; // a marker that wrapped onto two lines: keep only the first box
}
seen.push(uri.clone());
let kind_str = uri[MARKER_PREFIX.len()..].split('/').next().unwrap_or_default();
let kind = Kind::from_marker(kind_str)
.with_context(|| format!("unknown annotation marker {uri} on page {}", index + 1))?;
let struct_parent = doc.get_dictionary(id)?.get(b"StructParent").ok().cloned();
let built = annotator.build(doc, page, id, kind, r);
let mut dict = built.dict;
if let Some(sp) = struct_parent {
dict.set("StructParent", sp);
}
doc.objects.insert(id, Object::Dictionary(dict));
keep.push(id);
annotation_count += 1 + built.extra.len();
extra.extend(built.extra);
}
keep.extend(extra);
if index == PAGE_SETTING_TEXT {
keep.push(annotator.simple_note(
doc,
page,
572.0,
704.0,
"Drop cap, small-caps first line and old-style figures are all plain glyph placement.",
));
annotation_count += 2;
}
set_page_annots(doc, page, keep)?;
}
// 2. Optional content: a visible draft watermark and hidden print-only notes.
let ocg_draft = doc.add_object(dictionary! {
"Type" => "OCG",
"Name" => text("Draft watermark"),
"Intent" => "View",
"Usage" => dictionary! {
"CreatorInfo" => dictionary! { "Creator" => text(CREATOR), "Subtype" => "Artwork" },
"Watermark" => dictionary! { "Subtype" => "Watermark" },
},
});
let ocg_notes = doc.add_object(dictionary! {
"Type" => "OCG",
"Name" => text("Print-only notes"),
"Usage" => dictionary! {
"Print" => dictionary! { "Subtype" => "Watermark", "PrintState" => "ON" },
"View" => dictionary! { "ViewState" => "OFF" },
},
});
add_watermark(doc, pages[PAGE_REVIEW], ocg_draft, &fonts)?;
// 3. Appended AcroForm page.
let pages_root = doc.catalog()?.get(b"Pages")?.as_reference()?;
let form_page = form::build(
doc,
&mut annotator,
FormInputs {
fonts: &fonts,
pages_root,
contents_page: pages[PAGE_CONTENTS],
print_only_ocg: ocg_notes,
folio: "10",
},
);
{
let root = doc.get_dictionary_mut(pages_root)?;
let count = root.get(b"Count")?.as_i64()?;
root.get_mut(b"Kids")?.as_array_mut()?.push(Object::Reference(form_page.page));
root.set("Count", count + 1);
}
annotation_count += 3; // two links and a note (the rest are widgets)
let field_count = form_page.fields.len();
let acroform = doc.add_object(form::acroform(&fonts, &form_page.fields));
let mut all_pages = pages.clone();
all_pages.push(form_page.page);
// 4. Outline.
let outline = extend_outline(doc, &all_pages, form_page.page)?;
// 5. Embedded file, metadata and catalog entries.
let csv = include_str!("../../../assets/demo/showcase-data.csv");
let csv_spec = embedded_file(doc, "showcase-data.csv", "text/csv", csv.as_bytes(), "Sample data behind the charts on page 7", &now);
doc.get_dictionary_mut(csv_spec)?.set("AFRelationship", "Data");
add_embedded_file_name(doc, "showcase-data.csv", csv_spec)?;
let producer = producer(doc);
let info = doc.add_object(info_dict(&producer, &now));
doc.trailer.set("Info", info);
let xmp_stream = Stream::new(dictionary! { "Type" => "Metadata", "Subtype" => "XML" }, xmp(&producer, &now).into_bytes());
let xmp = doc.add_object(xmp_stream.with_compression(false));
let catalog = doc.catalog_mut()?;
catalog.set("Metadata", xmp);
catalog.set("AcroForm", acroform);
catalog.set("PageMode", "UseOutlines");
catalog.set("ViewerPreferences", dictionary! { "DisplayDocTitle" => true });
catalog.set("AF", vec![Object::Reference(csv_spec)]);
if !catalog.has(b"Lang") {
catalog.set("Lang", Object::string_literal("en-US"));
}
catalog.set(
"PageLabels",
dictionary! {
"Nums" => vec![
0.into(), dictionary! { "P" => text("Cover") }.into(),
1.into(), dictionary! { "S" => "r" }.into(),
3.into(), dictionary! { "S" => "D" }.into(),
],
},
);
catalog.set(
"OCProperties",
dictionary! {
"OCGs" => vec![Object::Reference(ocg_draft), Object::Reference(ocg_notes)],
"D" => dictionary! {
"Name" => text("Showcase layers"),
"Creator" => text(CREATOR),
"BaseState" => "ON",
"ON" => vec![Object::Reference(ocg_draft)],
"OFF" => vec![Object::Reference(ocg_notes)],
"Order" => vec![Object::Reference(ocg_draft), Object::Reference(ocg_notes)],
"AS" => vec![
dictionary! { "Event" => "Print", "Category" => vec![name("Print")], "OCGs" => vec![Object::Reference(ocg_notes)] }.into(),
dictionary! { "Event" => "View", "Category" => vec![name("View")], "OCGs" => vec![Object::Reference(ocg_notes)] }.into(),
],
},
},
);
Ok(Summary { pages: all_pages.len(), annotations: annotation_count, fields: field_count, outline })
}
/// If `id` is one of Chrome's link annotations pointing at a marker URI, return it with its rect.
fn marker_link(doc: &Document, id: ObjectId) -> Option<(String, Rect)> {
let dict = doc.get_dictionary(id).ok()?;
if dict.get(b"Subtype").and_then(Object::as_name).ok()? != b"Link" {
return None;
}
let action = dict.get_deref(b"A", doc).and_then(Object::as_dict).ok()?;
let uri = action.get_deref(b"URI", doc).and_then(Object::as_str).ok()?;
let uri = String::from_utf8_lossy(uri).into_owned();
if !uri.starts_with(MARKER_PREFIX) {
return None;
}
let r = Rect::from_obj(dict.get(b"Rect").ok()?)?;
Some((uri, r))
}
fn page_annots(doc: &Document, page: ObjectId) -> Result<Vec<ObjectId>> {
let dict = doc.get_dictionary(page)?;
let Ok(obj) = dict.get_deref(b"Annots", doc) else {
return Ok(Vec::new());
};
Ok(obj.as_array()?.iter().filter_map(|o| o.as_reference().ok()).collect())
}
fn set_page_annots(doc: &mut Document, page: ObjectId, annots: Vec<ObjectId>) -> Result<()> {
let dict = doc.get_dictionary_mut(page)?;
if annots.is_empty() {
dict.remove(b"Annots");
} else {
dict.set("Annots", annots.into_iter().map(Object::Reference).collect::<Vec<_>>());
dict.set("Tabs", "S");
}
Ok(())
}
/// Mutable access to a page's own `/Resources` dictionary (direct or indirect).
fn resources_mut(doc: &mut Document, page: ObjectId) -> Result<&mut Dictionary> {
match doc.get_dictionary(page)?.get(b"Resources").ok().cloned() {
Some(Object::Reference(id)) => Ok(doc.get_dictionary_mut(id)?),
Some(Object::Dictionary(_)) => Ok(doc.get_dictionary_mut(page)?.get_mut(b"Resources")?.as_dict_mut()?),
_ => {
let page_dict = doc.get_dictionary_mut(page)?;
page_dict.set("Resources", Dictionary::new());
Ok(page_dict.get_mut(b"Resources")?.as_dict_mut()?)
}
}
}
/// Insert `key => value` into the `category` sub-dictionary of a page's resources.
fn add_resource(doc: &mut Document, page: ObjectId, category: &str, key: &str, value: Object) -> Result<()> {
let indirect = match resources_mut(doc, page)?.get(category.as_bytes()) {
Ok(Object::Reference(id)) => Some(*id),
_ => None,
};
let sub = match indirect {
Some(id) => doc.get_dictionary_mut(id)?,
None => {
let res = resources_mut(doc, page)?;
if !res.has(category.as_bytes()) {
res.set(category, Dictionary::new());
}
res.get_mut(category.as_bytes())?.as_dict_mut()?
}
};
sub.set(key, value);
Ok(())
}
/// Wrap the page's existing content in q/Q and append a diagonal "DRAFT" on layer `ocg`.
fn add_watermark(doc: &mut Document, page: ObjectId, ocg: ObjectId, fonts: &Fonts) -> Result<()> {
add_resource(doc, page, "Properties", "PCDraft", Object::Reference(ocg))?;
add_resource(doc, page, "Font", "PCDraftFont", Object::Reference(fonts.id(Font::HelvBold)))?;
add_resource(
doc,
page,
"ExtGState",
"PCDraftGS",
dictionary! { "Type" => "ExtGState", "ca" => 0.09, "CA" => 0.09 }.into(),
)?;
let size = 150.0f32;
let word = "DRAFT";
let tw = Font::HelvBold.width(word, size);
let cap = 0.72 * size;
let (sin, cos) = 52f32.to_radians().sin_cos();
let (cx, cy) = (306.0, 396.0);
let x = cx - (cos * tw / 2.0 - sin * cap / 2.0);
let y = cy - (sin * tw / 2.0 + cos * cap / 2.0);
let mut c = Content::new();
c.raw("Q").save();
c.raw("/OC /PCDraft BDC /Artifact << /Type /Pagination /Subtype /Watermark >> BDC");
c.gs("PCDraftGS").fill_color(colors::ACCENT);
c.raw(&format!(
"BT /PCDraftFont {size} Tf {cos:.4} {sin:.4} {:.4} {cos:.4} {x:.2} {y:.2} Tm (DRAFT) Tj ET",
-sin
));
c.raw("EMC EMC").restore();
let open = doc.add_object(Stream::new(Dictionary::new(), b"q\n".to_vec()));
let close = doc.add_object(Stream::new(Dictionary::new(), c.into_bytes()));
let mut contents = vec![Object::Reference(open)];
let page_dict = doc.get_dictionary(page)?;
match page_dict.get(b"Contents")? {
Object::Reference(id) => match doc.get_object(*id)? {
Object::Array(items) => contents.extend(items.iter().cloned()),
_ => contents.push(Object::Reference(*id)),
},
Object::Array(items) => contents.extend(items.iter().cloned()),
other => bail!("unexpected /Contents {other:?}"),
}
contents.push(Object::Reference(close));
doc.get_dictionary_mut(page)?.set("Contents", contents);
Ok(())
}
/// Keep Chrome's heading-based outline and add the form page; if Chrome did not
/// produce one, build a flat outline of the chapters.
fn extend_outline(doc: &mut Document, pages: &[ObjectId], form_page: ObjectId) -> Result<String> {
let dest = |page: ObjectId| -> Object {
vec![Object::Reference(page), name("XYZ"), 0.into(), 792.into(), Object::Null].into()
};
let existing = doc
.catalog()?
.get(b"Outlines")
.and_then(Object::as_reference)
.ok()
.filter(|id| doc.get_dictionary(*id).map(|d| d.has(b"First")).unwrap_or(false));
let (root, description) = match existing {
Some(root) => (root, "Chrome headings + form page"),
None => {
let root = doc.add_object(dictionary! { "Type" => "Outlines", "Count" => 0 });
let chapters = [
("Cover", 0),
("Contents", 1),
("Foreword", 2),
("1 Setting Text", 3),
("2 OpenType Features", 4),
("3 Expressive Type", 5),
("4 Scripts of the World", 6),
("5 Mathematics", 7),
("6 Vector Graphics", 8),
("7 Data & Tables", 9),
("8 Code & Images", 10),
("9 Review & Markup", 11),
];
for (title, index) in chapters {
append_outline_item(doc, root, title, dest(pages[index]), None)?;
}
doc.catalog_mut()?.set("Outlines", root);
(root, "built by xtask (Chrome emitted none)")
}
};
let form_item = append_outline_item(doc, root, "10 Interactive Form", dest(form_page), Some(colors::ACCENT.array()))?;
for (title, top) in [("Contact", 620), ("Preferences", 426), ("Actions & signature", 240)] {
let d: Object = vec![Object::Reference(form_page), name("XYZ"), 0.into(), top.into(), Object::Null].into();
append_outline_item(doc, form_item, title, d, None)?;
}
// Leave the form entry collapsed: a negative /Count means closed.
let kids = doc.get_dictionary(form_item)?.get(b"Count")?.as_i64()?;
doc.get_dictionary_mut(form_item)?.set("Count", -kids.abs());
let total = count_outline(doc, root);
Ok(format!("{description}, {total} entries"))
}
/// Append a child item to outline node `parent`, updating First/Last/Count.
fn append_outline_item(doc: &mut Document, parent: ObjectId, title: &str, dest: Object, color: Option<Object>) -> Result<ObjectId> {
let last = doc.get_dictionary(parent)?.get(b"Last").and_then(Object::as_reference).ok();
let mut item = dictionary! { "Title" => text(title), "Parent" => parent, "Dest" => dest };
if let Some(color) = color {
item.set("C", color);
item.set("F", 2); // bold
}
if let Some(last) = last {
item.set("Prev", last);
}
let id = doc.add_object(item);
if let Some(last) = last {
doc.get_dictionary_mut(last)?.set("Next", id);
}
let p = doc.get_dictionary_mut(parent)?;
if last.is_none() {
p.set("First", id);
}
p.set("Last", id);
let count = p.get(b"Count").and_then(Object::as_i64).unwrap_or(0);
// Open nodes (and the root) count visible descendants positively.
p.set("Count", if count < 0 { count - 1 } else { count + 1 });
Ok(id)
}
fn count_outline(doc: &Document, node: ObjectId) -> usize {
let mut n = 0;
let mut cur = doc.get_dictionary(node).ok().and_then(|d| d.get(b"First").and_then(Object::as_reference).ok());
while let Some(id) = cur {
n += 1 + count_outline(doc, id);
cur = doc.get_dictionary(id).ok().and_then(|d| d.get(b"Next").and_then(Object::as_reference).ok());
}
n
}
fn add_embedded_file_name(doc: &mut Document, file_name: &str, spec: ObjectId) -> Result<()> {
let names_ref = doc.catalog()?.get(b"Names").and_then(Object::as_reference).ok();
let names: &mut Dictionary = match names_ref {
Some(id) => doc.get_dictionary_mut(id)?,
None => {
let catalog = doc.catalog_mut()?;
if !catalog.has(b"Names") {
catalog.set("Names", Dictionary::new());
}
catalog.get_mut(b"Names")?.as_dict_mut()?
}
};
names.set(
"EmbeddedFiles",
dictionary! { "Names" => vec![Object::string_literal(file_name), Object::Reference(spec)] },
);
Ok(())
}
/// Chrome's producer string with this tool appended.
fn producer(doc: &Document) -> String {
let chrome = doc
.trailer
.get_deref(b"Info", doc)
.and_then(Object::as_dict)
.and_then(|d| d.get(b"Producer"))
.and_then(Object::as_str)
.map(|s| String::from_utf8_lossy(s).into_owned())
.unwrap_or_else(|_| "Chrome".into());
format!("{chrome}; lopdf 0.45 via printcraft xtask")
}
fn info_dict(producer: &str, now: &Stamp) -> Dictionary {
dictionary! {
"Title" => text(TITLE),
"Author" => text(AUTHOR),
"Subject" => text(SUBJECT),
"Keywords" => text(&KEYWORDS.join(", ")),
"Creator" => text(CREATOR),
"Producer" => text(producer),
"CreationDate" => Object::string_literal(now.pdf.clone()),
"ModDate" => Object::string_literal(now.pdf.clone()),
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
fn xmp(producer: &str, now: &Stamp) -> String {
// A stable-looking document id derived from the timestamp (FNV-1a).
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in now.iso.bytes().chain(TITLE.bytes()) {
h = (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
}
let uuid = format!(
"{:08x}-{:04x}-4{:03x}-8{:03x}-{:012x}",
h >> 32,
(h >> 16) & 0xffff,
h & 0xfff,
(h >> 20) & 0xfff,
h & 0xffff_ffff_ffff
);
let keywords: String = KEYWORDS.iter().map(|k| format!("<rdf:li>{}</rdf:li>", xml_escape(k))).collect();
format!(
"<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n\
<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\n\
<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\n\
<rdf:Description rdf:about=\"\"\n\
xmlns:dc=\"http://purl.org/dc/elements/1.1/\"\n\
xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\"\n\
xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"\n\
xmlns:xmpMM=\"http://ns.adobe.com/xap/1.0/mm/\">\n\
<dc:format>application/pdf</dc:format>\n\
<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">{title}</rdf:li></rdf:Alt></dc:title>\n\
<dc:creator><rdf:Seq><rdf:li>{author}</rdf:li></rdf:Seq></dc:creator>\n\
<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">{subject}</rdf:li></rdf:Alt></dc:description>\n\
<dc:subject><rdf:Bag>{keywords}</rdf:Bag></dc:subject>\n\
<xmp:CreatorTool>{creator}</xmp:CreatorTool>\n\
<xmp:CreateDate>{date}</xmp:CreateDate>\n\
<xmp:ModifyDate>{date}</xmp:ModifyDate>\n\
<xmp:MetadataDate>{date}</xmp:MetadataDate>\n\
<pdf:Keywords>{kw_flat}</pdf:Keywords>\n\
<pdf:Producer>{producer}</pdf:Producer>\n\
<xmpMM:DocumentID>uuid:{uuid}</xmpMM:DocumentID>\n\
<xmpMM:InstanceID>uuid:{uuid}</xmpMM:InstanceID>\n\
</rdf:Description>\n\
</rdf:RDF>\n\
</x:xmpmeta>\n\
{padding}\n\
<?xpacket end=\"w\"?>",
title = xml_escape(TITLE),
author = xml_escape(AUTHOR),
subject = xml_escape(SUBJECT),
creator = xml_escape(CREATOR),
date = now.iso,
kw_flat = xml_escape(&KEYWORDS.join(", ")),
producer = xml_escape(producer),
padding = " ".repeat(1024),
)
}
+110
View File
@@ -0,0 +1,110 @@
//! A procedurally generated raster (so the showcase needs no third-party art).
use anyhow::Result;
/// Centre and width of the rendered region of the complex plane ("Seahorse Valley").
const CX: f64 = -0.743_643_887;
const CY: f64 = 0.131_825_904;
const SPAN: f64 = 0.018;
const MAX_ITER: u32 = 700;
/// Gradient stops for the smooth escape-time colouring, matching the showcase palette.
const STOPS: &[(f64, [f64; 3])] = &[
(0.00, [15.0, 24.0, 48.0]),
(0.18, [42.0, 111.0, 219.0]),
(0.38, [26.0, 158.0, 143.0]),
(0.58, [242.0, 193.0, 78.0]),
(0.78, [228.0, 87.0, 46.0]),
(1.00, [251.0, 248.0, 242.0]),
];
/// Render the Mandelbrot set (2×2 supersampled) and encode it as an RGB PNG.
pub fn mandelbrot_png(size: u32) -> Result<Vec<u8>> {
let mut rgb = Vec::with_capacity((size * size * 3) as usize);
let step = SPAN / f64::from(size);
for py in 0..size {
for px in 0..size {
let mut acc = [0.0f64; 3];
for (sx, sy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
let re = CX + (f64::from(px) + sx - f64::from(size) / 2.0) * step;
let im = CY - (f64::from(py) + sy - f64::from(size) / 2.0) * step;
let c = colour(escape(re, im));
for (a, v) in acc.iter_mut().zip(c) {
*a += v / 4.0;
}
}
rgb.extend(acc.map(|v| v.round().clamp(0.0, 255.0) as u8));
}
}
let mut png_bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut png_bytes, size, size);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header()?;
writer.write_image_data(&rgb)?;
writer.finish()?;
Ok(png_bytes)
}
/// Smooth (continuous) iteration count, or `None` inside the set.
fn escape(re: f64, im: f64) -> Option<f64> {
let (mut x, mut y) = (0.0f64, 0.0f64);
for n in 0..MAX_ITER {
let (x2, y2) = (x * x, y * y);
if x2 + y2 > 256.0 {
let log_zn = (x2 + y2).ln() / 2.0;
let nu = (log_zn / std::f64::consts::LN_2).ln() / std::f64::consts::LN_2;
return Some(f64::from(n) + 1.0 - nu);
}
y = 2.0 * x * y + im;
x = x2 - y2 + re;
}
None
}
fn colour(mu: Option<f64>) -> [f64; 3] {
let Some(mu) = mu else {
return [11.0, 18.0, 38.0];
};
// Cycle the palette on a log scale so detail shows at every depth.
let t = (mu.max(1.0).ln() * 0.9).fract();
let t = if t < 0.5 { t * 2.0 } else { 2.0 - t * 2.0 };
for pair in STOPS.windows(2) {
let ((t0, c0), (t1, c1)) = (pair[0], pair[1]);
if t <= t1 {
let f = (t - t0) / (t1 - t0);
return [0, 1, 2].map(|i| c0[i] + (c1[i] - c0[i]) * f);
}
}
STOPS[STOPS.len() - 1].1
}
/// Standard base64 (RFC 4648) with padding, for `data:` URIs.
pub fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
let n = u32::from(b[0]) << 16 | u32::from(b[1]) << 8 | u32::from(b[2]);
for i in 0..4 {
if i <= chunk.len() {
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
#[cfg(test)]
mod tests {
#[test]
fn base64_matches_rfc4648_vectors() {
let cases = [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foobar", "Zm9vYmFy")];
for (input, expected) in cases {
assert_eq!(super::base64(input.as_bytes()), expected);
}
}
}
+46
View File
@@ -0,0 +1,46 @@
//! Project automation for PrintCraft, run via `cargo xtask <command>`.
use std::process::ExitCode;
mod demo_pdf;
type Command = fn(&[String]) -> anyhow::Result<()>;
/// Every subcommand: name, one-line summary, entry point.
const COMMANDS: &[(&str, &str, Command)] = &[(
"demo-pdf",
"Build dist/demo/printcraft-showcase.pdf (needs Google Chrome or Chromium)",
demo_pdf::run,
)];
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(name) = args.first() else {
print_help();
return ExitCode::FAILURE;
};
if matches!(name.as_str(), "help" | "-h" | "--help") {
print_help();
return ExitCode::SUCCESS;
}
let Some((_, _, command)) = COMMANDS.iter().find(|(n, _, _)| n == name) else {
eprintln!("xtask: unknown command `{name}`\n");
print_help();
return ExitCode::FAILURE;
};
match command(&args[1..]) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("xtask {name}: error: {err:#}");
ExitCode::FAILURE
}
}
}
fn print_help() {
eprintln!("Usage: cargo xtask <command> [args]\n\nCommands:");
for (name, about, _) in COMMANDS {
eprintln!(" {name:<12} {about}");
}
eprintln!(" {:<12} Show this list", "help");
}