Print: preserve main updates without progress-log conflicts

This commit is contained in:
skylar taylor-barrick 2026-10-12 04:22:41 +02:00
commit 9644e646a6
57 changed files with 2218 additions and 129 deletions

View File

@ -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) |

View File

@ -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",
}
}

View File

@ -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", &["*"])
}

View File

@ -655,6 +655,18 @@ pub struct ParaProps {
/// First-line indent, points; negative = hanging.
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_first: Option<f32>,
/// 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<f32>,
/// 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<f32>,
/// 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<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f32>,
#[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() }

View File

@ -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`.

View File

@ -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<i16> { u16_of(prl).map(|v| v as i16) };
let i32of = |prl: &Prl| -> Option<i32> {
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))
});
}
}

View File

@ -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() {

411
crates/docx/src/flat.rs Normal file
View File

@ -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<Document, DocxError> {
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<Data>,
}
/// 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<Package, DocxError> {
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<String, Vec<u8>> = BTreeMap::new();
let mut types: Vec<(String, String)> = Vec::new();
let mut total: u64 = 0;
let mut part: Option<Part> = None;
// `xmlns` declarations on the package, part and data elements, in scope for the inline XML.
let mut scope: Vec<Vec<(String, String)>> = 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() {
// `<pkg:xmlData/>`: 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<String, Vec<u8>>, 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<u8>,
files: &mut BTreeMap<String, Vec<u8>>,
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<String> {
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<u8> {
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<usize> {
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"<!--") {
i += rest.windows(3).position(|w| w == b"-->")? + 3;
} else if rest.starts_with(b"<?") {
i += rest.windows(2).position(|w| w == 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<Vec<u8>, 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("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<?mso-application progid=\"Word.Document\"?>\n");
s.push_str(&format!("<pkg:package xmlns:pkg=\"{PKG_NS}\">"));
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!("<pkg:part pkg:name=\"/{}\" pkg:contentType=\"{}\"", esc(name), esc(ct)));
match xml_fragment(ct, bytes) {
Some(x) => {
s.push_str("><pkg:xmlData>");
s.push_str(x);
s.push_str("</pkg:xmlData></pkg:part>");
}
None => {
s.push_str(" pkg:compression=\"store\"><pkg:binaryData>");
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("</pkg:binaryData></pkg:part>");
}
}
}
s.push_str("</pkg:package>");
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("<?xml") {
Some(rest) => rest.split_once("?>").map(|(_, r)| r)?.trim_start(),
None => s,
};
(s.starts_with('<') && !s.contains("<!DOCTYPE")).then_some(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sniffs_root_elements() {
assert_eq!(
sniff_xml(
format!("<?xml version=\"1.0\"?><?mso-application progid=\"Word.Document\"?><!-- x --><p:package xmlns:p=\"{PKG_NS}\"/>").as_bytes()
),
XmlKind::FlatOpc
);
assert_eq!(sniff_xml(format!("\u{FEFF}<w:wordDocument xmlns:w=\"{W2003_NS}\"/>").as_bytes()), XmlKind::WordMl2003);
assert_eq!(sniff_xml(b"<package/>"), XmlKind::Other);
assert_eq!(sniff_xml(b"<html><body/></html>"), 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 <!-- c --> <w:document><w:body/></w:document>";
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, "<w:document xmlns:w=\"urn:w\"><w:body/></w:document>");
let own = b"<w:document xmlns:w=\"urn:own\"/>";
assert_eq!(with_scope(own, &scope), own.to_vec());
assert_eq!(with_scope(b"no element", &scope), b"no element".to_vec());
}
}

View File

@ -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<wordcraft_doc::Document, DocxError> {
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,

View File

@ -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<String, Vec<u8>>) -> 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('/');

View File

@ -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))
}

View File

@ -99,7 +99,12 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
/// [`read`] for bytes known not to be an encrypted package.
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
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<Document, DocxError> {
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<Document, DocxError> {
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<Document, DocxError> {
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") {

View File

@ -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<Rgb>,
/// `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<String, String>,
/// 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<Rgb> {
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<Rgb> {
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<TabStop> = 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<TabStop> {
Some(TabStop { pos, align, leader })
}
/// `w:shd/@w:fill` (a solid fill colour; `auto` = none).
pub fn shd_fill(e: &El) -> Option<Rgb> {
/// `w:shd/@w:fill` (a solid fill colour; `auto` = none), ignoring theme fills ([`PropCtx::shd_fill`]).
fn shd_fill(e: &El) -> Option<Rgb> {
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("")),

View File

@ -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() {

View File

@ -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<Document, DocxError> {
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<String, String>,
files: BTreeMap<String, Vec<u8>>,
/// Relationships per part: document, footnotes, endnotes, comments, then one per header or
/// footer (index in `stories` + 4).
rels: Vec<PartRels>,
footnotes: Vec<Node>,
endnotes: Vec<Node>,
comments: Vec<Node>,
/// 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<Package, DocxError> {
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("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\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<Node> {
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<Node> {
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<El> {
// 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<Node> {
let id = e.attr("aml:id").filter(|v| v.parse::<i64>().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 }
}
}
/// `<name w:val="v"/>`.
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::<u32>().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<u8> {
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()
}

View File

@ -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<u8> {
pub(crate) fn xml(&self) -> Vec<u8> {
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<Vec<u8>, DocxError> {
/// Write a package of the given flavour (`.docx`, `.docm`, `.dotx`, `.dotm`).
pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, 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<Vec<(String, Vec<u8>)>, 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<Vec<u8>, 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<u8>)>, overrides: &mut Vec<(String, String)>, path: &str, bytes: Vec<u8>, ct: &str, rels: PartRels) {

View File

@ -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));
}

View File

@ -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<String> {
pub(crate) fn utf16_to_utf8(bytes: &[u8]) -> Option<String> {
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<String> {
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<El, DocxError> {
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<El, DocxError> {
// 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<El, DocxError> {
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<El, DocxError> {
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) {

View File

@ -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<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
/// A package whose body is `body`.
fn docx(body: &str) -> Vec<u8> {
let root = r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let doc = format!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
);
zip(&[("_rels/.rels", root.as_bytes()), ("word/document.xml", doc.as_bytes())])
}
fn props(d: &Document) -> Vec<ParaProps> {
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#"<w:p><w:pPr><w:ind w:firstLineChars="200" w:firstLine="420"/></w:pPr><w:r><w:t>一</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:leftChars="150" w:left="315" w:rightChars="50" w:right="105" w:hangingChars="100" w:hanging="210"/></w:pPr><w:r><w:t>二</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:firstLineChars="0" w:firstLine="240"/></w:pPr><w:r><w:t>三</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:firstLineChars="99999999" w:leftChars="junk"/></w:pPr><w:r><w:t>四</w:t></w:r></w:p>"#;
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("<w:body>").unwrap() + 8..xml.find("</w:body>").unwrap()])).unwrap());
assert_eq!(again[..3], p[..3]);
}

View File

@ -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#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:clrScheme name="T"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="44546A"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="T"><a:majorFont><a:latin typeface="Major Face"/></a:majorFont><a:minorFont><a:latin typeface="Minor Face"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
let styles = format!(
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{styles}</w: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#"<w:settings {W_NS}>{s}</w:settings>"#));
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<TextColor>, 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#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:color w:val="FF0000" w:themeColor="accent1" w:themeShade="BF"/><w:u w:val="single" w:color="FF0000" w:themeColor="accent2"/></w:rPr></w:style>"#;
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>H</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="99"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="66"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="33"/></w:rPr><w:t>c</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeShade="7F"/></w:rPr><w:t>d</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent2" w:themeShade="BF"/></w:rPr><w:t>e</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="text2"/></w:rPr><w:t>f</w:t></w:r><w:r><w:rPr><w:color w:val="auto" w:themeColor="hyperlink"/></w:rPr><w:t>g</w:t></w:r><w:r><w:rPr><w:shd w:val="clear" w:color="auto" w:fill="000000" w:themeFill="accent2" w:themeFillTint="33"/></w:rPr><w:t>h</w:t></w:r></w:p>"#;
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#"<w:style w:type="character" w:styleId="T1"><w:name w:val="T1"/><w:rPr><w:color w:val="000000" w:themeColor="text1"/></w:rPr></w:style>"#;
let body = r#"<w:p><w:r><w:rPr><w:rStyle w:val="T1"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="background1"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="dark1"/></w:rPr><w:t>c</w:t></w:r></w:p>"#;
let mapping = r#"<w:clrSchemeMapping w:bg1="dark1" w:t1="accent2" w:bg2="light2" w:t2="dark2" w:accent1="accent1" w:accent2="accent2" w:accent3="accent3" w:accent4="accent4" w:accent5="accent5" w:accent6="accent6" w:hyperlink="hyperlink" w:followedHyperlink="followedHyperlink"/>"#;
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#"<w:p><w:r><w:rPr><w:color w:val="00FF00" w:themeColor="accent1" w:themeShade="BF"/></w:rPr><w:t>a</w:t></w:r></w:p>"#);
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#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:rFonts w:ascii="Calibri Light" w:hAnsi="Calibri Light" w:asciiTheme="majorHAnsi" w:hAnsiTheme="majorHAnsi"/></w:rPr></w:style>"#;
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>H</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:rFonts w:hAnsi="Arial" w:hAnsiTheme="minorHAnsi"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Arial" w:asciiTheme="minorHAnsi"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Arial" w:hAnsiTheme="majorHAnsi"/></w:rPr><w:t>c</w:t></w:r></w:p>"#;
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

View File

@ -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("<?xml") && text.contains("<?mso-application progid=\"Word.Document\"?>"));
assert!(text.contains("pkg:name=\"/word/document.xml\"") && text.contains("<pkg:binaryData>"));
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();

View File

@ -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<u32>) -> 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::<Vec<_>>().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::<Vec<_>>().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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<?mso-application progid="Word.Document"?>
<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<pkg:part pkg:name="/_rels/.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml">
<pkg:xmlData>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>
</pkg:xmlData>
</pkg:part>
<pkg:part pkg:name="/word/_rels/document.xml.rels" pkg:contentType="application/vnd.openxmlformats-package.relationships+xml">
<pkg:xmlData><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.org/flat" TargetMode="External"/><Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/dot.png"/></Relationships></pkg:xmlData>
</pkg:part>
<pkg:part pkg:name="/word/document.xml" pkg:contentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml">
<pkg:xmlData>
<w:document xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:v="urn:schemas-microsoft-com:vml"><w:body>
<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Seed &amp; soil</w:t></w:r></w:p>
<w:p><w:hyperlink r:id="rId5"><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>
<w:p><w:r><w:pict><v:shape style="width:24pt;height:12pt"><v:imagedata r:id="rId6"/></v:shape></w:pict></w:r></w:p>
</w:body></w:document>
</pkg:xmlData>
</pkg:part>
<pkg:part pkg:name="/word/media/dot.png" pkg:contentType="image/png" pkg:compression="store">
<pkg:binaryData>{png}</pkg:binaryData>
</pkg:part>
<pkg:part pkg:name="/word/empty.xml" pkg:contentType="application/xml"><pkg:xmlData/></pkg:part>
</pkg:package>"#
);
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##"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<?mso-application progid="Word.Document"?>
<w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml" xmlns:wx="http://schemas.microsoft.com/office/word/2003/auxHint" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:aml="http://schemas.microsoft.com/aml/2001/core" w:macrosPresent="no">
<o:DocumentProperties><o:Title>Garden notes</o:Title><o:Author>Ada Gardener</o:Author><o:Keywords>beans</o:Keywords></o:DocumentProperties>
<w:fonts><w:defaultFonts w:ascii="Georgia" w:fareast="Georgia" w:h-ansi="Georgia" w:cs="Georgia"/></w:fonts>
<w:lists>
<w:listDef w:listDefId="3"><w:plt w:val="HybridMultilevel"/>
<w:lvl w:ilvl="0"><w:start w:val="1"/><w:nfc w:val="4"/><w:lvlText w:val="%1)"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl>
</w:listDef>
<w:list w:ilfo="2"><w:ilst w:val="3"/></w:list>
</w:lists>
<w:styles>
<w:versionOfBuiltInStyleNames w:val="4"/>
<w:style w:type="paragraph" w:default="on" w:styleId="Normal"><w:name w:val="Normal"/><w:rPr><wx:font wx:val="Georgia"/><w:sz w:val="24"/><w:sz-cs w:val="24"/></w:rPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><wx:uiName wx:val="Heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:before="240" w:after="60"/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/></w:rPr></w:style>
</w:styles>
<w:docPr><w:view w:val="print"/><w:zoom w:percent="100"/><w:footnotePr><w:footnote w:type="separator"><w:p><w:r><w:separator/></w:r></w:p></w:footnote></w:footnotePr></w:docPr>
<w:body>
<wx:sect>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><aml:annotation aml:id="0" w:type="Word.Bookmark.Start" w:name="plan"/><aml:annotation aml:id="1" w:type="Word.Comment.Start"/><w:r><w:t>Plan</w:t></w:r><aml:annotation aml:id="1" w:type="Word.Comment.End"/><w:r><aml:annotation aml:id="1" aml:author="Bo" w:initials="B" w:type="Word.Comment"><aml:content><w:p><w:r><w:t>Too short?</w:t></w:r></w:p></aml:content></aml:annotation></w:r><aml:annotation aml:id="0" w:type="Word.Bookmark.End"/></w:p>
<w:p><w:pPr><w:jc w:val="center"/><w:ind w:first-line="360"/><w:spacing w:line="360" w:line-rule="auto"/></w:pPr>
<w:r><w:t>Plain </w:t></w:r>
<w:r><w:rPr><w:rFonts w:ascii="Courier New" w:h-ansi="Courier New"/><w:b/><w:i/><w:u w:val="single"/><w:sz w:val="28"/><w:color w:val="FF0000"/></w:rPr><w:t>loud</w:t></w:r>
<w:r><w:tab/><w:t>then</w:t><w:br/><w:t>more</w:t></w:r>
<w:r><w:footnote><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t>Sown in May.</w:t></w:r></w:p></w:footnote></w:r>
</w:p>
<w:p><w:hlink w:dest="https://example.org/beans" w:screenTip="Beans"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>bean guide</w:t></w:r></w:hlink></w:p>
<w:p><w:pPr><w:listPr><w:ilvl w:val="0"/><w:ilfo w:val="2"/><wx:t wx:val="a)"/></w:listPr></w:pPr><w:r><w:t>Water the beans</w:t></w:r><aml:annotation aml:id="7" aml:author="Ada" aml:createdate="2004-05-01T10:00:00Z" w:type="Word.Insertion"><aml:content><w:r><w:t> daily</w:t></w:r></aml:content></aml:annotation></w:p>
<w:tbl>
<w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>A1</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B1</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:vmerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr><w:p><w:r><w:t>B2</w:t></w:r></w:p></w:tc></w:tr>
</w:tbl>
<w:p><w:r><w:pict><w:binData w:name="wordml://02000001.png">{png}</w:binData><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:36pt;height:18pt"><v:imagedata src="wordml://02000001.png" o:title="dot"/></v:shape></w:pict></w:r></w:p>
<w:sectPr>
<w:hdr w:type="odd"><w:p><w:r><w:t>Garden header</w:t></w:r></w:p></w:hdr>
<w:pgSz w:w="12240" w:h="15840"/><w:pgMar w:top="1440" w:right="1800" w:bottom="1440" w:left="1800" w:header="720" w:footer="720" w:gutter="0"/>
</w:sectPr>
</wx:sect>
</w:body>
</w:wordDocument>"##
)
}
#[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#"<pkg:package xmlns:pkg="http://schemas.microsoft.com/office/2006/xmlPackage">"#;
let w2003 = r#"<w:wordDocument xmlns:w="http://schemas.microsoft.com/office/word/2003/wordml">"#;
// Other XML (and non-XML) is refused with the Word XML error.
for other in ["<html><body>hi</body></html>", "", "not xml at all", "<package/>"] {
assert_eq!(read_xml(other.as_bytes()), Err(DocxError::NotWordXml), "{other:?}");
}
// Broken packages: errors.
for bad in [
flat.to_string(),
format!("{flat}</pkg:package>"),
format!("{flat}<pkg:part pkg:name=\"/word/document.xml\"><pkg:binaryData>!!!not base64</pkg:binaryData></pkg:part></pkg:package>"),
format!("{flat}<pkg:part pkg:name=\"/word/document.xml\"><pkg:xmlData><w:document></pkg:xmlData></pkg:part></pkg:package>"),
] {
assert!(read_xml(bad.as_bytes()).is_err(), "{bad}");
}
// Lenient leftovers never panic.
for odd in [
format!("{flat}<pkg:part/><pkg:part pkg:name=\"\"><pkg:xmlData/></pkg:part><x/></pkg:package>"),
format!(
"{w2003}<w:body><w:p><w:r><w:footnote w:type=\"separator\"/><w:pict><w:binData w:name=\"a\">%%%</w:binData><v:imagedata src=\"a\"/></w:pict></w:r></w:p></w:body></w:wordDocument>"
),
format!(
"{w2003}<w:lists><w:list/><w:listDef/></w:lists><w:body><aml:annotation xmlns:aml=\"http://schemas.microsoft.com/aml/2001/core\" aml:id=\"x\" w:type=\"Word.Comment\"/><w:sectPr><w:hdr/><w:ftr w:type=\"first\"/></w:sectPr></w:body></w:wordDocument>"
),
format!("{w2003}{}", "<w:p>".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!("<pkg:part pkg:name=\"/p{i}.bin\"><pkg:binaryData>AA==</pkg:binaryData></pkg:part>"));
}
many.push_str("</pkg:package>");
assert!(matches!(read_xml(many.as_bytes()), Err(DocxError::Limit(_))));
}

View File

@ -70,11 +70,21 @@ pub fn specs() -> Vec<CommandSpec> {
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}"#),

View File

@ -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;
}

