Layout: text in a turned text box turns with it

A turned text box (free rotation) drew its frame turned and its text
upright. Its text now turns with it about the box's centre, as Word
draws it, on screen and in PNG and PDF export: upside down when the
box is flipped across, never mirrored by a side-to-side flip.

PDF export draws item by item, to tag each paragraph, so it turns each
of the box's lines the same way, and tags the turned lines as their
paragraph's text rather than as decoration.

Clicks and the caret inside a turned box still go by its upright text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
Claude
2026-11-01 17:42:07 +00:00
parent 8506d9da54
commit b87c80cdc7
5 changed files with 90 additions and 5 deletions
+42 -1
View File
@@ -48,13 +48,16 @@ pub struct ItemDisplay<'a> {
page: &'a Page,
authors: Authors<'a>,
clips: Clips,
/// The page's turned text boxes (see [`turned_boxes`]): their lines are drawn turned.
turned: Vec<(u32, Rect, Spin)>,
}
impl<'a> ItemDisplay<'a> {
pub fn new(doc: &'a Document, page: &'a Page) -> Self {
let cells: Vec<Placed> =
page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })).cloned().collect();
ItemDisplay { doc, page, authors: Authors::new(doc), clips: clipped_cells(&cells) }
let turned = [&page.items, &page.header, &page.footer].into_iter().flat_map(|items| turned_boxes(items)).collect();
ItemDisplay { doc, page, authors: Authors::new(doc), clips: clipped_cells(&cells), turned }
}
/// The draw items of `it` alone, as [`page_display`] gives them for a page holding just it
@@ -64,6 +67,14 @@ impl<'a> ItemDisplay<'a> {
let items = std::slice::from_ref(it);
let (_, ba) = dimming(opts);
stories_display(self.doc, &[items], Some(&self.clips), opts, &self.authors, ba, &mut out);
// A line of a turned text box turns with it (as `stories_display` turns them on a page).
if let Placed::Lines { story: StoryRef::Part(id), .. } = it
&& let Some((_, rect, spin)) = self.turned.iter().find(|(t, ..)| t == id)
{
let mut turned = Vec::new();
spun(*spin, *rect, std::mem::take(&mut out), &mut turned);
out = turned;
}
body_change_bars(self.doc, self.page, items, opts, ba, &mut out);
out
}
@@ -108,6 +119,7 @@ fn stories_display(
let hidden = opts.hide_object.as_ref().map(|p| Hidden::new(doc, p));
for items in stories {
let warps = warped_boxes(doc, items);
let turned = turned_boxes(items);
let own;
let clips = match shared {
Some(c) => c,
@@ -143,12 +155,41 @@ fn stories_display(
warped_text(WarpedText { doc, lines: &lines, rect: *rect, warp, opts, authors, alpha: a }, out);
}
}
Placed::Lines { story: StoryRef::Part(id), .. } if turned.iter().any(|(t, ..)| t == id) => {
// A turned text box's lines are drawn together, turned with it about its
// centre, where its first one is.
if done.contains(id) {
continue;
}
done.push(*id);
if let Some((_, rect, spin)) = turned.iter().find(|(t, ..)| t == id) {
let mut inner = Vec::new();
for line in items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if s == id)) {
item(doc, line, opts, authors, a, &mut inner);
}
super::objects::spun(*spin, *rect, inner, out);
}
}
_ => item(doc, it, opts, authors, a, out),
}
}
}
}
/// The text boxes among `items` that are turned: their story, frame (unturned) and how their
/// text turns with them. As in Word, the text turns with the box but isn't mirrored: a box
/// flipped across turns its text upside down, one flipped side to side leaves it readable.
fn turned_boxes(items: &[Placed]) -> Vec<(u32, Rect, Spin)> {
items
.iter()
.filter_map(|it| {
let Placed::Object { rect, spin, text_box: Some(id), .. } = it else { return None };
let text = Spin::new(spin.deg + if spin.flip_v { 180.0 } else { 0.0 }, false, false);
(!text.is_identity()).then_some((*id, *rect, text))
})
.collect()
}
/// One object drawn alone, as its page draws it: the picture, shape or group `o` in `rect` (its
/// unturned frame) and the text of the text box story `text_box` on `page`. A dragged object's
/// preview, which mustn't show what was behind it (#1010).
+39
View File
@@ -4427,6 +4427,45 @@ fn text_box_at(d: &mut Document, pos: &Pos, text: &str, w: f32, h: f32, float: w
id
}
#[test]
fn a_turned_text_box_turns_its_text_with_it() {
// Word turns a text box's text with the box (and turns it upside down when the box is
// flipped across, but never mirrors it): its lines are drawn inside the box's rotation.
let rotated_text = |rot: f32, flip_h: bool, flip_v: bool| {
let mut d = Document::from_text("Body text.");
let float =
wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, x: 20.0, y: 20.0, rot, flip_h, flip_v, ..Default::default() };
text_box(&mut d, 0, "Turned words", 140.0, 50.0, float);
let l = lay(&d);
let items = display::page_display(&d, &l.pages[0], &display::DisplayOptions::default());
items.iter().find_map(|x| match x {
display::Draw::Rotated { spin, items, .. }
if items.iter().any(|g| matches!(g, display::Draw::Glyphs { text, .. } if text.contains("Turned"))) =>
{
Some(*spin)
}
_ => None,
})
};
assert_eq!(rotated_text(0.0, false, false), None, "an upright box's text is drawn as it is");
let s = rotated_text(30.0, false, false).expect("turned with the box");
assert_eq!((s.deg, s.flip_h, s.flip_v), (30.0, false, false));
// Flipped side to side: readable, not mirrored.
assert_eq!(rotated_text(0.0, true, false), None);
// Flipped across: upside down.
let s = rotated_text(0.0, false, true).expect("upside down");
assert_eq!((s.deg, s.flip_h, s.flip_v), (180.0, false, false)); // Drawn item by item (as PDF export draws, to tag each paragraph): the same.
let mut d = Document::from_text("Body text.");
let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, x: 20.0, y: 20.0, rot: 30.0, ..Default::default() };
text_box(&mut d, 0, "Turned words", 140.0, 50.0, float);
let l = lay(&d);
let shown = display::ItemDisplay::new(&d, &l.pages[0]);
let turned = l.pages[0].items.iter().flat_map(|it| shown.draws(it, &Default::default())).any(|x| {
matches!(&x, display::Draw::Rotated { items, .. } if items.iter().any(|g| matches!(g, display::Draw::Glyphs { text, .. } if text.contains("Turned"))))
});
assert!(turned);
}
fn box_rect(l: &DocLayout, id: u32) -> wordcraft_geom::Rect {
l.pages[0]
.items
+2
View File
@@ -223,6 +223,8 @@ impl Exporter<'_> {
// A rotated inline picture, or a rotated group holding pictures: turned as a
// whole, each picture tagged on its own (see `rotated_pictures`).
Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d),
// A turned text box's line: still the paragraph's text.
Draw::Rotated { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
// An inline chart, diagram or described shape: its text is part of the figure, not the paragraph.
Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| {
for d in inner {
+3 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long