docvortex 0.2.1__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.
Files changed (364) hide show
  1. docvortex/__init__.py +21 -0
  2. docvortex/analyzers/__init__.py +3 -0
  3. docvortex/analyzers/native/__init__.py +37 -0
  4. docvortex/analyzers/native/_shared/__init__.py +3 -0
  5. docvortex/analyzers/native/_shared/hyperlink.py +16 -0
  6. docvortex/analyzers/native/_shared/image.py +8 -0
  7. docvortex/analyzers/native/_shared/markup/__init__.py +47 -0
  8. docvortex/analyzers/native/_shared/markup/anchors.py +30 -0
  9. docvortex/analyzers/native/_shared/markup/formula.py +37 -0
  10. docvortex/analyzers/native/_shared/markup/projector.py +57 -0
  11. docvortex/analyzers/native/_shared/markup/styles.py +19 -0
  12. docvortex/analyzers/native/_shared/mathml.py +17 -0
  13. docvortex/analyzers/native/_shared/names.py +7 -0
  14. docvortex/analyzers/native/_shared/xycut.py +414 -0
  15. docvortex/analyzers/native/contracts.py +44 -0
  16. docvortex/analyzers/native/csv.py +351 -0
  17. docvortex/analyzers/native/epub/__init__.py +18 -0
  18. docvortex/analyzers/native/epub/constants.py +45 -0
  19. docvortex/analyzers/native/epub/converter.py +80 -0
  20. docvortex/analyzers/native/epub/errors.py +20 -0
  21. docvortex/analyzers/native/epub/metadata.py +31 -0
  22. docvortex/analyzers/native/epub/package.py +649 -0
  23. docvortex/analyzers/native/epub/xhtml.py +306 -0
  24. docvortex/analyzers/native/html/__init__.py +6 -0
  25. docvortex/analyzers/native/html/anchors.py +271 -0
  26. docvortex/analyzers/native/html/constants.py +25 -0
  27. docvortex/analyzers/native/html/contracts.py +7 -0
  28. docvortex/analyzers/native/html/converter.py +117 -0
  29. docvortex/analyzers/native/html/document.py +389 -0
  30. docvortex/analyzers/native/html/errors.py +12 -0
  31. docvortex/analyzers/native/html/resources.py +365 -0
  32. docvortex/analyzers/native/html/selector.py +421 -0
  33. docvortex/analyzers/native/models.py +209 -0
  34. docvortex/analyzers/native/ofd/__init__.py +14 -0
  35. docvortex/analyzers/native/ofd/constants.py +59 -0
  36. docvortex/analyzers/native/ofd/converter.py +31 -0
  37. docvortex/analyzers/native/ofd/errors.py +16 -0
  38. docvortex/analyzers/native/ofd/geometry.py +215 -0
  39. docvortex/analyzers/native/ofd/images.py +124 -0
  40. docvortex/analyzers/native/ofd/metadata.py +53 -0
  41. docvortex/analyzers/native/ofd/models.py +170 -0
  42. docvortex/analyzers/native/ofd/package.py +346 -0
  43. docvortex/analyzers/native/ofd/path.py +201 -0
  44. docvortex/analyzers/native/ofd/reading_order.py +293 -0
  45. docvortex/analyzers/native/ofd/resources.py +122 -0
  46. docvortex/analyzers/native/ofd/scene.py +347 -0
  47. docvortex/analyzers/native/ofd/table.py +240 -0
  48. docvortex/analyzers/native/ofd/text.py +521 -0
  49. docvortex/analyzers/native/office/__init__.py +3 -0
  50. docvortex/analyzers/native/office/doc/__init__.py +3 -0
  51. docvortex/analyzers/native/office/doc/bookmarks.py +81 -0
  52. docvortex/analyzers/native/office/doc/doc_converter.py +573 -0
  53. docvortex/analyzers/native/office/doc/fib.py +261 -0
  54. docvortex/analyzers/native/office/doc/fields.py +101 -0
  55. docvortex/analyzers/native/office/doc/formatting.py +171 -0
  56. docvortex/analyzers/native/office/doc/images.py +162 -0
  57. docvortex/analyzers/native/office/doc/lists.py +356 -0
  58. docvortex/analyzers/native/office/doc/models.py +164 -0
  59. docvortex/analyzers/native/office/doc/parser.py +851 -0
  60. docvortex/analyzers/native/office/doc/pieces.py +256 -0
  61. docvortex/analyzers/native/office/doc/records.py +40 -0
  62. docvortex/analyzers/native/office/doc/sprm.py +269 -0
  63. docvortex/analyzers/native/office/doc/styles.py +215 -0
  64. docvortex/analyzers/native/office/docx/__init__.py +3 -0
  65. docvortex/analyzers/native/office/docx/context.py +71 -0
  66. docvortex/analyzers/native/office/docx/docx_converter.py +614 -0
  67. docvortex/analyzers/native/office/docx/equationxml.py +119 -0
  68. docvortex/analyzers/native/office/docx/fields.py +816 -0
  69. docvortex/analyzers/native/office/docx/formatting_types.py +23 -0
  70. docvortex/analyzers/native/office/docx/main.py +50 -0
  71. docvortex/analyzers/native/office/docx/numbering.py +491 -0
  72. docvortex/analyzers/native/office/docx/office_xml.py +57 -0
  73. docvortex/analyzers/native/office/docx/package_normalizer.py +248 -0
  74. docvortex/analyzers/native/office/docx/resources.py +526 -0
  75. docvortex/analyzers/native/office/docx/styles.py +693 -0
  76. docvortex/analyzers/native/office/docx/tables.py +515 -0
  77. docvortex/analyzers/native/office/equation/__init__.py +3 -0
  78. docvortex/analyzers/native/office/equation/image.py +470 -0
  79. docvortex/analyzers/native/office/equation/latex_dict.py +324 -0
  80. docvortex/analyzers/native/office/equation/mtef.py +885 -0
  81. docvortex/analyzers/native/office/equation/mtef_v5.py +941 -0
  82. docvortex/analyzers/native/office/equation/omml.py +561 -0
  83. docvortex/analyzers/native/office/equation/ooxml.py +62 -0
  84. docvortex/analyzers/native/office/errors.py +33 -0
  85. docvortex/analyzers/native/office/image.py +307 -0
  86. docvortex/analyzers/native/office/legacy/__init__.py +3 -0
  87. docvortex/analyzers/native/office/legacy/binary.py +43 -0
  88. docvortex/analyzers/native/office/legacy/officeart.py +362 -0
  89. docvortex/analyzers/native/office/legacy/ole.py +110 -0
  90. docvortex/analyzers/native/office/limits.py +12 -0
  91. docvortex/analyzers/native/office/odf/__init__.py +3 -0
  92. docvortex/analyzers/native/office/odf/chart.py +76 -0
  93. docvortex/analyzers/native/office/odf/constants.py +70 -0
  94. docvortex/analyzers/native/office/odf/converters.py +403 -0
  95. docvortex/analyzers/native/office/odf/errors.py +18 -0
  96. docvortex/analyzers/native/office/odf/metadata.py +91 -0
  97. docvortex/analyzers/native/office/odf/models.py +176 -0
  98. docvortex/analyzers/native/office/odf/package.py +270 -0
  99. docvortex/analyzers/native/office/odf/styles.py +329 -0
  100. docvortex/analyzers/native/office/odf/table.py +469 -0
  101. docvortex/analyzers/native/office/odf/text.py +1002 -0
  102. docvortex/analyzers/native/office/ooxml_chart.py +1016 -0
  103. docvortex/analyzers/native/office/opc.py +38 -0
  104. docvortex/analyzers/native/office/ppt/__init__.py +3 -0
  105. docvortex/analyzers/native/office/ppt/models.py +118 -0
  106. docvortex/analyzers/native/office/ppt/parser.py +1895 -0
  107. docvortex/analyzers/native/office/ppt/ppt_converter.py +292 -0
  108. docvortex/analyzers/native/office/ppt/records.py +131 -0
  109. docvortex/analyzers/native/office/ppt/style_text.py +247 -0
  110. docvortex/analyzers/native/office/pptx/__init__.py +3 -0
  111. docvortex/analyzers/native/office/pptx/context.py +113 -0
  112. docvortex/analyzers/native/office/pptx/lists.py +558 -0
  113. docvortex/analyzers/native/office/pptx/main.py +20 -0
  114. docvortex/analyzers/native/office/pptx/package_normalizer.py +321 -0
  115. docvortex/analyzers/native/office/pptx/pptx_converter.py +323 -0
  116. docvortex/analyzers/native/office/pptx/resources.py +329 -0
  117. docvortex/analyzers/native/office/pptx/shapes.py +393 -0
  118. docvortex/analyzers/native/office/pptx/text_styles.py +625 -0
  119. docvortex/analyzers/native/office/pptx/titles.py +178 -0
  120. docvortex/analyzers/native/office/rich_text.py +420 -0
  121. docvortex/analyzers/native/office/rtf/__init__.py +3 -0
  122. docvortex/analyzers/native/office/rtf/converter.py +708 -0
  123. docvortex/analyzers/native/office/rtf/lexer.py +213 -0
  124. docvortex/analyzers/native/office/rtf/math.py +339 -0
  125. docvortex/analyzers/native/office/rtf/models.py +185 -0
  126. docvortex/analyzers/native/office/rtf/parser.py +1553 -0
  127. docvortex/analyzers/native/office/spreadsheet/__init__.py +3 -0
  128. docvortex/analyzers/native/office/spreadsheet/html.py +81 -0
  129. docvortex/analyzers/native/office/spreadsheet/models.py +79 -0
  130. docvortex/analyzers/native/office/spreadsheet/projector.py +928 -0
  131. docvortex/analyzers/native/office/streams.py +18 -0
  132. docvortex/analyzers/native/office/xls/__init__.py +3 -0
  133. docvortex/analyzers/native/office/xls/chart.py +132 -0
  134. docvortex/analyzers/native/office/xls/embedded_chart.py +299 -0
  135. docvortex/analyzers/native/office/xls/models.py +109 -0
  136. docvortex/analyzers/native/office/xls/number_format.py +521 -0
  137. docvortex/analyzers/native/office/xls/parser.py +1145 -0
  138. docvortex/analyzers/native/office/xls/records.py +201 -0
  139. docvortex/analyzers/native/office/xls/strings.py +205 -0
  140. docvortex/analyzers/native/office/xls/xls_converter.py +354 -0
  141. docvortex/analyzers/native/office/xlsx/__init__.py +3 -0
  142. docvortex/analyzers/native/office/xlsx/main.py +20 -0
  143. docvortex/analyzers/native/office/xlsx/ooxml_ole.py +522 -0
  144. docvortex/analyzers/native/office/xlsx/package_normalizer.py +310 -0
  145. docvortex/analyzers/native/office/xlsx/xlsx_converter.py +716 -0
  146. docvortex/analyzers/native/pdf/__init__.py +3 -0
  147. docvortex/analyzers/native/pdf/auxiliary_text.py +1671 -0
  148. docvortex/analyzers/native/pdf/char_geometry.py +1662 -0
  149. docvortex/analyzers/native/pdf/code_blocks.py +535 -0
  150. docvortex/analyzers/native/pdf/formulas.py +1985 -0
  151. docvortex/analyzers/native/pdf/geometry.py +281 -0
  152. docvortex/analyzers/native/pdf/graphics.py +1501 -0
  153. docvortex/analyzers/native/pdf/index_blocks.py +268 -0
  154. docvortex/analyzers/native/pdf/inline/__init__.py +3 -0
  155. docvortex/analyzers/native/pdf/inline/common.py +112 -0
  156. docvortex/analyzers/native/pdf/inline/detection.py +590 -0
  157. docvortex/analyzers/native/pdf/inline/matching.py +1185 -0
  158. docvortex/analyzers/native/pdf/inline/materialize.py +457 -0
  159. docvortex/analyzers/native/pdf/inline/scripts.py +975 -0
  160. docvortex/analyzers/native/pdf/inline/types.py +385 -0
  161. docvortex/analyzers/native/pdf/line_layout.py +1106 -0
  162. docvortex/analyzers/native/pdf/line_merging.py +1223 -0
  163. docvortex/analyzers/native/pdf/models.py +246 -0
  164. docvortex/analyzers/native/pdf/native_text.py +1004 -0
  165. docvortex/analyzers/native/pdf/pipeline.py +1390 -0
  166. docvortex/analyzers/native/pdf/script_geometry.py +636 -0
  167. docvortex/analyzers/native/pdf/shared.py +30 -0
  168. docvortex/analyzers/native/pdf/spatial_text.py +383 -0
  169. docvortex/analyzers/native/pdf/table_annotations.py +446 -0
  170. docvortex/analyzers/native/pdf/table_constants.py +48 -0
  171. docvortex/analyzers/native/pdf/table_detection.py +227 -0
  172. docvortex/analyzers/native/pdf/table_filled_grid.py +218 -0
  173. docvortex/analyzers/native/pdf/table_geometry.py +40 -0
  174. docvortex/analyzers/native/pdf/table_materialization.py +444 -0
  175. docvortex/analyzers/native/pdf/table_recovery/__init__.py +15 -0
  176. docvortex/analyzers/native/pdf/table_recovery/candidate.py +356 -0
  177. docvortex/analyzers/native/pdf/table_recovery/contracts.py +154 -0
  178. docvortex/analyzers/native/pdf/table_recovery/engine.py +609 -0
  179. docvortex/analyzers/native/pdf/table_recovery/geometry.py +164 -0
  180. docvortex/analyzers/native/pdf/table_recovery/sparse_common.py +85 -0
  181. docvortex/analyzers/native/pdf/table_recovery/sparse_hybrid.py +804 -0
  182. docvortex/analyzers/native/pdf/table_recovery/sparse_multiline.py +1122 -0
  183. docvortex/analyzers/native/pdf/table_recovery/text.py +414 -0
  184. docvortex/analyzers/native/pdf/table_recovery/text_grid.py +618 -0
  185. docvortex/analyzers/native/pdf/table_recovery/vector.py +1931 -0
  186. docvortex/analyzers/native/pdf/table_rows.py +34 -0
  187. docvortex/analyzers/native/pdf/table_rules.py +1129 -0
  188. docvortex/analyzers/native/pdf/table_text_styles.py +283 -0
  189. docvortex/analyzers/native/pdf/tables.py +147 -0
  190. docvortex/analyzers/native/pdf/text_assembly/__init__.py +3 -0
  191. docvortex/analyzers/native/pdf/text_assembly/annotations.py +581 -0
  192. docvortex/analyzers/native/pdf/text_assembly/assembly.py +292 -0
  193. docvortex/analyzers/native/pdf/text_assembly/common.py +477 -0
  194. docvortex/analyzers/native/pdf/text_assembly/footnotes.py +394 -0
  195. docvortex/analyzers/native/pdf/text_assembly/merging.py +1274 -0
  196. docvortex/analyzers/native/pdf/text_assembly/rows.py +692 -0
  197. docvortex/analyzers/native/pdf/text_blocks.py +82 -0
  198. docvortex/analyzers/native/pdf/text_styles.py +55 -0
  199. docvortex/analyzers/native/pdf/title_analysis/__init__.py +3 -0
  200. docvortex/analyzers/native/pdf/title_analysis/body_profile.py +215 -0
  201. docvortex/analyzers/native/pdf/title_analysis/common.py +117 -0
  202. docvortex/analyzers/native/pdf/title_analysis/document_profile.py +164 -0
  203. docvortex/analyzers/native/pdf/title_analysis/lane_titles.py +758 -0
  204. docvortex/analyzers/native/pdf/title_analysis/page_titles.py +1024 -0
  205. docvortex/analyzers/native/pdf/title_analysis/prototype.py +194 -0
  206. docvortex/analyzers/native/pdf/title_analysis/structural.py +1081 -0
  207. docvortex/analyzers/native/pdf/titles.py +75 -0
  208. docvortex/analyzers/native/pdf/typography.py +19 -0
  209. docvortex/analyzers/native/pdf/visual_annotations.py +1262 -0
  210. docvortex/api.py +180 -0
  211. docvortex/assets/__init__.py +5 -0
  212. docvortex/assets/store.py +51 -0
  213. docvortex/cli.py +54 -0
  214. docvortex/codecs/__init__.py +3 -0
  215. docvortex/codecs/html/__init__.py +22 -0
  216. docvortex/codecs/html/contracts.py +236 -0
  217. docvortex/codecs/html/materializer.py +331 -0
  218. docvortex/codecs/html/parser.py +763 -0
  219. docvortex/codecs/html/resources.py +34 -0
  220. docvortex/codecs/json.py +17 -0
  221. docvortex/content/__init__.py +5 -0
  222. docvortex/content/inline.py +248 -0
  223. docvortex/content/markup/__init__.py +44 -0
  224. docvortex/content/markup/anchors.py +188 -0
  225. docvortex/content/markup/formula.py +280 -0
  226. docvortex/content/markup/projector.py +1237 -0
  227. docvortex/content/markup/styles.py +327 -0
  228. docvortex/content/mathml.py +167 -0
  229. docvortex/content/normalization.py +188 -0
  230. docvortex/content/spans.py +183 -0
  231. docvortex/content/table/__init__.py +18 -0
  232. docvortex/content/table/blocks.py +152 -0
  233. docvortex/content/table/content.py +425 -0
  234. docvortex/content/table/document.py +104 -0
  235. docvortex/content/table/html.py +399 -0
  236. docvortex/content/table/models.py +76 -0
  237. docvortex/content/table/rules.py +42 -0
  238. docvortex/content/table/structure.py +221 -0
  239. docvortex/content/tree.py +5 -0
  240. docvortex/document/__init__.py +3 -0
  241. docvortex/document/contracts.py +22 -0
  242. docvortex/document/detection.py +389 -0
  243. docvortex/document/filetypes.py +175 -0
  244. docvortex/document/page_range.py +167 -0
  245. docvortex/document/pdf/__init__.py +19 -0
  246. docvortex/document/pdf/classify.py +1138 -0
  247. docvortex/document/pdf/constants.py +132 -0
  248. docvortex/document/pdf/diagnostics.py +350 -0
  249. docvortex/document/pdf/document.py +582 -0
  250. docvortex/document/pdf/font_runtime.py +335 -0
  251. docvortex/document/pdf/geometry.py +31 -0
  252. docvortex/document/pdf/images.py +654 -0
  253. docvortex/document/pdf/native_annotations.py +367 -0
  254. docvortex/document/pdf/native_contracts.py +169 -0
  255. docvortex/document/pdf/native_coordinates.py +216 -0
  256. docvortex/document/pdf/native_lifecycle.py +16 -0
  257. docvortex/document/pdf/native_objects.py +902 -0
  258. docvortex/document/pdf/native_text_geometry.py +315 -0
  259. docvortex/document/pdf/pdfium.py +325 -0
  260. docvortex/document/pdf/raster.py +46 -0
  261. docvortex/document/pdf/text/__init__.py +62 -0
  262. docvortex/document/pdf/text/contracts.py +211 -0
  263. docvortex/document/pdf/text/extract.py +165 -0
  264. docvortex/document/pdf/text/geometry.py +16 -0
  265. docvortex/document/pdf/text/groups.py +162 -0
  266. docvortex/document/pdf/visual_geometry.py +201 -0
  267. docvortex/document/pdf/visuals.py +343 -0
  268. docvortex/document/source.py +92 -0
  269. docvortex/errors.py +21 -0
  270. docvortex/export/__init__.py +3 -0
  271. docvortex/export/bundle.py +98 -0
  272. docvortex/export/files.py +66 -0
  273. docvortex/export/middle.py +208 -0
  274. docvortex/foundation/__init__.py +3 -0
  275. docvortex/foundation/geometry.py +125 -0
  276. docvortex/foundation/hyperlink.py +65 -0
  277. docvortex/foundation/image.py +48 -0
  278. docvortex/foundation/image_encoding.py +30 -0
  279. docvortex/foundation/image_payload.py +280 -0
  280. docvortex/foundation/language.py +92 -0
  281. docvortex/foundation/platform.py +38 -0
  282. docvortex/foundation/text.py +153 -0
  283. docvortex/foundation/type_identity.py +20 -0
  284. docvortex/foundation/xml_names.py +20 -0
  285. docvortex/options.py +30 -0
  286. docvortex/postprocess/__init__.py +3 -0
  287. docvortex/postprocess/content.py +53 -0
  288. docvortex/postprocess/document.py +19 -0
  289. docvortex/postprocess/lists.py +236 -0
  290. docvortex/postprocess/page_blocks.py +214 -0
  291. docvortex/postprocess/pages.py +95 -0
  292. docvortex/postprocess/paragraphs.py +580 -0
  293. docvortex/postprocess/visual.py +715 -0
  294. docvortex/render/__init__.py +48 -0
  295. docvortex/render/_internal/__init__.py +3 -0
  296. docvortex/render/_internal/common/__init__.py +3 -0
  297. docvortex/render/_internal/common/context.py +43 -0
  298. docvortex/render/_internal/common/html_table.py +178 -0
  299. docvortex/render/_internal/common/index.py +33 -0
  300. docvortex/render/_internal/common/list_items.py +158 -0
  301. docvortex/render/_internal/common/planner.py +140 -0
  302. docvortex/render/_internal/docx/__init__.py +3 -0
  303. docvortex/render/_internal/docx/assets.py +202 -0
  304. docvortex/render/_internal/docx/inline.py +434 -0
  305. docvortex/render/_internal/docx/math.py +220 -0
  306. docvortex/render/_internal/docx/renderer.py +905 -0
  307. docvortex/render/_internal/docx/styles.py +195 -0
  308. docvortex/render/_internal/docx/table.py +442 -0
  309. docvortex/render/_internal/epub/__init__.py +5 -0
  310. docvortex/render/_internal/epub/assets.py +173 -0
  311. docvortex/render/_internal/epub/package.py +249 -0
  312. docvortex/render/_internal/epub/renderer.py +1156 -0
  313. docvortex/render/_internal/html/__init__.py +3 -0
  314. docvortex/render/_internal/html/inline.py +346 -0
  315. docvortex/render/_internal/html/renderer.py +1041 -0
  316. docvortex/render/_internal/html/sanitizer.py +478 -0
  317. docvortex/render/_internal/html/table.py +121 -0
  318. docvortex/render/_internal/latex/__init__.py +1 -0
  319. docvortex/render/_internal/latex/assets.py +85 -0
  320. docvortex/render/_internal/latex/inline.py +145 -0
  321. docvortex/render/_internal/latex/renderer.py +506 -0
  322. docvortex/render/_internal/latex/table.py +347 -0
  323. docvortex/render/_internal/markdown/__init__.py +3 -0
  324. docvortex/render/_internal/markdown/assets.py +78 -0
  325. docvortex/render/_internal/markdown/blocks.py +635 -0
  326. docvortex/render/_internal/markdown/escaping.py +51 -0
  327. docvortex/render/_internal/markdown/inline.py +260 -0
  328. docvortex/render/_internal/markdown/renderer.py +93 -0
  329. docvortex/render/_internal/markdown/table.py +281 -0
  330. docvortex/render/_internal/pdf/__init__.py +3 -0
  331. docvortex/render/_internal/pdf/assets.py +197 -0
  332. docvortex/render/_internal/pdf/formula.py +417 -0
  333. docvortex/render/_internal/pdf/inline.py +343 -0
  334. docvortex/render/_internal/pdf/renderer.py +734 -0
  335. docvortex/render/_internal/pdf/styles.py +206 -0
  336. docvortex/render/_internal/pdf/table.py +272 -0
  337. docvortex/render/_internal/structured_content/__init__.py +3 -0
  338. docvortex/render/_internal/structured_content/renderer.py +193 -0
  339. docvortex/render/api.py +199 -0
  340. docvortex/render/contracts.py +205 -0
  341. docvortex/render/docx.py +38 -0
  342. docvortex/render/epub.py +35 -0
  343. docvortex/render/fragments.py +70 -0
  344. docvortex/render/html.py +29 -0
  345. docvortex/render/latex.py +24 -0
  346. docvortex/render/markdown.py +50 -0
  347. docvortex/render/pdf.py +25 -0
  348. docvortex/render/structured_content.py +23 -0
  349. docvortex/resources/epub/docvortex.css +91 -0
  350. docvortex/resources/fasttext-langdetect/lid.176.ftz +0 -0
  351. docvortex/resources/fonts/DroidSansFallbackFull.ttf +0 -0
  352. docvortex/resources/fonts/NOTICE +190 -0
  353. docvortex/resources/fonts/manifest.json +9 -0
  354. docvortex/resources/html/docvortex.css +601 -0
  355. docvortex/resources/html/docvortex.min.css +1 -0
  356. docvortex/result.py +91 -0
  357. docvortex/schema.py +1141 -0
  358. docvortex/version.py +3 -0
  359. docvortex-0.2.1.dist-info/METADATA +193 -0
  360. docvortex-0.2.1.dist-info/RECORD +364 -0
  361. docvortex-0.2.1.dist-info/WHEEL +5 -0
  362. docvortex-0.2.1.dist-info/entry_points.txt +2 -0
  363. docvortex-0.2.1.dist-info/licenses/LICENSE.md +21 -0
  364. docvortex-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,649 @@
