├── .gitignore ├── README.md ├── batch.go ├── config.go ├── doc.go ├── epub.go ├── example ├── book.zip └── book │ ├── book.html │ ├── book.ini │ ├── cover.png │ ├── fullscreen.png │ └── style.css ├── extract.go ├── folder.go ├── main.go ├── make.go ├── merge.go ├── pack.go ├── readme.html ├── server.go └── utility.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MakeEpub 2 | 3 | 此工具可以将 *html* 文件转换为 *epub* 格式的电子书。它根据html文件中的特定标签,将其拆分为章节,并自动生成目录等信息。 4 | 5 | This tool helps to create *epub* books from *html* files. It split the html file into chapters according to special tags in the file and generate the TOC automatically. 6 | 7 | 这些特定标签被称为“拆分点”,有“标题标签(\, \,...\)”和“章节标签(*class* 属性包含 *makeepub-chapter* 的标签)”两种。章节标签主要在需要对章节标题进行修饰时使用,如在章节标题前插入横幅图片等。 8 | 9 | The special tags are call "split point", including two kinds of tags: "header tags(\, \, ... \)" and "chapter tags (tag's *class* attribute contains *makeepub-chapter* )". Chapter tags is useful if chapter title decoration is required, for example: add a banner image before the title. 10 | 11 | 每个拆分点有“级别”和“标题”两个属性,分别对应目录中的级别和标题。其中级别是一个0到6之间的整数,但0级拆分点只用于文件拆分,不会出现在目录中。 12 | 13 | Every split point has two properties: 'level' and 'title', they are mapped to the level and title properties of a TOC item. 'Level' is an integer betwwen 0 and 6, but level 0 split point is only for file split, will not be used for generate TOC. 14 | 15 | 此工具支持批处理模式,可一次性转换生成多个epub文件。它还支持打包、解包epub文件,合并html文件或文本文件;还可以作为一个web服务器,转换上传的zip文件为epub文件。 16 | 17 | The tool support batch mode which can generate multiple epub books in one execution. It also support pack/extract an epub book, merge html/text files. And can be used as a web server to convert an uploaded zip file to an epub book. 18 | 19 | ## 1. 命令行(Command Line) 20 | 21 | 转换(Create) : makeepub [OutputFolder] [-epub2] [-noduokan] 22 | 批处理(Batch) : makeepub -b [OutputFolder] [-epub2] [-noduokan] 23 | makeepub -b [OutputFolder] [-epub2] [-noduokan] 24 | 打包(Pack) : makeepub -p 25 | 解包(Extract) : makeepub -e 26 | 合并(Merge) HTML : makeepub -mh 27 | 合并(Merge) Text : makeepub -mt 28 | Web服务器(Server) : makeepub -s [Port] 29 | 30 | 各参数含义如下: 31 | 32 | The meaning of the arguments are as below: 33 | 34 | + **VirtualFolder** : 一个文件夹(如example文件夹下的book文件夹)或zip文件(如example文件夹下的book.zip),里面包含要处理的文件。(An OS folder (for example: folder *book* in folder *example*) or a zip file(for example: *book.zip* in folder *example*) which contains the input files.) 35 | + **OutputFolder** 一个文件夹,用于保存输出文件。(An OS folder to store the output file(s).) 36 | + **InputFolder** : 一个文件夹,里面有输入文件或文件夹。(An OS folder which contains the input folder(s)/file(s).) 37 | + **-epub2** : 默认生成EPUB3格式的文件,使用此参数将生成EPUB2格式的文件。(By default, the output file is EPUB3 format, use this argument if EPUB2 format is required.) 38 | + **-noduokan** : 禁用 [多看](http://www.duokan.com/) 扩展。(Disable [DuoKan](http://www.duokan.com/) externsion.) 39 | + **BatchFile** : 一个文本文件,里面列出了所有要处理的VirtualFolder,每行一个。(A text which lists the path of 'VirtualFolders' to be processed, one line for one 'VirtualFolder'.) 40 | + **OutputFile** : 输出文件的路径。(The path of the output file.) 41 | + **EpubFile** : 一个epub文件的路径。(The path of an EPUB file.) 42 | + **Port** : Web服务器的监听端口,默认80。(The TCP port for the web server to listen to, default value is 80.) 43 | 44 | ## 2. 转换(Create) 45 | 46 | makeepub [OutputFolder] 47 | 48 | 处理 *VirtualFolder* 中的文件,生成epub,并保存到 *OutputFolder* 中。在VirtualFolder中,必须有以下三个文件: 49 | 50 | Process files in *VirtualFolder*, generate epub file and save it to *OutputFolder* . The 3 files below 3 are mandatory and must exist in VirtualFolder: 51 | 52 | + **book.ini** 配置文件,用于指定书名、作者等信息(configuration file to specify book name, author and etc.) 53 | + **book.html** 书的正文(The content of the book) 54 | + **cover.png** or **cover.jpg** or **cover.gif** 封面图片文件(The cover image of the book) 55 | 56 | 请 **务必** 使用 *UTF-8* 编码保存前两个文件,否则程序可能不能正确处理。 57 | 58 | The first 2 files **MUST** stored in *UTF-8* encoding, otherwise, the tool may not able to process them correctly. 59 | 60 | 除以上文件外,其它书籍需要的文件,如层叠样式表(css),图片等也应保存到此文件夹中。如果文件内容是文本,建议也使用 *UTF-8* 编码保存。 61 | 62 | Besides the 3 files above, other files required by the book, like sytle sheet (css) and images, are 63 | also required to be put into the folder. And if the content of a file is text, it is also recommend to store it in *UTF-8* encoding. 64 | 65 | ### 2.1 文件格式 66 | 67 | 下面是对主要文件的格式的简单介绍,更多信息请参考example文件夹中的示例。 68 | 69 | Below is a brief introduction of the format of the mandatory files, more information please refer to the examples in the *example* folder. 70 | 71 | #### book.ini 72 | 73 | 此文件基于通用的ini文件格式,以'='开始的行将被合并到上一行,以'#'开始的行将被视为注释,并被忽略。 74 | 75 | This file is based on the common *INI* file format, line start with '=' will be joint to previous line, and line start with '#' will be regard as comment and ignored. 76 | 77 | 这个文件包含三个节, *book* 、 *split* 和 *output*,book节指定书籍信息,split节指定如何进行章节拆分,output节指定输出文件信息。下面的列表将介绍其中每一个选项的作用。 78 | 79 | This file contains three sections: *book*, *split* and *output*. section *book* is for the book information, *split* determines how chapters are split, and section *output* is for the output file. The below list explains the usage of each option. 80 | 81 | + Book节(Section Book) 82 | - **name**: 书名,如果没有提供会导致程序输出一个警告信息(Name of the book, if not specified, the tool will generate a warning) 83 | - **author**: 作者,如果没有提供会导致程序输出一个警告信息(Author of the book, if not specified, the tool will generate a warning) 84 | - **id**: 书的唯一标识,在正规出版的书中,它应该是ISBN编号,如果您没有指定,程序将随机生成一个(The unique identifier, it is the ISBN for a published book. If not specified, the tool will generate a random string for it.) 85 | - **publisher**: 出版社(The publisher of the book.) 86 | - **description**: 书籍简介(A brief introduction of the book.) 87 | - **language**: 语言,默认 *zh-CN* ,即简体中文(Language of the book, *zh-CN* by default, that's Chinese Simplified.) 88 | - **toc**: 一个 *1* 到 *6* 之间的整数,用于指定目录的粒度,默认为 *2*,即只生成1、2两级拆分点对应的目录(An integer between *1* and *6*, specifis how to TOC is generated. Default value is *2*, which means the TOC is based on level 1 and level 2 split points) 89 | 90 | + Split节(section Split) 91 | - **AtLevel**: 一个 *0* 到 *6* 之间的整数,用于指定章节拆分的粒度,默认为 *1*,即只根据1级拆分点拆分章节(An integer between *0* and *6*, specifis how to split the html file into chapters. Default value is *1*, which means the split is based on the level 1 split points) 92 | - **ByHeader**: 一个 *1* 到 *7* 之间的整数。如果一个“标题标签”拆分点的级别小于此选项的值,那么这个拆分点将被忽略。默认值是1,即不忽略任何“标题标签”拆分点。(An integer between *1* and *7*. A "header" split point will be ignored if its level property is smaller than this value. Default is *1* which means no "header" split point will be ignored.) 93 | 94 | + Output节(Section Output) 95 | - **path**: 输出epub文件的路径。如果没有指定,程序会产生一个警告且不会生成任何文件(The output path of the target epub file. If the path is not specified, the tool will generate a warning and no file will be created) 96 | 97 | 下面是book.ini的一个例子。 98 | 99 | Below is an example for book.ini. 100 | 101 | [book] 102 | name=My First eBook 103 | author=Super Man 104 | id=ISBN XXXXXXXXXXXX 105 | publisher=My Own Press 106 | description= 这是本书的简介,它占用了多行。 This is the description 107 | = of the book, and it has more than one line. 108 | language=zh-CN 109 | toc=2 110 | 111 | [split] 112 | AtLevel=1 113 | ByHeader=1 114 | 115 | [output] 116 | path=d:\MyBook.epub 117 | 118 | 119 | #### book.html 120 | 121 | 它是一个标准的html文件,根据 *split* 节的设置,程序会将此文件拆分成章节文件,根据 *toc* 设置生成书籍目录。\标签之前的内容会被复制到每个章节文件的开头。 122 | 123 | This is a standard html file. The tool will split this file into chapter files based on *split* setting, and generate TOC based on the *toc* setting. Content before \ tag will be copied to the beginning of each chapter file. 124 | 125 | 如果其中的某个 *img* 标签符合以下情况,它将会全屏显示 (An image is displayed as full screen if its *img* tag meet all below conditions): 126 | + 打开了多看扩展 (DuoKan externsion is enabled) 127 | + *img* 标签的父级是 *body* 标签 (The parent of *img* tag is *body* tag) 128 | + *img* 的 *class* 属性包含 *duokan-fullscreen* (The value of the *class* property of the *img* tag contains *duokan-fullscreen* ) 129 | 130 | #### cover.png/jpg/gif 131 | 132 | 一个图片文件,它将被用来生成封面。这个文件可以是cover.png、cover.jpg和cover.gif中的任意一个,如果存在多个,如同时有cover.png和cover.jpg,那么程序会随机使用其中一个生成封面。 133 | 134 | An image file which will be used to create the book cover. It can be 'cover.png', 'cover.jpg' or 'cover.gif', if more than one file exists (for example: both 'cover.png' and 'cover.jpg'), the tool will select one randomly. 135 | 136 | 封面文件的名字是cover.html,所以请勿使用这个文件名,否则程序的行为将是未知的。 137 | 138 | The file name of the cover page is 'cover.html', please don't use this name for any other purpose, otherwise the behavior of this tool is not defined. 139 | 140 | ### 2.2 拆分点(Split Point) 141 | 142 | 拆分点的使用遵循以下规则,具体使用方法请参考 *example* 文件夹中的例子。 143 | 144 | Below are the rules of split point, please refer to the *example* folder for examples. 145 | 146 | 0. 所有拆分点都必须是 *body* 标签的直接子标签。(All split point MUST be the direct child of the *body* tag.) 147 | 1. 默认情况下,“标题标签”都是拆分点,其“级别”是这个标签的级别,“标题”是这个标签的内容。 (By default, all "header tags" are split points, their "level" are the level of the tags and "title" are the content of these tags.) 148 | 2. “标题标签”的“标题”也可以通过 *data-chapter-title* 属性指定,这种情况下,目录中的标题和正文中的标题将不一样。("Title" can also be specified by *data-chapter-title* attribute, in this case, the chapter will have different title in TOC and content.) 149 | 3. 如果一个标题标签的 *class* 属性包含 *makeepub-not-chapter* ,那么它不是拆分点。(A header tag is not split point when its *class* attribute contains *makeepub-not-chapter* .) 150 | 4. 任何标签,如果它的 *class* 属性包含 *makeepub-chapter* ,那么它是一个“章节标签”拆分点。(A tag is a "chapter tag" split point if its *class* attribute contains *makeepub-chapter* .) 151 | 5. “章节标签”拆分点的“级别”和“标题”可以由 *data-chapter-level* 和 *data-chapter-title* 属性指定。(The "level" and "title" of a "chapter tag" can be specified by the "data-chapter-level" and "data-chapter-title" attributes.) 152 | 6. 如果一个“章节标签”没有 *data-chapter-level* 属性,那么它的“级别”和“标题”由后续的(包括此标签)第一个“标题标签”决定,同时这个“标题标签”失效。但在找到所需的“标题标签”之前,如果出现了其他“章节标签”,则此“章节标签”失效。(If a "chapter tag" does not have *data-chapter-level* attribute, its "level" and "title" will be determined by the first "header tag" after it (or itself, if it is a "header tag" also), and the "header tag" will be ignored. But, if another "chapter tag" is found before the required "header tag", this "chapter tag" will be ignored.) 153 | 7. “章节标签”的优先级高于“标题标签”,即如果一个标签既是“章节标签”又是“标题标签”,它将被作为“章节标签”处理。(The priority of "chapter tag" is higer than "header tag", so if a tag is both "chapter tag" and "header tag", it is regarded as "chapter tag".) 154 | 8. 0级拆分点只用于文件拆分,不生成目录。(Level 0 split point is only for file split, will not be used for generate TOC.) 155 | 9. 级别小于 *ByHeader* 的“标题标签”拆分点会全部被忽略。("Header tag" split points whose level are smaller than *ByHeader* will be ignored.) 156 | 10. 级别大于 *toc* 的拆分点不会生成目录。(Split points whose level are larger than *toc* will not appear in TOC.) 157 | 11. 级别大于 *AtLevel* 的拆分点不会造成文件拆分。(File split will not happen on split points whose level are larger than *AtLevel* .) 158 | 12. 为尽量避免拆分出来的文件只包含章节标题,即使某个拆分点按照 *AtLevel* 选项应该被拆分,如果它和它的上级拆分点之间没有任何正文,它也不会被拆分。(To avoid a chapter file only has a chapter title, file split will not happen on a split point if there's no text between the split point and its parent split point, no matter what the value of option *AtLevel* is.) 159 | 160 | ### 2.3 输出文件的路径(path of the output file) 161 | 162 | 输出文件的路径取决于 *book.ini* 中 *output* 节的 *path* 选项,命令行中的 *OutputFolder* 参数,以及程序的当前工作文件夹。 163 | 164 | The path of the out file is determined by the *path* option of section *output* in *book.ini*, argument *OutputFolder* in command line, and the current working folder of the tool. 165 | 166 | 如果缺少path选项,不会生成任何文件。 167 | 168 | No file will be create if there's no option *path*. 169 | 170 | 如果没有OutputFolder参数,且path是相对路径,输出文件路径是相对于当前工作文件夹的path。 171 | 172 | If argument *OutputFolder* is not specified, and *path* is relative, the output file path will be *path* relative to the current working folder. 173 | 174 | 如果没有OutputFolder参数,且path是绝对路径,输出文件路径是path。 175 | 176 | If argument *OutputFolder* is not specified, and *path* is absolute, the output file path is *path*. 177 | 178 | 如果指定了OutputFolder,文件会被保存在OutputFolder,文件名是path中的文件名部分。 179 | 180 | If *OutputFolder* is specified, output file will be save at *OutputFolder*, and file name is the 'file name' in *path*. 181 | 182 | ## 3. 批处理(Batch) 183 | 184 | makeepub -b [OutputFolder] [-epub2] [-noduokan] 185 | makeepub -b [OutputFolder] [-epub2] [-noduokan] 186 | 187 | 批处理模式,相当于对InputFolder中的(或BatchFile列出的)每个VirtualFolder **folder**,调用: 188 | 189 | Batch mode, is equal to: for each *VirtualFolder* **folder** in *InputFolder* (or listed in *BatchFile), call: 190 | 191 | makeepub folder [OutputFolder] [-epub2] [-noduokan] 192 | 193 | 194 | ## 4. 打包(Pack) 195 | 196 | makeepub -p 197 | 198 | 将VirtualFolder中的文件打包成一个EPUB文件,保存为OutputFile。 199 | 200 | Pack the files in *VirtualFolder* into an EPUB and save it as *OutputFile*. 201 | 202 | ## 5. 解包(Extract) 203 | 204 | makeepub -e 205 | 206 | 将EpubFile解包到OutputFolder中。 207 | 208 | Extract *EpubFile* to folder *OutputFolder*. 209 | 210 | ## 6. 合并(Merge) 211 | 212 | makeepub -mh 213 | makeepub -mt 214 | 215 | 按文件名升序合并VirtualFolder中的文件,并将合并结果保存为OutputFile。合并模式可以使html模式(-mh)或文本(-mt)。 216 | 217 | Sort files in *VirtualFolder* in ascend order by file name, merge them, and save the merge result as *OutputFile*. The merge mode can be *html*(-mh) or *text*(-mt). 218 | 219 | 文本模式是简单的将文件内容连接在一起,Html模式会分析文件,只保留一份文件头(<body>之前的部分)和文件尾(</body>之后的部分)。 220 | 221 | *text* mode is simply merge file content one by one. *html* mode will analysis the file to keep only one copy of file header (content before <body>) and file footer (content after </body>). 222 | 223 | 224 | ## 7. Web服务器(Web Server) 225 | 226 | makeepub -s [Port] 227 | 228 | 以Web服务器形式运行,处理用户上传的zip文件,生成EPUB文件,供用户下载。 229 | 230 | Run as a web server, process the user uploaded zip file, and generate the EPUB file for user to download. 231 | 232 | 如果不需要此功能,可将其删除以减小可执行文件的体积。 233 | 234 | If you don't need this feature, it can be removed to reduce the size of the executable file. 235 | 236 | ## 8. 授权及其他(License & Others) 237 | 238 | MakeEpub是自由软件,基于[MIT授权](http://opensource.org/licenses/mit-license.html)发布 239 | 240 | MakeEpub is free software distributed under the terms of the [MIT license](http://opensource.org/licenses/mit-license.html). 241 | 242 | 此程序是根据我自己制作epub书籍的需要编写,同时也通过编写过程熟悉了[Go语言](http://golang.org/)(可能需翻墙)。今后,将仅修正bug,而不再增加新的功能。 243 | 244 | This tool is developed for my own need when creating epub book, I also learned the [Go program language](http://golang.org/). From now on, I will only fix bugs and won't add new feature any more. -------------------------------------------------------------------------------- /batch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | ) 10 | 11 | type taskResult struct { 12 | input string 13 | e error 14 | } 15 | 16 | var ( 17 | chTaskResult chan *taskResult 18 | ) 19 | 20 | func runTask(input string, outdir string) { 21 | var ( 22 | maker = NewEpubMaker(logger) 23 | folder VirtualFolder 24 | tr = &taskResult{input: input} 25 | duokan = !getFlagBool("noduokan") 26 | ver = EPUB_VERSION_300 27 | ) 28 | if getFlagBool("epub2") { 29 | ver = EPUB_VERSION_200 30 | } 31 | if folder, tr.e = OpenVirtualFolder(input); tr.e != nil { 32 | logger.Printf("%s: failed to open source folder/file.\n", input) 33 | } else if tr.e = maker.Process(folder, duokan); tr.e == nil { 34 | tr.e = maker.SaveTo(outdir, ver) 35 | } 36 | 37 | chTaskResult <- tr 38 | } 39 | 40 | func processBatchFile(f *os.File, outdir string) (count int, e error) { 41 | scanner := bufio.NewScanner(f) 42 | for scanner.Scan() { 43 | name := strings.TrimSpace(scanner.Text()) 44 | if len(name) > 0 { 45 | go runTask(name, outdir) 46 | count++ 47 | } 48 | } 49 | if e = scanner.Err(); e != nil { 50 | logger.Println("error reading batch file.") 51 | } 52 | 53 | return 54 | } 55 | 56 | func processBatchFolder(f *os.File, outdir string) (count int, e error) { 57 | names, e := f.Readdirnames(-1) 58 | if e != nil { 59 | logger.Println("error reading source folder.") 60 | return 0, e 61 | } 62 | 63 | for _, name := range names { 64 | name = filepath.Join(f.Name(), name) 65 | go runTask(name, outdir) 66 | count++ 67 | } 68 | 69 | return count, nil 70 | } 71 | 72 | func RunBatch() { 73 | var input *os.File = nil 74 | if inpath := getArg(0, ""); len(inpath) == 0 { 75 | onCommandLineError() 76 | } else if f, e := os.Open(inpath); e != nil { 77 | logger.Fatalf("failed to open '%s'.\n", inpath) 78 | } else { 79 | input = f 80 | } 81 | defer input.Close() 82 | 83 | outpath := getArg(1, "") 84 | 85 | runtime.GOMAXPROCS(runtime.NumCPU() + 1) 86 | chTaskResult = make(chan *taskResult) 87 | defer close(chTaskResult) 88 | 89 | var count int 90 | var e error 91 | if fi, _ := input.Stat(); fi.IsDir() { 92 | count, e = processBatchFolder(input, outpath) 93 | } else { 94 | count, e = processBatchFile(input, outpath) 95 | } 96 | 97 | if e != nil && count == 0 { 98 | return 99 | } 100 | 101 | failed := 0 102 | for i := 0; i < count; i++ { 103 | if (<-chTaskResult).e != nil { 104 | failed++ 105 | } 106 | } 107 | 108 | logger.Printf("total: %d succeeded: %d failed: %d\n", count, count-failed, failed) 109 | } 110 | 111 | func init() { 112 | AddCommandHandler("b", RunBatch) 113 | } 114 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "os" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | type Config struct { 13 | data map[string]string 14 | } 15 | 16 | func ParseIni(reader io.Reader) (*Config, error) { 17 | section, lastKey, cfg := "/", "", make(map[string]string) 18 | firstLine, scanner := true, bufio.NewScanner(reader) 19 | 20 | for scanner.Scan() { 21 | s := scanner.Bytes() 22 | if firstLine { 23 | s = removeUtf8Bom(s) 24 | firstLine = false 25 | } 26 | 27 | s = bytes.TrimSpace(s) 28 | if len(s) == 0 || s[0] == '#' { // empty or comment 29 | continue 30 | } 31 | 32 | if s[0] == '[' && s[len(s)-1] == ']' { // section 33 | s = bytes.TrimSpace(s[1 : len(s)-1]) 34 | if len(s) >= 0 { 35 | section = "/" + string(bytes.ToLower(s)) 36 | } 37 | continue 38 | } 39 | 40 | k, v := "", "" 41 | if i := bytes.IndexByte(s, '='); i != -1 { 42 | k = string(bytes.ToLower(bytes.TrimSpace(s[:i]))) 43 | v = string(bytes.TrimSpace(s[i+1:])) 44 | } 45 | 46 | if len(k) > 0 { 47 | lastKey = section + "/" + k 48 | cfg[lastKey] = v 49 | continue 50 | } else if len(lastKey) == 0 { 51 | continue 52 | } 53 | 54 | c, lv := byte(128), cfg[lastKey] 55 | if len(lv) > 0 { 56 | c = lv[len(lv)-1] 57 | } 58 | 59 | if len(v) == 0 { // empty value means a new line 60 | cfg[lastKey] = lv + "\n" 61 | } else if c < 128 && c != '-' && v[0] < 128 { // need a white space? 62 | // not good enough, but should be ok in most cases 63 | cfg[lastKey] = lv + " " + v 64 | } else { 65 | cfg[lastKey] = lv + v 66 | } 67 | } 68 | 69 | if e := scanner.Err(); e != nil { 70 | return nil, e 71 | } 72 | 73 | return &Config{data: cfg}, nil 74 | } 75 | 76 | func OpenIniFile(path string) (*Config, error) { 77 | f, e := os.Open(path) 78 | if e != nil { 79 | return nil, e 80 | } 81 | defer f.Close() 82 | 83 | return ParseIni(f) 84 | } 85 | 86 | func (cfg *Config) GetInt(path string, dflt int) int { 87 | path = strings.ToLower(path) 88 | if v, ok := cfg.data[path]; ok { 89 | if i, e := strconv.Atoi(v); e == nil { 90 | return i 91 | } 92 | } 93 | return dflt 94 | } 95 | 96 | func (cfg *Config) GetString(path string, dflt string) string { 97 | path = strings.ToLower(path) 98 | if v, ok := cfg.data[path]; ok { 99 | return v 100 | } 101 | return dflt 102 | } 103 | 104 | func (cfg *Config) GetBool(path string, dflt bool) bool { 105 | path = strings.ToLower(path) 106 | if v, ok := cfg.data[path]; ok { 107 | if b, e := strconv.ParseBool(v); e == nil { 108 | return b 109 | } 110 | } 111 | return dflt 112 | } 113 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // makeepub project doc.go 2 | 3 | /* 4 | makeepub document 5 | */ 6 | package main 7 | -------------------------------------------------------------------------------- /epub.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "fmt" 7 | "html" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | const ( 15 | path_of_mimetype = "mimetype" 16 | path_of_toc_ncx = "toc.ncx" 17 | path_of_nav_xhtml = "nav.xhtml" 18 | path_of_content_opf = "content.opf" 19 | path_of_container_xml = "META-INF/container.xml" 20 | path_of_cover_page = "cover.html" 21 | 22 | EPUB_VERSION_NONE = iota // no version, pack all raw files into a zip package 23 | EPUB_VERSION_200 // epub version 2.0 24 | EPUB_VERSION_300 // epub version 3.0 25 | 26 | epub_NORMAL_FILE = 1 << iota // nomal files 27 | epub_CONTENT_FILE // content files: the chapters 28 | epub_FULL_SCREEN_PAGE // full screen pages in content 29 | epub_INTERNAL_FILE // internal file, generated automatically in most case 30 | ) 31 | 32 | var ( 33 | media_types = map[string]string{ 34 | ".html": "application/xhtml+xml", 35 | ".htm": "application/xhtml+xml", 36 | ".css": "text/css", 37 | ".txt": "text/plain", 38 | ".xml": "text/xml", 39 | ".xhtml": "application/xhtml+xml", 40 | ".ncx": "application/x-dtbncx+xml", 41 | ".jpg": "image/jpeg", 42 | ".jpeg": "image/jpeg", 43 | ".gif": "image/gif", 44 | ".png": "image/png", 45 | ".bmp": "image/bmp", 46 | ".otf": "application/x-font-opentype", 47 | ".ttf": "application/x-font-ttf", 48 | } 49 | ) 50 | 51 | func getMediaType(path string) string { 52 | ext := strings.ToLower(filepath.Ext(path)) 53 | if mt, ok := media_types[ext]; ok { 54 | return mt 55 | } 56 | return "application/octet-stream" 57 | } 58 | 59 | //////////////////////////////////////////////////////////////////////////////// 60 | // helper class, epub compressor 61 | 62 | type epubCompressor struct { 63 | zip *zip.Writer 64 | buf *bytes.Buffer 65 | } 66 | 67 | func (this *epubCompressor) init() error { 68 | this.buf = new(bytes.Buffer) 69 | this.zip = zip.NewWriter(this.buf) 70 | 71 | header := &zip.FileHeader{ 72 | Name: path_of_mimetype, 73 | Method: zip.Store, 74 | } 75 | w, e := this.zip.CreateHeader(header) 76 | if e == nil { 77 | _, e = w.Write([]byte("application/epub+zip")) 78 | } 79 | return e 80 | } 81 | 82 | func (this *epubCompressor) addFile(path string, data []byte) error { 83 | w, e := this.zip.Create(path) 84 | if e == nil { 85 | _, e = w.Write(data) 86 | } 87 | return e 88 | } 89 | 90 | func (this *epubCompressor) close() error { 91 | return this.zip.Close() 92 | } 93 | 94 | func (this *epubCompressor) result() []byte { 95 | return this.buf.Bytes() 96 | } 97 | 98 | //////////////////////////////////////////////////////////////////////////////// 99 | 100 | type Chapter struct { 101 | Level int 102 | Title string 103 | Link string 104 | } 105 | 106 | type File struct { 107 | Path string 108 | Data []byte 109 | Attr int 110 | Chapters []Chapter 111 | } 112 | 113 | type Epub struct { 114 | id string 115 | name string 116 | author string 117 | publisher string 118 | description string 119 | language string 120 | cover string // path of the cover image 121 | duokan bool // if duokan externsion is enabled 122 | files []*File 123 | } 124 | 125 | func NewEpub(duokan bool) *Epub { 126 | this := new(Epub) 127 | this.files = make([]*File, 0, 256) 128 | this.duokan = duokan 129 | return this 130 | } 131 | 132 | func (this *Epub) Id() string { 133 | if len(this.id) == 0 { 134 | this.SetId("") 135 | } 136 | return this.id 137 | } 138 | 139 | func (this *Epub) SetId(id string) { 140 | if len(id) == 0 { 141 | h, _ := os.Hostname() 142 | t := uint32(time.Now().Unix()) 143 | id = fmt.Sprintf("%s-book-%08x", h, t) 144 | } 145 | this.id = id 146 | } 147 | 148 | func (this *Epub) Name() string { 149 | return this.name 150 | } 151 | 152 | func (this *Epub) SetName(name string) { 153 | this.name = name 154 | } 155 | 156 | func (this *Epub) Author() string { 157 | return this.author 158 | } 159 | 160 | func (this *Epub) SetAuthor(author string) { 161 | this.author = author 162 | } 163 | 164 | func (this *Epub) Publisher() string { 165 | return this.publisher 166 | } 167 | 168 | func (this *Epub) SetPublisher(publisher string) { 169 | this.publisher = publisher 170 | } 171 | 172 | func (this *Epub) Description() string { 173 | return this.description 174 | } 175 | 176 | func (this *Epub) SetDescription(desc string) { 177 | this.description = desc 178 | } 179 | 180 | func (this *Epub) Language() string { 181 | return this.language 182 | } 183 | 184 | func (this *Epub) SetLanguage(lang string) { 185 | this.language = lang 186 | } 187 | 188 | func (this *Epub) Duokan() bool { 189 | return this.duokan 190 | } 191 | 192 | func (this *Epub) SetCoverImage(path string) { 193 | this.cover = filepath.ToSlash(path) 194 | } 195 | 196 | func (this *Epub) AddFile(path string, data []byte) { 197 | path = filepath.ToSlash(path) 198 | if strings.ToLower(path) == path_of_mimetype { 199 | return 200 | } 201 | f := &File{ 202 | Path: path, 203 | Data: data, 204 | } 205 | if path == path_of_cover_page || 206 | path == path_of_content_opf || 207 | path == path_of_toc_ncx || 208 | path == path_of_nav_xhtml || 209 | path == strings.ToLower(path_of_container_xml) { 210 | f.Attr = epub_INTERNAL_FILE 211 | } 212 | this.files = append(this.files, f) 213 | } 214 | 215 | func generateImagePage(path, alt string) []byte { 216 | path = filepath.ToSlash(path) 217 | s := fmt.Sprintf(""+ 218 | "\n"+ 219 | ""+ 220 | "\n"+ 221 | "\n"+ 222 | " \n"+ 223 | "\n"+ 224 | "\n"+ 225 | "

