Compare commits

..
2 changed files with 52 additions and 40 deletions
+5 -38
View File
@@ -171,9 +171,7 @@ impl Dictionary {
["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0), ["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0),
["REP", n] => { ["REP", n] => {
for _ in 0..n.parse::<usize>().unwrap_or(0) { for _ in 0..n.parse::<usize>().unwrap_or(0) {
// The count is the file's claim: stop when the records run out. let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
let Some(line) = lines.next() else { break };
let r: Vec<&str> = line.split_whitespace().collect();
if let [_, a, b, ..] = r.as_slice() { if let [_, a, b, ..] = r.as_slice() {
d.rep.push((a.replace('_', " "), b.replace('_', " "))); d.rep.push((a.replace('_', " "), b.replace('_', " ")));
} }
@@ -184,8 +182,7 @@ impl Dictionary {
let Some(flag) = one_flag(Some(flag), mode) else { continue }; let Some(flag) = one_flag(Some(flag), mode) else { continue };
let cross = cross.eq_ignore_ascii_case("y"); let cross = cross.eq_ignore_ascii_case("y");
for _ in 0..n.parse::<usize>().unwrap_or(0) { for _ in 0..n.parse::<usize>().unwrap_or(0) {
let Some(line) = lines.next() else { break }; let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
let r: Vec<&str> = line.split_whitespace().collect();
let [_, _, strip, add, rest @ ..] = r.as_slice() else { continue }; let [_, _, strip, add, rest @ ..] = r.as_slice() else { continue };
let (add, cont) = match add.split_once('/') { let (add, cont) = match add.split_once('/') {
Some((a, c)) => (a, parse_flags(c, mode)), Some((a, c)) => (a, parse_flags(c, mode)),
@@ -579,15 +576,6 @@ fn cache() -> &'static Cache {
CACHE.get_or_init(Default::default) CACHE.get_or_init(Default::default)
} }
/// Language strings remembered by [`dictionary`]; a string past it is resolved again on each call.
const MAX_CACHED_LANGS: usize = 256;
/// Parsed dictionaries by their affix file, so aliases of one language share it.
fn by_files() -> &'static Mutex<HashMap<PathBuf, Arc<Dictionary>>> {
static M: OnceLock<Mutex<HashMap<PathBuf, Arc<Dictionary>>>> = OnceLock::new();
M.get_or_init(Default::default)
}
/// The dictionary for `lang` (a BCP-47 tag such as `en-US`), loaded once per process. /// The dictionary for `lang` (a BCP-47 tag such as `en-US`), loaded once per process.
pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> { pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
let cache = cache(); let cache = cache();
@@ -598,23 +586,10 @@ pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
return d.clone(); return d.clone();
} }
let loaded = find_in(&search_dirs(), lang).and_then(|(a, d)| { let loaded = find_in(&search_dirs(), lang).and_then(|(a, d)| {
// Tags that resolve to the same files share one parsed dictionary. let (a, d) = (std::fs::read(a).ok()?, std::fs::read(d).ok()?);
if let Ok(m) = by_files().lock() Some(Arc::new(Dictionary::parse(&a, &d)))
&& let Some(dict) = m.get(&a)
{
return Some(dict.clone());
}
let (bytes_a, bytes_d) = (std::fs::read(&a).ok()?, std::fs::read(d).ok()?);
let dict = Arc::new(Dictionary::parse(&bytes_a, &bytes_d));
// A concurrent loader of the same files may have inserted first: share its copy.
if let Ok(mut m) = by_files().lock() {
return Some(m.entry(a).or_insert(dict).clone());
}
Some(dict)
}); });
if let Ok(mut c) = cache.lock() if let Ok(mut c) = cache.lock() {
&& c.len() < MAX_CACHED_LANGS
{
c.insert(key, loaded.clone()); c.insert(key, loaded.clone());
} }
loaded loaded
@@ -905,14 +880,6 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string())); assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string()));
} }
/// An affix header can claim billions of records; parsing stops where the file does.
#[test]
fn a_header_count_past_the_records_ends_with_them() {
let aff = b"SFX S Y 4000000000\nSFX S 0 s .\nREP 4000000000\nREP a b\n";
let d = Dictionary::parse(aff, b"1\ncat/S\n".as_slice());
assert!(d.check("cats"));
}
/// #1175: compound splitting is quadratic in the word's length, so a word longer than /// #1175: compound splitting is quadratic in the word's length, so a word longer than
/// `MAX_CHECK_CHARS` is not checked; shorter ones still are. /// `MAX_CHECK_CHARS` is not checked; shorter ones still are.
#[test] #[test]
+47 -2
View File
@@ -246,6 +246,9 @@ fn point_fills(w: &mut W, s: &Series, g: Group) {
} }
} }
/// Data points (categories and values counted per series) one chart group writes to its caches.
const MAX_EXPORT_POINTS: usize = 4_000_000;
fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) { fn series(w: &mut W, c: &Chart, s: &Series, i: usize, kind: ChartType, g: Group) {
w.open0("c:ser"); w.open0("c:ser");
w.val("c:idx", i); w.val("c:idx", i);
@@ -491,6 +494,24 @@ fn axes(w: &mut W, c: &Chart, g: Group) {
w.close("c:valAx"); w.close("c:valAx");
} }
/// Points `series` writes into its caches for group `g`, synthesized vectors included.
fn series_points(c: &Chart, s: &Series, g: Group) -> usize {
let v = s.values.len();
if matches!(g, Group::Scatter | Group::Bubble) {
let xs = if s.x_values.is_empty() { v } else { s.x_values.len() };
let sizes = if g != Group::Bubble {
0
} else if s.sizes.is_empty() {
v
} else {
s.sizes.len()
};
xs.saturating_add(v).saturating_add(sizes)
} else {
c.categories.len().saturating_add(v)
}
}
fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) { fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)]) {
let g = group_of(kind); let g = group_of(kind);
let grouping = if kind.percent() { let grouping = if kind.percent() {
@@ -529,7 +550,17 @@ fn plot_group(w: &mut W, c: &Chart, kind: ChartType, list: &[(usize, &Series)])
if g != Group::Stock { if g != Group::Stock {
w.val("c:varyColors", vary); w.val("c:varyColors", vary);
} }
for (i, s) in list { // Each series repeats the categories in its own cache, so the output grows with series times
// categories: stop adding series once a group's caches pass the budget (the first is always written).
// shortcut: the first series is not bounded here; export has no error path for an oversized one.
let mut left = MAX_EXPORT_POINTS;
for (n, (i, s)) in list.iter().enumerate() {
let cost = series_points(c, s, g);
if n > 0 && cost > left {
log::warn!("pptx: chart export stopped after {n} series; the data caches passed {MAX_EXPORT_POINTS} points");
break;
}
left = left.saturating_sub(cost);
series(w, c, s, *i, kind, g); series(w, c, s, *i, kind, g);
} }
if c.data_labels { if c.data_labels {
@@ -1049,7 +1080,21 @@ mod region_map_tests {
mod tests { mod tests {
use deckcraft_color::{ColorTransform, Rgba}; use deckcraft_color::{ColorTransform, Rgba};
use deckcraft_model::ColorRef; use deckcraft_model::ColorRef;
use deckcraft_model::chart::{Chart, ChartType}; use deckcraft_model::chart::{Chart, ChartType, Series};
#[test]
fn export_cost_counts_the_caches_each_group_writes() {
let s = Series { values: vec![Some(1.0); 600], ..Default::default() };
let mut c = Chart::sample(ChartType::Column);
c.categories = vec!["a".into(); 1000];
// Category and value caches: 1,000 + 600, however the series is stored.
assert_eq!(super::series_points(&c, &s, super::Group::Bar), 1600);
// A bubble series without x values or sizes still writes three value-length caches.
assert_eq!(super::series_points(&c, &s, super::Group::Bubble), 1800);
assert_eq!(super::series_points(&c, &s, super::Group::Scatter), 1200);
let s = Series { values: vec![Some(1.0); 600], x_values: vec![Some(1.0); 5], sizes: vec![Some(1.0); 7], ..Default::default() };
assert_eq!(super::series_points(&c, &s, super::Group::Bubble), 612);
}
/// The chart's text colour is written where PowerPoint keeps it (`c:txPr` on the axes, legend, /// The chart's text colour is written where PowerPoint keeps it (`c:txPr` on the axes, legend,
/// data labels and chart, the title's run defaults) and read back as the same colour. /// data labels and chart, the title's run defaults) and read back as the same colour.