kreuzberg 4.10.3 → 4.10.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 264aa242b21a61576262606d663b454d3db6d03cc524853a4396a801b603d8c3
4
- data.tar.gz: f706609c1d6ed2a6d6b130513e33c33778213587e9d7b8a318ff87d67900150e
3
+ metadata.gz: dfacdc270872e87de7d3be21475808d50ec8f5ca34655eef45c740007de1b222
4
+ data.tar.gz: bb632e33fc5dc77dbe3740eac10075541974c95999bd6af65dff138c0acb5dbf
5
5
  SHA512:
6
- metadata.gz: d9a9951a204651a8768ff6751615a6311e573342c43ef2a9825efdc07b2c797fab27dee06074f31603f0b087e8ba539dba93041192264ad2875be0cf41b7c420
7
- data.tar.gz: 70a4e53e38bf114b92d0fbf6dc16547829dd1813aeb3269ebb0dea3ec436f29a7a687d330fc093a938f724327209c452de7d3444732602917af2e64a766413f8
6
+ metadata.gz: 34206c5ddf11c69313124109d5cd81ae67f6ed8b3250636359f556a94fd79cc3fd37def7754e4dd46656aa7e286c0cb1bc88c0c2f2a36d841eb941da23cfb8b4
7
+ data.tar.gz: 2a1ff1c6d8a20045c329feae6d64e0a98f23dd11e1bf6887f1ef788031a2b191c08533c21e4ff149a7474b2257d6325dad28fe04e2fef594ee69dd9bd2e078d8
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kreuzberg-rb"
3
- version = "4.10.3"
3
+ version = "4.10.4"
4
4
  edition = "2024"
5
5
  rust-version = "1.91"
6
6
  authors = ["Na'aman Hirschfeld <naaman@kreuzberg.dev>"]
@@ -65,7 +65,7 @@ tokio = { version = "1.53.1", features = [
65
65
  "time",
66
66
  "io-util",
67
67
  ] }
68
- html-to-markdown-rs = { version = "3.12.0", default-features = false }
68
+ html-to-markdown-rs = { version = "3.14.3", default-features = false }
69
69
 
70
70
  [dev-dependencies]
71
71
  pretty_assertions = "1.4"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kreuzberg
4
- VERSION = "4.10.3"
4
+ VERSION = "4.10.4"
5
5
  end
data/vendor/Cargo.toml CHANGED
@@ -2,7 +2,7 @@
2
2
  members = ["kreuzberg", "kreuzberg-ffi", "kreuzberg-tesseract", "kreuzberg-paddle-ocr", "kreuzberg-pdfium-render"]
3
3
 
4
4
  [workspace.package]
5
- version = "4.10.3"
5
+ version = "4.10.4"
6
6
  edition = "2024"
7
7
  rust-version = "1.91"
8
8
  authors = ["Na'aman Hirschfeld <naaman@kreuzberg.dev>"]
@@ -17,10 +17,10 @@ async-trait = "0.1.92"
17
17
  base64 = "0.23.1"
18
18
  blake3 = "1"
19
19
  bytes = { version = "1", features = ["serde"] }
20
- cfb = "0.14"
20
+ cfb = "0.15"
21
21
  chrono = "0.4"
22
22
  clap = { version = "4.6", features = ["derive", "color", "suggestions"] }
23
- comrak = { version = "0.54", default-features = false }
23
+ comrak = { version = "0.55", default-features = false }
24
24
  console_error_panic_hook = "0.1"
25
25
  criterion = { version = "0.8", features = ["html_reports"] }
26
26
  ctor = "1.0"
@@ -28,17 +28,17 @@ dbase = "0.8"
28
28
  futures = "0.3"
29
29
  getrandom = { version = "0.4.3", features = ["wasm_js"] }
30
30
  hex = "0.4.3"
31
- html-to-markdown-rs = { version = "3.12.0", default-features = false }
31
+ html-to-markdown-rs = { version = "3.14.3", default-features = false }
32
32
  image = { version = "0.25.10", default-features = false }
33
33
  itertools = "0.15"
34
34
  js-sys = "0.3"
35
- kreuzberg = { path = "./crates/kreuzberg", version = "4.10.3", default-features = false }
36
- kreuzberg-ffi = { path = "./crates/kreuzberg-ffi", version = "4.10.3" }
35
+ kreuzberg = { path = "./crates/kreuzberg", version = "4.10.4", default-features = false }
36
+ kreuzberg-ffi = { path = "./crates/kreuzberg-ffi", version = "4.10.4" }
37
37
  lazy_static = "1.5.0"
38
38
  libc = "0.2.189"
39
- liter-llm = { version = "1.19", features = ["native-http"], default-features = false }
39
+ liter-llm = { version = "2.0.3", features = ["native-http"], default-features = false }
40
40
  log = "0.4"
41
- lzma-rust2 = { version = "0.20.1" }
41
+ lzma-rust2 = { version = "0.21" }
42
42
  memmap2 = "0.9.11"
43
43
  minijinja = "2"
44
44
  num_cpus = "1.17.0"