\"%s\"

\n"+ 226 | "\n"+ 227 | "\n", alt, path) 228 | return []byte(s) 229 | } 230 | 231 | func (this *Epub) AddFullScreenImage(path, alt string, chapters []Chapter) { 232 | f := &File{ 233 | Path: fmt.Sprintf("full_scrn_img_%04d.html", len(this.files)), 234 | Data: generateImagePage(path, alt), 235 | Attr: epub_CONTENT_FILE | epub_FULL_SCREEN_PAGE, 236 | Chapters: chapters, 237 | } 238 | this.files = append(this.files, f) 239 | } 240 | 241 | func (this *Epub) AddChapter(chapters []Chapter, data []byte) { 242 | f := &File{ 243 | Path: fmt.Sprintf("chapter_%04d.html", len(this.files)), 244 | Data: data, 245 | Attr: epub_CONTENT_FILE, 246 | Chapters: chapters, 247 | } 248 | this.files = append(this.files, f) 249 | } 250 | 251 | func (this *Epub) Depth() int { 252 | d := 0 253 | for _, f := range this.files { 254 | for _, c := range f.Chapters { 255 | if c.Level > d { 256 | d = c.Level 257 | } 258 | } 259 | } 260 | return d 261 | } 262 | 263 | func (this *Epub) generateContainerXml() []byte { 264 | return []byte("" + 265 | "\n" + 266 | "\n" + 267 | " \n" + 268 | " \n" + 269 | " \n" + 270 | "") 271 | } 272 | 273 | func (this *Epub) generateContentOpf(version int) []byte { 274 | buf := new(bytes.Buffer) 275 | 276 | buf.WriteString("\n") 277 | if version == EPUB_VERSION_200 { 278 | buf.WriteString("\n") 279 | } else { 280 | buf.WriteString("\n") 281 | } 282 | buf.WriteString(" \n") 283 | 284 | fmt.Fprintf(buf, " %s\n"+ 285 | " %s\n"+ 286 | " %s\n"+ 287 | " \n", 288 | html.EscapeString(this.Id()), 289 | html.EscapeString(this.Name()), 290 | html.EscapeString(this.Language()), 291 | this.cover, 292 | ) 293 | 294 | if version == EPUB_VERSION_200 { 295 | fmt.Fprintf(buf, " %s\n", html.EscapeString(this.Author())) 296 | fmt.Fprintf(buf, " %s\n", time.Now().UTC().Format(time.RFC3339)) 297 | } else { 298 | fmt.Fprintf(buf, " %s\n", html.EscapeString(this.Author())) 299 | buf.WriteString(" aut\n") 300 | fmt.Fprintf(buf, " %s\n", time.Now().UTC().Format(time.RFC3339)) 301 | } 302 | 303 | if len(this.Publisher()) > 0 { 304 | fmt.Fprintf(buf, " %s\n", html.EscapeString(this.Publisher())) 305 | } 306 | 307 | if len(this.Description()) > 0 { 308 | fmt.Fprintf(buf, " %s\n", html.EscapeString(this.Description())) 309 | } 310 | 311 | buf.WriteString(" \n \n") 312 | 313 | if version == EPUB_VERSION_200 { 314 | buf.WriteString(" \n") 315 | } else { 316 | buf.WriteString(" \n") 317 | } 318 | 319 | if len(this.cover) > 0 { 320 | buf.WriteString(" \n") 321 | } 322 | 323 | for i, f := range this.files { 324 | if (f.Attr & epub_INTERNAL_FILE) != 0 { 325 | continue 326 | } 327 | fmt.Fprintf(buf, 328 | " \n", 329 | f.Path, 330 | i, 331 | getMediaType(f.Path), 332 | ) 333 | } 334 | 335 | if version == EPUB_VERSION_200 { 336 | buf.WriteString(" \n \n") 337 | } else { 338 | buf.WriteString(" \n \n") 339 | } 340 | 341 | if len(this.cover) > 0 { 342 | buf.WriteString(" \n") 345 | } else { 346 | buf.WriteString("/>\n") 347 | } 348 | } 349 | 350 | for i, f := range this.files { 351 | if (f.Attr & epub_CONTENT_FILE) == 0 { 352 | continue 353 | } 354 | fmt.Fprintf(buf, " \n") 357 | } else { 358 | buf.WriteString("/>\n") 359 | } 360 | } 361 | 362 | buf.WriteString(" \n") 363 | 364 | return buf.Bytes() 365 | } 366 | 367 | //////////////////////////////////////////////////////////////////////////////// 368 | // epub 2.0 369 | 370 | func (this *Epub) generateTocNcx() []byte { 371 | buf := new(bytes.Buffer) 372 | fmt.Fprintf(buf, ""+ 373 | "\n"+ 374 | "\n"+ 375 | " \n"+ 376 | " \n"+ 377 | " \n"+ 378 | " \n"+ 379 | " \n"+ 380 | " \n"+ 381 | " \n"+ 382 | " %s\n"+ 383 | " %s\n"+ 384 | " \n", 385 | this.Id(), 386 | this.Depth(), 387 | this.Name(), 388 | this.Author(), 389 | ) 390 | 391 | depth, playorder := 0, 0 392 | for _, f := range this.files { 393 | if (f.Attr & epub_CONTENT_FILE) == 0 { 394 | continue 395 | } 396 | for _, c := range f.Chapters { 397 | if c.Level == depth { 398 | buf.WriteString("\n") 399 | } else if c.Level > depth { 400 | depth = c.Level 401 | } else { 402 | for c.Level <= depth { 403 | buf.WriteString("\n") 404 | depth-- 405 | } 406 | } 407 | fmt.Fprintf(buf, ""+ 408 | "\n"+ 409 | " \n"+ 410 | " %s\n"+ 411 | " \n"+ 412 | " \n", 413 | playorder, 414 | playorder, 415 | c.Title, 416 | f.Path+c.Link, 417 | ) 418 | playorder++ 419 | } 420 | } 421 | for depth > 0 { 422 | buf.WriteString("\n") 423 | depth-- 424 | } 425 | 426 | buf.WriteString(" \n") 427 | 428 | return buf.Bytes() 429 | } 430 | 431 | //////////////////////////////////////////////////////////////////////////////// 432 | // epub 3.0 433 | 434 | func (this *Epub) generateNavXhtml() []byte { 435 | buf := new(bytes.Buffer) 436 | fmt.Fprintf(buf, 437 | "\n"+ 438 | "\n"+ 439 | " \n"+ 440 | " %s\n"+ 441 | " \n"+ 442 | " \n"+ 443 | " \n \n") 481 | 482 | return buf.Bytes() 483 | } 484 | 485 | //////////////////////////////////////////////////////////////////////////////// 486 | 487 | func (this *Epub) Build(version int) ([]byte, error) { 488 | compressor := epubCompressor{} 489 | if e := compressor.init(); e != nil { 490 | return nil, e 491 | } 492 | 493 | if version != EPUB_VERSION_NONE { 494 | data := this.generateContainerXml() 495 | if e := compressor.addFile(path_of_container_xml, data); e != nil { 496 | return nil, e 497 | } 498 | data = this.generateContentOpf(version) 499 | if e := compressor.addFile(path_of_content_opf, data); e != nil { 500 | return nil, e 501 | } 502 | if version == EPUB_VERSION_200 { 503 | data = this.generateTocNcx() 504 | if e := compressor.addFile(path_of_toc_ncx, data); e != nil { 505 | return nil, e 506 | } 507 | } else { 508 | data = this.generateNavXhtml() 509 | if e := compressor.addFile(path_of_nav_xhtml, data); e != nil { 510 | return nil, e 511 | } 512 | } 513 | if len(this.cover) > 0 { 514 | data = generateImagePage(this.cover, "cover") 515 | if e := compressor.addFile(path_of_cover_page, data); e != nil { 516 | return nil, e 517 | } 518 | } 519 | } 520 | 521 | for _, f := range this.files { 522 | if e := compressor.addFile(f.Path, f.Data); e != nil { 523 | return nil, e 524 | } 525 | } 526 | 527 | if e := compressor.close(); e != nil { 528 | return nil, e 529 | } 530 | 531 | return compressor.result(), nil 532 | } 533 | 534 | func (this *Epub) Save(path string, version int) error { 535 | data, e := this.Build(version) 536 | if e != nil { 537 | return e 538 | } 539 | 540 | f, e := os.Create(path) 541 | if e == nil { 542 | _, e = f.Write(data) 543 | f.Close() 544 | } 545 | 546 | return e 547 | } 548 | -------------------------------------------------------------------------------- /example/book.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/localvar/makeepub/16fc6076ad3cf254dd29ea939b0fb14fa088a986/example/book.zip -------------------------------------------------------------------------------- /example/book/book.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 示例(Example) 6 | 7 | 8 | 9 |