1
+ """在固定资源预算内读取 EPUB OCF 容器、OPF manifest 与 spine。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import posixpath
6
+ from dataclasses import dataclass
7
+ from io import BytesIO
8
+ from pathlib import Path, PurePosixPath
9
+ from urllib.parse import unquote, urlsplit
10
+ from zipfile import BadZipFile, ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
11
+
12
+ from loguru import logger
13
+ from lxml import etree # type: ignore[reportMissingImports]
14
+
15
+ from .constants import (
16
+ EPUB_MIME,
17
+ MAX_ASSET_TOTAL_BYTES,
18
+ MAX_ENTRY_BYTES,
19
+ MAX_ENTRY_COUNT,
20
+ MAX_TOTAL_BYTES,
21
+ MAX_XML_DEPTH,
22
+ MAX_XML_NODES,
23
+ SVG_MEDIA_TYPE,
24
+ XHTML_MEDIA_TYPES,
25
+ )
26
+ from .errors import EpubEncryptedError, EpubParseError, EpubResourceLimitError
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class EpubTarget:
31
+ """保存解析后的 OCF 成员路径和可选 fragment。"""
32
+
33
+ path: str
34
+ fragment: str | None = None
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class EpubManifestItem:
39
+ """保存 OPF manifest 中一个资源的规范信息。"""
40
+
41
+ item_id: str
42
+ path: str
43
+ media_type: str
44
+ properties: frozenset[str]
45
+ fallback: str | None
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class EpubSpineItem:
50
+ """保存默认阅读顺序中一个已解析的逻辑内容项。"""
51
+
52
+ index: int
53
+ idref: str
54
+ path: str | None
55
+ media_type: str | None
56
+ linear: bool
57
+ properties: frozenset[str]
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class EpubMetadata:
62
+ """保存 OPF 中供 doclib 使用的基础出版物元数据。"""
63
+
64
+ title: str | None
65
+ author: str | None
66
+ subject: str | None
67
+ keywords: str | None
68
+ layout: str
69
+
70
+
71
+ def _xml_parser() -> etree.XMLParser:
72
+ """为每个 EPUB XML part 创建禁用实体、DTD 和网络的 parser。"""
73
+ return etree.XMLParser(
74
+ resolve_entities=False,
75
+ load_dtd=False,
76
+ no_network=True,
77
+ recover=False,
78
+ remove_blank_text=False,
79
+ huge_tree=False,
80
+ )
81
+
82
+
83
+ def _recovering_xml_parser() -> etree.XMLParser:
84
+ """创建禁用实体、DTD 和网络但允许修复局部 XHTML 语法的 parser。"""
85
+ return etree.XMLParser(
86
+ resolve_entities=False,
87
+ load_dtd=False,
88
+ no_network=True,
89
+ recover=True,
90
+ remove_blank_text=False,
91
+ huge_tree=False,
92
+ )
93
+
94
+
95
+ def _recovering_html_parser() -> etree.HTMLParser:
96
+ """创建禁用网络并按 HTML 空元素语义修复正文结构的 parser。"""
97
+ return etree.HTMLParser(
98
+ no_network=True,
99
+ recover=True,
100
+ remove_comments=False,
101
+ remove_blank_text=False,
102
+ huge_tree=False,
103
+ )
104
+
105
+
106
+ def _local_name(element: etree._Element) -> str:
107
+ """返回 XML 元素不含命名空间的本地名。"""
108
+ return etree.QName(element).localname
109
+
110
+
111
+ def _element_text(element: etree._Element | None) -> str | None:
112
+ """返回元素折叠首尾空白后的完整文本。"""
113
+ if element is None:
114
+ return None
115
+ value = "".join(element.itertext()).strip()
116
+ return value or None
117
+
118
+
119
+ class EpubPackage:
120
+ """负责 EPUB 包身份、资源读取、OPF 和 spine 的受限解析。"""
121
+
122
+ def __init__(self, file_bytes: bytes) -> None:
123
+ """打开内存 EPUB,并在读取正文前校验中央目录和必需结构。"""
124
+ if len(file_bytes) > MAX_TOTAL_BYTES:
125
+ raise EpubResourceLimitError(f"EPUB resource limit exceeded: max_total_bytes={MAX_TOTAL_BYTES}")
126
+ try:
127
+ self._zip = ZipFile(BytesIO(file_bytes))
128
+ except (BadZipFile, OSError, ValueError) as exc:
129
+ raise EpubParseError(f"Malformed EPUB package: {exc}") from exc
130
+ try:
131
+ self._infos = self._validate_members(self._zip.infolist())
132
+ self._cache: dict[str, bytes] = {}
133
+ self._total_read = 0
134
+ self._asset_parts: set[str] = set()
135
+ self._asset_bytes = 0
136
+ self._encrypted_parts = self._read_encrypted_parts()
137
+ self._validate_mimetype()
138
+ self.opf_path = self._read_default_rootfile()
139
+ opf_root = self.xml_part(self.opf_path, required=True)
140
+ if opf_root is None:
141
+ raise EpubParseError("Malformed EPUB package: OPF root could not be parsed")
142
+ self.opf_root: etree._Element = opf_root
143
+ self.manifest = self._read_manifest()
144
+ self.spine = self._read_spine()
145
+ self.navigation_path = self._read_navigation_path()
146
+ self.ncx_path = self._read_ncx_path()
147
+ self.metadata = self._read_metadata()
148
+ except Exception:
149
+ self._zip.close()
150
+ raise
151
+
152
+ @staticmethod
153
+ def _validate_members(infos: list[ZipInfo]) -> dict[str, ZipInfo]:
154
+ """校验成员数量、体积、路径、重名和 ZIP 级加密。"""
155
+ if len(infos) > MAX_ENTRY_COUNT:
156
+ raise EpubResourceLimitError(f"EPUB resource limit exceeded: max_entry_count={MAX_ENTRY_COUNT}")
157
+ total_size = 0
158
+ members: dict[str, ZipInfo] = {}
159
+ for info in infos:
160
+ name = info.filename
161
+ if not EpubPackage._is_safe_member_name(name):
162
+ raise EpubParseError(f"Malformed EPUB package: unsafe member path {name!r}")
163
+ if name in members:
164
+ raise EpubParseError(f"Malformed EPUB package: duplicate member {name!r}")
165
+ if info.flag_bits & 0x1:
166
+ raise EpubEncryptedError(f"Encrypted EPUB ZIP member is not supported: {name!r}")
167
+ if info.compress_type not in {ZIP_STORED, ZIP_DEFLATED}:
168
+ raise EpubParseError(f"Malformed EPUB package: unsupported ZIP compression for {name!r}")
169
+ if info.file_size > MAX_ENTRY_BYTES:
170
+ raise EpubResourceLimitError(
171
+ f"EPUB resource limit exceeded: member {name!r} exceeds max_entry_bytes={MAX_ENTRY_BYTES}"
172
+ )
173
+ total_size += info.file_size
174
+ if total_size > MAX_TOTAL_BYTES:
175
+ raise EpubResourceLimitError(f"EPUB resource limit exceeded: max_total_bytes={MAX_TOTAL_BYTES}")
176
+ members[name] = info
177
+ return members
178
+
179
+ @staticmethod
180
+ def _is_safe_member_name(name: str) -> bool:
181
+ """判断 ZIP 成员是否为无绝对路径、反斜杠和上跳段的 POSIX 路径。"""
182
+ if not name or "\x00" in name or "\\" in name or name.startswith("/"):
183
+ return False
184
+ parts = PurePosixPath(name).parts
185
+ return bool(parts) and all(part not in {"", ".", ".."} for part in parts)
186
+
187
+ def _validate_mimetype(self) -> None:
188
+ """验证存在时的 EPUB mimetype,并对顺序或压缩不规范记录兼容告警。"""
189
+ info = self._infos.get("mimetype")
190
+ if info is None:
191
+ logger.warning("EPUB package has no mimetype member; using container.xml compatibility detection")
192
+ return
193
+ data = self.read_part("mimetype", required=True)
194
+ assert data is not None
195
+ try:
196
+ value = data.decode("ascii", errors="strict")
197
+ except UnicodeDecodeError as exc:
198
+ raise EpubParseError("Malformed EPUB package: mimetype is not ASCII") from exc
199
+ if value != EPUB_MIME:
200
+ raise EpubParseError(f"Malformed EPUB package: invalid mimetype {value!r}")
201
+ first_name = self._zip.infolist()[0].filename if self._zip.infolist() else ""
202
+ if first_name != "mimetype" or info.compress_type != ZIP_STORED:
203
+ logger.warning("EPUB mimetype is not the first uncompressed member; continuing in compatibility mode")
204
+
205
+ def read_part(self, part_name: str, *, required: bool = False, asset: bool = False) -> bytes | None:
206
+ """读取一个已校验成员,并按唯一资源累计图片载荷。"""
207
+ if part_name in self._encrypted_parts:
208
+ raise EpubEncryptedError(f"Encrypted EPUB resource is not supported: {part_name!r}")
209
+ info = self._infos.get(part_name)
210
+ if info is None:
211
+ if required:
212
+ raise EpubParseError(f"Malformed EPUB package: missing required part {part_name!r}")
213
+ return None
214
+ if part_name in self._cache:
215
+ data = self._cache[part_name]
216
+ if asset:
217
+ self._charge_asset(part_name, len(data))
218
+ return data
219
+ try:
220
+ with self._zip.open(info) as source:
221
+ data = source.read(MAX_ENTRY_BYTES + 1)
222
+ except (BadZipFile, OSError, RuntimeError, ValueError) as exc:
223
+ if required:
224
+ raise EpubParseError(f"Malformed EPUB package: cannot read {part_name!r}: {exc}") from exc
225
+ return None
226
+ if len(data) > MAX_ENTRY_BYTES:
227
+ raise EpubResourceLimitError(
228
+ f"EPUB resource limit exceeded: member {part_name!r} exceeds max_entry_bytes={MAX_ENTRY_BYTES}"
229
+ )
230
+ self._charge_total(part_name, len(data))
231
+ if asset:
232
+ self._charge_asset(part_name, len(data))
233
+ self._cache[part_name] = data
234
+ return data
235
+
236
+ def _charge_total(self, part_name: str, byte_count: int) -> None:
237
+ """按首次实际读取字节累计全包解压预算。"""
238
+ self._total_read += byte_count
239
+ if self._total_read > MAX_TOTAL_BYTES:
240
+ raise EpubResourceLimitError(
241
+ f"EPUB resource limit exceeded while reading {part_name!r}: max_total_bytes={MAX_TOTAL_BYTES}"
242
+ )
243
+
244
+ def _charge_asset(self, part_name: str, byte_count: int) -> None:
245
+ """按唯一包成员累计保留图片字节,重复引用不重复计费。"""
246
+ if part_name in self._asset_parts:
247
+ return
248
+ self._asset_parts.add(part_name)
249
+ self._asset_bytes += byte_count
250
+ if self._asset_bytes > MAX_ASSET_TOTAL_BYTES:
251
+ raise EpubResourceLimitError(f"EPUB resource limit exceeded: max_asset_total_bytes={MAX_ASSET_TOTAL_BYTES}")
252
+
253
+ def xml_part(
254
+ self,
255
+ part_name: str,
256
+ *,
257
+ required: bool = False,
258
+ allow_external_doctype: bool = False,
259
+ ) -> etree._Element | None:
260
+ """安全解析 XML/XHTML part,并校验节点数和最大深度。"""
261
+ data = self.read_part(part_name, required=required)
262
+ if data is None:
263
+ return None
264
+ try:
265
+ root = etree.fromstring(data, parser=_xml_parser())
266
+ except (etree.XMLSyntaxError, ValueError) as exc:
267
+ if required:
268
+ raise EpubParseError(f"Malformed EPUB package: invalid XML part {part_name!r}: {exc}") from exc
269
+ return None
270
+ self._validate_xml_document(root, part_name, allow_external_doctype=allow_external_doctype)
271
+ return root
272
+
273
+ def xhtml_part(
274
+ self,
275
+ part_name: str,
276
+ *,
277
+ required: bool = False,
278
+ allow_external_doctype: bool = True,
279
+ ) -> etree._Element | None:
280
+ """严格解析 spine XHTML,失败后在相同安全预算内尝试局部语法恢复。"""
281
+ data = self.read_part(part_name, required=required)
282
+ if data is None:
283
+ return None
284
+ strict_error: etree.XMLSyntaxError | ValueError | None = None
285
+ try:
286
+ root = etree.fromstring(data, parser=_xml_parser())
287
+ except (etree.XMLSyntaxError, ValueError) as exc:
288
+ strict_error = exc
289
+ try:
290
+ xml_recovery_root = etree.fromstring(data, parser=_recovering_xml_parser())
291
+ except (etree.XMLSyntaxError, ValueError) as recovery_exc:
292
+ if required:
293
+ raise EpubParseError(
294
+ f"Malformed EPUB package: invalid XHTML part {part_name!r}: {recovery_exc}"
295
+ ) from recovery_exc
296
+ return None
297
+ if not isinstance(xml_recovery_root.tag, str) or _local_name(xml_recovery_root).casefold() != "html":
298
+ if required:
299
+ raise EpubParseError(f"Malformed EPUB package: XHTML part {part_name!r} has no html root")
300
+ return None
301
+ self._validate_xml_document(
302
+ xml_recovery_root,
303
+ part_name,
304
+ allow_external_doctype=allow_external_doctype,
305
+ )
306
+ try:
307
+ root = etree.fromstring(data, parser=_recovering_html_parser())
308
+ except (etree.XMLSyntaxError, ValueError) as recovery_exc:
309
+ if required:
310
+ raise EpubParseError(
311
+ f"Malformed EPUB package: invalid XHTML part {part_name!r}: {recovery_exc}"
312
+ ) from recovery_exc
313
+ return None
314
+ if not isinstance(root.tag, str) or _local_name(root).casefold() != "html":
315
+ if required:
316
+ raise EpubParseError(f"Malformed EPUB package: XHTML part {part_name!r} has no html root")
317
+ return None
318
+ self._validate_xml_document(root, part_name, allow_external_doctype=allow_external_doctype)
319
+ if strict_error is not None:
320
+ logger.warning("Recovered malformed EPUB XHTML part path={!r}: {}", part_name, strict_error)
321
+ return root
322
+
323
+ @classmethod
324
+ def _validate_xml_document(
325
+ cls,
326
+ root: etree._Element,
327
+ part_name: str,
328
+ *,
329
+ allow_external_doctype: bool,
330
+ ) -> None:
331
+ """统一校验已解析 XML/XHTML 的 DTD 策略、节点数和最大深度。"""
332
+ docinfo = root.getroottree().docinfo
333
+ if docinfo.doctype:
334
+ internal_dtd = docinfo.internalDTD
335
+ has_entities = internal_dtd is not None and bool(internal_dtd.entities())
336
+ if not allow_external_doctype or has_entities:
337
+ raise EpubParseError(f"Malformed EPUB package: DTD declarations are not allowed in {part_name!r}")
338
+ cls._validate_xml_shape(root, part_name)
339
+
340
+ @staticmethod
341
+ def _validate_xml_shape(root: etree._Element, part_name: str) -> None:
342
+ """迭代统计 XML 节点与深度,避免超大或深层 DOM 继续传播。"""
343
+ node_count = 0
344
+ stack: list[tuple[etree._Element, int]] = [(root, 1)]
345
+ while stack:
346
+ element, depth = stack.pop()
347
+ node_count += 1
348
+ if node_count > MAX_XML_NODES:
349
+ raise EpubResourceLimitError(
350
+ f"EPUB resource limit exceeded: {part_name!r} exceeds max_xml_nodes={MAX_XML_NODES}"
351
+ )
352
+ if depth > MAX_XML_DEPTH:
353
+ raise EpubResourceLimitError(
354
+ f"EPUB resource limit exceeded: {part_name!r} exceeds max_xml_depth={MAX_XML_DEPTH}"
355
+ )
356
+ stack.extend((child, depth + 1) for child in element if isinstance(child.tag, str))
357
+
358
+ def resolve_reference(self, href: str, *, base_part: str) -> EpubTarget | None:
359
+ """按 OCF URI 规则解析相对引用,拒绝外部地址和编码后的结构字符。"""
360
+ raw_href = (href or "").strip()
361
+ if not raw_href:
362
+ return None
363
+ try:
364
+ split = urlsplit(raw_href)
365
+ except ValueError:
366
+ return None
367
+ if split.scheme or split.netloc:
368
+ return None
369
+ raw_path = split.path
370
+ base_segments = [] if raw_path.startswith("/") else [part for part in posixpath.dirname(base_part).split("/") if part]
371
+ segments = list(base_segments)
372
+ for raw_segment in raw_path.split("/"):
373
+ if raw_segment in {"", "."}:
374
+ continue
375
+ if raw_segment == "..":
376
+ if not segments:
377
+ return None
378
+ segments.pop()
379
+ continue
380
+ decoded = unquote(raw_segment)
381
+ if decoded in {"", ".", ".."} or "/" in decoded or "\\" in decoded or "\x00" in decoded:
382
+ return None
383
+ segments.append(decoded)
384
+ path = "/".join(segments) if raw_path else base_part
385
+ if not path or path not in self._infos:
386
+ return None
387
+ fragment = unquote(split.fragment) if split.fragment else None
388
+ return EpubTarget(path=path, fragment=fragment)
389
+
390
+ def _read_encrypted_parts(self) -> frozenset[str]:
391
+ """读取 encryption.xml 中的 CipherReference URI,损坏时按加密文件失败。"""
392
+ if "META-INF/encryption.xml" not in self._infos:
393
+ return frozenset()
394
+ data = self._read_unchecked_part("META-INF/encryption.xml")
395
+ try:
396
+ root = etree.fromstring(data, parser=_xml_parser())
397
+ except (etree.XMLSyntaxError, ValueError) as exc:
398
+ raise EpubEncryptedError(f"Malformed EPUB encryption.xml: {exc}") from exc
399
+ if root.getroottree().docinfo.doctype:
400
+ raise EpubEncryptedError("Malformed EPUB encryption.xml: DTD is not allowed")
401
+ encrypted: set[str] = set()
402
+ for element in root.iter():
403
+ if not isinstance(element.tag, str) or _local_name(element) != "CipherReference":
404
+ continue
405
+ uri = element.get("URI")
406
+ if not uri:
407
+ continue
408
+ # OCF 规定 META-INF 控制文件中的 URI 以容器根为基准。
409
+ target = self._resolve_reference_against_members(uri, base_part="")
410
+ if target:
411
+ encrypted.add(target.path)
412
+ return frozenset(encrypted)
413
+
414
+ def _read_unchecked_part(self, part_name: str) -> bytes:
415
+ """在加密成员集合尚未建立时读取已验证的小型控制 part。"""
416
+ info = self._infos.get(part_name)
417
+ if info is None:
418
+ raise EpubParseError(f"Malformed EPUB package: missing required part {part_name!r}")
419
+ try:
420
+ with self._zip.open(info) as source:
421
+ data = source.read(MAX_ENTRY_BYTES + 1)
422
+ except (BadZipFile, OSError, RuntimeError, ValueError) as exc:
423
+ raise EpubParseError(f"Malformed EPUB package: cannot read {part_name!r}: {exc}") from exc
424
+ if len(data) > MAX_ENTRY_BYTES:
425
+ raise EpubResourceLimitError(
426
+ f"EPUB resource limit exceeded: member {part_name!r} exceeds max_entry_bytes={MAX_ENTRY_BYTES}"
427
+ )
428
+ self._charge_total(part_name, len(data))
429
+ return data
430
+
431
+ def _resolve_reference_against_members(self, href: str, *, base_part: str) -> EpubTarget | None:
432
+ """在初始化阶段仅依赖中央目录解析安全包内引用。"""
433
+ raw_href = (href or "").strip()
434
+ if not raw_href:
435
+ return None
436
+ split = urlsplit(raw_href)
437
+ if split.scheme or split.netloc:
438
+ return None
439
+ raw_path = split.path
440
+ segments = [] if raw_path.startswith("/") else [part for part in posixpath.dirname(base_part).split("/") if part]
441
+ for raw_segment in raw_path.split("/"):
442
+ if raw_segment in {"", "."}:
443
+ continue
444
+ if raw_segment == "..":
445
+ if not segments:
446
+ return None
447
+ segments.pop()
448
+ continue
449
+ decoded = unquote(raw_segment)
450
+ if decoded in {"", ".", ".."} or "/" in decoded or "\\" in decoded or "\x00" in decoded:
451
+ return None
452
+ segments.append(decoded)
453
+ path = "/".join(segments) if raw_path else base_part
454
+ return EpubTarget(path, unquote(split.fragment) if split.fragment else None) if path in self._infos else None
455
+
456
+ def _read_default_rootfile(self) -> str:
457
+ """从 container.xml 读取第一个默认 rendition 的 OPF 路径。"""
458
+ container = self.xml_part("META-INF/container.xml", required=True)
459
+ assert container is not None
460
+ for element in container.iter():
461
+ if not isinstance(element.tag, str) or _local_name(element) != "rootfile":
462
+ continue
463
+ full_path = element.get("full-path")
464
+ if not full_path:
465
+ continue
466
+ target = self._resolve_reference_against_members(full_path, base_part="")
467
+ if target is not None:
468
+ return target.path
469
+ raise EpubParseError("Malformed EPUB package: container.xml has no usable rootfile")
470
+
471
+ def _read_manifest(self) -> dict[str, EpubManifestItem]:
472
+ """读取 OPF manifest,并把 href 规范化为实际包成员路径。"""
473
+ manifest: dict[str, EpubManifestItem] = {}
474
+ for element in self.opf_root.iter():
475
+ if not isinstance(element.tag, str) or _local_name(element) != "item":
476
+ continue
477
+ item_id = (element.get("id") or "").strip()
478
+ href = (element.get("href") or "").strip()
479
+ if not item_id or not href or item_id in manifest:
480
+ continue
481
+ fallback = (element.get("fallback") or "").strip() or None
482
+ target = self.resolve_reference(href, base_part=self.opf_path)
483
+ if target is None and fallback is None:
484
+ continue
485
+ manifest[item_id] = EpubManifestItem(
486
+ item_id=item_id,
487
+ path=target.path if target else "",
488
+ media_type=(element.get("media-type") or "").strip().casefold(),
489
+ properties=frozenset((element.get("properties") or "").split()),
490
+ fallback=fallback,
491
+ )
492
+ return manifest
493
+
494
+ def _supported_manifest_item(self, item_id: str) -> EpubManifestItem | None:
495
+ """沿 manifest fallback chain 找到首个支持的 XHTML 或 SVG 内容项。"""
496
+ visited: set[str] = set()
497
+ current_id: str | None = item_id
498
+ while current_id and current_id not in visited:
499
+ visited.add(current_id)
500
+ item = self.manifest.get(current_id)
501
+ if item is None:
502
+ return None
503
+ if item.path and (item.media_type in XHTML_MEDIA_TYPES or item.media_type == SVG_MEDIA_TYPE):
504
+ return item
505
+ current_id = item.fallback
506
+ return None
507
+
508
+ def _read_spine(self) -> list[EpubSpineItem]:
509
+ """按 OPF itemref 顺序建立稳定逻辑页,并保留 non-linear 内容。"""
510
+ spine_element = next(
511
+ (element for element in self.opf_root.iter() if isinstance(element.tag, str) and _local_name(element) == "spine"),
512
+ None,
513
+ )
514
+ if spine_element is None:
515
+ raise EpubParseError("Malformed EPUB package: OPF has no spine")
516
+ result: list[EpubSpineItem] = []
517
+ for element in spine_element:
518
+ if not isinstance(element.tag, str) or _local_name(element) != "itemref":
519
+ continue
520
+ idref = (element.get("idref") or "").strip()
521
+ item = self._supported_manifest_item(idref)
522
+ result.append(
523
+ EpubSpineItem(
524
+ index=len(result),
525
+ idref=idref,
526
+ path=item.path if item else None,
527
+ media_type=item.media_type if item else None,
528
+ linear=(element.get("linear") or "yes").casefold() != "no",
529
+ properties=frozenset((element.get("properties") or "").split()),
530
+ )
531
+ )
532
+ if not result:
533
+ raise EpubParseError("Malformed EPUB package: OPF spine has no itemref")
534
+ return result
535
+
536
+ def _read_navigation_path(self) -> str | None:
537
+ """返回 manifest 中首个 EPUB3 navigation document 路径。"""
538
+ for item in self.manifest.values():
539
+ if "nav" in item.properties and item.media_type in XHTML_MEDIA_TYPES and item.path:
540
+ return item.path
541
+ return None
542
+
543
+ def _read_ncx_path(self) -> str | None:
544
+ """按 OPF spine 的 toc ID 返回 EPUB2 NCX 资源路径。"""
545
+ spine_element = next(
546
+ (element for element in self.opf_root.iter() if isinstance(element.tag, str) and _local_name(element) == "spine"),
547
+ None,
548
+ )
549
+ if spine_element is None:
550
+ return None
551
+ toc_id = (spine_element.get("toc") or "").strip()
552
+ item = self.manifest.get(toc_id)
553
+ return item.path if item is not None and item.path else None
554
+
555
+ def _read_metadata(self) -> EpubMetadata:
556
+ """提取 OPF 的首个标题、作者、主题、关键词和布局模式。"""
557
+ title = author = subject = None
558
+ keywords: list[str] = []
559
+ layout = "reflowable"
560
+ for element in self.opf_root.iter():
561
+ if not isinstance(element.tag, str):
562
+ continue
563
+ name = _local_name(element)
564
+ value = _element_text(element)
565
+ if name == "title" and title is None:
566
+ title = value
567
+ elif name == "creator" and author is None:
568
+ author = value
569
+ elif name == "subject" and value:
570
+ subject = subject or value
571
+ keywords.append(value)
572
+ elif name == "meta":
573
+ property_name = (element.get("property") or element.get("name") or "").casefold()
574
+ content = value or (element.get("content") or "").strip() or None
575
+ if property_name in {"rendition:layout", "fixed-layout"} and content:
576
+ layout = "pre-paginated" if content in {"pre-paginated", "true"} else content
577
+ if property_name in {"keywords", "keyword"} and content:
578
+ keywords.append(content)
579
+ return EpubMetadata(title, author, subject, ", ".join(dict.fromkeys(keywords)) or None, layout)
580
+
581
+ def content_type_for(self, part_name: str) -> str | None:
582
+ """返回 manifest 为指定成员声明的媒体类型。"""
583
+ for item in self.manifest.values():
584
+ if item.path == part_name:
585
+ return item.media_type or None
586
+ return None
587
+
588
+ def close(self) -> None:
589
+ """关闭底层 ZipFile。"""
590
+ self._zip.close()
591
+
592
+
593
+ def _detect_epub_zip(package: ZipFile) -> bool:
594
+ """在已打开 ZIP 中按 mimetype 或有效 container rootfile 识别 EPUB。"""
595
+ try:
596
+ mime_info = package.getinfo("mimetype")
597
+ if mime_info.file_size <= len(EPUB_MIME):
598
+ with package.open(mime_info) as source:
599
+ if source.read(len(EPUB_MIME) + 1) == EPUB_MIME.encode("ascii"):
600
+ return True
601
+ except (KeyError, BadZipFile, OSError, RuntimeError, ValueError):
602
+ pass
603
+ infos = EpubPackage._validate_members(package.infolist())
604
+ container_info = infos.get("META-INF/container.xml")
605
+ if container_info is None:
606
+ return False
607
+ with package.open(container_info) as source:
608
+ data = source.read(MAX_ENTRY_BYTES + 1)
609
+ if len(data) > MAX_ENTRY_BYTES:
610
+ return False
611
+ root = etree.fromstring(data, parser=_xml_parser())
612
+ if root.getroottree().docinfo.doctype:
613
+ return False
614
+ for element in root.iter():
615
+ if not isinstance(element.tag, str) or _local_name(element) != "rootfile":
616
+ continue
617
+ full_path = (element.get("full-path") or "").lstrip("/")
618
+ if full_path in infos:
619
+ return True
620
+ return False
621
+
622
+
623
+ def detect_epub(file_bytes: bytes) -> bool:
624
+ """从内存字节按 EPUB mimetype 或有效 container rootfile 识别 OCF 包。"""
625
+ try:
626
+ with ZipFile(BytesIO(file_bytes)) as package:
627
+ return _detect_epub_zip(package)
628
+ except (BadZipFile, EpubEncryptedError, EpubParseError, EpubResourceLimitError, OSError, ValueError, etree.XMLSyntaxError):
629
+ return False
630
+
631
+
632
+ def detect_epub_path(file_path: str | Path) -> bool:
633
+ """从文件路径打开 ZIP 并验证 EPUB 强内容身份。"""
634
+ try:
635
+ with ZipFile(file_path) as package:
636
+ return _detect_epub_zip(package)
637
+ except (BadZipFile, EpubEncryptedError, EpubParseError, EpubResourceLimitError, OSError, ValueError, etree.XMLSyntaxError):
638
+ return False
639
+
640
+
641
+ __all__ = [
642
+ "EpubManifestItem",
643
+ "EpubMetadata",
644
+ "EpubPackage",
645
+ "EpubSpineItem",
646
+ "EpubTarget",
647
+ "detect_epub",
648
+ "detect_epub_path",
649
+ ]