abstractcore 2.5.2__py3-none-any.whl → 2.5.3__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.
Files changed (50) hide show
  1. abstractcore/__init__.py +12 -0
  2. abstractcore/architectures/detection.py +250 -4
  3. abstractcore/assets/architecture_formats.json +14 -1
  4. abstractcore/assets/model_capabilities.json +533 -10
  5. abstractcore/compression/__init__.py +29 -0
  6. abstractcore/compression/analytics.py +420 -0
  7. abstractcore/compression/cache.py +250 -0
  8. abstractcore/compression/config.py +279 -0
  9. abstractcore/compression/exceptions.py +30 -0
  10. abstractcore/compression/glyph_processor.py +381 -0
  11. abstractcore/compression/optimizer.py +388 -0
  12. abstractcore/compression/orchestrator.py +380 -0
  13. abstractcore/compression/pil_text_renderer.py +818 -0
  14. abstractcore/compression/quality.py +226 -0
  15. abstractcore/compression/text_formatter.py +666 -0
  16. abstractcore/compression/vision_compressor.py +371 -0
  17. abstractcore/config/main.py +64 -0
  18. abstractcore/config/manager.py +100 -5
  19. abstractcore/core/session.py +61 -6
  20. abstractcore/events/__init__.py +1 -1
  21. abstractcore/media/auto_handler.py +312 -18
  22. abstractcore/media/handlers/local_handler.py +14 -2
  23. abstractcore/media/handlers/openai_handler.py +62 -3
  24. abstractcore/media/processors/__init__.py +11 -1
  25. abstractcore/media/processors/direct_pdf_processor.py +210 -0
  26. abstractcore/media/processors/glyph_pdf_processor.py +227 -0
  27. abstractcore/media/processors/image_processor.py +7 -1
  28. abstractcore/media/processors/text_processor.py +18 -3
  29. abstractcore/media/types.py +164 -7
  30. abstractcore/providers/__init__.py +18 -0
  31. abstractcore/providers/anthropic_provider.py +28 -2
  32. abstractcore/providers/base.py +278 -6
  33. abstractcore/providers/huggingface_provider.py +563 -23
  34. abstractcore/providers/lmstudio_provider.py +38 -2
  35. abstractcore/providers/mlx_provider.py +27 -2
  36. abstractcore/providers/model_capabilities.py +352 -0
  37. abstractcore/providers/ollama_provider.py +38 -4
  38. abstractcore/providers/openai_provider.py +28 -2
  39. abstractcore/providers/registry.py +85 -13
  40. abstractcore/server/app.py +91 -81
  41. abstractcore/utils/__init__.py +4 -1
  42. abstractcore/utils/trace_export.py +287 -0
  43. abstractcore/utils/version.py +1 -1
  44. abstractcore/utils/vlm_token_calculator.py +655 -0
  45. {abstractcore-2.5.2.dist-info → abstractcore-2.5.3.dist-info}/METADATA +107 -6
  46. {abstractcore-2.5.2.dist-info → abstractcore-2.5.3.dist-info}/RECORD +50 -33
  47. {abstractcore-2.5.2.dist-info → abstractcore-2.5.3.dist-info}/WHEEL +0 -0
  48. {abstractcore-2.5.2.dist-info → abstractcore-2.5.3.dist-info}/entry_points.txt +0 -0
  49. {abstractcore-2.5.2.dist-info → abstractcore-2.5.3.dist-info}/licenses/LICENSE +0 -0
  50. {abstractcore-2.5.2.dist-info → abstractcore-2.5.3.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')