repdf 0.0.8__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.
repdf/assets/check.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M3.5 8 6.5 11 12.5 5" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M4 8h8" stroke="white" stroke-width="2" stroke-linecap="round"/></svg>
repdf/assets/repdf.ico ADDED
Binary file
repdf/assets/repdf.png ADDED
Binary file
repdf/core.py ADDED
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ from repdf.strings import tr
4
+
5
+ import os
6
+ import tempfile
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from pathlib import Path
10
+ from typing import Iterable
11
+
12
+ from pypdf import PdfReader, PdfWriter
13
+
14
+
15
+ class RepdfError(Exception):
16
+ """Base error presented to the user."""
17
+
18
+
19
+ class UnsupportedEncryptedPdfError(RepdfError):
20
+ """Raised when a PDF requires a password."""
21
+
22
+
23
+ class InvalidPdfError(RepdfError):
24
+ """Raised when a PDF cannot be parsed."""
25
+
26
+
27
+ class SelectionState(Enum):
28
+ NONE = 0
29
+ PARTIAL = 1
30
+ ALL = 2
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class PdfInfo:
35
+ path: Path
36
+ page_count: int
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class DocumentSelection:
41
+ source: Path
42
+ pages: tuple[int, ...]
43
+
44
+
45
+ def _prepare_documents(documents: Iterable[DocumentSelection]):
46
+ prepared = []
47
+ readers = {}
48
+ for document in documents:
49
+ if not document.pages:
50
+ continue
51
+ source = Path(document.source).resolve()
52
+ if source not in readers:
53
+ if not source.is_file():
54
+ raise FileNotFoundError(tr('找不到來源 PDF 檔案:{p0}', p0=source))
55
+ readers[source] = PdfReader(source)
56
+ reader = readers[source]
57
+ if reader.is_encrypted:
58
+ raise UnsupportedEncryptedPdfError(tr('目前不支援密碼保護的 PDF:{p0}', p0=source))
59
+ if any(page < 0 or page >= len(reader.pages) for page in document.pages):
60
+ raise IndexError(tr('選取的頁碼超出來源 PDF 範圍:{p0}', p0=source))
61
+ prepared.append((source, reader, document.pages))
62
+ if not prepared:
63
+ raise ValueError(tr('請至少選取一頁。'))
64
+ return prepared
65
+
66
+
67
+ def _copy_metadata(reader: PdfReader, writer: PdfWriter) -> None:
68
+ if reader.metadata:
69
+ writer.add_metadata({str(key): str(value) for key, value in reader.metadata.items()
70
+ if key and value is not None})
71
+
72
+
73
+ def indices_for_parity(page_count: int, odd: bool) -> set[int]:
74
+ """Return zero-based indices for human-facing odd or even page numbers."""
75
+ start = 0 if odd else 1
76
+ return set(range(start, page_count, 2))
77
+
78
+
79
+ def parity_state(selected: set[int], page_count: int, odd: bool) -> SelectionState:
80
+ group = indices_for_parity(page_count, odd)
81
+ if not group or not (selected & group):
82
+ return SelectionState.NONE
83
+ if group <= selected:
84
+ return SelectionState.ALL
85
+ return SelectionState.PARTIAL
86
+
87
+
88
+ def set_parity(
89
+ selected: set[int], page_count: int, odd: bool, enabled: bool
90
+ ) -> set[int]:
91
+ result = set(selected)
92
+ group = indices_for_parity(page_count, odd)
93
+ if enabled:
94
+ result.update(group)
95
+ else:
96
+ result.difference_update(group)
97
+ return result
98
+
99
+
100
+ def default_output_path(source: str | Path) -> Path:
101
+ path = Path(source)
102
+ return path.with_name(f"{path.stem}_selected.pdf")
103
+
104
+
105
+ def split_output_paths(destination: str | Path, count: int) -> list[Path]:
106
+ path = Path(destination)
107
+ digits = max(2, len(str(count)))
108
+ return [path.with_name(f"{path.stem}_{number:0{digits}d}.pdf")
109
+ for number in range(1, count + 1)]
110
+
111
+
112
+ def split_selected_pages(
113
+ source: str | Path, destination: str | Path, selected: Iterable[int], *,
114
+ overwrite: bool = False,
115
+ ) -> list[Path]:
116
+ ordered = sorted(selected) if isinstance(selected, (set, frozenset)) else list(dict.fromkeys(selected))
117
+ return split_documents([DocumentSelection(Path(source), tuple(ordered))], destination,
118
+ overwrite=overwrite)
119
+
120
+
121
+ def split_documents(
122
+ documents: Iterable[DocumentSelection], destination: str | Path, *,
123
+ overwrite: bool = False, protected_sources: Iterable[Path] = (),
124
+ ) -> list[Path]:
125
+ prepared = _prepare_documents(documents)
126
+ outputs = split_output_paths(Path(destination).resolve(), sum(len(pages) for _, _, pages in prepared))
127
+ protected = {source for source, _, _ in prepared} | {Path(path).resolve() for path in protected_sources}
128
+ if protected.intersection(outputs):
129
+ raise ValueError(tr('輸出檔案不可覆蓋來源 PDF。'))
130
+ if not overwrite and any(path.exists() for path in outputs):
131
+ raise FileExistsError(tr('部分單頁檔案已存在,請確認覆蓋或更換檔名。'))
132
+ outputs[0].parent.mkdir(parents=True, exist_ok=True)
133
+ # Finish generating every page before publishing any output file.
134
+ with tempfile.TemporaryDirectory(prefix=".repdf-split-", dir=outputs[0].parent) as staging:
135
+ staged = []
136
+ ordered = [(reader, page) for _, reader, pages in prepared for page in pages]
137
+ for (reader, page), output in zip(ordered, outputs):
138
+ writer = PdfWriter()
139
+ writer.add_page(reader.pages[page])
140
+ _copy_metadata(reader, writer)
141
+ pending = Path(staging) / output.name
142
+ with pending.open("wb") as stream:
143
+ writer.write(stream)
144
+ stream.flush()
145
+ os.fsync(stream.fileno())
146
+ staged.append(pending)
147
+ published = 0
148
+ try:
149
+ for pending, output in zip(staged, outputs):
150
+ if overwrite:
151
+ os.replace(pending, output)
152
+ elif os.name == "nt":
153
+ # Windows rename is atomic and refuses an existing destination.
154
+ os.rename(pending, output)
155
+ else:
156
+ # Exclusive publication also protects against files created during export.
157
+ os.link(pending, output)
158
+ published += 1
159
+ except OSError as exc:
160
+ raise OSError(tr('拆分輸出失敗;已完成 {p0} / {p1} 個檔案。\n{p2}', p0=published, p1=len(outputs), p2=exc)) from exc
161
+ return outputs
162
+
163
+
164
+ def inspect_pdf(source: str | Path) -> PdfInfo:
165
+ path = Path(source).resolve()
166
+ if not path.is_file():
167
+ raise InvalidPdfError(tr('找不到來源 PDF 檔案。'))
168
+ try:
169
+ reader = PdfReader(path)
170
+ if reader.is_encrypted:
171
+ raise UnsupportedEncryptedPdfError(tr('目前不支援密碼保護的 PDF。'))
172
+ page_count = len(reader.pages)
173
+ except UnsupportedEncryptedPdfError:
174
+ raise
175
+ except Exception as exc:
176
+ raise InvalidPdfError(tr('無法讀取這個 PDF,檔案可能已損壞或格式不受支援。')) from exc
177
+ if page_count == 0:
178
+ raise InvalidPdfError(tr('這個 PDF 沒有可用的頁面。'))
179
+ return PdfInfo(path=path, page_count=page_count)
180
+
181
+
182
+ def export_selected_pages(
183
+ source: str | Path, destination: str | Path, selected: Iterable[int]
184
+ ) -> int:
185
+ ordered = (
186
+ sorted(selected) if isinstance(selected, (set, frozenset))
187
+ else list(dict.fromkeys(selected))
188
+ )
189
+ return export_documents([DocumentSelection(Path(source), tuple(ordered))], destination)
190
+
191
+
192
+ def export_documents(
193
+ documents: Iterable[DocumentSelection], destination: str | Path, *,
194
+ protected_sources: Iterable[Path] = (),
195
+ ) -> int:
196
+ prepared = _prepare_documents(documents)
197
+ destination_path = Path(destination).resolve()
198
+ protected = {source for source, _, _ in prepared} | {Path(path).resolve() for path in protected_sources}
199
+ if destination_path in protected:
200
+ raise ValueError(tr('輸出檔案不可覆蓋來源 PDF。'))
201
+
202
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
203
+ temp_path: Path | None = None
204
+ try:
205
+ writer = PdfWriter()
206
+ for _, reader, pages in prepared:
207
+ for page_index in pages:
208
+ writer.add_page(reader.pages[page_index])
209
+ _copy_metadata(prepared[0][1], writer)
210
+
211
+ with tempfile.NamedTemporaryFile(
212
+ mode="wb", suffix=".pdf", prefix=".repdf-", dir=destination_path.parent,
213
+ delete=False,
214
+ ) as stream:
215
+ temp_path = Path(stream.name)
216
+ writer.write(stream)
217
+ stream.flush()
218
+ os.fsync(stream.fileno())
219
+ os.replace(temp_path, destination_path)
220
+ temp_path = None
221
+ return sum(len(pages) for _, _, pages in prepared)
222
+ finally:
223
+ if temp_path is not None:
224
+ temp_path.unlink(missing_ok=True)
repdf/entrypoint.py ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+
6
+
7
+ def main() -> int:
8
+ # Keep libraries that log to stderr compatible with windowed bootloaders.
9
+ for name in ("stdout", "stderr"):
10
+ if getattr(sys, name) is None:
11
+ setattr(sys, name, open(os.devnull, "w", encoding="utf-8"))
12
+ if len(sys.argv) == 4 and sys.argv[1] == "--render-server":
13
+ from repdf.render_worker import serve_socket
14
+ return serve_socket(int(sys.argv[2]), sys.argv[3])
15
+ if len(sys.argv) == 3 and sys.argv[1] == "--self-test":
16
+ from repdf.packaging_smoke import main as smoke_main
17
+ return smoke_main(sys.argv[2])
18
+ from repdf.app import main as app_main
19
+ return app_main()
repdf/icons.py ADDED
@@ -0,0 +1,81 @@
1
+ """Small, flat toolbar icons drawn at device-independent coordinates."""
2
+
3
+ from PySide6.QtCore import QPointF, QRectF, Qt
4
+ from PySide6.QtGui import QFont, QIcon, QPainter, QPainterPath, QPalette, QPen, QPixmap, QPolygonF
5
+ from PySide6.QtWidgets import QApplication
6
+
7
+
8
+ def toolbar_icon(kind: str) -> QIcon:
9
+ icon = QIcon()
10
+ for mode, group in ((QIcon.Mode.Normal, QPalette.ColorGroup.Active),
11
+ (QIcon.Mode.Disabled, QPalette.ColorGroup.Disabled)):
12
+ pixmap = QPixmap(48, 48)
13
+ pixmap.fill(Qt.GlobalColor.transparent)
14
+ painter = QPainter(pixmap)
15
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
16
+ painter.scale(2, 2)
17
+ painter.setPen(QPen(QApplication.palette().color(group, QPalette.ColorRole.ButtonText),
18
+ 1.0, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap,
19
+ Qt.PenJoinStyle.RoundJoin))
20
+ if kind in ("language_zh", "language_en"):
21
+ font = QFont(QApplication.font())
22
+ font.setPixelSize(16)
23
+ painter.setFont(font)
24
+ painter.drawText(QRectF(1, 1, 22, 22), Qt.AlignmentFlag.AlignCenter,
25
+ "中" if kind == "language_zh" else "En")
26
+ elif kind == "language_system":
27
+ painter.drawEllipse(QRectF(3, 3, 18, 18))
28
+ painter.drawEllipse(QRectF(8, 3, 8, 18))
29
+ painter.drawLine(3, 12, 21, 12)
30
+ elif kind == "theme_system":
31
+ moon = QPainterPath(QPointF(8, 2))
32
+ moon.cubicTo(1, 1, 1, 10, 6, 10)
33
+ moon.cubicTo(8, 10, 9, 8, 9, 7)
34
+ moon.cubicTo(4, 8, 4, 4, 8, 2)
35
+ painter.drawPath(moon)
36
+ painter.drawLine(19, 2, 5, 22)
37
+ painter.drawEllipse(QRectF(14, 14, 6, 6))
38
+ for x1, y1, x2, y2 in ((17, 11, 17, 12), (17, 22, 17, 23),
39
+ (11, 17, 12, 17), (22, 17, 23, 17),
40
+ (12, 12, 13, 13), (21, 21, 22, 22),
41
+ (12, 22, 13, 21), (21, 13, 22, 12)):
42
+ painter.drawLine(x1, y1, x2, y2)
43
+ elif kind == "theme_light":
44
+ painter.drawEllipse(QRectF(7, 7, 10, 10))
45
+ for x1, y1, x2, y2 in ((12, 2, 12, 4), (12, 20, 12, 22), (2, 12, 4, 12),
46
+ (20, 12, 22, 12), (5, 5, 6, 6), (18, 18, 19, 19),
47
+ (5, 19, 6, 18), (18, 6, 19, 5)):
48
+ painter.drawLine(x1, y1, x2, y2)
49
+ elif kind == "theme_dark":
50
+ path = QPainterPath(QPointF(11, 3))
51
+ path.cubicTo(1, 4, 1, 19, 12, 21)
52
+ path.cubicTo(17, 22, 21, 18, 21, 14)
53
+ path.cubicTo(10, 18, 6, 9, 11, 3)
54
+ painter.drawPath(path)
55
+ elif kind == "open":
56
+ painter.drawPolyline(QPolygonF([QPointF(3, 19), QPointF(3, 5), QPointF(9, 5),
57
+ QPointF(12, 8), QPointF(20, 8), QPointF(20, 10)]))
58
+ painter.drawPolygon(QPolygonF([QPointF(3, 19), QPointF(7, 11), QPointF(22, 11),
59
+ QPointF(18, 19)]))
60
+ elif kind == "export":
61
+ painter.drawPolyline(QPolygonF([QPointF(8, 16), QPointF(4, 16), QPointF(4, 3),
62
+ QPointF(14, 3), QPointF(18, 7), QPointF(18, 10)]))
63
+ painter.drawPolyline(QPolygonF([QPointF(14, 3), QPointF(14, 7), QPointF(18, 7)]))
64
+ painter.drawLine(13, 11, 13, 20)
65
+ painter.drawPolyline(QPolygonF([QPointF(9, 16), QPointF(13, 20), QPointF(17, 16)]))
66
+ painter.drawPolyline(QPolygonF([QPointF(6, 20), QPointF(6, 22), QPointF(20, 22),
67
+ QPointF(20, 20)]))
68
+ elif kind == "split":
69
+ painter.drawRoundedRect(QRectF(2, 2, 8, 13), 1, 1)
70
+ painter.drawRoundedRect(QRectF(14, 9, 8, 13), 1, 1)
71
+ painter.drawLine(6, 19, 12, 19)
72
+ painter.drawPolyline(QPolygonF([QPointF(9, 16), QPointF(12, 19), QPointF(9, 22)]))
73
+ elif kind in ("zoom_in", "zoom_out"):
74
+ painter.drawEllipse(QRectF(3, 3, 13, 13))
75
+ painter.drawLine(15, 15, 21, 21)
76
+ painter.drawLine(6, 9, 13, 9)
77
+ if kind == "zoom_in":
78
+ painter.drawLine(9, 6, 9, 13)
79
+ painter.end()
80
+ icon.addPixmap(pixmap, mode)
81
+ return icon
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import traceback
7
+ from pathlib import Path
8
+
9
+
10
+ def main(report_path: str) -> int:
11
+ report = Path(report_path)
12
+ report.parent.mkdir(parents=True, exist_ok=True)
13
+ process = None
14
+ try:
15
+ os.environ["QT_QPA_PLATFORM"] = "offscreen"
16
+ from PySide6.QtGui import QImage
17
+ from PySide6.QtWidgets import QApplication
18
+ from pypdf import PdfReader, PdfWriter
19
+ from repdf import __version__
20
+ from repdf.app import load_app_icon
21
+ from repdf.core import DocumentSelection, export_selected_pages, export_documents, split_documents
22
+ from repdf.renderer import start_render_backend
23
+ from repdf.preferences import LanguageController
24
+ from repdf.strings import tr
25
+ from repdf.theme import ThemeController
26
+ app = QApplication([])
27
+ language = LanguageController("zh")
28
+ if language.qt_translator.isEmpty():
29
+ raise RuntimeError("Traditional Chinese dialog translations are missing")
30
+ language.set_mode("en")
31
+ if tr("第 {p0} 頁", p0=1) != "Page 1":
32
+ raise RuntimeError("English translation failed")
33
+ theme = ThemeController(app, "light")
34
+ theme.set_mode("dark")
35
+ language.close()
36
+ if load_app_icon().isNull():
37
+ raise RuntimeError("Application icon is missing")
38
+ with tempfile.TemporaryDirectory(prefix="repdf-smoke-") as directory:
39
+ root = Path(directory)
40
+ source = root / "source.pdf"
41
+ writer = PdfWriter()
42
+ for width in (200, 300):
43
+ writer.add_blank_page(width=width, height=400)
44
+ with source.open("wb") as stream:
45
+ writer.write(stream)
46
+ process = start_render_backend()
47
+ process.stdin.write(json.dumps({
48
+ "source": str(source), "output_dir": str(root),
49
+ "scale": 0.5, "generation": 1,
50
+ }) + "\n")
51
+ process.stdin.flush()
52
+ pages = []
53
+ for line in process.stdout:
54
+ message = json.loads(line)
55
+ if message["type"] == "error":
56
+ raise RuntimeError(message["message"])
57
+ if message["type"] == "page":
58
+ if QImage(message["path"]).isNull():
59
+ raise RuntimeError("Invalid rendered page")
60
+ pages.append(message["page"])
61
+ if message["type"] == "done":
62
+ break
63
+ if pages != [0, 1]:
64
+ raise RuntimeError(f"Unexpected rendered pages: {pages}")
65
+ output = root / "reordered.pdf"
66
+ export_selected_pages(source, output, [1, 0])
67
+ if [int(page.mediabox.width) for page in PdfReader(output).pages] != [300, 200]:
68
+ raise RuntimeError("Reordered export failed")
69
+ second = root / "second.pdf"
70
+ writer = PdfWriter()
71
+ writer.add_blank_page(width=400, height=500)
72
+ with second.open("wb") as stream:
73
+ writer.write(stream)
74
+ selections = [DocumentSelection(second, (0,)), DocumentSelection(source, (1, 0))]
75
+ merged = root / "multi.pdf"
76
+ export_documents(selections, merged)
77
+ if [int(page.mediabox.width) for page in PdfReader(merged).pages] != [400, 300, 200]:
78
+ raise RuntimeError("Multi-document export failed")
79
+ single_pages = split_documents(selections, root / "single.pdf")
80
+ if [int(PdfReader(path).pages[0].mediabox.width) for path in single_pages] != [400, 300, 200]:
81
+ raise RuntimeError("Multi-document split failed")
82
+ report.write_text(json.dumps({"ok": True, "version": __version__}), encoding="utf-8")
83
+ return 0
84
+ except Exception:
85
+ report.write_text(json.dumps({"ok": False, "error": traceback.format_exc()}), encoding="utf-8")
86
+ return 1
87
+ finally:
88
+ if process is not None:
89
+ process.terminate()
90
+ try:
91
+ process.wait(timeout=5)
92
+ except Exception:
93
+ process.kill()
94
+ process.wait(timeout=5)
95
+ for stream in (process.stdin, process.stdout, process.stderr):
96
+ if stream is not None:
97
+ stream.close()
repdf/preferences.py ADDED
@@ -0,0 +1,79 @@
1
+ from importlib.resources import files
2
+
3
+ from PySide6.QtCore import QLibraryInfo, QLocale, QObject, QTranslator, Signal
4
+ from PySide6.QtWidgets import QApplication
5
+
6
+ from repdf import strings
7
+
8
+
9
+ def migrate_preferences(settings, legacy) -> None:
10
+ """Copy existing choices once without changing the old app's settings."""
11
+ modes = {"ui/language": {"system", "zh", "en"}, "ui/theme": {"system", "light", "dark"}}
12
+ for key, allowed in modes.items():
13
+ if not settings.contains(key):
14
+ value = legacy.value(key)
15
+ if value in allowed:
16
+ settings.setValue(key, value)
17
+
18
+
19
+ def system_language() -> str:
20
+ locale = QLocale.system()
21
+ preferred = locale.uiLanguages()
22
+ if preferred:
23
+ language = preferred[0].lower().replace("_", "-").split("-")[0]
24
+ return "zh" if language == "zh" else "en"
25
+ return "zh" if locale.language() == QLocale.Language.Chinese else "en"
26
+
27
+
28
+ class DialogTranslator(QTranslator):
29
+ def isEmpty(self):
30
+ return False
31
+
32
+ def translate(self, context, source, disambiguation=None, n=-1):
33
+ words = {"&Yes": "是(&Y)", "&No": "否(&N)", "Yes": "是", "No": "否",
34
+ "OK": "確定", "&OK": "確定", "Cancel": "取消", "&Cancel": "取消",
35
+ "Open": "開啟", "&Open": "開啟(&O)", "Save": "儲存", "&Save": "儲存(&S)",
36
+ "Close": "關閉", "&Close": "關閉", "File &name:": "檔案名稱(&N):",
37
+ "Files of type:": "檔案類型:", "Look in:": "查詢位置:",
38
+ "Save in:": "儲存位置:", "Name": "名稱", "Size": "大小", "Type": "類型",
39
+ "Date Modified": "修改時間", "All Files (*)": "所有檔案 (*)"}
40
+ if source in words:
41
+ return words[source] if strings.LANGUAGE == "zh" else source
42
+ return None
43
+
44
+
45
+ class LanguageController(QObject):
46
+ changed = Signal()
47
+
48
+ def __init__(self, mode="system", parent=None):
49
+ super().__init__(parent)
50
+ self.mode = mode if mode in {"system", "zh", "en"} else "system"
51
+ self.translator = DialogTranslator(self)
52
+ self.qt_translator = QTranslator(self)
53
+ if not self.qt_translator.load(str(files("repdf").joinpath("assets", "qtbase_zh_TW.qm"))):
54
+ self.qt_translator.load("qtbase_zh_TW", QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath))
55
+ self.refresh()
56
+
57
+ @property
58
+ def language(self):
59
+ return strings.LANGUAGE
60
+
61
+ def set_mode(self, mode):
62
+ if mode not in {"system", "zh", "en"}:
63
+ raise ValueError(mode)
64
+ self.mode = mode
65
+ self.refresh()
66
+
67
+ def refresh(self):
68
+ strings.LANGUAGE = system_language() if self.mode == "system" else self.mode
69
+ app = QApplication.instance()
70
+ app.removeTranslator(self.translator)
71
+ app.removeTranslator(self.qt_translator)
72
+ if strings.LANGUAGE == "zh":
73
+ app.installTranslator(self.qt_translator)
74
+ app.installTranslator(self.translator)
75
+ self.changed.emit()
76
+
77
+ def close(self):
78
+ QApplication.instance().removeTranslator(self.translator)
79
+ QApplication.instance().removeTranslator(self.qt_translator)
repdf/render_worker.py ADDED
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import socket
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import TextIO
8
+
9
+ import pypdfium2 as pdfium
10
+
11
+
12
+ def render_document(
13
+ source: Path, scale: float, output_dir: Path, generation: int,
14
+ output_stream: TextIO | None = None,
15
+ ) -> None:
16
+ document = pdfium.PdfDocument(str(source))
17
+ try:
18
+ for page_index in range(len(document)):
19
+ page = document[page_index]
20
+ try:
21
+ bitmap = page.render(scale=max(scale, 0.1))
22
+ try:
23
+ image = bitmap.to_pil().convert("RGB")
24
+ output = output_dir / f"g{generation}-p{page_index}.png"
25
+ image.save(output, "PNG")
26
+ finally:
27
+ bitmap.close()
28
+ finally:
29
+ page.close()
30
+ print(json.dumps({
31
+ "type": "page", "generation": generation,
32
+ "page": page_index, "path": str(output),
33
+ }), file=output_stream, flush=True)
34
+ finally:
35
+ document.close()
36
+
37
+
38
+ def serve(
39
+ input_stream: TextIO | None = None,
40
+ output_stream: TextIO | None = None,
41
+ token: str | None = None,
42
+ ) -> int:
43
+ input_stream = input_stream if input_stream is not None else sys.stdin
44
+ output_stream = output_stream if output_stream is not None else sys.stdout
45
+ ready = {"type": "ready"}
46
+ if token is not None:
47
+ ready["token"] = token
48
+ print(json.dumps(ready), file=output_stream, flush=True)
49
+ for line in input_stream:
50
+ request: dict[str, object] = {}
51
+ try:
52
+ request = json.loads(line)
53
+ render_document(
54
+ Path(request["source"]), float(request["scale"]),
55
+ Path(request["output_dir"]), int(request["generation"]), output_stream,
56
+ )
57
+ print(json.dumps({
58
+ "type": "done", "generation": int(request["generation"]),
59
+ }), file=output_stream, flush=True)
60
+ except Exception as exc:
61
+ print(json.dumps({
62
+ "type": "error",
63
+ "generation": request.get("generation", -1) if isinstance(request, dict) else -1,
64
+ "message": str(exc),
65
+ }), file=output_stream, flush=True)
66
+ return 0
67
+
68
+
69
+ def serve_socket(port: int, token: str) -> int:
70
+ with socket.create_connection(("127.0.0.1", port), timeout=30) as connection:
71
+ connection.settimeout(None)
72
+ with connection.makefile("r", encoding="utf-8") as reader:
73
+ with connection.makefile("w", encoding="utf-8") as writer:
74
+ return serve(reader, writer, token)
75
+
76
+
77
+ def main() -> int:
78
+ if len(sys.argv) == 2 and sys.argv[1] == "--server":
79
+ return serve()
80
+ if len(sys.argv) != 5:
81
+ print("invalid render worker arguments", file=sys.stderr)
82
+ return 2
83
+ try:
84
+ render_document(
85
+ Path(sys.argv[1]), float(sys.argv[2]), Path(sys.argv[3]), int(sys.argv[4])
86
+ )
87
+ return 0
88
+ except Exception as exc:
89
+ print(str(exc), file=sys.stderr, flush=True)
90
+ return 1
91
+
92
+
93
+ if __name__ == "__main__":
94
+ raise SystemExit(main())