mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 00:41:28 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b39e287a4 |
@@ -227,6 +227,9 @@ 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> {
|
||||
@@ -236,9 +239,12 @@ 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),
|
||||
@@ -246,7 +252,6 @@ 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
|
||||
|
||||
+142
-17
@@ -368,7 +368,7 @@ struct CatalogEntry {
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
outlines: Mutex<OutlineCache>,
|
||||
#[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,6 +439,38 @@ 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()))
|
||||
@@ -694,6 +726,10 @@ 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();
|
||||
@@ -730,21 +766,30 @@ 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 named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
|
||||
if !seen.insert(style.to_ascii_lowercase()) {
|
||||
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)));
|
||||
@@ -905,7 +950,7 @@ impl FontDb {
|
||||
}
|
||||
Self {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
outlines: Mutex::new(OutlineCache::default()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
font_dirs,
|
||||
@@ -1513,41 +1558,72 @@ 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.clone();
|
||||
return p;
|
||||
}
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
|
||||
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.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());
|
||||
let p = Arc::new(pen.finish());
|
||||
self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
struct FlipPen(BezPath);
|
||||
/// 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 }
|
||||
}
|
||||
}
|
||||
|
||||
impl OutlinePen for FlipPen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.0.move_to((x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.move_to((x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.0.line_to((x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.line_to((x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.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) {
|
||||
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
||||
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));
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
if self.room() {
|
||||
self.path.close_path();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1575,6 +1651,55 @@ 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");
|
||||
|
||||
@@ -85,14 +85,14 @@ fn chart_parts_share_media_byte_and_item_budgets() {
|
||||
let mut p = fixture(vec![7; 600]);
|
||||
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
|
||||
let bytes = save(&p).unwrap();
|
||||
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
|
||||
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err(), "chart payloads share the cumulative media budget");
|
||||
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err(), "chart parts share the media item budget");
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
|
||||
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget");
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget");
|
||||
let duplicated = rewrite_manifest(&bytes, |manifest| {
|
||||
let media = manifest["presentation"]["media"].as_array_mut().unwrap();
|
||||
media.push(media[0].clone());
|
||||
});
|
||||
assert!(load_within(&duplicated, Budget::media(1200, 2)).is_err(), "duplicate media IDs still count as listed items");
|
||||
assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -104,9 +104,9 @@ fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
|
||||
part.remove("nativeEntry");
|
||||
part.insert("bytes".into(), json!(vec![9_u8; 600]));
|
||||
});
|
||||
assert_eq!(load_within(&legacy, Budget::media(1200, 2)).unwrap().0, p);
|
||||
assert!(load_within(&legacy, Budget::media(1199, 2)).is_err());
|
||||
assert!(load_within(&legacy, Budget::media(1200, 1)).is_err());
|
||||
assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p);
|
||||
assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err());
|
||||
assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -207,7 +207,7 @@ fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
|
||||
}
|
||||
// A failed CRC may already have inflated bytes. Continuing with the same remaining
|
||||
// budget would let each corrupt member consume that work budget again.
|
||||
let result = load_within(&bytes, Budget::media(10, 2));
|
||||
let result = load_within(&bytes, Budget { total: 10, items: 2 });
|
||||
assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget");
|
||||
assert!(result.unwrap_err().to_string().contains("chart-parts/0"));
|
||||
}
|
||||
@@ -228,7 +228,7 @@ fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
|
||||
assert_eq!(replaced, 2);
|
||||
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1);
|
||||
let (q, damaged) = load_within(&bytes, Budget::media(10, 2)).unwrap();
|
||||
let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap();
|
||||
assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again");
|
||||
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
|
||||
|
||||
+13
-55
@@ -29,24 +29,12 @@ const MAX_ENTRY: u64 = 2 << 30;
|
||||
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
|
||||
/// Media items and chart sidecar parts one file may list.
|
||||
const MAX_MEDIA_ITEMS: usize = 100_000;
|
||||
/// Largest `presentation.json` we will inflate. The manifest holds no media bytes, so it is far
|
||||
/// smaller than a media entry may be; inflating and parsing one is what dominates memory.
|
||||
const MAX_MANIFEST: u64 = 256 << 20;
|
||||
|
||||
/// What one load may inflate.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Budget {
|
||||
total: u64,
|
||||
items: usize,
|
||||
manifest: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Budget {
|
||||
/// A media budget with the default manifest limit.
|
||||
fn media(total: u64, items: usize) -> Self {
|
||||
Budget { total, items, manifest: MAX_MANIFEST }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -119,7 +107,6 @@ pub fn sniff(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"PK") && [MIMETYPE, LEGACY_MIMETYPE].iter().any(|m| bytes.windows(m.len()).take(200).any(|w| w == m.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<u8>> {
|
||||
read_entry_max(z, name, MAX_ENTRY)
|
||||
}
|
||||
@@ -127,30 +114,13 @@ fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<
|
||||
/// Read one member, refusing it before inflating if it declares more than `max` bytes, and
|
||||
/// stopping at `max` if it inflates further than it declared.
|
||||
fn read_entry_max(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64) -> Result<Vec<u8>> {
|
||||
read_entry_counted(z, name, max, &mut 0)
|
||||
}
|
||||
|
||||
/// [`read_entry_max`] within `left` (and [`MAX_ENTRY`]), charging `left` for every byte inflated,
|
||||
/// including those inflated before a decode or CRC error, so a member that fails late still
|
||||
/// spends the shared budget it used.
|
||||
fn read_entry_charged(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, left: &mut u64) -> Result<Vec<u8>> {
|
||||
let mut spent = 0;
|
||||
let out = read_entry_counted(z, name, (*left).min(MAX_ENTRY), &mut spent);
|
||||
*left = left.saturating_sub(spent);
|
||||
out
|
||||
}
|
||||
|
||||
/// [`read_entry_max`], adding the bytes inflated to `spent` whether or not the read succeeds.
|
||||
fn read_entry_counted(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64, spent: &mut u64) -> Result<Vec<u8>> {
|
||||
let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
|
||||
if f.size() > max {
|
||||
return Err(FormatError::Damaged(format!("{name} is too large")));
|
||||
}
|
||||
let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize);
|
||||
let mut f = f.take(max.saturating_add(1));
|
||||
let read = f.read_to_end(&mut out);
|
||||
*spent = spent.saturating_add(out.len() as u64);
|
||||
read.map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
|
||||
f.read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
|
||||
if out.len() as u64 > max {
|
||||
return Err(FormatError::Damaged(format!("{name} is too large")));
|
||||
}
|
||||
@@ -167,15 +137,12 @@ pub fn load(bytes: &[u8]) -> Result<Presentation> {
|
||||
/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file.
|
||||
/// Linked media saved without its bytes isn't damage.
|
||||
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS, manifest: MAX_MANIFEST })
|
||||
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
|
||||
}
|
||||
|
||||
fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String>)> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
if z.index_for_name("presentation.json").is_none() {
|
||||
return Err(FormatError::NotOurs("no presentation.json".into()));
|
||||
}
|
||||
let json = read_entry_max(&mut z, "presentation.json", budget.manifest)?;
|
||||
let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?;
|
||||
let m = parse_manifest(&json)?;
|
||||
if m.format != "deckcraft" {
|
||||
return Err(FormatError::NotOurs(m.format));
|
||||
@@ -197,9 +164,11 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
|
||||
if z.by_name(&name).is_ok_and(|f| f.size() > left) {
|
||||
return Err(FormatError::Damaged("embedded media is larger than DeckCraft can open".into()));
|
||||
}
|
||||
// A failed read is charged for what it inflated, so later members can't reuse that budget.
|
||||
match read_entry_charged(&mut z, &name, &mut left) {
|
||||
Ok(data) => item.data = Arc::new(data),
|
||||
match read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) {
|
||||
Ok(data) => {
|
||||
left -= data.len() as u64;
|
||||
item.data = Arc::new(data);
|
||||
}
|
||||
Err(_) if linked_only => {}
|
||||
Err(e) => damaged.push(e.to_string()),
|
||||
}
|
||||
@@ -219,11 +188,9 @@ pub fn repair(p: &mut Presentation) {
|
||||
}
|
||||
p.fix_next_id();
|
||||
let first_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
||||
// Indexed once: a per-slide scan of every layout is slides × layouts work.
|
||||
let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for i in 0..p.slides.len() {
|
||||
let needs_layout = p.slides.get(i).is_some_and(|s| !layouts.contains(&s.layout));
|
||||
let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none());
|
||||
let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id));
|
||||
if needs_layout || dup {
|
||||
// An exhausted id space leaves a duplicate slide as it is: the engine refuses to
|
||||
@@ -364,20 +331,16 @@ fn failure(src: &[u8]) -> std::result::Result<Manifest, (String, (usize, usize))
|
||||
/// nearest first, and a patch is kept only if the parse then gets further. (Files saved since
|
||||
/// [`finite::Finite`] never contain such `null`s; this reads older ones.)
|
||||
fn parse_manifest(json: &[u8]) -> Result<Manifest> {
|
||||
// Parse the inflated text as it is: only a file that fails on a `null` number is copied for
|
||||
// patching (the copy is bounded by `MAX_MANIFEST`).
|
||||
let mut last = match failure(json) {
|
||||
let mut src = json.to_vec();
|
||||
let mut last = match failure(&src) {
|
||||
Ok(m) => return Ok(m),
|
||||
Err(f) => f,
|
||||
};
|
||||
if !is_null_number(&last.0) {
|
||||
return Err(FormatError::Damaged(last.0));
|
||||
}
|
||||
let mut src = json.to_vec();
|
||||
let mut budget = MAX_REPARSE_BYTES;
|
||||
'fixes: for _ in 0..MAX_NULL_FIXES {
|
||||
let (msg, pos) = &last;
|
||||
if !is_null_number(msg) {
|
||||
let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number");
|
||||
if !number {
|
||||
break;
|
||||
}
|
||||
let end = offset(&src, pos.0, pos.1).unwrap_or(src.len());
|
||||
@@ -411,11 +374,6 @@ fn parse_manifest(json: &[u8]) -> Result<Manifest> {
|
||||
Err(FormatError::Damaged(last.0))
|
||||
}
|
||||
|
||||
/// Is this parse error a `null` where a number belongs (a NaN or infinity serde_json wrote)?
|
||||
fn is_null_number(msg: &str) -> bool {
|
||||
msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number")
|
||||
}
|
||||
|
||||
/// Byte offset of a 1-based `line`:`column`.
|
||||
fn offset(src: &[u8], line: usize, column: usize) -> Option<usize> {
|
||||
let mut start = 0;
|
||||
|
||||
@@ -93,60 +93,9 @@ fn media_budget_is_cumulative() {
|
||||
p.add_media("b.bin", "application/octet-stream", vec![2; 600]);
|
||||
let bytes = save(&p).unwrap();
|
||||
// Exactly at the limit opens; one byte under is refused, though each item fits.
|
||||
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
|
||||
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err());
|
||||
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_media_is_charged_for_what_it_inflated() {
|
||||
let mut p = Presentation::default();
|
||||
p.add_media("a.png", "image/png", b"PICTURE-AAAAAAAAAAAA".to_vec());
|
||||
p.add_media("b.png", "image/png", b"PICTURE-BBBBBBBBBBBB".to_vec());
|
||||
let mut bytes = save(&p).unwrap();
|
||||
// Corrupt both stored members, so each fails its CRC only after inflating all its bytes.
|
||||
for tag in [b"PICTURE-AAAA", b"PICTURE-BBBB"] {
|
||||
let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap();
|
||||
bytes[at] ^= 0xff;
|
||||
}
|
||||
// Each member fits the budget on its own; the second must not reuse what the first spent.
|
||||
let result = load_within(&bytes, Budget::media(20, 2));
|
||||
assert!(result.is_err(), "two corrupt 20-byte members must not share a 20-byte budget");
|
||||
// With room for both, the file opens and reports both.
|
||||
let (_, damaged) = load_within(&bytes, Budget::media(40, 2)).unwrap();
|
||||
assert_eq!(damaged.len(), 2, "{damaged:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_manifest_is_refused_before_inflating() {
|
||||
let bytes = save(&Presentation::default()).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
let size = z.by_name("presentation.json").unwrap().size();
|
||||
assert!(load_within(&bytes, Budget { total: 0, items: 0, manifest: size }).is_ok());
|
||||
let err = load_within(&bytes, Budget { total: 0, items: 0, manifest: size - 1 }).unwrap_err();
|
||||
assert!(err.to_string().contains("too large"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_slides_on_missing_layouts_are_repaired() {
|
||||
let mut p = Presentation::default();
|
||||
let template = p.slides[0].clone();
|
||||
let good = template.layout;
|
||||
p.slides = (0..2000u32)
|
||||
.map(|i| {
|
||||
let mut s = (*template).clone();
|
||||
s.id = deckcraft_model::SlideId(10_000 + i);
|
||||
if i % 2 == 1 {
|
||||
s.layout = deckcraft_model::LayoutId(900_000 + i);
|
||||
}
|
||||
Arc::new(s)
|
||||
})
|
||||
.collect();
|
||||
p.next_id = 20_000;
|
||||
repair(&mut p);
|
||||
assert!(p.validate().is_empty(), "{:?}", p.validate().first());
|
||||
assert!(p.slides.iter().all(|s| p.layout(s.layout).is_some()));
|
||||
assert_eq!(p.slides[0].layout, good);
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
|
||||
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err());
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -175,7 +124,7 @@ fn repeated_media_ids_are_read_once() {
|
||||
}
|
||||
let bytes = out.finish().unwrap().into_inner();
|
||||
// 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once.
|
||||
let q = load_within(&bytes, Budget::media(1000, 51)).unwrap().0;
|
||||
let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0;
|
||||
assert_eq!(q.media.len(), 1);
|
||||
assert_eq!(q.media[0].data.len(), 1000);
|
||||
// And the deck saves again.
|
||||
|
||||
@@ -1112,13 +1112,11 @@ impl Presentation {
|
||||
pub fn validate(&self) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// Indexed once: a per-slide scan of every layout is slides × layouts work.
|
||||
let layouts: std::collections::HashSet<_> = self.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
|
||||
for s in &self.slides {
|
||||
if !seen.insert(s.id) {
|
||||
out.push(format!("duplicate slide id {}", s.id));
|
||||
}
|
||||
if !layouts.contains(&s.layout) {
|
||||
if self.layout(s.layout).is_none() {
|
||||
out.push(format!("slide {} uses missing layout {}", s.id, s.layout));
|
||||
}
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
|
||||
Reference in New Issue
Block a user