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,1895 @@
1
+ """PowerPoint 97–2003 二进制文档的分页语义解析器。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ import struct
7
+ from typing import Iterable
8
+ import unicodedata
9
+ import zlib
10
+
11
+ from loguru import logger
12
+
13
+ from ..._shared.hyperlink import sanitize_hyperlink_target
14
+ from ..errors import LegacyOfficeEncryptedError, LegacyOfficeMalformedError, LegacyOfficeResourceLimitError
15
+ from ..legacy.binary import get_i16, get_u16, get_u32
16
+ from ..limits import MAX_ASSET_TOTAL_BYTES, MAX_ENTRY_BYTES, MAX_GRID_SLOTS, MAX_PICTURE_RECORDS, MAX_USER_EDIT_CHAIN
17
+ from ..equation.mtef import decode_equation_object
18
+ from ..legacy.ole import BoundedOleReader
19
+ from ..legacy.officeart import OfficeArtRecord, OfficeImagePayload, decode_blip as decode_officeart_blip
20
+ from ..image import serialize_office_image
21
+ from ..equation.image import OfficeImageEquationDecoder
22
+ from ..xls.embedded_chart import extract_embedded_chart_html_from_storage
23
+
24
+ from .models import (
25
+ PptChartElement,
26
+ PptEquationElement,
27
+ PptImageElement,
28
+ PptParagraph,
29
+ PptPresentation,
30
+ PptSlide,
31
+ PptTableCell,
32
+ PptTableElement,
33
+ PptTextElement,
34
+ PptTextRun,
35
+ )
36
+ from .records import CONTAINER_VERSION, PptRecord, RecordBudget, iter_descendants, iter_records, record_at, utf16_text
37
+ from .style_text import CharacterRun, MasterLevel, ParagraphRun, StyleRuns, parse_master_style, parse_style_text
38
+
39
+ # MS-PPT records.
40
+ RT_DOCUMENT = 0x03E8
41
+ RT_DOCUMENT_ATOM = 0x03E9
42
+ RT_SLIDE = 0x03EE
43
+ RT_SLIDE_ATOM = 0x03EF
44
+ RT_NOTES = 0x03F0
45
+ RT_NOTES_ATOM = 0x03F1
46
+ RT_SLIDE_PERSIST_ATOM = 0x03F3
47
+ RT_MAIN_MASTER = 0x03F8
48
+ RT_SLIDE_SHOW_SLIDE_INFO_ATOM = 0x03F9
49
+ RT_TEXT_HEADER_ATOM = 0x0F9F
50
+ RT_TEXT_CHARS_ATOM = 0x0FA0
51
+ RT_STYLE_TEXT_PROP_ATOM = 0x0FA1
52
+ RT_TEXT_MASTER_STYLE_ATOM = 0x0FA3
53
+ RT_TEXT_BYTES_ATOM = 0x0FA8
54
+ RT_CSTRING = 0x0FBA
55
+ RT_TEXT_INTERACTIVE_INFO_ATOM = 0x0FDF
56
+ RT_EXTERNAL_HYPERLINK = 0x0FD7
57
+ RT_EXTERNAL_HYPERLINK_ATOM = 0x0FD3
58
+ RT_SLIDE_LIST_WITH_TEXT = 0x0FF0
59
+ RT_INTERACTIVE_INFO = 0x0FF2
60
+ RT_INTERACTIVE_INFO_ATOM = 0x0FF3
61
+ RT_USER_EDIT_ATOM = 0x0FF5
62
+ RT_OUTLINE_TEXT_REF_ATOM = 0x0F9E
63
+ RT_PERSIST_DIRECTORY_ATOM = 0x1772
64
+ RT_CRYPT_SESSION10_CONTAINER = 0x2F14
65
+ RT_EXTERNAL_OBJECT_REF_ATOM = 0x0BC1
66
+ RT_EXTERNAL_OLE_OBJECT_ATOM = 0x0FC3
67
+ RT_EXTERNAL_OLE_OBJECT_STG = 0x1011
68
+
69
+ # OfficeArt records and properties.
70
+ RT_OFFICEART_DGG_CONTAINER = 0xF000
71
+ RT_OFFICEART_BSTORE_CONTAINER = 0xF001
72
+ RT_OFFICEART_SPGR_CONTAINER = 0xF003
73
+ RT_OFFICEART_SP_CONTAINER = 0xF004
74
+ RT_OFFICEART_BSE = 0xF007
75
+ RT_OFFICEART_FSPGR = 0xF009
76
+ RT_OFFICEART_FSP = 0xF00A
77
+ RT_OFFICEART_FOPT = 0xF00B
78
+ RT_OFFICEART_CLIENT_TEXTBOX = 0xF00D
79
+ RT_OFFICEART_CHILD_ANCHOR = 0xF00F
80
+ RT_OFFICEART_CLIENT_ANCHOR = 0xF010
81
+ RT_OFFICEART_CLIENT_DATA = 0xF011
82
+ RT_OFFICEART_TERTIARY_FOPT = 0xF122
83
+ RT_OE_PLACEHOLDER_ATOM = 0x0BC3
84
+
85
+ FOPT_PIB = 0x0104
86
+ FOPT_TABLE_PROPERTIES = 0x039F
87
+ FOPT_TABLE_ROW_PROPERTIES = 0x03A0
88
+
89
+ DEFAULT_SLIDE_WIDTH = 5760
90
+ DEFAULT_SLIDE_HEIGHT = 4320
91
+ _ALLOWED_LINK_SCHEMES = frozenset({"http", "https", "mailto"})
92
+ _CFB_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
93
+ _ZLIB_SYNC_FLUSH_SUFFIX = b"\x00\x00\xff\xff"
94
+ _OLE_SUBTYPE_GRAPH = 0x0000_0004
95
+ _OLE_SUBTYPE_EQUATION = 0x0000_0006
96
+ _OLE_SUBTYPE_EXCEL_CHART = 0x0000_000E
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class _TextContent:
101
+ """一个 TextHeaderAtom 对应的完整段落集合。"""
102
+
103
+ paragraphs: tuple[PptParagraph, ...]
104
+ text_type: int
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class _PersistLayout:
109
+ """最新 UserEdit 解析出的文档与 persist 映射。"""
110
+
111
+ document: PptRecord
112
+ persist: dict[int, int]
113
+ recovered: bool = False
114
+
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class _EmbeddedOleObject:
118
+ """一个已按 exObjId 绑定并解压的 PPT OLE 对象。"""
119
+
120
+ subtype: int
121
+ storage: bytes
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class _GroupSpace:
126
+ """嵌套 OfficeArt group 到幻灯片坐标的线性映射。"""
127
+
128
+ coord_left: int
129
+ coord_top: int
130
+ coord_right: int
131
+ coord_bottom: int
132
+ abs_left: float
133
+ abs_top: float
134
+ abs_right: float
135
+ abs_bottom: float
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class _ShapeInfo:
140
+ """带唯一遍历路径、坐标和 z-order 的 OfficeArt shape。"""
141
+
142
+ key: tuple[int, ...]
143
+ record: PptRecord
144
+ space: _GroupSpace | None
145
+ bbox: tuple[float, float, float, float]
146
+ order: int
147
+ group_key: tuple[int, ...] | None
148
+
149
+
150
+ @dataclass(frozen=True, slots=True)
151
+ class _TableGroup:
152
+ """一个由 fIsTable 标记的 group 及其所有叶子 shape。"""
153
+
154
+ key: tuple[int, ...]
155
+ group_shape: PptRecord
156
+ shapes: tuple[_ShapeInfo, ...]
157
+ order: int
158
+ authoritative: bool
159
+
160
+
161
+ _ImagePayload = OfficeImagePayload
162
+
163
+
164
+ @dataclass(frozen=True, slots=True)
165
+ class _NumberingStyle:
166
+ """一个 pp9rt 槽位的自动编号覆盖。"""
167
+
168
+ enabled: bool | None
169
+ start: int | None
170
+
171
+
172
+ @dataclass(frozen=True, slots=True)
173
+ class _ShapeCollection:
174
+ """一次遍历得到的叶子 shape 与表格 group。"""
175
+
176
+ shapes: tuple[_ShapeInfo, ...]
177
+ table_groups: tuple[_TableGroup, ...]
178
+
179
+
180
+ def _direct_children(record: PptRecord, budget: RecordBudget) -> list[PptRecord]:
181
+ """返回容器的直接子记录,普通 atom 返回空列表。"""
182
+
183
+ if record.version != CONTAINER_VERSION:
184
+ return []
185
+ return list(iter_records(record.payload, budget=budget))
186
+
187
+
188
+ def _find_latest_user_edit_offset(data: bytes, current_user: bytes) -> int | None:
189
+ """从 Current User stream 读取最新 UserEditAtom 偏移。"""
190
+
191
+ offset = get_u32(current_user, 16)
192
+ return int(offset) if offset else None
193
+
194
+
195
+ def _merge_persist_directory(
196
+ mapping: dict[int, int],
197
+ payload: bytes,
198
+ ) -> None:
199
+ """合并一个 PersistDirectoryAtom;调用顺序保证较新记录优先。"""
200
+
201
+ cursor = 0
202
+ while cursor < len(payload):
203
+ head = get_u32(payload, cursor)
204
+ if head is None:
205
+ raise LegacyOfficeMalformedError("PowerPoint persist directory is truncated")
206
+ cursor += 4
207
+ first_id = head & 0x000F_FFFF
208
+ count = head >> 20
209
+ if count <= 0 or cursor + count * 4 > len(payload):
210
+ raise LegacyOfficeMalformedError("PowerPoint persist directory entry is invalid")
211
+ for index in range(count):
212
+ offset = get_u32(payload, cursor)
213
+ if offset is None:
214
+ raise LegacyOfficeMalformedError("PowerPoint persist offset is truncated")
215
+ mapping.setdefault(first_id + index, int(offset))
216
+ cursor += 4
217
+
218
+
219
+ def _latest_document_fallback(data: bytes, budget: RecordBudget) -> PptRecord | None:
220
+ """在 persist 链损坏时选择最后一个完整 DocumentContainer。"""
221
+
222
+ candidates = [
223
+ record
224
+ for record in iter_records(data, budget=budget)
225
+ if record.record_type == RT_DOCUMENT and record.version == CONTAINER_VERSION
226
+ ]
227
+ return candidates[-1] if candidates else None
228
+
229
+
230
+ def _locate_document(
231
+ data: bytes,
232
+ current_user: bytes,
233
+ budget: RecordBudget,
234
+ ) -> _PersistLayout:
235
+ """解析最新 UserEdit 链,失败时回退到顶层 DocumentContainer。"""
236
+
237
+ edit_offset = _find_latest_user_edit_offset(data, current_user)
238
+ persist: dict[int, int] = {}
239
+ document_persist_id: int | None = None
240
+ seen: set[int] = set()
241
+ try:
242
+ for _ in range(MAX_USER_EDIT_CHAIN):
243
+ if not edit_offset:
244
+ break
245
+ if edit_offset in seen:
246
+ raise LegacyOfficeMalformedError("PowerPoint UserEdit chain is cyclic")
247
+ seen.add(edit_offset)
248
+ edit = record_at(data, edit_offset, strict=True, budget=budget)
249
+ if edit is None or edit.record_type != RT_USER_EDIT_ATOM:
250
+ raise LegacyOfficeMalformedError("PowerPoint UserEditAtom is invalid")
251
+ if document_persist_id is None:
252
+ document_persist_id = get_u32(edit.payload, 16)
253
+ directory_offset = get_u32(edit.payload, 12)
254
+ if directory_offset is None:
255
+ raise LegacyOfficeMalformedError("PowerPoint persist directory offset is missing")
256
+ directory = record_at(data, directory_offset, strict=True, budget=budget)
257
+ if directory is None or directory.record_type != RT_PERSIST_DIRECTORY_ATOM:
258
+ raise LegacyOfficeMalformedError("PowerPoint PersistDirectoryAtom is invalid")
259
+ _merge_persist_directory(persist, directory.payload)
260
+ previous = get_u32(edit.payload, 8)
261
+ if previous == edit_offset:
262
+ raise LegacyOfficeMalformedError("PowerPoint UserEdit chain points to itself")
263
+ edit_offset = int(previous or 0)
264
+ else:
265
+ raise LegacyOfficeResourceLimitError(f"UserEdit chain exceeds max_user_edit_chain={MAX_USER_EDIT_CHAIN}")
266
+ if document_persist_id is not None:
267
+ document_offset = persist.get(int(document_persist_id))
268
+ if document_offset is not None:
269
+ document = record_at(data, document_offset, strict=True, budget=budget)
270
+ if document is not None and document.record_type == RT_DOCUMENT:
271
+ return _PersistLayout(document=document, persist=persist)
272
+ except (LegacyOfficeMalformedError, LegacyOfficeResourceLimitError) as exc:
273
+ if isinstance(exc, LegacyOfficeResourceLimitError):
274
+ raise
275
+ logger.warning(f"PPT_PERSIST_RECOVERY: {exc}")
276
+
277
+ document = _latest_document_fallback(data, budget)
278
+ if document is None:
279
+ raise LegacyOfficeMalformedError("PowerPoint DocumentContainer is missing")
280
+ # 顶层 persist 记录仍可帮助恢复同一保存版本中的 slide。
281
+ recovered_persist: dict[int, int] = {}
282
+ for record in iter_records(data, budget=budget):
283
+ if record.record_type == RT_PERSIST_DIRECTORY_ATOM:
284
+ try:
285
+ _merge_persist_directory(recovered_persist, record.payload)
286
+ except LegacyOfficeMalformedError:
287
+ continue
288
+ return _PersistLayout(document=document, persist=recovered_persist, recovered=True)
289
+
290
+
291
+ def _slide_entries(document: PptRecord, budget: RecordBudget) -> list[tuple[int, int]]:
292
+ """按 SlideListWithText 的保存顺序返回 persist 引用与稳定 slide id。"""
293
+
294
+ entries: list[tuple[int, int]] = []
295
+ for container in iter_descendants(document, budget=budget):
296
+ if container.record_type != RT_SLIDE_LIST_WITH_TEXT or container.instance != 0:
297
+ continue
298
+ for record in iter_records(container.payload, budget=budget):
299
+ if record.record_type != RT_SLIDE_PERSIST_ATOM:
300
+ continue
301
+ reference = get_u32(record.payload, 0)
302
+ slide_id = get_u32(record.payload, 12)
303
+ if reference:
304
+ entries.append((int(reference), int(slide_id or 0)))
305
+ break
306
+ return entries
307
+
308
+
309
+ def _master_entries(document: PptRecord, budget: RecordBudget) -> list[tuple[int, int]]:
310
+ """返回 master persist 引用与 master id。"""
311
+
312
+ entries: list[tuple[int, int]] = []
313
+ for container in iter_descendants(document, budget=budget):
314
+ if container.record_type != RT_SLIDE_LIST_WITH_TEXT or container.instance != 1:
315
+ continue
316
+ for record in iter_records(container.payload, budget=budget):
317
+ if record.record_type != RT_SLIDE_PERSIST_ATOM:
318
+ continue
319
+ reference = get_u32(record.payload, 0)
320
+ master_id = get_u32(record.payload, 12)
321
+ if reference and master_id:
322
+ entries.append((int(reference), int(master_id)))
323
+ break
324
+ return entries
325
+
326
+
327
+ def _notes_entries(document: PptRecord, budget: RecordBudget) -> list[int]:
328
+ """返回 notes list 中的 persist 引用。"""
329
+
330
+ references: list[int] = []
331
+ for container in iter_descendants(document, budget=budget):
332
+ if container.record_type != RT_SLIDE_LIST_WITH_TEXT or container.instance != 2:
333
+ continue
334
+ for record in iter_records(container.payload, budget=budget):
335
+ if record.record_type == RT_SLIDE_PERSIST_ATOM:
336
+ reference = get_u32(record.payload, 0)
337
+ if reference:
338
+ references.append(int(reference))
339
+ break
340
+ return references
341
+
342
+
343
+ def _slide_master_id(slide: PptRecord, budget: RecordBudget) -> int | None:
344
+ """读取 SlideAtom.masterIdRef。"""
345
+
346
+ for child in iter_descendants(slide, budget=budget):
347
+ if child.record_type == RT_SLIDE_ATOM:
348
+ master_id = get_u32(child.payload, 12)
349
+ return int(master_id) if master_id else None
350
+ return None
351
+
352
+
353
+ def _presentation_size(document: PptRecord, budget: RecordBudget) -> tuple[int, int]:
354
+ """从 DocumentAtom 读取页面 master units 尺寸。"""
355
+
356
+ for child in iter_descendants(document, budget=budget):
357
+ if child.record_type != RT_DOCUMENT_ATOM or len(child.payload) < 8:
358
+ continue
359
+ width = get_u32(child.payload, 0)
360
+ height = get_u32(child.payload, 4)
361
+ if width and height and width < 100_000 and height < 100_000:
362
+ return int(width), int(height)
363
+ return DEFAULT_SLIDE_WIDTH, DEFAULT_SLIDE_HEIGHT
364
+
365
+
366
+ def _decode_text_atom(record: PptRecord) -> str | None:
367
+ """解码 TextCharsAtom 或 TextBytesAtom。"""
368
+
369
+ if record.record_type == RT_TEXT_CHARS_ATOM:
370
+ return utf16_text(record.payload)
371
+ if record.record_type == RT_TEXT_BYTES_ATOM:
372
+ return record.payload.decode("cp1252", "replace").rstrip("\x00")
373
+ return None
374
+
375
+
376
+ def _hyperlink_targets(document: PptRecord, budget: RecordBudget) -> dict[int, str]:
377
+ """建立 ExHyperlinkId 到安全外链目标的映射。"""
378
+
379
+ result: dict[int, str] = {}
380
+ for container in iter_descendants(document, budget=budget):
381
+ if container.record_type != RT_EXTERNAL_HYPERLINK or container.version != CONTAINER_VERSION:
382
+ continue
383
+ link_id = None
384
+ strings: list[str] = []
385
+ for child in iter_records(container.payload, budget=budget):
386
+ if child.record_type == RT_EXTERNAL_HYPERLINK_ATOM:
387
+ link_id = get_u32(child.payload, 0)
388
+ elif child.record_type == RT_CSTRING:
389
+ strings.append(utf16_text(child.payload))
390
+ if link_id is None or not strings:
391
+ continue
392
+ safe_target = sanitize_hyperlink_target(strings[-1], allowed_schemes=_ALLOWED_LINK_SCHEMES)
393
+ if safe_target is None:
394
+ logger.warning(f"PPT_UNSAFE_HYPERLINK: hyperlink id={link_id} was downgraded")
395
+ continue
396
+ result[int(link_id)] = safe_target
397
+ return result
398
+
399
+
400
+ def _interactive_spans(
401
+ related_records: Iterable[PptRecord],
402
+ hyperlinks: dict[int, str],
403
+ budget: RecordBudget,
404
+ ) -> list[tuple[int, int, str]]:
405
+ """解析一段文本之后的 InteractiveInfo 与 UTF-16 范围。"""
406
+
407
+ flattened: list[PptRecord] = []
408
+ for record in related_records:
409
+ flattened.append(record)
410
+ if record.version == CONTAINER_VERSION:
411
+ flattened.extend(iter_descendants(record, budget=budget))
412
+ spans: list[tuple[int, int, str]] = []
413
+ pending_id: int | None = None
414
+ for record in flattened:
415
+ if record.record_type == RT_INTERACTIVE_INFO_ATOM:
416
+ pending_id = get_u32(record.payload, 4)
417
+ continue
418
+ if record.record_type != RT_TEXT_INTERACTIVE_INFO_ATOM or pending_id is None:
419
+ continue
420
+ start = get_u32(record.payload, 0)
421
+ end = get_u32(record.payload, 4)
422
+ target = hyperlinks.get(int(pending_id))
423
+ if start is not None and end is not None and target and end > start:
424
+ spans.append((int(start), int(end), target))
425
+ pending_id = None
426
+ return spans
427
+
428
+
429
+ def _utf16_width(text: str) -> int:
430
+ """返回字符串占用的 UTF-16 code unit 数。"""
431
+
432
+ return len(text.encode("utf-16-le", "surrogatepass")) // 2
433
+
434
+
435
+ def _master_level(levels: list[MasterLevel], depth: int) -> MasterLevel:
436
+ """按深度取得母版默认值,超界时回退最后一个可用层级。"""
437
+
438
+ if not levels:
439
+ return MasterLevel()
440
+ return levels[min(max(depth, 0), len(levels) - 1)]
441
+
442
+
443
+ def _paragraph_run_at(runs: list[ParagraphRun], offset: int) -> ParagraphRun:
444
+ """返回覆盖指定 UTF-16 偏移的段落 run。"""
445
+
446
+ cursor = 0
447
+ for run in runs:
448
+ if cursor <= offset < cursor + run.count:
449
+ return run
450
+ cursor += run.count
451
+ return ParagraphRun(count=0, depth=0)
452
+
453
+
454
+ def _character_run_at(runs: list[CharacterRun], offset: int) -> CharacterRun:
455
+ """返回覆盖指定 UTF-16 偏移的字符 run。"""
456
+
457
+ cursor = 0
458
+ for run in runs:
459
+ if cursor <= offset < cursor + run.count:
460
+ return run
461
+ cursor += run.count
462
+ return CharacterRun(count=0)
463
+
464
+
465
+ def _hyperlink_at(spans: list[tuple[int, int, str]], offset: int) -> str | None:
466
+ """返回覆盖当前 UTF-16 偏移的超链接目标。"""
467
+
468
+ return next((target for start, end, target in spans if start <= offset < end), None)
469
+
470
+
471
+ def _resolve_run(
472
+ text: str,
473
+ explicit: CharacterRun,
474
+ master: MasterLevel,
475
+ hyperlink: str | None,
476
+ ) -> PptTextRun:
477
+ """把字符异常属性覆盖到母版默认值上。"""
478
+
479
+ return PptTextRun(
480
+ text=text,
481
+ bold=explicit.bold if explicit.bold is not None else bool(master.bold),
482
+ italic=explicit.italic if explicit.italic is not None else bool(master.italic),
483
+ underline=(explicit.underline if explicit.underline is not None else bool(master.underline) or hyperlink is not None),
484
+ strike=bool(explicit.strike),
485
+ baseline=explicit.baseline if explicit.baseline is not None else master.baseline,
486
+ hyperlink=hyperlink,
487
+ )
488
+
489
+
490
+ def _flush_text_run(
491
+ output: list[PptTextRun],
492
+ text: str,
493
+ style: PptTextRun | None,
494
+ ) -> None:
495
+ """追加非空文本,并合并相邻同样式 run。"""
496
+
497
+ if not text or style is None:
498
+ return
499
+ candidate = replace(style, text=text)
500
+ if output and replace(output[-1], text="") == replace(candidate, text=""):
501
+ output[-1] = replace(output[-1], text=f"{output[-1].text}{text}")
502
+ return
503
+ output.append(candidate)
504
+
505
+
506
+ def _build_paragraphs(
507
+ text: str,
508
+ styles: StyleRuns,
509
+ master_levels: list[MasterLevel],
510
+ hyperlinks: list[tuple[int, int, str]],
511
+ ) -> tuple[PptParagraph, ...]:
512
+ """把 UTF-16 属性范围转换为带样式 run 的段落。"""
513
+
514
+ paragraphs: list[PptParagraph] = []
515
+ current_runs: list[PptTextRun] = []
516
+ run_text: list[str] = []
517
+ active_style: PptTextRun | None = None
518
+ utf16_offset = 0
519
+ paragraph_start = 0
520
+
521
+ def flush_run() -> None:
522
+ """把当前相同样式字符提交到段落。"""
523
+
524
+ nonlocal run_text
525
+ _flush_text_run(current_runs, "".join(run_text), active_style)
526
+ run_text = []
527
+
528
+ def flush_paragraph() -> None:
529
+ """完成当前段落并解析列表属性。"""
530
+
531
+ flush_run()
532
+ paragraph_style = _paragraph_run_at(styles.paragraphs, paragraph_start)
533
+ character_style = _character_run_at(styles.characters, paragraph_start)
534
+ master = _master_level(master_levels, paragraph_style.depth)
535
+ bullet = paragraph_style.bullet if paragraph_style.bullet is not None else bool(master.bullet)
536
+ visible = any(run.text.strip() for run in current_runs)
537
+ if visible:
538
+ paragraphs.append(
539
+ PptParagraph(
540
+ runs=tuple(current_runs),
541
+ depth=max(0, int(paragraph_style.depth)),
542
+ list_kind="unordered" if bullet else None,
543
+ pp9rt=character_style.pp9rt,
544
+ )
545
+ )
546
+ current_runs.clear()
547
+
548
+ for character in text:
549
+ paragraph_style = _paragraph_run_at(styles.paragraphs, utf16_offset)
550
+ master = _master_level(master_levels, paragraph_style.depth)
551
+ explicit = _character_run_at(styles.characters, utf16_offset)
552
+ hyperlink = _hyperlink_at(hyperlinks, utf16_offset)
553
+ style = _resolve_run("", explicit, master, hyperlink)
554
+ if active_style is None or replace(active_style, text="") != replace(style, text=""):
555
+ flush_run()
556
+ active_style = style
557
+ if character == "\r":
558
+ flush_paragraph()
559
+ paragraph_start = utf16_offset + 1
560
+ active_style = None
561
+ elif character in {"\x0b", "\n"}:
562
+ run_text.append("\n")
563
+ elif not unicodedata.category(character).startswith("C") or character == "\t":
564
+ run_text.append(character)
565
+ utf16_offset += _utf16_width(character)
566
+ if run_text or current_runs:
567
+ flush_paragraph()
568
+ return tuple(paragraphs)
569
+
570
+
571
+ def _parse_text_contents(
572
+ records: list[PptRecord],
573
+ master_styles: dict[int, list[MasterLevel]],
574
+ hyperlinks: dict[int, str],
575
+ budget: RecordBudget,
576
+ ) -> list[_TextContent]:
577
+ """从一组相邻记录中解析所有文本形状内容。"""
578
+
579
+ result: list[_TextContent] = []
580
+ text_type = 4
581
+ for index, record in enumerate(records):
582
+ if record.record_type == RT_TEXT_HEADER_ATOM:
583
+ text_type = int(get_u32(record.payload, 0) or 4)
584
+ continue
585
+ text = _decode_text_atom(record)
586
+ if text is None:
587
+ continue
588
+ tail = records[index + 1 :]
589
+ next_text_index = next(
590
+ (
591
+ position
592
+ for position, candidate in enumerate(tail)
593
+ if candidate.record_type in {RT_TEXT_CHARS_ATOM, RT_TEXT_BYTES_ATOM, RT_SLIDE_PERSIST_ATOM}
594
+ ),
595
+ len(tail),
596
+ )
597
+ related = tail[:next_text_index]
598
+ style_atom = next(
599
+ (candidate for candidate in related if candidate.record_type == RT_STYLE_TEXT_PROP_ATOM),
600
+ None,
601
+ )
602
+ styles = parse_style_text(style_atom.payload, _utf16_width(text)) if style_atom is not None else StyleRuns()
603
+ spans = _interactive_spans(related, hyperlinks, budget)
604
+ paragraphs = _build_paragraphs(
605
+ text,
606
+ styles,
607
+ master_styles.get(text_type, []),
608
+ spans,
609
+ )
610
+ result.append(_TextContent(paragraphs=paragraphs, text_type=text_type))
611
+ return result
612
+
613
+
614
+ def _external_text_records(
615
+ document: PptRecord,
616
+ budget: RecordBudget,
617
+ ) -> dict[int, list[PptRecord]]:
618
+ """按 slide persist 引用收集 SlideListWithText 中的外置文本记录。"""
619
+
620
+ grouped: dict[int, list[PptRecord]] = {}
621
+ for container in iter_descendants(document, budget=budget):
622
+ if container.record_type != RT_SLIDE_LIST_WITH_TEXT or container.instance != 0:
623
+ continue
624
+ current_reference: int | None = None
625
+ for record in iter_records(container.payload, budget=budget):
626
+ if record.record_type == RT_SLIDE_PERSIST_ATOM:
627
+ reference = get_u32(record.payload, 0)
628
+ current_reference = int(reference) if reference else None
629
+ if current_reference is not None:
630
+ grouped.setdefault(current_reference, [])
631
+ continue
632
+ if current_reference is not None:
633
+ grouped[current_reference].append(record)
634
+ break
635
+ return grouped
636
+
637
+
638
+ def _master_styles(record: PptRecord, budget: RecordBudget) -> dict[int, list[MasterLevel]]:
639
+ """解析一个 master container 的逐文本类型默认样式。"""
640
+
641
+ result: dict[int, list[MasterLevel]] = {}
642
+ for child in iter_descendants(record, budget=budget):
643
+ if child.record_type != RT_TEXT_MASTER_STYLE_ATOM:
644
+ continue
645
+ result.setdefault(
646
+ int(child.instance),
647
+ parse_master_style(child.payload, int(child.instance)),
648
+ )
649
+ return result
650
+
651
+
652
+ def _collect_masters(
653
+ layout: _PersistLayout,
654
+ data: bytes,
655
+ budget: RecordBudget,
656
+ ) -> tuple[dict[int, tuple[PptRecord, dict[int, list[MasterLevel]]]], tuple[PptRecord, dict[int, list[MasterLevel]]] | None]:
657
+ """按 master id 建立容器与样式映射,并返回确定性 fallback。"""
658
+
659
+ masters: dict[int, tuple[PptRecord, dict[int, list[MasterLevel]]]] = {}
660
+ for reference, master_id in _master_entries(layout.document, budget):
661
+ offset = layout.persist.get(reference)
662
+ if offset is None:
663
+ continue
664
+ record = record_at(data, offset, budget=budget)
665
+ if record is None or record.record_type not in {RT_MAIN_MASTER, RT_SLIDE}:
666
+ continue
667
+ masters.setdefault(master_id, (record, _master_styles(record, budget)))
668
+
669
+ if not masters:
670
+ for root in iter_records(data, budget=budget):
671
+ if root.record_type != RT_MAIN_MASTER:
672
+ continue
673
+ masters.setdefault(0, (root, _master_styles(root, budget)))
674
+ fallback = next(iter(masters.values()), None)
675
+ return masters, fallback
676
+
677
+
678
+ def _fopt_properties(record: PptRecord) -> dict[int, int]:
679
+ """读取 OfficeArt FOPT 的简单属性,重复属性以后者覆盖。"""
680
+
681
+ count = int(record.instance)
682
+ properties: dict[int, int] = {}
683
+ for index in range(count):
684
+ offset = index * 6
685
+ if offset + 6 > len(record.payload):
686
+ break
687
+ opid, value = struct.unpack_from("<HI", record.payload, offset)
688
+ properties[opid & 0x3FFF] = int(value)
689
+ return properties
690
+
691
+
692
+ def _fopt_complex_properties(record: PptRecord) -> dict[int, bytes]:
693
+ """读取 OfficeArt FOPT 中紧随属性数组的复杂载荷。"""
694
+
695
+ count = int(record.instance)
696
+ cursor = count * 6
697
+ result: dict[int, bytes] = {}
698
+ entries: list[tuple[int, int, bool]] = []
699
+ for index in range(count):
700
+ offset = index * 6
701
+ if offset + 6 > len(record.payload):
702
+ break
703
+ opid, value = struct.unpack_from("<HI", record.payload, offset)
704
+ entries.append((opid & 0x3FFF, int(value), bool(opid & 0x8000)))
705
+ for property_id, size, is_complex in entries:
706
+ if not is_complex:
707
+ continue
708
+ end = cursor + size
709
+ if size < 0 or end < cursor or end > len(record.payload):
710
+ break
711
+ result[property_id] = record.payload[cursor:end]
712
+ cursor = end
713
+ return result
714
+
715
+
716
+ def _shape_properties(shape: PptRecord, budget: RecordBudget) -> dict[int, int]:
717
+ """合并 shape 的 primary 与 tertiary FOPT 属性。"""
718
+
719
+ properties: dict[int, int] = {}
720
+ for child in _direct_children(shape, budget):
721
+ if child.record_type in {RT_OFFICEART_FOPT, RT_OFFICEART_TERTIARY_FOPT}:
722
+ properties.update(_fopt_properties(child))
723
+ return properties
724
+
725
+
726
+ def _shape_external_object_id(shape: PptRecord, budget: RecordBudget) -> int | None:
727
+ """从 OfficeArtClientData 读取 ExObjRefAtom 的外部对象 id。"""
728
+
729
+ for child in _direct_children(shape, budget):
730
+ if child.record_type != RT_OFFICEART_CLIENT_DATA:
731
+ continue
732
+ candidates: Iterable[PptRecord]
733
+ if child.version == CONTAINER_VERSION:
734
+ candidates = iter_descendants(child, budget=budget)
735
+ else:
736
+ candidates = iter_records(child.payload, budget=budget)
737
+ for candidate in candidates:
738
+ if candidate.record_type != RT_EXTERNAL_OBJECT_REF_ATOM:
739
+ continue
740
+ reference = get_u32(candidate.payload, 0)
741
+ return int(reference) if reference else None
742
+ return None
743
+
744
+
745
+ def _embedded_object_references(
746
+ document: PptRecord,
747
+ budget: RecordBudget,
748
+ ) -> dict[int, tuple[int, int]]:
749
+ """收集支持的嵌入 OLE subtype、exObjId 与 persistIdRef。"""
750
+
751
+ references: dict[int, tuple[int, int]] = {}
752
+ for atom in iter_descendants(document, budget=budget):
753
+ if atom.record_type != RT_EXTERNAL_OLE_OBJECT_ATOM or len(atom.payload) < 24:
754
+ continue
755
+ object_type = get_u32(atom.payload, 4)
756
+ object_id = get_u32(atom.payload, 8)
757
+ object_subtype = get_u32(atom.payload, 12)
758
+ persist_id = get_u32(atom.payload, 16)
759
+ if (
760
+ object_type == 0
761
+ and object_subtype
762
+ in {
763
+ _OLE_SUBTYPE_GRAPH,
764
+ _OLE_SUBTYPE_EQUATION,
765
+ _OLE_SUBTYPE_EXCEL_CHART,
766
+ }
767
+ and object_id
768
+ and persist_id
769
+ ):
770
+ references.setdefault(
771
+ int(object_id),
772
+ (int(object_subtype), int(persist_id)),
773
+ )
774
+ return references
775
+
776
+
777
+ def _decompress_ole_storage(record: PptRecord) -> bytes | None:
778
+ """按 ExOleObjStg instance 有界恢复独立 CFB 字节。"""
779
+
780
+ if record.record_type != RT_EXTERNAL_OLE_OBJECT_STG:
781
+ return None
782
+ if record.instance == 0:
783
+ if len(record.payload) > MAX_ENTRY_BYTES:
784
+ raise LegacyOfficeResourceLimitError(f"OLE object exceeds max_entry_bytes={MAX_ENTRY_BYTES}")
785
+ storage = record.payload
786
+ if not storage.startswith(_CFB_MAGIC):
787
+ return None
788
+ try:
789
+ with BoundedOleReader(storage):
790
+ pass
791
+ except ValueError:
792
+ return None
793
+ return storage
794
+ if record.instance != 1 or len(record.payload) < 4:
795
+ return None
796
+ declared_size = int(get_u32(record.payload, 0) or 0)
797
+ if declared_size <= 0:
798
+ return None
799
+ if declared_size > MAX_ENTRY_BYTES:
800
+ raise LegacyOfficeResourceLimitError(f"OLE object exceeds max_entry_bytes={MAX_ENTRY_BYTES}")
801
+ try:
802
+ inflater = zlib.decompressobj(zlib.MAX_WBITS)
803
+ storage = inflater.decompress(record.payload[4:], MAX_ENTRY_BYTES + 1)
804
+ if len(storage) > MAX_ENTRY_BYTES:
805
+ raise LegacyOfficeResourceLimitError(f"OLE object exceeds max_entry_bytes={MAX_ENTRY_BYTES}")
806
+ storage += inflater.flush(MAX_ENTRY_BYTES + 1 - len(storage))
807
+ except (ValueError, zlib.error):
808
+ return None
809
+ if len(storage) > MAX_ENTRY_BYTES:
810
+ raise LegacyOfficeResourceLimitError(f"OLE object exceeds max_entry_bytes={MAX_ENTRY_BYTES}")
811
+ if len(storage) != declared_size or inflater.unconsumed_tail:
812
+ return None
813
+ if not inflater.eof:
814
+ if not record.payload[4:].endswith(_ZLIB_SYNC_FLUSH_SUFFIX):
815
+ return None
816
+ elif inflater.unused_data:
817
+ return None
818
+ if not storage.startswith(_CFB_MAGIC):
819
+ return None
820
+ try:
821
+ with BoundedOleReader(storage):
822
+ pass
823
+ except ValueError:
824
+ return None
825
+ return storage
826
+
827
+
828
+ def _embedded_object_map(
829
+ layout: _PersistLayout,
830
+ data: bytes,
831
+ budget: RecordBudget,
832
+ ) -> dict[int, _EmbeddedOleObject]:
833
+ """统一解压受支持的 PPT persist OLE storages 并共享资源预算。"""
834
+
835
+ objects: dict[int, _EmbeddedOleObject] = {}
836
+ asset_total = 0
837
+ for object_id, (subtype, persist_id) in _embedded_object_references(
838
+ layout.document,
839
+ budget,
840
+ ).items():
841
+ offset = layout.persist.get(persist_id)
842
+ record = record_at(data, offset, budget=budget) if offset is not None else None
843
+ if record is None:
844
+ logger.warning(
845
+ "PPT_OLE_FALLBACK: exObjId={} persistIdRef={} is missing",
846
+ object_id,
847
+ persist_id,
848
+ )
849
+ continue
850
+ storage = _decompress_ole_storage(record)
851
+ if storage is None:
852
+ logger.warning(
853
+ "PPT_OLE_FALLBACK: exObjId={} has an invalid OLE storage",
854
+ object_id,
855
+ )
856
+ continue
857
+ asset_total += len(storage)
858
+ if asset_total > MAX_ASSET_TOTAL_BYTES:
859
+ raise LegacyOfficeResourceLimitError(f"embedded assets exceed max_asset_total_bytes={MAX_ASSET_TOTAL_BYTES}")
860
+ objects[object_id] = _EmbeddedOleObject(subtype=subtype, storage=storage)
861
+ return objects
862
+
863
+
864
+ def _equation_map(objects: dict[int, _EmbeddedOleObject]) -> dict[int, str]:
865
+ """从共享 OLE 对象集合解析 Equation Native。"""
866
+
867
+ equations: dict[int, str] = {}
868
+ for object_id, embedded in objects.items():
869
+ if embedded.subtype != _OLE_SUBTYPE_EQUATION:
870
+ continue
871
+ latex = decode_equation_object(embedded.storage)
872
+ if latex is None:
873
+ logger.warning(
874
+ "PPT_MTEF_FALLBACK: exObjId={} has an invalid or unsupported Equation Native stream",
875
+ object_id,
876
+ )
877
+ continue
878
+ equations[object_id] = latex
879
+ return equations
880
+
881
+
882
+ def _chart_map(objects: dict[int, _EmbeddedOleObject]) -> dict[int, str]:
883
+ """从共享 OLE 对象集合解析 Excel.Chart 与 MSGraph.Chart 数据表。"""
884
+
885
+ charts: dict[int, str] = {}
886
+ for object_id, embedded in objects.items():
887
+ if embedded.subtype not in {_OLE_SUBTYPE_GRAPH, _OLE_SUBTYPE_EXCEL_CHART}:
888
+ continue
889
+ content = extract_embedded_chart_html_from_storage(embedded.storage)
890
+ if content is None:
891
+ logger.warning(
892
+ "PPT_CHART_FALLBACK: exObjId={} has no supported chart datasheet",
893
+ object_id,
894
+ )
895
+ continue
896
+ charts[object_id] = content
897
+ return charts
898
+
899
+
900
+ def _shape_complex_properties(shape: PptRecord, budget: RecordBudget) -> dict[int, bytes]:
901
+ """合并 shape 的复杂 FOPT 属性。"""
902
+
903
+ properties: dict[int, bytes] = {}
904
+ for child in _direct_children(shape, budget):
905
+ if child.record_type in {RT_OFFICEART_FOPT, RT_OFFICEART_TERTIARY_FOPT}:
906
+ properties.update(_fopt_complex_properties(child))
907
+ return properties
908
+
909
+
910
+ def _shape_type(shape: PptRecord, budget: RecordBudget) -> int | None:
911
+ """返回 OfficeArtFSP header 中的 MSOSPT 类型。"""
912
+
913
+ for child in _direct_children(shape, budget):
914
+ if child.record_type == RT_OFFICEART_FSP:
915
+ return int(child.instance)
916
+ return None
917
+
918
+
919
+ def _is_background_shape(shape: PptRecord, budget: RecordBudget) -> bool:
920
+ """判断 FSP 标志是否把 shape 标记为背景。"""
921
+
922
+ for child in _direct_children(shape, budget):
923
+ if child.record_type != RT_OFFICEART_FSP or len(child.payload) < 8:
924
+ continue
925
+ return bool(int(get_u32(child.payload, 4) or 0) & 0x0000_0400)
926
+ return False
927
+
928
+
929
+ def _is_placeholder(shape: PptRecord, budget: RecordBudget) -> bool:
930
+ """判断 shape 的 ClientData 是否包含 OEPlaceholderAtom。"""
931
+
932
+ for child in _direct_children(shape, budget):
933
+ if child.record_type != RT_OFFICEART_CLIENT_DATA:
934
+ continue
935
+ if child.version == CONTAINER_VERSION:
936
+ candidates: Iterable[PptRecord] = iter_descendants(child, budget=budget)
937
+ else:
938
+ candidates = iter_records(child.payload, budget=budget)
939
+ if any(candidate.record_type == RT_OE_PLACEHOLDER_ATOM for candidate in candidates):
940
+ return True
941
+ return False
942
+
943
+
944
+ def _client_rect(payload: bytes) -> tuple[float, float, float, float] | None:
945
+ """把 OfficeArtClientAnchor 转成 left/top/right/bottom。"""
946
+
947
+ if len(payload) < 8:
948
+ return None
949
+ top, left, right, bottom = struct.unpack_from("<4h", payload)
950
+ return float(left), float(top), float(right), float(bottom)
951
+
952
+
953
+ def _child_rect(payload: bytes) -> tuple[float, float, float, float] | None:
954
+ """把 OfficeArtChildAnchor 转成 left/top/right/bottom。"""
955
+
956
+ if len(payload) < 16:
957
+ return None
958
+ left, top, right, bottom = struct.unpack_from("<4i", payload)
959
+ return float(left), float(top), float(right), float(bottom)
960
+
961
+
962
+ def _map_group_rect(
963
+ rect: tuple[float, float, float, float],
964
+ space: _GroupSpace,
965
+ ) -> tuple[float, float, float, float]:
966
+ """把 group 子坐标线性映射到幻灯片坐标。"""
967
+
968
+ coord_width = max(1.0, float(space.coord_right - space.coord_left))
969
+ coord_height = max(1.0, float(space.coord_bottom - space.coord_top))
970
+ scale_x = (space.abs_right - space.abs_left) / coord_width
971
+ scale_y = (space.abs_bottom - space.abs_top) / coord_height
972
+ left, top, right, bottom = rect
973
+ return (
974
+ space.abs_left + (left - space.coord_left) * scale_x,
975
+ space.abs_top + (top - space.coord_top) * scale_y,
976
+ space.abs_left + (right - space.coord_left) * scale_x,
977
+ space.abs_top + (bottom - space.coord_top) * scale_y,
978
+ )
979
+
980
+
981
+ def _shape_bbox(
982
+ shape: PptRecord,
983
+ space: _GroupSpace | None,
984
+ budget: RecordBudget,
985
+ ) -> tuple[float, float, float, float] | None:
986
+ """解析 shape anchor,并应用父 group 坐标映射。"""
987
+
988
+ children = _direct_children(shape, budget)
989
+ for child in children:
990
+ if child.record_type == RT_OFFICEART_CLIENT_ANCHOR:
991
+ rect = _client_rect(child.payload)
992
+ if rect is not None:
993
+ return rect
994
+ for child in children:
995
+ if child.record_type != RT_OFFICEART_CHILD_ANCHOR:
996
+ continue
997
+ rect = _child_rect(child.payload)
998
+ if rect is None:
999
+ continue
1000
+ if space is not None:
1001
+ return _map_group_rect(rect, space)
1002
+ if max(abs(value) for value in rect) > 100_000:
1003
+ return tuple(value * 576.0 / 914_400.0 for value in rect) # type: ignore[return-value]
1004
+ return rect
1005
+ return None
1006
+
1007
+
1008
+ def _group_space(
1009
+ group_shape: PptRecord,
1010
+ parent: _GroupSpace | None,
1011
+ budget: RecordBudget,
1012
+ ) -> _GroupSpace | None:
1013
+ """解析 group 自身坐标系与它在父坐标系中的外框。"""
1014
+
1015
+ children = _direct_children(group_shape, budget)
1016
+ fspgr = next(
1017
+ (child for child in children if child.record_type == RT_OFFICEART_FSPGR and len(child.payload) >= 16),
1018
+ None,
1019
+ )
1020
+ if fspgr is None:
1021
+ return parent
1022
+ coord_left, coord_top, coord_right, coord_bottom = struct.unpack_from("<4i", fspgr.payload)
1023
+ if coord_right <= coord_left or coord_bottom <= coord_top:
1024
+ return parent
1025
+ raw_rect = None
1026
+ for child in children:
1027
+ if child.record_type == RT_OFFICEART_CLIENT_ANCHOR:
1028
+ raw_rect = _client_rect(child.payload)
1029
+ break
1030
+ if child.record_type == RT_OFFICEART_CHILD_ANCHOR:
1031
+ raw_rect = _child_rect(child.payload)
1032
+ break
1033
+ if raw_rect is None:
1034
+ return parent
1035
+ rect = _map_group_rect(raw_rect, parent) if parent is not None else raw_rect
1036
+ left, top, right, bottom = rect
1037
+ return _GroupSpace(
1038
+ coord_left=int(coord_left),
1039
+ coord_top=int(coord_top),
1040
+ coord_right=int(coord_right),
1041
+ coord_bottom=int(coord_bottom),
1042
+ abs_left=left,
1043
+ abs_top=top,
1044
+ abs_right=right,
1045
+ abs_bottom=bottom,
1046
+ )
1047
+
1048
+
1049
+ def _is_table_group(group_shape: PptRecord, budget: RecordBudget) -> bool:
1050
+ """读取 tableProperties.fIsTable 标志。"""
1051
+
1052
+ return bool(_shape_properties(group_shape, budget).get(FOPT_TABLE_PROPERTIES, 0) & 0x1)
1053
+
1054
+
1055
+ def _collect_shapes(slide: PptRecord, budget: RecordBudget) -> _ShapeCollection:
1056
+ """单次遍历收集叶子 shape,并保留 table group 的成员边界。"""
1057
+
1058
+ shapes: list[_ShapeInfo] = []
1059
+ groups: dict[tuple[int, ...], tuple[PptRecord, list[_ShapeInfo], int, bool]] = {}
1060
+ order = 0
1061
+
1062
+ def walk(
1063
+ record: PptRecord,
1064
+ space: _GroupSpace | None,
1065
+ path: tuple[int, ...],
1066
+ active_table: tuple[int, ...] | None,
1067
+ ) -> None:
1068
+ """递归遍历 OfficeArt 容器并传播 group 空间与 table 身份。"""
1069
+
1070
+ nonlocal order
1071
+ if record.record_type == RT_OFFICEART_SPGR_CONTAINER and record.version == CONTAINER_VERSION:
1072
+ children = _direct_children(record, budget)
1073
+ if not children:
1074
+ return
1075
+ group_shape = children[0]
1076
+ group_key = path + (0,)
1077
+ nested_space = _group_space(group_shape, space, budget)
1078
+ authoritative = _is_table_group(group_shape, budget)
1079
+ groups[group_key] = (group_shape, [], order, authoritative)
1080
+ table_key = group_key
1081
+ for index, child in enumerate(children[1:], start=1):
1082
+ walk(child, nested_space, path + (index,), table_key)
1083
+ return
1084
+ if record.record_type == RT_OFFICEART_SP_CONTAINER:
1085
+ bbox = _shape_bbox(record, space, budget)
1086
+ if bbox is None or bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
1087
+ return
1088
+ info = _ShapeInfo(
1089
+ key=path,
1090
+ record=record,
1091
+ space=space,
1092
+ bbox=bbox,
1093
+ order=order,
1094
+ group_key=active_table,
1095
+ )
1096
+ order += 1
1097
+ shapes.append(info)
1098
+ if active_table in groups:
1099
+ groups[active_table][1].append(info)
1100
+ return
1101
+ if record.version == CONTAINER_VERSION:
1102
+ for index, child in enumerate(_direct_children(record, budget)):
1103
+ walk(child, space, path + (index,), active_table)
1104
+
1105
+ walk(slide, None, (), None)
1106
+ table_groups = tuple(
1107
+ _TableGroup(
1108
+ key=key,
1109
+ group_shape=group_shape,
1110
+ shapes=tuple(members),
1111
+ order=group_order,
1112
+ authoritative=authoritative,
1113
+ )
1114
+ for key, (group_shape, members, group_order, authoritative) in groups.items()
1115
+ )
1116
+ return _ShapeCollection(shapes=tuple(shapes), table_groups=table_groups)
1117
+
1118
+
1119
+ def _shape_text_content(
1120
+ shape: _ShapeInfo,
1121
+ external_text: list[_TextContent],
1122
+ master_styles: dict[int, list[MasterLevel]],
1123
+ hyperlinks: dict[int, str],
1124
+ budget: RecordBudget,
1125
+ ) -> _TextContent | None:
1126
+ """解析 shape 的 ClientTextbox,必要时回退到 OutlineTextRefAtom。"""
1127
+
1128
+ textbox = next(
1129
+ (child for child in _direct_children(shape.record, budget) if child.record_type == RT_OFFICEART_CLIENT_TEXTBOX),
1130
+ None,
1131
+ )
1132
+ if textbox is None:
1133
+ return None
1134
+ textbox_records = list(iter_descendants(textbox, budget=budget))
1135
+ contents = _parse_text_contents(textbox_records, master_styles, hyperlinks, budget)
1136
+ if contents:
1137
+ return _apply_shape_numbering(contents[0], shape.record, budget)
1138
+ reference = next(
1139
+ (child for child in textbox_records if child.record_type == RT_OUTLINE_TEXT_REF_ATOM and len(child.payload) >= 4),
1140
+ None,
1141
+ )
1142
+ if reference is None:
1143
+ return None
1144
+ index = int(get_u32(reference.payload, 0) or 0)
1145
+ for candidate in (index, index - 1) if index else (0,):
1146
+ if 0 <= candidate < len(external_text):
1147
+ return _apply_shape_numbering(external_text[candidate], shape.record, budget)
1148
+ return None
1149
+
1150
+
1151
+ def _skip_style_text9_cf(payload: bytes, position: int) -> int | None:
1152
+ """跳过 TextCFException9;未知扩展位时停止该 atom 的解析。"""
1153
+
1154
+ mask = get_u32(payload, position)
1155
+ if mask is None:
1156
+ return None
1157
+ position += 4
1158
+ if mask == 0:
1159
+ return position
1160
+ # 当前只需要自动编号,复杂 CF9 不影响已解析文本样式,安全终止后续槽位。
1161
+ return None
1162
+
1163
+
1164
+ def _skip_style_text9_si(payload: bytes, position: int) -> int | None:
1165
+ """跳过 TextSIException 中固定长度的语言与拼写字段。"""
1166
+
1167
+ mask = get_u32(payload, position)
1168
+ if mask is None:
1169
+ return None
1170
+ position += 4
1171
+ for bit, size in ((0, 2), (1, 2), (2, 2), (5, 4), (6, 2)):
1172
+ if mask & (1 << bit):
1173
+ position += size
1174
+ if mask & (1 << 9) or position > len(payload):
1175
+ return None
1176
+ return position
1177
+
1178
+
1179
+ def _parse_style_text9_numbering(payload: bytes) -> dict[int, _NumberingStyle]:
1180
+ """解析 StyleTextProp9 数组中与自动编号有关的三个字段。"""
1181
+
1182
+ result: dict[int, _NumberingStyle] = {}
1183
+ position = 0
1184
+ slot = 0
1185
+ while position + 12 <= len(payload) and slot < 16:
1186
+ mask = get_u32(payload, position)
1187
+ if mask is None or mask & ~0x0380_0000:
1188
+ break
1189
+ position += 4
1190
+ if mask & 0x0080_0000:
1191
+ position += 2
1192
+ enabled = None
1193
+ if mask & 0x0200_0000:
1194
+ value = get_i16(payload, position)
1195
+ if value is None:
1196
+ break
1197
+ enabled = value == 1
1198
+ position += 2
1199
+ start = None
1200
+ if mask & 0x0100_0000:
1201
+ scheme = get_u16(payload, position)
1202
+ start_value = get_u16(payload, position + 2)
1203
+ if scheme is None or start_value is None:
1204
+ break
1205
+ start = max(0, int(start_value))
1206
+ position += 4
1207
+ position = _skip_style_text9_cf(payload, position) or -1
1208
+ if position < 0:
1209
+ break
1210
+ position = _skip_style_text9_si(payload, position) or -1
1211
+ if position < 0:
1212
+ break
1213
+ result[slot] = _NumberingStyle(enabled=enabled, start=start)
1214
+ slot += 1
1215
+ return result
1216
+
1217
+
1218
+ def _shape_numbering_styles(
1219
+ shape: PptRecord,
1220
+ budget: RecordBudget,
1221
+ ) -> dict[int, _NumberingStyle]:
1222
+ """从 PP9ShapeBinaryTagExtension 取出 StyleTextProp9 自动编号。"""
1223
+
1224
+ for tag in iter_descendants(shape, budget=budget):
1225
+ if tag.record_type != 0x138A or tag.version != CONTAINER_VERSION:
1226
+ continue
1227
+ children = list(iter_records(tag.payload, budget=budget))
1228
+ marker = next(
1229
+ (child for child in children if child.record_type == RT_CSTRING and utf16_text(child.payload) == "___PPT9"),
1230
+ None,
1231
+ )
1232
+ blob = next((child for child in children if child.record_type == 0x138B), None)
1233
+ if marker is None or blob is None:
1234
+ continue
1235
+ atom = record_at(blob.payload, 0, budget=budget)
1236
+ if atom is not None and atom.record_type == 0x0FAC:
1237
+ return _parse_style_text9_numbering(atom.payload)
1238
+ return {}
1239
+
1240
+
1241
+ def _apply_shape_numbering(
1242
+ content: _TextContent,
1243
+ shape: PptRecord,
1244
+ budget: RecordBudget,
1245
+ ) -> _TextContent:
1246
+ """按 paragraph 起始字符的 pp9rt 槽位覆盖列表类型和起始编号。"""
1247
+
1248
+ numbering = _shape_numbering_styles(shape, budget)
1249
+ if not numbering:
1250
+ return content
1251
+ paragraphs: list[PptParagraph] = []
1252
+ for paragraph in content.paragraphs:
1253
+ style = numbering.get(paragraph.pp9rt)
1254
+ if style is None or style.enabled is None:
1255
+ paragraphs.append(paragraph)
1256
+ continue
1257
+ paragraphs.append(
1258
+ replace(
1259
+ paragraph,
1260
+ list_kind="ordered" if style.enabled else None,
1261
+ start=style.start if style.enabled else None,
1262
+ )
1263
+ )
1264
+ return replace(content, paragraphs=tuple(paragraphs))
1265
+
1266
+
1267
+ def _cluster_coordinates(values: list[float], tolerance: float = 8.0) -> list[float]:
1268
+ """把生产器舍入误差导致的近邻坐标合并为稳定边界。"""
1269
+
1270
+ if not values:
1271
+ return []
1272
+ clusters: list[list[float]] = []
1273
+ for value in sorted(values):
1274
+ if clusters and abs(value - sum(clusters[-1]) / len(clusters[-1])) <= tolerance:
1275
+ clusters[-1].append(value)
1276
+ else:
1277
+ clusters.append([value])
1278
+ return [sum(cluster) / len(cluster) for cluster in clusters]
1279
+
1280
+
1281
+ def _boundary_index(boundaries: list[float], value: float, tolerance: float = 16.0) -> int | None:
1282
+ """返回与坐标最近的边界索引,偏差过大时拒绝映射。"""
1283
+
1284
+ if not boundaries:
1285
+ return None
1286
+ index = min(range(len(boundaries)), key=lambda candidate: abs(boundaries[candidate] - value))
1287
+ return index if abs(boundaries[index] - value) <= tolerance else None
1288
+
1289
+
1290
+ def _table_from_group(
1291
+ group: _TableGroup,
1292
+ external_text: list[_TextContent],
1293
+ master_styles: dict[int, list[MasterLevel]],
1294
+ hyperlinks: dict[int, str],
1295
+ budget: RecordBudget,
1296
+ ) -> PptTableElement | None:
1297
+ """从 fIsTable group 的矩形单元格恢复含合并信息的完整网格。"""
1298
+
1299
+ cell_shapes: list[tuple[_ShapeInfo, _TextContent | None]] = []
1300
+ x_values: list[float] = []
1301
+ y_values: list[float] = []
1302
+ for shape in group.shapes:
1303
+ shape_type = _shape_type(shape.record, budget)
1304
+ left, top, right, bottom = shape.bbox
1305
+ width = right - left
1306
+ height = bottom - top
1307
+ # 线条只贡献边界,不成为单元格。
1308
+ if shape_type in {20, 32, 33, 34, 35, 36, 37, 38, 39, 40} or width <= 2 or height <= 2:
1309
+ if width <= 2:
1310
+ x_values.extend((left, right))
1311
+ if height <= 2:
1312
+ y_values.extend((top, bottom))
1313
+ continue
1314
+ if shape_type != 1:
1315
+ continue
1316
+ content = _shape_text_content(
1317
+ shape,
1318
+ external_text,
1319
+ master_styles,
1320
+ hyperlinks,
1321
+ budget,
1322
+ )
1323
+ cell_shapes.append((shape, content))
1324
+ x_values.extend((left, right))
1325
+ y_values.extend((top, bottom))
1326
+ if len(cell_shapes) < 4:
1327
+ return None
1328
+
1329
+ x_boundaries = _cluster_coordinates(x_values)
1330
+ y_boundaries = _cluster_coordinates(y_values)
1331
+ cols = len(x_boundaries) - 1
1332
+ rows = len(y_boundaries) - 1
1333
+ if rows < 2 or cols < 2 or rows * cols > MAX_GRID_SLOTS:
1334
+ return None
1335
+
1336
+ coverage: dict[tuple[int, int], tuple[int, int]] = {}
1337
+ cells: list[PptTableCell] = []
1338
+ for shape, content in cell_shapes:
1339
+ left, top, right, bottom = shape.bbox
1340
+ col_start = _boundary_index(x_boundaries, left)
1341
+ col_end = _boundary_index(x_boundaries, right)
1342
+ row_start = _boundary_index(y_boundaries, top)
1343
+ row_end = _boundary_index(y_boundaries, bottom)
1344
+ if (
1345
+ col_start is None
1346
+ or col_end is None
1347
+ or row_start is None
1348
+ or row_end is None
1349
+ or col_end <= col_start
1350
+ or row_end <= row_start
1351
+ ):
1352
+ return None
1353
+ for row in range(row_start, row_end):
1354
+ for col in range(col_start, col_end):
1355
+ if (row, col) in coverage:
1356
+ return None
1357
+ coverage[(row, col)] = (row_start, col_start)
1358
+ cells.append(
1359
+ PptTableCell(
1360
+ row=row_start,
1361
+ col=col_start,
1362
+ row_span=row_end - row_start,
1363
+ col_span=col_end - col_start,
1364
+ paragraphs=content.paragraphs if content is not None else (),
1365
+ )
1366
+ )
1367
+ if len(coverage) != rows * cols:
1368
+ return None
1369
+
1370
+ cells.sort(key=lambda cell: (cell.row, cell.col))
1371
+ return PptTableElement(
1372
+ rows=rows,
1373
+ cols=cols,
1374
+ cells=tuple(cells),
1375
+ bbox=(x_boundaries[0], y_boundaries[0], x_boundaries[-1], y_boundaries[-1]),
1376
+ order=group.order,
1377
+ shape_offsets=frozenset(shape.key for shape in group.shapes),
1378
+ )
1379
+
1380
+
1381
+ def _decode_blip(record: PptRecord) -> _ImagePayload | None:
1382
+ """通过 legacy-office 共享层解码一个 OfficeArt BLIP。"""
1383
+
1384
+ return decode_officeart_blip(
1385
+ OfficeArtRecord(
1386
+ offset=record.offset,
1387
+ version=record.version,
1388
+ instance=record.instance,
1389
+ record_type=record.record_type,
1390
+ payload=record.payload,
1391
+ )
1392
+ )
1393
+
1394
+
1395
+ def _decode_bse_body(body: bytes, budget: RecordBudget) -> _ImagePayload | None:
1396
+ """从 FBSE body 的可选内嵌 BLIP 中提取图片。"""
1397
+
1398
+ if len(body) < 36:
1399
+ return None
1400
+ name_length = body[33]
1401
+ inner_offset = 36 + int(name_length)
1402
+ inner = record_at(body, inner_offset, budget=budget)
1403
+ return _decode_blip(inner) if inner is not None else None
1404
+
1405
+
1406
+ def _picture_map(
1407
+ document: PptRecord,
1408
+ pictures: bytes,
1409
+ budget: RecordBudget,
1410
+ ) -> dict[int, _ImagePayload]:
1411
+ """按 BStore 中 1-based BSE 序号建立图片资源映射。"""
1412
+
1413
+ result: dict[int, _ImagePayload] = {}
1414
+ asset_total = 0
1415
+ bse_records = [record for record in iter_descendants(document, budget=budget) if record.record_type == RT_OFFICEART_BSE]
1416
+ for index, bse in enumerate(bse_records[:MAX_PICTURE_RECORDS], start=1):
1417
+ decoded = None
1418
+ picture_offset = get_u32(bse.payload, 28)
1419
+ if picture_offset is not None and picture_offset < len(pictures):
1420
+ picture_record = record_at(pictures, int(picture_offset), budget=budget)
1421
+ if picture_record is not None:
1422
+ if picture_record.record_type == RT_OFFICEART_BSE:
1423
+ decoded = _decode_bse_body(picture_record.payload, budget)
1424
+ else:
1425
+ decoded = _decode_blip(picture_record)
1426
+ if decoded is None:
1427
+ decoded = _decode_bse_body(bse.payload, budget)
1428
+ if decoded is None:
1429
+ continue
1430
+ asset_total += len(decoded.data)
1431
+ if asset_total > MAX_ASSET_TOTAL_BYTES:
1432
+ raise LegacyOfficeResourceLimitError(f"embedded assets exceed max_asset_total_bytes={MAX_ASSET_TOTAL_BYTES}")
1433
+ result[index] = decoded
1434
+ if len(bse_records) > MAX_PICTURE_RECORDS:
1435
+ logger.warning(f"PPT_PICTURE_LIMIT: ignored BSE records after {MAX_PICTURE_RECORDS}")
1436
+ return result
1437
+
1438
+
1439
+ def _image_from_shape(
1440
+ shape: _ShapeInfo,
1441
+ image_map: dict[int, _ImagePayload],
1442
+ equation_decoder: OfficeImageEquationDecoder,
1443
+ budget: RecordBudget,
1444
+ ) -> PptImageElement | PptEquationElement | None:
1445
+ """把 shape 的 pib 属性解析为图片或 comment 内公式。"""
1446
+
1447
+ reference = _shape_properties(shape.record, budget).get(FOPT_PIB)
1448
+ if reference is None:
1449
+ return None
1450
+ image = image_map.get(int(reference))
1451
+ if image is None:
1452
+ logger.warning(f"PPT_IMAGE_REFERENCE_MISSING: shape={shape.key}, pib={reference}")
1453
+ return None
1454
+ latex = equation_decoder.decode(
1455
+ image.data,
1456
+ part_name=f"picture.{image.extension}",
1457
+ content_type=image.content_type,
1458
+ )
1459
+ if latex:
1460
+ return PptEquationElement(
1461
+ latex=latex,
1462
+ bbox=shape.bbox,
1463
+ order=shape.order,
1464
+ shape_offset=shape.order,
1465
+ )
1466
+ data_uri = serialize_office_image(
1467
+ image.data,
1468
+ part_name=f"picture.{image.extension}",
1469
+ content_type=image.content_type,
1470
+ render_size_emu=image.render_size_emu,
1471
+ )
1472
+ if not data_uri:
1473
+ logger.warning(f"PPT_IMAGE_UNSUPPORTED: shape={shape.key}, type={image.content_type}")
1474
+ return None
1475
+ return PptImageElement(
1476
+ image_base64=data_uri,
1477
+ bbox=shape.bbox,
1478
+ order=shape.order,
1479
+ shape_offset=shape.order,
1480
+ )
1481
+
1482
+
1483
+ def _is_small_picture(
1484
+ bbox: tuple[float, float, float, float],
1485
+ slide_width: int,
1486
+ slide_height: int,
1487
+ ) -> bool:
1488
+ """复用现代 PPTX 的尺寸阈值过滤装饰性小图。"""
1489
+
1490
+ width = bbox[2] - bbox[0]
1491
+ height = bbox[3] - bbox[1]
1492
+ if width <= 0 or height <= 0 or slide_width <= 0 or slide_height <= 0:
1493
+ return False
1494
+ if width < 0.1 * slide_width or height < 0.1 * slide_height:
1495
+ return True
1496
+ return width * height / float(slide_width * slide_height) < 0.01
1497
+
1498
+
1499
+ def _slide_elements(
1500
+ slide: PptRecord,
1501
+ external_text: list[_TextContent],
1502
+ master_styles: dict[int, list[MasterLevel]],
1503
+ hyperlinks: dict[int, str],
1504
+ image_map: dict[int, _ImagePayload],
1505
+ equation_map: dict[int, str],
1506
+ chart_map: dict[int, str],
1507
+ image_equation_decoder: OfficeImageEquationDecoder,
1508
+ slide_width: int,
1509
+ slide_height: int,
1510
+ budget: RecordBudget,
1511
+ ) -> list[PptTextElement | PptImageElement | PptEquationElement | PptChartElement | PptTableElement]:
1512
+ """把一张 slide 的 shapes 转换为文本、表格、chart 和图片元素。"""
1513
+
1514
+ collection = _collect_shapes(slide, budget)
1515
+ tables: list[PptTableElement] = []
1516
+ consumed_keys: set[tuple[int, ...]] = set()
1517
+ for group in sorted(
1518
+ collection.table_groups,
1519
+ key=lambda candidate: (not candidate.authoritative, candidate.order),
1520
+ ):
1521
+ if any(shape.key in consumed_keys for shape in group.shapes):
1522
+ continue
1523
+ table = _table_from_group(
1524
+ group,
1525
+ external_text,
1526
+ master_styles,
1527
+ hyperlinks,
1528
+ budget,
1529
+ )
1530
+ if table is None and group.authoritative:
1531
+ logger.warning(f"PPT_TABLE_RECOVERY_FAILED: group={group.key}")
1532
+ continue
1533
+ if table is None:
1534
+ continue
1535
+ tables.append(table)
1536
+ consumed_keys.update(shape.key for shape in group.shapes)
1537
+
1538
+ elements: list[PptTextElement | PptImageElement | PptEquationElement | PptChartElement | PptTableElement] = list(tables)
1539
+ for shape in collection.shapes:
1540
+ if shape.key in consumed_keys or _is_background_shape(shape.record, budget):
1541
+ continue
1542
+ external_object_id = _shape_external_object_id(shape.record, budget)
1543
+ chart = chart_map.get(external_object_id or 0)
1544
+ if chart:
1545
+ preview = _image_from_shape(
1546
+ shape,
1547
+ image_map,
1548
+ image_equation_decoder,
1549
+ budget,
1550
+ )
1551
+ elements.append(
1552
+ PptChartElement(
1553
+ content=chart,
1554
+ image_base64=(preview.image_base64 if isinstance(preview, PptImageElement) else None),
1555
+ bbox=shape.bbox,
1556
+ order=shape.order,
1557
+ shape_offset=shape.order,
1558
+ )
1559
+ )
1560
+ continue
1561
+ equation = equation_map.get(external_object_id or 0)
1562
+ if equation:
1563
+ elements.append(
1564
+ PptEquationElement(
1565
+ latex=equation,
1566
+ bbox=shape.bbox,
1567
+ order=shape.order,
1568
+ shape_offset=shape.order,
1569
+ )
1570
+ )
1571
+ continue
1572
+ content = _shape_text_content(
1573
+ shape,
1574
+ external_text,
1575
+ master_styles,
1576
+ hyperlinks,
1577
+ budget,
1578
+ )
1579
+ if content is not None and content.paragraphs:
1580
+ elements.append(
1581
+ PptTextElement(
1582
+ paragraphs=content.paragraphs,
1583
+ text_type=content.text_type,
1584
+ bbox=shape.bbox,
1585
+ order=shape.order,
1586
+ shape_offset=shape.order,
1587
+ is_placeholder=_is_placeholder(shape.record, budget),
1588
+ )
1589
+ )
1590
+ image_or_equation = _image_from_shape(
1591
+ shape,
1592
+ image_map,
1593
+ image_equation_decoder,
1594
+ budget,
1595
+ )
1596
+ if isinstance(image_or_equation, PptEquationElement):
1597
+ elements.append(image_or_equation)
1598
+ elif image_or_equation is not None and not _is_small_picture(
1599
+ image_or_equation.bbox,
1600
+ slide_width,
1601
+ slide_height,
1602
+ ):
1603
+ elements.append(image_or_equation)
1604
+ if not any(isinstance(element, PptTextElement) for element in elements):
1605
+ # Handmade、早期生产器或恢复路径可能把文本直接放在 SlideContainer 中。
1606
+ raw_contents = _parse_text_contents(
1607
+ list(iter_descendants(slide, budget=budget)),
1608
+ master_styles,
1609
+ hyperlinks,
1610
+ budget,
1611
+ )
1612
+ for offset, content in enumerate(raw_contents):
1613
+ if not content.paragraphs:
1614
+ continue
1615
+ elements.append(
1616
+ PptTextElement(
1617
+ paragraphs=content.paragraphs,
1618
+ text_type=content.text_type,
1619
+ bbox=(
1620
+ 288.0,
1621
+ 288.0 + offset * 432.0,
1622
+ float(max(slide_width - 288, 576)),
1623
+ 576.0 + offset * 432.0,
1624
+ ),
1625
+ order=len(elements) + offset,
1626
+ shape_offset=len(elements) + offset,
1627
+ )
1628
+ )
1629
+ return elements
1630
+
1631
+
1632
+ def _slide_hidden(slide: PptRecord, budget: RecordBudget) -> bool:
1633
+ """读取 SlideShowSlideInfoAtom 的隐藏标志。"""
1634
+
1635
+ for child in iter_descendants(slide, budget=budget):
1636
+ if child.record_type != RT_SLIDE_SHOW_SLIDE_INFO_ATOM or len(child.payload) < 12:
1637
+ continue
1638
+ flags = get_u16(child.payload, 10)
1639
+ return bool(int(flags or 0) & 0x0004)
1640
+ return False
1641
+
1642
+
1643
+ def _plain_paragraph_text(paragraph: PptParagraph) -> str:
1644
+ """返回一个内部段落的纯文本。"""
1645
+
1646
+ return "".join(run.text for run in paragraph.runs)
1647
+
1648
+
1649
+ def _note_paragraphs(
1650
+ note_record: PptRecord,
1651
+ master_styles: dict[int, list[MasterLevel]],
1652
+ hyperlinks: dict[int, str],
1653
+ budget: RecordBudget,
1654
+ ) -> list[PptParagraph]:
1655
+ """提取 notes container 中的正文,排除占位符字段。"""
1656
+
1657
+ paragraphs: list[PptParagraph] = []
1658
+ collection = _collect_shapes(note_record, budget)
1659
+ for shape in collection.shapes:
1660
+ content = _shape_text_content(shape, [], master_styles, hyperlinks, budget)
1661
+ if content is None:
1662
+ continue
1663
+ for paragraph in content.paragraphs:
1664
+ text = _plain_paragraph_text(paragraph).strip()
1665
+ if text and text != "*":
1666
+ paragraphs.append(paragraph)
1667
+ if paragraphs:
1668
+ return paragraphs
1669
+
1670
+ # 少数生产器不把 notes 文本包在 OfficeArtClientTextbox 中。
1671
+ all_records = list(iter_descendants(note_record, budget=budget))
1672
+ for content in _parse_text_contents(all_records, master_styles, hyperlinks, budget):
1673
+ for paragraph in content.paragraphs:
1674
+ text = _plain_paragraph_text(paragraph).strip()
1675
+ if text and text != "*":
1676
+ paragraphs.append(paragraph)
1677
+ return paragraphs
1678
+
1679
+
1680
+ def _notes_by_slide_id(
1681
+ layout: _PersistLayout,
1682
+ data: bytes,
1683
+ fallback_master_styles: dict[int, list[MasterLevel]],
1684
+ hyperlinks: dict[int, str],
1685
+ budget: RecordBudget,
1686
+ ) -> tuple[dict[int, list[PptParagraph]], list[list[PptParagraph]]]:
1687
+ """按 NotesAtom.slideIdRef 绑定备注,并保留无主备注的顺序。"""
1688
+
1689
+ bound: dict[int, list[PptParagraph]] = {}
1690
+ unbound: list[list[PptParagraph]] = []
1691
+ for reference in _notes_entries(layout.document, budget):
1692
+ offset = layout.persist.get(reference)
1693
+ if offset is None:
1694
+ continue
1695
+ note = record_at(data, offset, budget=budget)
1696
+ if note is None or note.record_type != RT_NOTES:
1697
+ continue
1698
+ slide_id = None
1699
+ for child in iter_descendants(note, budget=budget):
1700
+ if child.record_type == RT_NOTES_ATOM:
1701
+ slide_id = get_u32(child.payload, 0)
1702
+ break
1703
+ paragraphs = _note_paragraphs(
1704
+ note,
1705
+ fallback_master_styles,
1706
+ hyperlinks,
1707
+ budget,
1708
+ )
1709
+ if not paragraphs:
1710
+ continue
1711
+ if slide_id:
1712
+ bound[int(slide_id)] = paragraphs
1713
+ else:
1714
+ unbound.append(paragraphs)
1715
+ return bound, unbound
1716
+
1717
+
1718
+ def _root_slide_records(data: bytes, budget: RecordBudget) -> list[PptRecord]:
1719
+ """恢复 persist 不可用时直接位于文档 stream 顶层的 slide records。"""
1720
+
1721
+ return [
1722
+ record
1723
+ for record in iter_records(data, budget=budget)
1724
+ if record.record_type == RT_SLIDE and record.version == CONTAINER_VERSION
1725
+ ]
1726
+
1727
+
1728
+ def _fallback_slide_from_text(
1729
+ data: bytes,
1730
+ master_styles: dict[int, list[MasterLevel]],
1731
+ hyperlinks: dict[int, str],
1732
+ budget: RecordBudget,
1733
+ ) -> PptSlide | None:
1734
+ """没有可靠 slide 边界时,把可恢复文本放入单个逻辑页。"""
1735
+
1736
+ contents = _parse_text_contents(
1737
+ list(iter_records(data, budget=budget)),
1738
+ master_styles,
1739
+ hyperlinks,
1740
+ budget,
1741
+ )
1742
+ elements: list[PptTextElement] = []
1743
+ for order, content in enumerate(contents):
1744
+ if not content.paragraphs:
1745
+ continue
1746
+ elements.append(
1747
+ PptTextElement(
1748
+ paragraphs=content.paragraphs,
1749
+ text_type=content.text_type,
1750
+ bbox=(288.0, 288.0 + order * 432.0, 5472.0, 576.0 + order * 432.0),
1751
+ order=order,
1752
+ shape_offset=order,
1753
+ )
1754
+ )
1755
+ return PptSlide(slide_id=None, elements=list(elements)) if elements else None
1756
+
1757
+
1758
+ def parse_ppt_document(
1759
+ powerpoint_document: bytes,
1760
+ *,
1761
+ current_user: bytes = b"",
1762
+ pictures: bytes = b"",
1763
+ ) -> PptPresentation:
1764
+ """把三个核心 PPT streams 解析为分页内部语义模型。"""
1765
+
1766
+ if current_user:
1767
+ record_type = get_u16(current_user, 2)
1768
+ if record_type not in {None, 0x0FF6}:
1769
+ raise LegacyOfficeMalformedError("PowerPoint 95 or earlier Current User stream is unsupported")
1770
+ if get_u32(current_user, 12) == 0xF3D1_C4DF:
1771
+ raise LegacyOfficeEncryptedError("password-protected PPT is unsupported")
1772
+
1773
+ budget = RecordBudget()
1774
+ # 先完整验证可递归记录深度,避免无 Document 的攻击形状被误报为普通坏文件。
1775
+ for root_record in iter_records(
1776
+ powerpoint_document,
1777
+ budget=budget,
1778
+ strict_first=True,
1779
+ ):
1780
+ for _ in iter_descendants(root_record, budget=budget):
1781
+ pass
1782
+ layout = _locate_document(powerpoint_document, current_user, budget)
1783
+ if any(child.record_type == RT_CRYPT_SESSION10_CONTAINER for child in iter_descendants(layout.document, budget=budget)):
1784
+ raise LegacyOfficeEncryptedError("encrypted PPT record stream is unsupported")
1785
+
1786
+ width, height = _presentation_size(layout.document, budget)
1787
+ hyperlinks = _hyperlink_targets(layout.document, budget)
1788
+ image_map = _picture_map(layout.document, pictures, budget)
1789
+ image_equation_decoder = OfficeImageEquationDecoder()
1790
+ embedded_objects = _embedded_object_map(layout, powerpoint_document, budget)
1791
+ native_equations = _equation_map(embedded_objects)
1792
+ native_charts = _chart_map(embedded_objects)
1793
+ master_map, fallback_master = _collect_masters(
1794
+ layout,
1795
+ powerpoint_document,
1796
+ budget,
1797
+ )
1798
+ fallback_styles = fallback_master[1] if fallback_master is not None else {}
1799
+ external_records = _external_text_records(layout.document, budget)
1800
+ bound_notes, unbound_notes = _notes_by_slide_id(
1801
+ layout,
1802
+ powerpoint_document,
1803
+ fallback_styles,
1804
+ hyperlinks,
1805
+ budget,
1806
+ )
1807
+
1808
+ slides: list[PptSlide] = []
1809
+ resolved_offsets: set[int] = set()
1810
+ resolved_slide_count = 0
1811
+ unbound_note_index = 0
1812
+ for reference, slide_id in _slide_entries(layout.document, budget):
1813
+ offset = layout.persist.get(reference)
1814
+ notes = bound_notes.get(slide_id)
1815
+ if notes is None and unbound_note_index < len(unbound_notes):
1816
+ notes = unbound_notes[unbound_note_index]
1817
+ unbound_note_index += 1
1818
+ if offset is None or offset in resolved_offsets:
1819
+ logger.warning(f"PPT_SLIDE_MISSING: persist_ref={reference}, slide_id={slide_id}")
1820
+ slides.append(PptSlide(slide_id=slide_id or None, notes=list(notes or [])))
1821
+ continue
1822
+ slide = record_at(powerpoint_document, offset, budget=budget)
1823
+ if slide is None or slide.record_type != RT_SLIDE:
1824
+ logger.warning(f"PPT_SLIDE_MALFORMED: persist_ref={reference}, slide_id={slide_id}")
1825
+ slides.append(PptSlide(slide_id=slide_id or None, notes=list(notes or [])))
1826
+ continue
1827
+ resolved_offsets.add(offset)
1828
+ resolved_slide_count += 1
1829
+ master = master_map.get(_slide_master_id(slide, budget) or -1, fallback_master)
1830
+ master_styles = master[1] if master is not None else {}
1831
+ external_text = _parse_text_contents(
1832
+ external_records.get(reference, []),
1833
+ master_styles,
1834
+ hyperlinks,
1835
+ budget,
1836
+ )
1837
+ slides.append(
1838
+ PptSlide(
1839
+ slide_id=slide_id or None,
1840
+ elements=_slide_elements(
1841
+ slide,
1842
+ external_text,
1843
+ master_styles,
1844
+ hyperlinks,
1845
+ image_map,
1846
+ native_equations,
1847
+ native_charts,
1848
+ image_equation_decoder,
1849
+ width,
1850
+ height,
1851
+ budget,
1852
+ ),
1853
+ notes=list(notes or []),
1854
+ hidden=_slide_hidden(slide, budget),
1855
+ )
1856
+ )
1857
+
1858
+ if resolved_slide_count == 0:
1859
+ slides = []
1860
+ logger.warning("PPT_SLIDE_RECOVERY: persist mapping did not resolve slide records")
1861
+ for slide in _root_slide_records(powerpoint_document, budget):
1862
+ master = master_map.get(_slide_master_id(slide, budget) or -1, fallback_master)
1863
+ master_styles = master[1] if master is not None else {}
1864
+ slides.append(
1865
+ PptSlide(
1866
+ slide_id=None,
1867
+ elements=_slide_elements(
1868
+ slide,
1869
+ [],
1870
+ master_styles,
1871
+ hyperlinks,
1872
+ image_map,
1873
+ native_equations,
1874
+ native_charts,
1875
+ image_equation_decoder,
1876
+ width,
1877
+ height,
1878
+ budget,
1879
+ ),
1880
+ hidden=_slide_hidden(slide, budget),
1881
+ )
1882
+ )
1883
+ if not slides:
1884
+ fallback_slide = _fallback_slide_from_text(
1885
+ powerpoint_document,
1886
+ fallback_styles,
1887
+ hyperlinks,
1888
+ budget,
1889
+ )
1890
+ if fallback_slide is not None:
1891
+ logger.warning("PPT_SINGLE_PAGE_RECOVERY: slide boundaries were not recoverable")
1892
+ slides.append(fallback_slide)
1893
+ if not slides:
1894
+ raise LegacyOfficeMalformedError("PPT contains no recoverable slides or text")
1895
+ return PptPresentation(slides=slides, width=width, height=height)