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):
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:
###### 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