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,708 @@
1
+ """把 typed RTF 语义文档转换为 DocVortex 单逻辑页 raw model-list。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from html import escape
7
+ from typing import Any, BinaryIO, Iterable
8
+
9
+ from .....schema import BlockType
10
+ from .....content.spans import append_equation_span, extend_inline_spans, inline_span_plain_text, strip_span_dicts, text_spans
11
+ from ..image import ensure_bmp_header, is_valid_vector_image_payload, is_vector_image_part, serialize_office_image
12
+ from ..equation.image import OfficeImageEquationDecoder
13
+ from ..rich_text import OfficeRichTextSegment, build_rich_text_from_segments
14
+ from .models import (
15
+ RtfAnchor,
16
+ RtfBlock,
17
+ RtfDisplayEquation,
18
+ RtfDocument,
19
+ RtfImage,
20
+ RtfInline,
21
+ RtfInlineEquation,
22
+ RtfLineBreak,
23
+ RtfNoteReference,
24
+ RtfParagraph,
25
+ RtfTable,
26
+ RtfTableCell,
27
+ RtfTextRun,
28
+ )
29
+ from .parser import MAX_RTF_LIST_DEPTH, parse_rtf, parse_rtf_prelude, read_rtf_bytes
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class _GridOrigin:
34
+ """保存 HTML table 网格中一个 origin cell 的 span 状态。"""
35
+
36
+ row: int
37
+ col: int
38
+ cell: RtfTableCell
39
+ row_span: int = 1
40
+ col_span: int = 1
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class _HtmlListNode:
45
+ """保存表格单元格内列表段落的临时层级树。"""
46
+
47
+ paragraph: RtfParagraph
48
+ children: list[_HtmlListNode] = field(default_factory=list)
49
+
50
+
51
+ def _style_names(run: RtfTextRun) -> list[str]:
52
+ """把 RTF 字符属性映射为现有 Office 富文本样式名。"""
53
+ result: list[str] = []
54
+ if run.style.bold:
55
+ result.append("bold")
56
+ if run.style.italic:
57
+ result.append("italic")
58
+ if run.style.underline:
59
+ result.append("underline")
60
+ if run.style.strike:
61
+ result.append("strikethrough")
62
+ if run.style.superscript:
63
+ result.append("superscript")
64
+ elif run.style.subscript:
65
+ result.append("subscript")
66
+ return result
67
+
68
+
69
+ def _paragraph_anchor(paragraph: RtfParagraph) -> str | None:
70
+ """返回段落内第一个非空 bookmark 名。"""
71
+ for inline in paragraph.inlines:
72
+ if isinstance(inline, RtfAnchor) and inline.name.strip():
73
+ return inline.name.strip()
74
+ return None
75
+
76
+
77
+ def _iter_nested_blocks(blocks: Iterable[RtfBlock]) -> Iterable[RtfBlock]:
78
+ """按深度优先顺序遍历正文及嵌套表格单元格块。"""
79
+ pending = list(reversed(list(blocks)))
80
+ while pending:
81
+ block = pending.pop()
82
+ yield block
83
+ if isinstance(block, RtfTable):
84
+ for row in reversed(block.rows):
85
+ for cell in reversed(row.cells):
86
+ pending.extend(reversed(cell.blocks))
87
+
88
+
89
+ def _title_anchors(document: RtfDocument) -> set[str]:
90
+ """收集 schema 能公开的标题 bookmark,普通段落 bookmark 不生成悬空链接。"""
91
+ result: set[str] = set()
92
+ for block in _iter_nested_blocks(document.blocks):
93
+ if not isinstance(block, RtfParagraph):
94
+ continue
95
+ if not block.is_title and block.outline_level is None:
96
+ continue
97
+ anchor = _paragraph_anchor(block)
98
+ if anchor:
99
+ result.add(anchor)
100
+ return result
101
+
102
+
103
+ def _note_numbers(document: RtfDocument) -> dict[str, int]:
104
+ """按第一次引用顺序编号 note,未引用 note 稳定追加在末尾。"""
105
+ numbers: dict[str, int] = {}
106
+
107
+ def visit_blocks(blocks: Iterable[RtfBlock]) -> None:
108
+ """扫描一组块中的 note reference。"""
109
+ for block in _iter_nested_blocks(blocks):
110
+ if not isinstance(block, RtfParagraph):
111
+ continue
112
+ for inline in block.inlines:
113
+ if isinstance(inline, RtfNoteReference) and inline.note_id not in numbers:
114
+ numbers[inline.note_id] = len(numbers) + 1
115
+
116
+ visit_blocks(document.blocks)
117
+ visit_blocks(document.headers)
118
+ visit_blocks(document.footers)
119
+ for note in document.notes:
120
+ visit_blocks(note.blocks)
121
+ for note in document.notes:
122
+ numbers.setdefault(note.id, len(numbers) + 1)
123
+ return numbers
124
+
125
+
126
+ def _plain_inlines(inlines: Iterable[RtfInline], note_numbers: dict[str, int]) -> str:
127
+ """提取行内节点可见文本,供代码块、alt 和脚注降级使用。"""
128
+ parts: list[str] = []
129
+ for inline in inlines:
130
+ if isinstance(inline, RtfTextRun):
131
+ parts.append(inline.text)
132
+ elif isinstance(inline, RtfInlineEquation):
133
+ parts.append(inline.latex)
134
+ elif isinstance(inline, RtfImage):
135
+ parts.append(inline.alt)
136
+ elif isinstance(inline, RtfNoteReference):
137
+ number = note_numbers.get(inline.note_id)
138
+ if number is not None:
139
+ parts.append(f"[{number}]")
140
+ elif isinstance(inline, RtfLineBreak):
141
+ parts.append("\n")
142
+ return "".join(parts)
143
+
144
+
145
+ class RtfConverter:
146
+ """把 RTF typed IR 投影为现有 Office raw-block 协议。"""
147
+
148
+ def __init__(self) -> None:
149
+ """初始化空输出和每文档图片公式 decoder。"""
150
+ self.pages: list[list[dict[str, Any]]] = []
151
+ self.document: RtfDocument | None = None
152
+ self._note_numbers: dict[str, int] = {}
153
+ self._title_anchors: set[str] = set()
154
+ self._image_equations = OfficeImageEquationDecoder()
155
+
156
+ def convert(self, file_binary: BinaryIO) -> None:
157
+ """解析 RTF 二进制流并生成固定单逻辑页 model-list。"""
158
+ document = parse_rtf(file_binary)
159
+ self.document = document
160
+ self._note_numbers = _note_numbers(document)
161
+ self._title_anchors = _title_anchors(document)
162
+ page = self._document_blocks(document.blocks)
163
+ page.extend(self._auxiliary_blocks(document.headers, BlockType.HEADER))
164
+ page.extend(self._auxiliary_blocks(document.footers, BlockType.FOOTER))
165
+ page.extend(self._note_blocks(document))
166
+ self.pages = [page]
167
+
168
+ def _resolved_hyperlink(self, target: str | None) -> str | None:
169
+ """只保留指向可公开标题 bookmark 的内部链接。"""
170
+ if not target:
171
+ return None
172
+ if target.startswith("#") and target[1:] not in self._title_anchors:
173
+ return None
174
+ return target
175
+
176
+ def _rich_text(self, inlines: Iterable[RtfInline]) -> list[dict[str, Any]]:
177
+ """把非图片行内节点直接转换为结构化 Span。"""
178
+ spans: list[dict[str, Any]] = []
179
+ segments: list[OfficeRichTextSegment] = []
180
+
181
+ def flush() -> None:
182
+ """在公式边界前输出累计普通富文本。"""
183
+ if not segments:
184
+ return
185
+ extend_inline_spans(spans, build_rich_text_from_segments(segments, trim_plain_edges=not spans))
186
+ segments.clear()
187
+
188
+ for inline in inlines:
189
+ if isinstance(inline, RtfTextRun):
190
+ hyperlink = self._resolved_hyperlink(inline.hyperlink)
191
+ segments.append(
192
+ OfficeRichTextSegment(
193
+ text=inline.text,
194
+ style=_style_names(inline),
195
+ hyperlink=hyperlink,
196
+ )
197
+ )
198
+ elif isinstance(inline, RtfInlineEquation):
199
+ flush()
200
+ append_equation_span(spans, inline.latex)
201
+ elif isinstance(inline, RtfNoteReference):
202
+ number = self._note_numbers.get(inline.note_id)
203
+ if number is not None:
204
+ segments.append(
205
+ OfficeRichTextSegment(
206
+ text=f"[{number}]",
207
+ style="superscript",
208
+ )
209
+ )
210
+ elif isinstance(inline, RtfLineBreak):
211
+ segments.append(OfficeRichTextSegment(text="\n"))
212
+ flush()
213
+ return strip_span_dicts(spans)
214
+
215
+ def _image_payload(self, image: RtfImage) -> tuple[bytes, str, str] | None:
216
+ """规范 DIB 载荷并返回图片数据、part name 和 content type。"""
217
+ if image.part_name.lower().endswith(".dib"):
218
+ return ensure_bmp_header(image.data), "pict.bmp", "image/bmp"
219
+ if is_vector_image_part(image.part_name, image.content_type) and not is_valid_vector_image_payload(
220
+ image.data,
221
+ part_name=image.part_name,
222
+ content_type=image.content_type,
223
+ ):
224
+ return None
225
+ return image.data, image.part_name, image.content_type
226
+
227
+ def _image_block(self, image: RtfImage) -> dict[str, Any] | None:
228
+ """优先恢复图片 MTEF 公式,否则序列化为安全图片 data URI。"""
229
+ normalized = self._image_payload(image)
230
+ if normalized is None:
231
+ if image.alt.strip():
232
+ return {"type": BlockType.TEXT, "content": text_spans(image.alt.strip())}
233
+ return None
234
+ payload, part_name, content_type = normalized
235
+ latex = self._image_equations.decode(
236
+ payload,
237
+ part_name=part_name,
238
+ content_type=content_type,
239
+ )
240
+ if latex:
241
+ return {"type": BlockType.EQUATION, "content": latex}
242
+ image_base64 = serialize_office_image(
243
+ payload,
244
+ part_name=part_name,
245
+ content_type=content_type,
246
+ )
247
+ if image_base64 is None:
248
+ if image.alt.strip():
249
+ return {"type": BlockType.TEXT, "content": text_spans(image.alt.strip())}
250
+ return None
251
+ block: dict[str, Any] = {
252
+ "type": BlockType.IMAGE,
253
+ "content": "",
254
+ "image_base64": image_base64,
255
+ }
256
+ if image.alt.strip():
257
+ block["sub_type"] = image.alt.strip()
258
+ return block
259
+
260
+ def _paragraph_text_block(
261
+ self,
262
+ paragraph: RtfParagraph,
263
+ inlines: list[RtfInline],
264
+ *,
265
+ allow_title: bool,
266
+ ) -> dict[str, Any] | None:
267
+ """把一个不含图片的段落片段投影为标题、代码或正文 raw block。"""
268
+ if paragraph.block_style == "code":
269
+ content = _plain_inlines(inlines, self._note_numbers).strip("\n")
270
+ return {"type": BlockType.CODE, "content": content} if content else None
271
+ content = self._rich_text(inlines)
272
+ if not content:
273
+ return None
274
+ if allow_title and paragraph.is_title:
275
+ block: dict[str, Any] = {
276
+ "type": BlockType.DOC_TITLE,
277
+ "level": 1,
278
+ "content": content,
279
+ }
280
+ elif allow_title and paragraph.outline_level is not None:
281
+ block = {
282
+ "type": BlockType.PARAGRAPH_TITLE,
283
+ "level": min(max(paragraph.outline_level + 2, 2), 6),
284
+ "is_numbered_style": False,
285
+ "content": content,
286
+ }
287
+ else:
288
+ block = {"type": BlockType.TEXT, "content": content}
289
+ if block["type"] in {BlockType.DOC_TITLE, BlockType.PARAGRAPH_TITLE}:
290
+ anchor = _paragraph_anchor(paragraph)
291
+ if anchor:
292
+ block["anchor"] = anchor
293
+ return block
294
+
295
+ def _paragraph_blocks(self, paragraph: RtfParagraph) -> list[dict[str, Any]]:
296
+ """按行内图片位置拆分段落,并只让首个文本片段继承标题类型。"""
297
+ if paragraph.list_info is not None and (paragraph.is_title or paragraph.outline_level is not None):
298
+ label = paragraph.list_info.label if paragraph.list_info.ordered else None
299
+ if label:
300
+ paragraph = RtfParagraph(
301
+ inlines=[RtfTextRun(f"{label} "), *paragraph.inlines],
302
+ style_name=paragraph.style_name,
303
+ outline_level=paragraph.outline_level,
304
+ is_title=paragraph.is_title,
305
+ block_style=paragraph.block_style,
306
+ )
307
+
308
+ non_image = [inline for inline in paragraph.inlines if not isinstance(inline, (RtfImage, RtfAnchor))]
309
+ equations = [inline for inline in non_image if isinstance(inline, RtfInlineEquation)]
310
+ ordinary = [
311
+ inline
312
+ for inline in non_image
313
+ if not isinstance(inline, (RtfInlineEquation, RtfLineBreak, RtfNoteReference))
314
+ and (not isinstance(inline, RtfTextRun) or bool(inline.text.strip()))
315
+ ]
316
+ has_note_reference = any(isinstance(inline, RtfNoteReference) for inline in non_image)
317
+ if equations and not ordinary and not has_note_reference:
318
+ return [{"type": BlockType.EQUATION, "content": equation.latex} for equation in equations]
319
+
320
+ blocks: list[dict[str, Any]] = []
321
+ current: list[RtfInline] = []
322
+ text_emitted = False
323
+
324
+ def flush() -> None:
325
+ """输出当前图片边界前累计的段落片段。"""
326
+ nonlocal text_emitted
327
+ block = self._paragraph_text_block(
328
+ paragraph,
329
+ current,
330
+ allow_title=not text_emitted,
331
+ )
332
+ current.clear()
333
+ if block is not None:
334
+ blocks.append(block)
335
+ text_emitted = True
336
+
337
+ for inline in paragraph.inlines:
338
+ if isinstance(inline, RtfImage):
339
+ flush()
340
+ image_block = self._image_block(inline)
341
+ if image_block is not None:
342
+ blocks.append(image_block)
343
+ continue
344
+ current.append(inline)
345
+ flush()
346
+ return blocks
347
+
348
+ def _append_list_item(
349
+ self,
350
+ page: list[dict[str, Any]],
351
+ stack: list[dict[str, Any]],
352
+ identity: int | None,
353
+ paragraph: RtfParagraph,
354
+ ) -> int:
355
+ """把一个 RTF 列表段落追加到嵌套 raw list 树。"""
356
+ info = paragraph.list_info
357
+ if info is None:
358
+ return identity or -1
359
+ content = self._rich_text(inline for inline in paragraph.inlines if not isinstance(inline, (RtfImage, RtfAnchor)))
360
+ if identity != info.identity:
361
+ stack.clear()
362
+ identity = info.identity
363
+ level = min(max(info.level, 0), MAX_RTF_LIST_DEPTH)
364
+ while len(stack) > level + 1:
365
+ stack.pop()
366
+ while len(stack) < level + 1:
367
+ list_block: dict[str, Any] = {
368
+ "type": BlockType.LIST,
369
+ "attribute": "ordered" if info.ordered else "unordered",
370
+ "ilevel": len(stack),
371
+ "content": [],
372
+ }
373
+ if info.ordered:
374
+ list_block["start"] = info.start
375
+ if stack:
376
+ stack[-1]["content"].append(list_block)
377
+ else:
378
+ page.append(list_block)
379
+ stack.append(list_block)
380
+ current = stack[level]
381
+ expected = "ordered" if info.ordered else "unordered"
382
+ if current.get("attribute") != expected:
383
+ del stack[level:]
384
+ return self._append_list_item(page, stack, None, paragraph)
385
+ if content:
386
+ leaf: dict[str, Any] = {"type": BlockType.TEXT, "content": content}
387
+ if info.ordered and info.label:
388
+ leaf["list_label"] = info.label
389
+ current["content"].append(leaf)
390
+ return info.identity
391
+
392
+ def _document_blocks(self, blocks: Iterable[RtfBlock]) -> list[dict[str, Any]]:
393
+ """按源顺序转换正文块,并维护顶层列表连续性。"""
394
+ page: list[dict[str, Any]] = []
395
+ list_stack: list[dict[str, Any]] = []
396
+ list_identity: int | None = None
397
+ source_blocks = list(blocks)
398
+ index = 0
399
+ while index < len(source_blocks):
400
+ block = source_blocks[index]
401
+ if isinstance(block, RtfParagraph) and block.block_style == "code":
402
+ list_stack.clear()
403
+ list_identity = None
404
+ lines: list[str] = []
405
+ images: list[RtfImage] = []
406
+ while index < len(source_blocks):
407
+ candidate = source_blocks[index]
408
+ if not isinstance(candidate, RtfParagraph) or candidate.block_style != "code":
409
+ break
410
+ lines.append(_plain_inlines(candidate.inlines, self._note_numbers).rstrip("\n"))
411
+ images.extend(inline for inline in candidate.inlines if isinstance(inline, RtfImage))
412
+ index += 1
413
+ content = "\n".join(lines).strip("\n")
414
+ if content:
415
+ page.append({"type": BlockType.CODE, "content": content})
416
+ for image in images:
417
+ image_block = self._image_block(image)
418
+ if image_block is not None:
419
+ page.append(image_block)
420
+ continue
421
+ if (
422
+ isinstance(block, RtfParagraph)
423
+ and block.list_info is not None
424
+ and not (block.is_title or block.outline_level is not None)
425
+ ):
426
+ list_identity = self._append_list_item(page, list_stack, list_identity, block)
427
+ images = [inline for inline in block.inlines if isinstance(inline, RtfImage)]
428
+ if images:
429
+ list_stack.clear()
430
+ list_identity = None
431
+ for image in images:
432
+ image_block = self._image_block(image)
433
+ if image_block is not None:
434
+ page.append(image_block)
435
+ index += 1
436
+ continue
437
+ list_stack.clear()
438
+ list_identity = None
439
+ if isinstance(block, RtfParagraph):
440
+ page.extend(self._paragraph_blocks(block))
441
+ elif isinstance(block, RtfDisplayEquation):
442
+ if block.latex.strip():
443
+ page.append({"type": BlockType.EQUATION, "content": block.latex.strip()})
444
+ elif isinstance(block, RtfTable):
445
+ page.append({"type": BlockType.TABLE, "content": self._table_html(block)})
446
+ index += 1
447
+ return page
448
+
449
+ def _inline_html(self, inlines: Iterable[RtfInline]) -> str:
450
+ """把表格单元格行内节点转换为白名单 HTML。"""
451
+ parts: list[str] = []
452
+ for inline in inlines:
453
+ if isinstance(inline, RtfTextRun):
454
+ content = escape(inline.text, quote=False).replace("\n", "<br>")
455
+ if inline.style.code:
456
+ content = f"<code>{content}</code>"
457
+ if inline.style.superscript:
458
+ content = f"<sup>{content}</sup>"
459
+ elif inline.style.subscript:
460
+ content = f"<sub>{content}</sub>"
461
+ if inline.style.underline:
462
+ content = f"<u>{content}</u>"
463
+ if inline.style.bold:
464
+ content = f"<strong>{content}</strong>"
465
+ if inline.style.italic:
466
+ content = f"<em>{content}</em>"
467
+ if inline.style.strike:
468
+ content = f"<s>{content}</s>"
469
+ target = self._resolved_hyperlink(inline.hyperlink)
470
+ if target:
471
+ content = f'<a href="{escape(target, quote=True)}">{content}</a>'
472
+ parts.append(content)
473
+ elif isinstance(inline, RtfInlineEquation):
474
+ parts.append(f"<eq>{escape(inline.latex, quote=False)}</eq>")
475
+ elif isinstance(inline, RtfLineBreak):
476
+ parts.append("<br>")
477
+ elif isinstance(inline, RtfNoteReference):
478
+ number = self._note_numbers.get(inline.note_id)
479
+ if number is not None:
480
+ parts.append(f"<sup>[{number}]</sup>")
481
+ elif isinstance(inline, RtfImage):
482
+ normalized = self._image_payload(inline)
483
+ if normalized is None:
484
+ if inline.alt:
485
+ parts.append(escape(inline.alt, quote=False))
486
+ continue
487
+ payload, part_name, content_type = normalized
488
+ latex = self._image_equations.decode(
489
+ payload,
490
+ part_name=part_name,
491
+ content_type=content_type,
492
+ )
493
+ if latex:
494
+ parts.append(f"<eq>{escape(latex, quote=False)}</eq>")
495
+ continue
496
+ source = serialize_office_image(
497
+ payload,
498
+ part_name=part_name,
499
+ content_type=content_type,
500
+ )
501
+ if source:
502
+ parts.append(f'<img src="{escape(source, quote=True)}" alt="{escape(inline.alt, quote=True)}">')
503
+ elif inline.alt:
504
+ parts.append(escape(inline.alt, quote=False))
505
+ return "".join(parts)
506
+
507
+ def _list_tree(self, paragraphs: list[RtfParagraph]) -> list[_HtmlListNode]:
508
+ """把连续列表段落构造成单元格 HTML 使用的嵌套树。"""
509
+ roots: list[_HtmlListNode] = []
510
+ stack: list[_HtmlListNode] = []
511
+ for paragraph in paragraphs:
512
+ level = min(paragraph.list_info.level if paragraph.list_info else 0, len(stack))
513
+ while len(stack) > level:
514
+ stack.pop()
515
+ node = _HtmlListNode(paragraph)
516
+ if level > 0 and stack:
517
+ stack[-1].children.append(node)
518
+ else:
519
+ roots.append(node)
520
+ stack.append(node)
521
+ return roots
522
+
523
+ def _list_nodes_html(self, nodes: list[_HtmlListNode]) -> str:
524
+ """递归序列化一层单元格列表节点。"""
525
+ if not nodes:
526
+ return ""
527
+ info = nodes[0].paragraph.list_info
528
+ ordered = bool(info and info.ordered)
529
+ tag = "ol" if ordered else "ul"
530
+ start = f' start="{max(info.start, 0)}"' if ordered and info else ""
531
+ items: list[str] = []
532
+ for node in nodes:
533
+ content = self._inline_html(node.paragraph.inlines)
534
+ nested = self._list_nodes_html(node.children)
535
+ items.append(f"<li>{content}{nested}</li>")
536
+ return f"<{tag}{start}>{''.join(items)}</{tag}>"
537
+
538
+ def _blocks_html(self, blocks: list[RtfBlock]) -> str:
539
+ """序列化 table cell 内允许的段落、列表、代码、引用和嵌套表格。"""
540
+ parts: list[str] = []
541
+ index = 0
542
+ while index < len(blocks):
543
+ block = blocks[index]
544
+ if isinstance(block, RtfParagraph) and block.list_info is not None:
545
+ run: list[RtfParagraph] = []
546
+ identity = block.list_info.identity
547
+ while index < len(blocks):
548
+ candidate = blocks[index]
549
+ if not isinstance(candidate, RtfParagraph) or candidate.list_info is None:
550
+ break
551
+ if candidate.list_info.identity != identity:
552
+ break
553
+ run.append(candidate)
554
+ index += 1
555
+ parts.append(self._list_nodes_html(self._list_tree(run)))
556
+ continue
557
+ if isinstance(block, RtfParagraph):
558
+ content = self._inline_html(block.inlines)
559
+ if block.block_style == "code":
560
+ parts.append(f"<pre><code>{escape(_plain_inlines(block.inlines, self._note_numbers))}</code></pre>")
561
+ elif block.block_style == "quote":
562
+ parts.append(f"<blockquote>{content}</blockquote>")
563
+ else:
564
+ parts.append(f"<p>{content}</p>")
565
+ elif isinstance(block, RtfDisplayEquation):
566
+ parts.append(f"<p><eq>{escape(block.latex, quote=False)}</eq></p>")
567
+ elif isinstance(block, RtfTable):
568
+ parts.append(self._table_html(block))
569
+ index += 1
570
+ return "".join(parts)
571
+
572
+ def _table_grid(self, table: RtfTable) -> list[list[_GridOrigin | None]]:
573
+ """解析横向与纵向 merge continuation,生成 exactly-once origin 网格。"""
574
+ boundaries = sorted(
575
+ {cell.right_boundary for row in table.rows for cell in row.cells if cell.right_boundary is not None}
576
+ )
577
+ width = len(boundaries) or max((len(row.cells) for row in table.rows), default=0)
578
+ boundary_index = {value: index for index, value in enumerate(boundaries)}
579
+ grid: list[list[_GridOrigin | None]] = []
580
+ for row_index, row in enumerate(table.rows):
581
+ slots: list[_GridOrigin | None] = [None] * width
582
+ previous_end = -1
583
+ for fallback_col, cell in enumerate(row.cells):
584
+ right_boundary = cell.right_boundary
585
+ end_col = boundary_index.get(right_boundary, fallback_col) if right_boundary is not None else fallback_col
586
+ start_col = previous_end + 1
587
+ if end_col < start_col:
588
+ end_col = start_col
589
+ end_col = min(end_col, width - 1)
590
+ previous_end = end_col
591
+ above = grid[row_index - 1][start_col] if row_index > 0 and start_col < width else None
592
+ left = slots[start_col - 1] if start_col > 0 else None
593
+ has_content = bool(self._blocks_plain_text(cell.blocks).strip())
594
+ if cell.vertical_merge == "continue" and above is not None and not has_content:
595
+ origin = above
596
+ origin.row_span = max(origin.row_span, row_index - origin.row + 1)
597
+ elif cell.horizontal_merge == "continue" and left is not None and not has_content:
598
+ origin = left
599
+ origin.col_span = max(origin.col_span, end_col - origin.col + 1)
600
+ else:
601
+ origin = _GridOrigin(
602
+ row_index,
603
+ start_col,
604
+ cell,
605
+ col_span=max(end_col - start_col + 1, 1),
606
+ )
607
+ for col_index in range(start_col, end_col + 1):
608
+ slots[col_index] = origin
609
+ for col_index, origin in enumerate(slots):
610
+ if origin is None:
611
+ slots[col_index] = _GridOrigin(row_index, col_index, RtfTableCell())
612
+ grid.append(slots)
613
+ return grid
614
+
615
+ def _table_html(self, table: RtfTable) -> str:
616
+ """把 RTF 表格输出为带 rowspan/colspan 的安全 HTML。"""
617
+ grid = self._table_grid(table)
618
+ rows: list[str] = []
619
+ for row_index, slots in enumerate(grid):
620
+ tag = "th" if row_index < len(table.rows) and table.rows[row_index].header else "td"
621
+ cells: list[str] = []
622
+ for col_index, origin in enumerate(slots):
623
+ if origin is None or origin.row != row_index or origin.col != col_index:
624
+ continue
625
+ attributes: list[str] = []
626
+ if origin.row_span > 1:
627
+ attributes.append(f'rowspan="{origin.row_span}"')
628
+ if origin.col_span > 1:
629
+ attributes.append(f'colspan="{origin.col_span}"')
630
+ suffix = f" {' '.join(attributes)}" if attributes else ""
631
+ cells.append(f"<{tag}{suffix}>{self._blocks_html(origin.cell.blocks)}</{tag}>")
632
+ rows.append(f"<tr>{''.join(cells)}</tr>")
633
+ return f"<table>{''.join(rows)}</table>"
634
+
635
+ def _auxiliary_blocks(self, blocks: list[RtfBlock], block_type: BlockType) -> list[dict[str, Any]]:
636
+ """把页眉页脚段落去重后投影为页面辅助块。"""
637
+ result: list[dict[str, Any]] = []
638
+ seen: set[str] = set()
639
+ for block in blocks:
640
+ if isinstance(block, RtfParagraph):
641
+ content = self._rich_text(block.inlines)
642
+ elif isinstance(block, RtfDisplayEquation):
643
+ content = []
644
+ append_equation_span(content, block.latex)
645
+ elif isinstance(block, RtfTable):
646
+ content = text_spans(self._table_plain_text(block))
647
+ else:
648
+ continue
649
+ visible = inline_span_plain_text(content)
650
+ if content and not visible.isdigit() and visible not in seen:
651
+ seen.add(visible)
652
+ result.append({"type": block_type, "content": content})
653
+ return result
654
+
655
+ def _table_plain_text(self, table: RtfTable) -> str:
656
+ """把表格可见文本压平,供注释和辅助块无损降级。"""
657
+ rows: list[str] = []
658
+ for row in table.rows:
659
+ cells = [self._blocks_plain_text(cell.blocks) for cell in row.cells]
660
+ rows.append(" | ".join(cells))
661
+ return "\n".join(rows)
662
+
663
+ def _blocks_plain_text(self, blocks: Iterable[RtfBlock]) -> str:
664
+ """提取块列表可见文本,保持段落和表格行边界。"""
665
+ parts: list[str] = []
666
+ for block in blocks:
667
+ if isinstance(block, RtfParagraph):
668
+ parts.append(_plain_inlines(block.inlines, self._note_numbers))
669
+ elif isinstance(block, RtfDisplayEquation):
670
+ parts.append(block.latex)
671
+ elif isinstance(block, RtfTable):
672
+ parts.append(self._table_plain_text(block))
673
+ return "\n".join(part for part in parts if part.strip())
674
+
675
+ def _note_blocks(self, document: RtfDocument) -> list[dict[str, Any]]:
676
+ """按公开编号输出脚注与尾注正文。"""
677
+ result: list[dict[str, Any]] = []
678
+ ordered = sorted(
679
+ document.notes,
680
+ key=lambda note: self._note_numbers[note.id] if note.id in self._note_numbers else 2**31 - 1,
681
+ )
682
+ for note in ordered:
683
+ number = self._note_numbers.get(note.id)
684
+ content = self._blocks_plain_text(note.blocks).strip()
685
+ if number is None or not content:
686
+ continue
687
+ result.append(
688
+ {
689
+ "type": BlockType.PAGE_FOOTNOTE,
690
+ "content": text_spans(f"[{number}] {content}"),
691
+ }
692
+ )
693
+ return result
694
+
695
+
696
+ def extract_rtf_metadata(file_binary: BinaryIO) -> dict[str, str | None]:
697
+ """有界读取 RTF,仅解析 info destination 并返回 doclib 字段。"""
698
+ data = read_rtf_bytes(file_binary)
699
+ metadata = parse_rtf_prelude(data).metadata
700
+ return {
701
+ "title": metadata.title,
702
+ "author": metadata.author,
703
+ "subject": metadata.subject,
704
+ "keywords": metadata.keywords,
705
+ }
706
+
707
+
708
+ __all__ = ["RtfConverter", "extract_rtf_metadata"]