aboutsummaryrefslogtreecommitdiff
path: root/overlay.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-04-01 00:00:00 +0000
committerLena <lena@omega>2026-04-01 00:00:00 +0000
commit459f5935aab505cdb8174e5f9f04a9a3c426f37e (patch)
tree525a5b39b05ef2c799b953ad3969e869ab484745 /overlay.go
downloadpdfstamp-459f5935aab505cdb8174e5f9f04a9a3c426f37e.tar.gz
Enter pdfstampHEADmaster
Terminal-only PDF stamp picker. Renders a coarse page preview in the terminal, places text and image stamps with the keyboard, saves the layout as a JSON plan, and burns it into an output PDF. MuPDF (via go-fitz) renders the preview; pdfcpu overlays the generated stamp PDF onto the source 1:1 and validates the result.
Diffstat (limited to 'overlay.go')
-rw-r--r--overlay.go355
1 files changed, 355 insertions, 0 deletions
diff --git a/overlay.go b/overlay.go
new file mode 100644
index 0000000..f7e6221
--- /dev/null
+++ b/overlay.go
@@ -0,0 +1,355 @@
+package main
+
+import (
+ "bytes"
+ "compress/zlib"
+ "fmt"
+ "image"
+ _ "image/jpeg"
+ _ "image/png"
+ "io"
+ "math"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types"
+)
+
+// Image limits bound the decoded working set, not the compressed file size.
+const (
+ maxImagePixels = 50_000_000
+ maxTotalImagePixels = 100_000_000
+)
+
+// buildOverlay constructs an in-memory PDF with one page per source page,
+// each sized to the source page and drawing that page's stamps at absolute
+// coordinates (bottom-left origin, points). It is meant to be overlaid 1:1
+// onto the source by pdfcpu (multi-stamp, pos:c, sc:1 abs).
+func buildOverlay(p *plan, dims []types.Dim) ([]byte, error) {
+ n := len(dims)
+ byPage := map[int][]*stamp{}
+ haveText := false
+ for i := range p.Stamps {
+ s := &p.Stamps[i]
+ if s.Type == typeText {
+ haveText = true
+ }
+ for _, pg := range s.Pages {
+ if pg >= 1 && pg <= n {
+ byPage[pg] = append(byPage[pg], s)
+ }
+ }
+ }
+
+ w := &pdfWriter{}
+ catalog := w.reserve()
+ pages := w.reserve()
+
+ // Shared font (Helvetica base-14, no embedding).
+ fontNum := 0
+ if haveText {
+ fontNum = w.add([]byte("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"))
+ }
+
+ // One ExtGState per distinct opacity, created before the shared Resources
+ // dict so that dict lists every ExtGState the pages reference.
+ gs := map[string]string{} // opacity key -> ExtGState name
+ var gsEntries []string
+ for i := range p.Stamps {
+ k := opacityKey(p.Stamps[i].Opacity)
+ if _, ok := gs[k]; ok {
+ continue
+ }
+ name := fmt.Sprintf("GS%d", len(gs))
+ num := w.add([]byte(fmt.Sprintf("<< /Type /ExtGState /ca %s /CA %s >>", k, k)))
+ gs[k] = name
+ gsEntries = append(gsEntries, fmt.Sprintf("/%s %d 0 R", name, num))
+ }
+
+ // One image XObject per distinct src.
+ imgs := map[string]imageRes{}
+ var xobjEntries []string
+ remainingPixels := int64(maxTotalImagePixels)
+ for i := range p.Stamps {
+ s := &p.Stamps[i]
+ if s.Type != typeImage {
+ continue
+ }
+ if _, ok := imgs[s.Src]; ok {
+ continue
+ }
+ res, pixels, err := embedImage(w, s.Src, remainingPixels)
+ if err != nil {
+ return nil, fmt.Errorf("image stamp %q: %w", s.Src, err)
+ }
+ remainingPixels -= pixels
+ res.name = fmt.Sprintf("Im%d", len(imgs))
+ imgs[s.Src] = res
+ xobjEntries = append(xobjEntries, fmt.Sprintf("/%s %d 0 R", res.name, res.num))
+ }
+
+ var rb strings.Builder
+ rb.WriteString("<< /ProcSet [/PDF /Text /ImageB /ImageC /ImageI]")
+ if fontNum != 0 {
+ fmt.Fprintf(&rb, " /Font << /F1 %d 0 R >>", fontNum)
+ }
+ if len(gsEntries) > 0 {
+ fmt.Fprintf(&rb, " /ExtGState << %s >>", strings.Join(gsEntries, " "))
+ }
+ if len(xobjEntries) > 0 {
+ fmt.Fprintf(&rb, " /XObject << %s >>", strings.Join(xobjEntries, " "))
+ }
+ rb.WriteString(" >>")
+ resNum := w.add([]byte(rb.String()))
+
+ pageNums := make([]int, n)
+ for i := 0; i < n; i++ {
+ content := pageContent(byPage[i+1], gs, imgs)
+ cNum := w.add(makeStream("", content))
+ dict := fmt.Sprintf(
+ "<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %s %s] /Resources %d 0 R /Contents %d 0 R >>",
+ pages, ff(dims[i].Width), ff(dims[i].Height), resNum, cNum)
+ pageNums[i] = w.add([]byte(dict))
+ }
+
+ kids := make([]string, n)
+ for i, num := range pageNums {
+ kids[i] = fmt.Sprintf("%d 0 R", num)
+ }
+ w.set(pages, []byte(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), n)))
+ w.set(catalog, []byte(fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pages)))
+
+ return w.bytes(), nil
+}
+
+// pageContent renders all stamps for one page into a content stream.
+func pageContent(stamps []*stamp, gs map[string]string, imgs map[string]imageRes) []byte {
+ var b bytes.Buffer
+ for _, s := range stamps {
+ state := gs[opacityKey(s.Opacity)]
+ switch s.Type {
+ case typeText:
+ size, dx, dy := fitText(s.Content, s.Rect.W, s.Rect.H)
+ // cm: translate to the rect origin; Td centers the line in the box.
+ fmt.Fprintf(&b, "q /%s gs 1 0 0 1 %s %s cm BT /F1 %s Tf 0 g %s %s Td (%s) Tj ET Q\n",
+ state, ff(s.Rect.X), ff(s.Rect.Y),
+ ff(size), ff(dx), ff(dy), textEscaper.Replace(s.Content))
+ case typeImage:
+ res := imgs[s.Src]
+ sc := math.Min(s.Rect.W/float64(res.w), s.Rect.H/float64(res.h))
+ fmt.Fprintf(&b, "q /%s gs %s 0 0 %s %s %s cm /%s Do Q\n",
+ state, ff(float64(res.w)*sc), ff(float64(res.h)*sc),
+ ff(s.Rect.X), ff(s.Rect.Y), res.name)
+ }
+ }
+ return b.Bytes()
+}
+
+// opacityKey formats an opacity for the ExtGState dict; stamps share a state
+// when they format identically.
+func opacityKey(v float64) string { return strconv.FormatFloat(v, 'f', 3, 64) }
+
+// helvWidths holds Helvetica glyph widths for printable ASCII (0x20..0x7e)
+// in 1/1000 em, from the Adobe core-14 AFM metrics. Validation restricts
+// text content to this range, so exact fitting needs no embedded font.
+var helvWidths = [95]int{
+ 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,
+}
+
+// helvCapHeight is Helvetica's cap height in em units (AFM CapHeight 718).
+const helvCapHeight = 0.718
+
+// textWidth returns the width of s in em units (multiply by the font size).
+func textWidth(s string) float64 {
+ w := 0
+ for i := 0; i < len(s); i++ {
+ w += helvWidths[s[i]-0x20]
+ }
+ return float64(w) / 1000
+}
+
+// fitText sizes text to its rectangle using the real metrics: height drives
+// the size, shrunk if the line would overflow the width. dx/dy center the
+// line inside the box (offsets from the rect origin).
+func fitText(text string, w, h float64) (size, dx, dy float64) {
+ uw := textWidth(text)
+ size = 0.72 * h
+ if uw*size > w {
+ size = w / uw
+ }
+ return size, (w - uw*size) / 2, (h - helvCapHeight*size) / 2
+}
+
+// textEscaper escapes PDF string delimiters; validation already restricts
+// content to printable ASCII.
+var textEscaper = strings.NewReplacer("\\", "\\\\", "(", "\\(", ")", "\\)")
+
+// ff formats a float for PDF content with trailing zeros trimmed.
+func ff(v float64) string {
+ s := strconv.FormatFloat(v, 'f', 4, 64)
+ if strings.Contains(s, ".") {
+ s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
+ }
+ if s == "-0" {
+ s = "0"
+ }
+ return s
+}
+
+type imageRes struct {
+ name string // XObject name in the resources dict
+ num int // image XObject number
+ w, h int // pixel dimensions
+}
+
+// embedImage decodes a PNG/JPEG and writes an RGB image XObject (plus an
+// 8-bit DeviceGray SMask when the image has transparency). Header dimensions
+// are checked before decoding so a huge file fails fast, not out of memory.
+func embedImage(w *pdfWriter, path string, remainingPixels int64) (imageRes, int64, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return imageRes{}, 0, err
+ }
+ defer f.Close()
+ cfg, _, err := decodeImageConfig(f)
+ if err != nil {
+ return imageRes{}, 0, err
+ }
+ pixels := int64(cfg.Width) * int64(cfg.Height)
+ if pixels > remainingPixels {
+ return imageRes{}, 0, fmt.Errorf("images exceed the total limit of %d pixels", maxTotalImagePixels)
+ }
+ if _, err := f.Seek(0, io.SeekStart); err != nil {
+ return imageRes{}, 0, err
+ }
+ img, format, err := image.Decode(f)
+ if err != nil {
+ return imageRes{}, 0, err
+ }
+ if !supportedImageFormat(format) {
+ return imageRes{}, 0, fmt.Errorf("image format %q is not supported (want PNG or JPEG)", format)
+ }
+ bnds := img.Bounds()
+ pw, ph := bnds.Dx(), bnds.Dy()
+ if pw != cfg.Width || ph != cfg.Height {
+ return imageRes{}, 0, fmt.Errorf("decoded dimensions %dx%d do not match header %dx%d", pw, ph, cfg.Width, cfg.Height)
+ }
+ rgb := make([]byte, 0, 3*pw*ph)
+ hasAlpha := true
+ if opaque, ok := img.(interface{ Opaque() bool }); ok {
+ hasAlpha = !opaque.Opaque()
+ }
+ var alpha []byte
+ if hasAlpha {
+ alpha = make([]byte, 0, pw*ph)
+ }
+ for y := bnds.Min.Y; y < bnds.Max.Y; y++ {
+ for x := bnds.Min.X; x < bnds.Max.X; x++ {
+ r, g, b, a := img.At(x, y).RGBA() // 16-bit, alpha-premultiplied
+ a8 := byte(a >> 8)
+ // Un-premultiply so the SMask carries the transparency.
+ if a == 0 {
+ rgb = append(rgb, 0, 0, 0)
+ } else {
+ rgb = append(rgb, byte((r*0xffff/a)>>8), byte((g*0xffff/a)>>8), byte((b*0xffff/a)>>8))
+ }
+ if hasAlpha {
+ alpha = append(alpha, a8)
+ }
+ }
+ }
+
+ smask := 0
+ if hasAlpha {
+ smask = w.add(makeStream(
+ fmt.Sprintf("/Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode", pw, ph),
+ flate(alpha)))
+ }
+ dict := fmt.Sprintf("/Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode", pw, ph)
+ if smask != 0 {
+ dict += fmt.Sprintf(" /SMask %d 0 R", smask)
+ }
+ num := w.add(makeStream(dict, flate(rgb)))
+ return imageRes{num: num, w: pw, h: ph}, pixels, nil
+}
+
+// decodeImageConfig reads an image header and enforces maxImagePixels. It is
+// shared by embedImage and the picker's pick-time check so both agree.
+func decodeImageConfig(f *os.File) (image.Config, string, error) {
+ cfg, format, err := image.DecodeConfig(f)
+ if err != nil {
+ return cfg, "", err
+ }
+ if !supportedImageFormat(format) {
+ return cfg, format, fmt.Errorf("image format %q is not supported (want PNG or JPEG)", format)
+ }
+ if cfg.Width < 1 || cfg.Height < 1 || int64(cfg.Width) > int64(maxImagePixels)/int64(cfg.Height) {
+ return cfg, format, fmt.Errorf("image is %dx%d; the limit is %d pixels", cfg.Width, cfg.Height, maxImagePixels)
+ }
+ return cfg, format, nil
+}
+
+func supportedImageFormat(format string) bool {
+ return format == "png" || format == "jpeg"
+}
+
+func flate(b []byte) []byte {
+ var buf bytes.Buffer
+ zw := zlib.NewWriter(&buf)
+ zw.Write(b)
+ zw.Close()
+ return buf.Bytes()
+}
+
+// --- minimal PDF object writer ---
+
+type pdfWriter struct {
+ objs [][]byte // objs[i] is the body of object number i+1
+}
+
+func (w *pdfWriter) reserve() int { w.objs = append(w.objs, nil); return len(w.objs) }
+func (w *pdfWriter) add(body []byte) int {
+ w.objs = append(w.objs, body)
+ return len(w.objs)
+}
+func (w *pdfWriter) set(num int, body []byte) { w.objs[num-1] = body }
+
+// makeStream builds a stream object body: dict (without /Length) + stream data.
+func makeStream(dictInner string, data []byte) []byte {
+ var b bytes.Buffer
+ if dictInner == "" {
+ fmt.Fprintf(&b, "<< /Length %d >>\nstream\n", len(data))
+ } else {
+ fmt.Fprintf(&b, "<< %s /Length %d >>\nstream\n", dictInner, len(data))
+ }
+ b.Write(data)
+ b.WriteString("\nendstream")
+ return b.Bytes()
+}
+
+// bytes serializes all objects with a classic cross-reference table.
+func (w *pdfWriter) bytes() []byte {
+ var b bytes.Buffer
+ b.WriteString("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n")
+ offsets := make([]int, len(w.objs))
+ for i, body := range w.objs {
+ offsets[i] = b.Len()
+ fmt.Fprintf(&b, "%d 0 obj\n", i+1)
+ b.Write(body)
+ b.WriteString("\nendobj\n")
+ }
+ xref := b.Len()
+ fmt.Fprintf(&b, "xref\n0 %d\n", len(w.objs)+1)
+ b.WriteString("0000000000 65535 f \n")
+ for _, off := range offsets {
+ fmt.Fprintf(&b, "%010d 00000 n \n", off)
+ }
+ fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(w.objs)+1, xref)
+ return b.Bytes()
+}