Compare commits

..
4 changed files with 60 additions and 48 deletions
+25 -3
View File
@@ -811,10 +811,32 @@ fn clamp_selection(st: &mut DocState) {
/// Default for [`History::byte_limit`].
pub const UNDO_BYTE_BUDGET: usize = 256 << 20;
/// A rough size of a text body in memory: its paragraphs and runs, at their inline struct sizes.
fn text_cost(t: &deckcraft_model::text::TextBody) -> usize {
use deckcraft_model::text::{Paragraph, Run};
let (para, run) = (std::mem::size_of::<Paragraph>(), std::mem::size_of::<Run>());
t.paragraphs.iter().map(|p| para + p.runs.iter().map(|r| run + r.text.len()).sum::<usize>()).sum()
}
/// A rough size of comments in memory, replies included.
fn comments_cost(cs: &[deckcraft_model::Comment], depth: usize) -> usize {
let kids = |c: &deckcraft_model::Comment| if depth < 64 { comments_cost(&c.replies, depth + 1) } else { 0 };
cs.iter().map(|c| 256 + c.author.len() + c.initials.len() + c.text.len() + c.date.len() + kids(c)).sum()
}
/// A rough size of a slide's own data besides its shapes: notes, comments, tags and kept XML,
/// all copied with the slide when an edit changes it.
fn slide_extra_cost(s: &deckcraft_model::Slide) -> usize {
text_cost(&s.notes)
+ comments_cost(&s.comments, 0)
+ s.name.len()
+ s.tags.iter().map(|(k, v)| 48 + k.len() + v.len()).sum::<usize>()
+ s.raw_ext.as_ref().map_or(0, String::len)
}
/// A rough size of a shape in memory: the struct, its text and kept XML, its children.
fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize {
let text: usize =
sh.text.as_ref().map_or(0, |t| t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::<usize>()).sum());
let text: usize = sh.text.as_ref().map_or(0, text_cost);
let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 };
768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids
}
@@ -832,7 +854,7 @@ pub fn undo_cost(doc: &Presentation, newer: &Presentation) -> usize {
let media: HashSet<*const Vec<u8>> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect();
let mut n = 64 * doc.slides.len();
for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) {
n += 512 + shapes_cost(&s.shapes);
n += 512 + shapes_cost(&s.shapes) + slide_extra_cost(s);
}
for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) {
n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::<usize>();
+19
View File
@@ -3338,6 +3338,25 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() {
assert_eq!(s.doc().unwrap().history.undo.len(), 120);
}
/// A slide's notes count toward the undo budget: small edits to a slide with large notes keep a
/// copy of those notes per step, and must not slip past the budget as if the slide were empty.
#[test]
fn undo_history_counts_slide_notes() {
let mut s = Session::with_new();
let notes = "n".repeat(1 << 20);
s.edit(|doc, _| {
std::sync::Arc::make_mut(&mut doc.slides[0]).notes = deckcraft_model::text::TextBody::from_text(&notes);
Ok(())
})
.unwrap();
s.doc_mut().unwrap().history = History { limit: 500, byte_limit: 8 << 20, ..Default::default() };
for i in 0..40 {
s.execute("slide.rename", &json!({"name": format!("v{i}"), "index": 0})).unwrap();
}
let n = s.doc().unwrap().history.undo.len();
assert!((1..=8).contains(&n), "{n} steps of 1 MB notes kept under an 8 MB budget");
}
#[test]
fn picture_styles_replace_each_other_in_one_step() {
const PNG: &[u8] = &[
+16 -31
View File
@@ -498,9 +498,6 @@ impl Backdrops {
/// Most pixels one slide raster may have; it is RGBA, and the backdrop and the content are two rasters.
const MAX_RASTER_PIXELS: f64 = 64_000_000.0;
/// Most ruled note lines one handout area gets (a letter page holds about 30).
const MAX_NOTE_LINES: usize = 500;
/// Render scale for a slide of `slide` units drawn `box_width` points wide at `dpi`, with the total pixel count capped.
fn raster_scale(box_width: f64, slide: (f64, f64), dpi: f64) -> f64 {
let scale = (box_width / 72.0 * dpi.clamp(36.0, 600.0)) / slide.0;
@@ -576,17 +573,20 @@ impl Exporter<'_> {
/// Invisible glyphs exactly over the rasterised text.
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
// Each paragraph's characters, gathered once: a long paragraph wraps into many runs.
let mut para_chars: std::collections::HashMap<usize, Vec<char>> = std::collections::HashMap::new();
for run in &pt.layout.runs {
if run.glyphs.is_empty() || run.size <= 0.0 {
continue;
}
let chars = para_chars.entry(run.para).or_insert_with(|| {
pt.body.paragraphs.get(run.para).map(|p| p.runs.iter().flat_map(|r| r.text.chars()).collect()).unwrap_or_default()
});
let (a, b) = (run.chars.0.min(chars.len()), run.chars.1.min(chars.len()));
let text: String = chars.get(a..b.max(a)).unwrap_or_default().iter().collect();
let text: String = pt
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
.take(run.chars.1.saturating_sub(run.chars.0))
.collect();
if text.trim().is_empty() {
continue;
}
@@ -666,28 +666,19 @@ impl Exporter<'_> {
let lh = size * 1.3;
let mut y = area.y0 + size;
s.set_fill(Some(grey(0x20, 1.0)));
let space = width(" ");
'outer: for para in text.split('\n') {
// The line's width is kept as words are added, not measured again for each word.
let mut line = String::new();
let mut line_w = 0.0;
for word in para.split(' ') {
let word_w = width(word);
let cand_w = if line.is_empty() { word_w } else { line_w + space + word_w };
if cand_w > area.width() && !line.is_empty() {
let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
if width(&cand) > area.width() && !line.is_empty() {
if y > area.y1 {
break 'outer;
}
s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto);
y += lh;
line = word.to_string();
line_w = word_w;
} else {
if !line.is_empty() {
line.push(' ');
}
line.push_str(word);
line_w = cand_w;
line = cand;
}
}
if y > area.y1 {
@@ -704,26 +695,20 @@ impl Exporter<'_> {
/// Ruled lines for handwritten notes (3-per-page handouts).
fn note_lines(&self, s: &mut Surface, r: Rect) {
// Counted, not stepped: at huge coordinates adding 22 leaves y unchanged, and a huge page
// would want millions of rules.
let first = r.y0 + 18.0;
let count = ((r.y1 - first) / 22.0).floor() + 1.0;
if count.is_nan() || count < 1.0 {
return;
}
let mut y = r.y0 + 18.0;
s.set_stroke(Some(krilla::paint::Stroke {
paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(),
width: 0.5,
..Default::default()
}));
for i in 0..(count.min(MAX_NOTE_LINES as f64) as usize) {
let y = first + 22.0 * i as f64;
while y <= r.y1 {
let mut p = kurbo::BezPath::new();
p.move_to((r.x0, y));
p.line_to((r.x1, y));
if let Some(path) = to_path(&p) {
s.draw_path(&path);
}
y += 22.0;
}
s.set_stroke(None);
}
-14
View File
@@ -677,17 +677,3 @@ fn raster_scale_caps_total_pixels_not_each_side() {
let s = raster_scale(720.0, (960.0, 540.0), 200.0);
assert!((s - 720.0 / 72.0 * 200.0 / 960.0).abs() < 1e-12);
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn huge_paper_and_long_notes_export_in_bounded_work() {
// At 1e19 pt adding a rule's 22 pt leaves y unchanged: the rules are counted, not stepped. A narrow page keeps the raster small.
let p = deck(1);
let bytes = export(&p, &PdfOptions { paper: (612.0, 1.0e19), ..opts(PageLayout::Handouts { per_page: 3 }) }).expect("export");
assert!(bytes.starts_with(b"%PDF-"));
// 20,000 short words of notes, wrapped with the line width kept as words are added.
let mut p = deck(1);
Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text(&"ab ".repeat(20_000));
let bytes = export(&p, &opts(PageLayout::Notes)).expect("export");
assert!(bytes.starts_with(b"%PDF-"));
}