"Test writing basic PDF"

import datetime
import glob
import locale
import os
import queue
import re
import shutil
import subprocess
import tempfile
import threading
from unittest.mock import MagicMock

import config
import img2pdf
import pikepdf
import pytest
from basethread import Request
from docthread import DocThread
from document import Document
from loop_helpers import safe_mainloop
from page import Page
from PIL import Image


def has_locale(name):
    "Check if the given locale is available on the system."
    try:
        old_locale = locale.setlocale(locale.LC_CTYPE)
        locale.setlocale(locale.LC_CTYPE, name)
        locale.setlocale(locale.LC_CTYPE, old_locale)
        return True
    except locale.Error:
        return False


def get_page_size(path):
    "Get page size from PDF using pikepdf. Returns (width, height) in points."
    with pikepdf.open(path) as pdf:
        page = pdf.pages[0]
        width = float(page.MediaBox[2]) - float(page.MediaBox[0])
        height = float(page.MediaBox[3]) - float(page.MediaBox[1])
        return width, height


def test_has_locale():
    "Test has_locale function"
    assert has_locale("C") is True
    assert has_locale("non-existent-locale") is False


def test_do_save_pdf(rose_pnm, temp_db, temp_pdf):
    "Test writing basic PDF"
    thread = DocThread(db=temp_db.name)
    thread._write_tid = threading.get_native_id()
    with tempfile.TemporaryDirectory() as tdir:
        _number, _thumb, page_id = thread.add_page(
            Page(
                filename=rose_pnm,
                dir=tdir,
                delete=True,
                format="Portable anymap",
                resolution=(72, 72, "PixelsPerInch"),
                width=70,
                height=46,
            ),
        )
        options = {
            "dir": tdir,
            "path": temp_pdf.name,
            "list_of_pages": [page_id],
            "options": {},
        }
        request = Request("save_pdf", (options,), queue.Queue())
        thread.do_save_pdf(request)
        width, height = get_page_size(temp_pdf.name)
        assert (width, height) == pytest.approx((70, 46), 0.1), "valid PDF created"


def test_save_pdf(rose_pnm, temp_db, temp_pdf, clean_up_files):
    "Test writing basic PDF"
    slist = Document(db=temp_db.name)

    asserts = 0

    # FIXME: add support for completed, total vars
    #    def import_files_started_cb( thread, process, completed, total ):
    def import_files_started_cb(response):
        nonlocal asserts
        # FIXME: add support for completed/total
        # assert completed== 0, 'completed counter starts at 0'
        # assert total==     2, 'total counter starts at 2'
        assert response.request.process in ["get_file_info", "import_file"]
        asserts += 1

    def import_files_finished_cb(response):
        nonlocal asserts
        assert not slist.thread.pages_saved(), "pages not tagged as saved"
        asserts += 1
        mlp.quit()

    slist.import_files(
        paths=[rose_pnm],
        started_callback=import_files_started_cb,
        finished_callback=import_files_finished_cb,
    )
    mlp = safe_mainloop(5000)
    mlp.run()

    # FIXME: add support for completed, total vars
    #    def save_pdf_started_cb( result, completed, total ):
    def save_pdf_started_cb(result):
        nonlocal asserts
        assert result.request.process == "save_pdf", "save_pdf"
        # FIXME: add support for completed/total
        # assert completed== 0, 'completed counter re-initialised'
        # assert total==     1, 'total counter re-initialised'
        asserts += 1

    def save_pdf_finished_cb(result):
        nonlocal asserts
        width, height = get_page_size(temp_pdf.name)
        assert (width, height) == pytest.approx((70, 46), 0.1), "valid PDF created"
        assert slist.thread.pages_saved(), "pages tagged as saved"
        asserts += 1
        mlp.quit()

    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        options={
            "post_save_hook": "pdftoppm %i test",
        },
        started_callback=save_pdf_started_cb,
        finished_callback=save_pdf_finished_cb,
    )
    mlp = safe_mainloop(5000)
    mlp.run()
    assert asserts == 5, "ran all callbacks"

    img = Image.open("test-1.ppm")
    assert img.format == "PPM", "ran post-save hook on pdf"
    assert img.size == (146, 96), "post-save hook dimensions"

    #########################

    clean_up_files(["test-1.ppm"])


