InvoicePdf.java

package com.rbctcsworld.ecommerce.order;

import com.rbctcsworld.ecommerce.order.OrderDtos.OrderLine;
import com.rbctcsworld.ecommerce.order.OrderDtos.OrderResponse;

import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/**
 * The invoice of an order as a PDF, written by hand (no library): the PDF is text, a coloured band and thin rules,
 * which needs only the two standard fonts every viewer has (Helvetica and Helvetica-Bold).
 *
 * Layout: US Letter, 50 pt margins. Page 1 has the header band, order facts and the address; the item table
 * continues on more pages when there are many lines; the totals follow the last line.
 * Text is Latin-1; anything else is printed as "?" so a customer name can never break the file.
 */
public final class InvoicePdf {

    static final int PAGE_W = 612;
    static final int PAGE_H = 792;
    static final int LINES_FIRST = 22;
    static final int LINES_NEXT = 40;
    static final int NAME_MAX = 38;

    private InvoicePdf() {
    }

    /** Money as printed on the invoice: $1234.50 */
    static String money(BigDecimal v) {
        return "$" + (v == null ? BigDecimal.ZERO : v).setScale(2, RoundingMode.HALF_UP).toPlainString();
    }

    /** Text safe inside a PDF string: Latin-1 only, with \ ( ) escaped. */
    static String escape(String s) {
        StringBuilder b = new StringBuilder();
        for (char c : (s == null ? "" : s).toCharArray()) {
            if (c == '\\' || c == '(' || c == ')') b.append('\\').append(c);
            else if (c < 32 || c > 255) b.append('?');
            else b.append(c);
        }
        return b.toString();
    }

    /** Helvetica widths in 1/1000 em for the characters of money and numbers; the average for the rest. */
    static double width(String s, double size) {
        double w = 0;
        for (char c : s.toCharArray()) w += (Character.isDigit(c) || c == '$') ? 556 : (c == '.' || c == ',' || c == ' ' ? 278 : c == '-' ? 333 : 540);
        return w * size / 1000.0;
    }

    private static String cut(String s, int max) {
        return s.length() <= max ? s : s.substring(0, max - 1) + "...";
    }

    /** Builds one page's content stream. */
    private static final class Page {
        final StringBuilder out = new StringBuilder();

        void text(double x, double y, String font, double size, String s) {
            out.append("BT /").append(font).append(' ').append(size).append(" Tf ").append(x).append(' ').append(y)
                    .append(" Td (").append(escape(s)).append(") Tj ET\n");
        }

        void right(double xRight, double y, String font, double size, String s) {
            text(xRight - width(s, size), y, font, size, s);
        }

        void rect(double x, double y, double w, double h, String rgb) {
            out.append(rgb).append(" rg ").append(x).append(' ').append(y).append(' ').append(w).append(' ').append(h).append(" re f 0 g\n");
        }

        void rule(double y) {
            rect(50, y, 512, 0.6, "0.75 0.78 0.80");
        }
    }

