Compare commits

..
Author SHA1 Message Date
Dylan Taylor 5ecb13649e Assign positional parameters to locals in sha256 verification 2026-10-23 23:01:38 -04:00
Dylan M. TaylorandClaude Opus 5.5 fc1f475e50 fix: restore compile and behaviour in Sonar refactors
- fonts: char::is_alphanumeric takes &self, so filter needs a closure
- h264: keep the mvd accumulator as i32 (prefix is usize)
- model: annotate the non-stacked range accumulators as f64
- engine: chart.options still clears the raw chart XML for upDownBars
  and quartiles; smartart goes back to the glob import (cmd! macro)
- web: keep the original viewport meta (behaviour change)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 02:35:02 +00:00
Dylan M. Taylor 3ef6433309 style: apply rustfmt formatting 2026-10-24 02:23:12 +00:00
Dylan M. Taylor 286a01eaae fix: address shellcheck SC2059 and fix generic cast ambiguity 2026-10-24 02:21:03 +00:00
Dylan M. Taylor f665887094 chore: resolve SonarCloud maintainability issues 2026-10-24 02:18:38 +00:00
aiongg 9aef21e627 Media: a clip opened from a .pptx gets its length and size from its headers (#1241) (#1250) 2026-10-23 19:56:24 -04:00
38 changed files with 1032 additions and 715 deletions
Generated
+584 -401
View File
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -54,22 +54,22 @@ unicode-linebreak = "0.1"
chrono = { version = "0.4", default-features = false, features = ["std"] }
# text / rendering / formats
skrifa = "0.48"
harfrust = "0.14"
vello_cpu = "0.3"
vello_common = { version = "0.3", default-features = false }
skrifa = "0.47"
harfrust = "0.13"
vello_cpu = "0.2"
vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
i_overlay = "9"
quick-xml = "0.42"
zip = { version = "9", default-features = false, features = ["deflate"] }
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
# ui
egui = "0.37"
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
+1 -1
View File
@@ -28,7 +28,7 @@ winresource = "0.1"
rfd = { workspace = true }
arboard = { workspace = true, features = ["image-data"] }
image = { workspace = true }
cpal = "0.18"
cpal = "0.16"
[target.'cfg(target_os = "macos")'.dependencies]
# Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as a
+1 -1
View File
@@ -49,7 +49,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
} {
i += 2;
while cs.get(i).is_some_and(|d| d.is_ascii_digit()) {
while cs.get(i).is_some_and(char::is_ascii_digit) {
i += 1;
}
break;
+2 -4
View File
@@ -2222,18 +2222,16 @@ fn neg_deinterleave(diff: i32, r: i32, max: i32) -> i32 {
if diff <= 2 * r {
if diff & 1 == 1 {
return r + ((diff + 1) >> 1);
} else {
return r - (diff >> 1);
}
return r - (diff >> 1);
}
diff
} else {
if diff <= 2 * (max - r - 1) {
if diff & 1 == 1 {
return r + ((diff + 1) >> 1);
} else {
return r - (diff >> 1);
}
return r - (diff >> 1);
}
max - (diff + 1)
}
+42 -1
View File
@@ -1,6 +1,10 @@
//! File menu: new, open, save, export, close, properties.
use deckcraft_model::{Presentation, defaults};
use std::collections::HashMap;
use std::sync::Arc;
use deckcraft_media::MediaInfo;
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
use serde_json::{Value, json};
use super::*;
@@ -156,6 +160,7 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
if deckcraft_pptx::sniff(bytes) {
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
deckcraft_format::repair(&mut p);
probe_clips(&mut p);
return Ok((p, lost_slides));
}
if deckcraft_pptx::is_encrypted(bytes) {
@@ -178,6 +183,42 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
}
/// Fill in each audio and video clip's length and picture size from its bytes: a `.pptx` doesn't
/// store them. Headers only, so opening never decodes a whole audio file.
fn probe_clips(p: &mut Presentation) {
fn rec(shapes: &mut [Shape], media: &[MediaItem], probed: &mut HashMap<MediaId, Option<MediaInfo>>, depth: usize) {
if depth > 64 {
return;
}
for s in shapes {
if let ShapeKind::Media(m) = &mut s.kind
&& (m.duration_ms == 0 || m.width == 0 && m.height == 0)
&& let Some(info) = probed
.entry(m.media)
.or_insert_with(|| media.iter().find(|i| i.id == m.media).and_then(|i| deckcraft_media::probe_headers(&i.data).ok()))
{
if m.duration_ms == 0 {
m.duration_ms = info.duration_ms.min(u32::MAX as u64) as u32;
}
if m.width == 0
&& m.height == 0
&& let Some(v) = &info.video
{
(m.width, m.height) = (v.width, v.height);
}
}
if let Some(children) = s.children_mut() {
rec(children, media, probed, depth + 1);
}
}
}
let mut probed = HashMap::new();
// Just read, so no slide is shared yet and `make_mut` doesn't copy.
for slide in &mut p.slides {
rec(&mut Arc::make_mut(slide).shapes, &p.media, &mut probed, 0);
}
}
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
crate::fonts::install_embedded_fonts(&p);
+35 -27
View File
@@ -1761,6 +1761,39 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option<Option<u32>>, bin_size: Option<Option<f64>>) {
if let Some(n) = bin_count {
c.bin_count = n;
if n.is_some() {
c.bin_size = None;
}
}
if let Some(w) = bin_size {
c.bin_size = w;
if w.is_some() {
c.bin_count = None;
}
}
}
fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) {
if let Some(t) = subtotals {
let n = c.series.first().map_or(0, |s| s.values.len());
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
t.sort_unstable();
t.dedup();
c.subtotals = t;
}
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
c.up_down_bars = b;
c.raw = None;
}
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
c.raw = None;
}
}
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
@@ -1772,33 +1805,8 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
let subtotals: Option<Vec<usize>> =
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
with_chart(s, p, "chart.options", |c| {
if let Some(n) = bin_count {
c.bin_count = n;
if n.is_some() {
c.bin_size = None;
}
}
if let Some(w) = bin_size {
c.bin_size = w;
if w.is_some() {
c.bin_count = None;
}
}
if let Some(t) = &subtotals {
let n = c.series.first().map_or(0, |s| s.values.len());
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
t.sort_unstable();
t.dedup();
c.subtotals = t;
}
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
c.up_down_bars = b;
c.raw = None;
}
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
c.raw = None;
}
apply_bin_options(c, bin_count, bin_size);
apply_subtotals_and_bars(c, p, subtotals.as_deref());
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
c.raw = None;
}
+15
View File
@@ -228,6 +228,21 @@ mod tests {
assert!(s.execute("media.posterFrame", &json!({})).is_err(), "audio has no poster frame");
}
#[test]
fn a_video_reopened_from_pptx_keeps_its_length_and_size() {
let mut s = Session::with_new();
let clip = include_bytes!("../../../media/tests/fixtures/clip-h264.mp4");
s.execute("insert.video", &json!({"name": "clip.mp4", "data": base64_encode(clip)})).expect("insert");
let before = s.execute("media.info", &json!({})).expect("info");
assert!(before["durationMs"].as_u64().is_some_and(|ms| ms > 0));
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).expect("save");
s.execute("file.openBytes", &json!({"name": "deck.pptx", "data": saved["data"]})).expect("open");
let after = s.execute("media.info", &json!({})).expect("info");
for key in ["durationMs", "width", "height"] {
assert_eq!(after[key], before[key], "{key}");
}
}
#[test]
fn media_commands_need_media() {
let mut s = Session::with_new();
+2 -2
View File
@@ -539,9 +539,9 @@ fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
if doc.sections.is_empty() {
// The slides before `at` go into a "Default Section".
if at > 0 {
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() });
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() });
}
doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() });
doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() });
} else {
let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) };
let si = doc.section_of(start).unwrap_or(0);
+1 -1
View File
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.map(ToString::to_string)
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown panic".into())
}
+1 -1
View File
@@ -60,7 +60,7 @@ pub fn stroke_width(icon: &Icon, w: f64, h: f64) -> f64 {
}
fn parsed() -> &'static HashMap<&'static str, BezPath> {
static CACHE: OnceLock<HashMap<&'static str, BezPath>> = OnceLock::new();
static CACHE: OnceLock<HashMap<&str, BezPath>> = OnceLock::new();
CACHE.get_or_init(|| {
let mut m = HashMap::new();
for icon in ICONS {
+2 -2
View File
@@ -152,13 +152,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
let Ok(rd) = std::fs::read_dir(dir) else { return out };
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("deckcraft") {
continue;
}
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
continue;
}
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
+1 -1
View File
@@ -15,4 +15,4 @@ harfrust = { workspace = true }
unicode-bidi = { workspace = true }
log = { workspace = true }
# Last-resort face only (Ubuntu Light); no UI code.
epaint_default_fonts = "0.37"
epaint_default_fonts = "0.36"
+28 -27
View File
@@ -161,12 +161,12 @@ pub struct FaceRef(&'static FontFace);
impl FaceRef {
/// The handle for a loaded face (memoised per face).
pub fn of(face: &Arc<FontFace>) -> FaceRef {
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
let id = face.id as usize;
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(std::sync::PoisonError::into_inner).get(id) {
return FaceRef(f);
}
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
let mut w = LEAKED.write().unwrap_or_else(std::sync::PoisonError::into_inner);
if w.len() <= id {
w.resize(id + 1, None);
}
@@ -743,7 +743,7 @@ pub fn base_style(style: &str) -> &str {
}
fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
/// Weight implied by a style name.
@@ -807,7 +807,7 @@ impl FontDb {
pub fn set_system_fallback(&self, on: bool) {
#[cfg(not(target_arch = "wasm32"))]
{
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled = on;
}
#[cfg(target_arch = "wasm32")]
let _ = on;
@@ -822,14 +822,14 @@ impl FontDb {
}
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
self.faces.read().unwrap_or_else(|e| e.into_inner())
self.faces.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The system font catalog, scanned first if it hasn't been yet.
#[cfg(not(target_arch = "wasm32"))]
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
self.ensure_catalog();
self.catalog.read().unwrap_or_else(|e| e.into_inner())
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
@@ -885,7 +885,7 @@ impl FontDb {
}
}
}
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut added = 0;
for f in new {
// Checked again under the lock: another thread may have added the same face.
@@ -923,7 +923,7 @@ impl FontDb {
#[cfg(not(target_arch = "wasm32"))]
pub fn load_system_fonts(&self) -> usize {
// Families load again from the new catalog when next looked up.
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
// The first scan, or a rescan once it is done (never both at once).
let mut first = None;
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
@@ -959,7 +959,7 @@ impl FontDb {
}
continue;
}
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
continue;
}
@@ -975,7 +975,7 @@ impl FontDb {
}
let n = found.len();
log::debug!("cataloged {n} system font faces");
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
*self.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner) = found;
n
}
@@ -983,7 +983,7 @@ impl FontDb {
/// it is loaded.
#[cfg(not(target_arch = "wasm32"))]
fn load_cataloged(&self, family: &str) -> bool {
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
self.load_family(family, &loading)
}
@@ -1047,7 +1047,7 @@ impl FontDb {
// them rather than finding none and falling back to another family.
#[cfg(not(target_arch = "wasm32"))]
{
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
let mut loaded = self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let key = family.to_lowercase();
if !loaded.contains(&key) {
self.load_cataloged(family);
@@ -1112,7 +1112,7 @@ impl FontDb {
}
let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
let f = Arc::new(f);
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(f.clone());
Some(f)
}
@@ -1214,20 +1214,20 @@ impl FontDb {
}
let seen = self.read_faces().len();
{
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !sys.enabled || sys.misses.contains(&c) {
return false;
}
}
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// Another thread may have loaded a face for `c` (faces are only ever added), or found
// there is none, while this one waited.
if self.read_faces().len() != seen && covered(self) {
return true;
}
if self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.contains(&c) {
if self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.contains(&c) {
return false;
}
for fam in SYSTEM_FALLBACKS {
@@ -1238,7 +1238,8 @@ impl FontDb {
}
}
}
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
let mut paths: Vec<std::path::PathBuf> =
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
paths.sort();
paths.dedup();
for p in paths {
@@ -1253,7 +1254,7 @@ impl FontDb {
}
}
if remember_miss {
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.insert(c);
}
false
}
@@ -1326,10 +1327,10 @@ impl FontDb {
if c.is_control() || c.is_whitespace() {
return false;
}
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled {
return false;
}
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
return false;
}
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
@@ -1347,7 +1348,7 @@ impl FontDb {
return true;
}
}
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
false
}
@@ -1359,12 +1360,12 @@ impl FontDb {
return false;
}
{
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if !sys.enabled {
return false;
}
}
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
return false;
}
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
@@ -1382,14 +1383,14 @@ impl FontDb {
return true;
}
}
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
false
}
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p.clone();
}
let mut pen = FlipPen(BezPath::new());
@@ -1399,7 +1400,7 @@ impl FontDb {
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
}
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
+26 -26
View File
@@ -1070,33 +1070,33 @@ mod tests {
assert_eq!(md_list_item(not), None, "{not:?}");
}
}
}
/// #814: slides_to_outline includes table cell text, which it used to skip.
#[test]
fn outline_includes_table_text() {
use deckcraft_model::table::{Cell, Row, Table};
use deckcraft_model::{Shape, ShapeKind, Xfrm};
let mut p = defaults::new_presentation(None);
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
body.text = Some(TextBody::from_text("Text box body"));
s.shapes.push(body);
/// #814: slides_to_outline includes table cell text, which it used to skip.
#[test]
fn outline_includes_table_text() {
use deckcraft_model::table::{Cell, Row, Table};
use deckcraft_model::{Shape, ShapeKind, Xfrm};
let mut p = defaults::new_presentation(None);
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
body.text = Some(TextBody::from_text("Text box body"));
s.shapes.push(body);
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] {
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
table.rows.push(Row { height: 30.0, cells });
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] {
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
table.rows.push(Row { height: 30.0, cells });
}
let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() };
tbl.kind = ShapeKind::Table(table);
s.shapes.push(tbl);
p.slides = vec![std::sync::Arc::new(s)];
let out = slides_to_outline(&p);
assert!(out.contains("Text box body"), "{out}");
assert!(out.contains("Cell A1\tCell B1"), "{out}");
assert!(out.contains("Cell A2\tCell B2"), "{out}");
}
let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() };
tbl.kind = ShapeKind::Table(table);
s.shapes.push(tbl);
p.slides = vec![std::sync::Arc::new(s)];
let out = slides_to_outline(&p);
assert!(out.contains("Text box body"), "{out}");
assert!(out.contains("Cell A1\tCell B1"), "{out}");
assert!(out.contains("Cell A2\tCell B2"), "{out}");
}
+1 -1
View File
@@ -22,4 +22,4 @@ log = { workspace = true }
image = { workspace = true }
# Audio decoding for the formats we have no decoder of our own for (MPL-2.0, used unmodified,
# as in FilmCraft).
symphonia = { version = "0.6.1", default-features = false, features = ["aac", "adpcm", "alac", "flac", "mp1", "mp2", "mp3", "pcm", "vorbis", "wav", "aiff", "caf", "ogg", "mkv", "isomp4"] }
symphonia = { version = "0.5.5", default-features = false, features = ["aac", "adpcm", "alac", "flac", "mp1", "mp2", "mp3", "pcm", "vorbis", "wav", "aiff", "caf", "ogg", "mkv", "isomp4"] }
+1 -1
View File
@@ -31,7 +31,7 @@ pub use audio::Pcm;
pub use clip::ClipParams;
pub use feed::VideoFeed;
pub use player::{AudioOut, PlayState, Player, VoiceSpec, VoiceStatus};
pub use probe::{AudioInfo, MediaInfo, VideoInfo, probe};
pub use probe::{AudioInfo, MediaInfo, VideoInfo, probe, probe_headers};
pub use video::{Frame, VideoDecoder, poster_frame, poster_png};
/// Shared media bytes (the model's `MediaItem::data`).
+1 -1
View File
@@ -254,7 +254,7 @@ impl Player {
}
fn with<R>(&self, f: impl FnOnce(&mut Mixer) -> R) -> R {
let mut g = self.mixer.lock().unwrap_or_else(|e| e.into_inner());
let mut g = self.mixer.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
f(&mut g)
}
+13 -6
View File
@@ -48,6 +48,19 @@ fn ms(secs: f64) -> u64 {
/// Probe `bytes`.
pub fn probe(bytes: &Bytes) -> Result<MediaInfo> {
let mut info = probe_headers(bytes)?;
if info.duration_ms == 0 && info.audio.as_ref().is_some_and(|a| a.decodable) && info.video.is_none() {
// No length in the header (raw MP3 / ADTS without an index): decode to measure.
if let Ok(pcm) = crate::audio::decode(bytes) {
info.duration_ms = ms(pcm.duration());
}
}
Ok(info)
}
/// [`probe`] from the headers alone, without decoding: a raw MP3 or ADTS stream without an
/// index reports a `duration_ms` of 0.
pub fn probe_headers(bytes: &Bytes) -> Result<MediaInfo> {
let c = Container::sniff(bytes);
let mut info = match c {
Container::Mp4 => probe_mp4(bytes)?,
@@ -73,12 +86,6 @@ pub fn probe(bytes: &Bytes) -> Result<MediaInfo> {
info.duration_ms = ms(n as f64 / rate as f64);
}
}
if info.duration_ms == 0 && info.audio.as_ref().is_some_and(|a| a.decodable) && info.video.is_none() {
// No length in the header (raw MP3 / ADTS without an index): decode to measure.
if let Ok(pcm) = crate::audio::decode(bytes) {
info.duration_ms = ms(pcm.duration());
}
}
Ok(info)
}
+1 -1
View File
@@ -1187,7 +1187,7 @@ fn image(r: Bytes) -> Option<ImageData> {
fn metafile(r: Bytes) -> Option<Picture> {
let size = r.u32(12)? as usize;
let data = r.slice(16, size).or_else(|| r.tail(16).map(|t| t.0))?;
let depth = NESTING.with(|n| n.get());
let depth = NESTING.with(std::cell::Cell::get);
if depth >= MAX_NESTING {
return None;
}
+1 -1
View File
@@ -1716,7 +1716,7 @@ fn plus_pic(recs: Vec<Vec<u8>>) -> Picture {
}
fn u16s(s: &str) -> Vec<u8> {
s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()
s.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
/// A 2 x 2 32bpp ARGB bitmap image object: red, green / blue, white.
+86 -56
View File
@@ -491,50 +491,66 @@ impl Default for Chart {
}
impl Chart {
fn sample_treemap_sunburst(kind: ChartType) -> Self {
let mut c = Chart {
kind,
categories: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(ToString::to_string).collect(),
category_levels: vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(ToString::to_string).collect()],
vary_colors: true,
..Default::default()
};
let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0];
c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
c
}
fn sample_observation_chart(kind: ChartType) -> Self {
let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0];
let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 };
let categories = if kind == ChartType::BoxWhisker {
(0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect()
} else {
vec![]
};
let mut c =
Chart { kind, categories, gridlines: true, legend: (kind == ChartType::BoxWhisker).then(|| "b".to_string()), ..Default::default() };
for s in 0..n_series {
c.series.push(Series {
name: format!("Series {}", s + 1),
values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(),
..Default::default()
});
}
c
}
fn sample_stock() -> Self {
let mut c = Chart {
kind: ChartType::Stock,
categories: ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(ToString::to_string).collect(),
..Default::default()
};
let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])];
for (name, v) in hlc {
c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
}
c
}
/// The sample data a new chart starts with (our own numbers).
pub fn sample(kind: ChartType) -> Self {
let cats = ["Q1", "Q2", "Q3", "Q4"];
let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]];
let mut c = Chart { kind, categories: cats.iter().map(|s| s.to_string()).collect(), ..Default::default() };
if matches!(kind, ChartType::Treemap | ChartType::Sunburst) {
// A two-level hierarchy: months inside quarters.
c.categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"].iter().map(|s| s.to_string()).collect();
c.category_levels = vec![["Q1", "Q1", "Q1", "Q2", "Q2", "Q2", "Q3", "Q3"].iter().map(|s| s.to_string()).collect()];
let v = [4.2, 2.8, 3.6, 4.7, 2.6, 4.1, 1.9, 3.0];
c.series.push(Series { name: "Series 1".into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
c.vary_colors = true;
return c;
return Self::sample_treemap_sunburst(kind);
}
if matches!(kind, ChartType::Histogram | ChartType::Pareto | ChartType::BoxWhisker) {
// These plot raw observations: a histogram bins one series, a box chart summarises
// each category's points.
let obs = [12.0, 15.0, 17.0, 18.0, 21.0, 22.0, 22.0, 24.0, 25.0, 27.0, 28.0, 31.0, 33.0, 36.0, 41.0, 48.0];
let n_series = if kind == ChartType::BoxWhisker { 2 } else { 1 };
c.categories = if kind == ChartType::BoxWhisker {
(0..obs.len()).map(|i| if i % 2 == 0 { "Group A" } else { "Group B" }.to_string()).collect()
} else {
vec![]
};
for s in 0..n_series {
c.series.push(Series {
name: format!("Series {}", s + 1),
values: obs.iter().map(|v| Some(v + s as f64 * 6.0)).collect(),
..Default::default()
});
}
c.gridlines = true;
c.legend = (kind == ChartType::BoxWhisker).then(|| "b".to_string());
return c;
return Self::sample_observation_chart(kind);
}
if kind == ChartType::Stock {
// High-low-close over five days, the series in the order PowerPoint expects.
c.categories = ["Mon", "Tue", "Wed", "Thu", "Fri"].iter().map(|s| s.to_string()).collect();
let hlc = [("High", [55.0, 57.0, 57.0, 58.0, 58.0]), ("Low", [11.0, 12.0, 13.0, 11.0, 25.0]), ("Close", [25.0, 38.0, 50.0, 35.0, 43.0])];
for (name, v) in hlc {
c.series.push(Series { name: name.into(), values: v.iter().map(|x| Some(*x)).collect(), ..Default::default() });
}
return c;
return Self::sample_stock();
}
let cats = ["Q1", "Q2", "Q3", "Q4"];
let data = [[4.2, 2.6, 2.1], [2.8, 4.1, 2.4], [3.6, 1.9, 3.3], [4.7, 3.0, 5.1]];
let mut c = Chart { kind, categories: cats.iter().map(ToString::to_string).collect(), ..Default::default() };
let n_series =
if matches!(kind, ChartType::Pie | ChartType::Doughnut | ChartType::Funnel | ChartType::Waterfall | ChartType::Treemap) { 1 } else { 3 };
for s in 0..n_series {
@@ -699,38 +715,52 @@ impl Chart {
crate::numfmt::format(code.map_or("General", String::as_str), v, self.lang.as_deref())
}
/// Value range across series (stacked sums for stacked types).
pub fn value_range(&self) -> (f64, f64) {
fn waterfall_value_range(&self) -> (f64, f64) {
let mut lo: f64 = 0.0;
let mut hi: f64 = 0.0;
if self.kind == ChartType::Waterfall {
for (a, b) in self.waterfall_bars() {
lo = lo.min(a.min(b));
hi = hi.max(a.max(b));
for (a, b) in self.waterfall_bars() {
lo = lo.min(a.min(b));
hi = hi.max(a.max(b));
}
(lo, hi)
}
fn stacked_value_range(&self, n: usize) -> (f64, f64) {
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 }
}
return (lo, hi);
hi = hi.max(p);
lo = lo.min(m);
}
(lo, hi)
}
/// Value range across series (stacked sums for stacked types).
pub fn value_range(&self) -> (f64, f64) {
if self.kind == ChartType::Waterfall {
return self.waterfall_value_range();
}
let n = self.categories.len().max(self.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
if self.kind.stacked() {
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 (mut lo, mut hi) = if self.kind.stacked() {
self.stacked_value_range(n)
} else {
let mut l: f64 = 0.0;
let mut h: f64 = 0.0;
for s in &self.series {
for v in s.values.iter().flatten() {
if v.is_finite() {
hi = hi.max(*v);
lo = lo.min(*v);
h = h.max(*v);
l = l.min(*v);
}
}
}
}
(l, h)
};
if self.kind.percent() {
return (0.0, 1.0);
}
+3 -3
View File
@@ -1066,13 +1066,13 @@ impl Presentation {
self.slides.iter().position(|s| s.id == id)
}
pub fn slide(&self, id: SlideId) -> Option<&Slide> {
self.slides.iter().find(|s| s.id == id).map(|s| s.as_ref())
self.slides.iter().find(|s| s.id == id).map(AsRef::as_ref)
}
pub fn slide_mut(&mut self, id: SlideId) -> Option<&mut Slide> {
self.slides.iter_mut().find(|s| s.id == id).map(Arc::make_mut)
}
pub fn slide_at(&self, i: usize) -> Option<&Slide> {
self.slides.get(i).map(|s| s.as_ref())
self.slides.get(i).map(AsRef::as_ref)
}
/// Master and layout for a layout id.
pub fn layout(&self, id: LayoutId) -> Option<(&Master, &Layout)> {
@@ -1086,7 +1086,7 @@ impl Presentation {
self.masters.first().map(|m| (m.as_ref(), m.layouts.first()))
}
pub fn first_master(&self) -> Option<&Master> {
self.masters.first().map(|m| m.as_ref())
self.masters.first().map(AsRef::as_ref)
}
pub fn media(&self, id: MediaId) -> Option<&MediaItem> {
self.media.iter().find(|m| m.id == id)
+1 -1
View File
@@ -275,7 +275,7 @@ pub enum Dash {
}
impl Dash {
pub const PRESETS: [&'static str; 11] =
pub const PRESETS: [&str; 11] =
["solid", "sysDot", "sysDash", "dash", "dashDot", "lgDash", "lgDashDot", "lgDashDotDot", "dot", "sysDashDot", "sysDashDotDot"];
/// Dash/space lengths in multiples of the line width.
pub fn pattern(&self) -> Vec<f64> {
+2 -2
View File
@@ -530,7 +530,7 @@ impl Paragraph {
true
}
pub fn text(&self) -> String {
self.runs.iter().map(|r| r.content()).collect()
self.runs.iter().map(Run::content).collect()
}
pub fn char_len(&self) -> usize {
self.runs.iter().map(Run::char_len).sum()
@@ -576,7 +576,7 @@ pub struct ListStyle {
impl ListStyle {
pub fn level(&self, l: u8) -> Option<&LevelStyle> {
self.levels.get(l as usize).and_then(|x| x.as_ref())
self.levels.get(l as usize).and_then(Option::as_ref)
}
pub fn set(&mut self, l: u8, s: LevelStyle) {
let i = (l as usize).min(8);
+87 -74
View File
@@ -57,22 +57,7 @@ impl Trendline {
let yhat: Vec<f64> = xs.iter().map(|x| poly_y(&coeff, *x)).collect();
(Model::Poly(coeff), ys, yhat)
}
TrendlineType::Exp => {
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(_, y)| *y > 0.0).collect();
if pairs.len() < 2 {
return None;
}
let (xs, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip();
let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln());
let (ln_c, b) = linreg(&xs, &ln_y, fixed)?;
let c = ln_c.exp();
if !c.is_finite() || c <= 0.0 {
return None;
}
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
let yhat: Vec<f64> = xs.iter().map(|x| c * (b * x).exp()).collect();
(Model::Exp { c, b }, ys, yhat)
}
TrendlineType::Exp => fit_exp(&pairs, intercept)?,
TrendlineType::Log => {
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, _)| *x > 0.0).collect();
if pairs.len() < 2 {
@@ -83,43 +68,10 @@ impl Trendline {
let yhat: Vec<f64> = ln_x.iter().map(|u| b + c * u).collect();
(Model::Log { b, c }, ys, yhat)
}
TrendlineType::Power => {
let pairs: Vec<(f64, f64)> = pairs.into_iter().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect();
if pairs.len() < 2 {
return None;
}
let (ln_x, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip();
let fixed = intercept.filter(|c| *c > 0.0).map(|c| c.ln());
let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?;
let c = ln_c.exp();
if !c.is_finite() || c <= 0.0 {
return None;
}
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
let yhat: Vec<f64> = pairs.iter().map(|(x, _)| c * x.powf(b)).collect();
(Model::Power { c, b }, ys, yhat)
}
TrendlineType::Power => fit_power(&pairs, intercept)?,
TrendlineType::MovingAvg => {
let period = self.period.unwrap_or(2).clamp(2, 255) as usize;
if pairs.len() <= period {
return None;
}
let mut pts = Vec::new();
let mut ys = Vec::new();
let mut yhat = Vec::new();
for i in period - 1..pairs.len() {
let window = &pairs[i + 1 - period..=i];
let avg = window.iter().map(|(_, y)| y).sum::<f64>() / period as f64;
if avg.is_finite() {
pts.push((pairs[i].0, avg));
ys.push(pairs[i].1);
yhat.push(avg);
}
}
if pts.len() < 2 {
return None;
}
(Model::Moving(pts), ys, yhat)
fit_moving_avg(&pairs, period)?
}
};
if yhat.iter().any(|v| !v.is_finite()) {
@@ -325,6 +277,89 @@ fn poly_y(coeff: &[f64], x: f64) -> f64 {
acc
}
fn fit_exp(pairs: &[(f64, f64)], intercept: Option<f64>) -> Option<(Model, Vec<f64>, Vec<f64>)> {
let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(_, y)| *y > 0.0).collect();
if pairs.len() < 2 {
return None;
}
let (xs, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (*x, y.ln())).unzip();
let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln);
let (ln_c, b) = linreg(&xs, &ln_y, fixed)?;
let c = ln_c.exp();
if !c.is_finite() || c <= 0.0 {
return None;
}
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
let yhat: Vec<f64> = xs.iter().map(|x| c * (b * x).exp()).collect();
Some((Model::Exp { c, b }, ys, yhat))
}
fn fit_power(pairs: &[(f64, f64)], intercept: Option<f64>) -> Option<(Model, Vec<f64>, Vec<f64>)> {
let pairs: Vec<(f64, f64)> = pairs.iter().copied().filter(|(x, y)| *x > 0.0 && *y > 0.0).collect();
if pairs.len() < 2 {
return None;
}
let (ln_x, ln_y): (Vec<f64>, Vec<f64>) = pairs.iter().map(|(x, y)| (x.ln(), y.ln())).unzip();
let fixed = intercept.filter(|c| *c > 0.0).map(f64::ln);
let (ln_c, b) = linreg(&ln_x, &ln_y, fixed)?;
let c = ln_c.exp();
if !c.is_finite() || c <= 0.0 {
return None;
}
let ys: Vec<f64> = pairs.iter().map(|(_, y)| *y).collect();
let yhat: Vec<f64> = pairs.iter().map(|(x, _)| c * x.powf(b)).collect();
Some((Model::Power { c, b }, ys, yhat))
}
fn fit_moving_avg(pairs: &[(f64, f64)], period: usize) -> Option<(Model, Vec<f64>, Vec<f64>)> {
if pairs.len() <= period {
return None;
}
let mut pts = Vec::new();
let mut ys = Vec::new();
let mut yhat = Vec::new();
for i in period - 1..pairs.len() {
let window = &pairs[i + 1 - period..=i];
let avg = window.iter().map(|(_, y)| y).sum::<f64>() / period as f64;
if avg.is_finite() {
pts.push((pairs[i].0, avg));
ys.push(pairs[i].1);
yhat.push(avg);
}
}
if pts.len() < 2 {
return None;
}
Some((Model::Moving(pts), ys, yhat))
}
#[allow(clippy::needless_range_loop)]
fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option<Vec<f64>> {
let m = order;
let mut ata = vec![vec![0.0; m]; m];
let mut atb = vec![0.0; m];
for (x, y) in xs.iter().zip(ys) {
let dy = y - b0;
for i in 0..m {
let xi = x.powi((i + 1) as i32);
atb[i] += dy * xi;
for j in i..m {
ata[i][j] += xi * x.powi((j + 1) as i32);
}
}
}
for i in 0..m {
for j in 0..i {
ata[i][j] = ata[j][i];
}
}
let solved = solve(ata, atb)?;
let mut coeff = Vec::with_capacity(order + 1);
coeff.push(b0);
coeff.extend(solved);
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
}
/// Polynomial through the normal equations. `x` is centred when the intercept is free, then
/// expanded back to powers of `x`, so order 6 on category numbers stays solvable.
#[allow(clippy::needless_range_loop)] // the index is the power in the normal equations
@@ -334,29 +369,7 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
return None;
}
if let Some(b0) = intercept {
let m = order;
let mut ata = vec![vec![0.0; m]; m];
let mut atb = vec![0.0; m];
for (x, y) in xs.iter().zip(ys) {
let dy = y - b0;
for i in 0..m {
let xi = x.powi((i + 1) as i32);
atb[i] += dy * xi;
for j in i..m {
ata[i][j] += xi * x.powi((j + 1) as i32);
}
}
}
for i in 0..m {
for j in 0..i {
ata[i][j] = ata[j][i];
}
}
let solved = solve(ata, atb)?;
let mut coeff = Vec::with_capacity(order + 1);
coeff.push(b0);
coeff.extend(solved);
return coeff.iter().all(|v| v.is_finite()).then_some(coeff);
return polyfit_with_intercept(xs, ys, order, b0);
}
let xm = xs.iter().sum::<f64>() / n as f64;
let us: Vec<f64> = xs.iter().map(|x| x - xm).collect();
+1 -1
View File
@@ -71,7 +71,7 @@ fn exp_rotation1(x: &mut [f32], len: usize, stride: usize, c: f32, s: f32) {
x[i + stride] = c * x2 + s * x1;
x[i] = c * x1 + ms * x2;
}
if len >= 2 * stride + 1 {
if len > 2 * stride {
for i in (0..=len - 2 * stride - 1).rev() {
let x1 = x[i];
let x2 = x[i + stride];
+1 -1
View File
@@ -578,7 +578,7 @@ fn tf_decode(start: usize, end: usize, is_transient: bool, tf_res: &mut [i32; NB
let mut budget = dec.storage() as i32 * 8;
let mut tell = dec.tell();
let mut logp: u32 = if is_transient { 2 } else { 4 };
let tf_select_rsv = lm > 0 && tell + logp as i32 + 1 <= budget;
let tf_select_rsv = lm > 0 && tell + (logp as i32) < budget;
budget -= tf_select_rsv as i32;
let mut tf_changed = 0;
let mut curr = 0;
+1 -1
View File
@@ -217,7 +217,7 @@ impl StreamDecoder {
}
}
}
let data_len = data.map_or(0, |d| d.len());
let data_len = data.map_or(0, <[_]>::len);
let mut dec_storage;
let mut dec: Option<&mut RangeDecoder> = match data {
Some(d) => {
+1 -1
View File
@@ -20,7 +20,7 @@ thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
hayro-syntax = { version = "=0.8.0", default-features = false, features = ["std"] }
hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
+29 -21
View File
@@ -131,6 +131,33 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
write::guard(|| export_inner(pres, opts))
}
fn plan_pages(pres: &Presentation, opts: &PdfOptions, slides: &[usize], sw: f64, sh: f64) -> Vec<Vec<(usize, Rect)>> {
match opts.layout {
PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(),
PageLayout::Notes => {
let b = notes_frames(pres, opts.paper).0;
slides.iter().map(|&i| vec![(i, b)]).collect()
}
PageLayout::Handouts { per_page } => {
let boxes = handout_boxes(per_page, opts.paper, sw / sh);
slides.chunks(boxes.len()).map(|c| c.iter().zip(&boxes).map(|(i, b)| (*i, *b)).collect()).collect()
}
}
}
fn build_outline(pres: &Presentation, pages: &[Vec<(usize, Rect)>]) -> Outline {
let mut outline = Outline::new();
for (p, v) in pages.iter().enumerate() {
for (i, _) in v {
let t = pres.slides[*i].title();
let t = t.lines().next().unwrap_or("").trim().to_string();
let label = if t.is_empty() { format!("Slide {}", i + 1) } else { format!("{}. {t}", i + 1) };
outline.push_child(OutlineNode::new(label, XyzDestination::new(p, Point::from_xy(0.0, 0.0))));
}
}
outline
}
fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
let slides: Vec<usize> = match &opts.slides {
Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(),
@@ -153,29 +180,10 @@ fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
let mut ex = Exporter { pres, opts, fonts: HashMap::new() };
// Page plan: per page, the slides on it with their boxes (page points).
let pages: Vec<Vec<(usize, Rect)>> = match opts.layout {
PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(),
PageLayout::Notes => {
let b = notes_frames(pres, opts.paper).0;
slides.iter().map(|&i| vec![(i, b)]).collect()
}
PageLayout::Handouts { per_page } => {
let boxes = handout_boxes(per_page, opts.paper, sw / sh);
slides.chunks(boxes.len()).map(|c| c.iter().zip(&boxes).map(|(i, b)| (*i, *b)).collect()).collect()
}
};
let pages = plan_pages(pres, opts, &slides, sw, sh);
// Bookmarks and slide → page lookup for internal links.
let page_of: HashMap<usize, usize> = pages.iter().enumerate().flat_map(|(p, v)| v.iter().map(move |(i, _)| (*i, p))).collect();
let mut outline = Outline::new();
for (p, v) in pages.iter().enumerate() {
for (i, _) in v {
let t = pres.slides[*i].title();
let t = t.lines().next().unwrap_or("").trim().to_string();
let label = if t.is_empty() { format!("Slide {}", i + 1) } else { format!("{}. {t}", i + 1) };
outline.push_child(OutlineNode::new(label, XyzDestination::new(p, Point::from_xy(0.0, 0.0))));
}
}
pdf.set_outline(outline);
pdf.set_outline(build_outline(pres, &pages));
for v in &pages {
let (pw, ph) = match opts.layout {
+1 -1
View File
@@ -76,7 +76,7 @@ impl Anim {
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
fn with<R>(f: impl FnOnce(&mut Cache) -> R) -> R {
let mut g = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let mut g = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
f(g.get_or_insert_with(Cache::default))
}
+2 -2
View File
@@ -1090,7 +1090,7 @@ fn shape_item(
for g in &glyphs {
let Some(&h) = char_at_byte.get(&g.cluster) else { continue };
let Some(cell) = cells.get_mut(h) else { continue };
if matches!(cell.ch, '\t' | '\u{b}' | '\n') {
if matches!(cell.ch, '\t'..='\u{b}') {
continue;
}
let s = scale_of(cell);
@@ -1738,7 +1738,7 @@ fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) {
let mut w = 0.0;
let mut dec = None;
for c in cells.iter().skip(i + 1) {
if matches!(c.ch, '\t' | '\u{b}' | '\n') {
if matches!(c.ch, '\t'..='\u{b}') {
break;
}
if c.ch == '.' && dec.is_none() {
+3 -4
View File
@@ -2027,7 +2027,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
ui.horizontal(|ui| {
if ui.button("+ Row").clicked() {
let n = grid.first().map(|r| r.len()).unwrap_or(2);
let n = grid.first().map(Vec::len).unwrap_or(2);
grid.push(vec![String::new(); n]);
}
if ui.button("+ Series").clicked() {
@@ -2943,7 +2943,7 @@ fn paragraph_return_params(d: &mut Dialog) -> Value {
let line_value = finite_pt(&d.get("lineValue", "1")).unwrap_or(1.0);
let before_same = d.get("spaceBefore", "") == d.get("spaceBeforeSeed", "");
let after_same = d.get("spaceAfter", "") == d.get("spaceAfterSeed", "");
let v = json!({
json!({
"align": d.get("align", "left"),
"indentLeft": before,
"indentRight": after,
@@ -2957,8 +2957,7 @@ fn paragraph_return_params(d: &mut Dialog) -> Value {
"lineValue": line_value,
"tabs": d.params.get("tabs").cloned().unwrap_or_else(|| json!([])),
"defaultTab": d.params.get("defaultTab").cloned().unwrap_or_else(|| json!(72.0)),
});
v
})
}
fn line_preset_label(kind: &str, value: &str) -> &'static str {
+34 -26
View File
@@ -1349,6 +1349,38 @@ pub fn table_grid_picker(ui: &mut Ui) -> Option<(usize, usize)> {
if resp.clicked() && hover.0 > 0 { Some(hover) } else { None }
}
fn render_icon_categories(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], color: Color32, hover: Color32) {
let mut cats: Vec<&str> = Vec::new();
for icon in icons {
if !cats.contains(&icon.category) {
cats.push(icon.category);
}
}
for cat in cats {
ui.label(egui::RichText::new(cat).small().strong());
ui.horizontal_wrapped(|ui| {
for icon in icons.iter().filter(|i| i.category == cat) {
icon_cell(app, ui, icon, color, hover);
}
});
}
}
fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_engine::icons::Icon], q: &str, color: Color32, hover: Color32) {
let mut n = 0;
ui.horizontal_wrapped(|ui| {
for icon in icons {
if icon.name.contains(q) || icon.label.to_lowercase().contains(q) {
icon_cell(app, ui, icon, color, hover);
n += 1;
}
}
});
if n == 0 {
ui.label("No icons match.");
}
}
/// Lucide stroke icons (Insert › Icons). Search filters the gallery; a click inserts the outline.
fn icon_library(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
@@ -1361,33 +1393,9 @@ fn icon_library(app: &mut SlideApp, ui: &mut Ui) {
let icons = deckcraft_engine::icons::icons();
egui::ScrollArea::vertical().id_salt("icon-scroll").max_height(420.0).show(ui, |ui| {
if q.is_empty() {
let mut cats: Vec<&str> = Vec::new();
for icon in icons {
if !cats.contains(&icon.category) {
cats.push(icon.category);
}
}
for cat in cats {
ui.label(egui::RichText::new(cat).small().strong());
ui.horizontal_wrapped(|ui| {
for icon in icons.iter().filter(|i| i.category == cat) {
icon_cell(app, ui, icon, color, hover);
}
});
}
render_icon_categories(app, ui, icons, color, hover);
} else {
let mut n = 0;
ui.horizontal_wrapped(|ui| {
for icon in icons {
if icon.name.contains(&q) || icon.label.to_lowercase().contains(&q) {
icon_cell(app, ui, icon, color, hover);
n += 1;
}
}
});
if n == 0 {
ui.label("No icons match.");
}
render_filtered_icons(app, ui, icons, &q, color, hover);
}
});
}
+10 -4
View File
@@ -19,6 +19,12 @@ set -euo pipefail
HERE="$ROOT/packaging/linux"
APP_ID=ai.storyteller.deckcraft
verify_sha256() {
local expected="$1"
local file="$2"
printf '%s %s\n' "$expected" "$file" | sha256sum -c -
}
SKIP_BUILD=0
FORMATS="appimage deb rpm tar"
while [[ $# -gt 0 ]]; do
@@ -137,21 +143,21 @@ if has appimage; then
download="$TOOL.download"
curl --proto '=https' -fsSL -o "$download" \
"https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage"
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c -
verify_sha256 "$APPIMAGETOOL_SHA256" "$download"
chmod 755 "$download"
mv "$download" "$TOOL"
fi
printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c -
verify_sha256 "$APPIMAGETOOL_SHA256" "$TOOL"
fi
RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH"
if [[ ! -f "$RUNTIME" ]]; then
download="$RUNTIME.download"
curl --proto '=https' -fsSL -o "$download" \
"https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH"
printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$download" | sha256sum -c -
verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$download"
mv "$download" "$RUNTIME"
fi
printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" | sha256sum -c -
verify_sha256 "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME"
# Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be
# relative).
OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
+1 -1
View File
@@ -51,7 +51,7 @@ const ASSET_EXT: &[&str] = &[
const ASSET_DIRS: &[&str] = &["assets/", "docs/images/", "docs/brand/", "examples/"];
pub fn is_asset(path: &str) -> bool {
let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
let ext = Path::new(path).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default();
ASSET_DIRS.iter().any(|d| path.starts_with(d)) || ASSET_EXT.contains(&ext.as_str())
}
+1 -1
View File
@@ -207,7 +207,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
}
}
}
out.sort_by_key(|v| v.to_string());
out.sort_by_key(ToString::to_string);
out.dedup();
out
}