@pytest.mark.skipif(
    not has_locale("de_DE.utf8"), reason="Locale de_DE.utf8 not available"
)
def test_save_pdf_with_locale(rose_pnm, temp_db, temp_pdf, import_in_mainloop):
    "Test with non-English locale"
    locale.setlocale(locale.LC_CTYPE, "de_DE.utf8")

    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [rose_pnm])

    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        finished_callback=mlp.quit,
    )
    mlp.run()

    width, height = get_page_size(temp_pdf.name)
    assert (width, height) == pytest.approx((70, 46), 0.1), "valid PDF created"


def test_save_pdf_with_error(rose_pnm, temp_pdf, import_in_mainloop):
    "Test saving a PDF and triggering an error"
    with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as dirname:
        slist = Document(dir=dirname)
        asserts = 0

        import_in_mainloop(slist, [rose_pnm])

        # inject error before save_pdf
        os.chmod(dirname, 0o500)  # no write access

        def error_callback1(_page, _process, _message):
            "no write access"
            assert True, "caught error injected before save_pdf"
            nonlocal asserts
            asserts += 1
            mlp.quit()

        mlp = safe_mainloop(5000)
        slist.save_pdf(
            path=temp_pdf.name,
            list_of_pages=[slist.data[0][2]],
            error_callback=error_callback1,
        )
        mlp.run()

        def error_callback2(_page, _process, _message):
            assert True, "save_pdf caught error injected in queue"
            os.chmod(dirname, 0o700)  # allow write access
            nonlocal asserts
            asserts += 1
            mlp.quit()

        mlp = safe_mainloop(5000)
        slist.save_pdf(
            path=temp_pdf.name,
            list_of_pages=[slist.data[0][2]],
            error_callback=error_callback2,
        )
        mlp.run()

        assert asserts == 2, "ran all callbacks"


def test_save_pdf_different_resolutions(
    temp_png, temp_db, temp_pdf, import_in_mainloop
):
    "test saving a PDF with different resolutions in the height and width directions"

    # Create test image
    subprocess.run(
        [config.CONVERT_COMMAND, "rose:", "-density", "100x200", temp_png.name],
        check=True,
    )

    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [temp_png.name])

    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    width, height = get_page_size(temp_pdf.name)
    assert (width, height) == pytest.approx((50.4, 16.56), 0.1), "valid PDF created"


@pytest.mark.skipif(shutil.which("qpdf") is None, reason="qpdf not found")
def test_save_encrypted_pdf(rose_jpg, temp_db, temp_pdf, import_in_mainloop):
    "test saving an encrypted PDF"
    slist = Document(db=temp_db.name)
    import_in_mainloop(slist, [rose_jpg])
    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        options={"user-password": "123"},
        list_of_pages=[slist.data[0][2]],
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    with pytest.raises(pikepdf.PasswordError):
        pikepdf.open(temp_pdf.name)


