diff --git a/ROADMAP.md b/ROADMAP.md index 138d271..7aeed3a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,9 @@ **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-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Insert › SmartArt landed, #497: catalog 429/451 → 437/458 (95.4%); 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 + +> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. 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 @@ -131,6 +133,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Word XML files (#548): Word XML Document (Flat OPC `.xml`) opens and saves through the DOCX reader and writer; Word 2003 XML opens (styles, lists, tables, sections, notes, comments, pictures, hyperlinks) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Font fallback indexes which characters each installed font maps, reading each font file once (#605): text in a script no installed font covers no longer re-reads every system font per distinct character (200 such characters: 13 s → 0.2 s) | | 2026-10-11 | Find and Replace special characters (`^p`, `^t`, `^#`, `^&` …) and Use wildcards (ranges, counts, word anchors, groups with `\1` in Replace); `^p` finds and splits paragraphs; Special ▾ menu and search options in the dialog (#543) | @@ -150,6 +153,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | 2026-10-11 | Line Numbers, Hyphenation (zone, CAPS, consecutive limit, Manual), Language, Envelopes and Labels dialogs (#407); hyphenation options kept in DOCX; 30 modal dialogs | | 2026-10-11 | Accessibility (#386): Alt Text pane for every object and table, Mark as decorative, alt text round-trips in DOCX, deeper checker with fix actions | | 2026-10-11 | References dialogs (#402): Caption (labels, chapter numbering), Index and Mark Entry, Custom Table of Contents, Source Manager and Create/Edit Source, with TOC/INDEX/XE/SEQ switches honoured on update | +| 2026-10-11 | Font dialog: separate Asian text font and Latin text font (#505); `format.font {"eastAsia"}`; East Asian fonts found by the font scan (OS/2 code pages) | | 2026-10-11 | Document grid (#391): Page Setup › Document Grid sets lines per page and characters per line (`w:docGrid` round trip); lines snap to the line pitch, East Asian characters to the character pitch; View › Gridlines shows it | | 2026-10-11 | Move tracking (#400): `w:moveFrom`/`w:moveTo` and their named ranges kept in DOCX, a tracked cut and paste of a paragraph or sentence records a move, accept/reject at either end resolves both, green double strikethrough/underline and "Moved from/to" balloons | | 2026-10-11 | Footnote and Endnote dialog from the References › Footnotes launcher: options per section or document (below text, end of section, number format, start at, restart each section or page), custom marks, convert footnotes ↔ endnotes; kept in DOCX (#385) | @@ -182,8 +186,8 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| -| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) | +| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) | | 2026-10-11 | trivial | Progress log: Insert › SmartArt (#497); catalog 429/451 → 437/458 (95.4%) | | 2026-10-11 | trivial | Progress log: Outline view (#493) | diff --git a/apps/wordcraft-web/src/web.rs b/apps/wordcraft-web/src/web.rs index 5d68374..a49a04d 100644 --- a/apps/wordcraft-web/src/web.rs +++ b/apps/wordcraft-web/src/web.rs @@ -8,7 +8,7 @@ use wasm_bindgen::JsCast as _; use wordcraft_engine::Session; use wordcraft_ui_egui::{Inbox, Services, WordApp}; -const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]; +const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]; const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"]; const RECIPIENT_EXTS: &[&str] = &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]; const CANVAS_ID: &str = "wordcraft_canvas"; @@ -306,6 +306,7 @@ fn mime_for(name: &str) -> &'static str { Some("md" | "txt") => "text/plain", Some("tex") => "application/x-tex", Some("json") => "application/json", + Some("xml") => "application/xml", _ => "application/octet-stream", } } diff --git a/apps/wordcraft/src/file_dialogs.rs b/apps/wordcraft/src/file_dialogs.rs index f348b16..a194c96 100644 --- a/apps/wordcraft/src/file_dialogs.rs +++ b/apps/wordcraft/src/file_dialogs.rs @@ -75,7 +75,7 @@ fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog } else if purpose == "recipients" { d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"]) } else { - d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json"]) + d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"]) .add_filter("Word document", &["docx"]) .add_filter("All files", &["*"]) } diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index d1f9d2c..4405d4d 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -655,6 +655,18 @@ pub struct ParaProps { /// First-line indent, points; negative = hanging. #[serde(skip_serializing_if = "Option::is_none")] pub indent_first: Option, + /// Left (start) indent in characters (`w:leftChars` / `w:startChars`, stored by Word in + /// hundredths). A non-zero value wins over `indent_left`; one character is the size of the + /// paragraph mark's font. `Some(0.0)` cancels an inherited value. Common in East Asian documents. + #[serde(skip_serializing_if = "Option::is_none")] + pub indent_left_chars: Option, + /// Right (end) indent in characters (`w:rightChars` / `w:endChars`), like `indent_left_chars`. + #[serde(skip_serializing_if = "Option::is_none")] + pub indent_right_chars: Option, + /// First-line indent in characters (`w:firstLineChars`; negative = `w:hangingChars`), like + /// `indent_left_chars`. + #[serde(skip_serializing_if = "Option::is_none")] + pub indent_first_chars: Option, #[serde(skip_serializing_if = "Option::is_none")] pub space_before: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -731,7 +743,7 @@ pub struct ParaProps { impl ParaProps { pub fn overlay(&mut self, patch: &ParaProps) { - overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, space_before, space_after, line_spacing, + overlay_fields!(self, patch; style, align, indent_left, indent_right, indent_first, indent_left_chars, indent_right_chars, indent_first_chars, space_before, space_after, line_spacing, contextual_spacing, mirror_indents, keep_next, keep_lines, page_break_before, widow_control, outline_level, numbering, tabs, shading, borders, suppress_hyphens, suppress_line_numbers, bidi, drop_cap, kinsoku, word_wrap, overflow_punct, top_line_punct, auto_space_de, auto_space_dn, snap_to_grid); @@ -743,6 +755,15 @@ impl ParaProps { pub fn is_empty(&self) -> bool { *self == ParaProps::default() } + /// After setting indents in points: keep the indents in characters (left, right, first line) + /// an inherited style may set from winning over them, as Word does (`w:firstLineChars="0"`). + pub fn cancel_char_indents(&mut self, left: bool, right: bool, first: bool) { + for (on, chars) in [(left, &mut self.indent_left_chars), (right, &mut self.indent_right_chars), (first, &mut self.indent_first_chars)] { + if on { + *chars = Some(0.0); + } + } + } /// Just the formatting: without the tracked changes. pub fn formatting(&self) -> ParaProps { ParaProps { num_change: None, fmt_change: None, ..self.clone() } diff --git a/crates/doc/src/resolve.rs b/crates/doc/src/resolve.rs index 10dbebb..a96f352 100644 --- a/crates/doc/src/resolve.rs +++ b/crates/doc/src/resolve.rs @@ -165,6 +165,10 @@ pub struct ResolvedPara { pub auto_space_dn: bool, /// Lines snap to the document grid, when the section has one. pub snap_to_grid: bool, + /// Indents in characters (left, right, first line; 0 = in points), which win over the + /// point values; see [`ResolvedPara::set_char_unit`]. + #[serde(default)] + pub indent_chars: [f32; 3], } const MAX_INDENT: f32 = 1584.0; // 22" @@ -176,6 +180,18 @@ impl ResolvedPara { pub fn visual_indents(&self) -> (f32, f32) { if self.bidi { (self.indent_right, self.indent_left) } else { (self.indent_left, self.indent_right) } } + + /// Turn the indents given in characters into points, one character being `size` points: the + /// size of the paragraph mark's font (as Word measures `w:firstLineChars` and friends). + pub fn set_char_unit(&mut self, size: f32) { + let size = if size.is_finite() { size.clamp(1.0, 1638.0) } else { 12.0 }; + let [l, r, f] = self.indent_chars; + for (chars, pts) in [(l, &mut self.indent_left), (r, &mut self.indent_right), (f, &mut self.indent_first)] { + if chars != 0.0 { + *pts = (chars * size).clamp(-MAX_INDENT, MAX_INDENT); + } + } + } } impl ResolvedPara { @@ -219,10 +235,15 @@ impl ResolvedPara { auto_space_de: p.auto_space_de.unwrap_or(true), auto_space_dn: p.auto_space_dn.unwrap_or(true), snap_to_grid: p.snap_to_grid.unwrap_or(true), + indent_chars: [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars] + .map(|c| c.filter(|c| c.is_finite()).unwrap_or(0.0).clamp(-MAX_CHARS, MAX_CHARS)), } } } +/// The largest indent in characters (Word allows indents up to 22"). +const MAX_CHARS: f32 = 1584.0; + impl StyleSheet { /// Paragraph properties after defaults and the style chain (without direct formatting). pub fn para_style_props(&self, style: &str) -> (ParaProps, CharProps) { @@ -272,7 +293,13 @@ impl StyleSheet { direct.tabs = Some(merged); } base.overlay(&direct); - ResolvedPara::from(&base, &style) + let mut rp = ResolvedPara::from(&base, &style); + // Indents in characters: measured in the style's font size here; layout measures them in + // the paragraph mark's (`set_char_unit`), which direct formatting may change. + if rp.indent_chars.iter().any(|c| *c != 0.0) { + rp.set_char_unit(self.resolve_char(Some(&style), &CharProps::default()).size); + } + rp } /// Resolve character formatting for a run in a paragraph of style `para_style`. diff --git a/crates/docbin/src/fmt.rs b/crates/docbin/src/fmt.rs index 446a57d..30b8301 100644 --- a/crates/docbin/src/fmt.rs +++ b/crates/docbin/src/fmt.rs @@ -30,6 +30,11 @@ const P_DXA_LEFT80: u16 = 0x840F; const P_DXA_RIGHT: u16 = 0x845D; const P_DXA_RIGHT80: u16 = 0x840E; const P_DXA_FIRST: u16 = 0x8460; // sprmPDxaLeft1 +const P_DXA_FIRST80: u16 = 0x8411; // sprmPDxaLeft1_80 +// Indents in hundredths of a character (sprmPDxcRight, sprmPDxcLeft, sprmPDxcLeft1). +const P_DXC_RIGHT: u16 = 0x4455; +const P_DXC_LEFT: u16 = 0x4456; +const P_DXC_FIRST: u16 = 0x4457; const P_DYA_BEFORE: u16 = 0xA413; const P_DYA_AFTER: u16 = 0xA414; const P_DYA_LINE: u16 = 0x6412; @@ -186,12 +191,6 @@ pub(crate) fn apply_char(p: &mut CharProps, prl: &Prl, base: &CharProps, fonts: pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) { let tw = |v: i32| (v as f32 / 20.0).clamp(-1000.0, 1000.0); let s16 = |prl: &Prl| -> Option { u16_of(prl).map(|v| v as i16) }; - let i32of = |prl: &Prl| -> Option { - match prl.operand { - [b0, b1, b2, b3, ..] => Some(i32::from_le_bytes([*b0, *b1, *b2, *b3])), - _ => None, - } - }; match prl.op { P_JC | P_JC80 => { p.align = match prl.operand.first().copied().unwrap_or(0) { @@ -212,9 +211,20 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) { p.indent_right = Some(tw(v as i32)); } } - P_DXA_FIRST => { - if let Some(v) = i32of(prl) { - p.indent_first = Some(tw(v)); + // A two-byte operand, like the other indents (spra 4). + P_DXA_FIRST | P_DXA_FIRST80 => { + if let Some(v) = s16(prl) { + p.indent_first = Some(tw(v as i32)); + } + } + P_DXC_RIGHT | P_DXC_LEFT | P_DXC_FIRST => { + if let Some(v) = s16(prl) { + let chars = Some(f32::from(v) / 100.0); + match prl.op { + P_DXC_RIGHT => p.indent_right_chars = chars, + P_DXC_LEFT => p.indent_left_chars = chars, + _ => p.indent_first_chars = chars, + } } } P_DYA_BEFORE => { @@ -227,13 +237,18 @@ pub(crate) fn apply_para(p: &mut ParaProps, prl: &Prl) { p.space_after = Some((v as f32 / 20.0).clamp(0.0, 3168.0)); } } + // LSPD ([MS-DOC] §2.9.150): a signed dyaLine, then fMultLinespace. In lines (240ths) when + // fMultLinespace is 1; else twips, at least when positive and exactly when negative. P_DYA_LINE => { - if let Some(v) = u16_of(prl) { - let mult = (v & 0x3FFF) as f32; - p.line_spacing = Some(match v & 0xC000 { - 0x0000 => LineSpacing::Multiple((mult / 240.0).clamp(0.1, 20.0)), - 0x8000 => LineSpacing::AtLeast((mult / 20.0).clamp(0.0, 1584.0)), - _ => LineSpacing::Exactly((mult / 20.0).clamp(0.0, 1584.0)), + if let Some(v) = s16(prl) { + let mult = prl.operand.get(2..4).is_some_and(|b| b == [1, 0]); + let v = f32::from(v); + p.line_spacing = Some(if mult { + LineSpacing::Multiple((v.abs() / 240.0).clamp(0.06, 132.0)) + } else if v < 0.0 { + LineSpacing::Exactly((-v / 20.0).clamp(0.7, 1584.0)) + } else { + LineSpacing::AtLeast((v / 20.0).clamp(0.0, 1584.0)) }); } } diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 0feb129..5258219 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -673,6 +673,34 @@ fn paragraph_props_and_heading_style() { assert_eq!(p1.props.style.as_deref(), Some("Normal")); } +/// #534: Chinese theses set a first-line indent of two characters (`sprmPDxaLeft1`, whose operand +/// is two bytes, plus `sprmPDxcLeft1` in hundredths of a character), and exact line spacing as a +/// negative LSPD `dyaLine`. Both were lost: the indent read as four bytes, and exact 20pt as +/// 799pt, a page per empty paragraph. +#[test] +fn first_line_indent_chars_and_exact_line_spacing() { + let lspd = |dya: i16, mult: u16| [dya.to_le_bytes(), mult.to_le_bytes()].concat(); + let (exact, multiple) = (lspd(-400, 0), lspd(360, 1)); + let spec = RichSpec { + text: "indented\rexact\rold\r", + styles: vec![(1, 0, "Normal", Vec::new(), Vec::new())], + fonts: vec!["Times New Roman"], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![ + (0, 0, grpprl(&[(0x8460, &480i16.to_le_bytes()), (0x4457, &200i16.to_le_bytes()), (0x6412, &multiple)])), + (9, 0, grpprl(&[(0x6412, &exact)])), + (15, 0, grpprl(&[(0x8411, &(-360i16).to_le_bytes())])), + ], + }; + let doc = read(&rich_doc(&spec)).expect("opens"); + let para = |i: usize| doc.body.get(i).and_then(|b| b.as_para()).expect("para").props.clone(); + let p0 = para(0); + assert_eq!((p0.indent_first, p0.indent_first_chars), (Some(24.0), Some(2.0))); + assert_eq!(p0.line_spacing, Some(wordcraft_doc::props::LineSpacing::Multiple(1.5))); + assert_eq!(para(1).line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(20.0))); + assert_eq!(para(2).indent_first, Some(-18.0)); // sprmPDxaLeft1_80: a hanging indent +} + /// Local corpus (gitignored `plan/word/fixtures`): real files must open with text and styles. #[test] fn corpus_files_open() { diff --git a/crates/docx/src/flat.rs b/crates/docx/src/flat.rs new file mode 100644 index 0000000..f0949a2 --- /dev/null +++ b/crates/docx/src/flat.rs @@ -0,0 +1,411 @@ +//! Word XML Document: a whole WordprocessingML package as one XML file (the Flat OPC format Word +//! saves as "Word XML Document (*.xml)"): a `pkg:package` root holding one `pkg:part` per package +//! part, each with its name and content type, and the part's XML inline (`pkg:xmlData`) or its +//! bytes in base64 (`pkg:binaryData`). +//! +//! Reading unpacks the parts into an in-memory [`Package`] and hands it to the DOCX reader; +//! writing runs the DOCX writer and lays its parts out in one file. Bounded like the zip reader: +//! part count, part size, total size and base64 length are capped. + +use std::collections::BTreeMap; + +use base64::Engine as _; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::name::ResolveResult; +use quick_xml::reader::NsReader; +use wordcraft_doc::Document; + +use crate::package::{ContentTypes, MAX_ENTRIES, MAX_PART, MAX_TOTAL, Package}; +use crate::xml::{self, W, esc}; +use crate::{DocxError, Flavor}; + +/// The Flat OPC package namespace. +pub const PKG_NS: &str = "http://schemas.microsoft.com/office/2006/xmlPackage"; +/// The WordprocessingML 2003 namespace (Word 2003 XML Document). +pub const W2003_NS: &str = "http://schemas.microsoft.com/office/word/2003/wordml"; + +/// What kind of XML file a `.xml` holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum XmlKind { + /// A Flat OPC package (Word XML Document, Word 2007 and later). + FlatOpc, + /// A Word 2003 XML Document (WordprocessingML 2003). + WordMl2003, + /// Some other XML, or not XML at all. + Other, +} + +/// Elements and declarations looked at before the root element (comments, processing +/// instructions) before giving up. +const MAX_PROLOG_EVENTS: usize = 10_000; + +/// The text of `bytes` as UTF-8: UTF-16 with a byte-order mark is transcoded, a UTF-8 one dropped. +fn utf8_text(bytes: &[u8]) -> std::borrow::Cow<'_, [u8]> { + match xml::utf16_to_utf8(bytes) { + Some(s) => std::borrow::Cow::Owned(s.into_bytes()), + None => std::borrow::Cow::Borrowed(bytes.strip_prefix(&[0xEF, 0xBB, 0xBF][..]).unwrap_or(bytes)), + } +} + +/// Tell a Word XML file by its root element: `pkg:package` (Flat OPC) or `w:wordDocument` +/// (Word 2003), each in its namespace. +pub fn sniff_xml(bytes: &[u8]) -> XmlKind { + let text = utf8_text(bytes); + let mut r = NsReader::from_reader(text.as_ref()); + for _ in 0..MAX_PROLOG_EVENTS { + match r.read_resolved_event() { + Ok((res, Event::Start(e) | Event::Empty(e))) => { + let ns = match res { + ResolveResult::Bound(ns) => ns.as_ref().to_vec(), + _ => Vec::new(), + }; + return match (e.local_name().as_ref(), ns.as_slice()) { + (b"package", ns) if ns == PKG_NS.as_bytes() => XmlKind::FlatOpc, + (b"wordDocument", ns) if ns == W2003_NS.as_bytes() => XmlKind::WordMl2003, + _ => XmlKind::Other, + }; + } + Ok((_, Event::Eof)) | Err(_) => return XmlKind::Other, + Ok(_) => {} + } + } + XmlKind::Other +} + +/// Read a Flat OPC file. +pub fn read(bytes: &[u8]) -> Result { + crate::read::read_opened(&unpack(bytes)?) +} + +/// The data element of the part being read. +enum Data { + /// `pkg:xmlData`: its content starts at this byte offset. + Xml(usize), + /// `pkg:binaryData`: the base64 text so far. + Binary(String), +} + +/// The part being read: name, content type, data. +struct Part { + name: String, + content_type: String, + data: Option, +} + +/// Longest base64 text of one part (the size limit, encoded; whitespace isn't kept). +const MAX_BASE64: u64 = (MAX_PART / 3 + 1) * 4; + +/// Unpack a Flat OPC file into its parts, with a `[Content_Types].xml` made from the parts' +/// content types (unless the file carries one). +pub(crate) fn unpack(bytes: &[u8]) -> Result { + let text = utf8_text(bytes); + let src: &[u8] = text.as_ref(); + let mut r = NsReader::from_reader(src); + r.config_mut().check_end_names = true; + let mut depth = 0usize; + let mut files: BTreeMap> = BTreeMap::new(); + let mut types: Vec<(String, String)> = Vec::new(); + let mut total: u64 = 0; + let mut part: Option = None; + // `xmlns` declarations on the package, part and data elements, in scope for the inline XML. + let mut scope: Vec> = Vec::new(); + let bad = |e: quick_xml::Error| DocxError::Xml(e.to_string()); + loop { + let before = usize::try_from(r.buffer_position()).unwrap_or(usize::MAX); + let (res, ev) = r.read_resolved_event().map_err(bad)?; + let in_pkg = matches!(&res, ResolveResult::Bound(ns) if ns.as_ref() == PKG_NS.as_bytes()); + match ev { + Event::Start(ref e) | Event::Empty(ref e) => { + let empty = matches!(ev, Event::Empty(_)); + depth += 1; + let local = e.local_name().as_ref().to_vec(); + match depth { + 1 if !(in_pkg && local == b"package") => { + return Err(DocxError::NotWord("the root element isn't a Flat OPC pkg:package".into())); + } + 2 if in_pkg && local == b"part" => { + if files.len() + usize::from(part.is_some()) >= MAX_ENTRIES { + return Err(DocxError::Limit(format!("more than {MAX_ENTRIES} parts"))); + } + let name = attr(e, b"name").unwrap_or_default(); + let content_type = attr(e, b"contentType").unwrap_or_default(); + part = Some(Part { name, content_type, data: None }); + } + 3 if in_pkg => { + if let Some(p) = part.as_mut().filter(|p| p.data.is_none()) { + let pos = usize::try_from(r.buffer_position()).unwrap_or(usize::MAX); + p.data = match local.as_slice() { + // ``: an empty part. + b"xmlData" if empty => Some(Data::Binary(String::new())), + b"xmlData" => Some(Data::Xml(pos)), + b"binaryData" => Some(Data::Binary(String::new())), + _ => None, + }; + } + } + _ => {} + } + if depth <= 3 { + scope.push(ns_decls(e)); + } + if empty { + if depth == 2 + && let Some(p) = part.take() + { + finish_part(p, &mut files, &mut types, &mut total)?; + } + if depth <= 3 { + scope.pop(); + } + depth -= 1; + } + } + Event::End(_) => { + // The inline XML ends where its data element's end tag starts. + if depth == 3 + && let Some(p) = part.as_mut() + && let Some(Data::Xml(start)) = p.data + { + p.data = None; + let xml = with_scope(src.get(start..before).unwrap_or_default(), &scope); + store(&p.name, &p.content_type, xml, &mut files, &mut types, &mut total)?; + } + if depth == 2 + && let Some(p) = part.take() + { + finish_part(p, &mut files, &mut types, &mut total)?; + } + if depth <= 3 { + scope.pop(); + } + depth = depth.saturating_sub(1); + if depth == 0 { + break; + } + } + Event::Text(ref t) if depth == 3 => { + if let Some(Part { data: Some(Data::Binary(b64)), .. }) = part.as_mut() { + let s = std::str::from_utf8(t.as_ref()).map_err(|e| DocxError::Xml(e.to_string()))?; + b64.extend(s.chars().filter(|c| !c.is_ascii_whitespace())); + if b64.len() as u64 > MAX_BASE64 { + return Err(DocxError::Limit("a binary part is larger than the size limit".into())); + } + } + } + Event::Eof => break, + _ => {} + } + } + if files.is_empty() { + return Err(DocxError::NotWord("the Flat OPC package has no parts".into())); + } + if !files.contains_key("[Content_Types].xml") { + let mut w = W::new(); + w.open("Types", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/content-types")]); + for (name, ct) in &types { + w.empty("Override", &[("PartName", &format!("/{name}")), ("ContentType", ct)]); + } + w.close("Types"); + files.insert("[Content_Types].xml".into(), w.into_bytes()); + } + Ok(Package::from_parts(files)) +} + +/// End of a `pkg:part`: store its binary data (inline XML was stored when it ended). +fn finish_part(p: Part, files: &mut BTreeMap>, types: &mut Vec<(String, String)>, total: &mut u64) -> Result<(), DocxError> { + match p.data { + Some(Data::Binary(b64)) => { + let bytes = base64::engine::general_purpose::STANDARD_NO_PAD + .decode(b64.trim_end_matches('=')) + .map_err(|_| DocxError::Xml(format!("part {} has invalid base64 data", p.name)))?; + store(&p.name, &p.content_type, bytes, files, types, total) + } + // A part without data, or whose inline XML was already stored. + _ => Ok(()), + } +} + +/// Add a part, within the size limits; a repeated or empty name is ignored. +fn store( + name: &str, + content_type: &str, + bytes: Vec, + files: &mut BTreeMap>, + types: &mut Vec<(String, String)>, + total: &mut u64, +) -> Result<(), DocxError> { + let name = name.trim_start_matches('/'); + if name.is_empty() || files.contains_key(name) { + return Ok(()); + } + let len = bytes.len() as u64; + if len > MAX_PART { + return Err(DocxError::Limit(format!("part {name} is larger than the size limit"))); + } + *total = total.saturating_add(len); + if *total > MAX_TOTAL { + return Err(DocxError::Limit("the package is larger than the size limit".into())); + } + if !content_type.is_empty() && !content_type.contains(['\r', '\n']) { + types.push((name.to_string(), content_type.to_string())); + } + files.insert(name.to_string(), bytes); + Ok(()) +} + +/// An attribute's unescaped value, by local name (any prefix). +fn attr(e: &BytesStart, local: &[u8]) -> Option { + e.attributes().with_checks(false).flatten().find(|a| a.key.local_name().as_ref() == local).map(|a| { + match a.normalized_value(quick_xml::XmlVersion::Explicit1_0) { + Ok(v) => v.into_owned(), + Err(_) => String::from_utf8_lossy(&a.value).into_owned(), + } + }) +} + +/// The `xmlns` / `xmlns:p` declarations of a start tag, as written (value still escaped). +fn ns_decls(e: &BytesStart) -> Vec<(String, String)> { + e.attributes() + .with_checks(false) + .flatten() + .filter(|a| a.key.as_ref() == b"xmlns" || a.key.as_ref().starts_with(b"xmlns:")) + .filter(|a| !a.value.contains(&b'"')) + .map(|a| (String::from_utf8_lossy(a.key.as_ref()).into_owned(), String::from_utf8_lossy(&a.value).into_owned())) + .take(256) + .collect() +} + +/// The inline XML of a part as a document of its own: namespaces it inherits from the package +/// elements are declared on its root element (unless it declares them itself). +fn with_scope(frag: &[u8], scope: &[Vec<(String, String)>]) -> Vec { + let decls: Vec<&(String, String)> = scope.iter().flatten().filter(|(k, _)| k != "xmlns:pkg").collect(); + let Some(open) = root_tag_start(frag) else { return frag.to_vec() }; + if decls.is_empty() { + return frag.get(open..).unwrap_or_default().to_vec(); + } + 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 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()) { + continue; + } + seen.push(k); + out.extend_from_slice(format!(" {k}=\"{v}\"").as_bytes()); + } + out.extend_from_slice(rest.get(name_end..).unwrap_or_default()); + out +} + +/// Offset of the `<` of the first element in `frag` (after whitespace, comments and processing +/// instructions). +fn root_tag_start(frag: &[u8]) -> Option { + let mut i = 0usize; + loop { + while frag.get(i).is_some_and(u8::is_ascii_whitespace) { + i += 1; + } + let rest = frag.get(i..)?; + if rest.starts_with(b"")? + 3; + } else if rest.starts_with(b"")? + 2; + } else if rest.first() == Some(&b'<') { + return Some(i); + } else { + return None; + } + } +} + +/// Write `doc` as a Word XML Document (Flat OPC). +pub fn write(doc: &Document) -> Result, DocxError> { + let parts = crate::write::write_parts(doc, Flavor::Document)?; + let types = parts + .iter() + .find(|(n, _)| n == "[Content_Types].xml") + .map(|(n, b)| ContentTypes::read(&Package::from_parts(BTreeMap::from([(n.clone(), b.clone())])))) + .unwrap_or_default(); + let mut s = String::from("\n\n"); + s.push_str(&format!("")); + for (name, bytes) in &parts { + if name == "[Content_Types].xml" { + continue; + } + let ct = types.of(name).unwrap_or("application/octet-stream"); + s.push_str(&format!(" { + s.push_str(">"); + s.push_str(x); + s.push_str(""); + } + None => { + s.push_str(" pkg:compression=\"store\">"); + let b64 = base64::engine::general_purpose::STANDARD.encode(bytes); + for (i, chunk) in b64.as_bytes().chunks(76).enumerate() { + if i > 0 { + s.push('\n'); + } + s.push_str(std::str::from_utf8(chunk).unwrap_or_default()); + } + s.push_str(""); + } + } + } + s.push_str(""); + Ok(s.into_bytes()) +} + +/// An XML part's markup without its XML declaration, to inline as `pkg:xmlData`; `None` for +/// a part that isn't UTF-8 XML (or has a DOCTYPE), which goes in base64. +fn xml_fragment<'a>(ct: &str, bytes: &'a [u8]) -> Option<&'a str> { + let ct = ct.to_ascii_lowercase(); + if !(ct.ends_with("+xml") || ct.ends_with("/xml")) { + return None; + } + let s = std::str::from_utf8(bytes).ok()?; + let s = s.strip_prefix('\u{FEFF}').unwrap_or(s).trim_start(); + let s = match s.strip_prefix(" rest.split_once("?>").map(|(_, r)| r)?.trim_start(), + None => s, + }; + (s.starts_with('<') && !s.contains("").as_bytes() + ), + XmlKind::FlatOpc + ); + assert_eq!(sniff_xml(format!("\u{FEFF}").as_bytes()), XmlKind::WordMl2003); + assert_eq!(sniff_xml(b""), XmlKind::Other); + assert_eq!(sniff_xml(b""), XmlKind::Other); + assert_eq!(sniff_xml(b"\x00\xff garbage"), XmlKind::Other); + assert_eq!(sniff_xml(b""), XmlKind::Other); + } + + #[test] + fn inline_xml_inherits_namespaces_from_the_package() { + let frag = b"\n "; + let scope = vec![vec![("xmlns:pkg".to_string(), PKG_NS.to_string()), ("xmlns:w".to_string(), "urn:w".to_string())]]; + let out = String::from_utf8(with_scope(frag, &scope)).unwrap(); + assert_eq!(out, ""); + let own = b""; + assert_eq!(with_scope(own, &scope), own.to_vec()); + assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec()); + } +} diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 62b8478..e5895d3 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -10,10 +10,12 @@ mod chart_spec; mod crypt; mod custom; +mod flat; mod package; mod read; mod smart_art; mod units; +mod wordml2003; mod write; mod xml; @@ -22,10 +24,28 @@ pub use crypt::{ DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count, is_encrypted, protection_hash, }; +pub use flat::{XmlKind, sniff_xml, write as write_flat}; pub use read::read; pub use smart_art::smart_art_items; pub use write::{write, write_as}; +/// Largest `.xml` file read as a Word XML document. +pub const MAX_XML_FILE: usize = 1 << 30; + +/// Read a Word XML file (`.xml`): a Word XML Document (Flat OPC, the whole package in one XML +/// file) or a Word 2003 XML Document (WordprocessingML 2003), told apart by the root element. +/// Any other XML gives [`DocxError::NotWordXml`]. +pub fn read_xml(bytes: &[u8]) -> Result { + if bytes.len() > MAX_XML_FILE { + return Err(DocxError::Limit(format!("XML file of {} bytes", bytes.len()))); + } + match sniff_xml(bytes) { + XmlKind::FlatOpc => flat::read(bytes), + XmlKind::WordMl2003 => wordml2003::read(bytes), + XmlKind::Other => Err(DocxError::NotWordXml), + } +} + /// What [`DocxError::PasswordRequired`] says. pub const PASSWORD_REQUIRED: &str = "this document is protected with a password"; /// What [`DocxError::WrongPassword`] says. @@ -108,6 +128,9 @@ pub enum DocxError { /// The package is readable but isn't a WordprocessingML document. #[error("not a Word document: {0}")] NotWord(String), + /// An `.xml` file that is neither a Word XML Document (Flat OPC) nor Word 2003 XML. + #[error("not a Word XML document")] + NotWordXml, /// The package is encrypted and no password was given. #[error("{}", PASSWORD_REQUIRED)] PasswordRequired, diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 58d7e8d..002dd70 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -178,6 +178,12 @@ impl Package { Ok(Package { files }) } + /// A package from parts already in memory (a Flat OPC file, a converted document), by part + /// name without the leading `/`. + pub fn from_parts(files: BTreeMap>) -> Package { + Package { files } + } + /// Part bytes by name (leading `/` ignored; case-insensitive fallback). pub fn get(&self, name: &str) -> Option<&[u8]> { let n = name.trim_start_matches('/'); diff --git a/crates/docx/src/read/drawing_color.rs b/crates/docx/src/read/drawing_color.rs index e5a95c5..21a5a58 100644 --- a/crates/docx/src/read/drawing_color.rs +++ b/crates/docx/src/read/drawing_color.rs @@ -93,7 +93,8 @@ fn mix(c: Rgb, to: f64, v: f64) -> Rgb { Rgb(f(c.0), f(c.1), f(c.2)) } -fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb { +/// `c` with its HSL luminance changed by `f`. +pub(crate) fn adjust_lum(c: Rgb, f: impl Fn(f64) -> f64) -> Rgb { let (h, s, l) = to_hsl(c); from_hsl(h, s, f(l).clamp(0.0, 1.0)) } diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 590d607..0ac67dc 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -99,7 +99,12 @@ pub fn read(bytes: &[u8]) -> Result { /// [`read`] for bytes known not to be an encrypted package. pub(crate) fn read_package(bytes: &[u8]) -> Result { - let pkg = Package::open(bytes)?; + read_opened(&Package::open(bytes)?) +} + +/// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted +/// Word 2003 XML document). +pub(crate) fn read_opened(pkg: &Package) -> Result { let root_rels = pkg.rels(""); let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string()); let Some(root) = pkg.xml(&main)? else { @@ -112,7 +117,7 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result { let mut doc = Document::new(); doc.body.clear(); let mut r = Reader { - pkg: &pkg, + pkg, doc, pc: PropCtx::default(), revs: HashMap::new(), @@ -150,6 +155,10 @@ pub(crate) fn read_package(bytes: &[u8]) -> Result { log::warn!("docx: ignoring unreadable {what}: {e}"); } }; + // Styles come before settings, but their theme colours already follow its colour mapping. + if let Some(p) = part(rt::SETTINGS) { + r.read_color_mapping(&p); + } if let Some(p) = part(rt::STYLES) { lenient("styles", r.read_styles(&p)); } @@ -560,6 +569,7 @@ impl Reader<'_> { *slot = rgb; } } + self.pc.theme_colors = colors.clone(); self.doc.settings.theme_colors = colors; } if let Some(fs) = el.child("a:fontScheme") { @@ -577,6 +587,47 @@ impl Reader<'_> { self.pc.theme = self.doc.settings.theme_colors.clone(); } + /// `w:settings/w:clrSchemeMapping`: which scheme colour text1, background1… stand for. + fn read_color_mapping(&mut self, path: &str) { + let Ok(Some(root)) = self.xml(path) else { return }; + let Some(m) = root.child("w:clrSchemeMapping") else { return }; + let names = [ + "dark1", + "light1", + "dark2", + "light2", + "accent1", + "accent2", + "accent3", + "accent4", + "accent5", + "accent6", + "hyperlink", + "followedHyperlink", + ]; + let attrs = [ + "w:t1", + "w:bg1", + "w:t2", + "w:bg2", + "w:accent1", + "w:accent2", + "w:accent3", + "w:accent4", + "w:accent5", + "w:accent6", + "w:hyperlink", + "w:followedHyperlink", + ]; + let mut map = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + for (slot, a) in map.iter_mut().zip(attrs) { + if let Some(i) = m.attr(a).and_then(|v| names.iter().position(|n| *n == v)) { + *slot = i; + } + } + self.pc.color_map = Some(map); + } + fn read_styles(&mut self, path: &str) -> Result<(), DocxError> { let Some(root) = self.xml(path)? else { return Ok(()) }; // The default paragraph style must be called `Normal` in our model. @@ -653,12 +704,12 @@ impl Reader<'_> { if let Some(b) = tp.child("w:tblBorders") { t.borders = Some(props::borders(b)); } - t.fill = tp.child("w:shd").and_then(props::shd_fill); + t.fill = tp.child("w:shd").and_then(|e| self.pc.shd_fill(e)); t.cell_margins = tp.child("w:tblCellMar").map(props::margins); t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000)); } for c in s.children("w:tblStylePr").take(64) { - let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(props::shd_fill); + let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(|e| self.pc.shd_fill(e)); let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders); let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(); match c.attr("w:type") { diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 3270683..7a307db 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -12,14 +12,21 @@ use wordcraft_doc::section::{ SectionProps, SectionStart, }; +use super::drawing_color::adjust_lum; use crate::units::{int, measure, on_off, tw, u32_of}; use crate::xml::El; -/// Context for property reading: theme fonts and style id aliases. +/// Context for property reading: theme fonts and colours, and style id aliases. #[derive(Default)] pub struct PropCtx { pub major_font: String, pub minor_font: String, + /// The theme's colour scheme (dk1, lt1, dk2, lt2, accent1..6, hlink, folHlink); empty when the + /// package has none, and then `w:themeColor` falls back to the fixed colour. + pub theme_colors: Vec, + /// `w:clrSchemeMapping`: the scheme slot each of text1, background1, text2, background2, + /// accent1..6, hyperlink and followedHyperlink uses. `None` = the default (same order). + pub color_map: Option<[usize; 12]>, /// Style id renames (e.g. a localized default paragraph style id → `Normal`). pub alias: HashMap, /// The theme's colours (for WordArt effects in theme colours). @@ -36,31 +43,82 @@ impl PropCtx { (!f.is_empty()).then(|| f.clone()) } + /// The theme colour `e` names in `attr` (`w:themeColor`, `w:themeFill`), through the colour + /// scheme mapping, with its `tint` and `shade` (hex bytes) applied to the HSL luminance as + /// Word does. When given it supersedes the fixed colour; `None` leaves the fixed one. + fn theme_color(&self, e: &El, attr: &str, tint: &str, shade: &str) -> Option { + let logical = |i: usize| self.color_map.and_then(|m| m.get(i).copied()).unwrap_or(i); + let slot = match e.attr(attr)? { + "dark1" => 0, + "light1" => 1, + "dark2" => 2, + "light2" => 3, + "text1" => logical(0), + "background1" => logical(1), + "text2" => logical(2), + "background2" => logical(3), + "accent1" => logical(4), + "accent2" => logical(5), + "accent3" => logical(6), + "accent4" => logical(7), + "accent5" => logical(8), + "accent6" => logical(9), + "hyperlink" => logical(10), + "followedHyperlink" => logical(11), + _ => return None, + }; + let mut c = *self.theme_colors.get(slot)?; + let byte = |a: &str| e.attr(a).and_then(|v| u8::from_str_radix(v.trim(), 16).ok()).map(|v| f64::from(v) / 255.0); + if let Some(t) = byte(tint) { + c = adjust_lum(c, |l| l * t + (1.0 - t)); + } + if let Some(s) = byte(shade) { + c = adjust_lum(c, |l| l * s); + } + Some(c) + } + + /// `w:shd`: its fill colour (a theme fill first), or a solid pattern's colour. + pub fn shd_fill(&self, e: &El) -> Option { + if let Some(c) = self.theme_color(e, "w:themeFill", "w:themeFillTint", "w:themeFillShade") { + return Some(c); + } + // A solid pattern without a fill is painted in its pattern colour, a theme one first. + if e.attr("w:val") == Some("solid") + && e.attr("w:fill").and_then(Rgb::parse).is_none() + && let Some(c) = self.theme_color(e, "w:themeColor", "w:themeTint", "w:themeShade") + { + return Some(c); + } + shd_fill(e) + } + pub fn rpr(&self, e: &El) -> CharProps { let mut c = CharProps::default(); for k in e.els() { match k.name.as_str() { "w:rStyle" => c.style = k.attr("w:val").filter(|v| !v.is_empty()).map(|v| self.style_id(v)), "w:rFonts" => { - c.font = k - .attr("w:ascii") - .or_else(|| k.attr("w:hAnsi")) - .filter(|f| !f.is_empty()) - .map(str::to_string) - .or_else(|| k.attr("w:asciiTheme").or_else(|| k.attr("w:hAnsiTheme")).and_then(|t| self.theme_font(t))) - .or_else(|| { - // `w:eastAsia` and `w:cs` name fonts for other scripts; they stand in for the - // Latin font only when the run is hinted as that script. Otherwise the Latin - // font is inherited (a Normal style with only `w:eastAsia`/`w:cs` keeps the - // `docDefaults` font, as in Word). - match k.attr("w:hint") { - Some("eastAsia") => k.attr("w:eastAsia"), - Some("cs") => k.attr("w:cs"), - _ => None, - } - .filter(|f| !f.is_empty()) - .map(str::to_string) - }); + // A theme font supersedes the fixed font beside it (`w:asciiTheme` over + // `w:ascii`, `w:hAnsiTheme` over `w:hAnsi`). + let named = |a: &str| k.attr(a).filter(|f| !f.is_empty()).map(str::to_string); + let themed = |a: &str| k.attr(a).and_then(|t| self.theme_font(t)); + c.font = + themed("w:asciiTheme").or_else(|| named("w:ascii")).or_else(|| themed("w:hAnsiTheme")).or_else(|| named("w:hAnsi")).or_else( + || { + // `w:eastAsia` and `w:cs` name fonts for other scripts; they stand in for the + // Latin font only when the run is hinted as that script. Otherwise the Latin + // font is inherited (a Normal style with only `w:eastAsia`/`w:cs` keeps the + // `docDefaults` font, as in Word). + match k.attr("w:hint") { + Some("eastAsia") => k.attr("w:eastAsia"), + Some("cs") => k.attr("w:cs"), + _ => None, + } + .filter(|f| !f.is_empty()) + .map(str::to_string) + }, + ); // The complex-script font (Persian, Arabic, Hebrew text). A `w:cstheme` font is // left to inherit: the theme's per-script fonts aren't read. c.font_cs = k.attr("w:cs").filter(|f| !f.is_empty() && f.len() < 256).map(str::to_string); @@ -85,10 +143,11 @@ impl PropCtx { "w:vanish" => c.hidden = Some(on_off(k)), "w:rtl" => c.rtl = Some(on_off(k)), "w:color" => { - c.color = match k.attr("w:val") { - Some(v) if v.eq_ignore_ascii_case("auto") => Some(TextColor::Auto), - Some(v) => Rgb::parse(v).map(TextColor::Rgb), - None => None, + c.color = match (self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade"), k.attr("w:val")) { + (Some(t), _) => Some(TextColor::Rgb(t)), + (None, Some(v)) if v.eq_ignore_ascii_case("auto") => Some(TextColor::Auto), + (None, Some(v)) => Rgb::parse(v).map(TextColor::Rgb), + (None, None) => None, } } "w:spacing" => c.spacing = tw(k, "w:val").map(|v| v.clamp(-1584.0, 1584.0)), @@ -104,10 +163,11 @@ impl PropCtx { } else { c.underline = Some(Underline::Single); } - c.underline_color = k.attr("w:color").and_then(Rgb::parse); + c.underline_color = + self.theme_color(k, "w:themeColor", "w:themeTint", "w:themeShade").or_else(|| k.attr("w:color").and_then(Rgb::parse)); } "w:bdr" => c.border = Some(border(k)), - "w:shd" => c.shading = shd_fill(k), + "w:shd" => c.shading = self.shd_fill(k), "w:vertAlign" => { c.vert_align = k.attr("w:val").map(|v| match v { "superscript" => VertAlign::Superscript, @@ -179,7 +239,7 @@ impl PropCtx { } } "w:pBdr" => p.borders = Some(borders(k)), - "w:shd" => p.shading = shd_fill(k), + "w:shd" => p.shading = self.shd_fill(k), "w:tabs" => { let tabs: Vec = k.children("w:tab").take(64).filter_map(tab_stop).collect(); p.tabs = Some(tabs); @@ -213,6 +273,19 @@ impl PropCtx { } else if let Some(v) = tw(k, "w:firstLine") { p.indent_first = Some(v); } + // Character units (hundredths of a character), which win when non-zero. + let chars = |a: &str| k.attr(a).and_then(int).map(|v| v.clamp(-158_400, 158_400) as f32 / 100.0); + if let Some(v) = chars("w:leftChars").or_else(|| chars("w:startChars")) { + p.indent_left_chars = Some(v); + } + if let Some(v) = chars("w:rightChars").or_else(|| chars("w:endChars")) { + p.indent_right_chars = Some(v); + } + match (chars("w:hangingChars"), chars("w:firstLineChars")) { + (Some(h), f) if h != 0.0 || f.is_none() => p.indent_first_chars = Some(-h), + (_, Some(f)) => p.indent_first_chars = Some(f), + _ => {} + } } "w:jc" => p.align = k.attr("w:val").map(align), "w:outlineLvl" => p.outline_level = k.attr("w:val").and_then(u32_of).map(|v| v.min(9) as u8), @@ -254,8 +327,8 @@ fn tab_stop(t: &El) -> Option { Some(TabStop { pos, align, leader }) } -/// `w:shd/@w:fill` (a solid fill colour; `auto` = none). -pub fn shd_fill(e: &El) -> Option { +/// `w:shd/@w:fill` (a solid fill colour; `auto` = none), ignoring theme fills ([`PropCtx::shd_fill`]). +fn shd_fill(e: &El) -> Option { let fill = e.attr("w:fill").and_then(Rgb::parse); if fill.is_some() { return fill; @@ -320,7 +393,7 @@ impl PropCtx { "w:jc" => t.align = k.attr("w:val").map(align), "w:tblInd" => t.indent = tw(k, "w:w"), "w:tblBorders" => t.borders = Some(borders(k)), - "w:shd" => t.shading = shd_fill(k), + "w:shd" => t.shading = self.shd_fill(k), "w:tblLayout" => t.fixed = k.attr("w:type") == Some("fixed"), "w:bidiVisual" => t.bidi_visual = on_off(k), "w:tblCellMar" => t.cell_margins = Some(margins(k)), @@ -395,7 +468,7 @@ pub fn trpr(e: &El) -> RowProps { } /// `w:tcPr`; returns the props and whether the cell is a legacy `hMerge` continuation. -pub fn tcpr(e: &El) -> (CellProps, bool) { +pub fn tcpr(pc: &PropCtx, e: &El) -> (CellProps, bool) { let mut c = CellProps { span: 1, ..Default::default() }; let mut hcont = false; for k in e.els() { @@ -414,7 +487,7 @@ pub fn tcpr(e: &El) -> (CellProps, bool) { "w:hMerge" => hcont = k.attr("w:val") != Some("restart"), "w:vMerge" => c.vmerge = if k.attr("w:val") == Some("restart") { VMerge::Restart } else { VMerge::Continue }, "w:tcBorders" => c.borders = Some(borders(k)), - "w:shd" => c.shading = shd_fill(k), + "w:shd" => c.shading = pc.shd_fill(k), "w:noWrap" => c.no_wrap = on_off(k), "w:tcMar" => c.margins = Some(margins(k)), "w:textDirection" => c.text_direction = TextDirection::from_ooxml(k.attr("w:val").unwrap_or("")), diff --git a/crates/docx/src/read/story.rs b/crates/docx/src/read/story.rs index 702865c..94e5522 100644 --- a/crates/docx/src/read/story.rs +++ b/crates/docx/src/read/story.rs @@ -1295,10 +1295,12 @@ impl Reader<'_> { let mut tcs = Vec::new(); collect(tr, "w:tc", &mut tcs, &mut Vec::new(), 0); for (tc, cell_controls) in tcs.into_iter().take(MAX_COLS) { - let (mut props, hcont) = - tc.child("w:tcPr").map(tcpr).unwrap_or_else(|| (wordcraft_doc::props::CellProps { span: 1, ..Default::default() }, false)); + let (mut props, hcont) = tc + .child("w:tcPr") + .map(|p| tcpr(&self.pc, p)) + .unwrap_or_else(|| (wordcraft_doc::props::CellProps { span: 1, ..Default::default() }, false)); if let Some(ch) = tc.child("w:tcPr").and_then(|p| p.child("w:tcPrChange")) { - let old = ch.child("w:tcPr").map(|o| tcpr(o).0).unwrap_or_default(); + let old = ch.child("w:tcPr").map(|o| tcpr(&self.pc, o).0).unwrap_or_default(); props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, ch), old); } if hcont && let Some(prev) = row.cells.last_mut() { diff --git a/crates/docx/src/wordml2003.rs b/crates/docx/src/wordml2003.rs new file mode 100644 index 0000000..31de867 --- /dev/null +++ b/crates/docx/src/wordml2003.rs @@ -0,0 +1,576 @@ +//! Word 2003 XML Document (WordprocessingML 2003, root `w:wordDocument`, from the public Office +//! 2003 XML Reference Schemas). Import only. +//! +//! ECMA-376 kept most of the 2003 vocabulary (`w:p`, `w:r`, `w:pPr`, `w:rPr`, `w:tbl`, +//! `w:sectPr`, `w:styles`…), so the file is opened by rewriting it as the package it corresponds +//! to and reading that with the DOCX reader: one reader, one set of property rules. The rewrite +//! renames what changed (hyphenated names, `w:hlink`, `w:listPr`, `w:nfc`, `w:vmerge`…), moves +//! what 2003 keeps inline into parts (lists into numbering, headers and footers, footnotes, +//! endnotes, comments, `w:binData` pictures into media) and turns `aml:annotation` bookmarks, +//! comments and tracked insertions/deletions into their ECMA-376 elements. Bounded: the parser +//! caps depth and element count, pictures are capped in number and size. + +use std::collections::{BTreeMap, HashMap}; + +use base64::Engine as _; +use wordcraft_doc::Document; + +use crate::DocxError; +use crate::flat::W2003_NS; +use crate::package::{MAX_PART, MAX_TOTAL, Package, rt}; +use crate::write::PartRels; +use crate::xml::{self, El, MAX_DEPTH, NAMESPACES, Node, W}; + +/// Auxiliary hints (`wx:`): display hints Word writes alongside the real markup. +const AUX_NS: &str = "http://schemas.microsoft.com/office/word/2003/auxHint"; +/// Annotation markup (`aml:`): bookmarks, comments, tracked changes. +const AML_NS: &str = "http://schemas.microsoft.com/aml/2001/core"; + +/// Most pictures (`w:binData`) taken from one file. +const MAX_MEDIA: usize = 4096; +/// Most header, footer, footnote, endnote and comment stories. +const MAX_STORIES: usize = 50_000; + +/// Read a Word 2003 XML Document. +pub fn read(bytes: &[u8]) -> Result { + crate::read::read_opened(&convert(bytes)?) +} + +/// Parts the converted package gets relationships for. +const DOC: usize = 0; +const FOOTNOTES: usize = 1; +const ENDNOTES: usize = 2; +const COMMENTS: usize = 3; + +struct Conv { + /// `w:binData` name (`wordml://…`) → media target relative to `word/`. + media: HashMap, + files: BTreeMap>, + /// Relationships per part: document, footnotes, endnotes, comments, then one per header or + /// footer (index in `stories` + 4). + rels: Vec, + footnotes: Vec, + endnotes: Vec, + comments: Vec, + /// Header and footer parts: (part name under `word/`, root element). + stories: Vec<(String, El)>, + next_note: i64, + story_count: usize, +} + +/// Rewrite a Word 2003 XML Document as a WordprocessingML package. +pub(crate) fn convert(bytes: &[u8]) -> Result { + let root = xml::parse_ns(bytes, &[("w", W2003_NS), ("wx", AUX_NS), ("aml", AML_NS)])?; + if root.name != "w:wordDocument" { + return Err(DocxError::NotWord(format!("root element is {}", root.name))); + } + let mut c = Conv { + media: HashMap::new(), + files: BTreeMap::new(), + rels: (0..4).map(|_| PartRels::default()).collect(), + footnotes: Vec::new(), + endnotes: Vec::new(), + comments: Vec::new(), + stories: Vec::new(), + next_note: 1, + story_count: 0, + }; + let mut total = 0u64; + c.collect_media(&root, 0, &mut total)?; + + // Body: `wx:sect` / `wx:sub-section` wrappers unwrapped; the last section's properties last. + let mut body = El { name: "w:body".into(), ..El::default() }; + if let Some(b) = root.child("w:body") { + body.kids = c.kids(b, DOC, 1); + } + let last_sect = body.kids.iter().rposition(|n| matches!(n, Node::El(e) if e.name == "w:sectPr")); + if let Some(i) = last_sect { + let s = body.kids.remove(i); + body.kids.retain(|n| !matches!(n, Node::El(e) if e.name == "w:sectPr")); + body.kids.push(s); + } + let mut document = El { name: "w:document".into(), attrs: xml::body_ns(), kids: Vec::new() }; + document.kids.push(Node::El(body)); + + let mut overrides: Vec<(String, &str)> = Vec::new(); + let wml = |s: &str| format!("application/vnd.openxmlformats-officedocument.wordprocessingml.{s}+xml"); + let styles = c.styles(&root); + c.put("word/styles.xml", &styles); + c.rels[DOC].add(rt::STYLES, "styles.xml", false); + overrides.push(("/word/styles.xml".into(), "styles")); + if let Some(n) = root.child("w:lists").map(|l| c.numbering(l)) { + c.put("word/numbering.xml", &n); + c.rels[DOC].add(rt::NUMBERING, "numbering.xml", false); + overrides.push(("/word/numbering.xml".into(), "numbering")); + } + for (part, root_name, kids, rel, ct) in [ + (FOOTNOTES, "w:footnotes", std::mem::take(&mut c.footnotes), rt::FOOTNOTES, "footnotes"), + (ENDNOTES, "w:endnotes", std::mem::take(&mut c.endnotes), rt::ENDNOTES, "endnotes"), + (COMMENTS, "w:comments", std::mem::take(&mut c.comments), rt::COMMENTS, "comments"), + ] { + if kids.is_empty() { + continue; + } + let file = format!("{ct}.xml"); + c.put(&format!("word/{file}"), &El { name: root_name.into(), attrs: xml::body_ns(), kids }); + c.put_rels(&file, part); + c.rels[DOC].add(rel, &file, false); + overrides.push((format!("/word/{file}"), ct)); + } + for (i, (file, story)) in std::mem::take(&mut c.stories).into_iter().enumerate() { + c.put(&format!("word/{file}"), &story); + c.put_rels(&file, i + 4); + overrides.push((format!("/word/{file}"), if story.name == "w:ftr" { "footer" } else { "header" })); + } + c.put("word/document.xml", &document); + c.put_rels("document.xml", DOC); + + let mut root_rels = PartRels::default(); + root_rels.add(rt::OFFICE_DOC, "word/document.xml", false); + if let Some(props) = root.child("o:DocumentProperties") { + c.files.insert("docProps/core.xml".into(), core_xml(props)); + root_rels.add(rt::CORE, "docProps/core.xml", false); + } + c.files.insert("_rels/.rels".into(), root_rels.xml()); + + let mut w = W::new(); + w.open("Types", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/content-types")]); + w.empty("Default", &[("Extension", "rels"), ("ContentType", "application/vnd.openxmlformats-package.relationships+xml")]); + w.empty("Default", &[("Extension", "xml"), ("ContentType", "application/xml")]); + let mut exts: Vec<&str> = c.media.values().filter_map(|t| t.rsplit_once('.').map(|(_, e)| e)).collect(); + exts.sort_unstable(); + exts.dedup(); + for ext in exts { + w.empty("Default", &[("Extension", ext), ("ContentType", media_type(ext))]); + } + w.empty("Override", &[("PartName", "/word/document.xml"), ("ContentType", &wml("document.main"))]); + for (part, kind) in &overrides { + w.empty("Override", &[("PartName", part), ("ContentType", &wml(kind))]); + } + if c.files.contains_key("docProps/core.xml") { + w.empty("Override", &[("PartName", "/docProps/core.xml"), ("ContentType", "application/vnd.openxmlformats-package.core-properties+xml")]); + } + w.close("Types"); + c.files.insert("[Content_Types].xml".into(), w.into_bytes()); + Ok(Package::from_parts(c.files)) +} + +impl Conv { + /// Store an element as a part. + fn put(&mut self, path: &str, root: &El) { + let mut s = String::from("\n"); + s.push_str(&root.to_xml()); + self.files.insert(path.into(), s.into_bytes()); + } + + /// Store the relationships of `word/{file}` (if it has any). + fn put_rels(&mut self, file: &str, part: usize) { + if let Some(r) = self.rels.get(part).filter(|r| !r.is_empty()) { + let x = r.xml(); + self.files.insert(format!("word/_rels/{file}.rels"), x); + } + } + + /// Every `w:binData` in the file, decoded into a media part. + fn collect_media(&mut self, e: &El, depth: usize, total: &mut u64) -> Result<(), DocxError> { + if depth > MAX_DEPTH { + return Ok(()); + } + for k in e.els() { + if k.name == "w:binData" { + let Some(name) = k.attr("w:name").filter(|n| !self.media.contains_key(*n)) else { continue }; + if self.media.len() >= MAX_MEDIA { + continue; + } + let text = k.text(); + let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + if (b64.len() / 4 * 3) as u64 > MAX_PART { + return Err(DocxError::Limit(format!("picture {name} is larger than the size limit"))); + } + let Ok(bytes) = base64::engine::general_purpose::STANDARD_NO_PAD.decode(b64.trim_end_matches('=')) else { + log::warn!("wordml2003: skipping picture {name}: invalid base64"); + continue; + }; + *total = total.saturating_add(bytes.len() as u64); + if *total > MAX_TOTAL { + return Err(DocxError::Limit("pictures larger than the size limit".into())); + } + let ext = picture_ext(&bytes, name); + let target = format!("media/image{}.{ext}", self.media.len() + 1); + self.files.insert(format!("word/{target}"), bytes); + self.media.insert(name.to_string(), target); + } else { + self.collect_media(k, depth + 1, total)?; + } + } + Ok(()) + } + + /// The converted children of `e`. + fn kids(&mut self, e: &El, part: usize, depth: usize) -> Vec { + let mut out = Vec::with_capacity(e.kids.len()); + for n in &e.kids { + match n { + Node::Text(t) => out.push(Node::Text(t.clone())), + Node::El(k) => out.extend(self.node(k, part, depth + 1)), + } + } + out + } + + /// `e` converted: nothing (dropped), its children (unwrapped) or one element. + fn node(&mut self, e: &El, part: usize, depth: usize) -> Vec { + if depth > MAX_DEPTH { + return Vec::new(); + } + match e.name.as_str() { + "wx:sect" | "wx:sub-section" | "wx:pBdrGroup" => return self.kids(e, part, depth), + "w:binData" | "w:fonts" | "w:docPr" | "w:lists" | "w:styles" | "w:latentStyles" | "w:versionOfBuiltInStyleNames" => return Vec::new(), + "w:hlink" => return vec![Node::El(self.hyperlink(e, part, depth))], + "w:listPr" => return vec![Node::El(list_pr(e))], + "w:footnote" | "w:endnote" => return self.note(e, depth).into_iter().map(Node::El).collect(), + "aml:annotation" => return self.annotation(e, part, depth), + "w:sectPr" => return vec![Node::El(self.section(e, part, depth))], + "w:nfc" => return vec![Node::El(num_fmt(e))], + _ => {} + } + if !keep(&e.name) { + return Vec::new(); + } + let mut el = El { name: rename(&e.name), attrs: attrs(e), kids: self.kids(e, part, depth) }; + if el.name == "v:imagedata" + && let Some(target) = e.attr("src").and_then(|s| self.media.get(s)).cloned() + && let Some(r) = self.rels.get_mut(part) + { + let id = r.add(rt::IMAGE, &target, false); + el.attrs.retain(|(k, _)| k != "r:id"); + el.attrs.push(("r:id".into(), id)); + } + if el.name == "w:style" { + for (k, v) in &mut el.attrs { + if k == "w:default" && v == "on" { + *v = "1".into(); + } + } + } + vec![Node::El(el)] + } + + /// `w:hlink` → `w:hyperlink` (an external relationship for `w:dest`). + fn hyperlink(&mut self, e: &El, part: usize, depth: usize) -> El { + let mut attrs = Vec::new(); + if let Some(dest) = e.attr("w:dest").filter(|d| !d.is_empty()) + && let Some(r) = self.rels.get_mut(part) + { + attrs.push(("r:id".into(), r.add(rt::HYPERLINK, dest, true))); + } + for (from, to) in [("w:bookmark", "w:anchor"), ("w:screenTip", "w:tooltip"), ("w:target", "w:tgtFrame")] { + if let Some(v) = e.attr(from) { + attrs.push((to.into(), v.to_string())); + } + } + El { name: "w:hyperlink".into(), attrs, kids: self.kids(e, part, depth) } + } + + /// A footnote or endnote written inline in its run → a reference, the note in its part. + fn note(&mut self, e: &El, depth: usize) -> Option { + // Separator definitions (`w:type`) live in document settings, not in the text. + if e.attr("w:type").is_some() || self.story_count >= MAX_STORIES { + return None; + } + self.story_count += 1; + let foot = e.name == "w:footnote"; + let id = self.next_note.to_string(); + self.next_note = self.next_note.saturating_add(1); + let part = if foot { FOOTNOTES } else { ENDNOTES }; + let kids = self.kids(e, part, depth); + let note = El { name: e.name.clone(), attrs: vec![("w:id".into(), id.clone())], kids }; + if foot { + self.footnotes.push(Node::El(note)); + } else { + self.endnotes.push(Node::El(note)); + } + let name = if foot { "w:footnoteReference" } else { "w:endnoteReference" }; + Some(El { name: name.into(), attrs: vec![("w:id".into(), id)], kids: Vec::new() }) + } + + /// `aml:annotation`: bookmarks, comments and tracked insertions/deletions. + fn annotation(&mut self, e: &El, part: usize, depth: usize) -> Vec { + let id = e.attr("aml:id").filter(|v| v.parse::().is_ok()).unwrap_or("0").to_string(); + let el = |name: &str, attrs: Vec<(String, String)>| vec![Node::El(El { name: name.into(), attrs, kids: Vec::new() })]; + let author_date = |e: &El| { + let mut a = vec![("w:id".to_string(), id.clone())]; + if let Some(v) = e.attr("aml:author") { + a.push(("w:author".into(), v.to_string())); + } + if let Some(v) = e.attr("aml:createdate") { + a.push(("w:date".into(), v.to_string())); + } + a + }; + let content = |c: &mut Conv, part: usize| e.child("aml:content").map(|k| c.kids(k, part, depth + 1)).unwrap_or_default(); + match e.attr("w:type").unwrap_or("") { + "Word.Bookmark.Start" => { + el("w:bookmarkStart", vec![("w:id".into(), id.clone()), ("w:name".into(), e.attr("w:name").unwrap_or("").to_string())]) + } + "Word.Bookmark.End" => el("w:bookmarkEnd", vec![("w:id".into(), id)]), + "Word.Comment.Start" => el("w:commentRangeStart", vec![("w:id".into(), id)]), + "Word.Comment.End" => el("w:commentRangeEnd", vec![("w:id".into(), id)]), + "Word.Comment" => { + if self.story_count >= MAX_STORIES { + return Vec::new(); + } + self.story_count += 1; + let mut attrs = author_date(e); + if let Some(v) = e.attr("w:initials") { + attrs.push(("w:initials".into(), v.to_string())); + } + let kids = content(self, COMMENTS); + self.comments.push(Node::El(El { name: "w:comment".into(), attrs, kids })); + el("w:commentReference", vec![("w:id".into(), id)]) + } + t @ ("Word.Insertion" | "Word.Deletion") => { + let name = if t == "Word.Insertion" { "w:ins" } else { "w:del" }; + vec![Node::El(El { name: name.into(), attrs: author_date(e), kids: content(self, part) })] + } + _ => Vec::new(), + } + } + + /// `w:sectPr`: inline `w:hdr` / `w:ftr` become header and footer parts. + fn section(&mut self, e: &El, part: usize, depth: usize) -> El { + let mut refs = Vec::new(); + let mut rest = Vec::new(); + for n in &e.kids { + let Node::El(k) = n else { continue }; + if (k.name == "w:hdr" || k.name == "w:ftr") && self.story_count < MAX_STORIES { + self.story_count += 1; + let footer = k.name == "w:ftr"; + let kind = match k.attr("w:type") { + Some("even") => "even", + Some("first") => "first", + _ => "default", + }; + let idx = self.stories.len() + 4; + self.rels.push(PartRels::default()); + let kids = self.kids(k, idx, depth + 1); + let file = format!("{}{}.xml", if footer { "footer" } else { "header" }, self.stories.len() + 1); + self.stories.push((file.clone(), El { name: k.name.clone(), attrs: xml::body_ns(), kids })); + let id = self.rels.get_mut(part).map(|r| r.add(if footer { rt::FOOTER } else { rt::HEADER }, &file, false)).unwrap_or_default(); + let name = if footer { "w:footerReference" } else { "w:headerReference" }; + refs.push(Node::El(El { name: name.into(), attrs: vec![("w:type".into(), kind.into()), ("r:id".into(), id)], kids: Vec::new() })); + } else if k.name != "w:hdr" && k.name != "w:ftr" { + rest.extend(self.node(k, part, depth + 1)); + } + } + refs.extend(rest); + El { name: "w:sectPr".into(), attrs: attrs(e), kids: refs } + } + + /// `w:styles` → `styles.xml`, with the document's default fonts (`w:fonts/w:defaultFonts`). + fn styles(&mut self, root: &El) -> El { + let mut kids = Vec::new(); + if let Some(f) = root.child("w:fonts").and_then(|f| f.child("w:defaultFonts")) { + let fonts = El { name: "w:rFonts".into(), attrs: attrs(f), kids: Vec::new() }; + let rpr = El { name: "w:rPr".into(), attrs: Vec::new(), kids: vec![Node::El(fonts)] }; + let rpr_default = El { name: "w:rPrDefault".into(), attrs: Vec::new(), kids: vec![Node::El(rpr)] }; + kids.push(Node::El(El { name: "w:docDefaults".into(), attrs: Vec::new(), kids: vec![Node::El(rpr_default)] })); + } + if let Some(s) = root.child("w:styles") { + for st in s.children("w:style") { + kids.extend(self.node(st, DOC, 2)); + } + } + El { name: "w:styles".into(), attrs: xml::body_ns(), kids } + } + + /// `w:lists` → `numbering.xml`: `w:listDef` → `w:abstractNum`, `w:list` → `w:num`. + fn numbering(&mut self, lists: &El) -> El { + let mut kids = Vec::new(); + for def in lists.children("w:listDef") { + let Some(id) = def.attr("w:listDefId") else { continue }; + let mut a = El { name: "w:abstractNum".into(), attrs: vec![("w:abstractNumId".into(), id.to_string())], kids: Vec::new() }; + for k in def.els() { + let converted = match k.name.as_str() { + "w:plt" => { + let v = match k.attr("w:val").unwrap_or("") { + "SingleLevel" => "singleLevel", + "HybridMultilevel" => "hybridMultilevel", + _ => "multilevel", + }; + vec![Node::El(val_el("w:multiLevelType", v))] + } + "w:lsid" => vec![Node::El(val_el("w:nsid", k.attr("w:val").unwrap_or("")))], + "w:listStyleLink" => vec![Node::El(val_el("w:numStyleLink", k.attr("w:val").unwrap_or("")))], + _ => self.node(k, DOC, 2), + }; + a.kids.extend(converted); + } + kids.push(Node::El(a)); + } + for list in lists.children("w:list") { + let Some(id) = list.attr("w:ilfo") else { continue }; + let Some(abs) = list.child_val("w:ilst") else { continue }; + let mut n = + El { name: "w:num".into(), attrs: vec![("w:numId".into(), id.to_string())], kids: vec![Node::El(val_el("w:abstractNumId", abs))] }; + for o in list.children("w:lvlOverride") { + n.kids.extend(self.node(o, DOC, 2)); + } + kids.push(Node::El(n)); + } + El { name: "w:numbering".into(), attrs: xml::body_ns(), kids } + } +} + +/// ``. +fn val_el(name: &str, v: &str) -> El { + El { name: name.into(), attrs: vec![("w:val".into(), v.into())], kids: Vec::new() } +} + +/// `w:listPr` (`w:ilvl`, `w:ilfo`) → `w:numPr` (`w:ilvl`, `w:numId`). +fn list_pr(e: &El) -> El { + let mut kids = Vec::new(); + if let Some(v) = e.child_val("w:ilvl") { + kids.push(Node::El(val_el("w:ilvl", v))); + } + if let Some(v) = e.child_val("w:ilfo") { + kids.push(Node::El(val_el("w:numId", v))); + } + El { name: "w:numPr".into(), attrs: Vec::new(), kids } +} + +/// `w:nfc` (a number format code) → `w:numFmt`. +fn num_fmt(e: &El) -> El { + let f = match e.attr("w:val").and_then(|v| v.trim().parse::().ok()).unwrap_or(0) { + 1 => "upperRoman", + 2 => "lowerRoman", + 3 => "upperLetter", + 4 => "lowerLetter", + 5 => "ordinal", + 6 => "cardinalText", + 7 => "ordinalText", + 9 => "chicago", + 14 | 19 => "decimalFullWidth", + 15 => "decimalHalfWidth", + 18 => "decimalEnclosedCircle", + 22 => "decimalZero", + 23 => "bullet", + 24 => "ganada", + 25 => "chosung", + 26 => "decimalEnclosedFullstop", + 27 => "decimalEnclosedParen", + 255 => "none", + _ => "decimal", + }; + val_el("w:numFmt", f) +} + +/// Is an element or attribute with this name kept (a namespace the package knows, or none)? +fn keep(name: &str) -> bool { + match name.split_once(':') { + None => true, + Some(("xml", _)) => true, + Some((p, _)) => NAMESPACES.iter().any(|(q, _)| *q == p), + } +} + +/// A WordprocessingML 2003 name in its ECMA-376 spelling: `w:sz-cs` → `w:szCs`, `w:first-line` +/// → `w:firstLine`, `w:fareast` → `w:eastAsia`, `w:vmerge` → `w:vMerge`. +fn rename(name: &str) -> String { + let Some(local) = name.strip_prefix("w:") else { return name.to_string() }; + match local { + "fareast" => return "w:eastAsia".into(), + "vmerge" => return "w:vMerge".into(), + "hmerge" => return "w:hMerge".into(), + _ => {} + } + let mut out = String::from("w:"); + let mut up = false; + for c in local.chars() { + if c == '-' { + up = true; + } else if up { + out.extend(c.to_uppercase()); + up = false; + } else { + out.push(c); + } + } + out +} + +/// The attributes of `e` that the package keeps, renamed. +fn attrs(e: &El) -> Vec<(String, String)> { + e.attrs.iter().filter(|(k, _)| keep(k)).map(|(k, v)| (rename(k), v.clone())).collect() +} + +/// A picture's file extension: from its bytes, else from its `wordml://` name. +fn picture_ext(b: &[u8], name: &str) -> String { + let sniffed = if b.starts_with(b"\x89PNG") { + "png" + } else if b.starts_with(&[0xFF, 0xD8]) { + "jpeg" + } else if b.starts_with(b"GIF8") { + "gif" + } else if b.starts_with(b"BM") { + "bmp" + } else if b.get(40..44) == Some(b" EMF") { + "emf" + } else if b.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) || b.starts_with(&[0x01, 0x00, 0x09, 0x00]) { + "wmf" + } else { + "" + }; + if !sniffed.is_empty() { + return sniffed.into(); + } + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default(); + if !ext.is_empty() && ext.len() <= 5 && ext.chars().all(|c| c.is_ascii_alphanumeric()) && ext != "xml" && ext != "rels" { + ext + } else { + "bin".into() + } +} + +fn media_type(ext: &str) -> &'static str { + match ext { + "png" => "image/png", + "jpeg" | "jpg" => "image/jpeg", + "gif" => "image/gif", + "bmp" => "image/bmp", + "emf" => "image/x-emf", + "wmf" => "image/x-wmf", + _ => "application/octet-stream", + } +} + +/// `o:DocumentProperties` → `docProps/core.xml`. +fn core_xml(props: &El) -> Vec { + let mut w = W::new(); + w.open( + "cp:coreProperties", + &[ + ("xmlns:cp", "http://schemas.openxmlformats.org/package/2006/metadata/core-properties"), + ("xmlns:dc", "http://purl.org/dc/elements/1.1/"), + ("xmlns:dcterms", "http://purl.org/dc/terms/"), + ], + ); + for (from, to) in [ + ("o:Title", "dc:title"), + ("o:Subject", "dc:subject"), + ("o:Author", "dc:creator"), + ("o:Keywords", "cp:keywords"), + ("o:Description", "dc:description"), + ("o:LastAuthor", "cp:lastModifiedBy"), + ("o:Revision", "cp:revision"), + ("o:Category", "cp:category"), + ("o:Created", "dcterms:created"), + ("o:LastSaved", "dcterms:modified"), + ] { + if let Some(t) = props.child(from).map(El::text).filter(|t| !t.trim().is_empty()) { + w.leaf(to, &[], t.trim()); + } + } + w.close("cp:coreProperties"); + w.into_bytes() +} diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 9ab3066..d2fbe91 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -44,10 +44,10 @@ impl PartRels { } self.list.push((id.to_string(), kind.to_string(), target.to_string(), external)); } - fn is_empty(&self) -> bool { + pub(crate) fn is_empty(&self) -> bool { self.list.is_empty() } - fn xml(&self) -> Vec { + pub(crate) fn xml(&self) -> Vec { let mut w = W::new(); w.open("Relationships", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/relationships")]); for (id, kind, target, external) in &self.list { @@ -117,6 +117,12 @@ pub fn write(doc: &Document) -> Result, DocxError> { /// Write a package of the given flavour (`.docx`, `.docm`, `.dotx`, `.dotm`). pub fn write_as(doc: &Document, flavor: Flavor) -> Result, DocxError> { + zip_entries(&write_parts(doc, flavor)?) +} + +/// The parts of the package [`write_as`] zips, in order, `[Content_Types].xml` first: (part name +/// without the leading `/`, bytes). +pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result)>, DocxError> { // A range field must be whole in the file: drop markers that lost their partner in editing. let balanced; let doc = if doc.has_unbalanced_field_ranges() { @@ -451,8 +457,7 @@ pub fn write_as(doc: &Document, flavor: Flavor) -> Result, DocxError> { } w.close("Types"); entries.insert(0, ("[Content_Types].xml".into(), w.into_bytes())); - - zip_entries(&entries) + Ok(entries) } fn push_part(entries: &mut Vec<(String, Vec)>, overrides: &mut Vec<(String, String)>, path: &str, bytes: Vec, ct: &str, rels: PartRels) { diff --git a/crates/docx/src/write/props.rs b/crates/docx/src/write/props.rs index 26636ab..b9e0837 100644 --- a/crates/docx/src/write/props.rs +++ b/crates/docx/src/write/props.rs @@ -289,17 +289,30 @@ pub fn ppr_inner(w: &mut W, p: &ParaProps, framed: bool, num_change: Option<&Cha } w.empty("w:spacing", &a); } - if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() { + let chars = [p.indent_left_chars, p.indent_right_chars, p.indent_first_chars]; + if p.indent_left.is_some() || p.indent_right.is_some() || p.indent_first.is_some() || chars.iter().any(Option::is_some) { let l = p.indent_left.map(twips); let r = p.indent_right.map(twips); let f = p.indent_first.map(|v| (v < 0.0, twips(v.abs()))); + // Character units in hundredths of a character. + let hundredths = |v: f32| ((v.clamp(-1584.0, 1584.0) * 100.0).round() as i64).to_string(); + let [lc, rc, fc] = chars.map(|c| c.map(|v| (v < 0.0, hundredths(v.abs()), hundredths(v)))); let mut a: Vec<(&str, &str)> = Vec::new(); + if let Some((_, _, v)) = &lc { + a.push(("w:leftChars", v)); + } if let Some(l) = &l { a.push(("w:left", l)); } + if let Some((_, _, v)) = &rc { + a.push(("w:rightChars", v)); + } if let Some(r) = &r { a.push(("w:right", r)); } + if let Some((hang, v, _)) = &fc { + a.push((if *hang { "w:hangingChars" } else { "w:firstLineChars" }, v)); + } if let Some((hang, v)) = &f { a.push((if *hang { "w:hanging" } else { "w:firstLine" }, v)); } diff --git a/crates/docx/src/xml.rs b/crates/docx/src/xml.rs index c0c43c0..d38ea80 100644 --- a/crates/docx/src/xml.rs +++ b/crates/docx/src/xml.rs @@ -68,7 +68,10 @@ const STRICT: &[(&str, &str)] = &[ ("m", "http://purl.oclc.org/ooxml/officeDocument/math"), ]; -fn prefix_for(uri: &[u8]) -> &'static str { +fn prefix_for(uri: &[u8], extra: &[(&'static str, &str)]) -> &'static str { + if let Some((p, _)) = extra.iter().find(|(_, u)| u.as_bytes() == uri) { + return p; + } for (p, u) in NAMESPACES.iter().chain(OTHER_NAMESPACES).chain(STRICT) { if u.as_bytes() == uri { return p; @@ -210,10 +213,9 @@ impl El { } } -/// Parse XML bytes into the root element. /// UTF-16 LE/BE text with a byte-order mark as UTF-8 (`None` without one). Lossy: an unpaired /// surrogate becomes U+FFFD and a trailing odd byte is dropped. The result is at most 1.5× the input. -fn utf16_to_utf8(bytes: &[u8]) -> Option { +pub(crate) fn utf16_to_utf8(bytes: &[u8]) -> Option { let (rest, le) = match bytes.get(..2) { Some([0xFF, 0xFE]) => (bytes.get(2..)?, true), Some([0xFE, 0xFF]) => (bytes.get(2..)?, false), @@ -223,7 +225,14 @@ fn utf16_to_utf8(bytes: &[u8]) -> Option { Some(char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()) } +/// Parse XML bytes into the root element. pub fn parse(bytes: &[u8]) -> Result { + parse_ns(bytes, &[]) +} + +/// [`parse`] with `extra` namespaces (prefix, URI) mapped to canonical prefixes first, so a +/// related vocabulary (WordprocessingML 2003) reads with the prefixes the reader knows. +pub fn parse_ns(bytes: &[u8], extra: &[(&'static str, &str)]) -> Result { // UTF-16 (with a BOM) is transcoded to UTF-8; a UTF-8 BOM is stripped. let transcoded = utf16_to_utf8(bytes); let bytes = match &transcoded { @@ -246,7 +255,7 @@ pub fn parse(bytes: &[u8]) -> Result { buf.clear(); let (res, ev) = r.read_resolved_event_into(&mut buf).map_err(|e| DocxError::Xml(e.to_string()))?; let el_prefix = match &res { - ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref())), + ResolveResult::Bound(ns) => Some(prefix_for(ns.as_ref(), extra)), _ => None, }; match ev { @@ -271,7 +280,7 @@ pub fn parse(bytes: &[u8]) -> Result { let (ares, alocal) = r.resolver_mut().resolve_attribute(key); let alocal = String::from_utf8_lossy(alocal.as_ref()).into_owned(); let aname = match ares { - ResolveResult::Bound(ns) => format!("{}:{alocal}", prefix_for(ns.as_ref())), + ResolveResult::Bound(ns) => format!("{}:{alocal}", prefix_for(ns.as_ref(), extra)), _ => alocal, }; let val = match a.normalized_value(XmlVersion::Explicit1_0) { diff --git a/crates/docx/tests/char_units.rs b/crates/docx/tests/char_units.rs new file mode 100644 index 0000000..3aa09e3 --- /dev/null +++ b/crates/docx/tests/char_units.rs @@ -0,0 +1,55 @@ +//! Indents in character units (#534), common in Chinese documents: ECMA-376 Part 1 §17.3.1.12 +//! `w:ind` `w:firstLineChars`, `w:hangingChars`, `w:leftChars` and `w:rightChars`, in hundredths +//! of a character. Fixtures are hand-written. + +use std::io::{Read, Write}; + +use wordcraft_doc::Document; +use wordcraft_doc::props::ParaProps; + +fn zip(entries: &[(&str, &[u8])]) -> Vec { + 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 { + let root = r#""#; + let doc = format!( + r#"{body}"# + ); + zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())]) +} + +fn props(d: &Document) -> Vec { + d.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.clone()).collect() +} + +#[test] +fn character_indents_read_and_round_trip() { + let body = r#"一 + 二 + 三 + 四"#; + let d = wordcraft_docx::read(&docx(body)).unwrap(); + let p = props(&d); + assert_eq!((p[0].indent_first_chars, p[0].indent_first), (Some(2.0), Some(21.0))); + assert_eq!((p[1].indent_left_chars, p[1].indent_right_chars, p[1].indent_first_chars), (Some(1.5), Some(0.5), Some(-1.0))); + // Zero cancels an inherited value; the points apply. + assert_eq!((p[2].indent_first_chars, p[2].indent_first), (Some(0.0), Some(12.0))); + // Hostile values are clamped or dropped. + assert_eq!((p[3].indent_first_chars, p[3].indent_left_chars), (Some(1584.0), None)); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_docx::write(&d).unwrap())).unwrap(); + let mut xml = String::new(); + z.by_name("word/document.xml").unwrap().read_to_string(&mut xml).unwrap(); + for attr in [r#"w:firstLineChars="200""#, r#"w:leftChars="150""#, r#"w:rightChars="50""#, r#"w:hangingChars="100""#, r#"w:firstLineChars="0""#] { + assert!(xml.contains(attr), "{attr} missing: {xml}"); + } + let again = props(&wordcraft_docx::read(&docx(&xml[xml.find("").unwrap() + 8..xml.find("").unwrap()])).unwrap()); + assert_eq!(again[..3], p[..3]); +} diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 2d67a0a..1019346 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -1084,6 +1084,87 @@ fn formatting_revisions_round_trip() { assert_eq!(again.revisions, d.revisions); } +/// A package with `styles`, a theme (accent1 4472C4, accent2 ED7D31, major font "Major Face") and, +/// when given, `settings`. +fn themed_docx(body: &str, styles: &str, settings: Option<&str>) -> Document { + let theme = r#""#; + let styles = format!( + r#"{styles}"# + ); + let mut doc_rels = vec![("rId1", "styles", "styles.xml"), ("rId2", "theme", "theme/theme1.xml")]; + let mut extra = vec![("word/styles.xml", styles.as_str()), ("word/theme/theme1.xml", theme)]; + let settings = settings.map(|s| format!(r#"{s}"#)); + if let Some(s) = &settings { + doc_rels.push(("rId3", "settings", "settings.xml")); + extra.push(("word/settings.xml", s.as_str())); + } + wordcraft_docx::read(&docx(body, &doc_rels, &extra)).unwrap() +} + +/// Within one step per channel: Word's tint and shade round differently from ours in the last bit. +fn near(c: Option, want: Rgb) -> bool { + matches!(c, Some(TextColor::Rgb(Rgb(r, g, b))) if r.abs_diff(want.0) <= 1 && g.abs_diff(want.1) <= 1 && b.abs_diff(want.2) <= 1) +} + +#[test] +fn theme_colour_wins_over_the_fixed_colour() { + // Heading 1 as Word writes it: the stale w:val is ignored, accent1 darkened 25% is 2F5496. + let styles = r#""#; + let body = r#"H +abcdefgh"#; + let d = themed_docx(body, styles, None); + let h = d.styles.get("Heading1").unwrap(); + assert_eq!(h.chr.color, Some(TextColor::Rgb(Rgb(0x2F, 0x54, 0x96)))); + assert_eq!(h.chr.underline_color, Some(Rgb(0xED, 0x7D, 0x31))); + let p = paras(&d); + let at = |i: usize| p[1].props_of_char(i); + // Colours Word writes into w:val next to these theme colours (Grid Table and heading styles). + assert!(near(at(0).color, Rgb(0x8E, 0xAA, 0xDB)), "{:?}", at(0).color); + assert!(near(at(1).color, Rgb(0xB4, 0xC6, 0xE7)), "{:?}", at(1).color); + assert!(near(at(2).color, Rgb(0xD9, 0xE2, 0xF3)), "{:?}", at(2).color); + assert!(near(at(3).color, Rgb(0x1F, 0x37, 0x63)), "{:?}", at(3).color); + assert!(near(at(4).color, Rgb(0xC4, 0x59, 0x11)), "{:?}", at(4).color); + // text2 maps to dk2 without a w:clrSchemeMapping; hyperlink to hlink. + assert_eq!(at(5).color, Some(TextColor::Rgb(Rgb(0x44, 0x54, 0x6A)))); + assert_eq!(at(6).color, Some(TextColor::Rgb(Rgb(0x05, 0x63, 0xC1)))); + let fill = at(7).shading.unwrap(); + assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}"); +} + +#[test] +fn theme_colours_follow_the_colour_scheme_mapping() { + // Styles are read before settings; the mapping still applies to them. + let styles = + r#""#; + let body = r#"abc"#; + let mapping = r#""#; + let d = themed_docx(body, styles, Some(mapping)); + assert_eq!(d.styles.get("T1").unwrap().chr.color, Some(TextColor::Rgb(Rgb(0xED, 0x7D, 0x31)))); + let p = paras(&d); + assert_eq!(p[0].props_of_char(1).color, Some(TextColor::Rgb(Rgb(0, 0, 0))), "background1 → dark1"); + assert_eq!(p[0].props_of_char(2).color, Some(TextColor::Rgb(Rgb(0, 0, 0))), "dark1 isn't remapped"); +} + +#[test] +fn theme_colour_without_a_theme_keeps_the_fixed_colour() { + let d = read_body(r#"a"#); + assert_eq!(paras(&d)[0].props_of_char(0).color, Some(TextColor::Rgb(Rgb(0, 0xFF, 0)))); +} + +#[test] +fn theme_font_wins_over_the_fixed_font() { + let styles = r#""#; + let body = r#"H +abc"#; + let d = themed_docx(body, styles, None); + assert_eq!(d.styles.get("Heading1").unwrap().chr.font.as_deref(), Some("Major Face")); + let p = paras(&d); + assert_eq!(p[1].props_of_char(0).font.as_deref(), Some("Minor Face")); + assert_eq!(p[1].props_of_char(1).font.as_deref(), Some("Minor Face")); + // An explicit ascii font still beats a theme font given only for hAnsi. + assert_eq!(p[1].props_of_char(2).font.as_deref(), Some("Arial")); +} + /// Issue #400: moved text (ECMA-376 §17.13.5.21–28). `w:moveFrom` / `w:moveTo` inside named /// move ranges are the two ends of one move, paragraph marks included; the ranges are written /// back around them. A move missing an end, or whose range never ends, is a plain deletion and diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index e2c8c47..49f7b54 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -136,6 +136,9 @@ fn every_para_prop_round_trips() { indent_left: Some(36.0), indent_right: Some(18.0), indent_first: Some(-18.0), + indent_left_chars: Some(1.5), + indent_right_chars: Some(0.0), + indent_first_chars: Some(-1.0), space_before: Some(6.0), space_after: Some(12.0), line_spacing: Some(LineSpacing::Multiple(1.5)), @@ -1275,6 +1278,21 @@ fn second_generation_is_stable() { assert_eq!(d2, d1); } +/// A Word XML Document (Flat OPC) carries the same package as a .docx: reading it back gives +/// what the .docx round trip gives, pictures included (#548). +#[test] +fn flat_opc_round_trip_equals_docx() { + let d = kitchen_sink(); + let xml = wordcraft_docx::write_flat(&d).expect("write flat"); + let text = std::str::from_utf8(&xml).expect("utf-8"); + assert!(text.starts_with("")); + assert!(text.contains("pkg:name=\"/word/document.xml\"") && text.contains("")); + assert_eq!(wordcraft_docx::sniff_xml(&xml), wordcraft_docx::XmlKind::FlatOpc); + let back = wordcraft_docx::read_xml(&xml).expect("read flat"); + assert_eq!(back, rt(&d)); + assert_eq!(back.media.len(), 1); +} + #[test] fn builtin_stylesheet_survives() { let d = Document::new(); diff --git a/crates/docx/tests/word_xml.rs b/crates/docx/tests/word_xml.rs new file mode 100644 index 0000000..1a4287b --- /dev/null +++ b/crates/docx/tests/word_xml.rs @@ -0,0 +1,215 @@ +//! Word XML files (#548): Flat OPC (Word XML Document) and Word 2003 XML, from hand-written +//! samples (never files produced by Word). + +use base64::Engine as _; +use wordcraft_doc::props::Align; +use wordcraft_doc::section::NumFormat; +use wordcraft_doc::{Block, Document, InlineObject, Paragraph}; +use wordcraft_docx::{DocxError, XmlKind, read_xml, sniff_xml}; + +fn paras(doc: &Document) -> Vec<&Paragraph> { + doc.body.iter().filter_map(|b| b.as_para()).collect() +} + +fn part_text(doc: &Document, id: Option) -> String { + id.and_then(|i| doc.parts.get(&i)) + .map(|p| p.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::>().join("\n")) + .unwrap_or_default() +} + +fn tiny_png_b64() -> String { + let img = image::RgbaImage::from_fn(2, 2, |x, y| image::Rgba([x as u8 * 100, y as u8 * 100, 50, 255])); + let mut out = std::io::Cursor::new(Vec::new()); + img.write_to(&mut out, image::ImageFormat::Png).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(out.into_inner()); + // Line breaks inside base64, as the formats write it. + b64.as_bytes().chunks(20).map(|c| std::str::from_utf8(c).unwrap()).collect::>().join("\n") +} + +/// A minimal Flat OPC package written by hand: namespaces declared on the package element only, +/// a hyperlink relationship, a picture in base64 and an empty part. +#[test] +fn reads_a_hand_written_flat_opc_package() { + let png = tiny_png_b64(); + let xml = format!( + r#" + + + + + + + + + + + + + + Seed & soil + link + + + + + + {png} + + +"# + ); + assert_eq!(sniff_xml(xml.as_bytes()), XmlKind::FlatOpc); + let doc = read_xml(xml.as_bytes()).unwrap(); + let ps = paras(&doc); + assert_eq!(ps[0].plain_text(), "Seed & soil"); + assert_eq!(ps[0].props_at(0).bold, Some(true)); + assert_eq!(ps[1].props_at(0).link.as_deref(), Some("https://example.org/flat")); + assert!(matches!(ps[2].objects.first(), Some(InlineObject::Image { w, h, .. }) if *w == 24.0 && *h == 12.0)); + assert_eq!(doc.media.len(), 1); +} + +/// A Word 2003 XML Document, hand-written from the Office 2003 XML Reference Schemas. +fn wordml_2003_sample() -> String { + let png = tiny_png_b64(); + format!( + r##" + + + Garden notesAda Gardenerbeans + + + + + + + + + + + + + + + + PlanToo short? + + Plain + loud + thenmore + Sown in May. + + bean guide + Water the beans daily + + + + A1B1 + B2 + + {png} + + Garden header + + + + +"## + ) +} + +#[test] +fn imports_word_2003_xml() { + let xml = wordml_2003_sample(); + assert_eq!(sniff_xml(xml.as_bytes()), XmlKind::WordMl2003); + let doc = read_xml(xml.as_bytes()).unwrap(); + assert_eq!(doc.core.title, "Garden notes"); + assert_eq!(doc.core.creator, "Ada Gardener"); + + let h1 = doc.styles.get("Heading1").expect("Heading1 style"); + assert_eq!(h1.chr.bold, Some(true)); + assert_eq!(h1.chr.size, Some(16.0)); + + let ps = paras(&doc); + assert_eq!(ps[0].plain_text(), "Plan"); + assert_eq!(ps[0].props.style.as_deref(), Some("Heading1")); + assert!(ps[0].objects.iter().any(|o| matches!(o, InlineObject::BookmarkStart { name, .. } if name == "plan"))); + + let p = ps[1]; + assert_eq!(p.props.align, Some(Align::Center)); + assert_eq!(p.props.indent_first, Some(18.0)); + let loud = p.text.find("loud").unwrap(); + let c = p.props_of_char(loud); + assert_eq!((c.bold, c.italic, c.size, c.font.as_deref()), (Some(true), Some(true), Some(14.0), Some("Courier New"))); + assert!(c.underline.is_some() && c.color.is_some()); + assert!(p.plain_text().contains("Plain loud\tthen")); + let note = p.objects.iter().find_map(|o| match o { + InlineObject::NoteRef { id, .. } => Some(*id), + _ => None, + }); + assert_eq!(part_text(&doc, note), "Sown in May."); + + assert_eq!(ps[2].props_at(0).link.as_deref(), Some("https://example.org/beans")); + let listed = ps[3]; + let num = listed.props.numbering.expect("list paragraph"); + let lvl = doc.numbering.level(num.num, num.level).expect("list level"); + assert_eq!((lvl.format, lvl.text.as_str(), lvl.indent), (NumFormat::LowerLetter, "%1)", 36.0)); + let ins = listed.props_of_char(listed.text.find("daily").unwrap()).ins.and_then(|i| doc.revisions.get(i as usize)); + assert_eq!(ins.map(|r| r.author.as_str()), Some("Ada")); + let comment = doc.comments.values().next().expect("comment"); + assert_eq!((comment.author.as_str(), part_text(&doc, Some(comment.part)).as_str()), ("Bo", "Too short?")); + + let table = doc.body.iter().find_map(|b| match b.as_ref() { + Block::Table(t) => Some(t), + _ => None, + }); + let table = table.expect("table"); + assert_eq!(table.rows.len(), 2); + assert_eq!(table.rows[0].cells[1].blocks.first().and_then(|b| b.as_para()).map(|p| p.plain_text()).as_deref(), Some("B1")); + + let pic = ps.iter().flat_map(|p| p.objects.iter()).find(|o| matches!(o, InlineObject::Image { .. })); + assert!(matches!(pic, Some(InlineObject::Image { w, h, .. }) if *w == 36.0 && *h == 18.0)); + assert_eq!(doc.media.len(), 1); + + assert_eq!(doc.last_section.page_w, 612.0); + assert_eq!(doc.last_section.margin_left, 90.0); + assert_eq!(part_text(&doc, doc.last_section.headers.default), "Garden header"); +} + +#[test] +fn hostile_word_xml_is_an_error_not_a_panic() { + let flat = r#""#; + let w2003 = r#""#; + // Other XML (and non-XML) is refused with the Word XML error. + for other in ["hi", "", "not xml at all", ""] { + assert_eq!(read_xml(other.as_bytes()), Err(DocxError::NotWordXml), "{other:?}"); + } + // Broken packages: errors. + for bad in [ + flat.to_string(), + format!("{flat}"), + format!("{flat}!!!not base64"), + format!("{flat}"), + ] { + assert!(read_xml(bad.as_bytes()).is_err(), "{bad}"); + } + // Lenient leftovers never panic. + for odd in [ + format!("{flat}"), + format!( + "{w2003}%%%" + ), + format!( + "{w2003}" + ), + format!("{w2003}{}", "".repeat(5000)), + ] { + let _ = read_xml(odd.as_bytes()); + } + // Too many parts. + let mut many = String::from(flat); + for i in 0..20_001 { + many.push_str(&format!("AA==")); + } + many.push_str(""); + assert!(matches!(read_xml(many.as_bytes()), Err(DocxError::Limit(_)))); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 0a0682b..e7243a3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -70,11 +70,21 @@ pub fn specs() -> Vec { CommandSpec::new("edit.advancedFind", "Advanced Find", "Home › Editing › Find", advanced_find) .params(r#"{"text"?: string, …options of edit.find, "in"?: "main|selection", "highlight"?: bool}"#) .pure(), - CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, _| step(s, 1)).key("Mod+G / F3 / Mod+PageDown").pure(), - CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, _| step(s, -1)) - // Elsewhere Ctrl+Shift+G is Word Count; Ctrl+Page Up/Down browse the find results. - .key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp")) - .pure(), + CommandSpec::new("edit.findNext", "Find Next", "Home › Editing › Find", |s, v| { + read_opts(s, v); + step(s, 1) + }) + .key("Mod+G / F3 / Mod+PageDown") + .params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#) + .pure(), + CommandSpec::new("edit.findPrevious", "Find Previous", "Home › Editing › Find", |s, v| { + read_opts(s, v); + step(s, -1) + }) + // Elsewhere Ctrl+Shift+G is Word Count; Ctrl+Page Up/Down browse the find results. + .key(super::mac_or("Mod+Shift+G / Mod+PageUp", "Mod+PageUp")) + .params(r#"{"text"?: string, "matchCase"?: bool, "wholeWord"?: bool, "regex"?: bool}"#) + .pure(), CommandSpec::new("edit.replace", "Replace", "Home › Editing", replace).key("Mod+H").params(r#"{"text": string, "with": string, …options of edit.replaceAll}"#), CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all) .params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8317748..a958ab8 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -176,7 +176,7 @@ fn save(s: &mut Session, v: &Value) -> CmdResult { crate::io::save_path_with(&path, &s.doc, password).map_err(CmdError::Failed)?; let encrypted = password.is_some() && crate::io::can_encrypt(&path.to_string_lossy()); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); - if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json"].contains(&ext.as_str()) { + if ["docx", "docm", "dotx", "dotm", "odt", "rtf", "json", "xml"].contains(&ext.as_str()) { s.path = Some(path.clone()); s.dirty = false; } diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 1f8172d..ef9a353 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -95,7 +95,7 @@ pub fn specs() -> Vec { CommandSpec::new("format.shadow", "Shadow", "Home › Font › Text Effects", |s, v| toggle(s, v, |r| r.shadow, |c, on| c.shadow = Some(on))), CommandSpec::new("format.emboss", "Emboss", "Home › Font › Font", |s, v| toggle(s, v, |r| r.emboss, |c, on| c.emboss = Some(on))), CommandSpec::new("format.engrave", "Engrave", "Home › Font › Font", |s, v| toggle(s, v, |r| r.engrave, |c, on| c.engrave = Some(on))), - CommandSpec::new("format.font", "Font", "Home › Font", font).key("Mod+Shift+F").params(r#"{"name": string}"#), + CommandSpec::new("format.font", "Font", "Home › Font", font).key("Mod+Shift+F").params(r#"{"name"?: string, "eastAsia"?: string}"#), CommandSpec::new("format.size", "Font Size", "Home › Font", size).key("Mod+Shift+P").params(r#"{"size": number}"#), CommandSpec::new("format.growFont", "Increase Font Size", "Home › Font", |s, _| step_size(s, 1)).key("Mod+Shift+. / Mod+Shift+>"), CommandSpec::new("format.shrinkFont", "Decrease Font Size", "Home › Font", |s, _| step_size(s, -1)).key("Mod+Shift+, / Mod+Shift+<"), @@ -275,23 +275,37 @@ fn underline(s: &mut Session, v: &Value) -> CmdResult { apply(s, &|c| c.underline = Some(u)) } +/// `format.font`: `name` is the font (Latin text, and Persian/Arabic text too); `eastAsia` sets the +/// East Asian text's font on its own, as the Font dialog's Asian text font box does (#505). fn font(s: &mut Session, v: &Value) -> CmdResult { - let name = p::req_str(v, "name")?.trim().to_string(); - if name.is_empty() || name.len() > 128 { - return Err(CmdError::Params("bad font name".into())); + let arg = |k: &str| -> Result, CmdError> { + let Some(n) = v.get(k) else { return Ok(None) }; + let n = n.as_str().map(str::trim).unwrap_or(""); + if n.is_empty() || n.len() > 128 { + return Err(CmdError::Params(format!("bad font name `{k}`"))); + } + Ok(Some(n.to_string())) + }; + let (name, east_asia) = (arg("name")?, arg("eastAsia")?); + if name.is_none() && east_asia.is_none() { + return Err(CmdError::Params("`name` or `eastAsia` (string) is required".into())); } // The font applies to Persian/Arabic text in the selection too (its complex-script font). A // Chinese, Japanese or Korean font is the East Asian text's font as well (`w:eastAsia`), as in // Word; choosing a Latin font leaves that text's font as it was. - let east_asian = { - let r = wordcraft_fonts::word::resolve(&name, false, false); - !r.substituted && r.face.is_east_asian() - }; + let ea = east_asia.or_else(|| { + name.clone().filter(|n| { + let r = wordcraft_fonts::word::resolve(n, false, false); + !r.substituted && r.face.is_east_asian() + }) + }); apply(s, &|c| { - c.font = Some(name.clone()); - c.font_cs = Some(name.clone()); - if east_asian { - c.font_ea = Some(name.clone()); + if let Some(n) = &name { + c.font = Some(n.clone()); + c.font_cs = Some(n.clone()); + } + if let Some(n) = &ea { + c.font_ea = Some(n.clone()); } }) } @@ -571,6 +585,7 @@ pub fn state(s: &Session) -> Value { let style_name = s.doc.styles.get(&style).map(|st| st.name.clone()).unwrap_or(style.clone()); json!({ "font": same(&|r| json!(r.font)), + "fontEastAsia": same(&|r| json!(r.font_ea)), "size": same(&|r| json!(r.size)), "bold": all.iter().all(|r| r.bold), "italic": all.iter().all(|r| r.italic), diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 618b227..e7f58cb 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -220,6 +220,8 @@ fn define(s: &mut Session, v: &Value) -> CmdResult { pp.numbering = Some(NumRef { num, level }); pp.indent_left = None; pp.indent_first = None; + pp.indent_left_chars = None; + pp.indent_first_chars = None; if pp.style.is_none() || pp.style.as_deref() == Some("Normal") { pp.style = Some("ListParagraph".into()); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5bc81a5..62042a2 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -291,6 +291,8 @@ pub fn leave_list(para: &mut wordcraft_doc::Paragraph) { } para.props.indent_left = None; para.props.indent_first = None; + para.props.indent_left_chars = None; + para.props.indent_first_chars = None; para.touch(); } diff --git a/crates/engine/src/cmd/para.rs b/crates/engine/src/cmd/para.rs index 71241a0..1bb4831 100644 --- a/crates/engine/src/cmd/para.rs +++ b/crates/engine/src/cmd/para.rs @@ -43,6 +43,7 @@ pub fn specs() -> Vec { let l = p.indent_left.unwrap_or(0.0) + 36.0; p.indent_left = Some(l); p.indent_first = Some(-36.0); + p.cancel_char_indents(true, false, true); }) }) .key("Mod+T"), @@ -50,6 +51,7 @@ pub fn specs() -> Vec { fmt(s, &|p| { p.indent_left = Some((p.indent_left.unwrap_or(0.0) - 36.0).max(0.0)); p.indent_first = Some(0.0); + p.cancel_char_indents(true, false, true); }) }) .key("Mod+Shift+T"), @@ -65,6 +67,7 @@ pub fn specs() -> Vec { if let Some(x) = f { p.indent_first = Some(x.clamp(-1584.0, 1584.0)); } + p.cancel_char_indents(l.is_some(), r.is_some(), f.is_some()); }) }) .params(r#"{"left"?: pt, "right"?: pt, "firstLine"?: pt (negative = hanging)}"#), @@ -319,7 +322,10 @@ pub fn indent(s: &mut Session, _: &Value) -> CmdResult { let para = s.doc.para_mut(a.story, &path)?; match num { Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: (n.level + 1).min(8) }), - None => para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab), + None => { + para.props.indent_left = Some(((left / tab).floor() + 1.0) * tab); + para.props.cancel_char_indents(true, false, false); + } } para.touch(); } @@ -338,7 +344,10 @@ pub fn outdent(s: &mut Session, _: &Value) -> CmdResult { let para = s.doc.para_mut(a.story, &path)?; match num { Some(n) => para.props.numbering = Some(NumRef { num: n.num, level: n.level.saturating_sub(1) }), - None => para.props.indent_left = Some((((left / tab).ceil() - 1.0) * tab).max(0.0)), + None => { + para.props.indent_left = Some((((left / tab).ceil() - 1.0) * tab).max(0.0)); + para.props.cancel_char_indents(true, false, false); + } } para.touch(); } @@ -443,6 +452,8 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult { p.numbering = Some(NumRef { num: 0, level: 0 }); p.indent_left = None; p.indent_first = None; + p.indent_left_chars = None; + p.indent_first_chars = None; if p.style.as_deref() == Some("ListParagraph") { p.style = None; } @@ -466,6 +477,8 @@ fn list(s: &mut Session, v: &Value, default: ListKind) -> CmdResult { p.numbering = Some(NumRef { num, level }); p.indent_left = None; p.indent_first = None; + p.indent_left_chars = None; + p.indent_first_chars = None; if p.style.is_none() || p.style.as_deref() == Some("Normal") { p.style = Some("ListParagraph".into()); } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1f98d19..00eb738 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -520,9 +520,10 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { super::leave_list(s.doc.para_mut(f.story, &f.path)?); return sel_result(s); } - if para.props.indent_first.is_some_and(|x| x > 0.0) { + if para.props.indent_first.is_some_and(|x| x > 0.0) || para.props.indent_first_chars.is_some_and(|x| x > 0.0) { let p = s.doc.para_mut(f.story, &f.path)?; p.props.indent_first = Some(0.0); + p.props.cancel_char_indents(false, false, true); p.touch(); return sel_result(s); } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 6de1952..59a43fa 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -20,11 +20,12 @@ pub const OPEN_EXTS: &[&str] = &[ "tex", "latex", "ltx", + "xml", "wcraft.json", "json", ]; /// Formats WordCraft saves (Save As). -pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json"]; +pub const SAVE_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "pdf", "txt", "md", "html", "rtf", "odt", "tex", "png", "json", "xml"]; fn ext_of(name: &str) -> String { let lower = name.to_ascii_lowercase(); diff --git a/crates/engine/src/io_ext.rs b/crates/engine/src/io_ext.rs index 6c41656..9fbbb2f 100644 --- a/crates/engine/src/io_ext.rs +++ b/crates/engine/src/io_ext.rs @@ -7,6 +7,8 @@ pub fn open(ext: &str, bytes: &[u8]) -> Option> { match ext { "docx" | "docm" | "dotx" | "dotm" => Some(wordcraft_docx::read(bytes).map_err(|e| e.to_string())), "doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())), + // Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element. + "xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())), other => wordcraft_formats::import(other, bytes), } } @@ -18,6 +20,7 @@ pub fn save(ext: &str, doc: &Document) -> Option, String>> { return Some(wordcraft_docx::write_as(doc, flavor).map_err(|e| e.to_string())); } match ext { + "xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())), "png" => Some(render_png(doc, 0, 2.0)), "pdf" => Some(wordcraft_pdf::export(doc, &Default::default()).map_err(|e| e.to_string())), other => wordcraft_formats::export(other, doc), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a77dc6b..d11f18f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -650,6 +650,30 @@ fn find_reads_the_text_around_tracked_deletions() { assert_eq!(r["remaining"], 1); } +#[test] +fn find_next_with_options_visits_every_match() { + // The Find dialog sends its options with each Find Next (#468): every click is one step. + let opts = json!({"text": "cat", "with": "", "matchCase": false, "wholeWord": false, "regex": false}); + let starts = |text: &str, clicks: usize, cmd: &str| { + let mut s = s(); + run(&mut s, "document.setText", json!({"text": text})); + run(&mut s, "caret.docStart", json!({})); + (0..clicks).map(|_| run(&mut s, cmd, opts.clone())["index"].as_u64().unwrap()).collect::>() + }; + assert_eq!(starts("cat cat cat cat", 5, "edit.findNext"), [0, 1, 2, 3, 0]); + assert_eq!(starts("cat cat", 3, "edit.findNext"), [0, 1, 0]); + assert_eq!(starts("cat cat cat", 4, "edit.findPrevious"), [2, 1, 0, 2]); + + // A new query takes effect at once, without a separate `edit.find`. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "cat dog cat dog"})); + run(&mut s, "caret.docStart", json!({})); + assert_eq!(run(&mut s, "edit.findNext", opts.clone())["index"], 0); + let r = run(&mut s, "edit.findNext", json!({"text": "dog"})); + assert_eq!((r["index"].clone(), r["count"].clone()), (json!(0), json!(2))); + assert_eq!(s.sel.ordered().0.off, 4); +} + #[test] fn clipboard_round_trip() { let mut s = s(); @@ -2954,6 +2978,19 @@ fn style_visibility_round_trips_through_docx() { assert!(s.doc.styles.get("Heading4").is_some_and(|h| !h.quick), "undoable"); } +/// #548: `.xml` saves a Word XML Document (Flat OPC) and opens it again; other XML is refused. +#[test] +fn word_xml_saves_and_opens_through_io() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Kept in one XML file"})); + let bytes = crate::io::save_bytes("notes.xml", &s.doc).unwrap(); + assert_eq!(wordcraft_docx::sniff_xml(&bytes), wordcraft_docx::XmlKind::FlatOpc); + let doc = crate::io::open_bytes("notes.XML", &bytes).unwrap(); + assert_eq!(doc.body, crate::io::open_bytes("n.docx", &crate::io::save_bytes("n.docx", &s.doc).unwrap()).unwrap().body); + let err = crate::io::open_bytes("feed.xml", b"").unwrap_err(); + assert!(err.contains("not a Word XML document"), "{err}"); +} + /// #146: the first/last column, total row and banded column regions apply in layout (later /// regions win: the total row's fill beats the first column's) and round-trip through .docx. #[test] @@ -3609,3 +3646,28 @@ fn outline_collapse_hides_descendants_and_expand_restores() { run(&mut s, "outline.selectSubtree", json!({"block": 0})); assert_eq!(s.selected_text(), "One\na\nTwo\nb"); } + +#[test] +fn format_font_sets_the_asian_and_latin_fonts_separately() { + // #505: Word's Font dialog sets an Asian text font and a Latin text font of their own. + let mut s = s(); + run(&mut s, "document.setText", json!({"text": "Hello 你好"})); + run(&mut s, "select.all", json!({})); + let fonts = |s: &Session| { + let c = s.doc.para(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props_of_char(0).clone(); + (c.font, c.font_ea) + }; + let before = fonts(&s); + run(&mut s, "format.font", json!({"name": "Georgia", "eastAsia": "SimSun"})); + assert_eq!(fonts(&s), (Some("Georgia".into()), Some("SimSun".into()))); + assert_eq!(run(&mut s, "format.state", json!({}))["fontEastAsia"], "SimSun"); + // The Asian font alone leaves the Latin font as it is. + run(&mut s, "format.font", json!({"eastAsia": "Noto Sans CJK SC"})); + assert_eq!(fonts(&s), (Some("Georgia".into()), Some("Noto Sans CJK SC".into()))); + // Each change is one undo step. + run(&mut s, "edit.undo", json!({})); + assert_eq!(fonts(&s), (Some("Georgia".into()), Some("SimSun".into()))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(fonts(&s), before); + assert!(s.run("format.font", &json!({})).is_err() && s.run("format.font", &json!({"eastAsia": " "})).is_err()); +} diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 0de5de5..c10e7f7 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -219,12 +219,18 @@ impl FontFace { /// (Shift-JIS, GB2312, Wansung, Big5, Johab), or, without them, it maps common ideographs. /// Word applies such a font to East Asian text too (`w:eastAsia`). pub fn is_east_asian(&self) -> bool { - use skrifa::raw::TableProvider; - const CJK_CODE_PAGES: u32 = 0b1_1111 << 17; - match self.skrifa().and_then(|f| f.os2().ok()).and_then(|t| t.ul_code_page_range_1()) { - Some(pages) if pages != 0 => pages & CJK_CODE_PAGES != 0, - _ => ['中', '国', '日'].iter().all(|c| self.covers(*c)), - } + self.skrifa().is_some_and(|f| east_asian_font(&f)) + } +} + +/// Is `f` an East Asian (Chinese, Japanese or Korean) font: do its OS/2 code pages say so +/// (Shift-JIS, GB2312, Wansung, Big5, Johab), or, without them, does it map common ideographs? +fn east_asian_font(f: &skrifa::FontRef<'_>) -> bool { + use skrifa::raw::TableProvider; + const CJK_CODE_PAGES: u32 = 0b1_1111 << 17; + match f.os2().ok().and_then(|t| t.ul_code_page_range_1()) { + Some(pages) if pages != 0 => pages & CJK_CODE_PAGES != 0, + _ => ['中', '国', '日'].iter().all(|c| f.charmap().map(*c).is_some()), } } @@ -265,6 +271,8 @@ struct CatalogEntry { style: String, legacy: String, local: Vec, + /// A Chinese, Japanese or Korean font ([`FontFace::is_east_asian`]). + east_asian: bool, path: std::path::PathBuf, } @@ -576,8 +584,9 @@ fn file_face_names(path: &std::path::Path) -> Vec { read_at(offset.into(), len as usize) }; let name = table(b"name")?; - // Variable fonts: the named instances. - let mut tables: Vec<([u8; 4], Vec)> = table(b"fvar").map(|t| (*b"fvar", t)).into_iter().collect(); + // Variable fonts: the named instances; OS/2: the code pages (East Asian fonts). + let mut tables: Vec<([u8; 4], Vec)> = table(b"OS/2").map(|t| (*b"OS/2", t)).into_iter().collect(); + tables.extend(table(b"fvar").map(|t| (*b"fvar", t))); tables.push((*b"name", name)); // A font holding just those tables, to read them as the font itself would. let font = mini_font(&tables); @@ -660,6 +669,9 @@ struct Found { legacy: String, /// The family's names in other languages ([`FontFace::local_names`]). local: Vec, + /// A Chinese, Japanese or Korean font ([`FontFace::is_east_asian`]); the scan's catalog keeps it. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + east_asian: bool, /// Variable fonts: the named instance's axis settings. coords: Vec<([u8; 4], f32)>, } @@ -669,6 +681,7 @@ struct Found { fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec { let Some((family, style, id1)) = face_names(f) else { return vec![] }; let local = local_names(f, &family, id1.as_deref()); + let east_asian = east_asian_font(f); let axes = f.axes(); let mut named: Vec = Vec::new(); for ni in f.named_instances().iter() { @@ -681,13 +694,13 @@ fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec { let legacy = legacy_family(&family, &style); // Name ID 1 names the default instance only: keep the names of the whole family. let local = local.iter().filter(|n| n.english.eq_ignore_ascii_case(&family)).cloned().collect(); - named.push(Found { index, family: family.clone(), style, legacy, local, coords }); + named.push(Found { index, family: family.clone(), style, legacy, local, east_asian, coords }); } if !named.is_empty() { return named; } let legacy = id1.filter(|n| !n.eq_ignore_ascii_case(&family)).unwrap_or_else(|| legacy_family(&family, &style)); - vec![Found { index, family, style, legacy, local, coords: Vec::new() }] + vec![Found { index, family, style, legacy, local, east_asian, coords: Vec::new() }] } /// Parse every face in `data` (a font file or collection); a variable font yields one face per @@ -872,6 +885,17 @@ impl FontDb { v } + /// The installed Chinese, Japanese and Korean families (English names), for the Font dialog's + /// Asian text font list. + pub fn east_asian_families(&self) -> Vec { + let mut v: Vec = self.read_faces().iter().filter(|f| f.is_east_asian()).map(|f| f.family.clone()).collect(); + #[cfg(not(target_arch = "wasm32"))] + v.extend(self.read_catalog().iter().filter(|c| c.east_asian).map(|c| c.family.clone())); + v.sort_by_key(|a| a.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v + } + /// Every family's name in the interface language `lang` (`zh-hans`, `zh-hant`, `ja`…), as /// (English name, localized name) pairs: SimSun is 宋体 in Simplified Chinese. Families without /// a name in that language are left out. @@ -1040,7 +1064,14 @@ impl FontDb { continue; } for f in file_face_names(&p) { - found.push(CatalogEntry { family: f.family, style: f.style, legacy: f.legacy, local: f.local, path: p.clone() }); + found.push(CatalogEntry { + family: f.family, + style: f.style, + legacy: f.legacy, + local: f.local, + east_asian: f.east_asian, + path: p.clone(), + }); } } } diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index 7cdbb23..d51fd64 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -369,3 +369,37 @@ fn chinese_fonts_are_listed_by_their_chinese_names_in_a_chinese_interface() { assert_eq!(name_language_matches(ui, tag), ok, "{ui} {tag}"); } } + +/// `data` with its OS/2 code page bits (`ulCodePageRange1`) set to `pages`. +fn with_code_pages(data: &[u8], pages: u32) -> Vec { + let font = skrifa::FontRef::new(data).unwrap(); + let tables: Vec<([u8; 4], Vec)> = font + .table_directory() + .table_records() + .iter() + .map(|r| { + let mut t = font.table_data(r.tag()).unwrap().as_bytes().to_vec(); + if r.tag() == skrifa::Tag::new(b"OS/2") { + t[78..82].copy_from_slice(&pages.to_be_bytes()); + } + (r.tag().to_be_bytes(), t) + }) + .collect(); + mini_font(&tables) +} + +#[test] +fn east_asian_fonts_are_listed_for_the_asian_text_font_box() { + // #505: the Font dialog's Asian text font box lists the Chinese, Japanese and Korean fonts, + // found by their OS/2 code pages (here GB2312) during the scan, without loading them. + let dir = font_dir("eastasian"); + let song = with_family_names("SourceSans3-Regular.ttf", &[(0x0409, "Songti Test"), (0x0804, "测试宋体")]); + std::fs::write(dir.join("song.ttf"), with_code_pages(&song, 1 << 18)).unwrap(); + let db = FontDb::with_font_dirs(vec![dir]); + let listed = |db: &FontDb| db.east_asian_families().iter().filter(|f| *f == "Songti Test").count(); + assert_eq!(listed(&db), 1); + assert!(!has(&db.east_asian_families(), FAMILY), "Latin fonts are left out"); + // Loaded, it is still listed once. + assert!(db.face("Songti Test", "Regular").is_east_asian()); + assert_eq!(listed(&db), 1); +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 029fac8..d67d1e9 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -2452,7 +2452,14 @@ fn place_para(ctx: &mut Ctx, pb: &mut PageBuilder, p: &Paragraph, block: usize, if bottom > limit + 0.01 && l1 > l0 && !split { break; } - if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split { + // A line holding nothing but a page or column break stays at the bottom of the page + // even when it doesn't fit: it only ends the page, and moving it would leave a blank + // page behind it (Word does the same). + let only_break = matches!(l.end, LineEnd::PageBreak | LineEnd::ColumnBreak) + && p.text + .get(l.start..l.stop) + .is_some_and(|t| t.chars().all(|c| matches!(c, wordcraft_doc::para::PAGE_BREAK | wordcraft_doc::para::COLUMN_BREAK))); + if bottom > limit + 0.01 && l1 == l0 && !pb.at_top() && !split && !only_break { break; } new_notes.extend(line_notes); @@ -2794,7 +2801,16 @@ fn place_floating_table(pb: &mut PageBuilder, tl: &table::TableLayout, f: &Table }; let x = float_table_x(pb, w, f, legacy); let r = clear_of_float_tables(Rect::new(x, align_in(v_area, f.y, h, f.v_align), w, h), f, legacy, h_area.0, &pb.float_tables); - if attempt == 0 && r.bottom() > pb.bottom + 0.01 && !pb.at_top() && f.v_rel == Anchor::Paragraph { + // Like Word, a table that doesn't fit below the text before it moves to the next page with + // its anchor: one placed relative to the paragraph when it would run off the page, one + // placed on the page or margin when it would also cover the text already on the page. + let moves = if f.v_rel == Anchor::Paragraph { + r.bottom() > pb.bottom + 0.01 + } else { + let covers_text = r.y < pb.y - 0.01 && r.bottom() > pb.top + 0.01 && r.x < pb.col_x() + pb.col_w() && r.right() > pb.col_x(); + covers_text && pb.y + h > pb.bottom + 0.01 + }; + if attempt == 0 && moves && !pb.at_top() { pb.advance(block, body_top); continue; } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 8e190be..880f6bf 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -952,17 +952,21 @@ pub(crate) fn left_out(rc: &ResolvedChar, show_hidden: bool, hide_deleted: bool) pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { let doc = env.doc; let mut rp = doc.styles.resolve_para_in(&p.props, env.table.map(|t| &t.para)); + let para_style = p.props.style.as_deref(); + let table_chr = env.table.map(|t| &t.chr); + // Indents in characters are measured in the paragraph mark's font size. + if rp.indent_chars.iter().any(|c| *c != 0.0) { + rp.set_char_unit(doc.styles.resolve_char_in(para_style, table_chr, &p.mark).size); + } // List level indents apply unless the paragraph sets its own. if let Some((_, lvl)) = &env.label { - if p.props.indent_left.is_none() { + if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) { rp.indent_left = lvl.indent; } - if p.props.indent_first.is_none() { + if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) { rp.indent_first = -lvl.hanging; } } - let para_style = p.props.style.as_deref(); - let table_chr = env.table.map(|t| &t.chr); let resolve = |c: &CharProps| -> Arc { Arc::new(doc.styles.resolve_char_in(para_style, table_chr, c)) }; let levels = bidi_levels(&p.text, rp.bidi); let mut b = diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 1fff79f..736ea31 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -2441,6 +2441,74 @@ fn a_floating_table_taller_than_a_page_runs_across_pages() { assert_eq!(rows, 80); } +/// #534: a table placed on the page (`w:tblpPr w:vertAnchor="page"`) whose anchor comes after a +/// page full of text goes to the next page with it, like Word, instead of covering that text. +#[test] +fn a_page_anchored_floating_table_after_a_full_page_moves_to_the_next() { + use wordcraft_doc::para::Anchor; + use wordcraft_doc::props::TableFloat; + let text: Vec = (0..24).map(|i| format!("Paragraph {i} before the table.")).collect(); + let mut d = Document::from_text(&(text.join("\n") + "\nAfter the table.")); + let mut t = Table::new(15, 1, 300.0); + for (i, r) in t.rows.iter_mut().enumerate() { + r.cells[0].blocks = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("Row {i}"), Default::default()))]; + } + t.props.float = Some(TableFloat { h_rel: Anchor::Page, v_rel: Anchor::Page, x: 70.0, y: 71.0, ..Default::default() }); + d.insert_block(StoryRef::Body, &Path::top(24), wordcraft_doc::Block::Table(t)).unwrap(); + let l = lay(&d); + let page_of = |want: &dyn Fn(&Path) -> bool| -> Vec { + l.pages + .iter() + .enumerate() + .flat_map(|(i, p)| p.items.iter().map(move |it| (i, it))) + .filter_map(|(i, it)| if let Placed::Lines { story: StoryRef::Body, path, .. } = it { want(path).then_some(i) } else { None }) + .collect() + }; + // The text before the table fills page 1; the table and the text after it are on page 2. + assert_eq!(page_of(&|p| p.0 == [23]), vec![0]); + let rows = page_of(&|p| p.0.first() == Some(&24) && p.0.len() > 1); + assert!(rows.len() == 15 && rows.iter().all(|pg| *pg == 1), "table rows on pages {rows:?}"); + assert_eq!(page_of(&|p| p.0 == [25]), vec![1]); +} + +/// #534: a paragraph that starts with a page break right where the page is full keeps the break +/// on that page: no blank page between the two tables around it. +#[test] +fn a_page_break_at_the_bottom_of_a_full_page_leaves_no_blank_page() { + use wordcraft_doc::props::LineSpacing; + let props = ParaProps { line_spacing: Some(LineSpacing::Exactly(24.0)), space_before: Some(0.0), space_after: Some(0.0), ..Default::default() }; + let para = |text: &str| { + let mut p = wordcraft_doc::Paragraph::with_text(text, Default::default()); + p.props = props.clone(); + wordcraft_doc::para_block(p) + }; + let mut d = Document::from_text(""); + // 27 lines of 24pt fill the 648pt of text on a Letter page. + d.body = (0..27).map(|i| para(&format!("Line {i}"))).collect(); + d.body.push(para("\u{000C}Next page")); + let l = lay(&d); + assert_eq!(l.pages.len(), 2, "pages: {}", l.pages.len()); + let last_on_2 = l.pages[1].items.iter().any(|it| matches!(it, Placed::Lines { path, .. } if path.0 == [27])); + assert!(last_on_2, "the text after the break is on page 2"); +} + +/// #534: indents in characters (`w:firstLineChars`) are measured in the paragraph mark's font +/// size and win over the indent in points. +#[test] +fn character_indents_use_the_paragraph_mark_size() { + let mut p = wordcraft_doc::Paragraph::with_text(&"字".repeat(80), Default::default()); + p.props.indent_first = Some(5.0); + p.props.indent_first_chars = Some(2.0); + p.props.indent_left_chars = Some(1.0); + p.mark.size = Some(20.0); + let mut d = Document::from_text(""); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = lay(&d); + let para = l.pages[0].items.iter().find_map(|it| if let Placed::Lines { para, .. } = it { Some(para.clone()) } else { None }).unwrap(); + assert!((para.lines[0].left - 60.0).abs() < 0.01, "first line at {}", para.lines[0].left); + assert!((para.lines[1].left - 20.0).abs() < 0.01, "second line at {}", para.lines[1].left); +} + #[test] fn no_line_break_right_after_a_slash() { // Word keeps "and/or" and web addresses whole on a line when they fit. @@ -3661,3 +3729,42 @@ fn table_cell_footnotes_are_placed() { let body = l.caret(&Pos::body(1, 0)).unwrap(); assert!(c.top > body.top + 100.0, "at the page bottom: {c:?} {body:?}"); } + +#[test] +fn east_asian_text_takes_the_asian_font_and_latin_text_the_latin_font() { + // #505: one run with a Latin font and an Asian font (w:eastAsia) of its own sets each script in + // its font. Prefer an installed CJK font other than the one fallback would pick anyway. + let resolve = |f: &str| wordcraft_fonts::word::resolve(f, false, false); + let fallback = wordcraft_fonts::FontDb::global().fallback_for('你', 0).map(|f| f.family.clone()); + let mut fonts: Vec = wordcraft_fonts::FontDb::global() + .east_asian_families() + .into_iter() + .filter(|f| { + let r = resolve(f); + !r.substituted && r.face.covers('你') && r.face.covers('好') + }) + .collect(); + fonts.sort_by_key(|f| Some(f) == fallback.as_ref()); + let Some(asian) = fonts.first().cloned() else { + eprintln!("skipped: no Chinese font installed"); + return; + }; + let latin = wordcraft_doc::styles::BODY_FONT; + let text = "Hello 你好 World"; + let mut d = Document::from_text(text); + d.format_range(&Pos::body(0, 0), &Pos::body(0, text.len()), &|c| { + c.font = Some(latin.into()); + c.font_ea = Some(asian.clone()); + }) + .unwrap(); + let l = lay(&d); + let para = + l.pages.iter().flat_map(|p| p.items.iter()).find_map(|it| if let Placed::Lines { para, .. } = it { Some(para) } else { None }).unwrap(); + let (latin_face, asian_face) = (resolve(latin).face.id(), resolve(&asian).face.id()); + for c in ¶.clusters { + let Some(ch) = text.get(c.start..c.end).and_then(|s| s.chars().next()).filter(|ch| !ch.is_whitespace()) else { continue }; + let face = para.styles[c.style as usize].face.id(); + let want = if ch.is_ascii() { latin_face } else { asian_face }; + assert_eq!(face, want, "{ch:?} in {}", if ch.is_ascii() { latin } else { &asian }); + } +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index cdcd866..025bbe0 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -360,6 +360,7 @@ fn export_page(app: &mut WordApp, ui: &mut Ui) { for (label, ext) in [ ("PDF document (*.pdf)", "pdf"), ("Word document (*.docx)", "docx"), + ("Word XML Document (*.xml)", "xml"), ("OpenDocument Text (*.odt)", "odt"), ("Rich Text Format (*.rtf)", "rtf"), ("Web page (*.html)", "html"), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 496cc35..7a76d26 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -121,7 +121,15 @@ fn special_menu(ui: &mut Ui, extra: &FindExtra) -> Option<&'static str> { #[serde(tag = "dialog", rename_all = "camelCase")] pub enum Dialog { Font { + /// The font (Latin text) and the Asian text font, as the font lists name them, and their + /// values when the dialog opened: OK changes only the ones changed (#505). font: String, + font_was: String, + font_ea: String, + font_ea_was: String, + /// Show the Asian text font box: a Chinese or Japanese interface, or East Asian text or + /// an Asian font in the selection. + asian: bool, size: String, bold: bool, italic: bool, @@ -908,8 +916,22 @@ impl Dialog { let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false); Some(match name { "font" => { + let font = app.previews.font_shown(&s("font")); + let font_ea = app.previews.font_shown(&s("fontEastAsia")); + let lang = crate::i18n::current().code(); + let text = app.session.selected_text(); + let text = if text.is_empty() { + app.session.doc.para_at(&app.session.sel.focus).map(|p| p.plain_text().to_string()).unwrap_or_default() + } else { + text + }; + let asian = ["zh", "ja", "ko"].iter().any(|l| lang.starts_with(l)) || !font_ea.is_empty() || text.chars().any(east_asian_char); let mut d = Dialog::Font { - font: s("font"), + font_was: font.clone(), + font, + font_ea_was: font_ea.clone(), + font_ea, + asian, size: st.get("size").and_then(Value::as_f64).map(|v| v.to_string()).unwrap_or_default(), bold: b("bold"), italic: b("italic"), @@ -1329,7 +1351,7 @@ pub(crate) fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) { /// The character properties the Font dialog's fields stand for. fn font_props(d: &Dialog) -> serde_json::Map { - let Dialog::Font { font, size, bold, italic, underline, strike, sup, sub, small_caps, caps, hidden, color, spacing, .. } = d else { + let Dialog::Font { font, font_ea, size, bold, italic, underline, strike, sup, sub, small_caps, caps, hidden, color, spacing, .. } = d else { return serde_json::Map::new(); }; let mut props = serde_json::Map::new(); @@ -1352,6 +1374,9 @@ fn font_props(d: &Dialog) -> serde_json::Map { if !font.trim().is_empty() { props.insert("font".into(), json!(font.trim())); } + if !font_ea.trim().is_empty() { + props.insert("fontEa".into(), json!(font_ea.trim())); + } if let Ok(s) = size.trim().parse::() { props.insert("size".into(), json!(s)); } @@ -1361,10 +1386,26 @@ fn font_props(d: &Dialog) -> serde_json::Map { props } -/// Font dialog OK: set the properties the user changed, leave the others alone. +/// Is `c` Chinese, Japanese or Korean text (ideographs, kana, Hangul, CJK punctuation, fullwidth +/// forms)? +fn east_asian_char(c: char) -> bool { + matches!(u32::from(c), 0x1100..=0x11FF | 0x2E80..=0x9FFF | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xF900..=0xFAFF | 0xFE30..=0xFE4F | 0xFF00..=0xFFEF | 0x20000..=0x3FFFF) +} + +/// The Font dialog's OK: one formatting step that sets only the properties the user changed, so +/// formatting the dialog can't show or left alone stays as it is. Fonts are set by their stored +/// (English) names, the Latin font for Persian/Arabic text too (as `format.font`). fn apply_font(app: &mut WordApp, d: &Dialog) { let Dialog::Font { start, .. } = d else { return }; - let changed: serde_json::Map = font_props(d).into_iter().filter(|(k, v)| start.get(k) != Some(v)).collect(); + let mut changed: serde_json::Map = font_props(d).into_iter().filter(|(k, v)| start.get(k) != Some(v)).collect(); + if let Some(f) = changed.get("font").and_then(Value::as_str).map(str::to_string) { + let f = app.previews.font_stored(&f); + changed.insert("font".into(), json!(f)); + changed.insert("fontCs".into(), json!(f)); + } + if let Some(f) = changed.get("fontEa").and_then(Value::as_str).map(str::to_string) { + changed.insert("fontEa".into(), json!(app.previews.font_stored(&f))); + } if !changed.is_empty() { let _ = app.run("format.set", json!({"props": changed})); } @@ -1373,7 +1414,7 @@ fn apply_font(app: &mut WordApp, d: &Dialog) { /// Returns true to close. fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { match d { - Dialog::Font { font, size, bold, italic, underline, strike, sup, sub, small_caps, caps, hidden, color, spacing, .. } => { + Dialog::Font { font, font_ea, asian, size, bold, italic, underline, strike, sup, sub, small_caps, caps, hidden, color, spacing, .. } => { // Build the sample text format for the preview pane below. let preview_size = size.trim().parse::().unwrap_or(12.0).clamp(6.0, 72.0); let preview_color = wordcraft_doc::Rgb::parse(color).map(crate::theme::c32).unwrap_or_else(|| ui.visuals().text_color()); @@ -1395,8 +1436,22 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { fmt.strikethrough = egui::Stroke::new(1.0, preview_color); } egui::Grid::new("fontdlg").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { - ui.label(tl!("Font:")); - ui.text_edit_singleline(font); + // Word's two font boxes when East Asian text is in use: the Latin text font and + // the Asian text font, each with its own list (type to find a font). + let prev = app.previews.font_preview_fn(); + if *asian { + ui.label(tl!("Asian text font:")); + let fams = app.previews.east_asian_families(); + if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_asian", 220.0, font_ea, &fams, Some(&*prev)) { + *font_ea = f; + } + ui.end_row(); + } + ui.label(if *asian { tl!("Latin text font:") } else { tl!("Font:") }); + let fams = app.previews.families(); + if let Some(f) = crate::widgets::font_combo(ui, "fontdlg_font", 220.0, font, &fams, Some(&*prev)) { + *font = f; + } ui.end_row(); ui.label(tl!("Size:")); ui.text_edit_singleline(size); @@ -1674,8 +1729,8 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } } if ui.button(tl!("Find Next")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { - let _ = app.session.run("edit.find", &opts); - match app.run("edit.findNext", json!({})) { + // One step per click: `edit.find` would select a match itself, and the step after it skip one. + match app.run("edit.findNext", opts.clone()) { Ok(r) => *message = format!("Match {} of {}", r["index"].as_u64().unwrap_or(0) + 1, r["count"]), Err(e) => *message = e, } @@ -3011,6 +3066,7 @@ fn enclose_preview(ui: &mut Ui, style: &str, text: &str, shape: &str) { #[cfg(test)] mod tests { use super::*; + use wordcraft_doc::Pos; use wordcraft_doc::props::Align; use wordcraft_engine::Session; @@ -3135,6 +3191,35 @@ mod tests { } } + #[test] + fn font_dialog_sets_the_asian_and_latin_fonts_separately() { + // #505: the Asian text font box and the Latin text font box each set their own font, and + // OK leaves the one not changed as it was. + let mut app = app_with("Hello 你好"); + let _ = app.run("select.all", json!({})); + let fonts = |app: &WordApp| { + let c = app.session.doc.para_at(&Pos::body(0, 0)).map(|p| p.props_of_char(0).clone()).unwrap_or_default(); + (c.font, c.font_ea) + }; + let latin = fonts(&app).0; + let Some(mut d) = Dialog::open("font", &mut app) else { panic!("no font dialog") }; + let Dialog::Font { asian, font_ea, .. } = &mut d else { panic!() }; + assert!(*asian, "Chinese text in the selection shows the Asian text font box"); + *font_ea = "SimSun".into(); + apply_font(&mut app, &d); + assert_eq!(fonts(&app), (latin, Some("SimSun".into()))); + let Some(mut d) = Dialog::open("font", &mut app) else { panic!() }; + let Dialog::Font { font, font_ea, .. } = &mut d else { panic!() }; + assert_eq!(font_ea, "SimSun"); + *font = "Georgia".into(); + apply_font(&mut app, &d); + assert_eq!(fonts(&app), (Some("Georgia".into()), Some("SimSun".into()))); + // One undo step each. + let _ = app.run("edit.undo", json!({})); + assert_eq!(fonts(&app).1.as_deref(), Some("SimSun")); + assert_ne!(fonts(&app).0.as_deref(), Some("Georgia")); + } + #[test] fn paragraph_dialog_changes_direction() { let mut app = app_with("Hello"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 9488459..ee89c6b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -63,6 +63,7 @@ Arrow Flecha Artistic Effects Efectos artísticos As you type Mientras escribes + Asian text font: Fuente de texto asiático: Asian Typography Tipografía asiática Author Autor AutoCorrect Options Opciones de autocorrección @@ -105,6 +106,7 @@ Interface Interfaz Interface language Idioma de la interfaz Inside: Interior: + Latin text font: Fuente de texto latino: Left border Borde izquierdo Legal style numbering Numeración de estilo legal Link level to style: Vincular nivel con estilo: @@ -954,6 +956,7 @@ Word Left Palabra a la izquierda Word Right Palabra a la derecha Word document (*.docx) Documento de Word (*.docx) + Word XML Document (*.xml) Documento XML de Word (*.xml) WordArt WordArt WordCraft WordCraft Words Palabras diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 81428c6..338533b 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -50,6 +50,7 @@ Arrow Nool Artistic Effects Kunstilised efektid As you type Kirjutamise ajal + Asian text font: Aasia teksti font: Author Autor AutoCorrect Options Automaatparanduse valikud AutoFit Automaatne sobitus @@ -383,6 +384,7 @@ Lasso Lasso Last Column Viimane veerg Last commit Viimane commit + Latin text font: Ladina teksti font: Layout Paigutus Layout Summary Paigutuse kokkuvõte Left Vasak @@ -838,6 +840,7 @@ Word Left Sõna vasakule Word Right Sõna paremale Word document (*.docx) Wordi dokument (*.docx) + Word XML Document (*.xml) Wordi XML-dokument (*.xml) WordArt WordArt WordCraft WordCraft Words Sõnad diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 527b533..b4f3a7c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -49,6 +49,7 @@ Arrow 矢印 Artistic Effects アート効果 As you type 入力中 + Asian text font: アジア文字のフォント: Asian Typography アジア文字の組版 Author 作成者 AutoCorrect Options オートコレクトのオプション @@ -102,6 +103,7 @@ Interface インターフェイス Interface language 表示言語 Inside: 内側: + Latin text font: ラテン文字のフォント: Left border 左罫線 Left Margin 左余白 Indents and Spacing インデントと間隔 @@ -928,6 +930,7 @@ Window ウィンドウ Word Count 文字カウント Word document (*.docx) Word 文書 (*.docx) + Word XML Document (*.xml) Word XML ドキュメント (*.xml) Word Left 前の単語へ Word Right 次の単語へ WordArt ワードアート diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 5401d6f..1bc856d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -59,6 +59,7 @@ Arrow Seta Artistic Effects Efeitos artísticos As you type Enquanto você digita + Asian text font: Fonte de texto asiático: Asian Typography Tipografia asiática Author Autor AutoCorrect Options Opções de autocorreção @@ -106,6 +107,7 @@ Interface Interface Interface language Idioma da interface Inside: Interno: + Latin text font: Fonte de texto latino: Left border Borda esquerda Legal style numbering Numeração no estilo legal Link level to style: Vincular nível ao estilo: @@ -928,6 +930,7 @@ Window Janela Word Count Contagem de palavras Word document (*.docx) Documento Word (*.docx) + Word XML Document (*.xml) Documento XML do Word (*.xml) Word Left Palavra à esquerda Word Right Palavra à direita WordArt Texto artístico diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index cc8a840..52e4409 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2112,6 +2112,8 @@ preview Title Пример Apply Применить Convert Notes Преобразовать сноски +# Word XML Document (#548). + Word XML Document (*.xml) Документ Word XML (*.xml) # Find and Replace special characters and wildcards (#543). Use wildcards Подстановочные знаки Special Специальный @@ -2359,3 +2361,6 @@ preview Title Пример Typing replaces selected text Ввод заменяет выделенный текст Update fields before printing Обновлять поля перед печатью Use the Insert key to switch overtype on and off Клавиша Insert включает и выключает режим замены +# Asian and Latin text fonts (#505). + Asian text font: Шрифт азиатского текста: + Latin text font: Шрифт латинского текста: diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 9eab4d3..d8b53f9 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -66,6 +66,7 @@ Artistic Effects Umetnički efekti As Recommended Kao što je preporučeno As you type Tokom kucanja + Asian text font: Font azijskog teksta: Asian Typography Azijska tipografija Author Autor AutoCorrect Options Opcije automatske ispravke @@ -129,6 +130,7 @@ Interface Interfejs Interface language Jezik interfejsa Inside: Unutra: + Latin text font: Font latiničnog teksta: Left border Leva ivica Left Margin Leva margina Legal style numbering Pravno numerisanje @@ -988,6 +990,7 @@ Window Prozor Word Count Broj reči Word document (*.docx) Word dokument (*.docx) + Word XML Document (*.xml) Word XML dokument (*.xml) Word Left Reč levo Word Right Reč desno WordArt WordArt diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 6db3d07..862b1ac 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -70,6 +70,7 @@ Artistic Effects Уметнички ефекти As Recommended Као што је препоручено As you type Током куцања + Asian text font: Фонт азијског текста: Asian Typography Азијска типографија Author Аутор AutoCorrect Options Опције аутоматске исправке @@ -133,6 +134,7 @@ Interface Интерфејс Interface language Језик интерфејса Inside: Унутра: + Latin text font: Фонт латиничног текста: Left border Лева ивица Left Margin Лева маргина Legal style numbering Правно нумерисање @@ -992,6 +994,7 @@ Window Прозор Word Count Број речи Word document (*.docx) Word документ (*.docx) + Word XML Document (*.xml) Word XML документ (*.xml) Word Left Реч лево Word Right Реч десно WordArt WordArt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 30f9743..df024f8 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1734,6 +1734,8 @@ Apply Uygula Convert Notes Notları Dönüştür +# Word XML Document (#548). + Word XML Document (*.xml) Word XML Belgesi (*.xml) # Find and Replace special characters and wildcards (#543). Use wildcards Joker karakter kullan Special Özel @@ -1981,3 +1983,6 @@ Typing replaces selected text Yazmak seçili metnin yerine geçer Update fields before printing Yazdırmadan önce alanları güncelle Use the Insert key to switch overtype on and off Üzerine yazmayı açıp kapatmak için Insert tuşunu kullan +# Asian and Latin text fonts (#505). + Asian text font: Asya metni yazı tipi: + Latin text font: Latin metni yazı tipi: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e0494fb..e0ad736 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -61,6 +61,7 @@ Arrow Стрілка Artistic Effects Художні ефекти As you type Під час введення + Asian text font: Шрифт азійського тексту: Asian Typography Азійська типографіка Author Автор AutoCorrect Options Параметри автовиправлення @@ -108,6 +109,7 @@ Interface Інтерфейс Interface language Мова інтерфейсу Inside: Усередині: + Latin text font: Шрифт латинського тексту: Left border Ліва межа Legal style numbering Юридична нумерація Link level to style: Зв'язати рівень зі стилем: @@ -954,6 +956,7 @@ Window Вікно Word Count Кількість слів Word document (*.docx) Документ Word (*.docx) + Word XML Document (*.xml) Документ Word XML (*.xml) Word Left На слово ліворуч Word Right На слово праворуч WordArt WordArt diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0bce8ce..b956e3b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -61,6 +61,7 @@ Arrow 箭头 Artistic Effects 艺术效果 As you type 键入时 + Asian text font: 亚洲文字字体: Asian Typography 亚洲版式 Author 作者 AutoCorrect Options 自动更正选项 @@ -114,6 +115,7 @@ Interface 界面 Interface language 界面语言 Inside: 内侧: + Latin text font: 拉丁文字字体: Left border 左框线 Left Margin 左边距 Indents and Spacing 缩进和间距 @@ -940,6 +942,7 @@ Window 窗口 Word Count 字数统计 Word document (*.docx) Word 文档 (*.docx) + Word XML Document (*.xml) Word XML 文档 (*.xml) Word Left 左移一个单词 Word Right 右移一个单词 WordArt 艺术字 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 37ba1e5..ecc2f83 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -50,6 +50,7 @@ Arrow 箭號 Artistic Effects 美術效果 As you type 輸入時 + Asian text font: 亞洲文字字型: Asian Typography 亞洲版式 Author 作者 AutoCorrect Options 自動校正選項 @@ -103,6 +104,7 @@ Interface 介面 Interface language 介面語言 Inside: 內側: + Latin text font: 拉丁文字字型: Left border 左框線 Left Margin 左邊界 Indents and Spacing 縮排和間距 @@ -929,6 +931,7 @@ Window 視窗 Word Count 字數統計 Word document (*.docx) Word 文件 (*.docx) + Word XML Document (*.xml) Word XML 文件 (*.xml) Word Left 左移一個單字 Word Right 右移一個單字 WordArt 文字藝術師 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 906ffa4..ca728aa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1308,7 +1308,7 @@ impl AutoSaveBlock { /// copy that leaves it unsaved. AutoSave writes only these formats. fn keeps_everything(name: &str) -> bool { let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json") + matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "odt" | "rtf" | "json" | "xml") } /// The dialog a user-run command opens when it lacks the input it needs (scripts and agents get diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 751d57a..e088741 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -20,6 +20,8 @@ pub struct Previews { /// Lowercase English family → its name in that language (simsun → 宋体), and back. local_names: HashMap, english_names: HashMap, + /// The Chinese, Japanese and Korean fonts of the font list (the Asian text font box). + east_asian: Option>, styles_hash: (u64, u64), /// Previews rendered this frame (rendering is spread over frames). budget: std::cell::Cell, @@ -45,10 +47,29 @@ impl Previews { self.local_names = names.iter().map(|(e, l)| (e.to_lowercase(), l.clone())).collect(); self.english_names = names.into_iter().map(|(e, l)| (l, e)).collect(); self.families = Some((lang, f)); + self.east_asian = None; } self.families.as_ref().map(|(_, f)| f.clone()).unwrap_or_default() } + /// The Font dialog's Asian text font list: the installed Chinese, Japanese and Korean fonts, + /// named as in [`Previews::families`]. + pub fn east_asian_families(&mut self) -> Vec { + self.families(); + if self.east_asian.is_none() { + let mut f: Vec = wordcraft_fonts::FontDb::global() + .east_asian_families() + .into_iter() + .filter(|f| !f.starts_with('.')) + .map(|f| self.local_names.get(&f.to_lowercase()).cloned().unwrap_or(f)) + .collect(); + f.sort_by_key(|a| a.to_lowercase()); + f.dedup(); + self.east_asian = Some(f); + } + self.east_asian.clone().unwrap_or_default() + } + /// The name the font box shows for the document's font `family`: its name in the interface /// language when the font has one. pub fn font_shown(&mut self, family: &str) -> String { diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 99bb701..2920629 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,13 +1,13 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (every `w:numFmt` number format kept, East Asian ones drawn, #534; 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-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens, #548; 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; write: Word opens it showing the same). Summary in [`target-app-parity.md`](target-app-parity.md); the work items are in [`gaps.md`](gaps.md). -**Dimension: ~60% (estimated), 185–300 h to full** (the sum of the rows below). The weighted table +**Dimension: ~60% (estimated), 179–290 h to full** (the sum of the rows below). The weighted table gives ~64%; we discount it to ~60% because DOCX fidelity has never been checked on real-world files. DOCX dominates the weight: a Word user sends and receives .docx; everything else is occasional. @@ -30,8 +30,8 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection; Word's encoding choice dialog and line-ending options missing | | `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `` tolerated (#154). No Word "Web Page" (with `_files` folder) round-trip | | `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | | -| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | The DOCX parts in one XML file; cheap once the DOCX code is reused | -| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | ❌ | ❌ | 0% | 5–8 | — | Legacy | +| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element | +| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | ❌ | ~55% | 2–3 | `crates/docx/tests/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Saving as 2003 XML is not planned (legacy) | | `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value | | Encrypted/password-protected `.docx`/`.doc` | 1% | R/W | ❌ | ❌ | 0% | 6–10 | — | ECMA-376 Agile Encryption; `.doc` RC4/XOR detected and refused (`docbin` fib). Issue #55 | | `.xlsx`/`.xlsm`/`.ods` as a mail-merge recipient list | — | R (data source) | ✅ | — | ~90% | 1–3 | `crates/formats/src/sheet.rs`, `cmd/mailings.rs` | Read-only, one sheet's cell values (#334): shared/inline/rich strings, numbers, booleans, cached formula results, dates and times as ISO 8601 (`yyyy-mm-dd`) from the cell's number format, sparse cells; ODS value types, repeated rows/columns, covered cells. Select Table picks the sheet. Capped (64 MiB per part, 100,000 rows, 1,000 columns, 2 M cells, 32,767 characters a cell). Not shown: number formats other than dates (currency, percent, thousands separators) in `.xlsx`; no `.xls`, Access or Outlook sources | @@ -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); 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) | +| 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) | | 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) | @@ -91,6 +91,8 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Word XML Document (Flat OPC) read and written, Word 2003 XML read (#548) | +| 2026-10-11 | trivial | #534: DOCX indents in characters read and written; `.doc` first-line indents (`sprmPDxaLeft1`, its two-byte operand, and `sprmPDxaLeft1_80`), indents in characters (`sprmPDxc*`) and exact/at-least line spacing (LSPD) read correctly | | 2026-10-11 | trivial | List and page number formats: all ST_NumberFormat values round-trip; Chinese, Japanese and Korean counting, legal and digit formats, stems/branches, kana, hangul, enclosed and full-width numbers drawn; unknown values are decimal; .doc number format codes mapped (#534) | | 2026-10-11 | trivial | SmartArt made in WordCraft written as diagram parts (data, layout, quick style, colours, drawing) and read back editable (#497) | | 2026-10-11 | trivial | Attached template (`w:attachedTemplate`, external relationship of the settings part) and `w:linkStyles` read and written in schema order; opening never reads the template (#377) | diff --git a/docs/gaps.md b/docs/gaps.md index d2a6cf4..1e49648 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,8 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (Word XML Document and Word 2003 XML landed, #548; 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 + +> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. 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 @@ -36,7 +38,7 @@ first (`gh pr list`), and update this file and the parity doc when a gap closes. | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | | 24 | **Default shortcuts**: ~135 of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and the Quick Access Toolbar #379 landed) | | [ui-parity.md](ui-parity.md) | Power users | 4–7 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | -| 26 | **Minor formats**: Flat OPC XML, Word 2003 XML, MHT, Works/WordPerfect | | | Rare | 20–35 | [file-format-parity.md](file-format-parity.md) | +| 26 | **Minor formats**: MHT, Works/WordPerfect, saving Word 2003 XML | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) | Rare | 15–28 | [file-format-parity.md](file-format-parity.md) | | 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29) | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | | 29 | **Equations**: ink equations | | #191 notes; long display equations wrap at operators (#326) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | @@ -64,6 +66,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Word XML Document (Flat OPC) and Word 2003 XML opening landed (#548); gap #26 narrowed to 15–28 h | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | #13 narrowed: Insert › SmartArt with eight layouts, Text Pane and SmartArt Design landed (#497); 25–40 h left for charts | | 2026-10-11 | trivial | File › Options panes landed (#485); removed from the dialog-depth list | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index b5a15b4..81be4e7 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -23,8 +23,9 @@ font can't match Word's metrics. | Kashida justification (Arabic) | ❌ | no kashida in `crates/layout` | 3–5 | | Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 | | Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner | +| Indents in characters (`w:firstLineChars`, `w:leftChars`…), measured in the paragraph mark's font size | ✅ | #534, `layout::tests::character_indents_use_the_paragraph_mark_size`; spacing in lines (`w:beforeLines`/`w:afterLines`) uses the stored points (needs the document grid) | — | | Line spacing (single, 1.5, double, at least, exactly, multiple), spacing before/after, contextual spacing | 🟡 | Word puts multiple-spacing extra below the text and collapses spacing differently (#135, #108 open) | 3–5 | -| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines | — | +| Widow/orphan control, keep with next, keep lines together, page break before | ✅ | `lib.rs` keep_next/keep_lines; a line holding only a page break stays at the bottom of a full page instead of leaving a blank page (#534) | — | | Tabs (left, center, right, decimal, bar), leaders | 🟡 | Bar tabs untested | 1–2 | | Hyphenation (automatic, zone from the document, words in capitals, soft hyphens, consecutive limit, Manual Hyphenation, #407) | 🟡 | English patterns only; other languages hyphenate wrongly or not at all | in localization | | Drop caps | ✅ | 2026-10-06 | — | @@ -37,7 +38,7 @@ font can't match Word's metrics. | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138 | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); cell preferred widths and spans still differ | 3–5 | -| Floating tables (`w:tblpPr`) | 🟡 | #137; overlap rules untested | 2–3 | +| Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover the text before it and doesn't fit below it moves to the next page (#534); overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | | Floating pictures/shapes placement (relative to page/margin/column/paragraph/line/character) | ✅ | #136 | — | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06 | — | @@ -73,6 +74,7 @@ font can't match Word's metrics. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Chinese documents (#534): indents in characters, a page-anchored floating table after a full page moves to the next page, no blank page from a page break at the bottom of a full page | | 2026-10-11 | trivial | East Asian list number formats (一、二、三, 第一章, ①); list labels whose characters come from two fonts draw each part with its own font (the digits of "1、" no longer show as other letters); a list level without indents leaves the label at the paragraph edge (#534) | | 2026-10-11 | trivial | Vertical sections: `w:textDirection` in `w:sectPr`, upright East Asian glyphs (#491) | | 2026-10-11 | trivial | Mirror indents (`w:mirrorIndents`): left/right indents swap on even pages; suppress line numbers inherited from styles (#512) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index a06f3fc..252290d 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -76,6 +76,7 @@ proofing is not included; terminology and checks: [`localization-tr.md`](localiz | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Font dialog sets the Asian text font (`w:eastAsia`) and the Latin text font separately; the Asian list holds the installed Chinese, Japanese and Korean fonts by their interface-language names (#505) | | 2026-10-11 | minor | Font list shows installed fonts by their names in the interface language (宋体 for SimSun in Chinese, Japanese and Korean names likewise) and finds them by either name; a Chinese, Japanese or Korean font is stored as `w:eastAsia` too (#505) | | 2026-10-11 | minor | Chinese/Japanese interface fonts: a test checks every catalog character has a glyph; an installed CJK font follows the embedded faces whenever they miss one; macOS font asset folders searched for PingFang (#486) | | 2026-10-10 | minor | Russian interface (#300; 1,524 entries, built-in style names shown translated; covers everything the interface audit finds) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 83f89cd..d1d4b3e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -49,6 +49,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever). | 2026-10-11 | trivial | Line Numbers, Hyphenation (with Manual Hyphenation), Language, Envelopes and Labels dialogs (#407); the three duplicated modal-dialog rows merged into one | | 2026-10-11 | trivial | Accessibility pane (errors/warnings/tips, jump-to, fixes) and Alt Text pane, opened from Picture/Shape Format › Accessibility and right-click › View Alt Text… (#386) | | 2026-10-11 | trivial | References dialogs: Caption (New/Delete Label, Numbering with chapter numbers), Index and Mark Index Entry (Mark All), Custom Table of Contents (Options, TOC styles via Modify Style), Source Manager and Create/Edit Source; modal dialog count 34 (#402) | +| 2026-10-11 | trivial | Font dialog: Asian text font and Latin text font boxes, each with its own list and type-to-find, shown for a Chinese/Japanese interface or East Asian text in the selection; OK sets only the fonts changed; `format.font {"eastAsia"}` (#505) | | 2026-10-11 | trivial | Customize Ribbon (custom tabs and groups, hidden and reordered tabs) and a customizable Quick Access Toolbar with a working … menu (#379, #376) | | 2026-10-11 | trivial | Customize Keyboard dialog (Tools › Customize Keyboard, File › Options › Keyboard shortcuts): custom keys override built-ins and persist (#368) | | 2026-10-11 | minor | Keyboard shortcuts 101 → 125 commands (#330): Reset Paragraph/Character Formatting, symbols, DATE/TIME/PAGE fields, view switches, Word Count, mail merge, F4 Repeat now reachable |