From fe44b1cfddfb03a07e823e1055c1c2f236fa5905 Mon Sep 17 00:00:00 2001 From: alichaudhry55 Date: Sat, 24 Oct 2026 17:45:45 +0500 Subject: [PATCH] Tables: gradient cell fills draw as gradients on screen and in PDF Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/pdf/src/tests.rs | 19 +++++++++++++++++++ crates/pdf/src/text.rs | 14 ++++++++++---- crates/render/src/lib.rs | 37 +++++++++++++++++++++++++++++++++++++ crates/render/src/text.rs | 12 +++++++----- 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c997b9e35..480fd177a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -359,6 +359,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change. +- **2026-10-24** · Tables: a gradient swatch in a cell fill draws as a gradient on the canvas and in PDF, in one gradient space for the whole table, so it runs across cells without restarting. Cells showed the gradient's first stop as a solid colour. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change. diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 39f472ffc..435f562c0 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -200,6 +200,25 @@ fn standards() { assert_eq!(Standard::parse("pdfa-2b"), Some(Standard::PdfA2b)); } +#[test] +fn table_gradient_fill_exports_a_shading() { + let mut d = Document::new(&NewDocument::default()); + let mut gradient = designcraft_color::Gradient::default(); + gradient.stops[0].color = Color::rgb(1.0, 0.0, 0.0); + gradient.stops[1].color = Color::rgb(0.0, 0.0, 1.0); + d.swatches.push(Swatch { name: "Table gradient".into(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false }); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap(); + let mut t = designcraft_doc::Table::new(1, 1, 2, 0, 0, 200.0); + t.rows[0].height = 40.0; + t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + for cell in &mut t.cells { + cell.fill = "Table gradient".into(); + } + d.story_mut(sid).unwrap().insert_table(0, t); + let pdf = uncompressed(&d, PdfOptions::default()); + assert!(pdf.contains("/ShadingType 2") || pdf.contains("/ShadingType2"), "table fill must export a linear gradient"); +} + #[test] fn tables_export_cell_text() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 7687bd2f8..003f038fc 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -326,10 +326,16 @@ impl Exporter<'_> { fn tables(&mut self, s: &mut Surface, ft: &FrameText) { for t in &ft.tables { for c in &t.cells { - if let Some((sw, tint)) = &c.fill - && let Some(col) = self.swatch_color(sw, *tint) - { - self.fill_rect(s, c.rect, col); + if let Some((sw, tint)) = &c.fill { + let fill = designcraft_doc::Fill { swatch: sw.clone(), tint: *tint, ..designcraft_doc::Fill::none() }; + if let Some(paint) = self.fill_paint(&fill, t.rect) + && let Some(path) = to_path(&c.rect.to_path(0.1)) + { + s.set_stroke(None); + s.set_fill(Some(paint_fill(paint))); + s.draw_path(&path); + s.set_fill(None); + } } } for c in &t.cells { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 522e245cb..9249dbfbe 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2161,6 +2161,43 @@ mod tests { assert_eq!((a, b), (designcraft_geom::Point::new(top.x0 - 4.0, top.y0), edge.b), "only table corners extend"); } + #[test] + fn table_gradient_fill_is_continuous_across_cells() { + let mut d = Document::new(&NewDocument::default()); + let mut gradient = designcraft_color::Gradient::default(); + gradient.stops[0].color = designcraft_color::Color::rgb(1.0, 0.0, 0.0); + gradient.stops[1].color = designcraft_color::Color::rgb(0.0, 0.0, 1.0); + d.swatches.push(designcraft_color::Swatch { + name: "Table gradient".into(), + value: designcraft_color::SwatchValue::Gradient { gradient }, + locked: false, + named: true, + hidden: false, + }); + let (_, sid) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 300.0), d.default_layer(), "", ParaFormat::default()).unwrap(); + let mut t = designcraft_doc::Table::new(1, 1, 2, 0, 0, 200.0); + t.rows[0].height = 40.0; + t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + for cell in &mut t.cells { + cell.fill = "Table gradient".into(); + } + d.story_mut(sid).unwrap().insert_table(0, t); + let cache = Cache::new(); + let rect = cache.get(&d, sid, None).frames[0].tables[0].rect; + let mut renderer = Renderer::new(); + renderer.threads = 0; + let img = renderer.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + let y = rect.center().y as u32; + let a = img.pixel((rect.x0 + 20.0) as u32, y); + let b = img.pixel((rect.x1 - 20.0) as u32, y); + assert!(i16::from(a[0]) - i16::from(a[2]) > 80, "red end: {a:?}"); + assert!(i16::from(b[2]) - i16::from(b[0]) > 80, "blue end: {b:?}"); + let left = img.pixel((rect.center().x - 2.0) as u32, y); + let right = img.pixel((rect.center().x + 2.0) as u32, y); + assert!((i16::from(left[0]) - i16::from(right[0])).abs() < 20, "no gradient restart between cells"); + } + #[test] fn renders_tables() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index a9cdc5ce3..c29107c36 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -470,14 +470,16 @@ impl Renderer { let doc = f.doc; for t in &ft.tables { for c in &t.cells { - if let Some((sw, tint)) = &c.fill - && let Some(col) = doc.resolve_color(sw, *tint) - { + if let Some((sw, tint)) = &c.fill { ctx.set_transform(f.view * xf); - ctx.set_paint(color_of(&col, 1.0)); + let fill = designcraft_doc::Fill { swatch: sw.clone(), tint: *tint, ..designcraft_doc::Fill::none() }; // Overlap neighbours by half a device pixel so adjacent fills show no seams. let h = 0.5 * f.px; - ctx.fill_rect(&c.rect.inflate(h, h)); + // All cells share the table's gradient space; it must not restart at a cell edge. + if crate::set_fill_paint(ctx, doc, &fill, t.rect) { + ctx.fill_rect(&c.rect.inflate(h, h)); + } + ctx.reset_paint_transform(); } } for c in &t.cells {