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.
- docvortex/__init__.py +21 -0
- docvortex/analyzers/__init__.py +3 -0
- docvortex/analyzers/native/__init__.py +37 -0
- docvortex/analyzers/native/_shared/__init__.py +3 -0
- docvortex/analyzers/native/_shared/hyperlink.py +16 -0
- docvortex/analyzers/native/_shared/image.py +8 -0
- docvortex/analyzers/native/_shared/markup/__init__.py +47 -0
- docvortex/analyzers/native/_shared/markup/anchors.py +30 -0
- docvortex/analyzers/native/_shared/markup/formula.py +37 -0
- docvortex/analyzers/native/_shared/markup/projector.py +57 -0
- docvortex/analyzers/native/_shared/markup/styles.py +19 -0
- docvortex/analyzers/native/_shared/mathml.py +17 -0
- docvortex/analyzers/native/_shared/names.py +7 -0
- docvortex/analyzers/native/_shared/xycut.py +414 -0
- docvortex/analyzers/native/contracts.py +44 -0
- docvortex/analyzers/native/csv.py +351 -0
- docvortex/analyzers/native/epub/__init__.py +18 -0
- docvortex/analyzers/native/epub/constants.py +45 -0
- docvortex/analyzers/native/epub/converter.py +80 -0
- docvortex/analyzers/native/epub/errors.py +20 -0
- docvortex/analyzers/native/epub/metadata.py +31 -0
- docvortex/analyzers/native/epub/package.py +649 -0
- docvortex/analyzers/native/epub/xhtml.py +306 -0
- docvortex/analyzers/native/html/__init__.py +6 -0
- docvortex/analyzers/native/html/anchors.py +271 -0
- docvortex/analyzers/native/html/constants.py +25 -0
- docvortex/analyzers/native/html/contracts.py +7 -0
- docvortex/analyzers/native/html/converter.py +117 -0
- docvortex/analyzers/native/html/document.py +389 -0
- docvortex/analyzers/native/html/errors.py +12 -0
- docvortex/analyzers/native/html/resources.py +365 -0
- docvortex/analyzers/native/html/selector.py +421 -0
- docvortex/analyzers/native/models.py +209 -0
- docvortex/analyzers/native/ofd/__init__.py +14 -0
- docvortex/analyzers/native/ofd/constants.py +59 -0
- docvortex/analyzers/native/ofd/converter.py +31 -0
- docvortex/analyzers/native/ofd/errors.py +16 -0
- docvortex/analyzers/native/ofd/geometry.py +215 -0
- docvortex/analyzers/native/ofd/images.py +124 -0
- docvortex/analyzers/native/ofd/metadata.py +53 -0
- docvortex/analyzers/native/ofd/models.py +170 -0
- docvortex/analyzers/native/ofd/package.py +346 -0
- docvortex/analyzers/native/ofd/path.py +201 -0
- docvortex/analyzers/native/ofd/reading_order.py +293 -0
- docvortex/analyzers/native/ofd/resources.py +122 -0
- docvortex/analyzers/native/ofd/scene.py +347 -0
- docvortex/analyzers/native/ofd/table.py +240 -0
- docvortex/analyzers/native/ofd/text.py +521 -0
- docvortex/analyzers/native/office/__init__.py +3 -0
- docvortex/analyzers/native/office/doc/__init__.py +3 -0
- docvortex/analyzers/native/office/doc/bookmarks.py +81 -0
- docvortex/analyzers/native/office/doc/doc_converter.py +573 -0
- docvortex/analyzers/native/office/doc/fib.py +261 -0
- docvortex/analyzers/native/office/doc/fields.py +101 -0
- docvortex/analyzers/native/office/doc/formatting.py +171 -0
- docvortex/analyzers/native/office/doc/images.py +162 -0
- docvortex/analyzers/native/office/doc/lists.py +356 -0
- docvortex/analyzers/native/office/doc/models.py +164 -0
- docvortex/analyzers/native/office/doc/parser.py +851 -0
- docvortex/analyzers/native/office/doc/pieces.py +256 -0
- docvortex/analyzers/native/office/doc/records.py +40 -0
- docvortex/analyzers/native/office/doc/sprm.py +269 -0
- docvortex/analyzers/native/office/doc/styles.py +215 -0
- docvortex/analyzers/native/office/docx/__init__.py +3 -0
- docvortex/analyzers/native/office/docx/context.py +71 -0
- docvortex/analyzers/native/office/docx/docx_converter.py +614 -0
- docvortex/analyzers/native/office/docx/equationxml.py +119 -0
- docvortex/analyzers/native/office/docx/fields.py +816 -0
- docvortex/analyzers/native/office/docx/formatting_types.py +23 -0
- docvortex/analyzers/native/office/docx/main.py +50 -0
- docvortex/analyzers/native/office/docx/numbering.py +491 -0
- docvortex/analyzers/native/office/docx/office_xml.py +57 -0
- docvortex/analyzers/native/office/docx/package_normalizer.py +248 -0
- docvortex/analyzers/native/office/docx/resources.py +526 -0
- docvortex/analyzers/native/office/docx/styles.py +693 -0
- docvortex/analyzers/native/office/docx/tables.py +515 -0
- docvortex/analyzers/native/office/equation/__init__.py +3 -0
- docvortex/analyzers/native/office/equation/image.py +470 -0
- docvortex/analyzers/native/office/equation/latex_dict.py +324 -0
- docvortex/analyzers/native/office/equation/mtef.py +885 -0
- docvortex/analyzers/native/office/equation/mtef_v5.py +941 -0
- docvortex/analyzers/native/office/equation/omml.py +561 -0
- docvortex/analyzers/native/office/equation/ooxml.py +62 -0
- docvortex/analyzers/native/office/errors.py +33 -0
- docvortex/analyzers/native/office/image.py +307 -0
- docvortex/analyzers/native/office/legacy/__init__.py +3 -0
- docvortex/analyzers/native/office/legacy/binary.py +43 -0
- docvortex/analyzers/native/office/legacy/officeart.py +362 -0
- docvortex/analyzers/native/office/legacy/ole.py +110 -0
- docvortex/analyzers/native/office/limits.py +12 -0
- docvortex/analyzers/native/office/odf/__init__.py +3 -0
- docvortex/analyzers/native/office/odf/chart.py +76 -0
- docvortex/analyzers/native/office/odf/constants.py +70 -0
- docvortex/analyzers/native/office/odf/converters.py +403 -0
- docvortex/analyzers/native/office/odf/errors.py +18 -0
- docvortex/analyzers/native/office/odf/metadata.py +91 -0
- docvortex/analyzers/native/office/odf/models.py +176 -0
- docvortex/analyzers/native/office/odf/package.py +270 -0
- docvortex/analyzers/native/office/odf/styles.py +329 -0
- docvortex/analyzers/native/office/odf/table.py +469 -0
- docvortex/analyzers/native/office/odf/text.py +1002 -0
- docvortex/analyzers/native/office/ooxml_chart.py +1016 -0
- docvortex/analyzers/native/office/opc.py +38 -0
- docvortex/analyzers/native/office/ppt/__init__.py +3 -0
- docvortex/analyzers/native/office/ppt/models.py +118 -0
- docvortex/analyzers/native/office/ppt/parser.py +1895 -0
- docvortex/analyzers/native/office/ppt/ppt_converter.py +292 -0
- docvortex/analyzers/native/office/ppt/records.py +131 -0
- docvortex/analyzers/native/office/ppt/style_text.py +247 -0
- docvortex/analyzers/native/office/pptx/__init__.py +3 -0
- docvortex/analyzers/native/office/pptx/context.py +113 -0
- docvortex/analyzers/native/office/pptx/lists.py +558 -0
- docvortex/analyzers/native/office/pptx/main.py +20 -0
- docvortex/analyzers/native/office/pptx/package_normalizer.py +321 -0
- docvortex/analyzers/native/office/pptx/pptx_converter.py +323 -0
- docvortex/analyzers/native/office/pptx/resources.py +329 -0
- docvortex/analyzers/native/office/pptx/shapes.py +393 -0
- docvortex/analyzers/native/office/pptx/text_styles.py +625 -0
- docvortex/analyzers/native/office/pptx/titles.py +178 -0
- docvortex/analyzers/native/office/rich_text.py +420 -0
- docvortex/analyzers/native/office/rtf/__init__.py +3 -0
- docvortex/analyzers/native/office/rtf/converter.py +708 -0
- docvortex/analyzers/native/office/rtf/lexer.py +213 -0
- docvortex/analyzers/native/office/rtf/math.py +339 -0
- docvortex/analyzers/native/office/rtf/models.py +185 -0
- docvortex/analyzers/native/office/rtf/parser.py +1553 -0
- docvortex/analyzers/native/office/spreadsheet/__init__.py +3 -0
- docvortex/analyzers/native/office/spreadsheet/html.py +81 -0
- docvortex/analyzers/native/office/spreadsheet/models.py +79 -0
- docvortex/analyzers/native/office/spreadsheet/projector.py +928 -0
- docvortex/analyzers/native/office/streams.py +18 -0
- docvortex/analyzers/native/office/xls/__init__.py +3 -0
- docvortex/analyzers/native/office/xls/chart.py +132 -0
- docvortex/analyzers/native/office/xls/embedded_chart.py +299 -0
- docvortex/analyzers/native/office/xls/models.py +109 -0
- docvortex/analyzers/native/office/xls/number_format.py +521 -0
- docvortex/analyzers/native/office/xls/parser.py +1145 -0
- docvortex/analyzers/native/office/xls/records.py +201 -0
- docvortex/analyzers/native/office/xls/strings.py +205 -0
- docvortex/analyzers/native/office/xls/xls_converter.py +354 -0
- docvortex/analyzers/native/office/xlsx/__init__.py +3 -0
- docvortex/analyzers/native/office/xlsx/main.py +20 -0
- docvortex/analyzers/native/office/xlsx/ooxml_ole.py +522 -0
- docvortex/analyzers/native/office/xlsx/package_normalizer.py +310 -0
- docvortex/analyzers/native/office/xlsx/xlsx_converter.py +716 -0
- docvortex/analyzers/native/pdf/__init__.py +3 -0
- docvortex/analyzers/native/pdf/auxiliary_text.py +1671 -0
- docvortex/analyzers/native/pdf/char_geometry.py +1662 -0
- docvortex/analyzers/native/pdf/code_blocks.py +535 -0
- docvortex/analyzers/native/pdf/formulas.py +1985 -0
- docvortex/analyzers/native/pdf/geometry.py +281 -0
- docvortex/analyzers/native/pdf/graphics.py +1501 -0
- docvortex/analyzers/native/pdf/index_blocks.py +268 -0
- docvortex/analyzers/native/pdf/inline/__init__.py +3 -0
- docvortex/analyzers/native/pdf/inline/common.py +112 -0
- docvortex/analyzers/native/pdf/inline/detection.py +590 -0
- docvortex/analyzers/native/pdf/inline/matching.py +1185 -0
- docvortex/analyzers/native/pdf/inline/materialize.py +457 -0
- docvortex/analyzers/native/pdf/inline/scripts.py +975 -0
- docvortex/analyzers/native/pdf/inline/types.py +385 -0
- docvortex/analyzers/native/pdf/line_layout.py +1106 -0
- docvortex/analyzers/native/pdf/line_merging.py +1223 -0
- docvortex/analyzers/native/pdf/models.py +246 -0
- docvortex/analyzers/native/pdf/native_text.py +1004 -0
- docvortex/analyzers/native/pdf/pipeline.py +1390 -0
- docvortex/analyzers/native/pdf/script_geometry.py +636 -0
- docvortex/analyzers/native/pdf/shared.py +30 -0
- docvortex/analyzers/native/pdf/spatial_text.py +383 -0
- docvortex/analyzers/native/pdf/table_annotations.py +446 -0
- docvortex/analyzers/native/pdf/table_constants.py +48 -0
- docvortex/analyzers/native/pdf/table_detection.py +227 -0
- docvortex/analyzers/native/pdf/table_filled_grid.py +218 -0
- docvortex/analyzers/native/pdf/table_geometry.py +40 -0
- docvortex/analyzers/native/pdf/table_materialization.py +444 -0
- docvortex/analyzers/native/pdf/table_recovery/__init__.py +15 -0
- docvortex/analyzers/native/pdf/table_recovery/candidate.py +356 -0
- docvortex/analyzers/native/pdf/table_recovery/contracts.py +154 -0
- docvortex/analyzers/native/pdf/table_recovery/engine.py +609 -0
- docvortex/analyzers/native/pdf/table_recovery/geometry.py +164 -0
- docvortex/analyzers/native/pdf/table_recovery/sparse_common.py +85 -0
- docvortex/analyzers/native/pdf/table_recovery/sparse_hybrid.py +804 -0
- docvortex/analyzers/native/pdf/table_recovery/sparse_multiline.py +1122 -0
- docvortex/analyzers/native/pdf/table_recovery/text.py +414 -0
- docvortex/analyzers/native/pdf/table_recovery/text_grid.py +618 -0
- docvortex/analyzers/native/pdf/table_recovery/vector.py +1931 -0
- docvortex/analyzers/native/pdf/table_rows.py +34 -0
- docvortex/analyzers/native/pdf/table_rules.py +1129 -0
- docvortex/analyzers/native/pdf/table_text_styles.py +283 -0
- docvortex/analyzers/native/pdf/tables.py +147 -0
- docvortex/analyzers/native/pdf/text_assembly/__init__.py +3 -0
- docvortex/analyzers/native/pdf/text_assembly/annotations.py +581 -0
- docvortex/analyzers/native/pdf/text_assembly/assembly.py +292 -0
- docvortex/analyzers/native/pdf/text_assembly/common.py +477 -0
- docvortex/analyzers/native/pdf/text_assembly/footnotes.py +394 -0
- docvortex/analyzers/native/pdf/text_assembly/merging.py +1274 -0
- docvortex/analyzers/native/pdf/text_assembly/rows.py +692 -0
- docvortex/analyzers/native/pdf/text_blocks.py +82 -0
- docvortex/analyzers/native/pdf/text_styles.py +55 -0
- docvortex/analyzers/native/pdf/title_analysis/__init__.py +3 -0
- docvortex/analyzers/native/pdf/title_analysis/body_profile.py +215 -0
- docvortex/analyzers/native/pdf/title_analysis/common.py +117 -0
- docvortex/analyzers/native/pdf/title_analysis/document_profile.py +164 -0
- docvortex/analyzers/native/pdf/title_analysis/lane_titles.py +758 -0
- docvortex/analyzers/native/pdf/title_analysis/page_titles.py +1024 -0
- docvortex/analyzers/native/pdf/title_analysis/prototype.py +194 -0
- docvortex/analyzers/native/pdf/title_analysis/structural.py +1081 -0
- docvortex/analyzers/native/pdf/titles.py +75 -0
- docvortex/analyzers/native/pdf/typography.py +19 -0
- docvortex/analyzers/native/pdf/visual_annotations.py +1262 -0
- docvortex/api.py +180 -0
- docvortex/assets/__init__.py +5 -0
- docvortex/assets/store.py +51 -0
- docvortex/cli.py +54 -0
- docvortex/codecs/__init__.py +3 -0
- docvortex/codecs/html/__init__.py +22 -0
- docvortex/codecs/html/contracts.py +236 -0
- docvortex/codecs/html/materializer.py +331 -0
- docvortex/codecs/html/parser.py +763 -0
- docvortex/codecs/html/resources.py +34 -0
- docvortex/codecs/json.py +17 -0
- docvortex/content/__init__.py +5 -0
- docvortex/content/inline.py +248 -0
- docvortex/content/markup/__init__.py +44 -0
- docvortex/content/markup/anchors.py +188 -0
- docvortex/content/markup/formula.py +280 -0
- docvortex/content/markup/projector.py +1237 -0
- docvortex/content/markup/styles.py +327 -0
- docvortex/content/mathml.py +167 -0
- docvortex/content/normalization.py +188 -0
- docvortex/content/spans.py +183 -0
- docvortex/content/table/__init__.py +18 -0
- docvortex/content/table/blocks.py +152 -0
- docvortex/content/table/content.py +425 -0
- docvortex/content/table/document.py +104 -0
- docvortex/content/table/html.py +399 -0
- docvortex/content/table/models.py +76 -0
- docvortex/content/table/rules.py +42 -0
- docvortex/content/table/structure.py +221 -0
- docvortex/content/tree.py +5 -0
- docvortex/document/__init__.py +3 -0
- docvortex/document/contracts.py +22 -0
- docvortex/document/detection.py +389 -0
- docvortex/document/filetypes.py +175 -0
- docvortex/document/page_range.py +167 -0
- docvortex/document/pdf/__init__.py +19 -0
- docvortex/document/pdf/classify.py +1138 -0
- docvortex/document/pdf/constants.py +132 -0
- docvortex/document/pdf/diagnostics.py +350 -0
- docvortex/document/pdf/document.py +582 -0
- docvortex/document/pdf/font_runtime.py +335 -0
- docvortex/document/pdf/geometry.py +31 -0
- docvortex/document/pdf/images.py +654 -0
- docvortex/document/pdf/native_annotations.py +367 -0
- docvortex/document/pdf/native_contracts.py +169 -0
- docvortex/document/pdf/native_coordinates.py +216 -0
- docvortex/document/pdf/native_lifecycle.py +16 -0
- docvortex/document/pdf/native_objects.py +902 -0
- docvortex/document/pdf/native_text_geometry.py +315 -0
- docvortex/document/pdf/pdfium.py +325 -0
- docvortex/document/pdf/raster.py +46 -0
- docvortex/document/pdf/text/__init__.py +62 -0
- docvortex/document/pdf/text/contracts.py +211 -0
- docvortex/document/pdf/text/extract.py +165 -0
- docvortex/document/pdf/text/geometry.py +16 -0
- docvortex/document/pdf/text/groups.py +162 -0
- docvortex/document/pdf/visual_geometry.py +201 -0
- docvortex/document/pdf/visuals.py +343 -0
- docvortex/document/source.py +92 -0
- docvortex/errors.py +21 -0
- docvortex/export/__init__.py +3 -0
- docvortex/export/bundle.py +98 -0
- docvortex/export/files.py +66 -0
- docvortex/export/middle.py +208 -0
- docvortex/foundation/__init__.py +3 -0
- docvortex/foundation/geometry.py +125 -0
- docvortex/foundation/hyperlink.py +65 -0
- docvortex/foundation/image.py +48 -0
- docvortex/foundation/image_encoding.py +30 -0
- docvortex/foundation/image_payload.py +280 -0
- docvortex/foundation/language.py +92 -0
- docvortex/foundation/platform.py +38 -0
- docvortex/foundation/text.py +153 -0
- docvortex/foundation/type_identity.py +20 -0
- docvortex/foundation/xml_names.py +20 -0
- docvortex/options.py +30 -0
- docvortex/postprocess/__init__.py +3 -0
- docvortex/postprocess/content.py +53 -0
- docvortex/postprocess/document.py +19 -0
- docvortex/postprocess/lists.py +236 -0
- docvortex/postprocess/page_blocks.py +214 -0
- docvortex/postprocess/pages.py +95 -0
- docvortex/postprocess/paragraphs.py +580 -0
- docvortex/postprocess/visual.py +715 -0
- docvortex/render/__init__.py +48 -0
- docvortex/render/_internal/__init__.py +3 -0
- docvortex/render/_internal/common/__init__.py +3 -0
- docvortex/render/_internal/common/context.py +43 -0
- docvortex/render/_internal/common/html_table.py +178 -0
- docvortex/render/_internal/common/index.py +33 -0
- docvortex/render/_internal/common/list_items.py +158 -0
- docvortex/render/_internal/common/planner.py +140 -0
- docvortex/render/_internal/docx/__init__.py +3 -0
- docvortex/render/_internal/docx/assets.py +202 -0
- docvortex/render/_internal/docx/inline.py +434 -0
- docvortex/render/_internal/docx/math.py +220 -0
- docvortex/render/_internal/docx/renderer.py +905 -0
- docvortex/render/_internal/docx/styles.py +195 -0
- docvortex/render/_internal/docx/table.py +442 -0
- docvortex/render/_internal/epub/__init__.py +5 -0
- docvortex/render/_internal/epub/assets.py +173 -0
- docvortex/render/_internal/epub/package.py +249 -0
- docvortex/render/_internal/epub/renderer.py +1156 -0
- docvortex/render/_internal/html/__init__.py +3 -0
- docvortex/render/_internal/html/inline.py +346 -0
- docvortex/render/_internal/html/renderer.py +1041 -0
- docvortex/render/_internal/html/sanitizer.py +478 -0
- docvortex/render/_internal/html/table.py +121 -0
- docvortex/render/_internal/latex/__init__.py +1 -0
- docvortex/render/_internal/latex/assets.py +85 -0
- docvortex/render/_internal/latex/inline.py +145 -0
- docvortex/render/_internal/latex/renderer.py +506 -0
- docvortex/render/_internal/latex/table.py +347 -0
- docvortex/render/_internal/markdown/__init__.py +3 -0
- docvortex/render/_internal/markdown/assets.py +78 -0
- docvortex/render/_internal/markdown/blocks.py +635 -0
- docvortex/render/_internal/markdown/escaping.py +51 -0
- docvortex/render/_internal/markdown/inline.py +260 -0
- docvortex/render/_internal/markdown/renderer.py +93 -0
- docvortex/render/_internal/markdown/table.py +281 -0
- docvortex/render/_internal/pdf/__init__.py +3 -0
- docvortex/render/_internal/pdf/assets.py +197 -0
- docvortex/render/_internal/pdf/formula.py +417 -0
- docvortex/render/_internal/pdf/inline.py +343 -0
- docvortex/render/_internal/pdf/renderer.py +734 -0
- docvortex/render/_internal/pdf/styles.py +206 -0
- docvortex/render/_internal/pdf/table.py +272 -0
- docvortex/render/_internal/structured_content/__init__.py +3 -0
- docvortex/render/_internal/structured_content/renderer.py +193 -0
- docvortex/render/api.py +199 -0
- docvortex/render/contracts.py +205 -0
- docvortex/render/docx.py +38 -0
- docvortex/render/epub.py +35 -0
- docvortex/render/fragments.py +70 -0
- docvortex/render/html.py +29 -0
- docvortex/render/latex.py +24 -0
- docvortex/render/markdown.py +50 -0
- docvortex/render/pdf.py +25 -0
- docvortex/render/structured_content.py +23 -0
- docvortex/resources/epub/docvortex.css +91 -0
- docvortex/resources/fasttext-langdetect/lid.176.ftz +0 -0
- docvortex/resources/fonts/DroidSansFallbackFull.ttf +0 -0
- docvortex/resources/fonts/NOTICE +190 -0
- docvortex/resources/fonts/manifest.json +9 -0
- docvortex/resources/html/docvortex.css +601 -0
- docvortex/resources/html/docvortex.min.css +1 -0
- docvortex/result.py +91 -0
- docvortex/schema.py +1141 -0
- docvortex/version.py +3 -0
- docvortex-0.2.1.dist-info/METADATA +193 -0
- docvortex-0.2.1.dist-info/RECORD +364 -0
- docvortex-0.2.1.dist-info/WHEEL +5 -0
- docvortex-0.2.1.dist-info/entry_points.txt +2 -0
- docvortex-0.2.1.dist-info/licenses/LICENSE.md +21 -0
- 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
|
+
]
|