feat(text): gradient swatches on text, laid across the frame as in InDesign

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 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-13 19:30:55 +02:00
co-authored by Claude Opus 5.5
parent e862e0a613
commit ac54c8faf7
6 changed files with 177 additions and 33 deletions
+1
View File
@@ -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.
+55
View File
@@ -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");
}
}
+21 -5
View File
@@ -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<designcraft_doc::StoryId>,
/// 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<Rect>,
/// Marked content of each (story, paragraph index).
pub(crate) para_tags: HashMap<(designcraft_doc::StoryId, usize), Vec<Identifier>>,
}
@@ -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<krilla::paint::Paint> {
pub(crate) fn fill_paint(&mut self, fill: &designcraft_doc::Fill, bounds: Rect) -> Option<krilla::paint::Paint> {
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<krilla::paint::Paint> {
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(),
}
}
+37 -13
View File
@@ -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<Paint> {
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<krilla::color::Color>,
stroke: Option<krilla::color::Color>,
fill: Option<Paint>,
stroke: Option<Paint>,
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);
+62 -14
View File
@@ -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<Self> {
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<Option<Option<peniko::Color>>> = vec![None; cs.styles.len()];
let mut fill_of = |si: u32| -> Option<peniko::Color> {
*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<Option<Option<TextPaint>>> = vec![None; cs.styles.len()];
let mut fill_of = |si: u32| -> Option<TextPaint> {
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;
}
+1 -1
View File
@@ -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.