Choose which side of an object text wraps on (#364)

* Tight and Through wrapping follow the object's outline (#354)

Text now wraps around a Tight or Through object's shape instead of its
bounding box. The outline is the object's wrap polygon (wp:wrapPolygon,
in the 21600-unit space of its frame) when it has one; otherwise it is
derived: a shape's geometry (ellipse, triangle, star, heart... flattened),
the convex hull of a picture's opaque pixels (alpha threshold on a coarse
grid, at most 64 points), or the rectangle. The outline turns and flips
with the object, keeps the left/right and top/bottom distances from text,
and is built once per placed object; each line asks it which stretches of
its band it covers. Through also lets text into gaps inside the outline
that are wide enough.

DOCX reads and writes wp:wrapPolygon (with edited) for wrapTight and
wrapThrough; a shape without one gets its outline written, other objects
their rectangle, since the schema requires a polygon. Square, top and
bottom, behind and in front of text are unchanged.

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

* Choose which side of an object text wraps on (#354)

Square, Tight and Through objects now have a wrap side, like Word's
"Wrap text" choice: both sides (the default), left only, right only, or
the largest side. Left keeps the band to the object's right empty on
every line beside it, Right the band to its left, and Largest picks the
wider side line by line. It works with contour wrapping and with turned
objects, and right-to-left paragraphs mirror it.

The setting is Float::wrap_text, read from and written to wrapText on
wp:wrapSquare, wp:wrapTight and wp:wrapThrough (previously always
written as bothSides). The new arrange.wrapText command
{"side": "bothSides|left|right|largest"} sets it on the selected object
as one undo step. The ribbon's Wrap Text button is now a menu with the
wrap styles and a Wrap Text Sides submenu, translated in every
interface language.

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-11 18:39:42 +02:00 committed by GitHub
parent 860f4ca260
commit dba6e66d53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 338 additions and 22 deletions

View File

@ -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<WrapText> {
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`).

View File

@ -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,

View File

@ -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() {

View File

@ -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#"<wp:wrapSquare wrapText="left"/>"#,
r#"<wp:wrapTight wrapText="right">"#,
r#"<wp:wrapThrough wrapText="largest">"#,
r#"<wp:wrapSquare wrapText="bothSides"/>"#,
] {
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);
}

View File

@ -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<CommandSpec> {
})
.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);

View File

@ -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!({})),

View File

@ -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<F
}
/// An area text keeps clear of: its bounds, whether text may only go above and below it, and
/// (Tight and Through wrapping) the outline text follows inside it.
type WrapArea = (Rect, bool, Option<Arc<contour::Contour>>);
/// (Tight and Through wrapping) the outline text follows inside it, and the sides text may go on.
type WrapArea = (Rect, bool, Option<Arc<contour::Contour>>, 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<W
if matches!(float.wrap, Wrap::Tight | Wrap::Through)
&& let Some((area, c)) = outline.and_then(|o| contour::contour_area(r, float, o))
{
return Some((area, false, Some(Arc::new(c))));
return Some((area, false, Some(Arc::new(c)), float.wrap_text));
}
let d = |v: f32| if v.is_finite() { v.clamp(0.0, 1584.0) } else { 0.0 };
let (side, top, bottom) = (d(float.dist), d(float.dist_top), d(float.dist_bottom));
// A rotated object keeps text clear of its rotated bounds.
let r = float.spin().bounds(r);
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom, None))
Some((Rect::new(r.x - side, r.y - top, r.w + side * 2.0, r.h + top + bottom), float.wrap == Wrap::TopAndBottom, None, float.wrap_text))
}
/// Wrap areas relative to a paragraph whose column starts at `x0` and whose top is `y0`.
fn rel_exclusions(excl: &[WrapArea], x0: f32, y0: f32) -> Vec<para::Exclusion> {
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;
}

View File

@ -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<Arc<crate::contour::Contour>>,
/// 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() {

View File

@ -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::<Vec<_>>()
};
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:?}");
}

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -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 ショートカットを選択

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 Только по большей стороне

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 Изаберите пречицу

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -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ş

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -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 Виберіть сполучення клавіш

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -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 选择快捷键

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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 選擇快速鍵

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -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);

View File

@ -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) |

View File

@ -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) |

View File

@ -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) |