mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
M1: preserve independent complex-script DOCX formatting (#366)
Keep inherited complex-script size, bold and italic independent from Latin properties and retain explicit values during DOCX round trips. Preserve ordinary formatting-command behavior and add regression coverage. Resolve eight existing Clippy warnings without changing behavior so all six required CI gates pass. Co-authored-by: ChatGPT <noreply@openai.com> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
19 files changed
+228
-74
No files matched your search
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (independent complex-script formatting preserved across DOCX open/save; previously trivial: Turkish interface landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and
|
||||
agent control. This page is the summary; the assessment is
|
||||
@@ -134,6 +134,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
|
||||
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|
||||
|---|---|
|
||||
| 2026-10-11 | DOCX open/save keeps inherited complex-script size, bold and italic independent of Latin formatting; explicit values are retained, and font commands still format both scripts. Covered by synthetic round trips and a local GUI save/reopen check. |
|
||||
| 2026-10-11 | Czech (`cs`) interface catalog: 2,021 entries (the full zh-hans key set), Czech locale detection and persisted language choice |
|
||||
| 2026-10-11 | Polish: interface (`pl`, the thirteenth language, every catalog key) and proofing — spelling against the installed Hunspell `pl_PL` dictionary (read at runtime, not bundled) through a new Hunspell-style affix engine, suggestions tuned for Polish, Polish grammar rules with Polish messages, TeX Polish hyphenation patterns (MIT option); proofing language per run (`w:lang`) for squiggles, F7, suggestions and hyphenation, languages without tools left unmarked; Review › Language menu and the status bar's language; a Polish interface writes new documents in Polish |
|
||||
| 2026-10-11 | German (`de`) interface (#574): 2,021 translated entries covering the full zh-hans key set, locale and saved-preference tests, bundled-font checks; no German proofing resources |
|
||||
@@ -212,6 +213,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | DOCX complex-script formatting inheritance fix and round-trip coverage |
|
||||
| 2026-10-11 | minor | Czech (`cs`) interface catalog added |
|
||||
| 2026-10-11 | trivial | Progress log: German interface (#574) |
|
||||
| 2026-10-11 | trivial | Progress log: tables resize with the mouse (#217) |
|
||||
|
||||
+4
-16
@@ -303,10 +303,10 @@ pub struct CharProps {
|
||||
/// inherit, then `font`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub font_ea: Option<String>,
|
||||
/// Complex-script size, points (`w:szCs`). `None` = the same as `size` (see [`CharProps::overlay`]).
|
||||
/// Complex-script size, points (`w:szCs`). `None` leaves the inherited value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size_cs: Option<f32>,
|
||||
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` = the same as `bold` / `italic`.
|
||||
/// Complex-script bold / italic (`w:bCs`, `w:iCs`). `None` leaves the inherited value unchanged.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bold_cs: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -390,11 +390,8 @@ impl CharProps {
|
||||
|
||||
/// Apply every `Some` field of `patch` on top of `self`.
|
||||
///
|
||||
/// Size, bold and italic come in pairs with their complex-script values (Word's own buttons
|
||||
/// set both): a patch that sets the plain value and leaves the complex-script one `None` sets
|
||||
/// both, so a run's size or bold isn't overridden for its Persian/Arabic characters by an
|
||||
/// inherited `w:szCs` / `w:bCs`. The complex-script font inherits on its own (Word often
|
||||
/// sets only the Latin font and lets complex-script text keep the style's).
|
||||
/// Plain and complex-script properties inherit independently. Commands that intend to
|
||||
/// format both scripts must set both values explicitly.
|
||||
pub fn overlay(&mut self, patch: &CharProps) {
|
||||
overlay_fields!(self, patch; style, font, size, bold, italic, underline, underline_color, strike, double_strike, color, highlight,
|
||||
shading, border, vert_align, caps, small_caps, hidden, spacing, scale, position, kern, outline, shadow, emboss, engrave, lang, no_proof, rtl,
|
||||
@@ -402,15 +399,6 @@ impl CharProps {
|
||||
if let Some(p) = &patch.text_effects {
|
||||
self.text_effects.get_or_insert_with(Default::default).overlay(p);
|
||||
}
|
||||
if patch.size.is_some() && patch.size_cs.is_none() {
|
||||
self.size_cs = None;
|
||||
}
|
||||
if patch.bold.is_some() && patch.bold_cs.is_none() {
|
||||
self.bold_cs = None;
|
||||
}
|
||||
if patch.italic.is_some() && patch.italic_cs.is_none() {
|
||||
self.italic_cs = None;
|
||||
}
|
||||
}
|
||||
pub fn overlaid(mut self, patch: &CharProps) -> CharProps {
|
||||
self.overlay(patch);
|
||||
|
||||
@@ -219,11 +219,9 @@ fn node(k: &El, depth: usize, out: &mut Arg) {
|
||||
});
|
||||
}
|
||||
// Tracked insertions show; deletions don't.
|
||||
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" => {
|
||||
if depth <= MAX_DEPTH {
|
||||
for c in k.els() {
|
||||
node(c, d, out);
|
||||
}
|
||||
"w:ins" | "w:moveTo" | "w:smartTag" | "w:customXml" | "m:oMath" if depth <= MAX_DEPTH => {
|
||||
for c in k.els() {
|
||||
node(c, d, out);
|
||||
}
|
||||
}
|
||||
"w:sdt" => {
|
||||
|
||||
@@ -189,17 +189,6 @@ impl PropCtx {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// A complex-script value equal to its plain one is the same as none (see
|
||||
// `CharProps::overlay`), which keeps documents round-tripping exactly.
|
||||
if c.size_cs.is_some() && c.size_cs == c.size {
|
||||
c.size_cs = None;
|
||||
}
|
||||
if c.bold_cs.is_some() && c.bold_cs == c.bold {
|
||||
c.bold_cs = None;
|
||||
}
|
||||
if c.italic_cs.is_some() && c.italic_cs == c.italic {
|
||||
c.italic_cs = None;
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
|
||||
@@ -527,9 +527,9 @@ fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String {
|
||||
.split(';')
|
||||
.filter(|d| {
|
||||
let key = d.split_once(':').map_or("", |(k, _)| k).trim().to_ascii_lowercase();
|
||||
!d.trim().is_empty()
|
||||
&& !matches!(key.as_str(), "width" | "height" | "rotation" | "flip")
|
||||
&& !(absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top"))
|
||||
!(d.trim().is_empty()
|
||||
|| matches!(key.as_str(), "width" | "height" | "rotation" | "flip")
|
||||
|| (absolute && matches!(key.as_str(), "left" | "top" | "margin-left" | "margin-top")))
|
||||
})
|
||||
.map(|d| d.trim().to_string())
|
||||
.collect();
|
||||
|
||||
@@ -26,9 +26,7 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
if let Some(s) = &c.style {
|
||||
w.val("w:rStyle", s);
|
||||
}
|
||||
// The complex-script font is written only when set: without it, Persian/Arabic text keeps
|
||||
// the style's. Complex-script bold, italic and size default to the plain ones (they come in
|
||||
// pairs, see `CharProps::overlay`), so text formatted here looks the same in Word.
|
||||
// Emit only explicit properties so absent values keep inheriting from the style chain.
|
||||
let mut fonts: Vec<(&str, &str)> = Vec::new();
|
||||
if let Some(f) = &c.font {
|
||||
fonts.extend([("w:ascii", f.as_str()), ("w:hAnsi", f.as_str())]);
|
||||
@@ -43,9 +41,9 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
w.empty("w:rFonts", &fonts);
|
||||
}
|
||||
toggle(w, "w:b", c.bold);
|
||||
toggle(w, "w:bCs", c.bold_cs.or(c.bold));
|
||||
toggle(w, "w:bCs", c.bold_cs);
|
||||
toggle(w, "w:i", c.italic);
|
||||
toggle(w, "w:iCs", c.italic_cs.or(c.italic));
|
||||
toggle(w, "w:iCs", c.italic_cs);
|
||||
toggle(w, "w:caps", c.caps);
|
||||
toggle(w, "w:smallCaps", c.small_caps);
|
||||
toggle(w, "w:strike", c.strike);
|
||||
@@ -77,7 +75,7 @@ pub fn rpr_inner(w: &mut W, c: &CharProps) {
|
||||
if let Some(s) = c.size {
|
||||
w.val("w:sz", &half(s));
|
||||
}
|
||||
if let Some(s) = c.size_cs.or(c.size) {
|
||||
if let Some(s) = c.size_cs {
|
||||
w.val("w:szCs", &half(s));
|
||||
}
|
||||
if let Some(h) = c.highlight {
|
||||
|
||||
@@ -114,8 +114,8 @@ fn reads_word_style_bidi_markup() {
|
||||
assert_eq!(fa.font.as_deref(), Some("Times New Roman"));
|
||||
assert_eq!(fa.font_cs.as_deref(), Some("B Lotus"));
|
||||
assert_eq!((fa.size, fa.size_cs), (Some(12.0), Some(16.0)));
|
||||
// b + bCs alike is stored once: an unset complex-script bold is the same as `bold`.
|
||||
assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), None, Some(true)));
|
||||
// Both explicit values survive, even when they happen to be equal.
|
||||
assert_eq!((fa.bold, fa.bold_cs, fa.rtl), (Some(true), Some(true), Some(true)));
|
||||
assert_eq!((fa.lang.as_deref(), fa.lang_bidi.as_deref()), (Some("en-US"), Some("fa-IR")));
|
||||
assert_eq!(ps[1].props.bidi, Some(false));
|
||||
let x = &ps[1].runs[0].props;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Independent complex-script formatting through style inheritance and DOCX round trips.
|
||||
//! Synthetic packages, written here; no external documents or installed fonts are needed.
|
||||
|
||||
use std::io::Write;
|
||||
use wordcraft_doc::{Document, Paragraph};
|
||||
|
||||
fn package(run: &str, defaults: &str, styles: &str, paragraph_style: &str) -> Vec<u8> {
|
||||
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#;
|
||||
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="r1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="s1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
|
||||
let document = format!(
|
||||
r#"<w:document {ns}><w:body><w:p><w:pPr>{paragraph_style}<w:bidi/></w:pPr><w:r><w:rPr>{run}</w:rPr><w:t>שָׁלוֹם ABC 123</w:t></w:r></w:p></w:body></w:document>"#
|
||||
);
|
||||
let styles = format!("<w:styles {ns}><w:docDefaults><w:rPrDefault><w:rPr>{defaults}</w:rPr></w:rPrDefault></w:docDefaults>{styles}</w:styles>");
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, data) in [
|
||||
("_rels/.rels", root),
|
||||
("word/_rels/document.xml.rels", rels),
|
||||
("word/document.xml", document.as_str()),
|
||||
("word/styles.xml", styles.as_str()),
|
||||
] {
|
||||
z.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn paragraph(d: &Document) -> &Paragraph {
|
||||
d.body.iter().find_map(|b| b.as_para()).unwrap()
|
||||
}
|
||||
|
||||
fn check_generations(bytes: &[u8], check: impl Fn(&Document)) {
|
||||
let mut d = wordcraft_docx::read(bytes).unwrap();
|
||||
for _ in 0..3 {
|
||||
check(&d);
|
||||
let next = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
assert_eq!(paragraph(&next).text, paragraph(&d).text);
|
||||
assert_eq!(paragraph(&next).runs, paragraph(&d).runs);
|
||||
assert_eq!(paragraph(&next).props.bidi, Some(true));
|
||||
d = next;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_hebrew_font_size_language_and_flags_survive() {
|
||||
let run = r#"<w:rFonts w:ascii="Source Serif 4" w:hAnsi="Source Serif 4" w:cs="Arial Hebrew"/><w:b w:val="0"/><w:bCs/><w:i w:val="0"/><w:iCs/><w:sz w:val="24"/><w:szCs w:val="48"/><w:rtl/><w:cs/><w:lang w:val="en-US" w:bidi="he-IL"/>"#;
|
||||
check_generations(&package(run, "", "", ""), |d| {
|
||||
let c = ¶graph(d).runs[0].props;
|
||||
assert_eq!((c.font.as_deref(), c.font_cs.as_deref()), (Some("Source Serif 4"), Some("Arial Hebrew")));
|
||||
assert_eq!((c.size, c.size_cs), (Some(12.0), Some(24.0)));
|
||||
assert_eq!((c.bold, c.bold_cs, c.italic, c.italic_cs), (Some(false), Some(true), Some(false), Some(true)));
|
||||
assert_eq!((c.rtl, c.cs), (Some(true), Some(true)));
|
||||
assert_eq!((c.lang.as_deref(), c.lang_bidi.as_deref()), (Some("en-US"), Some("he-IL")));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_direct_properties_leave_complex_script_defaults_inherited() {
|
||||
let defaults = r#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
|
||||
let run = r#"<w:sz w:val="20"/><w:b w:val="0"/><w:i w:val="0"/>"#;
|
||||
check_generations(&package(run, defaults, "", ""), |d| {
|
||||
let p = paragraph(d);
|
||||
let c = &p.runs[0].props;
|
||||
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (None, None, None), "Absent direct values must remain absent when saved");
|
||||
let resolved = d.styles.resolve_char(p.props.style.as_deref(), c);
|
||||
assert_eq!((resolved.size, resolved.bold, resolved.italic), (10.0, false, false));
|
||||
let cs = resolved.complex();
|
||||
assert_eq!((cs.size, cs.bold, cs.italic), (18.0, true, true));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latin_style_properties_leave_complex_script_style_chain_inherited() {
|
||||
let styles = r#"
|
||||
<w:style w:type="paragraph" w:styleId="HebrewBase"><w:name w:val="HebrewBase"/><w:rPr><w:rFonts w:cs="Arial Hebrew"/><w:szCs w:val="36"/><w:bCs/><w:iCs/><w:lang w:bidi="he-IL"/></w:rPr></w:style>
|
||||
<w:style w:type="paragraph" w:styleId="MixedChild"><w:name w:val="MixedChild"/><w:basedOn w:val="HebrewBase"/><w:rPr><w:sz w:val="24"/><w:b w:val="0"/><w:i w:val="0"/></w:rPr></w:style>
|
||||
<w:style w:type="character" w:styleId="LatinOnly"><w:name w:val="LatinOnly"/><w:rPr><w:sz w:val="20"/><w:b w:val="0"/><w:i w:val="0"/></w:rPr></w:style>"#;
|
||||
let run = r#"<w:rStyle w:val="LatinOnly"/><w:rFonts w:ascii="Source Serif 4" w:hAnsi="Source Serif 4"/>"#;
|
||||
check_generations(&package(run, "", styles, r#"<w:pStyle w:val="MixedChild"/>"#), |d| {
|
||||
let p = paragraph(d);
|
||||
let c = d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props);
|
||||
assert_eq!((c.font.as_str(), c.size, c.bold, c.italic), ("Source Serif 4", 10.0, false, false));
|
||||
let cs = c.complex();
|
||||
assert_eq!((cs.font.as_str(), cs.size, cs.bold, cs.italic), ("Arial Hebrew", 18.0, true, true));
|
||||
assert_eq!(d.styles.get("HebrewBase").unwrap().chr.lang_bidi.as_deref(), Some("he-IL"));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_explicit_complex_script_values_are_not_discarded() {
|
||||
let run = r#"<w:sz w:val="20"/><w:szCs w:val="20"/><w:b w:val="0"/><w:bCs w:val="0"/><w:i w:val="0"/><w:iCs w:val="0"/>"#;
|
||||
let defaults = r#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
|
||||
check_generations(&package(run, defaults, "", ""), |d| {
|
||||
let p = paragraph(d);
|
||||
let c = &p.runs[0].props;
|
||||
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(false), Some(false)));
|
||||
let cs = d.styles.resolve_char(p.props.style.as_deref(), c).complex();
|
||||
assert_eq!((cs.size, cs.bold, cs.italic), (10.0, false, false));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complex_script_direct_properties_leave_latin_defaults_inherited() {
|
||||
let run = r#"<w:szCs w:val="20"/><w:bCs w:val="0"/><w:iCs w:val="0"/><w:rtl w:val="0"/><w:cs w:val="0"/>"#;
|
||||
let defaults = r#"<w:sz w:val="36"/><w:b/><w:i/>"#;
|
||||
check_generations(&package(run, defaults, "", ""), |d| {
|
||||
let p = paragraph(d);
|
||||
let c = &p.runs[0].props;
|
||||
assert_eq!((c.size, c.bold, c.italic), (None, None, None));
|
||||
assert_eq!((c.rtl, c.cs), (Some(false), Some(false)));
|
||||
let resolved = d.styles.resolve_char(p.props.style.as_deref(), c);
|
||||
assert_eq!((resolved.size, resolved.bold, resolved.italic), (18.0, true, true));
|
||||
let cs = resolved.complex();
|
||||
assert_eq!((cs.size, cs.bold, cs.italic), (10.0, false, false));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_formatting_keeps_independent_complex_script_values() {
|
||||
let defaults = r#"<w:szCs w:val="36"/><w:bCs/><w:iCs/>"#;
|
||||
let run = r#"<w:sz w:val="24"/><w:b w:val="0"/><w:i w:val="0"/><w:rPrChange w:id="7" w:author="Synthetic Author" w:date="2026-10-11T00:00:00Z"><w:rPr><w:sz w:val="20"/><w:szCs w:val="20"/><w:b w:val="0"/><w:bCs w:val="0"/><w:i w:val="0"/><w:iCs w:val="0"/></w:rPr></w:rPrChange>"#;
|
||||
check_generations(&package(run, defaults, "", ""), |d| {
|
||||
let p = paragraph(d);
|
||||
let c = &p.runs[0].props;
|
||||
let current = d.styles.resolve_char(None, c).complex();
|
||||
assert_eq!((current.size, current.bold, current.italic), (18.0, true, true));
|
||||
let change = c.fmt_change.as_ref().unwrap();
|
||||
assert_eq!((change.old.size_cs, change.old.bold_cs, change.old.italic_cs), (Some(10.0), Some(false), Some(false)));
|
||||
let old = d.styles.resolve_char(None, &change.old).complex();
|
||||
assert_eq!((old.size, old.bold, old.italic), (10.0, false, false));
|
||||
let mut overlaid = wordcraft_doc::props::CharProps::default();
|
||||
overlaid.overlay(c);
|
||||
assert_eq!(overlaid.fmt_change, c.fmt_change);
|
||||
});
|
||||
}
|
||||
@@ -1146,7 +1146,8 @@ fn formatting_revisions_round_trip() {
|
||||
assert!(xml.contains(parent), "{tag} last: {xml}");
|
||||
}
|
||||
assert!(xml.contains(r#"<w:numberingChange w:id="#) && xml.contains(r#"w:original="1."/></w:numPr>"#), "{xml}");
|
||||
assert!(xml.contains(r#"<w:b/><w:bCs/><w:rPrChange w:id="#), "{xml}");
|
||||
assert!(xml.contains(r#"<w:b/><w:rPrChange w:id="#), "{xml}");
|
||||
assert!(!xml.contains("<w:bCs"), "Saving must not add an absent complex-script bold: {xml}");
|
||||
let again = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(again.body, d.body);
|
||||
assert_eq!(again.last_section, d.last_section);
|
||||
|
||||
@@ -15,15 +15,14 @@ pub const SIZES: [f32; 17] = [8.0, 9.0, 10.0, 10.5, 11.0, 12.0, 14.0, 16.0, 18.0
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("format.bold", "Bold", "Home › Font", |s, v| {
|
||||
// Like Word's button, for complex-script (Persian, Arabic) text too: an unset `bold_cs`
|
||||
// follows `bold`.
|
||||
// The button formats both plain and complex-script text explicitly.
|
||||
toggle(
|
||||
s,
|
||||
v,
|
||||
|r| r.bold,
|
||||
|c, on| {
|
||||
c.bold = Some(on);
|
||||
c.bold_cs = None;
|
||||
c.bold_cs = Some(on);
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -36,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|r| r.italic,
|
||||
|c, on| {
|
||||
c.italic = Some(on);
|
||||
c.italic_cs = None;
|
||||
c.italic_cs = Some(on);
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -319,7 +318,7 @@ fn size(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let sz = (sz * 2.0).round() / 2.0;
|
||||
apply(s, &|c| {
|
||||
c.size = Some(sz);
|
||||
c.size_cs = None;
|
||||
c.size_cs = Some(sz);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,7 +337,7 @@ fn step_size(s: &mut Session, dir: i32) -> CmdResult {
|
||||
// Each run steps from its own size when the selection mixes sizes.
|
||||
apply(s, &|c| {
|
||||
c.size = Some(next);
|
||||
c.size_cs = None;
|
||||
c.size_cs = Some(next);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -346,7 +345,7 @@ fn nudge_size(s: &mut Session, d: f32) -> CmdResult {
|
||||
let next = (current_size(s) + d).clamp(1.0, 1638.0);
|
||||
apply(s, &|c| {
|
||||
c.size = Some(next);
|
||||
c.size_cs = None;
|
||||
c.size_cs = Some(next);
|
||||
})
|
||||
}
|
||||
|
||||
@@ -568,7 +567,12 @@ fn char_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
let mut props: CharProps = serde_json::from_value(v.get("props").cloned().unwrap_or(Value::Null)).map_err(|e| CmdError::Params(e.to_string()))?;
|
||||
// The font dialog and callers supplying one value format both scripts. Explicit
|
||||
// complex-script values still take precedence; importing a DOCX never uses this command.
|
||||
props.size_cs = props.size_cs.or(props.size);
|
||||
props.bold_cs = props.bold_cs.or(props.bold);
|
||||
props.italic_cs = props.italic_cs.or(props.italic);
|
||||
apply(s, &|c| c.overlay(&props))
|
||||
}
|
||||
|
||||
|
||||
@@ -1200,7 +1200,7 @@ mod tests {
|
||||
|
||||
let list = s.run("review.changes", &json!({})).unwrap();
|
||||
let desc: Vec<&str> = list.as_array().unwrap().iter().filter(|c| c["kind"] == "format").filter_map(|c| c["description"].as_str()).collect();
|
||||
assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Size: 14"], "{list:#}");
|
||||
assert_eq!(desc, ["Formatted: Align: center", "Formatted: Bold, Bold cs, Size: 14, Size cs: 14"], "{list:#}");
|
||||
|
||||
let (d, body_x) = draws(&mut s);
|
||||
let bars = lines(&d).into_iter().filter(|(x0, _, x1, _)| x0 == x1 && *x0 < body_x).count();
|
||||
|
||||
@@ -2428,12 +2428,13 @@ fn style_inspector_reports_and_clears_levels() {
|
||||
assert!(pdirect.iter().any(|d| d["prop"] == "align" && d["value"] == "center"), "{pdirect:?}");
|
||||
assert!(r["character"]["style"].is_null());
|
||||
let cdirect = r["character"]["direct"].as_array().unwrap();
|
||||
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true})]);
|
||||
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true}), json!({"prop": "boldCs", "value": true})]);
|
||||
|
||||
// Formatting that matches the style isn't a difference (Heading 1 is 20 pt).
|
||||
run(&mut s, "format.size", json!({"size": 20}));
|
||||
let r = run(&mut s, "styles.inspect", json!({}));
|
||||
assert_eq!(r["character"]["direct"].as_array().unwrap().len(), 1, "{r}");
|
||||
let cdirect = r["character"]["direct"].as_array().unwrap();
|
||||
assert_eq!(cdirect, &vec![json!({"prop": "bold", "value": true}), json!({"prop": "boldCs", "value": true})]);
|
||||
|
||||
// Character style level.
|
||||
run(&mut s, "format.charStyle", json!({"style": "Emphasis"}));
|
||||
|
||||
@@ -89,6 +89,48 @@ fn font_commands_reach_persian_text() {
|
||||
assert_eq!(d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.bidi, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_commands_override_inherited_complex_script_properties_explicitly() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
|
||||
let mut s = session("שלום ABC", true);
|
||||
s.doc.styles.default_chr.size_cs = Some(30.0);
|
||||
s.doc.styles.default_chr.bold_cs = Some(true);
|
||||
s.doc.styles.default_chr.italic_cs = Some(true);
|
||||
run(&mut s, "select.all", json!({}));
|
||||
run(&mut s, "format.bold", json!({"value": false}));
|
||||
run(&mut s, "format.italic", json!({"value": false}));
|
||||
run(&mut s, "format.size", json!({"size": 18}));
|
||||
for (command, expected_size) in [
|
||||
(None, 18.0),
|
||||
(Some("format.growFont"), 20.0),
|
||||
(Some("format.shrinkFont"), 18.0),
|
||||
(Some("format.growFont1"), 19.0),
|
||||
(Some("format.shrinkFont1"), 18.0),
|
||||
] {
|
||||
if let Some(command) = command {
|
||||
run(&mut s, command, json!({}));
|
||||
}
|
||||
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
let c = p.props_of_char(0);
|
||||
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(expected_size), Some(false), Some(false)));
|
||||
let d = crate::io::open_bytes("x.docx", &crate::io::save_bytes("x.docx", &s.doc).unwrap()).unwrap();
|
||||
let c = d.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0);
|
||||
let cs = d.styles.resolve_char(None, c).complex();
|
||||
assert_eq!((cs.size, cs.bold, cs.italic), (expected_size, false, false));
|
||||
}
|
||||
// The font dialog's command keeps formatting both scripts when given one value.
|
||||
run(&mut s, "format.set", json!({"props": {"size": 10, "bold": true, "italic": true}}));
|
||||
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
let c: &CharProps = p.props_of_char(0);
|
||||
assert_eq!((c.size_cs, c.bold_cs, c.italic_cs), (Some(10.0), Some(true), Some(true)));
|
||||
// Callers can still supply distinct values explicitly, including false.
|
||||
run(&mut s, "format.set", json!({"props": {"size": 12, "sizeCs": 18, "bold": true, "boldCs": false, "italic": true, "italicCs": false}}));
|
||||
let p = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
|
||||
let c = p.props_of_char(0);
|
||||
assert_eq!((c.size, c.size_cs, c.bold_cs, c.italic_cs), (Some(12.0), Some(18.0), Some(false), Some(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_move_visually_in_rtl_text() {
|
||||
let fa = "سلام دنیا";
|
||||
|
||||
@@ -1222,10 +1222,8 @@ impl Body<'_> {
|
||||
}
|
||||
match local(q) {
|
||||
"p" | "h" if !self.in_desc => self.end_para(),
|
||||
"span" | "a" => {
|
||||
if self.fmt.len() > usize::from(self.para.is_some()) {
|
||||
self.fmt.pop();
|
||||
}
|
||||
"span" | "a" if self.fmt.len() > usize::from(self.para.is_some()) => {
|
||||
self.fmt.pop();
|
||||
}
|
||||
"list" => {
|
||||
self.end_para();
|
||||
|
||||
@@ -1003,10 +1003,8 @@ impl Reader {
|
||||
self.list_cur.clear();
|
||||
self.list_cur_id = 0;
|
||||
}
|
||||
"listlevel" => {
|
||||
if self.list_cur.len() < 9 {
|
||||
self.list_cur.push(true);
|
||||
}
|
||||
"listlevel" if self.list_cur.len() < 9 => {
|
||||
self.list_cur.push(true);
|
||||
}
|
||||
"levelnfc" | "levelnfcn" => {
|
||||
if let Some(l) = self.list_cur.last_mut() {
|
||||
|
||||
@@ -623,7 +623,7 @@ impl SymbolForm {
|
||||
self.chars = face.chars().into_iter().map(|(c, _)| c).filter(|c| !c.is_control() && !(0xD800..=0xDFFF).contains(&(*c as u32))).collect();
|
||||
self.chars_of = Some(family);
|
||||
// Keep the selection when the new font has it; otherwise its first character.
|
||||
if !self.selected.is_some_and(|c| self.chars.binary_search(&c).is_ok()) {
|
||||
if self.selected.is_none_or(|c| self.chars.binary_search(&c).is_err()) {
|
||||
self.selected = self.chars.iter().copied().find(|c| *c > ' ').or(self.chars.first().copied());
|
||||
}
|
||||
self.sync_code();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (file associations and the default-app button, #295; 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-11 · **Change:** 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;
|
||||
@@ -46,7 +46,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
|
||||
| Feature | Read | Write | Notes |
|
||||
|---|---|---|---|
|
||||
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
|
||||
| Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111); independent complex-script formatting inherited through defaults/styles and preserved over repeated saves (`complex_script_inheritance.rs`), indents in characters (`w:firstLineChars`, `w:hangingChars`, `w:leftChars`, `w:rightChars`, #534); every ST_NumberFormat value of `w:numFmt` is kept on save, Chinese/Japanese/Korean, enclosed and full-width formats are drawn, the rest (Hebrew, Arabic, Hindi, Thai…) show decimal (#534). `w:beforeLines`/`w:afterLines` not kept (the points are) |
|
||||
| Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) |
|
||||
| Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | |
|
||||
| Hyphenation settings (`w:autoHyphenation`, `w:hyphenationZone`, `w:doNotHyphenateCaps`, `w:consecutiveHyphenLimit`) | ✅ | ✅ | Zone, capitals and limit kept and honoured by layout (#407) |
|
||||
@@ -161,6 +161,7 @@ raise readiness estimates. No Word-derived files are fixtures.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | Independent complex-script size, bold and italic inherit correctly through defaults and styles; explicit equal values survive; six synthetic multi-generation round-trip tests, including tracked formatting, added |
|
||||
| 2026-10-11 | minor | Opening files from the system: file associations and the default-app button (#295) |
|
||||
| 2026-10-11 | trivial | `.doc`: inline and floating pictures (BLIP store, PNG/JPEG/DIB/TIFF/EMF/WMF), basic shapes, groups and text boxes read from OfficeArt drawings (#551); fidelity ~55% → ~60% |
|
||||
| 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Where WordCraft falls short of Word
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Polish interface and Polish proofing; previously trivial: .doc pictures and drawings read, #551; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (DOCX complex-script formatting inheritance fixed; broader RTL work remains; previously trivial: #24 narrowed: Customize Keyboard and Customize Ribbon landed; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
|
||||
The work list. One entry per shortfall, ranked by how much it stops a Word user from switching
|
||||
(**B** = blocks beta). Each says what's missing, the evidence, who it hurts, an Opus 5.5 agent
|
||||
@@ -69,6 +69,7 @@ The same gaps grouped the way the parity documents are, for agents working in on
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | trivial | #16: DOCX open/save preserves independent complex-script formatting inheritance; RTL sections, tables and typography remain open |
|
||||
| 2026-10-11 | trivial | `.doc` pictures, shapes and text boxes read (#551); gap #14 narrowed to the writer and Word 6/95 files |
|
||||
| 2026-10-11 | trivial | Table column and row borders and the resize handle drag on the page (#217); UI/UX gap narrowed to the ruler's column markers |
|
||||
| 2026-10-11 | trivial | UI/UX gaps list: the system's animation setting is now read on macOS and the Linux/BSD desktops; trying it on real machines remains |
|
||||
|
||||
+4
-6
@@ -169,13 +169,11 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
|
||||
continue;
|
||||
}
|
||||
match classify(&d.name) {
|
||||
// Unregistered deps are reported on their own entry.
|
||||
None => {}
|
||||
Some(Class::Testkit) => {
|
||||
if d.kind != DepKind::Dev {
|
||||
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
||||
}
|
||||
Some(Class::Testkit) if d.kind != DepKind::Dev => {
|
||||
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
|
||||
}
|
||||
// Unregistered deps are reported on their own entry; testkit dev deps are allowed.
|
||||
None | Some(Class::Testkit) => {}
|
||||
Some(dc) => {
|
||||
let to = dc.layer().unwrap_or(u8::MAX);
|
||||
if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) {
|
||||
|
||||
Reference in new issue
Block a user