Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor da6472f36c Thumbnails: index section starts once 2026-11-03 20:53:44 +00:00
16 changed files with 151 additions and 436 deletions
Generated
+6 -35
View File
@@ -1275,7 +1275,6 @@ dependencies = [
"deckcraft-render",
"deckcraft-text",
"egui",
"egui_extras",
"egui_kittest",
"log",
"serde",
@@ -1521,19 +1520,6 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1601,26 +1587,6 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -3521,7 +3487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -6545,6 +6511,11 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+1 -4
View File
@@ -425,8 +425,6 @@ impl Decoder {
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
let mut tu = 0usize;
// Repeated-frame outputs in `out`, counted as they are added rather than recounted per OBU.
let mut existing = out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count();
let mut pos = 0;
while pos < data.len() {
let obu = parse_obu_unit(data, pos)?;
@@ -446,10 +444,9 @@ impl Decoder {
}
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
if let Some(o) = self.frame_obu(&obu)? {
if existing >= 32 {
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
return Err(Error::Invalid("too many frame outputs in chunk"));
}
existing += usize::from(matches!(o, Output::Existing { .. }));
out.push((o, tu, spatial_id));
}
}
+1 -3
View File
@@ -326,9 +326,7 @@ impl SliceHeader {
/// entry_point_offset list (tiles / wavefront substreams).
fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> {
// A fresh list, not `clear()`: a dependent segment starts as a copy of its independent
// segment's header, and clearing would keep that copy's allocation for every segment.
self.entry_points = Vec::new();
self.entry_points.clear();
if pps.tiles_enabled || pps.entropy_coding_sync {
let n = r.read_ue()?;
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
+34 -89
View File
@@ -758,20 +758,17 @@ impl Chart {
}
fn stacked_value_range(&self, n: usize) -> (f64, f64) {
// Sums per category, from the values the series hold (an absent one adds nothing).
let (mut pos, mut neg) = (vec![0.0f64; n], vec![0.0f64; n]);
for s in &self.series {
for (i, v) in s.values.iter().enumerate().take(n) {
let v = v.unwrap_or(0.0);
if v >= 0.0 {
pos[i] += v;
} else {
neg[i] += v;
}
let mut lo: f64 = 0.0;
let mut hi: f64 = 0.0;
for i in 0..n {
let (mut p, mut m) = (0.0, 0.0);
for s in &self.series {
let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
if v >= 0.0 { p += v } else { m += v }
}
hi = hi.max(p);
lo = lo.min(m);
}
let hi = pos.iter().fold(0.0f64, |a, &p| a.max(p));
let lo = neg.iter().fold(0.0f64, |a, &m| a.min(m));
(lo, hi)
}
@@ -1351,50 +1348,40 @@ impl Chart {
/// The box chart's groups (distinct category labels in order of appearance; one unnamed
/// group without categories) and, per series, each group's summary (None when it has no
/// points).
///
/// Each value is put in its group's bucket once (groups are found by name in a map), and the
/// series x groups result is held to [`MAX_BOX_CELLS`]: groups past it are left out.
pub fn box_groups(&self) -> (Vec<String>, Vec<Vec<Option<BoxStats>>>) {
let max_groups = (MAX_BOX_CELLS / self.series.len().max(1)).max(1);
let mut groups: Vec<String> = Vec::new();
let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for c in &self.categories {
if !index.contains_key(c.as_str()) && groups.len() < max_groups {
index.insert(c, groups.len());
if !groups.contains(c) {
groups.push(c.clone());
}
}
if groups.is_empty() {
groups.push(String::new());
index.insert("", 0);
}
// A value past the categories belongs to the unnamed group, when there is one.
let past_end = index.get("").copied();
let stats = self
.series
.iter()
.map(|s| {
let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); groups.len()];
for (i, v) in s.values.iter().enumerate() {
let Some(v) = v.filter(|v| v.is_finite()) else { continue };
let g = match self.categories.get(i) {
Some(c) => index.get(c.as_str()).copied(),
None => past_end,
};
if let Some(b) = g.and_then(|g| buckets.get_mut(g)) {
b.push(v);
}
}
buckets.into_iter().map(|pts| box_stats_by(pts, self.inclusive_quartiles)).collect()
groups
.iter()
.map(|g| {
let pts: Vec<f64> = s
.values
.iter()
.enumerate()
.filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g))
.filter_map(|(_, v)| *v)
.filter(|v| v.is_finite())
.collect();
box_stats_by(pts, self.inclusive_quartiles)
})
.collect()
})
.collect();
(groups, stats)
}
}
/// Most series x group summaries [`Chart::box_groups`] makes.
pub const MAX_BOX_CELLS: usize = 1_000_000;
/// Widens the (min, max) extent `ext` to include `v`.
fn widen(ext: &mut (f64, f64), v: f64) {
ext.0 = ext.0.min(v);
@@ -1467,41 +1454,33 @@ impl Chart {
let n = self.categories.len().max(s.values.len());
let levels: Vec<&Vec<String>> = self.category_levels.iter().take(MAX_LEVELS).collect();
// Each leaf's path from the top level down, blanks filled from the row above.
// Labels are shared, not copied: an outer label given once stands for every leaf under it.
use std::rc::Rc;
let empty: Rc<str> = Rc::from("");
let mut last: Vec<Rc<str>> = vec![empty.clone(); levels.len()];
let paths: Vec<Vec<Rc<str>>> = (0..n)
let mut last: Vec<String> = vec![String::new(); levels.len()];
let paths: Vec<Vec<String>> = (0..n)
.map(|i| {
let mut path = Vec::with_capacity(levels.len() + 1);
for (d, lv) in levels.iter().enumerate().rev() {
let l = lv.get(i).map(|x| x.trim()).unwrap_or("");
if !l.is_empty() {
last[d] = Rc::from(l);
last[d] = l.to_string();
// A new outer label starts fresh inner groups.
for inner in last.iter_mut().take(d) {
*inner = empty.clone();
inner.clear();
}
}
path.push(last[d].clone());
}
path.push(self.categories.get(i).map_or_else(|| empty.clone(), |c| Rc::from(c.as_str())));
path.push(self.categories.get(i).cloned().unwrap_or_default());
path
})
.collect();
let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0);
fn build(rows: &[(usize, &[std::rc::Rc<str>])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
let mut out: Vec<TreeNode> = Vec::new();
let mut i = 0;
while i < rows.len() {
let (idx, path) = rows[i];
if path.len() <= 1 {
out.push(TreeNode {
name: path.first().map(|p| p.to_string()).unwrap_or_default(),
value: value(idx),
leaf: Some(idx),
children: vec![],
});
out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] });
i += 1;
continue;
}
@@ -1510,14 +1489,14 @@ impl Chart {
while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head {
j += 1;
}
let sub: Vec<(usize, &[std::rc::Rc<str>])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
let children = build(&sub, value);
out.push(TreeNode { name: head.to_string(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
i = j;
}
out
}
let rows: Vec<(usize, &[Rc<str>])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
build(&rows, &value)
}
}
@@ -1628,40 +1607,6 @@ mod chartex_tests {
assert!(c.category_tree().iter().all(|n| n.leaf.is_some()));
}
#[test]
fn large_box_hierarchy_and_stacked_charts_take_linear_work() {
// 50,000 distinct categories in one series: grouped through a map, one pass.
let n = 50_000;
let mut c = Chart::sample(ChartType::BoxWhisker);
c.series.truncate(1);
c.categories = (0..n).map(|i| format!("g{i}")).collect();
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
let (groups, stats) = c.box_groups();
assert_eq!((groups.len(), stats[0].len()), (n, n));
assert!(stats[0].iter().all(Option::is_some));
// 2,000 series cap the groups so the result stays within MAX_BOX_CELLS.
c.series = vec![c.series[0].clone(); 2000];
let (groups, stats) = c.box_groups();
assert_eq!(groups.len(), MAX_BOX_CELLS / 2000);
assert!(stats.iter().all(|s| s.len() == groups.len()));
// One long outer label over 50,000 leaves is shared, not copied per leaf.
let mut t = Chart::sample(ChartType::Treemap);
t.series.truncate(1);
t.categories = (0..n).map(|i| format!("c{i}")).collect();
t.series[0].values = (0..n).map(|_| Some(1.0)).collect();
let mut outer = vec![String::new(); n];
outer[0] = "o".repeat(1 << 16);
t.category_levels = vec![outer];
let tree = t.category_tree();
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children.len(), n);
// Stacked sums over many sparse series.
let mut st = Chart::sample(ChartType::StackedBar);
st.categories = (0..n).map(|i| i.to_string()).collect();
st.series = (0..2000).map(|k| Series { values: vec![Some(1.0); if k == 0 { n } else { 1 }], ..Default::default() }).collect();
assert!(st.value_range().1 >= 2000.0, "{:?}", st.value_range());
}
#[test]
fn box_groups_follow_category_labels() {
let c = Chart::sample(ChartType::BoxWhisker);
-13
View File
@@ -143,14 +143,9 @@ fn group(digits: &str, sep: char) -> String {
out
}
/// Longest number format applied, as in Excel (255 characters); a longer one formats as
/// General, so a chart cannot make every label expand a huge format.
pub const MAX_CODE_LEN: usize = 255;
/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text
/// section of a format doesn't apply).
pub fn format(code: &str, v: f64, lang: Option<&str>) -> String {
let code = if code.len() > MAX_CODE_LEN * 4 || code.chars().count() > MAX_CODE_LEN { "General" } else { code };
let (sep, dec) = marks(lang);
if !v.is_finite() {
return String::new();
@@ -304,14 +299,6 @@ mod tests {
const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#;
#[test]
fn an_overlong_format_is_general() {
let code = "0".repeat(100_000);
assert_eq!(format(&code, 12.5, None), format("General", 12.5, None));
let ok = "0".repeat(MAX_CODE_LEN);
assert_eq!(format(&ok, 7.0, None).len(), MAX_CODE_LEN);
}
#[test]
fn scientific_subnormal() {
assert_eq!(format("0.00E+00", 5e-324, None), "4.94E-324");
+6 -13
View File
@@ -210,20 +210,13 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
let mut w = W::new();
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
let names = deckcraft_model::table::table_styles();
// Indexes instead of scans: written ids (as given and ASCII lower-case), and the file's
// definitions by lower-case id (the first wins).
let mut done: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut done_lower: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut def_of: std::collections::HashMap<String, &deckcraft_model::table::TableStyleDef> = std::collections::HashMap::new();
for d in defs {
def_of.entry(d.id.to_ascii_lowercase()).or_insert(d);
}
let mut done: Vec<&str> = vec![];
for s in used {
if !done.insert(s.as_str()) {
if done.contains(&s.as_str()) {
continue;
}
done_lower.insert(s.to_ascii_lowercase());
if let Some(d) = def_of.get(&s.to_ascii_lowercase())
done.push(s);
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s))
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
{
w.raw(&d.raw);
@@ -238,11 +231,11 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
}
// Styles the file defined but no table uses still belong to the deck.
for d in defs {
if done_lower.contains(&d.id.to_ascii_lowercase()) {
if done.iter().any(|s| s.eq_ignore_ascii_case(&d.id)) {
continue;
}
if crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) {
done_lower.insert(d.id.to_ascii_lowercase());
done.push(&d.id);
w.raw(&d.raw);
}
}
+7 -32
View File
@@ -53,8 +53,6 @@ pub struct Exp<'a> {
pub p: &'a Presentation,
pub pkg: PackageOut,
media_parts: HashMap<MediaId, String>,
/// Each media id's item in `p.media` (the first with the id, as `Presentation::media`).
media_at: HashMap<MediaId, usize>,
n_image: u32,
n_media: u32,
n_chart: u32,
@@ -65,8 +63,6 @@ pub struct Exp<'a> {
n_kept: u32,
pub slide_parts: HashMap<SlideId, String>,
pub table_styles: Vec<String>,
/// The ids in `table_styles`, for checking membership without a scan.
pub table_style_set: std::collections::HashSet<String>,
placeholder: Option<String>,
}
@@ -123,17 +119,12 @@ fn ext_for(content_type: &str, name: &str, data: &[u8]) -> String {
if !e.is_empty() && e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()) { e } else { "bin".into() }
}
impl<'a> Exp<'a> {
/// The media item `id`, found through the index rather than a scan of every item.
pub fn media(&self, id: MediaId) -> Option<&'a deckcraft_model::MediaItem> {
let p: &'a Presentation = self.p;
self.media_at.get(&id).and_then(|&i| p.media.get(i))
}
impl Exp<'_> {
fn media_part(&mut self, id: MediaId) -> Option<String> {
if let Some(p) = self.media_parts.get(&id) {
return Some(p.clone());
}
let m = self.media(id)?;
let m = self.p.media(id)?;
if m.data.is_empty() {
return None;
}
@@ -156,7 +147,7 @@ impl<'a> Exp<'a> {
}
/// A relationship from `o` to a media item (embedded part, or external link).
pub fn media_rel(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
let m = self.media(id)?;
let m = self.p.media(id)?;
if m.data.is_empty() {
let link = m.link.clone()?;
return Some(o.rels.add(kind, &link, true));
@@ -165,7 +156,7 @@ impl<'a> Exp<'a> {
Some(o.rels.add(kind, &opc::relative(&o.name, &part), false))
}
pub fn media_rel_new(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
let m = self.media(id)?;
let m = self.p.media(id)?;
if m.data.is_empty() {
return None;
}
@@ -498,8 +489,7 @@ fn valid_date(s: &str) -> bool {
/// A copy of the presentation with every slide pointing at a layout that exists.
fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> {
let mut need = p.masters.is_empty() || p.masters.iter().any(|m| m.layouts.is_empty());
let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
need |= p.slides.iter().any(|s| !layouts.contains(&s.layout));
need |= p.slides.iter().any(|s| p.layout(s.layout).is_none());
if !need {
return std::borrow::Cow::Borrowed(p);
}
@@ -542,13 +532,6 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
p,
pkg: PackageOut::default(),
media_parts: HashMap::new(),
media_at: {
let mut m = HashMap::new();
for (i, item) in p.media.iter().enumerate() {
m.entry(item.id).or_insert(i);
}
m
},
n_image: 0,
n_media: 0,
n_chart: 0,
@@ -558,7 +541,6 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
n_kept: 0,
slide_parts: HashMap::new(),
table_styles: vec![],
table_style_set: Default::default(),
placeholder: None,
};
for (i, s) in p.slides.iter().enumerate() {
@@ -977,16 +959,9 @@ fn section_slides(p: &Presentation) -> Vec<(String, Vec<usize>)> {
if out.is_empty() {
return out;
}
// Each slide's first section, found once: a section may name a slide many times.
let mut first: HashMap<SlideId, usize> = HashMap::new();
for (k, sec) in p.sections.iter().enumerate() {
for id in &sec.slides {
first.entry(*id).or_insert(k);
}
}
let mut cur = 0usize;
for (i, s) in p.slides.iter().enumerate() {
if let Some(&k) = first.get(&s.id)
if let Some(k) = p.sections.iter().position(|sec| sec.slides.contains(&s.id))
&& k >= cur
{
cur = k;
@@ -1052,7 +1027,7 @@ fn write_embedded_fonts(x: &mut Exp, o: &mut Out) -> Vec<WrittenFont> {
if let Some(rid) = seen_part.get(&id) {
return Some(rid.clone());
}
let m = x.media(id)?;
let m = x.p.media(id)?;
if m.data.is_empty() || m.data.len() > 32 * 1024 * 1024 {
return None;
}
+3 -3
View File
@@ -100,7 +100,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) {
// An image that lives outside the package is a linked picture: `a:blip` names it through
// `r:link`; `r:embed` may only reference a part inside the package.
let linked = x.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some());
let linked = x.p.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some());
let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o));
w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" }));
let adj = &pf.adjust;
@@ -449,7 +449,7 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink)
return;
};
w.open(tag, attrs);
let name = x.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into());
let name = x.p.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into());
w.empty("a:snd", A::new().a("r:embed", sound_rid).a("name", name));
w.close(tag);
}
@@ -601,7 +601,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") {
Some(rid) => {
w.open0("a:buBlip");
let a = if x.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) {
let a = if x.p.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) {
A::new().a("r:link", &rid)
} else {
A::new().a("r:embed", &rid)
+4 -4
View File
@@ -446,7 +446,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
}
fn model_rel(x: &mut Exp, o: &mut Out, media: deckcraft_model::MediaId) -> Option<String> {
if x.media(media).is_none_or(|m| m.data.is_empty()) {
if x.p.media(media).is_none_or(|m| m.data.is_empty()) {
return None;
}
x.media_rel(o, media, crate::opc::RT_MODEL3D)
@@ -479,7 +479,7 @@ fn write_model(
return;
};
let id = cx.id_for(s);
let poster = poster.filter(|p| x.media(*p).is_some_and(|m| !m.data.is_empty()));
let poster = poster.filter(|p| x.p.media(*p).is_some_and(|m| !m.data.is_empty()));
let Some(poster) = poster else {
write_model_part(w, x, o, id, s, &rid);
return;
@@ -700,7 +700,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
t.normalize();
w.open0("a:tbl");
let (guid, _) = crate::tables::guid_for_style(&t.style);
if x.table_style_set.insert(t.style.clone()) {
if !x.table_styles.contains(&t.style) {
x.table_styles.push(t.style.clone());
}
w.open(
@@ -884,7 +884,7 @@ fn opaque(
p.name = label.to_string();
}
match preview {
Some(m) if x.media(m).is_some() => {
Some(m) if x.p.media(m).is_some() => {
p.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: m, ..Default::default() } };
p.ph = None;
shape_with_id(w, x, o, cx, &p, id);
+1 -1
View File
@@ -16,7 +16,7 @@ pub fn transition(w: &mut W, x: &mut Exp, o: &mut Out, t: &Transition) {
601..=900 => "med",
_ => "slow",
};
let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.media(m).map(|i| i.name.clone()).unwrap_or_default())));
let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.p.media(m).map(|i| i.name.clone()).unwrap_or_default())));
let snd = |w: &mut W| {
if let Some((r, name)) = &sound {
w.open0("p:sndAc");
-31
View File
@@ -1,31 +0,0 @@
//! Export looks media, layouts, sections and table styles up by id: a deck with many of each and
//! many references to the last ones exports in time linear in its size.
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Section, Shape, ShapeId, ShapeKind, defaults};
#[test]
fn many_media_sections_and_table_styles_export() {
let mut p = defaults::new_presentation(None);
// 5,000 media items; 5,000 pictures of the last one.
let mut last = None;
for i in 0..5_000u32 {
last = p.add_media(&format!("m{i}.png"), "image/png", i.to_le_bytes().to_vec());
}
let last = last.unwrap();
let pic = |k: u32| Shape {
id: ShapeId(100_000 + k),
kind: ShapeKind::Picture { fill: PictureFill { media: last, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut p.slides[0]).shapes = (0..5_000).map(pic).collect();
// A section naming the slide 50,000 times, after 2,000 empty sections.
let id = p.slides[0].id;
p.sections = (0..2_000).map(|i| Section { name: format!("s{i}"), slides: vec![] }).collect();
p.sections.push(Section { name: "last".into(), slides: vec![id; 50_000] });
let bytes = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&bytes).expect("import");
assert_eq!(q.slides[0].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count(), 5_000);
}
-1
View File
@@ -9,7 +9,6 @@ mod corpus_kinds;
mod corpus_media;
mod corpus_style;
mod corpus_themes;
mod export_scale;
mod fills;
mod kept_parts;
mod locks;
-1
View File
@@ -19,7 +19,6 @@ deckcraft-anim = { workspace = true }
deckcraft-engine = { workspace = true }
deckcraft-media = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_extras = { workspace = true }
craft-ui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+1 -1
View File
@@ -1,7 +1,7 @@
use super::{Color32, CornerRadius, Rect, Sense, SlideApp, Stroke, Tokens, Ui, pos2, theme, vec2};
/// Room the About credits need for their table; the About tab keeps its compact size.
pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(820.0, 380.0);
pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(640.0, 380.0);
/// The Contributors / Models body in a block of its own fixed size, below the tab row. Sizing the
/// dialog's `ui` instead (`set_min_size` / `set_max_height`) squeezed the tab row and the first
+50 -202
View File
@@ -246,14 +246,12 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
ui.separator();
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
// once its columns are dragged wider than the dialog.
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
} else {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
table(ui, &list, &mut v);
} else {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
@@ -262,148 +260,39 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
});
}
}
});
ui.data_mut(|d| d.insert_temp(id, v));
}
/// How the contributor table's spare width is shared out: all of it to Name.
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
/// How the model table's spare width is shared out: to Company and Model, the text columns.
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
/// The narrowest a column can be dragged.
const MIN_COLUMN: f32 = 28.0;
/// The width a column needs for its header and its widest cell.
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
widest + 2.0 * ui.spacing().button_padding.x
}
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, down to [`MIN_COLUMN`].
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if spare >= 0.0 && weight > 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
(w + spare * w / flexible).max(MIN_COLUMN)
} else {
*w
}
})
.collect()
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills
/// whatever width is left but never shrinks below its opening width, so widening a column grows
/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider
/// than the window scrolls sideways. Text too long for its column is clipped.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
let last = widths.len().saturating_sub(1);
for (i, w) in widths.into_iter().enumerate() {
let c = if i == last {
egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN))
} else {
egui_extras::Column::initial(w).at_least(MIN_COLUMN)
};
t = t.column(c.clip(true));
}
t
}
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
ui.with_layout(layout, add).inner
}
fn row_height(ui: &egui::Ui) -> f32 {
ui.text_style_height(&egui::TextStyle::Body) + 6.0
}
/// One contributor's table cells after the name, in `SortKey::ALL` order.
fn cells(c: &Contributor) -> [String; 9] {
[
group(c.prs),
group(c.commits),
group(c.lines_added),
group(c.lines_deleted),
signed(c.lines_delta()),
group(c.binary_added),
group(c.binary_deleted),
day(c.first_commit).to_string(),
day(c.last_commit).to_string(),
]
}
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
/// toolbar's sort-order button, so no arrow widens the column.
fn header_text(k: SortKey) -> RichText {
RichText::new(k.label().1).strong()
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
let h = row_height(ui);
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
let widths: Vec<f32> = SortKey::ALL
.iter()
.enumerate()
.map(|(i, &k)| match i.checked_sub(1) {
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
})
.collect();
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
.header(h + 4.0, |mut header| {
for k in SortKey::ALL {
header.col(|ui| {
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
if r.clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
})
.body(|mut body| {
for (c, cells) in list.iter().zip(rows) {
body.row(h, |mut row| {
row.col(|ui| {
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
.on_hover_text(c.summary());
});
for text in cells {
row.col(|ui| {
aligned(ui, true, |ui| ui.label(text));
});
}
});
}
});
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
@@ -413,48 +302,22 @@ pub fn models_ui(ui: &mut egui::Ui) {
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
let h = row_height(ui);
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
// Company, Model and Version read as text; the counts line up on the right.
let right = [false, false, false, true, true, true];
let rows: Vec<[String; 6]> = MODELS
.iter()
.map(|m| {
[
m.company.to_string(),
m.model.to_string(),
m.version.to_string(),
group(m.commits),
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
]
})
.collect();
let widths: Vec<f32> = titles
.iter()
.enumerate()
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
.collect();
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
.header(h + 4.0, |mut header| {
for (title, r) in titles.into_iter().zip(right) {
header.col(|ui| {
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
});
}
})
.body(|mut body| {
for cells in rows {
body.row(h, |mut row| {
for (text, r) in cells.into_iter().zip(right) {
row.col(|ui| {
aligned(ui, r, |ui| ui.label(text));
});
}
});
}
});
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
});
});
}
@@ -534,19 +397,4 @@ mod tests {
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
#[test]
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
}
#[test]
fn too_little_room_shrinks_only_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
}
+37 -3
View File
@@ -112,6 +112,21 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
}
}
/// Slide index -> the first section starting there, built once instead of searched per slide.
fn section_starts(doc: &deckcraft_model::Presentation) -> std::collections::HashMap<usize, usize> {
let mut index_of = std::collections::HashMap::new();
for (i, s) in doc.slides.iter().enumerate() {
index_of.entry(s.id).or_insert(i);
}
let mut starts = std::collections::HashMap::new();
for (si, s) in doc.sections.iter().enumerate() {
if let Some(&i) = s.slides.first().and_then(|id| index_of.get(id)) {
starts.entry(i).or_insert(si);
}
}
starts
}
/// Render the scrollable thumbnail list, scrolling the current slide into view when it changes.
fn list(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
@@ -135,11 +150,10 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
let ppp = ui.ctx().pixels_per_point();
let mut budget = 3u32;
let mut rects = vec![];
let section_starts: Vec<(usize, usize)> =
doc.sections.iter().enumerate().filter_map(|(si, s)| s.slides.first().and_then(|id| doc.slide_index(*id)).map(|i| (i, si))).collect();
let section_starts = section_starts(&doc);
let mut need_more = false;
for i in 0..doc.slides.len() {
if let Some((_, si)) = section_starts.iter().find(|(start, _)| *start == i)
if let Some(si) = section_starts.get(&i)
&& let Some(sec) = doc.sections.get(*si)
{
ui.add_space(4.0);
@@ -434,6 +448,26 @@ mod tests {
h
}
#[test]
fn section_starts_map_each_first_slide_to_its_first_section() {
let mut s = Session::with_new();
for _ in 0..3 {
s.execute("slide.new", &json!({})).unwrap();
}
let mut doc = (*s.active().unwrap().doc).clone();
let ids: Vec<_> = doc.slides.iter().map(|sl| sl.id).collect();
doc.sections = vec![
deckcraft_model::Section { name: "a".into(), slides: vec![ids[0], ids[1]] },
deckcraft_model::Section { name: "dup".into(), slides: vec![ids[0]] },
deckcraft_model::Section { name: "c".into(), slides: vec![ids[2]] },
deckcraft_model::Section { name: "empty".into(), slides: vec![] },
];
let starts = section_starts(&doc);
assert_eq!(starts.get(&0), Some(&0), "the first section starting at a slide wins");
assert_eq!(starts.get(&2), Some(&2));
assert_eq!(starts.len(), 2);
}
/// Vertical scroll offset of the thumbnail pane, as recorded each frame by `show`.
fn thumbs_scroll(h: &Harness<'_, SlideApp>) -> f32 {
h.state().ui.thumbs_scroll