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,816 @@
1
+ """DOCX 字段与目录处理;共享当前 Converter 的单文档状态。"""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Iterator, Optional, Union
6
+ from docx.oxml.xmlchemy import BaseOxmlElement
7
+ from docx.text.hyperlink import Hyperlink
8
+ from docx.text.paragraph import Paragraph
9
+ from docx.text.run import Run
10
+ from loguru import logger
11
+ from .....schema import BlockType
12
+ from .formatting_types import Formatting
13
+
14
+ from .context import _DocxConstants, _DocxComplexFieldFrame, _ParagraphElement, _ParagraphHyperlink
15
+
16
+
17
+ class _DocxFields:
18
+ """集中维护字段与目录,不自行创建文档或持有跨文档缓存。"""
19
+
20
+ def _collect_toc_anchor_set(self) -> set[str]:
21
+ """从真实超链接和复杂域中收集整份文档的 TOC bookmark 目标。"""
22
+ anchor_attr = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}anchor"
23
+ anchors: set[str] = set()
24
+ for hl in self.docx_obj.element.body.findall(".//w:hyperlink", namespaces=_DocxConstants._BLIP_NAMESPACES):
25
+ anchor = hl.get(anchor_attr, "").strip()
26
+ if anchor and anchor.startswith("_Toc"):
27
+ anchors.add(anchor)
28
+ for paragraph in self.docx_obj.element.body.findall(
29
+ ".//w:p",
30
+ namespaces=_DocxConstants._BLIP_NAMESPACES,
31
+ ):
32
+ for instruction in self._complex_field_instructions(paragraph):
33
+ target, is_internal = self._complex_field_hyperlink_target(instruction)
34
+ anchor = target.removeprefix("#") if target and is_internal else ""
35
+ if anchor.startswith("_Toc"):
36
+ anchors.add(anchor)
37
+ return anchors
38
+
39
+ @classmethod
40
+ def _paragraph_bookmark_names(
41
+ cls,
42
+ paragraph_element: BaseOxmlElement,
43
+ ) -> list[str]:
44
+ """按文档顺序返回段落内可公开的 bookmark 名称,并排除 Word 导航标记。"""
45
+
46
+ bookmark_name_attr = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}name"
47
+ names: list[str] = []
48
+ for bookmark in paragraph_element.findall(
49
+ ".//w:bookmarkStart",
50
+ namespaces=cls._BLIP_NAMESPACES,
51
+ ):
52
+ name = bookmark.get(bookmark_name_attr, "").strip()
53
+ if name and not name.startswith("_GoBack"):
54
+ names.append(name)
55
+ return names
56
+
57
+ def _collect_toc_anchor_aliases(
58
+ self,
59
+ referenced_anchors: set[str],
60
+ ) -> dict[str, str]:
61
+ """把同一段落的多个 TOC bookmark 收敛到一个 Middle JSON canonical anchor。"""
62
+
63
+ aliases: dict[str, str] = {}
64
+ for paragraph in self.docx_obj.element.body.findall(
65
+ ".//w:p",
66
+ namespaces=_DocxConstants._BLIP_NAMESPACES,
67
+ ):
68
+ toc_names = [name for name in self._paragraph_bookmark_names(paragraph) if name.startswith("_Toc")]
69
+ if not toc_names:
70
+ continue
71
+ referenced_names = [name for name in toc_names if name in referenced_anchors]
72
+ canonical = referenced_names[0] if referenced_names else toc_names[0]
73
+ for name in toc_names:
74
+ aliases.setdefault(name, canonical)
75
+ return aliases
76
+
77
+ def _canonical_toc_anchor(self, anchor: str) -> str:
78
+ """返回 bookmark alias 对应的唯一公开 anchor,未知名称保持原值。"""
79
+
80
+ return self.toc_anchor_aliases.get(anchor, anchor)
81
+
82
+ @staticmethod
83
+ def _complex_field_hyperlink_target(instruction: str) -> tuple[str | None, bool]:
84
+ """从复杂字段指令中提取外部 URL 或内部 bookmark fragment。"""
85
+ external_match = re.search(r'\bHYPERLINK\s+"([^"]+)"', instruction, re.IGNORECASE)
86
+ bookmark_match = re.search(r'\\l\s+"([^"]+)"', instruction, re.IGNORECASE)
87
+ address = external_match.group(1).strip() if external_match else ""
88
+ bookmark = bookmark_match.group(1).strip() if bookmark_match else ""
89
+ if address:
90
+ return (f"{address}#{bookmark}" if bookmark else address), False
91
+ if bookmark:
92
+ return f"#{bookmark}", True
93
+ return None, False
94
+
95
+ @classmethod
96
+ def _complex_field_instructions(
97
+ cls,
98
+ paragraph_element: BaseOxmlElement,
99
+ ) -> list[str]:
100
+ """按字段边界与嵌套顺序合并段落中被拆分的复杂字段指令。"""
101
+
102
+ word_namespace = cls._BLIP_NAMESPACES["w"]
103
+ field_char_tag = f"{{{word_namespace}}}fldChar"
104
+ instruction_tag = f"{{{word_namespace}}}instrText"
105
+ field_type_attr = f"{{{word_namespace}}}fldCharType"
106
+ field_stack: list[_DocxComplexFieldFrame] = []
107
+ instructions: list[str] = []
108
+
109
+ def append_instruction(frame: _DocxComplexFieldFrame) -> None:
110
+ """把一个字段已累计的非空指令追加到输出。"""
111
+
112
+ instruction = "".join(frame.instruction_parts).strip()
113
+ if instruction:
114
+ instructions.append(instruction)
115
+
116
+ for element in paragraph_element.iter():
117
+ if element.tag == field_char_tag:
118
+ field_type = element.get(field_type_attr)
119
+ if field_type == "begin":
120
+ field_stack.append(_DocxComplexFieldFrame())
121
+ elif field_type == "separate" and field_stack:
122
+ frame = field_stack[-1]
123
+ if frame.phase == "instr":
124
+ append_instruction(frame)
125
+ frame.phase = "result"
126
+ elif field_type == "end" and field_stack:
127
+ frame = field_stack.pop()
128
+ if frame.phase == "instr":
129
+ append_instruction(frame)
130
+ continue
131
+ if element.tag != instruction_tag:
132
+ continue
133
+ text = element.text or ""
134
+ if field_stack and field_stack[-1].phase == "instr":
135
+ field_stack[-1].instruction_parts.append(text)
136
+ elif text.strip():
137
+ # 兼容缺少 fldChar 包裹、但过去可被逐节点解析的非规范指令。
138
+ instructions.append(text.strip())
139
+
140
+ for frame in field_stack:
141
+ if frame.phase == "instr":
142
+ append_instruction(frame)
143
+ return instructions
144
+
145
+ @staticmethod
146
+ def _python_docx_hyperlink_target(hyperlink: Hyperlink) -> _ParagraphHyperlink:
147
+ """把 python-docx Hyperlink 的地址或 fragment 转换为行内目标。"""
148
+ address = hyperlink.address
149
+ fragment = hyperlink.fragment
150
+ if address and fragment:
151
+ return f"{address}#{fragment}"
152
+ if address and "://" in address:
153
+ return address
154
+ if address:
155
+ return Path(address)
156
+ if fragment:
157
+ return f"#{fragment}"
158
+ return Path(".")
159
+
160
+ def _resolve_complex_field_elements(
161
+ self,
162
+ frame: _DocxComplexFieldFrame,
163
+ *,
164
+ suppress_internal_links: bool,
165
+ ) -> list[_ParagraphElement]:
166
+ """闭合复杂字段,并把字段结果绑定到解析出的超链接目标。"""
167
+ target, is_internal = self._complex_field_hyperlink_target("".join(frame.instruction_parts))
168
+ if target is None or (is_internal and suppress_internal_links):
169
+ return frame.result_elements
170
+ return [(text, format_obj, existing_target or target) for text, format_obj, existing_target in frame.result_elements]
171
+
172
+ def _flatten_paragraph_elements(
173
+ self,
174
+ paragraph: Paragraph,
175
+ inner_contents: list[Union[Run, Hyperlink]],
176
+ ) -> list[_ParagraphElement]:
177
+ """按文档顺序展开普通 run、真实超链接与可嵌套复杂字段。"""
178
+ elements: list[_ParagraphElement] = []
179
+ field_stack: list[_DocxComplexFieldFrame] = []
180
+ suppress_internal_links = self._get_toc_item_level(paragraph) is not None
181
+ word_namespace = _DocxConstants._BLIP_NAMESPACES["w"]
182
+
183
+ for content_index, content in enumerate(inner_contents):
184
+ if isinstance(content, Hyperlink):
185
+ hyperlink_target = self._python_docx_hyperlink_target(content)
186
+ if suppress_internal_links and isinstance(hyperlink_target, str) and hyperlink_target.startswith("#"):
187
+ hyperlink_target = None
188
+ hyperlink_elements: list[_ParagraphElement] = []
189
+ for hyperlink_run in content.runs:
190
+ if self._is_hidden_run(hyperlink_run):
191
+ continue
192
+ text = hyperlink_run.text or ""
193
+ format_obj = self._normalize_format_for_text(
194
+ self._get_format_from_run(hyperlink_run),
195
+ text,
196
+ preserve_blank_non_visible_style=True,
197
+ )
198
+ if text != "" or self._has_visible_style(format_obj):
199
+ hyperlink_elements.append((text, format_obj, hyperlink_target))
200
+ if field_stack and field_stack[-1].phase == "result":
201
+ field_stack[-1].result_elements.extend(hyperlink_elements)
202
+ else:
203
+ elements.extend(hyperlink_elements)
204
+ continue
205
+
206
+ if not isinstance(content, Run):
207
+ continue
208
+
209
+ field_char = content._element.find(f"{{{word_namespace}}}fldChar")
210
+ if field_char is not None:
211
+ field_type = field_char.get(f"{{{word_namespace}}}fldCharType")
212
+ if field_type == "begin":
213
+ field_stack.append(_DocxComplexFieldFrame())
214
+ elif field_type == "separate" and field_stack:
215
+ field_stack[-1].phase = "result"
216
+ elif field_type == "end" and field_stack:
217
+ frame = field_stack.pop()
218
+ resolved = self._resolve_complex_field_elements(
219
+ frame,
220
+ suppress_internal_links=suppress_internal_links,
221
+ )
222
+ if field_stack and field_stack[-1].phase == "result":
223
+ field_stack[-1].result_elements.extend(resolved)
224
+ else:
225
+ elements.extend(resolved)
226
+ continue
227
+
228
+ instruction = content._element.find(f"{{{word_namespace}}}instrText")
229
+ if instruction is not None and field_stack and field_stack[-1].phase == "instr":
230
+ if instruction.text:
231
+ field_stack[-1].instruction_parts.append(instruction.text)
232
+ continue
233
+
234
+ text = content.text or ""
235
+ raw_format = self._get_format_from_run(content)
236
+ preserve_blank_non_visible_style = self._should_preserve_blank_non_visible_style(
237
+ inner_contents,
238
+ content_index,
239
+ text,
240
+ raw_format,
241
+ )
242
+ format_obj = self._normalize_format_for_text(
243
+ raw_format,
244
+ text,
245
+ preserve_blank_non_visible_style=preserve_blank_non_visible_style,
246
+ )
247
+ element = (text, format_obj, None)
248
+ if field_stack:
249
+ if field_stack[-1].phase == "result":
250
+ field_stack[-1].result_elements.append(element)
251
+ continue
252
+ elements.append(element)
253
+
254
+ while field_stack:
255
+ frame = field_stack.pop()
256
+ resolved = self._resolve_complex_field_elements(
257
+ frame,
258
+ suppress_internal_links=suppress_internal_links,
259
+ )
260
+ if field_stack and field_stack[-1].phase == "result":
261
+ field_stack[-1].result_elements.extend(resolved)
262
+ else:
263
+ elements.extend(resolved)
264
+ return elements
265
+
266
+ def _get_paragraph_elements(self, paragraph: Paragraph) -> list[_ParagraphElement]:
267
+ """
268
+ 提取段落元素及其格式和超链接信息。
269
+
270
+ Args:
271
+ paragraph: 段落对象
272
+
273
+ Returns:
274
+ list[_ParagraphElement]:
275
+ 段落元素列表,每个元素包含文本、格式和超链接信息
276
+ """
277
+
278
+ inner_contents = list(self._iter_paragraph_inner_content(paragraph))
279
+ paragraph_text = self._get_paragraph_text_from_contents(inner_contents)
280
+
281
+ # 目前保留空段落以保持向后兼容性:
282
+ if paragraph_text.strip() == "":
283
+ # 检查是否存在带可见样式(下划线或删除线)的空白文本 run。
284
+ # 有可见样式的空白文本(如带下划线的空格)在视觉上是可见的,应予保留,
285
+ # 因此跳过提前返回,交由后续完整 run 处理流程处理。
286
+ has_visible_style_run = any(
287
+ isinstance(c, Run) and c.text and self._has_visible_style(self._get_format_from_run(c)) for c in inner_contents
288
+ )
289
+ if not has_visible_style_run:
290
+ return [("", None, None)]
291
+
292
+ paragraph_elements: list[_ParagraphElement] = []
293
+ group_text = ""
294
+ previous_format: Optional[Formatting] = None
295
+
296
+ # 遍历已经展开的普通 run、超链接与复杂字段结果,并按格式分组。
297
+ flattened_elements = self._flatten_paragraph_elements(paragraph, inner_contents)
298
+ for text, format_obj, hyperlink in flattened_elements:
299
+ # 当新 run 有可见内容(非空或带可见样式的空白)且格式变化时触发分组
300
+ has_visible_content = len(text.strip()) > 0 or self._has_visible_style(format_obj)
301
+ is_blank_text = bool(text) and not text.strip()
302
+ format_changed = format_obj != previous_format
303
+ has_visible_boundary = self._has_visible_style(previous_format) or self._has_visible_style(format_obj)
304
+ should_split_blank_boundary = is_blank_text and bool(group_text) and format_changed and has_visible_boundary
305
+ if (has_visible_content and format_changed) or should_split_blank_boundary or (hyperlink is not None):
306
+ # 前一组有实质内容(非空或带可见样式的空白)时才保存
307
+ preserve_plain_blank = (
308
+ bool(group_text)
309
+ and not group_text.strip()
310
+ and (self._has_visible_style(previous_format) or self._has_visible_style(format_obj))
311
+ )
312
+ prev_has_visible = self._should_keep_group_text(
313
+ group_text,
314
+ previous_format,
315
+ preserve_plain_blank=preserve_plain_blank,
316
+ )
317
+ if prev_has_visible:
318
+ paragraph_elements.append((group_text, previous_format, None))
319
+ group_text = ""
320
+
321
+ # 如果有超链接,则立即添加
322
+ if hyperlink is not None:
323
+ self._append_paragraph_element(paragraph_elements, text, format_obj, hyperlink)
324
+ text = ""
325
+ else:
326
+ previous_format = format_obj
327
+
328
+ group_text += text
329
+
330
+ # 格式化最后一个组
331
+ # 注意:使用 previous_format(当前累积组的格式),而非 format(最后一次循环迭代的格式)。
332
+ # 最后一次迭代可能是无样式的空 run,若使用 format 会导致样式丢失。
333
+ last_has_visible = self._should_keep_group_text(
334
+ group_text,
335
+ previous_format,
336
+ )
337
+ if last_has_visible:
338
+ paragraph_elements.append((group_text, previous_format, None))
339
+
340
+ return self._normalize_hyperlink_group_boundaries(paragraph_elements)
341
+
342
+ def _iter_paragraph_inner_content(
343
+ self,
344
+ paragraph: Paragraph,
345
+ container: Optional[BaseOxmlElement] = None,
346
+ ) -> Iterator[Union[Run, Hyperlink]]:
347
+ """Yield visible paragraph inline containers in document order.
348
+
349
+ python-docx only walks direct ``w:r`` and ``w:hyperlink`` children of ``w:p``.
350
+ Inline ``w:sdt`` content controls are skipped entirely, which drops their text
351
+ from both ``paragraph.text`` and ``paragraph.iter_inner_content()``. This walker
352
+ treats ``w:sdt`` and a few transparent wrapper nodes as pass-through containers
353
+ and reuses the existing Run/Hyperlink wrappers for the actual visible content.
354
+ """
355
+ if container is None:
356
+ container = paragraph._element
357
+
358
+ _W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
359
+
360
+ for child in container:
361
+ tag_name = self._local_name(child)
362
+ if tag_name is None:
363
+ continue
364
+
365
+ if tag_name == "r":
366
+ yield Run(child, paragraph)
367
+ elif tag_name == "hyperlink":
368
+ yield Hyperlink(child, paragraph)
369
+ elif tag_name == "sdt":
370
+ sdt_content = child.find(f"{{{_W_NS}}}sdtContent")
371
+ if sdt_content is not None:
372
+ yield from self._iter_paragraph_inner_content(paragraph, sdt_content)
373
+ elif tag_name in self._PARAGRAPH_TRANSPARENT_INLINE_CONTAINERS:
374
+ yield from self._iter_paragraph_inner_content(paragraph, child)
375
+
376
+ def _is_toc_sdt(self, element: BaseOxmlElement) -> bool:
377
+ """
378
+ 检测SDT元素是否为目录(Table of Contents)。
379
+
380
+ 检测策略:
381
+ 1. 检查 w:sdtPr 中的 docPartGallery 或 tag 元素
382
+ 2. 回退到检查内容中的段落样式是否为 "TOC N" 格式
383
+
384
+ Args:
385
+ element: SDT XML元素
386
+
387
+ Returns:
388
+ bool: 如果是目录SDT返回 True,否则返回 False
389
+ """
390
+ # 方法1: 检查 w:sdtPr 中的 docPartGallery
391
+ sdt_pr = element.find("w:sdtPr", namespaces=_DocxConstants._BLIP_NAMESPACES)
392
+ if sdt_pr is not None:
393
+ doc_part_gallery = sdt_pr.find(".//w:docPartGallery", namespaces=_DocxConstants._BLIP_NAMESPACES)
394
+ if doc_part_gallery is not None:
395
+ val = doc_part_gallery.get(self.XML_KEY, "")
396
+ if "Table of Contents" in val or "toc" in val.lower():
397
+ return True
398
+
399
+ # 检查 tag 元素的值
400
+ tag_elem = sdt_pr.find("w:tag", namespaces=_DocxConstants._BLIP_NAMESPACES)
401
+ if tag_elem is not None:
402
+ val = tag_elem.get(self.XML_KEY, "").lower().replace(" ", "")
403
+ if "toc" in val or "contents" in val or "tableofcontents" in val:
404
+ return True
405
+
406
+ # 方法2: 检查内容段落的样式是否为 "TOC N" 格式
407
+ sdt_content = element.find("w:sdtContent", namespaces=_DocxConstants._BLIP_NAMESPACES)
408
+ if sdt_content is not None:
409
+ paragraphs = sdt_content.findall("w:p", namespaces=_DocxConstants._BLIP_NAMESPACES)
410
+ for p in paragraphs[:5]: # 只检查前5个段落即可判断
411
+ try:
412
+ p_obj = Paragraph(p, self.docx_obj)
413
+ paragraph_style = self._get_paragraph_style(p_obj)
414
+ if paragraph_style and paragraph_style.name:
415
+ style_name = paragraph_style.name
416
+ if re.match(r"^TOC\s*\d+$", style_name, re.IGNORECASE) or re.match(r"^目录\s*\d+$", style_name):
417
+ return True
418
+ except Exception:
419
+ continue
420
+
421
+ return False
422
+
423
+ def _get_toc_item_level(self, paragraph: Paragraph) -> Optional[int]:
424
+ """
425
+ 从段落样式中获取目录项的层级(0-based)。
426
+
427
+ "TOC 1" -> 0
428
+ "TOC 2" -> 1
429
+ "目录 1" -> 0
430
+
431
+ Args:
432
+ paragraph: 段落对象
433
+
434
+ Returns:
435
+ Optional[int]: 层级(0-based),如果不是目录样式则返回 None
436
+ """
437
+ paragraph_style = self._get_paragraph_style(paragraph)
438
+ if paragraph_style is None:
439
+ return None
440
+ style_name = paragraph_style.name
441
+ if style_name:
442
+ match = re.match(r"^(?:TOC|目录)\s*(\d+)$", style_name, re.IGNORECASE)
443
+ if match:
444
+ level = int(match.group(1))
445
+ return level - 1 # 转换为 0-based
446
+ return None
447
+
448
+ def _is_flat_list_toc(self, items: list[tuple[int, str, list, list, Optional[str]]]) -> bool:
449
+ """
450
+ 检测目录是否为扁平列表(插图清单、列表清单等),
451
+ 这类目录的所有条目应在同一层级,不应嵌套。
452
+
453
+ 策略:检查是否超过 50% 的条目以"图"或"表"开头。
454
+ """
455
+ match_count = 0
456
+ total_count = 0
457
+ for _level, text, _elements, _equations, _anchor in items:
458
+ stripped = text.strip()
459
+ if not stripped:
460
+ continue
461
+ total_count += 1
462
+ if re.match(r"^[图表][\d\s.]", stripped) or re.match(r"^(Figure|Table)\s+\d", stripped, re.IGNORECASE):
463
+ match_count += 1
464
+ if total_count == 0:
465
+ return False
466
+ return match_count / total_count > 0.5
467
+
468
+ def _correct_toc_level_by_text(self, toc_level: int, text: str) -> int:
469
+ """
470
+ 通过文本中的编号深度修正目录项的层级。
471
+
472
+ 仅对 toc_level > 0 的条目进行修正,避免影响顶层章节标题。
473
+ 例如:
474
+ - "1.1 LYSO..." (toc 3 → ilevel=2) → text depth 2 → 返回 1
475
+ - "1.1.1 LYSO..." (toc 3 → ilevel=2) → text depth 3 → 返回 2
476
+ - "本章小结" (toc 1 → ilevel=0) → 返回 0(不修正)
477
+ """
478
+ if toc_level == 0:
479
+ return 0
480
+ stripped = text.strip()
481
+ match = re.match(r"^(\d+(?:\.\d+)+)(?![\d.])", stripped)
482
+ if match:
483
+ parts = match.group(1).split(".")
484
+ # 只用明确的多级章节号把异常偏深的 TOC 样式修浅,避免普通列表编号被提升层级。
485
+ text_level = len(parts) - 1
486
+ if text_level < toc_level:
487
+ return text_level
488
+ return toc_level
489
+
490
+ def _add_index_item(
491
+ self,
492
+ *,
493
+ ilevel: int,
494
+ elements: list,
495
+ text: str = "",
496
+ equations: list = None,
497
+ anchor: Optional[str] = None,
498
+ ) -> None:
499
+ """
500
+ 添加目录项到索引块。
501
+
502
+ 生成的索引结构:
503
+ {
504
+ "type": "index",
505
+ "ilevel": 0,
506
+ "content": [
507
+ {"type": "text", "content": "目录项文本"},
508
+ {"type": "index", "ilevel": 1, "content": [...]},
509
+ ]
510
+ }
511
+
512
+ Args:
513
+ ilevel: 缩进等级(0-based)
514
+ elements: 元素列表
515
+ text: 处理后的文本(包含公式标记)
516
+ equations: 公式列表
517
+ """
518
+ if equations is None:
519
+ equations = []
520
+ if not elements:
521
+ return
522
+
523
+ content_text = self._build_text_with_equations_and_hyperlinks(elements, text, equations)
524
+ content_text = self._normalize_text_block_content(content_text)
525
+ if not content_text:
526
+ return
527
+
528
+ # 情况 1: 首个目录项,创建新的顶层索引块
529
+ if self.pre_index_ilevel == -1:
530
+ index_block = {
531
+ "type": BlockType.INDEX,
532
+ "content": [],
533
+ "ilevel": ilevel,
534
+ }
535
+ self.cur_page.append(index_block)
536
+ self.index_block_stack.append(index_block)
537
+
538
+ index_item = {
539
+ "type": BlockType.TEXT,
540
+ "content": content_text,
541
+ }
542
+ if anchor:
543
+ index_item["anchor"] = anchor
544
+ index_block["content"].append(index_item)
545
+ self.pre_index_ilevel = ilevel
546
+
547
+ # 情况 2: 增加缩进,打开子索引块
548
+ elif self.pre_index_ilevel < ilevel:
549
+ if not self.index_block_stack:
550
+ # 防御异常 TOC 状态:栈为空时按新的目录块恢复,避免单个坏层级阻断解析。
551
+ logger.debug(
552
+ "Recovering DOCX index stack before adding TOC item at level {}",
553
+ ilevel,
554
+ )
555
+ self.pre_index_ilevel = -1
556
+ self._add_index_item(
557
+ ilevel=ilevel,
558
+ elements=elements,
559
+ text=text,
560
+ equations=equations,
561
+ anchor=anchor,
562
+ )
563
+ return
564
+
565
+ child_index_block = {
566
+ "type": BlockType.INDEX,
567
+ "content": [],
568
+ "ilevel": ilevel,
569
+ }
570
+ parent_index_block = self.index_block_stack[-1]
571
+ parent_index_block["content"].append(child_index_block)
572
+ self.index_block_stack.append(child_index_block)
573
+
574
+ index_item = {
575
+ "type": BlockType.TEXT,
576
+ "content": content_text,
577
+ }
578
+ if anchor:
579
+ index_item["anchor"] = anchor
580
+ child_index_block["content"].append(index_item)
581
+ self.pre_index_ilevel = ilevel
582
+
583
+ # 情况 3: 减少缩进,关闭子索引块
584
+ elif ilevel < self.pre_index_ilevel:
585
+ while self.index_block_stack:
586
+ top_block = self.index_block_stack[-1]
587
+ if top_block["ilevel"] == ilevel:
588
+ break
589
+ self.index_block_stack.pop()
590
+ if self.index_block_stack:
591
+ index_block = self.index_block_stack[-1]
592
+ index_item = {
593
+ "type": BlockType.TEXT,
594
+ "content": content_text,
595
+ }
596
+ if anchor:
597
+ index_item["anchor"] = anchor
598
+ index_block["content"].append(index_item)
599
+ self.pre_index_ilevel = ilevel
600
+
601
+ # 情况 4: 同级目录项
602
+ else:
603
+ if self.index_block_stack:
604
+ index_block = self.index_block_stack[-1]
605
+ index_item = {
606
+ "type": BlockType.TEXT,
607
+ "content": content_text,
608
+ }
609
+ if anchor:
610
+ index_item["anchor"] = anchor
611
+ index_block["content"].append(index_item)
612
+
613
+ def _extract_paragraph_bookmark(self, paragraph_element: BaseOxmlElement) -> Optional[str]:
614
+ """Extract a bookmark name from a paragraph, prioritizing TOC bookmarks."""
615
+ names = self._paragraph_bookmark_names(paragraph_element)
616
+ if not names:
617
+ return None
618
+ toc_names = [name for name in names if name.startswith("_Toc")]
619
+ if toc_names:
620
+ # Prefer anchors that are actually referenced by TOC hyperlinks.
621
+ for name in toc_names:
622
+ if name in self.toc_anchor_set:
623
+ return self._canonical_toc_anchor(name)
624
+ return self._canonical_toc_anchor(toc_names[0])
625
+ return names[0]
626
+
627
+ def _extract_toc_target_anchor(self, paragraph_element: BaseOxmlElement) -> Optional[str]:
628
+ """从真实超链接或复杂域中提取 TOC 段落的内部 bookmark。"""
629
+ anchor_attr = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}anchor"
630
+ anchors = []
631
+ for hl in paragraph_element.findall(".//w:hyperlink", namespaces=_DocxConstants._BLIP_NAMESPACES):
632
+ anchor = hl.get(anchor_attr, "").strip()
633
+ if anchor:
634
+ anchors.append(anchor)
635
+ for anchor in anchors:
636
+ if anchor.startswith("_Toc"):
637
+ return self._canonical_toc_anchor(anchor)
638
+ if anchors:
639
+ return anchors[0]
640
+
641
+ field_anchors: list[str] = []
642
+ for instruction in self._complex_field_instructions(
643
+ paragraph_element,
644
+ ):
645
+ target, is_internal = self._complex_field_hyperlink_target(instruction)
646
+ anchor = target.removeprefix("#") if target and is_internal else ""
647
+ if anchor:
648
+ field_anchors.append(anchor)
649
+ for anchor in field_anchors:
650
+ if anchor.startswith("_Toc"):
651
+ return self._canonical_toc_anchor(anchor)
652
+ return field_anchors[0] if field_anchors else None
653
+
654
+ def _handle_plain_toc_paragraph_as_index(
655
+ self,
656
+ *,
657
+ paragraph: Paragraph,
658
+ paragraph_element: BaseOxmlElement,
659
+ paragraph_elements: list,
660
+ text: str,
661
+ equations: list,
662
+ ) -> bool:
663
+ """将未包裹在 SDT 中的普通目录段落转换为 INDEX 项。"""
664
+ toc_level = self._get_toc_item_level(paragraph)
665
+ if toc_level is None:
666
+ return False
667
+ if not text:
668
+ return True
669
+
670
+ target_anchor = self._extract_toc_target_anchor(paragraph_element)
671
+ # 只有已经进入目录序列后才允许无锚点条目,避免误收复用 TOC 样式的封面文本。
672
+ if not target_anchor and self.pre_index_ilevel == -1:
673
+ return False
674
+ if target_anchor and target_anchor.startswith("_Toc"):
675
+ self.toc_anchor_set.add(target_anchor)
676
+
677
+ if self.plain_toc_base_level is None:
678
+ self.plain_toc_base_level = toc_level
679
+ normalized_level = max(0, toc_level - self.plain_toc_base_level)
680
+ corrected_level = self._correct_toc_level_by_text(normalized_level, text)
681
+ self._add_index_item(
682
+ ilevel=corrected_level,
683
+ elements=paragraph_elements,
684
+ text=text,
685
+ equations=equations,
686
+ anchor=target_anchor,
687
+ )
688
+ return True
689
+
690
+ def _handle_sdt_as_index(self, sdt_content: BaseOxmlElement) -> None:
691
+ """
692
+ 处理目录SDT内容,将其转换为层级化的INDEX块。
693
+
694
+ 两阶段处理:
695
+ 1. 收集所有段落及其层级;
696
+ 2. 检测目录类型(常规目录 vs 扁平列表),对层级进行修正后写入索引块。
697
+
698
+ Args:
699
+ sdt_content: w:sdtContent XML元素
700
+ """
701
+ paragraphs = sdt_content.findall(".//w:p", namespaces=_DocxConstants._BLIP_NAMESPACES)
702
+
703
+ # --- 第一阶段:收集所有条目 ---
704
+ toc_items: list[tuple[int, str, list, list, Optional[str]]] = []
705
+ for p in paragraphs:
706
+ try:
707
+ p_obj = Paragraph(p, self.docx_obj)
708
+ paragraph_elements = self._get_paragraph_elements(p_obj)
709
+ text, equations = self._handle_equations_in_text(
710
+ element=p,
711
+ text=p_obj.text,
712
+ part=p_obj.part,
713
+ )
714
+ target_anchor = self._extract_toc_target_anchor(p)
715
+ if target_anchor and target_anchor.startswith("_Toc"):
716
+ self.toc_anchor_set.add(target_anchor)
717
+ if text is None:
718
+ continue
719
+ text = text.strip()
720
+ if not text:
721
+ continue
722
+
723
+ toc_level = self._get_toc_item_level(p_obj)
724
+ if toc_level is None:
725
+ toc_level = 0
726
+
727
+ toc_items.append((toc_level, text, paragraph_elements, equations, target_anchor))
728
+ except Exception as e:
729
+ logger.debug(f"Error collecting TOC paragraph: {e}")
730
+ continue
731
+
732
+ # --- 第二阶段:修正层级并写入索引块 ---
733
+ is_flat = self._is_flat_list_toc(toc_items)
734
+
735
+ # 重置索引状态,开始新的目录块
736
+ self._reset_index_state()
737
+
738
+ for toc_level, text, elements, equations, target_anchor in toc_items:
739
+ if is_flat:
740
+ # 插图/列表清单:强制全部扁平(层级 0)
741
+ corrected_level = 0
742
+ else:
743
+ # 常规目录:依据文本编号深度修正层级,解决 docx 跳级问题
744
+ corrected_level = self._correct_toc_level_by_text(toc_level, text)
745
+
746
+ self._add_index_item(
747
+ ilevel=corrected_level,
748
+ elements=elements,
749
+ text=text,
750
+ equations=equations,
751
+ anchor=target_anchor,
752
+ )
753
+
754
+ # 处理完成后重置索引状态
755
+ self._reset_index_state()
756
+
757
+ def _get_heading_and_level(self, style_label: str) -> tuple[str, Optional[int]]:
758
+ """
759
+ 从样式标签获取标题和层级。
760
+
761
+ Args:
762
+ style_label: 样式标签
763
+
764
+ Returns:
765
+ tuple[str, Optional[int]]: (标签字符串, 层级) 元组
766
+ """
767
+ parts = self._split_text_and_number(style_label)
768
+
769
+ if len(parts) == 2:
770
+ parts.sort()
771
+ label_str: str = ""
772
+ label_level: Optional[int] = 0
773
+ if parts[0].strip().lower() == "heading":
774
+ label_str = "Heading"
775
+ label_level = self._str_to_int(parts[1], None)
776
+ if parts[1].strip().lower() == "heading":
777
+ label_str = "Heading"
778
+ label_level = self._str_to_int(parts[0], None)
779
+ return label_str, label_level
780
+
781
+ return style_label, None
782
+
783
+ def _split_text_and_number(self, input_string: str) -> list[str]:
784
+ """
785
+ 分割字符串中的文本和数字部分。
786
+
787
+ Args:
788
+ input_string: 输入字符串
789
+
790
+ Returns:
791
+ list[str]: 分割后的部分列表
792
+ """
793
+ match = re.match(r"(\D+)(\d+)$|^(\d+)(\D+)", input_string)
794
+ if match:
795
+ parts = list(filter(None, match.groups()))
796
+ return parts
797
+ else:
798
+ return [input_string]
799
+
800
+ def _str_to_int(self, s: Optional[str], default: Optional[int] = 0) -> Optional[int]:
801
+ """
802
+ 将字符串转换为整数。
803
+
804
+ Args:
805
+ s: 要转换的字符串
806
+ default: 默认值,转换失败时返回
807
+
808
+ Returns:
809
+ Optional[int]: 转换后的整数,转换失败时返回默认值
810
+ """
811
+ if s is None:
812
+ return None
813
+ try:
814
+ return int(s)
815
+ except ValueError:
816
+ return default