diff --git a/crates/oxml-drawing/README.md b/crates/oxml-drawing/README.md index 4fe15078..c37fee23 100644 --- a/crates/oxml-drawing/README.md +++ b/crates/oxml-drawing/README.md @@ -18,7 +18,7 @@ The archive row is regenerated from the package that carries this README. | Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on | |---|---|---|---|---|---|---|---|---| -| Crates.io archive: oxml-drawing | 161,119 compressed bytes, 1,128,372 member bytes, 24 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `oxml-drawing` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-19 | +| Crates.io archive: oxml-drawing | 161,853 compressed bytes, 1,131,956 member bytes, 24 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `oxml-drawing` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-27 | ## Use it when diff --git a/crates/oxml-drawing/src/fill.rs b/crates/oxml-drawing/src/fill.rs index b38d01a9..2afc7e2f 100644 --- a/crates/oxml-drawing/src/fill.rs +++ b/crates/oxml-drawing/src/fill.rs @@ -287,8 +287,12 @@ pub enum GradientGeometry { /// Linear gradient angle and scaling behavior. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct LinearGradient { + /// `@ang`. The schema makes it optional without a default, and a missing + /// angle reads as 0, a left to right axis, as in LibreOffice's import. pub angle: Angle, pub scaled: Option, + /// The source had no `@ang`, so a zero angle is written without one. + angle_omitted: bool, raw_children: OrderedRawChildren, } @@ -322,8 +326,12 @@ impl PathGradientKind { /// Path gradient shape and optional focal rectangle. #[derive(Clone, Debug, Eq, PartialEq)] pub struct PathGradient { + /// `@path`. The schema makes it optional without a default, and a missing + /// path reads as `rect`, as in LibreOffice's import. pub kind: PathGradientKind, pub fill_to_rect: Option, + /// The source had no `@path`, so a `rect` path is written without one. + kind_omitted: bool, raw_children: OrderedRawChildren, } @@ -1035,9 +1043,11 @@ fn push_raw_attributes(start: &mut BytesStart<'_>, attributes: &[(String, String fn parse_linear(start: &BytesStart<'_>) -> Result { reject_conflicting_a_prefix(start)?; + let angle = optional_parse::(start, b"ang")?; Ok(LinearGradient { - angle: Angle(required_i32(start, b"ang")?), + angle: Angle(angle.unwrap_or(0)), scaled: optional_bool(start, b"scaled")?, + angle_omitted: angle.is_none(), raw_children: OrderedRawChildren::default(), }) } @@ -1053,11 +1063,17 @@ fn parse_linear_element( fn parse_empty_path(start: &BytesStart<'_>) -> Result { reject_conflicting_a_prefix(start)?; - let value = required_attr(start, b"path")?; - let kind = PathGradientKind::parse(&value).ok_or_else(|| invalid(start, b"path", value))?; + let (kind, kind_omitted) = match get_attr(start, b"path") { + Some(value) => ( + PathGradientKind::parse(&value).ok_or_else(|| invalid(start, b"path", value))?, + false, + ), + None => (PathGradientKind::Rectangle, true), + }; Ok(PathGradient { kind, fill_to_rect: None, + kind_omitted, raw_children: OrderedRawChildren::default(), }) } @@ -1099,7 +1115,9 @@ fn write_geometry(writer: &mut Writer, geometry: &GradientGeometry) GradientGeometry::Linear(linear) => { let angle = linear.angle.0.to_string(); let mut start = BytesStart::new("a:lin"); - start.push_attribute(("ang", angle.as_str())); + if !linear.angle_omitted || linear.angle.0 != 0 { + start.push_attribute(("ang", angle.as_str())); + } if let Some(scaled) = linear.scaled.map(bool_text) { start.push_attribute(("scaled", scaled)); } @@ -1112,7 +1130,9 @@ fn write_geometry(writer: &mut Writer, geometry: &GradientGeometry) } GradientGeometry::Path(path) => { let mut start = BytesStart::new("a:path"); - start.push_attribute(("path", path.kind.as_str())); + if !path.kind_omitted || path.kind != PathGradientKind::Rectangle { + start.push_attribute(("path", path.kind.as_str())); + } if path.fill_to_rect.is_none() && path.raw_children.is_empty() { return write_empty(writer, start); } @@ -1486,7 +1506,9 @@ fn write_end(writer: &mut Writer, name: &str) -> Result<()> { #[cfg(test)] mod tests { - use super::{Fill, FillError, GradientGeometry}; + use super::{ + Angle, Fill, FillError, GradientFill, GradientGeometry, LinearGradient, PathGradientKind, + }; #[test] fn every_fill_form_round_trips_and_gradient_stops_keep_document_order() { @@ -1523,6 +1545,66 @@ mod tests { )); } + #[test] + fn gradient_geometry_without_angle_or_path_round_trips_without_adding_them() { + let cases: &[&[u8]] = &[ + br#""#, + br#""#, + br#""#, + br#""#, + ]; + for xml in cases { + let parsed = Fill::from_xml(xml).unwrap(); + assert_eq!(parsed.to_xml().unwrap(), *xml); + } + + // A missing angle reads as 0 and a missing path as rect. Another value + // assigned to either is written, since the default no longer holds. + let Fill::Gradient(mut gradient) = Fill::from_xml(cases[0]).unwrap() else { + panic!("expected gradient") + }; + let Some(GradientGeometry::Linear(linear)) = &mut gradient.geometry else { + panic!("expected linear geometry") + }; + assert_eq!(linear.angle, Angle(0)); + assert_eq!(linear.scaled, Some(false)); + linear.angle = Angle(5_400_000); + assert_eq!( + Fill::Gradient(gradient).to_xml().unwrap(), + br#""# + ); + + let Fill::Gradient(mut gradient) = Fill::from_xml(cases[3]).unwrap() else { + panic!("expected gradient") + }; + let Some(GradientGeometry::Path(path)) = &mut gradient.geometry else { + panic!("expected path geometry") + }; + assert_eq!(path.kind, PathGradientKind::Rectangle); + assert!(path.fill_to_rect.is_some()); + path.kind = PathGradientKind::Circle; + assert_eq!( + Fill::Gradient(gradient).to_xml().unwrap(), + br#""# + ); + + // Explicit defaults, and a value built in code, keep their attribute. + for xml in [ + br#""#.as_slice(), + br#""#, + ] { + assert_eq!(Fill::from_xml(xml).unwrap().to_xml().unwrap(), xml); + } + let built = GradientFill { + geometry: Some(GradientGeometry::Linear(LinearGradient::default())), + ..GradientFill::default() + }; + assert_eq!( + Fill::Gradient(built).to_xml().unwrap(), + br#""# + ); + } + #[test] fn fill_forms_read_any_prefix_and_write_fixed_a_prefix_in_schema_order() { let fill = Fill::from_xml(br#""#).unwrap(); diff --git a/crates/oxml-pdf/README.md b/crates/oxml-pdf/README.md index 56bb4a87..e2970b6d 100644 --- a/crates/oxml-pdf/README.md +++ b/crates/oxml-pdf/README.md @@ -19,7 +19,7 @@ rows are the enforced release-mode bounds plus one dated observation. | Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on | |---|---|---|---|---|---|---|---|---| -| Crates.io archive: oxml-pdf | 66,015 compressed bytes, 304,432 member bytes, 14 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `oxml-pdf` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-19 | +| Crates.io archive: oxml-pdf | 67,056 compressed bytes, 310,995 member bytes, 14 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `oxml-pdf` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-27 | | Large-document layout throughput | minimum 250 pages/s, observed 31,019.1 pages/s | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 one-page paragraphs with deterministic fonts | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | pages per wall-clock second | 2026-09-19 | | Large-document layout peak allocation | maximum 64 MiB, observed 29.03 MiB | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 one-page paragraphs with deterministic fonts | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | peak live allocation | 2026-09-19 | | Large-document PDF throughput | minimum 1,000 pages/s, observed 60,058.0 pages/s | rdocx 0.14.0 | macOS 26.6.2, Apple M5 Max, arm64 | release, one test thread | 1,000 deterministic layout pages | `cargo test -p rdocx --test regression_test --release a_thousand_page_document_paginates_and_renders_within_the_declared_limits -- --ignored --exact --nocapture --test-threads=1` | pages per wall-clock second | 2026-09-19 | diff --git a/crates/oxml-pdf/src/writer.rs b/crates/oxml-pdf/src/writer.rs index 613b1a3f..91b09ff4 100644 --- a/crates/oxml-pdf/src/writer.rs +++ b/crates/oxml-pdf/src/writer.rs @@ -34,7 +34,7 @@ impl AlphaStates { fn new(pages: &[Arc], alloc: &mut impl FnMut() -> Ref) -> Self { let mut keys = BTreeSet::new(); for page in pages { - collect_alpha_keys(&page.elements, &mut keys); + collect_page_alpha_keys(page, &mut keys); } let entries: Vec = keys @@ -62,7 +62,7 @@ impl AlphaStates { fn page_entries(&self, page: &oxml_layout::PageFrame) -> Vec<&AlphaEntry> { let mut keys = BTreeSet::new(); - collect_alpha_keys(&page.elements, &mut keys); + collect_page_alpha_keys(page, &mut keys); keys.into_iter() .filter_map(|key| self.by_key.get(&key).map(|index| &self.entries[*index])) .collect() @@ -79,6 +79,13 @@ fn alpha_key(alpha: f64) -> Option { (normalized < 1.0).then(|| normalized.to_bits()) } +fn collect_page_alpha_keys(page: &oxml_layout::PageFrame, keys: &mut BTreeSet) { + if let Some(Paint::Solid(color)) = &page.background { + insert_alpha(keys, color.a); + } + collect_alpha_keys(&page.elements, keys); +} + fn collect_alpha_keys(elements: &[PositionedElement], keys: &mut BTreeSet) { for element in elements { match element { @@ -127,6 +134,8 @@ fn insert_alpha(keys: &mut BTreeSet, alpha: f64) { #[derive(Clone, Copy, PartialEq, Eq, Hash)] enum GradientTarget { + /// The page background, registered at leaf 0 of its page. + Background, Fill, Stroke, } @@ -171,6 +180,16 @@ impl GradientRegistry { e: 0.0, f: page.height, }; + if let Some(background) = &page.background { + registry.collect( + background, + page_idx, + 0, + GradientTarget::Background, + page_flip, + alloc, + ); + } walk(&page.elements, &mut |element, transform| { let current_idx = leaf_idx; leaf_idx += 1; @@ -1035,6 +1054,7 @@ fn build_page_content( let page_height = page.height as f32; content.save_state(); content.transform([1.0, 0.0, 0.0, -1.0, 0.0, page_height]); + emit_background(&mut content, page_idx, page, resources); let mut state = EmitState { page_idx, @@ -1060,6 +1080,40 @@ fn build_page_content( content.finish().to_vec() } +/// Paint the page background under every element, as the rasteriser does. +fn emit_background( + content: &mut Content, + page_idx: usize, + page: &oxml_layout::PageFrame, + resources: PageContentResources<'_>, +) { + // Tile paint resolves to nothing, exactly as it does for a path fill. + let Some(paint) = page.background.as_ref().and_then(|background| { + resolve_paint( + background, + page_idx, + 0, + GradientTarget::Background, + resources.gradients, + ) + }) else { + return; + }; + // A tagged page marks this decorative paint as an artifact. + let tagged = resources.structure.is_some(); + if tagged { + content.begin_marked_content(Name(b"Artifact")); + } + content.save_state(); + set_fill_paint(content, paint, resources.alpha_states); + content.rect(0.0, 0.0, page.width as f32, page.height as f32); + content.fill_nonzero(); + content.restore_state(); + if tagged { + content.end_marked_content(); + } +} + struct EmitState<'a> { page_idx: usize, page_height: f64, @@ -1960,7 +2014,10 @@ mod tests { } fn content_for(elements: Vec) -> String { - let page = page_with(elements); + content_for_page(page_with(elements), None) + } + + fn content_for_page(page: PageFrame, structure: Option<&DocumentStructure>) -> String { let mut next_ref = 100; let page = Arc::new(page); let alpha_states = AlphaStates::new(std::slice::from_ref(&page), &mut || { @@ -1973,6 +2030,13 @@ mod tests { next_ref += 1; reference }); + let structure = structure.and_then(|structure| { + PreparedStructure::new(structure, std::slice::from_ref(&page), &mut || { + let reference = Ref::new(next_ref); + next_ref += 1; + reference + }) + }); String::from_utf8(build_page_content( 0, &page, @@ -1982,7 +2046,7 @@ mod tests { image_map: &HashMap::new(), alpha_states: &alpha_states, gradients: &gradients, - structure: None, + structure: structure.as_ref(), }, )) .expect("PDF content operators are ASCII") @@ -3355,6 +3419,140 @@ mod tests { assert_eq!(pdf.matches("/Subtype /Link").count(), 1, "{pdf}"); } + fn page_with_background(background: Paint) -> PageFrame { + let mut page = page_with(vec![alpha_rect(1.0)]); + page.background = Some(background); + page + } + + fn radial_gradient() -> Paint { + Paint::Radial { + center: Point { x: 50.0, y: 50.0 }, + radius: 40.0, + focal: Point { x: 50.0, y: 50.0 }, + stops: gradient_stops(), + extend: (false, false), + } + } + + #[test] + fn solid_page_background_fills_the_page_before_its_content() { + let red = Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }; + let content = content_for_page(page_with_background(Paint::Solid(red)), None); + + assert!( + content.starts_with("q\n1 0 0 -1 0 792 cm\nq\n1 0 0 rg\n0 0 612 792 re\nf\nQ\n"), + "{content}" + ); + let background = content + .find("0 0 612 792 re") + .expect("background rectangle"); + let child = content.find("0 0 10 10 re").expect("child rectangle"); + assert!(background < child, "{content}"); + } + + #[test] + fn gradient_page_background_fills_the_page_with_its_pattern_first() { + for (background, shading) in [ + (linear_gradient(), "/ShadingType 2"), + (radial_gradient(), "/ShadingType 3"), + ] { + let content = content_for_page(page_with_background(background.clone()), None); + assert!( + content.starts_with( + "q\n1 0 0 -1 0 792 cm\nq\n/Pattern cs\n/P0 scn\n0 0 612 792 re\nf\nQ\n" + ), + "{content}" + ); + + let layout = LayoutResult::new( + vec![page_with_background(background).into()], + Vec::new(), + None, + Vec::new(), + ); + let pdf = String::from_utf8_lossy(&write_pdf(&layout)).into_owned(); + assert!(pdf.contains("/Pattern <<\n /P0"), "{pdf}"); + assert!(pdf.contains("/Matrix [1 0 0 -1 0 792]"), "{pdf}"); + assert!(pdf.contains(shading), "{pdf}"); + } + } + + #[test] + fn translucent_page_background_selects_a_registered_alpha_state() { + let background = Paint::Solid(Color { + r: 0.0, + g: 0.0, + b: 1.0, + a: 0.5, + }); + let content = content_for_page(page_with_background(background.clone()), None); + assert!( + content.starts_with("q\n1 0 0 -1 0 792 cm\nq\n0 0 1 rg\n/GS0 gs\n0 0 612 792 re"), + "{content}" + ); + + let layout = LayoutResult::new( + vec![page_with_background(background).into()], + Vec::new(), + None, + Vec::new(), + ); + let pdf = String::from_utf8_lossy(&write_pdf(&layout)).into_owned(); + assert!(pdf.contains("/GS0"), "{pdf}"); + assert!(pdf.contains("/ca 0.5"), "{pdf}"); + } + + #[test] + fn tile_page_background_leaves_the_content_unchanged() { + let tile = Paint::Tile { + image: MediaId(1), + tile: Rect { + x: 0.0, + y: 0.0, + width: 10.0, + height: 10.0, + }, + transform: Transform::IDENTITY, + }; + + assert_eq!( + content_for_page(page_with_background(tile), None), + content_for(vec![alpha_rect(1.0)]) + ); + } + + #[test] + fn tagged_page_background_is_an_artifact() { + let paragraph = StructureId::new(2).unwrap(); + let mut page = page_with(vec![PositionedElement::MarkedContent { + structure: Some(paragraph), + children: vec![alpha_rect(1.0)], + }]); + page.background = Some(Paint::Solid(Color::WHITE)); + let structure = DocumentStructure { + root: StructureId::new(1).unwrap(), + nodes: vec![ + structure_node(1, StructureRole::Document, vec![paragraph]), + structure_node(2, StructureRole::Paragraph, Vec::new()), + ], + }; + let content = content_for_page(page, Some(&structure)); + + assert!( + content.starts_with( + "q\n1 0 0 -1 0 792 cm\n/Artifact BMC\nq\n1 1 1 rg\n0 0 612 792 re\nf\nQ\nEMC\n" + ), + "{content}" + ); + assert!(content.contains("/MCID 0"), "{content}"); + } + #[test] fn page_content_uses_one_global_flip() { let content = content_for(Vec::new()); diff --git a/crates/rpptx/README.md b/crates/rpptx/README.md index 34eb7caa..72751ec7 100644 --- a/crates/rpptx/README.md +++ b/crates/rpptx/README.md @@ -22,7 +22,7 @@ presentation, notes, handout, PDF, and animation outputs. | Measurement | Value | Version | Platform | Build mode | Input | Command | Statistic | Measured on | |---|---|---|---|---|---|---|---|---| -| Crates.io archive: rpptx | 407,658 compressed bytes, 2,122,094 member bytes, 16 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rpptx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-26 | +| Crates.io archive: rpptx | 409,601 compressed bytes, 2,130,438 member bytes, 16 members | 0.12.1 | macOS 26.6.2, Apple M5 Max, arm64 | `cargo package --locked --no-verify` | Tracked `rpptx` package inventory | `python3 scripts/readme_doctests.py --record-measurements` | gzip archive bytes, tar member bytes, tar member count | 2026-09-27 | ## Use it when diff --git a/crates/rpptx/tests/integration.rs b/crates/rpptx/tests/integration.rs index cbf98946..c565b741 100644 --- a/crates/rpptx/tests/integration.rs +++ b/crates/rpptx/tests/integration.rs @@ -15104,6 +15104,209 @@ fn picture_alpha_mod_fix_matches_presentation_renderers() { fs::remove_dir_all(root).unwrap(); } +/// A one-slide blank deck whose parts of `content_type` carry `background`, +/// in place of their own `p:bg` or ahead of their shape tree. +fn blank_deck_with_background(content_type: &str, background: &str) -> Vec { + let mut presentation = Presentation::new().expect("open bundled template"); + presentation.add_slide(6).expect("add blank slide"); + let mut package = open_opc(&presentation.to_bytes().unwrap(), "background fixture"); + let parts = package + .content_types + .overrides + .iter() + .filter(|(_, part_type)| *part_type == content_type) + .map(|(part, _)| part.clone()) + .collect::>(); + assert!(!parts.is_empty(), "no {content_type} part"); + for part in parts { + let xml = String::from_utf8(package.get_part(&part).unwrap().to_vec()).unwrap(); + let xml = match (xml.find(""), xml.find("")) { + (Some(start), Some(end)) => format!( + "{}{background}{}", + &xml[..start], + &xml[end + "".len()..] + ), + _ => xml.replacen(" String { + let package = open_opc(&presentation.to_bytes().unwrap(), "saved slide"); + let part = package + .content_types + .overrides + .iter() + .find_map(|(part, part_type)| (part_type == content_types::SLIDE).then_some(part)) + .unwrap(); + String::from_utf8(package.get_part(part).unwrap().to_vec()).unwrap() +} + +#[test] +fn gradient_backgrounds_without_angle_or_path_open_render_and_round_trip() { + let stops = r#""#; + let linear = format!( + r#"{stops}"# + ); + let presentation = + Presentation::from_bytes(&blank_deck_with_background(content_types::SLIDE, &linear)) + .expect("open a linear gradient without @ang"); + let (input, layout) = presentation.render_deterministic().unwrap(); + assert!( + input.slides[0].diagnostics.is_empty(), + "{:?}", + input.slides[0].diagnostics + ); + let png = oxml_pdf::render_page_to_png(&layout, 0, 72.0).unwrap(); + let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap(); + let rgb = |x: u32, y: u32| { + let pixel = pixmap.pixel(x, y).unwrap(); + (pixel.red(), pixel.green(), pixel.blue()) + }; + let (right, bottom) = (pixmap.width() - 5, pixmap.height() - 5); + let (top_left, top_right) = (rgb(5, 5), rgb(right, 5)); + // A missing angle is 0 degrees, so colour varies left to right only. + assert!(top_left.0 > 200 && top_left.2 < 55, "{top_left:?}"); + assert!(top_right.2 > 200 && top_right.0 < 55, "{top_right:?}"); + assert_eq!(rgb(5, bottom), top_left); + assert_eq!(rgb(right, bottom), top_right); + let saved = saved_slide_xml(&presentation); + assert!(saved.contains(r#""#), "{saved}"); + + // The PDF fills the page with the same gradient before any slide content, + // as a pattern whose axial shading runs left to right from red to blue. + let pdf = lopdf::Document::load_mem(&presentation.to_pdf_deterministic().unwrap()).unwrap(); + let page = *pdf.get_pages().values().next().unwrap(); + let operations = pdf.get_and_decode_page_content(page).unwrap().operations; + let operators = operations + .iter() + .take(8) + .map(|operation| operation.operator.as_str()) + .collect::>(); + assert_eq!(operators, ["q", "cm", "q", "cs", "scn", "re", "f", "Q"]); + let dictionary = |object| pdf.dereference(object).unwrap().1.as_dict().unwrap(); + let numbers = |object: &lopdf::Object| { + object + .as_array() + .unwrap() + .iter() + .map(|value| value.as_float().unwrap()) + .collect::>() + }; + let resources = dictionary(pdf.get_dictionary(page).unwrap().get(b"Resources").unwrap()); + let patterns = dictionary(resources.get(b"Pattern").unwrap()); + let pattern = dictionary( + patterns + .get(operations[4].operands[0].as_name().unwrap()) + .unwrap(), + ); + let shading = dictionary(pattern.get(b"Shading").unwrap()); + assert_eq!(shading.get(b"ShadingType").unwrap().as_i64().unwrap(), 2); + let axis = numbers(shading.get(b"Coords").unwrap()); + assert!( + (axis[1] - axis[3]).abs() < 1e-3 && axis[0] < axis[2], + "{axis:?}" + ); + let intervals = dictionary(shading.get(b"Function").unwrap()) + .get(b"Functions") + .unwrap() + .as_array() + .unwrap(); + assert_eq!(intervals.len(), 1); + let interval = dictionary(&intervals[0]); + assert_eq!(numbers(interval.get(b"C0").unwrap()), [1.0, 0.0, 0.0]); + assert_eq!(numbers(interval.get(b"C1").unwrap()), [0.0, 0.0, 1.0]); + + let path = format!( + r#"{stops}"# + ); + let presentation = + Presentation::from_bytes(&blank_deck_with_background(content_types::SLIDE, &path)) + .expect("open a path gradient without @path"); + let (input, _) = presentation.render_deterministic().unwrap(); + assert_eq!( + input.slides[0] + .diagnostics + .iter() + .map(|diagnostic| diagnostic.message.as_str()) + .collect::>(), + ["unsupported background rectangle path gradient"] + ); + let saved = saved_slide_xml(&presentation); + assert!( + saved.contains( + r#""# + ), + "{saved}" + ); +} + +#[test] +fn solid_backgrounds_from_slide_layout_and_master_reach_pdf_and_png() { + let background = r#""#; + let expected = [0x7B_u8, 0x1E, 0x3A]; + for owner in [ + content_types::SLIDE, + content_types::SLIDE_LAYOUT, + content_types::SLIDE_MASTER, + ] { + let presentation = + Presentation::from_bytes(&blank_deck_with_background(owner, background)).unwrap(); + let png = presentation + .slide_png_deterministic(0, 72.0) + .unwrap() + .unwrap(); + let pixmap = tiny_skia::Pixmap::decode_png(&png).unwrap(); + let corner = pixmap.pixel(5, 5).unwrap(); + assert_eq!( + [corner.red(), corner.green(), corner.blue()], + expected, + "{owner} PNG" + ); + + let pdf = lopdf::Document::load_mem(&presentation.to_pdf_deterministic().unwrap()).unwrap(); + let page = *pdf.get_pages().values().next().unwrap(); + let media_box = pdf + .get_dictionary(page) + .unwrap() + .get(b"MediaBox") + .unwrap() + .as_array() + .unwrap() + .iter() + .map(|value| value.as_float().unwrap()) + .collect::>(); + let operations = pdf.get_and_decode_page_content(page).unwrap().operations; + let operators = operations + .iter() + .take(7) + .map(|operation| operation.operator.as_str()) + .collect::>(); + // The background fills the whole page before any slide content. + assert_eq!( + operators, + ["q", "cm", "q", "rg", "re", "f", "Q"], + "{owner} PDF" + ); + let numbers = |index: usize| { + operations[index] + .operands + .iter() + .map(|value| value.as_float().unwrap()) + .collect::>() + }; + for (component, byte) in numbers(3).into_iter().zip(expected) { + assert!( + (component * 255.0 - f32::from(byte)).abs() < 0.5, + "{owner} PDF fill {component}" + ); + } + assert_eq!(numbers(4), media_box, "{owner} PDF rectangle"); + } +} + #[test] fn picture_sniffs_bytes_when_extension_is_misleading() { let png = png_header(5, 4); diff --git a/scripts/readme_doctests.py b/scripts/readme_doctests.py index e24c643b..c747691c 100644 --- a/scripts/readme_doctests.py +++ b/scripts/readme_doctests.py @@ -367,9 +367,11 @@ class ReadmeCase: ) MEASUREMENT_DATE = "2026-09-19" ARCHIVE_REMEASUREMENT_DATES = { + "oxml-drawing": "2026-09-27", + "oxml-pdf": "2026-09-27", "rdocx": "2026-09-26", "rdocx-layout": "2026-09-26", - "rpptx": "2026-09-26", + "rpptx": "2026-09-27", } MEASUREMENT_PLATFORM = "macOS 26.6.2, Apple M5 Max, arm64" ARCHIVE_COMPRESSION_TOLERANCE_BYTES = 64 @@ -377,11 +379,11 @@ class ReadmeCase: "oxml-chart": (102_042, 659_367, 6), "oxml-cli-support": (6_718, 21_586, 6), "oxml-core": (20_677, 100_124, 15), - "oxml-drawing": (161_119, 1_128_372, 24), + "oxml-drawing": (161_853, 1_131_956, 24), "oxml-layout": (4_623_324, 9_227_483, 51), "oxml-media": (12_252, 50_992, 6), "oxml-opc": (92_122, 355_510, 12), - "oxml-pdf": (66_015, 304_432, 14), + "oxml-pdf": (67_056, 310_995, 14), "oxml-sml": (12_511, 49_803, 6), "rdocx": (1_092_256, 6_498_484, 36), "rdocx-cli": (33_805, 145_256, 8), @@ -390,7 +392,7 @@ class ReadmeCase: "rdocx-opc": (3_655, 9_668, 6), "rdocx-oxml": (367_500, 2_380_047, 32), "rdocx-pdf": (8_111, 26_758, 6), - "rpptx": (407_658, 2_122_094, 16), + "rpptx": (409_601, 2_130_438, 16), "rpptx-chart": (6_648, 21_136, 6), "rpptx-cli": (36_709, 159_585, 8), "rpptx-layout": (79_109, 458_112, 11),