Merge main into fix/fitted-gradient-turns-with-transform

This commit is contained in:
dotCooCoo
2026-11-08 12:09:50 -08:00
111 changed files with 3772 additions and 307 deletions
+30 -1
View File
@@ -34,7 +34,16 @@ impl Document {
/// The name of the next unnamed asset: one more than the highest `Asset N` so far.
pub fn next_asset_name(&self) -> String {
let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::<u64>().ok()).max().unwrap_or(0);
let n = self
.assets
.iter()
.filter_map(|a| {
let digits = a.name.strip_prefix(ASSET)?;
// `parse` alone would accept a leading `+`.
digits.bytes().all(|b| b.is_ascii_digit()).then(|| digits.parse::<u64>().ok()).flatten()
})
.max()
.unwrap_or(0);
format!("{ASSET}{}", n.saturating_add(1))
}
@@ -61,3 +70,23 @@ impl Document {
v.into_iter().map(|(_, id)| id).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn name_after(names: &[&str]) -> String {
let mut doc = Document::new(100.0, 100.0);
for (i, n) in names.iter().enumerate() {
doc.assets.push(ExportAsset { id: i as u64 + 1, name: (*n).to_string(), nodes: vec![], format_3d: None });
}
doc.next_asset_name()
}
#[test]
fn next_asset_name_ignores_non_digit_suffixes() {
assert_eq!(name_after(&["Asset +5"]), "Asset 1");
assert_eq!(name_after(&["Asset ", "Asset 5x"]), "Asset 1");
assert_eq!(name_after(&["Asset 5"]), "Asset 6");
}
}
+21
View File
@@ -120,6 +120,21 @@ impl Quotes {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p)))
}
/// The straight quote a typed curly quote stands for: `"` for `”` (U+201D) and nothing else.
/// The macOS Serbian Cyrillic layout has no straight `"` and types `”` on Shift+2, so with
/// Typographer's Quotes on, a `”` typed alone where a quote opens takes the document's opening
/// quote ([`Quotes::opens_after`]); where a quote closes it stays `”`. `“ „` and the single
/// quotes `‘ ’ ‚` stay as typed: that layout's apostrophe key already types `’`, as Serbian
/// wants, and `’90s` keeps its apostrophe.
pub fn typed_as_straight(c: char) -> Option<char> {
(c == '”').then_some('"')
}
/// Does a quote typed after `prev` (the character before it, `None` at the start) open?
pub fn opens_after(&self, prev: Option<char>) -> bool {
self.opens(prev, None)
}
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
/// context (an apostrophe is a closing single quote). `prev` is the character before `text`,
/// and is updated to its last character, so text can be converted in pieces.
@@ -420,6 +435,12 @@ mod tests {
}
let mut prev = None;
assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
// Only a typed `”` stands for a straight quote; the other curly quotes stay as typed.
assert_eq!(Quotes::typed_as_straight('”'), Some('"'));
assert!(['“', '„', '‘', '’', '‚', '"', '\'', '«', '»', '「', 'a'].iter().all(|&c| Quotes::typed_as_straight(c).is_none()));
// A quote opens at the start and after a space, and closes after a letter.
assert!(Quotes::ENGLISH.opens_after(None) && Quotes::ENGLISH.opens_after(Some(' ')));
assert!(!Quotes::ENGLISH.opens_after(Some('a')));
// Bulgarian: „…“ at either level, as CLDR has it.
let mut prev = None;
assert_eq!(language_quotes("bulgarian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a „b“ c“");