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/__init__.py +5 -0
- repdf/__main__.py +5 -0
- repdf/app.py +860 -0
- repdf/assets/check.svg +1 -0
- repdf/assets/partial.svg +1 -0
- repdf/assets/repdf.ico +0 -0
- repdf/assets/repdf.png +0 -0
- repdf/core.py +224 -0
- repdf/entrypoint.py +19 -0
- repdf/icons.py +81 -0
- repdf/packaging_smoke.py +97 -0
- repdf/preferences.py +79 -0
- repdf/render_worker.py +94 -0
- repdf/renderer.py +169 -0
- repdf/strings.py +107 -0
- repdf/theme.py +134 -0
- repdf/widgets.py +276 -0
- repdf/workers.py +61 -0
- repdf-0.0.8.dist-info/METADATA +104 -0
- repdf-0.0.8.dist-info/RECORD +22 -0
- repdf-0.0.8.dist-info/WHEEL +4 -0
- repdf-0.0.8.dist-info/entry_points.txt +3 -0
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>
|
repdf/assets/partial.svg
ADDED
|
@@ -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
|
repdf/packaging_smoke.py
ADDED
|
@@ -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())
|