2 Commits

6 changed files with 766 additions and 139 deletions

View File

@@ -27,6 +27,8 @@ serve: deps
PDF := $(OUT_DIR)/output.pdf PDF := $(OUT_DIR)/output.pdf
PDF_2UP := $(OUT_DIR)/output-2up.pdf PDF_2UP := $(OUT_DIR)/output-2up.pdf
PDF_IMAGE_DPI ?= 144
PDF_JPEG_QUALITY ?= 75
.PHONY: deps .PHONY: deps
deps: deps:
@@ -35,13 +37,12 @@ deps:
.PHONY: pdf .PHONY: pdf
pdf: build deps pdf: build deps
@echo "Generating PDF by rendering each page and merging..." @echo "Generating PDF by rendering each page and merging..."
@$(GO) run ./cmd/pdfbook --pages $(OUT_DIR) --order pages.yaml --out $(PDF) @$(GO) run ./cmd/pdfbook --pages $(OUT_DIR) --order pages.yaml --out $(PDF) --image-dpi $(PDF_IMAGE_DPI) --jpeg-quality $(PDF_JPEG_QUALITY)
.PHONY: pdf-2up .PHONY: pdf-2up
pdf-2up: build deps pdf-2up: build deps
@echo "Generating 2-up PDF with headless Chrome..." @echo "Generating 2-up PDF with headless Chrome..."
@$(GO) run ./cmd/pdf --in $(OUT_DIR)/print_2up.html --out $(PDF_2UP) --w 11 --h 8.5 @$(GO) run ./cmd/pdf --in $(OUT_DIR)/print_2up.html --out $(PDF_2UP) --w 11 --h 8.5 --image-dpi $(PDF_IMAGE_DPI) --jpeg-quality $(PDF_JPEG_QUALITY)
clean: clean:
rm -rf $(OUT_DIR) index.html rm -rf $(OUT_DIR) index.html

View File

