Compare commits

..
Author SHA1 Message Date
autocraft-bot[bot] 6ae02d2fc1 Apply review fixes
- crates/pptx/src/write/chart.rs
2026-11-11 22:29:54 +00:00
Dylan M. Taylor 4ea8f8b149 fix(pptx): bound the chartEx writer's caches and series 2026-11-11 22:06:21 +00:00
6 changed files with 70 additions and 232 deletions
-5
View File
@@ -1,10 +1,6 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "16 1 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -34,7 +30,6 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
app-name: deckcraft
app-title: DeckCraft
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
+1 -17
View File
@@ -811,14 +811,6 @@ fn decode_frame(job: &FrameJob, pool: &Pool, stats: &mut DecodeStats) -> Result<
Ok((rf, pic))
}
/// Bytes all parallel tile states may hold of whole-frame CDEF and restoration metadata; past it
/// the tiles decode one after another on the frame's own metadata.
const TILE_METADATA_BUDGET: usize = 256 << 20;
fn tile_metadata_fits(tiles: usize, per_tile: usize) -> bool {
tiles.checked_mul(per_tile).is_some_and(|n| n <= TILE_METADATA_BUDGET)
}
/// Decode the tiles of a frame (tile number, data), in parallel when several; returns the final
/// CDFs of tile context_update_tile_id.
fn decode_tiles(pool: &Pool, fs: &mut FrameState, cdfs: &Cdfs, tiles: &[(usize, Vec<u8>)]) -> Result<Option<Box<Cdfs>>> {
@@ -832,7 +824,7 @@ fn decode_tiles(pool: &Pool, fs: &mut FrameState, cdfs: &Cdfs, tiles: &[(usize,
};
// Intra block copy reads the current frame through the tile's own buffer: keep whole-frame
// buffers (tiles one after another) for it.
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc && tile_metadata_fits(tiles.len(), fs.tile_metadata_bytes()) {
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc {
let mut states: Vec<TileState> = tiles
.iter()
.map(|(num, _)| {
@@ -1117,14 +1109,6 @@ mod guard_tests {
#[cfg(test)]
mod budget_tests {
use super::*;
#[test]
fn tile_metadata_copies_are_budgeted() {
assert!(tile_metadata_fits(64, 1 << 20));
assert!(!tile_metadata_fits(4096, 1 << 20));
assert!(!tile_metadata_fits(usize::MAX, 2));
}
use deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET;
fn color(mono: bool) -> crate::header::ColorConfig {
-7
View File
@@ -253,13 +253,6 @@ impl FrameState {
self.lr = t.lr;
}
/// Bytes of CDEF and loop-restoration metadata each parallel tile state copies whole.
pub fn tile_metadata_bytes(&self) -> usize {
let lr = &self.lr;
// 1 (type) + 6 (Wiener) + 1 (SGR set) + 2 (SGR weights) bytes per unit.
self.cdef.len() + lr.lr_type.iter().map(|v| v.len() * 10).sum::<usize>()
}
/// Private buffers for the tile covering mode info rows [r0, r1) x columns [c0, c1); tiles
/// on the right / bottom frame edge also cover the allocation margins.
pub fn tile_state(&self, r0: usize, r1: usize, c0: usize, c1: usize) -> TileState {
+26 -194
View File
@@ -10,7 +10,6 @@ mod timing;
pub(crate) use chart::{chart_from as chart_from_el, read_chart_xml};
pub(crate) use chartex::{chartex_from, read_chartex_xml};
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@@ -100,22 +99,21 @@ pub struct Part {
pub ns: Vec<(String, String)>,
/// Prefixes bound below the root only (on a wrapper such as `mc:Choice`), each to one namespace.
pub inner_ns: Vec<(String, String)>,
/// Bytes of namespace declarations this part may still copy onto kept fragments.
ns_budget: Cell<usize>,
}
impl Part {
pub fn new(pkg: &Package, name: &str, doc: &Doc) -> Self {
let ns = doc.ns_decls();
Part { name: name.to_string(), rels: pkg.rels(name), inner_ns: inner_decls(&doc.root, &ns), ns, ns_budget: Cell::new(MAX_COPIED_NS_BYTES) }
Part { name: name.to_string(), rels: pkg.rels(name), inner_ns: inner_decls(&doc.root, &ns), ns }
}
/// Serialize an element with the namespace declarations it needs from the part root.
pub fn keep(&self, el: &El) -> String {
keep_with(el, &[&self.ns], Some(&self.ns_budget))
keep_xml(el, &self.ns)
}
/// Like [`Part::keep`], also declaring prefixes that only an enclosing wrapper binds.
pub fn keep_inner(&self, el: &El) -> String {
keep_with(el, &[&self.ns, &self.inner_ns], Some(&self.ns_budget))
let ns: Vec<(String, String)> = self.ns.iter().chain(&self.inner_ns).cloned().collect();
keep_xml(el, &ns)
}
}
@@ -150,81 +148,36 @@ fn inner_decls(root: &El, ns: &[(String, String)]) -> Vec<(String, String)> {
out
}
/// Longest namespace name copied onto a kept fragment or a resolved `mc` branch. Real ones are
/// URIs of well under 100 bytes; a longer one is not copied, so one declaration cannot be
/// multiplied by every fragment that uses its prefix.
const MAX_NS_VALUE: usize = 2048;
/// Namespace declaration bytes one part may copy onto all its kept fragments together; past it a
/// fragment keeps its markup but is not given further declarations.
const MAX_COPIED_NS_BYTES: usize = 8 << 20;
/// Prefixes `el` and its descendants use in element and attribute names, and those listed in
/// `mc:Ignorable` or `mc:MustUnderstand` (which must stay declared for the markup-compatibility rules to hold),
/// and those starting a QName-like attribute value.
fn used_prefixes(el: &El) -> HashSet<&str> {
fn rec<'a>(e: &'a El, used: &mut HashSet<&'a str>, depth: usize) {
/// Serialize `el`, declaring every prefix it uses that the source root declared.
pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String {
let mut used = HashSet::new();
fn rec(e: &El, used: &mut HashSet<String>, depth: usize) {
if depth > crate::xml::MAX_DEPTH {
return;
}
if let Some((p, _)) = e.name.split_once(':') {
used.insert(p);
used.insert(p.to_string());
}
for (k, v) in &e.attrs {
if let Some((p, local)) = k.split_once(':')
for (k, _) in &e.attrs {
if let Some((p, _)) = k.split_once(':')
&& p != "xmlns"
&& p != "xml"
{
used.insert(p);
if local == "Ignorable" || local == "MustUnderstand" {
used.extend(v.split_whitespace());
}
}
// QName-valued attributes (`xsi:type="q:Item"`) need their prefix declared too.
if k != "xmlns" && !k.starts_with("xmlns:") {
for t in v.split_whitespace() {
if let Some((p, _)) = t.split_once(':')
&& !p.is_empty()
{
used.insert(p);
}
}
used.insert(p.to_string());
}
}
for c in e.elements() {
rec(c, used, depth + 1);
}
}
let mut used = HashSet::new();
rec(el, &mut used, 0);
used
}
/// Serialize `el`, declaring every prefix it uses that the source root declared.
pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String {
keep_with(el, &[ns], None)
}
fn keep_with(el: &El, decls: &[&[(String, String)]], budget: Option<&Cell<usize>>) -> String {
let used = used_prefixes(el);
let mut have: HashSet<&str> = el.attrs.iter().map(|(a, _)| a.as_str()).collect();
let mut extra = Vec::new();
for (k, v) in decls.iter().flat_map(|d| d.iter()) {
let mut el = el.clone();
for (k, v) in ns {
let p = k.strip_prefix("xmlns:").unwrap_or("");
if !p.is_empty() && used.contains(p) && v.len() <= MAX_NS_VALUE && !have.contains(k.as_str()) {
let cost = k.len() + v.len();
if let Some(b) = budget {
if b.get() < cost {
continue;
}
b.set(b.get() - cost);
}
have.insert(k);
extra.push((k.clone(), v.clone()));
if !p.is_empty() && used.contains(p) && !el.attrs.iter().any(|(a, _)| a == k) {
el.attrs.push((k.clone(), v.clone()));
}
}
let mut el = el.clone();
el.attrs.extend(extra);
el.to_xml()
}
@@ -373,18 +326,10 @@ fn kept_parts(pkg: &Package, pres_path: &str) -> Vec<deckcraft_model::KeptPart>
// The part's own relationships (customXml item → itemProps) and their targets.
let rp = opc::rels_path(&name);
if let Some(rels_xml) = text(&rp) {
let mut capped = false;
for sub in pkg.rels(&name).items.iter().filter(|s| !s.external && keepable(&s.target)) {
// Checked before the part is read: repeated relationships to one large part
// must not copy it again each time.
if out.iter().any(|k| k.name.eq_ignore_ascii_case(&sub.target)) {
continue;
}
if out.len() >= 400 {
capped = true;
continue;
}
if let Some(x) = text(&sub.target) {
if let Some(x) = text(&sub.target)
&& !out.iter().any(|k| k.name.eq_ignore_ascii_case(&sub.target))
{
out.push(KeptPart {
content_type: pkg.content_type(&sub.target).unwrap_or_default(),
name: sub.target.clone(),
@@ -393,9 +338,7 @@ fn kept_parts(pkg: &Package, pres_path: &str) -> Vec<deckcraft_model::KeptPart>
});
}
}
if !capped && out.len() < 400 {
out.push(KeptPart { name: rp, xml: rels_xml, ..Default::default() });
}
out.push(KeptPart { name: rp, xml: rels_xml, ..Default::default() });
}
}
}
@@ -432,39 +375,15 @@ fn resolve_mc_rels(el: &mut El, depth: usize, rels: Option<&Rels>) {
});
let branch = choice.or_else(|| ac.child("Fallback"));
if let Some(b) = branch {
// The branch's namespace declarations (the first usable one of each name,
// in order), indexed by prefix once: each child then looks up the prefixes
// it uses instead of scanning every declaration.
let mut decls: Vec<(&String, &String)> = Vec::new();
// An oversized declaration still shadows an outer one of its name: its
// content is left unbound rather than bound to the outer namespace.
let mut by_prefix: HashMap<&str, Option<usize>> = HashMap::new();
for (k, v) in b.attrs.iter().chain(ac.attrs.iter()) {
let prefix = if k == "xmlns" { Some("") } else { k.strip_prefix("xmlns:") };
if let Some(p) = prefix
&& !by_prefix.contains_key(p)
{
by_prefix.insert(p, (v.len() <= MAX_NS_VALUE).then_some(decls.len()));
if v.len() <= MAX_NS_VALUE {
decls.push((k, v));
}
}
}
for n in &b.children {
if let Node::El(e) = n {
// Carry the namespace declarations from the branch that its content
// uses (all of them per child made children x declarations copies).
let used = used_prefixes(e);
let have: HashSet<&str> = e.attrs.iter().map(|(a, _)| a.as_str()).collect();
let mut wanted: Vec<usize> = std::iter::once("")
.chain(used.iter().copied())
.filter_map(|p| by_prefix.get(p).copied().flatten())
.filter(|&i| !have.contains(decls[i].0.as_str()))
.collect();
wanted.sort_unstable();
wanted.dedup();
let mut e = e.clone();
e.attrs.extend(wanted.into_iter().map(|i| (decls[i].0.clone(), decls[i].1.clone())));
// Carry namespace declarations from the branch onto its content.
for (k, v) in b.attrs.iter().chain(ac.attrs.iter()) {
if k.starts_with("xmlns") && !e.attrs.iter().any(|(a, _)| a == k) {
e.attrs.push((k.clone(), v.clone()));
}
}
out.push(Node::El(e));
}
}
@@ -1526,90 +1445,3 @@ fn header_footer(p: &Presentation) -> HeaderFooter {
}
hf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mc_branches_carry_only_the_declarations_their_content_uses() {
// 2,000 declarations on the branch and 2,000 children: each child gets the one it uses
// (and the default), not all 2,000.
let decls: String = (0..2000).map(|i| format!(r#" xmlns:n{i}="urn:n{i}""#)).collect();
let kids: String = (0..2000).map(|i| format!("<n{i}:x/>")).collect();
let x = format!(
r#"<r xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Fallback xmlns="urn:d"{decls}>{kids}</mc:Fallback></mc:AlternateContent></r>"#
);
let mut d = crate::xml::parse(x.as_bytes()).unwrap();
resolve_mc(&mut d.root, 0);
let kids: Vec<&El> = d.root.elements().collect();
assert_eq!(kids.len(), 2000);
assert_eq!(kids[7].attrs, vec![("xmlns".to_string(), "urn:d".to_string()), ("xmlns:n7".to_string(), "urn:n7".to_string())]);
}
/// The branch's own declaration of a prefix wins over the `AlternateContent`'s; an oversized
/// one still shadows it (its content stays unbound); a child's own declaration is left alone.
#[test]
fn mc_branch_declarations_keep_their_precedence() {
let long = "u".repeat(MAX_NS_VALUE + 1);
let x = format!(
r#"<r xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent xmlns:p="urn:ac" xmlns:q="urn:q-short"><mc:Fallback xmlns:p="urn:b" xmlns:q="{long}"><p:x/><q:y/><p:z xmlns:p="urn:own"/></mc:Fallback></mc:AlternateContent></r>"#
);
let mut d = crate::xml::parse(x.as_bytes()).unwrap();
resolve_mc(&mut d.root, 0);
let kids: Vec<&El> = d.root.elements().collect();
let attrs = |i: usize| kids[i].attrs.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>();
assert_eq!(attrs(0), ["xmlns:p=urn:b"]);
assert!(attrs(1).is_empty(), "{:?}", attrs(1));
assert_eq!(attrs(2), ["xmlns:p=urn:own"]);
}
#[test]
fn mc_branches_keep_prefixes_named_by_must_understand() {
let x = r#"<r xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Fallback xmlns:q="urn:q"><a mc:MustUnderstand="q"/></mc:Fallback></mc:AlternateContent></r>"#;
let mut d = crate::xml::parse(x.as_bytes()).unwrap();
resolve_mc(&mut d.root, 0);
let kid = d.root.elements().next().unwrap();
assert!(kid.attrs.iter().any(|(k, v)| k == "xmlns:q" && v == "urn:q"), "{:?}", kid.attrs);
}
#[test]
fn mc_branches_keep_prefixes_used_in_qname_values() {
let x = r#"<r xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"><mc:AlternateContent><mc:Fallback xmlns:q="urn:q" xmlns:z="urn:z"><n xmlns:xsi="urn:xsi" xsi:type="q:Item"/></mc:Fallback></mc:AlternateContent></r>"#;
let mut d = crate::xml::parse(x.as_bytes()).unwrap();
resolve_mc(&mut d.root, 0);
let kid = d.root.elements().next().unwrap();
assert!(kid.attrs.iter().any(|(k, v)| k == "xmlns:q" && v == "urn:q"), "{:?}", kid.attrs);
assert!(!kid.attrs.iter().any(|(k, _)| k == "xmlns:z"), "unused declarations stay out: {:?}", kid.attrs);
}
#[test]
fn kept_fragments_skip_oversized_namespace_names() {
let big = "u".repeat(MAX_NS_VALUE + 1);
let ns = vec![("xmlns:a".to_string(), "urn:a".to_string()), ("xmlns:b".to_string(), big)];
let d = crate::xml::parse(br#"<a:x xmlns:a="urn:a" xmlns:b="urn:b"><b:y/></a:x>"#).unwrap();
let mut el = d.root.clone();
el.attrs.clear();
let out = keep_xml(&el, &ns);
assert!(out.contains(r#"xmlns:a="urn:a""#), "{out}");
assert!(!out.contains("uuuu"), "the oversized declaration is not copied");
}
#[test]
fn copied_namespace_bytes_are_budgeted_per_part() {
let d = crate::xml::parse(br#"<a:x xmlns:a="urn:a"><a:y/></a:x>"#).unwrap();
let mut el = d.root.clone();
el.attrs.clear();
let part = Part {
name: String::new(),
rels: Default::default(),
ns: vec![("xmlns:a".to_string(), "urn:a".to_string())],
inner_ns: vec![],
ns_budget: Cell::new(15),
};
assert!(part.keep(&el).contains(r#"xmlns:a="urn:a""#), "within budget");
assert!(part.ns_budget.get() < 15);
let again = part.keep(&el);
assert!(!again.contains("xmlns:a"), "budget spent, no further declarations: {again}");
}
}
+1 -7
View File
@@ -741,13 +741,7 @@ mod tests {
// 200 `m:t` nested in each other around one word: the word, not 200 copies of it.
let x = format!(r#"<a14:m xmlns:a14="a" xmlns:m="m"><m:oMath>{}word{}</m:oMath></a14:m>"#, "<m:t>".repeat(200), "</m:t>".repeat(200));
let d = parse(x.as_bytes()).unwrap();
let part = Part {
name: "ppt/slides/slide1.xml".into(),
rels: Default::default(),
ns: vec![],
inner_ns: vec![],
ns_budget: std::cell::Cell::new(1 << 20),
};
let part = Part { name: "ppt/slides/slide1.xml".into(), rels: Default::default(), ns: vec![], inner_ns: vec![] };
let mut para = Paragraph::default();
push_math_run(&mut para, &part, &d.root);
assert_eq!(para.runs[0].text, "word");
+42 -2
View File
@@ -916,6 +916,7 @@ fn cx_text(w: &mut W, t: &str) {
/// A `cx:lvl` of points.
fn cx_level(w: &mut W, pts: &[String], format: Option<&str>) {
let pts = capped(pts);
let mut a = A::new().a("ptCount", pts.len());
if let Some(f) = format {
a = a.a("formatCode", f);
@@ -941,14 +942,39 @@ fn kept_region_layout(raw: &str) -> Option<String> {
lp.name.starts_with("cx:").then(|| lp.to_xml())
}
/// Points one `chartEx` part may carry in all, categories repeated for every series: Excel's 255
/// series x 32,000 points. Series past it are left out with a warning (the first is always written).
const MAX_CHARTEX_POINTS: usize = 255 * 32_000;
/// How many of `c`'s series fit [`MAX_CHARTEX_POINTS`] (at least one).
fn chartex_series_within_budget(c: &Chart) -> usize {
let level = |v: &Vec<String>| v.len().min(MAX_CACHE_POINTS);
let cats = if c.categories.is_empty() {
0
} else {
level(&c.categories).saturating_add(c.category_levels.iter().map(level).fold(0usize, usize::saturating_add))
};
let mut left = MAX_CHARTEX_POINTS;
for (n, s) in c.series.iter().enumerate() {
let cost = cats.saturating_add(s.values.len().min(MAX_CACHE_POINTS));
if n > 0 && cost > left {
log::warn!("pptx: chart part stopped after {n} series; its data passed {MAX_CHARTEX_POINTS} points");
return n;
}
left = left.saturating_sub(cost);
}
c.series.len()
}
/// Writes an Office 2016 chart from the model, as PowerPoint lays the part out: one `cx:data`
/// per series (categories and values), then the plot area with each series' layout, the axes
/// the layout uses, the title and the legend.
pub fn build_chartex(c: &Chart) -> Vec<u8> {
let n_series = chartex_series_within_budget(c);
let mut w = W::new();
w.open("cx:chartSpace", A::new().a("xmlns:a", NS_A).a("xmlns:r", NS_R).a("xmlns:cx", NS_CX));
w.open0("cx:chartData");
for (i, s) in c.series.iter().enumerate() {
for (i, s) in c.series.iter().enumerate().take(n_series) {
w.open("cx:data", A::new().a("id", i));
if !c.categories.is_empty() {
w.open("cx:strDim", A::new().a("type", "cat"));
@@ -988,7 +1014,7 @@ pub fn build_chartex(c: &Chart) -> Vec<u8> {
_ => "clusteredColumn",
};
let one = !matches!(c.kind, ChartType::BoxWhisker);
for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { usize::MAX }) {
for (i, s) in c.series.iter().enumerate().take(if one { 1 } else { n_series }) {
w.open("cx:series", A::new().a("layoutId", layout).a("uniqueId", format!("{{DC00000{i}-0000-4000-8000-000000000000}}")));
cx_text(&mut w, &s.name);
if c.series_data_labels(s) {
@@ -1187,6 +1213,20 @@ mod tests {
mod cache_cap_tests {
use super::*;
#[test]
fn a_chartex_part_stops_adding_series_past_excels_point_count() {
const { assert!(MAX_CHARTEX_POINTS >= 255 * 32_000) };
let mut c = Chart::sample(ChartType::Waterfall);
c.categories = vec![String::new(); 32_000];
let mut s = c.series[0].clone();
s.values = Vec::new();
c.series = vec![s; 300];
// 32,000 categories per series: 255 fit exactly, the 256th does not.
assert_eq!(chartex_series_within_budget(&c), 255);
c.series.truncate(3);
assert_eq!(chartex_series_within_budget(&c), 3);
}
/// A cache longer than the cap is cut, never refused; anything a real chart holds (32,000
/// points per series) is far under it. Zero-sized elements, so the test allocates nothing.
#[test]