View File

@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Option<String>, 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),

View File

@ -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());
}

View File

@ -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();
}

View File

@ -43,6 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
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());
}

View File

@ -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);
}

View File

@ -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();

View File

@ -7,6 +7,8 @@ pub fn open(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
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<Result<Vec<u8>, 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),

View File

@ -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::<Vec<_>>()
};
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"<rss><channel/></rss>").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());
}

View File

@ -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<LocalName>,
/// 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<Found> {
read_at(offset.into(), len as usize)
};
let name = table(b"name")?;
// Variable fonts: the named instances.
let mut tables: Vec<([u8; 4], Vec<u8>)> = 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<u8>)> = 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<LocalName>,
/// 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<Found> {
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<Found> = Vec::new();
for ni in f.named_instances().iter() {
@ -681,13 +694,13 @@ fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec<Found> {
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<String> {
let mut v: Vec<String> = 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(),
});
}
}
}

View File

@ -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<u8> {
let font = skrifa::FontRef::new(data).unwrap();
let tables: Vec<([u8; 4], Vec<u8>)> = 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);
}

View File

@ -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;
}

View File

@ -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<ResolvedChar> { Arc::new(doc.styles.resolve_char_in(para_style, table_chr, c)) };
let levels = bidi_levels(&p.text, rp.bidi);
let mut b =

