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.
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.
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
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync
Bounded source sync