Bindery counts the pages as the last step of a build, from the text of the ePub. The story list and the OPDS catalog show the count. A story has no count until its book is built. The method follows "Paragraphs (APNX Accurate)" of the Count Pages plugin for calibre. Bindery counts each paragraph that a reader sees, so the same text gives the same count as <p> elements, as <div> elements and as lines with <br> between them. An empty paragraph is not counted.
96 lines
3.5 KiB
Python
96 lines
3.5 KiB
Python
"""Read the OPF metadata of an epub that came from CWA."""
|
|
|
|
import posixpath
|
|
import re
|
|
import zipfile
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from urllib.parse import unquote
|
|
from xml.etree.ElementTree import Element
|
|
|
|
from defusedxml import ElementTree
|
|
|
|
OPF = "{http://www.idpf.org/2007/opf}"
|
|
DC = "{http://purl.org/dc/elements/1.1/}"
|
|
CONTAINER = "{urn:oasis:names:tc:opendocument:xmlns:container}"
|
|
|
|
|
|
class EpubError(Exception):
|
|
pass
|
|
|
|
|
|
def _opf(path: Path, z: zipfile.ZipFile) -> tuple[str, Element]:
|
|
"""The name of the OPF file in the epub, and its content."""
|
|
container = ElementTree.fromstring(z.read("META-INF/container.xml"))
|
|
rootfile = container.find(f".//{CONTAINER}rootfile")
|
|
if rootfile is None or not rootfile.get("full-path"):
|
|
raise EpubError(f"{path.name} has no OPF file")
|
|
return rootfile.get("full-path"), ElementTree.fromstring(z.read(rootfile.get("full-path")))
|
|
|
|
|
|
def _metadata(path: Path) -> Element:
|
|
try:
|
|
with zipfile.ZipFile(path) as z:
|
|
opf = _opf(path, z)[1]
|
|
except (zipfile.BadZipFile, KeyError, ElementTree.ParseError) as e:
|
|
raise EpubError(f"{path.name} is not a readable epub: {e}") from e
|
|
metadata = opf.find(f"{OPF}metadata")
|
|
if metadata is None:
|
|
raise EpubError(f"{path.name} has no OPF metadata")
|
|
return metadata
|
|
|
|
|
|
def spine_documents(path: Path) -> list[str]:
|
|
"""The text of each document in the spine of an epub, in reading order."""
|
|
try:
|
|
with zipfile.ZipFile(path) as z:
|
|
name, opf = _opf(path, z)
|
|
hrefs = {item.get("id"): item.get("href") for item in opf.iter(f"{OPF}item")}
|
|
files = [
|
|
posixpath.join(posixpath.dirname(name), unquote(hrefs[ref.get("idref")]))
|
|
for ref in opf.iter(f"{OPF}itemref")
|
|
]
|
|
return [z.read(file).decode("utf-8", "replace") for file in files]
|
|
except (zipfile.BadZipFile, KeyError, ElementTree.ParseError) as e:
|
|
raise EpubError(f"{path.name} is not a readable epub: {e}") from e
|
|
|
|
|
|
def epub_urls(path: Path) -> list[str]:
|
|
"""Every URL in the identifiers and the source of an epub's OPF.
|
|
|
|
FanFicFare writes the story URL as dc:source and as a URL identifier. CWA
|
|
keeps the identifier when it writes its own metadata into a download.
|
|
"""
|
|
urls: list[str] = []
|
|
for element in _metadata(path):
|
|
if element.tag not in (f"{DC}identifier", f"{DC}source"):
|
|
continue
|
|
match = re.search(r"https?://\S+", element.text or "")
|
|
if match and match.group(0) not in urls:
|
|
urls.append(match.group(0))
|
|
return urls
|
|
|
|
|
|
def epub_metadata(path: Path) -> dict[str, Any]:
|
|
"""The metadata fields of an epub's OPF, in Bindery's field format."""
|
|
metadata = _metadata(path)
|
|
|
|
def texts(tag: str) -> list[str]:
|
|
return [(e.text or "").strip() for e in metadata.findall(f"{DC}{tag}") if (e.text or "").strip()]
|
|
|
|
meta = {m.get("name"): m.get("content") for m in metadata.findall(f"{OPF}meta") if m.get("name")}
|
|
try:
|
|
series_index = float(meta["calibre:series_index"]) if meta.get("calibre:series_index") else None
|
|
except ValueError:
|
|
series_index = None
|
|
return {
|
|
"title": (texts("title") or [""])[0],
|
|
"authors": texts("creator"),
|
|
"comments": (texts("description") or [""])[0],
|
|
"tags": texts("subject"),
|
|
"series": meta.get("calibre:series") or "",
|
|
"series_index": series_index,
|
|
"pubdate": (texts("date") or [""])[0],
|
|
"languages": (texts("language") or [""])[0],
|
|
}
|