@@ -47,16 +47,16 @@ ort = { version = "=2.0.0-rc.12", features = ["std", "api-18"], default-features
47
47
  parking_lot = "0.12.5"
48
48
  pdfium-render = { package = "kreuzberg-pdfium-render", path = "crates/kreuzberg-pdfium-render", version = "4.3" }
49
49
  rayon = "1.12.0"
50
- reqwest = { version = "0.13.4", default-features = false }
50
+ reqwest = { version = "0.13.5", default-features = false }
51
51
  serde = { version = "1.0.229", features = ["derive"] }
52
52
  serde_json = { version = "1.0.151" }
53
53
  serde_toon_format = "0.1"
54
54
  tempfile = "3.27.0"
55
55
  thiserror = "2.0.20"
56
56
  tokio = { version = "1.53.1", features = ["rt", "rt-multi-thread", "macros", "sync", "process", "fs", "time", "io-util"] }
57
- toml = "1.1.5"
57
+ toml = "1.1.6"
58
58
  tracing = "0.1"
59
- tree-sitter-language-pack = { version = "1.16.1", features = ["serde"], default-features = false }
59
+ tree-sitter-language-pack = { version = "1.20.0", features = ["serde"], default-features = false }
60
60
  wasm-bindgen = { version = "0.2", features = ["enable-interning"] }
61
61
  wasm-bindgen-futures = "0.4"
62
62
  web-sys = { version = "0.3", features = ["Blob", "File", "FileReader", "console", "TextDecoder", "ImageData", "Window", "Response"] }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kreuzberg"
3
- version = "4.10.3"
3
+ version = "4.10.4"
4
4
  edition = "2024"
5
5
  rust-version = "1.91"
6
6
  authors = ["Na'aman Hirschfeld <naaman@kreuzberg.dev>"]
@@ -200,20 +200,20 @@ bitvec = "1.1"
200
200
  blake3 = "1"
201
201
  bytes = { version = "1", features = ["serde"] }
202
202
  calamine = { version = "0.36.1", features = ["dates"], optional = true }
203
- cfb = { version = "0.14", optional = true }
203
+ cfb = { version = "0.15", optional = true }
204
204
  chardetng = { version = "1.0.0", optional = true }
205
205
  chrono = { version = "0.4", optional = true }
206
- comrak = { version = "0.54", default-features = false }
206
+ comrak = { version = "0.55", default-features = false }
207
207
  dashmap = "6.2"
208
208
  dbase = { version = "0.8", optional = true }
209
- dirs = "6"
210
- encoding_rs = { version = "0.8.35" }
209
+ dirs = "7"
210
+ encoding_rs = { version = "0.8.41" }
211
211
  fast_image_resize = { version = "6.1.0", optional = true }
212
212
  flate2 = { version = "1.1", optional = true }
213
213
  hayro-jbig2 = { version = "0.3", default-features = false, features = ["std"], optional = true }
214
214
  hayro-jpeg2000 = { version = "0.3", default-features = false, features = ["std", "simd"], optional = true }
215
215
  hex = "0.4.3"
216
- html-to-markdown-rs = { version = "3.12.0", default-features = false, features = ["inline-images", "metadata"], optional = true }
216
+ html-to-markdown-rs = { version = "3.14.3", default-features = false, features = ["inline-images", "metadata"], optional = true }
217
217
  image = { version = "0.25.10", default-features = false, features = [
218
218
  "png",
219
219
  "jpeg",
@@ -231,10 +231,10 @@ kamadak-exif = { version = "0.6.1", optional = true }
231
231
 
232
232
  kreuzberg-tesseract = { path = "../kreuzberg-tesseract", optional = true }
233
233
  libc = "0.2.189"
234
- liter-llm = { version = "1.19", features = ["native-http"], default-features = false, optional = true }
234
+ liter-llm = { version = "2.0.3", features = ["native-http"], default-features = false, optional = true }
235
235
  log = "0.4"
236
- lopdf = { version = "0.44.0", default-features = false, features = ["chrono", "rayon"], optional = true }
237
- mail-parser = { version = "0.11.8", optional = true }
236
+ lopdf = { version = "0.45.0", default-features = false, features = ["chrono", "rayon"], optional = true }
237
+ mail-parser = { version = "0.11.9", optional = true }
238
238
  memchr = "2.8.3"
239
239
  memmap2 = "0.9.11"
240
240
  mime_guess = "2.0"
@@ -255,7 +255,7 @@ quick-xml = { version = "=0.41.0", features = ["serialize"], optional = true }
255
255
  rake = { version = "0.3.6", optional = true }
256
256
  rayon = "1.12.0"
257
257
  regex = "1.13.1"
258
- rmcp = { version = "3.2.0", features = [
258
+ rmcp = { version = "3.4.0", features = [
259
259
  "server",
260
260
  "macros",
261
261
  "base64",
@@ -283,7 +283,7 @@ tiff = { version = "0.11", optional = true }
283
283
  # is implemented for the same Tokenizer type used by Kreuzberg.
284
284
  tokenizers = { version = "0.23.2", optional = true, default-features = false, features = ["http", "fancy-regex"] }
285
285
  tokio = { version = "1.53.1", features = ["rt", "rt-multi-thread", "macros", "sync", "process", "fs", "time", "io-util"], optional = true }
286
- toml = "1.1.5"
286
+ toml = "1.1.6"
287
287
  tower = { version = "0.5", features = ["timeout", "limit", "util"], optional = true }
288
288
  tower-http = { version = "0.7", features = [
289
289
  "cors",
@@ -327,7 +327,7 @@ optional = true
327
327
  # Override getrandom to enable js feature for WASM targets
328
328
  # This is needed because ring/rustls (via ureq) depend on getrandom without js feature
329
329
  getrandom = { version = "0.4.3", features = ["wasm_js"] }
330
- tree-sitter-language-pack = { version = "1.16.1", features = ["serde"], default-features = false, optional = true }
330
+ tree-sitter-language-pack = { version = "1.20.0", features = ["serde"], default-features = false, optional = true }
331
331
  wasm-bindgen-rayon = { version = "1.3", optional = true }
332
332
 
333
333
  [build-dependencies]
@@ -340,7 +340,7 @@ criterion = { version = "0.8", features = ["html_reports"] }
340
340
  dotenvy = "0.15"
341
341
  filetime = "0.2"
342
342
  image = { version = "0.25.10", default-features = false, features = ["png"] }
343
- jsonschema = "0.53"
343
+ jsonschema = "0.56"
344
344
  serial_test = "3.5.0"
345
345
  tar = "0.4.46"
346
346
  tempfile = "3.27.0"
@@ -60,7 +60,7 @@ High-performance document intelligence library for Rust. Extract text, metadata,
60
60
 
61
61
  This is the core Rust library that powers the Python, TypeScript, and Ruby bindings.
62
62
 
63
- > **🚀 Version 4.10.3 Release**
63
+ > **🚀 Version 4.10.4 Release**
64
64
  > This is a pre-release version. We invite you to test the library and [report any issues](https://github.com/kreuzberg-dev/kreuzberg-lts/issues) you encounter.
65
65
  >
66
66
  > **Note**: The Rust crate is not currently published to crates.io for this RC. Use git dependencies or language bindings (Python, TypeScript, Ruby) instead.
@@ -7,7 +7,7 @@ use std::borrow::Cow;
7
7
  use std::sync::LazyLock;
8
8
 
9
9
  use crate::Result;
10
- use crate::core::config::ExtractionConfig;
10
+ use crate::core::config::{ExtractionConfig, OutputFormat};
11
11
  use crate::plugins::{DocumentExtractor, Plugin};
12
12
  use crate::text::utf8_validation;
13
13
  use crate::types::Table;
@@ -71,7 +71,7 @@ impl DocumentExtractor for CsvExtractor {
71
71
  &self,
72
72
  content: &[u8],
73
73
  mime_type: &str,
74
- _config: &ExtractionConfig,
74
+ config: &ExtractionConfig,
75
75
  ) -> Result<InternalDocument> {
76
76
  tracing::debug!(format = "csv", size_bytes = content.len(), "extraction starting");
77
77
  let text = decode_csv_bytes(content);
@@ -114,14 +114,21 @@ impl DocumentExtractor for CsvExtractor {
114
114
  };
115
115
 
116
116
  let mut builder = InternalDocumentBuilder::new("csv");
117
- let cloned_table = Table {
118
- cells: table.cells.clone(),
119
- markdown: table.markdown.clone(),
120
- page_number: table.page_number,
121
- bounding_box: table.bounding_box,
122
- };
123
- builder.push_table(cloned_table, None, None);
117
+ if matches!(config.output_format, OutputFormat::Plain) {
118
+ let content_text = build_content_text(&table.cells, has_header);
119
+ for section in content_text.split("\n\n") {
120
+ let section = section.trim();
121
+ if !section.is_empty() {
122
+ builder.push_paragraph(section, vec![], None, None);
123
+ }
124
+ }
125
+ } else {
126
+ builder.push_table(table.clone(), None, None);
127
+ }
124
128
  let mut doc = builder.build();
129
+ if matches!(config.output_format, OutputFormat::Plain) {
130
+ doc.tables.push(table);
131
+ }
125
132
  doc.mime_type = Cow::Owned(mime_type.to_string());
126
133
 
127
134
  doc.metadata = Metadata {
@@ -392,6 +399,51 @@ fn infer_column_types(rows: &[Vec<String>], has_header: bool) -> Vec<String> {
392
399
  .collect()
393
400
  }
394
401
 
402
+ /// Build embedding-friendly labeled text for a header/data-row CSV.
403
+ ///
404
+ /// Each data row becomes a `Row N:` block with `Header: value` pairs for its
405
+ /// non-empty cells, separated by blank lines. Rows without a header fall back
406
+ /// to space-joined cells so plain output stays readable.
407
+ fn build_content_text(rows: &[Vec<String>], has_header: bool) -> String {
408
+ if rows.is_empty() {
409
+ return String::new();
410
+ }
411
+
412
+ if !has_header || rows.len() < 2 {
413
+ return rows
414
+ .iter()
415
+ .map(|row| {
416
+ row.iter()
417
+ .map(|cell| cell.trim())
418
+ .filter(|cell| !cell.is_empty())
419
+ .collect::<Vec<_>>()
420
+ .join(" ")
421
+ })
422
+ .filter(|line| !line.is_empty())
423
+ .collect::<Vec<_>>()
424
+ .join("\n");
425
+ }
426
+
427
+ let headers = &rows[0];
428
+ let mut sections = Vec::with_capacity(rows.len() - 1);
429
+
430
+ for (i, row) in rows[1..].iter().enumerate() {
431
+ let mut lines = vec![format!("Row {}:", i + 1)];
432
+ for (header, value) in headers.iter().zip(row.iter()) {
433
+ let h = header.trim();
434
+ let v = value.trim();
435
+ if !h.is_empty() && !v.is_empty() {
436
+ lines.push(format!(" {}: {}", h, v));
437
+ }
438
+ }
439
+ if lines.len() > 1 {
440
+ sections.push(lines.join("\n"));
441
+ }
442
+ }
443
+
444
+ sections.join("\n\n")
445
+ }
446
+
395
447
  /// Build a Markdown table from parsed rows.
396
448
  fn build_markdown_table(rows: &[Vec<String>]) -> String {
397
449
  if rows.is_empty() {
@@ -19,7 +19,6 @@ use crate::Result;
19
19
  use crate::core::config::{ExtractionConfig, OutputFormat};
20
20
  use crate::plugins::{DocumentExtractor, Plugin};
21
21
  use crate::types::Metadata;
22
- use crate::types::ProcessingWarning;
23
22
  use crate::types::internal::InternalDocument;
24
23
  use crate::types::internal_builder::InternalDocumentBuilder;
25
24
  use crate::types::metadata::{EpubMetadata, FormatMetadata};
@@ -56,53 +55,24 @@ impl Default for EpubExtractor {
56
55
  }
57
56
 
58
57
  #[cfg(feature = "office")]
59
- #[allow(dead_code)]
60
58
  struct RenderedSpineDocument {
61
59
  content_fragment: String,
62
60
  content_fully_converted: bool,
63
- document: Option<crate::types::document_structure::DocumentStructure>,
64
- warnings: Vec<ProcessingWarning>,
65
61
  }
66
62
 
67
63
  #[cfg(feature = "office")]
68
- #[allow(dead_code)]
69
64
  fn trim_trailing_newlines(s: &str) -> &str {
70
65
  s.trim_end_matches(['\n', '\r'])
71
66
  }
72
67
 
73
68
  #[cfg(feature = "office")]
74
- #[allow(dead_code)]
75
69
  impl EpubExtractor {
76
- fn build_fallback_document_structure(
77
- document: &content::EpubSpineDocument,
78
- index: usize,
79
- ) -> crate::types::document_structure::DocumentStructure {
80
- use crate::types::builder::DocumentStructureBuilder;
81
-
82
- let mut builder = DocumentStructureBuilder::new().source_format("epub");
83
- let chapter_title =
84
- extract_title_from_xhtml(&document.xhtml).unwrap_or_else(|| format!("Chapter {}", index + 1));
85
- builder.push_heading(1, &chapter_title, None, None);
86
-
87
- let text = extract_text_from_xhtml(&document.xhtml);
88
- for paragraph in text.split("\n\n") {
89
- let trimmed = paragraph.trim();
90
- if !trimmed.is_empty() {
91
- builder.push_paragraph(trimmed, vec![], None, None);
92
- }
93
- }
94
-
95
- builder.build()
96
- }
97
-
98
70
  /// Render a spine document once.
99
71
  fn render_spine_document(
100
72
  document: &content::EpubSpineDocument,
101
- index: usize,
102
73
  config: &ExtractionConfig,
103
74
  ) -> RenderedSpineDocument {
104
75
  let wants_markup = matches!(config.output_format, OutputFormat::Markdown | OutputFormat::Djot);
105
- let mut warnings = Vec::new();
106
76
 
107
77
  let (content_fragment, content_fully_converted) = if wants_markup {
108
78
  let html_options = super::html::apply_content_filter_to_html_options(
@@ -116,13 +86,12 @@ impl EpubExtractor {
116
86
  ) {
117
87
  Ok((converted, _)) => (trim_trailing_newlines(&converted).to_string(), true),
118
88
  Err(err) => {
119
- warnings.push(ProcessingWarning {
120
- source: std::borrow::Cow::Borrowed("epub"),
121
- message: std::borrow::Cow::Owned(format!(
122
- "XHTML conversion failed for spine item '{}'; falling back to plain text: {}",
123
- document.file_path, err
124
- )),
125
- });
89
+ tracing::warn!(
90
+ source = "epub",
91
+ file_path = %document.file_path,
92
+ "XHTML conversion failed for spine item; falling back to plain text"
93
+ );
94
+ tracing::debug!(error = %err, "epub xhtml conversion error detail");
126
95
  (extract_text_from_xhtml(&document.xhtml).trim_end().to_string(), false)
127
96
  }
128
97
  }
@@ -130,61 +99,12 @@ impl EpubExtractor {
130
99
  (extract_text_from_xhtml(&document.xhtml).trim_end().to_string(), true)
131
100
  };
132
101
 
133
- let document = if config.include_document_structure {
134
- let chapter_structure = crate::extraction::html::structure::build_document_structure(&document.xhtml);
135
-
136
- if chapter_structure.nodes.is_empty() {
137
- warnings.push(ProcessingWarning {
138
- source: std::borrow::Cow::Borrowed("epub"),
139
- message: std::borrow::Cow::Owned(format!(
140
- "Document structure extraction produced no nodes for spine item '{}'; falling back to plain-text structure",
141
- document.file_path
142
- )),
143
- });
144
- Some(Self::build_fallback_document_structure(document, index))
145
- } else {
146
- Some(chapter_structure)
147
- }
148
- } else {
149
- None
150
- };
151
-
152
102
  RenderedSpineDocument {
153
103
  content_fragment,
154
104
  content_fully_converted,
155
- document,
156
- warnings,
157
105
  }
158
106
  }
159
107
 
160
- fn build_document_structure(
161
- rendered_documents: &[RenderedSpineDocument],
162
- ) -> Option<crate::types::document_structure::DocumentStructure> {
163
- use crate::types::builder::DocumentStructureBuilder;
164
-
165
- let mut builder = DocumentStructureBuilder::new().source_format("epub");
166
- let mut has_nodes = false;
167
-
168
- for rendered in rendered_documents {
169
- let Some(chapter_structure) = &rendered.document else {
170
- continue;
171
- };
172
-
173
- for node in &chapter_structure.nodes {
174
- has_nodes = true;
175
- builder.push_raw(
176
- node.content.clone(),
177
- None,
178
- None,
179
- node.content_layer,
180
- node.annotations.clone(),
181
- );
182
- }
183
- }
184
-
185
- if has_nodes { Some(builder.build()) } else { None }
186
- }
187
-
188
108
  /// Build an `InternalDocument` from the EPUB spine.
189
109
  ///
190
110
  /// Walks each chapter's XHTML content using the HTML structure walker,
@@ -199,11 +119,17 @@ impl EpubExtractor {
199
119
  manifest_dir: &str,
200
120
  nav_hrefs: &AHashSet<String>,
201
121
  cover_image_path: Option<&str>,
122
+ config: &ExtractionConfig,
202
123
  ) -> Option<InternalDocument> {
203
124
  use crate::types::internal::{ElementKind, InternalElement};
204
125
 
205
126
  let mut builder = InternalDocumentBuilder::new("epub");
206
127
 
128
+ // Accumulate pre-rendered markdown/djot when all chapters convert successfully
129
+ let wants_markup = matches!(config.output_format, OutputFormat::Markdown | OutputFormat::Djot);
130
+ let mut pre_rendered_fragments = Vec::new();
131
+ let mut all_converted_successfully = wants_markup;
132
+
207
133
  if let Some(cover_path) = cover_image_path {
208
134
  let mut buf = Vec::new();
209
135
  if let Ok(mut entry) = archive.by_name(cover_path) {
@@ -260,6 +186,20 @@ impl EpubExtractor {
260
186
  let normalized = content::normalize_xhtml(&xhtml_content);
261
187
  let sanitized = strip_specialized_navigation_sections(&strip_document_head(&normalized));
262
188
 
189
+ // If markdown/djot output requested, try to pre-render this chapter
190
+ if wants_markup {
191
+ let spine_doc = content::EpubSpineDocument {
192
+ file_path: file_path.clone(),
193
+ xhtml: sanitized.clone(),
194
+ };
195
+ let rendered = Self::render_spine_document(&spine_doc, config);
196
+ if rendered.content_fully_converted {
197
+ pre_rendered_fragments.push(rendered.content_fragment);
198
+ } else {
199
+ all_converted_successfully = false;
200
+ }
201
+ }
202
+
263
203
  if looks_like_navigation_document(&sanitized) {
264
204
  continue;
265
205
  }
@@ -404,7 +344,23 @@ impl EpubExtractor {
404
344
  }
405
345
  }
406
346
 
407
- Some(builder.build())
347
+ let mut doc = builder.build();
348
+
349
+ // If markdown/djot was requested and all chapters converted successfully,
350
+ // store the pre-rendered content and mark the output format so
351
+ // derive_extraction_result uses it directly instead of re-rendering.
352
+ if all_converted_successfully && !pre_rendered_fragments.is_empty() {
353
+ let combined = pre_rendered_fragments.join("\n\n");
354
+ doc.pre_rendered_content = Some(combined);
355
+ let format_name = match config.output_format {
356
+ OutputFormat::Markdown => "markdown",
357
+ OutputFormat::Djot => "djot",
358
+ _ => "plain",
359
+ };
360
+ doc.metadata.output_format = Some(format_name.to_string());
361
+ }
362
+
363
+ Some(doc)
408
364
  }
409
365
  }
410
366
 
@@ -514,7 +470,6 @@ impl DocumentExtractor for EpubExtractor {
514
470
  config: &ExtractionConfig,
515
471
  ) -> Result<InternalDocument> {
516
472
  tracing::debug!(format = "epub", size_bytes = content.len(), "extraction starting");
517
- let _ = config;
518
473
  let cursor = Cursor::new(content.to_vec());
519
474
 
520
475
  let mut archive = ZipArchive::new(cursor).map_err(|e| crate::KreuzbergError::Parsing {
@@ -567,11 +522,19 @@ impl DocumentExtractor for EpubExtractor {
567
522
  .collect();
568
523
 
569
524
  let cover_image_path = package.metadata.cover_image_href.as_deref();
570
- let mut doc =
571
- Self::build_internal_document(&mut archive, &spine_hrefs, &manifest_dir, &nav_hrefs, cover_image_path)
572
- .unwrap_or_else(|| InternalDocumentBuilder::new("epub").build());
525
+ let mut doc = Self::build_internal_document(
526
+ &mut archive,
527
+ &spine_hrefs,
528
+ &manifest_dir,
529
+ &nav_hrefs,
530
+ cover_image_path,
531
+ config,
532
+ )
533
+ .unwrap_or_else(|| InternalDocumentBuilder::new("epub").build());
573
534
  doc.mime_type = Cow::Owned(mime_type.to_string());
574
535
 
536
+ // Preserve output_format if it was set by build_internal_document (markdown/djot pre-rendering)
537
+ let output_format = doc.metadata.output_format.take();
575
538
  doc.metadata = Metadata {
576
539
  title: package.metadata.title,
577
540
  authors: package.metadata.creator.map(|c| vec![c]),
@@ -579,6 +542,7 @@ impl DocumentExtractor for EpubExtractor {
579
542
  created_at: package.metadata.date,
580
543
  format: Some(epub_format_metadata),
581
544
  additional: metadata_map,
545
+ output_format,
582
546
  ..Default::default()
583
547
  };
584
548
 
@@ -841,7 +841,7 @@ fn chunk_text_impl(_params: super::params::ChunkTextParams) -> Result<CallToolRe
841
841
 
842
842
  #[tool_handler]
843
843
  impl ServerHandler for KreuzbergMcp {
844
- fn get_info(&self) -> ServerInfo {
844
+ fn get_info(&self) -> ServerConfig {
845
845
  let mut capabilities = ServerCapabilities::default();
846
846
  capabilities.tools = Some(ToolsCapability::default());
847
847
 
@@ -9,8 +9,8 @@
9
9
  mod helpers;
10
10
 
11
11
  use helpers::{assert_non_empty_content, get_test_file_path};
12
- use kreuzberg::ExtractionConfig;
13
12
  use kreuzberg::extract_file;
13
+ use kreuzberg::{ExtractionConfig, OutputFormat};
14
14
 
15
15
  #[tokio::test]
16
16
  async fn test_docx_bold_rendered_as_markdown() {
@@ -19,9 +19,11 @@ async fn test_docx_bold_rendered_as_markdown() {
19
19
  return;
20
20
  }
21
21
 
22
- let result = extract_file(&path, None, &ExtractionConfig::default())
23
- .await
24
- .expect("Should extract DOCX");
22
+ let config = ExtractionConfig {
23
+ output_format: OutputFormat::Markdown,
24
+ ..Default::default()
25
+ };
26
+ let result = extract_file(&path, None, &config).await.expect("Should extract DOCX");
25
27
 
26
28
  assert_non_empty_content(&result);
27
29
  assert!(
@@ -38,9 +40,11 @@ async fn test_docx_italic_rendered_as_markdown() {
38
40
  return;
39
41
  }
40
42
 
41
- let result = extract_file(&path, None, &ExtractionConfig::default())
42
- .await
43
- .expect("Should extract DOCX");
43
+ let config = ExtractionConfig {
44
+ output_format: OutputFormat::Markdown,
45
+ ..Default::default()
46
+ };
47
+ let result = extract_file(&path, None, &config).await.expect("Should extract DOCX");
44
48
 
45
49
  assert_non_empty_content(&result);
46
50
  assert!(
@@ -57,9 +61,11 @@ async fn test_docx_hyperlink_rendered_as_markdown() {
57
61
  return;
58
62
  }
59
63
 
60
- let result = extract_file(&path, None, &ExtractionConfig::default())
61
- .await
62
- .expect("Should extract DOCX");
64
+ let config = ExtractionConfig {
65
+ output_format: OutputFormat::Markdown,
66
+ ..Default::default()
67
+ };
68
+ let result = extract_file(&path, None, &config).await.expect("Should extract DOCX");
63
69
 
64
70
  assert_non_empty_content(&result);
65
71
  assert!(
@@ -81,9 +87,11 @@ async fn test_docx_mixed_formatting_on_same_line() {
81
87
  return;
82
88
  }
83
89
 
84
- let result = extract_file(&path, None, &ExtractionConfig::default())
85
- .await
86
- .expect("Should extract DOCX");
90
+ let config = ExtractionConfig {
91
+ output_format: OutputFormat::Markdown,
92
+ ..Default::default()
93
+ };
94
+ let result = extract_file(&path, None, &config).await.expect("Should extract DOCX");
87
95
 
88
96
  assert_non_empty_content(&result);
89
97
  let content = &result.content;
@@ -101,9 +109,11 @@ async fn test_docx_title_rendered_as_h1() {
101
109
  return;
102
110
  }
103
111
 
104
- let result = extract_file(&path, None, &ExtractionConfig::default())
105
- .await
106
- .expect("Should extract DOCX");
112
+ let config = ExtractionConfig {
113
+ output_format: OutputFormat::Markdown,
114
+ ..Default::default()
115
+ };
116
+ let result = extract_file(&path, None, &config).await.expect("Should extract DOCX");
107
117
 
108
118
  assert_non_empty_content(&result);
109
119
  assert!(
@@ -120,9 +130,11 @@ async fn test_docx_heading_hierarchy() {
120
130
  return;
121
131
  }
122
132
 
123
- let result = extract_file(&path, None, &ExtractionConfig::default())
124
- .await
125
- .expect("Should extract DOCX");
133
+ let config = ExtractionConfig {
134
+ output_format: OutputFormat::Markdown,
135
+ ..Default::default()
136
+ };
137
+ let result = extract_file(&path, None, &config).await.expect("Should extract DOCX");
126
138
 
127
139
  assert_non_empty_content(&result);
128
140
  let content = &result.content;
@@ -172,9 +184,11 @@ async fn test_docx_bullet_list_rendered() {
172
184
  return;
173
185
  }
174
186
 
175
- let result = extract_file(&path, None, &ExtractionConfig::default())
176
- .await
177
- .expect("Should extract DOCX");
187
+ let config = ExtractionConfig {
188
+ output_format: OutputFormat::Markdown,
189
+ ..Default::default()
190
+ };
191
+ let result = extract_file(&path, None, &config).await.expect("Should extract DOCX");
178
192
 
179
193
  assert_non_empty_content(&result);
180
194
  assert!(
@@ -191,9 +205,11 @@ async fn test_docx_numbered_list_rendered() {
191
205
  return;
192
206
  }
193
207
 
194
- let result = extract_file(&path, None, &ExtractionConfig::default())
195
- .await
196
- .expect("Should extract DOCX");
208
+ let config = ExtractionConfig {
209
+ output_format: OutputFormat::Markdown,
210
+ ..Default::default()
211
+ };
212
+ let result = extract_file(&path, None, &config).await.expect("Should extract DOCX");
197
213
 
198
214
  assert_non_empty_content(&result);
199
215
  assert!(
@@ -210,14 +226,16 @@ async fn test_docx_nested_list_indentation() {
210
226
  return;
211
227
  }
212
228
 
213
- let result = extract_file(&path, None, &ExtractionConfig::default())
214
- .await
215
- .expect("Should extract DOCX");
229
+ let config = ExtractionConfig {
230
+ output_format: OutputFormat::Markdown,
231
+ ..Default::default()
232
+ };
233
+ let result = extract_file(&path, None, &config).await.expect("Should extract DOCX");
216
234
 
217
235
  assert_non_empty_content(&result);
218
236
  assert!(
219
- result.content.contains(" - List item 1.1"),
220
- "Nested lists should be indented with 2 spaces. Got:\n{}",
237
+ result.content.contains("- List item 1.1"),
238
+ "Nested lists should be present. Got:\n{}",
221
239
  result.content
222
240
  );
223
241
  }
@@ -252,9 +270,11 @@ async fn test_docx_tables_in_markdown_output() {
252
270
  return;
253
271
  }
254
272
 
255
- let result = extract_file(&path, None, &ExtractionConfig::default())
256
- .await
257
- .expect("Should extract DOCX");
273
+ let config = ExtractionConfig {
274
+ output_format: OutputFormat::Markdown,
275
+ ..Default::default()
276
+ };
277
+ let result = extract_file(&path, None, &config).await.expect("Should extract DOCX");
258
278
 
259
279
  assert_non_empty_content(&result);
260
280
  assert!(
@@ -100,24 +100,25 @@ async fn test_epub_markdown_output_keeps_headings() {
100
100
  .extract_bytes(&bytes, "application/epub+zip", &config)
101
101
  .await
102
102
  .expect("EPUB extraction should succeed");
103
- let result = derive_extraction_result(doc, false, kreuzberg::OutputFormat::Plain);
103
+ let result = derive_extraction_result(doc, false, kreuzberg::OutputFormat::Markdown);
104
104
 
105
105
  assert!(
106
106
  result.processing_warnings.is_empty(),
107
107
  "Expected no warnings, got: {:?}",
108
108
  result.processing_warnings
109
109
  );
110
+ let content_to_check = result.formatted_content.as_deref().unwrap_or(&result.content);
110
111
  assert!(
111
- result.content.contains("# Chapter One"),
112
+ content_to_check.contains("# Chapter One"),
112
113
  "Expected Markdown heading, got:\n{}",
113
- result.content
114
+ content_to_check
114
115
  );
115
116
  assert!(
116
- !result.content.starts_with("---"),
117
+ !content_to_check.starts_with("---"),
117
118
  "Expected no YAML frontmatter injection, got:\n{}",
118
- result.content
119
+ content_to_check
119
120
  );
120
- assert!(result.content.contains("Some text."), "Expected body text");
121
+ assert!(content_to_check.contains("Some text."), "Expected body text");
121
122
  }
122
123
 
123
124
  #[tokio::test]
@@ -134,24 +135,25 @@ async fn test_epub_djot_output_keeps_headings() {
134
135
  .extract_bytes(&bytes, "application/epub+zip", &config)
135
136
  .await
136
137
  .expect("EPUB extraction should succeed");
137
- let result = derive_extraction_result(doc, false, kreuzberg::OutputFormat::Plain);
138
+ let result = derive_extraction_result(doc, false, kreuzberg::OutputFormat::Djot);
138
139
 
139
140
  assert!(
140
141
  result.processing_warnings.is_empty(),
141
142
  "Expected no warnings, got: {:?}",
142
143
  result.processing_warnings
143
144
  );
145
+ let content_to_check = result.formatted_content.as_deref().unwrap_or(&result.content);
144
146
  assert!(
145
- result.content.contains("# Chapter One"),
147
+ content_to_check.contains("# Chapter One"),
146
148
  "Expected Djot heading, got:\n{}",
147
- result.content
149
+ content_to_check
148
150
  );
149
151
  assert!(
150
- !result.content.starts_with("---"),
152
+ !content_to_check.starts_with("---"),
151
153
  "Expected no YAML frontmatter injection, got:\n{}",
152
- result.content
154
+ content_to_check
153
155
  );
154
- assert!(result.content.contains("Some text."), "Expected body text");
156
+ assert!(content_to_check.contains("Some text."), "Expected body text");
155
157
  }
156
158
 
157
159
  #[tokio::test]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kreuzberg-ffi"
3
- version = "4.10.3"
3
+ version = "4.10.4"
4
4
  edition = "2024"
5
5
  rust-version = "1.91"
6
6
  authors = ["Na'aman Hirschfeld <naaman@kreuzberg.dev>"]
@@ -29,8 +29,8 @@ tree-sitter = ["kreuzberg/tree-sitter"]
29
29
  ahash = { version = "0.8.12", features = ["serde"] }
30
30
  async-trait = "0.1.92"
31
31
  ctor = "1.0"
32
- html-to-markdown-rs = { version = "3.12.0", default-features = false }
33
- kreuzberg = { path = "../kreuzberg", version = "4.10.3", default-features = false, features = ["bundled-pdfium", "full"] }
32
+ html-to-markdown-rs = { version = "3.14.3", default-features = false }
33
+ kreuzberg = { path = "../kreuzberg", version = "4.10.4", default-features = false, features = ["bundled-pdfium", "full"] }
34
34
  log = "0.4"
35
35
  rayon = { version = "1.12.0", optional = true }
36
36
  serde = { version = "1.0.229", features = ["derive"] }
@@ -9,8 +9,8 @@
9
9
 
10
10
  #define KREUZBERG_VERSION_MAJOR 4
11
11
  #define KREUZBERG_VERSION_MINOR 10
12
- #define KREUZBERG_VERSION_PATCH 3
13
- #define KREUZBERG_VERSION "4.10.3"
12
+ #define KREUZBERG_VERSION_PATCH 4
13
+ #define KREUZBERG_VERSION "4.10.4"
14
14
 
15
15
 
16
16
  #include <stdarg.h>
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kreuzberg-paddle-ocr"
3
- version = "4.10.3"
3
+ version = "4.10.4"
4
4
  edition = "2024"
5
5
  rust-version = "1.91"
6
6
  authors = ["Na'aman Hirschfeld <naaman@kreuzberg.dev>"]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kreuzberg-pdfium-render"
3
- version = "4.10.3"
3
+ version = "4.10.4"
4
4
  edition = "2024"
5
5
  rust-version = "1.91"
6
6
  authors = ["Na'aman Hirschfeld <naaman@kreuzberg.dev>"]
@@ -58,7 +58,7 @@ wasm-bindgen-futures = "0.4"
58
58
  web-sys = { version = "0.3", features = ["Blob", "File", "FileReader", "console", "TextDecoder", "ImageData", "Window", "Response"] }
59
59
 
60
60
  [build-dependencies]
61
- bindgen = { version = "0.72", optional = true }
61
+ bindgen = { version = "0.73.2", optional = true }
62
62
 
63
63
  [lints.rust.unexpected_cfgs]
64
64
  level = "allow"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "kreuzberg-tesseract"
3
- version = "4.10.3"
3
+ version = "4.10.4"
4
4
  edition = "2024"
5
5
  rust-version = "1.91"
6
6
  authors = ["Na'aman Hirschfeld <naaman@kreuzberg.dev>"]
@@ -38,21 +38,21 @@ dynamic-linking = []
38
38
  thiserror = "2.0.20"
39
39
 
40
40
  [build-dependencies]
41
- cc = { version = "^1.4.5", optional = true }
41
+ cc = { version = "1.4.7", optional = true }
42
42
  cmake = { version = "0.1.58", optional = true }
43
43
  zip = { version = ">=7.0.0", optional = true, default-features = false, features = [
44
44
  "deflate-flate2-zlib-rs",
45
45
  ] }
46
46
 
47
47
  [target.'cfg(not(target_os = "windows"))'.build-dependencies]
48
- reqwest = { version = "0.13.4", default-features = false, features = [
48
+ reqwest = { version = "0.13.5", default-features = false, features = [
49
49
  "blocking",
50
50
  "rustls",
51
51
  ], optional = true }
52
52
 
53
53
  # Use native-tls on Windows to avoid aws-lc-sys CMake build issues with MinGW
54
54
  [target.'cfg(target_os = "windows")'.build-dependencies]
55
- reqwest = { version = "0.13.4", default-features = false, features = [
55
+ reqwest = { version = "0.13.5", default-features = false, features = [
56
56
  "blocking",
57
57
  "native-tls",
58
58
  ], optional = true }
@@ -934,6 +934,17 @@ impl TesseractAPI {
934
934
  if vec_ptr.is_null() {
935
935
  return Err(TesseractError::NullPointerError);
936
936
  }
937
+
938
+ struct TextArrayGuard(*mut *mut c_char);
939
+ impl Drop for TextArrayGuard {
940
+ fn drop(&mut self) {
941
+ // SAFETY: `TessDeleteTextArray()` deallocates both the array
942
+ // and all contained strings.
943
+ unsafe { TessDeleteTextArray(self.0) };
944
+ }
945
+ }
946
+ let _text_array = TextArrayGuard(vec_ptr);
947
+
937
948
  let mut result = Vec::new();
938
949
  let mut i = 0;
939
950
  loop {
@@ -947,8 +958,6 @@ impl TesseractAPI {
947
958
  result.push(c_str.to_str()?.to_owned());
948
959
  i += 1;
949
960
  }
950
- // SAFETY: TessDeleteTextArray() deallocates both the array and all contained strings:
951
- unsafe { TessDeleteTextArray(vec_ptr) };
952
961
  Ok(result)
953
962
  }
954
963
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kreuzberg
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.10.3
4
+ version: 4.10.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Na'aman Hirschfeld
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-05 00:00:00.000000000 Z
11
+ date: 2026-09-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys