"""
Testing the text-extraction submodule and ensuring the quality of text extraction.

The tested code might be in _page.py.
"""

import re
from dataclasses import asdict
from io import BytesIO
from unittest import mock

import pytest

from pypdf import PdfReader, PdfWriter, mult
from pypdf._font import Font
from pypdf._text_extraction import set_custom_rtl
from pypdf._text_extraction._layout_mode._fixed_width_page import (
    BTGroup,
    fixed_width_page,
    recurse_to_target_op,
    text_show_operations,
)
from pypdf._text_extraction._layout_mode._text_state_manager import TextStateManager
from pypdf._text_extraction._layout_mode._text_state_params import TextStateParams
from pypdf.errors import PdfReadError
from pypdf.generic import (
    ArrayObject,
    ContentStream,
    DecodedStreamObject,
    DictionaryObject,
    NameObject,
    NumberObject,
    RectangleObject,
    StreamObject,
)

from . import RESOURCE_ROOT, SAMPLE_ROOT, get_data_from_url


@pytest.mark.samples
@pytest.mark.parametrize(("visitor_text"), [None, lambda a, b, c, d, e: None])  # noqa: ARG005
def test_multi_language(visitor_text):
    reader = PdfReader(RESOURCE_ROOT / "multilang.pdf")
    txt = reader.pages[0].extract_text(visitor_text=visitor_text)
    assert "Hello World" in txt, "English not correctly extracted"
    # iss #1296
    assert "مرحبا بالعالم" in txt, "Arabic not correctly extracted"
    assert "Привет, мир" in txt, "Russian not correctly extracted"
    assert "你好世界" in txt, "Chinese not correctly extracted"
    assert "สวัสดีชาวโลก" in txt, "Thai not correctly extracted"
    assert "こんにちは世界" in txt, "Japanese not correctly extracted"
    # check customizations
    set_custom_rtl(None, None, "Russian:")
    assert ":naissuR" in reader.pages[0].extract_text(
        visitor_text=visitor_text
    ), "(1) CUSTOM_RTL_SPECIAL_CHARS failed"
    set_custom_rtl(None, None, [ord(x) for x in "Russian:"])
    assert ":naissuR" in reader.pages[0].extract_text(
        visitor_text=visitor_text
    ), "(2) CUSTOM_RTL_SPECIAL_CHARS failed"
    set_custom_rtl(0, 255, None)
    assert ":hsilgnE" in reader.pages[0].extract_text(
        visitor_text=visitor_text
    ), "CUSTOM_RTL_MIN/MAX failed"
    set_custom_rtl("A", "z", [])
    assert ":hsilgnE" in reader.pages[0].extract_text(
        visitor_text=visitor_text
    ), "CUSTOM_RTL_MIN/MAX failed"
    set_custom_rtl(-1, -1, [])  # to prevent further errors

    reader = PdfReader(SAMPLE_ROOT / "015-arabic/habibi-rotated.pdf")
    assert "habibi" in reader.pages[0].extract_text(visitor_text=visitor_text)
    assert "حَبيبي" in reader.pages[0].extract_text(visitor_text=visitor_text)
    assert "habibi" in reader.pages[1].extract_text(visitor_text=visitor_text)
    assert "حَبيبي" in reader.pages[1].extract_text(visitor_text=visitor_text)
    assert "habibi" in reader.pages[2].extract_text(visitor_text=visitor_text)
    assert "حَبيبي" in reader.pages[2].extract_text(visitor_text=visitor_text)
    assert "habibi" in reader.pages[3].extract_text(visitor_text=visitor_text)
    assert "حَبيبي" in reader.pages[3].extract_text(visitor_text=visitor_text)


@pytest.mark.parametrize(
    ("file_name", "constraints"),
    [
        (
            "inkscape-abc.pdf",
            {
                "A": lambda x, y: 0 < x < 94 and 189 < y < 283,  # In upper left
                "B": lambda x, y: 94 < x < 189 and 94 < y < 189,  # In the center
                "C": lambda x, y: 189 < x < 283 and 0 < y < 94,  # In lower right
            },
        )
    ],
)
def test_visitor_text_matrices(file_name, constraints):
    """
    Checks if the matrices given to the visitor_text function when calling
    `extract_text` on the first page of `file_name` match some given constraints.
    `constraints` is a dictionary mapping a line of text to a constraint that should
    evaluate to `True` on its expected x,y-coordinates.
    """
    reader = PdfReader(RESOURCE_ROOT / file_name)

    lines = []

    def visitor_text(text, cm, tm, font_dict, font_size) -> None:
        ctm = mult(tm, cm)
        x = ctm[4]  # mult(tm, cm)[4]
        y = ctm[5]  # mult(tm, cm)[5]
        lines.append({"text": text, "x": x, "y": y})

    reader.pages[0].extract_text(visitor_text=visitor_text)

    for text, constraint in constraints.items():
        matches = [li for li in lines if li["text"].strip() == text]
        assert len(matches) <= 1, f"Multiple lines match {text}"
        assert len(matches) >= 1, f"No lines match {text}"

        x = matches[0]["x"]
        y = matches[0]["y"]
        assert constraint(x, y), f'Line "{text}" is wrong at x:{x}, y:{y}'


