repdf 0.1.1__tar.gz → 0.1.2__tar.gz

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.
Files changed (39) hide show
  1. {repdf-0.1.1 → repdf-0.1.2}/PKG-INFO +18 -3
  2. {repdf-0.1.1 → repdf-0.1.2}/README.md +17 -2
  3. {repdf-0.1.1 → repdf-0.1.2}/pyproject.toml +1 -1
  4. {repdf-0.1.1 → repdf-0.1.2}/pyproject.toml.orig +1 -1
  5. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/app.py +11 -0
  6. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/icons.py +7 -1
  7. repdf-0.1.2/src/repdf/navigation.py +397 -0
  8. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/packaging_smoke.py +20 -0
  9. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/page_view.py +14 -0
  10. repdf-0.1.2/src/repdf/pdf_annotations.py +92 -0
  11. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/pdf_navigation.py +8 -1
  12. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/preview.py +4 -0
  13. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/render_worker.py +20 -4
  14. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/renderer.py +1 -1
  15. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/strings.py +29 -0
  16. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/widgets.py +26 -2
  17. repdf-0.1.1/src/repdf/navigation.py +0 -235
  18. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/__init__.py +0 -0
  19. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/__main__.py +0 -0
  20. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/assets/check-dark.svg +0 -0
  21. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/assets/check.svg +0 -0
  22. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/assets/partial-dark.svg +0 -0
  23. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/assets/partial.svg +0 -0
  24. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/assets/repdf.ico +0 -0
  25. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/assets/repdf.png +0 -0
  26. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/core.py +0 -0
  27. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/entrypoint.py +0 -0
  28. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/images.py +0 -0
  29. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/ocr.py +0 -0
  30. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/ocr_languages.py +0 -0
  31. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/ocr_models.py +0 -0
  32. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/preferences.py +0 -0
  33. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/print_preview.py +0 -0
  34. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/printing.py +0 -0
  35. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/search.py +0 -0
  36. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/search_panel.py +0 -0
  37. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/theme.py +0 -0
  38. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/toolbar.py +0 -0
  39. {repdf-0.1.1 → repdf-0.1.2}/src/repdf/workers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: repdf
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: RePDF (Read the PDF): a desktop PDF reader with printing, search, bookmarks, OCR and page rearrangement.
5
5
  Requires-Dist: onnxruntime>=1.31.0,<2
6
6
  Requires-Dist: pillow>=11.0,<13
@@ -19,7 +19,7 @@ Description-Content-Type: text/markdown
19
19
 
20
20
  [繁體中文](README.md) | [English](README.en.md)
21
21
 
22
- RePDF 代表 **Read the PDF**,命令與 Python 套件名稱為 `repdf`。這是一套桌面 PDF 閱讀器,支援多標籤閱讀、列印、書籤與目錄跳頁、文字搜尋、選字複製、OCR 及圖片擷取,也可勾選頁面,依標籤與頁面排列順序重組或拆分輸出。重組與拆分保留原始頁面品質。
22
+ RePDF 代表 **Read the PDF**,命令與 Python 套件名稱為 `repdf`。這是一套桌面 PDF 閱讀器,支援多標籤閱讀、列印、書籤與目錄跳頁、註解清單、文字搜尋、選字複製、OCR 及圖片擷取,也可勾選頁面,依標籤與頁面排列順序重組或拆分輸出。重組與拆分保留原始頁面品質。
23
23
 
24
24
  ## 重點特色
25
25
 
@@ -68,6 +68,7 @@ repdf
68
68
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-ocr-region-dark.png"><img src="docs/images/toolbar-ocr-region.png" width="24" alt="區域 OCR 圖示"></picture> | 區域 OCR | 拖曳矩形框選頁面或圖片,放開滑鼠後辨識並複製文字。 | — |
69
69
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-extract-image-dark.png"><img src="docs/images/toolbar-extract-image.png" width="24" alt="擷取圖片圖示"></picture> | 擷取圖片 | 啟用後點選內嵌點陣圖片,再選擇位置另存為 PNG。 | — |
70
70
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-bookmarks-dark.png"><img src="docs/images/toolbar-bookmarks.png" width="24" alt="書籤圖示"></picture> | 書籤 | 在左側章節樹展開或收合書籤,點選章節即可跳頁。 | — |
71
+ | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-annotations-dark.png"><img src="docs/images/toolbar-annotations.png" width="24" alt="註解圖示"></picture> | 註解 | 在左側列出註解,點選即可跳至對應頁面。 | — |
71
72
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-search-dark.png"><img src="docs/images/toolbar-search.png" width="24" alt="搜尋文字圖示"></picture> | 搜尋文字 | 在右側窗格搜尋文字,上方選檔案、下方選命中段落,單擊即可跳頁並高亮。 | Windows/Linux:<kbd>Ctrl</kbd>+<kbd>F</kbd>;macOS:<kbd>⌘</kbd>+<kbd>F</kbd> |
72
73
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-zoom-dark.png"><img src="docs/images/toolbar-zoom.png" width="160" alt="顯示比例與縮放滑桿"></picture> | 顯示比例 | 滑桿調整 25%–200%;點擊比例回到 100%。鍵盤縮放每次調整 25 個百分點。 | 放大:<kbd>Ctrl</kbd>+<kbd>+</kbd>/<kbd>Ctrl</kbd>+<kbd>=</kbd>;縮小:<kbd>Ctrl</kbd>+<kbd>-</kbd>;100%:<kbd>Ctrl</kbd>+<kbd>0</kbd> |
73
74
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-fit-dark.png"><img src="docs/images/toolbar-fit.png" width="24" alt="Fit 圖示"></picture> | Fit | 將目前單頁等比例縮放至預覽區;調整視窗大小時自動適應。拖動滑桿或使用縮放快捷鍵會回到手動比例。 | — |
@@ -153,11 +154,25 @@ repdf
153
154
  - 書籤跟隨目前標籤頁切換;沒有內建書籤的文件會顯示提示。
