kreuzberg 4.0.0.pre.rc.6 → 4.0.0.rc1

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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +0 -6
  3. data/.rubocop.yaml +534 -1
  4. data/Gemfile +2 -1
  5. data/Gemfile.lock +11 -11
  6. data/README.md +5 -10
  7. data/examples/async_patterns.rb +0 -1
  8. data/ext/kreuzberg_rb/extconf.rb +0 -10
  9. data/ext/kreuzberg_rb/native/Cargo.toml +15 -23
  10. data/ext/kreuzberg_rb/native/build.rs +2 -0
  11. data/ext/kreuzberg_rb/native/include/ieeefp.h +1 -1
  12. data/ext/kreuzberg_rb/native/include/msvc_compat/strings.h +1 -1
  13. data/ext/kreuzberg_rb/native/include/strings.h +2 -2
  14. data/ext/kreuzberg_rb/native/include/unistd.h +1 -1
  15. data/ext/kreuzberg_rb/native/src/lib.rs +16 -75
  16. data/kreuzberg.gemspec +14 -57
  17. data/lib/kreuzberg/cache_api.rb +0 -1
  18. data/lib/kreuzberg/cli.rb +2 -2
  19. data/lib/kreuzberg/config.rb +2 -9
  20. data/lib/kreuzberg/errors.rb +7 -75
  21. data/lib/kreuzberg/extraction_api.rb +0 -1
  22. data/lib/kreuzberg/setup_lib_path.rb +0 -1
  23. data/lib/kreuzberg/version.rb +1 -1
  24. data/lib/kreuzberg.rb +0 -21
  25. data/pkg/kreuzberg-4.0.0.rc1.gem +0 -0
  26. data/sig/kreuzberg.rbs +3 -55
  27. data/spec/binding/cli_proxy_spec.rb +4 -2
  28. data/spec/binding/cli_spec.rb +11 -12
  29. data/spec/examples.txt +104 -0
  30. data/spec/fixtures/config.yaml +1 -0
  31. data/spec/spec_helper.rb +1 -1
  32. data/vendor/kreuzberg/Cargo.toml +42 -112
  33. data/vendor/kreuzberg/README.md +2 -2
  34. data/vendor/kreuzberg/build.rs +4 -18
  35. data/vendor/kreuzberg/src/bin/profile_extract.rs +455 -0
  36. data/vendor/kreuzberg/src/cache/mod.rs +3 -27
  37. data/vendor/kreuzberg/src/core/batch_mode.rs +0 -60
  38. data/vendor/kreuzberg/src/core/extractor.rs +81 -202
  39. data/vendor/kreuzberg/src/core/io.rs +2 -4
  40. data/vendor/kreuzberg/src/core/mime.rs +12 -2
  41. data/vendor/kreuzberg/src/core/mod.rs +1 -4
  42. data/vendor/kreuzberg/src/core/pipeline.rs +33 -111
  43. data/vendor/kreuzberg/src/embeddings.rs +16 -125
  44. data/vendor/kreuzberg/src/error.rs +1 -1
  45. data/vendor/kreuzberg/src/extraction/docx.rs +1 -1
  46. data/vendor/kreuzberg/src/extraction/image.rs +13 -13
  47. data/vendor/kreuzberg/src/extraction/libreoffice.rs +1 -0
  48. data/vendor/kreuzberg/src/extraction/mod.rs +5 -9
  49. data/vendor/kreuzberg/src/extraction/office_metadata/mod.rs +0 -2
  50. data/vendor/kreuzberg/src/extraction/pandoc/batch.rs +275 -0
  51. data/vendor/kreuzberg/src/extraction/pandoc/mime_types.rs +178 -0
  52. data/vendor/kreuzberg/src/extraction/pandoc/mod.rs +491 -0
  53. data/vendor/kreuzberg/src/extraction/pandoc/server.rs +496 -0
  54. data/vendor/kreuzberg/src/extraction/pandoc/subprocess.rs +1188 -0
  55. data/vendor/kreuzberg/src/extraction/pandoc/version.rs +162 -0
  56. data/vendor/kreuzberg/src/extractors/archive.rs +0 -21
  57. data/vendor/kreuzberg/src/extractors/docx.rs +128 -16
  58. data/vendor/kreuzberg/src/extractors/email.rs +0 -14
  59. data/vendor/kreuzberg/src/extractors/excel.rs +20 -19
  60. data/vendor/kreuzberg/src/extractors/html.rs +154 -137
  61. data/vendor/kreuzberg/src/extractors/image.rs +4 -7
  62. data/vendor/kreuzberg/src/extractors/mod.rs +9 -106
  63. data/vendor/kreuzberg/src/extractors/pandoc.rs +201 -0
  64. data/vendor/kreuzberg/src/extractors/pdf.rs +15 -12
  65. data/vendor/kreuzberg/src/extractors/pptx.rs +3 -17
  66. data/vendor/kreuzberg/src/extractors/structured.rs +0 -14
  67. data/vendor/kreuzberg/src/extractors/text.rs +5 -23
  68. data/vendor/kreuzberg/src/extractors/xml.rs +0 -7
  69. data/vendor/kreuzberg/src/keywords/rake.rs +1 -0
  70. data/vendor/kreuzberg/src/lib.rs +1 -4
  71. data/vendor/kreuzberg/src/mcp/mod.rs +1 -1
  72. data/vendor/kreuzberg/src/mcp/server.rs +3 -5
  73. data/vendor/kreuzberg/src/ocr/processor.rs +2 -18
  74. data/vendor/kreuzberg/src/pdf/error.rs +1 -1
  75. data/vendor/kreuzberg/src/pdf/table.rs +44 -17
  76. data/vendor/kreuzberg/src/pdf/text.rs +3 -0
  77. data/vendor/kreuzberg/src/plugins/extractor.rs +5 -8
  78. data/vendor/kreuzberg/src/plugins/ocr.rs +11 -2
  79. data/vendor/kreuzberg/src/plugins/processor.rs +1 -2
  80. data/vendor/kreuzberg/src/plugins/registry.rs +0 -13
  81. data/vendor/kreuzberg/src/plugins/validator.rs +8 -9
  82. data/vendor/kreuzberg/src/stopwords/mod.rs +2 -2
  83. data/vendor/kreuzberg/src/types.rs +12 -42
  84. data/vendor/kreuzberg/tests/batch_orchestration.rs +5 -19
  85. data/vendor/kreuzberg/tests/batch_processing.rs +3 -15
  86. data/vendor/kreuzberg/tests/chunking_offset_demo.rs +92 -0
  87. data/vendor/kreuzberg/tests/concurrency_stress.rs +1 -17
  88. data/vendor/kreuzberg/tests/config_features.rs +0 -18
  89. data/vendor/kreuzberg/tests/config_loading_tests.rs +39 -15
  90. data/vendor/kreuzberg/tests/core_integration.rs +7 -24
  91. data/vendor/kreuzberg/tests/csv_integration.rs +81 -71
  92. data/vendor/kreuzberg/tests/docx_metadata_extraction_test.rs +25 -23
  93. data/vendor/kreuzberg/tests/pandoc_integration.rs +503 -0
  94. data/vendor/kreuzberg/tests/pipeline_integration.rs +1 -0
  95. data/vendor/kreuzberg/tests/plugin_postprocessor_test.rs +1 -0
  96. data/vendor/kreuzberg/tests/registry_integration_tests.rs +22 -1
  97. data/vendor/kreuzberg/tests/security_validation.rs +1 -12
  98. metadata +25 -90
  99. data/.rubocop.yml +0 -538
  100. data/ext/kreuzberg_rb/native/Cargo.lock +0 -6535
  101. data/lib/kreuzberg/error_context.rb +0 -32
  102. data/vendor/kreuzberg/benches/otel_overhead.rs +0 -48
  103. data/vendor/kreuzberg/src/extraction/markdown.rs +0 -213
  104. data/vendor/kreuzberg/src/extraction/office_metadata/odt_properties.rs +0 -287
  105. data/vendor/kreuzberg/src/extractors/bibtex.rs +0 -469
  106. data/vendor/kreuzberg/src/extractors/docbook.rs +0 -502
  107. data/vendor/kreuzberg/src/extractors/epub.rs +0 -707
  108. data/vendor/kreuzberg/src/extractors/fictionbook.rs +0 -491
  109. data/vendor/kreuzberg/src/extractors/fictionbook.rs.backup2 +0 -738
  110. data/vendor/kreuzberg/src/extractors/jats.rs +0 -1051
  111. data/vendor/kreuzberg/src/extractors/jupyter.rs +0 -367
  112. data/vendor/kreuzberg/src/extractors/latex.rs +0 -652
  113. data/vendor/kreuzberg/src/extractors/markdown.rs +0 -700
  114. data/vendor/kreuzberg/src/extractors/odt.rs +0 -628
  115. data/vendor/kreuzberg/src/extractors/opml.rs +0 -634
  116. data/vendor/kreuzberg/src/extractors/orgmode.rs +0 -528
  117. data/vendor/kreuzberg/src/extractors/rst.rs +0 -576
  118. data/vendor/kreuzberg/src/extractors/rtf.rs +0 -810
  119. data/vendor/kreuzberg/src/extractors/security.rs +0 -484
  120. data/vendor/kreuzberg/src/extractors/security_tests.rs +0 -367
  121. data/vendor/kreuzberg/src/extractors/typst.rs +0 -650
  122. data/vendor/kreuzberg/src/panic_context.rs +0 -154
  123. data/vendor/kreuzberg/tests/api_extract_multipart.rs +0 -52
  124. data/vendor/kreuzberg/tests/bibtex_parity_test.rs +0 -421
  125. data/vendor/kreuzberg/tests/docbook_extractor_tests.rs +0 -498
  126. data/vendor/kreuzberg/tests/docx_vs_pandoc_comparison.rs +0 -370
  127. data/vendor/kreuzberg/tests/epub_native_extractor_tests.rs +0 -275
  128. data/vendor/kreuzberg/tests/fictionbook_extractor_tests.rs +0 -228
  129. data/vendor/kreuzberg/tests/html_table_test.rs +0 -551
  130. data/vendor/kreuzberg/tests/instrumentation_test.rs +0 -139
  131. data/vendor/kreuzberg/tests/jats_extractor_tests.rs +0 -639
  132. data/vendor/kreuzberg/tests/jupyter_extractor_tests.rs +0 -704
  133. data/vendor/kreuzberg/tests/latex_extractor_tests.rs +0 -496
  134. data/vendor/kreuzberg/tests/markdown_extractor_tests.rs +0 -490
  135. data/vendor/kreuzberg/tests/odt_extractor_tests.rs +0 -695
  136. data/vendor/kreuzberg/tests/opml_extractor_tests.rs +0 -616
  137. data/vendor/kreuzberg/tests/orgmode_extractor_tests.rs +0 -822
  138. data/vendor/kreuzberg/tests/rst_extractor_tests.rs +0 -692
  139. data/vendor/kreuzberg/tests/rtf_extractor_tests.rs +0 -776
  140. data/vendor/kreuzberg/tests/typst_behavioral_tests.rs +0 -1259
  141. data/vendor/kreuzberg/tests/typst_extractor_tests.rs +0 -647
  142. data/vendor/rb-sys/.cargo-ok +0 -1
  143. data/vendor/rb-sys/.cargo_vcs_info.json +0 -6
  144. data/vendor/rb-sys/Cargo.lock +0 -393
  145. data/vendor/rb-sys/Cargo.toml +0 -70
  146. data/vendor/rb-sys/Cargo.toml.orig +0 -57
  147. data/vendor/rb-sys/LICENSE-APACHE +0 -190
  148. data/vendor/rb-sys/LICENSE-MIT +0 -21
  149. data/vendor/rb-sys/bin/release.sh +0 -21
  150. data/vendor/rb-sys/build/features.rs +0 -108
  151. data/vendor/rb-sys/build/main.rs +0 -246
  152. data/vendor/rb-sys/build/stable_api_config.rs +0 -153
  153. data/vendor/rb-sys/build/version.rs +0 -48
  154. data/vendor/rb-sys/readme.md +0 -36
  155. data/vendor/rb-sys/src/bindings.rs +0 -21
  156. data/vendor/rb-sys/src/hidden.rs +0 -11
  157. data/vendor/rb-sys/src/lib.rs +0 -34
  158. data/vendor/rb-sys/src/macros.rs +0 -371
  159. data/vendor/rb-sys/src/memory.rs +0 -53
  160. data/vendor/rb-sys/src/ruby_abi_version.rs +0 -38
  161. data/vendor/rb-sys/src/special_consts.rs +0 -31
  162. data/vendor/rb-sys/src/stable_api/compiled.c +0 -179
  163. data/vendor/rb-sys/src/stable_api/compiled.rs +0 -257
  164. data/vendor/rb-sys/src/stable_api/ruby_2_6.rs +0 -316
  165. data/vendor/rb-sys/src/stable_api/ruby_2_7.rs +0 -316
  166. data/vendor/rb-sys/src/stable_api/ruby_3_0.rs +0 -324
  167. data/vendor/rb-sys/src/stable_api/ruby_3_1.rs +0 -317
  168. data/vendor/rb-sys/src/stable_api/ruby_3_2.rs +0 -315
  169. data/vendor/rb-sys/src/stable_api/ruby_3_3.rs +0 -326
  170. data/vendor/rb-sys/src/stable_api/ruby_3_4.rs +0 -327
  171. data/vendor/rb-sys/src/stable_api.rs +0 -261
  172. data/vendor/rb-sys/src/symbol.rs +0 -31
  173. data/vendor/rb-sys/src/tracking_allocator.rs +0 -332
  174. data/vendor/rb-sys/src/utils.rs +0 -89
  175. data/vendor/rb-sys/src/value_type.rs +0 -7
