mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-01 22:07:28 -06:00
Refactor text for strict Clippy checks
This commit is contained in:
+94
-657
@@ -17,36 +17,33 @@
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
|
||||
use parley::{
|
||||
Alignment, AlignmentOptions, FontData, FontFamily, FontFeatures, FontStyle, FontVariations, FontWeight, IndentOptions, Layout, LayoutContext,
|
||||
PositionedLayoutItem, StyleProperty,
|
||||
};
|
||||
use parley::{FontData, FontFamily, FontFeatures, FontStyle, FontVariations, FontWeight, Layout, LayoutContext, PositionedLayoutItem, StyleProperty};
|
||||
use photocraft_doc::TextLayer;
|
||||
use photocraft_doc::text::{Caps, CharStyle, FontBaseline, Kerning, Orientation, ParagraphStyle, TextAlign, TextDirection, TextShape};
|
||||
use photocraft_doc::text::{Caps, CharStyle, FontBaseline, Kerning, Orientation, ParagraphStyle, TextShape};
|
||||
use skrifa::raw::types::Tag;
|
||||
|
||||
use crate::fonts::FontDb;
|
||||
|
||||
mod area;
|
||||
mod lines;
|
||||
mod navigate;
|
||||
mod paragraphs;
|
||||
mod shaping;
|
||||
mod style;
|
||||
mod vertical;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use area::AREA_PASSES;
|
||||
use area::{LineMetrics, MAX_AREA_PASSES, area_metrics, break_area, line_metrics};
|
||||
pub use navigate::{byte_index, char_index, hit_char, line_edge, line_index, line_step, text_point_inside, word_boundary};
|
||||
use paragraphs::next_tab_stop;
|
||||
pub use paragraphs::{DEFAULT_TAB_STOP_PT, split_paragraphs};
|
||||
use paragraphs::{next_tab_stop, strip_break, tab_widths};
|
||||
use shaping::{Para, resolve_run_styles, running_starts, small_caps_modes};
|
||||
use style::SmallCapsMode;
|
||||
#[cfg(test)]
|
||||
use style::{SMCP_PROBES, feature_list, small_caps_mode, variation_list};
|
||||
#[cfg(test)]
|
||||
use style::{SMCP_PROBES, variation_list};
|
||||
use style::{
|
||||
SYNTHETIC_SMALL_CAPS_SCALE, SmallCapsMode, drawn_style, effective_horizontal_scale, feature_list, first_ascent, probe_smcp, small_caps_mode, style_props,
|
||||
};
|
||||
pub(crate) use style::{hangul_runs, squeeze_punctuation, synthetic_bold};
|
||||
pub use vertical::{VClass, vertical_class};
|
||||
use vertical::{VGlyph, VMetrics};
|
||||
|
||||
/// Index of the run containing byte offset `off`, given each run's start offset (non-decreasing):
|
||||
/// the last run starting at or before `off`, or 0 when none does. Binary search, so a layer with
|
||||
@@ -270,29 +267,6 @@ pub(crate) struct Layouter {
|
||||
optical: crate::optical::Cache,
|
||||
}
|
||||
|
||||
/// A cluster of a line in visual order, for kerning.
|
||||
struct KernSlot {
|
||||
/// Index of the run in the paragraph layout.
|
||||
run: usize,
|
||||
/// Byte offset in the paragraph text.
|
||||
start: usize,
|
||||
first: Option<u32>,
|
||||
last: Option<u32>,
|
||||
font: FontData,
|
||||
coords: Vec<i16>,
|
||||
size: f32,
|
||||
rtl: bool,
|
||||
blank: bool,
|
||||
/// Advance along the line (px, horizontal scale included).
|
||||
advance: f32,
|
||||
/// What horizontal scale adds to the shaped advance (px; negative below 100%).
|
||||
growth: f32,
|
||||
/// A tab, shaped as a space; it advances to the next tab stop.
|
||||
tab: bool,
|
||||
/// Vertical type, upright glyph (its outline doesn't run along the column).
|
||||
upright: bool,
|
||||
}
|
||||
|
||||
impl Layouter {
|
||||
pub fn new() -> Self {
|
||||
Self { lcx: LayoutContext::new(), optical: crate::optical::Cache::default() }
|
||||
@@ -304,48 +278,9 @@ impl Layouter {
|
||||
let paras = t.paragraph_runs();
|
||||
let vertical = t.orientation == Orientation::Vertical;
|
||||
let mut out = TextLayout { px_per_pt: k, vertical, ..Default::default() };
|
||||
// Resolve families (PostScript names from PSDs, unknown families).
|
||||
for r in &runs {
|
||||
let mut s = r.style.clone();
|
||||
if let Some(ps) = s.postscript_name.clone()
|
||||
&& !fonts.faces(&s.font_family).iter().any(|f| f.postscript_name.as_deref() == Some(&ps))
|
||||
{
|
||||
let f = fonts.resolve_postscript(&ps);
|
||||
// An exact face match wins; a guessed family only fills a missing family.
|
||||
if f.exact || (!fonts.has_family(&s.font_family) && fonts.has_family(&f.family)) {
|
||||
s.font_family = f.family;
|
||||
s.weight = f.weight;
|
||||
s.italic = f.italic;
|
||||
}
|
||||
}
|
||||
// Drawn with a fallback for now; a host that serves this family fetches it (`served`).
|
||||
if !fonts.has_family(&s.font_family) {
|
||||
crate::served::request(&s.font_family);
|
||||
}
|
||||
fonts.select_named_face(&mut s);
|
||||
drawn_style(&mut s);
|
||||
out.styles.push(s);
|
||||
}
|
||||
let run_starts: Vec<usize> = runs
|
||||
.iter()
|
||||
.scan(0, |a, r| {
|
||||
let s = *a;
|
||||
*a += r.len;
|
||||
Some(s)
|
||||
})
|
||||
.collect();
|
||||
let style_at = |off: usize| run_index_at(&run_starts, off);
|
||||
let para_starts: Vec<usize> = paras
|
||||
.iter()
|
||||
.scan(0, |a, r| {
|
||||
let s = *a;
|
||||
*a += r.len;
|
||||
Some(s)
|
||||
})
|
||||
.collect();
|
||||
let para_style_at = |off: usize| ¶s[para_starts.iter().rposition(|&s| s <= off).unwrap_or(0)].style;
|
||||
|
||||
let text = &t.text;
|
||||
let styles = resolve_run_styles(fonts, &runs);
|
||||
let run_starts = running_starts(runs.iter().map(|r| r.len));
|
||||
let para_starts = running_starts(paras.iter().map(|r| r.len));
|
||||
let (box_rect, is_box) = match t.shape {
|
||||
TextShape::Box { x, y, width, height } => ((x, y, width, height), true),
|
||||
// Path text is laid out as one point-text line, then bent onto the path. Area text
|
||||
@@ -358,591 +293,93 @@ impl Layouter {
|
||||
TextShape::Area { path } => crate::area::AreaShape::new(path, vertical),
|
||||
_ => None,
|
||||
};
|
||||
// Box and area text wrap, align within their lines and hide what doesn't fit.
|
||||
let wraps = is_box || area.is_some();
|
||||
let mut prev_baseline: Option<f32> = None;
|
||||
let mut pending_space = 0.0f32;
|
||||
let mut stop = false;
|
||||
// Fixed for the whole layout, and the font probe only matters for styles that use small
|
||||
// caps. A real `smcp` substitution must receive lowercase source text; only the fallback
|
||||
// path uppercases it and scales it down.
|
||||
let small_caps: Vec<SmallCapsMode> = out
|
||||
.styles
|
||||
.iter()
|
||||
.map(|style| match style.caps {
|
||||
Caps::SmallCaps => small_caps_mode(style.caps, probe_smcp(fonts, style)),
|
||||
_ => SmallCapsMode::None,
|
||||
})
|
||||
.collect();
|
||||
for (pi, prange) in split_paragraphs(text).into_iter().enumerate() {
|
||||
if stop {
|
||||
let small_caps = small_caps_modes(fonts, &styles);
|
||||
let env = Env {
|
||||
text: &t.text,
|
||||
runs: &runs,
|
||||
styles: &styles,
|
||||
run_starts: &run_starts,
|
||||
small_caps: &small_caps,
|
||||
k,
|
||||
vertical,
|
||||
is_box,
|
||||
// Box and area text wrap, align within their lines and hide what doesn't fit.
|
||||
wraps: is_box || area.is_some(),
|
||||
box_rect,
|
||||
area,
|
||||
};
|
||||
let mut flow = Flow::default();
|
||||
for (pi, prange) in split_paragraphs(&t.text).into_iter().enumerate() {
|
||||
if flow.stop {
|
||||
break;
|
||||
}
|
||||
let ps = para_style_at(prange.start).clone();
|
||||
let content_end = strip_break(text, &prange);
|
||||
let content = &text[prange.start..content_end];
|
||||
let prefix = match ps.direction {
|
||||
TextDirection::Auto => "",
|
||||
TextDirection::Ltr => LRM,
|
||||
TextDirection::Rtl => RLM,
|
||||
};
|
||||
let mut ptext = String::with_capacity(prefix.len() + content.len());
|
||||
ptext.push_str(prefix);
|
||||
// Ranges in `ptext` for lowercase characters rendered as synthetic small caps.
|
||||
// They have the same UTF-8 length as their uppercase form, so the layer's byte-based
|
||||
// run and caret offsets remain unchanged.
|
||||
let mut synthetic_small_caps: Vec<(Range<usize>, f32)> = Vec::new();
|
||||
// Offsets in `ptext` of the spaces standing in for tabs.
|
||||
let mut tabs: Vec<usize> = Vec::new();
|
||||
let tab_px = DEFAULT_TAB_STOP_PT * k;
|
||||
for (i, ch) in content.char_indices() {
|
||||
// A forced line break ends the line but not the paragraph. The line breaker knows
|
||||
// it as a newline, which has the same length, so text offsets don't move.
|
||||
if ch == FORCED_LINE_BREAK {
|
||||
ptext.push('\n');
|
||||
continue;
|
||||
}
|
||||
// Imported PSD text can contain literal tab controls. Font shaping may
|
||||
// render those as .notdef boxes; a space preserves the one-byte source
|
||||
// and style/caret offsets while supplying a real whitespace advance. The space
|
||||
// is widened to its tab stop after line breaking (`tab_px` below).
|
||||
if ch == '\t' {
|
||||
tabs.push(ptext.len());
|
||||
ptext.push(' ');
|
||||
continue;
|
||||
}
|
||||
let style = &out.styles[style_at(prange.start + i)];
|
||||
let caps = style.caps;
|
||||
if caps == Caps::AllCaps || small_caps[style_at(prange.start + i)] == SmallCapsMode::Synthetic {
|
||||
let up: String = ch.to_uppercase().collect();
|
||||
if up.len() == ch.len_utf8() {
|
||||
ptext.push_str(&up);
|
||||
if caps == Caps::SmallCaps && ch.is_lowercase() {
|
||||
let end = ptext.len();
|
||||
synthetic_small_caps.push((end - up.len()..end, style.size_pt * k * SYNTHETIC_SMALL_CAPS_SCALE));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
ptext.push(ch);
|
||||
}
|
||||
let first_style = &out.styles[style_at(prange.start)];
|
||||
let first_px = first_style.size_pt * k;
|
||||
// Scripts the loaded fonts may lack (Arabic, Japanese, …): a host serving a font for them
|
||||
// fetches it (`served`); it joins the fallback stack once it arrives.
|
||||
crate::served::request_for_text(&ptext);
|
||||
let fallback: Vec<String> = fonts.fallback_stack().map(str::to_string).collect();
|
||||
let si0 = style_at(prange.start);
|
||||
let si_end = run_starts.partition_point(|&rs| rs < content_end).max(si0 + 1).min(out.styles.len());
|
||||
// Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px).
|
||||
let build = |lcx: &mut LayoutContext<RunBrush>, fcx: &mut parley::FontContext, tab_spacing: &[f32]| -> Layout<RunBrush> {
|
||||
let mut b = lcx.ranged_builder(fcx, &ptext, 1.0, false);
|
||||
// Paragraph-start style as the default (covers the direction mark and empty
|
||||
// paragraphs), then every run piece intersecting this paragraph.
|
||||
for p in style_props(&out.styles[si0], k, &fallback, si0 as u32, small_caps[si0]) {
|
||||
b.push_default(p);
|
||||
}
|
||||
// Paragraph text breaks a word wider than the box at the box edge, as Photoshop
|
||||
// does (#6694): parley's default only breaks at UAX #14 opportunities and lets the
|
||||
// word run out of the box. Point text has no width to overflow.
|
||||
if is_box {
|
||||
b.push_default(StyleProperty::OverflowWrap(parley::style::OverflowWrap::BreakWord));
|
||||
}
|
||||
for (ri, st) in out.styles.iter().enumerate().take(si_end).skip(si0) {
|
||||
let rs = run_starts[ri];
|
||||
let (a, z) = (rs.max(prange.start), (rs + runs[ri].len).min(content_end));
|
||||
if a >= z {
|
||||
continue;
|
||||
}
|
||||
let range = (a - prange.start + prefix.len())..(z - prange.start + prefix.len());
|
||||
for p in style_props(st, k, &fallback, ri as u32, small_caps[ri]) {
|
||||
b.push(p, range.clone());
|
||||
}
|
||||
if vertical {
|
||||
// Upright pieces get the vertical alternates and their class in the brush.
|
||||
let piece = ptext.get(range.clone()).unwrap_or("");
|
||||
let mut seg: Option<(usize, VClass)> = None;
|
||||
let flush = |b: &mut parley::RangedBuilder<'_, RunBrush>, from: usize, to: usize, cls: VClass| {
|
||||
if cls == VClass::Rotate || from >= to {
|
||||
return;
|
||||
}
|
||||
let mut feats = feature_list(st, small_caps[ri]);
|
||||
feats.push("\"vert\" 1".into());
|
||||
let r = (range.start + from)..(range.start + to);
|
||||
b.push(StyleProperty::FontFeatures(FontFeatures::Source(Cow::Owned(feats.join(", ")))), r.clone());
|
||||
b.push(StyleProperty::Brush(RunBrush(ri as u32, cls as u8)), r);
|
||||
};
|
||||
for (i, ch) in piece.char_indices() {
|
||||
let cls = vertical_class(ch);
|
||||
match seg {
|
||||
Some((_, c)) if c == cls => {}
|
||||
Some((from, c)) => {
|
||||
flush(&mut b, from, i, c);
|
||||
seg = Some((i, cls));
|
||||
}
|
||||
None => seg = Some((i, cls)),
|
||||
}
|
||||
}
|
||||
if let Some((from, c)) = seg {
|
||||
flush(&mut b, from, piece.len(), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (range, size) in &synthetic_small_caps {
|
||||
b.push(StyleProperty::FontSize(*size), range.clone());
|
||||
}
|
||||
// Korean breaks lines at spaces, between words, as Photoshop sets it; Unicode's
|
||||
// default rules break between any two Hangul syllables, mid-word (#5959).
|
||||
for range in hangul_runs(&ptext) {
|
||||
b.push(StyleProperty::WordBreak(parley::WordBreak::KeepAll), range);
|
||||
}
|
||||
for (&at, &spacing) in tabs.iter().zip(tab_spacing) {
|
||||
b.push(StyleProperty::LetterSpacing(spacing), at..at + 1);
|
||||
}
|
||||
b.build(&ptext)
|
||||
};
|
||||
let indent_start = ps.start_indent_pt * k;
|
||||
let indent_end = ps.end_indent_pt * k;
|
||||
let first_indent = ps.first_line_indent_pt * k;
|
||||
// Box extent along the lines: width, or height for vertical type (columns).
|
||||
let (line_origin, line_len) = if vertical { (box_rect.1, box_rect.3) } else { (box_rect.0, box_rect.2) };
|
||||
let avail = if is_box { Some((line_len - indent_start - indent_end).max(1.0)) } else { None };
|
||||
// Horizontal scale is applied after shaping, so paragraph text breaks at the box
|
||||
// (or area span) width divided by it (#5565). Mixed scales break at the largest, so
|
||||
// no line overflows the box.
|
||||
let break_scale = if wraps {
|
||||
(si0..si_end)
|
||||
.filter(|&ri| {
|
||||
let rs = run_starts.get(ri).copied().unwrap_or(0);
|
||||
let end = rs.saturating_add(runs.get(ri).map_or(0, |r| r.len));
|
||||
ri == si0 || (rs.max(prange.start) < end.min(content_end))
|
||||
})
|
||||
.filter_map(|ri| out.styles.get(ri))
|
||||
.map(effective_horizontal_scale)
|
||||
.fold(0.0f32, f32::max)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let break_scale = if break_scale > 0.0 { break_scale } else { 1.0 };
|
||||
// Area type: the first row goes no higher than one leading (plus paragraph spacing)
|
||||
// below the previous paragraph's last line.
|
||||
let space_before = if prev_baseline.is_some() { ps.space_before_pt * k } else { 0.0 };
|
||||
let area_from = |m: LineMetrics| match (&area, prev_baseline) {
|
||||
(Some(_), Some(b)) => b + m.leading + pending_space + space_before,
|
||||
(Some(a), None) => a.bounds[1] + m.ascent,
|
||||
(None, _) => 0.0,
|
||||
};
|
||||
// Line-breaking passes left for this paragraph's area type (see `break_area`).
|
||||
let area_budget = std::cell::Cell::new(MAX_AREA_PASSES);
|
||||
let break_lines = |layout: &mut Layout<RunBrush>| -> (Vec<Slot>, bool) {
|
||||
if first_indent != 0.0 {
|
||||
layout.set_text_indent(first_indent / break_scale, IndentOptions::default());
|
||||
}
|
||||
let Some(a) = &area else {
|
||||
layout.break_all_lines(avail.map(|a| a / break_scale));
|
||||
return (Vec::new(), false);
|
||||
};
|
||||
// Plan every row with the paragraph's real line metrics (its tallest font and
|
||||
// largest leading, measured on the unwrapped paragraph), so no wrapped line comes
|
||||
// out taller than the row it was planned in and no second plan is needed.
|
||||
layout.break_all_lines(None);
|
||||
let m = area_metrics(layout, &out.styles, &ps, k, first_style, vertical);
|
||||
break_area(layout, a, area_from(m), m, (indent_start, indent_end), first_px, break_scale, &area_budget)
|
||||
};
|
||||
// Paragraph text: a tab is never wider than a space plus one tab interval, so breaking
|
||||
// lines at that width keeps the expanded tabs inside the box, but can wrap early.
|
||||
// Break again with the tab widths that layout gives until they agree; if they don't
|
||||
// settle, keep the safe layout.
|
||||
let wide = if wraps { tab_px } else { 0.0 };
|
||||
let mut layout = build(&mut self.lcx, &mut fonts.fcx, &vec![wide; tabs.len()]);
|
||||
let (mut area_slots, mut area_full) = break_lines(&mut layout);
|
||||
if wraps && !tabs.is_empty() {
|
||||
let first = tab_widths(&layout, &tabs, &out.styles, indent_start, first_indent, tab_px);
|
||||
let mut want: Vec<f32> = first.iter().map(|t| t.width).collect();
|
||||
for _ in 0..4 {
|
||||
let spacing: Vec<f32> = first.iter().zip(&want).map(|(t, w)| w / t.scale - (t.advance - tab_px)).collect();
|
||||
let mut next = build(&mut self.lcx, &mut fonts.fcx, &spacing);
|
||||
let (next_slots, next_full) = break_lines(&mut next);
|
||||
let got: Vec<f32> = tab_widths(&next, &tabs, &out.styles, indent_start, first_indent, tab_px).iter().map(|t| t.width).collect();
|
||||
if got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 0.01) {
|
||||
layout = next;
|
||||
area_slots = next_slots;
|
||||
area_full = next_full;
|
||||
break;
|
||||
}
|
||||
want = got;
|
||||
}
|
||||
}
|
||||
let alignment = if wraps {
|
||||
match ps.align {
|
||||
TextAlign::Left => Alignment::Left,
|
||||
TextAlign::Center => Alignment::Center,
|
||||
TextAlign::Right => Alignment::Right,
|
||||
_ => Alignment::Justify,
|
||||
}
|
||||
} else {
|
||||
Alignment::Left
|
||||
};
|
||||
layout.align(alignment, AlignmentOptions { align_when_overflowing: !wraps });
|
||||
|
||||
// Stack lines.
|
||||
if prev_baseline.is_some() {
|
||||
pending_space += ps.space_before_pt * k;
|
||||
}
|
||||
let nlines = layout.len();
|
||||
for (li, line) in layout.lines().enumerate() {
|
||||
// Area type: the slot the breaker gave this line.
|
||||
let slot = area_slots.get(li).copied();
|
||||
if area.is_some() && slot.is_none() {
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
let m = *line.metrics();
|
||||
let LineMetrics { ascent, descent, leading } = line_metrics(&line, &out.styles, &ps, k, first_style, vertical);
|
||||
let baseline = match (slot, prev_baseline) {
|
||||
(Some(s), _) => s.baseline,
|
||||
// The first column's right edge touches the box's right edge.
|
||||
// (In vertical type the ascent is half the column width.)
|
||||
(None, None) if is_box && vertical => -(box_rect.0 + box_rect.2) + ascent,
|
||||
// Photoshop's "first baseline: ascent": the top of the tallest ascender
|
||||
// (height of 'd') touches the box top, not the font's hhea ascent.
|
||||
(None, None) if is_box => box_rect.1 + first_ascent(&line).unwrap_or(ascent),
|
||||
(None, None) => 0.0,
|
||||
(None, Some(b)) => b + leading + pending_space,
|
||||
};
|
||||
pending_space = 0.0;
|
||||
// Photoshop keeps a horizontal line whose baseline is inside the box and lets
|
||||
// its descenders hang below the bottom edge (#1317: three 35 pt lines in a
|
||||
// 91.3 pt box, the last baseline at 88). Requiring the descent to fit dropped
|
||||
// that line.
|
||||
let overflow = if vertical { baseline + descent > -box_rect.0 + 0.5 } else { baseline > box_rect.1 + box_rect.3 + 0.5 };
|
||||
if is_box && overflow {
|
||||
stop = true;
|
||||
break;
|
||||
}
|
||||
prev_baseline = Some(baseline);
|
||||
let last_line = li + 1 == nlines;
|
||||
let adv = m.advance - m.trailing_whitespace;
|
||||
let map = |o: usize| (prange.start + o.saturating_sub(prefix.len())).min(content_end);
|
||||
|
||||
// Kerning after each cluster (px), per run in visual cluster order: manual
|
||||
// kerning plus optical pair kerning. Applied after shaping (like horizontal
|
||||
// scale) in line space, so it moves the following glyphs, carets and the line
|
||||
// extent; in vertical type that is along the column. Optical kerning measures
|
||||
// horizontal outlines, so it applies to rotated (Latin) glyphs only there.
|
||||
let mut slots: Vec<KernSlot> = Vec::new();
|
||||
let mut line_runs: Vec<usize> = Vec::new();
|
||||
for item in line.items() {
|
||||
let PositionedLayoutItem::GlyphRun(gr) = item else {
|
||||
continue;
|
||||
};
|
||||
let run = gr.run();
|
||||
if line_runs.contains(&run.index()) {
|
||||
continue;
|
||||
}
|
||||
line_runs.push(run.index());
|
||||
let hs = out.styles.get(gr.style().brush.0 as usize).map_or(1.0, |st| if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 });
|
||||
for c in run.visual_clusters() {
|
||||
let mut gl = c.glyphs();
|
||||
let first = gl.next().map(|g| g.id);
|
||||
let last = gl.last().map(|g| g.id).or(first);
|
||||
slots.push(KernSlot {
|
||||
run: run.index(),
|
||||
start: c.text_range().start,
|
||||
first,
|
||||
last,
|
||||
font: run.font().clone(),
|
||||
coords: run.normalized_coords().to_vec(),
|
||||
size: run.font_size(),
|
||||
rtl: c.is_rtl(),
|
||||
blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= prefix.len(),
|
||||
upright: vertical && c.first_style().brush.1 != VClass::Rotate as u8,
|
||||
advance: c.advance() * hs,
|
||||
growth: c.advance() * (hs - 1.0),
|
||||
tab: tabs.binary_search(&c.text_range().start).is_ok(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut kern_px: Vec<f32> = vec![0.0; slots.len()];
|
||||
for j in 0..slots.len().saturating_sub(1) {
|
||||
let (a, b) = (&slots[j], &slots[j + 1]);
|
||||
if a.start < prefix.len() {
|
||||
continue;
|
||||
}
|
||||
let st = &out.styles[style_at(map(a.start))];
|
||||
let next = &out.styles[style_at(map(b.start))];
|
||||
let mut units = if st.kern.is_finite() { st.kern } else { 0.0 };
|
||||
// Optical pairs: both characters optical (a mode change splits shaping
|
||||
// runs, which ends automatic kerning, as with Metrics).
|
||||
if st.kerning == Kerning::Optical
|
||||
&& next.kerning == Kerning::Optical
|
||||
&& st.kern == 0.0
|
||||
&& next.kern == 0.0
|
||||
&& !a.rtl
|
||||
&& !b.rtl
|
||||
&& !a.blank
|
||||
&& !b.blank
|
||||
&& !a.upright
|
||||
&& !b.upright
|
||||
&& a.font.data.id() == b.font.data.id()
|
||||
&& a.font.index == b.font.index
|
||||
&& a.coords == b.coords
|
||||
&& (a.size - b.size).abs() < 1e-3
|
||||
&& let (Some(l), Some(r)) = (a.last, b.first)
|
||||
{
|
||||
let coords: Vec<skrifa::instance::NormalizedCoord> =
|
||||
a.coords.iter().map(|&c| skrifa::instance::NormalizedCoord::from_bits(c)).collect();
|
||||
if let Some(k) = self.optical.pair(a.font.data.id(), a.font.data.as_ref(), a.font.index, &coords, l, r) {
|
||||
units += k + crate::optical::size_adjust(st.size_pt);
|
||||
}
|
||||
}
|
||||
kern_px[j] = units / 1000.0 * a.size;
|
||||
}
|
||||
// Tabs: the "kerning" after a tab takes the pen to the next stop, measured from
|
||||
// the anchor or the box edge as if the line were left-aligned (alignment then
|
||||
// moves the whole line).
|
||||
let mut pos = indent_start + if li == 0 { first_indent } else { 0.0 };
|
||||
let mut trailing_tabs = 0.0f32;
|
||||
let mut trailing_growth = 0.0f32;
|
||||
for (j, s) in slots.iter().enumerate() {
|
||||
// Trailing whitespace is outside the line's extent (`adv`).
|
||||
let trailing = s.blank && slots[j + 1..].iter().all(|n| n.blank);
|
||||
if trailing {
|
||||
trailing_growth += s.growth;
|
||||
}
|
||||
if s.tab {
|
||||
kern_px[j] = next_tab_stop(pos, tab_px) - pos - s.advance;
|
||||
if slots[j + 1..].iter().all(|n| n.blank) {
|
||||
trailing_tabs += kern_px[j];
|
||||
}
|
||||
}
|
||||
pos += s.advance + kern_px.get(j).copied().unwrap_or(0.0);
|
||||
}
|
||||
let line_kern: f32 = kern_px.iter().sum::<f32>() - trailing_tabs;
|
||||
// Per run on the line: (run index, glyph → slot, glyphs emitted, first slot whose
|
||||
// kerning isn't applied yet).
|
||||
let mut cursors: Vec<(usize, Vec<usize>, usize, usize)> = Vec::new();
|
||||
for item in line.items() {
|
||||
let PositionedLayoutItem::GlyphRun(gr) = item else {
|
||||
continue;
|
||||
};
|
||||
let run = gr.run();
|
||||
if cursors.iter().any(|c| c.0 == run.index()) {
|
||||
continue;
|
||||
}
|
||||
let base = slots.iter().position(|s| s.run == run.index()).unwrap_or(0);
|
||||
let mut g2s = Vec::new();
|
||||
for (ci, c) in run.visual_clusters().enumerate() {
|
||||
g2s.extend(c.glyphs().map(|_| base + ci));
|
||||
}
|
||||
cursors.push((run.index(), g2s, 0, base));
|
||||
}
|
||||
// Parley aligned box lines without the kerning.
|
||||
let kern_align = if wraps {
|
||||
match ps.align {
|
||||
TextAlign::Center => line_kern / 2.0,
|
||||
TextAlign::Right => line_kern,
|
||||
_ => 0.0,
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Box and area lines start at their box or span edge; the line's own width
|
||||
// (`line_avail`) takes the justify-last-line slack.
|
||||
let line_avail = slot.map(|s| s.width).or(avail);
|
||||
let dx = if wraps {
|
||||
let origin = slot.map_or(line_origin, |s| s.x);
|
||||
let base = origin + indent_start - kern_align;
|
||||
let slack = line_avail.unwrap_or(0.0) - adv * break_scale - line_kern;
|
||||
// Parley aligned the unscaled line in `avail / break_scale`: scale its
|
||||
// indent and alignment offset back to the box.
|
||||
let scaled_offset = m.offset * (break_scale - 1.0);
|
||||
base + if last_line {
|
||||
match ps.align {
|
||||
TextAlign::JustifyCenter => slack * 0.5 - m.offset,
|
||||
TextAlign::JustifyRight => slack - m.offset,
|
||||
_ => scaled_offset,
|
||||
}
|
||||
} else {
|
||||
scaled_offset
|
||||
}
|
||||
} else {
|
||||
let target = match ps.align {
|
||||
TextAlign::Center | TextAlign::JustifyCenter => -(adv + line_kern) / 2.0,
|
||||
TextAlign::Right | TextAlign::JustifyRight => -(adv + line_kern),
|
||||
_ => indent_start,
|
||||
};
|
||||
target - m.offset
|
||||
};
|
||||
let justify_all = wraps && last_line && ps.align == TextAlign::JustifyAll;
|
||||
let line_index = out.lines.len();
|
||||
let lr = line.text_range();
|
||||
let g0 = out.glyphs.len();
|
||||
let c0 = out.clusters.len();
|
||||
let mut vinfo: Vec<VGlyph> = Vec::new();
|
||||
let mut extra = 0.0f32; // horizontal-scale growth and kerning along the line
|
||||
let mut seen_runs: Vec<usize> = Vec::new();
|
||||
for item in line.items() {
|
||||
let PositionedLayoutItem::GlyphRun(gr) = item else {
|
||||
continue;
|
||||
};
|
||||
let run = gr.run();
|
||||
let si = gr.style().brush.0;
|
||||
let st = &out.styles[si as usize];
|
||||
let hs = if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 };
|
||||
let synth = run.synthesis();
|
||||
let face = out.faces.len() as u32;
|
||||
out.faces.push(GlyphFace {
|
||||
font: run.font().clone(),
|
||||
coords: run.normalized_coords().to_vec(),
|
||||
size_px: run.font_size(),
|
||||
embolden: synthetic_bold(synth.embolden(), st.weight, run.font_attrs().weight.value()),
|
||||
skew_deg: synth.skew().unwrap_or(0.0),
|
||||
});
|
||||
let run_x0 = dx + gr.offset() + extra;
|
||||
if !seen_runs.contains(&run.index()) {
|
||||
seen_runs.push(run.index());
|
||||
let mut cx = run_x0;
|
||||
let base = slots.iter().position(|s| s.run == run.index()).unwrap_or(0);
|
||||
for (ci, c) in run.visual_clusters().enumerate() {
|
||||
let a = c.advance() * hs + kern_px.get(base + ci).copied().unwrap_or(0.0);
|
||||
let r = c.text_range();
|
||||
if r.end > prefix.len() || prefix.is_empty() {
|
||||
out.clusters.push(ClusterInfo { range: map(r.start)..map(r.end), x: cx, advance: a, line: line_index, rtl: c.is_rtl() });
|
||||
}
|
||||
cx += a;
|
||||
}
|
||||
}
|
||||
let rm = run.metrics();
|
||||
let vm = if vertical { Some(VMetrics::new(run.font(), run.normalized_coords(), run.font_size())) } else { None };
|
||||
let class = gr.style().brush.1;
|
||||
// Glyphs of the run's own characters (not substituted by `vert`).
|
||||
let plain: Vec<u32> = match (&vm, class) {
|
||||
(Some(v), 2) => ptext.get(run.text_range()).unwrap_or("").chars().filter_map(|c| v.cmap(c)).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut pen = gr.offset();
|
||||
let mut cursor = cursors.iter_mut().find(|c| c.0 == run.index());
|
||||
for g in gr.glyphs() {
|
||||
// Kerning of the clusters before this glyph's cluster.
|
||||
if let Some(c) = cursor.as_deref_mut() {
|
||||
if let Some(&slot) = c.1.get(c.2) {
|
||||
while c.3 < slot {
|
||||
extra += kern_px.get(c.3).copied().unwrap_or(0.0);
|
||||
c.3 += 1;
|
||||
}
|
||||
}
|
||||
c.2 += 1;
|
||||
}
|
||||
out.glyphs.push(PlacedGlyph {
|
||||
face,
|
||||
id: g.id,
|
||||
x: dx + pen + g.x + extra,
|
||||
y: baseline + g.y,
|
||||
style: si,
|
||||
orient: GlyphOrient::Horizontal,
|
||||
});
|
||||
if let Some(v) = &vm {
|
||||
let upright = class == 1 || (class == 2 && !plain.contains(&g.id));
|
||||
vinfo.push(VGlyph {
|
||||
upright,
|
||||
advance: g.advance * hs,
|
||||
origin: if upright { v.origin(g.id) } else { 0.0 },
|
||||
// Rotated text is centred on the column: its baseline sits
|
||||
// (ascent − descent) / 2 from the centre line.
|
||||
centre: (rm.ascent - rm.descent) * 0.5,
|
||||
});
|
||||
}
|
||||
extra += g.advance * (hs - 1.0);
|
||||
pen += g.advance;
|
||||
}
|
||||
// The run's last glyph: its remaining clusters' kerning follows it.
|
||||
if let Some(c) = cursor
|
||||
&& c.2 >= c.1.len()
|
||||
{
|
||||
let end = slots.iter().rposition(|s| s.run == c.0).map_or(c.3, |p| p + 1);
|
||||
while c.3 < end {
|
||||
extra += kern_px.get(c.3).copied().unwrap_or(0.0);
|
||||
c.3 += 1;
|
||||
}
|
||||
}
|
||||
let run_x1 = dx + gr.offset() + gr.advance() + extra;
|
||||
let shift = st.baseline_shift_pt * k;
|
||||
if vertical {
|
||||
// Underline to the right of the column, strikethrough through its centre
|
||||
// (stored directly in text space).
|
||||
let half = run.font_size() * 0.5;
|
||||
let mut push = |v0: f32, v1: f32| out.decorations.push(DecorationRect { x0: -v1, y0: run_x0, x1: -v0, y1: run_x1, style: si });
|
||||
if gr.style().underline.is_some() {
|
||||
let v1 = baseline - half;
|
||||
push(v1 - rm.underline_size.max(1.0), v1);
|
||||
}
|
||||
if gr.style().strikethrough.is_some() {
|
||||
let sz = rm.strikethrough_size.max(1.0);
|
||||
push(baseline - sz * 0.5, baseline + sz * 0.5);
|
||||
}
|
||||
} else if gr.style().underline.is_some() {
|
||||
let y0 = baseline - rm.underline_offset - shift;
|
||||
out.decorations.push(DecorationRect { x0: run_x0, y0, x1: run_x1, y1: y0 + rm.underline_size.max(1.0), style: si });
|
||||
}
|
||||
if !vertical && gr.style().strikethrough.is_some() {
|
||||
let y0 = baseline - rm.strikethrough_offset - shift;
|
||||
out.decorations.push(DecorationRect { x0: run_x0, y0, x1: run_x1, y1: y0 + rm.strikethrough_size.max(1.0), style: si });
|
||||
}
|
||||
}
|
||||
if vertical {
|
||||
extra -= squeeze_punctuation(text, &mut out.clusters[c0..], &mut out.glyphs[g0..]);
|
||||
}
|
||||
if justify_all {
|
||||
let slack = line_avail.unwrap_or(0.0) - (adv + extra);
|
||||
let n = out.clusters.len() - c0;
|
||||
if n > 1 && slack > 0.0 {
|
||||
let step = slack / (n - 1) as f32;
|
||||
let starts: Vec<f32> = out.clusters[c0..].iter().map(|c| c.x).collect();
|
||||
for (i, c) in out.clusters[c0..].iter_mut().enumerate() {
|
||||
c.x += step * i as f32;
|
||||
}
|
||||
for g in &mut out.glyphs[g0..] {
|
||||
let i = starts.iter().rposition(|&s| s <= g.x + 1e-3).unwrap_or(0);
|
||||
g.x += step * i as f32;
|
||||
}
|
||||
extra += slack;
|
||||
}
|
||||
}
|
||||
if vertical {
|
||||
// Line space → text space (90° clockwise); upright glyphs centred on the column.
|
||||
for (g, v) in out.glyphs[g0..].iter_mut().zip(&vinfo) {
|
||||
let (u, cross) = (g.x, g.y);
|
||||
if v.upright {
|
||||
g.x = -baseline - v.advance * 0.5;
|
||||
g.y = u + v.origin;
|
||||
g.orient = GlyphOrient::Upright;
|
||||
} else {
|
||||
g.x = -(cross + v.centre);
|
||||
g.y = u;
|
||||
g.orient = GlyphOrient::Rotated;
|
||||
}
|
||||
}
|
||||
}
|
||||
let x0 = dx + m.offset;
|
||||
out.lines.push(LineInfo {
|
||||
range: map(lr.start)..map(lr.end).min(content_end),
|
||||
baseline,
|
||||
x0,
|
||||
x1: x0 + adv + extra - trailing_tabs - trailing_growth,
|
||||
ascent,
|
||||
descent,
|
||||
paragraph: pi,
|
||||
});
|
||||
}
|
||||
if area_full {
|
||||
// The shape is full: later paragraphs are hidden too.
|
||||
stop = true;
|
||||
}
|
||||
pending_space += ps.space_after_pt * k;
|
||||
let ps = paras[para_starts.iter().rposition(|&s| s <= prange.start).unwrap_or(0)].style.clone();
|
||||
self.layout_paragraph(fonts, &mut out, &mut flow, Para::new(&env, fonts, pi, prange, ps));
|
||||
}
|
||||
out.styles = styles;
|
||||
out
|
||||
}
|
||||
|
||||
/// Breaks one paragraph and stacks its lines below the previous paragraph's.
|
||||
fn layout_paragraph(&mut self, fonts: &mut FontDb, out: &mut TextLayout, flow: &mut Flow, para: Para<'_>) {
|
||||
let env = para.env;
|
||||
let (mut layout, area_slots, area_full) = para.break_paragraph(&mut self.lcx, &mut fonts.fcx, flow);
|
||||
layout.align(para.alignment(), para.align_options());
|
||||
if flow.prev_baseline.is_some() {
|
||||
flow.pending_space += para.ps.space_before_pt * env.k;
|
||||
}
|
||||
let nlines = layout.len();
|
||||
for (li, line) in layout.lines().enumerate() {
|
||||
// Area type: the slot the breaker gave this line.
|
||||
let slot = area_slots.get(li).copied();
|
||||
if env.area.is_some() && slot.is_none() {
|
||||
flow.stop = true;
|
||||
break;
|
||||
}
|
||||
let Some(frame) = para.frame_line(&line, li, nlines, slot, flow) else {
|
||||
flow.stop = true;
|
||||
break;
|
||||
};
|
||||
para.place_line(&mut self.optical, out, &line, &frame);
|
||||
}
|
||||
if area_full {
|
||||
// The shape is full: later paragraphs are hidden too.
|
||||
flow.stop = true;
|
||||
}
|
||||
flow.pending_space += para.ps.space_after_pt * env.k;
|
||||
}
|
||||
}
|
||||
|
||||
/// What a layout run shares between its paragraphs.
|
||||
struct Env<'a> {
|
||||
text: &'a str,
|
||||
runs: &'a [photocraft_doc::text::TextRun],
|
||||
styles: &'a [CharStyle],
|
||||
/// Start offset of each run.
|
||||
run_starts: &'a [usize],
|
||||
small_caps: &'a [SmallCapsMode],
|
||||
k: f32,
|
||||
vertical: bool,
|
||||
is_box: bool,
|
||||
/// Box and area text wrap, align within their lines and hide what doesn't fit.
|
||||
wraps: bool,
|
||||
box_rect: (f32, f32, f32, f32),
|
||||
area: Option<crate::area::AreaShape>,
|
||||
}
|
||||
|
||||
impl Env<'_> {
|
||||
/// Index of the run (and style) containing byte offset `off`.
|
||||
fn style_at(&self, off: usize) -> usize {
|
||||
run_index_at(self.run_starts, off)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the previous paragraph left off.
|
||||
#[derive(Default)]
|
||||
struct Flow {
|
||||
prev_baseline: Option<f32>,
|
||||
pending_space: f32,
|
||||
stop: bool,
|
||||
}
|
||||
|
||||
/// Where one line of area type goes: its baseline, the left edge of its span, and the width
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{CharStyle, Layout, ParagraphStyle, PositionedLayoutItem, RunBrush, Slot};
|
||||
|
||||
/// A line's ascent, descent and leading (px); for area type, the largest over a paragraph.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
//! Placing one broken line of a paragraph: kerning and tab stops, glyph and cluster positions,
|
||||
//! decorations, and the line's vertical-type and justification adjustments.
|
||||
|
||||
use parley::{PositionedLayoutItem, Run};
|
||||
use photocraft_doc::text::{CharStyle, Kerning, TextAlign};
|
||||
use skrifa::instance::NormalizedCoord;
|
||||
|
||||
use super::area::{LineMetrics, line_metrics};
|
||||
use super::shaping::Para;
|
||||
use super::style::{first_ascent, squeeze_punctuation, synthetic_bold};
|
||||
use super::vertical::{VGlyph, VMetrics};
|
||||
use super::{ClusterInfo, DecorationRect, Flow, GlyphFace, GlyphOrient, LineInfo, PlacedGlyph, RunBrush, Slot, TextLayout, VClass};
|
||||
|
||||
type Line<'a> = parley::Line<'a, RunBrush>;
|
||||
type GlyphRun<'a> = parley::GlyphRun<'a, RunBrush>;
|
||||
|
||||
/// A cluster of a line in visual order, for kerning.
|
||||
struct KernSlot {
|
||||
/// Index of the run in the paragraph layout.
|
||||
run: usize,
|
||||
/// Byte offset in the paragraph text.
|
||||
start: usize,
|
||||
first: Option<u32>,
|
||||
last: Option<u32>,
|
||||
font: parley::FontData,
|
||||
coords: Vec<i16>,
|
||||
size: f32,
|
||||
rtl: bool,
|
||||
blank: bool,
|
||||
/// Advance along the line (px, horizontal scale included).
|
||||
advance: f32,
|
||||
/// What horizontal scale adds to the shaped advance (px; negative below 100%).
|
||||
growth: f32,
|
||||
/// A tab, shaped as a space; it advances to the next tab stop.
|
||||
tab: bool,
|
||||
/// Vertical type, upright glyph (its outline doesn't run along the column).
|
||||
upright: bool,
|
||||
}
|
||||
|
||||
/// Where a line sits in its paragraph and on the page.
|
||||
pub(super) struct LineFrame {
|
||||
pub(super) index: usize,
|
||||
pub(super) last: bool,
|
||||
/// The slot the area breaker gave this line.
|
||||
pub(super) slot: Option<Slot>,
|
||||
pub(super) baseline: f32,
|
||||
pub(super) ascent: f32,
|
||||
pub(super) descent: f32,
|
||||
}
|
||||
|
||||
/// The clusters of one run, for applying kerning as glyphs are emitted.
|
||||
struct KernCursor {
|
||||
run: usize,
|
||||
/// Slot of the cluster each glyph of the run belongs to.
|
||||
glyph_slot: Vec<usize>,
|
||||
/// Glyphs emitted so far.
|
||||
emitted: usize,
|
||||
/// First slot whose kerning isn't applied yet.
|
||||
applied: usize,
|
||||
}
|
||||
|
||||
impl KernCursor {
|
||||
/// Kerning of the clusters before the next glyph's cluster.
|
||||
fn before_glyph(&mut self, kern_px: &[f32], extra: &mut f32) {
|
||||
if let Some(&slot) = self.glyph_slot.get(self.emitted) {
|
||||
while self.applied < slot {
|
||||
*extra += kern_px.get(self.applied).copied().unwrap_or(0.0);
|
||||
self.applied += 1;
|
||||
}
|
||||
}
|
||||
self.emitted += 1;
|
||||
}
|
||||
|
||||
/// After the run's last glyph: its remaining clusters' kerning follows it.
|
||||
fn after_run(&mut self, slots: &[KernSlot], kern_px: &[f32], extra: &mut f32) {
|
||||
if self.emitted < self.glyph_slot.len() {
|
||||
return;
|
||||
}
|
||||
let end = slots.iter().rposition(|s| s.run == self.run).map_or(self.applied, |p| p + 1);
|
||||
while self.applied < end {
|
||||
*extra += kern_px.get(self.applied).copied().unwrap_or(0.0);
|
||||
self.applied += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What is accumulated while a line's glyph runs are emitted.
|
||||
struct LineState {
|
||||
cursors: Vec<KernCursor>,
|
||||
seen_runs: Vec<usize>,
|
||||
vinfo: Vec<VGlyph>,
|
||||
/// Horizontal-scale growth and kerning along the line.
|
||||
extra: f32,
|
||||
}
|
||||
|
||||
/// Fixed facts about the line being emitted.
|
||||
struct LineCtx<'a> {
|
||||
dx: f32,
|
||||
baseline: f32,
|
||||
slots: &'a [KernSlot],
|
||||
kern_px: &'a [f32],
|
||||
line_index: usize,
|
||||
}
|
||||
|
||||
/// The horizontal extent of one glyph run on the line.
|
||||
struct RunSpan {
|
||||
x0: f32,
|
||||
x1: f32,
|
||||
style: u32,
|
||||
}
|
||||
|
||||
/// A run style's horizontal scale; unset (zero or negative) is 100%.
|
||||
fn scale_of(st: &CharStyle) -> f32 {
|
||||
if st.horizontal_scale > 0.0 { st.horizontal_scale } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The clusters of `line` in visual order, per run.
|
||||
fn collect_kern_slots(line: &Line<'_>, para: &Para<'_>) -> Vec<KernSlot> {
|
||||
let env = para.env;
|
||||
let mut slots: Vec<KernSlot> = Vec::new();
|
||||
let mut line_runs: Vec<usize> = Vec::new();
|
||||
for item in line.items() {
|
||||
let PositionedLayoutItem::GlyphRun(gr) = item else {
|
||||
continue;
|
||||
};
|
||||
let run = gr.run();
|
||||
if line_runs.contains(&run.index()) {
|
||||
continue;
|
||||
}
|
||||
line_runs.push(run.index());
|
||||
let hs = env.styles.get(gr.style().brush.0 as usize).map_or(1.0, scale_of);
|
||||
for c in run.visual_clusters() {
|
||||
let mut gl = c.glyphs();
|
||||
let first = gl.next().map(|g| g.id);
|
||||
let last = gl.last().map(|g| g.id).or(first);
|
||||
slots.push(KernSlot {
|
||||
run: run.index(),
|
||||
start: c.text_range().start,
|
||||
first,
|
||||
last,
|
||||
font: run.font().clone(),
|
||||
coords: run.normalized_coords().to_vec(),
|
||||
size: run.font_size(),
|
||||
rtl: c.is_rtl(),
|
||||
blank: first.is_none() || c.is_space_or_nbsp() || c.text_range().end <= para.prefix.len(),
|
||||
upright: env.vertical && c.first_style().brush.1 != VClass::Rotate as u8,
|
||||
advance: c.advance() * hs,
|
||||
growth: c.advance() * (hs - 1.0),
|
||||
tab: para.tabs.binary_search(&c.text_range().start).is_ok(),
|
||||
});
|
||||
}
|
||||
}
|
||||
slots
|
||||
}
|
||||
|
||||
/// Both characters of a pair kern optically: a mode change splits shaping runs, which ends
|
||||
/// automatic kerning, as with Metrics.
|
||||
fn optical_pair(st: &CharStyle, next: &CharStyle, a: &KernSlot, b: &KernSlot) -> bool {
|
||||
st.kerning == Kerning::Optical
|
||||
&& next.kerning == Kerning::Optical
|
||||
&& st.kern == 0.0
|
||||
&& next.kern == 0.0
|
||||
&& !a.rtl
|
||||
&& !b.rtl
|
||||
&& !a.blank
|
||||
&& !b.blank
|
||||
&& !a.upright
|
||||
&& !b.upright
|
||||
&& a.font.data.id() == b.font.data.id()
|
||||
&& a.font.index == b.font.index
|
||||
&& a.coords == b.coords
|
||||
&& (a.size - b.size).abs() < 1e-3
|
||||
}
|
||||
|
||||
/// Optical kerning between two adjacent clusters (font units, with the size adjustment), if the
|
||||
/// font has the pair.
|
||||
fn optical_kern(optical: &mut crate::optical::Cache, st: &CharStyle, a: &KernSlot, b: &KernSlot) -> Option<f32> {
|
||||
let (l, r) = (a.last?, b.first?);
|
||||
let coords: Vec<NormalizedCoord> = a.coords.iter().map(|&c| NormalizedCoord::from_bits(c)).collect();
|
||||
let k = optical.pair(a.font.data.id(), a.font.data.as_ref(), a.font.index, &coords, l, r)?;
|
||||
Some(k + crate::optical::size_adjust(st.size_pt))
|
||||
}
|
||||
|
||||
/// One kerning cursor per run on `line`.
|
||||
fn kern_cursors(line: &Line<'_>, slots: &[KernSlot]) -> Vec<KernCursor> {
|
||||
let mut cursors: Vec<KernCursor> = Vec::new();
|
||||
for item in line.items() {
|
||||
let PositionedLayoutItem::GlyphRun(gr) = item else {
|
||||
continue;
|
||||
};
|
||||
let run = gr.run();
|
||||
if cursors.iter().any(|c| c.run == run.index()) {
|
||||
continue;
|
||||
}
|
||||
let base = slots.iter().position(|s| s.run == run.index()).unwrap_or(0);
|
||||
cursors.push(KernCursor { run: run.index(), glyph_slot: glyph_slots(run, base), emitted: 0, applied: base });
|
||||
}
|
||||
cursors
|
||||
}
|
||||
|
||||
/// For each glyph of `run`, the slot of its cluster (the run's clusters start at slot `base`).
|
||||
fn glyph_slots(run: &Run<'_, RunBrush>, base: usize) -> Vec<usize> {
|
||||
let mut g2s = Vec::new();
|
||||
for (ci, c) in run.visual_clusters().enumerate() {
|
||||
g2s.extend(c.glyphs().map(|_| base + ci));
|
||||
}
|
||||
g2s
|
||||
}
|
||||
|
||||
/// Distributes `slack` between the clusters of the line from `c0` (and their glyphs from `g0`);
|
||||
/// returns what was added to the line's length.
|
||||
fn justify_line(out: &mut TextLayout, c0: usize, g0: usize, slack: f32) -> f32 {
|
||||
let n = out.clusters.len() - c0;
|
||||
if n <= 1 || slack <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let step = slack / (n - 1) as f32;
|
||||
let starts: Vec<f32> = out.clusters[c0..].iter().map(|c| c.x).collect();
|
||||
for (i, c) in out.clusters[c0..].iter_mut().enumerate() {
|
||||
c.x += step * i as f32;
|
||||
}
|
||||
for g in &mut out.glyphs[g0..] {
|
||||
let i = starts.iter().rposition(|&s| s <= g.x + 1e-3).unwrap_or(0);
|
||||
g.x += step * i as f32;
|
||||
}
|
||||
slack
|
||||
}
|
||||
|
||||
/// Line space → text space (90° clockwise); upright glyphs centred on the column.
|
||||
fn rotate_to_text_space(glyphs: &mut [PlacedGlyph], vinfo: &[VGlyph], baseline: f32) {
|
||||
for (g, v) in glyphs.iter_mut().zip(vinfo) {
|
||||
let (u, cross) = (g.x, g.y);
|
||||
if v.upright {
|
||||
g.x = -baseline - v.advance * 0.5;
|
||||
g.y = u + v.origin;
|
||||
g.orient = GlyphOrient::Upright;
|
||||
} else {
|
||||
g.x = -(cross + v.centre);
|
||||
g.y = u;
|
||||
g.orient = GlyphOrient::Rotated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Underline and strikethrough rects of one run.
|
||||
fn push_decorations(out: &mut TextLayout, vertical: bool, gr: &GlyphRun<'_>, baseline: f32, shift: f32, span: &RunSpan) {
|
||||
let rm = gr.run().metrics();
|
||||
let (x0, x1, si) = (span.x0, span.x1, span.style);
|
||||
if vertical {
|
||||
// Underline to the right of the column, strikethrough through its centre
|
||||
// (stored directly in text space).
|
||||
let half = gr.run().font_size() * 0.5;
|
||||
let mut push = |v0: f32, v1: f32| out.decorations.push(DecorationRect { x0: -v1, y0: x0, x1: -v0, y1: x1, style: si });
|
||||
if gr.style().underline.is_some() {
|
||||
let v1 = baseline - half;
|
||||
push(v1 - rm.underline_size.max(1.0), v1);
|
||||
}
|
||||
if gr.style().strikethrough.is_some() {
|
||||
let sz = rm.strikethrough_size.max(1.0);
|
||||
push(baseline - sz * 0.5, baseline + sz * 0.5);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if gr.style().underline.is_some() {
|
||||
let y0 = baseline - rm.underline_offset - shift;
|
||||
out.decorations.push(DecorationRect { x0, y0, x1, y1: y0 + rm.underline_size.max(1.0), style: si });
|
||||
}
|
||||
if gr.style().strikethrough.is_some() {
|
||||
let y0 = baseline - rm.strikethrough_offset - shift;
|
||||
out.decorations.push(DecorationRect { x0, y0, x1, y1: y0 + rm.strikethrough_size.max(1.0), style: si });
|
||||
}
|
||||
}
|
||||
|
||||
impl Para<'_> {
|
||||
/// Kerning after each cluster (px): manual kerning plus optical pair kerning. Applied after
|
||||
/// shaping (like horizontal scale) in line space, so it moves the following glyphs, carets and
|
||||
/// the line extent; in vertical type that is along the column. Optical kerning measures
|
||||
/// horizontal outlines, so it applies to rotated (Latin) glyphs only there.
|
||||
fn kerning(&self, optical: &mut crate::optical::Cache, slots: &[KernSlot]) -> Vec<f32> {
|
||||
let env = self.env;
|
||||
let mut kern_px: Vec<f32> = vec![0.0; slots.len()];
|
||||
for j in 0..slots.len().saturating_sub(1) {
|
||||
let (a, b) = (&slots[j], &slots[j + 1]);
|
||||
if a.start < self.prefix.len() {
|
||||
continue;
|
||||
}
|
||||
let st = &env.styles[env.style_at(self.map_offset(a.start))];
|
||||
let next = &env.styles[env.style_at(self.map_offset(b.start))];
|
||||
let mut units = if st.kern.is_finite() { st.kern } else { 0.0 };
|
||||
if optical_pair(st, next, a, b) {
|
||||
units += optical_kern(optical, st, a, b).unwrap_or(0.0);
|
||||
}
|
||||
kern_px[j] = units / 1000.0 * a.size;
|
||||
}
|
||||
kern_px
|
||||
}
|
||||
|
||||
/// Tabs: the "kerning" after a tab takes the pen to the next stop, measured from the anchor or
|
||||
/// the box edge as if the line were left-aligned (alignment then moves the whole line).
|
||||
/// Returns the tab width and horizontal-scale growth in the line's trailing whitespace, which
|
||||
/// is outside its extent.
|
||||
fn apply_tab_stops(&self, slots: &[KernSlot], kern_px: &mut [f32], line_index: usize) -> (f32, f32) {
|
||||
let mut pos = self.indent_start + if line_index == 0 { self.first_indent } else { 0.0 };
|
||||
let mut trailing_tabs = 0.0f32;
|
||||
let mut trailing_growth = 0.0f32;
|
||||
for (j, s) in slots.iter().enumerate() {
|
||||
let rest_blank = slots[j + 1..].iter().all(|n| n.blank);
|
||||
if s.blank && rest_blank {
|
||||
trailing_growth += s.growth;
|
||||
}
|
||||
if s.tab {
|
||||
kern_px[j] = super::next_tab_stop(pos, self.tab_px) - pos - s.advance;
|
||||
if rest_blank {
|
||||
trailing_tabs += kern_px[j];
|
||||
}
|
||||
}
|
||||
pos += s.advance + kern_px.get(j).copied().unwrap_or(0.0);
|
||||
}
|
||||
(trailing_tabs, trailing_growth)
|
||||
}
|
||||
|
||||
/// Where the line starts along its extent: box and area lines at their box or span edge,
|
||||
/// point lines around the anchor.
|
||||
fn line_start(&self, f: &LineFrame, offset: f32, adv: f32, line_kern: f32) -> f32 {
|
||||
if !self.env.wraps {
|
||||
let target = match self.ps.align {
|
||||
TextAlign::Center | TextAlign::JustifyCenter => -(adv + line_kern) / 2.0,
|
||||
TextAlign::Right | TextAlign::JustifyRight => -(adv + line_kern),
|
||||
_ => self.indent_start,
|
||||
};
|
||||
return target - offset;
|
||||
}
|
||||
// Parley aligned box lines without the kerning.
|
||||
let kern_align = match self.ps.align {
|
||||
TextAlign::Center => line_kern / 2.0,
|
||||
TextAlign::Right => line_kern,
|
||||
_ => 0.0,
|
||||
};
|
||||
let origin = f.slot.map_or(self.line_origin, |s| s.x);
|
||||
let base = origin + self.indent_start - kern_align;
|
||||
// The line's own width takes the justify-last-line slack.
|
||||
let slack = self.line_avail(f).unwrap_or(0.0) - adv * self.break_scale - line_kern;
|
||||
// Parley aligned the unscaled line in `avail / break_scale`: scale its
|
||||
// indent and alignment offset back to the box.
|
||||
let scaled_offset = offset * (self.break_scale - 1.0);
|
||||
base + if f.last {
|
||||
match self.ps.align {
|
||||
TextAlign::JustifyCenter => slack * 0.5 - offset,
|
||||
TextAlign::JustifyRight => slack - offset,
|
||||
_ => scaled_offset,
|
||||
}
|
||||
} else {
|
||||
scaled_offset
|
||||
}
|
||||
}
|
||||
|
||||
/// The width the line may fill: its area slot's, else the box's.
|
||||
fn line_avail(&self, f: &LineFrame) -> Option<f32> {
|
||||
f.slot.map(|s| s.width).or(self.avail)
|
||||
}
|
||||
|
||||
/// Where line `index` of `nlines` goes, advancing `flow` past it; `None` if a box clips it.
|
||||
pub(super) fn frame_line(&self, line: &Line<'_>, index: usize, nlines: usize, slot: Option<Slot>, flow: &mut Flow) -> Option<LineFrame> {
|
||||
let env = self.env;
|
||||
let LineMetrics { ascent, descent, leading } = line_metrics(line, env.styles, &self.ps, env.k, self.first_style(), env.vertical);
|
||||
let (box_x, box_y, box_w, box_h) = env.box_rect;
|
||||
let baseline = match (slot, flow.prev_baseline) {
|
||||
(Some(s), _) => s.baseline,
|
||||
// The first column's right edge touches the box's right edge.
|
||||
// (In vertical type the ascent is half the column width.)
|
||||
(None, None) if env.is_box && env.vertical => -(box_x + box_w) + ascent,
|
||||
// Photoshop's "first baseline: ascent": the top of the tallest ascender
|
||||
// (height of 'd') touches the box top, not the font's hhea ascent.
|
||||
(None, None) if env.is_box => box_y + first_ascent(line).unwrap_or(ascent),
|
||||
(None, None) => 0.0,
|
||||
(None, Some(b)) => b + leading + flow.pending_space,
|
||||
};
|
||||
flow.pending_space = 0.0;
|
||||
// Photoshop keeps a horizontal line whose baseline is inside the box and lets
|
||||
// its descenders hang below the bottom edge (#1317: three 35 pt lines in a
|
||||
// 91.3 pt box, the last baseline at 88). Requiring the descent to fit dropped
|
||||
// that line.
|
||||
let overflow = if env.vertical { baseline + descent > -box_x + 0.5 } else { baseline > box_y + box_h + 0.5 };
|
||||
if env.is_box && overflow {
|
||||
return None;
|
||||
}
|
||||
flow.prev_baseline = Some(baseline);
|
||||
Some(LineFrame { index, last: index + 1 == nlines, slot, baseline, ascent, descent })
|
||||
}
|
||||
|
||||
/// Appends one line's clusters, glyphs, decorations and `LineInfo` to `out`.
|
||||
pub(super) fn place_line(&self, optical: &mut crate::optical::Cache, out: &mut TextLayout, line: &Line<'_>, f: &LineFrame) {
|
||||
let env = self.env;
|
||||
let m = *line.metrics();
|
||||
let adv = m.advance - m.trailing_whitespace;
|
||||
let slots = collect_kern_slots(line, self);
|
||||
let mut kern_px = self.kerning(optical, &slots);
|
||||
let (trailing_tabs, trailing_growth) = self.apply_tab_stops(&slots, &mut kern_px, f.index);
|
||||
let line_kern: f32 = kern_px.iter().sum::<f32>() - trailing_tabs;
|
||||
let dx = self.line_start(f, m.offset, adv, line_kern);
|
||||
let lc = LineCtx { dx, baseline: f.baseline, slots: &slots, kern_px: &kern_px, line_index: out.lines.len() };
|
||||
let (g0, c0) = (out.glyphs.len(), out.clusters.len());
|
||||
let mut state = LineState { cursors: kern_cursors(line, &slots), seen_runs: Vec::new(), vinfo: Vec::new(), extra: 0.0 };
|
||||
for item in line.items() {
|
||||
if let PositionedLayoutItem::GlyphRun(gr) = item {
|
||||
self.emit_run(out, &lc, &mut state, &gr);
|
||||
}
|
||||
}
|
||||
if env.vertical {
|
||||
state.extra -= squeeze_punctuation(env.text, &mut out.clusters[c0..], &mut out.glyphs[g0..]);
|
||||
}
|
||||
if env.wraps && f.last && self.ps.align == TextAlign::JustifyAll {
|
||||
let slack = self.line_avail(f).unwrap_or(0.0) - (adv + state.extra);
|
||||
state.extra += justify_line(out, c0, g0, slack);
|
||||
}
|
||||
if env.vertical {
|
||||
rotate_to_text_space(&mut out.glyphs[g0..], &state.vinfo, f.baseline);
|
||||
}
|
||||
let lr = line.text_range();
|
||||
let x0 = dx + m.offset;
|
||||
out.lines.push(LineInfo {
|
||||
range: self.map_offset(lr.start)..self.map_offset(lr.end).min(self.content_end),
|
||||
baseline: f.baseline,
|
||||
x0,
|
||||
x1: x0 + adv + state.extra - trailing_tabs - trailing_growth,
|
||||
ascent: f.ascent,
|
||||
descent: f.descent,
|
||||
paragraph: self.index,
|
||||
});
|
||||
}
|
||||
|
||||
/// Emits one glyph run of the line: its face, clusters (once per shaping run), glyphs and
|
||||
/// decorations.
|
||||
fn emit_run(&self, out: &mut TextLayout, lc: &LineCtx<'_>, state: &mut LineState, gr: &GlyphRun<'_>) {
|
||||
let env = self.env;
|
||||
let run = gr.run();
|
||||
let si = gr.style().brush.0;
|
||||
let st = &env.styles[si as usize];
|
||||
let hs = scale_of(st);
|
||||
let synth = run.synthesis();
|
||||
let face = out.faces.len() as u32;
|
||||
out.faces.push(GlyphFace {
|
||||
font: run.font().clone(),
|
||||
coords: run.normalized_coords().to_vec(),
|
||||
size_px: run.font_size(),
|
||||
embolden: synthetic_bold(synth.embolden(), st.weight, run.font_attrs().weight.value()),
|
||||
skew_deg: synth.skew().unwrap_or(0.0),
|
||||
});
|
||||
let run_x0 = lc.dx + gr.offset() + state.extra;
|
||||
if !state.seen_runs.contains(&run.index()) {
|
||||
state.seen_runs.push(run.index());
|
||||
self.push_clusters(out, lc, run, hs, run_x0);
|
||||
}
|
||||
self.push_glyphs(out, lc, state, gr, face, hs);
|
||||
let span = RunSpan { x0: run_x0, x1: lc.dx + gr.offset() + gr.advance() + state.extra, style: si };
|
||||
push_decorations(out, env.vertical, gr, lc.baseline, st.baseline_shift_pt * env.k, &span);
|
||||
}
|
||||
|
||||
/// The caret clusters of a shaping run, with kerning and horizontal scale in their advances.
|
||||
fn push_clusters(&self, out: &mut TextLayout, lc: &LineCtx<'_>, run: &Run<'_, RunBrush>, hs: f32, run_x0: f32) {
|
||||
let mut cx = run_x0;
|
||||
let base = lc.slots.iter().position(|s| s.run == run.index()).unwrap_or(0);
|
||||
for (ci, c) in run.visual_clusters().enumerate() {
|
||||
let a = c.advance() * hs + lc.kern_px.get(base + ci).copied().unwrap_or(0.0);
|
||||
let r = c.text_range();
|
||||
if r.end > self.prefix.len() || self.prefix.is_empty() {
|
||||
out.clusters.push(ClusterInfo {
|
||||
range: self.map_offset(r.start)..self.map_offset(r.end),
|
||||
x: cx,
|
||||
advance: a,
|
||||
line: lc.line_index,
|
||||
rtl: c.is_rtl(),
|
||||
});
|
||||
}
|
||||
cx += a;
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyphs of one run, with the kerning of the clusters before each.
|
||||
fn push_glyphs(&self, out: &mut TextLayout, lc: &LineCtx<'_>, state: &mut LineState, gr: &GlyphRun<'_>, face: u32, hs: f32) {
|
||||
let run = gr.run();
|
||||
let rm = run.metrics();
|
||||
let vm = if self.env.vertical { Some(VMetrics::new(run.font(), run.normalized_coords(), run.font_size())) } else { None };
|
||||
let class = gr.style().brush.1;
|
||||
// Glyphs of the run's own characters (not substituted by `vert`).
|
||||
let plain: Vec<u32> = match (&vm, class) {
|
||||
(Some(v), 2) => self.text_of(run.text_range()).chars().filter_map(|c| v.cmap(c)).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let mut pen = gr.offset();
|
||||
let mut cursor = state.cursors.iter_mut().find(|c| c.run == run.index());
|
||||
for g in gr.glyphs() {
|
||||
if let Some(c) = cursor.as_deref_mut() {
|
||||
c.before_glyph(lc.kern_px, &mut state.extra);
|
||||
}
|
||||
out.glyphs.push(PlacedGlyph {
|
||||
face,
|
||||
id: g.id,
|
||||
x: lc.dx + pen + g.x + state.extra,
|
||||
y: lc.baseline + g.y,
|
||||
style: gr.style().brush.0,
|
||||
orient: GlyphOrient::Horizontal,
|
||||
});
|
||||
if let Some(v) = &vm {
|
||||
let upright = class == 1 || (class == 2 && !plain.contains(&g.id));
|
||||
state.vinfo.push(VGlyph {
|
||||
upright,
|
||||
advance: g.advance * hs,
|
||||
origin: if upright { v.origin(g.id) } else { 0.0 },
|
||||
// Rotated text is centred on the column: its baseline sits
|
||||
// (ascent − descent) / 2 from the centre line.
|
||||
centre: (rm.ascent - rm.descent) * 0.5,
|
||||
});
|
||||
}
|
||||
state.extra += g.advance * (hs - 1.0);
|
||||
pen += g.advance;
|
||||
}
|
||||
if let Some(c) = cursor {
|
||||
c.after_run(lc.slots, lc.kern_px, &mut state.extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::TextLayout;
|
||||
|
||||
/// Byte offset of character `index` (the text length when `index` is past the end).
|
||||
pub fn byte_index(text: &str, index: usize) -> usize {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{CharStyle, Layout, PositionedLayoutItem, Range, RunBrush};
|
||||
|
||||
/// Photoshop's default tab stops: every half inch, measured from the anchor of point text or the
|
||||
/// left edge of a paragraph box. PSD type carries no custom tab stops, so these are the only ones.
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Per-paragraph preparation of a text layer's layout: the paragraph's shaped text (prefix, case
|
||||
//! mapping, tabs), the parley shaping pass over it, and its line breaking.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::cell::Cell;
|
||||
use std::ops::Range;
|
||||
|
||||
use parley::{Alignment, AlignmentOptions, FontFeatures, Layout, LayoutContext, StyleProperty};
|
||||
use photocraft_doc::text::{CharStyle, ParagraphStyle, TextAlign, TextDirection};
|
||||
|
||||
use super::area::{LineMetrics, MAX_AREA_PASSES, area_metrics, break_area};
|
||||
use super::paragraphs::{TabWidth, strip_break, tab_widths};
|
||||
use super::style::{
|
||||
SYNTHETIC_SMALL_CAPS_SCALE, SmallCapsMode, drawn_style, effective_horizontal_scale, feature_list, hangul_runs, probe_smcp, small_caps_mode, style_props,
|
||||
};
|
||||
use super::vertical::vertical_class;
|
||||
use super::{Env, FORCED_LINE_BREAK, Flow, LRM, RLM, RunBrush, Slot, VClass};
|
||||
use crate::fonts::FontDb;
|
||||
use crate::layout::DEFAULT_TAB_STOP_PT;
|
||||
|
||||
/// The character styles of `runs`, with families resolved (PostScript names from PSDs, unknown
|
||||
/// families) and drawn the way they are laid out.
|
||||
pub(super) fn resolve_run_styles(fonts: &mut FontDb, runs: &[photocraft_doc::text::TextRun]) -> Vec<CharStyle> {
|
||||
runs.iter()
|
||||
.map(|r| {
|
||||
let mut s = r.style.clone();
|
||||
adopt_postscript_face(fonts, &mut s);
|
||||
// Drawn with a fallback for now; a host that serves this family fetches it (`served`).
|
||||
if !fonts.has_family(&s.font_family) {
|
||||
crate::served::request(&s.font_family);
|
||||
}
|
||||
fonts.select_named_face(&mut s);
|
||||
drawn_style(&mut s);
|
||||
s
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A style naming a PostScript face the family doesn't carry takes the family the face resolves to.
|
||||
fn adopt_postscript_face(fonts: &mut FontDb, s: &mut CharStyle) {
|
||||
let Some(ps) = s.postscript_name.clone() else {
|
||||
return;
|
||||
};
|
||||
if fonts.faces(&s.font_family).iter().any(|f| f.postscript_name.as_deref() == Some(&ps)) {
|
||||
return;
|
||||
}
|
||||
let f = fonts.resolve_postscript(&ps);
|
||||
// An exact face match wins; a guessed family only fills a missing family.
|
||||
if f.exact || (!fonts.has_family(&s.font_family) && fonts.has_family(&f.family)) {
|
||||
s.font_family = f.family;
|
||||
s.weight = f.weight;
|
||||
s.italic = f.italic;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start offset of each item, given the items' lengths.
|
||||
pub(super) fn running_starts(lens: impl Iterator<Item = usize>) -> Vec<usize> {
|
||||
lens.scan(0, |a, len| {
|
||||
let s = *a;
|
||||
*a += len;
|
||||
Some(s)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How each style sets small caps. Fixed for the whole layout, and the font probe only matters for
|
||||
/// styles that use small caps. A real `smcp` substitution must receive lowercase source text; only
|
||||
/// the fallback path uppercases it and scales it down.
|
||||
pub(super) fn small_caps_modes(fonts: &mut FontDb, styles: &[CharStyle]) -> Vec<SmallCapsMode> {
|
||||
styles
|
||||
.iter()
|
||||
.map(|style| match style.caps {
|
||||
photocraft_doc::text::Caps::SmallCaps => small_caps_mode(style.caps, probe_smcp(fonts, style)),
|
||||
_ => SmallCapsMode::None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The text handed to the shaper for one paragraph.
|
||||
struct ParaText {
|
||||
ptext: String,
|
||||
/// Ranges in `ptext` for lowercase characters rendered as synthetic small caps. They have the
|
||||
/// same UTF-8 length as their uppercase form, so the layer's byte-based run and caret offsets
|
||||
/// remain unchanged.
|
||||
synthetic_small_caps: Vec<(Range<usize>, f32)>,
|
||||
/// Offsets in `ptext` of the spaces standing in for tabs.
|
||||
tabs: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The shaped text of the paragraph at `range` (`content_end` excludes its break), led by `prefix`.
|
||||
fn paragraph_text(env: &Env<'_>, prefix: &str, range: &Range<usize>, content_end: usize) -> ParaText {
|
||||
let content = &env.text[range.start..content_end];
|
||||
let mut ptext = String::with_capacity(prefix.len() + content.len());
|
||||
ptext.push_str(prefix);
|
||||
let mut synthetic_small_caps = Vec::new();
|
||||
let mut tabs = Vec::new();
|
||||
for (i, ch) in content.char_indices() {
|
||||
// A forced line break ends the line but not the paragraph. The line breaker knows
|
||||
// it as a newline, which has the same length, so text offsets don't move.
|
||||
if ch == FORCED_LINE_BREAK {
|
||||
ptext.push('\n');
|
||||
continue;
|
||||
}
|
||||
// Imported PSD text can contain literal tab controls. Font shaping may
|
||||
// render those as .notdef boxes; a space preserves the one-byte source
|
||||
// and style/caret offsets while supplying a real whitespace advance. The space
|
||||
// is widened to its tab stop after line breaking (`tab_px`).
|
||||
if ch == '\t' {
|
||||
tabs.push(ptext.len());
|
||||
ptext.push(' ');
|
||||
continue;
|
||||
}
|
||||
let si = env.style_at(range.start + i);
|
||||
let synthetic = env.small_caps[si] == SmallCapsMode::Synthetic;
|
||||
push_cased(&mut ptext, &mut synthetic_small_caps, ch, &env.styles[si], synthetic, env.k);
|
||||
}
|
||||
ParaText { ptext, synthetic_small_caps, tabs }
|
||||
}
|
||||
|
||||
/// Appends `ch` as its style sets it: uppercased for all caps and synthetic small caps (when the
|
||||
/// uppercase form has the same length, so offsets don't move), recording small-cap pieces.
|
||||
fn push_cased(ptext: &mut String, small_caps: &mut Vec<(Range<usize>, f32)>, ch: char, style: &CharStyle, synthetic: bool, k: f32) {
|
||||
let caps = style.caps;
|
||||
if caps == photocraft_doc::text::Caps::AllCaps || synthetic {
|
||||
let up: String = ch.to_uppercase().collect();
|
||||
if up.len() == ch.len_utf8() {
|
||||
ptext.push_str(&up);
|
||||
if caps == photocraft_doc::text::Caps::SmallCaps && ch.is_lowercase() {
|
||||
let end = ptext.len();
|
||||
small_caps.push((end - up.len()..end, style.size_pt * k * SYNTHETIC_SMALL_CAPS_SCALE));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
ptext.push(ch);
|
||||
}
|
||||
|
||||
/// One run piece of vertical type, whose upright stretches get the vertical alternates and their
|
||||
/// class in the brush.
|
||||
struct VerticalPiece<'a> {
|
||||
style: &'a CharStyle,
|
||||
run: usize,
|
||||
small_caps: SmallCapsMode,
|
||||
/// Offset of the piece in the paragraph text.
|
||||
start: usize,
|
||||
}
|
||||
|
||||
impl VerticalPiece<'_> {
|
||||
fn push_upright(&self, b: &mut parley::RangedBuilder<'_, RunBrush>, from: usize, to: usize, cls: VClass) {
|
||||
if cls == VClass::Rotate || from >= to {
|
||||
return;
|
||||
}
|
||||
let mut feats = feature_list(self.style, self.small_caps);
|
||||
feats.push("\"vert\" 1".into());
|
||||
let r = (self.start + from)..(self.start + to);
|
||||
b.push(StyleProperty::FontFeatures(FontFeatures::Source(Cow::Owned(feats.join(", ")))), r.clone());
|
||||
b.push(StyleProperty::Brush(RunBrush(self.run as u32, cls as u8)), r);
|
||||
}
|
||||
|
||||
/// Splits `piece` into stretches of one vertical class and styles each.
|
||||
fn push_all(&self, b: &mut parley::RangedBuilder<'_, RunBrush>, piece: &str) {
|
||||
let mut seg: Option<(usize, VClass)> = None;
|
||||
for (i, ch) in piece.char_indices() {
|
||||
let cls = vertical_class(ch);
|
||||
match seg {
|
||||
Some((_, c)) if c == cls => {}
|
||||
Some((from, c)) => {
|
||||
self.push_upright(b, from, i, c);
|
||||
seg = Some((i, cls));
|
||||
}
|
||||
None => seg = Some((i, cls)),
|
||||
}
|
||||
}
|
||||
if let Some((from, c)) = seg {
|
||||
self.push_upright(b, from, piece.len(), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What line placement needs of one paragraph: its text, styles, indents and the extents its lines
|
||||
/// break at.
|
||||
pub(super) struct Para<'a> {
|
||||
pub(super) env: &'a Env<'a>,
|
||||
pub(super) index: usize,
|
||||
pub(super) ps: ParagraphStyle,
|
||||
pub(super) start: usize,
|
||||
pub(super) content_end: usize,
|
||||
pub(super) prefix: &'static str,
|
||||
ptext: String,
|
||||
synthetic_small_caps: Vec<(Range<usize>, f32)>,
|
||||
/// Offsets in the paragraph text of the spaces standing in for tabs.
|
||||
pub(super) tabs: Vec<usize>,
|
||||
/// Runs intersecting the paragraph: `si0..si_end`.
|
||||
si0: usize,
|
||||
si_end: usize,
|
||||
fallback: Vec<String>,
|
||||
pub(super) indent_start: f32,
|
||||
indent_end: f32,
|
||||
pub(super) first_indent: f32,
|
||||
/// Box extent along the lines: where they start.
|
||||
pub(super) line_origin: f32,
|
||||
/// Box extent along the lines, less the indents; `None` for point text.
|
||||
pub(super) avail: Option<f32>,
|
||||
pub(super) break_scale: f32,
|
||||
pub(super) tab_px: f32,
|
||||
/// Line-breaking passes left for this paragraph's area type (see `break_area`).
|
||||
area_budget: Cell<usize>,
|
||||
}
|
||||
|
||||
impl<'a> Para<'a> {
|
||||
pub(super) fn new(env: &'a Env<'a>, fonts: &FontDb, index: usize, range: Range<usize>, ps: ParagraphStyle) -> Self {
|
||||
let k = env.k;
|
||||
let content_end = strip_break(env.text, &range);
|
||||
let prefix = match ps.direction {
|
||||
TextDirection::Auto => "",
|
||||
TextDirection::Ltr => LRM,
|
||||
TextDirection::Rtl => RLM,
|
||||
};
|
||||
let ParaText { ptext, synthetic_small_caps, tabs } = paragraph_text(env, prefix, &range, content_end);
|
||||
// Scripts the loaded fonts may lack (Arabic, Japanese, …): a host serving a font for them
|
||||
// fetches it (`served`); it joins the fallback stack once it arrives.
|
||||
crate::served::request_for_text(&ptext);
|
||||
let fallback = fonts.fallback_stack().map(str::to_string).collect();
|
||||
let si0 = env.style_at(range.start);
|
||||
let si_end = env.run_starts.partition_point(|&rs| rs < content_end).max(si0 + 1).min(env.styles.len());
|
||||
let indent_start = ps.start_indent_pt * k;
|
||||
let indent_end = ps.end_indent_pt * k;
|
||||
let first_indent = ps.first_line_indent_pt * k;
|
||||
// Box extent along the lines: width, or height for vertical type (columns).
|
||||
let (box_x, box_y, box_w, box_h) = env.box_rect;
|
||||
let (line_origin, line_len) = if env.vertical { (box_y, box_h) } else { (box_x, box_w) };
|
||||
let avail = if env.is_box { Some((line_len - indent_start - indent_end).max(1.0)) } else { None };
|
||||
let mut para = Self {
|
||||
env,
|
||||
index,
|
||||
ps,
|
||||
start: range.start,
|
||||
content_end,
|
||||
prefix,
|
||||
ptext,
|
||||
synthetic_small_caps,
|
||||
tabs,
|
||||
si0,
|
||||
si_end,
|
||||
fallback,
|
||||
indent_start,
|
||||
indent_end,
|
||||
first_indent,
|
||||
line_origin,
|
||||
avail,
|
||||
break_scale: 1.0,
|
||||
tab_px: DEFAULT_TAB_STOP_PT * k,
|
||||
area_budget: Cell::new(MAX_AREA_PASSES),
|
||||
};
|
||||
para.break_scale = para.horizontal_break_scale();
|
||||
para
|
||||
}
|
||||
|
||||
pub(super) fn first_style(&self) -> &'a CharStyle {
|
||||
&self.env.styles[self.si0]
|
||||
}
|
||||
|
||||
/// Text offset of the paragraph text's byte `o`, as the layer's text offset.
|
||||
pub(super) fn map_offset(&self, o: usize) -> usize {
|
||||
(self.start + o.saturating_sub(self.prefix.len())).min(self.content_end)
|
||||
}
|
||||
|
||||
/// The paragraph text up to the end of the run piece at text range `range`.
|
||||
pub(super) fn text_of(&self, range: Range<usize>) -> &str {
|
||||
self.ptext.get(range).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Horizontal scale is applied after shaping, so paragraph text breaks at the box (or area span)
|
||||
/// width divided by it (#5565). Mixed scales break at the largest, so no line overflows the box.
|
||||
fn horizontal_break_scale(&self) -> f32 {
|
||||
let env = self.env;
|
||||
if !env.wraps {
|
||||
return 1.0;
|
||||
}
|
||||
let scale = (self.si0..self.si_end)
|
||||
.filter(|&ri| {
|
||||
let rs = env.run_starts.get(ri).copied().unwrap_or(0);
|
||||
let end = rs.saturating_add(env.runs.get(ri).map_or(0, |r| r.len));
|
||||
ri == self.si0 || (rs.max(self.start) < end.min(self.content_end))
|
||||
})
|
||||
.filter_map(|ri| env.styles.get(ri))
|
||||
.map(effective_horizontal_scale)
|
||||
.fold(0.0f32, f32::max);
|
||||
if scale > 0.0 { scale } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Shapes the paragraph, each tab widened by its letter spacing in `tab_spacing` (px).
|
||||
fn shape(&self, lcx: &mut LayoutContext<RunBrush>, fcx: &mut parley::FontContext, tab_spacing: &[f32]) -> Layout<RunBrush> {
|
||||
let env = self.env;
|
||||
let mut b = lcx.ranged_builder(fcx, &self.ptext, 1.0, false);
|
||||
// Paragraph-start style as the default (covers the direction mark and empty
|
||||
// paragraphs), then every run piece intersecting this paragraph.
|
||||
for p in style_props(&env.styles[self.si0], env.k, &self.fallback, self.si0 as u32, env.small_caps[self.si0]) {
|
||||
b.push_default(p);
|
||||
}
|
||||
// Paragraph text breaks a word wider than the box at the box edge, as Photoshop
|
||||
// does (#6694): parley's default only breaks at UAX #14 opportunities and lets the
|
||||
// word run out of the box. Point text has no width to overflow.
|
||||
if env.is_box {
|
||||
b.push_default(StyleProperty::OverflowWrap(parley::style::OverflowWrap::BreakWord));
|
||||
}
|
||||
for ri in self.si0..self.si_end {
|
||||
self.push_run_piece(&mut b, ri);
|
||||
}
|
||||
for (range, size) in &self.synthetic_small_caps {
|
||||
b.push(StyleProperty::FontSize(*size), range.clone());
|
||||
}
|
||||
// Korean breaks lines at spaces, between words, as Photoshop sets it; Unicode's
|
||||
// default rules break between any two Hangul syllables, mid-word (#5959).
|
||||
for range in hangul_runs(&self.ptext) {
|
||||
b.push(StyleProperty::WordBreak(parley::WordBreak::KeepAll), range);
|
||||
}
|
||||
for (&at, &spacing) in self.tabs.iter().zip(tab_spacing) {
|
||||
b.push(StyleProperty::LetterSpacing(spacing), at..at + 1);
|
||||
}
|
||||
b.build(&self.ptext)
|
||||
}
|
||||
|
||||
/// Styles the part of run `ri` inside this paragraph.
|
||||
fn push_run_piece(&self, b: &mut parley::RangedBuilder<'_, RunBrush>, ri: usize) {
|
||||
let env = self.env;
|
||||
let rs = env.run_starts[ri];
|
||||
let (a, z) = (rs.max(self.start), (rs + env.runs[ri].len).min(self.content_end));
|
||||
if a >= z {
|
||||
return;
|
||||
}
|
||||
let range = (a - self.start + self.prefix.len())..(z - self.start + self.prefix.len());
|
||||
for p in style_props(&env.styles[ri], env.k, &self.fallback, ri as u32, env.small_caps[ri]) {
|
||||
b.push(p, range.clone());
|
||||
}
|
||||
if env.vertical {
|
||||
let piece = VerticalPiece { style: &env.styles[ri], run: ri, small_caps: env.small_caps[ri], start: range.start };
|
||||
piece.push_all(b, self.text_of(range));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an area paragraph's first row goes: no higher than one leading (plus paragraph
|
||||
/// spacing) below the previous paragraph's last line.
|
||||
fn area_origin(&self, m: LineMetrics, flow: &Flow) -> f32 {
|
||||
let space_before = if flow.prev_baseline.is_some() { self.ps.space_before_pt * self.env.k } else { 0.0 };
|
||||
match (&self.env.area, flow.prev_baseline) {
|
||||
(Some(_), Some(b)) => b + m.leading + flow.pending_space + space_before,
|
||||
(Some(a), None) => a.bounds[1] + m.ascent,
|
||||
(None, _) => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Breaks `layout` into lines; for area type also returns each line's slot and whether the
|
||||
/// shape ran full.
|
||||
fn break_lines(&self, layout: &mut Layout<RunBrush>, flow: &Flow) -> (Vec<Slot>, bool) {
|
||||
let env = self.env;
|
||||
if self.first_indent != 0.0 {
|
||||
layout.set_text_indent(self.first_indent / self.break_scale, parley::IndentOptions::default());
|
||||
}
|
||||
let Some(a) = &env.area else {
|
||||
layout.break_all_lines(self.avail.map(|a| a / self.break_scale));
|
||||
return (Vec::new(), false);
|
||||
};
|
||||
// Plan every row with the paragraph's real line metrics (its tallest font and
|
||||
// largest leading, measured on the unwrapped paragraph), so no wrapped line comes
|
||||
// out taller than the row it was planned in and no second plan is needed.
|
||||
layout.break_all_lines(None);
|
||||
let m = area_metrics(layout, env.styles, &self.ps, env.k, self.first_style(), env.vertical);
|
||||
let from = self.area_origin(m, flow);
|
||||
let first_px = self.first_style().size_pt * env.k;
|
||||
break_area(layout, a, from, m, (self.indent_start, self.indent_end), first_px, self.break_scale, &self.area_budget)
|
||||
}
|
||||
|
||||
/// The widths of the tabs on the broken lines of `layout`.
|
||||
fn tab_widths(&self, layout: &Layout<RunBrush>) -> Vec<TabWidth> {
|
||||
tab_widths(layout, &self.tabs, self.env.styles, self.indent_start, self.first_indent, self.tab_px)
|
||||
}
|
||||
|
||||
/// Shapes and breaks the paragraph into lines (and area slots).
|
||||
///
|
||||
/// A tab is never wider than a space plus one tab interval, so breaking lines at that width
|
||||
/// keeps the expanded tabs inside the box, but can wrap early. Break again with the tab widths
|
||||
/// that layout gives until they agree; if they don't settle, keep the safe layout.
|
||||
pub(super) fn break_paragraph(&self, lcx: &mut LayoutContext<RunBrush>, fcx: &mut parley::FontContext, flow: &Flow) -> (Layout<RunBrush>, Vec<Slot>, bool) {
|
||||
let wide = if self.env.wraps { self.tab_px } else { 0.0 };
|
||||
let mut layout = self.shape(lcx, fcx, &vec![wide; self.tabs.len()]);
|
||||
let (mut slots, mut full) = self.break_lines(&mut layout, flow);
|
||||
if self.env.wraps
|
||||
&& !self.tabs.is_empty()
|
||||
&& let Some((settled, settled_slots, settled_full)) = self.settle_tabs(lcx, fcx, &layout, flow)
|
||||
{
|
||||
layout = settled;
|
||||
slots = settled_slots;
|
||||
full = settled_full;
|
||||
}
|
||||
(layout, slots, full)
|
||||
}
|
||||
|
||||
/// Re-breaks until the tab widths the layout gives agree with the ones it was shaped for.
|
||||
fn settle_tabs(
|
||||
&self,
|
||||
lcx: &mut LayoutContext<RunBrush>,
|
||||
fcx: &mut parley::FontContext,
|
||||
layout: &Layout<RunBrush>,
|
||||
flow: &Flow,
|
||||
) -> Option<(Layout<RunBrush>, Vec<Slot>, bool)> {
|
||||
let first = self.tab_widths(layout);
|
||||
let mut want: Vec<f32> = first.iter().map(|t| t.width).collect();
|
||||
for _ in 0..4 {
|
||||
let spacing: Vec<f32> = first.iter().zip(&want).map(|(t, w)| w / t.scale - (t.advance - self.tab_px)).collect();
|
||||
let mut next = self.shape(lcx, fcx, &spacing);
|
||||
let (next_slots, next_full) = self.break_lines(&mut next, flow);
|
||||
let got: Vec<f32> = self.tab_widths(&next).iter().map(|t| t.width).collect();
|
||||
if got.iter().zip(&want).all(|(a, b)| (a - b).abs() < 0.01) {
|
||||
return Some((next, next_slots, next_full));
|
||||
}
|
||||
want = got;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// How parley aligns the paragraph's lines.
|
||||
pub(super) fn alignment(&self) -> Alignment {
|
||||
if !self.env.wraps {
|
||||
return Alignment::Left;
|
||||
}
|
||||
match self.ps.align {
|
||||
TextAlign::Left => Alignment::Left,
|
||||
TextAlign::Center => Alignment::Center,
|
||||
TextAlign::Right => Alignment::Right,
|
||||
_ => Alignment::Justify,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn align_options(&self) -> AlignmentOptions {
|
||||
AlignmentOptions { align_when_overflowing: !self.env.wraps }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::paragraphs::quote;
|
||||
use super::*;
|
||||
use super::{
|
||||
Caps, CharStyle, ClusterInfo, Cow, FontBaseline, FontDb, FontFamily, FontFeatures, FontStyle, FontVariations, FontWeight, Kerning, PlacedGlyph, RunBrush,
|
||||
StyleProperty, Tag,
|
||||
};
|
||||
|
||||
/// Photoshop synthesizes small caps for faces without an OpenType `smcp` table. Keep the same
|
||||
/// readable hierarchy for every font instead of silently rendering lowercase text unchanged.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::FontData;
|
||||
|
||||
/// How a character is set in vertical type (a simplification of Unicode's
|
||||
/// `Vertical_Orientation`, UAX #50).
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::edit::{TEMPLATE_NODES, edited_object, fits, is_stub, names_one_frame, new_object, pad_frames, td_edited_object, with_modes};
|
||||
use super::import::{OPENTYPE_KEYS, TXT2_FEATURES, TXT2_STYLISTIC_SETS, kern_units, manual, object_frame, pair_runs, parse_txt2, postscript_for, real};
|
||||
use super::*;
|
||||
use super::{
|
||||
AntiAlias, Caps, CharStyle, Color, ColorMode, D, Descriptor, E, FontBaseline, Id, Kerning, Orientation, ParagraphStyle, TextAlign, TextLayer, TextShape,
|
||||
TySh, UnicodeString, ed, engine_data, enum_value, enumv, parse_tysh, write_tysh,
|
||||
};
|
||||
|
||||
/// A fill colour as EngineData and `Txt2` store it: the colour type (0 gray, 1 RGB, 2 CMYK) and
|
||||
/// its values, alpha first.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::build::{MAX_TEXT_INDEX, utf16_len};
|
||||
use super::import::parse_txt2;
|
||||
use super::*;
|
||||
use super::{D, E, Id, parse_tysh, write_tysh};
|
||||
|
||||
/// The most values a `Txt2` object may hold to serve as the template for new objects. Photoshop's
|
||||
/// own objects hold a few hundred (611 at most in the file from #7698).
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::build::engine_antialias;
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, AntiAlias, Caps, CharStyle, Color, ColorMode, D, Descriptor, E, FontBaseline, Kerning, Orientation, ParagraphRun, ParagraphStyle, Point, TextAlign,
|
||||
TextLayer, TextRun, TextShape, TextWarp, TySh, dnum, ed, engine_data, enum_value, guess_from_postscript, parse_tysh,
|
||||
};
|
||||
|
||||
/// Parses the document's `Txt2` block (Photoshop's text engine data for all type layers).
|
||||
pub fn parse_txt2(data: &[u8]) -> Option<E> {
|
||||
|
||||
Reference in New Issue
Block a user