package lib import ( "archive/zip" "bytes" "fmt" "image" "image/color" "image/draw" "image/gif" "image/jpeg" "image/png" "io" "regexp" "strings" "github.com/pdfcpu/pdfcpu/pkg/api" "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types" ) // Watermark applies per-request watermarks at serve time. // Stored files are always clean originals; watermarks are injected on read. // // Supported types: // - PDF: diagonal text watermark (pdfcpu) // - Word (.docx): watermark shape in header // - Images: text overlay in bottom-right // - Other: pass-through unchanged // Watermark dispatches to the appropriate watermarking function based on MIME type. func Watermark(in []byte, mimeType string, label string) ([]byte, error) { switch mimeType { case "application/pdf": return WatermarkPDF(in, label) case "image/jpeg", "image/png", "image/gif": return WatermarkImage(in, mimeType, label) case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": return WatermarkDOCX(in, label) default: return in, nil // pass through unsupported types } } // WatermarkPDF applies a diagonal text watermark to every page of a PDF. // Uses pdfcpu for PDF manipulation. // Label format: "CONFIDENTIAL — {UserName} — {Date} — {ProjectName}" func WatermarkPDF(in []byte, label string) ([]byte, error) { if len(in) == 0 { return nil, fmt.Errorf("empty input") } // Build watermark description string for pdfcpu // Format: "text, fontname:Helvetica, fontsize:36, color:0.5 0.5 0.5, opacity:0.3, rotation:45, diagonal:1, scale:1 abs" wmDesc := fmt.Sprintf("%s, fontname:Helvetica, fontsize:36, color:0.5 0.5 0.5, opacity:0.3, rotation:45, diagonal:1, scale:1 abs", escapeWatermarkText(label)) wm, err := api.TextWatermark(wmDesc, "", true, false, types.POINTS) if err != nil { return nil, fmt.Errorf("create watermark: %w", err) } // Apply watermark to all pages var out bytes.Buffer inReader := bytes.NewReader(in) conf := model.NewDefaultConfiguration() conf.ValidationMode = model.ValidationRelaxed if err := api.AddWatermarks(inReader, &out, nil, wm, conf); err != nil { return nil, fmt.Errorf("apply watermark: %w", err) } return out.Bytes(), nil } // escapeWatermarkText escapes special characters for pdfcpu watermark text. func escapeWatermarkText(text string) string { // Escape commas and colons which have special meaning in pdfcpu text = strings.ReplaceAll(text, ",", "\\,") text = strings.ReplaceAll(text, ":", "\\:") return text } // WatermarkImage applies a text watermark to an image. // Supports JPEG, PNG, and GIF (first frame only for GIF). // The watermark is placed in the bottom-right corner with semi-transparent white text and dark shadow. func WatermarkImage(in []byte, mimeType string, label string) ([]byte, error) { if len(in) == 0 { return nil, fmt.Errorf("empty input") } reader := bytes.NewReader(in) // Handle GIF specially (only watermark first frame) if mimeType == "image/gif" { return watermarkGIF(reader, label) } // Decode image img, format, err := image.Decode(reader) if err != nil { return nil, fmt.Errorf("decode image: %w", err) } // Create a new RGBA image to draw on bounds := img.Bounds() rgba := image.NewRGBA(bounds) draw.Draw(rgba, bounds, img, bounds.Min, draw.Src) // Draw watermark text drawWatermarkText(rgba, label) // Encode output var out bytes.Buffer switch format { case "jpeg": if err := jpeg.Encode(&out, rgba, &jpeg.Options{Quality: 90}); err != nil { return nil, fmt.Errorf("encode jpeg: %w", err) } case "png": if err := png.Encode(&out, rgba); err != nil { return nil, fmt.Errorf("encode png: %w", err) } default: // For other formats decoded by image.Decode, output as PNG if err := png.Encode(&out, rgba); err != nil { return nil, fmt.Errorf("encode png: %w", err) } } return out.Bytes(), nil } // watermarkGIF handles GIF watermarking (first frame only). func watermarkGIF(reader io.Reader, label string) ([]byte, error) { g, err := gif.DecodeAll(reader) if err != nil { return nil, fmt.Errorf("decode gif: %w", err) } if len(g.Image) == 0 { return nil, fmt.Errorf("gif has no frames") } // Watermark first frame firstFrame := g.Image[0] bounds := firstFrame.Bounds() rgba := image.NewRGBA(bounds) draw.Draw(rgba, bounds, firstFrame, bounds.Min, draw.Src) drawWatermarkText(rgba, label) // Convert back to paletted image paletted := image.NewPaletted(bounds, firstFrame.Palette) draw.Draw(paletted, bounds, rgba, bounds.Min, draw.Src) g.Image[0] = paletted var out bytes.Buffer if err := gif.EncodeAll(&out, g); err != nil { return nil, fmt.Errorf("encode gif: %w", err) } return out.Bytes(), nil } // drawWatermarkText draws watermark text on an RGBA image. // Uses a simple pixel-based text rendering (8x8 bitmap font style). // Text is placed in bottom-right corner with shadow effect. func drawWatermarkText(img *image.RGBA, label string) { bounds := img.Bounds() width := bounds.Dx() height := bounds.Dy() // Calculate font scale based on image size scale := 1 if width > 1000 { scale = 2 } if width > 2000 { scale = 3 } // Measure text charWidth := 6 * scale charHeight := 10 * scale textWidth := len(label) * charWidth textHeight := charHeight // Position: bottom-right with padding padding := 10 * scale x := width - textWidth - padding y := height - textHeight - padding // Clamp to image bounds if x < padding { x = padding } if y < padding { y = padding } // Draw shadow (dark, offset by 1-2 pixels) shadowColor := color.RGBA{0, 0, 0, 180} for i := 1; i <= scale; i++ { drawText(img, label, x+i, y+i, charWidth, charHeight, shadowColor, scale) } // Draw main text (semi-transparent white) textColor := color.RGBA{255, 255, 255, 200} drawText(img, label, x, y, charWidth, charHeight, textColor, scale) } // drawText draws text using a simple bitmap font approach. // This is a basic implementation that renders readable ASCII text. func drawText(img *image.RGBA, text string, startX, startY, charWidth, charHeight int, c color.Color, scale int) { bounds := img.Bounds() for i, ch := range text { x := startX + i*charWidth if x >= bounds.Max.X { break } // Get character bitmap bitmap := getCharBitmap(ch) if bitmap == nil { continue } // Draw character for row := 0; row < 8; row++ { for col := 0; col < 5; col++ { if bitmap[row]&(1<<(4-col)) != 0 { // Draw scaled pixel for sy := 0; sy < scale; sy++ { for sx := 0; sx < scale; sx++ { px := x + col*scale + sx py := startY + row*scale + sy if px >= bounds.Min.X && px < bounds.Max.X && py >= bounds.Min.Y && py < bounds.Max.Y { img.Set(px, py, c) } } } } } } } } // getCharBitmap returns an 8-row bitmap for a character (5 bits per row). // Basic ASCII font supporting uppercase, lowercase, digits, and common punctuation. func getCharBitmap(ch rune) []byte { // 5x8 bitmap font - each byte represents one row, 5 bits used fonts := map[rune][]byte{ 'A': {0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11, 0x00}, 'B': {0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E, 0x00}, 'C': {0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E, 0x00}, 'D': {0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E, 0x00}, 'E': {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F, 0x00}, 'F': {0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10, 0x00}, 'G': {0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0E, 0x00}, 'H': {0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11, 0x00}, 'I': {0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00}, 'J': {0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C, 0x00}, 'K': {0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11, 0x00}, 'L': {0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F, 0x00}, 'M': {0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11, 0x00}, 'N': {0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11, 0x00}, 'O': {0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E, 0x00}, 'P': {0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10, 0x00}, 'Q': {0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D, 0x00}, 'R': {0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11, 0x00}, 'S': {0x0E, 0x11, 0x10, 0x0E, 0x01, 0x11, 0x0E, 0x00}, 'T': {0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00}, 'U': {0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E, 0x00}, 'V': {0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04, 0x00}, 'W': {0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11, 0x00}, 'X': {0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11, 0x00}, 'Y': {0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04, 0x00}, 'Z': {0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F, 0x00}, 'a': {0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F, 0x00}, 'b': {0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x1E, 0x00}, 'c': {0x00, 0x00, 0x0E, 0x11, 0x10, 0x11, 0x0E, 0x00}, 'd': {0x01, 0x01, 0x0F, 0x11, 0x11, 0x11, 0x0F, 0x00}, 'e': {0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E, 0x00}, 'f': {0x06, 0x09, 0x08, 0x1C, 0x08, 0x08, 0x08, 0x00}, 'g': {0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x0E, 0x00}, 'h': {0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x11, 0x00}, 'i': {0x04, 0x00, 0x0C, 0x04, 0x04, 0x04, 0x0E, 0x00}, 'j': {0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C, 0x00}, 'k': {0x10, 0x10, 0x12, 0x14, 0x18, 0x14, 0x12, 0x00}, 'l': {0x0C, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00}, 'm': {0x00, 0x00, 0x1A, 0x15, 0x15, 0x15, 0x15, 0x00}, 'n': {0x00, 0x00, 0x1E, 0x11, 0x11, 0x11, 0x11, 0x00}, 'o': {0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E, 0x00}, 'p': {0x00, 0x00, 0x1E, 0x11, 0x1E, 0x10, 0x10, 0x00}, 'q': {0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x01, 0x00}, 'r': {0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x10, 0x00}, 's': {0x00, 0x00, 0x0F, 0x10, 0x0E, 0x01, 0x1E, 0x00}, 't': {0x08, 0x08, 0x1C, 0x08, 0x08, 0x09, 0x06, 0x00}, 'u': {0x00, 0x00, 0x11, 0x11, 0x11, 0x11, 0x0F, 0x00}, 'v': {0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04, 0x00}, 'w': {0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A, 0x00}, 'x': {0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x00}, 'y': {0x00, 0x00, 0x11, 0x11, 0x0F, 0x01, 0x0E, 0x00}, 'z': {0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F, 0x00}, '0': {0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E, 0x00}, '1': {0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00}, '2': {0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F, 0x00}, '3': {0x0E, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0E, 0x00}, '4': {0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02, 0x00}, '5': {0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E, 0x00}, '6': {0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E, 0x00}, '7': {0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08, 0x00}, '8': {0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E, 0x00}, '9': {0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C, 0x00}, ' ': {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, '.': {0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, 0x0C, 0x00}, ',': {0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x02, 0x04}, '-': {0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00, 0x00}, '_': {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x00}, ':': {0x00, 0x0C, 0x0C, 0x00, 0x0C, 0x0C, 0x00, 0x00}, '/': {0x01, 0x02, 0x02, 0x04, 0x08, 0x08, 0x10, 0x00}, '@': {0x0E, 0x11, 0x17, 0x15, 0x17, 0x10, 0x0E, 0x00}, '(': {0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02, 0x00}, ')': {0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08, 0x00}, } if bitmap, ok := fonts[ch]; ok { return bitmap } // Return a small square for unknown characters return []byte{0x00, 0x1F, 0x11, 0x11, 0x11, 0x1F, 0x00, 0x00} } // WatermarkDOCX adds a text watermark to a Word document. // DOCX is a ZIP file; we unzip, modify word/document.xml to add a watermark shape in the header, and rezip. func WatermarkDOCX(in []byte, label string) ([]byte, error) { if len(in) == 0 { return nil, fmt.Errorf("empty input") } reader := bytes.NewReader(in) zipReader, err := zip.NewReader(reader, int64(len(in))) if err != nil { return nil, fmt.Errorf("open zip: %w", err) } var out bytes.Buffer zipWriter := zip.NewWriter(&out) // Track which relationship files we've seen var hasHeader1 bool headerRelId := "rIdWatermarkHeader" // First pass: check if header1.xml exists for _, f := range zipReader.File { if f.Name == "word/header1.xml" { hasHeader1 = true break } } for _, f := range zipReader.File { rc, err := f.Open() if err != nil { return nil, fmt.Errorf("open %s: %w", f.Name, err) } content, err := io.ReadAll(rc) rc.Close() if err != nil { return nil, fmt.Errorf("read %s: %w", f.Name, err) } // Modify content based on file switch f.Name { case "word/document.xml": // Add header reference to document if not already present if !hasHeader1 { content = addHeaderReferenceToDocument(content, headerRelId) } case "word/header1.xml": // Add watermark to existing header content = addWatermarkToHeader(content, label) case "word/_rels/document.xml.rels": // Add relationship for header if we created one if !hasHeader1 { content = addHeaderRelationship(content, headerRelId) } case "[Content_Types].xml": // Ensure header content type is registered if !hasHeader1 { content = ensureHeaderContentType(content) } } // Write modified content w, err := zipWriter.Create(f.Name) if err != nil { return nil, fmt.Errorf("create %s: %w", f.Name, err) } if _, err := w.Write(content); err != nil { return nil, fmt.Errorf("write %s: %w", f.Name, err) } } // If no header existed, create one with the watermark if !hasHeader1 { header := createWatermarkHeader(label) w, err := zipWriter.Create("word/header1.xml") if err != nil { return nil, fmt.Errorf("create header: %w", err) } if _, err := w.Write(header); err != nil { return nil, fmt.Errorf("write header: %w", err) } } if err := zipWriter.Close(); err != nil { return nil, fmt.Errorf("close zip: %w", err) } return out.Bytes(), nil } // createWatermarkHeader creates a new header XML with a diagonal watermark shape. func createWatermarkHeader(label string) []byte { // VML shape for diagonal watermark text // The shape uses a text path to render the watermark diagonally header := fmt.Sprintf(` `, escapeXML(label)) return []byte(header) } // addWatermarkToHeader adds a watermark shape to an existing header. func addWatermarkToHeader(content []byte, label string) []byte { // Insert watermark shape before watermarkShape := fmt.Sprintf(` `, escapeXML(label)) // Find and insert before it str := string(content) if idx := strings.LastIndex(str, ""); idx != -1 { str = str[:idx] + watermarkShape + str[idx:] } return []byte(str) } // addHeaderReferenceToDocument adds a header reference to the document.xml. func addHeaderReferenceToDocument(content []byte, relId string) []byte { str := string(content) // Find the sectPr element and add headerReference // Look for headerRef := fmt.Sprintf(``, relId) sectPrPattern := regexp.MustCompile(`(]*>)`) if sectPrPattern.MatchString(str) { str = sectPrPattern.ReplaceAllString(str, "${1}"+headerRef) } else { // No sectPr exists, add one before sectPr := fmt.Sprintf(`%s`, headerRef) if idx := strings.LastIndex(str, ""); idx != -1 { str = str[:idx] + sectPr + str[idx:] } } // Ensure r namespace is declared if !strings.Contains(str, `xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"`) { str = strings.Replace(str, ``, relId) // Insert before if idx := strings.LastIndex(str, ""); idx != -1 { str = str[:idx] + rel + str[idx:] } return []byte(str) } // ensureHeaderContentType ensures the header content type is in [Content_Types].xml. func ensureHeaderContentType(content []byte) []byte { str := string(content) if strings.Contains(str, "header1.xml") { return content } override := `` if idx := strings.LastIndex(str, ""); idx != -1 { str = str[:idx] + override + str[idx:] } return []byte(str) } // escapeXML escapes special XML characters. func escapeXML(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") s = strings.ReplaceAll(s, "'", "'") s = strings.ReplaceAll(s, "\"", """) return s }