diff --git a/client.go b/client.go index ed642a2..1809d15 100644 --- a/client.go +++ b/client.go @@ -929,7 +929,7 @@ func (c *client) Visit(url string) { // +++ Begin Protocol Handlers +++ func (c *client) handleGopher(u Url) { - if u.DownloadOnly { + if u.DownloadOnly || (c.Options["showimages"] == "false" && (u.Mime == "I" || u.Mime == "g")) { nameSplit := strings.Split(u.Resource, "/") filename := nameSplit[len(nameSplit)-1] filename = strings.Trim(filename, " \t\r\n\v\f\a") @@ -947,6 +947,11 @@ func (c *client) handleGopher(u Url) { return } pg := MakePage(u, content, links) + if u.Mime == "I" || u.Mime == "g" { + pg.FileType = "image" + } else { + pg.FileType = "text" + } pg.WrapContent(c.Width-1, (c.Options["theme"] == "color")) c.PageState.Add(pg) c.SetPercentRead() @@ -968,8 +973,9 @@ func (c *client) handleGemini(u Url) { case 1: c.search("", u.Full, capsule.Content) case 2: - if capsule.MimeMaj == "text" { + if capsule.MimeMaj == "text" || (c.Options["showimages"] == "true" && capsule.MimeMaj == "image") { pg := MakePage(u, capsule.Content, capsule.Links) + pg.FileType = capsule.MimeMaj pg.WrapContent(c.Width-1, (c.Options["theme"] == "color")) c.PageState.Add(pg) c.SetPercentRead() diff --git a/defaults.go b/defaults.go index 1c0a230..58ff0c4 100644 --- a/defaults.go +++ b/defaults.go @@ -45,12 +45,13 @@ var defaultOptions = map[string]string{ // the "configlocation" as follows: // "configlocation": xdgConfigPath() + "configlocation": xdgConfigPath(), + "defaultscheme": "gopher", // "gopher", "gemini", "http", "https" "homeurl": "gopher://bombadillo.colorfield.space:70/1/user-guide.map", + "showimages": "false", "savelocation": homePath(), "searchengine": "gopher://gopher.floodgap.com:70/7/v2/vs", "telnetcommand": "telnet", - "configlocation": xdgConfigPath(), - "defaultscheme": "gopher", // "gopher", "gemini", "http", "https" "theme": "normal", // "normal", "inverted", "color" "tlscertificate": "", "tlskey": "", diff --git a/main.go b/main.go index 1362e78..53918f7 100644 --- a/main.go +++ b/main.go @@ -65,6 +65,7 @@ func validateOpt(opt, val string) bool { "webmode": []string{"none", "gui", "lynx", "w3m", "elinks"}, "theme": []string{"normal", "inverse", "color"}, "defaultscheme": []string{"gopher", "gemini", "http", "https"}, + "showimages": []string{"true", "false"}, } opt = strings.ToLower(opt) @@ -83,7 +84,7 @@ func validateOpt(opt, val string) bool { func lowerCaseOpt(opt, val string) string { switch opt { - case "webmode", "theme", "defaultscheme": + case "webmode", "theme", "defaultscheme", "showimages": return strings.ToLower(val) default: return val diff --git a/page.go b/page.go index 08e75fb..ba9258b 100644 --- a/page.go +++ b/page.go @@ -3,6 +3,8 @@ package main import ( "fmt" "strings" + + "tildegit.org/sloum/bombadillo/tdiv" ) //------------------------------------------------\\ @@ -21,6 +23,7 @@ type Page struct { FoundLinkLines []int SearchTerm string SearchIndex int + FileType string } //------------------------------------------------\\ @@ -47,11 +50,23 @@ func (p *Page) ScrollPositionRange(termHeight int) (int, int) { return p.ScrollPosition, end } +func (p *Page) RenderImage(width int) { + w := (width - 5) * 2 + if w > 300 { + w = 300 + } + p.WrappedContent = tdiv.Render([]byte(p.RawContent), w) +} + // WrapContent performs a hard wrap to the requested // width and updates the WrappedContent // of the Page struct width a string slice // of the wrapped data func (p *Page) WrapContent(width int, color bool) { + if p.FileType == "image" { + p.RenderImage(width) + return + } counter := 0 var content strings.Builder var esc strings.Builder @@ -165,6 +180,6 @@ func (p *Page) FindText() { // MakePage returns a Page struct with default values func MakePage(url Url, content string, links []string) Page { - p := Page{make([]string, 0), content, links, url, 0, make([]int, 0), "", 0} + p := Page{make([]string, 0), content, links, url, 0, make([]int, 0), "", 0, ""} return p } diff --git a/tdiv/tdiv.go b/tdiv/tdiv.go new file mode 100644 index 0000000..2ce3e2d --- /dev/null +++ b/tdiv/tdiv.go @@ -0,0 +1,290 @@ +package tdiv + +import ( + "bytes" + "fmt" + "image" + "image/gif" + "image/jpeg" + "image/png" + "io" + "strings" +) + +func getBraille(pattern string) (rune, error) { + switch pattern { + case "000000": + return ' ', nil + case "100000": + return '⠁', nil + case "001000": + return '⠂', nil + case "101000": + return '⠃', nil + case "000010": + return '⠄', nil + case "100010": + return '⠅', nil + case "001010": + return '⠆', nil + case "101010": + return '⠇', nil + case "010000": + return '⠈', nil + case "110000": + return '⠉', nil + case "011000": + return '⠊', nil + case "111000": + return '⠋', nil + case "010010": + return '⠌', nil + case "110010": + return '⠍', nil + case "011010": + return '⠎', nil + case "111010": + return '⠏', nil + case "000100": + return '⠐', nil + case "100100": + return '⠑', nil + case "001100": + return '⠒', nil + case "101100": + return '⠓', nil + case "000110": + return '⠔', nil + case "100110": + return '⠕', nil + case "001110": + return '⠖', nil + case "101110": + return '⠗', nil + case "010100": + return '⠘', nil + case "110100": + return '⠙', nil + case "011100": + return '⠚', nil + case "111100": + return '⠛', nil + case "010110": + return '⠜', nil + case "110110": + return '⠝', nil + case "011110": + return '⠞', nil + case "111110": + return '⠟', nil + case "000001": + return '⠠', nil + case "100001": + return '⠡', nil + case "001001": + return '⠢', nil + case "101001": + return '⠣', nil + case "000011": + return '⠤', nil + case "100011": + return '⠥', nil + case "001011": + return '⠦', nil + case "101011": + return '⠧', nil + case "010001": + return '⠨', nil + case "110001": + return '⠩', nil + case "011001": + return '⠪', nil + case "111001": + return '⠫', nil + case "010011": + return '⠬', nil + case "110011": + return '⠭', nil + case "011011": + return '⠮', nil + case "111011": + return '⠯', nil + case "000101": + return '⠰', nil + case "100101": + return '⠱', nil + case "001101": + return '⠲', nil + case "101101": + return '⠳', nil + case "000111": + return '⠴', nil + case "100111": + return '⠵', nil + case "001111": + return '⠶', nil + case "101111": + return '⠷', nil + case "010101": + return '⠸', nil + case "110101": + return '⠹', nil + case "011101": + return '⠺', nil + case "111101": + return '⠻', nil + case "010111": + return '⠼', nil + case "110111": + return '⠽', nil + case "011111": + return '⠾', nil + case "111111": + return '⠿', nil + default: + return '!', fmt.Errorf("Invalid character entry") + } +} + +// scaleImage loads and scales an image and returns a 2d pixel-int slice +// +// Adapted from: +// http://tech-algorithm.com/articles/nearest-neighbor-image-scaling/ +func scaleImage(file io.Reader, newWidth int) (int, int, [][]int, error) { + img, _, err := image.Decode(file) + if err != nil { + return 0, 0, nil, err + } + + bounds := img.Bounds() + width, height := bounds.Max.X, bounds.Max.Y + newHeight := int(float64(newWidth) * (float64(height) / float64(width))) + + out := make([][]int, newHeight) + for i := range out { + out[i] = make([]int, newWidth) + } + + xRatio := float64(width) / float64(newWidth) + yRatio := float64(height) / float64(newHeight) + var px, py int + for i := 0; i < newHeight; i++ { + for j := 0; j < newWidth; j++ { + px = int(float64(j) * xRatio) + py = int(float64(i) * yRatio) + out[i][j] = rgbaToGray(img.At(px, py).RGBA()) + } + } + return newWidth, newHeight, out, nil +} + +// Get the bi-dimensional pixel array +func getPixels(file io.Reader) (int, int, [][]int, error) { + img, _, err := image.Decode(file) + if err != nil { + return 0, 0, nil, err + } + + bounds := img.Bounds() + width, height := bounds.Max.X, bounds.Max.Y + + var pixels [][]int + for y := 0; y < height; y++ { + var row []int + for x := 0; x < width; x++ { + row = append(row, rgbaToGray(img.At(x, y).RGBA())) + } + pixels = append(pixels, row) + } + + return width, height, pixels, nil +} + +func errorDither(w, h int, p [][]int) [][]int { + mv := [4][2]int{ + [2]int{0, 1}, + [2]int{1, 1}, + [2]int{1, 0}, + [2]int{1, -1}, + } + per := [4]float64{0.4375, 0.0625, 0.3125, 0.1875} + var res, diff int + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + cur := p[y][x] + if cur > 128 { + res = 1 + diff = -(255 - cur) + } else { + res = 0 + diff = cur // TODO see why this was abs() in the py version + } + for i, v := range mv { + if y+v[0] >= h || x+v[1] >= w || x+v[1] <= 0 { + continue + } + px := p[y+v[0]][x+v[1]] + px = int(float64(diff)*per[i] + float64(px)) + if px < 0 { + px = 0 + } else if px > 255 { + px = 255 + } + p[y+v[0]][x+v[1]] = px + p[y][x] = res + } + } + } + return p +} + +func toBraille(p [][]int) []rune { + w := len(p[0]) // TODO this is unsafe + h := len(p) + rows := h / 3 + cols := w / 2 + out := make([]rune, rows*(cols+1)) + counter := 0 + for y := 0; y < h-3; y += 4 { + for x := 0; x < w-1; x += 2 { + str := fmt.Sprintf( + "%d%d%d%d%d%d", + p[y][x], p[y][x+1], + p[y+1][x], p[y+1][x+1], + p[y+2][x], p[y+2][x+1]) + b, err := getBraille(str) + if err != nil { + out[counter] = ' ' + } else { + out[counter] = b + } + counter++ + } + out[counter] = '\n' + counter++ + } + return out +} + +func rgbaToGray(r uint32, g uint32, b uint32, a uint32) int { + rf := float64(r/257) * 0.92126 + gf := float64(g/257) * 0.97152 + bf := float64(b/257) * 0.90722 + grey := int((rf + gf + bf) / 3) + return grey +} + +func Render(in []byte, width int) []string { + image.RegisterFormat("jpeg", "jpeg", jpeg.Decode, jpeg.DecodeConfig) + image.RegisterFormat("png", "png", png.Decode, png.DecodeConfig) + image.RegisterFormat("gif", "gif", gif.Decode, gif.DecodeConfig) + w, h, p, err := scaleImage(bytes.NewReader(in), width) + + if err != nil { + return []string{"Unable to render image.", "Please download using:", "", " :w ."} + } + px := errorDither(w, h, p) + b := toBraille(px) + out := strings.SplitN(string(b), "\n", -1) + return out +} diff --git a/url.go b/url.go index a6823fa..0831411 100644 --- a/url.go +++ b/url.go @@ -130,7 +130,7 @@ func MakeUrl(u string) (Url, error) { out.Mime = "1" } switch out.Mime { - case "1", "0", "h", "7": + case "1", "0", "h", "7", "I", "g": out.DownloadOnly = false default: out.DownloadOnly = true