@pytest.mark.xfail(reason="known whitespace issue #2336")
@pytest.mark.enable_socket
def test_issue_2336():
    name = "Pesquisa-de-Precos-Combustiveis-novembro-2023.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(name=name)))
    page = reader.pages[0]
    actual_text = page.extract_text()
    assert "Beira Rio" in actual_text


def test_font_class_to_dict():
    font = Font(
        name = "Unknown",
        space_width=8,
        character_map={},
        encoding = "utf-16-be"
    )
    assert asdict(font) == {
        "name": "Unknown",
        "character_map": {},
        "encoding": "utf-16-be",
        "sub_type": "Unknown",
        "font_descriptor": {
            "name": "Unknown",
            "family": "Unknown",
            "weight": "Unknown",
            "ascent": 700.0,
            "descent": -200.0,
            "cap_height": 600.0,
            "x_height": 500.0,
            "italic_angle": 0.0,
            "flags": 32,
            "font_file": None,
            "bbox": (
                -100.0,
                -200.0,
                1000.0,
                900.0,
            ),
        },
        "character_widths": {"default": 500},
        "space_char": " ",
        "space_width": 8,
        "interpretable": True,
    }


@pytest.mark.enable_socket
def test_uninterpretable_type3_font(caplog):
    url = "https://github.com/user-attachments/files/18551904/UninterpretableType3Font.pdf"
    name = "UninterpretableType3Font.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    page = reader.pages[0]
    assert page.extract_text(extraction_mode="layout") == ""
    assert "PDF contains an uninterpretable font. Output will be incomplete." in caplog.messages


@pytest.mark.enable_socket
def test_layout_mode_epic_page_fonts():
    url = "https://github.com/py-pdf/pypdf/files/13836944/Epic.Page.PDF"
    name = "Epic Page.PDF"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    expected = (RESOURCE_ROOT / "Epic.Page.layout.txt").read_text(encoding="utf-8")
    assert expected == reader.pages[0].extract_text(extraction_mode="layout")


def test_layout_mode_uncommon_operators():
    # Coverage for layout mode Tc, Tz, Ts, ', ", TD, TL, and Tw
    reader = PdfReader(RESOURCE_ROOT / "toy.pdf")
    expected = (RESOURCE_ROOT / "toy.layout.txt").read_text(encoding="utf-8")
    assert expected == reader.pages[0].extract_text(extraction_mode="layout")


def test_layout_mode_character_spacing_per_glyph():
    """Tc advances the text matrix once per glyph, not once per shown string.

    Regression test for #3948. Both content streams place the same glyphs at
    the same positions per PDF 32000-1: with Courier 12pt and Tc=36 the
    ten-glyph run advances 10 * (600/1000 * 12) + 10 * 36 = 432pt, so ``BB``
    starts at x = 72 + 432 in both, abutting the run. Before the fix the TJ
    form only added Tc once (advancing the run by 9 * Tc too little), so the
    two forms disagreed.
    """

    def build(content: bytes) -> str:
        writer = PdfWriter()
        page = writer.add_blank_page(width=612, height=792)

        font = DictionaryObject()
        font[NameObject("/Type")] = NameObject("/Font")
        font[NameObject("/Subtype")] = NameObject("/Type1")
        font[NameObject("/BaseFont")] = NameObject("/Courier")
        font[NameObject("/FirstChar")] = NumberObject(32)
        font[NameObject("/LastChar")] = NumberObject(90)
        font[NameObject("/Widths")] = ArrayObject([NumberObject(600)] * 59)
        font_resources = DictionaryObject()
        font_resources[NameObject("/F1")] = writer._add_object(font)
        resources = DictionaryObject()
        resources[NameObject("/Font")] = font_resources
        page[NameObject("/Resources")] = resources

        stream = DecodedStreamObject()
        stream.set_data(content)
        page[NameObject("/Contents")] = writer._add_object(stream)

        buffer = BytesIO()
        writer.write(buffer)
        buffer.seek(0)
        return PdfReader(buffer).pages[0].extract_text(extraction_mode="layout")

    tj_form = build(b"BT /F1 12 Tf 36 Tc 72 720 Td [(AAAAAAAAAA) 0 (BB)] TJ ET")
    td_form = build(b"BT /F1 12 Tf 36 Tc 72 720 Td (AAAAAAAAAA) Tj 432 0 Td (BB) Tj ET")

    # The run ends exactly where BB begins, so BB abuts it in both streams.
    assert "AAAAAAAAAABB" in tj_form.replace(" ", "")
    assert tj_form == td_form


