sign-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 528326727848
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 1da91c033c738ab74538d13d
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
src/lib/finalpdf.ts
// Builds the signed PDF: every signer's values drawn onto the original pages, then a completion
// certificate page with the audit trail and the original document's SHA-256 fingerprint.
import { PDFDocument, StandardFonts, rgb, degrees, type PDFPage, type PDFFont } from "pdf-lib";
import { toWinAnsi } from "./textpdf";
import { FIELD_LABEL, parseJson, type Doc, type Field, type Signer, type Signature, type SignEvent } from "../../functions/lib/doc";

const INK = rgb(0.06, 0.12, 0.3);
const TEXT = rgb(0.09, 0.1, 0.12);
const MUTED = rgb(0.42, 0.44, 0.48);
const LINE = rgb(0.85, 0.87, 0.9);

interface Box { page: PDFPage; x: number; y: number; w: number; h: number; rot: number }

/** Maps a field (fractions of the page as displayed) to page space, honoring the page's rotation. */
function place(page: PDFPage, f: Field): Box {
  const { width: W, height: H } = page.getSize();
  const rot = ((page.getRotation().angle % 360) + 360) % 360;
  const [DW, DH] = rot % 180 === 0 ? [W, H] : [H, W];
  const px = f.x * DW;
  const py = (f.y + f.h) * DH; // the box's bottom-left corner, as displayed
  const map: Record<number, [number, number]> = { 0: [px, H - py], 90: [py, px], 180: [W - px, py], 270: [W - py, H - px] };
  const [x, y] = map[rot] ?? map[0];
  return { page, x, y, w: f.w * DW, h: f.h * DH, rot };
}

function dataUrlBytes(url: string): Uint8Array {
  const b64 = url.slice(url.indexOf(",") + 1);
  const bin = atob(b64);
  const out = new Uint8Array(bin.length);
  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
  return out;
}

function fitText(s: string, font: PDFFont, max: number, width: number): number {
  let size = max;
  while (size > 6 && font.widthOfTextAtSize(s, size) > width) size -= 0.5;
  return size;
}

export interface SignedPdfInput {
  docId: string;
  doc: Doc;
  base: Uint8Array;
  fields: Field[];
  signers: Signer[];
  signatures: Record<string, Signature>;
  events: SignEvent[];
  siteUrl: string;
}