    public static byte[] render(OrderResponse o) {
        List<Page> pages = new ArrayList<>();
        Page p = new Page();
        pages.add(p);

        p.rect(0, 722, PAGE_W, 70, "0.71 0.33 0.04");
        p.text(50, 752, "F2", 24, "RbcTcsWorld");
        p.text(50, 733, "F1", 10, "Everyday goods, delivered to your door");
        p.right(562, 752, "F2", 22, "INVOICE");

        p.text(50, 690, "F2", 11, "Order " + o.orderNumber());
        p.text(50, 674, "F1", 10, "Date: " + (o.createdAt() == null ? "" : o.createdAt().toLocalDate()));
        p.text(50, 660, "F1", 10, "Status: " + o.status() + (o.paidAt() == null ? "  (payment due)" : "  (paid " + o.paidAt().toLocalDate() + ")"));
        ShippingAddress a = o.shippingAddress();
        if (a != null) {
            p.text(330, 690, "F2", 11, "Ship to");
            p.text(330, 674, "F1", 10, a.fullName());
            p.text(330, 660, "F1", 10, a.line1() + (a.line2() == null ? "" : ", " + a.line2()));
            p.text(330, 646, "F1", 10, a.city() + ", " + a.state() + " " + a.zip());
        }

        double y = 610;
        header(p, y);
        y -= 20;
        int onPage = 0;
        int limit = LINES_FIRST;
        for (OrderLine l : o.items()) {
            if (onPage == limit) {
                p = new Page();
                pages.add(p);
                y = 740;
                header(p, y);
                y -= 20;
                onPage = 0;
                limit = LINES_NEXT;
            }
            p.text(50, y, "F1", 9, cut(l.sku() == null ? "" : l.sku(), 18));
            p.text(140, y, "F1", 9, cut(l.name() == null ? "" : l.name(), NAME_MAX));
            p.right(400, y, "F1", 9, String.valueOf(l.quantity()));
            p.right(480, y, "F1", 9, money(l.unitPrice()));
            p.right(562, y, "F1", 9, money(l.lineTotal()));
            y -= 16;
            onPage++;
        }

        if (y < 180) {
            p = new Page();
            pages.add(p);
            y = 740;
        }
        p.rule(y + 8);
        y -= 10;
        y = total(p, y, "Subtotal", money(o.subtotal()), false);
        if (o.discount() != null && o.discount().signum() > 0) {
            y = total(p, y, "Discount" + (o.couponCode() == null ? "" : " (" + o.couponCode() + ")"), "-" + money(o.discount()), false);
        }
        y = total(p, y, "Shipping", money(o.shippingFee()), false);
        y = total(p, y, "Tax", money(o.tax()), false);
        total(p, y - 2, "Total", money(o.total()), true);

        int last = pages.size();
        for (int i = 0; i < last; i++) {
            pages.get(i).text(50, 30, "F1", 8, "RbcTcsWorld E-Commerce  |  " + o.orderNumber() + "  |  page " + (i + 1) + " of " + last);
        }
        return assemble(pages);
    }

    private static void header(Page p, double y) {
        p.rect(50, y - 6, 512, 18, "0.91 0.94 0.93");
        p.text(54, y, "F2", 9, "SKU");
        p.text(140, y, "F2", 9, "Item");
        p.right(400, y, "F2", 9, "Qty");
        p.right(480, y, "F2", 9, "Unit price");
        p.right(562, y, "F2", 9, "Total");
    }

    private static double total(Page p, double y, String label, String value, boolean bold) {
        String font = bold ? "F2" : "F1";
        double size = bold ? 12 : 10;
        p.text(380 - (bold ? 0 : 0), y, font, size, label);
        p.right(562, y, font, size, value);
        return y - (bold ? 18 : 15);
    }

    /** Objects: 1 catalog, 2 pages, 3 Helvetica, 4 Helvetica-Bold, then (content, page) per page. */
    private static byte[] assemble(List<Page> pages) {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        List<Integer> offsets = new ArrayList<>();
        write(out, "%PDF-1.4\n");
        int n = pages.size();
        StringBuilder kids = new StringBuilder();
        for (int i = 0; i < n; i++) kids.append(6 + 2 * i).append(" 0 R ");

        obj(out, offsets, "<< /Type /Catalog /Pages 2 0 R >>");
        obj(out, offsets, "<< /Type /Pages /Kids [" + kids.toString().trim() + "] /Count " + n + " >>");
        obj(out, offsets, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>");
        obj(out, offsets, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>");
        for (int i = 0; i < n; i++) {
            String content = pages.get(i).out.toString();
            int len = content.getBytes(StandardCharsets.ISO_8859_1).length;
            obj(out, offsets, "<< /Length " + len + " >>\nstream\n" + content + "endstream");
            obj(out, offsets, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 " + PAGE_W + " " + PAGE_H + "] /Contents " + (5 + 2 * i)
                    + " 0 R /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> >>");
        }
        int xref = out.size();
        write(out, "xref\n0 " + (offsets.size() + 1) + "\n0000000000 65535 f \n");
        for (int off : offsets) write(out, String.format("%010d 00000 n \n", off));
        write(out, "trailer\n<< /Size " + (offsets.size() + 1) + " /Root 1 0 R >>\nstartxref\n" + xref + "\n%%EOF\n");
        return out.toByteArray();
    }

    private static void obj(ByteArrayOutputStream out, List<Integer> offsets, String body) {
        offsets.add(out.size());
        write(out, offsets.size() + " 0 obj\n" + body + "\nendobj\n");
    }

    private static void write(ByteArrayOutputStream out, String s) {
        out.writeBytes(s.getBytes(StandardCharsets.ISO_8859_1));
    }
}