Repository: rootVIII/pdfinverter Branch: master Commit: d1a9a490f407 Files: 10 Total size: 17.6 KB Directory structure: gitextract_vgv9217i/ ├── .gitignore ├── README.md ├── bin/ │ └── .gitignore ├── go.mod ├── go.sum ├── inverter/ │ ├── cli.go │ ├── gui.go │ ├── pdfinverter.go │ └── utils.go └── main.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ *.pdf qtbox pdfinverter icon_1024x1024.png ================================================ FILE: README.md ================================================ ### PDFINVERTER - darken (or lighten) a PDF PDFInverter (GUI and CLI) will create a new PDF at the specified location from a source PDF. All colors will be inverted (original shown on left): example1
example2
example3
Unfortunately page links are not preserved, but this program will darken PDFs making them suitable for night reading. A 2-3 page PDF will invert very quickly. However a 400 page PDF may take 3-4 minutes. This project should build on any platform with ImageMagick bindings for Golang. export CGO_CFLAGS_ALLOW='-Xpreprocessor' may need to be executed to run/build. The GUI is developed with Golang QT bindings: example2 ###### Get the project and build:
  
git clone https://github.com/rootVIII/pdfinverter.git
cd <project root>
go build -o bin/pdfinverter
./bin/pdfinverter 
  
###### command-line usage:
  
# Required
-i     input PDF file path
-o     output PDF file path

Note:  If no command line arguments are provided, the GUI version will open.
  

