diff --git a/ROADMAP.md b/ROADMAP.md index c3608b9b6..b678d059f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,6 +14,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope **Working today** - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. +- FID2: IDML underline/strikethrough Text Color resets survive style inheritance and roundtrip. Raster and PDF paint underlines behind glyphs, retain strikethrough above, and center rules on their offsets; synthetic overlap tests cover both backends. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3db5070d5..b67bb1ee8 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -71,7 +71,7 @@ pub struct RunStyle { pub kenten_character: String, } -/// An underline or strikethrough bar: its top edge `offset` below the baseline (negative = +/// An underline or strikethrough bar: its centre `offset` below the baseline (negative = /// above), thickness and colour. #[derive(Clone, Debug, PartialEq)] pub struct Rule { @@ -81,13 +81,21 @@ pub struct Rule { pub tint: f32, } +impl Rule { + /// The stroke is centred on the offset, matching Underline / Strikethrough Options. + pub fn rect(&self, x0: f64, x1: f64, baseline: f64) -> Rect { + let center = baseline + self.offset; + Rect::new(x0, center - self.weight / 2.0, x1, center + self.weight / 2.0) + } +} + impl RunStyle { /// The bars this run draws between `x0` and `x1` on a line at `baseline`. pub fn rules(&self, x0: f64, x1: f64, baseline: f64) -> impl Iterator { [(self.underline, &self.underline_rule), (self.strikethrough, &self.strike_rule)] .into_iter() .filter(|(on, _)| *on) - .map(move |(_, r)| (r, Rect::new(x0, baseline + r.offset, x1, baseline + r.offset + r.weight))) + .map(move |(_, r)| (r, r.rect(x0, x1, baseline))) } } diff --git a/crates/engine/tests/text_decorations.rs b/crates/engine/tests/text_decorations.rs new file mode 100644 index 000000000..cd3388f76 --- /dev/null +++ b/crates/engine/tests/text_decorations.rs @@ -0,0 +1,165 @@ +//! The same synthetic overlapping runs must paint correctly on screen and in exported PDF. +use designcraft_color::{Color, Swatch}; +use designcraft_compose::{Cache, compose_story}; +use designcraft_doc::{CharAttrs, Document, ParaFormat, SpreadRef, build::NewDocument}; +use designcraft_geom::{Affine, Rect}; +use designcraft_pdf::{PdfOptions, export_pdf}; +use designcraft_render::{Placed, RenderOptions, Rendered, Renderer}; + +fn document(decoration: u8, hide_first: bool, hide_second: bool) -> Document { + let mut d = Document::new(&NewDocument { width: 240.0, height: 240.0, facing_pages: false, ..Default::default() }); + d.swatches.push(Swatch::color("Cyan rule", Color::rgb(0.0, 1.0, 1.0))); + let (_, sid) = d + .add_text_frame( + SpreadRef::Doc(0), + Rect::new(0.0, 0.0, 240.0, 180.0), + d.default_layer(), + "HI", + ParaFormat { + chars: CharAttrs { font_family: Some("Source Sans 3".into()), size: Some(72.0), fill: Some("[Black]".into()), ..Default::default() }, + ..Default::default() + }, + ) + .unwrap(); + d.story_mut(sid).unwrap().format_chars(0..1, |f| { + f.over.skew = Some(45.0); + if hide_first { + f.over.fill = Some("[None]".into()); + } + }); + d.story_mut(sid).unwrap().format_chars(1..2, |f| { + if hide_second { + f.over.fill = Some("[None]".into()); + } + f.over.underline = Some(decoration == 1 || decoration == 3); + f.over.strikethrough = Some(decoration == 2); + f.over.underline_color = Some(if decoration == 3 { "[None]" } else { "Cyan rule" }.into()); + f.over.strikethrough_color = Some("Cyan rule".into()); + f.over.underline_weight = Some(Some(18.0)); + f.over.strikethrough_weight = Some(Some(18.0)); + f.over.underline_offset = Some(Some(-24.0)); + // Strike offsets are positive above the baseline in the document model. + f.over.strikethrough_offset = Some(Some(24.0)); + }); + d +} + +fn raster(d: &Document, threads: u16) -> Rendered { + let cache = Cache::new(); + let mut renderer = Renderer::new(); + renderer.threads = threads; + let placed = [Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }]; + let options = RenderOptions { paper: false, background: Some([255; 4]), greek_below_px: 0.0, ..Default::default() }; + let first = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options); + let cached = renderer.render(d, &cache, &placed, 480, 480, Affine::scale(2.0), &options); + assert_eq!(first.pixels, cached.pixels, "decoration cache hits must retain paint order"); + first +} + +fn pdf(d: &Document) -> Rendered { + let bytes = export_pdf(d, &Cache::new(), &PdfOptions::default()).unwrap(); + let image = designcraft_render::render_pdf_page(&bytes, 0, 480).unwrap(); + assert_eq!((image.width(), image.height()), (480, 480)); + // hayro renders on transparent paper; composite onto the same white backdrop as the canvas. + let pixels = image + .data() + .iter() + .flat_map(|p| { + let white = 255 - p.a; + [p.r.saturating_add(white), p.g.saturating_add(white), p.b.saturating_add(white), 255] + }) + .collect(); + Rendered { width: 480, height: 480, pixels } +} + +fn dark(p: [u8; 4]) -> bool { + // Process black is color managed (about 52/51/52 with the bundled profile). + p[0] < 80 && p[1] < 80 && p[2] < 80 +} +fn white(p: [u8; 4]) -> bool { + p[0] > 245 && p[1] > 245 && p[2] > 245 +} +fn cyan(p: [u8; 4]) -> bool { + p[0] < 35 && p[1] > 220 && p[2] > 220 +} + +fn check_paint_order(render: impl Fn(&Document) -> Rendered) { + let plain_doc = document(0, false, false); + let plain = render(&plain_doc); + let first_only = render(&document(0, false, true)); + let second_only = render(&document(0, true, false)); + let under = render(&document(1, false, false)); + let strike = render(&document(2, false, false)); + assert_eq!(render(&document(3, false, false)).pixels, plain.pixels, "explicit None must draw no rule"); + let sid = *plain_doc.stories.keys().next().unwrap(); + let cs = compose_story(&plain_doc, sid, &Default::default()); + let line = &cs.frames[0].lines[0]; + let g = line.glyphs.iter().find(|g| g.byte == 1).unwrap(); + let rect = Rect::new(g.x, line.baseline - 24.0 - 9.0, g.x + g.adv, line.baseline - 24.0 + 9.0); + let mut cross_run = 0; + let mut exposed = 0; + for y in (rect.y0 * 2.0).ceil() as u32 + 2..(rect.y1 * 2.0).floor() as u32 - 2 { + for x in (rect.x0 * 2.0).ceil() as u32 + 2..(rect.x1 * 2.0).floor() as u32 - 2 { + // The first run's skewed H overlaps the SECOND run's rule. A per-run under/glyph + // loop would still cover these pixels, so the witness is stronger than one run. + if dark(first_only.pixel(x, y)) && white(second_only.pixel(x, y)) { + cross_run += 1; + assert!(dark(under.pixel(x, y)), "underline covered an earlier run at {x},{y}: {:?}", under.pixel(x, y)); + assert!(cyan(strike.pixel(x, y)), "strike must remain above glyphs at {x},{y}"); + } + if white(plain.pixel(x, y)) { + exposed += 1; + assert!(cyan(under.pixel(x, y)), "the visible underline must retain its fill"); + } + } + } + assert!(cross_run > 20, "fixture needs cross-run interior-ink witnesses, got {cross_run}"); + assert!(exposed > 20, "fixture needs visible rule pixels, got {exposed}"); +} + +#[test] +fn raster_underlines_are_behind_all_runs_and_strikes_remain_above() { + for threads in [0, 2] { + check_paint_order(|d| raster(d, threads)); + } +} + +#[test] +fn pdf_underlines_are_behind_all_runs_and_strikes_remain_above() { + check_paint_order(pdf); +} + +#[test] +fn decoration_offsets_are_centers_for_explicit_and_automatic_rules() { + let mut d = document(1, false, false); + let sid = *d.stories.keys().next().unwrap(); + for (automatic, size) in [(false, 72.0), (true, 28.0)] { + d.story_mut(sid).unwrap().format_chars(0..2, |f| { + f.over.size = Some(size); + f.over.underline = Some(true); + f.over.strikethrough = Some(true); + f.over.underline_color = Some(String::new()); + f.over.strikethrough_color = Some(String::new()); + f.over.fill_tint = Some(0.4); + f.over.underline_tint = Some(0.9); + f.over.strikethrough_tint = Some(0.8); + f.over.underline_weight = Some(if automatic { None } else { Some(12.0) }); + f.over.strikethrough_weight = Some(if automatic { None } else { Some(6.0) }); + f.over.underline_offset = Some(if automatic { None } else { Some(-3.0) }); + f.over.strikethrough_offset = Some(if automatic { None } else { Some(8.0) }); + }); + let cs = compose_story(&d, sid, &Default::default()); + for style in &cs.styles { + let baseline = 84.222687; + let rules: Vec<_> = style.rules(10.0, 50.0, baseline).collect(); + assert_eq!(rules.len(), 2); + let expected = if automatic { [(size * 0.12, size / 14.0), (-size * 0.3, size / 14.0)] } else { [(-3.0, 12.0), (-8.0, 6.0)] }; + for ((rule, rect), (offset, weight)) in rules.iter().zip(expected) { + assert!((rect.center().y - (baseline + offset)).abs() < 1e-9, "offset must locate the center: {rect:?}"); + assert!((rect.height() - weight).abs() < 1e-9); + assert_eq!(rule.color, style.fill); + assert_eq!(rule.tint, 0.4); + } + } + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 919991eb0..62f50d0f9 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1098,8 +1098,8 @@ impl<'a> Ex<'a> { if let Some(o) = o { el.set(&format!("{k}Offset"), num(o.unwrap_or(-9999.0))); } - if let Some(c) = c.as_ref().filter(|c| !c.is_empty()) { - el.set(&format!("{k}Color"), self.sw(c)); + if let Some(c) = c { + el.set(&format!("{k}Color"), if c.is_empty() { "Text Color".into() } else { self.sw(c) }); } if let Some(t) = t { el.set(&format!("{k}Tint"), num(t as f64 * 100.0)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 3e1c17e28..f6e711d51 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1095,7 +1095,9 @@ impl<'r> Importer<'r> { // Underline / strikethrough options (-9999 = automatic). for k in ["Underline", "StrikeThru"] { let num = |n: &str| e.num(&format!("{k}{n}")).filter(|v| *v > -9000.0); - let color = e.prop(&format!("{k}Color")).map(|r| self.swatch_ref(r.trim())).filter(|r| r != "Text Color" && !r.is_empty()); + // An explicit Text Color resets an inherited swatch; absence still inherits. + // Resolve the sentinel before swatch_ref, which maps unknown names to [None]. + let color = e.prop(&format!("{k}Color")).map(|r| if r.trim() == "Text Color" { String::new() } else { self.swatch_ref(r.trim()) }); let tint = e.num(&format!("{k}Tint")).filter(|v| *v >= 0.0).map(|v| (v / 100.0) as f32); if k == "Underline" { a.underline_weight = num("Weight").map(Some); diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index b636a2ac1..910c89a00 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -7,6 +7,8 @@ use designcraft_geom::{Rect, shapes}; use super::*; +mod decorations; + fn zip_files(files: &[(&str, &str)]) -> Vec { use zip::write::SimpleFileOptions; let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); diff --git a/crates/idml/src/tests/decorations.rs b/crates/idml/src/tests/decorations.rs new file mode 100644 index 000000000..338acffca --- /dev/null +++ b/crates/idml/src/tests/decorations.rs @@ -0,0 +1,83 @@ +use super::*; + +fn fixture(local: &str) -> Document { + let styles = r#" + + + ParagraphStyle/PBase + ParagraphStyle/PReset + + + + CharacterStyle/CBaseText ColorText Color + CharacterStyle/CReset + CharacterStyle/CBase + + "#; + let story = format!( + r#"Ink"# + ); + let map = r#""#; + let spread = r#""#; + import_idml(&zip_files(&[("designmap.xml", map), ("Resources/Styles.xml", styles), ("Stories/s.xml", &story), ("Spreads/s.xml", spread)])) + .unwrap() +} + +fn assert_style_resets(d: &Document) { + let p = d.styles.para("PReset").unwrap(); + let c = d.styles.char_style("CReset").unwrap(); + for attrs in [&p.chars, &c.chars] { + assert_eq!(attrs.underline_color.as_deref(), Some(""), "an explicit reset must remain a sparse value"); + assert_eq!(attrs.strikethrough_color.as_deref(), Some("")); + } + let (_, base) = d.styles.resolve_para_style("PBase"); + let (_, reset) = d.styles.resolve_para_style("PLeaf"); + assert_eq!(base.underline_color, "[Black]"); + assert_eq!(base.strikethrough_color, "[Black]"); + assert!(reset.underline_color.is_empty() && reset.strikethrough_color.is_empty()); + let reset = d.styles.resolve_char(&base, &designcraft_doc::CharFormat { style: "CLeaf".into(), ..Default::default() }); + assert!(reset.underline_color.is_empty() && reset.strikethrough_color.is_empty()); + let inherited = d.styles.resolve_char(&base, &designcraft_doc::CharFormat { style: "CInherit".into(), ..Default::default() }); + assert_eq!(inherited.underline_color, "[Black]"); + assert_eq!(inherited.strikethrough_color, "[Black]"); + assert!(d.styles.char_style("CInherit").unwrap().chars.underline_color.is_none()); +} + +#[test] +fn text_color_resets_paragraph_and_character_style_chains_through_roundtrip() { + let d = fixture(""); + assert_style_resets(&d); + assert_style_resets(&import_idml(&export_idml(&d)).unwrap()); +} + +#[test] +fn local_text_color_and_explicit_none_remain_distinct_through_roundtrip() { + for (local, expected) in [ + (r#"UnderlineColor="Text Color" StrikeThruColor="Text Color""#, ""), + (r#"UnderlineColor="n" StrikeThruColor="n""#, "[None]"), + (r#"UnderlineColor="" StrikeThruColor="""#, "[None]"), + ("", "[Black]"), + ] { + let imported = fixture(local); + let reopened = import_idml(&export_idml(&imported)).unwrap(); + for d in [&imported, &reopened] { + let story = d.stories.values().next().unwrap(); + let (_, para) = d.styles.resolve_para(&story.paras[0]); + let actual = d.styles.resolve_char(¶, story.char_format_at(0)); + assert_eq!(actual.underline_color, expected); + assert_eq!(actual.strikethrough_color, expected); + } + } +} + +#[test] +fn exporting_native_text_color_reset_writes_the_sentinel() { + let mut d = fixture(""); + let attrs = &mut d.styles_mut().para_mut("PReset").unwrap().chars; + attrs.underline_color = Some(String::new()); + attrs.strikethrough_color = Some(String::new()); + let attrs = &mut d.styles_mut().char_style_mut("CReset").unwrap().chars; + attrs.underline_color = Some(String::new()); + attrs.strikethrough_color = Some(String::new()); + assert_style_resets(&import_idml(&export_idml(&d)).unwrap()); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index 7fd8e3654..fb2370cb5 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -39,6 +39,28 @@ fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool { && (a.y - b.y).abs() < 1e-6 } +/// Merge bars of one paint layer; underlines and strikes must never be merged together. +fn text_rules(cs: &ComposedStory, ft: &FrameText, underline: bool) -> Vec<(designcraft_compose::Rule, Rect)> { + let mut bars: Vec<(designcraft_compose::Rule, Rect)> = Vec::new(); + for line in &ft.lines { + for g in line.glyphs.iter().filter(|g| g.visible) { + let style = &cs.styles[g.style as usize]; + let (on, rule) = if underline { (style.underline, &style.underline_rule) } else { (style.strikethrough, &style.strike_rule) }; + if !on { + continue; + } + let rect = rule.rect(g.x, g.x + g.adv, line.baseline); + match bars.last_mut() { + Some((last_rule, last)) if last_rule == rule && (last.y0 - rect.y0).abs() < 1e-6 && (rect.x0 - last.x1).abs() < 0.5 => { + last.x1 = rect.x1; + } + _ => bars.push((rule.clone(), rect)), + } + } + } + bars +} + impl Exporter<'_> { pub(crate) fn font(&mut self, face: &FontFace) -> Option { self.fonts @@ -108,7 +130,17 @@ impl Exporter<'_> { me.tables(s, ft); } }); - let mut deco: Vec<(designcraft_compose::Rule, Rect)> = Vec::new(); + // All underlines precede all glyph runs, including ink overlapping from another run. + let underlines = text_rules(cs, ft, true); + if !underlines.is_empty() { + self.as_artifact(s, |me, s| { + for (rule, r) in underlines { + if let Some(c) = me.swatch_color(&rule.color, rule.tint) { + me.fill_rect(s, r, c); + } + } + }); + } for l in &ft.lines { let gs = &l.glyphs; let mut i = 0; @@ -135,21 +167,9 @@ impl Exporter<'_> { self.tagged_run(s, cs, &gs[i..j], l.baseline, story); i = j; } - // Underline / strikethrough, merged per style along the line. - for g in gs.iter().filter(|g| g.visible) { - let st = &cs.styles[g.style as usize]; - for (rule, r) in st.rules(g.x, g.x + g.adv, l.baseline) { - match deco.last_mut() { - Some((last_rule, last)) if *last_rule == *rule && (last.y0 - r.y0).abs() < 1e-6 && (r.x0 - last.x1).abs() < 0.5 => { - last.x1 = r.x1 - } - _ => deco.push((rule.clone(), r)), - } - } - } } self.as_artifact(s, |me, s| { - for (rule, r) in deco { + for (rule, r) in text_rules(cs, ft, false) { if let Some(c) = me.swatch_color(&rule.color, rule.tint) { me.fill_rect(s, r, c); } diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 165366eb7..854681642 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -25,8 +25,9 @@ pub(crate) struct LineGlyphs { pub bounds: Option, /// Glyph outlines batched per run style. pub runs: Vec<(u32, BezPath)>, - /// Underline / strikethrough bars: colour swatch, tint, rectangle. - pub decos: Vec<(String, f32, Rect)>, + /// Bars below/above the glyph paint pass: colour swatch, tint, rectangle. + pub underlines: Vec<(String, f32, Rect)>, + pub strikes: Vec<(String, f32, Rect)>, pub glyphs: usize, } @@ -116,15 +117,18 @@ fn union(r: Option, b: Rect) -> Option { fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> LineGlyphs { let mut runs: Vec<(u32, BezPath)> = Vec::new(); - let mut decos = Vec::new(); + let mut underlines = Vec::new(); + let mut strikes = Vec::new(); let mut glyphs = 0; for g in &l.glyphs { if !g.visible { continue; } let style = &cs.styles[g.style as usize]; - for (rule, r) in style.rules(g.x, g.x + g.adv, l.baseline) { - decos.push((rule.color.clone(), rule.tint, r)); + for (on, rule, bars) in [(style.underline, &style.underline_rule, &mut underlines), (style.strikethrough, &style.strike_rule, &mut strikes)] { + if on { + bars.push((rule.color.clone(), rule.tint, rule.rect(g.x, g.x + g.adv, l.baseline))); + } } let outline = db.outline(&g.face, g.gid); if outline.elements().is_empty() { @@ -155,10 +159,10 @@ fn build_line(db: &FontDb, cs: &ComposedStory, l: &Line, vertical: bool) -> Line let sw = if st.stroke != designcraft_color::swatch::NONE { st.stroke_weight } else { 0.0 }; bounds = union(bounds, bp.bounding_box().inflate(sw, sw)); } - for (_, _, r) in &decos { + for (_, _, r) in underlines.iter().chain(&strikes) { bounds = union(bounds, *r); } - LineGlyphs { bounds, runs, decos, glyphs } + LineGlyphs { bounds, runs, underlines, strikes, glyphs } } fn build(cs: &ComposedStory, ft: &FrameText) -> FrameGlyphs { @@ -339,12 +343,19 @@ impl Renderer { doc.resolve_color(&st.fill, st.fill_tint).map(|c| color_of(&c, 1.0)) }) }; - for i in shown { - let lg = &fg.lines[i]; - let Some(b) = lg.bounds else { continue }; - if !rect_overlaps(b, vis) { - continue; + 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 + // can overlap a later run's rule. A per-run paint pass would still cover ink. + for &i in &shown { + for (sw, tint, r) in &fg.lines[i].underlines { + if let Some(c) = doc.resolve_color(sw, *tint) { + ctx.set_paint(color_of(&c, 1.0)); + ctx.fill_rect(r); + } } + } + for &i in &shown { + let lg = &fg.lines[i]; self.stats.glyphs += lg.glyphs; for (si, bp) in &lg.runs { if let Some(c) = fill_of(*si) { @@ -374,7 +385,9 @@ impl Renderer { ctx.stroke_path(bp); } } - for (sw, tint, r) in &lg.decos { + } + for &i in &shown { + for (sw, tint, r) in &fg.lines[i].strikes { if let Some(c) = doc.resolve_color(sw, *tint) { ctx.set_paint(color_of(&c, 1.0)); ctx.fill_rect(r);