diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 660b6d9..8385924 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -58,6 +58,49 @@ pub enum Wrap { InFrontOfText, } +/// Which sides of a Square, Tight or Through object text may flow on (`@wrapText`, ECMA-376 +/// Part 1, §20.4.3.7 `ST_WrapText`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum WrapText { + /// Both sides of the object. + #[default] + BothSides, + /// Only the left side: the band to the object's right stays empty. + Left, + /// Only the right side. + Right, + /// Only whichever side has more room, decided per line. + Largest, +} + +impl WrapText { + /// From the OOXML value (`bothSides`, `left`, `right`, `largest`). + pub fn from_ooxml(v: &str) -> Option { + match v { + "bothSides" => Some(WrapText::BothSides), + "left" => Some(WrapText::Left), + "right" => Some(WrapText::Right), + "largest" => Some(WrapText::Largest), + _ => None, + } + } + + /// The OOXML value. + pub fn ooxml(self) -> &'static str { + match self { + WrapText::BothSides => "bothSides", + WrapText::Left => "left", + WrapText::Right => "right", + WrapText::Largest => "largest", + } + } + + pub fn is_both(&self) -> bool { + *self == WrapText::BothSides + } +} + /// What a floating object's offset is relative to. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -130,6 +173,9 @@ impl FloatAlign { #[serde(default, rename_all = "camelCase")] pub struct Float { pub wrap: Wrap, + /// The sides text wraps on (Square, Tight and Through). + #[serde(skip_serializing_if = "WrapText::is_both")] + pub wrap_text: WrapText, pub h_rel: Anchor, pub v_rel: Anchor, /// Offsets, points (used when the matching alignment is `None`). diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 3a2b6ce..e3b1996 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use wordcraft_doc::effects::{Glow, Shadow, ShapeEffects}; use wordcraft_doc::graphic::{Embedded, Graphic, GraphicItem, GraphicKind}; -use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap}; +use wordcraft_doc::para::{Anchor, Float, FloatAlign, NoteKind, ShapeKind, Wrap, WrapText}; use wordcraft_doc::props::{CharProps, NumChange, PropChange, Rgb}; use wordcraft_doc::table::{Cell, MAX_COLS, MAX_ROWS, Row, Table}; use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, PartKind, RevisionKind, Run, para_block}; @@ -1044,9 +1044,13 @@ fn anchor_float(c: &El) -> Float { let behind = on_off_attr(c, "behindDoc"); for k in c.els() { match k.name.as_str() { - "wp:wrapSquare" => f.wrap = Wrap::Square, + "wp:wrapSquare" => { + f.wrap = Wrap::Square; + f.wrap_text = k.attr("wrapText").and_then(WrapText::from_ooxml).unwrap_or_default(); + } "wp:wrapTight" | "wp:wrapThrough" => { f.wrap = if k.name == "wp:wrapTight" { Wrap::Tight } else { Wrap::Through }; + f.wrap_text = k.attr("wrapText").and_then(WrapText::from_ooxml).unwrap_or_default(); f.wrap_polygon = k.child("wp:wrapPolygon").and_then(wrap_polygon).map(Arc::new); } "wp:wrapTopAndBottom" => f.wrap = Wrap::TopAndBottom, diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 67f6482..6210f44 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -809,10 +809,10 @@ impl Writer<'_> { w.empty("wp:effectExtent", &[("l", &el), ("t", &et), ("r", &er), ("b", &eb)]); match float.wrap { Wrap::Inline => {} - Wrap::Square => w.empty("wp:wrapSquare", &[("wrapText", "bothSides")]), + Wrap::Square => w.empty("wp:wrapSquare", &[("wrapText", float.wrap_text.ooxml())]), Wrap::Tight | Wrap::Through => { let tag = if float.wrap == Wrap::Tight { "wp:wrapTight" } else { "wp:wrapThrough" }; - w.open(tag, &[("wrapText", "bothSides")]); + w.open(tag, &[("wrapText", float.wrap_text.ooxml())]); // The object's own polygon, or its rectangle (the schema requires one). let s = wordcraft_doc::wrap::WRAP_SPACE; let (edited, pts) = match float.wrap_polygon.as_deref() { diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 26a5599..3c483e4 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -1503,3 +1503,48 @@ fn wrap_polygons_round_trip() { assert_eq!(floats[1].wrap, Wrap::Through); assert_eq!(floats[1].wrap_polygon.as_deref(), ellipse.as_ref()); } + +/// #354: the sides text wraps on (`@wrapText`) are read and written for Square, Tight and +/// Through; both sides is the default. +#[test] +fn wrap_text_sides_round_trip() { + use wordcraft_doc::para::WrapText; + let mut d = Document::new(); + let shape = |float| InlineObject::Shape { + kind: ShapeKind::Rectangle, + w: 80.0, + h: 40.0, + fill: Some(Rgb(1, 2, 3)), + stroke: None, + stroke_width: 0.0, + float, + story: None, + effects: Default::default(), + freeform: None, + }; + let sides = + [(Wrap::Square, WrapText::Left), (Wrap::Tight, WrapText::Right), (Wrap::Through, WrapText::Largest), (Wrap::Square, WrapText::BothSides)]; + let mut p = Paragraph::with_text("wrapped ", CharProps::default()); + for (wrap, side) in sides { + let end = p.len(); + p.insert_object(end, shape(Float { wrap, wrap_text: side, v_rel: Anchor::Paragraph, ..Default::default() }), &CharProps::default()).unwrap(); + } + d.body = vec![para_block(p)]; + 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(); + for tag in [ + r#""#, + r#""#, + r#""#, + r#""#, + ] { + assert!(xml.contains(tag), "{tag}: {xml}"); + } + let r = wordcraft_docx::read(&bytes).expect("read"); + let got: Vec<_> = paras(&r)[0].objects.iter().filter_map(|o| o.frame().map(|(_, _, f)| (f.wrap, f.wrap_text))).collect(); + assert_eq!(got, sides); + // A value outside the schema is no side (read as both sides). + assert_eq!(WrapText::from_ooxml("middle"), None); +} diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index 478cfb6..d9e9e0e 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -4,7 +4,7 @@ //! caret). Picture adjustments re-encode the bitmap (originals are kept for Reset Picture). use serde_json::{Value, json}; -use wordcraft_doc::para::{Float, InlineObject, Wrap}; +use wordcraft_doc::para::{Float, InlineObject, Wrap, WrapText}; use wordcraft_doc::props::Rgb; use wordcraft_doc::{Pos, StoryRef}; use wordcraft_geom::Spin; @@ -229,6 +229,13 @@ pub fn specs() -> Vec { }) .params(r#"{"wrap": "inline|square|tight|through|topAndBottom|behindText|inFrontOfText"}"#) .when(has_movable), + CommandSpec::new("arrange.wrapText", "Wrap Text Sides", "Layout › Arrange", |s, v| { + let side = p::str(v, "side").unwrap_or("bothSides"); + let side = WrapText::from_ooxml(side).ok_or_else(|| CmdError::Params(format!("unknown side {side:?}")))?; + with_float(s, |f| f.wrap_text = side) + }) + .params(r#"{"side": "bothSides|left|right|largest"} (the sides of the selected Square, Tight or Through object text flows on; largest: the wider side, line by line)"#) + .when(has_movable), CommandSpec::new("arrange.position", "Position", "Layout › Arrange", |s, v| { let preset = p::str(v, "preset").unwrap_or("middleCenter").to_string(); let (x, y) = (p::f32(v, "x"), p::f32(v, "y")); @@ -1195,6 +1202,13 @@ mod tests { assert_eq!(obj_size(&selected(&s).unwrap().1), (100.0, 50.0)); s.run("picture.reset", &json!({})).unwrap(); s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap(); + // Text on one side only (#354), one undo step; an unknown side is refused. + let side = |s: &Session| selected(s).unwrap().1.frame().unwrap().2.wrap_text; + s.run("arrange.wrapText", &json!({"side": "largest"})).unwrap(); + assert_eq!(side(&s), WrapText::Largest); + assert!(s.run("arrange.wrapText", &json!({"side": "middle"})).is_err()); + s.run("edit.undo", &json!({})).unwrap(); + assert_eq!(side(&s), WrapText::BothSides); s.run("arrange.position", &json!({"preset": "topRight"})).unwrap(); let l = s.run("arrange.selectionPane", &json!({})).unwrap(); assert_eq!(l.as_array().unwrap().len(), 1); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 71a8519..f073c35 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2405,6 +2405,7 @@ fn charts_and_diagrams_can_be_selected_and_deleted_but_not_moved() { ("arrange.bounds", json!({"width": 50})), ("picture.size", json!({"width": 50})), ("arrange.wrap", json!({"wrap": "square"})), + ("arrange.wrapText", json!({"side": "left"})), ("arrange.position", json!({"preset": "topLeft"})), ("arrange.align", json!({"value": "left"})), ("arrange.bringForward", json!({})), diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 3a03d7a..a4725c7 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -22,7 +22,7 @@ use std::hash::{Hash, Hasher}; use std::sync::Arc; use wordcraft_doc::numbering::{Counters, Level}; -use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, Wrap}; +use wordcraft_doc::para::{Anchor, Float, FloatAlign, InlineObject, Wrap, WrapText}; use wordcraft_doc::props::{Border, CharProps, Rgb, TableFloat, TextDirection}; use wordcraft_doc::section::{SectionProps, SectionStart}; use wordcraft_doc::{Block, Blocks, Document, Paragraph, Path, StoryRef}; @@ -633,7 +633,7 @@ fn layout_box( Some(f) => { let legacy = if ctx.doc.settings.compat_mode < 15 { table::first_cell_left_margin(ctx, t) } else { 0.0 }; let (r, area) = box_float_table_rect(frame, width, y, &tl, &f, legacy, &float_tables); - excl.push((area, false, None)); + excl.push((area, false, None, WrapText::BothSides)); float_tables.push(area); (r.x, r.y) } @@ -1418,8 +1418,8 @@ fn align_in((start, extent): (f32, f32), offset: f32, size: f32, align: Option>); +/// (Tight and Through wrapping) the outline text follows inside it, and the sides text may go on. +type WrapArea = (Rect, bool, Option>, WrapText); /// The area text keeps clear of around a floating object at `r` (its rectangle grown by the /// distances from text; Tight and Through: around `outline`, unit coordinates across `r`, when @@ -1432,25 +1432,26 @@ fn wrap_area(r: Rect, float: &Float, outline: Option<&[(f32, f32)]>) -> Option Vec { excl.iter() - .map(|(r, tb, c)| para::Exclusion { + .map(|(r, tb, c, side)| para::Exclusion { top: r.y - y0, bottom: r.bottom() - y0, left: r.x - x0, right: r.right() - x0, top_bottom: *tb, contour: c.clone(), + side: *side, }) .filter(|e| e.bottom > 0.0) .collect() @@ -1999,7 +2000,7 @@ fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &Table } } let area = r.inset(-dl, -dt, -dr, -db); - pb.excl.push((area, false, None)); + pb.excl.push((area, false, None, WrapText::BothSides)); pb.float_tables.push(area); return; } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 7b56362..7ffe514 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use wordcraft_doc::math::MathJc; use wordcraft_doc::numbering::{Level, LevelSuffix}; -use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, SOFT_HYPHEN}; +use wordcraft_doc::para::{COLUMN_BREAK, InlineObject, LINE_BREAK, NoteKind, OBJ, PAGE_BREAK, SOFT_HYPHEN, WrapText}; use wordcraft_doc::props::{Align, CharProps, LineSpacing, ParaProps, TabAlign, TabLeader, TabStop}; use wordcraft_doc::resolve::{ResolvedChar, ResolvedPara}; use wordcraft_doc::{Document, Paragraph}; @@ -304,6 +304,8 @@ pub struct Exclusion { /// Tight and Through wrapping: the outline text follows inside the area (relative to its /// top-left); `None` = the whole area. pub contour: Option>, + /// The sides of the object text may go on (Square, Tight, Through). + pub side: WrapText, } struct Builder<'a> { @@ -974,6 +976,12 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio left: width - e.right, right: width - e.left, contour: e.contour.as_ref().map(|c| Arc::new(c.mirrored(e.right - e.left))), + // Mirrored, the object's left side is the line's right. + side: match e.side { + WrapText::Left => WrapText::Right, + WrapText::Right => WrapText::Left, + s => s, + }, ..e.clone() }) .collect(); @@ -1557,10 +1565,23 @@ fn row_spans(exclusions: &[Exclusion], top: f32, h: f32, lo: f32, hi: f32) -> Re // outline takes, as (left, right, bottom of the object's area). let mut taken: Vec<(f32, f32, f32)> = Vec::new(); for e in &here { + let first = taken.len(); match &e.contour { None => taken.push((e.left, e.right, e.bottom)), Some(c) => taken.extend(c.occupied(top - e.top, top + h - e.top).into_iter().map(|(a, b)| (a + e.left, b + e.left, e.bottom))), } + // Text on one side only: the object's other side of the row is taken too. + let Some(mine) = taken.get(first..).filter(|t| !t.is_empty() && !e.side.is_both()) else { continue }; + let left = mine.iter().map(|t| t.0).fold(f32::INFINITY, f32::min); + let right = mine.iter().map(|t| t.1).fold(f32::NEG_INFINITY, f32::max); + let text_left = match e.side { + WrapText::Left => true, + WrapText::Right => false, + // The side with more room on this line (left on a tie). + _ => left - lo >= hi - right, + }; + taken.truncate(first); + taken.push(if text_left { (left, hi.max(right), e.bottom) } else { (lo.min(left), right, e.bottom) }); } taken.retain(|(l, r, _)| *r > lo && *l < hi); if taken.is_empty() { diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index e2333cc..1c60af4 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2126,10 +2126,10 @@ fn floats_in_headers_and_table_cells_are_drawn() { fn wrap_area_uses_each_distance() { use wordcraft_doc::para::{Float, Wrap}; let f = Float { wrap: Wrap::Square, dist: 9.0, dist_top: 0.0, dist_bottom: 4.0, ..Default::default() }; - let (r, tb, _) = wrap_area(Rect::new(10.0, 20.0, 100.0, 50.0), &f, None).unwrap(); + let (r, tb, ..) = wrap_area(Rect::new(10.0, 20.0, 100.0, 50.0), &f, None).unwrap(); assert_eq!((r.x, r.y, r.w, r.h, tb), (1.0, 20.0, 118.0, 54.0, false)); let hostile = Float { wrap: Wrap::TopAndBottom, dist: f32::NAN, dist_top: -5.0, dist_bottom: f32::INFINITY, ..Default::default() }; - let (r, tb, _) = wrap_area(Rect::new(0.0, 0.0, 10.0, 10.0), &hostile, None).unwrap(); + let (r, tb, ..) = wrap_area(Rect::new(0.0, 0.0, 10.0, 10.0), &hostile, None).unwrap(); assert!(r.w.is_finite() && r.h.is_finite() && tb, "{r:?}"); assert!(wrap_area(Rect::new(0.0, 0.0, 10.0, 10.0), &Float { wrap: Wrap::BehindText, ..Default::default() }, None).is_none()); } @@ -3078,3 +3078,31 @@ fn contour_wrap_turns_with_the_object() { // Square wrapping ignores the polygon. assert_eq!(first_row(Wrap::Square, 0.0, false)[0].0, 209.0); } + +/// #354: `wrapText`. Left: text only to the left of a centred object (Square or Tight); Right: +/// only to its right; Largest: only on its wider side. +#[test] +fn wrap_text_keeps_text_on_one_side() { + use wordcraft_doc::para::{Float, ShapeKind, Wrap, WrapText}; + // A 100pt shape at `x` in the 468pt column; the lines beside it. + let beside = |kind, wrap, x: f32, side| { + let f = Float { wrap, wrap_text: side, x, ..Default::default() }; + line_spans(&lay(&wrapped_shape_doc(kind, 100.0, 100.0, f))).into_iter().filter(|l| l.0 < 100.0).collect::>() + }; + for (kind, wrap) in [(ShapeKind::Rectangle, Wrap::Square), (ShapeKind::Ellipse, Wrap::Tight)] { + let both = beside(kind, wrap, 184.0, WrapText::BothSides); + assert!(both.iter().any(|l| l.2 < 1.0) && both.iter().any(|l| l.2 > 200.0), "{both:?}"); + let left = beside(kind, wrap, 184.0, WrapText::Left); + // Nothing right of the object (Tight: lines come up to the outline, short of its middle). + let mid = if wrap == Wrap::Square { 175.0 } else { 234.0 }; + assert!(!left.is_empty() && left.iter().all(|l| l.2 < 1.0 && l.3 <= mid + 0.01), "{wrap:?}: {left:?}"); + let right = beside(kind, wrap, 184.0, WrapText::Right); + let mid = if wrap == Wrap::Square { 293.0 } else { 234.0 }; + assert!(!right.is_empty() && right.iter().all(|l| l.2 >= mid - 0.01), "{wrap:?}: {right:?}"); + } + // Nearer the left edge the right side is wider, nearer the right edge the left. + let near_left = beside(ShapeKind::Rectangle, Wrap::Square, 100.0, WrapText::Largest); + assert!(!near_left.is_empty() && near_left.iter().all(|l| l.2 >= 209.0 - 0.01), "{near_left:?}"); + let near_right = beside(ShapeKind::Rectangle, Wrap::Square, 268.0, WrapText::Largest); + assert!(!near_right.is_empty() && near_right.iter().all(|l| l.2 < 1.0 && l.3 <= 259.0 + 0.01), "{near_right:?}"); +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3fd2abc..4163060 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1355,6 +1355,17 @@ Formatted Con formato Interface size: Tamaño de la interfaz: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Agranda o reduce la cinta, los menús y los cuadros de diálogo, además del escalado de pantalla del sistema. El zoom del documento es aparte. + In Line with Text En línea con el texto + Square Cuadrado + Through Transparente + Top and Bottom Arriba y abajo + Behind Text Detrás del texto + In Front of Text Delante del texto + Wrap Text Sides Lados del ajuste + Both Sides Ambos lados + Left Only Solo a la izquierda + Right Only Solo a la derecha + Largest Only Solo el lado más ancho Customize Keyboard Personalizar teclado Choose a command Elija un comando Choose a shortcut Elija un método abreviado diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 744f19e..034d20d 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1349,6 +1349,17 @@ Formatted Vormindatud Interface size: Kasutajaliidese suurus: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Muudab menüülindi, menüüd ja dialoogid suuremaks või väiksemaks, lisaks süsteemi ekraani skaleerimisele. Dokumendi suum on eraldi. + In Line with Text Tekstiga reas + Square Ruudukujuline + Through Läbiv + Top and Bottom Üleval ja all + Behind Text Teksti taga + In Front of Text Teksti ees + Wrap Text Sides Mähkimise küljed + Both Sides Mõlemal küljel + Left Only Ainult vasakul + Right Only Ainult paremal + Largest Only Ainult laiemal küljel All Commands Kõik käsud Assign Määra Choose a command Vali käsk diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ca49565..237c52e 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1339,6 +1339,17 @@ Formatted 書式変更 Interface size: 表示サイズ: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. リボン、メニュー、ダイアログを、システムの表示倍率に加えて拡大または縮小します。文書のズームは別に設定します。 + In Line with Text 行内 + Square 四角形 + Through 内部 + Top and Bottom 上下 + Behind Text 背面 + In Front of Text 前面 + Wrap Text Sides 文字を配置する側 + Both Sides 両側 + Left Only 左側のみ + Right Only 右側のみ + Largest Only 広い側のみ Customize Keyboard キーボードのユーザー設定 Choose a command コマンドを選択 Choose a shortcut ショートカットを選択 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index abcffac..e47edb9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1351,6 +1351,17 @@ Formatted Formatado Interface size: Tamanho da interface: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Aumenta ou diminui a faixa de opções, os menus e as caixas de diálogo, além da escala de exibição do sistema. O zoom do documento é separado. + In Line with Text Alinhado ao texto + Square Quadrado + Through Através + Top and Bottom Acima e abaixo + Behind Text Atrás do texto + In Front of Text Na frente do texto + Wrap Text Sides Lados da quebra + Both Sides Ambos os lados + Left Only Somente à esquerda + Right Only Somente à direita + Largest Only Somente o lado mais largo Customize Keyboard Personalizar teclado Choose a command Escolha um comando Choose a shortcut Escolha um atalho diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e4f33d5..e6f9ba9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1767,3 +1767,13 @@ preview Title Пример New Group Создать группу Rename… Переименовать… Ribbon and Quick Access Toolbar: Лента и панель быстрого доступа: + In Line with Text В тексте + Through Сквозное + Top and Bottom Сверху и снизу + Behind Text За текстом + In Front of Text Перед текстом + Wrap Text Sides Стороны обтекания + Both Sides С обеих сторон + Left Only Только слева + Right Only Только справа + Largest Only Только по большей стороне diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 6bab87d..e5c7b1d 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1349,6 +1349,17 @@ Formatted Formatirano Interface size: Veličina interfejsa: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Povećava ili smanjuje traku, menije i dijaloge, povrh skaliranja ekrana u sistemu. Zumiranje dokumenta je zasebno. + In Line with Text U ravni sa tekstom + Square Kvadratno + Through Kroz + Top and Bottom Iznad i ispod + Behind Text Iza teksta + In Front of Text Ispred teksta + Wrap Text Sides Strane preloma teksta + Both Sides Obe strane + Left Only Samo levo + Right Only Samo desno + Largest Only Samo šira strana Customize Keyboard Prilagođavanje tastature Choose a command Izaberite komandu Choose a shortcut Izaberite prečicu diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 05c641c..05818e7 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1353,6 +1353,17 @@ Formatted Форматирано Interface size: Величина интерфејса: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Повећава или смањује траку, меније и дијалоге, поврх скалирања екрана у систему. Зумирање документа је засебно. + In Line with Text У равни са текстом + Square Квадратно + Through Кроз + Top and Bottom Изнад и испод + Behind Text Иза текста + In Front of Text Испред текста + Wrap Text Sides Стране прелома текста + Both Sides Обе стране + Left Only Само лево + Right Only Само десно + Largest Only Само шира страна Customize Keyboard Прилагођавање тастатуре Choose a command Изаберите команду Choose a shortcut Изаберите пречицу diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 98f6fc1..04743e9 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1384,3 +1384,14 @@ New Group Yeni Grup Rename… Yeniden Adlandır… Ribbon and Quick Access Toolbar: Şerit ve Hızlı Erişim Araç Çubuğu: + In Line with Text Metinle Aynı Hizada + Square Kare + Through Geçişli + Top and Bottom Üst ve Alt + Behind Text Metnin Arkasında + In Front of Text Metnin Önünde + Wrap Text Sides Metin Kaydırma Kenarları + Both Sides Her İki Kenar + Left Only Yalnızca Sol + Right Only Yalnızca Sağ + Largest Only Yalnızca En Geniş diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index da5a073..a0b7fd8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1351,6 +1351,17 @@ Formatted Відформатовано Interface size: Розмір інтерфейсу: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. Збільшує або зменшує стрічку, меню й діалогові вікна понад системне масштабування екрана. Масштаб документа налаштовується окремо. + In Line with Text У рядку з текстом + Square Квадрат + Through Наскрізне + Top and Bottom Зверху і знизу + Behind Text За текстом + In Front of Text Перед текстом + Wrap Text Sides Сторони обтікання + Both Sides З обох боків + Left Only Лише ліворуч + Right Only Лише праворуч + Largest Only Лише з ширшого боку Customize Keyboard Настроювання клавіатури Choose a command Виберіть команду Choose a shortcut Виберіть сполучення клавіш diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 060324d..cfdc917 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1351,6 +1351,17 @@ Formatted 已设置格式 Interface size: 界面大小: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系统显示缩放的基础上放大或缩小功能区、菜单和对话框。文档缩放单独设置。 + In Line with Text 与文字同行 + Square 四周型 + Through 穿越型 + Top and Bottom 上下型 + Behind Text 衬于文字下方 + In Front of Text 浮于文字上方 + Wrap Text Sides 文字环绕侧 + Both Sides 两侧 + Left Only 仅左侧 + Right Only 仅右侧 + Largest Only 仅较宽一侧 Customize Keyboard 自定义键盘 Choose a command 选择命令 Choose a shortcut 选择快捷键 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3e66d24..5cf2f21 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1340,6 +1340,17 @@ Formatted 已設定格式 Interface size: 介面大小: Makes the ribbon, menus and dialogs larger or smaller, on top of your system's display scaling. Document zoom is separate. 在系統顯示縮放的基礎上放大或縮小功能區、功能表與對話方塊。文件縮放另行設定。 + In Line with Text 與文字同行 + Square 矩形 + Through 穿透 + Top and Bottom 上及下 + Behind Text 文字之後 + In Front of Text 文字之前 + Wrap Text Sides 文字繞排側 + Both Sides 兩側 + Left Only 僅左側 + Right Only 僅右側 + Largest Only 僅較寬的一側 Customize Keyboard 自訂鍵盤 Choose a command 選擇命令 Choose a shortcut 選擇快速鍵 diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index f72d3d8..8c446be 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -921,7 +921,7 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Arrange", None, app, |ui, app| { big(ui, app, "position", "Position", "arrange.position", json!({}), false); - big(ui, app, "wrapText", "Wrap\nText", "arrange.wrap", json!({}), false); + wrap_menu(ui, app); stack(ui, |ui| { small(ui, app, "bringForward", Some("Bring Forward"), "Bring Forward", "arrange.bringForward", json!({}), false); small(ui, app, "sendBackward", Some("Send Backward"), "Send Backward", "arrange.sendBackward", json!({}), false); @@ -936,6 +936,39 @@ fn layout(app: &mut WordApp, ui: &mut Ui) { } /// Arrange › Rotate: turn 90° either way or flip (the angle and mirroring, not the pixels). +/// Arrange › Wrap Text: how the selected object sits in the text, and (Square, Tight, Through) +/// which of its sides text flows on. +fn wrap_menu(ui: &mut Ui, app: &mut WordApp) { + let (wrap, side) = wordcraft_engine::cmd::objects::selected(&app.session) + .and_then(|(_, o)| o.frame().map(|(_, _, f)| (f.wrap, f.wrap_text))) + .unwrap_or_default(); + menu_button(ui, app, "wrapText", Some("Wrap\nText"), "Wrap Text", true, |ui, app| { + use wordcraft_doc::para::{Wrap, WrapText}; + for (l, w, v) in [ + ("In Line with Text", Wrap::Inline, "inline"), + ("Square", Wrap::Square, "square"), + ("Tight", Wrap::Tight, "tight"), + ("Through", Wrap::Through, "through"), + ("Top and Bottom", Wrap::TopAndBottom, "topAndBottom"), + ("Behind Text", Wrap::BehindText, "behindText"), + ("In Front of Text", Wrap::InFrontOfText, "inFrontOfText"), + ] { + mi_check(ui, app, l, wrap == w, "arrange.wrap", json!({"wrap": v})); + } + ui.separator(); + ui.menu_button(tl!("Wrap Text Sides"), |ui| { + for (l, s, v) in [ + ("Both Sides", WrapText::BothSides, "bothSides"), + ("Left Only", WrapText::Left, "left"), + ("Right Only", WrapText::Right, "right"), + ("Largest Only", WrapText::Largest, "largest"), + ] { + mi_check(ui, app, l, side == s, "arrange.wrapText", json!({"side": v})); + } + }); + }); +} + fn rotate_menu(ui: &mut Ui, app: &mut WordApp, label: Option<&str>) { menu_button(ui, app, "rotate", label, "Rotate", false, |ui, app| { mi(ui, app, "Rotate Right 90°", "arrange.rotate", json!({"direction": "right"})); @@ -1308,7 +1341,7 @@ fn shape_format(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Arrange", None, app, |ui, app| { big(ui, app, "position", "Position", "arrange.position", json!({}), false); - big(ui, app, "wrapText", "Wrap\nText", "arrange.wrap", json!({}), false); + wrap_menu(ui, app); stack(ui, |ui| { rotate_menu(ui, app, Some("Rotate")); small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false); @@ -1420,7 +1453,7 @@ fn picture_format(app: &mut WordApp, ui: &mut Ui) { }); group(ui, "Arrange", None, app, |ui, app| { big(ui, app, "position", "Position", "arrange.position", json!({}), false); - big(ui, app, "wrapText", "Wrap\nText", "arrange.wrap", json!({}), false); + wrap_menu(ui, app); stack(ui, |ui| { rotate_menu(ui, app, Some("Rotate")); small(ui, app, "align", Some("Align"), "Align", "arrange.align", json!({}), false); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4c30c10..da5a9ed 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -55,7 +55,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result | | Content controls (`w:sdt`) | 🟡 | ❌ | Content kept, the control (type, binding, placeholder, lock) dropped | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | -| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle) | +| DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | Group shapes (`wpg:`), drawing canvas | 🟡 | 🟡 | Group shapes read and written (#267); drawing canvas (`wpc:`) dropped | | DrawingML shapes and text boxes (`wps:`) | ✅ | ✅ | Preset geometries subset; rotation and flips (`a:xfrm` `rot`/`flipH`/`flipV`, pictures and groups too, #332), the effect extent covering the rotated bounds | | Freeform shapes (`a:custGeom`: `a:moveTo`, `a:lnTo`, Bézier curves flattened) and WordCraft ink | ✅ | ✅ | Ink is written as a freeform `wps:wsp` in a `wp:anchor` (round caps, alpha for highlighter) and recognised again by its drawing name (#307). It is saved as a custom-geometry shape, not as Word's own ink (InkML in `w14:contentPart`), so Word shows WordCraft ink as a freeform shape it can move and recolour but not erase with its ink eraser; arcs drawn straight | @@ -88,6 +88,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | 2026-10-11 | trivial | Formatting revisions (`w:rPrChange`, `w:pPrChange`, table, row, cell, section and numbering changes) read and written (#41) | | 2026-10-10 | trivial | OLE objects turned or flipped in WordCraft keep the turn on save (VML `rotation`/`flip`, DrawingML `a:xfrm` with the rotated effect extent); test counts refreshed | | 2026-10-10 | trivial | Charts, SmartArt and OLE objects written back on save with their parts (#319); chart/SmartArt reading (#292) recorded | +| 2026-10-10 | trivial | `wrapText` on square, tight and through wraps read and written (#354) | | 2026-10-10 | trivial | Wrap polygons for Tight and Through wrapping read and written (#354) | | 2026-10-10 | trivial | DrawingML rotation and flips read and written (#332) | | 2026-10-10 | trivial | Equation breaks and `m:mathPr` wrapping options round-trip (#326) | diff --git a/docs/gaps.md b/docs/gaps.md index ae4bf04..1513cc5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -20,7 +20,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 7 | **Dialog depth**: 35 modal dialogs vs Word's ~100 (Caption, Index, TOC options, Citation, Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 20–30 | [ui-parity.md](ui-parity.md) | | 8 | **Proofing is English only**; no language per run, no dictionaries for other languages | | `crates/proof` has one dictionary; Word ships 120 proofing tools; issues #25, #40, #100 | Every non-English writer | 25–45 | [localization-parity.md](localization-parity.md) | | 9 | **Password-protected files** can't be opened or saved | | #55; `docbin` detects and refuses RC4/XOR | Corporate and legal users | 6–10 | [file-format-parity.md](file-format-parity.md) | -| 10 | **Objects**: no group (#267 open), rotated text-box text (free rotation and flips landed, #332; contour wrap landed, #354), shape effects, WordArt, connectors, Drawing Canvas, Icons | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | +| 10 | **Objects**: no group (#267 open), rotated text-box text (free rotation and flips landed, #332; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, Drawing Canvas, Icons | | [parity-checklist.md](parity-checklist.md) (Shape Format 2/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) | | 11 | **Interface languages**: 8 of the 12 key languages missing (hi, ar, fr, id, de, ko, vi, pt-PT); no mirrored RTL interface; ~5% of strings still English in the six translated catalogs | | [localization-parity.md](localization-parity.md) | Most of the world's writers | 50–80 | [localization-parity.md](localization-parity.md) | | 12 | **Draw tab / ink**: pens, pencil, highlighter, eraser, Select and Hide Ink landed (#307); missing: Lasso Select, Add Pen, Ink to Shape, Ink to Math, Ink Replay, pressure, Word's own ink (`w14:contentPart`/InkML) | | [parity-checklist.md](parity-checklist.md) (Draw 5/11) | Pen and tablet users | 8–14 | [hardware-parity.md](hardware-parity.md) | | 13 | **Charts and SmartArt editing** (own renderer, chart data editing, SmartArt layouts) | | `insert.chart`, `insert.smartArt` missing | Reports and business documents | 45–70 | [target-app-parity.md](target-app-parity.md) | @@ -77,6 +77,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | 2026-10-10 | trivial | #2 narrowed: charts and SmartArt drawn (#292), and with OLE objects kept on save (#319); group shapes landed (#267); ink and drawing canvases remain | | 2026-10-10 | trivial | #4: long footnotes continue onto the next page (#352) | | 2026-10-10 | trivial | Define New Multilevel List and Track Changes Options dialogs landed (#328); removed from the dialog-depth list | +| 2026-10-10 | trivial | #10: wrap side (both sides, left, right, largest) for Square, Tight and Through objects (#354) | | 2026-10-10 | trivial | #10: Tight and Through wrapping follow the object's outline (#354) | | 2026-10-10 | trivial | #10: free rotation and flips for pictures, shapes and text boxes, with a rotation handle (#332); text boxes rotate their frame; their text stays upright | | 2026-10-10 | trivial | #29: long display equations wrap across lines (#326) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index d6251a3..7b476cd 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -38,7 +38,7 @@ font can't match Word's metrics. | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06 | — | -| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Text always wraps on both sides (Word's left/right/largest-side choice isn't modelled); picture outlines are convex; not compared page-for-page with Word | 1–2 | +| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; not compared page-for-page with Word | 1–2 | | Text boxes, overflow, linked text boxes | 🟡 | #46; linked text boxes missing | 3–5 | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | @@ -70,6 +70,7 @@ font can't match Word's metrics. |---|---|---| | 2026-10-11 | trivial | Group and Ungroup keep turned members' orientation and placement (#441, #443); Size resizes a group (#442); the eraser hits turned ink where it is drawn (#445) | | 2026-10-10 | trivial | Long footnotes continue onto the next page with a continuation separator (#352) | +| 2026-10-10 | trivial | Wrap side: text on both sides, left only, right only or the largest side of an object (#354) | | 2026-10-10 | trivial | Tight and Through wrapping follow the object's outline (#354) | | 2026-10-10 | trivial | Rotated and flipped objects laid out and drawn (#332) | | 2026-10-10 | trivial | Long display equations wrap across lines (#326) |