def test_save_pdf_with_hocr(
    import_in_mainloop, set_text_in_mainloop, temp_db, temp_pdf, temp_png, get_page_sync
):
    "Test writing PDF with text layer from hocr"

    subprocess.run(
        [
            config.CONVERT_COMMAND,
            "label:The quick brown fox",
            "-alpha",
            "Off",
            "-depth",
            "1",
            "-colorspace",
            "Gray",
            "-family",
            "DejaVu Sans",
            "-pointsize",
            "12",
            "-units",
            "PixelsPerInch",
            "-density",
            "300",
            "-border",
            "20x10",
            temp_png.name,
        ],
        check=True,
    )
    img = Image.open(temp_png.name)
    width, height = img.size

    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [temp_png.name])

    hocr = """<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
    "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
 <head>
  <title></title>
  <meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
  <meta name='ocr-system' content='tesseract 5.3.4' />
  <meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
 </head>
 <body>
  <div class='ocr_page' id='page_1' title='bbox 0 0 136 38; ppageno 0; scan_res 300 300'>
   <div class='ocr_carea' id='block_1_1' title="bbox 20 13 115 26">
    <p class='ocr_par' id='par_1_1' lang='eng' title="bbox 20 13 115 26">
     <span class='ocr_line' id='line_1_1' title="bbox 20 13 115 26; baseline 0 -3; x_size 21.25; x_descenders 5.5; x_ascenders 5.25">
      <span class='ocrx_word' id='word_1_1' title='bbox 16 4 36 33; x_wconf 66'>The</span>
      <span class='ocrx_word' id='word_1_2' title='bbox 40 13 115 26; x_wconf 17'>quick brown</span>
      <span class='ocrx_word' id='word_1_3' title='bbox 100 4 118 33; x_wconf 65'>fox</span>
     </span>
    </p>
   </div>
  </div>
 </body>
</html>
"""
    page = get_page_sync(slist.thread, id=1)
    page.import_hocr(hocr)
    set_text_in_mainloop(slist, 1, page.text_layer)
    # slist.data[0][2].import_annotations(hocr)

    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()
    subprocess.run(["cp", temp_pdf.name, "fox.pdf"], check=True)

    capture = subprocess.check_output(["pdftotext", temp_pdf.name, "-"], text=True)
    assert re.search(  # some tesseract installations find an extra "r"
        r"Thr?e.*quick.*brow[mn].*f", capture, re.DOTALL
    ), "PDF with expected text"
    # capture = subprocess.check_output(["cat", temp_pdf.name], text=True)
    # assert re.search(r"/Type\s/Annot\s/Subtype\s/Highlight\s/C.+/Contents.+fox",
    #                  capture) is not None, 'PDF with expected annotation'

    import_in_mainloop(slist, [temp_pdf.name])

    # Because we cannot reproduce the exact typeface used
    # in the original, we cannot expect to be able to
    # round-trip the text layer. Here, at least we can check
    # that we have scaled the page size correctly.
    page = get_page_sync(slist.thread, id=2)
    regex = re.search(r"bbox\s0\s0\s(\d+)\s(\d+)", page.export_hocr())
    page_width, page_height = None, None
    if regex:
        page_width, page_height = int(regex.group(1)), int(regex.group(2))
    assert abs(page_width - width) < 2, "imported page width correct"
    assert abs(page_height - height) < 2, "imported page height correct"
    # assert re.search(r"The.+quick.+brown.+fox", slist.data[1][2].annotations) \
    #     is not None, 'import annotations'


@pytest.mark.xfail(
    reason="ocrmypdf GlyphlessFont + PDF/A conversion mangles non-ASCII text extraction"
)
def test_save_pdf_with_utf8(
    rose_pnm, temp_pdf, temp_db, import_in_mainloop, set_text_in_mainloop
):
    "Test writing PDF with utf8 in text layer"
    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [rose_pnm])

    set_text_in_mainloop(
        slist,
        1,
        '[{"bbox": [0, 0, 422, 61], "type": "page", "depth": 0}, '
        '{"bbox": [1, 14, 420, 59], "type": "column", "depth": 1}, '
        '{"bbox": [1, 14, 420, 59], "type": "line", "depth": 2}, '
        '{"bbox": [1, 14, 20, 48], "type": "word", "text": "пени", "depth": 3}, '
        '{"bbox": [21, 14, 50, 48], "type": "word", "text": "способствовала", "depth": 3}, '
        '{"bbox": [51, 14, 77, 48], "type": "word", "text": "сохранению", "depth": 3}]',
    )

    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    out = subprocess.check_output(["pdftotext", temp_pdf.name, "-"], text=True)
    assert (
        re.search(r"пени\s*способствовала\s*сохранению", out) is not None
    ), "PDF with expected text"


def test_save_pdf_with_1bpp(
    temp_pbm, temp_db, temp_pdf, import_in_mainloop, clean_up_files
):
    "Test writing PDF with a 1bpp image"

    subprocess.run(
        [config.CONVERT_COMMAND, "magick:netscape", temp_pbm.name], check=True
    )

    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [temp_pbm.name])

    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    subprocess.run(["pdfimages", temp_pdf.name, "x"], check=True)
    img = Image.open(glob.glob("x-000.p*m")[0])
    assert img.mode == "1", "PDF with 1bpp created"

    #########################

    clean_up_files(glob.glob("x-000.p*m"))


