abstractcore 2.5.2__py3-none-any.whl → 2.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abstractcore/__init__.py +19 -1
- abstractcore/architectures/detection.py +252 -6
- abstractcore/assets/architecture_formats.json +14 -1
- abstractcore/assets/model_capabilities.json +533 -10
- abstractcore/compression/__init__.py +29 -0
- abstractcore/compression/analytics.py +420 -0
- abstractcore/compression/cache.py +250 -0
- abstractcore/compression/config.py +279 -0
- abstractcore/compression/exceptions.py +30 -0
- abstractcore/compression/glyph_processor.py +381 -0
- abstractcore/compression/optimizer.py +388 -0
- abstractcore/compression/orchestrator.py +380 -0
- abstractcore/compression/pil_text_renderer.py +818 -0
- abstractcore/compression/quality.py +226 -0
- abstractcore/compression/text_formatter.py +666 -0
- abstractcore/compression/vision_compressor.py +371 -0
- abstractcore/config/main.py +64 -0
- abstractcore/config/manager.py +100 -5
- abstractcore/core/retry.py +2 -2
- abstractcore/core/session.py +193 -7
- abstractcore/download.py +253 -0
- abstractcore/embeddings/manager.py +2 -2
- abstractcore/events/__init__.py +113 -2
- abstractcore/exceptions/__init__.py +49 -2
- abstractcore/media/auto_handler.py +312 -18
- abstractcore/media/handlers/local_handler.py +14 -2
- abstractcore/media/handlers/openai_handler.py +62 -3
- abstractcore/media/processors/__init__.py +11 -1
- abstractcore/media/processors/direct_pdf_processor.py +210 -0
- abstractcore/media/processors/glyph_pdf_processor.py +227 -0
- abstractcore/media/processors/image_processor.py +7 -1
- abstractcore/media/processors/office_processor.py +2 -2
- abstractcore/media/processors/text_processor.py +18 -3
- abstractcore/media/types.py +164 -7
- abstractcore/media/utils/image_scaler.py +2 -2
- abstractcore/media/vision_fallback.py +2 -2
- abstractcore/providers/__init__.py +18 -0
- abstractcore/providers/anthropic_provider.py +228 -8
- abstractcore/providers/base.py +378 -11
- abstractcore/providers/huggingface_provider.py +563 -23
- abstractcore/providers/lmstudio_provider.py +284 -4
- abstractcore/providers/mlx_provider.py +27 -2
- abstractcore/providers/model_capabilities.py +352 -0
- abstractcore/providers/ollama_provider.py +282 -6
- abstractcore/providers/openai_provider.py +286 -8
- abstractcore/providers/registry.py +85 -13
- abstractcore/providers/streaming.py +2 -2
- abstractcore/server/app.py +91 -81
- abstractcore/tools/common_tools.py +2 -2
- abstractcore/tools/handler.py +2 -2
- abstractcore/tools/parser.py +2 -2
- abstractcore/tools/registry.py +2 -2
- abstractcore/tools/syntax_rewriter.py +2 -2
- abstractcore/tools/tag_rewriter.py +3 -3
- abstractcore/utils/__init__.py +4 -1
- abstractcore/utils/self_fixes.py +2 -2
- abstractcore/utils/trace_export.py +287 -0
- abstractcore/utils/version.py +1 -1
- abstractcore/utils/vlm_token_calculator.py +655 -0
- {abstractcore-2.5.2.dist-info → abstractcore-2.6.0.dist-info}/METADATA +207 -8
- abstractcore-2.6.0.dist-info/RECORD +108 -0
- abstractcore-2.5.2.dist-info/RECORD +0 -90
- {abstractcore-2.5.2.dist-info → abstractcore-2.6.0.dist-info}/WHEEL +0 -0
- {abstractcore-2.5.2.dist-info → abstractcore-2.6.0.dist-info}/entry_points.txt +0 -0
- {abstractcore-2.5.2.dist-info → abstractcore-2.6.0.dist-info}/licenses/LICENSE +0 -0
- {abstractcore-2.5.2.dist-info → abstractcore-2.6.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,818 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PIL/Pillow-based text renderer with proper bold and italic font support.
|
|
3
|
+
|
|
4
|
+
This renderer directly creates images using PIL/Pillow, giving us complete control
|
|
5
|
+
over font rendering and text layout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import tempfile
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
from .config import GlyphConfig, RenderingConfig
|
|
15
|
+
from .text_formatter import TextSegment
|
|
16
|
+
from .exceptions import RenderingError
|
|
17
|
+
from ..utils.structured_logging import get_logger
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PILTextRenderer:
|
|
21
|
+
"""Direct text renderer using PIL/Pillow with proper font support."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: GlyphConfig):
|
|
24
|
+
"""
|
|
25
|
+
Initialize PIL text renderer.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: Glyph configuration
|
|
29
|
+
"""
|
|
30
|
+
self.config = config
|
|
31
|
+
self.logger = get_logger(self.__class__.__name__)
|
|
32
|
+
|
|
33
|
+
# Check dependencies
|
|
34
|
+
self._check_dependencies()
|
|
35
|
+
|
|
36
|
+
# Track if we're using OCRB or OCRA fonts for special bold handling
|
|
37
|
+
self.using_ocrb_fonts = False
|
|
38
|
+
self.using_ocra_fonts = False
|
|
39
|
+
|
|
40
|
+
self.logger.debug("PILTextRenderer initialized")
|
|
41
|
+
|
|
42
|
+
def _get_effective_dimensions(self, config: RenderingConfig) -> tuple[int, int]:
|
|
43
|
+
"""Get effective image dimensions (target dimensions or VLM-optimized defaults)."""
|
|
44
|
+
if config.target_width and config.target_height:
|
|
45
|
+
return (config.target_width, config.target_height)
|
|
46
|
+
else:
|
|
47
|
+
# VLM-optimized defaults: 1024x1024 works well with most vision models
|
|
48
|
+
# - Fits within Claude 3.5 Sonnet (1568x1568 max)
|
|
49
|
+
# - Efficient for GPT-4o tokenization (~1800 tokens)
|
|
50
|
+
# - Square aspect ratio (1:1) for consistent layout
|
|
51
|
+
# - Supported by all major VLM families
|
|
52
|
+
return (1024, 1024)
|
|
53
|
+
|
|
54
|
+
def _check_dependencies(self):
|
|
55
|
+
"""Check if required dependencies are available."""
|
|
56
|
+
try:
|
|
57
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
58
|
+
except ImportError as e:
|
|
59
|
+
raise RenderingError(
|
|
60
|
+
f"PIL/Pillow not available: {e}. Install with: pip install pillow"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _estimate_text_capacity(self, config: RenderingConfig, fonts: dict) -> int:
|
|
64
|
+
"""
|
|
65
|
+
Estimate how many characters can fit in the target image dimensions.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
config: Rendering configuration
|
|
69
|
+
fonts: Loaded fonts dictionary
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Estimated character capacity
|
|
73
|
+
"""
|
|
74
|
+
img_width, img_height = self._get_effective_dimensions(config)
|
|
75
|
+
|
|
76
|
+
# Calculate available space
|
|
77
|
+
available_width = img_width - 2 * config.margin_x
|
|
78
|
+
available_height = img_height - 2 * config.margin_y
|
|
79
|
+
|
|
80
|
+
# Account for multi-column layout
|
|
81
|
+
columns = max(1, config.columns)
|
|
82
|
+
column_gap = config.column_gap if columns > 1 else 0
|
|
83
|
+
column_width = (available_width - (columns - 1) * column_gap) / columns
|
|
84
|
+
|
|
85
|
+
# Estimate character dimensions using regular font
|
|
86
|
+
regular_font = fonts.get('regular')
|
|
87
|
+
if regular_font:
|
|
88
|
+
# Use average character width (test with common characters)
|
|
89
|
+
test_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 "
|
|
90
|
+
try:
|
|
91
|
+
# Create temporary image to measure text
|
|
92
|
+
from PIL import Image, ImageDraw
|
|
93
|
+
temp_img = Image.new('RGB', (1, 1))
|
|
94
|
+
temp_draw = ImageDraw.Draw(temp_img)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
bbox = temp_draw.textbbox((0, 0), test_chars, font=regular_font)
|
|
98
|
+
total_width = bbox[2] - bbox[0]
|
|
99
|
+
except AttributeError:
|
|
100
|
+
# Fallback for older PIL versions
|
|
101
|
+
total_width = temp_draw.textsize(test_chars, font=regular_font)[0]
|
|
102
|
+
|
|
103
|
+
avg_char_width = total_width / len(test_chars)
|
|
104
|
+
except:
|
|
105
|
+
# Fallback estimate
|
|
106
|
+
avg_char_width = config.font_size * 0.6
|
|
107
|
+
else:
|
|
108
|
+
# Fallback estimate
|
|
109
|
+
avg_char_width = config.font_size * 0.6
|
|
110
|
+
|
|
111
|
+
# Estimate line capacity
|
|
112
|
+
line_height = int(config.line_height * 1.3) # Match spacing calculation
|
|
113
|
+
chars_per_line = int(column_width / avg_char_width)
|
|
114
|
+
lines_per_column = int((available_height * 1.1 + line_height) / line_height)
|
|
115
|
+
total_lines = lines_per_column * columns
|
|
116
|
+
|
|
117
|
+
# More realistic capacity estimation
|
|
118
|
+
# The previous 30% efficiency was way too conservative
|
|
119
|
+
if min(img_width, img_height) < 600:
|
|
120
|
+
# For small images like 448x448, use more realistic estimate
|
|
121
|
+
efficiency_factor = 0.75 # Much higher to use available space
|
|
122
|
+
else:
|
|
123
|
+
efficiency_factor = 0.85
|
|
124
|
+
|
|
125
|
+
estimated_capacity = int(chars_per_line * total_lines * efficiency_factor)
|
|
126
|
+
|
|
127
|
+
self.logger.debug(f"Text capacity estimation: {estimated_capacity} chars "
|
|
128
|
+
f"({chars_per_line} chars/line × {total_lines} lines × {efficiency_factor} efficiency)")
|
|
129
|
+
|
|
130
|
+
return max(estimated_capacity, 500) # Minimum 500 chars per page for small images
|
|
131
|
+
|
|
132
|
+
def _split_segments_into_pages(self, segments: List[TextSegment], capacity_per_page: int) -> List[List[TextSegment]]:
|
|
133
|
+
"""
|
|
134
|
+
Split text segments into pages based on estimated capacity.
|
|
135
|
+
Handles large segments by splitting them if needed.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
segments: List of TextSegment objects
|
|
139
|
+
capacity_per_page: Estimated character capacity per page
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
List of pages, each containing a list of segments
|
|
143
|
+
"""
|
|
144
|
+
pages = []
|
|
145
|
+
current_page = []
|
|
146
|
+
current_page_chars = 0
|
|
147
|
+
|
|
148
|
+
for segment in segments:
|
|
149
|
+
segment_length = len(segment.text)
|
|
150
|
+
|
|
151
|
+
# If this single segment is larger than page capacity, split it
|
|
152
|
+
if segment_length > capacity_per_page:
|
|
153
|
+
# Finish current page if it has content
|
|
154
|
+
if current_page:
|
|
155
|
+
pages.append(current_page)
|
|
156
|
+
current_page = []
|
|
157
|
+
current_page_chars = 0
|
|
158
|
+
|
|
159
|
+
# Split the large segment into chunks
|
|
160
|
+
text = segment.text
|
|
161
|
+
while text:
|
|
162
|
+
# Take a chunk that fits the capacity
|
|
163
|
+
chunk_size = min(capacity_per_page, len(text))
|
|
164
|
+
|
|
165
|
+
# Try to break at word boundaries if possible
|
|
166
|
+
if chunk_size < len(text):
|
|
167
|
+
# Look for a space within the last 20% of the chunk
|
|
168
|
+
search_start = max(0, int(chunk_size * 0.8))
|
|
169
|
+
space_pos = text.rfind(' ', search_start, chunk_size)
|
|
170
|
+
if space_pos > search_start:
|
|
171
|
+
chunk_size = space_pos + 1 # Include the space
|
|
172
|
+
|
|
173
|
+
chunk_text = text[:chunk_size]
|
|
174
|
+
text = text[chunk_size:]
|
|
175
|
+
|
|
176
|
+
# Create new segment with same formatting
|
|
177
|
+
chunk_segment = TextSegment(
|
|
178
|
+
text=chunk_text,
|
|
179
|
+
is_bold=segment.is_bold,
|
|
180
|
+
is_italic=segment.is_italic,
|
|
181
|
+
is_header=segment.is_header,
|
|
182
|
+
header_level=segment.header_level
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Add as a new page
|
|
186
|
+
pages.append([chunk_segment])
|
|
187
|
+
|
|
188
|
+
else:
|
|
189
|
+
# Normal segment handling
|
|
190
|
+
# Check if adding this segment would exceed capacity
|
|
191
|
+
if current_page_chars + segment_length > capacity_per_page and current_page:
|
|
192
|
+
# Start new page
|
|
193
|
+
pages.append(current_page)
|
|
194
|
+
current_page = [segment]
|
|
195
|
+
current_page_chars = segment_length
|
|
196
|
+
else:
|
|
197
|
+
# Add to current page
|
|
198
|
+
current_page.append(segment)
|
|
199
|
+
current_page_chars += segment_length
|
|
200
|
+
|
|
201
|
+
# Add the last page if it has content
|
|
202
|
+
if current_page:
|
|
203
|
+
pages.append(current_page)
|
|
204
|
+
|
|
205
|
+
# Log pagination statistics
|
|
206
|
+
if pages:
|
|
207
|
+
page_sizes = [sum(len(s.text) for s in page) for page in pages]
|
|
208
|
+
avg_size = sum(page_sizes) / len(pages)
|
|
209
|
+
min_size = min(page_sizes)
|
|
210
|
+
max_size = max(page_sizes)
|
|
211
|
+
|
|
212
|
+
self.logger.debug(f"Split {len(segments)} segments into {len(pages)} pages")
|
|
213
|
+
self.logger.debug(f"Page sizes - avg: {avg_size:.0f}, min: {min_size}, max: {max_size} chars")
|
|
214
|
+
|
|
215
|
+
# Warn about very small pages (less than 20% of capacity)
|
|
216
|
+
small_pages = [i+1 for i, size in enumerate(page_sizes) if size < capacity_per_page * 0.2]
|
|
217
|
+
if small_pages:
|
|
218
|
+
self.logger.warning(f"Small pages detected (< 20% capacity): {small_pages[:5]}{'...' if len(small_pages) > 5 else ''}")
|
|
219
|
+
|
|
220
|
+
return pages
|
|
221
|
+
|
|
222
|
+
def segments_to_images(
|
|
223
|
+
self,
|
|
224
|
+
segments: List[TextSegment],
|
|
225
|
+
config: RenderingConfig,
|
|
226
|
+
output_dir: Optional[str] = None,
|
|
227
|
+
unique_id: Optional[str] = None
|
|
228
|
+
) -> List[Path]:
|
|
229
|
+
"""
|
|
230
|
+
Convert TextSegment objects to images using PIL/Pillow.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
segments: List of TextSegment objects with formatting
|
|
234
|
+
config: Rendering configuration
|
|
235
|
+
output_dir: Output directory for images
|
|
236
|
+
unique_id: Unique identifier for this rendering
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
List of paths to rendered images
|
|
240
|
+
"""
|
|
241
|
+
try:
|
|
242
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
243
|
+
|
|
244
|
+
# Setup output directory
|
|
245
|
+
if output_dir is None:
|
|
246
|
+
output_dir = tempfile.mkdtemp(prefix="glyph_pil_")
|
|
247
|
+
else:
|
|
248
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
249
|
+
|
|
250
|
+
output_dir = Path(output_dir)
|
|
251
|
+
unique_id = unique_id or "render"
|
|
252
|
+
|
|
253
|
+
# Load fonts
|
|
254
|
+
fonts = self._load_fonts(config)
|
|
255
|
+
|
|
256
|
+
# Estimate text capacity and split into pages if needed
|
|
257
|
+
capacity_per_page = self._estimate_text_capacity(config, fonts)
|
|
258
|
+
total_chars = sum(len(segment.text) for segment in segments)
|
|
259
|
+
|
|
260
|
+
self.logger.debug(f"Text pagination: {total_chars} total chars, "
|
|
261
|
+
f"{capacity_per_page} chars/page capacity")
|
|
262
|
+
|
|
263
|
+
if total_chars > capacity_per_page:
|
|
264
|
+
# Split into multiple pages
|
|
265
|
+
pages = self._split_segments_into_pages(segments, capacity_per_page)
|
|
266
|
+
self.logger.info(f"Text split into {len(pages)} pages for rendering")
|
|
267
|
+
else:
|
|
268
|
+
# Single page
|
|
269
|
+
pages = [segments]
|
|
270
|
+
self.logger.debug("Text fits in single page")
|
|
271
|
+
|
|
272
|
+
# Render each page
|
|
273
|
+
image_paths = []
|
|
274
|
+
|
|
275
|
+
for page_idx, page_segments in enumerate(pages):
|
|
276
|
+
self.logger.debug(f"Rendering page {page_idx + 1}/{len(pages)} "
|
|
277
|
+
f"({len(page_segments)} segments, "
|
|
278
|
+
f"{sum(len(s.text) for s in page_segments)} chars)")
|
|
279
|
+
|
|
280
|
+
# Calculate text layout for this page
|
|
281
|
+
columns_data = self._layout_text(page_segments, fonts, config)
|
|
282
|
+
|
|
283
|
+
# Get target dimensions
|
|
284
|
+
img_width, img_height = self._get_effective_dimensions(config)
|
|
285
|
+
|
|
286
|
+
# Create image with exact dimensions
|
|
287
|
+
# Use white background for better compression
|
|
288
|
+
image = Image.new('RGB', (img_width, img_height), 'white')
|
|
289
|
+
|
|
290
|
+
# Set DPI information on the image
|
|
291
|
+
dpi_tuple = (config.dpi, config.dpi)
|
|
292
|
+
image.info['dpi'] = dpi_tuple
|
|
293
|
+
|
|
294
|
+
draw = ImageDraw.Draw(image)
|
|
295
|
+
|
|
296
|
+
# Render text for this page
|
|
297
|
+
self._render_text_to_image(draw, columns_data, fonts, config)
|
|
298
|
+
|
|
299
|
+
# Save image with page number
|
|
300
|
+
image_path = output_dir / f"{unique_id}_page_{page_idx + 1}.png"
|
|
301
|
+
image.save(image_path, 'PNG', optimize=True, dpi=dpi_tuple)
|
|
302
|
+
image_paths.append(image_path)
|
|
303
|
+
|
|
304
|
+
self.logger.debug(f"Generated page {page_idx + 1}: {image_path} ({img_width}x{img_height})")
|
|
305
|
+
|
|
306
|
+
self.logger.info(f"Rendered {len(pages)} pages total")
|
|
307
|
+
return image_paths
|
|
308
|
+
|
|
309
|
+
except Exception as e:
|
|
310
|
+
self.logger.error(f"PIL text rendering failed: {e}")
|
|
311
|
+
raise RenderingError(f"Failed to render text with PIL: {e}") from e
|
|
312
|
+
|
|
313
|
+
def _load_fonts(self, config: RenderingConfig) -> dict:
|
|
314
|
+
"""Load fonts for different styles with custom font support."""
|
|
315
|
+
from PIL import ImageFont
|
|
316
|
+
import os
|
|
317
|
+
|
|
318
|
+
fonts = {}
|
|
319
|
+
font_size = config.font_size
|
|
320
|
+
|
|
321
|
+
self.logger.debug(f"Loading fonts with size: {font_size}")
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
# Check if custom font path is specified
|
|
325
|
+
if config.font_path and os.path.exists(config.font_path):
|
|
326
|
+
self.logger.info(f"Using custom font path: {config.font_path}")
|
|
327
|
+
try:
|
|
328
|
+
# Try to load the custom font for all styles
|
|
329
|
+
base_font = ImageFont.truetype(config.font_path, font_size)
|
|
330
|
+
fonts['regular'] = base_font
|
|
331
|
+
fonts['bold'] = base_font # Use same font for all styles
|
|
332
|
+
fonts['italic'] = base_font
|
|
333
|
+
fonts['bold_italic'] = base_font
|
|
334
|
+
|
|
335
|
+
self.logger.info(f"Successfully loaded custom font: {config.font_path}")
|
|
336
|
+
return fonts
|
|
337
|
+
except Exception as e:
|
|
338
|
+
self.logger.warning(f"Failed to load custom font {config.font_path}: {e}. Falling back to system fonts.")
|
|
339
|
+
|
|
340
|
+
# Check if custom font name is specified
|
|
341
|
+
if config.font_name:
|
|
342
|
+
self.logger.info(f"Trying to load font by name: {config.font_name}")
|
|
343
|
+
|
|
344
|
+
# Special handling for OCRB font family
|
|
345
|
+
if config.font_name.upper() == "OCRB":
|
|
346
|
+
return self._load_ocrb_font_family(font_size)
|
|
347
|
+
|
|
348
|
+
# Special handling for OCRA font family
|
|
349
|
+
if config.font_name.upper() == "OCRA":
|
|
350
|
+
return self._load_ocra_font_family(font_size)
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
# Try to load by name (works on some systems)
|
|
354
|
+
base_font = ImageFont.truetype(config.font_name, font_size)
|
|
355
|
+
fonts['regular'] = base_font
|
|
356
|
+
fonts['bold'] = base_font
|
|
357
|
+
fonts['italic'] = base_font
|
|
358
|
+
fonts['bold_italic'] = base_font
|
|
359
|
+
|
|
360
|
+
self.logger.info(f"Successfully loaded font by name: {config.font_name}")
|
|
361
|
+
return fonts
|
|
362
|
+
except Exception as e:
|
|
363
|
+
self.logger.warning(f"Failed to load font by name {config.font_name}: {e}. Falling back to system fonts.")
|
|
364
|
+
|
|
365
|
+
# Fall back to system fonts with good readability
|
|
366
|
+
self.logger.info("Using default system fonts")
|
|
367
|
+
font_paths = {
|
|
368
|
+
'regular': [
|
|
369
|
+
'/System/Library/Fonts/Helvetica.ttc', # macOS
|
|
370
|
+
'/System/Library/Fonts/Arial.ttf',
|
|
371
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', # Linux
|
|
372
|
+
'arial.ttf' # Windows
|
|
373
|
+
],
|
|
374
|
+
'bold': [
|
|
375
|
+
'/System/Library/Fonts/Helvetica.ttc', # Contains bold variant
|
|
376
|
+
'/System/Library/Fonts/Arial Bold.ttf',
|
|
377
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', # Linux
|
|
378
|
+
'arialbd.ttf' # Windows
|
|
379
|
+
],
|
|
380
|
+
'italic': [
|
|
381
|
+
'/System/Library/Fonts/Helvetica.ttc', # Contains italic variant
|
|
382
|
+
'/System/Library/Fonts/Arial Italic.ttf',
|
|
383
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf', # Linux
|
|
384
|
+
'ariali.ttf' # Windows
|
|
385
|
+
],
|
|
386
|
+
'bold_italic': [
|
|
387
|
+
'/System/Library/Fonts/Helvetica.ttc', # Contains bold italic variant
|
|
388
|
+
'/System/Library/Fonts/Arial Bold Italic.ttf',
|
|
389
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans-BoldOblique.ttf', # Linux
|
|
390
|
+
'arialbi.ttf' # Windows
|
|
391
|
+
]
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
# Load regular font
|
|
395
|
+
fonts['regular'] = self._load_font_from_paths(font_paths['regular'], font_size, 'regular')
|
|
396
|
+
|
|
397
|
+
# Load bold font
|
|
398
|
+
fonts['bold'] = self._load_font_from_paths(font_paths['bold'], font_size, 'bold')
|
|
399
|
+
|
|
400
|
+
# Load italic font
|
|
401
|
+
fonts['italic'] = self._load_font_from_paths(font_paths['italic'], font_size, 'italic')
|
|
402
|
+
|
|
403
|
+
# Load bold italic font
|
|
404
|
+
fonts['bold_italic'] = self._load_font_from_paths(font_paths['bold_italic'], font_size, 'bold_italic')
|
|
405
|
+
|
|
406
|
+
self.logger.info(f"Loaded PIL fonts: regular={fonts['regular'] is not None}, "
|
|
407
|
+
f"bold={fonts['bold'] is not None}, italic={fonts['italic'] is not None}")
|
|
408
|
+
|
|
409
|
+
except Exception as e:
|
|
410
|
+
self.logger.warning(f"Failed to load system fonts, using default: {e}")
|
|
411
|
+
# Fallback to default font
|
|
412
|
+
fonts = {
|
|
413
|
+
'regular': ImageFont.load_default(),
|
|
414
|
+
'bold': ImageFont.load_default(),
|
|
415
|
+
'italic': ImageFont.load_default(),
|
|
416
|
+
'bold_italic': ImageFont.load_default()
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return fonts
|
|
420
|
+
|
|
421
|
+
def _load_ocrb_font_family(self, font_size: int) -> dict:
|
|
422
|
+
"""Load OCRB font family with proper regular and italic variants."""
|
|
423
|
+
from PIL import ImageFont
|
|
424
|
+
import os
|
|
425
|
+
|
|
426
|
+
fonts = {}
|
|
427
|
+
|
|
428
|
+
# Define paths to OCRB font files relative to the package
|
|
429
|
+
try:
|
|
430
|
+
# Get the path to the assets directory
|
|
431
|
+
assets_dir = Path(__file__).parent.parent / "assets"
|
|
432
|
+
ocrb_regular_path = assets_dir / "OCRB.ttf"
|
|
433
|
+
ocrb_italic_path = assets_dir / "OCRBL.ttf"
|
|
434
|
+
|
|
435
|
+
self.logger.info(f"Loading OCRB font family from assets directory")
|
|
436
|
+
|
|
437
|
+
# Load regular font
|
|
438
|
+
if ocrb_regular_path.exists():
|
|
439
|
+
fonts['regular'] = ImageFont.truetype(str(ocrb_regular_path), font_size)
|
|
440
|
+
fonts['bold'] = ImageFont.truetype(str(ocrb_regular_path), font_size) # Use regular for bold
|
|
441
|
+
self.logger.info(f"Loaded OCRB regular font: {ocrb_regular_path}")
|
|
442
|
+
else:
|
|
443
|
+
self.logger.warning(f"OCRB regular font not found at: {ocrb_regular_path}")
|
|
444
|
+
fonts['regular'] = ImageFont.load_default()
|
|
445
|
+
fonts['bold'] = ImageFont.load_default()
|
|
446
|
+
|
|
447
|
+
# Load italic font (OCRBL)
|
|
448
|
+
if ocrb_italic_path.exists():
|
|
449
|
+
fonts['italic'] = ImageFont.truetype(str(ocrb_italic_path), font_size)
|
|
450
|
+
fonts['bold_italic'] = ImageFont.truetype(str(ocrb_italic_path), font_size) # Use italic for bold-italic
|
|
451
|
+
self.logger.info(f"Loaded OCRB italic font: {ocrb_italic_path}")
|
|
452
|
+
else:
|
|
453
|
+
self.logger.warning(f"OCRB italic font not found at: {ocrb_italic_path}")
|
|
454
|
+
fonts['italic'] = fonts['regular'] # Fall back to regular
|
|
455
|
+
fonts['bold_italic'] = fonts['regular']
|
|
456
|
+
|
|
457
|
+
self.logger.info("Successfully loaded OCRB font family with proper italic support")
|
|
458
|
+
self.using_ocrb_fonts = True # Set flag for special bold handling
|
|
459
|
+
return fonts
|
|
460
|
+
|
|
461
|
+
except Exception as e:
|
|
462
|
+
self.logger.error(f"Failed to load OCRB font family: {e}")
|
|
463
|
+
# Fall back to default fonts
|
|
464
|
+
return {
|
|
465
|
+
'regular': ImageFont.load_default(),
|
|
466
|
+
'bold': ImageFont.load_default(),
|
|
467
|
+
'italic': ImageFont.load_default(),
|
|
468
|
+
'bold_italic': ImageFont.load_default()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
def _load_ocra_font_family(self, font_size: int) -> dict:
|
|
472
|
+
"""Load OCRA font family with special handling (no italic variant available)."""
|
|
473
|
+
from PIL import ImageFont
|
|
474
|
+
import os
|
|
475
|
+
|
|
476
|
+
fonts = {}
|
|
477
|
+
|
|
478
|
+
# Define path to OCRA font file relative to the package
|
|
479
|
+
try:
|
|
480
|
+
# Get the path to the assets directory
|
|
481
|
+
assets_dir = Path(__file__).parent.parent / "assets"
|
|
482
|
+
ocra_regular_path = assets_dir / "OCRA.ttf"
|
|
483
|
+
|
|
484
|
+
self.logger.info(f"Loading OCRA font family from assets directory")
|
|
485
|
+
|
|
486
|
+
# Load regular font (OCRA only has one variant)
|
|
487
|
+
if ocra_regular_path.exists():
|
|
488
|
+
fonts['regular'] = ImageFont.truetype(str(ocra_regular_path), font_size)
|
|
489
|
+
fonts['bold'] = ImageFont.truetype(str(ocra_regular_path), font_size) # Use regular for bold
|
|
490
|
+
fonts['italic'] = ImageFont.truetype(str(ocra_regular_path), font_size) # Use regular for italic
|
|
491
|
+
fonts['bold_italic'] = ImageFont.truetype(str(ocra_regular_path), font_size) # Use regular for bold-italic
|
|
492
|
+
self.logger.info(f"Loaded OCRA regular font: {ocra_regular_path}")
|
|
493
|
+
else:
|
|
494
|
+
self.logger.warning(f"OCRA regular font not found at: {ocra_regular_path}")
|
|
495
|
+
fonts['regular'] = ImageFont.load_default()
|
|
496
|
+
fonts['bold'] = ImageFont.load_default()
|
|
497
|
+
fonts['italic'] = ImageFont.load_default()
|
|
498
|
+
fonts['bold_italic'] = ImageFont.load_default()
|
|
499
|
+
|
|
500
|
+
self.logger.info("Successfully loaded OCRA font family (using regular font for all styles)")
|
|
501
|
+
self.using_ocra_fonts = True # Set flag for special bold handling
|
|
502
|
+
return fonts
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
self.logger.error(f"Failed to load OCRA font family: {e}")
|
|
506
|
+
# Fall back to default fonts
|
|
507
|
+
return {
|
|
508
|
+
'regular': ImageFont.load_default(),
|
|
509
|
+
'bold': ImageFont.load_default(),
|
|
510
|
+
'italic': ImageFont.load_default(),
|
|
511
|
+
'bold_italic': ImageFont.load_default()
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
def _load_font_from_paths(self, paths: List[str], size: int, style: str = 'regular'):
|
|
515
|
+
"""Try to load font from a list of possible paths."""
|
|
516
|
+
from PIL import ImageFont
|
|
517
|
+
|
|
518
|
+
for path in paths:
|
|
519
|
+
try:
|
|
520
|
+
if Path(path).exists():
|
|
521
|
+
# For .ttc files (TrueType Collections), try different indices for different styles
|
|
522
|
+
if path.endswith('.ttc'):
|
|
523
|
+
# Map styles to font indices in Helvetica.ttc
|
|
524
|
+
style_indices = {
|
|
525
|
+
'regular': 0, # Helvetica Regular
|
|
526
|
+
'bold': 1, # Helvetica Bold
|
|
527
|
+
'italic': 2, # Helvetica Oblique
|
|
528
|
+
'bold_italic': 3 # Helvetica Bold Oblique
|
|
529
|
+
}
|
|
530
|
+
index = style_indices.get(style, 0)
|
|
531
|
+
try:
|
|
532
|
+
font = ImageFont.truetype(path, size, index=index)
|
|
533
|
+
self.logger.debug(f"Loaded {style} font from {path} index {index}")
|
|
534
|
+
return font
|
|
535
|
+
except Exception as e:
|
|
536
|
+
self.logger.warning(f"Failed to load {style} from {path} index {index}: {e}")
|
|
537
|
+
continue
|
|
538
|
+
else:
|
|
539
|
+
font = ImageFont.truetype(path, size)
|
|
540
|
+
self.logger.debug(f"Loaded {style} font from {path}")
|
|
541
|
+
return font
|
|
542
|
+
except Exception as e:
|
|
543
|
+
self.logger.debug(f"Failed to load font from {path}: {e}")
|
|
544
|
+
continue
|
|
545
|
+
|
|
546
|
+
# Fallback to default
|
|
547
|
+
self.logger.warning(f"Using default font for {style}")
|
|
548
|
+
return ImageFont.load_default()
|
|
549
|
+
|
|
550
|
+
def _layout_text(self, segments: List[TextSegment], fonts: dict, config: RenderingConfig) -> List[List[List[dict]]]:
|
|
551
|
+
"""
|
|
552
|
+
Layout text segments into columns and lines with word wrapping.
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
List of columns, where each column is a list of lines,
|
|
556
|
+
and each line is a list of text chunks with formatting info
|
|
557
|
+
"""
|
|
558
|
+
lines = []
|
|
559
|
+
current_line = []
|
|
560
|
+
current_line_width = 0
|
|
561
|
+
|
|
562
|
+
# Calculate available width using effective dimensions
|
|
563
|
+
img_width, img_height = self._get_effective_dimensions(config)
|
|
564
|
+
margin_x = config.margin_x
|
|
565
|
+
available_width = img_width - 2 * margin_x
|
|
566
|
+
|
|
567
|
+
# Handle multi-column layout properly
|
|
568
|
+
columns = max(1, config.columns)
|
|
569
|
+
column_gap = config.column_gap if columns > 1 else 0
|
|
570
|
+
column_width = (available_width - (columns - 1) * column_gap) / columns
|
|
571
|
+
|
|
572
|
+
self.logger.debug(f"Layout: img_width={img_width}, available_width={available_width}, "
|
|
573
|
+
f"columns={columns}, column_width={column_width}")
|
|
574
|
+
|
|
575
|
+
for segment in segments:
|
|
576
|
+
if segment.text == "\n":
|
|
577
|
+
# Force line break
|
|
578
|
+
if current_line:
|
|
579
|
+
lines.append(current_line)
|
|
580
|
+
current_line = []
|
|
581
|
+
current_line_width = 0
|
|
582
|
+
continue
|
|
583
|
+
|
|
584
|
+
# Get appropriate font
|
|
585
|
+
font = self._get_font_for_segment(segment, fonts)
|
|
586
|
+
|
|
587
|
+
# Handle space-only segments (like " " from single newlines)
|
|
588
|
+
if segment.text.strip() == "":
|
|
589
|
+
# This is a space-only segment - preserve it
|
|
590
|
+
space_width = self._get_text_width(segment.text, font, segment)
|
|
591
|
+
if current_line_width + space_width <= column_width:
|
|
592
|
+
# Add spaces to current line
|
|
593
|
+
current_line.append({
|
|
594
|
+
'text': segment.text,
|
|
595
|
+
'font': font,
|
|
596
|
+
'segment': segment
|
|
597
|
+
})
|
|
598
|
+
current_line_width += space_width
|
|
599
|
+
else:
|
|
600
|
+
# Start new line with spaces
|
|
601
|
+
if current_line:
|
|
602
|
+
lines.append(current_line)
|
|
603
|
+
current_line = [{
|
|
604
|
+
'text': segment.text,
|
|
605
|
+
'font': font,
|
|
606
|
+
'segment': segment
|
|
607
|
+
}]
|
|
608
|
+
current_line_width = space_width
|
|
609
|
+
continue
|
|
610
|
+
|
|
611
|
+
# Split segment text into words but preserve spaces more carefully
|
|
612
|
+
# Use a simple approach: split on spaces but keep track of them
|
|
613
|
+
import re
|
|
614
|
+
|
|
615
|
+
# Split while preserving spaces - use regex to capture spaces
|
|
616
|
+
parts = re.split(r'(\s+)', segment.text)
|
|
617
|
+
|
|
618
|
+
for part in parts:
|
|
619
|
+
if not part: # Skip empty parts
|
|
620
|
+
continue
|
|
621
|
+
|
|
622
|
+
part_width = self._get_text_width(part, font, segment)
|
|
623
|
+
|
|
624
|
+
# Check if part fits on current line
|
|
625
|
+
if current_line_width + part_width <= column_width or not current_line:
|
|
626
|
+
# Add to current line
|
|
627
|
+
current_line.append({
|
|
628
|
+
'text': part,
|
|
629
|
+
'font': font,
|
|
630
|
+
'segment': segment
|
|
631
|
+
})
|
|
632
|
+
current_line_width += part_width
|
|
633
|
+
else:
|
|
634
|
+
# Start new line
|
|
635
|
+
if current_line:
|
|
636
|
+
lines.append(current_line)
|
|
637
|
+
current_line = [{
|
|
638
|
+
'text': part,
|
|
639
|
+
'font': font,
|
|
640
|
+
'segment': segment
|
|
641
|
+
}]
|
|
642
|
+
current_line_width = part_width
|
|
643
|
+
|
|
644
|
+
# Add final line
|
|
645
|
+
if current_line:
|
|
646
|
+
lines.append(current_line)
|
|
647
|
+
|
|
648
|
+
# Now distribute lines among columns
|
|
649
|
+
if columns == 1:
|
|
650
|
+
return [lines] # Single column
|
|
651
|
+
|
|
652
|
+
# Multi-column: distribute lines evenly
|
|
653
|
+
column_data = [[] for _ in range(columns)]
|
|
654
|
+
lines_per_column = len(lines) // columns
|
|
655
|
+
extra_lines = len(lines) % columns
|
|
656
|
+
|
|
657
|
+
line_idx = 0
|
|
658
|
+
for col in range(columns):
|
|
659
|
+
lines_in_this_column = lines_per_column + (1 if col < extra_lines else 0)
|
|
660
|
+
column_data[col] = lines[line_idx:line_idx + lines_in_this_column]
|
|
661
|
+
line_idx += lines_in_this_column
|
|
662
|
+
|
|
663
|
+
return column_data
|
|
664
|
+
|
|
665
|
+
def _get_font_for_segment(self, segment: TextSegment, fonts: dict):
|
|
666
|
+
"""Get the appropriate font for a text segment."""
|
|
667
|
+
if segment.is_bold and segment.is_italic:
|
|
668
|
+
return fonts['bold_italic']
|
|
669
|
+
elif segment.is_header or segment.is_bold:
|
|
670
|
+
# Headers should ONLY be bold, never italic
|
|
671
|
+
return fonts['bold']
|
|
672
|
+
elif segment.is_italic:
|
|
673
|
+
return fonts['italic']
|
|
674
|
+
else:
|
|
675
|
+
return fonts['regular']
|
|
676
|
+
|
|
677
|
+
def _get_text_width(self, text: str, font, segment=None) -> int:
|
|
678
|
+
"""Get the width of text in pixels, accounting for stroke effects."""
|
|
679
|
+
from PIL import Image, ImageDraw
|
|
680
|
+
|
|
681
|
+
if not text:
|
|
682
|
+
return 0
|
|
683
|
+
|
|
684
|
+
# Create temporary image to measure text
|
|
685
|
+
temp_img = Image.new('RGB', (1, 1))
|
|
686
|
+
temp_draw = ImageDraw.Draw(temp_img)
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
|
690
|
+
base_width = bbox[2] - bbox[0]
|
|
691
|
+
except AttributeError:
|
|
692
|
+
# Fallback for older PIL versions
|
|
693
|
+
try:
|
|
694
|
+
base_width = temp_draw.textsize(text, font=font)[0]
|
|
695
|
+
except AttributeError:
|
|
696
|
+
# Ultimate fallback - estimate based on font size
|
|
697
|
+
base_width = len(text) * config.font_size * 0.6
|
|
698
|
+
|
|
699
|
+
# Add extra width for OCRB and OCRA bold overlay effect
|
|
700
|
+
if ((self.using_ocrb_fonts or self.using_ocra_fonts) and segment and
|
|
701
|
+
(segment.is_bold or segment.is_header) and not segment.is_italic):
|
|
702
|
+
# Add width for enhanced horizontal overlays (0.6 pixel max offset)
|
|
703
|
+
return int(base_width + 0.6)
|
|
704
|
+
|
|
705
|
+
return base_width
|
|
706
|
+
|
|
707
|
+
def _get_text_height(self, font) -> int:
|
|
708
|
+
"""Get the height of text in pixels."""
|
|
709
|
+
from PIL import Image, ImageDraw
|
|
710
|
+
|
|
711
|
+
# Create temporary image to measure text
|
|
712
|
+
temp_img = Image.new('RGB', (1, 1))
|
|
713
|
+
temp_draw = ImageDraw.Draw(temp_img)
|
|
714
|
+
|
|
715
|
+
try:
|
|
716
|
+
bbox = temp_draw.textbbox((0, 0), "Ag", font=font)
|
|
717
|
+
return bbox[3] - bbox[1]
|
|
718
|
+
except:
|
|
719
|
+
# Fallback for older PIL versions
|
|
720
|
+
return temp_draw.textsize("Ag", font=font)[1]
|
|
721
|
+
|
|
722
|
+
def _calculate_image_size(self, columns_data: List[List[List[dict]]], fonts: dict, config: RenderingConfig) -> Tuple[int, int]:
|
|
723
|
+
"""Calculate the required image size for multi-column layout with DPI scaling."""
|
|
724
|
+
if not columns_data or not any(columns_data):
|
|
725
|
+
return (config.page_width, self._scale_dimension(100, config)) # Minimum size
|
|
726
|
+
|
|
727
|
+
# Calculate width (use page width - no scaling needed as it's already in points)
|
|
728
|
+
width = config.page_width
|
|
729
|
+
|
|
730
|
+
# Calculate height based on the tallest column with DPI scaling
|
|
731
|
+
scaled_line_height = self._scale_dimension(config.line_height, config)
|
|
732
|
+
scaled_line_spacing = int(scaled_line_height * 1.3) # 30% more space between lines
|
|
733
|
+
scaled_margin_y = self._scale_dimension(config.margin_y, config)
|
|
734
|
+
scaled_padding = self._scale_dimension(20, config)
|
|
735
|
+
|
|
736
|
+
max_lines_in_column = max(len(column) for column in columns_data)
|
|
737
|
+
total_height = max_lines_in_column * scaled_line_spacing + 2 * scaled_margin_y + scaled_padding
|
|
738
|
+
|
|
739
|
+
# Add some padding
|
|
740
|
+
min_height = self._scale_dimension(100, config)
|
|
741
|
+
height = max(total_height, min_height)
|
|
742
|
+
|
|
743
|
+
self.logger.debug(f"Image size calculation: columns={len(columns_data)}, "
|
|
744
|
+
f"max_lines_in_column={max_lines_in_column}, "
|
|
745
|
+
f"scaled_line_height={scaled_line_height}, scaled_line_spacing={scaled_line_spacing}, height={height}")
|
|
746
|
+
|
|
747
|
+
return (int(width), int(height))
|
|
748
|
+
|
|
749
|
+
def _render_text_to_image(self, draw, columns_data: List[List[List[dict]]], fonts: dict, config: RenderingConfig):
|
|
750
|
+
"""Render text columns to the image with proper multi-column support."""
|
|
751
|
+
line_height = config.line_height
|
|
752
|
+
line_spacing = int(line_height * 1.3) # Match the spacing calculation
|
|
753
|
+
|
|
754
|
+
# Calculate column layout using effective dimensions
|
|
755
|
+
columns = len(columns_data)
|
|
756
|
+
img_width, img_height = self._get_effective_dimensions(config)
|
|
757
|
+
available_width = img_width - 2 * config.margin_x
|
|
758
|
+
column_gap = config.column_gap if columns > 1 else 0
|
|
759
|
+
column_width = (available_width - (columns - 1) * column_gap) / columns
|
|
760
|
+
|
|
761
|
+
# Render each column
|
|
762
|
+
for col_idx, column_lines in enumerate(columns_data):
|
|
763
|
+
# Calculate column x position
|
|
764
|
+
column_x = config.margin_x + col_idx * (column_width + column_gap)
|
|
765
|
+
|
|
766
|
+
# Render this column
|
|
767
|
+
y = config.margin_y
|
|
768
|
+
for line in column_lines:
|
|
769
|
+
x = column_x
|
|
770
|
+
|
|
771
|
+
for chunk in line:
|
|
772
|
+
text = chunk['text']
|
|
773
|
+
font = chunk['font']
|
|
774
|
+
segment = chunk.get('segment')
|
|
775
|
+
|
|
776
|
+
# Draw text with special handling for OCRB bold
|
|
777
|
+
self._draw_text_with_effects(draw, (x, y), text, font, segment)
|
|
778
|
+
|
|
779
|
+
# Move x position
|
|
780
|
+
x += self._get_text_width(text, font, segment)
|
|
781
|
+
|
|
782
|
+
# Move to next line
|
|
783
|
+
y += line_spacing
|
|
784
|
+
|
|
785
|
+
def _draw_text_with_effects(self, draw, position, text, font, segment):
|
|
786
|
+
"""Draw text with special effects for OCRB and OCRA bold text."""
|
|
787
|
+
x, y = position
|
|
788
|
+
|
|
789
|
+
# Check if this should be bold and we're using OCRB or OCRA fonts
|
|
790
|
+
if ((self.using_ocrb_fonts or self.using_ocra_fonts) and segment and
|
|
791
|
+
(segment.is_bold or segment.is_header) and not segment.is_italic):
|
|
792
|
+
|
|
793
|
+
# Use improved bold effect for OCRB and OCRA text
|
|
794
|
+
# Method: Enhanced multiple overlays for more visible bold effect
|
|
795
|
+
try:
|
|
796
|
+
# Draw the base text
|
|
797
|
+
draw.text((x, y), text, font=font, fill='black')
|
|
798
|
+
|
|
799
|
+
# Add horizontal overlays for width (increased for 10% more visibility)
|
|
800
|
+
draw.text((x + 0.2, y), text, font=font, fill='black')
|
|
801
|
+
draw.text((x + 0.4, y), text, font=font, fill='black')
|
|
802
|
+
draw.text((x + 0.6, y), text, font=font, fill='black') # Additional overlay
|
|
803
|
+
|
|
804
|
+
# Add vertical overlays for height (enhanced for better visibility)
|
|
805
|
+
draw.text((x, y - 0.1), text, font=font, fill='black')
|
|
806
|
+
draw.text((x, y + 0.1), text, font=font, fill='black') # Bottom overlay
|
|
807
|
+
|
|
808
|
+
# Add diagonal overlays for smoother appearance
|
|
809
|
+
draw.text((x + 0.1, y - 0.05), text, font=font, fill='black')
|
|
810
|
+
|
|
811
|
+
except Exception as e:
|
|
812
|
+
# Fallback: just draw regular text if anything fails
|
|
813
|
+
font_type = "OCRB" if self.using_ocrb_fonts else "OCRA"
|
|
814
|
+
self.logger.debug(f"{font_type} bold effect failed, using regular text: {e}")
|
|
815
|
+
draw.text((x, y), text, font=font, fill='black')
|
|
816
|
+
else:
|
|
817
|
+
# Regular text drawing
|
|
818
|
+
draw.text((x, y), text, font=font, fill='black')
|