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,1553 @@
1
+ """RTF 1.9.1 常用语义的有界状态机 parser。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ from dataclasses import dataclass, field, replace
7
+ import re
8
+ from typing import BinaryIO, Literal
9
+
10
+ from loguru import logger
11
+
12
+ from ..._shared.hyperlink import sanitize_hyperlink_target
13
+ from .....document.filetypes import rtf_header_offset
14
+ from ..errors import LegacyOfficeMalformedError, LegacyOfficeResourceLimitError
15
+ from ..limits import MAX_ASSET_TOTAL_BYTES, MAX_ENTRY_BYTES, MAX_GRID_SLOTS
16
+ from .lexer import RtfBinary, RtfClose, RtfControlSymbol, RtfControlWord, RtfHexByte, RtfLexer, RtfOpen, RtfTextBytes
17
+ from .math import parse_rtf_math
18
+ from .models import (
19
+ RtfAnchor,
20
+ RtfBlock,
21
+ RtfDisplayEquation,
22
+ RtfDocument,
23
+ RtfImage,
24
+ RtfInline,
25
+ RtfInlineEquation,
26
+ RtfLineBreak,
27
+ RtfListInfo,
28
+ RtfMetadata,
29
+ RtfNote,
30
+ RtfNoteReference,
31
+ RtfParagraph,
32
+ RtfTable,
33
+ RtfTableCell,
34
+ RtfTableRow,
35
+ RtfTextRun,
36
+ RtfTextStyle,
37
+ )
38
+
39
+ MAX_RTF_BYTES = MAX_ENTRY_BYTES
40
+ MAX_RTF_LIST_DEPTH = 8
41
+ MAX_RTF_TABLE_DEPTH = 4
42
+ MAX_RTF_ROMAN_VALUE = 3_999
43
+ MAX_RTF_TABLE_CELLS = 100_000
44
+
45
+ _CHARSET_ENCODINGS = {
46
+ 0: "cp1252",
47
+ 2: "cp1252",
48
+ 77: "mac_roman",
49
+ 128: "cp932",
50
+ 129: "cp949",
51
+ 130: "cp1361",
52
+ 134: "gb18030",
53
+ 136: "big5",
54
+ 161: "cp1253",
55
+ 162: "cp1254",
56
+ 163: "cp1258",
57
+ 177: "cp1255",
58
+ 178: "cp1256",
59
+ 186: "cp1257",
60
+ 204: "cp1251",
61
+ 222: "cp874",
62
+ 238: "cp1250",
63
+ 255: "cp437",
64
+ }
65
+ _NFC_MARKERS = {
66
+ 0: "decimal",
67
+ 1: "upper_roman",
68
+ 2: "lower_roman",
69
+ 3: "upper_alpha",
70
+ 4: "lower_alpha",
71
+ 23: "bullet",
72
+ 255: "none",
73
+ }
74
+ _BLOCK_STYLE_NAMES = {
75
+ "html preformatted": "code",
76
+ "preformatted text": "code",
77
+ "source code": "code",
78
+ "block text": "quote",
79
+ "intense quote": "quote",
80
+ "quotation": "quote",
81
+ "quotations": "quote",
82
+ "quote": "quote",
83
+ }
84
+ _TITLE_STYLE_NAMES = {"document title", "title"}
85
+ _HEADER_DESTINATIONS = {"header", "headerf", "headerl", "headerr"}
86
+ _FOOTER_DESTINATIONS = {"footer", "footerf", "footerl", "footerr"}
87
+ _SUPPRESSED_DESTINATIONS = {
88
+ "atnauthor",
89
+ "atnid",
90
+ "background",
91
+ "blipuid",
92
+ "colortbl",
93
+ "colorschememapping",
94
+ "datafield",
95
+ "datastore",
96
+ "defchp",
97
+ "defpap",
98
+ "doccomm",
99
+ "docvar",
100
+ "filetbl",
101
+ "fonttbl",
102
+ "generator",
103
+ "info",
104
+ "latentstyles",
105
+ "listoverridetable",
106
+ "listtable",
107
+ "nonshppict",
108
+ "objdata",
109
+ "operator",
110
+ "panose",
111
+ "revtbl",
112
+ "rsidtbl",
113
+ "stylesheet",
114
+ "themedata",
115
+ "userprops",
116
+ "wgrffmtfilter",
117
+ "xmlnstbl",
118
+ }
119
+ _SPECIAL_WORDS = {
120
+ "bullet": "\u2022",
121
+ "emdash": "\u2014",
122
+ "emspace": " ",
123
+ "endash": "\u2013",
124
+ "enspace": " ",
125
+ "ldblquote": "\u201c",
126
+ "line": "\n",
127
+ "lquote": "\u2018",
128
+ "qmspace": " ",
129
+ "rdblquote": "\u201d",
130
+ "rquote": "\u2019",
131
+ "tab": " ",
132
+ }
133
+ _HYPERLINK_RE = re.compile(
134
+ r"\bHYPERLINK\b(?P<local>\s+\\l)?\s+(?:\"(?P<quoted>[^\"]*)\"|(?P<bare>\S+))",
135
+ re.IGNORECASE,
136
+ )
137
+ _CONTROL_RE = re.compile(rb"\\(?P<name>[A-Za-z]+)(?P<param>-?\d+)?(?: )?")
138
+
139
+
140
+ @dataclass(frozen=True, slots=True)
141
+ class _TextStyleOverrides:
142
+ """保存 stylesheet 中可区分缺省与显式关闭的字符样式覆盖。"""
143
+
144
+ bold: bool | None = None
145
+ italic: bool | None = None
146
+ underline: bool | None = None
147
+ strike: bool | None = None
148
+
149
+ def resolve(self, base: RtfTextStyle = RtfTextStyle()) -> RtfTextStyle:
150
+ """用当前显式值覆盖基样式,缺省字段继续继承。"""
151
+ return RtfTextStyle(
152
+ bold=base.bold if self.bold is None else self.bold,
153
+ italic=base.italic if self.italic is None else self.italic,
154
+ underline=base.underline if self.underline is None else self.underline,
155
+ strike=base.strike if self.strike is None else self.strike,
156
+ )
157
+
158
+
159
+ @dataclass(frozen=True, slots=True)
160
+ class _StyleDefinition:
161
+ """保存 stylesheet 中与语义投影相关的段落样式。"""
162
+
163
+ name: str = ""
164
+ outline_level: int | None = None
165
+ is_title: bool = False
166
+ block_style: Literal["normal", "code", "quote"] = "normal"
167
+ based_on: int | None = None
168
+ text_style: RtfTextStyle = RtfTextStyle()
169
+ text_style_overrides: _TextStyleOverrides = _TextStyleOverrides()
170
+
171
+
172
+ @dataclass(frozen=True, slots=True)
173
+ class _ListLevel:
174
+ """保存 RTF listlevel 的起始值和常用编号格式。"""
175
+
176
+ marker: str = "decimal"
177
+ start: int = 1
178
+
179
+ @property
180
+ def ordered(self) -> bool:
181
+ """返回当前 level 是否应按有序列表输出。"""
182
+ return self.marker not in {"bullet", "none"}
183
+
184
+
185
+ @dataclass(frozen=True, slots=True)
186
+ class _ListDefinition:
187
+ """保存一个 list override 对应的列表定义。"""
188
+
189
+ identity: int
190
+ levels: tuple[_ListLevel, ...]
191
+
192
+
193
+ @dataclass(slots=True)
194
+ class _Prelude:
195
+ """保存正文解析前从 RTF header tables 收敛出的定义。"""
196
+
197
+ default_encoding: str = "cp1252"
198
+ font_encodings: dict[int, str] = field(default_factory=dict)
199
+ styles: dict[int, _StyleDefinition] = field(default_factory=dict)
200
+ lists: dict[int, _ListDefinition] = field(default_factory=dict)
201
+ metadata: RtfMetadata = field(default_factory=RtfMetadata)
202
+
203
+
204
+ @dataclass(slots=True)
205
+ class _State:
206
+ """保存随 RTF group 继承和恢复的字符、段落及 destination 状态。"""
207
+
208
+ style: RtfTextStyle = RtfTextStyle()
209
+ font_id: int | None = None
210
+ uc_skip: int = 1
211
+ hidden: bool = False
212
+ paragraph_style_id: int | None = None
213
+ outline_level: int | None = None
214
+ list_override_id: int | None = None
215
+ list_level: int = 0
216
+ in_table: bool = False
217
+ table_depth: int = 0
218
+ destination: str = "body"
219
+
220
+
221
+ @dataclass(slots=True)
222
+ class _CellDefinition:
223
+ """保存 cellx 处冻结的单元格合并属性。"""
224
+
225
+ horizontal_merge: Literal["none", "start", "continue"] = "none"
226
+ vertical_merge: Literal["none", "start", "continue"] = "none"
227
+ right_boundary: int | None = None
228
+
229
+
230
+ class _TableBuilder:
231
+ """把 RTF row/cell 控制按当前 table depth 组装为语义表格。"""
232
+
233
+ def __init__(self) -> None:
234
+ """初始化空表格和空 row/cell 状态。"""
235
+ self.rows: list[RtfTableRow] = []
236
+ self._definitions: list[_CellDefinition] = []
237
+ self._cells: list[RtfTableCell] = []
238
+ self._cell_blocks: list[RtfBlock] = []
239
+ self._row_header = False
240
+ self._pending_horizontal: Literal["none", "start", "continue"] = "none"
241
+ self._pending_vertical: Literal["none", "start", "continue"] = "none"
242
+ self._row_open = False
243
+ self._total_slots = 0
244
+
245
+ def _check_cell_budget(self, row_cell_count: int) -> None:
246
+ """在保存定义或物化 cell 前校验当前表格对象预算。"""
247
+ limit = min(MAX_GRID_SLOTS, MAX_RTF_TABLE_CELLS)
248
+ if self._total_slots + row_cell_count > limit:
249
+ raise LegacyOfficeResourceLimitError(f"RTF table exceeds max_grid_slots={limit}")
250
+
251
+ def start_row(self) -> None:
252
+ """开始新行;若上一行未显式结束则先安全收束。"""
253
+ if self._row_open:
254
+ if self._cells or self._cell_blocks:
255
+ # 嵌套表格的 nesttableprops 位于 cell 内容之后,此处只补写定义。
256
+ self._definitions = []
257
+ return
258
+ self.end_row()
259
+ self._definitions = []
260
+ self._cells = []
261
+ self._cell_blocks = []
262
+ self._row_header = False
263
+ self._pending_horizontal = "none"
264
+ self._pending_vertical = "none"
265
+ self._row_open = True
266
+
267
+ def set_header(self) -> None:
268
+ """把当前行标记为重复表头行。"""
269
+ self._row_header = True
270
+
271
+ def set_horizontal_merge(self, value: Literal["start", "continue"]) -> None:
272
+ """记录下一个 cellx 使用的横向合并属性。"""
273
+ self._pending_horizontal = value
274
+
275
+ def set_vertical_merge(self, value: Literal["start", "continue"]) -> None:
276
+ """记录下一个 cellx 使用的纵向合并属性。"""
277
+ self._pending_vertical = value
278
+
279
+ def add_definition(self, right_boundary: int | None) -> None:
280
+ """在 cellx 边界冻结当前单元格定义。"""
281
+ if not self._row_open:
282
+ self.start_row()
283
+ self._check_cell_budget(max(len(self._definitions) + 1, len(self._cells)))
284
+ self._definitions.append(
285
+ _CellDefinition(
286
+ horizontal_merge=self._pending_horizontal,
287
+ vertical_merge=self._pending_vertical,
288
+ right_boundary=right_boundary,
289
+ )
290
+ )
291
+ definition_index = len(self._definitions) - 1
292
+ if definition_index < len(self._cells):
293
+ definition = self._definitions[definition_index]
294
+ existing = self._cells[definition_index]
295
+ existing.horizontal_merge = definition.horizontal_merge
296
+ existing.vertical_merge = definition.vertical_merge
297
+ existing.right_boundary = definition.right_boundary
298
+ self._pending_horizontal = "none"
299
+ self._pending_vertical = "none"
300
+
301
+ def add_block(self, block: RtfBlock) -> None:
302
+ """向当前尚未结束的单元格追加语义块。"""
303
+ if not self._row_open:
304
+ self.start_row()
305
+ self._cell_blocks.append(block)
306
+
307
+ def end_cell(self) -> None:
308
+ """结束当前单元格,并按同位置 cellx 定义附加合并属性。"""
309
+ if not self._row_open:
310
+ self.start_row()
311
+ self._check_cell_budget(max(len(self._definitions), len(self._cells) + 1))
312
+ definition_index = len(self._cells)
313
+ definition = self._definitions[definition_index] if definition_index < len(self._definitions) else _CellDefinition()
314
+ self._cells.append(
315
+ RtfTableCell(
316
+ blocks=self._cell_blocks,
317
+ horizontal_merge=definition.horizontal_merge,
318
+ vertical_merge=definition.vertical_merge,
319
+ right_boundary=definition.right_boundary,
320
+ )
321
+ )
322
+ self._cell_blocks = []
323
+
324
+ def end_row(self) -> None:
325
+ """补齐当前行定义的空单元格并写入表格。"""
326
+ if not self._row_open:
327
+ return
328
+ if self._cell_blocks:
329
+ self.end_cell()
330
+ target_cells = max(len(self._definitions), len(self._cells))
331
+ self._check_cell_budget(target_cells)
332
+ while len(self._cells) < target_cells:
333
+ self.end_cell()
334
+ if target_cells:
335
+ self.rows.append(RtfTableRow(cells=self._cells, header=self._row_header))
336
+ self._total_slots += target_cells
337
+ self._definitions = []
338
+ self._cells = []
339
+ self._cell_blocks = []
340
+ self._row_open = False
341
+
342
+ def finish(self) -> RtfTable | None:
343
+ """结束未闭合行并返回非空表格。"""
344
+ self.end_row()
345
+ return RtfTable(rows=self.rows) if self.rows else None
346
+
347
+
348
+ @dataclass(slots=True)
349
+ class _OutputContext:
350
+ """隔离正文、脚注及页眉页脚各自的块和表格状态。"""
351
+
352
+ blocks: list[RtfBlock] = field(default_factory=list)
353
+ inlines: list[RtfInline] = field(default_factory=list)
354
+ text_fragments: list[str] = field(default_factory=list)
355
+ text_style: RtfTextStyle | None = None
356
+ pending_list_label: str | None = None
357
+ tables: dict[int, _TableBuilder] = field(default_factory=dict)
358
+
359
+
360
+ @dataclass(slots=True)
361
+ class _PictCapture:
362
+ """保存 pict destination 的格式、hex 和 bin 数据。"""
363
+
364
+ content_type: str = "application/octet-stream"
365
+ extension: str = "bin"
366
+ hex_data: bytearray = field(default_factory=bytearray)
367
+ binary: bytes | None = None
368
+ alt: str = ""
369
+
370
+
371
+ @dataclass(slots=True)
372
+ class _GroupFrame:
373
+ """保存一个 group 的父状态和需要在右花括号处收束的 destination。"""
374
+
375
+ previous_state: _State
376
+ start: int
377
+ ignorable: bool = False
378
+ destination: str | None = None
379
+ parent_context: _OutputContext | None = None
380
+ inline_start: int = 0
381
+ capture_text: list[str] = field(default_factory=list)
382
+ instruction: list[str] = field(default_factory=list)
383
+ pict: _PictCapture | None = None
384
+ note_kind: Literal["footnote", "endnote", "annotation"] = "footnote"
385
+ upr_child_count: int = 0
386
+
387
+
388
+ @dataclass(frozen=True, slots=True)
389
+ class _GroupSpan:
390
+ """用原始 RTF buffer 上的起止位置表示 group,避免嵌套切片复制。"""
391
+
392
+ data: bytes
393
+ start: int
394
+ end: int
395
+
396
+ def materialize(self) -> bytes:
397
+ """仅在实际解析匹配 destination 时物化当前 group。"""
398
+ return self.data[self.start : self.end]
399
+
400
+
401
+ def _lookup_encoding(code_page: int, fallback: str = "cp1252") -> str:
402
+ """把 RTF code page 规范为 Python codec,不支持时返回稳定 fallback。"""
403
+ candidate = f"cp{code_page}"
404
+ try:
405
+ codecs.lookup(candidate)
406
+ except LookupError:
407
+ logger.warning("Unsupported RTF code page {}, falling back to {}", code_page, fallback)
408
+ return fallback
409
+ return candidate
410
+
411
+
412
+ def _default_encoding(data: bytes) -> str:
413
+ """按 RTF header 控制确定默认字符编码。"""
414
+ match = re.search(rb"\\ansicpg(?P<code>\d+)", data[:65536], re.IGNORECASE)
415
+ if match is not None:
416
+ return _lookup_encoding(int(match.group("code")))
417
+ if re.search(rb"\\mac(?:\D|$)", data[:1024], re.IGNORECASE):
418
+ return "mac_roman"
419
+ if re.search(rb"\\pca(?:\D|$)", data[:1024], re.IGNORECASE):
420
+ return "cp850"
421
+ if re.search(rb"\\pc(?:\D|$)", data[:1024], re.IGNORECASE):
422
+ return "cp437"
423
+ return "cp1252"
424
+
425
+
426
+ def _group_spans(data: bytes, *, direct_only: bool = False) -> list[_GroupSpan]:
427
+ """按二进制安全 token 边界返回共享原始 buffer 的子 group 范围。"""
428
+ depth = 0
429
+ starts: list[int] = []
430
+ result: list[_GroupSpan] = []
431
+ for token in RtfLexer(data):
432
+ if isinstance(token, RtfOpen):
433
+ depth += 1
434
+ starts.append(token.start)
435
+ elif isinstance(token, RtfClose) and starts:
436
+ start = starts.pop()
437
+ if (direct_only and depth == 2) or (not direct_only and depth >= 2):
438
+ result.append(_GroupSpan(data, start, token.end))
439
+ depth = max(depth - 1, 0)
440
+ return result
441
+
442
+
443
+ def _group_destination(group: _GroupSpan) -> str | None:
444
+ """读取 group 开头最多两个 control word 以识别 destination。"""
445
+ prefix = group.data[group.start : min(group.end, group.start + 256)]
446
+ controls = list(_CONTROL_RE.finditer(prefix))
447
+ for match in controls[:3]:
448
+ name = match.group("name").decode("ascii").lower()
449
+ if name not in {"rtf", "ansi", "deff"}:
450
+ return name
451
+ return None
452
+
453
+
454
+ def _named_groups(data: bytes, destination: str) -> list[_GroupSpan]:
455
+ """返回全部匹配 destination 的零拷贝 group 范围。"""
456
+ return [group for group in _group_spans(data) if _group_destination(group) == destination]
457
+
458
+
459
+ def _decode_group_text(data: bytes, encoding: str) -> str:
460
+ """解码定义表或 metadata group 中的可见文本,不解释正文结构。"""
461
+ parts: list[str] = []
462
+ byte_buffer = bytearray()
463
+ uc_skip = 1
464
+ fallback_skip = 0
465
+ pending_high: int | None = None
466
+
467
+ def flush_byte_buffer() -> None:
468
+ """按当前代码页整体解码连续字节,保留多字节字符边界。"""
469
+ if not byte_buffer:
470
+ return
471
+ parts.append(bytes(byte_buffer).decode(encoding, errors="replace"))
472
+ byte_buffer.clear()
473
+
474
+ for token in RtfLexer(data):
475
+ if not isinstance(token, (RtfHexByte, RtfTextBytes)):
476
+ flush_byte_buffer()
477
+ if isinstance(token, RtfControlWord):
478
+ if token.name == "uc":
479
+ uc_skip = max(token.param or 0, 0)
480
+ elif token.name == "u" and token.param is not None:
481
+ unit = (token.param + 65536 if token.param < 0 else token.param) & 0xFFFF
482
+ if 0xD800 <= unit <= 0xDBFF:
483
+ pending_high = unit
484
+ elif 0xDC00 <= unit <= 0xDFFF and pending_high is not None:
485
+ parts.append(chr(0x10000 + ((pending_high - 0xD800) << 10) + unit - 0xDC00))
486
+ pending_high = None
487
+ else:
488
+ if pending_high is not None:
489
+ parts.append("\ufffd")
490
+ pending_high = None
491
+ parts.append(chr(unit) if not 0xD800 <= unit <= 0xDFFF else "\ufffd")
492
+ fallback_skip = uc_skip
493
+ elif token.name in _SPECIAL_WORDS:
494
+ parts.append(_SPECIAL_WORDS[token.name])
495
+ elif isinstance(token, RtfHexByte):
496
+ if fallback_skip:
497
+ fallback_skip -= 1
498
+ else:
499
+ byte_buffer.append(token.value)
500
+ elif isinstance(token, RtfTextBytes):
501
+ raw = token.data.replace(b"\r", b"").replace(b"\n", b"")
502
+ if fallback_skip:
503
+ skipped = min(fallback_skip, len(raw))
504
+ raw = raw[skipped:]
505
+ fallback_skip -= skipped
506
+ if raw:
507
+ byte_buffer.extend(raw)
508
+ elif isinstance(token, RtfControlSymbol):
509
+ if token.symbol in {"\\", "{", "}"}:
510
+ parts.append(token.symbol)
511
+ elif token.symbol == "~":
512
+ parts.append("\u00a0")
513
+ elif token.symbol == "_":
514
+ parts.append("-")
515
+ flush_byte_buffer()
516
+ if pending_high is not None:
517
+ parts.append("\ufffd")
518
+ return "".join(parts)
519
+
520
+
521
+ def _parse_fonts(data: bytes, default: str) -> dict[int, str]:
522
+ """解析 fonttbl 中 font id 到字符编码的映射。"""
523
+ groups = _named_groups(data, "fonttbl")
524
+ if not groups:
525
+ return {}
526
+ result: dict[int, str] = {}
527
+ font_table = groups[0].materialize()
528
+ for font_span in _group_spans(font_table, direct_only=True):
529
+ font_group = font_span.materialize()
530
+ font_match = re.search(rb"\\f(?P<id>\d+)(?:\D|$)", font_group)
531
+ if font_match is None:
532
+ continue
533
+ font_id = int(font_match.group("id"))
534
+ cpg_match = re.search(rb"\\cpg(?P<code>\d+)", font_group)
535
+ charset_match = re.search(rb"\\fcharset(?P<charset>\d+)", font_group)
536
+ if cpg_match is not None:
537
+ result[font_id] = _lookup_encoding(int(cpg_match.group("code")), default)
538
+ elif charset_match is not None:
539
+ result[font_id] = _CHARSET_ENCODINGS.get(int(charset_match.group("charset")), default)
540
+ else:
541
+ result[font_id] = default
542
+ return result
543
+
544
+
545
+ def _style_control_value(data: bytes, name: str) -> bool | None:
546
+ """读取样式 on/off control 的三态值,缺省时返回空。"""
547
+ pattern = re.compile(
548
+ rb"\\" + name.encode("ascii") + rb"(?P<param>-?\d+)?(?=[^A-Za-z]|$)",
549
+ re.IGNORECASE,
550
+ )
551
+ matches = list(pattern.finditer(data))
552
+ if not matches:
553
+ return None
554
+ raw_param = matches[-1].group("param")
555
+ return raw_param is None or int(raw_param) != 0
556
+
557
+
558
+ def _parse_styles(data: bytes, encoding: str) -> dict[int, _StyleDefinition]:
559
+ """解析 stylesheet 的标题、outline、代码与引用样式。"""
560
+ groups = _named_groups(data, "stylesheet")
561
+ if not groups:
562
+ return {}
563
+ result: dict[int, _StyleDefinition] = {}
564
+ stylesheet = groups[0].materialize()
565
+ for style_span in _group_spans(stylesheet, direct_only=True):
566
+ style_group = style_span.materialize()
567
+ style_match = re.search(rb"\\s(?P<id>-?\d+)(?:\D|$)", style_group)
568
+ if style_match is None:
569
+ continue
570
+ style_id = int(style_match.group("id"))
571
+ name = _decode_group_text(style_group, encoding).strip().rstrip(";").strip()
572
+ normalized = name.casefold()
573
+ outline_match = re.search(rb"\\outlinelevel(?P<level>\d+)", style_group)
574
+ outline = int(outline_match.group("level")) if outline_match is not None else None
575
+ if outline is None:
576
+ heading_name = re.fullmatch(r"heading\s+([1-9])", normalized)
577
+ if heading_name is not None:
578
+ outline = int(heading_name.group(1)) - 1
579
+ based_on_match = re.search(rb"\\sbasedon(?P<id>-?\d+)", style_group)
580
+ based_on = int(based_on_match.group("id")) if based_on_match is not None else None
581
+ if "code" in normalized or normalized in {"macro", "macro text"}:
582
+ block_style = "code"
583
+ elif "quote" in normalized:
584
+ block_style = "quote"
585
+ else:
586
+ block_style = _BLOCK_STYLE_NAMES.get(normalized, "normal")
587
+ text_style_overrides = _TextStyleOverrides(
588
+ bold=_style_control_value(style_group, "b"),
589
+ italic=_style_control_value(style_group, "i"),
590
+ underline=_style_control_value(style_group, "ul"),
591
+ strike=_style_control_value(style_group, "strike"),
592
+ )
593
+ result[style_id] = _StyleDefinition(
594
+ name=name,
595
+ outline_level=outline,
596
+ is_title=normalized in _TITLE_STYLE_NAMES,
597
+ block_style=block_style, # type: ignore[arg-type]
598
+ based_on=based_on,
599
+ text_style=text_style_overrides.resolve(),
600
+ text_style_overrides=text_style_overrides,
601
+ )
602
+
603
+ resolved: dict[int, _StyleDefinition] = {}
604
+
605
+ def resolve(style_id: int, visiting: set[int]) -> _StyleDefinition:
606
+ """递归合并 based-on 样式,循环引用时保留当前显式属性。"""
607
+ if style_id in resolved:
608
+ return resolved[style_id]
609
+ current = result.get(style_id, _StyleDefinition())
610
+ if current.based_on is None or current.based_on in visiting:
611
+ resolved[style_id] = current
612
+ return current
613
+ base = resolve(current.based_on, {*visiting, style_id})
614
+ merged_style = current.text_style_overrides.resolve(base.text_style)
615
+ merged = _StyleDefinition(
616
+ name=current.name,
617
+ outline_level=current.outline_level if current.outline_level is not None else base.outline_level,
618
+ is_title=current.is_title or base.is_title,
619
+ block_style=current.block_style if current.block_style != "normal" else base.block_style,
620
+ based_on=current.based_on,
621
+ text_style=merged_style,
622
+ text_style_overrides=current.text_style_overrides,
623
+ )
624
+ resolved[style_id] = merged
625
+ return merged
626
+
627
+ for style_id in result:
628
+ resolve(style_id, set())
629
+ return resolved
630
+
631
+
632
+ def _parse_lists(data: bytes) -> dict[int, _ListDefinition]:
633
+ """解析 listtable/listoverridetable 的常见编号格式与 override identity。"""
634
+ by_list_id: dict[int, tuple[_ListLevel, ...]] = {}
635
+ list_tables = _named_groups(data, "listtable")
636
+ if list_tables:
637
+ list_table = list_tables[0].materialize()
638
+ for list_span in _named_groups(list_table, "list"):
639
+ list_group = list_span.materialize()
640
+ id_match = re.search(rb"\\listid(?P<id>-?\d+)", list_group)
641
+ if id_match is None:
642
+ continue
643
+ levels: list[_ListLevel] = []
644
+ for level_span in _named_groups(list_group, "listlevel")[: MAX_RTF_LIST_DEPTH + 1]:
645
+ level_group = level_span.materialize()
646
+ nfc_match = re.search(rb"\\levelnfc(?P<nfc>\d+)", level_group)
647
+ start_match = re.search(rb"\\levelstartat(?P<start>-?\d+)", level_group)
648
+ nfc = int(nfc_match.group("nfc")) if nfc_match is not None else 0
649
+ start = int(start_match.group("start")) if start_match is not None else 1
650
+ levels.append(_ListLevel(marker=_NFC_MARKERS.get(nfc, "decimal"), start=max(start, 0)))
651
+ if not levels:
652
+ levels.append(_ListLevel())
653
+ by_list_id[int(id_match.group("id"))] = tuple(levels)
654
+
655
+ result: dict[int, _ListDefinition] = {}
656
+ override_tables = _named_groups(data, "listoverridetable")
657
+ if override_tables:
658
+ override_table = override_tables[0].materialize()
659
+ for override_span in _named_groups(override_table, "listoverride"):
660
+ override_group = override_span.materialize()
661
+ id_match = re.search(rb"\\listid(?P<id>-?\d+)", override_group)
662
+ ls_match = re.search(rb"\\ls(?P<ls>\d+)", override_group)
663
+ if id_match is None or ls_match is None:
664
+ continue
665
+ list_id = int(id_match.group("id"))
666
+ ls = int(ls_match.group("ls"))
667
+ levels = list(by_list_id.get(list_id, (_ListLevel(),)))
668
+ for level_index, level_span in enumerate(_named_groups(override_group, "lfolevel")):
669
+ level_override = level_span.materialize()
670
+ while len(levels) <= level_index:
671
+ levels.append(_ListLevel())
672
+ current = levels[level_index]
673
+ start_match = re.search(rb"\\levelstartat(?P<start>-?\d+)", level_override)
674
+ nfc_match = re.search(rb"\\levelnfc(?P<nfc>\d+)", level_override)
675
+ start = max(int(start_match.group("start")), 0) if start_match is not None else current.start
676
+ marker = (
677
+ _NFC_MARKERS.get(int(nfc_match.group("nfc")), current.marker) if nfc_match is not None else current.marker
678
+ )
679
+ levels[level_index] = _ListLevel(marker=marker, start=start)
680
+ result[ls] = _ListDefinition(identity=ls, levels=tuple(levels))
681
+ return result
682
+
683
+
684
+ def _parse_metadata(data: bytes, encoding: str) -> RtfMetadata:
685
+ """解析 info destination 中允许公开的四个字符串字段。"""
686
+ info_groups = _named_groups(data, "info")
687
+ if not info_groups:
688
+ return RtfMetadata()
689
+ info_group = info_groups[0].materialize()
690
+ values: dict[str, str | None] = {}
691
+ for name in ("title", "author", "subject", "keywords"):
692
+ groups = _named_groups(info_group, name)
693
+ value = _decode_group_text(groups[0].materialize(), encoding).strip() if groups else ""
694
+ values[name] = value or None
695
+ return RtfMetadata(**values)
696
+
697
+
698
+ def parse_rtf_prelude(data: bytes) -> _Prelude:
699
+ """解析 RTF header tables、列表和 metadata,供正文 parser 与 doclib 共用。"""
700
+ offset = rtf_header_offset(data[:128])
701
+ if offset is None:
702
+ raise LegacyOfficeMalformedError("not an RTF document")
703
+ normalized = data[offset:]
704
+ default = _default_encoding(normalized)
705
+ return _Prelude(
706
+ default_encoding=default,
707
+ font_encodings=_parse_fonts(normalized, default),
708
+ styles=_parse_styles(normalized, default),
709
+ lists=_parse_lists(normalized),
710
+ metadata=_parse_metadata(normalized, default),
711
+ )
712
+
713
+
714
+ def _roman(value: int) -> str:
715
+ """把受支持正整数格式化为 Roman 编号,越界时安全回退十进制。"""
716
+ if value <= 0 or value > MAX_RTF_ROMAN_VALUE:
717
+ return str(value)
718
+ pairs = (
719
+ (1000, "M"),
720
+ (900, "CM"),
721
+ (500, "D"),
722
+ (400, "CD"),
723
+ (100, "C"),
724
+ (90, "XC"),
725
+ (50, "L"),
726
+ (40, "XL"),
727
+ (10, "X"),
728
+ (9, "IX"),
729
+ (5, "V"),
730
+ (4, "IV"),
731
+ (1, "I"),
732
+ )
733
+ parts: list[str] = []
734
+ remaining = value
735
+ for number, label in pairs:
736
+ while remaining >= number:
737
+ parts.append(label)
738
+ remaining -= number
739
+ return "".join(parts)
740
+
741
+
742
+ def _alpha(value: int) -> str:
743
+ """把正整数格式化为 Excel 风格字母编号。"""
744
+ if value <= 0:
745
+ return str(value)
746
+ parts: list[str] = []
747
+ remaining = value
748
+ while remaining:
749
+ remaining, digit = divmod(remaining - 1, 26)
750
+ parts.append(chr(ord("A") + digit))
751
+ return "".join(reversed(parts))
752
+
753
+
754
+ def _format_marker(marker: str, value: int) -> str:
755
+ """按常见 RTF levelnfc marker 格式化一个编号。"""
756
+ if marker == "upper_roman":
757
+ return _roman(value)
758
+ if marker == "lower_roman":
759
+ return _roman(value).lower()
760
+ if marker == "upper_alpha":
761
+ return _alpha(value)
762
+ if marker == "lower_alpha":
763
+ return _alpha(value).lower()
764
+ return str(value)
765
+
766
+
767
+ def _capture_picture_group(data: bytes) -> _PictCapture | None:
768
+ """从独立 pict group 中提取 direct hex/bin,用于 Office Math 图片 fallback。"""
769
+ capture = _PictCapture()
770
+ depth = 0
771
+ for token in RtfLexer(data):
772
+ if isinstance(token, RtfOpen):
773
+ depth += 1
774
+ continue
775
+ if isinstance(token, RtfClose):
776
+ depth = max(depth - 1, 0)
777
+ continue
778
+ if isinstance(token, RtfControlWord) and depth == 1:
779
+ if token.name == "pngblip":
780
+ capture.content_type, capture.extension = "image/png", "png"
781
+ elif token.name == "jpegblip":
782
+ capture.content_type, capture.extension = "image/jpeg", "jpg"
783
+ elif token.name == "emfblip":
784
+ capture.content_type, capture.extension = "image/x-emf", "emf"
785
+ elif token.name == "wmetafile":
786
+ capture.content_type, capture.extension = "image/x-wmf", "wmf"
787
+ elif token.name in {"dibitmap", "wbitmap"}:
788
+ capture.content_type, capture.extension = "image/bmp", "dib"
789
+ elif isinstance(token, RtfBinary) and depth == 1:
790
+ capture.binary = token.data
791
+ elif isinstance(token, RtfHexByte) and depth == 1:
792
+ capture.hex_data.extend(f"{token.value:02x}".encode("ascii"))
793
+ elif isinstance(token, RtfTextBytes) and depth == 1:
794
+ capture.hex_data.extend(token.data)
795
+ if capture.binary is None and not capture.hex_data:
796
+ return None
797
+ return capture
798
+
799
+
800
+ class RtfParser:
801
+ """把一个 RTF 字节串解析为无布局、单逻辑页的 typed document。"""
802
+
803
+ def __init__(self, data: bytes, prelude: _Prelude | None = None) -> None:
804
+ """校验输入大小和根组,并初始化所有每文档状态。"""
805
+ if len(data) > MAX_RTF_BYTES:
806
+ raise LegacyOfficeResourceLimitError(f"RTF input exceeds max_bytes={MAX_RTF_BYTES}")
807
+ offset = rtf_header_offset(data[:128])
808
+ if offset is None:
809
+ raise LegacyOfficeMalformedError("not an RTF document")
810
+ self.data = data[offset:]
811
+ self.prelude = prelude or parse_rtf_prelude(data)
812
+ self.state = _State()
813
+ self.frames: list[_GroupFrame] = []
814
+ self.context = _OutputContext()
815
+ self.document = RtfDocument(metadata=self.prelude.metadata)
816
+ self._byte_buffer = bytearray()
817
+ self._fallback_skip = 0
818
+ self._pending_high_surrogate: int | None = None
819
+ self._list_counters: dict[tuple[int, int], int] = {}
820
+ self._asset_total = 0
821
+ self._recovered = False
822
+
823
+ def parse(self) -> RtfDocument:
824
+ """运行状态机,恢复未闭合组并返回 typed RTF 文档。"""
825
+ for token in RtfLexer(self.data):
826
+ if isinstance(token, RtfOpen):
827
+ self._open_group(token)
828
+ elif isinstance(token, RtfClose):
829
+ self._close_group(token)
830
+ elif isinstance(token, RtfControlSymbol):
831
+ self._control_symbol(token)
832
+ elif isinstance(token, RtfControlWord):
833
+ self._control_word(token)
834
+ elif isinstance(token, RtfHexByte):
835
+ self._hex_byte(token)
836
+ elif isinstance(token, RtfTextBytes):
837
+ self._text_bytes(token)
838
+ elif isinstance(token, RtfBinary):
839
+ self._binary(token)
840
+
841
+ self._flush_bytes()
842
+ while self.frames:
843
+ self._recovered = True
844
+ frame = self.frames.pop()
845
+ self._finish_destination(frame, len(self.data))
846
+ self.state = frame.previous_state
847
+ self._finalize_context(self.context)
848
+ self.document.blocks = self.context.blocks
849
+ if self._recovered:
850
+ logger.warning("Recovered unbalanced RTF groups")
851
+ return self.document
852
+
853
+ def _open_group(self, token: RtfOpen) -> None:
854
+ """压入当前状态,新的 group 初始继承所有属性。"""
855
+ self._flush_bytes()
856
+ parent = self._current_frame()
857
+ self.frames.append(_GroupFrame(previous_state=replace(self.state), start=token.start))
858
+ if parent is not None and parent.destination == "upr":
859
+ parent.upr_child_count += 1
860
+ if parent.upr_child_count == 2:
861
+ self.state.destination = "upr_unicode"
862
+ else:
863
+ self.state.destination = "suppressed"
864
+
865
+ def _close_group(self, token: RtfClose) -> None:
866
+ """收束当前 destination 并恢复父 group 状态。"""
867
+ self._flush_bytes()
868
+ if not self.frames:
869
+ self._recovered = True
870
+ return
871
+ frame = self.frames.pop()
872
+ self._finish_destination(frame, token.end)
873
+ self.state = frame.previous_state
874
+
875
+ def _current_frame(self) -> _GroupFrame | None:
876
+ """返回当前最内层 group frame。"""
877
+ return self.frames[-1] if self.frames else None
878
+
879
+ def _nearest_frame(self, destination: str) -> _GroupFrame | None:
880
+ """从内向外查找负责指定 destination 的 frame。"""
881
+ return next((frame for frame in reversed(self.frames) if frame.destination == destination), None)
882
+
883
+ def _start_destination(self, name: str) -> bool:
884
+ """识别 group destination 并初始化其隔离输出或捕获状态。"""
885
+ frame = self._current_frame()
886
+ if frame is None or frame.destination is not None:
887
+ return False
888
+ if name == "ud" and self.state.destination == "upr_unicode":
889
+ upr_frame = self._nearest_frame("upr")
890
+ if upr_frame is not None:
891
+ frame.destination = "ud"
892
+ self.state.destination = upr_frame.previous_state.destination
893
+ return True
894
+ if self.state.destination in {"math", "pict", "suppressed"}:
895
+ return False
896
+ if name == "upr":
897
+ frame.destination = "upr"
898
+ self.state.destination = "upr"
899
+ return True
900
+ if name in _SUPPRESSED_DESTINATIONS:
901
+ frame.destination = name
902
+ self.state.destination = "suppressed"
903
+ return True
904
+ if name == "field":
905
+ self._flush_text_run()
906
+ frame.destination = "field"
907
+ frame.inline_start = len(self.context.inlines)
908
+ return True
909
+ if name == "object":
910
+ frame.destination = "object"
911
+ self.state.destination = "object"
912
+ return True
913
+ if name == "result" and self.state.destination == "object":
914
+ frame.destination = "result"
915
+ self.state.destination = "body"
916
+ return True
917
+ if name == "fldinst":
918
+ frame.destination = "fldinst"
919
+ self.state.destination = "field_instruction"
920
+ return True
921
+ if name == "fldrslt":
922
+ frame.destination = "fldrslt"
923
+ self.state.destination = "body"
924
+ return True
925
+ if name in {"footnote", "endnote", "annotation"}:
926
+ frame.destination = "note"
927
+ frame.note_kind = "endnote" if name == "endnote" else "annotation" if name == "annotation" else "footnote"
928
+ frame.parent_context = self.context
929
+ self.context = _OutputContext()
930
+ self.state.destination = "body"
931
+ if name == "annotation":
932
+ self.state.hidden = False
933
+ return True
934
+ if name in _HEADER_DESTINATIONS | _FOOTER_DESTINATIONS:
935
+ frame.destination = "header" if name in _HEADER_DESTINATIONS else "footer"
936
+ frame.parent_context = self.context
937
+ self.context = _OutputContext()
938
+ self.state.destination = "body"
939
+ return True
940
+ if name == "pict":
941
+ frame.destination = "pict"
942
+ frame.pict = _PictCapture()
943
+ self.state.destination = "pict"
944
+ return True
945
+ if name == "mmath":
946
+ frame.destination = "math"
947
+ self.state.destination = "math"
948
+ return True
949
+ if name in {"listtext", "pntext"}:
950
+ frame.destination = "listtext"
951
+ self.state.destination = "listtext"
952
+ return True
953
+ if name == "bkmkstart":
954
+ frame.destination = "bookmark"
955
+ self.state.destination = "bookmark"
956
+ return True
957
+ if name == "bkmkend":
958
+ frame.destination = "bookmark_end"
959
+ self.state.destination = "suppressed"
960
+ return True
961
+ if name == "nonshppict":
962
+ frame.destination = name
963
+ self.state.destination = "suppressed"
964
+ return True
965
+ if name == "shppict":
966
+ frame.destination = name
967
+ return True
968
+ if name == "nesttableprops":
969
+ frame.destination = name
970
+ return True
971
+ if name == "pn":
972
+ frame.destination = name
973
+ self.state.list_override_id = self.state.list_override_id or -1
974
+ return True
975
+ if frame.ignorable:
976
+ frame.destination = "unknown"
977
+ self.state.destination = "suppressed"
978
+ return True
979
+ return False
980
+
981
+ def _finish_destination(self, frame: _GroupFrame, end: int) -> None:
982
+ """在 group 结束处物化 field、note、pict、math 和捕获文本。"""
983
+ destination = frame.destination
984
+ if destination == "field":
985
+ self._finish_field(frame)
986
+ elif destination == "note":
987
+ self._finish_note(frame)
988
+ elif destination in {"header", "footer"}:
989
+ self._finish_auxiliary(frame, destination)
990
+ elif destination == "pict":
991
+ self._finish_picture(frame)
992
+ elif destination == "math":
993
+ self._finish_math(frame, end)
994
+ elif destination == "listtext":
995
+ label = "".join(frame.capture_text).replace("\t", " ").strip()
996
+ if label:
997
+ self.context.pending_list_label = label
998
+ elif destination == "bookmark":
999
+ name = "".join(frame.capture_text).strip()
1000
+ if name:
1001
+ self._append_inline(RtfAnchor(name))
1002
+
1003
+ def _finish_field(self, frame: _GroupFrame) -> None:
1004
+ """把安全 HYPERLINK field result 包装回行内 run。"""
1005
+ self._flush_text_run()
1006
+ instruction = "".join(frame.instruction).strip()
1007
+ match = _HYPERLINK_RE.search(instruction)
1008
+ if match is None:
1009
+ return
1010
+ raw_target = match.group("quoted") or match.group("bare") or ""
1011
+ candidate = f"#{raw_target.lstrip('#')}" if match.group("local") else raw_target
1012
+ target = sanitize_hyperlink_target(candidate, allow_relative=True, allow_fragment=True)
1013
+ if target is None:
1014
+ return
1015
+ start = min(frame.inline_start, len(self.context.inlines))
1016
+ for index in range(start, len(self.context.inlines)):
1017
+ inline = self.context.inlines[index]
1018
+ if isinstance(inline, RtfTextRun):
1019
+ self.context.inlines[index] = replace(inline, hyperlink=target)
1020
+
1021
+ def _finish_note(self, frame: _GroupFrame) -> None:
1022
+ """结束隔离 note context,登记正文并按类型决定是否插入引用。"""
1023
+ note_context = self.context
1024
+ self._finalize_context(note_context)
1025
+ parent = frame.parent_context or _OutputContext()
1026
+ self.context = parent
1027
+ if not note_context.blocks:
1028
+ return
1029
+ note_id = f"rtf{len(self.document.notes)}"
1030
+ self.document.notes.append(RtfNote(note_id, frame.note_kind, note_context.blocks))
1031
+ if frame.note_kind != "annotation":
1032
+ self._append_inline(RtfNoteReference(note_id))
1033
+
1034
+ def _finish_auxiliary(self, frame: _GroupFrame, destination: str) -> None:
1035
+ """结束页眉页脚隔离 context,并恢复父正文。"""
1036
+ auxiliary_context = self.context
1037
+ self._finalize_context(auxiliary_context)
1038
+ self.context = frame.parent_context or _OutputContext()
1039
+ target = self.document.headers if destination == "header" else self.document.footers
1040
+ target.extend(auxiliary_context.blocks)
1041
+
1042
+ def _finish_picture(self, frame: _GroupFrame) -> None:
1043
+ """校验 pict 大小并向当前行内流追加图片。"""
1044
+ capture = frame.pict
1045
+ if capture is None:
1046
+ return
1047
+ image = self._materialize_picture(capture)
1048
+ if image is not None:
1049
+ self._append_inline(image)
1050
+
1051
+ def _materialize_picture(self, capture: _PictCapture) -> RtfImage | None:
1052
+ """把已捕获 pict 转成有界图片载荷,并累计文档素材预算。"""
1053
+ if capture.binary is not None:
1054
+ payload = capture.binary
1055
+ else:
1056
+ compact = bytes(character for character in capture.hex_data if chr(character).strip())
1057
+ if len(compact) % 2:
1058
+ logger.warning("Skipping RTF pict with odd hex length")
1059
+ return
1060
+ try:
1061
+ payload = bytes.fromhex(compact.decode("ascii"))
1062
+ except (UnicodeDecodeError, ValueError):
1063
+ logger.warning("Skipping malformed RTF pict hex payload")
1064
+ return
1065
+ if not payload:
1066
+ return
1067
+ if len(payload) > MAX_ENTRY_BYTES:
1068
+ raise LegacyOfficeResourceLimitError(f"RTF pict exceeds max_entry_bytes={MAX_ENTRY_BYTES}")
1069
+ if self._asset_total + len(payload) > MAX_ASSET_TOTAL_BYTES:
1070
+ raise LegacyOfficeResourceLimitError(f"RTF pict assets exceed max_asset_total_bytes={MAX_ASSET_TOTAL_BYTES}")
1071
+ self._asset_total += len(payload)
1072
+ return RtfImage(
1073
+ data=payload,
1074
+ content_type=capture.content_type,
1075
+ part_name=f"pict.{capture.extension}",
1076
+ alt=capture.alt,
1077
+ )
1078
+
1079
+ def _finish_math(self, frame: _GroupFrame, end: int) -> None:
1080
+ """把 math group 转换为行内或行间 LaTeX,失败时静默保留其余正文。"""
1081
+ formulas, display = parse_rtf_math(
1082
+ self.data[frame.start : end],
1083
+ encoding=self._current_encoding(),
1084
+ )
1085
+ if not formulas:
1086
+ for group in _named_groups(self.data[frame.start : end], "pict"):
1087
+ capture = _capture_picture_group(group.materialize())
1088
+ if capture is None:
1089
+ continue
1090
+ image = self._materialize_picture(capture)
1091
+ if image is not None:
1092
+ self._append_inline(image)
1093
+ break
1094
+ return
1095
+ if display:
1096
+ self._end_paragraph()
1097
+ self._flush_tables()
1098
+ self.context.blocks.extend(RtfDisplayEquation(formula) for formula in formulas)
1099
+ else:
1100
+ for formula in formulas:
1101
+ self._append_inline(RtfInlineEquation(formula))
1102
+
1103
+ def _control_symbol(self, token: RtfControlSymbol) -> None:
1104
+ """处理 ignorable marker、转义结构字符和特殊空白。"""
1105
+ self._flush_bytes()
1106
+ frame = self._current_frame()
1107
+ if token.symbol == "*" and frame is not None:
1108
+ frame.ignorable = True
1109
+ return
1110
+ if self._fallback_skip:
1111
+ self._fallback_skip -= 1
1112
+ return
1113
+ if token.symbol == "\n":
1114
+ self._end_paragraph()
1115
+ elif token.symbol in {"\\", "{", "}"}:
1116
+ self._append_text(token.symbol)
1117
+ elif token.symbol == "~":
1118
+ self._append_text("\u00a0")
1119
+ elif token.symbol == "_":
1120
+ self._append_text("-")
1121
+
1122
+ def _control_word(self, token: RtfControlWord) -> None:
1123
+ """按 destination、文本、表格和列表的固定顺序解释 control word。"""
1124
+ self._flush_bytes()
1125
+ if self._start_destination(token.name):
1126
+ return
1127
+ if token.name == "ftnalt":
1128
+ note_frame = self._nearest_frame("note")
1129
+ if note_frame is not None:
1130
+ note_frame.note_kind = "endnote"
1131
+ return
1132
+ if self.state.destination == "pict":
1133
+ self._pict_control(token)
1134
+ return
1135
+ if self.state.destination in {"math", "suppressed"}:
1136
+ return
1137
+ if token.name == "u":
1138
+ self._unicode(token.param)
1139
+ return
1140
+ if token.name == "uc":
1141
+ self.state.uc_skip = max(token.param or 0, 0)
1142
+ return
1143
+ if token.name == "f":
1144
+ self.state.font_id = token.param
1145
+ return
1146
+ if token.name == "s" and token.param is not None:
1147
+ self.state.paragraph_style_id = token.param
1148
+ definition = self.prelude.styles.get(token.param)
1149
+ if definition is not None and definition.outline_level is not None:
1150
+ self.state.outline_level = definition.outline_level
1151
+ if definition is not None:
1152
+ self.state.style = definition.text_style
1153
+ return
1154
+ if token.name == "outlinelevel" and token.param is not None:
1155
+ self.state.outline_level = max(token.param, 0)
1156
+ return
1157
+ if token.name == "b":
1158
+ self.state.style = replace(self.state.style, bold=token.param != 0)
1159
+ return
1160
+ if token.name == "i":
1161
+ self.state.style = replace(self.state.style, italic=token.param != 0)
1162
+ return
1163
+ if token.name in {"ul", "uld", "uldash", "uldb", "ulth", "ulw"}:
1164
+ self.state.style = replace(self.state.style, underline=token.param != 0)
1165
+ return
1166
+ if token.name in {"ulnone", "ul0"}:
1167
+ self.state.style = replace(self.state.style, underline=False)
1168
+ return
1169
+ if token.name in {"strike", "striked"}:
1170
+ self.state.style = replace(self.state.style, strike=token.param != 0)
1171
+ return
1172
+ if token.name == "super":
1173
+ self.state.style = replace(self.state.style, superscript=True, subscript=False)
1174
+ return
1175
+ if token.name == "sub":
1176
+ self.state.style = replace(self.state.style, superscript=False, subscript=True)
1177
+ return
1178
+ if token.name == "nosupersub":
1179
+ self.state.style = replace(self.state.style, superscript=False, subscript=False)
1180
+ return
1181
+ if token.name == "v":
1182
+ self.state.hidden = token.param != 0
1183
+ return
1184
+ if token.name == "plain":
1185
+ self.state.style = RtfTextStyle()
1186
+ self.state.hidden = False
1187
+ return
1188
+ if token.name == "pard":
1189
+ if self.state.table_depth > 1:
1190
+ self._set_table_depth(self.state.table_depth - 1)
1191
+ self.state.table_depth = 0
1192
+ self.state.in_table = False
1193
+ self.state.paragraph_style_id = None
1194
+ self.state.outline_level = None
1195
+ self.state.list_override_id = None
1196
+ self.state.list_level = 0
1197
+ return
1198
+ if token.name in {"par", "sect"}:
1199
+ self._end_paragraph()
1200
+ return
1201
+ if token.name in {"page", "column", "lbr"}:
1202
+ self._append_inline(RtfLineBreak())
1203
+ return
1204
+ if token.name in _SPECIAL_WORDS:
1205
+ self._append_text(_SPECIAL_WORDS[token.name])
1206
+ return
1207
+ if token.name == "chftn":
1208
+ return
1209
+ if self._table_control(token):
1210
+ return
1211
+ self._list_control(token)
1212
+
1213
+ def _table_control(self, token: RtfControlWord) -> bool:
1214
+ """解释常见 row/cell/table-depth 与合并控制。"""
1215
+ name = token.name
1216
+ if name == "itap":
1217
+ self._set_table_depth(max(token.param or 0, 0))
1218
+ return True
1219
+ depth = max(self.state.table_depth, 1)
1220
+ if name == "trowd":
1221
+ self.state.in_table = True
1222
+ self.state.list_override_id = None
1223
+ self.state.list_level = 0
1224
+ self.context.pending_list_label = None
1225
+ if self.state.table_depth == 0:
1226
+ self.state.table_depth = 1
1227
+ self._table_builder(depth).start_row()
1228
+ return True
1229
+ if name == "intbl":
1230
+ self.state.in_table = token.param != 0
1231
+ if self.state.in_table and self.state.table_depth == 0:
1232
+ self.state.table_depth = 1
1233
+ return True
1234
+ if name == "trhdr":
1235
+ self._table_builder(depth).set_header()
1236
+ return True
1237
+ if name == "clmgf":
1238
+ self._table_builder(depth).set_horizontal_merge("start")
1239
+ return True
1240
+ if name == "clmrg":
1241
+ self._table_builder(depth).set_horizontal_merge("continue")
1242
+ return True
1243
+ if name == "clvmgf":
1244
+ self._table_builder(depth).set_vertical_merge("start")
1245
+ return True
1246
+ if name == "clvmrg":
1247
+ self._table_builder(depth).set_vertical_merge("continue")
1248
+ return True
1249
+ if name == "cellx":
1250
+ self._table_builder(depth).add_definition(token.param)
1251
+ return True
1252
+ if name in {"cell", "nestcell"}:
1253
+ self._end_paragraph()
1254
+ self._table_builder(depth).end_cell()
1255
+ return True
1256
+ if name in {"row", "nestrow"}:
1257
+ self._end_paragraph()
1258
+ self._table_builder(depth).end_row()
1259
+ return True
1260
+ return False
1261
+
1262
+ def _list_control(self, token: RtfControlWord) -> bool:
1263
+ """记录现代 ls/ilvl 及常见 legacy pn marker。"""
1264
+ if token.name == "ls" and token.param is not None:
1265
+ self.state.list_override_id = token.param
1266
+ return True
1267
+ if token.name == "ilvl" and token.param is not None:
1268
+ self.state.list_level = min(max(token.param, 0), MAX_RTF_LIST_DEPTH)
1269
+ return True
1270
+ if token.name == "pnlvlblt":
1271
+ self.state.list_override_id = self.state.list_override_id or -1
1272
+ return True
1273
+ if token.name in {"pndec", "pnucrm", "pnlcrm", "pnucltr", "pnlcltr"}:
1274
+ self.state.list_override_id = self.state.list_override_id or -2
1275
+ return True
1276
+ return False
1277
+
1278
+ def _pict_control(self, token: RtfControlWord) -> None:
1279
+ """记录 pict 格式;尺寸与裁剪控制不影响无布局语义。"""
1280
+ frame = self._nearest_frame("pict")
1281
+ capture = frame.pict if frame is not None else None
1282
+ if capture is None:
1283
+ return
1284
+ if token.name == "pngblip":
1285
+ capture.content_type, capture.extension = "image/png", "png"
1286
+ elif token.name == "jpegblip":
1287
+ capture.content_type, capture.extension = "image/jpeg", "jpg"
1288
+ elif token.name == "emfblip":
1289
+ capture.content_type, capture.extension = "image/x-emf", "emf"
1290
+ elif token.name == "wmetafile":
1291
+ capture.content_type, capture.extension = "image/x-wmf", "wmf"
1292
+ elif token.name in {"dibitmap", "wbitmap"}:
1293
+ capture.content_type, capture.extension = "image/bmp", "dib"
1294
+
1295
+ def _hex_byte(self, token: RtfHexByte) -> None:
1296
+ """把 hex byte 送入 pict 或当前代码页缓冲。"""
1297
+ if self.state.destination == "pict":
1298
+ frame = self._nearest_frame("pict")
1299
+ if frame is not None and frame is self._current_frame() and frame.pict is not None:
1300
+ frame.pict.hex_data.extend(f"{token.value:02x}".encode("ascii"))
1301
+ return
1302
+ if self.state.destination in {"math", "suppressed"}:
1303
+ return
1304
+ if self._fallback_skip:
1305
+ self._fallback_skip -= 1
1306
+ return
1307
+ self._byte_buffer.append(token.value)
1308
+
1309
+ def _text_bytes(self, token: RtfTextBytes) -> None:
1310
+ """规范源换行后把文本字节送入 destination 或代码页缓冲。"""
1311
+ if self.state.destination == "pict":
1312
+ frame = self._nearest_frame("pict")
1313
+ if frame is not None and frame is self._current_frame() and frame.pict is not None:
1314
+ frame.pict.hex_data.extend(token.data)
1315
+ return
1316
+ if self.state.destination in {"math", "suppressed"}:
1317
+ return
1318
+ raw = token.data.replace(b"\r", b"").replace(b"\n", b"")
1319
+ if self._fallback_skip:
1320
+ skipped = min(self._fallback_skip, len(raw))
1321
+ raw = raw[skipped:]
1322
+ self._fallback_skip -= skipped
1323
+ self._byte_buffer.extend(raw)
1324
+
1325
+ def _binary(self, token: RtfBinary) -> None:
1326
+ """只允许 pict destination 消费 bin 载荷,其他二进制内容直接跳过。"""
1327
+ if self.state.destination != "pict":
1328
+ return
1329
+ frame = self._nearest_frame("pict")
1330
+ if frame is not None and frame is self._current_frame() and frame.pict is not None:
1331
+ frame.pict.binary = token.data
1332
+
1333
+ def _unicode(self, value: int | None) -> None:
1334
+ """解码有符号 UTF-16 code unit,合并代理对并启动 fallback skip。"""
1335
+ if value is None:
1336
+ return
1337
+ unit = (value + 65536 if value < 0 else value) & 0xFFFF
1338
+ if 0xD800 <= unit <= 0xDBFF:
1339
+ if self._pending_high_surrogate is not None:
1340
+ self._append_text("\ufffd")
1341
+ self._pending_high_surrogate = unit
1342
+ elif 0xDC00 <= unit <= 0xDFFF and self._pending_high_surrogate is not None:
1343
+ codepoint = 0x10000 + ((self._pending_high_surrogate - 0xD800) << 10) + unit - 0xDC00
1344
+ self._append_text(chr(codepoint))
1345
+ self._pending_high_surrogate = None
1346
+ else:
1347
+ if self._pending_high_surrogate is not None:
1348
+ self._append_text("\ufffd")
1349
+ self._pending_high_surrogate = None
1350
+ self._append_text(chr(unit) if not 0xD800 <= unit <= 0xDFFF else "\ufffd")
1351
+ self._fallback_skip = self.state.uc_skip
1352
+
1353
+ def _current_encoding(self) -> str:
1354
+ """返回当前 font 的代码页或文档默认代码页。"""
1355
+ if self.state.font_id is None:
1356
+ return self.prelude.default_encoding
1357
+ return self.prelude.font_encodings.get(self.state.font_id, self.prelude.default_encoding)
1358
+
1359
+ def _flush_bytes(self) -> None:
1360
+ """使用当前 font code page 解码累计字节并写入当前 destination。"""
1361
+ if not self._byte_buffer:
1362
+ return
1363
+ payload = bytes(self._byte_buffer)
1364
+ self._byte_buffer.clear()
1365
+ self._append_text(payload.decode(self._current_encoding(), errors="replace"))
1366
+
1367
+ def _append_text(self, text: str) -> None:
1368
+ """按当前 destination 把文本写入 field、bookmark、list label 或正文。"""
1369
+ if not text or self.state.hidden:
1370
+ return
1371
+ if self.state.destination == "field_instruction":
1372
+ field_frame = self._nearest_frame("field")
1373
+ if field_frame is not None:
1374
+ field_frame.instruction.append(text)
1375
+ return
1376
+ if self.state.destination in {"bookmark", "listtext"}:
1377
+ frame = self._nearest_frame(self.state.destination)
1378
+ if frame is not None:
1379
+ frame.capture_text.append(text)
1380
+ return
1381
+ if self.state.destination != "body":
1382
+ return
1383
+ if self.context.text_fragments and self.context.text_style != self.state.style:
1384
+ self._flush_text_run()
1385
+ self.context.text_style = self.state.style
1386
+ self.context.text_fragments.append(text)
1387
+
1388
+ def _flush_text_run(self) -> None:
1389
+ """把当前 context 的相邻文本片段一次性合并为 typed run。"""
1390
+ if not self.context.text_fragments:
1391
+ return
1392
+ style = self.context.text_style if self.context.text_style is not None else RtfTextStyle()
1393
+ self.context.inlines.append(RtfTextRun(text="".join(self.context.text_fragments), style=style))
1394
+ self.context.text_fragments.clear()
1395
+ self.context.text_style = None
1396
+
1397
+ def _append_inline(self, inline: RtfInline) -> None:
1398
+ """先冻结累计文本,再追加公式、图片、锚点等结构行内节点。"""
1399
+ self._flush_text_run()
1400
+ self.context.inlines.append(inline)
1401
+
1402
+ def _resolve_list_info(self) -> RtfListInfo | None:
1403
+ """把 paragraph 的 ls/ilvl 和精确 listtext 收敛为显式列表信息。"""
1404
+ identity = self.state.list_override_id
1405
+ label = self.context.pending_list_label
1406
+ if identity is None and not label:
1407
+ return None
1408
+ level = min(max(self.state.list_level, 0), MAX_RTF_LIST_DEPTH)
1409
+ definition = self.prelude.lists.get(identity or 0)
1410
+ if definition is not None:
1411
+ level_def = definition.levels[min(level, len(definition.levels) - 1)]
1412
+ resolved_identity = definition.identity
1413
+ else:
1414
+ marker = "bullet" if label and any(char in label for char in ("\u2022", "\u00b7", "\uf0b7")) else "decimal"
1415
+ level_def = _ListLevel(marker=marker)
1416
+ resolved_identity = identity or 0
1417
+ counter_key = (resolved_identity, level)
1418
+ current = self._list_counters.get(counter_key, level_def.start - 1) + 1
1419
+ self._list_counters[counter_key] = current
1420
+ ordered = level_def.ordered
1421
+ if label:
1422
+ stripped_label = label.strip()
1423
+ if stripped_label in {"\u2022", "\u00b7", "\uf0b7", "o"}:
1424
+ ordered = False
1425
+ elif ordered:
1426
+ label = f"{_format_marker(level_def.marker, current)}."
1427
+ return RtfListInfo(
1428
+ identity=resolved_identity,
1429
+ level=level,
1430
+ ordered=ordered,
1431
+ marker=level_def.marker,
1432
+ start=level_def.start,
1433
+ label=label,
1434
+ )
1435
+
1436
+ def _end_paragraph(self) -> None:
1437
+ """把当前行内流冻结为段落,并路由到正文或当前表格单元格。"""
1438
+ self._flush_bytes()
1439
+ if self._pending_high_surrogate is not None:
1440
+ self._append_text("\ufffd")
1441
+ self._pending_high_surrogate = None
1442
+ self._flush_text_run()
1443
+ inlines = self.context.inlines
1444
+ self.context.inlines = []
1445
+ list_info = self._resolve_list_info()
1446
+ self.context.pending_list_label = None
1447
+ has_visible = any(
1448
+ not isinstance(inline, (RtfTextRun, RtfAnchor)) or (isinstance(inline, RtfTextRun) and bool(inline.text.strip()))
1449
+ for inline in inlines
1450
+ )
1451
+ if not has_visible and not any(isinstance(inline, RtfAnchor) for inline in inlines):
1452
+ return
1453
+ style_definition = self.prelude.styles.get(self.state.paragraph_style_id or -1, _StyleDefinition())
1454
+ outline = self.state.outline_level
1455
+ if outline is None:
1456
+ outline = style_definition.outline_level
1457
+ paragraph = RtfParagraph(
1458
+ inlines=inlines,
1459
+ style_name=style_definition.name,
1460
+ outline_level=outline,
1461
+ is_title=style_definition.is_title,
1462
+ block_style=style_definition.block_style,
1463
+ list_info=list_info,
1464
+ )
1465
+ active_depth = max(self.state.table_depth, 1)
1466
+ if self.state.in_table:
1467
+ self._table_builder(active_depth).add_block(paragraph)
1468
+ return
1469
+ self._flush_tables()
1470
+ self.context.blocks.append(paragraph)
1471
+
1472
+ def _table_builder(self, depth: int) -> _TableBuilder:
1473
+ """返回指定 depth 的 builder,并拒绝超出渲染能力的嵌套。"""
1474
+ if depth < 1 or depth > MAX_RTF_TABLE_DEPTH:
1475
+ raise LegacyOfficeResourceLimitError(f"RTF table nesting exceeds max_table_depth={MAX_RTF_TABLE_DEPTH}")
1476
+ return self.context.tables.setdefault(depth, _TableBuilder())
1477
+
1478
+ def _set_table_depth(self, depth: int) -> None:
1479
+ """切换 table depth,并把已结束的深层表格挂回父 cell。"""
1480
+ normalized = min(max(depth, 0), MAX_RTF_TABLE_DEPTH)
1481
+ if depth > MAX_RTF_TABLE_DEPTH:
1482
+ raise LegacyOfficeResourceLimitError(f"RTF table nesting exceeds max_table_depth={MAX_RTF_TABLE_DEPTH}")
1483
+ for current_depth in sorted(
1484
+ [value for value in self.context.tables if value > normalized],
1485
+ reverse=True,
1486
+ ):
1487
+ table = self.context.tables.pop(current_depth).finish()
1488
+ if table is None:
1489
+ continue
1490
+ if current_depth > 1:
1491
+ self._table_builder(current_depth - 1).add_block(table)
1492
+ else:
1493
+ self.context.blocks.append(table)
1494
+ self.state.table_depth = normalized
1495
+
1496
+ def _flush_tables(self) -> None:
1497
+ """从深到浅结束当前 context 的全部 table builder。"""
1498
+ for depth in sorted(self.context.tables, reverse=True):
1499
+ table = self.context.tables[depth].finish()
1500
+ if table is None:
1501
+ continue
1502
+ if depth > 1:
1503
+ self._table_builder(depth - 1).add_block(table)
1504
+ else:
1505
+ self.context.blocks.append(table)
1506
+ self.context.tables.clear()
1507
+
1508
+ def _finalize_context(self, context: _OutputContext) -> None:
1509
+ """结束一个输出 context 的残留段落和表格。"""
1510
+ if context is not self.context:
1511
+ current = self.context
1512
+ self.context = context
1513
+ self._end_paragraph()
1514
+ self._flush_tables()
1515
+ self.context = current
1516
+ return
1517
+ self._end_paragraph()
1518
+ self._flush_tables()
1519
+
1520
+
1521
+ def read_rtf_bytes(file_binary: BinaryIO) -> bytes:
1522
+ """从二进制流头部读取有界 RTF 输入,并恢复调用前流位置。"""
1523
+ try:
1524
+ original_position = file_binary.tell()
1525
+ except (AttributeError, OSError):
1526
+ original_position = None
1527
+ if original_position is not None:
1528
+ file_binary.seek(0)
1529
+ data = file_binary.read(MAX_RTF_BYTES + 1)
1530
+ if original_position is not None:
1531
+ file_binary.seek(original_position)
1532
+ if len(data) > MAX_RTF_BYTES:
1533
+ raise LegacyOfficeResourceLimitError(f"RTF input exceeds max_bytes={MAX_RTF_BYTES}")
1534
+ return data
1535
+
1536
+
1537
+ def parse_rtf(file_binary: BinaryIO) -> RtfDocument:
1538
+ """读取一个 RTF 二进制流并返回单逻辑页 typed document。"""
1539
+ data = read_rtf_bytes(file_binary)
1540
+ prelude = parse_rtf_prelude(data)
1541
+ return RtfParser(data, prelude).parse()
1542
+
1543
+
1544
+ __all__ = [
1545
+ "MAX_RTF_BYTES",
1546
+ "MAX_RTF_LIST_DEPTH",
1547
+ "MAX_RTF_TABLE_CELLS",
1548
+ "MAX_RTF_TABLE_DEPTH",
1549
+ "RtfParser",
1550
+ "parse_rtf",
1551
+ "parse_rtf_prelude",
1552
+ "read_rtf_bytes",
1553
+ ]