@pytest.mark.enable_socket
def test_layout_mode_type0_font_widths():
    # Cover both the 'int int int' and 'int [int int ...]' formats for Type0
    # /DescendantFonts /W array entries.
    url = "https://github.com/py-pdf/pypdf/files/13533204/Claim.Maker.Alerts.Guide_pg2.PDF"
    name = "Claim Maker Alerts Guide_pg2.PDF"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    expected = (RESOURCE_ROOT / "Claim Maker Alerts Guide_pg2.layout.txt").read_text(
        encoding="utf-8"
    )
    assert expected == reader.pages[0].extract_text(extraction_mode="layout")


@pytest.mark.enable_socket
def test_layout_mode_indirect_sequence_font_widths(caplog):
    # Cover the situation where the sequence for font widths is an IndirectObject
    # https://github.com/py-pdf/pypdf/pull/2788
    url = "https://github.com/user-attachments/files/16491621/2788_example.pdf"
    name = "2788_example.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    assert reader.pages[0].extract_text(extraction_mode="layout") == ""
    url = "https://github.com/user-attachments/files/16491619/2788_example_malformed.pdf"
    name = "2788_example_malformed.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    reader.pages[0].extract_text(extraction_mode="layout")
    assert any("Invalid font width definition" in message for message in caplog.messages)


def dummy_visitor_text(text, ctm, tm, fd, fs):
    pass


def test_layout_mode_warnings(caplog):
    # Check that a warning is issued when an argument is ignored
    reader = PdfReader(RESOURCE_ROOT / "hello-world.pdf")
    page = reader.pages[0]
    expected = "Argument visitor_text is ignored in layout mode"

    page.extract_text(extraction_mode="plain", visitor_text=dummy_visitor_text)
    assert expected not in caplog.messages
    page.extract_text(extraction_mode="layout", visitor_text=dummy_visitor_text)
    assert expected in caplog.messages


def test_layout_mode_undefined_font_name(caplog):
    # A Tf operator may name a font that is not declared in the page resources,
    # both inside a BT/ET block and at the top level. Layout mode should warn
    # and continue instead of raising KeyError.
    writer = PdfWriter()
    page = writer.add_blank_page(width=200, height=200)

    helvetica = DictionaryObject()
    helvetica[NameObject("/Type")] = NameObject("/Font")
    helvetica[NameObject("/Subtype")] = NameObject("/Type1")
    helvetica[NameObject("/BaseFont")] = NameObject("/Helvetica")
    font_resources = DictionaryObject()
    font_resources[NameObject("/F1")] = writer._add_object(helvetica)
    resources = DictionaryObject()
    resources[NameObject("/Font")] = font_resources
    page[NameObject("/Resources")] = resources

    # /F9 (top level) and /F2 (inside BT) are never declared; only /F1 is.
    content = DecodedStreamObject()
    content.set_data(
        b"/F9 12 Tf BT /F1 12 Tf 10 150 Td (Hi) Tj ET "
        b"BT /F2 12 Tf 10 100 Td (X) Tj ET"
    )
    page[NameObject("/Contents")] = writer._add_object(content)

    buffer = BytesIO()
    writer.write(buffer)
    buffer.seek(0)

    text = PdfReader(buffer).pages[0].extract_text(extraction_mode="layout")
    assert "Hi" in text
    assert "Font /F9 is not in the page resources." in caplog.messages
    assert "Font /F2 is not in the page resources." in caplog.messages


@pytest.mark.enable_socket
def test_space_with_one_unit_smaller_than_font_width():
    """Tests for #1328"""
    url = "https://github.com/py-pdf/pypdf/files/9498481/0004.pdf"
    name = "iss1328.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    page = reader.pages[0]
    extracted = page.extract_text()
    assert "Reporting crude oil leak.\n" in extracted


@pytest.mark.enable_socket
def test_space_position_calculation():
    """Tests for #1153"""
    url = "https://github.com/py-pdf/pypdf/files/9164743/file-0.pdf"
    name = "iss1153.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    page = reader.pages[3]
    extracted = page.extract_text()
    assert "Shortly after the Geneva BOF session, the" in extracted


def test_text_leading_height_unit():
    """Tests for #2262"""
    reader = PdfReader(RESOURCE_ROOT / "toy.pdf")
    page = reader.pages[0]
    extracted = page.extract_text()
    assert "Something[cited]\n" in extracted


