diff --git a/apps/wordcraft-web/index.html b/apps/wordcraft-web/index.html index ca6ad4693..b7528461a 100644 --- a/apps/wordcraft-web/index.html +++ b/apps/wordcraft-web/index.html @@ -2,7 +2,7 @@ - + WordCraft diff --git a/crates/doc/src/eq.rs b/crates/doc/src/eq.rs index 732532f04..6280896c7 100644 --- a/crates/doc/src/eq.rs +++ b/crates/doc/src/eq.rs @@ -117,7 +117,7 @@ fn arg_text(s: &str) -> String { } // A switch: its letters, then an optional (signed) number. Some(_) => { - while it.next_if(|n| n.is_ascii_alphabetic()).is_some() {} + while it.next_if(char::is_ascii_alphabetic).is_some() {} while it.next_if(|n| *n == ' ').is_some() {} while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {} } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d4c548a2c..91e33a993 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -229,7 +229,7 @@ impl Path { impl std::fmt::Display for Path { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let s: Vec = self.0.iter().map(|i| i.to_string()).collect(); + let s: Vec = self.0.iter().map(ToString::to_string).collect(); write!(f, "{}", s.join("/")) } } @@ -801,34 +801,36 @@ impl Document { d } - /// Every story must hold at least one paragraph, and every cell too. - pub fn ensure_nonempty(&mut self) { - fn fix(bl: &mut Blocks) { - if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { - bl.push(para_block(Paragraph::new())); - } - for b in bl.iter_mut() { - if let Block::Table(t) = &**b { - let needs = t - .rows - .iter() - .any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_))))); - if needs && let Block::Table(t) = Arc::make_mut(b) { - for r in &mut t.rows { - for c in &mut r.cells { - fix_cell(&mut c.blocks); - } + fn fix_cell_blocks(bl: &mut Blocks) { + if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { + bl.push(para_block(Paragraph::new())); + } + } + + fn fix_story_blocks(bl: &mut Blocks) { + if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { + bl.push(para_block(Paragraph::new())); + } + for b in bl.iter_mut() { + if let Block::Table(t) = &**b { + let needs = t + .rows + .iter() + .any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_))))); + if needs && let Block::Table(t) = Arc::make_mut(b) { + for r in &mut t.rows { + for c in &mut r.cells { + Self::fix_cell_blocks(&mut c.blocks); } } } } } - fn fix_cell(bl: &mut Blocks) { - if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) { - bl.push(para_block(Paragraph::new())); - } - } - fix(&mut self.body); + } + + /// Every story must hold at least one paragraph, and every cell too. + pub fn ensure_nonempty(&mut self) { + Self::fix_story_blocks(&mut self.body); for p in self.parts.values_mut() { if p.blocks.is_empty() { p.blocks.push(para_block(Paragraph::new())); diff --git a/crates/doc/src/math_latex.rs b/crates/doc/src/math_latex.rs index 48c60b781..932aab182 100644 --- a/crates/doc/src/math_latex.rs +++ b/crates/doc/src/math_latex.rs @@ -47,7 +47,7 @@ fn tokenize(s: &str) -> Vec { match c { '\\' => { let start = i; - while cs.get(i).is_some_and(|c| c.is_ascii_alphabetic()) { + while cs.get(i).is_some_and(char::is_ascii_alphabetic) { i += 1; } if i == start { diff --git a/crates/doc/src/math_linear.rs b/crates/doc/src/math_linear.rs index 5c3318732..1820c248d 100644 --- a/crates/doc/src/math_linear.rs +++ b/crates/doc/src/math_linear.rs @@ -339,7 +339,7 @@ impl P { /// A prefix structure after its character. fn prefix(&mut self, c: char, stops: &[char]) -> MNode { match c { - '√' | '∛' | '∜' => { + '√'..='∜' => { let mut deg = match c { '∛' => vec![text('3')], '∜' => vec![text('4')], @@ -410,7 +410,7 @@ impl P { fn word(&mut self, stops: &[char]) -> Arg { let start = self.i; let mut end = start; - while self.c.get(end).is_some_and(|c| c.is_ascii_alphabetic()) { + while self.c.get(end).is_some_and(char::is_ascii_alphabetic) { end += 1; } let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect(); @@ -628,7 +628,7 @@ impl P { // Word (#1609): the whole run of letters and digits up to a space or operator, so // `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`. let start = self.i; - while self.peek().is_some_and(|k| k.is_alphanumeric()) { + while self.peek().is_some_and(char::is_alphanumeric) { self.i += 1; } let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect(); diff --git a/crates/docx/src/units.rs b/crates/docx/src/units.rs index 7cbbfad8d..69b52eef6 100644 --- a/crates/docx/src/units.rs +++ b/crates/docx/src/units.rs @@ -31,7 +31,7 @@ pub fn measure(s: &str, per_pt: f32) -> Option { Some(&s) if s == b'+' || s == b'-' => &rest[1..], _ => rest, }; - if after_sign.first().is_some_and(|c| c.is_ascii_digit()) { + if after_sign.first().is_some_and(u8::is_ascii_digit) { in_exp = true; continue; } diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index 7f3ddc450..292d2bd54 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -29,6 +29,43 @@ enum Piece<'a> { } impl Writer<'_> { + fn update_toc_before_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: bool, w: &mut W) { + if let Some((start, end)) = toc { + if i == start { + w.raw(TOC_SDT_OPEN); + self.toc_hold_end = true; + } + self.toc_begin_here = toc_field_open && i == start + 1; + self.toc_end_here = toc_field_open && i == end; + } + } + + fn update_toc_after_block(&mut self, i: usize, toc: Option<(usize, usize)>, toc_field_open: &mut bool, w: &mut W) { + if let Some((start, end)) = toc { + if i == start { + // `field` holds the heading's TOC field back for the first entry. + self.toc_hold_end = false; + *toc_field_open = self.toc_field.is_some(); + } + if i == end { + w.raw(""); + } + } + } + + fn write_trailing_para(&mut self, w: &mut W, depth: usize) { + // A header with no paragraph of its own before: the watermark goes in this one. + if depth == 0 + && let Some(run) = self.pending_watermark.take() + { + w.raw(""); + w.raw(&run); + w.raw(""); + } else { + w.raw(""); + } + } + /// Write a block list. `top` = body level (sections allowed). pub fn blocks(&mut self, w: &mut W, bl: &Blocks, rels: &mut PartRels, top: bool, depth: usize) { if depth > MAX_DEPTH { @@ -43,14 +80,7 @@ impl Writer<'_> { for c in plan.opens.get(i).into_iter().flatten() { super::sdt::open(w, c); } - if let Some((start, end)) = toc { - if i == start { - w.raw(TOC_SDT_OPEN); - self.toc_hold_end = true; - } - self.toc_begin_here = toc_field_open && i == start + 1; - self.toc_end_here = toc_field_open && i == end; - } + self.update_toc_before_block(i, toc, toc_field_open, w); match &**b { Block::Para(p) => { self.inline_controls = plan.inline_in(i); @@ -58,16 +88,7 @@ impl Writer<'_> { } Block::Table(t) => self.table(w, t, rels, depth), } - if let Some((start, end)) = toc { - if i == start { - // `field` holds the heading's TOC field back for the first entry. - self.toc_hold_end = false; - toc_field_open = self.toc_field.is_some(); - } - if i == end { - w.raw(""); - } - } + self.update_toc_after_block(i, toc, &mut toc_field_open, w); for _ in 0..plan.closes.get(i).copied().unwrap_or(0) { super::sdt::close(w); } @@ -76,16 +97,7 @@ impl Writer<'_> { self.move_ranges(w, &[]); } if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) { - // A header with no paragraph of its own before: the watermark goes in this one. - if depth == 0 - && let Some(run) = self.pending_watermark.take() - { - w.raw(""); - w.raw(&run); - w.raw(""); - } else { - w.raw(""); - } + self.write_trailing_para(w, depth); } } @@ -158,6 +170,23 @@ impl Writer<'_> { self.para_inner(w, p, rels, top, depth, false); } + fn write_mark_rpr(&self, w: &mut W, mark: &CharProps, mark_revs: &[(u32, &str)]) { + w.open("w:rPr", &[]); + for (idx, tag) in mark_revs { + let (id, author, date) = self.rev_attrs(*idx); + if date.is_empty() { + w.empty(tag, &[("w:id", &id), ("w:author", &author)]); + } else { + w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]); + } + } + rpr_inner(w, mark); + if let Some(ch) = mark.fmt_change.as_deref() { + self.rpr_change(w, ch); + } + w.close("w:rPr"); + } + fn para_inner(&mut self, w: &mut W, p: &Paragraph, rels: &mut PartRels, top: bool, depth: usize, framed: bool) { let para_id = self.take_para_id(p); match ¶_id { @@ -185,20 +214,7 @@ impl Writer<'_> { }); ppr_inner(w, &pp, framed, num_change.as_ref()); if has_mark { - w.open("w:rPr", &[]); - for (idx, tag) in mark_revs { - let (id, author, date) = self.rev_attrs(idx); - if date.is_empty() { - w.empty(tag, &[("w:id", &id), ("w:author", &author)]); - } else { - w.empty(tag, &[("w:id", &id), ("w:author", &author), ("w:date", &date)]); - } - } - rpr_inner(w, &p.mark); - if let Some(ch) = p.mark.fmt_change.as_deref() { - self.rpr_change(w, ch); - } - w.close("w:rPr"); + self.write_mark_rpr(w, &p.mark, &mark_revs); } if let Some(s) = section { self.sectpr(w, s, rels); diff --git a/crates/engine/src/cmd/citations.rs b/crates/engine/src/cmd/citations.rs index 6c2cadd41..b3950cf56 100644 --- a/crates/engine/src/cmd/citations.rs +++ b/crates/engine/src/cmd/citations.rs @@ -1090,7 +1090,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> { out.push((l, false)); } } - let list = pages.iter().map(|p| p.to_string()).collect::>().join(", "); + let list = pages.iter().map(ToString::to_string).collect::>().join(", "); out.push(if kind.starts_with("INDEX") { (format!("{e}, {list}"), true) } else { (format!("{e}\t{list}"), false) }); } out diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 6ad46d76d..10ff46aef 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -579,27 +579,8 @@ fn info(s: &mut Session, v: &Value) -> CmdResult { Ok(describe(s, &r)) } -fn properties(s: &mut Session, v: &Value) -> CmdResult { - let r = target(s, v)?; - let mut c = r.control.clone(); - let mut content: Option = None; - if let Some(t) = short(v, "title") { - c.title = t; - } - if let Some(t) = short(v, "tag") { - c.tag = t; - } - let (no_delete, no_edit) = (p::bool(v, "lockDelete").unwrap_or(!c.lock.can_delete()), p::bool(v, "lockEdit").unwrap_or(!c.lock.can_edit())); - c.lock = ControlLock::from_flags(no_delete, no_edit); - if let Some(t) = p::bool(v, "temporary") { - c.temporary = t; - } - if let Some(t) = short(v, "placeholder").filter(|t| !t.trim().is_empty()) { - if c.showing_placeholder && t != c.placeholder_shown() { - content = Some(t.clone()); - } - c.placeholder_text = t; - } +fn apply_kind_properties(c: &mut StructuredControl, v: &Value) -> Option { + let mut content = None; match &mut c.kind { ControlKind::Text { multi_line } => { if let Some(m) = p::bool(v, "multiLine") { @@ -633,6 +614,33 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult { } _ => {} } + content +} + +fn properties(s: &mut Session, v: &Value) -> CmdResult { + let r = target(s, v)?; + let mut c = r.control.clone(); + let mut content: Option = None; + if let Some(t) = short(v, "title") { + c.title = t; + } + if let Some(t) = short(v, "tag") { + c.tag = t; + } + let (no_delete, no_edit) = (p::bool(v, "lockDelete").unwrap_or(!c.lock.can_delete()), p::bool(v, "lockEdit").unwrap_or(!c.lock.can_edit())); + c.lock = ControlLock::from_flags(no_delete, no_edit); + if let Some(t) = p::bool(v, "temporary") { + c.temporary = t; + } + if let Some(t) = short(v, "placeholder").filter(|t| !t.trim().is_empty()) { + if c.showing_placeholder && t != c.placeholder_shown() { + content = Some(t.clone()); + } + c.placeholder_text = t; + } + if let Some(kc) = apply_kind_properties(&mut c, v) { + content = Some(kc); + } if let Some(text) = content { let base = content_base(s, &r); let props = if c.showing_placeholder { placeholder_props(s, &base) } else { base }; diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 0cf413dd9..8c9b4a2d7 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -601,6 +601,48 @@ fn convert(src: &str, mode: &str, para_start: bool) -> String { convert_chars(src, mode, para_start, &turkic).concat() } +fn convert_title_char( + c: char, + i: usize, + up: &mut bool, + upper: impl Fn(char) -> String, + lower: impl Fn(usize, char) -> String, +) -> String { + let n = if *up && c.is_alphabetic() { + *up = false; + upper(c) + } else { + lower(i, c) + }; + if c.is_whitespace() { + *up = true; + } + n +} + +fn convert_sentence_char( + c: char, + i: usize, + up: &mut bool, + chars: &[char], + upper: impl Fn(char) -> String, + lower: impl Fn(usize, char) -> String, +) -> String { + let n = if *up && c.is_alphabetic() { + *up = false; + upper(c) + } else { + lower(i, c) + }; + let is_decimal = c == '.' + && i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(char::is_ascii_digit) + && chars.get(i + 1).is_some_and(char::is_ascii_digit); + if matches!(c, '!' | '?') || (c == '.' && !is_decimal) { + *up = true; + } + n +} + /// The case-converted form of each character of `src`, in order (one entry per char, so a /// change of length such as ß → SS or İ → i̇ can be applied run by run). `turkic` is one flag /// per char: true when that character's run is Turkish, Azerbaijani or Crimean Tatar. @@ -634,34 +676,8 @@ fn convert_chars(src: &str, mode: &str, para_start: bool, turkic: &[bool]) -> Ve upper(c) } } - "title" => { - let n = if up && c.is_alphabetic() { - up = false; - upper(c) - } else { - lower(i, c) - }; - if c.is_whitespace() { - up = true; - } - n - } - _ => { - // Sentence case. - let n = if up && c.is_alphabetic() { - up = false; - upper(c) - } else { - lower(i, c) - }; - let is_decimal = c == '.' - && i.checked_sub(1).and_then(|j| chars.get(j)).is_some_and(|p| p.is_ascii_digit()) - && chars.get(i + 1).is_some_and(|n| n.is_ascii_digit()); - if matches!(c, '!' | '?') || (c == '.' && !is_decimal) { - up = true; - } - n - } + "title" => convert_title_char(c, i, &mut up, &upper, &lower), + _ => convert_sentence_char(c, i, &mut up, &chars, &upper, &lower), }; out.push(new); } @@ -756,27 +772,6 @@ pub fn state(s: &Session) -> Value { }) } -#[cfg(test)] -mod tests { - use super::convert; - - #[test] - fn case_conversions() { - assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again"); - assert_eq!(convert("hello WORLD", "title", true), "Hello World"); - assert_eq!(convert("Hello", "toggle", true), "hELLO"); - assert_eq!(convert("straße", "upper", true), "STRASSE"); - assert_eq!(convert("hello world", "title", false), "Hello World"); - assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος"); - } - - #[test] - fn sentence_case_ignores_decimal_points() { - // #3485: A period between two digits is a decimal point, not a sentence boundary. - assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears."); - assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence."); - } -} /// What Enclose Characters works on: the selected text (one full-width or up to two half-width /// characters), or enclosed characters already there (selected, or beside the caret). @@ -884,3 +879,25 @@ fn enclose(s: &mut Session, v: &Value) -> CmdResult { s.sel = crate::Selection { anchor: at, focus: end }; sel_result(s) } + +#[cfg(test)] +mod tests { + use super::convert; + + #[test] + fn case_conversions() { + assert_eq!(convert("hello world. again", "sentence", true), "Hello world. Again"); + assert_eq!(convert("hello WORLD", "title", true), "Hello World"); + assert_eq!(convert("Hello", "toggle", true), "hELLO"); + assert_eq!(convert("straße", "upper", true), "STRASSE"); + assert_eq!(convert("hello world", "title", false), "Hello World"); + assert_eq!(convert("ΣΟΦΟΣ ΟΔΟΣ", "lower", true), "σοφος οδος"); + } + + #[test] + fn sentence_case_ignores_decimal_points() { + // #3485: A period between two digits is a decimal point, not a sentence boundary. + assert_eq!(convert("one 3.14 apples and two 2.5 pears.", "sentence", true), "One 3.14 apples and two 2.5 pears."); + assert_eq!(convert("pi is 3.14. next sentence.", "sentence", true), "Pi is 3.14. Next sentence."); + } +} diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2493d07f7..1a88ef96a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -979,6 +979,73 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } +/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph +/// and in one that starts with a space. +fn drop_cap_off(s: &Session) -> Option<&'static str> { + if s.sel.focus.path.cell().is_some() { + return Some("drop caps can't go in a table"); + } + let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next()); + match first { + None => Some("the paragraph is empty"), + Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"), + Some(_) => None, + } +} + +/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in +/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's). +/// None takes all of it away, the letter's font with it. +fn drop_cap(s: &mut Session, v: &Value) -> CmdResult { + let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite()); + let none = p::str(v, "position") == Some("none") || lines == Some(0.0); + let margin = p::str(v, "position") == Some("margin"); + let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8; + let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE)); + // A family name, not a paragraph of text. + let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::()); + super::para::fmt(s, &|p| { + p.drop_cap = (!none).then_some(lines); + p.drop_cap_margin = (!none && margin).then_some(true); + p.drop_cap_distance = (!none && distance > 0.0).then_some(distance); + p.drop_cap_font = font.clone().filter(|_| !none); + }) +} + +/// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values. +#[derive(Clone, Debug, PartialEq)] +pub struct DropCapState { + /// `none`, `dropped` or `margin`. + pub position: &'static str, + /// Lines to drop (3 when there is no drop cap). + pub lines: u8, + /// Distance from the text, points. + pub distance: f32, + /// The letter's own font, if it has one. + pub font: Option, + /// The font of the paragraph's first character. + pub text_font: String, +} + +pub fn drop_cap_state(s: &Session) -> DropCapState { + let para = s.doc.para_at(&s.sel.focus); + let props = para.map(|p| p.props.clone()).unwrap_or_default(); + let rp = s.doc.styles.resolve_para(&props); + let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default(); + let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font; + DropCapState { + position: match (rp.drop_cap, rp.drop_cap_margin) { + (0, _) => "none", + (_, true) => "margin", + _ => "dropped", + }, + lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap }, + distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance }, + font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0), + text_font, + } +} + #[cfg(test)] mod tests { use super::*; @@ -1309,69 +1376,3 @@ mod tests { } } -/// Why Insert › Drop Cap is off at the caret, as in Word: in a table cell, in an empty paragraph -/// and in one that starts with a space. -fn drop_cap_off(s: &Session) -> Option<&'static str> { - if s.sel.focus.path.cell().is_some() { - return Some("drop caps can't go in a table"); - } - let first = s.doc.para_at(&s.sel.focus).and_then(|p| p.text.chars().next()); - match first { - None => Some("the paragraph is empty"), - Some(c) if c.is_whitespace() => Some("the paragraph starts with a space"), - Some(_) => None, - } -} - -/// Insert › Drop Cap and Drop Cap Options: none, or the letter dropped into the text or hung in -/// the margin, `lines` tall, `distance` points from the text, in `font` (unset: the text's). -/// None takes all of it away, the letter's font with it. -fn drop_cap(s: &mut Session, v: &Value) -> CmdResult { - let lines = v.get("lines").and_then(Value::as_f64).filter(|l| l.is_finite()); - let none = p::str(v, "position") == Some("none") || lines == Some(0.0); - let margin = p::str(v, "position") == Some("margin"); - let lines = lines.map_or(3.0, |l| l.round().clamp(1.0, 10.0)) as u8; - let distance = p::f32(v, "distance").map_or(0.0, |d| d.clamp(0.0, wordcraft_doc::resolve::MAX_DROP_CAP_DISTANCE)); - // A family name, not a paragraph of text. - let font = p::str(v, "font").map(str::trim).filter(|f| !f.is_empty()).map(|f| f.chars().take(64).collect::()); - super::para::fmt(s, &|p| { - p.drop_cap = (!none).then_some(lines); - p.drop_cap_margin = (!none && margin).then_some(true); - p.drop_cap_distance = (!none && distance > 0.0).then_some(distance); - p.drop_cap_font = font.clone().filter(|_| !none); - }) -} - -/// Insert › Drop Cap at the caret: the menu's highlight and the Drop Cap dialog's values. -#[derive(Clone, Debug, PartialEq)] -pub struct DropCapState { - /// `none`, `dropped` or `margin`. - pub position: &'static str, - /// Lines to drop (3 when there is no drop cap). - pub lines: u8, - /// Distance from the text, points. - pub distance: f32, - /// The letter's own font, if it has one. - pub font: Option, - /// The font of the paragraph's first character. - pub text_font: String, -} - -pub fn drop_cap_state(s: &Session) -> DropCapState { - let para = s.doc.para_at(&s.sel.focus); - let props = para.map(|p| p.props.clone()).unwrap_or_default(); - let rp = s.doc.styles.resolve_para(&props); - let first = para.map(|p| p.props_at(0).clone()).unwrap_or_default(); - let text_font = s.doc.styles.resolve_char_in(props.style.as_deref(), None, &first).font; - DropCapState { - position: match (rp.drop_cap, rp.drop_cap_margin) { - (0, _) => "none", - (_, true) => "margin", - _ => "dropped", - }, - lines: if rp.drop_cap == 0 { 3 } else { rp.drop_cap }, - distance: if rp.drop_cap == 0 { 0.0 } else { rp.drop_cap_distance }, - font: rp.drop_cap_font.filter(|_| rp.drop_cap > 0), - text_font, - } -} diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 2d6e807d0..ac5d8719e 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -837,7 +837,7 @@ fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) { let inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or(""); let field = mergefield_name(inner).unwrap_or_default(); let rest = t.split_once('}').map(|(_, r)| r).unwrap_or(""); - let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '=' | '<' | '>')).collect(); + let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect(); let mut quoted = rest.split('"').skip(1).step_by(2); let cmp = quoted.next().unwrap_or(""); let v = value(s, row, &field); diff --git a/crates/engine/src/cmd/table_formula.rs b/crates/engine/src/cmd/table_formula.rs index 4550b4ece..378a87fa6 100644 --- a/crates/engine/src/cmd/table_formula.rs +++ b/crates/engine/src/cmd/table_formula.rs @@ -228,6 +228,50 @@ enum Tok { Cmp(&'static str), } +fn parse_num_token(chars: &mut std::iter::Peekable>) -> Result { + let mut lit = String::new(); + while let Some(&d) = chars.peek() { + if d.is_ascii_digit() || d == '.' { + lit.push(d); + chars.next(); + } else { + break; + } + } + let x = lit.parse::().map_err(|_| syntax(format!("`{lit}` isn't a number")))?; + Ok(Tok::Num(x)) +} + +fn parse_name_token(chars: &mut std::iter::Peekable>) -> Tok { + let mut name = String::new(); + while let Some(&d) = chars.peek() { + if d.is_ascii_alphanumeric() { + name.push(d.to_ascii_uppercase()); + chars.next(); + } else { + break; + } + } + Tok::Name(name) +} + +fn parse_cmp_token(ch: char, chars: &mut std::iter::Peekable>) -> Tok { + chars.next(); + let next = chars.peek().copied(); + let (op, longer) = match (ch, next) { + ('<', Some('=')) => ("<=", true), + ('<', Some('>')) => ("<>", true), + ('>', Some('=')) => (">=", true), + ('<', _) => ("<", false), + ('>', _) => (">", false), + _ => ("=", false), + }; + if longer { + chars.next(); + } + Tok::Cmp(op) +} + fn tokenize(s: &str) -> Result, CmdError> { let mut out = Vec::new(); let mut chars = s.chars().peekable(); @@ -235,46 +279,14 @@ fn tokenize(s: &str) -> Result, CmdError> { if ch.is_whitespace() { chars.next(); } else if ch.is_ascii_digit() || ch == '.' { - let mut lit = String::new(); - while let Some(&d) = chars.peek() { - if d.is_ascii_digit() || d == '.' { - lit.push(d); - chars.next(); - } else { - break; - } - } - let x = lit.parse::().map_err(|_| syntax(format!("`{lit}` isn't a number")))?; - out.push(Tok::Num(x)); + out.push(parse_num_token(&mut chars)?); } else if ch.is_ascii_alphabetic() { - let mut name = String::new(); - while let Some(&d) = chars.peek() { - if d.is_ascii_alphanumeric() { - name.push(d.to_ascii_uppercase()); - chars.next(); - } else { - break; - } - } - out.push(Tok::Name(name)); + out.push(parse_name_token(&mut chars)); } else if matches!(ch, '+' | '-' | '*' | '/' | '^' | '(' | ')' | ',' | ':') { out.push(Tok::Op(ch)); chars.next(); - } else if matches!(ch, '<' | '>' | '=') { - chars.next(); - let next = chars.peek().copied(); - let (op, longer) = match (ch, next) { - ('<', Some('=')) => ("<=", true), - ('<', Some('>')) => ("<>", true), - ('>', Some('=')) => (">=", true), - ('<', _) => ("<", false), - ('>', _) => (">", false), - _ => ("=", false), - }; - if longer { - chars.next(); - } - out.push(Tok::Cmp(op)); + } else if matches!(ch, '<'..='>') { + out.push(parse_cmp_token(ch, &mut chars)); } else { return Err(syntax(format!("`{ch}` isn't part of a formula"))); } @@ -468,9 +480,9 @@ impl Parser { /// `B4` → (row 3, column 1): the row counts from 1 and the column from A. fn cell_ref(name: &str) -> Option<(usize, usize)> { - let letters: String = name.chars().take_while(|c| c.is_ascii_alphabetic()).collect(); + let letters: String = name.chars().take_while(char::is_ascii_alphabetic).collect(); let digits = name.get(letters.len()..)?; - if letters.is_empty() || digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) { + if letters.is_empty() || digits.is_empty() || !digits.chars().all(char::is_ascii_digit) { return None; } let col = letters.bytes().try_fold(0usize, |acc, b| acc.checked_mul(26)?.checked_add((b.to_ascii_uppercase() - b'A') as usize + 1))? - 1; diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 3403cae1f..35d2c1ea5 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -196,7 +196,7 @@ impl AutoCorrectPrefs { .filter(|(a, _)| self.fractions || !FRACTIONS.contains(a)) .find(|(a, _)| { *a == word - || (a.chars().all(|c| c.is_lowercase()) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase)) + || (a.chars().all(char::is_lowercase) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase)) }) .map(|(a, b)| { if *a != word && b.chars().next().is_some_and(char::is_lowercase) { @@ -249,7 +249,7 @@ impl AutoCorrectPrefs { let mut v: Vec = NOT_SENTENCE_END .iter() .filter(|a| !self.exceptions_removed.iter().any(|r| r.eq_ignore_ascii_case(a))) - .map(|a| a.to_string()) + .map(ToString::to_string) .chain(self.exceptions.iter().map(|a| a.to_lowercase())) .collect(); v.sort(); diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fb9999c74..071cb48f5 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1519,7 +1519,7 @@ impl Session { let mut result = match result { Ok(r) => r, Err(p) => { - let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); + let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); Err(CmdError::Failed(format!("{id} panicked: {msg}"))) } }; diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 03ebb9a0f..f5564359d 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -99,7 +99,7 @@ pub struct FaceRef(&'static FontFace); impl FaceRef { /// The handle for a loaded face (memoised per face). pub fn of(face: &Arc) -> FaceRef { - static LEAKED: RwLock>> = RwLock::new(Vec::new()); + static LEAKED: RwLock>> = RwLock::new(Vec::new()); let id = face.id as usize; if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) { return FaceRef(f); @@ -933,7 +933,7 @@ pub fn base_style(style: &str) -> &str { } fn norm(s: &str) -> String { - s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() + s.chars().filter(char::is_alphanumeric).flat_map(char::to_lowercase).collect() } /// Weight implied by a style name. diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 8ec9f31cb..fa6ec5799 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -366,9 +366,8 @@ impl Lexer<'_> { self.pos += after_tag; self.skip_past(">"); return; - } else { - self.pos += k + 2; } + self.pos += k + 2; } } @@ -408,7 +407,7 @@ impl Lexer<'_> { self.skip_past(">"); return Some(Tok::End(name)); } - if !b.get(1).is_some_and(|c| c.is_ascii_alphabetic()) { + if !b.get(1).is_some_and(u8::is_ascii_alphabetic) { self.pos += 1; return Some(Tok::Text("<".into())); } @@ -419,7 +418,7 @@ impl Lexer<'_> { let mut attrs = Vec::new(); let mut self_close = false; loop { - while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) { + while b.get(i).is_some_and(u8::is_ascii_whitespace) { i += 1; } match b.get(i) { @@ -448,13 +447,13 @@ impl Lexer<'_> { i += 1; continue; } - while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) { + while b.get(i).is_some_and(u8::is_ascii_whitespace) { i += 1; } let mut val = String::new(); if b.get(i) == Some(&b'=') { i += 1; - while b.get(i).is_some_and(|c| c.is_ascii_whitespace()) { + while b.get(i).is_some_and(u8::is_ascii_whitespace) { i += 1; } match b.get(i) { diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index bc0dd3133..ecb171c08 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -778,13 +778,13 @@ fn linear_to_latex_at(linear: &str, depth: usize) -> String { o.push('\\'); o.push_str(&name); i += 1 + name.len(); - if chars.get(i).is_some_and(|n| n.is_ascii_alphabetic()) { + if chars.get(i).is_some_and(char::is_ascii_alphabetic) { o.push(' '); } } continue; } - '%' | '#' | '&' | '$' => { + '#'..='&' => { o.push('\\'); o.push(c); } @@ -796,7 +796,7 @@ fn linear_to_latex_at(linear: &str, depth: usize) -> String { o.push('\\'); o.push_str(name); // A control word needs a space only before a letter. - if chars.get(i + 1).is_some_and(|n| n.is_ascii_alphabetic()) { + if chars.get(i + 1).is_some_and(char::is_ascii_alphabetic) { o.push(' '); } } diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 000b18aac..bf1987701 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -268,7 +268,7 @@ fn list_marker(t: &str) -> Option { let (m, len) = if matches!(first, '-' | '+' | '*') { (Marker { ordered: false, ch: first, len: 1, start: 1 }, 1) } else { - let digits = t.chars().take_while(|c| c.is_ascii_digit()).count(); + let digits = t.chars().take_while(char::is_ascii_digit).count(); if !(1..=9).contains(&digits) { return None; } @@ -607,7 +607,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut i += 1; } // Blank lines at the end of an item belong between items. - while item.len() > 1 && item.last().is_some_and(|x| x.is_empty()) { + while item.len() > 1 && item.last().is_some_and(String::is_empty) { item.pop(); } let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start }; @@ -833,7 +833,7 @@ fn unescape(s: &str) -> String { let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { if c == '\\' - && let Some(n) = chars.peek().copied().filter(|n| n.is_ascii_punctuation()) + && let Some(n) = chars.peek().copied().filter(char::is_ascii_punctuation) { out.push(n); chars.next(); @@ -852,7 +852,7 @@ fn tag_attr(tag: &str, name: &str) -> Option { let at = from + k; let before = lower.get(..at)?.chars().next_back(); let after = lower.get(at + name.len()..)?.trim_start(); - if before.is_some_and(|c| c.is_whitespace()) && after.starts_with('=') { + if before.is_some_and(char::is_whitespace) && after.starts_with('=') { let v = after.get(1..)?.trim_start(); let q = v.chars().next()?; return Some(if q == '"' || q == '\'' { @@ -1257,7 +1257,7 @@ fn escape_line_start(s: String) -> String { || (t.starts_with('-') && t.trim_end_matches([' ', '\t']).chars().all(|c| c == '-')) || thematic_break(t) || { - let d = t.chars().take_while(|c| c.is_ascii_digit()).count(); + let d = t.chars().take_while(char::is_ascii_digit).count(); d > 0 && t.get(d..).is_some_and(|r| { r.starts_with(". ") || r.starts_with(") ") || r.starts_with(".\t") || r.starts_with(")\t") || r == "." || r == ")" diff --git a/crates/formats/src/mathml.rs b/crates/formats/src/mathml.rs index 43232e995..af362c9fe 100644 --- a/crates/formats/src/mathml.rs +++ b/crates/formats/src/mathml.rs @@ -88,10 +88,10 @@ fn run(r: &MRun, o: &mut String, eq_arr: bool) { let cs: Vec = r.text.chars().collect(); let mut i = 0; while let Some(&c) = cs.get(i) { - if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit())) { + if c.is_ascii_digit() || (c == '.' && cs.get(i + 1).is_some_and(char::is_ascii_digit)) { let mut num = String::new(); while let Some(&d) = - cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(|n| n.is_ascii_digit())) + cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.' || **d == ',' && cs.get(i + 1).is_some_and(char::is_ascii_digit)) { num.push(d); i += 1; diff --git a/crates/formats/src/odt/page_parts.rs b/crates/formats/src/odt/page_parts.rs index 0a142590e..967be4094 100644 --- a/crates/formats/src/odt/page_parts.rs +++ b/crates/formats/src/odt/page_parts.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use wordcraft_doc::{Part, PartKind}; -use super::*; +use super::{events, get, model, Body, Ev, FBlock, Pages, SectionProps, Styles}; /// Most XML events kept for all headers and footers together; a slot past it is left out. const MAX_EVENTS: usize = 1 << 20; diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 45479cca6..eda8bb65c 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -2638,7 +2638,7 @@ fn parse_full(bytes: &[u8]) -> Result { let Some(&n) = bytes.get(i + 1) else { break }; if n.is_ascii_alphabetic() { let mut j = i + 1; - while bytes.get(j).is_some_and(|b| b.is_ascii_alphabetic()) && j - i <= 32 { + while bytes.get(j).is_some_and(u8::is_ascii_alphabetic) && j - i <= 32 { j += 1; } let word = std::str::from_utf8(bytes.get(i + 1..j).unwrap_or(b"")).unwrap_or(""); @@ -2648,7 +2648,7 @@ fn parse_full(bytes: &[u8]) -> Result { k += 1; } let d0 = k; - while bytes.get(k).is_some_and(|b| b.is_ascii_digit()) && k - d0 < 10 { + while bytes.get(k).is_some_and(u8::is_ascii_digit) && k - d0 < 10 { k += 1; } let param = if k > d0 { diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 1689b500b..49cc1259f 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -788,7 +788,7 @@ fn iso_datetime(v: &str) -> Option { let y: i64 = it.next()?.parse().ok()?; let m: u32 = it.next()?.parse().ok()?; let d_part = it.next()?; - let d_digits: String = d_part.chars().take_while(|c| c.is_ascii_digit()).collect(); + let d_digits: String = d_part.chars().take_while(char::is_ascii_digit).collect(); let d: u32 = d_digits.parse().ok()?; if !(1..=12).contains(&m) || !(1..=31).contains(&d) { return None; diff --git a/crates/proof/src/it/grammar.rs b/crates/proof/src/it/grammar.rs index 235012bdd..463029369 100644 --- a/crates/proof/src/it/grammar.rs +++ b/crates/proof/src/it/grammar.rs @@ -276,7 +276,7 @@ fn e_apostrophe(text: &str, ws: &[(usize, usize)], v: &mut Vec) { } let mut after = text.get(*b..).unwrap_or("").chars(); let Some(ap) = after.next().filter(|&c| is_apostrophe(c)) else { continue }; - let ends = after.next().is_none_or(|c| c.is_whitespace()); + let ends = after.next().is_none_or(char::is_whitespace); let quoted = text.get(..*a).and_then(|t| t.chars().next_back()).is_some_and(|c| is_apostrophe(c) || c == '‘'); if ends && !quoted { v.push(issue(*a, *b + ap.len_utf8(), "«È» si scrive con l'accento, non con l'apostrofo".into(), vec!["È".into()])); diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 0ab16529d..38f274c75 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -320,7 +320,7 @@ const COMMON: &str = "a an the and or but if of to in on at by for with from as const EXTRA_EN: &str = include_str!("../../../assets/spelling/en-extra.txt"); fn extra_words() -> &'static HashSet<&'static str> { - static E: OnceLock> = OnceLock::new(); + static E: OnceLock> = OnceLock::new(); E.get_or_init(|| EXTRA_EN.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect()) } @@ -889,7 +889,7 @@ fn grammar_en(text: &str, capital_first: bool) -> Vec { if gap == " " && (w0 == "an" || w0 == "An") && w1.chars().next().is_some_and(|c| c.is_alphabetic() && !"aeiouAEIOUhH".contains(c)) - && !w1.chars().all(|c| c.is_uppercase()) + && !w1.chars().all(char::is_uppercase) { v.push(Issue { start: *a0, diff --git a/crates/proof/src/ru/mod.rs b/crates/proof/src/ru/mod.rs index c29bbc7c5..50faac380 100644 --- a/crates/proof/src/ru/mod.rs +++ b/crates/proof/src/ru/mod.rs @@ -109,7 +109,7 @@ pub fn has_dictionary() -> bool { /// Is `c` a letter of the Cyrillic script (the only one the Russian dictionary covers)? pub(crate) fn is_cyrillic(c: char) -> bool { - matches!(c, '\u{0400}'..='\u{04FF}' | '\u{0500}'..='\u{052F}') + matches!(c, '\u{0400}'..='\u{052F}') } /// Particles joined to a word with a hyphen (`кто-то`, `где-нибудь`, `давай-ка`, `всё-таки`, diff --git a/crates/render/src/cache_tests.rs b/crates/render/src/cache_tests.rs index b34ba52c4..3282f2f0e 100644 --- a/crates/render/src/cache_tests.rs +++ b/crates/render/src/cache_tests.rs @@ -1,6 +1,13 @@ //! Decoded-media cache: hits need the same allocation, and the byte budget bounds what is kept. -use super::*; +use std::collections::HashMap; +use std::sync::Arc; + +use wordcraft_doc::Document; +use wordcraft_doc::props::Rgb; +use wordcraft_layout::display::{Draw, Stroke}; + +use super::{CACHE_BYTES, MediaCache, RenderOptions, render_items, wave_path}; #[test] fn media_cache_only_hits_the_same_allocation() { diff --git a/crates/render/src/tests_color_glyphs.rs b/crates/render/src/tests_color_glyphs.rs index c1d38a7a8..163a5af15 100644 --- a/crates/render/src/tests_color_glyphs.rs +++ b/crates/render/src/tests_color_glyphs.rs @@ -1,9 +1,12 @@ //! Colour glyphs (#1269): emoji from colour fonts (bitmap strikes, COLR) are drawn in colour. -use super::*; -use wordcraft_layout::display::page_display; +use wordcraft_doc::Document; +use wordcraft_fonts::FontDb; +use wordcraft_layout::display::{DisplayOptions, Draw, page_display}; use wordcraft_layout::{LayoutCache, LayoutOptions, layout}; +use super::{RenderOptions, Rendered, render_page}; + /// Colour emoji families the system fallback may pick. const COLOR_EMOJI: &[&str] = &["Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji"]; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0709aca18..2135b5003 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1250,7 +1250,7 @@ impl Dialog { filter: RecipientFilter::from_session(m), } } else { - let fields: Vec = NEW_LIST_FIELDS.iter().map(|f| f.to_string()).collect(); + let fields: Vec = NEW_LIST_FIELDS.iter().map(ToString::to_string).collect(); let rows = vec![vec![String::new(); fields.len()]]; Dialog::RecipientList { fields, rows, message: String::new(), filter: RecipientFilter::default() } } diff --git a/crates/ui-egui/src/dialogs_refs.rs b/crates/ui-egui/src/dialogs_refs.rs index a6f49b5bf..0dfccb4e3 100644 --- a/crates/ui-egui/src/dialogs_refs.rs +++ b/crates/ui-egui/src/dialogs_refs.rs @@ -242,7 +242,7 @@ pub struct CaptionForm { impl CaptionForm { pub fn read(app: &WordApp) -> CaptionForm { - let mut labels: Vec = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect(); + let mut labels: Vec = BUILT_IN_LABELS.iter().map(ToString::to_string).collect(); for l in &app.ui.caption_labels { if !labels.contains(l) { labels.push(l.clone()); @@ -937,7 +937,7 @@ pub struct FiguresForm { impl FiguresForm { pub fn read(app: &WordApp) -> FiguresForm { - let mut labels: Vec = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect(); + let mut labels: Vec = BUILT_IN_LABELS.iter().map(ToString::to_string).collect(); for l in app.ui.caption_labels.iter().cloned().chain(wordcraft_engine::cmd::citations::caption_labels(&app.session)) { if !labels.contains(&l) { labels.push(l); diff --git a/packaging/env.sh b/packaging/env.sh index 87f0c0395..dcf7e4e39 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -19,6 +19,7 @@ workspace_version() { /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next } in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit } ' "$ROOT/Cargo.toml" + return 0 } VERSION="${WORDCRAFT_VERSION:-$(workspace_version)}" @@ -42,6 +43,7 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + return 0 } # Copy licence and readme files that exist into a package directory. @@ -51,6 +53,7 @@ copy_docs() { if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" + return 0 } # Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so @@ -65,5 +68,7 @@ copy_font_licences() { # Portable SHA-256 of a file (prints just the hash). sha256() { - if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi + local file="$1" + if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi + return 0 } diff --git a/packaging/icons.sh b/packaging/icons.sh index 368f7c5f1..c71136d8b 100755 --- a/packaging/icons.sh +++ b/packaging/icons.sh @@ -21,7 +21,11 @@ MAC="$TMP/mac.svg" sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC" grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; } -render() { resvg -w "$2" -h "$2" "$1" "$3" /dev/null; then appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" fi -has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } +has() { local item="$1"; case " $FORMATS " in *" $item "*) return 0 ;; *) return 1 ;; esac; } # ---- .tar.gz ------------------------------------------------------------------------------------ if has tar; then @@ -119,13 +119,15 @@ if has appimage; then fi # Downloads $1 to $3 once and checks it against SHA-256 $2, the cached copy again on every run. fetch_pinned() { - if [[ ! -x "$3" ]]; then - curl --proto '=https' -fsSL -o "$3.download" "$1" - printf '%s %s\n' "$2" "$3.download" | sha256sum -c - - chmod 755 "$3.download" - mv "$3.download" "$3" + local url="$1" expected_sha="$2" target="$3" + if [[ ! -x "$target" ]]; then + curl --proto '=https' -fsSL -o "$target.download" "$url" + printf '%s %s\n' "$expected_sha" "$target.download" | sha256sum -c - + chmod 755 "$target.download" + mv "$target.download" "$target" fi - printf '%s %s\n' "$2" "$3" | sha256sum -c - + printf '%s %s\n' "$expected_sha" "$target" | sha256sum -c - + return 0 } APPDIR="$WORK/WordCraft.AppDir" cp -R "$STAGE" "$APPDIR" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index e9f59e228..6e603bfec 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -143,18 +143,20 @@ rm -f "$DMG" # Retries an hdiutil verb that attaches a device: on CI runners it fails now and then with # "Resource busy". Usage: hdiutil_retry ; five tries with growing pauses. hdiutil_retry() { + local verb="$1" local attempt for attempt in 1 2 3 4 5; do if hdiutil "$@"; then return 0 fi if [[ "$attempt" = 5 ]]; then - echo "error: hdiutil $1 failed 5 times" >&2 + echo "error: hdiutil $verb failed 5 times" >&2 return 1 fi - warn "hdiutil $1 failed (attempt $attempt of 5); retrying in $((attempt * 10))s" + warn "hdiutil $verb failed (attempt $attempt of 5); retrying in $((attempt * 10))s" sleep $((attempt * 10)) done + return 1 } # `hdiutil create -srcfolder` copies the staged files as they are. The previous # `makehybrid -hfs` + `convert` gave every file and folder in the image a com.apple.FinderInfo