不是第一章(Not Chapter 1)

10 | 11 | 12 |
13 | 14 | 15 |

第一章第一节(Section 1 of Chapter 1)

16 | 17 |

第一章第一节(content of section 1 of chapter 1)

18 |

测试三级标题(Test Level 3 chapter)

19 | 20 |

测试四级标题(Test Level 4 chapter)

21 | 22 | 23 |

第二章(Chapter 2)

24 | 25 | 26 |

第二章正文的第一部分(part 1 of the chapter 2 content)

27 |
28 | 29 |

第二章正文的第二部分(part 2 of the chapter 2 content)

30 | 31 | fullscreen 32 | 33 |

第三章(Chapter 3)

34 |

不是章节(not a chapter)

35 | 36 |

第三章第一节(Section 1 one Chapter 3)

37 | 38 | 39 |
40 | 41 |

这些文字会出现在章节标题前(text before chapter title)

42 |

第三章第二节(Section 2 one Chapter 3)

43 | 44 | 45 | -------------------------------------------------------------------------------- /example/book/book.ini: -------------------------------------------------------------------------------- 1 | [book] 2 | name=示例(Example) 3 | author=佚名(Unknown) 4 | publisher=无名社(No Name Press) 5 | description= 这是一个多行属性。 6 | = 以等号开始的行会被合并入上一行。 7 | = This is a multi-line attribute, 8 | = lines begin with '=' will be joint 9 | = with last line. 10 | toc=3 11 | 12 | [split] 13 | AtLevel=2 14 | ByHeader=2 15 | 16 | [output] 17 | path=example.epub 18 | -------------------------------------------------------------------------------- /example/book/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/localvar/makeepub/16fc6076ad3cf254dd29ea939b0fb14fa088a986/example/book/cover.png -------------------------------------------------------------------------------- /example/book/fullscreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/localvar/makeepub/16fc6076ad3cf254dd29ea939b0fb14fa088a986/example/book/fullscreen.png -------------------------------------------------------------------------------- /example/book/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: block; 3 | font-size: 1em; 4 | line-height: 162%; 5 | margin-bottom: 0; 6 | margin-left: 0pt; 7 | margin-right: 0pt; 8 | margin-top: 0; 9 | padding-left: 0; 10 | padding-right: 0; 11 | } 12 | 13 | 14 | h1, h2, h3, h4, h5, h6 { 15 | text-align:center; 16 | } 17 | 18 | h1 { 19 | font-size: 1.4em; 20 | } 21 | 22 | h2 { 23 | font-size: 1.2em; 24 | } 25 | 26 | p{ 27 | display: block; 28 | font-size: 1em; 29 | margin-bottom: 1em; 30 | margin-left: 0; 31 | margin-right: 0; 32 | margin-top: 1em; 33 | text-indent: 4ex; 34 | } 35 | 36 | 37 | -------------------------------------------------------------------------------- /extract.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/zip" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | func RunExtract() { 11 | inpath, outpath := getArg(0, ""), getArg(1, "") 12 | if len(inpath) == 0 || len(outpath) == 0 { 13 | onCommandLineError() 14 | } 15 | zrc, e := zip.OpenReader(inpath) 16 | if e != nil { 17 | logger.Fatalf("failed to open '%s'.\n", inpath) 18 | } 19 | defer zrc.Close() 20 | 21 | if e = os.MkdirAll(outpath, os.ModeDir|0666); e != nil { 22 | logger.Fatalln("failed to create output folder.") 23 | } 24 | 25 | for _, zf := range zrc.File { 26 | path := filepath.Join(outpath, zf.Name) 27 | 28 | // skip folders, if it is not empty, will be created during file creation 29 | if zf.FileInfo().IsDir() { 30 | continue 31 | } 32 | 33 | // create the folder if needed, but no need to check error 34 | dir, _ := filepath.Split(path) 35 | os.MkdirAll(dir, os.ModeDir|0666) 36 | 37 | rc, e := zf.Open() 38 | if e != nil { 39 | logger.Printf("failed to open '%s'.\n", zf.Name) 40 | continue 41 | } 42 | 43 | if f, e := os.Create(path); e != nil { 44 | logger.Printf("failed to create output file '%s'.", zf.Name) 45 | } else if _, e = io.Copy(f, rc); e != nil { 46 | logger.Printf("error writing data to '%s'.\n", zf.Name) 47 | } else { 48 | f.Close() 49 | } 50 | 51 | rc.Close() 52 | } 53 | } 54 | 55 | func init() { 56 | AddCommandHandler("e", RunExtract) 57 | } 58 | -------------------------------------------------------------------------------- /folder.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | ) 12 | 13 | //////////////////////////////////////////////////////////////////////////////// 14 | 15 | type FxWalk func(path string) error 16 | 17 | type VirtualFolder interface { 18 | OpenFile(path string) (io.ReadCloser, error) 19 | Walk(fnWalk FxWalk) error 20 | ReadDirNames() ([]string, error) 21 | Name() string 22 | } 23 | 24 | //////////////////////////////////////////////////////////////////////////////// 25 | 26 | type SystemFolder struct { 27 | path string 28 | } 29 | 30 | func OpenSystemFolder(path string) *SystemFolder { 31 | return &SystemFolder{path: path} 32 | } 33 | 34 | func (this *SystemFolder) OpenFile(path string) (io.ReadCloser, error) { 35 | return os.Open(filepath.Join(this.path, path)) 36 | } 37 | 38 | func (this *SystemFolder) Name() string { 39 | return this.path 40 | } 41 | 42 | func (this *SystemFolder) Walk(fnWalk FxWalk) error { 43 | walk := func(path string, info os.FileInfo, err error) error { 44 | if err != nil { 45 | return err 46 | } 47 | if info.IsDir() { 48 | return nil 49 | } 50 | path, _ = filepath.Rel(this.path, path) 51 | return fnWalk(path) 52 | } 53 | 54 | return filepath.Walk(this.path, walk) 55 | } 56 | 57 | func (this *SystemFolder) ReadDirNames() ([]string, error) { 58 | f, e := os.Open(this.path) 59 | if e != nil { 60 | return nil, e 61 | } 62 | defer f.Close() 63 | return f.Readdirnames(-1) 64 | } 65 | 66 | //////////////////////////////////////////////////////////////////////////////// 67 | 68 | type ZipFolder struct { 69 | zr *zip.Reader 70 | name string 71 | } 72 | 73 | func NewZipFolder(data []byte) (*ZipFolder, error) { 74 | r := bytes.NewReader(data) 75 | if zr, e := zip.NewReader(r, int64(len(data))); e != nil { 76 | return nil, e 77 | } else { 78 | return &ZipFolder{zr: zr, name: ""}, nil 79 | } 80 | } 81 | 82 | func OpenZipFolder(path string) (*ZipFolder, error) { 83 | if data, e := ioutil.ReadFile(path); e != nil { 84 | return nil, e 85 | } else if zf, e := NewZipFolder(data); e != nil { 86 | return nil, e 87 | } else { 88 | zf.name = path 89 | return zf, nil 90 | } 91 | } 92 | 93 | func (this *ZipFolder) Name() string { 94 | return this.name 95 | } 96 | 97 | func (this *ZipFolder) OpenFile(path string) (io.ReadCloser, error) { 98 | for _, f := range this.zr.File { 99 | if strings.ToLower(f.Name) == path { 100 | return f.Open() 101 | } 102 | } 103 | return nil, os.ErrNotExist 104 | } 105 | 106 | func (this *ZipFolder) Walk(fnWalk FxWalk) error { 107 | for _, f := range this.zr.File { 108 | if e := fnWalk(f.Name); e != nil { 109 | return e 110 | } 111 | } 112 | return nil 113 | } 114 | 115 | func (this *ZipFolder) ReadDirNames() ([]string, error) { 116 | names := make([]string, len(this.zr.File)) 117 | for i, f := range this.zr.File { 118 | names[i] = f.Name 119 | } 120 | return names, nil 121 | } 122 | 123 | //////////////////////////////////////////////////////////////////////////////// 124 | 125 | func OpenVirtualFolder(path string) (VirtualFolder, error) { 126 | stat, e := os.Stat(path) 127 | if e != nil { 128 | return nil, e 129 | } 130 | 131 | if stat.IsDir() { 132 | return OpenSystemFolder(path), nil 133 | } 134 | 135 | return OpenZipFolder(path) 136 | } 137 | 138 | //////////////////////////////////////////////////////////////////////////////// 139 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | const version = "1.1.0" 12 | 13 | func showUsage() { 14 | usage := `Create/Batch Create/Pack/Extract EPUB file(s). Merge HTML/Text files. 15 | It can also work as a web server to convert an uploaded zip file to an EPUB. 16 | Please refer to manual for detailed usage. 17 | 18 | COMMAND LINE 19 | Create : makeepub [OutputFolder] [-epub2] [-noduokan] 20 | Batch Create : makeepub -b [OutputFolder] [-epub2] [-noduokan] 21 | makeepub -b [OutputFolder] [-epub2] [-noduokan] 22 | Pack : makeepub -p 23 | Extract : makeepub -e 24 | Merge HTML : makeepub -mh 25 | Merge Text : makeepub -mt 26 | Web Server : makeepub -s [Port] 27 | 28 | ARGUMENT 29 | VirtualFolder: An OS folder or a zip file which contains the input files. 30 | OutputFolder : An OS folder to store the output file(s). 31 | -epub2 : Generate books using EPUB2 format, otherwise EPUB3. 32 | -noduokan : Disable DuoKan externsion. 33 | InputFolder : An OS folder which contains the input folder(s)/file(s). 34 | BatchFile : A text which lists the path of 'VirtualFolders' to be 35 | processed, one line for one 'VirtualFolder' 36 | OutputFile : The path of the output file. 37 | EpubFile : The path of an EPUB file. 38 | Port : The TCP port to listen to, default value is 80. 39 | ` 40 | fmt.Print(usage) 41 | os.Exit(0) 42 | } 43 | 44 | func onCommandLineError() { 45 | logger.Fatalln("invalid command line. see 'makeepub -?'") 46 | } 47 | 48 | func getArg(index int, dflt string) string { 49 | count := 0 50 | for _, arg := range os.Args[1:] { 51 | if !isFlag(arg) { 52 | if count == index { 53 | return arg 54 | } 55 | count++ 56 | } 57 | } 58 | return dflt 59 | } 60 | 61 | func getFlag(index int) string { 62 | count := 0 63 | for _, arg := range os.Args[1:] { 64 | if isFlag(arg) { 65 | if count == index { 66 | return arg[1:] 67 | } 68 | count++ 69 | } 70 | } 71 | return "" 72 | } 73 | 74 | func isFlag(arg string) bool { 75 | if os.PathSeparator == '/' { 76 | return arg[0] == '-' 77 | } 78 | return arg[0] == '-' || arg[0] == '/' 79 | } 80 | 81 | func getFlagBool(flag string) bool { 82 | flag = strings.ToLower(flag) 83 | for _, arg := range os.Args[1:] { 84 | if isFlag(arg) && strings.ToLower(arg[1:]) == flag { 85 | return true 86 | } 87 | } 88 | return false 89 | } 90 | 91 | type CommandHandler struct { 92 | command string 93 | handler func() 94 | } 95 | 96 | var ( 97 | logger = log.New(os.Stderr, "makeepub: ", 0) 98 | handlers = make([]CommandHandler, 0, 8) 99 | ) 100 | 101 | func AddCommandHandler(cmd string, handler func()) { 102 | for _, h := range handlers { 103 | if h.command == cmd { 104 | logger.Fatalf("handler for command '%s' already exists.\n", cmd) 105 | } 106 | } 107 | handlers = append(handlers, CommandHandler{command: cmd, handler: handler}) 108 | } 109 | 110 | func findCommandHandler(cmd string) func() { 111 | if !isFlag(cmd) { 112 | return RunMake 113 | } 114 | cmd = strings.ToLower(cmd[1:]) 115 | for _, h := range handlers { 116 | if cmd == h.command { 117 | return h.handler 118 | } 119 | } 120 | return onCommandLineError 121 | } 122 | 123 | func main() { 124 | fmt.Println("makeepub v" + version + ", home page: https://github.com/localvar/makeepub") 125 | if len(os.Args) < 2 { 126 | onCommandLineError() 127 | } 128 | 129 | AddCommandHandler("?", showUsage) 130 | AddCommandHandler("h", showUsage) 131 | handler := findCommandHandler(os.Args[1]) 132 | 133 | start := time.Now() 134 | handler() 135 | logger.Println("done, time used:", time.Now().Sub(start).String()) 136 | 137 | os.Exit(0) 138 | } 139 | -------------------------------------------------------------------------------- /make.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | 13 | "golang.org/x/net/html" 14 | "golang.org/x/net/html/atom" 15 | ) 16 | 17 | const ( 18 | lowest_level = iota + 6 19 | unknown_level 20 | 21 | duokan_fullscreen = "duokan-fullscreen" 22 | makeepub_chapter_id = "makeepub-chapter-%d" 23 | makeepub_chapter = "makeepub-chapter" 24 | makeepub_not_chapter = "makeepub-not-chapter" 25 | data_chapter_level = "data-chapter-level" 26 | data_chapter_title = "data-chapter-title" 27 | ) 28 | 29 | type EpubMaker struct { 30 | folder VirtualFolder 31 | book *Epub 32 | logger *log.Logger 33 | output_path string 34 | chapter_id int 35 | toc int 36 | split int 37 | by_header int 38 | body *html.Node // 'body' element of the original html 39 | skip bool // skip next header (

,

...)? 40 | blank bool // current chapter is blank? 41 | } 42 | 43 | func NewEpubMaker(logger *log.Logger) *EpubMaker { 44 | return &EpubMaker{logger: logger} 45 | } 46 | 47 | func (this *EpubMaker) parseBook() (*html.Node, error) { 48 | f, e := this.folder.OpenFile("book.html") 49 | if e != nil { 50 | return nil, e 51 | } 52 | defer f.Close() 53 | root, e := html.Parse(f) 54 | if e != nil { 55 | return root, e 56 | } 57 | 58 | e = fmt.Errorf("structure of 'book.html' is invalid.") 59 | if root.Type != html.DocumentNode { 60 | return root, e 61 | } 62 | 63 | var Html *html.Node = nil 64 | for node := root.FirstChild; node != nil; node = node.NextSibling { 65 | if node.Type != html.ElementNode { 66 | continue 67 | } 68 | if node.DataAtom != atom.Html || Html != nil { 69 | return root, e 70 | } 71 | Html = node 72 | } 73 | if Html == nil { 74 | return root, e 75 | } 76 | 77 | var head *html.Node = nil 78 | var body *html.Node = nil 79 | for node := Html.FirstChild; node != nil; node = node.NextSibling { 80 | if node.Type != html.ElementNode { 81 | continue 82 | } 83 | if node.DataAtom == atom.Head { 84 | if head != nil { 85 | return root, e 86 | } 87 | head = node 88 | } else if node.DataAtom == atom.Body { 89 | if body != nil { 90 | return root, e 91 | } 92 | body = node 93 | } else { 94 | return root, e 95 | } 96 | } 97 | 98 | if head == nil || body == nil { 99 | return root, e 100 | } 101 | 102 | return root, nil 103 | } 104 | 105 | func (this *EpubMaker) addFilesToBook() error { 106 | walk := func(path string) error { 107 | p := strings.ToLower(path) 108 | if p == "book.ini" || p == "book.html" { 109 | return nil 110 | } 111 | 112 | rc, e := this.folder.OpenFile(path) 113 | if e != nil { 114 | return e 115 | } 116 | defer rc.Close() 117 | data, e := ioutil.ReadAll(rc) 118 | if e != nil { 119 | return e 120 | } 121 | 122 | if p == "cover.png" || p == "cover.jpg" || p == "cover.gif" { 123 | this.book.SetCoverImage(p) 124 | } 125 | this.book.AddFile(path, data) 126 | return nil 127 | } 128 | 129 | return this.folder.Walk(walk) 130 | } 131 | 132 | func checkHeaderNode(node *html.Node) *Chapter { 133 | if len(node.Data) != 2 || node.Data[0] != 'h' { 134 | return nil 135 | } 136 | 137 | level := int(node.Data[1] - '0') 138 | if level <= 0 || level > lowest_level { 139 | return nil 140 | } 141 | 142 | title := "" 143 | if attr := findAttribute(node, data_chapter_title); attr != nil { 144 | title = attr.Val 145 | } else if node.FirstChild != nil { 146 | title = node.FirstChild.Data 147 | } 148 | return &Chapter{Level: level, Title: title} 149 | } 150 | 151 | func (this *EpubMaker) checkChapterNode(node *html.Node) *Chapter { 152 | if !hasClass(node, makeepub_chapter) { 153 | return nil 154 | } 155 | 156 | // if it has the 'level' attribute, it should have 'title' attribute also 157 | if attr := findAttribute(node, data_chapter_level); attr != nil { 158 | level, e := strconv.Atoi(attr.Val) 159 | if e != nil || level < 0 || level > lowest_level { 160 | this.writeLog("invalid chapter level '" + attr.Val + "', ignored.") 161 | return nil 162 | } 163 | title := getAttributeValue(node, data_chapter_title, "") 164 | return &Chapter{Level: level, Title: title} 165 | } 166 | 167 | // if this is a 'header' element, use its own 'level' & 'title' 168 | if c := checkHeaderNode(node); c != nil { 169 | return c 170 | } 171 | 172 | // try to find next 'header' element for level & title 173 | for n := this.body.FirstChild; n != nil; n = n.NextSibling { 174 | if n.Type != html.ElementNode { 175 | continue 176 | } 177 | if hasClass(n, makeepub_chapter) { 178 | return nil 179 | } 180 | if c := checkHeaderNode(n); c != nil { 181 | this.skip = true 182 | return c 183 | } 184 | } 185 | 186 | return nil 187 | } 188 | 189 | func (this *EpubMaker) checkNewChapter(node *html.Node) *Chapter { 190 | if node.Type != html.ElementNode { 191 | return nil 192 | } 193 | 194 | var c *Chapter = nil 195 | if c = this.checkChapterNode(node); c == nil { 196 | if c = checkHeaderNode(node); c == nil { 197 | return nil 198 | } 199 | if this.skip { 200 | this.skip = false 201 | return nil 202 | } 203 | if c.Level < this.by_header || hasClass(node, makeepub_not_chapter) { 204 | return nil 205 | } 206 | } 207 | 208 | // only chapters in TOC need a Link 209 | if c.Level > 0 && c.Level <= this.toc { 210 | id := findAttribute(node, "id") 211 | if id == nil { 212 | node.Attr = append(node.Attr, html.Attribute{Key: "id"}) 213 | id = &node.Attr[len(node.Attr)-1] 214 | } 215 | if len(id.Val) == 0 { 216 | id.Val = fmt.Sprintf(makeepub_chapter_id, this.chapter_id) 217 | this.chapter_id++ 218 | } 219 | c.Link = "#" + id.Val 220 | } 221 | 222 | c.Title = strings.TrimSpace(c.Title) 223 | return c 224 | } 225 | 226 | func (this *EpubMaker) checkFullScreenImage(node *html.Node) (string, string) { 227 | if !this.book.Duokan() { 228 | return "", "" 229 | } 230 | if node.Type != html.ElementNode || node.DataAtom != atom.Img { 231 | return "", "" 232 | } 233 | fs, src, alt := false, "", "" 234 | for i := 0; i < len(node.Attr); i++ { 235 | attr := &node.Attr[i] 236 | if attr.Key == "class" { 237 | fs = containsField(attr.Val, duokan_fullscreen) 238 | } else if attr.Key == "src" { 239 | src = attr.Val 240 | } else if attr.Key == "alt" { 241 | alt = attr.Val 242 | } 243 | } 244 | if fs { 245 | return src, alt 246 | } 247 | return "", "" 248 | } 249 | 250 | func (this *EpubMaker) splitChapter(root *html.Node) { 251 | this.body = findFirstDirectChild(root, atom.Html) 252 | this.body = findFirstDirectChild(this.body, atom.Body) 253 | this.blank = true 254 | 255 | body := resetBody(this.body) 256 | chapters := make([]Chapter, 0) 257 | lastLevel := unknown_level 258 | 259 | for node := this.body.FirstChild; node != nil; node = this.body.FirstChild { 260 | this.body.RemoveChild(node) 261 | 262 | if isBlankNode(node) { 263 | body.AppendChild(node) 264 | continue 265 | } 266 | 267 | c := this.checkNewChapter(node) 268 | 269 | if path, alt := this.checkFullScreenImage(node); len(path) > 0 { 270 | this.saveChapter(root, chapters) 271 | body = resetBody(body) 272 | chapters = nil 273 | lastLevel = unknown_level 274 | this.saveFullScreenImage(path, alt, c) 275 | continue 276 | } 277 | 278 | if c == nil { 279 | lastLevel = unknown_level 280 | body.AppendChild(node) 281 | this.blank = false 282 | continue 283 | } 284 | 285 | // c.Level > lastLevel means current chapter is a child of last 286 | // chapter, and there's no text (only chapter names), so merge it into 287 | // last chapter 288 | if c.Level <= this.split && c.Level <= lastLevel { 289 | this.saveChapter(root, chapters) 290 | body = resetBody(body) 291 | chapters = nil 292 | lastLevel = c.Level 293 | } 294 | 295 | // level 0 is only for chapter split, will not be added to chapter list 296 | if c.Level > 0 && c.Level <= this.toc && len(c.Title) > 0 { 297 | chapters = append(chapters, *c) 298 | } 299 | 300 | body.AppendChild(node) 301 | this.blank = false 302 | } 303 | 304 | this.saveChapter(root, chapters) 305 | } 306 | 307 | func resetBody(body *html.Node) *html.Node { 308 | nb := cloneNode(body) 309 | body.Parent.AppendChild(nb) 310 | body.Parent.RemoveChild(body) 311 | return nb 312 | } 313 | 314 | func (this *EpubMaker) saveFullScreenImage(path, alt string, c *Chapter) { 315 | chapters := make([]Chapter, 0) 316 | if c != nil && c.Level > 0 && c.Level <= this.toc && len(c.Title) > 0 { 317 | c.Link = "" 318 | chapters = append(chapters, *c) 319 | } 320 | this.book.AddFullScreenImage(path, alt, chapters) 321 | } 322 | 323 | func (this *EpubMaker) saveChapter(root *html.Node, chapters []Chapter) { 324 | if !this.blank { 325 | buf := new(bytes.Buffer) 326 | html.Render(buf, root) 327 | this.book.AddChapter(chapters, buf.Bytes()) 328 | this.blank = true 329 | } 330 | } 331 | 332 | func (this *EpubMaker) writeLog(msg string) { 333 | this.logger.Printf("%s: %s\n", this.folder.Name(), msg) 334 | } 335 | 336 | func (this *EpubMaker) loadConfig() error { 337 | rc, e := this.folder.OpenFile("book.ini") 338 | if e != nil { 339 | return e 340 | } 341 | 342 | cfg, e := ParseIni(rc) 343 | rc.Close() 344 | if e != nil { 345 | return e 346 | } 347 | 348 | this.toc = cfg.GetInt("/book/toc", 2) 349 | if this.toc < 1 || this.toc > lowest_level { 350 | this.writeLog("option 'toc' is invalid, will use default value 2.") 351 | this.toc = 2 352 | } 353 | this.split = cfg.GetInt("/split/AtLevel", 1) 354 | if this.split < 0 || this.split > lowest_level { 355 | this.writeLog("option 'AtLevel' is invalid, will use default value 1.") 356 | this.split = 1 357 | } 358 | this.by_header = cfg.GetInt("/split/ByHeader", 1) 359 | if this.by_header < 1 || this.by_header > (lowest_level+1) { 360 | this.writeLog("option 'ByHeader' is invalid, will use default value 1.") 361 | this.by_header = 1 362 | } 363 | this.output_path = cfg.GetString("/output/path", "") 364 | 365 | s := cfg.GetString("/book/id", "") 366 | this.book.SetId(s) 367 | 368 | s = cfg.GetString("/book/name", "") 369 | if len(s) == 0 { 370 | this.writeLog("book name is empty.") 371 | } 372 | this.book.SetName(s) 373 | 374 | s = cfg.GetString("/book/author", "") 375 | if len(s) == 0 { 376 | this.writeLog("author name is empty.") 377 | } 378 | this.book.SetAuthor(s) 379 | 380 | s = cfg.GetString("/book/publisher", "") 381 | this.book.SetPublisher(s) 382 | 383 | s = cfg.GetString("/book/description", "") 384 | this.book.SetDescription(s) 385 | 386 | s = cfg.GetString("/book/language", "zh-CN") 387 | this.book.SetLanguage(s) 388 | 389 | return nil 390 | } 391 | 392 | func (this *EpubMaker) Process(folder VirtualFolder, duokan bool) error { 393 | this.folder = folder 394 | this.book = NewEpub(duokan) 395 | 396 | if e := this.loadConfig(); e != nil { 397 | this.writeLog(e.Error()) 398 | this.writeLog("failed to open configuration file.") 399 | return e 400 | } 401 | 402 | if root, e := this.parseBook(); e != nil { 403 | this.writeLog(e.Error()) 404 | this.writeLog("failed to parse 'book.html'.") 405 | return e 406 | } else { 407 | this.splitChapter(root) 408 | } 409 | 410 | if e := this.addFilesToBook(); e != nil { 411 | this.writeLog(e.Error()) 412 | this.writeLog("failed to add files to book.") 413 | return e 414 | } 415 | 416 | return nil 417 | } 418 | 419 | func (this *EpubMaker) SaveTo(outdir string, version int) error { 420 | path := this.output_path 421 | if len(path) == 0 { 422 | this.writeLog("output path is empty, no file will be created.") 423 | return nil 424 | } 425 | 426 | if len(outdir) != 0 { 427 | _, path = filepath.Split(path) 428 | path = filepath.Join(outdir, path) 429 | } 430 | 431 | if e := this.book.Save(path, version); e != nil { 432 | this.writeLog("failed to create output file.") 433 | return e 434 | } 435 | 436 | this.writeLog("output file created at '" + path + "'.") 437 | return nil 438 | } 439 | 440 | func (this *EpubMaker) GetResult(ver int) ([]byte, string, error) { 441 | path := this.output_path 442 | if len(path) > 0 { 443 | _, path = filepath.Split(path) 444 | } else { 445 | path = "book.epub" 446 | } 447 | 448 | data, e := this.book.Build(ver) 449 | return data, path, e 450 | } 451 | 452 | func RunMake() { 453 | duokan := !getFlagBool("noduokan") 454 | ver := EPUB_VERSION_300 455 | if getFlagBool("epub2") { 456 | ver = EPUB_VERSION_200 457 | } 458 | 459 | maker := NewEpubMaker(logger) 460 | 461 | if inpath := getArg(0, ""); len(inpath) == 0 { 462 | onCommandLineError() 463 | } else if folder, e := OpenVirtualFolder(inpath); e != nil { 464 | logger.Fatalf("%s: failed to open source folder/file.\n", inpath) 465 | } else if maker.Process(folder, duokan) != nil { 466 | os.Exit(1) 467 | } else if maker.SaveTo(getArg(1, ""), ver) != nil { 468 | os.Exit(1) 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /merge.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "sort" 7 | 8 | "golang.org/x/net/html" 9 | "golang.org/x/net/html/atom" 10 | ) 11 | 12 | func mergeHtml(folder VirtualFolder, names []string) []byte { 13 | var result *html.Node = nil 14 | var body *html.Node = nil 15 | 16 | for _, name := range names { 17 | f, e := folder.OpenFile(name) 18 | if e != nil { 19 | logger.Fatalf("error reading '%s'.\n", name) 20 | } 21 | 22 | doc, e := html.Parse(f) 23 | f.Close() 24 | if e != nil { 25 | logger.Fatalf("error parsing '%s'.\n", name) 26 | } 27 | 28 | b := findFirstChild(doc, atom.Body) 29 | if b == nil { 30 | logger.Fatalf("'%s' has no 'body' element.\n", name) 31 | } 32 | 33 | if body == nil { 34 | result = doc 35 | body = b 36 | continue 37 | } 38 | 39 | for n := b.FirstChild; n != nil; n = b.FirstChild { 40 | b.RemoveChild(n) 41 | body.AppendChild(n) 42 | } 43 | } 44 | 45 | buf := new(bytes.Buffer) 46 | if e := html.Render(buf, result); e != nil { 47 | logger.Fatalf("failed render result for '%s'.\n", folder.Name()) 48 | } 49 | 50 | return buf.Bytes() 51 | } 52 | 53 | func mergeText(folder VirtualFolder, names []string) []byte { 54 | buf := new(bytes.Buffer) 55 | 56 | for _, name := range names { 57 | f, e := folder.OpenFile(name) 58 | if e != nil { 59 | logger.Fatalf("error reading '%s'.\n", name) 60 | } 61 | 62 | data, e := ioutil.ReadAll(f) 63 | if e != nil { 64 | logger.Fatalf("error reading '%s'.\n", name) 65 | } 66 | 67 | buf.Write(removeUtf8Bom(data)) 68 | buf.WriteByte('\n') 69 | 70 | f.Close() 71 | } 72 | 73 | return buf.Bytes() 74 | } 75 | 76 | func RunMerge() { 77 | inpath, outpath := getArg(0, ""), getArg(1, "") 78 | if len(inpath) == 0 || len(outpath) == 0 { 79 | onCommandLineError() 80 | } 81 | 82 | folder, e := OpenVirtualFolder(inpath) 83 | if e != nil { 84 | logger.Fatalf("failed to open '%s'.\n", inpath) 85 | } 86 | 87 | names, e := folder.ReadDirNames() 88 | if e != nil { 89 | logger.Fatal("failed to get input file list.") 90 | } 91 | 92 | if len(names) == 0 { 93 | logger.Println("input folder is empty.") 94 | return 95 | } 96 | 97 | sort.Strings(names) 98 | 99 | var data []byte 100 | if flag := getFlag(0); flag[1] == 'h' || flag[1] == 'H' { 101 | data = mergeHtml(folder, names) 102 | } else { 103 | data = mergeText(folder, names) 104 | } 105 | 106 | if e = ioutil.WriteFile(outpath, data, 0666); e != nil { 107 | logger.Fatalln("failed to write to output file.\n") 108 | } 109 | } 110 | 111 | func init() { 112 | AddCommandHandler("mh", RunMerge) 113 | AddCommandHandler("mt", RunMerge) 114 | } 115 | -------------------------------------------------------------------------------- /pack.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | ) 7 | 8 | func packFiles(book *Epub, input string) error { 9 | folder, e := OpenVirtualFolder(input) 10 | if e != nil { 11 | logger.Println("failed to open source folder/file.\n") 12 | return e 13 | } 14 | 15 | walk := func(path string) error { 16 | rc, e := folder.OpenFile(path) 17 | if e != nil { 18 | logger.Println("failed to open file: ", path) 19 | return e 20 | } 21 | defer rc.Close() 22 | data, e := ioutil.ReadAll(rc) 23 | if e != nil { 24 | logger.Println("failed reading file: ", path) 25 | return e 26 | } 27 | 28 | book.AddFile(path, data) 29 | return e 30 | } 31 | 32 | return folder.Walk(walk) 33 | } 34 | 35 | func RunPack() { 36 | inpath, outpath := getArg(0, ""), getArg(1, "") 37 | if len(inpath) == 0 || len(outpath) == 0 { 38 | onCommandLineError() 39 | } 40 | 41 | book := NewEpub(false) 42 | 43 | if packFiles(book, inpath) != nil { 44 | os.Exit(1) 45 | } 46 | 47 | if book.Save(outpath, EPUB_VERSION_NONE) != nil { 48 | logger.Fatalln("failed to create output file: ", outpath) 49 | } 50 | } 51 | 52 | func init() { 53 | AddCommandHandler("p", RunPack) 54 | } 55 | -------------------------------------------------------------------------------- /readme.html: -------------------------------------------------------------------------------- 1 | MakeEpub使用说明