def test_layout_mode_space_vertically_font_height_weight():
    """Tests layout mode with vertical space and font height weight (issue #2915)"""
    with open(RESOURCE_ROOT / "crazyones.pdf", "rb") as inputfile:
        # Load PDF file from file
        reader = PdfReader(inputfile)
        page = reader.pages[0]

        # Normal behaviour
        with open(RESOURCE_ROOT / "crazyones_layout_vertical_space.txt", "rb") as pdftext_file:
            pdftext = pdftext_file.read()

        text = page.extract_text(extraction_mode="layout", layout_mode_space_vertically=True).encode("utf-8")

        # Compare the text of the PDF to a known source
        for expected_line, actual_line in zip(text.splitlines(), pdftext.splitlines()):
            assert expected_line == actual_line

        pdftext = pdftext.replace(b"\r\n", b"\n")  # fix for windows
        assert text == pdftext

        # Blank lines are added to truly separate paragraphs
        with open(RESOURCE_ROOT / "crazyones_layout_vertical_space_font_height_weight.txt", "rb") as pdftext_file:
            pdftext = pdftext_file.read()

        text = page.extract_text(extraction_mode="layout", layout_mode_space_vertically=True,
                                 layout_mode_font_height_weight=0.85).encode("utf-8")

        # Compare the text of the PDF to a known source
        for expected_line, actual_line in zip(text.splitlines(), pdftext.splitlines()):
            assert expected_line == actual_line

        pdftext = pdftext.replace(b"\r\n", b"\n")  # fix for windows
        assert text == pdftext


@pytest.mark.enable_socket
def test_infinite_loop_arrays():
    """Tests for #2928"""
    url = "https://github.com/user-attachments/files/17576546/arrayabruptending.pdf"
    name = "arrayabruptending.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))

    page = reader.pages[0]
    extracted = page.extract_text()
    assert "RNA structure comparison" in extracted


@pytest.mark.enable_socket
def test_content_stream_is_dictionary_object(caplog):
    """Tests for #2995"""
    url = "https://github.com/user-attachments/files/18049322/6fa5fd46-5f98-4a67-800d-5e2362b0164f.pdf"
    name = "iss2995.pdf"
    data = get_data_from_url(url=url, name=name)

    reader = PdfReader(BytesIO(data))
    page = reader.pages[0]
    assert "\nYours faithfully   \n" in page.extract_text()
    assert "Expected StreamObject, got DictionaryObject instead. Data might be wrong." in caplog.text
    caplog.clear()

    reader = PdfReader(BytesIO(data), strict=True)
    page = reader.pages[0]
    with pytest.raises(PdfReadError) as exception:
        page.extract_text()
    assert (
        "Invalid Elementary Object starting with b\\'\\\\x18\\' @3557: b\\'ateDecode/Length 629\\\\x18ck["
        in exception.value.args[0]
    )


@pytest.mark.enable_socket
def test_tz_with_no_operands():
    """Tests for #2975"""
    url = "https://github.com/user-attachments/files/17974120/9E5E080E-C8DB-4A6B-822B-9A67DC04E526-120438.pdf"
    name = "iss2975.pdf"
    data = get_data_from_url(url=url, name=name)

    reader = PdfReader(BytesIO(data))
    page = reader.pages[1]
    assert "\nThankyouforyourattentiontothismatter.\n" in page.extract_text()


@pytest.mark.enable_socket
def test_iss3060():
    """Test for not throwing 'font not set: is PDF missing a Tf operator'"""
    url = "https://github.com/user-attachments/files/18482531/test-anon.pdf"
    name = "iss3060.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    # pypdf.errors.PdfReadError: font not set: is PDF missing a Tf operator?
    txt = reader.pages[0].extract_text(extraction_mode="layout")
    assert txt.startswith(" *******")


@pytest.mark.enable_socket
def test_iss3074():
    """Test for not throwing 'ZeroDivisionError: float division by zero'"""
    url = "https://github.com/user-attachments/files/18533211/test-anon.pdf"
    name = "iss3074.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    # pypdf.errors.PdfReadError: ZeroDivisionError: float division by zero
    txt = reader.pages[0].extract_text(extraction_mode="layout")
    assert txt.strip().startswith("AAAAAA")


@pytest.mark.enable_socket
def test_layout_mode_text_state():
    """Ensure the text state is stored and reset with q/Q operators."""
    # Get the PDF from issue #3212
    url = "https://github.com/user-attachments/files/19396790/garbled.pdf"
    name = "garbled-font.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    # Get the txt from issue #3212 and normalize line endings
    txt_url = "https://github.com/user-attachments/files/19510731/garbled-font.layout.txt"
    txt_name = "garbled-font.layout.txt"
    expected = get_data_from_url(url=txt_url, name=txt_name).decode("utf-8").replace("\r\n", "\n")
    # Ignore differences in rendering of spaces to work around older differences between the
    # old layout mode Font code and the new Font class in calculating and dealing with the
    # fallback width for a character that has no width defined in character_widths.
    assert expected.replace(" ", "") == reader.pages[0].extract_text(extraction_mode="layout").replace(" ", "")


