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/textpdf.ts
// Turns a text document into a PDF, with a signature block per signer at the end.
// Deterministic: the same title, text and signers always give the same pages and field positions,
// so the sender, every signer and the final PDF agree without storing a file.
import { PDFDocument, StandardFonts, rgb, type PDFFont, type PDFPage } from "pdf-lib";
import { textFieldIds, type Field, type Signer } from "../../functions/lib/doc";
const W = 612;
const H = 792;
const M = 72;
const BODY = 11;
const LEAD = 15.5;
const INK = rgb(0.09, 0.1, 0.12);
const MUTED = rgb(0.42, 0.44, 0.48);
const LINE = rgb(0.78, 0.8, 0.84);
/** Standard PDF fonts only cover Western European characters; swap or drop the rest. */
export function toWinAnsi(s: string): string {
return s
.replace(/[‘’‚′]/g, "'")
.replace(/[“”„″]/g, '"')
.replace(/[–—−]/g, "-")
.replace(/…/g, "...")
.replace(/[ ]/g, " ")
.replace(/[•●▪]/g, "-")
.replace(/\t/g, " ")
.replace(/[^\x20-\x7E¡-ÿ\n]/g, "?");
}
function wrap(text: string, font: PDFFont, size: number, width: number): string[] {
const out: string[] = [];
for (const para of text.split("\n")) {
const words = para.split(/(\s+)/);
let line = "";
for (const w of words) {
const next = line + w;
if (font.widthOfTextAtSize(next.trimEnd(), size) <= width || !line.trim()) {
line = next;
// A single word longer than the line: break it hard.
while (font.widthOfTextAtSize(line.trimEnd(), size) > width) {
let cut = line.length - 1;
while (cut > 1 && font.widthOfTextAtSize(line.slice(0, cut), size) > width) cut--;
out.push(line.slice(0, cut));
line = line.slice(cut);
}
} else {
out.push(line.trimEnd());
line = w.trimStart();
}
}
out.push(line.trimEnd());
}
return out;
}
type Block = { kind: "h1" | "h2" | "p" | "li" | "space"; text: string; bullet?: string };
function parse(text: string): Block[] {
const blocks: Block[] = [];
let para: string[] = [];
const flush = () => { if (para.length) { blocks.push({ kind: "p", text: para.join(" ") }); para = []; } };
for (const raw of text.split("\n")) {
const line = raw.trim();
if (!line) { flush(); blocks.push({ kind: "space", text: "" }); continue; }
const h = line.match(/^(#{1,3})\s+(.*)$/);
const li = line.match(/^([-*]|\d+[.)])\s+(.*)$/);
if (h) { flush(); blocks.push({ kind: h[1].length === 1 ? "h1" : "h2", text: h[2] }); }
else if (li) { flush(); blocks.push({ kind: "li", text: li[2], bullet: /\d/.test(li[1]) ? li[1] : "-" }); }
else para.push(line.replace(/\*\*(.+?)\*\*/g, "$1").replace(/__(.+?)__/g, "$1"));
}
flush();
// Collapse runs of blank lines.
return blocks.filter((b, i) => !(b.kind === "space" && (i === 0 || blocks[i - 1].kind === "space")));
}
export interface TextPdf { bytes: Uint8Array; fields: Field[]; pageCount: number }
export async function renderTextPdf(title: string, text: string, signers: Signer[], createdAt: number): Promise<TextPdf> {
const pdf = await PDFDocument.create();
const date = new Date(createdAt * 1000);
pdf.setTitle(toWinAnsi(title));
pdf.setProducer("Sign by OpenApps");
pdf.setCreator("Sign by OpenApps");
pdf.setCreationDate(date);
pdf.setModificationDate(date);
const regular = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
const fields: Field[] = [];
let page: PDFPage = pdf.addPage([W, H]);
let y = H - M;
const newPage = () => { page = pdf.addPage([W, H]); y = H - M; };
const need = (h: number) => { if (y - h < M) newPage(); };
const lines = (s: string, font: PDFFont, size: number, lead: number, x = M, width = W - 2 * M, color = INK) => {
for (const l of wrap(toWinAnsi(s), font, size, width)) {
need(lead);
page.drawText(l, { x, y: y - size, size, font, color });
y -= lead;
}
};
lines(title, bold, 18, 24);
y -= 10;
const blocks = parse(text);
// A text that opens with its own title as a heading would print it twice.
if (blocks[0]?.kind === "h1" && blocks[0].text.trim().toLowerCase() === title.trim().toLowerCase()) blocks.shift();
for (const b of blocks) {
if (b.kind === "space") { y -= LEAD * 0.6; continue; }
if (b.kind === "h1") { need(40); y -= 8; lines(b.text, bold, 14, 19); y -= 4; continue; }
if (b.kind === "h2") { need(34); y -= 6; lines(b.text, bold, 12, 17); y -= 2; continue; }
if (b.kind === "li") {
need(LEAD);
page.drawText(toWinAnsi(b.bullet || "-"), { x: M + 6, y: y - BODY, size: BODY, font: regular, color: INK });
lines(b.text, regular, BODY, LEAD, M + 26, W - 2 * M - 26);
continue;
}
lines(b.text, regular, BODY, LEAD);
y -= 4;
}
// Signature block: one card per signer.
y -= 18;
need(60);
page.drawText("Signatures", { x: M, y: y - 13, size: 13, font: bold, color: INK });
y -= 30;
for (const s of signers) {
const blockH = 118;
need(blockH);
const top = y;
page.drawText(toWinAnsi(s.name), { x: M, y: top - 11, size: 11, font: bold, color: INK });
if (s.email) page.drawText(toWinAnsi(s.email), { x: M + bold.widthOfTextAtSize(toWinAnsi(s.name), 11) + 10, y: top - 11, size: 9.5, font: regular, color: MUTED });
const sigTop = top - 22;
const sigBox = { x: M, y: sigTop - 50, w: 260, h: 50 };
page.drawLine({ start: { x: sigBox.x, y: sigBox.y }, end: { x: sigBox.x + sigBox.w, y: sigBox.y }, thickness: 0.8, color: LINE });
page.drawText("Signature", { x: sigBox.x, y: sigBox.y - 12, size: 8.5, font: regular, color: MUTED });
const nameBox = { x: M + 290, y: sigTop - 22, w: 160, h: 18 };
const dateBox = { x: M + 290, y: sigTop - 52, w: 160, h: 18 };
for (const [box, label] of [[nameBox, "Full name"], [dateBox, "Date"]] as const) {
page.drawLine({ start: { x: box.x, y: box.y }, end: { x: box.x + box.w, y: box.y }, thickness: 0.8, color: LINE });
page.drawText(label, { x: box.x, y: box.y - 12, size: 8.5, font: regular, color: MUTED });
}
const pageIndex = pdf.getPageCount() - 1;
const toField = (id: string, type: Field["type"], b: { x: number; y: number; w: number; h: number }): Field =>
({ id, signerId: s.id, type, page: pageIndex, x: b.x / W, y: (H - (b.y + b.h)) / H, w: b.w / W, h: b.h / H, required: true });
const ids = textFieldIds(s.id);
fields.push(toField(ids.signature, "signature", sigBox), toField(ids.name, "name", nameBox), toField(ids.date, "date", dateBox));
y = top - blockH;
}
// Page numbers.
const pages = pdf.getPages();
pages.forEach((p, i) => {
const label = `${i + 1} of ${pages.length}`;
p.drawText(label, { x: W - M - regular.widthOfTextAtSize(label, 8.5), y: M / 2, size: 8.5, font: regular, color: MUTED });
});
return { bytes: await pdf.save({ useObjectStreams: false }), fields, pageCount: pages.length };
}
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