Files
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

123 lines
5.4 KiB
Python

"""The page estimate of a document and of a built epub."""
import zipfile
import pytest
from bindery import pages
from bindery.epub import EpubError, spine_documents
from bindery.pages import Measure
TEXT = ["One line of the story.", "Another, longer line of the story that goes on for a while.", "The end."]
CHARS = sum(map(len, TEXT))
def test_a_paragraph_is_text_after_a_block_or_a_line_break():
assert pages.measure("<p>One</p><p>Two</p>") == Measure(2, 6)
assert pages.measure("<div>One<br/><br/>Two<br>Three</div>") == Measure(3, 11)
assert pages.measure("<div><div>One</div><div>Two</div></div>") == Measure(2, 6)
# Text before, between and after the blocks of an element counts too.
assert pages.measure("<div>Intro<p>One</p>Middle<p>Two</p>End</div>") == Measure(5, 20)
assert pages.measure("<h3>Title</h3><ul><li>One</li><li>Two</li></ul><blockquote>Three</blockquote>") == Measure(
4, 16
)
@pytest.mark.parametrize(
"book",
[
"".join(f"<p>{line}</p>" for line in TEXT),
"".join(f'<p class="a">{line}</p>\n' for line in TEXT),
"<div>" + "".join(f"<div>{line}</div>" for line in TEXT) + "</div>",
"<div>" + "<br/><br/>".join(TEXT) + "</div>",
"<div>" + "<br>\n".join(TEXT) + "</div>",
f"<div>{TEXT[0]}<p>{TEXT[1]}</p>{TEXT[2]}</div>",
],
)
def test_the_same_text_gives_the_same_measure_in_each_markup(book):
assert pages.measure(book) == Measure(3, CHARS)
def test_inline_elements_and_table_cells_stay_in_the_line():
assert pages.measure('<p>ab<b>cd</b><a href="x">ef</a> <span><i>gh</i></span></p>') == Measure(1, 9)
assert pages.measure("<table><tr><td>Name</td><td>Value</td></tr><tr><th>a</th><td>b</td></tr></table>") == Measure(
2, 11
)
assert pages.measure('<p>Before<img src="a.png"/>after</p>') == Measure(1, 11)
def test_only_the_text_of_the_book_counts():
document = (
'<?xml version="1.0"?><html><head><title>Hidden title</title><style>p { color: red }</style>'
'<link href="a.css"/></head><body><script>var hidden;</script><script src="x.js"/>'
"<!-- a note --><p>Shown &amp; counted</p>\n\n<p> </p><p></p></body></html>"
)
assert pages.measure(document) == Measure(1, len("Shown & counted"))
assert pages.measure("") == Measure(0, 0)
assert pages.measure("<p>\n</p>") == Measure(0, 0)
def test_a_paragraph_that_only_spaces_the_text_is_not_counted():
spaced = "<p>One</p><p>&nbsp;</p><p>\xa0</p><p> &#160; </p><p><br/></p><p><span>&nbsp;</span></p><p>Two</p>"
assert pages.measure(spaced) == pages.measure("<p>One</p><p>Two</p>") == Measure(2, 6)
assert pages.measure("<div>One<br/>&nbsp;<br/>Two</div>") == Measure(2, 6)
# A no-break space inside a paragraph is a character of its text.
assert pages.measure("<p>One&nbsp;<b>two</b>\xa0three</p>") == Measure(1, 13)
def test_a_page_is_31_lines_and_a_line_is_a_paragraph_or_70_characters():
assert pages.page_count(Measure(62, 620)) == (62 + 620 // 70) // 31 == 2
assert pages.page_count(Measure(31 * 9, 31 * 9)) == 9
assert pages.page_count(Measure(1000, 177_000)) == (1000 + 177_000 // 70) // 31
def test_a_short_book_has_one_page_and_a_book_with_no_text_has_none():
assert pages.page_count(Measure(1, 10)) == 1
assert pages.page_count(Measure(30, 30)) == 1
assert pages.page_count(Measure()) == 0
# One block of text: 2400 characters start the second page before 31 lines do.
assert pages.page_count(Measure(1, 2399)) == 1
assert pages.page_count(Measure(1, 2400)) == 2
def epub(path, documents, spine=None):
names = [f"file {n}.xhtml" for n in range(len(documents))]
items = "".join(f'<item id="f{n}" href="{name.replace(" ", "%20")}"/>' for n, name in enumerate(names))
items += '<item id="css" href="stylesheet.css"/>'
refs = "".join(f'<itemref idref="f{n}"/>' for n in (spine or range(len(documents))))
with zipfile.ZipFile(path, "w") as z:
z.writestr(
"META-INF/container.xml",
'<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles>'
'<rootfile full-path="OEBPS/content.opf"/></rootfiles></container>',
)
z.writestr(
"OEBPS/content.opf",
f'<package xmlns="http://www.idpf.org/2007/opf"><metadata/><manifest>{items}</manifest>'
f"<spine>{refs}</spine></package>",
)
z.writestr("OEBPS/stylesheet.css", "p { margin: 0 }")
for name, document in zip(names, documents, strict=True):
z.writestr(f"OEBPS/{name}", document)
return path
def test_an_epub_is_counted_from_the_documents_of_its_spine(tmp_path):
chapter = "<html><head><title>T</title></head><body>" + "<p>x</p>" * 31 + "</body></html>"
book = epub(tmp_path / "book.epub", [chapter, "<html><body><p>Not in the spine</p></body></html>", chapter], [2, 0])
assert spine_documents(book) == [chapter, chapter]
# 62 paragraphs of one character.
assert pages.epub_pages(book) == 2
assert pages.epub_pages(epub(tmp_path / "empty.epub", ["<html><body><img src='a.png'/></body></html>"])) == 0
def test_a_file_that_is_not_an_epub_cannot_be_counted(tmp_path):
path = tmp_path / "bad.epub"
path.write_bytes(b"not a zip")
with pytest.raises(EpubError):
pages.epub_pages(path)
with zipfile.ZipFile(path, "w") as z:
z.writestr("META-INF/container.xml", "<container/>")
with pytest.raises(EpubError):
pages.epub_pages(path)