chatterer 0.1.26__py3-none-any.whl → 0.1.27__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.
- chatterer/__init__.py +87 -87
- chatterer/common_types/__init__.py +21 -21
- chatterer/common_types/io.py +19 -19
- chatterer/constants.py +5 -0
- chatterer/examples/__main__.py +75 -75
- chatterer/examples/any2md.py +83 -85
- chatterer/examples/pdf2md.py +231 -338
- chatterer/examples/pdf2txt.py +52 -54
- chatterer/examples/ppt.py +487 -486
- chatterer/examples/pw.py +141 -143
- chatterer/examples/snippet.py +54 -56
- chatterer/examples/transcribe.py +192 -192
- chatterer/examples/upstage.py +87 -89
- chatterer/examples/web2md.py +80 -80
- chatterer/interactive.py +422 -354
- chatterer/language_model.py +530 -536
- chatterer/messages.py +21 -21
- chatterer/tools/__init__.py +46 -46
- chatterer/tools/caption_markdown_images.py +388 -384
- chatterer/tools/citation_chunking/__init__.py +3 -3
- chatterer/tools/citation_chunking/chunks.py +51 -53
- chatterer/tools/citation_chunking/citation_chunker.py +117 -118
- chatterer/tools/citation_chunking/citations.py +284 -285
- chatterer/tools/citation_chunking/prompt.py +157 -157
- chatterer/tools/citation_chunking/reference.py +26 -26
- chatterer/tools/citation_chunking/utils.py +138 -138
- chatterer/tools/convert_pdf_to_markdown.py +636 -645
- chatterer/tools/convert_to_text.py +446 -446
- chatterer/tools/upstage_document_parser.py +704 -705
- chatterer/tools/webpage_to_markdown.py +739 -739
- chatterer/tools/youtube.py +146 -147
- chatterer/utils/__init__.py +15 -15
- chatterer/utils/base64_image.py +349 -350
- chatterer/utils/bytesio.py +59 -59
- chatterer/utils/code_agent.py +237 -237
- chatterer/utils/imghdr.py +145 -145
- {chatterer-0.1.26.dist-info → chatterer-0.1.27.dist-info}/METADATA +377 -390
- chatterer-0.1.27.dist-info/RECORD +43 -0
- chatterer-0.1.26.dist-info/RECORD +0 -42
- {chatterer-0.1.26.dist-info → chatterer-0.1.27.dist-info}/WHEEL +0 -0
- {chatterer-0.1.26.dist-info → chatterer-0.1.27.dist-info}/entry_points.txt +0 -0
- {chatterer-0.1.26.dist-info → chatterer-0.1.27.dist-info}/top_level.txt +0 -0
@@ -1,645 +1,636 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
import
|
4
|
-
import
|
5
|
-
import
|
6
|
-
from
|
7
|
-
|
8
|
-
from
|
9
|
-
|
10
|
-
|
11
|
-
from ..
|
12
|
-
from ..utils.
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
"""
|
35
|
-
|
36
|
-
|
37
|
-
"""
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
if not
|
50
|
-
return None
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
"""
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
**
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
**
|
76
|
-
• **
|
77
|
-
• **
|
78
|
-
• **
|
79
|
-
• **
|
80
|
-
• **
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
"""
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
```
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
"""
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
**
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
- **
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
-
|
139
|
-
-
|
140
|
-
|
141
|
-
-
|
142
|
-
-
|
143
|
-
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
""
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
)
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
if
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
|
289
|
-
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
319
|
-
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
logger.info("
|
336
|
-
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
390
|
-
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
)
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
)
|
472
|
-
|
473
|
-
|
474
|
-
|
475
|
-
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
491
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
497
|
-
|
498
|
-
|
499
|
-
|
500
|
-
|
501
|
-
|
502
|
-
|
503
|
-
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
|
510
|
-
|
511
|
-
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
|
516
|
-
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
521
|
-
)
|
522
|
-
|
523
|
-
|
524
|
-
|
525
|
-
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
|
530
|
-
|
531
|
-
|
532
|
-
|
533
|
-
|
534
|
-
|
535
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
if
|
540
|
-
|
541
|
-
|
542
|
-
|
543
|
-
|
544
|
-
|
545
|
-
|
546
|
-
|
547
|
-
|
548
|
-
|
549
|
-
|
550
|
-
|
551
|
-
|
552
|
-
|
553
|
-
|
554
|
-
|
555
|
-
|
556
|
-
|
557
|
-
|
558
|
-
|
559
|
-
|
560
|
-
|
561
|
-
|
562
|
-
|
563
|
-
|
564
|
-
|
565
|
-
|
566
|
-
|
567
|
-
|
568
|
-
|
569
|
-
|
570
|
-
|
571
|
-
|
572
|
-
|
573
|
-
|
574
|
-
|
575
|
-
|
576
|
-
|
577
|
-
|
578
|
-
|
579
|
-
|
580
|
-
|
581
|
-
|
582
|
-
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
591
|
-
|
592
|
-
|
593
|
-
|
594
|
-
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
|
599
|
-
|
600
|
-
|
601
|
-
|
602
|
-
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
|
611
|
-
|
612
|
-
|
613
|
-
|
614
|
-
|
615
|
-
|
616
|
-
|
617
|
-
|
618
|
-
|
619
|
-
|
620
|
-
|
621
|
-
|
622
|
-
|
623
|
-
|
624
|
-
|
625
|
-
|
626
|
-
|
627
|
-
|
628
|
-
|
629
|
-
|
630
|
-
|
631
|
-
|
632
|
-
|
633
|
-
|
634
|
-
|
635
|
-
|
636
|
-
|
637
|
-
if start > end:
|
638
|
-
raise ValueError(
|
639
|
-
f"Invalid range: {start} - {end}. Start index must be less than or equal to end index."
|
640
|
-
)
|
641
|
-
indices.update(range(start, end + 1))
|
642
|
-
else:
|
643
|
-
raise ValueError(f"Invalid page index format: '{part}'. Expected format is '1,2,3' or '1-3'.")
|
644
|
-
|
645
|
-
return sorted(indices) # Return sorted list of indices, ensuring no duplicates
|
1
|
+
import asyncio
|
2
|
+
import re
|
3
|
+
from contextlib import contextmanager
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from types import EllipsisType
|
6
|
+
from typing import TYPE_CHECKING, Callable, Iterable, List, Literal, Optional
|
7
|
+
|
8
|
+
from loguru import logger
|
9
|
+
|
10
|
+
from ..language_model import Chatterer, HumanMessage
|
11
|
+
from ..utils.base64_image import Base64Image
|
12
|
+
from ..utils.bytesio import PathOrReadable, read_bytes_stream
|
13
|
+
|
14
|
+
if TYPE_CHECKING:
|
15
|
+
from pymupdf import Document # pyright: ignore[reportMissingTypeStubs]
|
16
|
+
|
17
|
+
|
18
|
+
MARKDOWN_PATTERN: re.Pattern[str] = re.compile(r"```(?:markdown\s*\n)?(.*?)```", re.DOTALL)
|
19
|
+
PageIndexType = Iterable[int | tuple[int | EllipsisType, int | EllipsisType]] | int | str
|
20
|
+
|
21
|
+
|
22
|
+
@dataclass
|
23
|
+
class PdfToMarkdown:
|
24
|
+
"""
|
25
|
+
Converts PDF documents to Markdown using a multimodal LLM (Chatterer).
|
26
|
+
|
27
|
+
This class supports both sequential and parallel processing:
|
28
|
+
- Sequential processing preserves strict page continuity using previous page context
|
29
|
+
- Parallel processing enables faster conversion for large documents by using
|
30
|
+
previous page image and text for context instead of generated markdown
|
31
|
+
"""
|
32
|
+
|
33
|
+
chatterer: Chatterer
|
34
|
+
"""An instance of the Chatterer class configured with a vision-capable model."""
|
35
|
+
image_zoom: float = 2.0
|
36
|
+
"""Zoom factor for rendering PDF pages as images (higher zoom = higher resolution)."""
|
37
|
+
image_format: Literal["jpg", "jpeg", "png"] = "png"
|
38
|
+
"""The format for the rendered image ('png', 'jpeg', 'jpg'.)."""
|
39
|
+
image_jpg_quality: int = 95
|
40
|
+
"""Quality for JPEG images (if used)."""
|
41
|
+
context_tail_lines: int = 10
|
42
|
+
"""Number of lines from the end of the previous page's Markdown to use as context (sequential mode only)."""
|
43
|
+
|
44
|
+
def _get_context_tail(self, markdown_text: Optional[str]) -> Optional[str]:
|
45
|
+
"""Extracts the last N lines from the given markdown text."""
|
46
|
+
if not markdown_text or self.context_tail_lines <= 0:
|
47
|
+
return None
|
48
|
+
lines = markdown_text.strip().splitlines()
|
49
|
+
if not lines:
|
50
|
+
return None
|
51
|
+
tail_lines = lines[-self.context_tail_lines :]
|
52
|
+
return "\n".join(tail_lines)
|
53
|
+
|
54
|
+
def _format_prompt_content_sequential(
|
55
|
+
self,
|
56
|
+
page_text: str,
|
57
|
+
page_image_b64: Base64Image,
|
58
|
+
previous_markdown_context_tail: Optional[str] = None,
|
59
|
+
page_number: int = 0,
|
60
|
+
total_pages: int = 1,
|
61
|
+
) -> HumanMessage:
|
62
|
+
"""
|
63
|
+
Formats the content for sequential processing using previous page's markdown context.
|
64
|
+
"""
|
65
|
+
instruction = f"""You are an expert PDF to Markdown converter. Convert Page {page_number + 1} of {total_pages} into accurate, well-formatted Markdown.
|
66
|
+
|
67
|
+
**Input provided:**
|
68
|
+
1. **Raw Text**: Extracted text from the PDF page (may contain OCR errors)
|
69
|
+
2. **Page Image**: Visual rendering of the page showing actual layout
|
70
|
+
3. **Previous Context**: End portion of the previous page's generated Markdown (if available)
|
71
|
+
|
72
|
+
**Conversion Rules:**
|
73
|
+
• **Text Structure**: Use the image to understand the actual layout and fix any OCR errors in the raw text
|
74
|
+
• **Headings**: Use appropriate heading levels (# ## ### etc.) based on visual hierarchy
|
75
|
+
• **Lists**: Convert to proper Markdown lists (- or 1. 2. 3.) maintaining structure
|
76
|
+
• **Tables**: Convert to Markdown table format using | pipes |
|
77
|
+
• **Images/Diagrams**: Describe significant visual elements as: `<details><summary>Figure: Brief title</summary>Detailed description based on what you see in the image</details>`
|
78
|
+
• **Code/Formulas**: Use ``` code blocks ``` or LaTeX $$ math $$ as appropriate
|
79
|
+
• **Continuity**: If previous context shows incomplete content (mid-sentence, list, table), seamlessly continue from that point
|
80
|
+
• **NO REPETITION**: Never repeat content from the previous context - only generate new content for this page
|
81
|
+
|
82
|
+
**Raw Text:**
|
83
|
+
```
|
84
|
+
{page_text if page_text else "No text extracted from this page."}
|
85
|
+
```
|
86
|
+
|
87
|
+
**Page Image:** (attached)
|
88
|
+
"""
|
89
|
+
|
90
|
+
if previous_markdown_context_tail:
|
91
|
+
instruction += f"""
|
92
|
+
**Previous Page Context (DO NOT REPEAT):**
|
93
|
+
```markdown
|
94
|
+
... (previous page ended with) ...
|
95
|
+
{previous_markdown_context_tail}
|
96
|
+
```
|
97
|
+
|
98
|
+
Continue seamlessly from the above context if the current page content flows from it.
|
99
|
+
"""
|
100
|
+
else:
|
101
|
+
instruction += "\n**Note:** This is the first page or start of a new section."
|
102
|
+
|
103
|
+
instruction += "\n\n**Output only the Markdown content for the current page. Ensure proper formatting and NO repetition of previous content.**"
|
104
|
+
|
105
|
+
return HumanMessage(content=[instruction, page_image_b64.data_uri_content_dict])
|
106
|
+
|
107
|
+
def _format_prompt_content_parallel(
|
108
|
+
self,
|
109
|
+
page_text: str,
|
110
|
+
page_image_b64: Base64Image,
|
111
|
+
previous_page_text: Optional[str] = None,
|
112
|
+
previous_page_image_b64: Optional[Base64Image] = None,
|
113
|
+
page_number: int = 0,
|
114
|
+
total_pages: int = 1,
|
115
|
+
) -> HumanMessage:
|
116
|
+
"""
|
117
|
+
Formats the content for parallel processing using previous page's raw data.
|
118
|
+
"""
|
119
|
+
instruction = f"""You are an expert PDF to Markdown converter. Convert Page {page_number + 1} of {total_pages} into accurate, well-formatted Markdown.
|
120
|
+
|
121
|
+
**Task**: Convert the current page to Markdown while maintaining proper continuity with the previous page.
|
122
|
+
|
123
|
+
**Current Page Data:**
|
124
|
+
- **Raw Text**: Extracted text (may have OCR errors - use image to verify)
|
125
|
+
- **Page Image**: Visual rendering showing actual layout
|
126
|
+
|
127
|
+
**Previous Page Data** (for context only):
|
128
|
+
- **Previous Raw Text**: Text from the previous page
|
129
|
+
- **Previous Page Image**: Visual of the previous page
|
130
|
+
|
131
|
+
**Conversion Instructions:**
|
132
|
+
1. **Primary Focus**: Convert the CURRENT page content accurately
|
133
|
+
2. **Continuity Check**:
|
134
|
+
- Examine if the current page continues content from the previous page (sentences, paragraphs, lists, tables)
|
135
|
+
- If yes, start your Markdown naturally continuing that content
|
136
|
+
- If no, start fresh with proper heading/structure
|
137
|
+
3. **Format Rules**:
|
138
|
+
- Use image to fix OCR errors and understand layout
|
139
|
+
- Convert headings to # ## ### based on visual hierarchy
|
140
|
+
- Convert lists to proper Markdown (- or 1. 2. 3.)
|
141
|
+
- Convert tables to | pipe | format
|
142
|
+
- Describe significant images/charts as: `<details><summary>Figure: Title</summary>Description</details>`
|
143
|
+
- Use ``` for code blocks and $$ for math formulas
|
144
|
+
|
145
|
+
**Current Page Raw Text:**
|
146
|
+
```
|
147
|
+
{page_text if page_text else "No text extracted from this page."}
|
148
|
+
```
|
149
|
+
|
150
|
+
**Current Page Image:** (see first attached image)
|
151
|
+
"""
|
152
|
+
|
153
|
+
content: list[str | dict[str, object]] = [instruction, page_image_b64.data_uri_content_dict]
|
154
|
+
|
155
|
+
if previous_page_text is not None and previous_page_image_b64 is not None:
|
156
|
+
instruction += f"""
|
157
|
+
|
158
|
+
**Previous Page Raw Text (for context):**
|
159
|
+
```
|
160
|
+
{previous_page_text if previous_page_text else "No text from previous page."}
|
161
|
+
```
|
162
|
+
|
163
|
+
**Previous Page Image:** (see second attached image)
|
164
|
+
"""
|
165
|
+
content.append(previous_page_image_b64.data_uri_content_dict)
|
166
|
+
else:
|
167
|
+
instruction += "\n**Note:** This is the first page - no previous context available."
|
168
|
+
|
169
|
+
instruction += (
|
170
|
+
"\n\n**Generate ONLY the Markdown for the current page. Ensure proper continuity and formatting.**"
|
171
|
+
)
|
172
|
+
content[0] = instruction
|
173
|
+
|
174
|
+
return HumanMessage(content=content)
|
175
|
+
|
176
|
+
def convert(
|
177
|
+
self,
|
178
|
+
pdf_input: "Document | PathOrReadable",
|
179
|
+
page_indices: Optional[PageIndexType] = None,
|
180
|
+
progress_callback: Optional[Callable[[int, int], None]] = None,
|
181
|
+
mode: Literal["sequential", "parallel"] = "sequential",
|
182
|
+
) -> str:
|
183
|
+
"""
|
184
|
+
Converts a PDF document to Markdown synchronously.
|
185
|
+
|
186
|
+
Args:
|
187
|
+
pdf_input: Path to PDF file or pymupdf.Document object
|
188
|
+
page_indices: Specific page indices to convert (0-based). If None, converts all pages
|
189
|
+
progress_callback: Optional callback function called with (current_page, total_pages)
|
190
|
+
mode: "sequential" for strict continuity or "parallel" for independent page processing
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
Concatenated Markdown string for all processed pages
|
194
|
+
"""
|
195
|
+
if mode == "sequential":
|
196
|
+
return self._convert_sequential(pdf_input, page_indices, progress_callback)
|
197
|
+
else:
|
198
|
+
return self._convert_parallel_sync(pdf_input, page_indices, progress_callback)
|
199
|
+
|
200
|
+
async def aconvert(
|
201
|
+
self,
|
202
|
+
pdf_input: "Document | PathOrReadable",
|
203
|
+
page_indices: Optional[PageIndexType] = None,
|
204
|
+
progress_callback: Optional[Callable[[int, int], None]] = None,
|
205
|
+
max_concurrent: int = 5,
|
206
|
+
) -> str:
|
207
|
+
"""
|
208
|
+
Converts a PDF document to Markdown asynchronously with parallel processing.
|
209
|
+
|
210
|
+
Args:
|
211
|
+
pdf_input: Path to PDF file or pymupdf.Document object
|
212
|
+
page_indices: Specific page indices to convert (0-based). If None, converts all pages
|
213
|
+
progress_callback: Optional callback function called with (current_page, total_pages)
|
214
|
+
max_concurrent: Maximum number of concurrent LLM requests
|
215
|
+
|
216
|
+
Returns:
|
217
|
+
Concatenated Markdown string for all processed pages
|
218
|
+
"""
|
219
|
+
with open_pdf(pdf_input) as doc:
|
220
|
+
target_page_indices: list[int] = list(
|
221
|
+
_get_page_indices(page_indices=page_indices, max_doc_pages=len(doc), is_input_zero_based=True)
|
222
|
+
)
|
223
|
+
total_pages_to_process: int = len(target_page_indices)
|
224
|
+
|
225
|
+
if not total_pages_to_process:
|
226
|
+
logger.warning("No pages selected for processing.")
|
227
|
+
return ""
|
228
|
+
|
229
|
+
# Pre-process all pages
|
230
|
+
page_text_dict: dict[int, str] = extract_text_from_pdf(doc, target_page_indices)
|
231
|
+
page_image_dict: dict[int, bytes] = render_pdf_as_image(
|
232
|
+
doc,
|
233
|
+
page_indices=target_page_indices,
|
234
|
+
zoom=self.image_zoom,
|
235
|
+
output=self.image_format,
|
236
|
+
jpg_quality=self.image_jpg_quality,
|
237
|
+
)
|
238
|
+
|
239
|
+
semaphore = asyncio.Semaphore(max_concurrent)
|
240
|
+
|
241
|
+
async def process_page(i: int, page_idx: int) -> tuple[int, str]:
|
242
|
+
async with semaphore:
|
243
|
+
try:
|
244
|
+
# Get previous page data for context
|
245
|
+
prev_page_idx: int | None = target_page_indices[i - 1] if i > 0 else None
|
246
|
+
message: HumanMessage = self._format_prompt_content_parallel(
|
247
|
+
page_text=page_text_dict.get(page_idx, ""),
|
248
|
+
page_image_b64=Base64Image.from_bytes(page_image_dict[page_idx], ext=self.image_format),
|
249
|
+
previous_page_text=(
|
250
|
+
page_text_dict.get(prev_page_idx) if prev_page_idx is not None else None
|
251
|
+
),
|
252
|
+
previous_page_image_b64=(
|
253
|
+
Base64Image.from_bytes(page_image_dict[prev_page_idx], ext=self.image_format)
|
254
|
+
if prev_page_idx is not None
|
255
|
+
else None
|
256
|
+
),
|
257
|
+
page_number=page_idx,
|
258
|
+
total_pages=len(doc),
|
259
|
+
)
|
260
|
+
response: str = await self.chatterer.agenerate([message])
|
261
|
+
|
262
|
+
# Extract markdown
|
263
|
+
markdowns: list[str] = [
|
264
|
+
str(match.group(1).strip()) for match in MARKDOWN_PATTERN.finditer(response)
|
265
|
+
]
|
266
|
+
if markdowns:
|
267
|
+
current_page_markdown = "\n".join(markdowns)
|
268
|
+
else:
|
269
|
+
current_page_markdown = response.strip()
|
270
|
+
if current_page_markdown.startswith("```") and current_page_markdown.endswith("```"):
|
271
|
+
current_page_markdown = current_page_markdown[3:-3].strip()
|
272
|
+
|
273
|
+
# Call progress callback if provided
|
274
|
+
if progress_callback:
|
275
|
+
try:
|
276
|
+
progress_callback(i + 1, total_pages_to_process)
|
277
|
+
except Exception as cb_err:
|
278
|
+
logger.warning(f"Progress callback failed: {cb_err}")
|
279
|
+
|
280
|
+
return (i, current_page_markdown)
|
281
|
+
|
282
|
+
except Exception as e:
|
283
|
+
logger.error(f"Failed to process page index {page_idx}: {e}", exc_info=True)
|
284
|
+
return (i, f"<!-- Error processing page {page_idx + 1}: {str(e)} -->")
|
285
|
+
|
286
|
+
# Execute all page processing tasks
|
287
|
+
|
288
|
+
results: list[tuple[int, str] | BaseException] = await asyncio.gather(
|
289
|
+
*(process_page(i, page_idx) for i, page_idx in enumerate(target_page_indices)), return_exceptions=True
|
290
|
+
)
|
291
|
+
|
292
|
+
# Sort results by original page order and extract markdown
|
293
|
+
markdown_results = [""] * total_pages_to_process
|
294
|
+
for result in results:
|
295
|
+
if isinstance(result, Exception):
|
296
|
+
logger.error(f"Task failed with exception: {result}")
|
297
|
+
continue
|
298
|
+
if isinstance(result, tuple) and len(result) == 2:
|
299
|
+
page_order, markdown = result
|
300
|
+
markdown_results[page_order] = markdown
|
301
|
+
else:
|
302
|
+
logger.error(f"Unexpected result format: {result}")
|
303
|
+
|
304
|
+
return "\n\n".join(markdown_results).strip()
|
305
|
+
|
306
|
+
def _convert_sequential(
|
307
|
+
self,
|
308
|
+
pdf_input: "Document | PathOrReadable",
|
309
|
+
page_indices: Optional[PageIndexType] = None,
|
310
|
+
progress_callback: Optional[Callable[[int, int], None]] = None,
|
311
|
+
) -> str:
|
312
|
+
"""Sequential conversion maintaining strict page continuity."""
|
313
|
+
with open_pdf(pdf_input) as doc:
|
314
|
+
target_page_indices = list(
|
315
|
+
_get_page_indices(page_indices=page_indices, max_doc_pages=len(doc), is_input_zero_based=True)
|
316
|
+
)
|
317
|
+
total_pages_to_process = len(target_page_indices)
|
318
|
+
if total_pages_to_process == 0:
|
319
|
+
logger.warning("No pages selected for processing.")
|
320
|
+
return ""
|
321
|
+
|
322
|
+
full_markdown_output: List[str] = []
|
323
|
+
previous_page_markdown: Optional[str] = None
|
324
|
+
|
325
|
+
# Pre-process all pages
|
326
|
+
logger.info("Extracting text and rendering images for selected pages...")
|
327
|
+
page_text_dict = extract_text_from_pdf(doc, target_page_indices)
|
328
|
+
page_image_dict = render_pdf_as_image(
|
329
|
+
doc,
|
330
|
+
page_indices=target_page_indices,
|
331
|
+
zoom=self.image_zoom,
|
332
|
+
output=self.image_format,
|
333
|
+
jpg_quality=self.image_jpg_quality,
|
334
|
+
)
|
335
|
+
logger.info(f"Starting sequential Markdown conversion for {total_pages_to_process} pages...")
|
336
|
+
|
337
|
+
for i, page_idx in enumerate(target_page_indices):
|
338
|
+
logger.info(f"Processing page {i + 1}/{total_pages_to_process} (Index: {page_idx})...")
|
339
|
+
try:
|
340
|
+
context_tail = self._get_context_tail(previous_page_markdown)
|
341
|
+
|
342
|
+
message = self._format_prompt_content_sequential(
|
343
|
+
page_text=page_text_dict.get(page_idx, ""),
|
344
|
+
page_image_b64=Base64Image.from_bytes(page_image_dict[page_idx], ext=self.image_format),
|
345
|
+
previous_markdown_context_tail=context_tail,
|
346
|
+
page_number=page_idx,
|
347
|
+
total_pages=len(doc),
|
348
|
+
)
|
349
|
+
|
350
|
+
response = self.chatterer.generate([message])
|
351
|
+
|
352
|
+
# Extract markdown
|
353
|
+
markdowns = [match.group(1).strip() for match in MARKDOWN_PATTERN.finditer(response)]
|
354
|
+
if markdowns:
|
355
|
+
current_page_markdown = "\n".join(markdowns)
|
356
|
+
else:
|
357
|
+
current_page_markdown = response.strip()
|
358
|
+
if current_page_markdown.startswith("```") and current_page_markdown.endswith("```"):
|
359
|
+
current_page_markdown = current_page_markdown[3:-3].strip()
|
360
|
+
|
361
|
+
full_markdown_output.append(current_page_markdown)
|
362
|
+
previous_page_markdown = current_page_markdown
|
363
|
+
|
364
|
+
except Exception as e:
|
365
|
+
logger.error(f"Failed to process page index {page_idx}: {e}", exc_info=True)
|
366
|
+
continue
|
367
|
+
|
368
|
+
# Progress callback
|
369
|
+
if progress_callback:
|
370
|
+
try:
|
371
|
+
progress_callback(i + 1, total_pages_to_process)
|
372
|
+
except Exception as cb_err:
|
373
|
+
logger.warning(f"Progress callback failed: {cb_err}")
|
374
|
+
|
375
|
+
return "\n\n".join(full_markdown_output).strip()
|
376
|
+
|
377
|
+
def _convert_parallel_sync(
|
378
|
+
self,
|
379
|
+
pdf_input: "Document | PathOrReadable",
|
380
|
+
page_indices: Optional[PageIndexType] = None,
|
381
|
+
progress_callback: Optional[Callable[[int, int], None]] = None,
|
382
|
+
) -> str:
|
383
|
+
"""Synchronous parallel-style conversion (processes independently but sequentially)."""
|
384
|
+
with open_pdf(pdf_input) as doc:
|
385
|
+
target_page_indices = list(
|
386
|
+
_get_page_indices(page_indices=page_indices, max_doc_pages=len(doc), is_input_zero_based=True)
|
387
|
+
)
|
388
|
+
total_pages_to_process = len(target_page_indices)
|
389
|
+
if total_pages_to_process == 0:
|
390
|
+
logger.warning("No pages selected for processing.")
|
391
|
+
return ""
|
392
|
+
|
393
|
+
logger.info(f"Starting parallel-style Markdown conversion for {total_pages_to_process} pages...")
|
394
|
+
|
395
|
+
# Pre-process all pages
|
396
|
+
page_text_dict = extract_text_from_pdf(doc, target_page_indices)
|
397
|
+
page_image_dict = render_pdf_as_image(
|
398
|
+
doc,
|
399
|
+
page_indices=target_page_indices,
|
400
|
+
zoom=self.image_zoom,
|
401
|
+
output=self.image_format,
|
402
|
+
jpg_quality=self.image_jpg_quality,
|
403
|
+
)
|
404
|
+
|
405
|
+
full_markdown_output: List[str] = []
|
406
|
+
|
407
|
+
for i, page_idx in enumerate(target_page_indices):
|
408
|
+
logger.info(f"Processing page {i + 1}/{total_pages_to_process} (Index: {page_idx})...")
|
409
|
+
|
410
|
+
try:
|
411
|
+
# Get previous page data for context
|
412
|
+
prev_page_idx = target_page_indices[i - 1] if i > 0 else None
|
413
|
+
previous_page_text = page_text_dict.get(prev_page_idx) if prev_page_idx is not None else None
|
414
|
+
previous_page_image_b64 = None
|
415
|
+
if prev_page_idx is not None:
|
416
|
+
previous_page_image_b64 = Base64Image.from_bytes(
|
417
|
+
page_image_dict[prev_page_idx], ext=self.image_format
|
418
|
+
)
|
419
|
+
|
420
|
+
message = self._format_prompt_content_parallel(
|
421
|
+
page_text=page_text_dict.get(page_idx, ""),
|
422
|
+
page_image_b64=Base64Image.from_bytes(page_image_dict[page_idx], ext=self.image_format),
|
423
|
+
previous_page_text=previous_page_text,
|
424
|
+
previous_page_image_b64=previous_page_image_b64,
|
425
|
+
page_number=page_idx,
|
426
|
+
total_pages=len(doc),
|
427
|
+
)
|
428
|
+
|
429
|
+
response = self.chatterer.generate([message])
|
430
|
+
|
431
|
+
# Extract markdown
|
432
|
+
markdowns = [match.group(1).strip() for match in MARKDOWN_PATTERN.finditer(response)]
|
433
|
+
if markdowns:
|
434
|
+
current_page_markdown = "\n".join(markdowns)
|
435
|
+
else:
|
436
|
+
current_page_markdown = response.strip()
|
437
|
+
if current_page_markdown.startswith("```") and current_page_markdown.endswith("```"):
|
438
|
+
current_page_markdown = current_page_markdown[3:-3].strip()
|
439
|
+
|
440
|
+
full_markdown_output.append(current_page_markdown)
|
441
|
+
|
442
|
+
except Exception as e:
|
443
|
+
logger.error(f"Failed to process page index {page_idx}: {e}", exc_info=True)
|
444
|
+
continue
|
445
|
+
|
446
|
+
# Progress callback
|
447
|
+
if progress_callback:
|
448
|
+
try:
|
449
|
+
progress_callback(i + 1, total_pages_to_process)
|
450
|
+
except Exception as cb_err:
|
451
|
+
logger.warning(f"Progress callback failed: {cb_err}")
|
452
|
+
|
453
|
+
return "\n\n".join(full_markdown_output).strip()
|
454
|
+
|
455
|
+
|
456
|
+
def render_pdf_as_image(
|
457
|
+
doc: "Document",
|
458
|
+
zoom: float = 2.0,
|
459
|
+
output: Literal["png", "pnm", "pgm", "ppm", "pbm", "pam", "tga", "tpic", "psd", "ps", "jpg", "jpeg"] = "png",
|
460
|
+
jpg_quality: int = 100,
|
461
|
+
page_indices: Iterable[int] | int | None = None,
|
462
|
+
) -> dict[int, bytes]:
|
463
|
+
"""
|
464
|
+
Convert PDF pages to images in bytes.
|
465
|
+
|
466
|
+
Args:
|
467
|
+
doc (Document): The PDF document to convert.
|
468
|
+
zoom (float): Zoom factor for the image resolution. Default is 2.0.
|
469
|
+
output (str): Output format for the image. Default is 'png'.
|
470
|
+
jpg_quality (int): Quality of JPEG images (1-100). Default is 100.
|
471
|
+
page_indices (Iterable[int] | int | None): Specific pages to convert. If None, all pages are converted.
|
472
|
+
If an int is provided, only that page is converted.
|
473
|
+
|
474
|
+
Returns:
|
475
|
+
dict[int, bytes]: A dictionary mapping page numbers to image bytes.
|
476
|
+
"""
|
477
|
+
from pymupdf import Matrix # pyright: ignore[reportMissingTypeStubs]
|
478
|
+
from pymupdf.utils import get_pixmap # pyright: ignore[reportMissingTypeStubs, reportUnknownVariableType]
|
479
|
+
|
480
|
+
images_bytes: dict[int, bytes] = {}
|
481
|
+
matrix = Matrix(zoom, zoom) # Control output resolution
|
482
|
+
for page_idx in _get_page_indices(page_indices=page_indices, max_doc_pages=len(doc), is_input_zero_based=True):
|
483
|
+
img_bytes = bytes(
|
484
|
+
get_pixmap(
|
485
|
+
page=doc[page_idx],
|
486
|
+
matrix=matrix,
|
487
|
+
).tobytes(output=output, jpg_quality=jpg_quality) # pyright: ignore[reportUnknownArgumentType]
|
488
|
+
)
|
489
|
+
images_bytes[page_idx] = img_bytes
|
490
|
+
return images_bytes
|
491
|
+
|
492
|
+
|
493
|
+
def extract_text_from_pdf(doc: "Document", page_indices: Optional[PageIndexType] = None) -> dict[int, str]:
|
494
|
+
"""Convert a PDF file to plain text.
|
495
|
+
|
496
|
+
Extracts text from each page of a PDF file and formats it with page markers.
|
497
|
+
|
498
|
+
Args:
|
499
|
+
doc (Document): The PDF document to convert.
|
500
|
+
page_indices (Iterable[int] | int | None): Specific pages to convert. If None, all pages are converted.
|
501
|
+
If an int is provided, only that page is converted.
|
502
|
+
|
503
|
+
Returns:
|
504
|
+
dict[int, str]: A dictionary mapping page numbers to text content.
|
505
|
+
"""
|
506
|
+
return {
|
507
|
+
page_idx: doc[page_idx].get_textpage().extractText().strip() # pyright: ignore[reportUnknownMemberType]
|
508
|
+
for page_idx in _get_page_indices(
|
509
|
+
page_indices=page_indices,
|
510
|
+
max_doc_pages=len(doc),
|
511
|
+
is_input_zero_based=True,
|
512
|
+
)
|
513
|
+
}
|
514
|
+
|
515
|
+
|
516
|
+
@contextmanager
|
517
|
+
def open_pdf(pdf_input: "PathOrReadable | Document"):
|
518
|
+
"""Open a PDF document from a file path or use an existing Document object.
|
519
|
+
|
520
|
+
Args:
|
521
|
+
pdf_input (PathOrReadable | Document): The PDF file path or a pymupdf.Document object.
|
522
|
+
|
523
|
+
Returns:
|
524
|
+
tuple[Document, bool]: A tuple containing the opened Document object and a boolean indicating if it was opened internally.
|
525
|
+
"""
|
526
|
+
import pymupdf # pyright: ignore[reportMissingTypeStubs]
|
527
|
+
|
528
|
+
should_close = True
|
529
|
+
|
530
|
+
if isinstance(pdf_input, pymupdf.Document):
|
531
|
+
should_close = False
|
532
|
+
doc = pdf_input
|
533
|
+
else:
|
534
|
+
with read_bytes_stream(pdf_input) as stream:
|
535
|
+
if stream is None:
|
536
|
+
raise FileNotFoundError(pdf_input)
|
537
|
+
doc = pymupdf.Document(stream=stream.read())
|
538
|
+
yield doc
|
539
|
+
if should_close:
|
540
|
+
doc.close()
|
541
|
+
|
542
|
+
|
543
|
+
def _get_page_indices(
|
544
|
+
page_indices: Optional[PageIndexType], max_doc_pages: int, is_input_zero_based: bool
|
545
|
+
) -> list[int]:
|
546
|
+
"""Helper function to handle page indices for PDF conversion."""
|
547
|
+
|
548
|
+
def _to_zero_based_int(idx: int) -> int:
|
549
|
+
"""Convert a 1-based index to a 0-based index if necessary."""
|
550
|
+
if is_input_zero_based:
|
551
|
+
return idx
|
552
|
+
else:
|
553
|
+
if idx < 1 or idx > max_doc_pages:
|
554
|
+
raise ValueError(f"Index {idx} is out of bounds for document with {max_doc_pages} pages (1-based).")
|
555
|
+
return idx - 1
|
556
|
+
|
557
|
+
if page_indices is None:
|
558
|
+
return list(range(max_doc_pages)) # Convert all pages
|
559
|
+
elif isinstance(page_indices, int):
|
560
|
+
# Handle single integer input for page index
|
561
|
+
return [_to_zero_based_int(page_indices)]
|
562
|
+
elif isinstance(page_indices, str):
|
563
|
+
# Handle string input for page indices
|
564
|
+
return _interpret_index_string(
|
565
|
+
index_str=page_indices, max_doc_pages=max_doc_pages, is_input_zero_based=is_input_zero_based
|
566
|
+
)
|
567
|
+
else:
|
568
|
+
# Handle iterable input for page indices
|
569
|
+
indices: set[int] = set()
|
570
|
+
for idx in page_indices:
|
571
|
+
if isinstance(idx, int):
|
572
|
+
indices.add(_to_zero_based_int(idx))
|
573
|
+
else:
|
574
|
+
start, end = idx
|
575
|
+
if isinstance(start, EllipsisType):
|
576
|
+
start = 0
|
577
|
+
else:
|
578
|
+
start = _to_zero_based_int(start)
|
579
|
+
|
580
|
+
if isinstance(end, EllipsisType):
|
581
|
+
end = max_doc_pages - 1
|
582
|
+
else:
|
583
|
+
end = _to_zero_based_int(end)
|
584
|
+
|
585
|
+
if start > end:
|
586
|
+
raise ValueError(
|
587
|
+
f"Invalid range: {start} - {end}. Start index must be less than or equal to end index."
|
588
|
+
)
|
589
|
+
indices.update(range(start, end + 1))
|
590
|
+
|
591
|
+
return sorted(indices) # Return sorted list of indices
|
592
|
+
|
593
|
+
|
594
|
+
def _interpret_index_string(index_str: str, max_doc_pages: int, is_input_zero_based: bool) -> list[int]:
|
595
|
+
"""Interpret a string of comma-separated indices and ranges."""
|
596
|
+
|
597
|
+
def _to_zero_based_int(idx_str: str) -> int:
|
598
|
+
i = int(idx_str)
|
599
|
+
if is_input_zero_based:
|
600
|
+
if i < 0 or i >= max_doc_pages:
|
601
|
+
raise ValueError(f"Index {i} is out of bounds for document with {max_doc_pages} pages.")
|
602
|
+
return i
|
603
|
+
else:
|
604
|
+
if i < 1 or i > max_doc_pages:
|
605
|
+
raise ValueError(f"Index {i} is out of bounds for document with {max_doc_pages} pages (1-based).")
|
606
|
+
return i - 1 # Convert to zero-based index
|
607
|
+
|
608
|
+
indices: set[int] = set()
|
609
|
+
for part in index_str.split(","):
|
610
|
+
part: str = part.strip()
|
611
|
+
count_dash: int = part.count("-")
|
612
|
+
if count_dash == 0:
|
613
|
+
indices.add(_to_zero_based_int(part))
|
614
|
+
elif count_dash == 1:
|
615
|
+
idx_dash: int = part.index("-")
|
616
|
+
start = part[:idx_dash].strip()
|
617
|
+
end = part[idx_dash + 1 :].strip()
|
618
|
+
if not start:
|
619
|
+
start = _to_zero_based_int("0") # Default to 0 if no start index is provided
|
620
|
+
else:
|
621
|
+
start = _to_zero_based_int(start)
|
622
|
+
|
623
|
+
if not end:
|
624
|
+
end = _to_zero_based_int(str(max_doc_pages - 1)) # Default to last page if no end index is provided
|
625
|
+
else:
|
626
|
+
end = _to_zero_based_int(end)
|
627
|
+
|
628
|
+
if start > end:
|
629
|
+
raise ValueError(
|
630
|
+
f"Invalid range: {start} - {end}. Start index must be less than or equal to end index."
|
631
|
+
)
|
632
|
+
indices.update(range(start, end + 1))
|
633
|
+
else:
|
634
|
+
raise ValueError(f"Invalid page index format: '{part}'. Expected format is '1,2,3' or '1-3'.")
|
635
|
+
|
636
|
+
return sorted(indices) # Return sorted list of indices, ensuring no duplicates
|