diff --git a/CHANGELOG.md b/CHANGELOG.md
index 620b9cc..3afb309 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -79,6 +79,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
### Fixed
+- Spreadsheets: a sheet with a single row now renders that row as data under Excel column letters
+ (`| A | B |`) instead of as a header with no rows.
+- Spreadsheets: `Extraction.title` is filled from document properties (XLSX `docProps/core.xml`,
+ ODS `meta.xml`, XLS `SummaryInformation`).
+- `Format::from_content_type` detects XLSB (`application/vnd.ms-excel.sheet.binary.macroEnabled.12`)
+ as the new `Format::Xlsb` instead of `Xlsx`; `.xlsb` files and `xl/workbook.bin` containers too.
+- XLSX packages missing `_rels/.rels` no longer fail inside calamine.
+- Release builds use `panic = "unwind"`, so a parser panic becomes an error instead of aborting the
+ CLI or the host Python/Node process.
- Indented HTML (most real web pages) now keeps blank lines between blocks; previously
headings, paragraphs, lists and tables after the first ran together with a leading space
(`# A\n B` instead of `# A\n\nB`), which also broke headings and tables as markdown.
diff --git a/Cargo.toml b/Cargo.toml
index 62dc15f..ebd7d17 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -77,7 +77,10 @@ proptest = "1"
[profile.release]
lto = "thin"
codegen-units = 1
-panic = "abort"
+# Unwind (not abort): `extract_document` wraps third-party parsers in
+# `catch_unwind`, and PyO3/NAPI turn panics into exceptions. Abort would let one
+# malformed document kill the CLI or the host Python/Node process.
+panic = "unwind"
strip = true
# Browser bundle (crates/markkit-wasm/scripts/build.sh): size over speed.
diff --git a/crates/markkit/src/extract.rs b/crates/markkit/src/extract.rs
index f12a7a7..3a89da4 100644
--- a/crates/markkit/src/extract.rs
+++ b/crates/markkit/src/extract.rs
@@ -9,10 +9,12 @@ use crate::ir::Document;
#[derive(Debug, Clone, PartialEq, Serialize)]
#[non_exhaustive]
pub struct Extraction {
- /// Rendered Markdown.
+ /// Rendered Markdown. Non-empty output always ends with exactly one `\n`
+ /// (POSIX text convention, safe to concatenate); empty documents render `""`.
pub markdown: String,
- /// Document title when known (HTML `og:title` / JSON-LD / `
`;
- /// DOCX core properties or `Title` style).
+ /// Document title when known: HTML `og:title` / JSON-LD / ``; DOCX
+ /// core properties or `Title` style; XLSX `docProps/core.xml`, ODS
+ /// `meta.xml`, XLS `SummaryInformation`.
pub title: Option,
/// Format the document was parsed as.
pub format: Format,
@@ -118,7 +120,8 @@ impl ParsedDocument {
}
/// Run a parser, converting a panic into an error (defence in depth for
-/// third-party parsers; only effective when built with `panic = "unwind"`).
+/// third-party parsers; only effective when built with `panic = "unwind"`, which
+/// markkit's own release profile uses).
#[cfg(any(feature = "docx", feature = "spreadsheet"))]
fn guarded(
what: &str,
@@ -266,7 +269,9 @@ impl Markkit {
Format::Html => self.parse_html_doc(bytes),
Format::Pdf => self.parse_pdf(bytes),
Format::Docx => self.parse_docx(bytes),
- Format::Xlsx | Format::Xls | Format::Ods => self.parse_spreadsheet(bytes),
+ Format::Xlsx | Format::Xlsb | Format::Xls | Format::Ods => {
+ self.parse_spreadsheet(bytes)
+ }
Format::Doc => Err(Error::UnsupportedFormat(
"legacy Word 97-2003 (.doc) documents are not supported".into(),
)),
@@ -328,6 +333,7 @@ impl Markkit {
let mut doc = out.doc;
self.config.transforms.apply(&mut doc)?;
let mut p = ParsedDocument::new(doc, out.format);
+ p.title = out.title;
p.warnings = out.warnings;
p.stats.sheets = Some(out.sheets);
p.stats.rows = Some(out.rows);
diff --git a/crates/markkit/src/format.rs b/crates/markkit/src/format.rs
index cf632a7..509604c 100644
--- a/crates/markkit/src/format.rs
+++ b/crates/markkit/src/format.rs
@@ -22,6 +22,8 @@ pub enum Format {
Doc,
/// Office Open XML spreadsheet (`.xlsx`, `.xlsm`, `.xltx`).
Xlsx,
+ /// Excel binary workbook (`.xlsb`).
+ Xlsb,
/// Legacy Excel 97-2003 binary (`.xls`).
Xls,
/// OpenDocument spreadsheet (`.ods`).
@@ -76,6 +78,7 @@ impl Format {
Format::Docx => "docx",
Format::Doc => "doc",
Format::Xlsx => "xlsx",
+ Format::Xlsb => "xlsb",
Format::Xls => "xls",
Format::Ods => "ods",
Format::Pptx => "pptx",
@@ -98,6 +101,8 @@ impl Format {
Format::Docx
} else if ct.contains("msword") {
Format::Doc
+ } else if ct.contains("ms-excel.sheet.binary") {
+ Format::Xlsb
} else if ct.contains("spreadsheetml") || ct.contains("ms-excel.sheet") {
Format::Xlsx
} else if ct.contains("ms-excel") {
@@ -125,6 +130,7 @@ impl Format {
"docx" | "docm" | "dotx" | "dotm" => Format::Docx,
"doc" | "dot" => Format::Doc,
"xlsx" | "xlsm" | "xltx" | "xltm" => Format::Xlsx,
+ "xlsb" => Format::Xlsb,
"xls" | "xlt" => Format::Xls,
"ods" => Format::Ods,
"pptx" | "pptm" | "ppt" => Format::Pptx,
@@ -198,6 +204,8 @@ fn sniff_zip(bytes: &[u8]) -> Format {
Format::Ods
} else if contains(bytes, b"word/document") {
Format::Docx
+ } else if contains(bytes, b"xl/workbook.bin") {
+ Format::Xlsb
} else if contains(bytes, b"xl/workbook.") {
Format::Xlsx
} else if contains(bytes, b"ppt/presentation.") {
@@ -245,6 +253,18 @@ mod tests {
);
assert_eq!(Format::from_content_type("application/pdf"), Format::Pdf);
assert_eq!(Format::from_content_type("text/plain"), Format::PlainText);
+ assert_eq!(
+ Format::from_content_type("application/vnd.ms-excel.sheet.binary.macroEnabled.12"),
+ Format::Xlsb
+ );
+ assert_eq!(
+ Format::from_content_type("application/vnd.ms-excel.sheet.macroEnabled.12"),
+ Format::Xlsx
+ );
+ assert_eq!(
+ Format::from_content_type("application/vnd.ms-excel"),
+ Format::Xls
+ );
}
#[test]
@@ -252,6 +272,7 @@ mod tests {
assert_eq!(Format::from_extension("html"), Format::Html);
assert_eq!(Format::from_extension("PDF"), Format::Pdf);
assert_eq!(Format::from_extension("docx"), Format::Docx);
+ assert_eq!(Format::from_extension("XLSB"), Format::Xlsb);
}
#[test]
diff --git a/crates/markkit/src/lib.rs b/crates/markkit/src/lib.rs
index 7f9788b..66f11a1 100644
--- a/crates/markkit/src/lib.rs
+++ b/crates/markkit/src/lib.rs
@@ -62,7 +62,8 @@ mod spreadsheet;
#[cfg(feature = "tool")]
pub mod tool;
pub mod transform;
-#[cfg(feature = "docx")]
+#[cfg(any(feature = "docx", feature = "spreadsheet"))]
+#[cfg_attr(not(feature = "docx"), allow(dead_code))] // spreadsheet uses only `find`/`text`
mod xml;
pub use config::MarkkitBuilder;
diff --git a/crates/markkit/src/spreadsheet.rs b/crates/markkit/src/spreadsheet.rs
index 5b1f806..1df6fbe 100644
--- a/crates/markkit/src/spreadsheet.rs
+++ b/crates/markkit/src/spreadsheet.rs
@@ -34,6 +34,7 @@ use crate::render::escape_table_cell;
pub(crate) struct SheetOutput {
pub doc: Document,
pub format: Format,
+ pub title: Option,
pub warnings: Vec,
pub sheets: usize,
pub rows: usize,
@@ -63,7 +64,8 @@ enum Container {
}
pub(crate) fn parse(bytes: &[u8], config: &MarkkitBuilder) -> Result {
- let container = if bytes.starts_with(b"PK") {
+ let mut patched = None;
+ let (container, title) = if bytes.starts_with(b"PK") {
let mut zip = ZipGuard::open(bytes, config).map_err(archive_err)?;
let has =
|zip: &ZipGuard<'_>, name: &str| zip.names().any(|n| n.eq_ignore_ascii_case(name));
@@ -79,20 +81,29 @@ pub(crate) fn parse(bytes: &[u8], config: &MarkkitBuilder) -> Result Result out.warn(format!("sheet_error: sheet '{name}': {e}")),
}
}
- out.finish(Format::Xlsx)
+ out.finish(Format::Xlsx, title)
}
Container::Ods => {
let mut wb: Ods<_> = Ods::new(Cursor::new(bytes)).map_err(err)?;
let sel = out.select(wb.sheets_metadata());
read_ranges(&mut wb, sel, &mut out, config);
- out.finish(Format::Ods)
+ out.finish(Format::Ods, title)
}
Container::Xls => {
let mut wb: Xls<_> = Xls::new(Cursor::new(bytes)).map_err(err)?;
let sel = out.select(wb.sheets_metadata());
read_ranges(&mut wb, sel, &mut out, config);
- out.finish(Format::Xls)
+ out.finish(Format::Xls, title)
}
}
}
@@ -323,11 +334,20 @@ impl Grid {
}
row
});
- let header = rows.next()?;
+ let first = rows.next()?;
+ let rest: Vec> = rows.collect();
+ // GFM tables need a header row. A sheet with a single row is data, not a
+ // header: label its columns A, B, … so the row renders as a data row.
+ let (header, rows) = if rest.is_empty() {
+ let letters = (min_col..min_col + width as u32).map(col_name).collect();
+ (letters, vec![first])
+ } else {
+ (first, rest)
+ };
Some(SheetTable {
range,
header,
- rows: rows.collect(),
+ rows,
truncated_rows: self.truncated_rows,
truncated_cols: self.truncated_cols,
})
@@ -427,10 +447,11 @@ impl<'c> Collector<'c> {
});
}
- fn finish(self, format: Format) -> Result {
+ fn finish(self, format: Format, title: Option) -> Result {
Ok(SheetOutput {
doc: Document::new(self.nodes),
format,
+ title,
warnings: self.warnings,
sheets: self.sheets,
rows: self.rows,
@@ -438,6 +459,122 @@ impl<'c> Collector<'c> {
}
}
+// ─── Package fixes and document title ───────────────────────────────
+
+/// Longest title kept (characters); longer titles are cut.
+const MAX_TITLE_CHARS: usize = 1024;
+
+/// calamine locates the workbook through `_rels/.rels` and fails without it.
+/// Rebuild the (already validated) archive with the standard relationship to
+/// `xl/workbook.xml`; entries are copied raw, without recompression.
+fn with_package_rels(zip: &mut ZipGuard<'_>) -> Result, Error> {
+ const RELS: &str = r#""#;
+ let zerr = |e: zip::result::ZipError| err(format!("cannot rebuild XLSX package: {e}"));
+ let mut w = zip::ZipWriter::new(Cursor::new(Vec::new()));
+ for i in 0..zip.archive.len() {
+ let entry = zip.archive.by_index_raw(i).map_err(zerr)?;
+ w.raw_copy_file(entry).map_err(zerr)?;
+ }
+ let opts =
+ zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
+ w.start_file("_rels/.rels", opts).map_err(zerr)?;
+ std::io::Write::write_all(&mut w, RELS.as_bytes())
+ .map_err(|e| err(format!("cannot rebuild XLSX package: {e}")))?;
+ Ok(w.finish().map_err(zerr)?.into_inner())
+}
+
+fn clean_title(raw: &str) -> Option {
+ let t: String = raw
+ .split_whitespace()
+ .collect::>()
+ .join(" ")
+ .chars()
+ .take(MAX_TITLE_CHARS)
+ .collect();
+ (!t.is_empty()).then_some(t)
+}
+
+/// `dc:title` from OOXML `docProps/core.xml` or ODF `meta.xml`. Best effort:
+/// a missing or malformed part yields `None`; only archive limits are errors.
+fn read_dc_title(zip: &mut ZipGuard<'_>, part: &str) -> Result