@@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"smartbar/internal/config" "smartbar/internal/config"
"smartbar/internal/pdfassets"
"github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/page"
@@ -38,15 +39,19 @@ func findChromeExec() (string, error) {
func main() { func main() {
var ( var (
input string input string
output string output string
width float64 width float64
height float64 height float64
imageDPI int
jpegQuality int
) )
flag.StringVar(&input, "in", "", "input HTML file path (required)") flag.StringVar(&input, "in", "", "input HTML file path (required)")
flag.StringVar(&output, "out", "", "output PDF path (required)") flag.StringVar(&output, "out", "", "output PDF path (required)")
flag.Float64Var(&width, "w", config.PageWidthIn, "page width in inches") flag.Float64Var(&width, "w", config.PageWidthIn, "page width in inches")
flag.Float64Var(&height, "h", config.PageHeightIn, "page height in inches") flag.Float64Var(&height, "h", config.PageHeightIn, "page height in inches")
flag.IntVar(&imageDPI, "image-dpi", 144, "downsample image assets to this PDF pixel density; 0 disables")
flag.IntVar(&jpegQuality, "jpeg-quality", 75, "JPEG quality for PDF image assets")
flag.Parse() flag.Parse()
if input == "" || output == "" { if input == "" || output == "" {
@@ -54,6 +59,32 @@ func main() {
} }
absInput, _ := filepath.Abs(input) absInput, _ := filepath.Abs(input)
inputRoot := filepath.Dir(absInput)
inputRel := filepath.Base(absInput)
imageOpts := pdfassets.OptionsForPage(width, height, imageDPI, jpegQuality)
prepared, err := pdfassets.PrepareTree(inputRoot, imageOpts)
if err != nil {
log.Fatal(err)
}
defer func() {
if err := prepared.Cleanup(); err != nil {
log.Printf("warning: cleanup optimized PDF assets: %v", err)
}
}()
if prepared.Stats.ImageFiles > 0 {
fmt.Printf(
"Optimized PDF images: %d/%d files, %s -> %s (max %dx%d, JPEG quality %d)\n",
prepared.Stats.OptimizedImages,
prepared.Stats.ImageFiles,
pdfassets.FormatBytes(prepared.Stats.OriginalBytes),
pdfassets.FormatBytes(prepared.Stats.OutputBytes),
imageOpts.MaxWidth,
imageOpts.MaxHeight,
imageOpts.JPEGQuality,
)
}
absInput = filepath.Join(prepared.Root, inputRel)
url := "file://" + absInput url := "file://" + absInput
execPath, err := findChromeExec() execPath, err := findChromeExec()

View File

@@ -1,158 +1,183 @@
package main package main
import ( import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"smartbar/internal/config" "smartbar/internal/config"
"smartbar/internal/pdfassets"
"github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/emulation"
"github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
pdfapi "github.com/pdfcpu/pdfcpu/pkg/api" pdfapi "github.com/pdfcpu/pdfcpu/pkg/api"
) )
func must(err error) { func must(err error) {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
func parseYAMLListOfStrings(src string) ([]string, error) { func parseYAMLListOfStrings(src string) ([]string, error) {
var out []string var out []string
for _, line := range strings.Split(src, "\n") { for _, line := range strings.Split(src, "\n") {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
continue continue
} }
if strings.HasPrefix(line, "-") { if strings.HasPrefix(line, "-") {
item := strings.TrimSpace(strings.TrimPrefix(line, "-")) item := strings.TrimSpace(strings.TrimPrefix(line, "-"))
if item != "" { if item != "" {
out = append(out, item) out = append(out, item)
} }
} else { } else {
return nil, fmt.Errorf("invalid yaml line (expect '- item'): %s", line) return nil, fmt.Errorf("invalid yaml line (expect '- item'): %s", line)
} }
} }
return out, nil return out, nil
} }
func findChromeExec() (string, error) { func findChromeExec() (string, error) {
if v := os.Getenv("CHROME_PATH"); v != "" { if v := os.Getenv("CHROME_PATH"); v != "" {
return v, nil return v, nil
} }
candidates := []string{"google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"} candidates := []string{"google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"}
for _, name := range candidates { for _, name := range candidates {
if p, err := exec.LookPath(name); err == nil { if p, err := exec.LookPath(name); err == nil {
return p, nil return p, nil
} }
} }
// macOS common app bundle paths // macOS common app bundle paths
macPaths := []string{ macPaths := []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium",
} }
for _, p := range macPaths { for _, p := range macPaths {
if _, err := os.Stat(p); err == nil { if _, err := os.Stat(p); err == nil {
return p, nil return p, nil
} }
} }
return "", fmt.Errorf("no Chrome/Chromium executable found; set CHROME_PATH or install chromium/google-chrome") return "", fmt.Errorf("no Chrome/Chromium executable found; set CHROME_PATH or install chromium/google-chrome")
} }
func main() { func main() {
var ( var (
pagesDir string pagesDir string
orderPath string orderPath string
outPDF string outPDF string
width float64 width float64
height float64 height float64
) imageDPI int
flag.StringVar(&pagesDir, "pages", "_dist", "path to compiled pages directory (required)") jpegQuality int
flag.StringVar(&orderPath, "order", "pages.yaml", "path to YAML file listing page order (required)") )
flag.StringVar(&outPDF, "out", "_dist/output.pdf", "output PDF path (required)") flag.StringVar(&pagesDir, "pages", "_dist", "path to compiled pages directory (required)")
flag.Float64Var(&width, "w", config.PageWidthIn, "page width in inches") flag.StringVar(&orderPath, "order", "pages.yaml", "path to YAML file listing page order (required)")
flag.Float64Var(&height, "h", config.PageHeightIn, "page height in inches") flag.StringVar(&outPDF, "out", "_dist/output.pdf", "output PDF path (required)")
flag.Parse() flag.Float64Var(&width, "w", config.PageWidthIn, "page width in inches")
flag.Float64Var(&height, "h", config.PageHeightIn, "page height in inches")
flag.IntVar(&imageDPI, "image-dpi", 144, "downsample image assets to this PDF pixel density; 0 disables")
flag.IntVar(&jpegQuality, "jpeg-quality", 75, "JPEG quality for PDF image assets")
flag.Parse()
if pagesDir == "" || orderPath == "" || outPDF == "" { if pagesDir == "" || orderPath == "" || outPDF == "" {
log.Fatal("--pages, --order and --out are required") log.Fatal("--pages, --order and --out are required")
} }
// Load order // Load order
data, err := os.ReadFile(orderPath) data, err := os.ReadFile(orderPath)
must(err) must(err)
ordered, err := parseYAMLListOfStrings(string(data))
must(err)
// Resolve Chrome ordered, err := parseYAMLListOfStrings(string(data))
chromeExec, err := findChromeExec() must(err)
must(err)
opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.ExecPath(chromeExec), chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), chromedp.Flag("hide-scrollbars", true))
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
defer cancel()
ctx, cancel := chromedp.NewContext(allocCtx) imageOpts := pdfassets.OptionsForPage(width, height, imageDPI, jpegQuality)
defer cancel() prepared, err := pdfassets.PrepareTree(pagesDir, imageOpts)
must(err)
defer func() {
if err := prepared.Cleanup(); err != nil {
log.Printf("warning: cleanup optimized PDF assets: %v", err)
}
}()
pagesDir = prepared.Root
if prepared.Stats.ImageFiles > 0 {
fmt.Printf(
"Optimized PDF images: %d/%d files, %s -> %s (max %dx%d, JPEG quality %d)\n",
prepared.Stats.OptimizedImages,
prepared.Stats.ImageFiles,
pdfassets.FormatBytes(prepared.Stats.OriginalBytes),
pdfassets.FormatBytes(prepared.Stats.OutputBytes),
imageOpts.MaxWidth,
imageOpts.MaxHeight,
imageOpts.JPEGQuality,
)
}
// Temp dir for individual PDFs // Resolve Chrome
tmpDir, err := os.MkdirTemp("", "smartbar_pdfs_*") chromeExec, err := findChromeExec()
must(err) must(err)
defer os.RemoveAll(tmpDir)
var partPDFs []string opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.ExecPath(chromeExec), chromedp.Flag("headless", true), chromedp.Flag("disable-gpu", true), chromedp.Flag("hide-scrollbars", true))
for _, rel := range ordered { allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
abs := filepath.Join(pagesDir, rel) defer cancel()
if _, err := os.Stat(abs); err != nil {
log.Printf("skip missing page: %s", rel)
continue
}
absHTML, _ := filepath.Abs(abs)
url := "file://" + absHTML
outPath := filepath.Join(tmpDir, strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)) + ".pdf") ctx, cancel := chromedp.NewContext(allocCtx)
// Render one page PDF via printToPDF defer cancel()
err := chromedp.Run(ctx, chromedp.Tasks{
chromedp.Navigate(url),
chromedp.ActionFunc(func(ctx context.Context) error {
if err := emulation.SetEmulatedMedia().WithMedia("print").Do(ctx); err != nil {
return err
}
params := page.PrintToPDF().
WithPaperWidth(width).
WithPaperHeight(height).
WithMarginTop(0).WithMarginBottom(0).WithMarginLeft(0).WithMarginRight(0).
WithPrintBackground(true)
buf, _, err := params.Do(ctx)
if err != nil {
return err
}
return os.WriteFile(outPath, buf, 0o644)
}),
})
must(err)
partPDFs = append(partPDFs, outPath)
}
if len(partPDFs) == 0 { // Temp dir for individual PDFs
log.Fatal("no pages produced; check order file") tmpDir, err := os.MkdirTemp("", "smartbar_pdfs_*")
} must(err)
defer os.RemoveAll(tmpDir)
// Merge PDFs into single output var partPDFs []string
must(os.MkdirAll(filepath.Dir(outPDF), 0o755)) for _, rel := range ordered {
abs := filepath.Join(pagesDir, rel)
if _, err := os.Stat(abs); err != nil {
log.Printf("skip missing page: %s", rel)
continue
}
absHTML, _ := filepath.Abs(abs)
url := "file://" + absHTML
// pdfcpu expects []string in order and writes outPDF outPath := filepath.Join(tmpDir, strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))+".pdf")
must(pdfapi.MergeCreateFile(partPDFs, outPDF, false, nil)) // Render one page PDF via printToPDF
fmt.Printf("Wrote %s (%d pages)\n", outPDF, len(partPDFs)) err := chromedp.Run(ctx, chromedp.Tasks{
chromedp.Navigate(url),
chromedp.ActionFunc(func(ctx context.Context) error {
if err := emulation.SetEmulatedMedia().WithMedia("print").Do(ctx); err != nil {
return err
}
params := page.PrintToPDF().
WithPaperWidth(width).
WithPaperHeight(height).
WithMarginTop(0).WithMarginBottom(0).WithMarginLeft(0).WithMarginRight(0).
WithPrintBackground(true)
buf, _, err := params.Do(ctx)
if err != nil {
return err
}
return os.WriteFile(outPath, buf, 0o644)
}),
})
must(err)
partPDFs = append(partPDFs, outPath)
}
if len(partPDFs) == 0 {
log.Fatal("no pages produced; check order file")
}
// Merge PDFs into single output
must(os.MkdirAll(filepath.Dir(outPDF), 0o755))
// pdfcpu expects []string in order and writes outPDF
must(pdfapi.MergeCreateFile(partPDFs, outPDF, false, nil))
fmt.Printf("Wrote %s (%d pages)\n", outPDF, len(partPDFs))
} }

