Epub-Generator

Bisher habe ich meine Ebooks mit pandoc erzeugt. Funktioniert, aber das Tool optimiert meine freie Gestaltung weg. Darum habe ich mit Tcl/Tk das Kommandozeilen-Tool epubgen.tcl gemacht, das aus Standard-HTML ein Epub erzeugt. Epub ist im Grund nur eine Zip-Datei, deren Aufbau bei Wikipedia nachgeschlagen werden kann.

Der Buch-Autor wird im Kopfbereich des HTML mit <meta name="author" content="Nachname, Vorname" /> notiert.

Das HTML wird anhand der Haupt-Überschriften <h1>...</h1> in Epub-Kapitel zerteilt. Um auch bei Ebene Unter-Überschriften <h2>...</h2> zu unterteilen dient die Angabe <meta name="splitlevel" content="2" />. Reicht je nach Bedarf bis Ebene 6.

Um an beliebiger Stelle zu unterteilen, werden – zwischen den Block-Elementen <p>...</p> die HTML-Kommentare <!--split--> eingefügt.

Anwendung

tclsh epubgen.tcl Beispiel.htm

produziert Beispiel.epub

Randbedingungen

Das Kommandozeilentool zip muss im Suchpfad erreichbar sein.

CSS-Angaben müssen im Kopfbereich des HTML in <style>...</style> stehen.

Referenzierte Bilder (etwa <img src="hi.gif" />) müssen im gleichen Verzeichnis existieren, ebenso fonts (im style-Bereich etwa mit url(garamond.otf) referenziert).

Falls zu einer Datei Beispiel.htm eine gleichnamige Grafik Beispiel.jpg existiert (Dateiendung immer .jpg), so wird diese als Titelbild ins Epub eingearbeitet. Dazu muss das ImageMagick-Tool convert im Suchpfad erreichbar sein.

Zur Sicherheit kann das produzierte Epub mit dem Kommandozeilentool epubcheck überprüft werden.

Nachtrag 24.4.17 – Fehler entfernt (Inhaltsverzeichnis mit mehreren Ebenen war fehlerhaft verschachtelt), Code aktualisiert.

Nachtrag 2 – mit diesem Tool habe ich Karl Marxʼ Kapital von HTML nach Epub gebracht. Für meinen persönlichen Gebrauch die Struktur auf Vordermann gebracht und lesefreundlich formatiert (Schriftschnitt Garamond, open source). Fußnoten entfernt, Vorworte drin gelassen. Das Werk ist gemeinfrei, darum darf ich es voller Freude öffentlich verfügbar machen!


#!/usr/bin/tclsh

lassign $argv html

namespace path ::tcl::mathop

proc echo args {puts $args}

if {[string tolower [file extension $html]] ni {.htm .html .xhtml}} then {
  return -code error [list $html mus be a HTML file!]
}
if {![file exists $html]} then {
  return -code error [list HTML file $html does not exist!]
}

set epub [file root $html].epub
file delete -force $epub

apply {{html args} {
  foreach ext $args {
      if {[file exists [file root $html]$ext]} then {
        exec convert -resize x900 [file root $html]$ext cover.jpg
        break
      }
    }
}} $html .jpg .jpeg .png .gif .tif .tiff .JPG .JPEG .PNG .GIF .TIF .TIFF

proc strcat args {
  append result {*}$args
}

proc fileToString file {
  set chan [open $file r]
  set result [read $chan]
  close $chan
  set result
}

proc uuid {} {
  for {set i 0} {$i < 32} {incr i} {
    append result [format %x [expr {int(rand()*16)}]]
    if {[incr x] in {8 12 16 20}} then {
      append result -
    }
  }
  set result
}
set urnUuid [uuid]

# set urnUuid [fileToString /proc/sys/kernel/random/uuid]

proc stringToFile {str file} {
  set chan [open $file w]
  puts -nonewline $chan $str
  close $chan
}

proc coverGiven? {{img cover.jpg}} {
  file exists $img
}

proc coverImage {} {
  return -level 0 cover.jpg
}

proc element {name {atts {}} args} {
  strcat <$name \
    {*}[lmap {att val} $atts {
      subst {\n    $att="$val"}
    }]\
    {*}[if {[llength $args] == 0} then {
      list " " />
    } else {
      list > {*}[lmap el $args {
          string map [list \n "\n  "] \n[string trim $el]
        }] \n</$name>
    }]
}

proc xmlDoc el {
  subst {<?xml version="1.0" encoding="UTF-8"?>\n$el}
}

proc fileToMediaType file {
  dict get {
    .gif   image/gif
    .png   image/png
    .jpg   image/jpeg
    .jpeg  image/jpeg
    .woff  application/font-woff
    .woff2 application/font-woff2
    .ttf   application/x-font-truetype
    .svg   image/svg+xml
    .otf   application/x-font-opentype
  } [string tolower [file extension $file]]
}