@@ -1,652 +0,0 @@
1
- //! Native Rust LaTeX text extractor.
2
- //!
3
- //! This extractor provides comprehensive LaTeX document parsing and text extraction.
4
- //!
5
- //! Features:
6
- //! - Metadata extraction: title, author, date from \title{}, \author{}, \date{}
7
- //! - Section hierarchy: \section{}, \subsection{}, \subsubsection{}, etc.
8
- //! - Inline formatting: \emph{}, \textbf{}, \textit{}, \texttt{}, \underline{}
9
- //! - Lists: itemize, enumerate, description environments
10
- //! - Tables: tabular environment parsing
11
- //! - Math: inline ($...$) and display (\[...\]) math preservation
12
- //! - Unicode support
13
- //!
14
- //! Requires the `office` feature.
15
-
16
- use crate::Result;
17
- use crate::core::config::ExtractionConfig;
18
- use crate::plugins::{DocumentExtractor, Plugin};
19
- use crate::types::{ExtractionResult, Metadata, Table};
20
- use async_trait::async_trait;
21
-
22
- /// LaTeX document extractor
23
- pub struct LatexExtractor;
24
-
25
- impl LatexExtractor {
26
- /// Create a new LaTeX extractor.
27
- pub fn new() -> Self {
28
- Self
29
- }
30
-
31
- /// Parse LaTeX content and extract text.
32
- fn extract_from_latex(content: &str) -> (String, Metadata, Vec<Table>) {
33
- let mut parser = LatexParser::new(content);
34
- parser.parse()
35
- }
36
- }
37
-
38
- impl Default for LatexExtractor {
39
- fn default() -> Self {
40
- Self::new()
41
- }
42
- }
43
-
44
- impl Plugin for LatexExtractor {
45
- fn name(&self) -> &str {
46
- "latex-extractor"
47
- }
48
-
49
- fn version(&self) -> String {
50
- env!("CARGO_PKG_VERSION").to_string()
51
- }
52
-
53
- fn initialize(&self) -> Result<()> {
54
- Ok(())
55
- }
56
-
57
- fn shutdown(&self) -> Result<()> {
58
- Ok(())
59
- }
60
-
61
- fn description(&self) -> &str {
62
- "Native Rust LaTeX document extractor with metadata and table support"
63
- }
64
-
65
- fn author(&self) -> &str {
66
- "Kreuzberg Team"
67
- }
68
- }
69
-
70
- #[async_trait]
71
- impl DocumentExtractor for LatexExtractor {
72
- #[cfg_attr(feature = "otel", tracing::instrument(
73
- skip(self, content, _config),
74
- fields(
75
- extractor.name = self.name(),
76
- content.size_bytes = content.len(),
77
- )
78
- ))]
79
- async fn extract_bytes(
80
- &self,
81
- content: &[u8],
82
- mime_type: &str,
83
- _config: &ExtractionConfig,
84
- ) -> Result<ExtractionResult> {
85
- let latex_str = String::from_utf8_lossy(content).to_string();
86
- let (text, metadata, tables) = Self::extract_from_latex(&latex_str);
87
-
88
- Ok(ExtractionResult {
89
- content: text,
90
- mime_type: mime_type.to_string(),
91
- metadata,
92
- tables,
93
- detected_languages: None,
94
- chunks: None,
95
- images: None,
96
- })
97
- }
98
-
99
- fn supported_mime_types(&self) -> &[&str] {
100
- &["application/x-latex", "text/x-tex"]
101
- }
102
-
103
- fn priority(&self) -> i32 {
104
- 50
105
- }
106
- }
107
-
108
- /// LaTeX parser
109
- struct LatexParser<'a> {
110
- source: &'a str,
111
- metadata: Metadata,
112
- tables: Vec<Table>,
113
- output: String,
114
- }
115
-
116
- impl<'a> LatexParser<'a> {
117
- fn new(source: &'a str) -> Self {
118
- Self {
119
- source,
120
- metadata: Metadata::default(),
121
- tables: Vec::new(),
122
- output: String::new(),
123
- }
124
- }
125
-
126
- fn parse(&mut self) -> (String, Metadata, Vec<Table>) {
127
- let lines: Vec<&str> = self.source.lines().collect();
128
- let mut in_document = false;
129
- let mut skip_until_end = None::<String>;
130
- let mut i = 0;
131
-
132
- let is_plain_tex = self.source.contains("\\bye") && !self.source.contains("\\begin{document}");
133
- if is_plain_tex {
134
- in_document = true;
135
- }
136
-
137
- while i < lines.len() {
138
- let line = lines[i];
139
- let trimmed = line.trim();
140
-
141
- if let Some(ref env) = skip_until_end {
142
- if trimmed.contains(&format!("\\end{{{}}}", env)) {
143
- skip_until_end = None;
144
- }
145
- i += 1;
146
- continue;
147
- }
148
-
149
- if is_plain_tex && trimmed.contains("\\bye") {
150
- break;
151
- }
152
-
153
- if !in_document && !is_plain_tex {
154
- self.extract_metadata_from_line(trimmed);
155
- }
156
-
157
- if !is_plain_tex && trimmed.contains("\\begin{document}") {
158
- in_document = true;
159
-
160
- if trimmed.contains("\\end{document}") {
161
- let Some(begin_pos) = trimmed.find("\\begin{document}") else {
162
- break;
163
- };
164
- let Some(end_pos) = trimmed.find("\\end{document}") else {
165
- break;
166
- };
167
- let content_between = trimmed[begin_pos + 16..end_pos].trim();
168
- if !content_between.is_empty() {
169
- if content_between.starts_with("\\section{") {
170
- if let Some(title) = self.extract_braced(content_between, "section") {
171
- self.output.push_str(&format!("\n# {}\n\n", title));
172
- }
173
- } else {
174
- let processed = self.process_line(content_between);
175
- if !processed.is_empty() {
176
- self.output.push_str(&processed);
177
- self.output.push('\n');
178
- }
179
- }
180
- }
181
- break;
182
- }
183
-
184
- i += 1;
185
- continue;
186
- }
187
-
188
- if !is_plain_tex && trimmed.contains("\\end{document}") {
189
- break;
190
- }
191
-
192
- if in_document {
193
- if trimmed.contains("\\begin{") {
194
- let Some(env_name) = self.extract_env_name(trimmed) else {
195
- i += 1;
196
- continue;
197
- };
198
- match env_name.as_str() {
199
- "itemize" | "enumerate" | "description" => {
200
- let (env_content, new_i) = self.collect_environment(&lines, i, &env_name);
201
- self.process_list(&env_content, &env_name);
202
- i = new_i;
203
- continue;
204
- }
205
- "tabular" => {
206
- let (env_content, new_i) = self.collect_environment(&lines, i, "tabular");
207
- self.process_table(&env_content);
208
- i = new_i;
209
- continue;
210
- }
211
- "table" => {
212
- let (env_content, new_i) = self.collect_environment(&lines, i, "table");
213
- self.process_table_with_caption(&env_content);
214
- i = new_i;
215
- continue;
216
- }
217
- "equation" | "align" | "gather" | "multline" => {
218
- let (env_content, new_i) = self.collect_environment(&lines, i, &env_name);
219
- self.output.push_str("$$\\begin{");
220
- self.output.push_str(&env_name);
221
- self.output.push_str("}\n");
222
- self.output.push_str(&env_content);
223
- self.output.push_str("\\end{");
224
- self.output.push_str(&env_name);
225
- self.output.push_str("}$$\n\n");
226
- i = new_i;
227
- continue;
228
- }
229
- _ => {
230
- skip_until_end = Some(env_name);
231
- }
232
- }
233
- }
234
-
235
- if trimmed.starts_with("\\section{") {
236
- if let Some(title) = self.extract_braced(trimmed, "section") {
237
- self.output.push_str(&format!("\n# {}\n\n", title));
238
- }
239
- } else if trimmed.starts_with("\\subsection{") {
240
- if let Some(title) = self.extract_braced(trimmed, "subsection") {
241
- self.output.push_str(&format!("## {}\n\n", title));
242
- }
243
- } else if trimmed.starts_with("\\subsubsection{") {
244
- if let Some(title) = self.extract_braced(trimmed, "subsubsection") {
245
- self.output.push_str(&format!("### {}\n\n", title));
246
- }
247
- } else if trimmed.starts_with("\\[") {
248
- let mut math_content = trimmed.to_string();
249
- if !trimmed.contains("\\]") {
250
- i += 1;
251
- while i < lines.len() {
252
- let math_line = lines[i];
253
- math_content.push('\n');
254
- math_content.push_str(math_line);
255
- if math_line.trim().contains("\\]") {
256
- break;
257
- }
258
- i += 1;
259
- }
260
- }
261
- self.output.push_str(&math_content);
262
- self.output.push('\n');
263
- } else if !trimmed.is_empty() && !trimmed.starts_with("%") {
264
- let processed = self.process_line(trimmed);
265
- if !processed.is_empty() {
266
- self.output.push_str(&processed);
267
- self.output.push('\n');
268
- }
269
- }
270
- }
271
-
272
- i += 1;
273
- }
274
-
275
- let content = self.output.trim().to_string();
276
- (content, self.metadata.clone(), self.tables.clone())
277
- }
278
-
279
- fn extract_metadata_from_line(&mut self, line: &str) {
280
- if line.starts_with("\\title{") {
281
- let Some(title) = self.extract_braced(line, "title") else {
282
- return;
283
- };
284
- self.metadata.additional.insert("title".to_string(), title.into());
285
- } else if line.starts_with("\\author{") {
286
- let Some(author) = self.extract_braced(line, "author") else {
287
- return;
288
- };
289
- self.metadata.additional.insert("author".to_string(), author.into());
290
- } else if line.starts_with("\\date{") {
291
- let Some(date) = self.extract_braced(line, "date") else {
292
- return;
293
- };
294
- self.metadata.additional.insert("date".to_string(), date.into());
295
- }
296
- }
297
-
298
- fn extract_env_name(&self, line: &str) -> Option<String> {
299
- if let Some(start) = line.find("\\begin{") {
300
- let after = &line[start + 7..];
301
- if let Some(end) = after.find('}') {
302
- return Some(after[..end].to_string());
303
- }
304
- }
305
- None
306
- }
307
-
308
- fn collect_environment(&self, lines: &[&str], start_idx: usize, env_name: &str) -> (String, usize) {
309
- let mut content = String::new();
310
- let mut i = start_idx + 1;
311
- let end_marker = format!("\\end{{{}}}", env_name);
312
-
313
- while i < lines.len() {
314
- let line = lines[i];
315
- if line.trim().contains(&end_marker) {
316
- return (content, i + 1);
317
- }
318
- content.push_str(line);
319
- content.push('\n');
320
- i += 1;
321
- }
322
-
323
- (content, i)
324
- }
325
-
326
- fn process_list(&mut self, content: &str, list_type: &str) {
327
- let lines: Vec<&str> = content.lines().collect();
328
- let mut item_num = 1;
329
- let mut i = 0;
330
-
331
- while i < lines.len() {
332
- let line = lines[i];
333
- let trimmed = line.trim();
334
-
335
- if trimmed.contains("\\begin{") {
336
- let Some(env_name) = self.extract_env_name(trimmed) else {
337
- i += 1;
338
- continue;
339
- };
340
- if env_name == "itemize" || env_name == "enumerate" || env_name == "description" {
341
- let (nested_content, new_i) = self.collect_environment(&lines, i, &env_name);
342
- let current_output_len = self.output.len();
343
- self.process_list(&nested_content, &env_name);
344
- let nested_output = self.output[current_output_len..].to_string();
345
- self.output.truncate(current_output_len);
346
- for nested_line in nested_output.lines() {
347
- self.output.push_str(" ");
348
- self.output.push_str(nested_line);
349
- self.output.push('\n');
350
- }
351
- i = new_i;
352
- continue;
353
- }
354
- }
355
-
356
- if trimmed.starts_with("\\item") {
357
- let Some(pos) = trimmed.find("\\item") else {
358
- i += 1;
359
- continue;
360
- };
361
- let after = trimmed[pos + 5..].trim();
362
-
363
- if after.starts_with('[') {
364
- let Some(bracket_end) = after.find(']') else {
365
- i += 1;
366
- continue;
367
- };
368
- let label = after[1..bracket_end].to_string();
369
- let text = after[bracket_end + 1..].trim().to_string();
370
- if list_type == "description" {
371
- let processed_text = self.process_line(&text);
372
- self.output.push_str(&format!("{}: {}\n", label, processed_text));
373
- item_num += 1;
374
- i += 1;
375
- continue;
376
- }
377
- }
378
-
379
- let prefix = if list_type == "enumerate" {
380
- format!("{}. ", item_num)
381
- } else {
382
- "- ".to_string()
383
- };
384
- self.output.push_str(&prefix);
385
-
386
- let item_text = self.process_line(after);
387
- self.output.push_str(item_text.trim());
388
- self.output.push('\n');
389
- item_num += 1;
390
- }
391
-
392
- i += 1;
393
- }
394
- self.output.push('\n');
395
- }
396
-
397
- fn process_table(&mut self, content: &str) {
398
- let lines: Vec<&str> = content.lines().collect();
399
- let mut rows: Vec<Vec<String>> = Vec::new();
400
-
401
- for line in lines {
402
- let trimmed = line.trim();
403
- if trimmed.starts_with("\\hline") || trimmed.is_empty() || trimmed.contains("\\begin{tabular}") {
404
- continue;
405
- }
406
-
407
- let row_str = trimmed.replace("\\\\", "");
408
- let cells: Vec<String> = row_str
409
- .split('&')
410
- .map(|s| self.clean_text(s.trim()))
411
- .filter(|s| !s.is_empty())
412
- .collect();
413
-
414
- if !cells.is_empty() {
415
- rows.push(cells);
416
- }
417
- }
418
-
419
- if !rows.is_empty() {
420
- let mut markdown = String::new();
421
- for (i, row) in rows.iter().enumerate() {
422
- markdown.push('|');
423
- for cell in row {
424
- markdown.push_str(&format!(" {} |", cell));
425
- }
426
- markdown.push('\n');
427
-
428
- if i == 0 && rows.len() > 1 {
429
- markdown.push('|');
430
- for _ in row {
431
- markdown.push_str(" --- |");
432
- }
433
- markdown.push('\n');
434
- }
435
- }
436
-
437
- self.output.push_str(&markdown);
438
-
439
- let table = Table {
440
- cells: rows,
441
- markdown: markdown.clone(),
442
- page_number: 1,
443
- };
444
- self.tables.push(table);
445
- }
446
- }
447
-
448
- fn process_table_with_caption(&mut self, content: &str) {
449
- if content.contains("\\caption{") {
450
- let Some(caption) = self.extract_braced_from_content(content, "caption") else {
451
- return;
452
- };
453
- self.output.push_str(&caption);
454
- self.output.push('\n');
455
- }
456
-
457
- if content.contains("\\begin{tabular}") {
458
- let Some(start) = content.find("\\begin{tabular}") else {
459
- return;
460
- };
461
- let Some(end) = content.find("\\end{tabular}") else {
462
- return;
463
- };
464
- let tabular_content = &content[start..end + 13];
465
- self.process_table(tabular_content);
466
- }
467
- }
468
-
469
- fn process_line(&self, line: &str) -> String {
470
- let mut result = String::new();
471
- let mut chars = line.chars().peekable();
472
-
473
- while let Some(ch) = chars.next() {
474
- if ch == '\\' {
475
- let mut cmd = String::new();
476
- while let Some(&c) = chars.peek() {
477
- if c.is_alphabetic() {
478
- cmd.push(chars.next().unwrap());
479
- } else {
480
- break;
481
- }
482
- }
483
-
484
- match cmd.as_str() {
485
- "textbf" => {
486
- if let Some(content) = self.read_braced_from_chars(&mut chars) {
487
- let processed = self.process_line(&content);
488
- result.push_str(&processed);
489
- }
490
- }
491
- "textit" | "emph" => {
492
- if let Some(content) = self.read_braced_from_chars(&mut chars) {
493
- let processed = self.process_line(&content);
494
- result.push_str(&processed);
495
- }
496
- }
497
- "texttt" => {
498
- if let Some(content) = self.read_braced_from_chars(&mut chars) {
499
- result.push_str(&content);
500
- }
501
- }
502
- "underline" => {
503
- if let Some(content) = self.read_braced_from_chars(&mut chars) {
504
- let processed = self.process_line(&content);
505
- result.push_str(&processed);
506
- }
507
- }
508
- "font" => {
509
- while let Some(&c) = chars.peek() {
510
- if c == '\\' {
511
- break;
512
- }
513
- chars.next();
514
- }
515
- }
516
- "usepackage" => {
517
- self.read_braced_from_chars(&mut chars);
518
- }
519
- _ => {
520
- if let Some(content) = self.read_braced_from_chars(&mut chars) {
521
- let processed = self.process_line(&content);
522
- result.push_str(&processed);
523
- } else if cmd.len() == 1 {
524
- }
525
- }
526
- }
527
- } else if ch == '$' {
528
- result.push(ch);
529
- while let Some(&c) = chars.peek() {
530
- result.push(chars.next().unwrap());
531
- if c == '$' {
532
- break;
533
- }
534
- }
535
- } else {
536
- result.push(ch);
537
- }
538
- }
539
-
540
- result
541
- }
542
-
543
- fn read_braced_from_chars(&self, chars: &mut std::iter::Peekable<std::str::Chars>) -> Option<String> {
544
- while let Some(&c) = chars.peek() {
545
- if c.is_whitespace() {
546
- chars.next();
547
- } else {
548
- break;
549
- }
550
- }
551
-
552
- if chars.peek() != Some(&'{') {
553
- return None;
554
- }
555
- chars.next();
556
-
557
- let mut content = String::new();
558
- let mut depth = 1;
559
-
560
- for c in chars.by_ref() {
561
- match c {
562
- '{' => {
563
- depth += 1;
564
- content.push(c);
565
- }
566
- '}' => {
567
- depth -= 1;
568
- if depth == 0 {
569
- return Some(content);
570
- }
571
- content.push(c);
572
- }
573
- _ => content.push(c),
574
- }
575
- }
576
-
577
- Some(content)
578
- }
579
-
580
- fn extract_braced(&self, text: &str, command: &str) -> Option<String> {
581
- let pattern = format!("\\{}{{", command);
582
- if let Some(start) = text.find(&pattern) {
583
- let after = &text[start + pattern.len()..];
584
- let mut depth = 1;
585
- let mut content = String::new();
586
-
587
- for ch in after.chars() {
588
- match ch {
589
- '{' => {
590
- depth += 1;
591
- content.push(ch);
592
- }
593
- '}' => {
594
- depth -= 1;
595
- if depth == 0 {
596
- return Some(self.clean_text(&content));
597
- }
598
- content.push(ch);
599
- }
600
- _ => content.push(ch),
601
- }
602
- }
603
- }
604
- None
605
- }
606
-
607
- fn extract_braced_from_content(&self, text: &str, command: &str) -> Option<String> {
608
- self.extract_braced(text, command)
609
- }
610
-
611
- fn clean_text(&self, text: &str) -> String {
612
- text.to_string()
613
- .replace("\\\\", "\n")
614
- .replace("\\&", "&")
615
- .replace("\\#", "#")
616
- .replace("\\_", "_")
617
- .replace("\\{", "{")
618
- .replace("\\}", "}")
619
- .replace("\\%", "%")
620
- .trim()
621
- .to_string()
622
- }
623
- }
624
-
625
- #[cfg(test)]
626
- mod tests {
627
- use super::*;
628
-
629
- #[test]
630
- fn test_basic_title_extraction() {
631
- let latex = r#"\title{Hello World}"#;
632
- let (_, metadata, _) = LatexExtractor::extract_from_latex(latex);
633
- assert_eq!(
634
- metadata.additional.get("title").and_then(|v| v.as_str()),
635
- Some("Hello World")
636
- );
637
- }
638
-
639
- #[test]
640
- fn test_author_extraction() {
641
- let latex = r#"\author{John Doe}"#;
642
- let (_, metadata, _) = LatexExtractor::extract_from_latex(latex);
643
- assert!(metadata.additional.contains_key("author"));
644
- }
645
-
646
- #[test]
647
- fn test_section_extraction() {
648
- let latex = r#"\begin{document}\section{Introduction}\end{document}"#;
649
- let (content, _, _) = LatexExtractor::extract_from_latex(latex);
650
- assert!(content.contains("Introduction"));
651
- }
652
- }