@pytest.mark.enable_socket
def test_rotated_line_wrap():
    """Ensure correct 2D translation of rotated text after a line wrap."""
    # Get the PDF from issue #3247
    url = "https://github.com/user-attachments/files/19696918/link16-line-wrap.sanitized.pdf"
    name = "link16-line-wrap.sanitized.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))
    # Get the txt from issue #3247 and normalize line endings
    txt_url = "https://github.com/user-attachments/files/19696917/link16-line-wrap.sanitized.expected.txt"
    txt_name = "link16-line-wrap.sanitized.expected.txt"
    expected = get_data_from_url(url=txt_url, name=txt_name).decode("utf-8").replace("\r\n", "\n")

    assert expected == reader.pages[0].extract_text()


@pytest.mark.parametrize(
        ("op", "msg"),
        [
            (b"BT", "Unbalanced target operations, expected b'ET'."),
            (b"q", "Unbalanced target operations, expected b'Q'."),
        ],
)
def test_layout_mode_warns_on_malformed_content_stream(op, msg, caplog):
    """Ensures that imbalanced q/Q or EB/ET is handled gracefully."""
    text_show_operations(ops=iter([([], op)]), fonts={})
    assert caplog.records
    assert caplog.records[-1].getMessage() == msg


def test_text_operators_with_missing_operands():
    """Text operators carrying too few operands must not crash extraction."""
    writer = PdfWriter(clone_from=RESOURCE_ROOT / "crazyones.pdf")
    page = writer.pages[0]
    # Each malformed operator below previously raised IndexError/ValueError on
    # the default extraction path: TD/Td with a single operand, the show-and-
    # move " operator with fewer than three operands, and a bare Tf.
    content = (
        b"BT /F1 12 Tf 100 700 Td (Hello) Tj "
        b"5 TD (A) Tj "
        b"3 Td (B) Tj "
        b'(C) " '
        b"Tf (D) Tj "
        b"ET"
    )
    stream = ContentStream(stream=None, pdf=writer)
    stream.set_data(content)
    page.replace_contents(stream)
    assert "Hello" in page.extract_text()


def test_tm_operator_with_wrong_operand_count():
    """A Tm operator with the wrong number of operands must not crash extraction."""
    writer = PdfWriter(clone_from=RESOURCE_ROOT / "crazyones.pdf")
    page = writer.pages[0]
    # A short Tm left the text matrix with fewer than six entries, so the next
    # positioning operator read past its end (IndexError in mult()); a bare Tm
    # had the same effect. A non-numeric operand makes float() raise instead.
    content = (
        b"BT /F1 12 Tf 100 700 Td (Hello) Tj "
        b"1 0 0 Tm (A) Tj "
        b"T* (B) Tj "
        b"Tm (C) Tj "
        b"(x) 0 0 1 0 0 Tm (D) Tj "
        b"ET"
    )
    stream = ContentStream(stream=None, pdf=writer)
    stream.set_data(content)
    page.replace_contents(stream)
    assert "Hello" in page.extract_text()


def test_process_operation__cm_multiplication_issue():
    """Test for #3262."""
    writer = PdfWriter(clone_from=RESOURCE_ROOT / "crazyones.pdf")
    page = writer.pages[0]
    content = page.get_contents().get_data()
    content = content.replace(b" 1 0 0 1 72 720 cm ", b" 0.70278 65.3 163.36 cm ")
    stream = ContentStream(stream=None, pdf=writer)
    stream.set_data(content)
    page.replace_contents(stream)
    assert page.extract_text().startswith("The Crazy Ones\nOctober 14, 1998\n")


@pytest.mark.enable_socket
def test_rotated_layout_mode(caplog):
    """Ensures text extraction of rotated pages, as in issue #3270."""
    url = "https://github.com/user-attachments/files/19981120/rotated-page.pdf"
    name = "rotated-page.pdf"
    writer = PdfWriter(clone_from=BytesIO(get_data_from_url(url=url, name=name)))
    page = writer.pages[0]

    page.transfer_rotation_to_content()
    text = page.extract_text(extraction_mode="layout")

    assert not caplog.records, "No warnings should be issued"
    assert text, "Text matching the page rotation should be extracted"
    assert re.search(r"\r?\n +69\r?\n +UNCLASSIFIED$", text), "Contents should be in expected layout"


@pytest.mark.enable_socket
@pytest.mark.filterwarnings("ignore::pypdf.errors.PdfReadWarning")
def test_extract_text__none_objects():
    url = "https://github.com/user-attachments/files/18381726/tika-957721.pdf"
    name = "tika-957721.pdf"
    reader = PdfReader(BytesIO(get_data_from_url(url=url, name=name)))

    reader.pages[0].extract_text()
    reader.pages[8].extract_text()


