/* Hoursprig PDF writer: a small, dependency-free PDF 1.4 generator for timesheets. * Text uses the built-in Helvetica fonts (WinAnsiEncoding), so it is real, selectable, searchable text. * Glyph widths below come from Adobe's Core 14 AFM metrics for Helvetica and Helvetica-Bold * (Copyright (c) 1985-1997 Adobe Systems Incorporated; metrics files are freely distributable). * Everything else © 2026 CyberMax. All rights reserved. */ (function (root) { "use strict"; var C = typeof module === "object" && module.exports ? require("./calc.js") : root.HourCalc; // Widths (1/1000 em) for WinAnsi codes 32..255. var WR = [278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,1015,667,667,722,722,667,611,778,722,278,500,667,556,833,722,778,667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556,333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,556,556,333,500,278,556,500,722,500,500,500,334,260,334,584,350,556,350,222,556,333,1000,556,556,333,1000,667,333,1000,350,611,350,350,222,222,333,333,350,556,1000,333,1000,500,333,944,350,500,667,278,333,556,556,556,556,260,556,333,737,370,556,584,333,737,333,400,584,333,333,333,556,537,278,333,333,365,556,834,834,834,611,667,667,667,667,667,667,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,500,556,556,556,556,278,278,278,278,556,556,556,556,556,556,556,584,611,556,556,556,556,500,556,500]; var WB = [278,333,474,556,556,889,722,238,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,333,333,584,584,584,611,975,722,722,722,722,667,611,778,722,278,556,722,611,833,722,778,667,778,722,667,611,722,667,944,667,667,611,333,278,333,584,556,333,556,611,556,611,556,333,611,611,278,278,556,278,889,611,611,611,611,389,556,333,611,556,778,556,556,500,389,280,389,584,350,556,350,278,556,500,1000,556,556,333,1000,667,333,1000,350,611,350,350,278,278,500,500,350,556,1000,333,1000,556,333,944,350,500,667,278,333,556,556,556,556,280,556,333,737,370,556,584,333,737,333,400,584,333,333,333,611,556,278,333,333,365,556,834,834,834,611,722,722,722,722,722,722,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,556,556,556,556,556,278,278,278,278,611,611,611,611,611,611,611,584,611,611,611,611,611,556,611,556]; var CP = { 0x20AC: 128, 0x201A: 130, 0x0192: 131, 0x201E: 132, 0x2026: 133, 0x2020: 134, 0x2021: 135, 0x02C6: 136, 0x2030: 137, 0x0160: 138, 0x2039: 139, 0x0152: 140, 0x017D: 142, 0x2018: 145, 0x2019: 146, 0x201C: 147, 0x201D: 148, 0x2022: 149, 0x2013: 150, 0x2014: 151, 0x02DC: 152, 0x2122: 153, 0x0161: 154, 0x203A: 155, 0x0153: 156, 0x017E: 158, 0x0178: 159 }; var FALLBACK = { "ł": "l", "Ł": "L", "đ": "d", "Đ": "D", "ı": "i", "‐": "-", "‑": "-", "−": "-", " ": " ", " ": " ", " ": " ", "′": "'", "″": '"' }; function code(ch) { var c = ch.codePointAt(0); if (c >= 32 && c < 127) return c; if (c >= 160 && c <= 255) return c; if (CP[c]) return CP[c]; return -1; } /** Can every character be drawn with the built-in fonts? */ function canEncode(s) { for (var ch of String(s)) if (code(ch) < 0) return false; return true; } /** String -> WinAnsi byte codes (accents stripped or "?" for anything the core fonts cannot draw). */ function encode(s) { var out = []; for (var ch of String(s == null ? "" : s).replace(/[\t\r\n]/g, " ")) { var c = code(ch); if (c < 0 && FALLBACK[ch]) c = code(FALLBACK[ch]); if (c < 0) { var base = ch.normalize("NFD").charAt(0); c = code(base); } out.push(c < 0 ? 63 : c); } return out; } function width(s, size, bold) { var w = 0, t = bold ? WB : WR; encode(s).forEach(function (c) { w += t[c - 32] || 556; }); return w * size / 1000; } /** Greedy word wrap to a width in points; long words are broken. */ function wrap(s, maxW, size, bold) { var out = []; String(s == null ? "" : s).split(/\r?\n/).forEach(function (para) { var words = para.split(/\s+/).filter(Boolean), cur = ""; if (!words.length) { out.push(""); return; } words.forEach(function (wd) { while (width(wd, size, bold) > maxW && wd.length > 1) { var k = wd.length - 1; while (k > 1 && width(wd.slice(0, k), size, bold) > maxW) k--; if (cur) { out.push(cur); cur = ""; } out.push(wd.slice(0, k)); wd = wd.slice(k); } var next = cur ? cur + " " + wd : wd; if (width(next, size, bold) <= maxW) cur = next; else { out.push(cur); cur = wd; } }); out.push(cur); }); return out; } function lit(s) { return "(" + encode(s).map(function (c) { if (c === 40 || c === 41 || c === 92) return "\\" + String.fromCharCode(c); if (c >= 32 && c < 127) return String.fromCharCode(c); return "\\" + ("00" + c.toString(8)).slice(-3); }).join("") + ")"; } function n2(v) { return (Math.round(v * 100) / 100).toString(); } function rgb(hex) { var m = /^#?([0-9a-f]{6})$/i.exec(hex || "") || [0, "11141b"]; var v = parseInt(m[1], 16); return [(v >> 16 & 255) / 255, (v >> 8 & 255) / 255, (v & 255) / 255].map(function (x) { return Math.round(x * 1000) / 1000; }).join(" "); } function mix(hex, other, t) { var a = parseInt(hex.slice(1), 16), b = parseInt(other.slice(1), 16), o = "#"; [16, 8, 0].forEach(function (s) { var v = Math.round((a >> s & 255) * (1 - t) + (b >> s & 255) * t); o += ("0" + v.toString(16)).slice(-2); }); return o; } function validHex(h) { return /^#[0-9a-f]{6}$/i.test(h || "") ? h : null; } /* ---------- page writer (top-left coordinates in points) ---------- */ function Writer(W, H) { this.W = W; this.H = H; this.pages = []; this.add(); } Writer.prototype.add = function () { this.p = { ops: [], links: [] }; this.pages.push(this.p); return this.p; }; Writer.prototype.on = function (i) { this.p = this.pages[i]; }; Writer.prototype.rect = function (x, y, w, h, fill, stroke, lw) { var o = ["q"]; if (fill) o.push(rgb(fill) + " rg"); if (stroke) o.push(rgb(stroke) + " RG " + n2(lw || 1) + " w"); o.push(n2(x) + " " + n2(this.H - y - h) + " " + n2(w) + " " + n2(h) + " re " + (fill && stroke ? "B" : fill ? "f" : "S"), "Q"); this.p.ops.push(o.join(" ")); }; Writer.prototype.line = function (x1, y1, x2, y2, color, lw) { this.p.ops.push("q " + rgb(color) + " RG " + n2(lw || 0.5) + " w " + n2(x1) + " " + n2(this.H - y1) + " m " + n2(x2) + " " + n2(this.H - y2) + " l S Q"); }; /** Text with its baseline at y. o: size, bold, color, align ('left'|'right'|'center'). Returns the drawn width. */ Writer.prototype.text = function (x, y, s, o) { o = o || {}; var size = o.size || 10, w = width(s, size, o.bold); if (o.align === "right") x -= w; else if (o.align === "center") x -= w / 2; this.p.ops.push("BT /" + (o.bold ? "F2" : "F1") + " " + n2(size) + " Tf " + rgb(o.color || "#11141B") + " rg " + n2(x) + " " + n2(this.H - y) + " Td " + lit(s) + " Tj ET"); return w; }; Writer.prototype.image = function (x, y, w, h) { this.p.ops.push("q " + n2(w) + " 0 0 " + n2(h) + " " + n2(x) + " " + n2(this.H - y - h) + " cm /Im1 Do Q"); }; Writer.prototype.link = function (x, y, w, h, url) { this.p.links.push({ r: [x, this.H - y - h, x + w, this.H - y], url: url }); }; /* ---------- serialisation ---------- */ function serialize(wr, info, img) { var chunks = [], len = 0, offsets = []; function put(x) { if (typeof x === "string") { var b = new Uint8Array(x.length); for (var i = 0; i < x.length; i++) b[i] = x.charCodeAt(i) & 255; x = b; } chunks.push(x); len += x.length; } var next = img ? 7 : 6, pageNums = [], plan = []; wr.pages.forEach(function (p) { var pn = next++, cn = next++, an = p.links.map(function () { return next++; }); pageNums.push(pn); plan.push({ p: p, pn: pn, cn: cn, an: an }); }); function obj(n, body) { offsets[n] = len; put(n + " 0 obj\n"); if (typeof body === "string") put(body); else body(); put("\nendobj\n"); } put("%PDF-1.4\n%âãÏÓ\n"); obj(1, "<< /Type /Catalog /Pages 2 0 R >>"); obj(2, "<< /Type /Pages /Kids [" + pageNums.map(function (n) { return n + " 0 R"; }).join(" ") + "] /Count " + pageNums.length + " >>"); obj(3, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"); obj(4, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>"); obj(5, "<< /Title " + lit(info.title || "Document") + " /Author " + lit(info.author || "") + " /Producer (Hoursprig by CyberMax) /Creator (Hoursprig) /CreationDate (D:" + info.date + ") >>"); if (img) obj(6, function () { put("<< /Type /XObject /Subtype /Image /Width " + img.w + " /Height " + img.h + " /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length " + img.bytes.length + " >>\nstream\n"); put(img.bytes); put("\nendstream"); }); plan.forEach(function (q) { var content = q.p.ops.join("\n"); obj(q.pn, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + n2(wr.W) + " " + n2(wr.H) + "] /Resources << /Font << /F1 3 0 R /F2 4 0 R >>" + (img ? " /XObject << /Im1 6 0 R >>" : "") + " >> /Contents " + q.cn + " 0 R" + (q.an.length ? " /Annots [" + q.an.map(function (n) { return n + " 0 R"; }).join(" ") + "]" : "") + " >>"); obj(q.cn, "<< /Length " + content.length + " >>\nstream\n" + content + "\nendstream"); q.p.links.forEach(function (l, i) { obj(q.an[i], "<< /Type /Annot /Subtype /Link /Rect [" + l.r.map(n2).join(" ") + "] /Border [0 0 0] /A << /S /URI /URI " + lit(l.url) + " >> >>"); }); }); var xref = len, count = next; var x = "xref\n0 " + count + "\n0000000000 65535 f \n"; for (var i = 1; i < count; i++) x += ("000000000" + (offsets[i] || 0)).slice(-10) + " 00000 n \n"; put(x + "trailer\n<< /Size " + count + " /Root 1 0 R /Info 5 0 R >>\nstartxref\n" + xref + "\n%%EOF\n"); var out = new Uint8Array(len), o = 0; chunks.forEach(function (c) { out.set(c, o); o += c.length; }); return out; } /* ---------- the timesheet layout ---------- */ var INK = "#11141B", MUTED = "#5B6170", RULE = "#D9DCE3", WHITE = "#FFFFFF", SOFT = "#F4F6FA", ACCENT = "#1F62B5"; var LETTER = ["USD", "CAD", "MXN", "PHP"]; function pageSize(paper, currency) { var p = paper || (LETTER.indexOf(currency) > -1 ? "Letter" : "A4"); return p === "Letter" ? [612, 792] : [595.28, 841.89]; } /** * Build a timesheet PDF. * sheet: { company, employee: {name, ref}, result (HourCalc.compute output), notes } * opts: { credit: bool, creditText, creditUrl, logo: {bytes, w, h} (JPEG), paper: "A4"|"Letter"|"", date: Date } * Returns a Uint8Array. */ function build(sheet, opts) { opts = opts || {}; var r = sheet.result, s = r.settings, T = r.totals, h24 = !!s.h24; var sz = pageSize(opts.paper, s.currency), W = sz[0], H = sz[1]; var wr = new Writer(W, H); var L = 40, R = W - 40, CW = R - L, BOTTOM = H - 56; var money = function (v) { var t = C.money(v, s.currency); return canEncode(t) ? t : s.currency + " " + v.toFixed(2); }; var emp = sheet.employee || {}; var y = 40; // ---- header ---- var lx = L; if (opts.logo && opts.logo.bytes && opts.logo.w && opts.logo.h) { var lh = 40, lw = lh * opts.logo.w / opts.logo.h; if (lw > 130) { lw = 130; lh = lw * opts.logo.h / opts.logo.w; } wr.image(L, y, lw, lh); lx = L + lw + 12; } if (sheet.company) wrap(sheet.company, R - 200 - lx, 14, true).slice(0, 2).forEach(function (t, i) { wr.text(lx, y + 15 + i * 17, t, { size: 14, bold: true }); }); wr.text(lx, y + (sheet.company ? 15 + 17 * Math.min(2, wrap(sheet.company, R - 200 - lx, 14, true).length) : 14), (C.KIND_LABEL[r.kind] || "") + " timesheet", { size: 9, color: MUTED }); wr.text(R, y + 20, "TIMESHEET", { size: 22, bold: true, color: ACCENT, align: "right" }); wr.text(R, y + 36, C.fmtRange(r.start, r.end), { size: 9.5, bold: true, align: "right" }); y += 62; // ---- employee / period band ---- wr.rect(L, y, CW, 40, SOFT); var cells = [["EMPLOYEE", emp.name || "—"], ["EMPLOYEE ID", emp.ref || "—"], ["PAY PERIOD", C.fmtDate(r.start, "y") + " to " + C.fmtDate(r.end, "y")], ["HOURLY RATE", T.rate ? money(T.rate) : "—"]]; var cw4 = [CW * 0.3, CW * 0.17, CW * 0.36, CW * 0.17], cx = L + 10; cells.forEach(function (c, i) { wr.text(cx, y + 15, c[0], { size: 6.5, bold: true, color: MUTED }); var v = c[1]; while (v.length > 3 && width(v, 9.5, true) > cw4[i] - 14) v = v.slice(0, -2); if (v !== c[1]) v = v.slice(0, -1) + "…"; wr.text(cx, y + 30, v, { size: 9.5, bold: true }); cx += cw4[i]; }); y += 54; // ---- day table ---- var showDT = s.dtOn || s.seventhOn || T.dt > 0; var cols = [{ k: "date", t: "Date", w: 64, a: "left" }, { k: "shifts", t: "In – Out", w: 0, a: "left" }, { k: "brk", t: "Break", w: 38 }, { k: "hours", t: "Hours", w: 42 }, { k: "reg", t: "Regular", w: 44 }, { k: "ot", t: "Overtime", w: 48 }]; if (showDT) cols.push({ k: "dt", t: "Double", w: 42 }); cols.push({ k: "note", t: "Note", w: 96, a: "left" }); var fixed = cols.reduce(function (a, c) { return a + c.w; }, 0); cols[1].w = CW - fixed; function header() { wr.rect(L, y, CW, 20, ACCENT); var x = L; cols.forEach(function (c) { if (c.a === "left") wr.text(x + 6, y + 13.5, c.t.toUpperCase(), { size: 6.8, bold: true, color: WHITE }); else wr.text(x + c.w - 6, y + 13.5, c.t.toUpperCase(), { size: 6.8, bold: true, color: WHITE, align: "right" }); x += c.w; }); y += 20; } function row(vals, o) { o = o || {}; var lines = {}, n = 1; cols.forEach(function (c) { var v = vals[c.k]; var arr = Array.isArray(v) ? v : [v == null ? "" : String(v)]; if (c.a === "left") arr = [].concat.apply([], arr.map(function (t) { return wrap(t, c.w - 12, 8.3, o.bold); })); lines[c.k] = arr; n = Math.max(n, arr.length); }); var rh = n * 11 + 8; if (y + rh > BOTTOM) { wr.add(); y = 40; header(); } if (o.fill) wr.rect(L, y, CW, rh, o.fill); var x = L; cols.forEach(function (c) { lines[c.k].forEach(function (t, i) { var opt = { size: 8.3, bold: !!o.bold || (c.k === "hours" && t !== "—"), color: o.dim ? "#9AA0AC" : INK }; if (c.k === "note" || c.k === "date") opt.color = o.dim && c.k === "date" ? MUTED : (c.k === "note" ? MUTED : INK); if (c.a === "left") wr.text(x + 6, y + 12 + i * 11, t, opt); else wr.text(x + c.w - 6, y + 12 + i * 11, t, Object.assign(opt, { align: "right" })); }); x += c.w; }); y += rh; wr.line(L, y, R, y, RULE, 0.5); } header(); var multiWeek = r.weeks.length > 1; r.weeks.forEach(function (wk, wi) { wk.days.forEach(function (d) { var on = d.worked > 0; row({ date: C.fmtDate(d.date, true), shifts: on ? d.shifts.map(function (x) { return C.fmtClock(x.start, h24) + " – " + C.fmtClock(x.end, h24) + (x.end > C.DAY ? " (+1 day)" : ""); }) : "—", brk: on && d.brk ? d.brk + " min" : "", hours: on ? C.hm(d.worked) : "—", reg: on ? C.hm(d.reg) : "", ot: d.ot ? C.hm(d.ot) : "", dt: d.dt ? C.hm(d.dt) : "", note: (d.seventh ? "7th day. " : "") + (d.note || ""), }, { dim: !on }); }); if (multiWeek) row({ date: "Week " + (wi + 1), shifts: C.fmtDate(wk.start) + " – " + C.fmtDate(wk.end), brk: "", hours: C.hm(wk.worked), reg: C.hm(wk.reg), ot: C.hm(wk.ot), dt: showDT ? C.hm(wk.dt) : "", note: "" }, { bold: true, fill: SOFT }); }); row({ date: "Total", shifts: T.daysWorked + (T.daysWorked === 1 ? " day" : " days") + " worked", brk: T.brk ? T.brk + " min" : "", hours: C.hm(T.worked), reg: C.hm(T.reg), ot: C.hm(T.ot), dt: showDT ? C.hm(T.dt) : "", note: C.dec(T.worked) + " h decimal" }, { bold: true, fill: "#E3ECF8" }); y += 16; // ---- pay summary (right) + rules (left) ---- var payRows = [["Regular", C.hm(T.reg) + " (" + C.dec(T.reg) + " h)", T.rate ? money(T.regPay) : ""]]; if (s.weeklyOn || s.dailyOn || s.seventhOn || T.ot) payRows.push(["Overtime x" + s.otMult, C.hm(T.ot) + " (" + C.dec(T.ot) + " h)", T.rate ? money(T.otPay) : ""]); if (showDT) payRows.push(["Double time x" + s.dtMult, C.hm(T.dt) + " (" + C.dec(T.dt) + " h)", T.rate ? money(T.dtPay) : ""]); var boxH = payRows.length * 16 + 44; if (y + boxH + 120 > BOTTOM) { wr.add(); y = 40; } var bx = L + CW * 0.46, by = y; payRows.forEach(function (p) { wr.text(bx, by + 10, p[0], { size: 9, color: MUTED }); wr.text(bx + CW * 0.25, by + 10, p[1], { size: 9 }); if (p[2]) wr.text(R, by + 10, p[2], { size: 9, align: "right" }); by += 16; }); wr.line(bx, by + 2, R, by + 2, ACCENT, 1.2); wr.text(bx, by + 20, T.rate ? "Gross pay" : "Total hours", { size: 11, bold: true }); wr.text(R, by + 20, T.rate ? money(T.gross) : C.hm(T.worked) + " (" + C.dec(T.worked) + " h)", { size: 13, bold: true, color: ACCENT, align: "right" }); if (T.rate) wr.text(R, by + 34, "Total hours " + C.hm(T.worked) + " (" + C.dec(T.worked) + " h)", { size: 8, color: MUTED, align: "right" }); var ly = y; wr.text(L, ly + 8, "HOW THIS WAS CALCULATED", { size: 6.8, bold: true, color: MUTED }); ly += 14; wrap(C.ruleLines(s), CW * 0.42, 8, false).forEach(function (t) { wr.text(L, ly + 8, t, { size: 8 }); ly += 11; }); if (sheet.notes) { ly += 6; wr.text(L, ly + 8, "NOTES", { size: 6.8, bold: true, color: MUTED }); ly += 14; wrap(sheet.notes, CW * 0.42, 8, false).slice(0, 8).forEach(function (t) { wr.text(L, ly + 8, t, { size: 8 }); ly += 11; }); } y = Math.max(ly, by + 40) + 26; // ---- signatures ---- if (y + 92 > BOTTOM) { wr.add(); y = 60; } wr.text(L, y, "I confirm the hours above are a true record of the time worked.", { size: 8.5, color: MUTED }); y += 42; function sig(x, w, label, sub) { wr.line(x, y, x + w, y, INK, 0.7); wr.text(x, y + 11, label, { size: 7.5, bold: true, color: MUTED }); if (sub) wr.text(x, y + 22, sub, { size: 7.5, color: MUTED }); } var gap = 14, dw = 80, sw = (CW - 2 * dw - 3 * gap) / 2; sig(L, sw, "EMPLOYEE SIGNATURE", emp.name || ""); sig(L + sw + gap, dw, "DATE"); sig(L + sw + dw + 2 * gap, sw, "SUPERVISOR / EMPLOYER SIGNATURE", sheet.company || ""); sig(R - dw, dw, "DATE"); // ---- footer on every page ---- var n = wr.pages.length; for (var i = 0; i < n; i++) { wr.on(i); if (opts.credit) { var credit = opts.creditText || CREDIT; var cw = wr.text(L, H - 24, credit, { size: 7.5, color: "#7A8090" }); if (opts.creditUrl) wr.link(L, H - 32, cw, 11, opts.creditUrl); } wr.text(L, H - 36, "Calculated from the entered times and settings; not legal or payroll advice.", { size: 6.5, color: "#9AA0AC" }); if (n > 1) wr.text(R, H - 24, "Page " + (i + 1) + " of " + n, { size: 7.5, color: "#7A8090", align: "right" }); } var d = opts.date || new Date(); var stamp = d.getUTCFullYear() + [d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds()].map(function (v) { return ("0" + v).slice(-2); }).join("") + "Z"; return serialize(wr, { title: "Timesheet " + (emp.name ? emp.name + " " : "") + r.start + " to " + r.end, author: sheet.company || "", date: stamp }, opts.logo && opts.logo.bytes ? opts.logo : null); } /** Default footer credit on free PDFs (app.js passes the same text plus a tagged creditUrl). */ var CREDIT = "Made with Hoursprig by CyberMax \u2014 make your own free: cybermaxtools.com/apps/time-card-calculator"; /** Timesheet-Jordan-Rivera-2026-09-28.pdf */ function fileName(sheet, ext) { var parts = ["Timesheet", sheet.employee && sheet.employee.name, sheet.result.start].filter(Boolean).join("-"); return parts.normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 90) + "." + (ext || "pdf"); } var api = { build: build, fileName: fileName, encode: encode, canEncode: canEncode, width: width, wrap: wrap, pageSize: pageSize, CREDIT: CREDIT }; if (typeof module === "object" && module.exports) module.exports = api; else root.HourPDF = api; })(typeof self !== "undefined" ? self : this);