From ac54c8faf7a6e85d6ca5572e8e2177f210d7bca8 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Tue, 13 Oct 2026 19:30:55 +0200 Subject: [PATCH] feat(text): gradient swatches on text, laid across the frame as in InDesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LYH Studio episode 6 (8:28) gives the "1969" headline the "New Gradient Swatch" (magenta → cyan) from the Swatches panel. InDesign runs a gradient on text across its text frame's box. DesignCraft resolved a character fill to one solid colour, so a gradient swatch drew no text at all. - render: a character fill or stroke that names a gradient swatch is painted with that gradient over the frame's box in text space (type on a path: the path's box). - pdf: text runs take a gradient paint: the shading is laid out in the frame's text space and mapped into each run's origin, so skew and horizontal scale don't bend it; outlined runs and type on a path too. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 55 +++++++++++++++++++++++++ crates/pdf/src/export.rs | 26 +++++++++--- crates/pdf/src/text.rs | 50 +++++++++++++++++------ crates/render/src/text.rs | 76 ++++++++++++++++++++++++++++------- docs/target-app-parity.md | 2 +- 6 files changed, 177 insertions(+), 33 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..9689ce7f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-13** · Text takes gradient swatches: a gradient fill or stroke on characters spans the text frame's box (on a path: the path's), on screen and in PDF. No readiness percentage change. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index b57e9e88a..3441b451c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2048,3 +2048,58 @@ mod multi_click_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod gradient_text_tests { + use serde_json::json; + + /// Mean red and blue of the ink (pixels whose green is low: red → blue has none) in columns + /// `x0..x1`. + fn 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 { + (r, b, n) = (r + p[0] as f64, b + p[2] as f64, n + 1.0); + } + } + } + assert!(n > 50.0, "no ink in {x0}..{x1}"); + (r / n, b / n) + } + + /// Red ink at the frame's left, blue at its right. + fn check(img: &designcraft_render::Rendered, what: &str) { + let (lr, lb) = ink(img, 0, 60); + let (rr, rb) = ink(img, 200, 300); + assert!(lr > lb + 80.0, "{what}: the left ink is red ({lr:.0}, {lb:.0})"); + assert!(rb > rr + 80.0, "{what}: the right ink is blue ({rr:.0}, {rb:.0})"); + } + + #[test] + fn a_gradient_on_text_spans_its_frame_on_screen_and_in_pdf() { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 120, "facingPages": false, "margins": 0})).unwrap(); + // A gradient swatch (an off-page object's unnamed one), red → blue. + let shape = s.execute("frame.create", &json!({"rect": [-60, 0, -50, 10], "content": "unassigned"})).unwrap()["id"].clone(); + s.execute("object.gradient", &json!({"ids": [shape], "stops": [{"location": 0, "color": "#ff0000"}, {"location": 100, "color": "#0000ff"}]})) + .unwrap(); + let id = designcraft_doc::ItemId(shape.as_u64().unwrap()); + let swatch = s.doc().unwrap().doc.item(id).unwrap().fill.swatch.clone(); + let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 120], "content": "text", "text": "IIIIIIIIIIIIIIIIIIIIIIIIIIIIII"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 30})).unwrap(); + s.execute("type.char", &json!({"size": 60, "tracking": 0, "fill": swatch})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let mut rr = designcraft_render::Renderer::new(); + check(&rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(), "screen"); + // The PDF, placed back and drawn: the same gradient. + let pdf = designcraft_pdf::export_pdf(&d, &s.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": "text.pdf", "x": 0, "y": 0, "width": 300})) + .unwrap(); + let d = p.doc().unwrap().doc.clone(); + check(&rr.render_page(&d, &p.cache, 0, 1.0, false, &Default::default()).unwrap(), "pdf"); + } +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index dcf8abb83..45eb331ff 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -161,6 +161,7 @@ pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, + text_area: None, para_tags: HashMap::new(), }; if ex.rgb_only { @@ -338,6 +339,7 @@ pub fn export_booklet(doc: &Document, cache: &Cache, opts: &BookletOptions) -> R tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, + text_area: None, para_tags: HashMap::new(), }; let pairs = booklet_pairs(n, opts.kind); @@ -468,6 +470,9 @@ pub(crate) struct Exporter<'a> { /// While drawing a story's frame for the structure tree: the story (text runs are tagged /// per paragraph, everything else in the frame is an artifact). pub(crate) tag_story: Option, + /// While drawing a frame's text: the frame's box in text space, which a gradient on the text + /// spans (as InDesign lays it across the frame). + pub(crate) text_area: Option, /// Marked content of each (story, paragraph index). pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec>, } @@ -925,8 +930,10 @@ impl Exporter<'_> { // Type on a path: the glyphs as outlines along the path. for (si, gp) in designcraft_compose::path_glyphs(&cs, ft, &bp, pt) { let st = &cs.styles[si as usize]; - if let (Some(c), Some(p)) = (self.swatch_color(&st.fill, st.fill_tint), to_path(&gp)) { - s.set_fill(Some(Fill { paint: c.into(), opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); + // A gradient spans the path's box. + let fill = designcraft_doc::Fill { swatch: st.fill.clone(), tint: st.fill_tint, ..designcraft_doc::Fill::none() }; + if let (Some(paint), Some(p)) = (self.fill_paint(&fill, bp.bounding_box()), to_path(&gp)) { + s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero })); s.draw_path(&p); s.set_fill(None); } @@ -937,7 +944,9 @@ impl Exporter<'_> { if local != Affine::IDENTITY { s.push_transform(&tf(local)); } + self.text_area = (local.determinant().abs() > 1e-12).then(|| local.inverse().transform_rect_bbox(it.inner_bounds())); self.frame_text(s, &cs, ft, text); + self.text_area = None; if local != Affine::IDENTITY { s.pop(); } @@ -977,7 +986,13 @@ impl Exporter<'_> { } /// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown. - fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { + pub(crate) fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option { + self.fill_paint_in(fill, bounds, Affine::IDENTITY) + } + + /// [`Self::fill_paint`] for a gradient laid out in a space that `xf` maps to the current one + /// (text runs: the frame's text space under the run's origin). + pub(crate) fn fill_paint_in(&mut self, fill: &designcraft_doc::Fill, bounds: Rect, xf: Affine) -> Option { let (swatch, tint, angle) = (fill.swatch.as_str(), fill.tint, fill.gradient_angle); if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) { let kind = g.kind; @@ -1028,7 +1043,7 @@ impl Exporter<'_> { cx, cy, cr: r, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, @@ -1048,7 +1063,7 @@ impl Exporter<'_> { y1: p0.y as f32, x2: p1.x as f32, y2: p1.y as f32, - transform: Transform::identity(), + transform: tf(xf), spread_method: SpreadMethod::Pad, stops, anti_alias: false, @@ -1577,6 +1592,7 @@ mod image_content_cache_tests { tags: Vec::new(), story_tags: HashMap::new(), tag_story: None, + text_area: None, para_tags: HashMap::new(), } } diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..b7921f326 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -15,13 +15,31 @@ use designcraft_fonts::{FontDb, FontFace}; use designcraft_geom::kurbo::Shape as _; use designcraft_geom::{Affine, BezPath, Rect}; use krilla::geom::Point; -use krilla::paint::Stroke; +use krilla::num::NormalizedF32; +use krilla::paint::{Fill, FillRule, Paint, Stroke}; use krilla::surface::Surface; use krilla::tagging::{Artifact, ArtifactType, ContentTag, SpanTag}; use krilla::text::{Font, GlyphId, KrillaGlyph}; use crate::export::{Exporter, solid_fill, tf, to_path}; +impl Exporter<'_> { + /// A text run's paint: solid, or a gradient spanning the frame's box (`area`, text space), + /// for glyphs drawn under `origin` (text space ← run space). + fn text_paint(&mut self, swatch: &str, tint: f32, origin: Affine, area: Rect) -> Option { + if designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch).is_some() && origin.determinant().abs() > 1e-12 { + let fill = designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() }; + return self.fill_paint_in(&fill, self.text_area.unwrap_or(area), origin.inverse()); + } + self.swatch_color(swatch, tint).map(Into::into) + } +} + +/// An opaque nonzero fill with `paint`. +fn paint_fill(paint: Paint) -> Fill { + Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero } +} + /// Private-use characters DesignCraft stores for markers (page numbers, section markers, breaks). fn is_marker(c: char) -> bool { ('\u{E000}'..='\u{E1FF}').contains(&c) @@ -289,17 +307,23 @@ impl Exporter<'_> { fn run(&mut self, s: &mut Surface, cs: &ComposedStory, glyphs: &[PlacedGlyph], baseline: f64, story: &str) { let g0 = &glyphs[0]; let st = &cs.styles[g0.style as usize]; - let fill = self.swatch_color(&st.fill, st.fill_tint); - let stroke = - if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 { self.swatch_color(&st.stroke, st.stroke_tint) } else { None }; - if fill.is_none() && stroke.is_none() { - return; - } let face = g0.face; let size = g0.sy * face.upem; let hs = g0.sx / g0.sy; let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0); + // Outside a frame (no text area), a gradient spans the run. + let end = glyphs.last().map_or(g0.x, |g| g.x + g.adv); + let run_area = Rect::new(g0.x, baseline - size, end.max(g0.x + 1.0), baseline + size * 0.25); + let fill = self.text_paint(&st.fill, st.fill_tint, origin, run_area); + let stroke = if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 { + self.text_paint(&st.stroke, st.stroke_tint, origin, run_area) + } else { + None + }; + if fill.is_none() && stroke.is_none() { + return; + } // A missing glyph is drawn as its box: PDF/A and PDF/UA forbid showing .notdef as text. if g0.gid == 0 { self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight); @@ -322,8 +346,8 @@ impl Exporter<'_> { }) .collect(); s.push_transform(&tf(origin)); - s.set_fill(fill.map(|c| solid_fill(c, 1.0))); - s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() })); + s.set_fill(fill.map(paint_fill)); + s.set_stroke(stroke.map(|paint| Stroke { paint, width: st.stroke_weight as f32, ..Default::default() })); s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false); s.set_fill(None); s.set_stroke(None); @@ -338,8 +362,8 @@ impl Exporter<'_> { glyphs: &[PlacedGlyph], origin: Affine, size: f64, - fill: Option, - stroke: Option, + fill: Option, + stroke: Option, stroke_weight: f64, ) { let db = FontDb::global(); @@ -354,8 +378,8 @@ impl Exporter<'_> { } let Some(p) = to_path(&bp) else { return }; s.push_transform(&tf(origin)); - s.set_fill(fill.map(|c| solid_fill(c, 1.0))); - s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() })); + s.set_fill(fill.map(paint_fill)); + s.set_stroke(stroke.map(|paint| Stroke { paint, width: stroke_weight as f32, ..Default::default() })); s.draw_path(&p); s.set_fill(None); s.set_stroke(None); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index cc46ad729..f10c0d348 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -19,6 +19,45 @@ use vello_cpu::peniko; use crate::{Frame, Renderer, color_of, rect_overlaps}; +/// A text colour: solid, or a gradient swatch laid across the frame (InDesign spans a gradient on +/// text across its frame's box). +#[derive(Clone)] +enum TextPaint { + Solid(peniko::Color), + Gradient(designcraft_doc::Fill), +} + +impl TextPaint { + fn of(doc: &designcraft_doc::Document, swatch: &str, tint: f32) -> Option { + if designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch).is_some() { + return Some(TextPaint::Gradient(designcraft_doc::Fill { swatch: swatch.into(), tint, ..designcraft_doc::Fill::none() })); + } + doc.resolve_color(swatch, tint).map(|c| TextPaint::Solid(color_of(&c, 1.0))) + } + + /// Make this the context's paint; a gradient runs across `area` (text space). + fn set(&self, ctx: &mut RenderContext, doc: &designcraft_doc::Document, area: Rect) { + match self { + TextPaint::Solid(c) => ctx.set_paint(*c), + TextPaint::Gradient(fill) => { + crate::set_fill_paint(ctx, doc, fill, area); + } + } + } +} + +/// The box a gradient on a frame's text spans: the frame's, in text space (the union of its lines +/// when the frame is gone). +fn text_area(doc: &designcraft_doc::Document, ft: &FrameText) -> Rect { + if let Some(it) = doc.item(ft.frame) { + let local = doc.text_local(it); + if local.determinant().abs() > 1e-12 { + return local.inverse().transform_rect_bbox(it.inner_bounds()); + } + } + ft.lines.iter().filter_map(line_box).reduce(|a, b| a.union(b)).unwrap_or(Rect::new(0.0, 0.0, 1.0, 1.0)) +} + /// Cached outlines of one composed line. pub(crate) struct LineGlyphs { /// Frame-space bounds of everything drawn for the line (empty lines: `None`). @@ -349,13 +388,16 @@ impl Renderer { ctx.set_transform(m); if !shown.is_empty() { let fg = self.glyphs.get(cs, ft); - // Resolve each style's paints once per frame. - let mut fills: Vec>> = vec![None; cs.styles.len()]; - let mut fill_of = |si: u32| -> Option { - *fills[si as usize].get_or_insert_with(|| { - let st = &cs.styles[si as usize]; - doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0)) - }) + // Resolve each style's paints once per frame; gradients span the frame. + let area = text_area(doc, ft); + let mut fills: Vec>> = vec![None; cs.styles.len()]; + let mut fill_of = |si: u32| -> Option { + fills[si as usize] + .get_or_insert_with(|| { + let st = &cs.styles[si as usize]; + TextPaint::of(doc, &st.fill, st.fill_tint) + }) + .clone() }; shown.retain(|&i| fg.lines[i].bounds.is_some_and(|b| rect_overlaps(b, vis))); // Paint all underlines before any glyphs: skewed runs and tightly spaced lines @@ -384,19 +426,21 @@ impl Renderer { None, ); } - ctx.set_paint(c); + c.set(ctx, doc, area); ctx.fill_path(bp); + ctx.reset_paint_transform(); if op { ctx.pop_layer(); } } let st = &cs.styles[*si as usize]; if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) + && let Some(c) = TextPaint::of(doc, &st.stroke, st.stroke_tint) { - ctx.set_paint(color_of(&c, 1.0)); + c.set(ctx, doc, area); ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); ctx.stroke_path(bp); + ctx.reset_paint_transform(); } } } @@ -496,18 +540,22 @@ impl Renderer { ) { let doc = f.doc; ctx.set_transform(f.view * xf); + // A gradient on type on a path spans the path's box. + let area = path.bounding_box(); for (si, bp) in designcraft_compose::path_glyphs(cs, ft, path, pt) { let st = &cs.styles[si as usize]; - if let Some(c) = doc.resolve_color(&st.fill, st.fill_tint) { - ctx.set_paint(color_of(&c, 1.0)); + if let Some(c) = TextPaint::of(doc, &st.fill, st.fill_tint) { + c.set(ctx, doc, area); ctx.fill_path(&bp); + ctx.reset_paint_transform(); } if st.stroke != designcraft_color::swatch::NONE - && let Some(c) = doc.resolve_color(&st.stroke, st.stroke_tint) + && let Some(c) = TextPaint::of(doc, &st.stroke, st.stroke_tint) { - ctx.set_paint(color_of(&c, 1.0)); + c.set(ctx, doc, area); ctx.set_stroke(kurbo::Stroke::new(st.stroke_weight)); ctx.stroke_path(&bp); + ctx.reset_paint_transform(); } self.stats.glyphs += 1; } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..eae72856a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -421,7 +421,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - View › Display Performance: Fast (grey boxes, no effects, ⌥⇧⌘Z), Typical (72 ppi proxies, ⌥⌘Z), High Quality (⌥⌘H, our default — the renderer is fast enough). - OpenType menu (Character panel): discretionary ligatures, fractions, ordinal, swash, titling, contextual alternates, slashed zero, the four figure styles and stylistic sets 1–20; `type.openType`; mapped to IDML's OTF attributes. - Add Anchor Point (=), Delete Anchor Point (-) and Convert Direction Point (⇧C) tools (click toggles smooth/corner, drag pulls out handles), with `path.addAnchor`, `path.deleteAnchor`, `path.convertAnchor`. -- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. +- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel sets type, angle, reverse and edits stops on a ramp (drag, add, drag off to remove, location, colour). Edited gradients are unnamed swatches; `object.gradient`; the vector round-trips through IDML. Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF). - Document Setup (⌥⌘P): intent, number of pages, start page # (an even start begins with a left page), facing pages, size presets and orientation, bleed and slug per edge; `layout.documentSetup {}` reports the setup. - Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots. - Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.