@pytest.mark.enable_socket
def test_extract_text__with_visitor_text():
    def visitor_text(*args, **kwargs):  # noqa: ANN002, ANN003, ANN202
        pass

    url = "https://github.com/user-attachments/files/18381718/tika-952016.pdf"
    name = "tika-952016.pdf"
    stream = BytesIO(get_data_from_url(url=url, name=name))
    reader = PdfReader(stream)
    page = reader.pages[0]
    page.extract_text(visitor_text=visitor_text)

    reader = PdfReader(BytesIO(get_data_from_url(name="TextAttack_paper.pdf")))
    page = reader.pages[0]
    page.extract_text(visitor_text=visitor_text)


@pytest.mark.enable_socket
def test_extract_text__restore_cm_stack_pop_error():
    url = "https://github.com/user-attachments/files/18381737/tika-966635.pdf"
    name = "tika-966635.pdf"
    stream = BytesIO(get_data_from_url(url=url, name=name))
    reader = PdfReader(stream)
    page = reader.pages[10]

    # The cm stack pop error ("pop from empty list") is already omitted. This
    # page also carries a short Tm operator that used to raise IndexError in
    # mult(); it is now tolerated as well, so extraction completes.
    page.extract_text()


@pytest.mark.timeout(60)
@pytest.mark.enable_socket
def test_slow_huge_string():
    """Tests for #3541"""
    url = "https://github.com/user-attachments/files/23855795/file.pdf"
    name = "issue-3541.pdf"
    stream = BytesIO(get_data_from_url(url=url, name=name))
    reader = PdfReader(stream)
    page = reader.pages[0]

    _ = page.extract_text(extraction_mode="layout")


@pytest.mark.enable_socket
def test_extract_text_with_missing_font_bbox():
    url = "https://github.com/user-attachments/files/24611650/bbox_bug_emoji.pdf"
    name = "issue-3599.pdf"
    stream = BytesIO(get_data_from_url(url=url, name=name))
    reader = PdfReader(stream)
    page = reader.pages[0]
    text = page.extract_text()
    assert "🎉" in text


def test_recurse_to_target_op__excessive_intra_group_spacing(caplog):
    operators = [
        (["/F1", 12], b"Tf"),
        ([1, 0, 0, 1, 0, 700], b"Tm"),
        ([b"A"], b"Tj"),
        ([1, 0, 0, 1, 1000000, 700], b"Tm"),
        ([b"B"], b"Tj"),
        ([], b"ET")
    ]
    text_state_manager = TextStateManager()
    font = DictionaryObject({
        NameObject("/Type"): NameObject("/Font"),
        NameObject("/Subtype"): NameObject("/Type1"),
        NameObject("/BaseFont"): NameObject("/Helvetica"),
    })
    fonts = {"/F1": Font.from_font_resource(font)}

    bt_groups, _tj_ops = recurse_to_target_op(
        ops=iter(operators),
        text_state_mgr=text_state_manager,
        end_target=b"ET",
        fonts=fonts,
    )
    assert bt_groups == [
        {
            "displaced_tx": 1000008.004,
            "flip_sort": 1,
            "font_height": 12.0,
            "font_size": 12,
            "text": "A" + 10000 * " " + "B",
            "tx": 0.0,
            "ty": 700.0
        }
    ]
    assert caplog.messages == ["Limiting excessive whitespace from 299758 to 10000 characters."]


def test_fixed_width_page__excessive_blank_lines(caplog):
    ty_groups = {
        100: [
            BTGroup(tx=0, text="Top", displaced_tx=3, font_height=1, ty=0, font_size=12, flip_sort=1),
        ],
        # Creates 1499 blank lines:
        # (1600 - 100) / (1 * 1) - 1
        # = 1500 - 1
        # = 1499
        1600: [
            BTGroup(tx=0, text="Bottom", displaced_tx=6, font_height=1, ty=0, font_size=12, flip_sort=1)
        ],
    }

    result = fixed_width_page(
        ty_groups=ty_groups,
        char_width=1,
        space_vertically=True,
        font_height_weight=1,
    )

    lines = result.splitlines()

    assert lines[0] == "Top"
    assert lines[-1] == "Bottom"

    # 2 content lines + reduced 1000 blank lines
    assert len(lines) == 1002

    blank_lines = lines[1:-1]
    assert all(line == "" for line in blank_lines)

    assert caplog.messages == ["Limiting excessive newlines from 1499 to 1000."]