This project was developed on macOS Big Sur 11.0.1 ================================================ FILE: bin/.gitignore ================================================ * */ !.gitignore ================================================ FILE: go.mod ================================================ module pdfinverter go 1.18 require ( github.com/gen2brain/go-fitz v1.19.0 // indirect github.com/google/uuid v1.3.0 // indirect github.com/gopherjs/gopherjs v0.0.0-20190411002643-bd77b112433e // indirect github.com/therecipe/qt v0.0.0-20200904063919-c0c124a5770d // indirect gopkg.in/gographics/imagick.v3 v3.4.0 // indirect ) ================================================ FILE: go.sum ================================================ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gen2brain/go-fitz v1.19.0 h1:tXuT5dpsxPNn7LS8eGv2uQ04EviyGb/o2AdEr1e4W0M= github.com/gen2brain/go-fitz v1.19.0/go.mod h1:UZAxMETTDK4UPpuh80HaRpPzgkSibUihXVzwj2ip5oQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20190411002643-bd77b112433e h1:XWcjeEtTFTOVA9Fs1w7n2XBftk5ib4oZrhzWk0B+3eA= github.com/gopherjs/gopherjs v0.0.0-20190411002643-bd77b112433e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/therecipe/qt v0.0.0-20200904063919-c0c124a5770d h1:T+d8FnaLSvM/1BdlDXhW4d5dr2F07bAbB+LpgzMxx+o= github.com/therecipe/qt v0.0.0-20200904063919-c0c124a5770d/go.mod h1:SUUR2j3aE1z6/g76SdD6NwACEpvCxb3fvG82eKbD6us= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190418165655-df01cb2cc480/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190420063019-afa5a82059c6/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190420181800-aa740d480789/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= gopkg.in/gographics/imagick.v3 v3.4.0 h1:kSnbsXOWofo81VJEn/Hw8w3qqoOrfTyWwjAQzSdtPlg= gopkg.in/gographics/imagick.v3 v3.4.0/go.mod h1:+Q9nyA2xRZXrDyTtJ/eko+8V/5E7bWYs08ndkZp8UmA= ================================================ FILE: inverter/cli.go ================================================ package inverter // rootVIII 2020 import ( "io/ioutil" "strings" "sync" ) // CLI embeds/inherits App type and controls CLI application startup & processing. type CLI struct { App } // RunApp inverts a pdf based on cmd-line arguments. func (cli *CLI) RunApp() { cli.extractImage() files, _ := ioutil.ReadDir(cli.TmpDir) for _, batch := range chunk(files) { var wg sync.WaitGroup for _, fileName := range batch { if !strings.Contains(fileName, "out-") { continue } wg.Add(1) go cli.imageRoutine(fileName, &wg) cli.imgCount++ } wg.Wait() } cli.writePDF() } ================================================ FILE: inverter/gui.go ================================================ package inverter // rootVIII 2020 import ( "fmt" "io/ioutil" "os" "os/user" "path/filepath" "strings" "sync" "time" "github.com/therecipe/qt/core" "github.com/therecipe/qt/gui" "github.com/therecipe/qt/widgets" ) // GUI embeds App Type and controls GUI application startup & processing. type GUI struct { App window *widgets.QMainWindow inputTextBox *widgets.QLineEdit outputTextBox *widgets.QLineEdit statusLabel *widgets.QLabel userInfo *user.User workingCount, statusCount uint8 haveStatus, runningJob bool workingTitle string } // openPDFInput opens the PDF that needs to be inverted. func (g *GUI) openPDFInput() { if !g.runningJob { g.PDFIn = widgets.QFileDialog_GetOpenFileName( g.window, "Open PDF", g.userInfo.HomeDir, "(*.pdf)", "", widgets.QFileDialog__DontUseNativeDialog) g.inputTextBox.SetText(g.PDFIn) } else { g.statusLabel.SetText("Job is currently running... please wait") } } // openPDFOutput sets the path for the output PDF.. func (g *GUI) openPDFOutput() { if !g.runningJob { g.PDFOut = widgets.QFileDialog_GetSaveFileName( g.window, "Save", g.userInfo.HomeDir, "", "", widgets.QFileDialog__DontUseNativeDialog) g.outputTextBox.SetText(g.PDFOut) } else { g.statusLabel.SetText("Job is currently running... please wait") } } // invert signals to the background go-routine that a new job is ready to be processed. func (g *GUI) invert() { g.PDFIn = g.inputTextBox.Text() g.PDFOut = g.outputTextBox.Text() err := g.shouldExecute() if err != nil { g.statusLabel.SetText(err.Error()) } else { g.runningJob = true } } // resetGUI sets the GUI fields and attributes back to default if a job is not running. // Otherwise the user is warned that a job is currently being processed. func (g *GUI) resetGUI() { if !g.runningJob { g.reset() } else { g.statusLabel.SetText("Job is currently running... please wait") } } // reset the gui and variables to inital/empty values. func (g *GUI) reset() { g.PDFIn, g.PDFOut = "", "" g.inputTextBox.SetText(g.PDFIn) g.outputTextBox.SetText(g.PDFOut) g.imgCount, g.workingCount = 0, 0 g.runningJob = false g.window.SetWindowTitle("") } func (g GUI) shouldExecute() error { if g.runningJob { return fmt.Errorf("Job is currently running") } if len(g.PDFIn) < 5 || strings.ToLower(g.PDFIn[len(g.PDFIn)-4:]) != ".pdf" { return fmt.Errorf("input file must have .pdf extension and MIME type") } if len(g.PDFOut) < 1 { return fmt.Errorf("invalid output file path provided") } fileStat, err := os.Stat(g.PDFIn) if err != nil || fileStat.IsDir() { return fmt.Errorf("invalid file provided: %v", err) } fileStat, err = os.Stat(g.PDFOut) if err == nil && fileStat.IsDir() { return fmt.Errorf("output path must be a .pdf file") } return nil } // clearStatus is a QTimer function that periodically clears any status message after 4 seconds. func (g *GUI) clearStatus() { if g.statusCount > 4 { g.statusLabel.SetText("") g.haveStatus = false g.statusCount = 0 } if len(g.statusLabel.Text()) > 0 { g.haveStatus = true g.statusCount++ } } // displayStatusRunning is a QTimer() method that // animates the title bar during processing. func (g *GUI) displayStatusRunning() { if g.runningJob { if g.workingCount > 10 { g.workingCount = 0 } else { g.window.SetWindowTitle(g.workingTitle[:g.workingCount]) g.workingCount++ } } } // removePNGs cleans up old PNGs after converting a PDF from within goroutine. func (g GUI) removePNGs() { contents, _ := ioutil.ReadDir(g.TmpDir) for _, png := range contents { if strings.Contains(png.Name(), "out") { err := os.Remove(fmt.Sprintf("%s%s", g.TmpDir, png.Name())) if err != nil { fmt.Printf("%v\n", err) } } } } // RunApp runs the GUI version of the app. Check for new jobs and process // found jobs in a single background goroutine to prevent the hanging GUI // and possible spinning beach-ball for larger-sized PDFs. func (g *GUI) RunApp() { go func() { for { if !g.runningJob { time.Sleep(time.Millisecond * 500) } else { g.extractImage() files, _ := ioutil.ReadDir(g.TmpDir) for _, batch := range chunk(files) { var wg sync.WaitGroup for _, fileName := range batch { if !strings.Contains(fileName, "out-") { continue } wg.Add(1) go g.imageRoutine(fileName, &wg) g.imgCount++ } wg.Wait() } g.writePDF() g.statusLabel.SetText(fmt.Sprintf("%s created", filepath.Base(g.PDFOut))) g.reset() g.removePNGs() g.runningJob = false } } }() userInfo, err := user.Current() if err != nil { panic("Unable to extract username of current user") } g.userInfo = userInfo g.workingTitle = "working..." ui := widgets.NewQApplication(len(os.Args), os.Args) g.window = widgets.NewQMainWindow(nil, 0) g.window.SetMinimumSize2(600, 250) g.window.SetMaximumSize2(600, 250) g.window.SetWindowTitle("") h1 := widgets.NewQHBoxLayout() h2 := widgets.NewQHBoxLayout() h3 := widgets.NewQHBoxLayout() h4 := widgets.NewQHBoxLayout() h5 := widgets.NewQHBoxLayout() v := widgets.NewQVBoxLayout() title := widgets.NewQGraphicsScene(nil) title.AddText("P D F I N V E R T E R", gui.NewQFont2("Menlo", 20, 1, false)) view := widgets.NewQGraphicsView(nil) view.SetScene(title) timer1 := core.NewQTimer(g.window) timer1.ConnectTimeout(func() { g.clearStatus() }) timer1.Start(1000) timer2 := core.NewQTimer(g.window) timer2.ConnectTimeout(func() { g.displayStatusRunning() }) timer2.Start(500) inputLabel := widgets.NewQLabel(nil, 0) inputLabel.SetText("Input PDF:") g.inputTextBox = widgets.NewQLineEdit(nil) g.inputTextBox.SetPlaceholderText("None Selected") g.inputTextBox.SetFixedWidth(400) g.inputTextBox.SetStyleSheet("color: #00FFFF") inputButton := widgets.NewQPushButton2("Browse", nil) inputButton.ConnectClicked(func(bool) { g.openPDFInput() }) outputLabel := widgets.NewQLabel(nil, 0) outputLabel.SetText("Output PDF:") g.outputTextBox = widgets.NewQLineEdit(nil) g.outputTextBox.SetPlaceholderText("None Selected") g.outputTextBox.SetFixedWidth(400) g.outputTextBox.SetStyleSheet("color: #00FFFF") outputButton := widgets.NewQPushButton2("Browse", nil) outputButton.ConnectClicked(func(bool) { g.openPDFOutput() }) resetButton := widgets.NewQPushButton2("Reset", nil) resetButton.ConnectClicked(func(bool) { g.resetGUI() }) invertButton := widgets.NewQPushButton2("Invert", nil) invertButton.ConnectClicked(func(bool) { g.invert() }) g.statusLabel = widgets.NewQLabel(nil, 0) g.statusLabel.SetText(fmt.Sprintf("Greetings %s", g.userInfo.Username)) h1.Layout().AddWidget(view) h2.Layout().AddWidget(inputLabel) h2.Layout().AddWidget(g.inputTextBox) h2.Layout().AddWidget(inputButton) h3.Layout().AddWidget(outputLabel) h3.Layout().AddWidget(g.outputTextBox) h3.Layout().AddWidget(outputButton) h4.Layout().AddWidget(resetButton) h4.Layout().AddWidget(invertButton) h5.Layout().AddWidget(g.statusLabel) for _, layout := range []*widgets.QHBoxLayout{h1, h2, h3, h4, h5} { v.AddLayout(layout, 0) } widget := widgets.NewQWidget(nil, 0) widget.SetLayout(v) g.window.SetCentralWidget(widget) g.window.Show() ui.Exec() } ================================================ FILE: inverter/pdfinverter.go ================================================ package inverter // rootVIII 2020 import ( "fmt" "image" "image/color" "strconv" "sync" "github.com/gen2brain/go-fitz" "gopkg.in/gographics/imagick.v3/imagick" ) // PDFInverter provides an interface to the CLI and GUI types. type PDFInverter interface { imageRoutine(imgName string, wg *sync.WaitGroup) extractImage() iterImage(imgName string) writePDF() RunApp() } // App controls CLI and GUI application startup & processing. type App struct { TmpDir, PDFIn, PDFOut string imgCount int } // imageRoutine inverts the image within a goroutine. func (app *App) imageRoutine(imgName string, wg *sync.WaitGroup) { defer wg.Done() app.iterImage(imgName) } // extractImage extracts PNG images from the input PDF using fitz. func (app App) extractImage() { doc, err := fitz.New(app.PDFIn) if err != nil { panic(err) } defer doc.Close() for pageCount := 0; pageCount < doc.NumPage(); pageCount++ { currentImg, err := doc.Image(pageCount) if err != nil { panic(err) } writePNG(fmt.Sprintf("%sout-%06d.png", app.TmpDir, pageCount), currentImg) } } // iterImage examines each row of pixels in a PNG while creating a new image. func (app App) iterImage(imgName string) { pathPNG := fmt.Sprintf("%s%s", app.TmpDir, imgName) currentPNG := readPNG(pathPNG) perimeter := currentPNG.Bounds() width, height := perimeter.Max.X, perimeter.Max.Y revised := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{width, height}}) var currentPixel color.Color for y := 0; y < height; y++ { for x := 0; x < width; x++ { red, green, blue, alpha := currentPNG.At(x, y).RGBA() r, g, b, a := uint8(red), uint8(green), uint8(blue), uint8(alpha) if r == 0x7F && g == 0x7F && b == 0x7F { currentPixel = color.RGBA{0x1E, 0x1B, 0x24, a} } else { currentPixel = color.RGBA{0xFF - r, 0xFF - g, 0xFF - b, a} } revised.Set(x, y, currentPixel) } } writePNG(pathPNG, revised) } // writePDF uses write images to the PDF file. func (app *App) writePDF() { convertCMD := []string{"convert"} for index := 0; index < app.imgCount; index++ { var indexString string = strconv.Itoa(index) var leadingZeroes string for i := 0; i < (6 - len(indexString)); i++ { leadingZeroes += "0" } inputPath := fmt.Sprintf("%sout-%s%s.png", app.TmpDir, leadingZeroes, indexString) convertCMD = append(convertCMD, inputPath) } convertCMD = append(convertCMD, "-quality", "100", app.PDFOut) imagick.Initialize() defer imagick.Terminate() _, err := imagick.ConvertImageCommand(convertCMD) if err != nil { panic(err) } } ================================================ FILE: inverter/utils.go ================================================ package inverter import ( "bytes" "image" "image/png" "io/ioutil" "os" ) // writePNG writes an inverted PNG to disk. func writePNG(path string, newIMG image.Image) { buf := &bytes.Buffer{} err := png.Encode(buf, newIMG) if err != nil { panic(err) } else { err = ioutil.WriteFile(path, buf.Bytes(), 0600) if err != nil { panic(err) } } } // readPNG reads the image to be inverted. func readPNG(path string) image.Image { imgRaw, err := os.Open(path) defer imgRaw.Close() if err != nil { panic(err) } imgDecoded, err := png.Decode(imgRaw) if err != nil { panic(err) } return imgDecoded } // chunk breaks a slice of file names into evenly sized slices. The // final slice will contain the remaining filenames. func chunk(fileNames []os.FileInfo) [][]string { chunked := [][]string{} index, chunkSize := 0, 100 for i := 0; i < len(fileNames)/chunkSize+1; i++ { section := make([]string, chunkSize) for j := 0; j < chunkSize && index < len(fileNames); j++ { section[j] = fileNames[index].Name() index++ } chunked = append(chunked, section) } return chunked } ================================================ FILE: main.go ================================================ package main import ( "flag" "fmt" "io/ioutil" "log" "os" "github.com/google/uuid" "pdfinverter/inverter" ) // runCLI is the entry point to the cmd-line version. func runCLI(tmpDir string) { inputFile := flag.String("i", "", "input file path") outputFile := flag.String("o", "", "output file path") flag.Parse() if len(*inputFile) < 1 || len(*outputFile) < 1 { fmt.Println("-i -o are required") } else if _, err := os.Stat(*inputFile); err != nil { fmt.Println("invalid file path provided for -i ") } else { var cliInit inverter.PDFInverter cliInit = &inverter.CLI{ App: inverter.App{ TmpDir: tmpDir, PDFIn: *inputFile, PDFOut: *outputFile, }, } cliInit.RunApp() } } // runGUI runs the program with a QT front-end.. func runGUI(tmpDir string) { var guiInit inverter.PDFInverter guiInit = &inverter.GUI{ App: inverter.App{ TmpDir: tmpDir, }, } guiInit.RunApp() } func main() { randPrefix, err := uuid.NewRandom() if err != nil { log.Fatal(err) } tmpdir, err := ioutil.TempDir("", randPrefix.String()) if err != nil { log.Fatal(err) } defer os.RemoveAll(tmpdir) tmpdir += "/" if len(os.Args) > 1 { runCLI(tmpdir) } else { runGUI(tmpdir) } }