proc htmlDoc el {
  join [list\
          {<?xml version="1.0" encoding="UTF-8"?>}\
          {<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"}\
          {  "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">}\
          $el] \n
}

proc coverDoc {{img cover.jpg}} {
  htmlDoc\
    [element html {xmlns http://www.w3.org/1999/xhtml}\
       [element head {}\
          [element title {} Cover]\
          [element style {type text/css} {
          img {
            max-width: 100%;
            max-height: 100%;
          }
          body {
            padding: 0px;
            margin: 0px;
          }
          div {
            display: table;
            width: 100%;
            height: 100%;
          }
          div div {
            display: table-cell;
            text-align: center;
            vertical-align: middle;
          }
        }]]\
       [element body {}\
          [element div {}\
             [element div {}\
                [element img\
                   [list src $img alt "cover image"]]]]]]
}

# ====

proc tocListToNest {_toc {level 1}} {
  upvar $_toc toc
  set result {}
  while {[llength $toc] > 0} {
    if {[lindex $toc 1] > $level} then {
      lset result end [concat [lindex $result end] [tocListToNest toc [+ $level 1]]]
    } elseif {[lindex $toc 1] < $level} then break else {
      set toc [lassign $toc label - target]
      lappend result [list $label $target]
    }
  }
  set result
}

proc tocElementToMarkup el {
  upvar navPointID navPointID
  upvar navPlayOrder navPlayOrder
  set data [lassign $el label file]
  element navPoint [list\
                      id navPoint-[incr navPointID]\
                      playOrder [incr navPlayOrder]]\
    [element navLabel {}\
       [element text {} $label]]\
    [element content [list src $file]]\
    {*}[lmap subEl $data {
      tocElementToMarkup $subEl
    }]
}

proc navMap toc {
  set navPointID 0
  set navPlayOrder 0
  set nestedToc [tocListToNest toc]
  element navMap {} {*}[lmap el $nestedToc {
      tocElementToMarkup $el
    }]
}

# ===

proc zip args {
  exec zip {*}$args
}

stringToFile application/epub+zip mimetype
zip -m0Xq $epub mimetype

file mkdir META-INF
stringToFile\
  [xmlDoc\
     [element container {
      xmlns urn:oasis:names:tc:opendocument:xmlns:container
      version 1.0
    } [element rootfiles {
      } [element rootfile {
          full-path content.opf
          media-type application/oebps-package+xml
        }]]]] META-INF/container.xml

zip -mr9X $epub META-INF/

proc srcPart {src element} {
  # return element content of XML source,
  # e.g. range "head" or "body" of HTML document.
  set idx0 [string first <$element $src]
  if {$idx0 >= 0} then {
    set idx1 [string first > $src $idx0]
    if {$idx1 >= 0} then {
      incr idx1
      set idx2 [string first </$element $src $idx1]
      if {$idx2 >= 0} then {
        incr idx2 -1
        string range $src $idx1 $idx2
      }
    }
  }
}

proc splitByComment src {
  # return splices of document delimited by string "<!--split-->"
  set idx [string first <!--split--> $src]
  if {$idx < 0} then {
    list $src
  } else {
    list [string range $src 0 $idx-1]\
      {*}[splitByComment [string range $src $idx+12 end]]
  }
}

proc withoutComments src {
  regsub -all {<!--(?!split-->).*?-->} $src ""
}

set source [fileToString $html]
set headSrc [srcPart $source head]
set bodySrc [withoutComments [srcPart $source body]]
set cssText [srcPart $headSrc style]

stringToFile $cssText style.css
zip -m9 $epub style.css

set title [string trim [srcPart $headSrc title]]
set metaSrc [regexp -inline -all {<meta [^>]*>} $headSrc]

set splitlevel 1
set author unknown


foreach el $metaSrc {
  if {[string first {name="author"} $el] >= 0} then {
    regexp {content="\s*([^"]+)\s*"} $el - author
  } elseif {[string first {name="splitlevel"} $el] >= 0} then {
    regexp {content="\s*([^"]+)\s*"} $el - splitlevel
  }
}

set sections\
  [lmap el\
     [regexp -inline -all -indices "<h\[1-$splitlevel\]" $bodySrc] {
  lindex $el 0
}]

set parts [lmap i0 [concat 0 $sections] i1 [concat $sections end] {
  string trim [string range $bodySrc $i0 $i1-1]
}]

# vgh-quakenbrueck@vgh.de
# tobias.bleischwitz@vgh.de

set count 0
set navCount 0
set toc ""
set metaData ""
set manifestData ""
set spineData ""
set guideData ""

append metaData\
  \n [element dc:identifier {id epub-id-1} urn:uuid:$urnUuid]\
  \n [element dc:title {id epub-title-1} $title]\
  \n [element dc:language {} de-DE]\
  \n [element dc:creator [list opf:role aut opf:file-as $author]\
        [join [lreverse [lmap part [split $author ,] {
          string trim $part
        }]]]]

if {[coverGiven?]} then {
  stringToFile [coverDoc] cover.htm
  zip -m $epub cover.htm cover.jpg
  append manifestData\
    \n [element item {id cover
                      href cover.htm
                      media-type application/xhtml+xml}]\
    \n [element item {id cover-image
                      href cover.jpg
                      media-type image/jpeg}]
  append spineData \n [element itemref {idref cover
                                        linear no}]
  append guideData \n [element reference {type cover
                                          title Cover
                                          href cover.htm}]
  append metaData \n [element meta {name cover
                                    content cover-image}]
}

# collect resource files (images, fonts)

set imageSrc [regexp -inline -all {<img\s[^>]*?/\s*>} $bodySrc]
set externalFiles [lmap src $imageSrc {
  regexp {<.*src="(.*?)".*?>} $src - file
  return -level 0 $file
}]

# append css ressource file names to resource list

set cssFileSrc [regexp -inline -all {url\(.+?\)} $cssText]
lappend externalFiles {*}[lmap src $cssFileSrc {
  string map [list ./ "" \" "" ' ""] [string range $src 4 end-1]
}]

append manifestData\
  \n [element item {id ncx
                    href toc.ncx
                    media-type application/x-dtbncx+xml}]\
  \n [element item {id style
                    href style.css
                    media-type text/css}]\
  {*}[if {[llength $imageSrc] > 0} then { list \n }]\
  [join\
     [lmap file [lsort -unique $externalFiles] {
      zip -r9 $epub $file
      element item [subst {
          id [file rootname [file tail $file]]
          href $file
          media-type [fileToMediaType $file] 
        }]
    }] \n]

set chapterTitle $title

foreach el $parts {
  foreach splitEl [splitByComment $el] {
    if {$splitEl ne ""} then {
      # set splitEl [withoutComments $splitEl]
      set fileID part[format %04d [incr count]]
      set fileName $fileID.xhtml
      append manifestData \n\
        [element item [subst {id $fileID
                              href $fileName
                              media-type application/xhtml+xml}]]
      append spineData \n [element itemref [subst {idref $fileID}]]
      if {[regexp\
             {<h([1-6])[^>]*>\s*([^<]*?)\s*</h\1>}\
             $splitEl - headLevel chapterTitle]} then {
        lappend toc $chapterTitle $headLevel $fileName
      }
      stringToFile\
        [htmlDoc\
           [element html {lang de
                          xml:lang de
                          xmlns http://www.w3.org/1999/xhtml}\
              [element head {}\
                 [element meta {http-equiv Content-Type
                                content "text/html; charset=utf-8"}]\
                 [element meta {http-equiv Content-Style-Type
                                content text/css}]\
                 [element meta {name DC.language
                                content de}]\
                 [element title {} $chapterTitle]\
                 [element link {type text/css
                                rel stylesheet
                                href style.css}]]\
              [element body {} $splitEl]]]\
        $fileName
      zip -m $epub $fileName
    }
  }
}

set packageData [element package {version 2.0
                                  xmlns http://www.idpf.org/2007/opf
                                  unique-identifier epub-id-1}\
                   [element metadata {xmlns:dc http://purl.org/dc/elements/1.1/
                                      xmlns:opf http://www.idpf.org/2007/opf}\
                      $metaData]\
                   [element manifest {} $manifestData]\
                   [element spine {toc ncx} $spineData]\
                   [element guide {} $guideData]
]
stringToFile [xmlDoc $packageData] content.opf
zip -m $epub content.opf

set ncxData\
  [element ncx {version 2005-1
                xmlns http://www.daisy.org/z3986/2005/ncx/}\
     [element head {}\
        [element meta [subst {name dtb:uid
                              content urn:uuid:$urnUuid}]]\
        [element meta {name dtb:depth
                       content 1}]\
        [element meta {name dtb:totalPageCount
                       content 0}]\
        [element meta {name dtb:maxPageNumber
                       content 0}]\
        [element meta {name cover
                       content cover-image}]]\
     [element docTitle {}\
        [element text {} $title]]\
     [navMap $toc]]

stringToFile [xmlDoc $ncxData] toc.ncx
zip -m $epub toc.ncx

Läuft unter Linux, sollte unter Windows ebenfalls funktionieren. Viel Spaß beim Ausprobieren!

Montag, den 3. April 2017, um 13 Uhr 24