mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
one-shot first ver
This commit is contained in:
@@ -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 },
|
||||
})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 { ®ULAR };
|
||||
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)");
|
||||
}
|
||||
}
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user