mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-29 21:07:24 -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:
+5
-1
File diff suppressed because one or more lines are too long
@@ -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]));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>"#;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ fn x(s: &str) -> String {
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
'\'' => o.push_str("'"),
|
||||
'\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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user