Print: preserve main updates without progress-log conflicts
This commit is contained in:
commit
9644e646a6
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", &["*"])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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('/');
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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("")),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 & 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(_))));
|
||||
}
|
||||
|
|
@ -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}"#),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 ¶.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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue