updated with functional changes, testing

This commit is contained in:
Rory Hinnen
2026-07-10 21:02:34 -05:00
parent 48b9dee6d6
commit c14a97331c
11 changed files with 931 additions and 381 deletions
+3
View File
@@ -0,0 +1,3 @@
[pytest]
pythonpath = src
testpaths = tests
+1
View File
@@ -0,0 +1 @@
pytest
+27 -32
View File
@@ -1,50 +1,45 @@
import os
import argparse
from datetime import datetime
from . import state
from .models import Book
from .parser import parseBook
from .models import BuildContext
from .parser import parse_book
from .writer import (
writeStructure, writeMisc, writeStyles, writeNCX,
writeCover, writeContents, writeSections, writeIllustrations, writeEpub,
write_structure, write_misc, write_styles, write_ncx,
write_cover, write_contents, write_sections, write_illustrations, write_epub,
)
def parseArgs():
def parse_args(ctx: BuildContext) -> bool:
parser = argparse.ArgumentParser()
parser.add_argument("fileName", help="File to process")
parser.add_argument("-f", "--force", help="Force overwrite an existing book directory", action="store_true")
parser.add_argument("-v", "--verbose", help="Provide updates while processing", action="store_true")
args = parser.parse_args()
state.book.filename = str(args.fileName)
if not os.path.isfile(state.book.filename):
print("File '{}' is not found. Exiting...".format(state.book.filename))
ctx.book.filename = str(args.fileName)
if not os.path.isfile(ctx.book.filename):
print("File '{}' is not found. Exiting...".format(ctx.book.filename))
exit()
state.verbose = args.verbose
ctx.verbose = args.verbose
return args.force
def main():
state.book = Book()
state.book.publishDate = (
str(datetime.now().year) + "-" +
str(datetime.now().month) + "-" +
str(datetime.now().day)
def main() -> None:
ctx = BuildContext()
ctx.book.publish_date = "{}-{}-{}".format(
datetime.now().year,
datetime.now().month,
datetime.now().day,
)
state.inSection = False
state.inStyle = False
state.verbose = False
overwrite = parseArgs()
parseBook()
if overwrite and state.verbose:
overwrite = parse_args(ctx)
parse_book(ctx)
if overwrite and ctx.verbose:
print("Force flag, overwriting existing directory")
writeStructure(overwrite)
writeMisc()
writeStyles()
writeNCX()
writeCover()
writeContents()
writeSections()
writeIllustrations()
writeEpub()
write_structure(ctx, overwrite)
write_misc(ctx)
write_styles(ctx)
write_ncx(ctx)
write_cover(ctx)
write_contents(ctx)
write_sections(ctx)
write_illustrations(ctx)
write_epub(ctx)
+47 -36
View File
@@ -1,49 +1,60 @@
from dataclasses import dataclass, field
from typing import List
@dataclass
class Name:
def __init__(self):
self.first = ""
self.last = ""
first: str = ""
last: str = ""
@dataclass
class Series:
def __init__(self):
self.number = ""
self.name = ""
number: str = ""
name: str = ""
@dataclass
class Image:
def __init__(self):
self.name = ""
self.type = ""
self.alt = ""
self.caption = ""
name: str = ""
type: str = ""
alt: str = ""
caption: str = ""
@dataclass
class Section:
def __init__(self):
self.type = ""
self.name = ""
self.title = ""
self.body = ""
type: str = ""
name: str = ""
title: str = ""
body: str = ""
@dataclass
class Book:
def __init__(self):
self.filename = ""
self.title = ""
self.titlesort = ""
self.series = Series()
self.author = []
self.artist = Name()
self.editor = Name()
self.publisher = ""
self.copyright = ""
self.subjects = ""
self.style = ""
self.directory = ""
self.isbn = ""
self.uuid = ""
self.source = ""
self.images = []
self.sections = []
self.cover = Image()
self.publishDate = ""
filename: str = ""
title: str = ""
titlesort: str = ""
series: Series = field(default_factory=Series)
author: List[Name] = field(default_factory=list)
artist: Name = field(default_factory=Name)
editor: Name = field(default_factory=Name)
publisher: str = ""
copyright: str = ""
subjects: str = ""
style: str = ""
directory: str = ""
isbn: str = ""
uuid: str = ""
source: str = ""
images: List[Image] = field(default_factory=list)
sections: List[Section] = field(default_factory=list)
cover: Image = field(default_factory=Image)
publish_date: str = ""
@dataclass
class BuildContext:
book: Book = field(default_factory=Book)
verbose: bool = False
in_section: bool = False
+105 -123
View File
@@ -1,174 +1,156 @@
from uuid import uuid4
from . import state
from .models import Name, Image, Section
from .models import Name, Image, Section, BuildContext
def extract(line):
def extract(line: str) -> str | None:
if line.startswith("<!-- "):
return line[5:-4]
return None
def identify(line):
def identify(line: str, ctx: BuildContext) -> None:
key, value = extract(line).split(" ", 1)
match key:
case "Directory:":
state.inSection = False
state.book.directory = value.strip()
ctx.in_section = False
ctx.book.directory = value.strip()
case "Title:":
state.inSection = False
state.book.title = value.strip()
if state.book.title.startswith("A "):
state.book.titlesort = state.book.title[2:] + ", " + state.book.title[:1]
if state.book.title.startswith("An "):
state.book.titlesort = state.book.title[3:] + ", " + state.book.title[:2]
if state.book.title.startswith("The "):
state.book.titlesort = state.book.title[4:] + ", " + state.book.title[:3]
ctx.in_section = False
ctx.book.title = value.strip()
if ctx.book.title.startswith("A "):
ctx.book.titlesort = ctx.book.title[2:] + ", " + ctx.book.title[:1]
if ctx.book.title.startswith("An "):
ctx.book.titlesort = ctx.book.title[3:] + ", " + ctx.book.title[:2]
if ctx.book.title.startswith("The "):
ctx.book.titlesort = ctx.book.title[4:] + ", " + ctx.book.title[:3]
case "Titlesort":
state.inSection = False
state.book.titlesort = value.strip()
ctx.in_section = False
ctx.book.titlesort = value.strip()
case "Author:":
state.inSection = False
last, first = value.strip().split(', ')
name = Name()
name.first = first
name.last = last
state.book.author.append(name)
ctx.in_section = False
last, first = value.strip().split(", ")
ctx.book.author.append(Name(first=first, last=last))
case "Editor:":
state.inSection = False
state.book.editor.last, state.book.editor.first = value.strip().split(', ')
ctx.in_section = False
ctx.book.editor.last, ctx.book.editor.first = value.strip().split(", ")
case "Artist:":
state.inSection = False
state.book.artist.last, state.book.artist.first = value.strip().split(', ')
ctx.in_section = False
ctx.book.artist.last, ctx.book.artist.first = value.strip().split(", ")
case "Publisher:":
state.inSection = False
state.book.publisher = value.strip()
ctx.in_section = False
ctx.book.publisher = value.strip()
case "Copyright:":
state.inSection = False
state.book.copyright = value.strip()
ctx.in_section = False
ctx.book.copyright = value.strip()
case "ISBN:":
state.inSection = False
state.book.isbn = value.strip()
ctx.in_section = False
ctx.book.isbn = value.strip()
case "UUID:":
state.inSection = False
if value.strip() == "":
state.book.uuid = uuid4()
else:
state.book.uuid = value.strip()
ctx.in_section = False
ctx.book.uuid = str(uuid4()) if value.strip() == "" else value.strip()
case "Source:":
state.inSection = False
state.book.source = value.strip()
ctx.in_section = False
ctx.book.source = value.strip()
case "Subjects:":
state.inSection = False
state.book.subjects = value.strip()
ctx.in_section = False
ctx.book.subjects = value.strip()
case "Series:":
state.inSection = False
state.book.series.number, state.book.series.name = value.strip().split(' ', 1)
ctx.in_section = False
ctx.book.series.number, ctx.book.series.name = value.strip().split(" ", 1)
case "Cover:":
state.inSection = False
state.book.cover.name, state.book.cover.type = value.strip().split('.')
ctx.in_section = False
ctx.book.cover.name, ctx.book.cover.type = value.strip().split(".")
case "TitlePage:":
state.inSection = True
section = Section()
section.type = "title"
section.name, section.title = value.strip().split(' ', 1)
state.book.sections.append(section)
ctx.in_section = True
section = Section(type="title")
section.name, section.title = value.strip().split(" ", 1)
ctx.book.sections.append(section)
case "CopyrightPage:":
state.inSection = True
section = Section()
section.type = "copyright-page"
section.name, section.title = value.strip().split(' ', 1)
state.book.sections.append(section)
ctx.in_section = True
section = Section(type="copyright-page")
section.name, section.title = value.strip().split(" ", 1)
ctx.book.sections.append(section)
case "Dedication:":
state.inSection = True
section = Section()
section.type = "dedication"
section.name, section.title = value.strip().split(' ', 1)
state.book.sections.append(section)
ctx.in_section = True
section = Section(type="dedication")
section.name, section.title = value.strip().split(" ", 1)
ctx.book.sections.append(section)
case "Text:":
state.inSection = True
section = Section()
section.type = "text"
section.name, section.title = value.strip().split(' ', 1)
state.book.sections.append(section)
ctx.in_section = True
section = Section(type="text")
section.name, section.title = value.strip().split(" ", 1)
ctx.book.sections.append(section)
case "Foreward:":
state.inSection = True
section = Section()
section.type = "forward"
section.name, section.title = value.strip().split(' ', 1)
state.book.sections.append(section)
ctx.in_section = True
section = Section(type="forward")
section.name, section.title = value.strip().split(" ", 1)
ctx.book.sections.append(section)
case "Notes:":
state.inSection = True
section = Section()
section.type = "notes"
section.name, section.title = value.strip().split(' ', 1)
state.book.sections.append(section)
ctx.in_section = True
section = Section(type="notes")
section.name, section.title = value.strip().split(" ", 1)
ctx.book.sections.append(section)
case "License:":
state.inSection = True
section = Section()
section.type = "license"
section.name, section.title = value.strip().split(' ', 1)
state.book.sections.append(section)
ctx.in_section = True
section = Section(type="license")
section.name, section.title = value.strip().split(" ", 1)
ctx.book.sections.append(section)
case "Acknowledgement:":
state.inSection = True
section = Section()
section.type = "acknowledgement"
section.name, section.title = value.strip().split(' ', 1)
state.book.sections.append(section)
ctx.in_section = True
section = Section(type="acknowledgement")
section.name, section.title = value.strip().split(" ", 1)
ctx.book.sections.append(section)
case "TOC:":
state.inSection = True
section = Section()
section.type = "toc"
section.name, section.title = value.strip().split(' ', 1)
state.book.sections.append(section)
ctx.in_section = True
section = Section(type="toc")
section.name, section.title = value.strip().split(" ", 1)
ctx.book.sections.append(section)
case "Image:":
image = Image()
image.name, imageText = value.strip().split(' ', 1)
extension = image.name.strip().split('.')[1]
image.name, image_text = value.strip().split(" ", 1)
extension = image.name.strip().split(".")[1]
match extension:
case "jpg":
image.type = "jpeg"
case "jpeg":
case "jpg" | "jpeg":
image.type = "jpeg"
case "png":
image.type = "png"
image.alt, image.caption = imageText.strip().split('::')
state.book.images.append(image)
image.alt, image.caption = image_text.strip().split("::")
ctx.book.images.append(image)
if not image.caption:
imageCode = '<img src="../Images/' + image.name + '" alt="' + image.alt + '"/>\n'
imageCode += ' <a id="' + image.name.split('.')[0] + '"><!-- Anchor --></a>\n'
image_code = '<img src="../Images/' + image.name + '" alt="' + image.alt + '"/>\n'
image_code += ' <a id="' + image.name.split(".")[0] + '"><!-- Anchor --></a>\n'
else:
imageCode = '<div class="center group">\n'
imageCode += ' <img src="../Images/' + image.name + '" alt="' + image.alt + '" />\n'
imageCode += ' <a id="' + image.name.split('.')[0] + '"><!-- Anchor --></a>\n'
imageCode += ' <p class="caption">' + image.caption + '</p>\n'
imageCode += '</div>\n<br />\n'
state.book.sections[-1].body += imageCode
image_code = '<div class="center group">\n'
image_code += ' <img src="../Images/' + image.name + '" alt="' + image.alt + '" />\n'
image_code += ' <a id="' + image.name.split(".")[0] + '"><!-- Anchor --></a>\n'
image_code += ' <p class="caption">' + image.caption + "</p>\n"
image_code += "</div>\n<br />\n"
ctx.book.sections[-1].body += image_code
case _:
pass
def findStyle(line):
stylesheetFile = line.split(' ')[2].split('=')[1].replace('"', '').replace('>', '').strip()
def find_style(line: str, ctx: BuildContext) -> None:
stylesheet_file = line.split(" ")[2].split("=")[1].replace('"', "").replace(">", "").strip()
contents = ""
with open(stylesheetFile) as file:
for line in file:
if line != '<style>\n' and line != '</style>' and not line.strip().startswith("/*"):
with open(stylesheet_file) as f:
for line in f:
if line != "<style>\n" and line != "</style>" and not line.strip().startswith("/*"):
contents += line
state.book.style = contents
ctx.book.style = contents
print("found style")
def parseBook():
filename = state.book.filename
if state.verbose:
print("Parsing {}".format(filename))
with open(filename) as source:
def parse_book(ctx: BuildContext) -> None:
if ctx.verbose:
print("Parsing {}".format(ctx.book.filename))
with open(ctx.book.filename) as source:
for line in source:
if line.startswith('<!-- '):
identify(line)
if line.startswith("<!-- "):
identify(line, ctx)
elif line.startswith('<link rel="stylesheet"'):
findStyle(line)
elif state.inSection:
state.book.sections[-1].body += line
if state.verbose:
print("Parse complete for {}".format(state.book.title))
find_style(line, ctx)
elif ctx.in_section:
ctx.book.sections[-1].body += line
if ctx.verbose:
print("Parse complete for {}".format(ctx.book.title))
-4
View File
@@ -1,4 +0,0 @@
book = None
inSection = False
inStyle = False
verbose = False
+185 -186
View File
@@ -1,202 +1,202 @@
import os
import subprocess
from shutil import rmtree, copy
from . import state
from .models import BuildContext
def writeStructure(overwrite):
def write_structure(ctx: BuildContext, overwrite: bool) -> None:
mode = 0o775
if os.path.isdir(state.book.directory):
if os.path.isdir(ctx.book.directory):
if overwrite:
rmtree(state.book.directory)
rmtree(ctx.book.directory)
else:
print("writeStructure: Directory exists.")
print("write_structure: Directory exists.")
exit()
os.mkdir(state.book.directory, mode)
os.mkdir(ctx.book.directory, mode)
for directory in ["META-INF", "OEBPS", "OEBPS/Images", "OEBPS/Styles", "OEBPS/Text"]:
path = os.path.join(state.book.directory, directory)
os.mkdir(path, mode)
if state.verbose:
os.mkdir(os.path.join(ctx.book.directory, directory), mode)
if ctx.verbose:
print("Finished writing ePub structure")
def writeMisc():
access = 'w'
directory = state.book.directory
def write_misc(ctx: BuildContext) -> None:
directory = ctx.book.directory
file = os.path.join(state.book.directory, "mimetype")
mimetype = open(file, access)
mimetype.write("application/epub+zip")
mimetype.close()
with open(os.path.join(directory, "mimetype"), "w") as f:
f.write("application/epub+zip")
file = os.path.join(directory, "META-INF", "com.apple.ibooks.display-options.xml")
meta1 = open(file, access)
meta1.write('<?xml version="1.0" encoding="UTF-8"?>\n<display_options>\n <platform name="*">\n <option name="specified-fonts">false</option>\n </platform>\n</display_options>')
meta1.close()
with open(os.path.join(directory, "META-INF", "com.apple.ibooks.display-options.xml"), "w") as f:
f.write(
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<display_options>\n"
' <platform name="*">\n'
' <option name="specified-fonts">false</option>\n'
" </platform>\n"
"</display_options>"
)
file = os.path.join(directory, "META-INF", "container.xml")
meta2 = open(file, access)
meta2.write('<?xml version="1.0" encoding="UTF-8"?>\n<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n <rootfiles>\n <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>\n </rootfiles>\n</container>')
meta2.close()
if state.verbose:
with open(os.path.join(directory, "META-INF", "container.xml"), "w") as f:
f.write(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">\n'
" <rootfiles>\n"
' <rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>\n'
" </rootfiles>\n"
"</container>"
)
if ctx.verbose:
print("Finished writing metadata")
def writeStyles():
access = 'w'
file = os.path.join(state.book.directory, "OEBPS", "Styles", "style.css")
style = open(file, access)
style.write(state.book.style)
style.close()
def write_styles(ctx: BuildContext) -> None:
with open(os.path.join(ctx.book.directory, "OEBPS", "Styles", "style.css"), "w") as f:
f.write(ctx.book.style)
source_dir = os.path.split(state.book.filename)[0]
styles_dir = os.path.join(state.book.directory, "OEBPS", "Styles")
source_dir = os.path.split(ctx.book.filename)[0]
styles_dir = os.path.join(ctx.book.directory, "OEBPS", "Styles")
for font in ["Inconsolata-Regular.otf", "invisible1.ttf", "MgOpenModataRegular.ttf"]:
copy(os.path.join(source_dir, "Images", font), os.path.join(styles_dir, font))
if state.verbose:
if ctx.verbose:
print("Finished writing style")
def writeNCX():
directory = state.book.directory
access = 'w'
file = os.path.join(directory, 'OEBPS', 'toc.ncx')
def write_ncx(ctx: BuildContext) -> None:
navpoints = []
contentsstart = (
'<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n'
'<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">\n'
'<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">\n'
' <head>\n'
' <meta content="urn:isbn:' + str(state.book.uuid) + '" name="dtb:uid"/>\n'
' <meta content="1" name="dtb:depth"/>\n'
' <meta content="-1" name="dtb:totalPageCount"/>\n'
' <meta content="-1" name="dtb:maxPageNumber"/>\n'
' </head>\n'
' <docTitle>\n'
' <text>' + state.book.title.strip() + '</text>\n'
' </docTitle>\n'
' <navMap>\n'
' <navPoint id="cover" playOrder="1">\n'
' <navLabel>\n'
' <text>Cover</text>\n'
' </navLabel>\n'
' <content src="Text/cover.xhtml"/>\n'
' </navPoint>\n'
)
contentsend = " </navMap>\n</ncx>"
number = 2
for section in state.book.sections:
for section in ctx.book.sections:
if section.type != "toc":
navpoints.append(
'\t<navPoint id ="' + section.name.strip() + '" playOrder="' + str(number) + '">\n'
'\t\t<navLabel>\n\t\t\t<text>' + section.title.strip() + '</text>\n\t\t</navLabel>\n'
"\t\t<navLabel>\n\t\t\t<text>" + section.title.strip() + "</text>\n\t\t</navLabel>\n"
'\t\t<content src="Text/' + section.name.strip() + '.xhtml"/>\n\t</navPoint>\n'
)
number += 1
contents = contentsstart + "".join(navpoints) + contentsend
contents = (
'<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n'
'<!DOCTYPE ncx PUBLIC "-//NISO//DTD ncx 2005-1//EN" "http://www.daisy.org/z3986/2005/ncx-2005-1.dtd">\n'
'<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">\n'
" <head>\n"
' <meta content="urn:isbn:' + str(ctx.book.uuid) + '" name="dtb:uid"/>\n'
' <meta content="1" name="dtb:depth"/>\n'
' <meta content="-1" name="dtb:totalPageCount"/>\n'
' <meta content="-1" name="dtb:maxPageNumber"/>\n'
" </head>\n"
" <docTitle>\n"
" <text>" + ctx.book.title.strip() + "</text>\n"
" </docTitle>\n"
" <navMap>\n"
' <navPoint id="cover" playOrder="1">\n'
" <navLabel>\n"
" <text>Cover</text>\n"
" </navLabel>\n"
' <content src="Text/cover.xhtml"/>\n'
" </navPoint>\n"
+ "".join(navpoints)
+ " </navMap>\n</ncx>"
)
ncx = open(file, access)
ncx.write(contents)
ncx.close()
if state.verbose:
with open(os.path.join(ctx.book.directory, "OEBPS", "toc.ncx"), "w") as f:
f.write(contents)
if ctx.verbose:
print("Finished writing ncx")
def writeTOC(section):
access = 'w'
file = os.path.join(state.book.directory, 'OEBPS', 'toc.xhtml')
def write_toc(ctx: BuildContext, section) -> None:
contents = (
'<?xml version="1.0" encoding="utf-8"?>\n'
'<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">\n'
' <head>\n'
' <title>Table of Contents</title>\n'
" <head>\n"
" <title>Table of Contents</title>\n"
' <link href="Styles/style.css" rel="stylesheet" type="text/css" />\n'
'</head>\n'
'<body>\n'
"</head>\n"
"<body>\n"
+ section.body
+ "\n</body>\n</html>"
)
contents += section.body
contents += '\n</body>\n</html>'
toc = open(file, access)
toc.write(contents)
toc.close()
if state.verbose:
with open(os.path.join(ctx.book.directory, "OEBPS", "toc.xhtml"), "w") as f:
f.write(contents)
if ctx.verbose:
print("Finished writing toc")
def writeCover():
access = 'w'
file = os.path.join(state.book.directory, 'OEBPS', 'Text', 'cover.xhtml')
def write_cover(ctx: BuildContext) -> None:
contents = (
'<?xml version="1.0" encoding="utf-8" standalone="no"?>\n'
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"\n'
'"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">\n\n'
'<html xmlns="http://www.w3.org/1999/xhtml">\n'
' <head>\n'
' <title>Cover</title>\n'
" <head>\n"
" <title>Cover</title>\n"
' <style type="text/css">\n'
' /* <![CDATA[ */\n'
' img { max-width: 100%; }\n'
' body { oeb-column-number: 1; }\n'
' #cover-image { text-align: center; }\n'
' /* ]]> */\n'
' </style>\n'
' </head>\n'
' <body>\n'
" /* <![CDATA[ */\n"
" img { max-width: 100%; }\n"
" body { oeb-column-number: 1; }\n"
" #cover-image { text-align: center; }\n"
" /* ]]> */\n"
" </style>\n"
" </head>\n"
" <body>\n"
' <div id="cover-image">\n'
' <img alt="' + state.book.title.strip() + '" src="../Images/cover.jpeg" />\n'
' </div>\n'
' </body>\n'
'</html>\n'
' <img alt="' + ctx.book.title.strip() + '" src="../Images/cover.jpeg" />\n'
" </div>\n"
" </body>\n"
"</html>\n"
)
cover = open(file, access)
cover.write(contents)
cover.close()
new = os.path.join(state.book.directory, "OEBPS", "Images", "cover.jpeg")
old = os.path.join(os.path.split(state.book.filename)[0], "Images", "cover.jpeg")
copy(old, new)
if state.verbose:
with open(os.path.join(ctx.book.directory, "OEBPS", "Text", "cover.xhtml"), "w") as f:
f.write(contents)
source_dir = os.path.split(ctx.book.filename)[0]
copy(
os.path.join(source_dir, "Images", "cover.jpeg"),
os.path.join(ctx.book.directory, "OEBPS", "Images", "cover.jpeg"),
)
if ctx.verbose:
print("Finished writing cover")
def writeContents():
def write_contents(ctx: BuildContext) -> None:
contents = '<?xml version="1.0" encoding="UTF-8"?>\n'
contents += '<package xmlns="http://www.idpf.org/2007/opf" version="3.0" xml:lang="en" unique-identifier="bookid" prefix="rendition: http://www.idpf.org/vocab/rendition/# cc: http://creativecommons.org/ns#">\n'
contents += ' <metadata xmlns:dc="http://purl.org/dc/elements/1.1/">\n'
contents += ' <dc:identifier id="bookid">urn:uuid' + state.book.isbn + '</dc:identifier>\n'
contents += ' <dc:title id="pub-title">' + state.book.title.strip() + '</dc:title>\n'
contents += ' <dc:identifier id="bookid">urn:uuid' + ctx.book.isbn + "</dc:identifier>\n"
contents += ' <dc:title id="pub-title">' + ctx.book.title.strip() + "</dc:title>\n"
contents += ' <meta refines="#pub-title" property="title-type">main</meta>\n'
if state.book.titlesort != "":
contents += ' <meta refines="#pub-title" property="file-as">' + state.book.titlesort + '</meta>\n'
if state.book.series.name != "":
if ctx.book.titlesort:
contents += ' <meta refines="#pub-title" property="file-as">' + ctx.book.titlesort + "</meta>\n"
if ctx.book.series.name:
print("found a book series")
contents += ' <dc:title id="pub-title2">' + state.book.series.name.strip() + '</dc:title>\n'
contents += ' <dc:title id="pub-title2">' + ctx.book.series.name.strip() + "</dc:title>\n"
contents += ' <meta refines="#pub-title2" property="title-type">collection</meta>\n'
contents += ' <dc:title id="pub-title3">' + state.book.series.number + '</dc:title>\n'
contents += ' <dc:title id="pub-title3">' + ctx.book.series.number + "</dc:title>\n"
contents += ' <meta refines="#pub-title3" property="title-type">display-seq</meta>\n'
contents += ' <meta name="calibre:series" content="' + state.book.series.name.strip() + '" />\n'
contents += ' <meta name="calibre:series_index" content="' + state.book.series.number.strip() + '" />\n'
contents += ' <meta name="calibre:series" content="' + ctx.book.series.name.strip() + '" />\n'
contents += ' <meta name="calibre:series_index" content="' + ctx.book.series.number.strip() + '" />\n'
contents += ' <dc:language id="pub-language">en</dc:language>\n'
contents += ' <dc:date>' + state.book.publishDate + '</dc:date>\n'
for name in state.book.author:
contents += ' <dc:creator id="author">' + name.first.strip() + ' ' + name.last.strip() + '</dc:creator>\n'
contents += " <dc:date>" + ctx.book.publish_date + "</dc:date>\n"
for name in ctx.book.author:
contents += ' <dc:creator id="author">' + name.first.strip() + " " + name.last.strip() + "</dc:creator>\n"
contents += ' <meta refines="#author" scheme="marc:relators" property="role">aut</meta>\n'
for name in state.book.author:
contents += ' <meta refines="#author" property="file-as">' + name.last.strip() + ', ' + name.first.strip() + '</meta>\n'
contents += ' <dc:publisher>' + state.book.publisher.strip() + '</dc:publisher>\n'
contents += ' <dc:rights>' + state.book.copyright.strip() + '</dc:rights>\n'
contents += ' <dc:subject>' + state.book.subjects.strip() + '</dc:subject>\n'
contents += ' <dc:source>' + state.book.source.strip() + '</dc:source>\n'
for name in ctx.book.author:
contents += ' <meta refines="#author" property="file-as">' + name.last.strip() + ", " + name.first.strip() + "</meta>\n"
contents += " <dc:publisher>" + ctx.book.publisher.strip() + "</dc:publisher>\n"
contents += " <dc:rights>" + ctx.book.copyright.strip() + "</dc:rights>\n"
contents += " <dc:subject>" + ctx.book.subjects.strip() + "</dc:subject>\n"
contents += " <dc:source>" + ctx.book.source.strip() + "</dc:source>\n"
contents += ' <meta name="cover" content="cover.jpeg" />\n'
contents += ' </metadata>\n'
contents += ' <manifest>\n'
contents += " </metadata>\n"
contents += " <manifest>\n"
contents += ' <item href="Images/cover.jpeg" id="cover.jpeg" media-type="image/jpeg" properties="cover-image" />\n'
contents += ' <item href="Text/cover.xhtml" id="cover" media-type="application/xhtml+xml" />\n'
contents += ' <item href="toc.xhtml" id="toc" media-type="application/xhtml+xml" properties="nav"/>\n'
@@ -206,107 +206,106 @@ def writeContents():
contents += ' <item href="Styles/invisible1.ttf" id="invisible1" media-type="application/vnd.ms-opentype" />\n'
contents += ' <item href="Styles/Inconsolata-Regular.otf" id="inconsolate" media-type="application/vnd.ms-opentype" />\n'
if len(state.book.images) > 0:
if ctx.book.images:
contents += ' <item href="Text/illustrations.xhtml" id="illustrations" media-type="application/xhtml+xml" />\n'
for section in state.book.sections:
if section.type != 'toc':
for section in ctx.book.sections:
if section.type != "toc":
contents += ' <item href="Text/' + section.name + '.xhtml" id="' + section.name + '" media-type="application/xhtml+xml" />\n'
for image in state.book.images:
contents += ' <item href="Images/' + image.name + '" id="' + image.name.split('.')[0] + '" media-type="image/' + image.type + '" />\n'
new = os.path.join(state.book.directory, "OEBPS", "Images", image.name)
old = os.path.join(os.path.split(state.book.filename)[0], "Images", image.name)
copy(old, new)
source_dir = os.path.split(ctx.book.filename)[0]
for image in ctx.book.images:
contents += ' <item href="Images/' + image.name + '" id="' + image.name.split(".")[0] + '" media-type="image/' + image.type + '" />\n'
copy(
os.path.join(source_dir, "Images", image.name),
os.path.join(ctx.book.directory, "OEBPS", "Images", image.name),
)
contents += ' </manifest>\n'
contents += " </manifest>\n"
contents += ' <spine toc="ncx">\n'
contents += ' <itemref idref="cover" />\n'
if len(state.book.images) > 0:
if ctx.book.images:
contents += ' <itemref idref="illustrations" />\n'
for section in state.book.sections:
for section in ctx.book.sections:
contents += ' <itemref idref="' + section.name + '" />\n'
contents += ' </spine>\n'
contents += ' <guide>\n'
contents += " </spine>\n"
contents += " <guide>\n"
contents += ' <reference href="Text/cover.xhtml" title="Cover" type="cover" />\n'
contents += ' <reference href="toc.xhtml" title="Table of Contents" type="toc" />\n'
if len(state.book.images) > 0:
if ctx.book.images:
contents += ' <reference href="Text/illustrations.xhtml" title="Illustrations" type="text" />\n'
for section in state.book.sections:
if section.type != 'toc':
for section in ctx.book.sections:
if section.type != "toc":
contents += ' <reference href="Text/' + section.name.strip() + '.xhtml" title="' + section.title.strip() + '" type="' + section.type.strip() + '" />\n'
contents += ' </guide>\n'
contents += '</package>\n'
contents += " </guide>\n"
contents += "</package>\n"
access = 'w'
file = os.path.join(state.book.directory, 'OEBPS', 'content.opf')
content = open(file, access)
content.write(contents)
content.close()
if state.verbose:
with open(os.path.join(ctx.book.directory, "OEBPS", "content.opf"), "w") as f:
f.write(contents)
if ctx.verbose:
print("Finished writing content")
def writeSections():
access = 'w'
for section in state.book.sections:
def write_sections(ctx: BuildContext) -> None:
for section in ctx.book.sections:
if section.type.strip() == "toc":
writeTOC(section)
write_toc(ctx, section)
continue
contents = (
'<?xml version="1.0" encoding="utf-8" standalone="no"?>\n'
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"\n'
'"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">\n\n'
'<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">\n'
' <head>\n'
' <title>' + section.title.strip() + '</title>\n'
" <head>\n"
" <title>" + section.title.strip() + "</title>\n"
' <link href="../Styles/style.css" rel="stylesheet" type="text/css" />\n'
' </head>\n'
' <body>\n'
" </head>\n"
" <body>\n"
' <div id="' + section.name.strip() + '" xml:lang="en-US">\n'
+ section.body
+ " </div>\n </body>\n</html>\n"
)
contents += section.body
contents += " </div>\n </body>\n</html>\n"
file = os.path.join(state.book.directory, 'OEBPS', 'Text', section.name.strip() + ".xhtml")
content = open(file, access)
content.write(contents)
content.close()
if state.verbose:
with open(os.path.join(ctx.book.directory, "OEBPS", "Text", section.name.strip() + ".xhtml"), "w") as f:
f.write(contents)
if ctx.verbose:
print(f"Finished writing section {section.name}.xhtml")
def writeIllustrations():
if len(state.book.images) == 0:
def write_illustrations(ctx: BuildContext) -> None:
if not ctx.book.images:
return
access = 'w'
contents = (
'<?xml version="1.0" encoding="utf-8" standalone="no"?>\n'
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"\n'
'"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">\n\n'
'<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">\n'
'<head>\n'
'<title>Illustrations</title>\n'
"<head>\n"
"<title>Illustrations</title>\n"
'<link href="../Styles/style.css" rel="stylesheet" type="text/css" />\n'
'</head>\n'
'<body>\n'
"</head>\n"
"<body>\n"
' <h1 class="title">Illustrations</h1>\n'
' <table>\n'
" <table>\n"
)
for image in state.book.images:
contents += ' <tr><td><a href="../Images/' + image.name + '">' + image.caption + '</a></td></tr>\n'
contents += ' </table>\n</body>\n</html>'
for image in ctx.book.images:
contents += ' <tr><td><a href="../Images/' + image.name + '">' + image.caption + "</a></td></tr>\n"
contents += " </table>\n</body>\n</html>"
file = os.path.join(state.book.directory, 'OEBPS', 'Text', "illustrations.xhtml")
content = open(file, access)
content.write(contents)
content.close()
if state.verbose:
with open(os.path.join(ctx.book.directory, "OEBPS", "Text", "illustrations.xhtml"), "w") as f:
f.write(contents)
if ctx.verbose:
print("Finished writing section illustrations.xhtml")
def writeEpub():
cmd = f'cd {state.book.directory}; zip -X0 "{state.book.directory}.epub" mimetype; zip -X9Dr "{state.book.directory}.epub" META-INF OEBPS'
def write_epub(ctx: BuildContext) -> None:
cmd = (
f'cd {ctx.book.directory}; '
f'zip -X0 "{ctx.book.directory}.epub" mimetype; '
f'zip -X9Dr "{ctx.book.directory}.epub" META-INF OEBPS'
)
subprocess.run(cmd, shell=True)
if state.verbose:
print(f"Finished writing {state.book.directory}.epub")
if ctx.verbose:
print(f"Finished writing {ctx.book.directory}.epub")
View File
+71
View File
@@ -0,0 +1,71 @@
from epubmaker.models import Name, Series, Image, Section, Book, BuildContext
def test_name_defaults():
n = Name()
assert n.first == ""
assert n.last == ""
def test_series_defaults():
s = Series()
assert s.number == ""
assert s.name == ""
def test_image_defaults():
img = Image()
assert img.name == ""
assert img.type == ""
assert img.alt == ""
assert img.caption == ""
def test_section_defaults():
s = Section()
assert s.type == ""
assert s.name == ""
assert s.title == ""
assert s.body == ""
def test_book_defaults():
b = Book()
assert b.filename == ""
assert b.title == ""
assert b.titlesort == ""
assert b.publisher == ""
assert b.isbn == ""
assert b.uuid == ""
assert b.author == []
assert b.images == []
assert b.sections == []
def test_book_nested_objects():
b = Book()
assert isinstance(b.series, Series)
assert isinstance(b.artist, Name)
assert isinstance(b.editor, Name)
assert isinstance(b.cover, Image)
def test_book_author_lists_are_independent():
b1 = Book()
b2 = Book()
b1.author.append(Name(first="Jane", last="Doe"))
assert len(b2.author) == 0
def test_build_context_defaults():
ctx = BuildContext()
assert isinstance(ctx.book, Book)
assert ctx.verbose is False
assert ctx.in_section is False
def test_build_context_books_are_independent():
ctx1 = BuildContext()
ctx2 = BuildContext()
ctx1.book.title = "Book One"
assert ctx2.book.title == ""
+249
View File
@@ -0,0 +1,249 @@
import pytest
from epubmaker.models import BuildContext
from epubmaker.parser import extract, identify, parse_book
@pytest.fixture
def ctx():
return BuildContext()
def test_extract_returns_inner_content():
assert extract("<!-- Title: Foo Bar -->") == "Title: Foo Bar"
def test_extract_with_newline():
assert extract("<!-- Title: Foo Bar -->\n") == "Title: Foo Bar"
def test_extract_returns_none_for_non_comment():
assert extract("<p>Not a comment</p>") is None
def test_identify_directory(ctx):
identify("<!-- Directory: mybook -->", ctx)
assert ctx.book.directory == "mybook"
assert ctx.in_section is False
def test_identify_title_plain(ctx):
identify("<!-- Title: My Book -->", ctx)
assert ctx.book.title == "My Book"
assert ctx.book.titlesort == ""
def test_identify_title_the(ctx):
identify("<!-- Title: The Great Book -->", ctx)
assert ctx.book.title == "The Great Book"
assert ctx.book.titlesort == "Great Book, The"
def test_identify_title_a(ctx):
identify("<!-- Title: A Good Story -->", ctx)
assert ctx.book.title == "A Good Story"
assert ctx.book.titlesort == "Good Story, A"
def test_identify_title_an(ctx):
identify("<!-- Title: An Old Tale -->", ctx)
assert ctx.book.title == "An Old Tale"
assert ctx.book.titlesort == "Old Tale, An"
def test_identify_author(ctx):
identify("<!-- Author: Smith, John -->", ctx)
assert len(ctx.book.author) == 1
assert ctx.book.author[0].last == "Smith"
assert ctx.book.author[0].first == "John"
def test_identify_multiple_authors(ctx):
identify("<!-- Author: Smith, John -->", ctx)
identify("<!-- Author: Jones, Jane -->", ctx)
assert len(ctx.book.author) == 2
assert ctx.book.author[1].last == "Jones"
def test_identify_editor(ctx):
identify("<!-- Editor: Brown, Alice -->", ctx)
assert ctx.book.editor.last == "Brown"
assert ctx.book.editor.first == "Alice"
def test_identify_publisher(ctx):
identify("<!-- Publisher: Acme Press -->", ctx)
assert ctx.book.publisher == "Acme Press"
def test_identify_copyright(ctx):
identify("<!-- Copyright: 2024 Author -->", ctx)
assert ctx.book.copyright == "2024 Author"
def test_identify_isbn(ctx):
identify("<!-- ISBN: 978-0-000-00000-0 -->", ctx)
assert ctx.book.isbn == "978-0-000-00000-0"
def test_identify_uuid_explicit(ctx):
identify("<!-- UUID: abc-123 -->", ctx)
assert ctx.book.uuid == "abc-123"
def test_identify_uuid_empty_generates_uuid(ctx):
identify("<!-- UUID: -->", ctx)
assert ctx.book.uuid != ""
assert ctx.book.uuid is not None
def test_identify_source(ctx):
identify("<!-- Source: Some Original Work -->", ctx)
assert ctx.book.source == "Some Original Work"
def test_identify_subjects(ctx):
identify("<!-- Subjects: Fiction, Mystery -->", ctx)
assert ctx.book.subjects == "Fiction, Mystery"
def test_identify_series(ctx):
identify("<!-- Series: 1 My Series Name -->", ctx)
assert ctx.book.series.number == "1"
assert ctx.book.series.name == "My Series Name"
def test_identify_cover(ctx):
identify("<!-- Cover: cover.jpeg -->", ctx)
assert ctx.book.cover.name == "cover"
assert ctx.book.cover.type == "jpeg"
def test_identify_text_section(ctx):
identify("<!-- Text: chapter1 Chapter One -->", ctx)
assert ctx.in_section is True
assert len(ctx.book.sections) == 1
assert ctx.book.sections[0].type == "text"
assert ctx.book.sections[0].name == "chapter1"
assert ctx.book.sections[0].title == "Chapter One"
def test_identify_toc_section(ctx):
identify("<!-- TOC: toc Table of Contents -->", ctx)
assert ctx.in_section is True
assert ctx.book.sections[0].type == "toc"
def test_identify_title_page(ctx):
identify("<!-- TitlePage: tp Title Page -->", ctx)
assert ctx.book.sections[0].type == "title"
def test_identify_copyright_page(ctx):
identify("<!-- CopyrightPage: cp Copyright -->", ctx)
assert ctx.book.sections[0].type == "copyright-page"
def test_identify_dedication(ctx):
identify("<!-- Dedication: ded For someone -->", ctx)
assert ctx.book.sections[0].type == "dedication"
def test_identify_foreward(ctx):
identify("<!-- Foreward: fw Foreword -->", ctx)
assert ctx.book.sections[0].type == "forward"
def test_identify_notes(ctx):
identify("<!-- Notes: notes Notes -->", ctx)
assert ctx.book.sections[0].type == "notes"
def test_identify_acknowledgement(ctx):
identify("<!-- Acknowledgement: ack Acknowledgements -->", ctx)
assert ctx.book.sections[0].type == "acknowledgement"
def test_identify_metadata_clears_in_section(ctx):
identify("<!-- Text: ch1 Chapter One -->", ctx)
assert ctx.in_section is True
identify("<!-- Title: My Book -->", ctx)
assert ctx.in_section is False
def test_identify_image_jpeg(ctx):
identify("<!-- Text: ch1 Chapter One -->", ctx)
identify("<!-- Image: photo.jpg Alt text::Caption text -->", ctx)
assert len(ctx.book.images) == 1
assert ctx.book.images[0].name == "photo.jpg"
assert ctx.book.images[0].type == "jpeg"
assert ctx.book.images[0].alt == "Alt text"
assert ctx.book.images[0].caption == "Caption text"
def test_identify_image_png(ctx):
identify("<!-- Text: ch1 Chapter One -->", ctx)
identify("<!-- Image: photo.png Alt text::Caption -->", ctx)
assert ctx.book.images[0].type == "png"
def test_identify_image_appends_to_section_body(ctx):
identify("<!-- Text: ch1 Chapter One -->", ctx)
identify("<!-- Image: photo.jpg Alt::Caption -->", ctx)
assert "photo.jpg" in ctx.book.sections[-1].body
def test_identify_image_with_caption_uses_div(ctx):
identify("<!-- Text: ch1 Chapter One -->", ctx)
identify("<!-- Image: photo.jpg Alt::My Caption -->", ctx)
assert '<div class="center group">' in ctx.book.sections[-1].body
assert "My Caption" in ctx.book.sections[-1].body
def test_identify_image_without_caption_uses_img(ctx):
identify("<!-- Text: ch1 Chapter One -->", ctx)
identify("<!-- Image: photo.jpg Alt:: -->", ctx)
body = ctx.book.sections[-1].body
assert "<img" in body
assert '<div class="center group">' not in body
def test_identify_unknown_key_ignored(ctx):
identify("<!-- UnknownKey: some value -->", ctx)
assert ctx.book.title == ""
assert ctx.book.directory == ""
def test_parse_book_populates_book(tmp_path):
source = tmp_path / "book.html"
source.write_text(
"<!-- Directory: mybook -->\n"
"<!-- Title: Test Book -->\n"
"<!-- Author: Doe, Jane -->\n"
"<!-- Text: ch1 Chapter One -->\n"
"<p>Body content</p>\n"
)
ctx = BuildContext()
ctx.book.filename = str(source)
parse_book(ctx)
assert ctx.book.title == "Test Book"
assert ctx.book.directory == "mybook"
assert len(ctx.book.author) == 1
assert ctx.book.author[0].last == "Doe"
assert len(ctx.book.sections) == 1
assert "<p>Body content</p>\n" in ctx.book.sections[0].body
def test_parse_book_body_not_captured_outside_section(tmp_path):
source = tmp_path / "book.html"
source.write_text(
"<!-- Title: Test Book -->\n"
"<p>This is outside any section</p>\n"
"<!-- Text: ch1 Chapter One -->\n"
"<p>Inside section</p>\n"
)
ctx = BuildContext()
ctx.book.filename = str(source)
parse_book(ctx)
assert len(ctx.book.sections) == 1
assert "outside any section" not in ctx.book.sections[0].body
assert "Inside section" in ctx.book.sections[0].body
+243
View File
@@ -0,0 +1,243 @@
import os
import pytest
from unittest.mock import patch
from epubmaker.models import BuildContext, Book, Section, Image, Name
@pytest.fixture
def ctx(tmp_path):
context = BuildContext()
context.book.title = "Test Book"
context.book.uuid = "test-uuid-1234"
context.book.isbn = "978-0-000-00000-0"
context.book.publisher = "Test Publisher"
context.book.copyright = "2024 Test"
context.book.subjects = "Fiction"
context.book.source = "Original Source"
context.book.publish_date = "2024-1-1"
context.book.directory = str(tmp_path / "testbook")
context.book.filename = str(tmp_path / "source" / "book.html")
context.book.author.append(Name(first="Jane", last="Doe"))
return context
from epubmaker.writer import (
write_structure, write_misc, write_ncx, write_toc,
write_cover, write_contents, write_sections, write_illustrations,
)
# --- write_structure ---
def test_write_structure_creates_epub_directories(ctx):
write_structure(ctx, overwrite=False)
for subdir in ["META-INF", "OEBPS", "OEBPS/Images", "OEBPS/Styles", "OEBPS/Text"]:
assert os.path.isdir(os.path.join(ctx.book.directory, subdir))
def test_write_structure_exits_if_exists(ctx):
os.makedirs(ctx.book.directory)
with pytest.raises(SystemExit):
write_structure(ctx, overwrite=False)
def test_write_structure_overwrite_replaces_existing(ctx):
os.makedirs(ctx.book.directory)
sentinel = os.path.join(ctx.book.directory, "old_file.txt")
open(sentinel, "w").close()
write_structure(ctx, overwrite=True)
assert os.path.isdir(ctx.book.directory)
assert not os.path.exists(sentinel)
# --- write_misc ---
def test_write_misc_mimetype_content(ctx):
write_structure(ctx, overwrite=False)
write_misc(ctx)
with open(os.path.join(ctx.book.directory, "mimetype")) as f:
assert f.read() == "application/epub+zip"
def test_write_misc_container_xml_references_opf(ctx):
write_structure(ctx, overwrite=False)
write_misc(ctx)
with open(os.path.join(ctx.book.directory, "META-INF", "container.xml")) as f:
assert 'full-path="OEBPS/content.opf"' in f.read()
def test_write_misc_ibooks_display_options_created(ctx):
write_structure(ctx, overwrite=False)
write_misc(ctx)
path = os.path.join(ctx.book.directory, "META-INF", "com.apple.ibooks.display-options.xml")
assert os.path.isfile(path)
# --- write_ncx ---
def test_write_ncx_contains_title_and_uuid(ctx):
write_structure(ctx, overwrite=False)
write_ncx(ctx)
with open(os.path.join(ctx.book.directory, "OEBPS", "toc.ncx")) as f:
content = f.read()
assert ctx.book.title in content
assert ctx.book.uuid in content
def test_write_ncx_includes_non_toc_sections(ctx):
ctx.book.sections.append(Section(type="text", name="chapter1", title="Chapter One"))
write_structure(ctx, overwrite=False)
write_ncx(ctx)
with open(os.path.join(ctx.book.directory, "OEBPS", "toc.ncx")) as f:
content = f.read()
assert "chapter1" in content
assert "Chapter One" in content
def test_write_ncx_excludes_toc_type_sections(ctx):
ctx.book.sections.append(Section(type="toc", name="toc", title="Table of Contents"))
write_structure(ctx, overwrite=False)
write_ncx(ctx)
with open(os.path.join(ctx.book.directory, "OEBPS", "toc.ncx")) as f:
content = f.read()
assert 'id ="toc"' not in content
def test_write_ncx_play_order_increments(ctx):
for i in range(3):
ctx.book.sections.append(Section(type="text", name=f"ch{i}", title=f"Chapter {i}"))
write_structure(ctx, overwrite=False)
write_ncx(ctx)
with open(os.path.join(ctx.book.directory, "OEBPS", "toc.ncx")) as f:
content = f.read()
assert 'playOrder="2"' in content
assert 'playOrder="3"' in content
assert 'playOrder="4"' in content
# --- write_toc ---
def test_write_toc_writes_section_body(ctx):
section = Section(body="<p>Contents here</p>\n")
write_structure(ctx, overwrite=False)
write_toc(ctx, section)
with open(os.path.join(ctx.book.directory, "OEBPS", "toc.xhtml")) as f:
content = f.read()
assert "<p>Contents here</p>" in content
assert "Table of Contents" in content
# --- write_cover ---
def test_write_cover_creates_xhtml_with_title(ctx, tmp_path):
source_images = tmp_path / "source" / "Images"
source_images.mkdir(parents=True)
(source_images / "cover.jpeg").write_bytes(b"fake")
write_structure(ctx, overwrite=False)
write_cover(ctx)
with open(os.path.join(ctx.book.directory, "OEBPS", "Text", "cover.xhtml")) as f:
content = f.read()
assert ctx.book.title in content
assert 'src="../Images/cover.jpeg"' in content
def test_write_cover_copies_image_to_output(ctx, tmp_path):
source_images = tmp_path / "source" / "Images"
source_images.mkdir(parents=True)
(source_images / "cover.jpeg").write_bytes(b"image-data")
write_structure(ctx, overwrite=False)
write_cover(ctx)
assert os.path.isfile(os.path.join(ctx.book.directory, "OEBPS", "Images", "cover.jpeg"))
# --- write_sections ---
def test_write_sections_creates_xhtml_per_section(ctx):
ctx.book.sections.append(Section(type="text", name="chapter1", title="Chapter One", body="<p>Content</p>\n"))
write_structure(ctx, overwrite=False)
write_sections(ctx)
path = os.path.join(ctx.book.directory, "OEBPS", "Text", "chapter1.xhtml")
assert os.path.isfile(path)
with open(path) as f:
content = f.read()
assert "Chapter One" in content
assert "<p>Content</p>" in content
def test_write_sections_routes_toc_to_toc_xhtml(ctx):
ctx.book.sections.append(Section(type="toc", name="toc", title="Table of Contents", body="<p>toc</p>\n"))
write_structure(ctx, overwrite=False)
write_sections(ctx)
assert os.path.isfile(os.path.join(ctx.book.directory, "OEBPS", "toc.xhtml"))
assert not os.path.isfile(os.path.join(ctx.book.directory, "OEBPS", "Text", "toc.xhtml"))
def test_write_sections_multiple_sections(ctx):
for i in range(3):
ctx.book.sections.append(Section(type="text", name=f"ch{i}", title=f"Chapter {i}", body=f"<p>ch{i}</p>\n"))
write_structure(ctx, overwrite=False)
write_sections(ctx)
for i in range(3):
assert os.path.isfile(os.path.join(ctx.book.directory, "OEBPS", "Text", f"ch{i}.xhtml"))
# --- write_illustrations ---
def test_write_illustrations_skips_when_no_images(ctx):
write_structure(ctx, overwrite=False)
write_illustrations(ctx)
assert not os.path.isfile(os.path.join(ctx.book.directory, "OEBPS", "Text", "illustrations.xhtml"))
def test_write_illustrations_creates_file_with_image_links(ctx):
ctx.book.images.append(Image(name="photo.jpg", caption="A scenic photo"))
write_structure(ctx, overwrite=False)
write_illustrations(ctx)
path = os.path.join(ctx.book.directory, "OEBPS", "Text", "illustrations.xhtml")
assert os.path.isfile(path)
with open(path) as f:
content = f.read()
assert "photo.jpg" in content
assert "A scenic photo" in content
# --- write_contents ---
def test_write_contents_creates_opf_with_metadata(ctx):
write_structure(ctx, overwrite=False)
write_contents(ctx)
with open(os.path.join(ctx.book.directory, "OEBPS", "content.opf")) as f:
content = f.read()
assert ctx.book.title in content
assert ctx.book.publisher in content
assert ctx.book.isbn in content
def test_write_contents_includes_section_in_manifest(ctx):
ctx.book.sections.append(Section(type="text", name="chapter1", title="Chapter One"))
write_structure(ctx, overwrite=False)
write_contents(ctx)
with open(os.path.join(ctx.book.directory, "OEBPS", "content.opf")) as f:
content = f.read()
assert "chapter1.xhtml" in content
def test_write_contents_includes_series_metadata(ctx):
ctx.book.series.name = "My Series"
ctx.book.series.number = "1"
write_structure(ctx, overwrite=False)
write_contents(ctx)
with open(os.path.join(ctx.book.directory, "OEBPS", "content.opf")) as f:
content = f.read()
assert "My Series" in content
assert "calibre:series" in content
def test_write_contents_includes_illustrations_when_images_present(ctx):
ctx.book.images.append(Image(name="photo.jpg", type="jpeg", caption="caption"))
write_structure(ctx, overwrite=False)
with patch("epubmaker.writer.copy"):
write_contents(ctx)
with open(os.path.join(ctx.book.directory, "OEBPS", "content.opf")) as f:
content = f.read()
assert "illustrations.xhtml" in content