Commit: 27f2f20cb211252f7383db891284487091838c49 Author: Vi Grey Date: 2024-02-08 13:04 UTC Summary: Initial commit .gitignore | 1 + CHANGELOG.txt | 10 ++++ LICENSE.txt | 24 +++++++++ Makefile | 11 ++++ README.txt | 116 ++++++++++++++++++++++++++++++++++++++++ go.mod | 3 ++ src/address.go | 36 +++++++++++++ src/err.go | 11 ++++ src/flags.go | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/gemini.go | 227 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/gmi.go | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/gmi2pdf.go | 53 +++++++++++++++++++ src/groff.go | 48 +++++++++++++++++ src/ms.go | 128 ++++++++++++++++++++++++++++++++++++++++++++ src/txt.go | 69 ++++++++++++++++++++++++ 15 files changed, 1125 insertions(+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 0000000..86993b1 --- /dev/null +++ b/CHANGELOG.txt @@ -0,0 +1,10 @@ +# Change Log + +All notable changes to this project will be documented in this file. + + +## [0.0.1] - 2024-02-08 + +### Added + +- Initial release diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..79ea072 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,24 @@ +Copyright (C) 2024, Vi Grey +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY AUTHOR AND CONTRIBUTORS ``AS IS'' AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL AUTHOR OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS +OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) +HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..64d5b0d --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +PKG_NAME := gmi2pdf +CURRENTDIR := $(dir $(realpath $(firstword $(MAKEFILE_LIST)))) + +all: + mkdir -p $(CURRENTDIR)build/bin; \ + cd $(CURRENTDIR)src; \ + go build -ldflags="-s -w" -o $(CURRENTDIR)build/bin/$(PKG_NAME); \ + cd $(CURRENTDIR); \ + +clean: + rm -rf -- $(CURRENTDIR)build; \ diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..6ea57a2 --- /dev/null +++ b/README.txt @@ -0,0 +1,116 @@ +# gmi2pdf + +Convert gemtext/plaintext file (local or Gemini resource) to PDF file + +gmi2pdf was created by Vi Grey + + +## Description + +gmi2pdf takes a gemtext file or a gemini link and converts the content +to a PDF file. For converting local files, gmi2pdf assumes the file is +a gemtext file. For converting content from a link, both plaintext and +gemtext files are accepted to be converted to a pdf file. Optionally, +a TLS client signed certificate file and TLS client certificate private +key can be specified for gemini requests. + +PDF files are created using groff. If ghostscript is installed, +ghostscript will be used to shrink the filesize of the resulting PDF +file. + +Only ASCII characters are currently supported. An optional flag can be +used to turn unicode characters into squares rather than ommitting them +entirely. + + +## Dependencies + +### OS Dependencies + +- Linux + +This program might work on quite a few flavors of *BSD and possibly +macOS as well, but they have not been tested on those platforms. This +program also might work on Windows via WSL, but has not been tested on +that platform. + +### Build Dependencies + +- go >= 1.20 + +### Use Dependencies + +- groff + +### Optional Use Dependencies + +- ghostscript + +## Build + +To build gmi2pdf, use the following command in the root directory of +this repository + +``` +$ make +``` + +The resulting binary file will be located at `build/bin/gmi2pdf + + +## Usage + +``` +$ gm2pdf -h +Usage: gmi2pdf [OPTIONS]... + +Options: + -2, --2column Create 2-column PDF + -a, --address URL to gemini capsule page to turn into PDF + Cannot be used with --in flag + -c, --tlscert Path to TLS client signed certificate file + -d, --debug Print ms macro troff/groff output to STDOUT + --excludeheader Do not include headers on PDF + --excludefooter Do not include footers on PDF + -h, --help Print Help (this message) and exit + -i, --in Path to gemtext file to turn into PDF. + Cannot be used with --address flag + -k, --tlskey Path to TLS client certificate private key + file + -o, --out Path of where PDF file will be written + -q, --query Query to send to gemini capsule with address + -s, --size PDF page size. Can be "Letter" (8.5in by + 11in), "A4" (210mm by 297mm), "Legal" (8.5in + by 14in), or "Tabloid" (11in by 17in). + Default is "Letter" + -t, --plaintext If using --in flag, process the file as + a plaintext file + -u, --unicode Show unsupported unicode characters as boxes + -v, --version Print version and exit + +Examples: + gmi2pdf -a gemini://example.com/comment.gmi \ + -q "Test content" -k /path/to/gemini-ident.key \ + -c /path/to/gemini-ident.crt -o /path/for/comment.pdf + (This example sends the query "Test content" to + gemini://example.com/comment.gmi using the TLS client certificate + and key found at path/to/gemini-ident.key and + path/to/gemini-ident.csv. The resulting gemtext file is converted + into a US letter sized PDF file with headers and footers and and + stored at /path/for/comment.pdf. Unsupported unicode characters + are not included in the PDF file.) + gmi2pdf -i /path/to/article.gmi \ + -o /path/for/article.pdf -2 -s tabloid --excludefooter \ + --excludeheader -u -d + (This example converts the gemtext file at /path/to/article.gmi to + a tabloid sized PDF of 11in by 17in and saves the PDF file to + /path/for/article.pdf. The PDF content is 2 columned and the + header and footer data are not included in the PDF. Unsupported + unicode characters are converted to boxes. After the PDF file is + created, the ms macro groff/troff data is printed to STDOUT.) + gmi2pdf -i /path/to/article.txt \ + -o /path/for/article.pdf -s A4 -t + (This example converts the plaintext file at /path/to/article.txt + to an A4 sized PDF of 210mm by 297mm and saves the PDF file to + /path/for/article.pdf.) +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..85daed2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gmi2pdf + +go 1.20 diff --git a/src/address.go b/src/address.go new file mode 100644 index 0000000..5ea4a75 --- /dev/null +++ b/src/address.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "net/url" +) + +func formatAddress() error { + u, err := url.ParseRequestURI(address) + if err != nil { + u, err = url.ParseRequestURI("gemini://" + address) + if err != nil { + return err + } + } + if u.Scheme != "gemini" { + return fmt.Errorf("Invalid Address Scheme") + } + address = u.Scheme + "://" + u.Host + u.EscapedPath() + return nil +} + +func addressValid() bool { + if err := formatAddress(); err != nil { + return false + } + query = url.QueryEscape(query) + if query != "" { + if len(address+"?"+query) > 1024 { + return false + } + } else if len(address) > 1024 { + return false + } + return true +} diff --git a/src/err.go b/src/err.go new file mode 100644 index 0000000..f6fb532 --- /dev/null +++ b/src/err.go @@ -0,0 +1,11 @@ +package main + +import ( + "log" +) + +func handleErr(err error, msg string) { + if err != nil { + log.Fatalf("%s\nExiting...\n", msg) + } +} diff --git a/src/flags.go b/src/flags.go new file mode 100644 index 0000000..4f6630b --- /dev/null +++ b/src/flags.go @@ -0,0 +1,221 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" +) + +var ( + address string + clientCertPath string + clientKeyPath string + inputFilePath string + outputFilePath string + query string + size string = "letter" + debug bool + excludeFooter bool + excludeHeader bool + help bool + plaintext bool + twoColumn bool + unicode bool + version bool +) + +func getFlagStrValue(args []string, x, argsLen int) (string, error) { + if x+1 == argsLen { + err := fmt.Errorf("%s: flag `%s` missing value\n"+ + "Use `%s --help` for details", PROGRAM, args[x], os.Args[0]) + return "", err + } + return args[x+1], nil +} + +func handleFlags(args []string) (err error) { + argsLen := len(args) + for x := 0; x < argsLen; x++ { + switch strings.ToLower(args[x]) { + case "--size", "-s": + size, err = getFlagStrValue(args, x, argsLen) + x++ + if err != nil { + return + } + size = strings.ToLower(size) + case "--query", "-q": + query, err = getFlagStrValue(args, x, argsLen) + x++ + if err != nil { + return + } + case "--address", "-a": + address, err = getFlagStrValue(args, x, argsLen) + x++ + if err != nil { + return + } + case "--in", "-i": + inputFilePath, err = getFlagStrValue(args, x, argsLen) + x++ + if err != nil { + return + } + case "--out", "-o": + outputFilePath, err = getFlagStrValue(args, x, argsLen) + x++ + if err != nil { + return + } + case "--tlskey", "-k": + clientKeyPath, err = getFlagStrValue(args, x, argsLen) + x++ + if err != nil { + return + } + case "--tlscert", "-c": + clientCertPath, err = getFlagStrValue(args, x, argsLen) + x++ + if err != nil { + return + } + case "--help", "-h": + help = true + break + case "--version", "-v": + version = true + case "--2column", "-2": + twoColumn = true + case "--unicode", "-u": + unicode = true + case "--debug", "-d": + debug = true + case "--excludeheader": + excludeHeader = true + case "--excludefooter": + excludeFooter = true + case "--plaintext", "-t": + plaintext = true + default: + err = errors.New(fmt.Sprintf("%s: invalid argument `%s`\n"+ + "Use `%s --help` for details", PROGRAM, args[x], os.Args[0])) + return + } + } + if help { + displayHelp() + os.Exit(0) + } + if version { + displayVersion() + os.Exit(0) + } + requiredFlags := [][]string{ + []string{"--out", outputFilePath}, + } + for x := range requiredFlags { + if requiredFlags[x][1] == "" { + err = fmt.Errorf("%s: missing flag `%s`\nUse `%s --help` for details", + PROGRAM, requiredFlags[x][0], os.Args[0]) + return + } + } + if inputFilePath != "" && address != "" { + err = fmt.Errorf("%s: cannot use `--in` and `--address` "+ + "flags at the same time\n"+ + "Use `%s --help` for details", PROGRAM, os.Args[0]) + return + } + if inputFilePath == "" && address == "" { + err = fmt.Errorf("%s: missing flag `--in` or `--address`\n"+ + "Use `%s --help` for details", PROGRAM, os.Args[0]) + return + } + if !addressValid() { + err = fmt.Errorf("%s: Gemini address + query too long.\n"+ + "MUST be AT MOST 1024 characters long (including `gemini://`)\n"+ + "Use `%s --help` for details", PROGRAM, os.Args[0]) + return + } + if size != "letter" && size != "a4" && size != "legal" && + size != "tabloid" { + err = fmt.Errorf("%s: value of `--size` flag MUST be one of "+ + "the following:\n"+ + "`A4`, `Letter`, `Legal`, or `Tabloid`\n"+ + "Use `%s --help` for details", PROGRAM, os.Args[0]) + return + } + if clientCertPath == "" && clientKeyPath != "" { + err = fmt.Errorf("%s: `--tlscert` flag MUST be be used with "+ + "`--tlskey` flag\n"+ + "Use `%s --help` for details", PROGRAM, os.Args[0]) + } + if clientCertPath != "" && clientKeyPath == "" { + err = fmt.Errorf("%s: `--tlskey` flag MUST be be used with "+ + "`--tlscert` flag\n"+ + "Use `%s --help` for details", PROGRAM, os.Args[0]) + return + } + return +} + +func displayHelp() { + fmt.Printf(`Usage %s [OPTIONS]... + +Options: + -2, --2column Create 2-column PDF + -a, --address URL to gemini capsule page to turn into PDF + Cannot be used with --in flag + -c, --tlscert Path to TLS client signed certificate file + -d, --debug Print ms macro troff/groff output to STDOUT + --excludeheader Do not include headers on PDF + --excludefooter Do not include footers on PDF + -h, --help Print Help (this message) and exit + -i, --in Path to gemtext file to turn into PDF. + Cannot be used with --address flag + -k, --tlskey Path to TLS client certificate private key + file + -o, --out Path of where PDF file will be written + -q, --query Query to send to gemini capsule with address + -s, --size PDF page size. Can be "Letter" (8.5in by + 11in), "A4" (210mm by 297mm), "Legal" (8.5in + by 14in), or "Tabloid" (11in by 17in). + Default is "Letter" + -t, --plaintext If using --in flag, process the file as + a plaintext file + -u, --unicode Show unsupported unicode characters as boxes + -v, --version Print version and exit + +Examples: + %s -a gemini://example.com/comment.gmi \ + -q "Test content" -k /path/to/gemini-ident.key \ + -c /path/to/gemini-ident.crt -o /path/for/comment.pdf + (This example sends the query "Test content" to + gemini://example.com/comment.gmi using the TLS client certificate + and key found at path/to/gemini-ident.key and + path/to/gemini-ident.csv. The resulting gemtext file is converted + into a US letter sized PDF file with headers and footers and and + stored at /path/for/comment.pdf. Unsupported unicode characters + are not included in the PDF file.) + %s -i /path/to/article.gmi \ + -o /path/for/article.pdf -2 -s tabloid --excludefooter \ + --excludeheader -u -d + (This example converts the gemtext file at /path/to/article.gmi to + a tabloid sized PDF of 11in by 17in and saves the PDF file to + /path/for/article.pdf. The PDF content is 2 columned and the + header and footer data are not included in the PDF. Unsupported + unicode characters are converted to boxes. After the PDF file is + created, the ms macro groff/troff data is printed to STDOUT.) + %s -i /path/to/article.txt \ + -o /path/for/article.pdf -s A4 -t + (This example converts the plaintext file at /path/to/article.txt + to an A4 sized PDF of 210mm by 297mm and saves the PDF file to + /path/for/article.pdf.) +`, PROGRAM, PROGRAM, PROGRAM, PROGRAM) +} + +func displayVersion() { + fmt.Printf("%s\nversion %s\nby Vi Grey\n", PROGRAM, VERSION) +} diff --git a/src/gemini.go b/src/gemini.go new file mode 100644 index 0000000..62432cc --- /dev/null +++ b/src/gemini.go @@ -0,0 +1,227 @@ +package main + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "net/url" + "os" + "strconv" + "strings" +) + +const ( + GEMINI_DEFAULT_PORT = 1965 + STATUS_INPUT = 10 + STATUS_SENSITIVE_INPUT = 11 + STATUS_SUCCESS = 20 + STATUS_REDIRECT_TEMPORARY = 30 + STATUS_REDIRECT_PERMANENT = 31 + STATUS_TEMPORARY_FAILURE = 40 + STATUS_SERVER_UNAVAILABLE = 41 + STATUS_CGI_ERROR = 42 + STATUS_PROXY_ERROR = 43 + STATUS_SLOW_DOWN = 44 + STATUS_PERMANENT_FAILURE = 50 + STATUS_NOT_FOUND = 51 + STATUS_GONE = 52 + STATUS_PROXY_REQUEST_REFUSED = 53 + STATUS_BAD_REQUEST = 59 + STATUS_CLIENT_CERTIFICATE_REQUIRED = 60 + STATUS_CERTIFICATE_NOT_AUTHORISED = 61 + STATUS_CERTIFICATE_NOT_VALID = 62 +) + +var ( + geminiStatusList = []int{ + STATUS_INPUT, + STATUS_SENSITIVE_INPUT, + STATUS_SUCCESS, + STATUS_REDIRECT_TEMPORARY, + STATUS_REDIRECT_PERMANENT, + STATUS_TEMPORARY_FAILURE, + STATUS_SERVER_UNAVAILABLE, + STATUS_CGI_ERROR, + STATUS_PROXY_ERROR, + STATUS_SLOW_DOWN, + STATUS_PERMANENT_FAILURE, + STATUS_NOT_FOUND, + STATUS_GONE, + STATUS_PROXY_REQUEST_REFUSED, + STATUS_BAD_REQUEST, + STATUS_CLIENT_CERTIFICATE_REQUIRED, + STATUS_CERTIFICATE_NOT_AUTHORISED, + STATUS_CERTIFICATE_NOT_VALID, + } +) + +type geminiResponse struct { + status int + meta string + body []byte +} + +// i is geminiInstances offset. +func doGeminiRequest(reqURL string) (err error) { + reqU, err := url.Parse(reqURL) + if err != nil { + return err + } + host := reqU.Hostname() + port := reqU.Port() + if port == "" { + port = fmt.Sprintf("%d", GEMINI_DEFAULT_PORT) + } + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, + } + if clientCertPath != "" && clientKeyPath != "" { + cert, err := tls.LoadX509KeyPair(clientCertPath, clientKeyPath) + if err != nil { + return err + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%s", host, port), tlsConfig) + if err != nil { + return err + } + defer conn.Close() + conn.Write([]byte(fmt.Sprintf("%s\r\n", reqU.String()))) + var n int + var respHeader string + var rBuf []byte + var resp geminiResponse + var body []byte + // Continue reading from connection until first \r\n is found + // MUST be within 1029 bytes + for { + rBuf = make([]byte, 1029) + n, err = conn.Read(rBuf) + if err != nil && err != io.EOF { + return + } + if n == 0 { + break + } + nlIndex := bytes.Index(rBuf[:n], []byte("\r\n")) + if nlIndex > -1 { + respHeader += string(rBuf[:nlIndex]) + body = append(body, rBuf[nlIndex+2:n]...) + break + } + } + if len(respHeader) < 4 { + err = fmt.Errorf("Response Header too short") + return + } + resp.status, err = strconv.Atoi(respHeader[:2]) + if err != nil { + return + } + geminiStatusListLast := len(geminiStatusList) + for x := range geminiStatusList { + if resp.status == geminiStatusList[x] { + break + } + if x == geminiStatusListLast { + err = fmt.Errorf("Invalid Response Header Status") + return + } + } + if respHeader[2] != ' ' { + err = fmt.Errorf("Response Header missing space after status") + return + } + resp.meta = respHeader[3:] + if resp.status >= 20 && resp.status < 30 { + if strings.Index(resp.meta, "text/gemini") == 0 { + // Gemtext file, so parse all lines + parseGemtextData(address, conn, body) + } else if strings.Index(resp.meta, "text/plain") == 0 { + parsePlaintextData(address, conn, body) + } else { + err = fmt.Errorf("Resource is not gemtext file") + return + } + } + if resp.status >= 30 && resp.status < 40 { + doGeminiRequest(resp.meta) + return nil + } + switch resp.status { + case STATUS_INPUT: + err = fmt.Errorf("Gemini Response Status: 10 INPUT\n"+ + "%s\nInput required for Gemini request.\n"+ + "Add input as query string to address\n"+ + "(Example: gemini://example.com/resource?input_data_here)", + resp.meta) + return + case STATUS_SENSITIVE_INPUT: + err = fmt.Errorf("Gemini Response Status: 11 SENSITIVE INPUT\n"+ + "%s\nPassword input required for Gemini request.\n"+ + "Add password input as query string to address\n"+ + "(Example: gemini://example.com/resource?password_input_data_here)", + resp.meta) + return + case STATUS_TEMPORARY_FAILURE: + err = fmt.Errorf("Gemini Response Status: 40 TEMPORARY FAILURE\n" + + "%s\nRequest unsuccessful.") + return + case STATUS_SERVER_UNAVAILABLE: + err = fmt.Errorf("Gemini Response Status: 41 SERVER_UNAVAILALBE\n" + + "%s\nServer currently unavailable.") + return + case STATUS_CGI_ERROR: + err = fmt.Errorf("Gemini Response Status: 42 CGI ERROR\n"+ + "%s\nCGI process of request unsuccessful.", resp.meta) + return + case STATUS_PROXY_ERROR: + err = fmt.Errorf("Gemini Response Status: 43 PROXY ERROR\n"+ + "%s\nProxy request unsuccessful.", resp.meta) + return + case STATUS_SLOW_DOWN: + err = fmt.Errorf("Gemini Response Status: 44 SLOW DOWN\n"+ + "Please wait at least %d seconds before sending request again.", + resp.meta) + return + case STATUS_PERMANENT_FAILURE: + err = fmt.Errorf("Gemini Response Status: 50 PERMANENT FAILURE\n"+ + "%s\nServer no longer available.", resp.meta) + return + case STATUS_NOT_FOUND: + err = fmt.Errorf("Gemini Response Status: 51 NOT FOUND\n"+ + "%s\nRequested resource not found.", resp.meta) + return + case STATUS_GONE: + err = fmt.Errorf("Gemini Response Status: 52 GONE\n"+ + "%s\nRequested resource permanently gone.", resp.meta) + return + case STATUS_PROXY_REQUEST_REFUSED: + err = fmt.Errorf("Gemini Response Status: 53 Proxy Request Refused\n"+ + "%s\nServer will not process Proxy request.", resp.meta) + return + case STATUS_BAD_REQUEST: + err = fmt.Errorf("Gemini Response Status: 59 BAD REQUEST\n"+ + "%s\nServer unable to process request.", resp.meta) + return + case STATUS_CLIENT_CERTIFICATE_REQUIRED: + err = fmt.Errorf("Gemini Response Status: 60 CLIENT CERTIFICATE REQUIRED\n"+ + "%s\nAdd a TLS client certificate using the `--tlskey` and "+ + "`--tlscert` flags.\n"+ + "Use `%s --help` for details", resp.meta, os.Args[0]) + return + case STATUS_CERTIFICATE_NOT_AUTHORISED: + err = fmt.Errorf("Gemini Response Status: 61 CERTIFICATE NOT AUTHORIZED\n"+ + "%s\nTLS client certificate is not authorized to access resource.", + resp.meta) + return + case STATUS_CERTIFICATE_NOT_VALID: + err = fmt.Errorf("Gemini Response Status: 62 CERTIFICATE NOT VALID\n"+ + "%s\nTechnical issue with TLS client certificate.", resp.meta) + return + } + return nil +} diff --git a/src/gmi.go b/src/gmi.go new file mode 100644 index 0000000..5e62340 --- /dev/null +++ b/src/gmi.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "regexp" + "strings" + "time" +) + +const ( + GEMTEXT_TEXT int = iota + GEMTEXT_LINK + GEMTEXT_PREFORMATTED_TOGGLE + GEMTEXT_PREFORMATTED_TEXT + GEMTEXT_HEADING + GEMTEXT_LIST_ITEM + GEMTEXT_QUOTE + PLAINTEXT_TEXT +) + +var ( + nonAsciiRe = regexp.MustCompile(`[^\x00-\x7F]`) +) + +type gemtextLine struct { + lineType int + text string + path string + level int +} + +func parseGemtextData(resourcePath string, r io.Reader, content []byte) { + contentLen := len(content) + var preformattedToggle bool + if size == "a4" { + *msString += fmt.Sprintf(msLines[A4]) + } else if size == "tabloid" { + *msString += fmt.Sprintf(msLines[TABLOID]) + } else if size == "legal" { + *msString += fmt.Sprintf(msLines[LEGAL]) + } else { + *msString += fmt.Sprintf(msLines[LETTER]) + } + timeNow := time.Now().UTC().Format("2006-01-02 15:04:05 UTC") + if !excludeHeader { + // Escape percentage signs of resource path + resource := strings.ReplaceAll(resourcePath, "%", "%%") + *msString += fmt.Sprintf(msLines[HEADERS], resource) + } else { + // Blank header + *msString += ".ds LH\n.ds CH\n.ds RH\n" + } + if !excludeFooter { + *msString += fmt.Sprintf(msLines[FOOTERS], timeNow, "text/gemini") + } else { + // Blank footer + *msString += ".ds LF\n.ds CF\n.ds RF\n" + } + *msString += ".P1\n" + if twoColumn { + *msString += ".2C\n" + } + var end bool + for !end { + rBuf := make([]byte, 1024) + n, err := r.Read(rBuf) + if err != nil || n == 0 { + end = true + } + content = append(content, rBuf[:n]...) + contentLen += n + for { + newlineIndex := bytes.Index(content, []byte("\n")) + if newlineIndex < 0 || contentLen == 0 { + break + } + line := parseGemtextLine(string(content[:newlineIndex]), + preformattedToggle) + if line.lineType == GEMTEXT_PREFORMATTED_TOGGLE { + preformattedToggle = !preformattedToggle + if preformattedToggle { + *msString += msLines[PREFORMATTED_TOGGLE_ON] + } else { + *msString += msLines[PREFORMATTED_TOGGLE_OFF] + } + } + writeMSToMSString(line) + content = content[newlineIndex+1:] + contentLen -= newlineIndex + } + } + if len(content) > 0 { + line := parseGemtextLine(string(content), preformattedToggle) + if line.lineType == GEMTEXT_PREFORMATTED_TOGGLE { + preformattedToggle = !preformattedToggle + if preformattedToggle { + *msString += msLines[PREFORMATTED_TOGGLE_ON] + } else { + *msString += msLines[PREFORMATTED_TOGGLE_OFF] + } + } + writeMSToMSString(line) + } +} + +func parseGemtextLine(line string, preformattedToggle bool) (g gemtextLine) { + if !preformattedToggle { + switch { + // Check if preformatted toggle (5.4.3 of specification.gmi) + case strings.HasPrefix(line, "```"): + g.lineType = GEMTEXT_PREFORMATTED_TOGGLE + // Check if invalid heading + case strings.HasPrefix(line, "####"): + g.lineType = GEMTEXT_HEADING + g.level = 3 + g.text = line[3:] + // Check if heading level 3 (5.5.1 of specification.gmi) + case strings.HasPrefix(line, "###"): + g.lineType = GEMTEXT_HEADING + g.level = 3 + g.text = strings.TrimSpace(line[3:]) + // Check if heading level 2 (5.5.1 of specification.gmi) + case strings.HasPrefix(line, "##"): + g.lineType = GEMTEXT_HEADING + g.level = 2 + g.text = strings.TrimSpace(line[2:]) + // Check if heading level 1 (5.5.1 of specification.gmi) + case strings.HasPrefix(line, "#"): + g.lineType = GEMTEXT_HEADING + g.level = 1 + g.text = strings.TrimSpace(line[1:]) + // Check if list item (5.5.2 of specification.gmi) + case strings.HasPrefix(line, "* "): + g.lineType = GEMTEXT_LIST_ITEM + g.text = strings.TrimSpace(line[2:]) + // Check if link (5.4.2 of specification.gmi) + case strings.HasPrefix(line, "=> ") || strings.HasPrefix(line, "=>\t"): + g.lineType = GEMTEXT_LINK + lineFields := strings.Fields(strings.TrimSpace(line[3:])) + if len(lineFields) > 0 { + g.path = lineFields[0] + g.text = strings.TrimSpace(line[3+len(lineFields[0]):]) + } + // Check if quote (5.5.3 of specification.gmi) + case strings.HasPrefix(line, ">"): + g.lineType = GEMTEXT_QUOTE + g.text = strings.TrimSpace(line[1:]) + // + default: + g.lineType = GEMTEXT_TEXT + g.text = strings.TrimSpace(line) + } + } else { + switch { + // Check if preformatted text (5.4.3 of specification.gmi) + case strings.HasPrefix(line, "```"): + g.lineType = GEMTEXT_PREFORMATTED_TOGGLE + // Preformatted text (5.4.4 of specification.gmi) + default: + g.lineType = GEMTEXT_PREFORMATTED_TEXT + g.text = line + } + } + return +} diff --git a/src/gmi2pdf.go b/src/gmi2pdf.go new file mode 100644 index 0000000..e46c400 --- /dev/null +++ b/src/gmi2pdf.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "os" +) + +const ( + PROGRAM = "gmi2pdf" + VERSION = "0.0.1" +) + +var ( + msString *string +) + +func init() { + if err := handleFlags(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func main() { + msString = new(string) + if inputFilePath != "" { + f, err := os.OpenFile(inputFilePath, os.O_RDONLY, 0444) + if err != nil { + handleErr(err, fmt.Sprintf("Unable to open gemtext file %s", + inputFilePath)) + } + defer f.Close() + if !plaintext { + parseGemtextData(inputFilePath, f, []byte{}) + } else { + parsePlaintextData(inputFilePath, f, []byte{}) + } + } else { + fullAddress := address + if query != "" { + fullAddress += "?" + query + } + err := doGeminiRequest(fullAddress) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + runGroff() + if debug { + fmt.Print(*msString) + } +} diff --git a/src/groff.go b/src/groff.go new file mode 100644 index 0000000..3cacd83 --- /dev/null +++ b/src/groff.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +func runGroff() { + pageSize := "11i,8.5i" + switch size { + case "legal": + pageSize = "14i,8.5i" + case "tabloid": + pageSize = "17i,11i" + case "octavo": + pageSize = "9i,6i" + case "a4": + pageSize = "29.7c,21c" + } + groffCmd := exec.Command("groff", "-Tpdf", "-ms", "-rHY=0", "-t", + "-P-p"+pageSize, "-") + groffCmd.Stdin = strings.NewReader(*msString) + if ghostscriptAvailable() { + gsCmd := exec.Command("gs", "-o", outputFilePath, "-sDEVICE=pdfwrite", "-") + var err error + gsCmd.Stdin, err = groffCmd.StdoutPipe() + handleErr(err, "Unable to pipe groff STDOUT to gs STDIN") + handleErr(gsCmd.Start(), "Unable to run gs command") + handleErr(groffCmd.Run(), "Unable to run groff command") + handleErr(gsCmd.Wait(), "Error when running gs command") + } else { + outFile, err := os.Create(outputFilePath) + handleErr(err, fmt.Sprintf("Unable to create file %s", outFile)) + defer outFile.Close() + groffCmd.Stdout = outFile + handleErr(groffCmd.Run(), "Unable to run groff command") + } +} + +func ghostscriptAvailable() bool { + cmd := exec.Command("/bin/sh", "-c", "command -v gs") + if err := cmd.Run(); err != nil { + return false + } + return true +} diff --git a/src/ms.go b/src/ms.go new file mode 100644 index 0000000..4489671 --- /dev/null +++ b/src/ms.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + "strings" +) + +const ( + LETTER int = iota + OCTAVO + A4 + TABLOID + LEGAL + HEADERS + FOOTERS + HEADING_1 + HEADING_2 + HEADING_3 + TEXT + LIST_ITEM + LINK + LINK_BLANK + QUOTE + PREFORMATTED_TOGGLE_ON + PREFORMATTED_TOGGLE_OFF + PREFORMATTED_TEXT + PLAINTEXT +) + +var ( + msLines = map[int]string{ + LETTER: ".nr PO 1i\n.nr LL 6.5i\n.nr HM 1i\n.nr FM 1i\n.ll 6.5i\n.pl 11i\n", + A4: ".nr PO 2.5c\n.nr LL 16c\n.nr HM 2.5c\n.nr FM 2.5c\n.ll 16c\n.pl 29.7c\n", + LEGAL: ".nr PO 1i\n.nr LL 6.5i\n.nr HM 1i\n.nr FM 1i\n.ll 6.5i\n.pl 14i\n", + TABLOID: ".nr PO 1i\n.nr LL 9i\n.nr HM 1i\n.nr FM 1i\n.ll 9i\n.pl 17i\n", + OCTAVO: ".nr PO 0.75i\n.nr LL 4.5i\n.nr HM 1i\n.nr FM 1i\n.ll 4.5i\n.pl 9i\n", + HEADERS: ".ds LH \\fB%s\\fR\n.ds CH\n.ds RH\n", + FOOTERS: ".ds LF Received: \\fB%s\\fR\n.ds CF Page %%\n.ds RF MIME Type: \\fB%s\\fR\n", + HEADING_1: ".ps 24\n.vs 24\n\\fB%s\\fR\n.sp 0\n.ps 12\n.vs 12\n.sp 0.5\n", + HEADING_2: ".ps 20\n.vs 20\n\\fB%s\\fR\n.sp 0\n.ps 12\n.vs 12\n.sp 0.5\n", + HEADING_3: ".ps 16\n.vs 16\n\\fB%s\\fR\n.sp 0\n.ps 12\n.vs 12\n.sp 0.5\n", + TEXT: "%s\n.sp 0\n", + LIST_ITEM: ".IP \\(bu 2\n.ps 12\n.vs 12\n%s\n.in 0\n.sp 0\n", + LINK: "\\fB\\(rA %s\\fR - \\fI%s\\fR\n.sp 0\n", + LINK_BLANK: "\\fB\\(rA %s\\fR\n.sp 0\n", + QUOTE: ".in +1\n\\fI%s\\fR\n.in -1\n.sp 0\n", + PREFORMATTED_TOGGLE_ON: ".sp 0.5\n.mk\n.rj 1\n\\(rn\\(rn\\(br\n.rt\n\\(br\\(rn\\(rn\n.sp -0.5\n", + PREFORMATTED_TOGGLE_OFF: ".sp -0.5\n.mk\n.rj 1\n\\(ul\\(ul\\(br\n.rt\n\\(br\\(ul\\(ul\n.sp 0.5\n", + PREFORMATTED_TEXT: ".in +0.5\n.ll -0.5\n\\fC%s\\fR\n.ll +0.5\n.in -0.5\n.sp 0\n", + PLAINTEXT: "\\fC%s\\fR\n.sp 0\n", + } +) + +func writeMSToMSString(line gemtextLine) { + unicodeSq := "" + if unicode { + unicodeSq = "\\(sq" + } + t := line.text + t = strings.ReplaceAll(t, "\\", "\\(rs") + if len(t) > 0 { + if t[0] == '.' { + t = "\\&" + t + } + } + if line.lineType != GEMTEXT_PREFORMATTED_TEXT { + t = nonAsciiRe.ReplaceAllString(t, unicodeSq) + } + p := line.path + p = strings.ReplaceAll(p, "\\", "\\(rs") + if len(p) > 0 { + if p[0] == '.' { + p = "\\&" + p + } + } + p = nonAsciiRe.ReplaceAllString(p, unicodeSq) + switch line.lineType { + case PLAINTEXT_TEXT: + preformattedT := "" + for _, letter := range t { + preformattedT += string(letter) + if letter != ' ' { + preformattedT += "\\:" + } + } + preformattedT = nonAsciiRe.ReplaceAllString(preformattedT, unicodeSq) + if len(t) == 0 { + *msString += ".sp\n" + } else { + *msString += fmt.Sprintf(msLines[PLAINTEXT], preformattedT) + } + case GEMTEXT_PREFORMATTED_TEXT: + preformattedT := "" + for _, letter := range t { + preformattedT += string(letter) + if letter != ' ' { + preformattedT += "\\:" + } + } + preformattedT = nonAsciiRe.ReplaceAllString(preformattedT, unicodeSq) + *msString += fmt.Sprintf(msLines[PREFORMATTED_TEXT], preformattedT) + case GEMTEXT_TEXT: + if len(t) == 0 { + *msString += ".sp\n" + } else { + *msString += fmt.Sprintf(msLines[TEXT], t) + } + case GEMTEXT_LINK: + if t == "" { + *msString += fmt.Sprintf(msLines[LINK_BLANK], p) + } else { + *msString += fmt.Sprintf(msLines[LINK], t, p) + } + case GEMTEXT_QUOTE: + *msString += fmt.Sprintf(msLines[QUOTE], t) + case GEMTEXT_LIST_ITEM: + *msString += fmt.Sprintf(msLines[LIST_ITEM], t) + case GEMTEXT_HEADING: + switch line.level { + case 1: + *msString += fmt.Sprintf(msLines[HEADING_1], t) + case 2: + *msString += fmt.Sprintf(msLines[HEADING_2], t) + case 3: + *msString += fmt.Sprintf(msLines[HEADING_3], t) + } + } +} diff --git a/src/txt.go b/src/txt.go new file mode 100644 index 0000000..08e2ab9 --- /dev/null +++ b/src/txt.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "strings" + "time" +) + +func parsePlaintextData(resourcePath string, r io.Reader, content []byte) { + contentLen := len(content) + if size == "a4" { + *msString += fmt.Sprintf(msLines[A4]) + } else if size == "tabloid" { + *msString += fmt.Sprintf(msLines[TABLOID]) + } else if size == "legal" { + *msString += fmt.Sprintf(msLines[LEGAL]) + } else { + *msString += fmt.Sprintf(msLines[LETTER]) + } + timeNow := time.Now().UTC().Format("2006-01-02 15:04:05 UTC") + if !excludeHeader { + // Escape percentage signs of resource path + resource := strings.ReplaceAll(resourcePath, "%", "%%") + *msString += fmt.Sprintf(msLines[HEADERS], resource) + } else { + // Blank header + *msString += ".ds LH\n.ds CH\n.ds RH\n" + } + if !excludeFooter { + *msString += fmt.Sprintf(msLines[FOOTERS], timeNow, "text/plain") + } else { + // Blank footer + *msString += ".ds LF\n.ds CF\n.ds RF\n" + } + *msString += ".P1\n" + if twoColumn { + *msString += ".2C\n" + } + var end bool + for !end { + rBuf := make([]byte, 1024) + n, err := r.Read(rBuf) + if err != nil || n == 0 { + end = true + } + content = append(content, rBuf[:n]...) + contentLen += n + for { + newlineIndex := bytes.Index(content, []byte("\n")) + if newlineIndex < 0 || contentLen == 0 { + break + } + var line gemtextLine + line.text = string(content[:newlineIndex]) + line.lineType = PLAINTEXT_TEXT + writeMSToMSString(line) + content = content[newlineIndex+1:] + contentLen -= newlineIndex + } + } + if len(content) > 0 { + var line gemtextLine + line.text = string(content) + line.lineType = PLAINTEXT_TEXT + writeMSToMSString(line) + } +}