def test_save_pdf_g4(rose_png, temp_db, temp_pdf, import_in_mainloop, clean_up_files):
    "Test writing PDF with group 4 compression"
    slist = Document(db=temp_db.name)
    import_in_mainloop(slist, [rose_png])
    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        options={
            "compression": "g4",
        },
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    subprocess.run(["pdfimages", temp_pdf.name, "x"], check=True)
    img = Image.open(glob.glob("x-000.p*m")[0])
    assert img.mode == "1", "PDF with 1bpp created from 8-bit image"

    #########################

    clean_up_files(glob.glob("x-000.p*m"))


def test_save_pdf_g4_alpha(temp_tif, temp_png, temp_db, temp_pdf, import_in_mainloop):
    "Test writing PDF with group 4 compression"

    subprocess.run(
        [
            config.CONVERT_COMMAND,
            "rose:",
            "-define",
            "tiff:rows-per-strip=1",
            "-compress",
            "group4",
            temp_tif.name,
        ],
        check=True,
    )

    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [temp_tif.name])

    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        options={
            "compression": "g4",
        },
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    subprocess.run(
        [
            "gs",
            "-q",
            "-dNOPAUSE",
            "-dBATCH",
            "-sDEVICE=pnggray",
            "-g70x46",
            "-dPDFFitPage",
            "-dUseCropBox",
            f"-sOutputFile={temp_png.name}",
            temp_pdf.name,
        ],
        check=True,
    )
    example = subprocess.check_output(
        [
            config.CONVERT_COMMAND,
            temp_png.name,
            "-depth",
            "1",
            "-alpha",
            "off",
            "txt:-",
        ],
        text=True,
    )
    expected = subprocess.check_output(
        [
            config.CONVERT_COMMAND,
            temp_tif.name,
            "-depth",
            "1",
            "-alpha",
            "off",
            "txt:-",
        ],
        text=True,
    )
    assert example == expected, "valid G4 PDF created from multi-strip TIFF"


def test_save_pdf_with_metadata(rose_pnm, temp_pdf, temp_db, import_in_mainloop):
    "Test writing PDF with metadata"
    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [rose_pnm])

    metadata = {
        "datetime": datetime.datetime(2016, 2, 10, 0, 0, tzinfo=datetime.timezone.utc),
        "title": "metadata title",
        "subject": "",
    }
    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        metadata=metadata,
        options={"set_timestamp": True},
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    with pikepdf.open(temp_pdf.name) as pdf:
        docinfo = pdf.docinfo or {}
        assert docinfo.get("/Title") == "metadata title", "metadata title in PDF"
        assert (
            "/Subject" not in docinfo or docinfo["/Subject"] != ""
        ), "don't add blank metadata"
        creationdate = str(docinfo.get("/CreationDate", ""))
        assert "20160210" in creationdate, "metadata CreationDate in PDF"
    stb = os.stat(temp_pdf.name)
    assert datetime.datetime.fromtimestamp(
        stb.st_mtime, tz=datetime.timezone.utc
    ) == datetime.datetime(
        2016, 2, 10, 0, 0, 0, tzinfo=datetime.timezone.utc
    ), "timestamp"


def test_save_pdf_without_title_has_no_placeholder_title(
    rose_pnm, temp_pdf, temp_db, import_in_mainloop
):
    "Test writing PDF without title gets no placeholder title"
    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [rose_pnm])

    metadata = {
        "datetime": datetime.datetime(2016, 2, 10, 0, 0, tzinfo=datetime.timezone.utc),
    }
    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        metadata=metadata,
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    with pikepdf.open(temp_pdf.name) as pdf:
        docinfo = pdf.docinfo or {}
        assert "/Title" not in docinfo, "no placeholder title in docinfo"
        with pdf.open_metadata() as md:
            assert "dc:title" not in md, "no placeholder title in XMP"
    pdfinfo = subprocess.run(
        ["pdfinfo", temp_pdf.name], capture_output=True, text=True, check=True
    )
    assert "Title:" not in pdfinfo.stdout, "no placeholder title in pdfinfo"


def test_save_pdf_with_title_retains_title_in_xmp(
    rose_pnm, temp_pdf, temp_db, import_in_mainloop
):
    "Test writing PDF with title retains it in docinfo and XMP"
    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [rose_pnm])

    metadata = {
        "datetime": datetime.datetime(2016, 2, 10, 0, 0, tzinfo=datetime.timezone.utc),
        "title": "metadata title",
    }
    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        metadata=metadata,
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    with pikepdf.open(temp_pdf.name) as pdf:
        docinfo = pdf.docinfo or {}
        assert docinfo.get("/Title") == "metadata title", "metadata title in docinfo"
        with pdf.open_metadata() as md:
            assert md.get("dc:title") == "metadata title", "metadata title in XMP"


