natural-pdf 0.1.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.
Files changed (52) hide show
  1. natural_pdf/__init__.py +55 -0
  2. natural_pdf/analyzers/__init__.py +6 -0
  3. natural_pdf/analyzers/layout/__init__.py +1 -0
  4. natural_pdf/analyzers/layout/base.py +151 -0
  5. natural_pdf/analyzers/layout/docling.py +247 -0
  6. natural_pdf/analyzers/layout/layout_analyzer.py +166 -0
  7. natural_pdf/analyzers/layout/layout_manager.py +200 -0
  8. natural_pdf/analyzers/layout/layout_options.py +78 -0
  9. natural_pdf/analyzers/layout/paddle.py +240 -0
  10. natural_pdf/analyzers/layout/surya.py +151 -0
  11. natural_pdf/analyzers/layout/tatr.py +251 -0
  12. natural_pdf/analyzers/layout/yolo.py +165 -0
  13. natural_pdf/analyzers/text_options.py +60 -0
  14. natural_pdf/analyzers/text_structure.py +270 -0
  15. natural_pdf/analyzers/utils.py +57 -0
  16. natural_pdf/core/__init__.py +3 -0
  17. natural_pdf/core/element_manager.py +457 -0
  18. natural_pdf/core/highlighting_service.py +698 -0
  19. natural_pdf/core/page.py +1444 -0
  20. natural_pdf/core/pdf.py +653 -0
  21. natural_pdf/elements/__init__.py +3 -0
  22. natural_pdf/elements/base.py +761 -0
  23. natural_pdf/elements/collections.py +1345 -0
  24. natural_pdf/elements/line.py +140 -0
  25. natural_pdf/elements/rect.py +122 -0
  26. natural_pdf/elements/region.py +1793 -0
  27. natural_pdf/elements/text.py +304 -0
  28. natural_pdf/ocr/__init__.py +56 -0
  29. natural_pdf/ocr/engine.py +104 -0
  30. natural_pdf/ocr/engine_easyocr.py +179 -0
  31. natural_pdf/ocr/engine_paddle.py +204 -0
  32. natural_pdf/ocr/engine_surya.py +171 -0
  33. natural_pdf/ocr/ocr_manager.py +191 -0
  34. natural_pdf/ocr/ocr_options.py +114 -0
  35. natural_pdf/qa/__init__.py +3 -0
  36. natural_pdf/qa/document_qa.py +396 -0
  37. natural_pdf/selectors/__init__.py +4 -0
  38. natural_pdf/selectors/parser.py +354 -0
  39. natural_pdf/templates/__init__.py +1 -0
  40. natural_pdf/templates/ocr_debug.html +517 -0
  41. natural_pdf/utils/__init__.py +3 -0
  42. natural_pdf/utils/highlighting.py +12 -0
  43. natural_pdf/utils/reading_order.py +227 -0
  44. natural_pdf/utils/visualization.py +223 -0
  45. natural_pdf/widgets/__init__.py +4 -0
  46. natural_pdf/widgets/frontend/viewer.js +88 -0
  47. natural_pdf/widgets/viewer.py +765 -0
  48. natural_pdf-0.1.0.dist-info/METADATA +295 -0
  49. natural_pdf-0.1.0.dist-info/RECORD +52 -0
  50. natural_pdf-0.1.0.dist-info/WHEEL +5 -0
  51. natural_pdf-0.1.0.dist-info/licenses/LICENSE +21 -0
  52. natural_pdf-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,765 @@
