using System.Globalization; using System.IO.Compression; using System.Text; using QRCoder; /// /// Builds printable cover sheet PDFs using raw PDF 1.4 objects and QRCoder for QR images. /// Each page includes a header, QR code, human-readable metadata, and a footer instruction line. /// public static class CoverSheetPdfGenerator { private const float PageWidth = 612f; private const float PageHeight = 792f; private const float QrDisplaySize = 200f; private const float LeftMargin = 72f; public static byte[] Generate(CoverSheet sheet) => GenerateBatch(new[] { sheet }); public static byte[] GenerateBatch(IReadOnlyList sheets) { if (sheets.Count == 0) throw new ArgumentException("At least one cover sheet is required.", nameof(sheets)); var pages = sheets.Select(BuildPage).ToList(); return BuildPdf(pages); } private sealed record PageData(string ContentStream, byte[] ImageRgb, int ImageWidth, int ImageHeight); private static PageData BuildPage(CoverSheet sheet) { var (rgb, width, height) = GenerateQrRgb(sheet.Code); var content = BuildContentStream(sheet); return new PageData(content, rgb, width, height); } private static (byte[] Rgb, int Width, int Height) GenerateQrRgb(string code) { using var generator = new QRCodeGenerator(); using var data = generator.CreateQrCode(code, QRCodeGenerator.ECCLevel.M); var modules = data.ModuleMatrix.Count; const int scale = 8; const int quiet = 2; var size = (modules + quiet * 2) * scale; var rgb = new byte[size * size * 3]; for (var y = 0; y < size; y++) { for (var x = 0; x < size; x++) { var mx = x / scale - quiet; var my = y / scale - quiet; var dark = mx >= 0 && my >= 0 && mx < modules && my < modules && data.ModuleMatrix[my][mx]; var color = dark ? (byte)0 : (byte)255; var i = (y * size + x) * 3; rgb[i] = color; rgb[i + 1] = color; rgb[i + 2] = color; } } return (rgb, size, size); } private static string BuildContentStream(CoverSheet sheet) { var sb = new StringBuilder(); var qrX = (PageWidth - QrDisplaySize) / 2f; const float qrY = 470f; sb.AppendLine(CultureInfo.InvariantCulture, $"q {QrDisplaySize} 0 0 {QrDisplaySize} {qrX} {qrY} cm /Im1 Do Q"); WriteTextLine(sb, 18f, LeftMargin, 740f, "VigilCare Records - Cover Sheet"); var y = 430f; y = WriteTextLine(sb, 12f, LeftMargin, y, $"Code: {sheet.Code}"); y = WriteTextLine(sb, 12f, LeftMargin, y, $"Batch Type: {sheet.BatchType.ToDbString()}"); y = WriteTextLine(sb, 12f, LeftMargin, y, $"Track: {sheet.Track.ToDbString()}"); if (sheet.Patient is not null) { y = WriteTextLine(sb, 12f, LeftMargin, y, $"Patient: {sheet.Patient.FullName} (MRN {sheet.Patient.Mrn})"); } if (sheet.AssignToUser is not null) { y = WriteTextLine(sb, 12f, LeftMargin, y, $"Assigned To: {sheet.AssignToUser.FullName}"); } var generated = sheet.CreatedAt.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); WriteTextLine(sb, 12f, LeftMargin, y, $"Generated: {generated}"); WriteTextLine(sb, 10f, LeftMargin, 48f, "Attach to front of chart section. Scanner reads QR code automatically."); return sb.ToString(); } private static float WriteTextLine(StringBuilder sb, float fontSize, float x, float y, string text) { sb.AppendLine("BT"); sb.AppendLine(CultureInfo.InvariantCulture, $"/F1 {fontSize} Tf"); sb.AppendLine(CultureInfo.InvariantCulture, $"{x} {y} Td"); sb.AppendLine(CultureInfo.InvariantCulture, $"({EscapePdfString(text)}) Tj"); sb.AppendLine("ET"); return y - fontSize - 8f; } private static byte[] BuildPdf(IReadOnlyList pages) { using var ms = new MemoryStream(); ms.Write("%PDF-1.4\n"u8); var offsets = new List(); const int catalogId = 1; const int pagesId = 2; const int fontId = 3; var pageIds = Enumerable.Range(0, pages.Count).Select(i => 4 + i * 3).ToArray(); var contentIds = pageIds.Select(id => id + 1).ToArray(); var imageIds = pageIds.Select(id => id + 2).ToArray(); offsets.Add(ms.Position); WriteAscii(ms, $"{catalogId} 0 obj\n<< /Type /Catalog /Pages {pagesId} 0 R >>\nendobj\n"); offsets.Add(ms.Position); var kids = string.Join(" ", pageIds.Select(id => $"{id} 0 R")); WriteAscii(ms, $"{pagesId} 0 obj\n<< /Type /Pages /Kids [{kids}] /Count {pages.Count} >>\nendobj\n"); offsets.Add(ms.Position); WriteAscii(ms, $"{fontId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n"); for (var i = 0; i < pages.Count; i++) { var page = pages[i]; var pageId = pageIds[i]; var contentId = contentIds[i]; var imageId = imageIds[i]; var contentBytes = Encoding.ASCII.GetBytes(page.ContentStream); var imageBytes = FlateEncode(page.ImageRgb); offsets.Add(ms.Position); WriteAscii(ms, $"{pageId} 0 obj\n" + $"<< /Type /Page /Parent {pagesId} 0 R " + $"/MediaBox [0 0 {PageWidth} {PageHeight}] " + $"/Contents {contentId} 0 R " + $"/Resources << /Font << /F1 {fontId} 0 R >> /XObject << /Im1 {imageId} 0 R >> >> >>\n" + "endobj\n"); offsets.Add(ms.Position); WriteAscii(ms, $"{contentId} 0 obj\n<< /Length {contentBytes.Length} >>\nstream\n"); ms.Write(contentBytes); WriteAscii(ms, "\nendstream\nendobj\n"); offsets.Add(ms.Position); WriteAscii(ms, $"{imageId} 0 obj\n" + $"<< /Type /XObject /Subtype /Image " + $"/Width {page.ImageWidth} /Height {page.ImageHeight} " + "/ColorSpace /DeviceRGB /BitsPerComponent 8 " + $"/Filter /FlateDecode /Length {imageBytes.Length} >>\nstream\n"); ms.Write(imageBytes); WriteAscii(ms, "\nendstream\nendobj\n"); } var xrefOffset = ms.Position; WriteAscii(ms, "xref\n"); WriteAscii(ms, $"0 {offsets.Count + 1}\n"); WriteAscii(ms, "0000000000 65535 f \n"); foreach (var offset in offsets) WriteAscii(ms, $"{offset:D10} 00000 n \n"); WriteAscii(ms, "trailer\n" + $"<< /Size {offsets.Count + 1} /Root {catalogId} 0 R >>\n" + "startxref\n" + $"{xrefOffset}\n" + "%%EOF\n"); return ms.ToArray(); } private static void WriteAscii(Stream stream, string text) => stream.Write(Encoding.ASCII.GetBytes(text)); private static byte[] FlateEncode(byte[] data) { using var output = new MemoryStream(); using (var deflate = new ZLibStream(output, CompressionLevel.Optimal, leaveOpen: true)) deflate.Write(data, 0, data.Length); return output.ToArray(); } private static string EscapePdfString(string value) => value.Replace("\\", "\\\\", StringComparison.Ordinal) .Replace("(", "\\(", StringComparison.Ordinal) .Replace(")", "\\)", StringComparison.Ordinal); }