def test_fixed_width_page__excessive_needed_spaces(caplog):
    ty_groups = {
        100: [
            BTGroup(
                tx=13_000,
                text="X",
                displaced_tx=13_370,
                font_height=12,
                ty=0,
                font_size=12,
                flip_sort=1,
            )
        ]
    }

    result = fixed_width_page(
        ty_groups=ty_groups,
        char_width=1,
        space_vertically=True,
        font_height_weight=1,
    )

    assert result == " " * 10_000 + "X"
    assert caplog.messages == ["Limiting excessive whitespace from 13000 to 10000 characters."]


def test_page__extract_text__xform__self_references(caplog):
    writer = PdfWriter()
    page = writer.add_blank_page(width=10, height=10)

    form = ContentStream(stream=None, pdf=writer)
    form[NameObject("/Type")] = NameObject("/XObject")
    form[NameObject("/Subtype")] = NameObject("/Form")
    form.set_data(b"/X1 Do")
    form_reference = writer._add_object(form)
    form[NameObject("/Resources")] = DictionaryObject({
        NameObject("/XObject"): DictionaryObject({
            NameObject("/X1"): form_reference
        })
    })

    page[NameObject("/Resources")] = form[NameObject("/Resources")]
    content = ContentStream(stream=None, pdf=writer)
    content.set_data(b"q /X1 Do Q")
    page.replace_contents(content)

    assert page.extract_text() == ""
    assert caplog.messages == ["Detected cyclic form XObject reference, skipping /X1."]


@pytest.mark.parametrize(
    "encoding",
    [
        "ascii",
        {65: "A"},
    ],
    ids=["string", "dictionary"],
)
def test_text_state_params__unicode_decode_error(encoding):
    font_dictionary = DictionaryObject({
        NameObject("/Type"): NameObject("/Font"),
        NameObject("/Subtype"): NameObject("/Type1"),
        NameObject("/BaseFont"): NameObject("/Helvetica"),
    })
    font = Font.from_font_resource(font_dictionary)
    font.encoding = encoding

    # For string: 0xff (255) is out of range for ASCII (0-127)
    # For dictionary: 0xff (255) is missing from the dict, and bytes((255,)).decode()
    # throws a UnicodeDecodeError under default UTF-8 rules.
    parameters = TextStateParams(value=b"\xff", font=font, font_size=10)

    # Assertions: 'replace' mode changes invalid UTF-8 bytes to '\xfffd'.
    assert parameters.text == "\ufffd"
    assert parameters._decoded_value == "\ufffd"


@pytest.mark.timeout(5)
def test_page_object__layout_mode_fonts__cyclic(caplog) -> None:
    writer = PdfWriter()

    font = DictionaryObject({
        NameObject("/Type"): NameObject("/Font"),
        NameObject("/Subtype"): NameObject("/Type1"),
        NameObject("/BaseFont"): NameObject("/Helvetica"),
    })
    fonts = {"/F1": Font.from_font_resource(font)}
    page = writer.add_blank_page(width=10, height=10)
    dictionary2 = DictionaryObject(DictionaryObject({
        NameObject("/Resources"): DictionaryObject({
            NameObject("/Font"): DictionaryObject({
                NameObject("/F1"): font
            })
        })
    }))
    reference2 = writer._add_object(dictionary2)
    dictionary3 = DictionaryObject({NameObject("/Parent"): reference2})
    reference3 = writer._add_object(dictionary3)
    page[NameObject("/Parent")] = reference3
    dictionary2[NameObject("/Parent")] = page.indirect_reference
    page.pdf = writer

    assert page._layout_mode_fonts() == fonts
    assert caplog.messages == ["Detected cycle in /Parent hierarchy when retrieving fonts."]