154
155
  - 一般預覽與全螢幕都可直接點選 PDF 頁內的目錄連結;滑鼠移到可跳頁區域時會顯示手指游標與目的頁碼。
155
156
  - 跳頁保留目前縮放/Fit 模式。頁面已重新排序時,仍會找到目的章節所屬的原始頁面。
156
- - 視窗縮窄時,書籤與 PDF 以單一窗格切換;點選章節後返回 PDF,再點「書籤」可返回目錄。搜尋與書籤窗格可透過各自的工具按鈕切換。
157
+ - 視窗縮窄時,書籤與 PDF 以單一窗格切換;點選章節後返回 PDF,再點「書籤」可返回目錄。書籤、註解與搜尋窗格可透過各自的工具按鈕切換。
157
158
  - 啟用複製文字、區域 OCR 或圖片擷取模式時,頁內連結暫停點選;停用後即可跳頁,拖曳頁面仍可排序。
158
159
  - 一般預覽與全螢幕可點選 PDF 內建的 HTTP/HTTPS 超連結,以系統預設瀏覽器開啟;滑鼠停留可查看網址。
159
160
  - 此功能使用 PDF 已有的書籤與連結;純文字或掃描圖片形式的目錄、網址不會自動變成連結。電子郵件、外部文件與其他動作不在支援範圍。
160
161
 
162
+ ### 註解清單
163
+
164
+ - 點工具列的「註解」對話框圖示,開啟目前標籤頁的左側註解清單。
165
+ - 清單依 PDF 原始頁序分組,每頁顯示註解數量,預設收合;展開後查看個別註解的類型與摘要。切換標籤、書籤或介面語言後會保留展開狀態。滑鼠停留可查看完整內容、作者、主旨及修改時間(若文件有提供)。
166
+ - 點選頁碼群組即可跳到該頁清單中的第一個註解,並標示其範圍;點選個別註解會跳到對應位置,並以半透明底色與外框標示註解範圍。保留目前縮放/Fit 模式,頁面重新排列後仍會找到原始目標頁。
167
+ - 選取含文字的註解時,會自動將註解文字複製到剪貼簿;頁碼群組會複製第一個註解的文字。無文字的註解不會覆蓋剪貼簿內容。
168
+ - 關閉註解窗格或切換至其他側欄時,會取消框選;重新開啟清單不會自動複製文字或恢復框選。
169
+ - 框選會跟隨縮放與全螢幕顯示;點選另一註解或頁碼群組會更新標示,點選書籤或搜尋結果則清除標示。文件未提供有效註解範圍時,仍可跳頁。
170
+ - 支援備註、文字方塊、螢光標示、底線、刪除線、手繪及形狀等註解。文字標示若沒有附註內容,會嘗試顯示標示的原文。
171
+ - 在一般閱讀模式可直接點選頁面上的註解,框選其範圍並同步選取左側清單、展開對應頁面群組;全螢幕也可使用。選字、區域 OCR 與圖片擷取模式保留各自操作。窄視窗點選後維持 PDF 顯示,再按「註解」即可查看清單中的選取項目。
172
+ - 註解跟隨標籤頁切換;沒有註解時顯示提示。書籤、註解與搜尋窗格透過各自的按鈕切換。
173
+ - 視窗較窄時,點選後返回 PDF;再點「註解」可返回清單。
174
+ - 完整註解清單只在開啟窗格時於背景讀取;頁面點選範圍則隨目前需要顯示的頁面讀取,不會在開檔時掃描整份文件;此功能提供檢視與跳頁,不編輯註解或執行附件內容。
175
+
161
176
  ### 搜尋文字
162
177
 
163
178
  1. 點「搜尋文字」或按 <kbd>Ctrl</kbd>+<kbd>F</kbd>(macOS:<kbd>⌘</kbd>+<kbd>F</kbd>),開啟右側窗格。
@@ -2,7 +2,7 @@
2
2
 
3
3
  [繁體中文](README.md) | [English](README.en.md)
4
4
 
5
- RePDF 代表 **Read the PDF**,命令與 Python 套件名稱為 `repdf`。這是一套桌面 PDF 閱讀器,支援多標籤閱讀、列印、書籤與目錄跳頁、文字搜尋、選字複製、OCR 及圖片擷取,也可勾選頁面,依標籤與頁面排列順序重組或拆分輸出。重組與拆分保留原始頁面品質。
5
+ RePDF 代表 **Read the PDF**,命令與 Python 套件名稱為 `repdf`。這是一套桌面 PDF 閱讀器,支援多標籤閱讀、列印、書籤與目錄跳頁、註解清單、文字搜尋、選字複製、OCR 及圖片擷取,也可勾選頁面,依標籤與頁面排列順序重組或拆分輸出。重組與拆分保留原始頁面品質。
6
6
 
7
7
  ## 重點特色
8
8
 
