chore: resolve SonarCloud maintainability issues (#4083)

* chore: resolve SonarCloud maintainability issues

* fix: restore compiling refactors, revert behaviour changes, run cargo fmt

- fontdb::norm: keep the closure for filter (char::is_alphanumeric takes
  self, not &self, so it does not fit Iterator::filter).
- story.rs: write_mark_rpr needs &mut self (rev_attrs, rpr_change).
- Revert the viewport meta change and the return 0 added to
  workspace_version/sha256, which changed behaviour or masked failures.
- cargo fmt.

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

* Import Document in odt page_parts after dropping glob re-export

* fix: apply_kind_properties binds ContentControl; cell_ref keeps char closures

* fix(engine): pass case helpers by value (clippy needless borrows)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
This commit is contained in:
Dylan M. Taylor
2026-10-24 03:54:19 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dylan Taylor
parent c524027f34
commit 47edcd2ff2
33 changed files with 364 additions and 297 deletions
+1 -1
View File
@@ -117,7 +117,7 @@ fn arg_text(s: &str) -> String {
} }
// A switch: its letters, then an optional (signed) number. // A switch: its letters, then an optional (signed) number.
Some(_) => { 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_some() {}
while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {} while it.next_if(|n| n.is_ascii_digit() || *n == '-' || *n == '.').is_some() {}
} }
+26 -24
View File
@@ -229,7 +229,7 @@ impl Path {
impl std::fmt::Display for Path { impl std::fmt::Display for Path {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s: Vec<String> = self.0.iter().map(|i| i.to_string()).collect(); let s: Vec<String> = self.0.iter().map(ToString::to_string).collect();
write!(f, "{}", s.join("/")) write!(f, "{}", s.join("/"))
} }
} }
@@ -801,34 +801,36 @@ impl Document {
d d
} }
/// Every story must hold at least one paragraph, and every cell too. fn fix_cell_blocks(bl: &mut Blocks) {
pub fn ensure_nonempty(&mut self) { if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
fn fix(bl: &mut Blocks) { bl.push(para_block(Paragraph::new()));
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() { fn fix_story_blocks(bl: &mut Blocks) {
if let Block::Table(t) = &**b { if !bl.iter().any(|b| matches!(**b, Block::Para(_))) || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
let needs = t bl.push(para_block(Paragraph::new()));
.rows }
.iter() for b in bl.iter_mut() {
.any(|r| r.cells.iter().any(|c| c.blocks.is_empty() || matches!(c.blocks.last().map(|b| &**b), Some(Block::Table(_))))); if let Block::Table(t) = &**b {
if needs && let Block::Table(t) = Arc::make_mut(b) { let needs = t
for r in &mut t.rows { .rows
for c in &mut r.cells { .iter()
fix_cell(&mut c.blocks); .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())); /// 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);
fix(&mut self.body);
for p in self.parts.values_mut() { for p in self.parts.values_mut() {
if p.blocks.is_empty() { if p.blocks.is_empty() {
p.blocks.push(para_block(Paragraph::new())); p.blocks.push(para_block(Paragraph::new()));
+1 -1
View File
@@ -47,7 +47,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
match c { match c {
'\\' => { '\\' => {
let start = i; 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; i += 1;
} }
if i == start { if i == start {
+3 -3
View File
@@ -339,7 +339,7 @@ impl P {
/// A prefix structure after its character. /// A prefix structure after its character.
fn prefix(&mut self, c: char, stops: &[char]) -> MNode { fn prefix(&mut self, c: char, stops: &[char]) -> MNode {
match c { match c {
'√' | '∛' | '∜' => { '√'..='∜' => {
let mut deg = match c { let mut deg = match c {
'∛' => vec![text('3')], '∛' => vec![text('3')],
'∜' => vec![text('4')], '∜' => vec![text('4')],
@@ -410,7 +410,7 @@ impl P {
fn word(&mut self, stops: &[char]) -> Arg { fn word(&mut self, stops: &[char]) -> Arg {
let start = self.i; let start = self.i;
let mut end = start; 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; end += 1;
} }
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect(); 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 // 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)`. // `V_dd` is V with a subscript `dd`, exactly like `V_(dd)`.
let start = self.i; 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; self.i += 1;
} }
let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect(); let t: String = self.c.get(start..self.i).unwrap_or(&[]).iter().collect();
+1 -1
View File
@@ -31,7 +31,7 @@ pub fn measure(s: &str, per_pt: f32) -> Option<f32> {
Some(&s) if s == b'+' || s == b'-' => &rest[1..], Some(&s) if s == b'+' || s == b'-' => &rest[1..],
_ => rest, _ => 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; in_exp = true;
continue; continue;
} }
+58 -42
View File
@@ -29,6 +29,43 @@ enum Piece<'a> {
} }
impl Writer<'_> { 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("</w:sdtContent></w:sdt>");
}
}
}
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:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
}
/// Write a block list. `top` = body level (sections allowed). /// 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) { pub fn blocks(&mut self, w: &mut W, bl: &Blocks, rels: &mut PartRels, top: bool, depth: usize) {
if depth > MAX_DEPTH { if depth > MAX_DEPTH {
@@ -43,14 +80,7 @@ impl Writer<'_> {
for c in plan.opens.get(i).into_iter().flatten() { for c in plan.opens.get(i).into_iter().flatten() {
super::sdt::open(w, c); super::sdt::open(w, c);
} }
if let Some((start, end)) = toc { self.update_toc_before_block(i, toc, toc_field_open, w);
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;
}
match &**b { match &**b {
Block::Para(p) => { Block::Para(p) => {
self.inline_controls = plan.inline_in(i); self.inline_controls = plan.inline_in(i);
@@ -58,16 +88,7 @@ impl Writer<'_> {
} }
Block::Table(t) => self.table(w, t, rels, depth), Block::Table(t) => self.table(w, t, rels, depth),
} }
if let Some((start, end)) = toc { self.update_toc_after_block(i, toc, &mut toc_field_open, w);
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("</w:sdtContent></w:sdt>");
}
}
for _ in 0..plan.closes.get(i).copied().unwrap_or(0) { for _ in 0..plan.closes.get(i).copied().unwrap_or(0) {
super::sdt::close(w); super::sdt::close(w);
} }
@@ -76,16 +97,7 @@ impl Writer<'_> {
self.move_ranges(w, &[]); self.move_ranges(w, &[]);
} }
if bl.last().is_none_or(|b| matches!(**b, Block::Table(_))) { 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. self.write_trailing_para(w, depth);
if depth == 0
&& let Some(run) = self.pending_watermark.take()
{
w.raw("<w:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
} }
} }
@@ -158,6 +170,23 @@ impl Writer<'_> {
self.para_inner(w, p, rels, top, depth, false); self.para_inner(w, p, rels, top, depth, false);
} }
fn write_mark_rpr(&mut 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) { 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); let para_id = self.take_para_id(p);
match &para_id { match &para_id {
@@ -185,20 +214,7 @@ impl Writer<'_> {
}); });
ppr_inner(w, &pp, framed, num_change.as_ref()); ppr_inner(w, &pp, framed, num_change.as_ref());
if has_mark { if has_mark {
w.open("w:rPr", &[]); self.write_mark_rpr(w, &p.mark, &mark_revs);
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");
} }
if let Some(s) = section { if let Some(s) = section {
self.sectpr(w, s, rels); self.sectpr(w, s, rels);
+1 -1
View File
@@ -1090,7 +1090,7 @@ fn entries_for(s: &mut Session, kind: &str) -> Vec<(String, bool)> {
out.push((l, false)); out.push((l, false));
} }
} }
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", "); let list = pages.iter().map(ToString::to_string).collect::<Vec<_>>().join(", ");
out.push(if kind.starts_with("INDEX") { (format!("{e}, {list}"), true) } else { (format!("{e}\t{list}"), false) }); out.push(if kind.starts_with("INDEX") { (format!("{e}, {list}"), true) } else { (format!("{e}\t{list}"), false) });
} }
out out
+29 -21
View File
@@ -579,27 +579,8 @@ fn info(s: &mut Session, v: &Value) -> CmdResult {
Ok(describe(s, &r)) Ok(describe(s, &r))
} }
fn properties(s: &mut Session, v: &Value) -> CmdResult { fn apply_kind_properties(c: &mut ContentControl, v: &Value) -> Option<String> {
let r = target(s, v)?; let mut content = None;
let mut c = r.control.clone();
let mut content: Option<String> = 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;
}
match &mut c.kind { match &mut c.kind {
ControlKind::Text { multi_line } => { ControlKind::Text { multi_line } => {
if let Some(m) = p::bool(v, "multiLine") { 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<String> = 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 { if let Some(text) = content {
let base = content_base(s, &r); let base = content_base(s, &r);
let props = if c.showing_placeholder { placeholder_props(s, &base) } else { base }; let props = if c.showing_placeholder { placeholder_props(s, &base) } else { base };
+60 -50
View File
@@ -601,6 +601,42 @@ fn convert(src: &str, mode: &str, para_start: bool) -> String {
convert_chars(src, mode, para_start, &turkic).concat() 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 /// 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 /// 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. /// per char: true when that character's run is Turkish, Azerbaijani or Crimean Tatar.
@@ -634,34 +670,8 @@ fn convert_chars(src: &str, mode: &str, para_start: bool, turkic: &[bool]) -> Ve
upper(c) upper(c)
} }
} }
"title" => { "title" => convert_title_char(c, i, &mut up, upper, lower),
let n = if up && c.is_alphabetic() { _ => convert_sentence_char(c, i, &mut up, &chars, upper, lower),
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
}
}; };
out.push(new); out.push(new);
} }
@@ -756,28 +766,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 /// 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). /// characters), or enclosed characters already there (selected, or beside the caret).
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -884,3 +872,25 @@ fn enclose(s: &mut Session, v: &Value) -> CmdResult {
s.sel = crate::Selection { anchor: at, focus: end }; s.sel = crate::Selection { anchor: at, focus: end };
sel_result(s) 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.");
}
}
+67 -67
View File
@@ -979,6 +979,73 @@ fn cover_page(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s) 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::<String>());
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<String>,
/// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -1308,70 +1375,3 @@ mod tests {
assert_eq!(date_picture("DATE"), None); assert_eq!(date_picture("DATE"), None);
} }
} }
/// 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::<String>());
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<String>,
/// 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,
}
}
+1 -1
View File
@@ -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 inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or("");
let field = mergefield_name(inner).unwrap_or_default(); let field = mergefield_name(inner).unwrap_or_default();
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or(""); 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 mut quoted = rest.split('"').skip(1).step_by(2);
let cmp = quoted.next().unwrap_or(""); let cmp = quoted.next().unwrap_or("");
let v = value(s, row, &field); let v = value(s, row, &field);
+49 -37
View File
@@ -228,6 +228,50 @@ enum Tok {
Cmp(&'static str), Cmp(&'static str),
} }
fn parse_num_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Result<Tok, CmdError> {
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::<f64>().map_err(|_| syntax(format!("`{lit}` isn't a number")))?;
Ok(Tok::Num(x))
}
fn parse_name_token(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> 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<std::str::Chars<'_>>) -> 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<Vec<Tok>, CmdError> { fn tokenize(s: &str) -> Result<Vec<Tok>, CmdError> {
let mut out = Vec::new(); let mut out = Vec::new();
let mut chars = s.chars().peekable(); let mut chars = s.chars().peekable();
@@ -235,46 +279,14 @@ fn tokenize(s: &str) -> Result<Vec<Tok>, CmdError> {
if ch.is_whitespace() { if ch.is_whitespace() {
chars.next(); chars.next();
} else if ch.is_ascii_digit() || ch == '.' { } else if ch.is_ascii_digit() || ch == '.' {
let mut lit = String::new(); out.push(parse_num_token(&mut chars)?);
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() || d == '.' {
lit.push(d);
chars.next();
} else {
break;
}
}
let x = lit.parse::<f64>().map_err(|_| syntax(format!("`{lit}` isn't a number")))?;
out.push(Tok::Num(x));
} else if ch.is_ascii_alphabetic() { } else if ch.is_ascii_alphabetic() {
let mut name = String::new(); out.push(parse_name_token(&mut chars));
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));
} else if matches!(ch, '+' | '-' | '*' | '/' | '^' | '(' | ')' | ',' | ':') { } else if matches!(ch, '+' | '-' | '*' | '/' | '^' | '(' | ')' | ',' | ':') {
out.push(Tok::Op(ch)); out.push(Tok::Op(ch));
chars.next(); chars.next();
} else if matches!(ch, '<' | '>' | '=') { } else if matches!(ch, '<'..='>') {
chars.next(); out.push(parse_cmp_token(ch, &mut chars));
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 { } else {
return Err(syntax(format!("`{ch}` isn't part of a formula"))); return Err(syntax(format!("`{ch}` isn't part of a formula")));
} }
@@ -468,7 +480,7 @@ impl Parser {
/// `B4` → (row 3, column 1): the row counts from 1 and the column from A. /// `B4` → (row 3, column 1): the row counts from 1 and the column from A.
fn cell_ref(name: &str) -> Option<(usize, usize)> { 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()..)?; 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(|c| c.is_ascii_digit()) {
return None; return None;
+2 -3
View File
@@ -195,8 +195,7 @@ impl AutoCorrectPrefs {
.filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a)) .filter(|(a, _)| !self.removed.iter().any(|r| r == a) && !self.entries.iter().any(|(e, _)| e == a))
.filter(|(a, _)| self.fractions || !FRACTIONS.contains(a)) .filter(|(a, _)| self.fractions || !FRACTIONS.contains(a))
.find(|(a, _)| { .find(|(a, _)| {
*a == word *a == word || (a.chars().all(char::is_lowercase) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
|| (a.chars().all(|c| c.is_lowercase()) && word.to_lowercase() == *a && word.chars().next().is_some_and(char::is_uppercase))
}) })
.map(|(a, b)| { .map(|(a, b)| {
if *a != word && b.chars().next().is_some_and(char::is_lowercase) { if *a != word && b.chars().next().is_some_and(char::is_lowercase) {
@@ -249,7 +248,7 @@ impl AutoCorrectPrefs {
let mut v: Vec<String> = NOT_SENTENCE_END let mut v: Vec<String> = NOT_SENTENCE_END
.iter() .iter()
.filter(|a| !self.exceptions_removed.iter().any(|r| r.eq_ignore_ascii_case(a))) .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())) .chain(self.exceptions.iter().map(|a| a.to_lowercase()))
.collect(); .collect();
v.sort(); v.sort();
+1 -1
View File
@@ -1519,7 +1519,7 @@ impl Session {
let mut result = match result { let mut result = match result {
Ok(r) => r, Ok(r) => r,
Err(p) => { Err(p) => {
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default(); let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
Err(CmdError::Failed(format!("{id} panicked: {msg}"))) Err(CmdError::Failed(format!("{id} panicked: {msg}")))
} }
}; };
+2 -2
View File
@@ -99,7 +99,7 @@ pub struct FaceRef(&'static FontFace);
impl FaceRef { impl FaceRef {
/// The handle for a loaded face (memoised per face). /// The handle for a loaded face (memoised per face).
pub fn of(face: &Arc<FontFace>) -> FaceRef { pub fn of(face: &Arc<FontFace>) -> FaceRef {
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new()); static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
let id = face.id as usize; let id = face.id as usize;
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) { if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
return FaceRef(f); return FaceRef(f);
@@ -933,7 +933,7 @@ pub fn base_style(style: &str) -> &str {
} }
fn norm(s: &str) -> String { fn norm(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect() s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
} }
/// Weight implied by a style name. /// Weight implied by a style name.
+5 -6
View File
@@ -366,9 +366,8 @@ impl Lexer<'_> {
self.pos += after_tag; self.pos += after_tag;
self.skip_past(">"); self.skip_past(">");
return; return;
} else {
self.pos += k + 2;
} }
self.pos += k + 2;
} }
} }
@@ -408,7 +407,7 @@ impl Lexer<'_> {
self.skip_past(">"); self.skip_past(">");
return Some(Tok::End(name)); 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; self.pos += 1;
return Some(Tok::Text("<".into())); return Some(Tok::Text("<".into()));
} }
@@ -419,7 +418,7 @@ impl Lexer<'_> {
let mut attrs = Vec::new(); let mut attrs = Vec::new();
let mut self_close = false; let mut self_close = false;
loop { 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; i += 1;
} }
match b.get(i) { match b.get(i) {
@@ -448,13 +447,13 @@ impl Lexer<'_> {
i += 1; i += 1;
continue; 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; i += 1;
} }
let mut val = String::new(); let mut val = String::new();
if b.get(i) == Some(&b'=') { if b.get(i) == Some(&b'=') {
i += 1; 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; i += 1;
} }
match b.get(i) { match b.get(i) {
+3 -3
View File
@@ -778,13 +778,13 @@ fn linear_to_latex_at(linear: &str, depth: usize) -> String {
o.push('\\'); o.push('\\');
o.push_str(&name); o.push_str(&name);
i += 1 + name.len(); 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(' '); o.push(' ');
} }
} }
continue; continue;
} }
'%' | '#' | '&' | '$' => { '#'..='&' => {
o.push('\\'); o.push('\\');
o.push(c); o.push(c);
} }
@@ -796,7 +796,7 @@ fn linear_to_latex_at(linear: &str, depth: usize) -> String {
o.push('\\'); o.push('\\');
o.push_str(name); o.push_str(name);
// A control word needs a space only before a letter. // 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(' '); o.push(' ');
} }
} }
+5 -5
View File
@@ -268,7 +268,7 @@ fn list_marker(t: &str) -> Option<Marker> {
let (m, len) = if matches!(first, '-' | '+' | '*') { let (m, len) = if matches!(first, '-' | '+' | '*') {
(Marker { ordered: false, ch: first, len: 1, start: 1 }, 1) (Marker { ordered: false, ch: first, len: 1, start: 1 }, 1)
} else { } 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) { if !(1..=9).contains(&digits) {
return None; return None;
} }
@@ -607,7 +607,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut
i += 1; i += 1;
} }
// Blank lines at the end of an item belong between items. // 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(); item.pop();
} }
let li = ListInfo { ordered: first.ordered, level: level.min(8), start: first.start }; 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(); let mut chars = s.chars().peekable();
while let Some(c) = chars.next() { while let Some(c) = chars.next() {
if c == '\\' 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); out.push(n);
chars.next(); chars.next();
@@ -852,7 +852,7 @@ fn tag_attr(tag: &str, name: &str) -> Option<String> {
let at = from + k; let at = from + k;
let before = lower.get(..at)?.chars().next_back(); let before = lower.get(..at)?.chars().next_back();
let after = lower.get(at + name.len()..)?.trim_start(); 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 v = after.get(1..)?.trim_start();
let q = v.chars().next()?; let q = v.chars().next()?;
return Some(if q == '"' || q == '\'' { 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 == '-')) || (t.starts_with('-') && t.trim_end_matches([' ', '\t']).chars().all(|c| c == '-'))
|| thematic_break(t) || 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 d > 0
&& t.get(d..).is_some_and(|r| { && t.get(d..).is_some_and(|r| {
r.starts_with(". ") || r.starts_with(") ") || r.starts_with(".\t") || r.starts_with(")\t") || r == "." || r == ")" r.starts_with(". ") || r.starts_with(") ") || r.starts_with(".\t") || r.starts_with(")\t") || r == "." || r == ")"
+2 -2
View File
@@ -88,10 +88,10 @@ fn run(r: &MRun, o: &mut String, eq_arr: bool) {
let cs: Vec<char> = r.text.chars().collect(); let cs: Vec<char> = r.text.chars().collect();
let mut i = 0; let mut i = 0;
while let Some(&c) = cs.get(i) { 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(); let mut num = String::new();
while let Some(&d) = 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); num.push(d);
i += 1; i += 1;
+2 -2
View File
@@ -2,9 +2,9 @@
//! reader (#863). //! reader (#863).
use std::collections::HashSet; use std::collections::HashSet;
use wordcraft_doc::{Part, PartKind}; use wordcraft_doc::{Document, Part, PartKind};
use super::*; use super::{Body, Ev, FBlock, Pages, SectionProps, Styles, events, get, model};
/// Most XML events kept for all headers and footers together; a slot past it is left out. /// Most XML events kept for all headers and footers together; a slot past it is left out.
const MAX_EVENTS: usize = 1 << 20; const MAX_EVENTS: usize = 1 << 20;
+2 -2
View File
@@ -2638,7 +2638,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
let Some(&n) = bytes.get(i + 1) else { break }; let Some(&n) = bytes.get(i + 1) else { break };
if n.is_ascii_alphabetic() { if n.is_ascii_alphabetic() {
let mut j = i + 1; 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; j += 1;
} }
let word = std::str::from_utf8(bytes.get(i + 1..j).unwrap_or(b"")).unwrap_or(""); 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<Parsed, String> {
k += 1; k += 1;
} }
let d0 = k; 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; k += 1;
} }
let param = if k > d0 { let param = if k > d0 {
+1 -1
View File
@@ -788,7 +788,7 @@ fn iso_datetime(v: &str) -> Option<String> {
let y: i64 = it.next()?.parse().ok()?; let y: i64 = it.next()?.parse().ok()?;
let m: u32 = it.next()?.parse().ok()?; let m: u32 = it.next()?.parse().ok()?;
let d_part = it.next()?; 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()?; let d: u32 = d_digits.parse().ok()?;
if !(1..=12).contains(&m) || !(1..=31).contains(&d) { if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None; return None;
+1 -1
View File
@@ -276,7 +276,7 @@ fn e_apostrophe(text: &str, ws: &[(usize, usize)], v: &mut Vec<Issue>) {
} }
let mut after = text.get(*b..).unwrap_or("").chars(); let mut after = text.get(*b..).unwrap_or("").chars();
let Some(ap) = after.next().filter(|&c| is_apostrophe(c)) else { continue }; 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 == '‘'); let quoted = text.get(..*a).and_then(|t| t.chars().next_back()).is_some_and(|c| is_apostrophe(c) || c == '‘');
if ends && !quoted { if ends && !quoted {
v.push(issue(*a, *b + ap.len_utf8(), "«È» si scrive con l'accento, non con l'apostrofo".into(), vec!["È".into()])); v.push(issue(*a, *b + ap.len_utf8(), "«È» si scrive con l'accento, non con l'apostrofo".into(), vec!["È".into()]));
+2 -2
View File
@@ -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"); const EXTRA_EN: &str = include_str!("../../../assets/spelling/en-extra.txt");
fn extra_words() -> &'static HashSet<&'static str> { fn extra_words() -> &'static HashSet<&'static str> {
static E: OnceLock<HashSet<&'static str>> = OnceLock::new(); static E: OnceLock<HashSet<&str>> = OnceLock::new();
E.get_or_init(|| EXTRA_EN.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).collect()) 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<Issue> {
if gap == " " if gap == " "
&& (w0 == "an" || w0 == "An") && (w0 == "an" || w0 == "An")
&& w1.chars().next().is_some_and(|c| c.is_alphabetic() && !"aeiouAEIOUhH".contains(c)) && 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 { v.push(Issue {
start: *a0, start: *a0,
+1 -1
View File
@@ -109,7 +109,7 @@ pub fn has_dictionary() -> bool {
/// Is `c` a letter of the Cyrillic script (the only one the Russian dictionary covers)? /// Is `c` a letter of the Cyrillic script (the only one the Russian dictionary covers)?
pub(crate) fn is_cyrillic(c: char) -> bool { 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 (`кто-то`, `где-нибудь`, `давай-ка`, `всё-таки`, /// Particles joined to a word with a hyphen (`кто-то`, `где-нибудь`, `давай-ка`, `всё-таки`,
+8 -1
View File
@@ -1,6 +1,13 @@
//! Decoded-media cache: hits need the same allocation, and the byte budget bounds what is kept. //! 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] #[test]
fn media_cache_only_hits_the_same_allocation() { fn media_cache_only_hits_the_same_allocation() {
+5 -2
View File
@@ -1,9 +1,12 @@
//! Colour glyphs (#1269): emoji from colour fonts (bitmap strikes, COLR) are drawn in colour. //! Colour glyphs (#1269): emoji from colour fonts (bitmap strikes, COLR) are drawn in colour.
use super::*; use wordcraft_doc::Document;
use wordcraft_layout::display::page_display; use wordcraft_fonts::FontDb;
use wordcraft_layout::display::{DisplayOptions, Draw, page_display};
use wordcraft_layout::{LayoutCache, LayoutOptions, layout}; use wordcraft_layout::{LayoutCache, LayoutOptions, layout};
use super::{RenderOptions, Rendered, render_page};
/// Colour emoji families the system fallback may pick. /// Colour emoji families the system fallback may pick.
const COLOR_EMOJI: &[&str] = &["Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji"]; const COLOR_EMOJI: &[&str] = &["Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji"];
+1 -1
View File
@@ -1250,7 +1250,7 @@ impl Dialog {
filter: RecipientFilter::from_session(m), filter: RecipientFilter::from_session(m),
} }
} else { } else {
let fields: Vec<String> = NEW_LIST_FIELDS.iter().map(|f| f.to_string()).collect(); let fields: Vec<String> = NEW_LIST_FIELDS.iter().map(ToString::to_string).collect();
let rows = vec![vec![String::new(); fields.len()]]; let rows = vec![vec![String::new(); fields.len()]];
Dialog::RecipientList { fields, rows, message: String::new(), filter: RecipientFilter::default() } Dialog::RecipientList { fields, rows, message: String::new(), filter: RecipientFilter::default() }
} }
+2 -2
View File
@@ -242,7 +242,7 @@ pub struct CaptionForm {
impl CaptionForm { impl CaptionForm {
pub fn read(app: &WordApp) -> CaptionForm { pub fn read(app: &WordApp) -> CaptionForm {
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect(); let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(ToString::to_string).collect();
for l in &app.ui.caption_labels { for l in &app.ui.caption_labels {
if !labels.contains(l) { if !labels.contains(l) {
labels.push(l.clone()); labels.push(l.clone());
@@ -937,7 +937,7 @@ pub struct FiguresForm {
impl FiguresForm { impl FiguresForm {
pub fn read(app: &WordApp) -> FiguresForm { pub fn read(app: &WordApp) -> FiguresForm {
let mut labels: Vec<String> = BUILT_IN_LABELS.iter().map(|l| l.to_string()).collect(); let mut labels: Vec<String> = 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)) { for l in app.ui.caption_labels.iter().cloned().chain(wordcraft_engine::cmd::citations::caption_labels(&app.session)) {
if !labels.contains(&l) { if !labels.contains(&l) {
labels.push(l); labels.push(l);
+4 -1
View File
@@ -42,6 +42,7 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
# Emit a GitHub Actions warning (plain stderr outside Actions). # Emit a GitHub Actions warning (plain stderr outside Actions).
warn() { warn() {
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi 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. # Copy licence and readme files that exist into a package directory.
@@ -51,6 +52,7 @@ copy_docs() {
if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi
done done
copy_font_licences "$dest" copy_font_licences "$dest"
return 0
} }
# Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so # Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so
@@ -65,5 +67,6 @@ copy_font_licences() {
# Portable SHA-256 of a file (prints just the hash). # Portable SHA-256 of a file (prints just the hash).
sha256() { 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
} }
+5 -1
View File
@@ -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" 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; } 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; } render() {
local src="$1" size="$2" dst="$3"
resvg -w "$size" -h "$size" "$src" "$dst" </dev/null
return 0
}
render "$SVG" 1024 "$DIR/wordcraft-1024.png" render "$SVG" 1024 "$DIR/wordcraft-1024.png"
# Runtime window/Dock icon on macOS (embedded by apps/wordcraft); other platforms use hicolor 256. # Runtime window/Dock icon on macOS (embedded by apps/wordcraft); other platforms use hicolor 256.
+9 -7
View File
@@ -75,7 +75,7 @@ if command -v appstreamcli >/dev/null; then
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
fi 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 ------------------------------------------------------------------------------------ # ---- .tar.gz ------------------------------------------------------------------------------------
if has tar; then if has tar; then
@@ -119,13 +119,15 @@ if has appimage; then
fi fi
# Downloads $1 to $3 once and checks it against SHA-256 $2, the cached copy again on every run. # Downloads $1 to $3 once and checks it against SHA-256 $2, the cached copy again on every run.
fetch_pinned() { fetch_pinned() {
if [[ ! -x "$3" ]]; then local url="$1" expected_sha="$2" target="$3"
curl --proto '=https' -fsSL -o "$3.download" "$1" if [[ ! -x "$target" ]]; then
printf '%s %s\n' "$2" "$3.download" | sha256sum -c - curl --proto '=https' -fsSL -o "$target.download" "$url"
chmod 755 "$3.download" printf '%s %s\n' "$expected_sha" "$target.download" | sha256sum -c -
mv "$3.download" "$3" chmod 755 "$target.download"
mv "$target.download" "$target"
fi fi
printf '%s %s\n' "$2" "$3" | sha256sum -c - printf '%s %s\n' "$expected_sha" "$target" | sha256sum -c -
return 0
} }
APPDIR="$WORK/WordCraft.AppDir" APPDIR="$WORK/WordCraft.AppDir"
cp -R "$STAGE" "$APPDIR" cp -R "$STAGE" "$APPDIR"
+4 -2
View File
@@ -143,18 +143,20 @@ rm -f "$DMG"
# Retries an hdiutil verb that attaches a device: on CI runners it fails now and then with # Retries an hdiutil verb that attaches a device: on CI runners it fails now and then with
# "Resource busy". Usage: hdiutil_retry <verb> <args...>; five tries with growing pauses. # "Resource busy". Usage: hdiutil_retry <verb> <args...>; five tries with growing pauses.
hdiutil_retry() { hdiutil_retry() {
local verb="$1"
local attempt local attempt
for attempt in 1 2 3 4 5; do for attempt in 1 2 3 4 5; do
if hdiutil "$@"; then if hdiutil "$@"; then
return 0 return 0
fi fi
if [[ "$attempt" = 5 ]]; then if [[ "$attempt" = 5 ]]; then
echo "error: hdiutil $1 failed 5 times" >&2 echo "error: hdiutil $verb failed 5 times" >&2
return 1 return 1
fi 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)) sleep $((attempt * 10))
done done
return 1
} }
# `hdiutil create -srcfolder` copies the staged files as they are. The previous # `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 # `makehybrid -hfs` + `convert` gave every file and folder in the image a com.apple.FinderInfo