feat: add CLI with TUI, self-update, install script, and terminal section on landing page
- Full-screen Bubble Tea TUI with cream background fill using PadLine/FillBlankLines - Self-update command (--update) pulling from GitHub releases - install.sh for curl one-liner installation - Terminal Lovers section on web landing page with install command and CLI features - All 7 format categories, glob/directory batch support, auto-download ffmpeg
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/noauf/transmute-cli/internal/detect"
|
||||
)
|
||||
|
||||
// Result holds the outcome of a single conversion.
|
||||
type Result struct {
|
||||
InputPath string
|
||||
OutputPath string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Convert is the main entry point. It routes to the correct converter based on
|
||||
// the source file's category.
|
||||
func Convert(inputPath, targetFormat, outputDir string) Result {
|
||||
ext := strings.TrimPrefix(filepath.Ext(inputPath), ".")
|
||||
ext = strings.ToLower(ext)
|
||||
|
||||
cat := detect.DetectCategory(ext)
|
||||
|
||||
// Determine output path
|
||||
base := strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath))
|
||||
dir := filepath.Dir(inputPath)
|
||||
if outputDir != "" {
|
||||
dir = outputDir
|
||||
}
|
||||
outPath := filepath.Join(dir, base+"."+targetFormat)
|
||||
|
||||
// Avoid overwriting — append _converted if output == input
|
||||
if outPath == inputPath {
|
||||
outPath = filepath.Join(dir, base+"_converted."+targetFormat)
|
||||
}
|
||||
|
||||
var err error
|
||||
switch cat {
|
||||
case detect.CategoryImage:
|
||||
err = convertImage(inputPath, outPath, targetFormat)
|
||||
case detect.CategoryDocument:
|
||||
err = convertDocument(inputPath, outPath, ext, targetFormat)
|
||||
case detect.CategoryAudio, detect.CategoryVideo:
|
||||
err = convertMedia(inputPath, outPath, targetFormat)
|
||||
case detect.CategoryData:
|
||||
err = convertData(inputPath, outPath, ext, targetFormat)
|
||||
case detect.CategorySpreadsheet:
|
||||
err = convertSpreadsheet(inputPath, outPath, ext, targetFormat)
|
||||
case detect.CategoryFont:
|
||||
err = convertFont(inputPath, outPath, ext, targetFormat)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported file type: %s", ext)
|
||||
}
|
||||
|
||||
return Result{
|
||||
InputPath: inputPath,
|
||||
OutputPath: outPath,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// OutputPath computes what the output path would be without performing conversion.
|
||||
func OutputPath(inputPath, targetFormat, outputDir string) string {
|
||||
base := strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath))
|
||||
dir := filepath.Dir(inputPath)
|
||||
if outputDir != "" {
|
||||
dir = outputDir
|
||||
}
|
||||
outPath := filepath.Join(dir, base+"."+targetFormat)
|
||||
if outPath == inputPath {
|
||||
outPath = filepath.Join(dir, base+"_converted."+targetFormat)
|
||||
}
|
||||
return outPath
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func convertData(inputPath, outputPath, sourceExt, targetFormat string) error {
|
||||
// Strategy: parse input into a generic Go structure, then serialize to target format.
|
||||
// For tabular data (CSV, TSV) we use [][]string -> []map[string]interface{} (first row = headers).
|
||||
// For structured data (JSON, YAML, TOML, XML) we use interface{}.
|
||||
|
||||
raw, err := os.ReadFile(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading input: %w", err)
|
||||
}
|
||||
|
||||
// Determine if source is tabular or structured
|
||||
switch sourceExt {
|
||||
case "csv", "tsv":
|
||||
return convertTabularData(raw, outputPath, sourceExt, targetFormat)
|
||||
case "json":
|
||||
var data interface{}
|
||||
if err := json.Unmarshal(raw, &data); err != nil {
|
||||
return fmt.Errorf("parsing JSON: %w", err)
|
||||
}
|
||||
return writeData(data, outputPath, targetFormat)
|
||||
case "ndjson", "jsonl":
|
||||
return convertNDJSON(raw, outputPath, targetFormat)
|
||||
case "yaml", "yml":
|
||||
var data interface{}
|
||||
if err := yaml.Unmarshal(raw, &data); err != nil {
|
||||
return fmt.Errorf("parsing YAML: %w", err)
|
||||
}
|
||||
return writeData(data, outputPath, targetFormat)
|
||||
case "toml":
|
||||
var data interface{}
|
||||
if err := toml.Unmarshal(raw, &data); err != nil {
|
||||
return fmt.Errorf("parsing TOML: %w", err)
|
||||
}
|
||||
return writeData(data, outputPath, targetFormat)
|
||||
case "xml":
|
||||
return convertXML(raw, outputPath, targetFormat)
|
||||
case "ini", "env", "properties":
|
||||
data := parseKeyValue(string(raw), sourceExt)
|
||||
return writeData(data, outputPath, targetFormat)
|
||||
case "sql":
|
||||
return convertSQL(raw, outputPath, targetFormat)
|
||||
default:
|
||||
return fmt.Errorf("unsupported data source format: %s", sourceExt)
|
||||
}
|
||||
}
|
||||
|
||||
func convertTabularData(raw []byte, outputPath, sourceExt, targetFormat string) error {
|
||||
delimiter := ','
|
||||
if sourceExt == "tsv" {
|
||||
delimiter = '\t'
|
||||
}
|
||||
|
||||
reader := csv.NewReader(strings.NewReader(string(raw)))
|
||||
reader.Comma = delimiter
|
||||
reader.LazyQuotes = true
|
||||
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing %s: %w", sourceExt, err)
|
||||
}
|
||||
|
||||
if len(records) == 0 {
|
||||
return fmt.Errorf("empty %s file", sourceExt)
|
||||
}
|
||||
|
||||
// Convert to []map[string]interface{} using first row as headers
|
||||
headers := records[0]
|
||||
var rows []map[string]interface{}
|
||||
for _, record := range records[1:] {
|
||||
row := make(map[string]interface{})
|
||||
for i, header := range headers {
|
||||
if i < len(record) {
|
||||
row[header] = record[i]
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
switch targetFormat {
|
||||
case "json":
|
||||
return writeJSON(rows, outputPath)
|
||||
case "yaml", "yml":
|
||||
return writeYAML(rows, outputPath)
|
||||
case "toml":
|
||||
wrapper := map[string]interface{}{"data": rows}
|
||||
return writeTOML(wrapper, outputPath)
|
||||
case "xml":
|
||||
return writeXMLFromRows(rows, outputPath)
|
||||
case "tsv":
|
||||
return writeDelimited(headers, records[1:], outputPath, '\t')
|
||||
case "csv":
|
||||
return writeDelimited(headers, records[1:], outputPath, ',')
|
||||
case "html":
|
||||
return writeHTMLTable(headers, records[1:], outputPath)
|
||||
case "sql":
|
||||
return writeSQLInserts(headers, records[1:], outputPath, "data")
|
||||
case "ndjson":
|
||||
return writeNDJSON(rows, outputPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported target format for tabular data: %s", targetFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func convertNDJSON(raw []byte, outputPath, targetFormat string) error {
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
var items []interface{}
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var item interface{}
|
||||
if err := json.Unmarshal([]byte(line), &item); err != nil {
|
||||
continue // skip invalid lines
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return writeData(items, outputPath, targetFormat)
|
||||
}
|
||||
|
||||
func convertXML(raw []byte, outputPath, targetFormat string) error {
|
||||
// Simple XML -> generic map conversion
|
||||
var data interface{}
|
||||
if err := xml.Unmarshal(raw, &data); err != nil {
|
||||
// XML to map is tricky — treat as string content for simple cases
|
||||
// or use a simple parser
|
||||
data = map[string]interface{}{"xml_content": string(raw)}
|
||||
}
|
||||
return writeData(data, outputPath, targetFormat)
|
||||
}
|
||||
|
||||
func convertSQL(raw []byte, outputPath, targetFormat string) error {
|
||||
// Very basic: extract INSERT statement values
|
||||
content := string(raw)
|
||||
lines := strings.Split(content, "\n")
|
||||
var records []map[string]interface{}
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
upper := strings.ToUpper(line)
|
||||
if strings.HasPrefix(upper, "INSERT") {
|
||||
// Very basic INSERT parser — extract values
|
||||
valIdx := strings.Index(upper, "VALUES")
|
||||
if valIdx == -1 {
|
||||
continue
|
||||
}
|
||||
valPart := line[valIdx+6:]
|
||||
valPart = strings.Trim(valPart, " ;()")
|
||||
values := strings.Split(valPart, ",")
|
||||
row := make(map[string]interface{})
|
||||
for i, v := range values {
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.Trim(v, "'\"")
|
||||
row[fmt.Sprintf("col%d", i+1)] = v
|
||||
}
|
||||
records = append(records, row)
|
||||
}
|
||||
}
|
||||
|
||||
if targetFormat == "json" {
|
||||
return writeJSON(records, outputPath)
|
||||
}
|
||||
if targetFormat == "csv" {
|
||||
// Flatten to CSV
|
||||
if len(records) == 0 {
|
||||
return os.WriteFile(outputPath, []byte(""), 0o644)
|
||||
}
|
||||
var headers []string
|
||||
for k := range records[0] {
|
||||
headers = append(headers, k)
|
||||
}
|
||||
var csvRecords [][]string
|
||||
for _, r := range records {
|
||||
var row []string
|
||||
for _, h := range headers {
|
||||
row = append(row, fmt.Sprintf("%v", r[h]))
|
||||
}
|
||||
csvRecords = append(csvRecords, row)
|
||||
}
|
||||
return writeDelimited(headers, csvRecords, outputPath, ',')
|
||||
}
|
||||
return fmt.Errorf("unsupported target format for SQL: %s", targetFormat)
|
||||
}
|
||||
|
||||
func parseKeyValue(content, format string) map[string]interface{} {
|
||||
result := make(map[string]interface{})
|
||||
lines := strings.Split(content, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
// Handle different separators
|
||||
sep := "="
|
||||
if format == "properties" && strings.Contains(line, ":") && !strings.Contains(line, "=") {
|
||||
sep = ":"
|
||||
}
|
||||
parts := strings.SplitN(line, sep, 2)
|
||||
if len(parts) == 2 {
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
// Remove quotes from .env values
|
||||
if format == "env" {
|
||||
value = strings.Trim(value, "\"'")
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Writers ─────────────────────────────────────────────────
|
||||
|
||||
func writeData(data interface{}, outputPath, targetFormat string) error {
|
||||
switch targetFormat {
|
||||
case "json":
|
||||
return writeJSON(data, outputPath)
|
||||
case "yaml", "yml":
|
||||
return writeYAML(data, outputPath)
|
||||
case "toml":
|
||||
return writeTOML(data, outputPath)
|
||||
case "csv":
|
||||
return writeDataAsCSV(data, outputPath)
|
||||
case "tsv":
|
||||
return writeDataAsTSV(data, outputPath)
|
||||
case "xml":
|
||||
return writeXMLGeneric(data, outputPath)
|
||||
case "html":
|
||||
return writeDataAsHTML(data, outputPath)
|
||||
case "ndjson":
|
||||
return writeDataAsNDJSON(data, outputPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported target format: %s", targetFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(data interface{}, outputPath string) error {
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outputPath, b, 0o644)
|
||||
}
|
||||
|
||||
func writeYAML(data interface{}, outputPath string) error {
|
||||
b, err := yaml.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outputPath, b, 0o644)
|
||||
}
|
||||
|
||||
func writeTOML(data interface{}, outputPath string) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
enc := toml.NewEncoder(f)
|
||||
return enc.Encode(data)
|
||||
}
|
||||
|
||||
func writeDelimited(headers []string, rows [][]string, outputPath string, sep rune) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := csv.NewWriter(f)
|
||||
w.Comma = sep
|
||||
if err := w.Write(headers); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := w.Write(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
return w.Error()
|
||||
}
|
||||
|
||||
func writeHTMLTable(headers []string, rows [][]string, outputPath string) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<table>\n<thead>\n<tr>")
|
||||
for _, h := range headers {
|
||||
sb.WriteString("<th>" + h + "</th>")
|
||||
}
|
||||
sb.WriteString("</tr>\n</thead>\n<tbody>\n")
|
||||
for _, row := range rows {
|
||||
sb.WriteString("<tr>")
|
||||
for _, cell := range row {
|
||||
sb.WriteString("<td>" + cell + "</td>")
|
||||
}
|
||||
sb.WriteString("</tr>\n")
|
||||
}
|
||||
sb.WriteString("</tbody>\n</table>")
|
||||
return os.WriteFile(outputPath, []byte(sb.String()), 0o644)
|
||||
}
|
||||
|
||||
func writeSQLInserts(headers []string, rows [][]string, outputPath, tableName string) error {
|
||||
var sb strings.Builder
|
||||
cols := strings.Join(headers, ", ")
|
||||
for _, row := range rows {
|
||||
var vals []string
|
||||
for _, v := range row {
|
||||
vals = append(vals, "'"+strings.ReplaceAll(v, "'", "''")+"'")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);\n",
|
||||
tableName, cols, strings.Join(vals, ", ")))
|
||||
}
|
||||
return os.WriteFile(outputPath, []byte(sb.String()), 0o644)
|
||||
}
|
||||
|
||||
func writeNDJSON(rows []map[string]interface{}, outputPath string) error {
|
||||
var sb strings.Builder
|
||||
for _, row := range rows {
|
||||
b, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sb.Write(b)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return os.WriteFile(outputPath, []byte(sb.String()), 0o644)
|
||||
}
|
||||
|
||||
func writeXMLFromRows(rows []map[string]interface{}, outputPath string) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data>\n")
|
||||
for _, row := range rows {
|
||||
sb.WriteString(" <row>\n")
|
||||
for k, v := range row {
|
||||
sb.WriteString(fmt.Sprintf(" <%s>%v</%s>\n", k, v, k))
|
||||
}
|
||||
sb.WriteString(" </row>\n")
|
||||
}
|
||||
sb.WriteString("</data>")
|
||||
return os.WriteFile(outputPath, []byte(sb.String()), 0o644)
|
||||
}
|
||||
|
||||
func writeXMLGeneric(data interface{}, outputPath string) error {
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Wrap JSON in XML as a simple approach
|
||||
content := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<data>\n" + string(b) + "\n</data>"
|
||||
return os.WriteFile(outputPath, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func writeDataAsCSV(data interface{}, outputPath string) error {
|
||||
rows := toRowsOfMaps(data)
|
||||
if len(rows) == 0 {
|
||||
return os.WriteFile(outputPath, []byte(""), 0o644)
|
||||
}
|
||||
headers := extractHeaders(rows[0])
|
||||
var records [][]string
|
||||
for _, row := range rows {
|
||||
var record []string
|
||||
for _, h := range headers {
|
||||
record = append(record, fmt.Sprintf("%v", row[h]))
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return writeDelimited(headers, records, outputPath, ',')
|
||||
}
|
||||
|
||||
func writeDataAsTSV(data interface{}, outputPath string) error {
|
||||
rows := toRowsOfMaps(data)
|
||||
if len(rows) == 0 {
|
||||
return os.WriteFile(outputPath, []byte(""), 0o644)
|
||||
}
|
||||
headers := extractHeaders(rows[0])
|
||||
var records [][]string
|
||||
for _, row := range rows {
|
||||
var record []string
|
||||
for _, h := range headers {
|
||||
record = append(record, fmt.Sprintf("%v", row[h]))
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
return writeDelimited(headers, records, outputPath, '\t')
|
||||
}
|
||||
|
||||
func writeDataAsHTML(data interface{}, outputPath string) error {
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
html := "<html><body><pre>" + string(b) + "</pre></body></html>"
|
||||
return os.WriteFile(outputPath, []byte(html), 0o644)
|
||||
}
|
||||
|
||||
func writeDataAsNDJSON(data interface{}, outputPath string) error {
|
||||
rows := toRowsOfMaps(data)
|
||||
var sb strings.Builder
|
||||
for _, row := range rows {
|
||||
b, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sb.Write(b)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return os.WriteFile(outputPath, []byte(sb.String()), 0o644)
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
func toRowsOfMaps(data interface{}) []map[string]interface{} {
|
||||
switch v := data.(type) {
|
||||
case []interface{}:
|
||||
var rows []map[string]interface{}
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
rows = append(rows, m)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
case []map[string]interface{}:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
return []map[string]interface{}{v}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func extractHeaders(row map[string]interface{}) []string {
|
||||
var headers []string
|
||||
for k := range row {
|
||||
headers = append(headers, k)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/russross/blackfriday/v2"
|
||||
)
|
||||
|
||||
func convertDocument(inputPath, outputPath, sourceExt, targetFormat string) error {
|
||||
raw, err := os.ReadFile(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading document: %w", err)
|
||||
}
|
||||
content := string(raw)
|
||||
|
||||
switch sourceExt {
|
||||
case "txt":
|
||||
return convertFromTxt(content, outputPath, targetFormat)
|
||||
case "md":
|
||||
return convertFromMarkdown(content, outputPath, targetFormat)
|
||||
case "html", "htm":
|
||||
return convertFromHTML(content, outputPath, targetFormat)
|
||||
case "rtf":
|
||||
return convertFromRTF(content, outputPath, targetFormat)
|
||||
case "docx":
|
||||
return convertDocx(inputPath, outputPath, targetFormat)
|
||||
case "pdf":
|
||||
return convertPdf(inputPath, outputPath, targetFormat)
|
||||
default:
|
||||
return fmt.Errorf("unsupported document source: %s", sourceExt)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── TXT conversions ─────────────────────────────────────────
|
||||
|
||||
func convertFromTxt(content, outputPath, target string) error {
|
||||
switch target {
|
||||
case "html":
|
||||
html := "<html><body><pre>" + escapeHTML(content) + "</pre></body></html>"
|
||||
return os.WriteFile(outputPath, []byte(html), 0o644)
|
||||
case "md":
|
||||
return os.WriteFile(outputPath, []byte(content), 0o644)
|
||||
case "pdf":
|
||||
return textToPDF(content, outputPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported target for txt: %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Markdown conversions ────────────────────────────────────
|
||||
|
||||
func convertFromMarkdown(content, outputPath, target string) error {
|
||||
switch target {
|
||||
case "html":
|
||||
html := blackfriday.Run([]byte(content))
|
||||
wrapped := "<html><body>" + string(html) + "</body></html>"
|
||||
return os.WriteFile(outputPath, []byte(wrapped), 0o644)
|
||||
case "txt":
|
||||
text := stripMarkdown(content)
|
||||
return os.WriteFile(outputPath, []byte(text), 0o644)
|
||||
case "pdf":
|
||||
html := string(blackfriday.Run([]byte(content)))
|
||||
return htmlToPDF(html, outputPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported target for md: %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTML conversions ────────────────────────────────────────
|
||||
|
||||
func convertFromHTML(content, outputPath, target string) error {
|
||||
switch target {
|
||||
case "txt":
|
||||
text := stripHTMLTags(content)
|
||||
return os.WriteFile(outputPath, []byte(text), 0o644)
|
||||
case "md":
|
||||
md := htmlToMarkdown(content)
|
||||
return os.WriteFile(outputPath, []byte(md), 0o644)
|
||||
case "pdf":
|
||||
return htmlToPDF(content, outputPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported target for html: %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── RTF conversions ─────────────────────────────────────────
|
||||
|
||||
func convertFromRTF(content, outputPath, target string) error {
|
||||
text := stripRTF(content)
|
||||
switch target {
|
||||
case "txt":
|
||||
return os.WriteFile(outputPath, []byte(text), 0o644)
|
||||
case "html":
|
||||
html := "<html><body><pre>" + escapeHTML(text) + "</pre></body></html>"
|
||||
return os.WriteFile(outputPath, []byte(html), 0o644)
|
||||
case "md":
|
||||
return os.WriteFile(outputPath, []byte(text), 0o644)
|
||||
default:
|
||||
return fmt.Errorf("unsupported target for rtf: %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DOCX conversions ────────────────────────────────────────
|
||||
|
||||
func convertDocx(inputPath, outputPath, target string) error {
|
||||
text, err := extractDocxText(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extracting DOCX text: %w", err)
|
||||
}
|
||||
|
||||
switch target {
|
||||
case "txt":
|
||||
return os.WriteFile(outputPath, []byte(text), 0o644)
|
||||
case "html":
|
||||
html := "<html><body><pre>" + escapeHTML(text) + "</pre></body></html>"
|
||||
return os.WriteFile(outputPath, []byte(html), 0o644)
|
||||
case "md":
|
||||
return os.WriteFile(outputPath, []byte(text), 0o644)
|
||||
case "pdf":
|
||||
return textToPDF(text, outputPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported target for docx: %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── PDF conversions ─────────────────────────────────────────
|
||||
|
||||
func convertPdf(inputPath, outputPath, target string) error {
|
||||
text, err := extractPDFText(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("extracting PDF text: %w", err)
|
||||
}
|
||||
|
||||
switch target {
|
||||
case "txt":
|
||||
return os.WriteFile(outputPath, []byte(text), 0o644)
|
||||
case "html":
|
||||
html := "<html><body><pre>" + escapeHTML(text) + "</pre></body></html>"
|
||||
return os.WriteFile(outputPath, []byte(html), 0o644)
|
||||
case "md":
|
||||
return os.WriteFile(outputPath, []byte(text), 0o644)
|
||||
default:
|
||||
return fmt.Errorf("unsupported target for pdf: %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
func stripHTMLTags(html string) string {
|
||||
var result strings.Builder
|
||||
inTag := false
|
||||
for _, r := range html {
|
||||
switch {
|
||||
case r == '<':
|
||||
inTag = true
|
||||
case r == '>':
|
||||
inTag = false
|
||||
case !inTag:
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func stripMarkdown(md string) string {
|
||||
lines := strings.Split(md, "\n")
|
||||
var result []string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimLeft(line, "# ")
|
||||
line = strings.ReplaceAll(line, "**", "")
|
||||
line = strings.ReplaceAll(line, "*", "")
|
||||
line = strings.ReplaceAll(line, "__", "")
|
||||
line = strings.ReplaceAll(line, "_", "")
|
||||
line = strings.ReplaceAll(line, "`", "")
|
||||
result = append(result, line)
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
func htmlToMarkdown(html string) string {
|
||||
md := html
|
||||
md = strings.ReplaceAll(md, "<br>", "\n")
|
||||
md = strings.ReplaceAll(md, "<br/>", "\n")
|
||||
md = strings.ReplaceAll(md, "<br />", "\n")
|
||||
md = strings.ReplaceAll(md, "<p>", "\n")
|
||||
md = strings.ReplaceAll(md, "</p>", "\n")
|
||||
md = strings.ReplaceAll(md, "<strong>", "**")
|
||||
md = strings.ReplaceAll(md, "</strong>", "**")
|
||||
md = strings.ReplaceAll(md, "<em>", "*")
|
||||
md = strings.ReplaceAll(md, "</em>", "*")
|
||||
md = strings.ReplaceAll(md, "<h1>", "# ")
|
||||
md = strings.ReplaceAll(md, "</h1>", "\n")
|
||||
md = strings.ReplaceAll(md, "<h2>", "## ")
|
||||
md = strings.ReplaceAll(md, "</h2>", "\n")
|
||||
md = strings.ReplaceAll(md, "<h3>", "### ")
|
||||
md = strings.ReplaceAll(md, "</h3>", "\n")
|
||||
md = stripHTMLTags(md)
|
||||
return strings.TrimSpace(md)
|
||||
}
|
||||
|
||||
func stripRTF(rtf string) string {
|
||||
var result strings.Builder
|
||||
i := 0
|
||||
depth := 0
|
||||
for i < len(rtf) {
|
||||
ch := rtf[i]
|
||||
switch {
|
||||
case ch == '{':
|
||||
depth++
|
||||
i++
|
||||
case ch == '}':
|
||||
depth--
|
||||
i++
|
||||
case ch == '\\':
|
||||
i++
|
||||
if i < len(rtf) && rtf[i] == '\'' {
|
||||
i += 3
|
||||
} else {
|
||||
for i < len(rtf) && ((rtf[i] >= 'a' && rtf[i] <= 'z') || (rtf[i] >= 'A' && rtf[i] <= 'Z')) {
|
||||
i++
|
||||
}
|
||||
for i < len(rtf) && ((rtf[i] >= '0' && rtf[i] <= '9') || rtf[i] == '-') {
|
||||
i++
|
||||
}
|
||||
if i < len(rtf) && rtf[i] == ' ' {
|
||||
i++
|
||||
}
|
||||
}
|
||||
default:
|
||||
if depth <= 1 {
|
||||
result.WriteByte(ch)
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
// extractDocxText extracts plain text from a .docx file (ZIP of XML files).
|
||||
func extractDocxText(path string) (string, error) {
|
||||
r, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opening docx: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
if f.Name == "word/document.xml" {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return stripHTMLTags(string(data)), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("word/document.xml not found in docx")
|
||||
}
|
||||
|
||||
// extractPDFText tries pdftotext (poppler-utils), falls back to error.
|
||||
func extractPDFText(path string) (string, error) {
|
||||
pdftotextPath, err := exec.LookPath("pdftotext")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("PDF text extraction requires 'pdftotext' — install poppler-utils")
|
||||
}
|
||||
out, err := exec.Command(pdftotextPath, path, "-").CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pdftotext failed: %w\n%s", err, string(out))
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// textToPDF creates a basic PDF from plain text.
|
||||
func textToPDF(text, outputPath string) error {
|
||||
lines := strings.Split(text, "\n")
|
||||
var content strings.Builder
|
||||
|
||||
content.WriteString("%PDF-1.4\n")
|
||||
content.WriteString("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n")
|
||||
content.WriteString("2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n")
|
||||
|
||||
var stream strings.Builder
|
||||
stream.WriteString("BT\n/F1 10 Tf\n")
|
||||
y := 780.0
|
||||
for _, line := range lines {
|
||||
if y < 40 {
|
||||
break
|
||||
}
|
||||
safe := strings.ReplaceAll(line, "\\", "\\\\")
|
||||
safe = strings.ReplaceAll(safe, "(", "\\(")
|
||||
safe = strings.ReplaceAll(safe, ")", "\\)")
|
||||
stream.WriteString(fmt.Sprintf("1 0 0 1 40 %.0f Tm\n(%s) Tj\n", y, safe))
|
||||
y -= 14
|
||||
}
|
||||
stream.WriteString("ET\n")
|
||||
streamBytes := stream.String()
|
||||
|
||||
content.WriteString(fmt.Sprintf("3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>\nendobj\n"))
|
||||
content.WriteString(fmt.Sprintf("4 0 obj\n<< /Length %d >>\nstream\n%sendstream\nendobj\n", len(streamBytes), streamBytes))
|
||||
content.WriteString("5 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n")
|
||||
content.WriteString("xref\n0 6\n")
|
||||
content.WriteString("trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n0\n%%EOF\n")
|
||||
|
||||
return os.WriteFile(outputPath, []byte(content.String()), 0o644)
|
||||
}
|
||||
|
||||
func htmlToPDF(html, outputPath string) error {
|
||||
text := stripHTMLTags(html)
|
||||
return textToPDF(text, outputPath)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/russross/blackfriday/v2"
|
||||
)
|
||||
|
||||
// convertEbook handles epub ↔ txt/html/md/pdf conversions.
|
||||
func convertEbook(inputPath, outputPath, sourceExt, targetFormat string) error {
|
||||
if sourceExt == "epub" {
|
||||
return convertFromEpub(inputPath, outputPath, targetFormat)
|
||||
}
|
||||
// txt/html/md → epub
|
||||
return convertToEpub(inputPath, outputPath, sourceExt)
|
||||
}
|
||||
|
||||
// ─── EPUB → other formats ────────────────────────────────────
|
||||
|
||||
func convertFromEpub(inputPath, outputPath, targetFormat string) error {
|
||||
title, htmlChapters, err := extractEpubContent(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading epub: %w", err)
|
||||
}
|
||||
|
||||
fullHTML := strings.Join(htmlChapters, "\n<hr/>\n")
|
||||
|
||||
switch targetFormat {
|
||||
case "txt":
|
||||
text := stripHTMLTags(fullHTML)
|
||||
return os.WriteFile(outputPath, []byte(text), 0o644)
|
||||
case "html":
|
||||
styled := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>%s</title>
|
||||
<style>body{font-family:serif;line-height:1.7;max-width:700px;margin:40px auto;padding:0 20px;color:#1a1a1a}h1,h2,h3{margin-top:1.5em}</style>
|
||||
</head><body><h1>%s</h1>%s</body></html>`, escapeHTML(title), escapeHTML(title), fullHTML)
|
||||
return os.WriteFile(outputPath, []byte(styled), 0o644)
|
||||
case "md":
|
||||
md := "# " + title + "\n\n" + ebookHTMLToMarkdown(fullHTML)
|
||||
return os.WriteFile(outputPath, []byte(md), 0o644)
|
||||
case "pdf":
|
||||
text := stripHTMLTags(fullHTML)
|
||||
return textToPDF(text, outputPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported target for epub: %s", targetFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Other formats → EPUB ────────────────────────────────────
|
||||
|
||||
func convertToEpub(inputPath, outputPath, sourceExt string) error {
|
||||
raw, err := os.ReadFile(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading input: %w", err)
|
||||
}
|
||||
content := string(raw)
|
||||
title := strings.TrimSuffix(filepath.Base(inputPath), filepath.Ext(inputPath))
|
||||
|
||||
var htmlContent string
|
||||
switch sourceExt {
|
||||
case "txt":
|
||||
// Split into paragraphs on double newlines
|
||||
paragraphs := strings.Split(content, "\n\n")
|
||||
var sb strings.Builder
|
||||
for _, p := range paragraphs {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
sb.WriteString("<p>" + escapeHTML(p) + "</p>\n")
|
||||
}
|
||||
}
|
||||
htmlContent = sb.String()
|
||||
case "html", "htm":
|
||||
// Extract body if full document
|
||||
bodyRe := regexp.MustCompile(`(?is)<body[^>]*>(.*)</body>`)
|
||||
if m := bodyRe.FindStringSubmatch(content); m != nil {
|
||||
htmlContent = m[1]
|
||||
} else {
|
||||
htmlContent = content
|
||||
}
|
||||
case "md":
|
||||
htmlBytes := blackfriday.Run([]byte(content))
|
||||
htmlContent = string(htmlBytes)
|
||||
default:
|
||||
return fmt.Errorf("unsupported source for epub creation: %s", sourceExt)
|
||||
}
|
||||
|
||||
return writeEpubFile(outputPath, title, htmlContent)
|
||||
}
|
||||
|
||||
// ─── EPUB reader ─────────────────────────────────────────────
|
||||
|
||||
func extractEpubContent(path string) (string, []string, error) {
|
||||
r, err := zip.OpenReader(path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
title := "Untitled"
|
||||
var htmlChapters []string
|
||||
|
||||
// Find OPF file via container.xml
|
||||
var opfPath string
|
||||
for _, f := range r.File {
|
||||
if f.Name == "META-INF/container.xml" {
|
||||
data, err := readZipFile(f)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
re := regexp.MustCompile(`full-path="([^"]+)"`)
|
||||
if m := re.FindStringSubmatch(string(data)); m != nil {
|
||||
opfPath = m[1]
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if opfPath != "" {
|
||||
// Read OPF
|
||||
opfContent := ""
|
||||
for _, f := range r.File {
|
||||
if f.Name == opfPath {
|
||||
data, err := readZipFile(f)
|
||||
if err == nil {
|
||||
opfContent = string(data)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if opfContent != "" {
|
||||
// Extract title
|
||||
titleRe := regexp.MustCompile(`<dc:title[^>]*>([^<]+)</dc:title>`)
|
||||
if m := titleRe.FindStringSubmatch(opfContent); m != nil {
|
||||
title = m[1]
|
||||
}
|
||||
|
||||
// Build manifest map: id -> href
|
||||
manifest := make(map[string]string)
|
||||
itemRe := regexp.MustCompile(`<item[^>]*id="([^"]*)"[^>]*href="([^"]*)"[^>]*`)
|
||||
for _, m := range itemRe.FindAllStringSubmatch(opfContent, -1) {
|
||||
manifest[m[1]] = m[2]
|
||||
}
|
||||
// Also handle reversed attr order
|
||||
itemRe2 := regexp.MustCompile(`<item[^>]*href="([^"]*)"[^>]*id="([^"]*)"[^>]*`)
|
||||
for _, m := range itemRe2.FindAllStringSubmatch(opfContent, -1) {
|
||||
manifest[m[2]] = m[1]
|
||||
}
|
||||
|
||||
// Get spine order
|
||||
var spineIDs []string
|
||||
spineRe := regexp.MustCompile(`<itemref[^>]*idref="([^"]*)"[^>]*`)
|
||||
for _, m := range spineRe.FindAllStringSubmatch(opfContent, -1) {
|
||||
spineIDs = append(spineIDs, m[1])
|
||||
}
|
||||
|
||||
// Resolve relative to OPF dir
|
||||
opfDir := ""
|
||||
if idx := strings.LastIndex(opfPath, "/"); idx >= 0 {
|
||||
opfDir = opfPath[:idx+1]
|
||||
}
|
||||
|
||||
// Build a map of zip files for quick lookup
|
||||
zipFiles := make(map[string]*zip.File)
|
||||
for _, f := range r.File {
|
||||
zipFiles[f.Name] = f
|
||||
}
|
||||
|
||||
for _, id := range spineIDs {
|
||||
href, ok := manifest[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fullPath := opfDir + href
|
||||
zf, ok := zipFiles[fullPath]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
data, err := readZipFile(zf)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Extract body content
|
||||
bodyRe := regexp.MustCompile(`(?is)<body[^>]*>(.*)</body>`)
|
||||
if m := bodyRe.FindStringSubmatch(string(data)); m != nil {
|
||||
htmlChapters = append(htmlChapters, m[1])
|
||||
} else {
|
||||
htmlChapters = append(htmlChapters, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan for any xhtml/html files
|
||||
if len(htmlChapters) == 0 {
|
||||
var htmlFiles []*zip.File
|
||||
htmlRe := regexp.MustCompile(`(?i)\.(x?html?)$`)
|
||||
for _, f := range r.File {
|
||||
if htmlRe.MatchString(f.Name) {
|
||||
htmlFiles = append(htmlFiles, f)
|
||||
}
|
||||
}
|
||||
sort.Slice(htmlFiles, func(i, j int) bool {
|
||||
return htmlFiles[i].Name < htmlFiles[j].Name
|
||||
})
|
||||
for _, f := range htmlFiles {
|
||||
data, err := readZipFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
bodyRe := regexp.MustCompile(`(?is)<body[^>]*>(.*)</body>`)
|
||||
if m := bodyRe.FindStringSubmatch(string(data)); m != nil {
|
||||
htmlChapters = append(htmlChapters, m[1])
|
||||
} else {
|
||||
htmlChapters = append(htmlChapters, string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return title, htmlChapters, nil
|
||||
}
|
||||
|
||||
func readZipFile(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// ─── EPUB writer ─────────────────────────────────────────────
|
||||
|
||||
func writeEpubFile(outputPath, title, htmlContent string) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := zip.NewWriter(f)
|
||||
defer w.Close()
|
||||
|
||||
uid := fmt.Sprintf("transmute-%d", time.Now().UnixNano())
|
||||
modified := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
|
||||
// mimetype (must be stored, not compressed)
|
||||
mimeHeader := &zip.FileHeader{
|
||||
Name: "mimetype",
|
||||
Method: zip.Store,
|
||||
}
|
||||
mw, err := w.CreateHeader(mimeHeader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mw.Write([]byte("application/epub+zip"))
|
||||
|
||||
// META-INF/container.xml
|
||||
cw, _ := w.Create("META-INF/container.xml")
|
||||
cw.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>`))
|
||||
|
||||
// OEBPS/content.opf
|
||||
ow, _ := w.Create("OEBPS/content.opf")
|
||||
ow.Write([]byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" unique-identifier="BookId" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="BookId">%s</dc:identifier>
|
||||
<dc:title>%s</dc:title>
|
||||
<dc:language>en</dc:language>
|
||||
<meta property="dcterms:modified">%s</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="chapter1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="chapter1"/>
|
||||
</spine>
|
||||
</package>`, uid, escapeHTML(title), modified)))
|
||||
|
||||
// OEBPS/nav.xhtml
|
||||
nw, _ := w.Create("OEBPS/nav.xhtml")
|
||||
nw.Write([]byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head><title>Navigation</title></head>
|
||||
<body>
|
||||
<nav epub:type="toc">
|
||||
<h1>Table of Contents</h1>
|
||||
<ol><li><a href="chapter1.xhtml">%s</a></li></ol>
|
||||
</nav>
|
||||
</body>
|
||||
</html>`, escapeHTML(title))))
|
||||
|
||||
// OEBPS/chapter1.xhtml
|
||||
chw, _ := w.Create("OEBPS/chapter1.xhtml")
|
||||
chw.Write([]byte(fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>%s</title>
|
||||
<style>
|
||||
body { font-family: serif; line-height: 1.6; margin: 1em; }
|
||||
h1, h2, h3 { margin-top: 1.5em; }
|
||||
p { margin: 0.5em 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
%s
|
||||
</body>
|
||||
</html>`, escapeHTML(title), htmlContent)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
func ebookHTMLToMarkdown(html string) string {
|
||||
md := html
|
||||
// Headers
|
||||
for i := 6; i >= 1; i-- {
|
||||
prefix := strings.Repeat("#", i) + " "
|
||||
openTag := fmt.Sprintf("<h%d>", i)
|
||||
closeTag := fmt.Sprintf("</h%d>", i)
|
||||
md = strings.ReplaceAll(md, openTag, prefix)
|
||||
md = strings.ReplaceAll(md, closeTag, "\n\n")
|
||||
// Also case-insensitive with attributes
|
||||
re := regexp.MustCompile(fmt.Sprintf(`(?i)<h%d[^>]*>`, i))
|
||||
md = re.ReplaceAllString(md, prefix)
|
||||
re2 := regexp.MustCompile(fmt.Sprintf(`(?i)</h%d>`, i))
|
||||
md = re2.ReplaceAllString(md, "\n\n")
|
||||
}
|
||||
md = strings.ReplaceAll(md, "<strong>", "**")
|
||||
md = strings.ReplaceAll(md, "</strong>", "**")
|
||||
md = strings.ReplaceAll(md, "<b>", "**")
|
||||
md = strings.ReplaceAll(md, "</b>", "**")
|
||||
md = strings.ReplaceAll(md, "<em>", "*")
|
||||
md = strings.ReplaceAll(md, "</em>", "*")
|
||||
md = strings.ReplaceAll(md, "<i>", "*")
|
||||
md = strings.ReplaceAll(md, "</i>", "*")
|
||||
md = strings.ReplaceAll(md, "<br>", "\n")
|
||||
md = strings.ReplaceAll(md, "<br/>", "\n")
|
||||
md = strings.ReplaceAll(md, "<br />", "\n")
|
||||
md = strings.ReplaceAll(md, "<p>", "\n")
|
||||
md = strings.ReplaceAll(md, "</p>", "\n")
|
||||
md = strings.ReplaceAll(md, "<hr/>", "\n---\n")
|
||||
md = strings.ReplaceAll(md, "<hr>", "\n---\n")
|
||||
md = strings.ReplaceAll(md, "<hr />", "\n---\n")
|
||||
// Strip remaining tags
|
||||
md = stripHTMLTags(md)
|
||||
return strings.TrimSpace(md)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// convertFont handles font format conversions.
|
||||
// Go doesn't have native font conversion libraries, so we use fonttools (Python) or ffmpeg
|
||||
// as external dependencies. For a CLI tool this is acceptable — we check for fonttools first.
|
||||
func convertFont(inputPath, outputPath, sourceExt, targetFormat string) error {
|
||||
// Font conversion is complex — the most reliable approach is using fonttools/pyftsubset.
|
||||
// We'll try a simple copy-based approach for woff/woff2 ↔ ttf/otf since the underlying
|
||||
// data is similar, but for proper conversion we'd need external tools.
|
||||
|
||||
// For now, provide a clear error explaining what's needed.
|
||||
// In the future we could bundle a Go-native font converter or auto-install fonttools.
|
||||
|
||||
switch {
|
||||
case (sourceExt == "ttf" || sourceExt == "otf") && (targetFormat == "woff" || targetFormat == "woff2"):
|
||||
return fontConvertViaFFmpeg(inputPath, outputPath)
|
||||
case (sourceExt == "woff" || sourceExt == "woff2") && (targetFormat == "ttf" || targetFormat == "otf"):
|
||||
return fontConvertViaFFmpeg(inputPath, outputPath)
|
||||
case sourceExt == "ttf" && targetFormat == "otf":
|
||||
return fontConvertViaFFmpeg(inputPath, outputPath)
|
||||
case sourceExt == "otf" && targetFormat == "ttf":
|
||||
return fontConvertViaFFmpeg(inputPath, outputPath)
|
||||
case sourceExt == "woff" && targetFormat == "woff2":
|
||||
return fontConvertViaFFmpeg(inputPath, outputPath)
|
||||
case sourceExt == "woff2" && targetFormat == "woff":
|
||||
return fontConvertViaFFmpeg(inputPath, outputPath)
|
||||
default:
|
||||
return fmt.Errorf("font conversion from %s to %s is not supported", sourceExt, targetFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// fontConvertViaFFmpeg attempts font conversion. FFmpeg doesn't actually handle fonts,
|
||||
// so we check for fonttools (Python pyftsubset/fonttools CLI).
|
||||
func fontConvertViaFFmpeg(inputPath, outputPath string) error {
|
||||
// Check if fonttools is available
|
||||
// fonttools provides `pyftsubset` and `fonttools` CLI
|
||||
// For simple conversions: fonttools ttLib can convert between formats
|
||||
|
||||
// Write a small Python script to do the conversion
|
||||
script := fmt.Sprintf(`
|
||||
import sys
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
font = TTFont("%s")
|
||||
font.save("%s")
|
||||
print("OK")
|
||||
except ImportError:
|
||||
print("ERROR: fonttools not installed. Run: pip install fonttools brotli")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
sys.exit(1)
|
||||
`, inputPath, outputPath)
|
||||
|
||||
tmpScript, err := os.CreateTemp("", "transmute-font-*.py")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp script: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpScript.Name())
|
||||
|
||||
if _, err := tmpScript.WriteString(script); err != nil {
|
||||
tmpScript.Close()
|
||||
return err
|
||||
}
|
||||
tmpScript.Close()
|
||||
|
||||
// Try python3 first, then python
|
||||
for _, pyCmd := range []string{"python3", "python"} {
|
||||
output, err := runPython(pyCmd, tmpScript.Name())
|
||||
if err == nil {
|
||||
if output == "OK" || len(output) > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("font conversion requires Python + fonttools. Install with: pip install fonttools brotli")
|
||||
}
|
||||
|
||||
func runPython(python, scriptPath string) (string, error) {
|
||||
cmd := exec.Command(python, scriptPath)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/bmp"
|
||||
"golang.org/x/image/tiff"
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
func convertImage(inputPath, outputPath, targetFormat string) error {
|
||||
f, err := os.Open(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening image: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Decode input — Go's image package auto-registers png, jpeg, gif via import
|
||||
// We also need x/image decoders for bmp, tiff, webp
|
||||
img, format, err := image.Decode(f)
|
||||
if err != nil {
|
||||
// Try specific decoders as fallback
|
||||
f.Seek(0, 0)
|
||||
img, err = tryDecodeImage(f, inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decoding image (%s): %w", format, err)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating output: %w", err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
target := strings.ToLower(targetFormat)
|
||||
switch target {
|
||||
case "png":
|
||||
return png.Encode(out, img)
|
||||
case "jpg", "jpeg":
|
||||
return jpeg.Encode(out, img, &jpeg.Options{Quality: 92})
|
||||
case "gif":
|
||||
return gif.Encode(out, img, &gif.Options{NumColors: 256})
|
||||
case "bmp":
|
||||
return bmp.Encode(out, img)
|
||||
case "tiff", "tif":
|
||||
return tiff.Encode(out, img, &tiff.Options{Compression: tiff.Deflate})
|
||||
case "webp":
|
||||
// Go doesn't have a webp encoder in stdlib. Use ffmpeg as fallback.
|
||||
out.Close()
|
||||
os.Remove(outputPath)
|
||||
return convertImageViaFFmpeg(inputPath, outputPath, target)
|
||||
case "avif":
|
||||
out.Close()
|
||||
os.Remove(outputPath)
|
||||
return convertImageViaFFmpeg(inputPath, outputPath, target)
|
||||
case "ico":
|
||||
// ICO is just a small PNG wrapped in ICO container for simple cases.
|
||||
// We'll convert to PNG via ffmpeg or write a 256x256 PNG for now.
|
||||
out.Close()
|
||||
os.Remove(outputPath)
|
||||
return convertImageViaFFmpeg(inputPath, outputPath, target)
|
||||
default:
|
||||
out.Close()
|
||||
os.Remove(outputPath)
|
||||
return fmt.Errorf("unsupported image target format: %s", target)
|
||||
}
|
||||
}
|
||||
|
||||
func tryDecodeImage(f *os.File, path string) (image.Image, error) {
|
||||
ext := strings.ToLower(path)
|
||||
switch {
|
||||
case strings.HasSuffix(ext, ".webp"):
|
||||
return webp.Decode(f)
|
||||
case strings.HasSuffix(ext, ".bmp"):
|
||||
return bmp.Decode(f)
|
||||
case strings.HasSuffix(ext, ".tiff"), strings.HasSuffix(ext, ".tif"):
|
||||
return tiff.Decode(f)
|
||||
default:
|
||||
return nil, fmt.Errorf("unable to decode image: %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func convertImageViaFFmpeg(inputPath, outputPath, format string) error {
|
||||
args := []string{"-y", "-i", inputPath}
|
||||
|
||||
switch format {
|
||||
case "webp":
|
||||
args = append(args, "-quality", "90", outputPath)
|
||||
case "avif":
|
||||
args = append(args, "-c:v", "libaom-av1", "-still-picture", "1", outputPath)
|
||||
case "ico":
|
||||
// Scale to 256x256 for ICO
|
||||
args = append(args, "-vf", "scale=256:256:force_original_aspect_ratio=decrease,pad=256:256:(ow-iw)/2:(oh-ih)/2", outputPath)
|
||||
default:
|
||||
args = append(args, outputPath)
|
||||
}
|
||||
|
||||
return mediaConvert(inputPath, outputPath, format, args)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/noauf/transmute-cli/internal/ffmpeg"
|
||||
)
|
||||
|
||||
func convertMedia(inputPath, outputPath, targetFormat string) error {
|
||||
args := buildFFmpegArgs(inputPath, outputPath, targetFormat)
|
||||
return mediaConvert(inputPath, outputPath, targetFormat, args)
|
||||
}
|
||||
|
||||
func mediaConvert(inputPath, outputPath, targetFormat string, args []string) error {
|
||||
if !ffmpeg.IsAvailable() {
|
||||
return fmt.Errorf("ffmpeg is required for %s conversion — run `transmute --install-ffmpeg` to install it", targetFormat)
|
||||
}
|
||||
return ffmpeg.Run(args...)
|
||||
}
|
||||
|
||||
func buildFFmpegArgs(inputPath, outputPath, targetFormat string) []string {
|
||||
args := []string{"-y", "-i", inputPath}
|
||||
|
||||
switch targetFormat {
|
||||
// Audio
|
||||
case "mp3":
|
||||
args = append(args, "-codec:a", "libmp3lame", "-q:a", "2", outputPath)
|
||||
case "wav":
|
||||
args = append(args, "-codec:a", "pcm_s16le", outputPath)
|
||||
case "flac":
|
||||
args = append(args, "-codec:a", "flac", outputPath)
|
||||
case "ogg":
|
||||
args = append(args, "-codec:a", "libvorbis", "-q:a", "6", outputPath)
|
||||
case "aac":
|
||||
args = append(args, "-codec:a", "aac", "-b:a", "192k", outputPath)
|
||||
case "m4a":
|
||||
args = append(args, "-codec:a", "aac", "-b:a", "192k", outputPath)
|
||||
case "opus":
|
||||
args = append(args, "-codec:a", "libopus", "-b:a", "128k", outputPath)
|
||||
|
||||
// Video
|
||||
case "mp4":
|
||||
args = append(args, "-codec:v", "libx264", "-preset", "medium", "-crf", "23",
|
||||
"-codec:a", "aac", "-b:a", "192k", outputPath)
|
||||
case "webm":
|
||||
args = append(args, "-codec:v", "libvpx-vp9", "-crf", "30", "-b:v", "0",
|
||||
"-codec:a", "libvorbis", outputPath)
|
||||
case "avi":
|
||||
args = append(args, "-codec:v", "mpeg4", "-q:v", "5",
|
||||
"-codec:a", "libmp3lame", "-q:a", "4", outputPath)
|
||||
case "mov":
|
||||
args = append(args, "-codec:v", "libx264", "-preset", "medium", "-crf", "23",
|
||||
"-codec:a", "aac", "-b:a", "192k", outputPath)
|
||||
case "mkv":
|
||||
args = append(args, "-codec:v", "libx264", "-preset", "medium", "-crf", "23",
|
||||
"-codec:a", "aac", "-b:a", "192k", outputPath)
|
||||
|
||||
default:
|
||||
// Generic: let ffmpeg figure it out from the extension
|
||||
args = append(args, outputPath)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func convertSpreadsheet(inputPath, outputPath, sourceExt, targetFormat string) error {
|
||||
// Read spreadsheet using excelize (supports xlsx, xls via xlsx conversion)
|
||||
f, err := excelize.OpenFile(inputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening spreadsheet: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Get first sheet
|
||||
sheetName := f.GetSheetName(0)
|
||||
if sheetName == "" {
|
||||
return fmt.Errorf("no sheets found in spreadsheet")
|
||||
}
|
||||
|
||||
rows, err := f.GetRows(sheetName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading rows: %w", err)
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return fmt.Errorf("spreadsheet is empty")
|
||||
}
|
||||
|
||||
headers := rows[0]
|
||||
dataRows := rows[1:]
|
||||
|
||||
switch targetFormat {
|
||||
case "csv":
|
||||
return writeSpreadsheetDelimited(headers, dataRows, outputPath, ',')
|
||||
case "tsv":
|
||||
return writeSpreadsheetDelimited(headers, dataRows, outputPath, '\t')
|
||||
case "json":
|
||||
return writeSpreadsheetJSON(headers, dataRows, outputPath)
|
||||
case "yaml", "yml":
|
||||
return writeSpreadsheetYAML(headers, dataRows, outputPath)
|
||||
case "xml":
|
||||
return writeSpreadsheetXML(headers, dataRows, outputPath)
|
||||
case "html":
|
||||
return writeSpreadsheetHTML(headers, dataRows, outputPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported target for spreadsheet: %s", targetFormat)
|
||||
}
|
||||
}
|
||||
|
||||
func writeSpreadsheetDelimited(headers []string, rows [][]string, outputPath string, sep rune) error {
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := csv.NewWriter(f)
|
||||
w.Comma = sep
|
||||
if err := w.Write(headers); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
// Pad row to match header length
|
||||
for len(row) < len(headers) {
|
||||
row = append(row, "")
|
||||
}
|
||||
if err := w.Write(row[:len(headers)]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
return w.Error()
|
||||
}
|
||||
|
||||
func writeSpreadsheetJSON(headers []string, rows [][]string, outputPath string) error {
|
||||
var records []map[string]string
|
||||
for _, row := range rows {
|
||||
record := make(map[string]string)
|
||||
for i, h := range headers {
|
||||
if i < len(row) {
|
||||
record[h] = row[i]
|
||||
} else {
|
||||
record[h] = ""
|
||||
}
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
b, err := json.MarshalIndent(records, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outputPath, b, 0o644)
|
||||
}
|
||||
|
||||
func writeSpreadsheetYAML(headers []string, rows [][]string, outputPath string) error {
|
||||
var records []map[string]string
|
||||
for _, row := range rows {
|
||||
record := make(map[string]string)
|
||||
for i, h := range headers {
|
||||
if i < len(row) {
|
||||
record[h] = row[i]
|
||||
} else {
|
||||
record[h] = ""
|
||||
}
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
b, err := yaml.Marshal(records)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(outputPath, b, 0o644)
|
||||
}
|
||||
|
||||
func writeSpreadsheetXML(headers []string, rows [][]string, outputPath string) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<spreadsheet>\n")
|
||||
for _, row := range rows {
|
||||
sb.WriteString(" <row>\n")
|
||||
for i, h := range headers {
|
||||
val := ""
|
||||
if i < len(row) {
|
||||
val = row[i]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" <%s>%s</%s>\n", h, val, h))
|
||||
}
|
||||
sb.WriteString(" </row>\n")
|
||||
}
|
||||
sb.WriteString("</spreadsheet>")
|
||||
return os.WriteFile(outputPath, []byte(sb.String()), 0o644)
|
||||
}
|
||||
|
||||
func writeSpreadsheetHTML(headers []string, rows [][]string, outputPath string) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("<html><body>\n<table border=\"1\">\n<thead>\n<tr>")
|
||||
for _, h := range headers {
|
||||
sb.WriteString("<th>" + h + "</th>")
|
||||
}
|
||||
sb.WriteString("</tr>\n</thead>\n<tbody>\n")
|
||||
for _, row := range rows {
|
||||
sb.WriteString("<tr>")
|
||||
for i := range headers {
|
||||
val := ""
|
||||
if i < len(row) {
|
||||
val = row[i]
|
||||
}
|
||||
sb.WriteString("<td>" + val + "</td>")
|
||||
}
|
||||
sb.WriteString("</tr>\n")
|
||||
}
|
||||
sb.WriteString("</tbody>\n</table>\n</body></html>")
|
||||
return os.WriteFile(outputPath, []byte(sb.String()), 0o644)
|
||||
}
|
||||
Reference in New Issue
Block a user