Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 2e168e0bc4 fix(engine): pass case helpers by value (clippy needless borrows) 2026-10-23 23:29:13 -04:00
Dylan M. Taylor 05a3ac74b0 fix: apply_kind_properties binds ContentControl; cell_ref keeps char closures 2026-10-23 23:07:02 -04:00
Dylan Taylor f3c3c6480a Import Document in odt page_parts after dropping glob re-export 2026-10-23 22:52:35 -04:00
Dylan M. TaylorandClaude Opus 5.5 213e433563 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>
2026-10-24 02:34:38 +00:00
Dylan M. Taylor 32afcb661a chore: resolve SonarCloud maintainability issues 2026-10-24 02:21:15 +00:00
14001aee65 Proofing options reach the Czech, Croatian, Indonesian, Italian, Polish and Portuguese checks (#3966)
Each of these languages accepted every word in capitals of up to six letters and every word
with a digit on its own, so turning off Ignore words in UPPERCASE or Ignore words that contain
numbers had no effect on them for short words. The check now takes the options, as the Spanish
one does; the direct is_correct/is_correct_with keep the defaults.

Co-authored-by: Federico Olivares <FedeOlivares@users.noreply.github.com>
Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-10-23 20:07:34 -04:00
39 changed files with 565 additions and 316 deletions
+1 -1
View File
@@ -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() {}
}
+26 -24
View File
@@ -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<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("/"))
}
}
@@ -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()));
+1 -1
View File
@@ -47,7 +47,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
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 {
+3 -3
View File
@@ -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();
+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..],
_ => 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;
}
+58 -42
View File
@@ -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("</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).
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("</w:sdtContent></w:sdt>");
}
}
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:p>");
w.raw(&run);
w.raw("</w:p>");
} else {
w.raw("<w:p/>");
}
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(&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) {
let para_id = self.take_para_id(p);
match &para_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);
+1 -1
View File
@@ -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::<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
+29 -21
View File
@@ -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<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;
}
fn apply_kind_properties(c: &mut ContentControl, v: &Value) -> Option<String> {
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<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 {
let base = content_base(s, &r);
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()
}
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 +670,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,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
/// characters), or enclosed characters already there (selected, or beside the caret).
#[derive(Clone, Debug, PartialEq)]
@@ -884,3 +872,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.");
}
}
+67 -67
View File
@@ -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::<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)]
mod tests {
use super::*;
@@ -1308,70 +1375,3 @@ mod tests {
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 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);
+49 -37
View File
@@ -228,6 +228,50 @@ enum Tok {
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> {
let mut out = Vec::new();
let mut chars = s.chars().peekable();
@@ -235,46 +279,14 @@ fn tokenize(s: &str) -> Result<Vec<Tok>, 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::<f64>().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,7 +480,7 @@ 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()) {
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.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 == word || (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 +248,7 @@ impl AutoCorrectPrefs {
let mut v: Vec<String> = 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();
+1 -1
View File
@@ -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::<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}")))
}
};
+2 -2
View File
@@ -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<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;
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(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
/// Weight implied by a style name.
+5 -6
View File
@@ -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) {
+3 -3
View File
@@ -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(' ');
}
}
+5 -5
View File
@@ -268,7 +268,7 @@ fn list_marker(t: &str) -> Option<Marker> {
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<String> {
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 == ")"
+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 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;
+2 -2
View File
@@ -2,9 +2,9 @@
//! reader (#863).
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.
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 };
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<Parsed, String> {
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 {
+1 -1
View File
@@ -788,7 +788,7 @@ fn iso_datetime(v: &str) -> Option<String> {
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;
+32 -2
View File
@@ -98,18 +98,30 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Czech dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
return true;
}
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -233,4 +245,22 @@ mod tests {
let opts = crate::Options { ignore_uppercase: false, ..Default::default() };
assert!(!crate::is_correct_in_with("ZQZAKÁZANÉ", crate::ProofLang::Cs, &opts));
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Czech checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_czech_check() {
test_dict::install();
let lang = crate::ProofLang::Cs;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("ŠKULA", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("ŠKULA", lang, &check_caps));
assert!(crate::is_correct_in_with("ŠKOLA", lang, &check_caps));
assert!(crate::is_correct_in_with("škula2", lang, &defaults));
assert!(!crate::is_correct_in_with("škula2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("ŠKULA"), "direct checks keep the defaults");
}
}
+32 -2
View File
@@ -85,18 +85,30 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Croatian dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
return true;
}
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -147,4 +159,22 @@ mod tests {
fn missing_dictionary_is_silent() {
assert!(is_correct_with(&AffixDict::default(), "nepostojećariječ"));
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Croatian checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_croatian_check() {
test_dict::install();
let lang = crate::ProofLang::Hr;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("ŠKOLLA", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("ŠKOLLA", lang, &check_caps));
assert!(crate::is_correct_in_with("ŠKOLA", lang, &check_caps));
assert!(crate::is_correct_in_with("školla2", lang, &defaults));
assert!(!crate::is_correct_in_with("školla2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("ŠKOLLA"), "direct checks keep the defaults");
}
}
+35 -3
View File
@@ -91,18 +91,30 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Indonesian dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
return true;
}
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -113,7 +125,7 @@ pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
|| w.split('-').all(|p| {
p.chars().count() <= 1
|| CLITICS.iter().any(|c| p.eq_ignore_ascii_case(c))
|| (p.chars().all(|c| !c.is_lowercase()) && p.chars().count() <= 6)
|| (opts.ignore_uppercase && p.chars().all(|c| !c.is_lowercase()) && p.chars().count() <= 6)
|| known(d, p)
});
}
@@ -224,4 +236,24 @@ mod tests {
let opts = crate::Options { ignore_uppercase: false, ..Default::default() };
assert!(!crate::is_correct_in_with("ZQTERLARANG", crate::ProofLang::Id, &opts));
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Indonesian checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_indonesian_check() {
test_dict::install();
let lang = crate::ProofLang::Id;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("RUMHA", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("RUMHA", lang, &check_caps));
assert!(crate::is_correct_in_with("RUMAH", lang, &check_caps));
assert!(crate::is_correct_in_with("RUMHA-nya", lang, &defaults));
assert!(!crate::is_correct_in_with("RUMHA-nya", lang, &check_caps), "a part in capitals is checked too");
assert!(crate::is_correct_in_with("rumha2", lang, &defaults));
assert!(!crate::is_correct_in_with("rumha2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("RUMHA"), "direct checks keep the defaults");
}
}
+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 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()]));
+32 -2
View File
@@ -111,18 +111,30 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Italian dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
return true;
}
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -229,4 +241,22 @@ mod tests {
crate::add_word("Zxqitalianaparola");
assert!(is_correct("zxqitalianaparola"));
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Italian checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_italian_check() {
test_dict::install();
let lang = crate::ProofLang::It;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("LIBRRO", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("LIBRRO", lang, &check_caps));
assert!(crate::is_correct_in_with("LIBRO", lang, &check_caps));
assert!(crate::is_correct_in_with("librro2", lang, &defaults));
assert!(!crate::is_correct_in_with("librro2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("LIBRRO"), "direct checks keep the defaults");
}
}
+8 -8
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");
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())
}
@@ -729,14 +729,14 @@ pub fn is_correct_in(word: &str, lang: ProofLang) -> bool {
pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool {
match lang {
ProofLang::En | ProofLang::EnGb | ProofLang::EnCa => is_correct_english(word, lang, opts),
ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct(word),
ProofLang::Hr => accepted_by_options(word, opts) || hr::is_correct(word),
ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct(word),
ProofLang::It => accepted_by_options(word, opts) || it::is_correct(word),
ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct_with_options(word, opts),
ProofLang::Hr => accepted_by_options(word, opts) || hr::is_correct_with_options(word, opts),
ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct_with_options(word, opts),
ProofLang::It => accepted_by_options(word, opts) || it::is_correct_with_options(word, opts),
ProofLang::Tr => accepted_by_options(word, opts) || tr::is_correct(word),
ProofLang::Ru => accepted_by_options(word, opts) || ru::is_correct(word),
ProofLang::Cs => accepted_by_options(word, opts) || cs::is_correct(word),
ProofLang::Id => accepted_by_options(word, opts) || id::is_correct(word),
ProofLang::Cs => accepted_by_options(word, opts) || cs::is_correct_with_options(word, opts),
ProofLang::Id => accepted_by_options(word, opts) || id::is_correct_with_options(word, opts),
ProofLang::Bg | ProofLang::De | ProofLang::Other => true,
}
}
@@ -889,7 +889,7 @@ fn grammar_en(text: &str, capital_first: bool) -> Vec<Issue> {
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,
+32 -2
View File
@@ -96,18 +96,30 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Polish dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
return true;
}
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -209,4 +221,22 @@ mod tests {
crate::add_word("Zxqpolskisłowo");
assert!(is_correct("zxqpolskisłowo"));
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Polish checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_polish_check() {
test_dict::install();
let lang = crate::ProofLang::Pl;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("ŻEKA", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("ŻEKA", lang, &check_caps));
assert!(crate::is_correct_in_with("RZEKA", lang, &check_caps));
assert!(crate::is_correct_in_with("żeka2", lang, &defaults));
assert!(!crate::is_correct_in_with("żeka2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("ŻEKA"), "direct checks keep the defaults");
}
}
+32 -2
View File
@@ -91,18 +91,30 @@ pub fn is_correct(word: &str) -> bool {
is_correct_with(&dictionary(), word)
}
/// [`is_correct`] with the File › Options › Proofing choices: with Ignore words in UPPERCASE off,
/// short words in capitals are checked against the dictionary too, and with Ignore words that
/// contain numbers off, so are words with digits.
pub fn is_correct_with_options(word: &str, opts: &crate::Options) -> bool {
check(&dictionary(), word, opts)
}
/// [`is_correct`] against a given dictionary (an empty one accepts everything).
pub fn is_correct_with(d: &AffixDict, word: &str) -> bool {
check(d, word, &crate::Options::default())
}
/// [`is_correct_with_options`] against a given dictionary.
fn check(d: &AffixDict, word: &str, opts: &crate::Options) -> bool {
if d.is_empty() {
return true; // no Portuguese dictionary installed: no spelling marks
}
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '\'' && c != '’');
let w = w.trim_matches(['\'', '’']);
let n = w.chars().count();
if n <= 1 || w.chars().any(|c| c.is_ascii_digit()) || !w.chars().any(char::is_alphabetic) {
if n <= 1 || (opts.ignore_numbers && w.chars().any(|c| c.is_ascii_digit())) || !w.chars().any(char::is_alphabetic) {
return true;
}
if w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
if opts.ignore_uppercase && w.chars().all(|c| !c.is_lowercase()) && n <= 6 {
return true;
}
if !w.chars().any(is_latin_letter) {
@@ -217,4 +229,22 @@ mod tests {
crate::add_word("Unigranrio-RJ");
assert!(crate::check_spelling_in(text, crate::ProofLang::Pt).is_empty());
}
/// With Ignore words in UPPERCASE or Ignore words that contain numbers off, Portuguese checks
/// those words against the dictionary instead of accepting them.
#[test]
fn proofing_options_reach_the_portuguese_check() {
test_dict::install();
let lang = crate::ProofLang::Pt;
let defaults = crate::Options::default();
let check_caps = crate::Options { ignore_uppercase: false, ..defaults };
let check_digits = crate::Options { ignore_numbers: false, ..defaults };
assert!(crate::is_correct_in_with("PRAYA", lang, &defaults), "short capitals are acronyms by default");
assert!(!crate::is_correct_in_with("PRAYA", lang, &check_caps));
assert!(crate::is_correct_in_with("PRAIA", lang, &check_caps));
assert!(crate::is_correct_in_with("praya2", lang, &defaults));
assert!(!crate::is_correct_in_with("praya2", lang, &check_digits));
assert!(crate::is_correct_in_with("2026", lang, &check_digits), "numbers alone stay correct");
assert!(is_correct("PRAYA"), "direct checks keep the defaults");
}
}
+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)?
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 (`кто-то`, `где-нибудь`, `давай-ка`, `всё-таки`,
+8 -1
View File
@@ -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() {
+5 -2
View File
@@ -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"];
+1 -1
View File
@@ -1250,7 +1250,7 @@ impl Dialog {
filter: RecipientFilter::from_session(m),
}
} 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()]];
Dialog::RecipientList { fields, rows, message: String::new(), filter: RecipientFilter::default() }
}
+2 -2
View File
@@ -242,7 +242,7 @@ pub struct CaptionForm {
impl 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 {
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<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)) {
if !labels.contains(&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).
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 +52,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 +67,6 @@ 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
}
+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"
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"
# 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"
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"
+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
# "Resource busy". Usage: hdiutil_retry <verb> <args...>; 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