mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 20:07:19 -06:00
* feat(doc,docx): complex-script run properties and RTL OOXML round-trip Model the formatting Word applies to Persian, Arabic and Hebrew text: cs, font_cs, size_cs, bold_cs, italic_cs and lang_bidi on CharProps, resolved with Word's fallbacks (ResolvedChar::complex). Size, bold and italic pair with their complex-script values so a run's size or bold also covers its Persian characters. Add doc::bidi script helpers, and keep ZWNJ and Arabic marks inside words for word selection. DOCX: read and write w:rFonts/@w:cs, w:szCs, w:bCs, w:iCs, w:cs and w:lang/@w:bidi in schema order; stop using the cs font as the Latin font. Tests round-trip Persian text with ZWNJ, digits and mixed Latin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(fonts): directional shaping and Persian font fallback Add shape_run to shape a run in a known bidi direction (Arabic joining, mirrored brackets). Prefer Persian-capable fonts (Vazirmatn, Noto Naskh/ Sans Arabic…) for right-to-left fallback, pick bold/italic fallback faces, and map common Persian document fonts (B Nazanin, B Lotus, B Titr, IRANSans…) to open substitutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(layout): Unicode Bidirectional Algorithm and right-to-left paragraphs Run UAX #9 per paragraph (unicode-bidi), shape each level run in its direction, keep marks and ZWNJ with their letter and a word in one fallback face. After line breaking, apply rules L1/L2 per line and give each cluster a visual box; right-to-left paragraphs are laid out from their start edge and mirrored, so indents, tabs, alignment, list labels, drop caps, floats and borders sit on the right. Caret, hit testing and selection use visual geometry (split selections, caret at bidi boundaries, DocLayout::visual_step for arrow keys). Display items carry glyph-to-text ranges, which the PDF writer now uses. Plain left-to-right lines are unchanged. No hyphenation of RTL words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(engine,ui): paragraph direction commands and RTL editing Add para.ltr / para.rtl (Word's text direction buttons) that change only the reading order, and make the alignment buttons visual in right-to-left paragraphs (para.align also takes start/end). Arrow keys move visually in bidirectional text, Ctrl+arrows follow reading order, and Bold/Italic/ Size/Font cover complex-script text. Ribbon buttons with code-drawn icons, a mirrored ruler for RTL paragraphs, translations, catalog, parity and roadmap updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(contributors): add Saeed Azarehgar to people.toml Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(engine): para.align with an explicit value sets it, without toggling Only the alignment buttons toggle back to the start edge when pressed again. para.align (used by the Paragraph dialog, scripts and agents) now sets the requested alignment, as seen on the page, in each selected paragraph's own direction. Previously pressing OK in the dialog with Center or Right selected reset the paragraph to the start edge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(ui): Direction option in the Paragraph dialog Add Word's Direction (Right-to-left / Left-to-right) to the Paragraph dialog's General section. The dialog now shows alignment and Left/Right indents as seen on the page, mapping them to the paragraph's logical start/end values on OK, so an unchanged OK no longer flips a right-to-left paragraph. OK logic moved to apply_paragraph, with tests; ja/zh translations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(layout): allow hanging trailing spaces in the RTL display test A wrapped right-to-left line's trailing spaces hang past its end edge (the left margin), as left-to-right lines hang them past the right. The test failed whenever the loaded fallback fonts made a line end there; it now checks only visible glyphs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
6.9 KiB
Rust
142 lines
6.9 KiB
Rust
//! Right-to-left and complex-script OOXML: `w:bidi` paragraphs, `w:rtl` / `w:cs` runs, the
|
|
//! complex-script font, size, bold and italic, and the bidi language. Fixtures are hand-written
|
|
//! from ECMA-376 Part 1 §17.3.1.6 (bidi), §17.3.2 (run properties).
|
|
|
|
use std::io::{Read, Write};
|
|
|
|
use wordcraft_doc::props::{Align, CharProps, ParaProps};
|
|
use wordcraft_doc::{Document, Paragraph, para_block};
|
|
|
|
fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
|
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
|
for (name, bytes) in entries {
|
|
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
|
zw.write_all(bytes).unwrap();
|
|
}
|
|
zw.finish().unwrap().into_inner()
|
|
}
|
|
|
|
/// A package whose body is `body`.
|
|
fn docx(body: &str) -> Vec<u8> {
|
|
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
|
|
let doc = format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
|
|
);
|
|
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
|
|
}
|
|
|
|
fn paras(d: &Document) -> Vec<&Paragraph> {
|
|
d.body.iter().filter_map(|b| b.as_para()).collect()
|
|
}
|
|
|
|
/// `word/document.xml` of a written package.
|
|
fn document_xml(bytes: &[u8]) -> String {
|
|
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
|
let mut s = String::new();
|
|
z.by_name("word/document.xml").unwrap().read_to_string(&mut s).unwrap();
|
|
s
|
|
}
|
|
|
|
/// Persian text with a ZWNJ (نیمفاصله), Persian digits, Latin and punctuation.
|
|
const FA: &str = "میخواهم نسخهٔ ۲٫۵ از WordCraft را (امروز) نصب کنم.";
|
|
|
|
fn persian_run() -> CharProps {
|
|
CharProps {
|
|
rtl: Some(true),
|
|
font_cs: Some("B Nazanin".into()),
|
|
size_cs: Some(14.0),
|
|
bold_cs: Some(true),
|
|
italic_cs: Some(false),
|
|
lang_bidi: Some("fa-IR".into()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn persian_paragraph_round_trips() {
|
|
let mut rtl = Paragraph::with_text(FA, persian_run());
|
|
rtl.props = ParaProps { bidi: Some(true), align: Some(Align::Justify), ..Default::default() };
|
|
let ltr = Paragraph::with_text("English with فارسی inside", CharProps { cs: Some(true), ..Default::default() });
|
|
let mut d = Document::new();
|
|
d.body = vec![para_block(rtl.clone()), para_block(ltr.clone())];
|
|
let bytes = wordcraft_docx::write(&d).unwrap();
|
|
let r = wordcraft_docx::read(&bytes).unwrap();
|
|
let ps = paras(&r);
|
|
assert_eq!(ps[0].text, FA, "Persian text, ZWNJ and digits survive byte for byte");
|
|
assert_eq!(ps[0].props.bidi, Some(true));
|
|
assert_eq!(ps[0].props.align, Some(Align::Justify));
|
|
assert_eq!(ps[0].runs[0].props, persian_run());
|
|
assert_eq!(ps[1].text, ltr.text);
|
|
assert_eq!(ps[1].props.bidi, None);
|
|
assert_eq!(ps[1].runs[0].props.cs, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn bidi_markup_is_written_in_schema_order() {
|
|
let mut p =
|
|
Paragraph::with_text(FA, CharProps { bold: Some(true), italic: Some(true), size: Some(12.0), font: Some("Arial".into()), ..persian_run() });
|
|
p.props.bidi = Some(true);
|
|
let mut d = Document::new();
|
|
d.body = vec![para_block(p)];
|
|
let xml = document_xml(&wordcraft_docx::write(&d).unwrap());
|
|
assert!(xml.contains("<w:bidi/>"), "paragraph direction: {xml}");
|
|
assert!(xml.contains(r#"w:cs="B Nazanin""#), "complex-script font");
|
|
assert!(xml.contains(r#"<w:szCs w:val="28"/>"#), "complex-script size in half points");
|
|
assert!(xml.contains(r#"w:bidi="fa-IR""#), "bidi language");
|
|
// CT_RPr is a sequence: b, bCs, i, iCs … sz, szCs … rtl, cs, lang.
|
|
let pos = |t: &str| xml.find(t).unwrap_or_else(|| panic!("{t} missing: {xml}"));
|
|
assert!(pos("<w:rFonts") < pos("<w:b/>"));
|
|
assert!(pos("<w:b/>") < pos("<w:bCs/>") && pos("<w:bCs/>") < pos("<w:i/>"));
|
|
assert!(pos("<w:i/>") < pos(r#"<w:iCs w:val="0"/>"#));
|
|
assert!(pos(r#"<w:sz w:val="24"/>"#) < pos("<w:szCs"));
|
|
assert!(pos("<w:rtl/>") < pos("<w:lang"));
|
|
}
|
|
|
|
#[test]
|
|
fn reads_word_style_bidi_markup() {
|
|
// What a right-to-left paragraph looks like in a Word document: w:bidi, logical
|
|
// alignment and indents (left = start), runs with w:rtl and the complex-script values.
|
|
let body = r#"
|
|
<w:p>
|
|
<w:pPr><w:bidi/><w:ind w:left="720" w:right="0"/><w:jc w:val="left"/></w:pPr>
|
|
<w:r><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="B Lotus"/><w:b/><w:bCs/><w:sz w:val="24"/><w:szCs w:val="32"/><w:rtl/><w:lang w:val="en-US" w:bidi="fa-IR"/></w:rPr><w:t>سلام دنیا</w:t></w:r>
|
|
<w:r><w:rPr><w:rtl/></w:rPr><w:t xml:space="preserve"> </w:t></w:r>
|
|
<w:r><w:t>Word 2025</w:t></w:r>
|
|
</w:p>
|
|
<w:p><w:pPr><w:bidi w:val="0"/></w:pPr><w:r><w:rPr><w:cs/><w:iCs/></w:rPr><w:t>x</w:t></w:r></w:p>"#;
|
|
let d = wordcraft_docx::read(&docx(body)).unwrap();
|
|
let ps = paras(&d);
|
|
assert_eq!(ps[0].text, "سلام دنیا Word 2025");
|
|
assert_eq!(ps[0].props.bidi, Some(true));
|
|
assert_eq!(ps[0].props.indent_left, Some(36.0), "w:left is the start indent");
|
|
assert_eq!(ps[0].props.align, Some(Align::Left), "jc=left is the start edge");
|
|
let fa = &ps[0].runs[0].props;
|
|
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)));
|
|
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;
|
|
assert_eq!((x.cs, x.italic_cs, x.italic), (Some(true), Some(true), None));
|
|
// The resolved complex-script formatting is what Persian characters are drawn with.
|
|
let rc = d.styles.resolve_char(None, fa);
|
|
let cs = rc.complex();
|
|
assert_eq!((cs.font.as_str(), cs.size, cs.bold), ("B Lotus", 16.0, true));
|
|
assert!(rc.uses_complex('س') && rc.uses_complex('W'), "every character of an rtl run is complex script");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_bidi_markup_is_bounded() {
|
|
let long = "x".repeat(10_000);
|
|
let body = format!(
|
|
r#"<w:p><w:pPr><w:bidi w:val="banana"/></w:pPr><w:r><w:rPr><w:rFonts w:cs="{long}"/><w:szCs w:val="-5"/><w:szCs w:val="99999999"/><w:lang w:bidi="{long}"/><w:rtl w:val="maybe"/></w:rPr><w:t>ok</w:t></w:r></w:p>"#
|
|
);
|
|
let d = wordcraft_docx::read(&docx(&body)).unwrap();
|
|
let c = ¶s(&d)[0].runs[0].props;
|
|
assert_eq!(c.font_cs, None, "absurd font names are dropped");
|
|
assert_eq!(c.lang_bidi, None);
|
|
assert!(c.size_cs.is_none_or(|s| (1.0..=1638.0).contains(&s)));
|
|
}
|