mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-01 22:19:10 -06:00
* #163: allow replacing saved signatures and render long names fully Add saved signature and initials previews with change/remove/add controls in Fill & Sign. Preserve placed annotations and saved values on cancellation. Use bounded line outlines for long names and fit typed placements to 150 pt. Co-authored-by: Codex <noreply@openai.com> * cargo fmt after the rename Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
240 lines
8.9 KiB
Rust
240 lines
8.9 KiB
Rust
//! Typed signatures and bounded glyph outlines from approved fonts (bundled, or craft-fonts).
|
|
|
|
use skrifa::instance::{LocationRef, Size};
|
|
use skrifa::outline::{DrawSettings, OutlinePen};
|
|
use skrifa::{FontRef, MetadataProvider};
|
|
|
|
static FONT: &[u8] = include_bytes!("../../../assets/fonts/DancingScript.ttf");
|
|
|
|
/// Bound signature work while allowing long personal names.
|
|
pub const MAX_SIGNATURE_CHARS: usize = 256;
|
|
|
|
/// Outlines of a line of existing text at a font size of 1 (em units).
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct ScriptOutline {
|
|
pub contours: Vec<Vec<[f64; 2]>>,
|
|
pub width: f64,
|
|
pub ascent: f64,
|
|
pub descent: f64,
|
|
}
|
|
|
|
impl ScriptOutline {
|
|
/// Layout bounds including glyph overhangs, so previews and PDF appearances never clip ink.
|
|
pub fn bounds(&self) -> [f64; 4] {
|
|
self.contours.iter().flatten().fold([0.0, self.descent, self.width, self.ascent], |mut b, p| {
|
|
b[0] = b[0].min(p[0]);
|
|
b[1] = b[1].min(p[1]);
|
|
b[2] = b[2].max(p[0]);
|
|
b[3] = b[3].max(p[1]);
|
|
b
|
|
})
|
|
}
|
|
}
|
|
|
|
/// One glyph outline in em units, with the baseline at y = 0.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct GlyphOutline {
|
|
pub contours: Vec<Vec<[f64; 2]>>,
|
|
pub width: f64,
|
|
pub bbox: [f64; 4],
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum GlyphError {
|
|
/// This build has no Japanese face: it was built without craft-fonts (`CRAFT_FONTS_DIR`).
|
|
NoFont,
|
|
Missing,
|
|
TooComplex,
|
|
}
|
|
|
|
struct Flatten {
|
|
contours: Vec<Vec<[f64; 2]>>,
|
|
cur: Vec<[f64; 2]>,
|
|
scale: f64,
|
|
dx: f64,
|
|
points: usize,
|
|
too_complex: bool,
|
|
max_points: usize,
|
|
}
|
|
|
|
impl Flatten {
|
|
fn new(scale: f64) -> Self {
|
|
Self { contours: Vec::new(), cur: Vec::new(), scale, dx: 0.0, points: 0, too_complex: false, max_points: 4096 }
|
|
}
|
|
|
|
fn pt(&self, x: f32, y: f32) -> [f64; 2] {
|
|
[self.dx + x as f64 * self.scale, y as f64 * self.scale]
|
|
}
|
|
|
|
fn last(&self) -> [f64; 2] {
|
|
self.cur.last().copied().unwrap_or([self.dx, 0.0])
|
|
}
|
|
|
|
fn push(&mut self, p: [f64; 2]) {
|
|
if self.points >= self.max_points {
|
|
self.too_complex = true;
|
|
return;
|
|
}
|
|
self.points += 1;
|
|
self.cur.push(p);
|
|
}
|
|
|
|
fn close(&mut self) {
|
|
if self.cur.len() > 2 {
|
|
self.contours.push(std::mem::take(&mut self.cur));
|
|
} else {
|
|
self.cur.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
const STEPS: usize = 8;
|
|
|
|
impl OutlinePen for Flatten {
|
|
fn move_to(&mut self, x: f32, y: f32) {
|
|
self.close();
|
|
self.push(self.pt(x, y));
|
|
}
|
|
|
|
fn line_to(&mut self, x: f32, y: f32) {
|
|
self.push(self.pt(x, y));
|
|
}
|
|
|
|
fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {
|
|
let (p0, c, p) = (self.last(), self.pt(cx, cy), self.pt(x, y));
|
|
for i in 1..=STEPS {
|
|
let t = i as f64 / STEPS as f64;
|
|
let u = 1.0 - t;
|
|
self.push([u * u * p0[0] + 2.0 * u * t * c[0] + t * t * p[0], u * u * p0[1] + 2.0 * u * t * c[1] + t * t * p[1]]);
|
|
}
|
|
}
|
|
|
|
fn curve_to(&mut self, c0x: f32, c0y: f32, c1x: f32, c1y: f32, x: f32, y: f32) {
|
|
let (p0, c0, c1, p) = (self.last(), self.pt(c0x, c0y), self.pt(c1x, c1y), self.pt(x, y));
|
|
for i in 1..=STEPS {
|
|
let t = i as f64 / STEPS as f64;
|
|
let u = 1.0 - t;
|
|
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
|
self.push([a * p0[0] + b * c0[0] + c * c1[0] + d * p[0], a * p0[1] + b * c0[1] + c * c1[1] + d * p[1]]);
|
|
}
|
|
}
|
|
|
|
fn close(&mut self) {
|
|
Flatten::close(self);
|
|
}
|
|
}
|
|
|
|
/// Return one glyph of the Japanese document face ([`crate::document_japanese_font`], Shippori
|
|
/// Mincho from craft-fonts), bounded so hostile replacement text cannot allocate unbounded
|
|
/// outline data. [`GlyphError::NoFont`] when the build has no Japanese face.
|
|
pub fn japanese_glyph(ch: char) -> Result<GlyphOutline, GlyphError> {
|
|
let Some(face) = crate::document_japanese_font() else { return Err(GlyphError::NoFont) };
|
|
let Ok(font) = FontRef::new(face.bytes) else { return Err(GlyphError::Missing) };
|
|
let loc = LocationRef::default();
|
|
let metrics = font.metrics(Size::unscaled(), loc);
|
|
let scale = 1.0 / metrics.units_per_em.max(1) as f64;
|
|
let Some(gid) = font.charmap().map(ch) else { return Err(GlyphError::Missing) };
|
|
let advances = font.glyph_metrics(Size::unscaled(), loc);
|
|
let width = advances.advance_width(gid).unwrap_or(0.0) as f64 * scale;
|
|
if !width.is_finite() || width <= 0.0 || width > 2.0 {
|
|
return Err(GlyphError::Missing);
|
|
}
|
|
let Some(glyph) = font.outline_glyphs().get(gid) else { return Err(GlyphError::Missing) };
|
|
let mut pen = Flatten::new(scale);
|
|
let _ = glyph.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen);
|
|
pen.close();
|
|
if pen.too_complex || pen.contours.len() > 256 {
|
|
return Err(GlyphError::TooComplex);
|
|
}
|
|
let mut bbox = [0.0, 0.0, width, 0.0];
|
|
let mut any = false;
|
|
for p in pen.contours.iter().flatten() {
|
|
if !p[0].is_finite() || !p[1].is_finite() {
|
|
return Err(GlyphError::Missing);
|
|
}
|
|
if !any {
|
|
bbox = [p[0], p[1], p[0], p[1]];
|
|
any = true;
|
|
} else {
|
|
bbox[0] = bbox[0].min(p[0]);
|
|
bbox[1] = bbox[1].min(p[1]);
|
|
bbox[2] = bbox[2].max(p[0]);
|
|
bbox[3] = bbox[3].max(p[1]);
|
|
}
|
|
}
|
|
Ok(GlyphOutline { contours: pen.contours, width, bbox })
|
|
}
|
|
|
|
/// The outlines of `text` in the script font (characters it lacks are skipped).
|
|
/// Over-limit input returns an empty outline instead of a silently truncated signature.
|
|
pub fn script_outline(text: &str) -> ScriptOutline {
|
|
if text.chars().take(MAX_SIGNATURE_CHARS + 1).count() > MAX_SIGNATURE_CHARS {
|
|
return ScriptOutline::default();
|
|
}
|
|
let Ok(font) = FontRef::new(FONT) else { return ScriptOutline::default() };
|
|
let loc = LocationRef::default();
|
|
let metrics = font.metrics(Size::unscaled(), loc);
|
|
let scale = 1.0 / metrics.units_per_em.max(1) as f64;
|
|
let charmap = font.charmap();
|
|
let glyphs = font.outline_glyphs();
|
|
let advances = font.glyph_metrics(Size::unscaled(), loc);
|
|
let mut pen = Flatten::new(scale);
|
|
// The 4096-point budget is for one untrusted document glyph (japanese_glyph), not a
|
|
// whole signature in our bundled font. Keep a separate bounded budget for the line.
|
|
pen.max_points = 262_144;
|
|
for ch in text.chars() {
|
|
let Some(gid) = charmap.map(ch) else { continue };
|
|
if let Some(g) = glyphs.get(gid) {
|
|
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen);
|
|
pen.close();
|
|
}
|
|
pen.dx += advances.advance_width(gid).unwrap_or(0.0) as f64 * scale;
|
|
}
|
|
pen.close();
|
|
if pen.too_complex {
|
|
return ScriptOutline::default();
|
|
}
|
|
ScriptOutline { contours: pen.contours, width: pen.dx, ascent: metrics.ascent as f64 * scale, descent: metrics.descent as f64 * scale }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn long_names_keep_every_glyph_with_bounded_outline_work() {
|
|
let text = "Alexandria Catherine Elizabeth Montgomery-Wellington";
|
|
let o = super::script_outline(text);
|
|
let expected: usize = text.chars().map(|c| super::script_outline(&c.to_string()).contours.len()).sum();
|
|
assert_eq!(o.contours.len(), expected, "the entire name must be drawn");
|
|
let max_x = o.contours.iter().flatten().map(|p| p[0]).fold(0.0, f64::max);
|
|
assert!(max_x > o.width - 0.5, "ink reaches the last letter: {max_x} / {}", o.width);
|
|
assert!(!super::script_outline(&"W".repeat(super::MAX_SIGNATURE_CHARS)).contours.is_empty());
|
|
assert!(super::script_outline(&"W".repeat(super::MAX_SIGNATURE_CHARS + 1)).contours.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_name_becomes_outlines() {
|
|
let o = super::script_outline("Ada L.");
|
|
assert!(o.contours.len() >= 5, "{}", o.contours.len());
|
|
assert!(o.width > 1.0 && o.width < 6.0, "{}", o.width);
|
|
assert!(o.ascent > 0.5 && o.descent < 0.0);
|
|
let max_x = o.contours.iter().flatten().map(|p| p[0]).fold(0.0, f64::max);
|
|
assert!(max_x <= o.width + 0.2);
|
|
assert!(super::script_outline("").contours.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn japanese_glyphs_come_from_craft_fonts() {
|
|
if crate::document_japanese_font().is_none() {
|
|
eprintln!("skipping the glyph checks: built without craft-fonts (set CRAFT_FONTS_DIR)");
|
|
assert_eq!(super::japanese_glyph('こ'), Err(super::GlyphError::NoFont));
|
|
return;
|
|
}
|
|
for ch in "日本語の文字こ".chars() {
|
|
let g = super::japanese_glyph(ch).expect("craft-fonts face has the Japanese glyph");
|
|
assert!(g.width > 0.2 && g.width < 2.0);
|
|
assert!(!g.contours.is_empty());
|
|
}
|
|
assert_eq!(super::japanese_glyph('\u{1f4a9}'), Err(super::GlyphError::Missing));
|
|
}
|
|
}
|