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.
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"""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 & 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> </p><p>\xa0</p><p>   </p><p><br/></p><p><span> </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/> <br/>Two</div>") == Measure(2, 6)
|
||||
# A no-break space inside a paragraph is a character of its text.
|
||||
assert pages.measure("<p>One <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)
|
||||
Reference in New Issue
Block a user