From 459f5935aab505cdb8174e5f9f04a9a3c426f37e Mon Sep 17 00:00:00 2001 From: Lena Date: Wed, 1 Apr 2026 00:00:00 +0000 Subject: Enter pdfstamp 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. --- overlay.go | 355 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 overlay.go (limited to 'overlay.go') 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() +} -- cgit v1.2.3