Files
OpsLog/internal/pdf/pdf.go
T
rouggy 5ee0ade54b feat(labels): print the labels — queue, address review, PDF, log update
The printing session in three steps. The worklist is the paper queue (ADIF
qsl_sent R/Q), grouped by callsign since several QSOs of one station share a
card. Each recipient is reviewed before anything prints: routing first — via
manager when qsl_via says so, direct when an address is known, bureau otherwise
— then the address itself, editable and fetchable from QRZ (the MANAGER's
address when routing says via). What is printed is the reviewed text verbatim,
not a re-resolution that could differ from what was checked.

One PDF per label kind, never one file for all: a roll printer holds one stock
at a time, and a file mixing 29 mm addresses with 62 mm QSO labels could not be
printed at all. Pages are rasterised by the designer's own renderer at the
stock's dpi and carried into the PDF untouched — internal/pdf is a hand-written
image-page writer (DeviceGray + flate: monochrome, lossless, no cgo) because
that is the entire need.

Bureau stations get no address label (no envelope); return labels are printed
one per envelope. Finishing offers the log update: QSL_SENT=Y, the chosen date,
via B or D per routing — through the same BulkUpdateQSL the paper view uses.
2026-08-28 19:58:37 +02:00

132 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}