2
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/chromedp/chromedp v0.9.5 github.com/chromedp/chromedp v0.9.5
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/pdfcpu/pdfcpu v0.11.0 github.com/pdfcpu/pdfcpu v0.11.0
golang.org/x/image v0.27.0
) )
require ( require (
@@ -25,7 +26,6 @@ require (
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/crypto v0.38.0 // indirect golang.org/x/crypto v0.38.0 // indirect
golang.org/x/image v0.27.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect

View File

@@ -0,0 +1,470 @@
package pdfassets
import (
"bytes"
"encoding/binary"
"fmt"
"image"
stddraw "image/draw"
"image/jpeg"
"image/png"
"io"
"io/fs"
"math"
"os"
"path/filepath"
"strings"
xdraw "golang.org/x/image/draw"
)
type Options struct {
MaxWidth int
MaxHeight int
JPEGQuality int
}
type Stats struct {
ImageFiles int
OptimizedImages int
OriginalBytes int64
OutputBytes int64
}
type PreparedTree struct {
Root string
Stats Stats
Cleanup func() error
}
func OptionsForPage(widthIn, heightIn float64, imageDPI, jpegQuality int) Options {
if imageDPI <= 0 {
return Options{JPEGQuality: clampJPEGQuality(jpegQuality)}
}
return Options{
MaxWidth: max(1, int(math.Ceil(widthIn*float64(imageDPI)))),
MaxHeight: max(1, int(math.Ceil(heightIn*float64(imageDPI)))),
JPEGQuality: clampJPEGQuality(jpegQuality),
}
}
func (o Options) Enabled() bool {
return o.MaxWidth > 0 && o.MaxHeight > 0
}
func PrepareTree(srcRoot string, opts Options) (PreparedTree, error) {
if !opts.Enabled() {
return PreparedTree{
Root: srcRoot,
Cleanup: func() error { return nil },
}, nil
}
absRoot, err := filepath.Abs(srcRoot)
if err != nil {
return PreparedTree{}, err
}
tmpRoot, err := os.MkdirTemp("", "smartbar_pdf_assets_*")
if err != nil {
return PreparedTree{}, err
}
prepared := PreparedTree{
Root: tmpRoot,
Cleanup: func() error { return os.RemoveAll(tmpRoot) },
}
err = filepath.WalkDir(absRoot, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(absRoot, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
dst := filepath.Join(tmpRoot, rel)
if d.IsDir() {
return os.MkdirAll(dst, 0o755)
}
info, err := d.Info()
if err != nil {
return err
}
if info.Mode()&fs.ModeSymlink != 0 {
return copySymlink(path, dst)
}
if !info.Mode().IsRegular() {
return nil
}
if strings.EqualFold(filepath.Ext(path), ".pdf") {
return nil
}
if isSupportedImage(path) {
stats, err := optimizeImageFile(path, dst, info.Mode().Perm(), opts)
if err == nil {
prepared.Stats.ImageFiles++
prepared.Stats.OriginalBytes += stats.originalBytes
prepared.Stats.OutputBytes += stats.outputBytes
if stats.optimized {
prepared.Stats.OptimizedImages++
}
return nil
}
}
return copyFile(path, dst, info.Mode().Perm())
})
if err != nil {
_ = prepared.Cleanup()
return PreparedTree{}, err
}
return prepared, nil
}
type imageFileStats struct {
originalBytes int64
outputBytes int64
optimized bool
}
func optimizeImageFile(src, dst string, mode fs.FileMode, opts Options) (imageFileStats, error) {
data, err := os.ReadFile(src)
if err != nil {
return imageFileStats{}, err
}
optimized, changed, err := optimizeImage(data, filepath.Ext(src), opts)
if err != nil {
return imageFileStats{}, err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return imageFileStats{}, err
}
output := optimized
if !changed {
output = data
}
if err := os.WriteFile(dst, output, mode); err != nil {
return imageFileStats{}, err
}
return imageFileStats{
originalBytes: int64(len(data)),
outputBytes: int64(len(output)),
optimized: changed,
}, nil
}
func optimizeImage(data []byte, ext string, opts Options) ([]byte, bool, error) {
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, false, err
}
orientation := 1
if format == "jpeg" || strings.EqualFold(ext, ".jpg") || strings.EqualFold(ext, ".jpeg") {
orientation = jpegOrientation(data)
img = applyOrientation(img, orientation)
}
srcBounds := img.Bounds()
dstW, dstH := fitWithin(srcBounds.Dx(), srcBounds.Dy(), opts.MaxWidth, opts.MaxHeight)
resized := dstW != srcBounds.Dx() || dstH != srcBounds.Dy()
if resized {
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, srcBounds, stddraw.Src, nil)
img = dst
}
var buf bytes.Buffer
if imageHasTransparency(img) {
encoder := png.Encoder{CompressionLevel: png.BestCompression}
err = encoder.Encode(&buf, img)
} else {
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: clampJPEGQuality(opts.JPEGQuality)})
}
if err != nil {
return nil, false, err
}
changed := resized || orientation != 1 || buf.Len() < len(data)
if !changed {
return data, false, nil
}
return buf.Bytes(), true, nil
}
func fitWithin(width, height, maxWidth, maxHeight int) (int, int) {
if width <= 0 || height <= 0 || maxWidth <= 0 || maxHeight <= 0 {
return width, height
}
if width <= maxWidth && height <= maxHeight {
return width, height
}
scale := math.Min(float64(maxWidth)/float64(width), float64(maxHeight)/float64(height))
return max(1, int(math.Round(float64(width)*scale))), max(1, int(math.Round(float64(height)*scale)))
}
func imageHasTransparency(img image.Image) bool {
switch m := img.(type) {
case *image.YCbCr:
return false
case *image.Gray, *image.Gray16, *image.CMYK:
return false
case *image.NRGBA:
for i := 3; i < len(m.Pix); i += 4 {
a := m.Pix[i]
if a != 0xff {
return true
}
}
return false
case *image.RGBA:
for i := 3; i < len(m.Pix); i += 4 {
a := m.Pix[i]
if a != 0xff {
return true
}
}
return false
case *image.Alpha, *image.Alpha16:
return true
}
b := img.Bounds()
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
_, _, _, a := img.At(x, y).RGBA()
if a != 0xffff {
return true
}
}
}
return false
}
func applyOrientation(img image.Image, orientation int) image.Image {
if orientation < 2 || orientation > 8 {
return img
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
dstW, dstH := w, h
if orientation >= 5 {
dstW, dstH = h, w
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
for y := 0; y < dstH; y++ {
for x := 0; x < dstW; x++ {
sx, sy := orientedSourcePoint(x, y, w, h, orientation)
dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
}
}
return dst
}
func orientedSourcePoint(x, y, width, height, orientation int) (int, int) {
switch orientation {
case 2:
return width - 1 - x, y
case 3:
return width - 1 - x, height - 1 - y
case 4:
return x, height - 1 - y
case 5:
return y, x
case 6:
return y, height - 1 - x
case 7:
return width - 1 - y, height - 1 - x
case 8:
return width - 1 - y, x
default:
return x, y
}
}
func jpegOrientation(data []byte) int {
if len(data) < 4 || data[0] != 0xff || data[1] != 0xd8 {
return 1
}
for i := 2; i+4 <= len(data); {
if data[i] != 0xff {
i++
continue
}
for i < len(data) && data[i] == 0xff {
i++
}
if i >= len(data) {
return 1
}
marker := data[i]
i++
if marker == 0xd9 || marker == 0xda {
return 1
}
if marker >= 0xd0 && marker <= 0xd7 {
continue
}
if i+2 > len(data) {
return 1
}
segmentLen := int(binary.BigEndian.Uint16(data[i : i+2]))
i += 2
if segmentLen < 2 || i+segmentLen-2 > len(data) {
return 1
}
payload := data[i : i+segmentLen-2]
if marker == 0xe1 && bytes.HasPrefix(payload, []byte("Exif\x00\x00")) {
return tiffOrientation(payload[6:])
}
i += segmentLen - 2
}
return 1
}
func tiffOrientation(data []byte) int {
if len(data) < 8 {
return 1
}
var order binary.ByteOrder
switch string(data[:2]) {
case "II":
order = binary.LittleEndian
case "MM":
order = binary.BigEndian
default:
return 1
}
if order.Uint16(data[2:4]) != 42 {
return 1
}
ifdOffset := int(order.Uint32(data[4:8]))
if ifdOffset < 0 || ifdOffset+2 > len(data) {
return 1
}
entryCount := int(order.Uint16(data[ifdOffset : ifdOffset+2]))
pos := ifdOffset + 2
for i := 0; i < entryCount; i++ {
if pos+12 > len(data) {
return 1
}
tag := order.Uint16(data[pos : pos+2])
fieldType := order.Uint16(data[pos+2 : pos+4])
count := order.Uint32(data[pos+4 : pos+8])
if tag == 0x0112 && fieldType == 3 && count == 1 {
value := int(order.Uint16(data[pos+8 : pos+10]))
if value >= 1 && value <= 8 {
return value
}
return 1
}
pos += 12
}
return 1
}
func isSupportedImage(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".jpg", ".jpeg", ".png":
return true
default:
return false
}
}
func copyFile(src, dst string, mode fs.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
return out.Close()
}
func copySymlink(src, dst string) error {
target, err := os.Readlink(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.Symlink(target, dst)
}
func clampJPEGQuality(q int) int {
if q <= 0 {
return 82
}
if q < 1 {
return 1
}
if q > 100 {
return 100
}
return q
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func FormatBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for n >= unit*div && exp < 4 {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

View File

@@ -0,0 +1,100 @@
package pdfassets
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"os"
"path/filepath"
"testing"
)
func TestFitWithin(t *testing.T) {
w, h := fitWithin(400, 200, 100, 100)
if w != 100 || h != 50 {
t.Fatalf("fitWithin landscape = %dx%d, want 100x50", w, h)
}
w, h = fitWithin(200, 400, 100, 100)
if w != 50 || h != 100 {
t.Fatalf("fitWithin portrait = %dx%d, want 50x100", w, h)
}
}
func TestApplyOrientationRightTop(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 2, 3))
for y := 0; y < 3; y++ {
for x := 0; x < 2; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), A: 0xff})
}
}
got := applyOrientation(img, 6)
if got.Bounds().Dx() != 3 || got.Bounds().Dy() != 2 {
t.Fatalf("oriented bounds = %v, want 3x2", got.Bounds())
}
assertRGBA(t, got.At(0, 0), color.RGBA{R: 0, G: 2, A: 0xff})
assertRGBA(t, got.At(2, 1), color.RGBA{R: 1, G: 0, A: 0xff})
}
func TestPrepareTreeOptimizesImagesAndSkipsPDFs(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "assets", "img"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "index.html"), []byte(`<img src="assets/img/photo.jpg">`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "old.pdf"), []byte("old pdf"), 0o644); err != nil {
t.Fatal(err)
}
var src bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 400, 200))
for y := 0; y < 200; y++ {
for x := 0; x < 400; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 120, A: 0xff})
}
}
if err := jpeg.Encode(&src, img, &jpeg.Options{Quality: 95}); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "assets", "img", "photo.jpg"), src.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
prepared, err := PrepareTree(root, Options{MaxWidth: 100, MaxHeight: 100, JPEGQuality: 70})
if err != nil {
t.Fatal(err)
}
defer prepared.Cleanup()
if prepared.Stats.ImageFiles != 1 || prepared.Stats.OptimizedImages != 1 {
t.Fatalf("stats = %+v, want one optimized image", prepared.Stats)
}
if _, err := os.Stat(filepath.Join(prepared.Root, "old.pdf")); !os.IsNotExist(err) {
t.Fatalf("old PDF copied into prepared tree; err=%v", err)
}
out, err := os.Open(filepath.Join(prepared.Root, "assets", "img", "photo.jpg"))
if err != nil {
t.Fatal(err)
}
defer out.Close()
cfg, err := jpeg.DecodeConfig(out)
if err != nil {
t.Fatal(err)
}
if cfg.Width > 100 || cfg.Height > 100 {
t.Fatalf("optimized dimensions = %dx%d, want within 100x100", cfg.Width, cfg.Height)
}
}
func assertRGBA(t *testing.T, got color.Color, want color.RGBA) {
t.Helper()
r, g, b, a := got.RGBA()
if uint8(r>>8) != want.R || uint8(g>>8) != want.G || uint8(b>>8) != want.B || uint8(a>>8) != want.A {
t.Fatalf("color = rgba(%d,%d,%d,%d), want %+v", uint8(r>>8), uint8(g>>8), uint8(b>>8), uint8(a>>8), want)
}
}