diff --git a/Makefile b/Makefile index d6c82b5..6cc6e34 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,8 @@ serve: deps PDF := $(OUT_DIR)/output.pdf PDF_2UP := $(OUT_DIR)/output-2up.pdf +PDF_IMAGE_DPI ?= 144 +PDF_JPEG_QUALITY ?= 75 .PHONY: deps deps: @@ -35,13 +37,12 @@ deps: .PHONY: pdf pdf: build deps @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 pdf-2up: build deps @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: rm -rf $(OUT_DIR) index.html - diff --git a/cmd/pdf/main.go b/cmd/pdf/main.go index 5144051..c71eb7c 100644 --- a/cmd/pdf/main.go +++ b/cmd/pdf/main.go @@ -10,6 +10,7 @@ import ( "path/filepath" "smartbar/internal/config" + "smartbar/internal/pdfassets" "github.com/chromedp/cdproto/emulation" "github.com/chromedp/cdproto/page" @@ -38,15 +39,19 @@ func findChromeExec() (string, error) { func main() { var ( - input string - output string - width float64 - height float64 + input string + output string + width float64 + height float64 + imageDPI int + jpegQuality int ) flag.StringVar(&input, "in", "", "input HTML file path (required)") flag.StringVar(&output, "out", "", "output PDF path (required)") 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 input == "" || output == "" { @@ -54,6 +59,32 @@ func main() { } 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 execPath, err := findChromeExec() diff --git a/cmd/pdfbook/main.go b/cmd/pdfbook/main.go index 44de845..805954f 100644 --- a/cmd/pdfbook/main.go +++ b/cmd/pdfbook/main.go @@ -1,158 +1,183 @@ package main import ( - "context" - "flag" - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "strings" + "context" + "flag" + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" - "smartbar/internal/config" + "smartbar/internal/config" + "smartbar/internal/pdfassets" - "github.com/chromedp/cdproto/emulation" - "github.com/chromedp/cdproto/page" - "github.com/chromedp/chromedp" - pdfapi "github.com/pdfcpu/pdfcpu/pkg/api" + "github.com/chromedp/cdproto/emulation" + "github.com/chromedp/cdproto/page" + "github.com/chromedp/chromedp" + pdfapi "github.com/pdfcpu/pdfcpu/pkg/api" ) func must(err error) { - if err != nil { - log.Fatal(err) - } + if err != nil { + log.Fatal(err) + } } func parseYAMLListOfStrings(src string) ([]string, error) { - var out []string - for _, line := range strings.Split(src, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - if strings.HasPrefix(line, "-") { - item := strings.TrimSpace(strings.TrimPrefix(line, "-")) - if item != "" { - out = append(out, item) - } - } else { - return nil, fmt.Errorf("invalid yaml line (expect '- item'): %s", line) - } - } - return out, nil + var out []string + for _, line := range strings.Split(src, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "-") { + item := strings.TrimSpace(strings.TrimPrefix(line, "-")) + if item != "" { + out = append(out, item) + } + } else { + return nil, fmt.Errorf("invalid yaml line (expect '- item'): %s", line) + } + } + return out, nil } func findChromeExec() (string, error) { - if v := os.Getenv("CHROME_PATH"); v != "" { - return v, nil - } - candidates := []string{"google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"} - for _, name := range candidates { - if p, err := exec.LookPath(name); err == nil { - return p, nil - } - } - // macOS common app bundle paths - macPaths := []string{ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - } - for _, p := range macPaths { - if _, err := os.Stat(p); err == nil { - return p, nil - } - } - return "", fmt.Errorf("no Chrome/Chromium executable found; set CHROME_PATH or install chromium/google-chrome") + if v := os.Getenv("CHROME_PATH"); v != "" { + return v, nil + } + candidates := []string{"google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "chrome"} + for _, name := range candidates { + if p, err := exec.LookPath(name); err == nil { + return p, nil + } + } + // macOS common app bundle paths + macPaths := []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + } + for _, p := range macPaths { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("no Chrome/Chromium executable found; set CHROME_PATH or install chromium/google-chrome") } - + func main() { - var ( - pagesDir string - orderPath string - outPDF string - width float64 - height float64 - ) - flag.StringVar(&pagesDir, "pages", "_dist", "path to compiled pages directory (required)") - 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.Float64Var(&width, "w", config.PageWidthIn, "page width in inches") - flag.Float64Var(&height, "h", config.PageHeightIn, "page height in inches") - flag.Parse() + var ( + pagesDir string + orderPath string + outPDF string + width float64 + height float64 + imageDPI int + jpegQuality int + ) + flag.StringVar(&pagesDir, "pages", "_dist", "path to compiled pages directory (required)") + 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.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 == "" { - log.Fatal("--pages, --order and --out are required") - } + if pagesDir == "" || orderPath == "" || outPDF == "" { + log.Fatal("--pages, --order and --out are required") + } - // Load order - data, err := os.ReadFile(orderPath) - must(err) - - ordered, err := parseYAMLListOfStrings(string(data)) - must(err) + // Load order + data, err := os.ReadFile(orderPath) + must(err) - // Resolve Chrome - chromeExec, err := findChromeExec() - 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() + ordered, err := parseYAMLListOfStrings(string(data)) + must(err) - ctx, cancel := chromedp.NewContext(allocCtx) - defer cancel() + imageOpts := pdfassets.OptionsForPage(width, height, imageDPI, jpegQuality) + 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 - tmpDir, err := os.MkdirTemp("", "smartbar_pdfs_*") - must(err) - defer os.RemoveAll(tmpDir) + // Resolve Chrome + chromeExec, err := findChromeExec() + must(err) - var partPDFs []string - 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 + 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() - outPath := filepath.Join(tmpDir, strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)) + ".pdf") - // Render one page PDF via printToPDF - 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) - } + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() - if len(partPDFs) == 0 { - log.Fatal("no pages produced; check order file") - } + // Temp dir for individual PDFs + tmpDir, err := os.MkdirTemp("", "smartbar_pdfs_*") + must(err) + defer os.RemoveAll(tmpDir) - // Merge PDFs into single output - must(os.MkdirAll(filepath.Dir(outPDF), 0o755)) + var partPDFs []string + 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 - must(pdfapi.MergeCreateFile(partPDFs, outPDF, false, nil)) - fmt.Printf("Wrote %s (%d pages)\n", outPDF, len(partPDFs)) + outPath := filepath.Join(tmpDir, strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel))+".pdf") + // Render one page PDF via printToPDF + 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)) } - - diff --git a/go.mod b/go.mod index 7de66ee..3248647 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/chromedp/chromedp v0.9.5 github.com/fsnotify/fsnotify v1.9.0 github.com/pdfcpu/pdfcpu v0.11.0 + golang.org/x/image v0.27.0 ) require ( @@ -25,7 +26,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // 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/text v0.25.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/internal/pdfassets/pdfassets.go b/internal/pdfassets/pdfassets.go new file mode 100644 index 0000000..ec4b86b --- /dev/null +++ b/internal/pdfassets/pdfassets.go @@ -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]) +} diff --git a/internal/pdfassets/pdfassets_test.go b/internal/pdfassets/pdfassets_test.go new file mode 100644 index 0000000..274f868 --- /dev/null +++ b/internal/pdfassets/pdfassets_test.go @@ -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(``), 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) + } +}