mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-05 01:49:41 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
953ae8d060 | ||
|
|
1b2b48c0d7 | ||
|
|
9e7fab1038 |
+26
-37
@@ -301,14 +301,18 @@ 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 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();
|
||||
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();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -494,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;
|
||||
@@ -573,17 +574,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: HashMap<usize, Vec<char>> = 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.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();
|
||||
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();
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -663,28 +667,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 {
|
||||
@@ -701,26 +696,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);
|
||||
}
|
||||
|
||||
@@ -728,17 +728,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-"));
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ pub struct Imp<'a> {
|
||||
pub tag_bytes: usize,
|
||||
/// The tag budget ran out and the warning was given.
|
||||
pub tags_capped: bool,
|
||||
/// Bytes of preserved theme XML kept by the masters read so far.
|
||||
pub theme_extra_bytes: usize,
|
||||
/// Shapes read from SmartArt drawings so far (see `shapes::MAX_SMARTART_SHAPES`).
|
||||
pub smartart_shapes: usize,
|
||||
/// Bytes of SmartArt layout, style and colour definitions copied into diagrams so far.
|
||||
pub diagram_xml_bytes: usize,
|
||||
/// Whether a part has relationships of its own, by normalized part name: looked up once
|
||||
/// however many embedded objects point at the part.
|
||||
pub has_rels: HashMap<String, bool>,
|
||||
@@ -449,6 +455,9 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
|
||||
tags_by_part: HashMap::new(),
|
||||
tag_bytes: 0,
|
||||
tags_capped: false,
|
||||
theme_extra_bytes: 0,
|
||||
smartart_shapes: 0,
|
||||
diagram_xml_bytes: 0,
|
||||
has_rels: HashMap::new(),
|
||||
};
|
||||
let part = Part::new(&pkg, &theme_path, &doc);
|
||||
@@ -491,6 +500,9 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
tags_by_part: HashMap::new(),
|
||||
tag_bytes: 0,
|
||||
tags_capped: false,
|
||||
theme_extra_bytes: 0,
|
||||
smartart_shapes: 0,
|
||||
diagram_xml_bytes: 0,
|
||||
has_rels: HashMap::new(),
|
||||
};
|
||||
// Slide ids first so hyperlinks anywhere can point at slides.
|
||||
|
||||
@@ -559,9 +559,20 @@ fn diagram_data(imp: &mut Imp, target: &str) -> Option<deckcraft_model::MediaId>
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// Shapes all SmartArt drawings of one presentation may add; a frame read after that is left as a
|
||||
/// placeholder. Each frame that references a drawing reads it again.
|
||||
const MAX_SMARTART_SHAPES: usize = 250_000;
|
||||
|
||||
/// SmartArt definition XML (layout, style, colours) all diagrams of one presentation may copy.
|
||||
const MAX_DIAGRAM_XML_BYTES: usize = 32 << 20;
|
||||
|
||||
fn count_shapes(shapes: &[Shape]) -> usize {
|
||||
shapes.iter().map(|s| 1 + count_shapes(s.children())).sum()
|
||||
}
|
||||
|
||||
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
|
||||
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
||||
if depth > 8 {
|
||||
if depth > 8 || imp.smartart_shapes >= MAX_SMARTART_SHAPES {
|
||||
return None;
|
||||
}
|
||||
let rel_ids = gd.child("relIds")?;
|
||||
@@ -573,10 +584,13 @@ fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) ->
|
||||
let dpart = Part::new(imp.pkg, &drawing_rel, &d);
|
||||
let tree = d.root.child("spTree")?;
|
||||
let mut sub = IdCtx { fresh: true, ..Default::default() };
|
||||
let before = imp.smartart_shapes;
|
||||
let children = sp_tree(imp, &dpart, &mut sub, tree, depth + 1);
|
||||
if children.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Counted once here, nested SmartArt included: a drawing that frames reuse counts every time it is read.
|
||||
imp.smartart_shapes = before.saturating_add(count_shapes(&children));
|
||||
let children = smartart_text_rects(imp, &mut sub, tree, children, depth + 1);
|
||||
let mut g = frame.clone();
|
||||
let x = frame.xfrm.unwrap_or_default();
|
||||
@@ -704,15 +718,22 @@ fn text_area(geom: &Geom, w: f64, h: f64) -> deckcraft_geom::Rect {
|
||||
|
||||
/// The outline of a SmartArt graphic (for the Text Pane), with its layout, quick style and colour
|
||||
/// parts kept to be written back.
|
||||
fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option<deckcraft_model::Diagram> {
|
||||
fn diagram_outline(imp: &mut Imp, part: &Part, rel_ids: &El, data: &El) -> Option<deckcraft_model::Diagram> {
|
||||
let nodes = crate::dgm::outline(data);
|
||||
if nodes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (pkg, spent) = (imp.pkg, std::cell::Cell::new(imp.diagram_xml_bytes));
|
||||
let raw = |attr: &str| -> Option<String> {
|
||||
let rel = part.rels.get(rel_ids.attr(attr)?)?;
|
||||
let bytes = imp.pkg.get(&rel.target)?;
|
||||
(bytes.len() <= 4 << 20).then(|| String::from_utf8(bytes.to_vec()).ok()).flatten()
|
||||
let bytes = pkg.get(&rel.target)?;
|
||||
// Every diagram copies its definitions, so the copies share one presentation-wide budget.
|
||||
if bytes.len() > 4 << 20 || spent.get().saturating_add(bytes.len()) > MAX_DIAGRAM_XML_BYTES {
|
||||
return None;
|
||||
}
|
||||
let xml = String::from_utf8(bytes.to_vec()).ok()?;
|
||||
spent.set(spent.get() + xml.len());
|
||||
Some(xml)
|
||||
};
|
||||
let layout_xml = raw("r:lo");
|
||||
let (layout_id, cat) = layout_xml
|
||||
@@ -733,6 +754,8 @@ fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option<de
|
||||
let pr = data.path(&["ptLst"]).and_then(|l| l.children_named("pt").find(|p| p.attr("type") == Some("doc"))).and_then(|p| p.child("prSet"));
|
||||
let style = crate::dgm::style_for(pr.and_then(|p| p.attr("qsTypeId")).unwrap_or("")).to_string();
|
||||
let colors = crate::dgm::colors_for(pr.and_then(|p| p.attr("csTypeId")).unwrap_or(""));
|
||||
let (style_xml, colors_xml) = (raw("r:qs"), raw("r:cs"));
|
||||
imp.diagram_xml_bytes = spent.get();
|
||||
Some(deckcraft_model::Diagram {
|
||||
kind: crate::dgm::kind_for(&id, &cat).to_string(),
|
||||
nodes,
|
||||
@@ -741,8 +764,8 @@ fn diagram_outline(imp: &Imp, part: &Part, rel_ids: &El, data: &El) -> Option<de
|
||||
imported: true,
|
||||
layout_id: id,
|
||||
layout_xml,
|
||||
style_xml: raw("r:qs"),
|
||||
colors_xml: raw("r:cs"),
|
||||
style_xml,
|
||||
colors_xml,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ fn font_set(e: Option<&El>) -> FontSet {
|
||||
FontSet { latin: tf("latin"), ea: tf("ea"), cs: tf("cs"), scripts }
|
||||
}
|
||||
|
||||
/// Bytes of preserved theme XML (object defaults and extra colour schemes) all masters of one
|
||||
/// presentation may keep.
|
||||
const MAX_THEME_EXTRA_BYTES: usize = 16 << 20;
|
||||
|
||||
pub fn read_theme(imp: &mut Imp, part: &Part, root: &El) -> Theme {
|
||||
let mut t = Theme { name: root.attr("name").unwrap_or("Theme").to_string(), ..Default::default() };
|
||||
let Some(te) = root.child("themeElements") else { return t };
|
||||
@@ -89,8 +93,13 @@ pub fn read_theme(imp: &mut Imp, part: &Part, root: &El) -> Theme {
|
||||
.filter(|e| !super::has_rel_refs(e))
|
||||
.map(|e| part.keep(e))
|
||||
.collect();
|
||||
if !extra.is_empty() {
|
||||
// Every master that references this theme keeps its own copy of the preserved XML, so the
|
||||
// copies share one budget for the presentation.
|
||||
if !extra.is_empty() && imp.theme_extra_bytes.saturating_add(extra.len()) <= MAX_THEME_EXTRA_BYTES {
|
||||
imp.theme_extra_bytes += extra.len();
|
||||
t.raw_extra = Some(extra);
|
||||
} else if !extra.is_empty() {
|
||||
log::warn!("pptx: theme object defaults not kept; the preserved-XML budget is spent");
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user