Files
bindery/bindery/epub.py
T
tim 46de77c228 Show an estimated page count for each built book
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.
2026-10-04 02:05:47 +02:00

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],
}