mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 17:12:52 -06:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e6a461fe | ||
|
|
31a821436f | ||
|
|
8ec4f6cabb | ||
|
|
5a668bf02e |
+59
-29
@@ -1,5 +1,7 @@
|
|||||||
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
|
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
|
||||||
use deckcraft_model::resolve::{self, Ctx};
|
use deckcraft_model::resolve::{self, Ctx};
|
||||||
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
|
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
|
||||||
|
|
||||||
@@ -15,48 +17,66 @@ fn text_of(s: &Shape) -> String {
|
|||||||
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
|
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
|
||||||
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
|
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
|
||||||
/// once; top-level shapes only (groups move whole).
|
/// once; top-level shapes only (groups move whole).
|
||||||
|
///
|
||||||
|
/// Each pass compares a key of the two shapes, so the shapes of `b` are indexed by key once per
|
||||||
|
/// pass: matching costs the sum of the two shape lists, not their product.
|
||||||
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
|
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
|
||||||
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
|
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
|
||||||
let mut used_a = vec![false; a.shapes.len()];
|
let mut used_a = vec![false; a.shapes.len()];
|
||||||
let mut used_b = vec![false; b.shapes.len()];
|
let mut used_b = vec![false; b.shapes.len()];
|
||||||
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
|
// Each shape's text, worked out once rather than inside every comparison.
|
||||||
&|x, y| x.name.starts_with("!!") && x.name == y.name,
|
let text_a: Vec<String> = a.shapes.iter().map(text_of).collect();
|
||||||
&|x, y| !x.name.is_empty() && x.name == y.name,
|
let text_b: Vec<String> = b.shapes.iter().map(text_of).collect();
|
||||||
&|x, y| {
|
let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
|
||||||
if x.kind_name() != y.kind_name() {
|
let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
|
||||||
return false;
|
let same_text = |s: &Shape, t: &str| (!t.trim().is_empty()).then(|| MorphKey::Text(s.kind_name(), t.to_string()));
|
||||||
|
let same_geom = |s: &Shape, t: &str| match s.geom.preset_name() {
|
||||||
|
Some(g) if t.trim().is_empty() => Some(MorphKey::Geom(s.kind_name(), g.to_string())),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let passes: [&dyn Fn(&Shape, &str) -> Option<MorphKey>; 4] = [&forced, &named, &same_text, &same_geom];
|
||||||
|
for key in passes {
|
||||||
|
// Unused shapes of `b` by key, in slide order: the front of each queue is the first unused
|
||||||
|
// match, which is the one a search through `b` would find.
|
||||||
|
let mut index: HashMap<MorphKey, VecDeque<usize>> = HashMap::new();
|
||||||
|
for (j, sb) in b.shapes.iter().enumerate() {
|
||||||
|
if used_b.get(j).copied().unwrap_or(true) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
let tx = text_of(x);
|
if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
|
||||||
!tx.trim().is_empty() && tx == text_of(y)
|
index.entry(k).or_default().push_back(j);
|
||||||
},
|
}
|
||||||
&|x, y| {
|
}
|
||||||
x.kind_name() == y.kind_name()
|
if index.is_empty() {
|
||||||
&& x.geom.preset_name().is_some()
|
continue;
|
||||||
&& x.geom.preset_name() == y.geom.preset_name()
|
}
|
||||||
&& text_of(x).trim().is_empty()
|
|
||||||
&& text_of(y).trim().is_empty()
|
|
||||||
},
|
|
||||||
];
|
|
||||||
for pass in passes {
|
|
||||||
for (i, sa) in a.shapes.iter().enumerate() {
|
for (i, sa) in a.shapes.iter().enumerate() {
|
||||||
if used_a.get(i).copied().unwrap_or(true) {
|
if used_a.get(i).copied().unwrap_or(true) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
|
let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
|
||||||
if let Some((j, sb)) = found {
|
let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
|
||||||
if let Some(u) = used_a.get_mut(i) {
|
let Some(sb) = b.shapes.get(j) else { continue };
|
||||||
*u = true;
|
if let Some(u) = used_a.get_mut(i) {
|
||||||
}
|
*u = true;
|
||||||
if let Some(u) = used_b.get_mut(j) {
|
|
||||||
*u = true;
|
|
||||||
}
|
|
||||||
pairs.push((sa.id, sb.id));
|
|
||||||
}
|
}
|
||||||
|
if let Some(u) = used_b.get_mut(j) {
|
||||||
|
*u = true;
|
||||||
|
}
|
||||||
|
pairs.push((sa.id, sb.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pairs
|
pairs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What one [`morph_pairs`] pass matches shapes on.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||||
|
enum MorphKey {
|
||||||
|
Name(String),
|
||||||
|
Text(&'static str, String),
|
||||||
|
Geom(&'static str, String),
|
||||||
|
}
|
||||||
|
|
||||||
fn lerp(a: f64, b: f64, t: f64) -> f64 {
|
fn lerp(a: f64, b: f64, t: f64) -> f64 {
|
||||||
let v = a + (b - a) * t;
|
let v = a + (b - a) * t;
|
||||||
if v.is_finite() {
|
if v.is_finite() {
|
||||||
@@ -256,13 +276,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
|||||||
f.shapes.push((sh, old));
|
f.shapes.push((sh, old));
|
||||||
id
|
id
|
||||||
};
|
};
|
||||||
|
// Lookups by id, built once (the first shape or pair with an id wins, as a search would).
|
||||||
|
let paired_a: HashSet<ShapeId> = pairs.iter().map(|(ia, _)| *ia).collect();
|
||||||
|
let mut partner: HashMap<ShapeId, ShapeId> = HashMap::with_capacity(pairs.len());
|
||||||
|
for (ia, ib) in &pairs {
|
||||||
|
partner.entry(*ib).or_insert(*ia);
|
||||||
|
}
|
||||||
|
let mut a_by_id: HashMap<ShapeId, &Shape> = HashMap::with_capacity(a.shapes.len());
|
||||||
|
for x in &a.shapes {
|
||||||
|
a_by_id.entry(x.id).or_insert(x);
|
||||||
|
}
|
||||||
for sa in &a.shapes {
|
for sa in &a.shapes {
|
||||||
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
|
if !paired_a.contains(&sa.id) {
|
||||||
push(sa.clone(), true, 1.0 - s);
|
push(sa.clone(), true, 1.0 - s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for sb in &b.shapes {
|
for sb in &b.shapes {
|
||||||
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
|
let pair = partner.get(&sb.id).and_then(|ia| a_by_id.get(ia).copied());
|
||||||
let Some(sa) = pair else {
|
let Some(sa) = pair else {
|
||||||
if !hidden_b(sb.id) {
|
if !hidden_b(sb.id) {
|
||||||
push(sb.clone(), false, s);
|
push(sb.clone(), false, s);
|
||||||
|
|||||||
@@ -1155,3 +1155,58 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
|
|||||||
assert_eq!(tl.effects().len(), 6_000);
|
assert_eq!(tl.effects().len(), 6_000);
|
||||||
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
|
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn morph_pairs_take_the_first_unused_match_in_slide_order() {
|
||||||
|
let mut a = Slide::default();
|
||||||
|
let mut b = Slide::default();
|
||||||
|
a.shapes = vec![shape(1, "Same"), shape(2, "Same"), shape(3, "Other")];
|
||||||
|
b.shapes = vec![shape(11, "Same"), shape(12, "Same"), shape(13, "Same")];
|
||||||
|
// By name first, in slide order; the shape of `a` left over pairs by kind and geometry with
|
||||||
|
// the shape of `b` left over.
|
||||||
|
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(11)), (ShapeId(2), ShapeId(12)), (ShapeId(3), ShapeId(13))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn morph_of_large_slides_stays_linear() {
|
||||||
|
let n = 20_000u32;
|
||||||
|
let text = |i: u32, side: &str| Some(TextBody::from_text(&format!("{side} {i}")));
|
||||||
|
let mut a = Slide::default();
|
||||||
|
let mut b = Slide::default();
|
||||||
|
// Text no shape on the other slide shares: every pass looks for a partner and finds none.
|
||||||
|
a.shapes = (0..n).map(|i| Shape { text: text(i, "a"), ..shape(i + 1, "") }).collect();
|
||||||
|
b.shapes = (0..n).map(|i| Shape { text: text(i, "b"), ..shape(n + i + 1, "") }).collect();
|
||||||
|
// One named pair at opposite ends.
|
||||||
|
a.shapes[0].name = "Logo".into();
|
||||||
|
b.shapes[n as usize - 1].name = "Logo".into();
|
||||||
|
// No wall-clock bound (it would flake on a loaded runner): at this size a quadratic pairing
|
||||||
|
// takes minutes, so a regression shows as a test that hangs rather than a failed assertion.
|
||||||
|
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2 * n))]);
|
||||||
|
let p = deckcraft_model::defaults::new_presentation(None);
|
||||||
|
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||||
|
// Every shape fades in place; the named pair differs in text, so it cross-fades as two.
|
||||||
|
assert_eq!(f.shapes.len(), 2 * n as usize);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paragraph_states_of_a_large_build_stay_linear() {
|
||||||
|
let n = 20_000usize;
|
||||||
|
let mut s = slide_with(vec![]);
|
||||||
|
let mut body = TextBody::from_text(&vec!["line"; n].join("\n"));
|
||||||
|
// One first-level paragraph owning every other paragraph.
|
||||||
|
for p in body.paragraphs.iter_mut().skip(1) {
|
||||||
|
p.level = 1;
|
||||||
|
}
|
||||||
|
s.shapes[0].text = Some(body);
|
||||||
|
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
|
||||||
|
a.text_build = TextBuild::ByParagraph;
|
||||||
|
s.animations = vec![a];
|
||||||
|
let tl = Timeline::new(&s, W, H);
|
||||||
|
// No wall-clock bound, as above: a per-paragraph scan of the slide would take minutes here.
|
||||||
|
assert_eq!(tl.paragraph_targets(ShapeId(1)).len(), n);
|
||||||
|
for p in 0..n {
|
||||||
|
assert!(!tl.state(ShapeId(1), Some(p), 0, 0.0).visible);
|
||||||
|
assert!(tl.state(ShapeId(1), Some(p), 1, 0.0).visible);
|
||||||
|
}
|
||||||
|
assert_eq!(tl.animated_shapes(), [ShapeId(1)]);
|
||||||
|
}
|
||||||
|
|||||||
+47
-12
@@ -103,6 +103,41 @@ pub struct Timeline {
|
|||||||
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
|
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
|
||||||
/// (shape, member paragraph, owner paragraph).
|
/// (shape, member paragraph, owner paragraph).
|
||||||
groups: Vec<(ShapeId, usize, usize)>,
|
groups: Vec<(ShapeId, usize, usize)>,
|
||||||
|
/// Lookups built with the timeline, so evaluating a shape or paragraph costs its own effects
|
||||||
|
/// rather than a scan of every effect, group and colour on the slide.
|
||||||
|
index: TimelineIndex,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
struct TimelineIndex {
|
||||||
|
/// Effects (indices into `effects`, in order) by target shape and paragraph.
|
||||||
|
by_target: HashMap<(ShapeId, Option<usize>), Vec<usize>>,
|
||||||
|
/// Effects (indices into `effects`, in order) by target shape.
|
||||||
|
by_shape: HashMap<ShapeId, Vec<usize>>,
|
||||||
|
/// Owner paragraph of each member paragraph (the first group recorded wins).
|
||||||
|
owner: HashMap<(ShapeId, usize), usize>,
|
||||||
|
/// Groups (member, owner) by shape, in order.
|
||||||
|
groups_by_shape: HashMap<ShapeId, Vec<(usize, usize)>>,
|
||||||
|
/// Base colour by shape (the first recorded wins).
|
||||||
|
color: HashMap<ShapeId, Rgba>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TimelineIndex {
|
||||||
|
fn build(effects: &[Effect], groups: &[(ShapeId, usize, usize)], colors: &[(ShapeId, Rgba)]) -> TimelineIndex {
|
||||||
|
let mut ix = TimelineIndex::default();
|
||||||
|
for (i, e) in effects.iter().enumerate() {
|
||||||
|
ix.by_target.entry((e.timing.shape, e.timing.paragraph)).or_default().push(i);
|
||||||
|
ix.by_shape.entry(e.timing.shape).or_default().push(i);
|
||||||
|
}
|
||||||
|
for &(shape, member, owner) in groups {
|
||||||
|
ix.owner.entry((shape, member)).or_insert(owner);
|
||||||
|
ix.groups_by_shape.entry(shape).or_default().push((member, owner));
|
||||||
|
}
|
||||||
|
for &(shape, c) in colors {
|
||||||
|
ix.color.entry(shape).or_insert(c);
|
||||||
|
}
|
||||||
|
ix
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Status {
|
enum Status {
|
||||||
@@ -324,6 +359,7 @@ impl Timeline {
|
|||||||
};
|
};
|
||||||
tl.colors.push((id, c));
|
tl.colors.push((id, c));
|
||||||
}
|
}
|
||||||
|
tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
|
||||||
tl
|
tl
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,8 +449,12 @@ impl Timeline {
|
|||||||
|
|
||||||
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
|
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
|
||||||
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
|
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
|
||||||
let mut v: Vec<usize> = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
|
let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
|
||||||
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>());
|
let mut v: Vec<usize> = mine.iter().filter_map(|&i| self.effects.get(i)).filter_map(|e| e.timing.paragraph).collect();
|
||||||
|
let owners: HashSet<usize> = v.iter().copied().collect();
|
||||||
|
if let Some(groups) = self.index.groups_by_shape.get(&shape) {
|
||||||
|
v.extend(groups.iter().filter(|(_, owner)| owners.contains(owner)).map(|(member, _)| *member));
|
||||||
|
}
|
||||||
v.sort_unstable();
|
v.sort_unstable();
|
||||||
v.dedup();
|
v.dedup();
|
||||||
v
|
v
|
||||||
@@ -422,13 +462,8 @@ impl Timeline {
|
|||||||
|
|
||||||
/// Shapes with any effect.
|
/// Shapes with any effect.
|
||||||
pub fn animated_shapes(&self) -> Vec<ShapeId> {
|
pub fn animated_shapes(&self) -> Vec<ShapeId> {
|
||||||
let mut v: Vec<ShapeId> = Vec::new();
|
let mut seen: HashSet<ShapeId> = HashSet::new();
|
||||||
for e in &self.effects {
|
self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
|
||||||
if !v.contains(&e.timing.shape) {
|
|
||||||
v.push(e.timing.shape);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
v
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
|
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
|
||||||
@@ -456,9 +491,9 @@ impl Timeline {
|
|||||||
triggers: &[TriggerPlay],
|
triggers: &[TriggerPlay],
|
||||||
stamps: &[StepStamp],
|
stamps: &[StepStamp],
|
||||||
) -> AnimState {
|
) -> AnimState {
|
||||||
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
|
let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
|
||||||
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
|
let mine = self.index.by_target.get(&(shape, para)).map_or(&[][..], Vec::as_slice).iter().filter_map(|&i| self.effects.get(i));
|
||||||
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||||
let mut st = AnimState::default();
|
let mut st = AnimState::default();
|
||||||
// Initial visibility comes from the first effect in the Animation Pane order.
|
// Initial visibility comes from the first effect in the Animation Pane order.
|
||||||
let mut ordered: Vec<&Effect> = mine.collect();
|
let mut ordered: Vec<&Effect> = mine.collect();
|
||||||
|
|||||||
@@ -85,14 +85,20 @@ impl AutomationRoots {
|
|||||||
|
|
||||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||||
|
self.check_path(Path::new(path), access)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
|
||||||
|
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
|
||||||
|
let path_text = path.display();
|
||||||
let roots = match access {
|
let roots = match access {
|
||||||
Access::Read => &self.read,
|
Access::Read => &self.read,
|
||||||
Access::Write => &self.write,
|
Access::Write => &self.write,
|
||||||
};
|
};
|
||||||
if roots.is_empty() {
|
if roots.is_empty() {
|
||||||
return match access {
|
return match access {
|
||||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
|
||||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
let resolved = resolve(path)?;
|
let resolved = resolve(path)?;
|
||||||
@@ -101,8 +107,8 @@ impl AutomationRoots {
|
|||||||
}
|
}
|
||||||
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
||||||
match access {
|
match access {
|
||||||
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
|
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
|
||||||
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
|
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,17 +197,23 @@ pub fn check_write(path: &str) -> Result<(), String> {
|
|||||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
|
||||||
|
pub fn check_write_path(path: &Path) -> Result<(), String> {
|
||||||
|
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
|
||||||
|
}
|
||||||
|
|
||||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||||
/// existing folder's links followed and the rest appended.
|
/// existing folder's links followed and the rest appended.
|
||||||
fn resolve(path: &str) -> Result<PathBuf, String> {
|
fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||||
if path.is_empty() {
|
if path.as_os_str().is_empty() {
|
||||||
return Err("automation path rejected: path is empty".into());
|
return Err("automation path rejected: path is empty".into());
|
||||||
}
|
}
|
||||||
|
let shown = path.display();
|
||||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||||
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||||
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
|
||||||
}
|
}
|
||||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
|
||||||
// The names below the nearest existing folder, last first.
|
// The names below the nearest existing folder, last first.
|
||||||
let mut missing: Vec<OsString> = vec![];
|
let mut missing: Vec<OsString> = vec![];
|
||||||
let mut at = absolute.as_path();
|
let mut at = absolute.as_path();
|
||||||
@@ -212,11 +224,11 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
|
|||||||
return Ok(found);
|
return Ok(found);
|
||||||
}
|
}
|
||||||
Err(e) => match std::fs::symlink_metadata(at) {
|
Err(e) => match std::fs::symlink_metadata(at) {
|
||||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
|
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
||||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
let parent = at.parent();
|
let parent = at.parent();
|
||||||
@@ -226,12 +238,12 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
|
|||||||
at = parent;
|
at = parent;
|
||||||
}
|
}
|
||||||
(Some(Component::ParentDir), _) => {
|
(Some(Component::ParentDir), _) => {
|
||||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
|
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
||||||
}
|
}
|
||||||
// Down to the root (or drive) and it doesn't exist either.
|
// Down to the root (or drive) and it doesn't exist either.
|
||||||
_ => {
|
_ => {
|
||||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||||
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
|
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -668,6 +668,8 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
|
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
|
||||||
|
// A confined automation client may write only inside its write roots, this file included.
|
||||||
|
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||||
if let Some(d) = path.parent() {
|
if let Some(d) = path.parent() {
|
||||||
std::fs::create_dir_all(d)?;
|
std::fs::create_dir_all(d)?;
|
||||||
}
|
}
|
||||||
@@ -953,6 +955,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
|||||||
assert_eq!(w, vec!["check", "done"]);
|
assert_eq!(w, vec!["check", "done"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
|
||||||
|
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&root).unwrap();
|
||||||
|
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
|
||||||
|
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
|
||||||
|
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
|
||||||
|
set_user_dictionary_path(outside.join("user.dic"));
|
||||||
|
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
|
||||||
|
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
|
||||||
|
assert!(!outside.exists(), "the refused write created nothing");
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn long_and_numeric_flags_and_latin1() {
|
fn long_and_numeric_flags_and_latin1() {
|
||||||
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
|
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
|
||||||
|
|||||||
+161
-38
@@ -37,56 +37,146 @@ pub struct ShapeParams<'a> {
|
|||||||
pub language: Option<&'a str>,
|
pub language: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Most bytes of text given to the shaper at once. The shaper lets one call grow its buffers to
|
||||||
|
/// 256 glyphs per character (a font's substitutions decide how many), so a long item is shaped in
|
||||||
|
/// pieces of this size, each with the rest of the paragraph as context.
|
||||||
|
const MAX_SHAPE_CHUNK: usize = 1024;
|
||||||
|
/// Most glyphs kept per character of a piece: a piece the font expands further is laid out one
|
||||||
|
/// glyph per character from the cmap instead.
|
||||||
|
const MAX_GLYPHS_PER_CHAR: usize = 32;
|
||||||
|
|
||||||
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
|
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
|
||||||
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
|
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
|
||||||
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
|
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
|
||||||
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||||
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
|
let (Some(_), Some(text), Some(_)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let mut out = Vec::with_capacity(text.len());
|
let chunks = chunks(text, MAX_SHAPE_CHUNK);
|
||||||
|
let mut pieces: Vec<Vec<ShapedGlyph>> = Vec::with_capacity(chunks.len());
|
||||||
let shaped = face.hb().map(|hb| {
|
let shaped = face.hb().map(|hb| {
|
||||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||||
let mut buf = UnicodeBuffer::new();
|
// Pieces of one item share the script the whole item would be guessed to have.
|
||||||
buf.set_pre_context(pre);
|
let item_script = (chunks.len() > 1 && params.script.is_none()).then(|| {
|
||||||
buf.set_post_context(post);
|
let mut probe = UnicodeBuffer::new();
|
||||||
for (i, c) in text.char_indices() {
|
for c in text.chars() {
|
||||||
buf.add(map(c), (range.start + i) as u32);
|
probe.add(map(c), 0);
|
||||||
}
|
}
|
||||||
buf.guess_segment_properties();
|
probe.guess_segment_properties();
|
||||||
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
probe.script()
|
||||||
buf.set_script(s);
|
});
|
||||||
}
|
for c in &chunks {
|
||||||
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
|
let r = (range.start + c.start)..(range.start + c.end);
|
||||||
{
|
let (Some(pre), Some(piece), Some(post)) = (para.get(..r.start), para.get(r.clone()), para.get(r.end..)) else { continue };
|
||||||
buf.set_language(l);
|
let mut buf = UnicodeBuffer::new();
|
||||||
}
|
buf.set_pre_context(pre);
|
||||||
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
buf.set_post_context(post);
|
||||||
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
|
for (i, c) in piece.char_indices() {
|
||||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
buf.add(map(c), (r.start + i) as u32);
|
||||||
out.push(ShapedGlyph {
|
}
|
||||||
gid: info.glyph_id,
|
buf.guess_segment_properties();
|
||||||
cluster: info.cluster as usize,
|
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
||||||
x_advance: pos.x_advance,
|
buf.set_script(s);
|
||||||
x_offset: pos.x_offset,
|
} else if let Some(s) = item_script {
|
||||||
y_offset: pos.y_offset,
|
buf.set_script(s);
|
||||||
});
|
}
|
||||||
|
if let Some(l) =
|
||||||
|
params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
|
||||||
|
{
|
||||||
|
buf.set_language(l);
|
||||||
|
}
|
||||||
|
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||||
|
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
|
||||||
|
let budget = piece.chars().count().saturating_mul(MAX_GLYPHS_PER_CHAR);
|
||||||
|
if gb.len() > budget {
|
||||||
|
// Font-controlled expansion past anything real text needs: keep it bounded.
|
||||||
|
pieces.push(cmap_glyphs(face, para, r, params.rtl, &map));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pieces.push(
|
||||||
|
gb.glyph_infos()
|
||||||
|
.iter()
|
||||||
|
.zip(gb.glyph_positions())
|
||||||
|
.map(|(info, pos)| ShapedGlyph {
|
||||||
|
gid: info.glyph_id,
|
||||||
|
cluster: info.cluster as usize,
|
||||||
|
x_advance: pos.x_advance,
|
||||||
|
x_offset: pos.x_offset,
|
||||||
|
y_offset: pos.y_offset,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if shaped.is_none()
|
if shaped.is_none() {
|
||||||
&& let Some(f) = face.skrifa()
|
|
||||||
{
|
|
||||||
// No shaper for this face: one glyph per character from the cmap, in visual order.
|
// No shaper for this face: one glyph per character from the cmap, in visual order.
|
||||||
let cmap = f.charmap();
|
return cmap_glyphs(face, para, range, params.rtl, &map);
|
||||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
}
|
||||||
for (i, c) in text.char_indices() {
|
// Each piece is in visual order; right to left, the later pieces come first.
|
||||||
let g = cmap.map(map(c)).unwrap_or_default();
|
if params.rtl {
|
||||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
pieces.reverse();
|
||||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
}
|
||||||
}
|
let mut out = Vec::with_capacity(pieces.iter().map(Vec::len).sum());
|
||||||
if params.rtl {
|
for p in pieces {
|
||||||
out.reverse();
|
out.extend(p);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One glyph per character of `para[range]` from the cmap, in visual order.
|
||||||
|
fn cmap_glyphs(face: &FontFace, para: &str, range: Range<usize>, rtl: bool, map: &impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||||
|
let (Some(text), Some(f)) = (para.get(range.clone()), face.skrifa()) else { return Vec::new() };
|
||||||
|
let cmap = f.charmap();
|
||||||
|
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||||
|
let mut out = Vec::with_capacity(text.len());
|
||||||
|
for (i, c) in text.char_indices() {
|
||||||
|
let g = cmap.map(map(c)).unwrap_or_default();
|
||||||
|
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||||
|
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||||
|
}
|
||||||
|
if rtl {
|
||||||
|
out.reverse();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` cut into byte ranges of at most `max` bytes (or one character, if longer), at the last
|
||||||
|
/// space of a piece where there is one, else before a character that starts a cluster (not a
|
||||||
|
/// combining mark or joiner, not after a joiner). Text that fits is one piece.
|
||||||
|
fn chunks(text: &str, max: usize) -> Vec<Range<usize>> {
|
||||||
|
use unicode_bidi::BidiClass::{BN, NSM};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut start = 0;
|
||||||
|
while text.len() - start > max {
|
||||||
|
let window = text.get(start..).unwrap_or_default();
|
||||||
|
let mut space = None;
|
||||||
|
let mut boundary = None;
|
||||||
|
let mut any = None;
|
||||||
|
let mut prev: Option<char> = None;
|
||||||
|
for (i, c) in window.char_indices() {
|
||||||
|
if i > max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
any = Some(i);
|
||||||
|
let joined = prev.is_some_and(|p| matches!(unicode_bidi::bidi_class(p), BN));
|
||||||
|
if !joined && !matches!(unicode_bidi::bidi_class(c), NSM | BN) {
|
||||||
|
boundary = Some(i);
|
||||||
|
if prev.is_some_and(char::is_whitespace) {
|
||||||
|
space = Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = Some(c);
|
||||||
}
|
}
|
||||||
|
// A piece always moves forward by at least one character.
|
||||||
|
let first = window.chars().next().map_or(window.len(), char::len_utf8);
|
||||||
|
let cut = space.filter(|&i| i >= max / 2).or(boundary).or(any).unwrap_or(first).max(first);
|
||||||
|
out.push(start..start + cut);
|
||||||
|
start += cut;
|
||||||
|
}
|
||||||
|
if start < text.len() || out.is_empty() {
|
||||||
|
out.push(start..text.len());
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -121,4 +211,37 @@ mod tests {
|
|||||||
// Not a char boundary.
|
// Not a char boundary.
|
||||||
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
|
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_items_are_shaped_in_bounded_pieces() {
|
||||||
|
for text in ["word ".repeat(2_000), "e\u{301}".repeat(2_000), "x".repeat(5_000), "\u{1f469}\u{200d}\u{1f4bb} ".repeat(500)] {
|
||||||
|
let pieces = chunks(&text, MAX_SHAPE_CHUNK);
|
||||||
|
assert_eq!(pieces.first().map(|r| r.start), Some(0));
|
||||||
|
assert_eq!(pieces.last().map(|r| r.end), Some(text.len()));
|
||||||
|
for w in pieces.windows(2) {
|
||||||
|
assert_eq!(w[0].end, w[1].start);
|
||||||
|
}
|
||||||
|
for r in &pieces {
|
||||||
|
assert!(!r.is_empty() && r.len() <= MAX_SHAPE_CHUNK);
|
||||||
|
// Never before a combining mark, never after a joiner.
|
||||||
|
let rest = &text[r.end..];
|
||||||
|
assert!(!rest.starts_with('\u{301}'));
|
||||||
|
assert!(!text[..r.end].ends_with('\u{200d}') || rest.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(chunks("short", MAX_SHAPE_CHUNK), vec![0..5]);
|
||||||
|
assert_eq!(chunks("", MAX_SHAPE_CHUNK), vec![0..0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_items_keep_every_cluster_in_visual_order() {
|
||||||
|
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||||
|
let text = "ab cd ".repeat(1_000);
|
||||||
|
let g = shape_range(&face, &text, 0..text.len(), &ShapeParams::default(), |c| c);
|
||||||
|
assert_eq!(g.len(), text.len());
|
||||||
|
assert!(g.windows(2).all(|w| w[0].cluster < w[1].cluster));
|
||||||
|
let rtl = shape_range(&face, &text, 0..text.len(), &ShapeParams { rtl: true, ..Default::default() }, |c| c);
|
||||||
|
assert_eq!(rtl.len(), text.len());
|
||||||
|
assert!(rtl.windows(2).all(|w| w[0].cluster > w[1].cluster));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user