Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
874818b
feat(sheet): read xlsx and xlsm in-house with number format support
tomsideguide Aug 19, 2026
0972fc0
feat(sheet): read ole .xls in-house instead of calamine
tomsideguide Aug 19, 2026
cc0f4ba
feat(sheet): read xlsb in-house, narrowing calamine to legacy xls
tomsideguide Aug 19, 2026
d466d45
chore(sheet): drop calamine now every workbook container is read in-h…
tomsideguide Aug 19, 2026
290537f
fix(sheet): match am/pm format tokens over chars, not bytes
tomsideguide Aug 19, 2026
6369125
fix(sheet): cap the grid a sparse sheet extent can materialize
tomsideguide Aug 19, 2026
4c09b4e
refactor(sheet): share the rk decoder and error table between the bin…
tomsideguide Aug 19, 2026
7ddc5c4
test(fuzz): reach the binary readers and the format engine with wrapp…
tomsideguide Aug 19, 2026
5c933fc
fix(sheet): keep the literals decorating a general format, and drop a…
tomsideguide Aug 19, 2026
41ab351
fix(sheet): resolve a workbook part that is not at its conventional path
tomsideguide Aug 19, 2026
6fbed6c
fix(xls): warn when the workbook stream ends mid-record
tomsideguide Aug 19, 2026
2ab6526
test(fuzz): let xlsb mutation reach the cell, style and string readers
tomsideguide Aug 19, 2026
667b262
docs(limits): trim the grid slot cap comment to its statement
tomsideguide Aug 19, 2026
fd5f3cb
docs(sheet): trim the reader header to what it does
tomsideguide Aug 19, 2026
bcc40d4
fix(sheet): keep the fictitious 1900 leap day distinct from the day b…
tomsideguide Aug 19, 2026
a22d2b0
fix(sheet): pick the workbook reader from the part's bytes and propag…
tomsideguide Aug 19, 2026
13d2db8
test(fuzz): write the second worksheet part the xlsb package declares
tomsideguide Aug 19, 2026
67422e3
fix(sheet): read a byte order mark as xml and test the leap day befor…
tomsideguide Aug 20, 2026
110cfde
fix(sheet): fall back to general on an empty format code, and honour …
tomsideguide Aug 20, 2026
baa517e
test(fuzz): seed the am/pm token path and keep whitespace in format c…
tomsideguide Aug 20, 2026
f3c6cd8
fix(sheet): resolve the main part before conventional paths and requi…
tomsideguide Aug 20, 2026
9729235
fix(sheet): render only the date and time parts a format names
tomsideguide Aug 20, 2026
3ab3314
fix(sheet): budget grid materialization across the workbook, not per …
tomsideguide Aug 20, 2026
63375b8
fix(sheet): require a workbook from the conventional binary part too
tomsideguide Aug 20, 2026
f02155c
fix(sheet): record an elapsed bracket as a time run
tomsideguide Aug 20, 2026
afad634
fix(sheet): find the closest fraction by continued fractions, and dro…
tomsideguide Aug 20, 2026
4fd9384
fix(sheet): open the package once and classify the main part from its…
tomsideguide Aug 20, 2026
1a611b8
fix(sheet): keep the decimal point when no placeholder follows it
tomsideguide Aug 20, 2026
1da773b
fix(sheet): keep the clock a combined format names on a sub-day serial
tomsideguide Aug 20, 2026
30f5167
fix(sheet): saturate the fraction bound past nineteen denominator places
tomsideguide Aug 20, 2026
7c4e4a5
fix(sheet): walk the continued fraction of the float's exact ratio
tomsideguide Aug 20, 2026
5137971
fix(sheet): an elapsed format is a duration, never a date
tomsideguide Aug 20, 2026
3a44210
refactor(sheet): borrow a general section's decorations instead of cl…
tomsideguide Aug 20, 2026
3e66203
test(fuzz): make the xlsb seed's second sheet visible so its part is …
tomsideguide Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ __pycache__/
# wasm-pack build output: pkg/ is the npm package, www/pkg/ the demo site copy
/wasm/pkg/
/wasm/www/pkg/
# cargo-fuzz working dirs: seeds live in fuzz/seeds/ and are checked in
/fuzz/corpus/
/fuzz/artifacts/
/fuzz/coverage/
58 changes: 0 additions & 58 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ exclude = ["fuzz"]
name = "anydoc"
version = "0.1.9"
edition = "2024"
# Edition 2024 needs 1.85; zip and calamine both raise it to 1.88.
# Edition 2024 needs 1.85; zip raises it to 1.88.
rust-version = "1.88"
description = "Convert documents (doc, docx, odt, rtf, epub, pdf, presentations, spreadsheets, csv) to GitHub-Flavored Markdown"
license = "MIT"
Expand All @@ -22,7 +22,6 @@ insta = "1"
sha2 = "0.11"