export async function buildSignedPdf(input: SignedPdfInput): Promise<Uint8Array> {
  const { doc, docId, fields, signers, signatures, events } = input;
  const pdf = await PDFDocument.load(input.base, { ignoreEncryption: true });
  const regular = await pdf.embedFont(StandardFonts.Helvetica);
  const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
  const pages = pdf.getPages();

  for (const f of fields) {
    const sig = signatures[f.signerId];
    const page = pages[f.page];
    if (!sig || !page) continue;
    const value = parseJson<Record<string, string>>(sig.values, {})[f.id];
    if (!value) continue;
    const b = place(page, f);
    if ((f.type === "signature" || f.type === "initials") && value.startsWith("data:image/png")) {
      const img = await pdf.embedPng(dataUrlBytes(value));
      const scale = Math.min(b.w / img.width, b.h / img.height);
      const w = img.width * scale;
      const h = img.height * scale;
      b.page.drawImage(img, { x: b.x, y: b.y, width: w, height: h, rotate: degrees(b.rot) });
    } else if (f.type === "checkbox") {
      if (value === "true") {
        const s = Math.min(b.w, b.h);
        b.page.drawText("X", { x: b.x + s * 0.18, y: b.y + s * 0.15, size: s * 0.95, font: bold, color: INK, rotate: degrees(b.rot) });
      }
    } else {
      const text = toWinAnsi(value);
      const size = fitText(text, regular, Math.min(12, b.h * 0.72), b.w);
      b.page.drawText(text, { x: b.x + 1, y: b.y + Math.max(2, (b.h - size) / 2), size, font: regular, color: INK, rotate: degrees(b.rot) });
    }
  }

  // Completion certificate.
  const W = 612;
  const H = 792;
  const M = 56;
  let page = pdf.addPage([W, H]);
  let y = H - M;
  const write = (s: string, font = regular, size = 10, color = TEXT, x = M) => {
    if (y < M + 20) { page = pdf.addPage([W, H]); y = H - M; }
    page.drawText(toWinAnsi(s), { x, y: y - size, size, font, color });
    y -= size + 6;
  };
  const rule = () => { page.drawLine({ start: { x: M, y }, end: { x: W - M, y }, thickness: 0.6, color: LINE }); y -= 14; };
  const iso = (sec: number) => new Date(sec * 1000).toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC");

  write("Certificate of completion", bold, 18);
  y -= 4;
  write(`${doc.title}`, regular, 12, MUTED);
  y -= 6;
  rule();
  const completedAt = Math.max(...signers.map((s) => signatures[s.id]?.signedAt || 0));
  const rows: [string, string][] = [
    ["Document ID", docId],
    ["Original document SHA-256", doc.fileHash],
    ["Pages", String(pages.length)],
    ["Sent", doc.sentAt ? iso(doc.sentAt) : "-"],
    ["Completed", completedAt ? iso(completedAt) : "-"],
    ["Signers", String(signers.length)],
  ];
  for (const [k, v] of rows) {
    page.drawText(toWinAnsi(k), { x: M, y: y - 9.5, size: 9, font: bold, color: MUTED });
    page.drawText(toWinAnsi(v), { x: M + 150, y: y - 9.5, size: 9.5, font: regular, color: TEXT });
    y -= 17;
  }
  y -= 4;
  rule();
  for (const s of signers) {
    const sig = signatures[s.id];
    write(s.name + (s.email ? `  <${s.email}>` : ""), bold, 11);
    if (sig) {
      write(`Signed ${iso(sig.signedAt)}, agreeing to sign electronically.`, regular, 9.5, MUTED);
      const viewed = events.filter((e) => e.type === "viewed" && e.signerId === s.id).sort((a, b) => a.at - b.at)[0];
      if (viewed) write(`First opened ${iso(viewed.at)}.`, regular, 9.5, MUTED);
      if (sig.ua) write(`Browser: ${sig.ua.slice(0, 110)}`, regular, 8, MUTED);
      const vals = parseJson<Record<string, string>>(sig.values, {});
      const sigField = fields.find((f) => f.signerId === s.id && f.type === "signature" && vals[f.id]);
      if (sigField) {
        const img = await pdf.embedPng(dataUrlBytes(vals[sigField.id]));
        const h = 34;
        const w = Math.min(200, (img.width / img.height) * h);
        if (y - h < M + 20) { page = pdf.addPage([W, H]); y = H - M; }
        page.drawImage(img, { x: M, y: y - h, width: w, height: h });
        y -= h + 8;
      }
      // Typed values, and labelled checkboxes the signer ticked (the page shows only the mark, not what it meant).
      const filled = fields.filter((f) => f.signerId === s.id && vals[f.id] && f.type !== "signature" && f.type !== "initials" && (f.type !== "checkbox" || f.label));
      for (const f of filled) write(`${f.label || FIELD_LABEL[f.type]}: ${f.type === "checkbox" ? "Checked" : vals[f.id]}`, regular, 9.5);
    } else {
      write("Not signed.", regular, 9.5, MUTED);
    }
    y -= 8;
  }
  rule();
  write("Audit trail", bold, 11);
  const nameOf = (id?: string | null) => signers.find((s) => s.id === id)?.name ?? "the sender";
  for (const e of [...events].sort((a, b) => a.at - b.at)) {
    const what = e.type === "created" ? "Document created" : e.type === "sent" ? "Sent for signature"
      : e.type === "viewed" ? `Opened by ${nameOf(e.signerId)}` : e.type === "signed" ? `Signed by ${nameOf(e.signerId)}` : "Voided";
    write(`${iso(e.at)}   ${what}`, regular, 9.5);
  }
  y -= 10;
  write(`Signed with Sign by OpenApps, ${input.siteUrl.replace(/^https?:\/\//, "")}. Free e-signatures.`, regular, 8.5, MUTED);
  write("Each page above is the original document with the signers' entries added. The fingerprint identifies the original.", regular, 8.5, MUTED);

  pdf.setTitle(toWinAnsi(`${doc.title} (signed)`));
  pdf.setProducer("Sign by OpenApps");
  if (completedAt) pdf.setModificationDate(new Date(completedAt * 1000));
  return pdf.save();
}

export function download(bytes: Uint8Array, name: string) {
  const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
  const a = document.createElement("a");
  a.href = url;
  a.download = name.replace(/[^\w.\- ]+/g, "").trim() || "document.pdf";
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 10_000);
}

Change history

Quality pass: phone signing, edit signers, copy to send again
5283267278 · 6 hours ago
Bounded source sync
15f0fa2e27 · 1 day ago
Bounded source sync
d3aa1ca65b · 1 day ago
Bounded source sync
8e2ca0b773 · 1 day ago
Bounded source sync
5cfa84715c · 1 day ago
Bounded source sync
c849e7b286 · 1 day ago
Bounded source sync
68c1cabe03 · 1 day ago
Bounded source sync
62ae8944f0 · 1 day ago
Bounded source sync
7ed54e7381 · 1 day ago