rollpie get rust/folio@0.1.0
A Rust library that reads and writes docx, xlsx and pptx. It has no dependencies.
All three formats work. They share one base of ZIP, XML and the package, and the xlsx, docx and pptx layers sit on top of it.
The older formats (.xls, .doc, .ppt) can be read too.
Book::open, Document::open and Presentation::open look at the first bytes of the file to tell the formats apart.
The caller does not need to care which format a file is in.
1| A=Name B=Number C=Date
2| A=first B=3.5 C=2026-03-31 00:00:00
3| A=second B=-1 C=2026-09-08 13:45:30
The Japanese documentation is in README.ja.md.
Both were measured on the same machine with an xlsx of 1 million rows by 5 columns. Whether writing or reading, folio's memory peaks at 17MB. It never holds the whole sheet in memory, and passes the rows through one at a time, in order.
write read peak memory
folio 4.4 s 1.0 s 17MB
openpyxl 26.5 s 27.3 s 114MB (read_only)
openpyxl 32.7 s 1790MB (normal load)
Reading is 27 times faster and writing 6 times faster. A normal load needs 1.8GB, and folio does the same with 17MB. The finished file was 24MB from folio and 26MB from openpyxl.
To read, open a sheet and take its rows one by one, in order.
use folio::book::Book;
use folio::cell::Value;
use std::path::Path;
let mut book = Book::open(Path::new("data.xlsx"))?;
for one in book.sheet() {
println!("{}", one.name);
}
let mut sheet = book.read("Sheet1")?;
while let Some(row) = sheet.read()? {
for cell in &row.cell {
println!("column {} {:?}", cell.column, cell.value);
}
}
To create a workbook, pass all the sheet names first.
[Content_Types].xml goes at the start of the file, so the set of sheets has to be decided before writing begins.
use folio::book::Writer;
let mut writer = Writer::create(Path::new("out.xlsx"), &["Data"])?;
writer.start("Data")?;
writer.row(&["Name".into(), 3.5.into(), true.into()])?;
writer.finish()?;
To skip row numbers or columns, use place. Formats are given here as well.
use folio::cell::Cell;
use folio::style::Format;
writer.place(10, &[
Cell::new(1, "sparse".into()),
Cell::styled(3, serial.into(), Format::Date.index()),
])?;
Writer::edit copies everything except the sheets being rewritten, still compressed.
Nothing is decompressed or compressed again, so it is fast, and parts folio does not know (charts, pivot tables, images, print settings) are kept as they are.
let source = Book::open(Path::new("data.xlsx"))?;
let mut writer = Writer::edit(source, Path::new("out.xlsx"), &["Summary"])?;
writer.start("Summary")?;
writer.row(&["rewritten".into(), 999.into()])?;
writer.finish()?;
A rewritten sheet also keeps everything outside <sheetData>.
Column widths, merged cells, conditional formatting and references to drawings are not lost.
To keep them, the original sheet is read through once, which takes time on a large sheet.
Value has five variants.
Empty - nothing. A cell that has only a format is also EmptyNumber(f64) - a number. Dates are stored here tooText(String) - textBoolean(bool) - true or falseError(String) - an error such as #N/AAn xlsx cell has no date type. A date is stored as a number, and it is read as a date when the cell's number format is a date format.
let style = sheet.style();
let epoch1904 = sheet.epoch1904();
while let Some(row) = sheet.read()? {
for cell in &row.cell {
if let Value::Number(number) = &cell.value {
if style.is_date(cell.style) {
let when = folio::date::moment(*number, epoch1904);
println!("{}-{:02}-{:02}", when.year, when.month, when.day);
}
}
}
}
For reading from other languages, examples/json.rs writes each row as one line of JSON (JSON Lines).
Given a sheet name, it writes that sheet's rows. Without one, it writes the list of sheets.
cargo run --release --example json -- data.xlsx
cargo run --release --example json -- data.xlsx Sheet1
Each line has this shape. Empty cells are left out.
{"number":2,"cell":[{"column":1,"letter":"A","value":"first"},{"column":2,"letter":"B","value":3.5}]}
Values go in value with their JSON types: text is a string, a number is a number, and true or false is a boolean.
A number whose format is a date stays a number in value, and date is added with the date and time in the form 2026-03-31T00:00:00.
An error has error in place of value. A cell with a formula also gets formula.
From Rust, row and sheet in folio::json build strings of the same shape.
The body is split into paragraphs and tables, which come in order.
When only the text is needed, text returns all of it at once.
use folio::document::{Block, Document};
let mut document = Document::open(Path::new("file.docx"))?;
println!("{}", document.text()?);
let mut body = document.read()?;
while let Some(block) = body.read()? {
match block {
Block::Paragraph(one) => println!("{} {}", one.style, one.text()),
Block::Table(one) => println!("table of {} rows", one.row.len()),
}
}
A paragraph is a sequence of runs (Run), each a stretch of text with one format.
When only part of a sentence is bold, that part has a different format, so the sentence is split into several runs.
A Run holds bold, italic, underline, strikethrough, size, color and typeface.
A line break goes into the text as \n, and a tab as \t.
To create a document, write paragraphs and tables in order. The paragraph styles Normal, Title and Heading1 to Heading3 are provided.
use folio::document::Writer;
use folio::paragraph::{Paragraph, Run};
use folio::table::{Row, Table};
let mut writer = Writer::create(Path::new("out.docx"))?;
writer.paragraph(&Paragraph::styled("Heading", "Heading1"))?;
writer.paragraph(&Paragraph::new("This is the body."))?;
writer.table(&Table::new(vec![Row::new(&["a", "b"])]))?;
writer.finish()?;
Writer::edit rewrites only the body and copies the other parts still compressed.
The page size, margins and references to headers and footers, which come after the body, are kept too.
let source = Document::open(Path::new("file.docx"))?;
let mut writer = Writer::edit(source, Path::new("out.docx"))?;
writer.paragraph(&Paragraph::new("Rewritten."))?;
writer.finish()?;
Measured with a docx of 20,000 paragraphs:
write read
folio 0.04 s 0.01 s
python-docx 2.5 s 0.39 s
A slide is a sequence of shapes. Text sits inside shapes, and cannot be placed outside one.
Positions and sizes are in EMU, and INCH is one inch.
use folio::presentation::Presentation;
let mut file = Presentation::open(Path::new("deck.pptx"))?;
for index in 0..file.count() {
let one = file.read(index)?;
for shape in &one.shape {
println!("{} {}", shape.name, shape.body());
}
}
To create a presentation, pass the number of slides first.
use folio::presentation::Writer;
use folio::slide::{INCH, Shape, Slide};
let mut writer = Writer::create(Path::new("out.pptx"), 1)?;
let title = Shape::text("Title", INCH, INCH, INCH * 11, INCH, &["folio"]);
writer.slide(0, &Slide::new(vec![title]))?;
writer.finish()?;
Writer::edit replaces only the slides being rewritten.
What lies outside the shapes, such as the background, transitions and placeholder formats, is kept.
Slides that are not rewritten, and the other parts, are copied still compressed.
let source = Presentation::open(Path::new("deck.pptx"))?;
let mut writer = Writer::edit(source, Path::new("out.pptx"), &[1])?;
writer.slide(0, &Slide::new(vec![shape]))?;
writer.finish()?;
.xls, .doc and .ppt are not ZIP files. Each keeps its own binary data in a different container called a compound file, and the data is not XML either. These formats can only be read. To write, create the file again in the newer format.
use folio::book::Book;
// decided by the first bytes, not by the extension
let mut book = Book::open(Path::new("old.xls"))?;
let mut sheet = book.read("Sheet1")?;
while let Some(row) = sheet.read()? {
// the same code as for xlsx
}
Excel 97 and later (BIFF8) and Word 97 and later can be read. Earlier formats store text in a locale-specific encoding that cannot be converted back, so they are refused with a clear error. Files protected with a password are refused as well.
Some things are not returned from the older formats. From .xls, the text of formulas is not returned, although the results saved with the file are. From .doc, character formatting such as bold is not returned, nor the grouping of table cells into rows and columns. Each cell comes out as a separate paragraph. From .ppt, the position and size of shapes are not returned.
The base is shared by all three formats. From the top, it is layered as package, XML, ZIP and compression.
book - workbooks: open, create, editsheet - reads a sheet row by row, and writes rowscell, shared, style, date - cell values, shared strings, number formats, date numbersjson - writes rows and the sheet list as JSONdocument - documents: open, create, editparagraph, table - paragraphs and runs, docx tablespresentation - presentations: open, create, editslide - one slide: its shapes and the paragraphs inside thempackage - OPC: parts, relationships, and carrying over the parts that are not touchedxml - a reader that returns one event at a time, and escaping for writingzip - reading and writing ZIP, with ZIP64 and copying entries without decompressing theminflate, deflate, crc - deflate decompression and compressioncfb - the container of the older formats (compound file)xls, doc, ppt - the contents of the older formatsDecompression runs on a separate thread. It works at the same time as the reading side, so there is less waiting. The compressed bytes are not read all at once either. They are passed from the file a little at a time.
cargo test
cargo run --release --example show -- data.xlsx
cargo run --release --example show -- data.xlsx Sheet1
cargo run --release --example show -- file.docx
cargo run --release --example show -- deck.pptx
cargo run --release --example show -- old.xls Sheet1
cargo run --release --example json -- data.xlsx Sheet1
cargo run --release --example scale -- 1000000
Dual licensed under MIT and Apache-2.0. Take whichever you prefer.