[dependencies]
calamine = { version = "0.36.1", features = ["dates"] }
cfb = "0.14.0"
csv = "1.4.0"
flate2 = "1"
Expand Down
20 changes: 20 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
anydoc = { path = ".." }
cfb = "0.14.0"
zip = { version = "8.6.0", default-features = false, features = ["deflate"] }

[[bin]]
name = "docx"
Expand Down Expand Up @@ -64,3 +66,21 @@ name = "pdf"
path = "fuzz_targets/pdf.rs"
test = false
doc = false

[[bin]]
name = "xls"
path = "fuzz_targets/xls.rs"
test = false
doc = false

[[bin]]
name = "xlsb"
path = "fuzz_targets/xlsb.rs"
test = false
doc = false

[[bin]]
name = "numfmt"
path = "fuzz_targets/numfmt.rs"
test = false
doc = false
12 changes: 12 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Fuzzing

cargo +nightly fuzz run xlsx fuzz/corpus/xlsx fuzz/seeds/xlsx

`fuzz/seeds/` holds checked-in starting inputs; `fuzz/corpus/` is the working
directory libfuzzer writes to and is not checked in.

`xls`, `xlsb` and `numfmt` wrap their input in a valid container (an OLE
compound file, an OPC package, a styles part) so mutation reaches the record
and format-code parsers rather than dying at the container gate. `xlsx` takes
a whole workbook, and its seeds cover all three containers because the
frontend picks the reader from the bytes.
54 changes: 54 additions & 0 deletions fuzz/fuzz_targets/numfmt.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#![no_main]

use libfuzzer_sys::fuzz_target;
use std::io::{Cursor, Write};

// A number format code, carried into the engine on a minimal workbook. The
// grammar is where the parsing complexity lives, and reaching it through a
// discovered styles part would cost the fuzzer most of its budget. Only the
// control characters XML forbids are dropped, so whitespace a code may carry
// still reaches the parser.
fuzz_target!(|data: &[u8]| {
let code = String::from_utf8_lossy(data);
let escaped: String = code
.chars()
.filter(|c| !c.is_control() || matches!(c, '\t' | '\n' | '\r'))
.map(|c| match c {
'&' => "&".to_string(),
'<' => "&lt;".to_string(),
'>' => "&gt;".to_string(),
'"' => "&quot;".to_string(),
c => c.to_string(),
})
.collect();
let styles = format!(
r#"<?xml version="1.0"?><styleSheet xmlns="{SML}"><numFmts count="1"><numFmt numFmtId="164" formatCode="{escaped}"/></numFmts><cellXfs count="2"><xf numFmtId="0"/><xf numFmtId="164" applyNumberFormat="1"/></cellXfs></styleSheet>"#
);

let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
let parts: [(&str, &str); 6] = [
("[Content_Types].xml", CONTENT_TYPES),
("_rels/.rels", RELS),
("xl/workbook.xml", WORKBOOK),
("xl/_rels/workbook.xml.rels", WORKBOOK_RELS),
("xl/styles.xml", &styles),
("xl/worksheets/sheet1.xml", SHEET),
];
for (name, body) in parts {
if zip.start_file(name, opts).is_err() || zip.write_all(body.as_bytes()).is_err() {
return;
}
}
let Ok(bytes) = zip.finish() else {
return;
};
let _ = anydoc::to_markdown_bytes(bytes.into_inner().as_slice(), anydoc::Format::Excel);
});

const SML: &str = "http://schemas.openxmlformats.org/spreadsheetml/2006/main";
const CONTENT_TYPES: &str = r#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/></Types>"#;
const RELS: &str = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#;
const WORKBOOK: &str = r#"<?xml version="1.0"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets></workbook>"#;
const WORKBOOK_RELS: &str = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
const SHEET: &str = r#"<?xml version="1.0"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="A1" s="1"><v>1234.5</v></c><c r="B1" s="1"><v>0.075</v></c><c r="C1" s="1" t="inlineStr"><is><t>text</t></is></c></row></sheetData></worksheet>"#;
22 changes: 22 additions & 0 deletions fuzz/fuzz_targets/xls.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#![no_main]

use libfuzzer_sys::fuzz_target;
use std::io::{Cursor, Write};