此网页将自动跳转,如跳转失败,请点击下列链接之一查看使用说明:github,csdn

-------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "strconv" 11 | "time" 12 | ) 13 | 14 | const ( 15 | homePage = ` 16 | 17 | 18 | 19 | MakeEpub 20 | 21 | 22 |
23 |
24 |
25 |
26 | 27 |
28 | 29 | 30 | ` 31 | errorPage = ` 32 | 33 | 34 | 35 | MakeEpub 36 | 37 | 38 |

Failed to convert / 转换失败

39 |

%s

40 |

%s

41 | 42 | 43 | ` 44 | ) 45 | 46 | func doConvert(l *log.Logger, w http.ResponseWriter, r *http.Request) error { 47 | in, _, e := r.FormFile("input") 48 | if e != nil { 49 | return e 50 | } 51 | data, e := ioutil.ReadAll(in) 52 | in.Close() 53 | if e != nil { 54 | return e 55 | } 56 | 57 | folder, e := NewZipFolder(data) 58 | if e != nil { 59 | return e 60 | } 61 | 62 | maker := NewEpubMaker(l) 63 | if e = maker.Process(folder, r.FormValue("duokan") == "duokan"); e != nil { 64 | return e 65 | } 66 | 67 | ver := EPUB_VERSION_300 68 | if r.FormValue("epub2") == "epub2" { 69 | ver = EPUB_VERSION_200 70 | } 71 | if data, name, e := maker.GetResult(ver); e != nil { 72 | return e 73 | } else { 74 | w.Header().Add("Content-Disposition", "attachment; filename="+name) 75 | http.ServeContent(w, r, name, time.Now(), bytes.NewReader(data)) 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func handleConvert(w http.ResponseWriter, r *http.Request) { 82 | buf := new(bytes.Buffer) 83 | l := log.New(buf, "", 0) 84 | 85 | if e := doConvert(l, w, r); e != nil { 86 | fmt.Fprintf(w, 87 | errorPage, 88 | html.EscapeString(buf.String()), 89 | html.EscapeString(e.Error())) 90 | } 91 | } 92 | 93 | func handler(w http.ResponseWriter, r *http.Request) { 94 | if r.Method == "GET" { 95 | fmt.Fprint(w, homePage) 96 | } else if r.Method == "POST" { 97 | handleConvert(w, r) 98 | } 99 | } 100 | 101 | func RunServer() { 102 | port, e := strconv.Atoi(getArg(0, "80")) 103 | if e != nil || port <= 0 || port > 65535 { 104 | logger.Fatalln("invalid port number.") 105 | } 106 | fmt.Printf("Web Server started, listen at port '%d'\n", port) 107 | fmt.Println("Press 'Ctrl + C' to exit.") 108 | http.HandleFunc("/", handler) 109 | http.ListenAndServe(fmt.Sprintf(":%d", port), nil) 110 | } 111 | 112 | func init() { 113 | AddCommandHandler("s", RunServer) 114 | } 115 | -------------------------------------------------------------------------------- /utility.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strings" 5 | "unicode" 6 | 7 | "golang.org/x/net/html" 8 | "golang.org/x/net/html/atom" 9 | ) 10 | 11 | func removeUtf8Bom(data []byte) []byte { 12 | if len(data) > 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { 13 | data = data[3:] 14 | } 15 | return data 16 | } 17 | 18 | func containsField(str, field string) bool { 19 | for _, f := range strings.Fields(str) { 20 | if f == field { 21 | return true 22 | } 23 | } 24 | return false 25 | } 26 | 27 | func isBlankNode(node *html.Node) bool { 28 | if node.Type == html.CommentNode { 29 | return true 30 | } 31 | if node.Type != html.TextNode { 32 | return false 33 | } 34 | isNonSpace := func(r rune) bool { return !unicode.IsSpace(r) } 35 | return strings.IndexFunc(node.Data, isNonSpace) == -1 36 | } 37 | 38 | func findFirstDirectChild(parent *html.Node, a atom.Atom) *html.Node { 39 | for node := parent.FirstChild; node != nil; node = node.NextSibling { 40 | if node.Type == html.ElementNode && node.DataAtom == a { 41 | return node 42 | } 43 | } 44 | return nil 45 | } 46 | 47 | func findDirectChildren(parent *html.Node, a atom.Atom) (result []*html.Node) { 48 | for node := parent.FirstChild; node != nil; node = node.NextSibling { 49 | if node.Type == html.ElementNode && node.DataAtom == a { 50 | result = append(result, node) 51 | } 52 | } 53 | return 54 | } 55 | 56 | func findFirstChild(parent *html.Node, a atom.Atom) *html.Node { 57 | for node := parent.FirstChild; node != nil; node = node.NextSibling { 58 | if node.Type != html.ElementNode { 59 | continue 60 | } 61 | if node.DataAtom == a { 62 | return node 63 | } 64 | if n := findFirstChild(node, a); n != nil { 65 | return n 66 | } 67 | } 68 | return nil 69 | } 70 | 71 | func findChildren(parent *html.Node, a atom.Atom) (result []*html.Node) { 72 | for node := parent.FirstChild; node != nil; node = node.NextSibling { 73 | if node.Type != html.ElementNode { 74 | continue 75 | } 76 | if node.DataAtom == a { 77 | result = append(result, node) 78 | } 79 | if r := findChildren(node, a); len(r) > 0 { 80 | result = append(result, r...) 81 | } 82 | } 83 | return 84 | } 85 | 86 | func findAttribute(node *html.Node, name string) *html.Attribute { 87 | for i := 0; i < len(node.Attr); i++ { 88 | if node.Attr[i].Key == name { 89 | return &node.Attr[i] 90 | } 91 | } 92 | return nil 93 | } 94 | 95 | func removeAttribute(node *html.Node, name string) { 96 | attr := node.Attr 97 | for i := len(attr) - 1; i >= 0; i-- { 98 | if attr[i].Key == name { 99 | attr = append(attr[:i], attr[i+1:]...) 100 | } 101 | } 102 | node.Attr = attr 103 | } 104 | 105 | func getAttributeValue(node *html.Node, name string, dflt string) string { 106 | if attr := findAttribute(node, name); attr != nil { 107 | return attr.Val 108 | } 109 | return dflt 110 | } 111 | 112 | func hasClass(node *html.Node, class string) bool { 113 | if attr := findAttribute(node, "class"); attr != nil { 114 | return containsField(attr.Val, class) 115 | } 116 | return false 117 | } 118 | 119 | func addClass(node *html.Node, class string) { 120 | if attr := findAttribute(node, "class"); attr != nil { 121 | if !containsField(attr.Val, class) { 122 | attr.Val = class + " " + attr.Val 123 | } 124 | } else { 125 | node.Attr = append(node.Attr, html.Attribute{Key: "class", Val: class}) 126 | } 127 | } 128 | 129 | func removeClass(node *html.Node, class string) { 130 | attr := findAttribute(node, "class") 131 | if attr == nil { 132 | return 133 | } 134 | classes := "" 135 | for _, c := range strings.Fields(attr.Val) { 136 | if c == class { 137 | continue 138 | } 139 | if len(classes) == 0 { 140 | classes = c 141 | } else { 142 | classes = classes + " " + c 143 | } 144 | } 145 | attr.Val = classes 146 | } 147 | 148 | func cloneNode(node *html.Node) *html.Node { 149 | n := &html.Node{ 150 | Type: node.Type, 151 | DataAtom: node.DataAtom, 152 | Data: node.Data, 153 | Attr: make([]html.Attribute, len(node.Attr)), 154 | } 155 | copy(n.Attr, node.Attr) 156 | return n 157 | } 158 | --------------------------------------------------------------------------------