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
+5 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -258,7 +258,9 @@ mod tests {
fn an_instant_cancel_retries_then_falls_back() {
let quick = Duration::from_millis(200);
let slow_cancel = || async {
std::thread::sleep(Duration::from_millis(250));
// The test polls this future by hand, with no async runtime, so a blocking sleep
// models a slow portal response.
std::thread::sleep(Duration::from_millis(250)); // NOSONAR
Vec::<u8>::new()
};
assert_eq!(pick(async { vec![1u8] }, async { vec![2] }, quick), Ok(vec![1]));
+13
View File
@@ -74,6 +74,8 @@ impl Counting {
out.push(u);
}
}
// Trailing zeros of a group are not a gap: the next group starts a new place.
zero_pending = false;
match gi {
0 => out.extend(self.myriads.get(1)),
1 => out.extend(self.myriads.first()),
@@ -324,6 +326,17 @@ pub(crate) fn chosung(n: u32) -> String {
mod tests {
use super::*;
#[test]
fn chinese_no_zero_after_group_trailing_zeros() {
assert_eq!(chinese_counting_thousand(201_000), "二十万一千");
assert_eq!(chinese_counting_thousand(120_000), "十二万");
assert_eq!(chinese_counting_thousand(100_100), "十万零一百");
assert_eq!(taiwanese_counting_thousand(201_000), "二十萬一千");
assert_eq!(chinese_counting_thousand(100_000_001), "一亿零一");
assert_eq!(chinese_counting_thousand(100_010_000), "一亿零一万");
assert_eq!(chinese_counting_thousand(120_000_001), "一亿二千万零一");
}
#[test]
fn chinese_counting_systems() {
let c = |n| chinese_counting(n);
+12
View File
@@ -102,6 +102,9 @@ pub fn parse_number(s: &str, lang: Option<&str>) -> Option<f64> {
return None;
}
let v: f64 = format!("0{int_digits}.{frac_digits}0").parse().ok()?;
if !v.is_finite() {
return None;
}
Some(if neg { -v } else { v })
}
@@ -151,6 +154,15 @@ mod tests {
assert_eq!(format_number(-0.001, pl), "0,00", "a rounded zero has no sign");
}
#[test]
fn overflowing_digit_strings_are_not_numbers() {
assert_eq!(parse_number(&"1".repeat(350), None), None);
assert_eq!(parse_number(&"9".repeat(400), Some("pl-PL")), None);
assert_eq!(parse_number(&format!("-{}", "1".repeat(350)), None), None);
let big = parse_number(&"1".repeat(300), None);
assert!(big.is_some_and(f64::is_finite), "{big:?}");
}
#[test]
fn cuban_and_peruvian_numbers_use_decimal_points() {
for lang in [Some("es-CU"), Some("es-PE")] {
+30 -3
View File
@@ -193,7 +193,8 @@ impl Table {
match idx {
Some((i, true)) => {
if let Some(c) = row.cells.get_mut(i) {
c.props.span += 1;
// From the clamped span: a `.json` file can carry any `u32` (#3637).
c.props.span = (c.span() + 1).min(MAX_COLS) as u32;
}
}
Some((i, false)) => {
@@ -223,7 +224,7 @@ impl Table {
for (i, c) in row.cells.iter_mut().enumerate() {
if g < x + c.span() {
if c.span() > 1 {
c.props.span -= 1;
c.props.span = (c.span() - 1) as u32;
} else {
rm = Some(i);
}
@@ -247,7 +248,7 @@ impl Table {
let removed: Vec<Cell> = row.cells.drain(c0 + 1..=c1).collect();
if let Some(first) = row.cells.get_mut(c0) {
for c in removed {
first.props.span += c.props.span.max(1);
first.props.span = (first.span() + c.span()).min(MAX_COLS) as u32;
let nonempty: Vec<_> = c.blocks.into_iter().filter(|b| b.as_para().is_none_or(|p| !p.is_empty())).collect();
first.blocks.extend(nonempty);
}
@@ -549,6 +550,32 @@ mod tests {
t.delete_col(99);
}
/// #3637: a `.json` file can give a cell any span; inserting a column through it, merging it
/// or deleting a column under it doesn't overflow, and the span it gets is in 1..=MAX_COLS.
#[test]
fn a_huge_span_from_a_file_does_not_overflow() {
let huge = || {
let mut t = Table::new(1, 2, 200.0);
t.rows[0].cells[0].props.span = u32::MAX;
t
};
// Column 1 is inside the spanning cell, so the cell widens.
let mut t = huge();
t.insert_col(1);
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
let mut t = huge();
t.merge_right(0, 0, 1);
assert_eq!(t.rows[0].cells.len(), 1);
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
let mut t = huge();
t.rows[0].cells[1].props.span = u32::MAX;
t.merge_right(0, 0, 1);
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32);
let mut t = huge();
t.delete_col(0);
assert_eq!(t.rows[0].cells[0].props.span, MAX_COLS as u32 - 1);
}
#[test]
fn merge_and_split() {
let mut t = Table::new(2, 3, 300.0);
+20 -4
View File
@@ -9,7 +9,7 @@
//! or not (a repeated name), data is checked before it's copied, and nesting and elements are
//! capped as the XML tree reader caps a part's.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashSet};
use base64::Engine as _;
use quick_xml::events::{BytesStart, Event};
@@ -340,14 +340,21 @@ fn with_scope(frag: &[u8], scope: &[Vec<(String, String)>]) -> Vec<u8> {
let rest = frag.get(open..).unwrap_or_default();
let name_end = rest.iter().position(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/').unwrap_or(rest.len());
let tag_end = rest.iter().position(|b| *b == b'>').unwrap_or(rest.len());
let tag = rest.get(..tag_end).unwrap_or_default();
let tag = String::from_utf8_lossy(rest.get(1..tag_end).unwrap_or_default());
let start = BytesStart::from_content(tag.as_ref(), name_end.saturating_sub(1));
let declared: HashSet<Vec<u8>> = start
.attributes()
.with_checks(false)
.flatten()
.filter(|a| a.key.as_ref() == b"xmlns" || a.key.as_ref().starts_with(b"xmlns:"))
.map(|a| a.key.as_ref().to_vec())
.collect();
let mut out = Vec::with_capacity(rest.len() + 256);
out.extend_from_slice(rest.get(..name_end).unwrap_or_default());
// Innermost declarations win: walk from the data element out, skipping prefixes seen.
let mut seen: Vec<&str> = Vec::new();
for (k, v) in decls.iter().rev() {
let declared = tag.windows(k.len() + 1).any(|w| w.get(..k.len()) == Some(k.as_bytes()) && matches!(w.last(), Some(b'=' | b' ')));
if declared || seen.contains(&k.as_str()) {
if declared.contains(k.as_bytes()) || seen.contains(&k.as_str()) {
continue;
}
seen.push(k);
@@ -463,6 +470,15 @@ mod tests {
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
}
#[test]
fn inherited_namespaces_do_not_rescan_a_long_root_tag() {
let scope = vec![(0..256).map(|i| (format!("xmlns:p{i}"), format!("urn:{i}"))).collect::<Vec<_>>()];
let frag = format!("<w:document{}><w:body/></w:document>", " a=\"1\"".repeat(4_000_000));
let out = with_scope(frag.as_bytes(), &scope);
let out = String::from_utf8(out).unwrap();
assert_eq!(out.matches("xmlns:p").count(), 256);
}
#[test]
fn nesting_is_capped_where_the_xml_tree_reader_stops() {
let inline = |levels: usize| format!("{}<b/>{}", "<a>".repeat(levels), "</a>".repeat(levels));
+11
View File
@@ -256,6 +256,10 @@ fn vml_commands(path: &str) -> Vec<(String, Vec<f64>)> {
};
while let Some(c) = chars.next() {
if c.is_ascii_alphabetic() {
// Cap the commands too, so a path of bare letters can't build a million entries.
if out.len() >= MAX_VML_PARAMS {
break;
}
if !has_num {
push(&mut out, 0.0);
}
@@ -361,6 +365,13 @@ mod tests {
crate::xml::parse(xml.as_bytes()).expect("parse spPr")
}
#[test]
fn a_vml_path_of_bare_letters_builds_a_bounded_command_list() {
let path = "x".repeat(MAX_VML_PATH);
let n = vml_commands(&path).len();
assert!(n <= MAX_VML_PARAMS, "{n} commands");
}
#[test]
fn a_bezier_segment_with_many_points_is_bounded() {
// One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the
+37
View File
@@ -121,6 +121,8 @@ impl<'d> EmbedWriter<'d> {
/// A free part name for (a copy of) `orig`: `orig` itself if free, else `stem_N.ext`.
fn alloc(&mut self, orig: &str) -> String {
let clean = valid_part_name(orig);
let orig = clean.as_str();
// Suffixes cannot escape reserved directories or a .rels extension. Such a kept
// part needs a different base name, otherwise every candidate would be reserved.
let orig = if Self::reserved(&orig.to_ascii_lowercase()) { "word/embeddings/part.xml" } else { orig };
@@ -684,6 +686,29 @@ fn pt(v: f32) -> String {
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
/// `name` as a valid OPC part name: no backslashes, empty or dot segments, segments ending in a
/// dot, or encoded separators.
fn valid_part_name(name: &str) -> String {
let mut out = Vec::new();
for seg in name.split('/') {
let mut seg = seg.replace('\\', "_");
for enc in ["%2f", "%5c"] {
while let Some(i) = seg.to_ascii_lowercase().find(enc) {
seg.replace_range(i..i + 3, "_");
}
}
if seg.is_empty() || seg == "." || seg == ".." {
continue;
}
// A segment can't end in a dot either (`word/a.`).
if seg.ends_with('.') {
seg = format!("{}_", seg.trim_end_matches('.'));
}
out.push(seg);
}
if out.is_empty() { "word/embeddings/part.xml".to_string() } else { out.join("/") }
}
#[cfg(test)]
mod tests {
use super::*;
@@ -714,4 +739,16 @@ mod tests {
// Unturned: no rotation or flip left behind.
assert_eq!(vml_style_with("rotation:90;flip:y;width:1pt", 2.0, 3.0, &Float::default()), "width:2pt;height:3pt");
}
#[test]
fn kept_part_names_are_valid_opc_names() {
let doc = wordcraft_doc::Document::default();
let mut w = EmbedWriter::new(&doc);
for orig in ["word/a\\b.bin", "word/..\\..\\x.bin", "word/a/../b%2Fc.bin", "word/a//./b%5Cc.bin", "word/a./b.bin", "word/a..."] {
let name = w.alloc(orig);
assert!(!name.contains('\\'), "{name}");
assert!(!name.to_ascii_lowercase().contains("%2f") && !name.to_ascii_lowercase().contains("%5c"), "{name}");
assert!(name.split('/').all(|s| !s.is_empty() && !s.ends_with('.')), "{name}");
}
}
}
+4
View File
@@ -116,6 +116,9 @@ pub(crate) struct Writer<'d> {
used_media: std::collections::BTreeSet<String>,
/// Bounds writing text boxes inside text boxes (as layout shows them).
boxes: wordcraft_doc::BoxBudget,
/// Text box stories already written: a story shown by many shapes is written once, the other
/// shapes get an empty box, so the file doesn't grow as shapes × story size.
written_boxes: std::collections::HashSet<u32>,
/// Charts, diagrams and OLE objects kept from a file: the parts they need.
embeds: embed::EmbedWriter<'d>,
/// Linked text boxes: part id → (`wps:txbx`/`wps:linkedTxbx` id, seq; 0 for the box holding
@@ -175,6 +178,7 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
inline_controls: Vec::new(),
used_media: Default::default(),
boxes: wordcraft_doc::BoxBudget::default(),
written_boxes: std::collections::HashSet::new(),
embeds: embed::EmbedWriter::new(doc),
txbx_links: doc
.text_box_chains()
+29 -2
View File
@@ -153,11 +153,26 @@ struct Child {
end: usize,
}
/// Byte index of the `>` that ends the tag at the start of `s`, skipping quoted attribute values.
fn tag_end(s: &str) -> Option<usize> {
let mut quote = None;
for (i, c) in s.char_indices() {
match (quote, c) {
(Some(q), c) if c == q => quote = None,
(Some(_), _) => {}
(None, '"' | '\'') => quote = Some(c),
(None, '>') => return Some(i),
_ => {}
}
}
None
}
/// The root start tag's span end and the root's direct children, or `None` for anything the
/// scanner doesn't expect (CDATA, DOCTYPE, a different root).
fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec<Child>)> {
let open = xml.find(&format!("<{root}"))?;
let open_end = open + xml.get(open..)?.find('>')? + 1;
let open_end = open + tag_end(xml.get(open..)?)? + 1;
if xml.get(open_end - 2..open_end)? == "/>" {
return None;
}
@@ -182,7 +197,7 @@ fn children(xml: &str, root: &str) -> Option<(usize, usize, Vec<Child>)> {
i = at + tail.find("?>")? + 2;
continue;
}
let gt = at + tail.find('>')? + 1;
let gt = at + tag_end(tail)? + 1;
let tag = xml.get(at..gt)?;
if tag.starts_with("</") {
depth = depth.checked_sub(1)?;
@@ -343,6 +358,18 @@ mod tests {
assert!(merge(br#"<w:settings xmlns:x="x"><w:rsids/></w:settings>"#, GEN.as_bytes()).is_none());
}
#[test]
fn a_gt_inside_an_attribute_value_does_not_hide_the_kept_settings() {
for v in [r#"w:val="x>y""#, "w:val='x>y'"] {
let orig = format!(
r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:docVar w:name="a" {v}/><w:docVar w:name="b" {v}></w:docVar></w:settings>"#
);
assert!(has_unowned(orig.as_bytes()), "{v}");
let out = String::from_utf8(merge(orig.as_bytes(), GEN.as_bytes()).unwrap()).unwrap();
assert!(out.contains(r#"w:name="a""#) && out.contains(r#"w:name="b""#), "{v}");
}
}
#[test]
fn a_compat_block_without_a_mode_gets_the_generated_one() {
let orig = r#"<w:settings xmlns:w="w" xmlns:m="m" xmlns:r="r"><w:compat><w:useFELayout/></w:compat></w:settings>"#;
+6 -2
View File
@@ -1126,8 +1126,12 @@ impl Writer<'_> {
None => w.open("wps:txbx", &[]),
}
w.open("w:txbxContent", &[]);
let blocks = part.blocks.clone();
self.blocks(w, &blocks, rels, false, depth + 1);
if self.written_boxes.insert(id) {
let blocks = part.blocks.clone();
self.blocks(w, &blocks, rels, false, depth + 1);
} else {
w.empty("w:p", &[]);
}
w.close("w:txbxContent");
w.close("wps:txbx");
self.boxes.leave();
+13
View File
@@ -234,3 +234,16 @@ fn picture_in_box_keeps_its_frame() {
let p = read.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
assert!(matches!(&p.objects[0], InlineObject::Shape { w, h, .. } if *w == 144.0 && *h == 72.0));
}
#[test]
fn story_shared_by_many_shapes_is_written_once() {
let mut d = Document::from_text("x");
let text = "abcdefghij".repeat(5_000);
let o = text_box(&mut d, &text, TextBody::default());
for _ in 0..500 {
d.insert_object(&wordcraft_doc::Pos::body(0, 0), o.clone(), &Default::default()).unwrap();
}
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
assert!(xml.len() < 2_000_000, "document.xml is {} bytes", xml.len());
assert_eq!(xml.matches(text.as_str()).count(), 1);
}
+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);
}
+6
View File
@@ -55,6 +55,7 @@ fn x(s: &str) -> String {
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\'' => o.push_str("&apos;"),
'\u{FFFE}' | '\u{FFFF}' => {}
c if c.is_control() && !matches!(c, '\t' | '\n') => {}
_ => o.push(c),
}
@@ -2725,6 +2726,11 @@ fn apply_layout(a: &Attrs, sec: &mut SectionProps) {
mod tests {
use super::*;
#[test]
fn escaper_drops_noncharacters() {
assert_eq!(x("a\u{FFFF}b\u{FFFE}c\u{1}d\te"), "abcd\te");
}
#[test]
fn lengths() {
assert_eq!(length("1in"), Some(72.0));
+2 -1
View File
@@ -3791,7 +3791,8 @@ fn place_para_in_flow(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block:
if pl.rp.keep_lines || (pl.rp.keep_next && pb.y + height <= pb.bottom + 0.01) {
let need = height + if pl.rp.keep_next { next_first_line(ctx, block, width, 0, &mut ctx.counters.clone()) } else { 0.0 };
// (Against the whole column: balanced columns grow to hold it.)
if pb.y + need > pb.bottom && !pb.at_top() && need <= pb.uncapped_bottom() - pb.top {
// With the 0.01 pt of slack the other fit checks take.
if pb.y + need > pb.bottom + 0.01 && !pb.at_top() && need <= pb.uncapped_bottom() - pb.top {
pb.advance(block, body_top);
pb.y += pl.rp.space_before.min(0.0);
applied_before = pl.rp.space_before.min(0.0);
+10 -2
View File
@@ -646,6 +646,11 @@ pub(crate) fn ligature_features(l: wordcraft_doc::props::Ligatures) -> [wordcraf
[f(b"liga", l.standard), f(b"clig", l.contextual), f(b"hlig", l.historical), f(b"dlig", l.discretional)]
}
/// A Khmer letter, sign or digit (U+1780–U+17FF, Khmer Symbols U+19E0–U+19FF).
fn is_khmer(c: char) -> bool {
matches!(u32::from(c), 0x1780..=0x17FF | 0x19E0..=0x19FF)
}
/// Whether `c`'s capital is more than one character (ß → SS), so All Caps draws more
/// characters than the document holds.
fn caps_expands(c: char) -> bool {
@@ -1060,7 +1065,10 @@ impl<'a> Builder<'a> {
// The shaper kerns by default; Word kerns only when Kerning for fonts covers the size.
// Without `w14:ligatures` the shaper's default (standard and contextual) stays.
let [kern_off] = no_kern;
let all = match rc.ligatures {
// Khmer shaping builds its syllables (pre-base vowels, stacked signs) in `liga` and
// `clig`, so a run's ligature setting never reaches Khmer text: Word ignores it there.
let ligatures = rc.ligatures.filter(|_| !shape_text.chars().any(is_khmer));
let all = match ligatures {
Some(l) => {
let [a, b, c, d] = ligature_features(l);
[kern_off, a, b, c, d]
@@ -1068,7 +1076,7 @@ impl<'a> Builder<'a> {
None => [kern_off; 5],
};
let from = usize::from(rc.kerned());
let to = if rc.ligatures.is_some() { all.len() } else { 1 };
let to = if ligatures.is_some() { all.len() } else { 1 };
let features: &[wordcraft_fonts::Feature] = all.get(from..to).unwrap_or_default();
let shaped = if use_levels {
wordcraft_fonts::shape_run(&st.face, shape_text, features, map, key.rtl)
+17
View File
@@ -10688,3 +10688,20 @@ fn layout_cache_invalidates_on_changed_filepath() {
let text_b = shown(&l_b);
assert_eq!(text_b, "/path/two/a.docx", "second layout with same cache shows /path/two/a.docx");
}
/// Keep with next takes the same 0.01 pt of slack as the other fit checks: a kept paragraph and
/// the next one's first line that fill the page to within it stay on the page.
#[test]
fn keep_with_next_fits_to_the_hundredth_of_a_point() {
use wordcraft_doc::props::LineSpacing;
let exact =
|h: f32| ParaProps { space_before: Some(0.0), space_after: Some(0.0), line_spacing: Some(LineSpacing::Exactly(h)), ..Default::default() };
let mut d = Document::from_text("Filler\nKept\nNext");
let body = d.last_section.page_h - d.last_section.margin_top - d.last_section.margin_bottom;
d.para_mut(StoryRef::Body, &Path::top(0)).unwrap().props = exact(body - 24.0 + 0.005);
d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().props = ParaProps { keep_next: Some(true), ..exact(12.0) };
d.para_mut(StoryRef::Body, &Path::top(2)).unwrap().props = exact(12.0);
let l = lay(&d);
assert_eq!(l.caret(&Pos::body(1, 0)).unwrap().page, 0, "the kept paragraph stays on the first page");
assert_eq!(l.caret(&Pos::body(2, 0)).unwrap().page, 0, "and so does the next one's first line");
}
+18
View File
@@ -266,3 +266,21 @@ fn a_missing_roman_thai_font_is_not_drawn_in_cambria() {
assert_ne!(face(true).id(), cambria.id());
assert_eq!(face(true).id(), face(false).id());
}
/// `w14:ligatures` (Word writes `historicalDiscretional` on whole Khmer documents) turns `liga`
/// and `clig` off, which breaks Khmer OS syllables: Khmer text is shaped the same either way.
#[test]
fn khmer_ignores_the_ligature_setting() {
use wordcraft_doc::props::{CharProps, Ligatures};
let text = "\u{179F}\u{17CA}\u{17BE}\u{1794}\u{17A2}\u{1784}\u{17D2}\u{1780}\u{17C1}\u{178F} \u{1795}\u{17D2}\u{178F}\u{1793}\u{17D2}\u{1791}\u{17B6}\u{1791}\u{17C4}\u{179F}";
let gids = |ligatures: Option<Ligatures>| {
let chr = CharProps { font: Some("Khmer OS Siemreap".into()), size: Some(12.0), ligatures, ..Default::default() };
let mut d = Document::new();
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))];
let l = lay(&d);
para_layout(&l, 0).glyphs.iter().map(|g| g.gid).collect::<Vec<_>>()
};
let hd = Ligatures { historical: true, discretional: true, ..Default::default() };
assert_eq!(gids(Some(hd)), gids(None));
assert_eq!(gids(Ligatures::from_ooxml("none")), gids(None));
}
+33 -1
View File
@@ -1657,6 +1657,12 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
r
}
/// The Font dialog preview's size: the Size box's points between 6 and 72, else 12. `"NaN"` and
/// `"inf"` parse as floats too, and a NaN font size trips epaint's glyph assertion (#3663).
fn preview_size(size: &str) -> f32 {
size.trim().parse::<f32>().ok().filter(|pt| pt.is_finite()).unwrap_or(12.0).clamp(6.0, 72.0)
}
/// The character properties the Font dialog's fields stand for.
fn font_props(d: &Dialog) -> serde_json::Map<String, Value> {
let Dialog::Font {
@@ -1784,7 +1790,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
..
} => {
// Build the sample text format for the preview pane below.
let preview_size = size.trim().parse::<f32>().unwrap_or(12.0).clamp(6.0, 72.0);
let preview_size = preview_size(size);
let preview_color = wordcraft_doc::Rgb::parse(color).map(crate::theme::c32).unwrap_or_else(|| ui.visuals().text_color());
let mut fmt = egui::TextFormat {
font_id: egui::FontId::new(preview_size, egui::FontFamily::Proportional),
@@ -4585,6 +4591,32 @@ mod tests {
}
}
/// #3663: "NaN" in the Font dialog's Size box parses as a float; the preview draws at 12 pt
/// instead of a NaN size, which tripped epaint's glyph assertion and closed the app.
#[test]
fn font_dialog_preview_ignores_a_size_that_is_not_a_number() {
for (typed, pt) in [("NaN", 12.0), ("nan", 12.0), ("inf", 12.0), ("-infinity", 12.0), ("10.5", 10.5), ("200", 72.0), ("", 12.0)] {
assert_eq!(preview_size(typed), pt, "{typed:?}");
}
let mut app = app_with("Example");
let _ = app.run("select.all", json!({}));
app.dialog = Dialog::open("font", &mut app);
let Some(Dialog::Font { size, .. }) = &mut app.dialog else { panic!("no font dialog") };
*size = "NaN".into();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
|ui, app: &mut WordApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..4 {
h.step();
}
assert!(matches!(&h.state().dialog, Some(Dialog::Font { size, .. }) if size == "NaN"), "the dialog stays open");
}
#[test]
fn font_dialog_ok_keeps_what_the_user_left_alone() {
// Issue #466: OK sent every field, clearing small caps, spacing and double underline
+39 -3
View File
@@ -721,7 +721,15 @@ fn language_picker(app: &mut WordApp, ui: &mut Ui) {
pub fn lenient<T: Serialize + DeserializeOwned + Default>(input: &Value) -> T {
let mut base = serde_json::to_value(T::default()).unwrap_or(Value::Null);
if base.is_object() {
overlay::<T>(&mut base, &mut Vec::new(), input, 0);
let mut extra = Vec::new();
overlay::<T>(&mut base, &mut Vec::new(), input, 0, &mut extra);
// Keys with no slot in the defaults (write-only legacy settings, or unknown ones) are
// kept only after the loop, so `base` stays the size of `T` while each key is checked.
for (path, k, v) in extra {
if let Some(parent) = object_at(&mut base, &path) {
parent.insert(k, v);
}
}
}
serde_json::from_value(base).unwrap_or_default()
}
@@ -735,18 +743,34 @@ fn object_at<'a>(root: &'a mut Value, path: &[String]) -> Option<&'a mut Map<Str
v.as_object_mut()
}
fn overlay<T: DeserializeOwned>(root: &mut Value, path: &mut Vec<String>, input: &Value, depth: usize) {
fn overlay<T: DeserializeOwned>(
root: &mut Value,
path: &mut Vec<String>,
input: &Value,
depth: usize,
extra: &mut Vec<(Vec<String>, String, Value)>,
) {
let Some(map) = input.as_object() else { return };
for (k, v) in map {
let Some(parent) = object_at(root, path) else { return };
let old = parent.get(k).cloned();
if depth < 4 && v.is_object() && old.as_ref().is_some_and(Value::is_object) {
path.push(k.clone());
overlay::<T>(root, path, v, depth + 1);
overlay::<T>(root, path, v, depth + 1, extra);
path.pop();
continue;
}
parent.insert(k.clone(), v.clone());
if old.is_none() {
let ok = serde_json::from_value::<T>(root.clone()).is_ok();
if let Some(parent) = object_at(root, path) {
parent.remove(k);
}
if ok {
extra.push((path.clone(), k.clone(), v.clone()));
}
continue;
}
if serde_json::from_value::<T>(root.clone()).is_err()
&& let Some(parent) = object_at(root, path)
{
@@ -810,6 +834,18 @@ mod tests {
use crate::theme::Appearance;
use crate::{Services, UiState, WordApp};
#[test]
fn unknown_settings_keys_load_in_linear_time() {
let mut map = serde_json::Map::new();
for i in 0..20_000 {
map.insert(format!("unknownKey{i}"), json!(i));
}
map.insert("miniToolbar".into(), json!(false));
let bytes = serde_json::to_vec(&serde_json::Value::Object(map)).unwrap();
let state = UiState::from_json(&bytes).unwrap();
assert!(!state.mini_toolbar);
}
fn app() -> WordApp {
WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default())
}
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity with Microsoft Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** trivial (a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `<blockquote>` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
share of a typical real-world file's content and formatting that survives (read: shows correctly;
@@ -68,7 +68,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
| `.tex` LaTeX | — | — | ✅ | ✅ | — | — | `crates/formats/src/tests.rs` | Beyond Word (#11), incl. math |
| Pictures for Insert › Picture, drag-and-drop and paste | — | R | ✅ | — | — | — | `insert_picture_takes_a_windows_metafile` (engine), `paste_picture.rs` | PNG, JPEG, GIF, WebP, BMP, and Windows Metafile `.wmf` / Enhanced Metafile `.emf` at their own frame size (#802), drawn by `crates/metafile`. Not yet: SVG, TIFF, ICO, HEIC |
| `.png` page images | — | (Save as picture, Mac) | — | ✅ | — | — | `io_ext.rs` | |
| `.json` document model | — | — | ✅ | ✅ | — | — | engine tests | For agents |
| `.json` document model | — | — | ✅ | ✅ | — | — | engine tests | For agents. A table cell's span is any number in the file; Insert Column and Merge Cells count it as at most 63 columns, as the `.docx` reader does (#3637) |
## DOCX in depth
@@ -416,3 +416,4 @@ Imported text fields use the first nonempty cached result run's formatting on th
| 2026-10-18 | trivial | Word 2007 WordArt (VML `v:textpath` in the body or a header logo) shown as a text box with its text, font, colour and warp, not dropped (#2875) |
| 2026-10-20 | trivial | A folder that keeps no Unix modes (a NAS share mounted by GVFS) still takes a save instead of failing with "Operation not supported" (#3003) |
| 2026-10-16 | trivial | Word's Table of Contents content control (`w:docPartObj`) is kept around a TOC that isn't in the heading-and-entries form (#2427) |
| 2026-10-23 | trivial | `.json`: a table cell span past 63 columns no longer overflows when a column is inserted through the cell or the cell is merged (#3637) |
+2 -1
View File
File diff suppressed because one or more lines are too long