def test_save_import_without_title_roundtrip(
    rose_pnm, temp_pdf, temp_db, import_in_mainloop
):
    "Test saving without title and re-importing yields no title"
    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [rose_pnm])

    metadata = {
        "datetime": datetime.datetime(2016, 2, 10, 0, 0, tzinfo=datetime.timezone.utc),
    }
    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        metadata=metadata,
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    asserts = 0

    def metadata_cb(response):
        assert "title" not in response, "no title in re-imported metadata"
        nonlocal asserts
        asserts += 1

    slist2 = Document(db=temp_db.name)
    mlp2 = safe_mainloop()
    slist2.import_files(
        paths=[temp_pdf.name],
        metadata_callback=metadata_cb,
        finished_callback=lambda response: mlp2.quit(),
    )
    mlp2.run()

    assert asserts == 1, "metadata callback ran"


def test_save_pdf_with_old_metadata(rose_pnm, temp_pdf, temp_db, import_in_mainloop):
    "Test writing PDF with old metadata"
    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [rose_pnm])

    metadata = {
        "datetime": datetime.datetime(1966, 2, 10, 0, 0, tzinfo=datetime.timezone.utc),
        "title": "metadata title",
    }

    called = False

    def error_callback(_result):
        nonlocal called
        called = True
        mlp.quit()

    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        metadata=metadata,
        options={"set_timestamp": True},
        finished_callback=lambda response: mlp.quit(),
        error_callback=error_callback,
    )
    mlp.run()

    assert called, "caught errors setting timestamp"

    with pikepdf.open(temp_pdf.name) as pdf:
        docinfo = pdf.docinfo or {}
        assert docinfo.get("/Title") == "metadata title", "metadata title in PDF"
        creationdate = str(docinfo.get("/CreationDate", ""))
        assert "19660210" in creationdate, "metadata CreationDate in PDF"


def test_save_pdf_with_downsample(
    temp_png, temp_pdf, temp_db, import_in_mainloop, clean_up_files
):
    "Test writing PDF with downsampled image"

    subprocess.run(
        [
            config.CONVERT_COMMAND,
            "-density",
            "300",
            "label:The quick brown fox",
            "-alpha",
            "Off",
            "-depth",
            "1",
            "-colorspace",
            "Gray",
            "-family",
            "DejaVu Sans",
            "-pointsize",
            "12",
            temp_png.name,
        ],
        check=True,
    )

    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [temp_png.name])

    mlp = safe_mainloop(5000)
    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        finished_callback=lambda response: mlp.quit(),
    )
    mlp.run()

    with tempfile.NamedTemporaryFile(suffix=".pdf") as temp_pdf2:
        mlp = safe_mainloop(5000)
        slist.save_pdf(
            path=temp_pdf2.name,
            list_of_pages=[slist.data[0][2]],
            options={
                "downsample": True,
                "downsample dpi": 150,
            },
            finished_callback=lambda response: mlp.quit(),
        )
        mlp.run()

        assert os.path.getsize(temp_pdf.name) > os.path.getsize(
            temp_pdf2.name
        ), "downsampled PDF smaller than original"

        subprocess.run(["pdfimages", temp_pdf2.name, "x"], check=True)
        img = Image.open("x-000.pbm")
        assert img.mode == "1", "downsampled"
        assert img.size[0] > 0 and img.size[1] > 0, "downsampled dimensions"

    clean_up_files(["x-000.pbm"])


def test_cancel_save_pdf(rose_pnm, temp_pdf, temp_db, temp_jpg, import_in_mainloop):
    "Test writing PDF with downsampled image"
    slist = Document(db=temp_db.name)

    import_in_mainloop(slist, [rose_pnm])

    finished_callback = MagicMock()
    mlp = safe_mainloop(5000)
    called = False

    def cancelled_callback(_response):
        nonlocal called
        called = True
        mlp.quit()

    slist.save_pdf(
        path=temp_pdf.name,
        list_of_pages=[slist.data[0][2]],
        finished_callback=finished_callback,
    )
    slist.cancel(cancelled_callback)
    mlp.run()
    finished_callback.assert_not_called()

    slist.save_image(
        path=temp_jpg.name,
        list_of_pages=[slist.data[0][2]],
        finished_callback=lambda response: mlp.quit(),
    )
    mlp = safe_mainloop(5000)
    mlp.run()

    img = Image.open(temp_jpg.name)
    assert (
        img.format == "JPEG"
    ), "can create a valid JPG after cancelling save PDF process"


