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 {