// Package pdf writes the one kind of PDF the label printer needs: a document // whose every page is a single full-bleed image at an exact physical size. // // Written by hand rather than through a library for two reasons. The build is // pure Go with no room for cgo, and the need is tiny: the pages arrive as // PNGs rasterised by the SAME canvas renderer the designer's preview uses, so // this file only has to carry pixels to paper without touching them. Fonts, // vectors, compression profiles — all already decided upstream. // // The images are stored as 8-bit DeviceGray with FlateDecode: labels are // monochrome, grey keeps antialiased text edges smooth on a 300 dpi thermal // head, and flate is lossless — JPEG artefacts around small print are exactly // what a QSL label cannot afford. package pdf import ( "bytes" "compress/zlib" "fmt" "image/png" ) const mmToPt = 72.0 / 25.4 // Doc accumulates pages; Bytes() renders the file. type Doc struct { pages []pageData } type pageData struct { wPt, hPt float64 imgW int imgH int gray []byte // zlib-compressed 8-bit samples } // AddImagePage appends one page of wMm×hMm entirely covered by the PNG. // The PNG's aspect ratio is not checked against the page's: the caller // rasterised it AT this size, and a mismatch would be its bug to see. func (d *Doc) AddImagePage(pngBytes []byte, wMm, hMm float64) error { img, err := png.Decode(bytes.NewReader(pngBytes)) if err != nil { return fmt.Errorf("page image: %w", err) } b := img.Bounds() w, h := b.Dx(), b.Dy() if w <= 0 || h <= 0 { return fmt.Errorf("page image is empty") } // To 8-bit grey. Luminance weights, not an average: blue text on a designer // screen should darken the way a photocopier would darken it. gray := make([]byte, w*h) i := 0 for y := b.Min.Y; y < b.Max.Y; y++ { for x := b.Min.X; x < b.Max.X; x++ { r, g, bb, _ := img.At(x, y).RGBA() gray[i] = byte((299*r + 587*g + 114*bb) / 1000 >> 8) i++ } } var buf bytes.Buffer zw := zlib.NewWriter(&buf) if _, err := zw.Write(gray); err != nil { return err } if err := zw.Close(); err != nil { return err } d.pages = append(d.pages, pageData{ wPt: wMm * mmToPt, hPt: hMm * mmToPt, imgW: w, imgH: h, gray: buf.Bytes(), }) return nil } // Bytes renders the whole document. func (d *Doc) Bytes() ([]byte, error) { if len(d.pages) == 0 { return nil, fmt.Errorf("no pages") } var out bytes.Buffer offsets := []int{0} // object 0 is the free-list head obj := func(body func()) int { offsets = append(offsets, out.Len()) n := len(offsets) - 1 fmt.Fprintf(&out, "%d 0 obj\n", n) body() out.WriteString("endobj\n") return n } out.WriteString("%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") // Objects 1 (catalog) and 2 (pages) reference their children by number, so // the numbering is laid out first: 3 objects per page after the two roots. nPages := len(d.pages) pageObj := func(i int) int { return 3 + i*3 } obj(func() { out.WriteString("<< /Type /Catalog /Pages 2 0 R >>\n") }) // 1 obj(func() { // 2 out.WriteString("<< /Type /Pages /Kids [") for i := 0; i < nPages; i++ { fmt.Fprintf(&out, "%d 0 R ", pageObj(i)) } fmt.Fprintf(&out, "] /Count %d >>\n", nPages) }) for i, p := range d.pages { content := fmt.Sprintf("q %.4f 0 0 %.4f 0 0 cm /Im0 Do Q", p.wPt, p.hPt) obj(func() { // page fmt.Fprintf(&out, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.4f %.4f] /Contents %d 0 R /Resources << /XObject << /Im0 %d 0 R >> >> >>\n", p.wPt, p.hPt, pageObj(i)+1, pageObj(i)+2) }) obj(func() { // contents fmt.Fprintf(&out, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content) }) obj(func() { // image fmt.Fprintf(&out, "<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length %d >>\nstream\n", p.imgW, p.imgH, len(p.gray)) out.Write(p.gray) out.WriteString("\nendstream\n") }) } xref := out.Len() fmt.Fprintf(&out, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)) for _, off := range offsets[1:] { fmt.Fprintf(&out, "%010d 00000 n \n", off) } fmt.Fprintf(&out, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref) return out.Bytes(), nil }