From 4fd9075e99ca700073d73bf8ee2a476bba372c84 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:30:49 -0400 Subject: [PATCH] Sonar: split flood fill, CSV separator, noise collapse and numbered backspace Extract helpers with unchanged behaviour to bring these functions under the cognitive-complexity limit (S3776): - engine cmd/objects.rs clear_background: border_pixels, run_span, push_open_runs. - engine cmd/tools.rs collapse_noise_matches: flatten_region (Kind is now module-level). - engine cmd/mailings.rs parse_csv: csv_separator, push_csv_row. - engine cmd/text.rs backspace_inner: remove_number. --- crates/engine/src/cmd/mailings.rs | 28 +++++++----- crates/engine/src/cmd/objects.rs | 71 +++++++++++++++++++------------ crates/engine/src/cmd/text.rs | 36 +++++++++------- crates/engine/src/cmd/tools.rs | 27 +++++++----- 4 files changed, 98 insertions(+), 64 deletions(-) diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 488b9bc4a..800745636 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -522,7 +522,8 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult { } /// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs). -pub fn parse_csv(text: &str) -> Vec> { +/// The field separator: whichever of `,` `;` and tab the first line (outside quotes) uses most. +fn csv_separator(text: &str) -> char { let first = text.lines().next().unwrap_or(""); let mut quoted = false; let mut unquoted = String::new(); @@ -533,7 +534,20 @@ pub fn parse_csv(text: &str) -> Vec> { unquoted.push(c); } } - let sep = [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(','); + [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',') +} + +/// Ends the current row: kept when it has any non-empty field, otherwise dropped. +fn push_csv_row(rows: &mut Vec>, row: &mut Vec) { + if row.iter().any(|x| !x.is_empty()) { + rows.push(std::mem::take(row)); + } else { + row.clear(); + } +} + +pub fn parse_csv(text: &str) -> Vec> { + let sep = csv_separator(text); let mut rows = Vec::new(); let mut row = Vec::new(); let mut cell = String::new(); @@ -560,11 +574,7 @@ pub fn parse_csv(text: &str) -> Vec> { chars.next(); } row.push(std::mem::take(&mut cell)); - if row.iter().any(|x| !x.is_empty()) { - rows.push(std::mem::take(&mut row)); - } else { - row.clear(); - } + push_csv_row(&mut rows, &mut row); } else { cell.push(c); } @@ -573,9 +583,7 @@ pub fn parse_csv(text: &str) -> Vec> { } } row.push(cell); - if row.iter().any(|x| !x.is_empty()) { - rows.push(row); - } + push_csv_row(&mut rows, &mut row); rows } diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index b99c30252..752831931 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -1396,27 +1396,13 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize { !seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0)) }; let mut seen = vec![false; (w as usize) * (h as usize)]; - let mut stack: Vec<(u32, u32)> = Vec::new(); - for x in 0..w { - stack.push((x, 0)); - stack.push((x, h - 1)); - } - for y in 0..h { - stack.push((0, y)); - stack.push((w - 1, y)); - } + let mut stack = border_pixels(w, h); let mut most = stack.len(); while let Some((x, y)) = stack.pop() { if !open(rgba, &seen, x, y) { continue; } - let (mut x0, mut x1) = (x, x); - while x0 > 0 && open(rgba, &seen, x0 - 1, y) { - x0 -= 1; - } - while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) { - x1 += 1; - } + let (x0, x1) = run_span(x, y, w, |xi, yi| open(rgba, &seen, xi, yi)); for xi in x0..=x1 { if let Some(s) = seen.get_mut(at(xi, y)) { *s = true; @@ -1426,23 +1412,54 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize { } } for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() { - let mut xi = x0; - while xi <= x1 { - if open(rgba, &seen, xi, ny) { - stack.push((xi, ny)); - while xi <= x1 && open(rgba, &seen, xi, ny) { - xi += 1; - } - } else { - xi += 1; - } - } + push_open_runs(&mut stack, x0, x1, ny, |xi, yi| open(rgba, &seen, xi, yi)); } most = most.max(stack.len()); } most } +/// Every pixel on the image's edge, the flood-fill seeds. +fn border_pixels(w: u32, h: u32) -> Vec<(u32, u32)> { + let mut stack = Vec::new(); + for x in 0..w { + stack.push((x, 0)); + stack.push((x, h - 1)); + } + for y in 0..h { + stack.push((0, y)); + stack.push((w - 1, y)); + } + stack +} + +/// The horizontal run of open pixels around `(x, y)`, both ends included. +fn run_span(x: u32, y: u32, w: u32, open: impl Fn(u32, u32) -> bool) -> (u32, u32) { + let (mut x0, mut x1) = (x, x); + while x0 > 0 && open(x0 - 1, y) { + x0 -= 1; + } + while x1 + 1 < w && open(x1 + 1, y) { + x1 += 1; + } + (x0, x1) +} + +/// Queues the first pixel of each open run in row `ny` between `x0` and `x1`. +fn push_open_runs(stack: &mut Vec<(u32, u32)>, x0: u32, x1: u32, ny: u32, open: impl Fn(u32, u32) -> bool) { + let mut xi = x0; + while xi <= x1 { + if open(xi, ny) { + stack.push((xi, ny)); + while xi <= x1 && open(xi, ny) { + xi += 1; + } + } else { + xi += 1; + } + } +} + fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result { let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?; let mut out = canvas(w, h, color)?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index ee7ebaafa..3ec800674 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -916,6 +916,24 @@ fn backspace(s: &mut Session, v: &Value) -> CmdResult { /// numbering while keeping the text position, takes a List Paragraph out of the list, outdents, /// joins the previous paragraph or moves into a preceding table; the command wrapper restores a /// content-control placeholder. +/// The first Backspace at a numbered paragraph's start removes the number but keeps the item's +/// indent (Word): the text stays where it was, so the indent becomes the paragraph's own. +fn remove_number(s: &mut Session, f: &Pos, n: NumRef) -> CmdResult { + let text_at = s.doc.numbering.level(n.num, n.level).map(|l| l.indent); + let p = s.doc.para_mut(f.story, &f.path)?; + if p.props.style.as_deref() == Some("ListParagraph") { + if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) { + p.props.indent_left = text_at; + } + if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) { + p.props.indent_first = Some(0.0); + } + } + p.props.numbering = Some(NumRef { num: 0, level: 0 }); + p.touch(); + sel_result(s) +} + fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { s.goal_x = None; s.pending = None; @@ -949,22 +967,8 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { return sel_result(s); } // At the start of a paragraph. - if para.props.numbering.is_some_and(|n| n.num != 0) { - // First Backspace removes the number but keeps the item's indent (Word): the text stays - // where it was, so the indent becomes the paragraph's own (`num: 0` alone drops it). - let text_at = para.props.numbering.and_then(|n| s.doc.numbering.level(n.num, n.level)).map(|l| l.indent); - let p = s.doc.para_mut(f.story, &f.path)?; - if p.props.style.as_deref() == Some("ListParagraph") { - if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) { - p.props.indent_left = text_at; - } - if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) { - p.props.indent_first = Some(0.0); - } - } - p.props.numbering = Some(NumRef { num: 0, level: 0 }); - p.touch(); - return sel_result(s); + if let Some(n) = para.props.numbering.filter(|n| n.num != 0) { + return remove_number(s, &f, n); } if para.props.style.as_deref() == Some("ListParagraph") { // The second one takes it out of the list's indent instead of joining paragraphs. diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 695501aea..ea9386b9c 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -1554,12 +1554,13 @@ const NOISE_MATCH_LEN: usize = 1; /// stripped before running its table, so it's left untouched regardless of length; only a short /// match produced by the table's own backtracking, which can only ever be interior, is a /// candidate. +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Keep, + Fold, +} + fn collapse_noise_matches(ops: &[Op]) -> Vec { - #[derive(Clone, Copy, PartialEq)] - enum Kind { - Keep, - Fold, - } let mut runs: Vec<(Op, usize, Kind)> = Vec::new(); let mut k = 0; while k < ops.len() { @@ -1586,12 +1587,7 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec { } let region = &runs[start..i]; if region.iter().any(|(_, _, kind)| *kind == Kind::Fold) { - // Flatten the region: every original character first (in its own order), then every - // revised character (in its own order) — one clean delete, one clean insert. - let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum(); - let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum(); - out.extend(std::iter::repeat_n(Op::Del, dels)); - out.extend(std::iter::repeat_n(Op::Ins, inss)); + flatten_region(&mut out, region); } else { for (op, len, _) in region { out.extend(std::iter::repeat_n(*op, *len)); @@ -1601,6 +1597,15 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec { out } +/// Flattens a region with a folded run: every original character first (in its own order), then +/// every revised character (in its own order) — one clean delete, one clean insert. +fn flatten_region(out: &mut Vec, region: &[(Op, usize, Kind)]) { + let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum(); + let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum(); + out.extend(std::iter::repeat_n(Op::Del, dels)); + out.extend(std::iter::repeat_n(Op::Ins, inss)); +} + /// Words with the spaces after them ("The ", "cat "), or single characters. fn tokens(s: &str, characters: bool) -> Vec { if characters {