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
}