def _generate_dag_with_forms(depth: int) -> bytes:
    writer = PdfWriter()
    page = writer.add_blank_page(width=612, height=792)

    font = DictionaryObject(
        {
            NameObject("/Type"): NameObject("/Font"),
            NameObject("/Subtype"): NameObject("/Type1"),
            NameObject("/BaseFont"): NameObject("/Helvetica"),
        }
    )
    font_ref = writer._add_object(font)

    # There are only depth + 1 actual Form objects:
    #
    #   F0 -> F1 -> F2 -> ... -> Fdepth
    #
    # But each form invokes its child twice:
    #
    #   F0
    #   ├── F1
    #   │   ├── F2
    #   │   │   └── ...
    #   │   └── F2
    #   └── F1
    #       ├── F2
    #       └── F2
    #
    # This gives a DAG with exponentially many traversal paths while
    # keeping the PDF itself linear in size.
    num_forms = depth + 1
    forms: list[StreamObject] = []

    for _ in range(num_forms):
        form = StreamObject()
        forms.append(form)

    # Register all forms first so that their indirect references are
    # available when constructing their resource dictionaries.
    form_refs = [writer._add_object(form) for form in forms]

    for k, form in enumerate(forms):
        if k < depth:
            next_name = NameObject(f"/F{k + 1}")
            form_content = (
                f"q\n"
                f"{next_name} Do\n"
                f"{next_name} Do\n"
                f"Q\n"
            ).encode("ascii")
            xobjects = DictionaryObject({
                next_name: form_refs[k + 1],
            })
        else:
            # Leaf form: emit a single character.
            form_content = (
                b"BT\n"
                b"/F1 12 Tf\n"
                b"100 700 Td\n"
                b"(.) Tj\n"
                b"ET\n"
            )
            xobjects = DictionaryObject()

        resources = DictionaryObject({
            NameObject("/Font"): DictionaryObject({
                NameObject("/F1"): font_ref,
            }),
        })
        if xobjects:
            resources[NameObject("/XObject")] = xobjects

        form.update({
            NameObject("/Type"): NameObject("/XObject"),
            NameObject("/Subtype"): NameObject("/Form"),
            NameObject("/FormType"): NumberObject(1),
            NameObject("/BBox"): RectangleObject([0, 0, 612, 792]),
            NameObject("/Resources"): resources,
        })
        form.set_data(form_content)

    # Invoke the root form twice.
    page_content = StreamObject()
    page_content.set_data(
        b"q\n"
        b"/F0 Do\n"
        b"/F0 Do\n"
        b"Q\n"
    )
    page_content_ref = writer._add_object(page_content)

    page_resources = DictionaryObject({
        NameObject("/XObject"): DictionaryObject({
            NameObject("/F0"): form_refs[0],
        }),
    })

    page[NameObject("/Resources")] = page_resources
    page[NameObject("/Contents")] = page_content_ref
    page[NameObject("/MediaBox")] = ArrayObject(list(map(NumberObject, [0, 0, 612, 792])))

    data = BytesIO()
    writer.write(data)
    return data.getvalue()


@pytest.mark.timeout(5)
def test_extract_text__form_xobject__limit(caplog) -> None:
    # Takes about 15 seconds without fix.
    reader = PdfReader(BytesIO(_generate_dag_with_forms(12)))
    page = reader.pages[0]
    with mock.patch("pypdf._page.MAX_XFORM_INVOCATIONS_PER_EXTRACTION", 100):
        text = page.extract_text()
    assert len(text) == 92
    assert text == ".\n" * 46
    assert caplog.messages == [
        "Exceeded 100 form XObject invocations while extracting text; further form content is skipped."
    ]


def _page_with_helvetica(content_stream: bytes) -> BytesIO:
    """Build a single page using /F1 (Helvetica) and the given content stream."""
    writer = PdfWriter()
    page = writer.add_blank_page(width=612, height=792)

    helvetica = DictionaryObject()
    helvetica[NameObject("/Type")] = NameObject("/Font")
    helvetica[NameObject("/Subtype")] = NameObject("/Type1")
    helvetica[NameObject("/BaseFont")] = NameObject("/Helvetica")
    font_resources = DictionaryObject()
    font_resources[NameObject("/F1")] = writer._add_object(helvetica)
    resources = DictionaryObject()
    resources[NameObject("/Font")] = font_resources
    page[NameObject("/Resources")] = resources

    content = DecodedStreamObject()
    content.set_data(content_stream)
    page[NameObject("/Contents")] = writer._add_object(content)

    buffer = BytesIO()
    writer.write(buffer)
    buffer.seek(0)
    return buffer


def test_text_leading_is_not_scaled_by_font_size() -> None:
    """Tests for #3982"""
    buffer = _page_with_helvetica(
        b"BT /F1 12 Tf 1 0 0 1 72 700 Tm 14 TL "
        b"(Line one) Tj T* (Line two) Tj T* (Line three) Tj ET"
    )

    positions = []

    def visitor_text(text, cm, tm, font_dict, font_size) -> None:
        if text.strip():
            positions.append(round(tm[5], 2))

    text = PdfReader(buffer).pages[0].extract_text(visitor_text=visitor_text)

    # T* moves down by the leading itself: 14 units, not 14 * 12 (the font size).
    assert positions == [700.0, 686.0, 672.0]
    assert text == "Line one\nLine two\nLine three"


def test_line_breaks_with_scaled_current_matrix() -> None:
    """Tests for #2262: the line height has to be compared in the same space."""
    # The lines are 240 units apart in text space, which the CTM scales down to
    # 12 units, matching a 200 pt font scaled down to 10 pt.
    buffer = _page_with_helvetica(
        b"q 0.05 0 0 0.05 0 0 cm "
        b"BT /F1 200 Tf 1 0 0 1 200 14000 Tm (Line one) Tj "
        b"1 0 0 1 200 13760 Tm (Line two) Tj ET Q"
    )

    assert PdfReader(buffer).pages[0].extract_text() == "Line one\nLine two"
