diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs
index e60c2ec52b..d759c115eb 100644
--- a/crates/doc/src/props.rs
+++ b/crates/doc/src/props.rs
@@ -648,6 +648,50 @@ pub enum VAlign {
Bottom,
}
+/// Which way a table cell's text runs (`w:textDirection`, ECMA-376 §17.4.72).
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+pub enum TextDirection {
+ /// Left to right, lines stacking downwards.
+ #[default]
+ Horizontal,
+ /// Turned 90° clockwise: lines read top to bottom and stack from right to left (`tbRl`).
+ Down,
+ /// Turned 90° counter-clockwise: lines read bottom to top and stack from left to right (`btLr`).
+ Up,
+}
+
+impl TextDirection {
+ /// The direction an OOXML `ST_TextDirection` value names (transitional and strict names).
+ /// The East Asian vertical layouts turn like `Down`; unknown values are horizontal.
+ pub fn from_ooxml(v: &str) -> TextDirection {
+ match v {
+ "tbRl" | "tbRlV" | "tbLrV" | "rl" | "rlV" | "lrV" => TextDirection::Down,
+ "btLr" | "lr" => TextDirection::Up,
+ _ => TextDirection::Horizontal,
+ }
+ }
+ /// The transitional OOXML name.
+ pub fn ooxml(self) -> &'static str {
+ match self {
+ TextDirection::Horizontal => "lrTb",
+ TextDirection::Down => "tbRl",
+ TextDirection::Up => "btLr",
+ }
+ }
+ /// Word's Text Direction button: horizontal → down → up → horizontal.
+ pub fn next(self) -> TextDirection {
+ match self {
+ TextDirection::Horizontal => TextDirection::Down,
+ TextDirection::Down => TextDirection::Up,
+ TextDirection::Up => TextDirection::Horizontal,
+ }
+ }
+ pub fn is_turned(self) -> bool {
+ self != TextDirection::Horizontal
+ }
+}
+
/// Table-wide properties.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
@@ -765,8 +809,8 @@ pub struct CellProps {
pub valign: VAlign,
/// Cell margins override (top, left, bottom, right), points.
pub margins: Option<[f32; 4]>,
- /// Text direction: false = horizontal, true = rotated (top-to-bottom).
- pub vertical_text: bool,
+ /// Which way the cell's text runs.
+ pub text_direction: TextDirection,
pub no_wrap: bool,
}
@@ -818,6 +862,23 @@ mod tests {
}
}
+ #[test]
+ fn text_direction_names() {
+ let mut d = TextDirection::Horizontal;
+ for _ in 0..3 {
+ assert_eq!(TextDirection::from_ooxml(d.ooxml()), d);
+ d = d.next();
+ }
+ assert_eq!(d, TextDirection::Horizontal, "the button cycles through three directions");
+ // Strict names, East Asian vertical layouts and junk.
+ assert_eq!(TextDirection::from_ooxml("rl"), TextDirection::Down);
+ assert_eq!(TextDirection::from_ooxml("lr"), TextDirection::Up);
+ assert_eq!(TextDirection::from_ooxml("tbRlV"), TextDirection::Down);
+ assert_eq!(TextDirection::from_ooxml("tb"), TextDirection::Horizontal);
+ assert_eq!(TextDirection::from_ooxml("lrTbV"), TextDirection::Horizontal);
+ assert_eq!(TextDirection::from_ooxml("é?"), TextDirection::Horizontal);
+ }
+
#[test]
fn props_serde_skip_none() {
let p = CharProps { bold: Some(true), ..Default::default() };
diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs
index 72b324da38..45f2d96085 100644
--- a/crates/docx/src/read/props.rs
+++ b/crates/docx/src/read/props.rs
@@ -4,7 +4,7 @@ use std::collections::HashMap;
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign,
- TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, Underline, VAlign, VMerge, VertAlign,
+ TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart};
@@ -387,7 +387,7 @@ pub fn tcpr(e: &El) -> (CellProps, bool) {
"w:shd" => c.shading = shd_fill(k),
"w:noWrap" => c.no_wrap = on_off(k),
"w:tcMar" => c.margins = Some(margins(k)),
- "w:textDirection" => c.vertical_text = !matches!(k.attr("w:val"), None | Some("lrTb") | Some("lrTbV") | Some("tb")),
+ "w:textDirection" => c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")),
"w:vAlign" => {
c.valign = match k.attr("w:val") {
Some("center") => VAlign::Center,
diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs
index a58a51bdac..688d8dfe9d 100644
--- a/crates/docx/src/write/props.rs
+++ b/crates/docx/src/write/props.rs
@@ -407,8 +407,8 @@ pub fn tcpr(w: &mut W, c: &CellProps) {
if let Some(m) = &c.margins {
margins(w, "w:tcMar", m);
}
- if c.vertical_text {
- w.val("w:textDirection", "btLr");
+ if c.text_direction.is_turned() {
+ w.val("w:textDirection", c.text_direction.ooxml());
}
match c.valign {
VAlign::Top => {}
diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs
index 39cdd7b9da..44e7715e93 100644
--- a/crates/docx/tests/roundtrip.rs
+++ b/crates/docx/tests/roundtrip.rs
@@ -6,7 +6,7 @@ use wordcraft_doc::numbering::ListKind;
use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{
Align, Border, BorderStyle, Borders, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader,
- TabStop, TableLook, TextColor, Underline, VAlign, VMerge, VertAlign,
+ TabStop, TableLook, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign,
};
use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart};
use wordcraft_doc::styles::{Style, StyleKind};
@@ -232,7 +232,7 @@ fn tables_with_merges_round_trip() {
let c = &mut t.rows[1].cells[0];
c.props.shading = Some(Rgb(0xFF, 0, 0));
c.props.valign = VAlign::Bottom;
- c.props.vertical_text = true;
+ c.props.text_direction = TextDirection::Up;
c.props.no_wrap = true;
c.props.margins = Some([2.0, 3.0, 4.0, 5.0]);
c.props.borders = Some(Borders::box_(Border { style: BorderStyle::Dashed, width: 1.0, color: Some(Rgb(0, 0, 255)), space: 0.0 }));
@@ -631,6 +631,29 @@ fn toc_field_wraps_its_entries() {
assert_eq!(ps[1].props.style.as_deref(), Some("TOC1"));
}
+/// Table Layout › Text Direction (#226): each direction is written as Word's `w:textDirection`
+/// value and read back.
+#[test]
+fn cell_text_direction_round_trips() {
+ let mut t = Table::new(1, 3, 300.0);
+ let dirs = [TextDirection::Horizontal, TextDirection::Down, TextDirection::Up];
+ for (c, dir) in t.rows[0].cells.iter_mut().zip(dirs) {
+ c.props.text_direction = dir;
+ }
+ let mut d = Document::new();
+ d.body = vec![Arc::new(Block::Table(t)), para_block(Paragraph::new())];
+ let bytes = wordcraft_docx::write(&d).expect("write");
+ let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
+ let mut xml = String::new();
+ std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
+ assert!(xml.contains(r#""#), "{xml}");
+ assert!(xml.contains(r#""#), "{xml}");
+ assert_eq!(xml.matches("w:textDirection").count(), 2, "horizontal cells write nothing: {xml}");
+ let r = wordcraft_docx::read(&bytes).expect("read");
+ let got: Vec = r.body[0].as_table().expect("table").rows[0].cells.iter().map(|c| c.props.text_direction).collect();
+ assert_eq!(got, dirs);
+}
+
/// The TOC field's begin and end belong to the entries' own paragraphs, even when the heading or
/// an entry holds a text box: the box's paragraphs are written inside them and must not take either.
#[test]
diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs
index 944645674f..837c032b8b 100644
--- a/crates/engine/src/cmd/table.rs
+++ b/crates/engine/src/cmd/table.rs
@@ -1,7 +1,7 @@
//! Table Design and Table Layout tabs.
use serde_json::{Value, json};
-use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, Rgb, VAlign};
+use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, Rgb, TextDirection, VAlign};
use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef, Table, para_block};
use super::sel_result;
@@ -94,6 +94,8 @@ pub fn specs() -> Vec {
})
})
.params(r#"{"value": "topLeft|topCenter|…|bottomRight"}"#)),
+ t(CommandSpec::new("table.textDirection", "Text Direction", "Table Layout › Alignment", text_direction)
+ .params(r#"{"value"?: "horizontal|down|up"} (default: the next direction after the caret cell's, like Word's button)"#)),
t(CommandSpec::new("table.autofit", "AutoFit", "Table Layout › Cell Size", |s, v| {
let mode = p::str(v, "mode").unwrap_or("window");
let tw = super::page::sect(s).text_width();
@@ -241,6 +243,23 @@ fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResul
}
/// Bump the revision of every paragraph in a table (table style changes alter their layout).
+/// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the
+/// caret cell's direction (horizontal → down → up) and gives every selected cell the result.
+fn text_direction(s: &mut Session, v: &Value) -> CmdResult {
+ let (tp, r, c) = cell(s)?;
+ let dir = match p::str(v, "value") {
+ Some("horizontal" | "lrTb") => TextDirection::Horizontal,
+ Some("down" | "tbRl") => TextDirection::Down,
+ Some("up" | "btLr") => TextDirection::Up,
+ Some(x) => return Err(CmdError::Params(format!("unknown text direction `{x}`"))),
+ None => {
+ let t = s.doc.table(s.sel.focus.story, &tp).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table".into()))?;
+ t.rows.get(r).and_then(|row| row.cells.get(c)).map(|cl| cl.props.text_direction).unwrap_or_default().next()
+ }
+ };
+ with_cells(s, |cl| cl.props.text_direction = dir)
+}
+
fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> {
let story = s.sel.focus.story;
let paths: Vec = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect();
diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs
index 7ec80f8786..022e8e69a0 100644
--- a/crates/engine/src/cmd/tools.rs
+++ b/crates/engine/src/cmd/tools.rs
@@ -274,14 +274,6 @@ pub fn specs() -> Vec {
Ok(json!({"header": sect.header, "footer": sect.footer}))
})
.params(r#"{"header"?: pt, "footer"?: pt}"#),
- CommandSpec::new("table.textDirection", "Text Direction", "Table Layout › Alignment", |s, _| {
- let Some((tp, r, c)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) };
- let t = s.doc.table_mut(s.sel.focus.story, &tp)?;
- if let Some(cell) = t.rows.get_mut(r).and_then(|x| x.cells.get_mut(c)) {
- cell.props.vertical_text = !cell.props.vertical_text;
- }
- sel_result(s)
- }),
CommandSpec::new("table.cellMargins", "Cell Margins", "Table Layout › Alignment", |s, v| {
let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Err(CmdError::Disabled("not in a table".into())) };
let m = [
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index 88b46f9bdc..d5830ad8fe 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -541,6 +541,54 @@ fn tables_commands() {
assert!(s.run("table.merge", &json!({})).is_err());
}
+/// Table Layout › Text Direction (#226) cycles the selected cells' text through horizontal,
+/// top-to-bottom and bottom-to-top, and the layout turns the text.
+#[test]
+fn table_text_direction_cycles_and_turns_the_text() {
+ use wordcraft_doc::props::TextDirection;
+ let mut s = s();
+ run(&mut s, "insert.table", json!({"rows": 1, "cols": 3}));
+ run(&mut s, "text.insert", json!({"text": "Turned"}));
+ let dirs = |s: &Session| -> Vec {
+ s.doc.body.iter().find_map(|b| b.as_table()).unwrap().rows[0].cells.iter().map(|c| c.props.text_direction).collect()
+ };
+ let turned = |s: &mut Session| {
+ s.layout().pages[0].items.iter().find_map(|it| match it {
+ wordcraft_layout::Placed::Lines { para, turn, .. } if para.lines.first().is_some_and(|l| l.stop > 0) => Some(*turn),
+ _ => None,
+ })
+ };
+ assert_eq!(turned(&mut s), Some(TextDirection::Horizontal));
+ run(&mut s, "table.textDirection", json!({}));
+ assert_eq!(dirs(&s), [TextDirection::Down, TextDirection::Horizontal, TextDirection::Horizontal]);
+ assert_eq!(turned(&mut s), Some(TextDirection::Down), "the text is drawn turned");
+ run(&mut s, "table.textDirection", json!({}));
+ assert_eq!(dirs(&s)[0], TextDirection::Up);
+ assert_eq!(turned(&mut s), Some(TextDirection::Up));
+ run(&mut s, "table.textDirection", json!({}));
+ assert_eq!(dirs(&s)[0], TextDirection::Horizontal);
+ // Every selected cell gets the caret cell's next direction (the issue's selection spans cells).
+ let a = s.sel.focus.clone();
+ let mut b = a.clone();
+ if let Some(last) = b.path.0.get_mut(2) {
+ *last = 1;
+ }
+ b.off = 0;
+ s.sel = crate::Selection { anchor: a, focus: b };
+ run(&mut s, "table.textDirection", json!({}));
+ assert_eq!(dirs(&s), [TextDirection::Down, TextDirection::Down, TextDirection::Horizontal]);
+ run(&mut s, "table.textDirection", json!({"value": "up"}));
+ assert_eq!(dirs(&s), [TextDirection::Up, TextDirection::Up, TextDirection::Horizontal]);
+ assert!(s.run("table.textDirection", &json!({"value": "sideways"})).is_err());
+ run(&mut s, "edit.undo", json!({}));
+ assert_eq!(dirs(&s), [TextDirection::Down, TextDirection::Down, TextDirection::Horizontal]);
+ // Outside a table it is disabled.
+ s.sel = crate::Selection::caret(Pos::body(0, 0));
+ if s.sel.focus.path.cell().is_none() {
+ assert!(s.run("table.textDirection", &json!({})).is_err());
+ }
+}
+
#[test]
fn table_border_presets_mask_the_sides_they_clear() {
// On a default (TableGrid) table the "outside"/"inside" presets must write nil over the
diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs
index 7ad8ed9a01..c9e19dcb3c 100644
--- a/crates/layout/src/display.rs
+++ b/crates/layout/src/display.rs
@@ -1,7 +1,7 @@
//! Page → draw items, shared by the raster renderer, the PDF exporter and thumbnails.
use wordcraft_doc::para::{InlineObject, ShapeKind};
-use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, Underline};
+use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline};
use wordcraft_doc::{Document, Path, StoryRef};
use wordcraft_fonts::FaceRef;
use wordcraft_geom::Rect;
@@ -74,6 +74,26 @@ pub enum Draw {
ch: char,
color: Option,
},
+ /// Turned text (a table cell's text direction): `items` are drawn in a frame turned `turn`
+ /// whose origin is page point (`x`, `y`) (see [`crate::turn_point`]).
+ Turned {
+ x: f32,
+ y: f32,
+ turn: TextDirection,
+ items: Vec,
+ },
+}
+
+impl Draw {
+ /// The affine map (a, b, c, d, e, f: x' = a·x + c·y + e, y' = b·x + d·y + f) from a frame
+ /// turned `turn` with its origin at page point (`x`, `y`) to the page.
+ pub fn turn_matrix(turn: TextDirection, x: f32, y: f32) -> [f32; 6] {
+ match turn {
+ TextDirection::Horizontal => [1.0, 0.0, 0.0, 1.0, x, y],
+ TextDirection::Down => [0.0, 1.0, -1.0, 0.0, x, y],
+ TextDirection::Up => [0.0, -1.0, 1.0, 0.0, x, y],
+ }
+ }
}
#[derive(Clone, Debug)]
@@ -172,7 +192,18 @@ fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mu
out.push(Draw::Shape { rect: *rect, kind: *kind, fill: *fill, stroke: *stroke, stroke_width: *stroke_width })
}
Placed::Cell { .. } | Placed::Object { .. } => {}
- Placed::Lines { story, path, para, l0, l1, x, y } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, alpha, out),
+ Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => {
+ let mut items = Vec::new();
+ lines(doc, *story, path, para, *l0, *l1, 0.0, 0.0, opts, alpha, &mut items);
+ // Link areas are axis-aligned page rectangles: turned text gets none.
+ for d in &mut items {
+ if let Draw::Glyphs { link, .. } = d {
+ *link = None;
+ }
+ }
+ out.push(Draw::Turned { x: *x, y: *y, turn: *turn, items });
+ }
+ Placed::Lines { story, path, para, l0, l1, x, y, .. } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, alpha, out),
}
}
diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs
index 1c6a4c2aeb..8cc2bbfa11 100644
--- a/crates/layout/src/hit.rs
+++ b/crates/layout/src/hit.rs
@@ -1,19 +1,23 @@
//! Hit testing and caret geometry.
use wordcraft_doc::para::Wrap;
+use wordcraft_doc::props::TextDirection;
use wordcraft_doc::{Document, Path, Pos, StoryRef};
use wordcraft_geom::{Point, Rect};
use crate::para::ParaLayout;
use crate::{DocLayout, Page, Placed};
-/// Caret geometry on a page.
+/// Caret geometry on a page: a bar from (`x`, `top`) to (`x + width`, `top + height`). It is
+/// upright (`width` 0) except in turned text (a table cell's text direction), where it lies
+/// across the page (`height` 0).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Caret {
pub page: usize,
pub x: f32,
pub top: f32,
pub height: f32,
+ pub width: f32,
}
/// Result of [`DocLayout::visual_step`].
@@ -98,6 +102,9 @@ fn text_at(p: &Page, index: usize, x: f32, y: f32) -> Option {
let boxes: Vec = objects(p, index).filter_map(|o| o.text_box.map(StoryRef::Part)).collect();
p.items.iter().find_map(|it| match it {
Placed::Lines { story, .. } if boxes.contains(story) => None,
+ Placed::Lines { story, .. } if it.turned_bounds().is_some() => {
+ it.turned_bounds().filter(|r| r.expand(2.0).contains(Point::new(x, y))).map(|_| *story)
+ }
Placed::Lines { story, para, l0, l1, x: lx, y: ly, .. } => {
let first = para.lines.get(*l0)?;
let last = para.lines.get(l1.checked_sub(1)?)?;
@@ -117,12 +124,13 @@ fn items_of(page: &Page, story: StoryRef) -> Box +
}
}
-/// Every line on a page that belongs to `story` (body, or a header/footer part).
+/// Every upright line on a page that belongs to `story` (body, or a header/footer part).
+/// Turned lines (see [`turned_hit`]) are left out.
pub fn page_lines(page: &Page, story: StoryRef) -> Vec> {
let mut v = Vec::new();
for it in items_of(page, story) {
- if let Placed::Lines { story: s, path, para, l0, l1, x, y } = it {
- if *s != story {
+ if let Placed::Lines { story: s, path, para, l0, l1, x, y, turn } = it {
+ if *s != story || turn.is_turned() {
continue;
}
let Some(first) = para.lines.get(*l0) else { continue };
@@ -136,10 +144,43 @@ pub fn page_lines(page: &Page, story: StoryRef) -> Vec> {
v
}
+/// The position in turned text (a table cell's text direction) of `story` under page point
+/// (x, y), if there is turned text there.
+fn turned_hit(page: &Page, story: StoryRef, x: f32, y: f32) -> Option {
+ items_of(page, story).find_map(|it| {
+ let Placed::Lines { story: s, path, para, l0, l1, x: ox, y: oy, turn } = it else { return None };
+ if *s != story || !it.turned_bounds()?.expand(2.0).contains(Point::new(x, y)) {
+ return None;
+ }
+ let (u, v) = crate::unturn_point(*turn, *ox, *oy, x, y);
+ let first = para.lines.get(*l0)?;
+ // The line across whose band v falls, else the nearest.
+ let li = (*l0..*l1)
+ .filter_map(|li| para.lines.get(li).map(|l| (li, l.top - first.top, l.height)))
+ .min_by(|a, b| band_dist(v, a.1, a.2).total_cmp(&band_dist(v, b.1, b.2)))?
+ .0;
+ Some(Pos { story: *s, path: path.clone(), off: para.off_at_x(li, u) })
+ })
+}
+
+/// How far `v` is outside the band `top..top + h`.
+fn band_dist(v: f32, top: f32, h: f32) -> f32 {
+ if v < top {
+ top - v
+ } else if v > top + h {
+ v - top - h
+ } else {
+ 0.0
+ }
+}
+
impl DocLayout {
/// The position nearest to (x, y) on `page`, in `story`.
pub fn hit(&self, page: usize, x: f32, y: f32, story: StoryRef) -> Option {
let p = self.pages.get(page)?;
+ if let Some(pos) = turned_hit(p, story, x, y) {
+ return Some(pos);
+ }
let lines = page_lines(p, story);
let best = lines.iter().min_by(|a, b| score(a, x, y).total_cmp(&score(b, x, y)))?;
let off = best.para.off_at_x(best.li, x - best.x);
@@ -244,7 +285,7 @@ impl DocLayout {
pub fn caret_on(&self, pos: &Pos, page_hint: usize) -> Option {
for (pi, it) in self.pieces(pos.story, &pos.path, page_hint) {
- let Placed::Lines { para, l0, l1, x, y, .. } = it else { continue };
+ let Placed::Lines { para, l0, l1, x, y, turn, .. } = it else { continue };
let li = para.line_of(pos.off);
if li < *l0 || li >= *l1 {
continue;
@@ -252,14 +293,14 @@ impl DocLayout {
let first = para.lines.get(*l0)?;
let l = para.lines.get(li)?;
let cx = para.x_of(li, pos.off)?;
- let top = y + (l.top - first.top);
+ // In the lines' own frame (turned text turns it onto the page).
+ let top = l.top - first.top;
// Caret spans the text height (ascent + descent), not the full line spacing.
let (asc, desc) = para.caret_metrics(li);
let base = top + (l.baseline - l.top);
- let h = (asc + desc).min(l.height.max(asc + desc));
let ctop = (base - asc).max(top);
- let _ = h;
- return Some(Caret { page: pi, x: x + cx, top: ctop, height: (base + desc - ctop).max(4.0) });
+ let r = crate::turn_rect(*turn, *x, *y, Rect::new(cx, ctop, 0.0, (base + desc - ctop).max(4.0)));
+ return Some(Caret { page: pi, x: r.x, top: r.y, height: r.h, width: r.w });
}
None
}
@@ -338,8 +379,11 @@ impl DocLayout {
let mut out = Vec::new();
for path in doc.paths_between(a, b) {
for (pi, it) in self.pieces(a.story, &path, page_hint) {
- let Placed::Lines { para, l0, l1, x, y, .. } = it else { continue };
+ let Placed::Lines { para, l0, l1, x, y, turn, .. } = it else { continue };
let Some(first) = para.lines.get(*l0) else { continue };
+ // Rectangles in the lines' own frame, turned onto the page.
+ let (ox, oy, turn) = (*x, *y, *turn);
+ let mut put = |r: Rect| out.push((pi, crate::turn_rect(turn, ox, oy, r)));
for li in *l0..*l1 {
let Some(l) = para.lines.get(li) else { continue };
let from = if path == a.path { a.off.max(l.start) } else { l.start };
@@ -353,17 +397,17 @@ impl DocLayout {
if from > to {
continue;
}
- let top = y + (l.top - first.top);
+ let top = l.top - first.top;
if !l.vis.is_empty() {
// Bidirectional line: the selected text can be several visual pieces.
for (x0, x1) in para.x_spans(li, from, to) {
- out.push((pi, Rect::new(x + x0, top, (x1 - x0).max(0.0), l.height)));
+ put(Rect::new(x0, top, (x1 - x0).max(0.0), l.height));
}
// Selection continuing past the paragraph end shows the paragraph mark.
if li + 1 == para.lines.len() && path != b.path {
let ex = l.end_x();
let x0 = if l.rtl { ex - 5.0 } else { ex };
- out.push((pi, Rect::new(x + x0, top, 5.0, l.height)));
+ put(Rect::new(x0, top, 5.0, l.height));
}
continue;
}
@@ -380,7 +424,7 @@ impl DocLayout {
if x1 <= x0 && !(last_line && path != b.path) {
continue;
}
- out.push((pi, Rect::new(x + x0, top, (x1 - x0).max(0.0), l.height)));
+ put(Rect::new(x0, top, (x1 - x0).max(0.0), l.height));
}
}
}
@@ -419,7 +463,8 @@ impl DocLayout {
/// baseline (page coordinates), and its layout.
pub fn equation_geom(&self, pos: &Pos, page_hint: usize) -> Option<(usize, f32, f32, std::sync::Arc)> {
for (pi, it) in self.pieces(pos.story, &pos.path, page_hint) {
- let Placed::Lines { para, l0, l1, x, y, .. } = it else { continue };
+ // Equations in turned text aren't edited in place.
+ let Placed::Lines { para, l0, l1, x, y, turn: TextDirection::Horizontal, .. } = it else { continue };
let li = para.line_of(pos.off);
if li < *l0 || li >= *l1 {
continue;
diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs
index 944f06c1ad..91624a0628 100644
--- a/crates/layout/src/lib.rs
+++ b/crates/layout/src/lib.rs
@@ -21,7 +21,7 @@ use std::sync::Arc;
use wordcraft_doc::numbering::{Counters, Level};
use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, Wrap};
-use wordcraft_doc::props::{Border, CharProps, Rgb, TableFloat};
+use wordcraft_doc::props::{Border, CharProps, Rgb, TableFloat, TextDirection};
use wordcraft_doc::section::{SectionProps, SectionStart};
use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, StoryRef};
use wordcraft_geom::{Point, Rect};
@@ -58,6 +58,8 @@ pub struct LayoutOptions {
#[derive(Clone, Debug)]
pub enum Placed {
/// Lines `l0..l1` of a paragraph; `y` is the top of line `l0`; `x` the column's left edge.
+ /// Turned lines (a table cell's text direction) run as `turn` says from the page point
+ /// (`x`, `y`): see [`turn_point`].
Lines {
story: StoryRef,
path: Path,
@@ -66,6 +68,7 @@ pub enum Placed {
l1: usize,
x: f32,
y: f32,
+ turn: TextDirection,
},
Fill {
rect: Rect,
@@ -143,6 +146,75 @@ impl Placed {
}
}
}
+
+ /// The page area of turned lines (`None` for anything else).
+ pub fn turned_bounds(&self) -> Option {
+ let Placed::Lines { para, l0, l1, x, y, turn, .. } = self else { return None };
+ if !turn.is_turned() {
+ return None;
+ }
+ let first = para.lines.get(*l0)?;
+ let last = para.lines.get(l1.checked_sub(1)?)?;
+ let len = para.lines.get(*l0..*l1).unwrap_or(&[]).iter().map(|l| l.right).fold(0.0f32, f32::max);
+ Some(turn_rect(*turn, *x, *y, Rect::new(0.0, 0.0, len, last.top + last.height - first.top)))
+ }
+
+ /// Move an item laid out in a turned frame (a table cell's text running `turn`) onto the
+ /// page, the frame's origin landing on page point (`x`, `y`).
+ fn turn(&mut self, turn: TextDirection, x: f32, y: f32) {
+ if !turn.is_turned() {
+ self.translate(x, y);
+ return;
+ }
+ match self {
+ Placed::Lines { x: lx, y: ly, turn: t, .. } => {
+ (*lx, *ly) = turn_point(turn, x, y, *lx, *ly);
+ // Turned text inside turned text keeps its own direction (no 180° text).
+ if !t.is_turned() {
+ *t = turn;
+ }
+ }
+ Placed::Fill { rect, .. } | Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Cell { rect, .. } => {
+ *rect = turn_rect(turn, x, y, *rect);
+ }
+ Placed::Object { rect, origin, .. } => {
+ *rect = turn_rect(turn, x, y, *rect);
+ (origin.x, origin.y) = turn_point(turn, x, y, origin.x, origin.y);
+ }
+ Placed::Rule { x0, y0, x1, y1, .. } => {
+ (*x0, *y0) = turn_point(turn, x, y, *x0, *y0);
+ (*x1, *y1) = turn_point(turn, x, y, *x1, *y1);
+ }
+ }
+ }
+}
+
+/// Where point (`u`, `v`) of a frame turned `turn` lands on the page, the frame's origin being
+/// page point (`x`, `y`): `u` runs along the lines, `v` across them (down for horizontal text).
+pub fn turn_point(turn: TextDirection, x: f32, y: f32, u: f32, v: f32) -> (f32, f32) {
+ match turn {
+ TextDirection::Horizontal => (x + u, y + v),
+ TextDirection::Down => (x - v, y + u),
+ TextDirection::Up => (x + v, y - u),
+ }
+}
+
+/// The inverse of [`turn_point`]: the frame point at page point (`px`, `py`).
+pub fn unturn_point(turn: TextDirection, x: f32, y: f32, px: f32, py: f32) -> (f32, f32) {
+ match turn {
+ TextDirection::Horizontal => (px - x, py - y),
+ TextDirection::Down => (py - y, x - px),
+ TextDirection::Up => (y - py, px - x),
+ }
+}
+
+/// The page rectangle a frame rectangle covers (see [`turn_point`]).
+pub fn turn_rect(turn: TextDirection, x: f32, y: f32, r: Rect) -> Rect {
+ match turn {
+ TextDirection::Horizontal => Rect::new(x + r.x, y + r.y, r.w, r.h),
+ TextDirection::Down => Rect::new(x - r.y - r.h, y + r.x, r.h, r.w),
+ TextDirection::Up => Rect::new(x + r.y, y - r.x - r.w, r.h, r.w),
+ }
}
/// One laid-out page.
@@ -518,8 +590,15 @@ fn fit_box(items: Vec, height: f32) -> Vec {
let mut out = Vec::with_capacity(items.len());
let mut kept_line = false;
for it in items {
+ if let Some(r) = it.turned_bounds() {
+ // Turned text (in a table cell) stays whole.
+ if r.y < limit {
+ out.push(it);
+ }
+ continue;
+ }
match it {
- Placed::Lines { story, path, para, l0, l1, x, y } => {
+ Placed::Lines { story, path, para, l0, l1, x, y, turn } => {
let Some(first) = para.lines.get(l0) else { continue };
let mut end = l0;
for k in l0..l1 {
@@ -531,7 +610,7 @@ fn fit_box(items: Vec, height: f32) -> Vec {
kept_line = true;
}
if end > l0 {
- out.push(Placed::Lines { story, path, para, l0, l1: end, x, y });
+ out.push(Placed::Lines { story, path, para, l0, l1: end, x, y, turn });
}
}
Placed::Fill { rect, color } if rect.y < limit => {
@@ -564,7 +643,7 @@ fn push_para(items: &mut Vec, story: StoryRef, path: &[u32], pl: &Arc| o.map(|b| b.space).unwrap_or(0.0);
let (lx, rx) = (left - sp(&b.left), right + sp(&b.right));
@@ -1334,6 +1413,7 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize,
l1: 1,
x: pb.sect.margin_left + pb.sect.gutter - dist - w,
y: ly,
+ turn: TextDirection::Horizontal,
});
}
}
diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs
index 9724db3834..491a4ef95b 100644
--- a/crates/layout/src/table.rs
+++ b/crates/layout/src/table.rs
@@ -2,7 +2,8 @@
//! table-style conditional formatting. Rows are laid out as free-standing boxes; pagination
//! moves whole rows or splits them between lines (`split_row`).
-use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, VAlign, VMerge};
+use wordcraft_doc::numbering::Counters;
+use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge};
use wordcraft_doc::styles::TableStyleProps;
use wordcraft_doc::{StoryRef, Table};
use wordcraft_geom::Rect;
@@ -25,6 +26,55 @@ pub struct TableLayout {
const DEFAULT_MARGINS: [f32; 4] = [0.0, 5.4, 0.0, 5.4];
+/// The longest line turned text gets when its row height isn't exact (a Letter page's body
+/// height): longer text wraps.
+const TURNED_MAX: f32 = 648.0;
+/// The longest line turned text ever gets (Word's largest page, 22 inches).
+const TURNED_LIMIT: f32 = 1584.0;
+
+/// A cell whose text is turned, between the two passes of [`layout_table`].
+struct Turned {
+ turn: TextDirection,
+ /// Line length it was laid out with, and how far its lines reach across the cell.
+ len: f32,
+ across: f32,
+ /// List counters and equation count before the cell, to lay it out again the same way.
+ snap: (Counters, u32),
+ /// Its formatting region (header row, total row, first column) and cell index in the row.
+ region: (bool, bool, bool),
+ ci: usize,
+ /// Top margin plus top border band.
+ top: f32,
+}
+
+/// How long the lines laid out in `items` are without the room they didn't use: the length
+/// turned text needs so that nothing wraps.
+fn natural_length(items: &[Placed]) -> f32 {
+ let mut len = 0.0f32;
+ for it in items {
+ let Placed::Lines { para, l0, l1, x, .. } = it else { continue };
+ let indents = para.rp.indent_left.max(0.0) + para.rp.indent_right.max(0.0) + para.rp.indent_first.max(0.0);
+ for l in para.lines.get(*l0..*l1).unwrap_or(&[]) {
+ let (mut lo, mut hi) = (f32::MAX, f32::MIN);
+ for v in &l.vis {
+ lo = lo.min(v.x);
+ hi = hi.max(v.x + v.w);
+ }
+ if l.vis.is_empty()
+ && let (Some(a), Some(b)) = (l.xs.first(), l.xs.last())
+ {
+ (lo, hi) = (*a, *b);
+ }
+ let hy = l.hyphen.map_or(0.0, |h| h.2);
+ if hi >= lo {
+ len = len.max(x.max(0.0) + hi - lo + hy + indents);
+ }
+ }
+ }
+ // A little slack, so laying the text out again at this length doesn't wrap it.
+ len + 0.5
+}
+
/// The table's style with its based-on chain merged.
fn table_style(ctx: &Ctx, t: &Table) -> Option {
ctx.doc.styles.table_style(t.props.style.as_deref()?)
@@ -115,6 +165,8 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
margins: [f32; 4],
g: usize,
span: usize,
+ /// Turned text: `items` are still in the turned frame (placed in the second pass).
+ turned: Option,
}
let mut rows: Vec> = Vec::with_capacity(nrows);
let mut heights: Vec = Vec::with_capacity(nrows);
@@ -184,15 +236,34 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
// border): the border's width sits on top of the cell margin.
let band = |b: Option| b.filter(Border::is_visible).map_or(0.0, |b| b.width.clamp(0.0, 12.0));
let (band_t, band_b) = (band(borders.top), if ri + 1 == nrows { band(borders.bottom) } else { 0.0 });
- let (mut items, h) = if cell.props.vmerge == VMerge::Continue {
- (Vec::new(), 0.0)
- } else {
- layout_box(ctx, story, &cell.blocks, &cpath, cw, text, depth, None)
- };
- for it in &mut items {
- it.translate(x0 + margins[1], margins[0] + band_t);
- }
let insets = margins[0] + margins[2] + band_t + band_b;
+ let turn = cell.props.text_direction;
+ let mut turned = None;
+ let (items, h) = if cell.props.vmerge == VMerge::Continue {
+ (Vec::new(), 0.0)
+ } else if turn.is_turned() {
+ // Turned text: its lines run along the cell's height. An exact row height sets
+ // their length; otherwise they get the length the text needs unwrapped and the row
+ // grows to fit (the second pass lays them out again if the row ends up taller).
+ let snap = (ctx.counters.clone(), ctx.eq_count);
+ let len = match (row.props.height, row.props.height_rule) {
+ (Some(h), HeightRule::Exact) if h > 0.0 => (h - insets).clamp(4.0, TURNED_LIMIT),
+ _ => {
+ let (probe, _) = layout_box(ctx, story, &cell.blocks, &cpath, TURNED_MAX, text, depth, None);
+ (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1);
+ natural_length(&probe).clamp(4.0, TURNED_MAX)
+ }
+ };
+ let (items, across) = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None);
+ turned = Some(Turned { turn, len, across, snap, region, ci, top: margins[0] + band_t });
+ (items, len)
+ } else {
+ let (mut items, h) = layout_box(ctx, story, &cell.blocks, &cpath, cw, text, depth, None);
+ for it in &mut items {
+ it.translate(x0 + margins[1], margins[0] + band_t);
+ }
+ (items, h)
+ };
let h = h + insets;
if cell.props.vmerge != VMerge::Restart {
rh = rh.max(h);
@@ -210,6 +281,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
margins,
g,
span,
+ turned,
});
g += span;
}
@@ -277,13 +349,46 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
{
items.push(Placed::Fill { rect, color: f });
}
- let dy = match c.valign {
+ let mut dy = match c.valign {
VAlign::Top => 0.0,
VAlign::Center => ((span_h - c.h) / 2.0).max(0.0),
VAlign::Bottom => (span_h - c.h).max(0.0),
};
- let _ = c.margins;
- for mut it in c.items {
+ let mut cell_items = c.items;
+ if let Some(tn) = c.turned {
+ dy = 0.0;
+ let m = c.margins;
+ // The lines run the cell's whole height (laid out again if the row grew).
+ let len = (span_h - (c.h - tn.len)).clamp(4.0, TURNED_LIMIT);
+ let mut across = tn.across;
+ if (len - tn.len).abs() > 0.01
+ && let Some(cell) = t.rows.get(ri).and_then(|r| r.cells.get(tn.ci))
+ {
+ let mut cpath = path.to_vec();
+ cpath.extend([ri as u32, tn.ci as u32]);
+ let text = cell_text.iter().find(|(r, _)| *r == tn.region).map(|(_, c)| c);
+ // Same list numbers as the first layout: lay it out from the same counters.
+ let now = (std::mem::replace(&mut ctx.counters, tn.snap.0), std::mem::replace(&mut ctx.eq_count, tn.snap.1));
+ (cell_items, across) = layout_box(ctx, story, &cell.blocks, &cpath, len, text, depth, None);
+ (ctx.counters, ctx.eq_count) = now;
+ }
+ // Lines stack across the cell from its start edge (right for top-to-bottom text,
+ // left for bottom-to-top); the vertical alignment moves them across it.
+ let cw = (c.w - m[1] - m[3]).max(0.0);
+ let off = match c.valign {
+ VAlign::Top => 0.0,
+ VAlign::Center => ((cw - across) / 2.0).max(0.0),
+ VAlign::Bottom => (cw - across).max(0.0),
+ };
+ let (ox, oy) = match tn.turn {
+ TextDirection::Up => (c.x + m[1] + off, tn.top + len),
+ _ => (c.x + m[1] + cw - off, tn.top),
+ };
+ for it in &mut cell_items {
+ it.turn(tn.turn, ox, oy);
+ }
+ }
+ for mut it in cell_items {
it.translate(0.0, dy);
items.push(it);
}
@@ -321,6 +426,15 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
let mut first_moved = f32::MAX;
let mut kept_any = false;
for it in &row.items {
+ // Turned text moves as a whole, like a picture.
+ if let Some(r) = it.turned_bounds() {
+ if r.bottom() <= cut + 0.01 {
+ kept_any = true;
+ } else {
+ first_moved = first_moved.min(r.y);
+ }
+ continue;
+ }
match it {
Placed::Lines { para, l0, l1, y, .. } => {
let base = para.lines.get(*l0).map(|l| l.top).unwrap_or(0.0);
@@ -352,8 +466,18 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
let shift = (first_moved - 2.0).max(0.0);
let (mut a, mut b) = (Vec::new(), Vec::new());
for it in &row.items {
+ if let Some(r) = it.turned_bounds() {
+ let mut it = it.clone();
+ if r.bottom() <= cut + 0.01 {
+ a.push(it);
+ } else {
+ it.translate(0.0, -shift);
+ b.push(it);
+ }
+ continue;
+ }
match it {
- Placed::Lines { story, path, para, l0, l1, x, y } => {
+ Placed::Lines { story, path, para, l0, l1, x, y, turn } => {
let base = para.lines.get(*l0).map(|l| l.top).unwrap_or(0.0);
let mut split = *l1;
for k in *l0..*l1 {
@@ -364,11 +488,20 @@ pub fn split_row(row: &RowLayout, cut: f32) -> Option<(RowLayout, RowLayout)> {
}
}
if split > *l0 {
- a.push(Placed::Lines { story: *story, path: path.clone(), para: para.clone(), l0: *l0, l1: split, x: *x, y: *y });
+ a.push(Placed::Lines { story: *story, path: path.clone(), para: para.clone(), l0: *l0, l1: split, x: *x, y: *y, turn: *turn });
}
if split < *l1 {
let top = para.lines.get(split).map(|l| y + l.top - base).unwrap_or(*y);
- b.push(Placed::Lines { story: *story, path: path.clone(), para: para.clone(), l0: split, l1: *l1, x: *x, y: top - shift });
+ b.push(Placed::Lines {
+ story: *story,
+ path: path.clone(),
+ para: para.clone(),
+ l0: split,
+ l1: *l1,
+ x: *x,
+ y: top - shift,
+ turn: *turn,
+ });
}
}
Placed::Image { rect, .. } | Placed::Shape { rect, .. } | Placed::Object { rect, .. } => {
diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs
index 83b716d2ff..601fb80f57 100644
--- a/crates/layout/src/tests.rs
+++ b/crates/layout/src/tests.rs
@@ -1,6 +1,6 @@
use super::*;
use wordcraft_doc::para::InlineObject;
-use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, ParaProps};
+use wordcraft_doc::props::{Align, Border, BorderStyle, Borders, ParaProps, TextDirection};
use wordcraft_doc::{Pos, Table};
fn lay(doc: &Document) -> DocLayout {
@@ -440,6 +440,116 @@ fn tables_lay_out_cells() {
assert!(l.cell_at(0, c.x, c.top + 2.0).is_some());
}
+/// A 1×3 table after "before" whose middle cell holds `text` running `dir`.
+fn turned_table(dir: TextDirection, text: &str, row: Option) -> Document {
+ let mut d = Document::from_text("before\nafter");
+ let mut t = Table::new(1, 3, 468.0);
+ t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("plain", Default::default()))];
+ t.rows[0].cells[1].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, Default::default()))];
+ t.rows[0].cells[1].props.text_direction = dir;
+ if let Some(h) = row {
+ t.rows[0].props.height = Some(h);
+ t.rows[0].props.height_rule = wordcraft_doc::props::HeightRule::Exact;
+ }
+ d.insert_block(StoryRef::Body, &Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
+ d
+}
+
+fn turned_lines(l: &DocLayout) -> &Placed {
+ l.pages[0].items.iter().find(|it| matches!(it, Placed::Lines { path, .. } if path.0 == [1, 0, 1, 0])).unwrap()
+}
+
+/// Table Layout › Text Direction (#226): turned cell text runs along the cell's height, the row
+/// grows to fit it, and caret, clicks and drawing follow the turn.
+#[test]
+fn turned_cell_text_runs_down_the_cell() {
+ let text = "Turned cell text";
+ let d = turned_table(TextDirection::Down, text, None);
+ let l = lay(&d);
+ let it = turned_lines(&l);
+ assert!(matches!(it, Placed::Lines { turn: TextDirection::Down, l0: 0, l1: 1, .. }), "one unwrapped line: {it:?}");
+ let b = it.turned_bounds().unwrap();
+ // Tall and narrow, against the cell's right edge (cell 2 spans x 228..384, 5.4pt margins).
+ assert!(b.h > 60.0 && b.w < 20.0, "{b:?}");
+ assert!((b.right() - (72.0 + 312.0 - 5.4)).abs() < 1.0 && b.x > 72.0 + 156.0, "{b:?}");
+ // The row grew to hold the text: the next paragraph starts below it.
+ let after = l.caret(&Pos::body(2, 0)).unwrap();
+ assert!(after.top > b.bottom(), "{after:?} vs {b:?}");
+ let cell = l.pages[0].items.iter().find_map(|i| if let Placed::Cell { rect, cell: 1, .. } = i { Some(*rect) } else { None }).unwrap();
+ assert!(cell.h >= b.h, "{cell:?}");
+ // The caret lies across the page and moves down as the text goes on.
+ let pos = |off| Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off };
+ let c0 = l.caret(&pos(0)).unwrap();
+ let c1 = l.caret(&pos(text.len())).unwrap();
+ assert!(c0.width > 5.0 && c0.height == 0.0, "{c0:?}");
+ assert!((c0.top - b.y).abs() < 1.0 && c1.top > c0.top + 50.0, "{c0:?} {c1:?}");
+ assert!(c0.x >= b.x - 0.5 && c0.x + c0.width <= b.right() + 0.5, "{c0:?} {b:?}");
+ // A click on the turned text lands in it, by how far down the click is.
+ let hit = l.hit(0, b.x + b.w / 2.0, b.y + b.h * 0.6, StoryRef::Body).unwrap();
+ assert_eq!(hit.path, Path(vec![1, 0, 1, 0]));
+ assert!(hit.off > 3 && hit.off < text.len(), "{hit:?}");
+ // Selection highlights are turned too: tall, inside the text's area.
+ let sel = l.selection_rects(&d, &pos(0), &pos(text.len()), 0);
+ assert!(sel.iter().all(|(_, r)| r.h > r.w && r.x >= b.x - 1.0 && r.right() <= b.right() + 1.0), "{sel:?}");
+ // Drawn in a turned frame.
+ let draws = display::page_display(&d, &l.pages[0], &Default::default());
+ let turned = draws.iter().find_map(|x| if let display::Draw::Turned { turn, items, .. } = x { Some((*turn, items)) } else { None });
+ let (turn, items) = turned.expect("turned draw");
+ assert_eq!(turn, TextDirection::Down);
+ assert!(items.iter().any(|x| matches!(x, display::Draw::Glyphs { text, .. } if text.contains("Turned"))), "{items:?}");
+}
+
+#[test]
+fn turned_up_cell_text_reads_bottom_to_top() {
+ let text = "Bottom to top";
+ let d = turned_table(TextDirection::Up, text, None);
+ let l = lay(&d);
+ let b = turned_lines(&l).turned_bounds().unwrap();
+ // Against the cell's left edge; the text starts at the bottom and climbs.
+ assert!((b.x - (72.0 + 156.0 + 5.4)).abs() < 1.0, "{b:?}");
+ let pos = |off| Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off };
+ let (c0, c1) = (l.caret(&pos(0)).unwrap(), l.caret(&pos(text.len())).unwrap());
+ assert!((c0.top - b.bottom()).abs() < 1.0 && c1.top < c0.top - 50.0, "{c0:?} {c1:?} {b:?}");
+ let hit = l.hit(0, b.x + b.w / 2.0, b.bottom() - 2.0, StoryRef::Body).unwrap();
+ assert_eq!((hit.path, hit.off), (Path(vec![1, 0, 1, 0]), 0));
+}
+
+#[test]
+fn turned_text_wraps_in_an_exact_row() {
+ let d = turned_table(TextDirection::Down, &"word ".repeat(30), Some(72.0));
+ let l = lay(&d);
+ let it = turned_lines(&l);
+ let lines = if let Placed::Lines { l0, l1, .. } = it { l1 - l0 } else { 0 };
+ assert!(lines > 2, "the text wraps at the row height");
+ let b = it.turned_bounds().unwrap();
+ assert!(b.h <= 72.0 && b.w > 30.0, "{b:?}");
+ let after = l.caret(&Pos::body(2, 0)).unwrap();
+ assert!(after.top < b.y + 72.0 + 30.0, "the row keeps its exact height: {after:?}");
+}
+
+#[test]
+fn hostile_turned_cells_never_panic() {
+ // Empty, huge and nested turned cells, in exact rows too small for anything.
+ for dir in [TextDirection::Down, TextDirection::Up] {
+ for row in [None, Some(0.5), Some(1e9)] {
+ let mut d = turned_table(dir, "", row);
+ let l = lay(&d);
+ let _ = l.caret(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 1, 0]), off: 0 });
+ let mut inner = Table::new(1, 1, 20.0);
+ inner.rows[0].cells[0].props.text_direction = dir;
+ if let Some(Block::Table(t)) = d.body.get_mut(1).map(Arc::make_mut) {
+ t.rows[0].cells[1].blocks.insert(0, Arc::new(Block::Table(inner)));
+ }
+ let l = lay(&d);
+ for p in &l.pages {
+ let _ = display::page_display(&d, p, &Default::default());
+ }
+ }
+ }
+ let l = lay(&turned_table(TextDirection::Down, &"long ".repeat(5000), None));
+ assert!(!l.pages.is_empty());
+}
+
fn rules_on_page0(l: &DocLayout) -> Vec {
l.pages[0].items.iter().filter_map(|i| if let Placed::Rule { border, .. } = i { Some(border.width) } else { None }).collect()
}
diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs
index b348bfd7f0..3a79b92628 100644
--- a/crates/pdf/src/lib.rs
+++ b/crates/pdf/src/lib.rs
@@ -544,7 +544,7 @@ impl Exporter<'_> {
let idx = self.entry(*story, path, para);
for d in &draws {
match d {
- Draw::Glyphs { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
+ Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)),
Draw::Image { .. } => self.tagged(s, Role::Figure, None, |me, s| me.draw(s, d)),
_ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)),
}
@@ -651,6 +651,17 @@ impl Exporter<'_> {
s.set_stroke(None);
}
Draw::Mark { .. } => {}
+ Draw::Turned { x, y, turn, items } => {
+ if !ok(*x) || !ok(*y) {
+ return;
+ }
+ let [a, b, c, d, e, f] = Draw::turn_matrix(*turn, *x, *y);
+ s.push_transform(&Transform::from_row(a, b, c, d, e, f));
+ for it in items {
+ self.draw(s, it);
+ }
+ s.pop();
+ }
}
}
diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs
index cde73f8e46..9c5fdfbfe2 100644
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -549,3 +549,18 @@ fn synthetic_bold_text_is_extracted_once() {
let text = squash(&extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat());
assert!(text.contains("Mono bold"), "{text:?}");
}
+
+#[test]
+fn turned_cell_text_exports_as_text() {
+ // Table Layout › Text Direction (#226): turned cell text is drawn in a turned frame and
+ // stays real text in the PDF.
+ for dir in [wordcraft_doc::props::TextDirection::Down, wordcraft_doc::props::TextDirection::Up] {
+ let mut t = Table::new(1, 2, 300.0);
+ t.rows[0].cells[0].blocks = vec![para_block(Paragraph::with_text("Turned", CharProps::default()))];
+ t.rows[0].cells[0].props.text_direction = dir;
+ let mut d = Document::new();
+ d.body = vec![Arc::new(Block::Table(t)), para_block(Paragraph::with_text("Below", CharProps::default()))];
+ let text = squash(&extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat());
+ assert!(text.contains("Turned") && text.contains("Below"), "{dir:?}: {text:?}");
+ }
+}
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index 8d66f32ff7..ac11793957 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -431,6 +431,13 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl
ctx.stroke_path(&path);
}
}
+ Draw::Turned { x, y, turn, items } => {
+ let m = Affine::new(Draw::turn_matrix(*turn, *x, *y).map(f64::from));
+ let local = m.inverse().transform_rect_bbox(*visible);
+ for it in items {
+ draw(ctx, doc, it, view * m, &local, opts);
+ }
+ }
}
}
@@ -523,6 +530,28 @@ mod tests {
assert!(!img.to_png().is_empty());
}
+ /// Table Layout › Text Direction (#226): a turned cell's text is painted running down the
+ /// cell, not across it.
+ #[test]
+ fn turned_cell_text_is_painted_turned() {
+ use wordcraft_doc::props::TextDirection;
+ let ink = |dir: TextDirection| {
+ let mut d = Document::from_text("before\nafter");
+ let mut t = wordcraft_doc::Table::new(1, 2, 400.0);
+ t.rows[0].cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("WWWWWWWWWWWW", Default::default()))];
+ t.rows[0].cells[0].props.text_direction = dir;
+ d.insert_block(wordcraft_doc::StoryRef::Body, &wordcraft_doc::Path::top(1), wordcraft_doc::Block::Table(t)).unwrap();
+ let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
+ let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default());
+ // Dark pixels inside the first cell (x 72..272, clear of its borders), well below
+ // the first text line.
+ (80..264).flat_map(|x| (140..190).map(move |y| (x, y))).filter(|(x, y)| img.pixel(*x, *y)[0] < 128).count()
+ };
+ assert_eq!(ink(TextDirection::Horizontal), 0, "horizontal text stays in its line");
+ assert!(ink(TextDirection::Down) > 30, "down");
+ assert!(ink(TextDirection::Up) > 30, "up");
+ }
+
#[test]
fn stem_darkening_follows_the_macos_curve() {
assert_eq!(stem_darkening(0.0), 0.0);
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 25b31fb619..b4eb327127 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -207,8 +207,8 @@ fn page_key(app: &WordApp, page: &Page, scale_px: f32, dim_body: bool) -> u64 {
list.len().hash(&mut h);
for it in list.iter() {
match it {
- Placed::Lines { para, l0, l1, x, y, .. } => {
- (std::sync::Arc::as_ptr(para) as usize, l0, l1, x.to_bits(), y.to_bits()).hash(&mut h);
+ Placed::Lines { para, l0, l1, x, y, turn, .. } => {
+ (std::sync::Arc::as_ptr(para) as usize, l0, l1, x.to_bits(), y.to_bits(), turn).hash(&mut h);
}
Placed::Fill { rect, color } => format!("{rect:?}{color:?}").hash(&mut h),
Placed::Rule { x0, y0, x1, y1, border } => format!("{x0}{y0}{x1}{y1}{border:?}").hash(&mut h),
@@ -279,7 +279,10 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
&& let Some(c) = caret
&& let Some(pr) = geo.rects.get(c.page)
{
- let r = Rect::from_min_size(pos2(pr.min.x + c.x * geo.scale, pr.min.y + c.top * geo.scale), vec2(2.0, c.height * geo.scale));
+ let r = Rect::from_min_size(
+ pos2(pr.min.x + c.x * geo.scale, pr.min.y + c.top * geo.scale),
+ vec2((c.width * geo.scale).max(2.0), c.height * geo.scale),
+ );
scroll_target = Some(r.expand2(vec2(40.0, 60.0)));
}
app.canvas.scroll_to_caret = false;
@@ -461,7 +464,13 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
let since = crate::now_ms() - app.canvas.caret_visible_since;
let on = ((since / 530.0) as u64).is_multiple_of(2);
if app.session.sel.is_collapsed() && on && focused {
- painter.line_segment([pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)], Stroke::new(1.5, caret_color));
+ let bar = if c.width > 0.0 {
+ // Turned text (a table cell's text direction): the caret lies across the page.
+ [pos2(x, y0.round() + 0.5), pos2(x + c.width * scale, y0.round() + 0.5)]
+ } else {
+ [pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)]
+ };
+ painter.line_segment(bar, Stroke::new(1.5, caret_color));
}
if focused {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530)));
@@ -998,7 +1007,7 @@ pub fn caret_screen(app: &mut WordApp) -> Option<(Pos2, f32)> {
.get(c.page)
.and_then(|r| l.pages.get(c.page).map(|page| page_screen_scale(*r, page, app.canvas.scale)))
.unwrap_or(app.canvas.scale);
- Some((p, c.height * scale))
+ Some((p, c.height.max(c.width) * scale))
}
pub fn pos_from_screen(app: &mut WordApp, p: Pos2) -> Option {