File size: 9,380 Bytes
b28b620
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Existing evaluation renderer, packaged without evaluation/cache dependencies."""
from __future__ import annotations

import math
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence

from PIL import Image, ImageDraw, ImageFont

@dataclass(frozen=True)
class DocumentImageConfig:
    """Exact document rendering parameters used by the vision evaluation."""

    font_path: Path
    font_index: int = 0
    font_family: str = "Roboto"
    font_style: str = "Regular"
    font_size_px: int = 16
    dpi: int = 96
    line_height_px: int | None = None
    width_px: int = 896
    min_height_px: int = 32
    height_step_px: int = 32
    max_height_px: int = 896
    max_lines_per_page: int | None = None

    def __post_init__(self) -> None:
        object.__setattr__(self, "font_path", Path(self.font_path))
        line_height_px = self.font_size_px if self.line_height_px is None else self.line_height_px
        object.__setattr__(self, "line_height_px", line_height_px)
        max_lines_per_page = (
            min(56, self.max_height_px // line_height_px)
            if self.max_lines_per_page is None
            else self.max_lines_per_page
        )
        object.__setattr__(self, "max_lines_per_page", max_lines_per_page)
        numeric_values = {
            "font_index": self.font_index,
            "font_size_px": self.font_size_px,
            "dpi": self.dpi,
            "line_height_px": line_height_px,
            "width_px": self.width_px,
            "min_height_px": self.min_height_px,
            "height_step_px": self.height_step_px,
            "max_height_px": self.max_height_px,
            "max_lines_per_page": max_lines_per_page,
        }
        for name, value in numeric_values.items():
            if name == "font_index" and value < 0:
                raise ValueError("font_index cannot be negative")
            if name != "font_index" and value < 1:
                raise ValueError(f"{name} must be positive")
        if self.min_height_px > self.max_height_px:
            raise ValueError("min_height_px cannot exceed max_height_px")
        if self.min_height_px % self.height_step_px or self.max_height_px % self.height_step_px:
            raise ValueError("page heights must be exact multiples of height_step_px")
        if line_height_px * max_lines_per_page > self.max_height_px:
            raise ValueError("max_lines_per_page does not fit in max_height_px")

    def to_dict(self) -> dict[str, Any]:
        rendering = {
            "font": f"{self.font_family} {self.font_style}",
            "font_path": str(self.font_path),
            "font_size_px": self.font_size_px,
            "dpi": self.dpi,
            "line_height_px": self.line_height_px,
            "width_px": self.width_px,
            "min_height_px": self.min_height_px,
            "height_step_px": self.height_step_px,
            "max_height_px": self.max_height_px,
            "max_lines_per_page": self.max_lines_per_page,
            "margins_px": 0,
            "background": "white",
            "foreground": "black",
            "whitespace_normalization": "all_runs_to_single_space",
            "wrap_measurement": (
                "roboto_pixel_width"
                if self.font_family.casefold() == "roboto"
                else "font_pixel_width"
            ),
            "overwide_word_split": "character",
            "page_overlap": 0,
        }
        if self.font_index:
            rendering["font_index"] = self.font_index
        return rendering


class DocumentImageRenderer:
    """Normalize, pixel-wrap, paginate, and render document text."""

    def __init__(self, config: DocumentImageConfig) -> None:
        self.config = config
        if not config.font_path.is_file():
            raise FileNotFoundError(f"font not found: {config.font_path}")
        self.font = ImageFont.truetype(
            str(config.font_path),
            config.font_size_px,
            index=config.font_index,
        )
        family, style = self.font.getname()
        if (
            family.casefold() != config.font_family.casefold()
            or style.casefold() != config.font_style.casefold()
        ):
            raise ValueError(
                f"{config.font_family} {config.font_style} is required, "
                f"but {config.font_path} reports {family} {style}"
            )

    @staticmethod
    def normalize_text(text: str) -> str:
        return " ".join(str(text).split())

    def text_length(self, text: str) -> float:
        return self.font.getlength(text)

    def draw_line(self, draw: ImageDraw.ImageDraw, line: str, y: int) -> None:
        draw.text(
            (0, y),
            line,
            font=self.font,
            fill="black",
            anchor="lt",
        )

    def rendering_metadata(self) -> dict[str, Any]:
        return self.config.to_dict()

    def _split_overwide_word(self, word: str, max_width: int) -> list[str]:
        chunks: list[str] = []
        offset = 0
        while offset < len(word):
            low, high = offset + 1, len(word)
            best = offset
            while low <= high:
                middle = (low + high) // 2
                if self.text_length(word[offset:middle]) <= max_width:
                    best = middle
                    low = middle + 1
                else:
                    high = middle - 1
            if best == offset:
                raise ValueError(
                    f"width {max_width}px is narrower than one rendered character"
                )
            chunks.append(word[offset:best])
            offset = best
        return chunks

    def wrap_text(self, text: str, *, max_width: int | None = None) -> list[str]:
        max_width = self.config.width_px if max_width is None else max_width
        if max_width < 1:
            raise ValueError("max_width must be positive")
        normalized = self.normalize_text(text)
        if not normalized:
            return []

        lines: list[str] = []
        current = ""
        for word in normalized.split(" "):
            if self.text_length(word) > max_width:
                if current:
                    lines.append(current)
                    current = ""
                chunks = self._split_overwide_word(word, max_width)
                lines.extend(chunks[:-1])
                current = chunks[-1]
                continue

            candidate = word if not current else f"{current} {word}"
            if self.text_length(candidate) <= max_width:
                current = candidate
            else:
                lines.append(current)
                current = word
        if current:
            lines.append(current)
        return lines

    def paginate_lines(self, lines: Sequence[str]) -> list[list[str]]:
        if not lines:
            return [[]]
        page_size = self.config.max_lines_per_page
        return [list(lines[start : start + page_size]) for start in range(0, len(lines), page_size)]

    def page_height(self, line_count: int) -> int:
        if line_count < 0 or line_count > self.config.max_lines_per_page:
            raise ValueError(
                f"line_count must be between 0 and {self.config.max_lines_per_page}"
            )
        content_height = max(1, line_count) * self.config.line_height_px
        stepped_height = math.ceil(content_height / self.config.height_step_px) * self.config.height_step_px
        return min(
            self.config.max_height_px,
            max(self.config.min_height_px, stepped_height),
        )

    def render_document(self, text: str) -> list[Image.Image]:
        lines = self.wrap_text(text)
        rendered: list[Image.Image] = []
        for page_lines in self.paginate_lines(lines):
            image = Image.new(
                "RGB",
                (self.config.width_px, self.page_height(len(page_lines))),
                "white",
            )
            draw = ImageDraw.Draw(image)
            for line_index, line in enumerate(page_lines):
                self.draw_line(draw, line, line_index * self.config.line_height_px)
            rendered.append(image)
        return rendered



def prepare_render_inputs(inputs: dict, renderer: DocumentImageRenderer) -> dict:
    """Convert render documents to images; leave query/instruction/scoring untouched."""
    if not isinstance(inputs, dict):
        raise TypeError("inputs must be a dictionary")
    documents = inputs.get("documents", [])
    if not isinstance(documents, list):
        raise TypeError("documents must be a list")
    converted = []
    for document in documents:
        if not isinstance(document, dict):
            raise TypeError("each document must be a dictionary")
        if "render" not in document:
            converted.append(document)
            continue
        if any(key in document for key in ("text", "image", "video")):
            raise ValueError("render cannot be combined with text, image, or video")
        if not isinstance(document["render"], str):
            raise TypeError("render must contain document text as a string")
        converted.append({
            **{key: value for key, value in document.items() if key != "render"},
            "image": renderer.render_document(document["render"]),
        })
    return {**inputs, "documents": converted}