1"""Apify Actor: PDF pages to PNG/JPG/WebP, images to PDF, image format conversion (incl. iPhone HEIC)."""
2from __future__ import annotations
3
4import asyncio
5import mimetypes
6import os
7import re
8import tempfile
9import zipfile
10
11import httpx
12from apify import Actor
13
14from .convert import OUTPUT_FORMATS, ConversionError, convert_image, detect_kind, images_to_one_pdf, pdf_to_images
15from .download import DownloadError, download
16
17EVENT_PAGE = 'page'
18
19
20def _sources(inp: dict) -> list[str]:
21 urls = []
22 for item in inp.get('sources') or []:
23 url = item.get('url') if isinstance(item, dict) else item
24 if isinstance(url, str) and url.strip():
25 urls.append(url.strip())
26 return urls
27
28
29def _stem(name: str, idx: int) -> str:
30 stem = re.sub(r'[^A-Za-z0-9._-]+', '-', os.path.splitext(name or '')[0]).strip('-.')[:60]
31 return f'{idx:04d}-{stem or "file"}'
32
33
34async def main() -> None:
35 async with Actor:
36 inp = await Actor.get_input() or {}
37 urls = _sources(inp)
38 if not urls:
39 await Actor.fail(status_message='Input "sources" is empty: add at least one file URL.')
40 return
41 fmt = OUTPUT_FORMATS.get(str(inp.get('outputFormat', 'png')).lower())
42 if not fmt:
43 await Actor.fail(status_message=f'outputFormat must be one of {", ".join(OUTPUT_FORMATS)}.')
44 return
45 dpi = min(max(int(inp.get('dpi') or 150), 36), 600)
46 quality = min(max(int(inp.get('quality') or 85), 10), 100)
47 max_width = int(inp.get('maxWidth') or 0)
48 pages_spec = inp.get('pages') or ''
49 merge_images = bool(inp.get('mergeImagesIntoOnePdf', False))
50 make_zip = bool(inp.get('zipAll', False))
51
52 charging = Actor.get_charging_manager()
53 store = await Actor.open_key_value_store()
54 all_outputs, image_inputs = [], []
55 ok = failed = 0
56 with tempfile.TemporaryDirectory() as tmp:
57 async with httpx.AsyncClient(timeout=httpx.Timeout(120, connect=20),
58 headers={'User-Agent': 'pdf-image-converter (Apify Actor)'}) as client:
59 for idx, url in enumerate(urls, 1):
60 item = {'url': url}
61 left = charging.calculate_max_event_charge_count_within_limit(EVENT_PAGE)
62 if left == 0:
63 Actor.log.warning('Maximum charge per run reached, remaining files were skipped.')
64 break
65 src = os.path.join(tmp, f'in-{idx:04d}')
66 outdir = os.path.join(tmp, f'out-{idx:04d}')
67 os.makedirs(outdir)
68 try:
69 name, ctype = await download(client, url, src)
70 kind = detect_kind(src, name, ctype)
71 stem = _stem(name, idx)
72 warnings = []
73 if kind == 'pdf':
74 if fmt == 'PDF':
75 raise ConversionError('the file is already a PDF; choose png, jpg, webp or tiff')
76 outs, total, warnings = await asyncio.to_thread(
77 pdf_to_images, src, outdir, stem, fmt=fmt, dpi=dpi, pages=pages_spec,
78 quality=quality, max_width=max_width, limit=left)
79 item['sourcePages'] = total
80 else:
81 outs = await asyncio.to_thread(convert_image, src, outdir, stem, fmt=fmt,
82 quality=quality, max_width=max_width)
83 if left is not None and len(outs) > left:
84 warnings.append(f'only {left} of {len(outs)} frames kept: maximum charge per run reached')
85 outs = outs[:left]
86 image_inputs.append(src)
87 except (DownloadError, ConversionError, httpx.HTTPError) as e:
88 failed += 1
89 item['error'] = str(e) or type(e).__name__
90 await Actor.push_data(item)
91 continue
92 files = []
93 for p in outs:
94 key = os.path.basename(p)
95 with open(p, 'rb') as f:
96 await store.set_value(key, f.read(), content_type=mimetypes.guess_type(p)[0])
97 files.append({'key': key, 'url': await store.get_public_url(key), 'bytes': os.path.getsize(p)})
98 all_outputs.append(p)
99 item.update(fileName=name or None, kind=kind, outputFormat=fmt.lower(), count=len(files),
100 files=files, warnings=warnings)
101 await Actor.push_data(item)
102
103 if files:
104 await Actor.charge(EVENT_PAGE, count=len(files))
105 ok += 1
106
107 if merge_images and len(image_inputs) > 0 and fmt != 'PDF':
108 Actor.log.info('mergeImagesIntoOnePdf works with outputFormat "pdf" only; skipped.')
109 elif merge_images and len(image_inputs) > 1:
110 dest = os.path.join(tmp, 'merged.pdf')
111 pages = await asyncio.to_thread(images_to_one_pdf, image_inputs, dest, max_width)
112 with open(dest, 'rb') as f:
113 await store.set_value('merged.pdf', f.read(), content_type='application/pdf')
114 await Actor.push_data({'url': None, 'merged': True, 'count': pages,
115 'files': [{'key': 'merged.pdf', 'url': await store.get_public_url('merged.pdf'),
116 'bytes': os.path.getsize(dest)}]})
117 if make_zip and all_outputs:
118 dest = os.path.join(tmp, 'all-files.zip')
119 with zipfile.ZipFile(dest, 'w', zipfile.ZIP_DEFLATED) as z:
120 for p in all_outputs:
121 z.write(p, os.path.basename(p))
122 with open(dest, 'rb') as f:
123 await store.set_value('all-files.zip', f.read(), content_type='application/zip')
124 await Actor.push_data({'url': None, 'zip': True, 'count': len(all_outputs),
125 'files': [{'key': 'all-files.zip',
126 'url': await store.get_public_url('all-files.zip'),
127 'bytes': os.path.getsize(dest)}]})
128 await Actor.set_status_message(f'Converted {ok} file(s), failed {failed}.')