Dialogs: Line Numbers, Hyphenation, Language, Envelopes and Labels (#407) (#414)

* Dialogs: Line Numbers, Hyphenation, Language, Envelopes and Labels (#407)

- Line Numbers (Line Numbering Options…): add numbering, start at, from
  text (auto or a distance), count by, restart each page/section or
  continuous; `layout.lineNumbers` takes start/countBy/distance.
- Hyphenation Options: automatic, words in CAPS, hyphenation zone,
  consecutive-hyphen limit; kept in DOCX (w:hyphenationZone,
  w:doNotHyphenateCaps, w:consecutiveHyphenLimit) and honoured by
  layout. Manual…: `layout.manualHyphenation` finds words at line starts
  that would fit hyphenated on the line above; Yes inserts an optional
  hyphen, No skips.
- Language: localized language list, do not check spelling or grammar,
  detect language automatically (per-user preference), Set As Default
  (document default language).
- Envelopes: delivery/return address, common sizes (#10, #9, Monarch,
  DL, C4, C5, C6, B5); Add to Document inserts an envelope section
  before the letter. Labels: common sheets (Letter and A4), full page or
  a single label at row/column, one per recipient; New Document builds
  the sheet with gap columns.
- Ribbon: Line Numbering Options…, Hyphenation Options…, Manual…; the
  Language, Envelopes and Labels buttons open their dialogs.

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

* Remove duplicate Label: keys in ru and tr (#407)

---------

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 16:15:53 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent d5bcb9b490
commit 1a0db9ec65
31 changed files with 2121 additions and 84 deletions
+2
View File
@@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | What landed |
|---|---|
| 2026-10-11 | Line Numbers, Hyphenation (zone, CAPS, consecutive limit, Manual), Language, Envelopes and Labels dialogs (#407); hyphenation options kept in DOCX; 30 modal dialogs |
| 2026-10-11 | Accessibility (#386): Alt Text pane for every object and table, Mark as decorative, alt text round-trips in DOCX, deeper checker with fix actions |
| 2026-10-11 | References dialogs (#402): Caption (labels, chapter numbering), Index and Mark Entry, Custom Table of Contents, Source Manager and Create/Edit Source, with TOC/INDEX/XE/SEQ switches honoured on update |
| 2026-10-11 | Document grid (#391): Page Setup › Document Grid sets lines per page and characters per line (`w:docGrid` round trip); lines snap to the line pitch, East Asian characters to the character pitch; View › Gridlines shows it |
@@ -165,6 +166,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Progress log: Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs (#407) |
| 2026-10-11 | trivial | Progress log: Alt Text pane, decorative objects, deeper accessibility checker (#386) |
| 2026-10-11 | trivial | Progress log: Draw lasso, Add Pen, Ink to Shape, Ink Replay (#395); catalog 406/434 |
| 2026-10-11 | trivial | Progress log: document grid (#391) |
+13
View File
@@ -391,6 +391,13 @@ pub struct Settings {
pub even_odd_headers: bool,
pub mirror_margins: bool,
pub auto_hyphenation: bool,
/// Hyphenation zone (`w:hyphenationZone`), points: a line is hyphenated only when breaking
/// it at a word boundary would leave more than this empty at its end.
pub hyphenation_zone: f32,
/// Words in capitals may be hyphenated (`w:doNotHyphenateCaps` turns it off).
pub hyphenate_caps: bool,
/// Most consecutive lines that may end with a hyphen (`w:consecutiveHyphenLimit`); 0 = no limit.
pub consecutive_hyphen_limit: u32,
pub page_color: Option<Rgb>,
pub watermark: Option<Watermark>,
/// Theme fonts (major = headings, minor = body).
@@ -426,6 +433,9 @@ pub const LEGACY_COMPAT_MODE: u32 = 12;
/// The compatibility mode of documents created by Word 2013 and later, and by WordCraft.
pub const COMPAT_MODE_CURRENT: u32 = 15;
/// Word's hyphenation zone when a document doesn't set one (0.25"), points.
pub const DEFAULT_HYPHENATION_ZONE: f32 = 18.0;
/// ECMA-376 §17.15.1.44/45: 180 twentieths of a point when a document doesn't say.
pub const DEFAULT_GRID: f32 = 9.0;
@@ -469,6 +479,9 @@ impl Default for Settings {
even_odd_headers: false,
mirror_margins: false,
auto_hyphenation: false,
hyphenation_zone: DEFAULT_HYPHENATION_ZONE,
hyphenate_caps: true,
consecutive_hyphen_limit: 0,
page_color: None,
watermark: None,
major_font: styles::HEADING_FONT.into(),
+11
View File
@@ -804,6 +804,17 @@ impl Reader<'_> {
"m:mathPr" => s.math = Some(math::read_math_pr(k)),
"w:mirrorMargins" => s.mirror_margins = on_off(k),
"w:autoHyphenation" => s.auto_hyphenation = on_off(k),
"w:doNotHyphenateCaps" => s.hyphenate_caps = !on_off(k),
"w:hyphenationZone" => {
if let Some(v) = tw(k, "w:val").filter(|v| v.is_finite() && *v >= 0.0) {
s.hyphenation_zone = v.min(1584.0);
}
}
"w:consecutiveHyphenLimit" => {
if let Some(v) = k.attr("w:val").and_then(u32_of) {
s.consecutive_hyphen_limit = v.min(32_767);
}
}
"w:footnotePr" => {
if let Some(f) = k.child_val("w:numFmt") {
s.footnote_format = NumFormat::from_ooxml(f);
+10
View File
@@ -852,6 +852,16 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec<u8> {
if s.auto_hyphenation {
w.empty("w:autoHyphenation", &[]);
}
// Schema order: consecutiveHyphenLimit, hyphenationZone, doNotHyphenateCaps.
if s.consecutive_hyphen_limit > 0 {
w.val("w:consecutiveHyphenLimit", &s.consecutive_hyphen_limit.min(32_767).to_string());
}
if s.hyphenation_zone.is_finite() && (s.hyphenation_zone - wordcraft_doc::DEFAULT_HYPHENATION_ZONE).abs() > 0.01 {
w.val("w:hyphenationZone", &crate::units::twips(s.hyphenation_zone.clamp(0.0, 1584.0)));
}
if !s.hyphenate_caps {
w.empty("w:doNotHyphenateCaps", &[]);
}
if s.even_odd_headers {
w.empty("w:evenAndOddHeaders", &[]);
}
+4
View File
@@ -1116,6 +1116,10 @@ fn settings_core_theme_round_trip() {
d.settings.even_odd_headers = true;
d.settings.mirror_margins = true;
d.settings.auto_hyphenation = true;
// Hyphenation Options (#407): zone, words in capitals, consecutive hyphens.
d.settings.hyphenation_zone = 27.0;
d.settings.hyphenate_caps = false;
d.settings.consecutive_hyphen_limit = 2;
d.settings.page_color = Some(Rgb(250, 240, 230));
d.settings.major_font = "Cambria".into();
d.settings.minor_font = "Calibri".into();
+298 -36
View File
@@ -136,10 +136,12 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"path"?: string (save the merged document), "from"?: n, "to"?: n}"#)
.key(super::non_mac("Alt+Shift+N"))
.when(has_recipients),
CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes)
.params(r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|Envelope DL"}"#),
CommandSpec::new("mailings.labels", "Labels", "Mailings › Create", labels)
.params(r#"{"text"?: string, "rows"?: n, "cols"?: n, "fromRecipients"?: bool}"#),
CommandSpec::new("mailings.envelopes", "Envelopes", "Mailings › Create", envelopes).params(
r#"{"delivery": string, "return"?: string, "size"?: "Envelope #10|#9|Monarch|DL|C4|C5|C6|B5", "width"?: pt, "height"?: pt, "add"?: bool (add to this document instead of a new one)}"#,
),
CommandSpec::new("mailings.labels", "Labels", "Mailings › Create", labels).params(
r#"{"text"?: string, "product"?: "letter-3x10|letter-2x10|letter-2x7|letter-2x5|letter-4x20|a4-3x7|a4-2x7|a4-3x8|a4-2x4", "rows"?: n, "cols"?: n, "fromRecipients"?: bool, "single"?: {"row": n, "col": n}}"#,
),
]
}
@@ -739,17 +741,41 @@ fn merge_blocks(s: &Session, blocks: &mut Blocks, cur: &mut usize, end: usize, d
true
}
/// Common envelope sizes: name, width and height in points (long side first).
pub const ENVELOPE_SIZES: [(&str, f32, f32); 8] = [
("Envelope #10", 684.0, 297.0),
("Envelope #9", 639.0, 279.0),
("Envelope Monarch", 540.0, 279.0),
("Envelope DL", 623.6, 311.8),
("Envelope C4", 918.4, 649.1),
("Envelope C5", 649.1, 459.2),
("Envelope C6", 459.2, 323.1),
("Envelope B5", 708.7, 498.9),
];
/// Envelope size by name (`Envelope DL`, or just `DL`, any case), or `width`/`height` in points.
fn envelope_size(v: &Value) -> Result<(f32, f32), CmdError> {
if let (Some(w), Some(h)) = (p::f32(v, "width"), p::f32(v, "height")) {
let (w, h) = (w.clamp(144.0, 1584.0), h.clamp(144.0, 1584.0));
return Ok((w.max(h), w.min(h)));
}
let name = p::str(v, "size").unwrap_or("Envelope #10").trim();
ENVELOPE_SIZES
.iter()
.find(|(n, ..)| n.eq_ignore_ascii_case(name) || n.strip_prefix("Envelope ").is_some_and(|short| short.eq_ignore_ascii_case(name)))
.map(|(_, w, h)| (*w, *h))
.ok_or_else(|| CmdError::Params(format!("unknown envelope size `{name}`")))
}
/// Envelopes: a landscape page with the return address at the top left and the delivery address
/// in the middle. As a new document, or (`add`) as a section of its own at the start of this one.
fn envelopes(s: &mut Session, v: &Value) -> CmdResult {
let delivery = p::str(v, "delivery").map(str::to_string).unwrap_or_else(|| {
if s.merge.rows.is_empty() { "Recipient Name\nStreet Address\nCity, ST 00000".into() } else { address_block(s, s.merge.record) }
});
let ret = p::str(v, "return").unwrap_or("").to_string();
let (w, h) = match p::str(v, "size").unwrap_or("Envelope #10") {
"Envelope DL" => (623.6, 311.8),
_ => (684.0, 297.0),
};
let mut d = Document::new();
d.last_section = SectionProps {
let (w, h) = envelope_size(v)?;
let sect = SectionProps {
page_w: w,
page_h: h,
landscape: true,
@@ -759,55 +785,262 @@ fn envelopes(s: &mut Session, v: &Value) -> CmdResult {
margin_bottom: 22.0,
..Default::default()
};
// Addresses are capped: a pasted novel is not an address.
let lines = |t: &str| t.split('\n').take(20).map(str::to_string).collect::<Vec<_>>();
let mut blocks = Vec::new();
for l in ret.split('\n') {
let ret_lines = if ret.is_empty() { Vec::new() } else { lines(&ret) };
for l in &ret_lines {
blocks.push(para_block(Paragraph::with_text(l, CharProps { size: Some(10.0), ..Default::default() }).styled("NoSpacing")));
}
let spacer = (6 - ret.lines().count().min(6)).max(1);
let spacer = (6 - ret_lines.len().min(6)).max(1);
for _ in 0..spacer + 2 {
blocks.push(para_block(Paragraph::new().styled("NoSpacing")));
}
for l in delivery.split('\n') {
let mut p = Paragraph::with_text(l, CharProps::default()).styled("NoSpacing");
let mut last = None;
for l in lines(&delivery) {
let mut p = Paragraph::with_text(&l, CharProps::default()).styled("NoSpacing");
p.props.indent_left = Some(w * 0.42);
blocks.push(para_block(p));
if let Some(prev) = last.replace(p) {
blocks.push(para_block(prev));
}
}
if p::bool(v, "add") == Some(true) {
// The envelope's own section ends with its last address line.
let mut end = last.unwrap_or_else(|| Paragraph::new().styled("NoSpacing"));
end.section = Some(Box::new(sect));
blocks.push(para_block(end));
let n = blocks.len();
s.doc.body.splice(0..0, blocks);
s.sel = Selection::caret(s.doc.start_of(StoryRef::Body));
return Ok(json!({"width": w, "height": h, "added": n}));
}
blocks.extend(last.map(para_block));
let mut d = Document::new();
d.last_section = sect;
d.body = blocks;
s.set_document(d);
s.path = None;
Ok(json!({"width": w, "height": h}))
}
/// A sheet of labels: the label size, how many across and down, and where the first one sits.
/// Lengths in points; the pitch is from one label's edge to the next one's.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LabelProduct {
pub id: &'static str,
/// What the labels are for: `address`, `shipping` or `return`.
pub kind: &'static str,
/// Letter or A4.
pub paper: &'static str,
pub cols: usize,
pub rows: usize,
pub width: f32,
pub height: f32,
pub top: f32,
pub side: f32,
pub h_pitch: f32,
pub v_pitch: f32,
}
const MM: f32 = 72.0 / 25.4;
/// Common label sheets (the sizes are the industry's; the names are ours).
pub const LABEL_PRODUCTS: [LabelProduct; 9] = [
LabelProduct {
id: "letter-3x10",
kind: "address",
paper: "Letter",
cols: 3,
rows: 10,
width: 189.0,
height: 72.0,
top: 36.0,
side: 13.5,
h_pitch: 198.0,
v_pitch: 72.0,
},
LabelProduct {
id: "letter-2x10",
kind: "address",
paper: "Letter",
cols: 2,
rows: 10,
width: 288.0,
height: 72.0,
top: 36.0,
side: 11.25,
h_pitch: 301.5,
v_pitch: 72.0,
},
LabelProduct {
id: "letter-2x7",
kind: "address",
paper: "Letter",
cols: 2,
rows: 7,
width: 288.0,
height: 96.0,
top: 60.0,
side: 11.25,
h_pitch: 301.5,
v_pitch: 96.0,
},
LabelProduct {
id: "letter-2x5",
kind: "shipping",
paper: "Letter",
cols: 2,
rows: 5,
width: 288.0,
height: 144.0,
top: 36.0,
side: 11.25,
h_pitch: 301.5,
v_pitch: 144.0,
},
LabelProduct {
id: "letter-4x20",
kind: "return",
paper: "Letter",
cols: 4,
rows: 20,
width: 126.0,
height: 36.0,
top: 36.0,
side: 21.6,
h_pitch: 147.6,
v_pitch: 36.0,
},
LabelProduct {
id: "a4-3x7",
kind: "address",
paper: "A4",
cols: 3,
rows: 7,
width: 63.5 * MM,
height: 38.1 * MM,
top: 15.15 * MM,
side: 7.21 * MM,
h_pitch: 66.04 * MM,
v_pitch: 38.1 * MM,
},
LabelProduct {
id: "a4-2x7",
kind: "address",
paper: "A4",
cols: 2,
rows: 7,
width: 99.1 * MM,
height: 38.1 * MM,
top: 15.15 * MM,
side: 4.65 * MM,
h_pitch: 101.6 * MM,
v_pitch: 38.1 * MM,
},
LabelProduct {
id: "a4-3x8",
kind: "address",
paper: "A4",
cols: 3,
rows: 8,
width: 63.5 * MM,
height: 33.9 * MM,
top: 12.9 * MM,
side: 7.21 * MM,
h_pitch: 66.04 * MM,
v_pitch: 33.9 * MM,
},
LabelProduct {
id: "a4-2x4",
kind: "shipping",
paper: "A4",
cols: 2,
rows: 4,
width: 99.1 * MM,
height: 67.7 * MM,
top: 13.1 * MM,
side: 4.65 * MM,
h_pitch: 101.6 * MM,
v_pitch: 67.7 * MM,
},
];
impl LabelProduct {
pub fn by_id(id: &str) -> Option<&'static LabelProduct> {
LABEL_PRODUCTS.iter().find(|p| p.id == id)
}
/// Label size as people say it: inches on Letter, millimetres on A4.
pub fn size_text(&self) -> String {
if self.paper == "A4" {
format!("{:.1} × {:.1} mm", self.width / MM, self.height / MM)
} else {
let i = |pt: f32| format!("{}", ((pt / 72.0) * 1000.0).round() / 1000.0);
format!("{}\" × {}\"", i(self.width), i(self.height))
}
}
}
/// Labels: a new document with a table of labels laid out like the sheet (`product`; spacer
/// columns and rows stand for the gaps). A full page of the same `text` (or one recipient per
/// label), or (`single`: `{"row", "col"}`, from 1) one label at that place on the sheet.
fn labels(s: &mut Session, v: &Value) -> CmdResult {
// A 3 × 10 sheet of 2.625" × 1" labels on Letter (a common layout).
let rows = p::u64(v, "rows").unwrap_or(10).clamp(1, 40) as usize;
let cols = p::u64(v, "cols").unwrap_or(3).clamp(1, 10) as usize;
let from_rec = p::bool(v, "fromRecipients").unwrap_or(!s.merge.rows.is_empty() && p::str(v, "text").is_none());
let prod = match p::str(v, "product") {
Some(id) => *LabelProduct::by_id(id).ok_or_else(|| CmdError::Params(format!("unknown label product `{id}`")))?,
None => LABEL_PRODUCTS[0],
};
let rows = p::u64(v, "rows").map_or(prod.rows, |n| n.clamp(1, 40) as usize);
let cols = p::u64(v, "cols").map_or(prod.cols, |n| n.clamp(1, 10) as usize);
let single = v.get("single").filter(|x| !x.is_null()).map(|x| {
let at = |k: &str| x.get(k).and_then(Value::as_u64).unwrap_or(1).max(1) as usize;
(at("row").min(rows), at("col").min(cols))
});
let from_rec = single.is_none() && p::bool(v, "fromRecipients").unwrap_or(!s.merge.rows.is_empty() && p::str(v, "text").is_none());
let text = p::str(v, "text").unwrap_or("Name\nAddress\nCity, ST 00000").to_string();
let (page_w, page_h) = if prod.paper == "A4" { (595.3, 841.9) } else { (612.0, 792.0) };
let h_gap = (prod.h_pitch - prod.width).max(0.0);
let v_gap = (prod.v_pitch - prod.height).max(0.0);
let mut d = Document::new();
d.last_section.margin_top = 36.0;
d.last_section.page_w = page_w;
d.last_section.page_h = page_h;
d.last_section.margin_top = prod.top;
d.last_section.margin_bottom = 0.0;
d.last_section.margin_left = 13.5;
d.last_section.margin_right = 13.5;
let mut t = Table::new(rows, cols, 189.0 * cols as f32);
d.last_section.margin_left = prod.side;
d.last_section.margin_right = (page_w - prod.side - prod.h_pitch * (cols - 1) as f32 - prod.width).max(0.0);
d.last_section.header = 0.0;
d.last_section.footer = 0.0;
// Grid columns: label, gap, label, …; rows likewise.
let grid_cols: Vec<Option<usize>> = (0..cols).flat_map(|c| [Some(c)].into_iter().chain((c + 1 < cols && h_gap > 0.5).then_some(None))).collect();
let grid_rows: Vec<Option<usize>> = (0..rows).flat_map(|r| [Some(r)].into_iter().chain((r + 1 < rows && v_gap > 0.5).then_some(None))).collect();
let width: f32 = grid_cols.iter().map(|c| if c.is_some() { prod.width } else { h_gap }).sum();
let mut t = Table::new(grid_rows.len(), grid_cols.len(), width);
t.props.style = None;
t.props.borders = Some(wordcraft_doc::props::Borders::default());
t.props.fixed = true;
t.grid = grid_cols.iter().map(|c| if c.is_some() { prod.width } else { h_gap }).collect();
let mut k = 0usize;
for r in &mut t.rows {
r.props.height = Some(72.0);
r.props.height_rule = wordcraft_doc::props::HeightRule::Exact;
for c in &mut r.cells {
let content = if from_rec {
let rec = address_block(s, k);
k += 1;
if k > s.merge.rows.len() { String::new() } else { rec }
} else {
text.clone()
let mut made = 0usize;
for (row, r) in t.rows.iter_mut().zip(&grid_rows) {
row.props.height = Some(if r.is_some() { prod.height } else { v_gap });
row.props.height_rule = wordcraft_doc::props::HeightRule::Exact;
for (cell, c) in row.cells.iter_mut().zip(&grid_cols) {
cell.props.width = Some(if c.is_some() { prod.width } else { h_gap });
let (Some(r), Some(c)) = (r, c) else { continue };
let content = match single {
Some((sr, sc)) if (sr, sc) != (r + 1, c + 1) => String::new(),
_ if from_rec => {
let rec = if k < s.merge.rows.len() { address_block(s, k) } else { String::new() };
k += 1;
rec
}
_ => text.clone(),
};
c.props.valign = wordcraft_doc::props::VAlign::Center;
c.blocks = content
if !content.is_empty() {
made += 1;
}
cell.props.valign = wordcraft_doc::props::VAlign::Center;
cell.blocks = content
.split('\n')
.take(20)
.map(|l| {
let mut p = Paragraph::with_text(l, CharProps::default()).styled("NoSpacing");
p.props.align = Some(Align::Left);
@@ -821,7 +1054,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult {
s.set_document(d);
s.path = None;
let _ = Pos::body(0, 0);
Ok(json!({"labels": rows * cols}))
Ok(json!({"labels": if single.is_some() { 1 } else { rows * cols }, "filled": made, "product": prod.id}))
}
#[cfg(test)]
@@ -1023,4 +1256,33 @@ mod tests {
s.run("mailings.labels", &json!({"text": "Hello"})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).matches("Hello").count() == 30);
}
/// #407: Envelopes adds an envelope section of the chosen size before the letter; Labels
/// lays out a sheet of the chosen product, or a single label at a row and column.
#[test]
fn envelope_sizes_add_to_document_and_label_products() {
let mut s = Session::new(Document::from_text("Dear Jo,"));
let r = s.run("mailings.envelopes", &json!({"delivery": "Jo Doe\n1 Main St", "return": "Me", "size": "C5", "add": true})).unwrap();
assert_eq!((r["width"].as_f64().map(|w| w.round()), r["height"].as_f64().map(|h| h.round())), (Some(649.0), Some(459.0)));
let secs = s.doc.sections();
assert_eq!(secs.len(), 2, "an envelope section of its own");
assert!(secs[0].1.landscape && (secs[0].1.page_w - 649.1).abs() < 0.1);
let text = s.doc.plain_text(StoryRef::Body);
assert!(text.starts_with("Me") && text.contains("1 Main St") && text.trim_end().ends_with("Dear Jo,"), "{text}");
assert!(s.run("mailings.envelopes", &json!({"size": "Envelope X"})).is_err());
let r = s.run("mailings.labels", &json!({"text": "Ada", "product": "a4-2x7"})).unwrap();
assert_eq!(r["labels"], 14);
assert!((s.doc.last_section.page_w - 595.3).abs() < 0.1, "A4 sheet");
let Some(Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table") };
assert_eq!((t.rows.len(), t.grid.len()), (7, 3), "two label columns and the gap between");
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Ada").count(), 14);
let r = s.run("mailings.labels", &json!({"text": "Bo", "product": "letter-3x10", "single": {"row": 2, "col": 3}})).unwrap();
assert_eq!(r["filled"], 1);
let Some(Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table") };
// Row 2, column 3 (grid column 5: label, gap, label, gap, label).
let cell = t.rows.get(1).and_then(|r| r.cells.get(4)).and_then(|c| c.blocks.first());
assert!(matches!(cell.map(|b| &**b), Some(Block::Para(p)) if p.text == "Bo"));
assert!(s.run("mailings.labels", &json!({"product": "nope"})).is_err());
}
}
+190 -18
View File
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
use wordcraft_doc::section::{
Columns, DocGrid, DocGridType, LineNumbering, MAX_CHAR_SPACE, MAX_GRID_PITCH, MIN_GRID_PITCH, SectionProps, SectionStart,
};
use wordcraft_doc::{Block, Pos};
use wordcraft_doc::{Block, Pos, StoryRef};
use super::sel_result;
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -30,23 +30,14 @@ pub fn specs() -> Vec<CommandSpec> {
),
CommandSpec::new("layout.break", "Breaks", "Layout › Page Setup", breaks)
.params(r#"{"kind": "page|column|textWrapping|nextPage|continuous|evenPage|oddPage"}"#),
CommandSpec::new("layout.lineNumbers", "Line Numbers", "Layout › Page Setup", |s, v| {
let mode = p::str(v, "value").unwrap_or("continuous");
with_sect(s, |x| {
x.line_numbers = match mode {
"none" => None,
"restartPage" => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Page, ..Default::default() }),
"restartSection" => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Section, ..Default::default() }),
_ => Some(LineNumbering { restart: wordcraft_doc::section::LineNumberRestart::Continuous, ..Default::default() }),
}
})
})
.params(r#"{"value": "none|continuous|restartPage|restartSection"}"#),
CommandSpec::new("layout.hyphenation", "Hyphenation", "Layout › Page Setup", |s, v| {
let on = p::bool(v, "value").unwrap_or(!s.doc.settings.auto_hyphenation);
s.doc.settings.auto_hyphenation = on;
Ok(json!({"value": on}))
}),
CommandSpec::new("layout.lineNumbers", "Line Numbers", "Layout › Page Setup", line_numbers).params(
r#"{"value"?: "none|continuous|restartPage|restartSection", "start"?: 1-32767, "countBy"?: 1-100, "distance"?: pt (0 = auto)}"#,
),
CommandSpec::new("layout.hyphenation", "Hyphenation", "Layout › Page Setup", hyphenation)
.params(r#"{"value"?: bool (automatic; toggles when nothing is given), "zone"?: pt, "caps"?: bool (hyphenate words in capitals), "limit"?: n (consecutive hyphens, 0 = no limit)}"#),
CommandSpec::new("layout.manualHyphenation", "Manual Hyphenation", "Layout › Page Setup › Hyphenation", manual_hyphenation)
.params(r#"{"from"?: Pos (default: the caret)} → the next word at a line end that could be hyphenated: {"word", "pos", "points": [char index], "suggest": char index} or {"done": true}"#)
.pure(),
CommandSpec::new("layout.pageSetup", "Page Setup", "Layout › Page Setup", |s, v| {
if let Some(props) = v.get("section") {
let new: SectionProps = serde_json::from_value(props.clone()).map_err(|e| CmdError::Params(e.to_string()))?;
@@ -98,6 +89,130 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Line Numbers: on with a restart mode (`value`), or off (`none`). `start`, `countBy` and
/// `distance` change the numbering's options and turn it on (keeping its restart mode).
fn line_numbers(s: &mut Session, v: &Value) -> CmdResult {
use wordcraft_doc::section::LineNumberRestart as R;
let mode = p::str(v, "value");
if mode == Some("none") {
return with_sect(s, |x| x.line_numbers = None);
}
let restart = match mode {
Some("restartPage") => Some(R::Page),
Some("restartSection") => Some(R::Section),
Some("continuous") => Some(R::Continuous),
Some(x) => return Err(CmdError::Params(format!("unknown value `{x}`"))),
None => None,
};
let start = p::u64(v, "start").map(|n| n.clamp(1, 32_767) as u32);
let count_by = p::u64(v, "countBy").map(|n| n.clamp(1, 100) as u32);
let distance = p::f32(v, "distance").map(|d| d.clamp(0.0, 1584.0));
let options = start.is_some() || count_by.is_some() || distance.is_some();
with_sect(s, |x| {
// Options alone keep the current numbering's other settings; a new mode alone starts over.
let mut l = match (&x.line_numbers, options) {
(Some(cur), true) => cur.clone(),
(None, true) => LineNumbering { restart: R::Continuous, ..Default::default() },
_ => LineNumbering::default(),
};
if let Some(r) = restart.or(if options { None } else { Some(R::Continuous) }) {
l.restart = r;
}
if let Some(n) = start {
l.start = n;
}
if let Some(n) = count_by {
l.count_by = n;
}
if let Some(d) = distance {
l.distance = d;
}
x.line_numbers = Some(l);
})
}
/// Hyphenation: automatic on or off, and its options (Hyphenation Options).
fn hyphenation(s: &mut Session, v: &Value) -> CmdResult {
let st = &mut s.doc.settings;
let options = ["zone", "caps", "limit"].iter().any(|k| v.get(*k).is_some_and(|x| !x.is_null()));
match p::bool(v, "value") {
Some(on) => st.auto_hyphenation = on,
None if !options => st.auto_hyphenation = !st.auto_hyphenation,
None => {}
}
if let Some(z) = p::f32(v, "zone") {
st.hyphenation_zone = z.clamp(0.0, 1584.0);
}
if let Some(c) = p::bool(v, "caps") {
st.hyphenate_caps = c;
}
if let Some(n) = p::u64(v, "limit") {
st.consecutive_hyphen_limit = n.min(32_767) as u32;
}
Ok(json!({"value": st.auto_hyphenation, "zone": st.hyphenation_zone, "caps": st.hyphenate_caps, "limit": st.consecutive_hyphen_limit}))
}
/// Manual hyphenation: the next word after `from` that starts a line after a line wrapped at a
/// space, and could end the line before it if hyphenated. `points` are where a hyphen may go
/// (char indices into `word`); `suggest` is the last one whose first part fits on that line.
/// Accepting is `caret.set` to `pos` plus the chosen point, then `text.optionalHyphen`.
fn manual_hyphenation(s: &mut Session, v: &Value) -> CmdResult {
let from = match v.get("from") {
Some(f) if !f.is_null() => super::parse_pos(f).ok_or_else(|| CmdError::Params("bad `from`".into()))?,
_ => s.sel.ordered().0,
};
let lim = wordcraft_proof::hyphen::Limits::default();
let caps = s.doc.settings.hyphenate_caps;
let layout = s.layout();
let mut best: Option<(Pos, Value)> = None;
for page in &layout.pages {
for it in &page.items {
let wordcraft_layout::Placed::Lines { story, path, para, l0, l1, .. } = it else { continue };
if *story != StoryRef::Body {
continue;
}
let Some(text) = s.doc.para_at(&Pos { story: *story, path: path.clone(), off: 0 }).map(|p| p.text.clone()) else { continue };
for li in (*l0).max(1)..*l1 {
let (Some(prev), Some(line)) = (para.lines.get(li - 1), para.lines.get(li)) else { continue };
if prev.end != wordcraft_layout::LineEnd::Wrap || prev.hyphen.is_some() {
continue;
}
let at = Pos { story: *story, path: path.clone(), off: line.start };
if at < from || best.as_ref().is_some_and(|(b, _)| *b <= at) {
continue;
}
let Some(rest) = text.get(line.start..) else { continue };
let len = rest.char_indices().find(|(_, c)| !(c.is_alphabetic() || *c == '\'' || *c == '\u{2019}')).map_or(rest.len(), |(i, _)| i);
let Some(word) = rest.get(..len) else { continue };
// A word with an optional hyphen already breaks where its author said.
if rest.get(len..).is_some_and(|r| r.starts_with('\u{ad}'))
|| word.chars().count() < lim.min_word
|| (!caps && word.chars().all(char::is_uppercase))
{
continue;
}
let points = wordcraft_proof::hyphen::hyphen_points(word, &lim);
// The room left at the end of the line above, and the width of each first part.
let room = prev.right - prev.end_x();
let hyphen_w = para
.x_of(li, line.start)
.zip(para.x_of(li, line.start + word.chars().next().map_or(0, char::len_utf8)))
.map_or(6.0, |(a, b)| (b - a) * 0.6);
let fits = |pt: usize| {
let bytes = word.char_indices().nth(pt).map_or(word.len(), |(i, _)| i);
match (para.x_of(li, line.start), para.x_of(li, line.start + bytes)) {
(Some(a), Some(b)) => (b - a) + hyphen_w <= room,
_ => false,
}
};
let Some(suggest) = points.iter().copied().filter(|pt| fits(*pt)).max() else { continue };
best = Some((at.clone(), json!({"word": word, "pos": super::pos_json(&at), "points": points, "suggest": suggest})));
}
}
}
Ok(best.map(|(_, v)| v).unwrap_or_else(|| json!({"done": true})))
}
fn block_of(s: &Session) -> usize {
s.sel.focus.path.0.first().copied().unwrap_or(0) as usize
}
@@ -385,4 +500,61 @@ mod tests {
assert_eq!(s.doc.last_section.start, SectionStart::Continuous);
assert!(s.run("layout.columns", &json!({"count": 2, "apply": "elsewhere"})).is_err());
}
/// #407 Line Numbers options: start, count by, distance and restart; options alone keep the
/// restart mode, `none` turns numbering off.
#[test]
fn line_numbering_options() {
use wordcraft_doc::section::LineNumberRestart;
let mut s = Session::new(wordcraft_doc::Document::from_text("One\nTwo"));
s.run("layout.lineNumbers", &json!({"value": "restartSection", "start": 5, "countBy": 2, "distance": 18})).unwrap();
let l = s.doc.last_section.line_numbers.clone().unwrap();
assert_eq!((l.start, l.count_by, l.distance, l.restart), (5, 2, 18.0, LineNumberRestart::Section));
s.run("layout.lineNumbers", &json!({"countBy": 1e9, "start": -3})).unwrap();
let l = s.doc.last_section.line_numbers.clone().unwrap();
assert_eq!((l.start, l.count_by, l.restart), (5, 100, LineNumberRestart::Section), "clamped, mode kept");
s.run("layout.lineNumbers", &json!({"value": "none"})).unwrap();
assert!(s.doc.last_section.line_numbers.is_none());
assert!(s.run("layout.lineNumbers", &json!({"value": "sometimes"})).is_err());
}
/// #407 Hyphenation Options: options don't toggle automatic hyphenation; Manual finds a
/// word at a line start that would fit hyphenated on the line above, and an optional hyphen
/// there breaks the word.
#[test]
fn hyphenation_options_and_manual_hyphenation() {
let mut s = Session::new(wordcraft_doc::Document::from_text(
&"a extraordinarily be uncharacteristic of responsibilities the considerations ".repeat(12),
));
let r = s.run("layout.hyphenation", &json!({"zone": 36, "caps": false, "limit": 3})).unwrap();
assert_eq!(r, json!({"value": false, "zone": 36.0, "caps": false, "limit": 3}));
s.run("layout.hyphenation", &json!({})).unwrap();
assert!(s.doc.settings.auto_hyphenation, "nothing given toggles");
s.run("layout.hyphenation", &json!({"value": false})).unwrap();
// Accept every candidate (which words they are depends on the fonts).
let mut found = 0;
let mut from = json!(null);
while found < 5 {
let c = s.run("layout.manualHyphenation", &json!({"from": from})).unwrap();
if c["done"] == true {
break;
}
let word = c["word"].as_str().unwrap().to_string();
let pts = c["points"].as_array().unwrap();
assert!(!pts.is_empty() && pts.contains(&c["suggest"]), "{c}");
let at = c["suggest"].as_u64().unwrap() as usize;
let mut pos = c["pos"].clone();
let off = pos["off"].as_u64().unwrap() as usize + word.char_indices().nth(at).unwrap().0;
pos["off"] = json!(off);
s.run("caret.set", &json!({"pos": pos})).unwrap();
s.run("text.optionalHyphen", &json!({})).unwrap();
pos["off"] = json!(off + word.len());
from = pos;
found += 1;
}
// Some line of thirty or so starts with a long word that fits hyphenated above, whatever the font.
assert!(found > 0, "no candidates");
assert_eq!(s.doc.plain_text(wordcraft_doc::StoryRef::Body).matches('\u{ad}').count(), found);
assert!(s.run("layout.manualHyphenation", &json!({"from": {"block": 999, "off": 0}})).unwrap()["done"] == true);
}
}
+60 -11
View File
@@ -350,17 +350,9 @@ pub fn specs() -> Vec<CommandSpec> {
})
})
.params(r#"{"format": "decimal|upperRoman|lowerLetter…", "text"?: "%1.", "start"?: n}"#),
CommandSpec::new("review.language", "Language", "Review › Language", |s, v| {
let lang = p::str(v, "lang").unwrap_or("en-US").to_string();
let no_proof = p::bool(v, "noProof");
super::format::apply(s, &|c| {
c.lang = Some(lang.clone());
if let Some(np) = no_proof {
c.no_proof = Some(np);
}
})
})
.params(r#"{"lang": "en-US|en-GB|fr-FR|…", "noProof"?: bool}"#),
CommandSpec::new("review.language", "Language", "Review › Language", language).params(
r#"{"lang"?: "en-US|en-GB|fr-FR|…", "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (make `lang` the document's default language instead)}"#,
),
CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
s.relayout();
@@ -410,6 +402,48 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Set Proofing Language: the selection's language and "do not check spelling or grammar",
/// or (`default`) the document's default language; `detect` is the per-user preference.
/// Nothing at all sets US English, as before.
fn language(s: &mut Session, v: &Value) -> CmdResult {
let lang = match p::str(v, "lang") {
Some(l) if !l.is_empty() && l.len() <= 35 && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') => Some(l.to_string()),
Some(l) => return Err(CmdError::Params(format!("`{l}` is not a language tag"))),
None => None,
};
let no_proof = p::bool(v, "noProof");
let detect = p::bool(v, "detect");
if let Some(d) = detect {
s.prefs.detect_language = d;
}
if p::bool(v, "default") == Some(true) {
let l = lang.ok_or_else(|| CmdError::Params("`lang` is required with `default`".into()))?;
s.doc.styles.default_chr.lang = Some(l.clone());
// A Normal style that names its own language follows the new default.
if let Some(n) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal")
&& n.chr.lang.is_some()
{
n.chr.lang = Some(l.clone());
}
return Ok(json!({"default": l, "detect": s.prefs.detect_language}));
}
let lang = match (lang, no_proof, detect) {
(None, None, None) => Some("en-US".to_string()),
(l, ..) => l,
};
if lang.is_none() && no_proof.is_none() {
return Ok(json!({"detect": s.prefs.detect_language}));
}
super::format::apply(s, &|c| {
if let Some(l) = &lang {
c.lang = Some(l.clone());
}
if let Some(np) = no_proof {
c.no_proof = Some(np);
}
})
}
fn macros(s: &mut Session, v: &Value) -> CmdResult {
if let Some(d) = v.get("define") {
let name = d.get("name").and_then(Value::as_str).unwrap_or("Macro").to_string();
@@ -1048,4 +1082,19 @@ mod tests {
s.run("insert.quickParts", &json!({"insert": "m"})).unwrap();
assert!(s.doc.plain_text(StoryRef::Body).ends_with("Membership"));
}
/// #407 Language: the selection's language and "do not check", the default language and
/// the detect preference; a bad tag is refused.
#[test]
fn proofing_language_options() {
let mut s = Session::new(Document::from_text("Bonjour"));
s.run("select.all", &json!({})).unwrap();
s.run("review.language", &json!({"lang": "fr-FR", "noProof": true, "detect": false})).unwrap();
let rc = s.doc.para_at(&Pos::body(0, 1)).map(|p| p.props_at(1).clone()).unwrap();
assert_eq!((rc.lang.as_deref(), rc.no_proof), (Some("fr-FR"), Some(true)));
assert!(!s.prefs.detect_language);
s.run("review.language", &json!({"lang": "de-DE", "default": true})).unwrap();
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"));
assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
}
}
+3 -1
View File
@@ -312,11 +312,13 @@ pub struct Prefs {
/// Track Changes Options: what markup shows and how revisions are drawn (per user, as in
/// Word).
pub markup: wordcraft_layout::display::MarkupOptions,
/// Language › Detect language automatically (per user, as in Word).
pub detect_language: bool,
}
impl Default for Prefs {
fn default() -> Self {
Prefs { count_notes: true, markup: Default::default() }
Prefs { count_notes: true, markup: Default::default(), detect_language: true }
}
}
+7 -1
View File
@@ -360,10 +360,16 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 {
opts.proofing,
opts.field_codes,
wordcraft_proof::user_dictionary().len(),
doc.settings.auto_hyphenation,
hyphenation_key(doc),
))
}
/// The hyphenation settings, hashable (the zone as its bits).
fn hyphenation_key(doc: &Document) -> (bool, u32, bool, u32) {
let s = &doc.settings;
(s.auto_hyphenation, s.hyphenation_zone.to_bits(), s.hyphenate_caps, s.consecutive_hyphen_limit)
}
fn has_page_fields(p: &Paragraph) -> bool {
p.objects.iter().any(|o| match o {
InlineObject::Field { instr, .. } => {
+14 -5
View File
@@ -1253,7 +1253,8 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
left_out: Vec::new(),
enclosures,
};
pl.hyph_after = hyphenation_points(&text, to_para, &pl, env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens);
let auto = env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens;
pl.hyph_after = hyphenation_points(&text, to_para, &pl, auto, env.doc.settings.hyphenate_caps);
pl.left_out = left;
for k in pl.hyph_after.clone() {
if let Some(c) = pl.clusters.get_mut(k as usize) {
@@ -1292,6 +1293,9 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
// spaces, up to MAX_SPACE_SHRINK of their width; never on lines with tabs.
let shrink_spaces = rp.align == Align::Justify && env.doc.settings.compat_mode >= wordcraft_doc::COMPAT_MODE_CURRENT;
let hanging_at = if rp.indent_first < 0.0 { Some(rp.indent_left) } else { None };
// Hyphenation Options: the zone, and how many lines in a row may end with a hyphen.
let zone = Some(env.doc.settings.hyphenation_zone).filter(|z| z.is_finite() && *z >= 0.0).unwrap_or(HYPHENATION_ZONE).min(1584.0);
let hyph_limit = env.doc.settings.consecutive_hyphen_limit as usize;
let n = pl.clusters.len();
// A right-to-left paragraph is laid out from its start edge, as if mirrored: indents, tabs,
// the list label and alignment are measured from the right. Lines are mirrored back (and
@@ -1398,6 +1402,8 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
let mut leaders = Vec::new();
let mut last_break: Option<usize> = None; // cluster index after which we may break
let mut last_plain: Option<(usize, f32)> = None; // last non-hyphen break and the x after it
// After `hyph_limit` hyphenated lines in a row this one may not end with a hyphen.
let hyph_ok = hyph_limit == 0 || lines.iter().rev().take_while(|l| l.hyphen.is_some()).count() < hyph_limit;
let mut end = LineEnd::Para;
let mut j = i;
let mut pending_tab: Option<(usize, TabStop, f32)> = None; // tab cluster, stop, x where tab started
@@ -1481,7 +1487,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
&& pj >= c0
&& pj < bk
&& pl.hyph_after.binary_search(&(bk as u32)).is_ok()
&& right_edge - px <= HYPHENATION_ZONE
&& right_edge - px <= zone
{
last_break = Some(pj);
}
@@ -1555,7 +1561,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, level: Optio
if !hyph {
last_break = Some(j);
last_plain = Some((j, x));
} else if x + hyphen_glyph(pl, c.style, &mut hcache).2 <= right_edge + shrink + 0.01 {
} else if hyph_ok && x + hyphen_glyph(pl, c.style, &mut hcache).2 <= right_edge + shrink + 0.01 {
last_break = Some(j);
}
}
@@ -1948,7 +1954,7 @@ fn row_spans(exclusions: &[Exclusion], top: f32, h: f32, lo: f32, hi: f32) -> Re
}
/// Word's default hyphenation zone (0.25"), points.
const HYPHENATION_ZONE: f32 = 18.0;
const HYPHENATION_ZONE: f32 = wordcraft_doc::DEFAULT_HYPHENATION_ZONE;
/// How much of their width the spaces of a justified line may give up to fit more text (Word
/// 2013+, compatibility mode 15).
@@ -2005,7 +2011,7 @@ fn hyphen_glyph(pl: &ParaLayout, style: u16, cache: &mut Vec<(u16, u32, f32)>) -
/// pattern hyphenation points of each word when automatic hyphenation is on. Both are found in
/// `text`, the paragraph as laid out (without what the layout leaves out, [`left_out`]), and
/// `to_para` maps the end of a char there to the paragraph's text.
fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option<usize>, pl: &ParaLayout, auto: bool) -> Vec<u32> {
fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option<usize>, pl: &ParaLayout, auto: bool, caps: bool) -> Vec<u32> {
let mut bytes: Vec<usize> = text.char_indices().filter(|(_, c)| *c == SOFT_HYPHEN).map(|(i, c)| i + c.len_utf8()).collect();
if auto {
let lim = wordcraft_proof::hyphen::Limits::default();
@@ -2016,9 +2022,12 @@ fn hyphenation_points(text: &str, to_para: impl Fn(usize) -> Option<usize>, pl:
(None, true) => start = Some(i),
(Some(a), false) => {
// The patterns are for Latin-script languages: never hyphenate Persian/Arabic words.
// Words in capitals (`NASA`, `README`) only when the document allows it.
let all_caps = |w: &str| w.chars().filter(|c| c.is_alphabetic()).all(char::is_uppercase);
if let Some(w) = text.get(a..i)
&& w.chars().count() >= lim.min_word
&& !w.chars().any(wordcraft_fonts::is_rtl)
&& (caps || !all_caps(w))
{
let offs: Vec<usize> = w.char_indices().map(|(o, _)| o).collect();
for pt in wordcraft_proof::hyphen::hyphen_points(w, &lim) {
+26
View File
@@ -1385,6 +1385,32 @@ fn auto_hyphenation_breaks_long_words() {
let _ = hyphens(&l2);
}
/// Hyphenation Options (#407): a consecutive-hyphen limit of 1 never lets two lines in a row end
/// with a hyphen, and words in capitals stay whole when the document says so.
#[test]
fn hyphenation_options_limit_hyphens_and_spare_capitals() {
let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(6);
let lines = |d: &Document| -> Vec<bool> {
lay(d).pages[0]
.items
.iter()
.filter_map(|i| {
if let Placed::Lines { para, .. } = i { Some(para.lines.iter().map(|x| x.hyphen.is_some()).collect::<Vec<_>>()) } else { None }
})
.flatten()
.collect()
};
let mut d = Document::from_text(&text);
d.settings.auto_hyphenation = true;
d.settings.consecutive_hyphen_limit = 1;
let hy = lines(&d);
assert!(hy.windows(2).all(|w| !(w[0] && w[1])), "two hyphenated lines in a row: {hy:?}");
let mut caps = Document::from_text(&text.to_uppercase());
caps.settings.auto_hyphenation = true;
caps.settings.hyphenate_caps = false;
assert!(lines(&caps).iter().all(|h| !h), "a word in capitals was hyphenated");
}
/// A table of `rows` x 2 with text in every cell, after a "before" paragraph.
fn styled_table_doc(style: Option<&str>, rows: usize) -> Document {
let mut d = Document::from_text("before\nafter");
+8
View File
@@ -314,6 +314,8 @@ pub enum Dialog {
NoteOptions {
form: Box<crate::dialogs_notes::NoteForm>,
},
/// Line Numbers, Hyphenation, Language, Envelopes and Labels (#407).
Layout(Box<crate::dialogs_layout::LayoutDialog>),
}
/// The Table Properties dialog's fields. Lengths are in the interface unit
@@ -735,6 +737,7 @@ impl Dialog {
Dialog::CustomizeRibbon { .. } => "customizeRibbon",
Dialog::Enclose { .. } => "enclose",
Dialog::NoteOptions { .. } => "noteOptions",
Dialog::Layout(d) => d.name(),
}
}
@@ -761,6 +764,9 @@ impl Dialog {
if let Some(d) = crate::dialogs_refs::open(name, app) {
return Some(Dialog::Refs(Box::new(d)));
}
if let Some(d) = crate::dialogs_layout::open(name, app) {
return Some(Dialog::Layout(Box::new(d)));
}
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
let s = |k: &str| st.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
@@ -1115,6 +1121,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) {
Dialog::CustomizeRibbon { .. } => "Customize Ribbon",
Dialog::Enclose { .. } => "Enclose Characters",
Dialog::NoteOptions { .. } => "Footnote and Endnote",
Dialog::Layout(d) => d.title(),
};
let id = egui::Id::new(("dialog", title));
let shown = place_dialog(ctx, id, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false)).open(&mut open).show(ctx, |ui| {
@@ -2133,6 +2140,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
cancel
}
Dialog::NoteOptions { form } => crate::dialogs_notes::note_options(app, ui, form),
Dialog::Layout(d) => crate::dialogs_layout::body(app, ui, d),
Dialog::FindRecipient { text, message } => {
ui.horizontal(|ui| {
ui.label(tl!("Find:"));
+744
View File
@@ -0,0 +1,744 @@
//! Layout, Review and Mailings dialogs (#407): Line Numbers (Layout › Line Numbers › Line
//! Numbering Options…), Hyphenation (Hyphenation Options… and Manual…), Language (Review ›
//! Language), and Envelopes and Labels (Mailings › Create). Each ends by running a command
//! (`layout.lineNumbers`, `layout.hyphenation`, `layout.manualHyphenation` with `caret.set` and
//! `text.optionalHyphen`, `review.language`, `mailings.envelopes`, `mailings.labels`), so scripts
//! and agents get the same result without the dialog. Shown through
//! [`crate::dialogs::Dialog::Layout`].
use egui::{Color32, Ui, vec2};
use serde::Serialize;
use serde_json::{Value, json};
use wordcraft_doc::section::LineNumberRestart;
use wordcraft_engine::cmd::mailings::{ENVELOPE_SIZES, LABEL_PRODUCTS};
use crate::WordApp;
use crate::theme::semibold;
#[derive(Clone, Debug, Serialize)]
#[serde(tag = "form", rename_all = "camelCase")]
pub enum LayoutDialog {
LineNumbers(LineNumbersForm),
Hyphenation(HyphenationForm),
ManualHyphenation(ManualForm),
Language(LanguageForm),
Envelopes(EnvelopeForm),
Labels(LabelsForm),
}
impl LayoutDialog {
pub fn name(&self) -> &'static str {
match self {
LayoutDialog::LineNumbers(_) => "lineNumbers",
LayoutDialog::Hyphenation(_) => "hyphenation",
LayoutDialog::ManualHyphenation(_) => "manualHyphenation",
LayoutDialog::Language(_) => "language",
LayoutDialog::Envelopes(_) => "envelopes",
LayoutDialog::Labels(_) => "labels",
}
}
/// The window title (English; the caller translates it).
pub fn title(&self) -> &'static str {
match self {
LayoutDialog::LineNumbers(_) => "Line Numbers",
LayoutDialog::Hyphenation(_) => "Hyphenation",
LayoutDialog::ManualHyphenation(_) => "Manual Hyphenation",
LayoutDialog::Language(_) => "Language",
LayoutDialog::Envelopes(_) => "Envelopes",
LayoutDialog::Labels(_) => "Labels",
}
}
}
/// The dialog `ui.dialog` opens by `name`.
pub fn open(name: &str, app: &mut WordApp) -> Option<LayoutDialog> {
Some(match name {
"lineNumbers" => LayoutDialog::LineNumbers(LineNumbersForm::read(app)),
"hyphenation" => LayoutDialog::Hyphenation(HyphenationForm::read(app)),
"manualHyphenation" => LayoutDialog::ManualHyphenation(ManualForm::start(app)),
"language" => LayoutDialog::Language(LanguageForm::read(app)),
"envelopes" => LayoutDialog::Envelopes(EnvelopeForm::read(app)),
"labels" => LayoutDialog::Labels(LabelsForm::read(app)),
_ => return None,
})
}
/// Draws the dialog; returns true to close it.
pub fn body(app: &mut WordApp, ui: &mut Ui, d: &mut LayoutDialog) -> bool {
match d {
LayoutDialog::LineNumbers(f) => line_numbers_ui(app, ui, f),
LayoutDialog::Hyphenation(f) => hyphenation_ui(app, ui, f),
LayoutDialog::ManualHyphenation(f) => manual_ui(app, ui, f),
LayoutDialog::Language(f) => language_ui(app, ui, f),
LayoutDialog::Envelopes(f) => envelopes_ui(app, ui, f),
LayoutDialog::Labels(f) => labels_ui(app, ui, f),
}
}
/// The accent button and Cancel; returns (ok, cancel).
fn buttons(ui: &mut Ui, ok: &str, ok_enabled: bool) -> (bool, bool) {
let mut r = (false, false);
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
r.1 = true;
}
let okb = ui.add_enabled(ok_enabled, egui::Button::new(egui::RichText::new(tl!(ok)).color(Color32::WHITE)).fill(crate::theme::APP_COLOR));
if okb.clicked() {
r.0 = true;
}
});
});
r
}
fn heading(ui: &mut Ui, s: &str) {
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5)));
}
/// Runs a dialog's command; a refusal shows in the status bar and keeps the dialog open.
fn run(app: &mut WordApp, id: &str, params: Value) -> bool {
match app.run(id, params) {
Ok(_) => true,
Err(e) => {
app.status(e);
false
}
}
}
fn unit() -> wordcraft_geom::Unit {
wordcraft_geom::Unit::default()
}
// ---------------------------------------------------------------------------------------------
// Line Numbers
/// The Line Numbers dialog's fields. `distance` is in the interface unit.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LineNumbersForm {
pub on: bool,
pub start: u32,
/// From text: automatic, or `distance`.
pub auto_distance: bool,
pub distance: f32,
pub count_by: u32,
/// `restartPage`, `restartSection` or `continuous`.
pub restart: String,
}
impl LineNumbersForm {
pub fn read(app: &WordApp) -> LineNumbersForm {
let sp = wordcraft_engine::cmd::page::sect(&app.session);
let l = sp.line_numbers.clone();
let cur = l.clone().unwrap_or_default();
LineNumbersForm {
on: l.is_some(),
start: cur.start.max(1),
auto_distance: cur.distance <= 0.0,
distance: if cur.distance > 0.0 { cur.distance / unit().pt_per_unit() } else { 0.25 * 72.0 / unit().pt_per_unit() },
count_by: cur.count_by.max(1),
restart: match cur.restart {
LineNumberRestart::Page => "restartPage",
LineNumberRestart::Section => "restartSection",
LineNumberRestart::Continuous => "continuous",
}
.into(),
}
}
/// `layout.lineNumbers` parameters.
pub fn params(&self) -> Value {
if !self.on {
return json!({"value": "none"});
}
let distance = if self.auto_distance { 0.0 } else { (self.distance * unit().pt_per_unit()).max(0.0) };
json!({"value": self.restart, "start": self.start, "countBy": self.count_by, "distance": distance})
}
}
fn line_numbers_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LineNumbersForm) -> bool {
ui.checkbox(&mut f.on, tl!("Add line numbering"));
ui.add_enabled_ui(f.on, |ui| {
egui::Grid::new("lnum").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Start at:"));
ui.add(egui::DragValue::new(&mut f.start).range(1..=32_767));
ui.end_row();
ui.label(tl!("From text:"));
ui.horizontal(|ui| {
ui.checkbox(&mut f.auto_distance, tl!("Auto"));
ui.add_enabled(
!f.auto_distance,
egui::DragValue::new(&mut f.distance)
.speed(0.01)
.range(0.0..=22.0 * 72.0 / unit().pt_per_unit())
.suffix(unit().suffix())
.max_decimals(2),
);
});
ui.end_row();
ui.label(tl!("Count by:"));
ui.add(egui::DragValue::new(&mut f.count_by).range(1..=100));
ui.end_row();
});
heading(ui, "Numbering:");
for (v, l) in [("restartPage", "Restart Each Page"), ("restartSection", "Restart Each Section"), ("continuous", "Continuous")] {
ui.radio_value(&mut f.restart, v.to_string(), tl!(l));
}
});
let (ok, cancel) = buttons(ui, "OK", true);
if ok {
return run(app, "layout.lineNumbers", f.params());
}
cancel
}
// ---------------------------------------------------------------------------------------------
// Hyphenation
/// The Hyphenation dialog's fields. `zone` is in the interface unit; `limit` 0 = no limit.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HyphenationForm {
pub auto: bool,
pub caps: bool,
pub zone: f32,
pub limit: u32,
}
impl HyphenationForm {
pub fn read(app: &WordApp) -> HyphenationForm {
let s = &app.session.doc.settings;
HyphenationForm {
auto: s.auto_hyphenation,
caps: s.hyphenate_caps,
zone: s.hyphenation_zone.max(0.0) / unit().pt_per_unit(),
limit: s.consecutive_hyphen_limit,
}
}
/// `layout.hyphenation` parameters.
pub fn params(&self) -> Value {
json!({"value": self.auto, "caps": self.caps, "zone": (self.zone * unit().pt_per_unit()).max(0.0), "limit": self.limit})
}
}
fn hyphenation_ui(app: &mut WordApp, ui: &mut Ui, f: &mut HyphenationForm) -> bool {
ui.checkbox(&mut f.auto, tl!("Automatically hyphenate document"));
ui.checkbox(&mut f.caps, tl!("Hyphenate words in CAPS"));
egui::Grid::new("hyph").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label(tl!("Hyphenation zone:"));
ui.add(egui::DragValue::new(&mut f.zone).speed(0.01).range(0.0..=22.0 * 72.0 / unit().pt_per_unit()).suffix(unit().suffix()).max_decimals(2));
ui.end_row();
ui.label(tl!("Limit consecutive hyphens to:"));
let no_limit = tl!("No limit").to_string();
ui.add(
egui::DragValue::new(&mut f.limit).range(0..=99).custom_formatter(move |n, _| if n < 0.5 { no_limit.clone() } else { format!("{n:.0}") }),
);
ui.end_row();
});
ui.add_space(6.0);
let manual = ui.button(tl!("Manual…")).clicked();
let (ok, cancel) = buttons(ui, "OK", true);
if (ok || manual) && !run(app, "layout.hyphenation", f.params()) {
return false;
}
// Manual… keeps what was set here and moves on to manual hyphenation.
if manual {
app.dialog = crate::dialogs::Dialog::open("manualHyphenation", app);
}
ok || cancel || manual
}
// ---------------------------------------------------------------------------------------------
// Manual hyphenation
/// One word at a time: where it may be hyphenated and where the hyphen goes.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ManualForm {
pub word: String,
/// Where the word starts.
pub pos: Value,
/// Char indices a hyphen may go before.
pub points: Vec<usize>,
pub choice: usize,
/// No more words.
pub done: bool,
}
impl ManualForm {
/// From the selection, or from the start of the document.
pub fn start(app: &mut WordApp) -> ManualForm {
let from = if app.session.sel.is_collapsed() { Value::Null } else { wordcraft_engine::cmd::pos_json(&app.session.sel.ordered().0) };
let from = if from.is_null() { json!({"block": 0, "off": 0}) } else { from };
Self::next(app, from)
}
/// The next word from `from`.
pub fn next(app: &mut WordApp, from: Value) -> ManualForm {
let c = app.session.run("layout.manualHyphenation", &json!({"from": from})).unwrap_or_else(|_| json!({"done": true}));
let points: Vec<usize> =
c.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default();
// The word is selected, so the page shows which one is asked about.
if let (Some(pos), Some(word)) = (c.get("pos"), c.get("word").and_then(Value::as_str)) {
let end = with_off(pos, pos.get("off").and_then(Value::as_u64).unwrap_or(0).saturating_add(word.len() as u64));
let _ = app.session.run("select.range", &json!({"anchor": pos, "focus": end}));
}
ManualForm {
word: c.get("word").and_then(Value::as_str).unwrap_or("").to_string(),
pos: c.get("pos").cloned().unwrap_or(Value::Null),
choice: c.get("suggest").and_then(Value::as_u64).map(|n| n as usize).or_else(|| points.last().copied()).unwrap_or(0),
points,
done: c.get("done").and_then(Value::as_bool).unwrap_or(false) || c.get("word").is_none(),
}
}
/// The word's position plus `bytes`.
fn at(&self, bytes: usize) -> Value {
let off = self.pos.get("off").and_then(Value::as_u64).unwrap_or(0);
with_off(&self.pos, off.saturating_add(bytes as u64))
}
fn byte_of(&self, ch: usize) -> usize {
self.word.char_indices().nth(ch).map_or(self.word.len(), |(i, _)| i)
}
/// Yes: an optional hyphen at the chosen point, then the next word.
pub fn accept(&mut self, app: &mut WordApp) {
let at = self.at(self.byte_of(self.choice));
if run(app, "caret.set", json!({"pos": at})) && run(app, "text.optionalHyphen", json!({})) {
let after = self.at(self.word.len() + '\u{ad}'.len_utf8());
*self = Self::next(app, after);
}
}
/// No: leave the word alone and go to the next one.
pub fn skip(&mut self, app: &mut WordApp) {
let after = self.at(self.word.len());
*self = Self::next(app, after);
}
}
/// A position (`{"story", "path", "off"}`) at another offset in the same paragraph.
fn with_off(pos: &Value, off: u64) -> Value {
let mut p = pos.as_object().cloned().unwrap_or_default();
p.insert("off".into(), json!(off));
Value::Object(p)
}
fn manual_ui(app: &mut WordApp, ui: &mut Ui, f: &mut ManualForm) -> bool {
if f.done {
ui.label(tl!("Manual hyphenation is complete."));
ui.add_space(8.0);
return ui.button(tl!("Close")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter));
}
ui.label(tl!("Hyphenate at:"));
// The word with every possible break; the chosen one is the hyphen that will be inserted.
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
let mut from = 0;
for pt in f.points.clone() {
ui.label(egui::RichText::new(f.word.chars().skip(from).take(pt.saturating_sub(from)).collect::<String>()).size(16.0));
if ui.selectable_label(f.choice == pt, egui::RichText::new("-").size(16.0)).clicked() {
f.choice = pt;
}
from = pt;
}
ui.label(egui::RichText::new(f.word.chars().skip(from).collect::<String>()).size(16.0));
});
ui.add_space(8.0);
let mut close = false;
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
}
if ui.button(tl!("No")).clicked() {
f.skip(app);
}
if ui.add(egui::Button::new(egui::RichText::new(tl!("Yes")).color(Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked() {
f.accept(app);
}
});
});
close
}
// ---------------------------------------------------------------------------------------------
// Language
/// Proofing languages offered, by BCP 47 tag, with their English names (translated for display).
pub const LANGUAGES: [(&str, &str); 29] = [
("en-US", "English (United States)"),
("en-GB", "English (United Kingdom)"),
("de-DE", "German (Germany)"),
("fr-FR", "French (France)"),
("es-ES", "Spanish (Spain)"),
("es-MX", "Spanish (Mexico)"),
("it-IT", "Italian"),
("pt-BR", "Portuguese (Brazil)"),
("pt-PT", "Portuguese (Portugal)"),
("nl-NL", "Dutch"),
("sv-SE", "Swedish"),
("da-DK", "Danish"),
("nb-NO", "Norwegian (Bokmål)"),
("fi-FI", "Finnish"),
("et-EE", "Estonian"),
("pl-PL", "Polish"),
("cs-CZ", "Czech"),
("uk-UA", "Ukrainian"),
("ru-RU", "Russian"),
("sr-Cyrl-RS", "Serbian (Cyrillic)"),
("sr-Latn-RS", "Serbian (Latin)"),
("el-GR", "Greek"),
("tr-TR", "Turkish"),
("ja-JP", "Japanese"),
("zh-CN", "Chinese (Simplified)"),
("zh-TW", "Chinese (Traditional)"),
("ko-KR", "Korean"),
("ar-SA", "Arabic"),
("he-IL", "Hebrew"),
];
/// The Language dialog's fields.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LanguageForm {
pub lang: String,
pub no_proof: bool,
pub detect: bool,
}
impl LanguageForm {
pub fn read(app: &WordApp) -> LanguageForm {
let s = &app.session;
let here = s.typing_props();
let lang = here.lang.clone().or_else(|| s.doc.styles.default_chr.lang.clone()).unwrap_or_else(|| "en-US".into());
LanguageForm { lang, no_proof: here.no_proof.unwrap_or(false), detect: s.prefs.detect_language }
}
/// `review.language` parameters for OK.
pub fn params(&self) -> Value {
json!({"lang": self.lang, "noProof": self.no_proof, "detect": self.detect})
}
}
/// A language's name in the interface language (the tag itself when we don't know it).
fn language_name(tag: &str) -> String {
LANGUAGES.iter().find(|(t, _)| t.eq_ignore_ascii_case(tag)).map(|(_, n)| tl!(n).to_string()).unwrap_or_else(|| tag.to_string())
}
fn language_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LanguageForm) -> bool {
ui.label(tl!("Mark selected text as:"));
let mut list: Vec<(String, String)> = LANGUAGES.iter().map(|(t, _)| (t.to_string(), language_name(t))).collect();
if !LANGUAGES.iter().any(|(t, _)| t.eq_ignore_ascii_case(&f.lang)) {
list.push((f.lang.clone(), f.lang.clone()));
}
list.sort_by_key(|a| a.1.to_lowercase());
egui::Frame::NONE.stroke(egui::Stroke::new(1.0, crate::theme::Tokens::get(ui.ctx()).border_strong)).inner_margin(4.0).show(ui, |ui| {
egui::ScrollArea::vertical().max_height(220.0).min_scrolled_height(220.0).show(ui, |ui| {
ui.set_min_width(300.0);
for (tag, name) in &list {
let on = f.lang.eq_ignore_ascii_case(tag);
let r = ui.selectable_label(on, name.as_str());
if r.clicked() {
f.lang = tag.clone();
}
}
});
});
ui.add_space(4.0);
ui.checkbox(&mut f.no_proof, tl!("Do not check spelling or grammar"));
ui.checkbox(&mut f.detect, tl!("Detect language automatically"));
ui.add_space(4.0);
let set_default = ui.button(tl!("Set As Default")).clicked();
if set_default && run(app, "review.language", json!({"lang": f.lang, "default": true, "detect": f.detect})) {
app.status(crate::i18n::fmt(tl!("{language} is now the document's default language."), &[("language", &language_name(&f.lang))]));
}
let (ok, cancel) = buttons(ui, "OK", true);
if ok {
return run(app, "review.language", f.params());
}
cancel
}
// ---------------------------------------------------------------------------------------------
// Envelopes
/// The Envelopes dialog's fields. `size` indexes [`ENVELOPE_SIZES`].
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnvelopeForm {
pub delivery: String,
pub ret: String,
pub omit_return: bool,
pub size: usize,
}
impl EnvelopeForm {
/// The delivery address starts as the selected text (an address in the letter).
pub fn read(app: &WordApp) -> EnvelopeForm {
let sel = if app.session.sel.is_collapsed() { String::new() } else { app.session.selected_text() };
EnvelopeForm { delivery: sel.trim().replace("\r\n", "\n").replace('\r', "\n"), ret: String::new(), omit_return: false, size: 0 }
}
/// `mailings.envelopes` parameters; `add` puts the envelope at the start of this document.
pub fn params(&self, add: bool) -> Value {
let size = ENVELOPE_SIZES.get(self.size).map_or("Envelope #10", |s| s.0);
let ret = if self.omit_return { "" } else { self.ret.as_str() };
json!({"delivery": self.delivery, "return": ret, "size": size, "add": add})
}
}
fn envelopes_ui(app: &mut WordApp, ui: &mut Ui, f: &mut EnvelopeForm) -> bool {
ui.label(tl!("Delivery address:"));
ui.add(egui::TextEdit::multiline(&mut f.delivery).desired_rows(4).desired_width(340.0));
ui.horizontal(|ui| {
ui.label(tl!("Return address:"));
ui.checkbox(&mut f.omit_return, tl!("Omit"));
});
ui.add_enabled(!f.omit_return, egui::TextEdit::multiline(&mut f.ret).desired_rows(3).desired_width(340.0));
ui.horizontal(|ui| {
ui.label(tl!("Envelope size:"));
let label = |i: usize| ENVELOPE_SIZES.get(i).map(|(n, w, h)| envelope_label(n, *w, *h)).unwrap_or_default();
egui::ComboBox::from_id_salt("env_size").selected_text(label(f.size)).width(220.0).show_ui(ui, |ui| {
for i in 0..ENVELOPE_SIZES.len() {
ui.selectable_value(&mut f.size, i, label(i));
}
});
});
let ready = !f.delivery.trim().is_empty();
ui.add_space(8.0);
let (mut add, mut new, mut cancel) = (false, false, false);
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
cancel = ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape));
new = ui.add_enabled(ready, egui::Button::new(tl!("New Document"))).clicked();
add = ui
.add_enabled(
ready,
egui::Button::new(egui::RichText::new(tl!("Add to Document")).color(Color32::WHITE)).fill(crate::theme::APP_COLOR),
)
.clicked();
});
});
if add || new {
return run(app, "mailings.envelopes", f.params(add));
}
cancel
}
/// `DL 110 × 220 mm` / `#10 4.13 × 9.5 in`: metric sizes in millimetres, the others in inches.
fn envelope_label(name: &str, w: f32, h: f32) -> String {
let short = name.strip_prefix("Envelope ").unwrap_or(name);
if short.starts_with('#') || short == "Monarch" {
let i = |pt: f32| ((pt / 72.0) * 100.0).round() / 100.0;
format!("{short} {} × {} in", i(h), i(w))
} else {
let mm = |pt: f32| (pt * 25.4 / 72.0).round();
format!("{short} {} × {} mm", mm(h), mm(w))
}
}
// ---------------------------------------------------------------------------------------------
// Labels
/// The Labels dialog's fields. `product` indexes [`LABEL_PRODUCTS`]; `row`/`col` count from 1.
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LabelsForm {
pub text: String,
pub product: usize,
pub single: bool,
pub row: usize,
pub col: usize,
/// One label per recipient (when a recipient list is loaded).
pub recipients: bool,
}
impl LabelsForm {
pub fn read(app: &WordApp) -> LabelsForm {
let sel = if app.session.sel.is_collapsed() { String::new() } else { app.session.selected_text() };
LabelsForm {
text: sel.trim().replace("\r\n", "\n").replace('\r', "\n"),
product: 0,
single: false,
row: 1,
col: 1,
recipients: !app.session.merge.rows.is_empty(),
}
}
/// `mailings.labels` parameters.
pub fn params(&self) -> Value {
let id = LABEL_PRODUCTS.get(self.product).map_or("letter-3x10", |p| p.id);
let mut v = json!({"text": self.text, "product": id, "fromRecipients": self.recipients && !self.single});
if self.single {
v["single"] = json!({"row": self.row, "col": self.col});
}
v
}
}
/// `Address labels, 3 × 10, 2.625" × 1" (Letter)`.
fn product_label(i: usize) -> String {
let Some(p) = LABEL_PRODUCTS.get(i) else { return String::new() };
let kind = match p.kind {
"shipping" => tl!("Shipping labels"),
"return" => tl!("Return address labels"),
_ => tl!("Address labels"),
};
format!("{kind}, {} × {}, {} ({})", p.cols, p.rows, p.size_text(), p.paper)
}
fn labels_ui(app: &mut WordApp, ui: &mut Ui, f: &mut LabelsForm) -> bool {
let has_recipients = !app.session.merge.rows.is_empty();
if has_recipients {
ui.checkbox(&mut f.recipients, tl!("One label per recipient"));
}
ui.add_enabled_ui(!(has_recipients && f.recipients && !f.single), |ui| {
ui.label(tl!("Address:"));
ui.add(egui::TextEdit::multiline(&mut f.text).desired_rows(4).desired_width(340.0));
});
ui.horizontal(|ui| {
ui.label(tl!("Label:"));
egui::ComboBox::from_id_salt("label_product").selected_text(product_label(f.product)).width(300.0).show_ui(ui, |ui| {
for i in 0..LABEL_PRODUCTS.len() {
ui.selectable_value(&mut f.product, i, product_label(i));
}
});
});
let (rows, cols) = LABEL_PRODUCTS.get(f.product).map_or((1, 1), |p| (p.rows, p.cols));
heading(ui, "Print");
ui.radio_value(&mut f.single, false, tl!("Full page of the same label"));
ui.horizontal(|ui| {
ui.radio_value(&mut f.single, true, tl!("Single label"));
ui.add_enabled_ui(f.single, |ui| {
ui.label(tl!("Row:"));
ui.add(egui::DragValue::new(&mut f.row).range(1..=rows));
ui.label(tl!("Column:"));
ui.add(egui::DragValue::new(&mut f.col).range(1..=cols));
});
});
f.row = f.row.clamp(1, rows.max(1));
f.col = f.col.clamp(1, cols.max(1));
let ready = (has_recipients && f.recipients && !f.single) || !f.text.trim().is_empty();
let (ok, cancel) = buttons(ui, "New Document", ready);
if ok {
return run(app, "mailings.labels", f.params());
}
cancel
}
#[cfg(test)]
mod tests {
use serde_json::json;
use wordcraft_engine::Session;
use super::*;
use crate::dialogs::Dialog;
use crate::{Services, WordApp};
fn app() -> WordApp {
WordApp::new(Session::new(wordcraft_doc::Document::from_text("Dear Jo,\nThanks.")), Services::default())
}
fn form(a: &mut WordApp) -> LayoutDialog {
let Some(Dialog::Layout(d)) = a.dialog.take() else { panic!("no dialog: {:?}", a.dialog) };
*d
}
/// #407: Line Numbering Options… opens with the section's numbering and OK sets every
/// option; Hyphenation Options sets the document's hyphenation settings.
#[test]
fn line_numbers_and_hyphenation_options_apply() {
let mut a = app();
a.run("ui.dialog", json!({"name": "lineNumbers"})).unwrap();
let LayoutDialog::LineNumbers(mut f) = form(&mut a) else { panic!("line numbers") };
assert!(!f.on && f.auto_distance && f.restart == "restartPage");
f.on = true;
f.start = 10;
f.count_by = 5;
f.restart = "continuous".into();
a.run("layout.lineNumbers", f.params()).unwrap();
let l = a.session.doc.last_section.line_numbers.clone().unwrap();
assert_eq!((l.start, l.count_by, l.distance, l.restart), (10, 5, 0.0, LineNumberRestart::Continuous));
a.run("ui.dialog", json!({"name": "lineNumbers"})).unwrap();
assert!(matches!(form(&mut a), LayoutDialog::LineNumbers(f) if f.on && f.start == 10 && f.restart == "continuous"));
a.run("ui.dialog", json!({"name": "hyphenation"})).unwrap();
let LayoutDialog::Hyphenation(mut h) = form(&mut a) else { panic!("hyphenation") };
assert!(!h.auto && h.caps && h.limit == 0);
(h.auto, h.caps, h.limit) = (true, false, 2);
a.run("layout.hyphenation", h.params()).unwrap();
let s = &a.session.doc.settings;
assert!(s.auto_hyphenation && !s.hyphenate_caps && s.consecutive_hyphen_limit == 2);
assert!((s.hyphenation_zone - 18.0).abs() < 0.01, "zone untouched: {}", s.hyphenation_zone);
}
/// #407: the Language button opens the dialog (scripts with a language never see it); OK
/// marks the selection and "do not check", Set As Default changes the document default.
#[test]
fn language_dialog_marks_the_selection() {
let mut a = app();
a.run("select.all", json!({})).unwrap();
assert_eq!(a.run("review.language", json!({})).unwrap(), json!({"pending": "language"}));
let LayoutDialog::Language(mut f) = form(&mut a) else { panic!("language") };
assert_eq!(f.lang, "en-US");
f.lang = "sr-Latn-RS".into();
f.no_proof = true;
a.run("review.language", f.params()).unwrap();
let c = a.session.typing_props();
assert_eq!((c.lang.as_deref(), c.no_proof), (Some("sr-Latn-RS"), Some(true)));
a.run("review.language", json!({"lang": "fr-FR", "default": true})).unwrap();
assert_eq!(a.session.doc.styles.default_chr.lang.as_deref(), Some("fr-FR"));
assert!(a.dialog.is_none());
assert_eq!(language_name("xx-YY"), "xx-YY", "an unknown tag shows as itself");
}
/// #407: Envelopes starts from the selected address; Add to Document puts an envelope
/// section before the letter without asking to save (nothing is replaced).
#[test]
fn envelope_dialog_adds_an_envelope_section() {
let mut a = app();
a.run("select.paragraph", json!({})).unwrap();
a.session.dirty = true;
assert_eq!(a.run("mailings.envelopes", json!({})).unwrap(), json!({"pending": "envelopes"}));
let LayoutDialog::Envelopes(mut f) = form(&mut a) else { panic!("envelopes") };
assert_eq!(f.delivery, "Dear Jo,");
f.delivery = "Jo Doe\n1 Main St".into();
f.ret = "Me".into();
f.size = ENVELOPE_SIZES.iter().position(|s| s.0 == "Envelope DL").unwrap();
let document = a.session.document_id();
a.run("mailings.envelopes", f.params(true)).unwrap();
assert!(a.dialog.is_none(), "no save prompt");
assert_eq!(a.session.document_id(), document);
let secs = a.session.doc.sections();
assert_eq!(secs.len(), 2);
assert!((secs[0].1.page_w - 623.6).abs() < 0.1);
assert!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Thanks."));
}
/// #407: Labels builds a new document of the chosen sheet, or a single label.
#[test]
fn labels_dialog_builds_a_sheet() {
let mut a = app();
assert_eq!(a.run("mailings.labels", json!({})).unwrap(), json!({"pending": "labels"}));
let LayoutDialog::Labels(mut f) = form(&mut a) else { panic!("labels") };
f.text = "Ada Lovelace\nLondon".into();
f.product = LABEL_PRODUCTS.iter().position(|p| p.id == "a4-3x8").unwrap();
let r = a.session.run("mailings.labels", &f.params()).unwrap();
assert_eq!(r["labels"], 24);
assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).matches("Ada Lovelace").count(), 24);
f.single = true;
(f.row, f.col) = (3, 2);
assert_eq!(a.session.run("mailings.labels", &f.params()).unwrap()["filled"], 1);
assert!(product_label(f.product).contains("63.5 × 33.9 mm (A4)"));
}
}
+63
View File
@@ -1594,3 +1594,66 @@
Rewind Rebobinar
Forward Avanzar
Stroke {n} of {total} Trazo {n} de {total}
Add line numbering Agregar numeración de líneas
Add to Document Agregar al documento
Address labels Etiquetas de dirección
Arabic Árabe
Auto Auto
Automatically hyphenate document Separar palabras automáticamente en el documento
Chinese (Simplified) Chino (simplificado)
Chinese (Traditional) Chino (tradicional)
Column: Columna:
Count by: Intervalo:
Czech Checo
Danish Danés
Delivery address: Dirección del destinatario:
Detect language automatically Detectar idioma automáticamente
Do not check spelling or grammar No revisar la ortografía ni la gramática
Dutch Neerlandés
English (United Kingdom) Inglés (Reino Unido)
Envelope size: Tamaño del sobre:
Estonian Estonio
Finnish Finés
French (France) Francés (Francia)
From text: Desde el texto:
Full page of the same label Página completa de la misma etiqueta
German (Germany) Alemán (Alemania)
Greek Griego
Hebrew Hebreo
Hyphenate at: Separar en:
Hyphenate words in CAPS Separar palabras en MAYÚSCULAS
Hyphenation Options… Opciones de separación de palabras…
Hyphenation zone: Zona de separación:
Italian Italiano
Japanese Japonés
Korean Coreano
Limit consecutive hyphens to: Limitar guiones consecutivos a:
Line Numbering Options… Opciones de numeración de líneas…
Manual Hyphenation Separación manual de palabras
Manual hyphenation is complete. La separación manual de palabras ha terminado.
Manual… Manual…
Mark selected text as: Marcar el texto seleccionado como:
New Document Documento nuevo
No limit Sin límite
Norwegian (Bokmål) Noruego (bokmål)
Numbering: Numeración:
Omit Omitir
One label per recipient Una etiqueta por destinatario
Polish Polaco
Portuguese (Brazil) Portugués (Brasil)
Portuguese (Portugal) Portugués (Portugal)
Return address labels Etiquetas de remitente
Return address: Dirección del remitente:
Row: Fila:
Russian Ruso
Serbian (Cyrillic) Serbio (cirílico)
Serbian (Latin) Serbio (latino)
Set As Default Establecer como predeterminado
Shipping labels Etiquetas de envío
Single label Una sola etiqueta
Spanish (Mexico) Español (México)
Spanish (Spain) Español (España)
Swedish Sueco
Turkish Turco
Ukrainian Ucraniano
{language} is now the document's default language. {language} es ahora el idioma predeterminado del documento.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+63
View File
@@ -1588,3 +1588,66 @@
Rewind Keri algusesse
Forward Edasi
Stroke {n} of {total} Joon {n}/{total}
Add line numbering Lisa reanummerdus
Add to Document Lisa dokumenti
Address labels Aadressisildid
Arabic Araabia
Auto Automaatne
Automatically hyphenate document Poolita dokumenti automaatselt
Chinese (Simplified) Hiina (lihtsustatud)
Chinese (Traditional) Hiina (traditsiooniline)
Column: Veerg:
Count by: Samm:
Czech Tšehhi
Danish Taani
Delivery address: Saaja aadress:
Detect language automatically Tuvasta keel automaatselt
Do not check spelling or grammar Ära kontrolli õigekirja ega grammatikat
Dutch Hollandi
English (United Kingdom) Inglise (Ühendkuningriik)
Envelope size: Ümbriku suurus:
Estonian Eesti
Finnish Soome
French (France) Prantsuse (Prantsusmaa)
From text: Tekstist:
Full page of the same label Terve leht sama silti
German (Germany) Saksa (Saksamaa)
Greek Kreeka
Hebrew Heebrea
Hyphenate at: Poolita kohast:
Hyphenate words in CAPS Poolita SUURTÄHTEDES sõnu
Hyphenation Options… Poolitussätted…
Hyphenation zone: Poolitusala:
Italian Itaalia
Japanese Jaapani
Korean Korea
Limit consecutive hyphens to: Järjestikuseid poolitusi kuni:
Line Numbering Options… Reanummerduse sätted…
Manual Hyphenation Käsitsi poolitamine
Manual hyphenation is complete. Käsitsi poolitamine on lõpetatud.
Manual… Käsitsi…
Mark selected text as: Märgi valitud tekst keeleks:
New Document Uus dokument
No limit Piiranguta
Norwegian (Bokmål) Norra (bokmål)
Numbering: Nummerdus:
Omit Jäta välja
One label per recipient Üks silt saaja kohta
Polish Poola
Portuguese (Brazil) Portugali (Brasiilia)
Portuguese (Portugal) Portugali (Portugal)
Return address labels Saatja aadressi sildid
Return address: Saatja aadress:
Row: Rida:
Russian Vene
Serbian (Cyrillic) Serbia (kirillitsa)
Serbian (Latin) Serbia (ladina)
Set As Default Määra vaikeväärtuseks
Shipping labels Saatmissildid
Single label Üks silt
Spanish (Mexico) Hispaania (Mehhiko)
Spanish (Spain) Hispaania (Hispaania)
Swedish Rootsi
Turkish Türgi
Ukrainian Ukraina
{language} is now the document's default language. {language} on nüüd dokumendi vaikekeel.
Can't render this file because it contains an unexpected character in line 421 and column 13.
+63
View File
@@ -1578,3 +1578,66 @@
Rewind 巻き戻し
Forward 早送り
Stroke {n} of {total} ストローク {n}/{total}
Add line numbering 行番号を追加する
Add to Document 文書に追加
Address labels 宛名ラベル
Arabic アラビア語
Auto 自動
Automatically hyphenate document 文書を自動的にハイフネーションする
Chinese (Simplified) 中国語(簡体字)
Chinese (Traditional) 中国語(繁体字)
Column: 列:
Count by: 増分:
Czech チェコ語
Danish デンマーク語
Delivery address: 宛先:
Detect language automatically 言語を自動的に検出する
Do not check spelling or grammar スペルチェックと文章校正を行わない
Dutch オランダ語
English (United Kingdom) 英語(英国)
Envelope size: 封筒サイズ:
Estonian エストニア語
Finnish フィンランド語
French (France) フランス語(フランス)
From text: 本文からの距離:
Full page of the same label 同じラベルで 1 ページ全体
German (Germany) ドイツ語(ドイツ)
Greek ギリシャ語
Hebrew ヘブライ語
Hyphenate at: ハイフネーション位置:
Hyphenate words in CAPS 大文字の単語をハイフネーションする
Hyphenation Options… ハイフネーションのオプション…
Hyphenation zone: ハイフネーション領域:
Italian イタリア語
Japanese 日本語
Korean 韓国語
Limit consecutive hyphens to: 連続するハイフンの上限:
Line Numbering Options… 行番号のオプション…
Manual Hyphenation 手動ハイフネーション
Manual hyphenation is complete. 手動ハイフネーションが完了しました。
Manual… 手動…
Mark selected text as: 選択した文字列の言語:
New Document 新規文書
No limit 制限なし
Norwegian (Bokmål) ノルウェー語(ブークモール)
Numbering: 番号の付け方:
Omit 省略
One label per recipient 宛先ごとに 1 枚のラベル
Polish ポーランド語
Portuguese (Brazil) ポルトガル語(ブラジル)
Portuguese (Portugal) ポルトガル語(ポルトガル)
Return address labels 差出人ラベル
Return address: 差出人住所:
Row: 行:
Russian ロシア語
Serbian (Cyrillic) セルビア語(キリル文字)
Serbian (Latin) セルビア語(ラテン文字)
Set As Default 既定に設定
Shipping labels 配送ラベル
Single label 1 枚のラベル
Spanish (Mexico) スペイン語(メキシコ)
Spanish (Spain) スペイン語(スペイン)
Swedish スウェーデン語
Turkish トルコ語
Ukrainian ウクライナ語
{language} is now the document's default language. {language} がこの文書の既定の言語になりました。
Can't render this file because it contains an unexpected character in line 504 and column 13.
+63
View File
@@ -1590,3 +1590,66 @@
Rewind Retroceder
Forward Avançar
Stroke {n} of {total} Traço {n} de {total}
Add line numbering Adicionar numeração de linhas
Add to Document Adicionar ao documento
Address labels Etiquetas de endereço
Arabic Árabe
Auto Auto
Automatically hyphenate document Hifenizar o documento automaticamente
Chinese (Simplified) Chinês (simplificado)
Chinese (Traditional) Chinês (tradicional)
Column: Coluna:
Count by: Intervalo:
Czech Tcheco
Danish Dinamarquês
Delivery address: Endereço de entrega:
Detect language automatically Detectar o idioma automaticamente
Do not check spelling or grammar Não verificar ortografia nem gramática
Dutch Holandês
English (United Kingdom) Inglês (Reino Unido)
Envelope size: Tamanho do envelope:
Estonian Estoniano
Finnish Finlandês
French (France) Francês (França)
From text: Distância do texto:
Full page of the same label Página inteira da mesma etiqueta
German (Germany) Alemão (Alemanha)
Greek Grego
Hebrew Hebraico
Hyphenate at: Hifenizar em:
Hyphenate words in CAPS Hifenizar palavras em MAIÚSCULAS
Hyphenation Options… Opções de hifenização…
Hyphenation zone: Zona de hifenização:
Italian Italiano
Japanese Japonês
Korean Coreano
Limit consecutive hyphens to: Limitar hífens consecutivos a:
Line Numbering Options… Opções de numeração de linhas…
Manual Hyphenation Hifenização manual
Manual hyphenation is complete. A hifenização manual foi concluída.
Manual… Manual…
Mark selected text as: Marcar o texto selecionado como:
New Document Novo documento
No limit Sem limite
Norwegian (Bokmål) Norueguês (bokmål)
Numbering: Numeração:
Omit Omitir
One label per recipient Uma etiqueta por destinatário
Polish Polonês
Portuguese (Brazil) Português (Brasil)
Portuguese (Portugal) Português (Portugal)
Return address labels Etiquetas de remetente
Return address: Endereço do remetente:
Row: Linha:
Russian Russo
Serbian (Cyrillic) Sérvio (cirílico)
Serbian (Latin) Sérvio (latino)
Set As Default Definir como padrão
Shipping labels Etiquetas de envio
Single label Etiqueta única
Spanish (Mexico) Espanhol (México)
Spanish (Spain) Espanhol (Espanha)
Swedish Sueco
Turkish Turco
Ukrainian Ucraniano
{language} is now the document's default language. {language} agora é o idioma padrão do documento.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+63
View File
@@ -1965,3 +1965,66 @@ preview Title Пример
Continuous numbering Сквозная нумерация
Apply Применить
Convert Notes Преобразовать сноски
Add line numbering Добавить нумерацию строк
Add to Document Добавить в документ
Address labels Адресные наклейки
Arabic Арабский
Auto Авто
Automatically hyphenate document Автоматически расставлять переносы в документе
Chinese (Simplified) Китайский (упрощённый)
Chinese (Traditional) Китайский (традиционный)
Column: Столбец:
Count by: Шаг:
Czech Чешский
Danish Датский
Delivery address: Адрес получателя:
Detect language automatically Определять язык автоматически
Do not check spelling or grammar Не проверять орфографию и грамматику
Dutch Нидерландский
English (United Kingdom) Английский (Великобритания)
Envelope size: Размер конверта:
Estonian Эстонский
Finnish Финский
French (France) Французский (Франция)
From text: От текста:
Full page of the same label Целая страница одинаковых наклеек
German (Germany) Немецкий (Германия)
Greek Греческий
Hebrew Иврит
Hyphenate at: Переносить по:
Hyphenate words in CAPS Переносить слова из ПРОПИСНЫХ букв
Hyphenation Options… Параметры расстановки переносов…
Hyphenation zone: Зона переноса слов:
Italian Итальянский
Japanese Японский
Korean Корейский
Limit consecutive hyphens to: Максимум последовательных переносов:
Line Numbering Options… Параметры нумерации строк…
Manual Hyphenation Расстановка переносов вручную
Manual hyphenation is complete. Расстановка переносов вручную завершена.
Manual… Вручную…
Mark selected text as: Пометить выделенный текст как:
New Document Новый документ
No limit Без ограничения
Norwegian (Bokmål) Норвежский (букмол)
Numbering: Нумерация:
Omit Пропустить
One label per recipient Одна наклейка на получателя
Polish Польский
Portuguese (Brazil) Португальский (Бразилия)
Portuguese (Portugal) Португальский (Португалия)
Return address labels Наклейки с обратным адресом
Return address: Обратный адрес:
Row: Строка:
Russian Русский
Serbian (Cyrillic) Сербский (кириллица)
Serbian (Latin) Сербский (латиница)
Set As Default Использовать по умолчанию
Shipping labels Транспортные наклейки
Single label Одна наклейка
Spanish (Mexico) Испанский (Мексика)
Spanish (Spain) Испанский (Испания)
Swedish Шведский
Turkish Турецкий
Ukrainian Украинский
{language} is now the document's default language. {language} теперь язык документа по умолчанию.
Can't render this file because it contains an unexpected character in line 7 and column 25.
+63
View File
@@ -1588,3 +1588,66 @@
Rewind Na početak
Forward Napred
Stroke {n} of {total} Potez {n} od {total}
Add line numbering Dodaj brojeve redova
Add to Document Dodaj u dokument
Address labels Adresne nalepnice
Arabic Arapski
Auto Automatski
Automatically hyphenate document Automatski deli reči u dokumentu
Chinese (Simplified) Kineski (pojednostavljeni)
Chinese (Traditional) Kineski (tradicionalni)
Column: Kolona:
Count by: Korak:
Czech Češki
Danish Danski
Delivery address: Adresa primaoca:
Detect language automatically Automatski prepoznaj jezik
Do not check spelling or grammar Ne proveravaj pravopis ni gramatiku
Dutch Holandski
English (United Kingdom) Engleski (Ujedinjeno Kraljevstvo)
Envelope size: Veličina koverte:
Estonian Estonski
Finnish Finski
French (France) Francuski (Francuska)
From text: Od teksta:
Full page of the same label Cela stranica iste nalepnice
German (Germany) Nemački (Nemačka)
Greek Grčki
Hebrew Hebrejski
Hyphenate at: Podeli na:
Hyphenate words in CAPS Deli reči napisane VELIKIM SLOVIMA
Hyphenation Options… Opcije deljenja reči…
Hyphenation zone: Zona deljenja reči:
Italian Italijanski
Japanese Japanski
Korean Korejski
Limit consecutive hyphens to: Najviše uzastopnih crtica:
Line Numbering Options… Opcije brojeva redova…
Manual Hyphenation Ručno deljenje reči
Manual hyphenation is complete. Ručno deljenje reči je završeno.
Manual… Ručno…
Mark selected text as: Označi izabrani tekst kao:
New Document Novi dokument
No limit Bez ograničenja
Norwegian (Bokmål) Norveški (bukmol)
Numbering: Numerisanje:
Omit Izostavi
One label per recipient Jedna nalepnica po primaocu
Polish Poljski
Portuguese (Brazil) Portugalski (Brazil)
Portuguese (Portugal) Portugalski (Portugalija)
Return address labels Nalepnice sa adresom pošiljaoca
Return address: Adresa pošiljaoca:
Row: Red:
Russian Ruski
Serbian (Cyrillic) Srpski (ćirilica)
Serbian (Latin) Srpski (latinica)
Set As Default Postavi kao podrazumevano
Shipping labels Nalepnice za pošiljke
Single label Jedna nalepnica
Spanish (Mexico) Španski (Meksiko)
Spanish (Spain) Španski (Španija)
Swedish Švedski
Turkish Turski
Ukrainian Ukrajinski
{language} is now the document's default language. {language} je sada podrazumevani jezik dokumenta.
Can't render this file because it contains an unexpected character in line 6 and column 25.
+63
View File
@@ -1592,3 +1592,66 @@
Rewind На почетак
Forward Напред
Stroke {n} of {total} Потез {n} од {total}
Add line numbering Додај бројеве редова
Add to Document Додај у документ
Address labels Адресне налепнице
Arabic Арапски
Auto Аутоматски
Automatically hyphenate document Аутоматски дели речи у документу
Chinese (Simplified) Кинески (поједностављени)
Chinese (Traditional) Кинески (традиционални)
Column: Колона:
Count by: Корак:
Czech Чешки
Danish Дански
Delivery address: Адреса примаоца:
Detect language automatically Аутоматски препознај језик
Do not check spelling or grammar Не проверавај правопис ни граматику
Dutch Холандски
English (United Kingdom) Енглески (Уједињено Краљевство)
Envelope size: Величина коверте:
Estonian Естонски
Finnish Фински
French (France) Француски (Француска)
From text: Од текста:
Full page of the same label Цела страница исте налепнице
German (Germany) Немачки (Немачка)
Greek Грчки
Hebrew Хебрејски
Hyphenate at: Подели на:
Hyphenate words in CAPS Дели речи написане ВЕЛИКИМ СЛОВИМА
Hyphenation Options… Опције дељења речи…
Hyphenation zone: Зона дељења речи:
Italian Италијански
Japanese Јапански
Korean Корејски
Limit consecutive hyphens to: Највише узастопних цртица:
Line Numbering Options… Опције бројева редова…
Manual Hyphenation Ручно дељење речи
Manual hyphenation is complete. Ручно дељење речи је завршено.
Manual… Ручно…
Mark selected text as: Означи изабрани текст као:
New Document Нови документ
No limit Без ограничења
Norwegian (Bokmål) Норвешки (букмол)
Numbering: Нумерисање:
Omit Изостави
One label per recipient Једна налепница по примаоцу
Polish Пољски
Portuguese (Brazil) Португалски (Бразил)
Portuguese (Portugal) Португалски (Португалија)
Return address labels Налепнице са адресом пошиљаоца
Return address: Адреса пошиљаоца:
Row: Ред:
Russian Руски
Serbian (Cyrillic) Српски (ћирилица)
Serbian (Latin) Српски (латиница)
Set As Default Постави као подразумевано
Shipping labels Налепнице за пошиљке
Single label Једна налепница
Spanish (Mexico) Шпански (Мексико)
Spanish (Spain) Шпански (Шпанија)
Swedish Шведски
Turkish Турски
Ukrainian Украјински
{language} is now the document's default language. {language} је сада подразумевани језик документа.
Can't render this file because it contains an unexpected character in line 11 and column 25.
+63
View File
@@ -1587,3 +1587,66 @@
Continuous numbering Sürekli numaralandırma
Apply Uygula
Convert Notes Notları Dönüştür
Add line numbering Satır numaralandırma ekle
Add to Document Belgeye Ekle
Address labels Adres etiketleri
Arabic Arapça
Auto Otomatik
Automatically hyphenate document Belgede otomatik heceleme yap
Chinese (Simplified) Çince (Basitleştirilmiş)
Chinese (Traditional) Çince (Geleneksel)
Column: Sütun:
Count by: Artış:
Czech Çekçe
Danish Danca
Delivery address: Teslimat adresi:
Detect language automatically Dili otomatik algıla
Do not check spelling or grammar Yazım ve dil bilgisini denetleme
Dutch Felemenkçe
English (United Kingdom) İngilizce (Birleşik Krallık)
Envelope size: Zarf boyutu:
Estonian Estonca
Finnish Fince
French (France) Fransızca (Fransa)
From text: Metinden:
Full page of the same label Aynı etiketten tam sayfa
German (Germany) Almanca (Almanya)
Greek Yunanca
Hebrew İbranice
Hyphenate at: Hecele:
Hyphenate words in CAPS BÜYÜK HARFLİ sözcükleri hecele
Hyphenation Options… Heceleme Seçenekleri…
Hyphenation zone: Heceleme bölgesi:
Italian İtalyanca
Japanese Japonca
Korean Korece
Limit consecutive hyphens to: Ardışık tireleri şununla sınırla:
Line Numbering Options… Satır Numaralandırma Seçenekleri…
Manual Hyphenation El ile Heceleme
Manual hyphenation is complete. El ile heceleme tamamlandı.
Manual… El ile…
Mark selected text as: Seçili metni şu olarak işaretle:
New Document Yeni Belge
No limit Sınır yok
Norwegian (Bokmål) Norveççe (Bokmål)
Numbering: Numaralandırma:
Omit Atla
One label per recipient Alıcı başına bir etiket
Polish Lehçe
Portuguese (Brazil) Portekizce (Brezilya)
Portuguese (Portugal) Portekizce (Portekiz)
Return address labels Gönderen adresi etiketleri
Return address: Gönderen adresi:
Row: Satır:
Russian Rusça
Serbian (Cyrillic) Sırpça (Kiril)
Serbian (Latin) Sırpça (Latin)
Set As Default Varsayılan Olarak Ayarla
Shipping labels Kargo etiketleri
Single label Tek etiket
Spanish (Mexico) İspanyolca (Meksika)
Spanish (Spain) İspanyolca (İspanya)
Swedish İsveççe
Turkish Türkçe
Ukrainian Ukraynaca
{language} is now the document's default language. {language} artık belgenin varsayılan dili.
Can't render this file because it contains an unexpected character in line 430 and column 13.
+63
View File
@@ -1590,3 +1590,66 @@
Rewind На початок
Forward Вперед
Stroke {n} of {total} Штрих {n} з {total}
Add line numbering Додати нумерацію рядків
Add to Document Додати до документа
Address labels Адресні етикетки
Arabic Арабська
Auto Авто
Automatically hyphenate document Автоматично переносити слова в документі
Chinese (Simplified) Китайська (спрощена)
Chinese (Traditional) Китайська (традиційна)
Column: Стовпець:
Count by: Крок:
Czech Чеська
Danish Данська
Delivery address: Адреса одержувача:
Detect language automatically Визначати мову автоматично
Do not check spelling or grammar Не перевіряти правопис і граматику
Dutch Нідерландська
English (United Kingdom) Англійська (Велика Британія)
Envelope size: Розмір конверта:
Estonian Естонська
Finnish Фінська
French (France) Французька (Франція)
From text: Від тексту:
Full page of the same label Ціла сторінка однакових етикеток
German (Germany) Німецька (Німеччина)
Greek Грецька
Hebrew Іврит
Hyphenate at: Перенести після:
Hyphenate words in CAPS Переносити слова з ВЕЛИКИХ ЛІТЕР
Hyphenation Options… Параметри перенесення слів…
Hyphenation zone: Зона перенесення:
Italian Італійська
Japanese Японська
Korean Корейська
Limit consecutive hyphens to: Обмежити кількість переносів поспіль:
Line Numbering Options… Параметри нумерації рядків…
Manual Hyphenation Ручне перенесення слів
Manual hyphenation is complete. Ручне перенесення слів завершено.
Manual… Вручну…
Mark selected text as: Позначити виділений текст як:
New Document Новий документ
No limit Без обмеження
Norwegian (Bokmål) Норвезька (букмол)
Numbering: Нумерація:
Omit Пропустити
One label per recipient Одна етикетка на одержувача
Polish Польська
Portuguese (Brazil) Португальська (Бразилія)
Portuguese (Portugal) Португальська (Португалія)
Return address labels Етикетки зі зворотною адресою
Return address: Зворотна адреса:
Row: Рядок:
Russian Російська
Serbian (Cyrillic) Сербська (кирилиця)
Serbian (Latin) Сербська (латиниця)
Set As Default Зробити типовою
Shipping labels Етикетки для відправлень
Single label Одна етикетка
Spanish (Mexico) Іспанська (Мексика)
Spanish (Spain) Іспанська (Іспанія)
Swedish Шведська
Turkish Турецька
Ukrainian Українська
{language} is now the document's default language. {language} тепер типова мова документа.
Can't render this file because it contains an unexpected character in line 6 and column 31.
+63
View File
@@ -1590,3 +1590,66 @@
Rewind 倒回
Forward 前进
Stroke {n} of {total} 第 {n} 笔,共 {total} 笔
Add line numbering 添加行号
Add to Document 添加到文档
Address labels 地址标签
Arabic 阿拉伯语
Auto 自动
Automatically hyphenate document 自动断字
Chinese (Simplified) 中文(简体)
Chinese (Traditional) 中文(繁体)
Column: 列:
Count by: 行号间隔:
Czech 捷克语
Danish 丹麦语
Delivery address: 收信人地址:
Detect language automatically 自动检测语言
Do not check spelling or grammar 不检查拼写或语法
Dutch 荷兰语
English (United Kingdom) 英语(英国)
Envelope size: 信封尺寸:
Estonian 爱沙尼亚语
Finnish 芬兰语
French (France) 法语(法国)
From text: 距正文:
Full page of the same label 同一标签的整页
German (Germany) 德语(德国)
Greek 希腊语
Hebrew 希伯来语
Hyphenate at: 断字位置:
Hyphenate words in CAPS 大写单词断字
Hyphenation Options… 断字选项…
Hyphenation zone: 断字区:
Italian 意大利语
Japanese 日语
Korean 韩语
Limit consecutive hyphens to: 连续断字数限制:
Line Numbering Options… 行号选项…
Manual Hyphenation 手动断字
Manual hyphenation is complete. 手动断字已完成。
Manual… 手动…
Mark selected text as: 将所选文字标记为:
New Document 新建文档
No limit 无限制
Norwegian (Bokmål) 挪威语(书面挪威语)
Numbering: 编号:
Omit 省略
One label per recipient 每位收件人一个标签
Polish 波兰语
Portuguese (Brazil) 葡萄牙语(巴西)
Portuguese (Portugal) 葡萄牙语(葡萄牙)
Return address labels 回信地址标签
Return address: 寄信人地址:
Row: 行:
Russian 俄语
Serbian (Cyrillic) 塞尔维亚语(西里尔文)
Serbian (Latin) 塞尔维亚语(拉丁文)
Set As Default 设为默认值
Shipping labels 运输标签
Single label 单个标签
Spanish (Mexico) 西班牙语(墨西哥)
Spanish (Spain) 西班牙语(西班牙)
Swedish 瑞典语
Turkish 土耳其语
Ukrainian 乌克兰语
{language} is now the document's default language. {language} 现在是此文档的默认语言。
Can't render this file because it contains an unexpected character in line 7 and column 25.
+63
View File
@@ -1579,3 +1579,66 @@
Rewind 倒轉
Forward 快轉
Stroke {n} of {total} 第 {n} 筆,共 {total} 筆
Add line numbering 新增行號
Add to Document 新增至文件
Address labels 地址標籤
Arabic 阿拉伯文
Auto 自動
Automatically hyphenate document 自動斷字
Chinese (Simplified) 中文(簡體)
Chinese (Traditional) 中文(繁體)
Column: 欄:
Count by: 行號間距:
Czech 捷克文
Danish 丹麥文
Delivery address: 收件人地址:
Detect language automatically 自動偵測語言
Do not check spelling or grammar 不要檢查拼字或文法
Dutch 荷蘭文
English (United Kingdom) 英文(英國)
Envelope size: 信封大小:
Estonian 愛沙尼亞文
Finnish 芬蘭文
French (France) 法文(法國)
From text: 距本文:
Full page of the same label 整頁相同的標籤
German (Germany) 德文(德國)
Greek 希臘文
Hebrew 希伯來文
Hyphenate at: 斷字位置:
Hyphenate words in CAPS 大寫單字斷字
Hyphenation Options… 斷字選項…
Hyphenation zone: 斷字區:
Italian 義大利文
Japanese 日文
Korean 韓文
Limit consecutive hyphens to: 連續斷字數上限:
Line Numbering Options… 行號選項…
Manual Hyphenation 手動斷字
Manual hyphenation is complete. 手動斷字已完成。
Manual… 手動…
Mark selected text as: 將選取的文字標示為:
New Document 新文件
No limit 不限
Norwegian (Bokmål) 挪威文(巴克摩)
Numbering: 編號:
Omit 省略
One label per recipient 每位收件人一個標籤
Polish 波蘭文
Portuguese (Brazil) 葡萄牙文(巴西)
Portuguese (Portugal) 葡萄牙文(葡萄牙)
Return address labels 寄件人地址標籤
Return address: 寄件人地址:
Row: 列:
Russian 俄文
Serbian (Cyrillic) 塞爾維亞文(斯拉夫文)
Serbian (Latin) 塞爾維亞文(拉丁文)
Set As Default 設為預設值
Shipping labels 寄送標籤
Single label 單一標籤
Spanish (Mexico) 西班牙文(墨西哥)
Spanish (Spain) 西班牙文(西班牙)
Swedish 瑞典文
Turkish 土耳其文
Ukrainian 烏克蘭文
{language} is now the document's default language. {language} 現在是此文件的預設語言。
Can't render this file because it contains an unexpected character in line 505 and column 13.
+23 -7
View File
@@ -22,6 +22,7 @@ pub mod credits;
pub mod dialogs;
pub mod dialogs_insert;
pub mod dialogs_keyboard;
pub mod dialogs_layout;
pub mod dialogs_lists;
pub mod dialogs_notes;
pub mod dialogs_para;
@@ -1218,16 +1219,22 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> {
"references.markEntry" if !has("entry") => Some("markEntry"),
"references.sources" if params.as_object().is_none_or(|m| m.is_empty()) => Some("sourceManager"),
"references.citation" if !has("tag") && !has("source") => Some("createSource"),
// Language, Envelopes and Labels without settings show their dialogs (#407).
"review.language" if params.as_object().is_none_or(|m| m.is_empty()) => Some("language"),
"mailings.envelopes" if params.as_object().is_none_or(|m| m.is_empty()) => Some("envelopes"),
"mailings.labels" if params.as_object().is_none_or(|m| m.is_empty()) => Some("labels"),
_ => None,
}
}
/// User commands that replace or close the document (`file.open` without a path only shows the
/// file picker; the open that follows is checked). Envelopes, Labels and Finish & Merge make a
/// new document in its place; a merge written to a file (`path`) leaves it alone.
/// new document in its place; a merge written to a file (`path`) and an envelope added to this
/// document (`add`) leave it alone.
fn discards_document(id: &str, params: &Value) -> bool {
match id {
"file.new" | "file.close" | "mailings.envelopes" | "mailings.labels" => true,
"file.new" | "file.close" | "mailings.labels" => true,
"mailings.envelopes" => params.get("add").and_then(Value::as_bool) != Some(true),
"file.open" => params.get("path").is_some(),
"mailings.finish" => params.get("path").and_then(Value::as_str).is_none(),
_ => false,
@@ -2041,6 +2048,15 @@ mod tests {
const MAILINGS: [&str; 3] = ["mailings.envelopes", "mailings.labels", "mailings.finish"];
/// What the user gave: Envelopes and Labels without anything open their dialogs (#407).
fn mailing(id: &str) -> Value {
match id {
"mailings.envelopes" => json!({"delivery": "Ada"}),
"mailings.labels" => json!({"text": "Ada"}),
_ => json!({}),
}
}
/// Envelopes, Labels and Finish & Merge replace the document with a new one, like New, but
/// the ribbon ran them at once and the unsaved document was gone without a question.
#[test]
@@ -2049,7 +2065,7 @@ mod tests {
let mut a = typed();
a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
let document = a.session.document_id();
a.run(id, json!({})).unwrap();
a.run(id, mailing(id)).unwrap();
assert_eq!(prompt(&a), Some("saveChanges"), "{id}");
assert_eq!(a.session.document_id(), document, "{id}: untouched while the prompt is up");
@@ -2059,7 +2075,7 @@ mod tests {
assert!(body_text(&a).contains(UNSAVED));
assert!(a.session.dirty);
a.run(id, json!({})).unwrap();
a.run(id, mailing(id)).unwrap();
a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap();
assert_eq!(prompt(&a), None);
assert_ne!(a.session.document_id(), document, "{id}: Don't Save makes the new document");
@@ -2083,7 +2099,7 @@ mod tests {
a.run("file.save", json!({"path": dir.join("saved.docx").to_string_lossy()})).unwrap();
assert!(!a.session.dirty);
let document = a.session.document_id();
a.run(id, json!({})).unwrap();
a.run(id, mailing(id)).unwrap();
assert_eq!(prompt(&a), None, "{id}");
assert_ne!(a.session.document_id(), document, "{id}");
}
@@ -2111,7 +2127,7 @@ mod tests {
assert!(a.autosaves());
a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
a.run("text.insert", json!({"text": "Again "})).unwrap();
a.run(id, json!({})).unwrap();
a.run(id, mailing(id)).unwrap();
a.run("ui.saveChanges", json!({"answer": "save"})).unwrap();
assert_eq!(prompt(&a), None);
let saved = std::fs::read(&path).unwrap();
@@ -2187,7 +2203,7 @@ mod tests {
a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap();
a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap();
a.run("file.save", json!({})).unwrap();
a.run(id, json!({})).unwrap();
a.run(id, mailing(id)).unwrap();
assert_eq!(prompt(&a), None, "{id}: the document was saved");
let result = body_text(&a);
+5
View File
@@ -953,11 +953,16 @@ fn layout(app: &mut WordApp, ui: &mut Ui) {
{
mi(ui, app, l, "layout.lineNumbers", json!({"value": v}));
}
ui.separator();
mi(ui, app, "Line Numbering Options…", "ui.dialog", json!({"name": "lineNumbers"}));
});
let hy = app.session.doc.settings.auto_hyphenation;
menu_button(ui, app, "hyphenation", Some("Hyphenation"), "Hyphenation", false, |ui, app| {
mi(ui, app, if hy { "✓ Automatic" } else { "Automatic" }, "layout.hyphenation", json!({"value": true}));
mi(ui, app, if hy { "None" } else { "✓ None" }, "layout.hyphenation", json!({"value": false}));
mi(ui, app, "Manual…", "ui.dialog", json!({"name": "manualHyphenation"}));
ui.separator();
mi(ui, app, "Hyphenation Options…", "ui.dialog", json!({"name": "hyphenation"}));
});
});
});
+2
View File
@@ -49,6 +49,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111) |
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
| Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | |
| Hyphenation settings (`w:autoHyphenation`, `w:hyphenationZone`, `w:doNotHyphenateCaps`, `w:consecutiveHyphenLimit`) | ✅ | ✅ | Zone, capitals and limit kept and honoured by layout (#407) |
| Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds`, modern threaded comments (`w16cex`) partly |
| Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 |
| Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them |
@@ -87,6 +88,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Hyphenation zone, words in capitals and consecutive-hyphen limit read and written (#407) |
| 2026-10-11 | trivial | Alt text, titles and the decorative mark for every drawing, table captions and descriptions round-trip (#386) |
| 2026-10-11 | trivial | Document grid (`w:docGrid`) and paragraph `w:snapToGrid` read and written (#391) |
| 2026-10-11 | trivial | Move tracking (`w:moveFrom`/`w:moveTo` and their ranges) read and written (#400) |
+2 -1
View File
@@ -17,7 +17,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes.
| 4 | **Pagination differs from Word**: Aptos has no metric-matched substitute; legacy compatibility options ignored; no page-by-page comparison harness | B | [layout-parity.md](layout-parity.md); [typing-parity.md](typing-parity.md) Known gaps | Page counts and line breaks differ, which matters for forms, legal and academic work | 30–50 + owner (font) | [layout-parity.md](layout-parity.md) |
| 5 | **Content controls** are lost (`w:sdt` unwrapped). Move tracking landed (#400): `w:moveFrom`/`w:moveTo` with their named ranges round-trip, a tracked cut and paste of a paragraph or sentence records a move, either end accepts/rejects both, green double strike/underline markup and "Moved from/to" balloons; still missing: drag-and-drop moves (no text drag yet) and move options in Track Changes Options. Formatting revisions landed (#41): `w:rPrChange`/`w:pPrChange`/`w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange`/`w:numberingChange` round-trip, are recorded while tracking, accepted/rejected and shown in balloons; still missing: tracked style-definition changes and table/section changes made in WordCraft | B | Issues #41, #400; source grep | Legal and editorial review workflows; templates with form controls | 5–8 | [file-format-parity.md](file-format-parity.md) |
| 6 | **No native printing** on desktop: File › Print opens the PDF in the system viewer, which prints (#286); no print dialog of our own (printer, copies, ranges); web prints through the browser (#209) | B | `ui.print` → `apps/wordcraft/src/print.rs`; issue #15 | Everyone who prints | 5–8 | [hardware-parity.md](hardware-parity.md) |
| 7 | **Dialog depth**: 41 modal dialogs vs Word's ~100 (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 | 15–25 | [ui-parity.md](ui-parity.md) |
| 7 | **Dialog depth**: 46 modal dialogs vs Word's ~100 (Compare, Protect, AutoCorrect, Options panes…) | B | `Dialog` enum in `crates/ui-egui/src/dialogs.rs` | Power users reach for dialogs the ribbon alone doesn't cover | 11–19 | [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), text in a freely rotated text box (free rotation and flips landed, #332; text direction, alignment and linked text boxes landed, #369; contour wrap and wrap side landed, #354), shape effects, WordArt, connectors, dragging members inside a Drawing Canvas (the canvas landed, #344) | | [parity-checklist.md](parity-checklist.md) (Shape Format 6/6, Layout › Group) | Newsletters, flyers, reports with diagrams | 20–30 | [target-app-parity.md](target-app-parity.md) |
@@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Line Numbers, Hyphenation, Language, Envelopes and Labels dialogs landed (#407); gap #7 at 30 dialogs, 16–24 h; duplicated #7 rows merged |
| 2026-10-11 | trivial | Caption, Index, Mark Index Entry, Custom Table of Contents, Source Manager and Create/Edit Source dialogs landed (#402); removed from the dialog-depth list |
| 2026-10-11 | trivial | Document grid landed (#391); removed from the East Asian gap |
| 2026-10-11 | trivial | Move tracking landed (#400); gap #5 narrowed to content controls, 5–8 h |
+3 -2
View File
@@ -1,6 +1,6 @@
# Layout and pagination parity with Microsoft Word
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** major (first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (hyphenation zone, capitals and consecutive-hyphen limit from the document, #407; previously major: first version; line breaking, pagination and object placement checked against Word's behaviour from the layout source on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
A document only "opens correctly" if its pages break where Word breaks them: the same lines, the
same page count, footnotes and pictures on the same pages. This checklist covers line breaking,
@@ -26,7 +26,7 @@ font can't match Word's metrics.
| Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Word puts multiple-spacing extra below the text and collapses spacing differently (#135, #108 open) | 3–5 |
| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines | — |
| Tabs (left, center, right, decimal, bar), leaders | 🟡 | Bar tabs untested | 1–2 |
| Hyphenation (automatic, 0.25" zone, soft hyphens, consecutive limit) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization |
| Hyphenation (automatic, zone from the document, words in capitals, soft hyphens, consecutive limit, Manual Hyphenation, #407) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization |
| Drop caps | ✅ | 2026-10-06 | — |
| Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — |
| Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42) | 1–2 |
@@ -68,6 +68,7 @@ font can't match Word's metrics.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Hyphenation zone, words in capitals and consecutive-hyphen limit follow the document settings; Manual Hyphenation (#407) |
| 2026-10-11 | trivial | Document grid: line and character pitch (#391) |
| 2026-10-11 | trivial | Linked text boxes, Align Text and text-box text direction (#369) |
| 2026-10-11 | trivial | Right-to-left tables and sections laid out mirrored (#362) |
+3 -2
View File
@@ -6,7 +6,7 @@ How WordCraft looks and feels next to Word: ribbon, dialogs, panes, keyboard, mo
Typing behaviour has its own checklist ([`typing-parity.md`](typing-parity.md)). All assets are our
own (`AGENTS.md`: no Microsoft iconography, ever).
**Dimension: ~65% (estimated), 65–100 h to full** (the sum of the rows below; dialog depth is counted here, not under features).
**Dimension: ~65% (estimated), 61–94 h to full** (the sum of the rows below; dialog depth is counted here, not under features).
| Area | WordCraft | Word | Parity | Hours |
|---|---|---|---|---|
@@ -16,7 +16,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Quick Access Toolbar | AutoSave, then the user's commands (Save, Undo, Redo by default); add, remove and reorder from its … menu (common commands, More Commands…), right-click on any ribbon button, or Customize Ribbon; saved per user (#379, #376). Not shown below the ribbon | customizable | 90% | 0.5 |
| Customize Ribbon / Customize Keyboard | Customize Keyboard (#368): categories, a command's keys, press a combination to see which command uses it, Assign / Remove / Reset All; custom keys override built-ins, removed built-ins do nothing, saved per user; `tools.customizeKeyboard` for agents. Customize Ribbon (#379): show, hide and reorder tabs, custom tabs and custom groups (on any tab) holding any commands, rename, remove, Reset; right-click on the ribbon; saved per user; `tools.customizeRibbon` for agents. Built-in tabs can't be renamed; no import/export file | ✅ | 85% | 1–2 |
| Keyboard shortcuts | **125** commands bound in `CommandSpec::key`, 144 key combinations (measured on Linux/Windows); styles, spacing, symbols, fields, views, mail merge (#330); field keys Alt+F9, Shift+F9, Ctrl+Shift+F9, Ctrl+F11, Ctrl+Shift+F11, F11, Shift+F11 (#339); Alt+Shift+arrows (move paragraphs, promote/demote), Shift+F5 Go Back and Alt+X (#343). Missing: en dash on the numeric keypad | ~250 default shortcuts | ~70% | 2–4 |
| Modal dialogs | **44** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402)) plus panes and menus for the rest | ~100 (Compare, Protect, Language, Hyphenation, Line Numbers, Envelopes, Labels, AutoCorrect, Options panes…) | ~45% | 15–25 |
| Modal dialogs | **49** (`Dialog` enum: Font, Enclose Characters, Paragraph, Tabs, Borders and Shading, Find, Go To, Insert Table, Table Properties, Page Setup, Link, Bookmark, Word Count, Zoom, Watermark, New Style, Modify Style, Manage Styles, Table Style, Commands, Icons, Paste Special, About, Save Changes, Password, Encrypt with Password, Define New Multilevel List, Track Changes Options; Columns, Symbol with Special Characters, Field (#321); Field code (#339); mail merge Recipients, Insert Merge Field, Find Recipient, Rules, Match Fields, Check for Errors; Caption with New Label and Numbering, Index, Mark Index Entry, Custom Table of Contents with Options and TOC styles, Source Manager, Create/Edit Source (#402); Line Numbers, Hyphenation with Manual Hyphenation, Language, Envelopes, Labels (#407)) plus panes and menus for the rest | ~100 (Compare, Protect, AutoCorrect, Options panes…) | ~45% | 11–19 |
| Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation, Styles, Comments, Thesaurus, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 |
| Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339); objects and tables partial | rich per context | 55% | 3–5 |
| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268) | | 68% | 4–6 |
@@ -37,6 +37,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one |
| 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) |
| 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) |
| 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) |