diff --git a/app/editor/canvas.py b/app/editor/canvas.py index 11c626d..fc57817 100644 --- a/app/editor/canvas.py +++ b/app/editor/canvas.py @@ -235,10 +235,16 @@ def set_overlays(self, overlays: list): def load(self, path: str, password: str = ""): import fitz - if self._doc: self._doc.close() - self._doc = fitz.open(path) - if self._doc.needs_pass and password: - self._doc.authenticate(password) + if self._doc: + self._doc.close() + # Clear the reference *before* fitz.open so a corrupt PDF that + # raises never leaves ``self._doc`` pointing at an already-closed + # Document (use-after-close). Only publish on success. + self._doc = None + doc = fitz.open(path) + if doc.needs_pass and password: + doc.authenticate(password) + self._doc = doc self._path = path self._password = password self._page_idx = 0 diff --git a/app/tools/import_pdf.py b/app/tools/import_pdf.py index cddb701..bed7a96 100644 --- a/app/tools/import_pdf.py +++ b/app/tools/import_pdf.py @@ -19,6 +19,24 @@ _IMG_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".webp", ".gif") +class _NoContent: + """Sentinel result: a converter produced a zero-page document. + + Returned (instead of the output path) when every input was empty, + rejected or unreadable so ``do_work`` never hands a page-less + ``fitz.Document`` to ``doc.save()`` — which raises the cryptic + ``ValueError: cannot save with zero pages``. It is truthy/non-None so + ``_run_background`` treats it as success (not a cancel) and routes it + to the friendly ``tool.import.no_content`` message. ``skipped`` carries + any per-item skip count so the images path keeps its existing detail. + """ + + __slots__ = ("skipped",) + + def __init__(self, skipped: int = 0): + self.skipped = skipped + + class TabImport(BasePage): def __init__(self, status_fn): super().__init__("fa5s.file-import", t("tool.import.name"), @@ -155,7 +173,11 @@ def do_work(worker): for i, src in enumerate(sources): if worker.is_cancelled(): return None - with open(src, "r", encoding="utf-8") as f: + # errors="replace" mirrors the font pre-scan above: a + # Notepad/Excel .txt saved as Windows-1252/Latin-1 must not + # crash the real conversion with UnicodeDecodeError after + # the pre-scan told the user the file was fine. + with open(src, "r", encoding="utf-8", errors="replace") as f: all_lines.extend(f.read().split("\n")) all_lines.append("") # separator between files worker.progress.emit(i + 1, f"{i + 1}/{n}…") @@ -188,6 +210,11 @@ def do_work(worker): fontname="helv") est_lines = max(1, len(line) * fontsize * 0.5 / max_width + 1) y += line_height * est_lines + # Guard against an all-empty input set: doc.save() raises the + # cryptic "cannot save with zero pages" otherwise. + if doc.page_count == 0: + doc.close() + return _NoContent() try: # R11-M8: atomic write — avoids truncating a pre-existing # output file if the save crashes or the process is killed. @@ -198,7 +225,7 @@ def do_work(worker): self._run_background(do_work, total=max(n, 1), label=t("tool.import.converting"), - on_done=lambda _r: self._done(out_path)) + on_done=lambda r: self._on_result(r, out_path)) def _convert_images(self, sources: list, out_path: str): # R11-L1: filter to recognised image extensions up front. The @@ -239,6 +266,12 @@ def do_work(worker): finally: img.close() worker.progress.emit(i + 1, f"{i + 1}/{n}…") + # When every image was non-image/rejected/unreadable the + # doc has no pages. Signal that (carrying the skip count) + # rather than letting doc.save() raise "cannot save with + # zero pages" — which used to mask the skipped feedback. + if doc.page_count == 0: + return _NoContent(skipped) # R11-M8: atomic write — avoids truncating a pre-existing # output file if the save crashes or the process is killed. BasePage._atomic_pdf_write(doc, out_path) @@ -246,9 +279,12 @@ def do_work(worker): doc.close() return skipped - def on_done(skipped): - if skipped: - self._status(t("tool.import.skipped_images", n=skipped)) + def on_done(result): + if isinstance(result, _NoContent): + self._on_result(result, out_path) + return + if result: + self._status(t("tool.import.skipped_images", n=result)) self._done(out_path) self._run_background(do_work, total=max(n, 1), @@ -264,7 +300,9 @@ def do_work(worker): for i, src in enumerate(sources): if worker.is_cancelled(): return None - with open(src, "r", encoding="utf-8") as f: + # errors="replace": tolerate legacy Latin-1/Windows-1252 + # .md files instead of aborting with UnicodeDecodeError. + with open(src, "r", encoding="utf-8", errors="replace") as f: all_md.append(f.read()) worker.progress.emit(i + 1, f"{i + 1}/{n}…") md_text = "\n\n---\n\n".join(all_md) @@ -288,6 +326,8 @@ def do_work(worker): page.insert_text(fitz.Point(50, y), text, fontsize=size, fontname="helv") y += size * 1.5 + if doc.page_count == 0: + return _NoContent() # R11-M8: atomic write — avoids truncating a pre-existing # output file if the save crashes or the process is killed. BasePage._atomic_pdf_write(doc, out_path) @@ -297,7 +337,7 @@ def do_work(worker): self._run_background(do_work, total=max(n, 1), label=t("tool.import.converting"), - on_done=lambda _r: self._done(out_path)) + on_done=lambda r: self._on_result(r, out_path)) def _md_to_lines(self, md: str) -> list: """Convert markdown to list of (text, fontsize, bold) tuples.""" @@ -374,6 +414,11 @@ def do_work(worker): worker.progress.emit(i + 1, f"{i + 1}/{n}…") if worker.is_cancelled(): return None + # Empty/blank inputs (e.g. a text-less DOCX or an all-empty + # workbook) leave the doc page-less; signal that instead of + # letting doc.save() raise "cannot save with zero pages". + if doc.page_count == 0: + return _NoContent() # R11-M8: atomic write — avoids truncating a pre-existing # output file if the save crashes or the process is killed. BasePage._atomic_pdf_write(doc, out_path) @@ -383,7 +428,7 @@ def do_work(worker): self._run_background(do_work, total=max(n, 1), label=t("tool.import.converting"), - on_done=lambda _r: self._done(out_path)) + on_done=lambda r: self._on_result(r, out_path)) # ── PPTX → PDF ────────────────────────────────────────────────────── @@ -443,6 +488,11 @@ def do_work(worker): f"{fi + 1}/{n}: {i + 1}/{total_slides}…") if worker.is_cancelled(): return None + # Empty/blank inputs (e.g. a text-less DOCX or an all-empty + # workbook) leave the doc page-less; signal that instead of + # letting doc.save() raise "cannot save with zero pages". + if doc.page_count == 0: + return _NoContent() # R11-M8: atomic write — avoids truncating a pre-existing # output file if the save crashes or the process is killed. BasePage._atomic_pdf_write(doc, out_path) @@ -452,7 +502,7 @@ def do_work(worker): self._run_background(do_work, total=max(n, 1), label=t("tool.import.converting"), - on_done=lambda _r: self._done(out_path)) + on_done=lambda r: self._on_result(r, out_path)) # ── XLSX → PDF ────────────────────────────────────────────────────── @@ -487,6 +537,11 @@ def do_work(worker): worker.progress.emit(i + 1, f"{i + 1}/{n}…") if worker.is_cancelled(): return None + # Empty/blank inputs (e.g. a text-less DOCX or an all-empty + # workbook) leave the doc page-less; signal that instead of + # letting doc.save() raise "cannot save with zero pages". + if doc.page_count == 0: + return _NoContent() # R11-M8: atomic write — avoids truncating a pre-existing # output file if the save crashes or the process is killed. BasePage._atomic_pdf_write(doc, out_path) @@ -496,7 +551,7 @@ def do_work(worker): self._run_background(do_work, total=max(n, 1), label=t("tool.import.converting"), - on_done=lambda _r: self._done(out_path)) + on_done=lambda r: self._on_result(r, out_path)) # ── HTML → PDF ────────────────────────────────────────────────────── @@ -516,13 +571,20 @@ def do_work(worker): for i, src in enumerate(sources): if worker.is_cancelled(): return None - with open(src, "r", encoding="utf-8") as f: + # errors="replace": tolerate legacy Latin-1/Windows-1252 + # .html files instead of aborting with UnicodeDecodeError. + with open(src, "r", encoding="utf-8", errors="replace") as f: soup = BeautifulSoup(f.read(), "html.parser") lines = self._html_to_lines(soup) self._render_lines_to_doc(doc, lines) worker.progress.emit(i + 1, f"{i + 1}/{n}…") if worker.is_cancelled(): return None + # Empty/blank inputs (e.g. a text-less DOCX or an all-empty + # workbook) leave the doc page-less; signal that instead of + # letting doc.save() raise "cannot save with zero pages". + if doc.page_count == 0: + return _NoContent() # R11-M8: atomic write — avoids truncating a pre-existing # output file if the save crashes or the process is killed. BasePage._atomic_pdf_write(doc, out_path) @@ -532,7 +594,7 @@ def do_work(worker): self._run_background(do_work, total=max(n, 1), label=t("tool.import.converting"), - on_done=lambda _r: self._done(out_path)) + on_done=lambda r: self._on_result(r, out_path)) def _html_to_lines(self, soup) -> list: lines = [] @@ -599,6 +661,11 @@ def do_work(worker): worker.progress.emit(i + 1, f"{i + 1}/{n}…") if worker.is_cancelled(): return None + # Empty/blank inputs (e.g. a text-less DOCX or an all-empty + # workbook) leave the doc page-less; signal that instead of + # letting doc.save() raise "cannot save with zero pages". + if doc.page_count == 0: + return _NoContent() # R11-M8: atomic write — avoids truncating a pre-existing # output file if the save crashes or the process is killed. BasePage._atomic_pdf_write(doc, out_path) @@ -608,7 +675,7 @@ def do_work(worker): self._run_background(do_work, total=max(n, 1), label=t("tool.import.converting"), - on_done=lambda _r: self._done(out_path)) + on_done=lambda r: self._on_result(r, out_path)) # ── Shared line renderer ──────────────────────────────────────────── @@ -633,6 +700,25 @@ def _render_lines_to_doc(self, doc, lines: list): fontsize=size, fontname="helv") y += size * 1.5 + def _on_result(self, result, out_path: str): + """Shared completion handler for every converter. + + Surfaces a friendly, translated message when the converter yielded + no pages (``_NoContent``) instead of letting the raw + ``ValueError: cannot save with zero pages`` escape; otherwise + finishes normally. + """ + if isinstance(result, _NoContent): + if result.skipped: + self._status(t("tool.import.skipped_images", + n=result.skipped)) + else: + self._status(t("tool.import.no_content")) + QMessageBox.warning(self, t("msg.warning"), + t("tool.import.no_content")) + return + self._done(out_path) + def _done(self, out_path: str): self.lbl_result.setText(f" \u2192 {os.path.basename(out_path)}") self._status(t("tool.import.status.done", path=out_path)) diff --git a/app/translations.json b/app/translations.json index e3016c0..0ccfa4c 100644 --- a/app/translations.json +++ b/app/translations.json @@ -585,6 +585,7 @@ "tool.convert.status.epub_done": "✔ EPUB → {path}", "tool.convert.status.starting": "→ {format} @ {dpi} DPI…", "tool.import.skipped_images": "Skipped {n} unreadable image(s)", + "tool.import.no_content": "No convertible content found — nothing to save.", "tool.import.status.done": "✔ PDF → {path}", "tool.merge.status.done": "✔ PDF → {name}", "tool.extract.status.done": "✔ {n} → {name}", @@ -1196,6 +1197,7 @@ "tool.convert.status.epub_done": "✔ EPUB → {path}", "tool.convert.status.starting": "→ {format} @ {dpi} DPI…", "tool.import.skipped_images": "Ignoradas {n} imagem(ens) ilegível(is)", + "tool.import.no_content": "Nenhum conteúdo convertível encontrado — nada para guardar.", "tool.import.status.done": "✔ PDF → {path}", "tool.merge.status.done": "✔ PDF → {name}", "tool.extract.status.done": "✔ {n} → {name}", @@ -1807,6 +1809,7 @@ "tool.convert.status.epub_done": "✔ EPUB → {path}", "tool.convert.status.starting": "→ {format} @ {dpi} DPI…", "tool.import.skipped_images": "Omitida(s) {n} imagen(es) ilegible(s)", + "tool.import.no_content": "No se encontró contenido convertible — nada que guardar.", "tool.import.status.done": "✔ PDF → {path}", "tool.merge.status.done": "✔ PDF → {name}", "tool.extract.status.done": "✔ {n} → {name}", @@ -2418,6 +2421,7 @@ "tool.convert.status.epub_done": "✔ EPUB → {path}", "tool.convert.status.starting": "→ {format} @ {dpi} DPI…", "tool.import.skipped_images": "{n} image(s) illisible(s) ignorée(s)", + "tool.import.no_content": "Aucun contenu convertible trouvé — rien à enregistrer.", "tool.import.status.done": "✔ PDF → {path}", "tool.merge.status.done": "✔ PDF → {name}", "tool.extract.status.done": "✔ {n} → {name}", @@ -3029,6 +3033,7 @@ "tool.convert.status.epub_done": "✔ EPUB → {path}", "tool.convert.status.starting": "→ {format} @ {dpi} DPI…", "tool.import.skipped_images": "{n} unlesbare(s) Bild(er) übersprungen", + "tool.import.no_content": "Kein konvertierbarer Inhalt gefunden — nichts zu speichern.", "tool.import.status.done": "✔ PDF → {path}", "tool.merge.status.done": "✔ PDF → {name}", "tool.extract.status.done": "✔ {n} → {name}", @@ -3640,6 +3645,7 @@ "tool.convert.status.epub_done": "✔ EPUB → {path}", "tool.convert.status.starting": "→ {format} @ {dpi} DPI…", "tool.import.skipped_images": "已跳过 {n} 张无法读取的图像", + "tool.import.no_content": "未找到可转换的内容 — 没有可保存的内容。", "tool.import.status.done": "✔ PDF → {path}", "tool.merge.status.done": "✔ PDF → {name}", "tool.extract.status.done": "✔ {n} → {name}", @@ -4251,6 +4257,7 @@ "tool.convert.status.epub_done": "✔ EPUB → {path}", "tool.convert.status.starting": "→ {format} @ {dpi} DPI…", "tool.import.skipped_images": "Saltata(e) {n} immagine(i) illeggibile(i)", + "tool.import.no_content": "Nessun contenuto convertibile trovato — niente da salvare.", "tool.import.status.done": "✔ PDF → {path}", "tool.merge.status.done": "✔ PDF → {name}", "tool.extract.status.done": "✔ {n} → {name}", @@ -4862,6 +4869,7 @@ "tool.convert.status.epub_done": "✔ EPUB → {path}", "tool.convert.status.starting": "→ {format} @ {dpi} DPI…", "tool.import.skipped_images": "{n} onleesbare afbeelding(en) overgeslagen", + "tool.import.no_content": "Geen converteerbare inhoud gevonden — niets om op te slaan.", "tool.import.status.done": "✔ PDF → {path}", "tool.merge.status.done": "✔ PDF → {name}", "tool.extract.status.done": "✔ {n} → {name}", diff --git a/tests/test_import_encoding_and_editor_doc.py b/tests/test_import_encoding_and_editor_doc.py new file mode 100644 index 0000000..6624d98 --- /dev/null +++ b/tests/test_import_encoding_and_editor_doc.py @@ -0,0 +1,271 @@ +"""Regression tests for the import-encoding / zero-page / editor-doc audit. + +Bug map (adversarial bug hunt): + +* MAJOR #1 — ``TabImport`` TXT/MD/HTML converters read user files with a + strict ``open(..., encoding="utf-8")`` (no ``errors=``), so a Notepad/ + Excel ``.txt`` saved as Windows-1252/Latin-1 crashed the real + conversion with ``UnicodeDecodeError`` — *after* the font pre-scan (which + already used ``errors="replace"``) had told the user the file was fine. + The fix aligns the three ``do_work`` read sites with the pre-scan. + +* MINOR #2 — when every input was empty/rejected the ``fitz.Document`` + had zero pages and ``doc.save()`` raised the cryptic + ``ValueError: cannot save with zero pages`` (in ``_convert_images`` this + also masked the "N images skipped" feedback). The fix returns a + ``_NoContent`` sentinel that drives the friendly, translated + ``tool.import.no_content`` message. + +* MINOR #3 — ``PdfEditCanvas.load`` closed the previous doc then assigned + ``self._doc = fitz.open(path)``. A corrupt PDF that raised left + ``self._doc`` pointing at the *already-closed* Document (use-after-close). + The fix clears ``self._doc`` before ``fitz.open`` and only publishes on + success. +""" + +import os +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +# A QApplication is needed by app.i18n.t() and by the tool/canvas widgets +# we instantiate. Offscreen keeps headless CI display-free. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +from PySide6.QtWidgets import QApplication # noqa: E402 +_unused_app = QApplication.instance() or QApplication([]) + +import fitz # noqa: E402 + +from app.i18n import t # noqa: E402 +from app.tools.import_pdf import ( # noqa: E402 + TabImport, _NoContent, QMessageBox, +) +from app.editor.canvas import PdfEditCanvas # noqa: E402 + + +class _FakeWorker: + """Minimal TaskRunner stand-in for driving a captured do_work().""" + + def is_cancelled(self): + return False + + class _Sig: + def emit(self, *a, **k): + pass + + progress = _Sig() + + +def _make_page(): + """A TabImport whose status messages are captured into ``.messages``.""" + msgs = [] + page = TabImport(status_fn=msgs.append) + page.messages = msgs + return page + + +def _drive(page, method_name, sources, out_path, monkeypatch): + """Call a converter but intercept ``_run_background`` so the closure + runs synchronously on a fake worker. Returns (result, on_done).""" + captured = {} + + def fake_bg(do_work_fn, total, label, on_done=None, on_err=None, + cancelled_status=""): + captured["do_work"] = do_work_fn + captured["on_done"] = on_done + + monkeypatch.setattr(page, "_run_background", fake_bg) + getattr(page, method_name)(sources, out_path) + result = captured["do_work"](_FakeWorker()) + return result, captured["on_done"] + + +# ── MAJOR #1 — legacy encodings tolerated ──────────────────────────────── + + +def test_txt_cp1252_produces_valid_pdf(tmp_path, monkeypatch): + """A .txt written as Windows-1252 (``Ol\\xe1 mundo``) must convert + without UnicodeDecodeError. With the strict-utf8 bug the do_work + closure raised before producing any output.""" + src = tmp_path / "cp1252.txt" + src.write_bytes(b"Ol\xe1 mundo") # 0xE1 = 'á' in cp1252, invalid utf-8 + out = tmp_path / "out.pdf" + + result, _ = _drive(_make_page(), "_convert_txt", [str(src)], str(out), + monkeypatch) + + assert result == str(out) + assert out.exists() + doc = fitz.open(str(out)) + try: + assert doc.page_count >= 1 + finally: + doc.close() + + +def test_md_latin1_produces_valid_pdf(tmp_path, monkeypatch): + """Latin-1 .md must not crash the real conversion.""" + src = tmp_path / "latin1.md" + src.write_bytes(b"# Caf\xe9\n\nvinho \xe0 mesa") # invalid utf-8 bytes + out = tmp_path / "out.pdf" + + result, _ = _drive(_make_page(), "_convert_md", [str(src)], str(out), + monkeypatch) + + assert result == str(out) + assert out.exists() + doc = fitz.open(str(out)) + try: + assert doc.page_count >= 1 + finally: + doc.close() + + +def test_html_latin1_produces_valid_pdf(tmp_path, monkeypatch): + """Latin-1 .html must not crash the real conversion.""" + pytest.importorskip("bs4") + src = tmp_path / "latin1.html" + src.write_bytes( + b"
caf\xe9 \xe0 la maison
") + out = tmp_path / "out.pdf" + + result, _ = _drive(_make_page(), "_convert_html", [str(src)], str(out), + monkeypatch) + + assert result == str(out) + assert out.exists() + doc = fitz.open(str(out)) + try: + assert doc.page_count >= 1 + finally: + doc.close() + + +def test_strict_utf8_read_would_have_raised(tmp_path): + """Guard the premise: the cp1252 bytes really are invalid utf-8, so a + strict read (the old behaviour) *would* have raised. This keeps the + above tests honest — they'd pass trivially if the bytes were ASCII.""" + src = tmp_path / "cp1252.txt" + src.write_bytes(b"Ol\xe1 mundo") + with pytest.raises(UnicodeDecodeError): + with open(src, "r", encoding="utf-8") as f: + f.read() + + +# ── MINOR #2 — zero-page save guarded ──────────────────────────────────── + + +def test_images_all_rejected_returns_no_content(tmp_path, monkeypatch): + """Dropping only non-image files into the image list leaves the doc + page-less. do_work must return a _NoContent sentinel (not let + doc.save() raise ValueError) and preserve any skip count.""" + not_images = [str(tmp_path / "a.txt"), str(tmp_path / "b.pdf")] + for p in not_images: + Path(p).write_text("x") + out = tmp_path / "out.pdf" + + result, on_done = _drive(_make_page(), "_convert_images", not_images, + str(out), monkeypatch) + + assert isinstance(result, _NoContent) + assert not out.exists() # nothing written + + +def test_no_content_on_done_shows_friendly_message(tmp_path, monkeypatch): + """The images on_done must surface tool.import.no_content via a + warning box instead of crashing — and must NOT call self._done.""" + not_images = [str(tmp_path / "a.txt")] + Path(not_images[0]).write_text("x") + out = tmp_path / "out.pdf" + page = _make_page() + + warned = {} + monkeypatch.setattr(QMessageBox, "warning", + lambda *a, **k: warned.setdefault("text", a[2])) + done_called = [] + monkeypatch.setattr(page, "_done", lambda p: done_called.append(p)) + + result, on_done = _drive(page, "_convert_images", not_images, str(out), + monkeypatch) + on_done(result) # must not raise ValueError + + assert warned.get("text") == t("tool.import.no_content") + assert not done_called + assert not out.exists() + + +def test_docx_zero_page_guarded(monkeypatch): + """The docx converter shares the zero-page guard: a doc left page-less + returns _NoContent rather than raising on save. Simulated with an + empty source list so no python-docx dependency is required.""" + pytest.importorskip("docx") + out = "unused.pdf" + page = _make_page() + result, _ = _drive(page, "_convert_docx", [], out, monkeypatch) + assert isinstance(result, _NoContent) + + +def test_no_content_key_present_in_all_languages(): + """tool.import.no_content must ship for all 8 supported languages so + t() never falls back to the raw key name, and must not carry a stray + {n} placeholder (it takes no arguments).""" + import json + root = Path(__file__).resolve().parent.parent + data = json.loads((root / "app" / "translations.json").read_text( + encoding="utf-8")) + assert set(data.keys()) == {"en", "pt", "es", "fr", "de", "zh", "it", + "nl"} + for lang, kv in data.items(): + assert "tool.import.no_content" in kv, f"missing in {lang}" + assert "{n}" not in kv["tool.import.no_content"], lang + + +# ── MINOR #3 — corrupt load leaves _doc None, not a closed doc ──────────── + + +def _valid_pdf(path: Path) -> Path: + doc = fitz.open() + doc.new_page(width=200, height=200) + doc.save(str(path)) + doc.close() + return path + + +def test_canvas_load_corrupt_leaves_doc_none(tmp_path): + """A corrupt PDF that makes fitz.open raise must leave self._doc as + None — not a reference to the previous, already-closed Document.""" + canvas = PdfEditCanvas() + + good = _valid_pdf(tmp_path / "good.pdf") + canvas.load(str(good)) + assert canvas._doc is not None + assert canvas.page_count() >= 1 + + corrupt = tmp_path / "corrupt.pdf" + corrupt.write_bytes(b"not a pdf at all") + with pytest.raises(Exception): + canvas.load(str(corrupt)) + + # The core assertion: no dangling closed-Document reference. + assert canvas._doc is None + assert canvas.page_count() == 0 + + +def test_canvas_reload_valid_after_failed_load(tmp_path): + """After a failed load, a subsequent valid load must work — proving + the None reset didn't wedge the canvas into a broken state.""" + canvas = PdfEditCanvas() + + corrupt = tmp_path / "corrupt.pdf" + corrupt.write_bytes(b"%PDF garbage not really") + with pytest.raises(Exception): + canvas.load(str(corrupt)) + assert canvas._doc is None + + good = _valid_pdf(tmp_path / "good.pdf") + canvas.load(str(good)) + assert canvas._doc is not None + assert canvas.page_count() >= 1