mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 14:47:05 -06:00
Paragraph borders, shading, span space and list levels: model, layout and IDML
Borders gain a stroke type (built-in or a document stroke style), cap, join, gap colour and tint, a shape and size per corner, column or text width, top and bottom edges, closed boxes in each part of a split paragraph, and one box for consecutive paragraphs with the same settings. Shading gains the same corners, edges and width, Clip to Frame, and Do not Print or Export. Boxes are drawn once the lines are in place, as filled paths where they aren't plain rectangles. Span and split paragraphs keep at least their space before and after from the column text. Lists have levels that number on their own and restart after a higher level, ^1 to ^9 in the number text, a character style for the label, a bullet font, left, centre or right alignment at the line start, and a tab position for the text after the label. All attributes load from older files with their defaults and round-trip through IDML, apart from the tab position.
This commit is contained in:
@@ -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());
|
||||
|
||||
+121
-86
@@ -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<designcraft_geom::BezPath>,
|
||||
/// 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<Option<u32>> = {
|
||||
let mut counters: std::collections::HashMap<String, u32> = 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<Option<String>> = {
|
||||
let mut counters: std::collections::HashMap<String, ListCounter> = 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<Option<ParaBox>> = 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<Snapshot> = 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<usize>],
|
||||
) {
|
||||
@@ -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<Glyph>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Shading>,
|
||||
border: Option<Border>,
|
||||
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<ParaBox> {
|
||||
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<ParaBox>], 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<usize>, b: &ParaBox, frames: &[FrameSpec]) -> Vec<(usize, bool, Deco)> {
|
||||
let mut parts: Vec<Part<'_>> = 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<Rect> {
|
||||
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<f64> {
|
||||
let [wt, wl, wb, wr] = bd.weights;
|
||||
let w: Vec<f64> = [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<Rect> {
|
||||
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<Vec<f64>> = match kind {
|
||||
StrokeType::Dashed { pattern } => {
|
||||
let p: Vec<f64> = pattern.iter().copied().filter(|v| v.is_finite() && *v >= 0.0).collect();
|
||||
Some(if p.iter().sum::<f64>() > 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)
|
||||
}
|
||||
@@ -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<usize> {
|
||||
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<Rect> = 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<f64>| {
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -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<f64> = 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<u32> = [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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<El>, k: &str, r: &Rule) {
|
||||
|
||||
@@ -169,6 +169,23 @@ fn parse_point(s: Option<&str>) -> Option<Point> {
|
||||
}
|
||||
|
||||
/// 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<f64>) -> Option<f32> {
|
||||
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::<u32>().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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
|
||||
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]" ParagraphBorderOn="true"
|
||||
ParagraphBorderStrokeEndCap="RoundEndCap" ParagraphBorderStrokeEndJoin="BevelEndJoin"
|
||||
ParagraphBorderTopLeftCornerOption="RoundedCorner" ParagraphBorderTopLeftCornerRadius="6"
|
||||
ParagraphBorderTopRightCornerOption="BevelCorner" ParagraphBorderTopRightCornerRadius="4"
|
||||
ParagraphBorderBottomRightCornerOption="None" ParagraphBorderBottomRightCornerRadius="12"
|
||||
ParagraphBorderBottomLeftCornerOption="InsetCorner" ParagraphBorderBottomLeftCornerRadius="3"
|
||||
ParagraphBorderWidth="TextWidth" ParagraphBorderTopOrigin="EmBoxTopOrigin" ParagraphBorderBottomOrigin="EmBoxBottomOrigin"
|
||||
ParagraphBorderDisplayIfSplits="true" MergeConsecutiveParaBorders="false" ParagraphBorderGapTint="50"
|
||||
ParagraphShadingOn="true" ParagraphShadingTopLeftCornerOption="RoundedCorner" ParagraphShadingTopLeftCornerRadius="8"
|
||||
ParagraphShadingTopRightCornerOption="RoundedCorner" ParagraphShadingTopRightCornerRadius="8"
|
||||
ParagraphShadingWidth="TextWidth" ParagraphShadingTopOrigin="BaselineTopOrigin" ParagraphShadingBottomOrigin="BaselineBottomOrigin"
|
||||
ParagraphShadingClipToFrame="true" ParagraphShadingSuppressPrinting="true"
|
||||
SpanColumnType="SpanColumns" SpanColumnMinSpaceBefore="6" SpanColumnMinSpaceAfter="9"
|
||||
BulletsAndNumberingListType="NumberedList" NumberingLevel="2" NumberingAlignment="RightAlign" BulletsAlignment="CenterAlign"
|
||||
NumberingApplyRestartPolicy="false">
|
||||
<Properties>
|
||||
<ParagraphBorderType type="object">StrokeStyle/$ID/Dashed</ParagraphBorderType>
|
||||
<ParagraphBorderGapColor type="object">Swatch/None</ParagraphBorderGapColor>
|
||||
<SpanSplitColumnCount type="enumeration">All</SpanSplitColumnCount>
|
||||
<NumberingRestartPolicies RestartPolicy="SpecificPreviousLevel" LowerLevel="1" UpperLevel="1"/>
|
||||
<BulletsFont type="string">Courier New</BulletsFont>
|
||||
<BulletsFontStyle type="string">Bold</BulletsFontStyle>
|
||||
</Properties>
|
||||
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Boxed</Content></CharacterStyleRange>
|
||||
</ParagraphStyleRange></Story></idPkg:Story>"#;
|
||||
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(
|
||||
|
||||
+10
-3
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user