From 57c997634fe42cf2e1a6f39daae332642cfba9bc Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 13 Oct 2026 19:33:08 +0000 Subject: [PATCH] Integration: #825 PDF table-cell and footnote text lays a gradient across its own lines, as on screen (cherry picked from commit 14e98419ad7afd052a51de9c61eb4dd6c05462eb) --- crates/engine/src/cmd/text.rs | 71 +++++++++++++++++++++++++++++++++++ crates/pdf/src/text.rs | 20 ++++++++++ 2 files changed, 91 insertions(+) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 889ba3ed6..3bfacb353 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2612,4 +2612,75 @@ mod gradient_text_tests { let d = p.doc().unwrap().doc.clone(); check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf"); } + + /// The columns the red → blue ink spans (black table edges left out). + fn ink_span(img: &designcraft_render::Rendered) -> (u32, u32) { + let cols: Vec = (0..img.width) + .filter(|&x| { + (0..img.height).any(|y| { + let p = img.pixel(x, y); + p[1] < 100 && p[0].max(p[2]) > 100 + }) + }) + .collect(); + (*cols.first().unwrap(), *cols.last().unwrap()) + } + + /// Mean red and blue of the red → blue ink in columns `x0..x1`. + fn coloured_ink(img: &designcraft_render::Rendered, x0: u32, x1: u32) -> (f64, f64) { + let (mut r, mut b, mut n) = (0.0, 0.0, 0.0); + for x in x0..x1 { + for y in 0..img.height { + let p = img.pixel(x, y); + if p[1] < 100 && p[0].max(p[2]) > 100 { + (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); + } + } + } + assert!(n > 20.0, "no ink in {x0}..{x1}"); + (r / n, b / n) + } + + #[test] + fn a_gradient_on_table_cell_text_spans_the_cells_text_in_pdf_as_on_screen() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); + let g = s + .execute("swatch.create", &json!({"gradient": {"stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]}})) + .unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": ""})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 1, "cols": 3})).unwrap(); + // A third of the frame's width: the cell's text, red → blue. + let mut d: designcraft_doc::Document = (*s.doc().unwrap().doc).clone(); + let sid = d.stories.values().find(|st| !st.tables.is_empty()).unwrap().id; + let t = std::sync::Arc::make_mut(d.story_mut(sid).unwrap().tables.values_mut().next().unwrap()); + let text = &mut t.cell_mut(0, 0).unwrap().text; + text.insert(0, "HHHH"); + text.format_chars(0..4, |f| { + f.over.fill = Some(g.clone()); + f.over.size = Some(24.0); + }); + let mut rr = designcraft_render::Renderer::new(); + let cache = designcraft_compose::Cache::new(); + let screen = rr.render_page(&d, &cache, 0, 1.0, false, &Default::default()).unwrap(); + let pdf = designcraft_pdf::export_pdf(&d, &cache, &Default::default()).unwrap(); + let mut p = crate::Session::new(); + p.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false})).unwrap(); + p.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name": "cell.pdf", "x": 0, "y": 0, "width": 300})) + .unwrap(); + let placed = rr.render_page(&p.doc().unwrap().doc.clone(), &p.cache, 0, 1.0, false, &Default::default()).unwrap(); + let (x0, x1) = ink_span(&screen); + assert!(x1 < 120, "the text stays in the first column: {x0}..{x1}"); + let q = (x1 - x0) / 4; + for (what, img) in [("screen", &screen), ("pdf", &placed)] { + let (lr, lb) = coloured_ink(img, x0, x0 + q); + let (rr, rb) = coloured_ink(img, x1 + 1 - q, x1 + 1); + assert!(lr > lb + 50.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); + assert!(rb > rr + 50.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); + } + } } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index b7921f326..a6695d0b7 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -35,6 +35,22 @@ impl Exporter<'_> { } } +/// The box a gradient on a cell's or a note's text spans: its lines, each padded by its ascent as +/// the screen renderer pads them, so both lay the gradient out alike. +fn lines_area(ft: &FrameText) -> Option { + ft.lines + .iter() + .filter_map(|l| { + let first = l.glyphs.first()?; + let x0 = l.glyphs.iter().map(|g| g.x).fold(first.x, f64::min); + let x1 = l.glyphs.iter().map(|g| g.x + g.adv.max(0.0)).fold(l.end_x, f64::max); + let pad = l.ascent.max(1.0); + let bottom = l.drop_cap.map_or(l.baseline + l.descent.max(0.0), |dc| dc.rect.y1.max(l.baseline + l.descent.max(0.0))); + Some(Rect::new(x0 - pad, l.baseline - l.ascent - pad, x1 + pad, bottom + pad)) + }) + .reduce(|a, b| a.union(b)) +} + /// An opaque nonzero fill with `paint`. fn paint_fill(paint: Paint) -> Fill { Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero } @@ -195,7 +211,9 @@ impl Exporter<'_> { for n in &ft.notes { if let Some(nft) = n.text.frames.first() { s.push_transform(&tf(Affine::translate(n.origin.to_vec2()))); + let area = std::mem::replace(&mut me.text_area, lines_area(nft)); me.frame_text(s, &n.text, nft, &n.source); + me.text_area = area; s.pop(); } } @@ -223,7 +241,9 @@ impl Exporter<'_> { } } else if let Some(cft) = c.text.frames.first() { s.push_transform(&tf(Affine::translate(c.origin.to_vec2()))); + let area = std::mem::replace(&mut self.text_area, lines_area(cft)); self.frame_text(s, &c.text, cft, &c.source); + self.text_area = area; s.pop(); } }