mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 17:12:52 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0df3e034 |
+18
-112
@@ -121,10 +121,9 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
|
||||
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
|
||||
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
|
||||
let mut out = vec![];
|
||||
let mut media = Media::new(cur, rev);
|
||||
for (i, j) in &pairs {
|
||||
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
|
||||
let details = slide_details(cur, a, rev, b, &mut media);
|
||||
let details = slide_details(cur, a, rev, b);
|
||||
if !details.is_empty() {
|
||||
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
|
||||
}
|
||||
@@ -141,18 +140,10 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
|
||||
});
|
||||
}
|
||||
}
|
||||
// Each revised slide's open match (the first pair naming it).
|
||||
let mut rev_to_cur: HashMap<usize, usize> = HashMap::new();
|
||||
for p in &pairs {
|
||||
rev_to_cur.entry(p.1).or_insert(p.0);
|
||||
}
|
||||
// After the open slide that matches the nearest matched revised slide before it.
|
||||
let mut at = 0;
|
||||
for (j, s) in rev.slides.iter().enumerate() {
|
||||
if let Some(i) = rev_to_cur.get(&j) {
|
||||
at = i + 1;
|
||||
}
|
||||
if !rev_matched.contains(&j) {
|
||||
// After the open slide that matches the revised slide before it.
|
||||
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
|
||||
out.push(Change {
|
||||
kind: ChangeKind::Inserted,
|
||||
slide: at,
|
||||
@@ -183,34 +174,8 @@ fn shape_label(s: &Shape) -> String {
|
||||
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
|
||||
}
|
||||
|
||||
/// Both decks' media bytes by id, and which picture pairs were already compared: a deck can
|
||||
/// show one picture many times, and its bytes are compared once per pair.
|
||||
struct Media<'a> {
|
||||
cur: HashMap<MediaId, &'a [u8]>,
|
||||
rev: HashMap<MediaId, &'a [u8]>,
|
||||
same: HashMap<(MediaId, MediaId), bool>,
|
||||
}
|
||||
|
||||
impl<'a> Media<'a> {
|
||||
fn new(cur: &'a Presentation, rev: &'a Presentation) -> Self {
|
||||
// The first item with an id wins, as in `Presentation::media`.
|
||||
let index = |p: &'a Presentation| {
|
||||
let mut m = HashMap::new();
|
||||
for x in &p.media {
|
||||
m.entry(x.id).or_insert(x.data.as_slice());
|
||||
}
|
||||
m
|
||||
};
|
||||
Media { cur: index(cur), rev: index(rev), same: HashMap::new() }
|
||||
}
|
||||
/// Whether the pictures (by media id, `None` for a shape that is not a picture) differ.
|
||||
fn differ(&mut self, a: Option<MediaId>, b: Option<MediaId>) -> bool {
|
||||
let (ba, bb) = (a.and_then(|x| self.cur.get(&x)), b.and_then(|y| self.rev.get(&y)));
|
||||
match (a, b) {
|
||||
(Some(x), Some(y)) if ba.is_some() && bb.is_some() => !*self.same.entry((x, y)).or_insert_with(|| ba == bb),
|
||||
_ => ba.is_some() || bb.is_some(),
|
||||
}
|
||||
}
|
||||
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
|
||||
p.media(id).map(|m| m.data.as_slice())
|
||||
}
|
||||
|
||||
/// A shape without its ids (shape and media), for comparing across decks.
|
||||
@@ -239,7 +204,7 @@ fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
|
||||
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
|
||||
}
|
||||
|
||||
fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec<String>) {
|
||||
fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec<String>) {
|
||||
let label = shape_label(b);
|
||||
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
|
||||
let mut what = vec![];
|
||||
@@ -269,11 +234,11 @@ fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media
|
||||
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
|
||||
what.push("formatting");
|
||||
}
|
||||
let pic = |s: &Shape| match &s.kind {
|
||||
ShapeKind::Picture { fill } => Some(fill.media),
|
||||
let pic = |p: &Presentation, s: &Shape| match &s.kind {
|
||||
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
|
||||
_ => None,
|
||||
};
|
||||
if media.differ(pic(a), pic(b)) {
|
||||
if pic(cp, a) != pic(rp, b) {
|
||||
what.push("picture");
|
||||
}
|
||||
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
|
||||
@@ -300,32 +265,21 @@ fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media
|
||||
}
|
||||
}
|
||||
|
||||
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec<String> {
|
||||
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut used: HashSet<usize> = HashSet::new();
|
||||
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
|
||||
// The revised shapes by id (first wins) and by name (in order, with a cursor past used ones).
|
||||
let mut by_id: HashMap<ShapeId, usize> = HashMap::new();
|
||||
let mut by_name: HashMap<&str, (Vec<usize>, usize)> = HashMap::new();
|
||||
for (k, x) in b.shapes.iter().enumerate() {
|
||||
by_id.entry(x.id).or_insert(k);
|
||||
if !x.name.is_empty() {
|
||||
by_name.entry(x.name.as_str()).or_default().0.push(k);
|
||||
}
|
||||
}
|
||||
for sa in &a.shapes {
|
||||
let k = by_id.get(&sa.id).copied().or_else(|| {
|
||||
let (ks, next) = by_name.get_mut(sa.name.as_str())?;
|
||||
while ks.get(*next).is_some_and(|k| used.contains(k)) {
|
||||
*next += 1;
|
||||
}
|
||||
ks.get(*next).copied()
|
||||
});
|
||||
let k = b
|
||||
.shapes
|
||||
.iter()
|
||||
.position(|x| x.id == sa.id)
|
||||
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
|
||||
match k {
|
||||
Some(k) if !used.contains(&k) => {
|
||||
used.insert(k);
|
||||
if let Some(sb) = b.shapes.get(k) {
|
||||
shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out);
|
||||
shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out);
|
||||
}
|
||||
}
|
||||
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
|
||||
@@ -380,34 +334,19 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
let mut sl = slide.clone();
|
||||
// Media.
|
||||
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
|
||||
// Both decks' media by id (the first item with an id wins, as in `Presentation::media`),
|
||||
// looked up once per reference instead of searched.
|
||||
let mut rev_at: HashMap<MediaId, usize> = HashMap::new();
|
||||
for (i, m) in rev.media.iter().enumerate() {
|
||||
rev_at.entry(m.id).or_insert(i);
|
||||
}
|
||||
let mut doc_at: HashMap<MediaId, usize> = HashMap::new();
|
||||
for (i, m) in doc.media.iter().enumerate() {
|
||||
doc_at.entry(m.id).or_insert(i);
|
||||
}
|
||||
for id in crate::media_refs::slide_media(&sl) {
|
||||
let Some(m) = rev_at.get(&id).and_then(|&i| rev.media.get(i)) else { continue };
|
||||
match doc_at.get(&id).and_then(|&i| doc.media.get(i)) {
|
||||
let Some(m) = rev.media(id) else { continue };
|
||||
match doc.media(id) {
|
||||
Some(x) if x.data == m.data => {}
|
||||
None => {
|
||||
// Keeps the revision's id, so later ids must start past it.
|
||||
doc_at.insert(id, doc.media.len());
|
||||
doc.media.push(m.clone());
|
||||
doc.next_id = doc.next_id.max(id.0);
|
||||
}
|
||||
Some(_) => {
|
||||
let before = doc.media.len();
|
||||
let new = doc
|
||||
.add_media(&m.name, &m.content_type, m.data.as_ref().clone())
|
||||
.ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?;
|
||||
if doc.media.len() > before {
|
||||
doc_at.entry(new).or_insert(before);
|
||||
}
|
||||
media_map.insert(id, new);
|
||||
}
|
||||
}
|
||||
@@ -580,39 +519,6 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_inserted_slides_and_same_named_pictures_compare_by_index() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind, SlideId, defaults};
|
||||
|
||||
// One slide of 3,000 pictures of one image, all named "Box"; the revision has the same
|
||||
// pictures under other ids (matched by name, in order) and 3,000 slides inserted after it.
|
||||
let mut cur = defaults::new_presentation(None);
|
||||
let img = cur.add_media("a.png", "image/png", vec![7; 4096]).unwrap();
|
||||
let mut rev = cur.clone();
|
||||
let pic = |id: u32| Shape {
|
||||
id: ShapeId(id),
|
||||
name: "Box".into(),
|
||||
xfrm: Some(deckcraft_model::Xfrm::new(10.0, 10.0, 50.0, 50.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut cur.slides[0]).shapes = (0..3000).map(|k| pic(100_000 + k)).collect();
|
||||
Arc::make_mut(&mut rev.slides[0]).shapes = (0..3000).map(|k| pic(200_000 + k)).collect();
|
||||
let mut base = (*rev.slides[0]).clone();
|
||||
base.shapes.clear();
|
||||
for k in 0..3000 {
|
||||
let mut s = base.clone();
|
||||
s.id = SlideId(300_000 + k);
|
||||
rev.slides.push(Arc::new(s));
|
||||
}
|
||||
let changes = super::diff(&cur, &rev, false);
|
||||
assert_eq!(changes.len(), 3000, "only the inserted slides differ");
|
||||
assert!(changes.iter().all(|c| c.kind == super::ChangeKind::Inserted && c.slide == 1));
|
||||
}
|
||||
|
||||
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a
|
||||
/// transition sound. `tag` starts the bytes of each.
|
||||
fn media_deck(tag: &str) -> deckcraft_model::Presentation {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
|
||||
//! draws in a stand-in; see `FontDb::document_face`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Bullet, TextBody};
|
||||
@@ -12,8 +14,13 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
/// font to load and are left for the round trip.
|
||||
pub fn install_embedded_fonts(p: &Presentation) {
|
||||
let db = FontDb::global();
|
||||
// Faces can name one media item many times; each is copied and enumerated once.
|
||||
let mut seen = HashSet::new();
|
||||
for font in &p.embedded_fonts {
|
||||
for id in font.faces().into_iter().flatten() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(item) = p.media(id) else { continue };
|
||||
let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue };
|
||||
db.add_font(sfnt.to_vec());
|
||||
@@ -27,12 +34,8 @@ pub fn install_embedded_fonts(p: &Presentation) {
|
||||
/// and can't be installed, so they aren't reported.
|
||||
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
let db = FontDb::global();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for family in used_fonts(p) {
|
||||
if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
out.push(family);
|
||||
}
|
||||
}
|
||||
// used_fonts is already free of case-insensitive duplicates.
|
||||
let mut out: Vec<String> = used_fonts(p).into_iter().filter(|f| !f.starts_with('.') && !db.has_family(f)).collect();
|
||||
out.sort_by_key(|f| f.to_lowercase());
|
||||
out
|
||||
}
|
||||
@@ -40,9 +43,10 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
/// The families the slides' text runs and bullets are drawn in.
|
||||
pub fn used_fonts(p: &Presentation) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut add = |f: String| {
|
||||
let f = f.trim().to_string();
|
||||
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
|
||||
if !f.is_empty() && seen.insert(f.to_ascii_lowercase()) {
|
||||
out.push(f);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6458,3 +6458,24 @@ fn master_body_placeholder_format_keeps_per_level_formatting() {
|
||||
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0)));
|
||||
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_distinct_fonts_are_listed_once_each_without_a_quadratic_scan() {
|
||||
// 30,000 runs in 15,000 families, each written twice in different case.
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = deckcraft_model::text::TextBody::from_text("x");
|
||||
let run = body.paragraphs[0].runs[0].clone();
|
||||
body.paragraphs[0].runs = (0..30_000)
|
||||
.map(|i| {
|
||||
let mut r = run.clone();
|
||||
let name = format!("Qqq Font {}", i / 2);
|
||||
r.props.font = Some(if i % 2 == 0 { name } else { name.to_uppercase() });
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
slide.shapes[0].text = Some(body);
|
||||
let used = crate::fonts::used_fonts(&p);
|
||||
assert_eq!(used.iter().filter(|f| f.starts_with("Qqq Font ")).count(), 15_000);
|
||||
assert_eq!(crate::fonts::missing_fonts(&p).iter().filter(|f| f.to_ascii_lowercase().starts_with("qqq font ")).count(), 15_000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user