mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 08:51:30 -06:00
Read cell formatting from .xls files
.xls (Excel 97-2003) import used only each cell's number format. It now also reads the BIFF8 formatting records (Font, XF, Palette and Style), so opened sheets keep their fonts, solid and pattern fills, borders (including diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden protection, the workbook's custom colour palette, its Normal font as the default, and its user-defined cell styles. The decoding lives in a new xls/styles.rs; xls/mod.rs gains the record arms and the build call. Short or malformed formatting records keep the fields present and add one warning instead of refusing the workbook. Tests build the records in code, including truncation and byte mutation. Also merges origin/main, keeping its dialog-sheet, WINDOW2 and XF number-format handling; the XF arm now goes through the new styles module, which covers the number format too.
This commit is contained in:
@@ -25,6 +25,7 @@ mod shared_media;
|
||||
mod sheet_names;
|
||||
mod smooth_roundtrip;
|
||||
mod trendline_roundtrip;
|
||||
mod xls_style_tests;
|
||||
mod xls_tests;
|
||||
mod xlsb_tests;
|
||||
mod zip_order;
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Legacy `.xls` (BIFF8) cell formatting: Font, XF, Palette and Style records. Every workbook is
|
||||
//! assembled here from the [MS-XLS] structures; no Excel-produced file is used.
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::style::Protection;
|
||||
use gridcraft_model::{BorderLine, BorderStyle, Color, Fill, HAlign, PatternType, Style, StyleId, Underline, VAlign, VertAlign, Workbook};
|
||||
|
||||
use super::xls_tests::compound;
|
||||
use crate::tables::INDEXED;
|
||||
use crate::{read_xls, read_xlsx, write_xlsx};
|
||||
|
||||
fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = id.to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
fn u16s(v: &[u16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
fn bof(dt: u16) -> Vec<u8> {
|
||||
let mut p = u16s(&[0x0600, dt, 0x0DBB, 0x07CC]);
|
||||
p.extend_from_slice(&[0; 8]);
|
||||
rec(0x0809, &p)
|
||||
}
|
||||
|
||||
fn eof() -> Vec<u8> {
|
||||
rec(0x000A, &[])
|
||||
}
|
||||
|
||||
/// Font: height in twips, flags, colour, weight, script, underline, then a UTF-16 name.
|
||||
fn font(twips: u16, flags: u16, icv: u16, weight: u16, script: u16, underline: u8, name: &str) -> Vec<u8> {
|
||||
let mut p = u16s(&[twips, flags, icv, weight, script]);
|
||||
p.extend_from_slice(&[underline, 0, 0, 0]);
|
||||
p.push(name.encode_utf16().count() as u8);
|
||||
p.push(1);
|
||||
p.extend(u16s(&name.encode_utf16().collect::<Vec<_>>()));
|
||||
rec(0x0031, &p)
|
||||
}
|
||||
|
||||
/// The CellXF fields an XF record carries after ifnt, ifmt and the protection flags.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct XfData {
|
||||
alc: u8,
|
||||
wrap: bool,
|
||||
alc_v: u8,
|
||||
trot: u8,
|
||||
indent: u8,
|
||||
shrink: bool,
|
||||
/// dgLeft, dgRight, dgTop, dgBottom, dgDiag.
|
||||
dg: [u32; 5],
|
||||
/// icvLeft, icvRight, icvTop, icvBottom, icvDiag.
|
||||
icv: [u32; 5],
|
||||
diag: u32,
|
||||
fls: u32,
|
||||
fore: u16,
|
||||
back: u16,
|
||||
}
|
||||
|
||||
fn xf(ifnt: u16, ifmt: u16, flags: u16, d: XfData) -> Vec<u8> {
|
||||
let mut p = u16s(&[ifnt, ifmt, flags]);
|
||||
p.push(d.alc | (u8::from(d.wrap) << 3) | (d.alc_v << 4));
|
||||
p.push(d.trot);
|
||||
p.push(d.indent | (u8::from(d.shrink) << 4));
|
||||
p.push(0xFC); // fAtr* bits: no bearing on the values read
|
||||
let b1 = d.dg[0] | (d.dg[1] << 4) | (d.dg[2] << 8) | (d.dg[3] << 12) | (d.icv[0] << 16) | (d.icv[1] << 23) | (d.diag << 30);
|
||||
let b2 = d.icv[2] | (d.icv[3] << 7) | (d.icv[4] << 14) | (d.dg[4] << 21) | (d.fls << 26);
|
||||
p.extend_from_slice(&b1.to_le_bytes());
|
||||
p.extend_from_slice(&b2.to_le_bytes());
|
||||
p.extend_from_slice(&(d.fore | (d.back << 7)).to_le_bytes());
|
||||
rec(0x00E0, &p)
|
||||
}
|
||||
|
||||
/// What Excel writes for unformatted cells: bottom aligned, automatic fill colours.
|
||||
fn plain() -> XfData {
|
||||
XfData { alc_v: 2, fore: 0x40, back: 0x41, ..XfData::default() }
|
||||
}
|
||||
|
||||
/// Locked, a cell XF inheriting from style XF 0.
|
||||
const CELL: u16 = 0x0001;
|
||||
/// Locked, a style XF with no parent.
|
||||
const STYLE: u16 = 0xFFF5;
|
||||
|
||||
fn number(r: u16, c: u16, ixfe: u16, n: f64) -> Vec<u8> {
|
||||
let mut p = u16s(&[r, c, ixfe]);
|
||||
p.extend_from_slice(&n.to_le_bytes());
|
||||
rec(0x0203, &p)
|
||||
}
|
||||
|
||||
fn blank(r: u16, c: u16, ixfe: u16) -> Vec<u8> {
|
||||
rec(0x0201, &u16s(&[r, c, ixfe]))
|
||||
}
|
||||
|
||||
/// A one-sheet workbook: `formatting` records in the globals, `cells` in the sheet.
|
||||
fn workbook(formatting: &[Vec<u8>], cells: &[Vec<u8>]) -> Vec<u8> {
|
||||
let boundsheet = |pos: u32| {
|
||||
let mut p = pos.to_le_bytes().to_vec();
|
||||
p.extend_from_slice(&[0, 0, 6, 0]);
|
||||
p.extend_from_slice(b"Sheet1");
|
||||
rec(0x0085, &p)
|
||||
};
|
||||
let globals = |pos: u32| [vec![bof(0x0005)], formatting.to_vec(), vec![boundsheet(pos), eof()]].concat().concat();
|
||||
let sheet = [vec![bof(0x0010)], cells.to_vec(), vec![eof()]].concat().concat();
|
||||
let len = globals(0).len() as u32;
|
||||
[globals(len), sheet].concat()
|
||||
}
|
||||
|
||||
fn style_at<'a>(wb: &'a Workbook, a1: &str) -> &'a Style {
|
||||
wb.styles.get(wb.sheets[0].style_id(CellRef::parse(a1).unwrap()))
|
||||
}
|
||||
|
||||
/// Four default fonts (Arial 10 and its bold/italic variants), as Excel writes them.
|
||||
fn default_fonts() -> Vec<Vec<u8>> {
|
||||
vec![
|
||||
font(200, 0, 0x7FFF, 400, 0, 0, "Arial"),
|
||||
font(200, 0, 0x7FFF, 700, 0, 0, "Arial"),
|
||||
font(200, 2, 0x7FFF, 400, 0, 0, "Arial"),
|
||||
font(200, 2, 0x7FFF, 700, 0, 0, "Arial"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Fonts, a palette, style XFs, cell XFs and named styles covering every decoded field.
|
||||
fn formatted() -> Vec<u8> {
|
||||
let mut f = default_fonts();
|
||||
// ifnt 5 (index 4 is never written): 14 pt italic, struck, bold, double underline,
|
||||
// superscript, red (colour 10).
|
||||
f.push(font(280, 0x000A, 10, 700, 1, 2, "Courier New"));
|
||||
// ifnt 6: single accounting underline, subscript, a custom palette colour.
|
||||
f.push(font(220, 0, 9, 400, 2, 0x21, "Times New Roman"));
|
||||
// Palette: the first two of the 56 custom entries (colour indexes 8 and 9) replaced.
|
||||
let mut pal = u16s(&[56]);
|
||||
for (i, c) in INDEXED.iter().enumerate().skip(8) {
|
||||
let c = match i {
|
||||
8 => 0x123456,
|
||||
9 => 0xABCDEF,
|
||||
_ => *c,
|
||||
};
|
||||
pal.extend_from_slice(&[(c >> 16) as u8, (c >> 8) as u8, c as u8, 0]);
|
||||
}
|
||||
f.push(rec(0x0092, &pal));
|
||||
f.push(rec(0x041E, &[&164u16.to_le_bytes()[..], &[4u8, 0, 0][..], &b"0.0%"[..]].concat()));
|
||||
// XF 0: the Normal style.
|
||||
f.push(xf(0, 0, STYLE, plain()));
|
||||
// XF 1: everything at once.
|
||||
f.push(xf(
|
||||
5,
|
||||
164,
|
||||
0x0002, // hidden, not locked
|
||||
XfData {
|
||||
alc: 2,
|
||||
wrap: true,
|
||||
alc_v: 1,
|
||||
trot: 135,
|
||||
indent: 3,
|
||||
shrink: true,
|
||||
dg: [1, 0, 7, 2, 3],
|
||||
icv: [9, 0, 8, 0x40, 10],
|
||||
diag: 3,
|
||||
fls: 1,
|
||||
fore: 8,
|
||||
back: 0x41,
|
||||
},
|
||||
));
|
||||
// XF 2: a 12.5% gray pattern on yellow, font index 4 (invalid: the default font).
|
||||
f.push(xf(4, 0, CELL, XfData { alc: 7, alc_v: 4, trot: 255, fls: 0x11, fore: 10, back: 13, ..plain() }));
|
||||
// XF 3: a font index past the end, right aligned, diagonal-up only.
|
||||
f.push(xf(200, 0, CELL, XfData { alc: 3, alc_v: 0, trot: 90, dg: [0, 13, 0, 0, 5], icv: [0, 12, 0, 0, 16], diag: 2, ..plain() }));
|
||||
// XF 4: an ordinary cell: the default style.
|
||||
f.push(xf(0, 0, CELL, plain()));
|
||||
// XF 5: font ifnt 6.
|
||||
f.push(xf(6, 0, CELL, plain()));
|
||||
// Styles: built-in Normal, a user style on XF 1 and a duplicate name (ignored), a built-in
|
||||
// style (Comma) left to GridCraft's own definition.
|
||||
f.push(rec(0x0293, &[&u16s(&[0x8000])[..], &[0u8, 0xFF][..]].concat()));
|
||||
f.push(rec(0x0293, &[&u16s(&[1, 5])[..], &[0u8][..], &b"Fancy"[..]].concat()));
|
||||
f.push(rec(0x0293, &[&u16s(&[2, 5])[..], &[0u8][..], &b"FANCY"[..]].concat()));
|
||||
f.push(rec(0x0293, &[&u16s(&[0x8002])[..], &[3u8, 0xFF][..]].concat()));
|
||||
workbook(
|
||||
&f,
|
||||
&[
|
||||
number(0, 0, 1, 0.5),
|
||||
blank(0, 1, 1),
|
||||
number(1, 0, 2, 2.0),
|
||||
number(2, 0, 3, 3.0),
|
||||
number(3, 0, 4, 4.0),
|
||||
number(4, 0, 999, 5.0),
|
||||
number(5, 0, 5, 6.0),
|
||||
blank(6, 0, 4),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_fonts_fills_borders_alignment_and_protection() {
|
||||
let (wb, report) = read_xls(&compound("Workbook", &formatted())).unwrap();
|
||||
assert!(report.warnings[0].contains("cell formatting"), "{:?}", report.warnings);
|
||||
assert!(!report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings);
|
||||
// The Normal style's font is the workbook default.
|
||||
let normal = wb.styles.get(StyleId::DEFAULT);
|
||||
assert_eq!((normal.font.name.as_str(), normal.font.size, normal.font.bold), ("Arial", 10.0, false));
|
||||
assert_eq!(normal.font.color, Color::Auto);
|
||||
|
||||
let s = style_at(&wb, "A1");
|
||||
let f = &s.font;
|
||||
assert_eq!((f.name.as_str(), f.size), ("Courier New", 14.0));
|
||||
assert!(f.bold && f.italic && f.strike);
|
||||
assert_eq!((f.underline, f.vert, f.color), (Underline::Double, VertAlign::Superscript, Color::Rgb(0xFF0000)));
|
||||
assert_eq!(s.num_fmt.as_str(), "0.0%");
|
||||
assert_eq!(s.protection, Protection { locked: false, hidden: true });
|
||||
assert_eq!((s.align.h, s.align.v, s.align.wrap, s.align.shrink), (HAlign::Center, VAlign::Center, true, true));
|
||||
assert_eq!((s.align.rotation, s.align.indent), (-45, 3));
|
||||
// Colour index 8 and 9 come from the Palette record.
|
||||
assert_eq!(s.fill, Fill::solid(Color::Rgb(0x123456)));
|
||||
assert_eq!(s.borders.left, BorderLine { style: BorderStyle::Thin, color: Color::Rgb(0xABCDEF) });
|
||||
assert_eq!(s.borders.right, BorderLine::default());
|
||||
assert_eq!(s.borders.top, BorderLine { style: BorderStyle::Hair, color: Color::Rgb(0x123456) });
|
||||
assert_eq!(s.borders.bottom, BorderLine { style: BorderStyle::Medium, color: Color::Auto });
|
||||
let dashed = BorderLine { style: BorderStyle::Dashed, color: Color::Rgb(0xFF0000) };
|
||||
assert_eq!((s.borders.diag_down, s.borders.diag_up), (dashed, dashed));
|
||||
// A formatted blank cell keeps its formatting.
|
||||
assert_eq!(wb.sheets[0].style_id(CellRef::parse("B1").unwrap()), wb.sheets[0].style_id(CellRef::parse("A1").unwrap()));
|
||||
assert_eq!(wb.sheets[0].value(CellRef::parse("B1").unwrap()), Value::Empty);
|
||||
|
||||
let s = style_at(&wb, "A2");
|
||||
assert_eq!(s.font, normal.font, "font index 4 falls back to the default font");
|
||||
assert_eq!(s.fill, Fill { pattern: PatternType::Gray125, fg: Color::Rgb(0xFF0000), bg: Color::Rgb(0xFFFF00) });
|
||||
assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Distributed, VAlign::Distributed, 255));
|
||||
|
||||
let s = style_at(&wb, "A3");
|
||||
assert_eq!(s.font, normal.font, "a font index past the end falls back to the default font");
|
||||
assert_eq!((s.align.h, s.align.v, s.align.rotation), (HAlign::Right, VAlign::Top, 90));
|
||||
assert_eq!(s.borders.right, BorderLine { style: BorderStyle::SlantDashDot, color: Color::Rgb(0x0000FF) });
|
||||
assert_eq!(s.borders.diag_up, BorderLine { style: BorderStyle::Thick, color: Color::Rgb(0x800000) });
|
||||
assert_eq!(s.borders.diag_down, BorderLine::default());
|
||||
|
||||
// An ordinary cell XF is the default style; so is an XF index past the end.
|
||||
assert_eq!(wb.sheets[0].style_id(CellRef::parse("A4").unwrap()), StyleId::DEFAULT);
|
||||
assert_eq!(wb.sheets[0].style_id(CellRef::parse("A5").unwrap()), StyleId::DEFAULT);
|
||||
// A blank cell in the default style isn't stored.
|
||||
assert!(wb.sheets[0].cell(CellRef::parse("A7").unwrap()).is_none());
|
||||
|
||||
let s = style_at(&wb, "A6");
|
||||
assert_eq!((s.font.name.as_str(), s.font.size, s.font.color), ("Times New Roman", 11.0, Color::Rgb(0xABCDEF)));
|
||||
assert_eq!((s.font.underline, s.font.vert), (Underline::SingleAccounting, VertAlign::Subscript));
|
||||
|
||||
// User-defined styles are kept (first of a name wins); built-in ones are GridCraft's own.
|
||||
assert_eq!(wb.cell_styles.len(), 1, "{:?}", wb.cell_styles.iter().map(|(n, _)| n).collect::<Vec<_>>());
|
||||
assert_eq!(wb.cell_styles[0].0, "Fancy");
|
||||
assert_eq!(&wb.cell_styles[0].1, style_at(&wb, "A1"));
|
||||
|
||||
// The formatting survives a save as .xlsx.
|
||||
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
for a1 in ["A1", "A2", "A3", "A6"] {
|
||||
assert_eq!(style_at(&back, a1), style_at(&wb, a1), "{a1}");
|
||||
}
|
||||
assert_eq!(back.styles.get(StyleId::DEFAULT).font.name, "Arial");
|
||||
assert_eq!(back.cell_styles.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["Fancy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_palette_the_default_colours_apply() {
|
||||
let mut f = default_fonts();
|
||||
f.push(xf(0, 0, STYLE, plain()));
|
||||
f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 8, ..plain() }));
|
||||
f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 63, ..plain() }));
|
||||
// Out-of-range colour indexes are automatic.
|
||||
f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 0x7F, dg: [1, 0, 0, 0, 0], icv: [0x50, 0, 0, 0, 0], ..plain() }));
|
||||
let (wb, _) = read_xls(&compound("Workbook", &workbook(&f, &[number(0, 0, 1, 1.0), number(1, 0, 2, 1.0), number(2, 0, 3, 1.0)]))).unwrap();
|
||||
assert_eq!(style_at(&wb, "A1").fill, Fill::solid(Color::Rgb(0x000000)));
|
||||
assert_eq!(style_at(&wb, "A2").fill, Fill::solid(Color::Rgb(0x333333)));
|
||||
let s = style_at(&wb, "A3");
|
||||
assert_eq!(s.fill, Fill::solid(Color::Auto));
|
||||
assert_eq!(s.borders.left, BorderLine { style: BorderStyle::Thin, color: Color::Auto });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_formatting_records_keep_what_is_there() {
|
||||
let mut f = default_fonts();
|
||||
// A truncated font (no name): the default font keeps the numbering of the next one.
|
||||
f.push(rec(0x0031, &u16s(&[240, 0])));
|
||||
f.push(font(300, 0, 0x7FFF, 700, 0, 0, "Georgia"));
|
||||
// A palette claiming more entries than it holds.
|
||||
f.push(rec(0x0092, &[&u16s(&[56])[..], &[1u8, 2, 3, 0][..]].concat()));
|
||||
f.push(xf(0, 0, STYLE, plain()));
|
||||
// XF 1: only ifnt and ifmt and flags: number format kept, no alignment.
|
||||
f.push(rec(0x00E0, &u16s(&[6, 10, 0x0001])));
|
||||
// XF 2: two bytes.
|
||||
f.push(rec(0x00E0, &u16s(&[6])));
|
||||
// XF 3: palette colour 8 (overridden) as a fill.
|
||||
f.push(xf(0, 0, CELL, XfData { fls: 1, fore: 8, ..plain() }));
|
||||
// Style records cut short, and a style naming a missing XF.
|
||||
f.push(rec(0x0293, &u16s(&[0x8000])));
|
||||
f.push(rec(0x0293, &[&u16s(&[1, 40])[..], &[0u8][..], &b"Cut"[..]].concat()));
|
||||
f.push(rec(0x0293, &[&u16s(&[900, 4])[..], &[0u8][..], &b"Gone"[..]].concat()));
|
||||
let book = workbook(&f, &[number(0, 0, 1, 1.0), number(1, 0, 2, 1.0), number(2, 0, 3, 1.0)]);
|
||||
let (wb, report) = read_xls(&compound("Workbook", &book)).unwrap();
|
||||
assert!(report.warnings.iter().any(|w| w.contains("shorter than expected")), "{:?}", report.warnings);
|
||||
let s = style_at(&wb, "A1");
|
||||
assert_eq!((s.font.name.as_str(), s.font.bold), ("Georgia", true));
|
||||
assert_eq!(s.num_fmt.as_str(), "0.00%");
|
||||
assert_eq!((s.align.v, s.protection.locked), (VAlign::Bottom, true));
|
||||
assert_eq!(wb.sheets[0].style_id(CellRef::parse("A2").unwrap()), StyleId::DEFAULT);
|
||||
assert_eq!(style_at(&wb, "A3").fill, Fill::solid(Color::Rgb(0x010203)));
|
||||
assert!(wb.cell_styles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_formatting_records_never_panic() {
|
||||
let stream = formatted();
|
||||
for at in 0..stream.len() {
|
||||
for x in [0x00u8, 0xFF, 0x80] {
|
||||
let mut s = stream.clone();
|
||||
s[at] = x;
|
||||
let _ = read_xls(&compound("Workbook", &s));
|
||||
}
|
||||
}
|
||||
for cut in 0..stream.len() {
|
||||
let _ = read_xls(&compound("Workbook", &stream[..cut]));
|
||||
}
|
||||
}
|
||||
+20
-25
@@ -3,24 +3,27 @@
|
||||
//! <https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-xls/cd03cb5f-ca02-4934-a391-bb674cb8aa06>.
|
||||
//!
|
||||
//! Read: worksheets, shared strings, cell values, formulas (text and cached results, including
|
||||
//! shared and array formulas), number formats, the 1904 date system, merged cells, column
|
||||
//! widths, row heights, defined names, print areas, print titles and the calculation settings
|
||||
//! (mode, iteration, precision as displayed). Everything else (fonts, fills, borders, charts,
|
||||
//! pictures, comments, conditional formats, validation, macros) is skipped with a warning.
|
||||
//! shared and array formulas), cell formatting (number formats, fonts, fills, borders,
|
||||
//! alignment, protection, the colour palette and user-defined cell styles), the 1904 date
|
||||
//! system, merged cells, column widths, row heights, defined names, print areas, print titles
|
||||
//! and the calculation settings (mode, iteration, precision as displayed). Everything else
|
||||
//! (charts, pictures, comments, conditional formats, validation, macros) is skipped with a
|
||||
//! warning.
|
||||
//! Encrypted workbooks and pre-BIFF8 versions are refused.
|
||||
|
||||
mod formula;
|
||||
mod ftab;
|
||||
mod records;
|
||||
mod styles;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, NumFmt, Sheet, Style, StyleId, Visibility, Workbook};
|
||||
use gridcraft_model::{CalcMode, CalcSettings, Cell, DefinedName, Formula, LineInfo, Sheet, StyleId, Visibility, Workbook};
|
||||
|
||||
use crate::cfb::Compound;
|
||||
use crate::tables::{builtin_format, col_width_to_px, pt_to_px};
|
||||
use crate::tables::{col_width_to_px, pt_to_px};
|
||||
use crate::{IoError, ReadReport};
|
||||
use formula::{Links, Mode, SupBook, Unsupported, Xti, cell_error, decode};
|
||||
use records::{Cursor, Record, Records};
|
||||
@@ -123,7 +126,7 @@ struct Globals {
|
||||
sheets: Vec<BoundSheet>,
|
||||
date1904: bool,
|
||||
formats: HashMap<u16, String>,
|
||||
xf_formats: Vec<u16>,
|
||||
styles: styles::StyleRecords,
|
||||
sst: Vec<Arc<str>>,
|
||||
links: Links,
|
||||
names: Vec<Lbl>,
|
||||
@@ -153,19 +156,11 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheets.clear();
|
||||
wb.date_system = if g.date1904 { DateSystem::D1904 } else { DateSystem::D1900 };
|
||||
// One model style per XF: only the number format is imported.
|
||||
let styles: Vec<StyleId> = g
|
||||
.xf_formats
|
||||
.iter()
|
||||
.map(|ifmt| {
|
||||
let code = g.formats.get(ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(*ifmt))).unwrap_or("General");
|
||||
if code.eq_ignore_ascii_case("General") {
|
||||
StyleId::DEFAULT
|
||||
} else {
|
||||
wb.styles.intern(Style { num_fmt: NumFmt::new(code), ..Style::default() })
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// One model style per XF.
|
||||
let styles = styles::build(&g.styles, &g.formats, &mut wb);
|
||||
if g.styles.short {
|
||||
report.warn("Some cell formatting records were shorter than expected; the formatting present was kept.");
|
||||
}
|
||||
|
||||
// BoundSheet8 index → model sheet index.
|
||||
let mut model_index: Vec<Option<usize>> = Vec::with_capacity(g.sheets.len());
|
||||
@@ -202,7 +197,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo
|
||||
// The overview comes first.
|
||||
report.warnings.insert(
|
||||
0,
|
||||
"Imported from an Excel 97-2003 (.xls) workbook: values, formulas, number formats, merged cells, column widths, row heights and defined names were read. Fonts, fills, borders, charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(),
|
||||
"Imported from an Excel 97-2003 (.xls) workbook: values, formulas, cell formatting, merged cells, column widths, row heights and defined names were read. Charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(),
|
||||
);
|
||||
Ok((wb, report))
|
||||
}
|
||||
@@ -272,10 +267,10 @@ fn globals(stream: &[u8], budget: &mut Budget, report: &mut ReadReport) -> Resul
|
||||
budget.text(code.len())?;
|
||||
g.formats.insert(ifmt, code);
|
||||
}
|
||||
0x00E0 if g.xf_formats.len() < MAX_XFS => {
|
||||
c.u16()?;
|
||||
g.xf_formats.push(c.u16()?);
|
||||
}
|
||||
0x00E0 => g.styles.xf(&mut c, MAX_XFS),
|
||||
0x0031 => g.styles.font(&mut c),
|
||||
0x0092 => g.styles.palette(&mut c),
|
||||
0x0293 => g.styles.style(&mut c),
|
||||
0x00FC => g.sst = shared_strings(&mut c, budget)?,
|
||||
0x01AE => {
|
||||
let _ctab = c.u16()?;
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
//! Cell formatting in a BIFF8 Workbook stream, from [MS-XLS]: Font (2.4.122), XF (2.4.353) with
|
||||
//! CellXF (2.5.91), Palette (2.4.188) and Style (2.4.269). Every XF is
|
||||
//! decoded on its own: a cell XF stores its complete formatting (the fAtr* bits only say whether
|
||||
//! later changes to the parent style propagate), so nothing is inherited from `ixfParent`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gridcraft_model::style::Protection;
|
||||
use gridcraft_model::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Underline, VAlign,
|
||||
VertAlign, Workbook,
|
||||
};
|
||||
|
||||
use super::records::Cursor;
|
||||
use crate::tables::{INDEXED, builtin_format};
|
||||
|
||||
/// Font records kept at most ([MS-XLS] FontIndex allows indexes up to 1022).
|
||||
const MAX_FONTS: usize = 1024;
|
||||
/// Style records kept at most.
|
||||
const MAX_STYLES: usize = 65_536;
|
||||
/// Characters kept from a style name (Excel allows 255).
|
||||
const MAX_STYLE_NAME: usize = 255;
|
||||
|
||||
/// The automatic font colour (IcvFont 0x7FFF) and the system foreground/background colours.
|
||||
const ICV_FONT_AUTO: u16 = 0x7FFF;
|
||||
const ICV_DEFAULT_FG: u16 = 0x40;
|
||||
const ICV_DEFAULT_BG: u16 = 0x41;
|
||||
|
||||
/// One XF record, decoded as far as its bytes allow.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct Xf {
|
||||
ifnt: u16,
|
||||
ifmt: u16,
|
||||
flags: u16,
|
||||
/// The CellXF/StyleXF data: 14 bytes when complete.
|
||||
data: [u8; 14],
|
||||
/// Whether all 20 bytes were present.
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
/// A Style record: the XF it names and its name.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct NamedStyle {
|
||||
ixf: u16,
|
||||
/// `Some(id)` for a built-in style (BuiltInStyle.istyBuiltIn), `None` for a user style.
|
||||
builtin: Option<u8>,
|
||||
/// A user style's name (empty for built-in styles).
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Formatting records collected from the globals substream.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct StyleRecords {
|
||||
fonts: Vec<Font>,
|
||||
font_colors: Vec<u16>,
|
||||
xfs: Vec<Xf>,
|
||||
/// Palette entries for colour indexes 8 and up (RRGGBB), when a Palette record is present.
|
||||
palette: Option<Vec<u32>>,
|
||||
styles: Vec<NamedStyle>,
|
||||
/// Records that were shorter than their structure; the parts present were used.
|
||||
pub(super) short: bool,
|
||||
}
|
||||
|
||||
impl StyleRecords {
|
||||
/// A Font record. The colour is resolved later, once the palette is known.
|
||||
pub(super) fn font(&mut self, c: &mut Cursor<'_, '_>) {
|
||||
if self.fonts.len() >= MAX_FONTS {
|
||||
return;
|
||||
}
|
||||
match read_font(c) {
|
||||
Some((font, icv)) => {
|
||||
self.fonts.push(font);
|
||||
self.font_colors.push(icv);
|
||||
}
|
||||
None => {
|
||||
// Keep the numbering of the fonts after it.
|
||||
self.short = true;
|
||||
self.fonts.push(Font::default());
|
||||
self.font_colors.push(ICV_FONT_AUTO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An XF record. A short record keeps the fields it has.
|
||||
pub(super) fn xf(&mut self, c: &mut Cursor<'_, '_>, max: usize) {
|
||||
if self.xfs.len() >= max {
|
||||
return;
|
||||
}
|
||||
let mut xf = Xf::default();
|
||||
let head = (|| Some((c.u16().ok()?, c.u16().ok()?, c.u16().ok()?)))();
|
||||
match head {
|
||||
Some((ifnt, ifmt, flags)) => {
|
||||
xf.ifnt = ifnt;
|
||||
xf.ifmt = ifmt;
|
||||
xf.flags = flags;
|
||||
xf.complete = true;
|
||||
for b in xf.data.iter_mut() {
|
||||
match c.u8() {
|
||||
Ok(v) => *b = v,
|
||||
Err(_) => {
|
||||
xf.complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Not even the font and format: an unformatted, locked cell.
|
||||
xf.flags = 1;
|
||||
}
|
||||
}
|
||||
if !xf.complete {
|
||||
self.short = true;
|
||||
}
|
||||
self.xfs.push(xf);
|
||||
}
|
||||
|
||||
/// A Palette record: colours for indexes 8 onwards.
|
||||
pub(super) fn palette(&mut self, c: &mut Cursor<'_, '_>) {
|
||||
let Ok(ccv) = c.u16() else {
|
||||
self.short = true;
|
||||
return;
|
||||
};
|
||||
let n = usize::from(ccv).min(56);
|
||||
if usize::from(ccv) > c.remaining() / 4 {
|
||||
self.short = true;
|
||||
}
|
||||
let mut colors = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let (Ok(r), Ok(g), Ok(b), Ok(_)) = (c.u8(), c.u8(), c.u8(), c.u8()) else { break };
|
||||
colors.push((u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b));
|
||||
}
|
||||
self.palette = Some(colors);
|
||||
}
|
||||
|
||||
/// A Style record.
|
||||
pub(super) fn style(&mut self, c: &mut Cursor<'_, '_>) {
|
||||
if self.styles.len() >= MAX_STYLES {
|
||||
return;
|
||||
}
|
||||
let Ok(word) = c.u16() else {
|
||||
self.short = true;
|
||||
return;
|
||||
};
|
||||
let ixf = word & 0x0FFF;
|
||||
if word & 0x8000 != 0 {
|
||||
let Ok(id) = c.u8() else {
|
||||
self.short = true;
|
||||
return;
|
||||
};
|
||||
// BuiltInStyle: the style's number (0 is Normal) and outline level.
|
||||
self.styles.push(NamedStyle { ixf, builtin: Some(id), name: String::new() });
|
||||
} else {
|
||||
match c.string() {
|
||||
Ok(name) => {
|
||||
let name: String = name.chars().filter(|ch| *ch != '\0').take(MAX_STYLE_NAME).collect();
|
||||
if !name.is_empty() {
|
||||
self.styles.push(NamedStyle { ixf, builtin: None, name });
|
||||
}
|
||||
}
|
||||
Err(_) => self.short = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 64-entry colour table: the built-in eight, then the palette (or the default one).
|
||||
fn color_table(&self) -> [u32; 64] {
|
||||
let mut table = INDEXED;
|
||||
if let Some(p) = &self.palette {
|
||||
for (slot, c) in table.iter_mut().skip(8).zip(p.iter()) {
|
||||
*slot = *c;
|
||||
}
|
||||
}
|
||||
table
|
||||
}
|
||||
}
|
||||
|
||||
/// Font record fields up to the name; `None` if the record is too short for them.
|
||||
fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> {
|
||||
let height = c.u16().ok()?;
|
||||
let flags = c.u16().ok()?;
|
||||
let icv = c.u16().ok()?;
|
||||
let weight = c.u16().ok()?;
|
||||
let script = c.u16().ok()?;
|
||||
let underline = c.u8().ok()?;
|
||||
c.skip(3).ok()?; // family, character set, unused
|
||||
let name = c.short_string().ok()?;
|
||||
let mut f = Font::default();
|
||||
let name: String = name.chars().filter(|ch| *ch != '\0').take(255).collect();
|
||||
if !name.trim().is_empty() {
|
||||
f.name = name;
|
||||
}
|
||||
if height > 0 {
|
||||
f.size = (f32::from(height) / 20.0).clamp(1.0, 409.0);
|
||||
}
|
||||
f.italic = flags & 0x0002 != 0;
|
||||
f.strike = flags & 0x0008 != 0;
|
||||
// Weights run 100-1000; Excel writes 400 for normal and 700 for bold.
|
||||
f.bold = weight >= 600;
|
||||
f.vert = match script {
|
||||
1 => VertAlign::Superscript,
|
||||
2 => VertAlign::Subscript,
|
||||
_ => VertAlign::Baseline,
|
||||
};
|
||||
f.underline = match underline {
|
||||
0x01 => Underline::Single,
|
||||
0x02 => Underline::Double,
|
||||
0x21 => Underline::SingleAccounting,
|
||||
0x22 => Underline::DoubleAccounting,
|
||||
_ => Underline::None,
|
||||
};
|
||||
Some((f, icv))
|
||||
}
|
||||
|
||||
/// A colour index in an XF (IcvXF) or Font (IcvFont): 0-63 from the colour table, the system
|
||||
/// and automatic colours as automatic.
|
||||
fn color(icv: u16, table: &[u32; 64]) -> Color {
|
||||
match icv {
|
||||
ICV_DEFAULT_FG | ICV_DEFAULT_BG | ICV_FONT_AUTO => Color::Auto,
|
||||
_ => table.get(usize::from(icv)).map_or(Color::Auto, |c| Color::Rgb(*c)),
|
||||
}
|
||||
}
|
||||
|
||||
fn border_style(dg: u32) -> BorderStyle {
|
||||
match dg {
|
||||
1 => BorderStyle::Thin,
|
||||
2 => BorderStyle::Medium,
|
||||
3 => BorderStyle::Dashed,
|
||||
4 => BorderStyle::Dotted,
|
||||
5 => BorderStyle::Thick,
|
||||
6 => BorderStyle::Double,
|
||||
7 => BorderStyle::Hair,
|
||||
8 => BorderStyle::MediumDashed,
|
||||
9 => BorderStyle::DashDot,
|
||||
10 => BorderStyle::MediumDashDot,
|
||||
11 => BorderStyle::DashDotDot,
|
||||
12 => BorderStyle::MediumDashDotDot,
|
||||
13 => BorderStyle::SlantDashDot,
|
||||
_ => BorderStyle::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern(fls: u16) -> PatternType {
|
||||
match fls {
|
||||
0x01 => PatternType::Solid,
|
||||
0x02 => PatternType::Gray50,
|
||||
0x03 => PatternType::Gray75,
|
||||
0x04 => PatternType::Gray25,
|
||||
0x05 => PatternType::DarkHorizontal,
|
||||
0x06 => PatternType::DarkVertical,
|
||||
0x07 => PatternType::DarkDown,
|
||||
0x08 => PatternType::DarkUp,
|
||||
0x09 => PatternType::DarkGrid,
|
||||
0x0A => PatternType::DarkTrellis,
|
||||
0x0B => PatternType::LightHorizontal,
|
||||
0x0C => PatternType::LightVertical,
|
||||
0x0D => PatternType::LightDown,
|
||||
0x0E => PatternType::LightUp,
|
||||
0x0F => PatternType::LightGrid,
|
||||
0x10 => PatternType::LightTrellis,
|
||||
0x11 => PatternType::Gray125,
|
||||
0x12 => PatternType::Gray0625,
|
||||
_ => PatternType::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn line(dg: u32, icv: u32, table: &[u32; 64]) -> BorderLine {
|
||||
let style = border_style(dg);
|
||||
if style == BorderStyle::None {
|
||||
return BorderLine::default();
|
||||
}
|
||||
// Colour 0 means "not specified": automatic.
|
||||
let color = if icv == 0 { Color::Auto } else { color(icv as u16, table) };
|
||||
BorderLine { style, color }
|
||||
}
|
||||
|
||||
/// The font an XF names (FontIndex: index 4 is never written, so 5 and up count from one).
|
||||
fn xf_font(ifnt: u16, fonts: &[Font]) -> Font {
|
||||
let i = match ifnt {
|
||||
0..=3 => usize::from(ifnt),
|
||||
4 => return fonts.first().cloned().unwrap_or_default(),
|
||||
_ => usize::from(ifnt) - 1,
|
||||
};
|
||||
fonts.get(i).or_else(|| fonts.first()).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn num_fmt(ifmt: u16, formats: &HashMap<u16, String>) -> NumFmt {
|
||||
let code = formats.get(&ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(ifmt))).unwrap_or("General");
|
||||
if code.eq_ignore_ascii_case("General") { NumFmt::default() } else { NumFmt::new(code) }
|
||||
}
|
||||
|
||||
/// The model style of one XF.
|
||||
fn xf_style(xf: &Xf, fonts: &[Font], formats: &HashMap<u16, String>, table: &[u32; 64]) -> Style {
|
||||
let mut s = Style {
|
||||
font: xf_font(xf.ifnt, fonts),
|
||||
num_fmt: num_fmt(xf.ifmt, formats),
|
||||
protection: Protection { locked: xf.flags & 0x0001 != 0, hidden: xf.flags & 0x0002 != 0 },
|
||||
..Style::default()
|
||||
};
|
||||
if !xf.complete {
|
||||
return s;
|
||||
}
|
||||
let d = &xf.data;
|
||||
let byte = |i: usize| d.get(i).copied().unwrap_or(0);
|
||||
let word32 = |i: usize| u32::from_le_bytes([byte(i), byte(i + 1), byte(i + 2), byte(i + 3)]);
|
||||
let (a0, rot, a2) = (byte(0), byte(1), byte(2));
|
||||
s.align = Alignment {
|
||||
h: match a0 & 0x07 {
|
||||
1 => HAlign::Left,
|
||||
2 => HAlign::Center,
|
||||
3 => HAlign::Right,
|
||||
4 => HAlign::Fill,
|
||||
5 => HAlign::Justify,
|
||||
6 => HAlign::CenterAcross,
|
||||
7 => HAlign::Distributed,
|
||||
_ => HAlign::General,
|
||||
},
|
||||
wrap: a0 & 0x08 != 0,
|
||||
v: match (a0 >> 4) & 0x07 {
|
||||
0 => VAlign::Top,
|
||||
1 => VAlign::Center,
|
||||
3 => VAlign::Justify,
|
||||
4 => VAlign::Distributed,
|
||||
_ => VAlign::Bottom,
|
||||
},
|
||||
rotation: match rot {
|
||||
0..=90 => i16::from(rot),
|
||||
91..=180 => -(i16::from(rot) - 90),
|
||||
255 => 255,
|
||||
_ => 0,
|
||||
},
|
||||
indent: a2 & 0x0F,
|
||||
shrink: a2 & 0x10 != 0,
|
||||
};
|
||||
let b1 = word32(4);
|
||||
let b2 = word32(8);
|
||||
let mut borders = Borders {
|
||||
left: line(b1 & 0x0F, (b1 >> 16) & 0x7F, table),
|
||||
right: line((b1 >> 4) & 0x0F, (b1 >> 23) & 0x7F, table),
|
||||
top: line((b1 >> 8) & 0x0F, b2 & 0x7F, table),
|
||||
bottom: line((b1 >> 12) & 0x0F, (b2 >> 7) & 0x7F, table),
|
||||
..Borders::default()
|
||||
};
|
||||
let diag = line((b2 >> 21) & 0x0F, (b2 >> 14) & 0x7F, table);
|
||||
let which = (b1 >> 30) & 0x03;
|
||||
if which & 1 != 0 {
|
||||
borders.diag_down = diag;
|
||||
}
|
||||
if which & 2 != 0 {
|
||||
borders.diag_up = diag;
|
||||
}
|
||||
s.borders = borders;
|
||||
let fill = u16::from_le_bytes([byte(12), byte(13)]);
|
||||
let fls = ((b2 >> 26) & 0x3F) as u16;
|
||||
let fg = color(fill & 0x7F, table);
|
||||
let bg = color((fill >> 7) & 0x7F, table);
|
||||
s.fill = match pattern(fls) {
|
||||
PatternType::None => Fill::default(),
|
||||
PatternType::Solid => Fill::solid(fg),
|
||||
p => Fill { pattern: p, fg, bg },
|
||||
};
|
||||
s
|
||||
}
|
||||
|
||||
/// Imports the formatting: sets the workbook's default (Normal) font and named cell styles, and
|
||||
/// returns the model style of every XF in file order.
|
||||
pub(super) fn build(r: &StyleRecords, formats: &HashMap<u16, String>, wb: &mut Workbook) -> Vec<StyleId> {
|
||||
let table = r.color_table();
|
||||
let fonts: Vec<Font> = r.fonts.iter().zip(r.font_colors.iter()).map(|(f, icv)| Font { color: color(*icv, &table), ..f.clone() }).collect();
|
||||
// The Normal style's font is what every unformatted cell shows.
|
||||
let normal_xf = r.styles.iter().find(|s| s.builtin == Some(0)).map_or(0, |s| usize::from(s.ixf));
|
||||
let normal_font = r.xfs.get(normal_xf).map(|xf| xf_font(xf.ifnt, &fonts)).or_else(|| fonts.first().cloned());
|
||||
if let Some(font) = normal_font {
|
||||
wb.styles = StyleTable::with_default(Style { font, ..Style::default() });
|
||||
}
|
||||
let out = r.xfs.iter().map(|xf| wb.styles.intern(xf_style(xf, &fonts, formats, &table))).collect();
|
||||
// User-defined named styles. Excel 2007 and later write every built-in style into each .xls
|
||||
// (about fifty, their colours approximated to the palette); GridCraft defines those itself.
|
||||
let mut named: Vec<(String, Style)> = Vec::new();
|
||||
for ns in &r.styles {
|
||||
if ns.builtin.is_some() || ns.name.eq_ignore_ascii_case("Normal") || named.iter().any(|(n, _)| n.eq_ignore_ascii_case(&ns.name)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(xf) = r.xfs.get(usize::from(ns.ixf)) {
|
||||
named.push((ns.name.clone(), xf_style(xf, &fonts, formats, &table)));
|
||||
}
|
||||
}
|
||||
wb.cell_styles = named;
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user