From 46f667fab13d599257457d9f7ef5a890a21d1a98 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:53:15 +0000 Subject: [PATCH 1/2] feat(documents): markkit as default Office converter, deprecate office Add MarkkitConverter (docs-markkit, default) converting DOCX, XLSX/XLSM, XLS and ODS through the markkit crate on the shared blocking path, with limits tunable via MarkkitConverter::with_markkit. Register `markkit` in DocumentConversionSettings. Deprecate OfficeConverter: docs-office is no longer default nor part of docs-all; when enabled it runs after markkit and remains the only PPTX/XLSB backend. Gate the PDF integration test on docs-pdf. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Y2vd61x8kbPtmDxze9Q4Gi --- Cargo.lock | 46 +++ Cargo.toml | 1 + crates/fetchkit-cli/Cargo.toml | 2 + crates/fetchkit-cli/src/main.rs | 2 +- crates/fetchkit-python/Cargo.toml | 2 + crates/fetchkit/Cargo.toml | 11 +- crates/fetchkit/src/documents/markkit.rs | 353 ++++++++++++++++++++++ crates/fetchkit/src/documents/mod.rs | 44 ++- crates/fetchkit/src/documents/office.rs | 9 + crates/fetchkit/src/documents/settings.rs | 61 +++- crates/fetchkit/tests/documents.rs | 123 ++++++++ crates/fetchkit/tests/integration.rs | 10 +- 12 files changed, 647 insertions(+), 17 deletions(-) create mode 100644 crates/fetchkit/src/documents/markkit.rs diff --git a/Cargo.lock b/Cargo.lock index a24c9f2..235e0f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,6 +314,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -911,6 +922,7 @@ dependencies = [ "ed25519-dalek", "futures", "libc", + "markkit", "mira-eval", "pdf-inspector", "percent-encoding", @@ -1725,6 +1737,30 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "markkit" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8995a77024f1cd878ba5d07ee850796f4782ad00961aa3f312e79123eaa2b901" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "calamine", + "cfb", + "encoding_rs", + "futures", + "mime", + "quick-xml", + "regex", + "serde", + "serde_json", + "thiserror", + "tokio", + "unicode-segmentation", + "zip", +] + [[package]] name = "markup5ever" version = "0.12.1" @@ -3559,6 +3595,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 28986c4..9295d0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,7 @@ tower = { version = "0.5", features = ["util"] } # Binary document extraction pdf-inspector = "0.1.7" +markkit = { version = "0.1", default-features = false, features = ["docx", "spreadsheet"] } calamine = { version = "0.36", default-features = false } zip = { version = "8", default-features = false, features = ["deflate"] } quick-xml = "0.41" diff --git a/crates/fetchkit-cli/Cargo.toml b/crates/fetchkit-cli/Cargo.toml index b48078d..3334aff 100644 --- a/crates/fetchkit-cli/Cargo.toml +++ b/crates/fetchkit-cli/Cargo.toml @@ -20,6 +20,8 @@ doc = false default = [] bot-auth = ["fetchkit/bot-auth"] render-rakers = ["fetchkit/render-rakers"] +docs-markkit = ["fetchkit/docs-markkit"] +docs-office = ["fetchkit/docs-office"] docs-cloudflare = ["fetchkit/docs-cloudflare"] docs-http = ["fetchkit/docs-http"] docs-all = ["fetchkit/docs-all"] diff --git a/crates/fetchkit-cli/src/main.rs b/crates/fetchkit-cli/src/main.rs index ec7c1eb..4225c61 100644 --- a/crates/fetchkit-cli/src/main.rs +++ b/crates/fetchkit-cli/src/main.rs @@ -34,7 +34,7 @@ struct DocumentArgs { #[arg(long = "convert", value_delimiter = ',')] formats: Option>, - /// Converter order, comma-separated (pdf-inspector, office, cloudflare). Cloudflare + /// Converter order, comma-separated (pdf-inspector, markkit, office, cloudflare). Cloudflare /// reads CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN. #[arg(long = "converter", value_delimiter = ',')] converters: Option>, diff --git a/crates/fetchkit-python/Cargo.toml b/crates/fetchkit-python/Cargo.toml index c9f9ab0..596a173 100644 --- a/crates/fetchkit-python/Cargo.toml +++ b/crates/fetchkit-python/Cargo.toml @@ -14,6 +14,8 @@ crate-type = ["cdylib"] [features] default = [] +docs-markkit = ["fetchkit/docs-markkit"] +docs-office = ["fetchkit/docs-office"] docs-cloudflare = ["fetchkit/docs-cloudflare"] docs-all = ["fetchkit/docs-all"] diff --git a/crates/fetchkit/Cargo.toml b/crates/fetchkit/Cargo.toml index 3c9808b..371673b 100644 --- a/crates/fetchkit/Cargo.toml +++ b/crates/fetchkit/Cargo.toml @@ -11,13 +11,17 @@ categories.workspace = true readme = "../../README.md" [features] -default = ["docs-pdf", "docs-office"] +default = ["docs-pdf", "docs-markkit"] # Document conversion backends (see knowledge/foundations/fetchers.md) docs-pdf = ["dep:pdf-inspector"] +# Office documents (DOCX, XLSX/XLSM, XLS, ODS) via markkit. +docs-markkit = ["dep:markkit"] +# Deprecated in-crate Office converter (DOCX, PPTX, spreadsheets); use docs-markkit. docs-office = ["dep:calamine", "dep:zip", "dep:quick-xml"] docs-cloudflare = [] docs-http = [] -docs-all = ["docs-pdf", "docs-office", "docs-cloudflare", "docs-http"] +# Every supported backend; excludes the deprecated docs-office. +docs-all = ["docs-pdf", "docs-markkit", "docs-cloudflare", "docs-http"] bot-auth = ["dep:ed25519-dalek", "dep:base64", "dep:sha2", "dep:rand"] live-tests = [] render-rakers = ["dep:rakers"] @@ -37,6 +41,7 @@ bytes = { workspace = true } async-trait = { workspace = true } tower = { workspace = true } pdf-inspector = { workspace = true, optional = true } +markkit = { workspace = true, optional = true } calamine = { workspace = true, optional = true } zip = { workspace = true, optional = true } quick-xml = { workspace = true, optional = true } @@ -59,3 +64,5 @@ base64 = { workspace = true } sha2 = { workspace = true } rand = { workspace = true } mira-eval = "0.3" +# Builds in-memory DOCX/XLSX fixtures for document tests. +zip = { workspace = true, features = ["deflate"] } diff --git a/crates/fetchkit/src/documents/markkit.rs b/crates/fetchkit/src/documents/markkit.rs new file mode 100644 index 0000000..45b57bb --- /dev/null +++ b/crates/fetchkit/src/documents/markkit.rs @@ -0,0 +1,353 @@ +//! Office documents (DOCX, XLSX/XLSM, XLS, ODS) to Markdown via the `markkit` crate. +//! +//! Decisions: +//! - markkit is the default Office backend; the in-crate `office` converter is +//! deprecated. Parsing is local on the bounded response bytes; markkit is built +//! without its `http_client` feature, so it cannot fetch anything itself. +//! - THREAT[TM-DOS-008]: zip-bomb and grid limits are markkit's (archive entry count, +//! per-entry and total decompressed size, rows/columns per sheet, total cell grid). +//! fetchkit adds the blocking-pool two-document concurrency cap and caps the final +//! Markdown at `max_body_size`. Callers tune markkit limits through +//! [`MarkkitConverter::with_markkit`]. +//! - The confirmed fetchkit format's canonical media type is passed as the hint, so +//! markkit parses the same format fetchkit detected and magic-checked. +//! - markkit warnings are `code: detail`; only the stable code is surfaced as a +//! quality warning, since details embed document-controlled text (sheet names). + +use std::fmt; +use std::sync::Arc; + +use async_trait::async_trait; +use markkit::{DocumentHint, Markkit}; + +use super::{ + run_document_task, ConvertError, ConvertedDocument, DocumentConverter, DocumentFormat, + DocumentInput, Locality, +}; + +/// Converts Word (DOCX) and spreadsheets (XLSX, XLSM, XLS, ODS) to Markdown +/// in-process with [`markkit`]. +/// +/// ``` +/// use fetchkit::documents::{Markkit, MarkkitConverter}; +/// use fetchkit::DocumentConversion; +/// +/// let markkit = Markkit::builder() +/// .max_sheet_rows(500) +/// .include_hidden_sheets(true) +/// .build(); +/// // Replaces the default `markkit` converter in place. +/// let conversion = +/// DocumentConversion::default().converter(MarkkitConverter::with_markkit(markkit)); +/// # let _ = conversion; +/// ``` +#[derive(Clone)] +pub struct MarkkitConverter { + markkit: Arc, +} + +impl MarkkitConverter { + /// Converter with markkit's default limits. + pub fn new() -> Self { + Self::with_markkit(Markkit::builder().build()) + } + + /// Converter using a configured [`Markkit`] instance (sheet row/column caps, + /// hidden sheets, archive limits, maximum input size). + pub fn with_markkit(markkit: Markkit) -> Self { + Self { + markkit: Arc::new(markkit), + } + } + + /// The markkit instance used for conversion. + pub fn markkit(&self) -> &Markkit { + &self.markkit + } +} + +impl Default for MarkkitConverter { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Debug for MarkkitConverter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MarkkitConverter") + .field("config", self.markkit.config()) + .finish() + } +} + +#[async_trait] +impl DocumentConverter for MarkkitConverter { + fn name(&self) -> &str { + "markkit" + } + + fn locality(&self) -> Locality { + Locality::Local + } + + fn supports(&self, format: DocumentFormat) -> bool { + matches!( + format, + DocumentFormat::Docx | DocumentFormat::Xlsx | DocumentFormat::Xls | DocumentFormat::Ods + ) + } + + fn max_input_bytes(&self) -> Option { + Some(self.markkit.config().max_input_size) + } + + async fn convert(&self, input: DocumentInput) -> Result { + if !self.supports(input.format) { + return Err(ConvertError::Unsupported(input.format.to_string())); + } + let markkit = Arc::clone(&self.markkit); + let media_type = input.format.media_type(); + let DocumentInput { + body, file_name, .. + } = input; + let extraction = run_document_task(move || { + markkit.extract_document_sync( + &body, + DocumentHint { + content_type: Some(media_type), + file_name: Some(&file_name), + }, + ) + }) + .await? + .map_err(map_error)?; + + let mut warnings: Vec = Vec::new(); + for warning in &extraction.warnings { + let code = warning + .split_once(':') + .map_or(warning.as_str(), |(code, _)| code) + .trim() + .to_string(); + if !code.is_empty() && !warnings.contains(&code) { + warnings.push(code); + } + } + Ok(ConvertedDocument { + markdown: extraction.markdown, + title: extraction.title, + warnings, + ocr_pages: Vec::new(), + extraction_method: Some(format!("markkit_{}", extraction.format)), + }) + } +} + +fn map_error(error: markkit::Error) -> ConvertError { + match error { + markkit::Error::UnsupportedFormat(message) => ConvertError::Unsupported(message), + markkit::Error::ResourceLimit(message) => { + ConvertError::Failed(format!("document exceeds markkit limits: {message}")) + } + other => ConvertError::Failed(other.to_string()), + } +} + +#[cfg(test)] +mod tests { + use std::io::Write; + + use bytes::Bytes; + use url::Url; + + use super::*; + + fn zip(parts: &[(&str, &str)]) -> Vec { + let mut buffer = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(&mut buffer); + for (name, body) in parts { + writer + .start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(body.as_bytes()).unwrap(); + } + writer.finish().unwrap(); + buffer.into_inner() + } + + const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + + fn docx() -> Vec { + let document = format!( + r#"PlanShip on Friday.OwnerTaskAnnRelease"# + ); + let core = r#"Release plan"#; + zip(&[ + ("word/document.xml", &document), + ("docProps/core.xml", core), + ]) + } + + fn xlsx(hidden_second_sheet: bool) -> Vec { + let state = if hidden_second_sheet { + r#" state="hidden""# + } else { + "" + }; + let workbook = format!( + r#""# + ); + let rels = r#""#; + let sheet = |rows: &str| { + format!( + r#"{rows}"# + ) + }; + let row = |r: u32, a: &str, b: &str| { + format!( + r#"{a}{b}"# + ) + }; + let sales = sheet(&format!( + "{}{}{}", + row(1, "Region", "Total"), + row(2, "North", "10"), + row(3, "South", "20") + )); + let secret = sheet(&row(1, "Key", "hunter2")); + let content_types = r#""#; + let root_rels = r#""#; + zip(&[ + ("[Content_Types].xml", content_types), + ("_rels/.rels", root_rels), + ("xl/workbook.xml", &workbook), + ("xl/_rels/workbook.xml.rels", rels), + ("xl/worksheets/sheet1.xml", &sales), + ("xl/worksheets/sheet2.xml", &secret), + ]) + } + + fn input(format: DocumentFormat, body: Vec) -> DocumentInput { + DocumentInput { + url: Url::parse("https://example.com/file").unwrap(), + format, + content_type: Some("application/octet-stream".to_string()), + file_name: format!("file.{}", format.extensions()[0]), + body: Bytes::from(body), + } + } + + #[test] + fn supports_markkit_office_formats_only() { + let converter = MarkkitConverter::new(); + for format in [ + DocumentFormat::Docx, + DocumentFormat::Xlsx, + DocumentFormat::Xls, + DocumentFormat::Ods, + ] { + assert!(converter.supports(format), "{format}"); + } + for format in [ + DocumentFormat::Pdf, + DocumentFormat::Pptx, + DocumentFormat::Xlsb, + DocumentFormat::Doc, + DocumentFormat::Odt, + ] { + assert!(!converter.supports(format), "{format}"); + } + assert_eq!(converter.name(), "markkit"); + assert_eq!(converter.locality(), Locality::Local); + } + + #[tokio::test] + async fn converts_docx_with_title_heading_and_table() { + let converted = MarkkitConverter::new() + .convert(input(DocumentFormat::Docx, docx())) + .await + .unwrap(); + assert_eq!(converted.extraction_method.as_deref(), Some("markkit_docx")); + assert_eq!(converted.title.as_deref(), Some("Release plan")); + assert!( + converted.markdown.contains("# Plan"), + "{}", + converted.markdown + ); + assert!(converted.markdown.contains("Ship on Friday.")); + assert!(converted.markdown.contains("| Ann | Release |")); + assert!(converted.warnings.is_empty()); + } + + #[tokio::test] + async fn converts_xlsx_and_reports_warning_codes() { + let converted = MarkkitConverter::new() + .convert(input(DocumentFormat::Xlsx, xlsx(true))) + .await + .unwrap(); + assert_eq!(converted.extraction_method.as_deref(), Some("markkit_xlsx")); + assert!( + converted.markdown.contains("Sales"), + "{}", + converted.markdown + ); + assert!(converted.markdown.contains("| North | 10 |")); + assert!(!converted.markdown.contains("hunter2")); + assert_eq!(converted.warnings, ["hidden_sheet_skipped"]); + } + + #[tokio::test] + async fn markkit_limits_are_configurable() { + let markkit = Markkit::builder() + .include_hidden_sheets(true) + .max_sheet_rows(1) + .build(); + let converted = MarkkitConverter::with_markkit(markkit) + .convert(input(DocumentFormat::Xlsx, xlsx(true))) + .await + .unwrap(); + assert!(converted.markdown.contains("hunter2")); + assert!(!converted.markdown.contains("South")); + assert!(converted.warnings.contains(&"truncated_sheet".to_string())); + + let tiny = MarkkitConverter::with_markkit(Markkit::builder().max_input_size(16).build()); + assert_eq!(tiny.max_input_bytes(), Some(16)); + } + + #[tokio::test] + async fn maps_markkit_errors() { + let error = MarkkitConverter::new() + .convert(input(DocumentFormat::Docx, b"PK\x03\x04broken".to_vec())) + .await + .unwrap_err(); + assert!(matches!(error, ConvertError::Failed(_)), "{error:?}"); + + let error = MarkkitConverter::new() + .convert(input(DocumentFormat::Pptx, docx())) + .await + .unwrap_err(); + assert!(matches!(error, ConvertError::Unsupported(_)), "{error:?}"); + + let strict = Markkit::builder().max_archive_entries(1).build(); + let error = MarkkitConverter::with_markkit(strict) + .convert(input(DocumentFormat::Docx, docx())) + .await + .unwrap_err(); + assert!( + matches!(&error, ConvertError::Failed(message) if message.contains("limits")), + "{error:?}" + ); + } + + #[test] + fn maps_unsupported_and_resource_limit_errors() { + assert!(matches!( + map_error(markkit::Error::UnsupportedFormat("xlsb".into())), + ConvertError::Unsupported(_) + )); + assert!(matches!( + map_error(markkit::Error::ResourceLimit("too big".into())), + ConvertError::Failed(message) if message.contains("too big") + )); + } +} diff --git a/crates/fetchkit/src/documents/mod.rs b/crates/fetchkit/src/documents/mod.rs index 0f8b891..ee336c8 100644 --- a/crates/fetchkit/src/documents/mod.rs +++ b/crates/fetchkit/src/documents/mod.rs @@ -23,6 +23,8 @@ mod cloudflare; #[cfg(feature = "docs-http")] mod http; +#[cfg(feature = "docs-markkit")] +mod markkit; #[cfg(feature = "docs-office")] mod office; #[cfg(feature = "docs-pdf")] @@ -46,11 +48,17 @@ use crate::content::{ }; use crate::{PageMetadata, PageQuality}; +#[cfg(feature = "docs-markkit")] +pub use self::markkit::MarkkitConverter; +/// markkit types for configuring [`MarkkitConverter`] limits. +#[cfg(feature = "docs-markkit")] +pub use ::markkit::{Markkit, MarkkitBuilder}; #[cfg(feature = "docs-cloudflare")] pub use cloudflare::CloudflareConverter; #[cfg(feature = "docs-http")] pub use http::HttpConverter; #[cfg(feature = "docs-office")] +#[allow(deprecated)] pub use office::OfficeConverter; #[cfg(feature = "docs-pdf")] pub use pdf_inspector::PdfInspectorConverter; @@ -266,12 +274,20 @@ fn media_type(content_type: &str) -> &str { // Document parsers are CPU-bound (pdf-inspector also uses Rayon internally); bound // concurrent local conversions so batch fetches cannot multiply parser work. -#[cfg(any(feature = "docs-pdf", feature = "docs-office"))] +#[cfg(any( + feature = "docs-pdf", + feature = "docs-markkit", + feature = "docs-office" +))] static DOCUMENT_PROCESSING_LIMIT: std::sync::LazyLock> = std::sync::LazyLock::new(|| Arc::new(tokio::sync::Semaphore::new(2))); /// Run CPU-bound conversion on the blocking pool under the shared document limit. -#[cfg(any(feature = "docs-pdf", feature = "docs-office"))] +#[cfg(any( + feature = "docs-pdf", + feature = "docs-markkit", + feature = "docs-office" +))] pub(crate) async fn run_document_task(task: F) -> Result where T: Send + 'static, @@ -280,7 +296,11 @@ where run_limited(Arc::clone(&DOCUMENT_PROCESSING_LIMIT), task).await } -#[cfg(any(feature = "docs-pdf", feature = "docs-office"))] +#[cfg(any( + feature = "docs-pdf", + feature = "docs-markkit", + feature = "docs-office" +))] async fn run_limited(limit: Arc, task: F) -> Result where T: Send + 'static, @@ -449,9 +469,12 @@ pub trait DocumentConverter: Send + Sync { /// Which formats are converted, by which backends, in what order. /// -/// The default contains the built-in local converters enabled by Cargo features -/// (`docs-pdf` adds `PdfInspectorConverter`, `docs-office` adds `OfficeConverter`) -/// and accepts every format they support. +/// The default contains the built-in local converters enabled by Cargo features, in +/// this order: `docs-pdf` adds `PdfInspectorConverter`, `docs-markkit` adds +/// `MarkkitConverter` (the default Office backend), and the deprecated +/// `docs-office` adds `OfficeConverter` last, so with both Office features enabled +/// markkit runs first and `office` is the fallback (and the only PPTX/XLSB backend). +/// It accepts every format they support. #[derive(Clone)] pub struct DocumentConversion { converters: Vec>, @@ -467,7 +490,10 @@ impl Default for DocumentConversion { let conversion = Self::empty(); #[cfg(feature = "docs-pdf")] let conversion = conversion.converter(PdfInspectorConverter::new()); + #[cfg(feature = "docs-markkit")] + let conversion = conversion.converter(MarkkitConverter::new()); #[cfg(feature = "docs-office")] + #[allow(deprecated)] let conversion = conversion.converter(OfficeConverter::new()); conversion } @@ -1025,7 +1051,11 @@ mod tests { assert!(error.to_string().contains("converter limit is 2")); } - #[cfg(any(feature = "docs-pdf", feature = "docs-office"))] + #[cfg(any( + feature = "docs-pdf", + feature = "docs-markkit", + feature = "docs-office" + ))] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cancelled_caller_does_not_release_running_task_permit() { use std::sync::mpsc; diff --git a/crates/fetchkit/src/documents/office.rs b/crates/fetchkit/src/documents/office.rs index 9cc4b8d..3431f92 100644 --- a/crates/fetchkit/src/documents/office.rs +++ b/crates/fetchkit/src/documents/office.rs @@ -16,6 +16,10 @@ //! without limit. Spreadsheet output is capped per sheet (rows and columns) and the //! caller caps the final Markdown at `max_body_size`. Parsing runs on a blocking //! task under the same two-document concurrency cap as PDFs. +//! - Deprecated in favour of `MarkkitConverter` (`docs-markkit`), the default Office +//! backend. Kept behind the non-default `docs-office` feature for one release; it +//! is the only built-in PPTX and XLSB converter. +#![allow(deprecated)] use std::borrow::Cow; use std::io::{Cursor, Read}; @@ -76,6 +80,11 @@ impl OfficeKind { /// Converts Word (DOCX), PowerPoint (PPTX) and spreadsheets (XLSX, XLSM, XLSB, /// XLS, ODS) to Markdown in-process. +/// +/// Deprecated: use `MarkkitConverter` (`docs-markkit` feature), the default Office +/// backend. This converter remains available behind the non-default `docs-office` +/// feature and is the only built-in PPTX and XLSB backend. +#[deprecated(note = "use MarkkitConverter (docs-markkit)")] #[derive(Debug, Default, Clone, Copy)] pub struct OfficeConverter; diff --git a/crates/fetchkit/src/documents/settings.rs b/crates/fetchkit/src/documents/settings.rs index d2a5e76..974f212 100644 --- a/crates/fetchkit/src/documents/settings.rs +++ b/crates/fetchkit/src/documents/settings.rs @@ -4,7 +4,10 @@ use super::{DocumentConversion, DocumentFormat}; /// Built-in converter names accepted by [`DocumentConversionSettings`]. -pub const BUILTIN_CONVERTERS: &[&str] = &["pdf-inspector", "office", "cloudflare"]; +/// +/// `office` is the deprecated in-crate Office converter (`docs-office` feature); +/// `markkit` is the default Office backend. +pub const BUILTIN_CONVERTERS: &[&str] = &["pdf-inspector", "markkit", "office", "cloudflare"]; /// Plain settings resolved into a [`DocumentConversion`]. /// @@ -13,7 +16,7 @@ pub const BUILTIN_CONVERTERS: &[&str] = &["pdf-inspector", "office", "cloudflare pub struct DocumentConversionSettings { /// Accepted formats (e.g. `["pdf", "docx"]`). `None` keeps every supported format. pub formats: Option>, - /// Converter order applied to every format (e.g. `["office", "cloudflare"]`). + /// Converter order applied to every format (e.g. `["markkit", "cloudflare"]`). /// `None` keeps the built-in local converters in their default order. pub converters: Option>, /// Allow converters that send documents to another service. @@ -90,11 +93,18 @@ impl DocumentConversionSettings { Err("converter `pdf-inspector` requires the docs-pdf feature".to_string()) } } + "markkit" => { + if cfg!(feature = "docs-markkit") { + Ok(conversion) + } else { + Err("converter `markkit` requires the docs-markkit feature".to_string()) + } + } "office" => { if cfg!(feature = "docs-office") { Ok(conversion) } else { - Err("converter `office` requires the docs-office feature".to_string()) + Err("converter `office` (deprecated; use `markkit`) requires the docs-office feature".to_string()) } } "cloudflare" => self.cloudflare(conversion), @@ -187,6 +197,51 @@ mod tests { assert!(conversion.chain_for(DocumentFormat::Pptx).is_empty()); } + #[cfg(feature = "docs-markkit")] + #[test] + fn markkit_converter_is_selectable_by_name() { + let settings = DocumentConversionSettings { + converters: Some(vec!["markkit".into()]), + ..Default::default() + }; + let conversion = settings.build().unwrap(); + let names = |format| { + conversion + .chain_for(format) + .iter() + .map(|converter| converter.name().to_string()) + .collect::>() + }; + assert_eq!(names(DocumentFormat::Docx), ["markkit"]); + assert_eq!(names(DocumentFormat::Xlsx), ["markkit"]); + assert!(names(DocumentFormat::Pdf).is_empty()); + } + + #[cfg(not(feature = "docs-markkit"))] + #[test] + fn markkit_without_feature_is_explained() { + let settings = DocumentConversionSettings { + converters: Some(vec!["markkit".into()]), + ..Default::default() + }; + assert!(settings + .build() + .unwrap_err() + .contains("requires the docs-markkit feature")); + } + + #[cfg(not(feature = "docs-office"))] + #[test] + fn office_without_feature_points_to_markkit() { + let settings = DocumentConversionSettings { + converters: Some(vec!["office".into()]), + ..Default::default() + }; + let error = settings.build().unwrap_err(); + assert!(error.contains("docs-office feature"), "{error}"); + assert!(error.contains("markkit"), "{error}"); + } + #[cfg(not(feature = "docs-cloudflare"))] #[test] fn cloudflare_without_feature_is_explained() { diff --git a/crates/fetchkit/tests/documents.rs b/crates/fetchkit/tests/documents.rs index 536808c..37a0f2a 100644 --- a/crates/fetchkit/tests/documents.rs +++ b/crates/fetchkit/tests/documents.rs @@ -275,3 +275,126 @@ async fn cloudflare_backend_converts_downloaded_docx() { .windows(4) .any(|window| window == b"PK\x03\x04")); } + +#[cfg(feature = "docs-markkit")] +mod markkit_default { + use std::io::Write; + + use super::*; + + fn zip(parts: &[(&str, &str)]) -> Vec { + let mut buffer = std::io::Cursor::new(Vec::new()); + let mut writer = zip::ZipWriter::new(&mut buffer); + for (name, body) in parts { + writer + .start_file(*name, zip::write::SimpleFileOptions::default()) + .unwrap(); + writer.write_all(body.as_bytes()).unwrap(); + } + writer.finish().unwrap(); + buffer.into_inner() + } + + fn docx() -> Vec { + zip(&[( + "word/document.xml", + r#"RoadmapMarkkit converts this."#, + )]) + } + + fn xlsx() -> Vec { + zip(&[ + ( + "_rels/.rels", + r#""#, + ), + ( + "xl/workbook.xml", + r#""#, + ), + ( + "xl/_rels/workbook.xml.rels", + r#""#, + ), + ( + "xl/worksheets/sheet1.xml", + r#"ItemUnitsWidgets42"#, + ), + ]) + } + + async fn serve(path_: &str, content_type: &str, body: Vec) -> MockServer { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(path_)) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", content_type) + .set_body_bytes(body), + ) + .mount(&server) + .await; + server + } + + #[tokio::test] + async fn default_tool_converts_docx_with_markkit() { + let server = serve("/files/roadmap.docx", DOCX, docx()).await; + let response = builder() + .build() + .execute( + FetchRequest::new(format!("{}/files/roadmap.docx", server.uri())).as_markdown(), + ) + .await + .unwrap(); + + assert!(response.error.is_none(), "{:?}", response.error); + let content = response.content.unwrap(); + assert!(content.contains("Roadmap"), "{content}"); + assert!(content.contains("Markkit converts this."), "{content}"); + let metadata = response.metadata.unwrap(); + assert_eq!(metadata.extraction_method.as_deref(), Some("markkit_docx")); + assert_eq!(metadata.title.as_deref(), Some("Roadmap")); + } + + #[tokio::test] + async fn default_tool_converts_generic_xlsx_download_with_markkit() { + let server = serve("/export/q3.xlsx", "application/octet-stream", xlsx()).await; + let response = builder() + .build() + .execute(FetchRequest::new(format!("{}/export/q3.xlsx", server.uri())).as_markdown()) + .await + .unwrap(); + + let content = response.content.unwrap(); + assert!(content.contains("| Widgets | 42 |"), "{content}"); + assert_eq!( + response.metadata.unwrap().extraction_method.as_deref(), + Some("markkit_xlsx") + ); + } + + #[tokio::test] + async fn markkit_limits_flow_through_tool_builder() { + use fetchkit::documents::{Markkit, MarkkitConverter}; + + let server = serve("/export/q3.xlsx", "application/octet-stream", xlsx()).await; + let tight = MarkkitConverter::with_markkit(Markkit::builder().max_sheet_cols(1).build()); + let response = builder() + .document_conversion(DocumentConversion::default().converter(tight)) + .build() + .execute(FetchRequest::new(format!("{}/export/q3.xlsx", server.uri())).as_markdown()) + .await + .unwrap(); + + let content = response.content.unwrap(); + assert!(content.contains("Widgets"), "{content}"); + assert!(!content.contains("42"), "{content}"); + let quality = response.quality.unwrap(); + assert!( + quality.warnings.iter().any(|w| w.starts_with("truncated")), + "{:?}", + quality.warnings + ); + } +} diff --git a/crates/fetchkit/tests/integration.rs b/crates/fetchkit/tests/integration.rs index 80d9edb..7e727e1 100644 --- a/crates/fetchkit/tests/integration.rs +++ b/crates/fetchkit/tests/integration.rs @@ -35,6 +35,7 @@ fn test_tool_with_save() -> Tool { .build() } +#[cfg(feature = "docs-pdf")] fn text_pdf(text: &str) -> Vec { let escaped = text .replace('\\', "\\\\") @@ -436,6 +437,7 @@ async fn test_binary_content() { ); } +#[cfg(feature = "docs-pdf")] #[tokio::test] async fn test_pdf_content_processor_returns_markdown() { let mock_server = MockServer::start().await; @@ -465,7 +467,7 @@ async fn test_pdf_content_processor_returns_markdown() { assert!(resp.error.is_none()); } -#[cfg(feature = "docs-office")] +#[cfg(feature = "docs-markkit")] fn docx_with_paragraph(text: &str) -> Vec { use std::io::Write; @@ -485,7 +487,7 @@ fn docx_with_paragraph(text: &str) -> Vec { buffer.into_inner() } -#[cfg(feature = "docs-office")] +#[cfg(feature = "docs-markkit")] #[tokio::test] async fn test_docx_content_processor_returns_markdown() { let mock_server = MockServer::start().await; @@ -509,11 +511,11 @@ async fn test_docx_content_processor_returns_markdown() { assert_eq!(resp.format.as_deref(), Some("markdown")); assert_eq!( resp.content.as_deref(), - Some("## Minutes\n\nShip the release on Friday.") + Some("## Minutes\n\nShip the release on Friday.\n") ); assert_eq!( resp.metadata.unwrap().extraction_method.as_deref(), - Some("office_docx") + Some("markkit_docx") ); assert!(resp.error.is_none()); } From 4f237d7f5a812dfba579720ae57c79d5fc572059 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:53:15 +0000 Subject: [PATCH 2/2] docs: document markkit Office backend and office deprecation Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01Y2vd61x8kbPtmDxze9Q4Gi --- CHANGELOG.md | 16 +++++++++++++++- README.md | 16 ++++++++++++---- docs/hardening.md | 7 ++++--- knowledge/foundations/fetchers.md | 25 ++++++++++++++++++++++--- knowledge/foundations/tool-contract.md | 16 +++++++++++----- knowledge/log.md | 1 + knowledge/security/threat-model.md | 16 +++++++++++++--- 7 files changed, 78 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7cf56..7eff1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Built-in converters behind Cargo features: `pdf-inspector` for text-based PDFs (`docs-pdf`, default) and `office` for Word (DOCX), PowerPoint (PPTX) and spreadsheets (XLSX, XLSM, XLSB, XLS, ODS) with zip-bomb limits (`docs-office`, - default), plus opt-in remote `CloudflareConverter` (Workers AI `toMarkdown`, + now deprecated and off by default; see Deprecated), plus opt-in remote `CloudflareConverter` (Workers AI `toMarkdown`, `docs-cloudflare`) and generic `HttpConverter` (`docs-http`). Remote converters run only when explicitly allowed and never under `ToolBuilder::hardened()`. +- Add `MarkkitConverter` (`markkit`, `docs-markkit` feature, default): local + DOCX, XLSX/XLSM, XLS and ODS conversion through the + [markkit](https://crates.io/crates/markkit) crate, with configurable limits via + `MarkkitConverter::with_markkit` (`fetchkit::documents::Markkit`). It is the + default Office backend; `extraction_method` is `markkit_docx`, `markkit_xlsx`, + `markkit_xls` or `markkit_ods`. - `ToolBuilder::content_processor` adds custom processors ahead of the built-ins, and `FetchOptions` carries `document_conversion` and `content_processors`. - Discover agent-facing resources from HTTP and HTML declarations plus bounded @@ -36,6 +42,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 server cards, A2A agent cards, and Agent Skills indexes. Markdown responses include a compact navigation appendix. +### Deprecated + +- `OfficeConverter` (`office`, `docs-office` feature) is deprecated in favour of + `MarkkitConverter` and no longer a default feature or part of `docs-all`. It + still works when `docs-office` is enabled explicitly and remains the only + built-in PPTX and XLSB converter for this release; PPTX and XLSB responses are + rejected as unconverted binaries under default features. + ### Changed - Markdown requests now ask for `text/markdown` first in the `Accept` header, so diff --git a/README.md b/README.md index afa018a..a81bc4e 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,19 @@ returns nothing usable (for example a scanned PDF that needs OCR). | Converter | Cargo feature | Default | Where it runs | Formats | | --- | --- | --- | --- | --- | | `PdfInspectorConverter` (`pdf-inspector`) | `docs-pdf` | on | local | PDF (text layer; reports OCR-only pages) | -| `OfficeConverter` (`office`) | `docs-office` | on | local | DOCX, PPTX, XLSX, XLSM, XLSB, XLS, ODS | +| `MarkkitConverter` (`markkit`) | `docs-markkit` | on | local | DOCX, XLSX, XLSM, XLS, ODS (via [markkit](https://crates.io/crates/markkit)) | +| `OfficeConverter` (`office`), deprecated | `docs-office` | off | local | DOCX, PPTX, XLSX, XLSM, XLSB, XLS, ODS | | `CloudflareConverter` (`cloudflare`) | `docs-cloudflare` | off | remote | PDF, DOCX, XLSX, XLSB, XLS, ODT, ODS | | `HttpConverter` (any name) | `docs-http` | off | remote (configurable) | any format your service accepts | -`docs-all` enables every backend. Remote converters upload document bytes to a +`markkit` is the default Office backend. The in-crate `office` converter is +deprecated and kept for one release behind the non-default `docs-office` feature; +it is still the only built-in PPTX and XLSB converter. With both features enabled, +`markkit` runs first and `office` is the fallback. `docs-all` enables every +backend except the deprecated `docs-office`. Tune markkit limits (rows and +columns per sheet, hidden sheets, archive budgets) by registering +`MarkkitConverter::with_markkit(Markkit::builder()...build())`, which replaces the +default `markkit` converter in place. Remote converters upload document bytes to a third party, so they only run when remote conversion is explicitly allowed, and never under `ToolBuilder::hardened()`. @@ -79,7 +87,7 @@ use fetchkit::{DocumentConversion, DocumentFormat, ToolBuilder}; let tool = ToolBuilder::new() .document_conversion( - DocumentConversion::default() // pdf-inspector + office + DocumentConversion::default() // pdf-inspector + markkit .converter(CloudflareConverter::new(account_id, api_token)) .allow_remote(true) // opt in to uploads .chain(DocumentFormat::Pdf, ["pdf-inspector", "cloudflare"]) @@ -93,7 +101,7 @@ Extension points: implement `DocumentConverter` for a new backend, `FormatDetector` to change format detection, `ConverterClient` to route remote uploads through your own egress, or `ContentProcessor` to bypass the chain. -CLI and MCP server flags: `--convert pdf,docx`, `--converter office,cloudflare`, +CLI and MCP server flags: `--convert pdf,docx`, `--converter markkit,cloudflare`, `--allow-remote-conversion`, `--max-document-bytes`. The Cloudflare converter reads `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` (build with `--features docs-cloudflare`). Python: `FetchkitTool(document_formats=..., diff --git a/docs/hardening.md b/docs/hardening.md index 350602a..ba346db 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -99,9 +99,10 @@ If you must fetch nonstandard ports or internal-looking public domains, start fr ## Document conversion PDF and Office documents are converted by a chain of document converters. The -default converters (`pdf-inspector`, `office`) run in-process and make no network -requests. Remote converters (`CloudflareConverter`, `HttpConverter`) upload the -downloaded document to another service: +default converters (`pdf-inspector`, `markkit`) run in-process and make no network +requests; markkit is built without its HTTP client. Remote converters +(`CloudflareConverter`, `HttpConverter`) upload the downloaded document to +another service: - They run only when `DocumentConversion::allow_remote(true)` (CLI: `--allow-remote-conversion`) is set, and never under `ToolBuilder::hardened()`. diff --git a/knowledge/foundations/fetchers.md b/knowledge/foundations/fetchers.md index 596cc05..286c94e 100644 --- a/knowledge/foundations/fetchers.md +++ b/knowledge/foundations/fetchers.md @@ -116,14 +116,32 @@ Built-in converters (Cargo features): | Converter | Feature | Default | Locality | Formats | | --- | --- | --- | --- | --- | | `pdf-inspector` (`PdfInspectorConverter`) | `docs-pdf` | on | local | PDF | -| `office` (`OfficeConverter`) | `docs-office` | on | local | DOCX, PPTX, XLSX/XLSM, XLSB, XLS, ODS | +| `markkit` (`MarkkitConverter`) | `docs-markkit` | on | local | DOCX, XLSX/XLSM, XLS, ODS | +| `office` (`OfficeConverter`, deprecated) | `docs-office` | off | local | DOCX, PPTX, XLSX/XLSM, XLSB, XLS, ODS | | `cloudflare` (`CloudflareConverter`) | `docs-cloudflare` | off | remote | PDF, DOCX, XLSX, XLSB, XLS, ODT, ODS | | any name (`HttpConverter`) | `docs-http` | off | remote unless set local | configured | - `pdf-inspector`: classifies and extracts Markdown from bounded in-memory bytes; no OCR. OCR-required pages produce `pdf_requires_ocr` and `suggested_next_action: "use_ocr"` unless a later converter returns usable text. -- `office`: DOCX and PPTX XML parts via `quick-xml`, spreadsheets via `calamine`. Zip +- Default registration order: `pdf-inspector`, `markkit`, then `office` when the + deprecated `docs-office` feature is enabled, so markkit converts Office formats + first and `office` is the fallback and the only PPTX/XLSB backend. `docs-all` + excludes `docs-office`. +- `markkit`: the [markkit](https://crates.io/crates/markkit) crate (features `docx`, + `spreadsheet`; no `http_client`, so no reqwest of its own) parses the bounded bytes + with `extract_document_sync` on the shared blocking path. The confirmed format's + canonical media type and the sanitised file name are the hint. `extraction_method` + is `markkit_` (`markkit_docx`, `markkit_xlsx`, `markkit_xls`, + `markkit_ods`). markkit warnings (`code: detail`) surface as their stable code only + (`truncated_sheet`, `hidden_sheet_skipped`, `sheet_error`), since details carry + document-controlled text. `UnsupportedFormat` maps to `Unsupported` (chain falls + through), `ResourceLimit` and parse errors to `Failed`. `max_input_bytes` is + markkit's `max_input_size`. Limits (archive entries and sizes, rows/columns per + sheet, cell grid, hidden sheets) are markkit's defaults, tunable with + `MarkkitConverter::with_markkit` (`fetchkit::documents::{Markkit, MarkkitBuilder}`). + Spreadsheet titles are not reported (markkit returns none). +- `office` (deprecated, "not necessarily correct"; kept one release): DOCX and PPTX XML parts via `quick-xml`, spreadsheets via `calamine`. Zip containers are checked against an entry-count and total uncompressed-size budget before any part is inflated, and each part is read through a size cap (TM-DOS-008). Output caps: 1,000 slides, 1,000 rows and 50 columns per sheet. @@ -446,7 +464,8 @@ crates/fetchkit/src/ │ ├── mod.rs # formats, detection, chain, DocumentConversion │ ├── settings.rs # string settings for CLI, MCP, Python │ ├── pdf_inspector.rs # local PDF converter (docs-pdf) -│ ├── office.rs # local DOCX/PPTX/spreadsheet converter (docs-office) +│ ├── markkit.rs # local DOCX/spreadsheet converter via markkit (docs-markkit) +│ ├── office.rs # deprecated DOCX/PPTX/spreadsheet converter (docs-office) │ ├── remote.rs # ConverterClient, multipart, SecretString │ ├── cloudflare.rs # Cloudflare toMarkdown converter (docs-cloudflare) │ └── http.rs # generic HTTP converter (docs-http) diff --git a/knowledge/foundations/tool-contract.md b/knowledge/foundations/tool-contract.md index 23e2331..fa141bc 100644 --- a/knowledge/foundations/tool-contract.md +++ b/knowledge/foundations/tool-contract.md @@ -332,12 +332,18 @@ By default, Fetchkit blocks connections to private/reserved IP ranges: generic binary types, and confirms it by magic bytes after download. - Downloads up to `max_document_bytes` (25 MB default); output honors `max_body_size`. Partial inputs are never converted. - - Default converters (`docs-pdf`, `docs-office`): `pdf-inspector` for text PDFs + - Default converters (`docs-pdf`, `docs-markkit`): `pdf-inspector` for text PDFs (`pdf_inspector`; OCR-only pages produce `pdf_requires_ocr` and `use_ocr`) and - `office` for DOCX/PPTX/XLSX/XLSM/XLSB/XLS/ODS (`office_docx`, `office_pptx`, - `office_spreadsheet`; Word headings, lists and tables; one `## Slide N: title` - per slide; one `## sheet` table per sheet capped at 1,000 rows and 50 columns - with `sheet_truncated`). Titles come from document properties. + `markkit` for DOCX/XLSX/XLSM/XLS/ODS (`markkit_docx`, `markkit_xlsx`, + `markkit_xls`, `markkit_ods`; Word headings, lists and tables with the title + from core properties or the Title style; one table per visible sheet capped at + 10,000 rows and 256 columns by default, warnings `truncated_sheet`, + `hidden_sheet_skipped`, `sheet_error`). PPTX and XLSB are not converted by + default. + - Deprecated `office` converter (`docs-office`, off by default; fallback after + `markkit` when enabled): DOCX/PPTX/XLSX/XLSM/XLSB/XLS/ODS (`office_docx`, + `office_pptx`, `office_spreadsheet`; one `## Slide N: title` per slide; sheets + capped at 1,000 rows and 50 columns with `sheet_truncated`). - Remote converters (`cloudflare` -> `cloudflare_tomarkdown`, `HttpConverter`) run only when remote conversion is allowed and never under `hardened()`. - Converter fallthrough adds `fallback_converter`; quality score is 1.0 clean, diff --git a/knowledge/log.md b/knowledge/log.md index cc23772..5c5cb57 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-12 +* **Office backend**: `markkit` (`MarkkitConverter`, `docs-markkit`, default) is now the Office converter for DOCX/XLSX/XLS/ODS; the in-crate `office` converter is deprecated, off by default, excluded from `docs-all`, and remains the only PPTX/XLSB backend when enabled. TM-DOS-008 now describes markkit's limits. * **Document conversion**: Replaced the PDF and Office processors with one `DocumentProcessor` running a configurable, per-format `DocumentConverter` chain; added feature-gated converters (`docs-pdf`, `docs-office`, `docs-cloudflare`, `docs-http`), a separate 25 MB document cap, remote-converter opt-in, and threats TM-LEAK-006/007. * **Office documents**: Added the local Office processor (DOCX, PPTX, XLSX, XLS, ODS) to the fetchers and tool contract, its zip-bomb threat TM-DOS-008, and the Markdown-first `Accept` header. diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index ca47704..78c77e6 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -298,7 +298,7 @@ to protect against changes between validation and write. | TM-DOS-005 | DNS resolution delay | Low | DNS resolution uses system resolver; no explicit timeout on DNS lookup | **ACCEPTED** | | TM-DOS-006 | Memory exhaustion from large HTML conversion | Medium | Conversion input bounded by `max_body_size` (10 MB default) | MITIGATED | | TM-DOS-007 | CPU/memory exhaustion from adversarial PDF parsing | Medium | Bounded input/output, blocking-task isolation, and a cancellation-safe two-document concurrency cap; parser-internal expansion remains possible | **ACCEPTED** | -| TM-DOS-008 | Zip bomb or adversarial Office document | Medium | Central-directory entry and uncompressed-size budget before inflating, size-capped part reads, per-sheet output caps, shared two-document blocking cap | MITIGATED | +| TM-DOS-008 | Zip bomb or adversarial Office document | Medium | markkit archive entry/size budgets and cell-grid pre-scan before inflating (deprecated `office`: own budget and capped part reads), per-sheet output caps, shared two-document blocking cap | MITIGATED | ### Mitigation Details @@ -332,8 +332,18 @@ cancellation boundary once a blocking parse begins. **TM-DOS-008 — Zip bomb or adversarial Office document (MITIGATED):** Office documents are zip archives (except legacy XLS), so a body within `max_document_bytes` -can declare gigabytes of content. Before any part is inflated, the Office processor -(`office` converter) rejects archives with more than 10,000 entries or more than 200 MB of declared +can declare gigabytes of content. The default `markkit` converter relies on markkit's +limits: at most 10,000 zip entries, 64 MiB per decompressed entry and 256 MiB total +(enforced while inflating, not only from declared sizes), XLS/ODS cell-grid pre-scan +capped at 4,000,000 cells, and 10,000 rows by 256 columns rendered per sheet. Hidden +sheets are skipped by default. markkit is built without its HTTP client and receives +only the bounded bytes. Operators can tighten these via `MarkkitConverter::with_markkit`. +Parsing runs on the shared blocking path under the two-document concurrency cap, and the +final Markdown is capped at `max_body_size`. A markkit parser panic is contained by the +blocking task (`JoinError` becomes a converter failure) unless the embedding binary uses +`panic = "abort"`. + +The deprecated `office` converter (`docs-office`) rejects archives with more than 10,000 entries or more than 200 MB of declared uncompressed content, and every XML part it reads itself is read through a 64 MB cap, since declared sizes can lie. Spreadsheet output is capped at 1,000 rows and 50 columns per sheet before the `max_body_size` output cap. Parsing shares the local converters'