diff --git a/ROADMAP.md b/ROADMAP.md index cf684ff7e..e4465afa9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ae0c6928a..8b441b86b 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, pub watermark: Option, /// 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(), diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 3cd99ecb7..8de43028e 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -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); diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index b31fdf904..65a0e149b 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -852,6 +852,16 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec { 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", &[]); } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 902cca846..8a558e250 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -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(); diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 3ff6cb057..ba6611619 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -136,10 +136,12 @@ pub fn specs() -> Vec { .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::>(); 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> = (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> = (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()); + } } diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index f7c3388ae..a3311895e 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -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::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 { ] } +/// 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); + } } diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 111e44dba..8d1646504 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -350,17 +350,9 @@ pub fn specs() -> Vec { }) }) .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 { ] } +/// 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": "