@@ -51,6 +51,7 @@ repdf
51
51
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-ocr-region-dark.png"><img src="docs/images/toolbar-ocr-region.png" width="24" alt="區域 OCR 圖示"></picture> | 區域 OCR | 拖曳矩形框選頁面或圖片,放開滑鼠後辨識並複製文字。 | — |
52
52
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-extract-image-dark.png"><img src="docs/images/toolbar-extract-image.png" width="24" alt="擷取圖片圖示"></picture> | 擷取圖片 | 啟用後點選內嵌點陣圖片,再選擇位置另存為 PNG。 | — |
53
53
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-bookmarks-dark.png"><img src="docs/images/toolbar-bookmarks.png" width="24" alt="書籤圖示"></picture> | 書籤 | 在左側章節樹展開或收合書籤,點選章節即可跳頁。 | — |
54
+ | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-annotations-dark.png"><img src="docs/images/toolbar-annotations.png" width="24" alt="註解圖示"></picture> | 註解 | 在左側列出註解,點選即可跳至對應頁面。 | — |
54
55
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-search-dark.png"><img src="docs/images/toolbar-search.png" width="24" alt="搜尋文字圖示"></picture> | 搜尋文字 | 在右側窗格搜尋文字,上方選檔案、下方選命中段落,單擊即可跳頁並高亮。 | Windows/Linux:<kbd>Ctrl</kbd>+<kbd>F</kbd>;macOS:<kbd>⌘</kbd>+<kbd>F</kbd> |
55
56
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-zoom-dark.png"><img src="docs/images/toolbar-zoom.png" width="160" alt="顯示比例與縮放滑桿"></picture> | 顯示比例 | 滑桿調整 25%–200%;點擊比例回到 100%。鍵盤縮放每次調整 25 個百分點。 | 放大:<kbd>Ctrl</kbd>+<kbd>+</kbd>/<kbd>Ctrl</kbd>+<kbd>=</kbd>;縮小:<kbd>Ctrl</kbd>+<kbd>-</kbd>;100%:<kbd>Ctrl</kbd>+<kbd>0</kbd> |
56
57
  | <picture><source media="(prefers-color-scheme: dark)" srcset="docs/images/toolbar-fit-dark.png"><img src="docs/images/toolbar-fit.png" width="24" alt="Fit 圖示"></picture> | Fit | 將目前單頁等比例縮放至預覽區;調整視窗大小時自動適應。拖動滑桿或使用縮放快捷鍵會回到手動比例。 | — |
@@ -136,11 +137,25 @@ repdf
136
137
  - 書籤跟隨目前標籤頁切換;沒有內建書籤的文件會顯示提示。
137
138
  - 一般預覽與全螢幕都可直接點選 PDF 頁內的目錄連結;滑鼠移到可跳頁區域時會顯示手指游標與目的頁碼。
138
139
  - 跳頁保留目前縮放/Fit 模式。頁面已重新排序時,仍會找到目的章節所屬的原始頁面。
139
- - 視窗縮窄時,書籤與 PDF 以單一窗格切換;點選章節後返回 PDF,再點「書籤」可返回目錄。搜尋與書籤窗格可透過各自的工具按鈕切換。
140
+ - 視窗縮窄時,書籤與 PDF 以單一窗格切換;點選章節後返回 PDF,再點「書籤」可返回目錄。書籤、註解與搜尋窗格可透過各自的工具按鈕切換。
140
141
  - 啟用複製文字、區域 OCR 或圖片擷取模式時,頁內連結暫停點選;停用後即可跳頁,拖曳頁面仍可排序。
141
142
  - 一般預覽與全螢幕可點選 PDF 內建的 HTTP/HTTPS 超連結,以系統預設瀏覽器開啟;滑鼠停留可查看網址。
142
143
  - 此功能使用 PDF 已有的書籤與連結;純文字或掃描圖片形式的目錄、網址不會自動變成連結。電子郵件、外部文件與其他動作不在支援範圍。
143
144
 
145
+ ### 註解清單
146
+
147
+ - 點工具列的「註解」對話框圖示,開啟目前標籤頁的左側註解清單。
148
+ - 清單依 PDF 原始頁序分組,每頁顯示註解數量,預設收合;展開後查看個別註解的類型與摘要。切換標籤、書籤或介面語言後會保留展開狀態。滑鼠停留可查看完整內容、作者、主旨及修改時間(若文件有提供)。
149
+ - 點選頁碼群組即可跳到該頁清單中的第一個註解,並標示其範圍;點選個別註解會跳到對應位置,並以半透明底色與外框標示註解範圍。保留目前縮放/Fit 模式,頁面重新排列後仍會找到原始目標頁。
150
+ - 選取含文字的註解時,會自動將註解文字複製到剪貼簿;頁碼群組會複製第一個註解的文字。無文字的註解不會覆蓋剪貼簿內容。
151
+ - 關閉註解窗格或切換至其他側欄時,會取消框選;重新開啟清單不會自動複製文字或恢復框選。
152
+ - 框選會跟隨縮放與全螢幕顯示;點選另一註解或頁碼群組會更新標示,點選書籤或搜尋結果則清除標示。文件未提供有效註解範圍時,仍可跳頁。
153
+ - 支援備註、文字方塊、螢光標示、底線、刪除線、手繪及形狀等註解。文字標示若沒有附註內容,會嘗試顯示標示的原文。
154
+ - 在一般閱讀模式可直接點選頁面上的註解,框選其範圍並同步選取左側清單、展開對應頁面群組;全螢幕也可使用。選字、區域 OCR 與圖片擷取模式保留各自操作。窄視窗點選後維持 PDF 顯示,再按「註解」即可查看清單中的選取項目。
155
+ - 註解跟隨標籤頁切換;沒有註解時顯示提示。書籤、註解與搜尋窗格透過各自的按鈕切換。
156
+ - 視窗較窄時,點選後返回 PDF;再點「註解」可返回清單。
157
+ - 完整註解清單只在開啟窗格時於背景讀取;頁面點選範圍則隨目前需要顯示的頁面讀取,不會在開檔時掃描整份文件;此功能提供檢視與跳頁,不編輯註解或執行附件內容。
158
+
144
159
  ### 搜尋文字