1
+ # natural_pdf/widgets/viewer.py
2
+
3
+ import ipywidgets as widgets
4
+ from traitlets import Unicode, List, Dict, observe
5
+ import os
6
+ import logging # Add logging
7
+ import json
8
+ from IPython.display import display, HTML, Javascript
9
+ import uuid
10
+ from PIL import Image
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # --- Read JS code from file --- #
15
+ _MODULE_DIR = os.path.dirname(__file__)
16
+ _FRONTEND_JS_PATH = os.path.join(_MODULE_DIR, 'frontend', 'viewer.js')
17
+
18
+ try:
19
+ with open(_FRONTEND_JS_PATH, 'r', encoding='utf-8') as f:
20
+ _FRONTEND_JS_CODE = f.read()
21
+ logger.debug(f"Successfully read frontend JS from: {_FRONTEND_JS_PATH}")
22
+ except FileNotFoundError:
23
+ logger.error(f"Frontend JS file not found at {_FRONTEND_JS_PATH}. Widget will likely fail.")
24
+ _FRONTEND_JS_CODE = "console.error('Frontend JS file not found! Widget cannot load.');"
25
+ except Exception as e:
26
+ logger.error(f"Error reading frontend JS file {_FRONTEND_JS_PATH}: {e}")
27
+ _FRONTEND_JS_CODE = f"console.error('Error reading frontend JS file: {e}');"
28
+
29
+ class SimpleInteractiveViewerWidget(widgets.DOMWidget):
30
+ def __init__(self, pdf_data=None, **kwargs):
31
+ """
32
+ Create a simple interactive PDF viewer widget.
33
+
34
+ Args:
35
+ pdf_data (dict, optional): Dictionary containing 'page_image', 'elements', etc.
36
+ **kwargs: Additional parameters including image_uri, elements, etc.
37
+ """
38
+ super().__init__()
39
+
40
+ # Support both pdf_data dict and individual kwargs
41
+ if pdf_data:
42
+ self.pdf_data = pdf_data
43
+ # Ensure backward compatibility - if image_uri exists but page_image doesn't
44
+ if 'image_uri' in pdf_data and 'page_image' not in pdf_data:
45
+ self.pdf_data['page_image'] = pdf_data['image_uri']
46
+ else:
47
+ # Check for image_uri in kwargs
48
+ image_source = kwargs.get('image_uri', '')
49
+
50
+ self.pdf_data = {
51
+ 'page_image': image_source,
52
+ 'elements': kwargs.get('elements', [])
53
+ }
54
+
55
+ # Log for debugging
56
+ logger.debug(f"SimpleInteractiveViewerWidget initialized with widget_id={id(self)}")
57
+ logger.debug(f"Image source provided: {self.pdf_data.get('page_image', 'None')[:30]}...")
58
+ logger.debug(f"Number of elements: {len(self.pdf_data.get('elements', []))}")
59
+
60
+ self.widget_id = f"pdf-viewer-{str(uuid.uuid4())[:8]}"
61
+ self._generate_html()
62
+
63
+ def _generate_html(self):
64
+ """Generate the HTML for the PDF viewer"""
65
+ # Extract data - Coordinates in self.pdf_data['elements'] are already scaled
66
+ page_image = self.pdf_data.get('page_image', '')
67
+ elements = self.pdf_data.get('elements', [])
68
+
69
+ logger.debug(f"Generating HTML with image: {page_image[:30]}... and {len(elements)} elements (using scaled coords)")
70
+
71
+ # Create the container div
72
+ container_html = f"""
73
+ <div id="{self.widget_id}" class="pdf-viewer" style="position: relative; font-family: Arial, sans-serif;">
74
+ <div class="toolbar" style="margin-bottom: 10px; padding: 5px; background-color: #f0f0f0; border-radius: 4px;">
75
+ <button id="{self.widget_id}-zoom-in" style="margin-right: 5px;">Zoom In (+)</button>
76
+ <button id="{self.widget_id}-zoom-out" style="margin-right: 5px;">Zoom Out (-)</button>
77
+ <button id="{self.widget_id}-reset-zoom" style="margin-right: 5px;">Reset</button>
78
+ </div>
79
+ <div style="display: flex; flex-direction: row;">
80
+ <div class="pdf-outer-container" style="position: relative; overflow: hidden; border: 1px solid #ccc; flex-grow: 1;">
81
+ <div id="{self.widget_id}-zoom-pan-container" class="zoom-pan-container" style="position: relative; width: fit-content; height: fit-content; transform-origin: top left; cursor: grab;">
82
+ <!-- The image is rendered at scale, so its dimensions match scaled coordinates -->
83
+ <img src="{page_image}" style="display: block; max-width: none; height: auto;" />
84
+ <div id="{self.widget_id}-elements-layer" class="elements-layer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;">
85
+ """
86
+
87
+ # Add SVG overlay layer
88
+ container_html += f"""
89
+ </div>
90
+ <div id="{self.widget_id}-svg-layer" class="svg-layer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;">
91
+ <!-- SVG viewport should match the scaled image size -->
92
+ <svg width="100%" height="100%">
93
+ """
94
+
95
+ # Add elements and SVG boxes using the SCALED coordinates
96
+ for i, element in enumerate(elements):
97
+ element_type = element.get('type', 'unknown')
98
+ # Use the already scaled coordinates
99
+ x0 = element.get('x0', 0)
100
+ y0 = element.get('y0', 0)
101
+ x1 = element.get('x1', 0)
102
+ y1 = element.get('y1', 0)
103
+
104
+ # Calculate width and height from scaled coords
105
+ width = x1 - x0
106
+ height = y1 - y0
107
+
108
+ # Create the element div with the right styling based on type
109
+ # Use scaled coordinates for positioning and dimensions
110
+ element_style = "position: absolute; pointer-events: auto; cursor: pointer; "
111
+ element_style += f"left: {x0}px; top: {y0}px; width: {width}px; height: {height}px; "
112
+
113
+ # Different styling for different element types
114
+ if element_type == 'text':
115
+ element_style += "background-color: rgba(255, 255, 0, 0.3); border: 1px dashed transparent; "
116
+ elif element_type == 'image':
117
+ element_style += "background-color: rgba(0, 128, 255, 0.3); border: 1px dashed transparent; "
118
+ elif element_type == 'figure':
119
+ element_style += "background-color: rgba(255, 0, 255, 0.3); border: 1px dashed transparent; "
120
+ elif element_type == 'table':
121
+ element_style += "background-color: rgba(0, 255, 0, 0.3); border: 1px dashed transparent; "
122
+ else:
123
+ element_style += "background-color: rgba(200, 200, 200, 0.3); border: 1px dashed transparent; "
124
+
125
+ # Add element div
126
+ container_html += f"""
127
+ <div class="pdf-element" data-element-id="{i}" style="{element_style}"></div>
128
+ """
129
+
130
+ # Add SVG rectangle using scaled coordinates and dimensions
131
+ container_html += f"""
132
+ <rect data-element-id="{i}" x="{x0}" y="{y0}" width="{width}" height="{height}"
133
+ fill="none" stroke="rgba(255, 165, 0, 0.85)" stroke-width="1.5" />
134
+ """
135
+
136
+ # Close SVG and container divs
137
+ container_html += f"""
138
+ </svg>
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ <div id="{self.widget_id}-info-panel" class="info-panel" style="display: block; margin-left: 20px; padding: 10px; width: 300px; max-height: 80vh; overflow-y: auto; border: 1px solid #eee; background-color: #f9f9f9;">
144
+ <h4 style="margin-top: 0; margin-bottom: 5px; border-bottom: 1px solid #ccc; padding-bottom: 5px;">Element Info</h4>
145
+ <pre id="{self.widget_id}-element-data" style="white-space: pre-wrap; word-break: break-all; font-size: 0.9em;"></pre>
146
+ </div>
147
+
148
+ </div>
149
+ """
150
+
151
+ # Display the HTML
152
+ display(HTML(container_html))
153
+
154
+ # Generate JavaScript to add interactivity
155
+ self._add_javascript()
156
+
157
+ def _add_javascript(self):
158
+ """Add JavaScript to make the viewer interactive"""
159
+ # Create JavaScript for element selection and SVG highlighting
160
+ js_code = """
161
+ (function() {
162
+ // Store widget ID in a variable to avoid issues with string templates
163
+ const widgetId = "%s";
164
+
165
+ // Initialize PDF viewer registry if it doesn't exist
166
+ if (!window.pdfViewerRegistry) {
167
+ window.pdfViewerRegistry = {};
168
+ }
169
+
170
+ // Store PDF data for this widget
171
+ window.pdfViewerRegistry[widgetId] = {
172
+ initialData: %s,
173
+ selectedElement: null,
174
+ scale: 1.0, // Initial zoom scale
175
+ translateX: 0, // Initial pan X
176
+ translateY: 0, // Initial pan Y
177
+ isDragging: false, // Flag for panning
178
+ startX: 0, // Drag start X
179
+ startY: 0, // Drag start Y
180
+ startTranslateX: 0, // Translate X at drag start
181
+ startTranslateY: 0, // Translate Y at drag start
182
+ justDragged: false // Flag to differentiate click from drag completion
183
+ };
184
+
185
+ // Get references to elements
186
+ const viewerData = window.pdfViewerRegistry[widgetId];
187
+ const outerContainer = document.querySelector(`#${widgetId} .pdf-outer-container`);
188
+ const zoomPanContainer = document.getElementById(`${widgetId}-zoom-pan-container`);
189
+ const elements = zoomPanContainer.querySelectorAll(".pdf-element");
190
+ const zoomInButton = document.getElementById(`${widgetId}-zoom-in`);
191
+ const zoomOutButton = document.getElementById(`${widgetId}-zoom-out`);
192
+ const resetButton = document.getElementById(`${widgetId}-reset-zoom`);
193
+
194
+ // --- Helper function to apply transform ---
195
+ function applyTransform() {
196
+ zoomPanContainer.style.transform = `translate(${viewerData.translateX}px, ${viewerData.translateY}px) scale(${viewerData.scale})`;
197
+ }
198
+
199
+ // --- Zooming Logic ---
200
+ function handleZoom(event) {
201
+ event.preventDefault(); // Prevent default scroll
202
+
203
+ const zoomIntensity = 0.1;
204
+ const wheelDelta = event.deltaY < 0 ? 1 : -1; // +1 for zoom in, -1 for zoom out
205
+ const zoomFactor = Math.exp(wheelDelta * zoomIntensity);
206
+ const newScale = Math.max(0.5, Math.min(5, viewerData.scale * zoomFactor)); // Clamp scale
207
+
208
+ // Calculate mouse position relative to the outer container
209
+ const rect = outerContainer.getBoundingClientRect();
210
+ const mouseX = event.clientX - rect.left;
211
+ const mouseY = event.clientY - rect.top;
212
+
213
+ // Calculate the point in the content that the mouse is pointing to
214
+ const pointX = (mouseX - viewerData.translateX) / viewerData.scale;
215
+ const pointY = (mouseY - viewerData.translateY) / viewerData.scale;
216
+
217
+ // Update scale
218
+ viewerData.scale = newScale;
219
+
220
+ // Calculate new translation to keep the pointed-at location fixed
221
+ viewerData.translateX = mouseX - pointX * viewerData.scale;
222
+ viewerData.translateY = mouseY - pointY * viewerData.scale;
223
+
224
+ applyTransform();
225
+ }
226
+
227
+ outerContainer.addEventListener('wheel', handleZoom);
228
+
229
+ // --- Panning Logic ---
230
+ const dragThreshold = 5; // Pixels to move before drag starts
231
+
232
+ function handleMouseDown(event) {
233
+ // Prevent default only if needed (e.g., text selection on image)
234
+ if (event.target.tagName === 'IMG') {
235
+ event.preventDefault();
236
+ }
237
+ // Allow mousedown events on elements to proceed for potential clicks
238
+ // Record start position for potential drag
239
+ viewerData.startX = event.clientX;
240
+ viewerData.startY = event.clientY;
241
+ // Store initial translate values to calculate relative movement
242
+ viewerData.startTranslateX = viewerData.translateX;
243
+ viewerData.startTranslateY = viewerData.translateY;
244
+ // Don't set isDragging = true yet
245
+ // Don't change pointerEvents yet
246
+ }
247
+
248
+ function handleMouseMove(event) {
249
+ // Check if mouse button is actually down (browser inconsistencies)
250
+ if (event.buttons !== 1) {
251
+ if (viewerData.isDragging) {
252
+ // Force drag end if button is released unexpectedly
253
+ handleMouseUp(event);
254
+ }
255
+ return;
256
+ }
257
+
258
+ const currentX = event.clientX;
259
+ const currentY = event.clientY;
260
+ const deltaX = currentX - viewerData.startX;
261
+ const deltaY = currentY - viewerData.startY;
262
+
263
+ // If not already dragging, check if threshold is exceeded
264
+ if (!viewerData.isDragging) {
265
+ const movedDistance = Math.hypot(deltaX, deltaY);
266
+ if (movedDistance > dragThreshold) {
267
+ viewerData.isDragging = true;
268
+ zoomPanContainer.style.cursor = 'grabbing';
269
+ // Now disable pointer events on elements since a drag has started
270
+ elements.forEach(el => el.style.pointerEvents = 'none');
271
+ }
272
+ }
273
+
274
+ // If dragging, update transform
275
+ if (viewerData.isDragging) {
276
+ // Prevent text selection during drag
277
+ event.preventDefault();
278
+ viewerData.translateX = viewerData.startTranslateX + deltaX;
279
+ viewerData.translateY = viewerData.startTranslateY + deltaY;
280
+ applyTransform();
281
+ }
282
+ }
283
+
284
+ function handleMouseUp(event) {
285
+ const wasDragging = viewerData.isDragging;
286
+
287
+ // Always reset cursor on mouse up
288
+ zoomPanContainer.style.cursor = 'grab';
289
+
290
+ if (wasDragging) {
291
+ viewerData.isDragging = false;
292
+ // Restore pointer events now that drag is finished
293
+ elements.forEach(el => el.style.pointerEvents = 'auto');
294
+
295
+ // Set flag to indicate a drag just finished
296
+ viewerData.justDragged = true;
297
+ // Reset the flag after a minimal delay, allowing the click event to be ignored
298
+ setTimeout(() => { viewerData.justDragged = false; }, 0);
299
+
300
+ // IMPORTANT: Prevent this mouseup from triggering other default actions
301
+ event.preventDefault();
302
+ // Stop propagation might not be needed here if the click listener checks justDragged
303
+ // event.stopPropagation();
304
+ } else {
305
+ // If it wasn't a drag, do nothing here.
306
+ // The browser should naturally fire a 'click' event on the target element
307
+ // which will be handled by the element's specific click listener
308
+ // or the outerContainer's listener if it was on the background.
309
+ }
310
+ }
311
+
312
+ // Mousedown starts the *potential* for a drag
313
+ // Attach to outer container to catch drags starting anywhere inside
314
+ outerContainer.addEventListener('mousedown', handleMouseDown);
315
+
316
+ // Mousemove determines if it's *actually* a drag and updates position
317
+ // Attach to window or document for smoother dragging even if mouse leaves outerContainer
318
+ // Using outerContainer for now, might need adjustment if dragging feels jerky near edges
319
+ outerContainer.addEventListener('mousemove', handleMouseMove);
320
+
321
+ // Mouseup ends the drag *or* allows a click to proceed
322
+ // Attach to window or document to ensure drag ends even if mouse released outside
323
+ // Using outerContainer for now
324
+ outerContainer.addEventListener('mouseup', handleMouseUp);
325
+
326
+ // Stop dragging if mouse leaves the outer container entirely (optional but good practice)
327
+ outerContainer.addEventListener('mouseleave', (event) => {
328
+ // Only act if the primary mouse button is NOT pressed anymore when leaving
329
+ if (viewerData.isDragging && event.buttons !== 1) {
330
+ handleMouseUp(event);
331
+ }
332
+ });
333
+
334
+ // --- Button Listeners ---
335
+ zoomInButton.addEventListener('click', () => {
336
+ const centerRect = outerContainer.getBoundingClientRect();
337
+ const centerX = centerRect.width / 2;
338
+ const centerY = centerRect.height / 2;
339
+ const zoomFactor = 1.2;
340
+ const newScale = Math.min(5, viewerData.scale * zoomFactor);
341
+ const pointX = (centerX - viewerData.translateX) / viewerData.scale;
342
+ const pointY = (centerY - viewerData.translateY) / viewerData.scale;
343
+ viewerData.scale = newScale;
344
+ viewerData.translateX = centerX - pointX * viewerData.scale;
345
+ viewerData.translateY = centerY - pointY * viewerData.scale;
346
+ applyTransform();
347
+ });
348
+
349
+ zoomOutButton.addEventListener('click', () => {
350
+ const centerRect = outerContainer.getBoundingClientRect();
351
+ const centerX = centerRect.width / 2;
352
+ const centerY = centerRect.height / 2;
353
+ const zoomFactor = 1 / 1.2;
354
+ const newScale = Math.max(0.5, viewerData.scale * zoomFactor);
355
+ const pointX = (centerX - viewerData.translateX) / viewerData.scale;
356
+ const pointY = (centerY - viewerData.translateY) / viewerData.scale;
357
+ viewerData.scale = newScale;
358
+ viewerData.translateX = centerX - pointX * viewerData.scale;
359
+ viewerData.translateY = centerY - pointY * viewerData.scale;
360
+ applyTransform();
361
+ });
362
+
363
+ resetButton.addEventListener('click', () => {
364
+ viewerData.scale = 1.0;
365
+ viewerData.translateX = 0;
366
+ viewerData.translateY = 0;
367
+ applyTransform();
368
+ // Also reset selection on zoom reset
369
+ if (viewerData.selectedElement !== null) {
370
+ resetElementStyle(viewerData.selectedElement);
371
+ viewerData.selectedElement = null;
372
+ // Optionally clear info panel
373
+ // const elementData = document.getElementById(widgetId + "-element-data");
374
+ // if (elementData) elementData.textContent = '';
375
+ }
376
+ });
377
+
378
+ // --- Helper function to reset element style ---
379
+ function resetElementStyle(elementIdx) {
380
+ const el = zoomPanContainer.querySelector(`.pdf-element[data-element-id='${elementIdx}']`);
381
+ const svgRect = document.querySelector(`#${widgetId} .svg-layer svg rect[data-element-id='${elementIdx}']`);
382
+ if (!el) return;
383
+
384
+ const viewer = window.pdfViewerRegistry[widgetId];
385
+ const eType = viewer.initialData.elements[elementIdx].type || 'unknown';
386
+
387
+ if (eType === 'text') {
388
+ el.style.backgroundColor = "rgba(255, 255, 0, 0.3)";
389
+ } else if (eType === 'image') {
390
+ el.style.backgroundColor = "rgba(0, 128, 255, 0.3)";
391
+ } else if (eType === 'figure') {
392
+ el.style.backgroundColor = "rgba(255, 0, 255, 0.3)";
393
+ } else if (eType === 'table') {
394
+ el.style.backgroundColor = "rgba(0, 255, 0, 0.3)";
395
+ } else {
396
+ el.style.backgroundColor = "rgba(200, 200, 200, 0.3)";
397
+ }
398
+ el.style.border = "1px dashed transparent";
399
+
400
+ if (svgRect) {
401
+ svgRect.setAttribute("stroke", "rgba(255, 165, 0, 0.85)");
402
+ svgRect.setAttribute("stroke-width", "1.5");
403
+ }
404
+ }
405
+
406
+ // --- Helper function to set element style (selected/hover) ---
407
+ function setElementHighlightStyle(elementIdx) {
408
+ const el = zoomPanContainer.querySelector(`.pdf-element[data-element-id='${elementIdx}']`);
409
+ const svgRect = document.querySelector(`#${widgetId} .svg-layer svg rect[data-element-id='${elementIdx}']`);
410
+ if (!el) return;
411
+
412
+ el.style.backgroundColor = "rgba(64, 158, 255, 0.15)";
413
+ el.style.border = "2px solid rgba(64, 158, 255, 0.6)";
414
+
415
+ if (svgRect) {
416
+ svgRect.setAttribute("stroke", "rgba(64, 158, 255, 0.9)");
417
+ svgRect.setAttribute("stroke-width", "2.5");
418
+ }
419
+ }
420
+
421
+ // --- Background Click Listener (on outer container) ---
422
+ outerContainer.addEventListener('click', (event) => {
423
+ // Ignore click if it resulted from the end of a drag
424
+ if (viewerData.justDragged) {
425
+ return;
426
+ }
427
+
428
+ // If the click is on an element itself, let the element's click handler manage it.
429
+ if (event.target.closest('.pdf-element')) {
430
+ return;
431
+ }
432
+ // If dragging, don't deselect
433
+ if (viewerData.isDragging) {
434
+ return;
435
+ }
436
+
437
+ // If an element is selected, deselect it
438
+ if (viewerData.selectedElement !== null) {
439
+ resetElementStyle(viewerData.selectedElement);
440
+ viewerData.selectedElement = null;
441
+
442
+ // Optionally clear the info panel
443
+ const infoPanel = document.getElementById(widgetId + "-info-panel");
444
+ const elementData = document.getElementById(widgetId + "-element-data");
445
+ if (infoPanel && elementData) {
446
+ // infoPanel.style.display = "none"; // Or hide it
447
+ elementData.textContent = ""; // Clear content
448
+ }
449
+ }
450
+ });
451
+
452
+ // Add click handlers to elements
453
+ elements.forEach(function(el) {
454
+ el.addEventListener("click", function(event) {
455
+ // Stop propagation to prevent the background click handler from immediately deselecting.
456
+ event.stopPropagation();
457
+
458
+ const elementIdx = parseInt(this.dataset.elementId);
459
+ const viewer = window.pdfViewerRegistry[widgetId];
460
+
461
+ // If there was a previously selected element, reset its style
462
+ if (viewer.selectedElement !== null && viewer.selectedElement !== elementIdx) {
463
+ resetElementStyle(viewer.selectedElement);
464
+ }
465
+
466
+ // If clicking the already selected element, deselect it (optional, uncomment if desired)
467
+ /*
468
+ if (viewer.selectedElement === elementIdx) {
469
+ resetElementStyle(elementIdx);
470
+ viewer.selectedElement = null;
471
+ // Clear info panel maybe?
472
+ const elementData = document.getElementById(widgetId + "-element-data");
473
+ if (elementData) elementData.textContent = '';
474
+ return; // Stop further processing
475
+ }
476
+ */
477
+
478
+ // Store newly selected element
479
+ viewer.selectedElement = elementIdx;
480
+
481
+ // Highlight newly selected element
482
+ setElementHighlightStyle(elementIdx);
483
+
484
+ // Update info panel
485
+ const infoPanel = document.getElementById(widgetId + "-info-panel");
486
+ const elementData = document.getElementById(widgetId + "-element-data");
487
+
488
+ if (infoPanel && elementData) {
489
+ const element = viewer.initialData.elements[elementIdx];
490
+ if (!element) { /* console.error(`[${widgetId}] Element data not found for index ${elementIdx}!`); */ return; }
491
+ infoPanel.style.display = "block";
492
+ elementData.textContent = JSON.stringify(element, null, 2);
493
+ } else {
494
+ /* console.error(`[${widgetId}] Info panel or element data container not found via getElementById on click!`); */
495
+ }
496
+ });
497
+
498
+ // Add hover effects
499
+ el.addEventListener("mouseenter", function() {
500
+ // *** Only apply hover if NOTHING is selected ***
501
+ const viewer = window.pdfViewerRegistry[widgetId];
502
+ if (viewer.selectedElement !== null) {
503
+ return; // Do nothing if an element is selected
504
+ }
505
+ // Avoid hover effect while dragging
506
+ if (viewer.isDragging) {
507
+ return;
508
+ }
509
+
510
+ const elementIdx = parseInt(this.dataset.elementId);
511
+
512
+ // Apply hover styling
513
+ setElementHighlightStyle(elementIdx);
514
+
515
+ // Show element info on hover (only if nothing selected)
516
+ const infoPanel = document.getElementById(widgetId + "-info-panel");
517
+ const elementData = document.getElementById(widgetId + "-element-data");
518
+
519
+ if (infoPanel && elementData) {
520
+ const element = viewer.initialData.elements[elementIdx];
521
+ if (!element) { /* console.error(`[${widgetId}] Element data not found for index ${elementIdx}!`); */ return; }
522
+ infoPanel.style.display = "block";
523
+ elementData.textContent = JSON.stringify(element, null, 2);
524
+ } else {
525
+ // Don't spam console on hover if it's not found initially
526
+ // console.error(`[${widgetId}] Info panel or element data container not found via getElementById on hover!`);
527
+ }
528
+ });
529
+
530
+ el.addEventListener("mouseleave", function() {
531
+ // *** Only reset hover if NOTHING is selected ***
532
+ const viewer = window.pdfViewerRegistry[widgetId];
533
+ if (viewer.selectedElement !== null) {
534
+ return; // Do nothing if an element is selected
535
+ }
536
+ // Avoid hover effect while dragging
537
+ if (viewer.isDragging) {
538
+ return;
539
+ }
540
+
541
+ const elementIdx = parseInt(this.dataset.elementId);
542
+
543
+ // Reset styling
544
+ resetElementStyle(elementIdx);
545
+
546
+ // Optionally hide/clear the info panel on mouse leave when nothing is selected
547
+ // const infoPanel = document.getElementById(widgetId + "-info-panel");
548
+ // const elementData = document.getElementById(widgetId + "-element-data");
549
+ // if (infoPanel && elementData) {
550
+ // elementData.textContent = '';
551
+ // }
552
+ });
553
+ });
554
+
555
+ })();
556
+ """ % (self.widget_id, json.dumps(self.pdf_data))
557
+
558
+ # Add the JavaScript
559
+ display(Javascript(js_code))
560
+
561
+ def _repr_html_(self):
562
+ """Return empty string as HTML has already been displayed"""
563
+ return ""
564
+
565
+ @classmethod
566
+ def from_page(cls, page, on_element_click=None, include_attributes=None):
567
+ """
568
+ Create a viewer widget from a Page object.
569
+
570
+ Args:
571
+ page: A natural_pdf.core.page.Page object
572
+ on_element_click: Optional callback function for element clicks
573
+ include_attributes: Optional list of *additional* specific attributes to include.
574
+ A default set of common/useful attributes is always included.
575
+
576
+ Returns:
577
+ SimpleInteractiveViewerWidget instance or None if image rendering fails.
578
+ """
579
+ # Get the page image
580
+ import base64
581
+ from io import BytesIO
582
+ import json # Ensure json is imported
583
+ from PIL import Image # Ensure Image is imported
584
+
585
+ # Render page to image using the correct method and parameter
586
+ scale = 1.0 # Define scale factor used for rendering
587
+ try:
588
+ img_object = page.to_image(resolution=int(72 * scale)) # Call to_image
589
+ # Check if .original attribute exists, otherwise assume img_object is the PIL Image
590
+ if hasattr(img_object, 'original') and isinstance(img_object.original, Image.Image):
591
+ img = img_object.original
592
+ elif isinstance(img_object, Image.Image):
593
+ img = img_object
594
+ else:
595
+ # If it's neither, maybe it's the raw bytes? Try opening it.
596
+ try:
597
+ img = Image.open(BytesIO(img_object)).convert('RGB')
598
+ except Exception:
599
+ raise TypeError(f"page.to_image() returned unexpected type: {type(img_object)}")
600
+ logger.debug(f"Successfully rendered page {page.index} using to_image()")
601
+ except Exception as render_err:
602
+ logger.error(f"Error rendering page {page.index} image for widget: {render_err}", exc_info=True)
603
+ # Return None or raise the error? Let's raise for now to make it clear.
604
+ raise ValueError(f"Failed to render page image: {render_err}") from render_err
605
+
606
+ buffered = BytesIO()
607
+ img.save(buffered, format="PNG")
608
+ img_str = base64.b64encode(buffered.getvalue()).decode()
609
+ image_uri = f"data:image/png;base64,{img_str}"
610
+
611
+ # Convert elements to dict format
612
+ elements = []
613
+ # Use page.elements directly if available, otherwise fallback to find_all
614
+ page_elements = getattr(page, 'elements', page.find_all('*'))
615
+
616
+ # Filter out 'char' elements
617
+ filtered_page_elements = [el for el in page_elements if getattr(el, 'type', '').lower() != 'char']
618
+ logger.debug(f"Filtered out char elements, keeping {len(filtered_page_elements)} elements.")
619
+
620
+ # Define a list of common/useful attributes (properties) to check for
621
+ default_attributes_to_get = [
622
+ 'text', 'fontname', 'size', 'bold', 'italic', 'color',
623
+ 'linewidth', # For lines (pdfplumber uses 'linewidth')
624
+ 'is_horizontal', 'is_vertical', # For lines
625
+ 'source', 'confidence', # For text/OCR
626
+ 'label', # Common for layout elements
627
+ 'model', # Add the model name (engine)
628
+ # Add any other common properties you expect from your elements
629
+ 'upright', 'direction' # from pdfplumber chars/words
630
+ ]
631
+
632
+ for i, element in enumerate(filtered_page_elements):
633
+ # Get original coordinates and calculated width/height (always present via base class)
634
+ original_x0 = element.x0
635
+ original_y0 = element.top
636
+ original_x1 = element.x1
637
+ original_y1 = element.bottom
638
+ width = element.width
639
+ height = element.height
640
+
641
+ # Base element dict with required info
642
+ elem_dict = {
643
+ 'id': i,
644
+ # Use the standardized .type property
645
+ 'type': element.type,
646
+ # Scaled coordinates for positioning in HTML/SVG
647
+ 'x0': original_x0 * scale,
648
+ 'y0': original_y0 * scale,
649
+ 'x1': original_x1 * scale,
650
+ 'y1': original_y1 * scale,
651
+ 'width': width * scale,
652
+ 'height': height * scale,
653
+ }
654
+
655
+ # --- Get Default Attributes --- #
656
+ attributes_found = set()
657
+ for attr_name in default_attributes_to_get:
658
+ if hasattr(element, attr_name):
659
+ try:
660
+ value = getattr(element, attr_name)
661
+ # Convert non-JSON serializable types to string
662
+ processed_value = value
663
+ if not isinstance(value, (str, int, float, bool, list, dict, tuple)) and value is not None:
664
+ processed_value = str(value)
665
+ elem_dict[attr_name] = processed_value
666
+ attributes_found.add(attr_name)
667
+ except Exception as e:
668
+ logger.warning(f"Could not get or process default attribute '{attr_name}' for element {i} ({element.type}): {e}")
669
+
670
+ # --- Get User-Requested Attributes (if any) --- #
671
+ if include_attributes:
672
+ for attr_name in include_attributes:
673
+ # Only process if not already added and exists
674
+ if attr_name not in attributes_found and hasattr(element, attr_name):
675
+ try:
676
+ value = getattr(element, attr_name)
677
+ processed_value = value
678
+ if not isinstance(value, (str, int, float, bool, list, dict, tuple)) and value is not None:
679
+ processed_value = str(value)
680
+ elem_dict[attr_name] = processed_value
681
+ except Exception as e:
682
+ logger.warning(f"Could not get or process requested attribute '{attr_name}' for element {i} ({element.type}): {e}")
683
+ for attr_name in elem_dict:
684
+ if isinstance(elem_dict[attr_name], float):
685
+ elem_dict[attr_name] = round(elem_dict[attr_name], 2)
686
+ elements.append(elem_dict)
687
+
688
+ logger.debug(f"Prepared {len(elements)} elements for widget with scaled coordinates and curated attributes.")
689
+
690
+ # Create and return widget
691
+ # The actual JSON conversion happens when the data is sent to the frontend
692
+ return cls(
693
+ image_uri=image_uri,
694
+ elements=elements
695
+ )
696
+
697
+ # Keep the original widget class for reference, but make it not register
698
+ # by commenting out the decorator
699
+ # @widgets.register
700
+ class InteractiveViewerWidget(widgets.DOMWidget):
701
+ """Jupyter widget for interactively viewing PDF page elements."""
702
+ _view_name = Unicode('InteractiveViewerView').tag(sync=True)
703
+ _view_module = Unicode('viewer_widget').tag(sync=True)
704
+ _view_module_version = Unicode('^0.1.0').tag(sync=True)
705
+
706
+ image_uri = Unicode('').tag(sync=True)
707
+ page_dimensions = Dict({}).tag(sync=True)
708
+ elements = List([]).tag(sync=True)
709
+
710
+ def __init__(self, **kwargs):
711
+ super().__init__(**kwargs)
712
+ logger.debug("InteractiveViewerWidget initialized (Python).")
713
+
714
+ # Example observer (optional)
715
+ @observe('elements')
716
+ def _elements_changed(self, change):
717
+ # Only log if logger level allows
718
+ if logger.isEnabledFor(logging.DEBUG):
719
+ logger.debug(f"Python: Elements traitlet changed. New count: {len(change['new'])}")
720
+ # Can add Python-side logic here if needed when elements change
721
+ # print(f"Python: Elements traitlet changed. New count: {len(change['new'])}")
722
+ pass
723
+
724
+ # Example usage
725
+ """
726
+ Example usage:
727
+
728
+ # Method 1: Using pdf_data dictionary
729
+ viewer = SimpleInteractiveViewerWidget(pdf_data={
730
+ 'page_image': 'data:image/png;base64,...', # Base64 encoded image
731
+ 'elements': [
732
+ {
733
+ 'type': 'text',
734
+ 'x0': 100,
735
+ 'y0': 200,
736
+ 'x1': 300,
737
+ 'y1': 220,
738
+ 'text': 'Sample text'
739
+ }
740
+ ]
741
+ })
742
+
743
+ # Method 2: Using keyword arguments
744
+ viewer = SimpleInteractiveViewerWidget(
745
+ image_uri='data:image/png;base64,...', # Base64 encoded image
746
+ elements=[
747
+ {
748
+ 'type': 'text',
749
+ 'x0': 100,
750
+ 'y0': 200,
751
+ 'x1': 300,
752
+ 'y1': 220,
753
+ 'text': 'Sample text'
754
+ }
755
+ ]
756
+ )
757
+
758
+ # Method 3: Using a Page object
759
+ from natural_pdf.core.page import Page
760
+ page = doc.pages[0] # Assuming 'doc' is a Document object
761
+ viewer = SimpleInteractiveViewerWidget.from_page(page)
762
+
763
+ # Display the widget
764
+ viewer
765
+ """