mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-08 11:29:33 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cb3033e21 | ||
|
|
73ca6d6ccc |
@@ -11,10 +11,6 @@ use crate::geom::{Op, P};
|
||||
/// Line segments per Bézier when a crossing shape is flattened.
|
||||
const CURVE_STEPS: usize = 16;
|
||||
|
||||
/// Point-against-edge tests one clipped item may cost (all its subpaths together); an item over
|
||||
/// it is dropped, as a single polygon over it already was.
|
||||
const MAX_CLIP_WORK: usize = 1_000_000;
|
||||
|
||||
/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included).
|
||||
pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> {
|
||||
let mut b: Option<[f64; 4]> = None;
|
||||
@@ -104,20 +100,14 @@ fn is_rect(clip: &[P]) -> bool {
|
||||
|
||||
/// Sutherland–Hodgman: `poly` cut by convex `clip`.
|
||||
pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
||||
clip_polygon_within(poly, clip, &mut { MAX_CLIP_WORK }).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`clip_polygon`] charging each point visited against each clip edge to `budget`: the polygon
|
||||
/// can gain a vertex at every edge, so its cost isn't known from its input size alone. `None`
|
||||
/// once the budget is spent.
|
||||
fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) -> Option<Vec<P>> {
|
||||
if poly.len().saturating_mul(clip.len()) > 1_000_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut poly = poly.to_vec();
|
||||
for i in 0..clip.len() {
|
||||
// The work actually done: this edge visits the polygon as it now stands.
|
||||
if poly.len() > *budget {
|
||||
return None;
|
||||
if poly.len().saturating_mul(clip.len() - i) > 1_000_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
*budget -= poly.len();
|
||||
let (a, b) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||
let keep = |p: P| cross(a, b, p) >= 0.0;
|
||||
let cut = |p: P, q: P| {
|
||||
@@ -143,7 +133,7 @@ fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) -> Option<Vec
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(poly)
|
||||
poly
|
||||
}
|
||||
|
||||
fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool {
|
||||
@@ -168,12 +158,9 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
|
||||
return Vec::new();
|
||||
}
|
||||
let subs = flatten(&ops);
|
||||
if too_much_work(&subs, clip, usize::from(fill.is_some()) + usize::from(stroke.is_some())) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if fill.is_some() {
|
||||
let Some(f) = clip_fill(&subs, clip) else { return Vec::new() };
|
||||
let f = clip_fill(&subs, clip);
|
||||
if !f.is_empty() {
|
||||
out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() });
|
||||
}
|
||||
@@ -194,11 +181,7 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
|
||||
if disjoint(b, cb) {
|
||||
return Vec::new();
|
||||
}
|
||||
let subs = flatten(&ops);
|
||||
if too_much_work(&subs, clip, 1) {
|
||||
return Vec::new();
|
||||
}
|
||||
let f = clip_fill(&subs, clip).unwrap_or_default();
|
||||
let f = clip_fill(&flatten(&ops), clip);
|
||||
if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] }
|
||||
}
|
||||
Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(),
|
||||
@@ -247,28 +230,17 @@ pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec<P>, bool)> {
|
||||
subs
|
||||
}
|
||||
|
||||
/// Whether cutting every subpath of one item by `clip` would exceed [`MAX_CLIP_WORK`]; the
|
||||
/// per-polygon check alone lets many small subpaths each spend the whole allowance. `passes` is
|
||||
/// how many clipping passes the item gets (fill and stroke each cut every subpath), so one item
|
||||
/// shares a single allowance between them.
|
||||
fn too_much_work(subs: &[(Vec<P>, bool)], clip: &[P], passes: usize) -> bool {
|
||||
subs.iter().map(|s| s.0.len() + 1).sum::<usize>().saturating_mul(clip.len()).saturating_mul(passes) > MAX_CLIP_WORK
|
||||
}
|
||||
|
||||
/// The clipped fill of every subpath, its clipping work shared by all of them: `None` (the whole
|
||||
/// item goes) once [`MAX_CLIP_WORK`] is spent.
|
||||
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Option<Vec<Op>> {
|
||||
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
let mut ops = Vec::new();
|
||||
let mut budget = MAX_CLIP_WORK;
|
||||
for (pts, _) in subs {
|
||||
let poly = clip_polygon_within(pts, clip, &mut budget)?;
|
||||
let poly = clip_polygon(pts, clip);
|
||||
if poly.len() >= 3 {
|
||||
ops.push(Op::M(poly[0]));
|
||||
ops.extend(poly[1..].iter().map(|p| Op::L(*p)));
|
||||
ops.push(Op::Z);
|
||||
}
|
||||
}
|
||||
Some(ops)
|
||||
ops
|
||||
}
|
||||
|
||||
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
|
||||
@@ -2074,63 +2074,3 @@ fn clip_polygon_work_bound() {
|
||||
let clipped = crate::cliprect::clip_polygon(&poly, &clip);
|
||||
assert!(clipped.is_empty(), "excessive polygon clip product rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_polygon_charges_actual_work_not_projected_growth() {
|
||||
// Two convex 1,000-gons: the product is exactly the budget, and a cut that adds a vertex must
|
||||
// not make a later step look over budget.
|
||||
let gon = |cx: f64| -> Vec<(f64, f64)> {
|
||||
(0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect()
|
||||
};
|
||||
let clipped = crate::cliprect::clip_polygon(&gon(0.0), &gon(30.0));
|
||||
assert!(clipped.len() > 100, "the intersection is kept: {}", clipped.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_work_is_shared_by_fill_and_stroke() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
// One zigzag subpath crossing a 512-gon's edge: about 510k point-edge tests per pass, so a
|
||||
// fill alone fits the allowance but a fill plus a stroke would spend it twice.
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
let mut ops = vec![Op::M((0.0, 95.0))];
|
||||
ops.extend((1..1000).map(|i| Op::L((i as f64 * 0.19, if i % 2 == 0 { 95.0 } else { 300.0 }))));
|
||||
ops.push(Op::Z);
|
||||
let raw = |fill, stroke| Raw::Path { ops: ops.clone(), fill, stroke, even_odd: false, region: None };
|
||||
let black = [0, 0, 0, 255];
|
||||
assert!(!crate::cliprect::clip_raw(raw(Some(black), None), &clip).is_empty(), "a fill alone is within the allowance");
|
||||
assert!(crate::cliprect::clip_raw(raw(Some(black), Some((black, 1.0))), &clip).is_empty(), "fill and stroke share one allowance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_work_is_bounded_across_subpaths() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
// 512-gon clip; 4,000 small triangles crossing its edge: each under the per-polygon cap, together
|
||||
// far over it, for both the fill and the stroke.
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
let tri = |x: f64| [Op::M((x, 95.0)), Op::L((x + 1.0, 95.0)), Op::L((x, 300.0)), Op::Z];
|
||||
let many: Vec<Op> = (0..4000).flat_map(|i| tri(i as f64 % 200.0)).collect();
|
||||
let raw = |ops| Raw::Path { ops, fill: Some([0, 0, 0, 255]), stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false, region: None };
|
||||
assert!(crate::cliprect::clip_raw(raw(many), &clip).is_empty(), "the whole item's clipping work is bounded");
|
||||
assert_eq!(crate::cliprect::clip_raw(raw(tri(100.0).to_vec()), &clip).len(), 2, "a small crossing shape is still clipped");
|
||||
}
|
||||
|
||||
/// Clipping a fill can add a vertex at every clip edge, so a polygon's work isn't its input size:
|
||||
/// shapes that pass the up-front estimate but grow while clipped are charged as they grow, against
|
||||
/// one budget for the whole item.
|
||||
#[test]
|
||||
fn growing_fill_clips_share_one_budget() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
// A triangle around the whole circle: clipped, it becomes the 512-gon, about 130,000 steps.
|
||||
let tri = [Op::M((-1000.0, -1000.0)), Op::L((1000.0, -1000.0)), Op::L((100.0, 1000.0)), Op::Z];
|
||||
let raw = |n: usize| Raw::Path { ops: tri.repeat(n), fill: Some([0, 0, 0, 255]), stroke: None, even_odd: false, region: None };
|
||||
// 400 of them pass the estimate (400 x 4 x 512 is under a million) but cost about 52 million.
|
||||
assert!(crate::cliprect::clip_raw(raw(400), &clip).is_empty(), "the growing work is charged");
|
||||
assert_eq!(crate::cliprect::clip_raw(raw(2), &clip).len(), 1, "a couple of them are still clipped");
|
||||
}
|
||||
|
||||
+20
-17
@@ -663,52 +663,55 @@ fn read_slides(
|
||||
let pkg = imp.pkg;
|
||||
let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
||||
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
|
||||
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new();
|
||||
// A notes or comments part belongs to one slide; one shared by many must not be read (and
|
||||
// copied) once per slide that names it.
|
||||
let mut owned_parts: HashSet<String> = HashSet::new();
|
||||
for (file_id, path, id) in slide_ids {
|
||||
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
|
||||
let part = Part::new(pkg, path, doc);
|
||||
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
|
||||
let mut slide = read_slide(imp, &part, doc, *id, layout);
|
||||
if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) {
|
||||
if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) {
|
||||
slide.notes = notes;
|
||||
}
|
||||
read_slide_comments(pkg, pres_part, &part, &mut slide);
|
||||
read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts);
|
||||
file_slide_ids.insert(*file_id, *id);
|
||||
p.slides.push(Arc::new(slide));
|
||||
}
|
||||
file_slide_ids
|
||||
}
|
||||
|
||||
/// The slide's notes text, read once per notes part.
|
||||
fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap<String, TextBody>) -> Option<TextBody> {
|
||||
/// The slide's notes text. A notes part is the notes of the first slide that names it.
|
||||
fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet<String>) -> Option<TextBody> {
|
||||
let n = part.rels.first_of("notesSlide")?;
|
||||
let key = opc::norm(&n.target);
|
||||
if let Some(cached) = cache.get(&key) {
|
||||
return Some(cached.clone());
|
||||
if !owned.insert(opc::norm(&n.target)) {
|
||||
log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target);
|
||||
return None;
|
||||
}
|
||||
let pkg = imp.pkg;
|
||||
let nd = load(pkg, &n.target)?;
|
||||
let np = Part::new(pkg, &n.target, &nd);
|
||||
let notes = read_notes(imp, &np, &nd.root);
|
||||
cache.insert(key, notes.clone());
|
||||
Some(notes)
|
||||
Some(read_notes(imp, &np, &nd.root))
|
||||
}
|
||||
|
||||
/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
|
||||
/// writes both): the modern one wins, its flattened legacy copy is skipped.
|
||||
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) {
|
||||
let mut parts: Vec<(Doc, bool)> = vec![];
|
||||
/// Each comments part is read once, for the first slide that names it.
|
||||
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet<String>) {
|
||||
let mut parts: Vec<(Doc, bool, bool)> = vec![];
|
||||
for c in part.rels.all_of("comments") {
|
||||
// An already-owned part is not re-read, but its kind still decides modern precedence.
|
||||
let fresh = owned.insert(opc::norm(&c.target));
|
||||
if let Some(cd) = pkg.xml(&c.target) {
|
||||
// The content type names the modern part; a package that types it only through a
|
||||
// Default entry instead of an Override is still modern when its root is p188.
|
||||
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
|
||||
parts.push((cd, modern));
|
||||
parts.push((cd, modern, fresh));
|
||||
}
|
||||
}
|
||||
let has_modern = parts.iter().any(|(_, m)| *m);
|
||||
for (cd, modern) in parts {
|
||||
if has_modern && !modern {
|
||||
let has_modern = parts.iter().any(|(_, m, _)| *m);
|
||||
for (cd, modern, fresh) in parts {
|
||||
if !fresh || (has_modern && !modern) {
|
||||
continue;
|
||||
}
|
||||
slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern));
|
||||
|
||||
@@ -146,6 +146,33 @@ fn comments_on_several_slides_round_trip() {
|
||||
assert_eq!(q.slides[2].comments[0].text, "Slide three");
|
||||
}
|
||||
|
||||
/// A slide that names a modern part another slide already owns, plus its own legacy part, must
|
||||
/// not import the legacy copy: modern precedence holds even when the modern part is not re-read.
|
||||
#[test]
|
||||
fn owned_modern_part_still_suppresses_later_legacy() {
|
||||
let mut p = deck();
|
||||
let layout = p.slides[0].layout;
|
||||
let s2 = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
p.slides.push(Arc::new(s2));
|
||||
Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }];
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
|
||||
if f.name() == "ppt/slides/_rels/slide2.xml.rels" {
|
||||
std::io::Write::write_all(&mut out, rels2.as_bytes()).expect("write");
|
||||
} else {
|
||||
std::io::copy(&mut f, &mut out).expect("copy");
|
||||
}
|
||||
}
|
||||
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
|
||||
assert_eq!(q.slides[0].comments.len(), 2);
|
||||
assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments);
|
||||
}
|
||||
|
||||
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
|
||||
/// flattened replies.
|
||||
#[test]
|
||||
|
||||
@@ -287,3 +287,34 @@ fn unreadable_slide_is_reported() {
|
||||
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
|
||||
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
|
||||
}
|
||||
|
||||
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
|
||||
/// slide that names it.
|
||||
#[test]
|
||||
fn a_shared_notes_part_is_read_once() {
|
||||
use std::sync::Arc;
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = p.slides[0].layout;
|
||||
for _ in 0..3 {
|
||||
let s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
p.slides.push(Arc::new(s));
|
||||
}
|
||||
for (i, s) in p.slides.iter_mut().enumerate() {
|
||||
Arc::make_mut(s).notes = deckcraft_model::text::TextBody::from_text(&format!("notes {i}"));
|
||||
}
|
||||
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||
for (n, d) in es.iter_mut() {
|
||||
if n.starts_with("ppt/slides/_rels/") {
|
||||
let x = String::from_utf8(d.clone()).expect("utf8");
|
||||
let x = x
|
||||
.replace("notesSlide2.xml", "notesSlide1.xml")
|
||||
.replace("notesSlide3.xml", "notesSlide1.xml")
|
||||
.replace("notesSlide4.xml", "notesSlide1.xml");
|
||||
*d = x.into_bytes();
|
||||
}
|
||||
}
|
||||
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||
assert_eq!(q.slides.len(), 4);
|
||||
assert_eq!(q.slides[0].notes.text(), "notes 0");
|
||||
assert!(q.slides[1..].iter().all(|s| s.notes.text().is_empty()), "a shared notes part is not copied");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user