diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 6d6b1b980..1cdf4dd28 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -112,7 +112,9 @@ impl Cache { sig.push(usize::from(doc.settings.glyph_fallback)); // Named lists continue from earlier stories. for l in &doc.settings.lists { - sig.push(doc.list_start(sid, &l.name) as usize); + let mut h = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&doc.list_start(sid, &l.name), &mut h); + sig.push(std::hash::Hasher::finish(&h) as usize); } { let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..1c9a225a8 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -16,6 +16,7 @@ mod cache; pub mod hyphen; mod notes; mod overlay; +mod parabox; mod ruby; pub mod shape; pub mod table; @@ -29,8 +30,8 @@ use std::rc::Rc; use std::sync::{Arc, Mutex}; use designcraft_doc::{ - Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ParaProps, SpanColumns, StartParagraph, Story, StoryId, TabAlign, TextFrameOptions, - VerticalJustification, WrapMode, story, + Align, Composer, Document, FirstBaseline, GridAlign, ItemId, ListAlign, ListCounter, ParaProps, SpanColumns, StartParagraph, Story, StoryId, + TabAlign, TextFrameOptions, VerticalJustification, WrapMode, story, }; use designcraft_fonts::{FontDb, ScopedFonts}; use designcraft_geom::{Point, Rect}; @@ -39,6 +40,7 @@ use crate::breaker::{Break, Spacing}; pub use crate::cache::Cache; use crate::notes::Notes; pub use crate::notes::{find_note, hit_note, note_caret}; +use crate::parabox::ParaBox; use crate::shape::{Glyph, StyleTable, SubstCtx}; pub use crate::table::{PlacedCell, StrokeSeg, TableFrag, cell_caret, find_cell, hit_cell}; @@ -199,12 +201,16 @@ pub struct Line { pub keep_violation: bool, } -/// A paragraph rule or shading rectangle in frame inner space. -#[derive(Clone, Debug, PartialEq)] +/// A paragraph rule, border or shading in frame inner space: `rect` filled, or `path` (non-zero +/// winding) when it has one, with `rect` its bounds. +#[derive(Clone, Debug, Default, PartialEq)] pub struct Deco { pub rect: Rect, pub color: String, pub tint: f32, + pub path: Option, + /// Shown on screen only (not printed or exported). + pub nonprinting: bool, } #[derive(Clone, Debug, Default)] @@ -499,10 +505,10 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let para_ranges = story.para_ranges(); let np = para_ranges.len(); let hyph_exceptions = doc.hyphenation_exception_map(); - let mut list_counter: u32 = 0; - // Named lists number independently of layout: one pass up front. - let named_numbers: Vec> = { - let mut counters: std::collections::HashMap = std::collections::HashMap::new(); + let mut list_counter = ListCounter::default(); + // Named lists number independently of layout: one pass up front, for the labels. + let named_labels: Vec> = { + let mut counters: std::collections::HashMap = std::collections::HashMap::new(); story .paras .iter() @@ -513,11 +519,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C return None; } let c = counters.entry(pp.list_name.clone()).or_insert_with(|| doc.list_start(story.id, &pp.list_name)); - *c = pp.start_at.map_or(c.saturating_add(1), |s| s.max(1)); - Some(*c) + c.advance(&pp); + Some(pp.number_label_in(c)) }) .collect() }; + // Border and shading of each paragraph set, drawn once the lines are in place. + let mut boxes: Vec> = vec![None; np]; // Keep options: paragraphs are re-laid from a snapshot when a keep is violated, either forced // into the next column or with a cap on the lines set before moving on (widow control). let mut snaps: Vec = Vec::with_capacity(np); @@ -550,7 +558,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C cur.pi = pi; cur.limits = Rc::clone(&limits); cur.split_limits = Rc::clone(&split_limits); - let snap = Snapshot::take(&out, &cur, list_counter); + let snap = Snapshot::take(&out, &cur, &list_counter); snaps.truncate(pi); snaps.push(snap); note_snaps.truncate(pi); @@ -566,7 +574,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C continue 'paras; } let pf = &story.paras[pi]; - let (pp, base_chars) = doc.styles.resolve_para(pf); + let (mut pp, base_chars) = doc.styles.resolve_para(pf); // A break character that ends the previous paragraph sends this one on, like a start option. let after_break = pi .checked_sub(1) @@ -603,7 +611,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C keep_first: 1, keep_last: 1, }); - list_counter = 0; + list_counter = ListCounter::default(); + boxes[pi] = None; cur.pending += pp.space_after; pi += 1; continue; @@ -729,31 +738,54 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } // List labels take the default super/subscript settings. let label_env = shape::TypeEnv { adv: Default::default(), ..env }; - match pp.list_type { + let list_label = match pp.list_type { // An empty paragraph has no bullet or number, and the numbering carries on past it. - designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => {} + designcraft_doc::ListType::Numbers | designcraft_doc::ListType::Bullets if prange.is_empty() => None, + // A named list: carries on past other paragraphs (and from earlier stories). designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => { - // A named list: carries on past other paragraphs (and from earlier stories). - let n = named_numbers.get(pi).copied().flatten().unwrap_or(1); - let label = pp.number_label(n); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + Some(named_labels.get(pi).cloned().flatten().unwrap_or_else(|| pp.number_label(1))) } designcraft_doc::ListType::Numbers => { - list_counter = pp.start_at.map_or(list_counter.saturating_add(1), |s| s.max(1)); - let label = pp.number_label(list_counter); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + list_counter.advance(&pp); + Some(pp.number_label_in(&list_counter)) } - designcraft_doc::ListType::Bullets => { - let label = pp.bullet_label(); - prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, label_env, &mut table); + designcraft_doc::ListType::Bullets => Some(pp.bullet_label()), + designcraft_doc::ListType::None => { + list_counter = ListCounter::default(); + None + } + }; + // The label's glyphs at the paragraph start, and how many of them (up to its first tab) + // the list alignment places. + let mut label_aligned = 0usize; + let mut label_tab = false; + if let Some(label) = &list_label { + let chars = label_chars(doc, &pp, &base_chars); + let before = sp.glyphs.len(); + prepend_label(db, &mut sp.glyphs, label, prange.start, &chars, label_env, &mut table); + let n = sp.glyphs.len().saturating_sub(before); + label_aligned = sp.glyphs.get(..n).unwrap_or_default().iter().position(|g| g.ch == '\t').unwrap_or(n); + label_tab = label_aligned < n; + // Tab Position: a stop for the text after the label. + if let Some(t) = pp.list_tab.filter(|t| t.is_finite() && *t >= 0.0) + && !pp.tabs.iter().any(|s| (s.position - t).abs() < 1e-6) + { + pp.tabs.push(designcraft_doc::TabStop { position: t, align: TabAlign::Left, leader: String::new(), align_on: String::new() }); + pp.tabs.sort_by(|a, b| a.position.total_cmp(&b.position)); } - designcraft_doc::ListType::None => list_counter = 0, } if pi == 0 && let Some(label) = &opts.label { prepend_label(db, &mut sp.glyphs, label, prange.start, &base_chars, label_env, &mut table); + label_aligned = 0; } + let list_align = match pp.list_type { + _ if label_aligned == 0 || pp.direction == designcraft_doc::TextDirection::RightToLeft => designcraft_doc::ListAlign::Left, + designcraft_doc::ListType::Bullets => pp.bullet_align, + _ => pp.number_align, + }; + boxes[pi] = ParaBox::of(doc, &pp); let mut glyphs = sp.glyphs; let bidi_text = bidi::paragraph_text(&mut glyphs, &story.text); let bidi_info = unicode_bidi::BidiInfo::new( @@ -807,6 +839,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if cur.last_baseline.is_some() { begin_at(&mut cur, pp.start_paragraph); } + // Text in the columns above a spanning paragraph, or a split block starting below text. + let mut text_above = cur.split.is_none() && split_cfg.is_some(); if spanning && cur.fi < frames.len() { // Close the band: the paragraph goes below the deepest of its columns, which are // balanced first. @@ -843,13 +877,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C cur.col = 0; cur.last_baseline = Some(baseline); cur.last_descent = descent; + text_above = true; } else if cur.col != 0 { cur.col = 0; cur.resume(); } } if cur.last_baseline.is_some() { - cur.pending += pp.space_before; + cur.pending += if text_above { pp.space_before.max(pp.span_space_before) } else { pp.space_before }; } if let Some(cfg) = split_cfg && cur.split.is_none() @@ -876,8 +911,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C continue 'paras; } out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start)); - // The part of the paragraph that fits keeps its shading and border. - para_box_decos(&mut out, pi, &pp); break 'paras; } let f = &frames[cur.fi]; @@ -1037,6 +1070,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let forced_mid = brk == Some(story::FORCED_LINE_BREAK) && !last; let (mut placed, end_x, ratio) = layout_line(&glyphs, s, e, b.hyphen, lx0, lx1, col.x0, &pp, &spacing, ends_para, forced_mid, f.left_page, &bidi_info); + let end_x = if line_no == 0 && s == 0 { align_label(&mut placed, label_aligned, label_tab, list_align, end_x) } else { end_x }; // Warichu runs before ruby so a reading is placed over the stacked note. let end_x = end_x + warichu::place(&styles_tab, &mut placed); ruby::annotate(db, &styles_tab, &mut placed, doc.settings.glyph_fallback); @@ -1130,7 +1164,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C baseline: l.baseline, descent: l.descent, reference: cur.last_reference, - pending: pp.space_after, + pending: pp.space_after.max(pp.span_space_after), }; cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above) }; } @@ -1150,10 +1184,10 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color: r.color.clone(), tint: r.tint, + ..Deco::default() }); } } - para_box_decos(&mut out, pi, &pp); if pp.rule_below.on && let Some(ft) = out.frames.get_mut(cur.fi.min(frames.len().saturating_sub(1))) && let Some(l) = ft.lines.iter().rev().find(|l| l.para == pi) @@ -1161,15 +1195,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let r = &pp.rule_below; let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; - ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color: r.color.clone(), tint: r.tint }); + ft.decos.push(Deco { + rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), + color: r.color.clone(), + tint: r.tint, + ..Deco::default() + }); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. + let mut ends_split = false; if let Some(sb) = cur.split { let next = story.paras.get(pi + 1).filter(|_| story.para_table(pi + 1).is_none()).and_then(|p| SplitCfg::of(&doc.styles.resolve_para(p).0)); if next != Some(sb.cfg) { cur.split = None; + ends_split = true; let part = out.frames.get(cur.fi).and_then(|ft| { let lines = ft.lines.get(sb.line0?..)?; let top = lines.first().map(|l| l.baseline - l.ascent)?; @@ -1204,7 +1245,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } } } - cur.pending += pp.space_after; + cur.pending += if spanning || ends_split { pp.space_after.max(pp.span_space_after) } else { pp.space_after }; pi += 1; } notes.finish(doc, story, frames, &cols, &mut out); @@ -1287,12 +1328,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C last_end = ft.range.end; } } - // Column rules, once vertical justification has put the lines in place. + // Paragraph borders and shading (the part of a paragraph that fits keeps its box), and column + // rules, once vertical justification has put the lines in place. + parabox::draw(&mut out, &boxes, frames); for (ft, f) in out.frames.iter_mut().zip(frames) { if f.opts.column_rule { let rects = column_rule_rects(&ft.columns, &ft.lines, &f.opts); let tint = if f.opts.column_rule_tint.is_finite() { f.opts.column_rule_tint.clamp(0.0, 1.0) } else { 1.0 }; - ft.decos.extend(rects.into_iter().map(|rect| Deco { rect, color: f.opts.column_rule_color.clone(), tint })); + ft.decos.extend(rects.into_iter().map(|rect| Deco { rect, color: f.opts.column_rule_color.clone(), tint, ..Deco::default() })); } } out.styles = styles_tab; @@ -1353,51 +1396,6 @@ fn mark_keep_violations(doc: &Document, story: &Story, out: &mut ComposedStory) } } -/// Paragraph shading and border, per column the paragraph sits in (a split paragraph gets one box -/// per part; the border's top edge on the first part, its bottom edge on the last). -fn para_box_decos(out: &mut ComposedStory, pi: usize, pp: &ParaProps) { - if !pp.shading_on && !pp.border_on { - return; - } - let mut parts: Vec<(usize, Rect)> = Vec::new(); - for (fi, ft) in out.frames.iter().enumerate() { - // A part ends where the next line moves to another column or sub-column. - let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi).collect(); - for part in lines.chunk_by(|a, b| a.column == b.column && b.baseline > a.baseline) { - if let (Some(a), Some(z)) = (part.first(), part.last()) { - parts.push((fi, Rect::new(a.x0, a.baseline - a.ascent, a.x1, z.baseline + z.descent))); - } - } - } - let n = parts.len(); - for (k, (fi, r)) in parts.into_iter().enumerate() { - let ft = &mut out.frames[fi]; - if pp.shading_on { - let [t, l, b, rr] = pp.shading_offsets; - ft.decos - .insert(0, Deco { rect: Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + b), color: pp.shading_color.clone(), tint: pp.shading_tint }); - } - if pp.border_on { - let [t, l, b, rr] = pp.border_offsets; - let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + b); - let [wt, wl, wb, wr] = pp.border_weights.map(|w| w.max(0.0)); - let mut edge = |rect: Rect| ft.decos.push(Deco { rect, color: pp.border_color.clone(), tint: pp.border_tint }); - if k == 0 && wt > 0.0 { - edge(Rect::new(o.x0 - wl, o.y0 - wt, o.x1 + wr, o.y0)); - } - if k + 1 == n && wb > 0.0 { - edge(Rect::new(o.x0 - wl, o.y1, o.x1 + wr, o.y1 + wb)); - } - if wl > 0.0 { - edge(Rect::new(o.x0 - wl, o.y0, o.x0, o.y1)); - } - if wr > 0.0 { - edge(Rect::new(o.x1, o.y0, o.x1 + wr, o.y1)); - } - } - } -} - /// Thickest column rule drawn (points). const MAX_COLUMN_RULE_WEIGHT: f64 = 1000.0; @@ -1539,18 +1537,18 @@ fn apply_desired_spacing(glyphs: &mut [Glyph], pp: &ParaProps) { /// paragraphs from here). Only the cursor's frame and later ones can change afterwards. struct Snapshot { cur: Cursor, - list_counter: u32, + list_counter: ListCounter, lines: usize, decos: usize, tables: usize, } impl Snapshot { - fn take(out: &ComposedStory, cur: &Cursor, list_counter: u32) -> Snapshot { + fn take(out: &ComposedStory, cur: &Cursor, list_counter: &ListCounter) -> Snapshot { let (lines, decos, tables) = out.frames.get(cur.fi).map_or((0, 0, 0), |f| (f.lines.len(), f.decos.len(), f.tables.len())); - Snapshot { cur: cur.clone(), list_counter, lines, decos, tables } + Snapshot { cur: cur.clone(), list_counter: list_counter.clone(), lines, decos, tables } } - fn restore(&self, out: &mut ComposedStory, cur: &mut Cursor, list_counter: &mut u32) { + fn restore(&self, out: &mut ComposedStory, cur: &mut Cursor, list_counter: &mut ListCounter) { let fi = self.cur.fi; for (i, f) in out.frames.iter_mut().enumerate().skip(fi) { let (l, d, t) = if i == fi { (self.lines, self.decos, self.tables) } else { (0, 0, 0) }; @@ -1560,7 +1558,7 @@ impl Snapshot { f.objects.retain(|o| o.line < l); } *cur = self.cur.clone(); - *list_counter = self.list_counter; + *list_counter = self.list_counter.clone(); out.overset_at = None; } } @@ -1575,7 +1573,7 @@ fn rewind( notes: &mut Notes, out: &mut ComposedStory, cur: &mut Cursor, - list_counter: &mut u32, + list_counter: &mut ListCounter, force_col: &mut [bool], line_cap: &mut [Option], ) { @@ -2641,6 +2639,43 @@ fn place(g: &Glyph, x: f64) -> PlacedGlyph { } } +/// The character attributes of a list label: the paragraph's, under the bullet's or number's +/// character style, in the bullet font. +fn label_chars(doc: &Document, pp: &ParaProps, base: &designcraft_doc::CharProps) -> designcraft_doc::CharProps { + let bullets = pp.list_type == designcraft_doc::ListType::Bullets; + let style = if bullets { &pp.bullet_char_style } else { &pp.number_char_style }; + let mut cp = if style.is_empty() || style == designcraft_doc::NO_CHAR_STYLE { + base.clone() + } else { + doc.styles.resolve_char(base, &designcraft_doc::CharFormat { style: style.clone(), over: Default::default() }) + }; + if bullets && !pp.bullet_font.is_empty() { + cp.font_family = pp.bullet_font.clone(); + if !pp.bullet_font_style.is_empty() { + cp.font_style = pp.bullet_font_style.clone(); + } + } + cp +} + +/// List alignment on a paragraph's first line: the label's first `n` glyphs (its text before the +/// tab) are centred on or end at the line start. Text after the label's tab stays at its stop; +/// without a tab the whole line moves with the label. Returns the line's end x. +fn align_label(placed: &mut [PlacedGlyph], n: usize, tab: bool, align: ListAlign, end_x: f64) -> f64 { + let Some(label) = placed.get(..n).filter(|l| l.iter().all(|g| g.len == 0)) else { return end_x }; + let w: f64 = label.iter().map(|g| g.adv).sum(); + let dx = match align { + ListAlign::Left => return end_x, + ListAlign::Center => -w / 2.0, + ListAlign::Right => -w, + }; + let moved = if tab { n } else { placed.len() }; + for g in placed.iter_mut().take(moved) { + g.x += dx; + } + if tab { end_x } else { end_x + dx } +} + fn prepend_label( db: &ScopedFonts<'_>, glyphs: &mut Vec, diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index 32b2bbec2..bdd4eb754 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -169,6 +169,7 @@ impl Notes { rect: Rect::new(x0, y, (x0 + o.rule.width).min(col.x1), y + o.rule.weight), color: o.rule.color.clone(), tint: o.rule.tint, + ..Deco::default() }); } let mut y = top; diff --git a/crates/compose/src/parabox.rs b/crates/compose/src/parabox.rs new file mode 100644 index 000000000..b8b58cb44 --- /dev/null +++ b/crates/compose/src/parabox.rs @@ -0,0 +1,358 @@ +//! Paragraph border and shading: one box per column a paragraph (or a run of consecutive +//! paragraphs with the same settings) sits in, drawn once the lines are in place. + +use designcraft_doc::{BoxBottom, BoxTop, BoxWidth, Cap, Document, Join, ParaProps, StrokeType}; +use designcraft_geom::corners::{Corner, box_outline}; +use designcraft_geom::{BezPath, Rect, Shape, kurbo}; + +use crate::{ComposedStory, Deco, FrameSpec, Line}; + +/// Flattening tolerance of stroked outlines (points). +const TOL: f64 = 0.05; +/// Most dashes drawn along one border (more falls back to a solid stroke). +const MAX_DASHES: f64 = 20_000.0; + +/// The border and shading settings of a paragraph (the stroke type resolved against the document's +/// stroke styles). Consecutive paragraphs with equal settings share a box when they merge. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ParaBox { + shading: Option, + border: Option, + merge: bool, +} + +#[derive(Clone, Debug, PartialEq)] +struct Shading { + color: String, + tint: f32, + offsets: [f64; 4], + corners: [Corner; 4], + width: BoxWidth, + top: BoxTop, + bottom: BoxBottom, + clip: bool, + nonprinting: bool, +} + +#[derive(Clone, Debug, PartialEq)] +struct Border { + color: String, + tint: f32, + /// Top, left, bottom, right. + weights: [f64; 4], + offsets: [f64; 4], + kind: StrokeType, + cap: Cap, + join: Join, + corners: [Corner; 4], + width: BoxWidth, + top: BoxTop, + bottom: BoxBottom, + display_if_splits: bool, + gap_color: String, + gap_tint: f32, +} + +fn finite(v: f64) -> f64 { + if v.is_finite() { v } else { 0.0 } +} + +impl ParaBox { + pub(crate) fn of(doc: &Document, pp: &ParaProps) -> Option { + if !pp.shading_on && !pp.border_on { + return None; + } + let shading = pp.shading_on.then(|| Shading { + color: pp.shading_color.clone(), + tint: pp.shading_tint, + offsets: pp.shading_offsets.map(finite), + corners: pp.shading_corners, + width: pp.shading_width, + top: pp.shading_top, + bottom: pp.shading_bottom, + clip: pp.shading_clip, + nonprinting: pp.shading_nonprinting, + }); + let border = pp.border_on.then(|| Border { + color: pp.border_color.clone(), + tint: pp.border_tint, + weights: pp.border_weights.map(|w| finite(w).clamp(0.0, 1000.0)), + offsets: pp.border_offsets.map(finite), + kind: doc.stroke_kind(&pp.border_type), + cap: pp.border_cap, + join: pp.border_join, + corners: pp.border_corners, + width: pp.border_width, + top: pp.border_top, + bottom: pp.border_bottom, + display_if_splits: pp.border_display_if_splits, + gap_color: pp.border_gap_color.clone(), + gap_tint: pp.border_gap_tint, + }); + Some(ParaBox { shading, border, merge: pp.border_merge }) + } +} + +/// Lines of one box in one frame: a part ends where the next line moves to another column or +/// sub-column. +struct Part<'a> { + fi: usize, + lines: Vec<&'a Line>, +} + +/// Draw the boxes of `boxes` (one entry per paragraph) into `out`. +pub(crate) fn draw(out: &mut ComposedStory, boxes: &[Option], frames: &[FrameSpec]) { + let mut pi = 0; + while pi < boxes.len() { + let Some(b) = boxes.get(pi).and_then(Option::as_ref) else { + pi += 1; + continue; + }; + let mut end = pi; + while b.merge && boxes.get(end + 1).and_then(Option::as_ref).is_some_and(|n| n == b) { + end += 1; + } + let decos = group_decos(out, pi..=end, b, frames); + for (fi, under, d) in decos { + if let Some(ft) = out.frames.get_mut(fi) { + if under { ft.decos.insert(0, d) } else { ft.decos.push(d) } + } + } + pi = end + 1; + } +} + +/// The decorations of paragraphs `paras` sharing box `b`: (frame, drawn under the rest, deco). +fn group_decos(out: &ComposedStory, paras: std::ops::RangeInclusive, b: &ParaBox, frames: &[FrameSpec]) -> Vec<(usize, bool, Deco)> { + let mut parts: Vec> = Vec::new(); + for (fi, ft) in out.frames.iter().enumerate() { + let lines: Vec<&Line> = ft.lines.iter().filter(|l| paras.contains(&l.para)).collect(); + for part in lines.chunk_by(|a, b| a.column == b.column && b.baseline > a.baseline) { + parts.push(Part { fi, lines: part.to_vec() }); + } + } + let n = parts.len(); + let mut decos = Vec::new(); + for (k, part) in parts.iter().enumerate() { + let (first_part, last_part) = (k == 0, k + 1 == n); + if let Some(s) = &b.shading + && let Some(r) = inner_rect(&part.lines, s.width, s.top, s.bottom) + { + let [t, l, bo, rr] = s.offsets; + let mut rect = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo); + if s.clip + && let Some(f) = frames.get(part.fi) + { + rect = rect.intersect(frame_rect(f)); + } + if rect.width() > 0.0 && rect.height() > 0.0 { + // Corners stay square where the box is cut by a split. + let corners = cut_corners(s.corners, !first_part, !last_part); + let path = (!corners.iter().all(Corner::is_none)).then(|| box_outline(rect, &corners, [true; 4])); + decos.push((part.fi, true, Deco { rect, color: s.color.clone(), tint: s.tint, path, nonprinting: s.nonprinting })); + } + } + if let Some(bd) = &b.border + && let Some(r) = inner_rect(&part.lines, bd.width, bd.top, bd.bottom) + { + let [t, l, bo, rr] = bd.offsets; + let o = Rect::new(r.x0 - l, r.y0 - t, r.x1 + rr, r.y1 + bo); + let open_top = !first_part && !bd.display_if_splits; + let open_bottom = !last_part && !bd.display_if_splits; + for (path, gap) in border_paths(bd, o, open_top, open_bottom) { + let (color, tint) = if gap { (bd.gap_color.clone(), bd.gap_tint) } else { (bd.color.clone(), bd.tint) }; + decos.push((part.fi, false, Deco { rect: path.bounding_box(), color, tint, path: Some(path), nonprinting: false })); + } + if is_plain(bd, open_top, open_bottom) { + decos.extend( + plain_edges(bd, o, open_top, open_bottom) + .into_iter() + .map(|rect| (part.fi, false, Deco { rect, color: bd.color.clone(), tint: bd.tint, path: None, nonprinting: false })), + ); + } + } + } + decos +} + +/// The frame's edges in its inner space (the text area out to the insets). +fn frame_rect(f: &FrameSpec) -> Rect { + let a = f.area; + if f.vertical { + return a; + } + let [t, l, b, r] = f.opts.inset.map(finite); + Rect::new(a.x0 - l, a.y0 - t, a.x1 + r, a.y1 + b) +} + +/// `corners` with the top pair square when the box is cut at the top, the bottom pair when cut at +/// the bottom (top left, top right, bottom right, bottom left). +fn cut_corners(mut corners: [Corner; 4], top: bool, bottom: bool) -> [Corner; 4] { + for (i, c) in corners.iter_mut().enumerate() { + if (top && i < 2) || (bottom && i >= 2) { + *c = Corner::default(); + } + } + corners +} + +/// The em box of a line's tallest glyph: how far its top is above the baseline and its bottom below. +fn em_box(l: &Line) -> Option<(f64, f64)> { + l.glyphs.iter().filter(|g| g.visible && g.sy.is_finite() && g.sy > 0.0).fold(None, |acc: Option<(f64, f64)>, g| { + let (top, bottom) = g.face.em_box(); + let (t, b) = (top * g.sy, -bottom * g.sy); + Some(acc.map_or((t, b), |(at, ab)| (at.max(t), ab.max(b)))) + }) +} + +/// The box around `lines` before offsets: the column or the text across, from the top edge of the +/// first line to the bottom edge of the last. +fn inner_rect(lines: &[&Line], width: BoxWidth, top: BoxTop, bottom: BoxBottom) -> Option { + let (a, z) = (lines.first()?, lines.last()?); + let y0 = match top { + BoxTop::Ascent => a.baseline - a.ascent, + BoxTop::Baseline => a.baseline, + BoxTop::EmBox => a.baseline - em_box(a).map_or(a.ascent, |e| e.0), + BoxTop::Leading => a.baseline - a.leading.max(a.ascent), + }; + let y1 = match bottom { + BoxBottom::Descent => z.baseline + z.descent, + BoxBottom::Baseline => z.baseline, + BoxBottom::EmBox => z.baseline + em_box(z).map_or(z.descent, |e| e.1), + }; + let column = || (lines.iter().map(|l| l.x0).fold(f64::INFINITY, f64::min), lines.iter().map(|l| l.x1).fold(f64::NEG_INFINITY, f64::max)); + let (x0, x1) = match width { + BoxWidth::Column => column(), + BoxWidth::Text => { + let start = lines.iter().flat_map(|l| l.glyphs.iter().filter(|g| g.visible).map(|g| g.x.min(g.x + g.adv))).fold(f64::INFINITY, f64::min); + let end = lines.iter().filter(|l| l.glyphs.iter().any(|g| g.visible)).map(|l| l.end_x).fold(f64::NEG_INFINITY, f64::max); + if start.is_finite() && end.is_finite() && end > start { (start, end) } else { column() } + } + }; + (x0.is_finite() && x1.is_finite() && y0.is_finite() && y1.is_finite()).then(|| Rect::new(x0, y0, x1, y1)) +} + +/// The edges drawn: top, right, bottom, left (a cut end, or a side of no weight, isn't). +fn edges(bd: &Border, open_top: bool, open_bottom: bool) -> [bool; 4] { + let [wt, wl, wb, wr] = bd.weights; + [wt > 0.0 && !open_top, wr > 0.0, wb > 0.0 && !open_bottom, wl > 0.0] +} + +/// The common weight of the drawn edges, when they all have the same one. +fn equal_weight(bd: &Border, drawn: [bool; 4]) -> Option { + let [wt, wl, wb, wr] = bd.weights; + let w: Vec = [wt, wr, wb, wl].into_iter().zip(drawn).filter(|(_, d)| *d).map(|(w, _)| w).collect(); + let first = *w.first()?; + w.iter().all(|x| (x - first).abs() < 1e-9).then_some(first) +} + +/// A solid border with no corner shapes (or different weights per side) is filled rectangles, one +/// per edge. +fn is_plain(bd: &Border, open_top: bool, open_bottom: bool) -> bool { + let drawn = edges(bd, open_top, open_bottom); + let corners = cut_corners(bd.corners, open_top, open_bottom); + bd.kind == StrokeType::Solid && (corners.iter().all(Corner::is_none) || equal_weight(bd, drawn).is_none()) +} + +/// The edge rectangles of a plain border, outside the offset box `o`. +fn plain_edges(bd: &Border, o: Rect, open_top: bool, open_bottom: bool) -> Vec { + let [wt, wl, wb, wr] = bd.weights; + let [top, right, bottom, left] = edges(bd, open_top, open_bottom); + let mut v = Vec::new(); + if top { + v.push(Rect::new(o.x0 - wl, o.y0 - wt, o.x1 + wr, o.y0)); + } + if bottom { + v.push(Rect::new(o.x0 - wl, o.y1, o.x1 + wr, o.y1 + wb)); + } + if left { + v.push(Rect::new(o.x0 - wl, o.y0, o.x0, o.y1)); + } + if right { + v.push(Rect::new(o.x1, o.y0, o.x1 + wr, o.y1)); + } + v +} + +/// The stroked outlines of a border that isn't plain, each with whether it is the gap colour +/// (drawn first, under the dashes). The stroke lies outside the offset box `o`. +fn border_paths(bd: &Border, o: Rect, open_top: bool, open_bottom: bool) -> Vec<(BezPath, bool)> { + if is_plain(bd, open_top, open_bottom) { + return Vec::new(); + } + let drawn = edges(bd, open_top, open_bottom); + let mut out = Vec::new(); + let mut add = |path: &BezPath, w: f64, perimeter: f64| { + if bd.kind != StrokeType::Solid { + let gap = stroke_outline(path, w, &StrokeType::Solid, Cap::Butt, bd.join, perimeter); + out.push((gap, true)); + } + out.push((stroke_outline(path, w, &bd.kind, bd.cap, bd.join, perimeter), false)); + }; + match equal_weight(bd, drawn) { + Some(w) => { + // One outline along the stroke's centre, corners shaped where two edges meet. + let c = Rect::new(o.x0 - w / 2.0, o.y0 - w / 2.0, o.x1 + w / 2.0, o.y1 + w / 2.0); + let corners = cut_corners(bd.corners, open_top, open_bottom); + add(&box_outline(c, &corners, drawn), w, 2.0 * (c.width() + c.height())); + } + None => { + // Different weights: each edge on its own. + let [wt, wl, wb, wr] = bd.weights; + let lines = [ + (drawn[0], wt, (o.x0 - wl, o.y0 - wt / 2.0), (o.x1 + wr, o.y0 - wt / 2.0)), + (drawn[1], wr, (o.x1 + wr / 2.0, o.y0), (o.x1 + wr / 2.0, o.y1)), + (drawn[2], wb, (o.x0 - wl, o.y1 + wb / 2.0), (o.x1 + wr, o.y1 + wb / 2.0)), + (drawn[3], wl, (o.x0 - wl / 2.0, o.y0), (o.x0 - wl / 2.0, o.y1)), + ]; + for (on, w, a, b) in lines { + if on { + let mut p = BezPath::new(); + p.move_to(a); + p.line_to(b); + add(&p, w, kurbo::Point::from(a).distance(b.into())); + } + } + } + } + out +} + +/// The filled outline of stroking `path` `w` wide in stroke type `kind`. +fn stroke_outline(path: &BezPath, w: f64, kind: &StrokeType, cap: Cap, join: Join, length: f64) -> BezPath { + if !(w.is_finite() && w > 0.0) { + return BezPath::new(); + } + if let Some(o) = kind.outline(path, w, TOL) { + return o; + } + let mut st = kurbo::Stroke::new(w) + .with_caps(match cap { + Cap::Butt => kurbo::Cap::Butt, + Cap::Round => kurbo::Cap::Round, + Cap::Projecting => kurbo::Cap::Square, + }) + .with_join(match join { + Join::Miter => kurbo::Join::Miter, + Join::Round => kurbo::Join::Round, + Join::Bevel => kurbo::Join::Bevel, + }); + let dashes: Option> = match kind { + StrokeType::Dashed { pattern } => { + let p: Vec = pattern.iter().copied().filter(|v| v.is_finite() && *v >= 0.0).collect(); + Some(if p.iter().sum::() > 1e-3 { p } else { vec![3.0 * w, 2.0 * w] }) + } + StrokeType::Dotted => { + st = st.with_caps(kurbo::Cap::Round); + Some(vec![0.0, 2.0 * w]) + } + _ => None, + }; + if let Some(d) = dashes { + let period: f64 = d.iter().sum(); + if period > 1e-3 && length / period < MAX_DASHES { + st = st.with_dashes(0.0, d); + } + } + kurbo::stroke(path.iter(), &st, &kurbo::StrokeOpts::default(), TOL) +} diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0d1fe2d27..fdb76eaa6 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2771,3 +2771,191 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +// ---------- paragraph border and shading ---------- + +fn bordered(text: &str, rect: Rect, para: ParaAttrs) -> ComposedStory { + let para = ParaAttrs { border_on: Some(true), border_weights: Some([1.0; 4]), ..para }; + let (d, sid, _) = doc_with(text, rect, para); + compose_story(&d, sid, &ComposeOptions::default()) +} + +fn has_curves(bp: &designcraft_geom::BezPath) -> bool { + bp.elements().iter().any(|e| matches!(e, designcraft_geom::PathEl::CurveTo(..) | designcraft_geom::PathEl::QuadTo(..))) +} + +#[test] +fn rounded_border_corners_are_curves_and_dotted_borders_paint_their_gap_first() { + use designcraft_geom::corners::{Corner, CornerShape}; + let rounded = [Corner { shape: CornerShape::Rounded, size: 6.0 }; 4]; + let cs = bordered("Boxed text", Rect::new(0.0, 0.0, 300.0, 200.0), ParaAttrs { border_corners: Some(rounded), ..Default::default() }); + let paths: Vec<_> = cs.frames[0].decos.iter().filter_map(|dc| dc.path.as_ref()).collect(); + assert_eq!(paths.len(), 1, "one stroked outline"); + assert!(has_curves(paths[0])); + // Square corners: plain edges, no curves. + let cs = bordered("Boxed text", Rect::new(0.0, 0.0, 300.0, 200.0), ParaAttrs::default()); + assert!(cs.frames[0].decos.iter().all(|dc| dc.path.is_none())); + // A dotted border with a gap colour: the gap under the dots. + let dotted = ParaAttrs { border_type: Some(designcraft_doc::StrokeType::Dotted), border_gap_color: Some("[Paper]".into()), ..Default::default() }; + let cs = bordered("Boxed text", Rect::new(0.0, 0.0, 300.0, 200.0), dotted); + let colors: Vec<&str> = cs.frames[0].decos.iter().filter(|dc| dc.path.is_some()).map(|dc| dc.color.as_str()).collect(); + assert_eq!(colors, ["[Paper]", "[Black]"]); +} + +/// The width of the widest border edge (the top and bottom edges span the box). +fn border_width(cs: &ComposedStory) -> f64 { + cs.frames[0].decos.iter().filter(|dc| dc.color == "[Black]").map(|dc| dc.rect.width()).fold(0.0, f64::max) +} + +#[test] +fn a_text_width_border_is_narrower_than_a_column_width_one() { + let rect = Rect::new(0.0, 0.0, 300.0, 200.0); + let column = border_width(&bordered("Short", rect, ParaAttrs::default())); + let text = border_width(&bordered("Short", rect, ParaAttrs { border_width: Some(designcraft_doc::BoxWidth::Text), ..Default::default() })); + assert!((column - 302.0).abs() < 1e-6, "column and both side strokes: {column}"); + assert!(text > 2.0 && text < 60.0, "text: {text}"); +} + +/// Edges of a plain border by frame column: (column, count). +fn edges_per_column(cs: &ComposedStory) -> Vec { + let cols = &cs.frames[0].columns; + cols.iter() + .map(|c| { + cs.frames[0].decos.iter().filter(|dc| dc.color == "[Black]" && dc.rect.center().x > c.x0 - 2.0 && dc.rect.center().x < c.x1 + 2.0).count() + }) + .collect() +} + +#[test] +fn a_split_paragraph_closes_its_border_in_each_column_only_when_set_to() { + let text = format!("{LOREM} {LOREM}"); + let compose = |display: bool| { + let para = ParaAttrs { border_on: Some(true), border_weights: Some([1.0; 4]), border_display_if_splits: Some(display), ..Default::default() }; + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 400.0, 90.0), para); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + compose_story(&d, sid, &ComposeOptions::default()) + }; + let open = compose(false); + assert!(open.frames[0].lines.iter().any(|l| l.column == 1), "the paragraph reaches the second column"); + // Open at the split: top and sides in the first column, sides and bottom in the second. + assert_eq!(edges_per_column(&open), [3, 3]); + assert_eq!(edges_per_column(&compose(true)), [4, 4]); +} + +#[test] +fn consecutive_paragraphs_with_the_same_border_share_one_box() { + let compose = |merge: bool| { + let para = ParaAttrs { space_after: Some(10.0), border_merge: Some(merge), ..Default::default() }; + bordered("One\nTwo\nThree", Rect::new(0.0, 0.0, 300.0, 300.0), para) + }; + let merged = compose(true); + let edges: Vec = merged.frames[0].decos.iter().filter(|dc| dc.color == "[Black]").map(|dc| dc.rect).collect(); + assert_eq!(edges.len(), 4, "one box: {edges:?}"); + let lines = &merged.frames[0].lines; + let (first, last) = (&lines[0], &lines[lines.len() - 1]); + let left = edges.iter().find(|r| r.height() > r.width()).unwrap(); + assert!((left.y0 - (first.baseline - first.ascent)).abs() < 1e-6 && (left.y1 - (last.baseline + last.descent)).abs() < 1e-6, "{left:?}"); + assert_eq!(compose(false).frames[0].decos.iter().filter(|dc| dc.color == "[Black]").count(), 12, "a box each"); +} + +#[test] +fn shading_clips_to_the_frame_and_can_stay_off_print() { + let para = ParaAttrs { + shading_on: Some(true), + shading_offsets: Some([50.0; 4]), + shading_clip: Some(true), + shading_nonprinting: Some(true), + ..Default::default() + }; + let (d, sid, _) = doc_with("Shaded", Rect::new(0.0, 0.0, 300.0, 200.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let area = cs.frames[0].columns[0]; + let shade = &cs.frames[0].decos[0]; + assert!(shade.nonprinting); + assert!(shade.rect.x0 >= area.x0 - 1e-6 && shade.rect.y0 >= area.y0 - 1e-6 && shade.rect.x1 <= area.x1 + 1e-6, "{:?} in {area:?}", shade.rect); +} + +// ---------- span space, bullets and numbering ---------- + +#[test] +fn space_before_and_after_a_span_moves_the_span_and_the_text_below() { + let at = |before: f64, after: f64| { + let (mut d, sid, _, h) = span_doc(1, 1, Rect::new(0.0, 0.0, 600.0, 800.0)); + let p = &mut d.story_mut(sid).unwrap().paras[h].para; + p.span_space_before = Some(before); + p.span_space_after = Some(after); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let first = |pi: usize| cs.frames[0].lines.iter().find(|l| l.para == pi).map(|l| l.baseline).unwrap(); + (first(h), first(h + 1)) + }; + let (span0, below0) = at(0.0, 0.0); + let (span1, below1) = at(24.0, 0.0); + assert!((span1 - span0 - 24.0).abs() < 0.01, "{span0} → {span1}"); + assert!((below1 - below0 - 24.0).abs() < 0.01, "the text below moves with it"); + let (span2, below2) = at(0.0, 18.0); + assert!((span2 - span0).abs() < 0.01 && (below2 - below0 - 18.0).abs() < 0.01, "{below0} → {below2}"); +} + +/// The first line of each paragraph. +fn first_lines(cs: &ComposedStory) -> Vec<&Line> { + all_lines(cs).into_iter().filter(|l| l.first_in_para).collect() +} + +#[test] +fn a_right_aligned_number_ends_at_the_line_start() { + use designcraft_doc::{ListAlign, ListType}; + let compose = |align: ListAlign| { + let para = ParaAttrs { + list_type: Some(ListType::Numbers), + number_align: Some(align), + left_indent: Some(36.0), + first_line_indent: Some(-18.0), + ..Default::default() + }; + let (d, sid, _) = doc_with("Item", Rect::new(0.0, 0.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()) + }; + // The label "1.\t": its first two glyphs are the number and the full stop. + let cs = compose(ListAlign::Left); + let col = cs.frames[0].columns[0]; + let l = first_lines(&cs)[0]; + assert!((l.glyphs[0].x - (col.x0 + 18.0)).abs() < 0.01, "left: starts at the line start"); + let text_x = l.glyphs.iter().find(|g| g.len > 0).unwrap().x; + let cs = compose(ListAlign::Right); + let l = first_lines(&cs)[0]; + let end = l.glyphs[1].x + l.glyphs[1].adv; + assert!((end - (col.x0 + 18.0)).abs() < 0.01, "right: ends at the line start ({end})"); + assert!((l.glyphs.iter().find(|g| g.len > 0).unwrap().x - text_x).abs() < 0.01, "the text stays at the tab stop"); +} + +#[test] +fn level_two_numbering_restarts_after_level_one() { + let para = numbered(designcraft_doc::NumberStyle::Arabic, "^1.^#"); + let (mut d, sid, _) = doc_with("A\nB\nC\nD\nE", Rect::new(0.0, 0.0, 300.0, 300.0), para); + for (p, level) in d.story_mut(sid).unwrap().paras.iter_mut().zip([1, 2, 2, 1, 2]) { + p.para.list_level = Some(level); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + // `^1` is level 1's number, `^#` the paragraph's own level. + assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.1", "1.1", "1.2", "2.2", "2.1"]); + // Without the restart, level 2 carries on. + for p in &mut d.story_mut(sid).unwrap().paras { + p.para.restart_numbers = Some(false); + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.1", "1.1", "1.2", "2.2", "2.3"]); +} + +#[test] +fn the_tab_position_places_the_text_after_the_label() { + use designcraft_doc::ListType; + let compose = |tab: Option| { + let para = ParaAttrs { list_type: Some(ListType::Bullets), list_tab: Some(tab), ..Default::default() }; + let (d, sid, _) = doc_with("Item", Rect::new(0.0, 0.0, 300.0, 200.0), para); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let col = cs.frames[0].columns[0]; + first_lines(&cs)[0].glyphs.iter().find(|g| g.len > 0).unwrap().x - col.x0 + }; + assert!((compose(Some(50.0)) - 50.0).abs() < 0.01); + assert!((compose(None) - 36.0).abs() < 0.01, "the default tab stop"); +} diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index b99740e2c..36fbd8fb2 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -551,6 +551,52 @@ impl Default for Rule { } } +/// How wide a paragraph border or shading box is: the column, or the text's longest line. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoxWidth { + #[default] + Column, + Text, +} + +/// Where a paragraph border or shading box starts above the first line (before its top offset). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoxTop { + /// The tallest ascent on the line. + #[default] + Ascent, + Baseline, + /// The top of the em box. + EmBox, + /// The top of the line's leading. + Leading, +} + +/// Where a paragraph border or shading box ends below the last line (before its bottom offset). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BoxBottom { + /// The deepest descent on the line. + #[default] + Descent, + Baseline, + /// The bottom of the em box. + EmBox, +} + +/// Where a bullet or number sits against the first line's start (the left and first-line +/// indents): it starts there, is centred on it, or ends there. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ListAlign { + #[default] + Left, + Center, + Right, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ListType { @@ -615,6 +661,12 @@ impl NumberStyle { /// putting `number` in for `^#`. Level placeholders (`^1`…`^9`) and metacharacters with no /// meaning in a label are dropped; a label never holds a paragraph break. pub fn expand_list_text(s: &str, number: &str) -> String { + expand_list_levels(s, number, &[]) +} + +/// [`expand_list_text`] with `levels[k]` in for `^1`…`^9` (level k + 1's current number; a level +/// past the end is dropped). +pub fn expand_list_levels(s: &str, number: &str, levels: &[String]) -> String { let mut out = String::with_capacity(s.len() + number.len()); let mut it = s.chars(); while let Some(c) = it.next() { @@ -633,6 +685,13 @@ pub fn expand_list_text(s: &str, number: &str) -> String { out.push_str(number); continue; } + d @ '1'..='9' => { + let k = d as usize - '1' as usize; + if let Some(l) = levels.get(k) { + out.push_str(l); + } + continue; + } '^' => '^', 't' => '\t', 'y' => crate::story::RIGHT_INDENT_TAB, @@ -902,6 +961,53 @@ attr_set! { border_color: String = "[Black]".into(), border_tint: f32 = 1.0, border_offsets: [f64; 4] = [0.0; 4], + /// The border's stroke type (a document stroke style by name, or a built-in type), cap and + /// join. + border_type: crate::item::StrokeType = crate::item::StrokeType::Solid, + border_cap: crate::item::Cap = crate::item::Cap::Butt, + border_join: crate::item::Join = crate::item::Join::Miter, + /// Corner shape and size: top left, top right, bottom right, bottom left. + border_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], + border_width: BoxWidth = BoxWidth::Column, + border_top: BoxTop = BoxTop::Ascent, + border_bottom: BoxBottom = BoxBottom::Descent, + /// A paragraph split across frames or columns gets a closed box in each part (otherwise the + /// box is open where it splits). + border_display_if_splits: bool = false, + /// Consecutive paragraphs with the same border and shading share one box. + border_merge: bool = true, + /// The colour between the dashes, dots or stripes of a border that isn't solid. + border_gap_color: String = "[None]".into(), + border_gap_tint: f32 = 1.0, + /// Shading corners: top left, top right, bottom right, bottom left. + shading_corners: [designcraft_geom::corners::Corner; 4] = [designcraft_geom::corners::Corner::default(); 4], + shading_width: BoxWidth = BoxWidth::Column, + shading_top: BoxTop = BoxTop::Ascent, + shading_bottom: BoxBottom = BoxBottom::Descent, + /// The shading stops at the edges of the text frame. + shading_clip: bool = false, + /// The shading shows on screen only: not printed or exported. + shading_nonprinting: bool = false, + /// Least space between a spanning or split paragraph and the column text above and below it. + span_space_before: f64 = 0.0, + span_space_after: f64 = 0.0, + /// List level (1–9): each level numbers on its own, and `^1`…`^9` in a number's text are the + /// current numbers of those levels. + list_level: u32 = 1, + /// Character styles of the bullet and of the number. + bullet_char_style: String = crate::story::NO_CHAR_STYLE.into(), + number_char_style: String = crate::story::NO_CHAR_STYLE.into(), + bullet_align: ListAlign = ListAlign::Left, + number_align: ListAlign = ListAlign::Left, + /// A tab stop for the text after the bullet or number, from the column's left edge. + list_tab: Option = None, + /// Restart Numbers at This Level After: a paragraph at a higher level (0 = any; otherwise + /// that level) starts this level's numbering again. + restart_numbers: bool = true, + restart_after_level: u32 = 0, + /// The bullet's font family and style ("" = the paragraph's). + bullet_font: String = String::new(), + bullet_font_style: String = String::new(), } } @@ -934,6 +1040,13 @@ impl ParaProps { expand_list_text(&self.number_expression, &self.number_style.format(n)) } + /// The label of a numbered paragraph that `list` has just numbered ([`ListCounter::advance`]). + pub fn number_label_in(&self, list: &ListCounter) -> String { + let level = list_level_index(self.list_level); + let number = list.labels.get(level).map_or("", String::as_str); + expand_list_levels(&self.number_expression, number, &list.labels) + } + /// The label of a bulleted paragraph. pub fn bullet_label(&self) -> String { let mut s: String = self.bullet_char.chars().filter(|c| *c != '\n' && *c != '\r').collect(); @@ -942,10 +1055,70 @@ impl ParaProps { } } +/// Levels a list can have. +pub const LIST_LEVELS: usize = 9; + +/// Index (0-based) of list level `level` (1-based, clamped to 1–[`LIST_LEVELS`]). +fn list_level_index(level: u32) -> usize { + (level.clamp(1, LIST_LEVELS as u32) - 1) as usize +} + +/// The numbering of one list as it goes: each level's last number and its text, and when each +/// level last numbered a paragraph (for Restart Numbers at This Level After). +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct ListCounter { + numbers: [u32; LIST_LEVELS], + labels: [String; LIST_LEVELS], + seen: [u64; LIST_LEVELS], + tick: u64, +} + +impl ListCounter { + /// Number the next paragraph of `pp` (a numbered paragraph with text) and return its number. + pub fn advance(&mut self, pp: &ParaProps) -> u32 { + let l = list_level_index(pp.list_level); + self.tick = self.tick.saturating_add(1); + let since = self.seen[l]; + let restart = pp.restart_numbers + && match pp.restart_after_level { + 0 => self.seen[..l].iter().any(|&t| t > since), + m => (m as usize) <= l && self.seen.get(m as usize - 1).is_some_and(|&t| t > since), + }; + let n = match pp.start_at { + Some(s) => s.max(1), + None if restart => 1, + None => self.numbers[l].saturating_add(1), + }; + self.numbers[l] = n; + self.labels[l] = pp.number_style.format(n); + self.seen[l] = self.tick; + n + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn levels_number_on_their_own_and_restart_after_a_higher_level() { + let at = + |level: u32| ParaProps { list_type: ListType::Numbers, list_level: level, number_expression: "^1.^#".into(), ..ParaProps::default() }; + let mut c = ListCounter::default(); + let mut labels = vec![]; + for level in [1, 2, 2, 1, 2] { + let pp = at(level); + c.advance(&pp); + labels.push(pp.number_label_in(&c)); + } + assert_eq!(labels, ["1.1", "1.1", "1.2", "2.2", "2.1"]); + // Without the restart, level 2 carries on. + let mut c = ListCounter::default(); + let keep = |level: u32| ParaProps { restart_numbers: false, ..at(level) }; + let n: Vec = [1, 2, 1, 2].into_iter().map(|l| c.advance(&keep(l))).collect(); + assert_eq!(n, [1, 1, 2, 2]); + } + #[test] fn a_partial_rule_keeps_the_other_fields() { let current = ParaProps { diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index b681542cb..8df6a9ac2 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -219,26 +219,26 @@ impl Story { } impl crate::Document { - /// How many numbers of list `name` stories before `sid` used (page order), so `sid`'s numbering - /// carries on — 0 for lists that don't continue across stories. - pub fn list_start(&self, sid: StoryId, name: &str) -> u32 { + /// The numbering of list `name` after the stories before `sid` (page order), so `sid`'s + /// numbering carries on; a fresh count for lists that don't continue across stories. + pub fn list_start(&self, sid: StoryId, name: &str) -> crate::ListCounter { + let mut n = crate::ListCounter::default(); if !self.settings.lists.iter().any(|l| l.name == name && l.continue_across_stories) { - return 0; + return n; } let key = |st: &Story| -> (usize, u64) { let page = st.frames.first().and_then(|f| self.page_of_item(*f)).unwrap_or(usize::MAX); (page, st.id.0) }; - let Some(me) = self.story(sid) else { return 0 }; + let Some(me) = self.story(sid) else { return n }; let mine = key(me); let mut before: Vec<&Story> = self.stories.values().map(|s| s.as_ref()).filter(|st| st.id != sid && key(st) < mine).collect(); before.sort_by_key(|st| key(st)); - let mut n = 0u32; for st in before { for (p, r) in st.paras.iter().zip(st.para_ranges()) { let (pp, _) = self.styles.resolve_para(p); if pp.list_type == crate::ListType::Numbers && pp.list_name == name && !r.is_empty() { - n = pp.start_at.map_or(n.saturating_add(1), |s| s.max(1)); + n.advance(&pp); } } } diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index df1fd0fa9..e5d39a3d3 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -124,6 +124,64 @@ fn apply_subpath(sp: &SubPath, opts: &CornerOptions, out: &mut BezPath) { out.close_path(); } +/// The outline of rectangle `r` with the edges in `edges` (top, right, bottom, left) and the +/// corners in `corners` (top left, top right, bottom right, bottom left). A corner gets its shape +/// only where both its edges are drawn; with an edge missing the outline is open there. +pub fn box_outline(r: kurbo::Rect, corners: &[Corner; 4], edges: [bool; 4]) -> BezPath { + let r = r.abs(); + let v = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + let mut out = BezPath::new(); + // Vertex i joins edge i - 1 (in) and edge i (out); edge i runs from v[i] to v[i + 1]. + let corner = |out: &mut BezPath, i: usize, start: bool| { + let c = corners[i]; + let (p, prev, next) = (v[i], v[(i + 3) % 4], v[(i + 1) % 4]); + let (din, dout) = (prev - p, next - p); + let (lin, lout) = (din.hypot(), dout.hypot()); + if c.is_none() || lin < 1e-9 || lout < 1e-9 { + if start { + out.move_to(p) + } else { + out.line_to(p) + } + return; + } + let s = c.size.min(lin / 2.0).min(lout / 2.0); + let (ui, uo) = (din / lin, dout / lout); + let (a, b) = (p + ui * s, p + uo * s); + if start { + out.move_to(a) + } else { + out.line_to(a) + } + corner_shape(out, c.shape, p, a, b, ui, uo, s); + }; + if edges.iter().all(|e| *e) { + for i in 0..4 { + corner(&mut out, i, i == 0); + } + out.close_path(); + return out; + } + // Runs of drawn edges, each starting after a missing one. + let Some(first) = (0..4).find(|&i| edges[i] && !edges[(i + 3) % 4]) else { return out }; + for k in 0..4 { + let i = (first + k) % 4; + if !edges[i] { + continue; + } + if !edges[(i + 3) % 4] { + out.move_to(v[i]); + } + let j = (i + 1) % 4; + if edges[j] { + corner(&mut out, j, false); + } else { + out.line_to(v[j]); + } + } + out +} + #[allow(clippy::too_many_arguments)] fn corner_shape(out: &mut BezPath, shape: CornerShape, p: Point, a: Point, b: Point, ui: Vec2, uo: Vec2, s: f64) { const K: f64 = crate::shapes::KAPPA; @@ -185,6 +243,24 @@ mod tests { assert!((a - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}"); } + #[test] + fn box_outline_rounds_only_closed_corners() { + let r = Rect::new(0.0, 0.0, 100.0, 50.0); + let round = [Corner { shape: CornerShape::Rounded, size: 10.0 }; 4]; + let closed = box_outline(r, &round, [true; 4]); + let curves = |bp: &BezPath| bp.elements().iter().filter(|e| matches!(e, kurbo::PathEl::CurveTo(..))).count(); + assert_eq!(curves(&closed), 4); + assert!((closed.area().abs() - (5000.0 - (400.0 - std::f64::consts::PI * 100.0))).abs() < 1.0); + // Open at the top: the two bottom corners are rounded, the top ends are square. + let open = box_outline(r, &round, [false, true, true, true]); + assert_eq!(curves(&open), 2); + assert!(!open.elements().iter().any(|e| matches!(e, kurbo::PathEl::ClosePath))); + // Open at both ends: two separate sides. + let sides = box_outline(r, &round, [false, true, false, true]); + assert_eq!(sides.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count(), 2); + assert_eq!(curves(&sides), 0); + } + #[test] fn size_is_clamped_to_half_edge() { let r = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..cfa3f712f 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1511,6 +1511,80 @@ impl<'a> Ex<'a> { el.set(&format!("ParagraphBorder{side}Offset"), num(o[i])); } } + if let Some(t) = &a.border_type { + props.push(p("ParagraphBorderType", "object", names::stroke_type_out(t))); + } + if let Some(c) = a.border_cap { + el.set("ParagraphBorderStrokeEndCap", names::cap_out(c)); + } + if let Some(j) = a.border_join { + el.set("ParagraphBorderStrokeEndJoin", names::join_out(j)); + } + const CORNERS: [&str; 4] = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]; + for (prefix, corners) in [("ParagraphBorder", a.border_corners), ("ParagraphShading", a.shading_corners)] { + if let Some(cs) = corners { + for (c, k) in cs.iter().zip(CORNERS) { + el.set(&format!("{prefix}{k}CornerOption"), names::corner_out(c.shape)); + el.set(&format!("{prefix}{k}CornerRadius"), num(c.size)); + } + } + } + if let Some(w) = a.border_width { + el.set("ParagraphBorderWidth", names::box_width_out(w)); + } + if let Some(t) = a.border_top { + el.set("ParagraphBorderTopOrigin", names::box_top_out(t)); + } + if let Some(b) = a.border_bottom { + el.set("ParagraphBorderBottomOrigin", names::box_bottom_out(b)); + } + b!(border_display_if_splits, "ParagraphBorderDisplayIfSplits"); + b!(border_merge, "MergeConsecutiveParaBorders"); + if let Some(c) = &a.border_gap_color { + props.push(p("ParagraphBorderGapColor", "object", self.sw(c))); + } + n!(border_gap_tint, "ParagraphBorderGapTint", pct); + if let Some(w) = a.shading_width { + el.set("ParagraphShadingWidth", names::box_width_out(w)); + } + if let Some(t) = a.shading_top { + el.set("ParagraphShadingTopOrigin", names::box_top_out(t)); + } + if let Some(b) = a.shading_bottom { + el.set("ParagraphShadingBottomOrigin", names::box_bottom_out(b)); + } + b!(shading_clip, "ParagraphShadingClipToFrame"); + b!(shading_nonprinting, "ParagraphShadingSuppressPrinting"); + n!(span_space_before, "SpanColumnMinSpaceBefore"); + n!(span_space_after, "SpanColumnMinSpaceAfter"); + n!(list_level, "NumberingLevel"); + for (k, style) in [("BulletsCharacterStyle", &a.bullet_char_style), ("NumberingCharacterStyle", &a.number_char_style)] { + if let Some(st) = style { + props.push(p(k, "object", names::style_self("CharacterStyle", CHAR_BUILTINS, st))); + } + } + if let Some(v) = a.bullet_align { + el.set("BulletsAlignment", names::list_align_out(v)); + } + if let Some(v) = a.number_align { + el.set("NumberingAlignment", names::list_align_out(v)); + } + b!(restart_numbers, "NumberingApplyRestartPolicy"); + if let Some(level) = a.restart_after_level { + let (policy, level) = if level == 0 { ("AnyPreviousLevel", 0) } else { ("SpecificPreviousLevel", level) }; + props.push( + El::new("NumberingRestartPolicies") + .attr("RestartPolicy", policy) + .attr("LowerLevel", level.to_string()) + .attr("UpperLevel", level.to_string()), + ); + } + if let Some(f) = &a.bullet_font { + props.push(p("BulletsFont", "string", if f.is_empty() { "$ID/".to_string() } else { f.clone() })); + } + if let Some(f) = a.bullet_font_style.as_ref().filter(|f| !f.is_empty()) { + props.push(p("BulletsFontStyle", "string", f.clone())); + } } fn rule(&mut self, el: &mut El, props: &mut Vec, k: &str, r: &Rule) { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d85d9ee02..851a7ce60 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -169,6 +169,23 @@ fn parse_point(s: Option<&str>) -> Option { } /// IDML tint (percent, `-1` = default) → 0..1. +/// The four corners of a paragraph border or shading (`prefix` `ParagraphBorder` / +/// `ParagraphShading`): top left, top right, bottom right, bottom left. Unset when none is given. +fn corners(e: &El, prefix: &str) -> Option<[Corner; 4]> { + let keys = ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]; + let shapes = keys.map(|n| e.prop(&format!("{prefix}{n}CornerOption"))); + let sizes = keys.map(|n| e.num(&format!("{prefix}{n}CornerRadius"))); + if shapes.iter().all(Option::is_none) && sizes.iter().all(Option::is_none) { + return None; + } + let mut out = [Corner::default(); 4]; + for (c, (shape, size)) in out.iter_mut().zip(shapes.iter().zip(sizes)) { + c.shape = shape.as_deref().map_or(designcraft_geom::corners::CornerShape::None, |s| names::corner_in(s.trim())); + c.size = size.filter(|v| v.is_finite()).map_or(12.0, |v| v.max(0.0)); + } + Some(out) +} + fn tint(v: Option) -> Option { v.map(|t| if t < 0.0 { 1.0 } else { (t / 100.0) as f32 }) } @@ -1378,6 +1395,50 @@ impl<'r> Importer<'r> { a.border_tint = tint(e.num("ParagraphBorderTint")); a.border_weights = sides(&|s| format!("ParagraphBorder{s}LineWeight")); a.border_offsets = sides(&|s| format!("ParagraphBorder{s}Offset")); + a.border_type = e.prop("ParagraphBorderType").map(|v| names::stroke_type_in(v.trim())); + a.border_cap = e.prop("ParagraphBorderStrokeEndCap").map(|v| names::cap_in(v.trim())); + a.border_join = e.prop("ParagraphBorderStrokeEndJoin").map(|v| names::join_in(v.trim())); + a.border_corners = corners(e, "ParagraphBorder"); + a.border_width = e.prop("ParagraphBorderWidth").map(|v| names::box_width_in(v.trim())); + a.border_top = e.prop("ParagraphBorderTopOrigin").map(|v| names::box_top_in(v.trim())); + a.border_bottom = e.prop("ParagraphBorderBottomOrigin").map(|v| names::box_bottom_in(v.trim())); + a.border_display_if_splits = e.boolean("ParagraphBorderDisplayIfSplits"); + a.border_merge = e.boolean("MergeConsecutiveParaBorders"); + if let Some(c) = e.prop("ParagraphBorderGapColor") { + a.border_gap_color = Some(self.swatch_ref(c.trim())); + } + a.border_gap_tint = tint(e.num("ParagraphBorderGapTint")); + a.shading_corners = corners(e, "ParagraphShading"); + a.shading_width = e.prop("ParagraphShadingWidth").map(|v| names::box_width_in(v.trim())); + a.shading_top = e.prop("ParagraphShadingTopOrigin").map(|v| names::box_top_in(v.trim())); + a.shading_bottom = e.prop("ParagraphShadingBottomOrigin").map(|v| names::box_bottom_in(v.trim())); + a.shading_clip = e.boolean("ParagraphShadingClipToFrame"); + a.shading_nonprinting = e.boolean("ParagraphShadingSuppressPrinting"); + a.span_space_before = e.num("SpanColumnMinSpaceBefore").filter(|v| v.is_finite()); + a.span_space_after = e.num("SpanColumnMinSpaceAfter").filter(|v| v.is_finite()); + a.list_level = e.num("NumberingLevel").filter(|v| v.is_finite()).map(|v| v.round().clamp(1.0, designcraft_doc::LIST_LEVELS as f64) as u32); + if let Some(c) = e.prop("BulletsCharacterStyle") { + a.bullet_char_style = Some(self.char_style_ref(c.trim())); + } + if let Some(c) = e.prop("NumberingCharacterStyle") { + a.number_char_style = Some(self.char_style_ref(c.trim())); + } + a.bullet_align = e.prop("BulletsAlignment").map(|v| names::list_align_in(v.trim())); + a.number_align = e.prop("NumberingAlignment").map(|v| names::list_align_in(v.trim())); + a.restart_numbers = e.boolean("NumberingApplyRestartPolicy"); + if let Some(r) = e.prop_el("NumberingRestartPolicies") { + // A specific level restarts after that level; any other policy after any higher one. + let level = r.get("LowerLevel").and_then(|v| v.trim().parse::().ok()).unwrap_or(0); + a.restart_after_level = Some(if r.get("RestartPolicy").map(str::trim) == Some("SpecificPreviousLevel") { level } else { 0 }); + } + if let Some(f) = e.prop("BulletsFont") { + let f = f.trim(); + a.bullet_font = Some(if f == "$ID/" { String::new() } else { f.split('\t').next().unwrap_or(f).to_string() }); + } + if let Some(f) = e.prop("BulletsFontStyle") { + let f = f.trim(); + a.bullet_font_style = Some(if f == "Nothing" || f == "$ID/" { String::new() } else { f.trim_start_matches("$ID/").to_string() }); + } a } diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs index 1df0f56d5..1b0cd9f59 100644 --- a/crates/idml/src/names.rs +++ b/crates/idml/src/names.rs @@ -331,6 +331,63 @@ pub fn arrow_in(s: &str) -> Arrowhead { .unwrap_or(Arrowhead::None) } +pub fn box_width_out(w: designcraft_doc::BoxWidth) -> &'static str { + match w { + designcraft_doc::BoxWidth::Column => "ColumnWidth", + designcraft_doc::BoxWidth::Text => "TextWidth", + } +} +pub fn box_width_in(s: &str) -> designcraft_doc::BoxWidth { + if s == "TextWidth" { designcraft_doc::BoxWidth::Text } else { designcraft_doc::BoxWidth::Column } +} + +pub fn box_top_out(t: designcraft_doc::BoxTop) -> &'static str { + match t { + designcraft_doc::BoxTop::Ascent => "AscentTopOrigin", + designcraft_doc::BoxTop::Baseline => "BaselineTopOrigin", + designcraft_doc::BoxTop::EmBox => "EmBoxTopOrigin", + designcraft_doc::BoxTop::Leading => "LeadingTopOrigin", + } +} +pub fn box_top_in(s: &str) -> designcraft_doc::BoxTop { + match s { + "BaselineTopOrigin" => designcraft_doc::BoxTop::Baseline, + "EmBoxTopOrigin" => designcraft_doc::BoxTop::EmBox, + "LeadingTopOrigin" => designcraft_doc::BoxTop::Leading, + _ => designcraft_doc::BoxTop::Ascent, + } +} + +pub fn box_bottom_out(b: designcraft_doc::BoxBottom) -> &'static str { + match b { + designcraft_doc::BoxBottom::Descent => "DescentBottomOrigin", + designcraft_doc::BoxBottom::Baseline => "BaselineBottomOrigin", + designcraft_doc::BoxBottom::EmBox => "EmBoxBottomOrigin", + } +} +pub fn box_bottom_in(s: &str) -> designcraft_doc::BoxBottom { + match s { + "BaselineBottomOrigin" => designcraft_doc::BoxBottom::Baseline, + "EmBoxBottomOrigin" => designcraft_doc::BoxBottom::EmBox, + _ => designcraft_doc::BoxBottom::Descent, + } +} + +pub fn list_align_out(a: designcraft_doc::ListAlign) -> &'static str { + match a { + designcraft_doc::ListAlign::Left => "LeftAlign", + designcraft_doc::ListAlign::Center => "CenterAlign", + designcraft_doc::ListAlign::Right => "RightAlign", + } +} +pub fn list_align_in(s: &str) -> designcraft_doc::ListAlign { + match s { + "CenterAlign" => designcraft_doc::ListAlign::Center, + "RightAlign" => designcraft_doc::ListAlign::Right, + _ => designcraft_doc::ListAlign::Left, + } +} + pub fn corner_out(c: CornerShape) -> &'static str { match c { CornerShape::None => "None", diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7b747587a..3c2b9f580 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1357,6 +1357,67 @@ fn split_columns_import_and_round_trip() { assert_eq!((b.span_columns, b.split_inside_gutter, b.split_outside_gutter), (a.span_columns, a.split_inside_gutter, a.split_outside_gutter)); } +#[test] +fn paragraph_border_shading_span_and_list_options_import_and_round_trip() { + use designcraft_doc::{BoxBottom, BoxTop, BoxWidth, Cap, Join, ListAlign, StrokeType}; + use designcraft_geom::corners::{Corner, CornerShape}; + let story = r#" + + + StrokeStyle/$ID/Dashed + Swatch/None + All + + Courier New + Bold + + Boxed + "#; + let doc = import_idml(&fixture_with_story(story)).unwrap(); + let para = |d: &Document| d.stories.values().find(|s| s.text.contains("Boxed")).unwrap().paras[0].para.clone(); + let a = para(&doc); + let corner = |shape, size| Corner { shape, size }; + assert_eq!(a.border_type, Some(StrokeType::Dashed { pattern: vec![] })); + assert_eq!((a.border_cap, a.border_join), (Some(Cap::Round), Some(Join::Bevel))); + assert_eq!( + a.border_corners, + Some([corner(CornerShape::Rounded, 6.0), corner(CornerShape::Bevel, 4.0), corner(CornerShape::None, 12.0), corner(CornerShape::Inset, 3.0)]) + ); + assert_eq!((a.border_width, a.border_top, a.border_bottom), (Some(BoxWidth::Text), Some(BoxTop::EmBox), Some(BoxBottom::EmBox))); + assert_eq!((a.border_display_if_splits, a.border_merge, a.border_gap_tint), (Some(true), Some(false), Some(0.5))); + assert!(a.border_gap_color.is_some()); + assert_eq!(a.shading_corners.map(|c| c[1]), Some(corner(CornerShape::Rounded, 8.0))); + assert_eq!((a.shading_width, a.shading_top, a.shading_bottom), (Some(BoxWidth::Text), Some(BoxTop::Baseline), Some(BoxBottom::Baseline))); + assert_eq!((a.shading_clip, a.shading_nonprinting), (Some(true), Some(true))); + assert_eq!((a.span_space_before, a.span_space_after), (Some(6.0), Some(9.0))); + assert_eq!((a.list_level, a.number_align, a.bullet_align), (Some(2), Some(ListAlign::Right), Some(ListAlign::Center))); + assert_eq!((a.restart_numbers, a.restart_after_level), (Some(false), Some(1))); + assert_eq!((a.bullet_font.as_deref(), a.bullet_font_style.as_deref()), (Some("Courier New"), Some("Bold"))); + let b = para(&import_idml(&export_idml(&doc)).unwrap()); + let pick = |p: &designcraft_doc::ParaAttrs| { + let border = (&p.border_type, p.border_cap, p.border_join, p.border_corners, p.border_width, p.border_top, p.border_bottom); + let more = (p.border_display_if_splits, p.border_merge, &p.border_gap_color, p.border_gap_tint); + let shading = (p.shading_corners, p.shading_width, p.shading_top, p.shading_bottom, p.shading_clip, p.shading_nonprinting); + let list = (p.span_space_before, p.span_space_after, p.list_level, p.number_align, p.bullet_align, p.restart_numbers); + format!("{:?}", (border, more, shading, list, p.restart_after_level, &p.bullet_font, &p.bullet_font_style)) + }; + assert_eq!(pick(&b), pick(&a), "the attributes survive export and import"); +} + #[test] fn list_numbering_format_expression_and_bullet_import_and_round_trip() { let styles = STYLES.replace( diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index bf59efded..2e602451a 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -121,9 +121,12 @@ impl Exporter<'_> { pub(crate) fn frame_text(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, story: &str) { // Paragraph shading and rules under the text. self.as_artifact(s, |me, s| { - for d in &ft.decos { + for d in ft.decos.iter().filter(|d| !d.nonprinting) { if let Some(c) = me.swatch_color(&d.color, d.tint) { - me.fill_rect(s, d.rect, c); + match &d.path { + Some(p) => me.fill_path(s, p, c), + None => me.fill_rect(s, d.rect, c), + } } } if !ft.tables.is_empty() { @@ -238,7 +241,11 @@ impl Exporter<'_> { } fn fill_rect(&self, s: &mut Surface, r: Rect, c: krilla::color::Color) { - if let Some(p) = to_path(&r.to_path(0.1)) { + self.fill_path(s, &r.to_path(0.1), c); + } + + fn fill_path(&self, s: &mut Surface, bp: &designcraft_geom::BezPath, c: krilla::color::Color) { + if let Some(p) = to_path(bp) { s.set_stroke(None); s.set_fill(Some(solid_fill(c, 1.0))); s.draw_path(&p); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 734ec275e..79e4710be 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -192,13 +192,16 @@ impl Renderer { let vis = xf.inverse().transform_rect_bbox(f.visible); // Decorations under text (shading) and rules. for d in &ft.decos { - if !rect_overlaps(d.rect, vis) { + if !rect_overlaps(d.rect, vis) || d.nonprinting && f.opts.printing_only { continue; } if let Some(c) = doc.resolve_color(&d.color, d.tint) { ctx.set_transform(m); ctx.set_paint(color_of(&c, 1.0)); - ctx.fill_rect(&d.rect); + match &d.path { + Some(p) => ctx.fill_path(p), + None => ctx.fill_rect(&d.rect), + } } } if !ft.tables.is_empty() {