Merge origin/main into spanish-proofing

Resolve the ROADMAP.md conflict: main's progress-log entries come first, then
the Spanish proofing entry, and the status line keeps both changes.
This commit is contained in:
Federico Olivares
2026-10-23 12:49:42 -03:00
43 changed files with 456 additions and 63 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
let kind = chart_type(v)?;
// No type and no data: Insert › Chart's picker, as the ribbon button does.
if kind.is_none() && v.get("data").is_none() {
s.ui_requests.push(json!({"open": "insertChart"}));
s.push_ui_request(json!({"open": "insertChart"}));
return sel_result(s);
}
let kind = kind.unwrap_or_default();
+1
View File
@@ -529,6 +529,7 @@ fn auto_tag(doc: &Document, src: &Source) -> String {
}
/// The author of a source for matching untagged entries: the editor when there is no author.
#[cfg(not(target_arch = "wasm32"))]
fn by_author(x: &Source) -> &str {
if x.author.is_empty() { x.editor.as_str() } else { x.author.as_str() }
}
+14 -3
View File
@@ -112,9 +112,11 @@ fn symbol_param(v: &Value, k: &str) -> Option<char> {
/// A date `YYYY-MM-DD` (a full ISO date-time is fine too): (year, month, day), checked.
pub fn parse_date(t: &str) -> Option<(i64, u32, u32)> {
let t = t.trim();
let y: i64 = t.get(0..4)?.parse().ok()?;
let m: u32 = t.get(5..7)?.parse().ok()?;
let d: u32 = t.get(8..10)?.parse().ok()?;
// Digits only: `str::parse` would also accept a leading '+'.
let field = |r: std::ops::Range<usize>| t.get(r).filter(|s| s.bytes().all(|b| b.is_ascii_digit()));
let y: i64 = field(0..4)?.parse().ok()?;
let m: u32 = field(5..7)?.parse().ok()?;
let d: u32 = field(8..10)?.parse().ok()?;
if t.get(4..5) != Some("-") || t.get(7..8) != Some("-") || !(1..=9999).contains(&y) || !(1..=12).contains(&m) || d == 0 || d > days_in_month(y, m)
{
return None;
@@ -761,6 +763,15 @@ mod tests {
assert_eq!(super::super::civil_from_days(days_from_civil(2000, 3, 1)), (2000, 3, 1));
}
#[test]
fn parse_date_rejects_signs_and_non_digits() {
for bad in ["2024-+1-+5", "+123-01-01", "2024-01-+5", "2024-+1-05", "-024-01-01"] {
assert_eq!(parse_date(bad), None, "{bad}");
}
assert_eq!(parse_date("2024-01-05"), Some((2024, 1, 5)));
assert_eq!(parse_date("2024-01-05T10:00:00"), Some((2024, 1, 5)));
}
#[test]
fn days_from_civil_and_days_in_month_exact_beyond_chronos_range() {
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result; values from the
+5 -5
View File
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
t @ ("find" | "replace" | "goto") => t,
other => return Err(CmdError::Params(format!("`tab` is find, replace or goto, not `{other}`"))),
};
s.ui_requests.push(json!({"open": tab}));
s.push_ui_request(json!({"open": tab}));
Ok(json!({"open": tab}))
})
// Word for Mac's own key is ⌃H.
@@ -603,7 +603,7 @@ fn find(s: &mut Session, v: &Value) -> CmdResult {
// Word's Ctrl+F: the Navigation pane with its search box ready to type in (#712); the
// Find and Replace dialog is Advanced Find.
s.view.nav_pane = true;
s.ui_requests.push(json!({"focus": "navSearch"}));
s.push_ui_request(json!({"focus": "navSearch"}));
return Ok(json!({"count": s.find.results.len()}));
}
let story = s.sel.focus.story;
@@ -624,7 +624,7 @@ fn find(s: &mut Session, v: &Value) -> CmdResult {
/// Find in the classic dialog: every match in the body or the selection, optionally highlighted while you read.
fn advanced_find(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "text").is_none() && v.get("highlight").is_none() {
s.ui_requests.push(json!({"open": "find"}));
s.push_ui_request(json!({"open": "find"}));
return Ok(json!({"count": s.find.results.len()}));
}
read_opts(s, v);
@@ -748,7 +748,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
s.sel = Selection::caret(t);
s.page_hint = idx;
}
s.ui_requests.push(json!({"scrollTo": "caret"}));
s.push_ui_request(json!({"scrollTo": "caret"}));
return sel_result(s);
}
if let Some(name) = p::str(v, "bookmark") {
@@ -769,7 +769,7 @@ fn goto(s: &mut Session, v: &Value) -> CmdResult {
}
return sel_result(s);
}
s.ui_requests.push(json!({"open": "goto"}));
s.push_ui_request(json!({"open": "goto"}));
sel_result(s)
}
+10 -7
View File
@@ -101,7 +101,7 @@ pub fn specs() -> Vec<CommandSpec> {
// No path (the command search, say): the app asks where to save, as File › Export
// does, instead of failing on the required parameter (#2313).
let Some(path) = p::str(v, "path") else {
s.ui_requests.push(json!({"open": "exportPdf"}));
s.push_ui_request(json!({"open": "exportPdf"}));
return Ok(json!({"exported": false}));
};
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
@@ -127,13 +127,13 @@ pub fn specs() -> Vec<CommandSpec> {
)
.pure(),
CommandSpec::new("file.print", "Print", "File", |s, _| {
s.ui_requests.push(json!({"open": "print"}));
s.push_ui_request(json!({"open": "print"}));
sel_result(s)
})
.key("Mod+P / Mod+Shift+F12 / Mod+F2 / Mod+Alt+I")
.pure(),
CommandSpec::new("file.close", "Close", "File", |s, _| {
s.ui_requests.push(json!({"close": true}));
s.push_ui_request(json!({"close": true}));
sel_result(s)
})
.key("Mod+W / Mod+F4")
@@ -227,7 +227,7 @@ fn normal_document(s: &Session) -> Document {
/// Open a document, using the saved proofing default only for plain text and Markdown.
fn open(s: &mut Session, v: &Value) -> CmdResult {
let Some(path) = p::str(v, "path") else {
s.ui_requests.push(json!({"open": "openFile"}));
s.push_ui_request(json!({"open": "openFile"}));
return sel_result(s);
};
let password = p::str(v, "password");
@@ -290,7 +290,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult {
let path = match p::str(v, "path").map(std::path::PathBuf::from).or_else(|| s.path.clone()) {
Some(p) => p,
None => {
s.ui_requests.push(json!({"open": "saveAs"}));
s.push_ui_request(json!({"open": "saveAs"}));
return Ok(json!({"saved": false}));
}
};
@@ -357,7 +357,7 @@ fn is_word_2003_file(_path: &std::path::Path) -> bool {
fn save_as(s: &mut Session, v: &Value) -> CmdResult {
if p::str(v, "path").is_none() {
s.ui_requests.push(json!({"open": "saveAs"}));
s.push_ui_request(json!({"open": "saveAs"}));
return Ok(json!({"saved": false}));
}
save(s, v)
@@ -373,7 +373,7 @@ pub fn export_options(s: &Session, v: &Value) -> crate::io::ExportOptions {
fn export_png(s: &mut Session, v: &Value) -> CmdResult {
// No path: the app asks where to save, as file.exportPdf does (#2313).
let Some(path) = p::str(v, "path") else {
s.ui_requests.push(json!({"open": "exportPng"}));
s.push_ui_request(json!({"open": "exportPng"}));
return Ok(json!({"exported": false}));
};
let page = p::u64(v, "page").unwrap_or(1).max(1) as usize - 1;
@@ -384,6 +384,9 @@ fn export_png(s: &mut Session, v: &Value) -> CmdResult {
// the on-screen cue while editing the body, not part of the page.
let mut opts = wordcraft_render::RenderOptions::default();
opts.display.dim_header = false;
if !path.to_ascii_lowercase().ends_with(".png") {
return Err(CmdError::Params("path must end in .png".into()));
}
let img = wordcraft_render::render_page(&s.doc, pg, scale, &opts);
let png = img.to_png();
crate::io::write_file(path, &png).map_err(CmdError::Failed)?;
+2 -2
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
fn load_system(s: &mut Session, _v: &Value) -> CmdResult {
s.ui_requests.push(json!({"open": "loadSystemFonts"}));
s.push_ui_request(json!({"open": "loadSystemFonts"}));
Ok(json!({"pending": true}))
}
@@ -68,7 +68,7 @@ fn add(s: &mut Session, v: &Value) -> CmdResult {
}
} else {
// From the ribbon: the UI shows a file picker and runs this again with the file.
s.ui_requests.push(json!({"open": "addFont"}));
s.push_ui_request(json!({"open": "addFont"}));
return Ok(json!({"pending": true}));
};
let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?;
+1 -1
View File
@@ -139,7 +139,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("format.charStyle", "Apply Character Style", "Home › Styles", char_style).params(r#"{"style": string}"#),
CommandSpec::new("format.set", "Set Character Formatting", "Home › Font › Font", set).params(r#"{"props": CharProps}"#),
CommandSpec::new("format.fontDialog", "Font Dialog", "Home › Font", |s, _| {
s.ui_requests.push(json!({"open": "font"}));
s.push_ui_request(json!({"open": "font"}));
sel_result(s)
})
.key("Mod+D")
+3 -3
View File
@@ -221,7 +221,7 @@ pub(crate) fn picture(s: &mut Session, v: &Value) -> CmdResult {
} else if let Some(path) = p::str(v, "path") {
crate::io::read_file(path).map_err(CmdError::Failed)?
} else {
s.ui_requests.push(json!({"open": "insertPicture"}));
s.push_ui_request(json!({"open": "insertPicture"}));
return sel_result(s);
};
place_picture(s, bytes, p::f32(v, "width"), p::str(v, "alt").unwrap_or(""))
@@ -295,7 +295,7 @@ fn icon(s: &mut Session, v: &Value) -> CmdResult {
.iter()
.map(|i| json!({"id": i.id, "name": i.name, "category": i.category.name(), "keywords": i.keywords}))
.collect();
s.ui_requests.push(json!({"open": "icons"}));
s.push_ui_request(json!({"open": "icons"}));
return Ok(json!({"icons": icons}));
};
let icon = icon_lib::find(id).ok_or_else(|| CmdError::Params(format!("no icon {id:?} (insert.icon without an id lists them)")))?;
@@ -441,7 +441,7 @@ fn link(s: &mut Session, v: &Value) -> CmdResult {
None => match place_target(s, v)? {
Some(t) => t,
None => {
s.ui_requests.push(json!({"open": "link"}));
s.push_ui_request(json!({"open": "link"}));
return sel_result(s);
}
},
+8 -1
View File
@@ -122,7 +122,13 @@ fn clear(s: &mut Session, v: &Value) -> CmdResult {
}
"paragraphFormatting" => {
s.doc.format_paragraphs(&a, &b, &|p| {
*p = ParaProps { style: p.style.clone(), numbering: p.numbering, ..Default::default() };
*p = ParaProps {
style: p.style.clone(),
numbering: p.numbering,
num_change: p.num_change.take(),
fmt_change: p.fmt_change.take(),
..Default::default()
};
})?;
}
"characterStyle" => return super::format::apply(s, &|c| c.style = None),
@@ -136,6 +142,7 @@ fn clear(s: &mut Session, v: &Value) -> CmdResult {
lang: c.lang.clone(),
no_proof: c.no_proof,
rtl: c.rtl,
fmt_change: c.fmt_change.take(),
..Default::default()
};
});
+13 -1
View File
@@ -130,7 +130,8 @@ pub fn specs() -> Vec<CommandSpec> {
}
if sharpen > 0.0 {
img = img.unsharpen(1.0 + sharpen / 50.0, 2);
} else if sharpen < 0.0 {
} else if sharpen < 0.0 && (-sharpen / 25.0).is_normal() {
// `blur` asserts on a subnormal width.
img = img.blur(-sharpen / 25.0);
}
img
@@ -2010,6 +2011,17 @@ mod tests {
assert!(s.run("picture.crop", &json!({"left": 0.1})).is_err());
}
/// A `sharpen` too small to be a normal blur width must not reach the blur, which asserts on it.
#[test]
fn a_tiny_sharpen_value_does_not_panic() {
let mut s = Session::new(wordcraft_doc::Document::new());
let data = super::super::insert::base64_encode(&png(20, 10));
s.run("insert.picture", &json!({"data": data})).unwrap();
for sharpen in [-1e-40_f64, -1e-45, -1e-38, 1e-40, -1e-30, 0.0, -0.5] {
s.run("picture.corrections", &json!({"sharpen": sharpen})).unwrap_or_else(|e| panic!("sharpen {sharpen}: {e:?}"));
}
}
/// Transparency is absolute from the original pixels: 80% then 0% is opaque again
/// (it used to stay faded), and repeating a value is idempotent. Corrections and color
/// re-set the same way: brightness 0 and saturation 100 restore the original.
+1 -1
View File
@@ -201,7 +201,7 @@ fn options(s: &mut Session, v: &Value) -> CmdResult {
// Marks, hidden text and proofing change what is laid out.
s.relayout();
} else {
s.ui_requests.push(json!({"open": "options", "pane": pane}));
s.push_ui_request(json!({"open": "options", "pane": pane}));
}
let mut r = current(s);
if let Some(o) = r.as_object_mut() {
+1 -1
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
validate_section(&mut new)?;
return with_sect(s, |x| *x = new.clone());
}
s.ui_requests.push(json!({"open": "pageSetup"}));
s.push_ui_request(json!({"open": "pageSetup"}));
Ok(serde_json::to_value(sect(s)).unwrap_or(Value::Null))
})
.params(r#"{"section"?: SectionProps} (replaces the whole section, headers and footers included; checked like Size, Margins, Columns, Line Numbers, Footnotes and Page Borders: non-finite numbers and margins that leave no room for text are refused; page sides 7.2–1584 pt, 1–12 columns with 0–300 pt gaps, header/footer/gutter 0–1584 pt, page border width 0.25–6 pt and distance 0–31 pt, note start 1–32767, line numbers start 1–32767, countBy 1–100, distance 0–1584 pt are clamped)"#),
+2 -2
View File
@@ -199,7 +199,7 @@ pub fn specs() -> Vec<CommandSpec> {
r#"{"props": ParaProps, "onPage"?: {"align"?: "left|center|right|justify|distribute", "left"?: pt, "right"?: pt} (alignment and indents as seen on the page: each paragraph's own direction, after `props`, says which edge is its start)}"#,
),
CommandSpec::new("para.dialog", "Paragraph Settings", "Home › Paragraph", |s, _| {
s.ui_requests.push(json!({"open": "paragraph"}));
s.push_ui_request(json!({"open": "paragraph"}));
sel_result(s)
})
.pure(),
@@ -213,7 +213,7 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(CmdError::Params(format!("unknown sort `{sort}` (recommended|alphabetical)")));
}
let list = manage_list(s, sort == "alphabetical");
s.ui_requests.push(json!({"open": "manageStyles"}));
s.push_ui_request(json!({"open": "manageStyles"}));
Ok(list)
})
.params(r#"{"sort"?: "recommended|alphabetical"} → [{id, name, type: "paragraph|character|linked|table", builtIn, inGallery, hidden, basedOn, priority, format}]"#)
+3 -1
View File
@@ -106,7 +106,9 @@ fn paste_special(s: &mut Session, v: &Value) -> CmdResult {
req[k] = json!(t);
}
}
s.ui_requests.push(req);
// Only the newest dialog request matters; older ones would pin their payloads.
s.ui_requests.retain(|r| r["open"] != "pasteSpecial");
s.push_ui_request(req);
return Ok(json!({"formats": formats_json(&ids)}));
};
let fmt = match fmt.to_ascii_lowercase().as_str() {
+1 -1
View File
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
/// dialog and returns the selection's phonetic guide (`base`, `ruby`, `align`, `size`, `offset`).
fn phonetic(s: &mut Session, v: &Value) -> CmdResult {
let Some(rv) = v.get("ruby") else {
s.ui_requests.push(json!({"open": "phonetic"}));
s.push_ui_request(json!({"open": "phonetic"}));
return Ok(state(s));
};
let text: String = match rv {
+15
View File
@@ -1683,6 +1683,21 @@ mod tests {
assert!(s.run("review.changes", &json!({})).unwrap().as_array().unwrap().is_empty());
}
/// Clearing formatting in the Style Inspector keeps pending formatting revisions.
#[test]
fn inspector_clear_keeps_formatting_changes() {
let mut s = formatted_while_tracking();
s.run("review.trackChanges", &json!({"value": false})).unwrap();
s.run("styles.inspectorClear", &json!({"level": "paragraphFormatting"})).unwrap();
assert!(body_para(&s).props.fmt_change.is_some(), "paragraph formatting change survives");
let mut s = formatted_while_tracking();
s.run("review.trackChanges", &json!({"value": false})).unwrap();
s.run("select.range", &json!({"anchor": {"block": 0, "off": 6}, "focus": {"block": 0, "off": 11}})).unwrap();
s.run("styles.inspectorClear", &json!({"level": "characterFormatting"})).unwrap();
assert!(body_para(&s).props_of_char(6).fmt_change.is_some(), "character formatting change survives");
}
/// Comment anchors left in any story (the body, headers, footers, notes and text boxes), with
/// the story each sits in.
fn anchors(s: &Session) -> Vec<(wordcraft_doc::StoryRef, u32)> {
+4 -4
View File
@@ -539,7 +539,7 @@ pub fn specs() -> Vec<CommandSpec> {
})
.pure(),
CommandSpec::new("file.share", "Share", "File", |s, _| {
s.ui_requests.push(json!({"open": "saveAs"}));
s.push_ui_request(json!({"open": "saveAs"}));
Ok(json!({"hint": "Save or export a copy (PDF, Word document) to share it."}))
})
.pure(),
@@ -1879,7 +1879,7 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult {
}
out.insert("custom".into(), serde_json::to_value(&s.keymap).unwrap_or(Value::Null));
if !asked {
s.ui_requests.push(json!({"open": "customizeKeyboard"}));
s.push_ui_request(json!({"open": "customizeKeyboard"}));
}
Ok(Value::Object(out))
}
@@ -1888,7 +1888,7 @@ fn customize_keyboard(s: &mut Session, v: &Value) -> CmdResult {
/// with no query, the Search box (`ui_requests` → the UI's command search).
fn search_commands(s: &mut Session, v: &Value) -> CmdResult {
let Some(query) = p::str(v, "query") else {
s.ui_requests.push(json!({"open": "commands"}));
s.push_ui_request(json!({"open": "commands"}));
return Ok(json!({"matches": []}));
};
let words: Vec<String> = query.split_whitespace().map(str::to_lowercase).collect();
@@ -1984,7 +1984,7 @@ fn customize_ribbon(s: &mut Session, v: &Value) -> CmdResult {
out.insert("tabs".into(), serde_json::to_value(l.tabs().as_ref()).unwrap_or(Value::Null));
out.insert("qat".into(), json!(l.qat()));
if !asked {
s.ui_requests.push(json!({"open": "customizeRibbon"}));
s.push_ui_request(json!({"open": "customizeRibbon"}));
}
Ok(Value::Object(out))
}
+3
View File
@@ -23,6 +23,7 @@ pub const MAX_TEMPLATES: usize = 500;
/// Most category folders looked into.
pub const MAX_CATEGORIES: usize = 64;
/// Most directory entries looked at in one folder.
#[cfg(not(target_arch = "wasm32"))]
const MAX_ENTRIES: usize = 10_000;
/// Longest folder path taken, in bytes.
const MAX_DIR: usize = 32 * 1024;
@@ -186,6 +187,7 @@ fn entries(dir: &std::path::Path) -> (Vec<crate::io::DirEntry>, Vec<std::path::P
/// The name a template shows: its file name without the template extension, every one of them
/// (`Contract.dotm.dotx`, as older Save as Template made, is `Contract`); the whole name if
/// nothing would be left.
#[cfg(not(target_arch = "wasm32"))]
fn label_of(name: &str) -> String {
let mut label = name;
while is_template_name(label) {
@@ -355,6 +357,7 @@ mod tests {
/// .dotx and .dotm templates show their names the same way, without the extension; two that
/// would look alike in one category show their file names.
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn labels_drop_the_extension_alike_and_tell_twins_apart() {
let d = scratch("labels");
template(&d.join("Service contract.dotm"), "a", "");
+5 -5
View File
@@ -91,12 +91,12 @@ pub fn specs() -> Vec<CommandSpec> {
// light and dark, as the manual switch did.
CommandSpec::new("view.interfaceTheme", "Interface Theme", "View › Dark Mode", |s, v| match v.get("value") {
None => {
s.ui_requests.push(json!({"toggle": "interfaceTheme"}));
s.push_ui_request(json!({"toggle": "interfaceTheme"}));
Ok(json!({"value": "toggle"}))
}
Some(Value::String(name)) => match name.as_str() {
"light" | "dark" | "system" => {
s.ui_requests.push(json!({"interfaceTheme": name}));
s.push_ui_request(json!({"interfaceTheme": name}));
Ok(json!({"value": name}))
}
_ => Err(CmdError::Params("interface theme: light|dark|system".into())),
@@ -121,12 +121,12 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"value"?: bool}"#)
.pure(),
CommandSpec::new("view.newWindow", "New Window", "View › Window", |s, _| {
s.ui_requests.push(json!({"open": "newWindow"}));
s.push_ui_request(json!({"open": "newWindow"}));
sel_result(s)
})
.pure(),
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
s.ui_requests.push(json!({"toggle": "split"}));
s.push_ui_request(json!({"toggle": "split"}));
sel_result(s)
})
.key("Mod+Alt+S")
@@ -256,7 +256,7 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult {
s.view.multi_page = false;
}
None => {
s.ui_requests.push(json!({"open": "zoom"}));
s.push_ui_request(json!({"open": "zoom"}));
}
}
Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit}))
+11
View File
@@ -637,12 +637,23 @@ fn doc_bib_style(doc: &Document) -> String {
doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string()
}
/// Most pending front-end requests a session keeps.
pub const MAX_UI_REQUESTS: usize = 64;
/// Maximum undo depth.
const MAX_UNDO: usize = 500;
/// How many steps the Undo button's list shows (#701); `edit.undoTo` reaches no further.
pub const UNDO_LIST_MAX: usize = 100;
impl Session {
/// Queue a request for the front end. A headless session never drains the queue, so only
/// the newest [`MAX_UI_REQUESTS`] are kept.
pub fn push_ui_request(&mut self, req: Value) {
if self.ui_requests.len() >= MAX_UI_REQUESTS {
self.ui_requests.remove(0);
}
self.ui_requests.push(req);
}
/// File name of the persisted custom dictionary inside the settings folder.
pub const USER_DICT_FILE: &str = "custom-dictionary.txt";
+2 -1
View File
@@ -249,6 +249,7 @@ pub fn speech_language(text: &str, explicit: Option<&str>, bidi: Option<&str>) -
/// The primary language subtag of a plain tag, lowercased (`ru-RU` gives `ru`). `None` for tags
/// without a two- or three-letter language, such as Word's `x-none`.
#[cfg(any(unix, windows, test))]
fn primary_tag(tag: &str) -> Option<String> {
let t = plain_tag(tag)?;
t.split('-').next().filter(|p| (2..=3).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphabetic())).map(str::to_ascii_lowercase)
@@ -256,7 +257,7 @@ fn primary_tag(tag: &str) -> Option<String> {
/// The language to ask the speech program for, or `None` to keep its default voice: only when
/// `lang` isn't the language the default voice speaks (`default`, a primary subtag) already.
#[cfg(any(not(target_os = "windows"), test))]
#[cfg(any(unix, test))]
fn voice_language(lang: Option<&str>, default: &str) -> Option<String> {
lang.and_then(primary_tag).filter(|p| !p.eq_ignore_ascii_case(default))
}
+37
View File
@@ -9452,3 +9452,40 @@ fn table_cell_margins_preserves_unpassed_sides() {
let margins = s.doc.table(StoryRef::Body, &tp).unwrap().props.cell_margins.unwrap();
assert_eq!(margins, [0.0, 10.0, 0.0, 15.0]);
}
#[test]
fn headless_ui_requests_stay_bounded() {
let mut s = s();
let big = "x".repeat(1 << 20);
for _ in 0..200 {
run(&mut s, "edit.pasteSpecial", json!({"text": big}));
run(&mut s, "edit.goto", json!({}));
}
assert!(s.ui_requests.len() <= 64, "len = {}", s.ui_requests.len());
let size: usize = s.ui_requests.iter().map(|r| r.to_string().len()).sum();
assert!(size < 4 << 20, "queued {size} bytes");
}
/// `file.exportPng` writes only to `.png` paths, so automation can't replace other files with
/// page images (#3696).
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn export_png_refuses_a_path_that_is_not_png() {
let dir = scratch_dir("export-png-extension");
let mut s = s();
run(&mut s, "text.insert", json!({"text": "Hello"}));
for name in ["notes.txt", "config", "report.docx", "page.png.txt"] {
let target = dir.join(name);
std::fs::write(&target, b"precious user notes").unwrap();
let r = s.run("file.exportPng", &json!({"path": target.to_string_lossy()}));
assert!(matches!(r, Err(crate::CmdError::Params(_))), "{name}: {r:?}");
assert_eq!(std::fs::read(&target).unwrap(), b"precious user notes", "{name} was changed");
}
// A .png path still works, whatever its case.
for name in ["page.png", "PAGE.PNG"] {
let target = dir.join(name);
s.run("file.exportPng", &json!({"path": target.to_string_lossy()})).unwrap();
assert!(std::fs::read(&target).unwrap().starts_with(b"\x89PNG"), "{name} is not a PNG");
}
let _ = std::fs::remove_dir_all(&dir);
}