docslicer 0.2.2__tar.gz → 0.2.4__tar.gz

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 (157) hide show
  1. {docslicer-0.2.2/src/docslicer.egg-info → docslicer-0.2.4}/PKG-INFO +19 -3
  2. {docslicer-0.2.2 → docslicer-0.2.4}/README.md +17 -1
  3. {docslicer-0.2.2 → docslicer-0.2.4}/pyproject.toml +2 -2
  4. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/__init__.py +1 -1
  5. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_result.py +14 -4
  6. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_aggregation/registry_aggregator.py +1 -0
  7. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_export/reorder_columns.py +1 -0
  8. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_04_line_builder.py +132 -5
  9. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/mcp/_store.py +97 -12
  10. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/mcp/server.py +120 -19
  11. {docslicer-0.2.2 → docslicer-0.2.4/src/docslicer.egg-info}/PKG-INFO +19 -3
  12. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer.egg-info/SOURCES.txt +0 -1
  13. {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_charts.py +1 -1
  14. {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_exports.py +4 -4
  15. docslicer-0.2.2/LICENSE-COMMERCIAL.md +0 -29
  16. {docslicer-0.2.2 → docslicer-0.2.4}/LICENSE +0 -0
  17. {docslicer-0.2.2 → docslicer-0.2.4}/setup.cfg +0 -0
  18. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_config.py +0 -0
  19. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_orchestrator.py +0 -0
  20. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/__init__.py +0 -0
  21. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/color_utils.py +0 -0
  22. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/cpu.py +0 -0
  23. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_aggregation/__init__.py +0 -0
  24. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_aggregation/text_merge.py +0 -0
  25. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_export/__init__.py +0 -0
  26. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/df_export/export_debug.py +0 -0
  27. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/__init__.py +0 -0
  28. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/yaml_compilers/__init__.py +0 -0
  29. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/yaml_compilers/exhibit_patterns.py +0 -0
  30. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/yaml_compilers/hierarchy_type_patterns.py +0 -0
  31. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/yaml_compilers/page_label_patterns.py +0 -0
  32. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/io/yaml_loader.py +0 -0
  33. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/__init__.py +0 -0
  34. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/gutter_detector.py +0 -0
  35. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/layouts.py +0 -0
  36. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/line_merger.py +0 -0
  37. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/line_number_detector.py +0 -0
  38. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/reading_order.py +0 -0
  39. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/layout/shape_processor.py +0 -0
  40. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/oxm_package.py +0 -0
  41. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/parallel.py +0 -0
  42. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/password.py +0 -0
  43. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/safe_call.py +0 -0
  44. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/table/__init__.py +0 -0
  45. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/table/table_header.py +0 -0
  46. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/table/table_normalize.py +0 -0
  47. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/table/table_schema.py +0 -0
  48. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/text_utils.py +0 -0
  49. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/_utils/timing.py +0 -0
  50. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/cli.py +0 -0
  51. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/config/common_author_names.csv +0 -0
  52. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/config/exhibit_patterns.yaml +0 -0
  53. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/config/hierarchy_type_patterns.yaml +0 -0
  54. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/config/page_label_patterns.yaml +0 -0
  55. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/__init__.py +0 -0
  56. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/docx_orchestrator.py +0 -0
  57. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/native_metadata.py +0 -0
  58. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_01_package_reader.py +0 -0
  59. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_02_run_extractor.py +0 -0
  60. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_03_chart_point_builder.py +0 -0
  61. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_04_table_cell_builder.py +0 -0
  62. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_05_paragraph_builder.py +0 -0
  63. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_06_line_builder.py +0 -0
  64. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/docx/step_07_style_prefiller.py +0 -0
  65. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/__init__.py +0 -0
  66. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/extract_boxes.js +0 -0
  67. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/html_orchestrator.py +0 -0
  68. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/native_metadata.py +0 -0
  69. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_01_box_extractor.py +0 -0
  70. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_01_static_box_extractor.py +0 -0
  71. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_02_box_cleaner.py +0 -0
  72. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_03_page_label_detector.py +0 -0
  73. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_05_table_extractor.py +0 -0
  74. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/html/step_06_style_prefiller.py +0 -0
  75. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/mcp/__init__.py +0 -0
  76. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/__init__.py +0 -0
  77. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/consolidate.py +0 -0
  78. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/generator.py +0 -0
  79. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/ocr_detector.py +0 -0
  80. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/page_analysis.py +0 -0
  81. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/schema.py +0 -0
  82. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/metadata/text_fallback.py +0 -0
  83. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/__init__.py +0 -0
  84. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/_availability.py +0 -0
  85. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/ocr_orchestrator.py +0 -0
  86. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/step_01_word_extractor.py +0 -0
  87. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/step_02_word_colorizer.py +0 -0
  88. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/step_03_shape_extractor.py +0 -0
  89. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/step_04_text_cleaner.py +0 -0
  90. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/ocr/step_05_font_size_estimator.py +0 -0
  91. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/__init__.py +0 -0
  92. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/__init__.py +0 -0
  93. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/coordinates.py +0 -0
  94. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/form_fields.py +0 -0
  95. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/form_label_link.py +0 -0
  96. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/line_classification.py +0 -0
  97. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/page_rotation.py +0 -0
  98. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/script_thresholds.py +0 -0
  99. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/struct_context.py +0 -0
  100. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/_utils/struct_tree.py +0 -0
  101. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/native_metadata.py +0 -0
  102. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/pdf_orchestrator.py +0 -0
  103. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_01_word_extractor.py +0 -0
  104. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_02_image_extractor.py +0 -0
  105. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_03_shape_extractor.py +0 -0
  106. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_04_link_extractor.py +0 -0
  107. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_05_struct_group.py +0 -0
  108. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_06_style_prefiller.py +0 -0
  109. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_07_stream_group.py +0 -0
  110. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_08_reading_order.py +0 -0
  111. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_09_word_relationships.py +0 -0
  112. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_10_cell_builder.py +0 -0
  113. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_11_page_label_detector.py +0 -0
  114. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_12_cell_grouper.py +0 -0
  115. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_13_line_builder.py +0 -0
  116. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pdf/step_14_table_builder.py +0 -0
  117. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/__init__.py +0 -0
  118. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/native_metadata.py +0 -0
  119. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/pptx_orchestrator.py +0 -0
  120. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_01_package_reader.py +0 -0
  121. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_02_run_extractor.py +0 -0
  122. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_03_chart_point_builder.py +0 -0
  123. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_04_table_cell_builder.py +0 -0
  124. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_05_paragraph_builder.py +0 -0
  125. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_06_reading_order.py +0 -0
  126. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_07_line_builder.py +0 -0
  127. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/pptx/step_08_style_prefiller.py +0 -0
  128. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/__init__.py +0 -0
  129. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/config.py +0 -0
  130. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/cookie_consent.js +0 -0
  131. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/dispatcher.py +0 -0
  132. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/fetchers/__init__.py +0 -0
  133. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/fetchers/http_fetcher.py +0 -0
  134. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/fetchers/sec_fetcher.py +0 -0
  135. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/models.py +0 -0
  136. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/scraping/stealth_init.js +0 -0
  137. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/__init__.py +0 -0
  138. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/shared_orchestrator.py +0 -0
  139. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_01_navigation_detector.py +0 -0
  140. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_02_toc_detector.py +0 -0
  141. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_03_exhibit_detector.py +0 -0
  142. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_04_section_classifier.py +0 -0
  143. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_05_heading_detector.py +0 -0
  144. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_06_hierarchy_builder.py +0 -0
  145. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_07_block_merger.py +0 -0
  146. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer/shared/step_08_chunk_builder.py +0 -0
  147. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer.egg-info/dependency_links.txt +0 -0
  148. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer.egg-info/entry_points.txt +0 -0
  149. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer.egg-info/requires.txt +0 -0
  150. {docslicer-0.2.2 → docslicer-0.2.4}/src/docslicer.egg-info/top_level.txt +0 -0
  151. {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_api.py +0 -0
  152. {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_document_parser.py +0 -0
  153. {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_errors.py +0 -0
  154. {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_loose_box_reconstruction.py +0 -0
  155. {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_packaging.py +0 -0
  156. {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_smoke.py +0 -0
  157. {docslicer-0.2.2 → docslicer-0.2.4}/tests/test_table_cell_newlines.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: docslicer
3
- Version: 0.2.2
4
- Summary: Deterministic hierarchical document parser and chunker
3
+ Version: 0.2.4
4
+ Summary: Answer questions from documents too large to fit in context, reading only the sections you need
5
5
  Author-email: "Market Framer Inc." <jelle@docslicer.ai>
6
6
  License-Expression: AGPL-3.0-only
7
7
  Project-URL: Homepage, https://docslicer.ai
@@ -665,7 +665,8 @@ document and the next `parse` re-parses it automatically.
665
665
 
666
666
  | Variable | Effect |
667
667
  | --- | --- |
668
- | `DOCSLICER_MCP_ROOT` | Restrict file sources **and** written output to this directory tree |
668
+ | `DOCSLICER_MCP_ROOT` | Restrict file sources **and** written output to this directory tree. Several may be given, separated by `:` (`;` on Windows) |
669
+ | `DOCSLICER_MCP_ALLOW_CLAUDE_DIR` | Set to `0` to drop the Claude desktop app's own directory from the allowed roots (default `1`) |
669
670
  | `DOCSLICER_MCP_ALLOW_URLS` | Set to `0` to reject `http(s)` sources |
670
671
  | `DOCSLICER_MCP_CACHE` | Where parsed results are persisted (default `~/.cache/docslicer-mcp`) |
671
672
  | `DOCSLICER_MCP_CACHE_MAX_MB` | Cache size ceiling, oldest pruned first (default `2048`; `0` disables) |
@@ -674,6 +675,21 @@ Set `DOCSLICER_MCP_ROOT` when exposing the server to anything but yourself —
674
675
  without it, any readable path on the machine is parseable, and `to_markdown`
675
676
  can write anywhere the server process can.
676
677
 
678
+ **Documents dropped into a chat.** Attaching a file to a Claude conversation
679
+ does not hand the server the path you know it by: the app first copies it into
680
+ a per-session workspace under its own data directory (`~/Library/Application
681
+ Support/Claude` on macOS, `%APPDATA%\Claude` on Windows), which is nowhere near
682
+ the folder you would have picked as your root. That directory is therefore
683
+ allowed alongside `DOCSLICER_MCP_ROOT`, so both routes work — the folder you
684
+ chose, and whatever you drop into the chat. It is only ever *added* to a root
685
+ you set; leaving `DOCSLICER_MCP_ROOT` unset still means no sandbox at all, not
686
+ a sandbox of that one directory. Set `DOCSLICER_MCP_ALLOW_CLAUDE_DIR=0` to opt
687
+ out and accept only your own roots.
688
+
689
+ `to_markdown` writes beside the source document, except for a document dropped
690
+ into a chat: that copy lives in a session folder you cannot navigate to, so the
691
+ markdown goes to your first `DOCSLICER_MCP_ROOT` instead.
692
+
677
693
  ---
678
694
 
679
695
  ## Command line
@@ -605,7 +605,8 @@ document and the next `parse` re-parses it automatically.
605
605
 
606
606
  | Variable | Effect |
607
607
  | --- | --- |
608
- | `DOCSLICER_MCP_ROOT` | Restrict file sources **and** written output to this directory tree |
608
+ | `DOCSLICER_MCP_ROOT` | Restrict file sources **and** written output to this directory tree. Several may be given, separated by `:` (`;` on Windows) |
609
+ | `DOCSLICER_MCP_ALLOW_CLAUDE_DIR` | Set to `0` to drop the Claude desktop app's own directory from the allowed roots (default `1`) |
609
610
  | `DOCSLICER_MCP_ALLOW_URLS` | Set to `0` to reject `http(s)` sources |
610
611
  | `DOCSLICER_MCP_CACHE` | Where parsed results are persisted (default `~/.cache/docslicer-mcp`) |
611
612
  | `DOCSLICER_MCP_CACHE_MAX_MB` | Cache size ceiling, oldest pruned first (default `2048`; `0` disables) |
@@ -614,6 +615,21 @@ Set `DOCSLICER_MCP_ROOT` when exposing the server to anything but yourself —
614
615
  without it, any readable path on the machine is parseable, and `to_markdown`
615
616
  can write anywhere the server process can.
616
617
 
618
+ **Documents dropped into a chat.** Attaching a file to a Claude conversation
619
+ does not hand the server the path you know it by: the app first copies it into
620
+ a per-session workspace under its own data directory (`~/Library/Application
621
+ Support/Claude` on macOS, `%APPDATA%\Claude` on Windows), which is nowhere near
622
+ the folder you would have picked as your root. That directory is therefore
623
+ allowed alongside `DOCSLICER_MCP_ROOT`, so both routes work — the folder you
624
+ chose, and whatever you drop into the chat. It is only ever *added* to a root
625
+ you set; leaving `DOCSLICER_MCP_ROOT` unset still means no sandbox at all, not
626
+ a sandbox of that one directory. Set `DOCSLICER_MCP_ALLOW_CLAUDE_DIR=0` to opt
627
+ out and accept only your own roots.
628
+
629
+ `to_markdown` writes beside the source document, except for a document dropped
630
+ into a chat: that copy lives in a session folder you cannot navigate to, so the
631
+ markdown goes to your first `DOCSLICER_MCP_ROOT` instead.
632
+
617
633
  ---
618
634
 
619
635
  ## Command line
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "docslicer"
7
- version = "0.2.2"
8
- description = "Deterministic hierarchical document parser and chunker"
7
+ version = "0.2.4"
8
+ description = "Answer questions from documents too large to fit in context, reading only the sections you need"
9
9
  readme = "README.md"
10
10
  license = "AGPL-3.0-only"
11
11
  license-files = ["LICENSE"]
@@ -20,7 +20,7 @@ try:
20
20
  from importlib.metadata import version as _pkg_version
21
21
  __version__ = _pkg_version("docslicer")
22
22
  except Exception:
23
- __version__ = "0.2.2"
23
+ __version__ = "0.2.4"
24
24
 
25
25
  from ._config import ParseConfig
26
26
  from ._result import ParseResult, Chunk, Block, Table, TableCell, Chart, ChartPoint, BBox, HierarchyNode, HierarchyTree
@@ -1110,7 +1110,8 @@ class ParseResult:
1110
1110
  if self.charts:
1111
1111
  _to_parquet(self.charts_df(), path / "charts.parquet")
1112
1112
  (path / "metadata.json").write_text(
1113
- json.dumps(self.metadata.to_dict(), indent=2, ensure_ascii=False)
1113
+ json.dumps(self.metadata.to_dict(), indent=2, ensure_ascii=False),
1114
+ encoding="utf-8",
1114
1115
  )
1115
1116
  return
1116
1117
 
@@ -1129,20 +1130,29 @@ class ParseResult:
1129
1130
  if stem == "metadata":
1130
1131
  if suffix != ".json":
1131
1132
  raise ValueError("metadata can only be saved as .json")
1132
- path.write_text(json.dumps(self.metadata.to_dict(), indent=2, ensure_ascii=False))
1133
+ path.write_text(
1134
+ json.dumps(self.metadata.to_dict(), indent=2, ensure_ascii=False),
1135
+ encoding="utf-8",
1136
+ )
1133
1137
  return
1134
1138
 
1135
1139
  # Unknown stem (e.g. "result") → full document export
1136
1140
  if stem not in level_map:
1137
1141
  if suffix != ".json":
1138
1142
  raise ValueError(f"Unknown stem {stem!r}: use chunks/blocks/tables/charts/metadata, or a .json path for a full export")
1139
- path.write_text(json.dumps(self.to_dict(), indent=2, ensure_ascii=False))
1143
+ path.write_text(
1144
+ json.dumps(self.to_dict(), indent=2, ensure_ascii=False),
1145
+ encoding="utf-8",
1146
+ )
1140
1147
  return
1141
1148
 
1142
1149
  rows_fn = level_map[stem]
1143
1150
 
1144
1151
  if suffix == ".json":
1145
- path.write_text(json.dumps(rows_fn(), indent=2, ensure_ascii=False))
1152
+ path.write_text(
1153
+ json.dumps(rows_fn(), indent=2, ensure_ascii=False),
1154
+ encoding="utf-8",
1155
+ )
1146
1156
  elif suffix == ".jsonl":
1147
1157
  with path.open("w", encoding="utf-8") as f:
1148
1158
  for row in rows_fn():
@@ -145,6 +145,7 @@ COLUMN_REGISTRY: Dict[str, Agg] = {
145
145
 
146
146
  # --- layout / reading order ----------------------------------------------
147
147
  "layout_id": Agg.FIRST,
148
+ "layout_cell_group": Agg.DROP, # line-level only: groups the <td>s of a dissolved layout table
148
149
  "layout_type": Agg.FIRST,
149
150
  "layout_score": Agg.DROP,
150
151
  "block_type": Agg.FIRST,
@@ -101,6 +101,7 @@ MASTER_COLUMN_ORDER: List[str] = [
101
101
  "cell_id",
102
102
  "line_id",
103
103
  "layout_id",
104
+ "layout_cell_group",
104
105
  "cell_count",
105
106
  "paragraph_id",
106
107
  "gutter_id",
@@ -20,17 +20,32 @@ from docslicer._utils.df_aggregation.text_merge import (
20
20
  # Build Line Text
21
21
  # =================================
22
22
 
23
- def _build_line_text(df: pd.DataFrame) -> pd.Series:
23
+ def _build_line_text(df: pd.DataFrame, stacked: np.ndarray | None = None) -> pd.Series:
24
24
  """
25
25
  Build the text for each line: boxes sorted by x_left, non-table lines joined
26
26
  with spaces, table lines joined with pipes so downstream shared stages see a
27
27
  row-shaped representation. Inline markup (script/strikethrough) is already
28
28
  baked into box text by the box cleaner.
29
29
 
30
+ Args:
31
+ df: box-level frame carrying line_id.
32
+ stacked: optional boolean mask of boxes that stack vertically inside
33
+ their line (the layout-table cells split by
34
+ :func:`_split_layout_table_cell_lines`). Those boxes order by y first,
35
+ because a centred paragraph's x_left depends on its rendered width
36
+ and would otherwise come out widest-first. None (the normal case)
37
+ keeps the plain x_left sort.
38
+
30
39
  Returns:
31
40
  Series of joined text indexed by line_id.
32
41
  """
33
- ordered = df.sort_values(["line_id", "x_left"], kind="stable")
42
+ if stacked is None:
43
+ ordered = df.sort_values(["line_id", "x_left"], kind="stable")
44
+ else:
45
+ # 0.0 everywhere else, so unstacked lines sort on x_left exactly as before.
46
+ df = df.assign(_stack_y=np.where(stacked, df["y_top"].to_numpy(dtype=float), 0.0))
47
+ ordered = df.sort_values(["line_id", "_stack_y", "x_left"], kind="stable")
48
+ ordered = ordered.drop(columns="_stack_y")
34
49
  texts = ordered["text"].astype("string").fillna("")
35
50
  # <pre> code-line boxes keep their leading whitespace — indentation is
36
51
  # semantically meaningful in code. Everything else is stripped as before.
@@ -60,6 +75,100 @@ def _build_line_text(df: pd.DataFrame) -> pd.Series:
60
75
  return line_text
61
76
 
62
77
 
78
+ # =================================
79
+ # Layout-table cells
80
+ # =================================
81
+
82
+ def _split_layout_table_cell_lines(df: pd.DataFrame) -> tuple[pd.DataFrame, np.ndarray | None]:
83
+ """
84
+ Give every ``<td>`` of a single-row *layout* table its own line_id.
85
+
86
+ A single-row table whose cells stack several paragraphs on top of each other
87
+ is a layout device, not data — the multi-column signature block where each
88
+ cell holds one law firm. ``assign_line_id`` merges a whole ``<tr>`` into one
89
+ line, which is right for a data row but here glues four firms into one line
90
+ and makes it impossible to tell which people belong to which firm. Splitting
91
+ per cell yields one line per column, in document (left-to-right) order.
92
+
93
+ ``_remove_single_row_tables`` dissolves these tables a few steps later, so no
94
+ downstream table stage ever sees the split rows.
95
+
96
+ The detection is a linear neighbour scan over the table boxes only: cells are
97
+ contiguous within a line, so a line has a multi-box cell iff two adjacent
98
+ boxes share (line_id, table_cell_index), and spans several cells iff two
99
+ adjacent boxes share line_id but not table_cell_index. Both must hold before
100
+ anything heavier than that scan runs, which on ordinary documents is never.
101
+
102
+ Returns:
103
+ (df, stacked_mask) — df with the split line_ids applied, and a boolean
104
+ mask of the boxes that were split (None when nothing qualified, i.e. the
105
+ frame is untouched).
106
+ """
107
+ needed = ("line_id", "table_id", "table_row_id", "table_cell_index")
108
+ if any(c not in df.columns for c in needed):
109
+ return df, None
110
+
111
+ is_table = df["table_id"].notna().to_numpy()
112
+ if not is_table.any():
113
+ return df, None
114
+
115
+ pos = np.flatnonzero(is_table)
116
+ line = df["line_id"].to_numpy()[pos]
117
+ cell = pd.to_numeric(df["table_cell_index"], errors="coerce").to_numpy(dtype=float)[pos]
118
+
119
+ same_line = line[1:] == line[:-1]
120
+ same_cell = cell[1:] == cell[:-1]
121
+ multi_box_cell = same_line & same_cell # a cell holding >1 box
122
+ multi_cell_line = same_line & ~same_cell # a line spanning >1 cell
123
+ if not (multi_box_cell.any() and multi_cell_line.any()):
124
+ return df, None
125
+
126
+ # Lines that are both — the only candidates worth a closer look.
127
+ candidates = np.intersect1d(line[1:][multi_box_cell], line[1:][multi_cell_line])
128
+ if candidates.size == 0:
129
+ return df, None
130
+
131
+ # Keep only candidates whose table is single-row: a wrapped cell in a real
132
+ # multi-row table looks identical to the scan above but must stay one line
133
+ # per row (step_05 relies on that).
134
+ line_all = df["line_id"].to_numpy()
135
+ on_candidate_line = is_table & np.isin(line_all, candidates)
136
+ cand_tables = df.loc[on_candidate_line, "table_id"].unique()
137
+ in_cand_tables = df["table_id"].isin(cand_tables)
138
+ rows_per_table = df.loc[in_cand_tables].groupby("table_id")["table_row_id"].nunique()
139
+ single_row = set(rows_per_table[rows_per_table == 1].index)
140
+ if not single_row:
141
+ return df, None
142
+
143
+ stacked = on_candidate_line & df["table_id"].isin(single_row).to_numpy()
144
+ if not stacked.any():
145
+ return df, None
146
+
147
+ # Renumber: a new line starts wherever line_id changes, plus at every cell
148
+ # boundary inside a split line. Boxes are in document order, so the cumsum
149
+ # keeps line_ids ascending exactly as assign_line_id left them.
150
+ cell_all = pd.to_numeric(df["table_cell_index"], errors="coerce").to_numpy(dtype=float)
151
+ starts = np.empty(len(df), dtype=bool)
152
+ starts[0] = True
153
+ starts[1:] = line_all[1:] != line_all[:-1]
154
+ cell_break = np.zeros(len(df), dtype=bool)
155
+ cell_break[1:] = stacked[1:] & stacked[:-1] & (cell_all[1:] != cell_all[:-1])
156
+
157
+ out = df.copy()
158
+ out["line_id"] = np.cumsum(starts | cell_break)
159
+
160
+ # top_bucket is derived from line_id by the merger; refresh it for the boxes
161
+ # whose line changed (it is dropped at aggregation, but the box-level frame
162
+ # is exported for debugging).
163
+ if "top_bucket" in out.columns:
164
+ split = out.loc[stacked]
165
+ out.loc[stacked, "top_bucket"] = (
166
+ split["line_id"].map(split.groupby("line_id")["y_top"].min().round().astype("Int64"))
167
+ )
168
+
169
+ return out, stacked
170
+
171
+
63
172
  # =================================
64
173
  # Table vs Text
65
174
  # =================================
@@ -268,6 +377,7 @@ def _add_layout_id(df: pd.DataFrame) -> pd.DataFrame:
268
377
  group key (new id when entering/leaving/switching groups). A line's
269
378
  group key is, in order of precedence:
270
379
  - its table_id
380
+ - its layout_cell_group (sibling <td>s of a dissolved layout table)
271
381
  - its deepest ul/ol or h1-h6 struct ancestor (see
272
382
  :func:`_struct_layout_groups`), so list items and multi-line
273
383
  headings stay together
@@ -300,6 +410,9 @@ def _add_layout_id(df: pd.DataFrame) -> pd.DataFrame:
300
410
 
301
411
  # Rule 3: group key — table_id wins over the struct list/heading group
302
412
  group_key = _struct_layout_groups(df)
413
+ if "layout_cell_group" in df.columns:
414
+ cell_group = df["layout_cell_group"].astype("string")
415
+ group_key = group_key.mask(cell_group.notna(), "cells_" + cell_group)
303
416
  if "table_id" in df.columns:
304
417
  has_table = df["table_id"].notna()
305
418
  group_key = group_key.mask(has_table, "table_" + df["table_id"].astype(str))
@@ -340,7 +453,8 @@ def merge_boxes_to_lines(
340
453
  ) -> pd.DataFrame:
341
454
  """
342
455
  Merge boxes into lines:
343
- 1. Assign line_id using line_merger
456
+ 1. Assign line_id using line_merger, then split the cells of single-row
457
+ layout tables into their own lines
344
458
  2. Merge text within each line (sorted by x_left) via text_merge
345
459
  3. Aggregate the remaining columns via the registry aggregator
346
460
  4. Optionally remove single-row tables and reindex
@@ -391,12 +505,16 @@ def merge_boxes_to_lines(
391
505
  boxes_df["y_bottom"] = synthetic_y
392
506
 
393
507
  boxes_with_lines = assign_line_id(boxes_df, y_alignment="top")
394
-
508
+
509
+ # Step 1b: Split the cells of single-row layout tables into their own lines
510
+ # (no-op, after one linear scan, on everything else).
511
+ boxes_with_lines, stacked = _split_layout_table_cell_lines(boxes_with_lines)
512
+
395
513
  # Step 2: Create text for each line (sorted by x_left, joined with spaces,
396
514
  # pipe-joined on table lines). Text is merged from the x-sorted view while
397
515
  # aggregation runs on the frame in document order, so "first"/dominant
398
516
  # columns keep picking the same source boxes as before.
399
- line_text = _build_line_text(boxes_with_lines)
517
+ line_text = _build_line_text(boxes_with_lines, stacked=stacked)
400
518
 
401
519
  # Step 3: Aggregate everything else via the central column registry
402
520
  lines_df = aggregate_to(
@@ -406,6 +524,15 @@ def merge_boxes_to_lines(
406
524
  )
407
525
  lines_df["text"] = lines_df["line_id"].map(line_text)
408
526
 
527
+ # Sibling cells of one layout table share a layout group, so the columns stay
528
+ # in one layout after _remove_single_row_tables clears their table_id.
529
+ if stacked is not None:
530
+ split = boxes_with_lines.loc[stacked]
531
+ group = (
532
+ split["table_id"].astype(str) + ":" + split["table_row_id"].astype(str)
533
+ ).groupby(split["line_id"]).first()
534
+ lines_df["layout_cell_group"] = lines_df["line_id"].map(group)
535
+
409
536
  # Step 4: Remove single-row tables if requested
410
537
  if remove_single_row_tables:
411
538
  lines_df = _remove_single_row_tables(lines_df)
@@ -16,6 +16,7 @@ import hashlib
16
16
  import json
17
17
  import os
18
18
  import re
19
+ import sys
19
20
  import time
20
21
  from collections import OrderedDict
21
22
  from dataclasses import dataclass
@@ -47,10 +48,94 @@ def cache_limit_bytes() -> int:
47
48
  return max(0, megabytes) * 1024 * 1024
48
49
 
49
50
 
50
- def allowed_root() -> Path | None:
51
- """Optional sandbox root. When DOCSLICER_MCP_ROOT is set, only files beneath it parse."""
52
- env = os.environ.get("DOCSLICER_MCP_ROOT")
53
- return Path(env).expanduser().resolve() if env else None
51
+ def configured_roots() -> tuple[Path, ...]:
52
+ """The sandbox roots the operator named in DOCSLICER_MCP_ROOT.
53
+
54
+ More than one may be given, separated by the platform's path separator
55
+ (``:`` on macOS and Linux, ``;`` on Windows) — the same convention as PATH,
56
+ so a host that can only hand the server one string can still hand it
57
+ several directories. Empty when the variable is unset, which means no
58
+ sandbox at all.
59
+ """
60
+ env = os.environ.get("DOCSLICER_MCP_ROOT") or ""
61
+ parts = [p.strip() for p in env.split(os.pathsep)]
62
+ return tuple(Path(p).expanduser().resolve() for p in parts if p)
63
+
64
+
65
+ def claude_dir_allowed() -> bool:
66
+ """Whether the Claude desktop app's data directory joins the sandbox. Disable with
67
+ DOCSLICER_MCP_ALLOW_CLAUDE_DIR=0."""
68
+ return os.environ.get("DOCSLICER_MCP_ALLOW_CLAUDE_DIR", "1").lower() not in (
69
+ "0",
70
+ "false",
71
+ "no",
72
+ )
73
+
74
+
75
+ def claude_dir() -> Path | None:
76
+ """Where the Claude desktop app keeps its own files, or None if it is not there.
77
+
78
+ A document dropped into a Claude chat is not read from wherever the user
79
+ keeps it; the app copies it into a per-session workspace under this
80
+ directory and gives the model that path. So a root pointing at the user's
81
+ Documents or Desktop — the folder they would think to pick — matches none
82
+ of the files they actually hand the server, and every drop is refused as
83
+ outside the sandbox. This is the second root that makes the first usable.
84
+ """
85
+ if sys.platform == "darwin":
86
+ base = Path.home() / "Library" / "Application Support" / "Claude"
87
+ elif sys.platform == "win32":
88
+ appdata = os.environ.get("APPDATA")
89
+ if not appdata:
90
+ return None
91
+ base = Path(appdata) / "Claude"
92
+ else:
93
+ config = os.environ.get("XDG_CONFIG_HOME")
94
+ base = (Path(config) if config else Path.home() / ".config") / "Claude"
95
+
96
+ try:
97
+ resolved = base.expanduser().resolve(strict=True)
98
+ except OSError:
99
+ return None
100
+ return resolved if resolved.is_dir() else None
101
+
102
+
103
+ def allowed_roots() -> tuple[Path, ...]:
104
+ """Every directory tree this server may read from and write to.
105
+
106
+ Empty means unrestricted. That is what an unset DOCSLICER_MCP_ROOT has
107
+ always meant, and the Claude directory is added only alongside a root the
108
+ operator set — a server told to sandbox nothing must not find itself
109
+ sandboxed to one directory because the app happens to be installed.
110
+ """
111
+ roots = configured_roots()
112
+ if not roots or not claude_dir_allowed():
113
+ return roots
114
+
115
+ extra = claude_dir()
116
+ if extra is None or any(_within(extra, root) for root in roots):
117
+ return roots
118
+ return roots + (extra,)
119
+
120
+
121
+ def primary_root() -> Path | None:
122
+ """The root a path with nowhere else to go belongs in — the first one configured.
123
+
124
+ Only the operator's own roots qualify: the Claude directory is a place
125
+ files arrive from, not somewhere to file a document the caller asked for.
126
+ """
127
+ roots = configured_roots()
128
+ return roots[0] if roots else None
129
+
130
+
131
+ def _within(path: Path, root: Path) -> bool:
132
+ """Whether a resolved path is the root or sits beneath it."""
133
+ return path == root or root in path.parents
134
+
135
+
136
+ def _root_list(roots: tuple[Path, ...]) -> str:
137
+ """The allowed roots as an error message names them."""
138
+ return ", ".join(str(r) for r in roots)
54
139
 
55
140
 
56
141
  def urls_allowed() -> bool:
@@ -98,11 +183,11 @@ def resolve_source(source: str) -> str:
98
183
  except FileNotFoundError:
99
184
  raise FileNotFoundError(f"No such file: {source}") from None
100
185
 
101
- root = allowed_root()
102
- if root is not None and root not in resolved.parents and resolved != root:
186
+ roots = allowed_roots()
187
+ if roots and not any(_within(resolved, root) for root in roots):
103
188
  raise SourceNotAllowed(
104
- f"Access to {resolved} is outside the allowed root {root}. "
105
- "Copy the file inside that directory or adjust DOCSLICER_MCP_ROOT."
189
+ f"Access to {resolved} is outside the allowed roots: {_root_list(roots)}. "
190
+ "Copy the file inside one of those directories or adjust DOCSLICER_MCP_ROOT."
106
191
  )
107
192
  return str(resolved)
108
193
 
@@ -128,11 +213,11 @@ def resolve_output(path: str, base: Path | None = None) -> Path:
128
213
  anchor = base or Path.cwd()
129
214
  resolved = Path(target if target.is_absolute() else anchor / target).resolve()
130
215
 
131
- root = allowed_root()
132
- if root is not None and root not in resolved.parents:
216
+ roots = allowed_roots()
217
+ if roots and not any(root in resolved.parents for root in roots):
133
218
  raise SourceNotAllowed(
134
- f"Cannot write to {resolved}: it is outside the allowed root {root}. "
135
- "Choose an output_path inside that directory or adjust DOCSLICER_MCP_ROOT."
219
+ f"Cannot write to {resolved}: it is outside the allowed roots: {_root_list(roots)}. "
220
+ "Choose an output_path inside one of those directories or adjust DOCSLICER_MCP_ROOT."
136
221
  )
137
222
  return resolved
138
223