fix(type): preserve formatting for trailing empty paragraphs (#3198)

* fix(type): preserve formatting for trailing empty paragraphs

Represent an explicitly styled terminal empty paragraph with a zero-byte paragraph run, keep it through normalization, style only the caret's paragraph, and transfer that style when text is inserted at EOF. Cover line geometry, typing, and PSD text serialization.

* fix(type): retain imported terminal paragraph marker style

* cargo fmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nigel Ihza Firdausy 2026-10-11 16:36:52 +07:00 committed by GitHub
parent 11817fcb12
commit 59da288daa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 79 additions and 20 deletions

View File

@ -414,12 +414,20 @@ impl TextLayer {
.map(|(len, style)| text::TextRun { len, style })
.collect()
}
/// Paragraph runs covering exactly `text.len()` bytes.
/// Paragraph runs covering exactly `text.len()` bytes, with an optional zero-length
/// final run for an empty paragraph after a trailing line break.
pub fn paragraph_runs(&self) -> Vec<text::ParagraphRun> {
normalize_runs(&self.text, self.paragraphs.iter().map(|r| (r.len, r.style.clone())).collect(), text::ParagraphStyle::default())
.into_iter()
.map(|(len, style)| text::ParagraphRun { len, style })
.collect()
let mut runs = normalize_runs(&self.text, self.paragraphs.iter().map(|r| (r.len, r.style.clone())).collect(), text::ParagraphStyle::default());
// The final empty paragraph owns the synthetic terminal paragraph marker in PSD
// text. Its formatting has no source bytes; do not lose that last run when normalizing.
if self.text.ends_with(['\r', '\n'])
&& let Some((last, preceding)) = self.paragraphs.split_last()
&& last.len <= 1
&& preceding.iter().try_fold(0usize, |n, r| n.checked_add(r.len)) == Some(self.text.len())
{
runs.push((0, last.style.clone()));
}
runs.into_iter().map(|(len, style)| text::ParagraphRun { len, style }).collect()
}
/// Copies the first run's family, size and colour into the summary fields.
pub fn sync_summary(&mut self) {

View File

@ -357,15 +357,24 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P
.collect();
let style_at = |off: usize| old[starts.iter().rposition(|&s| s <= off).unwrap_or(0)].style.clone();
let mut out: Vec<(usize, ParagraphStyle)> = Vec::new();
let trailing_break = t.text.ends_with(['\r', '\n']);
for pr in photocraft_text::layout::split_paragraphs(&t.text) {
let mut st = style_at(pr.start);
let touches = (pr.start < b && pr.end > a) || (a == b && a >= pr.start && (a < pr.end || pr.end == t.text.len()));
let terminal_empty = pr.start == pr.end;
let touches =
(pr.start < b && pr.end > a) || (a == b && a >= pr.start && (a < pr.end || terminal_empty || (pr.end == t.text.len() && !trailing_break)));
if touches {
f(&mut st);
}
push_merge(&mut out, pr.len(), st.clone());
if out.is_empty() {
if terminal_empty && trailing_break {
// This paragraph has no bytes but must retain its own style for the
// next typed character and for the PSD's final paragraph marker.
out.push((0, st));
} else {
push_merge(&mut out, pr.len(), st.clone());
if out.is_empty() {
out.push((0, st));
}
}
}
t.paragraphs = out.into_iter().map(|(len, style)| ParagraphRun { len, style }).collect();
@ -374,18 +383,23 @@ pub fn style_paragraphs(t: &mut TextLayer, a: usize, b: usize, f: &dyn Fn(&mut P
/// Replaces bytes `a..b` with `new`, keeping styles: inserted text takes the style of the
/// character before it (or after it at the start).
pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) {
fn adjust<S: Clone + PartialEq>(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize) -> Vec<(usize, S)> {
fn adjust<S: Clone + PartialEq>(runs: Vec<(usize, S)>, a: usize, b: usize, ins: usize, trailing_paragraph: bool) -> Vec<(usize, S)> {
let mut out: Vec<(usize, S)> = Vec::new();
let mut at = 0;
let host = runs
.iter()
.scan(0, |acc, r| {
let s = *acc;
*acc += r.0;
Some((s, s + r.0))
})
.position(|(s, e)| if a == 0 { e > 0 || s == 0 } else { a > s && a <= e })
.unwrap_or(0);
let host = if trailing_paragraph && a == b && runs.last().is_some_and(|r| r.0 == 0) && a == runs.iter().map(|r| r.0).sum::<usize>() {
// Inserted text belongs to a styled final empty paragraph, not to
// the preceding paragraph whose break ends at this byte offset.
runs.len() - 1
} else {
runs.iter()
.scan(0, |acc, r| {
let s = *acc;
*acc += r.0;
Some((s, s + r.0))
})
.position(|(s, e)| if a == 0 { e > 0 || s == 0 } else { a > s && a <= e })
.unwrap_or(0)
};
for (i, (len, st)) in runs.into_iter().enumerate() {
let (rs, re) = (at, at + len);
at = re;
@ -401,9 +415,15 @@ pub fn replace_text(t: &mut TextLayer, a: usize, b: usize, new: &str) {
let pr: Vec<(usize, ParagraphStyle)> = t.paragraph_runs().into_iter().map(|r| (r.len, r.style)).collect();
let first_c = cr.first().map(|r| r.1.clone()).unwrap_or_default();
let first_p = pr.first().map(|r| r.1.clone()).unwrap_or_default();
let mut cr = adjust(cr, a, b, new.len());
let mut pr = adjust(pr, a, b, new.len());
let trailing_style = t.text.ends_with(['\r', '\n']).then(|| pr.last().filter(|r| r.0 == 0).map(|r| r.1.clone())).flatten();
let mut cr = adjust(cr, a, b, new.len(), false);
let mut pr = adjust(pr, a, b, new.len(), true);
t.text.replace_range(a..b, &new);
if t.text.ends_with(['\r', '\n'])
&& let Some(style) = trailing_style
{
pr.push((0, style));
}
if cr.is_empty() {
cr.push((t.text.len(), first_c));
}
@ -1093,6 +1113,37 @@ mod tests {
assert_eq!(t.runs.iter().map(|r| r.len).sum::<usize>(), t.text.len());
}
#[test]
fn trailing_empty_paragraph_styles_without_changing_previous_lines() {
let mut s = session();
let id = s.execute("type.create", json!({"text":"a\nb\n","x":20,"y":20,"size":12,"font":"Inter","align":"left"})).unwrap()["layer"].as_u64().unwrap();
let before = s.execute("type.info", json!({"layer":id})).unwrap();
let previous_line = before["lines"][1].clone();
let styled = s.execute("type.setStyle", json!({"layer":id,"range":[4,4],"align":"right"})).unwrap();
assert_eq!(styled["paragraphs"].as_array().unwrap().len(), 2);
assert_eq!(styled["paragraphs"][0]["start"], 0);
assert_eq!(styled["paragraphs"][0]["end"], 4);
assert_eq!(styled["paragraphs"][0]["style"]["align"], "Left");
assert_eq!(styled["paragraphs"][1]["start"], 4);
assert_eq!(styled["paragraphs"][1]["end"], 4);
assert_eq!(styled["paragraphs"][1]["style"]["align"], "Right");
assert_eq!(styled["lines"][1], previous_line, "the preceding paragraph must not move");
let raw = text_layer(&s, id).psd_raw.clone().unwrap();
let round_trip = photocraft_text::psd::text_layer_from_tysh(&raw, 72.0).unwrap();
assert_eq!(round_trip.paragraph_runs().last().unwrap().style.align, TextAlign::Right);
// Typing directly into the previously empty final paragraph retains its style.
let edited = s.execute("type.edit", json!({"layer":id,"replace":{"start":4,"end":4,"text":"c"}})).unwrap();
assert_eq!(edited["text"], "a\nb\nc");
assert_eq!(edited["paragraphs"][0]["end"], 4);
assert_eq!(edited["paragraphs"][0]["style"]["align"], "Left");
assert_eq!(edited["paragraphs"][1]["start"], 4);
assert_eq!(edited["paragraphs"][1]["end"], 5);
assert_eq!(edited["paragraphs"][1]["style"]["align"], "Right");
assert_eq!(edited["lines"][1], previous_line, "typing in the final paragraph does not move b");
}
#[test]
fn set_style_paragraph_and_box() {
let mut s = session();