The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
208 lines
5.7 KiB
Go
208 lines
5.7 KiB
Go
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package pdfutils
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"io"
|
|
"math"
|
|
"time"
|
|
|
|
"github.com/pdfcpu/pdfcpu/pkg/api"
|
|
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/types"
|
|
"go.probo.inc/probo/pkg/mail"
|
|
"golang.org/x/image/font"
|
|
"golang.org/x/image/font/gofont/goregular"
|
|
"golang.org/x/image/font/opentype"
|
|
"golang.org/x/image/math/fixed"
|
|
)
|
|
|
|
const (
|
|
watermarkFontSize = 90
|
|
watermarkRotationDegree = -55.0
|
|
watermarkOpacity = 0.1
|
|
watermarkScaleFactor = 1.0
|
|
fontDPI = 80
|
|
charWidthRatio = 0.6
|
|
lineSpacingRatio = 1.5
|
|
)
|
|
|
|
var (
|
|
fontSizeRatio = 72.0 / float64(fontDPI)
|
|
fontColor = color.RGBA{0, 0, 0, 255}
|
|
)
|
|
|
|
func MergePDFs(pdfs ...[]byte) ([]byte, error) {
|
|
readers := make([]io.ReadSeeker, len(pdfs))
|
|
for i, pdf := range pdfs {
|
|
readers[i] = bytes.NewReader(pdf)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := api.MergeRaw(readers, &buf, false, nil); err != nil {
|
|
return nil, fmt.Errorf("cannot merge PDFs: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func AddConfidentialWithTimestamp(pdfData []byte, email mail.Addr) ([]byte, error) {
|
|
reader := bytes.NewReader(pdfData)
|
|
|
|
watermarkLines := []string{
|
|
"Confidential",
|
|
email.String(),
|
|
time.Now().Format("2006-01-02"),
|
|
}
|
|
|
|
textImage, err := generateTextImage(watermarkLines)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot generate watermark image: %w", err)
|
|
}
|
|
|
|
// Apply rotation before pdfcpu scaling instead of using pdfcpu's rotation API
|
|
// to ensure the scaled watermark covers the full page properly
|
|
imageData, err := rotateImage(textImage, watermarkRotationDegree)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot rotate image: %w", err)
|
|
}
|
|
|
|
imageReader := bytes.NewReader(imageData)
|
|
desc := fmt.Sprintf(
|
|
"rotation:0,position:c,opacity:%.1f,scalefactor:%.1f rel",
|
|
watermarkOpacity,
|
|
watermarkScaleFactor,
|
|
)
|
|
|
|
watermarkConf, err := api.ImageWatermarkForReader(imageReader, desc, true, false, types.POINTS)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create watermark from reader: %w", err)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
err = api.AddWatermarks(reader, &buf, nil, watermarkConf, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot add watermark: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func generateTextImage(lines []string) (*image.RGBA, error) {
|
|
maxLineLength := 0
|
|
for _, line := range lines {
|
|
maxLineLength = max(maxLineLength, len(line))
|
|
}
|
|
|
|
charWidth := int(float64(watermarkFontSize) * charWidthRatio)
|
|
charHeight := watermarkFontSize
|
|
lineSpacing := int(float64(charHeight) * lineSpacingRatio)
|
|
|
|
textWidth := maxLineLength * charWidth
|
|
textHeight := len(lines)*charHeight + (len(lines)-1)*lineSpacing
|
|
|
|
textImg := image.NewRGBA(image.Rect(0, 0, textWidth, textHeight))
|
|
|
|
ttf, err := opentype.Parse(goregular.TTF)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot parse font: %w", err)
|
|
}
|
|
|
|
face, err := opentype.NewFace(ttf, &opentype.FaceOptions{
|
|
Size: float64(watermarkFontSize) * fontSizeRatio,
|
|
DPI: fontDPI,
|
|
Hinting: font.HintingFull,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot create font face: %w", err)
|
|
}
|
|
|
|
d := &font.Drawer{
|
|
Dst: textImg,
|
|
Src: image.NewUniform(fontColor),
|
|
Face: face,
|
|
}
|
|
|
|
for i, line := range lines {
|
|
y := charHeight + i*(charHeight+lineSpacing)
|
|
|
|
lineWidth := d.MeasureString(line)
|
|
centerX := textWidth/2 - int(lineWidth>>6)/2
|
|
|
|
d.Dot = fixed.Point26_6{
|
|
X: fixed.I(centerX),
|
|
Y: fixed.I(y),
|
|
}
|
|
d.DrawString(line)
|
|
}
|
|
|
|
return textImg, nil
|
|
}
|
|
|
|
func rotateImage(src image.Image, angleDegrees float64) ([]byte, error) {
|
|
srcBounds := src.Bounds()
|
|
srcWidth := srcBounds.Dx()
|
|
srcHeight := srcBounds.Dy()
|
|
|
|
angle := angleDegrees * math.Pi / 180.0
|
|
cosAngle := math.Cos(angle)
|
|
sinAngle := math.Sin(angle)
|
|
cos := math.Abs(cosAngle)
|
|
sin := math.Abs(sinAngle)
|
|
|
|
rotatedWidth := int(float64(srcWidth)*cos + float64(srcHeight)*sin)
|
|
rotatedHeight := int(float64(srcWidth)*sin + float64(srcHeight)*cos)
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, rotatedWidth, rotatedHeight))
|
|
|
|
srcCenterX := float64(srcWidth) / 2
|
|
srcCenterY := float64(srcHeight) / 2
|
|
dstCenterX := float64(rotatedWidth) / 2
|
|
dstCenterY := float64(rotatedHeight) / 2
|
|
|
|
for y := range rotatedHeight {
|
|
for x := range rotatedWidth {
|
|
fx := float64(x) - dstCenterX
|
|
fy := float64(y) - dstCenterY
|
|
|
|
srcX := fx*cosAngle + fy*sinAngle + srcCenterX
|
|
srcY := -fx*sinAngle + fy*cosAngle + srcCenterY
|
|
|
|
if srcX >= 0 && srcY >= 0 && int(srcX) < srcWidth && int(srcY) < srcHeight {
|
|
srcColor := src.At(int(srcX), int(srcY))
|
|
dst.Set(x, y, srcColor)
|
|
}
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, dst); err != nil {
|
|
return nil, fmt.Errorf("cannot encode rotated image: %w", err)
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|