Table Layout › Text Direction turns the cell's text (#226) (#245)

* Tests: shapes and pictures can be clicked, resized, dragged and nudged

Fixed by #46 but only text boxes were covered. These walk the editor's
path for shapes (rectangle, ellipse, star, arrow) and pictures: hit test,
select, resize with arrange.bounds, drag to float with Square wrap,
nudge, undo. Covers #142 and #82.

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

* Font list offers every weight a family installs, named as Word names them (#219)

Fonts were listed and matched only by their typographic family name
(name ID 16, e.g. "Roboto"), so a face other than Regular, Bold or Italic
(Light, Medium, SemiBold, Black, Condensed...) never appeared in the font
list, and a document asking for "Roboto Light" fell back to a substitute.

Each face now also carries its legacy family name, the one Word lists and
stores in documents: name ID 1 for static fonts ("Source Sans 3
Semibold"), or the family plus the style words other than Regular, Bold
and Italic for a variable font's named instances ("Inter Light"). The
font list includes these names, has_family and face() resolve them (bold
and italic still choose or synthesise within the faces the name covers),
and asking for a legacy name loads the whole installed family. The
system font scan now also reads fvar, so a variable font's instances are
listed before it is loaded.

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

* Absolutely positioned VML text boxes float instead of sitting inline (#149)

A legacy VML shape (w:pict > v:shape / v:rect) with position:absolute in its
style was read as an inline object, so a tall text box in a header made the
header as tall as the box and pushed the body down the page.

The reader now maps the VML style to the same float model DrawingML anchors
use: left/margin-left and top/margin-top offsets, mso-position-*-relative
reference areas, mso-position-horizontal/vertical alignment, mso-wrap-distance-*
and w10:wrap (square, tight, through, topAndBottom; none or absent is in front
of the text, or behind it at a negative z-index). Shapes without
position:absolute stay inline. Saving writes the boxes as DrawingML anchors
with the same placement.

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

* Reject All removes paragraph breaks inserted with Track Changes on (#229)

Enter under Track Changes already recorded the new paragraph mark as an
insertion, but accepting or rejecting only cleared that flag, so Reject All
left the paragraphs split. Rejecting an inserted mark (or accepting a deleted
one) now joins the paragraph with the next one again; Accept All, Reject All,
Accept/Reject at the caret or over a selection, Next/Previous Change and the
Reviewing Pane all see paragraph marks.

Backspace at a paragraph start and Delete at a paragraph end now record a
tracked deletion of the mark (or remove a mark the same author inserted)
instead of only moving the caret; tracked deletions across paragraphs mark
the paragraph marks too.

DOCX reads and writes paragraph-mark revisions (w:ins / w:del in the
mark's w:rPr), and a tracked mark is drawn in its author's colour when
formatting marks are shown.

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

* Table Layout: Text Direction turns cell text (#226)

The Text Direction button only flipped a flag that layout and rendering
ignored (and docx always wrote it as btLr), so pressing it did nothing
visible.

Cells now carry a text direction (horizontal, top-to-bottom tbRl,
bottom-to-top btLr). The button cycles through them like Word's, for every
selected cell. Layout runs turned text along the cell's height: an exact
row height sets the line length, otherwise the row grows to fit the
unwrapped text. Turned lines are drawn in a turned frame on screen and in
PDF, and clicks, caret, and selection follow the turn. docx reads and
writes w:textDirection with the matching value.

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

* Chinese interface: embed the Chinese UI font in releases, fall back to an installed one (#241)

The release workflow pinned storytold/craft-fonts at abb8331, the commit that added the
Japanese fonts. Noto Sans CJK SC came one commit later (8dcdacd), so release builds embedded
only the Japanese faces and the Simplified Chinese interface fell back to BIZ UDPGothic, which
lacks about 157 simplified-only hanzi (页, 选, 删 …) and drew them as boxes. Document text was
unaffected because it uses the system font fallback.

- Bump the craft-fonts pin in the release and FreeBSD workflows to 8dcdacd.
- With CRAFT_FONTS_REQUIRED, a native build now fails when the craft-fonts manifest has no
  Jpan or Hans font, so an outdated pin can't ship silently again.
- When no embedded face covers the interface language, the UI font families get an installed
  CJK font (desktop only): a short per-platform list of files in the system font folders
  (Windows Deng/msyh/simhei/simsun, macOS PingFang/STHeiti, Linux Noto Sans CJK/WenQuanYi),
  header-checked, size-capped, and the face chosen by coverage (a SC face in a pan-CJK
  collection), so egui never sees a file it can't parse.

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

* Add a System interface theme that follows the OS appearance (#115)

The interface theme is now a three-way choice: Use system setting,
Light or Dark, in File > Options > General and in a new Interface Theme
menu in View > Dark Mode. System follows the light/dark appearance egui
reports (winit on macOS, Windows and Linux; prefers-color-scheme on the
web) at launch and live, and falls back to light when the OS reports
none. egui's own theme is now pinned to ours, so an OS change no longer
swaps in egui's unstyled style.

Saved preferences migrate: the old "dark" switch becomes Dark or Light
and is never written again. Light stays the default for new installs.
New ui.theme command (light/dark/system); ui.dark still sets Light/Dark.
The dark page (View > Switch Modes) is unchanged.

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

* #248: read the installed CJK UI font only when CJK text is shown

A build without craft-fonts read a large system CJK font on the first frame even
for an English interface. Now it is loaded only for a CJK interface language, or
once File > Options shows the language list (names in their own scripts).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-10 13:05:04 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 18d3a39278
commit 8b49ea7546
16 changed files with 666 additions and 60 deletions
+63 -2
View File
@@ -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() };
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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 => {}
+25 -2
View File
@@ -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#"<w:textDirection w:val="tbRl"/>"#), "{xml}");
assert!(xml.contains(r#"<w:textDirection w:val="btLr"/>"#), "{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<TextDirection> = 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]
+20 -1
View File
@@ -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<CommandSpec> {
})
})
.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<Path> = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect();
-8
View File
@@ -274,14 +274,6 @@ pub fn specs() -> Vec<CommandSpec> {
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 = [
+48
View File
@@ -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<TextDirection> {
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
+33 -2
View File
@@ -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<Rgb>,
},
/// 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<Draw>,
},
}
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),
}
}
+60 -15
View File
@@ -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<StoryRef> {
let boxes: Vec<StoryRef> = 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<dyn Iterator<Item = &Placed> +
}
}
/// 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<LineHit<'_>> {
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<LineHit<'_>> {
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<Pos> {
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<Pos> {
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<Caret> {
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<crate::math::MathLayout>)> {
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;
+84 -4
View File
@@ -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<Rect> {
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<Placed>, height: f32) -> Vec<Placed> {
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<Placed>, height: f32) -> Vec<Placed> {
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<Placed>, story: StoryRef, path: &[u32], pl: &Arc<Pa
let pad = pl.rp.borders.as_ref().map(|b| b.left.map(|l| l.space).unwrap_or(4.0)).unwrap_or(0.0);
items.push(Placed::Fill { rect: Rect::new(left - pad, y, right - left + pad * 2.0, h), color: c });
}
items.push(Placed::Lines { story, path: Path(path.to_vec()), para: pl.clone(), l0, l1, x, y });
items.push(Placed::Lines { story, path: Path(path.to_vec()), para: pl.clone(), l0, l1, x, y, turn: TextDirection::Horizontal });
if let Some(b) = &pl.rp.borders {
let sp = |o: &Option<Border>| 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,
});
}
}
+148 -15
View File
@@ -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<TableStyleProps> {
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<Turned>,
}
let mut rows: Vec<Vec<CellBox>> = Vec::with_capacity(nrows);
let mut heights: Vec<f32> = 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<Border>| 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, .. } => {
+111 -1
View File
@@ -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<f32>) -> 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<f32> {
l.pages[0].items.iter().filter_map(|i| if let Placed::Rule { border, .. } = i { Some(border.width) } else { None }).collect()
}
+12 -1
View File
@@ -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();
}
}
}
+15
View File
@@ -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:?}");
}
}
+29
View File
@@ -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);
+14 -5
View File
@@ -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<Pos> {