145
160
 
146
161
  1. 點「搜尋文字」或按 <kbd>Ctrl</kbd>+<kbd>F</kbd>(macOS:<kbd>⌘</kbd>+<kbd>F</kbd>),開啟右側窗格。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repdf"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "RePDF (Read the PDF): a desktop PDF reader with printing, search, bookmarks, OCR and page rearrangement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repdf"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "RePDF (Read the PDF): a desktop PDF reader with printing, search, bookmarks, OCR and page rearrangement."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -90,8 +90,10 @@ class DocumentView(QWidget):
90
90
  self.focused_page = None
91
91
  self.page_sizes = {}
92
92
  self.page_links = {}
93
+ self.page_annotations = {}
93
94
  self.page_labels = {}
94
95
  self.bookmarks = None
96
+ self.annotations = None
95
97
  self.ready = False
96
98
  self._search_navigation = None
97
99
  self.password: str | None = None
@@ -178,6 +180,7 @@ class DocumentView(QWidget):
178
180
  self.owner._copy_text_selection(self, index, preview, start, end, copy=False))
179
181
  self.pages.prepare_card(card)
180
182
  card.preview.set_links(self.page_links.get(page_index, []))
183
+ card.preview.set_annotations(self.page_annotations.get(page_index, []))
181
184
  card.preview.link_activated.connect(lambda destination: self.owner.navigation.activate(self, destination))
182
185
  self.pages.chrome = max(1, card.sizeHint().height() - card.preview.height())
183
186
  return card
@@ -734,6 +737,12 @@ class MainWindow(QMainWindow):
734
737
  self.bookmark_action.triggered.connect(lambda: self.navigation.toggle())
735
738
  self.toolbar.addAction(self.bookmark_action)
736
739
  self.toolbar.widgetForAction(self.bookmark_action).setObjectName('bookmarksTool')
740
+ self.annotation_action = QAction(tr('註解'), self)
741
+ self.annotation_action.setCheckable(True)
742
+ self.annotation_action.setToolTip(tr('註解:開啟註解清單,點選即可跳至對應頁面'))
743
+ self.annotation_action.triggered.connect(lambda: self.navigation.toggle('annotations'))
744
+ self.toolbar.addAction(self.annotation_action)
745
+ self.toolbar.widgetForAction(self.annotation_action).setObjectName('annotationsTool')
737
746
  self.search_action = QAction(tr('搜尋文字'), self)
738
747
  self.search_action.setShortcuts(QKeySequence.keyBindings(QKeySequence.StandardKey.Find))
