mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-09 12:00:01 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d9799fe10 |
@@ -332,14 +332,6 @@ impl NumberFormat {
|
||||
None => Formatted { text: t.to_string(), color: None, fill: None, numeric: false },
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::format_text`]'s text, or `None` once it passes `max_units` UTF-16 code units.
|
||||
fn format_text_within(&self, t: &str, max_units: usize) -> Option<String> {
|
||||
match self.inner.text_section.and_then(|i| self.inner.sections.get(i)) {
|
||||
Some(sec) => render::render_text_within(sec, t, max_units).map(|out| out.text),
|
||||
None => (t.encode_utf16().count() <= max_units).then(|| t.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a cell value for display.
|
||||
@@ -433,10 +425,6 @@ impl NumberFormat {
|
||||
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
|
||||
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
|
||||
/// section, or are returned as is.
|
||||
/// Longest text a cell holds, in UTF-16 code units (Excel's 32,767 characters, which XLSB also
|
||||
/// stores that way), and so the longest `TEXT` result.
|
||||
const MAX_CELL_TEXT_CHARS: usize = 32_767;
|
||||
|
||||
pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
|
||||
if code.is_empty() {
|
||||
return Ok(String::new());
|
||||
@@ -452,9 +440,7 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
|
||||
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
|
||||
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
|
||||
Some(n) => n,
|
||||
// Every `@` repeats the text: a result past a cell's limit is `#VALUE!`, so nested
|
||||
// TEXT calls can't multiply it further.
|
||||
None => return fmt.format_text_within(&t, MAX_CELL_TEXT_CHARS).ok_or(CellError::Value),
|
||||
None => return Ok(fmt.format_text(&t).text),
|
||||
},
|
||||
Value::Array(_) => return Err(CellError::Value),
|
||||
};
|
||||
|
||||
@@ -45,35 +45,6 @@ pub(crate) fn render_text(sec: &Section, text: &str) -> Out {
|
||||
out
|
||||
}
|
||||
|
||||
/// [`render_text`], but `None` once the output passes `max_units` UTF-16 code units (a cell's
|
||||
/// limit), checked before each piece is appended, so an oversized result is never built.
|
||||
pub(crate) fn render_text_within(sec: &Section, text: &str, max_units: usize) -> Option<Out> {
|
||||
let text_units = text.encode_utf16().count();
|
||||
let mut out = Out::default();
|
||||
let mut units = 0usize;
|
||||
for t in &sec.toks {
|
||||
match t {
|
||||
Tok::At => {
|
||||
units = units.saturating_add(text_units);
|
||||
if units > max_units {
|
||||
return None;
|
||||
}
|
||||
out.push(text);
|
||||
}
|
||||
Tok::Fill(c) => out.fill(*c),
|
||||
other => {
|
||||
let literal = other.literal_text();
|
||||
units = units.saturating_add(literal.encode_utf16().count());
|
||||
if units > max_units {
|
||||
return None;
|
||||
}
|
||||
out.push(&literal);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Renders a number through a General section (literals around the General keyword).
|
||||
pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out {
|
||||
let mut out = Out::default();
|
||||
|
||||
@@ -396,13 +396,6 @@ fn text_fn() {
|
||||
assert_eq!(text_function(&Value::Number(1234.5), "$#,##0.00", S), Ok("$1,234.50".into()));
|
||||
assert_eq!(text_function(&Value::from("5"), "0.00", S), Ok("5.00".into()));
|
||||
assert_eq!(text_function(&Value::from("abc"), "0.00", S), Ok("abc".into()));
|
||||
// Each `@` repeats the text: up to a cell's 32,767 characters, then #VALUE!.
|
||||
let at = "@".repeat(255);
|
||||
assert_eq!(text_function(&Value::from("x".repeat(128).as_str()), &at, S).map(|s| s.len()), Ok(128 * 255));
|
||||
assert_eq!(text_function(&Value::from("x".repeat(32_000).as_str()), &at, S), Err(CellError::Value));
|
||||
// The limit counts UTF-16 units: 16,384 emoji are 32,768 of them, one too many.
|
||||
assert_eq!(text_function(&Value::from("\u{1F600}".repeat(16_383).as_str()), "@", S).map(|s| s.chars().count()), Ok(16_383));
|
||||
assert_eq!(text_function(&Value::from("\u{1F600}".repeat(16_384).as_str()), "@", S), Err(CellError::Value));
|
||||
assert_eq!(text_function(&Value::Error(CellError::NA), "0", S), Err(CellError::NA));
|
||||
assert_eq!(text_function(&Value::Number(46302.0), "yyyy", S), Ok("2026".into()));
|
||||
assert_eq!(text_function(&Value::Number(0.5), "General", S), Ok("0.5".into()));
|
||||
|
||||
@@ -85,3 +85,21 @@ fn different_pictures_with_the_same_length_stay_separate() {
|
||||
let back = read_xlsx(&bytes).unwrap().0;
|
||||
assert_eq!(back.sheets[0].images.iter().map(|i| i.data.to_vec()).collect::<Vec<_>>(), vec![a, b]);
|
||||
}
|
||||
|
||||
/// Many distinct pictures that share a long prefix: each is matched against the earlier ones
|
||||
/// through a content hash, not by comparing bytes with every one of them.
|
||||
#[test]
|
||||
fn many_distinct_pictures_with_a_common_prefix_are_each_written_once() {
|
||||
let pictures: Vec<Vec<u8>> = (0..400u32)
|
||||
.map(|i| {
|
||||
let mut v = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
v.extend(std::iter::repeat_n(7u8, 65_536));
|
||||
v.extend(i.to_le_bytes());
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let refs: Vec<(u32, &[u8])> = pictures.iter().enumerate().map(|(i, p)| (i as u32 + 1, p.as_slice())).collect();
|
||||
let wb = Workbook { sheets: vec![sheet_with("One", &refs), sheet_with("Two", &refs)], ..Default::default() };
|
||||
let bytes = write_xlsx(&wb).unwrap();
|
||||
assert_eq!(names(&bytes).iter().filter(|n| n.starts_with("xl/media/")).count(), 400);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::fmt::Write as _;
|
||||
use gridcraft_core::RangeRef;
|
||||
use gridcraft_formula::quote_sheet;
|
||||
use gridcraft_model::{CalcMode, CalcSettings, PhoneticString, ShowObjects, Style, Visibility, Workbook, WorkbookView};
|
||||
use sha2::Digest as _;
|
||||
|
||||
use crate::package::ZipOut;
|
||||
use crate::xml::{esc, esc_attr};
|
||||
@@ -112,6 +113,9 @@ pub struct Out {
|
||||
pub dxfs: Vec<Style>,
|
||||
pub sst: Sst,
|
||||
pub images: u32,
|
||||
/// Media parts written so far, by extension and SHA-256 of their bytes: a picture used again
|
||||
/// is found here instead of being compared byte by byte with every earlier one.
|
||||
media: HashMap<(String, [u8; 32]), String>,
|
||||
pub charts: u32,
|
||||
/// The workbook's file name, which a PivotChart's link to its PivotTable names in brackets.
|
||||
pub book_name: String,
|
||||
@@ -142,15 +146,14 @@ impl Out {
|
||||
/// The `xl/media` file name (`imageN.ext`) holding `data`. A picture used on several sheets,
|
||||
/// or twice on one, is written once and every drawing points to that part, as in Excel.
|
||||
pub fn image_part(&mut self, ext: &str, data: &[u8]) -> String {
|
||||
let existing = self.parts.iter().find(|(name, bytes)| {
|
||||
name.strip_prefix("xl/media/image").is_some_and(|rest| rest.ends_with(&format!(".{ext}"))) && bytes.as_slice() == data
|
||||
});
|
||||
if let Some((name, _)) = existing {
|
||||
return name.trim_start_matches("xl/media/").to_string();
|
||||
let key = (ext.to_string(), sha2::Sha256::digest(data).into());
|
||||
if let Some(file) = self.media.get(&key) {
|
||||
return file.clone();
|
||||
}
|
||||
self.images += 1;
|
||||
let file = format!("image{}.{ext}", self.images);
|
||||
self.part(&format!("xl/media/{file}"), None, data.to_vec());
|
||||
self.media.insert(key, file.clone());
|
||||
file
|
||||
}
|
||||
pub fn default_type(&mut self, ext: &str, ct: &str) {
|
||||
@@ -249,6 +252,7 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>)
|
||||
dxfs: vec![],
|
||||
sst: Sst::default(),
|
||||
images: 0,
|
||||
media: HashMap::new(),
|
||||
charts: 0,
|
||||
book_name: book_name.filter(|n| !n.is_empty() && !n.contains(['[', ']'])).unwrap_or(DEFAULT_BOOK_NAME).to_string(),
|
||||
chart_names: vec![],
|
||||
|
||||
Reference in New Issue
Block a user