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.
- natural_pdf/__init__.py +55 -0
- natural_pdf/analyzers/__init__.py +6 -0
- natural_pdf/analyzers/layout/__init__.py +1 -0
- natural_pdf/analyzers/layout/base.py +151 -0
- natural_pdf/analyzers/layout/docling.py +247 -0
- natural_pdf/analyzers/layout/layout_analyzer.py +166 -0
- natural_pdf/analyzers/layout/layout_manager.py +200 -0
- natural_pdf/analyzers/layout/layout_options.py +78 -0
- natural_pdf/analyzers/layout/paddle.py +240 -0
- natural_pdf/analyzers/layout/surya.py +151 -0
- natural_pdf/analyzers/layout/tatr.py +251 -0
- natural_pdf/analyzers/layout/yolo.py +165 -0
- natural_pdf/analyzers/text_options.py +60 -0
- natural_pdf/analyzers/text_structure.py +270 -0
- natural_pdf/analyzers/utils.py +57 -0
- natural_pdf/core/__init__.py +3 -0
- natural_pdf/core/element_manager.py +457 -0
- natural_pdf/core/highlighting_service.py +698 -0
- natural_pdf/core/page.py +1444 -0
- natural_pdf/core/pdf.py +653 -0
- natural_pdf/elements/__init__.py +3 -0
- natural_pdf/elements/base.py +761 -0
- natural_pdf/elements/collections.py +1345 -0
- natural_pdf/elements/line.py +140 -0
- natural_pdf/elements/rect.py +122 -0
- natural_pdf/elements/region.py +1793 -0
- natural_pdf/elements/text.py +304 -0
- natural_pdf/ocr/__init__.py +56 -0
- natural_pdf/ocr/engine.py +104 -0
- natural_pdf/ocr/engine_easyocr.py +179 -0
- natural_pdf/ocr/engine_paddle.py +204 -0
- natural_pdf/ocr/engine_surya.py +171 -0
- natural_pdf/ocr/ocr_manager.py +191 -0
- natural_pdf/ocr/ocr_options.py +114 -0
- natural_pdf/qa/__init__.py +3 -0
- natural_pdf/qa/document_qa.py +396 -0
- natural_pdf/selectors/__init__.py +4 -0
- natural_pdf/selectors/parser.py +354 -0
- natural_pdf/templates/__init__.py +1 -0
- natural_pdf/templates/ocr_debug.html +517 -0
- natural_pdf/utils/__init__.py +3 -0
- natural_pdf/utils/highlighting.py +12 -0
- natural_pdf/utils/reading_order.py +227 -0
- natural_pdf/utils/visualization.py +223 -0
- natural_pdf/widgets/__init__.py +4 -0
- natural_pdf/widgets/frontend/viewer.js +88 -0
- natural_pdf/widgets/viewer.py +765 -0
- natural_pdf-0.1.0.dist-info/METADATA +295 -0
- natural_pdf-0.1.0.dist-info/RECORD +52 -0
- natural_pdf-0.1.0.dist-info/WHEEL +5 -0
- natural_pdf-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
"""
|