mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 05:17:25 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)"#),
|
||||
|
||||
@@ -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}]"#)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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", "");
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user