From 398ad770773de836805a8b00ebac57ea98b66551 Mon Sep 17 00:00:00 2001 From: komo1iddin <45395275+komo1iddin@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:51:57 +0500 Subject: [PATCH] M7: rewrap a paragraph at a slant at the width its rows have MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A turned paragraph's width came from its rows' upright bounding boxes, turned into the baseline's frame. On a slant that box is wider than the row by the row's height times |sin 2θ| (about 12 pt for 12 pt text at 30°), so a rewrite let a word that doesn't fit stay on its row. Each run now also records its box along and across its baseline, from the same corners as its upright box, and a line unions those. Grouping and the rewrite's width read that box. Upright and quarter-turned text are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/tests.rs | 25 +++++++++++++++++++++++++ crates/edit/src/text.rs | 31 +++++++++++++++++++++++++------ crates/edit/src/text_regions.rs | 4 ---- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index b542142df..e48116c8a 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3906,6 +3906,31 @@ fn turned_paragraph_keeps_its_line_breaks_when_a_word_is_added() { assert!(text::rewrite_block(&mut doc, 0, 0, Some("Only line"), &style).is_err()); } +/// A paragraph drawn at a slant rewraps at the width its rows have along their baseline, as the +/// same paragraph upright does. Its upright bounding box turned into the baseline's frame is wider +/// by the rows' height times |sin 2θ|, which let a word that doesn't fit stay on its row. +#[test] +fn a_paragraph_at_a_slant_rewraps_at_the_width_it_has_upright() { + let rows = ["Rotated paragraph, first row of text", "that continues on a second row and", "ends on the third row here."]; + let draft = "Rotated paragraph, first row of text that continues on a second row and now carries a few extra words \ + before it ends on the third row here."; + let rewrapped = |degrees: f64| { + let (sin, cos) = degrees.to_radians().sin_cos(); + let shown = rows.iter().map(|r| format!("({r}) Tj")).collect::>().join(" T* "); + let mut doc = text_page(&format!("BT /F1 12 Tf 14.4 TL {cos:.6} {sin:.6} {:.6} {cos:.6} 300 400 Tm {shown} ET", -sin)); + let block = text::text_blocks(&doc, 0).unwrap().remove(0); + assert_eq!(block.editor_text, rows.join("\n"), "{degrees}°"); + text::rewrite_block(&mut doc, 0, 0, Some(draft), &text::BlockStyle::default()).unwrap(); + text::text_lines(&reopen(&doc), 0).unwrap().into_iter().map(|l| l.text).collect::>() + }; + let upright = rewrapped(0.0); + // The widest source row is 189.44 pt; "now carries a few extra words before" is 196.07 pt. + assert_eq!(upright[2], "now carries a few extra words", "{upright:?}"); + for degrees in [30.0, 45.0, 60.0, 90.0, 135.0, 210.0, 330.0] { + assert_eq!(rewrapped(degrees), upright, "{degrees}°"); + } +} + /// A paragraph in a Chrome-style subset font: `/W` leaves out the glyphs of the `/DW` width. /// Adding a word keeps every row as it was and shows the word in the page's own font (#1443). #[test] diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 9c83cf9a3..bf0fcbff0 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -50,6 +50,10 @@ pub struct TextLine { pub visual_order: bool, /// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]). face: Option, + /// Its box along and across its baseline, `[along0, across0, along1, across1]` (see + /// [`TextLine::frame_rect`]). Upright text has `rect`; turned text has its own box, which is + /// narrower than `rect` turned into the same frame. + frame: [f64; 4], /// The content stream piece the line starts in, and its operators (indices into the page's /// streams parsed joined). stream: usize, @@ -470,12 +474,7 @@ impl TextLine { /// Its box in the same frame, `[along0, across0, along1, across1]` (for upright text, `rect`). fn frame_rect(&self) -> [f64; 4] { - let dir = self.direction(); - let r = self.rect; - [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])] - .into_iter() - .map(|p| to_frame(dir, p)) - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]) + self.frame } } @@ -493,6 +492,11 @@ fn to_frame(dir: (f64, f64), p: (f64, f64)) -> (f64, f64) { (p.0 * dir.0 + p.1 * dir.1, p.1 * dir.0 - p.0 * dir.1) } +/// The box covering both `[x0, y0, x1, y1]` boxes. +fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] +} + /// What replacing a line did. #[derive(Clone, Debug, PartialEq)] pub struct LineEdit { @@ -639,6 +643,8 @@ struct Shown { bt_state: Ts, text: String, rect: [f64; 4], + /// Its box along and across its baseline (see [`TextLine::frame`]). + frame: [f64; 4], /// The boxes of its words (see [`TextLine::ink`]). ink: Vec<[f64; 4]>, baseline: f64, @@ -773,6 +779,7 @@ fn interpret( first.rect[2].max(shown.rect[2]), first.rect[3].max(shown.rect[3]), ]; + first.frame = union(first.frame, shown.frame); first.ink.extend(shown.ink); } first.extra_ops.extend([open, i]); @@ -936,6 +943,13 @@ fn interpret( let rect = corners .iter() .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + // The same corners along and across the baseline: the run's own box, not its + // upright bounding box turned (which is wider by the box's height on a slant). + let dir = direction(&trm0); + let frame = corners + .iter() + .map(|&p| to_frame(dir, p)) + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); let ink = words .iter() .map(|&(a, b)| { @@ -963,6 +977,7 @@ fn interpret( bt_state: at_bt.1.clone(), text, rect, + frame, ink, baseline: start.1, start_x: start.0, @@ -1040,6 +1055,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); @@ -1051,6 +1067,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro lines.push(TextLine { text: s.text.clone(), rect: s.rect, + frame: s.frame, ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), @@ -1128,6 +1145,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream } l.text.push_str(&s.text); l.rect = [l.rect[0].min(s.rect[0]), l.rect[1].min(s.rect[1]), l.rect[2].max(s.rect[2]), l.rect[3].max(s.rect[3])]; + l.frame = union(l.frame, s.frame); l.ink.extend(s.ink.iter().copied()); l.ops.push(s.op); l.ops.extend(s.extra_ops.iter().copied()); @@ -1138,6 +1156,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream lines.push(TextLine { text: s.text.clone(), rect: s.rect, + frame: s.frame, ink: s.ink.clone(), font: String::from_utf8_lossy(&s.font).into_owned(), base_font: s.base_font.clone(), diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 2d7baf519..7b512cf88 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -17,10 +17,6 @@ fn same_plane(a: &TextLine, b: &TextLine) -> bool { }) } -fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { - [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] -} - fn row_rect(row: &[usize], lines: &[TextLine]) -> Option<[f64; 4]> { row.iter().filter_map(|i| lines.get(*i)).map(|l| l.rect).reduce(union) }