mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-02 14:16:56 -06:00
M7: per-paragraph paragraph attributes
Each paragraph has its own alignment, indents, spacing, hyphenation, tab stops and paragraph style (TextObject.paras; para stays paragraph 0's, so old files and readers work). Layout, editing (split/merge), threads, the Paragraph/Tabs/Paragraph Styles panels, ranged text.setStyle/setFormat/ tabs/paraStyle.apply, SVG export/import and the fuzzers follow.
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
//! testing need nothing special; after an edit the engine joins the slices and re-distributes them
|
||||
//! with [`distribute`].
|
||||
|
||||
use vectorcraft_doc::{TextObject, TextRun};
|
||||
use vectorcraft_doc::{ParaStyle, TextObject, TextRun};
|
||||
|
||||
use crate::edit::{normalize, runs_len, slice_runs, style_at};
|
||||
use crate::{FontDb, layout};
|
||||
@@ -16,6 +16,36 @@ pub fn story(frames: &[&TextObject]) -> Vec<TextRun> {
|
||||
runs
|
||||
}
|
||||
|
||||
/// The story's paragraph styles, one per story paragraph. A frame whose slice ends inside a
|
||||
/// paragraph shares that paragraph with the next frame (its first one): the earlier frame's style
|
||||
/// wins unless `later_wins[k]` says frame `k`'s first paragraph should (it was just restyled), or
|
||||
/// the earlier frame's part of the paragraph is empty (its slice ends with a paragraph break).
|
||||
pub fn story_paras(frames: &[&TextObject], later_wins: &[bool]) -> Vec<ParaStyle> {
|
||||
let mut out: Vec<ParaStyle> = vec![];
|
||||
let mut prev_empty_tail = true;
|
||||
for (k, f) in frames.iter().enumerate() {
|
||||
let mut styles = f.paragraph_styles().into_iter();
|
||||
if k > 0
|
||||
&& let Some(first) = styles.next()
|
||||
&& (prev_empty_tail || later_wins.get(k).copied().unwrap_or(false))
|
||||
&& let Some(last) = out.last_mut()
|
||||
{
|
||||
*last = first;
|
||||
}
|
||||
out.extend(styles);
|
||||
let text = f.plain_text();
|
||||
prev_empty_tail = text.is_empty() || text.ends_with('\n') || out.is_empty();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Paragraph styles of the slice `runs` of a story starting at story paragraph `first`.
|
||||
fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec<ParaStyle> {
|
||||
let n = 1 + runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>();
|
||||
let fallback = paras.last().cloned().unwrap_or_default();
|
||||
(first..first + n).map(|i| paras.get(i).cloned().unwrap_or_else(|| fallback.clone())).collect()
|
||||
}
|
||||
|
||||
/// How many bytes of `runs` fit in `frame` (whole lines; a following break space or paragraph
|
||||
/// break stays with the line that ends there).
|
||||
fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
|
||||
@@ -37,21 +67,43 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize {
|
||||
end
|
||||
}
|
||||
|
||||
/// The story `runs` split across `frames` in order (the last frame takes any overflow). Empty
|
||||
/// slices keep one empty run carrying the style at the split, so typing there has a style.
|
||||
pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun]) -> Vec<Vec<TextRun>> {
|
||||
/// A frame's share of a story: its runs and its paragraphs' styles (one per paragraph).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Slice {
|
||||
pub runs: Vec<TextRun>,
|
||||
pub paras: Vec<ParaStyle>,
|
||||
}
|
||||
|
||||
/// The story `runs` (with paragraph styles `paras`, one per story paragraph; empty = each
|
||||
/// frame's own) split across `frames` in order (the last frame takes any overflow). Empty slices
|
||||
/// keep one empty run carrying the style at the split, so typing there has a style. A paragraph
|
||||
/// split between frames has its style in both.
|
||||
pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: &[ParaStyle]) -> Vec<Slice> {
|
||||
let mut rest = runs.to_vec();
|
||||
let mut out = Vec::with_capacity(frames.len());
|
||||
let mut next_para = 0;
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let first_para = next_para;
|
||||
let len = runs_len(&rest);
|
||||
let n = if i + 1 == frames.len() { len } else { fit(db, f, &rest) };
|
||||
let own = [f.para.clone()];
|
||||
let (paras, first_para) = if paras.is_empty() { (&own[..], 0) } else { (paras, first_para) };
|
||||
let n = if i + 1 == frames.len() {
|
||||
len
|
||||
} else {
|
||||
let mut probe = (*f).clone();
|
||||
probe.runs = rest.clone();
|
||||
probe.set_paragraph_styles(slice_paras(paras, first_para, &rest));
|
||||
fit(db, &probe, &rest)
|
||||
};
|
||||
let mut head = slice_runs(&rest, 0, n);
|
||||
if head.is_empty() {
|
||||
head.push(TextRun { text: String::new(), style: style_at(&rest, n) });
|
||||
}
|
||||
let tail = slice_runs(&rest, n, len);
|
||||
rest = if tail.is_empty() { vec![TextRun { text: String::new(), style: style_at(&rest, len) }] } else { tail };
|
||||
out.push(head);
|
||||
let head_paras = slice_paras(paras, first_para, &head);
|
||||
next_para += head_paras.len().saturating_sub(1);
|
||||
out.push(Slice { runs: head, paras: head_paras });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -59,7 +111,7 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun]) -> Vec<
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_doc::{CharStyle, TextKind};
|
||||
use vectorcraft_doc::{CharStyle, Justify, TextKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
fn frame(x: f64) -> TextObject {
|
||||
@@ -75,7 +127,7 @@ mod tests {
|
||||
let (a, b, c) = (frame(0.0), frame(200.0), frame(400.0));
|
||||
let text = "The quick brown fox jumps over the lazy dog. ".repeat(6) + "\nSecond paragraph here.";
|
||||
let story = vec![TextRun { text: text.clone(), style: CharStyle::default() }];
|
||||
let parts = distribute(db, &[&a, &b, &c], &story);
|
||||
let parts: Vec<Vec<TextRun>> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect();
|
||||
let lens: Vec<usize> = parts.iter().map(|p| runs_len(p)).collect();
|
||||
assert!(lens[0] > 0 && lens[1] > 0, "{lens:?}");
|
||||
let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect();
|
||||
@@ -90,12 +142,43 @@ mod tests {
|
||||
assert!(!parts[1][0].text.starts_with(' '));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_styles_flow_with_their_paragraphs() {
|
||||
let db = FontDb::global();
|
||||
let (a, b) = (frame(0.0), frame(200.0));
|
||||
let text = "The quick brown fox jumps over the lazy dog. ".repeat(4) + "\nSecond paragraph.\nThird.";
|
||||
let story = vec![TextRun { text, style: CharStyle::default() }];
|
||||
let style = |j| ParaStyle { justify: j, ..Default::default() };
|
||||
use vectorcraft_doc::Justify::*;
|
||||
let paras = vec![style(Left), style(Center), style(Right)];
|
||||
let parts = distribute(db, &[&a, &b], &story, ¶s);
|
||||
assert!(!parts[0].runs.is_empty() && !parts[1].runs.is_empty());
|
||||
// The first paragraph overflows into the second frame: it is in both, with its style.
|
||||
let pa: Vec<Justify> = parts[0].paras.iter().map(|p| p.justify).collect();
|
||||
let pb: Vec<Justify> = parts[1].paras.iter().map(|p| p.justify).collect();
|
||||
assert_eq!(pa, [Left]);
|
||||
assert_eq!(pb, [Left, Center, Right]);
|
||||
// Joined again, the shared paragraph counts once.
|
||||
let mut fa = a.clone();
|
||||
fa.runs = parts[0].runs.clone();
|
||||
fa.set_paragraph_styles(parts[0].paras.clone());
|
||||
let mut fb = b.clone();
|
||||
fb.runs = parts[1].runs.clone();
|
||||
fb.set_paragraph_styles(parts[1].paras.clone());
|
||||
assert_eq!(story_paras(&[&fa, &fb], &[]), paras);
|
||||
// A frame whose shared first paragraph was restyled wins.
|
||||
fb.edit_paras(Some(0..1), |p| p.justify = JustifyAll);
|
||||
assert_eq!(story_paras(&[&fa, &fb], &[false, true])[0].justify, JustifyAll);
|
||||
assert_eq!(story_paras(&[&fa, &fb], &[false, false])[0].justify, Left);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_story_leaves_later_frames_empty_with_a_style() {
|
||||
let db = FontDb::global();
|
||||
let (a, b) = (frame(0.0), frame(200.0));
|
||||
let st = CharStyle { size: 20.0, ..Default::default() };
|
||||
let parts = distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }]);
|
||||
let parts: Vec<Vec<TextRun>> =
|
||||
distribute(db, &[&a, &b], &[TextRun { text: "Hi".into(), style: st }], &[]).into_iter().map(|s| s.runs).collect();
|
||||
assert_eq!(parts[0][0].text, "Hi");
|
||||
assert_eq!((parts[1][0].text.as_str(), parts[1][0].style.size), ("", 20.0));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user