def test_import_pdf_without_text_and_resave(
    rose_png, temp_db, import_in_mainloop, clean_up_files
):
    """
    Regression test for bug where importing a PDF without a text layer
    and then re-saving it as a PDF would fail with:
    'HocrTransform' object has no attribute 'width'
    """

    # Create a PDF from a TIFF (no text layer)
    with tempfile.NamedTemporaryFile(suffix=".pdf") as temp_pdf1:
        with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as temp_pdf2:
            temp_pdf1.write(img2pdf.convert(rose_png))
            temp_pdf1.flush()

            # Import the PDF without text layer
            slist = Document(db=temp_db.name)
            import_in_mainloop(slist, [temp_pdf1.name])

            assert len(slist.data) == 1, "imported 1 page"

            # Re-save it as PDF - this should not fail
            mlp = safe_mainloop(10000)

            slist.save_pdf(
                path=temp_pdf2.name,
                list_of_pages=[slist.data[0][2]],
                finished_callback=lambda response: mlp.quit(),
            )
            mlp.run()

            # Verify the output PDF is valid
            width, height = get_page_size(temp_pdf2.name)
            assert width > 0 and height > 0, "valid PDF created"

            clean_up_files([temp_pdf2.name])


def test_import_pdf_from_transparent_image_creates_one_page(temp_db, tmp_path):
    """
    Regression test for issue #43: opening a PDF created from a transparent
    image must import a single page, not an extra page for the alpha mask.
    The imported page must be the image composited over a white background.
    """
    image = Image.new("LA", (100, 50), (255, 0))
    image.putpixel((5, 5), (0, 255))
    image.putpixel((6, 5), (0, 128))
    png = str(tmp_path / "transparent.png")
    image.save(png)

    with tempfile.NamedTemporaryFile(suffix=".pdf") as temp_pdf:
        temp_pdf.write(img2pdf.convert(png))
        temp_pdf.flush()

        slist = Document(db=temp_db.name)
        error_cb = MagicMock()

        mlp = safe_mainloop(10000)

        slist.import_files(
            paths=[temp_pdf.name],
            error_callback=error_cb,
            finished_callback=lambda response: mlp.quit(),
        )
        mlp.run()

        assert len(slist.data) == 1, "exactly one page imported"
        error_cb.assert_not_called()

        page = slist.thread.get_page(id=slist.data[0][2])
        imported = page.image_object.convert("L")
        assert imported.size == (100, 50)
        assert imported.getpixel((5, 5)) == 0, "opaque pixel stays black"
        assert (
            imported.getpixel((6, 5)) == 127
        ), "half-transparent pixel blended with white"
        assert imported.getpixel((0, 0)) == 255, "transparent pixel becomes white"


def test_save_pdf_with_empty_text_layer(rose_pnm, temp_db, temp_pdf):
    """
    Regression test for bug where saving a PDF with text_layer set to '[]'
    (empty JSON array, as produced by tesseract when no text is found)
    would fail with: 'HocrTransform' object has no attribute 'width'
    """
    thread = DocThread(db=temp_db.name)
    thread._write_tid = threading.get_native_id()
    with tempfile.TemporaryDirectory() as tdir:
        _number, _thumb, page_id = thread.add_page(
            Page(
                filename=rose_pnm,
                dir=tdir,
                delete=True,
                format="Portable anymap",
                resolution=(72, 72, "PixelsPerInch"),
                width=70,
                height=46,
                text_layer="[]",
            ),
        )
        options = {
            "dir": tdir,
            "path": temp_pdf.name,
            "list_of_pages": [page_id],
            "options": {},
        }
        request = Request("save_pdf", (options,), queue.Queue())
        thread.do_save_pdf(request)
        width, height = get_page_size(temp_pdf.name)
        assert (width, height) == pytest.approx((70, 46), 0.1), "valid PDF created"
