File size: 9,839 Bytes
f66643d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
"""Regression tests for arbitrary page-range rendering + output-only-translated.

The render layer used to conflate the compacted-list enumerate index with the
original page number, so any range not starting at page 0 silently rendered
nothing. These tests pin the fixed behavior:
  - a range like [2, 3, 4] (not starting at 0) renders correctly, and
  - the output PDF contains ONLY the translated pages, in order.
"""

import shutil
import sys
from pathlib import Path

import fitz
import pytest

sys.path.insert(0, str(Path(__file__).parent.parent))

from pdf2zh.render.config import RenderConfig
from pdf2zh.render.renderer import render_document

TYPST = shutil.which("typst")


def _make_pdf(path: Path, n_pages: int) -> None:
    doc = fitz.open()
    for i in range(n_pages):
        page = doc.new_page(width=300, height=200)
        page.insert_text((20, 40), f"Original page {i} native text")
    doc.save(str(path))
    doc.close()


def _compacted_parsed(page_indices: list[int]) -> dict:
    """A parsed doc compacted to `page_indices` (mirrors parse_pdf(pages=...))."""
    return {
        "pages": [
            {
                "page_index": pi,
                "page_width": 300,
                "page_height": 200,
                "elements": [
                    {
                        "category": "FLOWING_TEXT",
                        "label": "Text",
                        "bbox_pdf": [20, 30, 280, 55],
                        "source_text": f"Original page {pi} native text",
                        "translated_text": f"Trang dịch số {pi}",
                    }
                ],
            }
            for pi in page_indices
        ]
    }


@pytest.mark.skipif(TYPST is None, reason="typst binary not installed")
class TestArbitraryPageRange:
    def test_midrange_renders_and_outputs_only_selected(self, tmp_path):
        pdf_path = tmp_path / "src.pdf"
        _make_pdf(pdf_path, n_pages=6)

        pages = [2, 3, 4]  # 0-based, NOT starting at 0
        parsed = _compacted_parsed(pages)
        cfg = RenderConfig(typst_binary=TYPST)
        cfg.pages = pages
        out_pdf = tmp_path / "out.pdf"

        stats = render_document(pdf_path, parsed, out_pdf, cfg)

        # Rendered all 3 selected pages (the pre-fix bug rendered 0).
        assert stats["pages"] == 3
        assert stats["elements_rendered"] == 3

        # Output contains ONLY the translated pages, not the full 6-page doc.
        out = fitz.open(str(out_pdf))
        try:
            assert out.page_count == 3
            # Translated text present; the out-of-range page-0 text must be gone.
            assert "Trang dịch số 2" in out[0].get_text()
            assert "Original page 0" not in out[0].get_text()
        finally:
            out.close()

    def test_pages_none_keeps_all_pages(self, tmp_path):
        pdf_path = tmp_path / "src.pdf"
        _make_pdf(pdf_path, n_pages=3)

        parsed = _compacted_parsed([0, 1, 2])
        cfg = RenderConfig(typst_binary=TYPST)
        cfg.pages = None  # translate whole document
        out_pdf = tmp_path / "out.pdf"

        render_document(pdf_path, parsed, out_pdf, cfg)

        out = fitz.open(str(out_pdf))
        try:
            assert out.page_count == 3
        finally:
            out.close()


_PARA = (
    "This paragraph is long enough in the source that autofit picks a modest "
    "font size and the box is treated as multi line rather than single line."
)
_LONG = _PARA + " " + _PARA + " " + _PARA  # translated overflows the tight box


def _para_span_stats(pdf: Path):
    doc = fitz.open(str(pdf))
    try:
        sizes, bottoms = [], []
        for b in doc[0].get_text("dict")["blocks"]:
            for line in b.get("lines", []):
                for s in line["spans"]:
                    if "paragraph" in s["text"] or "multi" in s["text"]:
                        sizes.append(s["size"])
                        bottoms.append(s["bbox"][3])
        return (max(sizes) if sizes else 0.0), (max(bottoms) if bottoms else 0.0)
    finally:
        doc.close()


