mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 08:50:47 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae745477a | ||
|
|
8ec4f6cabb | ||
|
|
5a668bf02e |
+59
-29
@@ -1,5 +1,7 @@
|
||||
//! 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::{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
|
||||
/// 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).
|
||||
///
|
||||
/// 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)> {
|
||||
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
|
||||
let mut used_a = vec![false; a.shapes.len()];
|
||||
let mut used_b = vec![false; b.shapes.len()];
|
||||
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
|
||||
&|x, y| x.name.starts_with("!!") && x.name == y.name,
|
||||
&|x, y| !x.name.is_empty() && x.name == y.name,
|
||||
&|x, y| {
|
||||
if x.kind_name() != y.kind_name() {
|
||||
return false;
|
||||
// Each shape's text, worked out once rather than inside every comparison.
|
||||
let text_a: Vec<String> = a.shapes.iter().map(text_of).collect();
|
||||
let text_b: Vec<String> = b.shapes.iter().map(text_of).collect();
|
||||
let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
|
||||
let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
|
||||
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);
|
||||
!tx.trim().is_empty() && tx == text_of(y)
|
||||
},
|
||||
&|x, y| {
|
||||
x.kind_name() == y.kind_name()
|
||||
&& x.geom.preset_name().is_some()
|
||||
&& x.geom.preset_name() == y.geom.preset_name()
|
||||
&& text_of(x).trim().is_empty()
|
||||
&& text_of(y).trim().is_empty()
|
||||
},
|
||||
];
|
||||
for pass in passes {
|
||||
if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
|
||||
index.entry(k).or_default().push_back(j);
|
||||
}
|
||||
}
|
||||
if index.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, sa) in a.shapes.iter().enumerate() {
|
||||
if used_a.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
|
||||
if let Some((j, sb)) = found {
|
||||
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));
|
||||
let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
|
||||
let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
|
||||
let Some(sb) = b.shapes.get(j) else { continue };
|
||||
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));
|
||||
}
|
||||
}
|
||||
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 {
|
||||
let v = a + (b - a) * t;
|
||||
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));
|
||||
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 {
|
||||
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
|
||||
if !paired_a.contains(&sa.id) {
|
||||
push(sa.clone(), true, 1.0 - s);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
if !hidden_b(sb.id) {
|
||||
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.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:
|
||||
/// (shape, member paragraph, owner paragraph).
|
||||
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 {
|
||||
@@ -324,6 +359,7 @@ impl Timeline {
|
||||
};
|
||||
tl.colors.push((id, c));
|
||||
}
|
||||
tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
|
||||
tl
|
||||
}
|
||||
|
||||
@@ -413,8 +449,12 @@ impl Timeline {
|
||||
|
||||
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
|
||||
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();
|
||||
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>());
|
||||
let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
|
||||
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.dedup();
|
||||
v
|
||||
@@ -422,13 +462,8 @@ impl Timeline {
|
||||
|
||||
/// Shapes with any effect.
|
||||
pub fn animated_shapes(&self) -> Vec<ShapeId> {
|
||||
let mut v: Vec<ShapeId> = Vec::new();
|
||||
for e in &self.effects {
|
||||
if !v.contains(&e.timing.shape) {
|
||||
v.push(e.timing.shape);
|
||||
}
|
||||
}
|
||||
v
|
||||
let mut seen: HashSet<ShapeId> = HashSet::new();
|
||||
self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
|
||||
}
|
||||
|
||||
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
|
||||
@@ -456,9 +491,9 @@ impl Timeline {
|
||||
triggers: &[TriggerPlay],
|
||||
stamps: &[StepStamp],
|
||||
) -> 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 mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
|
||||
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
|
||||
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.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
let mut st = AnimState::default();
|
||||
// Initial visibility comes from the first effect in the Animation Pane order.
|
||||
let mut ordered: Vec<&Effect> = mine.collect();
|
||||
|
||||
@@ -227,9 +227,6 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most caret records read for one ligature glyph.
|
||||
const MAX_LIGATURE_CARETS: usize = 64;
|
||||
|
||||
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
|
||||
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
|
||||
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
@@ -239,12 +236,9 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
let Ok(cov) = list.coverage() else { return Vec::new() };
|
||||
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
|
||||
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
|
||||
// The limit applies to the records read, not to the carets kept: a font can list thousands of
|
||||
// records it then has to be read through for every ligature laid out.
|
||||
let mut out: Vec<i32> = lig
|
||||
.caret_values()
|
||||
.iter()
|
||||
.take(MAX_LIGATURE_CARETS)
|
||||
.filter_map(|cv| cv.ok())
|
||||
.filter_map(|cv| match cv {
|
||||
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
|
||||
@@ -252,6 +246,7 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
// Format 2 is a contour point index; outlines aren't consulted here.
|
||||
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
|
||||
})
|
||||
.take(64)
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
|
||||
+17
-142
@@ -368,7 +368,7 @@ struct CatalogEntry {
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<OutlineCache>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
||||
@@ -439,38 +439,6 @@ const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||
/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a
|
||||
/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes.
|
||||
const MAX_OUTLINE_ELEMENTS: usize = 1 << 15;
|
||||
/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it
|
||||
/// starts over: distinct glyph ids can each expand one dense outline.
|
||||
const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22;
|
||||
|
||||
/// Glyph outlines by (face, glyph), with the path elements they hold.
|
||||
#[derive(Default)]
|
||||
struct OutlineCache {
|
||||
paths: HashMap<(u32, u32), Arc<BezPath>>,
|
||||
elements: usize,
|
||||
}
|
||||
|
||||
impl OutlineCache {
|
||||
fn get(&self, key: &(u32, u32)) -> Option<Arc<BezPath>> {
|
||||
self.paths.get(key).cloned()
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: (u32, u32), path: Arc<BezPath>) {
|
||||
let n = path.elements().len();
|
||||
if let Some(old) = self.paths.remove(&key) {
|
||||
self.elements = self.elements.saturating_sub(old.elements().len());
|
||||
}
|
||||
if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS {
|
||||
self.paths.clear();
|
||||
self.elements = 0;
|
||||
}
|
||||
self.paths.insert(key, path);
|
||||
self.elements = self.elements.saturating_add(n);
|
||||
}
|
||||
}
|
||||
|
||||
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
@@ -726,10 +694,6 @@ const MAX_COLLECTION_FACES: u32 = 256;
|
||||
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
|
||||
/// Maximum named instances read from a single face.
|
||||
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
|
||||
/// Maximum faces (named instances counted separately) enumerated from one font file, across all
|
||||
/// of a collection's faces: the per-collection and per-face limits multiply, and each face found
|
||||
/// is then built with its own shaper.
|
||||
const MAX_ENUMERATED_FACES: usize = 4096;
|
||||
|
||||
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
|
||||
let mut offsets = std::collections::HashSet::new();
|
||||
@@ -766,30 +730,21 @@ fn enumerate_faces(data: &[u8]) -> Vec<Found> {
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
// Named instances read and plain faces kept, across the whole file.
|
||||
let mut budget = MAX_ENUMERATED_FACES;
|
||||
for i in indices {
|
||||
if budget == 0 {
|
||||
break;
|
||||
}
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
|
||||
let Some((family, style)) = face_names(&f) else { continue };
|
||||
let axes = f.axes();
|
||||
let mut named = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let instances = f.named_instances();
|
||||
for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
|
||||
let Some(left) = budget.checked_sub(1) else { break };
|
||||
budget = left;
|
||||
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
|
||||
if !seen.insert(style.to_ascii_lowercase()) {
|
||||
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
|
||||
continue;
|
||||
}
|
||||
let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect();
|
||||
named.push((i, style, coords));
|
||||
}
|
||||
if named.is_empty() {
|
||||
budget = budget.saturating_sub(1);
|
||||
out.push((i, family, style, Vec::new()));
|
||||
} else {
|
||||
out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c)));
|
||||
@@ -950,7 +905,7 @@ impl FontDb {
|
||||
}
|
||||
Self {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(OutlineCache::default()),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
font_dirs,
|
||||
@@ -1558,72 +1513,41 @@ impl FontDb {
|
||||
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(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return p;
|
||||
return p.clone();
|
||||
}
|
||||
let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
if let Some(f) = face.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.finish());
|
||||
self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
|
||||
let p = Arc::new(pen.0);
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, p.clone());
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores
|
||||
/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly.
|
||||
struct FlipPen {
|
||||
path: BezPath,
|
||||
limit: usize,
|
||||
over: bool,
|
||||
}
|
||||
|
||||
impl FlipPen {
|
||||
fn new(limit: usize) -> Self {
|
||||
FlipPen { path: BezPath::new(), limit, over: false }
|
||||
}
|
||||
|
||||
/// Room for one more element?
|
||||
fn room(&mut self) -> bool {
|
||||
if !self.over && self.path.elements().len() >= self.limit {
|
||||
self.over = true;
|
||||
self.path = BezPath::new();
|
||||
}
|
||||
!self.over
|
||||
}
|
||||
|
||||
fn finish(self) -> BezPath {
|
||||
if self.over { BezPath::new() } else { self.path }
|
||||
}
|
||||
}
|
||||
struct FlipPen(BezPath);
|
||||
|
||||
impl OutlinePen for FlipPen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
if self.room() {
|
||||
self.path.move_to((x as f64, -y as f64));
|
||||
}
|
||||
self.0.move_to((x as f64, -y as f64));
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
if self.room() {
|
||||
self.path.line_to((x as f64, -y as f64));
|
||||
}
|
||||
self.0.line_to((x as f64, -y as f64));
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
if self.room() {
|
||||
self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
if self.room() {
|
||||
self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
fn close(&mut self) {
|
||||
if self.room() {
|
||||
self.path.close_path();
|
||||
}
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1651,55 +1575,6 @@ mod font_dir_tests {
|
||||
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_of_one_file_are_bounded_across_the_collection() {
|
||||
// The per-collection and per-face limits together would allow far more.
|
||||
assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES);
|
||||
for data in bundled() {
|
||||
assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outline_past_the_element_limit_draws_nothing() {
|
||||
let mut pen = FlipPen::new(8);
|
||||
pen.move_to(0.0, 0.0);
|
||||
pen.line_to(1.0, 1.0);
|
||||
pen.close();
|
||||
assert_eq!(pen.finish().elements().len(), 3);
|
||||
// A shared outline drawn over and over (composite and VARC glyphs).
|
||||
let mut pen = FlipPen::new(8);
|
||||
for i in 0..1_000_000 {
|
||||
pen.move_to(0.0, 0.0);
|
||||
pen.line_to(i as f32, 1.0);
|
||||
pen.close();
|
||||
}
|
||||
assert_eq!(pen.path.elements().len(), 0);
|
||||
assert!(pen.finish().elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_cache_is_bounded_by_elements() {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
for i in 1..MAX_OUTLINE_ELEMENTS {
|
||||
path.line_to((i as f64, 0.0));
|
||||
}
|
||||
let path = Arc::new(path);
|
||||
let mut cache = OutlineCache::default();
|
||||
let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS;
|
||||
for gid in 0..glyphs as u32 {
|
||||
cache.insert((1, gid), path.clone());
|
||||
assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS);
|
||||
assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::<usize>());
|
||||
}
|
||||
assert!(cache.get(&(1, glyphs as u32 - 1)).is_some());
|
||||
// Replacing an entry does not count it twice.
|
||||
let before = cache.elements;
|
||||
cache.insert((1, glyphs as u32 - 1), path.clone());
|
||||
assert_eq!(cache.elements, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_data_dirs_contribute_their_fonts_folders() {
|
||||
let home = Path::new("/home/ann");
|
||||
|
||||
+161
-38
@@ -37,56 +37,146 @@ pub struct ShapeParams<'a> {
|
||||
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
|
||||
/// (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.
|
||||
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();
|
||||
};
|
||||
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 shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.set_pre_context(pre);
|
||||
buf.set_post_context(post);
|
||||
for (i, c) in text.char_indices() {
|
||||
buf.add(map(c), (range.start + i) as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
||||
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));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
out.push(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,
|
||||
});
|
||||
// Pieces of one item share the script the whole item would be guessed to have.
|
||||
let item_script = (chunks.len() > 1 && params.script.is_none()).then(|| {
|
||||
let mut probe = UnicodeBuffer::new();
|
||||
for c in text.chars() {
|
||||
probe.add(map(c), 0);
|
||||
}
|
||||
probe.guess_segment_properties();
|
||||
probe.script()
|
||||
});
|
||||
for c in &chunks {
|
||||
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 };
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.set_pre_context(pre);
|
||||
buf.set_post_context(post);
|
||||
for (i, c) in piece.char_indices() {
|
||||
buf.add(map(c), (r.start + i) as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
||||
buf.set_script(s);
|
||||
} else if let Some(s) = item_script {
|
||||
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()
|
||||
&& let Some(f) = face.skrifa()
|
||||
{
|
||||
if shaped.is_none() {
|
||||
// No shaper for this face: one glyph per character from the cmap, in visual order.
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
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 params.rtl {
|
||||
out.reverse();
|
||||
return cmap_glyphs(face, para, range, params.rtl, &map);
|
||||
}
|
||||
// Each piece is in visual order; right to left, the later pieces come first.
|
||||
if params.rtl {
|
||||
pieces.reverse();
|
||||
}
|
||||
let mut out = Vec::with_capacity(pieces.iter().map(Vec::len).sum());
|
||||
for p in pieces {
|
||||
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
|
||||
}
|
||||
@@ -121,4 +211,37 @@ mod tests {
|
||||
// Not a char boundary.
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,13 +210,20 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
|
||||
let mut w = W::new();
|
||||
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
|
||||
let names = deckcraft_model::table::table_styles();
|
||||
let mut done: Vec<&str> = vec![];
|
||||
// Indexes instead of scans: written ids (as given and ASCII lower-case), and the file's
|
||||
// definitions by lower-case id (the first wins).
|
||||
let mut done: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
let mut done_lower: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut def_of: std::collections::HashMap<String, &deckcraft_model::table::TableStyleDef> = std::collections::HashMap::new();
|
||||
for d in defs {
|
||||
def_of.entry(d.id.to_ascii_lowercase()).or_insert(d);
|
||||
}
|
||||
for s in used {
|
||||
if done.contains(&s.as_str()) {
|
||||
if !done.insert(s.as_str()) {
|
||||
continue;
|
||||
}
|
||||
done.push(s);
|
||||
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s))
|
||||
done_lower.insert(s.to_ascii_lowercase());
|
||||
if let Some(d) = def_of.get(&s.to_ascii_lowercase())
|
||||
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
|
||||
{
|
||||
w.raw(&d.raw);
|
||||
@@ -231,11 +238,11 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
|
||||
}
|
||||
// Styles the file defined but no table uses still belong to the deck.
|
||||
for d in defs {
|
||||
if done.iter().any(|s| s.eq_ignore_ascii_case(&d.id)) {
|
||||
if done_lower.contains(&d.id.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
if crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) {
|
||||
done.push(&d.id);
|
||||
done_lower.insert(d.id.to_ascii_lowercase());
|
||||
w.raw(&d.raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ pub struct Exp<'a> {
|
||||
pub p: &'a Presentation,
|
||||
pub pkg: PackageOut,
|
||||
media_parts: HashMap<MediaId, String>,
|
||||
/// Each media id's item in `p.media` (the first with the id, as `Presentation::media`).
|
||||
media_at: HashMap<MediaId, usize>,
|
||||
n_image: u32,
|
||||
n_media: u32,
|
||||
n_chart: u32,
|
||||
@@ -63,6 +65,8 @@ pub struct Exp<'a> {
|
||||
n_kept: u32,
|
||||
pub slide_parts: HashMap<SlideId, String>,
|
||||
pub table_styles: Vec<String>,
|
||||
/// The ids in `table_styles`, for checking membership without a scan.
|
||||
pub table_style_set: std::collections::HashSet<String>,
|
||||
placeholder: Option<String>,
|
||||
}
|
||||
|
||||
@@ -119,12 +123,17 @@ fn ext_for(content_type: &str, name: &str, data: &[u8]) -> String {
|
||||
if !e.is_empty() && e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()) { e } else { "bin".into() }
|
||||
}
|
||||
|
||||
impl Exp<'_> {
|
||||
impl<'a> Exp<'a> {
|
||||
/// The media item `id`, found through the index rather than a scan of every item.
|
||||
pub fn media(&self, id: MediaId) -> Option<&'a deckcraft_model::MediaItem> {
|
||||
let p: &'a Presentation = self.p;
|
||||
self.media_at.get(&id).and_then(|&i| p.media.get(i))
|
||||
}
|
||||
fn media_part(&mut self, id: MediaId) -> Option<String> {
|
||||
if let Some(p) = self.media_parts.get(&id) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
let m = self.p.media(id)?;
|
||||
let m = self.media(id)?;
|
||||
if m.data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -147,7 +156,7 @@ impl Exp<'_> {
|
||||
}
|
||||
/// A relationship from `o` to a media item (embedded part, or external link).
|
||||
pub fn media_rel(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
|
||||
let m = self.p.media(id)?;
|
||||
let m = self.media(id)?;
|
||||
if m.data.is_empty() {
|
||||
let link = m.link.clone()?;
|
||||
return Some(o.rels.add(kind, &link, true));
|
||||
@@ -156,7 +165,7 @@ impl Exp<'_> {
|
||||
Some(o.rels.add(kind, &opc::relative(&o.name, &part), false))
|
||||
}
|
||||
pub fn media_rel_new(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
|
||||
let m = self.p.media(id)?;
|
||||
let m = self.media(id)?;
|
||||
if m.data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -489,7 +498,8 @@ fn valid_date(s: &str) -> bool {
|
||||
/// A copy of the presentation with every slide pointing at a layout that exists.
|
||||
fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> {
|
||||
let mut need = p.masters.is_empty() || p.masters.iter().any(|m| m.layouts.is_empty());
|
||||
need |= p.slides.iter().any(|s| p.layout(s.layout).is_none());
|
||||
let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
|
||||
need |= p.slides.iter().any(|s| !layouts.contains(&s.layout));
|
||||
if !need {
|
||||
return std::borrow::Cow::Borrowed(p);
|
||||
}
|
||||
@@ -532,6 +542,13 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
|
||||
p,
|
||||
pkg: PackageOut::default(),
|
||||
media_parts: HashMap::new(),
|
||||
media_at: {
|
||||
let mut m = HashMap::new();
|
||||
for (i, item) in p.media.iter().enumerate() {
|
||||
m.entry(item.id).or_insert(i);
|
||||
}
|
||||
m
|
||||
},
|
||||
n_image: 0,
|
||||
n_media: 0,
|
||||
n_chart: 0,
|
||||
@@ -541,6 +558,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
|
||||
n_kept: 0,
|
||||
slide_parts: HashMap::new(),
|
||||
table_styles: vec![],
|
||||
table_style_set: Default::default(),
|
||||
placeholder: None,
|
||||
};
|
||||
for (i, s) in p.slides.iter().enumerate() {
|
||||
@@ -959,9 +977,16 @@ fn section_slides(p: &Presentation) -> Vec<(String, Vec<usize>)> {
|
||||
if out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
// Each slide's first section, found once: a section may name a slide many times.
|
||||
let mut first: HashMap<SlideId, usize> = HashMap::new();
|
||||
for (k, sec) in p.sections.iter().enumerate() {
|
||||
for id in &sec.slides {
|
||||
first.entry(*id).or_insert(k);
|
||||
}
|
||||
}
|
||||
let mut cur = 0usize;
|
||||
for (i, s) in p.slides.iter().enumerate() {
|
||||
if let Some(k) = p.sections.iter().position(|sec| sec.slides.contains(&s.id))
|
||||
if let Some(&k) = first.get(&s.id)
|
||||
&& k >= cur
|
||||
{
|
||||
cur = k;
|
||||
@@ -1027,7 +1052,7 @@ fn write_embedded_fonts(x: &mut Exp, o: &mut Out) -> Vec<WrittenFont> {
|
||||
if let Some(rid) = seen_part.get(&id) {
|
||||
return Some(rid.clone());
|
||||
}
|
||||
let m = x.p.media(id)?;
|
||||
let m = x.media(id)?;
|
||||
if m.data.is_empty() || m.data.len() > 32 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||
pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) {
|
||||
// An image that lives outside the package is a linked picture: `a:blip` names it through
|
||||
// `r:link`; `r:embed` may only reference a part inside the package.
|
||||
let linked = x.p.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some());
|
||||
let linked = x.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some());
|
||||
let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o));
|
||||
w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" }));
|
||||
let adj = &pf.adjust;
|
||||
@@ -441,7 +441,7 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink)
|
||||
return;
|
||||
};
|
||||
w.open(tag, attrs);
|
||||
let name = x.p.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into());
|
||||
let name = x.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into());
|
||||
w.empty("a:snd", A::new().a("r:embed", sound_rid).a("name", name));
|
||||
w.close(tag);
|
||||
}
|
||||
@@ -587,7 +587,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") {
|
||||
Some(rid) => {
|
||||
w.open0("a:buBlip");
|
||||
let a = if x.p.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) {
|
||||
let a = if x.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) {
|
||||
A::new().a("r:link", &rid)
|
||||
} else {
|
||||
A::new().a("r:embed", &rid)
|
||||
|
||||
@@ -446,7 +446,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
|
||||
}
|
||||
|
||||
fn model_rel(x: &mut Exp, o: &mut Out, media: deckcraft_model::MediaId) -> Option<String> {
|
||||
if x.p.media(media).is_none_or(|m| m.data.is_empty()) {
|
||||
if x.media(media).is_none_or(|m| m.data.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
x.media_rel(o, media, crate::opc::RT_MODEL3D)
|
||||
@@ -479,7 +479,7 @@ fn write_model(
|
||||
return;
|
||||
};
|
||||
let id = cx.id_for(s);
|
||||
let poster = poster.filter(|p| x.p.media(*p).is_some_and(|m| !m.data.is_empty()));
|
||||
let poster = poster.filter(|p| x.media(*p).is_some_and(|m| !m.data.is_empty()));
|
||||
let Some(poster) = poster else {
|
||||
write_model_part(w, x, o, id, s, &rid);
|
||||
return;
|
||||
@@ -700,7 +700,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
|
||||
t.normalize();
|
||||
w.open0("a:tbl");
|
||||
let (guid, _) = crate::tables::guid_for_style(&t.style);
|
||||
if !x.table_styles.contains(&t.style) {
|
||||
if x.table_style_set.insert(t.style.clone()) {
|
||||
x.table_styles.push(t.style.clone());
|
||||
}
|
||||
w.open(
|
||||
@@ -881,7 +881,7 @@ fn opaque(
|
||||
p.name = label.to_string();
|
||||
}
|
||||
match preview {
|
||||
Some(m) if x.p.media(m).is_some() => {
|
||||
Some(m) if x.media(m).is_some() => {
|
||||
p.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: m, ..Default::default() } };
|
||||
p.ph = None;
|
||||
shape_with_id(w, x, o, cx, &p, id);
|
||||
|
||||
@@ -16,7 +16,7 @@ pub fn transition(w: &mut W, x: &mut Exp, o: &mut Out, t: &Transition) {
|
||||
601..=900 => "med",
|
||||
_ => "slow",
|
||||
};
|
||||
let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.p.media(m).map(|i| i.name.clone()).unwrap_or_default())));
|
||||
let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.media(m).map(|i| i.name.clone()).unwrap_or_default())));
|
||||
let snd = |w: &mut W| {
|
||||
if let Some((r, name)) = &sound {
|
||||
w.open0("p:sndAc");
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Export looks media, layouts, sections and table styles up by id: a deck with many of each and
|
||||
//! many references to the last ones exports in time linear in its size.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Section, Shape, ShapeId, ShapeKind, defaults};
|
||||
|
||||
#[test]
|
||||
fn many_media_sections_and_table_styles_export() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
// 5,000 media items; 5,000 pictures of the last one.
|
||||
let mut last = None;
|
||||
for i in 0..5_000u32 {
|
||||
last = p.add_media(&format!("m{i}.png"), "image/png", i.to_le_bytes().to_vec());
|
||||
}
|
||||
let last = last.unwrap();
|
||||
let pic = |k: u32| Shape {
|
||||
id: ShapeId(100_000 + k),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: last, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = (0..5_000).map(pic).collect();
|
||||
// A section naming the slide 50,000 times, after 2,000 empty sections.
|
||||
let id = p.slides[0].id;
|
||||
p.sections = (0..2_000).map(|i| Section { name: format!("s{i}"), slides: vec![] }).collect();
|
||||
p.sections.push(Section { name: "last".into(), slides: vec![id; 50_000] });
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
assert_eq!(q.slides[0].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count(), 5_000);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod corpus_kinds;
|
||||
mod corpus_media;
|
||||
mod corpus_style;
|
||||
mod corpus_themes;
|
||||
mod export_scale;
|
||||
mod fills;
|
||||
mod kept_parts;
|
||||
mod locks;
|
||||
|
||||
Reference in New Issue
Block a user