View File

@ -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<String> = (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<usize> {
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<String> = 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 &para.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 });
}
}

View File

@ -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"),

View File

@ -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<String, Value> {
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<String, Value> {
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::<f64>() {
props.insert("size".into(), json!(s));
}
@ -1361,10 +1386,26 @@ fn font_props(d: &Dialog) -> serde_json::Map<String, Value> {
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<String, Value> = font_props(d).into_iter().filter(|(k, v)| start.get(k) != Some(v)).collect();
let mut changed: serde_json::Map<String, Value> = 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::<f32>().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");

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -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 ワードアート

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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: Шрифт латинского текста:

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -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:

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -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 艺术字

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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 文字藝術師

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -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

View File

@ -20,6 +20,8 @@ pub struct Previews {
/// Lowercase English family → its name in that language (simsun → 宋体), and back.
local_names: HashMap<String, String>,
english_names: HashMap<String, String>,
/// The Chinese, Japanese and Korean fonts of the font list (the Asian text font box).
east_asian: Option<Vec<String>>,
styles_hash: (u64, u64),
/// Previews rendered this frame (rendering is spread over frames).
budget: std::cell::Cell<u32>,
@ -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<String> {
self.families();
if self.east_asian.is_none() {
let mut f: Vec<String> = 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 {

View File

@ -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 `</head>` 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) |

View File

@ -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 |

View File

@ -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) |

View File

@ -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) |

View File

@ -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 |