@pytest.mark.skipif(TYPST is None, reason="typst binary not installed")
class TestCollisionAwareSizing:
    """Text keeps its size and overflows into empty space, but shrinks to avoid
    colliding with a neighbor below (mirrors the sizing heuristic)."""

    def _render(self, tmp_path, with_neighbor):
        pdf_path = tmp_path / f"src_{with_neighbor}.pdf"
        doc = fitz.open()
        doc.new_page(width=400, height=400)
        doc.save(str(pdf_path))
        doc.close()

        els = [
            {
                "label": "Text",
                "category": "FLOWING_TEXT",
                "bbox_pdf": [40, 40, 360, 110],
                "source_text": _PARA,
                "translated_text": _LONG,
                "cells": [],
            }
        ]
        if with_neighbor:
            els.append(
                {
                    "label": "Text",
                    "category": "FLOWING_TEXT",
                    "bbox_pdf": [40, 116, 360, 150],
                    "source_text": "N",
                    "translated_text": "Neighbor line",
                    "cells": [],
                }
            )
        parsed = {
            "pages": [
                {
                    "page_index": 0,
                    "page_width": 400,
                    "page_height": 400,
                    "elements": els,
                }
            ]
        }
        out = tmp_path / f"out_{with_neighbor}.pdf"
        render_document(pdf_path, parsed, out, RenderConfig(typst_binary=TYPST))
        return _para_span_stats(out)

    def test_no_neighbor_keeps_larger_size_and_overflows_down(self, tmp_path):
        size_free, bottom_free = self._render(tmp_path, with_neighbor=False)
        size_near, _ = self._render(tmp_path, with_neighbor=True)
        # Empty space below → keeps a bigger font than when a neighbor forces a shrink.
        assert size_free > size_near + 0.5
        # And the text is allowed to overflow below the tight bbox (y1 = 110).
        assert bottom_free > 110

    _LONG_TITLE = (
        "A very long translated chapter title that would run past the page number "
        "column if it were allowed to expand all the way to the right page margin"
    )

    def _title_right_edge(self, tmp_path, with_number):
        pdf_path = tmp_path / f"toc_{with_number}.pdf"
        doc = fitz.open()
        doc.new_page(width=595, height=842)
        doc.save(str(pdf_path))
        doc.close()

        els = [
            {
                "label": "Text",
                "category": "FLOWING_TEXT",
                "bbox_pdf": [80, 40, 290, 56],  # single-line title
                "source_text": "Short",
                "translated_text": self._LONG_TITLE,
                "cells": [],
            }
        ]
        if with_number:
            els.append(
                {
                    "label": "Text",
                    "category": "FLOWING_TEXT",
                    "bbox_pdf": [527, 40, 543, 53],  # page number, same row
                    "source_text": "ii",
                    "translated_text": "ii",
                    "cells": [],
                }
            )
        parsed = {
            "pages": [
                {
                    "page_index": 0,
                    "page_width": 595,
                    "page_height": 842,
                    "elements": els,
                }
            ]
        }
        out = tmp_path / f"toc_out_{with_number}.pdf"
        render_document(pdf_path, parsed, out, RenderConfig(typst_binary=TYPST))
        doc = fitz.open(str(out))
        try:
            right = 0.0
            for b in doc[0].get_text("dict")["blocks"]:
                for line in b.get("lines", []):
                    for s in line["spans"]:
                        if s["text"].strip() != "ii":
                            right = max(right, s["bbox"][2])
            return right
        finally:
            doc.close()

    def test_single_line_title_stops_before_right_neighbor(self, tmp_path):
        with_num = self._title_right_edge(tmp_path, with_number=True)
        without_num = self._title_right_edge(tmp_path, with_number=False)
        # With a page number on the right, the title must not overrun its left edge.
        assert with_num <= 527.5
        # Without it, the title is free to use the rest of the page width.
        assert without_num > with_num


class TestColorOverride:
    """User-added boxes may carry explicit bg/text colors (review.add_element)."""

    def test_sample_colors_honors_element_overrides(self, tmp_path):
        from pdf2zh.render.renderer import _sample_colors

        pdf = tmp_path / "p.pdf"
        _make_pdf(pdf, 1)
        parsed = {
            "pages": [
                {
                    "page_index": 0,
                    "page_width": 300,
                    "page_height": 200,
                    "elements": [
                        {
                            "category": "FLOWING_TEXT",
                            "label": "Text",
                            "bbox_pdf": [20, 30, 120, 55],
                            "source_text": "x",
                            "translated_text": "y",
                            "bg_color": [10, 20, 30],
                            "text_color": [200, 100, 50],
                        }
                    ],
                }
            ]
        }
        bg_colors: dict = {}
        text_colors: dict = {}
        stats = {"bg_samples": 0}
        _sample_colors(pdf, parsed, RenderConfig(), {}, bg_colors, text_colors, stats)
        # Override used verbatim — no sampling from the page pixels.
        assert bg_colors["p0:e0"] == (10, 20, 30)
        assert text_colors["p0:e0"] == (200, 100, 50)