mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-13 22:02:31 +00:00
Anchored objects wrap their own story as their anchor lines are set
#563 composed the story again with its objects where the previous pass put them, up to four times. In a long story whose objects move between frames from one pass to the next (Learn Skills Daily's 03_HP_LongDoc_Class, 79 pages), that never settled: stale wrap areas pushed headings into the next frame. Now the composer places a custom or above-line object when its anchor line is set and the lines after it wrap it in the same pass, as in InDesign. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fda2ff5835
commit
b642cec8cb
2 files changed
+94
-78
No files matched your search
+89
-42
@@ -514,6 +514,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
let mut out = ComposedStory { story: story.id, rev: story.rev, text_len: story.text.len(), ..Default::default() };
|
||||
let mut styles_tab: Vec<RunStyle> = Vec::new();
|
||||
let mut missing_fonts: HashMap<String, bool> = HashMap::new();
|
||||
// The wrap of the story's own custom and above-line anchored objects, per frame, added as
|
||||
// their anchor lines are set (see `own_wrap`).
|
||||
let mut own_ex: Vec<Vec<Exclusion>> = vec![Vec::new(); frames.len()];
|
||||
let cols: Vec<Vec<Rect>> = frames
|
||||
.iter()
|
||||
.map(|f| {
|
||||
@@ -986,7 +989,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
para_box_decos(&mut out, pi, &pp);
|
||||
break 'paras;
|
||||
}
|
||||
let f = &frames[cur.fi];
|
||||
let merged;
|
||||
let f = match own_ex.get(cur.fi).filter(|e| !e.is_empty()) {
|
||||
Some(extra) => {
|
||||
let mut spec = frames[cur.fi].clone();
|
||||
spec.exclusions.extend(extra.iter().cloned());
|
||||
merged = spec;
|
||||
&merged
|
||||
}
|
||||
None => &frames[cur.fi],
|
||||
};
|
||||
let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];
|
||||
let col = cur.clip(base);
|
||||
// A spanning paragraph isn't held to the balanced bottom of the columns above it.
|
||||
@@ -1232,16 +1244,32 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
continue 'paras;
|
||||
}
|
||||
}
|
||||
let mut wraps_below = false;
|
||||
if !sub_objects.is_empty() {
|
||||
let ft = &mut out.frames[cur.fi];
|
||||
let li = ft.lines.len() - 1;
|
||||
let text_ascent = line_glyphs.iter().filter(|g| g.ch != designcraft_doc::OBJECT_MARK).map(|g| g.ascent).fold(0.0, f64::max);
|
||||
let text_ascent = if text_ascent > 0.0 { text_ascent } else { base_chars.size * 0.75 };
|
||||
let first = ft.objects.len();
|
||||
for g in ft.lines[li].glyphs.iter().filter(|g| g.len > 0) {
|
||||
if let Some(o) = sub_objects.get(&g.byte) {
|
||||
ft.objects.push(PlacedObject { index: o.index, origin: Point::ZERO, size: (o.w, o.h), line: li, x: g.x, text_ascent });
|
||||
}
|
||||
}
|
||||
// Custom and above-line objects anchored here wrap the lines after this one.
|
||||
for o in ft.objects.get(first..).unwrap_or_default() {
|
||||
if let (Some(l), Some(obj)) = (ft.lines.get(li), story.objects.get(o.index))
|
||||
&& let Some(ex) = own_wrap(story, obj, o, l, &ft.columns, f)
|
||||
{
|
||||
for e in &mut own_ex {
|
||||
e.retain(|x| x.after != ex.after);
|
||||
}
|
||||
wraps_below |= ex.rect.x0 < col.x1 && ex.rect.x1 > col.x0 && ex.rect.y1 > baseline && ex.rect.y0 < col.y1;
|
||||
if let Some(e) = own_ex.get_mut(cur.fi) {
|
||||
e.push(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cur.last_baseline = Some(baseline);
|
||||
cur.last_reference = reference;
|
||||
@@ -1263,6 +1291,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
}
|
||||
// An own-story anchored object anchored in this line wraps the lines after it,
|
||||
// which were broken without it: break them again.
|
||||
if k + 1 < breaks.len() && wraps_below {
|
||||
g0 += b.next;
|
||||
moved = true;
|
||||
break;
|
||||
}
|
||||
if k + 1 < breaks.len()
|
||||
&& f.exclusions.iter().any(|ex| {
|
||||
ex.after.is_some_and(|a| (range_start..range_end).contains(&a))
|
||||
@@ -1404,47 +1437,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
for o in &mut ft.objects {
|
||||
let Some(l) = ft.lines.get(o.line) else { continue };
|
||||
let Some(obj) = story.objects.get(o.index) else { continue };
|
||||
let (w, h) = o.size;
|
||||
o.origin = match &obj.position {
|
||||
designcraft_doc::AnchorPosition::Inline { y_offset } => Point::new(o.x, l.baseline - y_offset - h),
|
||||
designcraft_doc::AnchorPosition::AboveLine { align, space_after, .. } => {
|
||||
let x = match align {
|
||||
designcraft_doc::anchored::AnchorAlign::Left => l.x0,
|
||||
designcraft_doc::anchored::AnchorAlign::Center => (l.x0 + l.x1 - w) / 2.0,
|
||||
designcraft_doc::anchored::AnchorAlign::Right => l.x1 - w,
|
||||
};
|
||||
Point::new(x, l.baseline - o.text_ascent - space_after - h)
|
||||
}
|
||||
designcraft_doc::AnchorPosition::Custom {
|
||||
x_relative,
|
||||
y_relative,
|
||||
x_offset,
|
||||
y_offset,
|
||||
object_point,
|
||||
ref_point,
|
||||
keep_within_column,
|
||||
} => {
|
||||
use designcraft_doc::anchored::AnchorRelative as R;
|
||||
let col = ft.columns.get(l.column as usize).copied().unwrap_or(f.area);
|
||||
let area = |r: R| match r {
|
||||
R::Anchor => None,
|
||||
R::TextFrame => Some(f.area),
|
||||
R::ColumnEdge => Some(col),
|
||||
R::PageMargin => Some(f.page_rect.map_or(f.area, |p| p.1)),
|
||||
R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)),
|
||||
};
|
||||
let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]);
|
||||
let (rfx, rfy) = frac(*ref_point);
|
||||
let (ofx, ofy) = frac(*object_point);
|
||||
let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx);
|
||||
let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy);
|
||||
let mut y = ry + y_offset - h * ofy;
|
||||
if *keep_within_column {
|
||||
y = y.clamp(col.y0, (col.y1 - h).max(col.y0));
|
||||
}
|
||||
Point::new(rx + x_offset - w * ofx, y)
|
||||
}
|
||||
};
|
||||
o.origin = object_origin(obj, o, l, &ft.columns, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1471,6 +1464,60 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
out
|
||||
}
|
||||
|
||||
/// The wrap of a custom or above-line anchored object `obj` of `story`, placed as `o` on line `l`
|
||||
/// of a frame (`columns`, spec `f`), in the frame's inner space: it wraps the lines of the story
|
||||
/// that start after its anchor. None for inline objects (they sit in their line) and objects
|
||||
/// without text wrap.
|
||||
fn own_wrap(story: &Story, obj: &designcraft_doc::AnchoredObject, o: &PlacedObject, l: &Line, columns: &[Rect], f: &FrameSpec) -> Option<Exclusion> {
|
||||
if obj.item.wrap.mode == WrapMode::None || obj.item.hidden || matches!(obj.position, designcraft_doc::AnchorPosition::Inline { .. }) {
|
||||
return None;
|
||||
}
|
||||
let anchor = story.text.match_indices(designcraft_doc::OBJECT_MARK).nth(o.index).map(|(i, _)| i)?;
|
||||
let origin = object_origin(obj, o, l, columns, f);
|
||||
let w = obj.item.wrap.offsets;
|
||||
let b = obj.item.bounds();
|
||||
let rect = Rect::new(b.x0 - w[1], b.y0 - w[0], b.x1 + w[3], b.y1 + w[2]) + origin.to_vec2();
|
||||
rect.is_finite().then_some(Exclusion { rect, mode: obj.item.wrap.mode, after: Some(anchor) })
|
||||
}
|
||||
|
||||
/// Where anchored object `obj`, placed as `o` on line `l` of a frame (`columns`, spec `f`), has its
|
||||
/// top-left, in the frame's inner space.
|
||||
fn object_origin(obj: &designcraft_doc::AnchoredObject, o: &PlacedObject, l: &Line, columns: &[Rect], f: &FrameSpec) -> Point {
|
||||
let (w, h) = o.size;
|
||||
match &obj.position {
|
||||
designcraft_doc::AnchorPosition::Inline { y_offset } => Point::new(o.x, l.baseline - y_offset - h),
|
||||
designcraft_doc::AnchorPosition::AboveLine { align, space_after, .. } => {
|
||||
let x = match align {
|
||||
designcraft_doc::anchored::AnchorAlign::Left => l.x0,
|
||||
designcraft_doc::anchored::AnchorAlign::Center => (l.x0 + l.x1 - w) / 2.0,
|
||||
designcraft_doc::anchored::AnchorAlign::Right => l.x1 - w,
|
||||
};
|
||||
Point::new(x, l.baseline - o.text_ascent - space_after - h)
|
||||
}
|
||||
designcraft_doc::AnchorPosition::Custom { x_relative, y_relative, x_offset, y_offset, object_point, ref_point, keep_within_column } => {
|
||||
use designcraft_doc::anchored::AnchorRelative as R;
|
||||
let col = columns.get(l.column as usize).copied().unwrap_or(f.area);
|
||||
let area = |r: R| match r {
|
||||
R::Anchor => None,
|
||||
R::TextFrame => Some(f.area),
|
||||
R::ColumnEdge => Some(col),
|
||||
R::PageMargin => Some(f.page_rect.map_or(f.area, |p| p.1)),
|
||||
R::PageEdge => Some(f.page_rect.map_or(f.area, |p| p.0)),
|
||||
};
|
||||
let frac = |i: u8| ([0.0, 0.5, 1.0][(i % 3) as usize], [0.0, 0.5, 1.0][(i / 3).min(2) as usize]);
|
||||
let (rfx, rfy) = frac(*ref_point);
|
||||
let (ofx, ofy) = frac(*object_point);
|
||||
let rx = area(*x_relative).map_or(o.x, |a| a.x0 + a.width() * rfx);
|
||||
let ry = area(*y_relative).map_or(l.baseline, |a| a.y0 + a.height() * rfy);
|
||||
let mut y = ry + y_offset - h * ofy;
|
||||
if *keep_within_column {
|
||||
y = y.clamp(col.y0, (col.y1 - h).max(col.y0));
|
||||
}
|
||||
Point::new(rx + x_offset - w * ofx, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The columns a spanning paragraph is set across, from `col`'s top to its bottom.
|
||||
fn span_rect(cols: &[Rect], col: Rect, span: SpanColumns) -> Rect {
|
||||
let SpanColumns::Span(n) = span else { return col };
|
||||
|
||||
@@ -10,22 +10,16 @@
|
||||
//! other's text don't depend on each other in a cycle.
|
||||
//! - **Its own story.** A custom or above-line anchored object wraps the lines of its own story
|
||||
//! that come after the line holding its anchor; that line and the lines before it are never
|
||||
//! wrapped by it (an inline object sits in its line and wraps none of its own story). The story
|
||||
//! is composed again with the objects where the previous pass placed them until they stop
|
||||
//! moving, at most [`MAX_PASSES`] more times.
|
||||
//! wrapped by it (an inline object sits in its line and wraps none of its own story). As in
|
||||
//! InDesign, the object is placed when its anchor line is set and wraps the text that follows
|
||||
//! in the same composition (`own_wrap` in the composer), so the layout doesn't depend on where
|
||||
//! an earlier composition put the objects.
|
||||
|
||||
use designcraft_doc::{AnchorPosition, AnchoredObject, Document, OBJECT_MARK, SpreadRef, Story, StoryId, WrapMode};
|
||||
use designcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::{ComposeOptions, ComposedStory, Exclusion, FrameSpec};
|
||||
|
||||
/// Extra compositions of a story with its own anchored objects' wrap; a layout that keeps moving
|
||||
/// its objects (keep options, vertical justification) stops here.
|
||||
pub(crate) const MAX_PASSES: usize = 4;
|
||||
|
||||
/// How far an obstacle may move between passes and still count as settled (points).
|
||||
const SETTLED: f64 = 0.01;
|
||||
|
||||
/// A wrapping anchored object where its story's composition placed it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Obstacle {
|
||||
@@ -161,18 +155,6 @@ fn with_exclusions(specs: &[FrameSpec], added: &[Vec<Exclusion>]) -> Vec<FrameSp
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn settled(a: &[Vec<Exclusion>], b: &[Vec<Exclusion>]) -> bool {
|
||||
let near = |x: &Exclusion, y: &Exclusion| {
|
||||
x.mode == y.mode
|
||||
&& x.after == y.after
|
||||
&& (x.rect.x0 - y.rect.x0).abs() <= SETTLED
|
||||
&& (x.rect.y0 - y.rect.y0).abs() <= SETTLED
|
||||
&& (x.rect.x1 - y.rect.x1).abs() <= SETTLED
|
||||
&& (x.rect.y1 - y.rect.y1).abs() <= SETTLED
|
||||
};
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.len() == y.len() && x.iter().zip(y).all(|(p, q)| near(p, q)))
|
||||
}
|
||||
|
||||
/// Compose story `sid` with the wrapping anchored objects of `others` (compositions of its
|
||||
/// [`neighbours`]) and of its own.
|
||||
pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions, others: &[&ComposedStory]) -> ComposedStory {
|
||||
@@ -190,18 +172,5 @@ pub(crate) fn compose_among(doc: &Document, sid: StoryId, opts: &ComposeOptions,
|
||||
specs = with_exclusions(&specs, &added);
|
||||
}
|
||||
}
|
||||
let mut out = crate::compose(doc, st, &specs, opts);
|
||||
if !has_wrapping_objects(st) {
|
||||
return out;
|
||||
}
|
||||
let mut prev = vec![Vec::new(); specs.len()];
|
||||
for _ in 0..MAX_PASSES {
|
||||
let added = exclusions(doc, &specs, &obstacles(doc, st, &out, true));
|
||||
if settled(&added, &prev) {
|
||||
break;
|
||||
}
|
||||
out = crate::compose(doc, st, &with_exclusions(&specs, &added), opts);
|
||||
prev = added;
|
||||
}
|
||||
out
|
||||
crate::compose(doc, st, &specs, opts)
|
||||
}
|
||||
Reference in new issue
Block a user