diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs new file mode 100644 index 0000000000..d1d50e4502 --- /dev/null +++ b/crates/edit/src/background.rs @@ -0,0 +1,201 @@ +//! A page's background colour: the colour the page is painted with before anything else, as +//! Edit a PDF ▸ Background and many producers do it, for exports that keep a page colour (#2992). + +use pdfcraft_content::{Matrix, Op, Pieces}; +use pdfcraft_cos::{Dict, Document}; + +use crate::{EditError, Rgb}; + +/// How deep `q` may nest while looking for the background (deeper saves are not tracked). +const MAX_SAVES: usize = 64; + +/// How many clipping rectangles are kept; past that the clip counts as unknown. +const MAX_CLIPS: usize = 64; + +/// A rectangle as it lands on the page: its four corners in page space, in order. Under a +/// rotating or shearing CTM it is a parallelogram, so its bounding box says too much (#2994). +type Quad = [(f64, f64); 4]; + +/// The colour space a fill colour is given in. +#[derive(Clone, Copy, PartialEq)] +enum Space { + Gray, + Rgb, + Cmyk, + /// Anything else (Separation, ICC, patterns…): no plain colour can be read from it. + Other, +} + +#[derive(Clone)] +struct State { + ctm: Matrix, + space: Space, + fill: Option, + /// Fill opacity (`ca`), and whether a soft mask or a blend mode makes the fill other than a + /// plain paint. + alpha: f64, + plain: bool, + /// The clip, as the rectangles it is the intersection of, in page space; `None` when a clip + /// that isn't one rectangle was set (its area can't be told). + clip: Option>, +} + +/// The page's background colour: the colour of the first thing its content paints, when that is +/// an opaque fill, in a gray, RGB or CMYK colour, of a path with a rectangle that covers the +/// whole visible page (and no clip keeps it from doing so). `None` for a page that paints anything else +/// first, or nothing. Forms and later content are not looked at. +pub fn page_background(doc: &Document, page: usize) -> Result, EditError> { + let pages = pdfcraft_model::pages(doc); + let p = pages.get(page).ok_or(EditError::NoSuchPage(page))?; + let crop = p.crop(doc); + let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default(); + let streams = crate::text::content_streams(doc, &p.dict); + let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); + let ops = pieces.parse().ops; + + let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) }; + let mut saved: Vec = Vec::new(); + let mut lost_saves = 0usize; + // The path being built: its rectangles in page space, and whether it has anything else. + // Each rectangle of the current path: its box on the page, and whether it winds + // counterclockwise there (its width and height and the CTM's determinant agree in sign). + let mut rects: Vec<(Quad, bool)> = Vec::new(); + let mut curved = false; + let mut clip_pending = false; + for op in &ops { + match op.op.as_slice() { + b"q" => { + if saved.len() < MAX_SAVES { + saved.push(st.clone()); + } else { + lost_saves = lost_saves.saturating_add(1); + } + } + b"Q" => { + if lost_saves > 0 { + lost_saves -= 1; + } else if let Some(s) = saved.pop() { + st = s; + } + } + b"cm" => { + if let Some(m) = Matrix::from_operands(&op.operands) { + st.ctm = m.then(&st.ctm); + } + } + b"g" => (st.space, st.fill) = (Space::Gray, op.nums::<1>().map(|[g]| [g; 3])), + b"rg" => (st.space, st.fill) = (Space::Rgb, op.nums::<3>()), + b"k" => (st.space, st.fill) = (Space::Cmyk, op.nums::<4>().map(cmyk)), + b"cs" => { + st.space = match op.name(0) { + Some(b"DeviceGray" | b"G") => Space::Gray, + Some(b"DeviceRGB" | b"RGB") => Space::Rgb, + Some(b"DeviceCMYK" | b"CMYK") => Space::Cmyk, + _ => Space::Other, + }; + st.fill = None; + } + b"sc" | b"scn" => st.fill = colour(st.space, op), + b"gs" => { + if let Some(g) = op.name(0).and_then(|n| gstates.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) { + apply_gstate(doc, &g, &mut st); + } + } + b"re" => match op.nums::<4>() { + Some([x, y, w, h]) => { + let [a, b, c, d, _, _] = st.ctm.0; + rects.push((quad(&st.ctm, [x, y, x + w, y + h]), (w * h) * (a * d - b * c) > 0.0)); + } + None => curved = true, + }, + b"m" | b"l" | b"c" | b"v" | b"y" | b"h" => curved = true, + b"W" | b"W*" => clip_pending = true, + b"n" => { + if clip_pending { + st.clip = match (st.clip.take(), rects.as_slice(), curved) { + (Some(mut c), [(r, _)], false) if c.len() < MAX_CLIPS => { + c.push(*r); + Some(c) + } + _ => None, + }; + } + (rects, curved, clip_pending) = (Vec::new(), false, false); + } + b"f" | b"F" | b"f*" | b"B" | b"B*" => { + let opaque = st.plain && st.alpha >= 0.999; + // A second rectangle can cut a hole in the one that covers the page: under the + // even-odd rule any second one, under nonzero one wound the other way (a + // frame-style page border). + let even_odd = matches!(op.op.as_slice(), b"f*" | b"B*"); + let solid = match rects.as_slice() { + [_] => true, + [(_, first), rest @ ..] => !even_odd && rest.iter().all(|(_, ccw)| ccw == first), + [] => false, + }; + let covers = !curved + && solid + && st.clip.as_ref().is_some_and(|c| c.iter().all(|q| covers(q, &crop))) + && rects.iter().any(|(r, _)| covers(r, &crop)); + return Ok(st.fill.filter(|_| opaque && covers && !clip_pending).map(|c| c.map(|v| v.clamp(0.0, 1.0)))); + } + // Anything else painted first: no page background. + b"S" | b"s" | b"b" | b"b*" | b"Tj" | b"TJ" | b"'" | b"\"" | b"Do" | b"sh" | b"BI" => return Ok(None), + _ => {} + } + } + Ok(None) +} + +/// CMYK to RGB, as a plain conversion without colour management. +fn cmyk([c, m, y, k]: [f64; 4]) -> Rgb { + [(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)] +} + +/// The colour `sc`/`scn` sets in `space`, if it is a plain one. +fn colour(space: Space, op: &Op) -> Option { + match (space, op.operands.len()) { + (Space::Gray, 1) => op.nums::<1>().map(|[g]| [g; 3]), + (Space::Rgb, 3) => op.nums::<3>(), + (Space::Cmyk, 4) => op.nums::<4>().map(cmyk), + _ => None, + } +} + +/// The fill opacity, soft mask and blend mode an ExtGState sets. +fn apply_gstate(doc: &Document, g: &Dict, st: &mut State) { + if let Some(ca) = g.get(b"ca").and_then(|v| doc.resolve(v).as_f64()) { + st.alpha = if ca.is_finite() { ca } else { 0.0 }; + } + if let Some(mask) = g.get(b"SMask") { + st.plain &= doc.resolve(mask).as_name() == Some(b"None"); + } + if let Some(bm) = g.get(b"BM") { + let normal = |n: Option<&[u8]>| matches!(n, Some(b"Normal" | b"Compatible")); + st.plain &= match &*doc.resolve(bm) { + pdfcraft_cos::Object::Array(a) => a.first().is_some_and(|n| normal(doc.resolve(n).as_name())), + other => normal(other.as_name()), + }; + } +} + +/// The page-space corners of a user-space rectangle under `ctm`, in order around it. +fn quad(ctm: &Matrix, r: [f64; 4]) -> Quad { + [ctm.apply(r[0], r[1]), ctm.apply(r[2], r[1]), ctm.apply(r[2], r[3]), ctm.apply(r[0], r[3])] +} + +/// Whether `outer` covers `page`, give or take half a percent of its size: each corner of the +/// page, pulled in by that much, lies inside the parallelogram `outer`. +fn covers(outer: &Quad, page: &[f64; 4]) -> bool { + let (w, h) = ((page[2] - page[0]).abs(), (page[3] - page[1]).abs()); + let (tx, ty) = (w * 0.005, h * 0.005); + let (x0, y0, x1, y1) = (page[0].min(page[2]) + tx, page[1].min(page[3]) + ty, page[0].max(page[2]) - tx, page[1].max(page[3]) - ty); + let cross = |a: (f64, f64), b: (f64, f64), p: (f64, f64)| (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0); + // Twice the signed area: its sign says which way the corners run. + let area: f64 = (0..4).map(|i| cross((0.0, 0.0), outer[i], outer[(i + 1) % 4])).sum(); + if !area.is_finite() || area.abs() < f64::EPSILON || outer.iter().any(|p| !(p.0.is_finite() && p.1.is_finite())) { + return false; + } + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].iter().all(|&p| (0..4).all(|i| cross(outer[i], outer[(i + 1) % 4], p) * area.signum() >= 0.0)) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index c1062b4105..f51fc75a06 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -847,6 +847,8 @@ pub fn marks_present(doc: &Document) -> Vec { added::scan_markers(doc).marks } +mod background; +pub use background::page_background; pub mod objects; pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects}; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c66..be4e67d032 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5218,3 +5218,92 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() { assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}"); assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched"); } + +/// #2992: the colour a page is painted with before anything else is its background, for exports +/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes +/// behind; a page that paints anything else first, a fill that doesn't cover the page, a +/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none. +#[test] +fn a_page_painted_first_with_a_full_page_fill_has_that_background() { + let mut doc = fixture(); + assert_eq!(page_background(&doc, 0).unwrap(), None, "text first"); + add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap(); + let doc = reopen(&doc); + let teal = page_background(&doc, 0).unwrap().expect("Background's fill"); + assert!(teal.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6), "{teal:?}"); + assert!(page_background(&doc, 1).unwrap().is_some(), "on a turned page too"); + assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content"); + assert!(page_background(&doc, 9).is_err()); + + let page = |content: &str, resources: &str| { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes()); + page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap() + }; + let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; + assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); + assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); + assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); + assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); + assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it"); + // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the + // other way under nonzero. + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole"); + assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm"); + assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); + assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); + assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); + assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); + assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); + assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); + assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); + assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it"); + // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop + // box's corners are inside it; its bounding box covering the crop box isn't enough. This one + // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but + // 354 along the diagonals, short of the corners. One reaching 707 covers them. + let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; + assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners"); + assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them"); + // The same for a clip. + assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip"); + assert_eq!( + page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned clip over the corners" + ); + // Clips add up: two that each cover the page leave it covered, one that doesn't among them + // doesn't. + assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips"); + assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover"); + // Past 64 clipping rectangles the clip counts as unknown. + let clips = "0 0 600 800 re W n ".repeat(65); + assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); + // A rectangle drawn the other way round (negative width) covers the page all the same. + assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); + // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the + // edge of that included. + assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); + assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); + assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2672592178..f119f3307d 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -525,7 +525,7 @@ struct Ts { render_mode: i64, } -fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { +pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), Some(c) => match &*doc.resolve(c) { diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs index f0ed2b4b84..50508e20ea 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -228,7 +228,9 @@ impl crate::Document { }) }) .collect(); - pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images } + // The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one. + let background = pdfcraft_edit::page_background(cos, i).ok().flatten(); + pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background } }) .collect(); (pages, truncated) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 080dc32dd3..7343751d59 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6147,3 +6147,29 @@ fn undo_never_makes_an_unsaved_document_clean() { s.undo(new).unwrap(); assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean"); } + +/// #2992: a page coloured with Edit a PDF ▸ Background exports to Word with that page colour, +/// behind its text. +#[test] +fn exporting_to_word_keeps_the_page_background() { + let mut s = Session::new(); + let text = s.create_from_text("t", "Blue text on teal").unwrap(); + let id = s.open("t.pdf", None, text, None).unwrap(); + assert_eq!(s.get(id).unwrap().export_pages()[0].background, None, "no background yet"); + s.apply( + id, + Edit::AddBackground { + pages: vec![0], + settings: Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, + replace: false, + file: None, + }, + ) + .unwrap(); + let d = s.get(id).unwrap(); + let page = &d.export_pages()[0]; + assert!(page.background.is_some_and(|c| c.iter().zip([0.0, 0.5, 0.5]).all(|(a, b)| (a - b).abs() < 1e-6)), "{:?}", page.background); + assert!(page.blocks.iter().any(|b| b.text.contains("Blue text")), "the text is still there"); + let docx = d.export_office(compare::OfficeFormat::Docx); + assert!(docx.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "Word is told to show the page colour"); +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 30299b2d76..27dbf7b4c6 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -49,6 +49,9 @@ pub struct Page { pub height: f64, pub blocks: Vec, pub images: Vec, + /// The colour the whole page is painted before its content (a page background), as sRGB + /// components in 0..=1; `None` for a page with no background of its own. + pub background: Option<[f64; 3]>, } /// One cell of a detected table. `span` is how many grid columns the cell covers (a header @@ -662,6 +665,14 @@ fn section(page: &Page) -> String { ) } +/// The page colour of a Word document: Word has one background for the whole document, so it +/// is written only when every page has the same, non-white background (#2992). +fn word_background(pages: &[Page]) -> Option { + let first = rgb8(pages.first()?.background?); + let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first)); + (same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2])) +} + /// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables; /// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout. pub fn docx(pages: &[Page], title: &str) -> Vec { @@ -728,10 +739,13 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { } after_table = false; } + // Word shows a document background only when its settings say so (`settings.xml` below). + let page_colour = word_background(pages); + let background = page_colour.as_deref().map(|c| format!("")).unwrap_or_default(); let doc = format!( "\n\ {body}\ +xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w10=\"urn:schemas-microsoft-com:office:word\">{background}{body}\ " ); let mut types = String::from( @@ -742,6 +756,11 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin \ ", ); + if page_colour.is_some() { + types.push_str( + "", + ); + } types.push_str(""); let rels = "\n\ \ @@ -756,6 +775,9 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin i + 1 )); } + if page_colour.is_some() { + doc_rels.push_str(""); + } doc_rels.push_str(""); let styles = "\n\ \ @@ -773,6 +795,9 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin z.add("word/document.xml", doc.as_bytes(), true); z.add("word/styles.xml", styles.as_bytes(), true); z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true); + if page_colour.is_some() { + z.add("word/settings.xml", b"\n", true); + } for (name, im) in &media { z.add(&format!("word/media/{name}"), &im.bytes, false); } @@ -912,6 +937,7 @@ mod tests { size: [200.0, 150.0], rotation: 0.0, }], + background: None, } } @@ -1008,6 +1034,30 @@ mod tests { } /// One part of a package written by [`Zip`], inflated. + /// #2992: pages that share a background colour get it as the Word document's page colour + /// (with the setting that makes Word show it); differing or white backgrounds don't. + #[test] + fn docx_keeps_a_shared_page_background() { + let teal = Page { background: Some([0.0, 0.5, 0.5]), ..page() }; + let zip = docx(&[teal.clone(), teal.clone()], "Teal"); + let xml = part(&zip, "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(part(&zip, "word/settings.xml").contains("")); + assert!(part(&zip, "[Content_Types].xml").contains("/word/settings.xml")); + assert!(part(&zip, "word/_rels/document.xml.rels").contains("Target=\"settings.xml\"")); + let other = Page { background: Some([1.0, 0.0, 0.0]), ..page() }; + for (pages, why) in [ + (vec![teal.clone(), other], "different colours"), + (vec![teal.clone(), page()], "a page without one"), + (vec![Page { background: Some([1.0; 3]), ..page() }], "white"), + (vec![page()], "none"), + ] { + let zip = docx(&pages, "Plain"); + assert!(!part(&zip, "word/document.xml").contains(" String { let at = |i: usize, n: usize| zip[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); let mut i = 0; @@ -1134,6 +1184,7 @@ mod tests { }, ], images: Vec::new(), + background: None, } } @@ -1214,6 +1265,7 @@ mod tests { }) .collect(), images: Vec::new(), + background: None, }; let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(""), "running text became a table: {h}"); @@ -1267,7 +1320,7 @@ mod tests { blocks.push(block(lead, 72.0, y, 211.0, y + 12.0)); blocks.push(blue("https://example.org/event", 212.5, y, 360.0, y + 12.0)); } - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; let h = html(std::slice::from_ref(&p), "Event"); assert!(!h.contains(""), "sentences became a table: {h}"); assert_eq!(h.matches("https://example.org/event").count(), 3, "{h}"); @@ -1284,7 +1337,7 @@ mod tests { blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) }); blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0)); } - let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new() }; + let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }; let h = html(std::slice::from_ref(&p), "Prizes"); assert!(h.contains("
"), "the prize table was lost: {h}"); assert!(h.contains("30,000") && h.contains("15,000"), "{h}"); @@ -1311,6 +1364,7 @@ mod tests { para("Second right block, still a tall flowing paragraph rather than a cell.", 320.0, 400.0), ], images: Vec::new(), + background: None, }; let h = html(&[p], "Paper"); assert!(!h.contains("
"), "tall two-column text must stay paragraphs: {h}"); diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index fc02c20c11..1a7250d9e0 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -396,6 +396,7 @@ mod tests { size: [228.0, 150.0], rotation: 0.0, }], + background: None, } } @@ -522,6 +523,7 @@ mod tests { cell("100%", 200.0, 320.0), ], images: Vec::new(), + background: None, }; let bytes = pptx(&[sample_page(), table_page], "Two"); let parts = entries(&bytes); @@ -619,7 +621,7 @@ mod tests { let images = (0..40) .map(|k| Image { ext: "png", bytes: vec![0; 4], rect: [0.0, -1e300, 1e-300, 1e300 - k as f64], size: [1.0, 1.0], rotation: 0.0 }) .collect(); - let p = Page { width: f64::MAX, height: f64::NAN, blocks, images }; + let p = Page { width: f64::MAX, height: f64::NAN, blocks, images, background: None }; let bytes = pptx(&[p], "Hostile"); let slide = part(&bytes, "ppt/slides/slide1.xml"); assert!(slide.contains("Title") && slide.contains(""), "the slide is still written"); @@ -698,6 +700,7 @@ mod tests { cell("100%", 200.0, 320.0, 100.0, 12.0), ], images: Vec::new(), + background: None, }; let bytes = pptx(&[p], "Table Check"); let slide_xml = part(&bytes, "ppt/slides/slide1.xml"); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8355cbdb6a..a3ac0c436f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6976,8 +6976,8 @@ milestone = "M10" status = "partial" commands = ["export.docx"] tools = ["doc_export_office"] -evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf"] -notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." +evidence = ["crates/export/src/lib.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/lib.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/lib.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/lib.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/lib.rs::docx_is_a_word_package", "crates/export/src/lib.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/lib.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/lib.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/lib.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/lib.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/lib.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/compare.rs::export_to_word_html_and_rtf", "crates/export/src/lib.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background"] +notes = "Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), borderless detected tables with spans/empty cells (a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. Not yet: exact font matching, vector rules, arbitrary paint-order/transparency, colour per run within a paragraph, general exact layout fidelity." [[feature]] id = "create.export-rtf"