mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-09 03:49:34 -06:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
569f10315b | ||
|
|
dc587a1036 | ||
|
|
27787121c4 | ||
|
|
31a7c2baf5 | ||
|
|
74d6145982 | ||
|
|
ee04033524 |
@@ -466,26 +466,22 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape:
|
||||
|
||||
/// Run `f` on each target shape (copy-on-write), materialising inherited boxes first.
|
||||
pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
let mut ids = targets(s, p)?;
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "no shapes selected"));
|
||||
}
|
||||
// A shape named twice is edited once.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
ids.retain(|id| seen.insert(*id));
|
||||
s.edit(|doc, sel| {
|
||||
// The boxes are read from the shapes in place: no copy of a shape (and its text) per id.
|
||||
let boxes: std::collections::HashMap<ShapeId, Xfrm> = ids
|
||||
.iter()
|
||||
.filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)))
|
||||
.map(|sh| (sh.id, xfrm_of(doc, sel, sh)))
|
||||
.collect();
|
||||
let boxes: Vec<(ShapeId, Xfrm)> = {
|
||||
let st_shapes: Vec<deckcraft_model::Shape> =
|
||||
ids.iter().filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)).cloned()).collect();
|
||||
st_shapes.iter().map(|sh| (sh.id, xfrm_of(doc, sel, sh))).collect()
|
||||
};
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
|
||||
if sh.xfrm.is_none() {
|
||||
sh.xfrm = boxes.get(id).copied();
|
||||
sh.xfrm = boxes.iter().find(|(i, _)| i == id).map(|(_, x)| *x);
|
||||
}
|
||||
f(sh)?;
|
||||
n += 1;
|
||||
|
||||
@@ -6483,18 +6483,6 @@ fn master_body_placeholder_format_keeps_per_level_formatting() {
|
||||
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
|
||||
}
|
||||
|
||||
/// A shape named many times in one edit is edited once and not copied per mention.
|
||||
#[test]
|
||||
fn repeated_target_ids_edit_the_shape_once() {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "x".repeat(100_000)})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
let edited = s.execute("shape.fill", &json!({"ids": vec![id; 50_000], "color": "#ff0000"})).unwrap();
|
||||
assert_eq!(edited, json!(1));
|
||||
let v = s.execute("shape.inspect", &json!({"id": id})).unwrap();
|
||||
assert_eq!(v["shape"]["fill"]["color"]["base"]["rgb"], json!({"a": 255, "b": 0, "g": 0, "r": 255}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_master_view_rejects_invalid_indices() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
+37
-26
@@ -301,18 +301,14 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
|
||||
let (kx, ky) = (paper.0 / pres.notes_size.width.max(1.0), paper.1 / pres.notes_size.height.max(1.0));
|
||||
let mut out = vec![];
|
||||
for pt in deckcraft_render::place_master_texts(pres, m, n) {
|
||||
let mut para_chars: HashMap<usize, Vec<char>> = HashMap::new();
|
||||
for run in &pt.layout.runs {
|
||||
let Some(g) = run.glyphs.first() else { continue };
|
||||
let text: String = pt
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
.take(run.chars.1.saturating_sub(run.chars.0))
|
||||
.collect();
|
||||
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.content().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();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -498,6 +494,9 @@ 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;
|
||||
@@ -574,20 +573,17 @@ 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: HashMap<usize, Vec<char>> = HashMap::new();
|
||||
for run in &pt.layout.runs {
|
||||
if run.glyphs.is_empty() || run.size <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let text: String = pt
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
.take(run.chars.1.saturating_sub(run.chars.0))
|
||||
.collect();
|
||||
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.content().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();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -667,19 +663,28 @@ 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 cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if width(&cand) > area.width() && !line.is_empty() {
|
||||
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() {
|
||||
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 {
|
||||
line = cand;
|
||||
if !line.is_empty() {
|
||||
line.push(' ');
|
||||
}
|
||||
line.push_str(word);
|
||||
line_w = cand_w;
|
||||
}
|
||||
}
|
||||
if y > area.y1 {
|
||||
@@ -696,20 +701,26 @@ impl Exporter<'_> {
|
||||
|
||||
/// Ruled lines for handwritten notes (3-per-page handouts).
|
||||
fn note_lines(&self, s: &mut Surface, r: Rect) {
|
||||
let mut y = r.y0 + 18.0;
|
||||
// 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;
|
||||
}
|
||||
s.set_stroke(Some(krilla::paint::Stroke {
|
||||
paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(),
|
||||
width: 0.5,
|
||||
..Default::default()
|
||||
}));
|
||||
while y <= r.y1 {
|
||||
for i in 0..(count.min(MAX_NOTE_LINES as f64) as usize) {
|
||||
let y = first + 22.0 * i as f64;
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -728,3 +728,17 @@ 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-"));
|
||||
}
|
||||
|
||||
@@ -1351,7 +1351,6 @@ fn read_show(sp: &El, shows: &HashMap<String, String>) -> ShowSettings {
|
||||
without_narration: sp.bool("showNarration") == Some(false),
|
||||
without_animation: sp.bool("showAnimation") == Some(false),
|
||||
use_timings: sp.bool("useTimings").unwrap_or(true),
|
||||
show_media_controls: sp.find("showMediaCtrls").and_then(|e| e.bool("val")).unwrap_or(true),
|
||||
..Default::default()
|
||||
};
|
||||
if sp.child("browse").is_some() {
|
||||
|
||||
@@ -1206,14 +1206,6 @@ fn pres_props_xml(p: &Presentation) -> Vec<u8> {
|
||||
w.open0("p:penClr");
|
||||
w.val("a:srgbClr", s.pen_color.hex());
|
||||
w.close("p:penClr");
|
||||
if !s.show_media_controls {
|
||||
// "Show media controls" is PowerPoint's p14 extension; absent means shown.
|
||||
w.open0("p:extLst");
|
||||
w.open("p:ext", A::new().a("uri", "{2FDB2607-1784-4EEB-B798-7EB5836EED8A}"));
|
||||
w.empty("p14:showMediaCtrls", A::new().a("xmlns:p14", NS_P14).a("val", 0));
|
||||
w.close("p:ext");
|
||||
w.close("p:extLst");
|
||||
}
|
||||
w.close("p:showPr");
|
||||
w.close("p:presentationPr");
|
||||
w.finish()
|
||||
|
||||
@@ -2258,34 +2258,6 @@ fn default_durations_match_powerpoint() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`: off is
|
||||
/// written there, under its extension uri and with the p14 namespace declared on the element;
|
||||
/// on (the default) writes nothing.
|
||||
#[test]
|
||||
fn hidden_media_controls_survive_save_and_reopen() {
|
||||
let props = |p: &Presentation| {
|
||||
let bytes = deckcraft_pptx::export(p).expect("export");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let mut x = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("ppt/presProps.xml").expect("presProps"), &mut x).expect("read");
|
||||
x
|
||||
};
|
||||
let mut p = Presentation::default();
|
||||
p.show.show_media_controls = false;
|
||||
let xml = props(&p);
|
||||
let show = xml.split("<p:showPr").nth(1).and_then(|r| r.split("</p:showPr>").next()).expect("p:showPr");
|
||||
assert!(
|
||||
show.contains(
|
||||
"<p:ext uri=\"{2FDB2607-1784-4EEB-B798-7EB5836EED8A}\"><p14:showMediaCtrls xmlns:p14=\"http://schemas.microsoft.com/office/powerpoint/2010/main\" val=\"0\"/></p:ext>"
|
||||
),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(!round(&p).show.show_media_controls);
|
||||
let default = Presentation::default();
|
||||
assert!(!props(&default).contains("showMediaCtrls"));
|
||||
assert!(round(&default).show.show_media_controls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflection_angles_and_skews_out_of_range_are_written_inside_the_schema() {
|
||||
let mut p = Presentation::default();
|
||||
|
||||
@@ -1173,12 +1173,6 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide);
|
||||
}
|
||||
// #1864: the web popup's Previous/Next buttons send the DOM key names.
|
||||
for (name, slide) in [("ArrowRight", 1), ("ArrowLeft", 0)] {
|
||||
bridge.borrow_mut().commands.push(Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{name}");
|
||||
}
|
||||
bridge.borrow_mut().commands.push(Command::End);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().show.is_none());
|
||||
|
||||
@@ -94,7 +94,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
show.zoom_arm = false;
|
||||
}
|
||||
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
|
||||
if let Some(key) = egui::Key::from_name(&name) {
|
||||
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
|
||||
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
|
||||
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user