mirror of
https://github.com/typst/typst
synced 2025-05-14 04:56:26 +08:00
Tidy paragraph layout
This commit is contained in:
parent
a302105b9f
commit
f27f7a05ab
@ -76,7 +76,7 @@ impl Layout for ParNode {
|
||||
let lines = linebreak(&p, &mut ctx.fonts, regions.first.x);
|
||||
|
||||
// Stack the lines into one frame per region.
|
||||
stack(ctx, &lines, regions, styles)
|
||||
stack(&p, ctx, &lines, regions)
|
||||
}
|
||||
}
|
||||
|
||||
@ -194,8 +194,16 @@ struct Preparation<'a> {
|
||||
items: Vec<Item<'a>>,
|
||||
/// The styles shared by all children.
|
||||
styles: StyleChain<'a>,
|
||||
/// The paragraph's children.
|
||||
children: &'a StyleVec<ParChild>,
|
||||
/// Whether to hyphenate if it's the same for all children.
|
||||
hyphenate: Option<bool>,
|
||||
/// The text language if it's the same for all children.
|
||||
lang: Option<Lang>,
|
||||
/// The resolved leading between lines.
|
||||
leading: Length,
|
||||
/// The paragraph's resolved alignment.
|
||||
align: Align,
|
||||
/// Whether to justify the paragraph.
|
||||
justify: bool,
|
||||
}
|
||||
|
||||
impl<'a> Preparation<'a> {
|
||||
@ -240,15 +248,6 @@ impl<'a> Preparation<'a> {
|
||||
|
||||
(expanded, &self.items[start .. end])
|
||||
}
|
||||
|
||||
/// Get a style property, but only if it is the same for all children of the
|
||||
/// paragraph.
|
||||
fn get_shared<K: Key<'a>>(&self, key: K) -> Option<K::Output> {
|
||||
self.children
|
||||
.maps()
|
||||
.all(|map| !map.contains(key))
|
||||
.then(|| self.styles.get(key))
|
||||
}
|
||||
}
|
||||
|
||||
/// A segment of one or multiple collapsed children.
|
||||
@ -286,7 +285,7 @@ enum Item<'a> {
|
||||
/// A layouted child node.
|
||||
Frame(Frame),
|
||||
/// A repeating node.
|
||||
Repeat(&'a RepeatNode),
|
||||
Repeat(&'a RepeatNode, StyleChain<'a>),
|
||||
}
|
||||
|
||||
impl<'a> Item<'a> {
|
||||
@ -303,7 +302,7 @@ impl<'a> Item<'a> {
|
||||
match self {
|
||||
Self::Text(shaped) => shaped.text.len(),
|
||||
Self::Absolute(_) | Self::Fractional(_) => SPACING_REPLACE.len_utf8(),
|
||||
Self::Frame(_) | Self::Repeat(_) => NODE_REPLACE.len_utf8(),
|
||||
Self::Frame(_) | Self::Repeat(_, _) => NODE_REPLACE.len_utf8(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -312,7 +311,7 @@ impl<'a> Item<'a> {
|
||||
match self {
|
||||
Item::Text(shaped) => shaped.width,
|
||||
Item::Absolute(v) => *v,
|
||||
Item::Fractional(_) | Self::Repeat(_) => Length::zero(),
|
||||
Item::Fractional(_) | Self::Repeat(_, _) => Length::zero(),
|
||||
Item::Frame(frame) => frame.size.x,
|
||||
}
|
||||
}
|
||||
@ -403,7 +402,7 @@ impl<'a> Line<'a> {
|
||||
self.items()
|
||||
.filter_map(|item| match item {
|
||||
Item::Fractional(fr) => Some(*fr),
|
||||
Item::Repeat(_) => Some(Fraction::one()),
|
||||
Item::Repeat(_, _) => Some(Fraction::one()),
|
||||
_ => None,
|
||||
})
|
||||
.sum()
|
||||
@ -505,38 +504,7 @@ fn prepare<'a>(
|
||||
let end = cursor + segment.len();
|
||||
match segment {
|
||||
Segment::Text(_) => {
|
||||
let mut process = |text, level: Level| {
|
||||
let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL };
|
||||
let shaped = shape(&mut ctx.fonts, text, styles, dir);
|
||||
items.push(Item::Text(shaped));
|
||||
};
|
||||
|
||||
let mut prev_level = Level::ltr();
|
||||
let mut prev_script = Script::Unknown;
|
||||
|
||||
// Group by embedding level and script.
|
||||
for i in cursor .. end {
|
||||
if !text.is_char_boundary(i) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let level = bidi.levels[i];
|
||||
let script =
|
||||
text[i ..].chars().next().map_or(Script::Unknown, |c| c.script());
|
||||
|
||||
if level != prev_level || !is_compatible(script, prev_script) {
|
||||
if cursor < i {
|
||||
process(&text[cursor .. i], prev_level);
|
||||
}
|
||||
cursor = i;
|
||||
prev_level = level;
|
||||
prev_script = script;
|
||||
} else if is_generic_script(prev_script) {
|
||||
prev_script = script;
|
||||
}
|
||||
}
|
||||
|
||||
process(&text[cursor .. end], prev_level);
|
||||
shape_range(&mut items, &mut ctx.fonts, &bidi, cursor .. end, styles);
|
||||
}
|
||||
Segment::Spacing(spacing) => match spacing {
|
||||
Spacing::Relative(v) => {
|
||||
@ -549,7 +517,7 @@ fn prepare<'a>(
|
||||
},
|
||||
Segment::Node(node) => {
|
||||
if let Some(repeat) = node.downcast() {
|
||||
items.push(Item::Repeat(repeat));
|
||||
items.push(Item::Repeat(repeat, styles));
|
||||
} else {
|
||||
let size = Size::new(regions.first.x, regions.base.y);
|
||||
let pod = Regions::one(size, regions.base, Spec::splat(false));
|
||||
@ -562,7 +530,60 @@ fn prepare<'a>(
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
Ok(Preparation { bidi, items, styles, children: &par.0 })
|
||||
Ok(Preparation {
|
||||
bidi,
|
||||
items,
|
||||
styles,
|
||||
hyphenate: shared_get(styles, &par.0, TextNode::HYPHENATE),
|
||||
lang: shared_get(styles, &par.0, TextNode::LANG),
|
||||
leading: styles.get(ParNode::LEADING),
|
||||
align: styles.get(ParNode::ALIGN),
|
||||
justify: styles.get(ParNode::JUSTIFY),
|
||||
})
|
||||
}
|
||||
|
||||
/// Group a range of text by BiDi level and script, shape the runs and generate
|
||||
/// items for them.
|
||||
fn shape_range<'a>(
|
||||
items: &mut Vec<Item<'a>>,
|
||||
fonts: &mut FontStore,
|
||||
bidi: &BidiInfo<'a>,
|
||||
range: Range,
|
||||
styles: StyleChain<'a>,
|
||||
) {
|
||||
let mut process = |text, level: Level| {
|
||||
let dir = if level.is_ltr() { Dir::LTR } else { Dir::RTL };
|
||||
let shaped = shape(fonts, text, styles, dir);
|
||||
items.push(Item::Text(shaped));
|
||||
};
|
||||
|
||||
let mut prev_level = Level::ltr();
|
||||
let mut prev_script = Script::Unknown;
|
||||
let mut cursor = range.start;
|
||||
|
||||
// Group by embedding level and script.
|
||||
for i in cursor .. range.end {
|
||||
if !bidi.text.is_char_boundary(i) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let level = bidi.levels[i];
|
||||
let script =
|
||||
bidi.text[i ..].chars().next().map_or(Script::Unknown, |c| c.script());
|
||||
|
||||
if level != prev_level || !is_compatible(script, prev_script) {
|
||||
if cursor < i {
|
||||
process(&bidi.text[cursor .. i], prev_level);
|
||||
}
|
||||
cursor = i;
|
||||
prev_level = level;
|
||||
prev_script = script;
|
||||
} else if is_generic_script(prev_script) {
|
||||
prev_script = script;
|
||||
}
|
||||
}
|
||||
|
||||
process(&bidi.text[cursor .. range.end], prev_level);
|
||||
}
|
||||
|
||||
/// Whether this is not a specific script.
|
||||
@ -575,6 +596,16 @@ fn is_compatible(a: Script, b: Script) -> bool {
|
||||
is_generic_script(a) || is_generic_script(b) || a == b
|
||||
}
|
||||
|
||||
/// Get a style property, but only if it is the same for all children of the
|
||||
/// paragraph.
|
||||
fn shared_get<'a, K: Key<'a>>(
|
||||
styles: StyleChain<'a>,
|
||||
children: &StyleVec<ParChild>,
|
||||
key: K,
|
||||
) -> Option<K::Output> {
|
||||
children.maps().all(|map| !map.contains(key)).then(|| styles.get(key))
|
||||
}
|
||||
|
||||
/// Find suitable linebreaks.
|
||||
fn linebreak<'a>(
|
||||
p: &'a Preparation<'a>,
|
||||
@ -672,9 +703,6 @@ fn linebreak_optimized<'a>(
|
||||
const MIN_COST: Cost = -MAX_COST;
|
||||
const MIN_RATIO: f64 = -0.15;
|
||||
|
||||
let em = p.styles.get(TextNode::SIZE);
|
||||
let justify = p.styles.get(ParNode::JUSTIFY);
|
||||
|
||||
// Dynamic programming table.
|
||||
let mut active = 0;
|
||||
let mut table = vec![Entry {
|
||||
@ -683,6 +711,8 @@ fn linebreak_optimized<'a>(
|
||||
line: line(p, fonts, 0 .. 0, false, false),
|
||||
}];
|
||||
|
||||
let em = p.styles.get(TextNode::SIZE);
|
||||
|
||||
for (end, mandatory, hyphen) in breakpoints(p) {
|
||||
let k = table.len();
|
||||
let eof = end == p.bidi.text.len();
|
||||
@ -706,7 +736,7 @@ fn linebreak_optimized<'a>(
|
||||
ratio = ratio.min(10.0);
|
||||
|
||||
// Determine the cost of the line.
|
||||
let mut cost = if ratio < if justify { MIN_RATIO } else { 0.0 } {
|
||||
let mut cost = if ratio < if p.justify { MIN_RATIO } else { 0.0 } {
|
||||
// The line is overfull. This is the case if
|
||||
// - justification is on, but we'd need to shrink to much
|
||||
// - justification is off and the line just doesn't fit
|
||||
@ -779,8 +809,6 @@ fn breakpoints<'a>(p: &'a Preparation) -> Breakpoints<'a> {
|
||||
suffix: 0,
|
||||
end: 0,
|
||||
mandatory: false,
|
||||
hyphenate: p.get_shared(TextNode::HYPHENATE),
|
||||
lang: p.get_shared(TextNode::LANG),
|
||||
}
|
||||
}
|
||||
|
||||
@ -800,10 +828,6 @@ struct Breakpoints<'a> {
|
||||
end: usize,
|
||||
/// Whether the break after the current word is mandatory.
|
||||
mandatory: bool,
|
||||
/// Whether to hyphenate if it's the same for all children.
|
||||
hyphenate: Option<bool>,
|
||||
/// The text language if it's the same for all children.
|
||||
lang: Option<Lang>,
|
||||
}
|
||||
|
||||
impl Iterator for Breakpoints<'_> {
|
||||
@ -820,7 +844,7 @@ impl Iterator for Breakpoints<'_> {
|
||||
// Filter out hyphenation opportunities where hyphenation was
|
||||
// actually disabled.
|
||||
let hyphen = self.offset < self.end;
|
||||
if hyphen && !self.hyphenate_at(self.offset) {
|
||||
if hyphen && !self.hyphenate(self.offset) {
|
||||
return self.next();
|
||||
}
|
||||
|
||||
@ -831,8 +855,8 @@ impl Iterator for Breakpoints<'_> {
|
||||
(self.end, self.mandatory) = self.linebreaks.next()?;
|
||||
|
||||
// Hyphenate the next word.
|
||||
if self.hyphenate != Some(false) {
|
||||
if let Some(lang) = self.lang_at(self.offset) {
|
||||
if self.p.hyphenate != Some(false) {
|
||||
if let Some(lang) = self.lang(self.offset) {
|
||||
let word = &self.p.bidi.text[self.offset .. self.end];
|
||||
let trimmed = word.trim_end_matches(|c: char| !c.is_alphabetic());
|
||||
if !trimmed.is_empty() {
|
||||
@ -850,8 +874,9 @@ impl Iterator for Breakpoints<'_> {
|
||||
|
||||
impl Breakpoints<'_> {
|
||||
/// Whether hyphenation is enabled at the given offset.
|
||||
fn hyphenate_at(&self, offset: usize) -> bool {
|
||||
self.hyphenate
|
||||
fn hyphenate(&self, offset: usize) -> bool {
|
||||
self.p
|
||||
.hyphenate
|
||||
.or_else(|| {
|
||||
let shaped = self.p.find(offset)?.text()?;
|
||||
Some(shaped.styles.get(TextNode::HYPHENATE))
|
||||
@ -860,8 +885,8 @@ impl Breakpoints<'_> {
|
||||
}
|
||||
|
||||
/// The text language at the given offset.
|
||||
fn lang_at(&self, offset: usize) -> Option<hypher::Lang> {
|
||||
let lang = self.lang.or_else(|| {
|
||||
fn lang(&self, offset: usize) -> Option<hypher::Lang> {
|
||||
let lang = self.p.lang.or_else(|| {
|
||||
let shaped = self.p.find(offset)?.text()?;
|
||||
Some(shaped.styles.get(TextNode::LANG))
|
||||
})?;
|
||||
@ -980,15 +1005,11 @@ fn line<'a>(
|
||||
|
||||
/// Combine layouted lines into one frame per region.
|
||||
fn stack(
|
||||
p: &Preparation,
|
||||
ctx: &mut Context,
|
||||
lines: &[Line],
|
||||
regions: &Regions,
|
||||
styles: StyleChain,
|
||||
) -> TypResult<Vec<Arc<Frame>>> {
|
||||
let leading = styles.get(ParNode::LEADING);
|
||||
let align = styles.get(ParNode::ALIGN);
|
||||
let justify = styles.get(ParNode::JUSTIFY);
|
||||
|
||||
// Determine the paragraph's width: Full width of the region if we
|
||||
// should expand or there's fractional spacing, fit-to-width otherwise.
|
||||
let mut width = regions.first.x;
|
||||
@ -1004,7 +1025,7 @@ fn stack(
|
||||
|
||||
// Stack the lines into one frame per region.
|
||||
for line in lines {
|
||||
let frame = commit(ctx, line, ®ions, width, styles, align, justify)?;
|
||||
let frame = commit(p, ctx, line, ®ions, width)?;
|
||||
let height = frame.size.y;
|
||||
|
||||
while !regions.first.y.fits(height) && !regions.in_last() {
|
||||
@ -1015,14 +1036,14 @@ fn stack(
|
||||
}
|
||||
|
||||
if !first {
|
||||
output.size.y += leading;
|
||||
output.size.y += p.leading;
|
||||
}
|
||||
|
||||
let pos = Point::with_y(output.size.y);
|
||||
output.size.y += height;
|
||||
output.merge_frame(pos, frame);
|
||||
|
||||
regions.first.y -= height + leading;
|
||||
regions.first.y -= height + p.leading;
|
||||
first = false;
|
||||
}
|
||||
|
||||
@ -1032,13 +1053,11 @@ fn stack(
|
||||
|
||||
/// Commit to a line and build its frame.
|
||||
fn commit(
|
||||
p: &Preparation,
|
||||
ctx: &mut Context,
|
||||
line: &Line,
|
||||
regions: &Regions,
|
||||
width: Length,
|
||||
styles: StyleChain,
|
||||
align: Align,
|
||||
justify: bool,
|
||||
) -> TypResult<Frame> {
|
||||
let mut remaining = width - line.width;
|
||||
let mut offset = Length::zero();
|
||||
@ -1077,7 +1096,7 @@ fn commit(
|
||||
let fr = line.fr();
|
||||
let mut justification = Length::zero();
|
||||
if remaining < Length::zero()
|
||||
|| (justify && line.justify && line.end < line.bidi.text.len() && fr.is_zero())
|
||||
|| (p.justify && line.justify && line.end < line.bidi.text.len() && fr.is_zero())
|
||||
{
|
||||
let justifiables = line.justifiables();
|
||||
if justifiables > 0 {
|
||||
@ -1113,17 +1132,17 @@ fn commit(
|
||||
Item::Frame(frame) => {
|
||||
push(&mut offset, frame.clone());
|
||||
}
|
||||
Item::Repeat(node) => {
|
||||
Item::Repeat(node, styles) => {
|
||||
let before = offset;
|
||||
let width = Fraction::one().share(fr, remaining);
|
||||
let size = Size::new(width, regions.base.y);
|
||||
let pod = Regions::one(size, regions.base, Spec::new(false, false));
|
||||
let frame = node.layout(ctx, &pod, styles)?.remove(0);
|
||||
let frame = node.layout(ctx, &pod, *styles)?.remove(0);
|
||||
let count = (width / frame.size.x).floor();
|
||||
let remaining = width % frame.size.x;
|
||||
let apart = remaining / (count - 1.0);
|
||||
if count == 1.0 {
|
||||
offset += align.position(remaining);
|
||||
offset += p.align.position(remaining);
|
||||
}
|
||||
if frame.size.x > Length::zero() {
|
||||
for _ in 0 .. (count as usize).min(1000) {
|
||||
@ -1147,7 +1166,7 @@ fn commit(
|
||||
|
||||
// Construct the line's frame.
|
||||
for (offset, frame) in frames {
|
||||
let x = offset + align.position(remaining);
|
||||
let x = offset + p.align.position(remaining);
|
||||
let y = top - frame.baseline();
|
||||
output.merge_frame(Point::new(x, y), frame);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user