739
748
  self.search_action.setToolTip(tr('搜尋文字({p0}):在右側窗格搜尋目前、勾選或所有標籤頁',
@@ -822,6 +831,7 @@ class MainWindow(QMainWindow):
822
831
  (self.search_action, "search"),
823
832
  (self.print_action, "print"),
824
833
  (self.bookmark_action, "bookmarks"),
834
+ (self.annotation_action, "annotations"),
825
835
  (self.fit_action, "fit"),
826
836
  (self.fullscreen_action, "fullscreen"),
827
837
  (self.spread_action, "fullscreen_spread"),
@@ -1503,6 +1513,7 @@ class MainWindow(QMainWindow):
1503
1513
 
1504
1514
  def closeEvent(self, event: QCloseEvent) -> None:
1505
1515
  self._closing = True
1516
+ self.navigation.shutdown()
1506
1517
  self.ocr_languages.close()
1507
1518
  self.print_controller.close()
1508
1519
  self.search_controller.stop()
@@ -20,7 +20,13 @@ def toolbar_icon(kind: str) -> QIcon:
20
20
  painter.setPen(QPen(QApplication.palette().color(group, role),
21
21
  1.0, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap,
22
22
  Qt.PenJoinStyle.RoundJoin))
23
- if kind == 'chevron_down':
23
+ if kind == 'annotations':
24
+ painter.drawPolyline(QPolygonF([QPointF(4, 3), QPointF(21, 3), QPointF(21, 17),
25
+ QPointF(11, 17), QPointF(6, 21), QPointF(6, 17),
26
+ QPointF(4, 17), QPointF(4, 3)]))
27
+ painter.drawLine(8, 8, 17, 8)
28
+ painter.drawLine(8, 12, 15, 12)
29
+ elif kind == 'chevron_down':
24
30
  pen = painter.pen()
25
31
  pen.setWidthF(2)
26
32
  painter.setPen(pen)
@@ -0,0 +1,397 @@
1
+ """Bookmark/annotation sidebar, internal navigation and web links."""
2
+
3
+ from html import escape
4
+
5
+ from PySide6.QtCore import QEvent, QObject, QSize, Qt, QUrl
6
+ from PySide6.QtGui import QDesktopServices
7
+ from PySide6.QtWidgets import (QApplication, QHBoxLayout, QLabel, QLayout, QSplitter, QToolButton,
8
+ QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget)
9
+
10
+ from repdf.icons import toolbar_icon
11
+ from repdf.strings import tr
12
+ from repdf.renderer import RenderService, start_render_backend
13
+
14
+
15
+ class BookmarkPanel(QWidget):
16
+ def __init__(self):
17
+ super().__init__()
18
+ self.setMinimumSize(0, 0)
19
+ layout = QVBoxLayout(self)
20
+ layout.setContentsMargins(8, 8, 8, 8)
21
+ layout.setSpacing(6)
22
+ layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
23
+ row = QHBoxLayout()
24
+ self.title = QLabel(tr('書籤'))
25
+ row.addWidget(self.title, 1)
26
+ self.close_button = QToolButton()
27
+ self.close_button.setText(tr('關閉書籤窗格'))
28
+ self.close_button.setAccessibleName(tr('關閉書籤窗格'))
29
+ self.close_button.setToolTip(tr('關閉書籤窗格'))
30
+ self.close_button.setFixedSize(34, 34)
31
+ self.close_button.setIconSize(QSize(20, 20))
32
+ self.close_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
33
+ self.close_button.setIcon(toolbar_icon('close'))
34
+ row.addWidget(self.close_button)
35
+ layout.addLayout(row)
36
+ self.status = QLabel()
37
+ self.status.setWordWrap(True)
38
+ self.status.setTextFormat(Qt.TextFormat.PlainText)
39
+ layout.addWidget(self.status)
40
+ self.tree = QTreeWidget()
41
+ self.tree.setHeaderHidden(True)
42
+ self.tree.setAccessibleName(tr('PDF 書籤'))
43
+ self.tree.setMinimumSize(0, 0)
44
+ self.tree.setIndentation(16)
45
+ self.tree.setTextElideMode(Qt.TextElideMode.ElideRight)
46
+ self.tree.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
47
+ layout.addWidget(self.tree, 1)
48
+
49
+ def changeEvent(self, event):
50
+ super().changeEvent(event)
51
+ if event.type() == QEvent.Type.PaletteChange and hasattr(self, 'close_button'):
52
+ self.close_button.setIcon(toolbar_icon('close'))
53
+
54
+
55
+ class NavigationController(QObject):
56
+ def __init__(self, owner, content):
57
+ super().__init__(owner)
58
+ self.owner, self.content = owner, content
59
+ self.panel = BookmarkPanel()
60
+ self.workspace = QSplitter(Qt.Orientation.Horizontal)
61
+ self.workspace.setChildrenCollapsible(False)
62
+ self.workspace.addWidget(self.panel)
63
+ self.workspace.addWidget(content)
64
+ self.workspace.setStretchFactor(0, 0)
65
+ self.workspace.setStretchFactor(1, 1)
66
+ self.panel.hide()
67
+ self.opened = False
68
+ self.narrow_results = True
69
+ self.displayed_id = None
70
+ self.kind = 'bookmarks'
71
+ self.displayed_kind = None
72
+ self.annotation_service = None
73
+ self.items = []
74
+ self.annotation_groups = {}
75
+ self.states = {}
76
+ self.requests = {}
77
+ self.latest_jump = None
78
+ self.annotation_selection = None
79
+ self.pending_annotation_copy = None
80
+ self._layout_state = None
81
+ self.panel.close_button.clicked.connect(self.close)
82
+ self.panel.tree.itemClicked.connect(self._clicked)
83
+ self.panel.tree.itemActivated.connect(self._clicked)
84
+
85
+ def toggle(self, kind='bookmarks'):
86
+ if self.panel.isVisible() and self.kind == kind:
87
+ self.close()
88
+ else:
89
+ self.open(kind)
90
+
91
+ def open(self, kind='bookmarks', *, keep_fullscreen=False):
92
+ if kind != self.kind:
93
+ if self.kind == 'annotations':
94
+ self._clear_annotation_selection()
95
+ self._cancel_annotations()
96
+ self.kind = kind
97
+ if self.owner._fullscreen_preview is not None and not keep_fullscreen:
98
+ self.owner._fullscreen_preview.close()
99
+ self.owner.search_controller.close()
100
+ self.opened = True
101
+ self.narrow_results = True
102
+ self.layout()
103
+ self.show_document()
104
+ self.panel.tree.setFocus()
105
+
106
+ def close(self):
107
+ if self.kind == 'annotations':
108
+ self._clear_annotation_selection()
109
+ self._cancel_annotations()
110
+ self.opened = False
111
+ self.layout()
112
+
113
+ def _clear_annotation_selection(self):
114
+ self.annotation_selection = None
115
+ self.pending_annotation_copy = None
116
+ self.latest_jump = None
117
+ for doc in self.owner.documents.values():
118
+ doc.pages.set_annotation_highlight(None, None)
119
+
120
+ def layout(self):
121
+ narrow = self.owner.width() < 640
122
+ state = narrow, self.opened, self.narrow_results, self.kind
123
+ if state == self._layout_state:
124
+ return
125
+ self._layout_state = state
126
+ visible = self.opened and (not narrow or self.narrow_results)
127
+ self.content.setVisible(not (narrow and visible))
128
+ self.panel.setVisible(visible)
129
+ self.owner.bookmark_action.setChecked(visible and self.kind == 'bookmarks')
130
+ self.owner.annotation_action.setChecked(visible and self.kind == 'annotations')
131
+ if visible and not narrow:
132
+ self.workspace.setSizes([260, max(1, self.owner.width() - 260)])
133
+
134
+ def show_document(self):
135
+ doc = self.owner.current_document()
136
+ document_id = doc.document_id if doc is not None else None
137
+ if self.pending_annotation_copy is not None and self.pending_annotation_copy[0] != document_id:
138
+ self.pending_annotation_copy = None
139
+ if (self.displayed_id, self.displayed_kind) != (document_id, self.kind):
140
+ self._cancel_annotations()
141
+ self._remember_state()
142
+ self.latest_jump = None
143
+ self.displayed_id = document_id
144
+ self.displayed_kind = self.kind
145
+ self.panel.tree.clear()
146
+ self.items = []
147
+ self.annotation_groups = {}
148
+ annotations = self.kind == 'annotations'
149
+ self.panel.title.setText(tr('註解' if annotations else '書籤'))
150
+ close_label = tr('關閉註解窗格' if annotations else '關閉書籤窗格')
151
+ self.panel.close_button.setText(close_label)
152
+ self.panel.close_button.setToolTip(close_label)
153
+ self.panel.close_button.setAccessibleName(close_label)
154
+ self.panel.tree.setAccessibleName(tr('PDF 註解' if annotations else 'PDF 書籤'))
155
+ self.panel.tree.setRootIsDecorated(True)
156
+ self.panel.tree.setIndentation(16)
157
+ if not self.opened:
158
+ return
159
+ if doc is None or not doc.ready:
160
+ self.panel.status.setText(tr('請先開啟 PDF 檔案'))
161
+ self.panel.status.show()
162
+ return
163
+ if getattr(doc, self.kind) is not None:
164
+ self.populate(doc)
165
+ return
166
+ self.panel.status.setText(tr('正在載入註解…' if annotations else '正在載入書籤…'))
167
+ self.panel.status.show()
168
+ if not any(value == (doc.document_id, self.kind, False) for value in self.requests.values()):
169
+ self._request(doc, self.kind)
170
+
171
+ def _request(self, doc, kind, destination=None, fullscreen=False):
172
+ self.owner._next_generation += 1
173
+ generation = self.owner._next_generation
174
+ self.requests = {key: value for key, value in self.requests.items()
175
+ if value[:2] != (doc.document_id, kind)}
176
+ self.requests[generation] = doc.document_id, kind, fullscreen
177
+ if kind == 'navigate':
178
+ self.latest_jump = generation
179
+ service = self.owner.render_service
180
+ if kind == 'annotations':
181
+ try:
182
+ if self.annotation_service is None:
183
+ self.annotation_service = RenderService(start_render_backend(), self)
184
+ self.annotation_service.message.connect(self.receive, Qt.ConnectionType.QueuedConnection)
185
+ service = self.annotation_service
186
+ except (OSError, RuntimeError) as exc:
187
+ self.receive({'type': 'error', 'generation': generation, 'message': str(exc)})
188
+ return generation
189
+ service.request_navigation(doc.source_path, doc.document_id, generation, kind,
190
+ destination=destination, password=doc.password)
191
+ return generation
192
+
193
+ def populate(self, doc):
194
+ if self.items:
195
+ return
196
+ self.panel.tree.clear()
197
+ if self.kind == 'annotations':
198
+ self.populate_annotations(doc)
199
+ return
200
+ parents = []
201
+ remembered = self.states.get((doc.document_id, self.kind))
202
+ for index, bookmark in enumerate(doc.bookmarks):
203
+ level = min(bookmark['level'], len(parents))
204
+ parents = parents[:level]
205
+ parent = parents[-1] if parents else self.panel.tree
206
+ title = bookmark['title'] or tr('未命名書籤')
207
+ item = QTreeWidgetItem(parent, [title])
208
+ item.setData(0, Qt.ItemDataRole.UserRole, bookmark['destination'])
209
+ item.setToolTip(0, title)
210
+ self.items.append(item)
211
+ parents.append(item)
212
+ if remembered and 0 <= remembered[1] < len(self.items):
213
+ self.panel.tree.setCurrentItem(self.items[remembered[1]])
214
+ for index, (item, bookmark) in enumerate(zip(self.items, doc.bookmarks)):
215
+ item.setExpanded(index in remembered[0] if remembered else bookmark['expanded'])
216
+ self.panel.status.setText(tr('此 PDF 沒有內建書籤'))
217
+ self.panel.status.setVisible(not bool(doc.bookmarks))
218
+
219
+ def populate_annotations(self, doc):
220
+ counts = {}
221
+ first_destinations = {}
222
+ for annotation in doc.annotations:
223
+ page = annotation['page']
224
+ counts[page] = counts.get(page, 0) + 1
225
+ first_destinations.setdefault(page, annotation['destination'])
226
+ for page, count in counts.items():
227
+ title = tr('第 {page} 頁({count} 則註解)', page=page + 1, count=count)
228
+ group = QTreeWidgetItem(self.panel.tree, [title])
229
+ group.setToolTip(0, title)
230
+ group.setData(0, Qt.ItemDataRole.UserRole, first_destinations[page])
231
+ self.annotation_groups[page] = group
232
+ for annotation in doc.annotations:
233
+ summary = ' '.join((annotation['contents'] or annotation['subject'] or tr('沒有文字內容')).split())
234
+ title = tr(annotation['kind']) + ' · ' + summary
235
+ item = QTreeWidgetItem(self.annotation_groups[annotation['page']], [title])
236
+ item.setData(0, Qt.ItemDataRole.UserRole, annotation['destination'])
237
+ details = [tr(annotation['kind']), annotation['contents'] or tr('沒有文字內容')]
238
+ for key, label in [('author', '作者:{value}'), ('subject', '主旨:{value}'), ('modified', '修改時間:{value}')]:
239
+ if annotation.get(key):
240
+ details.append(tr(label, value=annotation[key]))
241
+ item.setToolTip(0, '<qt>' + '<br>'.join(escape(line).replace('\n', '<br>') for line in details) + '</qt>')
242
+ self.items.append(item)
243
+ remembered = self.states.get((doc.document_id, self.kind))
244
+ if remembered and 0 <= remembered[1] < len(self.items):
245
+ self.panel.tree.setCurrentItem(self.items[remembered[1]])
246
+ for page, group in self.annotation_groups.items():
247
+ group.setExpanded(remembered is not None and page in remembered[0])
248
+ self._select_annotation_item(doc)
249
+ self.panel.status.setText(tr('共 {count} 則註解', count=len(doc.annotations)) if doc.annotations else tr('此 PDF 沒有註解'))
250
+ self.panel.status.show()
251
+
252
+ def _remember_state(self):
253
+ selected = self.panel.tree.currentItem()
254
+ expanded = ([page for page, group in self.annotation_groups.items() if group.isExpanded()]
255
+ if self.displayed_kind == 'annotations'
256
+ else [i for i, item in enumerate(self.items) if item.isExpanded()])
257
+ self.states[self.displayed_id, self.displayed_kind] = (
258
+ expanded, self.items.index(selected) if selected in self.items else -1)
259
+
260
+ def _clicked(self, item, _column=0):
261
+ self.annotation_selection = None
262
+ destination = item.data(0, Qt.ItemDataRole.UserRole)
263
+ doc = self.owner.current_document()
264
+ if destination is not None and doc is not None:
265
+ self.activate(doc, destination)
266
+ if self.kind == 'annotations':
267
+ target = item if item in self.items else item.child(0)
268
+ if target in self.items:
269
+ annotation = doc.annotations[self.items.index(target)]
270
+ self.pending_annotation_copy = doc.document_id, annotation['page'], annotation['index']
271
+
272
+ def activate(self, doc, destination, fullscreen=False):
273
+ if doc.ready and doc is self.owner.current_document():
274
+ self.pending_annotation_copy = None
275
+ if 'annotation_index' in destination:
276
+ self.latest_jump = None
277
+ doc._search_navigation = None
278
+ self.annotation_selection = doc.document_id, destination['page'], destination['annotation_index']
279
+ self.pending_annotation_copy = self.annotation_selection
280
+ self.open('annotations', keep_fullscreen=fullscreen)
281
+ self.narrow_results = False
282
+ self.layout()
283
+ self._select_annotation_item(doc)
284
+ self.owner.search_controller.clear_highlights()
285
+ doc.pages.set_annotation_highlight(destination['page'], destination.get('annotation_rect'))
286
+ doc._focus_page(destination['page'])
287
+ return
288
+ if 'url' in destination:
289
+ url = QUrl(destination['url'], QUrl.ParsingMode.StrictMode)
290
+ if (url.isValid() and url.scheme().lower() in ('http', 'https') and url.host()
291
+ and not any(ord(c) < 32 or ord(c) == 127 for c in destination['url'])):
292
+ if not QDesktopServices.openUrl(url):
293
+ self.owner.statusBar().showMessage(tr('無法開啟連結:{p0}', p0=destination['url']))
294
+ return
295
+ self._request(doc, 'navigate', destination, fullscreen)
296
+
297
+ def _select_annotation_item(self, doc):
298
+ if (self.annotation_selection is None or self.kind != 'annotations'
299
+ or self.displayed_id != doc.document_id or doc.annotations is None):
300
+ return
301
+ for annotation, item in zip(doc.annotations, self.items):
302
+ if self.annotation_selection == (doc.document_id, annotation['page'], annotation['index']):
303
+ item.parent().setExpanded(True)
304
+ self.panel.tree.setCurrentItem(item)
305
+ self.panel.tree.scrollToItem(item)
306
+ self._copy_pending_annotation(doc)
307
+ break
308
+
309
+ def _copy_pending_annotation(self, doc):
310
+ if doc.annotations is None or doc is not self.owner.current_document():
311
+ return
312
+ for annotation in doc.annotations:
313
+ if self.pending_annotation_copy == (doc.document_id, annotation['page'], annotation['index']):
314
+ self.pending_annotation_copy = None
315
+ text = annotation['contents']
316
+ if text.strip():
317
+ QApplication.clipboard().setText(text)
318
+ self.owner.statusBar().showMessage(tr('已複製 {p0} 個字元到剪貼簿', p0=len(text)))
319
+ break
320
+
321
+ def receive(self, payload):
322
+ generation = payload.get('generation')
323
+ request = self.requests.get(generation)
324
+ if request is None:
325
+ return False
326
+ document_id, kind, fullscreen = request
327
+ doc = self.owner.documents.get(document_id)
328
+ message_type = payload.get('type')
329
+ if message_type in ('done', 'error'):
330
+ self.requests.pop(generation, None)
331
+ if doc is None or self.owner._closing:
332
+ return True
333
+ if message_type in ('bookmarks', 'annotations'):
334
+ setattr(doc, message_type, payload[message_type])
335
+ if doc.document_id == self.displayed_id and self.kind == message_type:
336
+ self.populate(doc)
337
+ elif message_type == 'navigate' and generation == self.latest_jump and doc is self.owner.current_document():
338
+ page = payload['page']
339
+ doc.page_labels[page] = payload.get('page_label')
340
+ doc.page_sizes[page] = payload['width_points'], payload['height_points']
341
+ doc._reflow_grid()
342
+ self.owner.search_controller.clear_highlights()
343
+ doc.pages.set_annotation_highlight(page, payload.get('annotation_rect'))
344
+ self._copy_pending_annotation(doc)
345
+ viewer = self.owner._fullscreen_preview
346
+ if fullscreen and viewer is not None and viewer.document is doc:
347
+ viewer.show_page(page)
348
+ else:
349
+ self.narrow_results = False
350
+ self.layout()
351
+ doc.jump_to_page(page, payload['point'])
352
+ elif message_type == 'error':
353
+ message = tr('無法載入註解:{p0}' if kind == 'annotations' else '無法載入 PDF 導航:{p0}',
354
+ p0=payload.get('message', ''))
355
+ if doc.document_id == self.displayed_id and kind == self.kind:
356
+ self.panel.status.setText(message)
357
+ self.panel.status.show()
358
+ self.owner.statusBar().showMessage(message)
359
+ return True
360
+
361
+ def remove_document(self, doc):
362
+ if self.annotation_selection is not None and self.annotation_selection[0] == doc.document_id:
363
+ self.annotation_selection = None
364
+ if self.pending_annotation_copy is not None and self.pending_annotation_copy[0] == doc.document_id:
365
+ self.pending_annotation_copy = None
366
+ if self.displayed_id == doc.document_id or any(
367
+ value[:2] == (doc.document_id, 'annotations') for value in self.requests.values()):
368
+ self._cancel_annotations()
369
+ for kind in ('bookmarks', 'annotations'):
370
+ self.states.pop((doc.document_id, kind), None)
371
+ self.requests = {key: value for key, value in self.requests.items() if value[0] != doc.document_id}
372
+ if self.displayed_id == doc.document_id:
373
+ self.displayed_id = None
374
+ self.items = []
375
+ self.annotation_groups = {}
376
+ self.panel.tree.clear()
377
+ for prefix in (-3_000_000, -4_000_000):
378
+ self.owner.render_service.cancel_pending(prefix - doc.document_id)
379
+
380
+ def retranslate(self):
381
+ if self.kind == 'annotations':
382
+ self._remember_state()
383
+ self.panel.tree.clear()
384
+ self.items = []
385
+ self.annotation_groups = {}
386
+ if self.opened:
387
+ self.show_document()
388
+
389
+ def _cancel_annotations(self):
390
+ self.requests = {key: value for key, value in self.requests.items() if value[1] != 'annotations'}
391
+ service, self.annotation_service = self.annotation_service, None
392
+ if service is not None:
393
+ service.close(terminate_tree=True)
394
+ service.deleteLater()
395
+
396
+ def shutdown(self):
397
+ self._cancel_annotations()
@@ -90,6 +90,8 @@ def main(report_path: str) -> int:
90
90
  writer = PdfWriter()
91
91
  for width in (200, 300):
92
92
  writer.add_blank_page(width=width, height=400)
93
+ from pypdf.annotations import Text
94
+ writer.add_annotation(1, Text(rect=(30, 200, 100, 250), text='Smoke annotation 註解'))
93
95
  with source.open("wb") as stream:
94
96
  writer.write(stream)
95
97
  process = start_render_backend()
@@ -107,11 +109,29 @@ def main(report_path: str) -> int:
107
109
  if message["type"] == "page":
108
110
  if QImage(message["path"]).isNull():
109
111
  raise RuntimeError("Invalid rendered page")
112
+ hits = message.get('annotation_hits', [])
113
+ if message['page'] == 1 and (len(hits) != 1 or
114
+ hits[0]['destination'].get('annotation_index') != 0):
115
+ raise RuntimeError(f'Invalid annotation hit targets: {hits}')
110
116
  pages.append(message["page"])
111
117
  if message["type"] == "done":
112
118
  break
113
119
  if pages != [0, 1]:
114
120
  raise RuntimeError(f"Unexpected rendered pages: {pages}")
121
+ process.stdin.write(json.dumps({'type': 'annotations', 'source': str(source), 'generation': 2}) + '\n')
122
+ process.stdin.flush()
123
+ annotations = None
124
+ for line in process.stdout:
125
+ message = json.loads(line)
126
+ if message['type'] == 'error':
127
+ raise RuntimeError(message['message'])
128
+ if message['type'] == 'annotations':
129
+ annotations = message['annotations']
130
+ if message['type'] == 'done':
131
+ break
132
+ if (not annotations or len(annotations) != 1 or annotations[0]['page'] != 1
133
+ or annotations[0]['contents'] != 'Smoke annotation 註解'):
134
+ raise RuntimeError(f'Packaged annotation extraction failed: {annotations}')
115
135
  from PySide6.QtCore import QEventLoop, QTimer
116
136
  from PySide6.QtPrintSupport import QPrinter
117
137
  from repdf.printing import PrintJob, PrintPage