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/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
5283267278 · 5 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