// The BIFF record stream, wrapped in a valid OLE container so mutation
// reaches the reader instead of dying at the container gate.
fuzz_target!(|data: &[u8]| {
let Ok(mut ole) = cfb::CompoundFile::create(Cursor::new(Vec::new())) else {
return;
};
match ole.create_stream("Workbook") {
Ok(mut stream) => {
if stream.write_all(data).is_err() {
return;
}
}
Err(_) => return,
}
let bytes = ole.into_inner().into_inner();
let _ = anydoc::to_markdown_bytes(&bytes, anydoc::Format::Excel);
});
40 changes: 40 additions & 0 deletions fuzz/fuzz_targets/xlsb.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#![no_main]

use libfuzzer_sys::fuzz_target;
use std::io::{Cursor, Write};

// The worksheet, styles and shared-string parts, wrapped in a package whose
// workbook part and relationships are already valid. Fuzzing the workbook
// part instead would strand the input there: without a resolvable worksheet
// relationship every sheet is skipped, and the cell, format and string
// readers never run. The workbook part is parsed by the same record reader
// this does reach.
fuzz_target!(|data: &[u8]| {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default();
let parts: [(&str, &[u8]); 8] = [
("[Content_Types].xml", CONTENT_TYPES.as_bytes()),
("_rels/.rels", RELS.as_bytes()),
("xl/workbook.bin", WORKBOOK),
("xl/_rels/workbook.bin.rels", WORKBOOK_RELS.as_bytes()),
("xl/worksheets/sheet1.bin", data),
("xl/worksheets/sheet2.bin", data),
Comment thread
tomsideguide marked this conversation as resolved.
("xl/styles.bin", data),
("xl/sharedStrings.bin", data),
];
for (name, body) in parts {
if zip.start_file(name, opts).is_err() || zip.write_all(body).is_err() {
return;
}
}
let Ok(bytes) = zip.finish() else {
return;
};
let _ = anydoc::to_markdown_bytes(bytes.into_inner().as_slice(), anydoc::Format::Excel);
});

const WORKBOOK: &[u8] = include_bytes!("../seeds/xlsb/workbook-bin");

const CONTENT_TYPES: &str = r#"<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="bin" ContentType="application/vnd.ms-excel.sheet.binary.macroEnabled.main"/></Types>"#;
const RELS: &str = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.bin"/></Relationships>"#;
const WORKBOOK_RELS: &str = r#"<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.bin"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.bin"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.bin"/><Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.bin"/></Relationships>"#;
Comment thread
tomsideguide marked this conversation as resolved.
1 change: 1 addition & 0 deletions fuzz/seeds/numfmt/ampm-multibyte
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
aaaaé
Comment thread
tomsideguide marked this conversation as resolved.
1 change: 1 addition & 0 deletions fuzz/seeds/numfmt/ampm-token-multibyte
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
h:mm AM/PMé
1 change: 1 addition & 0 deletions fuzz/seeds/numfmt/conditions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[>100]0.0;[<=100]0.00
1 change: 1 addition & 0 deletions fuzz/seeds/numfmt/currency
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"$"#,##0.00
1 change: 1 addition & 0 deletions fuzz/seeds/numfmt/datetime
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
yyyy-mm-dd hh:mm:ss AM/PM
1 change: 1 addition & 0 deletions fuzz/seeds/numfmt/elapsed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[h]:mm:ss
1 change: 1 addition & 0 deletions fuzz/seeds/numfmt/four-sections
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#,##0.00;[Red](#,##0.00);"nil";@
1 change: 1 addition & 0 deletions fuzz/seeds/numfmt/fraction
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# ??/??
1 change: 1 addition & 0 deletions fuzz/seeds/numfmt/percent
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.0%
1 change: 1 addition & 0 deletions fuzz/seeds/numfmt/scientific
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
0.00E+00
Binary file added fuzz/seeds/xls/workbook-stream
Binary file not shown.
Binary file added fuzz/seeds/xlsb/sheet1-bin
Binary file not shown.
Binary file added fuzz/seeds/xlsb/styles-bin
Binary file not shown.
Binary file added fuzz/seeds/xlsb/workbook-bin
Binary file not shown.
Binary file added fuzz/seeds/xlsx/merged.xlsx
Binary file not shown.
Binary file added fuzz/seeds/xlsx/sheet.xls
Binary file not shown.
Binary file added fuzz/seeds/xlsx/sheet.xlsb
Binary file not shown.
Binary file added fuzz/seeds/xlsx/sheet.xlsx
Binary file not shown.
Loading